Published on

PostgreSQL 데드락(40P01) 원인·해결 9단계

Authors

서버가 멀쩡한데도 간헐적으로 SQLSTATE 40P01(deadlock detected)이 터지면, 대부분은 “동시에 처리되는 트랜잭션들이 서로가 가진 락을 기다리며 원형 대기(cycle)를 만든” 전형적인 교착 상태입니다. PostgreSQL은 일정 시간 동안 락 대기를 관찰하다가 데드락을 감지하면 한 트랜잭션을 강제로 롤백시켜 문제를 끊습니다.

중요한 점은, 데드락은 단순히 “동시성” 문제가 아니라 락을 잡는 순서, 잠금 범위, 인덱스 유무, 트랜잭션 길이가 합쳐져서 발생한다는 것입니다. 아래 9단계는 운영에서 40P01을 빠르게 줄이기 위한 실전 절차입니다.

참고로 락 경합이 길어지면 커넥션 풀이 함께 고갈되는 경우가 많습니다. 애플리케이션 측 증상까지 같이 본다면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결도 함께 점검해보세요.

1단계: 에러를 “데드락”으로 확정하고 로그를 확보

40P01은 데드락이 맞지만, 어떤 락 사이클이었는지 알아야 해결이 가능합니다. 우선 서버 로그에서 데드락 상세를 남기도록 설정합니다.

-- postgresql.conf (예시)
-- deadlock_timeout은 짧을수록 빨리 감지하지만 오탐/로그량이 늘 수 있습니다.
-- 운영에서는 200ms~1s 사이를 많이 씁니다.

-- deadlock_timeout = '500ms'
-- log_lock_waits = on
-- log_min_error_statement = error

-- 가능하면 ddl/쿼리 식별을 위해
-- log_line_prefix = '%m [%p] %u@%d app=%a '

운영에서 설정 변경이 어렵다면, 최소한 애플리케이션 로그에 다음을 남기세요.

  • 트랜잭션 시작/종료 시각
  • 요청 ID(코릴레이션)
  • 쿼리(또는 쿼리 ID)와 바인딩 파라미터
  • 재시도 횟수

2단계: 현재 락 대기/블로킹을 실시간으로 관측

데드락은 발생 순간에만 사이클이 보이지만, 락 경합 패턴은 평소에도 관측할 수 있습니다. 아래 쿼리는 “누가 누구를 막고 있는지”를 빠르게 보여줍니다.

WITH blocked AS (
  SELECT
    a.pid AS blocked_pid,
    a.usename,
    a.application_name,
    a.client_addr,
    a.state,
    a.query AS blocked_query,
    pg_blocking_pids(a.pid) AS blocking_pids
  FROM pg_stat_activity a
  WHERE cardinality(pg_blocking_pids(a.pid)) > 0
)
SELECT *
FROM blocked
ORDER BY blocked_pid;

블로킹 관계를 더 자세히 보려면 pg_locks를 함께 봅니다.

SELECT
  a.pid,
  a.usename,
  a.application_name,
  a.state,
  l.locktype,
  l.mode,
  l.granted,
  a.query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE a.datname = current_database()
ORDER BY l.granted, a.pid;

이 단계의 목표는 “어떤 테이블/행에서 락이 자주 경합되는지”를 좁히는 것입니다.

3단계: 데드락이 생기는 전형적인 패턴을 이해

PostgreSQL 데드락은 보통 아래 중 하나입니다.

패턴 A: 서로 다른 순서로 같은 리소스를 잠금

  • 트랜잭션 1: A 잠금 후 B 잠금
  • 트랜잭션 2: B 잠금 후 A 잠금

패턴 B: 인덱스 부재로 업데이트/삭제가 넓게 잠금

조건절 인덱스가 없으면 더 많은 행을 스캔하며 락을 오래 잡습니다. 이때 다른 트랜잭션이 해당 범위에 끼어들어 사이클이 커집니다.

패턴 C: FK(외래키) 관련 업데이트/삭제

부모/자식 테이블에서 UPDATE/DELETE가 동시에 일어나면, FK 검증을 위해 추가 락이 잡히고 순서가 꼬일 수 있습니다. 특히 FK 컬럼에 인덱스가 없을 때 심해집니다.

