학습 목표

직렬화 형식 비교

Kafka의 Serializer/Deserializer는 무엇이든 될 수 있습니다. 선택 기준은 스키마 진화를 감당할 수 있는가페이로드 크기, 그리고 팀이 쓰는 언어 생태계입니다.

직렬화 형식 비교
관점 JSON (스키마 없음) Avro Protobuf JSON Schema
인코딩 텍스트 바이너리 (스키마를 알아야 읽힘) 바이너리 (필드 번호 기반) 텍스트
페이로드 크기 가장 큼 (필드 이름이 매번 반복) 가장 작음 (필드 이름이 페이로드에 없음) 작음 (필드 번호만) 가장 큼
스키마 진화 없음. 코드가 알아서 감당 가장 강력. reader/writer 스키마 해석(resolution) 규칙이 명세로 정의됨 강력. 필드 번호로 매칭하므로 이름 변경이 안전 보통
필드 식별 이름 이름 (+ reader 측 aliases) 번호(tag) 이름
필드 이름 변경 깨짐 위험. aliases로만 완화(reader 쪽에만 효과) 안전 (번호가 같으면 됨) 깨짐
필드 삭제 default 유무에 따라 다름 번호를 reserved로 남겨 재사용을 막아야 함
사람이 읽을 수 있는가 아니요 아니요
Kafka 생태계 지원 기본 StringSerializer로 충분 가장 성숙. Connect·Streams·ksqlDB 전부 1급 지원 성숙 지원됨
쓰는 곳 PoC, 로그, 외부 공개 API 기본 선택. 데이터 파이프라인·CDC·분석 gRPC와 스키마를 공유하는 마이크로서비스 이미 JSON Schema로 계약을 관리하는 조직

Schema Registry 아키텍처

Schema Registry는 스키마를 중앙에 보관하고 정수 ID를 발급하는 HTTP 서비스입니다. 핵심 흐름은 네 단계입니다.

  1. 프로듀서가 직렬화 시점에 스키마를 subject 아래로 등록합니다. Registry가 schema id를 돌려줍니다.
  2. 프로듀서는 메시지 앞에 그 id를 붙여 Kafka에 씁니다. 스키마 본문은 붙이지 않습니다.
  3. 컨슈머는 메시지 앞의 id를 읽어 Registry에 스키마를 조회합니다(그리고 캐시합니다).
  4. 조회한 writer 스키마와 컨슈머가 가진 reader 스키마로 역직렬화합니다.
Schema Registry 아키텍처 — 스키마는 레지스트리에, 메시지에는 id 만 프로듀서, Schema Registry, Kafka 토픽, 컨슈머의 관계를 6단계로 그린 그림입니다. 1단계로 프로듀서가 스키마를 subject 에 등록하면 2단계로 Schema Registry 가 schema id 를 돌려줍니다. 3단계로 프로듀서는 매직 바이트 1바이트와 schema id 4바이트를 앞에 붙인 레코드를 Kafka 토픽에 씁니다. 스키마 본문은 메시지에 들어가지 않습니다. 4단계로 컨슈머가 그 레코드를 받으면 5단계로 앞쪽 id 를 뽑아 Schema Registry 에 조회하고 6단계로 스키마를 받아 로컬에 캐시합니다. 양쪽 모두 캐시를 쓰기 때문에 메시지마다 REST 호출이 일어나지 않고 새 id 를 처음 볼 때만 조회합니다. Schema Registry 는 스키마를 자신의 내부 토픽 _schemas 에 저장하고 subject 별 버전 관리와 호환성 검사를 담당합니다. Schema Registry — 스키마는 한 번 등록하고, 메시지에는 id 만 실립니다 Schema Registry subject 별 버전 · 호환성 검사 내부 토픽 _schemas 에 저장 ① 스키마 등록 schema id 반환 (정수) ⑤ id 로 조회 ⑥ 스키마 반환 → 캐시 프로듀서 KafkaAvroSerializer 스키마 → id 로컬 캐시 같은 스키마는 다시 등록 안 함 Kafka 토픽 magic(1) + id(4) + payload 스키마 본문은 실리지 않습니다 오버헤드는 레코드당 5바이트 컨슈머 KafkaAvroDeserializer id → 스키마 로컬 캐시 처음 본 id 만 조회 메시지마다 REST 호출이 일어나지 않습니다. 처음 보는 스키마·id 일 때만 왕복하고 이후에는 캐시를 씁니다. 그래서 Schema Registry 가 잠시 죽어도 이미 캐시된 스키마로는 계속 처리됩니다 — 새 스키마 등록·조회만 막힙니다. REST: 등록은 POST /subjects/{subject}/versions · 조회는 GET /schemas/ids/{id} Schema Registry 는 Confluent 컴포넌트입니다. Apache Kafka 배포판에는 포함되지 않습니다.
Schema Registry 아키텍처 — 프로듀서의 스키마 등록과 id 수신, 메시지에 id만 포함시켜 전송, 컨슈머의 id 기반 스키마 조회와 캐시. 스키마 본문이 메시지마다 반복되지 않는 구조를 보여줍니다.

subject · version · schema id

