콘텐츠로 이동

API 설계 & 계약 (OpenAPI · Contract Testing · Versioning)

분류: Layer 9 - 아키텍처 & 설계 패턴

API 설계와 계약(API Design & Contract)은 서비스 간 인터페이스를 OpenAPI/AsyncAPI 같은 명세를 1차 시민으로 두고, 그 명세를 소비자/제공자 양쪽이 자동으로 검증하는 Spec-First + Contract Testing 워크플로로, REST 자원 모델링·HTTP 의미론·버저닝·에러 표준(RFC 9457 Problem Details, RFC 7807 후속)·하위호환성을 운영 가능한 형태로 묶는 설계 분야다.

서비스 간 통신은 “코드는 통과하는데 통합 환경에서 깨지는” 모드를 가장 자주 만든다. 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.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: integerstring으로 바꾸는 PR은 YAML 문법상 정상이라 lint를 통과하지만, consumer 런타임에서는 깨질 수 있다.

Terminal window
oasdiff breaking openapi.base.yaml openapi.pr.yaml
# 예상 출력: ERR breaking changes found ... property "totalAmount" type changed from integer to string
# 다음 단계: 새 필드 totalAmountText를 optional로 추가하고, 기존 totalAmount는 Sunset 일정 전까지 유지
openapi: 3.1.0
info:
title: Order API
version: 1.4.0
servers:
- url: https://api.example.com/v1
paths:
/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 /ordersGET /getOrders
동사는 HTTP method로POST /orders (생성)POST /createOrder
계층 관계는 URI 중첩GET /orders/{id}/itemsGET /orderItems?orderId=...
액션은 자원의 상태 전이POST /orders/{id}/cancellation (cancellation 리소스 생성)POST /orders/{id}/cancel
검색·필터링은 query parameterGET /orders?status=PENDING&from=2026-05-01GET /orders/status/PENDING
비REST한 RPC가 자연스러우면 그건 RPC로POST /payments:refund (Google 스타일)REST 억지로 끼우기

HTTP method 의미론:

Method멱등성안전성의미
GETOO조회 (사이드 이펙트 없음)
HEADOO메타데이터만 조회
OPTIONSOOCORS preflight, 허용 메서드
PUTOX전체 교체 (멱등)
DELETEOX삭제 (멱등 — 두 번 호출해도 같은 상태)
PATCHX(원칙) / O(설계로)X부분 수정
POSTXX생성·비멱등 액션

멱등성 은 retry safety의 기반이다. consumer가 timeout으로 응답을 못 받고 retry 했을 때, PUT/DELETE는 안전하지만 POST는 중복 처리 가능성 이 있다 → Idempotency-Key 헤더 패턴(§3.6) 필요.

3가지 주요 방식 — 무엇이 옳다는 단일 답은 없고, 트레이드오프가 다르다.

방식예시가시성캐시·라우팅도구 호환추천 시나리오
URL pathGET /v1/orders/v2/orders최고최고최고공개 API · 외부 클라이언트
HeaderX-API-Version: 2낮음어려움표준내부 API · per-client granular
Media typeAccept: application/vnd.example.v2+json낮음어려움RESTfulHATEOAS · 콘텐츠 협상이 본질일 때
Query paramGET /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에서 검증

POST는 의미상 비멱등이다. 결제·주문 같은 작업에서 retry 시 중복 처리 위험. 표준 해결: Idempotency-Key 헤더.

POST /v1/payments
Idempotency-Key: "8a3b6c1e-..." ← 클라이언트가 생성한 UUID 또는 충분히 랜덤한 문자열
Content-Type: application/json
{"orderId": "ORD-1", "amount": 19900}

서버 측:

  1. Idempotency-Key를 키로 Redis/DB에서 이전 요청 결과 조회
  2. 있으면 저장된 응답을 그대로 반환 (다시 처리하지 않음)
  3. 없으면 처리하고 결과를 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 Contentapplication/problem+json으로 반환한다.

Terminal window
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 Found
Content-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는 이 타입을 보면 공통 에러 처리 로직 으로 분기.

큰 컬렉션 응답의 표준 패턴.

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)breakingoptional로 추가 후 deprecation 거쳐 required로
필드 제거breakingdeprecation 헤더 → 일정 후 제거
필드 타입 변경 (string → int)breaking새 필드 추가 + 옛 필드 deprecate
enum 값 추가상황별consumer가 unknown 처리 가능하면 안전, 아니면 breaking
enum 값 제거breakingdeprecation
에러 응답 형식 변경breakingRFC 9457 Problem Details로 처음부터 통일
URL path 변경breakingversioning
HTTP method 변경breakingversioning
기본값 변경breakingdeprecation 후 변경

Deprecation 표준: HTTP Deprecation 헤더(RFC 9745) + Sunset 헤더(RFC 8594) 활용. Deprecation은 리소스가 deprecated 되었거나 될 예정임을 알리는 hint이고, 동작 자체를 바꾸지는 않는다. Sunset은 URI가 특정 시점 이후 응답하지 않을 가능성을 알리며, RFC 9745는 Sunset 시각이 Deprecation 시각보다 빠르면 안 된다고 설명한다.

