본문 바로가기
Cloud Computing & MSA

Kafka 스키마를 안전하게 바꾸기 - Schema Registry와 Avro로 호환성 지키고 배포 순서 사고 막기

후아빠·2026년 9월 6일·조회 1

메시지 스키마에 필드 하나 추가하는 일은 사소해 보인다. 그런데 카프카처럼 프로듀서와 컨슈머가 따로 배포되는 구조에서는 이 사소한 변경이 컨슈머를 멈춰 세우는 원인이 된다. 예전에 JSON 문자열을 그냥 카프카에 밀어 넣던 서비스를 운영하다가, 필드 이름 하나 바꾼 배포가 다운스트림 컨슈머를 줄줄이 죽이는 걸 겪고 나서 Schema Registry를 진지하게 붙이게 됐다. 이 글은 그때 정리한 실무 메모다.

요약부터 적자면, Kafka에 Confluent Schema Registry와 Avro를 붙이면 스키마를 중앙에서 버전으로 관리하고, 호환되지 않는 변경은 등록 단계에서 막을 수 있다. 핵심은 호환성 모드 선택이다. 기본값인 BACKWARD는 컨슈머를 먼저 배포해야 하고, FORWARD는 프로듀서를 먼저 배포해야 한다. 이 배포 순서를 지키지 않는 것이 대부분의 스키마 사고 원인이다.

1. Schema Registry와 Avro가 하는 일

Kafka 브로커는 메시지를 바이트 배열로만 본다. 이 바이트가 어떤 구조인지는 프로듀서와 컨슈머가 알아서 약속해야 한다. 문제는 그 약속이 코드 안에만 있어서, 한쪽이 구조를 바꾸면 다른 쪽은 배포 전까지 알 방법이 없다는 점이다.

Schema Registry는 이 약속을 코드 밖으로 꺼내 중앙 저장소에 둔다. 스키마를 등록하면 정수 ID를 돌려주고, 프로듀서는 메시지 앞 5바이트에 이 ID만 실어 보낸다. 컨슈머는 그 ID로 레지스트리에서 스키마를 조회해 역직렬화한다. 스키마 본문이 매 메시지에 붙지 않아 payload가 작아지는 것은 덤이다.

Avro는 여기서 쓰는 직렬화 포맷이다. 스키마를 JSON으로 정의하고, 데이터는 컴팩트한 바이너리로 인코딩한다. Avro 스키마는 필드마다 default 값을 줄 수 있는데, 이 default가 뒤에서 볼 호환성 판정의 핵심 재료다.

2. subject와 스키마 버전

레지스트리는 스키마를 subject 단위로 관리한다. subject는 스키마 버전들이 쌓이는 이름공간이다. 기본 전략인 TopicNameStrategy에서는 subject 이름이 토픽에서 자동으로 만들어진다. 토픽이 orders라면 값 스키마의 subject는 orders-value, 키 스키마는 orders-key가 된다.

같은 subject에 스키마를 새로 등록하면 버전이 하나 올라간다. 이때 레지스트리는 그 subject에 걸린 호환성 규칙에 맞는지 검사하고, 어긋나면 등록을 거부한다. 즉 호환성은 subject마다 따로 설정할 수 있다.

3. 로컬에 레지스트리 띄우기

브로커와 레지스트리를 한 번에 올리려면 Confluent 이미지로 compose를 쓰는 방식이 가장 빠르다. 최소 구성은 이렇다.

