두 번째 BEGIN을 보내도 PostgreSQL은 중첩 트랜잭션을 만들지 않습니다. 경고를 표시하고 현재 트랜잭션을 유지하며, 첫 번째로 성공한 COMMIT이 트랜잭션을 종료합니다. 헬퍼에서 부분 롤백이 필요하면 세이브포인트를 사용하세요. PostgreSQL: BEGIN

이 가이드는 Mac Terminal에서 작업하는 개발자가 헬퍼가 문서 편집 작업을 너무 일찍 커밋하는 문제를 진단하는 데 도움을 줍니다. 예제에서는 가상의 문서 행을 사용하며, 헬퍼가 자체 배치 작업을 되돌릴 수 있도록 하면서 트랜잭션 소유자는 하나로 유지하는 방법을 보여줍니다. 현재 PostgreSQL 및 node-postgres 문서를 따릅니다.

다른 트랜잭션 안에서 BEGIN과 COMMIT을 실행하면 어떻게 되나요?

SQL 트랜잭션 명령은 연결의 현재 트랜잭션에 적용됩니다. PostgreSQL은 BEGIN 호출을 반복해도 횟수를 세고 그에 맞는 COMMIT 호출을 기다리지 않습니다. 두 번째 BEGIN은 트랜잭션을 변경하지 않으며, 일반 COMMIT이 성공하면 현재 트랜잭션을 커밋합니다. 그 뒤 일반 COMMIT을 다시 실행하면 열려 있는 트랜잭션이 없으므로 경고가 발생합니다. PostgreSQL: BEGIN, PostgreSQL: COMMIT

정상적인 트랜잭션의 경계 흐름은 다음과 같습니다.

연결 하나에서 전송한 명령 결과 경계
첫 번째 BEGIN 트랜잭션을 엽니다
첫 번째 문서 삽입 해당 트랜잭션에 포함됩니다
두 번째 BEGIN 경고가 발생하며 내부 경계는 만들어지지 않습니다
두 번째 문서 삽입 같은 트랜잭션에 포함됩니다
첫 번째 COMMIT 두 삽입을 모두 커밋하고 트랜잭션을 종료합니다
별도로 전송한 다른 문장 일반적인 자동 커밋 동작에 따라 자체 트랜잭션에서 실행됩니다
이후의 ROLLBACK 이미 커밋된 삽입을 되돌릴 수 없습니다

이로 인해 흔히 발생하는 애플리케이션 오류를 설명할 수 있습니다. 호출자가 트랜잭션을 열고, 자체 BEGIN과 COMMIT을 실행하는 헬퍼를 호출합니다. 헬퍼의 COMMIT이 호출자의 트랜잭션도 종료합니다. 이후 호출자에서 예외가 발생해도 원래의 전부 성공하거나 전부 실패하는 작업을 복원할 수 없습니다. 이 결론은 PostgreSQL에 문서화된 트랜잭션 경계에 따른 것입니다. PostgreSQL: Transactions

Mac Terminal에서 조기 커밋을 재현하는 방법

실행 중인 PostgreSQL 데이터베이스와 연결 설정이 올바른 psql 명령이 필요합니다. Terminal을 열고 psql -X로 새 세션을 시작하세요. -X 옵션은 시작 파일을 건너뛰므로 이 시연이 로컬 사용자 설정에 덜 의존합니다. 해당 세션에서 아래 스크립트를 실행하세요. 이 스크립트는 자동 커밋을 명시적으로 활성화합니다. PostgreSQL: psql

테이블은 임시 테이블이며 가상의 문서 제목만 들어 있습니다. ON COMMIT PRESERVE ROWS는 트랜잭션 경계를 지나도 행을 확인할 수 있도록 유지하며, PostgreSQL은 세션이 끝날 때 임시 테이블을 삭제합니다. 이 예제는 연결 해제 후 영구 저장되는 동작이 아니라 세션 내 롤백 동작을 보여줍니다. PostgreSQL: CREATE TABLE

\set AUTOCOMMIT on

CREATE TEMP TABLE article_boundary_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_boundary_demo VALUES (1, 'Opening draft');

-- Deliberately incorrect attempt to open an inner transaction.
BEGIN;
INSERT INTO article_boundary_demo VALUES (2, 'Middle draft');
COMMIT;

