지난 글에서 나는 왜 LMAX가 전통적인 Queue를 버렸는지, 그 배경을 살펴보았다.
핵심은 Lock과 Context Switching 비용을 줄이는 것이었다.
그렇다면 구체적으로 어떻게?
Disruptor는 RingBuffer라는 자료구조와 Mechanical Sympathy라는 철학을 통해 이를 구현했다.
오늘은 Disruptor의 내부로 깊숙이 들어가, 이들이 어떻게 하드웨어의 한계를 소프트웨어적으로 극복했는지 뜯어보려 한다.
1. RingBuffer

RingBuffer는 이름 그대로 고정 크기의 순환 큐다. 사실 알고리즘 문제에서나 보던 평범한 자료구조다. 하지만 Disruptor는 이를 사용하는 방식이 다르다.
런타임 할당이 없는 구조
일반적인 Queue나 Stack은 데이터가 들어올 때 노드를 새로 연결하거나 배열 크기를 동적으로 늘린다.
하지만 RingBuffer는 생성 시점에 크기가 고정된다.
런타임 시 메모리 할당이나 해제가 발생하지 않는다.
이것이 성능의 첫 번째 열쇠다.
Head와 Tail 대신 Sequence
일반적인 큐는 Head(read)와 Tail(write) 포인터를 쓴다.
Disruptor는 이를 Sequence(시퀀스)라는 개념으로 관리한다.
- Producer: next()를 통해 다음 쓸 위치(Sequence)를 받는다.
- Consumer: 자신이 어디까지 읽었는지 별도의 Sequence로 관리한다.
덮어쓰기 vs 대기하기
내가 처음 링버퍼를 봤을 때 든 의문은 단순했다.
버퍼가 꽉 찼는데 Producer가 더 빨리 쓰면 어떻게 돼?
기존 데이터를 덮어써버릴까?
아니다.
Disruptor는 배압(Back-pressure) 전략을 취한다.
소비자가 데이터를 다 처리하지 못해 버퍼가 꽉 차면, 생산자는 소비자가 자리를 비워줄 때까지 대기한다. (이 대기 전략도 WaitStrategy로 선택 가능하다)
이를 통해 시스템 전체의 안정성을 확보한다.
하지만 단순히 원형 큐를 쓴다고 해서 LMAX가 말한 성능이 나올까?
여기서부터 Disruptor의 Hardware Sympathy를 활용한 노하우가 등장한다.
2. Event Pre-allocation
일반적으로 Java로 개발할 객체 생성은 아래와 같이 Lazy Initialization을 기본으로 한다.
// 일반적인 방식: 필요할 때 생성 (Lazy Initialization)
void publish(String data) {
queue.add(new Event(data)); // 매번 새로운 객체 생성 -> GC 대상
}
하지만 초당 수백만 건을 처리하는 거래소 시스템에서 new 연산자는 사치다.
수백만 개의 객체가 생성되고 버려지면 GC가 많이 발생하고, 결국 Stop-the-world가 발생해 시스템이 중간중간 잠시(아주 짧은 시간) 멈추게 된다.
Disruptor는 이 문제를 객체 사전 할당으로 해결했다.
초기화 시점에 모든 것을 만든다
Disruptor는 시작과 동시에 RingBuffer의 모든 슬롯에 빈 객체를 미리 채워 넣는다.



