Blog
avroschemanifi

Apache Avro Schema 완벽 가이드 — 구조, 자료형, Java 처리, NiFi 이슈까지

Avro Schema의 용도와 구조, Primitive/Complex/Logical 자료형, null 처리, Java 직렬화/역직렬화, 그리고 NiFi 연동 시 주의사항을 정리합니다.

Data Dynamics2026年4月12日18 min read
This post is not yet translated. The original Korean version is shown below.

Apache Avro는 Hadoop 생태계에서 탄생한 행(row) 기반 데이터 직렬화 프레임워크입니다. 스키마를 데이터와 함께 저장하기 때문에 생산자와 소비자가 독립적으로 진화할 수 있고, 바이너리 인코딩 덕분에 JSON/CSV 대비 용량과 속도 모두 유리합니다. 이 글에서는 Avro Schema 를 체계적으로 정리합니다.

1. Avro Schema 의 용도

  • 데이터 직렬화/역직렬화: 바이너리 포맷으로 빠르고 컴팩트한 데이터 교환
  • 스키마 진화(Schema Evolution): 필드 추가·삭제 시에도 이전/이후 버전 호환 가능
  • Hadoop 생태계 표준 포맷: Hive, Spark, NiFi, Kafka, Flink 등에서 네이티브 지원
  • Schema Registry 연동: Confluent Schema Registry 등과 결합해 중앙 스키마 관리

2. Avro Schema 의 구조

Avro Schema 는 JSON 형식으로 정의합니다.

{
  "type": "record",
  "name": "User",
  "namespace": "io.datadynamics.example",
  "doc": "사용자 정보를 나타내는 레코드",
  "fields": [
    {"name": "id", "type": "long"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null}
  ]
}
설명
type최상위는 보통 "record"
name스키마(레코드) 이름
namespaceJava 패키지처럼 이름 충돌 방지용 네임스페이스
doc스키마 설명 (선택)
fields필드 배열. 각 필드는 name, type, 선택적으로 default, doc, order 를 가짐

3. Primitive 자료형

Avro 타입크기Java 매핑설명
null0null값 없음
boolean1 bitbooleantrue / false
int4 bytesint32비트 정수
long8 byteslong64비트 정수
float4 bytesfloat32비트 부동소수점
double8 bytesdouble64비트 부동소수점
bytes가변ByteBuffer임의 바이트 시퀀스
string가변CharSequence / StringUTF-8 문자열

4. Logical 자료형 — 날짜와 시간

Avro는 Primitive 타입 위에 logicalType 어노테이션을 붙여 의미를 확장합니다. 날짜/시간 관련 Logical Type이 특히 다양합니다.

4.1 날짜 (Date)

{"name": "birth_date", "type": {"type": "int", "logicalType": "date"}}
  • 기반 타입: int
  • 의미: 1970-01-01 기준 경과 일수
  • 예: 198272024-04-12

4.2 시간 (Time)

logicalType기반 타입정밀도예시 값
time-millisint밀리초43200000 → 12:00:00.000
time-microslong마이크로초43200000000 → 12:00:00.000000
{"name": "login_time", "type": {"type": "int", "logicalType": "time-millis"}}
{"name": "precise_time", "type": {"type": "long", "logicalType": "time-micros"}}

4.3 타임스탬프 (Timestamp)

logicalType기반 타입정밀도타임존
timestamp-millislong밀리초UTC
timestamp-microslong마이크로초UTC
local-timestamp-millislong밀리초로컬 (타임존 정보 없음)
local-timestamp-microslong마이크로초로컬 (타임존 정보 없음)
{"name": "created_at", "type": {"type": "long", "logicalType": "timestamp-millis"}}
{"name": "event_ts", "type": {"type": "long", "logicalType": "timestamp-micros"}}
{"name": "local_created", "type": {"type": "long", "logicalType": "local-timestamp-millis"}}
{"name": "local_event", "type": {"type": "long", "logicalType": "local-timestamp-micros"}}

