복합 PostgreSQL 외래 키에는 참조된 열을 포함하는 적격 고유 키가 필요합니다. id만으로 구성된 기본 키는 (id, edition)에 대한 참조를 충족하지 않습니다. 논리적으로는 이 쌍을 고유하게 만들더라도 마찬가지입니다. PostgreSQL: CREATE TABLE

이 가이드는 Mac의 Terminal에서 작업하는 개발자가 이러한 불일치를 진단하고, 적절한 관계를 선택한 뒤, node-postgres로 결과를 테스트하는 데 도움을 줍니다. 모든 예제 행은 가상의 문서를 나타냅니다. SQL은 PostgreSQL 18과 일반 등가 비교 기반 외래 키를 대상으로 합니다. PostgreSQL 18은 PERIOD을 사용하는 시간 참조에 대해 별도의 규칙을 설명합니다. PostgreSQL: CREATE TABLE

PostgreSQL이 복합 참조를 거부하는 이유는 무엇인가요?

문서 테이블에 id, edition, title의 세 열이 있다고 가정해 보겠습니다. 기본 키는 id입니다. 다른 테이블에는 문서 참조가 두 값인 article_id과 edition로 저장됩니다.

의도한 관계는 다음 테이블 제약 조건 구문으로 표현됩니다.

FOREIGN KEY (article_id, edition)
  REFERENCES demo_articles (id, edition)

이 선언은 PostgreSQL이 두 값을 함께 확인해 상위 테이블의 한 행과 일치하는지 검사하도록 요청합니다. 문서에 명시된 참조 대상은 지연할 수 없는 고유 제약 조건 또는 기본 키 제약 조건의 열이거나, 부분 인덱스가 아닌 고유 인덱스의 열이어야 합니다. 기존 기본 키는 id만 포함하지만, 참조는 (id, edition)을 지정합니다. PostgreSQL: CREATE TABLE

이 반박의 수학적 근거는 이해할 만합니다. 상위 테이블의 어떤 두 행도 같은 id을 가질 수 없다면, 같은 (id, edition) 쌍을 가질 수도 없습니다. 하지만 그 논리적 함의만으로는 외래 키 정의가 요구하는, 일치하는 참조 대상이 선언되지 않습니다. 이 스키마에서는 UNIQUE (id, edition)을 추가하면 해당 조건을 충족합니다. 이 추가 제약 조건은 고유성 측면에서는 논리적으로 중복되지만, 복합 참조 대상으로 구체적인 역할을 합니다.

다음은 설명을 위한 상위 테이블 행입니다.

id edition title
101 morning Paper Shapes
102 evening Color Notes

하위 테이블의 (101, 'morning') 쌍에는 일치하는 행이 있습니다. (101, 'evening') 쌍에는 일치하는 행이 없습니다. 두 값은 테이블에 모두 있지만 서로 다른 행에 나타납니다. 두 저장 값이 같은 문서를 나타내야 한다면 복합 관계를 사용해야 하는 이유가 바로 이 차이입니다.

고유 키를 추가해야 하나요, 아니면 외래 키를 단순화해야 하나요?

스키마를 변경하기 전에 관계를 선택하세요. 핵심은 하위 테이블이 문서 식별자와 함께 판본을 저장하고 이를 강제해야 하는지 여부입니다.

필요한 동작 적절한 설계 결과
하위 테이블에서 문서를 식별하고 현재 판본을 표시함 article_id을 저장하고 상위 테이블의 id을 참조 쿼리할 때 상위 테이블에서 판본을 조회
하위 테이블에 저장된 판본이 문서와 일치해야 함 복합 참조를 유지하고 상위 테이블에 UNIQUE (id, edition)를 추가 PostgreSQL이 저장된 쌍이 함께 존재하는지 확인
같은 문서 번호가 여러 판본 행을 식별할 수 있음 상위 테이블의 식별자를 (id, edition)로 모델링 id만을 기본 키로 삼으면 해당 상위 행들을 만들 수 없음

마지막 행은 다른 데이터 모델을 설명합니다. 오류를 없애기 위해 단일 열 기본 키를 제거하지 마세요. 먼저 상위 테이블에 같은 문서 번호가 실제로 여러 번 있어야 하는지 결정하세요. PostgreSQL은 복합 기본 키를 허용하지만, 테이블당 기본 키는 하나만 둘 수 있습니다. PostgreSQL: 제약 조건

아래 실행 예제에서는 문서 식별자가 계속 전역적으로 고유합니다. 하위 테이블은 의도적으로 판본도 저장하므로, 상위 테이블에는 PRIMARY KEY (id)를 유지하고 추가 고유 쌍을 더합니다.

Mac에서 오류를 재현하고 수정하려면 어떻게 해야 하나요?

기존 로컬 PostgreSQL 18 데이터베이스와 정상적으로 작동하는 Node.js 설치를 사용하세요. 이 안내에서는 둘 다 이미 사용할 수 있다고 가정합니다. Terminal을 열고 새 프로젝트 디렉터리를 만드세요. 문서에 안내된 패키지 명령으로 드라이버를 설치합니다. node-postgres: 시작하기

