Contents
1. 데드락이란
2. 데드락이 발생하기 위한 네 가지 조건
3. 자바 코드로 보는 데드락
4. 데드락 탐지
5. 해결 전략
6. 데이터베이스 데드락
7. 분산 환경 대응 전략
8. 자주하는 실수
9. 안전한 기본 패턴
1. 데드락이란
두 개 이상의 스레드나 프로세스가 서로의 자원을 기다리며 영원히 멈춰진 상태
일반적인 락 대기는 언젠가 락이 풀리면 다시 진행되지만, 데드락은 각 스레드가 서로의 락을 기다리는 구조가 만들어져 외부 개입 없이는 스스로 회복할 수 없다.
운영 환경에서는 오류 메시지가 바로 발생하지 않고 요청만 멈춘 것처럼 보일 수 있어 발견이 늦어질 수 있다. 특히 핵심 요청 처리 스레드가 데드락에 빠지면 장애 범위가 커지고, 심하면 시스템 전체가 멈출 수 있다.
특징:
- 여러 스레드나 프로세스가 서로의 자원을 기다림
- 누구도 진행하지 못하고, 누구도 락을 풀어주지 못함
- 외부 개입 없이는 자력 회복이 어려움
- 일반적인 락 대기와 달리 영원히 풀리지 않을 수 있음
- 운영 환경에서는 장애 원인 파악이 늦어질 수 있음
2. 데드락이 발생하기 위한 네 가지 조건
데드락은 아래 네 가지 조건이 동시에 만족될 때 발생한다.
| 조건 | 의미 |
|---|---|
| 상호배제 | 하나의 자원은 한 번에 하나의 스레드만 사용할 수 있음 |
| 점유와 대기 | 이미 자원을 가진 상태에서 다른 자원을 추가로 기다림 |
| 비선점 | 다른 스레드가 가진 자원을 강제로 빼앗을 수 없음 |
| 순환 대기 | 스레드들이 서로의 자원을 원형으로 기다림 |
| 이 네 가지 조건 중 하나라도 깨면 데드락은 발생하지 않는다. |
3. 자바 코드로 보는 데드락
아래 코드는 계좌 이체에서 발생할 수 있는 전형적인 데드락 예시이다.
public void transfer(Account from, Account to, long amount){
synchronized (from) {
synchronized (to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}두 사용자가 동시에 반대 방향으로 이체할 때 문제가 발생한다.
예시:
다음 두 요청이 동시에 실행됨
Thread-1: A 계좌 → B 계좌 이체
Thread-2: B 계좌 → A 계좌 이체
실행 흐름은 다음처럼 꼬일 수 있다.
Thread-1: A 계좌 락 획득
Thread-2: B 계좌 락 획득
Thread-1: B 계좌 락 대기
Thread-2: A 계좌 락 대기
이 경우 Thread-1은 B 계좌 락이 풀리기를 기다리고, Thread-2는 A 계좌 락이 풀리기를 기다린다. 하지만 두 스레드 모두 이미 자신이 가진 락을 놓지 않은 상태이므로 서로 영원히 기다리게 된다.
4. 데드락 탐지
4.1 JVM 데드락 탐지
자바 애플리케이션에서 데드락이 의심되면 가장 먼저 확인할 것은 스레드 덤프다.
jstack <PID>jstack은 JVM 프로세스 안의 모든 스레드 상태를 출력한다.
자바 레벨 데드락이 발생한 경우 JVM은 스레드 덤프에 다음과 같은 메시지를 출력할 수 있다.
Found one Java-level deadlock:4.2 스레드 덤프에서 봐야 할 항목
| 항목 | 의미 |
|---|---|
| BlOKCED | 모니터 락을 얻지 못해 대기 중인 상태 |
| waiting to lock | 현재 얻으려고 기다리는 락 |
| locked | 이미 획득한 락 |
| Found … deadlock | JVM이 직접 탐지한 데드락 메시지 |
4.3 운영 환경에서의 확인 순서
- 요청 지연 또는 응답 멈춤 확인
- CPU, 메모리, 스레드 수 등 기본 지표 확인
- 스레드 덤프 수집
BLOCKED상태 스레드 확인waiting to lock과locked관계 확인- DB 데드락 가능성이 있으면 DB 로그와 애플리케이션 로그 함께 확인
하지만 JVM 스레드 덤프만으로 모든 데드락을 확인할 수는 없다.
자바의 synchronized나 ReentrantLock 처럼 JVM 내부 락은 스레드 덤프로 확인할 수 있지만, DB 락이나 분산 락은 DB 로그, 트랜잭션 로그, 분산 추적 도구까지 함께 봐야 한다.
5. 해결 전략
데드락 해결 전략은 네 가지 발생 조건 중 하나를 깨는 방식이다.
| 항목 | 의미 |
|---|---|
| 락 순서 정렬 | 순환 대기 |
| 락 타임아웃 | 비선점 |
| tryLock 기반 회피 | 점유와 대기 |
| 단일 락 사용 | 순환 대기 또는 점유와 대기 구조 단순화 |
| 짧은 임계 구역 | 락 점유 시간 감소 |
5.1 락 순서 정렬
여러 자원을 동시에 잠가야 한다면, 모든 코드에서 항상 같은 순서로 락을 획득하도록 만드는 방식
가장 흔하고 효과적인 데드락 방지 방법이다.
public void transfer(Account from, Account to, long amount) {
Account first = from.getId() < to.getId() ? from : to;
Account second = first == from ? to : from;
synchronized(first) {
synchronized(second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}위 코드는 계좌 ID가 작은 계좌부터 먼저 락을 잡는다. 따라서 A → B 이체든, B → 이체 든 항상 같은 순서로 락을 획득한다.
예시:
A.id < B.id 라면
A → B 이체: A 락 → B 락
B → A 이체: A 락 → B 락서로 반대 방향으로 락을 잡는 상황이 사라지므로 순환 대기 조건이 깨진다.
장점:
- 구현이 단순하다.
- 성능 저하가 크지 않다.
- 데드락을 구조적으로 예방할 수 있다.
주의점:
- 모든 코드 경로에서 같은 기준을 사용해야 한다.
- 일부 코드만 다른 순서로 락을 잡으면 다시 데드락이 발생할 수 있다.
- ID가 같을 수 있는 경우에는 추가적인 기준이 필요하다.
5.2 락 타임아웃
일정 시간 동안 락을 얻지 못하면 대기를 포기하는 방식
ReentrantLock의 tryLock(timeout, unit)을 사용하면 구현할 수 있다.
public boolean tryTransfer(Account from, Account to, long amount) throws InterruptedException {
boolean fromLocked = false;
boolean toLocked = false;
try {
fromLocked = from.getLock().tryLock(500, TimeUnit.MILLISECONDS);
if (!fromLocked) {
return false;
}
toLocked = to.getLock().tryLock(500, TimeUnit.MILLISECONDS);
if (!toLocked) {
return false;
}
from.withdraw(amount);
to.deposit(amount);
return true;
} finally {
if (toLocked) {
to.getLock().unlock();
}
if (fromLocked) {
from.getLock().unlock();
}
}
}지정된 시간 안에 락을 얻지 못하면 작업을 포기하고 빠져나온다. 이 방식은 무한 대기를 막기 때문에 데드락 상황을 완화할 수 있다.
장점:
- 무한 대기를 방지할 수 있다.
- 실패 후 재시도 로직과 함께 사용할 수 있다.
- 운영 환경에서 요청이 영원히 멈추는 상황을 줄일 수 있다.
주의점:
- 타임아웃이 발생하면 작업 실패 처리나 재시도 정책이 필요하다.
- 너무 짧은 타임아웃은 정상 요청도 실패시킬 수 있다.
- 너무 긴 타임아웃은 장애 감지를 늦출 수 있다.
5.3 tryLock 기반 락 회피
tryLock()은 락을 즉시 시도하고, 실패하면 바로false를 반환하는 Lock을 기다리지 않는 방식
public boolean tryTransfer(Account from, Account to, long amount) {
if (!from.getLock().tryLock()) {
return false;
} try {
if (!to.getLock().tryLock()) {
return false;
} try {
from.withdraw(amount);
to.deposit(amount);
return true;
} finally {
to.getLock().unlock();
}
} finally {
from.getLock().unlock();
}
}이 방식의 핵심은 모든 락을 한 번에 잡거나, 하나라도 실패하면 이미 잡은 락을 모두 풀고 처음부터 다시 시도하는 것이다.
부분적으로 락을 잡은 채 다른 락을 계속 기다리지 않기 때문에 점유와 대기 조건을 깨는 방식이라고 볼 수 있다.
주의점:
- 라이브락을 주의해야 한다.
- 라이브락은 스레드들이 멈춰 있는 것은 아니지만, 서로 같은 패턴으로 양보하거나 재시도하면서 실제 작업은 계속 진행되지 않는 상태다.
- 예시:
- 락 획득 시도 → 일부 락 획득 실패 → 모두 해제 → 다시 동시에 재시도 → 또 충돌
따라서 재시도 전에 다음과 같은 전략을 사용할 수 있다.
- 짧은 대기 시간 추가
- 랜덤 백오프 적용
- 재시도 횟수 제한
- 실패 시 큐에 넣고 나중에 처리
5.4 단일 락으로 단순화
여러 자원마다 각각 락을 두지 않고, 하나의 큰 락으로 전체 임계 구역을 보호하는 방식
private final Object transferLock = new Object();
public void transfer(Account from, Account to, long amount) {
synchronized (transferLock) {
from.withdraw(amount);
to.deposit(amount);
}
}동시에 여러 이체를 처리할 수 없기 때문에 동시성은 떨어진다. 하지만 여러 락을 조합해서 사용할 일이 없어지므로 데드락 가능성이 크게 줄어든다.
단일 락의 트레이드오프:
| 항목 | 효과 |
|---|---|
| 데드락 가능성 | 거의 사라짐 |
| 동시성 | 크게 저하 |
| 코드 복잡도 | 단순 |
| 적합한 상황 | 짧은 임계 구역, 낮은 동시 접근, 안정성이 더 중요한 로직 |
단일 락은 성능보다 안정성이 중요한 구간에서 사용할 수 있다. 예를 들어 임계 구역이 짧고 동시 접근이 많지 않다면, 복잡한 락 설계보다 단일 락이 더 안전할 수 있다.
6. 데이터베이스 데드락
데드락은 자바 코드에서만 발생하는 것이 아니다. 데이터베이스에서도 트랜잭션이 서로의 락을 기다리면서 데드락이 발생할 수 있다.
예를 들어 두 트랜잭션이 같은 행을 반대 순서로 수정한다고 가정해보자.
Transaction-1: A row lock 획득 → B row lock 대기
Transaction-2: B row lock 획득 → A row lock 대기이 경우 DB 수준의 데드락이 발생한다.
대부분의 데이터베이스는 데드락을 감지하면 둘 중 하나의 트랜잭션을 강제로 롤백한다. Spring에서는 DB 데드락이 발생했을 때 다음과 같은 예외가 올라올 수 있다.
DeadlockLoserDataAccessException또는 DB와 드라이버에 따라 다음과 같은 예외로 감싸져 올라올 수 있다.
CannotAcquireLockException
TransactionSystemExceptionDB 데드락 대응 방법:
- 트랜잭션 안에서 락 획득 순서를 통일한다.
- 트랜잭션 범위를 짧게 유지한다.
- 불필요한 조회나 외부 API 호출을 트랜잭션 안에 넣지 않는다.
SELECT ... FOR UPDATE사용 시 정렬 순서를 명확히 한다.- 데드락 발생 시 재시도 로직을 둔다.
- 인덱스를 적절히 설계해 불필요한 범위 락을 줄인다.
SELECT FOR UPDATE 주의점:
- 여러 행을 잠가야 할 때는 항상 같은 순서로 조회해야 한다.
SELECT *
FROM account
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;이렇게 하면 여러 트랜잭션이 같은 행들을 잠그더라도 항상 id 오름차순으로 접근하게 되어 데드락 가능성을 줄일 수 있다.
7. 분산 환경 대응 전략
| 전략 | 설명 |
|---|---|
| 분산 락 타임아웃 | 일정 시간이 지나면 락이 자동 해제되도록 설정 |
| 락 순서 합의 | 여러 자원을 잠글 때 식별자 기준으로 순서를 통일 |
| 짧은 락 점유 | 락 안에서 외부 API 호출이나 긴 작업을 피함 |
| 사가 패턴 | 긴 락 대신 보상 트랜잭션으로 일관성 유지 |
| 분산 추적 | Trace ID로 서비스 간 호출 흐름 추적 |
| 멱등성 보장 | 재시도나 보상 처리 시 중복 실행 문제 방지 |
분산 환경에서는 락으로 모든 일관성을 보장하려고 하면 장애 전파 위험이 커질 수 있다. 따라서 긴 트랜잭션이나 긴 락 점유 대신, 가능한 경우 사가 패턴과 보상 트랜잭션으로 설계하는 것이 좋다.
8. 자주하는 실수
8.1 락 안에서 외부 API 호출
synchronized (lock) {
externalApi.call();
}락을 가진 상태에서 외부 API를 호출하면 외부 응답이 늦어질 때 락 점유 시간이 크게 늘어난다. 이로 인해 다른 스레드들이 모두 대기하게 되고, 장애 범위가 커질 수 있다.
가능하면 외부 호출은 락 밖에서 수행하고, 락 안에서는 공유 자원 변경만 짧게 처리해야 한다.
8.2 입력 순서에 따라 락 획득 순서가 달라짐
컬렉션을 순회하면서 여러 자원의 락을 잡을 때 입력 순서가 매번 달라지면 데드락 위험이 커진다.
for (Account account : accounts) {
synchronized (account) {
// 처리
}
}이 경우 accounts의 순서가 요청마다 다르면 스레드마다 락 획득 순서가 달라질 수 있다.
따라서 여러 자원을 잠글 때는 먼저 정렬해야 한다.
accounts.sort(Comparator.comparing(Account::getId));8.3 락 객체를 가변 필드로 둠
락 객체는 바뀌지 않아야 한다.
private Object lock = new Object();위처럼 락 객체를 변경할 수 있게 두면 같은 자원을 보호한다고 생각했지만 실제로는 서로 다른 락을 사용할 위험이 있다.
따라서 보통 락 객체는 final로 둔다.
private final Object lock = new Object();8.4 데드락 발생 후 재시작만 반복
데드락이 발생했을 때 프로세스를 재시작하면 당장은 문제가 사라질 수 있다. 하지만 락 획득 순서나 트랜잭션 구조가 그대로라면 같은 문제가 다시 발생한다.
따라서 재시작은 임시 복구일 뿐이고, 반드시 다음 자료를 함께 확인해야 한다.
- 스레드 덤프
- 애플리케이션 로그
- DB 데드락 로그
- 트랜잭션 쿼리 순서
- 요청 Trace ID
- 발생 시점의 트래픽 패턴
9. 안전한 기본 패턴
데드락을 줄이기 위한 기본 원칙은 다음과 같다.
- 여러 락을 잡아야 한다면 항상 같은 순서로 잡는다.
- 락 안에서는 공유 자원 변경만 짧게 처리한다.
- 락 안에서 외부 API 호출을 하지 않는다.
- 트랜잭션 안에서 불필요하게 긴 작업을 하지 않는다.
tryLock, 타임아웃, 재시도 전략을 검토한다.- DB에서 여러 행을 잠글 때는 정렬 기준을 통일한다.
- 분산 환경에서는 락보다 타임아웃, 멱등성, 보상 트랜잭션을 우선 고려한다.
- 데드락 발생 시 스레드 덤프와 DB 로그를 함께 분석한다.