콘텐츠로 이동

(KO) PostgreSQL 익스큐터 — 요구 풀 방식 플랜 노드 트리와 튜플 흐름

목차

최적화를 마친 관계형 질의는 물리 연산자 트리다. 리프에는 스캔이, 내부 노드에는 조인·집계·정렬이, 루트에는 프로젝션이나 수정 연산이 놓인다. 실행 엔진의 임무는 이 연산자 트리를 결과 튜플 스트림으로 바꾸는 일이다. Database System Concepts(Silberschatz, 7판, 15장 “Query Processing”)는 연산자 사이에서 튜플이 어떻게 이동하는지를 핵심 설계 축으로 규정하고 두 계열을 명명한다.

  • 물질화 평가 (materialized evaluation). 각 연산자가 완료될 때까지 실행하고 전체 출력을 임시 릴레이션에 쓴 뒤, 다음 연산자가 그것을 읽는다. 단순하지만 모든 중간 결과마다 I/O와 지연이 고스란히 발생하며, 첫 행이 나오기까지의 대기 시간은 가장 큰 중간 결과의 크기에 비례한다.
  • 파이프라인 평가 (pipelined evaluation). 인접한 연산자들을 파이프라인으로 결합해 한 연산자의 출력 튜플이 중간 릴레이션 없이 곧바로 다음 연산자로 흘러들어가게 한다(§15.7.2). 두 가지 이점이 따른다. 중간 결과가 디스크에 기록되지 않고, “질의 평가 플랜의 루트 연산자 … 는 빠르게 결과를 생성하기 시작할 수 있다”(§15.7.2). 즉 마지막 입력 행을 읽기 전에 첫 행을 내보낼 수 있다는 뜻이다.

파이프라인은 두 방향 중 하나로 진행할 수 있으며, 이것이 모든 엔진이 조정하는 핵심 매개변수다(§15.7.2.1).

  1. 요구 구동 방식 (demand-driven, pull, lazy). “시스템이 파이프라인 상단의 연산으로부터 튜플을 반복적으로 요청한다.” 각 연산자는 요청을 받으면 출력 튜플 하나를 계산하기 위해 자식에게 재귀적으로 입력을 요청한 뒤 반환한다. 튜플은 연산 트리 위쪽으로 당겨지며, 지연 평가(lazily) 방식으로 필요할 때만 생성된다.
  2. 생산자 구동 방식 (producer-driven, push, eager). 각 연산자가 독립된 프로세스나 스레드로 동작하며 출력을 버퍼가 꽉 찰 때까지 열성적으로 생성한다. 튜플은 연산 트리 아래쪽에서 위쪽으로 밀려 올라간다.

교재의 평가는 명확하다. “요구 구동 파이프라이닝이 생산자 구동 파이프라이닝보다 더 일반적으로 사용되는데, 구현하기 더 쉽기 때문이다”(§15.7.2.1). 요구 구동 방식의 표준 구현은 이터레이터 모델 혹은 Graefe의 Volcano 시스템을 따서 Volcano 모델이라 불린다. Volcano의 교환 연산자(DSC §22.5, 22장 병렬 처리)는 이 모델을 병렬 처리까지 일반화했다. 각 연산자는 세 함수를 노출한다.

“요구 구동 파이프라인의 각 연산은 open(), next(), close() 함수를 제공하는 이터레이터로 구현될 수 있다. open() 호출 이후 next()를 호출할 때마다 연산의 다음 출력 튜플이 반환된다. 연산의 구현은 필요할 때 입력 튜플을 얻기 위해 자신의 입력에서 open()next()를 차례로 호출한다. … 이터레이터는 연속적인 next() 요청이 연속적인 결과 튜플을 받을 수 있도록 호출 사이에 실행 상태를 유지한다.” (DSC §15.7.2.1)

이터레이터 모델의 세 가지 특성은 이후의 모든 엔진 구조를 결정하므로, 소스를 읽기 전에 짚어 두어야 한다.

  1. 균일한 인터페이스. 모든 연산자가 동일한 next() 호출에 응답하고 동일한 종류의 결과(튜플 하나 또는 “더 없음”)를 반환한다. 조인 연산자는 자신의 자식이 순차 스캔인지 다른 조인인지 알 필요가 없다. 인터페이스가 균일하기 때문에 트리가 구성될 수 있다는 점이다.
  2. 상태는 호출자가 아닌 이터레이터에 산다. next() 호출 사이에서 각 연산자는 자신이 어디까지 진행했는지를 기억한다. 스캔의 파일 오프셋, 해시 조인의 빌드/프로브 단계가 그 예다. 이 연산자별 실행 상태가 익스큐터가 할당하는 메모리의 대부분을 차지한다.
  3. 제어 흐름은 아래로, 튜플은 위로. 루트에 next() 하나를 호출하면 리프까지 깊이 우선으로 next() 호출이 연쇄된다. 리프는 튜플을 반환하고, 각 내부 연산자는 스트림을 변환하며 위로 전달한다.

Architecture of a Database System 설문(Hellerstein, Stonebraker & Hamilton, 2007, §1.1, §4)은 같은 그림을 상용 엔진에 적용해 설명한다. 관계형 질의 처리기는 “모든 질의를 실행하는 연산자 모음”이며, SQL은 클라이언트가 행을 반복 요청하는 “풀 모델”로 제공된다. PostgreSQL은 이 요구 구동 이터레이터 모델을 교재에 충실하게 구현한 엔진이다. 단, next()ExecProcNode, open()/close()ExecInitNode/ExecEndNode라는 이름을 쓰고, 전달하는 “튜플”은 단순 행이 아니라 TupleTableSlot 추상화다. 이 문서의 나머지 부분은 REL_18 소스에서 그 조각들을 추적한다.

교재는 모델을 제시한다. 연산자 트리 위에서 동작하는 요구 구동 이터레이터다. 이 절은 거의 모든 상용 이터레이터 엔진이 그 모델을 빠르고 안전하게 만들기 위해 채택하는 엔지니어링 관례를 정리한다. 교재가 암묵적으로 남겨 둔 패턴들이다. ## PostgreSQL의 접근 방식에서 나오는 PostgreSQL의 구체적 선택은 이 공통 공간 안의 한 가지 구성으로 읽으면 된다.

최적화기 출력인 연산자 트리 — 비용 추정, 조인 조건, 대상 목록 — 는 실행 시점에 논리적으로 읽기 전용이어야 한다. “스캔 커서가 현재 어디에 있는지” 같은 정보는 거기 속하지 않는다. 그래서 엔진은 두 개의 병렬 트리를 유지한다. 변하지 않는 플랜 트리(조리법)와 변하는 상태 트리(실행 중인 인스턴스)다. 각 상태 노드는 대응하는 플랜 노드를 역참조한다. 이로써 플랜 캐싱과 재사용이 가능해진다. 캐시된 플랜 하나를 여러 번, 심지어 여러 세션이나 병렬 워커에서 동시에 실행할 수 있는 것은, 각 실행이 자신만의 새 상태 트리를 갖고 플랜은 절대 변경되지 않기 때문이다.

균일한 튜플 형식이 아닌 균일한 튜플 핸들

섹션 제목: “균일한 튜플 형식이 아닌 균일한 튜플 핸들”

연산자는 구성 가능해야 하지만, 그 사이를 흐르는 튜플은 완전히 다른 출처에서 온다. 힙 페이지(MVCC 가시성 비트와 버퍼 핀 포함), 인덱스, 정렬 스필 파일, 인메모리 VALUES 목록, 두 입력의 조합을 새로 프로젝션한 조인 출력이 그 예다. 이 모두를 하나의 물리 레이아웃으로 강제하면 모든 경계마다 튜플을 복사해야 한다. 관례는 대신 균일한 튜플 핸들이다. 이 핸들은 다양한 실제 저장 방식을 감싸고 실제 컬럼 디코딩은 컬럼이 실제로 접근될 때까지 미루면서 공통의 “i번째 컬럼을 달라” 인터페이스를 노출한다. 파이프라인 엔진에서 가장 중요한 추상화다. 연산자가 자식 출력의 물리적 저장 방식을 알거나 신경 쓰지 않고 소비할 수 있게 된다는 점이다.

디스크의 힙 튜플은 패킹된 바이트 문자열이며, 이를 Datum 값 배열로 변환(“deforming”)하는 비용은 컬럼 수에 비례한다. 하지만 40컬럼 테이블에서 컬럼 2만 접근하는 질의가 컬럼 3–40을 디코딩하는 비용을 낼 이유는 없다. 관례는 지연, 좌-접두사 deforming이다. 무언가가 컬럼 n을 요청할 때만 1..n을 디코딩하고, 얼마나 디코딩했는지를 캐시하며, 중복 처리하지 않는다. Datum 배열로 이미 표현된 “가상(virtual)” 튜플 — 조인의 프로젝션 출력이 그 예 — 은 deforming 자체를 건너뛴다.

리셋 가능한 컨텍스트로 튜플별 메모리를 제한한다

섹션 제목: “리셋 가능한 컨텍스트로 튜플별 메모리를 제한한다”

