Netty 내부 유틸리티
Netty 소스를 읽다 보면 JDK에 이미 있는 것들을 굳이 다시 만들어 쓰는 클래스들이 눈에 띕니다.
Timer대신HashedWheelTimer,ThreadLocal대신FastThreadLocal, 그리고 객체 풀링을 위한Recycler까지 — 왜 바퀴를 다시 발명한 걸까요?
이전 글들에서 ByteBuf, EventLoop, 메모리 풀 등 Netty의 핵심 구성 요소를 살펴봤습니다. 이번에는 그 핵심 컴포넌트들을 뒤에서 받치고 있는 유틸리티 클래스 들을 정리해 봅니다. 겉으로 드러나지 않지만, Netty가 높은 성능을 유지하는 데 결정적인 역할을 하는 것들입니다.
HashedWheelTimer — 대량 타임아웃의 해결사
왜 필요한가
네트워크 서버에서는 커넥션마다 타임아웃 을 걸어야 합니다. 읽기 타임아웃, 쓰기 타임아웃, idle 감지 등 — 동시 접속이 1만 개라면 타임아웃도 수만 개입니다. JDK의 ScheduledExecutorService는 내부적으로 우선순위 큐(힙) 를 사용하기 때문에, 타임아웃 추가/제거에 O(log n) 비용이 발생합니다. 타임아웃 수가 많아질수록 이 비용이 누적됩니다.
해시 휠 구조
HashedWheelTimer는 시계처럼 생긴 원형 배열(wheel) 에 타임아웃을 분배하는 구조입니다.
[0] [1] [2] [3] [4] [5] [6] [7]
↑
현재 tick
- 휠 크기(ticksPerWheel): 512 (기본값)
- tick 간격(tickDuration): 100ms (기본값)
- 한 바퀴: 512 × 100ms = 약 51.2초
워커 스레드가 tickDuration마다 한 칸씩 전진하면서, 해당 슬롯에 매달린 타임아웃들을 확인합니다.
// HashedWheelTimer 생성 — tick 간격 100ms, 슬롯 512개
HashedWheelTimer timer = new HashedWheelTimer(
100, TimeUnit.MILLISECONDS, // tick 간격
512 // 휠 슬롯 수
);
// 타임아웃 등록 — 30초 후 실행
Timeout timeout = timer.newTimeout(t -> {
// 타임아웃 발생 시 처리 로직
System.out.println("커넥션 타임아웃 발생!");
channel.close();
}, 30, TimeUnit.SECONDS);
// 정상 응답이 왔으면 타임아웃 취소
timeout.cancel();
타임아웃이 슬롯에 배치되는 과정
- **deadline 계산 **: 등록 시점 + 지연 시간 = 만료 시각
- ** 슬롯 결정 **:
deadline / tickDuration % ticksPerWheel— 해시처럼 나머지 연산으로 슬롯 결정 - **remainingRounds 계산 **: 휠을 몇 바퀴 더 돌아야 하는지 기록
- ** 연결 리스트에 추가 **: 해당 슬롯의 링크드 리스트 끝에
O(1)로 추가
슬롯 [3]: → [타임아웃A, rounds=0] → [타임아웃B, rounds=2] → null
워커가 슬롯 [3]에 도착하면:
- 타임아웃A: rounds=0 → 만료! 콜백 실행
- 타임아웃B: rounds=2 → rounds를 1로 감소, 다음 바퀴에서 다시 확인
JDK ScheduledExecutorService와의 비교
| 항목 | HashedWheelTimer | ScheduledExecutorService |
|---|---|---|
| ** 자료구조** | 원형 배열 + 연결 리스트 | 우선순위 큐 (힙) |
| ** 추가/제거** | O(1) | O(log n) |
| ** 시간 정밀도** | tickDuration 단위 (약 100ms) | 나노초 수준 |
| ** 워커 스레드** | 1개 (단일 스레드) | 풀 크기만큼 병렬 |
| ** 적합한 용도** | 대량 타임아웃 (idle 감지, 커넥션 관리) | 소수의 정밀한 스케줄링 |
핵심은 ** 정밀도 vs 처리량 **의 트레이드오프입니다. 100ms 정도의 오차가 허용되는 네트워크 타임아웃에는 HashedWheelTimer가 압도적으로 효율적이고, 밀리초 단위의 정확한 스케줄링이 필요하면 ScheduledExecutorService가 적합합니다.
Netty의
IdleStateHandler,ReadTimeoutHandler,WriteTimeoutHandler가 모두 내부적으로HashedWheelTimer를 쓰는 이유가 여기에 있습니다. 커넥션이 수만 개여도 타이머 하나로 커버할 수 있으니까요.
FastThreadLocal — 해시 충돌 없는 스레드 로컬
JDK ThreadLocal의 문제
JDK의 ThreadLocal은 각 스레드가 가진 ThreadLocalMap에 값을 저장합니다. 이 맵은 ** 오픈 어드레싱 해시 테이블 **로 구현되어 있어서, 두 가지 오버헤드가 있습니다.
- ** 해시 계산 **:
ThreadLocal인스턴스의threadLocalHashCode로 슬롯 위치를 계산 - ** 선형 탐색(linear probing)**: 해시 충돌 시 다음 슬롯을 순회하며 탐색
ThreadLocal 인스턴스가 많아질수록 충돌 확률이 높아지고, 탐색 비용도 증가합니다. Netty처럼 ThreadLocal을 수십 개씩 사용하는 프레임워크에서는 이 오버헤드가 눈에 띄게 됩니다.
FastThreadLocal의 해결 방법
FastThreadLocal은 접근 방식 자체를 바꿉니다. ** 해시 테이블 대신 인덱스 기반 배열 직접 접근 **을 사용합니다.
public class FastThreadLocal<V> {
// 각 FastThreadLocal 인스턴스마다 고유 인덱스를 할당받는다
private final int index;
public FastThreadLocal() {
// 전역 AtomicInteger에서 인덱스를 하나 받는다
index = InternalThreadLocalMap.nextVariableIndex();
}
public final V get() {
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
// 해시 계산 없이, 인덱스로 배열에 직접 접근 — O(1)
Object v = threadLocalMap.indexedVariable(index);
if (v != InternalThreadLocalMap.UNSET) {
return (V) v;
}
return initialize(threadLocalMap);
}
}
비교하면 이렇습니다.
JDK ThreadLocal:
hashCode 계산 → 슬롯 위치 결정 → 충돌 시 선형 탐색 → 값 반환
FastThreadLocal:
index로 배열 접근 → 값 반환
InternalThreadLocalMap — 백킹 자료구조
FastThreadLocal의 값들은 InternalThreadLocalMap 안의 Object 배열 에 저장됩니다.
// InternalThreadLocalMap의 핵심 구조 (단순화)
class InternalThreadLocalMap {
// FastThreadLocal 값들을 저장하는 배열
private Object[] indexedVariables;
// 인덱스로 직접 접근 — 배열 참조 한 번이면 끝
public Object indexedVariable(int index) {
Object[] lookup = indexedVariables;
return index < lookup.length ? lookup[index] : UNSET;
}
// 값 설정도 배열 인덱스 접근
public boolean setIndexedVariable(int index, Object value) {
Object[] lookup = indexedVariables;
if (index < lookup.length) {
Object oldValue = lookup[index];
lookup[index] = value;
return oldValue == UNSET;
} else {
expandIndexedVariableTableAndSet(index, value);
return true;
}
}
}
배열 크기가 부족하면 2배씩 확장합니다. 해시 충돌 자체가 존재하지 않으므로, ThreadLocal이 아무리 많아도 접근 시간이 일정합니다.
FastThreadLocalThread와의 관계
FastThreadLocal이 제 성능을 발휘하려면 FastThreadLocalThread 에서 실행되어야 합니다.
// Netty의 DefaultThreadFactory가 생성하는 스레드
public class FastThreadLocalThread extends Thread {
// 스레드 필드에 InternalThreadLocalMap을 직접 보관
InternalThreadLocalMap threadLocalMap;
}
FastThreadLocalThread는 InternalThreadLocalMap을 스레드 인스턴스의 필드 로 직접 들고 있습니다. JDK ThreadLocal을 거치지 않으므로 한 단계 더 빠릅니다.
일반 스레드에서 FastThreadLocal을 쓰면? 동작은 합니다. 다만 InternalThreadLocalMap을 JDK ThreadLocal에 저장하는 폴백 경로(slow path) 를 타게 됩니다. 여전히 JDK ThreadLocal보다는 빠르지만(해시 충돌이 없으므로), 최적 성능은 아닙니다.
FastThreadLocalThread에서:
thread.threadLocalMap 필드 접근 → indexedVariables[index]
일반 Thread에서 (폴백):
JDK ThreadLocal.get() → InternalThreadLocalMap → indexedVariables[index]
Netty의
EventLoop스레드가FastThreadLocalThread인 이유가 바로 이것입니다. EventLoop 안에서 사용하는 수십 개의FastThreadLocal이 모두 최적 경로로 접근할 수 있도록요.
Recycler — 객체를 버리지 않고 재사용한다
GC 압박을 줄이는 객체 풀링
네트워크 서버에서는 요청마다 ByteBuf, ChannelOutboundBuffer 같은 객체를 생성하고 버립니다. 초당 수만 건이면 GC 부담이 상당합니다. Recycler는 이 객체들을 버리지 않고 풀에 반환해서 재사용 하는 메커니즘입니다.
기본 사용 패턴
public class MyMessage {
// Recycler 정의 — 풀에서 꺼낼 때 새 객체를 어떻게 만들지 지정
private static final Recycler<MyMessage> RECYCLER = new Recycler<MyMessage>() {
@Override
protected MyMessage newObject(Handle<MyMessage> handle) {
return new MyMessage(handle);
}
};
private final Recycler.Handle<MyMessage> handle;
private String content;
private MyMessage(Recycler.Handle<MyMessage> handle) {
this.handle = handle;
}
// 풀에서 객체를 꺼내는 팩토리 메서드
public static MyMessage newInstance() {
return RECYCLER.get();
}
// 사용이 끝나면 상태를 초기화하고 풀에 반환
public void recycle() {
this.content = null; // 상태 초기화 필수!
handle.recycle(this);
}
}
사용하는 쪽에서는 이렇게 됩니다.
// 풀에서 꺼내기 (새로 만들거나, 기존 객체 재사용)
MyMessage msg = MyMessage.newInstance();
msg.setContent("Hello");
// 처리 후 풀에 반환
msg.recycle();
내부 동작 — Handle과 Stack
Recycler의 내부는 스레드별 스택(Stack) 으로 구성되어 있습니다.
Thread-1의 Stack:
[MyMessage#3] ← top
[MyMessage#1]
[MyMessage#0]
get() 호출 → Stack에서 pop → MyMessage#3 반환
recycle() 호출 → Stack에 push → MyMessage#3 다시 적재
핵심 동작 흐름은 다음과 같습니다.
- get(): 현재 스레드의 Stack에서
pop. 비어 있으면newObject()로 새로 생성 - recycle():
Handle을 통해 객체를 Stack에push - ** 크로스 스레드 반환 **: 다른 스레드에서
recycle()하면WeakOrderQueue에 임시 저장 → 원래 스레드가get()할 때 전송
크로스 스레드 시나리오가 중요합니다. A 스레드에서 만든 객체를 B 스레드에서 쓰고 반환하는 일이 빈번하기 때문입니다.
Thread-A (소유자): Thread-B (사용자):
Stack [msg1, msg2]
│ msg3 = get() → Thread-A Stack에서 pop
│ msg3 사용 후 recycle()
│ ↓
│ WeakOrderQueue에 추가
│ ↓
다음 get() 시
WeakOrderQueue → Stack으로 전송
msg3을 다시 사용 가능
Netty 내부에서의 활용
Recycler는 Netty 곳곳에서 사용됩니다.
PooledDirectByteBuf/PooledHeapByteBuf: ByteBuf 객체 자체를 재사용 (메모리 풀링과는 별개로, 래퍼 객체의 GC를 줄임)ChannelOutboundBuffer.Entry: write 요청마다 생성되는 Entry 객체를 풀링PooledUnsafeDirectByteBuf: Unsafe 기반 ByteBuf의 래퍼 객체 재사용
// Netty 내부 — PooledDirectByteBuf에서 Recycler 사용 (단순화)
final class PooledDirectByteBuf extends PooledByteBuf<ByteBuffer> {
private static final Recycler<PooledDirectByteBuf> RECYCLER =
new Recycler<PooledDirectByteBuf>() {
@Override
protected PooledDirectByteBuf newObject(Handle<PooledDirectByteBuf> handle) {
return new PooledDirectByteBuf(handle, 0);
}
};
// allocate할 때 새로 만들지 않고 풀에서 꺼낸다
static PooledDirectByteBuf newInstance(int maxCapacity) {
PooledDirectByteBuf buf = RECYCLER.get();
buf.reuse(maxCapacity);
return buf;
}
// deallocate할 때 풀에 반환
@Override
protected void deallocate() {
// 메모리 풀에 chunk 반환 + 래퍼 객체를 Recycler에 반환
handle.recycle(this);
}
}
여기서 구분해야 할 점이 있습니다. ** 메모리 풀(PooledByteBufAllocator)**은 네이티브 메모리 청크를 재사용하는 것이고, Recycler 는 그 청크를 감싸는 Java 래퍼 객체를 재사용하는 것입니다. 둘은 서로 다른 계층의 최적화입니다.
왜 JDK 것을 안 쓰는가 — 정리
Netty가 자체 유틸리티를 만든 이유를 한 곳에 모아보면 이렇습니다.
ThreadLocal → FastThreadLocal
| 문제 | JDK ThreadLocal | FastThreadLocal |
|---|---|---|
| 접근 방식 | 해시 테이블 + 선형 탐색 | 인덱스 기반 배열 직접 접근 |
| 해시 충돌 | 인스턴스가 많으면 충돌 증가 | 충돌 자체가 없음 |
| 메모리 릭 위험 | remove() 누락 시 릭 가능 | removeAll()로 일괄 정리 |
| 최적 스레드 | 모든 Thread | FastThreadLocalThread |
Timer → HashedWheelTimer
| 문제 | ScheduledExecutorService | HashedWheelTimer |
|---|---|---|
| 타임아웃 등록 | O(log n) 힙 삽입 | O(1) 슬롯 추가 |
| 대량 타임아웃 | n이 커지면 성능 저하 | n에 무관하게 일정 |
| 정밀도 | 나노초 수준 | tick 단위 (보통 100ms) |
| 스레드 모델 | 스레드 풀 | 단일 워커 스레드 |
객체 생성 → Recycler
| 문제 | 매번 new | Recycler |
|---|---|---|
| GC 부담 | 객체 수에 비례 | 대폭 감소 |
| 할당 비용 | 매번 힙 할당 | 스택 pop (O(1)) |
| 크로스 스레드 | 해당 없음 | WeakOrderQueue로 지원 |
| 주의점 | 없음 | 상태 초기화 필수, recycle 누락 시 풀 고갈 |
공통 패턴이 보입니다. 범용 JDK 구현은 다양한 상황을 커버하기 위해 보수적으로 설계 되었고, Netty는 네트워크 서버라는 특정 워크로드에 맞게 트레이드오프를 조정 한 것입니다. 정밀도를 조금 포기하고 처리량을 극대화하거나, 메모리를 조금 더 쓰고 접근 속도를 올리는 식입니다.
실무에서 활용하기
HashedWheelTimer로 커넥션 타임아웃 관리
// 애플리케이션 전체에서 하나의 타이머를 공유한다
// HashedWheelTimer는 워커 스레드를 하나만 쓰므로, 인스턴스를 여러 개 만들면 낭비
private static final HashedWheelTimer TIMER = new HashedWheelTimer(
new DefaultThreadFactory("timeout-timer"),
100, TimeUnit.MILLISECONDS, // tick 간격
512 // 슬롯 수
);
// 커넥션 핸들러에서 타임아웃 등록
public class ConnectionHandler extends ChannelInboundHandlerAdapter {
private Timeout idleTimeout;
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 커넥션 생성 시 idle 타임아웃 등록
idleTimeout = TIMER.newTimeout(t -> {
System.out.println("60초 동안 데이터 없음 — 커넥션 종료");
ctx.close();
}, 60, TimeUnit.SECONDS);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 데이터가 올 때마다 타임아웃 갱신
if (idleTimeout != null) {
idleTimeout.cancel();
}
idleTimeout = TIMER.newTimeout(t -> {
ctx.close();
}, 60, TimeUnit.SECONDS);
// 메시지 처리
ctx.fireChannelRead(msg);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
// 커넥션 종료 시 타임아웃 취소
if (idleTimeout != null) {
idleTimeout.cancel();
}
}
}
주의사항이 몇 가지 있습니다.
- **인스턴스를 공유하세요 **:
HashedWheelTimer마다 워커 스레드가 하나 생깁니다. 커넥션마다 새로 만들면 스레드가 폭증합니다 - ** 콜백에서 무거운 작업을 하지 마세요 **: 워커 스레드가 하나이므로, 콜백이 오래 걸리면 다른 타임아웃 처리가 밀립니다
- **stop() 호출을 잊지 마세요 **: 애플리케이션 종료 시
timer.stop()을 호출해야 워커 스레드가 정리됩니다
Recycler로 커스텀 객체 풀링
// 요청마다 생성되는 컨텍스트 객체를 풀링
public class RequestContext {
private static final Recycler<RequestContext> RECYCLER = new Recycler<RequestContext>() {
@Override
protected RequestContext newObject(Handle<RequestContext> handle) {
return new RequestContext(handle);
}
};
private final Recycler.Handle<RequestContext> handle;
private long startTimeNanos;
private String requestId;
private Map<String, Object> attributes;
private RequestContext(Recycler.Handle<RequestContext> handle) {
this.handle = handle;
this.attributes = new HashMap<>();
}
public static RequestContext acquire() {
RequestContext ctx = RECYCLER.get();
ctx.startTimeNanos = System.nanoTime();
return ctx;
}
// 반환 시 모든 상태를 초기화 — 이걸 빠뜨리면 이전 요청 데이터가 남는다
public void release() {
this.startTimeNanos = 0;
this.requestId = null;
this.attributes.clear();
handle.recycle(this);
}
}
Recycler 사용 시 반드시 지켜야 할 것들입니다.
- ** 상태 초기화를 빠뜨리지 마세요 **:
recycle()전에 모든 필드를 리셋하지 않으면, 다음 사용자가 이전 데이터를 보게 됩니다. 보안 사고로도 이어질 수 있습니다 - **recycle을 두 번 호출하지 마세요 **: 같은 객체를 두 번 반환하면 풀이 오염됩니다
- ** 참조를 유지하지 마세요 **:
recycle()후에 해당 객체 참조를 다른 곳에서 계속 쓰면 use-after-free 문제가 발생합니다
정리
| 유틸리티 | JDK 대응물 | 핵심 개선 | Netty 내부 사용처 |
|---|---|---|---|
| HashedWheelTimer | ScheduledExecutorService | O(1) 타임아웃 관리, 단일 워커 | IdleStateHandler, ReadTimeoutHandler |
| FastThreadLocal | ThreadLocal | 인덱스 배열 직접 접근, 해시 충돌 제거 | EventLoop 내부 수십 개의 스레드 로컬 값 |
| InternalThreadLocalMap | ThreadLocalMap | Object[] 백킹 배열, 선형 탐색 없음 | FastThreadLocal의 자료구조 |
| Recycler | (없음) | 스레드별 스택 기반 객체 풀링 | PooledByteBuf, ChannelOutboundBuffer.Entry |
Netty의 철학이 일관됩니다. ** 범용 JDK 구현의 "안전한 기본값"을 네트워크 서버 워크로드에 맞게 재조정 **한 것입니다. 정밀도보다 처리량, 범용성보다 특화된 최적 경로를 선택한 결과, 해시 계산 한 번, 힙 삽입 한 번의 비용도 아끼는 수준까지 도달했습니다.
이 유틸리티들을 직접 사용할 일이 많지는 않겠지만, Netty 기반 서버를 운영하거나 커스텀 핸들러를 작성할 때 HashedWheelTimer의 타임아웃 관리와 Recycler의 객체 풀링은 알아두면 유용합니다.