TCP 연결은 한번 맺어지면 양쪽 중 하나가 명시적으로 끊기 전까지 살아 있다. 그런데 상대방 프로세스가 갑자기 죽거나 네트워크 케이블이 빠지면? 이쪽에서는 연결이 끊어진 줄 모르고 영원히 기다리게 된다. 이런 "좀비 연결"을 어떻게 감지하고 정리할 수 있을까?

이번 글에서는 Netty의 타임아웃과 유휴 감지 메커니즘을 정리합니다. IdleStateHandler로 유휴 상태를 감지하는 방법, ReadTimeoutHandlerWriteTimeoutHandler의 자동 예외 처리, 하트비트 ping/pong 패턴으로 살아있는 연결을 확인하는 전략, 그리고 네트워크 환경별 타임아웃 권장 값까지 다룹니다.


왜 타임아웃이 필요한가

타임아웃은 일정 시간 동안 응답이 없는 연결을 감지하고 정리하는 메커니즘 입니다. 타임아웃 없이 서버를 운영하면 다음과 같은 문제가 생깁니다.

좀비 연결이 만들어지는 상황

PLAINTEXT
정상 종료:
[클라이언트] --FIN--> [서버]    양쪽이 FIN을 주고받으며 깔끔하게 종료

비정상 종료:
[클라이언트] --전원 꺼짐--> [서버]    FIN 패킷을 보내지 못함
                                   서버는 연결이 살아있다고 착각
                                   → 좀비 연결

좀비 연결이 위험한 이유를 정리하면 다음과 같습니다.

  1. 리소스 낭비 — 각 연결은 소켓 파일 디스크립터, 메모리(ByteBuf, ChannelHandlerContext 등)를 점유한다
  2. fd 고갈 — 좀비 연결이 쌓이면 파일 디스크립터 한도에 도달해 새 연결을 받지 못한다
  3. ** 상태 불일치** — 서버는 클라이언트가 살아있다고 생각하고 데이터를 보내지만, 실제로는 아무도 받지 못한다
  4. ** 장애 감지 지연** — 네트워크 분리를 빠르게 감지하지 못하면 장애 대응이 늦어진다

TCP의 keepalive도 있지만, 기본 타이머가 2시간이라 실시간 서비스에는 너무 느리다. 네티의 유휴 감지 핸들러는 초 단위로 세밀하게 제어할 수 있다.


IdleStateHandler — 유휴 상태 감지의 핵심

IdleStateHandler는 채널의 읽기/쓰기 활동이 일정 시간 없으면 IdleStateEvent를 파이프라인에 전파하는 핸들러 입니다. 네티의 타임아웃 체계에서 가장 기본이 되는 핸들러입니다.

세 가지 유휴 타임

JAVA
// 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() 메서드로 지연 태스크를 등록합니다.

PLAINTEXT
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 자체는 이벤트를 발생시키기만 합니다. 이벤트를 받아서 어떻게 처리할지는 다음 핸들러에서 결정합니다.

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

파이프라인 구성은 다음과 같습니다.

JAVA
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을 발생시키고 채널을 닫는 핸들러 입니다.

JAVA
// ReadTimeoutHandler — 30초 동안 읽기 없으면 예외 발생 + 채널 닫기
ch.pipeline().addLast(new ReadTimeoutHandler(30, TimeUnit.SECONDS));

IdleStateHandler와의 관계

ReadTimeoutHandler의 내부를 보면, IdleStateHandler를 상속하고 channelIdle() 메서드를 오버라이드하여 예외를 던지는 구조입니다.

JAVA
// 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는 "읽기 유휴 = 연결 종료"라는 단순한 정책을 적용할 때 편리합니다. 하트비트 같은 추가 로직이 필요 없는 경우에 적합합니다.

JAVA
// 예외 처리 핸들러에서 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을 발생시키는 핸들러 입니다.

JAVA
// WriteTimeoutHandler — write 후 15초 내에 flush 완료되지 않으면 예외
ch.pipeline().addLast(new WriteTimeoutHandler(15, TimeUnit.SECONDS));

ReadTimeoutHandler와의 차이

ReadTimeoutHandler와 WriteTimeoutHandler는 감지하는 대상이 다릅니다.

구분ReadTimeoutHandlerWriteTimeoutHandler
감지 대상마지막 읽기 이후 경과 시간** 개별 write 작업 **의 완료 시간
타이머 기준마지막 channelRead() 시점각 write() 호출 시점
동작 방식일정 시간 읽기 없으면 예외write가 지정 시간 내에 완료 안 되면 예외

WriteTimeoutHandler는 "유휴 시간"이 아니라 "개별 쓰기 작업의 완료 시간" 을 측정합니다. write()를 호출했는데 네트워크가 느려서 데이터가 소켓에 전달되지 못하는 상황을 감지합니다.

JAVA
// 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()를 조합하면 깔끔하게 구현할 수 있습니다.

기본 하트비트 구조

PLAINTEXT
하트비트 동작 흐름

[클라이언트]                           [서버]
    │                                   │
    │  (30초간 쓸 데이터 없음)             │
    │                                   │
    ├── WRITER_IDLE 감지                 │
    │                                   │
    ├──── PING ─────────────────────────▶│
    │                                   ├── 읽기 활동으로 인식 (타이머 리셋)
    │◀──────────────────────── PONG ─────┤
    │                                   │
    ├── 읽기 활동으로 인식 (타이머 리셋)     │
    │                                   │

클라이언트 측 하트비트 핸들러

JAVA
// 클라이언트 — 쓰기 유휴 시 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);
        }
    }
}

서버 측 하트비트 핸들러

JAVA
// 서버 — 읽기 유휴 감지 + 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);
        }
    }
}

클라이언트/서버 양측 하트비트 구성

양측의 파이프라인을 구성할 때 각자의 역할에 맞는 유휴 타임을 설정합니다.

클라이언트 파이프라인

JAVA
// 클라이언트 — 쓰기 유휴 시 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());   // 비즈니스 로직
    }
});

서버 파이프라인

JAVA
// 서버 — 읽기 유휴 감지로 죽은 클라이언트 정리
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 인가

PLAINTEXT
클라이언트 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초빠른 이탈 감지가 중요

하트비트 주기 결정 기준

하트비트 주기를 정할 때 고려해야 할 요소가 있습니다.

  1. 장애 감지 속도 — 짧을수록 빨리 감지하지만 네트워크 부하 증가
  2. ** 네트워크 안정성** — 불안정할수록 여유를 둬야 오탐(false positive)이 줄어듦
  3. ** 연결 수** — 동시 연결이 많으면 하트비트 트래픽이 부담
  4. NAT/방화벽 — 중간 장비의 유휴 연결 타임아웃보다 짧아야 연결이 끊기지 않음
JAVA
// NAT 타임아웃 대응 예시
// AWS NAT Gateway 유휴 타임아웃: 350초
// → 하트비트를 300초 이내로 설정해야 NAT가 연결을 끊지 않음
new IdleStateHandler(0, 300, 0, TimeUnit.SECONDS);

실무에서 자주 쓰는 조합

JAVA
// 조합 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의 위치가 중요합니다. ** 코덱보다 앞에 배치 **해야 합니다.

JAVA
// 올바른 순서 — 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/방화벽의 유휴 타임아웃 보다 짧은 주기로 하트비트를 보내야 연결이 끊기지 않는다.
댓글 로딩 중...