본문 바로가기
데이터베이스/PostgreSQL 내부 구조

PostgreSQL 내부 구조 (3) — Locking, 동시성의 또 다른 축

by 조민서 2026. 4. 18.

2편에서 다룬 MVCC가 "읽기와 쓰기를 어떻게 분리하는가"라면, 락은 "동시 쓰기를 어떻게 조율하는가"다.
 
이 글에서 다루는 것:

  • 테이블 락 8단계와 호환성, 행 락 4종
  • 행 락의 여부의 저장 위치 (튜플 헤더)
  • 데드락, lock wait 디버깅 (pg_locks · pg_blocking_pids)
  • 타임아웃 설정 (statement_timeout · lock_timeout · idle_in_transaction_session_timeout)

 

MVCC만으로는 부족한 순간

https://medium.com/@ganapriyakheersagar/postgres-locks-for-handling-concurrency-data-integrity-ad3b72d03166

2편에서 본 MVCC는 한 가지를 보장한다. 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다. 옛 버전이 남아 있으니 SELECT는 자기 스냅샷에 맞는 버전을 보면 되고, UPDATE는 새 버전을 만들면 된다.
 
그런데 다음 상황들은 MVCC만으로 해결되지 않는다.

  • 쓰기 vs 쓰기: 두 트랜잭션이 같은 행을 동시에 UPDATE하면? 한쪽이 기다려야 한다.
  • DDL vs DML: 누가 SELECT하는 중에 다른 누군가가 DROP TABLE을 하면?
  • VACUUM vs DDL: VACUUM이 도는 동안 ALTER TABLE을 하면?

이런 조율을 위해 PostgreSQL은 락을 쓴다. 락은 크게 테이블 락행 락 두 가지 단위로 동작한다.

MVCC 만으로 해결되지 않는 상황은 2편에서 다룬 쓰기스큐도 있다.

  • 쓰기 스큐: 두 트랜잭션이 같은 행의 조건을 확인하고 서로 다른 행을 업데이트 하는 상황

 


모든 명령은 자동으로 테이블 락을 잡는다

먼저 알아야 할 게 있다.
PostgreSQL은 거의 모든 명령에서 자동으로 테이블 락을 잡는다. SELECT 한 번만 해도 그 테이블에 락이 걸린다. 다만 그 락이 너무 약해서 다른 작업을 거의 안 막을 뿐이다.

락을 건 정보(락 테이블) shared buffers에 위치하며, 현재 데이터베이스 내에서 데이터에 접근하는 모든 트랜잭션의 잠금 정보를 저장한다. 

 
테이블 락에는 8단계가 있다

약함 ───────────────────────────────────────────────────────────────────────────────────────────────── 강함
ACCESS SHARE → ROW SHARE → ROW EXCLUSIVE → SHARE UPDATE EXCLUSIVE
                                            → SHARE → SHARE ROW EXCLUSIVE → EXCLUSIVE → ACCESS EXCLUSIVE

자주 만나는 명령이 어떤 락을 잡는지만 알면 된다.

명령잡는 락의미
SELECTACCESS SHARE가장 약함. ACCESS EXCLUSIVE만 막음
SELECT FOR UPDATEROW SHARE행 락을 걸 거라는 신호
INSERT, UPDATE, DELETEROW EXCLUSIVE데이터 변경.
DDL은 막지만 다른 DML은 통과
VACUUM, CREATE INDEX CONCURRENTLYSHARE UPDATE
EXCLUSIVE
자기들끼리만 막음, DML 통과
CREATE INDEX (CONCURRENTLY 없이)SHAREDML 차단
DROP TABLE, TRUNCATE, VACUUM FULL, REINDEXACCESS EXCLUSIVE가장 강함. 모든 걸 막음

 
8단계를 다 외우는 대신 두 가지 규칙만 기억하면 된다.

