Deadlock: 서로의 락을 기다리며 영원히 멈추는 트랜잭션 순환 대기
데드락(Deadlock)은 둘 이상의 트랜잭션이 서로가 가진 락(Lock)을 기다리며 영원히 진행하지 못하는 상태다. 데이터베이스는 대기 그래프(Wait-for Graph)에서 순환을 탐지해 한쪽 트랜잭션을 강제로 롤백한다. PostgreSQL 기준으로 재현 방법, 로그 해석, 락 순서 통일과 재시도 같은 실무 대응법을 정리한다.
By 송민성
1. 개념
데드락(Deadlock, 교착 상태)은 둘 이상의 트랜잭션이 서로 상대가 쥔 락을 기다리느라 어느 쪽도 진행하지 못하는 상태다. 외부 개입 없이는 절대 풀리지 않는다.
가장 단순한 예는 다음과 같다.
- 트랜잭션 A: 계좌 1에 락을 잡고, 계좌 2의 락을 기다린다.
- 트랜잭션 B: 계좌 2에 락을 잡고, 계좌 1의 락을 기다린다.
둘 다 상대가 락을 놓기를 기다리므로 영원히 대기한다.
데드락이 성립하려면 다음 네 가지 조건(Coffman 조건)이 동시에 만족되어야 한다.
| 조건 | 의미 |
|---|---|
| 상호 배제(Mutual Exclusion) | 자원을 한 번에 한 트랜잭션만 점유한다 |
| 점유 대기(Hold and Wait) | 자원을 쥔 채로 다른 자원을 기다린다 |
| 비선점(No Preemption) | 쥔 락을 강제로 빼앗을 수 없다 |
| 순환 대기(Circular Wait) | 대기 관계가 원형으로 이어진다 |
이 중 하나라도 깨면 데드락은 발생하지 않는다. 실무에서는 주로 순환 대기를 깨는 방법(락 획득 순서 통일)을 쓴다.
2. 왜 이해해야 하는가
데드락은 락 기반 동시성 제어(Concurrency Control)의 필연적인 부산물이다. 동시성을 높이려고 행(row) 단위 락을 쓰는 순간, 서로 다른 순서로 락을 잡는 트랜잭션이 생기면 발생할 수 있다.
- 트래픽이 적을 때는 드물게 일어나다가 부하가 늘면 갑자기 나타난다.
- 개발 환경에서는 재현되지 않고 운영에서만 간헐적으로 에러가 난다.
- 원인은 쿼리 하나가 아니라 "트랜잭션 두 개의 락 순서 조합"에 있어 코드 리뷰만으로 찾기 어렵다.
3. 동작 원리
3.1 대기 그래프(Wait-for Graph)
DBMS는 "누가 누구를 기다리는가"를 방향 그래프로 관리한다. 노드는 트랜잭션, 간선 A → B는 "A가 B가 쥔 락을 기다림"을 뜻한다. 이 그래프에 사이클(cycle)이 생기면 데드락이다.
A ──▶ B
▲ │
└─────┘ (A→B, B→A: 순환)3.2 탐지와 해소
PostgreSQL은 락을 기다리기 시작한 뒤 deadlock_timeout(기본값 1초) 동안 기다려도 락을 얻지 못하면 그때 데드락 검사를 수행한다. 매번 검사하지 않는 이유는 대부분의 락 대기가 데드락이 아니라 짧은 정상 대기이고, 검사 비용을 아끼기 위해서다.
사이클이 발견되면 관련 트랜잭션 중 하나를 중단시켜 에러(SQLSTATE 40P01, deadlock_detected)를 반환한다. 중단된 트랜잭션은 롤백되고, 나머지는 락을 얻어 계속 진행한다. 어느 트랜잭션이 희생될지는 애플리케이션이 예측할 수 없다고 가정해야 한다.
MySQL InnoDB도 대기 그래프 기반으로 탐지하며, 보통 변경한 행이 적은 트랜잭션을 희생자로 고른다.
4. 코드 예제
4.1 SQL로 데드락 재현 (PostgreSQL)
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance INT NOT NULL
);
INSERT INTO accounts VALUES (1, 1000), (2, 1000);두 개의 psql 세션을 열고 아래 순서대로 실행한다.
-- 세션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- (1) id=1 락 획득-- 세션 B
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- (2) id=2 락 획득-- 세션 A
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- (3) B를 기다림-- 세션 B
UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- (4) A를 기다림 → 순환약 1초 뒤 한쪽 세션에 다음과 같은 에러가 발생한다.
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Process 5678 waits for ShareLock on transaction 1234; blocked by process 1234.4.2 해결 1: 락 획득 순서 통일
항상 id가 작은 행부터 잠그면 순환 대기가 생기지 않는다.
BEGIN;
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;ORDER BY id FOR UPDATE로 모든 트랜잭션이 같은 순서로 락을 잡게 만든다. 이체 방향이 1→2든 2→1이든 락 순서는 동일하다.
4.3 해결 2: 데드락 시 재시도 (TypeScript, node-postgres)
순서를 통일해도 인덱스 락, 외래 키 검사 등으로 데드락이 완전히 사라진다고 보장할 수는 없다. 그래서 40P01을 받으면 트랜잭션 전체를 재시도하는 방어 코드를 둔다.
import { Pool, PoolClient } from "pg";
const pool = new Pool(); // PGHOST, PGUSER 등 환경 변수 사용
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function withRetry<T>(
work: (client: PoolClient) => Promise<T>,
maxAttempts = 3,
): Promise<T> {
for (let attempt = 1; ; attempt++) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (err: any) {
await client.query("ROLLBACK").catch(() => {});
const isDeadlock = err?.code === "40P01";
if (!isDeadlock || attempt >= maxAttempts) throw err;
// 지수 백오프 + 지터(jitter)
await sleep(50 * 2 ** attempt + Math.random() * 50);
} finally {
client.release();
}
}
}
async function transfer(from: number, to: number, amount: number) {
await withRetry(async (c) => {
const [first, second] = from < to ? [from, to] : [to, from];
await c.query(
"SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE",
[[first, second]],
);
await c.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
await c.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
});
}
transfer(1, 2, 100).then(() => pool.end());5. 시간 복잡도 또는 성능 특성
- 탐지 비용: 대기 그래프의 사이클 탐지는 깊이 우선 탐색(DFS) 기반으로 O(V + E)이다. V는 대기 중인 트랜잭션 수, E는 대기 관계 수다.
- PostgreSQL은
deadlock_timeout경과 후에만 검사하므로, 데드락이 발생하면 최소 이 시간만큼은 요청이 멈춘다. 이 값을 지나치게 줄이면 검사가 잦아져 오버헤드가 늘고, 늘리면 데드락 해소가 늦어진다. - 희생된 트랜잭션은 이미 수행한 작업이 모두 롤백되므로, 긴 트랜잭션일수록 재시도 비용이 크다.
6. 실무 사용 사례
- 계좌 이체, 재고 차감: 두 개 이상의 행을 갱신하는 대표적인 경우다. 식별자 순서로 정렬해 잠근다.
- 배치와 온라인 요청의 충돌: 배치가 많은 행을 id 오름차순으로 갱신하는 동안, 온라인 요청이 다른 순서로 같은 행을 갱신하면 발생한다.
- 외래 키 관계: 자식 행을 삽입하면 부모 행에 공유 락이 걸린다. 부모와 자식을 서로 다른 순서로 갱신하는 트랜잭션이 충돌할 수 있다.
- 장애 분석: PostgreSQL은 서버 로그에 데드락 상세 정보를 남기고, MySQL은
SHOW ENGINE INNODB STATUS의LATEST DETECTED DEADLOCK항목에서 마지막 데드락을 확인할 수 있다.
현재 락 대기 상황은 다음 쿼리로 볼 수 있다.
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON TRUE
JOIN pg_stat_activity AS blocking ON blocking.pid = b.pid;7. 주의할 점
- 재시도는 트랜잭션 전체 단위로 한다. 실패한 구문만 다시 실행하면 이미 롤백된 앞선 작업이 사라진 상태가 된다.
- 재시도할 작업은 멱등성(Idempotency)이 있어야 한다. 트랜잭션 안에서 외부 API 호출이나 메시지 발행을 하면 재시도 시 중복 실행될 수 있다.
- 트랜잭션을 짧게 유지한다. 락을 쥐는 시간이 길수록 충돌 확률이 커진다. 트랜잭션 안에서 사용자 입력 대기나 외부 호출을 하지 않는다.
lock_timeout과 데드락은 다르다.lock_timeout은 락을 지정 시간 안에 못 얻으면 에러를 내는 설정이고, 순환이 없는 단순 장기 대기도 끊을 수 있다.- 단일 행에 대한
SELECT후UPDATE패턴은 락 업그레이드 충돌을 만들기 쉽다. 갱신이 목적이면 처음부터SELECT ... FOR UPDATE를 쓴다. - 격리 수준(Isolation Level)을 높이면 직렬화 실패(
40001)도 함께 발생할 수 있으므로, 재시도 로직에서40001과40P01을 함께 처리하는 경우가 많다.
8. 핵심 정리
- 데드락은 트랜잭션들이 서로의 락을 기다리는 순환 대기 상태이며, DBMS가 대기 그래프의 사이클을 탐지해 한쪽을 롤백시켜 푼다.
- PostgreSQL은
deadlock_timeout(기본 1초) 후 검사하고, 희생된 쪽은 SQLSTATE40P01에러를 받는다. - 예방의 핵심은 모든 트랜잭션이 같은 순서로 락을 잡게 하는 것(순환 대기 제거)이다.
- 완전한 제거는 어렵기 때문에 트랜잭션 전체를 재시도하는 로직을 함께 둔다.
- 트랜잭션은 짧게, 갱신할 행은 한 번에 정렬해서 잠그는 것이 기본 원칙이다.