PostgreSQL 데드락 원리 분석 및 동시성 제어를 위한 실무 트러블슈팅 가이드
트래픽이 활발한 대규모 웹 애플리케이션을 지탱하는 RDBMS 환경에서 데드락(Deadlock, 교착 상태) 장애는 시스템의 영혼을 잠식하는 골칫거리 중 하나입니다. 평소에는 안정적으로 동작하던 API들이 특정 결제 스파이크나 새벽 배치 작업 시간대에 물리면, 연쇄적으로 대기 상태에 빠지고 커넥션 풀이 고갈되며 사이트 전체가 마비되는 참사로 이어지곤 합니다. 백엔드 개발자나 DBA는 "deadlock detected"라는 무서운 시스템 로그를 보고 급히 인덱스를 추가하거나 데이터베이스 사양을 높이려 하지만, 이는 문제의 본질을 비껴간 조치일 때가 많습니다. 교착 상태는 데이터베이스 엔진의 버그가 아니라, 우리가 짠 비즈니스 애플리케이션의 쿼리 수행 흐름과 잠금 순서가 꼬이면서 일어나는 구조적인 모순이기 때문입니다. PostgreSQL 내부의 락 메커니즘과 트랜잭션 격리 수준이 데드락과 어떻게 얽히는지 규명하고, 실시간 모니터링 쿼리부터 방지 코드 설계까지 실무에서 즉시 사용 가능한 트러블슈팅 노하우를 집대성하여 제시합니다.
데이터베이스 동시성과 데드락의 개념적 정의
PostgreSQL에서 데드락(Deadlock)은 두 개 이상의 트랜잭션이 서로가 선점하고 있는 테이블이나 로우(Row)의 잠금(Lock)이 해제되기만을 기다리며 무한 대기 상태에 머무르는 치명적인 상태를 지칭합니다.
예를 들어, 트랜잭션 A가 1번 사용자의 포인트를 수정하고 2번 사용자의 포인트를 수정하려 할 때, 동시에 트랜잭션 B가 2번 사용자의 포인트를 수정하고 1번 사용자의 포인트를 수정하려고 시도하는 시나리오가 대표적입니다. 두 트랜잭션이 각각 첫 번째 갱신을 성공시킨 시점에서, 상대방이 락을 쥐고 있는 로우의 두 번째 갱신을 요청하게 되면서 무한 대기가 발생합니다.
PostgreSQL은 이처럼 해결 불가능한 교착 상태를 방지하기 위해 내부적으로 백그라운드 프로세스가 주기적으로 락 그래프(Lock Graph)를 순회하며 순환 대기(Circular Wait) 구조를 감지합니다. 만약 순환 관계가 발견되면, 대기 중인 트랜잭션 중 하나를 즉각 강제 강등 및 롤백(Rollback) 시켜 전체 시스템의 정체를 해제합니다. 이 과정에서 애플리케이션에는 ERROR: deadlock detected라는 에러 메시지가 반환되며 해당 요청은 실패 처리됩니다.
PostgreSQL 트랜잭션 격리 수준과 데드락의 관계
데이터베이스의 정합성을 수호하기 위해 정의하는 트랜잭션 격리 수준(Transaction Isolation Level)은 교착 상태의 발생 빈도 및 그 성격과 밀접한 연결 고리를 형성합니다.
PostgreSQL은 기본 격리 수준으로 Read Committed를 사용합니다. 이 수준에서는 개별 쿼리가 시작되는 시점의 스냅샷을 기준으로 데이터를 조회하고, 데이터를 변경할 때는 대상 로우에 대해서만 exclusive(배타적) lock을 획득합니다. 비교적 락 유지 시간이 짧기 때문에 교착 상태의 가능성은 낮지만, 논리적인 오차나 비반복 읽기(Non-repeatable Read) 현상이 일어날 수 있습니다.
데이터 정합성을 한 단계 더 끌어올리기 위해 Repeatable Read나 Serializable 격리 수준을 채택하게 되면 상황이 달라집니다. 특히 Serializable 수준은 트랜잭션들이 완전히 순차적으로 실행된 것과 동일한 효과를 보장하기 위해 물리적 로우 락뿐만 아니라, 특정 범위 자체의 잠금을 유발하는 공유 락(Predicate Lock)을 획득합니다. 이 수준에서는 데이터의 충돌을 적극적으로 방지하는 대신, 여러 트랜잭션이 동일 영역을 건드릴 경우 높은 확률로 직렬화 실패(Serialization Failure) 오류나 데드락을 동반하게 됩니다. 격리 수준이 상향될수록 데이터 신뢰도는 확보되지만, 잠금 범위와 획득 기간이 비대해져 교착 상태를 피하기 위한 면밀한 설계가 요구됩니다.
pg_locks 테이블을 활용한 실시간 잠금 및 데드락 모니터링 쿼리
데이터베이스 세션이 락 대기 상태로 정체되어 있을 때, 어떤 트랜잭션이 주범인지 찾아내기 위해서는 PostgreSQL 메타데이터 뷰인 pg_locks와 pg_stat_activity를 연동하여 확인하는 것이 가장 빠른 분석 방법입니다.
다음 쿼리는 현재 PostgreSQL 인프라 안에서 대기 상태(granted = false)에 놓인 쿼리 세션과, 해당 세션을 블로킹하여 잠그고 있는 원인 제공자 세션의 정보를 한눈에 매핑하여 출력합니다.
-- 실시간 락 블로킹 및 대기 쿼리 모니터링
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocked_activity.query AS blocked_statement,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocking_activity.query AS blocking_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
이 모니터링 쿼리를 사용하면 특정 로우의 락을 쥐고 있으면서 커넥션을 반환하지 않는 PID(Process ID)를 식별할 수 있습니다. 운영 중인 세션이 비정상적으로 락을 쥔 채 멈춰 있다면 SELECT pg_cancel_backend(blocking_pid); 또는 보다 강력한 SELECT pg_terminate_backend(blocking_pid); 명령을 내려 해당 원인 트랜잭션을 강제로 끊고 시스템을 살려낼 수 있습니다.
데드락 탐지 시간 조정과 설정 최적화 방안
데드락은 시스템의 정체를 초래하므로 감지 시간이 늦어질수록 애플리케이션 레이어의 커넥션 풀이 마르는 누수 현상이 벌어집니다. 이를 예방하려면 postgresql.conf 설정 파일 내의 탐지 주기 값을 튜닝해야 합니다.
핵심 설정 파라미터는 deadlock_timeout입니다. PostgreSQL 엔진은 잠금 대기가 시작된 후 이 설정에 기재된 시간이 경과하면, 잠금 그래프에 순환 대기 고리가 있는지를 검사하는 백그라운드 탐지 메커니즘을 깨웁니다. 기본값은 1s (1초)로 지정되어 있습니다.
만약 트래픽이 과도하게 밀집된 OLTP(Online Transaction Processing) 시스템에서 deadlock_timeout을 200ms처럼 지나치게 짧게 줄이면 어떻게 될까요? 데드락을 번개처럼 찾아내 롤백해 주기는 하겠지만, 정상적으로 락 대기 후 수행되어야 할 쿼리들마저 수시로 탐지 프로세스의 연산 부하를 겪게 되어 CPU 점유율이 솟구치는 오버헤드를 낳게 됩니다.
반대로 deadlock_timeout을 10초 이상으로 길게 늘려 잡으면, 실제 데드락이 터졌을 때 트랜잭션들이 10초간 아무 일도 하지 못하고 대기하여 웹 서버의 커넥션 풀 전체가 블로킹되는 2차 임계점 장애를 일으킵니다. 따라서 일반적인 상용 프로덕션 환경에서는 기본값인 1s를 유지하거나, 트래픽 프로파일링 하에 500ms ~ 1.5s 내외에서 적정 타협점을 조율해 설정하는 것이 가장 안전한 정석 기법입니다.
데드락 재현 테스트와 실전 방지 코드 패턴
교착 상태를 모면하는 최고의 정석 솔루션은 '일관된 순서의 데이터 잠금'과 '비블로킹 락 요청 쿼리'의 조합입니다.
다음 시퀀스 다이어그램은 데이터베이스 내에서 트랜잭션 A와 B가 비일관적인 순서로 데이터 수정을 시도할 때 데드락이 임베디드되어 폭발하는 원리를 타임라인별로 실감 나게 추적합니다.
sequenceDiagram
autonumber
participant App_A as Transaction A (User 1 -> 2)
participant DB as PostgreSQL Engine
participant App_B as Transaction B (User 2 -> 1)
App_A->>DB: UPDATE users SET point = 100 WHERE id = 1
Note over DB: User 1: Row Exclusive Lock acquired by Trans A
App_B->>DB: UPDATE users SET point = 200 WHERE id = 2
Note over DB: User 2: Row Exclusive Lock acquired by Trans B
App_A->>DB: UPDATE users SET point = 150 WHERE id = 2
Note over DB: Trans A waits for User 2 Lock... (Blocked)
App_B->>DB: UPDATE users SET point = 250 WHERE id = 1
Note over DB: Trans B waits for User 1 Lock... (Blocked)
Note over DB: Deadlock Detected by Engine!
DB-->>App_B: ERROR: deadlock detected (Trans B rolled back)
DB-->>App_A: Trans A completes update successfully
이와 같은 참사를 사전에 틀어막으려면 애플리케이션 레이어에서 다중 업데이트를 날릴 때 식별자(ID) 정렬 등의 공통 가이드에 맞추어 무조건 순차(오름차순 등) 수정을 행하도록 규칙을 통일해야 합니다.
더불어 대기 상태로 웹 서버 스레드가 마냥 얼어버리는 것을 막기 위해 NOWAIT이나 SKIP LOCKED 구문을 결합한 SELECT FOR UPDATE 절을 실무 쿼리에 적용하는 전략이 큰 힘을 발휘합니다.
-- 락이 즉시 획득 불가능하면 대기하지 않고 즉시 에러(55P03)를 반환하고 실패시킴
SELECT id, point
FROM users
WHERE id = 1
FOR UPDATE NOWAIT;
-- 이미 다른 트랜잭션이 선점한 행은 건너뛰고 락이 획득 가능한 대상만 조회 (큐 배치 처리에 유용)
SELECT id, status
FROM job_queue
WHERE status = 'READY'
FOR UPDATE SKIP LOCKED
LIMIT 10;
FOR UPDATE NOWAIT을 사용하면 락 충돌 시 마냥 대기 세션에 갇히는 대신 애플리케이션에서 즉각적인 예외 처리가 가능하므로, 잠시 후 재시도(Retry)하는 아키텍처를 적용해 동시성 병목을 세련되게 회피할 수 있습니다.
PostgreSQL 대규모 조회 성능 최적화에 대해서는 PostgreSQL 대용량 데이터 인덱스 최적화 및 튜닝 가이드를 함께 학습하면 전체 데이터베이스 아키텍처 개선에 시너지를 낼 수 있습니다. 또한, 메모리가 한계를 넘어 프로세스가 파괴되는 인프라 전반의 장애를 모니터링하기 위해서는 쿠버네티스 파드 메모리 초과 장애 해결 문서를 참조해 인프라의 기초 체력을 보정해 두는 것이 권장됩니다.
자주 묻는 질문 (FAQ)
Q. 데드락 탐지 시 발생하는 에러 코드는 무엇인가요?
PostgreSQL에서 데드락이 감지되어 트랜잭션이 강제 롤백될 때 반환되는 SQLSTATE 에러 코드는 40P01 (deadlock_detected) 입니다. 이 에러 코드를 확인하면 애플리케이션 예외 필터에서 이를 잡아내어 일시적 오류로 규정하고, 수 밀리초 대기 후 안전하게 비즈니스 트랜잭션을 재시도(Retry)하는 구조를 쉽게 코딩할 수 있습니다.
Q. 인덱스가 없으면 데드락이 발생할 확률이 정말 더 높아지나요?
그렇습니다. 만약 특정 조건으로 로우를 찾아 업데이트하는 쿼리에 적절한 인덱스가 없다면, PostgreSQL 엔진은 대상 로우를 찾기 위해 테이블 전체를 풀 스캔(Seq Scan)해야 합니다. 이 과정에서 업데이트 대상이 아닌 행들까지 거치며 락 획득 범위가 불필요하게 넓어져, 다른 동시 트랜잭션들과의 잠금 충돌 빈도가 급증해 데드락으로 쉽게 비화됩니다.
Q. 데드락 로그는 시스템 어디에 저장되고 확인 가능한가요?
PostgreSQL의 로그 디렉토리 내의 서버 로그 파일에서 확인할 수 있으며, postgresql.conf에서 log_min_messages가 warning 이상이고 log_lock_waits = on으로 켜져 있으면, 데드락 감지 당시에 충돌이 발생한 쿼리 문장과 락 세션 정보가 디테일하게 로그로 찍혀 상세 원인 파악을 수월하게 돕습니다.
결론
PostgreSQL의 교착 상태는 데이터베이스의 고유 보호 동작이자 애플리케이션의 순차 설계가 비정상적임을 알리는 경고장입니다. 락 상태를 해결하기 위한 핵심은 무작정 하드웨어 서버 스펙을 상향하는 것이 아니라 pg_locks 모니터링을 통한 병목 세션을 발굴하고, 데이터 수정 순서를 규격화하며 NOWAIT 패턴 등으로 대기 지연을 거부하는 코드 레벨의 체질 개선입니다. 오늘 작성한 데이터 트랜잭션 로직을 다시 검토하시고, 잠금 획득의 앞뒤 순서가 일치하는지부터 한 단계씩 대조하여 탄탄한 데이터 인프라를 달성하시기 바랍니다.