ZigBolt: 우리가 Zig로 자체 Aeron을 구축해 메시지당 20나노초를 달성한 이유
Lock-free 링 버퍼, 제로 카피 코덱, Raft 클러스터 — 모두 순수 Zig로, 모두 오픈소스.
알고리즘 트레이딩이나 마켓 메이킹을 하고 있다면, 1마이크로초의 가치를 잘 알 것입니다. 컨텍스트 스위치 하나만 추가되어도 주문이 한 발 늦게 도착합니다. JVM 가비지 컬렉션이 한 번 멈추면, 반대편의 마켓 메이커는 이미 호가를 갱신했습니다. 나노초 단위로 수익이 결정되는 세계에서, 메시징 인프라는 서비스 간의 지루한 파이프가 아니라 핵심 경쟁력입니다.
우리는 ZigBolt를 만들었습니다 — Zig 언어로 처음부터 구축한 고빈도 매매용 메시징 시스템입니다. JVM 없이, 가비지 컬렉터 없이, Media Driver 없이, XML 설정 없이. 그리고 SPSC 링 버퍼에서 p50 지연 20나노초, 공유 메모리 IPC에서 30나노초를 달성했습니다.
이 글에서는 왜 이것이 필요했는지, 내부 동작 원리, 그리고 왜 Zig를 선택했는지를 설명합니다.
요약
- ZigBolt — 순수 Zig로 작성된 HFT용 오픈소스(MIT) 메시징 시스템
- SPSC p50: 20ns, IPC p50: 30ns — Aeron의 공식 성능 수치를 상회
- 제로 카피 코덱은 0ns로 동작 (컴파일 타임 코드 생성, 런타임은 단순 포인터 캐스트)
- GC 없음, JVM 없음, Media Driver 없음 — 라이브러리를 앱에 직접 임베딩
- Raft 클러스터, 아카이브, 시퀀서 — 모두 내장
- FFI 바인딩: Rust, Python, Go, TypeScript, C 지원 — 편한 언어로 사용
문제점: Aeron은 훌륭하지만 충분하지 않다
Real Logic의 Aeron은 자본시장 저지연 메시징의 사실상 표준입니다. 수십 개의 HFT 회사가 사용하고 있으며, 실전에서 검증되었고, 우수한 아키텍처를 갖추고 있습니다. 그러나 Aeron에는 근본적인 문제가 있습니다 — JVM입니다.
JVM safepoints: 보이지 않는 적
모든 데이터를 신중하게 오프힙 메모리에 배치하고, GC 에르고노믹스를 끄고, GuaranteedSafepointInterval=300000을 설정해도, JVM은 여전히 때때로 safepoint에서 모든 스레드를 중지시킵니다. 이것은 버그가 아니라 아키텍처 설계입니다. JVM은 디옵티마이제이션, 바이어스드 락킹, 스택 워킹을 위해 safepoint가 필요합니다.
실제로는 이런 일이 벌어집니다: 스레드가 p50 = 200ns로 메시지를 보내고 있는데, 갑자기 p99.9가 50μs로 치솟습니다. 뚜렷한 원인 없이. JVM의 어떤 스레드가 safepoint 시간이라고 판단했기 때문입니다.
Media Driver: 불필요한 홉
Aeron은 Media Driver를 통해 작동합니다 — 공유 메모리를 통해 퍼블리셔와 서브스크라이버 간에 메시지를 라우팅하는 별도의 프로세스(또는 임베디드 JVM)입니다. 깔끔한 격리를 제공하지만, 최소 한 번의 추가 홉이 발생합니다:
Aeron: App → shm → Media Driver → shm → socket → NIC
ZigBolt: App → ring buffer → io_uring → NIC
매 홉마다 추가 나노초, 추가 캐시 미스, 추가 불확실성이 더해집니다.
SBE: 별도의 빌드 단계
Simple Binary Encoding은 금융 메시지를 위한 표준 FIX 코덱입니다. Aeron 생태계에서는 XML 스키마로부터 코드를 생성하는 별도의 Java 유틸리티입니다. 별도의 의존성, 별도의 빌드 단계, 별도의 문제들.
해결책: ZigBolt
우리는 스스로에게 물었습니다: Aeron의 최고의 아이디어들 — 트리플 버퍼 로그, lock-free 링 버퍼, Raft 클러스터 — 을 다음과 같은 특성을 가진 언어로 구현하면 어떨까:
- 런타임 오버헤드 없음 (GC 없음, safepoints 없음)
- 컴파일 타임에 코드 생성 가능 (comptime)
- C 라이브러리(DPDK, io_uring)와의 통합이 간단
- 바이너리 크기 약 100KB
그 언어는 바로 Zig입니다.
아키텍처
┌─────────────────────────────────────────────────────────┐
│ Publisher/Subscriber API (타입화된 래퍼) │
├─────────────────────────────────────────────────────────┤
│ Transport Layer (채널 팩토리, 라이프사이클) │
├─────────────────────────────────────────────────────────┤
│ IPC Channel (공유 메모리) │ UDP Channel (네트워크) │
├─────────────────────────────────────────────────────────┤
│ WireCodec (comptime 제로 카피) │ SBE Encoder/Decoder │
├─────────────────────────────────────────────────────────┤
│ Ring Buffers (SPSC/MPSC) │ LogBuffer (트리플 버퍼) │
├─────────────────────────────────────────────────────────┤
│ Archive (리플레이) │ Sequencer (전체 순서) │ Raft (HA) │
└─────────────────────────────────────────────────────────┘
7개의 계층으로 구성되며, 각 계층은 독립적으로 사용할 수 있습니다. 두 프로세스 간 IPC용 SPSC 링 버퍼만 필요한가요? 그대로 사용하면 됩니다. Raft 합의와 아카이브가 포함된 완전한 클러스터가 필요한가요? 그것도 있습니다.
벤치마크: 말이 아닌 숫자로