파이프라인은 한 질의에서 수십억 튜플을 처리할 수 있다. 각 튜플의 임시 할당 — WHERE 절 평가 스크래치 공간, 함수 호출 작업 저장소 — 이 질의 수명 동안 쌓이면 메모리가 폭발한다. 관례는 튜플별 메모리 아레나를 두고 튜플마다 *초기화(bulk-free)*하는 것이다. 이 아레나는 상태 트리를 담아 질의가 끝날 때까지 살아 있는 질의별 아레나와 분리된다. 두 가지 할당 수명, 각 경계에서 한 번의 저렴한 일괄 해제, 튜플 단위 free() 없음이 핵심이다.

스냅샷 하나를 질의 수명 동안 등록한다

섹션 제목: “스냅샷 하나를 질의 수명 동안 등록한다”

파이프라인 읽기는 처음부터 끝까지 일관된 행 집합을 봐야 한다. 엔진은 실행 전에 MVCC 스냅샷을 획득하고, vacuum이 아직 볼 수 있는 버전을 회수하지 못하도록 등록(registered) — 즉 고정(pinned) — 상태로 전체 ExecutorStartExecutorEnd 구간 동안 유지하다가 해제 시점에만 반환한다.

다음 절에서 PostgreSQL 심볼 이름을 만나기 전에, 그것이 어떤 종류의 것인지 먼저 알아야 한다.

이론 / 관례PostgreSQL 이름
연산자 open()ExecInitNode (디스패치) → 노드별 ExecInit*
연산자 next()ExecProcNode (인라인) → node->ExecProcNode → 노드별 Exec*
연산자 close()ExecEndNode (디스패치) → 노드별 ExecEnd*
읽기 전용 플랜 트리Plan 및 하위 타입 (플래너 출력)
변경 가능한 실행 상태 트리PlanState 및 하위 타입; lefttree / righttree 링크
실행별 전역 상태EState (익스큐터 호출당 하나)
균일한 튜플 핸들TupleTableSlot + TupleTableSlotOps vtable
튜플 핸들 실제 저장 방식TTSOpsVirtual / TTSOpsHeapTuple / TTSOpsMinimalTuple / TTSOpsBufferHeapTuple
지연 컬럼 디코드slot_getsomeattrs + tts_nvalid 워터마크
질의별 메모리 아레나EState.es_query_cxt
튜플별 메모리 아레나ExprContext.ecxt_per_tuple_memory, ResetExprContext로 초기화
등록된 질의 스냅샷EState.es_snapshot via RegisterSnapshot
스캔 보일러플레이트 (qual + project 루프)ExecScan / ExecScanExtended in execScan.[ch]
”더 없음” 센티넬TupleTableSlot (TupIsNull), 모든 곳에서 C NULL이 아님

플래너 측 — Plan 트리가 어떻게 구성되고 왜 읽기 전용인지 — 은 postgres-planner-overview.mdpostgres-node-trees.md가 담당한다. 표현식 평가(ExprState / ExprEvalStep 기계가 qual과 프로젝션을 컴파일하는 방식)는 postgres-expression-eval.md가 담당한다. 개별 리프 스캔과 조인 노드 구현은 postgres-scan-nodes.md가 담당한다. 이 문서는 프레임워크에 집중한다. 네 가지 수명 주기 진입점, 디스패치 계층, 슬롯 추상화, 그리고 모든 리프가 공유하는 스캔 보일러플레이트다.

PostgreSQL 익스큐터는 교재가 설명하는 요구 구동 이터레이터 모델을 거의 그대로 구현한다. 네 가지 설계 결정이 그 형태를 만든다.

  1. 네 가지 수명 주기 진입점. ExecutorStart / ExecutorRun / ExecutorFinish / ExecutorEnd는 “기계를 구축한다”, “튜플을 끌어올린다”, “부작용을 소진하고 AFTER 트리거를 발화한다”, “해제한다”를 분리한다. EXPLAIN ANALYZE가 트리거 발화 단계를 별도로 측정할 수 있고, 포털이 커서 FETCH를 위해 ExecutorRun을 반복 호출할 수 있는 것은 이 분리 덕분이다.
  2. Plan 트리를 1:1로 미러링하는 PlanState 트리. ExecInitNode가 플랜을 재귀적으로 탑-다운으로 구축한다. 플랜은 읽기 전용으로 유지되고, 모든 런타임 변경은 상태 트리에 쌓인다.
  3. ExecProcNodenext() 호출.PlanState에 저장된 함수 포인터(node->ExecProcNode)로 디스패치된다. 튜플마다 거대한 switch를 치지 않는 구조다. 디스패치 비용은 초기화 시점에 한 번만 지불된다.
  4. TupleTableSlot이 데이터 흐름의 단위. TupleTableSlotOps vtable이 물리적 실제 저장 방식을 선택한다. 모든 Exec* 함수는 슬롯을 반환하며, 슬롯은 데이터 끝을 의미한다.

각 진입점은 standard_* 구현을 감싸는 얇은 훅 디스패처다. 확장 기능(pg_stat_statements, 감사 플러그인 등)이 ExecutorStart_hook 등을 설정해 익스큐터 전체를 가로챌 수 있다. 이것이 익스큐터의 훅 기반 확장성 표면이다.

// ExecutorStart / ExecutorRun — src/backend/executor/execMain.c
void
ExecutorStart(QueryDesc *queryDesc, int eflags)
{
pgstat_report_query_id(queryDesc->plannedstmt->queryId, false);
if (ExecutorStart_hook)
(*ExecutorStart_hook) (queryDesc, eflags);
else
standard_ExecutorStart(queryDesc, eflags);
}
void
ExecutorRun(QueryDesc *queryDesc, ScanDirection direction, uint64 count)
{
if (ExecutorRun_hook)
(*ExecutorRun_hook) (queryDesc, direction, count);
else
standard_ExecutorRun(queryDesc, direction, count);
}

네 단계가 어떻게 중첩되는지는 README의 제어 흐름 스케치가 표준 지도다.

flowchart TB
  subgraph START["ExecutorStart"]
    CES["CreateExecutorState<br/>(질의별 컨텍스트 생성)"]
    SW1["es_query_cxt로 전환"]
    INIT["InitPlan -> ExecInitNode<br/>(PlanState 트리 재귀 구축)"]
    CES --> SW1 --> INIT
  end

  subgraph RUN["ExecutorRun"]
    EP["ExecutePlan 루프:<br/>빈 슬롯이 올 때까지 ExecProcNode(root)"]
    DEST["dest->receiveSlot 튜플 전달"]
    EP --> DEST
  end

  subgraph FIN["ExecutorFinish"]
    PPP["ExecPostprocessPlan<br/>(미완료 ModifyTable 노드 실행)"]
    AT["AfterTriggerEndQuery<br/>(AFTER 트리거 발화)"]
    PPP --> AT
  end

  subgraph END["ExecutorEnd"]
    EEP["ExecEndNode<br/>(리소스 재귀 해제)"]
    UNR["UnregisterSnapshot"]
    FREE["FreeExecutorState<br/>(질의별 컨텍스트 파괴)"]
    EEP --> UNR --> FREE
  end

  START --> RUN --> FIN --> END

그림 1 — 익스큐터의 네 수명 주기 단계와 각 단계가 하는 일. ExecutorStart는 질의별 컨텍스트 안에 상태 트리를 구축하고, ExecutorRun은 루프에서 튜플을 당긴다. ExecutorFinish는 부작용을 소진하고 AFTER 트리거를 발화한다(EXPLAIN ANALYZE가 별도로 측정할 수 있도록 분리). ExecutorEnd는 리소스를 해제하고 질의별 컨텍스트 전체를 한 번에 해제한다. (출처: executor/README “Query Processing Control Flow”.)

standard_ExecutorStart에서 질의별 EState가 태어나고, 질의 스냅샷이 등록되며, 상태 트리가 구축된다. 핵심 중간 구간은 다음과 같다.

// standard_ExecutorStart — src/backend/executor/execMain.c (condensed)
estate = CreateExecutorState();
queryDesc->estate = estate;
oldcontext = MemoryContextSwitchTo(estate->es_query_cxt);
estate->es_param_list_info = queryDesc->params;
/* ... allocate es_param_exec_vals for internal params ... */
estate->es_sourceText = queryDesc->sourceText;
estate->es_queryEnv = queryDesc->queryEnv;
/* set es_output_cid for non-read-only ops (or SELECT FOR UPDATE) */
estate->es_snapshot = RegisterSnapshot(queryDesc->snapshot);
estate->es_crosscheck_snapshot = RegisterSnapshot(queryDesc->crosscheck_snapshot);
estate->es_top_eflags = eflags;
estate->es_instrument = queryDesc->instrument_options;
estate->es_jit_flags = queryDesc->plannedstmt->jitFlags;
if (!(eflags & (EXEC_FLAG_SKIP_TRIGGERS | EXEC_FLAG_EXPLAIN_ONLY)))
AfterTriggerBeginQuery();
InitPlan(queryDesc, eflags); /* builds the PlanState tree */
MemoryContextSwitchTo(oldcontext);

