아티클 목록과 개수를 일관되게 유지하려면 동일한 node-postgres 클라이언트에서 두 읽기를 하나의 REPEATABLE READ READ ONLY 트랜잭션으로 실행하세요. 이 튜토리얼에서는 그 패턴과 Mac에서 실행할 수 있는 두 세션 확인 방법을 보여줍니다.

아티클 목록과 개수가 서로 다를 수 있는 이유는 무엇인가요?

데스크톱 스크립트가 craft 토픽에 게시된 아티클을 가져온 다음 일치하는 아티클의 개수를 센다고 가정해 보세요. 두 읽기 사이에 다른 세션이 아티클의 토픽을 변경하면, 반환된 목록에는 아티클이 두 개 있지만 개수는 하나라고 나올 수 있습니다.

PostgreSQL의 기본 격리 수준인 READ COMMITTED에서는 각 일반 SELECT이 해당 문장이 시작될 때의 스냅샷을 사용합니다. 따라서 하나의 트랜잭션 안에서도 연속된 읽기가 서로 다른 커밋 데이터를 볼 수 있습니다. PostgreSQL: Transaction Isolation.

이 작업에서 원하는 결과는 아티클 행을 일관된 하나의 관점에서 보는 것입니다. 이후의 수정 사항은 다음 실행에서 반영될 수 있습니다. PostgreSQL의 REPEATABLE READ는 트랜잭션의 첫 번째 트랜잭션 제어 이외 문장이 설정한 스냅샷을 연속된 읽기에서 유지합니다. BEGIN를 실행하는 것만으로는 해당 스냅샷이 설정되지 않습니다. PostgreSQL: Transaction Isolation.

구현에는 두 가지 요구 사항이 있습니다. 읽기 전에 격리 수준을 선택하고, 관련된 모든 쿼리를 같은 클라이언트에서 실행해야 합니다. node-postgres는 트랜잭션에 단일 클라이언트를 사용해야 한다고 명시하며, 해당 문장에 pool.query()을 사용하지 말라고 경고합니다. node-postgres: Transactions.

Mac에서 간단한 아티클 테스트 데이터를 준비하기

실행 중인 로컬 PostgreSQL 서버, psql 명령, 그리고 npm이 포함된 지원 대상 Node.js 설치 환경을 사용하세요. 아래 명령은 기존 로컬 연결에서 postgres 데이터베이스를 사용할 수 있다고 가정합니다. 작업 중인 데이터베이스의 이름이 다르면 모든 명령에서 일관되게 바꾸세요. PostgreSQL 설치와 연결 설정은 사전 조건입니다.

Terminal을 열고 프로젝트 폴더를 만드세요. 문서화된 node-postgres 패키지는 pg입니다. node-postgres: Welcome.

mkdir article-snapshot-demo
cd article-snapshot-demo
npm init -y
npm install pg
export PGDATABASE=postgres
psql -d postgres

psql 프롬프트에서 다음 설정을 한 번 실행하세요. 합성 아티클 행만 저장하는 전용 테이블을 만듭니다. 명시적 식별자를 사용하므로 이후 실습도 쉽게 따라갈 수 있습니다.

CREATE TABLE public.article_snapshot_demo (
    id integer PRIMARY KEY,
    title text NOT NULL,
    topic text NOT NULL,
    published boolean NOT NULL
);

INSERT INTO public.article_snapshot_demo
    (id, title, topic, published)
VALUES
    (1, 'Paper Shapes', 'craft', true),
    (2, 'Color Studies', 'craft', true),
    (3, 'Desk Layouts', 'design', true);

테이블이 이미 있으면 자동으로 삭제하거나 교체하지 말고, 중단한 뒤 이전 테스트 데이터를 확인하세요. 예제는 정확히 이 세 개의 초기 행을 가정합니다. Node.js 스크립트를 실행하기 전에 psql을 \q 상태로 두세요.

하나의 대여한 클라이언트로 목록과 개수 읽기

다음 전체 프로그램을 프로젝트 폴더의 read-articles.mjs 파일로 저장하세요. new Pool()은 위에서 내보낸 PGDATABASE 값을 비롯해 문서화된 PostgreSQL 연결 환경 변수를 사용합니다. node-postgres: Connecting.

프로그램은 클라이언트 하나를 가져오고, 트랜잭션 특성을 지정한 상태로 트랜잭션을 시작한 다음 두 읽기를 수행하고, 조합한 결과를 반환하기 전에 커밋합니다. PostgreSQL은 BEGIN에서 이러한 특성을 직접 지원하며, 트랜잭션은 COMMIT 또는 ROLLBACK로 끝납니다. PostgreSQL: BEGIN.

import pg from 'pg';
const { Pool } = pg;

