
# 개발 가이드

이 가이드는 병원 홈페이지, 캠페인 페이지, 외부 플랫폼에서 KOS를 연동하는 개발자를 위한 시작점입니다. 먼저 **KOS Connect**와 **KOS OpenAPI** 중 어떤 방식을 선택해야 하는지 결정하고, 이후 시술 메뉴판과 예약 기능 튜토리얼을 순서대로 따라가세요.

Stripe와 같은 결제/예약 연동 문서가 보통 `연동 방식 선택 → 빠른 시작 → 서버 구현 → 클라이언트 구현 → 테스트/운영 체크리스트` 흐름을 제공하듯, KOS 연동도 처음부터 API 목록을 모두 읽기보다 구현하려는 사용자 흐름을 기준으로 필요한 문서만 읽는 편이 안전합니다.

## 먼저 연동 방식을 고르기

| 구현하려는 일 | 권장 방식 | 이유 |
| --- | --- | --- |
| 병원 홈페이지에 예약 버튼이나 플로팅 위젯을 붙이고 싶다 | KOS Connect | KOS가 제공하는 예약 화면, 장바구니, 마이페이지를 그대로 사용할 수 있습니다. |
| LINE, SNS, QR 코드에서 바로 예약 링크를 열고 싶다 | KOS Connect | 독립 웹사이트나 LINE 진입점으로 예약 여정을 제공할 수 있습니다. |
| 자체 시술 목록, 검색, 상세 페이지를 만들고 싶다 | KOS OpenAPI | 카테고리, 상품, 옵션, 가격, 이미지, 다국어 데이터를 직접 조회해 화면을 구성합니다. |
| 자체 예약 화면과 서버에서 슬롯 조회/예약 생성을 처리하고 싶다 | KOS OpenAPI | 예약 그룹, 예약 슬롯, 내원객 정보, 예약 생성 요청을 직접 조합해야 합니다. |
| 자체 화면은 만들지만 예약 완료 화면은 KOS 위젯으로 넘기고 싶다 | OpenAPI + Connect | 상품 노출은 OpenAPI로 만들고, 선택한 옵션을 Connect 장바구니에 담는 방식이 가능합니다. |

:::warning{title="appId와 API Key는 다릅니다"}
KOS Connect의 `appId`는 위젯이나 독립 웹사이트를 식별하는 값입니다. KOS OpenAPI의 `API Key`나 인증 토큰으로 사용할 수 없습니다. 반대로 OpenAPI API Key가 있다고 해서 Connect 위젯을 사용할 수 있는 것도 아닙니다.
:::

## 전체 구현 흐름

1. **권한과 식별자 확인**

   Connect를 쓰면 `appId`가 필요합니다. OpenAPI를 쓰면 병원 또는 파트너 개발팀에 발급된 API Key와 API 접근 권한이 필요합니다.

2. **서버와 클라이언트 책임 분리**

   OpenAPI의 API Key와 인증 토큰 교환은 반드시 서버에서 처리하세요. 브라우저, 모바일 웹뷰, 정적 HTML에 API Key를 넣지 마세요. Connect SDK는 반대로 브라우저에서 동작하므로 Next.js Server Component나 SSR 실행 중에 호출하면 안 됩니다.

3. **시술 데이터 표시**

   카테고리와 상품 목록을 조회하고, `targetCountryCode`, `translsMap`, `displayPeriod`, `offeringPeriod`를 반영해 화면을 구성합니다.

4. **예약 진입**

   Connect를 쓰면 `KOSConnect.open()`, `KOSConnect.changeTab()`, `KOSConnect.addToCart()` 같은 명령으로 예약 여정에 진입합니다. OpenAPI를 쓰면 예약 그룹과 슬롯을 조회한 뒤 예약 생성 API를 호출합니다.

5. **운영 전 점검**

   개발/운영 appId 또는 API Key가 분리되어 있는지, 병원별 노출 설정이 준비되어 있는지, 다국어와 타겟 국가가 기대대로 표시되는지, 예약 가능 기간과 노출 기간이 UI에서 헷갈리지 않는지 확인합니다.

## 공통 서버 패턴

OpenAPI를 직접 호출하는 경우, 서버에 API 클라이언트를 하나 두고 토큰 교환, 인증 헤더, 재시도, 스키마 검증을 모아두세요.

```ts
const KOS_OPEN_API_BASE_URL = process.env.KOS_OPEN_API_BASE_URL!;
const KOS_OPEN_API_KEY = process.env.KOS_OPEN_API_KEY!;

let cachedToken: string | null = null;

async function getAuthToken(forceRefresh = false) {
  if (cachedToken && !forceRefresh) return cachedToken;

  const response = await fetch(
    `${KOS_OPEN_API_BASE_URL}/open/authorization/commands/exchange-token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ key: KOS_OPEN_API_KEY }),
    },
  );

  if (!response.ok) {
    throw new Error(`KOS token exchange failed: ${response.status}`);
  }

  const data = await response.json();
  cachedToken = data.token;
  return cachedToken;
}

