ON DELETE SET NULL과 ON DELETE CASCADE를 각각 별도의 외래 키에 지정해 결합합니다. 이 안내에서는 가상의 문서 데이터를 사용해 컬렉션을 삭제해도 문서는 유지되고, 문서를 삭제하면 해당 섹션은 제거되는 방식을 보여 줍니다.

목표는 이 동작을 정의하고, Mac의 Terminal에서 두 삭제 경로를 확인한 뒤 node-postgres로 같은 검사를 반복하는 것입니다. 예제에서는 일반 외래 키와 임시 테이블을 사용하므로 실행할 때마다 별도의 샘플 행이 만들어집니다.

각 삭제 작업에는 어떤 관계를 사용해야 하나요?

삭제 작업은 참조 테이블의 외래 키에 지정합니다. 참조 대상 행이 사라질 때 SET NULL은 참조를 지우고, CASCADE은 참조하는 행을 삭제합니다. PostgreSQL 문서에서는 선택적 관계에 SET NULL을, 종속 구성 요소에 CASCADE을 적합한 용도로 설명합니다. 제약 조건

이 예제에서 컬렉션은 문서를 묶지만, 문서는 컬렉션 없이도 존재할 수 있습니다. 섹션은 하나의 문서에 속하며 독립적인 역할은 없습니다. 이는 이 예제에서 명시적으로 선택한 설계입니다.

외래 키 선택한 작업 의도한 결과
demo_articles.collection_id는 demo_collections.collection_id을 참조 ON DELETE SET NULL 컬렉션을 삭제해도 문서는 유지되고 컬렉션 참조는 없어집니다.
demo_sections.article_id은 demo_articles.article_id을 참조 ON DELETE CASCADE 문서를 삭제하면 해당 섹션도 삭제됩니다.

결과 동작은 삭제가 어디서 시작되는지에 따라 달라집니다. 컬렉션을 삭제하면 문서의 collection_id가 변경되지만 문서 자체는 삭제되지 않습니다. 따라서 섹션 외래 키의 ON DELETE CASCADE가 반응할 문서 삭제는 발생하지 않습니다. 문서 자체를 삭제하면 해당 캐스케이드가 작동합니다. 이는 두 작업에 대한 문서상의 정의에 따른 동작입니다. CREATE TABLE

“참조를 지우고 행을 삭제”하기 위해 같은 관계에 서로 충돌하는 외래 키 두 개를 두지 마세요. 참조하는 행을 유지할지 결정한 뒤, 그 결정에 맞는 작업을 선택하세요.

Mac에서 완전한 샘플 만들기

실행 중인 PostgreSQL 서버, 접속할 수 있는 기존 데이터베이스, 그리고 Terminal에서 사용할 수 있는 psql가 필요합니다. 아래 명령은 기존 로컬 데이터베이스 postgres을 사용합니다. 필요한 경우 기존 데이터베이스 이름으로 바꾸세요. 설치 및 서버 설정은 이 안내의 범위에 포함되지 않습니다.

작업 폴더에 다음 내용을 일반 텍스트 파일 setup.sql로 저장하세요. 세 테이블은 모두 임시 테이블이며, 세션이 끝나면 PostgreSQL이 임시 테이블을 제거합니다. 이 예제에서 참조되는 테이블도 모두 임시 테이블입니다. PostgreSQL은 임시 테이블과 영구 테이블 사이의 외래 키를 허용하지 않기 때문입니다. CREATE TABLE

CREATE TEMP TABLE demo_collections (
    collection_id integer PRIMARY KEY,
    title text NOT NULL
);

CREATE TEMP TABLE demo_articles (
    article_id integer PRIMARY KEY,
    collection_id integer,
    title text NOT NULL,
    CONSTRAINT demo_articles_collection_fk
        FOREIGN KEY (collection_id)
        REFERENCES demo_collections (collection_id)
        ON DELETE SET NULL
);

CREATE TEMP TABLE demo_sections (
    section_id integer PRIMARY KEY,
    article_id integer NOT NULL,
    heading text NOT NULL,
    CONSTRAINT demo_sections_article_fk
        FOREIGN KEY (article_id)
        REFERENCES demo_articles (article_id)
        ON DELETE CASCADE
);

