콘텐츠로 이동

(KO) PostgreSQL ALTER TABLE — 다중 패스 기계와 힙 재작성

목차

ALTER TABLE은 *스키마 진화(schema evolution)*를 위한 표면 구문이다. 이미 행이 존재하는 릴레이션의 논리적 형태 — 컬럼, 타입, 제약, 저장 속성 — 를 변경한다. 모든 관계형 시스템이 답해야 하는 근본 질문이 하나 있다. 선언된 스키마가 바뀌면 디스크 위의 바이트는 어떻게 되는가? 이 질문에 대한 답이 전체 설계 공간을 둘로 가른다.

Database System Concepts(Silberschatz, Korth, Sudarshan)는 릴레이션을 스키마 R(A1, A2, …, An)을 따르는 튜플의 집합으로 정의한다. 스키마는 메타데이터이고 튜플은 데이터다. 스키마 변경은 두 방식 중 하나로 실현된다.

  1. 메타데이터 전용(논리) 변경. 카탈로그 설명만 갱신하고 기존 튜플은 새 설명 아래서 재해석한다. 저장된 바이트는 이동하지 않는다. 행 수에 무관하게 O(1) 비용이다. 그러나 온-디스크 표현이 이미 새 스키마와 호환될 때만 가능하다. 컬럼을 삭제하거나(바이트는 남지만 더 이상 프로젝션되지 않는다), varchar(10)varchar(20)으로 넓히는(물리 레이아웃이 같고 제약만 느슨해진다) 경우가 이에 해당한다.

  2. 물리(데이터) 변경 — 재작성. 저장된 모든 튜플을 읽어 변환한 뒤 새 레이아웃으로 다시 써야 한다. O(행 수) 작업이고, 테이블 크기에 비례하는 WAL을 생성하며, 단순한 구현에서는 전체 시간 동안 배타 락을 유지한다. 온-디스크 바이트 자체가 바뀌어야 할 때 — inttext로 변환하거나, 행의 물리 폭을 바꾸거나, 저장 파라미터 변경 후 정리할 때 — 피할 수 없다.

ALTER TABLE 구현의 기술은 경우 1을 최대화하고 경우 2를 최소화하는 것이다. 단순한 엔진은 모든 변경에서 테이블을 재작성한다. 성숙한 엔진은 메타데이터 전용으로 처리할 수 있는 넓은 범위를 인식하고 재작성을 불가피한 경우에만 예약한다. PostgreSQL은 20년에 걸쳐 연산을 경우 2에서 경우 1로 밀어왔다. DROP COLUMNattisdropped 논리 삭제(처음부터), ALTER TYPE의 이진 강제 변환 단축, PostgreSQL 11에서 추가된 빠른 기본값(fast default)ADD COLUMN ... DEFAULT <const>pg_attribute.attmissingval에 기본값을 저장해 순수 카탈로그 변경으로 처리 — 가 그 결과다.

두 번째 이론 축은 DDL 원자성이다. 많은 엔진에서 DDL은 자동 커밋이거나 부분적으로만 트랜잭션 처리된다. PostgreSQL은 DDL을 일반 트랜잭션 작업으로 취급한다. 카탈로그 행은 MVCC 튜플이고, 힙 재작성은 새 relfilenode에 대한 일반 힙 삽입이며, 어디서든 오류가 발생하면 — 또는 명시적 ROLLBACK 시 — 특별한 정리 없이 전체 ALTER TABLE이 취소된다. AlterTable 헤더 주석은 직접 이 점을 밝힌다. “MVCC의 마법 덕분에, 중간 어디서든 오류가 발생하면 전체 연산이 롤백된다. 정리를 위해 특별히 할 일은 없다.”

세 번째 축은 동시성이다. 스키마 변경은 동시 읽기·쓰기와 조율해야 한다. 전통적인 메커니즘은 변경의 가시성 요건에 꼭 맞는 강도의 테이블 수준 락이다. 실행 중인 SELECT에 보이지 않는 변경은 읽기를 허용하는 약한 락을 취할 수 있고, 힙을 재작성하거나 쿼리 플랜을 바꾸는 변경은 AccessExclusiveLock이 필요하다. PostgreSQL은 이 전체 정책을 함수 하나 AlterTableGetLockLevel에 인코딩하고, 락을 획득하기 전에 계산한다. 첫 시도에 올바른 락이 잡힌다는 뜻이다.

ALTER TABLE 구현자가 선택하는 설계 공간은 다음과 같다.

  1. 단일 패스 또는 다중 패스. 사용자가 하나의 구문에 여러 서브커맨드를 줄 수 있다. 일부 서브커맨드는 상대적 순서를 지켜야 한다. 구현은 조합을 거부하거나 고정 순서를 강제할 수 있다. PostgreSQL은 고정 패스 순서를 강제한다.

  2. 구문 단위 스캔 또는 서브커맨드 단위 스캔. 세 서브커맨드가 각각 모든 행을 검사해야 한다면, 세 번 스캔할 것인가 한 번 스캔할 것인가. PostgreSQL의 명시적 목표는 테이블당 한 번의 데이터 패스다.

  3. 제자리 재작성 또는 새 파일로의 재작성. PostgreSQL은 재작성 시 힙 페이지를 제자리에서 변경하지 않는다. 새 relfilenode를 구성하고 파일 포인터를 원자적으로 교체한다. CLUSTER/VACUUM FULL과 동일한 기계(make_new_heap, finish_heap_swap)를 재사용한다.

시스템을 가로지르는 스키마 변경 엔진은 인식 가능한 관례 집합으로 수렴한다. 이를 먼저 명명하면 PostgreSQL의 구체적인 기호가 공유 플레이북 안의 선택들로 읽힌다.

스키마 변경은 단일 패스에서 모든 전제 조건을 검증할 수 없다. 앞선 서브커맨드가 뒤에 오는 것들이 의존하는 카탈로그 상태를 바꾸기 때문이다. 보편적인 패턴은 준비 단계(권한 확인, 이름 해석, 재귀 여부 결정, 불법 조합 거부)와 실행 단계(카탈로그 변경, 그다음 데이터 변경)로 분리하는 것이다. PostgreSQL의 ATPrepCmd / ATExecCmd 분리가 정확히 이것이다. 준비 단계는 “다른 서브커맨드가 변경할 수 있는 테이블 세부 정보를 읽는 것”에 의도적으로 보수적이다.

상속과 파티셔닝은 하나의 구문이 여러 테이블에 영향을 줄 수 있음을 의미한다. 표준 구조는 작업 큐(work queue) — 영향받는 릴레이션당 하나의 항목, 각 항목은 수행할 연산 목록과 준비 단계에서 수집한 스크래치 상태를 담는다. PostgreSQL의 큐는 List of AlteredTableInfo이고, ATGetQueueEntry가 접근자다.

의존 연산이 올바르게 인터리브되도록 고정 단계 순서

섹션 제목: “의존 연산이 올바르게 인터리브되도록 고정 단계 순서”

