동시에 실행되는 PostgreSQL 업데이트가 겹칠 때 데드락을 방지하려면, 업데이트 전에 각 트랜잭션의 전체 대상 집합을 변경되지 않는 키의 동일한 순서로 잠그세요. 트랜잭션은 하나의 node-postgres 클라이언트에서 실행하고, 재시도 가능한 오류가 발생하면 처음부터 다시 시도하세요.

이 가이드는 합성 기사 행을 두 개의 동시 배치에서 빠짐없이 업데이트해야 하는 macOS Terminal 사용자 개발자를 위한 것입니다. 이 예제는 PostgreSQL 18을 명시한 현재 PostgreSQL 문서와 최신 node-postgres 문서를 사용합니다. 범위는 기존 기사 ID의 알려진 목록이며, 동시 작업 중 ID는 변경되지 않습니다. PostgreSQL: 명시적 잠금

겹치는 업데이트에서 데드락이 발생하는 이유

두 트랜잭션을 생각해 보세요. A는 기사 1을 업데이트한 다음 기사 2를 업데이트하고, B는 기사 2를 업데이트한 다음 기사 1을 업데이트합니다. 각 트랜잭션이 두 번째 행을 요청하기 전에 첫 번째 행 잠금을 획득하면, 서로 상대방이 보유한 잠금을 기다리게 됩니다. PostgreSQL은 이 순환을 감지해 트랜잭션 하나를 중단하지만, 어느 트랜잭션이 중단될지는 애플리케이션이 선택할 수 없습니다. 명시적 잠금 문을 사용하지 않아도 일반적인 업데이트에서 이런 상황이 발생할 수 있습니다. PostgreSQL: 명시적 잠금

핵심은 겹침과 순환을 구분하는 것입니다. 겹침은 대기를 일으킬 수 있습니다. 순환이 발생하면 어느 트랜잭션도 진행할 수 없습니다. PostgreSQL은 애플리케이션 전체에서 일관된 잠금 획득 순서를 사용하고, 필요한 잠금 모드 중 가장 강한 것을 먼저 획득하라고 권장합니다. PostgreSQL: 명시적 잠금

이 글의 작업에는 다음 규칙을 적용하세요. 변경할 기사에 대한 모든 행 잠금을 변경 전에 오름차순 id 순서로 획득합니다. 기사에 접근하는 앞선 문을 포함해 트랜잭션 전체에 이 규칙을 적용하세요.

업데이트 전에 전체 대상 집합 잠그기

JavaScript 배열을 정렬해도 대량 SQL 업데이트가 그 순서로 행을 방문하도록 지시하는 것은 아닙니다. PostgreSQL의 UPDATE 구문에는 직접적인 ORDER BY 절이 없습니다. PostgreSQL 문서는 조회 쿼리를 통한 순서 지정 잠금의 예를 보여 주며, 순서를 공유하면 업데이트 작업 간 데드락을 방지할 수 있다고 설명합니다. PostgreSQL: UPDATE

트랜잭션 안에서 별도의 잠금 문을 실행하고 완료될 때까지 기다리세요. 이 예시 SQL 트랜잭션은 기사 1, 2, 3을 대상으로 합니다. 설정 테이블은 다음 절에 나옵니다.

BEGIN ISOLATION LEVEL READ COMMITTED;

SELECT id
FROM deadlock_demo_articles
WHERE id = ANY(ARRAY[3, 1, 2]::integer[])
ORDER BY id
FOR UPDATE;

UPDATE deadlock_demo_articles
SET revision = revision + 1
WHERE id = ANY(ARRAY[3, 1, 2]::integer[]);

COMMIT;

첫 번째 문이 잠금 획득 순서를 정합니다. 그런 다음 업데이트는 해당 트랜잭션이 이미 잠근 행을 대상으로 실행됩니다. 일반적으로 행 잠금은 트랜잭션이 끝날 때까지 유지됩니다. 이 시간을 짧게 유지하세요. 트랜잭션을 시작하기 전에 입력을 준비하고, 잠금을 보유한 상태에서 데스크톱 입력을 기다리지 마세요. PostgreSQL: 명시적 잠금

