본문 바로가기

Apache Avro - 스키마를 파일에 넣으면 생기는 일

·7분 읽기·
목차

이전 글에서는 BigQuery가 Parquet 대신 자체 포맷 Capacitor를 쓰는 이유를 살펴봤습니다. Row Reordering으로 압축률을 높여서, compute와 storage 사이의 네트워크 전송량을 줄이는 구조였습니다.

이번 글에서는 다시 행 기반 포맷으로 돌아옵니다. Apache Avro는 Protobuf와 같은 바이너리 직렬화 포맷이지만, 필드마다 붙이던 태그 번호를 아예 없앴습니다. 태그를 빼면 크기는 줄지만, 스키마 없이는 바이트를 해석할 수 없게 됩니다. Avro는 이 문제를 스키마를 파일 안에 넣는 방식으로 풀었습니다.


1. Avro의 인코딩 - 태그가 없음

1-1. Protobuf와의 차이

Avro도 Protobuf처럼 행 기반 바이너리 직렬화 포맷입니다. 하나의 레코드를 바이트 스트림으로 변환합니다. 하지만 인코딩 방식이 근본적으로 다릅니다.

Protobuf는 각 필드에 태그 번호를 붙입니다. (앞에서 다뤘던 구조)

Protobuf 인코딩 (User: name="Alice", age=30):

[tag=1, type=2, len=5, "Alice"] [tag=2, type=0, 30]
 ─── 필드 1 ──────────────────   ─── 필드 2 ────────

태그 번호 덕분에 바이트만 봐도 "이 값은 필드 1, 다음은 필드 2"라고 판단할 수 있습니다. .proto 파일이 없어도 필드 경계는 구분됩니다.

Avro는 태그를 아예 쓰지 않습니다. 스키마에 정의된 순서대로 값만 나열합니다.

Avro 인코딩 (User: name="Alice", age=30):

[len=5, "Alice"]  [30]
 ───── 값 ──────  ─ 값 ─

태그도 타입 정보도 없습니다. 바이트만 봐서는 첫 번째 값이 이름인지 주소인지 알 수 없습니다. 스키마가 있어야만 바이트를 해석할 수 있습니다.

1-2. 태그를 빼면 무엇이 좋은가

메시지 크기가 줄어듭니다. Protobuf는 필드마다 태그 오버헤드가 붙지만(필드 번호에 따라 다르지만 보통 1~2바이트), Avro는 이 오버헤드가 없습니다.

같은 데이터 (name="Alice", age=30):

              tag  len  문자열   tag  값
Protobuf:    [1B] [1B] [5B]   [1B] [1B]   = 9 bytes
Avro:              [1B] [5B]       [1B]   = 7 bytes

차이: 태그 2개 = 2바이트

필드 2개에서 2바이트 차이는 작아 보이지만, 필드가 수십 개인 레코드에서는 누적됩니다. Kafka처럼 초당 수만 건의 메시지를 전송하는 환경에서 메시지당 수십 바이트를 줄이는 것은 전체 네트워크 처리량에 영향을 줍니다.

1-3. 대신 스키마에 의존한다

태그를 빼는 대가가 있습니다. 스키마 없이는 바이트를 해석할 수 없습니다.

Protobuf는 태그가 있으니 모르는 필드가 나와도 건너뛸 수 있습니다. 구버전 코드가 새 필드를 만나면, 태그를 보고 길이만큼 넘기면 됩니다. Avro는 이것이 불가능합니다. 값의 시작과 끝을 스키마 없이는 구분할 수 없기 때문입니다.

그래서 Avro는 데이터를 읽을 때 반드시 데이터를 쓸 때 사용한 스키마가 필요합니다. 이 스키마를 어디에 두느냐가 Avro 포맷의 핵심 설계입니다.


2. 스키마를 파일에 넣는다

2-1. Avro 파일 구조

Avro 파일(.avro)은 Header + Data Block으로 구성됩니다. Header에 스키마가 JSON으로 들어갑니다.

Avro 파일 (.avro):

┌───────────────────────────────┐
│  Header                       │
│  · Magic Bytes ("Obj1")       │
│  · Writer Schema (JSON)       │
│  · Codec (snappy, deflate 등) │
│  · Sync Marker (16 bytes)     │
├───────────────────────────────┤
│  Data Block 0                 │
│  · 레코드 수                    │
│  · 직렬화된 레코드들 (연속)        │
│  · Sync Marker                │
├───────────────────────────────┤
│  Data Block 1                 │
│  · ...                        │
│  · Sync Marker                │
├───────────────────────────────┤
│  ...                          │
└───────────────────────────────┘

