프런트엔드와 백엔드가 따로 배포되기 시작하면 장애의 상당수는 코드 버그가 아니라 "서로 기대한 응답이 달랐다"에서 나온다. 백엔드가 필드 이름 하나를 바꾸면 양쪽 단위 테스트는 모두 통과하는데 운영 화면만 깨진다. 이 글은 Consumer-Driven Contract Testing으로 그 약속을 테스트 코드로 만들고, Pact Broker와 can-i-deploy로 호환되지 않는 버전이 운영에 나가지 못하게 막는 방법을 정리한다.

한눈에 보기
| 단계 | 누가 | 무엇을 | 결과물 |
|---|---|---|---|
| 계약 생성 | 프런트엔드 | 실제로 쓰는 요청·응답을 테스트로 기술 | pacts/web-frontend-order-api.json |
| 계약 게시 | 프런트엔드 CI | 커밋 SHA·브랜치와 함께 Broker에 올림 | Broker의 계약 버전 |
| Provider 검증 | 백엔드 CI | 계약 속 요청을 실제 서버에 재생해 응답 비교 | 검증 결과(통과·실패) |
| 배포 게이트 | 양쪽 배포 파이프라인 | 운영에 떠 있는 상대 버전과 호환되는지 질의 | can-i-deploy 통과 여부 |
| 배포 기록 | 양쪽 배포 파이프라인 | 어떤 버전이 어느 환경에 있는지 기록 | 환경별 배포 이력 |

