← all posts
DEV 2026.05.02 · 13 min read Intermediate

Java 동시성 버그는 왜 재현이 어려운가

데드락 4가지 조건부터 Lock Contention 진단, Virtual Thread와 Spring 어노테이션의 스레드 모델 오해까지, Java 동시성 버그의 구조적 원인을 추적한다.


Java 동시성 버그는 세 가지 공통점을 가진다 — 간헐적으로 발생하고, 재현이 어렵고, 증상만 보면 원인을 알 수 없다. 데드락은 CPU 0%에 서버가 멈추고, 기아는 특정 요청만 느려지고, Lock Contention은 CPU도 낮은데 처리량도 낮다. 왜 이 버그들은 구조적으로 다르게 보이는가?

데드락: 순환 대기의 필요충분조건

데드락이 발생하려면 네 가지 조건이 동시에 성립해야 한다. 상호 배제(하나의 스레드만 락을 독점), 점유 대기(락을 보유한 채 추가 락 대기), 비선점(락을 강제로 빼앗을 수 없음), 순환 대기(스레드들이 원형으로 자원을 기다림). 이 중 하나라도 없애면 데드락은 발생하지 않는다.

가장 실용적인 예방책은 락 획득 순서 통일이다. 시스템 전체에서 항상 lock1 → lock2 순서로만 획득하면 순환 대기 조건 자체가 성립하지 않는다.

static void transfer(BankAccount from, BankAccount to, int amount) {
    BankAccount first  = from.id < to.id ? from : to;
    BankAccount second = from.id < to.id ? to : from;
    first.lock.lock();
    try {
        second.lock.lock();
        try {
            from.balance -= amount;
            to.balance   += amount;
        } finally { second.lock.unlock(); }
    } finally { first.lock.unlock(); }
}

데드락이 발생했을 때는 jstack <pid> 또는 jcmd <pid> Thread.print로 Thread Dump를 수집한다. "Found one Java-level deadlock:" 섹션이 있으면 JVM이 자동 분석한 결과다. waiting to lock(대기)과 locked(보유)를 따라가면 순환 사이클을 직접 확인할 수 있다.

데드락 vs 라이브락

데드락은 스레드가 BLOCKED 상태로 CPU 0%다. 라이브락은 스레드가 RUNNABLE로 CPU를 소비하지만 아무것도 완료하지 못한다. tryLock으로 상호 양보를 구현할 때, 두 스레드가 동시에 재시도하면 라이브락이 된다. 지수 백오프와 랜덤 딜레이가 필요한 이유다.

Lock Contention: 암달의 법칙이 보이는 곳

Lock Contention은 데드락처럼 서버가 멈추지 않는다. 대신 처리량이 이상하게 낮다. 스레드가 BLOCKED 상태로 락을 기다리는 동안에는 CPU를 소비하지 않으므로, CPU 낮음 + 처리량 낮음 + 응답 지연이 함께 보이면 Lock Contention을 의심해야 한다.

원인은 암달의 법칙에 있다. 전체 작업 시간 중 10%만 synchronized 블록 안에 있어도, 16스레드 시스템의 처리량 상한은 약 6.4배로 제한된다.

최대 가속=1(1p)+pN\text{최대 가속} = \frac{1}{(1 - p) + \frac{p}{N}}

p=0.9p = 0.9 (병렬화 가능 비율), N=16N = 16이면 6.4\approx 6.4배. 락 내부가 짧을수록 가속이 커진다.

진단은 JFR이 가장 정확하다.

jcmd <pid> JFR.start duration=60s settings=profile filename=/tmp/contention.jfr
jfr print --events jdk.JavaMonitorEnter /tmp/contention.jfr | head -200

jdk.JavaMonitorEnter 이벤트의 누적 시간을 클래스별로 정렬하면 경합 핫스팟이 드러난다. 해결 방향은 세 가지다 — synchronized 범위 최소화, Lock Striping(여러 독립 락으로 분산), ConcurrentHashMap으로 교체(버킷 레벨 자동 스트라이핑).

공정성: Fair Lock이 항상 정답이 아닌 이유

Non-Fair Lock의 기본 동작에서 새로 도착한 스레드는 큐에서 대기 중인 스레드보다 락을 먼저 획득할 수 있다(Barging). 고트래픽 환경에서 특정 스레드가 수십 ms 이상 락을 얻지 못하면 그 스레드가 처리하는 요청만 계속 지연된다 — 이것이 기아(Starvation)다.

