Netty 소스를 읽다 보면 JDK에 이미 있는 것들을 굳이 다시 만들어 쓰는 클래스들이 눈에 띕니다. Timer 대신 HashedWheelTimer, ThreadLocal 대신 FastThreadLocal, 그리고 객체 풀링을 위한 Recycler까지 — 왜 바퀴를 다시 발명한 걸까요?

이전 글들에서 ByteBuf, EventLoop, 메모리 풀 등 Netty의 핵심 구성 요소를 살펴봤습니다. 이번에는 그 핵심 컴포넌트들을 뒤에서 받치고 있는 유틸리티 클래스 들을 정리해 봅니다. 겉으로 드러나지 않지만, Netty가 높은 성능을 유지하는 데 결정적인 역할을 하는 것들입니다.


HashedWheelTimer — 대량 타임아웃의 해결사

왜 필요한가

네트워크 서버에서는 커넥션마다 타임아웃 을 걸어야 합니다. 읽기 타임아웃, 쓰기 타임아웃, idle 감지 등 — 동시 접속이 1만 개라면 타임아웃도 수만 개입니다. JDK의 ScheduledExecutorService는 내부적으로 우선순위 큐(힙) 를 사용하기 때문에, 타임아웃 추가/제거에 O(log n) 비용이 발생합니다. 타임아웃 수가 많아질수록 이 비용이 누적됩니다.

해시 휠 구조

HashedWheelTimer시계처럼 생긴 원형 배열(wheel) 에 타임아웃을 분배하는 구조입니다.

PLAINTEXT
        [0] [1] [2] [3] [4] [5] [6] [7]

      현재 tick

  - 휠 크기(ticksPerWheel): 512 (기본값)
  - tick 간격(tickDuration): 100ms (기본값)
  - 한 바퀴: 512 × 100ms = 약 51.2초

워커 스레드가 tickDuration마다 한 칸씩 전진하면서, 해당 슬롯에 매달린 타임아웃들을 확인합니다.

JAVA
// 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();

타임아웃이 슬롯에 배치되는 과정

  1. **deadline 계산 **: 등록 시점 + 지연 시간 = 만료 시각
  2. ** 슬롯 결정 **: deadline / tickDuration % ticksPerWheel — 해시처럼 나머지 연산으로 슬롯 결정
  3. **remainingRounds 계산 **: 휠을 몇 바퀴 더 돌아야 하는지 기록
  4. ** 연결 리스트에 추가 **: 해당 슬롯의 링크드 리스트 끝에 O(1)로 추가
PLAINTEXT
슬롯 [3]: → [타임아웃A, rounds=0] → [타임아웃B, rounds=2] → null

워커가 슬롯 [3]에 도착하면:
  - 타임아웃A: rounds=0 → 만료! 콜백 실행
  - 타임아웃B: rounds=2 → rounds를 1로 감소, 다음 바퀴에서 다시 확인

JDK ScheduledExecutorService와의 비교

항목HashedWheelTimerScheduledExecutorService
** 자료구조**원형 배열 + 연결 리스트우선순위 큐 (힙)
** 추가/제거**O(1)O(log n)
** 시간 정밀도**tickDuration 단위 (약 100ms)나노초 수준
** 워커 스레드**1개 (단일 스레드)풀 크기만큼 병렬
** 적합한 용도**대량 타임아웃 (idle 감지, 커넥션 관리)소수의 정밀한 스케줄링

핵심은 ** 정밀도 vs 처리량 **의 트레이드오프입니다. 100ms 정도의 오차가 허용되는 네트워크 타임아웃에는 HashedWheelTimer가 압도적으로 효율적이고, 밀리초 단위의 정확한 스케줄링이 필요하면 ScheduledExecutorService가 적합합니다.

Netty의 IdleStateHandler, ReadTimeoutHandler, WriteTimeoutHandler가 모두 내부적으로 HashedWheelTimer를 쓰는 이유가 여기에 있습니다. 커넥션이 수만 개여도 타이머 하나로 커버할 수 있으니까요.


