네티 파이프라인에서 예외가 터지면 어디로 가는 걸까? channelRead()에서 터진 예외와 write()에서 터진 예외가 서로 다른 경로로 전파된다는데, 안 잡으면 어떻게 되는 걸까?

예외 처리가 중요한 이유

네티는 비동기 이벤트 기반 프레임워크이다 보니, 예외가 발생해도 일반적인 try-catch처럼 호출 스택을 타고 올라가지 않습니다. 파이프라인이라는 체인 구조 안에서 이벤트로 전파되기 때문에, 이 전파 경로를 모르면 예외가 조용히 사라지거나, 리소스 릭이 생기거나, 연결이 좀비 상태로 남는 문제가 발생합니다.

공부하다 보니 인바운드 예외와 아웃바운드 예외가 완전히 다른 메커니즘으로 처리된다는 게 핵심이었습니다. 이 차이를 이해하지 못하면, 예외 핸들러를 열심히 만들어 놓고도 정작 아웃바운드 예외는 놓치는 상황이 벌어집니다.


인바운드 예외 — exceptionCaught()로 전파

기본 메커니즘

인바운드 핸들러(channelRead(), channelActive() 등)에서 예외가 발생하면, ** 네티가 자동으로 같은 파이프라인의 다음 핸들러에게 exceptionCaught() 이벤트를 전파 **합니다. 인바운드 이벤트와 동일하게 head → tail 방향으로 흐릅니다.

JAVA
public class DecoderHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 여기서 예외가 발생하면?
        ByteBuf buf = (ByteBuf) msg;
        int value = buf.readInt(); // 데이터가 부족하면 IndexOutOfBoundsException 발생!

        ctx.fireChannelRead(value);
    }
}

위 핸들러에서 예외가 터지면, 네티 내부적으로 다음과 같은 과정이 일어납니다.

JAVA
// 네티 내부 동작 (의사 코드)
try {
    handler.channelRead(ctx, msg);
} catch (Throwable t) {
    // 예외 발생 시 exceptionCaught 이벤트로 변환하여 전파
    ctx.fireExceptionCaught(t);
}

전파 경로

exceptionCaught() 이벤트는 ** 예외가 발생한 핸들러의 다음 핸들러부터 tail 방향으로** 전파됩니다. 인바운드 핸들러뿐 아니라 아웃바운드 핸들러의 exceptionCaught()도 호출 대상입니다.

JAVA
public class BusinessHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 비즈니스 로직 처리 중 예외 발생
        processMessage(msg); // NullPointerException!
    }
}

public class ExceptionHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // BusinessHandler에서 발생한 예외가 여기로 전달됨
        System.err.println("예외 발생: " + cause.getMessage());
        ctx.close(); // 채널 닫기
    }
}
JAVA
// 파이프라인 구성
ch.pipeline().addLast("decoder", new DecoderHandler());
ch.pipeline().addLast("business", new BusinessHandler());
ch.pipeline().addLast("exception", new ExceptionHandler()); // 마지막에 배치

아웃바운드 예외 — ChannelPromise에 실패 설정

exceptionCaught()로 가지 않는다

여기가 많이 헷갈리는 포인트입니다. ** 아웃바운드 핸들러(write(), flush(), connect() 등)에서 발생한 예외는 exceptionCaught()로 전파되지 않습니다.** 대신 해당 작업에 연결된 ChannelPromise에 실패로 설정됩니다.

JAVA
public class EncoderHandler extends ChannelOutboundHandlerAdapter {
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
        // 인코딩 중 예외 발생
        ByteBuf encoded = encode(msg); // IllegalArgumentException!
        ctx.write(encoded, promise);
    }
}

위 핸들러에서 예외가 터지면, 네티는 이렇게 처리합니다.

JAVA
// 네티 내부 동작 (의사 코드)
try {
    handler.write(ctx, msg, promise);
} catch (Throwable t) {
    // exceptionCaught()가 아니라 promise에 실패 설정!
    promise.setFailure(t);
}

아웃바운드 예외를 잡는 방법

