본문 바로가기

Schema Registry - 스키마 진화를 안전하게 다루는 법

·10분 읽기·
목차

이전 글에서는 Avro가 스키마를 파일에 넣어 태그 없는 바이트를 해석하고, Writer/Reader Schema 분리로 스키마 진화를 지원하는 구조를 살펴봤습니다. 마지막에 Kafka 환경에서 Schema Registry가 스키마를 중앙 관리하고 호환성을 검증한다고 짧게 언급했습니다.

이번 글에서는 Schema Registry의 내부 구조와 호환성에 대해서 알아보려고 합니다. Schema Registry가 스키마를 저장하고 ID를 반환하는 것 자체는 단순합니다. 중요한 것은 호환성(Compatibility) 검사입니다. 프로듀서와 컨슈머가 독립적으로 배포되는 환경에서, 스키마 변경이 기존 데이터를 깨뜨리지 않도록 어떤 규칙을 적용하는지, 그리고 실무에서는 어떻게 운영하는지를 정리합니다.

참고로 Schema Registry는 Avro만 지원하는 것이 아닙니다. Confluent Platform 5.5부터 Avro, Protobuf, JSON Schema 세 가지 포맷을 모두 지원합니다. 호환성 검증, Subject 관리, SerDe 모두 동일하게 동작합니다. 이 글의 예시는 Avro 기준이지만, 원리는 세 포맷에 공통으로 적용됩니다.


1. Schema Registry의 내부 구조

1-1. _schemas 토픽

Schema Registry는 독립된 서비스이지만, 데이터를 자체 DB에 저장하지 않습니다. 모든 스키마, 호환성 설정, Subject 메타데이터를 Kafka의 _schemas 토픽에 저장합니다.

Kafka Cluster
┌─────────────────────────────────┐
│  _schemas 토픽                   │
│  (단일 파티션, log compacted)      │
│                                 │
│  Key: Subject + Version         │
│  Value: Schema JSON + ID + 타입  │
│                                 │
│  Key: Config (Subject별 설정)     │
│  Value: 호환성 모드                │
└─────────────────────────────────┘

_schemas단일 파티션, log compacted 토픽입니다. 단일 파티션이므로 메시지 순서가 보장됩니다. log compaction은 같은 키를 가진 메시지 중 최신 것만 남기는 Kafka의 정리 방식입니다. 일반 토픽은 보존 기간이 지나면 메시지를 삭제하지만, compacted 토픽은 키별 최신 값을 계속 보존합니다. 스키마는 한 번 등록되면 언제든 참조할 수 있어야 하므로 이 방식이 맞습니다.

Schema Registry가 시작되면 이 토픽의 모든 메시지를 읽어 인메모리 캐시를 구축합니다. 이후 읽기 요청은 캐시에서 응답하고, 쓰기는 토픽에 새 메시지를 추가합니다.

이러한 방식 때문에 Schema Registry의 가용성이 Kafka 자체의 가용성에 의존합니다. Kafka가 살아 있으면 Schema Registry도 복구할 수 있고, Kafka가 죽으면 Schema Registry도 함께 영향을 받습니다.

1-2. Single Primary 아키텍처

Schema Registry는 Single Primary 아키텍처로 동작합니다.

              ┌──────────────┐
  쓰기 요청 ──> │   Primary    │──> _schemas 토픽에 기록
              └──────────────┘

              포워딩  │
              ┌──────────────┐
  쓰기 요청 ──> │  Secondary   │  읽기 요청은 직접 응답
              └──────────────┘
              ┌──────────────┐
  읽기 요청 ──> │  Secondary   │  인메모리 캐시에서 응답
              └──────────────┘
  • 쓰기(스키마 등록, 호환성 설정 변경)는 Primary만 수행합니다
  • 읽기(스키마 조회, ID로 스키마 가져오기)는 모든 노드가 직접 응답합니다
  • Secondary에 쓰기 요청이 들어오면, Primary로 포워딩합니다

Primary 선출은 Kafka Group Protocol을 사용합니다. Primary가 죽으면 새 Primary가 선출되고, _schemas 토픽의 모든 메시지를 재생하여 인메모리 캐시를 복구합니다.

1-3. Schema ID 할당

Schema Registry는 REST API로 통신합니다. 스키마 등록, 호환성 테스트, ID 조회 모두 HTTP 요청입니다. 프로듀서의 KafkaAvroSerializer가 내부적으로 이 REST API를 호출합니다.