const pool = new Pool({ connectionTimeoutMillis: 5000 });
pool.on('error', (error) => {
  console.error('Idle database client error:', error.message);
  process.exitCode = 1;
});

async function readArticles(topic) {
  const client = await pool.connect();
  let began = false;
  let discardClient = false;

  try {
    await client.query(
      'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
    );
    began = true;

    const articles = await client.query(
      `SELECT id, title, topic
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true
       ORDER BY id`,
      [topic]
    );

    const count = await client.query(
      `SELECT count(*)::text AS total
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true`,
      [topic]
    );

    await client.query('COMMIT');
    began = false;

    return {
      topic,
      articles: articles.rows,
      total: count.rows[0].total
    };
  } catch (error) {
    if (began) {
      try {
        await client.query('ROLLBACK');
      } catch (rollbackError) {
        discardClient = true;
        throw new AggregateError(
          [error, rollbackError],
          'Article read failed; rollback also failed'
        );
      }
    } else {
      discardClient = true;
    }
    throw error;
  } finally {
    client.release(discardClient);
  }
}

try {
  const result = await readArticles('craft');
  console.log(JSON.stringify(result, null, 2));
} catch (error) {
  console.error(error);
  process.exitCode = 1;
} finally {
  await pool.end();
}

같은 Terminal 세션에서 프로그램을 실행하세요.

node read-articles.mjs

테스트 데이터를 변경하지 않았다면 예상 결과에는 아티클 1과 2이 포함되고, total 값은 문자열 "2"입니다. 개수는 텍스트로 명시적으로 반환되므로 이 예제에서는 JavaScript 숫자로 변환할 필요가 없습니다. 두 쿼리 모두 같은 토픽과 게시 필터를 사용합니다. 격리 수준으로 서로 다른 조건자를 바로잡을 수는 없습니다.

정리 작업도 실용적인 패턴의 일부입니다. 읽기에 실패하면 롤백을 수행하고, 롤백에도 실패하면 클라이언트를 폐기합니다. node-postgres는 대여한 클라이언트를 폐기할 때 client.release(true)을, 스크립트의 풀을 종료할 때 pool.end()을 사용하도록 문서화합니다. node-postgres: Pool API.

이 코드는 독립 실행형 스크립트이므로 한 번 실행한 후 풀을 종료합니다. 기존 애플리케이션에서는 애플리케이션의 풀 수명 주기를 유지하면서 트랜잭션 함수를 그 안에 배치하세요. 이 읽기에 참여하는 모든 도우미 함수에는 대여한 client을 전달해야 합니다. 몰래 pool.query()를 호출하는 도우미 함수는 그 경계를 깨뜨립니다. node-postgres: Transactions.

Terminal 세션 두 개로 스냅샷 확인하기

빠른 스크립트는 다른 세션이 무언가를 변경하기 전에 끝날 수 있습니다. 관련 시점을 명확히 드러내려면 아래 순서를 의도적으로 지켜 실습하세요. 이 단계들은 이미 만든 테이블에서 세션별로 실행하는 SQL이며, 각각 독립적인 설정 프로그램은 아닙니다. 예상 데이터베이스 결과는 문서에 설명된 격리 동작을 따른 것이며, 실제 실행한 PostgreSQL 테스트 결과로 제시하는 것은 아닙니다.

Terminal 창 두 개를 열고 각각에서 psql -d postgres를 실행하세요. 읽기 세션을 시작하기 전에 세션 B에서 합성 테스트 데이터의 토픽을 복원하세요. 이 문장이 완료될 때까지 기다리세요.

-- Session B: restore the fixture before beginning the exercise.
UPDATE public.article_snapshot_demo
SET topic = 'craft'
WHERE id = 2;

세션 A에서 읽기 트랜잭션을 시작하고 목록을 가져오세요. 쿼리가 완료된 뒤 잠시 멈추세요.

-- Session A: run these statements, then pause.
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

SELECT id, title
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true
ORDER BY id;

목록에는 Paper Shapes와 Color Studies가 포함되어야 합니다. 이제 세션 B로 전환해 다음 독립 실행 문장으로 토픽을 변경하고 커밋하세요. 명시적 트랜잭션 블록 밖에서는 PostgreSQL이 성공한 문장을 자동으로 커밋합니다. PostgreSQL: BEGIN.

-- Session B: run while session A remains open.
UPDATE public.article_snapshot_demo
SET topic = 'design'
WHERE id = 2;

세션 A로 돌아가 관련 읽기를 마무리하세요.

-- Session A: finish the original reader transaction.
SELECT count(*)::text AS total
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true;

COMMIT;

예상 개수는 2이며 세션 A의 앞선 목록과 일치합니다. 이후 Node.js 스크립트를 새로 실행하면 Paper Shapes만 반환되고 총 개수는 "1"이어야 합니다. 이전 결과와 새 결과는 각각 일관된 관점을 나타냅니다.