Writer Schema는 이 파일을 쓸 때 사용한 스키마입니다. JSON으로 저장되므로 별도의 파서 없이 읽을 수 있습니다.

json
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "name", "type": "string"},
    {"name": "age", "type": "int"}
  ]
}

파일만 열면 Header에서 데이터 구조를 바로 알 수 있습니다. Protobuf는 이것이 안 됩니다. .proto 파일이 없으면 바이트가 무엇을 의미하는지 알 수 없습니다.

Parquet도 Footer에 스키마를 넣습니다. 하지만 목적이 다릅니다. Parquet의 스키마는 열 기반 저장을 위한 메타데이터이고, Avro의 스키마는 태그 없는 바이트를 해석하기 위한 키입니다. Avro는 스키마가 없으면 바이트의 경계조차 알 수 없습니다.

2-2. Sync Marker

Header 끝에 임의의 16바이트 Sync Marker가 생성되고, 각 Data Block 끝에 같은 Marker가 반복됩니다. 역할은 두 가지입니다.

  • 블록 경계 식별: 파일 중간이 손상되어도 다음 Sync Marker를 찾으면 다음 블록부터 읽을 수 있습니다
  • 분할 가능성(Splittability): MapReduce나 Spark에서 파일을 나눠 읽을 때, Sync Marker 기준으로 블록 단위 분할이 가능합니다

CSV는 줄바꿈으로 분할하고, Parquet는 Row Group 단위로 분할합니다. Avro는 Sync Marker 단위입니다.


3. Writer Schema와 Reader Schema

3-1. 스키마가 바뀌는 현실

데이터 시스템에서 스키마는 반드시 바뀝니다. 서비스가 성장하면서 필드가 추가되고, 사용하지 않는 필드는 제거됩니다.

v1 (2024년 1월):
  name: string
  age: int

v2 (2024년 6월, email 추가):
  name: string
  age: int
  email: string

v1 스키마로 저장한 파일이 수백 개 있는데, 지금 코드는 v2 스키마를 기준으로 데이터를 읽습니다. 어떻게 해야 할까요?

Protobuf라면 태그 기반이므로 간단합니다. v1 바이트에 email에 해당하는 태그 번호가 없으면, v2 코드는 해당 필드를 기본값으로 채웁니다. 모르는 태그는 건너뛰고, 없는 태그는 기본값으로 처리됩니다.

Avro는 태그가 없으므로 이 방식이 불가능합니다. 대신 Schema Resolution이라는 메커니즘을 씁니다.

3-2. Schema Resolution

Avro에서 데이터를 읽을 때는 두 개의 스키마가 관여합니다.

  • Writer Schema: 데이터를 직렬화할 때 사용한 스키마. 파일 Header에 저장되어 있음
  • Reader Schema: 데이터를 읽는 쪽이 원하는 스키마. 애플리케이션 코드에 정의되어 있음

Reader는 파일을 열면 먼저 Header에서 Writer Schema를 꺼냅니다. 그리고 자신의 Reader Schema와 필드 이름 기준으로 대조합니다.

Writer Schema (v1)         Reader Schema (v2)

┌──────────────┐           ┌──────────────────┐
│ name: string │ ──매칭──>  │ name: string     │
│ age: int     │ ──매칭──>  │ age: int         │
│              │           │ email: string    │ ← Writer에 없음
└──────────────┘           │ default: ""      │
                           └──────────────────┘

Resolution 규칙은 세 가지입니다.

  1. 양쪽에 있는 필드: Writer Schema 순서대로 바이트를 읽고, Reader Schema의 해당 필드에 채움
  2. Writer에 있고 Reader에 없는 필드: 바이트에서 해당 값을 읽되, 결과에서 버림
  3. Writer에 없고 Reader에 있는 필드: Reader Schema에 정의된 default 값을 사용. default가 없으면 에러
json
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "name", "type": "string"},
    {"name": "age", "type": "int"},
    {"name": "email", "type": "string", "default": ""}
  ]
}

참고로 Avro에서 null을 허용하려면 {"type": ["null", "string"]} 처럼 Union 타입으로 정의해야 합니다. 명시하지 않으면 null이 들어올 때 에러가 발생합니다.

v1 파일을 v2 코드로 읽으면, nameage는 파일에서 가져오고, email은 빈 문자열로 채워집니다. Writer Schema가 파일에 들어있으니 가능한 일입니다.

3-3. 이름 기반 매칭의 트레이드오프

Protobuf는 태그 번호로 필드를 식별하고, Avro는 필드 이름으로 식별합니다. 이 차이가 스키마 변경 시 호환성에 영향을 줍니다.