-- The previous COMMIT has already ended the transaction.
INSERT INTO article_boundary_demo VALUES (3, 'Closing draft');
ROLLBACK;

SELECT id, title FROM article_boundary_demo ORDER BY id;

예상 결과에는 세 행이 모두 포함됩니다. 두 번째 BEGIN은 트랜잭션이 이미 진행 중이라는 경고를 표시합니다. COMMIT은 행 1와 2을 커밋하고, 이후 행 3는 자동 커밋으로 커밋됩니다. 마지막 ROLLBACK을 실행할 때는 되돌릴 열린 트랜잭션이 없습니다. 이는 문서화된 동작에서 도출한 예상 결과이며, 데이터베이스에서 실행했다고 보고하는 내용은 아닙니다. PostgreSQL: BEGIN, PostgreSQL: Transactions

이 재현은 표시된 대로 대화형 psql에서 진행하세요. 클라이언트가 문장을 제출하는 방식이 중요합니다. 여러 SQL 문장을 하나의 psql -c 문자열로 전달하면 명시적인 트랜잭션 명령으로 구분하지 않는 한 암시적 트랜잭션을 공유할 수 있습니다. 별도로 실행하려면 각각의 명령을 따로 보내거나 표준 입력을 사용하라고 문서에서 권장합니다. PostgreSQL: psql

세이브포인트는 바깥 트랜잭션을 어떻게 보존하나요?

세이브포인트는 기존 트랜잭션 안의 특정 위치를 표시합니다. ROLLBACK TO SAVEPOINT는 그 위치 이후의 변경 사항을 버리고 이전 작업은 보존합니다. RELEASE SAVEPOINT는 표시를 제거하고 남아 있는 변경 사항을 포함 트랜잭션 안에 유지합니다. 변경 사항을 독립적으로 커밋하지는 않습니다. PostgreSQL: SAVEPOINT, PostgreSQL: RELEASE SAVEPOINT

같은 세션이나 다른 새 psql 세션에서 다음 전체 예제를 실행하세요. 예제에서 자체 임시 테이블을 만듭니다.

CREATE TEMP TABLE article_savepoint_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_savepoint_demo VALUES (1, 'Opening draft');

SAVEPOINT optional_article;
INSERT INTO article_savepoint_demo VALUES (2, 'Optional draft');
ROLLBACK TO SAVEPOINT optional_article;
RELEASE SAVEPOINT optional_article;

INSERT INTO article_savepoint_demo VALUES (3, 'Closing draft');
COMMIT;

SELECT id, title FROM article_savepoint_demo ORDER BY id;

예상 행은 1과 3입니다. 행 2은 세이브포인트에서 버려지며, 시작 행과 끝 행은 최종 COMMIT이 실행될 때까지 하나의 트랜잭션에 포함됩니다. 마지막 COMMIT을 ROLLBACK으로 바꾸면 남아 있는 두 삽입도 모두 버려집니다. PostgreSQL: SAVEPOINT, PostgreSQL: Transactions

헬퍼를 조합할 때는 세이브포인트의 수명도 중요합니다. 세이브포인트로 롤백해도 이름이 지정된 해당 세이브포인트는 계속 사용할 수 있지만, 그 뒤에 만들어진 세이브포인트는 삭제됩니다. 세이브포인트를 해제하면 해당 세이브포인트와 그 이후에 만들어진 활성 세이브포인트가 제거됩니다. 예제에서는 복구한 뒤 표시를 명시적으로 해제합니다. PostgreSQL: ROLLBACK TO SAVEPOINT, PostgreSQL: RELEASE SAVEPOINT

node-postgres 헬퍼는 트랜잭션을 어떻게 공유해야 하나요?

node-postgres에서는 트랜잭션의 모든 문장이 동일한 클라이언트 인스턴스를 사용해야 하며, 자동 중첩 트랜잭션 추상화는 제공하지 않습니다. 호출자의 클라이언트를 헬퍼에 전달하세요. 애플리케이션에서 풀을 사용한다면 pool.connect()로 클라이언트를 가져와 그 클라이언트를 처음부터 끝까지 사용한 다음 해제하세요. pool.query()은 연속된 쿼리를 서로 다른 클라이언트로 보낼 수 있습니다. node-postgres: Transactions, node-postgres: Pool