서브커맨드에 순서 제약이 있으므로, 엔진은 이들을 고정 단계에 넣고 모든 테이블에 걸쳐 단계 단위로 실행한다. 이렇게 하면 한 테이블의 연산이 다른 테이블의 나중 단계에 작업을 큐잉할 수 있다. PostgreSQL의 예: 기본 키 컬럼의 ALTER TYPE이 참조 테이블의 FK 제약 재추가를 나중 패스에 디스패치한다. PostgreSQL에는 13개 패스가 있다. AT_PASS_DROP이 첫 번째, AT_PASS_MISC가 마지막이다.

변경 가시성으로부터 파생된 락 강도

섹션 제목: “변경 가시성으로부터 파생된 락 강도”

DDL이 취하는 락은 변경을 관찰할 수 있는 동시 활동과 올바르게 직렬화하는 가장 약한 락이어야 한다. 시스템은 각 연산을 분류한다. 읽기에 보이지 않는 변경은 약한 락, 플랜을 무효화하거나 힙을 재작성하는 변경은 배타 락이다. PostgreSQL의 AlterTableGetLockLevel은 서브타입별로 ShareUpdateExclusiveLock, ShareRowExclusiveLock, AccessExclusiveLock 중 하나를 반환하는 거대한 switch이고, 모든 서브커맨드에 걸쳐 최댓값을 취한다.

카탈로그 플래그와 값 합성을 통한 메타데이터 전용 변경

섹션 제목: “카탈로그 플래그와 값 합성을 통한 메타데이터 전용 변경”

스키마 변경의 저렴한 경로는 카탈로그 플래그와 읽기 시간 합성이다. 컬럼을 삭제하면 “삭제됨” 플래그를 세우고 바이트는 남긴다. 기본값이 있는 컬럼을 추가하면 카탈로그에 기본값을 저장하고 해당 컬럼 이전 행에만 실체화한다. PostgreSQL은 전자에 pg_attribute.attisdropped를, 후자에 attmissingval(PG 11+의 빠른 기본값)을 사용한다.

제자리 변경이 아닌 새 파일 빌드·교체를 통한 재작성

섹션 제목: “제자리 변경이 아닌 새 파일 빌드·교체를 통한 재작성”

실제 재작성이 필요할 때 견고한 패턴은 새 물리 파일을 구성하고, 변환된 튜플을 복사한 뒤, 파일 신원을 원자적으로 교체하는 것이다. 충돌이나 롤백 시 원본이 그대로 남는다. CLUSTERVACUUM FULL이 같은 메커니즘을 사용한다. PostgreSQL은 코드를 공유한다. make_new_heap이 임시 릴레이션을 구성하고, ATRewriteTable이 복사하며, finish_heap_swap이 relfilenode를 교체하고 인덱스를 재구성한다.

개념PostgreSQL 이름
최상위 드라이버AlterTableATController
락 수준 정책 (락 획득 전)AlterTableGetLockLevel
1단계 — 준비ATPrepCmd
2단계 — 카탈로그 갱신ATRewriteCatalogsATExecCmd
3단계 — 스캔/재작성ATRewriteTablesATRewriteTable
작업 큐 항목 (테이블당)AlteredTableInfo
작업 큐 조회/생성ATGetQueueEntry
고정 패스 순서AlterTablePass 열거형 (AT_PASS_DROPAT_PASS_MISC)
재작성 이유 비트마스크tab->rewrite (AT_REWRITE_* 플래그)
3단계에서 계산할 새 컬럼 값NewColumnValue
3단계에서 검증할 새 제약NewConstraint
재작성 vs. 메타데이터 결정 (타입)ATColumnChangeRequiresRewrite
빠른 기본값 (메타데이터 전용 ADD)StoreAttrMissingVal / attmissingval
논리 컬럼 삭제RemoveAttributeById (attisdropped)
임시 힙 구성make_new_heap (cluster.c)
원자적 파일 교체finish_heap_swap / swap_relation_files
상속 재귀ATSimpleRecursion, find_all_inheritors

하나의 구문, 하나의 락, 세 단계

섹션 제목: “하나의 구문, 하나의 락, 세 단계”

ALTER TABLEAlterTable으로 진입하지만, 락은 호출자(utility.c)가 AlterTableGetLockLevel로 계산한 수준을 사용해 AlterTable 실행 에 획득한다. 핵심 불변식 — 함수 자체 주석에 명시 — 은 테이블을 보지 않고도 락 수준을 결정할 수 있어야 한다는 것이다. 테이블이 아직 잠기지 않았기 때문이다.

// AlterTableGetLockLevel — src/backend/commands/tablecmds.c
LOCKMODE
AlterTableGetLockLevel(List *cmds)
{
ListCell *lcmd;
LOCKMODE lockmode = ShareUpdateExclusiveLock;
foreach(lcmd, cmds)
{
AlterTableCmd *cmd = (AlterTableCmd *) lfirst(lcmd);
LOCKMODE cmd_lockmode = AccessExclusiveLock; /* default */
switch (cmd->subtype)
{
case AT_AddColumn: /* may rewrite heap */
case AT_SetAccessMethod: /* must rewrite heap */
case AT_SetTableSpace: /* must rewrite heap */
case AT_AlterColumnType: /* must rewrite heap */
cmd_lockmode = AccessExclusiveLock;
break;
/* ... dozens of cases ... */
case AT_SetStatistics:
case AT_ClusterOn:
case AT_DropCluster:
cmd_lockmode = ShareUpdateExclusiveLock;
break;
/* ... */
}
/* Take the greatest lockmode from any subcommand */
if (cmd_lockmode > lockmode)
lockmode = cmd_lockmode;
}
return lockmode;
}

기본 하한은 ShareUpdateExclusiveLock이다. ALTER TABLE이 사용하는 가장 약한 수준이지만, VACUUM, ANALYZE, 다른 DDL은 배제한다. 읽기와 쓰기는 허용된다. 대부분의 서브커맨드는 AccessExclusiveLock으로 상승하고, 트리거 활성화/비활성화와 FK 추가는 중간 수준인 ShareRowExclusiveLock으로 떨어진다. 모든 서브커맨드 중 최댓값이 선택되고, 그 단일 락이 커밋까지 유지된다. Hot Standby 조건도 있다. 스탠바이가 AccessExclusiveLock만 인식하므로, 스탠바이 SELECT에 보이는 모든 변경은 더 약한 락으로 충분하더라도 AccessExclusiveLock을 사용해야 한다.

락을 획득한 후 AlterTableNoLock으로 릴레이션을 열고(락이 이미 잡혀 있다), 안전 검사를 실행한 뒤 ATController에 위임한다. ATController가 3단계 설계를 가장 명확하게 표현한다.

