자원은 명사(복수형)로
좋은 예: `GET /orders`
나쁜 예: `GET /getOrders`분류: 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가 서로의 배포 시각과 내부 구현을 몰라도 같은 의미로 통신하게 만드는 것이다.
단일 프로세스 안의 함수 호출은 컴파일러와 같은 커밋이 호출자와 구현을 함께 바꾸도록 돕는다. 네트워크 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.
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(네트워크를 지나가는 데이터 형식)과 동작 의미가 팀 간 경계라면, 그 경계는 사람이 읽는 위키에만 있지 않고 빌드를 실패시킬 수 있어야 한다. 다만 자동 검증 가능한 문법과 실제 비즈니스 의미의 경계는 뒤에서 구분한다.
전통 방식은 코드를 먼저 짜고 Swagger 같은 도구가 코드에서 spec을 뽑아주는 형태였다. 이 방향이 어색한 이유:
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 일정 전까지 유지**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.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 배열은 필드의 존재 여부 를 정한다. OpenAPI 3.1에서 null 허용은 type: [string, "null"]처럼 표현하므로 “필수지만 null 허용”과 “필드 자체가 없음”은 별개다.enum으로 status 같은 closed set을 제한 → consumer 코드가 정확한 타입으로 받음.이 명세가 보장할 수 있는 것은 200 응답에 id가 있고 totalAmount가 0 이상의 정수라는 구조다. ORD-1의 합계가 실제 품목 합계와 같은지, 취소된 주문을 배송 상태로 바꾸지 않는지, 요청한 사용자가 그 주문의 소유자인지는 애플리케이션의 invariant(불변식)와 권한 규칙이다. 이는 단위·통합·End-to-End(E2E, 종단 간) 테스트가 별도로 검증해야 한다. 명세가 통과했다는 말은 메시지 모양이 계약과 맞는다는 뜻이지, 계산과 정책이 옳다는 뜻이 아니다.
**자원(resource)**은 API가 식별하고 수명주기를 관리하는 도메인 대상이다. **표현(representation)**은 어떤 시점의 상태 또는 요청 뒤 원하는 상태를 JSON 같은 형식으로 나타낸 정보다. /orders/ORD-1은 주문을 식별하고, 아래 두 GET 응답은 조회 시점의 같은 주문을 서로 다른 media type으로 나타낸 current representation이다.
GET /orders/ORD-1Accept: application/json
HTTP/1.1 200 OKContent-Type: application/json
{"id":"ORD-1","status":"CONFIRMED","totalAmount":19900}GET /orders/ORD-1Accept: text/csv
HTTP/1.1 200 OKContent-Type: text/csv
id,status,totalAmountORD-1,CONFIRMED,19900이 구분이 중요한 이유는 API를 테이블 CRUD(Create, Read, Update, Delete)에 그대로 맞추면 DB 컬럼과 관계가 외부 계약이 되기 때문이다. 주문 저장소가 여러 테이블로 분리되어도 client에게는 하나의 주문 자원일 수 있고, DB에 order_items 테이블이 있다고 반드시 /order-items 최상위 자원을 노출할 필요도 없다. 자원의 경계는 client가 어떤 대상을 독립적으로 식별하고 어떤 상태 전이를 요청하는가로 정한다.
좋은 예: `GET /orders`
나쁜 예: `GET /getOrders`좋은 예: `POST /orders` (생성)
나쁜 예: `POST /createOrder`좋은 예: `GET /orders/{id}/items`
나쁜 예: `GET /orderItems?orderId=...`좋은 예: `POST /orders/{id}/cancellation` (cancellation 리소스 생성)
나쁜 예: `POST /orders/{id}/cancel`좋은 예: `GET /orders?status=PENDING&from=2026-05-01`
나쁜 예: `GET /orders/status/PENDING`좋은 예: `POST /payments:refund` (Google 스타일)
나쁜 예: REST 억지로 끼우기위 표는 절대 규칙이 아니라 기본 어휘다. POST /orders/{id}/cancel이 언제나 잘못이고 cancellation 자원을 만드는 방식이 언제나 옳은 것은 아니다. 취소 자체에 식별자·승인 상태·환불 진행처럼 별도 수명주기가 있으면 /cancellations 자원이 유용하다. 단순 command 하나이고 RPC가 도메인에 더 정확하면 POST /orders/{id}:cancel처럼 의도를 드러내는 편이 억지 자원보다 낫다. 판단 기준은 URI가 명사인지가 아니라 재시도, 권한, 상태 전이, 응답 의미를 client가 예측할 수 있는가다.
HTTP method 의미론:
| Method | 멱등성 | 안전성 | 의미 |
|---|---|---|---|
| GET | O | O | 조회 (상태 변경을 요청하지 않음) |
| HEAD | O | O | 메타데이터만 조회 |
| OPTIONS | O | O | Cross-Origin Resource Sharing(CORS, 교차 출처 리소스 공유) preflight, 허용 메서드 |
| PUT | O | X | 전체 교체 (멱등) |
| DELETE | O | X | 삭제 (멱등 — 두 번 호출해도 같은 상태) |
| PATCH | X(원칙) / O(설계로) | X | 부분 수정 |
| POST | X | X | 생성·비멱등 액션 |
**안전한 메서드(safe method)**는 client가 target resource의 상태 변경을 요청하지 않는 메서드다. “아무 부수 효과도 없다”는 뜻은 아니다. GET 처리 중 access log, 통계, 광고 과금 같은 부수 효과가 생겨도 client가 그 상태 변경을 요청한 것이 아니므로 safe 의미를 어기지 않는다. 다만 GET으로 주문 취소처럼 target resource를 바꾸게 하면 safe 계약 위반이다.
**멱등성(idempotency)**은 같은 의도의 요청을 한 번 수행하든 여러 번 수행하든 서버의 의도된 최종 상태가 같다는 성질이다. 이는 응답이 매번 같은 status라는 뜻이 아니다. 첫 DELETE /orders/1이 204, 두 번째가 404여도 주문이 삭제된 최종 상태는 같다. 또한 메트릭이나 감사 로그가 두 줄 쌓일 수 있어도 자원 상태가 한 번 삭제되는 의미는 유지된다.
멱등성은 retry safety(응답 유실 뒤 재시도해도 중복 효과가 생기지 않는 성질)의 기반이다. 다만 PUT/DELETE라는 메서드 이름만 붙인다고 구현이 자동으로 멱등해지지는 않는다. PUT /accounts/1/balance가 body의 값으로 잔액을 교체하면 멱등이지만, body의 금액을 현재 잔액에 더하도록 구현하면 PUT 의미를 어긴 비멱등 API다. POST에는 중복 처리 위험을 제어하는 Idempotency-Key 패턴(§3.7)이 필요할 수 있다.
PUT 요청 body는 대상 URI에 적용하려는 desired representation을 나타내며, server는 이를 반영해 현재 상태를 교체한다. 같은 완전한 body를 반복 전송하면 최종 상태가 같으므로 멱등이다. 누락 필드를 무시하는 “사실상 부분 수정 PUT”은 client가 전체 표현을 보냈다고 믿을지, 일부만 보냈다고 믿을지 계약을 흐린다.
PUT /v1/users/U-1Content-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-1Content-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 path | GET /v1/orders → /v2/orders | 최고 | 최고 | 최고 | 공개 API · 외부 클라이언트 |
| Header | X-API-Version: 2 | 낮음 | 어려움 | custom | 내부 API · per-client granular |
| Media type | Accept: application/vnd.example.v2+json | 낮음 | 어려움 | RESTful | Hypermedia as the Engine of Application State(HATEOAS, 애플리케이션 상태 엔진으로서의 하이퍼미디어) · 콘텐츠 협상이 본질일 때 |
| Query param | GET /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 OKDeprecation: @1782950399Content-Type: application/json
{ "totalAmount": 19900, "money": {"currency":"KRW","minorUnits":19900}}money를 추가하고 두 필드를 함께 쓴다./v2에서 money를 required로 만든다.totalAmount는 Deprecation과 Sunset 기간 뒤 제거한다.반례는 /v2를 배포하자마자 /v1을 redirect하는 것이다. POST의 301/302 처리와 인증 헤더 전달은 client마다 다를 수 있고, 새 응답 schema도 구 client가 이해하지 못한다. 버전은 redirect 장치가 아니라 동시에 유지되는 계약과 migration 계획이다.
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 합계와 일치하는가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/paymentsIdempotency-Key: "8a3b6c1e-..." ← 클라이언트가 생성한 Universally Unique Identifier(UUID, 범용 고유 식별자) 또는 충분히 랜덤한 문자열Content-Type: application/json
{"orderId": "ORD-1", "amount": 19900}서버 측:
(caller, operation, Idempotency-Key)를 고유 키로 Redis/DB에서 이전 요청 상태를 조회한다.409 또는 완료 대기를 선택한다.보관 기간은 무조건 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 Content를 application/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처럼 의미 없는 값은 제외한다.
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 저장 모델 안에서 검증할 항목이다.
**에러 모델(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 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는 이 타입을 보면 공통 에러 처리 로직 으로 분기.
공통 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/504 | server 또는 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 OKContent-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 DESCLIMIT 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에 재사용하면 순서 기준 자체가 달라지기 때문이다.
**동시성 제어(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 OKETag: "order-7"Content-Type: application/json
{"id":"ORD-1","shippingAddress":"Seoul","status":"CONFIRMED"}PATCH /v1/orders/ORD-1If-Match: "order-7"Content-Type: application/merge-patch+json
{"shippingAddress":"Busan"}다른 client가 먼저 수정해 현재 ETag가 "order-8"이 되었다면 server는 patch를 적용하지 않는다.
HTTP/1.1 412 Precondition FailedContent-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 compatibility | old writer → new reader | old provider → new consumer | old consumer → new provider |
| Forward compatibility | new writer → old reader | new provider → old consumer | new 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) | 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 후 변경 |
“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 OKDeprecation: @1782950399Sunset: Thu, 01 Oct 2026 23:59:59 GMTLink: </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 종류와 배포 주기를 근거로 정하고, 헤더만 보낸 채 실제 사용률을 확인하지 않는 상태를 피하는 것이 중요하다.
계약 강도는 API의 모양보다 consumer가 독립적으로 실패할 가능성과 실패 비용에 맞춘다.
| 상황 | 기본 선택 | 추가 보호가 필요한 신호 |
|---|---|---|
| 단일 팀의 짧게 쓰는 admin API | Code-First + 생성 OpenAPI | consumer 분리, 수명 연장, 모바일 client 등장 |
| 불특정 외부 consumer가 쓰는 공개 API | Spec-First OpenAPI + conformance + URL versioning | 결제·주문 POST에는 idempotency와 긴 deprecation |
| 배포가 분리된 사내 서비스 | OpenAPI diff + provider verification | 장애 비용이 크면 Pact와 can-i-deploy gate |
| 여러 사용자가 수정하는 핵심 자원 | ETag + If-Match optimistic concurrency | 충돌이 빈번하면 command/merge 모델 재설계 |
| 쓰기가 잦고 큰 collection | stable sort + cursor pagination | snapshot 필요 시 snapshot version 추가 |
| 화면용 작은 검색 결과와 페이지 점프 | offset pagination | offset 증가 또는 중복·누락 관측 시 cursor 전환 |
| 내부 고성능 RPC | Protobuf/gRPC IDL | 외부 HTTP gateway와 변환 계약도 함께 검증 |
| Kafka/SQS/RabbitMQ event | AsyncAPI 또는 별도 event schema registry | producer/consumer 독립 배포에 compatibility gate |
같은 조직에서도 모든 API에 Pact Broker, 새 version, cursor, ETag를 동시에 도입할 필요는 없다. 계약 수단 자체의 운영 비용도 있으므로 독립 배포 가능성, 데이터 손실 비용, consumer 수, 변경 빈도, 관측 가능한 실패 이력으로 선택한다.
429).| A | B | 차이점 |
|---|---|---|
| OpenAPI | Swagger | Swagger 2.0 명세가 OpenAPI Specification으로 이어졌고, Swagger UI 같은 도구 이름은 별도로 남음 |
| OpenAPI | JSON Schema | OpenAPI는 전체 API 명세, JSON Schema는 데이터 구조. OpenAPI 3.1 schema는 JSON Schema 2020-12와 정렬됨 |
| Spec-First | Code-First | Spec-First: spec → 코드. Code-First: 코드 → spec(추출). 신규 프로젝트는 Spec-First 권장 |
| Consumer-Driven | Provider-Driven | CDC(Pact): consumer별 기대를 provider가 검증. PD(OpenAPI): provider spec을 구현이 검증. 혼용이 현실적 |
| Pact | OpenAPI | Pact는 consumer 기대와 provider verification, OpenAPI는 provider가 발행한 정적 명세. 결합 사용 가능 |
| Contract test | Integration test | consumer mock test만으로 provider는 미검증. provider verification은 격리 실행, 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: 호환성 유지하며 점진적 변경. 둘 다 필요 |
| Resource | Representation | resource는 식별되는 대상, representation은 특정 시점 또는 원하는 상태를 나타내는 정보 |
| PUT | PATCH | PUT은 client-owned 표현 교체, PATCH는 합의한 patch format에 따른 부분 변경 |
| Idempotency-Key | ETag / If-Match | 전자는 같은 command의 중복 실행 방지, 후자는 오래된 representation 기반 갱신 거부 |
| Backward compatible | Forward compatible | old writer → new reader인가 vs new writer → old reader인가를 request/response 주체와 함께 명시 |
가장 자주 일어나는 사고. 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대응 원칙은 영향 버전을 되돌리고 새 필드 추가 + 옛 필드 유지 형태로 다시 진화시키는 것이다. 구체적인 gateway rollback 절차보다, 타입 교체가 아니라 병행 필드로 계약을 복구한다는 판단이 핵심이다.
provider가 status에 REFUNDED를 추가했다. spec 상으론 필드 추가가 아니라 값 추가 라 자동 도구가 안 잡는다.
해결: 합의 자체를 명시적으로. consumer가 unknown enum을 어떻게 다루는지 가 spec의 문서화된 약속 이어야 함. OpenAPI 3.1 oneOf + discriminator로 더 안전한 union 표현 가능.
200 OK + body에 에러 — HTTP status 거짓말HTTP/1.1 200 OKContent-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 RequestsRetry-After: 30Content-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 버전의 RateLimit과 RateLimit-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 실패.
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를 삭제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로 보여야 한다.
두 client가 order-7을 읽은 뒤 A가 배송지를, B가 연락처를 수정했다고 하자. 두 요청이 전체 representation을 조건 없이 저장하면 나중 요청이 먼저 요청의 변경까지 덮을 수 있다. 사용자에게는 둘 다 200이므로 server 오류율은 0인데 값이 되돌아가는 silent failure가 된다.
ETag/If-Match를 쓰는 계약에서는 stale write가 412로 드러나야 한다. 412 비율이 급증하면 client가 최신 ETag를 보존하지 않거나, representation과 무관한 값까지 ETag를 바꾸거나, 한 자원에 충돌이 너무 집중된 신호다. 무조건 자동 retry하면 새 상태를 다시 덮으므로 merge 가능한 필드인지 먼저 판단한다.
사용자가 무한 스크롤에서 같은 주문을 두 번 보거나 전체 export 건수가 실행마다 달라지면 cursor 문자열 유무보다 정렬 계약을 확인한다. cursor가 createdAt만 담아 동률 row의 순서가 흔들리거나, cursor 발급 뒤 filter가 달라지거나, 수정 가능한 필드를 정렬 키로 쓴 경우가 흔하다.
(createdAt, id) 같은 unique tie-breaker, cursor와 filter/sort의 binding, snapshot 필요 여부를 점검한다. offset을 cursor로 이름만 바꾸어 encode한 구현은 큰 offset 비용과 데이터 변동 문제를 그대로 가진다.
Idempotency-Key는 같은 command의 중복 실행을 막고, ETag/If-Match는 오래된 상태에 기반한 write를 거부한다.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).