다음 조건은 이 예제의 순서 지정 계약을 정의합니다.

  • 경합하는 모든 배치는 오름차순 기사 id 순서를 사용합니다.
  • 동시 작업 중 ID는 변경되지 않습니다.
  • 각 배치는 잠금을 획득하기 전에 전체 대상 집합을 파악합니다.
  • 앞선 문에서 기사 행을 다른 순서로 이미 잠그지 않았습니다.
  • 예제 테이블에는 추가 잠금 의존성을 만드는 트리거나 관련 테이블 작업이 없습니다.

이 작업에 이 추론을 적용하려면 이러한 조건이 필요합니다. 기사 9를 먼저 잠근 뒤 기사 1~3을 요청하는 트랜잭션은 최종 쿼리에 ORDER BY id가 있더라도 이미 계약을 벗어난 것입니다.

Mac에서 완전한 node-postgres 예제 실행하기

필요한 항목은 실행 중인 로컬 PostgreSQL 서버, 기존 임시 데이터베이스, npm이 설치된 Node.js, 그리고 Terminal에서 사용할 수 있는 psql입니다. 기존 데이터베이스 연결은 PostgreSQL 환경 변수로 설정하세요. node-postgres 풀과 psql은 같은 연결 변수를 사용합니다. 두 명령 모두 같은 서버에 연결되도록 필요한 경우 PGHOST을 명시적으로 설정하세요. node-postgres: 연결

새 작업 폴더에서 의존성을 설치하세요.

npm init -y
npm install pg

다음 전체 설정 스크립트를 setup.sql으로 저장하세요. 새 데모 테이블과 합성 기사 세 개를 만듭니다. 임시 데이터베이스에서 한 번 실행하세요. 기존 콘텐츠를 교체하지 않고, 테이블이 이미 있으면 의도적으로 실패합니다.

BEGIN;

CREATE TABLE deadlock_demo_articles (
    id integer PRIMARY KEY,
    title text NOT NULL,
    revision integer NOT NULL DEFAULT 0
);

INSERT INTO deadlock_demo_articles (id, title)
VALUES
    (1, 'Paper Shapes'),
    (2, 'Color Notes'),
    (3, 'Desk Sketches');

COMMIT;
psql -v ON_ERROR_STOP=1 -f setup.sql

다음 전체 프로그램을 update-articles.mjs로 저장하세요. 시도에 속한 모든 문은 pool.connect()가 반환한 클라이언트를 사용합니다. node-postgres는 트랜잭션 전체에서 클라이언트 하나를 사용해야 한다고 명시합니다. 트랜잭션 문을 pool.query()으로 실행하면 이 요구 사항을 지킬 수 없습니다. node-postgres: 트랜잭션

import pg from 'pg';
import { setTimeout as sleep } from 'node:timers/promises';

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

function normalizeIds(input) {
  if (!Array.isArray(input) || input.length === 0 ||
      input.some(id => !Number.isInteger(id) ||
        id < 1 || id > 2147483647)) {
    throw new Error('Use a nonempty array of positive PostgreSQL integer IDs');
  }
  return [...new Set(input)].sort((a, b) => a - b);
}

async function incrementRevisions(input) {
  const ids = normalizeIds(input);
  const maxAttempts = 3;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const client = await pool.connect();
    let discard = false;

    try {
      await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

      const locked = await client.query(
        `SELECT id
         FROM deadlock_demo_articles
         WHERE id = ANY($1::integer[])
         ORDER BY id
         FOR UPDATE`,
        [ids]
      );

      if (locked.rows.length !== ids.length) {
        throw new Error('A requested article is missing; batch cancelled');
      }

      const updated = await client.query(
        `UPDATE deadlock_demo_articles
         SET revision = revision + 1
         WHERE id = ANY($1::integer[])
         RETURNING id, revision`,
        [ids]
      );

      if (updated.rowCount !== ids.length) {
        throw new Error('Unexpected update count; batch cancelled');
      }

      await client.query('COMMIT');
      return updated.rows.sort((a, b) => a.id - b.id);
    } catch (error) {
      try {
        await client.query('ROLLBACK');
      } catch (rollbackError) {
        discard = true;
        throw new Error('Rollback failed; connection discarded', {
          cause: rollbackError
        });
      }

      const retryable = error.code === '40P01' || error.code === '40001';
      if (!retryable || attempt === maxAttempts) throw error;
    } finally {
      client.release(discard);
    }

    // Illustrative bounded backoff, after releasing the client.
    await sleep(50 * 2 ** (attempt - 1) + Math.floor(Math.random() * 50));
  }
}

