클라이언트가 서버에 연결했다. 데이터가 잘 오가고 있다. 그런데 갑자기 서버가 재시작되거나, 네트워크가 순간적으로 끊기면? 연결은 그냥 끊어지고, 클라이언트는 아무것도 모른 채 멈춰 있게 된다. 이 상황을 어떻게 감지하고, 자동으로 복구할 수 있을까?

이번 글에서는 Netty 클라이언트의 자동 재연결 전략 과 커넥션 풀링 을 다룹니다. channelInactive()에서 재연결을 트리거하는 방법, Exponential Backoff로 재시도 간격을 조절하는 패턴, 그리고 ChannelPool을 활용해 여러 커넥션을 효율적으로 관리하는 방법까지 정리합니다.


왜 자동 재연결이 필요한가

네트워크 프로그래밍에서 연결이 끊기는 건 예외가 아니라 일상 입니다. 원인은 다양합니다.

  • 네트워크 불안정 — WiFi 전환, 일시적 패킷 손실, 라우터 재부팅
  • ** 서버 재시작** — 배포, 패치, 스케일링으로 인한 계획된 재시작
  • ** 일시적 장애** — 서버 과부하, GC 정지, 방화벽 타임아웃

클라이언트가 이런 상황에서 ** 수동 개입 없이 자동으로 연결을 복구 **하지 못하면, 서비스 전체가 멈출 수 있습니다. 특히 마이크로서비스 환경에서는 서비스 간 통신이 끊기면 연쇄 장애로 이어지기도 합니다.

자동 재연결은 "있으면 좋은 기능"이 아니라, 프로덕션 환경에서는 ** 반드시 구현해야 하는 기본 요구사항 **이다.


channelInactive()에서 재연결 트리거

Netty에서 연결이 끊기면 ChannelInboundHandlerchannelInactive()가 호출됩니다. 이 콜백이 바로 재연결을 시작하기 가장 좋은 지점입니다.

JAVA
public class ReconnectHandler extends ChannelInboundHandlerAdapter {

    private final Bootstrap bootstrap;

    public ReconnectHandler(Bootstrap bootstrap) {
        this.bootstrap = bootstrap;
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("연결 끊김 감지. 3초 후 재연결 시도...");

        // 끊어진 Channel의 EventLoop을 재사용하여 재연결 스케줄링
        ctx.channel().eventLoop().schedule(() -> {
            bootstrap.connect().addListener((ChannelFutureListener) future -> {
                if (future.isSuccess()) {
                    System.out.println("재연결 성공!");
                } else {
                    System.out.println("재연결 실패: " + future.cause().getMessage());
                    // 실패 시 새 Channel의 channelInactive()에서 다시 시도됨
                }
            });
        }, 3, TimeUnit.SECONDS);

        ctx.fireChannelInactive(); // 다음 핸들러에도 이벤트 전파
    }
}

핵심 포인트를 정리하면 이렇습니다.

  • ctx.channel().eventLoop().schedule() — 끊어진 Channel의 EventLoop 스레드에서 재연결을 실행합니다. 새로운 스레드를 만들지 않으므로 리소스 낭비가 없습니다.
  • bootstrap.connect() — 기존 Bootstrap 설정(서버 주소, 핸들러 등)을 그대로 재사용합니다.
  • ctx.fireChannelInactive() — 파이프라인의 다음 핸들러에도 연결 종료 이벤트를 전파합니다. 이걸 빼먹으면 다른 핸들러가 연결 종료를 감지하지 못합니다.
PLAINTEXT
연결 끊김 → channelInactive() 호출


    eventLoop.schedule(3초 후)


    bootstrap.connect() 시도
           ┌────┴────┐
        성공         실패
         │            │
     정상 동작    새 채널의 channelInactive()에서
                  다시 재연결 시도

Exponential Backoff

3초 고정 간격으로 재시도하는 건 단순하지만 문제가 있습니다. 서버가 장시간 다운된 경우, 수천 개의 클라이언트가 ** 동시에 3초마다 연결을 시도 **하면 서버가 복구되는 순간 연결 폭풍(connection storm)이 발생합니다.

Exponential Backoff 는 재시도 간격을 점점 늘려가는 전략입니다.

