Published on

PostgreSQL 락 대기 폭증? deadlock 진단·해결

Authors

운영 중인 PostgreSQL에서 어느 순간부터 API 응답이 느려지고, 애플리케이션 로그에는 타임아웃이 쌓이며, DB에서는 lock wait가 폭증하는 상황이 종종 발생합니다. 특히 트래픽이 늘어난 것도 아닌데 갑자기 지연이 커지면, 상당수는 특정 트랜잭션이 락을 오래 잡고 있거나(락 홀더), 여러 트랜잭션이 서로의 락을 기다리며 교착 상태(deadlock)에 빠진 경우입니다.

이 글은 “지금 당장 무엇을 봐야 하는지”에 초점을 맞춰, PostgreSQL 락 대기/데드락을 진단하고 해결하는 실전 절차를 정리합니다. 또한 재발을 줄이는 설계·코딩 규칙까지 함께 다룹니다.

관련해서 애플리케이션 단에서도 커넥션 풀 고갈과 락 대기가 함께 터지는 경우가 많습니다. DB 락 이슈가 의심될 때는 커넥션 풀 관점도 같이 확인하는 편이 좋습니다: Spring Boot HikariCP 커넥션 고갈 3분 진단

1) 락 대기 폭증과 deadlock의 차이

락 대기(Blocking)

  • 한 트랜잭션이 어떤 락을 잡고 있고(홀더), 다른 트랜잭션이 그 락이 풀리길 기다리는 상태(웨이터)입니다.
  • 대부분은 “오래 걸리는 트랜잭션”이 원인입니다. 예: 대량 업데이트, 느린 쿼리, 외부 호출을 포함한 트랜잭션, 인덱스 미비로 인한 긴 스캔 등.

데드락(Deadlock)

  • 트랜잭션 A가 트랜잭션 B의 락을 기다리고, 동시에 B도 A의 락을 기다리는 순환 대기입니다.
  • PostgreSQL은 deadlock_timeout 이후 데드락을 감지하면, 보통 한쪽 트랜잭션을 강제로 실패시켜 순환을 끊습니다.
  • 증상은 “갑자기 일부 요청만 실패(롤백)”로 나타나기도 하고, 애플리케이션 재시도 로직이 없으면 오류가 사용자까지 전파됩니다.

2) 가장 먼저 확인할 것: 현재 누가 누구를 막고 있나

운영 장애 상황에서는 “락을 잡고 있는 쿼리(홀더)”를 찾는 게 1순위입니다.

2.1 pg_stat_activity로 대기/실행 상태 확인

SELECT
  now() AS ts,
  pid,
  usename,
  application_name,
  client_addr,
  state,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  now() - xact_start AS xact_age,
  now() - query_start AS query_age,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
  • wait_event_typeLock이면 락을 기다리는 중일 가능성이 큽니다.
  • xact_age가 유난히 큰 세션이 있으면 “락을 오래 잡고 있을” 확률이 높습니다.

2.2 누가 누구를 막는지(Blocking chain) 빠르게 보기

PostgreSQL에는 “나를 막는 PID”를 알려주는 pg_blocking_pids(pid)가 있습니다.

SELECT
  a.pid AS waiting_pid,
  pg_blocking_pids(a.pid) AS blocking_pids,
  a.wait_event_type,
  a.wait_event,
  now() - a.query_start AS waiting_query_age,
  left(a.query, 160) AS waiting_query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY waiting_query_age DESC;

이 결과로 웨이터를 찾았다면, blocking_pids에 나온 PID들을 다시 pg_stat_activity에서 조회해 “홀더가 무엇을 하고 있는지” 확인합니다.

SELECT
  pid,
  usename,
  application_name,
  client_addr,
  state,
  xact_start,
  now() - xact_start AS xact_age,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE pid = ANY (ARRAY[12345, 23456]);

3) 락의 정체를 파악하기: 어떤 락을 기다리나

pg_locks를 보면 어떤 락 모드에서 막혔는지 확인할 수 있습니다.