ChannelPromise에 실패가 설정되므로, ChannelFuture에 리스너를 등록해야 예외를 감지할 수 있습니다.

JAVA
// 방법 1: 리스너 직접 등록
ctx.writeAndFlush(response).addListener((ChannelFutureListener) future -> {
    if (!future.isSuccess()) {
        // 아웃바운드 예외 처리
        Throwable cause = future.cause();
        System.err.println("쓰기 실패: " + cause.getMessage());
        future.channel().close();
    }
});
JAVA
// 방법 2: 미리 정의된 리스너 사용 — 실패 시 자동으로 채널 닫기
ctx.writeAndFlush(response)
   .addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
JAVA
// 방법 3: ChannelDuplexHandler로 인바운드/아웃바운드 예외를 한 곳에서 처리
public class UnifiedExceptionHandler extends ChannelDuplexHandler {
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // 인바운드 예외 처리
        handleException(ctx, cause);
    }

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
        ctx.write(msg, promise.addListener((ChannelFutureListener) future -> {
            if (!future.isSuccess()) {
                // 아웃바운드 예외 처리
                handleException(ctx, future.cause());
            }
        }));
    }

    private void handleException(ChannelHandlerContext ctx, Throwable cause) {
        System.err.println("예외 발생: " + cause.getMessage());
        ctx.close();
    }
}

인바운드 vs 아웃바운드 예외 경로 비교

두 예외 경로의 차이를 ASCII 다이어그램으로 정리하면 이렇습니다.

PLAINTEXT
                        ChannelPipeline

  ┌──────┐    ┌─────────┐    ┌──────────┐    ┌───────────┐    ┌──────┐
  │ Head │───▶│ Decoder │───▶│ Business │───▶│ Exception │───▶│ Tail │
  │      │    │(Inbound)│    │(Inbound) │    │ Handler   │    │      │
  └──────┘    └─────────┘    └──────────┘    └───────────┘    └──────┘

  ━━━ 인바운드 예외 경로 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Decoder에서 channelRead() 중 예외 발생!

      ▼ ctx.fireExceptionCaught(cause)
  Business.exceptionCaught() ──▶ Exception.exceptionCaught()
      (안 잡으면 다음으로 전파)        (여기서 처리!)

  방향: head → tail (인바운드 방향과 동일)
  메커니즘: exceptionCaught() 이벤트 체인

  ━━━ 아웃바운드 예외 경로 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  Business에서 ctx.writeAndFlush(msg) 호출

      ▼ 아웃바운드 핸들러 체인 (tail → head)
  Encoder.write()에서 예외 발생!

      ▼ promise.setFailure(cause)
  ChannelFuture에 실패 설정 → 리스너로만 감지 가능

  방향: exceptionCaught() 체인을 타지 않음
  메커니즘: ChannelPromise/ChannelFuture 콜백
구분인바운드 예외아웃바운드 예외
발생 위치channelRead, channelActive 등write, flush, connect 등
전파 메커니즘exceptionCaught() 이벤트 체인ChannelPromise에 실패 설정
전파 방향head → tail전파되지 않음 (Promise 콜백)
잡는 방법exceptionCaught() 오버라이드ChannelFuture 리스너 등록

exceptionCaught() 기본 동작

TailContext의 처리 방식

인바운드 예외가 파이프라인의 어떤 핸들러에서도 처리되지 않으면, 최종적으로 TailContext 에 도달합니다. TailContext의 exceptionCaught() 구현은 다음과 같습니다.

JAVA
// TailContext의 기본 구현 (네티 내부 코드 단순화)
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    // WARNING 로그만 남기고 끝!
    logger.warn("An exceptionCaught() event was fired, and it reached at the tail " +
                "of the pipeline. It usually means the last handler in the pipeline " +
                "did not handle the exception.", cause);
}

이 기본 동작이 위험한 이유는 명확합니다.

  • 채널이 자동으로 닫히지 않습니다 — 예외가 발생한 채널이 여전히 열린 상태로 남습니다.
  • ** 리소스가 해제되지 않습니다** — ByteBuf 등이 릴리스되지 않아 메모리 릭이 발생할 수 있습니다.
  • ** 로그만 남으므로 운영 중 알아차리기 어렵습니다** — WARNING 레벨이라 로그 볼륨이 많으면 묻힙니다.

