Ring Buffer — 고정 크기 큐로 로그와 메트릭을 처리하는 법
무한히 쏟아지는 로그나 메트릭 데이터를 어떻게 고정된 메모리 안에서 효율적으로 처리할 수 있을까요?
Ring Buffer란
Ring Buffer(원형 버퍼, Circular Buffer)는 고정 크기의 배열을 원형으로 사용 하는 자료구조입니다.
초기 상태: [ | | | | | | | ]
↑ head, tail
삽입 3개: [ A | B | C | | | | | ]
↑ head ↑ tail
삭제 2개: [ | | C | | | | | ]
↑ head ↑ tail
계속 삽입: [ | | C | D | E | F | G | H ]
↑ head ↑ tail
끝에 도달 후: [ I | J | C | D | E | F | G | H ]
↑ tail ↑ head ← 배열 처음으로 돌아감!
배열의 끝에 도달하면 처음으로 돌아갑니다. 크기를 넘으면 **가장 오래된 데이터를 덮어씁니다 **.
왜 필요한가
- ** 메모리 할당 없음 **: 배열을 한 번 할당하고 재사용
- **GC 없음 **: 객체 생성/소멸이 없어 지연 시간이 예측 가능
- ** 고정 메모리 **: 아무리 데이터가 많아도 메모리 사용량이 일정
- ** 높은 처리량 **: 배열 기반이므로 캐시 효율이 좋음
구현
public class RingBuffer<T> {
private Object[] buffer;
private int capacity;
private int head; // 읽기 위치
private int tail; // 쓰기 위치
private int size;
public RingBuffer(int capacity) {
this.capacity = capacity;
this.buffer = new Object[capacity];
this.head = 0;
this.tail = 0;
this.size = 0;
}
// 삽입: O(1)
public void offer(T item) {
buffer[tail] = item;
tail = (tail + 1) % capacity; // 모듈로 연산으로 순환
if (size == capacity) {
head = (head + 1) % capacity; // 가득 찼으면 가장 오래된 것 덮어쓰기
} else {
size++;
}
}
// 삭제: O(1)
@SuppressWarnings("unchecked")
public T poll() {
if (size == 0) return null;
T item = (T) buffer[head];
buffer[head] = null; // GC 도움
head = (head + 1) % capacity;
size--;
return item;
}
// 조회: O(1)
@SuppressWarnings("unchecked")
public T peek() {
if (size == 0) return null;
return (T) buffer[head];
}
public boolean isEmpty() { return size == 0; }
public boolean isFull() { return size == capacity; }
public int size() { return size; }
}
2의 거듭제곱 최적화
capacity를 2의 거듭제곱으로 설정하면 모듈로 연산을 비트 마스크로 대체할 수 있습니다.
// capacity = 1024 (2^10)
// index % 1024 == index & 1023 (비트 마스크)
int mask = capacity - 1;
tail = (tail + 1) & mask; // 모듈로보다 빠름
LMAX Disruptor
LMAX Disruptor는 Ring Buffer를 핵심으로 하는 ** 초고성능 메시지 큐** 라이브러리입니다. 금융 거래 시스템에서 탄생했으며, 초당 ** 수백만 건 **의 메시지를 처리합니다.
일반 BlockingQueue와의 차이
LinkedBlockingQueue:
- 노드 객체 생성 → GC 발생
- 락(lock)으로 동기화 → 컨텍스트 스위칭
- 약 300만 ops/sec
Disruptor:
- Ring Buffer, 객체 재사용 → GC 없음
- CAS + 메모리 배리어 → 락 프리
- 약 1억+ ops/sec
핵심 최적화
// 1. 캐시 라인 패딩 — False Sharing 방지
class Sequence {
long p1, p2, p3, p4, p5, p6, p7; // 패딩
volatile long value; // 실제 값
long p8, p9, p10, p11, p12, p13, p14; // 패딩
}
// 2. Sequence Barrier — 프로듀서/컨슈머 간 조율
// 프로듀서: "나는 여기까지 썼다" (sequence 공개)
// 컨슈머: "프로듀서가 여기까지 썼으니 여기까지 읽을 수 있다"
// 3. 다중 컨슈머 패턴
// 같은 이벤트를 여러 컨슈머가 독립적으로 처리
// 각 컨슈머가 자체 sequence를 관리
Disruptor 사용 패턴
// Disruptor 사용 예시 (개념적)
// Ring Buffer 크기는 반드시 2의 거듭제곱
Disruptor<OrderEvent> disruptor = new Disruptor<>(
OrderEvent::new, // 이벤트 팩토리
1024, // Ring Buffer 크기
DaemonThreadFactory.INSTANCE,
ProducerType.SINGLE, // 단일 프로듀서
new BusySpinWaitStrategy() // 대기 전략
);
// 이벤트 핸들러 체이닝
disruptor.handleEventsWith(journaler, replicator)
.then(businessLogic)
.then(publisher);
disruptor.start();
// 이벤트 발행
RingBuffer<OrderEvent> ringBuffer = disruptor.getRingBuffer();
long sequence = ringBuffer.next();
try {
OrderEvent event = ringBuffer.get(sequence);
event.setOrderId(12345);
event.setPrice(100.0);
} finally {
ringBuffer.publish(sequence); // 컨슈머에게 공개
}
Kafka 파티션
Kafka의 파티션은 Ring Buffer의 원리를 ** 디스크 레벨 **로 확장한 것입니다.
Kafka 파티션 (로그 세그먼트):
[segment-0] [segment-1] [segment-2] [segment-3]
↑ 새 메시지 추가
오래된 세그먼트는 retention 정책에 따라 삭제
→ 논리적으로 원형 구조
// Kafka Producer — 내부적으로 RecordAccumulator가 Ring Buffer 유사 구조
Properties props = new Properties();
props.put("buffer.memory", 33554432); // 32MB 버퍼
props.put("batch.size", 16384); // 16KB 배치
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
// RecordAccumulator가 메시지를 버퍼링한 뒤 배치로 전송
Java NIO ByteBuffer
Java NIO의 ByteBuffer도 Ring Buffer와 유사한 구조입니다.
ByteBuffer buffer = ByteBuffer.allocate(1024);
// position, limit, capacity 개념
// [ 쓰기 가능 영역 ]
// ↑ position ↑ limit ↑ capacity
// 쓰기
buffer.put((byte) 65); // position 이동
buffer.putInt(42); // position 이동
// 읽기 모드 전환
buffer.flip(); // limit = position, position = 0
// 읽기
byte b = buffer.get();
int i = buffer.getInt();
// 다시 쓰기 모드
buffer.clear(); // position = 0, limit = capacity (데이터 남아있음)
buffer.compact(); // 읽지 않은 데이터를 앞으로 이동
NIO 채널과 함께 사용하면 네트워크 I/O를 효율적으로 처리할 수 있습니다.
// 서버에서 NIO로 데이터 읽기
SocketChannel channel = ...;
ByteBuffer buffer = ByteBuffer.allocateDirect(4096); // 다이렉트 버퍼
while (channel.read(buffer) > 0) {
buffer.flip();
processData(buffer); // 데이터 처리
buffer.compact(); // 남은 데이터 유지
}
실무 활용
로그 버퍼
// 최근 N개의 로그만 메모리에 유지
public class LogBuffer {
private final RingBuffer<LogEntry> buffer;
public LogBuffer(int maxEntries) {
this.buffer = new RingBuffer<>(maxEntries);
}
public void log(String message, Level level) {
buffer.offer(new LogEntry(Instant.now(), message, level));
// 가득 차면 가장 오래된 로그가 자동으로 덮어씌워짐
}
// 최근 로그 조회
public List<LogEntry> getRecentLogs() {
List<LogEntry> result = new ArrayList<>();
// buffer를 순회하며 수집
return result;
}
}
메트릭 수집
// 최근 1분간의 응답 시간을 Ring Buffer로 수집
@Component
public class ResponseTimeTracker {
private static final int WINDOW_SIZE = 60; // 60초
private final long[] responseTimes = new long[WINDOW_SIZE];
private int index = 0;
public void record(long responseTimeMs) {
responseTimes[index % WINDOW_SIZE] = responseTimeMs;
index++;
}
public double averageResponseTime() {
long sum = 0;
int count = Math.min(index, WINDOW_SIZE);
for (int i = 0; i < count; i++) {
sum += responseTimes[i];
}
return count > 0 ? (double) sum / count : 0;
}
}
생산자-소비자 패턴
// 스레드 안전한 Ring Buffer (ArrayBlockingQueue 사용)
BlockingQueue<Event> eventQueue = new ArrayBlockingQueue<>(1024);
// 생산자
executor.submit(() -> {
while (true) {
Event event = receiveEvent();
eventQueue.put(event); // 가득 차면 대기
}
});
// 소비자
executor.submit(() -> {
while (true) {
Event event = eventQueue.take(); // 비어있으면 대기
processEvent(event);
}
});
Ring Buffer vs 일반 큐 비교
| 특성 | Ring Buffer | LinkedList 큐 | ArrayList 큐 |
|---|---|---|---|
| 메모리 할당 | 1회 (고정) | 매 삽입마다 | 확장 시 |
| GC 압력 | 없음 | 높음 | 가끔 |
| 캐시 효율 | 높음 | 낮음 | 높음 |
| 크기 제한 | 고정 | 무제한 | 가변 |
| 오래된 데이터 | 자동 덮어쓰기 | 직접 관리 | 직접 관리 |
정리
- Ring Buffer는 ** 고정 크기 배열을 원형으로 사용 **하여 메모리 할당 없이 데이터를 처리합니다.
- LMAX Disruptor 는 Ring Buffer 기반으로 초당 수억 건의 메시지를 처리하는 초고성능 큐입니다.
- Kafka의 파티션 도 Ring Buffer의 원리를 디스크 레벨로 확장한 것입니다.
- 로그 버퍼, 메트릭 수집, 생산자-소비자 패턴 등 고정 메모리로 스트리밍 데이터를 처리 해야 하는 곳에서 활용됩니다.
- capacity를 2의 거듭제곱으로 설정하면 비트 마스크로 모듈로 연산을 최적화할 수 있습니다.
댓글 로딩 중...