콘텐츠로 이동

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

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

**API 계약(Application Programming Interface contract)**은 서로 독립적으로 배포되는 client와 server가 요청의 의미, 허용 입력, 응답의 형태와 의미, 실패 방식, 변경 규칙에 합의한 경계다. API 설계는 이 계약을 자원과 HTTP 의미론으로 표현하고, OpenAPI 같은 기계 판독 가능한 명세와 계약 테스트로 합의가 실제 구현에서도 유지되는지 검증하는 작업이다.

이 문서에서 **provider(제공자)**는 API를 구현해 요청을 받고 응답하는 쪽이고, **consumer(소비자)**는 그 API를 호출해 응답에 의존하는 client다. 같은 서비스도 다른 API를 호출할 때는 consumer, 자신의 API를 제공할 때는 provider가 될 수 있다.

여기서 계약은 JSON 필드 목록만 뜻하지 않는다. POST /payments가 결제를 한 번만 생성해야 하는지, PATCH /orders/1이 누락 필드를 보존하는지, 목록을 넘기는 동안 새 주문이 들어와도 중복이 생기지 않는지, 두 사용자가 동시에 수정했을 때 누구의 변경을 거부할지까지 포함한다. 즉 계약의 목표는 Representational State Transfer(REST, 표현 상태 전이) 관습을 외우는 것이 아니라 client와 server가 서로의 배포 시각과 내부 구현을 몰라도 같은 의미로 통신하게 만드는 것이다.

2. 등장한 문제: 분산된 양쪽은 함께 바뀌지 않는다

섹션 제목: “2. 등장한 문제: 분산된 양쪽은 함께 바뀌지 않는다”

단일 프로세스 안의 함수 호출은 컴파일러와 같은 커밋이 호출자와 구현을 함께 바꾸도록 돕는다. 네트워크 API는 다르다. 모바일 앱은 구버전이 수개월 남고, 외부 파트너는 배포 일정을 공유하지 않으며, 사내 서비스도 서로 다른 파이프라인에서 배포된다. 이때 provider가 totalAmount를 number에서 string으로 바꾸면 provider 테스트는 통과해도 consumer의 산술 연산은 운영에서 깨진다.

API 계약이 없는 변경은 보통 다음 타임라인으로 실패한다.

월요일 10:00 provider가 status에 REFUNDED를 추가해 배포
월요일 10:03 구버전 consumer가 unknown enum을 역직렬화하지 못해 500 반환
월요일 10:08 provider 대시보드는 2xx 정상, consumer만 오류 증가
월요일 10:20 어느 팀 변경이 원인인지 수동으로 대조

문제는 네트워크 자체가 아니라 시간에 따라 서로 다른 버전이 공존한다는 사실이다. 따라서 좋은 계약은 현재 요청 하나만 설명하지 않고, 재시도·동시성·점진적 변경·폐기 기간까지 다룬다. Spec-First + Contract Testing 워크플로는 이 불확실성을 측정 가능한 안전망으로 바꾼다. breaking change를 Pull Request(PR)에서 찾고, consumer 기대를 provider 검증에 연결하며, deprecation을 예고된 일정으로 만든다.

필수 선수지식: L1 api-design-basics.mdx(REST 기초), L1 http-basics.mdx. 선택 선행 참고: clean-architecture.mdx(Interface Adapter 레이어), msa-patterns.mdx, testing-strategy.mdx.

2.1 핵심 철학: 구현이 아니라 관찰 가능한 의미를 약속한다

섹션 제목: “2.1 핵심 철학: 구현이 아니라 관찰 가능한 의미를 약속한다”

REST는 분산 시스템이 **자원(resource)**을 URI로 식별하고, 그 자원의 상태를 나타내는 **표현(representation)**을 주고받게 하는 아키텍처 스타일이다. representation은 현재 상태에만 한정되지 않고, 특정 시점의 상태나 요청 뒤 원하는 상태를 표현하는 정보다. 예를 들어 GET 응답은 조회 시점의 current representation을 보내고, PUT 요청 body는 적용하려는 desired representation을 보낸다. 자원은 ORD-1이라는 주문의 정체성이고, {"id":"ORD-1","status":"PENDING"}은 그 주문을 어느 시점 또는 의도한 상태로 JSON 직렬화한 표현이다. 같은 자원을 JSON, CSV, 다른 언어의 객체로 표현할 수 있으므로 DB row나 응답 JSON 자체를 자원이라고 생각하면 내부 스키마가 외부 계약으로 새기 쉽다.

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이 OpenAPI로 발전한 흐름은 이 공백을 “설명 문서”가 아니라 “도구가 읽는 계약”으로 메우려는 시도다.

