재연결 & Connection 관리
클라이언트가 서버에 연결했다. 데이터가 잘 오가고 있다. 그런데 갑자기 서버가 재시작되거나, 네트워크가 순간적으로 끊기면? 연결은 그냥 끊어지고, 클라이언트는 아무것도 모른 채 멈춰 있게 된다. 이 상황을 어떻게 감지하고, 자동으로 복구할 수 있을까?
이번 글에서는 Netty 클라이언트의 자동 재연결 전략 과 커넥션 풀링 을 다룹니다. channelInactive()에서 재연결을 트리거하는 방법, Exponential Backoff로 재시도 간격을 조절하는 패턴, 그리고 ChannelPool을 활용해 여러 커넥션을 효율적으로 관리하는 방법까지 정리합니다.
왜 자동 재연결이 필요한가
네트워크 프로그래밍에서 연결이 끊기는 건 예외가 아니라 일상 입니다. 원인은 다양합니다.
- 네트워크 불안정 — WiFi 전환, 일시적 패킷 손실, 라우터 재부팅
- ** 서버 재시작** — 배포, 패치, 스케일링으로 인한 계획된 재시작
- ** 일시적 장애** — 서버 과부하, GC 정지, 방화벽 타임아웃
클라이언트가 이런 상황에서 ** 수동 개입 없이 자동으로 연결을 복구 **하지 못하면, 서비스 전체가 멈출 수 있습니다. 특히 마이크로서비스 환경에서는 서비스 간 통신이 끊기면 연쇄 장애로 이어지기도 합니다.
자동 재연결은 "있으면 좋은 기능"이 아니라, 프로덕션 환경에서는 ** 반드시 구현해야 하는 기본 요구사항 **이다.
channelInactive()에서 재연결 트리거
Netty에서 연결이 끊기면 ChannelInboundHandler의 channelInactive()가 호출됩니다. 이 콜백이 바로 재연결을 시작하기 가장 좋은 지점입니다.
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()— 파이프라인의 다음 핸들러에도 연결 종료 이벤트를 전파합니다. 이걸 빼먹으면 다른 핸들러가 연결 종료를 감지하지 못합니다.
연결 끊김 → channelInactive() 호출
│
▼
eventLoop.schedule(3초 후)
│
▼
bootstrap.connect() 시도
┌────┴────┐
성공 실패
│ │
정상 동작 새 채널의 channelInactive()에서
다시 재연결 시도
Exponential Backoff
3초 고정 간격으로 재시도하는 건 단순하지만 문제가 있습니다. 서버가 장시간 다운된 경우, 수천 개의 클라이언트가 ** 동시에 3초마다 연결을 시도 **하면 서버가 복구되는 순간 연결 폭풍(connection storm)이 발생합니다.
Exponential Backoff 는 재시도 간격을 점점 늘려가는 전략입니다.
1차 시도: 1초 후
2차 시도: 2초 후
3차 시도: 4초 후
4차 시도: 8초 후
5차 시도: 16초 후
6차 시도: 30초 후 (최대 대기 시간 도달)
7차 시도: 30초 후
...
구현은 간단합니다.
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 를 추가하면 재시도 시점을 분산시킬 수 있습니다.
// 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를 사용하는 것이 중요합니다.
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
가장 기본적인 커넥션 풀입니다. 크기 제한 없이 커넥션을 관리합니다.
// 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을 상속하면서 ** 최대 커넥션 수를 제한 **합니다. 프로덕션에서 주로 사용하는 구현체입니다.
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 // 최대 대기 큐 크기
);
두 풀의 차이를 정리하면 이렇습니다.
| 항목 | SimpleChannelPool | FixedChannelPool |
|---|---|---|
| 최대 커넥션 수 | 제한 없음 | 설정 가능 |
| 대기 큐 | 없음 | 있음 (풀이 가득 차면 대기) |
| 타임아웃 | 없음 | 획득 대기 타임아웃 설정 가능 |
| 헬스체크 | 기본 제공 | 기본 제공 + 커스텀 가능 |
| 사용 시나리오 | 단순 테스트, 프로토타입 | 프로덕션 환경 |
ChannelPoolHandler — 풀 라이프사이클 콜백
ChannelPoolHandler는 커넥션이 풀에서 생성/획득/반납될 때 호출되는 콜백을 정의합니다. 파이프라인 초기화, 상태 초기화, 모니터링 등을 여기서 처리합니다.
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());
}
}
세 콜백의 호출 시점을 도식화하면 이렇습니다.
풀에 여유 커넥션 없음
│
▼
channelCreated() ──▶ channelAcquired() ──▶ 사용 ──▶ channelReleased()
(파이프라인 초기화) (꺼낼 때) (반납)
│
▼
풀에 보관
│
풀에 여유 커넥션 있음 │
│ │
▼ │
channelAcquired() ◀────────────────────────────────────────┘
(재사용)
channelCreated()에서 파이프라인을 설정하지 않으면, 풀에서 꺼낸 Channel에 핸들러가 없어서 아무 동작도 하지 않는다. Bootstrap의handler()와ChannelPoolHandler.channelCreated()에서 중복으로 파이프라인을 설정하지 않도록 주의해야 한다.
실전: HTTP 클라이언트 커넥션 풀
HTTP/1.1의 Keep-Alive를 활용하여 커넥션을 재사용하는 HTTP 클라이언트 풀을 구현해 봅니다.
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();
}
}
풀 사이즈 튜닝 가이드
커넥션 풀 크기는 워크로드에 따라 달라지지만, 기본적인 가이드라인이 있습니다.
적정 풀 크기 ≈ 동시 요청 수 × 평균 응답 시간(초)
예) 동시 100 요청, 평균 응답 50ms
→ 100 × 0.05 = 5개면 이론적으로 충분
→ 버스트 트래픽 대비 2~3배 여유 → 10~15개
| 풀 크기 | 영향 |
|---|---|
| 너무 작음 | 대기 큐에 요청이 쌓이고, 타임아웃 발생 |
| ** 적절함** | 커넥션 재사용률 높음, 응답 지연 최소 |
| ** 너무 큼** | 유휴 커넥션이 서버 리소스(파일 디스크립터, 메모리)를 낭비 |
헬스체크 커스터마이징
ChannelHealthChecker.ACTIVE는 단순히 channel.isActive()만 확인합니다. 프로덕션에서는 더 정교한 헬스체크가 필요할 수 있습니다.
// 커스텀 헬스체크: 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은 내부적으로 커넥션이 끊기면 풀에서 제거하고 새 커넥션을 생성하지만, 서버 전체가 다운된 경우에는 별도의 재연결 전략이 필요합니다.
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 | 최대 커넥션 수 제한, 대기 큐, 타임아웃 — 프로덕션용 |
| ChannelPoolHandler | channelCreated/Acquired/Released 콜백으로 라이프사이클 관리 |
| ** 헬스체크** | ChannelHealthChecker로 stale 커넥션 감지, 커스텀 로직 적용 가능 |
다음 글에서는 Netty에서의 SSL/TLS 처리 를 다룰 예정입니다. SslHandler의 동작 원리와 파이프라인에서의 위치, 인증서 설정까지 정리합니다.