SELECT
  a.pid,
  a.state,
  a.wait_event_type,
  a.wait_event,
  l.locktype,
  l.mode,
  l.granted,
  l.relation::regclass AS relation,
  l.page,
  l.tuple,
  now() - a.query_start AS query_age,
  left(a.query, 160) AS query
FROM pg_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
WHERE a.datname = current_database()
ORDER BY l.granted, query_age DESC;
  • granted = false인 락이 “대기 중인 락”입니다.
  • relation이 특정 테이블로 찍히면, 그 테이블에서 경합이 심한 것입니다.
  • locktypetransactionid로 나오면, 흔히 행(row) 단위 업데이트 경합 또는 FK/인덱스 관련 대기가 섞여 있을 수 있습니다.

4) deadlock 로그를 “재현 가능한 정보”로 바꾸기

데드락은 발생 순간의 로그가 핵심입니다. PostgreSQL 로그 설정을 조정해 “어떤 쿼리들이 어떤 락을 두고 엮였는지”를 남겨야 합니다.

4.1 권장 로그 설정

postgresql.conf 또는 파라미터 그룹에서 다음을 검토합니다.

log_lock_waits = on
deadlock_timeout = '1s'
log_min_duration_statement = 500
log_line_prefix = '%m [%p] %u@%d app=%a client=%h '
  • log_lock_waits는 일정 시간 이상 락 대기가 지속될 때 로그를 남깁니다.
  • deadlock_timeout은 데드락 감지 대기 시간입니다. 너무 길면 장애가 길게 보이고, 너무 짧으면 오탐/로그 폭증이 날 수 있어 보통 1s 전후로 조정합니다.
  • log_min_duration_statement는 느린 쿼리를 잡아 “락 홀더가 왜 느린지”까지 추적하는 데 도움이 됩니다.

4.2 deadlock 로그 해석 포인트

로그에는 보통 다음 정보가 포함됩니다.

  • 어떤 PID가 어떤 락을 기다리는지
  • 어떤 relation(테이블)과 어떤 lock mode가 충돌하는지
  • 관련 쿼리 텍스트

여기서 중요한 건 “순서”입니다. 같은 자원(예: 두 테이블, 또는 같은 테이블의 서로 다른 행)을 서로 다른 순서로 잠그면 데드락이 쉽게 생깁니다.

5) 즉시 완화(응급처치): 무엇을 죽이고 무엇을 살릴까

락 대기가 폭증했을 때, 가장 위험한 선택은 “아무 PID나 종료”하는 것입니다. 가능하면 다음 순서로 판단합니다.

  1. 홀더(막는 쿼리)가 배치/관리성 작업인지 확인
  2. 홀더가 사용자 트래픽을 막고 있다면, 홀더를 종료하는 것이 전체 복구에 유리
  3. 반대로, 홀더가 중요한 마이그레이션/정합성 작업이라면 웨이터를 종료하는 편이 나을 수 있음

5.1 안전한 종료 절차

먼저 “쿼리만 취소”를 시도합니다.

SELECT pg_cancel_backend(12345);

취소가 안 되거나 트랜잭션이 계속 락을 잡고 있으면 세션을 종료합니다.

SELECT pg_terminate_backend(12345);
  • pg_terminate_backend는 해당 세션의 트랜잭션을 롤백시킵니다.
  • 애플리케이션이 자동 재시도를 한다면 폭주할 수 있으므로, 종료 후에는 트래픽/재시도 정책도 함께 확인해야 합니다.

6) 근본 원인 1: “긴 트랜잭션”을 줄여라

락 대기의 가장 흔한 원인은 “트랜잭션이 필요 이상으로 길다”입니다.

6.1 트랜잭션 안에서 외부 호출 금지

  • HTTP 호출, 메시지 브로커 I/O, 파일 업로드 같은 작업을 트랜잭션 안에서 수행하면 락 보유 시간이 폭증합니다.
  • 패턴: DB 업데이트 BEGIN 후 외부 API 호출이 느려져서 락이 오래 유지됨