두 가지를 짚어야 한다. 첫째, 호출자(포털 / pquery.c 계층, postgres-portals-prepared.md가 담당)가 전달한 스냅샷이 여기서 등록되고 standard_ExecutorEnd에서만 해제된다. 즉 읽기는 전체 실행 동안 하나의 일관된 버전 집합을 본다. 둘째, CreateExecutorState 이후의 모든 작업이 es_query_cxt 안에서 이루어지므로, 상태 트리와 슬롯, 표현식 상태가 모두 그 하나의 컨텍스트에 할당된다.

InitPlan은 권한 검사, 범위 테이블 및 행 마크 설정, 초기 파티션 프루닝을 수행한 뒤 플랜 루트에 ExecInitNode를 호출해 상태 트리를 구축하고, 마지막으로 결과 튜플 디스크립터와 정크 필터를 도출한다.

// InitPlan — src/backend/executor/execMain.c (condensed)
ExecCheckPermissions(rangeTable, plannedstmt->permInfos, true);
ExecInitRangeTable(estate, rangeTable, plannedstmt->permInfos, ...);
estate->es_plannedstmt = plannedstmt;
ExecDoInitialPruning(estate);
/* ... build es_rowmarks from plannedstmt->rowMarks ... */
estate->es_tupleTable = NIL;
estate->es_epq_active = NULL;
/* init each SubPlan's state first ... */
planstate = ExecInitNode(plan, estate, eflags);
tupType = ExecGetResultType(planstate);
/* ... build junk filter if the top tlist has junk attrs ... */

standard_ExecutorRun은 목적지 수신자를 설정한 뒤, 페치 방향이 “이동 없음”이 아닌 경우 ExecutePlan(풀 루프)을 호출한다.

// standard_ExecutorRun — src/backend/executor/execMain.c (condensed)
oldcontext = MemoryContextSwitchTo(estate->es_query_cxt);
operation = queryDesc->operation;
dest = queryDesc->dest;
estate->es_processed = 0;
sendTuples = (operation == CMD_SELECT || queryDesc->plannedstmt->hasReturning);
if (sendTuples)
dest->rStartup(dest, operation, queryDesc->tupDesc);
if (!ScanDirectionIsNoMovement(direction))
ExecutePlan(queryDesc, operation, sendTuples, count, direction, dest);
estate->es_total_processed += estate->es_processed;
if (sendTuples)
dest->rShutdown(dest);
MemoryContextSwitchTo(oldcontext);

ExecutorFinish는 아직 완료되지 않은 ModifyTable 노드를 실행하고(ExecPostprocessPlan), 대기 중인 AFTER 트리거를 발화한다. ExecutorEnd와 의도적으로 분리된 이유는 EXPLAIN ANALYZE가 트리거 시간을 보고 총계에 포함할 수 있어야 하기 때문이다. ExecutorEndExecEndNode로 상태 트리를 재귀하며 릴레이션과 버퍼 핀을 해제하고, 두 스냅샷의 등록을 해제하며, FreeExecutorState를 호출한다. FreeExecutorStatees_query_cxt를 파괴하고 그 안의 모든 익스큐터 할당을 한꺼번에 없앤다.

PlanState 트리가 Plan 트리를 미러링한다

섹션 제목: “PlanState 트리가 Plan 트리를 미러링한다”

README는 핵심 불변 조건을 직접 서술한다. “익스큐터 시작 시점에 우리는 동일한 구조를 담은 병렬 트리를 구축한다 — 일반적으로 모든 플랜 노드 타입에 대응하는 익스큐터 상태 노드 타입이 있다. 상태 트리의 각 노드는 플랜 트리의 대응하는 노드를 가리키는 포인터를 갖는다 … 이 구조 덕분에 플랜 트리는 익스큐터 입장에서 완전히 읽기 전용으로 유지될 수 있다.”

PlanState는 모든 상태 노드가 첫 번째 필드로 내장하는 추상 기반 구조체다. 런타임에 관련된 필드들은 다음과 같다.

// PlanState (abridged) — src/include/nodes/execnodes.h
typedef struct PlanState
{
pg_node_attr(abstract)
NodeTag type;
Plan *plan; /* the read-only plan node */
EState *state; /* shared per-query state */
ExecProcNodeMtd ExecProcNode; /* the next() function (maybe wrapped) */
ExecProcNodeMtd ExecProcNodeReal; /* the real next() if above is a wrapper */
Instrumentation *instrument; /* optional EXPLAIN ANALYZE stats */
ExprState *qual; /* compiled WHERE / filter, or NULL */
struct PlanState *lefttree; /* input subplan(s) — the "children" */
struct PlanState *righttree;
List *initPlan; /* uncorrelated SubPlanState nodes */
List *subPlan; /* correlated SubPlanState nodes */
Bitmapset *chgParam; /* set of changed Param IDs -> triggers rescan */
TupleDesc ps_ResultTupleDesc; /* this node's output row type */
TupleTableSlot *ps_ResultTupleSlot; /* slot this node returns */
ExprContext *ps_ExprContext; /* per-tuple memory + expr scratch */
ProjectionInfo *ps_ProjInfo; /* compiled target list, or NULL */
/* ... slot-type hint fields (scanops/outerops/innerops/resultops) ... */
} PlanState;

lefttreerighttree가 이터레이터 트리의 엣지다. 조인의 두 입력은 각각 왼쪽·오른쪽 서브트리가 되고, 단일 입력 노드(정렬, 집계, 서브쿼리를 가진 스캔)는 lefttree를 유일한 자식으로 쓴다. 편의 매크로 outerPlanState(node) / innerPlanState(node)가 이를 읽는다. qualps_ProjInfo는 컴파일된 WHERE 필터와 대상 목록이다. 표현식 기계가 초기화 시점에 미리 컴파일하므로, 튜플별 경로는 트리 탐색이 아닌 타이트한 인터프리터 루프가 된다.

flowchart TB
  subgraph PLAN["Plan 트리 (읽기 전용, 플래너 출력)"]
    P0["NestLoop"]
    P1["SeqScan dept"]
    P2["SeqScan emp"]
    P0 --> P1
    P0 --> P2
  end

  subgraph STATE["PlanState 트리 (변경 가능, ExecInitNode가 구축)"]
    S0["NestLoopState<br/>ExecProcNode=ExecNestLoop"]
    S1["SeqScanState<br/>ExecProcNode=ExecSeqScan<br/>ss_currentScanDesc (cursor)"]
    S2["SeqScanState<br/>cursor + ss_ScanTupleSlot"]
    S0 -. "lefttree" .-> S1
    S0 -. "righttree" .-> S2
  end

  S0 -. "->plan" .-> P0
  S1 -. "->plan" .-> P1
  S2 -. "->plan" .-> P2

그림 2 — 두 개의 병렬 트리. Plan 트리는 플래너의 읽기 전용 조리법이다. ExecInitNode는 이와 동형인 PlanState 트리를 구축하며, 각 노드는 런타임 커서(ss_currentScanDesc), 노드별 ExecProcNode 함수 포인터, 플랜 노드에 대한 역참조 포인터를 갖는다. 플랜을 읽기 전용으로 유지하는 것이 플랜 캐싱과 병렬 재실행을 안전하게 만든다.

README는 한 가지 예외를 언급한다. 런타임 파티션 프루닝이 서브플랜이 행을 생성하지 않는다고 판단하면 상태 노드가 생략될 수 있다. 현재는 Append / MergeAppend 아래에서만 해당된다. 즉 상태 서브노드 배열이 플랜의 서브플랜 목록과 어긋날 수 있다. 1:1 대응이 원칙이고, 프루닝이 문서화된 예외다.

ExecInitNode는 재귀적 트리 구축자다. 플랜 노드의 태그에 따라 switch를 수행하고 적절한 ExecInit* 생성자를 호출한다. 각 생성자는 자신의 자식에 재귀적으로 ExecInitNode를 호출한다.

// ExecInitNode — src/backend/executor/execProcnode.c (condensed)
PlanState *
ExecInitNode(Plan *node, EState *estate, int eflags)
{
PlanState *result;
if (node == NULL) /* leaf of recursion */
return NULL;
check_stack_depth();
switch (nodeTag(node))
{
case T_SeqScan:
result = (PlanState *) ExecInitSeqScan((SeqScan *) node, estate, eflags);
break;
case T_NestLoop:
result = (PlanState *) ExecInitNestLoop((NestLoop *) node, estate, eflags);
break;
case T_Agg:
result = (PlanState *) ExecInitAgg((Agg *) node, estate, eflags);
break;
/* ... ~40 more node types: control, scan, join, materialization ... */
default:
elog(ERROR, "unrecognized node type: %d", (int) nodeTag(node));
result = NULL;
break;
}
ExecSetExecProcNode(result, result->ExecProcNode); /* install wrapper */
/* init this node's initPlans; set up instrumentation if requested */
return result;
}

ExecInit* 생성자가 자신의 자식에 ExecInitNode를 호출하므로, 루트에 한 번 호출하면 전체 트리가 깊이 우선으로 구축된다. 소스에서 nodeTag switch는 계열별로 묶인다. 제어 노드(Result, ProjectSet, ModifyTable, Append, …), 스캔 노드(SeqScan, IndexScan, BitmapHeapScan, ForeignScan, …), 조인 노드(NestLoop, MergeJoin, HashJoin), 물질화 노드(Material, Sort, Agg, Gather, …)다. ExecEndNode도 같은 그룹핑을 미러링한다.

