데드락(Deadlock) 완벽 가이드: 발생 원리부터 탐지·해결까지
데드락(Deadlock) 완벽 가이드: 발생 원리부터 탐지·해결까지
교차로에 네 방향에서 차가 동시에 진입했다고 상상해보자. 각 차는 자기 오른쪽의 차가 먼저 지나가기를 기다린다. 아무도 양보하지 않으면 네 대 모두 영원히 움직이지 못한다. 이것이 데드락(Deadlock)이다.
운영체제에서 데드락은 두 개 이상의 프로세스가 서로가 보유한 자원을 요청하면서 무한히 대기하는 상태를 말한다. 이전 글에서 프로세스/스레드의 동기화 문제를 다루며 데드락을 간략히 소개했는데, 이번 글에서는 자원 할당 그래프를 통한 시각적 분석, 은행원 알고리즘의 수학적 동작, 라이브락·기아·우선순위 역전 같은 관련 현상, 그리고 Java/DB 실무에서의 데드락 대응 전략까지를 깊이 있게 다룬다.
1. 시스템 모델과 데드락의 정의
1.1 시스템 모델
운영체제에서 자원(Resource)은 CPU 사이클, 메모리 공간, I/O 장치, 파일, 세마포어, 뮤텍스 등 프로세스가 작업을 수행하기 위해 필요로 하는 모든 것을 포함한다.
자원은 타입(Resource Type)으로 분류된다. 예를 들어 “프린터”가 하나의 자원 타입이고, 실제 프린터 3대가 그 타입의 인스턴스(Instance)이다.
1
2
3
자원 타입 R1: CPU (인스턴스 4개 — 쿼드코어)
자원 타입 R2: 프린터 (인스턴스 2개)
자원 타입 R3: 디스크 드라이브 (인스턴스 3개)
프로세스가 자원을 사용하는 과정은 세 단계로 나뉜다.
1
2
3
1. 요청(Request): 자원을 요청한다. 사용 가능하면 즉시 할당, 아니면 대기.
2. 사용(Use): 자원을 사용하여 작업을 수행한다.
3. 해제(Release): 작업이 끝나면 자원을 반환한다.
1.2 데드락의 정의
프로세스 집합 {P0, P1, …, Pn}에서 모든 프로세스 Pi가 집합 내의 다른 프로세스 Pj만이 유발할 수 있는 이벤트를 기다리고 있을 때, 이 집합은 데드락 상태에 있다.
“이벤트”란 대부분의 경우 자원의 해제이다. P0이 R1을 잡고 R2를 기다리고, P1이 R2를 잡고 R1을 기다리면 — 둘 다 영원히 진행할 수 없다.
2. 데드락 발생 조건 4가지 (Coffman 조건) — 심화
1971년 Coffman 등이 제시한 네 가지 조건이 동시에 성립할 때만 데드락이 발생한다. 역으로, 하나라도 깨뜨리면 데드락을 방지할 수 있다.
2.1 상호 배제 (Mutual Exclusion)
최소 하나의 자원이 비공유 모드로 점유되어야 한다. 한 번에 하나의 프로세스만 해당 자원을 사용할 수 있다.
모든 자원이 공유 가능하면 데드락은 발생하지 않는다. 읽기 전용 파일은 여러 프로세스가 동시에 접근할 수 있으므로 데드락의 원인이 되지 않는다. 그러나 프린터, 뮤텍스, 배타 락처럼 본질적으로 공유 불가능한 자원이 존재한다.
이 조건을 제거할 수 있는가? 일부 자원은 가능하다. 프린터를 스풀링(spooling)하면 여러 프로세스가 동시에 “프린터에 출력”할 수 있다. 실제로는 스풀러 데몬만 프린터를 사용하고, 다른 프로세스는 스풀 큐에 작업을 넣는 것이다. 그러나 뮤텍스처럼 상호 배제가 본질인 자원은 이 조건을 제거할 수 없다.
2.2 점유와 대기 (Hold and Wait)
자원을 최소 하나 보유한 프로세스가 다른 프로세스에 의해 점유된 추가 자원을 요청하며 대기한다.
이 조건이 없으면 프로세스는 자원을 잡은 채로 기다리지 않으므로 순환 대기가 형성될 수 없다.
이 조건을 제거하는 방법:
- 방법 1: 프로세스가 실행 전에 필요한 모든 자원을 한꺼번에 요청한다. 모두 사용 가능할 때만 할당한다.
- 방법 2: 프로세스가 새 자원을 요청하기 전에 현재 보유한 모든 자원을 해제한다.
둘 다 자원 이용률이 낮아지는 문제가 있다. 프로세스가 10분 동안 실행되면서 프린터는 마지막 1분에만 필요한데, 처음부터 프린터를 잡고 있으면 9분 동안 낭비다. 또한 기아(Starvation) 문제가 발생할 수 있다. 인기 있는 자원을 여러 개 동시에 요청하면 영원히 모두가 사용 가능한 순간이 오지 않을 수 있다.
2.3 비선점 (No Preemption)
프로세스가 보유한 자원을 강제로 빼앗을 수 없다. 자원은 프로세스가 자발적으로 해제할 때만 반환된다.
이 조건을 제거할 수 있는가? 자원의 성격에 따라 다르다.
- 선점 가능한 자원: CPU(컨텍스트 스위칭), 메모리(가상 메모리/페이징). 상태를 저장하고 나중에 복원할 수 있으므로 강제로 빼앗아도 문제가 없다.
- 선점 불가능한 자원: 프린터(인쇄 중간에 빼앗으면 출력물이 망가진다), 뮤텍스(임계 영역 중간에 빼앗으면 데이터 정합성이 깨진다).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 선점 가능한 패턴: tryLock으로 "자발적 선점" 구현
public void transferSafe(Account from, Account to, BigDecimal amount) {
while (true) {
if (from.getLock().tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (to.getLock().tryLock(100, TimeUnit.MILLISECONDS)) {
try {
from.withdraw(amount);
to.deposit(amount);
return;
} finally {
to.getLock().unlock();
}
}
} finally {
from.getLock().unlock(); // 실패 시 자발적 해제 → 비선점 조건 제거
}
}
Thread.sleep((long) (Math.random() * 50)); // 재시도 전 랜덤 대기
}
}
2.4 순환 대기 (Circular Wait)
프로세스 집합 {P0, P1, …, Pn}에서 P0은 P1이 보유한 자원을 기다리고, P1은 P2가 보유한 자원을 기다리고, …, Pn은 P0이 보유한 자원을 기다리는 순환 체인이 형성된다.
이것은 앞의 세 조건이 성립한 상태에서 추가로 “대기의 방향이 순환”을 이루는 것이다.
이 조건을 제거하는 방법: 모든 자원 타입에 고유한 번호를 부여하고, 프로세스가 항상 번호가 증가하는 순서로만 자원을 요청하도록 강제한다. 이 방법이 실무에서 가장 많이 사용되는 데드락 예방 기법이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 자원 순서 보장: 항상 ID가 작은 계좌의 락을 먼저 획득
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
Account first = accountRepository.findByIdForUpdate(firstId);
Account second = accountRepository.findByIdForUpdate(secondId);
if (fromId.equals(firstId)) {
first.withdraw(amount);
second.deposit(amount);
} else {
second.withdraw(amount);
first.deposit(amount);
}
}
2.5 조건 간의 관계
1
2
3
4
5
6
7
8
9
상호 배제 ── 자원의 본질적 속성 (대부분 변경 불가)
+
점유와 대기 ── "잡은 채로 기다린다"
+
비선점 ── "빼앗을 수 없다"
+
순환 대기 ── "대기가 순환한다" ← 실무에서 가장 깨기 쉬운 조건
↓
데드락 발생
2.6 식사하는 철학자 문제 (Dining Philosophers Problem)
데드락을 설명할 때 가장 유명한 예제가 바로 식사하는 철학자 문제(Dining Philosophers Problem)이다. 1965년 Dijkstra가 처음 제시하고 이후 Tony Hoare가 현재의 형태로 다듬은 이 문제는 Coffman의 네 가지 조건이 어떻게 동시에 성립하여 데드락을 유발하는지를 직관적으로 보여준다.
문제 설정: 다섯 명의 철학자가 원형 테이블에 앉아 있다. 각 철학자 사이에 포크(chopstick)가 하나씩, 총 5개가 놓여 있다. 철학자는 “생각하기(thinking)”와 “식사하기(eating)”를 반복하며, 식사를 하려면 자신의 왼쪽과 오른쪽 포크를 모두 집어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[P0]
/ \
F4 F0
/ \
[P4] [P1]
\ /
F3 F1
\ /
[P3]──[P2]
F2
P0~P4: 철학자 (Philosopher)
F0~F4: 포크 (Fork)
P0은 F0(오른쪽)과 F4(왼쪽)가 필요
P1은 F1(오른쪽)과 F0(왼쪽)이 필요
P2는 F2(오른쪽)과 F1(왼쪽)이 필요
P3은 F3(오른쪽)과 F2(왼쪽)가 필요
P4는 F4(오른쪽)과 F3(왼쪽)이 필요
이 문제에서 데드락이 발생하는 시나리오는 명확하다. 다섯 명의 철학자가 동시에 왼쪽 포크를 집으면, 각자 하나의 포크를 보유한 채 오른쪽 포크를 기다리게 된다. 오른쪽 포크는 옆 사람이 들고 있으므로 아무도 식사를 시작할 수 없고, 아무도 포크를 내려놓지 않으므로 영원히 대기한다.
Coffman 조건을 대입해보면 다음과 같다.
- 상호 배제: 포크는 한 번에 한 철학자만 사용할 수 있다.
- 점유와 대기: 철학자는 왼쪽 포크를 잡은 채로 오른쪽 포크를 기다린다.
- 비선점: 다른 철학자가 들고 있는 포크를 강제로 빼앗을 수 없다.
- 순환 대기: P0→F0→P1→F1→P2→F2→P3→F3→P4→F4→P0 순환이 형성된다.
데드락이 발생하는 구현
먼저, 데드락이 발생하는 순진한(naive) 구현을 살펴보자. 각 철학자가 왼쪽 포크를 먼저 집고, 이어서 오른쪽 포크를 집는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import java.util.concurrent.locks.ReentrantLock;
public class DiningPhilosophersDeadlock {
// 5개의 포크를 ReentrantLock으로 표현
private static final int NUM_PHILOSOPHERS = 5;
private static final ReentrantLock[] forks = new ReentrantLock[NUM_PHILOSOPHERS];
static {
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
forks[i] = new ReentrantLock();
}
}
static class Philosopher extends Thread {
private final int id;
private final ReentrantLock leftFork; // 왼쪽 포크
private final ReentrantLock rightFork; // 오른쪽 포크
Philosopher(int id) {
this.id = id;
this.leftFork = forks[id]; // 자신의 번호 = 왼쪽 포크
this.rightFork = forks[(id + 1) % NUM_PHILOSOPHERS]; // 다음 번호 = 오른쪽 포크
}
private void think() throws InterruptedException {
System.out.println("철학자 " + id + " 생각 중...");
Thread.sleep((long) (Math.random() * 100));
}
private void eat() throws InterruptedException {
System.out.println("철학자 " + id + " 식사 중!");
Thread.sleep((long) (Math.random() * 100));
}
@Override
public void run() {
try {
while (true) {
think();
// 왼쪽 포크를 먼저 집는다
leftFork.lock();
System.out.println("철학자 " + id + " 왼쪽 포크 획득");
// 오른쪽 포크를 집는다 — 여기서 데드락 가능!
rightFork.lock();
System.out.println("철학자 " + id + " 오른쪽 포크 획득");
eat();
// 포크를 내려놓는다
rightFork.unlock();
leftFork.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
// 5명의 철학자를 생성하고 시작
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
new Philosopher(i).start();
}
// 실행하면 얼마 지나지 않아 모든 철학자가 멈춘다 (데드락)
}
}
이 코드를 실행하면 모든 철학자가 왼쪽 포크를 집은 뒤 오른쪽 포크를 기다리며 멈추는 장면을 확인할 수 있다. Thread Dump를 떠보면 5개 스레드 모두 WAITING 상태로 교착된 것이 보일 것이다.
해결법 1: 자원 순서 부여 (Resource Ordering)
순환 대기 조건을 제거하는 가장 고전적인 방법이다. 포크에 번호를 부여하고, 항상 번호가 작은 포크를 먼저 집도록 강제한다. 이렇게 하면 순환 체인이 형성될 수 없다.
왜 순환이 깨지는지 직관적으로 생각해보자. P0부터 P3까지는 왼쪽 포크 번호가 오른쪽보다 작으므로 왼쪽을 먼저 집는다. 그런데 P4는 왼쪽 포크가 F4이고 오른쪽 포크가 F0이므로, 자원 순서 규칙에 따라 F0을 먼저 집어야 한다. P4가 F0을 먼저 집으려 하지만 P0이 이미 F0을 잡고 있다면 P4는 F4를 잡지 않고 대기한다. P4가 F4를 잡지 않았으므로 P3은 F4를 획득할 수 있고, P3이 식사를 마치면 순차적으로 진행이 가능해진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class DiningPhilosophersOrdered {
private static final int NUM_PHILOSOPHERS = 5;
private static final ReentrantLock[] forks = new ReentrantLock[NUM_PHILOSOPHERS];
static {
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
forks[i] = new ReentrantLock();
}
}
static class Philosopher extends Thread {
private final int id;
private final ReentrantLock firstFork; // 번호가 작은 포크
private final ReentrantLock secondFork; // 번호가 큰 포크
Philosopher(int id) {
this.id = id;
int left = id;
int right = (id + 1) % NUM_PHILOSOPHERS;
// 핵심: 항상 번호가 작은 포크를 먼저 집는다
if (left < right) {
firstFork = forks[left];
secondFork = forks[right];
} else {
firstFork = forks[right]; // P4의 경우: F0이 먼저
secondFork = forks[left]; // P4의 경우: F4가 나중
}
}
@Override
public void run() {
try {
while (true) {
think();
// 번호가 작은 포크를 먼저 획득
firstFork.lock();
try {
// 번호가 큰 포크를 획득
secondFork.lock();
try {
eat(); // 두 포크 모두 획득 → 식사
} finally {
secondFork.unlock();
}
} finally {
firstFork.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void think() throws InterruptedException {
System.out.println("철학자 " + id + " 생각 중...");
Thread.sleep((long) (Math.random() * 100));
}
private void eat() throws InterruptedException {
System.out.println("철학자 " + id + " 식사 중!");
Thread.sleep((long) (Math.random() * 100));
}
}
public static void main(String[] args) {
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
new Philosopher(i).start();
}
// 데드락 없이 모든 철학자가 돌아가며 식사한다
}
}
이 방식은 2.4절에서 설명한 순환 대기 조건 제거와 정확히 동일한 원리이다. 자원에 전역적인 순서를 부여하고 그 순서를 강제하면 순환 체인이 물리적으로 형성될 수 없다.
해결법 2: 세마포어를 활용한 동시 식사 인원 제한
또 다른 접근은 동시에 포크를 집으려는 철학자의 수를 제한하는 것이다. 5명의 철학자가 있을 때, 최대 4명만 동시에 포크를 집을 수 있게 하면 최소 한 명은 양쪽 포크를 모두 확보할 수 있어 데드락이 발생하지 않는다. 이것은 점유와 대기 조건을 간접적으로 약화시키는 방법이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
public class DiningPhilosophersSemaphore {
private static final int NUM_PHILOSOPHERS = 5;
private static final ReentrantLock[] forks = new ReentrantLock[NUM_PHILOSOPHERS];
// 핵심: 동시에 식사를 시도할 수 있는 철학자 수를 4명으로 제한
private static final Semaphore diningPermit = new Semaphore(NUM_PHILOSOPHERS - 1);
static {
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
forks[i] = new ReentrantLock();
}
}
static class Philosopher extends Thread {
private final int id;
Philosopher(int id) {
this.id = id;
}
@Override
public void run() {
try {
int left = id;
int right = (id + 1) % NUM_PHILOSOPHERS;
while (true) {
think();
// 세마포어로 동시 식사 인원 제한
diningPermit.acquire();
try {
// 이제 왼쪽-오른쪽 순서대로 집어도 안전하다
forks[left].lock();
try {
forks[right].lock();
try {
eat();
} finally {
forks[right].unlock();
}
} finally {
forks[left].unlock();
}
} finally {
diningPermit.release(); // 식사 완료 → 허가 반환
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void think() throws InterruptedException {
System.out.println("철학자 " + id + " 생각 중...");
Thread.sleep((long) (Math.random() * 100));
}
private void eat() throws InterruptedException {
System.out.println("철학자 " + id + " 식사 중!");
Thread.sleep((long) (Math.random() * 100));
}
}
public static void main(String[] args) {
for (int i = 0; i < NUM_PHILOSOPHERS; i++) {
new Philosopher(i).start();
}
}
}
세마포어 방식은 구현이 직관적이고 자원 순서를 신경 쓸 필요가 없다는 장점이 있지만, 동시성을 인위적으로 제한하므로 처리량(throughput)이 떨어질 수 있다. 실무에서는 자원 순서 부여 방식이 더 일반적으로 사용된다.
왜 식사하는 철학자 문제가 중요한가
식사하는 철학자 문제가 CS 교육에서 반복적으로 등장하는 이유는, 단순한 구조 속에 동시성 프로그래밍의 핵심 어려움이 모두 담겨 있기 때문이다. 데드락뿐 아니라 기아(특정 철학자만 계속 식사하지 못하는 것), 라이브락(서로 양보만 반복), 공정성(fairness) 문제까지 이 하나의 모델로 모두 시연할 수 있다.
또한 이 문제는 데이터베이스의 트랜잭션 락(여러 테이블에 대한 락을 순서 없이 잡는 것), 네트워크 라우팅(여러 자원을 동시에 확보해야 하는 것), 운영체제의 자원 관리(프로세스 간 공유 자원 배분) 등 실무의 다양한 시나리오와 직접적으로 매핑된다. 면접에서 “데드락을 코드로 보여주세요”라는 질문에 식사하는 철학자 문제를 구현하면서 Coffman 조건과 해결법을 함께 설명하면 깊이 있는 답변이 된다.
3. 자원 할당 그래프 (Resource Allocation Graph)
자원 할당 그래프(RAG)는 데드락을 시각적으로 분석하는 도구다. 1972년 Holt가 제안했다.
3.1 그래프 표기법
1
2
3
4
5
6
○ = 프로세스 (Process)
□ = 자원 타입 (Resource Type)
● = 자원 인스턴스 (□ 안의 점)
○ ──→ □ 요청 간선 (Request Edge): 프로세스가 자원을 요청 중
□ ──→ ○ 할당 간선 (Assignment Edge): 자원이 프로세스에 할당됨
3.2 데드락이 아닌 경우
1
2
3
4
5
6
7
8
9
┌──→ [R1 ●] ──→ P2
P1 ──┘ │
←── [R2 ●] ←──────┘
P1이 R1을 요청, R1은 P2에 할당
P2가 R2를 요청, R2는 ... 아무에게도 할당되지 않음
→ P2는 R2를 즉시 획득 가능 → P2 완료 후 R1 해제 → P1도 진행 가능
→ 사이클 없음 → 데드락 아님
3.3 데드락인 경우 (단일 인스턴스)
1
2
3
4
5
6
7
8
9
P1 ──→ [R1 ●] ──→ P2
↑ │
└── [R2 ●] ←────────┘
P1이 R1 요청, R1은 P2에 할당
P2가 R2 요청, R2는 P1에 할당
→ P1 → R1 → P2 → R2 → P1 (사이클!)
→ 각 자원이 1개 인스턴스 → 사이클 = 데드락
3.4 사이클이 있지만 데드락이 아닌 경우 (복수 인스턴스)
1
2
3
4
5
6
7
8
9
10
11
12
13
P1 ──→ [R1 ●●] ──→ P2
↑ └──→ P3 │
└── [R2 ●] ←─────────┘
R1은 인스턴스가 2개: 하나는 P2에, 하나는 P3에 할당
P1이 R1 요청 (대기 중)
P2가 R2 요청 (대기 중)
R2는 P1에 할당
사이클: P1 → R1 → P2 → R2 → P1
그러나 P3가 R1 인스턴스를 반환하면 P1이 진행 가능
→ 사이클이 있어도 데드락이 아닐 수 있다 (복수 인스턴스일 때)
핵심 규칙:
- 자원 타입당 인스턴스가 1개일 때: 사이클 ↔ 데드락 (필요충분조건)
- 자원 타입당 인스턴스가 여러 개일 때: 사이클은 데드락의 필요조건이지만 충분조건은 아니다
4. 데드락 처리 전략 개관
운영체제가 데드락을 처리하는 전략은 크게 네 가지다.
| 전략 | 설명 | 비용 | 실용성 |
|---|---|---|---|
| 예방 (Prevention) | 4가지 조건 중 하나를 원천 차단 | 자원 이용률 저하 | 실무에서 부분적 사용 |
| 회피 (Avoidance) | 안전 상태만 유지하며 자원 할당 | 사전 정보 필요, 계산 비용 | 이론적, 실무 제한적 |
| 탐지 & 복구 (Detection & Recovery) | 데드락 허용 후 탐지·해결 | 탐지 오버헤드, 복구 비용 | DBMS에서 주로 사용 |
| 무시 (Ostrich Algorithm) | 데드락을 무시한다 | 비용 없음 | Linux, Windows 기본 전략 |
대부분의 범용 운영체제(Linux, Windows)는 타조 알고리즘을 채택한다. 데드락 발생 빈도가 매우 낮고, 예방/회피/탐지의 오버헤드가 크기 때문이다. 반면 DBMS(MySQL InnoDB 등)는 데드락이 빈번하게 발생하므로 탐지 & 복구 전략을 적극 사용한다.
5. 데드락 회피 — 은행원 알고리즘
데드락 회피(Avoidance)는 자원을 할당하기 전에 “이 할당이 안전한가?”를 검사하여, 데드락으로 이어질 수 있는 할당을 거부하는 전략이다. 가장 유명한 알고리즘이 Dijkstra의 은행원 알고리즘(Banker’s Algorithm)이다.
5.1 안전 상태와 불안전 상태
안전 상태(Safe State): 모든 프로세스가 최대 자원을 요청하더라도 데드락 없이 순서대로 완료할 수 있는 안전 순서(Safe Sequence)가 존재하는 상태.
불안전 상태(Unsafe State): 안전 순서가 존재하지 않는 상태. 불안전 상태라고 반드시 데드락이 발생하는 것은 아니지만, 데드락이 발생할 가능성이 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──────────────────────────────┐
│ 전체 상태 공간 │
│ │
│ ┌────────────────────────┐ │
│ │ 안전 상태 │ │
│ │ (데드락 불가능) │ │
│ └────────────────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ 불안전 상태 │ │
│ │ ┌─────────┐ │ │
│ │ │ 데드락 │ │ │
│ │ └─────────┘ │ │
│ └─────────────┘ │
└──────────────────────────────┘
안전 상태 ⊂ 전체, 데드락 ⊂ 불안전 상태 ⊂ 전체
은행에 비유하면: 은행이 총 자금 1000만 원을 보유하고, 고객 A에게 최대 700만 원, B에게 최대 400만 원, C에게 최대 500만 원의 대출 한도를 약속했다. 현재 A에게 200만 원, B에게 300만 원을 대출한 상태다. 남은 자금 500만 원으로 누군가의 최대 한도를 채워줄 수 있는 순서가 있는가? 이것이 안전 순서 검사다.
5.2 알고리즘 데이터 구조
프로세스 n개, 자원 타입 m개일 때:
1
2
3
4
5
Available[m]: 현재 사용 가능한 각 자원 타입의 인스턴스 수
Max[n][m]: 각 프로세스가 최대로 요청할 수 있는 자원 수
Allocation[n][m]: 현재 각 프로세스에 할당된 자원 수
Need[n][m]: 각 프로세스가 추가로 필요한 자원 수
Need[i][j] = Max[i][j] - Allocation[i][j]
5.3 안전성 알고리즘 (Safety Algorithm)
1
2
3
4
5
6
7
8
9
10
11
12
13
1. Work = Available, Finish[i] = false (모든 i에 대해)
2. 다음 조건을 만족하는 프로세스 Pi를 찾는다:
Finish[i] == false AND Need[i] <= Work
3. 찾았으면:
Work = Work + Allocation[i] (Pi가 완료되어 자원 반환)
Finish[i] = true
2단계로 돌아간다
4. 못 찾았으면:
모든 Finish[i]가 true면 → 안전 상태
아니면 → 불안전 상태
5.4 완전한 예제
5개 프로세스(P0~P4), 3개 자원 타입(A: 10개, B: 5개, C: 7개)
1
2
3
4
5
6
7
Allocation Max Need Available
A B C A B C A B C A B C
P0 0 1 0 7 5 3 7 4 3 3 3 2
P1 2 0 0 3 2 2 1 2 2
P2 3 0 2 9 0 2 6 0 0
P3 2 1 1 2 2 2 0 1 1
P4 0 0 2 4 3 3 4 3 1
Available 계산: 총 자원(10,5,7) - 할당 합계(7,2,5) = (3,3,2)
안전성 검사 실행:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Work = (3, 3, 2)
반복 1: P1의 Need(1,2,2) <= Work(3,3,2)? YES
→ Work = (3,3,2) + Allocation[P1](2,0,0) = (5,3,2)
→ Finish = [F, T, F, F, F]
반복 2: P3의 Need(0,1,1) <= Work(5,3,2)? YES
→ Work = (5,3,2) + (2,1,1) = (7,4,3)
→ Finish = [F, T, F, T, F]
반복 3: P4의 Need(4,3,1) <= Work(7,4,3)? YES
→ Work = (7,4,3) + (0,0,2) = (7,4,5)
→ Finish = [F, T, F, T, T]
반복 4: P0의 Need(7,4,3) <= Work(7,4,5)? YES
→ Work = (7,4,5) + (0,1,0) = (7,5,5)
→ Finish = [T, T, F, T, T]
반복 5: P2의 Need(6,0,0) <= Work(7,5,5)? YES
→ Work = (7,5,5) + (3,0,2) = (10,5,7)
→ Finish = [T, T, T, T, T]
모든 Finish가 true → 안전 상태!
안전 순서: <P1, P3, P4, P0, P2>
5.5 자원 요청 알고리즘
프로세스 Pi가 Request[i]만큼 자원을 요청할 때:
1
2
3
4
5
6
7
8
9
1. Request[i] > Need[i] → 에러 (최대 요구량 초과)
2. Request[i] > Available → 대기 (자원 부족)
3. "가상으로" 할당해본다:
Available = Available - Request[i]
Allocation[i] = Allocation[i] + Request[i]
Need[i] = Need[i] - Request[i]
4. 안전성 알고리즘을 실행한다
안전하면 → 실제로 할당
불안전하면 → 가상 할당을 되돌리고 Pi를 대기시킨다
5.6 은행원 알고리즘의 한계
실무에서 은행원 알고리즘이 거의 사용되지 않는 이유:
- Max 정보를 사전에 알아야 한다: 프로세스가 실행 전에 최대 자원 요구량을 선언해야 하는데, 이는 대부분의 시스템에서 비현실적이다.
- 프로세스 수가 고정되어야 한다: 프로세스가 동적으로 생성/종료되는 현실에서는 적용하기 어렵다.
- 계산 비용: 매 자원 요청마다 O(m×n²)의 안전성 검사를 수행해야 한다.
- 자원 수가 고정되어야 한다: 장치 고장 등으로 가용 자원이 변할 수 있다.
그럼에도 은행원 알고리즘은 데드락 회피의 이론적 기반으로서 면접에서 자주 출제되며, “안전 상태”의 개념은 실무에서도 유효하다.
6. 데드락 탐지 (Detection)
데드락 탐지는 데드락 발생을 허용하되, 주기적으로 검사하여 데드락이 발생하면 복구하는 전략이다.
6.1 단일 인스턴스: Wait-for Graph
자원 타입당 인스턴스가 1개인 경우, 자원 할당 그래프에서 자원 노드를 제거하면 Wait-for Graph가 된다. 프로세스 간 대기 관계만 남기는 것이다.
1
2
3
4
5
6
자원 할당 그래프: Wait-for Graph:
P1 → R1 → P2 P1 → P2
P2 → R2 → P3 P2 → P3
P3 → R3 → P1 P3 → P1
사이클: P1 → P2 → P3 → P1 → 데드락!
Wait-for Graph에서 사이클이 존재하면 데드락이다. DFS(깊이 우선 탐색)로 O(n²) 시간에 사이클을 탐지할 수 있다.
InnoDB의 데드락 탐지가 바로 이 Wait-for Graph 방식을 사용한다.
6.2 복수 인스턴스: 탐지 알고리즘
복수 인스턴스에서는 은행원 알고리즘과 유사하되, Max 대신 현재 Request를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
데이터 구조:
Available[m]: 현재 사용 가능한 자원
Allocation[n][m]: 현재 할당된 자원
Request[n][m]: 현재 요청 중인 자원 (Max가 아닌 실제 요청!)
알고리즘:
1. Work = Available, Finish[i] = (Allocation[i] == 0)
2. Finish[i] == false이고 Request[i] <= Work인 Pi를 찾는다
3. 찾았으면:
Work = Work + Allocation[i]
Finish[i] = true
2단계로
4. Finish[i] == false인 프로세스가 존재하면 → 데드락
해당 프로세스들이 데드락 참여자
6.3 탐지 빈도
얼마나 자주 탐지 알고리즘을 실행할 것인가?
| 빈도 | 장점 | 단점 |
|---|---|---|
| 매 자원 요청마다 | 즉시 탐지, 정확한 원인 파악 | 오버헤드가 매우 큼 |
| 주기적 (예: 1초마다) | 적당한 오버헤드 | 데드락 발생 후 최대 1초 동안 방치 |
| CPU 이용률 저하 시 | 데드락 증상 기반 | 다른 원인의 성능 저하와 구분 어려움 |
InnoDB는 innodb_deadlock_detect = ON(기본값)일 때 매 락 대기 발생 시 즉시 탐지한다. 이 설정이 OFF이면 innodb_lock_wait_timeout(기본 50초) 후 타임아웃으로 처리한다. 동시 트랜잭션이 매우 많은 고부하 환경에서는 데드락 탐지 자체가 병목이 될 수 있어 OFF로 설정하는 경우도 있다.
7. 데드락 복구 (Recovery)
7.1 프로세스 종료
방법 1 — 전체 종료: 데드락에 참여한 모든 프로세스를 종료한다. 확실하지만 손실이 크다.
방법 2 — 하나씩 종료: 데드락이 해소될 때까지 프로세스를 하나씩 종료한다. 매번 탐지 알고리즘을 재실행해야 하므로 비용이 크다.
종료할 프로세스(희생자)를 선택하는 기준:
- 프로세스 우선순위
- 지금까지 수행한 계산량 (많이 했으면 종료 시 손실이 크다)
- 보유 자원의 수
- 남은 작업량
- 대화형(interactive) vs 배치(batch)
7.2 자원 선점
프로세스를 종료하지 않고 자원만 빼앗아 다른 프로세스에 할당한다.
세 가지 쟁점:
- 희생자 선택: 어떤 프로세스의 자원을 빼앗을 것인가?
- 롤백: 자원을 빼앗긴 프로세스를 안전한 상태로 되돌려야 한다. 전체 롤백(처음부터 다시) 또는 부분 롤백(체크포인트까지).
- 기아 방지: 같은 프로세스가 계속 희생자로 선택되면 기아가 발생한다. 선점 횟수를 비용 함수에 포함시킨다.
7.3 InnoDB의 데드락 복구
1
2
3
4
5
InnoDB 데드락 복구 과정:
1. Wait-for Graph에서 사이클 감지
2. 희생자 선택: Undo 로그 양이 적은 트랜잭션 (롤백 비용이 작은 쪽)
3. 희생 트랜잭션 롤백 (ERROR 1213: ER_LOCK_DEADLOCK)
4. 나머지 트랜잭션은 락을 획득하고 계속 진행
1
2
3
4
5
6
7
8
9
10
11
12
// Spring에서 InnoDB 데드락 복구 패턴
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void processOrder(Long orderId) {
// 데드락으로 롤백되면 자동 재시도
Order order = orderRepository.findByIdForUpdate(orderId);
order.process();
}
7.4 Wait-Die / Wound-Wait 기법
데드락을 복구하는 또 다른 접근 방식으로, 타임스탬프 기반의 선점 프로토콜이 있다. 데이터베이스 시스템에서 주로 사용되며, 트랜잭션에 시작 시점의 타임스탬프를 부여한 뒤 이를 기준으로 충돌을 해결한다. 대표적인 두 가지 기법이 Wait-Die와 Wound-Wait이다.
이 두 기법의 핵심 아이디어는 동일하다. “오래된 트랜잭션(타임스탬프가 작은, 즉 먼저 시작한 트랜잭션)에게 우선권을 부여한다.” 차이는 누가 양보하는가에 있다.
Wait-Die (비선점 기법)
Wait-Die는 오래된 트랜잭션만 기다릴 수 있고, 젊은 트랜잭션은 즉시 롤백(die)된다는 규칙이다.
트랜잭션 Ti가 트랜잭션 Tj가 보유한 자원을 요청할 때:
- Ti가 Tj보다 오래되었으면 (타임스탬프가 작으면): Ti는 기다린다(wait). 오래된 트랜잭션이니 기다릴 자격이 있다.
- Ti가 Tj보다 젊으면 (타임스탬프가 크면): Ti는 롤백된다(die). 젊은 트랜잭션이 양보하는 것이다. Ti는 같은 타임스탬프로 재시작한다.
“같은 타임스탬프로 재시작”하는 이유가 중요하다. 재시작할 때마다 새 타임스탬프를 받으면 영원히 젊은 트랜잭션으로 남아 기아가 발생할 수 있다. 원래 타임스탬프를 유지하면 재시작을 반복할수록 점점 “오래된” 트랜잭션이 되어 결국 기다릴 자격을 얻게 된다.
Wound-Wait (선점 기법)
Wound-Wait는 Wait-Die의 반대다. 오래된 트랜잭션이 젊은 트랜잭션을 상처 입히고(wound, 즉 롤백시키고), 젊은 트랜잭션만 기다린다.
트랜잭션 Ti가 트랜잭션 Tj가 보유한 자원을 요청할 때:
- Ti가 Tj보다 오래되었으면: Ti가 Tj를 wound한다 — Tj를 롤백시키고 자원을 빼앗는다. 오래된 트랜잭션이 젊은 트랜잭션을 선점하는 것이다.
- Ti가 Tj보다 젊으면: Ti는 기다린다(wait). 젊은 트랜잭션이 양보하여 기다리는 것이다.
두 기법의 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
상황: T1(오래됨, ts=10)이 T2(젊음, ts=20)가 보유한 자원을 요청
Wait-Die: Wound-Wait:
┌──────────────────────┐ ┌──────────────────────┐
│ T1(오래됨) → 자원 요청 │ │ T1(오래됨) → 자원 요청 │
│ │ │ │
│ T1이 T2보다 오래됨 │ │ T1이 T2보다 오래됨 │
│ → T1은 기다린다 (Wait)│ │ → T2를 롤백! (Wound) │
│ │ │ → T1이 자원 획득 │
└──────────────────────┘ └──────────────────────┘
상황: T2(젊음, ts=20)가 T1(오래됨, ts=10)이 보유한 자원을 요청
Wait-Die: Wound-Wait:
┌──────────────────────┐ ┌──────────────────────┐
│ T2(젊음) → 자원 요청 │ │ T2(젊음) → 자원 요청 │
│ │ │ │
│ T2가 T1보다 젊음 │ │ T2가 T1보다 젊음 │
│ → T2 롤백! (Die) │ │ → T2는 기다린다 (Wait)│
│ → T2 재시작 (ts=20) │ │ │
└──────────────────────┘ └──────────────────────┘
| 비교 항목 | Wait-Die | Wound-Wait |
|---|---|---|
| 선점 여부 | 비선점 (자원을 빼앗지 않음) | 선점 (오래된 TX가 자원을 빼앗음) |
| 누가 롤백되는가 | 젊은 요청자가 die | 젊은 보유자가 wound |
| 불필요한 롤백 | 많음 (젊은 TX가 자원 충돌 시마다 롤백) | 적음 (보유자가 젊을 때만 롤백) |
| 기다리는 방향 | 오래된 TX → 젊은 TX 방향으로만 대기 | 젊은 TX → 오래된 TX 방향으로만 대기 |
| 데드락 가능성 | 없음 (대기 방향이 단방향) | 없음 (대기 방향이 단방향) |
두 기법 모두 대기의 방향이 단방향이므로 순환 대기가 형성될 수 없어 데드락이 원천 차단된다. Wait-Die에서는 오래된 트랜잭션만 기다리므로 대기 간선이 “오래된 → 젊은” 방향으로만 존재하고, Wound-Wait에서는 젊은 트랜잭션만 기다리므로 “젊은 → 오래된” 방향으로만 존재한다.
Wait-Die 패턴의 코드 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class WaitDieScheduler {
private final Map<Long, Long> resourceHolders = new ConcurrentHashMap<>(); // 자원ID → 트랜잭션 타임스탬프
private final AtomicLong timestampGenerator = new AtomicLong(0);
/**
* 트랜잭션 시작 시 고유 타임스탬프 부여
* 숫자가 작을수록 오래된 트랜잭션
*/
public long beginTransaction() {
return timestampGenerator.incrementAndGet();
}
/**
* Wait-Die 방식으로 자원 요청
* @param txTimestamp 요청 트랜잭션의 타임스탬프
* @param resourceId 요청할 자원의 ID
* @return true면 대기(wait), false면 롤백 필요(die)
*/
public boolean requestResource(long txTimestamp, long resourceId) {
Long holderTimestamp = resourceHolders.get(resourceId);
if (holderTimestamp == null) {
// 자원이 비어 있으면 즉시 획득
resourceHolders.put(resourceId, txTimestamp);
return true;
}
if (txTimestamp < holderTimestamp) {
// 요청자가 보유자보다 오래됨 → 기다린다 (Wait)
// 실제 구현에서는 대기 큐에 넣고 블로킹
System.out.println("TX(" + txTimestamp + ") WAIT: 보유자 TX("
+ holderTimestamp + ")보다 오래되었으므로 대기");
return true; // 대기 허용
} else {
// 요청자가 보유자보다 젊음 → 롤백! (Die)
System.out.println("TX(" + txTimestamp + ") DIE: 보유자 TX("
+ holderTimestamp + ")보다 젊으므로 롤백 후 재시작");
return false; // 롤백 필요
}
}
/**
* 자원 해제
*/
public void releaseResource(long resourceId) {
resourceHolders.remove(resourceId);
}
}
어느 기법을 선택할 것인가
Wait-Die는 구현이 단순하지만 불필요한 롤백이 많다. 젊은 트랜잭션이 자원을 요청할 때마다 롤백되기 때문이다. 실제로 보유자가 곧 자원을 해제할 예정이었더라도 젊은 요청자는 일단 롤백된다.
Wound-Wait는 롤백 횟수가 적지만, 이미 진행 중인 트랜잭션을 강제로 롤백시키는 선점이 필요하므로 구현 복잡도가 높다. 그러나 전체적인 시스템 처리량은 Wound-Wait가 더 높은 경우가 많다.
실무에서는 Oracle이 초기에 Wait-Die 방식을 사용했으며, Google Spanner가 Wound-Wait를 채택한 것으로 알려져 있다. MySQL InnoDB는 이 두 기법 대신 Wait-for Graph 기반의 즉시 탐지를 사용한다. 분산 데이터베이스에서는 중앙 집중식 Wait-for Graph 관리가 어렵기 때문에 타임스탬프 기반 기법이 더 적합할 수 있다.
8. 라이브락, 기아, 우선순위 역전
데드락과 관련된 세 가지 중요한 현상이 있다.
8.1 라이브락 (Livelock)
라이브락은 프로세스들이 상태를 계속 변경하지만 실질적인 진전이 없는 상태다. 데드락과 달리 프로세스가 “살아 있고” 실행 중이지만 아무 유용한 작업을 하지 못한다.
좁은 복도에서 두 사람이 마주쳤을 때, 둘 다 동시에 같은 방향으로 비키고, 또 같은 방향으로 비키는 것을 반복하는 것과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 라이브락 예제
public class LivelockExample {
static final Lock lock1 = new ReentrantLock();
static final Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (true) {
lock1.lock();
if (!lock2.tryLock()) {
lock1.unlock(); // 양보
continue; // 다시 시도 → 라이브락!
}
try {
System.out.println("T1 작업 완료");
return;
} finally {
lock2.unlock();
lock1.unlock();
}
}
});
Thread t2 = new Thread(() -> {
while (true) {
lock2.lock();
if (!lock1.tryLock()) {
lock2.unlock(); // 양보
continue; // 다시 시도 → 라이브락!
}
try {
System.out.println("T2 작업 완료");
return;
} finally {
lock1.unlock();
lock2.unlock();
}
}
});
t1.start();
t2.start();
}
}
해결법: 재시도 전에 랜덤 대기 시간을 추가한다. 두 프로세스가 동시에 양보하는 패턴을 깨뜨린다.
1
2
3
4
5
6
// 라이브락 해결: 랜덤 백오프
if (!lock2.tryLock()) {
lock1.unlock();
Thread.sleep((long) (Math.random() * 100)); // 랜덤 대기
continue;
}
8.2 기아 (Starvation)
기아는 프로세스가 필요한 자원을 영원히 얻지 못하는 상태다. 데드락과 달리 다른 프로세스는 정상적으로 실행되지만, 특정 프로세스만 계속 밀리는 것이다.
대표적 원인:
- 우선순위 스케줄링: 높은 우선순위의 프로세스가 계속 도착하면 낮은 우선순위 프로세스는 실행 기회를 얻지 못한다.
- Writer Starvation in Reader-Writer Lock: 읽기 요청이 계속 들어오면 쓰기 요청이 영원히 대기한다.
- 데드락 복구에서의 기아: 같은 프로세스가 반복적으로 희생자로 선택된다.
해결법 — 에이징(Aging): 대기 시간이 길어질수록 우선순위를 점진적으로 높여준다. 충분히 오래 기다리면 결국 가장 높은 우선순위가 되어 실행된다.
8.3 우선순위 역전 (Priority Inversion)
우선순위가 높은 프로세스가 낮은 우선순위 프로세스가 보유한 자원을 기다리는 동안, 중간 우선순위의 프로세스가 CPU를 선점하는 현상이다.
1
2
3
4
5
6
7
8
9
10
우선순위: H(높음) > M(중간) > L(낮음)
시간 →
L: [실행 중, R1 획득]────[선점됨]──────────────────[실행 재개, R1 해제]
M: [선점, 실행 중]──────[완료]
H: [생성, R1 요청]──[대기]──────────────────────[R1 획득, 실행]
H는 L이 R1을 해제할 때까지 기다려야 한다.
그런데 M이 L을 선점해서 L의 실행이 지연된다.
→ H(최고 우선순위)가 M(중간 우선순위) 때문에 지연된다!
실제 사례 — Mars Pathfinder (1997): NASA의 화성 탐사 로봇 패스파인더에서 우선순위 역전이 발생하여 시스템이 반복적으로 리셋되었다. 낮은 우선순위의 기상 데이터 수집 태스크가 공유 자원(버스)을 잡고 있는 동안 중간 우선순위 태스크에 의해 선점되었고, 높은 우선순위의 통신 태스크가 버스를 기다리다가 워치독 타이머에 의해 시스템이 리셋된 것이다.
해결법 — 우선순위 상속 프로토콜(Priority Inheritance Protocol): 높은 우선순위 프로세스가 대기하면, 자원을 보유한 낮은 우선순위 프로세스의 우선순위를 일시적으로 높여준다. 중간 우선순위의 선점을 방지한다.
1
2
3
4
5
6
7
우선순위 상속 적용:
L: [실행, R1 획득]──[우선순위 H로 상승!]──[R1 해제, 원래 우선순위로]
M: [실행]
H: [R1 요청]──[대기]──────────────[R1 획득, 실행]
L의 우선순위가 H로 올라가므로 M이 선점할 수 없다.
H의 대기 시간이 최소화된다.
Java의 synchronized는 우선순위 상속을 지원하지 않는다. ReentrantLock도 마찬가지다. 이것은 리얼타임 OS에서 주로 중요한 문제이며, RTOS에서는 우선순위 상속이나 우선순위 천장(Priority Ceiling) 프로토콜을 제공한다.
9. Java에서의 데드락 — 실무 가이드
9.1 데드락 탐지: Thread Dump
Java에서 데드락이 의심되면 Thread Dump를 통해 확인할 수 있다.
1
2
3
4
5
# Thread Dump 생성
jstack <PID>
# 또는 kill 시그널
kill -3 <PID>
Thread Dump에서 데드락이 있으면 다음과 같이 표시된다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8b4c003f08 (object 0x000000076b4c1c38, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007f8b4c006358 (object 0x000000076b4c1c48, a java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadlockExample.method2(DeadlockExample.java:25)
- waiting to lock <0x000000076b4c1c38>
- locked <0x000000076b4c1c48>
"Thread-0":
at DeadlockExample.method1(DeadlockExample.java:15)
- waiting to lock <0x000000076b4c1c48>
- locked <0x000000076b4c1c38>
9.2 프로그래밍 방식 탐지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.lang.management.*;
public class DeadlockMonitor {
private static final ThreadMXBean threadBean =
ManagementFactory.getThreadMXBean();
public static void startMonitoring() {
ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
long[] deadlockedThreads = threadBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
ThreadInfo[] infos = threadBean.getThreadInfo(
deadlockedThreads, true, true);
StringBuilder sb = new StringBuilder("DEADLOCK DETECTED!\n");
for (ThreadInfo info : infos) {
sb.append(String.format(
"Thread: %s (state: %s)\n Lock: %s\n Owner: %s\n",
info.getThreadName(),
info.getThreadState(),
info.getLockName(),
info.getLockOwnerName()
));
}
log.error(sb.toString());
}
}, 0, 10, TimeUnit.SECONDS);
}
}
9.3 tryLock을 활용한 데드락 방지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SafeTransfer {
private static final long TIMEOUT = 1000;
public boolean transfer(Account from, Account to, BigDecimal amount)
throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TIMEOUT);
while (true) {
if (from.getLock().tryLock()) {
try {
if (to.getLock().tryLock()) {
try {
from.withdraw(amount);
to.deposit(amount);
return true;
} finally {
to.getLock().unlock();
}
}
} finally {
from.getLock().unlock();
}
}
if (System.nanoTime() >= deadline) {
return false; // 타임아웃
}
// 라이브락 방지: 랜덤 백오프
Thread.sleep(ThreadLocalRandom.current().nextLong(10, 50));
}
}
}
9.4 Lock-free 자료구조
데드락을 원천적으로 방지하는 방법은 락을 사용하지 않는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// AtomicInteger: CAS(Compare-And-Swap) 기반, 락 없음
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // 데드락 불가능
}
// ConcurrentHashMap: 세그먼트 단위 락, 세밀한 동시성 제어
private final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void update(String key) {
map.merge(key, 1, Integer::sum); // 원자적 연산
}
9.5 Spring/JPA에서의 데드락
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// MySQL Gap Lock에 의한 데드락 예시
// T1: DELETE FROM order WHERE status = 'EXPIRED' AND user_id = 1;
// T2: DELETE FROM order WHERE status = 'EXPIRED' AND user_id = 2;
// 두 트랜잭션이 같은 Gap에 대해 Gap Lock을 잡으면 데드락 가능
// 해결 방법 1: 락 순서 통일
@Transactional
public void cleanupExpiredOrders(Long userId) {
// Primary Key 순서로 처리
List<Order> orders = orderRepository
.findByUserIdAndStatusOrderById(userId, "EXPIRED");
orderRepository.deleteAll(orders);
}
// 해결 방법 2: 격리 수준 변경
@Transactional(isolation = Isolation.READ_COMMITTED)
public void cleanupExpiredOrders(Long userId) {
// READ COMMITTED에서는 Gap Lock이 걸리지 않음
orderRepository.deleteByUserIdAndStatus(userId, "EXPIRED");
}
9.6 실무에서 진짜 중요한 데드락 대응 원칙
데드락을 운영체제 교과서 개념으로만 설명하면 면접 답변이 평면적이다. 실무에서는 아래 원칙을 같이 말해야 한다.
- 데드락은 “없애는 것”보다 “발생 빈도를 낮추고, 발생 시 빨리 복구하는 것”이 더 현실적이다.
- 애플리케이션 락과 DB 락은 별개의 계층이므로 둘 다 순서 일관성이 필요하다.
- 락 범위를 최소화하고, 트랜잭션을 짧게 유지하는 것이 기본이다.
- 외부 API 호출, 파일 I/O, 긴 계산을 락이나 트랜잭션 안에 넣지 않는다.
- DB 데드락은 재시도가 기본 대응이지만, 무한 재시도는 또 다른 장애를 만든다.
9.7 데드락, 기아, 라이브락, 경합을 구분해서 말하기
면접에서는 이 네 개념을 섞어 말하는 경우를 자주 본다.
- 데드락: 서로가 가진 자원을 기다려 영원히 멈춤
- 기아: 자원을 받을 기회가 계속 밀려 장시간 실행 못 함
- 라이브락: 상태는 바뀌지만 실제 진전이 없음
- 경합(Contention): 여러 실행 주체가 같은 자원을 두고 경쟁해 느려짐
즉 모든 경합이 데드락은 아니고, 모든 응답 지연이 락 문제도 아니다. 이 구분을 분명히 해야 원인 분석이 가능하다.
9.8 면접형 답변 프레임
“데드락을 어떻게 예방하겠습니까?”라는 질문에는 아래 구조가 가장 안정적이다.
- 공유 자원과 락 획득 순서를 식별한다.
- 전역 순서를 정해 순환 대기를 끊는다.
- 락 보유 시간을 최소화한다.
- 필요하면 tryLock/timeout과 재시도 정책을 둔다.
- DB에서는 인덱스와 접근 순서를 일관화한다.
- 운영에서는 thread dump, deadlock log, retry metric으로 관측한다.
예시 답변:
저는 먼저 어떤 자원에 어떤 순서로 락이 걸리는지 정리합니다. 그다음 전역 락 순서를 강제해 순환 대기를 원천 차단하고, 트랜잭션 범위를 줄여 락 보유 시간을 최소화합니다. DB에서는 같은 조건을 같은 인덱스와 같은 순서로 접근하도록 맞추고, 그래도 데드락이 발생할 수 있으므로 재시도 정책과 모니터링을 함께 둡니다.
10. 면접에서 자주 나오는 데드락 질문과 답변
Q1: 데드락이란 무엇이며, 발생 조건 4가지를 설명하세요.
두 개 이상의 프로세스가 서로가 보유한 자원을 요청하면서 무한히 대기하는 상태입니다. 상호 배제(자원을 하나만 사용), 점유와 대기(잡은 채로 기다림), 비선점(강제 해제 불가), 순환 대기(대기가 순환) 네 가지가 동시에 성립할 때 발생합니다. 하나라도 깨뜨리면 데드락을 방지할 수 있으며, 실무에서는 주로 순환 대기를 깨뜨리는 방법(락 순서 통일)을 사용합니다.
Q2: 데드락 예방/회피/탐지의 차이를 설명하세요.
예방은 4가지 조건 중 하나를 구조적으로 불가능하게 만드는 것으로, 자원 이용률이 낮아질 수 있습니다. 회피는 자원 할당 전에 안전 상태를 유지할 수 있는지 검사하는 것으로, 은행원 알고리즘이 대표적이지만 최대 요구량을 사전에 알아야 하는 한계가 있습니다. 탐지는 데드락 발생을 허용하되 주기적으로 탐지하여 복구하는 것으로, InnoDB가 이 방식을 사용합니다.
Q3: 데드락과 라이브락의 차이를 설명하세요.
데드락은 프로세스가 완전히 멈춰서 대기하는 상태이고, 라이브락은 프로세스가 실행은 되지만 유용한 진전이 없는 상태입니다. 두 스레드가 서로 양보만 반복하는 것이 라이브락의 예입니다. 데드락은 Thread Dump에서 BLOCKED 상태로 명확히 보이지만, 라이브락은 RUNNABLE 상태이므로 탐지가 더 어렵습니다.
Q4: Java에서 데드락을 감지하고 해결하는 방법은?
jstack으로 Thread Dump를 생성하면 “Found one Java-level deadlock” 메시지로 데드락을 확인할 수 있습니다. ThreadMXBean.findDeadlockedThreads()로 프로그래밍 방식 탐지도 가능합니다. 해결 방법으로는 락 순서 통일, tryLock() 타임아웃, Lock-free 자료구조(AtomicInteger, ConcurrentHashMap) 등이 있습니다.
Q5: DB에서 데드락이 발생하면 어떻게 되나요?
InnoDB는 Wait-for Graph로 실시간 데드락을 탐지합니다. 사이클이 감지되면 Undo 로그 양이 적은 트랜잭션을 희생자로 선택하여 롤백하고 ERROR 1213을 반환합니다. 애플리케이션은 이 에러를 받으면 재시도해야 합니다. Spring에서는 @Retryable과 DeadlockLoserDataAccessException으로 자동 재시도 패턴을 구현합니다.
Q6: 우선순위 역전이란 무엇이고, 실제 사례가 있나요?
높은 우선순위 프로세스가 낮은 우선순위 프로세스의 자원을 기다리는 동안, 중간 우선순위 프로세스가 CPU를 선점하여 간접적으로 높은 우선순위 프로세스를 지연시키는 현상입니다. 1997년 Mars Pathfinder에서 발생하여 시스템이 반복 리셋되었습니다. 해결책은 우선순위 상속 프로토콜로, 자원 보유자의 우선순위를 대기자의 우선순위로 일시적으로 올려줍니다.
Q7: synchronized를 쓰면 데드락이 더 잘 생기나요?
synchronized 자체가 데드락을 만드는 것은 아닙니다. 문제는 여러 락을 중첩해서 잡는 방식과 순서 불일치입니다. ReentrantLock은 tryLock 같은 회피 수단이 있어 제어가 더 유연하지만, 잘못 쓰면 역시 데드락이 납니다.
Q8: DB 인덱스가 데드락에도 영향을 주나요?
영향이 큽니다. 인덱스가 부적절하면 더 넓은 범위를 스캔하면서 더 많은 레코드/갭 락을 잡게 되고, 이는 데드락 확률을 높입니다. 그래서 데드락은 단순히 애플리케이션 코드만이 아니라 실행 계획과 인덱스 설계 문제이기도 합니다.
Q9: 재시도는 몇 번까지 해야 하나요?
정답은 없지만, 보통 짧은 백오프를 둔 2~3회 재시도가 현실적입니다. 중요한 것은 무한 재시도를 피하고, 재시도 실패 시 로그와 메트릭으로 원인을 추적할 수 있어야 한다는 점입니다.
결론
데드락은 동시성 프로그래밍에서 피할 수 없는 주제다. 핵심을 정리하면 다음과 같다.
발생 조건: 상호 배제, 점유와 대기, 비선점, 순환 대기가 동시에 성립할 때 발생한다. 자원 할당 그래프에서 사이클이 데드락의 필요조건이며, 단일 인스턴스일 때는 충분조건이다.
처리 전략의 스펙트럼: 예방(조건 차단) → 회피(안전 상태 유지) → 탐지·복구(사후 처리) → 무시(타조 알고리즘) 순으로 보수적에서 공격적으로 갈수록 오버헤드는 줄지만 데드락 위험은 증가한다. 범용 OS는 주로 무시, DBMS는 탐지·복구를 선택한다.
실무 대응: 락 순서 통일이 가장 실용적인 예방법이다. tryLock() 타임아웃으로 비선점 조건을 완화할 수 있다. DB 데드락은 재시도 패턴으로 대응한다. 데드락, 라이브락, 기아, 우선순위 역전은 모두 동시성 문제의 변주이며, 각각의 특성과 해결책을 구분하여 이해해야 한다.