CREATE INDEX demo_articles_collection_idx
    ON demo_articles (collection_id);
CREATE INDEX demo_sections_article_idx
    ON demo_sections (article_id);

INSERT INTO demo_collections (collection_id, title) VALUES
    (1, 'Layout Notes'),
    (2, 'Color Notes');

INSERT INTO demo_articles (article_id, collection_id, title) VALUES
    (101, 1, 'Grid Spacing'),
    (102, 2, 'Color Pairings');

INSERT INTO demo_sections (section_id, article_id, heading) VALUES
    (1001, 101, 'Overview'),
    (1002, 101, 'Examples'),
    (1003, 102, 'Overview');

collection_id은 NULL을 허용하지만 섹션 테이블의 article_id은 필수 항목입니다. SET NULL 작업을 수행한 뒤에도 테이블의 다른 제약 조건을 만족해야 합니다. 명시적으로 만든 인덱스는 참조 열을 대상으로 합니다. 외래 키를 선언해도 PostgreSQL이 이 인덱스를 자동으로 만들지는 않습니다. 제약 조건

한 세션에서 두 삭제 경로 모두 확인하기

다음 블록을 setup.sql 옆에 verify.sql로 저장하세요. 완전한 psql 스크립트입니다. \ir은 확인 스크립트를 기준으로 상대 경로에 있는 설정 파일을 포함합니다. 임시 테이블이 같은 세션에 유지되도록 두 파일을 이 스크립트로 실행하세요. -f 옵션은 파일을 실행하고 ON_ERROR_STOP는 오류가 발생하면 스크립트 처리를 중단합니다. psql

BEGIN;
\ir setup.sql

DELETE FROM demo_collections
WHERE collection_id = 1
RETURNING collection_id;

SELECT a.article_id,
       a.title,
       a.collection_id,
       c.title AS collection_title,
       count(s.section_id) AS section_count
FROM demo_articles AS a
LEFT JOIN demo_collections AS c
    ON c.collection_id = a.collection_id
LEFT JOIN demo_sections AS s
    ON s.article_id = a.article_id
GROUP BY a.article_id, a.title, a.collection_id, c.title
ORDER BY a.article_id;

DELETE FROM demo_articles
WHERE article_id = 101
RETURNING article_id;

SELECT section_id, article_id, heading
FROM demo_sections
ORDER BY section_id;

ROLLBACK;

Terminal에서 파일이 있는 폴더로 이동한 다음 실행하세요.

psql -X -v ON_ERROR_STOP=1 -d postgres -f verify.sql

첫 번째 쿼리의 예상 결과는 샘플 행에서 도출한 것입니다.

문서 컬렉션 삭제 후 컬렉션 ID 컬렉션 제목 섹션 수
101 NULL NULL 2
102 2 Color Notes 1

문서 101은 두 섹션과 함께 유지됩니다. 두 번째 삭제가 끝난 뒤 섹션 쿼리에는 문서 102에 속한 섹션 1003만 반환되어야 합니다. 이 두 검사를 통해 문서를 유지하는 경우와 문서 및 종속 행을 삭제하는 경우를 구분할 수 있습니다.

확인 쿼리에서 LEFT JOIN을 의도적으로 사용했습니다. PostgreSQL은 왼쪽 테이블에서 일치하는 행이 없는 경우에도 해당 행을 유지하고, 오른쪽 테이블의 누락된 값에는 NULL을 넣습니다. 컬렉션에 내부 조인을 사용하면 참조가 NULL이 된 뒤 문서 101가 결과에서 빠집니다. 그러면 유지된 문서가 쿼리 결과에서 사라진 것처럼 보입니다. 테이블 표현식

RETURNING 절은 대상 테이블에서 삭제한 행을 보고합니다. 캐스케이드로 삭제된 모든 섹션의 통합 목록을 제공하지는 않습니다. 별도의 섹션 쿼리로 그 결과를 확인합니다. DELETE