세 가지 식별자의 관계
개념무엇인가범위
subject 스키마가 진화하는 이름 공간. 호환성 규칙이 적용되는 단위 기본 전략에서는 <topic>-key / <topic>-value
version subject 안에서 1부터 증가하는 순번. 호환성 검사의 비교 대상을 정할 때 씁니다 subject 내부에서만 의미가 있습니다
schema id 스키마 본문에 부여되는 정수 ID. 메시지에 실려 나갑니다 Registry 전역. 같은 스키마는 여러 subject에서 같은 id를 공유합니다

wire format 바이트 레이아웃

직렬화된 메시지의 앞부분은 고정된 구조를 갖습니다. Confluent 직렬화기의 소스에서 확인한 값은 MAGIC_BYTE_V0 = 0x0, ID_SIZE = 4입니다.

wire format v0 — magic byte + 4바이트 schema id + payload
바이트  0        1  2  3  4              5 ...
      ┌──────┬──────────────────────┬─────────────────────────┐
      │ 0x00 │  schema id (4 bytes) │  직렬화된 payload        │
      └──────┴──────────────────────┴─────────────────────────┘
        │         │                      │
        │         │                      └─ Avro binary / Protobuf wire / JSON 본문
        │         └─ 부호 있는 32비트 정수, big-endian (ByteBuffer.putInt 기본)
        └─ magic byte. 0x00 = "4바이트 schema id 형식"

예시 (schema id = 42, Avro payload 가 이어짐)
      00 00 00 00 2A  
      ^^  ^^^^^^^^^^^
     magic  42

Protobuf 는 schema id 뒤에 message index 배열이 추가됩니다
(한 .proto 파일 안의 여러 message 중 어느 것인지 지시).
wire format 바이트 레이아웃 — magic byte 1 + schema id 4 + payload Schema Registry 를 쓰는 직렬화 결과의 바이트 배치를 눈금으로 그린 그림입니다. 0번 바이트는 매직 바이트로 값이 0x00 입니다. 1번부터 4번까지 4바이트는 schema id 를 빅엔디언 정수로 담습니다. 그림의 예에서는 0x00 0x00 0x01 0x2F 이므로 schema id 는 303 입니다. 5번 바이트부터 끝까지가 payload 이며, Avro 바이너리로 인코딩된 값만 들어가고 스키마 본문은 들어가지 않습니다. 따라서 레코드당 고정 오버헤드는 정확히 5바이트입니다. 스키마 JSON 전문을 매 레코드에 넣으면 보통 수백에서 수천 바이트가 되므로, id 4바이트로 대체하는 것이 이 형식의 목적입니다. 참고로 Protobuf 는 schema id 뒤에 메시지 인덱스가 더 붙고, 매직 바이트가 0x01 인 형식은 id 대신 16바이트 GUID 를 담습니다. wire format — 레코드 앞 5바이트가 전부입니다 byte 0 1 2 3 4 5 … 레코드 value 0x00 magic 0x00 0x00 0x01 0x2F schema id = 303 payload — Avro 바이너리로 인코딩된 값 필드 이름도 스키마도 들어 있지 않습니다 1 B 4 B — big-endian 정수 가변 길이 고정 오버헤드 = 5바이트. 값이 1 KB 면 0.5% 도 되지 않습니다. 컨슈머는 앞 5바이트를 떼어 id 를 얻고, 그 id 로 읽기 스키마를 결정합니다. 레코드마다 붙는 스키마 정보의 크기 비교 id 4바이트 4 B 스키마 JSON 전문 수백 ~ 수천 B Protobuf 는 schema id 뒤에 메시지 인덱스가 더 붙습니다. Avro·JSON Schema 는 5바이트로 끝납니다. magic byte 가 0x01 인 형식은 id 4바이트 대신 16바이트 GUID 를 담습니다. 기본 형식은 0x00 입니다.
wire format 바이트 레이아웃 — magic byte 1바이트, schema id 4바이트(big-endian), 그 뒤의 payload. 실제 바이트 눈금으로 오프셋을 표시하고 Protobuf의 message index 추가분을 함께 보여줍니다.

subject naming strategy 3종

subject 이름을 어떻게 만들지가 호환성 검사의 단위를 결정합니다. Confluent 직렬화기 소스에서 확인한 세 전략의 산출 규칙입니다.

