Blog
avroschemanifi

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

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

Data Dynamics2026년 4월 12일18 min read

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 엔지니어링 팀