(KO) PostgreSQL DDL 실행 — ProcessUtility 디스패치, 단순/느린 분리, 그리고 CREATE TABLE 경로
목차
이론적 배경
섹션 제목: “이론적 배경”모든 관계형 엔진은 구문 집합을 두 계열로 나눈다. 첫 번째 계열은 SELECT, INSERT, UPDATE, DELETE, MERGE다. 비용 기반 플래너가 최적화할 수 있다는 점이다. 플래너는 통계를 살피고 대안 플랜을 생성해 가장 저렴한 것을 고른 뒤 실행기 트리를 만든다. 두 번째 계열은 CREATE TABLE, ALTER TABLE, CREATE INDEX, DROP, GRANT 등이다. 이들은 비용 추정 자체가 불가능하다. 작업이 구조적이기 때문이다. 사용자 행의 내용이 아니라 카탈로그의 모양을 바꾼다는 뜻이다. Database System Concepts(Silberschatz et al., 7th ed., §5.2)는 이 두 번째 계열을 **DDL(Data Definition Language)**이라 부르고, 그 역할을 스키마 유지로 정의한다. 스키마란 이후 모든 DML 구문이 컴파일되는 기반인 릴레이션 정의, 타입 주석, 무결성 제약, 접근 제어 규칙의 집합이다.
표준 DDL 파이프라인은 엔진에 관계없이 다섯 단계로 이루어진다.
- 파싱 — DDL 구문을 정의 트리로 변환한다. 정의되는 객체와 그 속성이 모두 명명된 트리다.
- 검증 — 기존 카탈로그와 대조해 정의를 확인한다. 네임스페이스 유일성, 타입 해석, 권한 검사가 여기에 해당한다.
- 온-디스크 아티팩트 물화 — 새 테이블의 힙 파일, 새 인덱스의 인덱스 파일을 할당한다.
- 카탈로그 행 쓰기 —
pg_class,pg_attribute,pg_index,pg_constraint등 관련 카탈로그 테이블에 행을 삽입 또는 갱신해서 다른 백엔드가 새 객체를 발견할 수 있게 한다. - 캐시 무효화 — 변경된 객체를 참조하는 캐시된 플랜이나 relcache 엔트리를 다음 컴파일 전에 버리도록 모든 백엔드에 브로드캐스트한다.
이 다섯 단계 전체에 영향을 미치는 설계 축이 두 가지 있다.
트랜잭션 DDL vs. 묵시적 커밋 DDL. PostgreSQL, SQL Server, DB2는 DDL을 DML과 동일한 트랜잭션 및 WAL 복구 프레임워크 안에서 실행한다. CREATE TABLE 다음에 ROLLBACK을 실행하면 CREATE TABLE이 없었던 것과 같다. 카탈로그 행이 단순히 커밋되지 않는다. Oracle과 MySQL 8.0 이전 버전은 DDL을 묵시적 커밋으로 처리한다. 엔진이 DDL을 실행하기 전에 현재 트랜잭션을 커밋한다는 뜻이다. 이 방식은 카탈로그 변경을 언두 로그에 기록할 필요가 없지만, DDL이 세션 안에서 되돌릴 수 없게 만든다. PostgreSQL의 선택 — 완전한 트랜잭션 DDL — 은 모든 카탈로그 변경을 WAL에 기록하고 언두 가능하게 요구한다. 이는 카탈로그 쓰기 경로가 사용자 데이터 경로와 동일한 락·MVCC 기계에 참여해야 함을 의미한다.
스키마 변경 DDL vs. 데이터 이동 DDL. 순수 메타데이터 DDL(CREATE TABLE, DROP INDEX)은 새 객체에 짧은 배타 락을 걸고 — 다른 세션은 아직 이 객체를 볼 수 없다 — 카탈로그 행 몇 개를 쓴 뒤 밀리초 안에 끝난다. 데이터를 건드리는 DDL(ALTER TABLE ... ADD COLUMN c NOT NULL DEFAULT expr, 파티션 재조직)은 기존 행을 스캔하고 다시 써야 한다. 테이블에 AccessExclusiveLock을 걸고 몇 시간씩 실행될 수 있으며, 그 사이 모든 동시 읽기를 블록한다. 이 분리는 코드에 뚜렷하게 반영돼 있다. PostgreSQL은 단순한 데이터 이동 없는 경우를 인라인으로 처리하는 standard_ProcessUtility와, 모든 명령을 이벤트 트리거 펜스로 감싸고 commands/tablecmds.c 계열 모듈로 디스패치하는 ProcessUtilitySlow를 분리한다.
CommandCounterIncrement를 통한 캐시 무효화. 단일 트랜잭션 안에서 한 구문이 작성한 카탈로그 변경은, 그 트랜잭션이 커밋되기 전에 이후 구문들에게 가시화돼야 한다. PostgreSQL은 트랜잭션별 명령 카운터(CommandId)로 이 문제를 해결한다. CommandCounterIncrement() 호출 하나가 카운터를 올리면, 이전 명령이 쓴 행이 다음 명령의 스냅샷에 보인다. DDL 경로는 정확한 위치에서 이 함수를 호출한다. pg_class를 쓴 직후에 한 번 호출해 새 테이블 튜플을 가시화하고, 기본값 표현식을 추가한 후에 한 번 더 호출해 생성 컬럼이 다음 제약 처리에서 참조될 수 있게 한다. 다른 백엔드로 relcache 무효화를 전파하는 sinval 메시지 브로드캐스트는 postgres-cache-invalidation.md에서 다룬다. 이 문서는 트랜잭션 안의 명령 카운터 규율에 집중한다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”교과서가 모델을 제시하고, 이 절은 거의 모든 DBMS가 DDL을 구현할 때 채택하는 공학적 관례를 정리한다. §“PostgreSQL의 접근법”에서 PostgreSQL의 구체적인 선택은 이 공유 공간 안의 하나의 다이얼 세트로 읽힌다.
비최적화 구문을 위한 단일 디스패치 관문
섹션 제목: “비최적화 구문을 위한 단일 디스패치 관문”DML과 DDL을 구분하는 엔진은 모두 비최적화 구문 집합 전체를 올바른 핸들러로 라우팅하는 단일 체크포인트가 필요하다. 플래너는 DDL을 건드리지 않으므로, 별도의 디스패치 함수가 비최적화 구문 전체의 현관 역할을 한다. 이 함수는 다음 작업의 자연스러운 위치이기도 하다.
- 읽기 전용 트랜잭션 규칙 강제 —
CREATE TABLE은 읽기 전용 트랜잭션 안이나 일관된 스냅샷을 찍는 백업 중에 실행되면 안 된다. - 병렬 모드 제한 확인 — 많은 DDL 구문은 병렬 워커 컨텍스트 안에서 실행될 수 없다.
- 익스텐션 훅 발동 — 로드 가능한 모듈이 내장 동작을 가로채거나 대체할 수 있게 한다.
dispatch_gate → extension_hook_or_default → per_command_module 패턴은 보편적이다. PostgreSQL의 ProcessUtility → ProcessUtility_hook → standard_ProcessUtility가 그 표준 사례다.
이벤트 트리거 지원 여부에 따른 단순/느린 분리
섹션 제목: “이벤트 트리거 지원 여부에 따른 단순/느린 분리”이벤트 트리거(PostgreSQL의 명령 수준 콜백 — “이 DDL 명령 전에 발동”, “이 DDL 명령 완료 후 발동”)는 엔진이 구문 실행 전체에 걸쳐 명령별 컨텍스트 객체를 유지하고, 영향받는 객체를 거기에 기록하며, 두 체크포인트(시작과 끝)에서 트리거를 발동시키도록 요구한다. 이 부기 작업은 비사소하며, 경량 경로(예: BEGIN, SET, CHECKPOINT)에 오버헤드를 추가하지 않고는 모든 구문에 후적용할 수 없다.
표준 패턴은 두 수준의 디스패치를 갖는 것이다.
- 빠른 경로는 이벤트 트리거를 전혀 지원하지 않거나 객체 타입에 따라 조건부로 지원하는 구문을 처리한다. 인라인으로 실행되며 트리거 컨텍스트를 할당하지 않는다.
- 느린 경로는 모든 명령을 트리거 펜스(
EventTriggerBeginCompleteQuery/EventTriggerDDLCommandStart/EventTriggerEndCompleteQuery)로 감싸고 명령별 핸들러로 위임한다.
PostgreSQL의 standard_ProcessUtility가 빠른 경로고, ProcessUtilitySlow가 느린 경로다.
명령 카운터 가시성을 이용한 단계별 구성
섹션 제목: “명령 카운터 가시성을 이용한 단계별 구성”DDL이 여러 단계로 객체를 생성할 때(힙 생성 → pg_attribute 행 쓰기 → 기본값 표현식 처리 → 제약 추가), 각 단계는 같은 트랜잭션 안에서 이전 단계의 결과를 볼 수 있어야 한다. 표준 메커니즘은 명령 카운터 또는 구문 카운터다. 이를 전진시키면 이전에 쓴 카탈로그 행이 다음 단계의 스냅샷에 보인다. Oracle의 Library Cache Lock과 PostgreSQL의 CommandCounterIncrement가 이 역할을 한다.
롤백을 지원하는 트랜잭션 카탈로그 쓰기
섹션 제목: “롤백을 지원하는 트랜잭션 카탈로그 쓰기”트랜잭션 DDL 엔진에서 카탈로그 행은 사용자 행과 동일한 MVCC 규칙이 적용되는 일반 힙 튜플이다. CREATE TABLE 중에 pg_class 행을 쓰는 것은 메커니즘 면에서 INSERT INTO pg_class (...)와 동일하다. 행은 현재 트랜잭션의 xmin 아래 삽입되며, 커밋 전까지 다른 트랜잭션에 보이지 않고, 트랜잭션이 롤백되면 vacuum이 제거한다. 이는 카탈로그 쓰기 경로가 단순히 시스템 카탈로그에 적용된 힙 쓰기 경로임을 뜻한다. DDL 롤백에는 표준 MVCC + WAL 조합 이상의 특별한 언두 기계가 필요 없다.
이론 ↔ PostgreSQL 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”| 개념 | PostgreSQL 이름 |
|---|---|
| DDL 디스패치 관문 | ProcessUtility — tcop/utility.c:499 |
| 익스텐션 훅 | ProcessUtility_hook (ProcessUtility_hook_type) |
| 기본 핸들러 | standard_ProcessUtility — tcop/utility.c:543 |
| 단순(이벤트 트리거 오버헤드 없음) 경로 | standard_ProcessUtility의 인라인 케이스 |
| 이벤트 트리거 전체 경로 | ProcessUtilitySlow — tcop/utility.c:1092 |
| 이벤트 트리거 펜스 열기 | EventTriggerBeginCompleteQuery + EventTriggerDDLCommandStart |
| 이벤트 트리거 펜스 닫기 | EventTriggerEndCompleteQuery |
| 정의 트리(파싱 출력) | CreateStmt, AlterTableStmt, … (nodes/parsenodes.h) |
| 명령별 의미 분석 | transformCreateStmt (parser/parse_utilcmd.c:164) |
| 테이블 생성 진입점 | DefineRelation — commands/tablecmds.c:764 |
| 카탈로그 행 작성자 | heap_create_with_catalog — catalog/heap.c:1139 |
| 물리 파일 할당자 | heap_create — catalog/heap.c:285 |
| 트랜잭션 내 가시성 전진 | CommandCounterIncrement |
| raw DEFAULT 표현식 처리기 | AddRelationNewConstraints — catalog/heap.c:2402 |
| 상속 체인 작성자 | StoreCatalogInheritance — commands/tablecmds.c:3521 |
PostgreSQL의 접근법
섹션 제목: “PostgreSQL의 접근법”디스패치 척추: ProcessUtility → standard_ProcessUtility → ProcessUtilitySlow
섹션 제목: “디스패치 척추: ProcessUtility → standard_ProcessUtility → ProcessUtilitySlow”DML이 아닌 모든 구문은 ProcessUtility로 진입한다. 이 함수의 역할은 단 하나다. 훅이 등록돼 있으면 훅에 제어를 넘기고, 없으면 standard_ProcessUtility로 넘기는 것이다.
// ProcessUtility — src/backend/tcop/utility.cProcessUtility(PlannedStmt *pstmt, const char *queryString, bool readOnlyTree, ProcessUtilityContext context, ParamListInfo params, QueryEnvironment *queryEnv, DestReceiver *dest, QueryCompletion *qc){ // ... if (ProcessUtility_hook) (*ProcessUtility_hook) (pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc); else standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc);}훅은 tcop/utility.c에서 ProcessUtility_hook_type ProcessUtility_hook = NULL로 선언되고, tcop/utility.h에서 PGDLLIMPORT로 공개된다. CREATE TABLE을 가로채고 싶은 익스텐션(예: 감사 로그 추가, 이름 규칙 강제)은 이 훅에 자신의 함수를 등록하고, 기본 경로를 유지하기 위해 내부에서 standard_ProcessUtility를 호출한다.
standard_ProcessUtility는 안전 검사(읽기 전용 트랜잭션 강제, 병렬 모드 제한, 스택 깊이)부터 시작한 뒤 파스 노드 태그로 분기한다. 이벤트 트리거를 지원하지 않거나 객체 타입에 따라 조건부로 지원하는 구문은 인라인으로 실행된다. 카탈로그를 변경하는 무거운 구문은 ProcessUtilitySlow로 전달된다. 예를 들어 T_DropStmt의 경우 다음과 같다.
// standard_ProcessUtility — src/backend/tcop/utility.ccase T_DropStmt: { DropStmt *stmt = (DropStmt *) parsetree; if (EventTriggerSupportsObjectType(stmt->removeType)) ProcessUtilitySlow(pstate, pstmt, queryString, context, params, queryEnv, dest, qc); else ExecDropStmt(stmt, isTopLevel); } break;T_CreateStmt(일반 CREATE TABLE)는 조건이 없다. 항상 ProcessUtilitySlow로 간다는 점이다. tcop/utility.c 529행의 주석이 설계 의도를 잘 담는다.
“standard_ProcessUtility 자체는 이벤트 트리거 지원이 없는 유틸리티 명령만 처리한다. 그런 지원이 있는 명령은 필요한 인프라를 갖춘 ProcessUtilitySlow로 전달된다. 이 분리는 성능만을 위한 것이 아니다. START TRANSACTION을 처리할 때 이벤트 트리거 코드가 호출되지 않아야 하는 것이 중요하다. 이벤트 트리거 캐시를 갱신해야 할 수 있는데, 이는 유효한 트랜잭션 안에 있어야 하기 때문이다.”
ProcessUtilitySlow는 이벤트 트리거 펜스를 열고, 노드 태그로 디스패치하며, 명령이 오류를 내도 PG_TRY/PG_FINALLY 블록에서 반드시 펜스를 닫는다.
// ProcessUtilitySlow — src/backend/tcop/utility.cProcessUtilitySlow(ParseState *pstate, PlannedStmt *pstmt, ...){ // ... bool isCompleteQuery = (context != PROCESS_UTILITY_SUBCOMMAND); needCleanup = isCompleteQuery && EventTriggerBeginCompleteQuery();
PG_TRY(); { if (isCompleteQuery) EventTriggerDDLCommandStart(parsetree);
switch (nodeTag(parsetree)) { case T_CreateStmt: case T_CreateForeignTableStmt: { List *stmts; // ... transform, then loop over stmts: stmts = transformCreateStmt((CreateStmt *) parsetree, queryString); while (stmts != NIL) { Node *stmt = (Node *) linitial(stmts); stmts = list_delete_first(stmts); if (IsA(stmt, CreateStmt)) { address = DefineRelation(cstmt, RELKIND_RELATION, InvalidOid, NULL, queryString); EventTriggerCollectSimpleCommand(address, ...); CommandCounterIncrement(); NewRelationCreateToastTable(address.objectId, ...); } // ... LIKE 절과 외부 테이블은 별도 처리 } } break; // ... 다른 T_* 케이스들 }
if (isCompleteQuery) { EventTriggerSQLDropAddTarget(/* ... */); EventTriggerDDLCommandEnd(parsetree); } } PG_FINALLY(); { if (needCleanup) EventTriggerEndCompleteQuery(); } PG_END_TRY();}그림 1 — 포털에서 카탈로그 작성자까지의 DDL 디스패치 흐름
flowchart TD PORTAL["PortalRunUtility\n(query-processing)"] --> PU["ProcessUtility\ntcop/utility.c"] PU -->|훅 등록됨| HOOK["ProcessUtility_hook\n익스텐션 코드"] PU -->|훅 없음| SPU["standard_ProcessUtility"] HOOK -.->|내부에서 재호출| SPU SPU -->|이벤트 트리거 지원 없음| INLINE["인라인 처리\nBeginTransactionBlock\nRequestCheckpoint 등"] SPU -->|조건부, 타입 불일치| INLINE2["인라인 ExecDropStmt\nExecRenameStmt 등"] SPU -->|카탈로그 변경 또는 조건부 일치| SLOW["ProcessUtilitySlow"] SLOW --> ETSTART["EventTriggerDDLCommandStart"] ETSTART --> DISPATCH["nodeTag 분기\nT_CreateStmt\nT_AlterTableStmt\nT_DropStmt\n..."] DISPATCH -->|CREATE TABLE| TRANSFORM["transformCreateStmt\nparse_utilcmd.c"] TRANSFORM --> DR["DefineRelation\ntablecmds.c"] DR --> HCC["heap_create_with_catalog\ncatalog/heap.c"] HCC --> HC["heap_create\ncatalog/heap.c"] HC -->|물리 파일| SMGR["smgr_create + 스토리지 할당"] HCC -->|카탈로그 행| PGCLASS["INSERT pg_class\npg_attribute\npg_type"] DR --> CCI["CommandCounterIncrement"] CCI --> ARNC["AddRelationNewConstraints\nraw DEFAULT + CHECK"] ARNC --> SCI["StoreCatalogInheritance"] SLOW --> ETEND["EventTriggerDDLCommandEnd\nEventTriggerEndCompleteQuery"]
그림 1 — 포털 진입부터 카탈로그 작성자까지의 단순화된 DDL 디스패치. 빠른 경로(인라인, 왼쪽 분기)와 느린 경로(오른쪽 분기)는 동일한 ProcessUtility 관문을 공유한다. 오류 경로는 생략했다. ProcessUtilitySlow는 PG_TRY/PG_FINALLY를 사용해 EventTriggerEndCompleteQuery가 항상 발동되도록 보장한다.
그림 1은 전체 척추를 보여준다. 그림 2는 standard_ProcessUtility 본문을 확대한다. nodeTag 분기가 구문별로 인라인 실행과 ProcessUtilitySlow 전달 중 어느 것을 선택하는지 드러난다. 분기 팔에는 세 종류가 있다. (1) 순수 런타임 구문(T_TransactionStmt, T_VariableSetStmt, T_CheckPointStmt)은 손으로 작성된 인라인 핸들러를 갖고 느린 경로에 절대 도달하지 않는다. 529행 주석이 START TRANSACTION은 반드시 이벤트 트리거 코드 밖에 있어야 한다고 경고하는 이유가 바로 이 구문들 때문이다. (2) 객체 타입 조건부 구문(T_DropStmt, T_RenameStmt, T_GrantStmt, T_CommentStmt, T_SecLabelStmt 등)은 EventTriggerSupportsObjectType을 호출하고, 해당 객체 타입이 이벤트 트리거를 지원할 때만 ProcessUtilitySlow로 전달하며 그 외에는 자체 Exec*Stmt를 인라인으로 실행한다. (3) 나머지 전부는 default: 팔로 흘러들어간다. 그 팔의 한 줄짜리 주석은 “All other statement types have event trigger support”다. T_CreateStmt와 T_AlterTableStmt가 별도의 조건 없이 ProcessUtilitySlow에 도달하는 메커니즘이 바로 이것이다.
그림 2 — standard_ProcessUtility 디스패치와 ProcessUtility_hook 이음새
flowchart TD
PU["ProcessUtility<br/>tcop/utility.c"] -->|ProcessUtility_hook != NULL| HOOK["ProcessUtility_hook<br/>익스텐션 함수 포인터"]
PU -->|hook is NULL| SPU["standard_ProcessUtility"]
HOOK -.->|"관례: 통과 호출"| SPU
SPU --> SAFE["check_stack_depth<br/>ClassifyUtilityCommandAsReadOnly<br/>PreventCommandIfReadOnly / ParallelMode"]
SAFE --> SW["switch nodeTag parsetree"]
SW -->|"T_TransactionStmt<br/>T_VariableSetStmt<br/>T_CheckPointStmt"| RUNTIME["인라인 핸들러<br/>BeginTransactionBlock<br/>SetPGVariable<br/>RequestCheckpoint"]
SW -->|"T_DropStmt / T_RenameStmt<br/>T_GrantStmt / T_CommentStmt<br/>T_SecLabelStmt ..."| COND{"EventTriggerSupportsObjectType ?"}
COND -->|no| INLINE_EXEC["인라인 ExecDropStmt<br/>ExecRenameStmt<br/>ExecuteGrantStmt ..."]
COND -->|yes| SLOW["ProcessUtilitySlow"]
SW -->|"default:<br/>T_CreateStmt, T_AlterTableStmt,<br/>T_IndexStmt, T_ViewStmt ..."| SLOW
SLOW --> FENCE["EventTriggerBeginCompleteQuery<br/>EventTriggerDDLCommandStart<br/>... dispatch ...<br/>EventTriggerDDLCommandEnd"]
그림 2 — standard_ProcessUtility 내부: 안전 검사가 nodeTag 분기 앞에 온다. 런타임 구문은 인라인으로 실행되며 느린 경로에 절대 도달하지 않는다. 객체 타입 조건부 구문은 EventTriggerSupportsObjectType을 호출하고, default: 팔은 카탈로그 변경이 필요한 나머지 전체를 ProcessUtilitySlow로 전달한다. ProcessUtility_hook 이음새는 그 한 단계 위에 있다. 훅을 설치한 익스텐션은 기본 경로를 유지하기 위해 standard_ProcessUtility를 호출하도록 관례상 요구된다(강제는 아니다).
default: 팔이 CREATE TABLE을 느린 경로로 보내는 실제 메커니즘이다. standard_ProcessUtility에는 case T_CreateStmt:가 아예 없다. 전부 포괄 분기에 흡수된다.
// standard_ProcessUtility (default arm) — src/backend/tcop/utility.c default: /* All other statement types have event trigger support */ ProcessUtilitySlow(pstate, pstmt, queryString, context, params, queryEnv, dest, qc); break; }객체 타입 조건부 팔은 이와 달리 명시적으로 분기한다. T_RenameStmt 케이스가 이 계열 전체를 대표한다.
// standard_ProcessUtility (T_RenameStmt arm) — src/backend/tcop/utility.c case T_RenameStmt: { RenameStmt *stmt = (RenameStmt *) parsetree;
if (EventTriggerSupportsObjectType(stmt->renameType)) ProcessUtilitySlow(pstate, pstmt, queryString, context, params, queryEnv, dest, qc); else ExecRenameStmt(stmt); } break;transformCreateStmt: DefineRelation 전의 의미 분석
섹션 제목: “transformCreateStmt: DefineRelation 전의 의미 분석”ProcessUtilitySlow는 DefineRelation을 호출하기 전에 raw CreateStmt를 transformCreateStmt(parser/parse_utilcmd.c:164)에 통과시킨다. 이 함수는 파서가 할 수 없는 의미 작업을 담당한다. LIKE 절을 확장해 기존 테이블의 컬럼 정의를 복사하고, INHERITS 부모를 해석하며, SERIAL/GENERATED 단축 표기를 명시적인 DEFAULT와 제약 노드로 변환하고, 컬럼 수준 제약을 테이블 수준 제약 노드로 정규화한다는 점이다. 반환값이 List *인 이유는 CREATE TABLE ... (LIKE t INCLUDING ALL) 하나가 여러 CreateStmt 노드로 확장될 수 있기 때문이다. 원본 테이블 노드 외에 소스 테이블의 인덱스를 재현하는 인덱스 생성 구문들이 추가될 수 있다. ProcessUtilitySlow는 이 목록을 순회하며 각 항목을 처리한다.
DefineRelation: CreateStmt에서 relationId까지
섹션 제목: “DefineRelation: CreateStmt에서 relationId까지”DefineRelation(commands/tablecmds.c:764)은 테이블 생성 경로의 코디네이터다. 이 함수는 카탈로그 행을 직접 쓰지 않는다. 모든 입력을 조립해 하위 작성자들을 호출하는 것이 역할이라는 점이다.
// DefineRelation — src/backend/commands/tablecmds.cDefineRelation(CreateStmt *stmt, char relkind, Oid ownerId, ObjectAddress *typaddress, const char *queryString){ // 1. 네임스페이스, 테이블스페이스, 접근 메서드 해석 namespaceId = RangeVarGetAndCheckCreationNamespace(stmt->relation, ...);
// 2. 부모 스캔 락모드 결정 parentLockmode = (stmt->partbound != NULL ? AccessExclusiveLock : ShareUpdateExclusiveLock);
// 3. 상속 속성을 tableElts에 병합 stmt->tableElts = MergeAttributes(stmt->tableElts, inheritOids, ...);
// 4. 병합된 컬럼 목록으로 TupleDesc 구성 descriptor = BuildDescForRelation(stmt->tableElts);
// 5. raw DEFAULT와 cooked(상속된) DEFAULT 분리 // raw -> rawDefaults; cooked -> cookedDefaults
// 6. 카탈로그 행 쓰기 + 물리 파일 할당 relationId = heap_create_with_catalog(relname, namespaceId, tablespaceId, InvalidOid, InvalidOid, ofTypeId, ownerId, accessMethodId, descriptor, list_concat(cookedDefaults, old_constraints), relkind, relpersistence, false, false, stmt->oncommit, reloptions, true, false, false, InvalidOid, typaddress);
// 7. 새 pg_class 튜플을 이 트랜잭션 안에서 가시화 CommandCounterIncrement();
// 8. 새 릴레이션 열기 (배타 락 — 락 매니저 회계용) rel = relation_open(relationId, AccessExclusiveLock);
// 9. relcache 엔트리가 생긴 이제 raw DEFAULT/CHECK 표현식 변환 if (rawDefaults) AddRelationNewConstraints(rel, rawDefaults, NIL, true, true, false, queryString);
// 10. 생성 컬럼 표현식 가시화를 위해 다시 전진 CommandCounterIncrement();
// 11. 파티셔닝 처리 (partbound != NULL인 경우) // 12. 파티션 전략 / 파티션 키 설정 // 13. 상속 연결 저장 StoreCatalogInheritance(relationId, inheritOids, partitioned);
// 14. 테이블 수준 CHECK 제약 추가 // ...
return address;}7단계가 중요하다. 소스의 주석은 이렇게 말한다.
“We must bump the command counter to make the newly-created relation tuple visible for opening.”
이것이 트랜잭션 내 가시성 메커니즘이다. heap_create_with_catalog는 현재 트랜잭션의 xmin 아래 pg_class 행을 삽입했다. 그 행은 스냅샷이 삽입 전에 찍혔기 때문에 아직 현재 트랜잭션 자신의 스냅샷에 보이지 않는다. CommandCounterIncrement가 명령 카운터를 올리면, 다음 스냅샷 획득 시 더 높은 curcid(현재 명령 ID)가 그 행을 가시화한다. 그래야 비로소 relation_open이 새 relcache 엔트리를 찾아 락을 걸 수 있다는 점이다.
heap_create_with_catalog: 카탈로그 작성자
섹션 제목: “heap_create_with_catalog: 카탈로그 작성자”heap_create_with_catalog(catalog/heap.c:1139)는 TupleDesc와 옵션 집합을 영속적인 카탈로그 행으로 변환하는 함수다. 단계는 다음과 같다.
RowExclusiveLock으로pg_class를 연다.TupleDesc를 검증한다. 컬럼 이름/타입 검사, 부트스트랩/시스템 테이블 모드 밖에서ANYARRAY거부가 해당된다.pg_class와pg_type모두에서 이름 충돌을 확인한다. 모든 테이블은 복합 행 타입을 갖기 때문에 타입 이름이 기존 타입과 충돌하면 안 된다는 점이다.GetNewRelFileNumber로 OID/relfilenumber를 할당하고,LockRelationOid(relid, AccessExclusiveLock)으로 OID를 락한다.heap_create를 호출해 물리 파일을 생성한다.heap_create는smgr_create를 호출해 초기 스토리지를 쓴다.InsertPgClassTuple로pg_class행을 삽입한다.InsertPgAttributeTuples로pg_attribute행들을 삽입한다.pg_type에 복합 행 타입을 생성한다. 시퀀스, 토스트 테이블, 인덱스는 행 타입을 갖지 않아 이 단계를 건너뛴다.cooked_constraints로 전달된 사전 조리된 DEFAULT 표현식을pg_attrdef에 저장한다.pg_class를 닫는다.
// heap_create_with_catalog (압축) — src/backend/catalog/heap.cheap_create_with_catalog(const char *relname, Oid relnamespace, Oid reltablespace, Oid relid, Oid reltypeid, Oid reloftypeid, Oid ownerid, Oid accessmtd, TupleDesc tupdesc, List *cooked_constraints, char relkind, char relpersistence, bool shared_relation, bool mapped_relation, OnCommitAction oncommit, Datum reloptions, bool use_user_acl, bool allow_system_table_mods, bool is_internal, Oid relrewrite, ObjectAddress *typaddress){ pg_class_desc = table_open(RelationRelationId, RowExclusiveLock);
// ... 이름 충돌 확인 ...
// OID 할당 (또는 바이너리 업그레이드 재정의 사용) relid = GetNewRelFileNumber(reltablespace, pg_class_desc, relpersistence); LockRelationOid(relid, AccessExclusiveLock);
// relcache 엔트리 + 물리 스토리지 생성 new_rel_desc = heap_create(relname, relnamespace, reltablespace, relid, relfilenumber, accessmtd, tupdesc, relkind, relpersistence, shared_relation, mapped_relation, allow_system_table_mods, &relfrozenxid, &relminmxid, true /* create_storage */);
// pg_class 행, pg_attribute 행, pg_type 행, pg_attrdef 행 쓰기 // ... (AddNewRelationTuple, InsertPgAttributeTuples 등) ...
table_close(pg_class_desc, RowExclusiveLock); return relid;}heap_create(catalog/heap.c:285)는 하위 절반이다. relcache 엔트리를 만들고 smgr_create를 호출해 디스크의 물리 포크 파일을 할당한다. 카탈로그 행은 전혀 쓰지 않는다. 그것은 전적으로 heap_create_with_catalog의 역할이다. 이 분리가 존재하는 이유는 유효한 relcache 엔트리가 이미 있는 호출자(예: REINDEX)가 전체 카탈로그 쓰기 경로를 거치지 않고 heap_create만 호출할 수 있게 하기 위해서다.
CommandCounterIncrement와 두 단계 DEFAULT 문제
섹션 제목: “CommandCounterIncrement와 두 단계 DEFAULT 문제”CREATE TABLE (col INT DEFAULT expr)의 raw DEFAULT 표현식은 파서가 볼 때 변환할 수 없다. transformExpr가 컬럼 참조를 해석하려면 기존 relcache 엔트리가 있어야 하기 때문이다. 그래서 DefineRelation은 “조리된” 기본값(이미 변환된 Expr * 노드, 상속 컬럼에서 온 것)과 “raw” 기본값(현재 CREATE TABLE의 미파싱 Node * 트리)을 분리한다. 조리된 기본값은 heap_create_with_catalog에 직접 전달된다. raw 기본값은 rawDefaults에 보관된다.
CommandCounterIncrement가 새 pg_class 행을 가시화한 뒤, DefineRelation은 릴레이션을 열고 AddRelationNewConstraints(catalog/heap.c:2402)를 호출한다. rawDefaults를 전달하는 것이다. AddRelationNewConstraints는 이제 열린 릴레이션의 파스 상태로 transformExpr를 호출해 각 raw DEFAULT 노드를 Expr *로 변환하고 pg_attrdef에 쓴다. 생성 컬럼(GENERATED ALWAYS AS expr STORED)의 경우 두 번째 CommandCounterIncrement가 뒤따른다. 생성 표현식이 이후의 partbound 처리에서 참조되기 전에 가시화돼야 하기 때문이다.
이 두 단계 패턴 — 카탈로그 행 쓰기, 카운터 전진, 그 다음 새 행을 참조하는 표현식 처리 — 은 DDL 경로 전체에서 반복된다. ALTER TABLE은 모든 하위 명령 경계에서 이 패턴을 수행하기 때문에 tablecmds.c에 수십 개의 CommandCounterIncrement() 호출이 있다.
ProcessUtilityForAlterTable: ProcessUtility로의 재귀
섹션 제목: “ProcessUtilityForAlterTable: ProcessUtility로의 재귀”ALTER TABLE 하위 명령(예: ADD CONSTRAINT FOREIGN KEY ... USING INDEX)은 추가적인 DDL을 트리거할 수 있다. ProcessUtilityForAlterTable(tcop/utility.c:1959)은 하위 구문을 PlannedStmt로 감싸고 PROCESS_UTILITY_SUBCOMMAND 컨텍스트로 ProcessUtility를 재귀 호출해 이를 처리한다. 서브커맨드 컨텍스트는 이벤트 트리거 펜스를 억제한다. 외부 ALTER TABLE이 이미 펜스를 열었기 때문이다. 또한 최상위 전용 검사도 방지한다.
// ProcessUtilityForAlterTable — src/backend/tcop/utility.cProcessUtilityForAlterTable(Node *stmt, AlterTableUtilityContext *context){ EventTriggerAlterTableEnd(); // 현재 AT 하위 명령 집합 닫기 // ... wrapper PlannedStmt 구성 ... ProcessUtility(wrapper, context->queryString, false, PROCESS_UTILITY_SUBCOMMAND, /* 펜스 억제 */ context->params, context->queryEnv, None_Receiver, NULL); EventTriggerAlterTableStart(context->pstmt->utilityStmt); EventTriggerAlterTableRelid(context->relid);}이 재귀 경로 덕분에 단일 ALTER TABLE 구문이 내부적으로 여러 CREATE INDEX, ADD CONSTRAINT, CREATE TRIGGER 명령을 실행하면서도 그 디스패치 로직을 중복 구현하지 않을 수 있다.
소스 탐방
섹션 제목: “소스 탐방”진입과 디스패치
섹션 제목: “진입과 디스패치”ProcessUtility(tcop/utility.c) —PortalRunUtility로부터 모든 유틸리티 구문이 들어오는 단일 진입점.ProcessUtility_hook을 확인하고, 훅이 없으면standard_ProcessUtility를 호출한다.ProcessUtility_hook—ProcessUtility_hook_type함수 포인터.PGDLLIMPORT로 선언돼 있으며 익스텐션 가로채기 지점이다.standard_ProcessUtility(tcop/utility.c) — 안전 검사(읽기 전용 모드, 병렬 모드, 스택 깊이) 후nodeTag분기. 단순 구문은 인라인으로 실행되고, 카탈로그 변경이 필요한 구문은ProcessUtilitySlow를 호출한다.ProcessUtilitySlow(tcop/utility.c) — 이벤트 트리거 펜스(EventTriggerBeginCompleteQuery/EventTriggerDDLCommandStart/…End) 래퍼로 전체 카탈로그 변경 디스패치 분기를 감싼다.
CREATE TABLE 경로
섹션 제목: “CREATE TABLE 경로”transformCreateStmt(parser/parse_utilcmd.c) —CreateStmt의 의미 분석.LIKE확장,INHERITS해석,SERIAL변환, 제약 분리.List *반환.DefineRelation(commands/tablecmds.c) — 코디네이터. 네임스페이스 조회,MergeAttributes로 상속 컬럼 병합,BuildDescForRelation,heap_create_with_catalog호출,CommandCounterIncrement두 번,AddRelationNewConstraints,StoreCatalogInheritance.heap_create_with_catalog(catalog/heap.c) — 카탈로그 작성자.pg_class열기, OID 할당,heap_create호출,pg_class/pg_attribute/pg_type행 쓰기.heap_create(catalog/heap.c) — 물리 계층. relcache 엔트리 구성,smgr_create로 세그먼트 파일 할당.CommandCounterIncrement— 트랜잭션별 명령 카운터 전진.CREATE TABLE당 최소 두 번(pg_class쓰기 후, DEFAULT 표현식 처리 후).AddRelationNewConstraints(catalog/heap.c) — rawDEFAULT와CHECK표현식 트리를 이제 열린 릴레이션의 파스 상태로 변환,pg_attrdef/pg_constraint행 쓰기.StoreCatalogInheritance(commands/tablecmds.c) —INHERITS부모와 선언적 파티션 부모를pg_inherits에 기록.NewRelationCreateToastTable(commands/tablecmds.c) — 새 테이블의 행 타입이TOAST_TUPLE_THRESHOLD를 초과할 수 있으면 TOAST 릴레이션 생성.
ALTER TABLE 재귀
섹션 제목: “ALTER TABLE 재귀”ProcessUtilityForAlterTable(tcop/utility.c) — 자체 DDL 디스패치가 필요한 하위 명령을 위해ATExecCmd에서 호출. 하위 구문을 래핑하고PROCESS_UTILITY_SUBCOMMAND로ProcessUtility를 재귀 호출한다.
위치 힌트 (2026-06-05 기준, 커밋 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, 커밋 273fe94)”| 심볼 | 파일 | 행 |
|---|---|---|
ProcessUtility | src/backend/tcop/utility.c | 499 |
ProcessUtility_hook | src/backend/tcop/utility.c | 70 |
standard_ProcessUtility | src/backend/tcop/utility.c | 543 |
ProcessUtilitySlow | src/backend/tcop/utility.c | 1092 |
ProcessUtilityForAlterTable | src/backend/tcop/utility.c | 1959 |
transformCreateStmt | src/backend/parser/parse_utilcmd.c | 164 |
DefineRelation | src/backend/commands/tablecmds.c | 764 |
RangeVarGetAndCheckCreationNamespace | src/backend/catalog/namespace.c | 739 |
MergeAttributes | src/backend/commands/tablecmds.c | 2546 |
BuildDescForRelation | src/backend/commands/tablecmds.c | 1380 |
heap_create_with_catalog | src/backend/catalog/heap.c | 1139 |
heap_create | src/backend/catalog/heap.c | 285 |
InsertPgAttributeTuples | src/backend/catalog/heap.c | 731 |
AddRelationNewConstraints | src/backend/catalog/heap.c | 2402 |
StoreConstraints | src/backend/catalog/heap.c | 2327 |
StoreCatalogInheritance | src/backend/commands/tablecmds.c | 3521 |
heap_drop_with_catalog | src/backend/catalog/heap.c | 1801 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
ProcessUtility는 단 하나의 훅(ProcessUtility_hook)을 확인한 후 위임한다.tcop/utility.c:518에서 검증. 훅은ProcessUtility_hook_type으로 타입 지정되고PGDLLIMPORT로 선언된다. 기본값은NULL이다. 이 수준에서 보조 훅은 존재하지 않는다. -
단순/느린 분리는
T_DropStmt,T_RenameStmt,T_GrantStmt등 일부 구문에서EventTriggerSupportsObjectType에 따라 조건부다.tcop/utility.c:977–1070에서 검증.T_CreateStmt와T_AlterTableStmt는 조건 없이 항상ProcessUtilitySlow로 간다. 이벤트 트리거가 정의돼 있지 않아도 모든CREATE TABLE은 이벤트 트리거 래핑을 받는다는 점이다. -
ProcessUtilitySlow는 오류 시에도EventTriggerEndCompleteQuery가 발동되도록 전체 명령 본문을PG_TRY로 감싼다.tcop/utility.c:1113에서 검증. 정리는PG_CATCH가 아닌PG_FINALLY블록에 있으므로 정상 완료와 오류 모두에서 발동된다. -
transformCreateStmt는DefineRelation전에 호출된다.tcop/utility.c:1143에서 검증. 변환된 목록을 순회해 목록의 각CreateStmt노드가DefineRelation을 독립적으로 호출한다. 따라서 사용자 구문 하나가 여러 힙 릴레이션을 생성할 수 있다(예: 여러 인덱스가 있는 테이블에LIKE … INCLUDING INDEXES를 사용할 때). -
DefineRelation에서CommandCounterIncrement는 최소 두 번 호출된다.tablecmds.c:1082와tablecmds.c:1111에서 검증. 첫 번째 전진(heap_create_with_catalog이후)은relation_open을 위해pg_class행을 가시화한다. 두 번째 전진(AddRelationNewConstraints이후)은 파티션 바운드 처리 전에 생성 컬럼 표현식을 가시화한다. -
heap_create_with_catalog는 OID를 할당한 직후 새 OID에AccessExclusiveLock을 건다.catalog/heap.c:1292에서 검증. 주석의 설명은 다음과 같다. “다른 세션의 카탈로그 스캔은 커밋 전까지 이것을 찾을 수 없다. 따라서 AccessExclusiveLock을 유지해도 무방하다.” 락은 순전히 락 매니저 회계용이다. 커밋 전에 같은 OID가 다시 참조될 경우 교착 상태 오보를 방지하기 위해서다. -
heap_create는 relcache 엔트리와 물리 파일 모두를 담당한다.heap_create_with_catalog는smgr_create를 직접 호출하지 않는다.catalog/heap.c:1330의heap_create_with_catalog를 읽어 검증.heap_create(... true /* create_storage */)를 호출하고,catalog/heap.c:336의heap_create가RELKIND_HAS_STORAGE일 때smgr_create를 호출한다.
미해결 질문
섹션 제목: “미해결 질문”-
NewRelationCreateToastTable판단 기준.DefineRelation은CommandCounterIncrement이후 조건 없이NewRelationCreateToastTable을 호출한다. 이 함수가TupleDesc를 검사해 TOAST 테이블이 필요한지 결정하는 것으로 보인다. 정확한 판단 임계값(MaxHeapTupleSize와 컬럼별 스토리지 모드 기반)은access/heap/에 문서화돼 있으나 이번 패스에서 REL_18_STABLE 소스를 직접 검증하지는 않았다. -
파티션 테이블의
pg_type행.heap_create_with_catalog는RELKIND_PARTITIONED_TABLE에 대한 복합 행 타입을 생성한다. 이 행 타입이 일반 테이블의 타입과 동일하게pg_type에서 조회 가능한지(예:ROW(...)생성 시) 이번 패스에서 검증하지 않았다.catalog/heap.c:1355의 조건이 타입 생성을 제어하지만 전체 로직을 추적하지는 않았다. -
ProcessUtility_hook재진입. 익스텐션의 훅 구현이standard_ProcessUtility가 아닌ProcessUtility로 다시 콜백하면 내부 호출에도 훅이 다시 발동된다. 출하된 익스텐션 중 이를 활용하거나 방어하는 것이 있는지 검증하지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프런티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”-
CUBRID의
do_statement/SM_TEMPLATEDDL 경로. CUBRID는 DDL을do_statement(execute_statement.c) →do_create_entity→dbt_create_class→sm_finish_class경로로 디스패치하며,SM_TEMPLATE이라는 스테이징 템플릿을 변이시킨 뒤update_class로 원자적으로 커밋한다. PostgreSQL과의 핵심 차이는 다음과 같다. CUBRID의 DDL 스테이징은 명시적인 가변 객체인 반면, PostgreSQL의 스테이징은 트랜잭션 가시성 규칙에 암묵적으로 내재한다. 현재xmin아래의pg_class행은 커밋 전까지 다른 트랜잭션에 보이지 않는다는 점이다.cubrid-ddl-execution.md를 참고하라. -
Oracle의 묵시적 커밋 DDL과 Library Cache Lock. Oracle은 DDL을 실행하기 전에 현재 트랜잭션을 커밋한다(묵시적 커밋). DDL이 비트랜잭션적이 되는 것이다. 캐시 일관성은 MVCC 방식의 가시성이 아닌 Library Cache Lock으로 유지된다. PostgreSQL 경험이 있는 엔지니어가 Oracle을 다룰 때 명시적
CommandCounterIncrement패턴이 생소하게 느껴지는 이유가 여기 있다. 이 패턴은 DDL이 트랜잭션적이고 같은 트랜잭션 범위 안에서 행을 가시화해야 하기 때문에 존재한다. -
MySQL 8.0의 데이터 딕셔너리를 통한 트랜잭션 DDL. MySQL 8.0은 비트랜잭션적
FRM파일을 트랜잭션 InnoDB 기반 데이터 딕셔너리로 교체했다. DDL이 이제 딕셔너리와 스토리지 엔진 사이의 원자적 DDL을 위한 2단계 커밋에 참여한다. PostgreSQL의 완전한 트랜잭션 카탈로그와 밀접하게 유사하다. 두 접근법을 비교하는 구현 논문은 아직 큐레이션되지 않았다.CommandCounterIncrement방식 대 2PC 방식의 트랜잭션 내 DDL 스테이징 비용 비교가 유용한 후속 작업이 될 것이다. -
이벤트 트리거 확장성 표면. PostgreSQL의 이벤트 트리거(
ddl_command_start,ddl_command_end,sql_drop,table_rewrite)는pg_audit,ddl_historizer, 스키마 마이그레이션 프레임워크 같은 도구를 코어 패치 없이 가능하게 하는 확장성 훅이다.ProcessUtilitySlow래퍼가 명령별 컨텍스트에 영향받은 객체를 수집하고 두 체크포인트에서 PL/pgSQL이나 C 함수를 발동시키는 설계는postgres-event-triggers.md에서 자세히 다룬다. -
DDL 로깅과 스키마 변경 캡처. PostgreSQL의 논리 디코딩은 WAL을 재생해 DML 변경을 캡처한다. DDL 변경은 복제 프로토콜에서 기본적으로 디코딩되지 않는다. 카탈로그 쓰기는 WAL에 기록되지만, 논리 디코딩 출력 플러그인이 받는 것은
COMMIT레코드이지 구조화된 DDL 이벤트가 아니다.pglogical이나pg_auto_failover같은 도구는 이벤트 트리거 출력을 캡처해 별도로 직렬화함으로써 이를 확장한다. “복제 스트림의 일부로서의 DDL”에 대한 공식적인 처리는 여전히 활발한 연구 및 엔지니어링 영역이다.
소스 파일 (REL_18_STABLE, 커밋 273fe94):
src/backend/tcop/utility.c—ProcessUtility,standard_ProcessUtility,ProcessUtilitySlow,ProcessUtilityForAlterTablesrc/backend/commands/tablecmds.c—DefineRelation,StoreCatalogInheritance,MergeAttributessrc/backend/catalog/heap.c—heap_create,heap_create_with_catalog,InsertPgAttributeTuples,AddRelationNewConstraints,StoreConstraintssrc/backend/parser/parse_utilcmd.c—transformCreateStmtsrc/include/tcop/utility.h—ProcessUtility_hook_type,ProcessUtility_hook
교재:
- Database System Concepts, Silberschatz et al., 7th ed., §5.2 “Data Definition Language”, §15.5 “DDL in SQL”
교차 참조:
postgres-overview-ddl-schema.md— 섹션 라우터. ProcessUtility 디스패치 맵과 두 이탈자(RLS, 파티션 라우팅) 프레이밍postgres-alter-table.md—tablecmds.c의 다단계AlterTable엔진.ProcessUtilityForAlterTable을 사용하는 AT 하위 명령 워크postgres-cache-invalidation.md— DDL 커밋 후 다른 백엔드로 relcache 무효화를 전파하는 sinval 메시지 브로드캐스트postgres-system-catalogs.md—pg_class,pg_attribute,pg_constraint행 레이아웃.heap_create_with_catalog가 쓰는 것들postgres-xact.md— 트랜잭션 DDL이 실행되는 트랜잭션 생명주기. xact 컨텍스트에서의CommandCounterIncrementcubrid-ddl-execution.md— CUBRID의SM_TEMPLATE기반 DDL 스테이징 경로. PostgreSQL의 암묵적 가시성 접근법과 대비