ByteBuf를 쓸 때마다 매번 새로 메모리를 할당하고 해제한다면, 네트워크 서버처럼 초당 수만 건의 요청을 처리하는 상황에서 성능은 어떻게 될까?

이전 글에서 ByteBuf의 구조와 사용 패턴을 다뤘는데, 사실 ByteBuf가 진짜 빛나는 이유는 메모리 풀링 에 있습니다. 네티는 PooledByteBufAllocator라는 정교한 메모리 풀을 기본 allocator로 사용하고 있고, 이 풀의 내부 구조는 C 라이브러리인 jemalloc에서 영감을 받았습니다. 이번 글에서는 왜 메모리 풀이 필요한지부터 시작해서, Arena → Chunk → Subpage로 이어지는 계층 구조와 튜닝 방법까지 정리해 보겠습니다.


왜 메모리 풀이 필요한가

네트워크 애플리케이션에서 버퍼 할당/해제가 빈번하게 일어나면, 세 가지 비용이 누적됩니다.

1. malloc/free의 시스템 콜 비용

Direct ByteBuf는 OS의 네이티브 메모리를 사용합니다. 매번 malloc()으로 할당하고 free()로 해제하면 시스템 콜 오버헤드 가 발생하고, 메모리 단편화(fragmentation)도 심해집니다. 특히 다양한 크기의 버퍼를 반복적으로 할당/해제하면, 남는 공간은 있는데 연속된 블록이 없어서 할당이 실패하는 외부 단편화가 생깁니다.

2. GC 부담

Heap ByteBuf는 JVM 힙에 할당되므로 GC 대상입니다. 초당 수만 개의 버퍼를 만들고 버리면 Young Generation GC가 빈번 해지고, 크기가 큰 버퍼는 Old Generation으로 승격되어 Full GC를 유발할 수도 있습니다.

3. Direct Buffer 할당 비용

ByteBuffer.allocateDirect()는 내부적으로 Unsafe.allocateMemory()를 호출하는데, 이 작업은 힙 할당보다 수십 배 느립니다. 네트워크 I/O에서 Direct Buffer가 유리하지만, 매번 새로 할당하면 오히려 성능이 떨어지는 역설이 생깁니다.

메모리 풀의 핵심 아이디어는 단순합니다. ** 미리 큰 메모리 블록을 할당해 두고, 요청이 들어오면 그 안에서 조각을 잘라서 빌려주고, 다 쓰면 반환받아 재사용하는 것 **입니다.


jemalloc 영감 — 왜 Arena 구조인가

네티의 메모리 풀은 Facebook(현 Meta)이 밀어준 것으로도 유명한 C 메모리 할당자 jemalloc 의 설계를 Java로 옮긴 것입니다. jemalloc의 핵심 아이디어는 두 가지입니다.

스레드별 Arena 분리

전통적인 malloc전역 힙 하나 를 모든 스레드가 공유하기 때문에, 동시에 메모리를 할당하려면 락(lock)이 필요합니다. 스레드가 많아질수록 락 경합이 심해지고 성능이 떨어집니다.

jemalloc은 이 문제를 여러 개의 Arena로 힙을 분리 해서 해결했습니다. 각 스레드는 자신에게 할당된 Arena에서 메모리를 가져가므로, 다른 스레드와 경합할 일이 크게 줄어듭니다.

크기별 계층적 관리

큰 블록(Chunk)을 미리 할당해 두고, 요청 크기에 따라 적절한 단위로 쪼개서 제공합니다. 이렇게 하면 외부 단편화를 줄이면서도 다양한 크기의 요청을 효율적으로 처리할 수 있습니다.

PLAINTEXT
jemalloc 구조 (Netty가 차용한 핵심)

Thread 1 ──→ Arena 0 ──→ Chunk ──→ Page ──→ Subpage
Thread 2 ──→ Arena 1 ──→ Chunk ──→ Page ──→ Subpage
Thread 3 ──→ Arena 0 ──→ ...   (Arena 수보다 스레드가 많으면 공유)

네티 4.x부터 이 구조를 Java로 구현한 PooledByteBufAllocator가 기본 allocator로 사용됩니다. 별도 설정 없이도 메모리 풀링이 적용되는 것이 바로 이 덕분입니다.


Arena 구조 — PoolArena와 스레드 바인딩

네티의 PooledByteBufAllocator는 내부적으로 PoolArena 배열을 가지고 있습니다. Heap용과 Direct용이 각각 따로 존재합니다.

PoolArena 개요

JAVA
// PooledByteBufAllocator 내부 (개념적 구조)
PoolArena<byte[]>[] heapArenas;      // Heap 버퍼용 Arena 배열
PoolArena<ByteBuffer>[] directArenas; // Direct 버퍼용 Arena 배열