계약의 철학은 내부 구현을 고정하는 데 있지 않다. provider는 DB를 PostgreSQL에서 다른 저장소로 바꿀 수 있고 consumer는 Software Development Kit(SDK, 소프트웨어 개발 키트)를 교체할 수 있다. 대신 네트워크에서 관찰되는 요청·응답과 그 의미를 안정시킨다. OpenAPI는 경로·파라미터·응답 스키마를 Continuous Integration(CI, 지속적 통합)이 읽는 산출물로 만들고, Contract Testing(계약 테스트)은 명세와 구현 또는 consumer 기대가 맞는지 검증한다. 같은 원리는 Kafka/SQS 이벤트에서는 AsyncAPI, 내부 Remote Procedure Call(RPC, 원격 프로시저 호출)에서는 Protobuf/gRPC Interface Definition Language(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 Specification(OAS, OpenAPI 명세)**은 HTTP API의 operation(경로와 메서드 한 쌍의 동작), parameter, request body, response, schema를 YAML 또는 JSON으로 기술하는 표준이다. 여기서 **schema(스키마)**는 데이터의 타입·필수 여부·허용 값·중첩 구조를 기계가 검사할 수 있게 적은 규칙이다. 스키마는 representation의 구조를 설명하지만, “totalAmount는 세금 포함 금액이다” 같은 비즈니스 뜻까지 저절로 증명하지는 않는다.

아래 YAML은 path와 재사용 구조에 집중한 학습용 발췌다. 따라서 참조 대상 중 OrderItem, ProblemDetails, RateLimited 정의는 생략했으며, 그대로 완전한 OpenAPI 문서로 검증하는 예제는 아니다.

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 배열은 필드의 존재 여부 를 정한다. OpenAPI 3.1에서 null 허용은 type: [string, "null"]처럼 표현하므로 “필수지만 null 허용”과 “필드 자체가 없음”은 별개다.
  • enum으로 status 같은 closed set을 제한 → consumer 코드가 정확한 타입으로 받음.

이 명세가 보장할 수 있는 것은 200 응답에 id가 있고 totalAmount가 0 이상의 정수라는 구조다. ORD-1의 합계가 실제 품목 합계와 같은지, 취소된 주문을 배송 상태로 바꾸지 않는지, 요청한 사용자가 그 주문의 소유자인지는 애플리케이션의 invariant(불변식)와 권한 규칙이다. 이는 단위·통합·End-to-End(E2E, 종단 간) 테스트가 별도로 검증해야 한다. 명세가 통과했다는 말은 메시지 모양이 계약과 맞는다는 뜻이지, 계산과 정책이 옳다는 뜻이 아니다.

3.3 자원과 표현 모델링 — URI보다 먼저 정체성을 정한다

섹션 제목: “3.3 자원과 표현 모델링 — URI보다 먼저 정체성을 정한다”

**자원(resource)**은 API가 식별하고 수명주기를 관리하는 도메인 대상이다. **표현(representation)**은 어떤 시점의 상태 또는 요청 뒤 원하는 상태를 JSON 같은 형식으로 나타낸 정보다. /orders/ORD-1은 주문을 식별하고, 아래 두 GET 응답은 조회 시점의 같은 주문을 서로 다른 media type으로 나타낸 current representation이다.

GET /orders/ORD-1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"ORD-1","status":"CONFIRMED","totalAmount":19900}
GET /orders/ORD-1
Accept: text/csv
HTTP/1.1 200 OK
Content-Type: text/csv
id,status,totalAmount
ORD-1,CONFIRMED,19900

이 구분이 중요한 이유는 API를 테이블 CRUD(Create, Read, Update, Delete)에 그대로 맞추면 DB 컬럼과 관계가 외부 계약이 되기 때문이다. 주문 저장소가 여러 테이블로 분리되어도 client에게는 하나의 주문 자원일 수 있고, DB에 order_items 테이블이 있다고 반드시 /order-items 최상위 자원을 노출할 필요도 없다. 자원의 경계는 client가 어떤 대상을 독립적으로 식별하고 어떤 상태 전이를 요청하는가로 정한다.

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 억지로 끼우기

위 표는 절대 규칙이 아니라 기본 어휘다. POST /orders/{id}/cancel이 언제나 잘못이고 cancellation 자원을 만드는 방식이 언제나 옳은 것은 아니다. 취소 자체에 식별자·승인 상태·환불 진행처럼 별도 수명주기가 있으면 /cancellations 자원이 유용하다. 단순 command 하나이고 RPC가 도메인에 더 정확하면 POST /orders/{id}:cancel처럼 의도를 드러내는 편이 억지 자원보다 낫다. 판단 기준은 URI가 명사인지가 아니라 재시도, 권한, 상태 전이, 응답 의미를 client가 예측할 수 있는가다.

HTTP method 의미론:

Method멱등성안전성의미
GETOO조회 (상태 변경을 요청하지 않음)
HEADOO메타데이터만 조회
OPTIONSOOCross-Origin Resource Sharing(CORS, 교차 출처 리소스 공유) preflight, 허용 메서드
PUTOX전체 교체 (멱등)
DELETEOX삭제 (멱등 — 두 번 호출해도 같은 상태)
PATCHX(원칙) / O(설계로)X부분 수정
POSTXX생성·비멱등 액션

**안전한 메서드(safe method)**는 client가 target resource의 상태 변경을 요청하지 않는 메서드다. “아무 부수 효과도 없다”는 뜻은 아니다. GET 처리 중 access log, 통계, 광고 과금 같은 부수 효과가 생겨도 client가 그 상태 변경을 요청한 것이 아니므로 safe 의미를 어기지 않는다. 다만 GET으로 주문 취소처럼 target resource를 바꾸게 하면 safe 계약 위반이다.

**멱등성(idempotency)**은 같은 의도의 요청을 한 번 수행하든 여러 번 수행하든 서버의 의도된 최종 상태가 같다는 성질이다. 이는 응답이 매번 같은 status라는 뜻이 아니다. 첫 DELETE /orders/1204, 두 번째가 404여도 주문이 삭제된 최종 상태는 같다. 또한 메트릭이나 감사 로그가 두 줄 쌓일 수 있어도 자원 상태가 한 번 삭제되는 의미는 유지된다.

멱등성은 retry safety(응답 유실 뒤 재시도해도 중복 효과가 생기지 않는 성질)의 기반이다. 다만 PUT/DELETE라는 메서드 이름만 붙인다고 구현이 자동으로 멱등해지지는 않는다. PUT /accounts/1/balance가 body의 값으로 잔액을 교체하면 멱등이지만, body의 금액을 현재 잔액에 더하도록 구현하면 PUT 의미를 어긴 비멱등 API다. POST에는 중복 처리 위험을 제어하는 Idempotency-Key 패턴(§3.7)이 필요할 수 있다.

3.4 PUT과 PATCH — 전체 표현 교체와 부분 변경

섹션 제목: “3.4 PUT과 PATCH — 전체 표현 교체와 부분 변경”

PUT 요청 body는 대상 URI에 적용하려는 desired representation을 나타내며, server는 이를 반영해 현재 상태를 교체한다. 같은 완전한 body를 반복 전송하면 최종 상태가 같으므로 멱등이다. 누락 필드를 무시하는 “사실상 부분 수정 PUT”은 client가 전체 표현을 보냈다고 믿을지, 일부만 보냈다고 믿을지 계약을 흐린다.

PUT /v1/users/U-1
Content-Type: application/json
{"name":"Min","email":"min@example.com","marketingConsent":false}

위 요청의 계약이 전체 교체라면 marketingConsent를 누락했을 때 기존 값을 보존하면 안 된다. 누락을 기본값으로 바꿀지 400 Bad Request로 거부할지 명시해야 한다. 반대로 server-generated createdAt처럼 client가 소유하지 않는 필드는 PUT body 밖에 둘 수 있다. “전체”는 DB row 전체가 아니라 client가 교체할 권한이 있는 representation 전체다.