PLAINTEXT
1차 시도: 1초 후
2차 시도: 2초 후
3차 시도: 4초 후
4차 시도: 8초 후
5차 시도: 16초 후
6차 시도: 30초 후 (최대 대기 시간 도달)
7차 시도: 30초 후
...

구현은 간단합니다.

JAVA
public class ExponentialBackoffReconnectHandler extends ChannelInboundHandlerAdapter {

    private static final int INITIAL_DELAY = 1;   // 초기 대기: 1초
    private static final int MAX_DELAY = 30;       // 최대 대기: 30초
    private static final int MAX_RETRIES = 10;     // 최대 재시도 횟수

    private final Bootstrap bootstrap;
    private int retryCount = 0;

    public ExponentialBackoffReconnectHandler(Bootstrap bootstrap) {
        this.bootstrap = bootstrap;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        retryCount = 0; // 연결 성공 시 카운터 초기화
        ctx.fireChannelActive();
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        if (retryCount >= MAX_RETRIES) {
            System.out.println("최대 재시도 횟수 초과. 재연결 포기.");
            ctx.fireChannelInactive();
            return;
        }

        // 대기 시간 계산: min(초기값 * 2^재시도횟수, 최대값)
        int delay = Math.min(INITIAL_DELAY << retryCount, MAX_DELAY);
        retryCount++;

        System.out.printf("재연결 시도 %d/%d — %d초 후%n", retryCount, MAX_RETRIES, delay);

        ctx.channel().eventLoop().schedule(() -> {
            bootstrap.connect().addListener((ChannelFutureListener) future -> {
                if (future.isSuccess()) {
                    System.out.println("재연결 성공! 재시도 카운터 초기화.");
                } else {
                    System.out.println("재연결 실패: " + future.cause().getMessage());
                }
            });
        }, delay, TimeUnit.SECONDS);

        ctx.fireChannelInactive();
    }
}

Exponential Backoff 구현에서 놓치기 쉬운 부분들이 있습니다.

항목설명
최대 대기 시간(cap)없으면 대기 시간이 무한 증가. 보통 30~60초로 설정
** 최대 재시도 횟수**무한 재시도는 리소스 낭비. 10~20회 정도가 적절
** 카운터 초기화**channelActive()에서 초기화해야 재연결 성공 후 다시 끊겼을 때 처음부터 시작
Jitter(랜덤 지터)여러 클라이언트의 재시도가 동시에 몰리는 것을 방지

Jitter 추가

여러 클라이언트가 동시에 끊기면 Exponential Backoff만으로는 부족합니다. 모든 클라이언트가 같은 시간에 재시도하기 때문입니다. Jitter 를 추가하면 재시도 시점을 분산시킬 수 있습니다.

JAVA
// Jitter 적용: 대기 시간에 ±50% 랜덤 변동 추가
private int calculateDelayWithJitter(int retryCount) {
    int baseDelay = Math.min(INITIAL_DELAY << retryCount, MAX_DELAY);
    // baseDelay의 50% ~ 150% 사이 랜덤 값
    double jitter = baseDelay * (0.5 + Math.random());
    return (int) Math.min(jitter, MAX_DELAY);
}

재연결 구현: Bootstrap 재사용 패턴

재연결할 때 Bootstrap을 새로 만들 필요는 없습니다. 기존 Bootstrap의 설정을 그대로 재사용하되, 새 Channel에 핸들러가 정상적으로 추가되도록 ChannelInitializer를 사용하는 것이 중요합니다.

JAVA
public class ReconnectableClient {

    private final String host;
    private final int port;
    private final EventLoopGroup group;
    private final Bootstrap bootstrap;

    public ReconnectableClient(String host, int port) {
        this.host = host;
        this.port = port;
        this.group = new NioEventLoopGroup();
        this.bootstrap = new Bootstrap();

        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .remoteAddress(host, port)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(
                            new LineBasedFrameDecoder(1024),
                            new StringDecoder(),
                            new StringEncoder(),
                            new BusinessHandler(),
                            // 재연결 핸들러는 파이프라인 마지막에 추가
                            new ExponentialBackoffReconnectHandler(bootstrap)
                        );
                    }
                });
    }

    public void connect() {
        bootstrap.connect().addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                System.out.println("최초 연결 성공: " + host + ":" + port);
            } else {
                System.out.println("최초 연결 실패. 재연결 스케줄링...");
                // 최초 연결 실패 시에도 재연결 시도
                future.channel().eventLoop().schedule(
                    this::connect, 3, TimeUnit.SECONDS
                );
            }
        });
    }

    public void shutdown() {
        group.shutdownGracefully();
    }
}