기본적으로 Arena 개수는 CPU 코어 수 × 2 입니다. 예를 들어 8코어 서버라면 Heap Arena 16개, Direct Arena 16개가 생성됩니다.

스레드별 Arena 할당

각 스레드가 처음으로 버퍼를 할당하면, 네티는 가장 적은 스레드가 바인딩된 Arena 를 골라서 해당 스레드에 연결합니다. 이후 그 스레드의 모든 버퍼 할당은 같은 Arena에서 이루어집니다.

JAVA
// 스레드 → Arena 바인딩 과정 (개념적 흐름)
// 1. 스레드가 처음 buffer() 호출
// 2. PoolThreadLocalCache에서 이 스레드에 할당된 Arena 조회
// 3. 없으면 → 가장 적게 사용된 Arena를 선택해서 바인딩
// 4. 이후 이 스레드는 항상 같은 Arena 사용

이 구조 덕분에 같은 Arena를 동시에 접근하는 스레드 수가 줄어들고, 락 경합이 최소화 됩니다.

PoolThreadCache — 스레드 로컬 캐시

Arena에서 한 단계 더 최적화된 것이 PoolThreadCache 입니다. 각 스레드는 자주 사용하는 크기의 버퍼를 스레드 로컬 캐시에 보관합니다. release()로 반환된 버퍼가 바로 Arena로 돌아가지 않고 스레드 캐시에 저장되었다가, 같은 스레드에서 같은 크기를 요청하면 락 없이 즉시 재사용 됩니다.

PLAINTEXT
할당 흐름:
1. PoolThreadCache에 맞는 크기가 있는가? → 있으면 즉시 반환 (락 없음)
2. 없으면 → PoolArena에서 할당 (Arena 내부 동기화)
3. Arena에도 여유가 없으면 → 새 PoolChunk 할당

PoolChunk & PoolSubpage — 메모리 계층

Arena 안에서 실제 메모리를 관리하는 단위가 PoolChunk 와 PoolSubpage 입니다.

PoolChunk — 16MB 단위의 큰 블록

PoolChunk는 Arena가 OS에서 한 번에 할당받는 큰 메모리 블록입니다.

  • **기본 크기 **: 16MB (pageSize × 2^maxOrder = 8KB × 2^11 = 16MB)
  • ** 내부 구조 **: 완전 이진 트리(complete binary tree)로 페이지들을 관리
PLAINTEXT
PoolChunk (16MB) — 이진 트리 구조

                    [16MB]              depth 0
                   /      \
              [8MB]        [8MB]        depth 1
             /    \       /    \
          [4MB] [4MB]  [4MB] [4MB]     depth 2
           ...    ...    ...    ...
          /  \
       [8KB][8KB]                      depth 11 (리프 = Page)
  • 트리의 ** 리프 노드 가 하나의 ** 페이지(8KB)
  • 8KB보다 큰 요청은 트리에서 적절한 크기의 노드를 찾아 할당 (버디 할당 알고리즘)
  • 예: 32KB 요청 → depth 9의 노드(32KB) 하나를 할당

PoolSubpage — 8KB 미만의 작은 할당

페이지(8KB)보다 작은 버퍼를 요청하면 PoolSubpage 가 동작합니다. 하나의 페이지를 동일한 크기의 작은 조각들로 나누어 관리합니다.

PLAINTEXT
PoolSubpage (하나의 8KB 페이지를 분할)

요청 크기: 512바이트
→ 8KB / 512B = 16개의 슬롯

+------+------+------+------+------+------+--- ... ---+------+
| 512B | 512B | 512B | 512B | 512B | 512B |           | 512B |
|  #0  |  #1  |  #2  |  #3  |  #4  |  #5  |           | #15  |
+------+------+------+------+------+------+--- ... ---+------+

비트맵으로 사용 중/빈 슬롯을 추적
bitmap: [1, 1, 0, 0, 1, 0, ..., 0]  (1=사용 중, 0=비어 있음)

크기별 할당 경로 정리

요청 크기할당 단위관리 방식
16B ~ 496B (Tiny)Subpage페이지를 동일 크기 슬롯으로 분할
512B ~ 4KB (Small)Subpage페이지를 동일 크기 슬롯으로 분할
8KB ~ 16MB (Normal)Page(들)Chunk 이진 트리에서 노드 할당
16MB 초과 (Huge)별도 할당풀을 거치지 않고 직접 할당

Tiny와 Small의 차이는 주로 정규화(normalization) 방식에 있습니다. Tiny는 16B 단위로, Small은 2의 거듭제곱 단위로 크기를 올림합니다. 예를 들어 100B 요청은 112B로, 600B 요청은 1024B로 정규화됩니다.


PooledByteBufAllocator vs UnpooledByteBufAllocator

두 allocator의 차이를 코드로 확인해 보겠습니다.

기본 사용법

