PostgreSQL 테이블을 작업 큐로 쓸 때: FOR UPDATE SKIP LOCKED와 잠금이 풀리는 시점

여러 워커가 PostgreSQL 테이블에서 작업을 나눠 가져갈 때 쓰는 FOR UPDATE SKIP LOCKED를 공식 문서로 정리했습니다. 잠금이 유지되는 범위, LIMIT과 OFFSET, ORDER BY 순서, WITH 안의 잠금 절을 다룹니다.

개발데이터베이스SQL

jobs 테이블에 처리할 작업을 쌓아 두고 워커 여러 개가 하나씩 가져가게 하려면, 두 워커가 같은 행을 동시에 집지 않게 해야 합니다. PostgreSQL에서는 SELECT ... FOR UPDATE SKIP LOCKED가 그 용도로 문서에 나옵니다. 이 글은 2026년 10월 10일 기준 최신 정식 버전인 PostgreSQL 18(18.6) 문서를 기준으로 했고, 아래에서 인용하는 SKIP LOCKED 설명은 지원 중인 가장 오래된 버전인 14(14.24) 문서에도 같은 문장으로 있습니다. SKIP LOCKED는 9.5에서 추가되었습니다.

기본 형태

예로 쓸 테이블입니다.

CREATE TABLE jobs (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  status  text NOT NULL DEFAULT 'pending',
  payload jsonb NOT NULL
);

워커는 트랜잭션을 열고 대기 중인 작업 하나를 잠그며 가져옵니다.

BEGIN;

SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- 애플리케이션에서 작업을 처리한 뒤, 위에서 받은 id로
UPDATE jobs SET status = 'done' WHERE id = 42;

COMMIT;

SKIP LOCKED가 없으면 두 번째 워커는 첫 번째 워커가 잠근 행 앞에서 그 트랜잭션이 끝날 때까지 기다립니다. NOWAIT를 붙이면 기다리지 않고 오류를 냅니다. SKIP LOCKED를 붙이면 바로 잠글 수 없는 행을 건너뛰므로, 두 번째 워커는 다음 대기 행을 받습니다.

문서는 이 동작을 조건부로 권합니다. 잠긴 행을 건너뛰면 데이터의 일관되지 않은 모습을 보게 되므로 일반적인 작업에는 맞지 않고, 여러 소비자가 큐 같은 테이블에 접근할 때 잠금 경합을 피하는 데 쓸 수 있다고 적혀 있습니다. 또 NOWAIT와 SKIP LOCKED는 행 수준 잠금에만 적용되고, 테이블 수준의 ROW SHARE 잠금은 평소처럼 잡습니다. 테이블 잠금 대기까지 피하려면 먼저 LOCK ... NOWAIT를 쓰라고 안내합니다.

잠금은 트랜잭션이 끝날 때 풀린다

FOR UPDATE로 잡은 행 잠금은 현재 트랜잭션이 끝날 때까지 다른 트랜잭션이 그 행을 잠그거나 고치거나 지우지 못하게 합니다. 행 수준 잠금은 트랜잭션이 끝날 때나 세이브포인트로 롤백할 때 풀립니다.

여기서 흔한 실수가 하나 나옵니다. BEGIN 없이 실행한 문장은 자동 커밋 모드에서 그 문장 하나가 트랜잭션이 되고, 문장이 끝날 때 커밋됩니다. 그래서 위의 SELECT ... FOR UPDATE SKIP LOCKED를 BEGIN 없이 보내면 결과를 받는 시점에는 잠금이 이미 풀려 있고, 다른 워커가 같은 행을 가져갈 수 있습니다. 드라이버나 ORM이 자동 커밋으로 동작하는지 확인해야 하는 이유입니다.

반대로 트랜잭션을 연 채로 작업을 처리하면, 처리 중에 실패해 롤백할 때 status 변경도 함께 취소되고 잠금도 풀리므로 그 행은 다시 다른 워커가 집을 수 있는 pending 상태로 남습니다. 대가는 처리 시간만큼 트랜잭션이 열려 있다는 점입니다. 문서는 교착 상태가 아닌 한 잠금을 기다리는 트랜잭션은 무기한 기다리므로, 사용자 입력을 기다리는 경우처럼 트랜잭션을 오래 열어 두는 것은 좋지 않다고 적고 있습니다.

처리 시간이 긴 작업이라면 짧은 트랜잭션에서 상태를 running으로 바꿔 커밋한 뒤 트랜잭션 밖에서 처리하는 구성도 생각할 수 있습니다. 이때는 워커가 처리 도중 죽으면 그 행이 running으로 남으므로, 그런 행을 되돌리는 방법은 따로 마련해야 합니다. 이 두 구성의 비교는 문서가 아니라 위 동작에서 이끌어 낸 것입니다.