ChannelInboundHandlerAdapter의 기본 구현

중간 핸들러에서 exceptionCaught()를 오버라이드하지 않으면, 어댑터 클래스의 기본 구현이 동작합니다.

JAVA
// ChannelInboundHandlerAdapter의 기본 구현
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    // 다음 핸들러로 그대로 전달
    ctx.fireExceptionCaught(cause);
}

예외를 잡지 않으면 그냥 다음 핸들러로 넘기고, 끝까지 안 잡히면 TailContext에서 WARNING 로그만 남기는 구조입니다.


예외 핸들러 위치 — 왜 파이프라인 마지막인가

마지막에 배치하는 이유

인바운드 예외는 ** 발생 지점부터 tail 방향으로** 전파됩니다. 예외 핸들러가 파이프라인 중간에 있으면, 그 뒤에 있는 핸들러에서 발생한 예외는 잡지 못합니다.

JAVA
// 잘못된 배치: 예외 핸들러가 중간에 있음
ch.pipeline().addLast("exception", new ExceptionHandler());  // 여기에 두면...
ch.pipeline().addLast("decoder", new MyDecoder());
ch.pipeline().addLast("business", new BusinessHandler());    // 여기서 발생한 예외를 못 잡음!
JAVA
// 올바른 배치: 예외 핸들러를 맨 마지막에 배치
ch.pipeline().addLast("decoder", new MyDecoder());
ch.pipeline().addLast("business", new BusinessHandler());
ch.pipeline().addLast("exception", new ExceptionHandler());  // 모든 인바운드 예외를 잡음

글로벌 예외 핸들러 패턴

실무에서 가장 많이 쓰는 패턴은, ** 파이프라인 맨 마지막에 글로벌 예외 핸들러를 하나 두는 것 **입니다. 이 핸들러가 앞쪽 모든 핸들러에서 발생한 인바운드 예외를 최종적으로 잡아서 처리합니다.

JAVA
@ChannelHandler.Sharable
public class GlobalExceptionHandler extends ChannelInboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        // 1. 로깅
        log.error("채널 {} 에서 미처리 예외 발생", ctx.channel().remoteAddress(), cause);

        // 2. 채널 닫기 (리소스 정리)
        if (ctx.channel().isActive()) {
            ctx.close();
        }
    }
}
JAVA
// 모든 채널 파이프라인에 공유 인스턴스로 등록
GlobalExceptionHandler globalHandler = new GlobalExceptionHandler();

bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast("decoder", new MyDecoder());
        ch.pipeline().addLast("business", new BusinessHandler());
        ch.pipeline().addLast("encoder", new MyEncoder());
        ch.pipeline().addLast("globalException", globalHandler); // 항상 마지막
    }
});

글로벌 예외 핸들러는 무상태이므로 @Sharable을 붙여 모든 채널에서 공유할 수 있습니다. 핸들러 인스턴스를 채널마다 새로 만들 필요 없이 하나만 두면 됩니다.


미처리 예외의 위험

예외를 잡지 않으면 구체적으로 어떤 문제가 발생하는지 정리해 보겠습니다.

1. 채널이 자동으로 닫히지 않는다

가장 흔한 오해가 "예외가 발생하면 채널이 자동으로 닫히겠지"라는 생각입니다. ** 네티는 예외가 발생해도 채널을 자동으로 닫지 않습니다.** 명시적으로 ctx.close()를 호출해야 합니다.

JAVA
// 예외 발생 후에도 채널은 여전히 열려 있다
public class LeakyHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        throw new RuntimeException("뭔가 잘못됐다!");
        // 예외가 exceptionCaught로 전파되지만,
        // 아무도 ctx.close()를 호출하지 않으면 채널은 계속 열려 있음
    }
}

2. 리소스 릭

예외가 발생한 시점에서 ByteBuf가 릴리스되지 않으면, 메모리 풀에 반환되지 못한 버퍼가 쌓입니다. 특히 PooledByteBufAllocator를 사용할 때 이 문제가 심각해집니다.