Schema ID는 Primary에서만 할당되고, 항상 증가합니다. 1, 2, 3처럼 연속일 수도 있고 1, 2, 5처럼 건너뛸 수도 있지만, 한 번 42가 나왔으면 다음 ID는 반드시 43 이상입니다.

스키마 등록 흐름:

1. 프로듀서가 스키마를 Schema Registry에 POST
2. Primary가 기존 스키마와 비교 (이미 등록된 스키마인지 확인)
3. 새 스키마라면 → 호환성 검사 수행
4. 호환성 통과 → 새 ID 할당 (마지막 ID + 1 이상)
5. _schemas 토픽에 기록
6. ID 반환

이미 등록된 스키마와 동일하면 새 ID를 할당하지 않고 기존 ID를 반환합니다. 같은 스키마를 100번 등록해도 같은 ID가 돌아옵니다.

Primary 재선출 시에는 _schemas 토픽에서 마지막으로 기록된 ID를 기준으로 다음 ID를 결정합니다. 이전 Primary가 GC pause로 일시적으로 응답하지 못했던 "좀비 프라이머리" 상황에서도 ID 충돌이 발생하지 않도록 설계되어 있습니다.


2. 호환성 모드

2-1. 왜 호환성이 중요한가

Kafka 환경에서 프로듀서와 컨슈머는 독립적으로 배포됩니다. 프로듀서가 먼저 업데이트될 수도, 컨슈머가 먼저 업데이트될 수도 있습니다. 동시에 업데이트하는 것은 현실적으로 불가능합니다.

시간 ──────────────────────────────────>

프로듀서 A:  ──── v1 스키마 ──── v2 스키마 ─────
컨슈머 B:   ──── v1 스키마 ──────────── v2 ────
컨슈머 C:   ──── v1 스키마 ────────────────────  (업데이트 안 함)

                            이 구간에서
                        v1 메시지와 v2 메시지가
                         토픽에 섞여 있음

컨슈머 B는 v1 메시지도 v2 메시지도 읽을 수 있어야 합니다. 컨슈머 C는 아직 v1인데 v2 메시지를 만납니다. 이런 상황에서 데이터가 깨지지 않으려면, 스키마 변경에 규칙이 필요합니다.

2-2. 네 가지 기본 모드

Schema Registry는 네 가지 기본 호환성 모드를 제공합니다.

BACKWARD (기본값)

새 스키마(v2)로 이전 데이터(v1)를 읽을 수 있어야 합니다. 이름이 헷갈릴 수 있는데, 새 코드(v2)가 옛날 데이터(v1)를 읽는 방향이 시간을 거슬러(backward) 가는 것이라서 BACKWARD입니다. 컨슈머를 먼저 업데이트하고, 프로듀서를 나중에 업데이트하는 전략입니다.

예를 들어 email 필드를 추가한다고 하면:

1. 컨슈머를 v2(email 필드 포함)로 먼저 배포
2. 토픽에는 아직 v1 메시지(email 없음)가 들어오고 있음
3. v2 컨슈머가 v1 메시지를 읽을 때 → email은 default 값("")으로 채움 ✅
4. 이후 프로듀서도 v2로 배포
BACKWARD 호환:
  "새 코드가 옛날 데이터를 읽을 수 있는가?"

허용: 필드 삭제, default 있는 필드 추가
금지: default 없는 필드 추가, 타입 변경
배포 순서: 컨슈머 먼저 → 프로듀서 나중

FORWARD

반대 방향입니다. 이전 스키마(v1)로 새 데이터(v2)를 읽을 수 있어야 합니다. 시간 순서대로(forward) 앞으로 나온 새 데이터를 옛날 코드가 읽을 수 있는가입니다. 프로듀서를 먼저 업데이트합니다.

FORWARD 호환:
  "옛날 코드가 새 데이터를 읽을 수 있는가?"

허용: default 있는 필드 삭제, 필드 추가
금지: default 없는 필드 삭제, 타입 변경
배포 순서: 프로듀서 먼저 → 컨슈머 나중

FULL

양방향 호환입니다. BACKWARD이면서 동시에 FORWARD입니다. 배포 순서에 상관없이 안전합니다.

FULL 호환:
  양쪽 모두 읽을 수 있어야 함

