API 스키마 한 벌로 OpenAPI 3.1·gRPC Protobuf·TypeScript 클라이언트를 동시에 뽑아내기 — TypeSpec 1.0 실전 가이드
작년에 직접 겪은 일입니다. 결제팀이 /payments/{id} 응답에서 settled_at 필드를 completedAt으로 이름을 바꿨습니다. JIRA 티켓도 있었고, 코드 리뷰도 통과했고, 사내 Slack에 공지도 됐습니다. 그런데 주문팀의 BFF는 여전히 settled_at을 읽고 있었고, 두 팀이 각자 다른 API 스펙 문서를 "최신 버전"이라고 부르고 있었습니다. CI는 멀쩡히 초록불이었지만 월요일 아침 프로덕션에서 undefined 에러가 조용히 터졌습니다.
이게 **계약 드리프트(Contract Drift)**입니다. 실제 구현 코드와 문서화된 API 계약이 시간이 지나며 서서히 어긋나는 현상으로, 마이크로서비스 환경에서 팀 간 경계가 늘어날수록 발생 빈도도 높아집니다. Swagger 코드젠을 도입해도 "스펙 먼저냐, 코드 먼저냐"에 대한 팀 내 합의가 없으면 드리프트는 막기 어렵습니다.
TypeSpec은 이 문제를 도구 수준에서 구조적으로 차단합니다. 하나의 .tsp 파일이 OpenAPI 3.1 스펙, gRPC Protobuf 정의, TypeScript 클라이언트 코드를 동시에 생성하는 단일 소스 오브 트루스(SSOT)가 됩니다. 2025년 5월 TypeSpec 1.0 GA가 출시되었고, Microsoft Azure는 수백 개의 서비스 API를 TypeSpec으로 관리하며 SDK를 자동 생성하는 파이프라인을 실제로 운영 중입니다.
이 글에서는 TypeSpec의 동작 원리와 핵심 문법, 단일 스키마에서 다중 프로토콜 아티팩트를 뽑아내는 실전 흐름, 그리고 현시점에서 알아야 할 한계를 함께 살펴봅니다.
핵심 개념
TypeSpec이 기존 방식과 무엇이 다른가
TypeSpec은 Microsoft가 개발한 오픈소스 API 정의 언어(IDL)입니다. TypeScript에서 영감받은 문법으로 API 형상을 선언하면, **에미터(Emitter)**라는 플러그인이 OpenAPI YAML, .proto 파일, 클라이언트 코드 등 원하는 아티팩트를 뽑아주는 방식입니다. (이전 이름이 "Cadl"이었기 때문에, Stack Overflow나 예전 블로그 포스트에서 "cadl"로 검색하면 같은 프로젝트의 이전 자료들이 나옵니다. 문법이 달라 혼선이 생길 수 있으니 공식 문서를 기준으로 삼는 것이 안전합니다.)
기존 접근 방식과 비교하면 이렇습니다.
| 방식 | 흐름 | 문제점 |
|---|---|---|
| Code-First | 코드 작성 → 어노테이션 → 스펙 생성 | 구현과 스펙이 비동기화되기 쉬움 |
| OpenAPI-First | 스펙 YAML 작성 → 코드젠 | YAML이 장황하고, 팀마다 스타일 불일치 |
| TypeSpec-First | .tsp 선언 → 에미터 → 모든 아티팩트 |
SSOT가 명확, 멀티 프로토콜 동시 지원 |
에미터 아키텍처 한눈에 보기
TypeSpec이 단일 소스에서 여러 아티팩트를 동시에 생성하는 흐름은 다음과 같습니다.
flowchart TD
A[.tsp 파일 단일 소스] --> B[TypeSpec 컴파일러]
B --> C[OpenAPI 3.1 에미터]
B --> D[Protobuf 에미터]
B --> E[TypeScript 클라이언트 에미터]
C --> F[openapi.yaml]
D --> G[service.proto]
E --> H[TypeScript SDK]
F --> I[Swagger UI / API 게이트웨이]
G --> J[gRPC 서버 스텁]
G --> K[gRPC 클라이언트 스텁]
H --> L[프론트엔드 / BFF]핵심 패키지 현황은 다음과 같습니다.
| 패키지 | 역할 | 상태 |
|---|---|---|
@typespec/compiler |
컴파일러 코어 | Stable 1.x |
@typespec/http |
HTTP/REST 데코레이터 | Stable 1.x |
@typespec/openapi3 |
OpenAPI 3.0/3.1 에미터 | Stable 1.x |
@typespec/protobuf |
Protobuf/gRPC 에미터 | Preview |
@typespec/http-client-js |
TypeScript 클라이언트 에미터 | Preview |
@typespec/json-schema |
JSON Schema 에미터 | Stable |
Preview 에미터에 대해 — 한 번만 짚고 넘어갑니다:
@typespec/protobuf와@typespec/http-client-js는 TypeSpec 1.0 GA 이후에도 Preview 상태입니다. API 안정성이 보장되지 않으며, 버전 업데이트 시 생성 결과물이 달라질 수 있습니다. 이후 섹션에서 이 두 에미터를 다룰 때는 이 전제를 기반으로 읽어주세요. 프로덕션 적용 전 충분한 검증이 필요하고, 패키지 버전 번호는 글 작성 시점과 달라질 수 있으니 npm에서 직접 확인하는 것이 가장 안전합니다.
기본 문법 훑어보기
TypeScript에 익숙하다면 처음 접하는 .tsp 파일도 자연스럽게 읽힙니다.
import "@typespec/http";
using TypeSpec.Http;
// 모델 정의
model Product {
id: string;
name: string;
priceInCents: int32; // 부동소수점 정밀도 문제를 피하기 위해 센트 단위 정수 사용
category: "electronics" | "clothing" | "food";
}
model ProductPage {
items: Product[];
total: int32;
page: int32;
}
// 에러 모델 — @statusCode가 없으면 OpenAPI 스펙에서 응답 코드가 `default`로 매핑됨
@error
model NotFound {
@statusCode statusCode: 404;
code: "NOT_FOUND";
message: string;
}
// 라우트 정의
@route("/products")
interface Products {
@get list(@query page?: int32, @query limit?: int32): ProductPage;
@get read(@path id: string): Product | NotFound;
@post create(@body product: Omit<Product, "id">): Product;
@put update(@path id: string, @body product: Product): Product | NotFound;
@delete remove(@path id: string): void | NotFound;
}@route, @get, @post, @body, @path, @query 같은 데코레이터가 HTTP 의미를 부여합니다. @error와 @statusCode를 함께 선언해야 OpenAPI 에미터가 404 상태 코드를 올바르게 매핑합니다. 유니온 타입이나 Omit<> 같은 유틸리티 타입도 TypeScript와 동일하게 씁니다.
tspconfig.yaml로 에미터 한 번에 실행
# tspconfig.yaml
emit:
- "@typespec/openapi3"
- "@typespec/protobuf"
- "@typespec/http-client-js"
options:
"@typespec/openapi3":
output-file: "dist/openapi.yaml"
openapi-versions:
- "3.1.0"
"@typespec/protobuf":
output-dir: "dist/proto"
"@typespec/http-client-js":
output-dir: "dist/client"이 파일 하나에 에미터를 나열해두면 tsp compile . 한 줄로 모든 아티팩트가 동시에 생성됩니다.
빠른 시작
실전 코드로 넘어가기 전에, 로컬 환경을 먼저 구성합니다.
# VS Code TypeSpec 확장을 설치하면 문법 강조·자동완성 지원
# marketplace.visualstudio.com 에서 "TypeSpec" 검색
# 새 TypeSpec 프로젝트 초기화
npm init -y
npm install -D @typespec/compiler @typespec/http @typespec/openapi3
# 빈 프로젝트 뼈대 생성 (tspconfig.yaml, main.tsp 등 자동 생성)
npx tsp init
# 컴파일 실행 — dist/ 아래에 아티팩트 생성
npx tsp compile .기존 REST API 하나를 TypeSpec으로 옮겨보고, 에미터 출력물이 현재 스펙과 일치하는지 비교해보는 것이 가장 빠르게 감을 잡는 방법입니다.
실전 적용
시나리오: 주문 서비스 마이크로서비스
OrderService가 있고, 외부 REST 클라이언트(모바일 앱, 파트너 API)와 내부 gRPC 서비스(결제, 재고)를 동시에 지원해야 하는 상황입니다.
먼저 공유 모델을 정의합니다. REST와 gRPC 양쪽에서 동일하게 사용할 모델을 하나의 파일에 선언하고, 반복되는 Omit 표현은 CreateOrderRequest로 추출합니다.
// models/order.tsp
import "@typespec/http";
import "@typespec/protobuf";
using TypeSpec.Http;
using TypeSpec.Protobuf;
@package
namespace OrderService;
enum OrderStatus {
Pending,
Fulfilled,
Cancelled,
}
model Order {
@field(1) id: string;
@field(2) customerId: string;
@field(3) amountInCents: int32; // 센트 단위 정수, 부동소수점 정밀도 회피
@field(4) status: OrderStatus;
@field(5) createdAt: utcDateTime;
}
@error
model OrderNotFound {
@statusCode statusCode: 404;
code: "ORDER_NOT_FOUND";
orderId: string;
}
// Omit 반복을 피하기 위해 생성 요청 모델을 명시적으로 추출
model CreateOrderRequest {
customerId: string;
amountInCents: int32;
}REST 인터페이스를 별도 파일에 정의합니다. HTTP 에미터는 @route와 @service 데코레이터가 붙은 인터페이스를 처리해 openapi.yaml을 생성합니다.
// services/orders-rest.tsp
import "../models/order.tsp";
using TypeSpec.Http;
using OrderService;
@service
@route("/orders")
interface Orders {
@get list(@query status?: OrderStatus): Order[];
@get read(@path id: string): Order | OrderNotFound;
@post create(@body order: CreateOrderRequest): Order;
}gRPC 서비스도 별도 파일로 분리합니다. Protobuf 에미터는 @package 네임스페이스와 @field 데코레이터를 기준으로 .proto 파일을 생성합니다.
// services/orders-grpc.tsp
import "../models/order.tsp";
using TypeSpec.Protobuf;
using OrderService;
@service
interface OrderGrpcService {
getOrder(req: { id: string }): Order;
listOrders(req: { status?: OrderStatus }): { orders: Order[] };
createOrder(req: CreateOrderRequest): Order;
}두 에미터가 서로 다른 데코레이터 체계로 동작하기 때문에 REST와 gRPC 인터페이스를 같은 소스에서 공존시킬 수 있습니다. 다만 Protobuf 에미터가 Preview 상태인 만큼, tsp compile . 후 생성된 .proto 파일이 의도한 대로 나왔는지 반드시 직접 확인해보세요.
CI 파이프라인에 통합하기
드리프트를 구조적으로 막으려면 스펙 생성을 CI에 묶어두는 것이 핵심입니다. 파이프라인에서 사용하는 두 가지 보조 도구를 먼저 짚고 넘어갑니다.
- Spectral: OpenAPI 스펙에 대한 린팅 도구입니다. 네이밍 규칙, 필수 필드 누락, 보안 스키마 같은 커스텀 규칙을 정의해 스펙 품질을 자동으로 검사합니다.
- oasdiff: 두 OpenAPI 스펙을 비교해 브레이킹 체인지(필드 삭제, 타입 변경 등)를 탐지합니다.
main브랜치 스펙과 PR 스펙을 비교해, 의도치 않은 하위 호환성 위반을 PR 단계에서 차단합니다.
# .github/workflows/typespec.yml
name: TypeSpec Compile & Lint
on:
push:
paths:
- "api/**/*.tsp"
- "tspconfig.yaml"
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Compile TypeSpec
run: npx tsp compile .
# Spectral: OpenAPI 스펙 품질 린팅
- name: Lint OpenAPI spec
run: npx spectral lint dist/openapi.yaml
# oasdiff: main 브랜치 대비 브레이킹 체인지 탐지
- name: Breaking change detection
run: |
npx oasdiff breaking \
https://raw.githubusercontent.com/org/repo/main/dist/openapi.yaml \
dist/openapi.yaml \
--fail-on ERR
# 선택: 생성 아티팩트를 저장소에 자동 커밋
# 대안으로 GitHub Actions 아티팩트 업로드 방식도 유효합니다
- name: Commit generated artifacts
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: regenerate API artifacts from TypeSpec"
file_pattern: "dist/**"아래는 이 파이프라인이 드리프트를 막는 전체 흐름입니다.
생성 아티팩트 커밋 전략 선택: dist/openapi.yaml 같은 생성물을 저장소에 커밋하면 PR에서 스펙 변경 diff를 직접 검토할 수 있고 스펙 최신성 보장이 쉬워집니다. 단, 파일 크기가 크거나 여러 팀이 동시에 PR을 올리는 환경에서는 merge 충돌이 잦아지고 diff 노이즈가 생기는 단점이 있습니다. 이 경우 GitHub Actions 아티팩트로 업로드하거나 외부 스펙 레지스트리에 배포하는 방식도 충분히 유효한 대안입니다. 어느 전략이든, 결정 이후에는 팀 전체가 일관되게 따르는 것이 중요합니다.
TypeScript 클라이언트 사용 흐름
@typespec/http-client-js(Preview)가 생성한 클라이언트를 프론트엔드나 BFF에서 이렇게 활용할 수 있습니다.
// 에미터가 자동 생성한 파일을 임포트
// 실제 경로와 클래스명은 에미터 버전마다 달라질 수 있으니 생성 결과물을 직접 확인하세요
import { OrdersClient } from "../dist/client";
const client = new OrdersClient({ endpoint: "https://api.example.com" });
// 타입이 완전히 추론됩니다
const orders = await client.orders.list({ status: "Pending" });
orders.forEach((order) => {
console.log(order.amountInCents); // number 타입으로 추론
});백엔드에서 Order.amountInCents 타입을 int32에서 string으로 바꾸면, 이 클라이언트 코드는 다음 컴파일 타임에 바로 에러가 납니다. 런타임이 아니라 컴파일 타임입니다. .tsp 파일이 SSOT 역할을 하기 때문에 이 보장이 성립합니다.
팀 간 공유 모델 라이브러리
마이크로서비스가 여러 개라면 공통 모델을 npm 패키지로 분리해서 공유할 수 있습니다.
// packages/common-types/main.tsp
namespace Common;
model PaginationParams {
@query page?: int32 = 1;
@query limit?: int32 = 20;
}
model PagedResponse<T> {
items: T[];
total: int32;
page: int32;
hasNext: boolean;
}
@error
model ApiError {
@statusCode statusCode: int32;
code: string;
message: string;
}// services/order-service/main.tsp
import "@company/common-types";
using Common;
model OrderPage is PagedResponse<Order>;
@route("/orders")
interface Orders {
@get list(...PaginationParams): OrderPage;
}팀 거버넌스를 공통 라이브러리 수준에서 강제할 수 있어서, 서비스마다 에러 포맷이 달라지는 문제를 구조적으로 막을 수 있습니다.
도입 판단과 장단점
먼저: 도입이 맞는 팀인가
장단점을 살펴보기 전에, 우리 팀 상황에 TypeSpec이 맞는 선택인지 먼저 확인하는 것이 실용적입니다.
flowchart TD
A[API 계약 관리 문제 인식] --> B{현재 팀 상황은?}
B -->|OpenAPI YAML 수동 관리| C[전환 효과 큼]
B -->|Code-First 어노테이션 방식| D{gRPC도 지원 필요?}
B -->|스펙 자동화는 이미 됨| E{gRPC 신규 도입 예정?}
D -->|예| F[TypeSpec 적극 검토]
D -->|아니오| G{TypeScript 팀?}
E -->|예| F
E -->|아니오| H[현행 유지도 합리적 선택]
G -->|예| I[경량 도입 고려]
G -->|아니오| J[OpenAPI Generator 유지도 무방]
C --> K[파이럿 서비스 하나로 시작]
F --> K
I --> K
K --> L[tsp compile + CI 연동]
L --> M[팀 전체 롤아웃]"이미 스펙 자동화가 잘 돼 있다"는 팀도, gRPC를 새로 도입하거나 멀티 프로토콜 지원이 필요해진다면 TypeSpec이 충분히 유효합니다. 반대로, REST만 쓰는 소규모 팀에서 기존 OpenAPI 파이프라인이 잘 돌아가고 있다면 전환 비용 대비 효익이 크지 않을 수 있습니다.
장단점 한눈에 보기
| 항목 | 평가 |
|---|---|
| 단일 SSOT | 계약 드리프트를 구조적으로 차단. 현시점 TypeSpec 최대 강점 |
| TypeScript 친화 문법 | TS 개발자라면 학습 곡선 낮음. 유니온·제네릭·유틸리티 타입 그대로 사용 |
| 재사용 가능한 라이브러리 | 공통 모델을 npm 패키지화해 팀 간 거버넌스 일관성 확보 |
| CI 통합 | tsp compile . 한 줄. Spectral·oasdiff와 조합하면 드리프트 감지 완결 |
| OpenAPI 에미터 안정성 | 1.x Stable. 기존 Swagger UI·Redoc·Postman·APIM과 그대로 연동 |
| Protobuf 에미터 성숙도 | Preview 상태. 복잡한 스트리밍 패턴 등 제약 있음 — 위 경고 박스 참조 |
| 클라이언트 코드 생성 | .NET·Java·Python·JS 모두 Preview — 위 경고 박스 참조 |
| 생태계 규모 | OpenAPI 대비 서드파티 플러그인·커뮤니티 자료 적음 |
| 벤더 종속성 | Microsoft 주도 언어. 도구 자율성 중시 팀은 고려 필요 |
| 전환 비용 | 기존 Code-First·수동 YAML 팀은 초기 학습 + 파이프라인 재설정 필요 |
실무에서 자주 하는 실수들
1. Preview 에미터를 즉시 프로덕션에 올리는 실수
Protobuf 에미터와 클라이언트 에미터가 Preview라는 사실을 간과하고 바로 gRPC 서비스에 연결하는 경우가 있습니다. 버전 업데이트 시 생성 결과물이 달라질 수 있으니, 충분히 검증하거나 .proto를 에미터 출력물이 아닌 레퍼런스로만 쓰는 전략도 있습니다.
2. 생성 아티팩트를 .gitignore에 무조건 넣는 실수
생성물을 무시 목록에 넣어버리면 CI에서 스펙 최신성 검증이 불가능해집니다. 커밋 전략과 아티팩트 업로드 전략 중 어느 쪽을 선택하든, 결정 이후에는 팀 전체가 일관되게 따르는 것이 중요합니다.
3. 네임스페이스 없이 모델 이름 충돌 방치
여러 .tsp 파일에서 namespace를 선언하지 않으면 동일한 모델 이름이 충돌합니다. 파일을 늘리기 전에 네임스페이스 전략을 먼저 잡아두는 것이 좋습니다.
4. @typespec/* 패키지 버전을 제각각 관리하는 실수
package.json에서 @typespec/* 패키지들의 버전을 모두 동일하게 맞추고, 마이너 업데이트 시 릴리즈 노트를 확인하는 습관이 필요합니다. 1.0 이전엔 "Cadl" 이름으로 문법이 여러 번 바뀐 전례가 있습니다.
마치며
계약 드리프트 문제를 해결하는 핵심은 "단일 소스를 강제하는 구조"입니다. REST만 쓰는 팀이라면 기존 OpenAPI 파이프라인으로도 충분할 수 있지만, gRPC와 REST를 함께 지원하거나 여러 팀이 소비하는 공통 계약을 관리해야 하는 상황이라면 TypeSpec이 현시점에서 가장 구조적인 해법입니다.
정리하면:
- 단일
.tsp파일이 OpenAPI, Protobuf, 클라이언트 코드의 SSOT가 됩니다 tsp compile .한 줄을 CI에 묶으면 스펙 최신성이 자동으로 보장됩니다- OpenAPI 에미터는 Stable, Protobuf·클라이언트 에미터는 Preview — 이 차이를 인식하고 도입 범위를 조절하면 됩니다
지금 시작해보시려면 이 순서가 자연스럽습니다.
tsp init으로 빈 프로젝트 생성 후 기존 REST API 하나를 TypeSpec으로 옮겨보기 — OpenAPI 에미터 출력물이 현재 스펙과 일치하는지 비교해보세요.tsp compile .을 GitHub Actions에 연결 — Spectral 린팅과 oasdiff 브레이킹 체인지 탐지를 붙이면 드리프트 감지 파이프라인이 완성됩니다.- 파이럿 서비스 하나에서 검증 후 팀 전체로 확장 — 처음부터 전사 전환보다는, 신규 서비스 하나에서 흐름을 익힌 다음 점진적으로 적용하는 것이 현실적입니다.
참고 자료
- TypeSpec 1.0 GA: API First, Made Practical — 공식 블로그
- TypeSpec 1.0-RC 릴리즈 노트
- TypeSpec 공식 문서
- Overview of TypeSpec — Microsoft Learn
- TypeSpec Protobuf 에미터 가이드
- TypeSpec OpenAPI v3 에미터 레퍼런스
- TypeSpec Configuration (tspconfig.yaml)
- GitHub — microsoft/typespec
- A Technical Journey into API Design-First — Microsoft ISE Developer Blog
- API-first MCP servers with TypeSpec — 공식 블로그
- OpenAPI vs. TypeSpec: Which To Use? — Nordic APIs
- TypeSpec: A Practical TypeScript-Inspired API Definition Language — InfoQ
- How to Use TypeSpec for Documenting and Modeling APIs — freeCodeCamp
- How to create OpenAPI and SDKs with TypeSpec — Speakeasy
- What is API drift and how do you prevent it? — Wiz
- Accelerating your OpenAPI Spec Generation with TypeSpec — Bump.sh
- @typespec/openapi3 — npm
- @typespec/protobuf — npm
- @typespec/http-client-js — npm
- TypeSpec for OpenAPI Developers
- TypeSpec 1.1.0 릴리즈 노트
- Azure TypeSpec Style Guide