코드를 최적화하고, 핸들러를 잘 구성했는데 — 실제로 부하를 걸어보니 기대만큼 성능이 안 나온다면, 문제는 어디에 있을까? 애플리케이션 코드 바깥에 있는 OS 설정, 소켓 옵션, 스레드 수 같은 "인프라 레벨 튜닝"을 놓치고 있었을 가능성이 높다.

Netty 서버의 성능은 코드 품질만으로 결정되지 않습니다. OS 파라미터, 소켓 옵션, 스레드 수, 메모리 할당 전략까지 총체적으로 맞춰야 합니다. 이번 글에서는 부하 테스트 방법 부터 튜닝 체크리스트 까지 한 번에 정리합니다.


벤치마크 방법 — 무엇으로 측정하는가

성능을 개선하려면 먼저 현재 상태를 정확히 측정 해야 합니다. 감으로 튜닝하면 오히려 성능이 떨어질 수 있습니다.

핵심 측정 지표

성능을 평가할 때 봐야 할 세 가지 축입니다.

  • TPS (Transactions Per Second): 초당 처리할 수 있는 요청 수. 처리량의 기본 지표
  • ** 레이턴시 (Latency)**: 요청~응답까지의 지연 시간. 평균보다 p99(99번째 퍼센타일) 가 더 중요하다
  • **동시 커넥션 수 **: 몇 개의 연결을 동시에 유지하면서 위 두 지표를 유지할 수 있는가

평균 레이턴시가 좋아도 p99가 나쁘면 100명 중 1명은 느린 경험을 한다. 프로덕션에서는 꼬리 지연(tail latency)이 더 중요하다.

wrk — HTTP 서버 벤치마크

wrk 는 가벼우면서도 강력한 HTTP 벤치마크 도구입니다. Netty 위에 HTTP 서버를 올렸다면 가장 먼저 시도할 도구입니다.

BASH
# 12개 스레드, 400개 커넥션으로 30초간 부하
wrk -t12 -c400 -d30s http://localhost:8080/api/test

# 결과 예시
# Requests/sec:  125,342.17     ← TPS
# Latency
#   Avg:    3.14ms              ← 평균 레이턴시
#   99%:   12.78ms              ← p99 레이턴시
# Transfer/sec:    14.82MB      ← 처리 대역폭

wrk의 장점은 Lua 스크립트 로 요청을 커스터마이즈할 수 있다는 것입니다.

LUA
-- wrk 스크립트 예시: POST 요청 + JSON 바디
wrk.method = "POST"
wrk.body   = '{"userId": 1, "action": "test"}'
wrk.headers["Content-Type"] = "application/json"

JMH — 마이크로벤치마크

wrk가 서버 전체의 성능을 측정한다면, JMH(Java Microbenchmark Harness) 는 특정 코드 조각의 성능을 정밀하게 측정합니다.

JAVA
// JMH로 ByteBuf 할당 성능 비교
@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public ByteBuf pooledDirect(AllocatorState state) {
    ByteBuf buf = state.pooledAllocator.directBuffer(256);
    buf.release();
    return buf;
}

@Benchmark
public ByteBuf unpooledDirect(AllocatorState state) {
    ByteBuf buf = state.unpooledAllocator.directBuffer(256);
    buf.release();
    return buf;
}

자체 Netty 클라이언트

HTTP가 아닌 커스텀 프로토콜 을 사용한다면, Netty로 부하 클라이언트를 직접 작성해야 합니다.

JAVA
// 부하 테스트 클라이언트 골격
public class LoadTestClient {

    private final EventLoopGroup group = new NioEventLoopGroup();
    private final List<Channel> channels = new ArrayList<>();

    // 동시 커넥션 N개를 열고 지정된 메시지를 반복 전송
    public void start(String host, int port, int connections, int messageCount) throws Exception {
        Bootstrap b = new Bootstrap();
        b.group(group)
         .channel(NioSocketChannel.class)
         .handler(new LoadTestInitializer());

        // 커넥션 동시 생성
        for (int i = 0; i < connections; i++) {
            Channel ch = b.connect(host, port).sync().channel();
            channels.add(ch);
        }

        // 각 커넥션에서 메시지 전송
        long startTime = System.nanoTime();
        for (Channel ch : channels) {
            for (int i = 0; i < messageCount; i++) {
                ch.writeAndFlush(createTestMessage());
            }
        }

        long elapsed = System.nanoTime() - startTime;
        double tps = (double)(connections * messageCount) / (elapsed / 1_000_000_000.0);
        System.out.printf("TPS: %.2f%n", tps);
    }
}

어떤 도구를 쓰든, 측정 전에 워밍업(JIT 컴파일 안정화) 시간을 충분히 주는 것이 중요하다. JVM이 안정화되기 전의 수치는 실제 성능과 다르다.


