왜 통합 테스트만으로는 부족한가
마이크로서비스가 늘어나면 서비스 A(소비자)가 서비스 B(제공자)의 API를 호출하는 관계가 얽힙니다. B 팀이 응답 필드 이름을 바꾸거나 필수 필드를 제거하면, B의 단위 테스트는 통과하지만 A는 운영 환경에서 깨집니다. 이 문제를 전 구간 통합 테스트로 잡으려면 모든 서비스를 동시에 띄워야 하고, 느리고 불안정하며 어느 팀이 계약을 깼는지 특정하기 어렵습니다.
계약 테스트(contract test)는 "소비자가 무엇을 기대하는가"를 명시적 계약으로 고정하고, 제공자가 그 계약을 지키는지 각자의 CI에서 독립적으로 검증합니다. 두 서비스를 동시에 띄우지 않고도 호환성 깨짐을 배포 전에 잡을 수 있습니다.
소비자 주도 계약(CDC)의 개념
가장 널리 쓰이는 방식은 소비자 주도 계약(Consumer-Driven Contracts)입니다. 소비자가 자신이 보내는 요청과 기대하는 응답을 기술한 계약 파일을 생성하고, 제공자는 이 계약을 자신의 실제 구현에 재생(replay)해 검증합니다. 핵심은 "실제 응답 전체가 아니라 소비자가 실제로 쓰는 부분만" 검증한다는 점입니다.
소비자 측 계약 생성 예시
Pact를 사용해 소비자 테스트에서 목(mock) 제공자를 세우고 계약 파일을 생성합니다.
import atexit
from pact import Consumer, Provider
pact = Consumer("order-service").has_pact_with(
Provider("user-service"), pact_dir="./pacts", port=1234
)
pact.start_service()
atexit.register(pact.stop_service)
def test_get_user():
expected = {"id": 42, "email": "[email protected]", "active": True}
(pact
.given("user 42 exists")
.upon_receiving("a request for user 42")
.with_request("GET", "/users/42")
.will_respond_with(200, body=expected))
with pact:
resp = fetch_user(42) # 실제 HTTP 클라이언트 코드
assert resp["email"] == "[email protected]"
이 테스트가 통과하면 ./pacts에 계약 JSON이 생성됩니다. 소비자가 email과 active만 참조하므로 계약에도 그 필드만 포함됩니다.
제공자 측 검증과 파이프라인 연결
제공자는 생성된 계약을 자신의 실제 엔드포인트에 재생해 검증합니다. CI에서 계약 브로커(Pact Broker 등)를 통해 계약을 주고받으면 팀 간 파일 공유가 자동화됩니다.
# .gitlab-ci.yml (제공자 파이프라인)
provider-verify:
stage: test
script:
- ./run_provider.sh & # 실제 서비스 기동
- pact-verifier \
--provider-base-url=http://localhost:8080 \
--pact-broker-url=$PACT_BROKER_URL \
--provider=user-service \
--provider-app-version=$CI_COMMIT_SHA \
--publish-verification-results
검증 결과가 브로커에 기록되면, 배포 전 can-i-deploy 게이트로 "이 버전이 현재 운영 중인 모든 소비자와 호환되는가"를 확인할 수 있습니다.
pact-broker can-i-deploy \
--pacticipant user-service \
--version $CI_COMMIT_SHA \
--to-environment production
스키마 검증과의 차이
OpenAPI/JSON Schema 검증과 계약 테스트는 목적이 다릅니다. 혼동하면 둘 다 도입하고도 호환성 사고가 납니다.
| 항목 | 스키마 검증 | 계약 테스트 |
|---|---|---|
| 검증 대상 | 응답 형식의 문법적 유효성 | 소비자가 실제로 쓰는 상호작용 |
| 미사용 필드 변경 | 스키마 위반 시 실패 | 소비자가 안 쓰면 통과 |
| 깨짐 원인 특정 | 어려움 | 어느 소비자-제공자 쌍인지 명확 |
| 실행 방식 | 정적/런타임 | 양측 CI에서 독립 실행 |
실무에서의 주의점
- 과도한 매칭 지양: 응답 전체를 고정값으로 검증하면 제공자가 무해한 필드를 추가할 때마다 계약이 깨집니다. 타입 매처(정규식·타입 기반)를 써서 소비자가 실제 의존하는 부분만 좁게 명시하세요.
- 계약은 실제 코드에서 생성: 손으로 쓴 계약은 소비자 실제 동작과 어긋나기 쉽습니다. 반드시 소비자 클라이언트 코드를 통해 생성해야 합니다.
- 버전과 환경 태깅: 커밋 SHA를 버전으로, 배포 환경을 태그로 브로커에 기록해야
can-i-deploy가 의미를 갖습니다. - 비동기 메시지도 대상: REST뿐 아니라 Kafka 등 이벤트 메시지의 페이로드도 계약으로 고정할 수 있습니다.
- 계약 테스트는 기능 테스트를 대체하지 않음: 계약은 "형식 호환성"만 보장합니다. 비즈니스 로직 정확성은 여전히 각 서비스의 단위·통합 테스트가 담당합니다.