상태 조건은 다시 확인된다

두 워커가 거의 동시에 같은 쿼리를 실행하면, 한쪽이 처리를 마치고 done으로 커밋한 행이 다른 쪽 쿼리의 스냅샷에서는 아직 pending일 수 있습니다. 문서에 따르면 최상위 SELECT에 잠금 절이 있을 때 쿼리 스냅샷 기준으로 조건에 맞던 행은 잠기지만, 스냅샷 이후 갱신되어 조건에 더 이상 맞지 않으면 결과로 돌려주지 않습니다. 기본 격리 수준인 READ COMMITTED에서 행을 다시 평가하는 방식은 READ COMMITTED와 스냅샷에서 다뤘습니다.

격리 수준을 REPEATABLE READ나 SERIALIZABLE로 올리면 이야기가 달라집니다. 이 수준에서는 잠그려는 행이 트랜잭션 시작 뒤 바뀌었으면 오류가 납니다. 이 글의 예는 READ COMMITTED를 전제로 합니다.

LIMIT은 괜찮고 OFFSET은 조심

잠금 절과 LIMIT을 함께 쓰면, 문서에 따르면 LIMIT을 채울 만큼 행을 돌려준 시점에 잠금도 멈춥니다. LIMIT 1이면 돌려받은 한 행만 잠깁니다. 다만 OFFSET으로 건너뛴 행은 잠깁니다. 워커마다 OFFSET을 다르게 줘서 작업을 나누려 하면 받지도 않은 행까지 잠그게 되는데, SKIP LOCKED를 쓴다면 그럴 필요 자체가 없습니다.

잠금 절은 집계와 함께 쓸 수 없습니다. 돌려주는 행이 테이블의 개별 행과 분명히 대응하지 않기 때문입니다.

ORDER BY는 처리 순서를 보장하지 않는다

ORDER BY id는 각 워커가 잠글 수 있는 행 중 id가 가장 작은 것을 고르게 할 뿐입니다. 더 작은 id가 다른 워커에게 잠겨 있으면 건너뛰므로, 작업이 시작되고 끝나는 순서가 id 순서와 같다는 보장은 없습니다. 잠긴 작업을 가진 워커가 롤백하면 그 작업은 뒤에 쌓인 작업보다 늦게 다시 처리될 수도 있습니다. 엄격한 선입선출이 필요하다면 이 방식만으로는 부족합니다. 이 단락의 결론은 앞의 문서 내용에서 이끌어 낸 것입니다.

그래도 ORDER BY는 빼지 않는 편이 좋습니다. 문서는 ORDER BY 없이 LIMIT을 쓰면 같은 쿼리를 반복해도 어떤 행 집합이 나올지 정해져 있지 않다고 설명합니다.

한 문장으로 가져가기: WITH 안의 잠금 절

조회와 상태 변경을 한 문장으로 묶을 수도 있습니다.

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
  ORDER BY id
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'running'
WHERE id IN (SELECT id FROM next_job)
RETURNING id, payload;

잠금 절의 위치가 중요합니다. 문서에 따르면 잠금 절은 주 쿼리가 참조하는 WITH 쿼리에는 적용되지 않으므로, WITH 안에서 행을 잠그려면 잠금 절을 WITH 쿼리 안에 써야 합니다. UPDATE에 붙인 RETURNING은 실제로 갱신된 행의 값을 클라이언트에 돌려주므로, 워커는 이 문장 하나로 가져간 작업의 id와 payload를 받습니다.

이 문장도 BEGIN 없이 보내면 자동 커밋으로 끝나며 잠금이 풀립니다. 이때 행을 다른 워커로부터 지키는 것은 잠금이 아니라 이미 커밋된 running 상태이고, 앞에서 말한 “짧은 트랜잭션으로 상태를 바꾸는” 구성과 같아집니다.

잠긴 행도 일반 SELECT에는 보인다

행 수준 잠금은 데이터 조회에 영향을 주지 않고, 같은 행을 쓰거나 잠그려는 쪽만 막습니다. 트랜잭션 안에서 잠근 채 처리하는 구성이라면 SELECT count(*) FROM jobs WHERE status = 'pending' 같은 모니터링 쿼리에는 처리 중인 작업도 pending으로 셉니다.

다른 데이터베이스

문서의 호환성 절에 따르면 SQL 표준은 FOR UPDATE를 DECLARE CURSOR의 옵션으로만 허용하고, NOWAIT와 SKIP LOCKED는 표준에 없습니다. 같은 키워드를 지원하는 다른 데이터베이스가 있더라도 동작은 각자의 문서로 확인해야 하며, 이 글의 내용은 PostgreSQL에만 해당합니다.

참고한 문서

링크와 내용은 2026년 10월 10일에 확인했습니다.

블로그로