- 들어가며 — deadlock detected 로그를 처음 만났다면
- 데드락은 장애가 아니라 정상 동작이다
- PostgreSQL 로그에서 데드락 리포트 읽기
- MySQL의 SHOW ENGINE INNODB STATUS 읽기
- 실무에서 반복되는 세 가지 패턴
- 예방 원칙 — 순서, 길이, 인덱스
- deadlock_timeout과 lock_timeout, 그리고 락 대기와의 구분
- 마치며 — 데드락은 없애는 것이 아니라 재시도 가능하게 만드는 것
들어가며 — deadlock detected 로그를 처음 만났다면
운영 로그에 이런 줄이 뜹니다.
ERROR: deadlock detected
DETAIL: Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
HINT: See server log for query details.
CONTEXT: while updating tuple (128,17) in relation "orders"
처음 보면 심각한 장애처럼 보이지만, 이 로그가 찍혔다는 것은 데이터베이스가 문제를 감지하고 이미 해결했다는 뜻입니다. 한쪽 트랜잭션이 롤백되었고 나머지 한쪽은 정상적으로 진행했습니다. 데이터는 일관됩니다.
진짜 문제는 두 가지입니다. 첫째, 애플리케이션이 이 오류를 재시도하지 않고 사용자에게 그대로 노출하고 있을 가능성. 둘째, 데드락이 반복적으로 발생한다면 트랜잭션 설계에 구조적 결함이 있다는 신호라는 점.
이 글은 로그에서 어느 두 쿼리가 엮였는지 특정하는 방법부터 시작해서, 반복되는 세 가지 패턴과 예방 원칙, 그리고 데드락과 단순 락 대기를 혼동하지 않는 법을 다룹니다.
데드락은 장애가 아니라 정상 동작이다
데드락은 두 트랜잭션이 서로가 쥔 잠금을 기다릴 때 발생합니다.
시간 ──────────────────────────────────────────▶
트랜잭션 A: row 1 잠금 획득 ─────── row 2 잠금 요청 ─── 대기
트랜잭션 B: row 2 잠금 획득 ────── row 1 잠금 요청 ─── 대기
두 쪽 모두 영원히 대기
이 상태에서 데이터베이스가 아무것도 하지 않으면 두 트랜잭션은 영원히 멈춥니다. 그래서 PostgreSQL과 MySQL 모두 잠금 대기 그래프에서 순환을 탐지하면 한쪽을 골라 중단시킵니다. 이것이 정상 설계이고, 다른 선택지는 없습니다.
핵심은 여기서 나옵니다. 데드락을 완전히 없애는 것은 목표가 될 수 없습니다. 어떤 설계에서도 동시성이 있는 한 발생 확률을 0으로 만들 수는 없습니다. 목표는 두 가지입니다. 빈도를 실용적인 수준까지 낮추는 것, 그리고 발생했을 때 애플리케이션이 조용히 재시도하는 것.
# PostgreSQL: 40P01 (deadlock_detected)
# MySQL: 1213 (ER_LOCK_DEADLOCK)
RETRYABLE = {"40001", "40P01"}
재시도할 때 두 가지를 지켜야 합니다. 트랜잭션 전체를 처음부터 다시 실행할 것, 그리고 지연에 지터를 섞을 것. 고정 지연으로 재시도하면 두 트랜잭션이 같은 간격으로 다시 충돌합니다.
PostgreSQL 로그에서 데드락 리포트 읽기
기본 설정으로는 앞의 로그처럼 "서버 로그를 보라"는 힌트만 나옵니다. 실제 쿼리를 보려면 로그 설정이 되어 있어야 합니다.
-- 데드락과 오래 걸린 잠금 대기를 로그에 남긴다
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
ALTER SYSTEM SET log_min_error_statement = 'error';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a ';
SELECT pg_reload_conf();
log_line_prefix에 프로세스 ID와 애플리케이션 이름을 넣는 것이 결정적입니다. 데드락 리포트는 프로세스 번호로 상대를 지목하므로, 그 번호로 다른 로그 줄을 찾아야 쿼리를 복원할 수 있습니다. 애플리케이션 쪽에서 커넥션 문자열에 application_name을 설정해 두면 어느 서비스가 관여했는지 바로 드러납니다.
설정 후의 로그는 이렇게 나옵니다.
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service ERROR: deadlock detected
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service DETAIL: Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
Process 24188: UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
Process 24193: UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service HINT: See server log for query details.
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service CONTEXT: while updating tuple (128,17) in relation "inventory"
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service STATEMENT: UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
읽는 순서는 이렇습니다.
Process 24188 waits ... blocked by process 24193— 순환의 방향을 파악합니다. 두 줄이면 2자 순환이고, 세 줄 이상이면 세 개 이상의 트랜잭션이 얽힌 것입니다.Process 24188:와Process 24193:줄 — 각 프로세스가 마지막으로 기다리고 있던 문장입니다.CONTEXT— 어느 테이블의 어느 튜플에서 막혔는지 알려 줍니다.
여기서 가장 중요한 오해를 짚고 갑니다. 리포트에 찍힌 두 문장은 데드락의 마지막 조각일 뿐 원인이 아닙니다. 24188은 B-200을 기다리고 있지만, 그가 이미 쥐고 있던 잠금은 A-100입니다. 그 A-100 잠금을 만든 문장은 리포트에 나오지 않습니다. 즉 원인 규명에는 두 트랜잭션이 그 이전에 실행한 문장 전체가 필요합니다.
그래서 실무에서는 이런 조합을 씁니다.
-- 관련 프로세스가 그 시각에 실행한 다른 문장을 로그에서 역추적
-- (log_line_prefix에 %p가 있어야 가능하다)
grep -E '\[24188\]|\[24193\]' /var/log/postgresql/postgresql.log \
| awk '$1 " " $2 >= "2026-07-26 14:01:50"' \
| head -60
데드락이 이미 지나간 뒤라면 로그가 유일한 증거입니다. 하지만 지금 진행 중인 잠금 대기를 보고 싶다면 카탈로그를 조회하면 됩니다.
SELECT a.pid,
a.application_name,
a.state,
now() - a.xact_start AS tx_age,
now() - a.state_change AS state_age,
pg_blocking_pids(a.pid) AS blocked_by,
left(a.query, 80) AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
AND (cardinality(pg_blocking_pids(a.pid)) > 0
OR a.state = 'idle in transaction')
ORDER BY tx_age DESC;
pid | application_name | state | tx_age | state_age | blocked_by | query
-------+------------------+---------------------+--------------+--------------+------------+--------------------------------
24193 | order-service | idle in transaction | 00:04:12.881 | 00:04:10.220 | {} | SELECT ... FOR UPDATE
24188 | order-service | active | 00:00:08.114 | 00:00:08.101 | {24193} | UPDATE inventory SET stock ...
pg_blocking_pids가 비어 있는데 상태가 idle in transaction인 프로세스가 있다면 그것이 근원입니다. 트랜잭션을 열어 놓고 애플리케이션이 다른 일을 하고 있다는 뜻입니다.
MySQL의 SHOW ENGINE INNODB STATUS 읽기
MySQL은 마지막 데드락 하나만 상태 출력에 보관합니다.
SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-26 14:02:11 0x7f2a1c0d5700
*** (1) TRANSACTION:
TRANSACTION 4821094, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8812, query id 2210934 10.0.3.21 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200'
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; ... 0: len 5; hex 412d313030; asc A-100;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; ... 0: len 5; hex 422d323030; asc B-200;
*** (2) TRANSACTION:
TRANSACTION 4821096, ACTIVE 2 sec starting index read
MySQL thread id 8815, query id 2210941 10.0.3.22 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100'
*** (2) HOLDS THE LOCK(S):
... asc B-200;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... asc A-100;
*** WE ROLL BACK TRANSACTION (2)
PostgreSQL 리포트보다 정보가 많습니다. 특히 HOLDS THE LOCK(S) 절이 있어서 각 트랜잭션이 이미 쥐고 있던 잠금까지 보여 줍니다. 위 출력만으로 순환이 완전히 재구성됩니다. 트랜잭션 1은 A-100을 쥐고 B-200을 기다리고, 트랜잭션 2는 B-200을 쥐고 A-100을 기다립니다.
읽을 때 눈여겨볼 항목은 이렇습니다.
asc뒤의 문자열 — 잠긴 레코드의 인덱스 키 값입니다. 어떤 행인지 바로 알 수 있습니다.index PRIMARY of table— 어느 인덱스에 잠금이 걸렸는지 나옵니다. 보조 인덱스 이름이 보인다면 그 인덱스 경로로 접근했다는 뜻입니다.lock_mode X locks rec but not gap— 레코드만 잠근 상태입니다.locks gap before rec나lock_mode X만 있으면 갭 락 또는 넥스트 키 락입니다. 갭 락이 보인다면 잠금 범위가 넓다는 신호입니다.WE ROLL BACK TRANSACTION (2)— InnoDB는 되돌릴 작업량이 적은 쪽, 즉 변경한 행 수가 적은 쪽을 희생시킵니다.
문제는 이 출력이 마지막 하나만 남는다는 것입니다. 운영에서는 전부 로그로 남겨야 합니다.
SET GLOBAL innodb_print_all_deadlocks = ON;
이 설정을 켜면 모든 데드락이 에러 로그에 기록됩니다. 부하가 걱정되는 수준으로 데드락이 발생한다면 그 자체가 이미 고쳐야 할 문제입니다.
실무에서 반복되는 세 가지 패턴
수많은 데드락 사례를 분해해 보면 대개 세 가지로 수렴합니다.
| 패턴 | 로그에서 보이는 모습 | 해법 |
|---|---|---|
| 다중 행 갱신 순서 불일치 | 두 트랜잭션이 같은 테이블의 서로 다른 키를 교차로 대기 | 항상 같은 기준으로 정렬한 순서대로 잠금 |
| 인덱스 부재로 락 범위 확대 | 잠긴 행 수가 실제 대상보다 훨씬 많고 갭 락이 보임 | 조건 컬럼에 인덱스 추가 |
| 외래 키의 부모 행 잠금 | 자식 테이블 INSERT인데 부모 테이블 레코드에서 대기 | 부모 갱신 트랜잭션 축소, 필요시 제약 검증 지연 |
패턴 1 — 갱신 순서 불일치
가장 흔합니다. 두 요청이 같은 두 행을 반대 순서로 건드립니다.
-- 요청 A
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
COMMIT;
-- 요청 B (동시에)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;
애플리케이션 코드에서는 이 순서가 보통 장바구니에 담긴 순서, 즉 사용자마다 다른 순서입니다. 해결은 잠글 대상을 미리 정렬하는 것입니다.
-- 한 문장으로 처리하고 순서를 명시한다
UPDATE inventory
SET stock = stock - v.qty
FROM (VALUES ('B-200', 1), ('A-100', 2)) AS v(sku, qty)
WHERE inventory.sku = v.sku;
주의할 점이 있습니다. 단일 UPDATE 문의 잠금 획득 순서는 실행 계획에 따라 결정되므로 SQL만으로 완전히 보장되지는 않습니다. 확실히 하려면 잠금을 먼저 정렬된 순서로 잡습니다.
BEGIN;
SELECT sku FROM inventory
WHERE sku IN ('B-200', 'A-100')
ORDER BY sku
FOR UPDATE;
-- 이후 갱신은 순서에 상관없이 안전하다
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;
배치 작업이라면 애플리케이션 쪽에서 키를 정렬해서 넘기는 것이 원칙입니다. 정렬 기준은 무엇이든 상관없고, 모든 코드 경로에서 같은 기준을 쓰는 것만이 중요합니다.
패턴 2 — 인덱스 부재로 락 범위가 넓어진 경우
MySQL에서 특히 치명적입니다. 조건 컬럼에 인덱스가 없으면 InnoDB는 훑는 모든 레코드에 잠금을 겁니다.
-- order_no에 인덱스가 없다면
UPDATE orders SET status = 'cancelled' WHERE order_no = 'ORD-20260726-001';
-- 실제로는 테이블 전체 스캔 + 스캔한 모든 행에 잠금
대상은 한 행인데 백만 행이 잠깁니다. 이 상태에서는 무관한 두 요청도 서로 충돌합니다. 데드락 로그에 등장하는 두 쿼리가 논리적으로 아무 관계가 없어 보인다면 이 패턴을 의심하십시오.
CREATE INDEX idx_orders_order_no ON orders (order_no);
PostgreSQL은 인덱스 없이 스캔한 행에 무조건 잠금을 걸지는 않지만, 갱신 대상이 되는 행에는 걸립니다. 그래도 인덱스가 없으면 갱신 대상을 찾는 시간이 길어지고 트랜잭션이 길어지므로 결국 충돌 확률이 올라갑니다.
패턴 3 — 외래 키가 유발하는 부모 행 잠금
자식 테이블에 INSERT하면 참조 무결성을 지키기 위해 부모 행을 잠급니다. PostgreSQL은 부모 행에 FOR KEY SHARE 수준의 잠금을 걸고, InnoDB는 공유 잠금을 겁니다.
CREATE TABLE users (id bigserial PRIMARY KEY, name text);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint REFERENCES users(id),
total numeric
);
-- 세션 A
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 1000); -- users(7)에 KEY SHARE
UPDATE users SET name = 'Kim' WHERE id = 7; -- 자기 잠금이라 통과
-- 세션 B (동시)
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 2000); -- KEY SHARE 획득 (공유라 가능)
UPDATE users SET name = 'Lee' WHERE id = 7; -- 세션 A의 잠금 대기
두 세션이 같은 부모 행을 참조하면서 그 부모 행을 갱신하려 들면 데드락이 됩니다. 공유 잠금은 여러 트랜잭션이 동시에 쥘 수 있으므로, 그 상태에서 배타 잠금으로 승격하려는 순간 서로를 막습니다.
실무에서 이 패턴은 "주문을 넣으면서 사용자의 마지막 주문 시각을 갱신"하는 코드에서 자주 나옵니다. 해법은 부모 갱신을 없애거나, 별도 테이블로 분리하거나, 순서를 강제해 부모를 먼저 배타적으로 잠그는 것입니다.
-- 부모를 먼저 배타적으로 잠가 승격 경합을 없앤다
BEGIN;
SELECT id FROM users WHERE id = 7 FOR UPDATE;
INSERT INTO orders (user_id, total) VALUES (7, 1000);
UPDATE users SET last_ordered_at = now() WHERE id = 7;
COMMIT;
PostgreSQL에서는 참조 컬럼을 갱신하지 않는 UPDATE라면 FOR KEY SHARE와 충돌하지 않으므로 이 문제가 덜합니다. 그럼에도 같은 부모 행을 놓고 배타 잠금을 다투는 구조라면 마찬가지입니다.
예방 원칙 — 순서, 길이, 인덱스
세 가지 패턴에서 예방 원칙이 그대로 도출됩니다.
항상 같은 순서로 접근합니다. 여러 행을 잠근다면 기본 키 오름차순 같은 결정적 기준으로 정렬합니다. 여러 테이블을 잠근다면 테이블 순서도 코딩 규약으로 고정합니다.
트랜잭션을 짧게 유지합니다. 데드락 확률은 잠금 보유 시간에 비례합니다. 트랜잭션 안에 외부 API 호출, 파일 입출력, 사용자 입력 대기가 들어가면 안 됩니다. 이 규칙 하나만 지켜도 대부분의 데드락이 사라집니다.
-- 커넥션이 트랜잭션을 열어 놓고 방치하는 것을 강제로 끊는다
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
SELECT pg_reload_conf();
인덱스로 잠금 범위를 좁힙니다. 갱신 조건과 잠금 읽기 조건은 반드시 인덱스로 처리되어야 합니다. 조회 성능 문제가 아니라 동시성 문제입니다.
배치는 정렬하고 잘게 나눕니다. 10만 건을 한 트랜잭션에서 갱신하면 그 시간 내내 10만 행이 잠깁니다.
-- 정렬된 순서로, 작은 덩어리로
WITH batch AS (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'processing'
WHERE id IN (SELECT id FROM batch);
SKIP LOCKED는 여러 워커가 같은 큐를 소비할 때 데드락을 구조적으로 없애 줍니다. 잠긴 행을 기다리지 않고 건너뛰기 때문에 순환 자체가 생기지 않습니다.
deadlock_timeout과 lock_timeout, 그리고 락 대기와의 구분
세 가지 타임아웃이 있고 역할이 전혀 다릅니다.
-- 데드락 탐지를 시작하기까지 기다리는 시간. 탐지 비용이 크므로 바로 하지 않는다.
SHOW deadlock_timeout; -- 기본 1s
-- 잠금 획득을 포기하기까지의 시간. 기본은 무제한.
SHOW lock_timeout; -- 기본 0 (무제한)
-- 문장 전체의 실행 시간 상한.
SHOW statement_timeout; -- 기본 0 (무제한)
deadlock_timeout을 줄이면 데드락이 더 빨리 해소되지만, 순환이 없는 평범한 잠금 대기에 대해서도 매번 탐지 알고리즘이 돌아 CPU를 씁니다. 기본값 1초는 대부분의 환경에서 합리적입니다. 이 값을 100ms 같은 값으로 낮추라는 조언을 종종 보는데, 데드락이 잦아 응답 지연이 실제로 문제가 되는 상황이 아니라면 이득보다 비용이 큽니다.
정말 설정해야 할 것은 lock_timeout입니다.
-- 사용자 응답 경로에서는 잠금을 오래 기다리지 않는다
SET lock_timeout = '3s';
-- DDL은 특히 짧게. 대기 중에 뒤따르는 모든 쿼리가 함께 막히기 때문이다.
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN memo text;
MySQL에서 대응되는 설정은 innodb_lock_wait_timeout이고 기본값이 50초입니다. 웹 요청 경로에는 지나치게 깁니다.
SET SESSION innodb_lock_wait_timeout = 5;
마지막으로, 가장 흔한 오진 하나를 정리합니다. 락 대기와 데드락은 다른 문제이고 대응도 다릅니다.
- 데드락은 순환입니다. 데이터베이스가 1초 안에 감지해 한쪽을 죽입니다. 오류 코드는 PostgreSQL 40P01, MySQL 1213입니다. 증상은 간헐적 오류이지 지연이 아닙니다.
- 락 대기는 순환이 아닙니다. 누군가 잠금을 오래 쥐고 있고 나머지가 줄을 섭니다. 아무도 죽지 않고, 대신 응답 시간이 길어지다가 커넥션 풀이 고갈되어 서비스 전체가 멈춥니다. 오류 코드는 55P03이거나 타임아웃입니다.
"데드락 때문에 서비스가 느려졌다"는 진단은 대개 틀립니다. 데드락은 빠르게 해소되므로 지연을 만들지 않습니다. 느려졌다면 락 대기, 그리고 그 뒤에 있는 긴 트랜잭션이나 방치된 idle in transaction 커넥션을 봐야 합니다.
-- 잠금 대기 사슬의 뿌리를 찾는다
WITH RECURSIVE chain AS (
SELECT pid, unnest(pg_blocking_pids(pid)) AS blocker, 1 AS depth
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0
UNION ALL
SELECT c.blocker, unnest(pg_blocking_pids(c.blocker)), c.depth + 1
FROM chain c
WHERE cardinality(pg_blocking_pids(c.blocker)) > 0 AND c.depth < 10
)
SELECT DISTINCT a.pid, a.state, now() - a.xact_start AS tx_age, left(a.query, 60)
FROM chain c
JOIN pg_stat_activity a ON a.pid = c.blocker
WHERE cardinality(pg_blocking_pids(c.blocker)) = 0;
마치며 — 데드락은 없애는 것이 아니라 재시도 가능하게 만드는 것
정리하면 이렇습니다.
로그를 볼 때는 리포트에 찍힌 두 문장이 원인이 아니라는 점부터 기억하십시오. 그것은 각 트랜잭션이 마지막으로 기다린 문장일 뿐이고, 진짜 원인은 그 이전에 이미 잡아 둔 잠금입니다. MySQL이라면 HOLDS THE LOCK(S) 절이 그 정보를 주고, PostgreSQL이라면 같은 프로세스 번호의 앞선 로그를 찾아야 합니다.
예방은 순서, 길이, 인덱스 세 단어로 요약됩니다. 같은 순서로 잠그고, 트랜잭션을 짧게 유지하고, 잠금 조건에 인덱스를 두십시오. 여기에 lock_timeout과 idle_in_transaction_session_timeout을 설정해 두면 사고가 전면 장애로 번지지 않습니다.
그리고 이 모든 것을 다 하더라도 데드락은 발생합니다. 40P01과 1213을 잡아 백오프와 지터를 섞어 재시도하는 코드가 있어야 합니다. 그 코드가 없다면 예방 작업의 성과는 사용자에게 전달되지 않습니다.
현재 단락 (1/224)
운영 로그에 이런 줄이 뜹니다.