잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계
1. 잠금은 작업을 소비했다는 기록이 아니다
에이전트 작업자를 여러 개 띄웠다. 각 작업자는 PostgreSQL에서 FOR UPDATE SKIP LOCKED로 작업 하나를 가져온다. 다른 작업자가 잡은 행을 기다리지 않으니 병렬 처리도 잘되는 것처럼 보인다. 그런데 커밋한 뒤 같은 작업이 다시 나온다면 무엇이 빠진 걸까?
핵심은 행을 잠갔다는 사실과 작업을 맡았다는 상태가 서로 다르다는 점이다. PostgreSQL 공식 문서는 행 잠금이 트랜잭션 종료 때 풀린다고 설명한다. SKIP LOCKED는 지금 즉시 잠글 수 없는 행을 건너뛰는 옵션이지, 그 작업을 영구히 큐에서 제거하는 명령이 아니다.
공식 PostgreSQL 18.0 소스의 회귀 테스트에도 이 차이가 드러난다. 상태를 바꾸지 않고 조회만 하는 순서에서 세션 A가 작업 1을 잠그면 B는 작업 2를 받는다. A가 커밋한 뒤 B가 다시 조회하면 작업 1이 나온다. 프로젝트가 저장한 기대 출력과 별도로, 이번 글에서는 PostgreSQL 16.2의 격리된 로컬 DB에서도 이 순서를 실제 확인했다. 문서와 고정 소스는 18 계열, 직접 실행한 엔진은 16.2다. 두 버전을 같은 실험으로 섞거나 최신 버전의 성능 측정이라고 부르지 않는다.
그림 1. 잠금이 해제돼도 작업 상태가 그대로면 같은 작업이 다시 선택된다. 공식 18.0 기대 출력과 별도 16.2 로컬 확인에 근거한 자체 도식. 출처: www.postgresql.org/docs/18/sql-select.html.
조회 결과가 0행일 때도 해석에 주의해야 한다. 모든 후보가 다른 작업자에게 잠겨 있으면 SKIP LOCKED는 아무것도 반환하지 않을 수 있다. 로컬 확인에서는 일반 조회로 보이는 행이 2개인데 잠금 건너뛰기 조회는 0행이었다. 따라서 “지금 가져올 작업이 없다”와 “큐 자체가 비었다”는 같은 말이 아니다. 공식 문서 역시 이 옵션이 일반적인 일관된 조회에는 적합하지 않고, 큐와 비슷한 테이블의 여러 소비자 사이 경합을 피하는 데 쓰일 수 있다고 범위를 제한한다.
2. 짧게 잠그고, 같은 문장에서 소유권을 기록한다
해결의 출발점은 모델 호출이 끝날 때까지 DB 잠금을 계속 잡는 것이 아니다. 긴 외부 작업 동안 트랜잭션을 열어두면 연결과 잠금의 수명이 외부 서비스 지연에 묶인다. 대신 선점 단계는 짧게 끝내고, 커밋 이후에도 남을 상태를 저장하는 설계가 필요하다.
아래는 id가 기본키이고, status, 기본값 0인 정수 generation, 시간대가 있는 lease_until 열이 존재한다는 전제의 축약 예시다. 기본 격리 수준인 Read Committed를 기준으로 한다. 대기 중인 행을 고르는 CTE 내부에 잠금 절을 두고, 그 행을 같은 문장에서 running으로 바꾼다.
WITH picked AS (
SELECT id FROM queue
WHERE status = 'ready'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE queue AS q
SET status = 'running',
generation = q.generation + 1,
lease_until = clock_timestamp()
+ interval '5 minutes'
FROM picked
WHERE q.id = picked.id
RETURNING q.id, q.generation;
여기서 5분은 설명용 값이지 권장 기본값이 아니다. 모델 응답 시간, 재시도 정책, 정상적인 작업 지연을 보고 정해야 한다. RETURNING은 실제로 갱신한 행의 ID와 새 세대를 돌려준다. 작업자는 그 값을 저장하고 선점 트랜잭션이 커밋됐음을 확인한 다음 외부 일을 시작한다. SQL 한 문장으로 썼더라도 클라이언트 라이브러리가 이미 트랜잭션을 열어두었다면 자동으로 커밋되는 것은 아니다.
이 방식도 완성형 큐 라이브러리는 아니다. 실행 가능한 시각, 실패 횟수, 재시도 지연, 취소, 관찰 지표 등은 별도 설계해야 한다. ORDER BY id를 썼다고 잠긴 앞쪽 작업까지 기다리는 엄격한 선입선출이 되는 것도 아니다. 또한 SKIP LOCKED가 건너뛰는 것은 행 잠금이며, 테이블 잠금 대기까지 모두 없애지는 않는다.
3. 임대가 만료돼도 옛 작업자는 살아 있을 수 있다
상태를 기록하면 다음 문제가 생긴다. A가 작업을 맡은 뒤 멈추면 누가 회수할까? 그래서 흔히 임대 만료 시각을 둔다. 다만 임대 만료는 DB가 정한 재선점 조건이지, A의 프로세스나 진행 중인 네트워크 요청을 강제로 종료하는 신호가 아니다.
예를 들어 A가 세대 1로 작업을 받았고, 임대가 끝난 뒤 B가 같은 행을 재선점하며 세대를 2로 올렸다고 하자. 늦게 돌아온 A가 작업 ID만 조건으로 삼아 완료를 기록하면 B의 현재 상태를 덮어쓸 수 있다. 이를 막으려면 완료 갱신에 status = 'running'과 자신이 선점할 때 받은 세대가 현재 세대와 같다는 조건을 함께 넣는다. 임대 갱신인 heartbeat에도 같은 소유권 검사를 적용한다.
그림 2. 새 세대가 저장된 뒤 구세대 완료를 거부하는 예시다. 임대 시간 조건은 커밋 시각의 절대 마감을 보장하지 않는다. 출처: www.postgresql.org/docs/18/transaction-iso.html.
로컬 사례에서는 새 세대가 저장된 뒤 옛 세대로 보낸 완료 갱신이 0행, 현재 세대로 보낸 완료 갱신이 1행이었다. 경쟁 갱신 때문에 실제 행 잠금을 기다리는 경우도 확인했다. Read Committed에서 PostgreSQL은 경쟁자가 바꾼 행 버전에 대해 WHERE 조건을 다시 평가한다. 단, UPDATE 0은 SQL 오류가 아니다. 프로그램이 반환 행 수를 확인하지 않으면 DB는 거부했는데 작업자는 성공했다고 보고하는 문제가 남는다.
시간 조건도 정확히 읽어야 한다. now()는 트랜잭션 시작 시각이고, clock_timestamp()는 호출 시점의 현재 시각이다. 임대 검사에 후자를 쓰더라도 “커밋 순간까지 임대가 유효함”을 보장하지는 않는다. 이번 로컬 반례에서는 유효한 임대로 시작한 완료 갱신이 다른 트랜잭션의 행 잠금을 기다린 뒤, 임대가 만료된 상태에서 1행을 갱신했다. 조건 평가 시점과 잠금 획득·커밋 시점은 다르다. 세대 비교의 핵심은 새 소유자가 기록된 뒤 옛 소유자의 덮어쓰기를 거부하는 것이며, 엄격한 시간 마감이 필요한 시스템은 잠금 획득 후 재검사와 시간 정책까지 별도로 설계해야 한다.
4. DB 완료와 외부 효과는 다른 경계다
세대 비교로 작업 행을 보호했다고 이메일 발송, 결제, 게시 API의 중복 효과까지 사라지는 것은 아니다. A가 이미 외부 요청을 보냈지만 응답을 받지 못했다면, B가 다시 보낸 요청도 외부 시스템에 도착할 수 있다. 마지막 DB 완료 기록을 거부해도 이미 일어난 외부 효과를 취소하지는 못한다.
여기서는 실행 세대와 업무 멱등 키를 구분해야 한다. 세대는 재선점할 때 바뀌어 옛 실행을 구별한다. 반면 같은 업무를 재시도하는 외부 요청의 멱등 키는 대상 API의 계약에 맞게 유지해야 한다. Amazon Builders’ Library는 호출자가 제공하는 동일 요청 식별자로 중복을 구분하는 방식을 설명하면서, 식별자 보존 기간과 같은 식별자에 다른 파라미터가 오는 경우의 정책도 함께 다룬다. 모든 API가 이를 지원한다고 가정하면 안 된다.
그림 3. 세대 번호는 실행을 구분하고 멱등 키는 같은 업무를 식별한다. 외부 효과의 보장은 대상 API의 계약에 달려 있다. 출처: aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs.
DB 상태 변경에 따라 이벤트를 발행한다면 transactional outbox도 검토할 수 있다. 업무 변경과 발행할 이벤트 행을 같은 DB 트랜잭션에 기록하고, 별도 전달자가 이벤트를 보낸다. 그러나 AWS 공식 가이드도 전달 과정의 중복 가능성과 소비자 멱등성을 명시한다. outbox는 이중 쓰기의 실패 간극을 줄이는 설계이지 모든 외부 동작을 정확히 한 번만 실행해주는 마법은 아니다. 이번 확인에서는 실제 결제나 게시 API를 호출하지 않았다.
5. 운영에서는 성공 로그보다 거부 경로를 먼저 확인한다
실무 점검은 다음 질문에서 시작할 수 있다. 아래 항목은 공식 문서의 기능 목록이 아니라, 문서와 로컬 확인을 바탕으로 정리한 설계 권고다.
- 선점할 때 선택과 상태 변경이 같은 트랜잭션에 있고, 외부 작업 전에 커밋을 확인하는가?
- 만료 작업을 회수할 때 새 세대를 저장하며, 옛 세대의 완료와 heartbeat를 모두 거부하는가?
RETURNING이 비었을 때 소유권 상실, 대상 없음, 이미 완료된 상태를 성공과 구분해 처리하는가?- 모든 행이 잠긴 상황을 큐가 비었다고 오해하지 않고, 적절한 대기·재조회 정책을 두었는가?
- 원격 응답이 사라졌을 때 안정적인 업무 키와 원격 상태 대조로 중복 영향을 제한할 수 있는가?
이번 검토는 공식 문서, 고정된 18.0 소스, PostgreSQL 16.2 로컬의 11개 조건·관찰 검사를 사용했다. 처리량 벤치마크나 분산 장애 전체를 검증한 결과는 아니다. 특히 작업별 세대 번호는 여러 작업이 공유하는 외부 자원의 전역 접근 제어를 대신하지 않는다. 임대 길이와 DB 시계 정책, 장시간 대기, 직렬화 실패 처리도 운영 환경에서 따로 확인해야 한다.
에이전트 수를 늘리기 전에 구분할 것은 세 가지다. 누가 작업을 가져왔는가, 누가 지금 완료를 기록할 수 있는가, 외부 효과가 이미 일어났는가. SKIP LOCKED는 첫 번째 질문의 일부를 해결한다. 나머지 질문의 답까지 잠금 하나에 맡기지 않는 것이 견고한 작업 큐의 출발점이다.
참고 자료
- PostgreSQL 18: SELECT의 잠금 절, 명시적 잠금, UPDATE와 RETURNING
- PostgreSQL 18: 트랜잭션 격리, 현재 시각 함수, 트랜잭션 튜토리얼
- PostgreSQL 18.0 고정 소스: SKIP LOCKED 테스트 정의, 커밋된 기대 출력, 행 잠금 실행 코드
- 로컬 실행 버전과 대조한 PostgreSQL 16 문서: SELECT, 트랜잭션 격리
- AWS Prescriptive Guidance: Transactional outbox pattern
- Amazon Builders’ Library, Malcolm Featonby: Making retries safe with idempotent APIs
잠금과 소비 상태를 구분하는 두 세션 확인 절차
2026-09-12 보강: 본문의 16.2 관찰을 독자가 점검할 수 있도록 최소 재현 순서를 적습니다. 오늘 새로 PostgreSQL을 실행한 결과가 아니라, 기존 로컬 기록과 공식 잠금 문서를 대조한 보강입니다. 운영 DB가 아닌 버릴 수 있는 별도 데이터베이스에서 실행하세요.
-- 준비: 한 세션에서 실행 후 커밋
CREATE TABLE vpl_queue_demo (id integer PRIMARY KEY);
INSERT INTO vpl_queue_demo VALUES (1), (2);
-- 이후 세션 A와 B를 별도로 연결하고 아래 순서대로 실행- A:
BEGIN; SELECT id FROM vpl_queue_demo ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1;— 기존 16.2 기록: 1. - B: 같은 BEGIN과 SELECT — 기존 기록: 2. 아직 B를 커밋하지 않습니다.
- A:
COMMIT; - B: BEGIN 없이 같은 SELECT를 다시 실행 — 기존 기록: 1. A의 잠금이 풀렸고 소비 상태를 저장하지 않았기 때문입니다.
- B:
ROLLBACK;후 준비 세션에서DROP TABLE vpl_queue_demo;로 예제 테이블만 정리합니다.
기존 로컬 기록은 PostgreSQL 16.2·pgserver 0.1.4·psycopg 3.3.5의 격리 환경에서 선택 ID [1, 2, 1], 모든 후보 잠금 시 SKIP LOCKED 0행/일반 조회 2행, 구세대 완료 0행/현재 세대 완료 1행을 담고 있습니다. 위 절차는 그중 선택만 하고 커밋하는 사례만 점검합니다. 세대 경쟁·임대 시각·외부 API 효과 전체를 재현하는 스크립트가 아닙니다.
공식 행 잠금 문서는 트랜잭션 종료 시 행 잠금이 해제됨을 설명합니다. Read Committed와 다른 격리 수준의 오류·재시도 조건은 별도로 검토하세요. 결과가 다르면 엔진 버전, 트랜잭션 자동 커밋 설정과 세션 실행 순서를 함께 기록해야 합니다.



댓글
댓글 쓰기