다음 전체 프로그램은 직접 생성한 Client 하나를 사용합니다. 호출자가 BEGIN, COMMIT, 전체 ROLLBACK을 소유합니다. 헬퍼는 선택 사항인 2행 배치 작업을 세이브포인트로 감쌉니다. 두 번째 삽입은 의도적으로 문서 ID 1을 다시 사용해 고유성 오류를 발생시킵니다. 오류 복구는 이전에 성공한 삽입을 포함해 선택적 배치 작업 전체를 버립니다.

전체 예제 설정 및 실행

npm이 설치된 현재 지원 Node.js 버전을 사용하세요. 작업 폴더에서 npm install pg를 실행한 다음 아래 프로그램을 article-transactions.mjs으로 저장하세요. 설정된 경우 PGHOST, PGPORT, PGDATABASE, PGUSER을 포함해 기존 PostgreSQL 연결 환경을 사용합니다. 예제를 실행하기 전에 이 설정으로 작동하는 데이터베이스를 지정해야 합니다. node-postgres: Welcome, node-postgres: Connecting

import pg from 'pg'
const { Client } = pg

async function addOptionalArticles(client) {
  await client.query('SAVEPOINT optional_articles')
  try {
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [2, 'Optional draft']
    )
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [1, 'Duplicate draft']
    )
  } catch (error) {
    if (error.code !== '23505') throw error
    await client.query('ROLLBACK TO SAVEPOINT optional_articles')
    await client.query('RELEASE SAVEPOINT optional_articles')
    return false
  }
  await client.query('RELEASE SAVEPOINT optional_articles')
  return true
}

async function main() {
  const client = new Client({ connectionTimeoutMillis: 5000 })
  await client.connect()
  try {
    await client.query(`
      CREATE TEMP TABLE article_demo (
        id integer PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `)
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED')
    try {
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [1, 'Opening draft']
      )
      const optionalKept = await addOptionalArticles(client)
      console.log('Optional batch kept:', optionalKept)
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [3, 'Closing draft']
      )
      await client.query('COMMIT')
    } catch (error) {
      try {
        await client.query('ROLLBACK')
      } catch (rollbackError) {
        throw new AggregateError(
          [error, rollbackError], 'Transaction and rollback failed'
        )
      }
      throw error
    }
    const result = await client.query(
      'SELECT id, title FROM article_demo ORDER BY id'
    )
    console.table(result.rows)
  } finally {
    await client.end()
  }
}

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

node article-transactions.mjs를 실행하세요. 예상 출력에는 Optional batch kept: false가 표시되고 문서 ID 1과 3이 나옵니다. 값에는 문서화된 query(text, values) 매개변수 인터페이스를 사용합니다. JavaScript 구문은 Node.js로 확인했으며, 데이터베이스 시나리오는 문서를 기준으로 검토했지만 여기서 실행하지는 않았습니다. node-postgres: Queries

명시적으로 허용된 오류에만 복구 적용

PostgreSQL은 23505을 unique_violation에 할당하며, 오류 메시지 텍스트가 아니라 오류 코드를 확인하라고 권장합니다. 여기서 이 코드를 허용하는 것은 가상의 선택적 배치 작업에 적용한 예시 규칙입니다. 다른 오류는 호출자에게 전파되며, 호출자는 전체 트랜잭션 롤백을 시도합니다. 버려진 선택 작업이 호출자에게 드러나도록 헬퍼가 명시적인 결과를 반환합니다. PostgreSQL: PostgreSQL Error Codes

JavaScript 예외만 잡아서는 실패한 PostgreSQL 트랜잭션을 복원할 수 없습니다. 복구하려면 미리 설정한 세이브포인트로 롤백하거나 전체 롤백을 해야 합니다. 특히 중단된 트랜잭션은 세이브포인트를 해제하는 것만으로 복구할 수 없습니다. PostgreSQL: Transactions, PostgreSQL: RELEASE SAVEPOINT

이 헬퍼는 활성 트랜잭션과 이 클라이언트의 순차적 사용을 전제로 합니다. 일반적인 재귀형 트랜잭션 래퍼는 아닙니다. 재사용 가능한 중첩 추상화를 만들려면 명시적인 소유권 및 세이브포인트 이름 관리도 필요합니다.