// ATController — src/backend/commands/tablecmds.c
static void
ATController(AlterTableStmt *parsetree,
Relation rel, List *cmds, bool recurse, LOCKMODE lockmode,
AlterTableUtilityContext *context)
{
List *wqueue = NIL;
ListCell *lcmd;
/* Phase 1: preliminary examination of commands, create work queue */
foreach(lcmd, cmds)
{
AlterTableCmd *cmd = (AlterTableCmd *) lfirst(lcmd);
ATPrepCmd(&wqueue, rel, cmd, recurse, false, lockmode, context);
}
/* Close the relation, but keep lock until commit */
relation_close(rel, NoLock);
/* Phase 2: update system catalogs */
ATRewriteCatalogs(&wqueue, lockmode, context);
/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
ATRewriteTables(parsetree, &wqueue, lockmode, context);
}

설계 의도(AlterTable 헤더에서)는 데이터에 대한 한 번의 패스다. 3단계는 어떤 서브커맨드가 요구하지 않으면 완전히 건너뛴다. 실행될 때도 작업에 기여한 서브커맨드 수에 무관하게 테이블당 정확히 한 번만 스캔한다.

flowchart TD
    A["utility.c: AlterTableGetLockLevel(cmds)<br/>computes lock WITHOUT opening table"] --> B["acquire table lock<br/>(held until commit)"]
    B --> C["AlterTable -> CheckAlterTableIsSafe -> ATController"]
    C --> D["Phase 1: ATPrepCmd per subcommand<br/>permission/relkind checks, recurse to children<br/>build work queue of AlteredTableInfo<br/>bucket subcmds into passes"]
    D --> E["relation_close(rel, NoLock)<br/>lock stays"]
    E --> F["Phase 2: ATRewriteCatalogs<br/>for pass = DROP .. MISC:<br/>for each table: ATExecCmd per subcmd<br/>mutate catalogs, maybe set tab->rewrite"]
    F --> G{"any table:<br/>tab->rewrite > 0<br/>or constraints/notnull<br/>to verify?"}
    G -->|no| H["Phase 3 mostly no-op<br/>run afterStmts"]
    G -->|"rewrite > 0"| I["make_new_heap -> ATRewriteTable<br/>copy+transform every tuple<br/>finish_heap_swap (swap relfilenodes)"]
    G -->|"verify only"| J["ATRewriteTable(tab, InvalidOid)<br/>scan to check constraints, no rewrite"]
    I --> K["validate FK constraints (final loop)<br/>run afterStmts"]
    J --> K
    H --> K

1단계는 영향받는 릴레이션당 하나씩 AlteredTableInfoList를 구성한다. 구조체는 2단계와 3단계가 필요한 모든 것을 담는다. 패스별 서브커맨드 목록, 항목이 생성되는 시점에 캡처한 변경 전 튜플 디스크립터(oldDesc), 재계산할 컬럼(newvals), 검증할 제약(constraints), 재작성 이유가 모두 여기 있다.

// AlteredTableInfo (condensed) — src/backend/commands/tablecmds.c
typedef struct AlteredTableInfo
{
Oid relid; /* Relation to work on */
char relkind; /* Its relkind */
TupleDesc oldDesc; /* Pre-modification tuple descriptor */
Relation rel; /* transiently set during Phase 2 */
List *subcmds[AT_NUM_PASSES]; /* Lists of AlterTableCmd */
List *constraints; /* List of NewConstraint */
List *newvals; /* List of NewColumnValue */
List *afterStmts; /* utility command parsetrees */
bool verify_new_notnull; /* T if we should recheck NOT NULL */
int rewrite; /* Reason for forced rewrite, if any */
bool chgAccessMethod;
Oid newAccessMethod;
Oid newTableSpace;
bool chgPersistence;
char newrelpersistence;
Expr *partition_constraint;
/* ... changedConstraintOids/Defs, changedIndexOids/Defs, etc. ... */
} AlteredTableInfo;

ATGetQueueEntry는 찾기·생성 접근자다. 생성 시점에 튜플 디스크립터를 스냅샷하는 점이 중요하다. oldDesc이전 형태다. 2단계가 pg_attribute를 재작성한 뒤에도 3단계가 기존 튜플을 읽을 때 사용한다.

// ATGetQueueEntry — src/backend/commands/tablecmds.c
static AlteredTableInfo *
ATGetQueueEntry(List **wqueue, Relation rel)
{
Oid relid = RelationGetRelid(rel);
AlteredTableInfo *tab;
ListCell *ltab;
foreach(ltab, *wqueue)
{
tab = (AlteredTableInfo *) lfirst(ltab);
if (tab->relid == relid)
return tab; /* already queued */
}
/* Not there, so add it (snapshot the OLD descriptor now) */
tab = (AlteredTableInfo *) palloc0(sizeof(AlteredTableInfo));
tab->relid = relid;
tab->relkind = rel->rd_rel->relkind;
tab->oldDesc = CreateTupleDescCopyConstr(RelationGetDescr(rel));
tab->newAccessMethod = InvalidOid;
tab->newTableSpace = InvalidOid;
tab->newrelpersistence = RELPERSISTENCE_PERMANENT;
*wqueue = lappend(*wqueue, tab);
return tab;
}

2단계가 사용자 입력 순서대로 서브커맨드를 실행할 수 없는 이유는 연산에 의존성이 있기 때문이다. DROP COLUMN은 같은 이름의 ADD COLUMN보다 먼저 일어나야 한다. 기존 인덱스는 재구성을 강제한 ALTER TYPE 이후에만 재추가된다. FK 인덱스 기반 제약은 FK 자체보다 먼저 와야 한다. PostgreSQL은 고정 패스 열거형으로 이를 해결한다. 1단계가 각 서브커맨드를 올바른 subcmds[pass] 버킷에 넣는다.

// AlterTablePass — src/backend/commands/tablecmds.c
typedef enum AlterTablePass
{
AT_PASS_UNSET = -1, /* UNSET will cause ERROR */
AT_PASS_DROP, /* DROP (all flavors) */
AT_PASS_ALTER_TYPE, /* ALTER COLUMN TYPE */
AT_PASS_ADD_COL, /* ADD COLUMN */
AT_PASS_SET_EXPRESSION, /* ALTER SET EXPRESSION */
AT_PASS_OLD_INDEX, /* re-add existing indexes */
AT_PASS_OLD_CONSTR, /* re-add existing constraints */
AT_PASS_ADD_CONSTR, /* ADD constraints (initial examination) */
AT_PASS_COL_ATTRS, /* set column attributes, eg NOT NULL */
AT_PASS_ADD_INDEXCONSTR, /* ADD index-based constraints */
AT_PASS_ADD_INDEX, /* ADD indexes */
AT_PASS_ADD_OTHERCONSTR, /* ADD other constraints, defaults */
AT_PASS_MISC, /* other stuff */
} AlterTablePass;
#define AT_NUM_PASSES (AT_PASS_MISC + 1)

DROP은 패스 0이고, ALTER TYPE은 패스 1이다. 이후 OLD_INDEXOLD_CONSTR 패스가 삭제된 객체를 재추가한다. ADD 계열은 패스 7–11에 펼쳐져 인덱스 기반 제약, 일반 인덱스, 기타 제약이 의존성 순서대로 처리된다. 분류되지 않은 것은 AT_PASS_MISC로 떨어진다.