ExecProcNode — next() 호출과 래퍼들

섹션 제목: “ExecProcNode — next() 호출과 래퍼들”

ExecProcNode는 요구 풀 next()다. executor.h의 작은 인라인 함수이지 switch가 아니다. 각 PlanState에는 초기화 시점에 설정된 자신의 노드별 루틴을 가리키는 함수 포인터가 이미 담겨 있다.

// ExecProcNode (inline) — src/include/executor/executor.h
static inline TupleTableSlot *
ExecProcNode(PlanState *node)
{
if (node->chgParam != NULL) /* a Param changed? */
ExecReScan(node); /* reset this subtree before pulling */
return node->ExecProcNode(node);
}

node->ExecProcNode를 통한 간접 호출이 트리를 다형성으로 만드는 핵심이다. 조인은 자식이 무엇인지 분기하지 않고 ExecProcNode(outerPlanState(node))를 호출한다. 그런데 포인터는 노드별 함수로 직접 설정되지 않는다. ExecSetExecProcNode는 첫 번째 호출 시 실행되는 래퍼 ExecProcNodeFirst를 설치하는데, 이 래퍼는 일회성 검사를 수행한 뒤 포인터를 순수 함수 또는 계측 래퍼로 재연결한다.

// ExecSetExecProcNode + ExecProcNodeFirst — src/backend/executor/execProcnode.c
void
ExecSetExecProcNode(PlanState *node, ExecProcNodeMtd function)
{
node->ExecProcNodeReal = function;
node->ExecProcNode = ExecProcNodeFirst; /* wrapper for first call */
}
static TupleTableSlot *
ExecProcNodeFirst(PlanState *node)
{
check_stack_depth(); /* once, on first execution */
if (node->instrument)
node->ExecProcNode = ExecProcNodeInstr; /* EXPLAIN ANALYZE path */
else
node->ExecProcNode = node->ExecProcNodeReal; /* fast path henceforth */
return node->ExecProcNode(node);
}

이는 의도적인 최적화다. 비용이 큰 스택 깊이 검사와 계측 분기를 질의당 노드당 한 번만 지불하고, 이후에는 지불하지 않는다. 첫 번째 호출 이후 비계측 노드의 ExecProcNodeExecSeqScan(또는 다른 함수)으로 직접 연결되므로, 핫 패스는 래퍼 오버헤드 없는 단일 간접 호출이다.

sequenceDiagram
    participant EP as ExecutePlan
    participant NL as NestLoopState
    participant OUT as outer SeqScanState
    participant IN as inner SeqScanState

    EP->>NL: ExecProcNode(root)
    NL->>OUT: ExecProcNode(outer)
    OUT-->>NL: slot (dept row) or empty
    NL->>IN: ExecProcNode(inner)
    IN-->>NL: slot (emp row) or empty
    note over NL: join match? project combined row
    NL-->>EP: result slot (one joined row)
    note over EP: send to dest, loop again
    EP->>NL: ExecProcNode(root)
    note over NL,IN: inner exhausted -> advance outer, rescan inner
    EP->>NL: ExecProcNode(root)
    NL-->>EP: empty slot (TupIsNull) -> done

그림 3 — 두 테이블 중첩 루프 조인에서의 요구 풀 재귀. 루트에 ExecProcNode 하나를 호출하면 리프 스캔까지 연쇄된다. 튜플은 위로 올라오고, 조인은 스트림을 변환한다. ExecutePlan은 루트가 빈 슬롯을 반환할 때까지 계속 호출한다. PostgreSQL 형태로 구현된 이터레이터 모델의 “제어는 아래로, 튜플은 위로” 특성이다.

ExecutePlan은 “파이프라인 상단에 next()를 반복 호출한다”는 말의 문자 그대로의 구현이다. ExecProcNode(planstate)를 루프 안에서 호출하고, 슬롯이 비면 멈추며, 선택적으로 정크 컬럼을 제거하고, 튜플을 목적지로 보내며, 튜플 수 제한(커서 FETCH n)을 따른다.

// ExecutePlan — src/backend/executor/execMain.c (condensed)
for (;;)
{
ResetPerTupleExprContext(estate); /* free last tuple's scratch memory */
slot = ExecProcNode(planstate); /* the demand-pull next() */
if (TupIsNull(slot)) /* empty slot == end of data */
break;
if (estate->es_junkFilter != NULL)
slot = ExecFilterJunk(estate->es_junkFilter, slot);
if (sendTuples)
{
if (!dest->receiveSlot(slot, dest)) /* client closed? */
break;
}
if (operation == CMD_SELECT)
(estate->es_processed)++;
current_tuple_count++;
if (numberTuples && numberTuples == current_tuple_count)
break; /* honored FETCH count limit */
}
if (!(estate->es_top_eflags & EXEC_FLAG_BACKWARD))
ExecShutdownNode(planstate); /* early resource release */

세 가지 세부 사항이 중요하다. 첫째, 매 반복 시작 시 ResetPerTupleExprContext를 호출하는 것이 ## DBMS 공통 설계 관례의 튜플별 메모리 제한 규율이다. 이전 튜플의 임시 할당이 이 튜플이 시작되기 전에 일괄 해제된다. 둘째, 빈 슬롯 테스트 TupIsNull(slot)이 “더 없음” 센티넬이다. PostgreSQL은 데이터 끝을 항상 C NULL이 아닌 빈 슬롯으로 신호한다. 셋째, 루프는 SELECT에서만 튜플을 센다. INSERT/UPDATE/DELETE에서는 ModifyTable 노드가 자체적으로 수정된 행을 카운트한다. 이것이 numberTuples(커서 제한)가 조회된 튜플에만 적용된다고 문서화된 이유다.

TupleTableSlot — 데이터 흐름의 단위

섹션 제목: “TupleTableSlot — 데이터 흐름의 단위”

모든 Exec*TupleTableSlot *를 반환한다. 슬롯은 튜플이 아닌 핸들이다. TupleTableSlotOps vtable 포인터와 디포밍된 컬럼 값 캐시를 담고 있으며, vtable이 실제 물리적 튜플(존재하는 경우)을 결정한다.

// TupleTableSlot — src/include/executor/tuptable.h
typedef struct TupleTableSlot
{
NodeTag type;
uint16 tts_flags; /* TTS_FLAG_EMPTY, _SHOULDFREE, _FIXED, _SLOW */
AttrNumber tts_nvalid; /* # of leading columns already deformed */
const TupleTableSlotOps *const tts_ops; /* the vtable (== the slot "type") */
TupleDesc tts_tupleDescriptor; /* row shape */
Datum *tts_values; /* deformed column values (cache) */
bool *tts_isnull; /* per-column null flags */
MemoryContext tts_mcxt;
ItemPointerData tts_tid; /* TID of the stored tuple, if any */
Oid tts_tableOid;
} TupleTableSlot;

네 가지 내장 슬롯 타입은 각각 하나의 실제 저장 방식용으로 init / clear / getsomeattrs / materialize / copyslot / get_heap_tuple / 기타를 채운 네 const TupleTableSlotOps vtable이다.

// the four slot vtables — src/include/executor/tuptable.h
extern PGDLLIMPORT const TupleTableSlotOps TTSOpsVirtual;
extern PGDLLIMPORT const TupleTableSlotOps TTSOpsHeapTuple;
extern PGDLLIMPORT const TupleTableSlotOps TTSOpsMinimalTuple;
extern PGDLLIMPORT const TupleTableSlotOps TTSOpsBufferHeapTuple;
슬롯 타입실제 저장 방식대표 생산자
TTSOpsVirtual없음 — tts_values/tts_isnull 자체가 튜플프로젝션, VALUES, 조인 계산 출력
TTSOpsHeapTuple슬롯이 소유하는 palloc된 HeapTuple메모리에서 구성된 튜플, FunctionScan
TTSOpsMinimalTuple슬롯이 소유하는 MinimalTuple (헤더·가시성 없음)정렬 출력, 해시 테이블, 튜플스토어
TTSOpsBufferHeapTuple고정된 공유 버퍼를 가리키는 HeapTuple힙 페이지를 읽는 SeqScan / IndexScan

BufferHeapTuple 슬롯은 익스큐터를 스토리지 계층에 연결하는 경계다. 버퍼 풀에서 행을 복사하지 않고, 버퍼를 고정한 채 내부를 직접 가리킨다. 복사와 핀 해제를 모두 미루는 방식이다. MinimalTuple 슬롯은 병렬 질의 shm_mq 경계를 넘는 슬롯이다. 최소 튜플에는 트랜잭션 헤더가 없어 경계를 넘겨도 안전하기 때문이다. Virtual 슬롯은 가장 비용이 낮으며 프로젝션이 만드는 슬롯이다. 컬럼 자체가 tts_values 배열이므로 “deforming”이 전혀 없다.