timestamp-millis vs timestamp-micros 의 차이는 정밀도 입니다. millis는 1713945600000 (13자리), micros는 1713945600000000 (16자리)입니다. 데이터 소스의 정밀도에 맞춰 선택하세요. 예를 들어 RDBMS의 TIMESTAMP(6) 은 마이크로초이므로 timestamp-micros가 적합합니다.

local-timestamp-* 은 Avro 1.10+ 에서 추가되었습니다. 타임존 변환 없이 "있는 그대로"의 시각을 저장할 때 사용합니다.

4.4 기타 Logical Type

logicalType기반 타입설명
decimalbytes 또는 fixed고정 소수점. precision, scale 필수
uuidstringRFC 4122 UUID
durationfixed(12)months(4) + days(4) + millis(4)
{"name": "price", "type": {"type": "bytes", "logicalType": "decimal", "precision": 10, "scale": 2}}
{"name": "uuid", "type": {"type": "string", "logicalType": "uuid"}}

5. Complex 자료형

5.1 Array

{
  "name": "tags",
  "type": {
    "type": "array",
    "items": "string"
  }
}

items 에 어떤 타입이든 넣을 수 있습니다. 중첩 레코드도 가능합니다.

{
  "name": "addresses",
  "type": {
    "type": "array",
    "items": {
      "type": "record",
      "name": "Address",
      "fields": [
        {"name": "city", "type": "string"},
        {"name": "zipcode", "type": "string"}
      ]
    }
  }
}

5.2 Map

키는 항상 string 이고, 값의 타입을 values 로 지정합니다.

{
  "name": "metadata",
  "type": {
    "type": "map",
    "values": "string"
  }
}

5.3 Enum

{
  "name": "status",
  "type": {
    "type": "enum",
    "name": "Status",
    "symbols": ["ACTIVE", "INACTIVE", "SUSPENDED"]
  }
}

5.4 Fixed

고정 길이 바이트 배열입니다.

{
  "name": "md5",
  "type": {
    "type": "fixed",
    "name": "MD5",
    "size": 16
  }
}

5.5 Union

여러 타입 중 하나를 허용합니다. nullable 필드를 만들 때 핵심적으로 사용됩니다.

{"name": "middle_name", "type": ["null", "string"], "default": null}

6. null 이 있을 때와 없을 때의 차이

이것은 Avro 스키마에서 가장 흔한 실수 포인트입니다.

null 을 허용하지 않는 필드 (Non-nullable)

{"name": "name", "type": "string"}
  • 반드시 값이 있어야 합니다.
  • 값이 null 이면 직렬화 시 예외 발생
  • 스키마 진화 시 이 필드를 새로 추가하면 이전 데이터를 읽을 수 없음 (default 가 없으므로)

null 을 허용하는 필드 (Nullable)

{"name": "name", "type": ["null", "string"], "default": null}
  • 값이 없으면 null 로 저장
  • Union 표기 ["null", "string"] 에서 첫 번째 타입이 default 의 타입과 일치해야 함
  • 스키마 진화 시 이 필드를 새로 추가해도 이전 데이터 읽기 가능 (default 로 null 채워짐)

순서가 중요합니다

// 올바름: default 가 null 이므로 "null" 이 첫 번째
{"name": "email", "type": ["null", "string"], "default": null}
 
// 잘못됨: "string" 이 첫 번째인데 default 가 null
{"name": "email", "type": ["string", "null"], "default": null}  // 에러!

실무 권장: 선택적 필드는 항상 ["null", "실제타입"] + "default": null 패턴을 사용하세요. 스키마 진화 호환성이 보장됩니다.

스키마 진화 호환성 비교

변경Non-nullableNullable (default: null)
새 필드 추가 → 이전 데이터 읽기default 없으면 실패null 로 채워져 성공
필드 삭제 → 새 데이터 읽기무시됨무시됨
Writer/Reader 스키마 불일치엄격하게 실패유연하게 대응

