OpenAPI 스펙 하나로 9개 언어 SDK 만들기: K-OTP의 Kiota 도입기

K-OTP 팀
2026. 11. 4.

K-OTP API(https://api.k-otp.dev/v1)는 어떤 언어에서든 호출할 수 있지만, 대부분의 팀은 멱등키, 재시도, 오류 정보를 올바르게 다뤄 주는 클라이언트를 원합니다. 이 글에서는 OpenAPI 문서 하나로 9개 언어의 클라이언트를 만들고, 검증하고, 배포하는 방법을 설명합니다. 여기서 다루는 내용은 모두 github.com/k-otp/sdk에 오픈 소스(MIT)로 공개되어 있습니다.

두 갈래, 하나의 스펙

SDK는 두 종류이고, 해결하는 문제가 다릅니다.

직접 작성한 @k-otp/sdk (TypeScript, JavaScript). npm 패키지 하나에 용도별 서브패스가 있습니다. 브라우저에서 pk_ 키로 쓰는 issue/verify 코어 클라이언트, sk_ 키로 모든 공개 작업을 호출하는 @k-otp/sdk/server, React, Vue, Svelte용 훅, 헤드리스 UI 컴포넌트(한 줄짜리 <OtpForm /> 또는 조합형 파츠), 단위 테스트용 인메모리 테스트 모드 API인 @k-otp/sdk/testing입니다. 이 패키지의 가치는 사용자 경험에 있습니다. 재발송 대기 시간, 발송 시도마다 하나의 멱등키, 국내 휴대폰 번호 처리, 붙여넣기를 지원하는 인증번호 입력처럼 생성기가 만들어 주지 않는 부분입니다.

나머지는 Kiota로 생성한 서버 클라이언트. 백엔드에는 UI보다 어떤 언어에서든 정확하게 동작하는 클라이언트가 필요합니다. C#, Java, Kotlin/JVM, PHP, Go, Python, Ruby, Dart, 그리고 @k-otp/sdk/kiota를 통한 TypeScript는 HTTP 계층을 Kiota로 생성하고, 그 위에 직접 작성한 작은 클라이언트를 얹었습니다. 모두 9개 공개 작업(발송, 검증, 상태, 발송 목록과 상세, 크레딧 내역, 잔액, 템플릿 목록과 상세)을 지원하며, JSON을 받고 JSON을 돌려줍니다.

9개 언어를 정확히 세면 이렇습니다. Kotlin/JVM은 별도 패키지가 아니라 Java JAR와 그 API를 그대로 쓰고, TypeScript에는 두 갈래가 모두 있습니다. TypeScript 서버라면 타입이 있는 @k-otp/sdk/server가 여전히 기본 선택이고, @k-otp/sdk/kiota는 여러 언어의 서비스에서 같은 API 형태를 쓰고 싶은 팀을 위한 것입니다.

두 갈래 모두 같은 파일에서 시작합니다. spec/openapi.json에 들여온(vendored) 공개 OpenAPI 문서입니다. 직접 작성한 SDK는 이 문서에서 TypeScript 타입을 생성하고, 타입이나 클라이언트 계약이 스펙과 어긋나면 드리프트 테스트가 CI를 실패시킵니다.

Kiota란, 그리고 왜 골랐나

Kiota는 OpenAPI로 기술된 API의 클라이언트를 생성하는 Microsoft의 명령줄 도구입니다. MIT 라이선스의 오픈 소스이고(github.com/microsoft/kiota), 생성된 코드는 Microsoft와 커뮤니티가 관리하는 언어별 런타임 라이브러리 위에서 동작합니다(2026년 10월 기준).

도입 전에 평가를 했고 그 기록을 저장소(poc/kiota/ADOPTION.md)에 남겼습니다. 결정 이유는 다음과 같습니다.

  • 공식 런타임, 포크 없음. 모든 SDK는 각 언어의 공개 Kiota 런타임을 씁니다. 생성된 파일을 고치지 않고, Kiota나 런타임을 포크하지 않습니다.
  • 재현 가능한 출력. CLI 버전을 고정하면(Kiota 1.35.0) 깨끗한 상태에서 두 번 생성한 코드가 바이트 단위로 같습니다. 생성 코드에 차이가 생기면 반드시 원인이 있습니다.
  • 계약 테스트 통과. 아래의 보정을 적용하면 모든 대상이 공통 HTTP 계약 28개 사례를 모두 통과했습니다.

한계도 분명했습니다. 원본 스펙 그대로 생성한 코드는 배포할 수 없었습니다. Kiota가 오류 응답의 오류 타입을 만들지 못한다고 경고했고, 실제로 원본 클라이언트는 실행 중에 오류 envelope의 필드를 잃었습니다. 고정한 CLI는 Ruby와 Dart를 실험적(experimental), TypeScript를 미리 보기(preview)로 표시하고, Swift 생성기가 없으며, .http 요청 예제는 검사를 통과하지 못했습니다. 이런 결과는 감추지 않고 기록해 둡니다.

오버레이와 래퍼

스펙과 실제로 설치하는 패키지 사이에는 두 계층이 있습니다.

오버레이는 생성할 때만 쓰는 스펙 사본입니다(poc/kiota/overlays/compat.ts). 들여온 스펙은 절대 수정하지 않고, 모든 변경을 JSON pointer와 이유와 함께 저장합니다. 하는 일은 세 가지입니다.

  • 상수들의 oneOf를 일반 enum으로 바꿉니다(허용 값은 그대로입니다).
  • type이 없는 const에 타입을 붙입니다.
  • 모든 오류 응답이 하나의 공통 오류 envelope(defined, code, status, message, data)를 가리키게 해서, 모든 클라이언트가 API 오류를 읽을 수 있게 합니다. 대가로 오류 data의 분기별 타입은 느슨해지며, 이 점도 문서에 남겼습니다.

Ruby와 Dart에는 언어별 힌트가 하나씩 더 있고, Ruby, Dart, TypeScript에는 공식 런타임 위에서 동작하는 작은 호환 계층(각 수십 줄)이 필요합니다.

래퍼는 sdks/<언어>/에 있는, 직접 작성한 작은 클라이언트입니다. K-OTP의 규칙이 여기에 있고, 모든 언어에서 똑같이 동작합니다.

  • sk_ 시크릿 키만 받습니다.
  • 기본 URL은 https://api.k-otp.dev/v1이고 HTTPS여야 합니다(로컬 테스트용 루프백 HTTP는 허용). 리디렉션과 다른 출처로 가는 요청은 거부하므로, 키는 설정한 API로만 전송됩니다.
  • issue는 요청을 보내기 전에 멱등키를 검사하고(앞뒤 공백을 제거한 1~128자 ASCII), 같은 키를 Idempotency-Key 헤더와 본문에 함께 보냅니다.
  • 자동 재시도는 꺼져 있습니다. HTTP 503 이후 issue를 같은 본문과 키로 정확히 한 번 다시 보내도록 직접 켤 수 있습니다. verify는 절대 재시도하지 않습니다.
  • 오류는 HTTP 상태, envelope, 요청 ID, 밀리초 단위 재시도 대기 시간을 담은 하나의 API 오류 타입으로 바뀝니다. 시간 초과나 네트워크 실패는 결과가 "unknown"인 전송 오류입니다. 인증번호가 이미 발송됐을 수 있으니 같은 키로 재시도합니다.

정확성을 지키는 방법

검사는 이 순서로 진행됩니다.

  1. 스펙은 릴리스된 API에서 가져옵니다. bun run sync:openapi가 공개된 OpenAPI 문서를 spec/openapi.json으로 복사합니다. 생성 도중에 내려받지 않습니다.
  2. 스펙을 고정합니다. 파일의 SHA-256을 Kiota 버전과 함께 기록해 두고, 파일이 다르면 생성을 멈춥니다.
  3. 두 번 생성합니다. 대상마다 원본 스펙과 오버레이로 각각, 깨끗한 디렉터리에서 두 번씩 생성하고 코드를 바이트 단위로 비교합니다.
  4. 전부 빌드합니다. 생성된 모든 파일을 각 언어의 실제 도구와 런타임으로 컴파일하거나 로드합니다.
  5. 패키지로 만들어 다른 곳에 설치합니다. SDK를 실제 패키지(NuGet 패키지, JAR, wheel, Composer ZIP, Go 모듈 ZIP, gem, Dart 아카이브, npm tarball)로 만들고, 별도의 소비자 프로젝트가 소스 트리가 아닌 그 패키지를 설치합니다. Kotlin은 설치한 Java JAR를 대상으로 컴파일합니다.
  6. 공통 계약을 실행합니다. 픽스처 파일 하나에 담긴 28개 사례를 실제로 생성된 요청 빌더와 직렬화기로 로컬 mock 서버에 보냅니다. 9개 작업 전체, 휴대폰 번호와 인증번호의 앞자리 0, 400부터 503까지의 오류, Retry-After, 커서 페이지, 위의 재시도 규칙을 확인합니다.
  7. 알려진 한계를 정확히 고정합니다. 오버레이를 적용하지 않은 원본 출력은 아직 일부 사례에 실패합니다. CI는 검토한 그대로 실패하는지 확인하므로, Kiota나 스펙이 바뀌어 그 실패가 달라지면, 하나가 고쳐지는 경우라도, 사람이 검토할 때까지 빌드가 멈춥니다.

이 모든 과정이 GitHub Actions 워크플로 하나(kiota-poc.yml)이며, SDK 변경을 병합하기 전과 릴리스 커밋의 main에서 다시 실행합니다. 마지막 관문은 그 실행에서 나온 9개 대상 전체의 증거를 요구합니다. 빠졌거나 오래된 증거는 통과로 치지 않습니다.

배포

npm. @k-otp/sdk는 Sampo changeset으로 릴리스하고, GitHub Actions에서 npm Trusted Publishing(OIDC)으로 게시합니다. 저장소에 npm 토큰이 없고, 이렇게 게시한 패키지에는 npm provenance 증명이 붙습니다. @k-otp/sdk/kiota는 이 패키지의 일부이므로 버전을 공유합니다.

언어별 패키지. 두 번째 워크플로 release-sdks.yml은 아무것도 빌드하지 않습니다. 성공한 검증 실행의 ID를 받아 그 실행이 같은 커밋의 main에서 돈 해당 워크플로인지 확인하고, 모든 소비자 결과와 산출물 목록, 파일별 SHA-256을 다시 검증합니다. /kiota가 포함된 npm 버전이 이미 게시됐는지도 확인합니다. 그다음에야 패키지, SHA256SUMS 파일, 검증 기록을 담은 server-sdk-vX.Y.Z GitHub Release를 만듭니다. Go는 검증된 모듈 내용만 담은 별도 스냅샷 커밋의 모듈 태그(sdks/go/vX.Y.Z)로 설치합니다. 이미 게시된 릴리스는 덮어쓰지 않습니다.

언어별 설치 명령은 언어별 서버 SDK에, npm 패키지는 SDK에 있습니다.

버전 관리

  • @k-otp/sdk의 major와 minor 버전은 API 계약 버전과 맞춥니다. 지금은 둘 다 2.1입니다.
  • 언어별 서버 SDK는 별도의 0.x 미리 보기 버전입니다. 0.2.0은 API 2.0.0 이상이 필요합니다. 1.0 전까지 이름과 옵션이 바뀔 수 있으니 정확한 버전으로 고정하세요.
  • 제거하기 전에 먼저 지원 중단(deprecated)으로 표시합니다. @k-otp/sdk 2.1.0에서는 API 2.x가 더 이상 받지 않는 옵션을 지원 중단으로 표시했지만, 그대로 컴파일되고 API로도 전송되어 명확한 400 응답을 받습니다. 실제 제거는 다음 major 버전에서만 합니다.

맛보기

직접 작성한 TypeScript 서버 클라이언트에는 타입이 있습니다.

import { createIdempotencyKey, createOtpServerClient } from "@k-otp/sdk/server";

const otp = createOtpServerClient({ apiKey: process.env.K_OTP_SECRET_KEY! });

const idempotencyKey = createIdempotencyKey("login"); // 저장해 두고 재시도할 때 다시 사용
const { issueId } = await otp.issue({
	phoneNumber: "01012345678",
	purpose: "login",
	idempotencyKey,
});
const { verified, reasonCode } = await otp.verify({ issueId, code: "123456" });

Kiota 기반 클라이언트는 API의 필드 이름 그대로 일반 JSON을 받고 돌려줍니다. Python:

import os

from kotp_sdk import KotpClient

async with KotpClient(os.environ["K_OTP_SECRET_KEY"]) as client:
    issued = await client.issue({
        "phoneNumber": "01012345678",
        "purpose": "login",
        "idempotencyKey": idempotency_key,  # 저장해 두고 재시도할 때 다시 사용
    })
    result = await client.verify({"issueId": issued["issueId"], "code": "123456"})
    verified = result["verified"]

Go:

client, err := kotp.New(kotp.Options{APIKey: os.Getenv("K_OTP_SECRET_KEY")})
if err != nil {
	return err
}
input, _ := json.Marshal(map[string]any{
	"phoneNumber":    "01012345678",
	"purpose":        "login",
	"idempotencyKey": idempotencyKey,
})
issued, err := client.Issue(ctx, input, kotp.RequestOptions{})
if err != nil {
	return err // *kotp.APIError 또는 *kotp.TransportError(결과 불명: 같은 키로 재시도)
}
issueID := issued.(map[string]any)["issueId"].(string)
verifyInput, _ := json.Marshal(map[string]any{"issueId": issueID, "code": "123456"})
result, err := client.Verify(ctx, verifyInput, kotp.RequestOptions{})

아직 부족한 점

  • 언어별 SDK는 미리 보기입니다. 아직 NuGet, Maven Central, PyPI, RubyGems, pub.dev, Packagist에 없습니다. 레지스트리마다 게시자 계정과 네임스페이스를 먼저 준비해야 하고, Packagist는 별도의 배포용 저장소도 필요합니다. 그때까지는 GitHub Release에서 설치하고 SHA256SUMS로 확인하세요.
  • 서버용 클라이언트만 있습니다. Android, Kotlin Multiplatform, Flutter, 브라우저는 지원하지 않습니다. 브라우저에서는 @k-otp/sdk를 쓰세요.
  • 테스트한 런타임이 정해져 있습니다. .NET 8, JDK 21, PHP 8.4, Go 1.26, Python 3.13, Ruby 3.3, Dart 3.9, Node.js 20.19, 22, 24입니다.
  • 고정한 Kiota CLI에 Swift 생성기가 없어서 Swift SDK는 없습니다.

레지스트리 패키지나 다른 런타임, 다른 언어가 필요하면 이슈를 열거나 문의해 주세요.

더 읽을거리