본문으로 건너뛰기
yutils

contract testing 은 어떻게 동작할까?

왜 microservice integration test 가 scale 안 되나, consumer-driven contract (Pact), provider 검증, git 버전 spec 으로서의 contract, '한 test 위해 service 20개 띄우기' 악몽 대체.

약 9분 읽기

microservice 20 개. service A 가 service B 의 API 형식 변경. deploy 후 A 의 client 코드가 깨짐. integration test 가 전부 통과 했는데 왜? — A 는 B 의 새 형식 안 보고 test 했고, B 는 A 의 사용 패턴 모름. Contract testing 이 이 gap 을 메꿈. 이 가이드는 consumer-driven contract 의 메커니즘과 Pact 의 실제 작동을 정리한다.

Microservice Integration Test 의 한계

naive 방법:
  Service A test:
    - service B 실제로 띄움 (또는 그 의존 stub)
    - HTTP 호출 → 응답 검증

문제:
1. CI 에서 B 띄우려면 B 의 모든 의존 (B → C → D → DB) 도
2. B 의 새 버전이 deploy 되기 전엔 A 의 test 가 새 형식 검증 X
3. 20 개 service 면 한 test 가 20 service 띄움 → 분 단위 + flaky
4. service 간 의존 cycle 어떻게?

→ "spin up 20 services for one test" 의 비용 + 신뢰성 hellhole

Contract — 양측의 약속

Contract = 한 service 가 다른 service 의 API 를 어떻게 사용하는지 명세.

예 (Pact):
{
  consumer: "service-a",
  provider: "service-b",
  interactions: [
    {
      description: "get user by id",
      request: {
        method: "GET",
        path: "/users/42",
        headers: {"Accept": "application/json"}
      },
      response: {
        status: 200,
        body: {
          id: 42,
          name: matcher.string("Alice"),  // 정확한 값 X, type 만
          email: matcher.regex(/.+@.+/)
        }
      }
    }
  ]
}

→ JSON 으로 저장. git versioned. Pact Broker (또는 git) 로 공유.

Consumer-Driven Contract Testing

Step 1 — Consumer (A) 가 contract 생성

  test("get user from B", () => {
    pact.given("user 42 exists")
        .uponReceiving("a GET")
        .withRequest({method: "GET", path: "/users/42"})
        .willRespondWith({status: 200, body: {id: 42, name: "Alice"}});

    // A 의 코드 실행 — Pact mock server 가 contract 대로 응답
    const user = await getUserFromB(42);
    expect(user.name).toBe("Alice");
  });

  → 통과 시 contract JSON 생성 → Pact Broker upload

Step 2 — Provider (B) 가 contract 검증

  pact.verifyProvider({
    providerName: "service-b",
    providerBaseUrl: "http://localhost:8080",
    pactBrokerUrl: "...",
    stateHandlers: {
      "user 42 exists": () => db.users.insert({id: 42, name: "Alice"})
    }
  });

  → B 가 실제로 도는 상태에서 contract 의 모든 interaction 호출 → 검증.
  → B 의 새 변경이 contract 깨면 → CI fail.

→ 두 단계 모두 통과 = A 와 B 의 통합 보장 (둘 다 띄우지 않고).

Consumer-Driven 의 의미

Provider (B) 가 spec 만들고 consumer (A) 가 따르는 게 아님 — consumer (A) 가 "내가 이렇게 쓴다" 선언하고 provider (B) 가 그것을 보장.

이점:
- B 가 "어떤 field 가 실제 사용되나" 알 수 있음
- B 가 unused field 안전하게 제거 (어느 consumer 도 사용 X 확인 후)
- A 가 "필요한 것만" 명세 — overspec 회피

vs Schema-first (OpenAPI):
- OpenAPI: B 가 "내가 이런 API 제공" → A 가 알아서 사용
- Pact: A 가 "내가 이렇게 쓴다" → B 가 만족
- 둘 다 가치 있음 — OpenAPI 는 외부 공개 API, Pact 는 내부 service 간

Provider States — given clause

Contract test 의 함정 — "user 42 가 있다" 가 검증 시점에 DB 에 없으면?

해결: provider state (Pact 의 given clause):
  consumer test:
    pact.given("user 42 exists")
        .uponReceiving("get user")
        ...

  provider verification:
    stateHandlers: {
      "user 42 exists": async () => {
        await db.clear();
        await db.users.insert({id: 42, name: "Alice"});
      },
      "user 42 does not exist": async () => {
        await db.clear();
      }
    }

→ provider 가 검증 전에 그 state 만듦. consumer 의 가정과 일치.

Matchers — flexible 검증

contract 가 너무 strict 하면 — provider 가 timestamp 등 변경 시 깨짐.
matcher 로 "type/format 만 검증, 값 자유":

response: {
  status: 200,
  body: {
    id: matcher.integer(42),         // integer 면 OK (42 는 예시)
    name: matcher.string("Alice"),    // string 면 OK
    created_at: matcher.iso8601DateTime(),  // ISO datetime 면 OK
    email: matcher.regex(/^.+@.+$/),
    items: matcher.eachLike({id: integer(), price: decimal()})
  }
}

→ provider 의 정확한 값 변동 (timestamp, id) 허용, 구조는 보장.

Pact Broker — 공유 + workflow

자체 host 또는 SaaS (PactFlow).

기능:
- contract JSON 저장 (consumer/provider 별)
- version tagging (main, staging, production)
- "can-i-deploy?" query — "service A v3 가 production 의 B 와 호환?"
- 의존 graph 시각화 (어떤 service 가 어떤 service 의존)
- 변경 webhook (consumer contract 새 버전 → provider CI 자동 trigger)

CI 통합:
  - consumer build → contract publish → "ready for verification" tag
  - provider build → broker 에서 contract 가져와 verify → "verified" tag
  - deploy 전 can-i-deploy 호출 → 양쪽 호환 시만 deploy

Contract vs Integration vs E2E

검증방법비용
한 함수 / classUnit저렴
한 service 의 통합 (DB 포함)Integration중간
service A ↔ service B contractContract (Pact)저렴 (둘 다 안 띄움)
사용자 관점 전체 flowE2E매우 비쌈

흔한 함정

  • contract 가 너무 strict — matcher 안 쓰고 정확한 값 비교 → provider 의 작은 변경 시 깨짐. matcher 활용.
  • provider state 부재 — "user 42 exists" 같은 전제 없이 verify → DB state 의존 fail.
  • contract 의 의존성 graph 무시 — A→B→C 의 contract 있어도 A→C 직접 호출은 검증 안 됨. 모든 dep 별 contract 필요.
  • contract = 모든 검증 대체 가정 — contract 는 interface 검증만. 비즈니스 로직 / performance 는 별도.
  • broker 없이 운영 — JSON 을 git 으로 직접 공유 가능하지만 version tag · can-i-deploy 같은 workflow 없음.

마무리

Contract testing 의 본질 — "service 둘 다 안 띄우고도 둘 사이 통합 보장". consumer-driven 으로 "실제 사용 패턴만 명세" → false positive 적음.

실용 — microservice 5+ 시 도입 가치. 내부 service 간은 Pact, 외부 API 는 OpenAPI 로 양분. Pact Broker + can-i-deploy 워크플로우가 deploy 안전성의 핵심.

가이드 목록으로