패턴 D: SELECT ... FOR UPDATE와 일반 업데이트 혼재

비즈니스 로직에서 “선점 잠금”을 걸어두고 다른 경로에서 같은 행을 업데이트하면 순서 불일치가 생깁니다.

4단계: 재현 가능한 최소 시나리오로 축소

운영 로그만으로는 “어떤 코드 경로 조합”이 문제인지 모호합니다. 가장 좋은 방법은 psql 두 세션으로 재현하는 것입니다.

아래는 순서 불일치로 데드락을 만드는 가장 작은 예시입니다.

-- 준비
CREATE TABLE IF NOT EXISTS account (
  id bigint PRIMARY KEY,
  balance bigint NOT NULL
);

INSERT INTO account (id, balance) VALUES (1, 1000)
ON CONFLICT (id) DO NOTHING;
INSERT INTO account (id, balance) VALUES (2, 1000)
ON CONFLICT (id) DO NOTHING;

세션 1:

BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 1;
-- 여기서 멈춘 상태로 두고
UPDATE account SET balance = balance + 10 WHERE id = 2;
COMMIT;

세션 2:

BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 2;
-- 세션 1이 두 번째 UPDATE를 시도하는 타이밍에
UPDATE account SET balance = balance + 10 WHERE id = 1;
COMMIT;

이런 형태가 실제 서비스에서는 “주문 상태 갱신”과 “재고 차감”이 서로 다른 코드 경로에서 섞이며 나타납니다.

5단계: 해결의 1순위는 “락 획득 순서 통일”

가장 효과적인 해결책은 모든 트랜잭션이 동일한 순서로 리소스를 잠그도록 강제하는 것입니다.

예를 들어, 두 계정 간 이체라면 항상 작은 id부터 잠그는 식으로 통일합니다.

-- 항상 id 오름차순으로 잠금
BEGIN;

-- 잠금 단계: 두 행을 일관된 순서로 선점
SELECT id
FROM account
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

-- 업데이트 단계
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;

COMMIT;

핵심은 FOR UPDATE를 무조건 쓰라는 뜻이 아니라, 여러 행/테이블을 건드릴 때 잠금 순서를 결정하고 지키는 것입니다.

6단계: 트랜잭션을 짧게 만들고 “락을 오래 잡는 작업”을 밖으로 빼기

데드락 자체는 사이클이 원인이지만, 사이클이 만들어질 가능성은 “락 보유 시간이 길수록” 커집니다.

다음은 트랜잭션을 불필요하게 길게 만드는 대표 요소입니다.

  • 외부 API 호출을 트랜잭션 안에서 수행
  • 파일 IO, 메시지 발행, 복잡한 계산을 트랜잭션 안에서 수행
  • 한 트랜잭션에서 너무 많은 행을 배치로 갱신

패턴을 바꿀 수 있다면:

  • DB 트랜잭션은 “검증 + 업데이트”까지만
  • 외부 작업은 커밋 이후 비동기(아웃박스 패턴 등)

트랜잭션 길이를 줄이면 데드락뿐 아니라 락 대기, 커넥션 점유 시간도 함께 줄어듭니다.

7단계: 인덱스와 쿼리를 점검해 잠금 범위를 줄이기

데드락을 만드는 직접 원인이 “순서”인 경우가 많지만, 인덱스 부재로 인해 원래 잠글 필요 없는 행까지 오래 스캔하면서 락이 커지는 경우가 흔합니다.

점검 체크리스트:

  • UPDATE ... WHERE 조건에 조건 컬럼 인덱스가 있는가
  • FK 컬럼에 인덱스가 있는가(특히 자식 테이블)
  • 동일 테이블을 여러 번 업데이트하는 쿼리가 있는가
  • SELECT ... FOR UPDATE가 필요 이상으로 넓은 범위를 잠그는가

실제로는 bloat(테이블/인덱스 팽창) 때문에 스캔이 느려져 락 보유 시간이 늘어나는 경우도 있습니다. 락 문제가 “최근에 갑자기” 늘었다면 bloat도 같이 확인하세요: PostgreSQL VACUUM 안 먹힐 때 bloat 10분 진단·해결

