API 계약으로 안전하게 서비스 경계를 진화시키기
API 설계와 계약을 코드보다 먼저 다루면 서비스 간 통합 실패를 PR 단계에서 발견할 수 있다. OpenAPI, Contract Testing, Versioning, Idempotency, RFC 9457 에러 형식을 중심으로 안전하게 API를 바꾸는 기준을 정리한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
서비스 간 통신에서 가장 위험한 순간은 코드 자체는 통과했는데 통합 환경에서 깨지는 때다. consumer가 totalAmount를 integer라고 믿고 있었는데 provider가 string을 보내면, 그 차이는 작은 오타가 아니라 production 장애가 된다. API 설계와 계약을 코드의 1차 시민으로 두지 않으면 모든 변경은 어디까지 영향이 갈지 모른다는 두려움 위에서 진행된다. Spec-First와 Contract Testing의 목표는 이 두려움을 측정 가능한 안전망으로 바꾸는 것이다.
- 02
REST와 HTTP 의미론은 공통 언어를 제공하지만, 그것만으로 응답 JSON의 필수 필드, enum 값, 에러 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 Initiative로 넘어가고 OpenAPI 3.1.0이 공개된 흐름은 이 공백을 도구가 읽는 계약으로 메우려는 시도다.
- 03
이 문서의 핵심은 REST를 더 예쁘게 문서화하는 일이 아니다. OpenAPI는 HTTP API의 경로, 파라미터, 응답 스키마를 CI가 읽는 산출물로 만들고, Contract Testing은 그 산출물이 실제 provider 응답과 consumer 기대를 동시에 만족하는지 확인한다. 같은 원리는 Kafka와 SQS 이벤트에서는 AsyncAPI로, 내부 RPC에서는 Protobuf와 gRPC IDL로 옮겨간다. wire format이 팀 간 경계라면, 그 경계는 사람이 읽는 위키가 아니라 빌드가 실패시킬 수 있는 계약이어야 한다.
- 04
Spec-First 또는 Design-First 워크플로는 순서를 뒤집는 방식이다. 전통적인 Code-First에서는 코드를 먼저 짜고 Swagger 같은 도구가 spec을 뽑아낸다. 이 경우 ORM 컬럼명이 응답 필드로 새어 나가는 식으로 구현 디테일이 인터페이스를 오염시킬 수 있고, consumer 팀은 코드가 merge 된 뒤에야 API를 볼 수 있다. Spec-First에서는 spec이 코드의 일부가 아니라 코드보다 먼저 변경되는 1차 시민이다. PR에서 중요한 검증 단위도 단순 lint가 아니라 diff다. totalAmount를 integer에서 string으로 바꾸는 변경은 YAML 문법상 정상이어도 consumer 런타임에서는 breaking change가 될 수 있다.
- 05
다만 Spec-First가 언제나 정답은 아니다. 단일 팀 내부의 임시 admin API처럼 consumer가 하나이고 provider와 항상 동시에 배포되며, 1~2주 안에 버려질 엔드포인트라면 Code-First가 더 싸다. 반대로 모바일 앱, 외부 파트너, 다른 팀 서비스처럼 consumer가 독립 배포되는 순간에는 spec이 먼저 바뀌어야 한다. 판단 기준은 문서를 예쁘게 만들고 싶은가가 아니다. consumer가 provider 배포와 독립적으로 실패할 수 있는가가 기준이다. 독립 실패 가능성이 있다면 계약은 리뷰되고, diff되고, 테스트되어야 한다.
- 06
OpenAPI 스펙에서는 작은 필드들이 실제 운영 안정성과 연결된다. operationId는 코드 생성이나 SDK 생성 시 함수명으로 쓰이므로 getOrder, createOrder처럼 명확한 동사와 명사 규칙이 필요하다. schema와 response는 $ref로 재사용해 중복을 줄인다. required 배열은 필수 필드를 뜻하며 nullable: true와는 다르다. 필수지만 null을 허용하는 것과, 필드 자체가 없어도 되는 것은 별개의 개념이다. enum은 status 같은 closed set을 제한해 consumer 코드가 더 정확한 타입으로 받을 수 있게 한다.
- 07
REST 자원 모델링에서는 자원을 명사로 두고 동작은 HTTP method로 표현한다. 예를 들어 GET /orders는 자연스럽지만 GET /getOrders는 동사를 경로에 넣은 형태다. 계층 관계는 GET /orders/{id}/items처럼 URI 중첩으로 드러내고, 검색과 필터링은 query parameter로 표현한다. 액션도 가능하면 상태 전이로 모델링한다. POST /orders/{id}/cancellation은 cancellation 리소스를 만드는 의미가 되지만, POST /orders/{id}/cancel은 RPC에 가깝다. 비REST한 RPC가 자연스러우면 억지로 REST에 끼우기보다 POST /payments:refund 같은 RPC 스타일을 인정하는 편이 낫다.
- 08
HTTP method 의미론에서 멱등성은 retry safety의 기반이다. GET, HEAD, OPTIONS는 안전성과 멱등성을 가지며, PUT과 DELETE는 사이드 이펙트가 있어도 같은 요청을 여러 번 보냈을 때 최종 상태가 같다는 의미에서 멱등이다. PATCH는 설계에 따라 멱등하게 만들 수도 있지만 원칙적으로는 조심해야 하고, POST는 생성이나 비멱등 액션에 쓰인다. consumer가 timeout 때문에 응답을 받지 못하고 retry할 때 PUT과 DELETE는 비교적 안전하지만, POST는 중복 처리 위험이 있다. 그래서 결제와 주문 같은 작업에는 Idempotency-Key 헤더 패턴이 필요하다.
- 09
Versioning에는 URL path, Header, Media type, Query parameter 방식이 있다. 단일 정답은 없지만 실무 디폴트는 URL path다. GET /v1/orders에서 /v2/orders로 옮기는 방식은 브라우저, 캐시, 로드밸런서, 로그, CDK 어디서나 자연스럽게 드러나고 공개 API에 적합하다. 문서에는 Stripe, GitHub, AWS가 이 방식을 채택한다고 정리되어 있다. Header와 Media type은 내부 환경에서 granular per-client 제어가 필요할 때 유용하다. 한 client만 새 버전을 받게 할 수 있지만, 협상이 숨겨져 디버깅이 어려워진다. Query parameter 방식은 예외 케이스를 제외하면 권장되지 않는다.
- 10
Contract Testing은 크게 Provider-Driven과 Consumer-Driven으로 나뉜다. Provider-Driven은 OpenAPI, AsyncAPI, Protobuf 같은 공식 명세를 단일 진실로 두고, consumer는 SDK로 사용하며 conformance test가 provider를 검증한다. 공개 API에 자연스럽고 oasdiff, openapi-generator 같은 도구와 잘 맞지만 consumer의 실제 사용 패턴은 spec에 담기지 않을 수 있다. Consumer-Driven은 Pact처럼 각 consumer가 내가 이렇게 부르면 이런 응답을 기대한다는 Pact 파일을 만들고 provider가 이를 검증한다. 실제 사용 패턴을 잡는 데 강하지만 모든 consumer를 알 수 있는 내부 환경에 더 적합하다.
- 11
현실적인 권장은 OpenAPI Bi-Directional을 기본으로 두고, 장애 비용이 큰 핵심 통합에만 Pact를 추가하는 것이다. 공개 API처럼 consumer 목록을 통제할 수 없으면 provider가 발행한 OpenAPI가 기준이 된다. 사내 MSA에서 shipping-service가 order-service의 어떤 응답만 쓰는지 알 수 있다면 Pact가 실제 사용 패턴을 더 잘 잡는다. OpenAPI만 쓰면 spec에는 있지만 아무도 안 쓰는 필드를 오래 끌고 갈 수 있고, Pact만 쓰면 아직 등록되지 않은 새 consumer를 놓칠 수 있다. 그래서 외부나 불특정 consumer는 OpenAPI conformance를 기본으로, 매출, 결제, 배송처럼 장애 비용이 큰 내부 연동은 Pact Broker의 can-i-deploy를 배포 gate로 둔다.
- 12
Idempotency-Key는 POST의 비멱등성을 운영 멱등성으로 끌어올리는 패턴이다. 서버는 Idempotency-Key를 키로 Redis나 DB에서 이전 요청 결과를 조회하고, 이미 있으면 저장된 응답을 그대로 반환한다. 없으면 처리한 뒤 결과를 일정 시간 보관한다. 문서에서는 24시간 정도 보관한다고 설명한다. 중요한 함정은 같은 키가 항상 같은 요청이라는 착각이다. buggy client가 같은 Idempotency-Key로 amount만 바꿔 재시도하면, 서버가 키만 보고 이전 응답을 반환해 잘못된 결제가 조용히 확정될 수 있다. 저장 값은 key, request body hash, response, status, expiresAt이어야 하고, 같은 키에 다른 body hash가 오면 409 Conflict 또는 422 Unprocessable Content를 application/problem+json으로 반환해야 한다.
- 13
에러 응답은 RFC 9457 Problem Details로 통일하는 것이 핵심이다. API마다 response.error, response.message, response.errors[0].detail처럼 형식이 다르면 consumer 코드는 분기로 가득해진다. RFC 9457은 RFC 7807을 대체한 공통 형식으로, HTTP status code만으로 부족한 API-specific detail을 application/problem+json body에 담는다. type은 URI 형태의 에러 식별자이고, title은 사람이 읽는 짧은 요약이며, status는 HTTP status와 일치해야 한다. detail은 인스턴스별 메시지, instance는 어느 요청에서 발생했는지를 나타낸다. Content-Type: application/problem+json은 consumer SDK가 공통 에러 처리 로직으로 분기할 수 있게 하는 핵심 시그널이다.
- 14
큰 컬렉션 응답에서는 pagination, filtering, sorting의 선택도 계약의 일부다. Cursor-based pagination은 불투명한 토큰을 사용하며, 문서에서는 BASE64로 인코딩된 마지막 row의 정렬 키를 예로 든다. row가 추가되거나 삭제되는 중에도 일관된 페이지를 유지하기 좋지만 임의 페이지 점프는 어렵다. Offset-based pagination은 직관적이지만 데이터 변동 중 페이지 중복이나 누락이 생길 수 있고, 큰 offset에서는 DB 부담이 커진다. Filtering과 Sorting은 status, from, sort 같은 query string으로 표현할 수 있다. 필터가 복잡해지면 GraphQL이나 자체 query DSL을 고민할 시점이다.
- 15
Backward Compatibility에서 안전한 변경과 breaking change를 구분해야 한다. optional 필드 추가는 대체로 안전하지만 required 필드 추가, 필드 제거, 필드 타입 변경, enum 값 제거, 에러 응답 형식 변경, URL path 변경, HTTP method 변경, 기본값 변경은 breaking change로 봐야 한다. enum 값 추가는 상황별이다. consumer가 unknown을 처리할 수 있으면 안전하지만 strict 타입으로 받아 throw한다면 breaking이다. 폐기는 HTTP Deprecation 헤더와 Sunset 헤더로 예고한다. Deprecation은 리소스가 deprecated 되었거나 될 예정임을 알리는 hint이고 동작 자체를 바꾸지는 않는다. Sunset은 특정 시점 이후 응답하지 않을 가능성을 알린다.
- 16
운영 함정은 대개 계약이 도구로 강제되지 않을 때 나타난다. 필드 타입을 number에서 string으로 슬쩍 바꾸는 silent breaking change는 lint만으로 잡히지 않으므로 CI에 oasdiff가 필요하다. 200 OK와 body 안의 에러는 consumer, 로드밸런서, 모니터링 도구가 모두 정상으로 인식하게 만들어 5xx 메트릭은 0인데 실제 장애가 나는 상황을 만든다. Rate limiting에서는 429 Too Many Requests와 Retry-After: 30 헤더가 표준이다. OpenAPI spec과 코드가 drift되면 consumer가 spec에 없는 필드에 의존할 수 있으므로 provider 측 spec conformance test가 필요하다. Pact Broker의 verification 실패가 쌓이는데도 머지된다면 can-i-deploy 같은 deploy gate로 신호를 실제 차단 조건으로 만들어야 한다. 정리하면, spec은 코드보다 먼저 바뀌고, HTTP 의미론은 거짓말하지 않으며, breaking change는 diff와 계약 테스트가 PR 단계에서 막아야 한다.
같은 레이어
L9에서 이어 듣기
- 설계 원칙을 운영 가능한 코드로 잇기 길이 미정
- Clean Architecture의 의존성 규칙 길이 미정
- DDD 기본기: 도메인 언어와 경계 설계 길이 미정
- Twelve-Factor App 운영 원칙 길이 미정
- CAP과 일관성으로 보는 분산 시스템 선택 길이 미정
- MSA 패턴, 분리의 이득과 운영 비용 길이 미정
- Saga Pattern: 로컬 커밋과 역순 보상 길이 미정
- CQRS와 이벤트 소싱의 운영 경계 길이 미정
- TDD와 테스트 피라미드로 설계하는 테스트 전략 길이 미정
- 대규모 웹 크롤러의 큐, 정중함, 중복 제거 길이 미정
- URL Shortener와 Rate Limiter로 보는 시스템 디자인 길이 미정