빠른 비교
BEGIN;
SELECT *
FROM jobs
WHERE status = 'queued'
FOR UPDATE SKIP LOCKED;
-- 작업 처리 후
UPDATE jobs SET status = 'processing' WHERE id = <picked_id>;
COMMIT;갈리는 기준
READ COMMITTED와 REPEATABLE READ의 차이는 스냅샷 시점이다
PostgreSQL의 기본 격리 수준인 READ COMMITTED는 각 쿼리 실행 시마다 새로운 스냅샷을 봅니다. 즉, 같은 트랜잭션 안에서 두 번 SELECT를 실행하면 그 사이에 다른 트랜잭션이 커밋한 변경이 두 번째 SELECT에 반영됩니다. REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 고정해 트랜잭션 내 모든 SELECT가 항상 같은 데이터를 봅니다. 잔액 조회 후 차감처럼 "읽은 값을 기반으로 판단하는" 로직에서는 REPEATABLE READ가 더 안전합니다.
-- READ COMMITTED (기본): 각 쿼리마다 최신 스냅샷
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000 읽음
-- 다른 트랜잭션이 800으로 업데이트하고 커밋
SELECT balance FROM accounts WHERE id = 1; -- 800 읽음 (non-repeatable read)
COMMIT;
-- REPEATABLE READ: 트랜잭션 시작 시점 스냅샷 고정
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000 읽음
-- 다른 트랜잭션이 800으로 업데이트하고 커밋
SELECT balance FROM accounts WHERE id = 1; -- 여전히 1000 (스냅샷 고정)
COMMIT;격리 수준과 행 잠금은 같은 동시성 문제를 다른 층에서 다룬다
격리 수준은 "어떤 스냅샷을 볼지"를 정하고, FOR UPDATE 같은 행 잠금은 "누가 이 행을 먼저 수정할지"를 정합니다. 읽기 일관성 문제인지, 수정 경쟁 문제인지를 먼저 구분하면 어떤 도구를 써야 할지 더 명확해집니다.
-- 읽기 일관성
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 수정 경쟁 방지
SELECT * FROM jobs
WHERE status = 'queued'
FOR UPDATE SKIP LOCKED;FOR UPDATE는 행을 잠가 동시 수정 경쟁을 막는다
SELECT ... FOR UPDATE는 읽은 행에 배타적 행 잠금을 겁니다. 잠긴 행을 다른 트랜잭션이 UPDATE하거나 FOR UPDATE로 선택하려면, 이 트랜잭션이 커밋하거나 롤백할 때까지 대기합니다. 잔액 차감, 재고 소진, 예약 시스템처럼 "읽고 나서 즉시 수정해야 하며 중간에 다른 트랜잭션이 끼어들면 안 되는" 패턴에 씁니다. FOR UPDATE는 MVCC 스냅샷이 아닌 현재 커밋된 버전의 행을 잠그므로, 잠금 시 다른 트랜잭션의 미커밋 변경을 기다립니다.
BEGIN;
-- 재고가 있는 상품을 잠그고 확인
SELECT stock FROM products WHERE id = 10 FOR UPDATE;
-- stock을 확인한 후에만 차감 (다른 트랜잭션은 대기 중)
UPDATE products SET stock = stock - 1 WHERE id = 10;
COMMIT;격리 수준을 높이는 것과 명시적 잠금은 대체 관계가 아니다
격리 수준은 읽기 일관성을, FOR UPDATE 같은 잠금은 수정 순서를 직접 제어합니다. 읽은 값을 기반으로 바로 수정해야 하는 업무에서는 REPEATABLE READ만으로 충분하지 않을 수 있고, 반대로 읽기 스냅샷만 중요할 때는 굳이 잠금까지 걸 필요가 없습니다.
-- 읽기 일관성 확보
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 수정 순서까지 제어
SELECT *
FROM jobs
WHERE status = 'queued'
FOR UPDATE;SKIP LOCKED는 잠긴 행을 건너뛰어 작업 큐를 구현한다
여러 워커가 동시에 같은 큐에서 작업을 가져가는 패턴에서 FOR UPDATE만 쓰면, 한 워커가 잠근 행을 다른 워커들이 모두 대기합니다. SKIP LOCKED를 추가하면 이미 잠긴 행은 결과에서 제외하고 잠기지 않은 행만 반환합니다. 각 워커가 서로 다른 행을 선택하게 되어 충돌 없는 분산 처리가 가능합니다. 이 패턴은 PostgreSQL을 메시지 큐처럼 활용할 때 표준 접근 방법입니다.
-- 워커가 잠기지 않은 작업 하나를 원자적으로 선점
WITH next_job AS (
SELECT id FROM jobs
WHERE status = 'queued'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'processing', started_at = NOW()
WHERE id = (SELECT id FROM next_job)
RETURNING *;격리 수준이 강할수록 직렬화 실패 재시도가 필요하다
SERIALIZABLE 격리 수준은 가장 강한 보장을 제공합니다. PostgreSQL은 직렬 실행과 동일한 결과를 보장하기 위해 트랜잭션 간 의존성을 추적하고, 충돌이 감지되면 하나의 트랜잭션을 serialization failure 오류로 중단합니다. 이 오류를 받은 트랜잭션은 처음부터 재시도해야 합니다. 높은 동시성 환경에서 재시도 로직 없이 SERIALIZABLE을 사용하면 오류가 빈번하게 발생합니다. 대부분의 경우 READ COMMITTED + 명시적 FOR UPDATE가 복잡도 대비 더 실용적인 선택입니다.
-- SERIALIZABLE: 가장 강한 격리, 실패 시 재시도 로직 필요
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... 작업 ...
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies
-- → 애플리케이션에서 재시도선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 대부분의 일반 업무 (기본값) | READ COMMITTED |
| 트랜잭션 내 일관된 스냅샷이 필요할 때 | REPEATABLE READ |
| 읽은 행을 즉시 잠가 동시 수정을 막을 때 | SELECT ... FOR UPDATE |
| 여러 워커가 충돌 없이 큐 작업을 나눠 처리할 때 | FOR UPDATE SKIP LOCKED |
주의할 점
트랜잭션을 사용한다고 해서 동시성 문제가 자동으로 해결되지는 않습니다. READ COMMITTED에서는
같은 트랜잭션 안에서도 다른 트랜잭션의 커밋이 반영되어 읽는 값이 바뀔 수 있습니다. 잔액 처리나
재고 소진처럼 "읽은 값을 기반으로 판단하는" 로직에는 FOR UPDATE로 명시적 잠금을 걸거나
격리 수준을 높이는 설계가 필요합니다. FOR UPDATE를 남용하면 잠금 경합이 늘어 성능이 저하되므로,
꼭 필요한 행에만 선별적으로 적용해야 합니다.
BEGIN;
SELECT stock FROM products WHERE id = 10;
-- 다른 세션이 stock을 변경 후 커밋
UPDATE products SET stock = stock - 1 WHERE id = 10;
COMMIT;트랜잭션만 감쌌다고 안전해지는 것은 아닙니다. 읽은 값을 믿고 업데이트해야 하는 로직이면 FOR UPDATE 같은 명시적 잠금이 빠졌는지부터 봐야 합니다.
BEGIN;
SELECT * FROM jobs
WHERE status = 'queued'
ORDER BY created_at
LIMIT 1
FOR UPDATE;여러 워커가 큐를 소비하는데 SKIP LOCKED 없이 이렇게만 두면, 첫 행을 잡은 워커 뒤에서 나머지 워커가 줄줄이 대기할 수 있습니다. 작업 큐 문제인지, 정말 순차 처리 문제인지 먼저 구분하고 잠금 옵션을 골라야 합니다.
참고 링크
2 sources