OS 레벨 튜닝

Netty가 아무리 잘 만들어져 있어도, OS가 병목이면 소용없습니다. 리눅스 기준으로 꼭 확인해야 할 설정들입니다.

파일 디스크립터 — ulimit

리눅스에서 소켓 하나는 파일 디스크립터(fd) 하나를 소비합니다. 기본 제한이 1024인 경우가 많아서, 동시 연결이 많으면 Too many open files 에러가 발생합니다.

BASH
# 현재 제한 확인
ulimit -n
# 1024  ← 기본값 (부족!)

# 세션 단위 변경
ulimit -n 65535

# 영구 설정 — /etc/security/limits.conf
# *    soft    nofile    65535
# *    hard    nofile    65535

# 시스템 전체 최대값 — /etc/sysctl.conf
# fs.file-max = 2097152

TCP 파라미터

BASH
# /etc/sysctl.conf에 추가하고 sysctl -p로 적용

# accept 큐 크기 — SO_BACKLOG와 짝을 이룸
net.core.somaxconn = 65535

# TIME_WAIT 소켓 재사용 — 짧은 연결이 많을 때 유용
net.ipv4.tcp_tw_reuse = 1

# 포트 범위 확대 — 클라이언트 측에서 아웃바운드 연결이 많을 때
net.ipv4.ip_local_port_range = 1024 65535

# TCP 메모리 버퍼 크기 (최소/기본/최대)
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# SYN 백로그 — SYN 패킷 대기열 크기
net.ipv4.tcp_max_syn_backlog = 65535

somaxconn은 커널이 허용하는 accept 큐의 최대 크기다. Netty의 SO_BACKLOG 값이 아무리 커도 somaxconn보다 작은 값으로 잘린다. 양쪽 다 맞춰줘야 한다.


Netty 소켓 옵션

ServerBootstrapBootstrap에서 설정하는 소켓 옵션은 성능에 직접적인 영향을 줍니다.

SO_BACKLOG

TCP accept 큐의 크기 입니다. 클라이언트가 3-way handshake를 완료했지만 서버가 아직 accept()하지 않은 연결이 대기하는 큐의 크기를 결정합니다.

JAVA
// 서버 측 설정 — .option()은 ServerSocketChannel에 적용
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.SO_BACKLOG, 1024);  // 기본값은 OS에 따라 다름 (리눅스: 128)

SO_REUSEADDR

서버 재시작 시 Address already in use 에러를 방지합니다. TIME_WAIT 상태의 포트를 즉시 재사용할 수 있게 합니다.

JAVA
b.option(ChannelOption.SO_REUSEADDR, true);

TCP_NODELAY

Nagle 알고리즘 을 비활성화합니다. Nagle은 작은 패킷을 모아서 보내 대역폭 효율을 높이지만, 지연 시간이 증가합니다. 실시간 통신에서는 끄는 것이 일반적입니다.

JAVA
// 자식 채널(클라이언트 연결)에 적용 — .childOption() 사용
b.childOption(ChannelOption.TCP_NODELAY, true);

SO_KEEPALIVE

유휴 연결에 대해 OS 레벨에서 keepalive 프로브를 보냅니다. 죽은 연결을 감지하여 리소스를 회수하는 데 유용합니다.

JAVA
b.childOption(ChannelOption.SO_KEEPALIVE, true);

전체 설정 예시

JAVA
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 // 서버 소켓 옵션
 .option(ChannelOption.SO_BACKLOG, 1024)
 .option(ChannelOption.SO_REUSEADDR, true)
 // 클라이언트 연결(자식 채널) 옵션
 .childOption(ChannelOption.TCP_NODELAY, true)
 .childOption(ChannelOption.SO_KEEPALIVE, true)
 .childOption(ChannelOption.SO_RCVBUF, 65536)
 .childOption(ChannelOption.SO_SNDBUF, 65536)
 // 할당자 설정
 .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
 .childHandler(new MyChannelInitializer());

.option()은 서버 소켓(Boss)에, .childOption()은 수락된 클라이언트 소켓(Worker)에 적용된다. 이 구분을 혼동하면 설정이 아예 안 먹는다.


EventLoopGroup 스레드 수

Boss vs Worker

  • Boss Group: 클라이언트 연결을 accept()하는 역할. 하나의 포트를 리스닝한다면 1개 면 충분하다
  • Worker Group: 실제 I/O 읽기/쓰기와 핸들러 실행을 담당. CPU 코어 수 × 2 가 기본값
JAVA
// 일반적인 설정
EventLoopGroup bossGroup   = new NioEventLoopGroup(1);       // accept 전담
EventLoopGroup workerGroup = new NioEventLoopGroup();         // 기본값: 코어 × 2