제자리 vs. 전체 재작성: 세 가지 저렴한 경로

섹션 제목: “제자리 vs. 전체 재작성: 세 가지 저렴한 경로”

ALTER TABLE 성능의 핵심은 tab->rewrite 비트마스크다. 초기값은 0이고, 실제로 힙 재작성이 필요한 각 서브커맨드가 이유 플래그를 OR한다.

// AT_REWRITE_* flags — src/include/commands/event_trigger.h
#define AT_REWRITE_ALTER_PERSISTENCE 0x01
#define AT_REWRITE_DEFAULT_VAL 0x02
#define AT_REWRITE_COLUMN_REWRITE 0x04
#define AT_REWRITE_ACCESS_METHOD 0x08

2단계 후에도 tab->rewrite가 여전히 0이고 검증할 새 제약도 없다면, 3단계는 해당 테이블을 사실상 건너뛴다. 세 서브커맨드 계열이 저렴한 경로와 비싼 경로의 선택을 보여준다.

DROP COLUMN — 항상 메타데이터 전용. 컬럼을 삭제해도 힙 페이지 하나 건드리지 않는다. ATExecDropColumn은 결국 (heap.c의) RemoveAttributeById를 구동한다. attisdropped를 세우고, 타입을 지우며, 컬럼 이름을 유일성이 보장된 플레이스홀더로 바꾼다. 모든 튜플에서 바이트는 그대로 남는다. 실행기가 해당 속성의 프로젝션을 중단할 뿐이다.

// RemoveAttributeById (condensed) — src/backend/catalog/heap.c
void
RemoveAttributeById(Oid relid, AttrNumber attnum)
{
/* ... open rel AccessExclusiveLock, fetch pg_attribute tuple ... */
attStruct->attisdropped = true;
attStruct->atttypid = InvalidOid; /* type link no longer reliable */
attStruct->attnotnull = false;
attStruct->attgenerated = '\0';
/* rename so the slot can't collide with a future ADD COLUMN */
snprintf(newattname, sizeof(newattname),
"........pg.dropped.%d........", attnum);
namestrcpy(&(attStruct->attname), newattname);
/* ... clear attmissingval, attstattarget, attacl, attoptions ... */
CatalogTupleUpdate(attr_rel, &tuple->t_self, tuple);
}

ADD COLUMN — 기본값이 상수일 때 메타데이터 전용 (빠른 기본값). ATExecAddColumnpg_attribute 행을 삽입한 뒤 재작성을 피하려 한다. 컬럼에 비휘발성, 비생성, 비도메인 제약 기본값이 있으면 기본값을 한 번 평가해 그 결과 datum을 StoreAttrMissingValpg_attribute.attmissingval에 저장한다. 기존 모든 행은 바이트를 힙에 쓰지 않고도 그 값을 반환한다. 이 빠른 경로를 쓸 수 없을 때만 AT_REWRITE_DEFAULT_VAL을 세운다.

// ATExecAddColumn (rewrite decision, condensed) — tablecmds.c
if (rel->rd_rel->relkind == RELKIND_RELATION &&
!colDef->generated &&
!has_domain_constraints &&
!contain_volatile_functions((Node *) defval))
{
/* Evaluate the default once and store it outside the heap */
missingval = ExecEvalExpr(exprState, ..., &missingIsNull);
if (!missingIsNull)
{
StoreAttrMissingVal(rel, attribute->attnum, missingval);
has_missing = true;
}
}
else
{
/* Failed to use missing mode -> must rewrite to install the value */
if (colDef->generated != ATTRIBUTE_GENERATED_VIRTUAL)
tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
}

기본값이 아예 없는 ADD COLUMN은 더 저렴하다. 힙 접근 루틴이 저장된 튜플의 속성 수를 넘는 attnum에는 NULL을 반환하므로, nullable 컬럼에는 재작성도 빠른 기본값도 필요하지 않다.

ALTER COLUMN TYPE — 강제 변환이 노-옵이 아닐 때만 재작성. ATPrepAlterColumnType은 변환 표현식을 계획하고 ATColumnChangeRequiresRewrite로 생략 가능한지 묻는다. 표현식이 단순히 컬럼 자체(varchar(10)varchar(20), 또는 이진 강제 변환)라면 재작성이 필요 없다. 카탈로그 타입만 바뀌고 바이트는 그대로다. 함수는 표현식 트리를 걸어 relabel/coerce 노드를 벗기고, 원래 Var에 도달하면 false(재작성 불필요)를 반환한다.

// ATColumnChangeRequiresRewrite (condensed) — tablecmds.c
static bool
ATColumnChangeRequiresRewrite(Node *expr, AttrNumber varattno)
{
for (;;)
{
if (IsA(expr, Var) && ((Var *) expr)->varattno == varattno)
return false; /* identity -> no rewrite */
else if (IsA(expr, RelabelType))
expr = (Node *) ((RelabelType *) expr)->arg;
else if (IsA(expr, CoerceToDomain))
{
CoerceToDomain *d = (CoerceToDomain *) expr;
if (DomainHasConstraints(d->resulttype))
return true; /* must scan to enforce domain */
expr = (Node *) d->arg;
}
else if (IsA(expr, FuncExpr))
{
/* a few timestamp casts are no-ops on some configs */
/* ... F_TIMESTAMPTZ_TIMESTAMP etc. ... */
return true;
}
else
return true; /* any real computation -> rewrite */
}
}

재작성이 실제로 필요하면 ATPrepAlterColumnTypeNewColumnValue를 큐잉하고 비트를 세운다.

// ATPrepAlterColumnType (tail, condensed) — tablecmds.c
newval = (NewColumnValue *) palloc0(sizeof(NewColumnValue));
newval->attnum = attnum;
newval->expr = (Expr *) transform;
tab->newvals = lappend(tab->newvals, newval);
if (ATColumnChangeRequiresRewrite(transform, attnum))
tab->rewrite |= AT_REWRITE_COLUMN_REWRITE;
flowchart TD
    S["subcommand in Phase 2 (ATExecCmd)"] --> T{"which subtype?"}
    T -->|DROP COLUMN| U["RemoveAttributeById<br/>attisdropped = true<br/>NEVER sets tab->rewrite"]
    T -->|"ADD COLUMN DEFAULT c"| V{"const, non-volatile,<br/>no domain constraint,<br/>plain relation?"}
    V -->|yes| W["StoreAttrMissingVal<br/>attmissingval = eval(default)<br/>metadata-only"]
    V -->|no| X["tab->rewrite |= AT_REWRITE_DEFAULT_VAL"]
    T -->|"ALTER TYPE"| Y{"ATColumnChangeRequiresRewrite?"}
    Y -->|no, binary-coercible| Z["catalog type change only<br/>metadata-only"]
    Y -->|yes| AA["tab->rewrite |= AT_REWRITE_COLUMN_REWRITE<br/>queue NewColumnValue"]
    T -->|"SET LOGGED / UNLOGGED"| AB["tab->rewrite |= AT_REWRITE_ALTER_PERSISTENCE"]
    T -->|"SET ACCESS METHOD"| AC["tab->rewrite |= AT_REWRITE_ACCESS_METHOD"]
    U --> END["Phase 3 reads tab->rewrite"]
    W --> END
    X --> END
    Z --> END
    AA --> END
    AB --> END
    AC --> END