subject naming strategy — 소스에서 확인한 산출 규칙
전략 subject 이름 호환성 검사 단위 쓰는 곳
TopicNameStrategy
기본 동작
<topic>-key / <topic>-value 토픽마다 독립 한 토픽에 한 종류의 레코드. 대부분의 경우
RecordNameStrategy 레코드의 fully-qualified 이름 (예: com.example.OrderCreated) 레코드 타입마다 독립. 토픽과 무관 여러 토픽에 같은 타입이 흐르고, 그 타입의 진화를 한 곳에서 관리하고 싶을 때
TopicRecordNameStrategy <topic>-<레코드 이름> 토픽 × 레코드 타입 한 토픽에 여러 이벤트 타입을 섞어 보내면서 토픽별로 다른 진화를 허용할 때
subject naming strategy — Topic · Record · TopicRecord Schema Registry 가 스키마를 등록할 subject 이름을 정하는 세 가지 전략을 비교한 표입니다. 예시는 토픽 orders 와 레코드 타입 com.ex.OrderCreated, com.ex.OrderCancelled 입니다. TopicNameStrategy 는 기본 동작으로 subject 가 토픽 이름에 -key 또는 -value 를 붙인 형태입니다. 예를 들어 orders-value 가 되며, 한 토픽의 value 에는 사실상 스키마 하나만 둘 수 있습니다. RecordNameStrategy 는 subject 가 레코드의 전체 이름입니다. 예를 들어 com.ex.OrderCreated 가 되며, 여러 토픽이 같은 스키마를 공유하고 토픽과 무관하게 전역으로 진화합니다. TopicRecordNameStrategy 는 subject 가 토픽 이름과 레코드 전체 이름을 이은 형태입니다. 예를 들어 orders-com.ex.OrderCreated 가 되며, 한 토픽에 여러 이벤트 타입을 담으면서 토픽별로 독립적으로 진화시킬 수 있습니다. 설정 키는 key.subject.name.strategy 와 value.subject.name.strategy 입니다. 호환성 검사는 subject 단위로 이루어지므로 전략을 바꾸면 subject 이름이 바뀌어 기존 진화 이력과 끊깁니다. subject naming strategy — 호환성 검사는 subject 단위로 이루어집니다 예시 — 토픽 orders · 레코드 타입 com.ex.OrderCreated, com.ex.OrderCancelled 전략 subject 이름 언제 쓰는가 TopicName Strategy 기본 동작 {topic}-key {topic}-value → orders-value 한 토픽 = 한 스키마. 대부분의 경우 이걸로 충분합니다. 한 토픽에 여러 타입을 섞기 어렵습니다. RecordName Strategy {레코드 전체 이름} → com.ex.OrderCreated 토픽 이름이 들어가지 않습니다 여러 토픽이 같은 스키마를 공유할 때. 타입 단위로 전역 진화합니다. 한 토픽에 여러 타입을 담을 수 있습니다. TopicRecord NameStrategy {topic}-{레코드 이름} → orders-com.ex. OrderCreated 한 토픽에 여러 이벤트 타입을 담고 토픽별로 독립 진화시킬 때. 가장 유연하지만 subject 수가 늘어납니다. key.subject.name.strategy value.subject.name.strategy 기본: {topic}-value 전략을 바꾸면 subject 이름이 바뀌어 기존 진화 이력과 끊깁니다 — 운영 중 변경은 신중히. 클래스 이름은 Schema Registry 버전에 따라 다를 수 있습니다. 최신 배포판의 기본 클래스도 조회 결과가 없으면 TopicNameStrategy 로 동작합니다.
subject naming strategy 3종 — 같은 토픽·레코드 조합에 대해 Topic / Record / TopicRecord 전략이 각각 어떤 subject 이름을 만들고, 그 결과 호환성 검사 단위가 어떻게 달라지는지 비교합니다.
subject naming strategy 지정
value.subject.name.strategy=io.confluent.kafka.serializers.subject.TopicRecordNameStrategy
key.subject.name.strategy=io.confluent.kafka.serializers.subject.TopicNameStrategy

호환성 모드 7종 — 무엇을 어느 방향으로 비교하는가

모드가 하는 일은 두 개의 축으로 완전히 결정됩니다. ① 검사 방향(누가 reader이고 누가 writer인지)과 ② 비교 범위(직전 버전만인지 모든 버전인지)입니다. Confluent Schema Registry의 CompatibilityChecker 소스가 이를 명시적으로 정의합니다.

호환성 모드 7종 — 소스의 검사 전략과 대조 범위
모드 검사 방향 (소스 주석) reader / writer 비교 범위
BACKWARD
Registry 기본값
새 스키마가 이전 스키마로 생산된 데이터를 읽을 수 있는가 reader = 새 스키마
writer = 직전 버전
직전 버전만 (validateLatest)
BACKWARD_TRANSITIVE 새 스키마가 이전의 모든 스키마로 생산된 데이터를 읽을 수 있는가 reader = 새 스키마
writer = 모든 이전 버전
모든 이전 버전 (validateAll)
FORWARD 새 스키마로 생산된 데이터를 이전 스키마가 읽을 수 있는가 reader = 직전 버전
writer = 새 스키마
직전 버전만
FORWARD_TRANSITIVE 새 스키마로 생산된 데이터를 이전의 모든 스키마가 읽을 수 있는가 reader = 모든 이전 버전
writer = 새 스키마
모든 이전 버전
FULL 새 스키마가 forward이자 backward로 호환되는가 양방향 (mutual read) 직전 버전만
FULL_TRANSITIVE 새 스키마가 모든 이전 버전에 대해 양방향 호환되는가 양방향 모든 이전 버전
NONE 검사하지 않습니다 (no-op validator)

Avro 스키마 해석 규칙 — 매트릭스의 근거

허용/거부는 임의로 정해진 것이 아닙니다. Confluent의 AvroSchema.isBackwardCompatible()Avro의 SchemaCompatibility.checkReaderWriterCompatibility(reader, writer)를 직접 호출합니다. 따라서 매트릭스의 모든 칸은 Avro의 해석 규칙에서 유도됩니다.

