Apache Avro Schema 완벽 가이드 — 구조, 자료형, Java 처리, NiFi 이슈까지
Avro Schema의 용도와 구조, Primitive/Complex/Logical 자료형, null 처리, Java 직렬화/역직렬화, 그리고 NiFi 연동 시 주의사항을 정리합니다.
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 | 스키마(레코드) 이름 |
namespace | Java 패키지처럼 이름 충돌 방지용 네임스페이스 |
doc | 스키마 설명 (선택) |
fields | 필드 배열. 각 필드는 name, type, 선택적으로 default, doc, order 를 가짐 |
3. Primitive 자료형
| Avro 타입 | 크기 | Java 매핑 | 설명 |
|---|---|---|---|
null | 0 | null | 값 없음 |
boolean | 1 bit | boolean | true / false |
int | 4 bytes | int | 32비트 정수 |
long | 8 bytes | long | 64비트 정수 |
float | 4 bytes | float | 32비트 부동소수점 |
double | 8 bytes | double | 64비트 부동소수점 |
bytes | 가변 | ByteBuffer | 임의 바이트 시퀀스 |
string | 가변 | CharSequence / String | UTF-8 문자열 |
4. Logical 자료형 — 날짜와 시간
Avro는 Primitive 타입 위에 logicalType 어노테이션을 붙여 의미를 확장합니다. 날짜/시간 관련 Logical Type이 특히 다양합니다.
4.1 날짜 (Date)
{"name": "birth_date", "type": {"type": "int", "logicalType": "date"}}- 기반 타입:
int - 의미: 1970-01-01 기준 경과 일수
- 예:
19827→2024-04-12
4.2 시간 (Time)
| logicalType | 기반 타입 | 정밀도 | 예시 값 |
|---|---|---|---|
time-millis | int | 밀리초 | 43200000 → 12:00:00.000 |
time-micros | long | 마이크로초 | 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-millis | long | 밀리초 | UTC |
timestamp-micros | long | 마이크로초 | UTC |
local-timestamp-millis | long | 밀리초 | 로컬 (타임존 정보 없음) |
local-timestamp-micros | long | 마이크로초 | 로컬 (타임존 정보 없음) |
{"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-millisvstimestamp-micros의 차이는 정밀도 입니다. millis는1713945600000(13자리), micros는1713945600000000(16자리)입니다. 데이터 소스의 정밀도에 맞춰 선택하세요. 예를 들어 RDBMS의TIMESTAMP(6)은 마이크로초이므로timestamp-micros가 적합합니다.
local-timestamp-*은 Avro 1.10+ 에서 추가되었습니다. 타임존 변환 없이 "있는 그대로"의 시각을 저장할 때 사용합니다.
4.4 기타 Logical Type
| logicalType | 기반 타입 | 설명 |
|---|---|---|
decimal | bytes 또는 fixed | 고정 소수점. precision, scale 필수 |
uuid | string | RFC 4122 UUID |
duration | fixed(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-nullable | Nullable (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 블록 압축 내장 |
| Splittable | HDFS 에서 블록 단위로 분할 처리 가능 (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.avro9.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 이 누락되거나, bytes ↔ BigDecimal 변환에서 값이 틀어지는 경우가 보고됩니다. 금융 데이터처럼 정밀도가 중요한 경우 변환 전후로 값을 반드시 검증하세요.
요약
Avro Schema 는 빅데이터 생태계에서 가장 널리 쓰이는 직렬화 포맷 중 하나입니다. 스키마 내장, 스키마 진화, 바이너리 압축 이라는 세 가지 강점 덕분에 대규모 데이터 파이프라인에 적합합니다. 하지만 바이너리 특성상 디버깅이 어렵고, 스키마 진화 규칙을 정확히 이해하지 않으면 운영 중 데이터 호환성 문제를 만나게 됩니다. 특히 NiFi 와 연동할 때는 null 처리, timestamp 변환, Schema Access Strategy 설정에 주의를 기울이세요.
— Data Dynamics 엔지니어링 팀