Java Deadlock은 어떤 상황에서 발생하는가
Java Deadlock은 둘 이상의 스레드가 서로가 가진 락을 기다리면서 더 이상 진행하지 못하는 상태를 말합니다. 보통 synchronized, ReentrantLock, 데이터베이스 트랜잭션, 외부 API 호출과 락이 섞이는 코드에서 자주 확인됩니다.
간단히 말하면 A 스레드는 lock1을 잡은 채 lock2를 기다리고, B 스레드는 lock2를 잡은 채 lock1을 기다리는 상황입니다. 서로 양보하지 않기 때문에 시간이 지나도 자연스럽게 풀리지 않습니다.
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void methodA() {
synchronized (lockA) {
synchronized (lockB) {
System.out.println("methodA 실행");
}
}
}
public void methodB() {
synchronized (lockB) {
synchronized (lockA) {
System.out.println("methodB 실행");
}
}
}
}
위 코드는 문법상 문제가 없습니다. 하지만 두 메서드가 서로 다른 스레드에서 동시에 실행되면 Deadlock이 발생할 수 있습니다. 문제는 코드만 봤을 때 항상 재현되는 오류가 아니라는 점입니다. 타이밍이 맞아야 발생하기 때문에 로컬 테스트에서는 조용하다가 운영 환경에서 갑자기 드러나는 경우가 있습니다.
Java Deadlock 발생 시 먼저 확인할 증상
Java Deadlock이 발생하면 애플리케이션 전체가 즉시 죽는 경우보다 특정 요청이나 특정 기능만 멈춘 것처럼 보이는 경우가 많습니다. CPU 사용률이 높지 않은데 요청이 끝나지 않거나, 특정 API의 응답만 계속 지연된다면 스레드 대기 상태를 의심해볼 수 있습니다.
실무에서는 이 지점에서 원인을 DB, 네트워크, 외부 API로 먼저 의심하기 쉽습니다. 물론 그 가능성도 확인해야 하지만, 스레드 덤프를 보지 않고 추측으로만 접근하면 시간이 길어집니다. Deadlock은 로그 한 줄로 명확하게 드러나지 않는 경우가 있기 때문에 스레드 상태를 직접 확인하는 과정이 중요합니다.
의심할 만한 대표 증상
요청은 들어오지만 처리가 끝나지 않습니다. 특정 배치 작업이 어느 지점 이후로 진행되지 않거나, 관리자 기능에서 저장 버튼을 누른 뒤 응답이 오지 않는 식으로 나타날 수 있습니다.
또한 애플리케이션 로그에는 에러가 없을 수도 있습니다. Deadlock은 예외가 발생해서 실패하는 흐름이 아니라, 서로 기다리는 상태로 멈춰 있는 흐름에 가깝기 때문입니다. 그래서 “에러 로그가 없으니 애플리케이션 문제는 아니다”라고 판단하면 원인 파악이 늦어질 수 있습니다.
Java Deadlock 원인 추적은 스레드 덤프부터 시작한다
Java Deadlock을 확인할 때 가장 먼저 볼 것은 스레드 덤프입니다. 스레드 덤프에는 각 스레드가 어떤 상태인지, 어떤 락을 가지고 있는지, 어떤 락을 기다리는지가 표시됩니다.
운영 환경에서는 프로세스 ID를 확인한 뒤 jstack을 사용해 스레드 덤프를 남길 수 있습니다. 컨테이너 환경이라면 컨테이너 안에서 JVM 프로세스를 확인해야 하며, 권한이나 JDK 설치 여부도 함께 확인해야 합니다.
jps -l
jstack -l <PID> > thread-dump.txt
스레드 덤프에서 Deadlock이 감지되면 보통 아래와 비슷한 문구가 포함됩니다. 이 문구가 보이면 단순한 지연이 아니라 실제 Deadlock 상태로 보는 것이 맞습니다.
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00000001
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00000002
which is held by "Thread-1"
여기서 중요한 것은 “어떤 스레드가 어떤 락을 기다리는지”입니다. 클래스명과 메서드명, 라인 번호를 기준으로 실제 코드 위치를 찾아야 합니다. 단순히 Thread-1, Thread-2라는 이름만 보고는 원인을 알 수 없습니다.
Java Deadlock을 유발하는 코드 패턴
Java Deadlock은 대부분 락 획득 순서가 일정하지 않을 때 발생합니다. 한쪽에서는 A 다음 B를 잡고, 다른 쪽에서는 B 다음 A를 잡으면 조건이 맞는 순간 서로 기다리게 됩니다.
락 획득 순서가 다른 경우
가장 흔한 패턴은 여러 개의 락을 사용하는데 순서가 통일되어 있지 않은 코드입니다. 이 경우 해결 방향은 비교적 명확합니다. 모든 코드에서 락을 잡는 순서를 하나로 맞춰야 합니다.
public void methodA() {
synchronized (lockA) {
synchronized (lockB) {
process();
}
}
}
public void methodB() {
synchronized (lockA) {
synchronized (lockB) {
process();
}
}
}
위처럼 두 메서드 모두 lockA를 먼저 잡고 lockB를 나중에 잡도록 통일하면 Deadlock 가능성을 줄일 수 있습니다. 팀 코드에서는 이 규칙을 문서나 코드 리뷰 기준으로 남겨두는 편이 좋습니다. 락 순서는 작성한 사람만 알고 있으면 유지보수 단계에서 다시 깨지기 쉽습니다.
락을 잡은 상태에서 오래 걸리는 작업을 하는 경우
락 안에서 외부 API 호출, 파일 처리, 긴 DB 작업을 수행하는 코드도 주의해야 합니다. 락을 오래 잡고 있으면 다른 스레드가 대기하는 시간이 길어지고, 여러 락이 함께 얽힐 때 Deadlock 가능성도 커집니다.
public void updateOrder() {
synchronized (lock) {
callExternalApi();
updateDatabase();
}
}
이 코드는 동작 자체는 단순해 보이지만, 락의 범위가 넓습니다. 가능하다면 락이 필요한 최소 구간만 남기고, 오래 걸릴 수 있는 작업은 락 바깥으로 빼는 것이 좋습니다.
public void updateOrder() {
ApiResult result = callExternalApi();
synchronized (lock) {
applyResult(result);
}
updateDatabase();
}
물론 모든 코드를 이렇게 바꿀 수 있는 것은 아닙니다. 데이터 정합성을 위해 반드시 함께 묶여야 하는 구간도 있습니다. 다만 락 내부에 꼭 있어야 하는 코드인지 한 번 나누어 보는 것만으로도 문제 범위를 줄일 수 있습니다.
Java Deadlock 해결 전략
Java Deadlock 해결은 단순히 synchronized를 제거하는 문제가 아닙니다. 공유 자원 보호, 데이터 정합성, 동시성 처리 방식이 함께 엮여 있기 때문에 원인에 맞는 선택이 필요합니다.
락 순서를 고정한다
여러 락을 동시에 잡아야 한다면 락 획득 순서를 고정하는 방식이 가장 기본적인 해결책입니다. 예를 들어 계좌 이체처럼 fromAccount와 toAccount 두 객체를 동시에 잠가야 한다면, 계좌 ID 기준으로 항상 작은 ID를 먼저 잠그도록 정할 수 있습니다.
public void transfer(Account from, Account to, long amount) {
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
이 방식은 코드가 조금 늘어나지만 의도가 분명합니다. 특히 여러 개발자가 함께 수정하는 도메인에서는 “항상 같은 순서로 락을 잡는다”는 규칙이 눈에 보이는 편이 낫습니다.
tryLock으로 무한 대기를 피한다
ReentrantLock을 사용하면 tryLock으로 일정 시간 안에 락을 얻지 못했을 때 포기하는 흐름을 만들 수 있습니다. 이 방식은 Deadlock을 구조적으로 없앤다기보다, 무한 대기로 빠지는 상황을 방지하는 데 가깝습니다.
private final ReentrantLock lockA = new ReentrantLock();
private final ReentrantLock lockB = new ReentrantLock();
public void process() throws InterruptedException {
boolean lockedA = false;
boolean lockedB = false;
try {
lockedA = lockA.tryLock(1, TimeUnit.SECONDS);
lockedB = lockB.tryLock(1, TimeUnit.SECONDS);
if (!lockedA || !lockedB) {
throw new IllegalStateException("락 획득 실패");
}
doSomething();
} finally {
if (lockedB) {
lockB.unlock();
}
if (lockedA) {
lockA.unlock();
}
}
}
여기서 주의할 점은 unlock 처리입니다. 락을 얻지 못했는데 unlock을 호출하면 다른 예외가 발생할 수 있습니다. 그래서 락 획득 여부를 변수로 분리해두는 편이 안전합니다.
락 범위를 줄인다
락은 필요한 곳에만 짧게 사용하는 것이 좋습니다. 공유 상태를 읽고 쓰는 구간만 보호하고, 계산이나 외부 호출은 가능하면 락 바깥으로 분리합니다.
이 방식은 코드 구조를 조금 더 명확하게 만들어 줍니다. “어디까지가 동기화가 필요한 영역인지”가 드러나기 때문입니다. 협업할 때도 락 범위가 넓은 코드보다 작은 코드가 리뷰하기 쉽습니다.
공유 상태 자체를 줄인다
가장 좋은 동시성 코드는 락을 잘 쓰는 코드가 아니라, 락을 덜 필요로 하는 코드인 경우가 많습니다. 불변 객체를 사용하거나, 요청 단위로 상태를 분리하거나, ConcurrentHashMap 같은 동시성 컬렉션을 사용하는 것도 선택지가 됩니다.
private final Map<String, Integer> countMap = new ConcurrentHashMap<>();
public void increase(String key) {
countMap.compute(key, (k, value) -> value == null ? 1 : value + 1);
}
단, 동시성 컬렉션을 사용한다고 해서 모든 동시성 문제가 사라지는 것은 아닙니다. 컬렉션의 단일 연산은 안전할 수 있지만, 여러 연산을 묶은 비즈니스 로직까지 자동으로 안전해지는 것은 아닙니다.
DB Deadlock과 Java Deadlock은 구분해서 봐야 한다
Java Deadlock을 이야기할 때 데이터베이스 Deadlock도 함께 언급되는 경우가 많습니다. 둘 다 “서로 기다린다”는 점은 비슷하지만, 원인과 확인 방법은 다릅니다.
Java Deadlock은 JVM 내부 스레드와 락의 문제입니다. 반면 DB Deadlock은 트랜잭션이 서로 다른 row lock이나 gap lock을 기다리면서 발생합니다. MySQL에서는 InnoDB가 Deadlock을 감지하면 한쪽 트랜잭션을 롤백시키는 방식으로 풀어줍니다.
SHOW ENGINE INNODB STATUS;
DB Deadlock이 의심되면 애플리케이션 스레드 덤프만 봐서는 부족합니다. DB의 Deadlock 로그, 실행 쿼리, 트랜잭션 순서, 인덱스 사용 여부를 함께 봐야 합니다.
실무에서 이 둘을 섞어서 보면 원인 분석이 흐려집니다. Java synchronized나 ReentrantLock이 문제인지, DB 트랜잭션 순서가 문제인지 먼저 분리해서 보는 편이 좋습니다.
Java Deadlock 대응 절차
Java Deadlock이 의심될 때는 바로 코드를 고치기보다 현재 상태를 먼저 남겨야 합니다. 재시작을 먼저 하면 증거가 사라질 수 있습니다. 서비스 영향이 크다면 복구가 우선이지만, 가능한 범위에서 스레드 덤프와 로그는 남겨두는 것이 좋습니다.
1단계: 스레드 덤프 확보
동일한 시점의 스레드 덤프만으로도 Deadlock은 확인할 수 있지만, 대기 상태의 흐름을 보려면 여러 번 남기는 것이 좋습니다. 예를 들어 몇 초 간격으로 3회 정도 수집하면 특정 스레드가 계속 같은 위치에 머물러 있는지 확인할 수 있습니다.
jstack -l <PID> > thread-dump-1.txt
sleep 5
jstack -l <PID> > thread-dump-2.txt
sleep 5
jstack -l <PID> > thread-dump-3.txt
2단계: BLOCKED 상태와 락 소유자 확인
스레드 덤프에서 BLOCKED 상태인 스레드를 찾고, 어떤 객체 락을 기다리는지 확인합니다. 동시에 그 락을 보유하고 있는 스레드가 어디에서 멈춰 있는지도 봐야 합니다.
여기서 라인 번호가 중요합니다. 라인 번호가 없으면 실제 원인 코드로 이어지기 어렵습니다. 운영 빌드에서도 디버깅에 필요한 최소한의 클래스 정보와 라인 정보를 확인할 수 있도록 빌드 설정을 관리하는 것이 좋습니다.
3단계: 락 순서와 트랜잭션 범위 확인
원인 코드가 확인되면 락을 잡는 순서가 서로 다른지, 락 안에서 오래 걸리는 작업을 하는지, 트랜잭션과 락이 겹쳐 있는지 확인합니다.
특히 Spring 환경에서는 @Transactional 메서드 안에서 synchronized를 함께 사용하는 코드가 있을 수 있습니다. 이 경우 Java 락과 DB 락이 함께 얽히면서 분석이 복잡해질 수 있습니다. 단순히 어노테이션 하나의 문제가 아니라 호출 흐름 전체를 봐야 합니다.
4단계: 수정 후 재현 테스트 작성
Deadlock은 한 번 수정했다고 끝내기 어렵습니다. 가능한 경우 동시 실행 테스트를 만들어 같은 조건에서 다시 멈추지 않는지 확인해야 합니다.
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(1);
executor.submit(() -> {
latch.await();
service.methodA();
return null;
});
executor.submit(() -> {
latch.await();
service.methodB();
return null;
});
latch.countDown();
executor.shutdown();
테스트로 모든 Deadlock 가능성을 완벽하게 증명할 수는 없습니다. 그래도 문제가 되었던 코드 흐름을 재현 가능한 형태로 남겨두면 이후 수정에서 같은 실수를 줄일 수 있습니다.
Java Deadlock 재발 방지를 위한 코드 리뷰 기준
Java Deadlock은 한 명의 개발자가 조심한다고 완전히 막기 어렵습니다. 시간이 지나면서 코드가 추가되고 호출 흐름이 바뀌기 때문입니다. 그래서 팀 차원의 리뷰 기준이 필요합니다.
여러 락을 잡는 코드는 순서를 명확히 한다
두 개 이상의 락을 사용하는 코드는 반드시 순서 기준이 있어야 합니다. 객체 ID, 생성 순서, 문자열 키 등 하나의 기준을 정하고 모든 코드에서 동일하게 사용해야 합니다.
기준이 보이지 않는 코드는 나중에 다른 개발자가 수정하면서 쉽게 깨질 수 있습니다. 락 순서는 암묵적인 약속으로 두기보다 코드에서 드러나는 편이 좋습니다.
락 내부에서 외부 의존 작업을 피한다
락 내부에서 외부 API, 파일 I/O, 긴 쿼리를 실행하면 락 보유 시간이 길어집니다. 꼭 필요한 경우가 아니라면 락 내부에는 공유 상태 변경만 남기는 방향으로 작성하는 것이 좋습니다.
이 기준은 성능보다 안정성과 해석 가능성에 가깝습니다. 문제가 생겼을 때 락 범위가 작으면 어디서 대기하는지 추적하기가 훨씬 수월합니다.
synchronized를 도메인 객체에 직접 거는 코드는 조심한다
도메인 객체 자체를 락으로 사용하는 코드는 간단하지만, 객체가 어디서 공유되는지 명확하지 않으면 위험해질 수 있습니다. 특히 여러 서비스 계층에서 같은 객체를 다루는 구조라면 락 범위를 파악하기 어렵습니다.
명시적인 lock 객체를 두거나, 동시성 제어 책임을 별도 컴포넌트로 분리하는 방식이 더 읽기 좋을 때가 많습니다. 코드가 조금 길어져도 책임이 분명해지는 장점이 있습니다.
Java Deadlock 정리
Java Deadlock은 스레드가 서로의 락을 기다리며 멈추는 문제입니다. 에러 로그가 없을 수도 있고, 특정 기능만 멈춘 것처럼 보일 수도 있기 때문에 스레드 덤프를 통해 실제 상태를 확인해야 합니다.
처리 전략은 크게 네 가지로 정리할 수 있습니다. 락 획득 순서를 통일하고, 락 범위를 줄이며, tryLock으로 무한 대기를 피하고, 가능하다면 공유 상태 자체를 줄이는 것입니다.
중요한 것은 Deadlock을 단순한 일회성 장애로만 보지 않는 것입니다. 원인 코드와 락 규칙을 정리하고, 재현 테스트나 코드 리뷰 기준으로 남겨야 같은 문제가 반복되지 않습니다. Java 동시성 코드는 한 번에 완벽하게 작성하기 어렵기 때문에, 명확한 규칙과 작은 락 범위를 유지하는 습관이 가장 안전한 접근입니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] JPA N+1 문제 발견하고 해결한 과정: 원인 분석부터 Fetch Join 적용까지 (0) | 2026.05.20 |
|---|---|
| [JAVA] Java에서 Too many connections 발생했을 때 대응 방법 (0) | 2026.05.18 |
| [JAVA] Java Could not open JPA EntityManager 에러 해결 방법 (0) | 2026.05.17 |
| [JAVA] Java Spring Transaction silently rolled back 문제 원인 분석 (1) | 2026.05.16 |
| [JAVA] LazyInitializationException 실무에서 해결한 방법 (0) | 2026.05.15 |
