FlatBuffers란 무엇인가: 역직렬화 없이 데이터를 읽는 고성능 직렬화 구조
API 서버, 메시지 큐, 소켓 통신 시스템을 개발하다 보면 객체를 네트워크로 전달하기 위해 직렬화가 필요하다.
가장 익숙한 방법은 JSON이다.
{
"aircraftId": "HL1234",
"sequence": 10231,
"occurredAt": 1783886400000,
"latitude": 37.5665,
"longitude": 126.9780,
"altitude": 1200.5
}
JSON은 사람이 읽기 쉽고 디버깅하기 편하다. 하지만 수신 측에서는 문자열을 파싱하고, 필드별 타입을 변환하고, 새로운 객체를 생성해야 한다.
데이터량이 적을 때는 문제가 되지 않는다. 그러나 다음과 같은 환경에서는 이야기가 달라진다.
- 초당 수천~수만 건의 메시지를 처리하는 시스템
- 항공기 위치처럼 작은 데이터를 지속적으로 송수신하는 시스템
- 게임, 모바일, 임베디드처럼 메모리 할당에 민감한 환경
- 전체 데이터 중 일부 필드만 읽어야 하는 시스템
- GC 발생과 지연 시간 변동을 줄여야 하는 시스템
이러한 환경에서 고려할 수 있는 직렬화 기술이 FlatBuffers다.
1. FlatBuffers란?
FlatBuffers는 Google에서 게임과 성능 민감형 애플리케이션을 위해 개발한 크로스 플랫폼 바이너리 직렬화 라이브러리다.
가장 중요한 특징은 다음 문장으로 정리할 수 있다.
수신한 바이너리 데이터를 별도의 객체로 역직렬화하지 않고 원본 버퍼에서 직접 읽는다.
일반적인 직렬화 방식은 다음 과정을 거친다.
Network Byte Array
↓
Binary Parsing
↓
Java Object 생성
↓
비즈니스 로직에서 사용
FlatBuffers는 다음과 같이 동작한다.
Network Byte Array
↓
ByteBuffer를 감싼 View 생성
↓
필요한 필드를 원본 버퍼에서 직접 조회
즉, 바이너리 전체를 Java,C,C++과 같은 프로그래밍 객체 그래프로 변환하는 별도의 unpacking 단계가 없다. FlatBuffers 공식 문서도 직렬화된 데이터를 parsing 또는 unpacking하지 않고 직접 접근할 수 있다는 점을 핵심 특성으로 설명한다.
2. FlatBuffers의 Zero-Copy는 무엇을 의미하는가?
FlatBuffers를 설명할 때 흔히 Zero-Copy Serialization이라는 표현을 사용한다.
하지만 이 표현을 정확히 이해할 필요가 있다.
FlatBuffers가 제거하는 것은 주로 다음 과정이다.
byte[]
→ 필드 파싱
→ 문자열 및 객체 생성
→ DTO 생성
→ DTO 필드 접근
FlatBuffers에서는 생성된 접근자가 ByteBuffer 내부의 offset을 계산해 원하는 위치의 값을 직접 읽는다.
byte[]
→ ByteBuffer
→ offset 계산
→ 필드 접근
따라서 다음과 같은 장점이 발생한다.
- 역직렬화용 객체 생성 감소
- 임시 메모리 할당 감소
- GC 압력 감소
- 전체 데이터를 읽지 않고 필요한 필드만 조회 가능
- 바이너리 버퍼를 그대로 보관하거나 전달 가능
다만 네트워크부터 애플리케이션까지 모든 복사가 사라진다는 의미는 아니다.
예를 들어 다음 코드에는 이미 한 번의 복사가 포함될 수 있다.
byte[] payload = inputStream.readNBytes(length);
ByteBuffer buffer = ByteBuffer.wrap(payload);
InputStream에서 byte[]로 읽어 오는 과정, RabbitMQ 클라이언트가 전달하는 byte[], Netty의 ByteBuf를 byte[]로 변환하는 과정에서는 복사가 발생할 수 있다.
또한 다음 메서드도 내부 버퍼 내용을 새로운 배열로 복사한다.
byte[] payload = builder.sizedByteArray();
따라서 FlatBuffers의 zero-copy는 다음과 같이 이해하는 것이 정확하다.
수신된 직렬화 데이터를 별도의 객체 구조로 복원하지 않고 원본 버퍼에서 직접 조회할 수 있다.
운영 환경에서 실제 복사 횟수는 네트워크 라이브러리와 애플리케이션의 버퍼 처리 방식까지 함께 분석해야 한다.
3. FlatBuffers가 데이터를 직접 읽을 수 있는 이유
FlatBuffer는 바이너리 내부의 데이터를 offset 기반으로 연결한다.
개념적으로 다음과 같은 구조를 가진다.
┌─────────────────────────────┐
│ Root Object Offset │
├─────────────────────────────┤
│ File Identifier(Optional) │
├─────────────────────────────┤
│ String / Vector / Table │
├─────────────────────────────┤
│ Root Table │
├─────────────────────────────┤
│ VTable │
└─────────────────────────────┘
각 객체는 다른 객체의 실제 메모리 주소 대신 버퍼 내부의 상대적인 위치인 offset을 저장한다.
Table은 vtable을 통해 각 필드의 위치를 찾는다. 필드가 존재하지 않으면 스키마에 정의된 기본값을 반환한다. 이 구조 덕분에 이전 버전에서 생성한 데이터와 새로운 스키마 사이의 호환성을 제공할 수 있다.
FlatBuffers의 바이너리는 little-endian을 사용하며, scalar 값은 해당 타입 크기에 맞춰 정렬된다.
4. Table과 Struct의 차이
FlatBuffers 스키마에는 table과 struct라는 두 가지 주요 데이터 구조가 있다.
Table
table AircraftPosition {
aircraftId:string;
sequence:long;
altitude:double;
}
Table은 다음 특징을 가진다.
- 필드를 생략할 수 있다.
- 생략된 필드는 기본값을 반환한다.
- 새로운 필드를 추가할 수 있다.
- offset과 vtable을 통해 필드에 접근한다.
- 스키마 변경이 필요한 도메인 객체에 적합하다.
대부분의 메시지 루트 객체는 Table로 정의하는 것이 안전하다.
Struct
struct Position {
latitude:double;
longitude:double;
altitude:double;
}
Struct는 다음 특징을 가진다.
- 모든 필드가 고정된 위치에 저장된다.
- 필드 조회가 빠르고 저장 공간이 상대적으로 작다.
- 필드가 항상 존재한다.
- 한번 정의하면 필드를 추가하거나 제거할 수 없다.
FlatBuffers 공식 튜토리얼도 Struct는 조회가 빠르고 메모리를 적게 사용하지만, 정의 후 변경할 수 없으므로 진화해야 하는 데이터에는 Table을 사용하라고 안내한다.
따라서 Struct는 다음과 같이 구조가 장기간 고정되는 값 객체에 적합하다.
- 좌표
- RGB 색상
- 고정 크기 벡터
- 행렬
- 센서 측정값 묶음
업무 요구사항에 따라 필드가 추가될 가능성이 있다면 Table을 선택하는 것이 낫다.
5. 항공기 위치 데이터를 FlatBuffers로 정의해 보자
항공기 위치 메시지를 FlatBuffers로 정의하면 다음과 같다.
namespace com.example.telemetry.fbs;
enum FlightStatus:byte {
Normal = 0,
Warning = 1,
Emergency = 2
}
struct Position {
latitude:double;
longitude:double;
altitude:double;
}
table AircraftPosition {
aircraftId:string;
sequence:long;
occurredAt:long;
position:Position;
heading:float;
groundSpeed:float;
status:FlightStatus = Normal;
}
root_type AircraftPosition;
file_identifier "APOS";
스키마 파일은 일반적으로 .fbs 확장자를 사용한다.
src/main/flatbuffers/aircraft-position.fbs
각 선언의 역할은 다음과 같다.
| 항목 | 역할 |
|---|---|
namespace |
생성되는 코드의 패키지 또는 네임스페이스 |
enum |
숫자 기반 상태값 정의 |
struct |
구조가 고정된 인라인 데이터 |
table |
스키마 진화가 가능한 메시지 |
root_type |
버퍼의 최상위 메시지 타입 |
file_identifier |
데이터 종류를 확인하기 위한 4바이트 식별자 |
FlatBuffer는 기본적으로 self-describing 포맷이 아니다. 즉, 수신자가 어떤 스키마로 읽어야 하는지 알고 있어야 한다. 필요하다면 정확히 4글자인 file_identifier를 추가해 데이터 종류를 확인할 수 있다.
6. flatc를 이용한 Java 코드 생성
FlatBuffers는 스키마 파일을 런타임에 해석하는 방식이 아니라, flatc 컴파일러로 언어별 코드를 생성한다.
flatc \
--java \
-o build/generated/flatbuffers \
src/main/flatbuffers/aircraft-position.fbs
실행하면 스키마에 정의된 Table, Struct, Enum에 대응하는 Java 코드가 생성된다.
build/generated/flatbuffers/
└── com/example/telemetry/fbs/
├── AircraftPosition.java
├── FlightStatus.java
└── Position.java
flatc는 Java뿐 아니라 C++, Kotlin, Go, Rust, Python, TypeScript 등 여러 언어의 코드를 생성할 수 있다. 따라서 C++ 클라이언트와 Java 서버가 동일한 .fbs 스키마를 공유하는 구조도 가능하다.
Java 프로젝트에는 FlatBuffers 런타임 라이브러리도 추가해야 한다.
dependencies {
implementation "com.google.flatbuffers:flatbuffers-java:${flatbuffersVersion}"
}
운영 프로젝트에서는 flatc 실행 파일 버전과 Java 런타임 라이브러리 버전을 동일하게 관리하는 것이 좋다.
7. Java에서 FlatBuffer 생성하기
생성된 Java 코드를 이용해 메시지를 직렬화해 보자.
import com.example.telemetry.fbs.AircraftPosition;
import com.example.telemetry.fbs.FlightStatus;
import com.example.telemetry.fbs.Position;
import com.google.flatbuffers.FlatBufferBuilder;
public final class AircraftPositionEncoder {
public byte[] encode(AircraftPositionCommand command) {
FlatBufferBuilder builder = new FlatBufferBuilder(256);
int aircraftIdOffset =
builder.createString(command.aircraftId());
int positionOffset = Position.createPosition(
builder,
command.latitude(),
command.longitude(),
command.altitude()
);
AircraftPosition.startAircraftPosition(builder);
AircraftPosition.addAircraftId(builder, aircraftIdOffset);
AircraftPosition.addSequence(builder, command.sequence());
AircraftPosition.addOccurredAt(builder, command.occurredAt());
AircraftPosition.addPosition(builder, positionOffset);
AircraftPosition.addHeading(builder, command.heading());
AircraftPosition.addGroundSpeed(builder, command.groundSpeed());
AircraftPosition.addStatus(
builder,
FlightStatus.Normal
);
int rootOffset = AircraftPosition.endAircraftPosition(builder);
AircraftPosition.finishAircraftPositionBuffer(
builder,
rootOffset
);
return builder.sizedByteArray();
}
}
FlatBuffers Builder는 일반적인 객체 생성 코드와 사용 방식이 다르다.
문자열, 하위 Table, Vector 등은 먼저 생성한 뒤 반환받은 offset을 상위 Table에 전달한다.
String 생성
↓
하위 객체 생성
↓
상위 Table 생성
↓
Root Table 지정
↓
Buffer 완성
공식 Java 샘플에서도 FlatBufferBuilder로 문자열과 Vector, Struct 등을 생성한 후 최종 Table을 구성하고 finish()를 호출한다.
sizedByteArray()의 복사 비용
다음 코드는 사용하기 편하지만 새로운 byte[]를 만든다.
return builder.sizedByteArray();
RabbitMQ Java Client처럼 메시지 본문으로 byte[]를 요구하는 API에서는 현실적으로 사용할 수 있다.
반면 ByteBuffer를 직접 전달할 수 있는 환경이라면 다음 방식을 검토할 수 있다.
ByteBuffer dataBuffer = builder.dataBuffer();
단, dataBuffer()가 반환한 버퍼의 유효 구간은 전체 backing array와 일치하지 않을 수 있다. 반드시 position()과 remaining()을 고려해야 한다.
8. Java에서 역직렬화 없이 읽기
수신한 byte[]를 읽는 코드는 단순하다.
import com.example.telemetry.fbs.AircraftPosition;
import java.nio.ByteBuffer;
public final class AircraftPositionDecoder {
public DecodedAircraftPosition decode(byte[] payload) {
ByteBuffer buffer = ByteBuffer.wrap(payload);
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(buffer);
return new DecodedAircraftPosition(
message.aircraftId(),
message.sequence(),
message.occurredAt(),
message.position().latitude(),
message.position().longitude(),
message.position().altitude(),
message.heading(),
message.groundSpeed(),
message.status()
);
}
}
여기서 중요한 점은 getRootAsAircraftPosition()이 전체 데이터를 Java 객체로 변환하지 않는다는 것이다.
AircraftPosition 객체는 실질적으로 원본 ByteBuffer에 접근하기 위한 View 역할을 한다.
공식 Java 문서 역시 byte[]를 ByteBuffer로 감싼 뒤 생성된 getRootAs...() 메서드에 전달해 데이터를 읽는 방식을 사용한다.
View 객체의 생명주기를 주의해야 한다
다음과 같은 코드는 위험할 수 있다.
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(sharedBuffer);
bufferPool.release(sharedBuffer);
// 이미 반환된 버퍼를 계속 참조할 수 있다.
processAsync(message);
FlatBuffers 객체는 원본 버퍼를 참조한다.
따라서 다음 조건을 지켜야 한다.
FlatBuffers View 사용 기간
≤
원본 ByteBuffer의 유효 기간
버퍼 풀에 반환하거나 다른 메시지로 덮어쓴 뒤 FlatBuffers 접근자를 호출하면 잘못된 데이터를 읽을 수 있다.
특히 Netty EventLoop에서 수신한 데이터를 다른 스레드로 넘길 때는 다음 중 하나를 선택해야 한다.
- 참조 카운트를 증가시켜 버퍼 생명주기를 연장
- 필요한 값만 애플리케이션 객체로 복사
- 메시지 전체를 별도 버퍼로 복사
- 동일 스레드에서 처리 완료 후 버퍼 해제
Zero-copy를 적용한다고 해서 버퍼 소유권 문제까지 사라지는 것은 아니다.
9. 전체 데이터를 DTO로 변환하면 FlatBuffers의 장점이 사라질까?
수신 직후 모든 필드를 DTO로 복사한다면 FlatBuffers의 핵심 장점 중 일부는 줄어든다.
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(buffer);
AircraftPositionDto dto = new AircraftPositionDto(
message.aircraftId(),
message.sequence(),
message.occurredAt(),
message.position().latitude(),
message.position().longitude(),
message.position().altitude()
);
이 구조에서는 결국 DTO와 문자열 객체 등이 생성된다.
그렇다고 FlatBuffers 사용이 무조건 의미 없는 것은 아니다.
- JSON 문자열 파싱 비용은 여전히 제거된다.
- 숫자 타입 변환 비용이 줄어든다.
- 바이너리 크기를 줄일 수 있다.
- 다중 언어 간 타입 계약을 유지할 수 있다.
하지만 FlatBuffers의 장점을 극대화하려면 필요한 필드만 읽어야 한다.
예를 들어 메시지 필터링 단계에서는 다음 세 필드만 확인할 수 있다.
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(buffer);
if (message.sequence() <= lastSequence) {
return;
}
if (message.status() == FlightStatus.Emergency) {
emergencyHandler.handle(buffer);
}
전체 메시지를 객체로 복원하지 않고 sequence와 status만 확인하는 것이다.
FlatBuffers는 이와 같은 부분 조회와 지연 조회에서 가장 큰 강점을 가진다.
10. FlatBuffers와 Protocol Buffers의 차이
FlatBuffers와 Protocol Buffers는 모두 스키마 기반의 바이너리 직렬화 기술이지만 최적화 방향이 다르다.
| 비교 항목 | FlatBuffers | Protocol Buffers |
|---|---|---|
| 데이터 읽기 | 원본 버퍼 직접 접근 | 일반적으로 파싱 후 메시지 객체 생성 |
| 부분 필드 조회 | 유리함 | 보통 전체 메시지 파싱 |
| 역직렬화 할당 | 매우 적게 구성 가능 | 메시지 객체 생성 필요 |
| Wire 크기 | offset과 vtable로 커질 수 있음 | 일반적으로 매우 작음 |
| API 사용성 | Builder 사용이 다소 복잡함 | 비교적 자연스러운 객체 API |
| 데이터 변경 | 제한적 | Builder를 이용한 수정이 편리함 |
| gRPC 생태계 | 지원하지만 상대적으로 제한적 | 사실상 표준에 가까움 |
| 주요 적용 영역 | 게임, 모바일, 실시간 데이터, 읽기 중심 시스템 | 일반적인 RPC, MSA, 서비스 간 통신 |
| 최대 강점 | 파싱 없는 직접 조회 | 작은 크기와 범용적인 개발 생산성 |
Protocol Buffers 역시 스키마에서 생성된 코드로 바이너리를 읽고 쓰지만, 일반적으로 바이너리를 메시지 객체로 파싱하는 과정을 거친다.
FlatBuffers가 항상 더 작거나 빠른 것은 아니다.
FlatBuffers 공식 C++ 벤치마크에서도 예제 데이터 기준으로 디코딩 비용과 임시 메모리 사용량은 FlatBuffers가 매우 낮았지만, Wire 크기는 Protocol Buffers보다 크게 측정됐다. 이 결과는 특정 C++ 데이터 구조를 기준으로 한 것이므로 실제 선택은 반드시 서비스의 실제 메시지로 검증해야 한다.
실무에서는 다음 기준으로 선택할 수 있다.
Protocol Buffers가 적합한 경우
- 일반적인 마이크로서비스 RPC
- gRPC를 적극적으로 사용하는 시스템
- 대부분의 필드를 매번 읽는 경우
- 메시지 크기가 매우 중요한 경우
- 개발 편의성과 생태계를 중시하는 경우
FlatBuffers가 적합한 경우
- 전체 데이터 중 일부 필드만 자주 읽는 경우
- 메시지를 여러 단계에서 필터링하거나 라우팅하는 경우
- C++, Java, 모바일 사이에서 동일한 바이너리를 공유하는 경우
- GC와 임시 객체 할당을 최소화해야 하는 경우
- 수신 메시지를 메모리 또는 파일에 저장한 뒤 반복 조회하는 경우
11. JSON, Protocol Buffers, FlatBuffers 비교
| 항목 | JSON | Protocol Buffers | FlatBuffers |
|---|---|---|---|
| 사람이 읽을 수 있는가 | 가능 | 불가능 | 불가능 |
| 스키마 필요 | 선택 | 필수 | 필수 |
| 파싱 비용 | 높음 | 낮음 | 매우 낮게 구성 가능 |
| 객체 할당 | 많음 | 중간 | 적게 구성 가능 |
| 부분 조회 | 어려움 | 제한적 | 유리함 |
| 메시지 크기 | 큼 | 작음 | 데이터에 따라 다름 |
| 스키마 진화 | 애플리케이션 책임 | 우수 | 우수 |
| 개발 편의성 | 높음 | 높음 | 상대적으로 낮음 |
| 디버깅 편의성 | 높음 | 중간 | 낮음 |
“JSON보다 빠른 포맷이 필요하다”는 이유만으로 FlatBuffers를 선택하는 것은 지나친 결정일 수 있다.
JSON 병목이 실제로 파싱과 객체 할당에서 발생하는지 먼저 확인해야 한다.
CPU 사용률이 높은가?
GC 시간이 증가하는가?
메시지 역직렬화가 프로파일에서 큰 비중을 차지하는가?
전체 필드 중 일부만 조회하는가?
P99 지연 시간에 변동이 있는가?
대부분의 일반적인 백엔드 서비스에서는 Protocol Buffers가 더 단순한 선택일 수 있다.
FlatBuffers는 읽기 경로의 할당과 지연 시간을 극단적으로 줄여야 하는 경우에 더 적합하다.
12. 스키마 진화 시 반드시 지켜야 할 규칙
FlatBuffers는 이전 버전과 이후 버전 사이의 호환성을 지원하지만, 아무렇게나 스키마를 변경해도 되는 것은 아니다.
12.1 새로운 필드는 Table의 마지막에 추가한다
기존 스키마가 다음과 같다고 가정하자.
table AircraftPosition {
aircraftId:string;
sequence:long;
}
필드를 추가할 때는 마지막에 추가한다.
table AircraftPosition {
aircraftId:string;
sequence:long;
occurredAt:long;
}
모든 필드에 명시적인 id를 지정하지 않는 한 새로운 필드는 반드시 Table 정의의 끝에 추가해야 한다.
12.2 기존 필드를 삭제하지 않는다
잘못된 변경이다.
table AircraftPosition {
aircraftId:string;
// sequence 필드를 삭제
}
사용하지 않는 필드는 삭제하는 대신 deprecated로 표시한다.
table AircraftPosition {
aircraftId:string;
sequence:long (deprecated);
}
deprecated 필드는 새로운 코드에서 접근자가 생성되지 않지만, 기존 바이너리 레이아웃과의 호환성을 유지한다.
12.3 기존 필드의 기본값을 변경하지 않는다
status:FlightStatus = Normal;
이 값을 나중에 다음과 같이 변경하면 구버전 데이터의 해석이 달라질 수 있다.
status:FlightStatus = Warning;
바이너리에 필드가 저장되지 않은 경우 접근자는 현재 스키마의 기본값을 반환하기 때문이다.
12.4 변경 가능성이 있는 데이터는 Struct로 만들지 않는다
Struct는 필드를 추가하거나 제거할 수 없다.
다음 좌표 구조에 정확도나 기준 좌표계 필드가 추가될 가능성이 있다면 Table이 더 안전할 수 있다.
table Position {
latitude:double;
longitude:double;
altitude:double;
coordinateSystem:string;
}
12.5 required 사용을 최소화한다
필드를 required로 지정하면 오래된 데이터에 해당 필드가 존재하지 않을 때 읽기 실패가 발생할 수 있다.
호환성이 중요한 메시지는 선택 필드와 기본값을 중심으로 설계하는 것이 좋다. 공식 스키마 문서도 required 속성의 추가 및 제거가 호환성을 깨뜨릴 수 있다고 경고한다.
13. TCP, UDP, RabbitMQ에서 사용할 때의 주의사항
FlatBuffers는 메시지의 바이너리 구조를 정의할 뿐, 전송 프로토콜의 메시지 경계까지 해결하지 않는다.
UDP
UDP는 하나의 Datagram이 하나의 메시지 경계를 제공하므로 비교적 단순하다.
UDP Datagram
┌─────────────────────────┐
│ FlatBuffers Payload │
└─────────────────────────┘
다만 MTU를 초과해 IP Fragmentation이 발생하지 않도록 메시지 크기를 관리해야 한다.
TCP
TCP는 Byte Stream이기 때문에 메시지 경계가 없다.
[length][flatbuffer][length][flatbuffer]
예를 들어 4바이트 길이 필드를 추가할 수 있다.
┌──────────────┬─────────────────────────┐
│ Length: 4B │ FlatBuffers Payload │
└──────────────┴─────────────────────────┘
FlatBuffers 자체의 size-prefixed 형식을 사용하거나 애플리케이션 프로토콜 헤더를 정의할 수도 있다.
RabbitMQ
RabbitMQ는 메시지 단위로 본문을 전달하므로 별도의 길이 프레이밍은 필요하지 않다.
대신 메시지 Header에 다음 정보를 둘 수 있다.
content-type: application/x-flatbuffers
schema-name: AircraftPosition
schema-version: 3
message-id: unique-key
occurred-at: 1783886400000
다만 FlatBuffers의 스키마 호환성이 유지된다면 단순히 애플리케이션 버전이 증가할 때마다 메시지 버전을 분기할 필요는 없다.
버전 필드는 스키마 호환으로 해결할 수 없는 의미적 변경을 구분할 때 사용하는 것이 좋다.
14. 외부 입력은 신뢰하지 말아야 한다
FlatBuffers가 파싱 단계를 줄여 준다고 해서 수신 데이터가 항상 안전한 것은 아니다.
외부 네트워크에서 들어오는 데이터에는 다음 문제가 있을 수 있다.
- 잘못된 Root offset
- 버퍼 범위를 벗어난 Vector 길이
- 손상된 문자열 offset
- 예상하지 못한 메시지 타입
- 지나치게 큰 Payload
- 잘못된 Schema로 생성된 데이터
따라서 네트워크 경계에서는 최소한 다음 항목을 검증해야 한다.
1. 최대 Payload 크기
2. 프로토콜 Magic 또는 File Identifier
3. 메시지 타입
4. 필수 비즈니스 필드
5. sequence 및 timestamp 범위
6. 비즈니스 값 범위
예를 들어 항공기 위치 데이터는 다음과 같이 검증할 수 있다.
private void validate(AircraftPosition message) {
if (message.aircraftId() == null
|| message.aircraftId().isBlank()) {
throw new IllegalArgumentException("aircraftId is required");
}
double latitude = message.position().latitude();
double longitude = message.position().longitude();
if (latitude < -90.0 || latitude > 90.0) {
throw new IllegalArgumentException("invalid latitude");
}
if (longitude < -180.0 || longitude > 180.0) {
throw new IllegalArgumentException("invalid longitude");
}
}
바이너리 구조 검증과 도메인 검증은 별개의 문제다.
FlatBuffer 형식이 정상이라고 해서 항공기 위치나 거래 금액 같은 비즈니스 데이터까지 정상이라는 의미는 아니다.
15. Spring Boot에서 계층을 어떻게 나누어야 할까?
생성된 FlatBuffers 클래스를 비즈니스 계층 전체에 노출하면 도메인 코드가 직렬화 기술에 종속된다.
권장 구조는 다음과 같다.
infrastructure
└── flatbuffers
├── AircraftPositionEncoder
├── AircraftPositionDecoder
└── generated
├── AircraftPosition
├── Position
└── FlightStatus
application
├── AircraftPositionCommand
└── AircraftPositionService
domain
└── AircraftTrack
수신 경계에서 필요한 데이터를 애플리케이션 모델로 변환한다.
@Component
public class FlatBuffersAircraftPositionMapper {
public AircraftPositionCommand map(byte[] payload) {
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(
ByteBuffer.wrap(payload)
);
return new AircraftPositionCommand(
message.aircraftId(),
message.sequence(),
message.occurredAt(),
message.position().latitude(),
message.position().longitude(),
message.position().altitude(),
message.heading(),
message.groundSpeed(),
message.status()
);
}
}
반면 메시지를 라우팅하거나 필터링만 하는 계층에서는 FlatBuffers View를 그대로 사용할 수 있다.
public void route(byte[] payload) {
AircraftPosition message =
AircraftPosition.getRootAsAircraftPosition(
ByteBuffer.wrap(payload)
);
if (message.status() == FlightStatus.Emergency) {
emergencyPublisher.publish(payload);
return;
}
normalPublisher.publish(payload);
}
즉, 계층별 목적에 따라 전략을 나눌 수 있다.
라우팅·필터링 계층
→ 원본 FlatBuffer 직접 조회
핵심 비즈니스 계층
→ 도메인 모델로 변환
저장·재전송 계층
→ 원본 byte[] 유지
FlatBuffers를 도입했다고 모든 계층에서 생성 코드를 직접 사용해야 하는 것은 아니다.
16. 성능 테스트는 어떻게 해야 하는가?
직렬화 기술의 성능은 라이브러리 홈페이지의 벤치마크만으로 결정하면 안 된다.
실제 서비스 메시지로 다음 항목을 측정해야 한다.
- 초당 직렬화 처리량
- 초당 역직렬화 또는 필드 조회 처리량
- 평균 지연 시간
- P95, P99 지연 시간
- 할당된 메모리 크기
- GC 횟수와 시간
- Wire 크기
- 전체 필드 조회와 일부 필드 조회의 차이
FlatBuffers는 데이터가 크고 일부만 조회할수록 상대적으로 유리할 가능성이 높다.
반대로 작은 메시지의 모든 필드를 매번 읽는 시스템에서는 Protocol Buffers와의 차이가 크지 않거나, 개발 복잡도까지 고려했을 때 오히려 손해일 수 있다.
17. FlatBuffers 도입 시 체크리스트
다음 질문에서 여러 항목에 “예”라고 답할 수 있을 때 FlatBuffers를 검토할 가치가 있다.
데이터 접근 특성
- 전체 데이터 중 일부 필드만 읽는가?
- 동일한 바이너리를 여러 단계에서 반복 조회하는가?
- 메시지를 역직렬화하지 않고 라우팅해야 하는가?
- 대규모 Vector에서 특정 데이터만 조회하는가?
성능 특성
- 역직렬화가 CPU 프로파일의 유의미한 비중을 차지하는가?
- 임시 객체 생성 때문에 GC가 증가하는가?
- 평균 처리량보다 P99 지연 시간이 중요한가?
- 메모리 사용량이 제한된 환경인가?
시스템 구조
- C++, Java, 모바일 등 여러 언어가 데이터를 공유하는가?
- 바이너리를 파일이나 메모리에 저장한 뒤 직접 조회하는가?
- 스키마를 중앙에서 관리할 수 있는가?
- 코드 생성 단계를 빌드 파이프라인에 포함할 수 있는가?
반대로 다음 환경에서는 도입을 신중하게 판단해야 한다.
- 트래픽이 많지 않은 일반적인 CRUD API
- 사람이 직접 Payload를 확인해야 하는 시스템
- 메시지 구조가 자주 그리고 크게 변경되는 초기 서비스
- 모든 필드를 항상 DTO로 변환하는 시스템
- gRPC 중심의 일반적인 마이크로서비스
- 성능 측정 없이 단순히 JSON보다 빠른 포맷을 찾는 경우
18. 결론
FlatBuffers의 핵심은 단순히 “바이너리라서 빠르다”가 아니다.
핵심은 다음 구조에 있다.
직렬화된 데이터
↓
별도 객체로 역직렬화하지 않음
↓
원본 버퍼에서 필요한 필드만 직접 조회
이 구조는 역직렬화 과정의 객체 생성과 임시 메모리 사용을 줄이고, 필요한 필드만 선택적으로 조회할 수 있게 한다.
하지만 그 대가도 분명하다.
- Builder API가 일반 객체 생성보다 복잡하다.
- 버퍼 생명주기와 소유권을 관리해야 한다.
- 바이너리를 사람이 직접 확인하기 어렵다.
- Protocol Buffers보다 Wire 크기가 클 수 있다.
- Schema 변경 규칙을 엄격하게 관리해야 한다.
- 모든 데이터를 DTO로 복사하면 장점이 감소한다.
따라서 FlatBuffers는 JSON이나 Protocol Buffers를 무조건 대체하는 기술이 아니다.
다음과 같은 문제를 실제로 가지고 있을 때 선택해야 한다.
역직렬화 객체 할당과 GC가 병목이고, 수신한 데이터 전체가 아니라 일부 필드만 빠르게 조회해야 한다.
성능 최적화 기술은 장점만 보고 선택하는 것이 아니라, 현재 시스템의 병목과 데이터 접근 패턴에 맞춰 선택해야 한다.
FlatBuffers 역시 도입 전에 실제 메시지를 이용한 벤치마크와 프로파일링이 선행되어야 한다.
참고 자료
- FlatBuffers 공식 Overview
- FlatBuffers 공식 Java Guide
- FlatBuffers Schema 문서
- FlatBuffers Schema Evolution 문서
- FlatBuffers Internals
- FlatBuffers 공식 Benchmark
- Protocol Buffers 공식 Overview