PATCH는 자원의 일부를 변경한다. 하지만 body 형식을 정하지 않으면 null과 누락의 의미가 모호하다. JSON Merge Patch(application/merge-patch+json)는 누락 필드를 보존하고 null을 삭제 의미로 사용한다.

PATCH /v1/users/U-1
Content-Type: application/merge-patch+json
{"name":"Mina","phone":null}

이 계약에서는 name을 바꾸고 phone을 제거하며, email은 보존한다. 같은 patch를 반복해도 결과가 같으므로 이 operation은 멱등으로 설계할 수 있다. JSON Merge Patch에서 {"incrementPoints":10}은 10을 누적하는 command가 아니라 incrementPoints 필드를 10으로 설정하는 뜻이다. 누적 의미가 필요하면 application/vnd.example.increment-points+json 같은 custom command media type과 {"amount":10}을 별도 계약으로 정의해야 하며, 이 command는 반복 시 결과가 달라지는 비멱등 operation이다.

JSON Patch(application/json-patch+json)는 add, remove, replace, test operation 배열로 변경을 표현한다. 예를 들어 [{"op":"add","path":"/tags/-","value":"priority"}]는 배열 끝에 원소를 append하므로 반복 적용하면 원소가 계속 늘어 비멱등이다. 배열 특정 위치 수정이나 test를 이용한 조건부 갱신에는 유리하지만 client가 경로 구조를 더 많이 알아야 한다. 단순한 sparse object 수정은 Merge Patch, 명시적인 operation과 배열 조작은 JSON Patch, 도메인 동작은 custom command media type, 전체 client-owned 표현 교체는 PUT이 기본 선택 기준이다.

**버저닝(versioning)**은 호환되지 않는 계약을 별도 식별자로 분리해 구버전과 신버전을 동시에 제공하는 전략이다. 버전을 만드는 목적은 변경 사실을 자랑하는 것이 아니라 consumer가 이전 계약을 사용할 시간을 사는 것이다. optional 필드 추가처럼 호환 가능한 변경마다 /v2를 만들면 버전 수만 늘고 migration은 끝나지 않는다. 가능한 변경은 같은 버전 안에서 schema evolution(스키마 진화)로 처리하고, 의미를 함께 유지할 수 없는 변경에 버전을 쓴다.

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

방식예시가시성캐시·라우팅도구 호환추천 시나리오
URL pathGET /v1/orders/v2/orders최고최고최고공개 API · 외부 클라이언트
HeaderX-API-Version: 2낮음어려움custom내부 API · per-client granular
Media typeAccept: application/vnd.example.v2+json낮음어려움RESTfulHypermedia as the Engine of Application State(HATEOAS, 애플리케이션 상태 엔진으로서의 하이퍼미디어) · 콘텐츠 협상이 본질일 때
Query paramGET /orders?api-version=2중간어중간표준권고 안 함 (예외 케이스만)

공개 API의 실무 디폴트는 URL path다. 브라우저·캐시·로드밸런서·로그에서 버전이 드러나고 SDK 문서도 나누기 쉽다. 하지만 /v2를 만들었다는 사실만으로 안전하지 않다. v1과 v2가 같은 DB를 공유할 때 v2 write가 v1에서 읽을 수 없는 상태를 만들면 버전 경계 뒤에서도 호환성이 깨진다.

Header/Media type은 내부 환경의 granular per-client 시나리오에서 쓸 수 있다. X-API-Version은 HTTP 표준 버전 헤더가 아니라 조직이 정의하는 custom header이므로 이름·허용 값·캐시 키 반영을 계약해야 한다. 한 client만 새 버전을 받게 하고 싶을 때 URL 변경 없이 분기할 수 있지만, 숨겨진 협상이라 디버깅이 어렵다.

concrete migration을 보자. totalAmount를 integer 원 단위에서 통화와 소수 단위를 가진 객체로 바꾸고 싶다면 다음처럼 한 번에 타입을 바꾸지 않는다. 호환 기간에는 기존 필드와 새 필드를 함께 제공한다.

HTTP/1.1 200 OK
Deprecation: @1782950399
Content-Type: application/json
{
"totalAmount": 19900,
"money": {"currency":"KRW","minorUnits":19900}
}
  1. 같은 버전에 optional money를 추가하고 두 필드를 함께 쓴다.
  2. consumer별 새 필드 사용률과 구필드 호출률을 관측한다.
  3. 의미가 크게 달라 별도 계약이 필요하면 /v2에서 money를 required로 만든다.
  4. v1의 totalAmountDeprecationSunset 기간 뒤 제거한다.

반례는 /v2를 배포하자마자 /v1을 redirect하는 것이다. POST의 301/302 처리와 인증 헤더 전달은 client마다 다를 수 있고, 새 응답 schema도 구 client가 이해하지 못한다. 버전은 redirect 장치가 아니라 동시에 유지되는 계약과 migration 계획이다.

3.6 Contract Testing — 구조적 합의와 비즈니스 의미의 경계

섹션 제목: “3.6 Contract Testing — 구조적 합의와 비즈니스 의미의 경계”

Provider-Driven(Spec-driven) 방식은 OpenAPI/AsyncAPI/Protobuf 같은 공식 명세를 단일 진실로 두고, conformance test(준수 테스트)가 provider의 실제 요청·응답이 명세와 맞는지 검증한다.

┌─ Provider 코드 ─┐
OpenAPI spec ─→ │ spec 준수 테스트 │
↓ └──────────────────┘
SDK 생성 ─→ Consumer A, B, C

장점: 공개 API에 자연. 도구 풍부 (oasdiff, openapi-generator). 단점: consumer의 실제 사용 패턴 은 spec에 담기지 않음.

Consumer-Driven Contract(CDC, 소비자 주도 계약) 방식은 각 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 Pact에서 사용하지 않는 필드는 제거 영향을 좁혀 판단할 수 있다. 단점: 모든 consumer를 알 수 있는 내부 환경에 적합하고, 미등록 consumer가 있는 공개 API에는 부적합하다.

현실적 권장: OpenAPI + 선택적 Pact CDC — 대부분 서비스 페어는 OpenAPI 기반으로 검증하고, 가장 중요한 핵심 통합 에만 Pact consumer-driven contract를 추가한다.