flowchart TB
  CONS["소비 노드<br/>(조인, 집계, 정렬, dest)"]
  CONS -->|"slot_getattr / slot_getsomeattrs"| SLOT
  subgraph SLOT["TupleTableSlot (균일한 핸들)"]
    VT["tts_ops -> vtable<br/>(getsomeattrs / materialize / copyslot)"]
    CACHE["tts_values[] + tts_isnull[]<br/>tts_nvalid = 디포밍 워터마크"]
  end
  VT --> V["TTSOpsVirtual<br/>(values가 곧 튜플)"]
  VT --> H["TTSOpsHeapTuple<br/>(소유된 HeapTuple)"]
  VT --> M["TTSOpsMinimalTuple<br/>(소유된 MinimalTuple)"]
  VT --> B["TTSOpsBufferHeapTuple<br/>(고정된 버퍼 안의 HeapTuple)"]
  B -.-> BUF[("공유 버퍼 풀")]

그림 4 — 슬롯 추상화. 소비자는 하나의 인터페이스로 슬롯에 컬럼 값을 요청하고, tts_ops vtable이 실제 저장 방식의 deform/materialize 루틴으로 디스패치한다. BufferHeapTuple 실제 저장 방식은 복사 없이 고정된 공유 버퍼를 직접 가리킨다. 익스큐터와 버퍼 관리자 사이의 경계다.

슬롯은 MakeTupleTableSlot으로 생성된다. 디스크립터가 알려진 경우 tts_values/tts_isnull 배열을 슬롯과 함께 한 번에 할당하고 vtable의 init을 호출한다.

// MakeTupleTableSlot — src/backend/executor/execTuples.c (condensed)
basesz = tts_ops->base_slot_size;
if (tupleDesc)
allocsz = MAXALIGN(basesz)
+ MAXALIGN(tupleDesc->natts * sizeof(Datum))
+ MAXALIGN(tupleDesc->natts * sizeof(bool));
else
allocsz = basesz;
slot = palloc0(allocsz);
*((const TupleTableSlotOps **) &slot->tts_ops) = tts_ops; /* set the vtable */
slot->type = T_TupleTableSlot;
slot->tts_flags |= TTS_FLAG_EMPTY; /* born empty */
slot->tts_tupleDescriptor = tupleDesc;
/* ... point tts_values/tts_isnull into the co-allocation ... */
slot->tts_ops->init(slot); /* backing-specific init */

새로 만들어진 슬롯은 상태(TTS_FLAG_EMPTY)이며, TupIsNull은 C NULL 포인터와 빈 슬롯 모두를 “튜플 없음”으로 처리한다.

// TupIsNull — src/include/executor/tuptable.h
#define TupIsNull(slot) \
((slot) == NULL || TTS_EMPTY(slot))

슬롯에 튜플을 넣으려면 슬롯 타입에 맞는 ExecStore* 루틴을 사용한다. ExecStoreHeapTuple, ExecStoreMinimalTuple, ExecStoreBufferHeapTuple, 그리고 tts_values가 직접 채워진 가상 슬롯용 ExecStoreVirtualTuple이 있다. ExecStoreVirtualTuple은 단순히 빈 플래그를 지우고 모든 컬럼을 유효로 선언한다.

// ExecStoreVirtualTuple — src/backend/executor/execTuples.c
TupleTableSlot *
ExecStoreVirtualTuple(TupleTableSlot *slot)
{
Assert(TTS_EMPTY(slot));
slot->tts_flags &= ~TTS_FLAG_EMPTY;
slot->tts_nvalid = slot->tts_tupleDescriptor->natts; /* all valid */
return slot;
}

tts_nvalid는 디포밍 워터마크다. 현재 튜플의 선행 컬럼 가운데 몇 개가 tts_values로 디코딩됐는지를 기록한다. 무언가가 컬럼 n을 요청하면, slot_getsomeattrs는 아직 안 됐다면 vtable의 getsomeattrs를 호출해 n까지만 디포밍한다.

// slot_getsomeattrs (inline) — src/include/executor/tuptable.h, calls execTuples.c
static inline void
slot_getsomeattrs(TupleTableSlot *slot, int attnum)
{
if (slot->tts_nvalid < attnum)
slot_getsomeattrs_int(slot, attnum); /* deform the gap, advance nvalid */
}

12컬럼 행에서 dept(컬럼 1)만 선택하는 질의는 컬럼 2–12를 디코딩하지 않는다. 표현식 기계도 이를 활용한다. README는 ExprState의 단계 배열이 “관련 튜플이 필요한 컬럼을 직접 접근할 수 있도록 디컨스트럭션됐음을 보장하는 EEOP_*_FETCHSOME 단계로 시작한다”고 설명한다. 따라서 각 Var 페치는 “배열 조회에 불과하다.” 해당 단계의 세부 구현은 postgres-expression-eval.md가 담당한다.

EState는 실행별 전역이다. 익스큐터 호출당 하나씩 존재하며, 트리의 모든 노드가 공유한다(각 PlanState.state가 그것을 가리킨다). 런타임에 관련된 필드들은 다음과 같다.

// EState (abridged) — src/include/nodes/execnodes.h
typedef struct EState
{
NodeTag type;
ScanDirection es_direction; /* forward / backward / no-movement */
Snapshot es_snapshot; /* the registered query snapshot */
Snapshot es_crosscheck_snapshot;
List *es_range_table; /* RangeTblEntry list */
PlannedStmt *es_plannedstmt;
JunkFilter *es_junkFilter;
CommandId es_output_cid; /* CID to stamp inserted/deleted tuples */
ResultRelInfo **es_result_relations; /* DML target tables */
ParamListInfo es_param_list_info; /* external params */
ParamExecData *es_param_exec_vals; /* internal params (subplans) */
MemoryContext es_query_cxt; /* PER-QUERY arena: holds everything */
List *es_tupleTable; /* all the TupleTableSlots */
uint64 es_processed; /* tuples processed this ExecutorRun */
int es_top_eflags;
List *es_subplanstates;
ExprContext *es_per_tuple_exprcontext; /* PER-OUTPUT-TUPLE arena */
struct EPQState *es_epq_active; /* non-NULL inside an EvalPlanQual recheck */
bool es_use_parallel_mode;
/* ... parallel-worker counters, DSA area, etc. ... */
} EState;

README가 명시하는 두 가지 메모리 수명은 다음과 같다.

  • 질의별 컨텍스트 (es_query_cxt): CreateExecutorState가 생성하며, PlanState 트리, ExprState 트리, 슬롯, 모든 것을 담는다. 해제는 FreeExecutorState 내부의 단일 MemoryContextDelete다. “개별 pfree와 발생 가능한 스토리지 누수를 건드리는 대신, 메모리 컨텍스트 자체를 파괴한다”는 것이 README의 설명이다.
  • 튜플별 컨텍스트 (각 ExprContext.ecxt_per_tuple_memory, 그리고 최상위 es_per_tuple_exprcontext): 한 튜플의 qual과 프로젝션 평가를 위한 스크래치 공간으로, 튜플마다 초기화된다. ResetExprContext / ResetPerTupleExprContext가 일괄 해제를 수행한다.
flowchart TB
  subgraph QCTX["es_query_cxt — 질의별 컨텍스트 (질의 전체 수명)"]
    PST["PlanState 트리"]
    EXS["ExprState 트리<br/>(컴파일된 qual + tlist)"]
    SLOTS["TupleTableSlots<br/>(es_tupleTable)"]
  end
  subgraph TCTX["튜플별 ExprContext 메모리 (튜플마다 초기화)"]
    SCR["qual / projection 스크래치<br/>함수 호출 작업 저장소"]
  end
  RESET["ResetExprContext / ResetPerTupleExprContext<br/>(일괄 해제, 튜플마다 한 번)"]
  RESET -.->|비운다| TCTX
  FREE["FreeExecutorState -> MemoryContextDelete"]
  FREE -.->|파괴한다| QCTX

그림 5 — 두 가지 메모리 수명. 질의별 컨텍스트는 전체 상태 기계를 담고 ExecutorEnd에서 한 번에 해제된다. 튜플별 컨텍스트는 현재 튜플의 임시 스크래치만 담으며 튜플마다 초기화된다. 이 덕분에 아무리 많은 튜플이 흘러도 인트라-질의 메모리가 제한된다. (출처: executor/README “Memory Management”.)

es_epq_active 필드는 EvalPlanQualREAD COMMITTED 갱신 재검사 기계 — 을 위한 익스큐터 훅이다. 동시 갱신이 발생하면 단일 수정 행이 qual을 통과하는지 재확인하기 위해 질의가 다시 실행된다. 익스큐터 프레임워크는 플래그와 대체 튜플 배관만 담당한다. 재검사 정책은 txn/MVCC 계층과 postgres-mvcc-snapshots.md에 속한다.

스캔 진입점 — ExecScan과 ExecScanExtended

섹션 제목: “스캔 진입점 — ExecScan과 ExecScanExtended”

모든 리프 스캔(SeqScan, IndexScan, SampleScan, SubqueryScan, …)은 보일러플레이트 하나를 공유한다. 액세스 메서드에서 후보 튜플을 가져오고, 노드의 qual로 검사하고, 살아남은 컬럼을 프로젝션하고, 튜플이 통과하거나 소스가 소진될 때까지 루프를 반복하는 것이다. 이 보일러플레이트가 execScan.[ch]ExecScan / ExecScanExtended다. ExecScan은 노드의 qual / projection / EPQ 상태를 가져와 항상 인라인되는 핵심으로 전달한다.