mkdir composite-fk-demo
cd composite-fk-demo
npm init -y
npm install pg

다음 전체 프로그램을 demo.mjs로 저장하세요. 이 프로그램은 임시 테이블을 만들고, 거부되는 스키마를 보여 주며, 일치하는 키를 추가하고, 유효한 문서 쌍과 유효하지 않은 문서 쌍을 테스트합니다. node-postgres에서는 트랜잭션을 같은 클라이언트 인스턴스에서 유지해야 하므로 모든 문에 하나의 클라이언트를 사용합니다. node-postgres: 트랜잭션

각 예상 오류를 처리하기 위해 도우미 함수는 세이브포인트를 사용합니다. PostgreSQL 문서에 따르면 ROLLBACK TO SAVEPOINT는 해당 세이브포인트 이후의 명령을 되돌리고 작업을 재개할 수 있게 합니다. PostgreSQL: ROLLBACK TO SAVEPOINT

import pg from 'pg';
const { Client } = pg;
const client = new Client();

const childDDL = `CREATE TEMP TABLE demo_article_links (
  link_id integer PRIMARY KEY,
  article_id integer NOT NULL,
  edition text NOT NULL,
  FOREIGN KEY (article_id, edition)
    REFERENCES demo_articles (id, edition)
)`;

async function expectError(sql, values, code) {
  await client.query('SAVEPOINT expected_error');
  try {
    await client.query(sql, values);
  } catch (error) {
    await client.query('ROLLBACK TO SAVEPOINT expected_error');
    await client.query('RELEASE SAVEPOINT expected_error');
    if (error.code !== code) throw error;
    console.log(`Expected error: ${code}`);
    return;
  }
  await client.query('ROLLBACK TO SAVEPOINT expected_error');
  await client.query('RELEASE SAVEPOINT expected_error');
  throw new Error(`Expected SQLSTATE ${code}, but query succeeded`);
}

try {
  await client.connect();
  await client.query('BEGIN');
  await client.query(`CREATE TEMP TABLE demo_articles (
    id integer PRIMARY KEY,
    edition text NOT NULL,
    title text NOT NULL
  )`);

  await expectError(childDDL, [], '42830');

  await client.query(`ALTER TABLE demo_articles
    ADD CONSTRAINT demo_articles_id_edition_key
    UNIQUE (id, edition)`);
  await client.query(childDDL);

  await client.query(`INSERT INTO demo_articles (id, edition, title)
    VALUES ($1, $2, $3), ($4, $5, $6)`,
    [101, 'morning', 'Paper Shapes', 102, 'evening', 'Color Notes']);

  await client.query(`INSERT INTO demo_article_links
    (link_id, article_id, edition) VALUES ($1, $2, $3)`,
    [1, 101, 'morning']);

  await expectError(`INSERT INTO demo_article_links
    (link_id, article_id, edition) VALUES ($1, $2, $3)`,
    [2, 101, 'evening'], '23503');

  const result = await client.query(`SELECT l.link_id, a.title, a.edition
    FROM demo_article_links AS l
    JOIN demo_articles AS a
      ON a.id = l.article_id AND a.edition = l.edition
    ORDER BY l.link_id`);
  console.table(result.rows);
} catch (error) {
  console.error(error.code ?? error.message);
  process.exitCode = 1;
} finally {
  try { await client.query('ROLLBACK'); }
  finally { await client.end(); }
}

이 프로그램은 드라이버의 매개변수화 쿼리 API에 따라 $1, $2 및 뒤이은 자리표시자를 통해 문서 값을 각각 전달합니다. 테이블 및 열 이름은 SQL에 고정되어 있습니다. PostgreSQL 매개변수는 식별자를 대신할 수 없습니다. node-postgres: 쿼리

기존 로컬 데이터베이스의 연결 설정을 사용해 프로그램을 실행하세요. 이 실행 예제는 데이터베이스 이름이 article_demo이고, 서버가 5432 포트에서 수신 대기하며, 기존의 기본 로컬 연결 설정이 작동한다고 가정합니다. 필요하면 데이터베이스 이름이나 포트를 변경하세요. node-postgres는 PostgreSQL 연결 환경 변수를 읽습니다. node-postgres: 연결

PGHOST=localhost PGPORT=5432 PGDATABASE=article_demo node demo.mjs

이 글을 준비하는 동안 JavaScript 구문 검사를 통과했습니다. 데이터베이스 동작은 문서를 바탕으로 확인했으며, 여기서는 PostgreSQL 서버에 연결해 프로그램을 실행하지 않았습니다.

두 오류와 마지막 쿼리는 무엇을 보여 주나요?

위 전제 조건이 충족되면 다음 메시지가 표시될 것으로 예상됩니다.

예상 오류: 42830
예상 오류: 23503

첫 번째 오류는 문서 행을 삽입하기 전, 하위 테이블을 만들 때 발생합니다. PostgreSQL은 42830을 invalid_foreign_key로 식별합니다. 상위 테이블에 일치하는 고유 키를 추가한 뒤에는 같은 하위 테이블 정의가 유효해집니다. PostgreSQL: 오류 코드

