2편에서 다룬 MVCC가 "읽기와 쓰기를 어떻게 분리하는가"라면, 락은 "동시 쓰기를 어떻게 조율하는가"다.
이 글에서 다루는 것:
- 테이블 락 8단계와 호환성, 행 락 4종
- 행 락의 여부의 저장 위치 (튜플 헤더)
- 데드락, lock wait 디버깅 (pg_locks · pg_blocking_pids)
- 타임아웃 설정 (statement_timeout · lock_timeout · idle_in_transaction_session_timeout)
MVCC만으로는 부족한 순간

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자주 만나는 명령이 어떤 락을 잡는지만 알면 된다.
| 명령 | 잡는 락 | 의미 |
| SELECT | ACCESS SHARE | 가장 약함. ACCESS EXCLUSIVE만 막음 |
| SELECT FOR UPDATE | ROW SHARE | 행 락을 걸 거라는 신호 |
| INSERT, UPDATE, DELETE | ROW EXCLUSIVE | 데이터 변경. DDL은 막지만 다른 DML은 통과 |
| VACUUM, CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | 자기들끼리만 막음, DML 통과 |
| CREATE INDEX (CONCURRENTLY 없이) | SHARE | DML 차단 |
| DROP TABLE, TRUNCATE, VACUUM FULL, REINDEX | ACCESS EXCLUSIVE | 가장 강함. 모든 걸 막음 |
8단계를 다 외우는 대신 두 가지 규칙만 기억하면 된다.
(1) 같은 락끼리 충돌하지 않으면 동시 실행 가능. 예를 들어 ACCESS SHARE는 자기들끼리 무한히 공존한다.
그래서 SELECT가 수백 개 동시에 돌 수 있다. 반대로 ACCESS EXCLUSIVE는 자기 자신과도 충돌하므로 한 번에 하나만 가능하다.
(2) ACCESS EXCLUSIVE만이 SELECT를 막는다.
그래서 운영 중인 DB에서 함부로 DROP TABLE, TRUNCATE, VACUUM FULL, 일부 ALTER TABLE을 돌리면 모든 쿼리가 멈춰버린다.
- CREATE INDEX와 CREATE 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에 대해 알아본다.
'데이터베이스 > PostgreSQL 내부 구조' 카테고리의 다른 글
| PostgreSQL 내부구조 (6) ㅡ Index (0) | 2026.07.18 |
|---|---|
| PostgreSQL 내부 구조 (5) — WAL과 Checkpoint, 장애 복구의 핵심 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (4) — VACUUM, 옛 버전은 누가 치우는가 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (2) — MVCC와 격리 수준 (1) | 2026.04.18 |
| PostgreSQL 내부 구조 (1) — 페이지와 Shared Buffers (1) | 2026.04.18 |
| PostgreSQL 기본 (0) — 연결과 프로세스 구조 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 — 시리즈를 시작하며 (0) | 2026.04.16 |