7. Java 에서 Avro 직렬화 / 역직렬화

7.1 Maven 의존성

<dependency>
  <groupId>org.apache.avro</groupId>
  <artifactId>avro</artifactId>
  <version>1.11.3</version>
</dependency>

7.2 스키마 정의 및 GenericRecord 방식

코드 생성 없이 동적으로 처리하는 방법입니다.

import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericDatumReader;
import org.apache.avro.generic.GenericDatumWriter;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.file.DataFileWriter;
import org.apache.avro.file.DataFileReader;
import org.apache.avro.io.DatumReader;
import org.apache.avro.io.DatumWriter;
import java.io.File;
 
public class AvroGenericExample {
 
    public static void main(String[] args) throws Exception {
        // 1. 스키마 정의
        String schemaJson = """
            {
              "type": "record",
              "name": "User",
              "namespace": "io.datadynamics.example",
              "fields": [
                {"name": "id", "type": "long"},
                {"name": "name", "type": "string"},
                {"name": "email", "type": ["null", "string"], "default": null},
                {"name": "created_at", "type": {"type": "long", "logicalType": "timestamp-millis"}}
              ]
            }
            """;
        Schema schema = new Schema.Parser().parse(schemaJson);
 
        // 2. 직렬화 (Serialize) — Avro 파일 쓰기
        File file = new File("users.avro");
        DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema);
        try (DataFileWriter<GenericRecord> fileWriter = new DataFileWriter<>(writer)) {
            fileWriter.create(schema, file);
 
            GenericRecord user = new GenericData.Record(schema);
            user.put("id", 1L);
            user.put("name", "홍길동");
            user.put("email", "hong@example.com");
            user.put("created_at", System.currentTimeMillis());
            fileWriter.append(user);
 
            GenericRecord user2 = new GenericData.Record(schema);
            user2.put("id", 2L);
            user2.put("name", "김철수");
            user2.put("email", null);  // nullable 필드
            user2.put("created_at", System.currentTimeMillis());
            fileWriter.append(user2);
        }
 
        // 3. 역직렬화 (Deserialize) — Avro 파일 읽기
        DatumReader<GenericRecord> reader = new GenericDatumReader<>(schema);
        try (DataFileReader<GenericRecord> fileReader = new DataFileReader<>(file, reader)) {
            while (fileReader.hasNext()) {
                GenericRecord record = fileReader.next();
                System.out.println("ID: " + record.get("id")
                    + ", Name: " + record.get("name")
                    + ", Email: " + record.get("email")
                    + ", Created: " + record.get("created_at"));
            }
        }
    }
}

7.3 코드 생성 (SpecificRecord) 방식

avro-maven-plugin 으로 .avsc 파일에서 Java 클래스를 자동 생성합니다.

<plugin>
  <groupId>org.apache.avro</groupId>
  <artifactId>avro-maven-plugin</artifactId>
  <version>1.11.3</version>
  <executions>
    <execution>
      <phase>generate-sources</phase>
      <goals><goal>schema</goal></goals>
      <configuration>
        <sourceDirectory>${project.basedir}/src/main/avro/</sourceDirectory>
        <outputDirectory>${project.basedir}/src/main/java/</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>

생성된 클래스를 사용하면 타입 안전성이 보장됩니다.

// 자동 생성된 User 클래스 사용
User user = User.newBuilder()
    .setId(1L)
    .setName("홍길동")
    .setEmail("hong@example.com")
    .setCreatedAt(System.currentTimeMillis())
    .build();

7.4 바이트 배열로 직렬화 (Kafka 등에서 활용)

파일이 아닌 바이트 배열로 변환할 때 사용합니다.