// ExecScan — src/backend/executor/execScan.c
TupleTableSlot *
ExecScan(ScanState *node,
ExecScanAccessMtd accessMtd, /* per-node "get next raw tuple" */
ExecScanRecheckMtd recheckMtd) /* per-node EPQ recheck */
{
EPQState *epqstate = node->ps.state->es_epq_active;
ExprState *qual = node->ps.qual;
ProjectionInfo *projInfo = node->ps.ps_ProjInfo;
return ExecScanExtended(node, accessMtd, recheckMtd,
epqstate, qual, projInfo);
}

ExecScanExtendedpg_attribute_always_inline으로 표시되어 컴파일러가 각 호출 지점에서 특수화할 수 있다. 스캔에 qual도 projInfo도 없으면 해당 분기가 완전히 제거된다. 루프는 다음과 같다.

// ExecScanExtended — src/include/executor/execScan.h (condensed)
ExprContext *econtext = node->ps.ps_ExprContext;
if (!qual && !projInfo) /* no filter, no projection */
{
ResetExprContext(econtext);
return ExecScanFetch(node, epqstate, accessMtd, recheckMtd); /* raw tuple */
}
ResetExprContext(econtext); /* free last tuple's scratch */
for (;;)
{
TupleTableSlot *slot = ExecScanFetch(node, epqstate, accessMtd, recheckMtd);
if (TupIsNull(slot)) /* source exhausted */
{
if (projInfo)
return ExecClearTuple(projInfo->pi_state.resultslot);
else
return slot;
}
econtext->ecxt_scantuple = slot; /* expose tuple to Var refs in qual/tlist */
if (qual == NULL || ExecQual(qual, econtext))
{
if (projInfo)
return ExecProject(projInfo); /* project surviving columns */
else
return slot; /* return raw scan tuple */
}
else
InstrCountFiltered1(node, 1); /* count a qual-rejected row */
ResetExprContext(econtext); /* tuple failed qual -> free + retry */
}

README가 언급하는 “패스트 패스”가 여기 산다. WHERE 없는 SELECT *(qual 없음, tlist가 스캔 디스크립터와 정확히 일치해 ps_ProjInfo == NULL)는 if (!qual && !projInfo) 분기를 타고 액세스 메서드의 슬롯을 그대로 반환한다. 프로젝션도 qual 호출도 없다. ExecAssignScanProjectionInfo가 “요청된 tlist가 기반 튜플 타입과 정확히 일치할 때” ps_ProjInfo를 NULL로 두어 프로젝션을 건너뛸 수 있게 한다.

ExecScanFetch(역시 항상 인라인)는 노드별 액세스 메서드로의 간접 호출이며, 앞단에 EvalPlanQual 대체가 레이어된다. EPQ 재검사 안에 있지 않을 때(epqstate == NULL, 일반 경우) 전체 EPQ 블록은 컴파일 시 제거되어 ExecScanFetch는 단순히 return (*accessMtd)(node)가 된다.

// ExecScanFetch — src/include/executor/execScan.h (common-case skeleton)
static pg_attribute_always_inline TupleTableSlot *
ExecScanFetch(ScanState *node, EPQState *epqstate,
ExecScanAccessMtd accessMtd, ExecScanRecheckMtd recheckMtd)
{
CHECK_FOR_INTERRUPTS();
if (epqstate != NULL)
{
/* inside an EvalPlanQual recheck: return the substitute test tuple,
* or an empty slot if this rel's EPQ tuple was already consumed */
/* ... relsubs_slot / relsubs_rowmark handling ... */
}
return (*accessMtd) (node); /* the node's real next-tuple routine */
}

accessMtd는 실제로 스토리지와 통신하는 노드별 함수다. 순차 스캔의 경우 테이블 AM의 table_scan_getnextslot을 호출하는 SeqNext가 그 예다. 노드별 액세스 메서드와 그것이 호출하는 테이블/인덱스 AM 계층은 postgres-scan-nodes.md와 스토리지 엔진 문서가 담당한다. 이 문서는 이들이 플러그인되는 ExecScan 보일러플레이트에서 멈춘다.

INSERT / UPDATE / DELETE / MERGE에서 실제 테이블 변경은 ExecutePlan에서 일어나지 않는다. 최상위 ModifyTable 플랜 노드(ExecInitModifyTable / ExecModifyTable) 안에서 일어난다. ExecutePlan은 여전히 루트에서 튜플을 당기지만, 이 명령들에서 루트는 ModifyTable 노드 자체다. 그 아래 플랜 트리는 새 컬럼 값과 “정크” 행 식별 컬럼(힙 테이블의 CTID)을 생성하고, ModifyTable은 테이블 AM을 호출해 삽입/갱신/삭제를 수행한다. RETURNING 절이 있으면 ModifyTable은 계산된 행을 출력으로 내보낸다(이것이 hasReturningsendTuples를 참으로 만드는 이유다). 없으면 아무것도 반환하지 않으며, 행 카운팅은 ExecutePlanCMD_SELECT 카운터가 아닌 ModifyTable 자신이 담당한다. ModifyTable의 노드별 세부 사항 — 파티션 튜플 라우팅, 트리거 발화, ON CONFLICT, MERGE 액션 디스패치 — 은 별도 노드 문서에서 다룬다. 이 문서에서 가져갈 프레임워크 사실은 쓰기도 노드이며, 다른 노드와 같은 방식으로 당겨진다는 것이다.

ExecEndNodeExecInitNode를 미러링한다. nodeTag switch로 노드별 ExecEnd* 루틴을 호출하고, 각 루틴은 자식으로 재귀하며 해당 노드의 리소스를 해제한다(릴레이션 닫기, 버퍼 핀 해제, 튜플스토어 해제). README는 이것이 메모리를 해제하는 것에 관한 것이 아님을 명시한다. “ExecEndNode가 메모리를 해제하는 것이 실제로 중요한 것은 아니다. 어차피 FreeExecutorState에서 모두 사라진다.” 중요한 것은 메모리 컨텍스트 삭제로는 누수될 비-메모리 리소스(릴레이션 잠금, 버퍼 핀)를 해제하는 것이다. ExecShutdownNode는 더 가볍고 이른 패스다(ExecutePlan이 역방향 스캔이 불가능할 때 호출). 비동기 리소스 소비를 중단한다. 특히 병렬 워커를 종료하고 Gather 노드에 버퍼 사용 통계를 전파한다.

심볼 이름에 닻을 내린다. 줄 번호가 아니다. PostgreSQL 소스는 릴리스마다 이동한다. 함수나 구조체 이름이 안정적인 기준점이다. git grep -n '<symbol>' src/backend/executor/로 현재 위치를 찾는다. 아래 위치 힌트 표의 줄 번호는 커밋 273fe94(REL_18_STABLE) 기준으로 관찰한 빠른 힌트다.

  • ExecutorStart / standard_ExecutorStartEState 생성, 스냅샷 등록, es_output_cid 설정, AFTER-트리거 스코프 시작, InitPlan 호출.
  • ExecutorRun / standard_ExecutorRun — dest 수신자 시작, 방향이 이동 없음이 아닌 경우 ExecutePlan 호출, es_total_processed 누적.
  • ExecutorFinish / standard_ExecutorFinishExecPostprocessPlan(미완료 ModifyTable 노드 실행) 후 AfterTriggerEndQuery.
  • ExecutorEnd / standard_ExecutorEndExecEndNode, 두 스냅샷 해제, FreeExecutorState.
  • ExecutorRewind — 커서 되감기를 위해 루트에 ExecReScan(SELECT 전용).
  • InitPlan — 권한, 범위 테이블, 행 마크, 초기 프루닝, 서브플랜 초기화, 그 다음 ExecInitNode(root). 결과 TupleDesc와 정크 필터 도출.
  • ExecutePlan — 요구 풀 루프: ResetPerTupleExprContextExecProcNode → 정크 필터 → dest->receiveSlot → 카운트 → 제한 검사.
  • ExecCheckPermissions / ExecCheckOneRelPermsInitPlan에서 구동되는 릴레이션·컬럼별 ACL 검사.
  • ExecInitNodeopen() 디스패치. nodeTag switchExecInit* 호출, 자식 재귀, ExecProcNode 래퍼 설치.
  • ExecSetExecProcNode — 첫 호출 래퍼 ExecProcNodeFirst 설치, 실제 함수를 ExecProcNodeReal에 저장.
  • ExecProcNodeFirst — 일회성 스택 검사 후 ExecProcNode를 실제 함수 또는 ExecProcNodeInstr로 재연결.
  • ExecProcNodeInstrEXPLAIN ANALYZE 아래에서 사용되는 계측 래퍼(InstrStartNode / InstrStopNode로 실제 호출을 감쌈).
  • MultiExecProcNode — 튜플 하나가 아닌 전체 구조(해시 테이블, 비트맵)를 반환하는 노드의 벌크 변형: Hash, BitmapIndexScan, BitmapAnd, BitmapOr.
  • ExecEndNodeclose() 디스패치. 대칭적인 nodeTag switchExecEnd* 호출.
  • ExecShutdownNode / ExecShutdownNode_walker — 조기 비동기 리소스 해제. Gather / GatherMerge 워커, Hash, 외부·커스텀 스캔 종료.
  • ExecSetTupleBound — 바운드-인식 노드(Sort, IncrementalSort, Append, Gather, …)로 FETCH n 제한을 전파해 바운드 정렬을 활성화.
  • ExecProcNode (인라인) — 공개 next(): chgParam 있으면 재스캔, 그 다음 node->ExecProcNode(node).
  • ExecProcNodeMtd (typedef, execnodes.h) — TupleTableSlot *를 반환하는 노드별 함수 포인터 타입.
  • ExecReScan — 서브트리를 재초기화해 출력 시퀀스를 다시 내보내게 함(변경된 Param에 의해 구동됨). README의 “rescan command.”
  • struct PlanState — 추상 기반. ExecProcNode 포인터, lefttree/righttree 엣지, qual, ps_ProjInfo, ps_ResultTupleSlot, ps_ExprContext.
  • struct EState — 실행별 전역. es_query_cxt, es_snapshot, es_direction, es_tupleTable, es_per_tuple_exprcontext, es_epq_active.
  • struct ExprContext — 튜플별 메모리(ecxt_per_tuple_memory)와 Var 참조가 해석되는 ecxt_scantuple / ecxt_innertuple / ecxt_outertuple 슬롯.
  • struct ScanState — 스캔 노드 기반: ss_currentRelation, ss_currentScanDesc(커서), ss_ScanTupleSlot.