export async function kosApiFetch(path: string, body: unknown) {
  let token = await getAuthToken();
  let response = await fetch(`${KOS_OPEN_API_BASE_URL}${path}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: token,
    },
    body: JSON.stringify(body),
  });

  if (response.status === 401) {
    token = await getAuthToken(true);
    response = await fetch(`${KOS_OPEN_API_BASE_URL}${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: token,
      },
      body: JSON.stringify(body),
    });
  }

  if (!response.ok) {
    throw new Error(`KOS API error: ${response.status} ${await response.text()}`);
  }

  return response.json();
}
```

:::tip{title="운영 코드에서는 스키마 검증을 추가하세요"}
튜토리얼 코드는 흐름을 보여주기 위해 최소화되어 있습니다. 운영 코드에서는 Zod 같은 런타임 스키마 검증, 로깅, 타임아웃, 오류 매핑을 추가하는 것을 권장합니다.
:::

## 헷갈리기 쉬운 개념

### `targetCountryCode`는 UI 언어가 아닙니다

`targetCountryCode`는 상품이 어느 국가 타겟으로 노출되는지 필터링하는 값입니다. 형식은 ISO 3166-1 alpha-2이고, 그 외 국가는 `ETC`를 사용합니다.

예를 들어 영어 UI라도 한국 방문객용 상품을 보여줘야 하면 `targetCountryCode`는 `KR`일 수 있습니다. 반대로 한국어 UI라도 해외 타겟 상품만 보여주는 화면이라면 `ETC`나 다른 국가 코드가 필요할 수 있습니다. 언어와 타겟 국가는 별도 의사결정으로 관리하세요.

### `translsMap`은 원문 필드의 대체값입니다

상품, 옵션, 카테고리에는 `title`, `description`, `caution`, 이미지 URL 등의 번역이 `translsMap`에 들어올 수 있습니다. 기본 원문 필드와 번역 필드가 모두 있을 수 있으므로, 항상 fallback 규칙을 정하세요.

```ts
function translated(
  translsMap: Record<string, { translation?: Record<string, string> }> | null | undefined,
  key: string,
  field: string,
  fallback: string,
) {
  if (!key) return fallback;
  return translsMap?.[field]?.translation?.[key] ?? fallback;
}
```

### `displayPeriod`와 `offeringPeriod`는 다릅니다

| 필드 | 의미 | UI에서 보통 하는 일 |
| --- | --- | --- |
| `displayPeriod` | 상품 노출 기간입니다. 미설정이면 상시 노출로 봅니다. | 목록이나 상세 페이지에 보여줄지 결정합니다. |
| `offeringPeriod` | 상품 예약 및 수납 가능 기간입니다. 미설정이면 상시 제공으로 봅니다. | 예약 버튼, 장바구니 담기, 결제/수납 진입 가능 여부를 결정합니다. |

상품이 아직 노출 기간 안이라면 목록에서 숨기는 편이 자연스럽습니다. 상품은 보이지만 제공 기간이 끝났다면 상세 설명은 보여주되 예약 버튼을 비활성화하거나 “예약 가능 기간이 아닙니다”처럼 명확한 상태를 표시하세요.

### 이벤트/프로모션은 상품의 `isEvent`로 다룹니다

프로모션 전용 API 중 일부는 deprecated입니다. 새 구현에서는 상품 목록 조회 시 `isEvent: true`로 이벤트 상품을 가져오는 흐름을 우선 검토하세요.

### 시간은 UTC로 주고받고, 화면에서만 현지 시간으로 바꾸세요

예약 슬롯과 상품 기간 필드는 `startDateTimeUtc`, `endDateTimeUtc`처럼 UTC ISO 8601 문자열을 사용합니다. API 요청에는 UTC를 사용하고, 사용자 화면에서는 병원 또는 사용자 기준 시간대로 변환해 보여주세요.

## 문서 읽는 순서

| 목적 | 먼저 읽을 문서 |
| --- | --- |
| 연동 방식 결정 | 이 페이지 |
| 시술 목록/상세/검색 구현 | [시술 메뉴판 개발하기](/docs/guide/procedure-menu/start-procedure-menu) |
| 자체 예약 슬롯/예약 생성 구현 | [예약 기능 개발하기](/docs/guide/reservation/start-reservation-tutorial) |
| Connect 위젯 설치 | [병원 홈페이지에 위젯 추가하기](/docs/connect/getting-started/widget-integration) |
| Connect 명령어 확인 | [KOS Connect API](/docs/connect/api) |
| OpenAPI 상세 스키마 확인 | [OpenAPI 문서](/docs/openapi) |

## 운영 전 체크리스트

- Connect `appId`와 OpenAPI `API Key`를 혼동하지 않았나요?
- OpenAPI API Key와 인증 토큰이 브라우저에 노출되지 않나요?
- 개발 환경과 운영 환경의 base URL, appId, API Key가 분리되어 있나요?
- 병원별 상품 노출 설정, 카테고리 `opened`, 상품 `deleted`, `isEvent` 필터를 확인했나요?
- 언어 코드와 `targetCountryCode`를 같은 값처럼 취급하지 않았나요?
- `translsMap`이 비어 있을 때 원문 필드 fallback이 동작하나요?
- `displayPeriod`와 `offeringPeriod`를 UI에서 서로 다른 상태로 표현하나요?
- 예약 슬롯 조회 범위와 예약 생성 시간이 UTC ISO 8601 형식인가요?
- 슬롯 선택 후 예약 생성 직전에 정원 초과나 중복 예약 오류를 사용자에게 처리하나요?
- 문서에 없는 운영 정책, rate limit, SLA, production 승인 조건을 임의로 가정하지 않았나요?
