오래된 문서 저장을 거부하려면 편집기가 처음 불러온 리비전을 업데이트 조건에 포함한 다음 원자적으로 증가시키세요. SERIALIZABLE만으로는 문서를 이전 트랜잭션에서 불러온 경우 이 요구 사항을 표현할 수 없습니다.

이 튜토리얼은 오래된 대체 텍스트를 거부해야 하는 PostgreSQL 문서 편집기를 만드는 Mac 터미널 사용 개발자를 위한 것입니다. 예시용 가상 문서 행과 완전한 node-postgres 데모를 사용합니다. 요구 사항은 명확합니다. 제출된 초안의 기준이 된 리비전이 저장된 리비전과 일치할 때만 저장합니다.

SERIALIZABLE에서 오래된 대체 내용이 허용될 수 있는 이유는 무엇인가요?

PostgreSQL은 직렬화 가능한 실행을 커밋된 트랜잭션을 어떤 순서로든 하나씩 실행한 것과 동일한 효과를 내는 실행으로 정의합니다. 이 보장은 데이터베이스 트랜잭션과 그 작업에 관한 것입니다. PostgreSQL: 트랜잭션 격리.

다음은 설명을 위한 순서입니다.

  1. 편집기가 리비전 1의 문서 1을 불러온 다음 읽기 트랜잭션이 종료됩니다.
  2. 다른 저장 작업이 문서 텍스트를 대체하고 리비전 2를 커밋합니다.
  3. 원래 편집기가 리비전 1을 기반으로 한 대체 텍스트를 제출합니다.
  4. 새 직렬화 가능 트랜잭션이 ID만 사용해 문서 1을 업데이트합니다.

마지막 트랜잭션은 성공할 수 있습니다. 트랜잭션이 이미 순차적으로 실행되었기 때문입니다. id = 1만 조건으로 삼는 업데이트에는 제출된 텍스트가 리비전 1을 필요로 한다는 내용이 없습니다. 이는 문서화된 직렬화 가능성 정의의 적용 사례이지, 그 정의의 예외가 아닙니다.

겹치는 트랜잭션은 다릅니다. 동시 업데이트가 스냅샷과 충돌하거나 종속 관계 때문에 직렬 실행이 불가능해지면 PostgreSQL은 직렬화 가능 트랜잭션을 거부할 수 있습니다. 스냅샷은 첫 번째 트랜잭션 제어문이 아닌 문장에서 설정됩니다. 앞서 BEGIN을 실행하기만 해서는 이전 편집기 읽기를 보존하지 않습니다. PostgreSQL: 트랜잭션 격리.

따라서 진단을 위한 질문은 다음과 같습니다. 저장 작업은 편집기가 불러온 리비전을 어디에서 명시적으로 요구하나요? 아무 데도 없다면 격리 수준을 변경해도 빠진 조건은 추가되지 않습니다.

리비전 검사는 무엇을 보장해야 하나요?

불러온 각 초안을 편집 가능한 텍스트와 기준 리비전의 쌍으로 취급하세요. 저장 요청에는 문서 ID, 대체 텍스트, 변경되지 않은 기준 리비전을 담습니다. 데이터베이스 업데이트는 ID와 해당 리비전이 모두 일치하는 행을 찾아 텍스트를 대체하고 저장된 리비전을 같은 문장에서 증가시켜야 합니다.

예를 들어 WHERE id = $1 AND revision = $2 조건은 선행 요건을 표현하고, SET body = $3, revision = revision + 1는 상태 전이를 수행합니다. PostgreSQL은 조건을 충족하는 행을 업데이트하며, RETURNING는 실제로 업데이트된 행의 값을 제공합니다. 행이 0개 업데이트되어도 SQL 명령 자체는 성공하므로 애플리케이션에서 이를 명시적으로 해석해야 합니다. PostgreSQL: UPDATE.

이 패턴은 흔히 낙관적 동시성 제어 또는 비교 후 교환(compare-and-swap)이라고 합니다. 이 문서의 설계에서는 편집 가능한 문서 콘텐츠를 변경하는 모든 작업이 리비전을 증가시켜야 합니다. 리비전을 null이 아니게 유지하고, 초기화하지 말며, 이전 리비전 토큰을 가진 문서 ID를 재사용하지 마세요. 그렇지 않으면 오래된 초안이 여전히 일치한다고 잘못 판단할 수 있습니다.

ID만 사용해 별도로 읽고 업데이트하면 쓰기 작업 자체에 요구 사항이 포함되지 않습니다. 마찬가지로 저장 직전에 최신 리비전을 가져와 제출된 리비전을 그 값으로 바꾸면 요청의 의미가 달라집니다. 편집기가 기준으로 삼지 않은 버전을 대체할 수 있게 됩니다.