Avro 해석 규칙 — Avro SchemaCompatibility 소스에서 확인
규칙내용
reader에만 있는 필드 reader 필드에 default가 있어야 합니다. 없으면 READER_FIELD_MISSING_DEFAULT_VALUE로 거부됩니다.
(예외: 필드 타입이 enum이고 enum default가 있으면 통과)
writer에만 있는 필드 조용히 무시됩니다. 아무 오류도 없습니다
필드 매칭 방법 이름으로 찾고, 없으면 reader 필드의 aliases로 writer 필드를 찾습니다. reader 쪽 alias만 유효합니다
타입 승격 (reader ← writer) long ← int · float ← int, long · double ← int, long, float · bytes ← string · string ← bytes.
역방향은 TYPE_MISMATCH
enum writer의 모든 심볼이 reader의 심볼 집합에 있어야 합니다. 없으면 MISSING_ENUM_SYMBOLS.
reader에 enum default가 있으면 통과합니다
union writer가 union이면 모든 분기를 reader가 읽을 수 있어야 합니다. reader에 없는 분기가 있으면 MISSING_UNION_BRANCH
레코드·fixed 이름 이름이 같거나 reader의 aliases가 writer의 full name을 포함해야 합니다. 아니면 NAME_MISMATCH
필드 순서 이름으로 매칭하므로 순서 변경은 영향이 없습니다
스키마 타입 불일치 Avro ↔ Protobuf처럼 타입이 다르면 즉시 거부됩니다 (Incompatible because of different schema type)

호환성 매트릭스 — 이 장의 핵심 산출물

위의 두 표를 결합하면 매트릭스가 계산됩니다. 각 칸의 판정 방법은 항상 같습니다 — BACKWARD는 reader=새 스키마, FORWARD는 reader=옛 스키마로 두고 Avro 해석 규칙을 적용합니다.

스키마 변경 유형 × 호환성 모드 — 허용(✓) / 거부(✕). Avro 기준
변경 유형 (v1 → v2) BACKWARD FORWARD FULL NONE 판정 근거
default 있는 필드 추가 BACKWARD: reader(v2)에만 있는 필드에 default가 있음 → 통과.
FORWARD: writer(v2)에만 있는 필드는 reader(v1)가 무시 → 통과
default 없는 필드 추가 BACKWARD: reader(v2)에만 있고 default 없음 → READER_FIELD_MISSING_DEFAULT_VALUE.
FORWARD: writer(v2)의 추가 필드는 무시됨
default 있는 필드 삭제 BACKWARD: writer(v1)에만 있는 필드는 reader(v2)가 무시.
FORWARD: reader(v1)에만 있지만 default가 있음 → 통과
default 없는 필드 삭제 BACKWARD: writer(v1)의 잉여 필드는 무시.
FORWARD: reader(v1)에만 있고 default 없음 → 거부
타입 승격 int → long BACKWARD: reader long ← writer int 승격 허용.
FORWARD: reader int ← writer longTYPE_MISMATCH
타입 축소 long → int 승격 방향이 뒤바뀐 경우. 위와 정확히 대칭입니다
타입 변경 string → int 승격 경로에 없는 조합 → 양방향 TYPE_MISMATCH
string ↔ bytes 변경 Avro는 양방향 승격을 허용합니다 (bytes ← string, string ← bytes 모두)
enum 심볼 추가 BACKWARD: reader(v2) 심볼 집합이 writer(v1)를 포함 → 통과.
FORWARD: writer(v2)의 새 심볼이 reader(v1)에 없음 → MISSING_ENUM_SYMBOLS. 단 v1에 enum default가 있으면 ✓
enum 심볼 삭제 위와 대칭. v2에 enum default가 있으면 BACKWARD도 ✓
필드 이름 변경 (default·alias 없음) 삭제 + 추가로 취급됩니다. 양쪽 모두 reader에만 있는 default 없는 필드가 생김 → 거부
필드 이름 변경 + 새 이름에 alias alias는 reader 쪽만 유효합니다. BACKWARD는 reader=v2이므로 v2의 alias가 작동. FORWARD는 reader=v1이고 v1에는 alias가 없음 → 거부
필드 이름 변경 + 양쪽 모두 default 있음 검사는 통과하지만 값이 옮겨지지 않습니다. 아래 경고 참조
T["null", T]
(nullable 로 변경, default null)
BACKWARD: reader union이 writer 분기 T를 포함 → 통과.
FORWARD: writer union의 null 분기를 reader T가 못 읽음 → 거부
["null", T]T
(nullable 제거)
위와 대칭
필드 순서 변경 Avro는 이름으로 매칭합니다. 순서는 무관합니다
레코드 이름 변경 (alias 없음) NAME_MISMATCH. reader의 aliases에 writer의 full name이 있어야 통과합니다
스키마 타입 변경
(Avro → Protobuf 등)
Registry가 방향 검사 이전에 스키마 타입 일치를 먼저 검사합니다

*_TRANSITIVE 모드는 위 표의 판정을 직전 버전 대신 모든 이전 버전에 대해 반복합니다. 한 버전이라도 거부되면 전체가 거부됩니다. 즉 비-TRANSITIVE에서 ✕인 것은 TRANSITIVE에서도 ✕이고, 비-TRANSITIVE에서 ✓라도 TRANSITIVE에서는 ✕일 수 있습니다.

