MVCC와 FOR UPDATE, FOR KEY SHARE의 차이
1. MVCC가 있는데도 Lock이 필요한 이유
PostgreSQL은 MVCC로 여러 버전의 데이터를 관리한다. 일반 SELECT는 다른 트랜잭션이 행을 수정 중이어도 자신의 스냅샷에서 읽을 수 있다. 행 잠금이 일반 SELECT까지 차단하는 것은 아니다.
다만 여러 트랜잭션이 같은 행을 변경할 때는 잠금으로 순서를 조정해야 한다.
UPDATE accounts SET balance = balance - 100 WHERE id = 1;같은 행을 변경하려는 다른 트랜잭션은 대기할 수 있지만, 단순 SELECT는 대체로 계속 실행된다. 행 잠금은 COMMIT 또는 ROLLBACK까지 유지된다. 따라서 SQL 실행 자체는 짧더라도 트랜잭션을 오래 열어 두면 다른 세션의 작업이 누적될 수 있다. 잠금 대기를 볼 때는 쿼리 시간뿐 아니라 트랜잭션의 지속 시간도 함께 확인해야 한다.
2. Table-Level Lock
SQL 실행 중 PostgreSQL은 테이블 수준 잠금도 자동 획득한다. 대표적인 모드는 다음과 같다.
| 테이블 잠금 모드 | 대표적으로 발생하는 작업 |
| ACCESS SHARE | 일반 SELECT |
| ROW SHARE | SELECT ... FOR UPDATE / FOR SHARE 등 |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE |
| SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY 등 |
| SHARE | 일반 CREATE INDEX |
| SHARE ROW EXCLUSIVE | CREATE TRIGGER, 일부 ALTER TABLE |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY |
| ACCESS EXCLUSIVE | TRUNCATE, DROP TABLE, VACUUM FULL, 일부 ALTER TABLE 등 |
ROW SHARE와 ROW EXCLUSIVE는 이름과 달리 테이블 잠금이다. 일반 SELECT의 ACCESS SHARE는 ACCESS EXCLUSIVE와만 충돌하므로, 보통의 UPDATE가 테이블 전체 SELECT를 차단하지는 않는다.
반대로 DROP TABLE이나 TRUNCATE처럼 ACCESS EXCLUSIVE를 요구하는 작업은 일반 조회까지 대기시킬 수 있다. 행 잠금과 테이블 잠금의 충돌을 구분하는 이유다.
3. 네 가지 Row-Level Lock의 차이
SELECT에 FOR 절을 붙여 반환 대상 행을 명시적으로 잠글 수 있다.
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
SELECT * FROM accounts WHERE id = 1 FOR KEY SHARE;FOR UPDATE
가장 강한 행 잠금이다. 같은 행에 대한 다른 네 모드의 잠금 요청 모두와 충돌한다. DELETE나 외래 키 참조에 사용할 수 있는 고유 키를 변경하는 UPDATE에 사용된다.
FOR NO KEY UPDATE
FOR UPDATE보다 약해 FOR KEY SHARE와 공존할 수 있다. 기본 키 등 참조 키를 바꾸지 않고 일반 컬럼을 수정할 때 주로 사용된다. 단, 여기서 NO KEY란 모든 키 컬럼을 무조건 변경하지 않는다는 뜻이 아니라 PostgreSQL이 외래 키 참조에 사용할 수 있다고 판단하는 고유 키를 바꾸지 않는다는 의미다.
FOR SHARE
다른 FOR SHARE, FOR KEY SHARE는 허용하지만 같은 행의 UPDATE나 DELETE는 차단한다.
FOR KEY SHARE
행 삭제와 외래 키 참조에 영향을 줄 수 있는 키 변경을 차단한다. 그러나 일반 컬럼의 UPDATE는 허용한다. 이때 키는 이름이 id인 모든 컬럼이 아니라, PostgreSQL이 특정 고유 인덱스를 기준으로 판단하는 컬럼이다.
4. 행 잠금 충돌
다음 표에서 '충돌'은 다른 트랜잭션이 같은 행에 잠금을 보유한 상태에서 요청 시 대기가 필요할 수 있음을 뜻한다. 같은 트랜잭션에서 획득한 잠금은 서로 충돌하지 않는다.
| 요청 기존 | UPDATE | NO KEY UPDATE | SHARE | KEY SHARE |
| FOR UPDATE | 충돌 | 충돌 | 충돌 | 충돌 |
| FOR NO KEY UPDATE | 충돌 | 충돌 | 충돌 | 허용 |
| FOR SHARE | 충돌 | 충돌 | 허용 | 허용 |
| FOR KEY SHARE | 충돌 | 허용 | 허용 | 허용 |
FOR NO KEY UPDATE와 FOR KEY SHARE는 서로 충돌하지 않는다.
5. NO KEY UPDATE와 KEY SHARE
기본 키 id와 일반 컬럼 name이 있는 테이블을 준비한다.
CREATE TABLE parent_item (id bigint PRIMARY KEY, name text NOT NULL);
INSERT INTO parent_item VALUES (1, 'original');세션 A가 키를 변경하지 않고 트랜잭션을 유지한다.
-- 세션 A
BEGIN;
UPDATE parent_item SET name = 'changed' WHERE id = 1;
-- 아직 COMMIT하지 않음같은 행에 세션 B가 FOR KEY SHARE를 요청한다.
-- 세션 B
BEGIN;
SELECT id, name FROM parent_item WHERE id = 1 FOR KEY SHARE;
COMMIT;세션 A는 일반 컬럼만 바꿨으므로 FOR NO KEY UPDATE 수준의 행 잠금을 사용한다. 따라서 B의 KEY SHARE와 충돌하지 않는다.
B는 커밋되지 않은 새 값 대신 스냅샷에 보이는 이전 값을 읽는다. 반면 B가 FOR SHARE나 FOR UPDATE를 요청했다면 A의 잠금과 충돌해 대기한다.
이 차이는 단순히 잠금의 이름이 달라서가 아니라, 키를 유지하는 변경과 키의 존재를 확인하는 작업을 동시에 허용하기 위한 설계에서 나온다.
반대로 A가 id 같은 참조 키를 변경하면 FOR UPDATE 수준이 되어 B의 KEY SHARE와 충돌할 수 있다. 외래 키는 이 차이를 이용해 부모 키를 보호하면서도 이름 같은 일반 컬럼의 수정을 허용한다.
6. NOWAIT와 SKIP LOCKED
FOR UPDATE는 잠긴 행을 만나면 기본적으로 대기한다. 선택적으로 대기 방식을 바꿀 수 있다.
- NOWAIT: 기다리지 않고 즉시 오류를 반환한다.
- SKIP LOCKED: 잠긴 행을 건너뛴다. 여러 워커가 서로 다른 작업을 가져가는 큐에 유용하다.
BEGIN;
WITH picked AS (
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED
)
UPDATE jobs AS j SET status = 'running'
FROM picked WHERE j.id = picked.id
RETURNING j.id;
COMMIT;SKIP LOCKED 결과는 잠긴 행을 제외하므로 일관된 전체 목록이 아니다. 일반 조회보다는 작업 선점에 적합하다.
NOWAIT와 SKIP LOCKED는 행 수준 잠금에 적용되며, SQL이 필요한 테이블 잠금 대기까지 모두 없애지는 않는다. 커밋하면 행 잠금이 해제되므로 장시간 실행할 작업의 상태와 재시도는 별도로 관리해야 한다.
작업을 실행하는 동안 트랜잭션을 유지하는 대신, 먼저 작업 소유권을 저장하고 빠르게 커밋하는 방식도 고려할 수 있다.
7. Deadlock과 잠금 대기
트랜잭션 A가 1번 행을 잠근 뒤 2번을 기다리고, B가 2번을 잠근 뒤 1번을 기다리면 Deadlock이 발생할 수 있다. PostgreSQL은 이를 탐지해 한 트랜잭션을 중단한다(SQLSTATE 40P01).
예방하려면 여러 행을 항상 동일한 순서로 잠그고 트랜잭션을 짧게 유지해야 한다. 중단된 트랜잭션 전체를 재시도하되, 횟수와 지연을 제한하는 것도 중요하다.
8. 잠금을 기다리는 세션 확인
pg_stat_activity와 pg_blocking_pids로 현재 잠금 대기를 확인할 수 있다.
SELECT a.pid, a.state, a.wait_event,
pg_blocking_pids(a.pid) AS blocking_pids,
now() - a.query_start AS elapsed,
left(a.query, 160) AS query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY elapsed DESC;잠금 종류는 pg_locks로 살펴본다. 단, 행 잠금이 모두 개별 행으로 표시되지는 않으므로 대기 중인 트랜잭션과 blocking PID를 함께 확인해야 한다. lock_timeout은 잠금 대기, statement_timeout은 쿼리 실행 시간을 제한한다.
예를 들어 트랜잭션 안에서 SET LOCAL lock_timeout = '3s'를 실행하면 잠금을 얻기 위해 3초 이상 기다리지 않도록 제한할 수 있다. 이를 쿼리 전체의 3초 제한으로 해석하면 안 된다.
9. Advisory Lock은 무엇이 다른가
Advisory Lock은 행이 아닌 애플리케이션이 정한 정수 키로 잠근다. 하나의 스케줄 작업만 실행하는 등 협력적 제어에 사용할 수 있다.
BEGIN;
SELECT pg_advisory_xact_lock(1001);
-- 작업 수행
COMMIT;트랜잭션 수준 잠금은 종료 시 자동 해제된다. 세션 수준 잠금은 롤백과 무관하게 유지되므로 커넥션 풀에서 주의해야 한다. 관련 코드가 동일한 잠금 키를 사용해야 효과가 있다.
10. 잠금 설계에서 기억할 점
- 일반 SELECT는 행 잠금만으로 차단되지 않는다. MVCC의 스냅샷을 읽는다.
- FOR UPDATE를 무조건 쓰기보다 필요한 충돌 관계를 따져야 한다. NO KEY UPDATE와 KEY SHARE는 서로 허용된다.
- SKIP LOCKED는 작업 큐에 유용하지만 전체 목록 조회에는 부적합하다.
- 트랜잭션은 짧게 유지하고, 잠금 순서와 재시도 정책을 정해야 한다.
FOR UPDATE는 존재하지 않는 미래의 행까지 잠그는 범위 잠금이 아니다. 중복 삽입 등의 문제는 Unique 제약 조건이나 원자적 UPDATE, 격리 수준도 함께 고려해야 한다.
동시성 제어를 위해 행 잠금을 무조건 추가하기보다, 어떤 경합이 실제로 발생하는지 먼저 확인해야 한다. 예를 들어 중복 생성 방지는 고유 제약 조건, 여러 값을 함께 수정하는 작업은 원자적인 UPDATE나 적절한 트랜잭션 격리 수준이 더 직접적인 해결책일 수 있다.
결국 잠금은 강할수록 좋은 것이 아니라 막아야 할 작업만 막는 최소한의 강도로 선택하는 것이 중요하다.