import org.apache.avro.io.EncoderFactory;
import org.apache.avro.io.DecoderFactory;
import org.apache.avro.io.BinaryEncoder;
import org.apache.avro.io.BinaryDecoder;
import java.io.ByteArrayOutputStream;
 
// 직렬화
ByteArrayOutputStream out = new ByteArrayOutputStream();
BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, null);
DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema);
writer.write(record, encoder);
encoder.flush();
byte[] bytes = out.toByteArray();
 
// 역직렬화
BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(bytes, null);
DatumReader<GenericRecord> reader = new GenericDatumReader<>(schema);
GenericRecord result = reader.read(null, decoder);

8. Avro 파일의 장점

장점설명
스키마 내장파일 헤더에 스키마가 포함되어 있어 별도 스키마 관리 불필요
컴팩트한 바이너리JSON 대비 50~80% 용량 절감
스키마 진화Forward/Backward/Full compatibility 지원
빠른 직렬화Protobuf 과 유사한 수준의 성능
압축 지원Snappy, Deflate, Zstandard 블록 압축 내장
SplittableHDFS 에서 블록 단위로 분할 처리 가능 (MapReduce, Spark 병렬 처리)
다국어 지원Java, Python, C, C++, Go 등 다양한 언어 라이브러리 제공
Hadoop 생태계 통합Hive, Pig, Spark, Kafka, NiFi 등에서 네이티브 지원

9. Avro 파일의 단점과 주의사항

9.1 사람이 읽을 수 없음

바이너리 포맷이므로 cat, less 같은 도구로 내용 확인이 불가능합니다. 디버깅 시 avro-tools 로 JSON 변환이 필요합니다.

java -jar avro-tools-1.11.3.jar tojson users.avro

9.2 스키마 진화의 함정

  • 필드 이름 변경은 호환성이 깨집니다. Avro 는 필드 이름으로 매칭하므로 rename = 삭제 + 추가와 동일
  • Union 타입 변경 시 주의: ["null", "string"]["null", "int"] 는 호환되지 않음
  • default 가 없는 Non-nullable 필드를 추가하면 이전 데이터를 읽을 수 없음
  • aliases 를 사용해 필드 이름 변경을 우회할 수 있지만, 모든 소비자가 새 스키마를 인식해야 함

9.3 Enum 변경의 위험

Enum 에서 기존 심볼을 삭제하면 해당 값을 가진 이전 데이터를 역직렬화할 수 없습니다. Enum 심볼은 추가만 하고 삭제하지 마세요.

9.4 decimal 타입의 정밀도 문제

  • bytes 기반 decimal 은 가변 길이라 정렬(sorting) 시 추가 처리 필요
  • fixed 기반 decimal 은 precision 에 맞는 size 를 정확히 계산해야 함
  • Hive/Spark 와 연동 시 precision/scale 불일치로 데이터가 잘리거나 예외 발생

9.5 대용량 컬렉션 성능

Array 나 Map 에 수만 개 이상의 요소가 들어가면 단일 레코드의 직렬화/역직렬화 비용이 급증합니다. 이런 경우 별도 레코드로 정규화하는 것이 좋습니다.

9.6 timestamp 정밀도 불일치

소스 시스템이 나노초 정밀도인데 Avro 스키마가 timestamp-millis 로 되어 있으면 데이터 손실이 발생합니다. 반대로 밀리초 데이터를 timestamp-micros 로 저장하면 불필요하게 큰 값이 저장됩니다. 소스의 정밀도를 먼저 확인하세요.

9.7 Writer/Reader 스키마 불일치

Avro 는 쓰기 스키마(Writer Schema)와 읽기 스키마(Reader Schema)가 다를 수 있는데, 호환되지 않는 변경이 있으면 런타임에 AvroTypeException 이 발생합니다. Schema Registry 를 사용해 호환성을 사전에 검증하는 것이 좋습니다.

10. NiFi 에서 Avro Schema 사용 시 발생하는 이슈

