왜 잠금이 문제가 되는가

PostgreSQL은 MVCC 덕분에 "읽기가 쓰기를 막지 않는다"는 원칙을 지키지만, 쓰기끼리는 여전히 행 잠금(row lock)과 테이블 잠금을 통해 직렬화된다. 트랜잭션이 길어지거나, 여러 트랜잭션이 같은 행을 다른 순서로 잠그면 대기 사슬이 생기고 처리량이 급락한다. 최악의 경우 서로가 서로를 기다리는 순환이 생겨 데드락으로 이어진다. 겉으로는 "가끔 느려짐" 또는 "요청 타임아웃"으로만 보이기 때문에, 원인을 잠금 계층에서 찾지 못하면 애플리케이션 코드만 헛돌게 된다.

지금 무엇이 무엇을 막고 있는가

실시간 경합 진단의 출발점은 pg_lockspg_stat_activity의 조인이다. 아래 쿼리는 대기 중인 세션과 그를 막고 있는 세션을 짝지어 보여준다.

SELECT
  blocked.pid            AS blocked_pid,
  blocked.query         AS blocked_query,
  blocking.pid          AS blocking_pid,
  blocking.query        AS blocking_query,
  now() - blocked.query_start AS waited
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock'
ORDER BY waited DESC;

pg_blocking_pids()는 대기 사슬의 직접 선행자를 반환하므로, 결과를 따라 올라가면 "루트 원인" 세션을 찾을 수 있다. 대개 그 세션은 오래 열려 있는 트랜잭션이거나, 커밋을 잊은 배치 작업이다.

데드락은 어떻게 재현되고 기록되는가

데드락의 전형적 패턴은 두 트랜잭션이 자원을 반대 순서로 잠그는 것이다.

-- 세션 A
BEGIN;
UPDATE accounts SET bal = bal - 100 WHERE id = 1;
-- 세션 B가 id=2를 먼저 잠근 뒤
UPDATE accounts SET bal = bal + 100 WHERE id = 2;  -- 여기서 대기

-- 세션 B
BEGIN;
UPDATE accounts SET bal = bal - 50 WHERE id = 2;
UPDATE accounts SET bal = bal + 50 WHERE id = 1;   -- 순환 발생 → 데드락

PostgreSQL은 데드락 감지기가 순환을 발견하면 한쪽 트랜잭션을 ERROR: deadlock detected로 강제 종료한다. 서버 로그에 관련된 두 프로세스의 쿼리가 함께 남으므로, 반드시 로그에서 원인 쿼리 쌍을 확인해야 한다.

로그를 진단 가능하게 만들기

기본 설정으로는 어떤 쿼리가 대기했는지 알기 어렵다. 다음 설정으로 느린 잠금과 데드락 컨텍스트를 남긴다.

-- postgresql.conf
log_lock_waits = on            # deadlock_timeout 초과 대기 시 로그
deadlock_timeout = '1s'        # 이 시간 뒤 데드락 검사 시작
log_min_duration_statement = '500ms'
lock_timeout = '3s'            # 세션 단위로도 지정 가능
statement_timeout = '30s'

lock_timeout은 데드락 예방책이 아니라 피해 억제책이다. 무한 대기 대신 빠르게 실패시켜 커넥션 풀 고갈을 막는다. 애플리케이션 세션에서 SET lock_timeout으로 트랜잭션별로 조절하는 편이 안전하다.

잠금 종류별 대응

상황증상대응
같은 행 갱신 경합행 잠금 대기 누적트랜잭션 축소, 갱신 순서 통일
DDL(ALTER TABLE)AccessExclusiveLock로 전체 차단lock_timeout 후 재시도, 온라인 마이그레이션
외래키 갱신부모 행에 공유 잠금참조 인덱스 확인, 배치 분할

근본 해결과 주의점

가장 효과적인 해결은 코드 규칙에 있다. 첫째, 여러 행을 갱신할 때 항상 같은 순서(예: id 오름차순)로 잠근다. SELECT ... FOR UPDATE ORDER BY id는 데드락 확률을 크게 낮춘다. 둘째, 트랜잭션 안에서 외부 API 호출이나 사용자 입력 대기를 하지 않는다. 셋째, 데드락은 완전히 없앨 수 없으므로 애플리케이션에 재시도 로직을 둔다. 다만 재시도는 지수 백오프와 최대 횟수를 함께 두어야 하며, 무한 재시도는 경합을 오히려 증폭시킨다. 진단 순서는 언제나 동일하다. 실시간 경합은 pg_blocking_pids로, 반복되는 데드락은 서버 로그의 쿼리 쌍으로 좁힌 뒤, 잠금 순서와 트랜잭션 길이를 고쳐야 재발을 막을 수 있다.