version: "3"
services:
  broker:
    image: confluentinc/cp-kafka:7.7.0
    ports: ["9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:29093
      KAFKA_LISTENERS: PLAINTEXT://broker:29092,CONTROLLER://broker:29093,EXTERNAL://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,EXTERNAL://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
  schema-registry:
    image: confluentinc/cp-schema-registry:7.7.0
    depends_on: [broker]
    ports: ["8081:8081"]
    environment:
      SCHEMA_REGISTRY_HOST_NAME: schema-registry
      SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: broker:29092
      SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081

올린 뒤 레지스트리가 응답하는지, 그리고 전역 호환성 기본값을 확인한다.

$ curl -s http://localhost:8081/subjects
[]

$ curl -s http://localhost:8081/config
{"compatibilityLevel":"BACKWARD"}

subject가 아직 없어 빈 배열이 나오고, 전역 호환성은 BACKWARD다. 별도로 바꾸지 않으면 모든 subject가 이 값을 상속한다.

4. Avro 스키마 등록과 프로듀서/컨슈머 설정

먼저 스키마를 정의한다. 주문 이벤트 v1이다.

{
  "type": "record",
  "name": "Order",
  "namespace": "io.sarc.orders",
  "fields": [
    {"name": "orderId", "type": "string"},
    {"name": "amount", "type": "long"}
  ]
}

Spring Boot 프로듀서라면 직렬화기로 KafkaAvroSerializer를 지정하고 레지스트리 주소를 준다. application.yml 예시다.

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
      properties:
        schema.registry.url: http://localhost:8081
        auto.register.schemas: false
    consumer:
      group-id: order-worker
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
      properties:
        schema.registry.url: http://localhost:8081
        specific.avro.reader: true

auto.register.schemas: false를 왜 넣었는지는 8절에서 따로 다룬다. 우선은 스키마를 REST로 직접 등록해 두자. Avro JSON을 문자열로 escape해서 schema 필드에 담는다.

$ curl -s -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema": "{\"type\":\"record\",\"name\":\"Order\",\"namespace\":\"io.sarc.orders\",\"fields\":[{\"name\":\"orderId\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"long\"}]}"}' \
  http://localhost:8081/subjects/orders-value/versions
{"id":1}

스키마 ID 1이 발급됐다. 이제 프로듀서가 보내는 메시지에는 이 ID가 실린다.

5. 호환성 모드 - BACKWARD, FORWARD, FULL

호환성 모드는 새 스키마 버전을 받아들일지 말지를 결정하는 규칙이다. Confluent 문서 기준으로 정리하면 이렇다.

BACKWARD (기본값)

새 스키마로 만든 컨슈머가 옛 스키마로 쓰인 데이터를 읽을 수 있어야 한다. 허용되는 변경은 필드 삭제default가 있는 옵션 필드 추가다. 필드를 새로 추가할 때 default가 없으면 옛 데이터를 읽을 때 채울 값이 없으므로 거부된다.

FORWARD

옛 스키마로 만든 컨슈머가 새 스키마로 쓰인 데이터를 읽을 수 있어야 한다. 허용되는 변경은 필드 추가옵션 필드 삭제다. BACKWARD와 정확히 대칭이다.

FULL

BACKWARD와 FORWARD를 동시에 만족해야 한다. 결과적으로 default가 있는 옵션 필드의 추가와 삭제만 허용된다. 규칙이 가장 빡빡한 대신 프로듀서와 컨슈머 배포 순서를 신경 쓰지 않아도 된다.

TRANSITIVE 접미사

BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE도 있다. 접미사가 없는 기본형은 바로 직전 버전 하나하고만 비교한다. TRANSITIVE는 그 subject의 모든 과거 버전과 비교한다. 오래된 데이터를 오래 보관하는 토픽이라면 TRANSITIVE가 안전하다.

NONE

호환성 검사를 끈다. 어떤 변경이든 통과한다. 실험용 토픽이 아니면 쓸 일이 없다.

subject 하나의 호환성만 바꾸려면 /config/{subject}에 PUT을 보낸다.

$ curl -s -X PUT \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"compatibility": "FULL"}' \
  http://localhost:8081/config/orders-value
{"compatibility":"FULL"}

6. 배포 순서가 사고를 부르는 지점

여기가 이 글의 핵심이다. 호환성 모드마다 프로듀서와 컨슈머 중 누구를 먼저 배포해야 하는지가 정해져 있다. 문서의 표현을 그대로 옮기면 이렇다.

  • BACKWARD: 컨슈머를 전부 새 스키마로 올린 다음, 프로듀서가 새 스키마로 쓰기 시작한다.
  • FORWARD: 프로듀서를 먼저 올리고, 그다음 컨슈머를 올린다.

왜 이런지 BACKWARD로 따져 보자. BACKWARD에서 필드 하나를 추가한 v2 스키마를 등록했다고 하자. v2 컨슈머는 default 덕분에 v1 데이터도 읽는다. 반대로 v1 컨슈머는 v2가 만든 데이터를 만나면 모르는 필드에 걸려 깨질 수 있다. 그래서 컨슈머를 먼저 v2로 올려 둔 다음에 프로듀서가 v2를 내보내야 안전하다.

순서를 뒤집으면 사고가 난다. 프로듀서를 먼저 배포해 v2 데이터가 토픽에 흘러 들어가는데 컨슈머는 아직 v1이라면, v1 컨슈머가 새 데이터를 만나 역직렬화에서 멈춘다. 배포 자체는 성공했는데 몇 분 뒤 컨슈머 랙이 치솟고 다음과 같은 로그가 쏟아지는 상황이 전형적이다.

org.apache.kafka.common.errors.SerializationException: 
  Error deserializing key/value for partition orders-0 at offset 48213
Caused by: org.apache.avro.AvroTypeException: 
  Found io.sarc.orders.Order, expecting io.sarc.orders.Order, missing required field ...

정리하면 이렇게 외운다. BACKWARD는 소비하는 쪽(컨슈머)을 먼저, FORWARD는 생산하는 쪽(프로듀서)을 먼저. 이 순서를 팀에서 강제하기 어렵다면, 배포 순서에 자유로운 FULL을 쓰는 편이 사고를 줄인다. 대신 스키마 변경 자유도가 줄어든다.

7. 호환성 사전 검사를 CI에 넣기