// Worker 스레드 수를 직접 지정
EventLoopGroup workerGroup = new NioEventLoopGroup(16);       // 코어 수 기반으로 직접 결정

스레드 수 결정 가이드

워크로드 유형권장 스레드 수이유
I/O 집약적 (순수 프록시)코어 × 2 (기본값)I/O 대기 시간에 다른 채널 처리
CPU 집약적 (암호화, 압축)코어 × 1CPU 경합 최소화
혼합형코어 × 2 + 블로킹 작업은 별도 ExecutorGroupEventLoop 블로킹 방지
JAVA
// 블로킹 작업이 있는 경우 — 별도 스레드 풀로 분리
DefaultEventExecutorGroup blockingGroup = new DefaultEventExecutorGroup(16);

pipeline.addLast("decoder", new MyDecoder());
pipeline.addLast(blockingGroup, "dbHandler", new DatabaseHandler());  // 블로킹 핸들러만 분리
pipeline.addLast("encoder", new MyEncoder());

Boss 스레드를 1개 이상으로 늘려도 포트가 하나라면 효과가 없다. 단, SO_REUSEPORT를 사용하면 여러 Boss 스레드가 동시에 accept 할 수 있어서 의미가 있다.


ByteBuf 할당 전략

PooledByteBufAllocator — 기본이자 최선

Netty 4.1부터 PooledByteBufAllocator기본 할당자 입니다. 버퍼를 풀링하여 GC 압력과 할당 비용을 줄입니다.

JAVA
// 기본 설정 확인 — 별도로 설정하지 않아도 Pooled가 사용된다
ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;

// 명시적으로 지정할 때
b.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

Direct vs Heap

구분Direct BufferHeap Buffer
메모리 위치OS 네이티브 메모리JVM 힙
I/O 성능빠름 (커널 버퍼 복사 없음)추가 복사 발생
할당 속도느림 (풀링으로 보완)빠름
GC 영향없음GC 대상
적합한 곳네트워크 I/O (기본 권장)비즈니스 로직 내 임시 버퍼
JAVA
// Direct 버퍼 할당 (기본 — I/O에 최적)
ByteBuf directBuf = alloc.directBuffer(1024);

// Heap 버퍼 할당 (빈번한 배열 접근이 필요할 때)
ByteBuf heapBuf = alloc.heapBuffer(1024);

Netty는 기본적으로 Direct Buffer를 우선 사용한다. 네트워크 I/O에서는 커널 버퍼로의 추가 복사가 없기 때문이다. 특별한 이유 없이 Heap으로 바꾸면 오히려 성능이 떨어질 수 있다.


Native Transport 활용

이전 글에서 다뤘던 Native Transport는 성능 튜닝 관점에서 ** 가장 쉬우면서도 효과적인 최적화 **입니다.

epoll/kqueue 전환

JAVA
// 리눅스 — epoll
EventLoopGroup bossGroup   = new EpollEventLoopGroup(1);
EventLoopGroup workerGroup = new EpollEventLoopGroup();

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(EpollServerSocketChannel.class);

SO_REUSEPORT — 멀티 accept

Native Transport에서만 사용 가능한 SO_REUSEPORT는 accept 병목을 해소합니다.

JAVA
// SO_REUSEPORT 활성화 — Boss 스레드가 여러 개일 때 효과적
ServerBootstrap b = new ServerBootstrap();
b.group(new EpollEventLoopGroup(4), new EpollEventLoopGroup())  // Boss 4개
 .channel(EpollServerSocketChannel.class)
 .option(EpollChannelOption.SO_REUSEPORT, true);                 // 커널 수준 연결 분산

커널이 들어오는 연결을 4개의 Boss 스레드에 직접 분산시키므로, 단일 accept 스레드가 병목이 되는 상황을 해소합니다.

Native Transport 전환만으로 20~30% 처리량 향상을 기대할 수 있다. 전환 비용(클래스 두 개 교체)에 비해 효과가 크니, 성능 튜닝의 첫 번째 체크 항목으로 삼을 만하다.


병목 찾기

튜닝 전에 ** 어디가 병목인지** 파악하는 것이 우선입니다. 감으로 튜닝하면 시간만 낭비됩니다.

EventLoop 태스크 큐 모니터링

EventLoop의 pendingTasks()를 통해 대기 중인 태스크 수를 확인할 수 있습니다. 이 값이 계속 증가하면 EventLoop가 처리 속도를 따라가지 못하는 것입니다.