HTTP/1.1 200 OK
Deprecation: @1782950399
Sunset: Thu, 01 Oct 2026 23:59:59 GMT
Link: </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.

  • 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).
  • L1 api-design-basics에서 다룬 REST 기초 → 이 문서가 운영 가능한 계약 으로 끌어올림
  • L9 msa-patterns: 서비스 간 통신의 신뢰성 ↔ contract testing
  • L8 cdc-outbox: 이벤트 발행 시 AsyncAPI 계약 동등 적용
  • L9 testing-strategy: 단위·통합·계약·e2e 피라미드에서 계약 단의 명확한 위치
  • L6 logs-metrics-traces: deprecated API 호출률을 메트릭으로
AB차이점
OpenAPISwagger본질 동일 (Swagger 2.0 → OpenAPI 3.0 명칭 변경). “Swagger UI” 같은 도구 이름은 그대로
OpenAPIJSON SchemaOpenAPI는 전체 API 명세, JSON Schema는 데이터 구조. OpenAPI 내부에서 JSON Schema(서브셋)를 schema로 사용
Spec-FirstCode-FirstSpec-First: spec → 코드. Code-First: 코드 → spec(추출). 신규 프로젝트는 Spec-First 권장
Consumer-DrivenProvider-DrivenCDC(Pact): consumer 기대가 단일 진실. PD(OpenAPI): provider spec이 단일 진실. 혼용이 현실적
PactOpenAPIPact는 consumer가 작성한 동적 계약, OpenAPI는 provider가 발행한 정적 명세. 결합 사용 가능 (bi-directional)
Contract testIntegration testContract test는 spec 준수 검증 (provider 단독 실행 가능). Integration은 실제 서비스 결합 검증
Idempotency-KeyRetry-AfterIdempotency-Key는 중복 방지 (client 생성). Retry-After는 서버가 client에게 알려주는 재시도 시각
RFC 9457JSON:API 에러 형식RFC 9457은 RFC 7807을 대체한 IETF 표준. JSON:API는 자체 스펙. RFC 9457이 더 일반적·범용
URL versioningHeader versioningURL은 가시·캐시 친화, Header는 URI 고정·granular. 공개 API는 URL, 내부는 Header 가능
200 + error in body4xx/5xx안티패턴 vs 표준. HTTP status를 거짓말하면 클라이언트·LB·모니터링이 모두 깨짐
404 Not Found410 Gone404: 없음 (있을 수도). 410: 영구히 없음 (deprecated 리소스). 로봇·캐시가 다르게 처리
Soft deleteHard deleteAPI 응답에서 soft delete는 보통 404 (consumer 시점에서 같음). Hard delete는 audit log 분리 필요
API versioningAPI evolutionversioning: 명시적 v1/v2 분기. evolution: 호환성 유지하며 점진적 변경. 둘 다 필요
  • 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.1 Silent breaking change — 필드 타입 슬쩍 바꾸기

섹션 제목: “8.1 Silent breaking change — 필드 타입 슬쩍 바꾸기”

가장 자주 일어나는 사고. spec을 lint만 하고 diff 안 하면 PR에서 안 잡힌다.

# v1.4.0
totalAmount:
type: integer
# v1.5.0 (실수)
totalAmount:
type: string # ← consumer 측 JSON parsing 깨짐

증상: 배포 직후 consumer 서비스 500 에러 폭발. 원인: number 가정한 코드가 "19900" string 받음 → 산술 연산 오류 또는 비교 깨짐.

감지: CI에 oasdiff 추가.

Terminal window
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는 맥락 만.

429 Too Many Requests + Retry-After: 30 헤더가 표준. 이걸 안 보내면 client가 exponential backoff 를 직접 구현해야 함 → 일관성 깨짐.

HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 1697123456
Content-Type: application/problem+json
{"type": ".../rate-limit-exceeded", "title": "Rate limit exceeded", "status": 429}

RateLimit-* 헤더는 IETF draft지만 GitHub, Stripe 등이 채택해 사실상 표준.

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가 안 쓰는 게 되었다는 결정).

Terminal window
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를 삭제
  • 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 카탈로그 + 소유권 관리
  • 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
  1. Spec-First가 단일 진실: OpenAPI/AsyncAPI를 코드보다 먼저 변경. spec이 PR에서 리뷰되고 oasdiff가 breaking change를 자동 감지.
  2. Contract Testing: OpenAPI(provider-driven) + Pact(consumer-driven)가 보완 관계. 대부분 OpenAPI bi-directional, 핵심 통합만 Pact 추가.
  3. HTTP 의미론을 지킨다: 자원=명사, 동사=method, 4xx/5xx로 거짓말 금지, POST는 Idempotency-Key로 retry safety, 429+Retry-After로 rate limit.
  4. 에러는 RFC 9457: application/problem+json 단일 형식. consumer SDK가 공통 에러 처리 로 분기 가능.
  5. 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).