변경한 스키마가 규칙을 통과하는지는 등록 전에 미리 물어볼 수 있다. /compatibility/subjects/{subject}/versions/{version}에 POST하면 등록하지 않고 판정만 받는다.

$ curl -s -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema": "{ ...변경한 Avro 스키마... }"}' \
  "http://localhost:8081/compatibility/subjects/orders-value/versions/latest?verbose=true"
{"is_compatible":true}

깨지는 변경이면 is_compatible이 false로 나오고, verbose=true를 붙였으니 어떤 필드가 문제인지 이유도 함께 온다. 이 호출을 CI 파이프라인에 넣어 두면 호환 안 되는 스키마가 머지되는 것을 코드 리뷰 단계에서 걸러낼 수 있다. Gradle이나 Maven용 Schema Registry 플러그인에도 같은 검사를 하는 testSchemasTask류 태스크가 있으니 빌드에 물려도 된다.

8. auto.register.schemas는 운영에서 끈다

KafkaAvroSerializerauto.register.schemas는 기본값이 true다. 이 상태면 프로듀서가 처음 보는 스키마를 만날 때마다 알아서 레지스트리에 등록한다. 개발 중에는 편하지만 운영에서는 위험하다. 애플리케이션 코드에 생긴 우연한 스키마 차이가 그대로 새 버전으로 등록돼 버리기 때문이다. 스키마 관리 주체가 코드가 되어 통제가 사라진다.

운영에서는 auto.register.schemas=false로 끄고, 스키마 등록은 앞서 본 REST 호출이나 CI의 등록 태스크로만 하도록 창구를 하나로 묶는다. 이렇게 하면 등록 시점에 호환성 검사가 강제되고, 누가 언제 어떤 버전을 올렸는지도 추적된다.

여기에 use.latest.version=true를 함께 주면, 프로듀서는 객체에서 스키마를 유추하지 않고 subject의 최신 등록 버전을 가져와 직렬화에 쓴다. 미리 등록한 스키마와 클라이언트가 유추한 스키마 사이의 미묘한 차이로 중복 등록이 생기는 문제를 막을 수 있다.

9. 마무리

Schema Registry와 Avro의 값어치는 결국 두 가지다. 호환되지 않는 스키마 변경을 등록 단계에서 막는 것, 그리고 그 대가로 배포 순서라는 규칙을 지켜야 한다는 것이다. 토픽 성격에 맞는 호환성 모드를 고르고(오래 보관하면 TRANSITIVE, 배포 순서 통제가 어려우면 FULL), 운영에서는 자동 등록을 끄고, 호환성 검사를 CI에 넣는다. 이 세 가지만 자리 잡으면 필드 하나 추가하는 배포가 컨슈머를 멈추는 사고는 거의 사라진다.

자주 묻는 질문

Schema Registry의 기본 호환성 모드는 무엇인가?

BACKWARD다. 별도로 바꾸지 않으면 전역 설정과 모든 subject가 BACKWARD를 따른다. 이 모드에서는 필드 삭제와 default가 있는 옵션 필드 추가만 허용되며, 컨슈머를 프로듀서보다 먼저 배포해야 한다. GET /config로 현재 값을 확인할 수 있다.

BACKWARD와 FORWARD 호환성 모드의 배포 순서는 어떻게 다른가?

BACKWARD는 새 컨슈머가 옛 데이터를 읽는 것을 보장하므로 컨슈머를 먼저 배포한 뒤 프로듀서를 올린다. FORWARD는 옛 컨슈머가 새 데이터를 읽는 것을 보장하므로 프로듀서를 먼저 배포한 뒤 컨슈머를 올린다. 순서를 뒤집으면 역직렬화 오류로 컨슈머가 멈출 수 있다.

일반 모드와 TRANSITIVE 모드의 차이는?

접미사가 없는 BACKWARD, FORWARD, FULL은 바로 직전 버전 하나하고만 호환성을 비교한다. BACKWARD_TRANSITIVE처럼 TRANSITIVE가 붙으면 그 subject의 모든 과거 버전과 비교한다. 오래된 메시지를 장기간 보관하는 토픽이라면 TRANSITIVE가 안전하다.

운영 환경에서 auto.register.schemas를 왜 끄는가?

기본값 true에서는 프로듀서가 처음 보는 스키마를 자동으로 레지스트리에 등록해, 코드의 우연한 차이가 그대로 새 버전이 되어 통제가 사라진다. false로 끄고 등록을 REST 호출이나 CI 태스크로 일원화하면 등록 시점에 호환성 검사가 강제되고 변경 이력도 추적된다.

스키마를 등록하기 전에 호환성을 미리 검사할 수 있나?

가능하다. POST /compatibility/subjects/{subject}/versions/latest 에 새 스키마를 보내면 등록하지 않고 판정만 받는다. 응답의 is_compatible이 결과이고, 쿼리에 verbose=true를 붙이면 어떤 필드가 문제인지 이유도 함께 온다. 이 호출을 CI에 넣어 머지 전에 거를 수 있다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.