선택한 행의 상태도 ready에서 processing로 바꾸는 쿼리 안에서 FOR UPDATE SKIP LOCKED를 사용하세요. 이렇게 하면 동시에 실행되는 작업자들이 서로 다른 합성 아티클 행을 선점하고, 저장된 상태 덕분에 잠금이 끝난 뒤 다른 작업자가 같은 행을 다시 선점하지 못합니다.
이 안내는 정상적인 PostgreSQL 연결을 사용하는 Mac의 Terminal 개발자를 위한 것입니다. 합성 아티클 세 개를 만들고, 데이터베이스 세션 두 개에서 동시 선점을 확인한 다음, node-postgres 전체 예제를 실행합니다.
두 작업자가 같은 아티클을 선택할 수 있는 이유는 무엇인가요?
일반적인 SELECT는 조회 결과를 예약하지 않습니다. PostgreSQL의 기본 Read Committed 격리 수준에서는 한 작업자가 변경하기 전에 두 작업자가 동일하게 커밋된 ready 행을 읽을 수 있습니다. 이 일반 조회와 이후 업데이트를 트랜잭션으로 묶는 것만으로는 선택이 배타적으로 이루어지지 않습니다. PostgreSQL은 트랜잭션 격리 문서에서 스냅샷 동작을 설명합니다.
선점에는 두 가지 보호가 필요합니다. 아티클을 선택할 때의 행 잠금과 해당 잠금을 해제하기 전의 상태 변경입니다. PostgreSQL은 트랜잭션이 끝나면 행 잠금을 해제합니다. 따라서 잠금 조회만 실행하고 이후에 처리하면, 그 조회의 트랜잭션이 끝난 뒤 해당 행을 다시 선점할 수 있게 됩니다. 명시적 잠금을 참조하세요.
모든 작업자에서 동일한 선점 프로토콜을 사용하세요. ready 아티클을 읽고 성공적으로 선점하지 않은 채 처리를 시작하는 애플리케이션 경로가 있으면 작업이 중복될 수 있습니다.
간단한 합성 아티클 대기열 만들기
사전 요구 사항은 실행 중인 PostgreSQL 데이터베이스, Terminal에서 사용할 수 있는 psql 명령어, 기존 연결 설정입니다. JavaScript 섹션을 진행하려면 현재 지원되는 Node.js 릴리스와 npm도 설치되어 있어야 합니다. 이 문서는 작동하는 데이터베이스 연결이 준비된 상태에서 시작합니다. 설치 방법은 기존 Mac 설정에 따라 다릅니다.
SKIP LOCKED 기능은 PostgreSQL 9.5에서 처음 제공되었습니다. 이는 구문을 사용할 수 있는 최소 버전이지, 지원되지 않는 릴리스를 권장한다는 뜻은 아닙니다. 지원되는 PostgreSQL 버전을 사용하세요. 가져온 최신 문서에서는 PostgreSQL 18을 현재 버전으로 안내합니다. 9.5 릴리스 노트와 최신 SELECT 문서를 참조하세요.
Terminal을 열고 평소 연결 설정으로 psql를 실행하세요. psql 문서에 설명된 것처럼 SQL을 실행하고 결과를 확인할 수 있는 대화형 프롬프트가 표시됩니다. article_claim_demo 테이블이 아직 없는 임시 데이터베이스에서 다음 설정을 한 번 실행하세요. 기존 테이블을 대체하지 않도록 되어 있습니다.
SHOW server_version;
SHOW transaction_isolation;
CREATE TABLE article_claim_demo (
id integer PRIMARY KEY,
title text NOT NULL,
state text NOT NULL DEFAULT 'ready'
CHECK (state IN ('ready', 'processing', 'done')),
rendered_title text
);
INSERT INTO article_claim_demo (id, title) VALUES
(1, 'Paper shapes'),
(2, 'Desk arrangements'),
(3, 'Color combinations');
예제는 Read Committed 격리 수준과 추가 트리거가 없는 간단한 테이블을 가정합니다. 두 세션 테스트를 마칠 때까지 예제 행 세 개를 그대로 유지하세요.
단일 SQL 문으로 아티클 하나 선점하기
작업자가 아티클 하나를 필요로 할 때마다 이 문을 실행하세요. 후보 쿼리는 사용 가능한 행을 잠그고, 업데이트는 선점 상태를 기록한 뒤 입력 필드를 반환합니다. PostgreSQL의 UPDATE 문서는 이 CTE 및 업데이트 패턴을 설명하고, RETURNING가 실제 업데이트된 행을 보고함을 확인해 줍니다.
WITH candidate AS (
SELECT id
FROM article_claim_demo
WHERE state = 'ready'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE article_claim_demo AS a
SET state = 'processing'
FROM candidate AS c
WHERE a.id = c.id
AND a.state = 'ready'
RETURNING a.id, a.title;
잠금은 후보 CTE 안에 있어야 합니다. SKIP LOCKED를 사용하면 다른 작업자는 즉시 잠글 수 없는 후보를 건너뛸 수 있습니다. LIMIT 1은 선점 수를 하나로 제한하고, 기본 키 조인은 업데이트 대상 하나를 지정합니다. PostgreSQL 문서는 SELECT: 잠금 절에서 WITH 쿼리 내부의 잠금과 대기열에서 행을 건너뛰는 용도를 명시적으로 설명합니다.
명시적인 BEGIN이 없으면 PostgreSQL은 해당 문을 자체 트랜잭션으로 실행합니다. 문이 성공하면 이후 처리가 시작되기 전에 선택에 따른 업데이트와 상태 변경이 모두 커밋됩니다. 커밋 후 행 잠금은 해제되지만, state = 'ready' 조건이 해당 아티클을 제외합니다. 트랜잭션을 참조하세요.
선점 문에서 반환된 아티클만 처리하세요. 결과가 비어 있으면 이번 시도에서 아티클을 얻지 못했다는 뜻이며, 작업이 모두 끝났다는 의미는 아닙니다. 다른 작업자들이 현재 선점 가능한 행을 잠그고 있을 수 있습니다. 또한 SKIP LOCKED는 행 잠금 대기를 건너뛰지만 테이블 수준 잠금은 여전히 대기를 유발할 수 있습니다. 이 제한 사항은 SELECT 문서에 나와 있습니다.
Terminal 세션 두 개에서 선점 동작 확인하기
같은 데이터베이스에 연결된 Terminal 창 두 개에서 psql을 사용하세요. 처음에는 원래의 ready 행 세 개가 있어야 합니다. 다음의 통제된 순서에서는 각 트랜잭션을 열린 상태로 유지해 겹치는 동작을 직접 관찰할 수 있습니다.
- 세션 A에서
BEGIN ISOLATION LEVEL READ COMMITTED;를 실행한 다음 위의 전체 선점 문을 붙여 넣으세요. 트랜잭션을 열린 상태로 둡니다. 샘플 데이터가 변경되지 않았다면 반환되는 ID는1이어야 합니다. - 세션 B에서
BEGIN ISOLATION LEVEL READ COMMITTED;를 실행한 다음 같은 선점 문을 붙여 넣으세요. A가 여전히 ID1를 잠근 상태에서 ID2가 반환되어야 합니다. - 세션 B에서
ROLLBACK;을 실행한 다음 세션 A에서도ROLLBACK;을 실행하세요. - 어느 세션에서든 아래 조회를 실행하세요. 두 선점이 롤백되었으므로 아티클 세 개 모두 다시
ready상태여야 합니다.
SELECT id, title, state, rendered_title
FROM article_claim_demo
ORDER BY id;
이는 데이터베이스에서 실제로 실행한 테스트 결과가 아니라, 문서화된 잠금 및 롤백 동작을 바탕으로 한 예상 결과입니다. 롤백하면 상태 업데이트가 취소되고 잠금이 해제됩니다. 대신 COMMIT를 사용하면 해당 아티클은 processing 상태로 남으며 이후 선점에서 선택되지 않습니다. 명시적 잠금과 트랜잭션을 참조하세요.
이 테스트에서는 유용한 두 가지 동작을 확인할 수 있습니다. 세션 B가 현재 잠긴 아티클을 건너뛰고 다음 아티클로 이동하는 것과, 커밋된 상태 변경으로 아티클이 향후 선점에서 제외되는 것입니다. 기존 작업자에 적용할 때 이 두 속성을 모두 테스트하세요.
node-postgres로 작업자 세 개 실행하기
테스트 트랜잭션 두 개를 롤백한 다음 Terminal에서 새 로컬 프로젝트 디렉터리를 만드세요. 문서에 나온 node-postgres 설치 명령어로 패키지를 설치합니다.
mkdir article-claim-demo
cd article-claim-demo
npm init -y
npm install pg
다음 전체 프로그램을 workers.mjs으로 저장하세요. psql와 동일한 데이터베이스 연결 환경 변수를 사용합니다. node-postgres는 연결 문서에서 공유 연결 변수를 설명합니다. 각 작업자는 아티클 하나를 선점하고, 합성 제목을 대문자로 변환한 뒤 결과를 저장합니다.
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({ max: 3 });
const claimSQL = `
WITH candidate AS (
SELECT id
FROM article_claim_demo
WHERE state = 'ready'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE article_claim_demo AS a
SET state = 'processing'
FROM candidate AS c
WHERE a.id = c.id AND a.state = 'ready'
RETURNING a.id, a.title;
`;
async function worker(label) {
const claimed = await pool.query(claimSQL);
if (claimed.rows.length === 0) {
console.log(`${label}: no row available now`);
return;
}
const article = claimed.rows[0];
const renderedTitle = article.title.toUpperCase();
const completed = await pool.query(
`UPDATE article_claim_demo
SET rendered_title = $2, state = 'done'
WHERE id = $1 AND state = 'processing'
RETURNING id`,
[article.id, renderedTitle]
);
if (completed.rowCount !== 1) {
throw new Error(`${label}: completion did not update one row`);
}
console.log(`${label}: completed article ${article.id}`);
}
try {
const results = await Promise.allSettled([
worker('A'),
worker('B'),
worker('C')
]);
for (const result of results) {
if (result.status === 'rejected') {
console.error(result.reason);
process.exitCode = 1;
}
}
} finally {
await pool.end();
}
node workers.mjs를 실행한 다음 psql에서 검사 쿼리를 다시 실행하세요. 준비 상태의 아티클 세 개가 변경되지 않았고 다른 작업이 없다면 상태 세 개 모두 done이 되고, rendered_title의 값은 대문자로 바뀌어야 합니다. 작업자 레이블과 콘솔 출력 순서가 아티클 순서와 일치할 필요는 없습니다.
각 선점은 완결된 단일 문이고 각 완료 처리는 별도 문이므로 여기서는 pool.query을 사용하는 것이 적절합니다. 풀은 연결을 대여하고 반납하는 작업을 관리합니다. 모든 시도가 끝나면 pool.end()가 풀을 닫습니다. 풀링을 참조하세요.
완료 쿼리는 쿼리 문서에 따라 $1 및 $2를 사용해 값을 별도로 전달합니다. 프로그램은 pg.Result 문서에 따라 반환된 rows 배열에서 선점 결과가 비었는지 확인하고, 완료 처리에서 업데이트된 행의 수는 rowCount로 확인합니다.
준비 과정에서 JavaScript 구문 검사를 통과했습니다. 조사 환경에서는 PostgreSQL 실행 파일을 사용할 수 없어 문서를 기준으로 SQL과 동시 데이터베이스 동작을 검토했지만, 여기서 실제로 실행하지는 않았습니다.
올바른 트랜잭션 경계를 정하고 미완료 작업 처리하기
이 예제는 제목을 변환하기 전에 짧은 선점 작업을 커밋합니다. 이렇게 하면 잠금 유지 시간이 짧아지지만, 의도적인 복구 경계가 생깁니다. 작업자가 선점한 뒤 완료 전에 중단되면 아티클은 processing 상태로 남습니다. 프로그램을 다시 실행해도 이 아티클은 다시 선점되지 않습니다.
이 데스크톱 예제에서는 모든 작업자를 중지하고 processing 상태인 아티클을 확인한 다음, 해당 아티클을 다시 선점 가능하게 만들지 신중히 결정하세요. 이전 작업자가 여전히 완료 처리를 할 수 있는 동안 자동으로 상태를 재설정하지 마세요. 이 예제에는 이전 시도와 이후 시도를 구분할 선점 세대 필드가 없습니다. 중복 선점을 방지하는 것만으로 자동 복구나 정확히 한 번 처리하는 동작이 보장되지는 않습니다.
예제를 적용할 때 다음 판단 기준을 사용하세요.
| 작업 | 트랜잭션 접근 방식 | 결과 |
|---|---|---|
| 아티클을 선점한 뒤 애플리케이션에서 오래 처리하기 | 단일 문으로 선점한 뒤 먼저 커밋 | processing 상태를 명확히 유지하고 복구는 별도로 설계 |
| PostgreSQL 안에서 작은 변환 작업 완료하기 | 한 트랜잭션 안에서 선점 및 완료 처리 | 데이터베이스 변경 사항을 함께 커밋하거나 롤백 가능 |
| Node.js에서 여러 SQL 문을 하나의 트랜잭션으로 실행하기 | 클라이언트 하나를 대여해 전체 과정에서 사용 | BEGIN, 문 실행, COMMIT 또는 ROLLBACK이 진행되는 동안 같은 연결 유지 |
마지막 방식의 경우 node-postgres에서는 트랜잭션의 모든 문이 동일하게 대여한 클라이언트를 사용해야 합니다. 별도의 pool.query 호출은 서로 다른 연결을 사용할 수 있으므로 해당 트랜잭션을 구성할 수 없습니다. 완료하거나 롤백한 뒤 클라이언트를 반납하세요. 트랜잭션과 풀링을 참조하세요.
설명한 격리 수준 가정을 명시적으로 유지하세요. Repeatable Read 및 Serializable에서는 트랜잭션이 시작된 이후 잠글 행이 변경된 경우 오류가 발생할 수 있습니다. 이런 격리 수준을 사용하는 애플리케이션은 트랜잭션 재시도 처리가 필요합니다. PostgreSQL은 명시적 잠금에서 이 제한 사항을 설명합니다.
이 합성 아티클 작업의 핵심 순서는 명확합니다. 선점 가능한 행을 선택하고 잠근 뒤, 같은 문에서 해당 상태를 변경하고, 선점에 성공한 후 반환된 아티클만 처리하세요. 세션 두 개에서 겹치는 동작을 확인한 다음, 작업자가 완료를 기록하지 않는 아티클을 애플리케이션에서 어떻게 처리할지 결정하세요.
출처
- SELECT — PostgreSQL Global Development Group.
- UPDATE — PostgreSQL Global Development Group.
- 13.3. 명시적 잠금 — PostgreSQL Global Development Group.
- 13.2. 트랜잭션 격리 — PostgreSQL Global Development Group.
- 3.4. 트랜잭션 — PostgreSQL Global Development Group.
- 릴리스 9.5 — PostgreSQL Global Development Group.
- psql — PostgreSQL Global Development Group.
- node-postgres: 소개 및 설치 — node-postgres.
- 연결 — node-postgres.
- 풀링 — node-postgres.
- 트랜잭션 — node-postgres.
- 쿼리 — node-postgres.
- pg.Result — node-postgres.