결정 기준은 consumer를 알 수 있느냐로 갈린다. 공개 API처럼 consumer 목록을 통제할 수 없으면 provider가 발행한 OpenAPI가 기준이고, 사내 Microservices Architecture(MSA, 마이크로서비스 아키텍처)처럼 shipping-service가 order-service의 어떤 응답만 쓰는지 알 수 있으면 Pact가 실제 사용 패턴을 더 잘 잡는다. OpenAPI만 쓰면 “spec에는 있지만 아무도 안 쓰는 필드”를 오래 끌고 갈 수 있고, Pact만 쓰면 “아직 등록되지 않은 새 consumer”를 놓칠 수 있다. 그래서 외부·불특정 consumer는 OpenAPI conformance를 기본으로, 매출·결제·배송처럼 장애 비용이 큰 내부 연동은 Pact Broker의 can-i-deploy를 배포 gate로 둔다.

**OpenAPI Bi-Directional Contract Testing(BDCT)**은 위 Pact provider verification과 다른 방식이다. provider가 OpenAPI와 자체 구현 검증 결과를 게시하면 broker가 consumer Pact와 OpenAPI를 cross-contract 비교하며, consumer Pact interaction을 실제 provider에 replay하지 않는다. 아래 예시는 BDCT가 아니라 standard Pact CDC의 consumer test와 실제 provider verification 흐름이다.

// Pact 예시 (consumer 측 Jest 테스트, 실제 order-service는 호출하지 않음)
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
const mockProvider = new PactV3({
consumer: "shipping-service",
provider: "order-service",
dir: "./pacts",
});
test("get order by id", async () => {
mockProvider
.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 mockProvider.executeTest(async (mockServer) => {
const order = await orderClient.getOrder(mockServer.url, "ORD-1");
expect(order.status).toBe("CONFIRMED");
});
});
// consumer test 결과: mock과 consumer 기대를 검증하고 Pact 파일을 생성
// 다음 단계: Broker publish → 실제 order-service build로 provider verification
// 배포 전: can-i-deploy로 대상 환경의 consumer/provider version 호환성 확인

consumer test만 통과하면 shipping-service client가 설정한 Pact mock과 상호작용하고 기대한 응답을 처리할 수 있으며, 그 기대가 Pact 파일로 생성됐다는 뜻이다. 실제 order-service는 호출하지 않았으므로 provider 구현이 status·필드·타입을 만족한다는 증거는 아직 없다. 실제 provider build를 대상으로 provider verification이 성공해야 등록된 Pact와 구현이 맞는다는 증거가 생기고, 배포하려는 consumer/provider version 조합이 Broker에 모두 검증됐는지는 can-i-deploy로 확인해야 한다. 세 단계를 통과해야 해당 배포 조합의 계약 호환성을 주장할 수 있다.

이 전체 흐름을 통과해도 다음은 보장하지 않는다.

  • totalAmount: 19900이 실제 item 합계와 일치하는가
  • CONFIRMED 주문만 배송을 시작한다는 정책을 모든 코드 경로가 지키는가
  • timeout 뒤 provider가 DB commit을 했는지, 재시도가 중복 배송을 만들지 않는가
  • 등록되지 않은 consumer와 외부 사용자가 어떤 필드에 의존하는가
  • 예시 한 건이 아닌 모든 유효 입력에서 비즈니스 invariant가 성립하는가

Pact의 provider state인 order ORD-1 exists with status CONFIRMED도 테스트 fixture를 준비하는 문장이지, 실제 운영 데이터의 상태 전이를 증명하는 논리가 아니다. 계약 테스트는 경계에서 서로의 가정을 검증하는 좁은 테스트이고, 도메인 규칙은 단위·통합 테스트, 실제 의존성 조합은 E2E 테스트, 재시도와 부분 실패는 장애 시나리오 테스트가 맡는다. 모든 테스트를 Pact로 옮기면 계약이 비대해지고 provider 내부 구현에 결합된다.

POST는 의미상 비멱등이다. 결제·주문 같은 작업에서 retry 시 중복 처리 위험이 있다. **Idempotency-Key**는 이를 제어하기 위해 널리 쓰이는 애플리케이션 계약이다. 이 헤더는 IETF HTTPAPI 작업반의 Internet-Draft로 논의됐지만 해당 초안은 만료 상태이며, RFC로 확정된 HTTP 표준 헤더는 아니다. 따라서 key 형식·범위·보관 기간·충돌 응답을 API 계약에 직접 명시해야 한다.

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

서버 측:

  1. (caller, operation, Idempotency-Key)를 고유 키로 Redis/DB에서 이전 요청 상태를 조회한다.
  2. 완료된 같은 요청이면 저장된 status·header·body를 반환하고 다시 처리하지 않는다.
  3. 같은 키의 요청이 처리 중이면 두 번째 실행을 시작하지 않고 409 또는 완료 대기를 선택한다.
  4. 새 요청이면 key와 request fingerprint를 원자적으로 선점한 뒤 처리하고 결과를 보관한다.

보관 기간은 무조건 24시간이 아니다. client의 최대 retry window가 10분인데 key를 30초만 보관하면 느린 retry가 중복 결제를 만든다. 반대로 key를 영구 보관하면 storage가 끝없이 자란다. 최대 네트워크 재시도 시간 + 큐 지연 + 운영자의 수동 재시도 가능 시간보다 길게 두고, 만료 뒤 같은 키의 재사용을 새 요청으로 볼지 거부할지 계약에 적는다.

이 패턴은 REST의 비멱등 operation을 운영상 재시도 가능하게 만드는 방법이다. 단, key 저장과 결제 commit이 서로 다른 시스템에 있으면 둘 사이에서 실패할 수 있다. DB transaction 안에서 업무 결과와 key 결과를 함께 기록하거나, 결제 제공자의 idempotency 기능과 내부 상태 머신을 연결해야 한다. 메모리 cache에만 key를 두면 process restart 직후 보호가 사라진다.

깨지는 조건은 “같은 키”가 항상 “같은 요청”이라는 착각에서 온다. buggy client가 같은 Idempotency-Key로 amount만 바꿔 재시도하면, 서버가 키만 보고 이전 응답을 반환해 조용히 잘못된 결제가 확정될 수 있다. 저장 값은 (scope, key, request fingerprint, response, status, expiresAt)이어야 하고, 같은 키에 다른 fingerprint가 들어오면 409 Conflict 또는 422 Unprocessable Contentapplication/problem+json으로 반환한다.

