왜 스키마 변경이 장애로 이어지는가

Kafka나 gRPC 기반 시스템에서 프로듀서와 컨슈머는 배포 주기가 서로 다르다. 프로듀서가 먼저 새 스키마로 배포되면, 아직 구 버전인 컨슈머가 디코딩에 실패한다. 특히 Avro는 필드 삭제나 타입 변경 시 조용히 역직렬화 오류를 내고, Protobuf는 필드 번호(tag)를 재사용하면 데이터가 엉뚱한 필드로 해석된다. 문제는 이런 결함이 컴파일이나 단위 테스트를 모두 통과한다는 점이다. 런타임에, 그것도 특정 메시지가 흐를 때만 터진다.

따라서 스키마 호환성은 코드 리뷰나 테스트가 아니라, 배포 파이프라인에서 이전 버전과의 호환성을 기계적으로 검증하는 게이트로 막아야 한다.

호환성 모드부터 정확히 이해하기

게이트를 세우기 전에 어떤 호환성을 강제할지 정해야 한다. Confluent Schema Registry 기준 주요 모드는 다음과 같다.

모드검사 방향안전한 변경
BACKWARD새 스키마로 구 데이터 읽기필드 삭제, optional 추가
FORWARD구 스키마로 새 데이터 읽기필드 추가, optional 삭제
FULL양방향optional 필드 추가/삭제만

컨슈머를 먼저 배포하는 조직이라면 BACKWARD, 프로듀서를 먼저 배포하면 FORWARD가 맞다. 배포 순서를 통제하기 어렵다면 FULL을 기본값으로 둔다. `_TRANSITIVE` 접미사를 붙이면 직전 버전이 아닌 모든 과거 버전과 비교하므로, 오래된 컨슈머가 남아있는 환경에서 권장한다.

CI에서 Avro 호환성 검사하기

Confluent의 Maven/Gradle 플러그인, 또는 REST API로 레지스트리에 "이 스키마를 등록해도 되는지" 사전 질의(dry-run)를 던진다. 실제 등록 없이 검사만 수행하는 것이 핵심이다.

#!/usr/bin/env bash
set -euo pipefail

REGISTRY="https://schema-registry.internal:8081"
SUBJECT="orders-value"
SCHEMA_FILE="schemas/order.avsc"

# 스키마 파일을 JSON 문자열로 이스케이프
payload=$(jq -Rs '{schema: .}' < "$SCHEMA_FILE")

http_code=$(curl -s -o /tmp/resp.json -w "%{http_code}" \
  -X POST "$REGISTRY/compatibility/subjects/$SUBJECT/versions/latest" \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d "$payload")

if [ "$http_code" != "200" ]; then
  echo "레지스트리 응답 오류: $http_code"; cat /tmp/resp.json; exit 1
fi

if [ "$(jq -r '.is_compatible' /tmp/resp.json)" != "true" ]; then
  echo "❌ 스키마 비호환: $SUBJECT"; cat /tmp/resp.json; exit 1
fi
echo "✅ 호환성 통과"

이 스크립트를 CI 잡의 필수 단계로 두면, 비호환 변경이 머지되기 전에 파이프라인이 실패한다.

GitHub Actions 게이트로 묶기

위 검사를 PR 단계에서 강제한다. 스키마 디렉터리가 바뀐 경우에만 돌도록 경로 필터를 걸어 불필요한 실행을 줄인다.

name: schema-compat-gate
on:
  pull_request:
    paths:
      - 'schemas/**'
jobs:
  compat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: install deps
        run: sudo apt-get update && sudo apt-get install -y jq curl
      - name: run compatibility check
        env:
          SR_USER: ${{ secrets.SR_USER }}
          SR_PASS: ${{ secrets.SR_PASS }}
        run: ./ci/check-schema-compat.sh

브랜치 보호 규칙에서 이 잡을 required check로 지정해야 우회가 막힌다. 잡이 존재만 하고 필수가 아니면 게이트가 아니라 알림에 불과하다.

Protobuf는 buf로 검사한다

Protobuf는 레지스트리 없이도 `buf breaking`으로 이전 버전 대비 파괴적 변경을 잡을 수 있다. 기준(baseline)을 git 태그나 main 브랜치로 지정하면 된다.

# buf.yaml
version: v2
breaking:
  use:
    - WIRE_JSON   # wire + JSON 인코딩 호환성 모두 검사

# CI 명령
buf breaking --against '.git#branch=main'

`WIRE_JSON`은 필드 번호 변경, 타입 변경, 필드 삭제 후 재사용 등을 모두 잡는다. 삭제한 필드 번호는 반드시 `reserved`로 예약해 재사용을 원천 차단하는 습관을 들이면, 검사와 무관하게 실수를 줄일 수 있다.

실무에서 놓치기 쉬운 주의점

  • 기본값과 옵셔널의 차이. Avro에서 필드를 나중에 추가하려면 반드시 `default`를 넣어야 BACKWARD 호환이 성립한다. 기본값 없는 필드 추가는 즉시 실패한다.
  • enum 확장. Avro enum에 값을 추가하는 것은 호환되지 않을 수 있다. Protobuf는 알 수 없는 enum 값을 허용하지만, 구 버전 코드의 분기 처리가 깨질 수 있으니 코드 레벨 방어가 필요하다.
  • 레지스트리와 실제 배포의 불일치. CI에서 dry-run만 하고 실제 등록을 자동화하지 않으면, 검증된 스키마와 운영 스키마가 어긋날 수 있다. 병합 후 main에서 실제 등록까지 파이프라인에 포함시켜야 한다.
  • transitive 검사 비용. `_TRANSITIVE` 모드는 과거 버전이 많을수록 느려진다. 오래되어 소비자가 사라진 버전은 정책적으로 정리해 검사 범위를 관리한다.

결국 스키마 호환성 게이트의 본질은 "런타임에서만 드러나는 오류를 배포 이전으로 앞당기는 것"이다. 검사 도구 자체는 성숙해 있으니, 남은 과제는 이를 우회 불가능한 필수 단계로 파이프라인에 못 박는 조직적 규율이다.