빌드노트
행은 보이는데 PostgreSQL SKIP LOCKED는 0건일 때
PostgreSQL 18.6의 두 연결에서 일반 조회·NOWAIT·SKIP LOCKED를 비교했다. 잠긴 후보를 건너뛴 0건과 필터 불일치의 0건을 구별하고, 빈 응답으로 전체 큐 소진을 판단할 수 없는 이유를 설명한다.
PostgreSQL 작업 큐에 ready 행이 보이는데 FOR UPDATE SKIP LOCKED 조회는 0건을 반환할 수 있다. 다른 트랜잭션이 그 행을 잠그고 있으면 조회 대상에서 건너뛰기 때문이다. 빈 결과만으로 전체 큐가 소진됐다고 판단하면 안 된다. 이번 조회에서 조건에 맞으면서 즉시 잠글 수 있는 행을 얻지 못했다는 뜻으로 읽어야 한다.
먼저 워커가 실행한 쿼리에서 잠금절만 빼고 조회해 본다. DB·접속 역할·바인딩한 값·나머지 조건은 같게 둔다. 관리 화면에서 테이블 전체를 본 것과 워커의 WHERE를 통과한 것은 다르다. 상태 외에 예약 시각이나 작업 종류를 제한한다면 그 조건도 그대로 비교해야 한다.
잠금절 없는 조회도 비어 있다면 우선 그 연결에 보이는 데이터와 필터를 확인한다. 일반 조회에는 나오고 SKIP LOCKED에서는 빠진다면 다른 트랜잭션의 행 잠금을 의심할 근거가 된다. 다만 두 조회 사이에 데이터가 바뀔 수 있으므로 이 차이만으로 운영 환경의 원인을 확정할 수는 없다.
같은 행을 읽기만 할 때와 잠그려 할 때
PostgreSQL 18의 행 잠금 문서 (새 탭에서 열림)는 행 잠금이 일반 조회를 막지 않으며, 같은 행을 변경하거나 잠그려는 다른 트랜잭션과 충돌한다고 설명한다. 그래서 일반 SELECT에 행이 보인다는 사실과 다른 워커가 그 행을 선점할 수 있다는 사실은 별개다.
이를 확인하려고 새 전용 컨테이너의 PostgreSQL·psql 18.6에서 두 연결 A와 B를 열었다. 두 연결의 서버 프로세스 ID는 달랐고 격리 수준은 READ COMMITTED였다. 운영 데이터를 옮기지 않고 (1, ready), (2, done) 두 행만 만들었다. 조건에 맞는 행은 1번 하나였다.
직접 비교하려면 테이블을 만들 수 있는 새 실습용 DB에 psql로 두 번 접속한다. 아래 설정은 두 연결 모두에 입력한다. 자동 커밋을 켜 두되 A에서만 나중에 명시적으로 트랜잭션을 연다. 문장 제한 시간은 2.5초, 잠금 대기는 0.5초로 제한한다.
\set AUTOCOMMIT on
\set ON_ERROR_STOP off
\set ON_ERROR_ROLLBACK off
\set VERBOSITY verbose
SET statement_timeout = '2500ms';
SET lock_timeout = '500ms';
앞의 네 줄은 psql 설정이다. 오류 뒤에도 다음 입력을 받으며 자동 저장점 복구는 끈다. psql 문서 (새 탭에서 열림)에 설명된 대로, 자동 커밋이 켜져 있어도 BEGIN을 실행하면 직접 트랜잭션을 끝내야 한다. B에서는 BEGIN을 실행하지 않으므로 뒤의 의도적인 오류가 다음 비교를 실패 상태로 묶지 않는다.
A에서 다음을 실행해 비교할 데이터를 커밋한다. public.skip_queue가 없는 DB에서 시작한다. 두 연결에서 함께 읽을 테이블이므로 세션 전용 임시 테이블은 쓰지 않는다.
CREATE TABLE public.skip_queue (id integer PRIMARY KEY, state text NOT NULL);
INSERT INTO public.skip_queue VALUES (1, 'ready'), (2, 'done');
이제 A에서 1번을 잠그고, B의 비교를 마칠 때까지 이 트랜잭션을 유지한다.
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT id, state FROM public.skip_queue
WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE;
A는 (1, ready)를 반환했다. 값을 바꾸지는 않았지만 행 잠금은 트랜잭션이 끝날 때까지 유지됐다. A가 자동 커밋 상태에서 SELECT 한 문장만 실행하고 끝났다면 이처럼 잠금을 유지하는 비교가 되지 않는다.
B에서 아래 세 문장을 각각 실행하면 같은 필터에 잠금 방식만 바꾼 결과를 볼 수 있다.
SELECT id, state FROM public.skip_queue
WHERE state = 'ready' ORDER BY id LIMIT 1;
SELECT id, state FROM public.skip_queue
WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE NOWAIT;
SELECT id, state FROM public.skip_queue
WHERE state = 'ready' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
실제 결과는 다음과 같았다. 행 표시는 읽기 쉽게 옮겼고 오류는 첫 줄을 발췌했다.
- 일반 SELECT:
(1, ready)한 행. NOWAIT:ERROR: 55P03: could not obtain lock on row in relation "skip_queue".SKIP LOCKED: 오류 없이(0 rows).
1번은 삭제되지 않았고 조건에서도 빠지지 않았다. B는 읽을 수 있었지만 A와 충돌하는 잠금을 얻을 수 없었다. SELECT 문서의 잠금절 설명 (새 탭에서 열림)은 이때 NOWAIT는 기다리는 대신 오류를 내고, SKIP LOCKED는 즉시 잠글 수 없는 행을 건너뛴다고 구분한다. 여기서는 유일한 후보를 건너뛰었으므로 결과가 비었다. LIMIT 1도 한 행이 반드시 나온다는 보장은 아니다.
별도 비교에서 B가 옵션 없는 FOR UPDATE를 실행하자 ERROR: 55P03: canceling statement due to lock timeout으로 끝났다. 이 메시지는 즉시 거절한 NOWAIT 오류와 다르다. 이번에는 대기하다 설정한 잠금 제한에 걸렸다. 제한 시간 문서 (새 탭에서 열림)도 lock_timeout을 잠금을 얻기 위해 기다리는 시간의 제한으로 설명한다. SQLSTATE만 보고 이 두 상황을 같은 실행 경로로 해석하지 않는다.
잠금을 풀면 같은 조건에서 다시 나온다
A에서 자기 실습 트랜잭션에 ROLLBACK;을 입력한 뒤, B에서 앞의 FOR UPDATE SKIP LOCKED 문장을 그대로 다시 실행했다. 이번에는 (1, ready) 한 행이었다. 잠그기 전 기준 조회에서도 같은 행이 나왔다. 두 행의 최종 값은 처음과 같았다.
이 실험에서는 A의 행 잠금만 생겼다가 사라졌다. 따라서 잠금 중의 0건을 큐 소진으로 해석하면 아직 ready인 1번을 놓친다. 실제 워커가 값을 갱신한 뒤 커밋하는 경우까지 같은 결과라고 예측할 수는 없다. 여기서는 변경 없이 잠근 뒤 롤백했다.
필터 불일치도 따로 비교했다. A가 끝난 뒤 B에서 두 조회의 state = 'ready'를 state = 'waiting'으로 바꾸자, 일반 SELECT와 SKIP LOCKED가 모두 0건이었다. 테이블에는 행이 있어도 waiting은 없었다. 잠금 때문에 건너뛴 0건과, 조건에 맞는 행이 없는 0건은 반환 행 수만으로 구별되지 않는다.
자기 쿼리에서도 먼저 같은 조건의 일반 조회를 확인하는 이유가 여기에 있다. 다만 READ COMMITTED 문서 (새 탭에서 열림)처럼 연속한 조회가 서로 다른 시점의 데이터를 볼 수 있다. 실제 서비스에서는 같은 DB와 역할에서 실행한 SQL·바인딩 값·관찰 시각을 맞춰 보고, 워커의 트랜잭션이 언제 시작하고 끝나는지도 함께 확인해야 한다. 두 SELECT를 나란히 실행하는 것만으로 원자적인 진단이 되는 것은 아니다.
워커의 빈 응답을 종료 신호로 쓰기 전에
여러 소비자가 같은 큐를 나눠 처리할 때 잠긴 후보를 건너뛰는 동작은 유용하다. 그러나 공식 SELECT 문서는 이 결과가 전체 데이터를 일관되게 보여 주지 않으므로 일반적인 데이터 조회에 적합하지 않다고 명시한다. 재고 총량이나 전체 미처리 목록을 확인하는 조회에 그대로 적용할 이유는 없다.
계속 돌아야 하는 워커라면 0건을 받았을 때 현재 시도에서 가져갈 작업이 없다고 처리하고, 정해 둔 대기·재조회 정책을 따른다. 유한한 배치가 끝났다고 선언하려면 생산이 끝났는지와 다른 워커가 진행 중인 작업이 남았는지를 별도로 확인해야 한다. 이 실험에서는 작업 완료 기록이나 재시도 간격을 설계·검증하지 않았다.
또한 SKIP LOCKED가 모든 종류의 잠금 대기를 없애는 것은 아니다. 공식 문서에 따르면 이 옵션은 행 잠금에 적용되며, 조회에 필요한 테이블 잠금은 일반 방식으로 얻는다. 조회가 오래 걸린다는 다른 증상까지 이번 0건 재현으로 설명할 수는 없다.
이번 관찰은 두 합성 행, 두 연결, READ COMMITTED, 행을 변경하지 않는 FOR UPDATE에 한정된다. 다른 격리 수준, 동시 상태 변경, 테이블 잠금 충돌, 많은 워커에서의 공정성·처리량은 실행하지 않았다. 확인한 것은 다른 연결이 후보 행을 잠그는 동안 일반 조회와 선점 조회의 결과가 달라지고, 그 빈 결과가 전체 큐 소진을 증명하지 못한다는 범위다.