슬롯 추상화 (tuptable.h, execTuples.c)

섹션 제목: “슬롯 추상화 (tuptable.h, execTuples.c)”
  • struct TupleTableSlot — 균일한 핸들. tts_ops, tts_values / tts_isnull, tts_nvalid, tts_flags.
  • struct TupleTableSlotOps — vtable(init / clear / getsomeattrs / materialize / copyslot / get_heap_tuple / …).
  • TTSOpsVirtual / TTSOpsHeapTuple / TTSOpsMinimalTuple / TTSOpsBufferHeapTuple — 네 가지 실제 저장 방식 구현.
  • TupIsNull (매크로) — NULL-또는-빈 검사. 데이터 끝 센티넬.
  • MakeTupleTableSlot / ExecAllocTableSlot / ExecInitScanTupleSlot — 슬롯 생성.
  • ExecStoreHeapTuple / ExecStoreMinimalTuple / ExecStoreVirtualTuple — 일치하는 타입의 슬롯에 튜플 저장.
  • ExecClearTuple (인라인) — 슬롯을 빈 상태로 반환.
  • slot_getsomeattrs / slot_getsomeattrs_intattnum까지 지연 좌-접두사 디포밍, tts_nvalid 전진.

스캔 보일러플레이트 (execScan.c, execScan.h)

섹션 제목: “스캔 보일러플레이트 (execScan.c, execScan.h)”
  • ExecScan — 공개 스캔 드라이버. qual / projInfo / EPQ를 읽어 ExecScanExtended로 전달.
  • ExecScanExtended (항상-인라인) — 패치/qual/프로젝션 루프. no-qual-no-project 패스트 패스 포함.
  • ExecScanFetch (항상-인라인) — CHECK_FOR_INTERRUPTS + EPQ 대체 + (*accessMtd)(node).
  • ExecAssignScanProjectionInfo — tlist가 스캔 디스크립터와 일치하면 프로젝션을 건너뛸지 결정(ps_ProjInfo를 NULL로 남김).
  • ExecScanReScan — 재스캔 시 스캔 슬롯을 비우고 EPQ done-flag를 초기화.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
ExecutorStartexecMain.c122
standard_ExecutorStartexecMain.c141
standard_ExecutorRunexecMain.c307
standard_ExecutorFinishexecMain.c415
standard_ExecutorEndexecMain.c475
ExecCheckPermissionsexecMain.c582
InitPlanexecMain.c836
ExecutePlanexecMain.c1660
ExecInitNodeexecProcnode.c142
ExecSetExecProcNodeexecProcnode.c430
ExecProcNodeFirstexecProcnode.c448
ExecProcNodeInstrexecProcnode.c479
MultiExecProcNodeexecProcnode.c507
ExecEndNodeexecProcnode.c562
ExecShutdownNodeexecProcnode.c772
ExecSetTupleBoundexecProcnode.c848
ExecProcNode (인라인)executor.h310
ExecProcNodeMtd (typedef)execnodes.h1140
struct PlanStateexecnodes.h1149
struct EStateexecnodes.h649
struct ExprContextexecnodes.h262
struct ScanStateexecnodes.h1609
struct TupleTableSlottuptable.h114
struct TupleTableSlotOpstuptable.h134
TupIsNull (매크로)tuptable.h310
MakeTupleTableSlotexecTuples.c1301
ExecStoreVirtualTupleexecTuples.c1741
ExecScanexecScan.c47
ExecScanReScanexecScan.c108
ExecScanFetch (인라인)execScan.h32
ExecScanExtended (인라인)execScan.h160

각 항목은 커밋 273fe94(REL_18_STABLE)의 현재 소스에 대한 사실로 시작한다. 다른 자료 없이도 읽을 수 있다. 끝 문장은 검증 방법을 보여 준다. 미해결 질문은 기록된 공백으로 이어진다.

  • 네 가지 익스큐터 진입점 모두 standard_* 본체를 감싸는 훅 디스패처다. ExecutorStart/Run/Finish/End는 각각 *_hook 전역을 테스트하고 그렇지 않으면 standard_*를 호출한다. 훅 전역(ExecutorStart_hook 등)은 execMain.c 상단에 정의되어 NULL로 초기화된다. 2026-06-05에 네 함수를 직접 읽어 검증.

  • ExecProcNodeswitch가 아닌 인라인 함수이며, 노드별 디스패치는 초기화 시점에 설정된 함수 포인터다. 인라인은 executor.h에 있으며 node->ExecProcNode(node)를 호출한다. 포인터는 ExecSetExecProcNode(execProcnode.c)가 첫 호출 래퍼 ExecProcNodeFirst로 설치하며, 이후 순수 노드별 함수 또는 ExecProcNodeInstr로 재연결된다. 2026-06-05에 세 함수를 모두 읽어 검증. 튜플별 비용은 첫 번째 호출 이후 간접 호출 하나다.

  • 데이터 끝은 TupIsNull로 테스트되는 빈 TupleTableSlot으로 신호되며, C NULL이 아니다. ExecutePlanTupIsNull(slot)에서 중단한다. TupIsNull(tuptable.h)은 ((slot) == NULL || TTS_EMPTY(slot))다. 두 파일을 읽어 검증. 개별 노드는 NULL 포인터나 비워진 슬롯을 반환할 수 있으며, 둘 다 TupIsNull을 충족한다.

  • 내장 슬롯 실제 저장 방식은 정확히 네 가지다. TTSOpsVirtual, TTSOpsHeapTuple, TTSOpsMinimalTuple, TTSOpsBufferHeapTupletuptable.hextern PGDLLIMPORT const로 선언되고 execTuples.c에 정의된다. 두 파일을 읽어 검증. tts_ops 포인터는 슬롯의 타입 태그 역할도 한다(TTS_IS_* 매크로가 vtable 주소로 비교).

  • 상태 트리와 플랜 트리는 1:1 동형이며, 문서화된 Append/MergeAppend 프루닝 예외가 있다. ExecInitNodenodeTag switch에는 플랜 노드 타입마다 하나의 ExecInit*가 있고, README는 예외를 명시한다(“런타임 프루닝 아래에서 익스큐터 상태의 서브노드 배열이 플랜의 서브플랜 목록과 어긋날 수 있다”). 2026-06-05에 ExecInitNode와 README를 읽어 검증.

  • 전체 익스큐터 호출은 단일 연산으로 해제되는 하나의 질의별 메모리 컨텍스트 안에 산다. CreateExecutorStatees_query_cxt를 생성하고, standard_ExecutorStart/Run/Finish가 그 안으로 전환하며, FreeExecutorState(standard_ExecutorEnd가 호출)가 파괴한다. README의 Memory Management 절은 의도를 서술한다(“메모리 컨텍스트를 파괴한다”). 진입점을 읽어 검증. MemoryContextDelete 자체는 execUtils.cFreeExecutorState 안에 있으며 별도로 확인하지 않았다. README의 서술과 switch/free 쌍을 근거로 채택.

  • 질의 스냅샷은 실행 동안 등록되고 끝에 해제된다. standard_ExecutorStartestate->es_snapshot = RegisterSnapshot(queryDesc->snapshot)을 수행하고 GetActiveSnapshot() == queryDesc->snapshot을 단언한다. standard_ExecutorEndUnregisterSnapshot(estate->es_snapshot)을 호출한다. 두 함수를 읽어 검증.

  • ExecScanExtended는 no-qual-no-projection 스캔을 위한 분기 제거 패스트 패스를 갖는다. execScan.hif (!qual && !projInfo) 이른 반환과 pg_attribute_always_inline 속성이 결합되어 컴파일러가 각 호출 지점에서 qual/projection 분기를 제거할 수 있다. ExecAssignScanProjectionInfo가 tlist가 스캔 튜플 타입과 일치할 때 ps_ProjInfo를 NULL로 남긴다. execScan.hexecScan.cExecAssignScanProjectionInfo를 읽어 검증.

  • ExecScanFetch는 EPQ 재검사 안에 있지 않을 때 EvalPlanQual 경로를 컴파일 시 제거한다. 전체 EPQ 블록이 if (epqstate != NULL) 아래에 있고, epqstate는 EPQ 재검사 외부에서 NULL인 node->ps.state->es_epq_active다. pg_attribute_always_inline으로 죽은 분기가 제거된다. ExecScanExecScanFetch를 읽어 검증.

  1. es_per_tuple_exprcontext는 노드별 ExprContext와 관련해 정확히 어디서 초기화되는가? ExecutePlanResetPerTupleExprContext(estate)(최상위 컨텍스트)를 호출하고, ExecScanExtended는 노드 자신의 ps_ExprContext를 초기화한다. EState 수준 튜플별 컨텍스트와 각 노드의 튜플별 컨텍스트 사이의 분업이 여기서 완전히 추적되지 않았다. 조사 경로: execUtils.cCreateExprContext / GetPerTupleExprContextexecnodes.hExprContext 위 주석 블록을 읽는다.

  2. MultiExecProcNode를 지원하는 노드의 정확한 집합은 무엇이며 닫혀 있는가? REL_18 switchHash, BitmapIndexScan, BitmapAnd, BitmapOr를 나열한다. 커스텀 스캔이나 외부 스캔 제공자가 이 집합을 확장할 수 있는지, 아니면 그 네 개로 고정됐는지 검증하지 않았다. 조사 경로: MultiExec* 정의를 grep하고 nodeCustom.c의 커스텀 스캔 API를 확인한다.

  3. 비동기 실행(ExecAsyncRequest / ExecAppendAsyncEventWait)이 동기 풀 루프와 어떻게 교차하는가? README는 비동기-가능 ForeignScan 자식 위의 Append를 위한 비동기 경로를 문서화하지만, 이 문서는 동기 요구 풀 척추만 다룬다. 조사 경로: nodeAppend.c의 비동기 이벤트 루프와 execAsync.c를 읽는다. 별도 후속 노트가 적합할 것이다.

