왜 통합 테스트만으로는 부족한가

마이크로서비스가 늘어나면 서비스 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 등 이벤트 메시지의 페이로드도 계약으로 고정할 수 있습니다.
  • 계약 테스트는 기능 테스트를 대체하지 않음: 계약은 "형식 호환성"만 보장합니다. 비즈니스 로직 정확성은 여전히 각 서비스의 단위·통합 테스트가 담당합니다.