ReentrantLock(true)(Fair Lock)는 FIFO 순서를 보장해 기아를 제거하지만, 처리량이 약 5배 감소한다. park → unpark 사이클이 모든 락 획득마다 강제된다.

트레이드오프

SLA가 전체 처리량(throughput) 기준이면 Non-Fair Lock이 유리하다. SLA가 p99/p999 응답 시간 기준이면 Fair Lock 또는 tryLock(timeout)이 필요하다. 작업 큐 기반 설계(BlockingQueue + 단일 소비자)는 구조적으로 기아를 없앤다.

비동기 모델: CompletableFuture와 Virtual Thread의 차이

“CompletableFuture로 이미 비동기 처리 중이면 최적화된 것 아닌가?”라는 질문이 자주 나온다. 아니다. CompletableFuture.supplyAsync()는 ForkJoinPool의 OS 스레드에서 실행되고, I/O 대기 중에도 그 스레드를 점유한다. 스레드 풀 크기가 동시 처리 수의 상한이 된다.

Virtual Thread는 다르다. JVM이 블로킹 I/O를 감지하면 VT를 캐리어 스레드에서 Unmount하고 캐리어를 해방한다. I/O 응답이 오면 다시 Mount해 실행을 재개한다. 1000개 VT가 동시에 DB 쿼리를 시작해도 캐리어 스레드(CPU 코어 수)만 있으면 된다.

// Virtual Thread: 기존 동기 코드 그대로
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user   = scope.fork(() -> userRepo.findById(userId));
    var orders = scope.fork(() -> orderRepo.findByUserId(userId));
    scope.join().throwIfFailed();
    return new UserProfile(user.get(), orders.get());
}

WebFlux(Reactor)는 EventLoop + Non-Blocking I/O로 메모리 효율이 가장 높고 역압력(Backpressure)을 지원하지만, JDBC 같은 블로킹 드라이버를 이벤트 루프 스레드에서 호출하면 모든 요청이 차단된다. 러닝 커브와 스택 트레이스 가독성도 세 방식 중 가장 낮다.

Spring 어노테이션이 숨긴 스레드 가정

@TransactionalThreadLocal에 트랜잭션 컨텍스트를 저장한다. 새 스레드를 생성하면 그 스레드는 다른 ThreadLocal 공간을 가지므로 부모 트랜잭션을 상속받지 못한다. @Transactional 메서드 안에서 new Thread(() -> inventoryService.deduct(...)).start()를 호출하면 주문 저장이 롤백돼도 재고 차감은 롤백되지 않는다.

@Async의 기본 executor는 SimpleAsyncTaskExecutor로, 호출마다 새 OS 스레드를 생성한다. 초당 수천 번 호출하면 OOM이 발생한다. ThreadPoolTaskExecutor를 명시적으로 설정하거나 Spring Boot 3.2+에서 spring.threads.virtual.enabled=true로 VT executor를 활성화해야 한다.

싱글톤 Bean의 인스턴스 변수는 모든 요청 스레드가 공유한다. private int count = 0에서 count++는 읽기-증가-쓰기 세 단계로, 원자적이지 않다. AtomicInteger, LongAdder, 또는 상태 없는 설계(메서드 로컬 변수만 사용)가 해결책이다.

정리

  • 데드락은 락 획득 순서 통일 하나로 순환 대기 조건을 제거할 수 있다. 발생 후엔 jstack"Found N Java-level deadlock" 섹션을 읽는다.
  • Lock Contention은 JFR의 jdk.JavaMonitorEnter 이벤트로 경합 핫스팟을 찾고, synchronized 범위 최소화와 ConcurrentHashMap으로 해결한다.
  • Fair Lock은 기아를 제거하지만 처리량을 5배 낮춘다. SLA 기준에 따라 선택한다.
  • Virtual Thread는 기존 동기 코드를 유지하면서 I/O 바운드 병목을 해소한다. JDBC 그대로 쓸 수 있다.
  • @Transactional은 ThreadLocal 기반이므로 새 스레드와 트랜잭션을 공유할 수 없다. @Async의 기본 executor는 스레드를 무한 생성한다.

다음 글에서는 Virtual Thread의 Pinning 문제 — synchronized 블록 안에서 I/O를 호출할 때 캐리어가 Unmount되지 않는 조건과 진단 방법을 추적한다.