3단계: 한 번의 스캔, 검증 또는 재작성

섹션 제목: “3단계: 한 번의 스캔, 검증 또는 재작성”

ATRewriteTables는 3단계다. 동일한 작업 큐를 순회하며 스토리지가 있는 테이블마다 tab->rewrite 비트마스크와 대기 중인 제약 목록을 기준으로 세 결과 중 하나를 선택한다. 이 분기가 제자리 vs. 전체 재작성이 제어 흐름으로 표현된 가장 명확한 지점이다.

// ATRewriteTables (branch, condensed) — src/backend/commands/tablecmds.c
if (tab->rewrite > 0 && tab->relkind != RELKIND_SEQUENCE)
{
/* Build a temporary relation and copy data */
OIDNewHeap = make_new_heap(tab->relid, NewTableSpace, NewAccessMethod,
persistence, lockmode);
/* Copy the heap data with modifications, test new constraints */
ATRewriteTable(tab, OIDNewHeap);
/* Swap the physical files, rebuild indexes, discard old heap */
finish_heap_swap(tab->relid, OIDNewHeap,
false, false, true,
!OidIsValid(tab->newTableSpace),
RecentXmin, ReadNextMultiXactId(), persistence);
}
else
{
/* No rewrite: scan only to verify new constraints, no new file */
if (tab->constraints != NIL || tab->verify_new_notnull ||
tab->partition_constraint != NULL)
ATRewriteTable(tab, InvalidOid);
/* SET TABLESPACE with no reconstruction is a block-by-block copy */
if (tab->newTableSpace)
ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
}

ATRewriteTable 단일 루틴이 두 역할을 모두 수행한다. OIDNewHeap이 유효한 OID인지 InvalidOid인지로 분기한다. 재작성 경로는 make_new_heap으로 새 relfilenode를 구성하고(CLUSTERVACUUM FULL이 호출하는 동일한 함수), 변환된 모든 튜플을 복사한 뒤, finish_heap_swap이 두 릴레이션의 relfilenode를 원자적으로 교체하고 인덱스를 재구성하며 이전 힙을 삭제한다. 교체 전까지 새 파일이 별도의 물리 릴레이션이므로, 어디서든 충돌 또는 ROLLBACK이 발생해도 원본 파일은 그대로다. AlterTable 헤더가 약속하는 MVCC 원자성이 이렇게 구현된다. 검증 전용 경로는 InvalidOid를 전달해 새 파일을 할당하지 않고, 새로 추가된 CHECK, NOT NULL, 파티션 제약을 기존 행이 위반하면 에러를 던진다.

ATRewriteTable 안의 스캔은 tab->oldDesc(1단계에서 스냅샷한 디스크립터)로 각 이전 튜플을 읽고, 재작성 시 현재 디스크립터로 구성한 새 슬롯에 프로젝션하면서 큐잉된 NewColumnValue 표현식을 평가한다.

// ATRewriteTable (per-tuple rewrite, condensed) — tablecmds.c
while (table_scan_getnextslot(scan, ForwardScanDirection, oldslot))
{
if (tab->rewrite > 0)
{
slot_getallattrs(oldslot);
ExecClearTuple(newslot);
/* copy unchanged attributes straight across */
memcpy(newslot->tts_values, oldslot->tts_values,
sizeof(Datum) * oldslot->tts_nvalid);
memcpy(newslot->tts_isnull, oldslot->tts_isnull,
sizeof(bool) * oldslot->tts_nvalid);
/* dropped columns become NULL in the new layout */
foreach(lc, dropped_attrs)
newslot->tts_isnull[lfirst_int(lc)] = true;
/* evaluate ALTER TYPE / new-default transform expressions */
econtext->ecxt_scantuple = oldslot;
foreach(l, tab->newvals)
{
NewColumnValue *ex = lfirst(l);
if (ex->is_generated) continue;
newslot->tts_values[ex->attnum - 1] =
ExecEvalExpr(ex->exprstate, econtext,
&newslot->tts_isnull[ex->attnum - 1]);
}
ExecStoreVirtualTuple(newslot);
insertslot = newslot;
}
else
insertslot = oldslot; /* verify in place, no projection */
/* NOT NULL / CHECK / partition checks run for BOTH paths */
econtext->ecxt_scantuple = insertslot;
foreach_int(attn, notnull_attrs)
if (slot_attisnull(insertslot, attn))
ereport(ERROR, (errcode(ERRCODE_NOT_NULL_VIOLATION), ...));
foreach(l, tab->constraints) { /* CONSTR_CHECK -> ExecCheck */ }
/* if (newrel) table_tuple_insert(newrel, insertslot, ...) */
}

이 루프가 전체 설계의 핵심이다. 첫 번째 세부 사항: 동일한 루프가 재작성 여부와 무관하게 새 제약을 강제한다. 검증 전용 경로는 oldslotinsertslot으로 재사용하고 삽입하지 않는다. 두 번째 세부 사항: 루프는 서브커맨드가 newvalsconstraints에 기여한 수와 무관하게 테이블당 한 번만 실행된다. 컬럼 타입 세 개를 바꾸고 CHECK를 두 개 추가해도 힙 스캔은 한 번이다. 릴레이션을 재작성할 때 ATRewriteTable은 앞서 TransferPredicateLocksToHeapRelation(oldrel)을 호출한다. 튜플을 새 파일로 이동하면 기존 튜플·페이지 수준 SSI 프레디케이트 락이 무효화되기 때문이다.

flowchart TD
    A["ATRewriteTables: foreach table in wqueue"] --> B{"RELKIND_HAS_STORAGE?"}
    B -->|no| A
    B -->|yes| C{"tab->rewrite > 0?"}
    C -->|"yes (not sequence)"| D["make_new_heap -> new relfilenode"]
    D --> E["ATRewriteTable(tab, OIDNewHeap)<br/>scan oldDesc, project newDesc<br/>eval newvals, check constraints<br/>table_tuple_insert into new file"]
    E --> F["finish_heap_swap<br/>swap_relation_files (relfilenode)<br/>rebuild indexes, drop old heap"]
    C -->|"no, but constraints/notnull pending"| G["ATRewriteTable(tab, InvalidOid)<br/>scan only, no new file<br/>error if a row violates"]
    C -->|"no, only SET TABLESPACE"| H["ATExecSetTableSpace<br/>block-by-block file copy"]
    C -->|"nothing pending"| I["skip table entirely"]
    F --> J["final loop: validate FK constraints<br/>execute tab->afterStmts"]
    G --> J
    H --> J
    I --> J