마지막 ROLLBACK는 이 예제에서 수행한 변경 사항을 취소합니다. PostgreSQL은 독립 실행 문도 트랜잭션으로 처리하므로, 여기서는 설정, 삭제, 검사, 정리 작업을 하나로 묶기 위해 명시적 트랜잭션을 사용합니다. 트랜잭션

node-postgres로 같은 검사 실행하기

JavaScript 버전에서는 setup.sql을 변경하지 마세요. 이 예제에는 Node.js 24를 사용하고 작업 폴더에 문서화된 pg 패키지를 설치하세요. node-postgres

npm install pg

다음 전체 프로그램을 setup.sql 옆에 verify.mjs로 저장하세요. 같은 가상 테스트 데이터를 읽어 컬렉션을 삭제해도 두 문서와 세 섹션이 모두 유지되는지 확인한 다음, 문서를 삭제하면 해당 섹션만 제거되는지 확인합니다.

프로그램은 node-postgres의 요구 사항에 따라 트랜잭션 전체에서 연결된 클라이언트 하나를 사용합니다. 쿼리 값은 문서화된 매개변수 배열 인터페이스에서 $1을 사용해 전달합니다. 트랜잭션, 쿼리

import pg from 'pg';
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';

const { Client } = pg;
const client = new Client();
const setup = await readFile(
  new URL('./setup.sql', import.meta.url),
  'utf8'
);

await client.connect();
try {
  await client.query('BEGIN');
  await client.query(setup);

  const removed = await client.query(
    'DELETE FROM demo_collections WHERE collection_id = $1 RETURNING collection_id',
    [1]
  );
  assert.deepEqual(removed.rows, [{ collection_id: 1 }]);

  const articles = await client.query(
    'SELECT article_id, collection_id FROM demo_articles ORDER BY article_id'
  );
  assert.deepEqual(articles.rows, [
    { article_id: 101, collection_id: null },
    { article_id: 102, collection_id: 2 }
  ]);

  const before = await client.query(
    'SELECT section_id FROM demo_sections ORDER BY section_id'
  );
  assert.deepEqual(
    before.rows.map(row => row.section_id),
    [1001, 1002, 1003]
  );

  await client.query(
    'DELETE FROM demo_articles WHERE article_id = $1',
    [101]
  );
  const after = await client.query(
    'SELECT section_id FROM demo_sections ORDER BY section_id'
  );
  assert.deepEqual(after.rows, [{ section_id: 1003 }]);

  console.log('Expected SET NULL and CASCADE outcomes verified.');
  await client.query('ROLLBACK');
} catch (error) {
  try {
    await client.query('ROLLBACK');
  } catch (rollbackError) {
    console.error('Rollback failed:', rollbackError.message);
  }
  throw error;
} finally {
  await client.end();
}

같은 기존 로컬 데이터베이스를 대상으로 실행하세요.

PGDATABASE=postgres node verify.mjs

new Client()은 PostgreSQL 연결 환경 변수를 읽습니다. 로컬 서버에 이미 필요한 연결 설정이 있다면 그대로 유지하세요. 데이터베이스 이름만 바꿔서는 다른 서버에 연결할 수 없습니다. 연결하기

단언이 실패하면 롤백 경로로 진입하고 오류가 표시됩니다. 모든 결과 검사를 통과한 뒤에만 성공 메시지가 나타납니다. 반복해서 실행할 수 있는 예제로 유지하기 위해 성공 시에도 의도적으로 롤백합니다.

준비 과정에서 JavaScript 구문을 확인했습니다. 조사 환경에는 PostgreSQL 실행 파일이 없어 데이터베이스 예제를 직접 실행하지 않고 문서와 대조해 검토했습니다. 위 표는 예상 결과를 보여 줍니다.

복합 외래 키에서는 어떻게 달라지나요?

주요 예제에서는 단일 열 외래 키를 사용합니다. 복합 외래 키를 사용하면 참조하는 모든 열을 비울지, 선택 사항인 열만 비울지 결정해야 합니다. PostgreSQL 15부터 외래 키의 ON DELETE SET 작업에 열 목록을 지정할 수 있습니다. 이전 버전에서는 외래 키에 포함된 모든 열에 작업이 적용됐습니다. PostgreSQL 15 릴리스 노트

