잠금(Lock)과 데드락 처리
동시성은 공짜가 아니다
여러 트랜잭션이 동시에 같은 데이터를 읽고 쓰면 충돌이 발생한다. PostgreSQL은 잠금(Lock) 으로 이 충돌을 조율한다. MVCC가 읽기와 쓰기 간의 충돌 대부분을 해결하지만, 쓰기와 쓰기 간의 충돌은 여전히 잠금이 필요하다. 잠금을 이해하지 못하면 프로덕션에서 갑작스러운 응답 지연, 타임아웃, 최악의 경우 데드락으로 인한 트랜잭션 롤백이 발생한다.
테이블 수준 잠금
PostgreSQL에는 8가지 테이블 잠금 모드가 있다. 숫자가 높을수록 더 강한(배타적인) 잠금이다.
| 잠금 모드 | 발생 구문 | 충돌 대상 |
|---|---|---|
ACCESS SHARE | SELECT | ACCESS EXCLUSIVE 만 |
ROW SHARE | SELECT ... FOR UPDATE/SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY | 자기 자신 포함 일부 |
SHARE | CREATE INDEX (비동시) | ROW EXCLUSIVE 이상 |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, 일부 DDL | ROW EXCLUSIVE 이상 |
EXCLUSIVE | 드문 명시적 잠금 | ROW SHARE 이상 |
ACCESS EXCLUSIVE | DROP TABLE, TRUNCATE, ALTER TABLE, LOCK TABLE | 모든 잠금 |
핵심 패턴: 일반 SELECT는 ACCESS EXCLUSIVE만 기다리므로 ALTER TABLE 도중에도 대부분의 읽기는 블로킹되지 않는다. 반면 ALTER TABLE은 ACCESS EXCLUSIVE를 얻기 위해 실행 중인 모든 트랜잭션이 끝날 때까지 기다린다.
행 수준 잠금
행 수준 잠금은 실제 데이터 행에 걸린다. 4가지 모드가 있다.
| 모드 | 구문 | 의미 |
|---|---|---|
FOR UPDATE | SELECT ... FOR UPDATE | 해당 행을 갱신/삭제할 것임을 예약 |
FOR NO KEY UPDATE | UPDATE (키 컬럼 제외) | 기본 UPDATE 동작 |
FOR SHARE | SELECT ... FOR SHARE | 읽는 동안 다른 쓰기를 막음 |
FOR KEY SHARE | 외래 키 체크 등 | 키 수준의 공유 잠금 |
FOR UPDATE는 동시 수정을 막아야 하는 경우(재고 차감, 좌석 예약)에 사용한다. NOWAIT를 붙이면 즉시 오류를 반환하고, SKIP LOCKED를 붙이면 잠긴 행을 건너뛴다.
-- 잠긴 행을 건너뛰는 큐 패턴 (Job Worker에서 자주 씀)
SELECT id FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;잠금 계층 다이어그램
8가지 모드
4가지 모드
(DB가 의미 강제 안 함)
어드바이저리 잠금(Advisory Lock)
PostgreSQL은 애플리케이션 정의 잠금을 제공한다. 정수 키를 기반으로 잠금을 걸고 해제한다. DB가 의미를 강제하지 않으며, 사용하는 쪽이 올바르게 호출해야 한다.
-- 세션 수준: 연결이 끊어지면 자동 해제
SELECT pg_advisory_lock(12345); -- 잠금 획득 (다른 세션이 갖고 있으면 대기)
SELECT pg_advisory_unlock(12345); -- 명시적 해제
-- 트랜잭션 수준: 트랜잭션 끝에 자동 해제 (xact_ 접두사)
SELECT pg_advisory_xact_lock(12345);주로 분산 환경에서 특정 작업의 중복 실행을 막을 때 쓴다 (예: 배치 잡 중복 방지).
잠금 현황 모니터링
pg_locks 와 pg_stat_activity 조인
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.query AS blocked_query,
blocking_locks.pid AS blocking_pid,
blocking_activity.query AS blocking_query
FROM pg_locks AS blocked_locks
JOIN pg_stat_activity AS blocked_activity
ON blocked_activity.pid = blocked_locks.pid
JOIN pg_locks AS blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.granted = true
AND blocked_locks.granted = false
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity AS blocking_activity
ON blocking_activity.pid = blocking_locks.pid;이 쿼리는 어떤 쿼리가 어떤 쿼리를 기다리고 있는지 한눈에 보여준다. 운영 문제가 발생하면 가장 먼저 실행하는 진단 쿼리다.
lock_timeout 설정
세션 단위로 잠금 대기 시간을 제한할 수 있다. 이 값이 초과되면 ERROR: canceling statement due to lock timeout이 발생하고 트랜잭션이 롤백된다.
-- 세션 단위로 5초 제한
SET lock_timeout = '5s';
-- postgresql.conf에서 전역 설정은 권장하지 않음 (DDL 포함 전부 적용됨)데드락
데드락(Deadlock)은 두 트랜잭션이 서로 상대방이 갖고 있는 잠금을 기다려 영원히 진행하지 못하는 상태다.
트랜잭션 A: accounts(id=1) 잠금 → accounts(id=2) 기다림
트랜잭션 B: accounts(id=2) 잠금 → accounts(id=1) 기다림PostgreSQL의 자동 감지
PostgreSQL은 deadlock_timeout(기본 1초) 간격으로 데드락을 감지한다. 데드락이 확인되면 한 트랜잭션을 자동으로 롤백하고, 나머지는 계속 진행한다. 롤백된 트랜잭션은 애플리케이션 수준에서 재시도해야 한다.
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 6789;
blocked by process 6789.
Process 6789 waits for ShareLock on transaction 12345;
blocked by process 12345.
HINT: See server log for query details.데드락 예방 전략
데드락의 근본 원인은 잠금 획득 순서가 일관되지 않은 것이다.
전략 1: 항상 일정한 순서로 행을 잠근다
-- 나쁜 예: 트랜잭션마다 순서가 다름
-- A: UPDATE accounts WHERE id=1; UPDATE accounts WHERE id=2;
-- B: UPDATE accounts WHERE id=2; UPDATE accounts WHERE id=1;
-- 좋은 예: 항상 id 오름차순으로 처리
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(from_id, to_id);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(from_id, to_id);전략 2: 트랜잭션을 짧게 유지한다
잠금 보유 시간이 짧을수록 다른 트랜잭션과 겹치는 구간이 줄어든다.
전략 3: SELECT ... FOR UPDATE로 미리 잠근다
필요한 행을 처음부터 한 번에 잠가 중간에 추가 잠금을 요청하는 상황을 피한다.
BEGIN;
-- 두 행을 동시에 잠금 (순서 보장을 위해 ORDER BY)
SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- 이후 UPDATE 실행
COMMIT;데드락 로그 활성화
# postgresql.conf
log_lock_waits = on -- lock_timeout 초과 시 로그 남김
deadlock_timeout = 1s -- 데드락 감지 주기 (낮추면 감지 빠르지만 CPU 증가)-- 로그에서 데드락 이력 확인 (pg_log)
-- 또는 pg_stat_activity에서 wait_event_type = 'Lock' 필터
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';DBA 체크리스트
pg_stat_activity에서wait_event_type = 'Lock'인 세션을 주기적으로 확인한다.log_lock_waits = on을 켜고 잠금 대기 패턴을 로그로 기록한다.ALTER TABLE처럼ACCESS EXCLUSIVE가 필요한 DDL은 저트래픽 시간에 수행하고, 먼저lock_timeout을 짧게 설정해 무한 대기를 방지한다.- 배치 작업에서 대량 행을 갱신할 때 작은 청크로 나눠 잠금 범위를 줄인다.
- 데드락 발생 시 애플리케이션이 재시도 로직을 갖추고 있는지 확인한다.
References
- https://www.postgresql.org/docs/current/explicit-locking.html
- https://www.postgresql.org/docs/current/runtime-config-locks.html
- https://wiki.postgresql.org/wiki/Lock_Monitoring
- https://www.cybertec-postgresql.com/en/debugging-deadlocks-in-postgresql/
- https://pganalyze.com/blog/postgresql-log-monitoring-101-deadlocks-checkpoints-blocked-queries
- https://ahmed-n-abdeltwab.github.io/blog/2025/06/25/postgres-locks-deep-dive.html
- https://oneuptime.com/blog/post/2026-01-21-postgresql-lock-contention-monitoring/view