Mac에서 가상 예시 전체를 실행하세요

실행 중인 PostgreSQL 인스턴스, 기존의 임시 데이터베이스, 그리고 npm이 포함된 최신 Node.js 또는 지원되는 LTS 버전이 필요합니다. Terminal을 열고 작업 폴더를 만든 뒤, 문서화된 npm install pg 명령으로 드라이버를 설치하세요. node-postgres: 소개 및 설치.

mkdir revision-write-demo
cd revision-write-demo
npm install pg

아래 전체 프로그램을 해당 폴더의 revision-demo.mjs 파일로 저장하세요. 문서화된 ESM 호환 기본 가져오기를 사용합니다. Client와 Pool은 모두 환경 변수에서 PostgreSQL 연결 설정을 읽을 수 있습니다. 실행하기 전에 기존 임시 데이터베이스에 맞게 해당 설정을 구성하세요. node-postgres: ESM, node-postgres: 연결.

이 프로그램은 가상의 문서 두 개를 비교합니다. 문서 1에는 조건 없는 직렬화 가능 저장 작업을 두 번 수행합니다. 문서 2에는 동일한 원래 리비전을 사용해 리비전 검사 저장 작업을 두 번 수행합니다. 임시 테이블을 사용하므로 영구 문서 테이블과 분리된 데모가 됩니다. JavaScript 구문은 Node.js로 검사했으며, 데이터베이스 데모는 조사 환경에서 실행하지 않았습니다.

import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();

async function saveArticle(id, expectedRevision, body) {
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    let active = false;
    try {
      await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
      active = true;
      const result = await client.query(
        `UPDATE revision_demo_articles
         SET body = $3, revision = revision + 1
         WHERE id = $1 AND revision = $2
         RETURNING id, body, revision`,
        [id, expectedRevision, body]
      );
      if (result.rowCount === 0) {
        await client.query('ROLLBACK');
        active = false;
        return { status: 'stale-or-missing' };
      }
      await client.query('COMMIT');
      active = false;
      return { status: 'saved', article: result.rows[0] };
    } catch (error) {
      if (active) await client.query('ROLLBACK');
      if (error.code !== '40001' || attempt === 3) throw error;
    }
  }
}

