소켓 옵션 튜닝 — TCP_NODELAY, SO_KEEPALIVE, 버퍼 크기 설정
Netty로 서버를 만들었는데, 소규모 메시지의 응답 지연이 비정상적으로 길다면 — 혹시 Nagle 알고리즘 때문은 아닐까요?
네트워크 프로그래밍에서 성능 문제가 발생하면 코드 로직을 먼저 의심하게 됩니다. 하지만 의외로 소켓 옵션 하나 바꿔주는 것만으로 지연 시간이 극적으로 줄어드는 경우가 있습니다. 이번 글에서는 Netty에서 자주 다루게 되는 소켓 옵션들의 역할과 튜닝 포인트를 정리합니다.
소켓 옵션이란
** 소켓 옵션은 OS 커널의 TCP/IP 스택 동작 방식을 제어하는 설정값 **입니다.
애플리케이션 코드가 아니라 그 아래 계층인 커널에서 동작하는 설정이라, 코드를 아무리 최적화해도 소켓 옵션이 잘못되어 있으면 성능 병목이 생길 수 있습니다. 예를 들어:
- 작은 메시지를 보냈는데 200ms씩 지연된다 → TCP_NODELAY 미설정
- 서버 재시작하면 "Address already in use" 에러가 뜬다 → SO_REUSEADDR 미설정
- 대량 연결이 몰리면 연결 요청이 거부된다 → SO_BACKLOG 값이 너무 작음
Java에서는 java.net.StandardSocketOptions로 정의되어 있고, Netty는 이를 ChannelOption 클래스로 래핑하여 더 편리하게 사용할 수 있게 해줍니다.
Netty에서 소켓 옵션 설정하기
ServerBootstrap: option() vs childOption()
Bootstrap & ServerBootstrap 글에서 다뤘듯이, 서버에는 두 종류의 채널이 있습니다.
- ** 서버 소켓 채널 (Boss)**: 연결 수락을 담당
- ** 자식 채널 (Worker)**: 수락된 개별 연결의 I/O 처리를 담당
소켓 옵션도 이 구분에 맞춰 ** 어디에 적용할지** 나눠서 설정합니다.
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
// 서버 소켓 채널(Boss)에 적용되는 옵션
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_REUSEADDR, true)
// 수락된 자식 채널(Worker)에 적용되는 옵션
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.SO_RCVBUF, 65536)
.childOption(ChannelOption.SO_SNDBUF, 65536);
SO_BACKLOG는 서버 소켓의 연결 대기 큐이므로.option()에,TCP_NODELAY는 개별 연결에 적용되는 옵션이므로.childOption()에 설정합니다. 이걸 반대로 넣으면 아무 효과가 없는데, 에러도 안 나서 찾기 어렵습니다.
Bootstrap (클라이언트)
클라이언트는 서버 소켓이 없으니 .option()만 사용합니다.
Bootstrap b = new Bootstrap();
b.group(workerGroup)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.SO_KEEPALIVE, true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000); // 연결 타임아웃
ChannelOption 클래스
ChannelOption은 타입 안전한 상수를 제공합니다. 주요 상수들을 정리하면:
| 상수 | 적용 대상 | 설명 |
|---|---|---|
TCP_NODELAY | 자식 채널 | Nagle 알고리즘 비활성화 |
SO_KEEPALIVE | 자식 채널 | TCP keepalive 활성화 |
SO_BACKLOG | 서버 소켓 | 연결 대기 큐 크기 |
SO_RCVBUF | 자식 채널 | 수신 버퍼 크기 |
SO_SNDBUF | 자식 채널 | 송신 버퍼 크기 |
SO_REUSEADDR | 서버 소켓 | 포트 재사용 허용 |
SO_LINGER | 자식 채널 | close() 시 미전송 데이터 처리 |
CONNECT_TIMEOUT_MILLIS | 클라이언트 | 연결 타임아웃 (Netty 자체 옵션) |
핵심 소켓 옵션
TCP_NODELAY — Nagle 알고리즘 제어
TCP_NODELAY는 Nagle 알고리즘의 on/off 스위치입니다.
Nagle 알고리즘은 1984년에 만들어진 최적화 기법으로, 작은 패킷을 모아서 한번에 보내 네트워크 대역폭을 절약합니다. 1바이트 데이터를 보내도 TCP 헤더(20바이트) + IP 헤더(20바이트)가 붙으니, 작은 패킷이 많으면 오버헤드가 어마어마하죠.
Nagle 알고리즘 동작 방식:
1. 첫 번째 데이터는 즉시 전송
2. 이후 데이터는 버퍼에 쌓음
3. 이전 전송에 대한 ACK를 받으면 → 쌓인 데이터를 한꺼번에 전송
4. 또는 버퍼가 MSS(최대 세그먼트 크기)에 도달하면 → 전송
문제는 Nagle + Delayed ACK 조합 입니다. 수신 측의 Delayed ACK는 ACK를 최대 200ms까지 지연시킬 수 있는데, 이 시간 동안 Nagle 알고리즘은 다음 데이터를 보내지 않고 기다립니다.
[송신] [수신]
|--- 첫 번째 작은 패킷 ----------->|
| (두 번째 패킷은 ACK 대기) | (Delayed ACK: 200ms 대기)
| ...200ms 지연... |
|<------------ ACK ---------------|
|--- 두 번째 패킷 ---------------->|
이 200ms 지연이 요청마다 쌓이면 체감 성능이 크게 떨어집니다. 그래서 대부분의 서버 애플리케이션에서는 TCP_NODELAY = true를 기본으로 설정합니다.
// Nagle 비활성화 — 데이터를 즉시 전송
.childOption(ChannelOption.TCP_NODELAY, true)
Nagle을 비활성화하면 작은 패킷이 많아져 대역폭 효율은 떨어지지만, 요즘 네트워크 환경에서는 대역폭보다 지연 시간이 훨씬 중요한 경우가 많습니다. 특히 실시간 통신, 게임 서버, API 서버라면 거의 무조건 true로 설정합니다.
SO_KEEPALIVE — TCP 연결 상태 확인
SO_KEEPALIVE는 OS 수준에서 유휴 연결에 keepalive 프로브를 보내 상대방이 살아있는지 확인하는 기능입니다.
.childOption(ChannelOption.SO_KEEPALIVE, true)
활성화하면 일정 시간 데이터 교환이 없는 연결에 대해 OS가 자동으로 프로브 패킷을 보냅니다.
[서버] [클라이언트]
| (유휴 시간 경과) |
|--- keepalive 프로브 ----------------->|
|<------------ ACK --------------------| ← 살아있음
| |
| (유휴 시간 다시 경과) |
|--- keepalive 프로브 -----X | ← 응답 없음 (프로세스 종료됨)
|--- 재시도 프로브 ---------X |
|--- 재시도 프로브 ---------X |
| → 연결이 끊어진 것으로 판단, 정리 |
그런데 기본 설정값이 좀 문제입니다.
| 파라미터 | Linux 기본값 | 의미 |
|---|---|---|
tcp_keepalive_time | 7200초 (2시간) | 유휴 상태 후 첫 프로브까지 대기 시간 |
tcp_keepalive_intvl | 75초 | 프로브 재전송 간격 |
tcp_keepalive_probes | 9 | 최대 재시도 횟수 |
2시간 동안 아무것도 안 하다가 프로브를 보내기 시작하고, 그마저도 9번 재시도하면 추가로 11분이 걸립니다. 상대방이 죽은 걸 감지하는 데 ** 최대 2시간 11분 **이 걸리는 셈입니다.
IdleStateHandler와의 비교
타임아웃 & 유휴 감지 글에서 다룬 IdleStateHandler는 ** 애플리케이션 레벨 **에서 유휴 상태를 감지합니다.
| 비교 항목 | SO_KEEPALIVE | IdleStateHandler |
|---|---|---|
| 동작 레벨 | OS 커널 | Netty 애플리케이션 |
| 감지 시간 | 기본 2시간 (OS 설정 변경 필요) | 자유롭게 설정 가능 (초 단위) |
| 커스터마이징 | OS 전역 설정 또는 소켓별 설정 | 채널별 자유로운 로직 구현 |
| 프로토콜 | TCP 프로브 | 애플리케이션 레벨 ping/pong |
실무에서는 ** 둘 다 같이 사용하는 것이 일반적 **입니다. IdleStateHandler로 빠른 감지(30~90초), SO_KEEPALIVE로 최종 안전망 역할을 맡기는 구조입니다.
SO_BACKLOG — 연결 대기 큐 크기
SO_BACKLOG는 TCP 3-way handshake가 완료된 후 accept()를 기다리는 연결의 최대 수를 설정합니다.
// 서버 소켓에 설정
.option(ChannelOption.SO_BACKLOG, 1024)
TCP 연결 수립 과정에서 두 가지 큐가 관여합니다.
[클라이언트] [서버]
|--- SYN ----------------->| → SYN Queue에 추가
|<-- SYN+ACK --------------|
|--- ACK ----------------->| → Accept Queue로 이동
| | (SO_BACKLOG가 이 큐의 크기)
| | accept()가 호출되면 큐에서 제거
- SYN Queue: handshake 진행 중인 연결 (half-open)
- Accept Queue: handshake 완료, accept 대기 중인 연결 (SO_BACKLOG)
Accept Queue가 가득 차면 새로운 연결 요청이 ** 무시(drop)** 됩니다. 클라이언트 입장에서는 SYN을 보냈는데 응답이 없으니 재전송을 시도하게 되고, 연결 수립 시간이 크게 늘어납니다.
| 상황 | 권장값 |
|---|---|
| 개발/테스트 | 128 (기본값) |
| 일반 서버 | 512~1024 |
| 대량 연결이 몰리는 서버 | 2048~4096 |
참고: Linux에서는
net.core.somaxconn커널 파라미터가 상한선 역할을 합니다. SO_BACKLOG를 아무리 크게 잡아도somaxconn보다 클 수 없으므로, 운영 환경에서는 커널 파라미터도 함께 조정해야 합니다.
커넥션 폭주(Thundering Herd) 상황
서비스 재시작 직후나 이벤트 시작 시점에 수만 개의 연결이 동시에 몰리면, Accept Queue가 순식간에 차버릴 수 있습니다. 이런 경우에는 SO_BACKLOG를 넉넉하게 잡는 것도 중요하지만, 근본적으로는 ** 로드 밸런서의 rate limiting이나 ** 연결 제한(maxConnections) 같은 상위 레벨 대책이 필요합니다.
SO_RCVBUF / SO_SNDBUF — 수신·송신 버퍼 크기
TCP 소켓의 수신 버퍼(SO_RCVBUF)와 송신 버퍼(SO_SNDBUF)는 커널 영역에 할당되는 데이터 임시 저장 공간입니다.
.childOption(ChannelOption.SO_RCVBUF, 65536) // 수신 버퍼 64KB
.childOption(ChannelOption.SO_SNDBUF, 65536) // 송신 버퍼 64KB
이 버퍼 크기는 TCP 윈도우 크기에 직접적인 영향을 줍니다.
TCP 흐름 제어와 버퍼 관계:
[송신 측] [수신 측]
송신 버퍼 수신 버퍼
┌──────────┐ ┌──────────┐
│ 데이터 │ ---- 네트워크 ---> │ 데이터 │
│ 대기 중 │ │ 도착 │
└──────────┘ └──────────┘
↓
애플리케이션이
read()로 꺼내감
수신 버퍼에 빈 공간이 있어야 → TCP 윈도우 광고 → 송신 측이 데이터 전송 가능
수신 버퍼가 가득 차면 → 윈도우 크기 = 0 → 송신 측 전송 중단
BDP(Bandwidth-Delay Product) 기반 산정
이론적으로 최적의 버퍼 크기는 BDP = 대역폭 x RTT(왕복 지연) 으로 계산합니다.
- 대역폭 1Gbps, RTT 10ms인 경우:
1,000,000,000 / 8 * 0.01 = 1,250,000 bytes ≈ 1.2MB - 대역폭 100Mbps, RTT 1ms인 경우:
100,000,000 / 8 * 0.001 = 12,500 bytes ≈ 12KB
같은 데이터센터 내 통신(RTT < 1ms)이면 기본 버퍼로도 충분하지만, 대역폭이 크고 RTT가 긴 환경(WAN, 해외 통신)에서는 버퍼를 키워야 throughput이 나옵니다.
Linux Auto-tuning
다행히 최근 Linux 커널은 TCP 버퍼 자동 조정(auto-tuning)을 지원합니다.
# Linux TCP 버퍼 자동 조정 범위 확인
$ cat /proc/sys/net/ipv4/tcp_rmem
4096 131072 6291456
# 최소 기본 최대
$ cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
명시적으로 SO_RCVBUF/SO_SNDBUF를 설정하면 auto-tuning이 비활성화됩니다. 따라서 특별한 이유가 없다면 OS의 auto-tuning에 맡기고, 필요할 때만 명시적으로 설정하는 것이 좋습니다.
SO_REUSEADDR — 포트 재사용
SO_REUSEADDR은 TIME_WAIT 상태의 소켓이 점유 중인 포트를 재사용할 수 있게 해줍니다.
// 서버 소켓에 설정
.option(ChannelOption.SO_REUSEADDR, true)
TCP 연결이 정상적으로 종료되면, 먼저 FIN을 보낸 쪽(주로 서버)의 소켓은 TIME_WAIT 상태에 들어갑니다. 이 상태는 기본 2분(2 x MSL) 동안 유지됩니다.
서버 재시작 시나리오:
1. 서버 종료 → 기존 연결들이 TIME_WAIT 상태로 전환
2. 서버 재시작 시도 → 같은 포트에 bind()
3. SO_REUSEADDR = false → "Address already in use" 에러!
4. SO_REUSEADDR = true → TIME_WAIT 무시하고 포트 사용 가능
서버 애플리케이션에서는 ** 거의 항상 true로 설정합니다.** 개발 중에 서버를 빈번하게 재시작하는 상황에서도 유용하고, 운영 환경에서 무중단 배포 시에도 필요합니다.
SO_LINGER — close() 시 미전송 데이터 처리
SO_LINGER는 소켓을 닫을 때 아직 송신 버퍼에 남아있는 데이터를 어떻게 처리할지 결정합니다.
세 가지 동작 모드가 있습니다.
1. 기본 (SO_LINGER 미설정):
close() 호출 → 즉시 반환 (비동기)
OS가 백그라운드에서 잔여 데이터 전송 시도
→ 대부분의 경우 이걸로 충분
2. SO_LINGER = 0 (타임아웃 0초):
close() 호출 → 송신 버퍼 즉시 폐기 + RST 패킷 전송
→ 정상적인 4-way handshake 없이 강제 종료
→ FIN 대신 RST을 보내므로 TIME_WAIT 상태도 건너뜀
3. SO_LINGER = N (N초 타임아웃):
close() 호출 → 최대 N초 동안 블로킹하며 잔여 데이터 전송 대기
N초 안에 완료되면 정상 종료, 초과하면 RST
// linger 설정 — 주의해서 사용
.childOption(ChannelOption.SO_LINGER, 0) // 즉시 RST (권장하지 않음)
.childOption(ChannelOption.SO_LINGER, 5) // 5초 대기 후 종료
SO_LINGER = 0은 TIME_WAIT을 피하려고 쓰는 경우가 있는데, 상대방 입장에서는 RST을 받으면 "비정상 종료"로 처리하게 됩니다. 데이터 유실 가능성도 있으므로 정말 필요한 경우가 아니면 기본 동작을 사용하는 것이 안전합니다.
옵션 요약 표
| 옵션 | 기본값 | 권장 설정 | 적용 위치 | 핵심 포인트 |
|---|---|---|---|---|
TCP_NODELAY | false | true | childOption | Nagle off → 지연 감소 |
SO_KEEPALIVE | false | true | childOption | 좀비 연결 감지 (안전망) |
SO_BACKLOG | 128 | 512~4096 | option | 연결 폭주 대비 |
SO_RCVBUF | OS 자동 | 보통 미설정 | childOption | auto-tuning에 맡기기 |
SO_SNDBUF | OS 자동 | 보통 미설정 | childOption | auto-tuning에 맡기기 |
SO_REUSEADDR | false | true | option | 서버 재시작 시 필수 |
SO_LINGER | 미설정 | 보통 미설정 | childOption | 기본 동작이 가장 안전 |
실전 설정 예제
일반적인 서버 설정
대부분의 서버에 적용할 수 있는 범용 설정입니다.
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) // Nagle 비활성화
.childOption(ChannelOption.SO_KEEPALIVE, true) // TCP keepalive 활성화
// 메모리 할당 전략
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
// 애플리케이션 레벨 유휴 감지 (SO_KEEPALIVE보다 빠르게 감지)
p.addLast(new IdleStateHandler(60, 30, 0));
p.addLast(new MyProtocolDecoder());
p.addLast(new MyProtocolEncoder());
p.addLast(new MyBusinessHandler());
}
});
저지연 요구 서버 설정
게임 서버, 실시간 메시징 등 지연 시간이 가장 중요한 경우입니다.
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
// 서버 소켓 — 대량 동시 접속 대비
.option(ChannelOption.SO_BACKLOG, 4096)
.option(ChannelOption.SO_REUSEADDR, true)
// 자식 채널 — 저지연 최적화
.childOption(ChannelOption.TCP_NODELAY, true) // 필수: Nagle 비활성화
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.SO_RCVBUF, 32768) // 32KB — 작은 메시지 위주
.childOption(ChannelOption.SO_SNDBUF, 32768)
// Netty 자체 옵션: 개별 write마다 flush (지연 최소화)
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(8 * 1024, 32 * 1024))
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
// 더 짧은 유휴 감지 주기
p.addLast(new IdleStateHandler(30, 15, 0));
p.addLast(new LengthFieldBasedFrameDecoder(
65536, 0, 4, 0, 4)); // 프레임 디코딩
p.addLast(new LengthFieldPrepender(4));
p.addLast(new GameProtocolDecoder());
p.addLast(new GameProtocolEncoder());
p.addLast(new GameLogicHandler());
}
});
저지연 환경에서는
SO_RCVBUF/SO_SNDBUF를 명시적으로 설정하는 경우가 있습니다. 작은 메시지 위주라면 버퍼를 줄여서 메모리를 절약하고, 대용량 데이터 전송이라면 BDP 계산에 맞춰 키우는 방식입니다. 다만 auto-tuning을 끄는 부작용이 있으니 벤치마크로 확인 후 적용하는 것이 좋습니다.
정리
소켓 옵션은 "한번 설정하고 잊어버리는" 성격의 설정이지만, 잘못 설정하면 원인을 찾기 어려운 성능 문제를 만들 수 있습니다. 핵심만 정리하면:
- TCP_NODELAY = true: 서버라면 거의 무조건. Nagle + Delayed ACK의 200ms 지연을 피합니다.
- SO_REUSEADDR = true: 서버라면 거의 무조건. 재시작 시 포트 충돌을 방지합니다.
- SO_BACKLOG: 기본값(128)은 프로덕션에 너무 작으므로, 예상 동시 연결 수에 맞게 올립니다.
- SO_KEEPALIVE = true + IdleStateHandler: OS 레벨 안전망과 애플리케이션 레벨 빠른 감지를 조합합니다.
- SO_RCVBUF / SO_SNDBUF: 특별한 이유가 없으면 OS auto-tuning에 맡기되, WAN 환경이라면 BDP를 계산해서 조정합니다.
- SO_LINGER: 기본 동작이 가장 안전합니다.
linger = 0(RST)은 데이터 유실 위험이 있으니 신중하게 사용합니다.
그리고 option()과 childOption()을 헷갈리지 않도록 주의합니다. 서버 소켓 옵션(SO_BACKLOG, SO_REUSEADDR)은 option()에, 개별 연결 옵션(TCP_NODELAY, SO_KEEPALIVE 등)은 childOption()에 설정해야 제대로 동작합니다.