request fingerprint 계약은 body hash만 정해서는 부족하다. raw 방식은 method, path, raw query string, 의미에 영향을 주는 header, raw body byte를 그대로 묶어 hash하므로 byte가 같은 재전송만 같은 요청으로 본다. canonical 방식은 JSON object key 순서처럼 의미 없는 차이를 정규화하되, method와 path, 정렬·인코딩 규칙을 정한 query field, Content-Type·통화·조건부 요청처럼 결과에 영향을 주는 header, 선택한 body field를 함께 canonicalize한다. 어떤 query/header를 포함·제외하고 배열 순서·숫자·null을 어떻게 다룰지 문서화해야 서로 다른 요청을 같은 것으로 합치거나 같은 요청을 충돌로 오인하지 않는다. 인증된 caller/tenant는 fingerprint보다 바깥의 scope에 두고, tracing header처럼 의미 없는 값은 제외한다.

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에서 수정

또 다른 반례는 key의 범위를 전 사용자에게 공유하는 것이다. 서로 다른 고객이 우연히 같은 UUID를 보내 한쪽 결제가 다른 쪽 응답을 받으면 데이터 노출까지 이어진다. key 조회 범위에는 인증된 caller 또는 tenant와 operation을 포함해야 한다. 이 보안 경계는 별도 인증 체크리스트보다 idempotency 저장 모델 안에서 검증할 항목이다.

3.8 에러 모델 — 실패도 안정적인 계약이다

섹션 제목: “3.8 에러 모델 — 실패도 안정적인 계약이다”

**에러 모델(error model)**은 실패를 status code, 기계 판독 식별자, 재시도 가능성, 사람용 설명으로 일관되게 표현하는 계약이다. API마다 형식이 다르면 consumer 코드가 if (response.error), if (response.message), if (response.errors[0].detail) 같은 분기로 가득해진다. 더 위험한 경우는 같은 409가 재고 부족, idempotency key 충돌, 동시 수정 충돌을 뜻하면서 body로 구분할 방법이 없는 것이다.

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

공통 envelope만으로는 충분하지 않다. client 분기는 사람이 읽는 detail 문자열이 아니라 안정적인 type URI 또는 별도 code를 사용해야 한다. detail은 번역되거나 개인정보를 제거하면서 바뀔 수 있다. validation 오류처럼 여러 필드의 문제가 필요하면 errors: [{pointer, code, detail}] 같은 extension을 계약하고, 내부 stack trace·SQL·access token은 넣지 않는다.

status 선택은 “비즈니스 오류는 모두 400”보다 실패의 해결 주체를 드러내야 한다.

상태계약상 의미client의 다음 행동
400 Bad Request요청 문법·형식이 유효하지 않음같은 요청 재시도 금지, 요청 생성 로직 수정
401 Unauthorized유효한 인증 자격이 없음인증 갱신 후 조건부 재시도
403 Forbidden신원은 알지만 해당 작업 권한이 없음권한 요청 또는 작업 중단
404 Not Found자원을 찾을 수 없거나 노출하지 않기로 함식별자·수명주기 확인
409 Conflict현재 자원 상태와 요청이 충돌최신 상태 조회 후 병합·새 key 생성
422 Unprocessable Content형식은 맞지만 도메인 규칙을 만족하지 못함입력 또는 상태 전이 수정
429 Too Many Requests호출 허용량 초과Retry-After 이후 재시도
500/502/503/504server 또는 upstream의 일시적·미분류 실패멱등성 확인 뒤 backoff 재시도

404로 권한이 없는 자원의 존재를 숨길지는 보안 정책 선택이다. 이 경우에도 내부 관측에서는 실제 authorization 실패와 미존재를 구분해야 한다. 외부 에러 계약을 단순화하는 것과 운영 원인을 지우는 것은 다른 결정이다.

**페이지네이션(pagination)**은 큰 collection representation을 한 번에 보내지 않고 경계를 가진 여러 응답으로 나누는 계약이다. 목적은 응답 body 크기와 DB 작업량을 제한하는 데 있다. 예를 들어 주문 100만 건이 건당 1KB라면 전체 응답은 약 1GB지만 limit=50이면 payload는 대략 50KB 수준이다. 대신 여러 요청 사이에 데이터가 변할 수 있으므로 정렬 기준과 다음 페이지의 의미까지 계약해야 한다.

Cursor-based (권장):

GET /v1/orders?limit=20&cursor=eyJpZCI6IjA1MjY...
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [...],
"nextCursor": "eyJpZCI6IjA1NDA...",
"hasMore": true
}

cursor는 client가 내부를 해석하지 않는 불투명한 토큰 이다. 흔히 마지막 row의 정렬 키를 서명해 encode하지만 단순 Base64는 암호화나 위변조 방지가 아니다. createdAt DESC만 사용하면 같은 timestamp의 row 순서가 흔들릴 수 있으므로 (createdAt DESC, id DESC)처럼 유일하고 안정적인 tie-breaker를 둔다.

-- cursor가 (2026-05-01T10:00:00Z, ORD-100)이면 다음 페이지 조건
WHERE (created_at, id) < ('2026-05-01T10:00:00Z', 'ORD-100')
ORDER BY created_at DESC, id DESC
LIMIT 20

이 keyset 방식은 첫 페이지를 읽은 뒤 더 최신 주문이 추가되어도 기존 cursor보다 앞쪽에 놓이므로 다음 페이지에 중복으로 끼어들지 않는다. 그러나 “조회 시작 시점의 완전한 snapshot”을 보장하는 것은 아니다. 중간에 기존 row의 createdAt 또는 정렬 필드가 바뀌면 누락이나 재등장이 가능하다. snapshot 일관성이 필요하면 cursor에 snapshot timestamp/version을 넣고 해당 시점 기준으로 조회하거나 DB snapshot을 유지하는 별도 비용이 든다.

Offset-based (간단하지만 한계):

GET /v1/orders?limit=20&offset=100

장점은 페이지 번호 점프와 전체 count UI가 직관적이라는 점이다. 단점은 데이터 변동 중 중복·누락이 생기고, 큰 offset에서 DB가 앞 row를 읽고 버려야 한다는 점이다. limit=20&offset=100000은 결과 20건을 위해 앞의 10만 건을 건너뛸 수 있다.