스키마 호환성 모드 매트릭스 — 변경 유형별 허용·거부 가로는 스키마 변경 유형 일곱 가지, 세로는 호환성 모드 일곱 가지인 표입니다. 변경 유형은 기본값 있는 필드 추가, 기본값 없는 필드 추가, 기본값 있던 필드 삭제, 기본값 없던 필드 삭제, 타입 확대 int 에서 long, 타입 축소 long 에서 int, 별칭 없는 필드 이름 변경입니다. 호환성 모드는 BACKWARD, BACKWARD_TRANSITIVE, FORWARD, FORWARD_TRANSITIVE, FULL, FULL_TRANSITIVE, NONE 입니다. BACKWARD 는 새 스키마가 이전 스키마로 쓰인 데이터를 읽을 수 있는지 검사하고, FORWARD 는 새 스키마로 쓴 데이터를 이전 스키마가 읽을 수 있는지 검사하며, FULL 은 둘 다 검사합니다. TRANSITIVE 가 붙으면 직전 버전만이 아니라 모든 이전 버전과 비교합니다. BACKWARD 에서는 기본값 있는 필드 추가, 필드 삭제, 타입 확대가 허용되고 기본값 없는 필드 추가, 타입 축소, 이름 변경은 거부됩니다. FORWARD 에서는 필드 추가와 기본값 있던 필드 삭제, 타입 축소가 허용되고 기본값 없던 필드 삭제, 타입 확대, 이름 변경은 거부됩니다. FULL 은 양쪽 모두 허용인 경우만 허용하므로 기본값 있는 필드 추가와 기본값 있던 필드 삭제만 통과합니다. NONE 은 검사를 하지 않으므로 모두 통과하지만 깨진 조합도 등록됩니다. 이 표는 이전 버전이 하나인 v1 에서 v2 로의 변경을 기준으로 하므로 TRANSITIVE 열의 판정이 같습니다. 컨트롤에서 변경 유형과 호환성 모드를 고르면 그 칸이 강조되고 판정 이유가 표시됩니다. 스키마 호환성 매트릭스 — 이 변경을 이 모드에서 등록할 수 있는가 이 표는 v1 → v2 (이전 버전이 1개) 기준입니다. TRANSITIVE 는 판정 규칙이 아니라 비교 대상 버전 범위가 다릅니다. 호환성 모드 ↓ 변경 유형 → 필드 추가 기본값 O 필드 추가 기본값 X 필드 삭제 기본값 O 필드 삭제 기본값 X 타입 확대 int→long 타입 축소 long→int 필드 이름 변경 BACKWARD 직전 버전과 비교 허용 거부 허용 허용 허용 거부 거부 BACKWARD_TRANSITIVE 모든 이전 버전과 비교 허용 거부 허용 허용 허용 거부 거부 FORWARD 직전 버전과 비교 허용 허용 허용 거부 거부 허용 거부 FORWARD_TRANSITIVE 모든 이전 버전과 비교 허용 허용 허용 거부 거부 허용 거부 FULL 직전 버전 · 양방향 허용 거부 허용 거부 거부 거부 거부 FULL_TRANSITIVE 모든 이전 · 양방향 허용 거부 허용 거부 거부 거부 거부 NONE 검사하지 않음 허용 허용 허용 허용 허용 허용 허용 허용 BACKWARD × 필드 추가 (기본값 O) — 직전 버전과 비교 새 스키마(reader)의 새 필드는 옛 데이터에 없지만 default 로 채워집니다. 업그레이드 순서: 컨슈머를 먼저 올립니다. 허용 — 등록됩니다 거부 — 등록이 실패합니다 NONE — 검사 없이 통과 (보장도 없음) 기준: Avro 해석 규칙 — 필드는 이름(또는 reader 별칭)으로 대응하고, 못 찾으면 reader 쪽 default 가 있어야 합니다. 타입 승격은 int→long→float→double 과 bytes↔string 만 허용됩니다. 이름 변경은 별칭(alias)이나 default 가 있으면 결과가 달라집니다.
호환성 모드 매트릭스 — 변경 유형(필드 추가/삭제/타입 변경/이름 변경)과 모드(BACKWARD / FORWARD / FULL / NONE + TRANSITIVE)의 교차점에서 허용·거부를 확인하고, 셀을 선택하면 어느 Avro 해석 규칙이 그 판정을 만들었는지 표시합니다.

배포 순서 — 매트릭스에서 곧바로 나옵니다

이것은 암기 사항이 아니라 모드의 정의에서 논리적으로 따라 나오는 결론입니다. 각 모드가 무엇을 보장하는지 다시 보면 순서가 자동으로 정해집니다.

BACKWARD면 왜 컨슈머 먼저인가

BACKWARD가 보장하는 것은 "새 스키마가 옛 스키마로 생산된 데이터를 읽을 수 있다"입니다. 보장되지 않는 것은 그 반대 — 옛 스키마가 새 데이터를 읽는 것입니다.

따라서 새 코드는 옛 데이터를 안전하게 읽을 수 있고, 옛 코드는 새 데이터를 못 읽습니다. 새 스키마의 데이터가 토픽에 나타나기 전에 컨슈머를 모두 새 버전으로 올려야 합니다. 컨슈머를 먼저 배포하면, 전환 기간 동안 새 컨슈머가 옛 데이터를 읽습니다 — 이것이 BACKWARD가 보장하는 방향입니다.