10.1 AvroSchemaRegistry 와 스키마 텍스트

NiFi 의 AvroSchemaRegistry 에 스키마를 등록할 때 JSON 문자열의 escape 문제가 자주 발생합니다. 특히 UI 에서 복사/붙여넣기 시 줄바꿈이 깨지거나, 따옴표가 변환되는 문제를 주의해야 합니다.

10.2 ConvertRecord 에서의 null 처리

ConvertRecord 프로세서로 CSV → Avro 변환 시, CSV 의 빈 문자열("")이 Avro 의 null 과 다르게 처리됩니다.

  • 스키마에 ["null", "string"] 으로 정의된 필드에 빈 문자열이 들어오면 null 이 아닌 "" 로 저장됨
  • 반대로 CSV 에 해당 컬럼이 아예 없으면 스키마가 Non-nullable 일 경우 변환 실패

10.3 timestamp 변환 문제

NiFi 에서 ConvertRecord 또는 UpdateRecord 를 사용할 때:

  • 소스 데이터의 날짜 포맷(예: "2026-04-12 15:30:00")과 Avro 의 timestamp-millis (epoch 밀리초) 사이 변환이 자동으로 되지 않는 경우가 있음
  • RecordPath 나 별도 UpdateAttribute 로 epoch 변환을 수동 처리해야 할 수 있음
  • 타임존 설정이 JVM 기본값을 따르므로 서버마다 결과가 달라질 수 있음

10.4 스키마 진화 시 FlowFile 호환성

NiFi 파이프라인 도중에 Avro 스키마를 변경하면:

  • 큐에 이미 쌓여 있는 FlowFile 은 이전 스키마로 직렬화된 상태
  • 변경된 스키마를 가진 프로세서가 이 FlowFile 을 읽으면 AvroTypeException 또는 필드 누락 발생
  • 스키마 변경 전에 큐를 비우거나, Reader 에서 "Embedded Avro Schema" 옵션을 사용해야 함

10.5 대용량 Avro 레코드의 메모리 문제

NiFi 의 Record 기반 프로세서들은 FlowFile 전체를 메모리에 올려 처리합니다. Avro 레코드에 대용량 bytes 필드나 수만 개 요소의 Array 가 있으면 NiFi 노드의 힙 메모리가 부족해질 수 있습니다.

10.6 Schema Access Strategy 설정 오류

NiFi 의 Record Reader/Writer 에서 Schema Access Strategy 설정이 일치하지 않으면 스키마를 찾지 못합니다.

  • Use Embedded Avro Schema: Avro 파일 헤더의 스키마 사용
  • Use Schema Name: AvroSchemaRegistry 에서 이름으로 조회
  • Use Schema Text: 프로세서에 직접 스키마 JSON 입력

이 세 가지를 혼용하거나 잘못 선택하면 "Schema not found" 또는 "Unable to obtain schema" 에러가 발생합니다.

10.7 decimal logicalType 지원 문제

NiFi 일부 버전에서 decimal logicalType 을 처리할 때 precision/scale 이 누락되거나, bytesBigDecimal 변환에서 값이 틀어지는 경우가 보고됩니다. 금융 데이터처럼 정밀도가 중요한 경우 변환 전후로 값을 반드시 검증하세요.


요약

Avro Schema 는 빅데이터 생태계에서 가장 널리 쓰이는 직렬화 포맷 중 하나입니다. 스키마 내장, 스키마 진화, 바이너리 압축 이라는 세 가지 강점 덕분에 대규모 데이터 파이프라인에 적합합니다. 하지만 바이너리 특성상 디버깅이 어렵고, 스키마 진화 규칙을 정확히 이해하지 않으면 운영 중 데이터 호환성 문제를 만나게 됩니다. 특히 NiFi 와 연동할 때는 null 처리, timestamp 변환, Schema Access Strategy 설정에 주의를 기울이세요.

— Data Dynamics 엔지니어링 팀