EXPLAIN (ANALYZE, BUFFERS)로 해당 업데이트/삭제가 인덱스를 타는지 확인합니다.

EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders
SET status = 'PAID'
WHERE order_id = 123 AND status = 'CREATED';

여기서 Seq Scan이 나오고 rows가 크다면, 잠금 경합과 데드락 위험이 같이 올라갑니다.

8단계: 격리 수준과 잠금 문법을 “의도대로” 사용

PostgreSQL의 기본 격리 수준은 READ COMMITTED입니다. 격리 수준을 올리면(예: REPEATABLE READ, SERIALIZABLE) 동시성 제약이 강해지고, 데드락 또는 직렬화 실패가 늘 수 있습니다.

또한 다음 문법들은 락/대기 전략을 바꿉니다.

  • FOR UPDATE NOWAIT: 잠겨 있으면 즉시 실패(대기하지 않음)
  • FOR UPDATE SKIP LOCKED: 잠긴 행은 건너뛰고 처리(큐/잡 워커에 유용)
  • LOCK TABLE ...: 테이블 레벨 락으로 범위가 매우 커질 수 있음

예를 들어 작업 큐를 여러 워커가 처리한다면 SKIP LOCKED로 데드락과 불필요한 대기를 크게 줄일 수 있습니다.

WITH next_job AS (
  SELECT id
  FROM job_queue
  WHERE status = 'READY'
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE job_queue j
SET status = 'RUNNING'
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

다만 SKIP LOCKED는 “공정성”이 깨질 수 있으니, 잡의 우선순위/재시도 정책과 함께 설계해야 합니다.

9단계: 애플리케이션에서 40P01을 “정상적인 충돌”로 다루기

아무리 잘 설계해도 높은 동시성 환경에서는 데드락이 0이 되기 어렵습니다. PostgreSQL이 한 트랜잭션을 희생시키는 것은 의도된 보호 메커니즘이므로, 애플리케이션은 다음을 갖춰야 합니다.

  • 40P01을 잡아서 짧은 지수 백오프로 재시도
  • 재시도는 멱등성이 보장되는 작업에만 적용
  • 재시도 횟수는 제한(예: 2~3회)
  • 재시도 시 같은 락 순서를 유지(코드 경로가 바뀌면 악화)

Spring 계열이라면 예외 매핑이 중요합니다. 드라이버/ORM에서 SQLState를 노출하므로 이를 기준으로 재시도 정책을 구성하세요.

// 의사코드: SQLState 40P01만 선별 재시도
try {
  txTemplate.execute(status -> {
    // DB 작업
    return null;
  });
} catch (SQLException e) {
  if ("40P01".equals(e.getSQLState())) {
    // backoff 후 재시도
  }
  throw e;
}

재시도는 만능이 아닙니다. 락 순서 불일치 같은 구조적 원인을 그대로 둔 채 재시도만 붙이면, 피크 타임에 재시도가 폭증하면서 오히려 DB 부하가 커질 수 있습니다.

운영 체크리스트: 9단계를 한 번에 적용하는 순서

마지막으로, 실제 장애 대응 시 적용 순서를 요약합니다.

  1. deadlock_timeout, log_lock_waits 등으로 로그 확보
  2. pg_stat_activity, pg_locks로 블로킹 관계 관측
  3. 데드락 로그에서 “어떤 테이블/쿼리 조합”인지 식별
  4. psql 두 세션으로 최소 재현
  5. 락 획득 순서 통일(가장 효과 큼)
  6. 트랜잭션 짧게(외부 작업 분리)
  7. 인덱스/쿼리 개선으로 잠금 범위 축소
  8. NOWAIT, SKIP LOCKED, 격리 수준을 의도에 맞게 조정
  9. 40P01 선별 재시도 + 멱등성/백오프 설계

데드락은 “DB가 이상해졌다”가 아니라, 동시성 하에서 애플리케이션이 락을 다루는 방식이 드러난 결과인 경우가 대부분입니다. 위 9단계를 통해 원인을 구조적으로 제거하고, 남는 충돌은 재시도로 흡수하는 형태로 설계하면 40P01은 충분히 관리 가능한 수준으로 떨어집니다.