JAVA
// Pooled — 기본 allocator (네티 4.x 이후)
ByteBufAllocator pooled = PooledByteBufAllocator.DEFAULT;
ByteBuf buf1 = pooled.buffer(256);     // 풀에서 빌려옴
buf1.release();                         // 풀로 반환 (재사용 가능)

// Unpooled — 매번 새로 할당
ByteBufAllocator unpooled = UnpooledByteBufAllocator.DEFAULT;
ByteBuf buf2 = unpooled.buffer(256);   // 새로 할당
buf2.release();                         // 메모리 해제 (재사용 안 됨)

성능 차이

풀링의 효과는 반복적인 할당/해제 시나리오에서 극적으로 나타납니다.

JAVA
// 성능 비교 (개념적 벤치마크)
ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;

for (int i = 0; i < 1_000_000; i++) {
    ByteBuf buf = allocator.buffer(256);
    // 데이터 처리...
    buf.release(); // 풀로 반환 → 다음 루프에서 재사용
}
// Pooled: 대부분 스레드 캐시에서 즉시 반환 → 락 없음, GC 없음
// Unpooled: 매번 malloc + free (또는 new byte[] + GC)
항목PooledByteBufAllocatorUnpooledByteBufAllocator
할당 속도빠름 (캐시/풀 재사용)느림 (매번 시스템 콜 또는 힙 할당)
GC 부담적음 (객체 재사용)큼 (매번 새 객체 생성)
메모리 단편화적음 (Chunk 단위 관리)심할 수 있음
메모리 사용량풀이 미리 확보하므로 초기에 높을 수 있음필요한 만큼만 사용
복잡도내부 구조 복잡 (디버깅 어려움)단순

언제 Unpooled를 쓰는가

풀링이 항상 좋은 것은 아닙니다. 다음 경우에는 UnpooledByteBufAllocator가 더 적합합니다.

  • ** 테스트 코드 **: 풀 관리 오버헤드가 불필요하고, 메모리 누수 추적이 더 간단해짐
  • ** 수명이 긴 버퍼 **: 한 번 할당하고 오랫동안 유지하는 버퍼는 풀에서 빌려 봐야 반환 시점이 없으므로 풀의 이점이 없음
  • ** 할당 빈도가 매우 낮은 경우 **: 풀 자체의 메모리 오버헤드(Arena, Chunk 사전 할당)가 오히려 낭비
JAVA
// Bootstrap에서 allocator 변경
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.ALLOCATOR, UnpooledByteBufAllocator.DEFAULT);

튜닝 파라미터

PooledByteBufAllocator의 동작은 시스템 프로퍼티나 생성자 파라미터로 조정할 수 있습니다.

주요 파라미터

파라미터기본값설명
nHeapArenaCPU 코어 × 2Heap용 Arena 개수
nDirectArenaCPU 코어 × 2Direct용 Arena 개수
pageSize8KB한 페이지의 크기
maxOrder11Chunk 크기 결정 (pageSize × 2^maxOrder)
tinyCacheSize512스레드 캐시의 Tiny 버퍼 캐시 크기
smallCacheSize256스레드 캐시의 Small 버퍼 캐시 크기
normalCacheSize64스레드 캐시의 Normal 버퍼 캐시 크기

시스템 프로퍼티로 설정

BASH
# Arena 수 조정
-Dio.netty.allocator.numHeapArenas=8
-Dio.netty.allocator.numDirectArenas=8

# 페이지 크기 변경 (2의 거듭제곱이어야 함)
-Dio.netty.allocator.pageSize=16384

# maxOrder 변경 (Chunk 크기에 영향)
-Dio.netty.allocator.maxOrder=9
# → Chunk 크기 = 16384 × 2^9 = 8MB

생성자로 직접 설정

JAVA
// 커스텀 PooledByteBufAllocator 생성
PooledByteBufAllocator customAllocator = new PooledByteBufAllocator(
    true,   // preferDirect — Direct 버퍼 우선 사용
    8,      // nHeapArena
    8,      // nDirectArena
    8192,   // pageSize (8KB)
    11      // maxOrder (Chunk = 8KB × 2^11 = 16MB)
);

// Bootstrap에 적용
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.ALLOCATOR, customAllocator);

튜닝 가이드라인

  • **Arena 수가 너무 적으면 **: 여러 스레드가 같은 Arena를 공유해서 락 경합 증가
  • **Arena 수가 너무 많으면 **: 각 Arena가 Chunk를 미리 확보하므로 메모리 낭비
  • **pageSize를 키우면 **: 작은 할당에 낭비가 생기지만, 큰 할당의 트리 탐색이 줄어듦
  • **maxOrder를 줄이면 **: Chunk 크기가 작아져 메모리를 덜 선점하지만, 대용량 할당 시 Chunk를 더 자주 생성

