Netty 메트릭 & 관찰성 — Micrometer로 EventLoop 모니터링하기
Netty 서버가 가끔 응답이 느려지는데, CPU나 메모리는 정상처럼 보입니다 — EventLoop 내부에서 무슨 일이 벌어지고 있는지 어떻게 알 수 있을까요?
JVM 메트릭 대시보드에서 힙 메모리와 CPU 사용률은 멀쩡한데, 간헐적으로 응답이 느려지는 Netty 서버. 이런 상황을 겪어 보면 "내부를 들여다보고 싶다"는 생각이 간절해집니다. Netty는 자체 메모리 관리(ByteBuf 풀)와 자체 스레드 모델(EventLoop)을 사용하기 때문에, JVM 표준 메트릭만으로는 내부 상태를 파악할 수 없습니다. 이번 글에서는 Micrometer와 Prometheus를 활용해서 Netty 전용 메트릭을 수집하고, Grafana 대시보드로 시각화하는 방법을 정리합니다.
개념 정의 — Netty 관찰성이란
Netty 관찰성(Observability)은 EventLoop, ByteBuf, Channel 같은 Netty 고유 자원의 상태를 실시간으로 측정하고 시각화하는 것 입니다.
일반적인 관찰성이 메트릭(Metrics), 로그(Logs), 트레이스(Traces) 세 축으로 구성되는 것처럼, Netty 관찰성도 같은 프레임워크를 따릅니다. 다만 측정 대상이 다릅니다.
- **메트릭 **: EventLoop 큐 크기, ByteBuf 메모리, 활성 연결 수, 처리량
- ** 로그 **: 핸들러별 에러 로그, 연결/해제 이벤트 로그
- ** 트레이스 **: 요청이 파이프라인을 통과하는 경로와 각 핸들러의 소요 시간
이 글에서는 메트릭 수집에 집중합니다.
왜 Netty 전용 메트릭이 필요한가
JVM 메트릭만으로는 부족한 이유
Netty는 JVM 위에서 돌아가지만, 핵심 자원을 JVM 표준 API 바깥에서 관리합니다.
| 관찰 대상 | JVM 표준 메트릭 | Netty 전용 메트릭 |
|---|---|---|
| 메모리 | 힙 사용량 | Direct Memory, PooledByteBuf 사용량 |
| 스레드 | 스레드 수, 상태 | EventLoop 태스크 큐 크기, I/O 대기 비율 |
| 네트워크 | 없음 | 활성 연결 수, 메시지 처리량 |
| 에러 | 예외 수 (대략적) | 핸들러별 에러 카운트, 연결 실패 수 |
-XX:MaxDirectMemorySize로 제한한 Direct Memory가 가득 차도 JVM 힙 메트릭에는 아무런 변화가 없습니다. EventLoop의 태스크 큐가 수천 개씩 밀려도 스레드 상태는 RUNNABLE로 표시됩니다. 이런 블라인드 스팟을 메우는 것이 Netty 전용 메트릭의 역할입니다.
Netty 고유 지표 세 가지
공부하다 보니 Netty 모니터링에서 핵심은 결국 이 세 가지로 모입니다.
- EventLoop 태스크 큐 — 처리 지연의 직접적인 지표. 큐가 쌓이면 모든 I/O가 느려집니다.
- Direct Memory (ByteBuf 풀) — OOM의 전조. JVM 힙과는 별도로 관리되어 놓치기 쉽습니다.
- ** 활성 연결 수** — 부하의 직접적인 지표. 연결이 제대로 해제되지 않으면 릭의 신호입니다.
핵심 모니터링 지표
대시보드에 올려야 할 지표를 우선순위 순으로 정리합니다.
1. 활성 연결 수 (Active Connections)
현재 열려 있는 Channel 수입니다. channelActive에서 증가, channelInactive에서 감소하는 Gauge로 측정합니다. 연결 수가 해제 없이 계속 올라가면 연결 릭을 의심해야 합니다.
2. EventLoop 태스크 큐 크기 (Pending Tasks)
각 EventLoop의 pendingTasks() 값입니다. 이 값이 지속적으로 높으면 해당 EventLoop에 바인딩된 Channel들의 I/O 처리가 밀리고 있다는 뜻입니다. 핸들러에서 블로킹 작업을 하고 있거나, Channel 분배가 불균형할 수 있습니다.
3. ByteBuf 메모리 사용량
PooledByteBufAllocator의 Direct Memory와 Heap Memory 사용량입니다. Direct Memory가 MaxDirectMemorySize에 근접하면 OutOfDirectMemoryError가 발생합니다.
4. 메시지 처리량 (Messages/sec)
channelRead 호출 횟수를 Counter로 측정합니다. 초당 처리 메시지 수의 변화를 추적하면 트래픽 패턴을 파악할 수 있습니다.
5. 에러율 (Exceptions/sec)
exceptionCaught 호출 횟수입니다. 갑자기 급증하면 장애의 징후입니다. 예외 클래스별로 태그를 달아두면 원인 분석이 훨씬 수월합니다.
6. 응답 시간 분포
channelRead에서 처리 시작, write 완료까지의 시간을 Timer로 측정합니다. 평균보다는 p99, p95 같은 백분위수가 실제 사용자 경험을 더 잘 반영합니다.
Micrometer + Prometheus 연동
MetricsHandler 구현
Netty 파이프라인에 추가할 메트릭 수집 핸들러입니다. ChannelDuplexHandler를 확장해서 인바운드와 아웃바운드 이벤트를 모두 잡습니다.
import io.micrometer.core.instrument.*;
import io.netty.channel.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Netty 파이프라인에 추가하는 메트릭 수집 핸들러.
* 연결 수, 메시지 처리량, 에러율, 응답 시간을 측정한다.
*/
@ChannelHandler.Sharable
public class MetricsHandler extends ChannelDuplexHandler {
private final AtomicInteger activeConnections = new AtomicInteger(0);
private final Counter messageCounter;
private final Counter errorCounter;
private final Timer responseTimer;
// channelRead 시작 시간을 Channel에 저장하기 위한 키
private static final AttributeKey<Long> READ_START =
AttributeKey.valueOf("metrics.readStart");
public MetricsHandler(MeterRegistry registry) {
// 활성 연결 수 — Gauge
Gauge.builder("netty.connections.active", activeConnections, AtomicInteger::get)
.description("현재 활성 연결 수")
.register(registry);
// 메시지 처리 카운터
messageCounter = Counter.builder("netty.messages.received")
.description("수신한 메시지 수")
.register(registry);
// 에러 카운터
errorCounter = Counter.builder("netty.errors.total")
.description("발생한 예외 수")
.register(registry);
// 응답 시간 타이머
responseTimer = Timer.builder("netty.response.time")
.description("메시지 처리 소요 시간")
.publishPercentiles(0.5, 0.95, 0.99) // p50, p95, p99
.register(registry);
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
activeConnections.incrementAndGet();
ctx.fireChannelActive();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
activeConnections.decrementAndGet();
ctx.fireChannelInactive();
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
messageCounter.increment();
// 처리 시작 시간 기록
ctx.channel().attr(READ_START).set(System.nanoTime());
ctx.fireChannelRead(msg);
}
@Override
public void write(ChannelHandlerContext ctx, Object msg,
ChannelPromise promise) throws Exception {
Long startTime = ctx.channel().attr(READ_START).getAndSet(null);
if (startTime != null) {
// 처리 시간 기록
responseTimer.record(
System.nanoTime() - startTime,
java.util.concurrent.TimeUnit.NANOSECONDS
);
}
ctx.write(msg, promise);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
errorCounter.increment();
ctx.fireExceptionCaught(cause);
}
}
파이프라인에 추가할 때는 가장 앞쪽에 넣어야 모든 이벤트를 빠짐없이 잡을 수 있습니다.
// 서버 부트스트랩 설정
MetricsHandler metricsHandler = new MetricsHandler(meterRegistry);
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast("metrics", metricsHandler) // 가장 앞에 추가
.addLast("decoder", new MyDecoder())
.addLast("encoder", new MyEncoder())
.addLast("handler", new BusinessHandler());
}
});
@Sharable어노테이션이 있으므로 모든 Channel이 같은 인스턴스를 공유합니다. 상태를AtomicInteger와AttributeKey로 관리하는 이유가 여기에 있습니다.
ByteBuf 메모리 메트릭
PooledByteBufAllocator는 자체적으로 메트릭 API를 제공합니다. 이걸 Micrometer Gauge로 노출시키면 됩니다.
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocatorMetric;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
/**
* PooledByteBufAllocator의 메모리 사용량을 Micrometer에 등록한다.
*/
public class ByteBufMetrics {
public static void register(MeterRegistry registry) {
PooledByteBufAllocatorMetric metric =
PooledByteBufAllocator.DEFAULT.metric();
// Direct Memory 사용량
Gauge.builder("netty.bytebuf.memory.direct",
metric, PooledByteBufAllocatorMetric::usedDirectMemory)
.description("PooledByteBufAllocator가 사용 중인 Direct Memory (bytes)")
.baseUnit("bytes")
.register(registry);
// Heap Memory 사용량
Gauge.builder("netty.bytebuf.memory.heap",
metric, PooledByteBufAllocatorMetric::usedHeapMemory)
.description("PooledByteBufAllocator가 사용 중인 Heap Memory (bytes)")
.baseUnit("bytes")
.register(registry);
// Arena 수 (스레드별 메모리 풀)
Gauge.builder("netty.bytebuf.arenas.direct",
metric, PooledByteBufAllocatorMetric::numDirectArenas)
.description("Direct Arena 수")
.register(registry);
Gauge.builder("netty.bytebuf.arenas.heap",
metric, PooledByteBufAllocatorMetric::numHeapArenas)
.description("Heap Arena 수")
.register(registry);
}
}
한 가지 주의할 점이 있습니다. usedDirectMemory()는 풀에서 관리 중인 메모리만 반영합니다. Unpooled.directBuffer()로 풀 바깥에서 할당한 Direct Memory는 잡히지 않으므로, 코드에서 Unpooled 사용을 최소화하는 것이 정확한 모니터링의 전제 조건입니다.
EventLoop 큐 모니터링
EventLoop의 태스크 큐 크기는 SingleThreadEventExecutor.pendingTasks()로 조회할 수 있습니다. 다만 이 메서드를 EventLoop 외부 스레드에서 호출하면 정확하지 않을 수 있으므로, 주기적으로 샘플링하는 방식을 사용합니다.
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SingleThreadEventLoop;
import io.netty.util.concurrent.EventExecutor;
import io.micrometer.core.instrument.*;
import java.util.concurrent.*;
/**
* EventLoop별 pendingTasks를 주기적으로 수집하여 Micrometer에 보고한다.
*/
public class EventLoopMetrics {
public static void register(MeterRegistry registry,
EventLoopGroup group,
String groupName) {
int index = 0;
for (EventExecutor executor : group) {
if (executor instanceof SingleThreadEventLoop eventLoop) {
String idx = String.valueOf(index++);
// 각 EventLoop의 대기 중인 태스크 수
Gauge.builder("netty.eventloop.pending.tasks",
eventLoop, SingleThreadEventLoop::pendingTasks)
.tag("group", groupName)
.tag("index", idx)
.description("EventLoop 태스크 큐에 대기 중인 작업 수")
.register(registry);
}
}
}
}
사용할 때는 boss/worker 그룹을 각각 등록합니다.
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
EventLoopMetrics.register(meterRegistry, bossGroup, "boss");
EventLoopMetrics.register(meterRegistry, workerGroup, "worker");
EventLoop 큐가 지속적으로 100 이상이면 해당 EventLoop가 과부하 상태일 가능성이 높습니다. 핸들러에서 블로킹 작업을 하고 있지 않은지, 또는 Channel 분배가 한쪽으로 치우쳐 있지 않은지 확인해 보아야 합니다.
Reactor Netty 자동 메트릭
Spring WebFlux를 사용한다면 Netty 메트릭을 직접 구현할 필요가 없습니다. Reactor Netty가 Micrometer 연동을 내장하고 있어서, 설정 한 줄이면 됩니다.
# application.yml
management:
metrics:
enable:
reactor.netty: true
# Reactor Netty 전용 메트릭 활성화
spring:
reactor:
netty:
metrics: true
자동 수집되는 메트릭 목록
| 메트릭 이름 | 타입 | 설명 |
|---|---|---|
reactor.netty.tcp.server.data.received | DistributionSummary | 수신 바이트 수 |
reactor.netty.tcp.server.data.sent | DistributionSummary | 송신 바이트 수 |
reactor.netty.tcp.server.errors | Counter | 에러 수 |
reactor.netty.http.server.response.time | Timer | HTTP 응답 시간 |
reactor.netty.http.server.connections.active | Gauge | 활성 연결 수 |
reactor.netty.connection.provider.active.connections | Gauge | 커넥션 풀 활성 연결 |
reactor.netty.connection.provider.idle.connections | Gauge | 커넥션 풀 유휴 연결 |
reactor.netty.connection.provider.pending.connections | Gauge | 대기 중인 연결 요청 |
이렇게 보면 핵심 지표의 상당 부분이 자동으로 수집됩니다. 여기에 ByteBuf 메모리 메트릭과 EventLoop 큐 모니터링만 위에서 작성한 코드로 추가하면 거의 완벽한 관찰성을 확보할 수 있습니다.
Grafana 대시보드 설계
수집된 메트릭을 Grafana에서 어떻게 보여줄지 설계합니다. 핵심은 4개 패널 입니다.
패널 1: 활성 연결 수
연결 수의 추세를 보는 패널입니다. 갑작스러운 급증이나 해제 없이 우상향하는 패턴을 포착합니다.
# 활성 연결 수
netty_connections_active
# Reactor Netty 사용 시
reactor_netty_http_server_connections_active
패널 2: EventLoop 태스크 큐
EventLoop별 큐 크기를 라인 차트로 표시합니다. 특정 EventLoop만 높으면 Channel 분배 불균형입니다.
# EventLoop별 대기 태스크 수
netty_eventloop_pending_tasks{group="worker"}
# 전체 worker EventLoop의 평균
avg(netty_eventloop_pending_tasks{group="worker"})
# 최대값 — 가장 바쁜 EventLoop
max(netty_eventloop_pending_tasks{group="worker"})
패널 3: ByteBuf 메모리 사용량
Direct Memory와 Heap Memory를 함께 표시합니다. MaxDirectMemorySize 대비 사용률을 계산하면 더 직관적입니다.
# Direct Memory 사용량 (MB 단위)
netty_bytebuf_memory_direct / 1024 / 1024
# Heap Memory 사용량 (MB 단위)
netty_bytebuf_memory_heap / 1024 / 1024
# Direct Memory 사용률 (MaxDirectMemorySize가 512MB일 때)
netty_bytebuf_memory_direct / (512 * 1024 * 1024) * 100
패널 4: 에러율 & 처리량
에러 수와 메시지 처리량을 같은 패널에 겹쳐서 보면, 에러 급증과 처리량 변화의 상관관계를 한눈에 파악할 수 있습니다.
# 초당 메시지 처리량
rate(netty_messages_received_total[1m])
# 초당 에러 수
rate(netty_errors_total[1m])
# 에러율 (%)
rate(netty_errors_total[1m])
/ rate(netty_messages_received_total[1m]) * 100
알림 설정
메트릭을 수집하는 것만으로는 부족합니다. 핵심 지표에 알림을 걸어 두어야 장애를 빠르게 감지할 수 있습니다.
EventLoop 큐 과부하
# Prometheus Alert Rule
- alert: NettyEventLoopQueueHigh
expr: max(netty_eventloop_pending_tasks{group="worker"}) > 1000
for: 2m
labels:
severity: warning
annotations:
summary: "EventLoop 태스크 큐가 1000을 초과했습니다"
description: "worker EventLoop 큐가 2분 이상 1000 이상입니다. 핸들러 블로킹 여부를 확인하세요."
임계치를 1000으로 잡은 이유는, 정상적인 Netty 서버에서 EventLoop 큐는 보통 한 자릿수를 유지하기 때문입니다. 100을 넘어가면 주의, 1000을 넘어가면 확실히 문제가 있습니다.
Direct Memory 고갈
- alert: NettyDirectMemoryHigh
expr: netty_bytebuf_memory_direct / (512 * 1024 * 1024) * 100 > 80
for: 5m
labels:
severity: critical
annotations:
summary: "Direct Memory 사용률이 80%를 초과했습니다"
description: "ByteBuf 릭이 발생했거나, 트래픽 급증으로 메모리가 부족합니다."
512 * 1024 * 1024부분은 실제-XX:MaxDirectMemorySize값으로 교체해야 합니다.
에러율 급증
- alert: NettyErrorRateSpike
expr: rate(netty_errors_total[5m]) > 10
for: 1m
labels:
severity: warning
annotations:
summary: "Netty 에러율이 급증했습니다"
description: "최근 5분간 초당 10회 이상의 예외가 발생하고 있습니다."
알림을 설정할 때 중요한 점은 for 절입니다. 순간적인 스파이크로 불필요한 알림이 울리지 않도록 일정 시간 지속될 때만 발동하게 합니다.
정리
Netty 관찰성의 핵심을 다시 짚으면 이렇습니다.
- JVM 메트릭만으로는 부족합니다. Direct Memory, EventLoop 큐, 활성 연결 수는 Netty 전용 메트릭으로만 볼 수 있습니다.
- 4가지 핵심 지표 를 대시보드에 올리세요: 활성 연결 수, EventLoop 큐 크기, ByteBuf 메모리, 에러율.
- MetricsHandler 를 파이프라인 가장 앞에 추가하면 연결 수, 처리량, 에러율, 응답 시간을 한 번에 수집할 수 있습니다.
- ByteBuf 메트릭 은
PooledByteBufAllocator.DEFAULT.metric()으로 접근합니다.Unpooled로 할당한 메모리는 잡히지 않으니 주의해야 합니다. - EventLoop 큐 가 지속적으로 높으면 핸들러 블로킹이나 Channel 불균형을 의심하세요.
- Reactor Netty 사용 시
spring.reactor.netty.metrics=true만 설정하면 대부분의 메트릭이 자동 수집됩니다. - ** 알림 **은 EventLoop 큐 > 1000, Direct Memory > 80%, 에러율 급증 세 가지를 기본으로 설정합니다.
처음부터 완벽한 대시보드를 만들 필요는 없습니다. 우선 4개 핵심 패널만 구성해두고, 운영하면서 필요한 지표를 하나씩 추가해 나가는 것이 현실적인 접근입니다.