제 팀에서 프로덕션까지 간 모든 API 파괴는 CI를 통과했습니다. 전부요. unit tests는 초록불이었고, integration suite도 통과했습니다. 배포가 나갔고, 그다음 Slack 메시지가 쏟아지기 시작했습니다.

문제는 테스트를 하지 않았던 것이 아닙니다. 잘못된 것을 테스트하고 있었던 것입니다. 대부분의 CI 파이프라인은 코드가 실행되는지 확인합니다. producer와 consumer 사이의 API contract가 여전히 온전한지는 확인하지 않습니다.

왜 기존 테스트가 API 파괴를 잡아내지 못하는가

unit tests는 낵부 로직을 점검합니다. mock된 의존성으로 handler를 호출하고 응답을 검증합니다. integration tests는 전체 stack을 띄워 실제 endpoint를 호출합니다.

둘 다 producer를 테스트합니다. 현재 호출 방식대로 API가 올바르게 동작하는지 확인합니다. 지난주의 contract를 기준으로 작성된 외부 consumer가 여전히 통신할 수 있는지는 테스트하지 않습니다.

미묘한 파괴가 위험합니다. 린터가 불평해서 user_id 필드를 userId로 바꿉니다. 200 응답을 평면 객체 대신 중첩된 객체를 반환하도록 변경합니다. 선택적이던 쿼리 파라미터를 필수로 만듭니다. 자체 테스트도 같은 PR에서 업데이트되니 모든 게 통과합니다. 하지만 프로덕션의 모든 클라이언트가 망가집니다.

이것이 코드를 테스트하는 것과 contract를 테스트하는 것의 차이입니다.

어떤 변경이 breaking API change인가

breaking change는 올바르게 구현된 클라이언트가 실패하게 만드는 모든 수정입니다. 이는 버그와 관련이 없습니다. API가 했던 약속에 관한 것입니다.

가장 흔한 파괴는 세 가지 범주로 나뉩니다:

  • 구조적 변경: 필드 제거 또는 이름 변경, 타입 변경, 중첩 구조 수정
  • 동작 변경: 선택적 파라미터를 필수로 전환, 페이지네이션 기본값 변경, 오류 응답 형태 수정
  • 수명주기 변경: endpoint 제거, URL 경로 변경, 사전 경고 없는 버전 폐기

일부는 명백합니다. 하지만 API를 consumer의 관점에서 볼 때에만 명백한 것도 있습니다. 대부분의 팀은 그렇게 보지 않습니다.

레이어 1: OpenAPI 디핑으로 구조적 파괴 잡아내기

OpenAPI spec은 API의 형태를 기술합니다. spec을 contract로 다룬다면 이전 버전과 디프하여 머지 전에 breaking change를 표시할 수 있습니다.

oasdiff 같은 도구는 두 OpenAPI 문서를 비교해 변경 사항을 breaking, dangerous, safe로 분류합니다. breaking change는 응답 필드를 제거하거나 파라미터를 선택에서 필수로 바꾸는 것과 같은 것입니다.

GitHub Actions workflow에서의 모습은 다음과 같습니다:

# .github/workflows/api-contract.yml
name: API Contract Check

on:
  pull_request:
    paths:
      - 'openapi.yaml'

jobs:
  diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download oasdiff
        run: |
          curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
          sudo mv oasdiff /usr/local/bin/

      - name: Check for breaking changes
        run: |
          oasdiff breaking \
            --base origin/main:openapi.yaml \
            --revision openapi.yaml \
            --fail-on WARN

PR이 구조적 breaking change를 도입하면 빌드가 실패합니다. 빠르고 deterministic하며, integration tests가 놓치는 이름 변경과 제거를 잡아냅니다.

하지만 함정이 있습니다. OpenAPI diffing은 schema만 볼 수 있습니다. 타입은 유지하면서 필드의 의미를 바꿨는지는 알 수 없습니다. boolean active 필드가 이전에는 “account enabled”를 의미하다가 이제는 “email verified”를 의미하도록 바뀌어도 diff에는 나타나지 않습니다. 타입은 변하지 않았지만, contract는 변했습니다.

레이어 2: Pact로 consumer contract 검증하기

Consumer-driven contract testing은 모델을 뒤집습니다. API provider가 자신의 정확성을 주장하는 대신, consumer가 필요한 것을 정의합니다. 이러한 기대가 contract가 되어 provider는 CI에서 이를 만족해야 합니다.

작동 방식은 이렇습니다. 프론트엔드 팀이 다음과 같은 테스트를 작성합니다: “GET /users/123을 호출하면 200 응답과 함께 body에 user_id가 문자열로 포함되어 있을 것이다.” Pact는 이 상호작용을 기록하고 contract를 저장합니다.

provider 측에서는 백엔드 CI가 모든 consumer contract를 날려받아 현재 코드에 대해 replay합니다. PR이 user_id를 제거하거나 숫자로 바꾸면 provider verification이 실패합니다. 자체 테스트가 통과하더라도 말입니다.

