- Published on
PostgreSQL deadlock detected 진단·해결 9단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 트랜잭션이 서로가 잡고 있는 락을 기다리며 원형 대기가 생기면 PostgreSQL은 한 쪽을 강제 중단시키고 deadlock detected를 발생시킵니다. 문제는 “누가 피해자였는가”보다 “왜 락 순서가 꼬였는가”에 있습니다. 이 글은 운영 환경에서 바로 적용 가능한 진단 절차와, 재발을 줄이는 설계/코딩 패턴을 9단계로 정리합니다.
참고로 데드락은 DB만의 문제가 아니라 애플리케이션 타임아웃·재시도 정책과도 강하게 연결됩니다. gRPC나 HTTP 타임아웃이 촉발하는 재시도 폭주까지 함께 보려면 Go gRPC 데드라인 초과? 컨텍스트·리트라이 튜닝도 같이 읽어두면 좋습니다.
0. 데드락의 전형적인 형태(감 잡기)
가장 흔한 패턴은 “같은 두 테이블/행을 서로 다른 순서로 업데이트”입니다.
- 트랜잭션 A:
orders(1)을 잠그고users(10)을 잠그려 함 - 트랜잭션 B:
users(10)을 잠그고orders(1)을 잠그려 함
둘 다 두 번째 락을 기다리며 원형 대기가 만들어지고, PostgreSQL이 감지 후 한 트랜잭션을 ERROR: deadlock detected로 종료합니다.
1단계. 에러 로그에서 “데드락 그래프”를 확보한다
운영에서 가장 먼저 할 일은 PostgreSQL 로그에서 데드락 상세를 확보하는 것입니다.
- 기본적으로 데드락이 감지되면 로그에 대기 관계가 출력됩니다.
- 가능하면 로그를 더 친절하게 만들기 위해 아래 설정을 점검합니다.
-- postgresql.conf 권장 점검 포인트(예시)
-- log_lock_waits = on
-- deadlock_timeout = '1s' -- 너무 길면 탐지가 늦고, 너무 짧으면 로그가 과도해질 수 있음
-- log_line_prefix = '%m [%p] %u@%d app=%a '
로그에서 특히 중요한 키워드:
Process ... waits for ...형태의 라인blocked by process ...체인- 어떤
relation(테이블) 또는transactionid락이 얽혔는지
이 단계에서 목표는 “어떤 SQL이 어떤 락을 잡고 있었는지”를 식별 가능한 수준으로 만드는 것입니다. application_name을 커넥션에 세팅해두면 원인 서비스 추적이 훨씬 쉬워집니다.
2단계. 같은 시각의 락 스냅샷을 pg_stat_activity로 채집한다
데드락은 발생 순간이 짧아 로그만으로 부족할 때가 많습니다. 장애 시각에 맞춰 pg_stat_activity를 덤프(또는 주기 수집)하면 재구성이 쉬워집니다.
SELECT
now() AS ts,
pid,
usename,
application_name,
client_addr,
state,
wait_event_type,
wait_event,
xact_start,
query_start,
left(query, 2000) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST, query_start;
포인트:
wait_event_type = 'Lock'인 세션이 많은지xact_start가 오래된 세션이 있는지(락을 오래 쥐고 있으면 데드락/락 경합이 증가)- 동일한 API 요청이 재시도되어 동일 쿼리가 중복 실행되는지
3단계. 누가 누구를 막고 있는지 pg_locks로 “대기 그래프”를 만든다
다음 쿼리는 “대기 중인 pid”와 “막고 있는 pid”를 연결해 줍니다.
WITH lock_waits AS (
SELECT
w.pid AS waiting_pid,
b.pid AS blocking_pid,
w.locktype,
w.mode AS waiting_mode,
b.mode AS blocking_mode,
w.relation::regclass AS relation
FROM pg_locks w
JOIN pg_locks b
ON b.locktype = w.locktype
AND b.database IS NOT DISTINCT FROM w.database
AND b.relation IS NOT DISTINCT FROM w.relation
AND b.page IS NOT DISTINCT FROM w.page
AND b.tuple IS NOT DISTINCT FROM w.tuple
AND b.virtualxid IS NOT DISTINCT FROM w.virtualxid
AND b.transactionid IS NOT DISTINCT FROM w.transactionid
AND b.classid IS NOT DISTINCT FROM w.classid
AND b.objid IS NOT DISTINCT FROM w.objid
AND b.objsubid IS NOT DISTINCT FROM w.objsubid
AND b.pid <> w.pid
WHERE NOT w.granted AND b.granted
)
SELECT
lw.relation,
lw.locktype,
lw.waiting_pid,
a1.application_name AS waiting_app,
a1.state AS waiting_state,
left(a1.query, 300) AS waiting_query,
lw.blocking_pid,
a2.application_name AS blocking_app,
a2.state AS blocking_state,
left(a2.query, 300) AS blocking_query,
lw.waiting_mode,
lw.blocking_mode
FROM lock_waits lw
JOIN pg_stat_activity a1 ON a1.pid = lw.waiting_pid
JOIN pg_stat_activity a2 ON a2.pid = lw.blocking_pid
ORDER BY lw.relation::text NULLS LAST, lw.waiting_pid;
이 결과로 다음을 확인합니다.
- 데드락에 관여한 테이블/인덱스(특히
relation) - 서로 다른 서비스(또는 워커)가 동일 리소스를 경쟁하는지
- 어떤 쿼리가 “첫 번째 락”을 잡고 “두 번째 락”에서 대기하는지
4단계. 문제 쿼리를 최소 재현 케이스로 줄인다(두 세션 스크립트)
운영에서 잡은 쿼리를 바탕으로, 로컬/스테이징에서 “두 개 세션”으로 재현해야 고칠 수 있습니다.
예시(서로 반대 순서로 업데이트):
-- 세션 A
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 1;
-- 여기서 잠깐 대기(애플리케이션 로직, 외부 API 호출 등)
UPDATE users SET last_order_id = 1 WHERE id = 10;
COMMIT;
-- 세션 B
BEGIN;
UPDATE users SET last_order_id = 1 WHERE id = 10;
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;
이 재현이 되면, 해결책은 대부분 “락 획득 순서 통일” 또는 “락 범위 축소”로 귀결됩니다.
5단계. 가장 흔한 원인 5가지를 빠르게 체크한다
데드락은 복잡해 보이지만, 실제 원인은 반복됩니다.
5-1. 테이블/행 업데이트 순서 불일치
- 같은 리소스를 여러 테이블에서 갱신하는데, 코드 경로마다 순서가 다름
5-2. SELECT ... FOR UPDATE와 일반 UPDATE의 조합
- 한 트랜잭션은
FOR UPDATE로 미리 잠그고, 다른 트랜잭션은 바로UPDATE - 특히 “여러 행을 잠근 다음 일부만 업데이트” 같은 패턴에서 꼬이기 쉽습니다.
5-3. 인덱스/유니크 제약 조건 충돌
- 서로 다른 키를 넣는 것 같아도 같은 유니크 인덱스 페이지/키 범위를 두고 경쟁할 수 있습니다.
INSERT ... ON CONFLICT DO UPDATE도 경합/데드락의 원인이 될 수 있습니다.
5-4. FK(외래 키)로 인한 잠금 전파
- 부모/자식 테이블 동시 갱신, 삭제/업데이트 시 참조 무결성 체크가 락을 추가로 유발
5-5. 트랜잭션이 너무 길다
- 락은 “잡는 것”보다 “오래 쥐는 것”이 더 위험합니다.
- 네트워크 호출, 파일 IO, 과도한 배치 처리 등이 트랜잭션 안에 들어가면 치명적입니다.
6단계. 즉시 완화책: 타임아웃과 “피해 최소화” 옵션을 적용한다
근본 해결 전이라도, 장애 폭발을 막기 위해 아래를 고려합니다.
6-1. lock_timeout으로 무한 대기 방지
-- 세션/트랜잭션 단위로 적용 가능
SET LOCAL lock_timeout = '2s';
락을 오래 기다리다 데드락으로 터지는 대신, 빨리 실패시키고 애플리케이션에서 재시도/에러 처리로 넘길 수 있습니다.
6-2. statement_timeout으로 장기 쿼리 차단
SET LOCAL statement_timeout = '5s';
6-3. 재시도는 “지수 백오프 + 지터”로 제한
데드락은 재시도로 회복 가능한 경우가 많지만, 무작정 즉시 재시도하면 락 경합을 더 키웁니다. 백오프/최대 재시도 횟수/서킷 브레이커를 함께 적용하세요. (타임아웃과 재시도 상호작용은 Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단에서도 유사한 결로 다룹니다.)
7단계. 근본 해결 1: 락 획득 순서를 “전역 규칙”으로 고정한다
가장 효과적인 해결책입니다.
- 여러 테이블을 갱신한다면 “항상 A 테이블 먼저, 그다음 B 테이블”
- 같은 테이블의 여러 행을 갱신한다면 “항상 PK 오름차순으로 잠금/업데이트”
예: 여러 행을 갱신해야 한다면 먼저 대상 행을 정렬해 잠그고 처리합니다.
-- 예: 같은 테이블 여러 행을 처리할 때 PK 순서로 잠금
BEGIN;
SELECT id
FROM orders
WHERE id = ANY(ARRAY[3,1,2])
ORDER BY id
FOR UPDATE;
-- 잠금 순서가 고정된 상태에서 업데이트
UPDATE orders SET status = 'paid'
WHERE id = ANY(ARRAY[1,2,3]);
COMMIT;
애플리케이션 레벨에서도 “업데이트 순서”를 유틸로 강제하거나, 도메인 서비스 간 호출 순서를 통일하는 식으로 규칙을 박아두는 것이 재발 방지에 좋습니다.
8단계. 근본 해결 2: 트랜잭션 범위를 줄이고, 잠금 강도를 낮춘다
8-1. 트랜잭션 내부에서 외부 작업을 제거
- HTTP 호출, 메시지 발행, 파일 업로드 같은 작업은 트랜잭션 밖으로 빼거나 Outbox 패턴을 사용
8-2. 필요한 행만 잠근다
- 불필요한
FOR UPDATE남발을 줄이고, 정말 필요한 행만 잠그도록 조건을 좁힙니다.
8-3. SKIP LOCKED/NOWAIT로 워커 경합을 완화
큐 처리/잡 워커에서 흔히 쓰는 패턴입니다.
-- 잠긴 행은 건너뛰고 다음 작업을 집어온다
WITH picked AS (
SELECT id
FROM job_queue
WHERE status = 'ready'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE job_queue j
SET status = 'processing'
FROM picked p
WHERE j.id = p.id
RETURNING j.*;
이 패턴은 “대기” 자체를 줄여 데드락 가능성을 낮춥니다.
9단계. 근본 해결 3: 인덱스/쿼리 플랜/테이블 상태까지 점검한다
데드락은 락 순서 문제지만, 락이 오래 유지되는 배경에는 “느린 쿼리”가 있는 경우가 많습니다.
- 업데이트/삭제가 테이블 스캔으로 커지면 더 많은 행을 오래 잠글 수 있습니다.
- 인덱스 부재, 잘못된 조인 순서, 통계 부정확 등이 원인이 됩니다.
필수로 해볼 것:
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders
SET status = 'paid'
WHERE id = 1;
Index Scan이 타는지- 불필요하게 많은 버퍼를 읽는지
- 트리거/제약 조건 체크로 시간이 늘어나지 않는지
또한 테이블 bloat가 심하면 업데이트가 느려지고 락 보유 시간이 길어질 수 있습니다. VACUUM/튜플 정리 이슈가 의심되면 PostgreSQL VACUUM 안 될 때 bloat 진단·해결도 함께 점검하세요.
실전 체크리스트(요약)
- 로그에서 데드락 체인을 확보했고,
application_name으로 가해/피해 서비스를 식별했는가 pg_stat_activity와pg_locks로 “대기 그래프”를 만들었는가- 두 세션 재현 스크립트로 문제를 고정했는가
- 업데이트/잠금 순서를 전역 규칙으로 통일했는가(테이블 순서, PK 정렬)
- 트랜잭션이 길어지는 외부 작업을 제거했는가
- 워커는
SKIP LOCKED또는NOWAIT로 경합을 줄였는가 lock_timeout,statement_timeout으로 피해를 제한했는가- 재시도는 백오프/지터/상한을 갖는가
- 느린 쿼리의 원인(인덱스, 플랜, bloat)을 제거했는가
마무리
deadlock detected는 “PostgreSQL이 이상하다”가 아니라 “동시성 하에서 락 순서가 불안정하다”는 신호입니다. 로그로 사실을 수집하고, 락 대기 그래프를 만들고, 재현한 뒤에 락 순서를 고정하는 것까지가 한 세트입니다. 여기에 트랜잭션 단축과 워커 패턴(SKIP LOCKED), 그리고 쿼리 성능 개선을 얹으면 재발 빈도를 체감할 정도로 낮출 수 있습니다.