(1) 같은 락끼리 충돌하지 않으면 동시 실행 가능. 예를 들어 ACCESS SHARE는 자기들끼리 무한히 공존한다.
그래서 SELECT가 수백 개 동시에 돌 수 있다. 반대로 ACCESS EXCLUSIVE는 자기 자신과도 충돌하므로 한 번에 하나만 가능하다.


(2) ACCESS EXCLUSIVE만이 SELECT를 막는다.
그래서 운영 중인 DB에서 함부로 DROP TABLE, TRUNCATE, VACUUM FULL, 일부 ALTER TABLE을 돌리면 모든 쿼리가 멈춰버린다.

 

  • CREATE INDEXCREATE INDEX CONCURRENTLY의 차이가 여기서 보인다. 전자는 SHARE 락을 잡아 DML을 다 막고, 후자는 SHARE UPDATE EXCLUSIVE만 잡아 DML과 공존한다. 운영 중에는 거의 항상 CONCURRENTLY를 쓴다.
  • VACUUM이 SHARE UPDATE EXCLUSIVE인 것도 같은 맥락이다. VACUUM은 같은 시간에 다른 VACUUM이나 ALTER TABLE이 안 돌게만 보장하면 되지, INSERT/UPDATE/DELETE는 그대로 돌게 두어야 한다. 운영 DB에서 VACUUM이 일반 트래픽을 막지 않는 이유다.

행 락 — 같은 행을 두 번 쓰지 않게

테이블 락이 "테이블 구조를 보호"한다면, 행 락은 "특정 행의 동시 수정을 막는다."
UPDATE나 DELETE는 자동으로 자기가 건드리는 행에 행 락(FOR UPDATE 수준)을 건다. 그래서 두 트랜잭션이 같은 행을 UPDATE하면, 두 번째 트랜잭션이 첫 번째가 끝날 때까지 자동으로 대기한다.
 
명시적으로 행 락을 거는 가장 흔한 패턴은 read-modify-write다.

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 100, 락 걸림
-- 애플리케이션에서 100 + 50 = 150 계산
UPDATE accounts SET balance = 150 WHERE id = 1;
COMMIT;

FOR UPDATE가 없으면 두 트랜잭션이 같은 100을 읽고 둘 다 150으로 UPDATE해서 한쪽 변경이 사라진다 (lost update). FOR UPDATE가 두 번째 트랜잭션을 첫 번째가 끝날 때까지 대기시킨다.
 
행 락은 4가지가 있다.

락 모드강도자동으로 걸리는 경우
FOR KEY SHARE가장 약함외래 키 참조 시
FOR SHARE 명시적으로만
FOR NO KEY UPDATE 키 컬럼이 아닌 일반 UPDATE
FOR UPDATE가장 강함DELETE, 키 컬럼을 바꾸는 UPDATE

대부분의 경우 그냥 FOR UPDATE를 쓰면 된다. FOR KEY SHARE/FOR NO KEY UPDATE 같은 약한 모드는 외래 키와 관련된 동시성을 높이려는 PostgreSQL의 내부 최적화로 대부분 자동으로 처리된다.

행 락의 저장 위치 — 튜플 헤더

튜플 헤더

 
행 락 정보는 어디에 저장될까? 별도의 락 테이블이 아니라 튜플 헤더에 저장된다. t_xmax에 락을 잡은 트랜잭션 ID를 기록하고, t_infomask에 "이건 삭제가 아니라 락이다"는 플래그를 켠다.
 
이 설계의 결과 두 가지:

  • 락 개수에 메모리 한계가 없다. 행 100만 개에 락을 걸어도 메모리 락 테이블이 안 터진다. 다 디스크 위 튜플 헤더에 들어가니까.
  • SELECT FOR UPDATE가 디스크 쓰기를 유발한다. 락을 표시하려고 튜플 헤더를 수정하니, 페이지가 dirty가 된다. SELECT인데도 디스크 I/O가 생기는 또 하나의 경우다. (커밋 됐는지 확인하는 hint bit).

