node-postgres 트랜잭션을 하나로 유지하려면 pool.connect()로 클라이언트 하나를 가져오고, 모든 문장을 해당 클라이언트로 실행한 다음, 커밋이나 롤백 후 클라이언트를 해제하세요. 헬퍼에도 해당 클라이언트를 전달하세요. 공식 트랜잭션 가이드에서 이 패턴을 요구합니다.

이 튜토리얼은 Mac에서 Terminal을 사용하는 개발자가 여러 article 행의 변경 사항을 모두 함께 성공시키거나 실패시키려는 경우를 위한 것입니다. 합성 article 행을 사용한 완전한 프로그램, 의도적인 롤백 테스트, 헬퍼 함수 안에 숨겨진 쿼리를 진단하는 순서를 포함합니다.

pool.query()는 왜 트랜잭션 범위를 깨뜨리나요?

풀은 연결을 관리하지만, 모든 쿼리를 하나의 트랜잭션에 포함시키지는 않습니다. 각 pool.query() 호출은 내부적으로 사용 가능한 클라이언트를 가져옵니다. 따라서 연속된 호출이 같은 연결을 사용한다고 보장할 수 없습니다. node-postgres Pool API는 트랜잭션에서 이 메서드를 사용하지 말라고 명시적으로 경고합니다.

예를 들어, BEGIN를 풀을 통해 보내고 이어서 INSERT를 풀을 통해 보내더라도 해당 INSERT가 시작한 트랜잭션에 포함된다고 보장할 수 없습니다. PostgreSQL은 일반적으로 명시적인 트랜잭션 블록 밖에서 실행되는 문장을 각각 별도의 트랜잭션으로 처리합니다. 다른 연결에서 나중에 수행하는 롤백으로 이미 커밋된 이 변경 사항을 취소할 수 없습니다. PostgreSQL의 BEGIN 문서를 참조하세요.

유효한 경계는 대여한 클라이언트 객체입니다. 헬퍼의 이름, 헬퍼가 포함된 모듈, 호출자가 해당 헬퍼를 await한다는 사실만으로 트랜잭션에 속하게 되지는 않습니다. 헬퍼의 쿼리가 트랜잭션을 시작한 클라이언트를 사용해야 트랜잭션에 참여합니다. 이는 트랜잭션 가이드의 동일 클라이언트 요구 사항에서 직접 비롯됩니다.

Mac에서 커밋과 롤백의 전체 예제를 실행하는 방법

npm이 포함된 Node.js, 실행 중인 PostgreSQL 서버, 기존 로컬 개발 데이터베이스가 필요합니다. 이 안내에서는 데이터베이스 이름이 article_demo이고 일반적인 로컬 연결 설정이 이미 작동한다고 가정합니다. 데이터베이스 설치는 이 트랜잭션 작업 범위에 포함되지 않습니다.

Terminal에서 새 작업 디렉터리를 만들고 드라이버를 설치하세요.

mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg

다음 전체 프로그램을 transaction-demo.mjs로 저장하세요. 임시 테이블에는 합성 article ID와 제목만 들어갑니다. PostgreSQL 임시 테이블은 일반적으로 커밋 후에도 유지되고 세션이 끝나면 제거됩니다. 이 특성 덕분에 프로그램은 성공한 배치의 결과를 확인한 다음 두 번째 배치를 롤백할 수 있습니다. CREATE TABLE을 참조하세요.

아래 트랜잭션 헬퍼는 문서에 설명된 BEGIN, COMMIT, ROLLBACK 순서를 따릅니다. 롤백도 실패하는 경우를 명시적으로 처리해 원래 오류를 덮어쓰지 않고 두 오류를 모두 보존합니다. 쿼리 값에는 쿼리에 설명된 텍스트와 값 API를 사용합니다.

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;