1단계(ATPrepCmd)는 단일 구문이 자식 테이블로 확산되는 지점이기도 하다. ATPrepCmdATGetQueueEntryAlteredTableInfo를 찾거나 생성하고, 재귀 가능한 서브타입이면 ATSimpleRecursion을 구동한다. ATSimpleRecursionfind_all_inheritors로 각 자손에 하나씩 작업 큐 항목을 큐잉하고 각각에서 준비 루틴을 다시 실행한다. 파티셔닝된 부모의 DROP COLUMN이 모든 파티션에서 자동으로 해당 컬럼을 삭제하는 원리가 이것이다. 각 파티션은 고유한 AlteredTableInfo, 고유한 패스별 버킷, 3단계에서의 고유한 단일 힙 스캔을 가진다. 재귀는 완전히 1단계에서 일어나므로, 2단계가 실행될 때 큐에는 이미 영향받는 모든 릴레이션이 담겨 있고 ATRewriteCatalogs가 “한 패스씩, 병렬로” 순회할 수 있다.

이 절은 호출 순서에 따라 단계별로 기호를 탐방한다. 유틸리티 훅에서 최종 FK 검증까지 ALTER TABLE을 따라갈 수 있다. 모든 기호는 이름으로 고정하고, 줄 번호는 절 끝의 위치 힌트 표에만 나온다.

  • AlterTable — 최상위 드라이버. NoLock으로 릴레이션을 다시 열고(호출자가 락을 이미 유지), CheckAlterTableIsSafe를 실행한 뒤 ATController를 호출한다. 헤더 주석이 3단계 계획과 MVCC 롤백 보증의 정식 서술이다.
  • AlterTableGetLockLevel — 테이블을 열기 전에 파싱된 서브커맨드 목록만 검사해 락을 계산한다. 서브타입별 락 수준의 최댓값을 ShareUpdateExclusiveLock을 하한으로 반환한다. 거대한 switch가 “어떤 ALTER에 어떤 락이 필요한지”의 정식 지도다. Hot Standby는 스탠바이 SELECT에 보이는 변경에 AccessExclusiveLock을 강제한다.
  • ATController — 3단계를 순서 지정한다. 서브커맨드를 foreach하며 ATPrepCmd 호출(1단계), relation_close(rel, NoLock), ATRewriteCatalogs(2단계), ATRewriteTables(3단계).
  • ATPrepCmd — 서브커맨드별 준비. AlteredTableInfo를 찾거나 생성하고, 서브타입을 relkind와 대조해 검증하며, 대상 패스를 결정하고 자식에 재귀한다. 나중에 서브커맨드가 변경할 수 있는 테이블 세부 정보를 읽는 것에 의도적으로 보수적이다.
  • ATGetQueueEntry — 릴레이션별 작업 큐 항목 찾기·생성 접근자. 생성 시점에 oldDesc(현재 튜플 디스크립터의 제약 포함 복사본)를 스냅샷한다. 3단계가 이전 튜플을 읽을 때 사용하는 디스크립터다.
  • ATSimpleRecursion / find_all_inheritors — 상속 자식/파티션으로 서브커맨드를 확산한다. 각각 하나씩 큐 항목을 생성한다.
  • AlteredTableInfo / AlterTablePass / AT_NUM_PASSES — 작업 큐 구조체, 고정 패스 열거형, 버킷 배열 크기. subcmdsList *[AT_NUM_PASSES]다.
  • ATRewriteCatalogsfor (pass = 0; pass < AT_NUM_PASSES; pass++) 외부 루프가 테이블을 순회하는 foreach 내부 루프를 감싼다. 모든 테이블을 “한 패스씩 병렬로” 처리하는 구조가 한 테이블의 서브커맨드가 다른 테이블의 나중 패스에 작업을 큐잉할 수 있게 한다(FK 재추가 예). ALTER TYPE/SET EXPRESSION 패스 후 ATPostAlterTypeCleanup을 호출하고, 끝에 필요한 곳에 TOAST 테이블을 추가한다.
  • ATExecCmdcmd->subtype에서 구체적인 실행자(ATExecAddColumn, ATExecDropColumn, ATExecAlterColumnType, ATExecColumnDefault 등)로의 디스패치 switch. 카탈로그가 실제로 변경되고 tab->rewrite 비트가 세워지는 곳이다.
  • ATExecAddColumnpg_attribute 행을 삽입한다. 기본값이 일반 릴레이션의 비휘발성 상수일 때 StoreAttrMissingVal로 빠른 기본값 경로를 취하고, 아니면 AT_REWRITE_DEFAULT_VAL을 세운다.
  • ATExecDropColumnRemoveAttributeById (heap.c) — 순수 카탈로그 논리 삭제. attisdropped = true, 타입 소거, ........pg.dropped.N........ 플레이스홀더로 이름 변경. tab->rewrite를 건드리지 않는다.
  • ATPrepAlterColumnType / ATColumnChangeRequiresRewrite — 변환 표현식을 계획하고 NewColumnValue를 큐잉하며, 강제 변환이 relabel(이진 강제 변환 및 varchar 길이 확장은 재작성을 건너뜀)을 넘는 경우에만 AT_REWRITE_COLUMN_REWRITE를 세운다.
  • StoreAttrMissingVal (heap.c) — 평가된 기본값을 pg_attribute.attmissingval에 쓰고 atthasmissing을 세운다. 빠른 기본값 저장소다.

3단계 — 스캔, 재작성, 교체, 검증

섹션 제목: “3단계 — 스캔, 재작성, 교체, 검증”
  • ATRewriteTablestab->rewrite와 대기 중인 제약 목록에 따른 테이블별 분기. 재작성 전에 EventTriggerTableRewrite를 발동한다. 시스템 릴레이션, 카탈로그 사용 릴레이션, 다른 백엔드의 임시 릴레이션 재작성은 거부한다.
  • make_new_heap (cluster.c) — 선택한 테이블스페이스, 접근 메서드, 영속성으로 임시 목적지 릴레이션을 구성한다. CLUSTER / VACUUM FULL과 공유한다.
  • ATRewriteTable — 단일 힙 스캔. tab->oldDesc로 읽고, 새 디스크립터로 프로젝션하고, tab->newvals를 평가(ExecEvalExpr)하며, 삭제된 속성을 NULL로 만들고, notnull_attrstab->constraints를 강제하며, newrel이 있으면 table_tuple_insert한다. 재작성 시 TransferPredicateLocksToHeapRelation을 호출한다.
  • finish_heap_swapswap_relation_files (cluster.c) — 이전·새 relfilenode(와 영속성)를 원자적으로 교체하고, 새 파일 기준으로 인덱스를 재구성하며, 이전 힙을 삭제한다. 모든 튜플이 방금 재작성됐으므로 RecentXmin을 새 relfrozenxid로 사용한다.
  • ATExecSetTableSpace — 재구성 없는 SET TABLESPACE 단축 경로. 튜플 재작성이 아닌 원시 블록 단위 파일 복사다.
  • AT_REWRITE_ALTER_PERSISTENCE / AT_REWRITE_DEFAULT_VAL / AT_REWRITE_COLUMN_REWRITE / AT_REWRITE_ACCESS_METHODevent_trigger.h의 네 재작성 이유 비트. tab->rewrite에 OR되고 ATRewriteTablesEventTriggerTableRewrite가 소비한다.