다음은 1,000만 회 반복(Apple Silicon / macOS)에서의 실측 결과입니다:
SPSC 링 버퍼
| 메시지 크기 | p50 | p99 | p99.9 | 처리량 |
|---|---|---|---|---|
| 8바이트 | 20ns | 30ns | 120ns | 42.8M msg/s |
| 32바이트 | 30ns | 50ns | 150ns | 28.5M msg/s |
| 64바이트 | 50ns | 60ns | 320ns | 17.6M msg/s |
| 256바이트 | 30ns | 50ns | 50ns | 29.5M msg/s |
IPC Channel (공유 메모리)
| 메시지 크기 | p50 | p99 | p99.9 | 처리량 |
|---|---|---|---|---|
| 64바이트 | 30ns | 40ns | 40ns | 35.7M msg/s |
| 256바이트 | 40ns | 40ns | 170ns | 27.4M msg/s |
| 1024바이트 | 90ns | 260ns | 900ns | 9.9M msg/s |
LogBuffer (Aeron 스타일 트리플 버퍼)
| 메시지 크기 | p50 | p99 | p99.9 | 처리량 |
|---|---|---|---|---|
| 32바이트 | 30ns | 40ns | 320ns | 33.6M msg/s |
| 64바이트 | 30ns | 30ns | 160ns | 38.0M msg/s |
| 256바이트 | 30ns | 40ns | 60ns | 31.1M msg/s |
WireCodec (comptime 제로 카피)
| 연산 | 지연 | 처리량 |
|---|---|---|
| 인코딩 (32바이트) | 0ns | ∞ (인라인 memcpy) |
| 디코딩 (32바이트) | ~0.4ns | 27억 msg/s |
네, 제대로 읽으셨습니다: 인코딩은 0나노초입니다. WireCodec(T)이 컴파일 타임에 구조체를 검증하고 encode/decode를 일반적인 @memcpy 또는 포인터 캐스트로 변환하기 때문입니다. 런타임 오버헤드 = 제로.
비교를 위해: Aeron은 IPC RTT(왕복 지연)를 약 250ns로 공표합니다. 우리의 단방향 지연은 30ns입니다. 왕복으로 계산해도 4배 더 빠릅니다.
내부 동작 원리
Lock-free SPSC: 단순함이라는 미덕