JAVA
// ByteBuf 릴리스 누락으로 인한 메모리 릭
public class UnsafeHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        String data = buf.toString(CharsetUtil.UTF_8);

        // 여기서 예외 발생 시 buf.release()가 호출되지 않음!
        processData(data); // NullPointerException 발생 가능

        buf.release(); // 이 줄에 도달하지 못함
    }
}
JAVA
// 안전한 패턴: try-finally로 릴리스 보장
public class SafeHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        try {
            String data = buf.toString(CharsetUtil.UTF_8);
            processData(data);
            ctx.fireChannelRead(msg);
        } catch (Exception e) {
            // 예외 발생 시에도 반드시 릴리스
            ctx.fireExceptionCaught(e);
        } finally {
            buf.release();
        }
    }
}

3. 좀비 연결

채널이 닫히지 않고 남아 있으면, 클라이언트 입장에서는 연결이 살아 있다고 착각하고 계속 데이터를 보냅니다. 하지만 서버 쪽 핸들러는 이미 비정상 상태이므로, ** 요청은 들어오지만 응답이 나가지 않는 좀비 연결 **이 됩니다.

좀비 연결이 쌓이면 다음 문제로 이어집니다.

  • 파일 디스크립터 고갈 — OS의 소켓 제한에 도달
  • EventLoop 스레드 낭비 — 쓸모없는 채널이 EventLoop에 등록된 상태 유지
  • 클라이언트 타임아웃 — 응답을 받지 못한 클라이언트가 타임아웃까지 대기

실전 패턴

패턴 1: 예외 타입별 분기 처리

JAVA
public class TypedExceptionHandler extends ChannelInboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(TypedExceptionHandler.class);

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        if (cause instanceof ReadTimeoutException) {
            // 읽기 타임아웃 — 클라이언트가 데이터를 안 보내는 경우
            log.warn("읽기 타임아웃: {}", ctx.channel().remoteAddress());
            ctx.close();

        } else if (cause instanceof DecoderException) {
            // 디코딩 실패 — 잘못된 프로토콜 데이터
            log.warn("디코딩 실패: {}", cause.getMessage());
            sendErrorResponse(ctx, "INVALID_PROTOCOL");
            ctx.close();

        } else if (cause instanceof IOException) {
            // 네트워크 오류 — 연결 끊김 등
            log.debug("네트워크 오류: {}", cause.getMessage());
            ctx.close();

        } else {
            // 예상치 못한 예외 — 반드시 로깅
            log.error("알 수 없는 예외 발생", cause);
            ctx.close();
        }
    }

    private void sendErrorResponse(ChannelHandlerContext ctx, String errorCode) {
        if (ctx.channel().isActive()) {
            ByteBuf response = ctx.alloc().buffer();
            response.writeCharSequence("ERROR:" + errorCode + "\n", CharsetUtil.UTF_8);
            ctx.writeAndFlush(response)
               .addListener(ChannelFutureListener.CLOSE); // 응답 후 채널 닫기
        }
    }
}

패턴 2: 로깅 + 채널 닫기 조합

가장 기본적이면서도 많이 쓰이는 패턴입니다. 예외 로깅 후 채널을 닫아 리소스를 정리합니다.

JAVA
@ChannelHandler.Sharable
public class LogAndCloseHandler extends ChannelInboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(LogAndCloseHandler.class);

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("[{}] 예외 발생, 채널을 닫습니다.",
                  ctx.channel().remoteAddress(), cause);

        // 채널이 아직 열려 있을 때만 닫기
        if (ctx.channel().isActive()) {
            ctx.close();
        }
    }
}

패턴 3: 에러 응답 전송 후 닫기

HTTP 서버처럼 에러 응답을 보내야 하는 경우, 응답을 먼저 보내고 나서 채널을 닫습니다.