6.2 사용자 입력 대기/긴 비즈니스 로직 금지

  • 트랜잭션을 열고 나서 복잡한 계산, 대량 루프, 대기 등을 수행하면 안 됩니다.

6.3 커넥션 풀 관점에서의 악화

락 대기는 “DB 내부 문제”로 끝나지 않고, 애플리케이션 커넥션 풀이 대기/고갈되면서 장애가 증폭됩니다. 특히 Spring Boot에서는 HikariCP에서 active가 꽉 차고, 스레드가 커넥션을 못 얻어 타임아웃이 발생합니다. 이 경우는 DB 락과 앱 풀 상태를 같이 봐야 합니다: Spring Boot HikariCP 커넥션 고갈 3분 진단

7) 근본 원인 2: 잠금 순서 불일치로 인한 deadlock

데드락의 전형적인 원인은 “같은 자원들을 서로 다른 순서로 잠그는 코드”입니다.

예를 들어 트랜잭션 A는 accounts를 잠근 뒤 orders를 잠그고, 트랜잭션 B는 orders를 잠근 뒤 accounts를 잠그면 순환 대기가 가능합니다.

7.1 해결 원칙: 항상 동일한 순서로 잠그기

  • 여러 테이블/행을 갱신해야 한다면, 정렬된 규칙(예: PK 오름차순, 테이블 이름 고정 순서)으로 잠금을 획득합니다.

예시(의도적으로 잠금 순서를 고정):

BEGIN;

-- 예: 두 계정 간 이체라면, id가 작은 계정을 먼저 잠금
SELECT id FROM accounts WHERE id IN (100, 200) ORDER BY id FOR UPDATE;

UPDATE accounts SET balance = balance - 100 WHERE id = 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 200;

COMMIT;

핵심은 FOR UPDATE로 “필요한 행을 먼저 잠그고” 이후 업데이트를 수행해 순서를 강제하는 것입니다.

8) 근본 원인 3: 인덱스/쿼리 플랜 문제로 락 보유 시간이 길어짐

락 자체가 문제라기보다, 락을 잡은 쿼리가 느려서 문제가 커집니다.

8.1 느린 업데이트/삭제가 테이블을 오래 붙잡는 경우

  • 조건절 컬럼에 인덱스가 없어 대량 스캔이 발생
  • 갱신 대상이 많아 VACUUM 압박, IO 증가

우선 느린 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인합니다.

EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders
SET status = 'CANCELLED'
WHERE user_id = 42
  AND status = 'PENDING';
  • Seq Scan이 보이고 rows가 큰데, 실제로는 일부만 갱신해야 한다면 인덱스 후보입니다.

8.2 인덱스 추가 시 주의

  • 인덱스 생성은 기본적으로 강한 락을 유발할 수 있습니다.
  • 운영에서는 CREATE INDEX CONCURRENTLY를 고려합니다.
CREATE INDEX CONCURRENTLY idx_orders_user_status
ON orders (user_id, status);

단, CONCURRENTLY는 트랜잭션 블록(BEGIN/COMMIT) 안에서 실행할 수 없습니다.

9) 자주 놓치는 락: DDL과 마이그레이션

운영 중 ALTER TABLE 같은 DDL은 ACCESS EXCLUSIVE 락을 잡아 읽기/쓰기를 폭넓게 막을 수 있습니다.

9.1 증상

  • 평소엔 잘 되던 단순 SELECT까지 대기
  • 특정 시간(배포/마이그레이션 시점)에만 락 대기 폭증

9.2 대응

  • 가능한 CONCURRENTLY 옵션 사용(인덱스)
  • 컬럼 추가는 비교적 안전하지만, 디폴트/NOT NULL 조합은 버전에 따라 테이블 리라이트가 발생할 수 있음
  • 마이그레이션은 트래픽 저점에 실행하고, 락 대기 타임아웃을 짧게 걸어 “오래 막지 말고 실패”하게 설계

예: 세션 단위로 락 타임아웃을 설정

