← 목록으로 돌아가기

API 계약 테스트 실전: Consumer Driven Contract로 프론트엔드와 백엔드 배포 충돌 줄이기

Backend

Consumer Driven Contract 기반 API 계약 테스트

통합 테스트만으로는 배포 타이밍을 막기 어렵다

프론트엔드와 백엔드가 독립적으로 배포되는 팀에서는 API 변경이 가장 흔한 장애 원인 중 하나입니다. 백엔드는 응답 필드를 optional로 바꿨다고 생각하지만 프론트엔드는 항상 존재한다고 가정하고, 프론트엔드는 새 query parameter를 보내기 시작했지만 백엔드는 오래된 버전에서 이를 잘못 처리할 수 있습니다. 스테이징 통합 테스트가 있어도 실제 배포 순서와 feature flag 조합을 모두 재현하기는 어렵습니다.

API 계약 테스트는 이 문제를 더 작은 단위에서 다룹니다. 핵심은 "서버가 이런 구현을 갖는다"가 아니라 "소비자가 이 요청과 응답 형태를 필요로 한다"는 계약을 명시하는 것입니다. Consumer Driven Contract는 프론트엔드, 모바일 앱, 다른 서비스 같은 소비자가 기대하는 요청/응답을 계약으로 만들고, 제공자인 백엔드가 그 계약을 계속 만족하는지 검증합니다.


1. 계약은 OpenAPI 문서와 다르다

OpenAPI는 API 전체 명세를 표현하는 데 강합니다. 엔드포인트, 스키마, 인증, 예시, 문서화를 한곳에 모을 수 있습니다. 하지만 실제 소비자가 사용하는 조합을 모두 보장하지는 않습니다. 스키마상 optional인 필드가 특정 화면에서는 사실상 필수일 수 있고, enum 값이 추가되었을 때 프론트엔드가 fallback을 갖고 있는지도 문서만으로는 알기 어렵습니다.

계약 테스트는 사용 시나리오 중심입니다. 예를 들어 주문 상세 화면은 GET /orders/:id에서 주문 상태, 결제 금액, 배송 주소, 취소 가능 여부를 필요로 합니다. 계약은 이 화면이 보내는 헤더와 query, 기대하는 상태 코드, 응답 payload의 핵심 필드를 고정합니다. 백엔드가 내부 구현을 바꿔도 이 계약을 만족하면 소비자 관점에서는 안전합니다.

OpenAPI와 계약 테스트는 경쟁 관계가 아닙니다. OpenAPI는 공개된 전체 인터페이스를 설명하고, 계약 테스트는 실제 소비자별 기대를 검증합니다. OpenAPI에서 생성한 타입을 프론트엔드에 쓰더라도 런타임 응답이 그 타입을 지키는지, 특정 화면에 필요한 값이 빠지지 않는지는 별도 검증이 필요합니다.


2. 소비자 계약은 작고 구체적이어야 한다

좋은 계약은 모든 필드를 검증하지 않습니다. 소비자가 실제로 의존하는 필드와 의미만 검증합니다. 모든 응답 필드를 고정하면 백엔드가 필드를 추가하거나 내부 표현을 개선할 때 계약이 불필요하게 깨집니다. 반대로 너무 느슨하면 장애를 막지 못합니다. 기준은 "이 값이 바뀌거나 사라지면 소비자 화면이 깨지는가"입니다.

계약 이름도 중요합니다. order api test보다 checkout page can render paid order summary처럼 소비자 시나리오를 드러내는 이름이 낫습니다. 실패했을 때 백엔드 개발자가 어떤 화면과 사용자 흐름이 영향을 받는지 바로 이해할 수 있습니다. 계약에는 예외 케이스도 포함합니다. 빈 목록, 권한 없음, 만료된 토큰, validation 실패, 부분 성공처럼 실제 사용자가 만나는 상태가 중요합니다.