try {
  const outcomes = await Promise.allSettled([
    incrementRevisions([3, 1, 2]),
    incrementRevisions([3, 2])
  ]);

  for (const outcome of outcomes) {
    if (outcome.status === 'rejected') throw outcome.reason;
  }

  const result = await pool.query(
    'SELECT id, title, revision FROM deadlock_demo_articles ORDER BY id'
  );
  console.table(result.rows);
} catch (error) {
  console.error(error);
  process.exitCode = 1;
} finally {
  await pool.end();
}
node update-articles.mjs

배열은 단일 매개변수로 전달하고 integer[]로 명시적으로 형 변환합니다. node-postgres 문서에 따르면 JavaScript 배열은 PostgreSQL 배열 값으로 변환됩니다. 중복을 제거해 요청 개수가 고유한 기사 수를 의미하도록 합니다. 잠금 획득 순서는 입력 배열이 아니라 데이터베이스의 ORDER BY가 정합니다. node-postgres: 쿼리

새로 초기화한 테이블에 다른 쓰기 작업이 없고 두 배치가 모두 성공하면, 예상 리비전은 기사 1에서 1, 기사 2와 3에서 2입니다. 어떤 배치가 먼저 끝나는지와 관계없이 배치 구성에 따라 이 합계가 결정됩니다. 이후 스크립트를 다시 실행하면 기존 값이 다시 증가합니다. 요청한 ID가 없으면 찾은 행만 조용히 업데이트하는 대신 해당 배치를 취소합니다.

각 배치는 별도의 트랜잭션입니다. 하나가 성공하고 다른 하나가 영구적으로 실패해도 성공한 배치는 커밋된 상태로 남습니다. Promise.allSettled()을 사용하면 프로그램이 풀을 닫기 전에 두 시도를 모두 마칠 수 있습니다. 마지막 pool.query()은 어느 트랜잭션에도 속하지 않은 상태로 실행됩니다. 풀 API 문서는 대여한 클라이언트를 반환하고, 필요한 경우 클라이언트를 폐기하며, 스크립트 작업이 끝난 뒤 풀을 종료하는 방법을 설명합니다. node-postgres: 풀

정규화 검사와 예시 리비전 계산은 Node.js로 실행했습니다. 조사 환경에서 PostgreSQL을 사용할 수 없어 SQL과 데이터베이스 동시성 동작은 실행하지 않고 문서와 대조해 확인했습니다.

작업에 필요한 격리 수준 유지하기

이 프로그램은 고정 ID 업데이트에 READ COMMITTED를 명시적으로 사용합니다. 이 수준에서는 잠금 문이 업데이트 작업을 기다린 다음, 조건에 여전히 일치한다면 업데이트된 행 버전을 잠글 수 있습니다. 각 문은 새 스냅샷을 사용합니다. 따라서 잠금을 획득하기 전에 삭제된 행은 빠질 수 있으며, 프로그램은 개수 확인으로 이를 처리합니다. PostgreSQL: 트랜잭션 격리

이 예제를 사용하기 위해 기존 트랜잭션의 격리 수준을 낮추지 마세요. REPEATABLE READ에서는 트랜잭션 스냅샷이 만들어진 후 변경된 행을 잠그거나 업데이트하려 할 때 직렬화 실패가 발생할 수 있습니다. SERIALIZABLE도 재시도 처리가 필요하며 커밋된 직렬화 이상 현상을 방지합니다. 하지만 일반 행 잠금에 일관된 순서를 적용하는 일을 대체하지는 않습니다. PostgreSQL: 트랜잭션 격리

