
멀티쓰레드 환경에서 자바 애플리케이션을 개발하다 보면, 때때로 프로그램이 아무런 오류 메시지 없이 갑자기 멈춰버리는 상황에 직면하게 됩니다. 시스템 리소스는 정상으로 보이지만, 더 이상 아무런 작업도 진행되지 않는 이 미스터리한 현상의 주범은 바로 데드락(Deadlock)입니다. 데드락은 여러 쓰레드가 서로 상대방이 점유한 자원을 무한정 기다리며 시스템 전체를 마비시키는 치명적인 동시성 문제입니다. 이 글에서는 자바 데드락의 정확한 정의와 발생 조건, 그리고 가장 중요한 효과적인 예방 및 회피 전략에 대해 심도 있게 다루어 보겠습니다. 안정적인 고성능 애플리케이션을 구축하고자 하는 개발자라면 반드시 이해해야 할 필수 개념입니다.
1. 데드락(Deadlock)이란 무엇인가?
데드락은 두 개 이상의 쓰레드가 서로 다른 쓰레드가 점유하고 있는 자원을 획득하려고 기다리면서, 영원히 다음 작업을 진행하지 못하는 상태를 의미합니다. 비유하자면, 두 명의 운전자가 좁은 외나무다리에서 서로 마주보고 멈춰 서서, 상대방이 길을 비켜주기만을 기다리는 상황과 같습니다. 아무도 양보하지 않으면 모두가 꼼짝없이 갇히게 됩니다. 자바에서는 주로 synchronized 블록이나 ReentrantLock 같은 명시적 락(Lock)을 사용할 때 발생하기 쉽습니다. 쓰레드가 하나의 락을 획득한 채로 다른 락을 기다리고, 동시에 다른 쓰레드도 유사한 방식으로 락을 기다리면서 순환적인 대기 상태에 빠지는 것이 일반적인 시나리오입니다.
2. 데드락 발생의 4가지 필요 조건 (코프만의 조건)
데드락은 다음 네 가지 조건이 모두 충족될 때 발생할 수 있습니다. 이 조건 중 하나라도 충족되지 않도록 설계하면 데드락을 예방할 수 있습니다.
| 조건 | 설명 | 예방 전략 |
|---|---|---|
| 상호 배제 (Mutual Exclusion) | 한 번에 한 쓰레드만 자원을 사용할 수 있다. | 락 없이 공유 자원에 접근 (단, 데이터 일관성 문제 발생) |
| 점유 및 대기 (Hold and Wait) | 자원을 보유한 채 다른 자원을 기다린다. | 모든 필요한 자원을 한 번에 획득하거나, 보유 자원 해제 후 재시도 |
| 비선점 (No Preemption) | 쓰레드가 자원을 강제로 뺏을 수 없다. (자발적 반납만 가능) | 선점 가능한 락 메커니즘 사용 (예: tryLock()) |
| 순환 대기 (Circular Wait) | 자원을 기다리는 쓰레드들이 원형으로 대기한다. (T1 -> L2, T2 -> L1) | 자원 획득 순서 강제 (순서 규칙 부여) |
3. 실무 예제: 데드락 상황 재현 (Sample Example)
아래 코드는 두 개의 Object 인스턴스를 락으로 사용하여 전형적인 데드락 상황을 연출합니다.
public class DeadlockExample {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
// Thread 1: lock1을 먼저 획득하고 lock2를 기다림
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: lock1 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: lock2 획득 시도 중...");
synchronized (lock2) {
System.out.println("Thread 1: lock2 획득"); // 여기에 도달하지 못함
}
}
});
// Thread 2: lock2를 먼저 획득하고 lock1을 기다림
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: lock2 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: lock1 획득 시도 중...");
synchronized (lock1) {
System.out.println("Thread 2: lock1 획득"); // 여기에 도달하지 못함
}
}
});
thread1.start();
thread2.start();
}
}
위 코드를 실행하면, 두 쓰레드 모두 두 번째 synchronized 블록에 진입하지 못하고 영원히 대기하는 것을 확인할 수 있습니다. 이것이 바로 데드락입니다.
4. 데드락 예방 및 회피 전략
데드락을 완전히 피하는 가장 좋은 방법은 코드를 설계할 때 위에서 언급한 4가지 조건 중 하나 이상을 충족시키지 않도록 하는 것입니다.
4.1. 자원 획득 순서 강제 (순환 대기 조건 방지)
가장 효과적이고 널리 사용되는 방법입니다. 모든 쓰레드가 동일한 순서로 락을 획득하도록 규칙을 정하는 것입니다. 예를 들어, 항상 lock1을 먼저 획득하고 그 다음에 lock2를 획득하도록 강제하는 것입니다.
// 데드락 예방을 위한 코드 수정 (자원 획득 순서 통일)
// Thread 1
synchronized (lock1) {
System.out.println("Thread 1: lock1 획득");
synchronized (lock2) { // lock2를 두 번째로 획득
System.out.println("Thread 1: lock2 획득");
}
}
// Thread 2 (동일하게 lock1을 먼저 획득하도록 수정)
synchronized (lock1) {
System.out.println("Thread 2: lock1 획득");
synchronized (lock2) { // lock2를 두 번째로 획득
System.out.println("Thread 2: lock2 획득");
}
}
4.2. 락 타임아웃 사용 (비선점 조건 완화)
java.util.concurrent.locks.ReentrantLock 같은 명시적 락은 tryLock(long timeout, TimeUnit unit) 메서드를 제공하여 특정 시간 동안 락 획득을 시도하고 실패하면 자원을 반납 후 재시도할 수 있게 합니다.
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
// Thread 1
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 임계 영역
} finally {
lock2.unlock();
}
} else {
// lock2 획득 실패, lock1 반납 후 재시도
}
} finally {
lock1.unlock();
}
}
4.3. 필요한 모든 자원을 한 번에 획득 (점유 및 대기 조건 방지)
실제 구현하기는 까다롭지만, 쓰레드가 작업을 시작하기 전에 필요한 모든 락을 한꺼번에 획득하는 방법입니다. 만약 모든 락을 획득하지 못하면, 현재 보유한 모든 락을 반납하고 다시 시도합니다.
5. Thread Dump를 통한 데드락 진단
실제 운영 환경에서 데드락이 발생했을 때는 Thread Dump를 분석하여 문제의 원인을 파악할 수 있습니다. JDK의 jstack 명령어를 사용하거나 JVM 툴(VisualVM, JConsole)을 통해 쓰레드 덤프를 생성할 수 있습니다. 덤프 파일에서 "Found one Java-level deadlock:" 메시지를 통해 데드락 발생 여부와 관련된 락 정보를 확인할 수 있습니다.
출처 및 참고문헌
- Java Concurrency in Practice (Brian Goetz)
- Oracle Java Documentation: Deadlock Detection
- Operating System Concepts (Abraham Silberschatz, Peter B. Galvin, Greg Gagne)
'Language > Java' 카테고리의 다른 글
| [JAVA] 자바 쓰레드 제어의 한 끗 차이 : sleep() vs wait() 완벽 분석 (0) | 2026.01.21 |
|---|---|
| [JAVA] 쓰레드 간의 효율적인 대화 : wait(), notify(), notifyAll() 완벽 가이드 (0) | 2026.01.21 |
| [JAVA] 동기화의 핵심, synchronized 키워드 완벽 정복하기 (0) | 2026.01.21 |
| [JAVA] Thread 생명 주기 완벽 가이드 : NEW에서 TERMINATED까지 (0) | 2026.01.21 |
| [JAVA] start()와 run() 메서드의 결정적 차이 : 왜 run()을 직접 호출하면 안 될까? (0) | 2026.01.21 |