구체적으로 [105, 104, 103]을 1페이지에서 읽고 offset=3으로 다음 페이지를 요청하기 전에 맨 앞에 106이 추가됐다고 하자. 새 배열은 [106, 105, 104, 103, ...]이고 offset 3부터 읽으면 103이 다시 나온다. offset은 admin 화면처럼 작은 결과 집합, 임의 페이지 점프, 데이터 변동이 적은 경우에 적합하다. cursor는 무한 스크롤, feed, 수십만 건 이상, 쓰기가 잦은 collection에 적합하다.

Filtering·Sorting: ?status=PENDING&from=2026-05-01&sort=createdAt:desc 식 query string. 복잡한 필터는 GraphQL 또는 자체 query DSL을 고민할 시점.

pagination 계약에는 limit 최대값도 포함한다. client가 limit=1000000으로 서버 보호를 우회하지 못하게 상한을 두고, cursor에는 filter·sort 조건을 묶거나 조건이 달라지면 400으로 거부한다. 이전 filter에서 발급한 cursor를 다른 filter에 재사용하면 순서 기준 자체가 달라지기 때문이다.

3.10 동시성 제어 — ETag와 conditional request

섹션 제목: “3.10 동시성 제어 — ETag와 conditional request”

**동시성 제어(concurrency control)**는 여러 client가 같은 자원을 거의 동시에 읽고 쓸 때 update loss(갱신 유실)와 잘못된 상태 전이를 막는 규칙이다. API에서는 lock을 오래 잡기보다 version을 비교하는 optimistic concurrency control(낙관적 동시성 제어)을 자주 쓴다. 충돌은 드물다고 가정하고, write 시점에 읽었던 version이 여전히 최신인지 확인한다.

HTTP의 **ETag(Entity Tag)**는 representation version을 식별하는 응답 헤더다. client는 GET에서 받은 ETag를 If-Match conditional request(조건부 요청)에 넣어 “내가 본 버전일 때만 수정”을 표현한다.

GET /v1/orders/ORD-1
HTTP/1.1 200 OK
ETag: "order-7"
Content-Type: application/json
{"id":"ORD-1","shippingAddress":"Seoul","status":"CONFIRMED"}
PATCH /v1/orders/ORD-1
If-Match: "order-7"
Content-Type: application/merge-patch+json
{"shippingAddress":"Busan"}

다른 client가 먼저 수정해 현재 ETag가 "order-8"이 되었다면 server는 patch를 적용하지 않는다.

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type":"https://api.example.com/errors/stale-representation",
"title":"The order changed after it was read",
"status":412,
"currentEtag":"order-8"
}

client는 최신 representation을 다시 읽고 사용자 또는 도메인 규칙에 따라 merge한 뒤 새 ETag로 재시도한다. 409 Conflict를 쓰는 API도 있지만, If-Match 조건 실패에는 412가 HTTP 의미를 더 정확히 전달한다. 조건 헤더를 필수로 하는 write에서 누락되면 428 Precondition Required로 거부할 수 있다.

ETag는 hash일 필요가 없고 DB version column처럼 representation data가 바뀔 때 함께 바뀌는 opaque 값이면 된다. RFC 9110의 If-Match 평가는 strong comparison을 사용하므로 W/"..." 형태의 weak ETag는 같은 opaque tag처럼 보여도 조건을 만족하지 않는다. 갱신용 GET 응답에는 strong ETag를 발급하고, server는 If-Match의 tag와 현재 tag를 strong comparison으로 비교해야 한다. 또한 ETag 없이 “마지막 write가 이긴다”를 택하는 것이 항상 오류는 아니다. 서로 독립적인 선호 설정처럼 overwrite 비용이 낮으면 단순성이 낫다. 배송지·재고·승인 상태처럼 이전 값을 근거로 결정하는 write에는 conditional request가 필요하다.

반례는 GET 직후 PATCH 전에 DB lock을 client 세션 동안 유지하는 것이다. network pause와 사용자 think time 동안 lock이 길게 살아 throughput과 장애 복구를 해친다. ETag는 lock을 network 경계 밖으로 끌고 가지 않고 충돌을 명시적인 API 결과로 바꾼다.

**스키마 진화(schema evolution)**는 client와 server의 서로 다른 버전이 공존하는 동안 message schema를 점진적으로 바꾸는 규칙이다. 여기서는 message를 만드는 쪽을 writer, 해석하는 쪽을 reader라고 부른다. response에서는 provider가 writer이고 consumer가 reader지만, request에서는 consumer가 writer이고 provider가 reader다.

호환성일반 방향Response 방향Request 방향
Backward compatibilityold writer → new readerold provider → new consumerold consumer → new provider
Forward compatibilitynew writer → old readernew provider → old consumernew consumer → old provider

따라서 response optional 필드 추가는 old consumer가 unknown field를 무시할 때 forward-compatible이고, request required 필드 추가는 new provider가 old consumer의 기존 request를 읽지 못하게 해 backward compatibility를 깬다. request optional 필드 제거도 new provider가 unknown field를 거부하면 같은 방향으로 깨진다. enum 값 추가는 wire schema 확장이지만 old consumer의 exhaustive switch나 strict generated enum에는 forward-incompatible할 수 있다. 용어만 쓰면 방향이 뒤집혀 보일 수 있으므로 실무 계약에는 old writer → new reader처럼 주체를 함께 적는다.