순서를 뒤바꿔 프로듀서를 먼저 배포하면, 아직 옛 스키마를 쓰는 컨슈머가 새 스키마의 데이터를 만나 역직렬화에 실패합니다. 예를 들어 v2에서 default 있는 필드를 추가했다면(BACKWARD ✓, FORWARD도 ✓라 안전하지만), v2에서 default 없는 필드를 추가한 경우 BACKWARD에서는 애초에 거부되고, 필드를 삭제한 경우(BACKWARD ✓, FORWARD ✕) 옛 컨슈머는 자신이 요구하는 필드를 새 데이터에서 찾지 못해 실패합니다.

FORWARD면 왜 프로듀서 먼저인가

FORWARD가 보장하는 것은 "새 스키마로 생산된 데이터를 옛 스키마가 읽을 수 있다"입니다. 즉 옛 컨슈머가 새 데이터를 안전하게 읽습니다.

그래서 프로듀서를 먼저 올려도 됩니다. 컨슈머는 아직 옛 버전이지만 새 데이터를 문제없이 처리합니다. 컨슈머는 여유를 두고 나중에 전환합니다.

순서를 뒤바꿔 컨슈머를 먼저 배포하면, 새 컨슈머가 아직 옛 스키마로 생산되는 데이터를 읽어야 합니다. 그런데 FORWARD는 그 방향(새 reader ← 옛 writer)을 보장하지 않습니다. v2에서 default 없는 필드를 추가한 경우(FORWARD ✓, BACKWARD ✕), 새 컨슈머는 옛 데이터에서 그 필드를 찾지 못하고 default도 없어 실패합니다.

스키마 배포 순서 — BACKWARD 는 컨슈머 먼저, FORWARD 는 프로듀서 먼저 호환성 모드에 따른 배포 순서를 좌우로 비교한 그림입니다. 왼쪽 BACKWARD 는 새 스키마가 이전 스키마로 쓰인 데이터를 읽을 수 있음을 보장합니다. 허용되는 변경은 필드 삭제와 기본값 있는 필드 추가입니다. 따라서 새 스키마를 등록한 뒤 컨슈머를 먼저 새 스키마로 배포하고, 그 다음에 프로듀서를 배포합니다. 순서를 뒤바꿔 프로듀서를 먼저 올리면 새 데이터에서 필드가 사라졌는데 아직 옛 스키마를 쓰는 컨슈머가 그 필드를 요구하므로 역직렬화가 실패합니다. 오른쪽 FORWARD 는 새 스키마로 쓴 데이터를 이전 스키마가 읽을 수 있음을 보장합니다. 허용되는 변경은 필드 추가와 기본값 있던 필드 삭제입니다. 따라서 프로듀서를 먼저 새 스키마로 배포하고, 그 다음에 컨슈머를 배포합니다. 순서를 뒤바꿔 컨슈머를 먼저 올리면 새 컨슈머가 기본값 없는 새 필드를 요구하는데 아직 옛 스키마를 쓰는 프로듀서는 그 필드를 쓰지 않으므로 새 컨슈머가 실패합니다. FULL 과 FULL_TRANSITIVE 는 양방향이 보장되므로 순서가 자유롭고, NONE 은 어느 순서로 올려도 깨질 수 있습니다. 배포 순서 — 무엇을 먼저 올리는지가 장애를 결정합니다 BACKWARD 새 스키마가 옛 데이터를 읽음 허용: 필드 삭제 · 기본값 있는 필드 추가 ① 새 스키마를 레지스트리에 등록 ② 컨슈머를 먼저 새 스키마로 배포 ③ 그 다음 프로듀서를 배포 뒤바꾸면 — 프로듀서를 먼저 올리면 새 데이터에서 필드가 사라졌는데 옛 스키마 컨슈머는 그 필드를 요구합니다. → 구 컨슈머 역직렬화 실패 FORWARD 새 데이터를 옛 스키마가 읽음 허용: 필드 추가 · 기본값 있던 필드 삭제 ① 새 스키마를 레지스트리에 등록 ② 프로듀서를 먼저 새 스키마로 배포 ③ 그 다음 컨슈머를 배포 뒤바꾸면 — 컨슈머를 먼저 올리면 새 컨슈머가 기본값 없는 새 필드를 요구하는데 옛 프로듀서는 아직 그 필드를 쓰지 않습니다. → 새 컨슈머 역직렬화 실패 외우는 방법: BACKWARD = 컨슈머(뒤에서 읽는 쪽) 먼저 · FORWARD = 프로듀서(앞에서 쓰는 쪽) 먼저 FULL · FULL_TRANSITIVE 는 양방향이므로 순서가 자유롭습니다. NONE 은 어느 순서로 올려도 깨질 수 있습니다.
배포 순서 — BACKWARD에서는 컨슈머를 먼저, FORWARD에서는 프로듀서를 먼저 올리는 이유와, 순서를 뒤바꿨을 때 전환 기간 동안 어느 조합이 역직렬화에 실패하는지 나란히 표시합니다.
모드별 배포 순서와 뒤바꿨을 때의 결과
모드 보장하는 방향 배포 순서 뒤바꾸면
BACKWARD
BACKWARD_TRANSITIVE
새 코드 → 옛 데이터 읽기 컨슈머 먼저 → 프로듀서 옛 컨슈머가 새 데이터를 만나 역직렬화 실패
FORWARD
FORWARD_TRANSITIVE
옛 코드 → 새 데이터 읽기 프로듀서 먼저 → 컨슈머 새 컨슈머가 옛 데이터를 만나 역직렬화 실패
FULL
FULL_TRANSITIVE
양방향 순서 무관 문제 없음. 대신 허용되는 변경이 가장 좁습니다(default 있는 필드의 추가/삭제만)
NONE 없음 런타임에 터집니다. Registry가 막아 주지 않습니다