SET lock_timeout = '2s';
SET statement_timeout = '30s';

ALTER TABLE orders ADD COLUMN extra jsonb;

10) 애플리케이션 레벨 재발 방지 체크리스트

10.1 트랜잭션 범위 최소화

  • “DB 작업만” 트랜잭션 안에 두기
  • 트랜잭션 시작은 최대한 늦게, 커밋/롤백은 최대한 빨리

10.2 재시도는 무조건 옳지 않다

  • 데드락은 재시도로 해결되는 경우가 많지만, 무작정 재시도하면 같은 패턴으로 다시 데드락이 나거나 DB 부하만 키울 수 있습니다.
  • 지수 백오프, 재시도 횟수 제한, idempotency 보장 등을 함께 설계합니다.

10.3 락 경합이 심한 핫스팟 키를 분산

  • 특정 사용자/특정 레코드에 업데이트가 몰리면 row lock 경합이 발생합니다.
  • 카운터 누적, 포인트 적립, 재고 차감 같은 패턴은 샤딩 키/버킷팅/비동기 적재를 고려합니다.

10.4 JDBC 지연과 스레드 모델도 함께 점검

애플리케이션이 가상 스레드나 높은 동시성을 사용한다면, DB 락 대기 시 “동시 요청이 더 많이 쌓이는” 형태로 장애가 확대될 수 있습니다. JDBC 대기 시간을 줄이고 병목을 줄이는 접근도 참고할 만합니다: Spring Boot 3 가상 스레드에서 JDBC 지연 줄이기

11) 실전 대응 플레이북(요약)

장애 순간에 아래 순서로 움직이면 원인 파악 시간이 크게 줄어듭니다.

  1. pg_stat_activity에서 wait_event_typeLock인 세션과 xact_age가 큰 세션을 찾기
  2. pg_blocking_pids(pid)로 블로킹 체인을 구해 “진짜 홀더”를 특정
  3. 홀더 쿼리를 확인: 느린 이유가 쿼리/인덱스/DDL/외부 호출인지 분류
  4. 응급처치: 가능하면 pg_cancel_backend, 필요 시 pg_terminate_backend
  5. 재발 방지: 트랜잭션 범위 축소, 잠금 순서 통일, 인덱스/쿼리 최적화, 마이그레이션 전략 개선
  6. 로깅 강화: log_lock_waits, deadlock_timeout, 슬로우 쿼리 로그로 “다음에는 더 빨리” 찾기

12) 부록: 블로킹 관계를 한 번에 보는 쿼리 예시

아래 쿼리는 “대기 중인 세션”과 “그를 막는 세션”을 한 화면에서 보려는 목적의 예시입니다.

WITH waiting AS (
  SELECT
    a.pid AS waiting_pid,
    unnest(pg_blocking_pids(a.pid)) AS blocking_pid,
    now() - a.query_start AS waiting_for,
    left(a.query, 120) AS waiting_query
  FROM pg_stat_activity a
  WHERE a.wait_event_type = 'Lock'
)
SELECT
  w.waiting_pid,
  w.waiting_for,
  w.waiting_query,
  b.pid AS blocking_pid,
  now() - b.xact_start AS blocking_xact_age,
  left(b.query, 120) AS blocking_query
FROM waiting w
JOIN pg_stat_activity b ON b.pid = w.blocking_pid
ORDER BY w.waiting_for DESC, blocking_xact_age DESC;

이 결과에서 blocking_xact_age가 큰 세션이 “락 대기 폭증의 시작점”인 경우가 많습니다.


락 대기 폭증과 데드락은 “DB가 느리다”로 뭉뚱그리면 해결이 어려워집니다. 누가 막고 있는지, 어떤 락을 기다리는지, 왜 오래 잡는지를 순서대로 분해하면 대부분은 빠르게 좁혀집니다. 그리고 재발 방지는 결국 트랜잭션을 짧게, 잠금 순서를 일정하게, 느린 쿼리를 줄이는 기본 원칙으로 귀결됩니다.