API 설계 & 계약 (OpenAPI · Contract Testing · Versioning)
분류: Layer 9 - 아키텍처 & 설계 패턴
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”API 설계와 계약(API Design & Contract)은 서비스 간 인터페이스를 OpenAPI/AsyncAPI 같은 명세를 1차 시민으로 두고, 그 명세를 소비자/제공자 양쪽이 자동으로 검증하는 Spec-First + Contract Testing 워크플로로, REST 자원 모델링·HTTP 의미론·버저닝·에러 표준(RFC 9457 Problem Details, RFC 7807 후속)·하위호환성을 운영 가능한 형태로 묶는 설계 분야다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”서비스 간 통신은 “코드는 통과하는데 통합 환경에서 깨지는” 모드를 가장 자주 만든다. consumer가 가정한 응답 형식과 provider가 보내는 응답 형식이 한 자 어긋나면 production 장애로 직결된다. API 설계와 계약이 코드의 1차 시민이 되어 있지 않으면, 모든 API 변경이 “어디까지 영향이 갈지 모르겠다”는 두려움 위에서 일어난다. Spec-First + Contract Testing 워크플로는 이 두려움을 측정 가능한 안전망 으로 바꾼다 — breaking change가 PR 단계에서 자동으로 잡히고, consumer 기대가 provider에 명시적으로 등록되며, deprecation이 일정대로 진행된다. 시니어 백엔드/플랫폼 엔지니어가 안전하게 API를 진화시키는 능력의 핵심.
선수지식: L1 api-design-basics.md(REST 기초), L1 http-basics.md, clean-architecture.md(Interface Adapter 레이어). 후속: msa-patterns.md, testing-strategy.md.
2.1 REST만으로는 왜 부족했나 — 계약이 등장한 이유
섹션 제목: “2.1 REST만으로는 왜 부족했나 — 계약이 등장한 이유”REST와 HTTP 의미론은 “무엇을 요청했는가”의 공통 언어를 줬지만, “응답 JSON에 totalAmount가 반드시 integer인가”, “status enum에 새 값이 들어오면 consumer가 죽지 않는가”, “404 에러 body가 어떤 필드로 파싱되는가”까지 자동 검증하지는 못한다. RFC 9110은 GET/HEAD/OPTIONS/TRACE를 safe method로, PUT/DELETE와 safe method를 idempotent method로 정의하지만, 이 정의만으로 provider 코드가 OpenAPI schema의 required, enum, format, application/problem+json을 실제로 지키는지는 알 수 없다. Swagger 2.0이 2015년에 OpenAPI Initiative로 넘어가고 OpenAPI 3.1.0이 2021-02-15에 공개된 흐름은 이 공백을 “문서”가 아니라 “도구가 읽는 계약”으로 메우려는 시도다.
이 토픽의 메커니즘은 단순히 REST를 더 예쁘게 문서화하는 것이 아니다. OpenAPI는 HTTP API의 경로·파라미터·응답 스키마를 CI가 읽는 산출물로 만들고, Contract Testing은 그 산출물이 실제 provider 응답과 consumer 기대를 동시에 만족하는지 검증한다. 같은 원리는 Kafka/SQS 이벤트에서는 AsyncAPI, 내부 RPC에서는 Protobuf/gRPC IDL로 옮겨간다. 도메인은 달라도 핵심 공식은 같다: wire format이 팀 간 경계라면, 그 경계는 사람이 읽는 위키가 아니라 빌드가 실패시킬 수 있는 계약이어야 한다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3.1 Spec-First (Design-First) 워크플로
섹션 제목: “3.1 Spec-First (Design-First) 워크플로”전통 방식은 코드를 먼저 짜고 Swagger 같은 도구가 코드에서 spec을 뽑아주는 형태였다. 이 방향이 어색한 이유:
- 코드를 짜기 시작한 순간 이미 구현 디테일이 인터페이스를 오염 시킨다 (ORM 컬럼명이 그대로 응답 필드로 나가는 식)
- consumer 팀이 API를 미리 검토할 수 없다 — 코드가 merge 된 뒤에야 spec이 보임
- breaking change를 코드 PR에서 발견할 도구가 없다
Spec-First는 순서를 뒤집는다:
1. OpenAPI YAML/JSON (또는 AsyncAPI for events, Protobuf for gRPC) 먼저 작성2. consumer 팀이 spec 리뷰 (PR로)3. provider 구현 (spec 준수 검증)4. consumer mock SDK 생성 (spec → SDK)5. CI에 oasdiff 같은 도구로 breaking change 자동 감지핵심은 spec이 코드의 일부 가 아니라 코드보다 먼저 변경되는 1차 시민 이라는 점이다.
이 방식을 쓰지 말아야 할 때도 있다. 단일 팀 내부의 임시 admin API처럼 consumer가 1개이고 배포가 항상 provider와 동시에 나가며, 1~2주 안에 버려질 엔드포인트라면 Code-First가 더 싸다. 반대로 모바일 앱, 외부 파트너, 다른 팀 서비스처럼 consumer가 독립 배포되는 순간 spec이 먼저 바뀌어야 한다. 판단 기준은 “문서를 예쁘게 만들고 싶은가”가 아니라 consumer가 provider 배포와 독립적으로 실패할 수 있는가다.
PR에서의 최소 검증 단위는 lint가 아니라 diff다. 예를 들어 totalAmount: integer를 string으로 바꾸는 PR은 YAML 문법상 정상이라 lint를 통과하지만, consumer 런타임에서는 깨질 수 있다.
oasdiff breaking openapi.base.yaml openapi.pr.yaml# 예상 출력: ERR breaking changes found ... property "totalAmount" type changed from integer to string# 다음 단계: 새 필드 totalAmountText를 optional로 추가하고, 기존 totalAmount는 Sunset 일정 전까지 유지3.2 OpenAPI 스펙 구조
섹션 제목: “3.2 OpenAPI 스펙 구조”openapi: 3.1.0info: title: Order API version: 1.4.0servers: - url: https://api.example.com/v1paths: /orders/{orderId}: get: operationId: getOrder parameters: - name: orderId in: path required: true schema: type: string format: uuid responses: "200": description: 주문 조회 성공 content: application/json: schema: $ref: "#/components/schemas/Order" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited"components: schemas: Order: type: object required: [id, userId, status, totalAmount] properties: id: { type: string, format: uuid } userId: { type: string, format: uuid } status: type: string enum: [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED] totalAmount: { type: integer, minimum: 0 } items: type: array items: { $ref: "#/components/schemas/OrderItem" } responses: NotFound: description: 리소스를 찾을 수 없음 content: application/problem+json: schema: { $ref: "#/components/schemas/ProblemDetails" }핵심 요소:
operationId: 코드 생성/SDK 생성 시 함수명으로 사용. 명확한 동사+명사 규칙 (getOrder,createOrder).$ref로 schema·response 재사용. 중복 제거.required배열에 필수 필드 명시.nullable: true와 다름 — 필수지만 null 허용은 별개 개념.enum으로 status 같은 closed set을 제한 → consumer 코드가 정확한 타입으로 받음.
3.3 자원 모델링 — REST 동사·자원 설계
섹션 제목: “3.3 자원 모델링 — REST 동사·자원 설계”| 원칙 | 좋은 예 | 나쁜 예 |
|---|---|---|
| 자원은 명사(복수형)로 | GET /orders | GET /getOrders |
| 동사는 HTTP method로 | POST /orders (생성) | POST /createOrder |
| 계층 관계는 URI 중첩 | GET /orders/{id}/items | GET /orderItems?orderId=... |
| 액션은 자원의 상태 전이 로 | POST /orders/{id}/cancellation (cancellation 리소스 생성) | POST /orders/{id}/cancel |
| 검색·필터링은 query parameter | GET /orders?status=PENDING&from=2026-05-01 | GET /orders/status/PENDING |
| 비REST한 RPC가 자연스러우면 그건 RPC로 | POST /payments:refund (Google 스타일) | REST 억지로 끼우기 |
HTTP method 의미론:
| Method | 멱등성 | 안전성 | 의미 |
|---|---|---|---|
| GET | O | O | 조회 (사이드 이펙트 없음) |
| HEAD | O | O | 메타데이터만 조회 |
| OPTIONS | O | O | CORS preflight, 허용 메서드 |
| PUT | O | X | 전체 교체 (멱등) |
| DELETE | O | X | 삭제 (멱등 — 두 번 호출해도 같은 상태) |
| PATCH | X(원칙) / O(설계로) | X | 부분 수정 |
| POST | X | X | 생성·비멱등 액션 |
멱등성 은 retry safety의 기반이다. consumer가 timeout으로 응답을 못 받고 retry 했을 때, PUT/DELETE는 안전하지만 POST는 중복 처리 가능성 이 있다 → Idempotency-Key 헤더 패턴(§3.6) 필요.
3.4 Versioning 전략
섹션 제목: “3.4 Versioning 전략”3가지 주요 방식 — 무엇이 옳다는 단일 답은 없고, 트레이드오프가 다르다.
| 방식 | 예시 | 가시성 | 캐시·라우팅 | 도구 호환 | 추천 시나리오 |
|---|---|---|---|---|---|
| URL path | GET /v1/orders → /v2/orders | 최고 | 최고 | 최고 | 공개 API · 외부 클라이언트 |
| Header | X-API-Version: 2 | 낮음 | 어려움 | 표준 | 내부 API · per-client granular |
| Media type | Accept: application/vnd.example.v2+json | 낮음 | 어려움 | RESTful | HATEOAS · 콘텐츠 협상이 본질일 때 |
| Query param | GET /orders?api-version=2 | 중간 | 어중간 | 표준 | 권고 안 함 (예외 케이스만) |
실무 디폴트는 URL path: Stripe, GitHub, AWS 모두 채택. 브라우저·캐시·로드밸런서·로그·CDK 어디서나 자연스럽게 동작. 공개 API의 표준.
Header/Media type은 내부 환경의 granular per-client 시나리오에서: 한 client만 새 버전을 받게 하고 싶을 때, URL 변경 없이 헤더 한 줄로 분기. 단점은 숨겨진 협상이라 디버깅 어려움.
3.5 Contract Testing — Consumer-Driven vs Provider-Driven
섹션 제목: “3.5 Contract Testing — Consumer-Driven vs Provider-Driven”Provider-Driven (Spec-driven): OpenAPI/AsyncAPI/Protobuf 같은 공식 명세 가 단일 진실. consumer는 SDK로 사용, conformance test가 provider를 검증.
┌─ Provider 코드 ─┐ OpenAPI spec ─→ │ spec 준수 테스트 │ ↓ └──────────────────┘ SDK 생성 ─→ Consumer A, B, C장점: 공개 API에 자연. 도구 풍부 (oasdiff, openapi-generator). 단점: consumer의 실제 사용 패턴 은 spec에 담기지 않음.
Consumer-Driven (Pact): 각 consumer가 “내가 이렇게 부르면 이런 응답을 기대한다”는 Pact 파일 을 자동 생성. provider는 이 Pact 파일들로 검증.
Consumer A test ─→ Pact A.json ┐ Consumer B test ─→ Pact B.json ├─→ Pact Broker ─→ Provider 검증 Consumer C test ─→ Pact C.json ┘장점: 실제 사용 패턴 기반 검증. consumer가 안 쓰는 필드 제거는 안전. 단점: 모든 consumer를 알 수 있는 내부 환경에 적합. 공개 API에는 부적합.
현실적 권장: OpenAPI Bi-Directional — 대부분 서비스 페어는 OpenAPI 기반, 가장 중요한 핵심 통합 만 Pact 컨슈머-드라이븐 추가.
결정 기준은 consumer를 알 수 있느냐로 갈린다. 공개 API처럼 consumer 목록을 통제할 수 없으면 provider가 발행한 OpenAPI가 기준이고, 사내 MSA처럼 shipping-service가 order-service의 어떤 응답만 쓰는지 알 수 있으면 Pact가 실제 사용 패턴을 더 잘 잡는다. OpenAPI만 쓰면 “spec에는 있지만 아무도 안 쓰는 필드”를 오래 끌고 갈 수 있고, Pact만 쓰면 “아직 등록되지 않은 새 consumer”를 놓칠 수 있다. 그래서 외부·불특정 consumer는 OpenAPI conformance를 기본으로, 매출·결제·배송처럼 장애 비용이 큰 내부 연동은 Pact Broker의 can-i-deploy를 배포 gate로 둔다.
// Pact 예시 (consumer 측 Jest 테스트)import { PactV3, MatchersV3 } from "@pact-foundation/pact";
const provider = new PactV3({ consumer: "shipping-service", provider: "order-service", dir: "./pacts",});
test("get order by id", async () => { provider .given("order ORD-1 exists with status CONFIRMED") .uponReceiving("GET /v1/orders/ORD-1") .withRequest({ method: "GET", path: "/v1/orders/ORD-1" }) .willRespondWith({ status: 200, headers: { "Content-Type": "application/json" }, body: { id: "ORD-1", status: MatchersV3.equal("CONFIRMED"), totalAmount: MatchersV3.integer(19900), }, });
await provider.executeTest(async (mockServer) => { const order = await orderClient.getOrder(mockServer.url, "ORD-1"); expect(order.status).toBe("CONFIRMED"); });});// 결과: pacts/shipping-service-order-service.json 자동 생성// → Pact Broker에 publish → order-service 측 verification에서 검증3.6 Idempotency — POST의 retry safety
섹션 제목: “3.6 Idempotency — POST의 retry safety”POST는 의미상 비멱등이다. 결제·주문 같은 작업에서 retry 시 중복 처리 위험. 표준 해결: Idempotency-Key 헤더.
POST /v1/paymentsIdempotency-Key: "8a3b6c1e-..." ← 클라이언트가 생성한 UUID 또는 충분히 랜덤한 문자열Content-Type: application/json
{"orderId": "ORD-1", "amount": 19900}서버 측:
Idempotency-Key를 키로 Redis/DB에서 이전 요청 결과 조회- 있으면 저장된 응답을 그대로 반환 (다시 처리하지 않음)
- 없으면 처리하고 결과를 24시간 정도 보관
이 패턴이 Stripe, AWS API Gateway, Shopify API의 표준. IETF Idempotency-Key 초안도 UUID v4와 랜덤 문자열 예시를 제시한다. REST의 비멱등을 운영 멱등으로 끌어올리는 실무적 방법이다.
깨지는 조건은 “같은 키”가 항상 “같은 요청”이라는 착각에서 온다. buggy client가 같은 Idempotency-Key로 amount만 바꿔 재시도하면, 서버가 키만 보고 이전 응답을 반환해 조용히 잘못된 결제가 확정될 수 있다. 저장 값은 (key, request body hash, response, status, expiresAt)여야 하고, 같은 키에 다른 body hash가 들어오면 409 Conflict 또는 422 Unprocessable Content를 application/problem+json으로 반환한다.
curl -i -X POST https://api.example.com/v1/payments \ -H 'Idempotency-Key: "fixed-key-1"' \ -H 'Content-Type: application/json' \ -d '{"orderId":"ORD-1","amount":19900}'# 예상 출력: HTTP/1.1 201 Created
curl -i -X POST https://api.example.com/v1/payments \ -H 'Idempotency-Key: "fixed-key-1"' \ -H 'Content-Type: application/json' \ -d '{"orderId":"ORD-1","amount":29900}'# 예상 출력: HTTP/1.1 409 Conflict + Content-Type: application/problem+json# 다음 단계: key 재사용 버그를 client retry middleware에서 수정3.7 에러 표준 — RFC 9457 Problem Details
섹션 제목: “3.7 에러 표준 — RFC 9457 Problem Details”API마다 에러 형식이 제각각인 게 가장 흔한 함정. consumer 코드가 if (response.error), if (response.message), if (response.errors[0].detail) 같은 분기로 가득해진다.
RFC 9457 Problem Details 는 RFC 7807을 대체한 에러 응답 공통 형식이다. HTTP status code만으로 API-specific detail을 충분히 전달하기 어렵기 때문에, application/problem+json body에 기계가 읽을 수 있는 type, status, instance를 함께 둔다. 일반적으로:
HTTP/1.1 404 Not FoundContent-Type: application/problem+json
{ "type": "https://api.example.com/errors/order-not-found", "title": "Order not found", "status": 404, "detail": "Order with id ORD-9999 does not exist", "instance": "/v1/orders/ORD-9999", "orderId": "ORD-9999"}핵심 필드:
type: URI 형태 에러 식별자 (문서 링크 가능)title: 사람이 읽는 짧은 요약status: HTTP status (응답 status와 일치)detail: 인스턴스별 메시지instance: 어느 요청에서 발생했나- 추가 필드 자유롭게 (
orderId같은 도메인 메타데이터)
Content-Type: application/problem+json이 핵심 시그널. consumer SDK는 이 타입을 보면 공통 에러 처리 로직 으로 분기.
3.8 Pagination·Filtering·Sorting
섹션 제목: “3.8 Pagination·Filtering·Sorting”큰 컬렉션 응답의 표준 패턴.
Cursor-based (권장):
GET /v1/orders?limit=20&cursor=eyJpZCI6IjA1MjY...
응답:{ "data": [...], "nextCursor": "eyJpZCI6IjA1NDA...", "hasMore": true}cursor는 불투명한 토큰 (BASE64로 인코딩된 마지막 row의 정렬 키). 장점: row 추가/삭제 중에도 일관된 페이지. 단점: 임의 페이지 점프 불가.
Offset-based (간단하지만 한계):
GET /v1/orders?limit=20&offset=100장점: 직관적. 단점: 데이터 변동 중 페이지 중복/누락, 큰 offset에서 DB 부담 (OFFSET 100000은 PG에서 풀 스캔에 가까움).
Filtering·Sorting: ?status=PENDING&from=2026-05-01&sort=createdAt:desc 식 query string. 복잡한 필터는 GraphQL 또는 자체 query DSL을 고민할 시점.
3.9 Backward Compatibility — breaking change 회피
섹션 제목: “3.9 Backward Compatibility — breaking change 회피”| 변경 | breaking? | 대응 |
|---|---|---|
| 필드 추가 (optional) | 안전 | 그대로 진행 |
| 필드 추가 (required) | breaking | optional로 추가 후 deprecation 거쳐 required로 |
| 필드 제거 | breaking | deprecation 헤더 → 일정 후 제거 |
| 필드 타입 변경 (string → int) | breaking | 새 필드 추가 + 옛 필드 deprecate |
| enum 값 추가 | 상황별 | consumer가 unknown 처리 가능하면 안전, 아니면 breaking |
| enum 값 제거 | breaking | deprecation |
| 에러 응답 형식 변경 | breaking | RFC 9457 Problem Details로 처음부터 통일 |
| URL path 변경 | breaking | versioning |
| HTTP method 변경 | breaking | versioning |
| 기본값 변경 | breaking | deprecation 후 변경 |
Deprecation 표준: HTTP Deprecation 헤더(RFC 9745) + Sunset 헤더(RFC 8594) 활용. Deprecation은 리소스가 deprecated 되었거나 될 예정임을 알리는 hint이고, 동작 자체를 바꾸지는 않는다. Sunset은 URI가 특정 시점 이후 응답하지 않을 가능성을 알리며, RFC 9745는 Sunset 시각이 Deprecation 시각보다 빠르면 안 된다고 설명한다.
HTTP/1.1 200 OKDeprecation: @1782950399Sunset: Thu, 01 Oct 2026 23:59:59 GMTLink: </v2/orders/{id}>; rel="successor-version"Link: <https://developer.example.com/deprecations/orders-v1>; rel="deprecation"; type="text/html"consumer 측 모니터링: 이 헤더가 보이면 자동 알림. 3개월 전 deprecation, 3개월 후 sunset이 일반적 cadence.
4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- MSA 서비스 간 통신: order-service ↔ payment-service ↔ shipping-service. OpenAPI spec이 각 서비스의 계약.
- 외부 공개 API: 파트너·고객사 통합. URL path versioning + Idempotency + RFC 9457 + Rate limiting (
429). - 모바일/프론트엔드 backend-for-frontend (BFF): 화면에 맞춘 응답 정제. spec-first로 design QA를 코드 전에 끝냄.
- 3rd party Webhook: 자신이 provider 면 spec 발행, consumer 면 그쪽 spec에 맞춰 검증.
- gRPC + Protobuf: 내부 서비스 간. 강타입 + 효율 + 자동 SDK. AsyncAPI는 메시지 기반 시스템(Kafka, SQS).
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- L1 api-design-basics에서 다룬 REST 기초 → 이 문서가 운영 가능한 계약 으로 끌어올림
- L9 msa-patterns: 서비스 간 통신의 신뢰성 ↔ contract testing
- L8 cdc-outbox: 이벤트 발행 시 AsyncAPI 계약 동등 적용
- L9 testing-strategy: 단위·통합·계약·e2e 피라미드에서 계약 단의 명확한 위치
- L6 logs-metrics-traces: deprecated API 호출률을 메트릭으로
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| A | B | 차이점 |
|---|---|---|
| OpenAPI | Swagger | 본질 동일 (Swagger 2.0 → OpenAPI 3.0 명칭 변경). “Swagger UI” 같은 도구 이름은 그대로 |
| OpenAPI | JSON Schema | OpenAPI는 전체 API 명세, JSON Schema는 데이터 구조. OpenAPI 내부에서 JSON Schema(서브셋)를 schema로 사용 |
| Spec-First | Code-First | Spec-First: spec → 코드. Code-First: 코드 → spec(추출). 신규 프로젝트는 Spec-First 권장 |
| Consumer-Driven | Provider-Driven | CDC(Pact): consumer 기대가 단일 진실. PD(OpenAPI): provider spec이 단일 진실. 혼용이 현실적 |
| Pact | OpenAPI | Pact는 consumer가 작성한 동적 계약, OpenAPI는 provider가 발행한 정적 명세. 결합 사용 가능 (bi-directional) |
| Contract test | Integration test | Contract test는 spec 준수 검증 (provider 단독 실행 가능). Integration은 실제 서비스 결합 검증 |
| Idempotency-Key | Retry-After | Idempotency-Key는 중복 방지 (client 생성). Retry-After는 서버가 client에게 알려주는 재시도 시각 |
| RFC 9457 | JSON:API 에러 형식 | RFC 9457은 RFC 7807을 대체한 IETF 표준. JSON:API는 자체 스펙. RFC 9457이 더 일반적·범용 |
| URL versioning | Header versioning | URL은 가시·캐시 친화, Header는 URI 고정·granular. 공개 API는 URL, 내부는 Header 가능 |
| 200 + error in body | 4xx/5xx | 안티패턴 vs 표준. HTTP status를 거짓말하면 클라이언트·LB·모니터링이 모두 깨짐 |
404 Not Found | 410 Gone | 404: 없음 (있을 수도). 410: 영구히 없음 (deprecated 리소스). 로봇·캐시가 다르게 처리 |
| Soft delete | Hard delete | API 응답에서 soft delete는 보통 404 (consumer 시점에서 같음). Hard delete는 audit log 분리 필요 |
| API versioning | API evolution | versioning: 명시적 v1/v2 분기. evolution: 호환성 유지하며 점진적 변경. 둘 다 필요 |
7. 체크리스트
섹션 제목: “7. 체크리스트”- OpenAPI spec과 코드 중 무엇이 먼저 변경 되는지 (spec-first vs code-first) 팀 합의가 명확한가?
- PR에서 OpenAPI breaking change를 자동 감지하는 도구(oasdiff 등)가 CI에 걸려 있는가?
- POST 엔드포인트에서 retry safety가 필요한 것은
Idempotency-Key를 받고 있는가? - 모든 4xx/5xx 응답이 RFC 9457 Problem Details 형식인가? (
application/problem+json) - consumer가 unknown enum 값을 받았을 때 crash 하지 않도록 코드(또는 contract test)가 막고 있는가?
- Versioning 전략(URL vs Header) 선택 이유를 팀이 한 줄로 설명할 수 있는가?
- Deprecation
Sunset헤더를 모니터링하는 알림이 consumer 측에 있는가? - 큰 컬렉션이 cursor-based pagination을 쓰는가, offset-based의 한계가 보이는 지점은 어디인가?
- Contract test (OpenAPI conformance / Pact)가 service 간 CI에 묶여 있는가?
- API 문서가 코드와 함께 versioning 되어 있는가 (Stoplight, ReadMe, Redocly 등)?
8. 운영 함정과 실패 모드
섹션 제목: “8. 운영 함정과 실패 모드”8.1 Silent breaking change — 필드 타입 슬쩍 바꾸기
섹션 제목: “8.1 Silent breaking change — 필드 타입 슬쩍 바꾸기”가장 자주 일어나는 사고. spec을 lint만 하고 diff 안 하면 PR에서 안 잡힌다.
# v1.4.0totalAmount: type: integer
# v1.5.0 (실수)totalAmount: type: string # ← consumer 측 JSON parsing 깨짐증상: 배포 직후 consumer 서비스 500 에러 폭발. 원인: number 가정한 코드가 "19900" string 받음 → 산술 연산 오류 또는 비교 깨짐.
감지: CI에 oasdiff 추가.
oasdiff breaking old-openapi.yaml new-openapi.yaml# breaking change 있으면 exit 1 → PR block복구: 즉시 rollback (API gateway weighted routing이라면 weight 0). 다시 PR 단계로 돌아가 새 필드 추가 + 옛 필드 유지 형태로 전환.
8.2 Enum 값 추가 — 합의 안 된 상황별 breaking
섹션 제목: “8.2 Enum 값 추가 — 합의 안 된 상황별 breaking”provider가 status에 REFUNDED를 추가했다. spec 상으론 필드 추가가 아니라 값 추가 라 자동 도구가 안 잡는다.
- consumer가 switch-case에 default가 있고 unknown을 그대로 두면: 안전
- consumer가 enum을 strict 타입으로 받아 unknown을 throw 하면: breaking
해결: 합의 자체를 명시적으로. consumer가 unknown enum을 어떻게 다루는지 가 spec의 문서화된 약속 이어야 함. OpenAPI 3.1 oneOf + discriminator로 더 안전한 union 표현 가능.
8.3 200 OK + body에 에러 — HTTP status 거짓말
섹션 제목: “8.3 200 OK + body에 에러 — HTTP status 거짓말”HTTP/1.1 200 OK{"error": "Not authorized"}가장 흔한 안티패턴. consumer/LB/모니터링 도구가 모두 정상으로 인식해 5xx 메트릭이 0인데 실제 장애 상황이 만들어진다.
해결: HTTP status를 상태 자체 로 쓴다. 비즈니스 실패는 4xx, 시스템 실패는 5xx. 응답 body는 맥락 만.
8.4 Rate limiting 응답 표준 부재
섹션 제목: “8.4 Rate limiting 응답 표준 부재”429 Too Many Requests + Retry-After: 30 헤더가 표준. 이걸 안 보내면 client가 exponential backoff 를 직접 구현해야 함 → 일관성 깨짐.
HTTP/1.1 429 Too Many RequestsRetry-After: 30RateLimit-Limit: 1000RateLimit-Remaining: 0RateLimit-Reset: 1697123456Content-Type: application/problem+json
{"type": ".../rate-limit-exceeded", "title": "Rate limit exceeded", "status": 429}RateLimit-* 헤더는 IETF draft지만 GitHub, Stripe 등이 채택해 사실상 표준.
8.5 OpenAPI spec과 코드 drift
섹션 제목: “8.5 OpenAPI spec과 코드 drift”spec-first로 시작했는데, 시간이 지나며 코드 측에서 spec에 없는 필드가 응답에 슬쩍 추가됐다.
증상: consumer가 도움 없이 발견하는 필드 로 의존성을 만들어 버림. 나중에 spec 정리하다 제거 → “분명히 받았던 필드인데 사라졌다”는 항의.
해결: provider 측에 spec conformance test. 예: chai-openapi-response-validator, dredd. 응답이 spec과 일치하지 않으면 test 실패.
8.6 Pact 깨짐을 무시하기 — broker green-washing
섹션 제목: “8.6 Pact 깨짐을 무시하기 — broker green-washing”Pact Broker에 주황색 (verification failed) 상태가 누적되는데 PR은 계속 머지된다. 처음엔 “곧 고친다”였는데 한 달 후엔 누구도 보지 않는 신호 가 된다.
해결: Pact Broker의 verification 실패 가 deploy gate에 걸리도록 한다 (Pact can-i-deploy 명령). 합의: 실패는 즉시 고치거나 해당 expectation 자체를 삭제 (consumer가 안 쓰는 게 되었다는 결정).
pact-broker can-i-deploy --pacticipant order-service --version $GIT_SHA --to-environment production# 예상 출력: Computer says yes → 배포 가능 / verification failed → deploy block# 다음 단계: 실패한 consumer expectation을 고치거나, consumer가 더 이상 쓰지 않으면 Pact를 삭제9. 추가 학습 키워드
섹션 제목: “9. 추가 학습 키워드”- gRPC + Protobuf: 내부 서비스 표준. buf CLI, buf.build registry
- AsyncAPI: Kafka/SQS/RabbitMQ 이벤트 스펙
- GraphQL + Apollo Federation: schema stitching, subgraph contract
- JSON:API spec: 자원·관계 표준 응답 형식 (대안 표준)
- HAL / HATEOAS: 응답에 다음 액션 링크 임베드
- OpenAPI Generator / Swagger Codegen: spec → SDK 자동 생성
- Stoplight / ReadMe / Redocly: spec hosting + 문서 + 변경 추적
- OAuth 2.1 / OpenID Connect: 인증/인가 표준 (L1 auth-vs-authz 연계)
- JSON Schema Draft 2020-12: OpenAPI 3.1의 기반 schema 표준
- Backstage Software Catalog: API 카탈로그 + 소유권 관리
10. 내가 직접 확인해볼 것
섹션 제목: “10. 내가 직접 확인해볼 것”- OpenAPI 3.1 spec 1개 작성:
petstore.yaml흉내가 아니라 본인 도메인 1개 자원의 CRUD + filter + pagination + RFC 9457 에러 - oasdiff로 breaking change 자동 감지:
oasdiff breaking v1.yaml v2.yaml→ CI에 통합 - openapi-generator로 TS SDK 생성:
openapi-generator-cli generate -g typescript-axios -i spec.yaml -o sdk/→ consumer가 그 SDK로 호출 - Pact 컨슈머 측 테스트 1개 작성 + provider verification: docker-compose로 Pact Broker 띄우고 publish → verify까지
-
Idempotency-Key처리 구현: NestJS interceptor + Redis. 같은 키로 2번 호출 → 두 번째는 캐시된 응답 반환 - RFC 9457 Problem Details 적용: NestJS exception filter로 모든 에러를
application/problem+json으로 통일 - Sunset 헤더 deprecation 시뮬레이션: v1 응답에
Sunset: <future-date>추가, consumer 측에서 알림 받는지 확인 - Contract test CI 통합: PR에서 contract 깨짐 발견 → block.
can-i-deploy명령으로 deploy gate
11. 5줄 요약
섹션 제목: “11. 5줄 요약”- Spec-First가 단일 진실: OpenAPI/AsyncAPI를 코드보다 먼저 변경. spec이 PR에서 리뷰되고 oasdiff가 breaking change를 자동 감지.
- Contract Testing: OpenAPI(provider-driven) + Pact(consumer-driven)가 보완 관계. 대부분 OpenAPI bi-directional, 핵심 통합만 Pact 추가.
- HTTP 의미론을 지킨다: 자원=명사, 동사=method, 4xx/5xx로 거짓말 금지, POST는
Idempotency-Key로 retry safety, 429+Retry-After로 rate limit. - 에러는 RFC 9457:
application/problem+json단일 형식. consumer SDK가 공통 에러 처리 로 분기 가능. - Versioning은 URL path가 디폴트: 공개 API는
/v1/, 내부 granular는 Header. Sunset+Deprecation 헤더로 예고된 폐기. breaking change는 새 필드 추가 후 옛 필드 deprecate 패턴으로.
참고 출처: OpenAPI Initiative 공식 3.1 spec (https://spec.openapis.org/oas/v3.1.0.html), RFC 9110 HTTP Semantics (https://httpwg.org/specs/rfc9110.html), RFC 9457 Problem Details (https://www.rfc-editor.org/rfc/rfc9457), RFC 9745 Deprecation Header (https://www.ietf.org/rfc/rfc9745.html), RFC 8594 Sunset Header (https://www.rfc-editor.org/rfc/rfc8594), IETF Idempotency-Key draft (https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header), Pact 공식 문서 can-i-deploy (https://docs.pact.io/pact_broker/can_i_deploy).