(KO) PostgreSQL 제약 조건 — CHECK, NOT NULL, Unique, Primary Key, Foreign Key 내부 구조
목차
이론적 배경
섹션 제목: “이론적 배경”*무결성 제약 조건(integrity constraint)*은 커밋된 모든 데이터베이스 상태가 만족해야 하는 서술어다. 관계형 모델은 스키마를 단순한 컬럼 타입 집합이 아니라, DBMS가 애플리케이션을 대신해 참이라고 보장하는 단언(assertion) 집합으로 본다. 급여는 절대 음수가 될 수 없고, 주문은 반드시 실존하는 고객을 참조해야 하며, 두 직원이 같은 ID를 가질 수 없다는 것이 그 예다. Database System Concepts(Silberschatz, Korth, Sudarshan) 4장 §4.4 “Integrity Constraints”는 SQL 제약 조건을 PostgreSQL이 거의 일대일로 구현하는 소규모 분류 체계로 정리한다.
-
도메인 /
CHECK제약 조건 — 단일 행의 컬럼들(또는 도메인 타입의 단일VALUE)에 대한 임의 불리언 서술어다.CHECK (salary > 0)이 전형적 예다. 이 제약 조건은 *행 내부(intra-tuple)*에서 결정할 수 있다. 즉, 정확히 한 행만 보면 된다. -
NOT NULL제약 조건 — 퇴화된 서술어col IS NOT NULL이다. SQL이 역사적 이유로 별도 범주로 다루지만, 논리적으로는 특수화된CHECK다. -
키 제약 조건 —
UNIQUE와PRIMARY KEY— 집합 수준(set-level) 단언이다. 키 컬럼에서 두 행이 같은 값을 가질 수 없다.PRIMARY KEY는 키 컬럼에NOT NULL을 추가하고 해당 행을 대표 식별자로 지정한다. 결정하려면 새 행을 모든 다른 행과 비교해야 하므로, 이것은 행별 서술어가 아니라 근본적으로 인덱스/검색 문제다. -
참조 무결성 —
FOREIGN KEY— 테이블 간(inter-table) 단언이다. 자식 테이블의 모든 비-NULL 외래 키 값이 부모 테이블의 키에 존재해야 한다. 이를 유지하려면 네 가지 이벤트에 각각 반응해야 한다. 자식의 INSERT/UPDATE(“부모가 존재하는가?”), 부모의 DELETE/UPDATE(“고아가 생기는가, 그렇다면 어떻게 처리할 것인가:RESTRICT,NO ACTION,CASCADE,SET NULL,SET DEFAULT?”)가 그것이다.
교재는 어느 구현도 해결해야 하는 두 가지 횡단 설계 차원을 강조한다. 이 두 가지는 PostgreSQL 소스에 그대로 나타난다.
제약 조건을 언제 검사하는가? SQL은 즉시(immediate)(각 문장 끝에서 검사) 방식과 지연(deferred)(트랜잭션 커밋 시 검사) 방식을 구분한다. DEFERRABLE로 선언된 제약 조건은 SET CONSTRAINTS로 둘 사이를 전환할 수 있다. 지연은 순환 참조에서 중요하다. 즉시 검사 아래서는 서로를 참조하는 두 행을 삽입하는 것이 불가능하지만, FK가 커밋까지 지연되면 합법이 된다.
참조 액션은 무엇인가? 부모 행이 삭제되거나 키가 갱신될 때 표준은 반응을 열거한다. NO ACTION / RESTRICT(금지), CASCADE(자식에게 삭제/갱신 전파), SET NULL, SET DEFAULT. NO ACTION과 RESTRICT는 미묘하게 다르다. NO ACTION은 문장 끝에서 검사되므로 같은 문장의 이후 연산이 그것을 다시 만족시킬 수 있다. RESTRICT는 즉시 발동하며 지연될 수 없다.
이 네 가지 범주가 세 가지 강제 메커니즘을 요구한다는 통찰이 PostgreSQL 코드의 구조를 결정한다.
CHECK/NOT NULL→ 튜플 수정 경로에서 행별로 평가되는 표현식. 비용이 낮고 지역적이며 별도 저장소가 필요 없다.UNIQUE/PRIMARY KEY→ 뒷받침하는 유일 인덱스. 제약 조건은 인덱스 관리의 부산물로 강제된다. 카탈로그 행은 인덱스에 붙은 레이블에 가깝다.FOREIGN KEY→ 트리거가 구동하는 SQL 쿼리. RI는 두 테이블에 걸치고 양쪽 이벤트에 반응해야 하므로, 지역 검사나 인덱스 탐색으로 줄일 수 없는 유일한 제약 조건이다. PostgreSQL은 이것을 평범한(하지만 숨겨진)AFTER트리거로 구현하며, 트리거 본문은 SPI로SELECT/DELETE/UPDATE를 발행한다.
이 세 갈래 분할, 그리고 모든 종류를 균일하게 다룰 수 있도록 기록하는 단일 카탈로그(pg_constraint)가 나머지 모든 내용의 출발점이다.
공통 DBMS 설계
섹션 제목: “공통 DBMS 설계”생산 수준 관계형 엔진들은 제약 조건 처리에서 놀랍도록 유사한 공학 관행으로 수렴한다. 이 관행을 먼저 명명하면 PostgreSQL의 구체적 심볼이 공유된 설계의 한 선택으로 읽힌다.
단일 제약 조건 카탈로그를 진실의 원천으로
섹션 제목: “단일 제약 조건 카탈로그를 진실의 원천으로”강제 방식에 관계없이 모든 제약 조건은 타입 판별자, 소유 릴레이션 또는 타입, 관련 컬럼 번호, 강제 메타데이터(지연 가능 여부, 검증 여부, 참조 액션)를 담은 카탈로그 행 하나로 기록된다. DROP, 의존성 추적, 스키마 덤프는 이 카탈로그 행 위에서 작동하며, 런타임 메커니즘(인덱스, 트리거, 표현식)은 여기서 파생된다. PostgreSQL의 pg_constraint가 정확히 이 역할을 한다. Oracle은 ALL_CONSTRAINTS, SQL Server는 sys.check_constraints / sys.foreign_keys를 쓴다.
키 제약 조건은 인덱스 위에 올라탄다
섹션 제목: “키 제약 조건은 인덱스 위에 올라탄다”주류 엔진은 UNIQUE를 강제하려고 테이블 전체를 다시 스캔하지 않는다. 키 컬럼에 유일 인덱스를 요구하고, 인덱스의 삽입 경로가 중복을 거부하게 두는 것이 공통 방법이다. 제약 조건 카탈로그 행은 단순히 인덱스를 가리킨다. PRIMARY KEY는 UNIQUE에 NOT NULL과 “행 식별자” 플래그를 더한 것이다. 제약 조건의 비용은 인덱스 유지 비용과 같고, 쿼리 플래너도 그 인덱스를 활용하므로 재사용도 얻는다.
참조 무결성은 트리거(또는 그에 상당하는 것)로 구현된다
섹션 제목: “참조 무결성은 트리거(또는 그에 상당하는 것)로 구현된다”FK는 두 테이블의 이벤트에 반응하고 연쇄 액션을 지원해야 하므로, 지배적인 구현 전략은 트리거다. 자식에는 숨겨진 AFTER ROW 트리거(부모 존재 확인), 부모에는 삭제/갱신 처리 트리거를 붙인다. 트리거 본문은 생성된 SQL이다. PostgreSQL을 비롯해 역사적으로 많은 엔진이 이 방식을 쓴다. 대안인 실행기 수정 경로 내부의 특수 C 코드는 드물다. 트리거가 이미 지연 큐, 행별 발동, 스냅샷 의미론을 무료로 제공하기 때문이다.
선언과 검증의 분리
섹션 제목: “선언과 검증의 분리”데이터가 채워진 테이블에 FK나 CHECK를 추가하면 모든 기존 행을 스캔해야 하고, 이는 테이블을 오랫동안 잠글 수 있다. 성숙한 엔진은 따라서 선언과 검증을 분리한다. NOT VALID로 선언하면(신규 행에는 즉시 적용, 약한 잠금, 스캔 건너뜀) 나중에 비용이 큰 소급 검증을 별도의, 동시성 친화적인 단계로 실행할 수 있다. Oracle의 ENABLE NOVALIDATE와 PostgreSQL의 NOT VALID + VALIDATE CONSTRAINT가 같은 개념이다.
이벤트 큐를 통한 지연 검사
섹션 제목: “이벤트 큐를 통한 지연 검사”지연 가능 제약 조건은 커밋 전에 “이 행을 재검사해야 한다”는 기록을 어딘가에 남겨야 한다. 표준 메커니즘은 사용자 트리거에 쓰는 것과 같은 after-trigger 이벤트 큐다. 행이 변경될 때 이벤트를 큐에 넣고, 즉시 모드라면 문장 끝에서, 지연 모드라면 커밋 또는 SET CONSTRAINTS 시점에 큐를 비운다. FK를 트리거로 구현했기 때문에 FK 지연이 트리거 큐를 재사용하는 것은 자연스러운 귀결이다.
이론 ↔ PostgreSQL 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”| 개념 | PostgreSQL 이름 |
|---|---|
| 제약 조건 카탈로그 | pg_constraint |
| 카탈로그 행 작성 함수 | CreateConstraintEntry (pg_constraint.c) |
| 타입 판별자 | pg_constraint.contype ('c' 'n' 'p' 'u' 'f' 'x') |
| CHECK 저장 | StoreRelCheck → conbin 텍스트 트리 (heap.c) |
| NOT NULL 저장 | StoreRelNotNull (heap.c) |
| 뒷받침 유일 인덱스 | pg_constraint.conindid |
| FK 검사 트리거 (child) | RI_FKey_check_ins / RI_FKey_check_upd |
| FK 액션 트리거 (parent) | RI_FKey_cascade_*, ri_restrict, RI_FKey_setnull_* |
| FK 메타데이터 캐시 | RI_ConstraintInfo (ri_triggers.c) |
| 생성된 쿼리 캐시 | RI_QueryKey로 키된 준비된 SPI 계획 |
| FK 일괄 검증 | RI_Initial_Check (anti-join 쿼리) |
| NOT VALID 플래그 | pg_constraint.convalidated = false |
| 검증 명령 | ATExecValidateConstraint |
| 지연 가능 / 지연됨 | condeferrable / condeferred; AFTER-trigger 큐 |
| 도메인 CHECK | domainAddCheckConstraint (typecmds.c) |
| 도메인 NOT NULL | domainAddNotNullConstraint (typecmds.c) |
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL은 모든 제약 조건 종류를 단일 카탈로그 작성 함수로 통과시킨 뒤 세 가지 강제 메커니즘으로 분기한다. 그 분기점이 CreateConstraintEntry다.
작성 함수 하나, 카탈로그 행 하나
섹션 제목: “작성 함수 하나, 카탈로그 행 하나”테이블 CHECK, NOT NULL, 기본 키, 유일, FK, 도메인 CHECK를 막론하고 모든 제약 조건은 단일 함수로 구체화된다. 이 함수의 긴 매개변수 목록은 모든 제약 조건 종류의 필요를 합집합한 것이다. 판별자는 constraintType(contype에 저장)이며, 해당하지 않는 필드는 InvalidOid / NULL / ' '로 전달된다.
// CreateConstraintEntry — src/backend/catalog/pg_constraint.cconOid = GetNewOidWithIndex(conDesc, ConstraintOidIndexId, Anum_pg_constraint_oid);values[Anum_pg_constraint_oid - 1] = ObjectIdGetDatum(conOid);values[Anum_pg_constraint_conname - 1] = NameGetDatum(&cname);values[Anum_pg_constraint_contype - 1] = CharGetDatum(constraintType);values[Anum_pg_constraint_condeferrable - 1] = BoolGetDatum(isDeferrable);values[Anum_pg_constraint_condeferred - 1] = BoolGetDatum(isDeferred);values[Anum_pg_constraint_conenforced - 1] = BoolGetDatum(isEnforced);values[Anum_pg_constraint_convalidated - 1] = BoolGetDatum(isValidated);values[Anum_pg_constraint_conrelid - 1] = ObjectIdGetDatum(relId);values[Anum_pg_constraint_contypid - 1] = ObjectIdGetDatum(domainId);values[Anum_pg_constraint_conindid - 1] = ObjectIdGetDatum(indexRelId);values[Anum_pg_constraint_confrelid - 1] = ObjectIdGetDatum(foreignRelId);values[Anum_pg_constraint_confupdtype - 1] = CharGetDatum(foreignUpdateType);values[Anum_pg_constraint_confdeltype - 1] = CharGetDatum(foreignDeleteType);values[Anum_pg_constraint_confmatchtype - 1] = CharGetDatum(foreignMatchType);행을 삽입한 뒤 함수는 의존성을 기록한다. 소유 릴레이션/컬럼에 대해서는 자동 의존성(컬럼 삭제 → 제약 조건 삭제)을, 참조된 FK 테이블, 그것을 뒷받침하는 유일 인덱스, PK-FK 값을 비교하는 동등 연산자들에 대해서는 일반 의존성을 기록한다.
// CreateConstraintEntry — src/backend/catalog/pg_constraint.cif (OidIsValid(foreignRelId)){ /* normal dependency: constraint -> foreign relation/columns */ if (foreignNKeys > 0) for (i = 0; i < foreignNKeys; i++) { ... add_exact_object_address(...); }}if (foreignNKeys > 0){ /* normal dependency on the pf/pp/ff equality operators */ for (i = 0; i < foreignNKeys; i++) { oprobject.objectId = pfEqOp[i]; add_exact_object_address(&oprobject, addrs_normal); if (ppEqOp[i] != pfEqOp[i]) { ... } if (ffEqOp[i] != pfEqOp[i]) { ... } }}contype 판별자는 단일 문자다.
// pg_constraint contype values — src/include/catalog/pg_constraint.h#define CONSTRAINT_CHECK 'c'#define CONSTRAINT_FOREIGN 'f'#define CONSTRAINT_NOTNULL 'n'#define CONSTRAINT_PRIMARY 'p'#define CONSTRAINT_UNIQUE 'u'#define CONSTRAINT_EXCLUSION 'x'CHECK와 NOT NULL — 텍스트로 저장된 표현식
섹션 제목: “CHECK와 NOT NULL — 텍스트로 저장된 표현식”테이블 CHECK 제약 조건은 노드 트리로 컴파일된 뒤 텍스트 문자열(conbin)로 평탄화되어 저장된다. 참조하는 컬럼들은 카탈로그가 컬럼 수준 의존성을 기록할 수 있도록 추출된다. 테이블 CHECK 제약 조건은 절대 지연될 수 없다. PostgreSQL이 deferrable/deferred에 false, false를 전달하는 것이 그 증거이며, 실행기의 수정 경로에서 즉시 평가된다.
// StoreRelCheck — src/backend/catalog/heap.cccbin = nodeToString(expr); /* flatten tree to text */varList = pull_var_clause(expr, 0); /* find referenced columns */...constrOid = CreateConstraintEntry(ccname, RelationGetNamespace(rel), CONSTRAINT_CHECK, false, /* Is Deferrable */ false, /* Is Deferred */ is_enforced, is_validated, InvalidOid, RelationGetRelid(rel), attNos, keycount, keycount, InvalidOid, InvalidOid, InvalidOid, /* ... no FK fields ... */ expr, ccbin, /* tree + binary form */ is_local, inhcount, is_no_inherit, false, is_internal);NOT NULL도 같은 방식으로 저장되지만, 자신만의 contype을 가지며, 표현식 대신 단일 attnum을 전달한다. PostgreSQL 18부터 이름 있는 NOT NULL 제약 조건은 pg_constraint의 1등 시민 행이다. NOT VALID로 선언하거나, 상속하거나, 독립적으로 검증할 수 있다.
// StoreRelNotNull — src/backend/catalog/heap.cconstrOid = CreateConstraintEntry(nnname, RelationGetNamespace(rel), CONSTRAINT_NOTNULL, false, false, /* not deferrable */ true, /* Is Enforced */ is_validated, InvalidOid, RelationGetRelid(rel), &attnum, 1, 1, /* one key column */ InvalidOid, InvalidOid, InvalidOid, /* ... */ NULL, NULL, /* no expression */ is_local, inhcount, is_no_inherit, ...);런타임에는 두 제약 조건 모두 실행기의 ExecConstraints에서 발동한다. 이 함수는 삽입 또는 갱신되는 모든 튜플마다 릴레이션의 ConstrCheck 배열과 attnotnull 플래그를 순회한다. 트리거도, SPI도, 추가 왕복도 없다. 실행기 경로의 세부 내용은 postgres-executor.md가 담당하며, 여기서 핵심은 카탈로그 행이 CHECK/NOT NULL이 남기는 유일한 아티팩트라는 점이다.
UNIQUE와 PRIMARY KEY — 인덱스에 붙은 레이블
섹션 제목: “UNIQUE와 PRIMARY KEY — 인덱스에 붙은 레이블”유일 제약 조건이나 기본 키 제약 조건은 표현식도 트리거도 저장하지 않는다. 유일 B-트리 인덱스를 가리키는 conindid를 기록할 뿐이며, 강제는 전적으로 해당 인덱스 삽입 경로의 부산물이다. 중복 키가 인덱스의 유일성 검사(_bt_check_unique, nbtree)에 걸리면 ERRCODE_UNIQUE_VIOLATION이 발생한다. 이 제약 조건들의 CreateConstraintEntry 호출은 인덱스가 생성된 후 index_constraint_create(index.c)에서 이루어진다. 의존성 방향이 FK 경우와 반대임을 주목하라. 제약 조건이 인덱스를 소유한다(제약 조건 삭제 → 인덱스 삭제). 인덱스를 구축하는 메커니즘은 postgres-index-creation.md와 postgres-nbtree.md가 다루며, 제약 조건 관점에서 중요한 것은 PRIMARY KEY = UNIQUE 인덱스 + 각 키 컬럼에 대한 묵시적 NOT NULL이고 contype = 'p'로 기록된다는 점이다.
flowchart TD
A["CREATE TABLE / ALTER TABLE<br/>... ADD CONSTRAINT"] --> B{"contype?"}
B -->|"'c' CHECK<br/>'n' NOT NULL"| C["StoreRelCheck /<br/>StoreRelNotNull<br/>conbin / attnum 저장"]
B -->|"'u' UNIQUE<br/>'p' PRIMARY KEY"| D["index_constraint_create<br/>conindid -> unique btree"]
B -->|"'f' FOREIGN KEY"| E["createForeignKeyCheckTriggers<br/>+ createForeignKeyActionTriggers"]
C --> Z["CreateConstraintEntry<br/>pg_constraint 행 하나"]
D --> Z
E --> Z
C --> C2["ExecConstraints에서 강제<br/>수정된 행마다"]
D --> D2["btree의 _bt_check_unique<br/>삽입 시 강제"]
E --> E2["AFTER ROW RI 트리거가<br/>SPI로 강제"]
FOREIGN KEY — 숨겨진 트리거로 구현된 참조 무결성
섹션 제목: “FOREIGN KEY — 숨겨진 트리거로 구현된 참조 무결성”외래 키는 인라인 강제가 없는 유일한 제약 조건이다. FK를 생성할 때 PostgreSQL은 CreateTrigger로 최대 네 개의 시스템 트리거를 만든다. 이 트리거들은 tgisinternal로 표시되고 tgconstraint로 제약 조건 OID와 연결된다.
- 참조 측(child) 테이블:
RI_FKey_check_ins/RI_FKey_check_upd를 호출하는AFTER INSERT와AFTER UPDATE트리거. “부모 행이 존재하는가?”를 확인한다. - 피참조 측(parent) 테이블: 선언된 참조 액션에 따라 함수가 선택되는
AFTER DELETE와AFTER UPDATE트리거(RI_FKey_cascade_del,RI_FKey_restrict_del,RI_FKey_noaction_del,RI_FKey_setnull_del, …).
// CreateFKCheckTrigger — src/backend/commands/tablecmds.cfk_trigger->isconstraint = true;fk_trigger->trigname = "RI_ConstraintTrigger_c";if (on_insert) { fk_trigger->funcname = SystemFuncName("RI_FKey_check_ins"); fk_trigger->events = TRIGGER_TYPE_INSERT;} else { fk_trigger->funcname = SystemFuncName("RI_FKey_check_upd"); fk_trigger->events = TRIGGER_TYPE_UPDATE;}fk_trigger->row = true;fk_trigger->timing = TRIGGER_TYPE_AFTER;fk_trigger->deferrable = fkconstraint->deferrable;fk_trigger->initdeferred = fkconstraint->initdeferred;trigAddress = CreateTrigger(fk_trigger, NULL, myRelOid, refRelOid, constraintOid, indexOid, InvalidOid, parentTrigOid, NULL, true, false);부모 측에서는 ON DELETE / ON UPDATE 액션에 따라 트리거 함수를 선택한다. NO ACTION만 지연 가능하다. RESTRICT, CASCADE, SET NULL, SET DEFAULT는 deferrable = false를 강제한다.
// createForeignKeyActionTriggers — src/backend/commands/tablecmds.cswitch (fkconstraint->fk_del_action){ case FKCONSTR_ACTION_NOACTION: fk_trigger->deferrable = fkconstraint->deferrable; fk_trigger->initdeferred = fkconstraint->initdeferred; fk_trigger->funcname = SystemFuncName("RI_FKey_noaction_del"); break; case FKCONSTR_ACTION_RESTRICT: fk_trigger->deferrable = false; fk_trigger->initdeferred = false; fk_trigger->funcname = SystemFuncName("RI_FKey_restrict_del"); break; case FKCONSTR_ACTION_CASCADE: fk_trigger->funcname = SystemFuncName("RI_FKey_cascade_del"); break; case FKCONSTR_ACTION_SETNULL: fk_trigger->funcname = SystemFuncName("RI_FKey_setnull_del"); break; case FKCONSTR_ACTION_SETDEFAULT: fk_trigger->funcname = SystemFuncName("RI_FKey_setdefault_del"); break;}참조 액션 코드 자체도 단일 문자로 confupdtype / confdeltype에 저장된다.
// FK action codes — src/include/nodes/parsenodes.h#define FKCONSTR_ACTION_NOACTION 'a'#define FKCONSTR_ACTION_RESTRICT 'r'#define FKCONSTR_ACTION_CASCADE 'c'#define FKCONSTR_ACTION_SETNULL 'n'#define FKCONSTR_ACTION_SETDEFAULT 'd'트리거 본문 — 핵심 로직 — 은 ri_triggers.c에 있으며 다음 절에서 설명한다.
RI 트리거 본문 — SPI를 통한 생성 SQL
섹션 제목: “RI 트리거 본문 — SPI를 통한 생성 SQL”검사 트리거가 발동하면 RI_FKey_check는 손으로 쓴 C 비교를 실행하지 않는다. 대신 SPI(Server Programming Interface)로 부모 테이블을 대상으로 하는 매개변수화된 SQL 탐색을 한 번 만들고, 이후에는 항상 실행한다. 쿼리 형태는 고정되어 있고, 매개변수는 새 행의 FK 컬럼 값이다.
// RI_FKey_check — src/backend/utils/adt/ri_triggers.cfk_rel = trigdata->tg_relation;pk_rel = table_open(riinfo->pk_relid, RowShareLock);...switch (ri_NullCheck(RelationGetDescr(fk_rel), newslot, riinfo, false)){ case RI_KEYS_ALL_NULL: /* an all-NULL key passes every type of foreign key constraint */ table_close(pk_rel, RowShareLock); return PointerGetDatum(NULL); ...}SPI_connect();ri_BuildQueryKey(&qkey, riinfo, RI_PLAN_CHECK_LOOKUPPK);if ((qplan = ri_FetchPreparedPlan(&qkey)) == NULL){ /* SELECT 1 FROM [ONLY] <pktable> x WHERE pkatt1 = $1 [AND ...] * FOR KEY SHARE OF x */ ...}세 가지 설계 사실이 여기서 드러나며, 이는 모든 RI 함수에서 반복된다.
- NULL 단락(short-circuit).
ri_NullCheck가 SQL 실행 전에 MATCH 의미론을 결정한다. 모든 컬럼이 NULL인 키는 항상 통과한다. 기본MATCH SIMPLE에서는 FK 컬럼 하나라도 NULL이면 통과한다.MATCH FULL만이 NULL과 비-NULL이 섞인 경우를 거부한다. SELECT ... FOR KEY SHARE OF x. 탐색은 단순히 존재 여부를 테스트하는 것이 아니라, 일치하는 부모 행에 키 공유(key-share) 행 잠금을 건다. 이 잠금은 부모의 동시DELETE/키-UPDATE(자식을 고아로 만들 수 있는)를 막지만, 키와 관련 없는 일반 갱신은 허용한다. 따라서 RI 검사와 관련 없는 부모 갱신이 직렬화되지 않는다.pk_rel을RowShareLock모드로 여는 이유가 이것이다.- 준비된 계획 캐시.
ri_BuildQueryKey가RI_QueryKey{constr_id, constr_queryno}를 도출하고,ri_FetchPreparedPlan이 저장된 SPI 계획 해시 테이블에서 그것을 찾는다. 비용이 큰 파싱과 플래닝은 제약 조건당 쿼리 형태당 한 번만 수행되며, 이후 행들은 캐시된SPIPlanPtr을 재사용한다.
부모 측 액션 함수들도 같은 골격을 따르되 다른 생성 SQL을 사용한다. RI_FKey_noaction_del과 RI_FKey_restrict_del은 모두 ri_restrict를 호출하며, 자식 테이블에 살아있는 참조가 있는지 탐색한다.
// ri_restrict — src/backend/utils/adt/ri_triggers.cfk_rel = table_open(riinfo->fk_relid, RowShareLock);pk_rel = trigdata->tg_relation;oldslot = trigdata->tg_trigslot;
/* In the NO ACTION case only, if another PK row now provides the old key * values, we should do nothing. RESTRICT does not allow a substitute. */if (is_no_action && !riinfo->hasperiod && ri_Check_Pk_Match(pk_rel, fk_rel, oldslot, riinfo)){ table_close(fk_rel, RowShareLock); return PointerGetDatum(NULL);}SPI_connect();ri_BuildQueryKey(&qkey, riinfo, is_no_action ? RI_PLAN_NO_ACTION : RI_PLAN_RESTRICT);/* SELECT 1 FROM [ONLY] <fktable> x WHERE $1 = fkatt1 [AND ...] * FOR KEY SHARE OF x */is_no_action 플래그는 교재의 NO ACTION 대 RESTRICT 구분을 코드로 구현한 것이다. NO ACTION은 먼저 ri_Check_Pk_Match를 호출해 다른 부모 행이 삭제된 키를 공급하는지 확인한다(NO ACTION은 문장 끝에만 검사되므로, 같은 문장의 후속 연산이 이를 다시 만족시킬 수 있다). RESTRICT는 어떤 대체도 허용하지 않고 즉시 실패한다. CASCADE, SET NULL, SET DEFAULT는 SELECT 대신 자식 테이블에 대한 생성된 DELETE/UPDATE 문을 실행해 부모 변경을 하위로 전파한다.
계획 캐시를 인덱싱하는 RI_PLAN_* 코드들은 이 쿼리 형태들을 정확히 열거한다.
// RI plan-type codes — src/backend/utils/adt/ri_triggers.c#define RI_PLAN_CHECK_LOOKUPPK 1 /* child INSERT/UPDATE check */#define RI_PLAN_CHECK_LOOKUPPK_FROM_PK 2#define RI_PLAN_CASCADE_ONDELETE 3#define RI_PLAN_CASCADE_ONUPDATE 4#define RI_PLAN_NO_ACTION 5#define RI_PLAN_RESTRICT 6#define RI_PLAN_SETNULL_ONDELETE 7#define RI_PLAN_SETNULL_ONUPDATE 8#define RI_PLAN_SETDEFAULT_ONDELETE 9#define RI_PLAN_SETDEFAULT_ONUPDATE 10FK별 메타데이터 캐시와 무효화
섹션 제목: “FK별 메타데이터 캐시와 무효화”매 행마다 pg_constraint를 다시 읽고 동등 연산자, attnum 배열, 매치 타입을 재계산하면 준비된 계획의 이점이 사라진다. ri_triggers.c는 두 번째 캐시인 RI_ConstraintInfo를 유지한다. FK 제약 조건 OID당 항목 하나이며, 트리거가 필요한 모든 정보를 담는다.
// RI_ConstraintInfo — src/backend/utils/adt/ri_triggers.ctypedef struct RI_ConstraintInfo{ Oid constraint_id; /* OID of pg_constraint entry (hash key) */ bool valid; /* successfully initialized? */ ... char confupdtype; /* foreign key's ON UPDATE action */ char confdeltype; /* foreign key's ON DELETE action */ char confmatchtype; /* foreign key's match type */ int nkeys; /* number of key columns */ int16 pk_attnums[RI_MAX_NUMKEYS]; /* attnums of referenced cols */ int16 fk_attnums[RI_MAX_NUMKEYS]; /* attnums of referencing cols */ Oid pf_eq_oprs[RI_MAX_NUMKEYS]; /* equality operators (PK = FK) */ ... dlist_node valid_link; /* Link in list of valid entries */} RI_ConstraintInfo;ri_LoadConstraintInfo는 첫 사용 시 시스템캐시에서 항목을 채우고 valid를 설정한다. DDL로 제약 조건 정의가 바뀌거나 삭제될 수 있으므로, 이 모듈은 시스템캐시 무효화 콜백을 등록한다. 콜백은 기반 pg_constraint 행이 변경될 때 캐시 항목을 폐기한다.
// ri_triggers.c — registered at first cache initCacheRegisterSyscacheCallback(CONSTROID, InvalidateConstraintCacheCallBack, (Datum) 0);InvalidateConstraintCacheCallBack은 ri_constraint_cache_valid_list를 순회하며 영향받은 항목에 valid = false를 설정하고, 다음 사용 시 재적재를 강제한다. 이것은 relcache와 다른 백엔드 캐시들이 쓰는 것과 같은 시스템캐시-콜백 패턴이다.
flowchart TD
A["자식 행에 INSERT/UPDATE"] --> B["AFTER ROW 트리거 발동<br/>RI_FKey_check_ins / _upd"]
B --> C["ri_FetchConstraintInfo<br/>RI_ConstraintInfo 캐시 (FK OID별)"]
C --> D{"ri_NullCheck<br/>MATCH 의미론"}
D -->|"전부 또는 일부 NULL 통과"| Z["OK — SQL 없음"]
D -->|"완전 비-NULL 키"| E["ri_BuildQueryKey<br/>RI_PLAN_CHECK_LOOKUPPK"]
E --> F{"ri_FetchPreparedPlan<br/>캐시된 SPI 계획?"}
F -->|"미스"| G["SQL 빌드 + ri_PlanCheck<br/>SPI_prepare, ri_HashPreparedPlan"]
F -->|"히트"| H["SPIPlanPtr 재사용"]
G --> H
H --> I["SELECT 1 FROM pk x WHERE ...<br/>FOR KEY SHARE OF x"]
I -->|"행 발견 + 키 공유 잠금"| Z
I -->|"행 없음"| J["ereport ERRCODE_FOREIGN_KEY_VIOLATION"]
지연 가능 제약 조건은 AFTER-트리거 큐를 탄다
섹션 제목: “지연 가능 제약 조건은 AFTER-트리거 큐를 탄다”FK 강제가 AFTER ROW 트리거이기 때문에, 지연은 저절로 따라온다. pg_constraint 행의 condeferrable / condeferred는 트리거의 tgdeferrable / tginitdeferred로 직접 매핑된다. 수정된 행은 지연 트리거 이벤트를 큐에 넣는다. 이벤트는 즉시 모드라면 문장 끝에서, SET CONSTRAINTS ... IMMEDIATE 또는 커밋(지연 모드)에서 소진된다. 지연 이벤트 큐 자체는 트리거 메커니즘(postgres-triggers.md)에 있으며, 제약 조건은 플래그와 트리거 함수만 기여한다. NO ACTION만 지연될 수 있는 이유도 여기 있다. 연쇄 액션은 행을 변경하며 트리거 문장을 넘어 의미있게 지연될 수 없으므로, createForeignKeyActionTriggers가 NO ACTION을 제외한 모든 것에 deferrable = false를 강제한다.
도메인도 CHECK와 NOT NULL을 가진다
섹션 제목: “도메인도 CHECK와 NOT NULL을 가진다”제약 조건은 테이블만의 기능이 아니다. DOMAIN은 기반 타입에 제약 조건을 더한 것이며, typecmds.c는 동일한 pg_constraint 메커니즘을 재사용한다. conrelid 대신 contypid(도메인 OID)를 키로 쓴다. domainAddCheckConstraint는 CHECK 표현식을 변환해 특수 VALUE 식별자를 CoerceToDomainValue 노드로 교체한 뒤 StoreConstraints / CreateConstraintEntry로 저장한다.
// domainAddCheckConstraint — src/backend/commands/typecmds.cAssert(constr->contype == CONSTR_CHECK);.../* Set up a CoerceToDomainValue to represent the occurrence of VALUE */domVal = makeNode(CoerceToDomainValue);domVal->typeId = baseTypeOid;domVal->typeMod = typMod;domVal->collation = get_typcollation(baseTypeOid);domVal->location = -1;pstate->p_pre_columnref_hook = replace_domain_constraint_value;pstate->p_ref_hook_state = domVal;expr = transformExpr(pstate, constr->raw_expr, EXPR_KIND_DOMAIN_CHECK);expr = coerce_to_boolean(pstate, expr, "CHECK");.../* Domains don't allow variables */if (pstate->p_rtable != NIL || contain_var_clause(expr)) ereport(ERROR, (errcode(ERRCODE_INVALID_COLUMN_REFERENCE), errmsg("cannot use table references in domain check constraint")));테이블 CHECK와의 결정적 차이는 도메인 CHECK가 어떤 컬럼도 참조할 수 없다는 점이다. 스칼라 VALUE만 볼 수 있다. 이미 사용 중인 타입에 도메인 제약 조건을 추가하면 validateDomainCheckConstraint / validateDomainNotNullConstraint가 도메인 타입을 쓰는 모든 테이블 컬럼을 스캔해 기존 데이터를 소급 검증한다. 테이블의 VALIDATE CONSTRAINT와 동일한 방식이다.
소스 워크스루
섹션 제목: “소스 워크스루”제약 조건 코드는 카탈로그/강제 축을 따라 깔끔하게 나뉜다. 카탈로그 측(pg_constraint.c, heap.c)은 모든 종류가 공유한다. 강제 측은 실행기(CHECK/NOT NULL), nbtree(UNIQUE/PK), ri_triggers.c(FK)로 분기한다.
카탈로그 작성 (pg_constraint.c)
섹션 제목: “카탈로그 작성 (pg_constraint.c)”CreateConstraintEntry가 단일 분기점이다. 매개변수 목록은 모든 제약 조건 종류의 필요를 합집합한 것이며, 호출자는 해당하지 않는 필드에 InvalidOid / NULL / ' '를 전달한다. 행을 삽입한 후 의존성을 기록한다. 소유 릴레이션/컬럼에는 자동 의존성, 참조된 FK 릴레이션, 그 유일 인덱스, pf/pp/ff 동등 연산자들에는 일반 의존성을 기록해 DROP이 올바르게 연쇄되게 한다. contype이 판별자이고, conbin이 평탄화된 CHECK 트리를 담으며, conindid가 UNIQUE/PK의 뒷받침 유일 인덱스를 가리키고, confrelid / confupdtype / confdeltype / confmatchtype이 FK 대상과 참조 액션을 전달한다.
CHECK / NOT NULL 저장 (heap.c)
섹션 제목: “CHECK / NOT NULL 저장 (heap.c)”StoreRelCheck는 파싱된 표현식을 nodeToString으로 평탄화하고, pull_var_clause로 참조된 Var들을 뽑아 컬럼 의존성을 계산한 뒤, isDeferrable = false로 CONSTRAINT_CHECK와 함께 CreateConstraintEntry를 호출한다. StoreRelNotNull은 CONSTRAINT_NOTNULL 경로에서 단일 attnum과 표현식 없이 같은 작업을 한다. PG18부터 NOT NULL 제약 조건은 NOT VALID로 선언하거나, 상속하거나, 독립적으로 검증할 수 있는 1등 시민 이름 있는 카탈로그 행이다. 두 제약 조건 모두 실행기의 ExecConstraints로 강제되며(postgres-executor.md 참조), 트리거로는 절대 강제되지 않는다.
UNIQUE / PRIMARY KEY (index.c)
섹션 제목: “UNIQUE / PRIMARY KEY (index.c)”index_constraint_create는 유일 인덱스가 생성된 후에 호출되며, conindid를 새 인덱스로 설정해 CONSTRAINT_UNIQUE 또는 CONSTRAINT_PRIMARY로 CreateConstraintEntry를 호출한다. 제약 조건은 내부 의존성으로 인덱스를 소유한다(제약 조건 삭제 → 인덱스 삭제). 강제는 전적으로 인덱스 AM에 위임된다. 중복은 nbtree의 _bt_check_unique에서 걸린다(postgres-nbtree.md, postgres-index-creation.md). PRIMARY KEY는 각 키 컬럼에 대한 NOT NULL도 함의한다.
FK 트리거 생성 (tablecmds.c)
섹션 제목: “FK 트리거 생성 (tablecmds.c)”CreateFKCheckTrigger는 RI_FKey_check_ins / RI_FKey_check_upd를 가리키는 자식 측 AFTER INSERT/AFTER UPDATE 트리거를 구성한다. createForeignKeyActionTriggers는 fk_del_action / fk_upd_action으로 함수를 선택해 부모 측 AFTER DELETE/AFTER UPDATE 트리거를 구성하며, NO ACTION을 제외한 모든 액션에 deferrable = false를 강제한다. createForeignKeyCheckTriggers가 두 검사 트리거 생성을 감싼다. 모든 트리거는 tgisinternal로 표시되고 tgconstraint로 제약 조건 OID와 연결된다.
RI 런타임 (ri_triggers.c)
섹션 제목: “RI 런타임 (ri_triggers.c)”런타임 진입점은 RI_FKey_* 트리거 함수들이다. RI_FKey_check(자식 INSERT/UPDATE), ri_restrict(RI_FKey_noaction_*과 RI_FKey_restrict_*가 공유), RI_FKey_cascade_del / RI_FKey_cascade_upd, RI_FKey_setnull_* / RI_FKey_setdefault_*가 있다. 각 함수는 RI_ConstraintInfo(ri_FetchConstraintInfo → ri_LoadConstraintInfo)를 가져오고, MATCH 의미론을 위해 ri_NullCheck를 실행하며, RI_QueryKey를 구성하고, SPI 계획을 가져오거나 준비한(ri_FetchPreparedPlan / ri_PlanCheck / ri_HashPreparedPlan) 뒤 ri_PerformCheck로 실행한다. 캐시 무효화는 CONSTROID에 등록된 InvalidateConstraintCacheCallBack으로 처리된다.
일괄 검증 (ri_triggers.c, tablecmds.c)
섹션 제목: “일괄 검증 (ri_triggers.c, tablecmds.c)”RI_Initial_Check는 VALIDATE CONSTRAINT / ADD CONSTRAINT on a populated table을 위한 빠른 경로다. 기존 자식 행 각각에 행별 검사 트리거를 발동하는 대신, 하나의 anti-join 쿼리를 실행한다. 부모와 매치되는 행이 없는 자식 행이 바로 위반이다.
// RI_Initial_Check — src/backend/utils/adt/ri_triggers.c/* SELECT fk.keycols FROM [ONLY] relname fk * LEFT OUTER JOIN [ONLY] pkrelname pk * ON (pk.pkkeycol1=fk.keycol1 [AND ...]) * WHERE pk.pkkeycol1 IS NULL AND * (fk.keycol1 IS NOT NULL [AND ...]) -- MATCH SIMPLE */appendStringInfo(&querybuf, " FROM %s%s fk LEFT OUTER JOIN %s%s pk ON", fk_only, fkrelname, pk_only, pkrelname);...appendStringInfo(&querybuf, ") WHERE pk.%s IS NULL AND (", pkattname);RI_Initial_Check를 사용할 수 없는 경우(예: 권한 제한으로 행별 스캔이 강제될 때) validateForeignKeyConstraint는 검사 트리거를 행 하나씩 발동하는 방식으로 폴백한다. ATExecValidateConstraint가 명령을 조율한다. 이 함수는 pg_constraint 행을 찾고, 검증 불가 타입을 거부하며, validateForeignKeyConstraint(RI_Initial_Check 선호)로 FK 검증을 실행하거나 CHECK / NOT NULL 경로에서 힙을 스캔한 뒤 convalidated = true로 뒤집고 카탈로그 행을 갱신한다.
flowchart TD
A["ALTER TABLE ... ADD CONSTRAINT ... NOT VALID"] --> B["CreateConstraintEntry<br/>convalidated = false"]
B --> C["새 행은 즉시 강제됨<br/>(트리거 / ExecConstraints 활성)"]
C --> D["ALTER TABLE ... VALIDATE CONSTRAINT"]
D --> E["ATExecValidateConstraint<br/>pg_constraint 행 조회"]
E --> F{"contype?"}
F -->|"'f' FK"| G["validateForeignKeyConstraint<br/>-> RI_Initial_Check anti-join"]
F -->|"'c' CHECK / 'n' NOT NULL"| H["힙 스캔, 기존 행마다<br/>서술어 평가"]
G --> I["convalidated = true 설정<br/>CatalogTupleUpdate"]
H --> I
위치 힌트 (2026-06-06 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-06 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
CreateConstraintEntry | src/backend/catalog/pg_constraint.c | 51 |
CONSTRAINT_CHECK … CONSTRAINT_EXCLUSION | src/include/catalog/pg_constraint.h | (매크로) |
StoreRelCheck | src/backend/catalog/heap.c | 2164 |
StoreRelNotNull | src/backend/catalog/heap.c | 2271 |
index_constraint_create | src/backend/catalog/index.c | 1885 |
FKCONSTR_ACTION_* | src/include/nodes/parsenodes.h | (매크로) |
CreateFKCheckTrigger | src/backend/commands/tablecmds.c | 13805 |
createForeignKeyActionTriggers | src/backend/commands/tablecmds.c | 13868 |
createForeignKeyCheckTriggers | src/backend/commands/tablecmds.c | 14003 |
RI_FKey_check | src/backend/utils/adt/ri_triggers.c | 250 |
ri_restrict | src/backend/utils/adt/ri_triggers.c | 712 |
RI_FKey_noaction_del | src/backend/utils/adt/ri_triggers.c | 639 |
RI_FKey_cascade_del | src/backend/utils/adt/ri_triggers.c | 915 |
RI_FKey_cascade_upd | src/backend/utils/adt/ri_triggers.c | 1017 |
RI_FKey_setnull_del | src/backend/utils/adt/ri_triggers.c | 1134 |
RI_ConstraintInfo (구조체) | src/backend/utils/adt/ri_triggers.c | 106 |
RI_QueryKey (구조체) | src/backend/utils/adt/ri_triggers.c | 142 |
RI_PLAN_CHECK_LOOKUPPK … RI_PLAN_SETDEFAULT_ONUPDATE | src/backend/utils/adt/ri_triggers.c | 71–83 |
ri_BuildQueryKey | src/backend/utils/adt/ri_triggers.c | 2136 |
ri_FetchConstraintInfo | src/backend/utils/adt/ri_triggers.c | 2214 |
ri_LoadConstraintInfo | src/backend/utils/adt/ri_triggers.c | 2268 |
InvalidateConstraintCacheCallBack | src/backend/utils/adt/ri_triggers.c | 2400 |
ri_PlanCheck | src/backend/utils/adt/ri_triggers.c | 2441 |
ri_PerformCheck | src/backend/utils/adt/ri_triggers.c | 2484 |
ri_FetchPreparedPlan | src/backend/utils/adt/ri_triggers.c | 2896 |
ri_HashPreparedPlan | src/backend/utils/adt/ri_triggers.c | 2948 |
RI_Initial_Check | src/backend/utils/adt/ri_triggers.c | 1519 |
ATExecValidateConstraint | src/backend/commands/tablecmds.c | 12916 |
validateForeignKeyConstraint | src/backend/commands/tablecmds.c | 13705 |
domainAddCheckConstraint | src/backend/commands/typecmds.c | 3512 |
domainAddNotNullConstraint | src/backend/commands/typecmds.c | 3672 |
validateDomainCheckConstraint | src/backend/commands/typecmds.c | 3203 |
validateDomainNotNullConstraint | src/backend/commands/typecmds.c | 3138 |
소스 검증 (2026-06-06 기준)
섹션 제목: “소스 검증 (2026-06-06 기준)”아래 모든 심볼, 구조체 필드, 매크로 값, 생성 SQL 단편은
/data/hgryoo/references/postgres, REL_18_STABLE, 커밋
273fe94852b3a7e34fd171e8abdf1481beb302fa의 작업 트리에서 직접 읽었다.
검증된 사실
섹션 제목: “검증된 사실”- 카탈로그 작성 함수 하나.
CreateConstraintEntry(pg_constraint.c:51)는 모든 제약 조건 종류의 단일pg_constraint행을 작성한다.contype은'c' 'f' 'n' 'p' 'u' 'x'값을 갖는char판별자다(pg_constraint.h).values[Anum_pg_constraint_*]할당(condeferrable,condeferred,conenforced,convalidated,conindid,confrelid,confupdtype,confdeltype,confmatchtype)을 확인했다. - CHECK/NOT NULL은 지연 불가 표현식.
StoreRelCheck(heap.c:2164)는 deferrable/deferred에false, false를 전달하고conbin(=nodeToString(expr))을 저장한다.StoreRelNotNull(heap.c:2271)은 표현식 없이 단일 attnum을 저장한다. 강제는ExecConstraints(실행기)에서 이루어지며, 트리거가 아니다. - UNIQUE/PK는 인덱스에 위임.
index_constraint_create(index.c:1885)가conindid를 설정하며, 표현식도 트리거도 없다. - FK = 최대 네 개의 내부 트리거.
CreateFKCheckTrigger(tablecmds.c:13805)가 자식 검사 트리거를 생성하고,createForeignKeyActionTriggers(tablecmds.c:13868)가 부모 액션 트리거를 생성하며FKCONSTR_ACTION_NOACTION을 제외한 모든 액션에deferrable = false를 강제한다. 액션 코드 매크로(FKCONSTR_ACTION_NOACTION 'a',RESTRICT 'r',CASCADE 'c',SETNULL 'n',SETDEFAULT 'd')는parsenodes.h에 있다. - RI는 키 공유 잠금과 함께 생성 SQL을 실행.
RI_FKey_check(ri_triggers.c:250)는pk_rel을RowShareLock으로 열고SELECT 1 FROM <pk> x WHERE pkatt1=$1 ... FOR KEY SHARE OF x를 발행한다(줄 371–372의 주석, 402/407에서 구성).ri_restrict(ri_triggers.c:712)는NO ACTION/RESTRICT경로를 공유하며is_no_action ? RI_PLAN_NO_ACTION : RI_PLAN_RESTRICT로 자식을 탐색한다. - 두 가지 캐시. FK별 메타데이터는
RI_ConstraintInfo에 캐시된다(구조체는ri_triggers.c:106, 필드로confupdtype,confdeltype,confmatchtype,pk_attnums[],fk_attnums[],pf_eq_oprs[]포함). 생성된 SPI 계획은RI_QueryKey(구조체는 줄 142;{constr_id, constr_queryno})를 키로ri_FetchPreparedPlan/ri_HashPreparedPlan이 캐시한다. 메타데이터 캐시는CONSTROID에 등록된InvalidateConstraintCacheCallBack(줄 2400, 줄 2871에서 등록)으로 무효화된다. RI_PLAN_*코드1…10(ri_triggers.c:71–83)은 생성 쿼리 형태를 열거한다(LOOKUPPK, CASCADE_ON{DELETE,UPDATE}, NO_ACTION, RESTRICT, SETNULL_ON*, SETDEFAULT_ON*).- NOT VALID / VALIDATE.
convalidated = false인 새 제약 조건은 새 행에 즉시 강제된다.ATExecValidateConstraint(tablecmds.c:12916)는 소급 검증하고convalidated = true로 뒤집는다.CONSTRAINT_FOREIGN,CONSTRAINT_CHECK,CONSTRAINT_NOTNULL만 허용하고(명시적contype가드 확인),NOT ENFORCED제약 조건을 거부하며, FK 검증은RI_Initial_Check(ri_triggers.c:1519)를 선호한다. 단일LEFT OUTER JOIN ... WHERE pk.<col> IS NULLanti-join이다(1603–1606 줄 주석, 1633/1665에서 구성). - 도메인.
domainAddCheckConstraint(typecmds.c:3512)는contypid로 키된 같은 카탈로그 경로를 재사용하고,VALUE를CoerceToDomainValue로 교체하며, 컬럼 참조를 거부한다(contain_var_clause).domainAddNotNullConstraint(typecmds.c:3672)는 NOT NULL 대응이고,validateDomainCheckConstraint(3203) /validateDomainNotNullConstraint(3138)는 기존 데이터를 소급 검증한다.
미해결 질문
섹션 제목: “미해결 질문”- PG18의 NOT NULL 1등 시민 제약 조건은 여러 호출 지점을 변경했다(
StoreRelNotNull, 상속 처리,ATExecValidateConstraint의CONSTRAINT_NOTNULL허용). 파티셔닝된 부모의NOT VALID로 선언된 상속NOT NULL과 그 파티션들 사이의 상호작용은 복잡하다. 이 문서는 단일 테이블 경로를 다루며, 상속/파티션 재귀는postgres-partitioning.md와postgres-alter-table.md에 위임한다. - 시간적 /
PERIOD외래 키(riinfo->hasperiod,RI_ConstraintInfo의range_agg/<@연산자)는 검사 쿼리에HAVING $n <@ range_agg(...)절을 추가한다. 이 문서는 해당 분기를 언급하지만 시간적 RI 변형을 완전히 추적하지는 않는다.
PostgreSQL 너머 — 비교 설계와 연구 방향
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 방향”PostgreSQL의 세 메커니즘 분할(인라인 표현식, 뒷받침 인덱스, 트리거 구동 SQL)은 더 넓은 설계 공간의 한 점이다.
트리거 대 네이티브 RI 엔진. FK를 평범한 AFTER 트리거로 구현하는 것은 우아하다. 지연, 행별 발동, 스냅샷 의미론이 트리거 메커니즘에서 무료로 제공된다. 하지만 수정된 행 하나당 SPI 왕복 비용을 지불한다. RI 검사를 SQL 계층으로 재진입하는 방식 대신 실행기의 수정 노드에 직접 컴파일하는 엔진은 SPI 오버헤드를 피하지만, 조인/잠금 로직을 중복시키는 비용을 치른다. PostgreSQL의 RI_Initial_Check anti-join 자체가 행별 트리거 경로가 일괄 검증에 너무 느리다는 인정이며, 일괄 케이스를 단일 집합 지향 쿼리로 특수 처리한다. 이 행별-대-집합 긴장은 제약 조건 관리의 반복 주제이며, Database System Concepts(Silberschatz/Korth/Sudarshan) 4장 §4.4 “Integrity Constraints”와 §5.3의 단언/트리거 논의에서 자세히 다룬다.
지연 제약 조건 검사와 격리. SELECT ... FOR KEY SHARE 잠금은 동시 RI 정확성의 미묘한 핵심이다. 동시에 부모를 삭제해 자식을 고아로 만들 수 있는 연산을 막을 만큼 강해야 하지만, 관련 없는 부모 갱신을 직렬화하지 않을 만큼 약해야 한다. Database Internals(Petrov)는 트랜잭션 처리 장에서 이것을 팬텀(phantom) 및 서술어(predicate) 의존성의 일반 문제로 프레임한다. READ COMMITTED와 REPEATABLE READ 아래서 SSI 비용을 치르지 않고 동작하는 키 공유 행 잠금(서술어 잠금이나 완전 직렬성 대신)이 PostgreSQL의 실용적 중간 지점이다. SERIALIZABLE 아래서 RI 검사는 추가로 SSI 서술어 잠금에 참여한다(postgres-ssi-predicate-locking.md).
증분 뷰 관리로서의 제약 조건 관리. 외래 키는 더 일반적인 단언(“이 쿼리가 아무 행도 반환하지 않는다”)의 특수 케이스이고, 참조 액션 전파(CASCADE)는 증분 뷰 관리의 특수 케이스다. counting/DRed 알고리즘과 최근의 differential-dataflow 시스템 같은 연구 분야가 제약 조건 관리를 임의의 구체화된 단언으로 일반화한다. PostgreSQL이 표준의 가장 일반적 제약 조건인 SQL ASSERTION을 의도적으로 구현하지 않는 이유가 여기 있다. 임의 다중 테이블 서술어의 효율적 증분 관리는 일반 케이스에서 미해결 문제다. FK당 트리거 설계는 제한된 행별 작업으로 증분 관리할 수 있는 케이스들로의 실용적 제한이다.
동시성 아래 검증. NOT VALID → VALIDATE CONSTRAINT 분할은 동시성 제어 기법이다. 선언은 약한 잠금을 취하고 스캔을 건너뛴다. 검증은 동시 읽기와 쓰기를 허용하는 잠금 아래 비용이 큰 검사를 실행한다. 이것은 CREATE INDEX CONCURRENTLY(postgres-index-creation.md)와 같은 철학이며, 긴 DDL을 빠른 메타데이터 단계와 동시성 친화적 데이터 단계로 분해하는 PostgreSQL의 더 넓은 전략의 일부다. 이 분야의 프론티어는 완전 온라인 제약 조건 추가다. CockroachDB의 스키마 변경 프로토콜, Google F1/Spanner의 온라인 스키마 변경 같은 여러 분산 SQL 시스템이 다중 버전, 다중 상태 스키마 요소를 사용해 이것을 더 밀고 나간다.
트리 내 소스 파일 (REL_18_STABLE, 커밋 273fe94)
섹션 제목: “트리 내 소스 파일 (REL_18_STABLE, 커밋 273fe94)”src/backend/catalog/pg_constraint.c—CreateConstraintEntry, 단일 카탈로그 행 작성 및 의존성 기록.src/backend/catalog/heap.c—StoreRelCheck,StoreRelNotNull(CHECK / NOT NULL 저장).src/backend/catalog/index.c—index_constraint_create(UNIQUE / PRIMARY KEY →conindid).src/backend/commands/tablecmds.c—CreateFKCheckTrigger,createForeignKeyActionTriggers,createForeignKeyCheckTriggers,ATExecValidateConstraint,validateForeignKeyConstraint.src/backend/utils/adt/ri_triggers.c— RI 런타임:RI_FKey_check,ri_restrict,RI_FKey_cascade_*,RI_FKey_setnull_*,RI_Initial_Check,RI_ConstraintInfo/RI_QueryKey캐시,InvalidateConstraintCacheCallBack.src/backend/commands/typecmds.c—domainAddCheckConstraint,domainAddNotNullConstraint,validateDomainCheckConstraint,validateDomainNotNullConstraint(도메인 제약 조건).src/include/catalog/pg_constraint.h—contype매크로와 카탈로그 구조체.src/include/nodes/parsenodes.h—FKCONSTR_ACTION_*와FKCONSTR_MATCH_*코드.
교재 근거
섹션 제목: “교재 근거”- Silberschatz, Korth, Sudarshan, Database System Concepts — 4장 “Intermediate SQL”, §4.4 “Integrity Constraints” (CHECK, 참조 무결성, 참조 액션); §5.3 (트리거와 단언).
knowledge/research/dbms-general/에 수록됨. - Petrov, Database Internals — 잠금 세분성과 서술어/팬텀 의존성에 대한 트랜잭션 처리 장. RI 검사에 쓰이는
FOR KEY SHARE잠금의 개념적 근거.
관련 모듈 문서 (상호 참조, 중복 없음)
섹션 제목: “관련 모듈 문서 (상호 참조, 중복 없음)”postgres-nbtree.md,postgres-index-creation.md— UNIQUE/PK 인덱스 구축 방법,_bt_check_unique의 중복 거부.postgres-triggers.md— FK 지연이 올라타는 AFTER-트리거 이벤트 큐.postgres-ddl-execution.md,postgres-alter-table.md—ALTER TABLE ... ADD CONSTRAINT명령 배관과 상속 재귀.postgres-executor.md—ExecConstraints, 행별 CHECK / NOT NULL 강제 경로.postgres-ssi-predicate-locking.md— SERIALIZABLE 격리 수준 아래 RI.