변경breaking?대응
응답 필드 추가 (optional)대체로 안전unknown field 허용 여부를 consumer test로 확인
필드 추가 (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 후 변경

“optional이면 안전”에도 반례가 있다. discountAmount가 없을 때 consumer가 0원으로 해석했는데 provider가 optional 필드를 추가하면서 null을 “계산 중”으로 사용하면 의미가 달라진다. schema compatibility와 semantic compatibility(의미 호환성)는 다르다. 필드 설명에 단위, null/absent 차이, 기본값, 상태별 허용 조건을 적고 consumer fixture로 확인해야 한다.

forward-compatible consumer는 다음처럼 모르는 enum 값을 보존하거나 UNKNOWN으로 매핑하고, 모르는 JSON 필드를 무시한다.

type KnownOrderStatus = "PENDING" | "CONFIRMED" | "SHIPPED";
function parseStatus(value: string): KnownOrderStatus | "UNKNOWN" {
return ["PENDING", "CONFIRMED", "SHIPPED"].includes(value)
? (value as KnownOrderStatus)
: "UNKNOWN";
}

하지만 결제 승인처럼 unknown 상태에서 행동하면 위험한 경우 UNKNOWN을 정상 상태로 간주하지 않고 처리를 보류해야 한다. “unknown을 허용하라”는 뜻은 조용히 성공시키라는 뜻이 아니라 역직렬화 crash 대신 안전한 fallback을 계약하라는 뜻이다.

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/ORD-1>; rel="successor-version"
Link: <https://developer.example.com/deprecations/orders-v1>; rel="deprecation"; type="text/html"

Link target은 현재 응답과 연결된 실제 URI여야 하므로 위 successor는 구체 자원을 가리킨다. /v2/orders/{id} 같은 URI Template을 제공하려면 OpenAPI path나 별도의 successorVersionTemplate field·문서에서 template임을 명시한다.

consumer 측에서는 이 헤더가 보이면 자동 알림을 만들고 migration 사용률을 추적한다. 예를 들어 팀 정책을 “최소 3개월 전에 deprecation을 알리고, 구버전 호출률이 0이 된 뒤 sunset”으로 정할 수 있다. 기간 자체보다 consumer 종류와 배포 주기를 근거로 정하고, 헤더만 보낸 채 실제 사용률을 확인하지 않는 상태를 피하는 것이 중요하다.

4. 어떤 계약 수단을 선택할 것인가

섹션 제목: “4. 어떤 계약 수단을 선택할 것인가”

계약 강도는 API의 모양보다 consumer가 독립적으로 실패할 가능성과 실패 비용에 맞춘다.

상황기본 선택추가 보호가 필요한 신호
단일 팀의 짧게 쓰는 admin APICode-First + 생성 OpenAPIconsumer 분리, 수명 연장, 모바일 client 등장
불특정 외부 consumer가 쓰는 공개 APISpec-First OpenAPI + conformance + URL versioning결제·주문 POST에는 idempotency와 긴 deprecation
배포가 분리된 사내 서비스OpenAPI diff + provider verification장애 비용이 크면 Pact와 can-i-deploy gate
여러 사용자가 수정하는 핵심 자원ETag + If-Match optimistic concurrency충돌이 빈번하면 command/merge 모델 재설계
쓰기가 잦고 큰 collectionstable sort + cursor paginationsnapshot 필요 시 snapshot version 추가
화면용 작은 검색 결과와 페이지 점프offset paginationoffset 증가 또는 중복·누락 관측 시 cursor 전환
내부 고성능 RPCProtobuf/gRPC IDL외부 HTTP gateway와 변환 계약도 함께 검증
Kafka/SQS/RabbitMQ eventAsyncAPI 또는 별도 event schema registryproducer/consumer 독립 배포에 compatibility gate

같은 조직에서도 모든 API에 Pact Broker, 새 version, cursor, ETag를 동시에 도입할 필요는 없다. 계약 수단 자체의 운영 비용도 있으므로 독립 배포 가능성, 데이터 손실 비용, consumer 수, 변경 빈도, 관측 가능한 실패 이력으로 선택한다.

  • 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차이점
OpenAPISwaggerSwagger 2.0 명세가 OpenAPI Specification으로 이어졌고, Swagger UI 같은 도구 이름은 별도로 남음
OpenAPIJSON SchemaOpenAPI는 전체 API 명세, JSON Schema는 데이터 구조. OpenAPI 3.1 schema는 JSON Schema 2020-12와 정렬됨
Spec-FirstCode-FirstSpec-First: spec → 코드. Code-First: 코드 → spec(추출). 신규 프로젝트는 Spec-First 권장
Consumer-DrivenProvider-DrivenCDC(Pact): consumer별 기대를 provider가 검증. PD(OpenAPI): provider spec을 구현이 검증. 혼용이 현실적
PactOpenAPIPact는 consumer 기대와 provider verification, OpenAPI는 provider가 발행한 정적 명세. 결합 사용 가능
Contract testIntegration testconsumer mock test만으로 provider는 미검증. provider verification은 격리 실행, 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: 호환성 유지하며 점진적 변경. 둘 다 필요
ResourceRepresentationresource는 식별되는 대상, representation은 특정 시점 또는 원하는 상태를 나타내는 정보
PUTPATCHPUT은 client-owned 표현 교체, PATCH는 합의한 patch format에 따른 부분 변경
Idempotency-KeyETag / If-Match전자는 같은 command의 중복 실행 방지, 후자는 오래된 representation 기반 갱신 거부
Backward compatibleForward compatibleold writer → new reader인가 vs new writer → old reader인가를 request/response 주체와 함께 명시

7. 설계 판단 질문 체크

  • 이 URI가 식별하는 resource와 response representation을 각각 한 문장으로 설명할 수 있는가?
  • consumer가 provider와 독립 배포되는가, 그렇다면 spec/code 중 무엇이 먼저 바뀌고 누가 리뷰하는가?
  • PUT의 누락 필드와 PATCH의 null이 보존·삭제·기본값 중 무엇을 뜻하는가?
  • timeout 뒤 같은 write를 재시도할 수 있는가, idempotency key의 범위와 보관 기간은 충분한가?
  • 동시 수정에서 last-write-wins를 허용하는가, 아니면 ETag/If-Match로 stale write를 거부하는가?
  • 정렬 필드가 같은 row에도 cursor 순서가 안정적인가, snapshot까지 보장해야 하는가?
  • 구 consumer가 unknown field와 enum을 어떻게 처리하며, 새 provider는 구 request를 계속 받는가?
  • OpenAPI/Pact가 구조 외에 보장하지 못하는 비즈니스 invariant를 어느 테스트가 맡는가?
  • 에러 type과 status만으로 retry·입력 수정·권한 요청을 결정할 수 있는가?
  • 인증된 caller나 tenant가 idempotency key와 resource scope에 포함되어 교차 노출을 막는가?

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

대응 원칙은 영향 버전을 되돌리고 새 필드 추가 + 옛 필드 유지 형태로 다시 진화시키는 것이다. 구체적인 gateway rollback 절차보다, 타입 교체가 아니라 병행 필드로 계약을 복구한다는 판단이 핵심이다.

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
Content-Type: application/json
{"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
Content-Type: application/problem+json
{"type": "https://api.example.com/errors/rate-limit-exceeded", "title": "Rate limit exceeded", "status": 429}

Retry-After: 30은 응답을 받은 뒤 30초를 뜻하며, client가 언제 재시도할지 정하는 안정된 기본 계약이다. client는 유효한 Retry-After 이전에는 같은 요청을 재시도하지 않고, 헤더가 없거나 잘못됐을 때 적용할 capped exponential backoff를 자체 계약으로 정해야 한다. 추가 quota 정보를 제공하려면 Internet-Draft 의존성을 별도로 받아들여야 한다. 이 경우 구현이 따르는 특정 draft 버전의 RateLimitRateLimit-Policy 구조·단위·갱신 시점을 OpenAPI와 client 문서에 고정하고, 이전 draft의 RateLimit-Limit·RateLimit-Remaining·RateLimit-Reset 예시나 epoch처럼 보이는 값을 섞지 않는다.

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를 삭제

8.7 timeout 뒤 중복 생성 — 멱등성 저장 경계가 끊김

섹션 제목: “8.7 timeout 뒤 중복 생성 — 멱등성 저장 경계가 끊김”

client에서 timeout과 retry가 늘어난 뒤 같은 orderId로 결제가 두 건 생기면 네트워크 오류가 원인이 아니라 idempotency 계약이 실패한 것이다. key가 process memory에만 있거나, key 선점과 업무 commit이 원자적이지 않거나, 보관 기간이 retry window보다 짧은지 확인한다. 단순히 client retry 횟수를 0으로 줄이면 일시 실패 복원력만 잃고 중복 원인은 남는다.

관측할 값은 (operation, caller)별 idempotency hit·conflict·in-flight 비율, key 만료 뒤 재사용, 같은 business identifier의 중복 생성 수다. 정상 retry는 같은 key와 같은 fingerprint로 이전 결과를 받아야 하고, 같은 key의 다른 fingerprint는 명시적인 conflict로 보여야 한다.

8.8 저장은 성공했는데 값이 되돌아감 — stale write

섹션 제목: “8.8 저장은 성공했는데 값이 되돌아감 — stale write”

두 client가 order-7을 읽은 뒤 A가 배송지를, B가 연락처를 수정했다고 하자. 두 요청이 전체 representation을 조건 없이 저장하면 나중 요청이 먼저 요청의 변경까지 덮을 수 있다. 사용자에게는 둘 다 200이므로 server 오류율은 0인데 값이 되돌아가는 silent failure가 된다.

ETag/If-Match를 쓰는 계약에서는 stale write가 412로 드러나야 한다. 412 비율이 급증하면 client가 최신 ETag를 보존하지 않거나, representation과 무관한 값까지 ETag를 바꾸거나, 한 자원에 충돌이 너무 집중된 신호다. 무조건 자동 retry하면 새 상태를 다시 덮으므로 merge 가능한 필드인지 먼저 판단한다.

8.9 목록의 중복·누락 — pagination 정렬이 불안정함

섹션 제목: “8.9 목록의 중복·누락 — pagination 정렬이 불안정함”

사용자가 무한 스크롤에서 같은 주문을 두 번 보거나 전체 export 건수가 실행마다 달라지면 cursor 문자열 유무보다 정렬 계약을 확인한다. cursor가 createdAt만 담아 동률 row의 순서가 흔들리거나, cursor 발급 뒤 filter가 달라지거나, 수정 가능한 필드를 정렬 키로 쓴 경우가 흔하다.

(createdAt, id) 같은 unique tie-breaker, cursor와 filter/sort의 binding, snapshot 필요 여부를 점검한다. offset을 cursor로 이름만 바꾸어 encode한 구현은 큰 offset 비용과 데이터 변동 문제를 그대로 가진다.

  • 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. 내가 직접 확인해볼 것 체크

  • **OpenAPI 3.1 spec 1개 작성**: `petstore.yaml` 흉내가 아니라 한 자원의 GET·PUT·PATCH + 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 consumer mock test + provider verification**: mock test로 Pact 생성 → Broker publish → 실제 provider build verify
  • **PUT/PATCH 경계 확인**: 누락 필드와 null을 각각 보내 representation이 교체·보존·삭제되는지 계약대로 검증
  • **`Idempotency-Key` 처리 구현**: 같은 key/fingerprint는 이전 응답, 같은 key/다른 fingerprint는 conflict, 동시 요청은 한 번만 commit
  • **ETag 동시 수정 실험**: 같은 ETag로 PATCH 두 번 → 첫 요청만 성공하고 둘째는 `412`인지 확인
  • **Cursor pagination 반례 재현**: 첫 페이지 뒤 row를 추가해 offset 중복과 stable cursor 결과를 비교
  • **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. 계약은 독립 변경을 가능하게 한다: resource의 정체성, representation의 구조와 의미, 실패와 변경 규칙을 network 경계에 고정한다.
  2. REST는 암기 규칙이 아니라 공통 어휘다: PUT은 client-owned 표현 교체, PATCH는 합의한 patch format의 부분 변경이며 command가 더 정확하면 RPC 형태도 가능하다.
  3. 재시도와 동시성은 다른 문제다: Idempotency-Key는 같은 command의 중복 실행을 막고, ETag/If-Match는 오래된 상태에 기반한 write를 거부한다.
  4. 페이지네이션도 계약이다: cursor에는 stable unique sort와 filter 조건을 묶고, snapshot 보장이 필요한지 별도로 결정한다.
  5. 스키마는 점진적으로 진화한다: backward/forward compatibility의 주체를 명시하고, optional 추가·unknown enum도 consumer 동작에 따라 breaking이 될 수 있음을 테스트한다.
  6. 명세와 계약 테스트에는 경계가 있다: OpenAPI conformance와 Pact provider verification은 wire 구조와 등록된 기대를 검증하지만 금액 계산·권한·상태 불변식까지 자동으로 보장하지 않는다.
  7. 실패도 안정된 모델로 표현한다: RFC 9457 type과 HTTP status가 client의 다음 행동을 결정하게 하고, versioning은 호환 진화가 불가능할 때 migration 기간을 제공한다.

참고 출처: 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 5789 PATCH Method (https://www.rfc-editor.org/rfc/rfc5789), RFC 7396 JSON Merge Patch (https://www.rfc-editor.org/rfc/rfc7396), RFC 6902 JSON Patch (https://www.rfc-editor.org/rfc/rfc6902), 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 Internet-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).