두 번째 오류는 (101, 'evening')을 삽입할 때 발생합니다. 이제 스키마는 유효하지만, 제공된 쌍과 일치하는 상위 행이 없습니다. PostgreSQL은 23503를 foreign_key_violation로 식별합니다. 오류 코드를 확인하면 지역화된 오류 메시지에 의존하지 않고 이 행 불일치를 앞서 발생한 스키마 불일치와 구분할 수 있습니다. PostgreSQL: 오류 코드

마지막 쿼리는 한 행을 반환해야 합니다. 링크 1, 제목 Paper Shapes, 판본 morning입니다. 조인 조건에는 관계의 두 구성 요소가 모두 포함됩니다. 따라서 쿼리는 제약 조건이 적용하는 것과 같은 쌍을 나타냅니다.

외래 키 두 개를 따로 두면 다른 규칙을 표현하게 됩니다. edition에도 적격 고유 키가 있더라도, id과 edition에 대한 개별 참조는 각 값이 서로 다른 상위 행과 일치하도록 허용합니다. 예제에서는 이 차이가 드러납니다. 거부된 쌍의 개별 값은 모두 존재하지만, 그 조합은 존재하지 않습니다.

마지막 ROLLBACK은 테이블 정의를 포함한 시연 내용을 의도적으로 폐기합니다. 이 설계를 영구 테이블에 적용하려면 일반적인 마이그레이션 절차를 통해 스키마를 변경하세요. 이 임시 테이블 프로그램을 복사해도 지속되는 애플리케이션 스키마는 만들어지지 않습니다.

기존 스키마를 변경하기 전에 무엇을 확인해야 하나요?

먼저 참조되는 열 목록을 확인하세요. 인덱스 이름이나 현재 행들이 우연히 서로 다르다는 사실에 기대지 말고, 해당 열 목록 전체를 실제로 포함하는 상위 키를 찾으세요. 이 예제에서 필요한 추가 항목은 프로그램에 표시된 이름 있는 UNIQUE (id, edition) 제약 조건입니다.

고유 제약 조건을 추가하면 고유 B-tree 인덱스가 생성됩니다. 복합 설계와 article_id만 저장하는 설계 중 선택할 때 이 추가 인덱스를 고려하세요. 외래 키 선언 자체는 하위 열에 인덱스를 자동으로 만들지 않습니다. PostgreSQL: 제약 조건

기존 하위 테이블에 외래 키를 추가하기 전에 저장된 쌍이 상위 행과 일치하는지 확인하세요. 유효한 참조 대상 키는 스키마 요건을 충족하지만, 불일치하는 하위 값을 수정하지는 않습니다. 일반적인 ALTER TABLE ... ADD CONSTRAINT는 기존 행을 검사합니다. PostgreSQL은 외래 키를 NOT VALID로 추가한 뒤 VALIDATE CONSTRAINT를 실행하는 방법도 지원합니다. 이를 사용하면 기존 행 검사를 나중으로 미루면서 이후의 삽입이나 업데이트에는 제약 조건을 적용할 수 있습니다. PostgreSQL: ALTER TABLE

검증은 참조 대상의 적격성과 별개의 문제로 다루세요. NOT VALID로는 누락된 상위 키를 마련할 수 없습니다. 고유성을 증명하지 않고 고유 제약 조건을 추가하는 방법도 아닙니다. PostgreSQL 18에서는 이 옵션을 외래 키, 검사, not-null 제약 조건에만 허용합니다. PostgreSQL: ALTER TABLE

어떤 조건이 결과를 바꿀 수 있나요?

부분 고유 인덱스는 여기에서 설명하는 일반 외래 키의 참조 대상으로 적합하지 않습니다. 마찬가지로 참조 대상 고유 제약 조건을 지연 가능하게 설정해도 문서에 명시된 지연 불가 제약 조건 요건을 충족하지 못합니다. 이 예제에서는 일반적인 기본값 UNIQUE (id, edition)을 사용하세요. PostgreSQL: CREATE TABLE

널 처리 방식도 중요합니다. 기본 일치 동작에서는 참조하는 구성 요소 중 하나라도 null이면 복합 외래 키에 상위 행이 일치할 필요가 없습니다. 이 예제에서는 저장된 모든 쌍이 일치해야 하므로 두 하위 구성 요소에 NOT NULL을 지정합니다. 선택적 관계를 의도한다면 MATCH FULL을 사용해 참조 값 전체가 null인 경우는 허용하고 일부만 null인 경우는 거부할 수 있습니다. PostgreSQL: 제약 조건

유용한 확인 질문은 쌍 자체에 관한 것입니다. 하위 행이 서로 다른 행에 속한 문서 식별자와 판본을 지정할 수 있나요? 그렇게 되는 것을 막아야 한다면 복합 관계를 유지하고 상위 테이블에 일치하는 키를 추가하세요. 하위 행이 문서만 식별하면 되는 경우에는 id를 참조하고, 쿼리할 때 해당 문서에서 판본을 가져오세요.

출처