테이블별 루프가 끝난 후 ATRewriteTables는 새 외래 키 제약을 검증하는 최종 패스(FK 양쪽이 완전히 재작성된 후 검증을 위해 지연)를 실행하고 각 테이블의 tab->afterStmts를 실행한다.

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
기호파일
AlterTablePass (열거형)src/backend/commands/tablecmds.c148
AT_NUM_PASSESsrc/backend/commands/tablecmds.c166
AlteredTableInfo (구조체)src/backend/commands/tablecmds.c178
AlterTablesrc/backend/commands/tablecmds.c4534
AlterTableGetLockLevelsrc/backend/commands/tablecmds.c4608
ATControllersrc/backend/commands/tablecmds.c4870
ATPrepCmdsrc/backend/commands/tablecmds.c4905
ATRewriteCatalogssrc/backend/commands/tablecmds.c5302
ATExecCmdsrc/backend/commands/tablecmds.c5376
ATRewriteTablessrc/backend/commands/tablecmds.c5838
ATRewriteTablesrc/backend/commands/tablecmds.c6126
ATGetQueueEntrysrc/backend/commands/tablecmds.c6562
ATSimpleRecursionsrc/backend/commands/tablecmds.c6816
ATExecAddColumnsrc/backend/commands/tablecmds.c7217
ATExecDropColumnsrc/backend/commands/tablecmds.c9284
ATPrepAlterColumnTypesrc/backend/commands/tablecmds.c14384
ATColumnChangeRequiresRewritesrc/backend/commands/tablecmds.c14690
RemoveAttributeByIdsrc/backend/catalog/heap.c1700
StoreAttrMissingValsrc/backend/catalog/heap.c2047
make_new_heapsrc/backend/commands/cluster.c705
swap_relation_filessrc/backend/commands/cluster.c1063
finish_heap_swapsrc/backend/commands/cluster.c1445
AT_REWRITE_* 플래그src/include/commands/event_trigger.h40-43
  • 락 수준은 테이블을 열기 전에, 파싱된 서브커맨드 목록만으로 계산된다. AlterTableGetLockLevel 검증: 함수는 List *cmds를 받고 릴레이션을 건드리지 않는다. 하한은 ShareUpdateExclusiveLock이고 각 서브타입의 cmd_lockmode를 누적 최댓값으로 취한다. utility.c의 호출자가 락을 획득한 뒤 AlterTableNoLock으로 연다.

  • ALTER TABLEATController가 구동하는 3단계다. 검증: 1단계는 foreach/ATPrepCmd 루프, 릴레이션은 relation_closeNoLock 닫기, 2단계는 ATRewriteCatalogs, 3단계는 ATRewriteTables. ATController의 주석 블록이 모두 세 단계를 표지한다.

  • 2단계는 모든 테이블을 “한 패스씩, 병렬로” 실행한다. ATRewriteCatalogs 검증: 외부 루프는 for (AlterTablePass pass = 0; pass < AT_NUM_PASSES; pass++)이고 내부 foreach가 작업 큐를 순회한다. 헤더 주석은 작업이 나중 패스로만 전파될 수 있다고 명시한다.

  • 13개 패스, AT_PASS_DROP이 첫 번째, AT_PASS_MISC가 마지막. AlterTablePass 열거형 검증: AT_PASS_UNSET = -1, AT_PASS_DROP (0) … AT_PASS_MISC, AT_NUM_PASSES = AT_PASS_MISC + 1. ALTER TYPE(패스 1)이 OLD_INDEX/OLD_CONSTR 재추가 패스보다 앞서온다.

  • DROP COLUMN은 카탈로그 전용 논리 삭제다. RemoveAttributeById (heap.c) 검증: attisdropped = true, atttypid 소거, attnotnull, attgenerated 소거, ........pg.dropped.%d........로 이름 변경, pg_attribute 튜플 갱신. 힙 페이지는 건드리지 않고, ATExecDropColumntab->rewrite에 비트를 OR하지 않는다.

  • 비휘발성 상수 기본값의 ADD COLUMN은 빠른 기본값으로 메타데이터 전용이다. 검증: ATExecAddColumn은 기본값이 비휘발성, 비생성, 일반 RELKIND_RELATION일 때 StoreAttrMissingVal로 datum을 저장(attmissingval/atthasmissing 쓰기)한다. 아니면 AT_REWRITE_DEFAULT_VAL을 세운다. 기본값이 없는 nullable ADD COLUMN은 둘 다 필요 없다.

  • ALTER COLUMN TYPE은 이진 강제 변환에서 재작성을 건너뛴다. ATColumnChangeRequiresRewrite 검증: 루프는 변환이 원래 Var에 도달하면 false(재작성 불필요)를 반환하고, RelabelType을 벗기며, 제약 없는 CoerceToDomain은 투명하게 처리한다. FuncExpr/실제 계산에는 true를 반환한다. 결정은 AT_REWRITE_COLUMN_REWRITE를 세우고 NewColumnValue를 큐잉한다.

  • 재작성은 새 relfilenode를 구성하고 교체하며, 제자리를 변경하지 않는다. ATRewriteTables 검증: make_new_heapATRewriteTable(tab, OIDNewHeap)finish_heap_swap(...). finish_heap_swapswap_relation_files를 호출하고 RecentXmin을 새 relfrozenxid로 사용한다. 검증 전용 분기는 InvalidOid를 전달하고 파일을 할당하지 않는다.

  • 3단계는 테이블당 최대 한 번 스캔한다. ATRewriteTable 검증: 단일 table_beginscan / table_scan_getnextslot 루프가 새 튜플을 프로젝션(tab->rewrite일 때)하면서 모든 notnull_attrs, tab->constraints, 파티션 제약을 확인한다. 여러 서브커맨드가 단일 스캔을 공유한다.

  • 재작성 이유는 event_trigger.h의 4비트 마스크다. 검증: AT_REWRITE_ALTER_PERSISTENCE 0x01, AT_REWRITE_DEFAULT_VAL 0x02, AT_REWRITE_COLUMN_REWRITE 0x04, AT_REWRITE_ACCESS_METHOD 0x08. 마스크는 ATRewriteTables에서 소비되고 EventTriggerTableRewrite로 브로드캐스트된다.

  1. ShareRowExclusiveLock vs. AccessExclusiveLock을 받는 서브커맨드 서브타입의 정확한 집합. 세 가지 락 단계와 하한은 명시됐지만, 전체 서브타입별 표는 크고 발전한다. 정식 목록은 이 개정판의 AlterTableGetLockLevel switch다. 조사 경로: 모든 casecmd_lockmode를 열거하고 사용자 문서의 “ALTER TABLE 락 수준” 주석과 대조한다.

  2. ALTER TYPE이 패스 경계를 넘어 의존 객체(인덱스, 뷰, 제약)를 어떻게 재구성하는가. 이 문서는 ATPostAlterTypeCleanupOLD_INDEX/OLD_CONSTR 재추가 패스를 블랙박스로 취급한다. 어떤 객체를 드롭·재생성할지 결정하는 의존성 추적은 postgres-ddl-execution.md가 소유한다.

  3. 빠른 기본값 컬럼과 이후 테이블 재작성 ALTER의 정확한 상호작용. attmissingval이 한번 세트되면, 이후 재작성이 모든 튜플에 빠진 값을 실체화하고 플래그를 초기화해야 한다. 재작성 중 atthasmissing이 정확히 어느 시점에 초기화되는지는 추적하지 않았다.