테스트 데이터는 안정적이어야 합니다. 랜덤 값이나 현재 시간에 과도하게 의존하면 계약이 자주 흔들립니다. matchers를 사용해 "문자열이어야 한다", "ISO 날짜여야 한다", "enum 중 하나여야 한다"처럼 형태를 검증하고, 비즈니스 의미가 중요한 값만 구체적으로 고정합니다.


3. 배포 파이프라인에 넣어야 효과가 있다

계약 테스트는 로컬에서만 돌리면 효과가 제한됩니다. 소비자 저장소에서 계약을 생성하고, 계약 broker나 artifact 저장소에 게시한 뒤, 제공자 저장소의 CI에서 최신 계약을 검증해야 합니다. 백엔드 PR이 기존 소비자 계약을 깨면 머지 전에 알 수 있어야 합니다. 반대로 프론트엔드가 새 계약을 추가할 때는 백엔드가 그 계약을 만족하는 버전이 배포될 때까지 feature flag나 호환 코드를 유지해야 합니다.

배포 순서도 계약 결과를 활용할 수 있습니다. 제공자가 새 계약을 만족하지 못하면 소비자는 해당 기능을 켜지 않습니다. 제공자가 새 필드를 먼저 배포하고, 소비자가 나중에 사용하기 시작하는 expand 단계가 안전합니다. 제거는 더 조심해야 합니다. 더 이상 어떤 소비자 계약도 해당 필드에 의존하지 않는다는 것을 확인한 뒤 contract 단계에서 제거합니다.

CI 비용을 줄이려면 변경 영향 범위를 계산합니다. 모든 PR에서 모든 소비자 계약을 검증하면 느려질 수 있습니다. 변경된 엔드포인트나 스키마와 관련된 계약만 우선 실행하고, main 브랜치에서는 전체 검증을 돌리는 식으로 조정할 수 있습니다. 중요한 것은 계약 실패가 무시되지 않는 것입니다.


4. 계약 실패는 비난이 아니라 협업 신호다

계약 테스트가 실패하면 백엔드가 잘못했다는 뜻이 아닐 수 있습니다. 소비자가 과도하게 내부 필드에 의존하고 있을 수도 있고, 오래된 화면이 더 이상 의미 없는 계약을 붙잡고 있을 수도 있습니다. 실패는 양쪽이 API 변경 의도를 확인해야 한다는 신호입니다. 그래서 계약에는 소유 팀, 관련 화면, 제거 예정일 같은 메타데이터가 있으면 좋습니다.

계약 유지보수도 필요합니다. 더 이상 사용하지 않는 화면의 계약은 제거해야 하고, feature flag가 완전히 제거되면 실험용 계약도 정리해야 합니다. 계약이 쌓이기만 하면 백엔드 변경 속도를 막는 비용이 됩니다. 분기마다 오래된 계약을 점검하고 실제 트래픽이나 코드 참조와 비교하는 운영 절차가 필요합니다.

프론트엔드에서는 계약 테스트와 MSW 같은 mock을 연결하면 효과가 큽니다. 화면 테스트에서 사용하는 mock 응답이 실제 계약과 같아지기 때문입니다. 테스트용 fixture가 API 현실과 멀어지는 문제를 줄일 수 있습니다. 백엔드에서는 계약을 만족하는 provider state를 명확히 만들고, 데이터베이스 전체를 띄우지 않아도 검증 가능한 경량 경로를 마련하는 것이 좋습니다.


5. 호환 가능한 변경과 깨지는 변경을 구분한다

API 변경을 안전하게 하려면 어떤 변경이 호환 가능한지 팀이 같은 기준을 가져야 합니다. 응답에 새 optional 필드를 추가하는 것은 대체로 안전하지만, 기존 필드의 타입을 바꾸거나 의미를 바꾸는 것은 깨지는 변경입니다. enum 값을 추가하는 것도 소비자가 exhaustive switch를 사용한다면 깨질 수 있습니다. optional 필드를 required로 바꾸는 변경, 날짜 포맷 변경, 정렬 기본값 변경, 오류 code 변경도 실제 화면에는 큰 영향을 줄 수 있습니다.