PostgreSQL 너머 — 비교 설계와 연구 전선

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”

포인터일 뿐, 분석이 아니다. 각 항목은 후속 문서를 위한 시작점이며, 여기서의 깊이는 의도적으로 얕다.

  • CUBRID의 XASL 트리 대 PostgreSQL의 PlanState 트리. CUBRID는 XASL(eXtended Access Specification Language) 트리를 실행한다. 역시 풀 방식 연산자 트리지만, 플랜과 실행 상태의 분리가 PostgreSQL의 읽기-전용-Plan / 변경-가능-PlanState 분리만큼 깔끔하지 않다. 각 엔진이 플랜 재사용과 병렬 재실행을 어떻게 달성하는지 나란히 비교하면 PostgreSQL이 엄격한 불변성 규칙에서 얻는 이득이 선명해질 것이다.

  • 벡터화 / 배치-at-a-time 실행. Volcano 모델에 대한 고전적 비판은 튜플별 인터프리터 오버헤드다. 호출 하나와 슬롯 하나가 행 하나에 해당한다. MonetDB/X100(Boncz, Zukowski & Nes, CIDR 2005, “MonetDB/X100: Hyper-Pipelining Query Execution”)과 컬럼 스토어는 호출당 값 벡터를 처리한다. C-Store / Vertica 계보(dbms-papers/cstore.md, vertica-7-years.md)가 상용 구현이다. PostgreSQL은 코어에서 튜플-at-a-time을 유지한다. JIT 컴파일(postgres-jit.md)이 벡터화 대신 인터프리터 오버헤드에 대한 PostgreSQL의 답이다.

  • 푸시 기반 / 데이터-중심 컴파일 실행. Neumann의 “Efficiently Compiling Efficient Query Plans for Modern Hardware”(VLDB 2011, HyPer 엔진)은 이터레이터를 반전시킨다. 튜플을 당기는 대신, 연산자 경계를 넘어 튜플을 CPU 레지스터에 유지하는 타이트한 푸시 기반 루프로 플랜을 컴파일한다. DSC §15.7.2.1이 명명하는 생산자-구동 방향이다. 비교하면 PostgreSQL의 풀 모델이 L1/분기-예측 측면에서 치르는 비용과 단순성·구성성 사이의 트레이드오프를 수치화할 수 있다.

  • Volcano 교환 연산자와 PostgreSQL 병렬 처리. Graefe의 “Volcano — An Extensible and Parallel Query Evaluation System”(IEEE TKDE 1994)은 연산자를 변경하지 않고 직렬 이터레이터 트리를 병렬로 만드는 교환 연산자를 도입했다. PostgreSQL의 Gather / GatherMerge 노드와 shm_mq는 같은 아이디어의 변형이다. 병렬-인식 노드가 백그라운드 워커로 서브플랜을 포크하고 스트림을 재병합한다. 교차 참조는 postgres-parallel-query.md다. Gather를 교환 연산자 추상화에 매핑하면 구현이 이론과 연결된다.

  • 모셀-구동 병렬 처리. Leis et al., “Morsel-Driven Parallelism”(SIGMOD 2014)은 교환 연산자를 작은 튜플 “모셀”에 대한 작업 훔치기 스케줄러로 대체한다. PostgreSQL의 워커별 서브플랜 분할보다 다중 코어 머신에서 부하 분산이 낫다. PostgreSQL 병렬 질의를 현대 NUMA 하드웨어에서 측정할 때 관련성이 있다.

  • src/backend/executor/README — 권위 있는 설계 문서. 요구 풀 파이프라인 모델, Plan/State 두 트리 분리, 표현식 대 상태 트리, 메모리 관리(질의별·튜플별 컨텍스트), 질의 처리 제어 흐름 스케치, EvalPlanQual, 비동기 실행.

교재 장 (knowledge/research/dbms-general/ 아래)

섹션 제목: “교재 장 (knowledge/research/dbms-general/ 아래)”
  • Database System Concepts(Silberschatz, Korth & Sudarshan, 7판), 15장 “Query Processing”, §15.7 “Evaluation of Expressions” — §15.7.2 “Pipelining”(물질화 대 파이프라인; 이점)과 §15.7.2.1 “Implementation of Pipelining”(요구 구동 대 생산자 구동; open()/next()/close() 이터레이터). 22장 §22.5: 병렬 처리에서의 Volcano 교환 연산자 모델.

논문 (knowledge/research/dbms-papers/ 아래)

섹션 제목: “논문 (knowledge/research/dbms-papers/ 아래)”
  • Architecture of a Database System(Hellerstein, Stonebraker & Hamilton, FnT 2007) — fntdb07-architecture.md, §1.1 / §4: 연산자 모음으로서의 관계형 질의 처리기, “풀 모델”로 제공되는 SQL.
  • (비교, 아직 수집 전) Graefe 1994 “Volcano”; Boncz et al. 2005 “MonetDB/X100”; Neumann 2011 “Compiling Efficient Query Plans”; Leis et al. 2014 “Morsel-Driven Parallelism” — §“PostgreSQL 너머” 참조.

PostgreSQL 소스 (/data/hgryoo/references/postgres/, REL_18 273fe94 기준)

섹션 제목: “PostgreSQL 소스 (/data/hgryoo/references/postgres/, REL_18 273fe94 기준)”
  • src/backend/executor/execMain.c — 수명 주기 진입점, InitPlan, ExecutePlan, 권한 검사.
  • src/backend/executor/execProcnode.cExecInitNode / ExecProcNode 래퍼 / ExecEndNode 디스패치, MultiExecProcNode, ExecShutdownNode, ExecSetTupleBound.
  • src/backend/executor/execTuples.c — 슬롯 생성과 ExecStore* / slot_getsomeattrs; 네 가지 TTSOps* vtable 정의.
  • src/backend/executor/execScan.c + src/include/executor/execScan.h — 스캔 페치/qual/프로젝션 보일러플레이트.
  • src/include/executor/executor.h — 인라인 ExecProcNode, 표현식 평가 프로토타입.
  • src/include/executor/tuptable.hTupleTableSlot, TupleTableSlotOps, TupIsNull, TTSOps* 선언.
  • src/include/nodes/execnodes.hPlanState, EState, ExprContext, ScanState, ExecProcNodeMtd.

교차 참조 (인접 메커니즘을 소유한 형제 문서)

섹션 제목: “교차 참조 (인접 메커니즘을 소유한 형제 문서)”
  • postgres-planner-overview.md — 읽기 전용 Plan 트리가 구축되는 방식.
  • postgres-node-trees.mdNode / Plan / PlanState 타입 시스템.
  • postgres-expression-eval.mdExprState / ExprEvalStep qual과 프로젝션, slot_getsomeattrs 페치 단계.
  • postgres-scan-nodes.mdExecScan에 플러그인되는 노드별 액세스 메서드(SeqNext 등).
  • postgres-portals-prepared.mdExecutorStart/Run/End를 호출하고 스냅샷을 공급하는 포털 계층.