단일 생산자-단일 소비자 링 버퍼는 가장 단순하고 빠른 lock-free 자료구조입니다. 쓰기 측이 head를 이동시키고, 읽기 측이 tail을 이동시킵니다. CAS가 필요 없이 acquire/release 아토믹만으로 충분합니다.
핵심 기법은 캐시 라인 패딩입니다. head와 tail이 같은 캐시 라인에 있으면, 한쪽 카운터의 업데이트가 다른 코어의 캐시를 무효화합니다(false sharing). 해결책:
// Head (쓰기 위치) — 자체 캐시 라인
head: std.atomic.Value(usize) align(128) = .init(0),
// 128바이트 패딩 — 격리 보장
_pad0: [128 - @sizeOf(std.atomic.Value(usize))]u8 = .{0} ** ...,
// Tail (읽기 위치) — 자체 캐시 라인
tail: std.atomic.Value(usize) align(128) = .init(0),
64바이트가 아닌 128바이트입니다 — Apple Silicon(및 많은 ARM)에서 하드웨어 프리페처가 캐시 라인 쌍 단위로 작동할 수 있기 때문입니다. 안전한 선택을 했습니다.
WireCodec: 코드 생성 대신 comptime

Java/C++ 세계에서 바이너리 코덱을 사용하려면 별도의 단계가 필요합니다: 스키마 작성 -> 코드 생성기 실행 -> 코드 획득 -> 컴파일. Zig에서는 이 모든 것이 컴파일 타임에 완료됩니다:
const TickMsg = packed struct {
symbol_id: u32,
price: i64,
quantity: u32,
side: u8,
_reserved: [3]u8,
timestamp: u64,
};
const Codec = WireCodec(TickMsg);
// 인코딩 — 32바이트 memcpy에 불과. 1-2개 명령어로 인라인화.
Codec.encode(&msg, buf[0..Codec.wire_size]);
// 디코딩 — 포인터 캐스트. 복사 제로.
const tick = Codec.decode(buf[0..Codec.wire_size]);
Zig 컴파일러는 comptime 단계에서 다음을 검증합니다:
- 구조체가 packed인지 (패딩 홀 없음)
- 크기가 8바이트의 배수인지 (SIMD 정렬)
- 모든 필드가 프리미티브 타입인지
문제가 있으면 — 컴파일 에러가 발생합니다. 새벽 3시 프로덕션 환경의 런타임 예외가 아닙니다.
공유 메모리를 통한 IPC
두 프로세스가 /dev/shm의 동일 파일을 매핑합니다. 퍼블리셔가 링 버퍼에 쓰고, 서브스크라이버가 읽습니다. 핫 패스에 소켓도 시스템 콜도 없습니다:
// Publisher
const channel = try IpcChannel.create("/market-data", .{
.term_length = 1024 * 1024, // 1 MB
});
channel.publish(&msg_bytes, msg_type_id);
// Subscriber (다른 프로세스)
const channel = try IpcChannel.open("/market-data", .{
.term_length = 1024 * 1024,
});
const count = channel.poll(handler_fn, 10);
publish()에서 서브스크라이버의 handler_fn 호출까지의 전체 경로 — 64바이트 메시지 기준 30나노초.
NAK 기반 UDP 신뢰성
네트워크 전송에서 ZigBolt는 수신 측 주도 재전송 메커니즘을 사용합니다. 수신 측이 비트맵으로 시퀀스 번호의 갭을 추적하고, 송신 측에 NAK(부정 확인 응답)을 보냅니다. 여기에 AIMD 혼잡 제어 — TCP와 유사한 슬로우 스타트 및 혼잡 회피 — 를 적용하여 네트워크 과부하를 방지합니다.
Raft 클러스터: 일관성이 필수일 때
메시지 손실이 허용되지 않는 경우(예: 매칭 엔진), ZigBolt는 완전한 Raft 합의를 탑재하고 있습니다:
- 리더 선출: 설정 가능한 타임아웃(150-300ms)
- 로그 복제 — 리더가 각 메시지를 팔로워에 복제
- WAL(선행 기록 로그): CRC32 검증 및 장애 복구
- 스냅샷 — WAL의 무한 증가 방지
아카이브: 기록과 리플레이
모든 메시지를 디스크의 세그먼트 아카이브에 기록할 수 있습니다. 이후 시간 또는 시퀀스 번호로 임의 위치에서 리플레이 가능합니다. 외부 의존성 없는 내장 LZ4 스타일 압축. 세그먼트 내 빠른 검색을 위한 희소 인덱스.
전체 순서 시퀀서
여러 거래소에서 마켓 메이킹을 할 때, 모든 이벤트에 글로벌 순서가 필요합니다. 시퀀서는 N개의 입력 스트림을 받아 단조 증가하는 시퀀스 번호를 부여하며 하나로 병합합니다. 모든 참여자가 동일한 이벤트 시퀀스를 보게 됩니다.
왜 Rust/C/C++가 아닌 Zig인가?
네 가지 후보 언어를 비교했습니다. 아래는 공정한 비교표입니다:
| 기준 | Zig | C/C++ | Rust | Java (Aeron) |
|---|---|---|---|---|
| GC / 런타임 오버헤드 | 없음 | 없음 | 없음 | JVM safepoints, GC |
| Comptime 코드 생성 | 네이티브 | 매크로/템플릿 | proc macros | 없음 |
| C 상호운용(DPDK, io_uring) | 간단한 @cImport |
네이티브 | FFI/bindgen | JNI 오버헤드 |
| SIMD | @Vector, 내장 |
Intrinsics | packed_simd (불안정) | 벡터화 힌트 |
| 크로스 컴파일 | 내장 | CMake 지옥 | cargo target | N/A |
| 빌드 시간 | 초 단위 | 분 단위 (C++) | 분 단위 | 초 단위 + JVM 시작 |
| 숨겨진 제어 흐름 | 없음 | 예외, 암시적 캐스팅 | unwrap의 패닉 |
예외 |
Zig는 독보적인 조합을 제공합니다: C의 성능 + 개발 시 안전성 + comptime 메타프로그래밍(코덱, 룩업 테이블, 프로토콜 상태 머신 — 모두 컴파일 타임에 생성) + @cImport를 통한 DPDK, liburing, ef_vi와의 원활한 통합.
게다가 Zig 바이너리는 약 100KB에 불과합니다. JVM 기반 솔루션의 20MB 이상과 비교해 보세요. 엣지 배포와 컨테이너에 있어 이는 중요합니다.
바인딩: 익숙한 언어로
ZigBolt는 C-ABI 공유 라이브러리로 컴파일되며, 다섯 가지 언어의 기성 바인딩을 제공합니다:
TypeScript / Node.js
import { IpcChannel } from "@zigbolt/node";
const channel = IpcChannel.create({
name: "/my-market-data",
termLength: 1024 * 1024,
});
const msg = Buffer.from("BTC/USDT 42000.50", "utf-8");
channel.publish(msg, 1);
Rust
use zigbolt::IpcChannel;
let ch = IpcChannel::create("/my-channel", 64 * 1024).unwrap();
ch.publish(b"hello", 1).unwrap();
// Subscriber
let sub = IpcChannel::open("/my-channel", 64 * 1024).unwrap();
sub.poll(|data, msg_type_id| {
println!("got {} bytes, type={}", data.len(), msg_type_id);
}, 10);
Python
from zigbolt import IpcChannel
ch = IpcChannel.create("/market-data", term_length=1024*1024)
ch.publish(b"tick data here", msg_type_id=1)
Go와 순수 C 바인딩도 있습니다. 동일한 공유 메모리 채널에 모든 언어에서 동시에 접근 가능합니다 — 퍼블리셔는 Zig, 서브스크라이버는 Python, 모니터링은 Go. 모두가 같은 mmap 영역을 읽습니다.
SBE 코덱: FIX 호환 메시지
금융 프로토콜을 위해 ZigBolt는 컴파일 타임 스키마를 갖춘 완전한 SBE(Simple Binary Encoding) 코덱을 탑재하고 있습니다. 내장 메시지 타입:
- NewOrderSingle — 주문 발주
- ExecutionReport — 체결 보고
- MarketDataIncrementalRefresh — 증분 시장 데이터 업데이트
- MassQuote — 대량 호가
- Heartbeat — 연결 확인
- Logon — 인증
외부 코드 생성기 불필요, XML 불필요. 모든 것을 Zig 구조체로 기술하며 컴파일 타임에 검증됩니다.
Wire Protocol: Aeron 호환
ZigBolt는 Aeron 호환 wire protocol flyweights를 구현합니다:
- DataHeaderFlyweight — 데이터 프레임
- StatusMessage — 흐름 제어
- NAK — 부정 확인 응답
- Setup, RTT, Error — 제어 프레임
이는 ZigBolt가 기존 Aeron 인프라와 공존할 수 있음을 의미합니다. 마이그레이션을 한 번에 할 필요가 없습니다.
향후 계획
ZigBolt는 현재 버전 0.2.1입니다. 코어는 안정적이고, 벤치마크는 재현 가능하며, 바인딩은 정상 작동합니다. 향후 계획:
- io_uring 백엔드 — Linux 6.0+에서 제로 카피 네트워크 전송 (IORING_OP_SEND_ZC)
- DPDK / AF_XDP — 1마이크로초가 승패를 가르는 상황을 위한 커널 바이패스
- Multi-Raft — 종목/전략별 샤딩
- 컬럼나 아카이브 — 분석을 위한 Apache Arrow/Parquet 통합
- Hugepage 지원 — TLB 미스 최소화를 위한 사전 할당 2MB/1GB 대형 페이지
직접 사용해 보세요
- 사이트: zigbolt-landing.vercel.app
- 문서: zigbolt-landing.vercel.app/getting-started/introduction/
- 소스 코드: github.com/suenot/zigbolt
- 라이선스: MIT
소스에서 빌드(zig build)하고, 벤치마크를 실행(zig build bench)하고, FFI로 어떤 언어에서든 연결하세요. Zig 0.15.1과 몇 분의 시간이 있다면 — ping-pong 벤치마크를 시도해서 현재 솔루션과 비교해 보세요.
링크:
- ZigBolt 사이트: zigbolt-landing.vercel.app
- GitHub: github.com/suenot/zigbolt
- Aeron (비교용): github.com/real-logic/aeron | Aeron 개요 글
- Zig 언어: ziglang.org
- Marketmaker.cc: marketmaker.cc
인용
@software{soloviov2026zigbolt,
author = {Soloviov, Eugen},
title = {ZigBolt: 우리가 Zig로 자체 Aeron을 구축해 메시지당 20나노초를 달성한 이유},
year = {2026},
url = {https://marketmaker.cc/ko/blog/post/zigbolt-zig-messaging-hft},
version = {0.2.1},
description = {HFT를 위한 초저지연 메시징 시스템을 Zig 언어로 처음부터 구축한 과정과 이유. JVM 없이, GC 없이, 예상치 못한 지연 없이.}
}
MarketMaker.cc Team
퀀트 리서치 및 전략