FastThreadLocal — 해시 충돌 없는 스레드 로컬

JDK ThreadLocal의 문제

JDK의 ThreadLocal은 각 스레드가 가진 ThreadLocalMap에 값을 저장합니다. 이 맵은 ** 오픈 어드레싱 해시 테이블 **로 구현되어 있어서, 두 가지 오버헤드가 있습니다.

  1. ** 해시 계산 **: ThreadLocal 인스턴스의 threadLocalHashCode로 슬롯 위치를 계산
  2. ** 선형 탐색(linear probing)**: 해시 충돌 시 다음 슬롯을 순회하며 탐색

ThreadLocal 인스턴스가 많아질수록 충돌 확률이 높아지고, 탐색 비용도 증가합니다. Netty처럼 ThreadLocal을 수십 개씩 사용하는 프레임워크에서는 이 오버헤드가 눈에 띄게 됩니다.

FastThreadLocal의 해결 방법

FastThreadLocal은 접근 방식 자체를 바꿉니다. ** 해시 테이블 대신 인덱스 기반 배열 직접 접근 **을 사용합니다.

JAVA
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);
    }
}

비교하면 이렇습니다.

PLAINTEXT
JDK ThreadLocal:
  hashCode 계산 → 슬롯 위치 결정 → 충돌 시 선형 탐색 → 값 반환

FastThreadLocal:
  index로 배열 접근 → 값 반환

InternalThreadLocalMap — 백킹 자료구조

FastThreadLocal의 값들은 InternalThreadLocalMap 안의 Object 배열 에 저장됩니다.

JAVA
// 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 에서 실행되어야 합니다.

JAVA
// Netty의 DefaultThreadFactory가 생성하는 스레드
public class FastThreadLocalThread extends Thread {
    // 스레드 필드에 InternalThreadLocalMap을 직접 보관
    InternalThreadLocalMap threadLocalMap;
}

FastThreadLocalThreadInternalThreadLocalMap스레드 인스턴스의 필드 로 직접 들고 있습니다. JDK ThreadLocal을 거치지 않으므로 한 단계 더 빠릅니다.

일반 스레드에서 FastThreadLocal을 쓰면? 동작은 합니다. 다만 InternalThreadLocalMap을 JDK ThreadLocal에 저장하는 폴백 경로(slow path) 를 타게 됩니다. 여전히 JDK ThreadLocal보다는 빠르지만(해시 충돌이 없으므로), 최적 성능은 아닙니다.

PLAINTEXT
FastThreadLocalThread에서:
  thread.threadLocalMap 필드 접근 → indexedVariables[index]

일반 Thread에서 (폴백):
  JDK ThreadLocal.get() → InternalThreadLocalMap → indexedVariables[index]

Netty의 EventLoop 스레드가 FastThreadLocalThread인 이유가 바로 이것입니다. EventLoop 안에서 사용하는 수십 개의 FastThreadLocal이 모두 최적 경로로 접근할 수 있도록요.


Recycler — 객체를 버리지 않고 재사용한다

GC 압박을 줄이는 객체 풀링

네트워크 서버에서는 요청마다 ByteBuf, ChannelOutboundBuffer 같은 객체를 생성하고 버립니다. 초당 수만 건이면 GC 부담이 상당합니다. Recycler는 이 객체들을 버리지 않고 풀에 반환해서 재사용 하는 메커니즘입니다.

기본 사용 패턴

JAVA
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);
    }
}

사용하는 쪽에서는 이렇게 됩니다.

JAVA
// 풀에서 꺼내기 (새로 만들거나, 기존 객체 재사용)
MyMessage msg = MyMessage.newInstance();
msg.setContent("Hello");

// 처리 후 풀에 반환
msg.recycle();

내부 동작 — Handle과 Stack

Recycler의 내부는 스레드별 스택(Stack) 으로 구성되어 있습니다.

