데드락은 왜 일어나는가 — 락 순서와 대기 그래프로 진단하기
데드락은 버그가 아니라 정상 동작이다
먼저 오해부터 바로잡겠습니다. 데드락이 발생했다는 건 DB가 고장 났다는 뜻이 아닙니다. 오히려 DB가 제 역할을 하고 있다는 증거입니다 — 서로가 서로를 영원히 기다리는 상황을 그대로 뒀다면 두 트랜잭션 모두 응답 없이 멈췄을 텐데, DB는 이 상황을 감지해서 한쪽을 강제로 실패시키고 나머지를 진행시킵니다. 문제는 데드락 자체가 아니라, 데드락을 만드는 코드 패턴이 애플리케이션에 있다는 것입니다.
재현: 락 순서가 엇갈리면 반드시 걸린다
-- 세션 A: 계좌 1 → 계좌 2 순서로 락을 건다
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- (여기서 잠깐 멈춤 — 네트워크 지연이나 다른 로직 실행 중이라 가정)
-- 세션 B: 계좌 2 → 계좌 1 순서로 락을 건다
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 2;
UPDATE accounts SET balance = balance + 500 WHERE id = 1;
-- A가 이미 걸어둔 id=1 락을 기다리며 블록된다
-- 세션 A, 계속 진행
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
-- B가 이미 걸어둔 id=2 락을 기다리며 블록된다
-- A는 B를 기다리고, B는 A를 기다린다 → 데드락몇 초 안에 둘 중 하나가 다음과 같은 에러를 받습니다.
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.
HINT: See server log for query details.패턴을 보면 원인이 명확합니다 — 두 트랜잭션이 같은 두 자원(계좌 1, 계좌 2)을 서로 다른 순서로 잠급니다. A는 1→2, B는 2→1. 이렇게 락 순서가 엇갈리는 두 트랜잭션이 동시에 실행되면 데드락은 확률의 문제가 아니라 시간문제입니다.
대기 그래프로 이해하기
DB는 내부적으로 "누가 누구를 기다리는가"를 그래프로 추적합니다. 이 그래프에 순환(cycle)이 생기면 데드락입니다.
flowchart LR A["트랜잭션 A\n(id=1 락 보유)"] -->|"id=2를 기다림"| B["트랜잭션 B\n(id=2 락 보유)"] B -->|"id=1을 기다림"| A
PostgreSQL은 이 그래프에 순환이 생기는 순간을 감지해서(기본적으로 1초 주기로
확인) 둘 중 하나를 골라 ERROR: deadlock detected로 강제 종료시킵니다. 어느 쪽이
희생되는지는 보통 "더 적은 작업을 되돌려도 되는 쪽"이 기준이지만, 이 선택 로직에
의존하는 코드를 짜면 안 됩니다 — 어느 트랜잭션이든 실패할 수 있다고 가정하고
재시도 로직을 만들어야 합니다.
가장 효과적인 예방책: 락 순서 통일
데드락을 근본적으로 막는 가장 확실한 방법은 화려한 기법이 아니라 단순한 규칙입니다. 여러 행을 잠글 때는 항상 같은 순서로 잠근다. 위 예제라면 "계좌 ID가 작은 쪽부터 잠근다"는 규칙 하나로 데드락이 원천적으로 사라집니다.
-- 두 계좌 ID 중 작은 값부터 잠그도록 애플리케이션에서 정렬
-- id 1, 2가 아니라 항상 min(id), max(id) 순서로 UPDATE
UPDATE accounts SET balance = balance - 1000 WHERE id = LEAST(1, 2);
UPDATE accounts SET balance = balance + 1000 WHERE id = GREATEST(1, 2);실무에서는 이 규칙을 이체 로직처럼 "여러 행을 함께 건드리는 코드" 전체에 컨벤션으로 박아두는 것이 좋습니다. 코드 리뷰에서 "이 트랜잭션이 여러 행을 잠그는데, 순서가 고정되어 있는가?"를 체크리스트에 넣는 팀도 봤습니다 — 효과가 확실한 데 비해 비용이 거의 들지 않는 규칙입니다.
그 외의 완화 방법
락 순서 통일이 안 되는 상황(동적으로 잠글 대상이 정해지는 경우)이라면 다음을 검토합니다.
- 락 획득 타임아웃 —
SET lock_timeout = '3s'로 무한 대기 대신 빠른 실패를 선택합니다. 데드락 자체를 막지는 못하지만 사용자를 하염없이 기다리게 하는 것보다 낫습니다. - 재시도 로직 — 애플리케이션 레벨에서
deadlock detected에러를 잡아 짧은 backoff 후 트랜잭션을 다시 시도합니다. 데드락으로 희생된 트랜잭션은 데이터가 잘못돼서가 아니라 순서 문제로 죽은 것이므로, 재시도는 안전하고 정상적인 대응입니다. - 트랜잭션을 짧게 유지 — 락을 오래 들고 있을수록 다른 트랜잭션과 충돌할 창이 넓어집니다. 트랜잭션 안에서 외부 API 호출이나 무거운 계산을 하지 않는 것만으로도 데드락 빈도가 눈에 띄게 줄어듭니다.
데드락을 완전히 0으로 만드는 것은 목표가 아닙니다. 락 순서를 통일해 빈도를 극히 낮추고, 나머지는 재시도로 흡수하는 것이 현실적인 목표입니다.