계약 테스트는 이 기준을 자동화하는 데 도움을 줍니다. 소비자가 의존하는 필드를 명시하면 제공자는 "문서상 괜찮다"가 아니라 "현재 소비자가 실제로 괜찮다"를 검증할 수 있습니다. 반대로 소비자는 필요 이상으로 많은 필드를 계약에 넣지 않아야 합니다. 계약이 너무 넓으면 백엔드의 내부 개선이 모두 깨지는 변경처럼 보입니다.

가장 안전한 변경 방식은 expand and contract입니다. 먼저 백엔드가 새 필드나 새 엔드포인트를 추가하고, 프론트엔드가 양쪽을 모두 처리할 수 있게 배포합니다. 충분히 전환된 뒤 오래된 필드를 더 이상 쓰지 않는다는 계약 검증 결과를 확인하고 제거합니다. 이 절차는 느려 보이지만 장애 대응 비용을 줄입니다. 특히 모바일 앱처럼 오래된 클라이언트가 남는 환경에서는 필수에 가깝습니다.


6. 오류 응답도 계약의 일부다

많은 팀이 성공 응답만 계약으로 검증합니다. 하지만 실제 사용자 경험은 실패 응답에서 더 자주 깨집니다. 백엔드가 validation 오류를 400으로 반환하는지, 오류 code가 필드별로 구분되는지, 인증 만료가 401인지 403인지, 재시도 가능한 장애인지 아닌지에 따라 프론트엔드 동작이 달라집니다. 오류 응답 구조가 바뀌면 사용자는 부정확한 메시지를 보거나 같은 요청을 반복하게 됩니다.

오류 계약에는 status code, machine-readable code, 사용자에게 노출 가능한 메시지 여부, field errors, requestId를 포함하는 것이 좋습니다. 메시지 원문은 지역화 정책에 따라 서버가 줄 수도 있고 클라이언트가 code를 번역할 수도 있습니다. 어떤 방식을 선택하든 계약으로 고정해야 합니다. 그렇지 않으면 한쪽은 메시지를 직접 노출한다고 생각하고, 다른 한쪽은 내부 디버그 문자열을 내려보낼 수 있습니다.

재시도 정책도 계약에 포함될 수 있습니다. 결제나 주문처럼 중복 실행이 위험한 요청은 idempotency key와 재시도 가능 오류를 명확히 나눠야 합니다. 429503에는 Retry-After 헤더가 있는지, 네트워크 오류와 비즈니스 거절을 어떻게 구분하는지 정하면 프론트엔드가 안정적으로 사용자 안내를 할 수 있습니다.


7. 계약 테스트는 타입 생성과 런타임 검증을 보완한다

TypeScript 타입 생성은 개발 경험을 크게 개선하지만, 런타임 API가 실제로 그 타입을 지킨다는 보장은 아닙니다. 빌드 시점 타입은 서버 응답이 배포 후 바뀌거나, 프록시가 오류 HTML을 반환하거나, 일부 필드가 데이터 마이그레이션 중 null로 내려오는 상황을 막지 못합니다. 계약 테스트는 제공자가 소비자 기대를 만족하는지 검증하고, 런타임 검증은 클라이언트가 예상 밖 응답을 받았을 때 안전하게 실패하도록 돕습니다.

프론트엔드에서는 Zod 같은 스키마로 핵심 API 응답을 검증할 수 있습니다. 모든 응답을 완벽히 검증하면 비용이 커질 수 있으므로, 결제·인증·권한·데이터 손실 위험이 있는 흐름부터 적용합니다. 검증 실패는 사용자에게 일반 오류를 보여주되, 내부 로그에는 endpoint, schema version, 누락 필드, requestId를 남깁니다. 이 신호가 계약 테스트 누락을 찾는 단서가 됩니다.

백엔드에서는 contract fixture가 실제 serializer와 같은 경로를 거치도록 해야 합니다. 테스트 전용 객체를 직접 만들어 응답하면 실제 API와 괴리가 생깁니다. 가능하면 controller나 handler 수준에서 검증하고, provider state만 테스트에 맞게 준비합니다. 그래야 인증, 직렬화, null 처리, 날짜 포맷 같은 실제 경로의 문제가 드러납니다.