허용: default 있는 필드 추가, default 있는 필드 삭제
금지: default 없는 필드 변경, 타입 변경
배포 순서: 무관

NONE

호환성 검사를 하지 않습니다. 어떤 스키마든 등록됩니다. 초기 개발 단계에서만 쓰고, 프로덕션에서는 사용하지 않습니다.

2-3. Transitive 변형

기본 모드는 직전 버전과의 호환성만 검사합니다. 하지만 컨슈머 C가 v1에 머물러 있는데, v2를 거쳐 v3가 등록되면 어떻게 될까요?

v1 → v2: 호환 ✅
v2 → v3: 호환 ✅
v1 → v3: 호환 ❓  ← 기본 모드에서는 검사하지 않음

BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE모든 이전 버전과의 호환성을 검사합니다.

v3 등록 시:
  BACKWARD:            v3 ↔ v2 만 검사
  BACKWARD_TRANSITIVE: v3 ↔ v2, v3 ↔ v1 모두 검사

데이터 웨어하우스처럼 전체 이력 데이터에 SQL을 실행해야 하는 경우, 또는 다중 컨슈머가 서로 다른 버전을 사용하는 환경에서는 Transitive 모드가 필수입니다.

2-4. 호환성 모드별 정리

모드검사 방향허용되는 변경배포 순서
BACKWARD새 → 이전필드 삭제, default 필드 추가컨슈머 먼저
FORWARD이전 → 새필드 추가, default 필드 삭제프로듀서 먼저
FULL양방향default 필드 추가/삭제만무관
NONE없음모든 변경동시 배포 필요
*_TRANSITIVE모든 버전 대상위와 동일위와 동일

3. Subject와 Naming Strategy

앞에서 호환성 모드를 살펴봤는데, 호환성 검사는 어느 단위로 수행될까요? 토픽 단위? 스키마 단위? Schema Registry는 Subject라는 단위를 씁니다.

3-1. Subject란

Schema Registry에서 스키마는 Subject 단위로 관리됩니다. Subject는 한마디로 스키마의 버전 이력을 묶는 이름입니다. 하나의 Subject 안에 v1, v2, v3가 쌓이고, 새 버전을 등록할 때마다 같은 Subject 내의 이전 버전과 호환성을 검사합니다.

Subject: "user-events-value"

  v1: { name: string, age: int }
  v2: { name: string, age: int, email: string (default: "") }
  v3: { name: string, email: string (default: "") }  // age 삭제

호환성 검사: v3 등록 시 → v2(또는 모든 이전 버전)와 비교

3-2. 세 가지 Naming Strategy

Subject 이름을 어떻게 정할지는 Naming Strategy가 결정합니다.

TopicNameStrategy (기본값)

Subject = "{토픽이름}-key" 또는 "{토픽이름}-value"

예: 토픽 "user-events" → Subject "user-events-value"

토픽당 하나의 스키마만 허용됩니다. 다른 타입의 레코드를 보내면 호환성 검사에서 실패합니다. 대부분의 경우 이 전략으로 충분합니다.

RecordNameStrategy

Subject = "{fully qualified record name}"

예: com.example.UserCreated → Subject "com.example.UserCreated"

하나의 토픽에 여러 이벤트 타입을 보낼 수 있습니다. 하지만 같은 레코드 타입은 클러스터 전체에서 동일한 스키마를 공유해야 합니다.

TopicRecordNameStrategy

Subject = "{토픽이름}-{fully qualified record name}"

예: user-events + com.example.UserCreated
    → Subject "user-events-com.example.UserCreated"

토픽별로 같은 레코드 타입을 독립적으로 진화시킬 수 있습니다. 가장 유연하지만, Subject 수가 많아져 관리 복잡도가 올라갑니다.

3-3. 어떤 전략을 쓸까

상황권장 전략
토픽당 이벤트 타입 하나TopicNameStrategy (기본값)
토픽 하나에 여러 이벤트 타입RecordNameStrategy
위 + 토픽별 독립 진화 필요TopicRecordNameStrategy

실무에서는 대부분 TopicNameStrategy를 씁니다. 토픽당 하나의 이벤트 타입이 가장 단순하고 관리하기 쉽습니다.


4. 스키마가 깨지면 - Poison Pill

4-1. Poison Pill이란