경계를 수정한 뒤에도 어떤 격리 수준의 한계가 남나요?

세이브포인트는 현재 트랜잭션 내에서 부분 롤백을 제공하지만 별도의 격리 정책을 설정하지는 않습니다. 격리 수준은 바깥 경계에서 선택하세요. PostgreSQL에서는 트랜잭션의 첫 쿼리 또는 데이터 수정 문장이 실행된 뒤 격리 수준을 변경할 수 없으므로, 헬퍼가 BEGIN을 다시 보내 이미 사용된 트랜잭션의 격리 수준을 높일 수는 없습니다. PostgreSQL: SAVEPOINT, PostgreSQL: SET TRANSACTION

일반 쿼리에서는 다음 차이점을 기억하세요.

격리 수준 관련 한계
READ COMMITTED 연속된 쿼리에서 다른 트랜잭션이 새로 커밋한 변경 사항이 보일 수 있습니다. 하나의 트랜잭션이라고 해서 고정된 스냅샷 하나를 의미하지는 않습니다.
REPEATABLE READ 트랜잭션 제어 명령이 아닌 첫 번째 문장에서 설정된 스냅샷을 쿼리들이 공유하며, 자체적으로 이전에 수행한 변경 사항은 볼 수 있습니다. 직렬화 실패가 발생할 수 있습니다.
SERIALIZABLE 성공적으로 커밋된 트랜잭션의 효과는 어떤 직렬 순서와 일치합니다. 애플리케이션은 여전히 직렬화 실패를 처리해야 합니다.
READ UNCOMMITTED PostgreSQL은 이를 READ COMMITTED로 취급합니다.

PostgreSQL: Transaction Isolation, PostgreSQL: SET TRANSACTION

직렬화 실패가 발생하면 SQL을 만들어 낸 의사 결정 과정을 포함해 전체 트랜잭션을 다시 시도해야 합니다. 헬퍼의 세이브포인트 작업만 다시 시도해도 바깥 트랜잭션의 스냅샷은 새로 고쳐지지 않습니다. PostgreSQL은 SQLSTATE 40001로 직렬화 실패를 식별합니다. 시연 프로그램은 이러한 실패를 호출자에게 전파하며 자동 재시도를 구현하지 않습니다. PostgreSQL: Transaction Isolation

적절한 헬퍼 경계를 선택하는 방법

호출자와 문서 헬퍼를 검토할 때 다음 순서로 결정하세요.

  1. 모든 작업이 함께 성공해야 함: 호출자가 트랜잭션을 소유하고 쿼리만 실행하는 헬퍼에 클라이언트를 전달합니다.
  2. 호출자가 계속 진행하면서 한 배치만 버릴 수 있음: 해당 배치 전에 세이브포인트를 설정하고, 허용된 오류에만 복구를 적용한 뒤 결과를 명시적으로 반환합니다.
  3. 배치를 독립적으로 커밋해야 함: 별도의 클라이언트와 트랜잭션을 사용합니다. 이 경우 커밋은 바깥 트랜잭션을 롤백해도 유지되므로 원래의 전부 성공하거나 전부 실패하는 작업을 충족할 수 없습니다.
  4. 동시 실행으로 직렬화 실패가 발생함: 헬퍼를 독립적인 재시도 경계로 간주하지 말고 전체 트랜잭션을 다시 시작합니다.

이 선택은 PostgreSQL의 세이브포인트 의미와 트랜잭션이 한 클라이언트에서 실행되어야 한다는 node-postgres의 요구 사항을 따릅니다. PostgreSQL: SAVEPOINT, node-postgres: Transactions, PostgreSQL: Transaction Isolation

여기서 다룬 문서 편집 작업에서는 트랜잭션 소유자를 하나로 유지하고 호출 체인 전체에 하나의 클라이언트를 전달하며, 선택 작업에는 세이브포인트를 사용하세요. 모든 헬퍼의 트랜잭션 명령을 확인하세요. 예기치 않은 COMMIT은 연결 전체의 경계를 바꾸지만, 세이브포인트를 해제해도 최종 결과를 책임지는 호출자는 여전히 남아 있습니다.

출처