여기서 주의할 점이 하나 있습니다. ChannelInitializer는 매번 새 Channel이 생성될 때 initChannel()을 호출 합니다. 그래서 재연결로 새 Channel이 만들어져도 핸들러 파이프라인이 자동으로 구성됩니다. ChannelInitializer 없이 핸들러를 직접 추가하면 재연결 시 빈 파이프라인 문제가 생길 수 있습니다.


ChannelPool — 커넥션 풀링

지금까지는 연결 하나를 관리하는 이야기였습니다. 하지만 HTTP 클라이언트나 데이터베이스 드라이버처럼 여러 커넥션을 동시에 관리 해야 하는 경우가 많습니다. 매번 새 연결을 만들고 닫는 건 TCP 핸드셰이크 비용 때문에 비효율적입니다.

Netty는 io.netty.channel.pool 패키지에서 커넥션 풀을 제공합니다.

SimpleChannelPool

가장 기본적인 커넥션 풀입니다. 크기 제한 없이 커넥션을 관리합니다.

JAVA
// SimpleChannelPool 생성
Bootstrap bootstrap = new Bootstrap()
    .group(new NioEventLoopGroup())
    .channel(NioSocketChannel.class)
    .remoteAddress("localhost", 8080);

ChannelPoolHandler poolHandler = new MyChannelPoolHandler();
SimpleChannelPool pool = new SimpleChannelPool(bootstrap, poolHandler);

// 커넥션 획득
Future<Channel> future = pool.acquire();
future.addListener((FutureListener<Channel>) f -> {
    if (f.isSuccess()) {
        Channel ch = f.getNow();
        try {
            // 채널 사용
            ch.writeAndFlush("Hello");
        } finally {
            // 사용 후 반드시 반납
            pool.release(ch);
        }
    }
});

FixedChannelPool

SimpleChannelPool을 상속하면서 ** 최대 커넥션 수를 제한 **합니다. 프로덕션에서 주로 사용하는 구현체입니다.

JAVA
FixedChannelPool pool = new FixedChannelPool(
    bootstrap,
    poolHandler,
    10                          // 최대 커넥션 수
);

// 또는 세부 옵션 지정
FixedChannelPool pool = new FixedChannelPool(
    bootstrap,
    poolHandler,
    ChannelHealthChecker.ACTIVE, // 헬스체크: 채널이 active인지 확인
    FixedChannelPool.AcquireTimeoutAction.FAIL, // 타임아웃 시 실패 처리
    5000,                        // 획득 대기 타임아웃 (ms)
    10,                          // 최대 커넥션 수
    Integer.MAX_VALUE            // 최대 대기 큐 크기
);

두 풀의 차이를 정리하면 이렇습니다.

항목SimpleChannelPoolFixedChannelPool
최대 커넥션 수제한 없음설정 가능
대기 큐없음있음 (풀이 가득 차면 대기)
타임아웃없음획득 대기 타임아웃 설정 가능
헬스체크기본 제공기본 제공 + 커스텀 가능
사용 시나리오단순 테스트, 프로토타입프로덕션 환경

ChannelPoolHandler — 풀 라이프사이클 콜백

ChannelPoolHandler는 커넥션이 풀에서 생성/획득/반납될 때 호출되는 콜백을 정의합니다. 파이프라인 초기화, 상태 초기화, 모니터링 등을 여기서 처리합니다.

JAVA
public class MyChannelPoolHandler implements ChannelPoolHandler {

    @Override
    public void channelCreated(Channel ch) throws Exception {
        // 새 커넥션이 생성될 때 한 번 호출
        // 파이프라인 초기화는 여기서!
        ch.pipeline().addLast(
            new HttpClientCodec(),
            new HttpObjectAggregator(1048576),
            new HttpClientHandler()
        );
        System.out.println("새 커넥션 생성: " + ch.remoteAddress());
    }