PostgreSQL 너머 — 비교 설계와 연구 프런티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”
  • MySQL/InnoDB ALGORITHM=INSTANT|INPLACE|COPY. InnoDB는 재작성 vs. 메타데이터 선택을 명시적 DDL 구문으로 노출한다. MySQL 8.0의 INSTANT ADD COLUMN은 PostgreSQL 빠른 기본값의 직접적인 유사체다. 컬럼 메타데이터를 기록하고 이전 행은 저장된 기본값을 읽는 방식이다. COPY는 메타데이터 락 아래 테이블을 재구성한다. InnoDB의 instant 컬럼 메타데이터와 attmissingval을 나란히 비교하면 각 엔진이 재작성 없이 할 수 있는 것과 없는 것이 선명해진다. 컬럼 위치, instant DROP COLUMN 지원이 그 예다.

  • 온라인 스키마 변경 도구 (gh-ost, pt-online-schema-change). 많은 엔진이 전체 재작성 동안 배타 락을 유지했기 때문에, 외부 도구들이 섀도 테이블을 구성하고 백필하며 트리거나 binlog로 동시 쓰기를 캡처한 뒤 짧은 락 아래 전환하는 방식을 개발했다. PostgreSQL의 인프로세스 make_new_heap+finish_heap_swap은 섀도 테이블과 교체를 하나의 트랜잭션 안에서 수행하지만, 여전히 전체 시간 동안 AccessExclusiveLock 아래 있다. 동일한 온라인 DDL 압박이 존재한다는 뜻이다. gh-ost의 트리거 기반 전환과 PostgreSQL의 단일 락 재작성을 대비하면 PostgreSQL 사용자들이 왜 CREATE INDEX CONCURRENTLY 스타일 패턴과 논리 복제 기반 마이그레이션에 여전히 손을 뻗는지 알 수 있다.

  • Google F1 / Spanner 비동기 스키마 변경. Rae et al., Online, Asynchronous Schema Change in F1 (VLDB 2013)은 스키마 진화를 중간 상태(delete-only, write-only)의 시퀀스로 형식화한다. 서로 다른 스키마 버전의 노드가 서로의 데이터를 손상시키지 않도록 하는 근본적으로 분산된 해답이다. PostgreSQL의 단일 노드·단일 락 모델은 이 문제를 완전히 우회한다. 대비가 시사하는 바가 있다. PostgreSQL은 MVCC와 하나의 락으로 원자성을 얻고, F1은 단계적·버전 허용 상태로 가용성을 얻는다.

  • Aurora / Neon과 스토리지 분리 재작성. 스토리지가 별도 서비스일 때 전체 힙 재작성은 로컬 I/O가 아니라 네트워크 대역폭 소비다. “그냥 재작성하자”는 비용 계산이 달라진다. 분리 엔진이 finish_heap_swap의 원자적 relfilenode 교체(데이터 이동 vs. 메타데이터 플립)를 어떻게 구현할지는 비교 관점에서 열린 질문이다.

  • CUBRID ALTER TABLE. CUBRID도 메타데이터 전용 alter와 리로드를 강제하는 것을 구분하지만, 카탈로그와 락 모델이 다르다. cubrid 트리의 CUBRID 스키마 변경 경로와 비교하면 PostgreSQL의 저렴한 경로 기계(빠른 기본값, attisdropped, 이진 강제 변환 단축)가 CUBRID에 대응물을 가지는지 드러낼 것이다.

인트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)

섹션 제목: “인트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)”
  • src/backend/commands/tablecmds.c — 전체 ALTER TABLE 기계: AlterTable, AlterTableGetLockLevel, ATController, ATPrepCmd, ATRewriteCatalogs, ATExecCmd, ATRewriteTables, ATRewriteTable, ATGetQueueEntry, ATSimpleRecursion, ATExecAddColumn, ATExecDropColumn, ATPrepAlterColumnType, ATColumnChangeRequiresRewrite, AlterTablePass 열거형, AlteredTableInfo 구조체.
  • src/backend/commands/cluster.cmake_new_heap, swap_relation_files, finish_heap_swap. CLUSTER / VACUUM FULL과 공유.
  • src/backend/catalog/heap.cRemoveAttributeById (attisdropped 논리 삭제)와 StoreAttrMissingVal (빠른 기본값 저장소).
  • src/include/commands/event_trigger.hATRewriteTablesEventTriggerTableRewrite가 소비하는 AT_REWRITE_* 이유 비트 매크로.
  • src/include/nodes/parsenodes.hAlterTableStmt, AlterTableCmd, 디스패치 switch가 사용하는 AlterTableType (AT_* 서브타입) 열거형.
  • Database System Concepts (Silberschatz, Korth, Sudarshan, 7e), 4장 “Intermediate SQL” / DDL, 2장 릴레이션/스키마 기초 — ALTER TABLE 설계 공간을 분리하는 메타데이터 vs. 데이터 구분 (knowledge/research/dbms-general/).
  • Database Internals (Petrov 2019) — 재작성의 배경인 relfilenode / 파일 교체와 WAL 프레이밍 (knowledge/research/dbms-general/).
  • Rae, I. et al. (2013). “Online, Asynchronous Schema Change in F1.” PVLDB 6(11):1045-1056. 단일 락 DDL에 대한 분산 단계 상태 대안.

형제 문서 (교차 참조 — 기계는 거기서 소유, 여기서는 중복 없음)

섹션 제목: “형제 문서 (교차 참조 — 기계는 거기서 소유, 여기서는 중복 없음)”
  • postgres-ddl-execution.md — 유틸리티 커맨드 디스패치, 의존성 추적, ALTER TYPEOLD_INDEX/OLD_CONSTR 패스가 의존하는 객체 재구성.
  • postgres-constraints.mdCHECK/NOT NULL/외래 키 제약 표현과 ALTER TABLE의 3단계 최종 루프가 호출하는 FK 검증.
  • postgres-index-creation.md — 인덱스 구성/재구성. ALTER TYPE의 인덱스 재추가 패스와 finish_heap_swap의 인덱스 재구성이 호출한다.
  • postgres-heap-am.mdATRewriteTable이 재작성된 튜플을 쓰는 table_tuple_insert / 힙 스토리지.
  • postgres-toast.mdATRewriteCatalogs 끝에서 트리거되는 TOAST 테이블 생성 (AlterTableCreateToastTable).
  • postgres-lock-manager.mdAlterTableGetLockLevel 락 수준 뒤의 헤비웨이트 락 테이블.