데드락

두 트랜잭션이 서로의 락을 기다리는 사이클이 생기면 데드락이다.
 
가장 흔한 패턴: 두 트랜잭션이 같은 행들을 다른 순서로 잠그기.

-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;  -- 1번 락
-- (이 사이에 B가 2번을 잠금)
UPDATE accounts SET balance = balance - 100 WHERE id = 2;  -- 2번 대기

-- 트랜잭션 B
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 2번 락
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 1번 대기

A는 B를, B는 A를 기다린다. PostgreSQL은 deadlock_timeout(기본 1초)마다 데드락을 탐지하고, 한쪽을 강제 롤백한다.
 

ERROR:  deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; ...

방어법은 단순하다. 모든 트랜잭션이 행을 같은 순서로 잠그게 한다. 위 예시도 두 트랜잭션이 모두 id = 1 → id = 2 순으로 UPDATE했다면 데드락이 안 일어났다. 단순히 WHERE id IN (1, 2)처럼 한 문장으로 묶거나, 정렬해서 잠그면 된다.
 

운영에서 만나는 문제: Lock Wait

데드락은 자동 탐지/복구되지만, 그보다 훨씬 자주 만나는 문제는
lock wait — 누군가가 락을 쥔 채 안 놓아서 다른 쿼리들이 줄줄이 대기하는 상황이다.
 
pg_stat_activity로 대기 중인 쿼리를 본다.

SELECT pid, state, wait_event_type, wait_event,
       now() - state_change AS waiting_for,
       query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

누구를 기다리는지 추적하려면 pg_locks와 조인한다.

SELECT blocked.pid AS blocked_pid,
       blocked.query AS blocked_query,
       blocking.pid AS blocking_pid,
       blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';

pg_blocking_pids() 한 줄로 "이 쿼리를 막고 있는 PID 목록"을 얻을 수 있어서 운영 디버깅에서 쓴다.
 
가장 흔한 원인은 idle in transaction이다. 애플리케이션이 BEGIN 후 UPDATE를 했는데, 트랜잭션을 안 닫고 그대로 멈춰 있는 상황이다. 그동안 그 행에 걸린 락은 안 풀린다.

Lock wait  방어 방법

-- 한 SQL 문이 너무 오래 돌면 자동 취소
SET statement_timeout = '30s';

-- 락을 너무 오래 기다리면 자동 취소
SET lock_timeout = '5s';

-- 트랜잭션 안에서 idle 상태가 너무 오래 가면 세션 종료
SET idle_in_transaction_session_timeout = '60s';

특히 마지막 idle_in_transaction_session_timeout은 운영 DB에서 거의 필수로 잡아둬야 한다.


정리

단위보호 대상락 여부 저장 위치
테이블 락테이블 구조, 동시 작업 조율shared buffers 락 테이블 (메모리)
행 락같은 행의 동시 수정튜플 헤더 (t_xmax, t_infomask)

 

    • MVCC가 "읽기 vs 쓰기"를 풀고, 락이 "쓰기 vs 쓰기"와 "DDL vs DML"을 푼다. 둘은 서로를 보완한다.
    • Locking 규칙 두 가지
      (1) 같은 락끼리 충돌하지 않으면 동시 실행 가능하다.
      (2) ACCESS EXCLUSIVE만이 SELECT를 막는다.
    • 운영에서 락 관련 문제의 90%는 "누가 트랜잭션을 안 닫았다"이다. 데드락보다 lock wait이 훨씬 자주 보인다. 타임아웃 설정 세 가지(statement_timeout, lock_timeout, idle_in_transaction_session_timeout)는 거의 필수다.

다음 글에서는 PostgreSQL의 MVCC가 만드는 옛 버전, 그걸 누가 어떻게 치우는지 VACUUM에 대해 알아본다.