기능을 잘 만드는 것과 운영 중 터진 문제를 빠르게 짚는 것은 다른 근육이에요. 이번 장은 관찰성(Observability)의 세 기둥과, "CPU 100%·메모리 누수·지연 급증·데드락" 같은 실전 증상을 증상 → 도구 → 원인으로 잇는 트러블슈팅 플레이북을 다룹니다. 시니어라면 반드시 손에 익혀야 할 마지막 관문이에요.
관찰성(Observability)은 시스템 외부로 나오는 신호만 보고 내부 상태를 추론할 수 있는 성질이에요. 그 신호를 세 종류로 나눈 게 세 기둥입니다 — 셋은 대체재가 아니라 서로 보완해요.
| 기둥 | 정체 | 언제 쓰나 |
|---|---|---|
| 로그(Logs) | 타임스탬프가 붙은 개별 이벤트의 상세 텍스트/구조체 | 특정 요청의 정확한 사정·스택트레이스·예외 원인을 파고들 때 |
| 메트릭(Metrics) | 일정 간격으로 집계된 수치 시계열(카운터·게이지·히스토그램) | 대시보드·알림(alert)·추세("언제부터 나빠졌나")를 볼 때. 저장 비용이 싸다 |
| 트레이스(Traces) | 하나의 요청이 여러 서비스를 거친 경로(span의 트리) | MSA에서 어느 구간이 병목인지, 요청이 어디서 실패했는지 짚을 때 |
level·timestamp·traceId·userId 같은 필드로 필터·집계가 가능해지죠.// 요청 진입점(필터/인터셉터)에서 상관관계 ID를 MDC에 심는다
String traceId = req.getHeader("X-Trace-Id");
if (traceId == null) traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId); // 이후 이 스레드의 모든 로그에 자동 첨부
try {
chain.doFilter(req, res);
} finally {
MDC.clear(); // 스레드 풀 재사용 → 반드시 정리!
}
스프링 부트 세계의 표준 계측 파사드는 Micrometer예요. 코드에서는 Micrometer API로 계측만 하고, 백엔드는 Prometheus·Datadog 등으로 갈아끼울 수 있죠("SLF4J의 메트릭 버전"). Prometheus가 /actuator/prometheus 엔드포인트를 주기적으로 pull(scrape)해 시계열로 저장합니다.
| 방법론 | 대상 | 보는 지표 |
|---|---|---|
| RED | 요청 기반 서비스(사용자가 보는 관점) | Rate(초당 요청) · Errors(에러율) · Duration(지연 분포) |
| USE | 리소스(CPU·메모리·디스크·풀) | Utilization(사용률) · Saturation(포화·대기큐) · Errors |
/user/{id})만 써야 해요. 고유 식별자는 레이블이 아니라 로그·트레이스로 넘깁니다.
// Micrometer: 타이머로 지연 분포(분위수 포함)를 계측
Timer timer = Timer.builder("http.server.requests")
.tag("uri", "/orders/{id}") // ✅ 템플릿(유한) — 카디널리티 안전
// .tag("orderId", orderId) // ❌ 고유값 — 카디널리티 폭발!
.publishPercentiles(0.95, 0.99) // p95·p99 산출
.register(registry);
timer.record(() -> handleRequest());
histogram_quantile()로 집계 시점에 분위수를 계산해요. 서비스별 SLO를 정확히 보려면 이 구조를 이해해야 합니다.MSA에서 요청 하나는 게이트웨이 → 주문 → 결제 → 재고처럼 여러 서비스를 넘나들어요. 로그만으론 "이 서비스가 느리다"까지가 한계죠. 분산 추적은 요청 전체를 하나로 꿰어 어느 구간이 병목·실패인지 시각화합니다.
| 개념 | 의미 |
|---|---|
| Trace | 하나의 요청이 시스템 전체를 지나간 전체 여정. 고유 traceId로 식별 |
| Span | 그 여정 안의 개별 작업 단위(예: "결제 API 호출", "DB 쿼리"). 시작·종료 시각·태그를 가짐 |
| 부모-자식 | span은 트리 구조. 상위 작업이 하위 작업을 호출하면 자식 span이 됨 |
| 컨텍스트 전파 | 서비스 경계를 넘을 때 HTTP 헤더(traceparent, W3C Trace Context)로 traceId를 전달 |
-javaagent:opentelemetry-javaagent.jar)를 붙이면 코드 수정 없이 자동 계측(HTTP·JDBC·메시징)이 되고, 수집한 데이터는 Jaeger·Tempo·Zipkin 같은 백엔드로 내보냅니다.
핵심은 서비스 경계를 넘어도 같은 traceId가 이어진다는 것. 그래서 게이트웨이에서 시작된 요청이 결제 서비스에서 500이 났을 때, 그 한 요청의 전 구간 타임라인을 한 화면에서 봅니다.
메트릭·트레이스가 "밖에서 본 신호"라면, 이제부터는 JVM 내부를 직접 뜯어보는 저수준 도구예요. 각각 진단하는 문제의 결이 다릅니다.
| 도구 | 뜨는 것 | 주 용도 |
|---|---|---|
스레드 덤프 (jstack <pid>) | 그 순간 모든 스레드의 스택·상태(RUNNABLE/BLOCKED/WAITING) | 데드락·블로킹 스레드 진단, "지금 스레드들이 어디서 멈춰있나" |
힙 덤프 (jmap -dump + MAT) | 그 순간 힙의 모든 객체 스냅샷 | 메모리 누수 분석 — MAT의 지배자 트리(dominator tree)로 누가 메모리를 붙잡는지 |
| JFR / async-profiler | 일정 시간 샘플링된 CPU·할당 프로파일 | 어느 메서드가 CPU·할당을 먹나(플레임 그래프), 저오버헤드 상시 프로파일 |
GC 로그 (-Xlog:gc*) | GC 빈도·pause 시간·회수량 이력 | 지연의 원인이 GC(특히 잦은 Full GC)인지 판별 |
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump를 걸면 OOM 순간의 힙이 자동 저장돼요(사후 부검용). 운영 상시 프로파일은 JFR(-XX:StartFlightRecording)이 오버헤드가 낮아 실전에서 많이 씁니다. async-profiler는 커널까지 포함한 플레임 그래프가 강력해요.장애 대응의 본질은 증상을 보고 올바른 도구를 즉시 집는 것이에요. 네 가지 대표 증상을 매핑해둡시다.
| ① 증상 | ② 첫 도구 | ③ 흔한 원인 |
|---|---|---|
| CPU 100% | 스레드 덤프(top -H로 바쁜 TID → 16진 변환 → jstack에서 매칭) | 무한 루프·바쁜 스핀·과도한 GC·정규식 백트래킹. 가장 CPU 먹는 스레드의 스택을 본다 |
| 메모리 누수 | 힙 덤프 + MAT(사용량이 계속 우상향하는지 추세 확인 후 덤프) | 제거 안 되는 컬렉션·캐시, ThreadLocal 미정리, 리스너 등록 후 해제 누락 → 지배자 트리로 범인 찾기 |
| 지연 급증 | 트레이스 + GC 로그 + 풀 지표 | 느린 다운스트림, 잦은 Full GC, 커넥션 풀/스레드 풀 고갈(대기 큐 포화) |
| 데드락 / 행(hang) | 스레드 덤프(jstack이 "Found one Java-level deadlock"을 직접 알려줌) | 두 스레드가 락을 서로 반대 순서로 잡음(순환 대기). BLOCKED 스레드가 어떤 락을 기다리는지 본다 |
getConnection·풀 대기에 WAITING이면 풀 사이즈·다운스트림을 의심하세요. "느림"의 원인이 CPU인지, GC인지, 대기(풀)인지부터 갈라야 합니다.