async function inTransaction(client, work) {
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

  try {
    const result = await work(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    try {
      await client.query('ROLLBACK');
    } catch (rollbackError) {
      throw new AggregateError(
        [error, rollbackError],
        'Transaction failed; rollback also failed'
      );
    }
    throw error;
  }
}

async function insertArticle(client, article) {
  const result = await client.query(
    `INSERT INTO pg_temp.transaction_demo_articles (id, title)
     VALUES ($1, $2)
     RETURNING id, title`,
    [article.id, article.title]
  );
  return result.rows[0];
}

async function insertBatch(client, articles) {
  const inserted = [];
  for (const article of articles) {
    inserted.push(await insertArticle(client, article));
  }
  return inserted;
}

async function main() {
  const pool = new Pool({ connectionTimeoutMillis: 5000 });
  let client;
  let discardClient = false;

  pool.on('error', (error) => {
    console.error('Idle client error:', error);
    process.exitCode = 1;
  });

  try {
    client = await pool.connect();

    await client.query(`
      CREATE TEMP TABLE transaction_demo_articles (
        id text PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `);

    const saved = await inTransaction(client, (tx) =>
      insertBatch(tx, [
        { id: 'article-001', title: 'A guide to paper shapes' },
        { id: 'article-002', title: 'Choosing a notebook layout' }
      ])
    );
    assert.equal(saved.length, 2);

    await assert.rejects(
      () => inTransaction(client, (tx) =>
        insertBatch(tx, [
          { id: 'article-003', title: 'Arranging a reading desk' },
          { id: 'article-001', title: 'Deliberate duplicate ID' }
        ])
      ),
      (error) => error.code === '23505'
    );

    const remaining = await client.query(`
      SELECT id, title
      FROM pg_temp.transaction_demo_articles
      ORDER BY id
    `);

    assert.deepEqual(
      remaining.rows.map((row) => row.id),
      ['article-001', 'article-002']
    );

    console.log('Commit and rollback checks passed.');
    console.table(remaining.rows);
  } catch (error) {
    discardClient = true;
    throw error;
  } finally {
    if (client) client.release(discardClient);
    await pool.end();
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

같은 디렉터리에서 실행하세요.

PGDATABASE=article_demo node transaction-demo.mjs

풀은 PostgreSQL 연결 환경 변수를 읽습니다. 로컬 설정에 다른 연결 설정이 필요한 경우 적절한 PGHOST 및 PGPORT을 PGDATABASE와 함께 지정하세요. 공식 연결 가이드에서 이 변수와 기본값을 설명합니다.

실행이 성공하면 Commit and rollback checks passed.가 출력되고 원래 article 행 두 개가 이어서 표시됩니다. 두 번째 배치는 article-003을 삽입한 다음 article-001을 다시 사용하려고 합니다. 이 중복으로 SQLSTATE 23505이 발생합니다. 직렬화 실패 처리에서는 이 오류를 unique_violation로 설명합니다. 배치를 롤백하면 앞서 수행한 삽입도 제거됩니다. 어설션은 article-003이 존재하지 않는지 확인합니다.

트랜잭션 헬퍼의 성공, 작업 오류, 롤백 오류 제어 경로는 결정론적 모의 클라이언트로 확인했습니다. 이 환경에는 pg 패키지와 PostgreSQL 테스트 연결이 없어 데이터베이스 예제를 여기서 실행하지는 않았습니다. 위 출력은 예상 결과를 설명합니다.

헬퍼에서 트랜잭션 클라이언트를 공유하는 방법

호출 체인 전체에서 클라이언트를 매개변수로 명시적으로 전달하세요. 프로그램에서 main()는 클라이언트를 가져오고, inTransaction()은 같은 객체를 콜백에 전달하며, insertBatch()은 이를 insertArticle()에 전달합니다. 두 INSERT 헬퍼 어디에서도 연결을 가져오지 않습니다.

이 구현에서는 각 함수의 책임을 명확히 유지하세요.

함수 책임
main() 클라이언트를 가져오고, 데모 설정을 수행하고, 클라이언트를 해제한 다음 스크립트의 풀을 닫습니다.
inTransaction() 전달받은 클라이언트에서 트랜잭션 하나를 시작하고 완료합니다.
insertBatch() 각 article 작업을 await하고 전달받은 클라이언트를 전달합니다.
insertArticle() 전달받은 클라이언트로 INSERT를 실행합니다.

헬퍼 계약은 의도적으로 범위를 좁혔습니다. 해당 클라이언트에 활성 트랜잭션이 없을 때만 inTransaction()를 호출하고, 콜백에서 발생한 오류는 전파하세요. 이미 PostgreSQL 트랜잭션 안에서 BEGIN을 호출해도 별도의 중첩 트랜잭션이 생성되지 않습니다. PostgreSQL은 중첩 작업에 세이브포인트를 사용하도록 안내합니다. BEGIN을 참조하세요.

트랜잭션에 속한 모든 작업을 콜백에서 반환하기 전에 await하세요. 이 예제에서는 순차 루프로 완료 시점이 명확히 드러납니다. INSERT를 비동기적으로 시작하고 프라미스를 반환하거나 await하지 않으면, 결과를 모두 확인하기 전에 콜백이 끝날 수 있습니다.

쿼리나 롤백이 실패하면 어떻게 해야 하나요?

일반적인 쿼리 오류는 트랜잭션 헬퍼의 catch 블록에 전달됩니다. catch 블록은 같은 클라이언트에서 롤백을 시도하고 원래 오류를 다시 던집니다. 의도적으로 발생시킨 중복 오류는 롤백이 완료된 후에만 허용됩니다. 롤백에 실패하면 헬퍼는 AggregateError를 던집니다. 중복 오류 확인은 이를 허용하지 않으므로 실행이 외부 오류 처리기로 넘어갑니다.

이 예제는 예상치 못한 오류가 발생하면 client.release(true)를 사용해 클라이언트를 보수적으로 폐기합니다. 정상 경로에서는 client.release(false)로 클라이언트를 반환합니다. destroy 인수는 Pool API에 설명되어 있습니다.

성공적으로 가져온 클라이언트는 항상 해제하세요. 그렇지 않으면 이후 호출자가 다시 사용할 수 없는 연결을 기다릴 수 있습니다. 이 독립 실행형 스크립트가 끝난 뒤에는 풀을 닫으세요. 애플리케이션에서는 애플리케이션 종료 시 풀을 닫아야 합니다. 풀링 가이드에서는 클라이언트 해제 요건과 pool.end()이 대여된 클라이언트가 반환될 때까지 기다리는 방법을 설명합니다.

클라이언트 하나를 사용하면 스냅샷이 안정적으로 유지되나요?

클라이언트 연속성과 격리 수준은 서로 다른 질문에 답합니다. 클라이언트는 어떤 트랜잭션이 문장을 처리하는지 결정합니다. 격리 수준은 다른 트랜잭션이 실행되는 동안 해당 문장에서 무엇을 볼 수 있는지를 결정합니다. PostgreSQL의 트랜잭션 격리 문서에서 이러한 한계를 설명합니다.

격리 수준 관련 한계
READ COMMITTED 기본값입니다. 연속된 일반 읽기에서 동시에 커밋된 서로 다른 데이터를 볼 수 있습니다. 각 문장은 새로운 스냅샷을 사용합니다.
REPEATABLE READ 첫 번째 트랜잭션 제어 문이 아닌 문장에서 설정된 스냅샷을 사용합니다. PostgreSQL은 팬텀 읽기를 방지하지만 직렬화 이상 현상은 여전히 발생할 수 있습니다.
SERIALIZABLE 커밋된 직렬화 가능 트랜잭션은 어떤 직렬 실행과 일관된 결과를 보입니다. 트랜잭션이 실패해 재시도가 필요할 수 있습니다.
READ UNCOMMITTED PostgreSQL에서는 READ COMMITTED처럼 동작합니다.

읽기 작업은 해당 트랜잭션이 앞서 변경한 내용도 볼 수 있습니다. 따라서 자신이 삽입한 데이터를 읽을 수 있다는 사실이 이후의 모든 읽기에서 다른 트랜잭션의 변경 사항이 고정된 상태로 보인다는 뜻은 아닙니다. 트랜잭션 격리를 참조하세요.

이 예제는 첫 문장에서 READ COMMITTED를 명시적으로 선택합니다. 다른 수준을 사용하려면 트랜잭션을 실행하기 전에 해당 문장을 변경하세요. PostgreSQL은 BEGIN에서 트랜잭션 특성을 지정할 수 있습니다. 대신 SET TRANSACTION을 사용하는 경우 첫 쿼리 또는 데이터 수정 문을 실행하기 전에 격리 수준을 설정해야 합니다. BEGIN 및 SET TRANSACTION을 참조하세요.

REPEATABLE READ 또는 SERIALIZABLE을 선택한다면 SQLSTATE 40001에 대비하세요. 실패한 쿼리만 다시 실행하지 말고 문장과 값을 선택하는 로직을 포함해 트랜잭션 전체를 재시도하세요. 이 예제는 의도적으로 재시도를 구현하지 않고 해당 오류를 전파합니다. PostgreSQL의 직렬화 실패 처리에서는 이 요구 사항을 설명하고 재시도가 다시 실패할 수 있다고 안내합니다.

트랜잭션에서 빠져나간 쿼리를 찾는 방법

의도한 배치 하나를 연결을 가져오는 시점부터 최종 커밋 또는 롤백까지 추적하세요. 다음 순서로 코드를 검토하세요.

  1. pool.connect() 호출을 찾아 반환되는 클라이언트를 확인합니다.
  2. BEGIN와 트랜잭션 완료 사이에 호출되는 각 헬퍼를 따라갑니다. 각 헬퍼가 해당 클라이언트를 전달받아 사용하는지 확인합니다.
  3. 공유 쿼리 래퍼에 숨겨진 pool.query() 호출이나 추가 연결 가져오기가 있는지 살펴봅니다.
  4. 모든 작업을 await하고 헬퍼가 오류를 전파하는지 확인합니다.
  5. 합성 중복 행 테스트를 배치에 맞게 적용하고, 이후 문이 실패했을 때 앞서 수행한 INSERT가 사라지는지 확인합니다.

롤백 테스트가 통과하면 테스트한 경로에 대한 유용한 근거가 되지만, 애플리케이션의 모든 분기에서 올바른 클라이언트를 사용한다고 입증할 수는 없습니다. 조건부 헬퍼도 검토하세요. 쿼리가 동일한 클라이언트에서 실행되는데도 반복된 읽기 결과가 서로 다르다면 연결 처리 방식을 바꾸기 전에 선택한 격리 수준과 그 동작을 비교하세요.

클라이언트를 가져오고 해제하는 과정을 코드에 드러내고, 클라이언트를 명시적으로 전달하며, 의도한 모든 작업이 끝난 뒤에 트랜잭션이 완료되도록 하세요. 이렇게 하면 살펴볼 수 있는 구체적인 구조와 article 워크플로가 확장될 때 반복해서 실행할 수 있는 롤백 테스트를 갖출 수 있습니다.

출처