타임아웃 & 유휴 감지
TCP 연결은 한번 맺어지면 양쪽 중 하나가 명시적으로 끊기 전까지 살아 있다. 그런데 상대방 프로세스가 갑자기 죽거나 네트워크 케이블이 빠지면? 이쪽에서는 연결이 끊어진 줄 모르고 영원히 기다리게 된다. 이런 "좀비 연결"을 어떻게 감지하고 정리할 수 있을까?
이번 글에서는 Netty의 타임아웃과 유휴 감지 메커니즘을 정리합니다. IdleStateHandler로 유휴 상태를 감지하는 방법, ReadTimeoutHandler와 WriteTimeoutHandler의 자동 예외 처리, 하트비트 ping/pong 패턴으로 살아있는 연결을 확인하는 전략, 그리고 네트워크 환경별 타임아웃 권장 값까지 다룹니다.
왜 타임아웃이 필요한가
타임아웃은 일정 시간 동안 응답이 없는 연결을 감지하고 정리하는 메커니즘 입니다. 타임아웃 없이 서버를 운영하면 다음과 같은 문제가 생깁니다.
좀비 연결이 만들어지는 상황
정상 종료:
[클라이언트] --FIN--> [서버] 양쪽이 FIN을 주고받으며 깔끔하게 종료
비정상 종료:
[클라이언트] --전원 꺼짐--> [서버] FIN 패킷을 보내지 못함
서버는 연결이 살아있다고 착각
→ 좀비 연결
좀비 연결이 위험한 이유를 정리하면 다음과 같습니다.
- 리소스 낭비 — 각 연결은 소켓 파일 디스크립터, 메모리(ByteBuf, ChannelHandlerContext 등)를 점유한다
- fd 고갈 — 좀비 연결이 쌓이면 파일 디스크립터 한도에 도달해 새 연결을 받지 못한다
- ** 상태 불일치** — 서버는 클라이언트가 살아있다고 생각하고 데이터를 보내지만, 실제로는 아무도 받지 못한다
- ** 장애 감지 지연** — 네트워크 분리를 빠르게 감지하지 못하면 장애 대응이 늦어진다
TCP의 keepalive도 있지만, 기본 타이머가 2시간이라 실시간 서비스에는 너무 느리다. 네티의 유휴 감지 핸들러는 초 단위로 세밀하게 제어할 수 있다.
IdleStateHandler — 유휴 상태 감지의 핵심
IdleStateHandler는 채널의 읽기/쓰기 활동이 일정 시간 없으면 IdleStateEvent를 파이프라인에 전파하는 핸들러 입니다. 네티의 타임아웃 체계에서 가장 기본이 되는 핸들러입니다.
세 가지 유휴 타임
// IdleStateHandler 생성자
new IdleStateHandler(
60, // readerIdleTime — 60초 동안 읽기 없으면 READER_IDLE 이벤트
30, // writerIdleTime — 30초 동안 쓰기 없으면 WRITER_IDLE 이벤트
0, // allIdleTime — 0이면 비활성화 (읽기+쓰기 모두 없을 때 ALL_IDLE)
TimeUnit.SECONDS
);
| 파라미터 | 감지 대상 | 발생 이벤트 |
|---|---|---|
| readerIdleTime | 마지막 읽기 이후 경과 시간 | IdleStateEvent(READER_IDLE) |
| writerIdleTime | 마지막 쓰기 이후 경과 시간 | IdleStateEvent(WRITER_IDLE) |
| allIdleTime | 읽기와 쓰기 모두 없는 경과 시간 | IdleStateEvent(ALL_IDLE) |
값을 0으로 설정하면 해당 유휴 감지가 비활성화 됩니다. 필요한 것만 켜면 됩니다.
내부 동작 원리
IdleStateHandler는 내부적으로 EventLoop의 스케줄된 태스크 를 사용합니다. 별도 타이머 스레드를 만드는 것이 아니라, EventLoop의 schedule() 메서드로 지연 태스크를 등록합니다.
IdleStateHandler 내부 동작 (readerIdleTime = 60초 기준)
channelActive() 호출
│
├── schedule(readerIdleCheck, 60초) ← 첫 번째 타이머 등록
│
│ ... 30초 후 데이터 수신 ...
│
├── channelRead() → lastReadTime 갱신
│
│ ... 60초 타이머 만료 ...
│
├── readerIdleCheck 실행
│ │
│ ├── 현재시간 - lastReadTime = 30초 < 60초
│ │ → 아직 유휴 아님
│ │ → schedule(readerIdleCheck, 30초) ← 남은 시간만큼 재스케줄
│ │
│ └── (만약 현재시간 - lastReadTime >= 60초)
│ → IdleStateEvent(READER_IDLE) 전파
│ → schedule(readerIdleCheck, 60초) ← 다음 주기 스케줄
IdleStateEvent 처리
IdleStateHandler 자체는 이벤트를 발생시키기만 합니다. 이벤트를 받아서 어떻게 처리할지는 다음 핸들러에서 결정합니다.
// IdleStateEvent를 처리하는 핸들러
public class IdleEventHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent event) {
switch (event.state()) {
case READER_IDLE:
// 읽기 유휴 — 상대방이 데이터를 보내지 않음
System.out.println("읽기 유휴 감지: " + ctx.channel().remoteAddress());
ctx.close(); // 연결 종료
break;
case WRITER_IDLE:
// 쓰기 유휴 — 내가 데이터를 보내지 않음
System.out.println("쓰기 유휴 감지 → 하트비트 전송");
ctx.writeAndFlush(new PingMessage());
break;
case ALL_IDLE:
// 양방향 모두 유휴
System.out.println("완전 유휴 → 연결 종료");
ctx.close();
break;
}
} else {
// IdleStateEvent가 아닌 다른 이벤트는 다음 핸들러로 전파
super.userEventTriggered(ctx, evt);
}
}
}
파이프라인 구성은 다음과 같습니다.
ch.pipeline().addLast(new IdleStateHandler(60, 30, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new IdleEventHandler()); // 바로 뒤에 이벤트 처리 핸들러
ch.pipeline().addLast(new MyBusinessHandler());
IdleStateHandler는 "감지"만 하고, "대응"은 다음 핸들러에 맡긴다. 이 분리 덕분에 같은 감지 로직으로 하트비트를 보낼 수도 있고, 연결을 끊을 수도 있고, 로그만 남길 수도 있다.
ReadTimeoutHandler — 읽기 타임아웃 자동 처리
ReadTimeoutHandler는 IdleStateHandler를 상속하여, 읽기 유휴가 감지되면 자동으로 ReadTimeoutException을 발생시키고 채널을 닫는 핸들러 입니다.
// ReadTimeoutHandler — 30초 동안 읽기 없으면 예외 발생 + 채널 닫기
ch.pipeline().addLast(new ReadTimeoutHandler(30, TimeUnit.SECONDS));
IdleStateHandler와의 관계
ReadTimeoutHandler의 내부를 보면, IdleStateHandler를 상속하고 channelIdle() 메서드를 오버라이드하여 예외를 던지는 구조입니다.
// ReadTimeoutHandler 내부 (단순화)
public class ReadTimeoutHandler extends IdleStateHandler {
public ReadTimeoutHandler(long timeout, TimeUnit unit) {
super(timeout, 0, 0, unit); // readerIdleTime만 설정
}
@Override
protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) {
if (evt.state() == IdleState.READER_IDLE) {
// ReadTimeoutException 발생 → exceptionCaught()로 전파
ctx.fireExceptionCaught(ReadTimeoutException.INSTANCE);
// 채널 닫기
ctx.close();
}
}
}
언제 사용하는가
| 상황 | 적합한 핸들러 |
|---|---|
| 유휴 감지 후 하트비트를 보내고 싶다 | IdleStateHandler |
| 유휴 감지 후 커스텀 로직을 실행하고 싶다 | IdleStateHandler |
| 읽기 유휴 시 그냥 연결을 끊으면 된다 | ReadTimeoutHandler |
ReadTimeoutHandler는 "읽기 유휴 = 연결 종료"라는 단순한 정책을 적용할 때 편리합니다. 하트비트 같은 추가 로직이 필요 없는 경우에 적합합니다.
// 예외 처리 핸들러에서 ReadTimeoutException 잡기
public class ExceptionHandler extends ChannelInboundHandlerAdapter {
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof ReadTimeoutException) {
// 읽기 타임아웃 — 이미 채널이 닫히는 중
System.out.println("읽기 타임아웃: " + ctx.channel().remoteAddress());
} else {
// 다른 예외 처리
cause.printStackTrace();
ctx.close();
}
}
}
WriteTimeoutHandler — 쓰기 타임아웃 처리
WriteTimeoutHandler는 write() 호출 후 지정 시간 내에 소켓 전송이 완료되지 않으면 WriteTimeoutException을 발생시키는 핸들러 입니다.
// WriteTimeoutHandler — write 후 15초 내에 flush 완료되지 않으면 예외
ch.pipeline().addLast(new WriteTimeoutHandler(15, TimeUnit.SECONDS));
ReadTimeoutHandler와의 차이
ReadTimeoutHandler와 WriteTimeoutHandler는 감지하는 대상이 다릅니다.
| 구분 | ReadTimeoutHandler | WriteTimeoutHandler |
|---|---|---|
| 감지 대상 | 마지막 읽기 이후 경과 시간 | ** 개별 write 작업 **의 완료 시간 |
| 타이머 기준 | 마지막 channelRead() 시점 | 각 write() 호출 시점 |
| 동작 방식 | 일정 시간 읽기 없으면 예외 | write가 지정 시간 내에 완료 안 되면 예외 |
WriteTimeoutHandler는 "유휴 시간"이 아니라 "개별 쓰기 작업의 완료 시간" 을 측정합니다. write()를 호출했는데 네트워크가 느려서 데이터가 소켓에 전달되지 못하는 상황을 감지합니다.
// WriteTimeoutHandler 내부 동작 (단순화)
// write()가 호출될 때마다 타이머를 건다
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
// 타임아웃 스케줄 등록
ScheduledFuture<?> timeout = ctx.executor().schedule(
() -> {
// 시간 내에 promise가 완료되지 않으면 예외 발생
ctx.fireExceptionCaught(WriteTimeoutException.INSTANCE);
ctx.close();
},
timeoutNanos, TimeUnit.NANOSECONDS
);
// write가 완료되면 타이머 취소
promise.addListener(future -> timeout.cancel(false));
// 실제 write 실행
ctx.write(msg, promise);
}
사용 시 주의점
- WriteTimeoutHandler는 **write()를 호출해야 의미가 있습니다 **. 아무 것도 쓰지 않는 상태에서는 동작하지 않습니다.
- 대용량 데이터를 전송할 때 네트워크 혼잡으로 flush가 오래 걸리는 상황을 감지하는 데 적합합니다.
- 타임아웃 값을 너무 짧게 잡으면 정상적인 대용량 전송도 실패할 수 있습니다.
하트비트 구현 — ping/pong 패턴
** 하트비트는 연결이 살아있는지 주기적으로 확인하는 메커니즘 **입니다. IdleStateHandler의 쓰기 유휴 감지와 userEventTriggered()를 조합하면 깔끔하게 구현할 수 있습니다.
기본 하트비트 구조
하트비트 동작 흐름
[클라이언트] [서버]
│ │
│ (30초간 쓸 데이터 없음) │
│ │
├── WRITER_IDLE 감지 │
│ │
├──── PING ─────────────────────────▶│
│ ├── 읽기 활동으로 인식 (타이머 리셋)
│◀──────────────────────── PONG ─────┤
│ │
├── 읽기 활동으로 인식 (타이머 리셋) │
│ │
클라이언트 측 하트비트 핸들러
// 클라이언트 — 쓰기 유휴 시 ping 전송
public class ClientHeartbeatHandler extends ChannelInboundHandlerAdapter {
private int missedPongs = 0;
private static final int MAX_MISSED_PONGS = 3; // pong 3번 못 받으면 연결 끊기
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent event) {
if (event.state() == IdleState.WRITER_IDLE) {
// 30초간 아무것도 쓰지 않았으면 ping 전송
if (missedPongs >= MAX_MISSED_PONGS) {
// pong을 3번 연속 못 받음 → 연결 끊기
System.out.println("서버 응답 없음 → 연결 종료");
ctx.close();
return;
}
missedPongs++;
ctx.writeAndFlush(new PingMessage());
}
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof PongMessage) {
// pong 수신 → 카운터 리셋
missedPongs = 0;
} else {
// 일반 메시지는 다음 핸들러로 전달
ctx.fireChannelRead(msg);
}
}
}
서버 측 하트비트 핸들러
// 서버 — 읽기 유휴 감지 + ping에 pong 응답
public class ServerHeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent event) {
if (event.state() == IdleState.READER_IDLE) {
// 90초간 읽기 없음 → 클라이언트가 죽었다고 판단
System.out.println("클라이언트 응답 없음 → 연결 종료: "
+ ctx.channel().remoteAddress());
ctx.close();
}
} else {
super.userEventTriggered(ctx, evt);
}
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof PingMessage) {
// ping 수신 → pong 응답 (읽기 타이머도 자동 리셋됨)
ctx.writeAndFlush(new PongMessage());
} else {
ctx.fireChannelRead(msg);
}
}
}
클라이언트/서버 양측 하트비트 구성
양측의 파이프라인을 구성할 때 각자의 역할에 맞는 유휴 타임을 설정합니다.
클라이언트 파이프라인
// 클라이언트 — 쓰기 유휴 시 ping 전송
Bootstrap b = new Bootstrap();
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new IdleStateHandler(
0, // readerIdleTime — 사용 안 함 (pong은 channelRead에서 처리)
30, // writerIdleTime — 30초간 쓰기 없으면 ping 전송
0, // allIdleTime — 사용 안 함
TimeUnit.SECONDS
));
ch.pipeline().addLast(new PingPongCodec()); // ping/pong 메시지 인코딩/디코딩
ch.pipeline().addLast(new ClientHeartbeatHandler()); // 하트비트 로직
ch.pipeline().addLast(new ClientBusinessHandler()); // 비즈니스 로직
}
});
서버 파이프라인
// 서버 — 읽기 유휴 감지로 죽은 클라이언트 정리
ServerBootstrap b = new ServerBootstrap();
b.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new IdleStateHandler(
90, // readerIdleTime — 90초간 읽기 없으면 연결 종료
0, // writerIdleTime — 사용 안 함
0, // allIdleTime — 사용 안 함
TimeUnit.SECONDS
));
ch.pipeline().addLast(new PingPongCodec());
ch.pipeline().addLast(new ServerHeartbeatHandler());
ch.pipeline().addLast(new ServerBusinessHandler());
}
});
왜 서버 readerIdleTime > 클라이언트 writerIdleTime 인가
클라이언트 writerIdleTime: 30초
서버 readerIdleTime: 90초 (30초 × 3)
시나리오:
┌─── 0초: 마지막 통신
│
├─── 30초: 클라이언트 WRITER_IDLE → ping 전송
│ (네트워크 지연으로 도착이 늦어질 수 있음)
│
├─── 60초: 클라이언트 두 번째 ping 전송
│ (여전히 도착 안 됨)
│
├─── 90초: 서버 READER_IDLE → "진짜 죽었다" 판단 → 연결 종료
│ (3번의 ping 기회를 줬는데도 아무것도 안 옴)
└
서버의 읽기 유휴 시간을 클라이언트 쓰기 유휴 시간의 2~3배 로 설정하면, 일시적인 네트워크 지연을 허용하면서도 진짜 죽은 연결은 확실하게 감지할 수 있습니다. 1배로 설정하면 ping이 살짝만 늦어도 건강한 연결이 끊깁니다.
타임아웃 설정 가이드
네트워크 환경별 권장 값
| 환경 | 클라이언트 writerIdleTime | 서버 readerIdleTime | 비고 |
|---|---|---|---|
| LAN (같은 데이터센터) | 10~15초 | 30~45초 | 지연이 거의 없어 짧게 설정 가능 |
| WAN (인터넷) | 30~60초 | 90~180초 | 네트워크 변동이 크므로 여유 있게 |
| 모바일 네트워크 | 60~120초 | 180~360초 | 네트워크 전환, 터널링 고려 |
| 게임 서버 (실시간) | 5~10초 | 15~30초 | 빠른 이탈 감지가 중요 |
하트비트 주기 결정 기준
하트비트 주기를 정할 때 고려해야 할 요소가 있습니다.
- 장애 감지 속도 — 짧을수록 빨리 감지하지만 네트워크 부하 증가
- ** 네트워크 안정성** — 불안정할수록 여유를 둬야 오탐(false positive)이 줄어듦
- ** 연결 수** — 동시 연결이 많으면 하트비트 트래픽이 부담
- NAT/방화벽 — 중간 장비의 유휴 연결 타임아웃보다 짧아야 연결이 끊기지 않음
// NAT 타임아웃 대응 예시
// AWS NAT Gateway 유휴 타임아웃: 350초
// → 하트비트를 300초 이내로 설정해야 NAT가 연결을 끊지 않음
new IdleStateHandler(0, 300, 0, TimeUnit.SECONDS);
실무에서 자주 쓰는 조합
// 조합 1: 단순한 서버 — ReadTimeoutHandler만 사용
// 클라이언트가 하트비트를 보내는 구조
ch.pipeline().addLast(new ReadTimeoutHandler(90, TimeUnit.SECONDS));
ch.pipeline().addLast(new MyHandler());
// 조합 2: 양방향 하트비트 — IdleStateHandler + 커스텀 핸들러
// 가장 유연한 구조
ch.pipeline().addLast(new IdleStateHandler(90, 30, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new HeartbeatHandler());
ch.pipeline().addLast(new MyHandler());
// 조합 3: 읽기 + 쓰기 타임아웃 — 안전하게 양쪽 모두 감시
ch.pipeline().addLast(new ReadTimeoutHandler(90, TimeUnit.SECONDS));
ch.pipeline().addLast(new WriteTimeoutHandler(15, TimeUnit.SECONDS));
ch.pipeline().addLast(new MyHandler());
주의 사항
파이프라인에서 IdleStateHandler의 위치가 중요합니다. ** 코덱보다 앞에 배치 **해야 합니다.
// 올바른 순서 — IdleStateHandler가 먼저
ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4));
ch.pipeline().addLast(new MyHandler());
IdleStateHandler가 코덱 뒤에 있으면, 코덱이 프레임을 완성하지 못해 channelRead()가 호출되지 않는 동안에도 실제 바이트는 수신되고 있을 수 있습니다. 이 경우 "데이터가 오고 있는데 유휴로 판정"하는 오탐이 발생할 수 있습니다.
정리
- ** 좀비 연결 **은 리소스 낭비와 fd 고갈의 원인이다. 타임아웃 없이 서버를 운영하면 안 된다.
- IdleStateHandler 는 읽기/쓰기/전체 유휴를 감지하여 이벤트를 전파한다. 감지와 대응이 분리되어 유연하다.
- ReadTimeoutHandler 는 IdleStateHandler를 상속하여, 읽기 유휴 시 자동으로 예외 발생 + 채널 닫기를 수행한다.
- WriteTimeoutHandler 는 개별 write 작업의 완료 시간을 감시한다. 네트워크 혼잡으로 flush가 오래 걸리는 상황을 감지한다.
- 하트비트 패턴 은 클라이언트가 쓰기 유휴 시 ping을 보내고, 서버가 읽기 유휴로 죽은 연결을 정리하는 구조다.
- 서버 readerIdleTime은 클라이언트 writerIdleTime의 2~3배 로 설정하여 네트워크 지연을 허용한다.
- NAT/방화벽의 유휴 타임아웃 보다 짧은 주기로 하트비트를 보내야 연결이 끊기지 않는다.