JAVA
public class HttpErrorHandler extends ChannelInboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(HttpErrorHandler.class);

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("HTTP 처리 중 예외 발생", cause);

        if (ctx.channel().isActive()) {
            // 500 에러 응답 생성
            ByteBuf content = ctx.alloc().buffer();
            content.writeCharSequence("Internal Server Error", CharsetUtil.UTF_8);

            // 응답 전송 후 채널 닫기
            ctx.writeAndFlush(content)
               .addListener(ChannelFutureListener.CLOSE);
        }
    }
}

ChannelFutureListener.CLOSE는 write 작업이 완료(성공이든 실패든)된 후 채널을 닫는 미리 정의된 리스너입니다. 응답을 보내고 바로 ctx.close()를 호출하면 응답이 전송되기 전에 채널이 닫힐 수 있으므로, 반드시 리스너를 통해 닫아야 합니다.


패턴 4: 인바운드 + 아웃바운드 통합 처리

인바운드 예외만 잡는 것이 아니라, 아웃바운드 예외까지 한 곳에서 관리하고 싶을 때 사용하는 패턴입니다.

JAVA
public class UnifiedErrorHandler extends ChannelDuplexHandler {
    private static final Logger log = LoggerFactory.getLogger(UnifiedErrorHandler.class);

    // 인바운드 예외 — exceptionCaught() 체인으로 전파된 것
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("[인바운드] 예외 발생: {}", cause.getMessage(), cause);
        closeOnError(ctx);
    }

    // 아웃바운드 예외 — write의 ChannelPromise를 감싸서 감지
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
        ctx.write(msg, promise.addListener((ChannelFutureListener) future -> {
            if (!future.isSuccess()) {
                log.error("[아웃바운드] 쓰기 실패: {}", future.cause().getMessage(), future.cause());
                closeOnError(ctx);
            }
        }));
    }

    private void closeOnError(ChannelHandlerContext ctx) {
        if (ctx.channel().isActive()) {
            ctx.close();
        }
    }
}

패턴 5: 예외 발생 횟수 제한

특정 채널에서 예외가 반복적으로 발생할 때, 일정 횟수 이상이면 채널을 강제로 닫는 패턴입니다.

JAVA
public class ThresholdExceptionHandler extends ChannelInboundHandlerAdapter {
    private static final Logger log = LoggerFactory.getLogger(ThresholdExceptionHandler.class);
    private static final AttributeKey<Integer> ERROR_COUNT =
            AttributeKey.valueOf("errorCount");
    private static final int MAX_ERRORS = 3;

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        Attribute<Integer> attr = ctx.channel().attr(ERROR_COUNT);
        int count = (attr.get() == null) ? 0 : attr.get();
        count++;
        attr.set(count);

        if (count >= MAX_ERRORS) {
            log.error("예외 {} 회 초과, 채널을 닫습니다: {}",
                      MAX_ERRORS, ctx.channel().remoteAddress(), cause);
            ctx.close();
        } else {
            log.warn("예외 발생 ({}/{}): {}",
                     count, MAX_ERRORS, cause.getMessage());
            // 채널은 유지하고 다음 요청을 기다림
        }
    }
}

정리

  • ** 인바운드 예외 **는 exceptionCaught() 이벤트로 head → tail 방향으로 전파된다. 예외 핸들러를 파이프라인 마지막에 배치해야 모든 예외를 잡을 수 있다.
  • ** 아웃바운드 예외 **는 exceptionCaught()로 전파되지 않고, ChannelPromise에 실패로 설정된다. ChannelFuture 리스너를 등록해야 감지할 수 있다.
  • TailContext 에 도달한 미처리 예외는 WARNING 로그만 남기고 무시된다. 채널이 자동으로 닫히지 않으므로 리소스 릭과 좀비 연결의 원인이 된다.
  • 실전에서는 글로벌 예외 핸들러를 파이프라인 마지막에 배치 하고, 예외 타입별 분기 처리 + 로깅 + 채널 닫기를 조합하는 것이 기본 패턴이다.
  • 인바운드와 아웃바운드 예외를 모두 잡으려면 ChannelDuplexHandler를 사용해서 exceptionCaught()write() 리스너를 함께 구현하는 것이 안전하다.
댓글 로딩 중...