2026년 09월 06일 | DBMS Error 가이드
이 글에서 다루는 내용
40P01 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
40P01 deadlock detected 는?
PostgreSQL 에러 코드 40P01, deadlock detected는 두 개 이상의 트랜잭션이 서로 상대방이 보유한 잠금(Lock)을 기다리며 무한정 대기 상태에 빠졌을 때 발생합니다. PostgreSQL의 데드락 감지기(deadlock detector)는 주기적으로 이 상황을 탐지하여 관련 트랜잭션 중 하나를 강제로 롤백시키고 에러를 반환합니다. 이 에러는 고트래픽 OLTP 환경에서 특히 자주 발생하며, 방치할 경우 애플리케이션의 신뢰성과 성능에 심각한 영향을 미칩니다.
주요 발생 원인
1. 서로 다른 순서로 행(Row)을 잠그는 트랜잭션
데드락의 가장 고전적인 원인입니다. 트랜잭션 A가 행 1을 잠근 뒤 행 2를 잠그려 하고, 동시에 트랜잭션 B가 행 2를 잠근 뒤 행 1을 잠그려 하면 두 트랜잭션은 서로 영원히 기다리게 됩니다. 이 패턴은 ORM을 사용하는 환경에서 특히 감지하기 어렵고, 개발자가 의도하지 않은 순서로 UPDATE 쿼리가 실행되면서 발생하는 경우가 많습니다.
2. 암묵적 잠금(Implicit Lock)과 외래 키(Foreign Key) 충돌
PostgreSQL은 UPDATE, DELETE, INSERT 시 관련 행에 대해 암묵적으로 Row-Level Lock을 획득합니다. 특히 외래 키가 있는 테이블에서 부모 행을 수정하면 자식 테이블에 공유 잠금이 걸리는데, 동시에 다른 트랜잭션이 자식 행을 수정하면서 부모 행을 기다리는 상황이 발생할 수 있습니다. 이는 JOIN이 복잡한 스키마에서 디버깅하기 매우 까다로운 유형의 데드락입니다.
3. 배치 업데이트 시 정렬 없이 대량의 행을 처리
여러 워커(worker) 또는 스레드가 동일한 테이블의 여러 행을 동시에 업데이트할 때, 각 워커가 무작위 순서로 행을 처리하면 데드락이 발생할 가능성이 급격히 높아집니다. 예를 들어, 재고 시스템에서 여러 주문 처리 프로세스가 동시에 inventory 테이블을 업데이트하는 경우가 대표적입니다. 이런 상황은 트래픽이 몰리는 시간대에 집중적으로 에러가 쏟아지는 패턴으로 나타납니다.
해결 방법
원인 1 해결: 잠금 순서 통일 (Consistent Lock Ordering)
모든 트랜잭션에서 동일한 기본 키(PK) 순서로 행을 처리하도록 강제합니다. 애플리케이션 레벨에서 ID를 정렬한 후 쿼리를 실행하거나, 아래처럼 ORDER BY를 활용합니다.
-- 나쁜 예: 순서 보장 없이 업데이트 (데드락 위험)
-- 트랜잭션 A
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 트랜잭션 B (동시 실행, 역순)
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- 좋은 예: 항상 id 오름차순으로 처리
-- 트랜잭션 A와 B 모두 id=1 먼저, id=2 다음 순서로 처리
-- 배치 업데이트 시 FOR UPDATE와 ORDER BY 조합
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;
원인 2 해결: 명시적 잠금으로 외래 키 충돌 방지
외래 키 관계가 있는 테이블을 다룰 때, 먼저 부모 테이블의 행을 명시적으로 잠근 뒤 자식 테이블을 처리합니다.
-- 외래 키 충돌 방지: 부모 먼저 명시적 잠금
BEGIN;
-- 1단계: 부모 행 먼저 잠금
SELECT id FROM orders
WHERE id = 100
FOR UPDATE;
-- 2단계: 이후 자식 행 처리
UPDATE order_items
SET quantity = quantity - 1
WHERE order_id = 100 AND product_id = 55;
UPDATE orders
SET total_amount = total_amount - 2500
WHERE id = 100;
COMMIT;
-- 데드락 발생 후 재시도 로직 (PL/pgSQL 예시)
DO $$
DECLARE
retry_count INT := 0;
max_retries INT := 3;
BEGIN
LOOP
BEGIN
-- 실제 비즈니스 로직
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
EXIT; -- 성공 시 루프 탈출
EXCEPTION
WHEN deadlock_detected THEN
retry_count := retry_count + 1;
IF retry_count >= max_retries THEN
RAISE; -- 최대 재시도 초과 시 에러 전파
END IF;
PERFORM pg_sleep(0.1 * retry_count); -- 지수 백오프
END;
END LOOP;
END;
$$;
원인 3 해결: 배치 처리 시 정렬된 순서로 처리
-- 나쁜 예: 정렬 없이 IN 절 사용
UPDATE inventory
SET stock = stock - 1
WHERE product_id IN (301, 205, 412, 108);
-- 좋은 예: 처리 전 ID를 오름차순으로 정렬하여 잠금
BEGIN;
SELECT product_id FROM inventory
WHERE product_id IN (108, 205, 301, 412) -- 항상 정렬된 ID 목록
ORDER BY product_id -- 반드시 ORDER BY 추가
FOR UPDATE;
UPDATE inventory
SET stock = stock - 1
WHERE product_id IN (108, 205, 301, 412);
COMMIT;
-- 데드락 현황 모니터링 쿼리
SELECT
pid,
usename,
application_name,
wait_event_type,
wait_event,
state,
query,
now() - query_start AS query_duration
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY query_duration DESC;
-- 현재 잠금 대기 상황 상세 조회
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocked.query AS blocked_query,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
예방 방법
1. 트랜잭션을 짧고 명확하게 유지하기 (Keep Transactions Short)
트랜잭션이 길어질수록 잠금을 오래 보유하게 되어 데드락 확률이 기하급수적으로 높아집니다. 트랜잭션 내부에서 외부 API 호출, 사용자 입력 대기, 복잡한 애플리케이션 로직 처리 등을 절대 수행하지 않도록 설계해야 합니다. 실무에서는 idle in transaction 상태로 오래 머무는 세션을 pg_stat_activity로 정기 모니터링하고, idle_in_transaction_session_timeout 파라미터를 설정하여 장기 유휴 트랜잭션을 자동 종료하는 것이 Best Practice입니다.
-- postgresql.conf 또는 세션 레벨 설정
SET idle_in_transaction_session_timeout = '30s';
-- 장기 트랜잭션 탐지 쿼리
SELECT pid, usename, state, now() - xact_start AS tx_duration, query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
AND xact_start < now() - interval '30 seconds'
ORDER BY tx_duration DESC;
2. 애플리케이션 레벨에서 재시도(Retry) 로직 구현
데드락은 완전히 제거하기 매우 어렵습니다. 따라서 프로덕션 환경에서는 에러 코드 40P01을 감지하면 지수 백오프(Exponential Backoff)와 함께 자동으로 트랜잭션을 재시도하는 로직을 반드시 구현해야 합니다. PostgreSQL 공식 문서도 이 접근 방식을 권장하며, 대부분의 ORM(SQLAlchemy, Hibernate 등)은 이를 위한 내장 재시도 메커니즘을 제공하므로 적극 활용하세요.
관련 에러
- 40001 serialization_failure:
SERIALIZABLE격리 수준에서 트랜잭션 충돌 시 발생하며, 40P01과 마찬가지로 재시도 로직이 필요합니다. - 55P03 lock_not_available:
NOWAIT옵션 사용 시 잠금을 즉시 획득하지 못하면 발생하며, 데드락 대신 즉시 실패를 선택하는 전략에서 사용됩니다. - 57014 query_canceled:
lock_timeout설정에 의해 잠금 대기 시간 초과 시 발생하며, 데드락 예방의 보조 수단으로 활용됩니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.