Disruptor의 크기만큼 할당받은 클래스를 인스턴스화하여 채워두는걸 볼 수 있다.
데이터를 담는 방식의 변화
데이터를 보낼 때 새로운 객체를 new 해서 넣는 게 아니라, 이미 그 자리에 있는 객체를 꺼내서 값만 업데이트한다.
val sequence = ringBuffer.next() // 1. 자리 배정
try {
val event = ringBuffer.get(sequence) // 2. 그 자리에 이미 있는 빈 객체를 꺼냄
// 3. 값만 변경
event.message = "Hello Disruptor"
event.timestamp = System.currentTimeMillis()
} finally {
ringBuffer.publish(sequence) // 4. 발행
}
우리가 흔히 배우는 "불변 객체를 사용하라"는 격언을 정면으로 깨부순다.
극한의 성능을 위해 '불변성'을 버리고 '재사용성'을 택한 것이다.
덕분에 GC는 수거할 쓰레기가 없어 한가해진다.
3. False Sharing
이제 하드웨어 레벨로 내려가보자. 이 부분이 Disruptor의 하이라이트이자 Hardware Sympathy의 정수다.
CPU 캐시와 공간 지역성
CPU는 메모리(RAM)에서 데이터를 가져올 때 1바이트씩 가져오지 않는다.
보통 64바이트씩 뭉텅이로 가져온다.
왜 64바이트씩 가져오느냐? 공간 지역성때문에 그렇다.
공간 지역성
한 번 접근한 메모리 위치 근처의 데이터에 가까운 미래에 다시 접근할 확률이 높은 현상
즉, 효율을 위해 한꺼번에 많이 가져온다는 뜻이다.
int[] array = {1, 2, 3, 4, 5, 6, 7, 8};
예를들어 위의 배열에서 array[0]을 읽으면 array[0]부터 array[7]까지 한번에 캐시에 로드된다.
array[1]부터 array[7]을 읽을때는 캐시에서 바로 읽게 되어 빠르게 읽을 수 있는 것이다.
단일 스레드에서는 하나의 코어에서 한 캐시 라인에 있는 모든 변수에 접근하여 문제가 없다.
하지만 단일 스레드 상황이 아니라면 이야기는 달라진다.
문제의 발생: 거짓 공유 (False Sharing)
멀티 스레드 환경에서는 여러 코어가 사용되며 단일 스레드와는 상황이 다르다.
변수 a,b가 있으며 아직 값은 할당되지 않은 상태로 캐시 라인에 있다고 가정하자.
class Counter {
volatile long count1; // Thread 1이 사용
volatile long count2; // Thread 2가 사용
}
// 캐시라인
┌──────────┬──────────┬─────────┐
│ a │ b │ ... │
└──────────┴──────────┴─────────┘
1번 스레드가 a에 100을 할당하는 경우 1번 CPU는 1번 CPU에 캐시 라인을 로딩하여 100을 저장한다.
┌──────────┬──────────┬─────────┐
│ 100 │ b │ ... │
└──────────┴──────────┴─────────┘
2번 스레드가 b에 50을 할당하는 경우 2번 CPU는 2번 CPU에 캐시 라인을 로딩한 뒤 50을 저장한다.
┌──────────┬──────────┬─────────┐
│ 100 │ 50 │ ... │
└──────────┴──────────┴─────────┘
이때 중요한건 캐시를 로딩할때 다른 코어의 캐시들을 무효화 한다는 것이다.
이는 MESI 프로토콜에 의해 발생하는 현상이다.
MESI 프로토콜?
CPU 환경에서 캐시 일관성을 유지하기 위한 프로토콜로 코어간의 캐시의 불일치가 발생할 가능성을 감지하여 미리 캐시를 무효화하고 상태를 변경하는 장치
2번 스레드가 b에 50을 할당하게 되면 1번 CPU에의 캐시는 아래와 같이 이전 상태로 되어있기 때문에 100과 50이 할당되어있는 2번 CPU의 캐시와 다른 상태인 것이다.
// 1번 CPU의 캐시 상태
┌──────────┬──────────┬─────────┐
│ 100 │ b │ ... │
└──────────┴──────────┴─────────┘
이때 MESI 프로토콜에 의해 1번 CPU의 캐시가 초기화 되며, 1번 스레드가 다시 변수에 접근할때 1번 CPU의 캐시가 로딩이 되며 최신화 된다.
// MESI 프로토콜에 의해 갱신된 1번 CPU의 캐시 상태
┌──────────┬──────────┬─────────┐
│ 100 │ 50 │ ... │
└──────────┴──────────┴─────────┘
결국 위와 같이 a, b와 같이 같은 캐시라인에 할당된 변수에 서로 다른 스레드에서 접근할때는 캐시의 일관성을 유지하려는 MESI 프로토콜에 의해 CPU에 캐시의 할당과 초기화가 무한히 반복될 수 밖에 없다.
스레드간에 서로 데이터를 공유하지 않는데도, 마치 공유하는 것처럼 성능 경합이 일어나는 현상, 이것이 거짓 공유(False Sharing)다.
Disruptor의 해결책: Padding
Disruptor는 이 문제를 해결하기 위해 변수 옆에 아무 의미 없는 더미 데이터(Padding)를 채워 넣는 기법을 사용했다.
핵심 변수(예: value)가 다른 변수와 같은 캐시 라인에 들어가지 않도록, 강제로 격리시키는 것이다.
value에 값을 할당하고 이를 하나의 캐시 라인으로 갖고 있기 위해 padding 전략을 활용하여 p1부터 p7까지 불필요한 변수를 추가하였다.
(일반적인으로 64바이트 단위로 캐시를 읽어오기 때문에 64바이트로 맞추었다)
class Counter {
volatile long value;
private long p1, p2, p3, p4, p5, p6, p7;
}
value에 값을 할당하고 이를 하나의 캐시 라인으로 갖고 있기 위해 padding 전략을 활용하여 p1부터 p7까지 불필요한 변수를 추가하였다.
하지만 JVM의 최적화 전략은 사용하지 않는 필드를 제거할 수 있기 때문에 이 또한 정상적으로 작동하기 어려울 수 있다.
JVM의 메모리 레이아웃 규칙은 다음과 같다.
1. 부모 클래스 필드를 먼저 배치
2. 자식 클래스 필드를 그 다음 배치
3. 계층 구조를 유지해야 함
위의 단순한 변수 선언 방식은 JVM의 메모리 레이아웃 규칙에 해당되지 않기 때문에 최적화 대상이 되어 p1~p7까지의 변수는 정상적으로 기능을 하지 못하게 된다.
Disruptor는 이를 우회하는 방식을 채택하였다.
class LhsPadding {
protected long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding {
protected volatile long value;
}
class RhsPadding extends Value {
protected long p9, p10, p11, p12, p13, p14, p15;
}
class Sequence extends RhsPadding {}
여기서 또 의문이 있다.
64바이트 캐시 라인이랬는데 위와 같이 처리하면 64바이트가 넘어가는데?
결국 목적은 64바이트 캐시 라인의 할당이 아닌 value를 독립적인 캐시 라인에 할당이다.
캐시 라인 0 (64바이트):
┌────────┬────┬────┬────┬────┬────┬────┬────┐
│ 헤더12B │ p1 │ p2 │ p3 │ p4 │ p5 │ p6 │p7일부│
└────────┴────┴────┴────┴────┴────┴────┴────┘
캐시 라인 1 (64바이트):
┌─────┬───────┬────┬─────┬─────┬─────┬─────┬─────┐
│ p7끝 │value │ p9 │ p10 │ p11 │ p12 │ p13 │ p14 │
└─────┴───────┴────┴─────┴─────┴─────┴─────┴─────┘
↑ Value
캐시 라인 2 (64바이트):
┌──────┬──────┐
│ p15 │ 정렬 │
└──────┴──────┘
JVM의 객체 헤더 크기와 메모리 정렬 방식을 고려할 때, 단순히 64바이트를 맞추는 것만으로는 부족하다.
변수가 캐시 라인의 시작점에 걸칠지, 끝점에 걸칠지 알 수 없기 때문이다.
그래서 Disruptor는 앞(Left)에도 패딩, 뒤(Right)에도 패딩을 넣어, 캐시 라인이 어디서 끊기더라도 value가 독자적인 라인에 존재하도록 확실하게 밀어버린 것이다.
(Java 8부터는 @Contended 어노테이션이 이 역할을 대신해줄 수 있다)
이렇게 되면 독립적인 캐시 라인을 갖게 되면서 서로 스레드에서 서로 다른 value에 접근할때 CPU간에 동일한 캐시 라인을 공유하지 않아서 (false sharing이 발생하지 않게 되어) 빠른 속도를 낼 수 있게 된다.
이것이 바로 Hardware Sympathry를 생각한 Disruptor의 전략이다.
'백엔드' 카테고리의 다른 글
| [Disruptor] 1. 왜 LMAX는 Queue를 버렸는가? (0) | 2025.12.27 |
|---|