JAVA
// EventLoop 상태 모니터링
ScheduledFuture<?> monitor = eventLoop.scheduleAtFixedRate(() -> {
    for (EventExecutor executor : workerGroup) {
        if (executor instanceof SingleThreadEventLoop) {
            SingleThreadEventLoop loop = (SingleThreadEventLoop) executor;
            long pending = loop.pendingTasks();
            if (pending > 1000) {
                // 경고: 태스크 큐가 쌓이고 있음
                logger.warn("EventLoop {} 태스크 큐 적체: {} 개", loop, pending);
            }
        }
    }
}, 0, 5, TimeUnit.SECONDS);

ioRatio 조정

EventLoop는 I/O 이벤트 처리와 태스크 큐 처리 사이에서 시간을 분배합니다. ioRatio 가 이 비율을 결정합니다.

JAVA
// ioRatio 조정 — 기본값 50
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
workerGroup.forEach(eventExecutor -> {
    if (eventExecutor instanceof NioEventLoop) {
        ((NioEventLoop) eventExecutor).setIoRatio(70);  // I/O에 70% 할당
    }
});
ioRatioI/O 시간태스크 시간적합한 상황
50 (기본)50%50%범용
70~8070~80%20~30%I/O 집약적, 태스크가 적을 때
30~4030~40%60~70%예약 태스크가 많을 때
100100% (I/O 후 모든 태스크)나머지 전부특수한 경우

어디가 느린지 빠르게 판단하기

PLAINTEXT
성능이 기대보다 낮다
├── CPU 사용률 높음 → 핸들러 로직 또는 코덱이 무겁다 → 프로파일링
├── CPU 사용률 낮음 + 처리량 낮음 → EventLoop가 블로킹되고 있다
│   ├── pendingTasks 증가 → 블로킹 작업을 별도 스레드 풀로 분리
│   └── pendingTasks 정상 → 네트워크 I/O 자체가 병목 (대역폭, 레이턴시)
└── GC 빈도 높음 → ByteBuf 누수 또는 Unpooled 사용 점검

체크리스트 — 한눈에 보는 튜닝 항목

카테고리항목기본값권장값설정 방법
OSulimit (nofile)102465535+ulimit -n 또는 limits.conf
OSsomaxconn12865535sysctl net.core.somaxconn
OStcp_tw_reuse01sysctl net.ipv4.tcp_tw_reuse
OStcp_max_syn_backlog12865535sysctl net.ipv4.tcp_max_syn_backlog
** 소켓**SO_BACKLOGOS 의존1024~65535.option(ChannelOption.SO_BACKLOG, N)
** 소켓**SO_REUSEADDRfalsetrue.option(ChannelOption.SO_REUSEADDR, true)
** 소켓**TCP_NODELAYfalsetrue.childOption(ChannelOption.TCP_NODELAY, true)
** 소켓**SO_KEEPALIVEfalsetrue.childOption(ChannelOption.SO_KEEPALIVE, true)
** 스레드**Boss 스레드코어×21 (포트 1개 기준)new NioEventLoopGroup(1)
** 스레드**Worker 스레드코어×2코어×2 (I/O 집약)new NioEventLoopGroup(N)
** 메모리**AllocatorPooledDirectPooledDirect (유지)ChannelOption.ALLOCATOR
** 메모리**버퍼 유형DirectDirect (유지)기본 유지
Transport종류NIONative (epoll/kqueue)클래스 교체
TransportSO_REUSEPORT미사용사용 (Native)EpollChannelOption.SO_REUSEPORT
EventLoopioRatio5050~80 (워크로드 따라)setIoRatio(N)
** 모니터링**pendingTasks미모니터링주기적 확인pendingTasks() 호출

정리

성능 튜닝은 한 곳만 건드린다고 되는 게 아니라, ** 측정 → 병목 식별 → 해당 레이어 튜닝 → 재측정** 사이클을 반복해야 합니다. 기억할 포인트를 요약하면 이렇습니다.

  • ** 측정이 먼저다** — wrk, JMH, 자체 클라이언트로 TPS/레이턴시/커넥션 수를 정량적으로 파악한다
  • OS 설정을 확인한다 — ulimit, somaxconn은 Netty 코드와 무관하게 성능 천장을 결정한다
  • ** 소켓 옵션을 맞춘다** — SO_BACKLOG, TCP_NODELAY, SO_KEEPALIVE는 거의 모든 서버에서 설정해야 한다
  • ** 스레드 수는 워크로드에 맞게** — Boss 1개, Worker는 코어×2가 출발점. 블로킹 작업은 반드시 별도 풀로 분리
  • PooledByteBufAllocator + Direct Buffer — 기본 설정이 이미 최적에 가깝다. 건드리지 않는 것이 최선
  • Native Transport는 가성비 최고 — 클래스 두 개 교체만으로 20~30% 향상
  • pendingTasks()와 ioRatio로 EventLoop 건강 상태를 파악한다 — 감이 아니라 숫자로 판단
댓글 로딩 중...