계약의 주인은 소비자(consumer)다. 프런트엔드가 "나는 이 필드만 이렇게 쓴다"고 적으므로, 백엔드는 아무도 안 쓰는 필드는 자유롭게 바꾸고 누군가 쓰는 필드를 건드리면 바로 실패를 본다.
목과 E2E 사이의 빈칸
프런트엔드 단위 테스트는 API를 목(mock)으로 대체한다. 목은 개발자가 기억하는 응답일 뿐이라, 백엔드가 status를 orderStatus로 바꿔도 테스트는 계속 초록색이다.
E2E 테스트는 이 차이를 잡지만 비용이 크다.
- 모든 서비스를 한 환경에 같은 버전으로 띄워야 한다.
- 실패 원인이 네트워크·데이터·타이밍 어디인지 바로 보이지 않는다.
- "어느 버전 조합이 호환되는가"에 답하지 못한다. 지금 띄운 조합이 통과했다는 사실만 남는다.
Contract Testing은 그 사이를 메운다. 프런트엔드 테스트의 목 응답을 계약으로 저장하고, 백엔드가 그 계약을 실제 코드로 재생해 목이 거짓말을 하지 않는지 확인한다. 각자 자기 CI에서 돌기 때문에 공용 환경이 필요 없다.
Consumer 쪽: 프런트엔드 테스트로 계약 만들기
Pact JS(@pact-foundation/pact)의 V3 API로 쓴 주문 조회 계약이다. Vitest나 Jest 안에서 그대로 돈다.
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchOrder } from '../src/api/orders';
const { integer, string, regex, eachLike } = MatchersV3;
const provider = new PactV3({ consumer: 'web-frontend', provider: 'order-api', dir: 'pacts' });
describe('주문 조회 API 계약', () => {
it('주문 1번을 조회하면 상태와 품목을 돌려준다', () => {
provider
.given('주문 1번이 있다')
.uponReceiving('주문 1번 조회 요청')
.withRequest({ method: 'GET', path: '/orders/1', headers: { Accept: 'application/json' } })
.willRespondWith({
status: 200,
body: {
id: integer(1),
status: regex('PENDING|PAID|CANCELLED', 'PAID'),
items: eachLike({ sku: string('A-100'), quantity: integer(2) }),
},
});
return provider.executeTest(async (mockServer) => {
const order = await fetchOrder(mockServer.url, 1);
expect(order.status).toBe('PAID');
});
});
});
지킬 것
- 테스트는 실제 API 클라이언트(
fetchOrder)를 부른다.fetch를 직접 부르면 클라이언트의 경로·헤더 실수를 계약이 잡지 못한다. - 값이 아니라 형태를 고정한다.
integer(1)은 "정수면 된다",eachLike는 "같은 형태의 원소가 하나 이상"이다. 값을 박으면 백엔드 데이터가 바뀔 때마다 깨진다. - 화면이 쓰는 필드만 적는다. 응답 전체를 복사하면 아무도 안 쓰는 필드 때문에 백엔드 변경이 막힌다.
given('주문 1번이 있다')는 Provider State다. 백엔드가 검증 전에 이 문장에 맞는 데이터를 준비한다.
Provider 쪽: 계약을 실제 서버에 재생하기
백엔드가 Spring Boot라면 pact-jvm의 JUnit 5 모듈로 검증한다. Broker에서 order-api를 상대로 하는 계약을 모두 받아 각 상호작용(interaction)을 실제 서버에 보낸다.
@Provider("order-api")
@PactBroker
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiContractTest {
@LocalServerPort int port;
@Autowired OrderRepository orders;
@BeforeEach
void target(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext context) {
context.verifyInteraction();
}
@State("주문 1번이 있다")
void orderExists() {
orders.save(Order.paid(1L, List.of(new OrderItem("A-100", 2)))); // 예시 도메인
}
}
운영 설정
- Broker 주소·토큰, 검증 결과 게시 여부, Provider 버전은 시스템 속성으로 준다(
pactbroker.url,pact.verifier.publishResults=true,pact.provider.version). 속성 이름은 쓰는 pact-jvm 버전의 문서로 확인한다. - Provider State 문장은 계약에 그대로 저장된다. 프런트엔드와 백엔드가 같은 문장을 쓰도록 목록을 공유한다. 철자가 하나만 달라도 "상태 없음"으로 실패한다.
- pending pacts를 켠다. 한 번도 통과한 적 없는 새 계약은 실패해도 Provider 빌드를 깨지 않고 소비자 배포만 막는다. 기능 브랜치의 계약까지 미리 보려면 WIP pacts를 함께 쓴다.
BFF를 두면 프런트엔드의 상대는 BFF이고 BFF는 하위 API의 소비자가 되어 계약이 두 겹 생긴다(API Gateway와 BFF 도입 기준 및 보안 설계).
배포 게이트: can-i-deploy와 record-deployment
검증 통과만으로는 배포해도 되는지 모른다. 내 새 버전이 지금 운영에 떠 있는 상대 버전과 맞는지가 질문이고, 계약·검증 결과·배포 기록을 모두 가진 Broker가 답한다.
# 프런트엔드 CI: 계약 게시 (PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN 환경 변수 사용)
pact-broker publish ./pacts \
--consumer-app-version "$GIT_SHA" --branch "$GIT_BRANCH"
# 배포 직전: 운영의 상대 버전과 호환되는가
pact-broker can-i-deploy \
--pacticipant web-frontend --version "$GIT_SHA" \
--to-environment production
# 배포 직후: 운영에 어떤 버전이 떠 있는지 기록
pact-broker record-deployment \
--pacticipant web-frontend --version "$GIT_SHA" --environment production
- 버전은 커밋 SHA로 쓴다. 사람이 붙이는 번호는 같은 번호로 다른 코드가 올라가면 판단이 틀어진다.
- 백엔드도 같은 세 명령을
--pacticipant order-api로 돈다. 그래야 "백엔드 새 버전이 운영 중인 프런트엔드를 깨는가"도 막힌다. record-deployment를 빼먹으면 Broker는 운영에 무엇이 있는지 몰라can-i-deploy가 근거 없이 판단한다.- 환경(environment) 기능은 Broker 버전에 따라 지원 여부가 다르니, 오래된 Broker라면 태그 방식 문서를 확인한다.
카나리처럼 두 버전이 함께 떠 있는 동안에는 둘 다와 호환돼야 한다. 배포 방식별 차이는 Blue-Green·Canary·Rolling 배포 전략 비교를 참고한다.
계약에 넣을 것과 뺄 것
계약은 통신 형식을 고정하는 도구이지 백엔드 기능 테스트가 아니다. 경계를 놓치면 계약이 백엔드의 모든 변경을 막는다.
넣을 것
- 경로, 메서드, 화면이 보내는 헤더와 쿼리
- 화면이 읽는 응답 필드의 이름과 타입, 배열 여부
- 화면이 분기하는 상태 코드(404면 "주문 없음" 화면, 409면 재시도 안내)
뺄 것
- 계산 결과의 정확한 값(합계 금액이 맞는지는 백엔드 단위 테스트의 몫이다)
- 화면이 쓰지 않는 필드
- 정렬 순서·페이지 크기처럼 화면이 의존하지 않는 동작
이 경계는 데이터 파이프라인의 Data Contract와 다르다. Data Contract는 테이블·토픽의 스키마와 품질 규칙을 생산자 쪽에서 선언하고, API 계약은 소비자가 쓰는 요청·응답만 소비자 쪽에서 적는다. 데이터 쪽 이야기는 데이터 정합성 검증 자동화와 Data Contract 설계에 있다.
다른 방식과 비교
| 방식 | 계약의 주인 | 잘 맞는 경우 | 없으면 생기는 일 |
|---|---|---|---|
| Pact (Consumer-Driven) | 소비자 | 프런트엔드·모바일처럼 소비자가 소수이고 팀이 서로 아는 경우 | 백엔드가 소비자 사용 범위를 모르고 바꾼다 |
| Spring Cloud Contract | 제공자 | 백엔드가 JVM이고 제공자가 계약을 관리하려는 경우. Groovy·YAML 계약에서 테스트와 stub이 함께 생성됨 | 소비자가 stub과 실제 서버 차이를 늦게 안다 |
| OpenAPI 스키마 검증 | 제공자 | 공개 API처럼 소비자를 모르는 경우 | 스펙과 구현이 따로 놀아도 모른다 |
| Bi-Directional (PactFlow) | 양쪽 | 이미 OpenAPI가 있고 Provider 검증 테스트를 새로 짜기 어려운 경우 | 스키마 수준 호환만 보므로 Provider State 같은 동작 차이는 놓친다 |
소비자를 알고 팀이 함께 일한다면 Pact가 가장 많은 실수를 잡는다. 소비자를 모르는 공개 API라면 OpenAPI 스펙을 기준으로 구현을 검사하는 편이 현실적이다.
자주 하는 실수
목 응답을 손으로 따로 관리한다. 계약과 단위 테스트 목이 갈라지면 화면 테스트는 다른 응답을 믿는다. 같은 테스트에서 계약을 만들게 한다.
계약에 정확한 값을 박는다. "total": 12000처럼 쓰면 백엔드 테스트 데이터가 바뀔 때마다 실패한다. Matcher로 타입과 형태만 고정한다.
Provider 검증만 하고 can-i-deploy를 빼먹는다. 검증 결과가 Broker에 쌓이기만 하고 배포를 막지 않는다. 게이트가 없으면 계약은 보고서일 뿐이다.
Provider State에서 외부 시스템을 그대로 부른다. 결제·인증 서버를 호출하면 검증이 느리고 흔들린다. DB 저장이나 테스트 대역으로 끝낸다.
모든 소비자의 계약을 한 번에 켠다. 기존 계약 수십 개가 첫날 동시에 실패하면 팀이 도구를 끈다. 가장 자주 깨지던 API 하나부터 시작한다.
점검표
- 계약 테스트가 실제 API 클라이언트 함수를 호출한다.
- 응답 본문은 Matcher(
integer,regex,eachLike)로 형태만 고정했다. - 계약에 화면이 실제로 읽는 필드만 들어 있다.
- Provider State 문장 목록을 양쪽 팀이 같은 문서로 공유한다.
- 계약과 검증 결과의 버전이 커밋 SHA다.
- 백엔드 CI가 빌드마다 Broker의 계약을 검증하고 결과를 게시한다.
- pending pacts가 켜져 있어 새 계약이 Provider 메인 빌드를 바로 깨지 않는다.
- 양쪽 배포 파이프라인이 배포 직전에
can-i-deploy를 돈다. - 양쪽 배포 파이프라인이 배포 직후에
record-deployment를 돈다. - 계약 실패 시 다른 필드가 CI 로그에 보인다.
같은 계약 문서를 프런트엔드가 만들고 백엔드가 재생하고 배포 파이프라인이 질의하게 하면, API가 서로 맞는지는 운영에서 깨진 화면이 아니라 배포 전 CI의 실패 한 줄로 드러난다.
함께 읽을 글: Mutation Testing으로 단위 테스트의 실효성 검증, TanStack Query와 Server State 관리 아키텍처, Micro Frontend와 Module Federation 도입 판단 기준.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.