디버거·스레드 덤프·프로파일러
원인을 버린 예외의 진단 손실을 재현하고 상태·CPU·할당·락 가설에 맞는 근거와 도구를 선택해 재현 가능한 결론을 만듭니다.
도구를 먼저 켜면 많은 데이터 속에서 눈에 띄는 항목을 원인으로 오해하기 쉽습니다.
증상, 시간 범위, 영향을 받은 요청, 예상 동작을 적고 “CPU 사용이 집중된 메서드”, “락 대기”, “할당 압력”, “I/O 지연”처럼 반증 가능한 가설을 만듭니다.
디버거는 한 실행의 상태, 스레드 덤프는 대기 관계, 프로파일러와 JFR은 시간에 따른 분포를 보여 줍니다.
예외 재생성과 원인 손실
아래 경계 코드는 최초의 NumberFormatException을 버리고 새 예외만 던집니다.
원문에서 새 예외에 원인을 연결하지 않으므로 getCause()는 null이고 비교식은 true가 됩니다.
public final class DiscardedExceptionCauseBug {
static int parseViewCount(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("invalid view count: " + value);
}
}
public static void main(String[] args) {
try {
parseViewCount("forty");
} catch (IllegalArgumentException exception) {
System.out.println("message=" + exception.getMessage());
System.out.println("wrong-cause-null=" + (exception.getCause() == null));
}
}
}원칙은 추상화 경계에서 문맥을 추가하되 원인을 연결하는 것입니다.
new IllegalArgumentException(message, exception)으로 원인을 보존하고 민감한 입력은 가립니다.
스택 추적, 요청 식별자, 버전, 연산을 함께 남겨 재현 경로를 좁힙니다.
가설·반증 조건의 구조화
장애 분석 메모에 “느리다”라고만 쓰지 않고 지표와 근거 자료를 기록합니다.
다음 모델은 가설을 확인하거나 기각하려면 무엇이 필요한지 명시하게 합니다.
import java.time.Instant;
import java.util.List;
public final class DiagnosticHypothesisModel {
private enum Status {
OPEN,
CONFIRMED,
REJECTED
}
private record Hypothesis(
String symptom,
String claim,
List<String> evidenceNeeded,
String falsifier,
Instant startedAt,
Status status) {}
public static void main(String[] args) {
Hypothesis lock =
new Hypothesis(
"save p99 > 2s",
"threads wait on PostRepository monitor",
List.of("three thread dumps", "JFR JavaMonitorEnter", "request timestamps"),
"no blocked threads and monitor wait below 10ms",
Instant.parse("2026-07-14T10:00:00Z"),
Status.OPEN);
System.out.println(lock);
}
}상태를 CONFIRMED로 바꾸기 전에 대안 가설과 관찰 누락을 검토합니다.
상관관계가 원인인지 통제된 변경이나 재현 가능한 실험으로 확인합니다.
ThreadMXBean 대기 분석
다음 예제는 작업자의 진입 신호를 받은 뒤 스레드 정보를 읽고 해제 래치를 엽니다.
운영 환경에서는 jcmd <pid> Thread.print -l을 일정한 간격으로 여러 번 수집해 지속적으로 BLOCKED 상태인 스레드와 락 소유자, 대기 큐를 찾습니다.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;
public final class ThreadStateEvidence {
public static void main(String[] args) throws Exception {
CountDownLatch waiting = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread worker =
Thread.ofPlatform()
.name("board-waiter")
.start(
() -> {
waiting.countDown();
try {
release.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
});
waiting.await();
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
ThreadInfo info = threads.getThreadInfo(worker.threadId(), 8);
System.out.println("name=" + info.getThreadName());
System.out.println("state=" + info.getThreadState());
System.out.println("top=" + info.getStackTrace()[0]);
release.countDown();
worker.join();
}
}진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다
countDown 신호 뒤 release.await에 진입하기 전과 실제 대기 중의 두 가능한 snapshot을 구분합니다. 스케줄을 확정하지 않고 신호 수신이 WAITING을 보장하지 않는 이유를 단계로 드러낼 수 있습니다.
ThreadStateEvidence의 waiting 신호와 release.await 진입 사이의 가능한 실행 순서를 비교합니다.
진입 신호의 보장 범위와 별도의 대기 진입
진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다
waiting.countDown()은 release.await()보다 앞에 있습니다. main이 신호를 받아도 작업자가 실제 대기에 들어갔는지는 보장되지 않아, 상태를 한 번 읽고 WAITING으로 단정할 수 없습니다.
신호 전달과 뒤의 대기 진입 사이에는 순서 보장이 없다
작업자의 waiting.countDown은 release.await 앞이다. main의 waiting.await는 신호만 받으므로 main 조회와 작업자의 대기 진입 사이 순서는 정해지지 않는다. RUNNABLE과 WAITING은 가능한 순간 상태다.
상태별 상세 해설
가능한 스냅샷
countDown 뒤, release.await 진입 전
- 가능한 상태
- RUNNABLE 등을 관측할 수 있음
- waiting.await()가 알려 주는 것
- 진입 신호를 보낸 지점까지 진행
가능한 스냅샷
release.await에서 실제 대기 중
- 가능한 상태
- WAITING을 관측할 수 있음
- waiting.await()가 알려 주는 것
- release 대기 진입을 확인한 신호는 아님
두 행은 코드가 허용하는 상황이며 새 실행 관측이 아닙니다. getThreadInfo의 상태·스택은 그 호출 시점의 스냅샷입니다.
이 예의 조건
- 가능한 스냅샷이며 새 관측이 아닙니다. getThreadInfo는 호출 순간의 상태·스택을 제공합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 ThreadStateEvidence의 waiting 신호와 release.await 진입 사이의 가능한 실행 순서를 비교합니다. 진입 신호의 보장 범위와 별도의 대기 진입 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 waiting.countDown()은 release.await()보다 앞에 있습니다. main이 신호를 받아도 작업자가 실제 대기에 들어갔는지는 보장되지 않아, 상태를 한 번 읽고 WAITING으로 단정할 수 없습니다. 가능한 스냅샷 countDown 뒤, release.await 진입 전 가능한 상태 RUNNABLE 등을 관측할 수 있음 waiting.await()가 알려 주는 것 진입 신호를 보낸 지점까지 진행 가능한 스냅샷 release.await에서 실제 대기 중 가능한 상태 WAITING을 관측할 수 있음 waiting.await()가 알려 주는 것 release 대기 진입을 확인한 신호는 아님 두 행은 코드가 허용하는 상황이며 새 실행 관측이 아닙니다. getThreadInfo의 상태·스택은 그 호출 시점의 스냅샷입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 진입 신호 waiting.countDown이 release.await보다 먼저입니다. |
|
|
| 대기 전 조회 countDown 뒤 await 전 스냅샷일 수 있습니다. |
|
|
| 실제 대기 조회 release.await에 진입한 순간은 WAITING일 수 있습니다. |
|
|
진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다
countDown 신호 뒤 release.await에 진입하기 전과 실제 대기 중의 두 가능한 snapshot을 구분합니다. 스케줄을 확정하지 않고 신호 수신이 WAITING을 보장하지 않는 이유를 단계로 드러낼 수 있습니다.
ThreadStateEvidence의 waiting 신호와 release.await 진입 사이의 가능한 실행 순서를 비교합니다.
진입 신호의 보장 범위와 별도의 대기 진입
진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다
waiting.countDown()은 release.await()보다 앞에 있습니다. main이 신호를 받아도 작업자가 실제 대기에 들어갔는지는 보장되지 않아, 상태를 한 번 읽고 WAITING으로 단정할 수 없습니다.
신호 전달과 뒤의 대기 진입 사이에는 순서 보장이 없다
작업자의 waiting.countDown은 release.await 앞이다. main의 waiting.await는 신호만 받으므로 main 조회와 작업자의 대기 진입 사이 순서는 정해지지 않는다. RUNNABLE과 WAITING은 가능한 순간 상태다.
상태별 상세 해설
가능한 스냅샷
countDown 뒤, release.await 진입 전
- 가능한 상태
- RUNNABLE 등을 관측할 수 있음
- waiting.await()가 알려 주는 것
- 진입 신호를 보낸 지점까지 진행
가능한 스냅샷
release.await에서 실제 대기 중
- 가능한 상태
- WAITING을 관측할 수 있음
- waiting.await()가 알려 주는 것
- release 대기 진입을 확인한 신호는 아님
두 행은 코드가 허용하는 상황이며 새 실행 관측이 아닙니다. getThreadInfo의 상태·스택은 그 호출 시점의 스냅샷입니다.
이 예의 조건
- 가능한 스냅샷이며 새 관측이 아닙니다. getThreadInfo는 호출 순간의 상태·스택을 제공합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 ThreadStateEvidence의 waiting 신호와 release.await 진입 사이의 가능한 실행 순서를 비교합니다. 진입 신호의 보장 범위와 별도의 대기 진입 진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다 waiting.countDown()은 release.await()보다 앞에 있습니다. main이 신호를 받아도 작업자가 실제 대기에 들어갔는지는 보장되지 않아, 상태를 한 번 읽고 WAITING으로 단정할 수 없습니다. 가능한 스냅샷 countDown 뒤, release.await 진입 전 가능한 상태 RUNNABLE 등을 관측할 수 있음 waiting.await()가 알려 주는 것 진입 신호를 보낸 지점까지 진행 가능한 스냅샷 release.await에서 실제 대기 중 가능한 상태 WAITING을 관측할 수 있음 waiting.await()가 알려 주는 것 release 대기 진입을 확인한 신호는 아님 두 행은 코드가 허용하는 상황이며 새 실행 관측이 아닙니다. getThreadInfo의 상태·스택은 그 호출 시점의 스냅샷입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 진입 신호 waiting.countDown이 release.await보다 먼저입니다. |
|
|
| 대기 전 조회 countDown 뒤 await 전 스냅샷일 수 있습니다. |
|
|
| 실제 대기 조회 release.await에 진입한 순간은 WAITING일 수 있습니다. |
|
|
원문은 빈 스택을 처리하지 않고 getStackTrace()[0]을 읽으며, 래치 해제를 finally로 보장하지 않습니다. 정보 처리 중 예외가 나면 작업자가 해제되지 않은 채 남을 수 있다는 한계를 가진 관찰 예제입니다.
한 번의 덤프에 나타난 WAITING은 정상적인 유휴 상태일 수 있습니다.
예제 코드의 진입 신호는 작업자가 countDown()을 지났다는 뜻이지, 뒤의 await()에서 이미 멈췄다는 뜻은 아닙니다. 어느 순간을 읽었는지에 따라 다른 상태가 가능하므로 이 예제에서 특정 상태 한 개를 고정 정답으로 삼지 않습니다. ThreadMXBean API는 플랫폼 스레드의 상태와 CPU 시간에 관한 지원 범위도 구별합니다.
여러 덤프에서 같은 요청 스레드의 대기와 락 소유자 스택이 지속되는지 확인합니다. ThreadMXBean의 관리 범위는 플랫폼 스레드이며 가상 스레드가 포함된 덤프에는 jcmd Thread.dump_to_file 등 별도 경로를 사용합니다.
CPU 시간과 경과 시간 분리
I/O나 락을 기다린 연산은 경과 시간이 길어도 스레드 CPU 시간은 짧습니다.
현재 스레드의 CPU 시간 측정을 지원하는 런타임에서 두 값을 비교합니다.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.time.Duration;
public final class CpuAndWallTimeEvidence {
public static void main(String[] args) throws Exception {
ThreadMXBean threads = ManagementFactory.getThreadMXBean();
if (!threads.isCurrentThreadCpuTimeSupported()) {
System.out.println("cpu-time-unsupported");
return;
}
if (!threads.isThreadCpuTimeEnabled()) {
threads.setThreadCpuTimeEnabled(true);
}
long wallStart = System.nanoTime();
long cpuStart = threads.getCurrentThreadCpuTime();
Thread.sleep(Duration.ofMillis(20));
long checksum = 0;
for (int value = 0; value < 100_000; value++) {
checksum += (long) value * value;
}
long cpu = threads.getCurrentThreadCpuTime() - cpuStart;
long wall = System.nanoTime() - wallStart;
System.out.println("wall-ns=" + wall);
System.out.println("cpu-ns=" + cpu);
System.out.println("checksum=" + checksum);
}
}관찰값은 머신마다 달라 정확한 시간을 단언하지 않습니다. CPU 시간의 반환 단위가 나노초여도 나노초 정확도를 보장하지는 않습니다.
같은 스레드와 측정 구간에서 경과 시간이 CPU 시간보다 훨씬 길면 I/O·sleep()·락·스케줄링 대기를 후보로 삼습니다. 두 값의 차이만으로 원인을 특정하지 않으며, CPU 부하 가설은 실행 표본 등과 함께 확인합니다.
원인과 문맥을 보존한 경계
수정 구현은 원인 타입·스택을 유지하고 연산 문맥을 추가합니다.
원래 입력 "forty"는 정수 해석에서 실패합니다. 별도 입력 "0"은 해석에는 성공하지만 양수 검사에서 실패합니다. 두 경우의 가장 바깥 예외 타입과 getCause() 타입을 코드 실행 전에 각각 예상하면, 같은 경계 메시지가 어느 실패 단계를 감싸는지 구별할 수 있습니다.
public final class DiagnosticExceptionBoundary {
static int parseViewCount(String value) {
try {
int viewCount = Integer.parseInt(value);
if (viewCount <= 0) {
throw new IllegalArgumentException("not positive");
}
return viewCount;
} catch (RuntimeException exception) {
throw new IllegalArgumentException("parse board view count failed", exception);
}
}
public static void main(String[] args) {
try {
parseViewCount("forty");
} catch (IllegalArgumentException exception) {
System.out.println("context=" + exception.getMessage());
System.out.println("cause=" + exception.getCause().getClass().getSimpleName());
}
}
}증상별 도구 선택
- 잘못된 한 상태: 디버거 중단점, 조건부 중단점, 감시점을 사용합니다.
- 멈춤·락: 여러 스레드 덤프, 소유 가능한 동기화 도구, JFR 모니터 이벤트를 봅니다.
- CPU: JFR 실행 표본, 비동기 프로파일러 CPU 모드, 컴파일러 활동을 봅니다.
- 할당·GC: JFR 할당, GC 로그,
class히스토그램, 힙 덤프 보유 경로를 봅니다. - I/O: 소켓·파일 이벤트, 하위 시스템 지연 시간, 연결 풀 큐를 봅니다.
- 클래스·연결 오류: 클래스 적재 로그, 모듈 그래프,
javap디스크립터를 봅니다.
jcmd -l
jcmd <pid> Thread.print -l
jcmd <pid> JFR.start name=incident settings=profile duration=60s filename=incident.jfr
jfr summary incident.jfr수집 명령, PID·시작 시간, 배포 버전, 시간대를 장애 분석 기록에 넣습니다.
진단 명령도 오버헤드와 민감 데이터 위험이 있으므로 운영 절차서를 따릅니다.
불꽃 그래프의 가장 넓은 프레임이 원인인가요?
그 프레임은 표본 시간을 많이 차지했다는 근거일 뿐입니다.
정상적으로 가장 많은 일을 하는 메서드일 수도 있습니다.
요청 지연 시간과 시간 범위를 맞추고 입력, 호출 횟수, 락, 할당 근거로 가설을 확인합니다.
연습 문제
CPU, LOCK, MEMORY, IO 증상을 enum으로 받고 각 값을 빠짐없이 처리하는 switch로 첫 진단 동작을 반환하세요.
네 입력에 대해 필요한 첫 근거를 먼저 적습니다. 이어 요청이 느리고 한 번의 스냅샷에 WAITING이 보인 경우, 곧바로 교착 상태로 확정할 수 있는지와 어떤 반복 관찰이 더 필요한지도 설명합니다. 이 함수의 반환값은 조사 시작점이며 원인을 확정한 진단 결과가 아닙니다.
해설 보기
public final class DiagnosticFirstActionSolution {
private enum Symptom {
CPU,
LOCK,
MEMORY,
IO
}
static String firstAction(Symptom symptom) {
return switch (symptom) {
case CPU -> "JFR execution samples";
case LOCK -> "three thread dumps";
case MEMORY -> "GC log and class histogram";
case IO -> "socket events and downstream latency";
};
}
public static void main(String[] args) {
for (Symptom symptom : Symptom.values()) {
System.out.println(symptom + " -> " + firstAction(symptom));
}
}
}종료 기준은 네 가지 증상이 빠짐없이 서로 다른 근거로 연결되는 것입니다.
다음 단계에서는 관찰 결과에 따라 가설을 갱신합니다.