안동민 개발노트

본문 시작

디버거·스레드 덤프·프로파일러

원인을 버린 예외의 진단 손실을 재현하고 상태·CPU·할당·락 가설에 맞는 근거와 도구를 선택해 재현 가능한 결론을 만듭니다.

도구를 먼저 켜면 많은 데이터 속에서 눈에 띄는 항목을 원인으로 오해하기 쉽습니다.

증상, 시간 범위, 영향을 받은 요청, 예상 동작을 적고 “CPU 사용이 집중된 메서드”, “락 대기”, “할당 압력”, “I/O 지연”처럼 반증 가능한 가설을 만듭니다.

디버거는 한 실행의 상태, 스레드 덤프는 대기 관계, 프로파일러와 JFR은 시간에 따른 분포를 보여 줍니다.


예외 재생성과 원인 손실

아래 경계 코드는 최초의 NumberFormatException을 버리고 새 예외만 던집니다.

원문에서 새 예외에 원인을 연결하지 않으므로 getCause()는 null이고 비교식은 true가 됩니다.

lab/DiscardedExceptionCauseBug.java
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)으로 원인을 보존하고 민감한 입력은 가립니다.

스택 추적, 요청 식별자, 버전, 연산을 함께 남겨 재현 경로를 좁힙니다.


가설·반증 조건의 구조화

장애 분석 메모에 “느리다”라고만 쓰지 않고 지표와 근거 자료를 기록합니다.

다음 모델은 가설을 확인하거나 기각하려면 무엇이 필요한지 명시하게 합니다.

src/DiagnosticHypothesisModel.java
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 상태인 스레드와 락 소유자, 대기 큐를 찾습니다.

src/ThreadStateEvidence.java
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();
    }
}
진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다

ThreadStateEvidence의 waiting 신호와 release.await 진입 사이의 가능한 실행 순서를 비교합니다.

진입 신호를 받았어도 작업자가 이미 대기 중이라고 확정할 수 없다
정보를 읽는 시점의 작업자가능한 상태waiting.await()가 알려 주는 것
countDown 뒤, release.await 진입 전RUNNABLE 등을 관측할 수 있음진입 신호를 보낸 지점까지 진행
release.await에서 실제 대기 중WAITING을 관측할 수 있음release 대기 진입을 확인한 신호는 아님
countDown 뒤, release.await 진입 전
가능한 상태: RUNNABLE 등을 관측할 수 있음
waiting.await()가 알려 주는 것: 진입 신호를 보낸 지점까지 진행
release.await에서 실제 대기 중
가능한 상태: WAITING을 관측할 수 있음
waiting.await()가 알려 주는 것: release 대기 진입을 확인한 신호는 아님

두 행은 코드가 허용하는 상황이며 새 실행 관측이 아닙니다. getThreadInfo의 상태·스택은 그 호출 시점의 스냅샷입니다.

원문은 빈 스택을 처리하지 않고 getStackTrace()[0]을 읽으며, 래치 해제를 finally로 보장하지 않습니다. 정보 처리 중 예외가 나면 작업자가 해제되지 않은 채 남을 수 있다는 한계를 가진 관찰 예제입니다.

한 번의 덤프에 나타난 WAITING은 정상적인 유휴 상태일 수 있습니다.

여러 덤프에서 같은 요청 스레드의 대기와 락 소유자 스택이 지속되는지 확인합니다. ThreadMXBean의 관리 범위는 플랫폼 스레드이며 가상 스레드가 포함된 덤프에는 jcmd Thread.dump_to_file 등 별도 경로를 사용합니다.


CPU 시간과 경과 시간 분리

I/O나 락을 기다린 연산은 경과 시간이 길어도 스레드 CPU 시간은 짧습니다.

현재 스레드의 CPU 시간 측정을 지원하는 런타임에서 두 값을 비교합니다.

src/CpuAndWallTimeEvidence.java
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 부하 가설은 실행 표본 등과 함께 확인합니다.


원인과 문맥을 보존한 경계

수정 구현은 원인 타입·스택을 유지하고 연산 문맥을 추가합니다.

app/DiagnosticExceptionBoundary.java
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로 첫 진단 동작을 반환하세요.

해설 보기
exercise/DiagnosticFirstActionSolution.java
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));
        }
    }
}

종료 기준은 네 가지 증상이 빠짐없이 서로 다른 근거로 연결되는 것입니다.

다음 단계에서는 관찰 결과에 따라 가설을 갱신합니다.