(KO) PostgreSQL 구체화 뷰 — 저장 구조와 REFRESH (CONCURRENTLY 포함)
목차
- 이론적 배경
- DBMS 공통 설계
- PostgreSQL의 접근 방식
- 소스 코드 워크스루
- 소스 검증 (2026-06-06 기준)
- PostgreSQL 너머 — 비교 설계와 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”**뷰(view)**는 이름이 붙은 쿼리다. SQL 엔진이 FROM 절에서 뷰를 만나면 그 자리에 정의 쿼리를 대입한다. 뷰는 가상이므로 참조할 때마다 다시 계산된다. **구체화 뷰(materialized view)**는 이 신선도를 속도와 맞바꾼다. 쿼리 결과를 한 번 계산해서 물리 테이블로 저장하므로 이후 읽기는 값비싼 조인과 집계를 반복하지 않고 미리 계산된 행을 스캔하면 된다.
Database System Concepts(Silberschatz, 7판, §4.2.3·§16.5)는 구체화 뷰를 공간-시간, 신선도-시간 사이의 전형적 트레이드오프로 정의하고, 시스템이 선택해야 하는 유지 관리 방식 세 가지를 제시한다.
-
즉시(eager) 유지 — 기반 테이블을 변경하는 트랜잭션 안에서 구체화 뷰도 함께 갱신한다. 뷰가 결코 오래된 상태가 되지 않지만, 쓰기 경로마다 유지 비용이 더해진다.
-
지연(lazy/on-demand) 유지 — 명시적 요청이 있을 때만 뷰를 새로 고친다. 갱신 사이에는 뷰가 오래된(stale) 상태다.
-
주기적(periodic/snapshot) 유지 — 스케줄(매 시간, 매 밤)에 따라 뷰를 재계산한다. 트리거가 시계인 지연 유지의 특수한 경우다.
얼마나 재계산할지는 언제 할지와 직교한다.
-
전체 재계산(full recomputation) — 저장된 결과를 버리고 정의 쿼리를 처음부터 다시 실행한다. 항상 정확하지만 결과 크기에 비례하는 비용이 든다.
-
증분 뷰 유지(incremental view maintenance, IVM) — 기반 테이블에 적용된 델타로부터 뷰에 대한 대응 델타를 관계 대수로 유도한다. Gupta–Mumick–Subrahmanian 1993(계수 알고리즘)이 대표 이론이다. 변경이 작을 때 효율적이지만 뷰의 대수를 엔진이 이해하고 중복도를 추적해야 한다.
PostgreSQL은 지연 + 전체 재계산 모퉁이에 자리한다. 구체화 뷰는 자동으로 유지되지 않는다. 기반 테이블이 바뀌는 순간 뷰는 오래된 상태가 되고, 명시적 REFRESH MATERIALIZED VIEW만이 뷰를 최신 상태로 만든다. 재계산은 항상 정의 쿼리 전체를 실행한다(PostgreSQL에 내장 IVM은 없다). 정교한 부분은 재계산 결과를 적용하는 방식이다. 물리 파일을 통째로 교체하거나, 동시 읽기를 허용한 채 행 단위 diff-merge를 수행하는 두 전략이 있다. 후자인 REFRESH ... CONCURRENTLY가 matview.c의 핵심 구조다.
쿼리 재작성을 통한 구체화 뷰 활용 — 옵티마이저가 구체화 뷰로 답할 수 있는 쿼리를 투명하게 대체하는 기능이 있는데, PostgreSQL은 이를 지원하지 않는다. 구체화 뷰는 명시적으로 참조할 때만 사용된다.
populated 비트 — 유효한 결과가 없을 수도 있는 저장 릴레이션은 “유효한 결과가 있다/없다”를 나타내는 플래그가 필요하다. PostgreSQL은 pg_class.relispopulated에 이를 기록하며, 채워지지 않은 구체화 뷰를 스캔하면 오류가 발생한다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”구체화 뷰 구현은 시스템을 막론하고 몇 가지 공통 설계 관행으로 수렴한다.
일반 테이블과 동일한 저장소
섹션 제목: “일반 테이블과 동일한 저장소”구체화 뷰의 내용은 스캔 가능한 곳에 저장되어야 한다. 보편적인 선택은 시스템이 일반 테이블에 사용하는 구조 — 힙, 클러스터드 B-트리, 컬럼 세그먼트 파일 — 와 동일한 구조를 재사용하는 것이다. 그러면 기존의 스캔, 인덱스, 통계, vacuum 기능이 그대로 적용된다. PostgreSQL은 구체화 뷰를 일반 힙(relkind = RELKIND_MATVIEW이지만 실제 relfilenode를 가짐)으로 저장한다.
정의 쿼리를 카탈로그에 보관
섹션 제목: “정의 쿼리를 카탈로그에 보관”새로 고치려면 시스템이 행이 어떻게 만들어졌는지 기억해야 한다. PostgreSQL은 기존 규칙(rule) 인프라를 재사용한다. 구체화 뷰는 단 하나의 SELECT INSTEAD 재작성 규칙을 가지며, 그 동작(action) 리스트가 정의 Query 트리다. 이는 일반 VIEW가 쓰는 것과 같은 표현이다. 새로 고침은 릴캐시 규칙에서 Query를 꺼내 다시 계획한다.
재계산 결과의 원자적 교체
섹션 제목: “재계산 결과의 원자적 교체”전체 재계산은 완전한 새 결과를 만든다. 이를 노출할 때 원자성이 필요하다. 읽기 작업이 이전 내용과 새 내용 사이의 중간 상태를 보면 안 된다. 두 가지 표준 기법이 있다.
-
포인터/파일 교체 — 새 내용을 부속 릴레이션에 만든 뒤 단일 카탈로그 포인터(relfilenode)를 뒤집어 구체화 뷰가 새 파일을 참조하도록 한다. O(1) 전환이지만 진행 중인 스캔 아래에서 파일을 빼앗기 때문에 배타 잠금이 필요하다.
-
집합 기반 DELETE/INSERT diff — 어떤 행이 바뀌었는지 계산하고 그 변경만 트랜잭션 안의 일반 DML로 적용한다. MVCC가 동시 읽기에 이전 내용의 일관된 스냅샷을 제공하므로 약한 잠금으로 충분하다. 느리지만(행을 건드리고, 조인을 수행하고, 인덱스를 유지) 동시 읽기를 허용한다.
PostgreSQL은 둘 다 구현한다. 파일 교체는 기본 경로(refresh_by_heap_swap), diff는 REFRESH ... CONCURRENTLY 경로(refresh_by_match_merge)다.
”신선도 / 유효” 플래그
섹션 제목: “”신선도 / 유효” 플래그”구체화 뷰는 유효한 내용 없이도 존재할 수 있으므로, 시스템은 “유효한 결과로 채워짐”과 “정의되었지만 비었거나 사용 불가”를 구별하는 플래그를 관리한다. PostgreSQL은 pg_class.relispopulated를 사용하며, 채워지지 않은 구체화 뷰를 스캔하는 것은 빈 결과가 아니라 오류다.
flowchart TD
A["CREATE MATERIALIZED VIEW foo AS SELECT ..."] --> B{"WITH DATA(기본) 또는<br/>WITH NO DATA?"}
B -->|WITH NO DATA| C["릴레이션 생성<br/>relispopulated = false<br/>스캔 시 오류"]
B -->|WITH DATA| D["릴레이션 생성 (skipData),<br/>이후 REFRESH 경로<br/>relispopulated = true"]
C -->|"REFRESH MATERIALIZED VIEW foo"| E["전체 재계산"]
D --> F["스캔 가능"]
E --> G{"CONCURRENTLY?"}
G -->|no| H["refresh_by_heap_swap<br/>AccessExclusiveLock<br/>relfilenode 교체"]
G -->|yes| I["refresh_by_match_merge<br/>ExclusiveLock<br/>diff + DELETE/INSERT"]
H --> F
I --> F
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL 구체화 뷰는 거의 전부 src/backend/commands/matview.c에 구현되어 있다. 생성 측 뼈대는 createas.c에, 물리 힙 교체 기본 연산은 CLUSTER/VACUUM FULL과 공유하는 cluster.c에 있다. 설계는 네 가지 요소로 구성된다.
- 저장소 — 실제 힙과 정의
Query를 담은SELECT INSTEAD규칙을 가진RELKIND_MATVIEW릴레이션. - 생성 — 릴레이션 생성 시에는 항상
WITH NO DATA로, 실제 채우기는 REFRESH 경로에 위임. - 새로 고침 — 커스텀
DestReceiver로 임시 힙에 재계산한 뒤 힙 교체 또는 diff-merge. - 상태 —
pg_class.relispopulated,SetMatViewPopulatedState로 제어, 스캔 가능 여부 결정.
저장소: 힙 + 규칙
섹션 제목: “저장소: 힙 + 규칙”구조적으로 구체화 뷰는 테이블과 뷰의 합집합이다. 힙을 가지므로 스캔과 인덱스가 가능하고, VIEW와 동일한 단일 동작 SELECT INSTEAD 재작성 규칙을 가지므로 엔진이 정의 쿼리를 기억한다. RefreshMatViewByOid는 릴캐시에서 Query를 직접 꺼내며, 규칙이 정확히 하나의 SELECT INSTEAD 동작인지 엄격하게 검증한다.
// RefreshMatViewByOid — src/backend/commands/matview.cif (matviewRel->rd_rel->relhasrules == false || matviewRel->rd_rules->numLocks < 1) elog(ERROR, "materialized view \"%s\" is missing rewrite information", ...);if (matviewRel->rd_rules->numLocks > 1) elog(ERROR, "materialized view \"%s\" has too many rules", ...);
rule = matviewRel->rd_rules->rules[0];if (rule->event != CMD_SELECT || !(rule->isInstead)) elog(ERROR, "the rule for materialized view \"%s\" is not a SELECT INSTEAD OF rule", ...);
actions = rule->actions;if (list_length(actions) != 1) elog(ERROR, "the rule for materialized view \"%s\" is not a single action", ...);/* The stored query was rewritten at MV definition time, not planner-scribbled. */dataQuery = linitial_node(Query, actions);구체화 뷰의 힙은 일급 릴레이션이다. 인덱스(CONCURRENTLY에 필요한 UNIQUE 인덱스 포함), reloptions, 테이블 접근 방법(relam), 테이블스페이스, TOAST 저장소를 모두 가질 수 있다. 카탈로그 아래의 모든 것은 일반 테이블과 공유하는 힙 메커니즘이다(postgres-heap-am.md 참조).
생성은 항상 데이터를 REFRESH에 위임
섹션 제목: “생성은 항상 데이터를 REFRESH에 위임”CREATE MATERIALIZED VIEW는 createas.c의 CREATE TABLE AS 실행기를 공유한다. 핵심 결정: 구체화 뷰는 항상 릴레이션 생성 시에 WITH NO DATA로 만들고, 실제 채우기는 새로 고침 경로에 위임한다. 이렇게 하면 새 릴레이션의 모든 의존성이 연결되기 전에 계획기/실행기를 실행하는 문제를 피할 수 있다.
// ExecCreateTableAs / create path — src/backend/commands/createas.cis_matview = (into->viewQuery != NULL);.../* For materialized views, always skip data during table creation, * and use REFRESH instead (see below). */if (is_matview){ do_refresh = !into->skipData; /* WITH DATA -> refresh after create */ into->skipData = true;}
if (into->skipData){ address = create_ctas_nodata(query->targetList, into); /* For materialized views, reuse the REFRESH logic, which locks down * security-restricted operations and restricts the search_path. */ if (do_refresh) RefreshMatViewByOid(address.objectId, true /* is_create */, false, false, pstate->p_sourcetext, qc);}CREATE MATERIALIZED VIEW ... WITH DATA는 기계적으로 빈 구체화 뷰 생성 후 REFRESH와 같다. is_create 플래그는 완료 태그(CMDTAG_SELECT vs CMDTAG_REFRESH_MATERIALIZED_VIEW) 선택과 CONCURRENTLY 경로 건너뛰기(새로 만든 구체화 뷰는 diff 대상이 없음)에만 사용된다.
새로 고침: 임시 힙으로 재계산
섹션 제목: “새로 고침: 임시 힙으로 재계산”전략에 무관하게 모든 새로 고침의 첫 번째 단계는 동일하다. 새 힙을 만들고 저장된 쿼리를 실행해, 결과 튜플을 frozen 벌크 삽입하는 커스텀 DestReceiver로 보낸다. ExecRefreshMatView는 SQL 진입점이며, CONCURRENTLY 여부에 따라 잠금 강도를 먼저 결정한다.
// ExecRefreshMatView — src/backend/commands/matview.c/* Determine strength of lock needed. */lockmode = stmt->concurrent ? ExclusiveLock : AccessExclusiveLock;
matviewOid = RangeVarGetRelidExtended(stmt->relation, lockmode, 0, RangeVarCallbackMaintainsTable, NULL);
return RefreshMatViewByOid(matviewOid, false, stmt->skipData, stmt->concurrent, queryString, qc);이 잠금 선택이 CONCURRENTLY의 핵심이다. 기본 경로는 AccessExclusiveLock(읽기 차단), CONCURRENTLY는 ExclusiveLock만 잡는다(쓰기와 다른 새로 고침은 차단하지만 AccessShareLock 읽기, 즉 일반 SELECT는 허용).
RefreshMatViewByOid는 옵션을 검증하고, populated 비트를 잠정적으로 뒤집고, make_new_heap으로 새 힙을 만들고(CLUSTER와 동일한 기본 연산), 쿼리를 실행해 힙을 채운 뒤 두 전략 중 하나로 분기한다.
// RefreshMatViewByOid — src/backend/commands/matview.c/* Tentatively mark the matview as populated or not (rolls back on failure). */SetMatViewPopulatedState(matviewRel, !skipData);
/* Concurrent refresh builds new data in temp tablespace, and does diff. */if (concurrent) { tableSpace = GetDefaultTablespace(RELPERSISTENCE_TEMP, false); relpersistence = RELPERSISTENCE_TEMP;} else { tableSpace = matviewRel->rd_rel->reltablespace; relpersistence = matviewRel->rd_rel->relpersistence;}
OIDNewHeap = make_new_heap(matviewOid, tableSpace, matviewRel->rd_rel->relam, relpersistence, ExclusiveLock);
if (!skipData) { DestReceiver *dest = CreateTransientRelDestReceiver(OIDNewHeap); processed = refresh_matview_datafill(dest, dataQuery, queryString, is_create);}
if (concurrent) { refresh_by_match_merge(matviewOid, OIDNewHeap, relowner, save_sec_context);} else { refresh_by_heap_swap(matviewOid, OIDNewHeap, relpersistence); pgstat_count_truncate(matviewRel); if (!skipData) pgstat_count_heap_insert(matviewRel, processed);}비동시 경로가 누적 통계 시스템에 “잘라내고 다시 삽입”을 명시적으로 알리는 것을 주목할 것. 힙 교체는 행 단위 통계 카운터에 보이지 않기 때문이다. 동시 경로의 DELETE/INSERT DML은 하위 계층 코드가 자동으로 계산한다.
데이터 채우기는 임시 릴레이션으로 리디렉션된 일반 쿼리 실행이다. 저장된 Query를 다시 계획하고 스냅샷을 밀어 넣은 뒤 DestReceiver로 계획을 실행한다.
// refresh_matview_datafill — src/backend/commands/matview.ccopied_query = copyObject(query);AcquireRewriteLocks(copied_query, true, false);rewritten = QueryRewrite(copied_query);query = (Query *) linitial(rewritten);
plan = pg_plan_query(query, queryString, CURSOR_OPT_PARALLEL_OK, NULL);
PushCopiedSnapshot(GetActiveSnapshot());UpdateActiveSnapshotCommandId();queryDesc = CreateQueryDesc(plan, queryString, GetActiveSnapshot(), InvalidSnapshot, dest, NULL, NULL, 0);ExecutorStart(queryDesc, 0);ExecutorRun(queryDesc, ForwardScanDirection, 0);processed = queryDesc->estate->es_processed;임시 릴레이션 DestReceiver
섹션 제목: “임시 릴레이션 DestReceiver”쿼리가 생성한 튜플은 클라이언트로 가지 않는다. DR_transientrel이라는 DestReceiver의 receiveSlot 콜백이 각 튜플을 새 힙에 삽입한다. 두 가지 성능 옵션이 로드를 빠르고 저렴하게 만든다. TABLE_INSERT_SKIP_FSM은 벌크 로드 중인 릴레이션의 자유 공간 맵 갱신을 건너뛰고, TABLE_INSERT_FROZEN은 행을 미리 frozen 상태로 삽입한다. 새 힙은 이 트랜잭션에만 사적이고 원자적으로 커밋되므로 frozen 삽입이 안전하다.
// transientrel_startup — src/backend/commands/matview.cmyState->transientrel = transientrel;myState->output_cid = GetCurrentCommandId(true);myState->ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_FROZEN;myState->bistate = GetBulkInsertState();// transientrel_receive — src/backend/commands/matview.ctable_tuple_insert(myState->transientrel, slot, myState->output_cid, myState->ti_options, myState->bistate);/* We know this is a newly created relation, so there are no indexes */return true;“인덱스가 없다”는 주석이 RefreshMatViewByOid 상단 주석의 근거다. 힙을 벌크 로드한 뒤 인덱스를 한 번에 만드는 것이, 행마다 인덱스를 유지하는 것보다 훨씬 저렴하다. CheckTableNotInUse가 교체 전 열린 스캔이 없음을 보장하므로 TABLE_INSERT_FROZEN이 안전하다.
상태: relispopulated와 스캔 가능성
섹션 제목: “상태: relispopulated와 스캔 가능성”populated 비트는 pg_class.relispopulated에 있다. SetMatViewPopulatedState는 그 단일 컬럼을 갱신하고 — 핵심적으로 — 카탈로그 갱신이 공유 무효화 메시지를 보내 모든 백엔드(현재 백엔드 포함)가 릴캐시 항목을 재구성하고 새 상태를 반영하도록 한다.
// SetMatViewPopulatedState — src/backend/commands/matview.cAssert(relation->rd_rel->relkind == RELKIND_MATVIEW);pgrel = table_open(RelationRelationId, RowExclusiveLock);tuple = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(RelationGetRelid(relation)));((Form_pg_class) GETSTRUCT(tuple))->relispopulated = newstate;CatalogTupleUpdate(pgrel, &tuple->t_self, tuple);table_close(pgrel, RowExclusiveLock);/* Advance command counter so the updated pg_class row is locally visible. */CommandCounterIncrement();플래그는 두 릴캐시 매크로로 읽힌다. 현재는 동일한 필드를 가리키지만, 헤더 주석은 향후 populated와 scannable이 다를 수 있다고 명시한다.
// RelationIsScannable / RelationIsPopulated — src/include/utils/rel.h#define RelationIsScannable(relation) ((relation)->rd_rel->relispopulated)#define RelationIsPopulated(relation) ((relation)->rd_rel->relispopulated)스캔 가능성 검사는 스캔 노드가 릴레이션을 열 때 실행기에서 강제된다. 채워지지 않은 구체화 뷰를 스캔하면 빈 결과가 아니라 하드 오류가 발생한다. 단, 실제로 쿼리가 실행되지 않는 경우(EXPLAIN만 하거나 WITH NO DATA 생성이 형태만 정의)는 예외다.
// ExecOpenScanRelation — src/backend/executor/execUtils.cif ((eflags & (EXEC_FLAG_EXPLAIN_ONLY | EXEC_FLAG_WITH_NO_DATA)) == 0 && !RelationIsScannable(rel)) ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), errmsg("materialized view \"%s\" has not been populated", RelationGetRelationName(rel)), errhint("Use the REFRESH MATERIALIZED VIEW command.")));채워지지 않은 구체화 뷰에 CONCURRENTLY를 거부하는 이유가 바로 이것이다. diff할 유효한 이전 내용이 없기 때문이다. RefreshMatViewByOid에서 명시적으로 차단한다.
// RefreshMatViewByOid — src/backend/commands/matview.cif (concurrent && !RelationIsPopulated(matviewRel)) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("CONCURRENTLY cannot be used when the materialized view is not populated")));if (concurrent && skipData) ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), errmsg("%s and %s options cannot be used together", "CONCURRENTLY", "WITH NO DATA")));소스 코드 워크스루
섹션 제목: “소스 코드 워크스루”두 가지 새로 고침 전략을 심볼 단위로 걷고, 이후 상태 및 보조 기계를 살펴본다. 달리 표기하지 않으면 모든 심볼은 matview.c에 있다.
진입점과 분기
섹션 제목: “진입점과 분기”ExecRefreshMatView는 REFRESH MATERIALIZED VIEW의 유틸리티 명령 처리기다. 잠금 강도를 결정하고(CONCURRENTLY이면 ExclusiveLock, 아니면 AccessExclusiveLock) RefreshMatViewByOid를 호출한다. 동일한 RefreshMatViewByOid가 createas.c에서 is_create = true로 호출되어 WITH DATA로 새로 만든 구체화 뷰를 채운다. RefreshMatViewByOid가 실질적인 작업을 수행한다. relkind/옵션 검증, 규칙에서 dataQuery 추출, CheckTableNotInUse, SetMatViewPopulatedState, make_new_heap, refresh_matview_datafill, 이후 refresh_by_heap_swap 또는 refresh_by_match_merge로 분기한다.
비동시: 전체 힙 교체
섹션 제목: “비동시: 전체 힙 교체”refresh_by_heap_swap은 cluster.c의 공유 finish_heap_swap 기본 연산을 감싸는 한 줄짜리 래퍼다. CLUSTER와 VACUUM FULL이 릴레이션의 저장소를 원자적으로 교체하는 데 사용하는 바로 그 메커니즘이다.
// refresh_by_heap_swap — src/backend/commands/matview.cstatic voidrefresh_by_heap_swap(Oid matviewOid, Oid OIDNewHeap, char relpersistence){ finish_heap_swap(matviewOid, OIDNewHeap, false, false, true, true, RecentXmin, ReadNextMultiXactId(), relpersistence);}finish_heap_swap은 카탈로그 안에서 구체화 뷰와 임시 힙의 relfilenode(및 TOAST 릴레이션, TOAST 인덱스)를 교체하고, 새 파일 위에 구체화 뷰의 인덱스를 재구성(REINDEX)한 뒤 고아가 된 이전 파일을 삭제한다. 교체는 카탈로그 포인터 변경이므로 결과 크기에 무관하게 O(1)이다. 하지만 진행 중인 스캐너 아래에서 이전 힙 파일을 빼앗으므로 기본 경로가 AccessExclusiveLock을 잡는 이유가 된다. 구체화 뷰의 OID는 교체 후에도 유지되므로 GRANT, 의존성, 참조가 모두 살아남는다. 변하는 것은 하위 relfilenode뿐이다(postgres-heap-am.md의 relfilenode 수명 주기 참조).
동시: match-merge diff
섹션 제목: “동시: match-merge diff”refresh_by_match_merge는 REFRESH ... CONCURRENTLY의 핵심이다. 구체화 뷰에 이미 잡힌 ExclusiveLock 아래에서 SPI(서버측 SQL)로 일련의 단계를 순차 실행한다.
단계 1 — 새 데이터 ANALYZE. 새로 채운 임시 테이블에는 통계가 없다. 계획기가 diff 조인에 적합한 계획을 선택하려면 통계가 필요하다.
// refresh_by_match_merge — src/backend/commands/matview.cappendStringInfo(&querybuf, "ANALYZE %s", tempname);if (SPI_exec(querybuf.data, 0) != SPI_OK_UTILITY) elog(ERROR, "SPI_exec failed: %s", querybuf.data);단계 2 — 중복 비-NULL 행 거부. diff는 구별할 수 없는 동일한 행이 두 개 있으면 FULL JOIN이 올바르게 작동하지 않는다. ctid 시스템 컬럼과 행 비교 연산자 OPERATOR(pg_catalog.*=)를 사용해 완전히 같으면서 NULL이 없는 물리적으로 다른 행 두 개를 찾는다.
// refresh_by_match_merge — duplicate check, src/backend/commands/matview.cappendStringInfo(&querybuf, "SELECT newdata.*::%s FROM %s newdata " "WHERE newdata.* IS NOT NULL AND EXISTS " "(SELECT 1 FROM %s newdata2 WHERE newdata2.* IS NOT NULL " "AND newdata2.* OPERATOR(pg_catalog.*=) newdata.* " "AND newdata2.ctid OPERATOR(pg_catalog.<>) newdata.ctid)", tempname, tempname, tempname);if (SPI_execute(querybuf.data, false, 1) != SPI_OK_SELECT) ...if (SPI_processed > 0) ereport(ERROR, (errcode(ERRCODE_CARDINALITY_VIOLATION), errmsg("new data for materialized view \"%s\" contains duplicate rows without any null columns", ...), errdetail("Row: %s", ...)));tablename.*::tablerowtype 캐스트는 .*가 별개의 컬럼으로 펼쳐지지 않도록 하는 의도적인 우회법이다(소스에도 언급됨). 행을 단일 복합 값으로 유지하여 전체 비교가 가능하게 한다.
단계 3 — diff 테이블 생성. (tid tid, newdata <temprowtype>) 구조의 임시 테이블을 만든다. 복합 컬럼은 두 번째 단계에서 추가한다. 보안 컨텍스트를 전환한 뒤 추가하는 이유는 SECURITY_RESTRICTED_OPERATION 컨텍스트 안에서는 임시 테이블을 만들 수 없기 때문이다.
// refresh_by_match_merge — diff table creation, src/backend/commands/matview.cSetUserIdAndSecContext(relowner, save_sec_context | SECURITY_LOCAL_USERID_CHANGE);appendStringInfo(&querybuf, "CREATE TEMP TABLE %s (tid pg_catalog.tid)", diffname);SPI_exec(querybuf.data, 0);SetUserIdAndSecContext(relowner, save_sec_context | SECURITY_RESTRICTED_OPERATION);appendStringInfo(&querybuf, "ALTER TABLE %s ADD COLUMN newdata %s", diffname, tempname);SPI_exec(querybuf.data, 0);단계 4 — 키 기반 FULL JOIN으로 diff 채우기. 조인 조건은 구체화 뷰의 사용 가능한 모든 UNIQUE 인덱스 컬럼으로 구성된다. 각 인덱스 컬럼의 opclass에서 등호 연산자를 조회해 등호 조건을 만든다. 마지막 조건은 한쪽에만 존재하는 행(실제 변경)만 남긴다.
// refresh_by_match_merge — diff INSERT skeleton, src/backend/commands/matview.cappendStringInfo(&querybuf, "INSERT INTO %s " "SELECT mv.ctid AS tid, newdata.*::%s AS newdata " "FROM %s mv FULL JOIN %s newdata ON (", diffname, tempname, matviewname, tempname);/* ... per unique-index-column equality quals appended here ... */appendStringInfoString(&querybuf, " AND newdata.* OPERATOR(pg_catalog.*=) mv.*) " "WHERE newdata.* IS NULL OR mv.* IS NULL " "ORDER BY tid");등호 연산자는 opclass/opfamily 카탈로그를 조회해 얻는다. 조인이 UNIQUE 인덱스 고유의 등호 개념을 사용하도록 보장하는 것이 이 조회의 목적이다.
// refresh_by_match_merge — equality operator lookup, src/backend/commands/matview.ccla_ht = SearchSysCache1(CLAOID, ObjectIdGetDatum(opclass));cla_tup = (Form_pg_opclass) GETSTRUCT(cla_ht);opfamily = cla_tup->opcfamily;opcintype = cla_tup->opcintype;op = get_opfamily_member_for_cmptype(opfamily, opcintype, opcintype, COMPARE_EQ);if (!OidIsValid(op)) elog(ERROR, "missing equality operator for (%u,%u) in opfamily %u", ...);단계 5 — 적용: DELETE 후 INSERT. 구체화 뷰에 대한 DML이 허용되도록 유지 관리 모드를 열고, 삭제(이전에는 있었지만 새것에는 없는 행)를 먼저 처리하고 삽입(새것에는 있지만 이전에는 없던 행)을 나중에 처리한다.
// refresh_by_match_merge — apply, src/backend/commands/matview.cOpenMatViewIncrementalMaintenance();/* Deletes must come before inserts; do them first. */appendStringInfo(&querybuf, "DELETE FROM %s mv WHERE ctid OPERATOR(pg_catalog.=) ANY " "(SELECT diff.tid FROM %s diff WHERE diff.tid IS NOT NULL " "AND diff.newdata IS NULL)", matviewname, diffname);SPI_exec(querybuf.data, 0);/* Inserts go last. */appendStringInfo(&querybuf, "INSERT INTO %s SELECT (diff.newdata).* FROM %s diff WHERE tid IS NULL", matviewname, diffname);SPI_exec(querybuf.data, 0);CloseMatViewIncrementalMaintenance();DELETE/INSERT가 새로 고침 트랜잭션 안에서 일반 MVCC DML로 실행되므로, AccessShareLock을 잡은 동시 SELECT는 새로 고침이 커밋될 때까지 자신의 스냅샷에서 이전 내용을 본다. 커밋 후에는 원자적으로 새 내용을 본다. CONCURRENTLY의 가치가 바로 여기에 있다.
사용 가능한 UNIQUE 인덱스 검사
섹션 제목: “사용 가능한 UNIQUE 인덱스 검사”RefreshMatViewByOid의 사전 검증과 refresh_by_match_merge의 조인 빌더 모두 is_usable_unique_index를 호출한다. 사용 가능한 인덱스는 unique, immediate, valid, 부분 조건 없음, 일반 사용자 컬럼만으로 정의되어야 한다.
// is_usable_unique_index — src/backend/commands/matview.cif (indexStruct->indisunique && indexStruct->indimmediate && indexStruct->indisvalid && RelationGetIndexPredicate(indexRel) == NIL && indexStruct->indnatts > 0){ int numatts = indexStruct->indnatts, i; for (i = 0; i < numatts; i++) { int attnum = indexStruct->indkey.values[i]; if (attnum <= 0) /* reject system columns / expressions */ return false; } return true;}return false;적합한 인덱스가 없으면 CONCURRENTLY는 인덱스 생성을 권고하는 힌트와 함께 일찍 실패한다. UNIQUE 인덱스가 diff의 “매칭 키”다. 이것이 없으면 FULL JOIN이 행 동일성을 기준으로 merge할 수 없다.
유지 관리 깊이 보호
섹션 제목: “유지 관리 깊이 보호”OpenMatViewIncrementalMaintenance / CloseMatViewIncrementalMaintenance는 정적 카운터를 증감시키고, MatViewIncrementalMaintenanceIsEnabled는 백엔드가 구체화 뷰 유지 관리 중인지 보고한다. 이 카운터가 구체화 뷰에 대한 직접 DML 허용 여부를 결정한다. 평소에는 금지되지만 새로 고침 기계가 자체 DELETE/INSERT를 실행하는 동안에는 허용된다.
// matview maintenance depth — src/backend/commands/matview.cstatic int matview_maintenance_depth = 0;bool MatViewIncrementalMaintenanceIsEnabled(void) { return matview_maintenance_depth > 0; }static void OpenMatViewIncrementalMaintenance(void) { matview_maintenance_depth++; }static void CloseMatViewIncrementalMaintenance(void) { matview_maintenance_depth--; Assert(matview_maintenance_depth >= 0); }RefreshMatViewByOid의 PG_TRY/PG_CATCH는 오류 발생 시 저장된 깊이를 복원한다. 실패한 동시 새로 고침이 백엔드를 “유지 관리 중” 상태에 고착시키지 않도록 하기 위해서다.
종단 간 흐름
섹션 제목: “종단 간 흐름”flowchart TD
A["REFRESH MATERIALIZED VIEW [CONCURRENTLY] foo"] --> B["ExecRefreshMatView<br/>잠금 선택: AEL 또는 EL"]
B --> C["RefreshMatViewByOid"]
C --> D["relkind + 규칙 검증<br/>dataQuery 추출"]
D --> E["CheckTableNotInUse<br/>SetMatViewPopulatedState"]
E --> F["make_new_heap (임시)"]
F --> G["refresh_matview_datafill<br/>DR_transientrel: frozen 벌크 삽입"]
G --> H{"concurrent?"}
H -->|no| I["refresh_by_heap_swap"]
I --> J["finish_heap_swap<br/>relfilenode 교체 + REINDEX"]
H -->|yes| K["refresh_by_match_merge"]
K --> L["ANALYZE 임시 테이블;<br/>중복 행 검사"]
L --> M["diff 임시 테이블 구성<br/>UNIQUE 인덱스 키로 FULL JOIN"]
M --> N["삭제된 행 DELETE<br/>추가된 행 INSERT"]
N --> O["임시 테이블 DROP"]
J --> P["커밋: 새 내용 가시화"]
O --> P
위치 힌트 (2026-06-05, REL_18 273fe94 기준)
섹션 제목: “위치 힌트 (2026-06-05, REL_18 273fe94 기준)”| 심볼 | 파일 | 줄 |
|---|---|---|
DR_transientrel (구조체) | src/backend/commands/matview.c | 45 |
matview_maintenance_depth | src/backend/commands/matview.c | 56 |
SetMatViewPopulatedState | src/backend/commands/matview.c | 78 |
ExecRefreshMatView | src/backend/commands/matview.c | 120 |
RefreshMatViewByOid | src/backend/commands/matview.c | 164 |
| concurrent/skipData 옵션 검사 | src/backend/commands/matview.c | 205 |
규칙 추출 (dataQuery) | src/backend/commands/matview.c | 221 |
make_new_heap 호출 | src/backend/commands/matview.c | 319 |
| 힙 교체 vs match-merge 분기 | src/backend/commands/matview.c | 335 |
refresh_matview_datafill | src/backend/commands/matview.c | 404 |
CreateTransientRelDestReceiver | src/backend/commands/matview.c | 464 |
transientrel_startup | src/backend/commands/matview.c | 482 |
transientrel_receive | src/backend/commands/matview.c | 508 |
make_temptable_name_n | src/backend/commands/matview.c | 570 |
refresh_by_match_merge | src/backend/commands/matview.c | 613 |
| 중복 행 검사 SQL | src/backend/commands/matview.c | 660 |
| diff INSERT 뼈대 | src/backend/commands/matview.c | 715 |
| 등호 연산자 조회 | src/backend/commands/matview.c | 772 |
| DELETE / INSERT 적용 | src/backend/commands/matview.c | 863 |
refresh_by_heap_swap | src/backend/commands/matview.c | 904 |
is_usable_unique_index | src/backend/commands/matview.c | 914 |
MatViewIncrementalMaintenanceIsEnabled | src/backend/commands/matview.c | 963 |
Open/CloseMatViewIncrementalMaintenance | src/backend/commands/matview.c | 969 |
| 구체화 뷰 생성의 데이터 위임 | src/backend/commands/createas.c | 274 |
make_new_heap (정의) | src/backend/commands/cluster.c | 705 |
RelationIsScannable / RelationIsPopulated | src/include/utils/rel.h | 689 |
| 스캔 가능성 오류 | src/backend/executor/execUtils.c | 754 |
줄 번호는 updated: 날짜 기준 힌트다. 심볼 이름이 정식 앵커이므로 줄 번호를 그대로 신뢰하지 말고 심볼로 grep할 것.
소스 검증 (2026-06-06 기준)
섹션 제목: “소스 검증 (2026-06-06 기준)”아래의 모든 주장은 커밋 273fe94852b(2026-06-05) REL_18_STABLE 기준으로, 해당 파일의 명명된 심볼을 직접 읽어 재확인했다.
검증된 사실
섹션 제목: “검증된 사실”-
구체화 뷰는
RELKIND_MATVIEW릴레이션이며, 정의Query를 단일 동작으로 가진 정확히 하나의SELECT INSTEAD재작성 규칙을 갖는다.RefreshMatViewByOid(matview.c)에서 검증:relhasrules == false/numLocks < 1(“missing rewrite information”),numLocks > 1(“too many rules”), 비-CMD_SELECT/비-isInstead규칙(“not a SELECT INSTEAD OF rule”),list_length(actions) != 1(“not a single action”)를 순서대로 거부한 뒤dataQuery = linitial_node(Query, actions)를 추출한다. -
CREATE MATERIALIZED VIEW는 항상WITH NO DATA로 릴레이션을 만든 뒤 REFRESH 경로를 재사용해 채운다.createas.c(ExecCreateTableAs경로)에서 검증:is_matview = (into->viewQuery != NULL), 이후if (is_matview) { do_refresh = !into->skipData; into->skipData = true; },create_ctas_nodata후RefreshMatViewByOid(address.objectId, true, false, false, ...)를is_create = true로 호출. 줄 274/277/296에서 확인. -
새로 고침 잠금 강도는
CONCURRENTLY플래그에서 미리 결정된다. 동시 이면ExclusiveLock, 아니면AccessExclusiveLock.ExecRefreshMatView(matview.c~128줄)에서 검증:lockmode = stmt->concurrent ? ExclusiveLock : AccessExclusiveLock;— 이 단일 삼항 연산자가 읽기 차단 여부의 전부다. -
CONCURRENTLY는 채워지지 않은 구체화 뷰에서 거부되고,CONCURRENTLY+WITH NO DATA조합도 모순으로 거부된다.RefreshMatViewByOid(matview.c208/214줄)에서 검증: 첫 번째는ERRCODE_FEATURE_NOT_SUPPORTED, 두 번째는ERRCODE_SYNTAX_ERROR를 발생시킨다. -
임시 힙은 frozen, FSM 건너뜀 벌크 삽입으로 채워진다.
transientrel_startup(matview.c495줄)에서 검증:myState->ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_FROZEN;.transientrel_receive는 “We know this is a newly created relation, so there are no indexes” 주석과 함께 그 옵션으로table_tuple_insert(...)를 호출한다. -
비동시 적용은
CLUSTER/VACUUM FULL이 사용하는 동일한 기본 연산인finish_heap_swap을 통한 전체 저장소 교체다.refresh_by_heap_swap(matview.c907줄)에서 검증:finish_heap_swap(matviewOid, OIDNewHeap, false, false, true, true, RecentXmin, ReadNextMultiXactId(), relpersistence);. -
동시 적용은 행 단위 diff다.
refresh_by_match_merge(matview.c614줄)에서 종단 간 검증:ANALYZE %s;ERRCODE_CARDINALITY_VIOLATION“contains duplicate rows without any null columns”(682줄)을 발생시키는 중복 검사;CREATE TEMP TABLE ... (tid pg_catalog.tid)후ALTER TABLE ... ADD COLUMN newdata;INSERT INTO ... FULL JOIN ... WHERE newdata.* IS NULL OR mv.* IS NULL;DELETE ... WHERE ctid = ANY (...)후INSERT INTO ... SELECT (diff.newdata).*. -
diff 조인 등호는 opfamily 카탈로그에서 조회한 UNIQUE 인덱스 고유의 opclass 등호 연산자다.
refresh_by_match_merge에서 검증:SearchSysCache1(CLAOID, ...)→opcfamily/opcintype→get_opfamily_member_for_cmptype(opfamily, opcintype, opcintype, COMPARE_EQ), “missing equality operator …”elog(ERROR, ...)보호와 함께. -
사용 가능한 UNIQUE 인덱스는 unique, immediate, valid, 비부분, 일반 사용자 컬럼만으로 정의되어야 한다.
is_usable_unique_index(matview.c915줄)에서 검증:indisunique && indimmediate && indisvalid && RelationGetIndexPredicate(indexRel) == NIL && indnatts > 0결합,indkey.values[i] <= 0(시스템 컬럼 또는 표현식)을 거부하는 컬럼별 루프와 함께. -
구체화 뷰에 대한 직접 DML은 새로 고침 기계가 자체 DELETE/INSERT 주위에 여닫는 유지 관리 깊이 카운터로 제어된다. 검증:
static int matview_maintenance_depth = 0;(56줄);MatViewIncrementalMaintenanceIsEnabled는depth > 0을 반환(966줄);Open/CloseMatViewIncrementalMaintenance는 증감(970/976줄);RefreshMatViewByOid는PG_TRY/PG_CATCH에서old_depth를 저장/복원(337/346/350줄). -
relispopulated는 단일 게이팅 비트이며 현재 동일한 두 매크로로 읽힌다.rel.h689/697줄에서 검증:RelationIsScannable과RelationIsPopulated모두((relation)->rd_rel->relispopulated)로 확장.SetMatViewPopulatedState(matview.c79줄)는CatalogTupleUpdate로pg_class컬럼 하나를 갱신하고CommandCounterIncrement()로 로컬 가시성을 확보한다. -
채워지지 않은 구체화 뷰를 스캔하면 하드 오류가 발생하며, EXPLAIN 전용 또는 WITH-NO-DATA 실행에서만 억제된다.
ExecOpenScanRelation(execUtils.c754-758줄)에서 검증:(eflags & (EXEC_FLAG_EXPLAIN_ONLY | EXEC_FLAG_WITH_NO_DATA)) == 0 && !RelationIsScannable(rel)조건이ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE“materialized view “%s” has not been populated”를 REFRESH 실행 힌트와 함께 발생시킨다.
열린 질문 / 의도적 미포함
섹션 제목: “열린 질문 / 의도적 미포함”-
증분 뷰 유지 없음. 모든 REFRESH는 정의 쿼리의 전체 재계산이다. PostgreSQL 코어에는 IVM이 없다. pgsql-hackers의 “Incremental View Maintenance (IVM)” 패치 시리즈(Nagata 등)는 수년간 out-of-tree 상태이며 REL_18에 없다. 이 문서는 전체 재계산 현실만을 주장한다.
-
make_new_heap/finish_heap_swap내부는postgres-heap-am.md와 CLUSTER 경로에 위임됐다. 이 문서는 두 함수를 저장소 교체 기본 연산으로 취급하며 relfilenode 수명 주기를 다시 추적하지 않는다. -
DDL 실행과
CREATE TABLE AS배관은postgres-ddl-execution.md에 위임됐다. 이 문서는createas.c의is_matview분기에서만 진입한다. -
교체 후 REINDEX 경로의 상세 내용(
finish_heap_swap의 마지막 두 boolean 인수가 인덱스 재구성을 구동하는 방식)은 요약만 하고 추적하지 않는다. CLUSTER/REINDEX 분석에 속하는 내용이다.
PostgreSQL 너머 — 비교 설계와 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”-
증분 뷰 유지(계수 알고리즘). 전체 재계산의 정규 대안은 기반 테이블 델타를 뷰의 관계 대수로 미분하는 것이다. Gupta, Mumick & Subrahmanian 1993(“Maintaining Views Incrementally,” SIGMOD)이 계수(counting) 알고리즘을 제시한다. 각 뷰 행에 중복도를 저장하고, 삽입/삭제 델타를 연산자별 미분 규칙으로 전파하며, 카운트를 더하고 뺀다. PostgreSQL의
refresh_by_match_merge는 퇴화된 IVM이다. 완전한 새 결과를 계산한 뒤 저장된 결과와 diff하므로 재계산에 O(결과)를 쓰지만 적용에는 O(변경된 행)만 쓴다. 진정한 IVM은 전체 새 결과를 물질화하지 않는다.refresh_by_match_merge의 FULL JOIN diff를 계수 알고리즘의 델타 규칙에 대응시키는 것이 자연스러운 이론적 연결이다. -
트리거를 통한 즉시(eager) 유지. Oracle(
FAST REFRESH와 구체화 뷰 로그), SQL Server(동기식으로 유지되는 인덱스 뷰), DB2(REFRESH IMMEDIATEMQT)는 모두 즉시 유지를 제공한다. 기반 테이블이 바뀌는 트랜잭션 안에서 뷰가 현재 상태를 유지하지만, 쓰기 경로에 비용이 더해지고 뷰 대수에 제약이 생긴다. PostgreSQL은 코어에서 이를 전혀 제공하지 않는다. 즉시 유지를 원하는 사용자는AFTER트리거(postgres-triggers.md)를 직접 작성하거나 익스텐션을 사용한다. Oracle의 MV 로그 델타 캡처와 PostgreSQL의 “전체 재계산 후 diff” 방식을 대조하면 트레이드오프가 선명해진다. -
구체화 뷰를 통한 쿼리 재작성. Oracle과 SQL Server 옵티마이저는 사용자가 명시하지 않은 구체화 뷰에서도 답을 투명하게 도출할 수 있다. PostgreSQL은 이를 지원하지 않는다. 교과서(Silberschatz 7판 §16.5)는 뷰 기반 재작성을 일급 옵티마이저 기법으로 다루는데, PostgreSQL이 이를 의도적으로 채택하지 않은 것은 구체화 뷰를 순수한 저장 릴레이션으로 유지한다는 선택이다.
-
컬럼형 / OLAP 구체화. 분석 엔진에서는 구체화된 결과 자체가 저장 형식의 핵심인 경우가 많다. C-Store / Vertica 프로젝션(
dbms-papers/cstore.md,dbms-papers/vertica-7-years.md)은 미리 정렬·조인·압축된 컬럼 세그먼트로, 항상 유지되는 구체화 뷰처럼 작동하며 사용자REFRESH대신 tuple-mover가 갱신한다. PostgreSQL의 힙 기반 구체화 뷰는 행 저장 방식의 on-demand 갱신이다. 컬럼 스토어 비교(dbms-papers/column-vs-row.md)는 행 저장 구체화 뷰가 OLAP 스캔 속도에서 포기하는 것을 구체화한다. -
동시 새로 고침 vs. 스냅샷 격리 즉시 교체. PostgreSQL의
CONCURRENTLY는ExclusiveLock아래에서 MVCC DML을 수행해 이전 독자의 일관성을 유지한다. 일부 시스템은 버전 관리/이중 버퍼링 릴레이션을 사용해 새로 고침이 섀도 복사본에 쓰고 원자적 카탈로그 뒤집기로 버전을 교체한다. 행 단위 DML 없이 비동시 힙 교체에 가깝지만 진행 중인 스캔에서 파일을 빼앗지 않는다. PostgreSQL이 diff-merge를 선택한 이유는 교체 기본 연산(finish_heap_swap)이 본질적으로 배타적이기 때문이다. 버전 관리 릴레이션이 채택되지 않은 이유(저장 계층에 릴레이션별 MVCC 버전 체인 없음)가 이 전선의 핵심이다. -
스트리밍 / 차분 데이터플로우. Materialize(데이터베이스)와 차분 데이터플로우 기반 시스템들은 스트림에 걸쳐 뷰를 지속적으로 유지하며, 영향받은 델타만 재계산해 준-실시간 지연을 달성한다. 이것이 PostgreSQL 코어가 거절한 IVM 꿈의 현대적 실현이다. PostgreSQL의 배치
REFRESH와 연속적 차분 유지를 대조하면 지연-즉시 축의 극단을 볼 수 있다.
소스 트리 내 파일 (REL_18_STABLE, 커밋 273fe94)
섹션 제목: “소스 트리 내 파일 (REL_18_STABLE, 커밋 273fe94)”src/backend/commands/matview.c— 기능 전체:ExecRefreshMatView,RefreshMatViewByOid,refresh_matview_datafill,DR_transientrelDestReceiver(transientrel_startup/_receive/_shutdown/_destroy),refresh_by_heap_swap,refresh_by_match_merge,is_usable_unique_index,SetMatViewPopulatedState,make_temptable_name_n,matview_maintenance_depth보호 및Open/Close/...IsEnabledAPI.src/backend/commands/createas.c—ExecCreateTableAs와into->skipData = true를 강제하고RefreshMatViewByOid(..., is_create = true, ...)에 위임하는is_matview분기.src/backend/commands/cluster.c—make_new_heap(임시 힙 구성)과finish_heap_swap(CLUSTER, VACUUM FULL과 공유하는 relfilenode 교체 + REINDEX).src/backend/executor/execUtils.c—ExecOpenScanRelation.RelationIsScannable이 강제되고 “has not been populated” 오류가 발생하는 곳.src/include/commands/matview.h—ExecRefreshMatView,RefreshMatViewByOid,MatViewIncrementalMaintenanceIsEnabled프로토타입.src/include/utils/rel.h—rd_rel->relispopulated위의RelationIsScannable/RelationIsPopulated매크로.src/include/catalog/pg_class.h— 기능이 의존하는relispopulated,relkind(RELKIND_MATVIEW),relam,relfilenode컬럼.
논문 및 교과서 챕터
섹션 제목: “논문 및 교과서 챕터”- Gupta, A., Mumick, I. S. & Subrahmanian, V. S. (1993). “Maintaining Views Incrementally.” SIGMOD — 증분 뷰 유지를 위한 계수 알고리즘. PostgreSQL의 전체 재계산 경로가 의도적으로 포기한 이론적 대안.
- Database System Concepts(Silberschatz, Korth, Sudarshan, 7판), §4.2.3 “Materialized Views” 및 §16.5 — 즉시/지연/주기적 유지 분류법, 전체 vs. 증분 재계산, 구체화 뷰를 활용한 쿼리 재작성(
knowledge/research/dbms-general/database-system-concepts.md). - Database Internals(Petrov 2019) — 힙 교체와 diff-merge 적용 전략에 대한 저장 관리자 및 MVCC 프레임(
knowledge/research/dbms-general/database-internals.md). - C-Store / Vertica(
knowledge/research/dbms-papers/cstore.md,vertica-7-years.md)와 column-vs-row(column-vs-row.md) — 항상 유지되는 구체화로서의 컬럼형 프로젝션, 행 저장 on-demand 구체화 뷰와의 OLAP 대조.
형제 문서 (교차 참조 — 메커니즘은 해당 문서 소유, 여기서 중복하지 않음)
섹션 제목: “형제 문서 (교차 참조 — 메커니즘은 해당 문서 소유, 여기서 중복하지 않음)”postgres-heap-am.md— 힙 튜플 삽입(table_tuple_insert),relfilenode수명 주기,make_new_heap/finish_heap_swap뒤의 저장 관리자 상세 내용.postgres-ddl-execution.md— 유틸리티 명령 디스패치와 이 문서가is_matview분기에서만 진입하는CREATE TABLE AS/ExecCreateTableAs배관.postgres-rewriter.md— 구체화 뷰의 정의Query를SELECT INSTEAD동작으로 저장하는 규칙 시스템.postgres-triggers.md— PostgreSQL 코어가 제공하지 않는 즉시 유지를 직접 구현하려는 사용자가 작성할AFTER트리거.postgres-mvcc-snapshots.md—CONCURRENTLY새로 고침이 커밋될 때까지AccessShareLock을 가진 동시 독자가 이전 내용을 보는 이유.postgres-system-catalogs.md/postgres-relcache.md—pg_class,relispopulated, populated 비트를 전파하는 공유 무효화.