변경 종류ProtobufAvro
필드 추가새 태그 번호 배정default 값 필수 (하위 호환성 조건)
필드 삭제태그 번호를 재사용하지 않으면 됨Reader에서 해당 필드를 빼면 됨
필드 이름 변경자유 (태그 번호가 유지되면 문제 없음)호환성 깨짐 (aliases로 우회 가능)
필드 순서 변경자유 (태그로 식별)자유 (이름으로 매칭)

Protobuf는 태그 번호를 바꾸지 않는다는 규칙만 지키면 됩니다. Avro는 필드 이름을 바꾸지 않는다는 규칙을 지켜야 합니다. 이름을 바꿔야 할 때는 aliases로 이전 이름을 매핑할 수 있습니다.

json
{"name": "full_name", "type": "string", "aliases": ["name"]}

이전 이름 name으로 저장된 데이터를 full_name으로 읽을 수 있습니다.


4. Kafka에서 Avro가 표준이 된 이유

4-1. 스트리밍에서 스키마 진화 문제

Kafka 토픽에는 프로듀서가 계속 메시지를 보냅니다. 서비스가 업데이트되면서 메시지 스키마가 바뀌면, 토픽 안에 여러 버전의 스키마로 직렬화된 메시지가 섞입니다.

Kafka Topic: user-events

[v1 메시지] [v1 메시지] [v2 메시지] [v2 메시지] [v1 메시지] ...

                  프로듀서 업데이트

컨슈머는 과거 메시지와 새 메시지를 모두 읽을 수 있어야 합니다. Avro 파일이라면 Header에서 Writer Schema를 읽으면 되지만, Kafka 메시지 하나하나에 스키마 전체를 넣으면 메시지마다 스키마가 중복되어 크기가 커집니다.

4-2. Schema Registry

이 문제를 Schema Registry가 풀어줍니다. 스키마를 중앙 서버에 등록하고, 메시지에는 schema ID(정수 4바이트)만 넣습니다.

프로듀서:
  스키마 등록 → Schema Registry가 schema_id = 42 반환
  메시지 전송 → [magic byte, schema_id=42, Avro 바이트]
                 ── 5 bytes 헤더 ──  ── 데이터 ──

컨슈머:
  메시지 수신 → schema_id = 42 추출
  Schema Registry에 조회 → Writer Schema 획득
  자신의 Reader Schema와 Resolution 수행

스키마 전체(수백 바이트~수 KB)를 매 메시지에 넣는 대신, ID 4바이트만 추가됩니다. Schema Registry는 새 스키마를 등록할 때 기존 스키마와의 호환성도 검증합니다. 호환되지 않는 변경은 등록 자체를 거부합니다.

4-3. Protobuf도 Kafka에서 쓸 수 있지 않나

쓸 수 있습니다. Confluent Schema Registry는 Avro, Protobuf, JSON Schema를 모두 지원합니다. 하지만 Kafka 생태계에서 Avro가 오래 표준처럼 쓰인 이유가 있습니다.

  • Kafka가 LinkedIn에서 만들어졌고, Avro도 같은 Hadoop 생태계에서 나왔습니다. Doug Cutting이 설계했고, 초기부터 함께 사용된 역사가 있습니다
  • Avro의 Schema Resolution이 Writer/Reader 분리를 명시적으로 지원합니다. 프로듀서와 컨슈머가 독립적으로 배포되는 스트리밍 환경에 맞는 설계입니다
  • Avro 스키마가 JSON이라 Registry에서 비교와 호환성 분석이 쉽습니다. Protobuf의 .proto는 별도 파서가 필요합니다

최근에는 Protobuf를 Kafka에서 쓰는 사례도 늘고 있습니다. 특히 gRPC를 이미 쓰는 조직에서는 .proto 파일을 Kafka에도 그대로 활용할 수 있어 Protobuf를 선택하기도 합니다.


마무리

Avro는 태그를 빼서 크기를 줄이고, 그 대신 필요한 스키마를 파일에 넣었습니다. 스키마가 데이터와 함께 있으니, 쓴 시점과 읽는 시점의 스키마가 달라도 Resolution으로 연결됩니다. Protobuf가 태그 번호로 호환성을 유지한다면, Avro는 이름 기반 매칭으로 유지합니다. 방식은 다르지만 스키마가 바뀌어도 데이터를 읽을 수 있게 한다는 목적은 같습니다.

다음 글에서는 이 스키마 진화를 체계적으로 관리하는 Schema Registry를 살펴보겠습니다.