Poison Pill(독약)은 Kafka에서 컨슈머가 절대 처리할 수 없는 메시지를 가리키는 용어입니다. Schema Registry 없이 Kafka를 운영하거나, 호환성 검사를 비활성화(NONE)한 상태에서, 프로듀서가 호환되지 않는 스키마로 메시지를 보내면 이것이 발생합니다.

프로듀서 (v2 스키마, 호환성 검사 없음):
  [v2 메시지] → Kafka 토픽에 정상 저장됨

컨슈머 (v1 스키마):
  [v2 메시지] 수신 → 역직렬화 실패 → SerializationException
  → 같은 메시지를 다시 읽음 → 또 실패 → 무한 루프

Poison Pill은 토픽에 한 번 들어가면, 아무리 재시도해도 소비할 수 없는 메시지입니다. 해당 파티션의 모든 후속 메시지도 처리가 차단됩니다. 컨슈머가 역직렬화에 실패하면 오프셋을 커밋하지 못하고, 같은 메시지를 반복해서 읽게 됩니다.

4-2. 한 건이면 충분하다

문제는 Poison Pill이 한 건만 있어도 해당 파티션이 멈춘다는 점입니다. 컨슈머는 파티션 내에서 순서대로 메시지를 읽기 때문에, 중간에 역직렬화가 실패하면 그 뒤의 정상 메시지도 읽지 못합니다. 역직렬화 실패가 적절히 처리되지 않으면, 같은 메시지를 반복해서 읽으며 매번 에러 로그를 남기고, 수 기가바이트의 로그가 디스크에 쌓일 수 있습니다.

최악의 시나리오:

1. 프로듀서가 호환되지 않는 메시지 전송
2. 컨슈머 역직렬화 실패 → 오프셋 커밋 불가
3. 같은 메시지 무한 재시도
4. 에러 로그 폭발 → 디스크 풀
5. 해당 파티션의 모든 후속 이벤트 처리 중단
6. 데이터 보존 기간(retention)이 만료될 때까지 복구 불가능

4-3. Schema Registry가 이 문제를 막는 방법

Schema Registry는 등록 시점에 호환성을 검증합니다. 호환되지 않는 스키마는 등록 자체를 거부합니다.

HTTP 409 Conflict

{
  "error_code": 409,
  "message": "Schema being registered is incompatible with
              an earlier schema for subject 'user-events-value'"
}

프로듀서가 호환되지 않는 스키마로 직렬화를 시도하면, Schema Registry 등록이 실패하고, 메시지가 Kafka에 전송되지 않습니다. Poison Pill이 토픽에 들어가기 전에 차단됩니다.

4-4. Schema Registry가 다운되면

Schema Registry가 일시적으로 다운되더라도, 프로듀서와 컨슈머는 로컬 캐시 덕분에 계속 동작할 수 있습니다.

정상 상태:
  프로듀서 → Schema Registry에 스키마 등록 → ID 캐싱
  이후 같은 스키마 → 캐시에서 ID 사용 (네트워크 호출 없음)

  컨슈머 → Schema Registry에서 ID로 스키마 조회 → 캐싱
  이후 같은 ID → 캐시에서 스키마 사용 (네트워크 호출 없음)

Schema Registry 다운:
  기존 스키마 → 캐시 hit → 정상 동작 ✅
  새 스키마 → 캐시 miss → 등록/조회 실패 ❌

기존에 사용하던 스키마로 메시지를 보내고 받는 것은 영향이 없습니다. 하지만 새 스키마를 등록하거나, 처음 보는 Schema ID를 만나면 실패합니다.


5. 실무 운영 패턴

5-1. 프로덕션에서 auto.register.schemas=false

개발 환경에서는 프로듀서가 직렬화할 때 Schema Registry에 자동으로 스키마를 등록합니다(auto.register.schemas=true가 기본값). 하지만 프로덕션에서는 반드시 false로 설정해야 합니다.

개발 환경:
  auto.register.schemas=true
  → 편리하지만, 누구나 아무 스키마를 등록할 수 있음

프로덕션 환경:
  auto.register.schemas=false
  → 스키마 등록은 CI/CD 파이프라인을 통해서만 수행
  → 프로듀서/컨슈머는 Schema Registry에 대해 read-only

