본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

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)이 생기면 데드락이다.

text
A ──▶ B
▲     │
└─────┘   (A→B, B→A: 순환)

3.2 탐지와 해소

PostgreSQL은 락을 기다리기 시작한 뒤 deadlock_timeout(기본값 1초) 동안 기다려도 락을 얻지 못하면 그때 데드락 검사를 수행한다. 매번 검사하지 않는 이유는 대부분의 락 대기가 데드락이 아니라 짧은 정상 대기이고, 검사 비용을 아끼기 위해서다.

사이클이 발견되면 관련 트랜잭션 중 하나를 중단시켜 에러(SQLSTATE 40P01, deadlock_detected)를 반환한다. 중단된 트랜잭션은 롤백되고, 나머지는 락을 얻어 계속 진행한다. 어느 트랜잭션이 희생될지는 애플리케이션이 예측할 수 없다고 가정해야 한다.

MySQL InnoDB도 대기 그래프 기반으로 탐지하며, 보통 변경한 행이 적은 트랜잭션을 희생자로 고른다.

4. 코드 예제

4.1 SQL로 데드락 재현 (PostgreSQL)

sql
CREATE TABLE accounts ( id INT PRIMARY KEY, balance INT NOT NULL ); INSERT INTO accounts VALUES (1, 1000), (2, 1000);

두 개의 psql 세션을 열고 아래 순서대로 실행한다.

sql
-- 세션 A BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- (1) id=1 락 획득
sql
-- 세션 B BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- (2) id=2 락 획득
sql
-- 세션 A UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- (3) B를 기다림
sql
-- 세션 B UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- (4) A를 기다림 → 순환

약 1초 뒤 한쪽 세션에 다음과 같은 에러가 발생한다.

text
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가 작은 행부터 잠그면 순환 대기가 생기지 않는다.

sql
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을 받으면 트랜잭션 전체를 재시도하는 방어 코드를 둔다.

typescript
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 항목에서 마지막 데드락을 확인할 수 있다.

현재 락 대기 상황은 다음 쿼리로 볼 수 있다.

sql
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초) 후 검사하고, 희생된 쪽은 SQLSTATE 40P01 에러를 받는다.
  • 예방의 핵심은 모든 트랜잭션이 같은 순서로 락을 잡게 하는 것(순환 대기 제거)이다.
  • 완전한 제거는 어렵기 때문에 트랜잭션 전체를 재시도하는 로직을 함께 둔다.
  • 트랜잭션은 짧게, 갱신할 행은 한 번에 정렬해서 잠그는 것이 기본 원칙이다.