    @Override
    public void channelAcquired(Channel ch) throws Exception {
        // 풀에서 커넥션을 꺼낼 때마다 호출
        // (새로 생성된 것이든, 반납 후 재사용하는 것이든)
        System.out.println("커넥션 획득: " + ch.remoteAddress());
    }

    @Override
    public void channelReleased(Channel ch) throws Exception {
        // 커넥션을 풀에 반납할 때 호출
        // 상태 초기화(예: 이전 요청의 잔여 데이터 정리)
        ch.pipeline().fireUserEventTriggered("RESET");
        System.out.println("커넥션 반납: " + ch.remoteAddress());
    }
}

세 콜백의 호출 시점을 도식화하면 이렇습니다.

PLAINTEXT
풀에 여유 커넥션 없음


channelCreated()  ──▶  channelAcquired()  ──▶  사용  ──▶  channelReleased()
(파이프라인 초기화)      (꺼낼 때)                          (반납)


                                                      풀에 보관

풀에 여유 커넥션 있음                                        │
    │                                                       │
    ▼                                                       │
channelAcquired()  ◀────────────────────────────────────────┘
(재사용)

channelCreated()에서 파이프라인을 설정하지 않으면, 풀에서 꺼낸 Channel에 핸들러가 없어서 아무 동작도 하지 않는다. Bootstrap의 handler()ChannelPoolHandler.channelCreated()에서 중복으로 파이프라인을 설정하지 않도록 주의해야 한다.


실전: HTTP 클라이언트 커넥션 풀

HTTP/1.1의 Keep-Alive를 활용하여 커넥션을 재사용하는 HTTP 클라이언트 풀을 구현해 봅니다.

JAVA
public class HttpConnectionPool {

    private final FixedChannelPool pool;
    private final EventLoopGroup group;

    public HttpConnectionPool(String host, int port, int maxConnections) {
        this.group = new NioEventLoopGroup();

        Bootstrap bootstrap = new Bootstrap()
            .group(group)
            .channel(NioSocketChannel.class)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
            .option(ChannelOption.SO_KEEPALIVE, true)
            .remoteAddress(host, port);

        this.pool = new FixedChannelPool(
            bootstrap,
            new HttpPoolHandler(),
            ChannelHealthChecker.ACTIVE,               // 헬스체크
            FixedChannelPool.AcquireTimeoutAction.FAIL, // 타임아웃 시 실패
            10_000,                                     // 획득 대기 10초
            maxConnections,                             // 최대 커넥션 수
            Integer.MAX_VALUE                           // 대기 큐 무제한
        );
    }

    /**
     * HTTP 요청 전송 — 풀에서 커넥션을 꺼내고, 응답 받은 후 반납
     */
    public void sendRequest(FullHttpRequest request,
                            Consumer<FullHttpResponse> callback) {
        pool.acquire().addListener((FutureListener<Channel>) future -> {
            if (!future.isSuccess()) {
                System.out.println("커넥션 획득 실패: " + future.cause().getMessage());
                return;
            }

            Channel ch = future.getNow();

            // 응답 처리 핸들러를 동적으로 추가
            ch.pipeline().addLast("responseHandler",
                new SimpleChannelInboundHandler<FullHttpResponse>() {
                    @Override
                    protected void channelRead0(ChannelHandlerContext ctx,
                                                FullHttpResponse response) {
                        try {
                            callback.accept(response);
                        } finally {
                            // 핸들러 제거 후 풀에 반납
                            ctx.pipeline().remove(this);
                            pool.release(ch);
                        }
                    }

                    @Override
                    public void exceptionCaught(ChannelHandlerContext ctx,
                                                Throwable cause) {
                        cause.printStackTrace();
                        ctx.pipeline().remove(this);
                        // 에러 발생 시에도 반드시 반납 (또는 닫기)
                        pool.release(ch);
                    }
                });

            ch.writeAndFlush(request);
        });
    }

    public void shutdown() {
        pool.close();  // 풀 내 모든 커넥션 정리
        group.shutdownGracefully();
    }
}

풀 사이즈 튜닝 가이드

커넥션 풀 크기는 워크로드에 따라 달라지지만, 기본적인 가이드라인이 있습니다.

PLAINTEXT
적정 풀 크기 ≈ 동시 요청 수 × 평균 응답 시간(초)

예) 동시 100 요청, 평균 응답 50ms
    → 100 × 0.05 = 5개면 이론적으로 충분
    → 버스트 트래픽 대비 2~3배 여유 → 10~15개