안전한 스키마 진화 규칙

매트릭스에서 실무 규칙을 추출하면 다음과 같습니다. Registry 기본값이 BACKWARD이므로 그 기준으로 정리합니다.

안전한 진화를 위한 실무 규칙
규칙이유
필드를 추가할 때는 항상 default를 준다 default 없는 추가는 BACKWARD에서 거부됩니다. default가 있으면 모든 모드에서 통과합니다
nullable 로 시작한다: ["null", T] + default: null 나중에 없앨 때도 안전합니다. "언젠가 값이 없을 수 있다"를 스키마에 미리 표현합니다
필드 이름은 바꾸지 않는다 거부되거나, 통과하더라도 값이 default로 대체되어 사라집니다
타입은 넓히는 방향만 (BACKWARD 기준) int → long → float → double. 축소는 BACKWARD에서 거부됩니다
enum 에는 default 심볼을 둔다 enum default가 있으면 심볼 추가/삭제 양방향이 완화됩니다. 처음 설계할 때 UNKNOWN 같은 값을 넣어 두세요
필드 삭제는 2단계 ① 먼저 쓰기를 멈추고(값을 default로 두고) 배포 → ② 컨슈머가 모두 그 필드를 안 쓰게 된 뒤 스키마에서 제거
이름 변경이 꼭 필요하면 3단계 ① 새 이름 필드를 default와 함께 추가 → ② 프로듀서가 두 필드를 모두 채움, 컨슈머는 새 필드로 전환 → ③ 옛 필드 제거
보관 기간이 길면 *_TRANSITIVE 비-TRANSITIVE는 직전 버전만 봅니다. 7일 이상 보관하는 토픽에서 v1 데이터를 v4 코드가 읽어야 한다면 직전 검사로는 부족합니다
배포 전에 호환성 API로 미리 검증 CI에서 POST /compatibility/subjects/{subject}/versions/latest로 검사하면 배포 후 실패를 막습니다
Avro 스키마 진화 — v1에서 v2로 안전하게
// v1
{
  "type": "record",
  "namespace": "com.example",
  "name": "OrderCreated",
  "fields": [
    { "name": "orderId",  "type": "string" },
    { "name": "amount",   "type": "int" },
    { "name": "status",   "type": { "type": "enum", "name": "Status",
                                    "symbols": ["NEW", "PAID"],
                                    "default": "NEW" } }
  ]
}
v2 — BACKWARD 안전한 변경만 모았습니다
{
  "type": "record",
  "namespace": "com.example",
  "name": "OrderCreated",
  "fields": [
    { "name": "orderId",  "type": "string" },

    // ✓ 타입 승격 int → long : reader(v2) long ← writer(v1) int 허용 → BACKWARD OK
    //   (FORWARD 로는 거부됩니다 — 모드가 BACKWARD 임을 전제합니다)
    { "name": "amount",   "type": "long" },

    // ✓ enum 심볼 추가 : reader(v2) 심볼 집합이 writer(v1) 를 포함 → BACKWARD OK
    //   default 심볼이 있으므로 FORWARD 도 완화됩니다
    { "name": "status",   "type": { "type": "enum", "name": "Status",
                                    "symbols": ["NEW", "PAID", "REFUNDED"],
                                    "default": "NEW" } },

    // ✓ default 있는 필드 추가 : 모든 모드에서 통과
    { "name": "currency", "type": "string", "default": "KRW" },

    // ✓ nullable + default null : 나중에 제거할 때도 안전합니다
    { "name": "couponId", "type": ["null", "string"], "default": null }
  ]
}
거부되는 변경 — 무엇이 왜 걸리는지
{
  "type": "record",
  "namespace": "com.example",
  "name": "OrderCreated",
  "fields": [
    // ✕ 이름 변경 (orderId → order_id, alias 없음)
    //   → BACKWARD: reader(v2) 의 order_id 가 writer(v1) 에 없고 default 없음 → 거부
    { "name": "order_id", "type": "string" },

    // ✕ default 없는 필드 추가 → BACKWARD 거부
    //   READER_FIELD_MISSING_DEFAULT_VALUE
    { "name": "channel",  "type": "string" },

    // ✕ 타입 축소 int → 유지했더라도, long → int 였다면 BACKWARD 거부
    //   TYPE_MISMATCH (reader int ← writer long 은 승격 경로가 아님)
    { "name": "amount",   "type": "int" }
  ]
}
이름 변경이 꼭 필요할 때 — reader 측 alias
{
  "name": "order_id",
  "type": "string",
  // alias 는 reader 쪽에서만 해석됩니다.
  // reader(v2) 가 writer(v1) 의 orderId 필드를 이 alias 로 찾아냅니다 → BACKWARD OK.
  // 반대로 FORWARD 는 reader 가 v1 이고 v1 에는 alias 가 없으므로 거부됩니다.
  "aliases": ["orderId"]
}

직렬화기 설정과 REST API

KafkaAvroSerializer 설정