8. 도입은 핵심 경계부터 시작한다

계약 테스트는 모든 API에 한 번에 적용할 필요가 없습니다. 먼저 배포 충돌 비용이 큰 경계를 고릅니다. 결제, 인증, 주문, 권한, 외부 파트너 연동처럼 실패했을 때 사용자 피해가 크거나 롤백이 어려운 API가 좋은 시작점입니다. 내부 관리자 화면의 단순 목록 조회보다 공개 사용자 흐름의 상태 변경 API가 우선입니다.

도입 초기에 중요한 것은 도구보다 소유권입니다. 소비자 계약을 누가 작성하고, 제공자 실패를 누가 확인하며, 계약 제거는 어떤 기준으로 할지 정해야 합니다. 계약이 실패했는데 담당자가 없으면 CI는 곧 무시됩니다. 반대로 담당자가 명확하면 실패는 빠른 대화로 이어집니다. 계약 테스트는 기술 장치이면서 팀 사이의 협업 규칙입니다.

첫 단계에서는 성공 케이스 하나와 대표 실패 케이스 하나면 충분합니다. 예를 들어 주문 생성 API라면 정상 생성, 재고 부족, 인증 만료를 계약으로 잡습니다. 이후 실제 장애나 변경 이력이 생길 때 계약을 추가합니다. 과거에 깨진 API 경계는 미래에도 다시 깨질 가능성이 높습니다. 장애 회고에서 "이 문제를 막을 계약은 무엇이었나"를 묻는 방식이 실용적입니다.


9. 검증 노트: 계약 테스트의 책임 경계를 명확히 한다

Pact 공식 문서는 consumer가 provider API에 대한 가정과 요구를 테스트로 작성하고, 그 결과 생성된 contract를 provider가 검증하는 흐름을 설명합니다. 이 글의 핵심도 같은 방향입니다. 계약 테스트는 provider 구현 전체를 검증하는 테스트가 아니라, 소비자가 실제로 의존하는 요청과 응답의 약속을 검증하는 장치입니다.

따라서 계약에는 모든 필드를 넣지 않아야 한다는 설명이 중요합니다. 소비자가 사용하지 않는 내부 필드까지 고정하면 provider 변경을 과도하게 막습니다. 반대로 오류 응답, 인증 만료, validation 실패처럼 소비자 UX에 직접 영향을 주는 부분은 성공 응답만큼 구체적으로 계약화해야 합니다. 이 균형이 맞아야 계약 테스트가 협업 도구가 되고, 단순한 CI 장애 원인이 되지 않습니다.

본문의 내용은 배포 전략과도 맞물립니다. 새 필드를 추가하고 소비자가 먼저 호환 코드를 배포한 뒤 오래된 필드를 제거하는 expand and contract 방식은 독립 배포 환경에서 가장 현실적인 순서입니다. 계약 테스트는 이 순서가 지켜졌는지 확인하는 신호로 쓰면 좋습니다. provider가 구버전 소비자를 깨뜨리지 않는지, consumer가 새 계약을 실제로 필요로 하는지 CI에서 확인할 수 있기 때문입니다.


결론: API는 코드가 아니라 약속이다

독립 배포 환경에서 API 안정성은 문서만으로 지켜지지 않습니다. 소비자가 실제로 기대하는 요청과 응답을 계약으로 만들고, 제공자가 배포 전에 이를 검증해야 배포 충돌을 줄일 수 있습니다. Consumer Driven Contract는 팀 사이의 신뢰를 테스트 가능한 형태로 바꾸는 방법입니다.

계약은 작고 구체적이어야 하며, CI와 배포 전략 안에서 작동해야 합니다. 잘 운영된 계약 테스트는 변경을 막는 장벽이 아니라 변경을 더 자신 있게 만드는 안전장치가 됩니다.