풀 크기영향
너무 작음대기 큐에 요청이 쌓이고, 타임아웃 발생
** 적절함**커넥션 재사용률 높음, 응답 지연 최소
** 너무 큼**유휴 커넥션이 서버 리소스(파일 디스크립터, 메모리)를 낭비

헬스체크 커스터마이징

ChannelHealthChecker.ACTIVE는 단순히 channel.isActive()만 확인합니다. 프로덕션에서는 더 정교한 헬스체크가 필요할 수 있습니다.

JAVA
// 커스텀 헬스체크: active 여부 + 마지막 사용 시간 확인
ChannelHealthChecker healthChecker = channel -> {
    // Channel이 active가 아니면 건강하지 않음
    if (!channel.isActive()) {
        return channel.eventLoop().newSucceededFuture(false);
    }

    // 마지막 사용 시간이 60초 이상이면 건강하지 않음 (stale 커넥션 방지)
    Attribute<Long> lastUsed = channel.attr(LAST_USED_KEY);
    Long timestamp = lastUsed.get();
    if (timestamp != null &&
        System.currentTimeMillis() - timestamp > 60_000) {
        return channel.eventLoop().newSucceededFuture(false);
    }

    return channel.eventLoop().newSucceededFuture(true);
};

재연결 + 풀 통합 패턴

실제 프로덕션에서는 재연결 로직과 커넥션 풀을 함께 사용하는 경우가 많습니다. FixedChannelPool은 내부적으로 커넥션이 끊기면 풀에서 제거하고 새 커넥션을 생성하지만, 서버 전체가 다운된 경우에는 별도의 재연결 전략이 필요합니다.

JAVA
public class ResilientConnectionPool {

    private final Bootstrap bootstrap;
    private final ChannelPoolHandler poolHandler;
    private volatile FixedChannelPool pool;
    private final ScheduledExecutorService scheduler =
        Executors.newSingleThreadScheduledExecutor();

    public ResilientConnectionPool(Bootstrap bootstrap,
                                    ChannelPoolHandler poolHandler,
                                    int maxConnections) {
        this.bootstrap = bootstrap;
        this.poolHandler = poolHandler;
        this.pool = createPool(maxConnections);

        // 주기적 헬스체크: 풀 상태 모니터링
        scheduler.scheduleAtFixedRate(this::checkPoolHealth, 30, 30, TimeUnit.SECONDS);
    }

    private FixedChannelPool createPool(int maxConnections) {
        return new FixedChannelPool(
            bootstrap, poolHandler,
            ChannelHealthChecker.ACTIVE,
            FixedChannelPool.AcquireTimeoutAction.FAIL,
            5000, maxConnections, Integer.MAX_VALUE
        );
    }

    private void checkPoolHealth() {
        // 풀에서 커넥션을 하나 획득해서 건강 상태 확인
        pool.acquire().addListener((FutureListener<Channel>) future -> {
            if (future.isSuccess()) {
                pool.release(future.getNow());
                System.out.println("풀 상태: 정상");
            } else {
                System.out.println("풀 상태: 비정상 — " + future.cause().getMessage());
            }
        });
    }
}

정리

개념핵심
** 자동 재연결**channelInactive()에서 감지, eventLoop.schedule()로 지연 재연결
Exponential Backoff재시도 간격을 1초→2초→4초→... 증가, 최대 대기 시간 설정 필수
Jitter여러 클라이언트의 재시도 시점 분산, 연결 폭풍 방지
Bootstrap 재사용ChannelInitializer가 새 Channel마다 파이프라인 자동 구성
SimpleChannelPool크기 제한 없는 기본 풀, 테스트용
FixedChannelPool최대 커넥션 수 제한, 대기 큐, 타임아웃 — 프로덕션용
ChannelPoolHandlerchannelCreated/Acquired/Released 콜백으로 라이프사이클 관리
** 헬스체크**ChannelHealthChecker로 stale 커넥션 감지, 커스텀 로직 적용 가능

다음 글에서는 Netty에서의 SSL/TLS 처리 를 다룰 예정입니다. SslHandler의 동작 원리와 파이프라인에서의 위치, 인증서 설정까지 정리합니다.

댓글 로딩 중...