통제된 비교를 위해 세션 B의 초기 복원 단계를 포함해 실습 전체를 반복하되, 세션 A에서는 BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY;로 시작하세요. 동일한 대기와 업데이트 순서를 유지하세요. 예상되는 첫 번째 목록에는 아티클 두 개가 포함되지만, 이후 개수는 1입니다. 이 비교에서는 트랜잭션 설정만 변경 조건으로 작용합니다.

관련 읽기에 적합한 경계 선택하기

예제를 적용할 때 다음 판단 기준을 사용하세요. 먼저 어떤 출력이 서로 일치해야 하는지 결정한 다음, 그 결과를 제공하는 가장 작은 데이터베이스 작업을 선택하세요.

읽기 작업 적합한 접근 방식 유지해야 할 조건
일반 쿼리 하나로 아티클 목록 하나 반환 READ COMMITTED로 쿼리 하나 실행 가능 이후 쿼리에서 같은 스냅샷을 공유할 필요가 없습니다.
목록과 별도 쿼리로 가져온 개수 반환 하나의 REPEATABLE READ READ ONLY 트랜잭션 클라이언트 하나를 사용하고 필터를 일치시키세요.
일관된 결과를 위해 여러 배치를 가져오기 같은 트랜잭션 안에서 읽기 유지 새 트랜잭션은 새로운 관점을 설정합니다.
별도의 데이터베이스 세션에서 읽기를 조정하기 스냅샷 명시적 내보내기 및 가져오기 별도 트랜잭션은 자동으로 스냅샷을 공유하지 않습니다.

여러 세션의 경우 PostgreSQL은 pg_export_snapshot()과 SET TRANSACTION SNAPSHOT을 제공합니다. 내보내는 세션은 다른 트랜잭션이 스냅샷을 가져올 때까지 열린 상태로 유지해야 합니다. 가져오는 트랜잭션은 REPEATABLE READ 또는 SERIALIZABLE을 사용해야 하며, 첫 쿼리나 데이터 수정 문장을 실행하기 전에 스냅샷을 가져와야 합니다. Serializable 가져오기에 관한 추가 제한 사항은 명령어 참조 문서에 나와 있습니다. PostgreSQL: Snapshot Synchronization Functions, PostgreSQL: SET TRANSACTION.

이 데스크톱의 목록 및 개수 작업에서는 클라이언트 하나를 유지하면 이러한 추가 조정이 필요하지 않습니다. 사람이 다음 작업을 선택하기를 기다리기 전에 데이터베이스 읽기를 마치고 트랜잭션을 닫으세요.

이 격리 수준으로 해결되지 않는 문제는 무엇인가요?

PostgreSQL의 REPEATABLE READ는 팬텀 읽기를 방지하지만 직렬화 이상 현상은 여전히 발생할 수 있습니다. 따라서 안정적인 스냅샷이 있다고 해서 동시 워크플로가 하나씩 실행된 것처럼 동작했다는 뜻은 아닙니다. 또한 시퀀스 값은 고정되지 않습니다. 시퀀스는 트랜잭션과 관련해 특별한 동작을 합니다. PostgreSQL: Transaction Isolation.

READ ONLY는 특정 임시 테이블 작업과 같은 문서화된 예외를 제외하고 일반 테이블의 변경을 허용하지 않습니다. 이 설정만으로 안정적인 스냅샷이 선택되지는 않습니다. 읽기 전에 두 특성을 모두 설정하세요. 트랜잭션의 첫 쿼리나 데이터 수정 문장이 실행된 뒤에는 PostgreSQL에서 격리 수준을 변경할 수 없습니다. PostgreSQL: SET TRANSACTION.

보고서에 더 강한 직렬화 가능 의미 체계가 필요하면 PostgreSQL은 SERIALIZABLE READ ONLY DEFERRABLE을 지원합니다. 스냅샷을 가져오는 동안 대기할 수 있으며, 이후에는 직렬화 실패로 인한 취소 없이 실행됩니다. 이러한 절충은 여기서 설명한 일반적인 아티클 읽기 요구 사항과는 별개의 문제입니다. PostgreSQL: SET TRANSACTION.

아티클 목록과 개수에서는 구현 경계를 명확히 유지하세요. 클라이언트 하나를 가져오고, 필요한 특성을 지정해 시작한 뒤 관련 결과를 모두 읽고 커밋한 다음 클라이언트를 반환합니다. 같은 필터를 재사용하고 트랜잭션이 완료된 후에만 조합한 결과를 공개하세요. 그러면 다음 데스크톱 작업에서 사용할 아티클 데이터를 일관된 하나의 세트로 얻을 수 있습니다.

출처