예를 들어 복합 문서 참조에는 필수 판본 식별자와 선택적 관련 문서 식별자가 포함될 수 있습니다. PostgreSQL 15 이상에서는 ON DELETE SET NULL (related_article_id)을 사용해 판본 식별자를 유지하면서 선택적 문서 참조만 지울 수 있습니다. 열 목록이 없으면 SET NULL은 두 열을 모두 대상으로 합니다. 이 구문은 PostgreSQL 확장이며 ON UPDATE가 아닌 ON DELETE에서 사용할 수 있습니다. CREATE TABLE

일부 열만 NULL로 만들려면 호환되는 일치 규칙도 필요합니다. 기본값인 MATCH SIMPLE은 복합 외래 키에 NULL이 포함되는 것을 허용하지만, MATCH FULL은 NULL과 NULL이 아닌 참조 값이 섞인 경우 이를 거부합니다. 따라서 관련 문서 식별자만 NULL로 만들고 필수 판본 식별자를 유지하는 방식은 MATCH FULL과 호환되지 않습니다. 제약 조건

PostgreSQL 14에서는 관계를 정확히 나타낼 수 있다면 주요 예제처럼 선택적 참조를 별도의 단일 열로 분리한 스키마를 사용하세요. 복합 관계를 유지해야 한다면 하나의 트랜잭션에서 참조 대상 행을 삭제하기 전에 선택적 참조를 명시적으로 지우세요. 이는 지원되지 않는 열 목록 구문 대신 애플리케이션이 직접 관리하는 순서입니다.

PostgreSQL 18에는 또 다른 문서화된 제약이 있습니다. PERIOD을 사용하는 시간 범위 외래 키는 CASCADE 또는 SET NULL을 지원하지 않습니다. 관계에 기간 범위 조건이 필요하지 않다면, 여기서 보여 준 일반 외래 키가 이러한 삭제 작업을 지원하는 방식입니다. CREATE TABLE

예상과 다른 결과를 어떻게 진단하나요?

삭제 경로를 기준으로 다음 순서에 따라 진단하세요.

  1. 참조 테이블을 확인합니다. 테이블이 있는 세션에서 \d demo_articles과 \d demo_sections를 살펴보세요. 열 이름으로 추측하지 말고 실제 외래 키 작업을 확인하세요. PostgreSQL 문서에서는 \d을 사용해 테이블 세부 정보와 제약 조건 이름을 확인할 수 있다고 설명합니다. 테이블 수정
  2. NULL 허용 여부와 다른 제약 조건을 확인합니다. 컬렉션 삭제가 실패하면 지우려는 열이 NULL을 허용하는지, 변경된 행이 다른 제약 조건도 만족하는지 확인하세요. SET NULL은 다른 제약 조건을 우회하지 않습니다. 제약 조건
  3. 관련된 모든 외래 키를 살펴봅니다. 다른 관계에 다른 작업이 지정되어 있을 수 있습니다. 작업을 생략하면 기본값인 NO ACTION이 적용되므로, 참조가 남아 있으면 삭제가 차단될 수 있습니다. CREATE TABLE
  4. 조인을 해석하기 전에 기본 테이블을 쿼리합니다. 문서가 여전히 존재한다면 조인이나 필터로 인해 새 NULL 참조가 있는 문서가 제외되는지 확인하세요. 테이블 표현식
  5. 같은 클라이언트에서 트랜잭션 문을 실행합니다. 풀을 사용하는 애플리케이션에서는 트랜잭션 전체에 하나의 클라이언트를 확보하세요. node-postgres 문서에서는 트랜잭션 문에 pool.query()을 사용하지 말라고 명시합니다. 트랜잭션

이 문서 모델의 수용 테스트는 명확합니다. 컬렉션 1을 삭제해도 문서 101과 해당 섹션은 유지되고, 문서 101을 삭제하면 문서 102의 섹션만 남습니다. 스키마를 변경할 때 이 두 검사를 함께 수행하면 의도한 유지 및 삭제 동작을 확인할 수 있습니다.

출처