Pact JS로 작성한 최소한의 consumer test:

// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');

const provider = new PactV3({
  consumer: 'web-app',
  provider: 'user-service',
  dir: './pacts',
});

describe('GET /users/:id', () => {
  it('returns the user', async () => {
    await provider
      .given('user exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          user_id: '123',
          email: 'alice@example.com',
        },
      });

    await provider.executeTest(async (mockserver) => {
      const user = await fetchUser(mockserver.url, '123');
      expect(user.user_id).to.equal('123');
    });
  });
});

CI에서의 provider verification:

# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts

on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Verify pacts
        run: |
          docker run --rm \
            -v $(pwd)/pacts:/pacts \
            pactfoundation/pact-cli \
            verify-provider \
            --provider-app-version ${{ github.sha }} \
            --pact-broker-base-url https://your-pact-broker.io \
            --provider user-service

이는 schema 디핑이 볼 수 없는 의미적 파괴를 잡아냅니다. consumer가 특정 값 형식이나 오류 메시지에 의존한다면 Pact가 이를 표시합니다.

대부분의 팀이 무시하는 트레이드오프

두 접근법 모두 공짜가 아닙니다.

OpenAPI 디핑은 정확한 spec을 유지해야 합니다. spec이 코드 애너테이션에서 생성된다면 자동으로 최신 상태를 유지합니다. 수동으로 작성된 경우에는 점차 동떨어지게 되어 디프가 무의미해집니다. 이 워크플로에서는 생성된 spec이 더 나은 선택입니다.

Pact는 조직적 규율이 필요합니다. consumer가 contract test를 작성해야 합니다. contract를 저장하고 버전을 관리할 broker가 필요합니다. consumer의 기대가 잘못되면 누군가 변경을 협상해야 합니다. 이는 마찰을 만듭니다. 하지만 그것이 핵심입니다. 그 마찰이 조용한 파괴를 방지합니다.

둘 다 실행하는 것이 이상적이지만 항상 실용적인 것은 아닙니다. 중요한 consumer가 한두 개라면 Pact가 빠르게 그 가치를 증명합니다. 수십 개의 익명 API consumer가 있다면 OpenAPI 디핑이 더 나은 출발점입니다.

한 가지 더: 두 도구 모두 성능 저하나 인증 변경은 잡아내지 못합니다. 이전에는 필요 없었던 OAuth를 요구하는 새 endpoint는 breaking change입니다. auth schema가 이미 다른 곳에 정의되어 있었다면 디프 도구가 이를 표시하지 않을 수도 있습니다. 이런 틈새를 놓치지 마세요.

자주 묻는 질문

breaking API change란 무엇인가?

breaking API change는 기존의 올바르게 구현된 클라이언트가 실패하게 만드는 API의 모든 수정입니다. 여기에는 필드 제거, 타입 변경, 선택적 파라미터를 필수로 만드는 것, 기존 endpoint의 응답 상태 코드 변경이 포함됩니다.

API contract testing과 integration testing의 차이점은 무엇인가?

integration tests는 시스템 전체가 작동하는지 확인합니다. contract tests는 API의 공개 인터페이스가 consumer가 기대하는 것과 일치하는지 확인합니다. 시스템의 양쪽이 같은 커밋에서 업데이트되면 contract가 깨져도 integration tests는 통과할 수 있습니다.

OpenAPI 디핑을 수동으로 spec을 작성하지 않고 사용할 수 있는가?

예. oasdiff 같은 도구는 SpringDoc, FastAPI, drf-spectacular 같은 라이브러리를 사용해 코드 annotation에서 생성된 OpenAPI 문서를 포함해 모든 OpenAPI 문서와 작동합니다. 생성된 spec은 구현과 동떨어질 수 없어서 더 신뢰할 수 있는 경우가 많습니다.

consumer-driven contracts를 사용하려면 Pact broker가 필요한가?

서비스가 두세 개를 넘는 팀이라면 그렇습니다. broker는 contract를 저장하고 버전을 추적하며, 제안된 변경이 어떤 consumer에 영향을 미치는지 보여줍니다. broker가 없으면 contract 파일을 수동으로 주고받아야 하는데, 이는 금방 망가집니다.

GraphQL은 어떤가?

GraphQL은 다른 breaking change 의미 체계를 가집니다. 필드를 제거하는 것은 breaking이지만 추가하는 것은 안전합니다. GraphQL Inspector 같은 도구는 OpenAPI 도구와 유사한 schema 디핑을 제공합니다. Pact도 GraphQL 상호작용을 지원합니다.

schema 디프부터 시작하라

OpenAPI 디핑부터 시작하세요. 가장 적은 노력으로 가장 흔한 파괴를 잡아냅니다. spec이 완벽하지 않더라도 오늘 CI에 추가하세요. 불완전한 검사라도 없는 것보다 낫습니다.

리뷰에서 누군가의 주말을 망칠 뻔한 파괴를 한 번 잡아내면, 왜 이것이 중요한지 이해하게 될 것입니다.