알려진 행을 잠근다고 해서 조건에 해당하는 행의 구성원이 그대로 유지되는 것은 아닙니다. 예를 들어 이 예제는 “조건에 맞는 제목을 가진 모든 기사와 동시에 추가되는 기사까지 업데이트”하는 작업을 구현하지 않습니다. 이는 별도의 일관성 작업입니다. 고정 ID 방식을 적용하기 전에 그 작업에 필요한 격리 수준과 조정 방식을 정하세요.

또한 순서를 정하는 열은 변경되지 않게 유지하세요. PostgreSQL 문서에 따르면 READ COMMITTED에서는 정렬이 잠금보다 먼저 이뤄집니다. 문이 대기하는 동안 값이 변경되면 반환된 행이 순서에서 벗어난 것처럼 보일 수 있습니다. 변경되지 않는 ID를 사용하면 이 문제를 피할 수 있습니다. PostgreSQL: SELECT

전체 트랜잭션을 재시도하고 반복되는 실패 조사하기

PostgreSQL은 데드락 오류를 SQLSTATE 40P01로, 직렬화 실패를 40001로 표시합니다. 문이나 값을 결정하는 데이터베이스 의존적 판단을 포함해 전체 트랜잭션을 재시도하세요. 마지막 업데이트만 재시도해서는 이 요구 사항을 충족할 수 없습니다. PostgreSQL: 직렬화 실패 처리

이 예제는 총 세 번까지 시도합니다. 짧은 지수형 대기 시간과 지터는 예시를 위한 애플리케이션 선택이지 PostgreSQL의 기본값이 아닙니다. 대기 전에 클라이언트를 반환하고, 다른 오류가 발생하면 즉시 멈추며, 재시도 한도를 소진하면 이를 보고합니다. PostgreSQL은 재시도한다고 해서 결국 완료된다는 보장은 없다고 주의를 줍니다. PostgreSQL: 직렬화 실패 처리

구현을 적용할 때는 다음 판단 기준을 사용하세요.

상황 조치
다른 배치가 요청한 행을 이미 보유함 요청된 행을 모두 처리해야 하는 작업이라면 순서에 따라 잠금을 획득하는 동안 대기합니다.
요청한 ID가 없음 롤백하고 대상 누락 상태를 보고합니다.
SQLSTATE가 40P01 또는 40001임 롤백한 다음, 재시도 가능한 전체 트랜잭션을 제한된 횟수 안에서 재시도합니다.
순서에 따라 잠근 뒤에도 실패가 계속됨 앞선 문, 다른 쓰기 작업, 트리거, 추가 테이블이 잠금 순서 계약을 어기는지 살펴봅니다.
작업이 여러 테이블에 접근함 경합하는 모든 경로에 대해 테이블 순서와 행 순서를 함께 정합니다.
배치를 원자적으로 커밋해야 함 하나의 트랜잭션을 유지합니다. 청크별로 따로 커밋하면 작업의 의미가 달라집니다.

모든 대상을 처리하는 이 배치에 SKIP LOCKED를 추가하지 마세요. 즉시 잠글 수 없는 행을 제외합니다. PostgreSQL은 큐 형태의 소비자에는 유용하지만 일반적인 일관된 뷰에는 적합하지 않다고 설명합니다. NOWAIT은 충돌하는 행 잠금을 기다리는 대신 오류를 보고합니다. 대기 방식은 바뀌지만 배치 전체가 완료되지는 않습니다. PostgreSQL: SELECT

전체 대상 집합을 명시할 수 있고, 순서 지정 키를 변경되지 않게 유지하며, 경합하는 트랜잭션에 동일한 잠금 획득 규칙을 적용할 수 있다면 이 구현을 적용할 준비가 된 것입니다. 잠금 경합이나 불완전한 입력이 발생했을 때 명확히 처리할 수 있도록 대상 누락 확인과 제한된 전체 트랜잭션 재시도를 유지하세요.

출처