PLAINTEXT
Thread-1의 Stack:
  [MyMessage#3] ← top
  [MyMessage#1]
  [MyMessage#0]

get() 호출 → Stack에서 pop → MyMessage#3 반환
recycle() 호출 → Stack에 push → MyMessage#3 다시 적재

핵심 동작 흐름은 다음과 같습니다.

  1. get(): 현재 스레드의 Stack에서 pop. 비어 있으면 newObject()로 새로 생성
  2. recycle(): Handle을 통해 객체를 Stack에 push
  3. ** 크로스 스레드 반환 **: 다른 스레드에서 recycle()하면 WeakOrderQueue에 임시 저장 → 원래 스레드가 get()할 때 전송

크로스 스레드 시나리오가 중요합니다. A 스레드에서 만든 객체를 B 스레드에서 쓰고 반환하는 일이 빈번하기 때문입니다.

PLAINTEXT
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의 래퍼 객체 재사용
JAVA
// 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 ThreadLocalFastThreadLocal
접근 방식해시 테이블 + 선형 탐색인덱스 기반 배열 직접 접근
해시 충돌인스턴스가 많으면 충돌 증가충돌 자체가 없음
메모리 릭 위험remove() 누락 시 릭 가능removeAll()로 일괄 정리
최적 스레드모든 ThreadFastThreadLocalThread

Timer → HashedWheelTimer

문제ScheduledExecutorServiceHashedWheelTimer
타임아웃 등록O(log n) 힙 삽입O(1) 슬롯 추가
대량 타임아웃n이 커지면 성능 저하n에 무관하게 일정
정밀도나노초 수준tick 단위 (보통 100ms)
스레드 모델스레드 풀단일 워커 스레드

객체 생성 → Recycler

문제매번 newRecycler
GC 부담객체 수에 비례대폭 감소
할당 비용매번 힙 할당스택 pop (O(1))
크로스 스레드해당 없음WeakOrderQueue로 지원
주의점없음상태 초기화 필수, recycle 누락 시 풀 고갈

공통 패턴이 보입니다. 범용 JDK 구현은 다양한 상황을 커버하기 위해 보수적으로 설계 되었고, Netty는 네트워크 서버라는 특정 워크로드에 맞게 트레이드오프를 조정 한 것입니다. 정밀도를 조금 포기하고 처리량을 극대화하거나, 메모리를 조금 더 쓰고 접근 속도를 올리는 식입니다.


실무에서 활용하기

HashedWheelTimer로 커넥션 타임아웃 관리

JAVA
// 애플리케이션 전체에서 하나의 타이머를 공유한다
// 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로 커스텀 객체 풀링

JAVA
// 요청마다 생성되는 컨텍스트 객체를 풀링
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 내부 사용처
HashedWheelTimerScheduledExecutorServiceO(1) 타임아웃 관리, 단일 워커IdleStateHandler, ReadTimeoutHandler
FastThreadLocalThreadLocal인덱스 배열 직접 접근, 해시 충돌 제거EventLoop 내부 수십 개의 스레드 로컬 값
InternalThreadLocalMapThreadLocalMapObject[] 백킹 배열, 선형 탐색 없음FastThreadLocal의 자료구조
Recycler(없음)스레드별 스택 기반 객체 풀링PooledByteBuf, ChannelOutboundBuffer.Entry

Netty의 철학이 일관됩니다. ** 범용 JDK 구현의 "안전한 기본값"을 네트워크 서버 워크로드에 맞게 재조정 **한 것입니다. 정밀도보다 처리량, 범용성보다 특화된 최적 경로를 선택한 결과, 해시 계산 한 번, 힙 삽입 한 번의 비용도 아끼는 수준까지 도달했습니다.

이 유틸리티들을 직접 사용할 일이 많지는 않겠지만, Netty 기반 서버를 운영하거나 커스텀 핸들러를 작성할 때 HashedWheelTimer의 타임아웃 관리와 Recycler의 객체 풀링은 알아두면 유용합니다.

댓글 로딩 중...