Schema Registry 직렬화기 주요 설정 — 소스에서 확인한 기본값
설정 기본값 의미 프로덕션 권장
schema.registry.url Registry 엔드포인트. 쉼표로 여러 개 지정 가능 이중화를 위해 여러 개
auto.register.schemas true 직렬화 시 스키마가 없으면 자동 등록 false — 아래 경고 참조
use.latest.version false 자동 등록 대신 subject의 최신 버전을 사용 auto.register.schemas=false와 함께 true를 고려
latest.compatibility.strict true use.latest.version 사용 시 최신 버전과의 호환성을 엄격히 검사 기본값 유지
use.schema.id -1 (미사용) 직렬화에 쓸 schema id를 고정 특수한 경우만
normalize.schemas false 등록·조회 시 스키마를 정규화해 의미상 동일한 스키마의 중복 등록을 방지 true 검토 — 불필요한 버전 증가를 막습니다
key.subject.name.strategy <topic>-key를 만드는 전략 키 스키마의 subject 이름 결정 기본값 유지
value.subject.name.strategy <topic>-value를 만드는 전략 값 스키마의 subject 이름 결정 한 토픽에 여러 타입이면 TopicRecordNameStrategy
max.schemas.per.subject 1000 클라이언트 로컬 캐시의 subject당 스키마 수 상한
specific.avro.reader false GenericRecord 대신 생성된 클래스로 역직렬화 코드 생성 방식을 쓰면 true
프로듀서 설정 — 프로덕션 구성
bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=io.confluent.kafka.serializers.KafkaAvroSerializer

schema.registry.url=http://schema-registry-1:8081,http://schema-registry-2:8081

# 스키마 등록은 CI/CD 에서만. 애플리케이션은 등록하지 않습니다.
auto.register.schemas=false
# 이미 등록된 최신 버전을 사용합니다.
use.latest.version=true
# 의미상 동일한 스키마의 중복 등록을 방지합니다.
normalize.schemas=true
컨슈머 설정
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=io.confluent.kafka.serializers.KafkaAvroDeserializer

schema.registry.url=http://schema-registry-1:8081,http://schema-registry-2:8081

# 생성된 Avro 클래스로 역직렬화 (기본값 false → GenericRecord)
specific.avro.reader=true

Registry REST API — 주요 엔드포인트

Schema Registry REST 리소스 — 소스의 @Path 애너테이션에서 확인
메서드 · 경로용도
GET /subjectssubject 목록
POST /subjects/{subject}이 subject에 해당 스키마가 이미 등록되어 있는지 조회
DELETE /subjects/{subject}subject 삭제
GET /subjects/{subject}/versions버전 목록
POST /subjects/{subject}/versions스키마 등록 (호환성 검사가 여기서 수행됩니다)
GET /subjects/{subject}/versions/{version}특정 버전 조회. {version}latest 사용 가능
GET /subjects/{subject}/versions/{version}/schema스키마 본문만 조회
DELETE /subjects/{subject}/versions/{version}특정 버전 삭제
GET /subjects/{subject}/versions/{version}/referencedby이 스키마를 참조하는 스키마 목록
POST /compatibility/subjects/{subject}/versions/{version}배포 전 호환성 검사. 등록하지 않고 판정만 받습니다
POST /compatibility/subjects/{subject}/versions모든 버전에 대한 호환성 검사
GET /config · PUT /config전역 호환성 레벨 조회·변경
GET /config/{subject} · PUT /config/{subject}subject별 호환성 레벨 조회·변경 (전역 설정을 오버라이드)
DELETE /config/{subject}subject별 오버라이드 제거 → 전역 설정으로 복귀
GET /schemas/ids/{id}schema id로 스키마 조회 (컨슈머가 실제로 호출하는 경로)
GET /schemas/ids/{id}/subjects · /versions이 id를 쓰는 subject와 버전
GET /schemas/types지원하는 스키마 타입 목록 (AVRO / PROTOBUF / JSON)
GET /mode · PUT /mode · PUT /mode/{subject}Registry 동작 모드 (읽기 전용 등)
CI에서 호환성 검사 — 배포 전에 거부를 잡습니다
# 새 스키마를 등록하지 않고 판정만 받습니다.
curl -sS -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema": "{\"type\":\"record\",\"name\":\"OrderCreated\",\"namespace\":\"com.example\",\"fields\":[{\"name\":\"orderId\",\"type\":\"string\"}]}"}' \
  http://schema-registry:8081/compatibility/subjects/orders-value/versions/latest
# {"is_compatible": true}   또는  {"is_compatible": false, "messages": [...]}

# subject 별 호환성 모드 확인 · 변경
curl -sS http://schema-registry:8081/config/orders-value
curl -sS -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"compatibility": "BACKWARD_TRANSITIVE"}' \
  http://schema-registry:8081/config/orders-value

# 전역 기본값 확인 (Registry 기본값은 BACKWARD)
curl -sS http://schema-registry:8081/config

흔한 오해

시험 포인트 요약

확인 문제

호환성 매트릭스의 각 칸과 배포 순서, wire format 바이트 구성이 집중적으로 나옵니다.

공식 문서 출처

Schema Registry는 Apache Kafka 본체가 아니므로, 이 장의 사실 확인은 Confluent Schema Registry와 Apache Avro의 공개 소스에서 했습니다. 각 항목이 어디서 확인되었는지 명시합니다.