자동 등록이 켜져 있으면, 프로듀서 코드에서 KafkaAvroSerializer가 직렬화를 수행할 때 Schema Registry에 스키마를 바로 등록합니다. 개발 환경에서는 편리하지만, 프로덕션에서는 위험합니다. 개발자가 실수로 필드 타입을 바꾼 코드를 배포하면, 그 스키마가 검증 없이 등록되어 버립니다. CI/CD에서 스키마를 검증하고 등록하면, 코드 리뷰와 자동화된 호환성 검사를 거친 스키마만 프로덕션에 도달합니다.

5-2. CI/CD 파이프라인에서 스키마 검증

스키마 파일을 Git에서 버전 관리하고, PR 시점에 호환성을 검증하는 패턴이 일반적입니다.

스키마 등록 CI/CD 흐름:

1. 개발자가 schemas/user-events.avsc 수정 → PR 생성
2. CI 파이프라인 실행:
   a. 스키마 파일 유효성 검사 (문법 오류 확인)
   b. Schema Registry에 호환성 테스트 (test-compatibility)
   c. 호환성 위반 → CI 실패 → PR 머지 차단
3. PR 승인 + 머지
4. CD 파이프라인:
   a. 스테이징 Schema Registry에 등록
   b. 프로덕션 Schema Registry에 등록
5. 프로듀서/컨슈머 배포 (스키마는 이미 등록됨)

이 패턴의 핵심은 스키마 등록과 애플리케이션 배포를 분리하는 것입니다. 스키마가 먼저 등록되어 있으므로, 프로듀서가 배포될 때 Schema Registry에서 ID를 조회만 하면 됩니다.

5-3. 스키마 진화 시나리오

필드 추가 (가장 흔한 변경)

json
v1: { "name": "string", "age": "int" }
v2: { "name": "string", "age": "int", "email": {"type": "string", "default": ""} }

default 값이 있으면 FULL 호환입니다. 배포 순서 상관없이 안전합니다.

필드 삭제 (주의 필요)

  1. 먼저 모든 컨슈머가 해당 필드에 의존하지 않는지 확인
  2. 컨슈머를 먼저 업데이트 (필드 미사용 코드 배포)
  3. 충분한 대기 시간 (모든 컨슈머 업데이트 확인)
  4. 프로듀서 업데이트 + 스키마에서 필드 제거

타입 변경 (breaking change)

타입 변경은 대부분 호환성을 깨뜨립니다. Schema Registry가 막아주는 것이 아니라, 애초에 호환 가능한 변경이 아니기 때문에 우회해야 합니다:

방법 1: 새 필드 추가 + 기존 필드 유지 → 점진적 마이그레이션

v1: { "amount": "int" }
v2: { "amount": "int", "amount_str": {"type": "string", "default": ""} }
    (모든 컨슈머가 amount_str로 전환될 때까지 양쪽 유지)
v3: { "amount_str": "string" }
    (충분한 시간이 지난 후 amount 제거)

방법 2: 새 토픽으로 마이그레이션 (Schema Registry 밖의 해결책)

기존: user-events (v1 스키마)
신규: user-events-v2 (새 스키마)
마이그레이션 서비스가 기존 토픽 데이터를 변환하여 새 토픽에 전송

마무리

Schema Registry의 핵심 역할은 스키마 저장이 아니라 호환성 검증입니다.

내부적으로는 Kafka의 _schemas 토픽에 스키마를 저장하고, Single Primary 아키텍처로 쓰기 일관성을 유지합니다. 호환성 모드(BACKWARD, FORWARD, FULL)가 스키마 변경의 규칙을 정하고, 위반하는 스키마는 등록 자체를 거부합니다. 이 게이트가 없으면 Poison Pill 한 건이 파티션 전체를 멈출 수 있습니다.

실무에서는 auto.register.schemas=false로 자동 등록을 끄고, CI/CD 파이프라인에서 스키마를 검증하고 등록합니다. 스키마 파일을 Git에서 관리하면, 코드 리뷰처럼 스키마 변경도 리뷰할 수 있습니다.

여기까지 다룬 건 개별 메시지의 스키마입니다. 그런데 데이터 레이크에는 Parquet 파일이 수만 개 쌓여 있습니다. 파일마다 스키마가 다를 수 있고, 파티션 구조도 바뀔 수 있습니다. 이 파일들을 하나의 ACID 테이블로 묶으려면 어떻게 해야 할까요?

다음 글에서는 Parquet 파일 위에 올라가는 테이블 포맷, Apache Iceberg를 다룰 예정입니다.