대부분의 경우 기본값으로 충분합니다. 튜닝은 ** 모니터링으로 병목을 확인한 후 **에 하는 것이 바람직합니다. 무작정 Arena를 늘리면 오히려 메모리만 낭비될 수 있습니다.


메모리 사용량 모니터링

운영 환경에서 메모리 풀의 상태를 모니터링하는 것은 매우 중요합니다. 네티는 PooledByteBufAllocatorMetric을 통해 상세한 메트릭을 제공합니다.

기본 메트릭 조회

JAVA
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
PooledByteBufAllocatorMetric metric = allocator.metric();

// 현재 사용 중인 Direct 메모리 (바이트)
long usedDirect = metric.usedDirectMemory();

// 현재 사용 중인 Heap 메모리 (바이트)
long usedHeap = metric.usedHeapMemory();

// Arena별 상세 메트릭
List<PoolArenaMetric> arenaMetrics = metric.directArenas();
for (PoolArenaMetric arena : arenaMetrics) {
    // 이 Arena의 활성 할당 수
    long activeAllocations = arena.numActiveAllocations();
    // 이 Arena가 관리하는 Chunk 수
    int numChunkLists = arena.chunkLists().size();

    System.out.printf("활성 할당: %d, ChunkList 수: %d%n",
        activeAllocations, numChunkLists);
}

주기적 모니터링 예시

JAVA
// 스케줄러를 이용한 주기적 메트릭 수집
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
    PooledByteBufAllocatorMetric metric =
        PooledByteBufAllocator.DEFAULT.metric();

    long directMB = metric.usedDirectMemory() / 1024 / 1024;
    long heapMB = metric.usedHeapMemory() / 1024 / 1024;

    log.info("메모리 풀 상태 — Direct: {}MB, Heap: {}MB", directMB, heapMB);

    // 임계치 초과 시 경고
    if (directMB > 512) {
        log.warn("Direct 메모리 사용량이 512MB를 초과했습니다. 누수를 점검하세요.");
    }
}, 0, 30, TimeUnit.SECONDS);

메모리 누수 감지

usedDirectMemory()가 ** 계속 증가만 하고 줄어들지 않는다면 **, ByteBufrelease() 누락으로 인한 메모리 누수를 의심해야 합니다. 네티는 이를 위해 리소스 누수 감지 기능도 제공합니다.

BASH
# 리소스 누수 감지 레벨 설정
-Dio.netty.leakDetection.level=PARANOID
레벨설명
DISABLED감지 안 함
SIMPLE샘플링으로 감지 (기본값, 약 1% 샘플)
ADVANCED샘플링 + 접근 기록 (어디서 누수가 났는지 추적)
PARANOID모든 버퍼를 추적 (성능 저하 있음, 개발/테스트용)

개발 환경에서는 PARANOID로 놓고 누수를 잡고, 운영 환경에서는 SIMPLE이나 ADVANCED를 사용하는 것이 일반적입니다.


전체 구조 요약

마지막으로, PooledByteBufAllocator의 전체 메모리 계층을 한 그림으로 정리해 보겠습니다.

PLAINTEXT
PooledByteBufAllocator
├── PoolArena[0] (Direct)
│   ├── PoolChunk (16MB)
│   │   ├── Page 0 (8KB) → PoolSubpage (작은 크기 분할)
│   │   ├── Page 1 (8KB)
│   │   ├── ...
│   │   └── Page 2047 (8KB)
│   ├── PoolChunk (16MB)
│   └── ...
├── PoolArena[1] (Direct)
│   └── ...
├── ...
└── PoolArena[N-1] (Direct)

스레드 바인딩:
Thread-1 → PoolThreadCache → PoolArena[0]
Thread-2 → PoolThreadCache → PoolArena[1]
Thread-3 → PoolThreadCache → PoolArena[0]  (Arena 수 < 스레드 수일 때 공유)

핵심 포인트를 정리하면 이렇습니다.

  • ** 메모리 풀이 필요한 이유 **: malloc/free 비용, GC 부담, Direct Buffer 할당 비용을 줄이기 위해
  • **jemalloc 차용 **: 스레드별 Arena 분리로 락 경합 최소화
  • **3단계 계층 **: Arena → Chunk(16MB) → Page(8KB) → Subpage
  • ** 스레드 로컬 캐시 **: 락 없이 즉시 재사용하는 최고 속도 경로
  • ** 기본값이 대부분 적절 **: 튜닝은 모니터링 후에, 무분별한 변경은 오히려 역효과
  • ** 모니터링 필수 **: usedDirectMemory()로 누수를 조기 감지

다음 글에서는 ByteBuf의 레퍼런스 카운팅과 release() 패턴을 다루면서, 메모리 풀이 제대로 동작하려면 왜 레퍼런스 카운팅이 필수인지 살펴보겠습니다.

댓글 로딩 중...