await client.connect();
try {
  await client.query(`
    CREATE TEMP TABLE revision_demo_articles (
      id integer PRIMARY KEY,
      body text NOT NULL,
      revision integer NOT NULL CHECK (revision > 0)
    )
  `);
  await client.query(
    `INSERT INTO revision_demo_articles (id, body, revision)
     VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
  );
  const loaded = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  const expectedRevision = loaded.rows[1].revision;

  // Illustrative blind saves: each is a separate transaction.
  for (const body of ['First edit', 'Stale replacement']) {
    await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
    try {
      await client.query(
        `UPDATE revision_demo_articles
         SET body = $1, revision = revision + 1 WHERE id = 1`,
        [body]
      );
      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    }
  }

  const first = await saveArticle(2, expectedRevision, 'First edit');
  const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
  assert.equal(first.status, 'saved');
  assert.equal(first.article.revision, 2);
  assert.equal(stale.status, 'stale-or-missing');

  const final = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  assert.deepEqual(final.rows, [
    { id: 1, body: 'Stale replacement', revision: 3 },
    { id: 2, body: 'First edit', revision: 2 }
  ]);
  console.table(final.rows);
  console.log(stale.status);
} finally {
  await client.end();
}

기존 임시 데이터베이스 이름이 revision_demo라면 아래 명령을 실행하세요. 실제 데이터베이스 이름으로 바꾸고 로컬 인스턴스에 필요한 연결 환경 설정은 유지하세요. node-postgres: 연결.

PGDATABASE=revision_demo node revision-demo.mjs

예상되는 최종 값은 다음과 같습니다.

문서 본문 리비전 의미
1 Stale replacement 3 조건 없는 저장 두 번이 모두 커밋됨
2 First edit 2 두 번째 리비전 검사 저장이 거부됨

출력되는 최종 상태는 stale-or-missing이어야 합니다.

이 순서는 타이밍에 좌우되는 동시성을 피합니다. 핵심 경계를 직접 보여 줍니다. 모든 저장 트랜잭션이 직렬화 가능하더라도 오래된 대체 내용이 저장될 수 있습니다. 저장 작업이 유효한 직렬 순서로 실행되기 때문입니다.

node-postgres는 각 결과를 어떻게 처리해야 하나요?

이 프로그램은 드라이버의 문서화된 매개변수화 쿼리 API를 사용해 값을 SQL 텍스트와 따로 전달합니다. 또한 업데이트가 처리한 행 수를 알려 주는 rowCount을 확인합니다. 빈 rows 배열만 확인하는 것으로는 일반적으로 업데이트 행 수 검사를 대체할 수 없습니다. node-postgres: 쿼리, node-postgres: pg.Result.

함수를 수정할 때는 다음 판단 표를 사용하세요.

결과 애플리케이션 조치
행 1개가 업데이트되고 커밋 성공 저장을 수락하고 반환된 리비전을 유지
업데이트된 행 없음 저장이 오래되었거나 문서가 없다고 판단해 거부하고 제출된 초안을 보존
SQLSTATE 40001 롤백하고 제한된 정책에 따라 전체 트랜잭션을 재시도
그 밖의 오류 또는 재시도 한도 도달 실패를 알리고 저장 성공으로 보고하지 않음

행이 0개인 결과만으로는 문서가 삭제되었는지 리비전이 변경되었는지 알 수 없습니다. 이 데모에서는 두 결과를 의도적으로 묶어 처리합니다. 인터페이스에서 더 자세한 정보가 필요하다면 그 후 현재 문서를 불러와 해당 결과를 나중에 관찰한 상태로 제시하세요. 실패한 업데이트 시점의 정확한 상태를 증명한다고 설명하지 마세요.

직렬화 실패의 SQLSTATE는 40001입니다. PostgreSQL에서는 문장과 값의 실행 여부를 결정하는 로직을 포함해 전체 트랜잭션을 재시도해야 합니다. 여러 번 시도해야 할 수 있으며 결국 완료된다는 보장은 없습니다. 이 예시는 애플리케이션 정책의 예로 세 번 시도합니다. PostgreSQL: 직렬화 실패 처리.

이러한 재시도 동안 expectedRevision은 변경하지 마세요. 이 값은 제출된 초안의 선행 요건을 나타냅니다. 다른 저장 작업이 리비전을 증가시켰다면 새 트랜잭션에서 오래된 토큰을 사용한 업데이트는 행을 0개 수정하여 거부할 수 있습니다. 토큰을 최신 리비전으로 자동 교체하면 오래된 쓰기 방지 기능이 사라집니다.

함수는 COMMIT가 완료된 후에만 성공을 반환합니다. node-postgres의 요구 사항에 따라 모든 트랜잭션 문장에서 동일한 Client를 사용합니다. 이를 풀에 맞게 수정할 때는 pool.connect()로 클라이언트를 가져와 트랜잭션 전체에서 해당 클라이언트를 사용한 다음 해제하세요. node-postgres: 트랜잭션, node-postgres: pg.Pool.

SERIALIZABLE 또는 SELECT FOR UPDATE가 여전히 필요한가요?

범위가 제한된 단일 행 조건부 업데이트에는 Read Committed도 리비전 선행 요건을 적용할 수 있습니다. 커밋되는 동시 업데이트를 기다린 후 PostgreSQL이 업데이트된 행을 기준으로 조건을 다시 평가하기 때문입니다. 그렇다고 Read Committed가 복잡한 여러 행 규칙까지 보장하는 것은 아닙니다. PostgreSQL: 트랜잭션 격리.

주변 트랜잭션이 보장을 위해 더 넓은 범위의 읽기와 쓰기에 의존한다면 SERIALIZABLE을 유지하세요. 리비전 조건은 편집기의 선행 요건을 표현하고, 격리 수준은 트랜잭션 데이터베이스 작업 간 상호작용을 제어합니다. 실제 작업에 따라 둘 다 선택하세요.

SELECT FOR UPDATE는 행을 살펴보고 여러 종속 작업을 수행해야 하는 트랜잭션에 사용할 수 있습니다. 이 행 잠금은 트랜잭션이 끝날 때까지 충돌하는 쓰기 작업을 막습니다. Repeatable Read 또는 Serializable에서는 트랜잭션 스냅샷 이후 변경된 행을 잠그려 할 때 오류가 발생할 수 있습니다. PostgreSQL: 명시적 잠금.

저장 요청이 들어온 시점에만 잠금을 획득해도 제출된 기준 리비전을 비교할 책임은 애플리케이션에 남습니다. 현재 행을 잠그는 것만으로는 초안이 어떤 이전 리비전에서 만들어졌는지 데이터베이스가 알 수 없습니다. 여기서 설명한 한 행 대체 방식에서는 조건부 업데이트가 비교와 변경을 이미 한데 묶습니다.

이 패턴을 통합하기 전에 콘텐츠를 쓰는 경로를 모두 확인하고, 거부 시 편집기의 초안을 보존하며, 저장 성공이 확인된 후에만 편집기의 기준 리비전을 업데이트하세요. 오래된 저장 요청이 거부되면 현재 문서와 보존된 초안을 함께 제시해 독자가 텍스트를 조정하고 실제로 검토한 리비전을 기준으로 새 저장 요청을 제출할 수 있게 하세요.

출처