콘텐츠로 이동

(KO) PostgreSQL 플랜 생성 — 승자 경로에서 실행 가능한 플랜 트리로

목차

비용 기반 옵티마이저는 교과서적 표현으로 하나의 쿼리에 대한 동등한 평가 플랜들의 공간을 탐색하는 과정이다. Database System Concepts (Silberschatz, 7e, 16장 “Query Optimization”)는 이 작업을 논리적으로 동등한 표현식들을 열거하고, 각각에 물리적 연산자와 접근 방법을 부여하고, 비용을 추정한 뒤 “추정 비용이 가장 낮은 평가 플랜을 선택한다”(§16.1)로 설명한다. 교재는 초보자가 혼동하기 쉬운 두 가지 결과물을 명확히 구분한다.

  • 평가 플랜 표현식(evaluation-plan expression) — 옵티마이저가 추론 대상으로 삼는 주석이 달린 연산자 트리. PostgreSQL 내부에서는 이를 Path라 부른다. Path는 생성과 폐기 비용이 낮다. 연산자 유형, 입력, 생성하는 정렬 순서, 방출할 행 수, 생성 비용만 기록한다. 조인 순서 탐색 과정에서 수천 개의 Path가 만들어지고 버려진다.
  • 평가 플랜(evaluation plan) — 실행 엔진이 실제로 수행하는 구체적인 레시피. DSC §16.1은 선택된 표현식이 여전히 “각 연산의 평가 방법 주석”을 달아 실행 가능한 형태로 바꿔야 한다고 설명한다. PostgreSQL에서 이것이 Plan이다. 비용 경쟁에서 이긴 단 하나의 Path에 대해서만, 단 하나의 Plan이 만들어진다.

“가장 저렴한 표현식”에서 “실행 가능한 플랜”으로의 변환은 독립적인 단계이며, 이 문서의 주제다. DSC는 이 과정을 거의 지나치듯 다룬다. 교재의 관심은 탐색이지 낮추기(lowering)가 아니기 때문이다. 그러나 낮추기 단계에서 실제 엔진은 놀라울 만큼 많은 작업을 수행한다. postgres-executor.md의 반복자 엔진에 연산자 트리를 넘기기 전에 세 가지 문제를 해결해야 한다.

  1. 연산자 선택과 노드 실체화. 승자 Path는 “여기서 해시 조인, 저기서 순차 스캔”을 명시한다. 각 추상 연산자를 실행기가 필요로 하는 모든 것을 담은 구체적 노드로 만들어야 한다. 가장 저렴한 조건이 먼저 검사되도록 정렬된 한정 조건, 출력 컬럼 목록, EXPLAIN을 위해 고정된 비용 추정치가 여기에 포함된다.

  2. 변수 해결. DSC §15.3과 §16은 컬럼을 이름(r.a, s.b)으로 취급한다. 파서는 이를 (범위-테이블-인덱스, 속성-번호) 쌍으로 번호를 매긴다. 그러나 실행기는 연산자별로 범위 테이블을 보유하지 않는다. 조인에서 입력은 두 개의 익명 튜플 슬롯이고, 컬럼을 지칭하는 유일한 방법은 “외부 튜플의 k번째 필드” 또는 “내부 튜플의 k번째 필드”다. 모든 Var카탈로그 식별자에서 슬롯 위치로 바꾸는 작업이 두 번째 독립적인 트리 순회다.

  3. 중첩 서브쿼리. WHERE 절의 스칼라 서브 SELECT는 완전한 두 번째 쿼리 트리다. DSC §16.4.4(“Optimizing Nested Subqueries”)는 외부 행을 참조하는 상관(correlated) 서브쿼리는 논리적으로 외부 튜플마다 한 번 평가되는 함수이고, 비상관(uncorrelated) 서브쿼리는 “한 번” 평가해 결과를 재사용할 수 있다고 설명한다. 이 이분법이 PostgreSQL의 SubPlan(튜플별)과 initPlan(쿼리당 한 번)의 구분으로 직접 대응된다.

나머지 절은 REL_18 소스를 기반으로 PostgreSQL의 createplan.c, setrefs.c, subselect.c가 이 세 가지 문제를 어떻게 해결하는지 추적한다. 상위 단계인 조인 열거와 Path 비용 계산은 postgres-planner-overview.mdpostgres-path-generation.md에서 다루며, 완성된 Plan의 소비자는 postgres-executor.md다.

교과서는 무엇을(선택된 표현식을 실행 가능한 것으로 낮추기) 알려준다. 이 절은 거의 모든 프로덕션 옵티마이저가 낮추기 단계에서 채택하는 공학적 관례를 정리한다. 그래야 PostgreSQL의 구체적 선택이 공유된 설계 공간의 한 지점으로 읽힌다.

두 객체 모델: 탐색용 일회성 노드와 생존자용 영속 노드

섹션 제목: “두 객체 모델: 탐색용 일회성 노드와 생존자용 영속 노드”

진지한 탐색을 수행하는 옵티마이저는 탐색 공간 노드를 의도적으로 얇게 유지하고, 생존자를 위한 별도의 더 무거운 노드를 사용한다. 탐색 노드(Path, Cascades의 Group expression, 일부 학술 시스템의 AbstractRelNode)는 비교에 필요한 필드만 담아 수백만 개를 저렴하게 할당할 수 있도록 한다. 생존자는 한 번만 확장되어 실행 세부 사항(해결된 표현식, 직렬화 힌트, 병렬 워커 수)을 담는다. 둘을 혼합하면 탐색이 99.99%의 경우 버릴 실행 메타데이터를 할당해야 한다. PostgreSQL의 Path vs Plan 분리가 교과서적 사례다.

하향 구조 재귀와 “어떤 tlist가 필요한가?” 계약

섹션 제목: “하향 구조 재귀와 “어떤 tlist가 필요한가?” 계약”

낮추기는 루트에서 아래로 선택된 트리를 순회한다. 거의 모든 엔진이 부딪히는 미묘한 문제가 있다. 상위 연산자가 때로는 정확히 특정 컬럼 집합을 특정 순서로 방출하도록 자식에게 요구하고(정렬은 정렬 키가 존재해야 하고, 집합 연산은 컬럼 수가 일치해야 한다), 때로는 신경 쓰지 않는다(중첩 루프 조인은 출력을 다시 투영하므로 자식은 편한 것을 방출해도 된다). 엔진은 재귀에 작은 “tlist 요건” 플래그를 실어 내려보내 자식이 허용 범위 내에서 가장 저렴한 타깃 리스트를 선택하게 한다. 허용될 때 더 넓은 물리 튜플을 방출하면 스캔이 투영 단계를 건너뛸 수 있다.

한정 조건: 정렬·제거, 일회성과 튜플별 분리

섹션 제목: “한정 조건: 정렬·제거, 일회성과 튜플별 분리”

노드의 한정 조건 절은 실행 시가 아니라 낮추기 시에 재구성된다. 두 가지 변환은 보편적이다. (a) AND가 단락 평가되므로 가장 저렴하고 선택도가 높은 것이 먼저 실행되도록 절을 정렬하고, (b) 평가기가 원하는 순수 불리언 표현식을 남기기 위해 각 절에서 옵티마이저의 bookkeeping 래퍼(PostgreSQL의 RestrictInfo)를 제거한다. 세 번째로 더 특화된 분리는 현재 행에 의존하지 않는 조건(WHERE 1=0, WHERE $1 IS NULL)인 유사 상수(pseudoconstant) 절을 골라내어 튜플마다 재검사하는 대신 한 번만 게이트로 평가하게 한다.

이것이 가장 깊은 관례이자 사용자에게 가장 보이지 않는 부분이다. 실행기 내부에서 연산자는 입력을 불투명한 튜플 슬롯으로 받는다. 컬럼은 “테이블 T, 컬럼 c”가 아니라 “슬롯, 오프셋 k”로 지칭해야 한다. 낮추기 단계는 전역 재작성을 수행한다. 스캔 리프에서 Var는 식별자를 유지하되 범위 테이블 인덱스가 단일 평탄화 네임스페이스로 이동하고, 모든 내부 노드에서 기본 컬럼을 가리키던 Var자식 출력 튜플에서 해당 컬럼의 위치를 가리키도록 재작성된다. 이 패스가 끝나면 어떤 연산자도 컬럼을 찾기 위해 카탈로그를 참조할 필요가 없다. 배열을 역참조하면 된다. 대가는 두 번째 전체 트리 순회와, “이 Var는 어떤 출력 위치인가?” 조회를 빠르게 만들기 위한 노드별 인덱스 구조다.

서브쿼리: 쿼리당 한 번 vs 행별 한 번

섹션 제목: “서브쿼리: 쿼리당 한 번 vs 행별 한 번”

모든 엔진은 중첩된 서브 SELECT마다 한 번만 실행(결과를 파라미터 슬롯에 캐시)할 수 있는지, 아니면 해당 행에 의존하기 때문에 외부 행마다 재평가해야 하는지를 결정해야 한다. 현재 레벨과의 상관관계가 없을 때는 비용이 더 낮은 전자를 선택한다. 행별 경우는 상관 값을 부모가 각 호출 전에 채우는 파라미터 슬롯에서 읽는 호출 가능한 서브 플랜으로 연결된다.

탐색 과정에서 계산된 비용과 행 수 추정치는 플랜 노드에 복사되어 고정된다. 더 이상 결정을 내리는 데 사용되지 않는다. 결정은 이미 끝났다. EXPLAIN에서 이를 노출하고, 자체 Path 없이 삽입되는 SortMaterial 노드 같은 일부 후기 재작성은 EXPLAIN의 일관성을 유지하기 위해 로컬 추정치를 재계산한다.

관례PostgreSQL 구현
탐색용 일회성 노드pathnodes.hPath (및 하위 타입)
영속 실행 노드plannodes.hPlan (및 하위 타입)
하향 낮추기create_plancreate_plan_recurse
Tlist 요건 플래그CP_EXACT_TLIST / CP_SMALL_TLIST / CP_LABEL_TLIST / CP_IGNORE_TLIST
넓은 물리 tlistuse_physical_tlistbuild_physical_tlist
한정 조건 정렬·제거order_qual_clauses + extract_actual_clauses
일회성 한정 조건 게이트get_gating_qualscreate_gating_plan (Result 노드)
위치 기반 변수 바인딩set_plan_referencesfix_scan_expr / fix_join_expr / fix_upper_expr
행별 서브쿼리SubPlan (build_subplan, isInitPlan = false)
쿼리당 한 번 서브쿼리initPlan (isInitPlan = true, SS_attach_initplans)
고정된 추정치copy_generic_path_info

PostgreSQL은 플랜 생성을 트리에 대한 두 개의 명확히 분리된 패스로 나누고, 서브쿼리 연결은 두 패스 전반에 걸쳐 처리한다.

패스 1 — createplan.c: Path 트리 → Plan 트리 (여전히 파서 Var 번호 체계). create_plan이 진입점이다. 조인 탐색이 선택한 단 하나의 best_path를 받아 아래로 재귀하며 Path 노드 하나당 Plan 노드 하나를 실체화한다. 이 패스가 변수 번호 체계를 그대로 두는 이유는 파일 헤더 주석이 명시적으로 설명한다.

// createplan.c file header — src/backend/optimizer/plan/createplan.c
* The tlists and quals in the plan tree are still in planner format,
* ie, Vars still correspond to the parser's numbering. This will be
* fixed later by setrefs.c.

재귀는 순수하게 best_path->pathtype에 따라 분기하며, 이 switch 문이 모듈 전체의 목차 역할을 한다.

// create_plan_recurse — src/backend/optimizer/plan/createplan.c
switch (best_path->pathtype)
{
case T_SeqScan:
case T_IndexScan:
/* ... 모든 스캔 타입 ... */
case T_CustomScan:
plan = create_scan_plan(root, best_path, flags);
break;
case T_HashJoin:
case T_MergeJoin:
case T_NestLoop:
plan = create_join_plan(root, (JoinPath *) best_path);
break;
/* ... Append, Sort, Agg, Material, Gather, ModifyTable, Limit ... */
}

flags 인수는 공통 설계 절에서 설명한 “tlist 요건” 계약이다. 값들은 파일 상단에 정의되어 있다.

// flag definitions — src/backend/optimizer/plan/createplan.c
#define CP_EXACT_TLIST 0x0001 /* Plan must return specified tlist */
#define CP_SMALL_TLIST 0x0002 /* Prefer narrower tlists */
#define CP_LABEL_TLIST 0x0004 /* tlist must contain sortgrouprefs */
#define CP_IGNORE_TLIST 0x0008 /* caller will replace tlist */

create_plan은 최상위 플랜이 정확히 쿼리 출력 컬럼을 방출해야 하므로 CP_EXACT_TLIST로 재귀를 시작한다. 내부 노드는 자신이 투영할 수 있을 때 자식에 이 제약을 완화해 넘긴다.

패스 2 — setrefs.c: 모든 Var를 슬롯 위치로 해결. create_plan이 반환된 후, 호출자(standard_plannerset_plan_references)는 완성된 Plan 트리를 두 번째로 순회한다. 이 패스는 모든 서브쿼리별 범위 테이블을 하나의 전역 finalrtable로 평탄화하고, 모든 스캔 노드의 Var를 rtoffset만큼 이동시키며, 각 조인·상위 노드의 Var를 기본 컬럼이 아닌 자식 튜플의 출력 위치를 가리키도록 재작성한다.

서브쿼리 연결 — subselect.c. 서브 SELECT는 표현식 전처리와 build_subplan 단계에서 이미 SubPlan/initPlan 구조로 변환되어 있다. 플랜 생성 단계에서 트리에 연결된다. SS_attach_initplans는 해당 레벨의 initPlan들을 최상위 노드에 건다. setrefs.c는 서브 플랜 출력과 사용처를 연결하는 PARAM_EXEC 참조를 해결한다.

create_scan_plan은 모든 리프 릴레이션의 작업자다. 순서대로 네 가지를 수행한다. 제한 조건 절을 선택하고, 타깃 리스트를 결정하고, 타입별 노드를 구성하고, 필요하면 게이팅 Result로 감싼다. 타깃 리스트 결정이 핵심이다. 부모가 정확한 tlist를 요구하지 않을 때, 스캔은 실행기가 투영 단계를 건너뛸 수 있도록 물리 tlist(저장 순서 기준 모든 컬럼)를 선택한다.

// create_scan_plan — src/backend/optimizer/plan/createplan.c
if (flags == CP_IGNORE_TLIST)
{
tlist = NULL;
}
else if (use_physical_tlist(root, best_path, flags))
{
/* ... 인덱스 온리 스캔은 인덱스 tlist 사용; 그 외: */
tlist = build_physical_tlist(root, rel);
if (tlist == NIL) /* 드롭된 컬럼 -> 폴백 */
tlist = build_path_tlist(root, best_path);
}
else
{
tlist = build_path_tlist(root, best_path);
}

일반 타깃 리스트는 build_path_tlist가 구성한다. Path의 pathtarget에 있는 각 표현식을 순번 resnoTargetEntry에 감싼다. 매개변수화된 경로에서는 lateral 참조를 nestloop Param으로 교체한다.

// build_path_tlist — src/backend/optimizer/plan/createplan.c
foreach(v, path->pathtarget->exprs)
{
Node *node = (Node *) lfirst(v);
TargetEntry *tle;
if (path->param_info)
node = replace_nestloop_params(root, node);
tle = makeTargetEntry((Expr *) node, resno, NULL, false);
if (sortgrouprefs)
tle->ressortgroupref = sortgrouprefs[resno - 1];
tlist = lappend(tlist, tle);
resno++;
}

use_physical_tlist는 게이트 함수다. 부모가 정확하거나 좁은 tlist를 요구할 때, 릴레이션이 단순한 기본/서브쿼리/함수/값/CTE 릴레이션이 아닐 때, 시스템 컬럼이나 전체 행 Var가 필요할 때 넓은 튜플 단축키를 거부한다.

// use_physical_tlist — src/backend/optimizer/plan/createplan.c
if (flags & (CP_EXACT_TLIST | CP_SMALL_TLIST))
return false;
if (rel->rtekind != RTE_RELATION &&
rel->rtekind != RTE_SUBQUERY &&
/* ... RTE_FUNCTION, RTE_TABLEFUNC, RTE_VALUES, RTE_CTE ... */ )
return false;
/* Can't do it if any system columns or whole-row Vars are requested. */
for (i = rel->min_attr; i <= 0; i++)
if (!bms_is_empty(rel->attr_needed[i - rel->min_attr]))
return false;

tlist가 결정되면 타입별 생성 루틴이 실행된다. 순차 스캔 경우가 전체 노드 구성 관용구를 가장 깔끔하게 보여준다. 한정 조건 정렬, 제거, 외부 Var 교체, 할당, 추정치 복사 순서로 진행된다.

// create_seqscan_plan — src/backend/optimizer/plan/createplan.c
/* Sort clauses into best execution order */
scan_clauses = order_qual_clauses(root, scan_clauses);
/* Reduce RestrictInfo list to bare expressions; ignore pseudoconstants */
scan_clauses = extract_actual_clauses(scan_clauses, false);
/* Replace any outer-relation variables with nestloop params */
if (best_path->param_info)
scan_clauses = (List *)
replace_nestloop_params(root, (Node *) scan_clauses);
scan_plan = make_seqscan(tlist, scan_clauses, scan_relid);
copy_generic_path_info(&scan_plan->scan.plan, best_path);

make_seqscan은 순수 할당기다. 로직 없이 필드 대입만 수행해 정책(위)과 노드 형태를 분리한다.

// make_seqscan — src/backend/optimizer/plan/createplan.c
SeqScan *node = makeNode(SeqScan);
Plan *plan = &node->scan.plan;
plan->targetlist = qptlist;
plan->qual = qpqual;
plan->lefttree = NULL;
plan->righttree = NULL;
node->scan.scanrelid = scanrelid;

copy_generic_path_info는 탐색 단계의 추정치를 영속 노드에 고정하는 곳이다.

// copy_generic_path_info — src/backend/optimizer/plan/createplan.c
dest->disabled_nodes = src->disabled_nodes;
dest->startup_cost = src->startup_cost;
dest->total_cost = src->total_cost;
dest->plan_rows = src->rows;
dest->plan_width = src->pathtarget->width;
dest->parallel_aware = src->parallel_aware;
dest->parallel_safe = src->parallel_safe;

상관된 중첩 루프를 포함하는 두 테이블 조인에 대한 패스 1 전체 제어 흐름은 다음과 같다.

flowchart TD
    A["create_plan(root, best_path)<br/>seed flags = CP_EXACT_TLIST"] --> B["create_plan_recurse<br/>dispatch on pathtype"]
    B -->|T_NestLoop| C["create_join_plan<br/>-> create_nestloop_plan"]
    C --> D["recurse: outer child<br/>flags = 0 (NL can project)"]
    C --> E["push outer relids into<br/>root->curOuterRels"]
    E --> F["recurse: inner child<br/>flags = 0"]
    D -->|T_SeqScan| G["create_scan_plan<br/>physical tlist allowed?"]
    F -->|T_IndexScan| H["create_scan_plan<br/>fix indexquals, replace outer Vars"]
    G --> I["make_seqscan +<br/>copy_generic_path_info"]
    H --> J["make_indexscan +<br/>copy_generic_path_info"]
    C --> K["identify_current_nestloop_params<br/>build nestParams list"]
    K --> L["make_nestloop(tlist, joinclauses,<br/>nestParams, outer, inner)"]
    B --> M["apply_tlist_labeling on top node"]
    M --> N["SS_attach_initplans(root, plan)"]
    N --> O["Plan tree in PARSER Var numbering"]

create_join_plan은 세 가지 조인 방법을 분기하고, 스캔 경로와 동일하게 유사 상수 게이팅 한정 조건을 확인한다.

// create_join_plan — src/backend/optimizer/plan/createplan.c
switch (best_path->path.pathtype)
{
case T_MergeJoin: plan = (Plan *) create_mergejoin_plan(root, (MergePath *) best_path); break;
case T_HashJoin: plan = (Plan *) create_hashjoin_plan(root, (HashPath *) best_path); break;
case T_NestLoop: plan = (Plan *) create_nestloop_plan(root, (NestPath *) best_path); break;
}
gating_clauses = get_gating_quals(root, best_path->joinrestrictinfo);
if (gating_clauses)
plan = create_gating_plan(root, (Path *) best_path, plan, gating_clauses);

create_nestloop_plan은 lateral 상관이 실제 기계 장치가 되는 곳이다. 중첩 루프 조인은 외부 행마다 내부 측을 다시 스캔할 수 있어, 내부 서브트리가 외부 컬럼을 참조할 수 있다. 탐색 중 이 참조는 Var로 표현되고, 여기서 NestLoopParam으로 변환된다. 이 루틴은 내부 자식에 재귀하기 전에 외부 relid를 root->curOuterRels에 넣어, 내부 스캔의 절 처리 깊숙이 호출되는 replace_nestloop_params가 어떤 Var가 외부 측 것인지 알 수 있게 한다.

// create_nestloop_plan — src/backend/optimizer/plan/createplan.c
/* NestLoop can project, so no need to be picky about child tlists */
outer_plan = create_plan_recurse(root, best_path->jpath.outerjoinpath, 0);
/* For a nestloop, include outer relids in curOuterRels for inner side */
outerrelids = best_path->jpath.outerjoinpath->parent->relids;
root->curOuterRels = bms_union(root->curOuterRels, outerrelids);
inner_plan = create_plan_recurse(root, best_path->jpath.innerjoinpath, 0);
/* Restore curOuterRels */
bms_free(root->curOuterRels);
root->curOuterRels = saveOuterRels;

양쪽 자식이 만들어지면 조인 절을 정렬하고 제거한다. 아우터 조인 분리로 nulled-side 한정 조건은 otherclauses가 된다. 살아남은 nestloop 파라미터는 식별되어 root->curOuterParams에서 제거된다.

// create_nestloop_plan — src/backend/optimizer/plan/createplan.c
if (best_path->jpath.path.param_info)
{
joinclauses = (List *) replace_nestloop_params(root, (Node *) joinclauses);
otherclauses = (List *) replace_nestloop_params(root, (Node *) otherclauses);
}
nestParams = identify_current_nestloop_params(root, outerrelids,
PATH_REQ_OUTER((Path *) best_path));
join_plan = make_nestloop(tlist, joinclauses, otherclauses, nestParams,
outer_plan, inner_plan,
best_path->jpath.jointype, best_path->jpath.inner_unique);

replace_nestloop_params 뮤테이터 자체의 규칙은 다음과 같다. Varvarno가 현재 외부 relid의 멤버일 때만 PARAM_EXEC 파라미터로 변환되고, 그렇지 않으면 그대로 통과한다. 위에서 curOuterRels 관리가 핵심인 이유가 이 때문이다.

// replace_nestloop_params_mutator — src/backend/optimizer/plan/createplan.c
if (IsA(node, Var))
{
Var *var = (Var *) node;
/* If not to be replaced, we can just return the Var unmodified */
if (IS_SPECIAL_VARNO(var->varno) ||
!bms_is_member(var->varno, root->curOuterRels))
return node;
/* Replace the Var with a nestloop Param */
return (Node *) replace_nestloop_param_var(root, var);
}

투영. create_projection_plan은 tlist 요건 계약이 결실을 맺는 곳이다. 부모가 정확한 tlist를 요구하지 않거나 자식이 자체 투영 능력이 있으면, 별도의 Result 노드를 만들지 않는다. 자식의 targetlist만 재할당해 투영을 접어 넣는다. Result 노드는 투영 불가능한 자식의 출력을 재구성해야 할 때만 실체화된다.

// create_projection_plan — src/backend/optimizer/plan/createplan.c
if (!needs_result_node)
{
/* Don't need a separate Result, just assign tlist to subplan */
plan = subplan;
plan->targetlist = tlist;
/* Label plan with the estimated costs we actually used */
plan->startup_cost = best_path->path.startup_cost;
/* ... */
}
else
{
/* We need a Result node */
plan = (Plan *) make_result(tlist, NULL, subplan);
copy_generic_path_info(plan, (Path *) best_path);
}

패스 1: 서브쿼리를 새 플래너 컨텍스트에 넘기기

섹션 제목: “패스 1: 서브쿼리를 새 플래너 컨텍스트에 넘기기”

스캔이 서브 SELECT(RTE_SUBQUERY) 위에 있을 때, create_subqueryscan_plancreate_plan_recurse를 호출하지 않는다. 서브쿼리가 별도의 범위 테이블 네임스페이스를 가지기 때문에 서브쿼리 자체의 PlannerInfo(rel->subroot)로 create_plan을 새로 호출해 독립적으로 낮춘 뒤 이어 붙인다.

// create_subqueryscan_plan — src/backend/optimizer/plan/createplan.c
/*
* Recursively create Plan from Path for subquery. Since we are entering
* a different planner context (subroot), recurse to create_plan not
* create_plan_recurse.
*/
subplan = create_plan(rel->subroot, best_path->subpath);
/* ... */
if (best_path->path.param_info)
{
process_subquery_nestloop_params(root, rel->subplan_params);
scan_clauses = (List *)
replace_nestloop_params(root, (Node *) scan_clauses);
}
scan_plan = make_subqueryscan(tlist, scan_clauses, scan_relid, subplan);

표현식 내부의 스칼라 서브 SELECT(FROM이 아닌 위치)는 완전히 다른 메커니즘으로 subselect.c에서 처리된다. build_subplan이 각각을 분류한다. 핵심 결정은 initPlan 대 SubPlan이다. 서브 SELECT에 현재 레벨이 공급하는 파라미터가 없고(parParam == NIL) 호이스팅 가능한 타입(EXISTS, EXPR, ARRAY, ROWCOMPARE, MULTIEXPR)이면 initPlan이 된다. 한 번 실행되어 PARAM_EXEC 슬롯에 결과를 저장하고, 부모 표현식에는 이를 대신하는 순수 Param이 반환된다.

// build_subplan — src/backend/optimizer/plan/subselect.c
if (splan->parParam == NIL && subLinkType == EXPR_SUBLINK)
{
TargetEntry *te = linitial(plan->targetlist);
Param *prm;
prm = generate_new_exec_param(root,
exprType((Node *) te->expr),
exprTypmod((Node *) te->expr),
exprCollation((Node *) te->expr));
splan->setParam = list_make1_int(prm->paramid);
isInitPlan = true;
result = (Node *) prm; /* parent sees a Param, not the SubPlan */
}

그렇지 않은 경우, 즉 상관 서브쿼리이거나 외부 행마다 출력을 스캔해야 하는 ANY/ALL 테스트라면 SubPlan으로 남아 실행기가 행마다 호출한다. 선택적으로 해시 테이블화하거나 실체화할 수 있다.

// build_subplan — src/backend/optimizer/plan/subselect.c
if (subLinkType == ANY_SUBLINK &&
splan->parParam == NIL &&
subplan_is_hashable(plan) &&
testexpr_is_hashable(splan->testexpr, splan->paramIds))
splan->useHashTable = true;
else if (splan->parParam == NIL && enable_material &&
!ExecMaterializesOutput(nodeTag(plan)))
plan = materialize_finished_plan(plan);
result = (Node *) splan;
isInitPlan = false;

어느 경우든 완성된 서브 플랜은 전역 glob->subplans 목록(plan_id로 인덱싱)에 등록된다. initPlan은 추가로 root->init_plans에 들어가 SS_attach_initplans가 나중에 최상위 노드에 걸 수 있게 한다.

// build_subplan (tail) — src/backend/optimizer/plan/subselect.c
root->glob->subplans = lappend(root->glob->subplans, plan);
splan->plan_id = list_length(root->glob->subplans);
if (isInitPlan)
root->init_plans = lappend(root->init_plans, splan);
splan->plan_name = psprintf("%s %d",
isInitPlan ? "InitPlan" : "SubPlan",
splan->plan_id);

두 번째 패스인 set_plan_references가 개념적 클라이맥스다. 파일 헤더는 아홉 가지 역할을 열거한다. 여기서 중요한 두 가지는 범위 테이블 평탄화와 Var 해결이다. 먼저 전역 범위 테이블의 현재 크기를 rtoffset으로 기록하고, 이 쿼리 레벨의 RTE들을 추가한다. 따라서 이 레벨의 모든 스캔 노드는 평탄화된 테이블을 가리키기 위해 varnortoffset만큼 올려야 한다.

// set_plan_references — src/backend/optimizer/plan/setrefs.c
int rtoffset = list_length(glob->finalrtable);
/* Add all the query's RTEs to the flattened rangetable. */
add_rtes_to_flat_rtable(root, false);
/* ... rowmarks, appendrels, alt-subplans 조정 ... */
/* Now fix the Plan tree */
result = set_plan_refs(root, plan, rtoffset);

set_plan_refs는 Plan 트리를 재귀하며 각 노드에 고유한 plan_node_id를 부여하고 노드 타입에 따라 분기한다. 스캔 리프의 경우 수정은 순수하게 덧셈이다. scanrelid와 모든 Var의 varnortoffset만큼 이동시킨다. 스캔 Var는 여전히 기본 컬럼을 지칭하기 때문이다. 달라지는 것은 테이블 인덱스가 전역 네임스페이스로 이동하는 것뿐이다.

// set_plan_refs (T_SeqScan case) — src/backend/optimizer/plan/setrefs.c
SeqScan *splan = (SeqScan *) plan;
splan->scan.scanrelid += rtoffset;
splan->scan.plan.targetlist =
fix_scan_list(root, splan->scan.plan.targetlist,
rtoffset, NUM_EXEC_TLIST(plan));
splan->scan.plan.qual =
fix_scan_list(root, splan->scan.plan.qual,
rtoffset, NUM_EXEC_QUAL(plan));

fix_scan_expr는 뮤테이터로 Var 이동을 수행한다. 스캔은 INNER_VAR/OUTER_VAR를 절대 보지 않는다는 것을 단언한다. 이 특수 varno들은 조인 위에서만 나타나고 리프에서는 나타나지 않는다.

// fix_scan_expr_mutator — src/backend/optimizer/plan/setrefs.c
if (IsA(node, Var))
{
Var *var = copyVar((Var *) node);
Assert(var->varno != INNER_VAR);
Assert(var->varno != OUTER_VAR);
Assert(var->varno != ROWID_VAR);
if (!IS_SPECIAL_VARNO(var->varno))
var->varno += context->rtoffset;
if (var->varnosyn > 0)
var->varnosyn += context->rtoffset;
return (Node *) var;
}

조인에서 재작성은 근본적으로 다르다. 조인의 출력 Var는 더 이상 기본 컬럼을 지칭할 수 없다. 내부 또는 외부 자식 출력 튜플의 위치를 지칭해야 한다. set_join_references는 각 자식의 타깃 리스트에 대한 indexed_tlist를 구성하고, 그 인덱스를 기준으로 조인 자체의 절과 tlist를 재작성한다. 특수 varno INNER_VAR (-1)OUTER_VAR (-2)는 실행기에서 “이 컬럼은 inner/outer 슬롯에서 온다”는 표현이다.

// INNER_VAR / OUTER_VAR / INDEX_VAR — src/include/nodes/primnodes.h
#define INNER_VAR (-1) /* reference to inner subplan */
#define OUTER_VAR (-2) /* reference to outer subplan */
#define INDEX_VAR (-3) /* reference to index column */
// set_join_references — src/backend/optimizer/plan/setrefs.c
outer_itlist = build_tlist_index(outer_plan->targetlist);
inner_itlist = build_tlist_index(inner_plan->targetlist);
join->joinqual = fix_join_expr(root, join->joinqual,
outer_itlist, inner_itlist,
(Index) 0, rtoffset, NRM_EQUAL, ...);
/* ... 조인 타입별: mergeclauses / hashclauses / nestParams ... */
join->plan.targetlist = fix_join_expr(root, join->plan.targetlist,
outer_itlist, inner_itlist,
(Index) 0, rtoffset,
(join->jointype == JOIN_INNER ?
NRM_EQUAL : NRM_SUPERSET), ...);

build_tlist_index는 조회 가속기다. 자식의 tlist를 (varno, varattno) -> resno 트리플의 배열로 평탄화해서, 부모 Var를 자식 출력 위치에 매핑하는 작업이 TargetEntry 노드를 순회하는 대신 컴팩트한 배열의 선형 스캔이 되게 한다.

// build_tlist_index — src/backend/optimizer/plan/setrefs.c
foreach(l, tlist)
{
TargetEntry *tle = (TargetEntry *) lfirst(l);
if (tle->expr && IsA(tle->expr, Var))
{
Var *var = (Var *) tle->expr;
vinfo->varno = var->varno;
vinfo->varattno = var->varattno;
vinfo->resno = tle->resno;
vinfo->varnullingrels = var->varnullingrels;
vinfo++;
}
else if (tle->expr && IsA(tle->expr, PlaceHolderVar))
itlist->has_ph_vars = true;
else
itlist->has_non_vars = true;
}

fix_join_expr_mutator는 Var별 재작성기다. 외부 인덱스를 먼저, 그다음 내부 인덱스를 검색한다. 매칭되면 varnoOUTER_VAR/INNER_VAR로, varattno를 자식 출력 resno(카탈로그 속성이 아닌 컬럼 위치)로 설정한 Var 복사본을 반환한다.

// fix_join_expr_mutator — src/backend/optimizer/plan/setrefs.c
if (context->outer_itlist)
{
newvar = search_indexed_tlist_for_var(var, context->outer_itlist,
OUTER_VAR, context->rtoffset,
context->nrm_match);
if (newvar) return (Node *) newvar;
}
if (context->inner_itlist)
{
newvar = search_indexed_tlist_for_var(var, context->inner_itlist,
INNER_VAR, context->rtoffset,
context->nrm_match);
if (newvar) return (Node *) newvar;
}
/* ... PlaceHolderVar / non-Var / acceptable_rel 폴백 ... */
elog(ERROR, "variable not found in subplan target lists");

search_indexed_tlist_for_var에서 치환이 구체화된다. 매칭된 Var의 varno는 특수 varno가 되고, varattno는 자식 출력 슬롯의 resno가 된다.

// search_indexed_tlist_for_var — src/backend/optimizer/plan/setrefs.c
if (vinfo->varno == varno && vinfo->varattno == varattno)
{
Var *newvar = copyVar(var);
/* ... varnullingrels 교차 검사 ... */
newvar->varno = newvarno; /* OUTER_VAR or INNER_VAR */
newvar->varattno = vinfo->resno; /* position in child tuple */
if (newvar->varnosyn > 0)
newvar->varnosyn += rtoffset;
return newvar;
}

단일 입력 상위 노드(Agg, Group, Result)에 대해서는 유사 루틴 set_upper_references가 단 하나의 자식에 대한 인덱스를 구성하고 Var를 OUTER_VAR로 재작성한다. 동일한 조인에 대한 패스 2 데이터 흐름은 다음과 같다.

flowchart TD
    A["set_plan_references<br/>rtoffset = len(finalrtable)"] --> B["add_rtes_to_flat_rtable<br/>append this level's RTEs"]
    B --> C["set_plan_refs<br/>recurse, assign plan_node_id"]
    C -->|leaf scan| D["scanrelid += rtoffset"]
    D --> E["fix_scan_expr<br/>Var.varno += rtoffset<br/>(still a base-column Var)"]
    C -->|join node| F["build_tlist_index<br/>over outer + inner tlists"]
    F --> G["fix_join_expr on<br/>joinqual, hashclauses, tlist"]
    G --> H["search_indexed_tlist_for_var"]
    H --> I["match outer?<br/>varno = OUTER_VAR<br/>varattno = child resno"]
    H --> J["match inner?<br/>varno = INNER_VAR<br/>varattno = child resno"]
    C -->|upper node| K["set_upper_references<br/>build_tlist_index over lefttree"]
    K --> L["fix_upper_expr -> OUTER_VAR"]
    I --> M["Plan tree: every Var is a<br/>(special varno, slot offset)"]
    J --> M
    L --> M
    E --> M

플랜 생성 기계는 src/backend/optimizer/plan/ 하위 세 파일에 있다. 아래 심볼들은 속하는 단계별로 묶었다. 각각은 안정적인 앵커다. 줄 번호는 끝의 표에서 힌트로 제공된다.

  • create_plan — 모듈 진입점. CP_EXACT_TLIST로 재귀를 시작하고, create_plan_recurse를 호출한 뒤 루트 노드에 세 가지 마무리 작업을 수행한다. apply_tlist_labeling(결과 디스크립터를 위한 원래 컬럼 이름 복원), SS_attach_initplans(이 레벨의 initPlan 연결), 모든 NestLoopParam이 할당되었다는 단언.
  • create_plan_recursebest_path->pathtype에 따른 분기 switch. check_stack_depth()로 깊은 트리를 방어한다. Path 하나당 Plan * 하나를 반환한다.
  • create_scan_plan — 리프 릴레이션 작업자. scan_clauses를 선택하고(인덱스 경로에는 indrestrictinfo 사용), 매개변수화된 스캔의 경우 param_info->ppi_clauses를 포함시키며, use_physical_tlist로 tlist를 결정하고, 타입별 생성기로 분기하며, get_gating_quals가 유사 상수를 반환하면 게이팅 Result로 감싼다.
  • create_join_plancreate_mergejoin_plan, create_hashjoin_plan, create_nestloop_plan으로 분기한다. 유사 상수 joinrestrictinfo가 있으면 게이팅 Result를 추가한다.
  • build_path_tlistpathtarget->exprs 요소 각각을 TargetEntry로 감싼다. 매개변수화된 경로에는 replace_nestloop_params를 적용하고, sortgrouprefs를 복사한다.
  • use_physical_tlist — 실행기가 투영을 건너뛸 수 있도록 저장 순서(전체 컬럼) tlist를 방출하는 게이트. CP_EXACT_TLIST/CP_SMALL_TLIST 설정 시, 단순하지 않은 rtekind 시, 상속/Custom 경로 시, 시스템 컬럼이나 전체 행 Var가 필요할 때 거부한다.
  • create_seqscan_plan / make_seqscan — 정규 노드 구성 관용구(정렬 → 제거 → 외부 Var 교체 → 할당 → 추정치 복사)와 순수 할당기.
  • create_indexscan_plan — 추가로 fix_indexqual_references를 호출해 인덱스 키 Var를 INDEX_VAR 속성 번호로 재작성하고 RestrictInfo 래퍼를 제거한다.
  • create_nestloop_plan — 내부 자식 재귀 주변에서 root->curOuterRels를 관리하고, identify_current_nestloop_params를 호출하며, make_nestloop으로 노드를 구성한다.
  • create_projection_plan / make_result / inject_projection_plan — 가능하면 투영을 자식에 접어 넣고, 불가능하면 Result를 실체화한다.
  • create_subqueryscan_planrel->subroot(별도 플래너 컨텍스트)에서 create_plan으로 재진입하고, lateral 참조가 있으면 process_subquery_nestloop_params를 호출한다.
  • replace_nestloop_params / replace_nestloop_params_mutatorroot->curOuterRels 멤버십을 기준으로 외부 릴레이션 VarPlaceHolderVarPARAM_EXEC 파라미터로 변환한다.
  • copy_generic_path_infoPath에서 Plan으로 비용/행 수/폭/병렬 플래그를 고정한다. **copy_plan_costsize**는 자체 Path 없이 삽입된 노드(예: Sort, Material)에 동일한 작업을 수행한다. **change_plan_targetlist**는 nestloop이 외부 출력에 PHV를 게시해야 할 때 자식의 tlist를 교체한다.

패스 2 — setrefs.c: 위치 기반 변수 바인딩

섹션 제목: “패스 2 — setrefs.c: 위치 기반 변수 바인딩”
  • set_plan_references — 패스 진입점. rtoffset = list_length(glob->finalrtable)을 계산하고, RTE를 평탄화(add_rtes_to_flat_rtable)하며, rowmarks/appendrels를 조정하고, set_plan_refs를 호출한다.
  • set_plan_refs — 재귀적 노드별 분기. plan_node_id를 부여하고, 스캔의 경우 scanrelid와 Var를 rtoffset만큼 이동시키며, 조인은 set_join_references에, 단일 입력 상위 노드는 set_upper_references에 위임한다. 불필요한 SubqueryScan/Append/MergeAppend 노드도 삭제한다(trivial_subqueryscan).
  • fix_scan_expr / fix_scan_expr_mutator / fix_scan_expr_walker — 스캔 레벨 Var 이동. rtoffset == 0이고 재작성할 파라미터/PHV가 없을 때는 복사 없는 walker 변형을 사용해 단순 쿼리의 사이클을 아낀다. PARAM_MULTIEXPRPARAM_EXEC 치환을 위해 fix_param_node를 호출한다.
  • set_join_references — 양쪽 자식에 대한 indexed_tlist를 구성하고, joinqual, 방법별 절(mergeclauses, hashclauses, hashkeys), nestParams, targetlist/qualfix_join_expr / fix_upper_expr로 재작성한다.
  • set_upper_references — 단일 자식 유사 루틴(Agg, Group, Result). Var를 OUTER_VAR로 재작성한다.
  • set_indexonlyscan_references — 인덱스 온리 스캔을 특수 처리해 Var가 INDEX_VAR 컬럼을 참조하게 한다.
  • build_tlist_index / build_tlist_index_other_vars — 자식 tlist를 O(n) Var 매칭을 위한 tlist_vinfo 배열로 평탄화하고, has_ph_vars / has_non_vars 플래그를 설정한다.
  • search_indexed_tlist_for_var — 핵심 치환: (varno, varattno)(newvarno, resno). NullingRelsMatch로 제어되는 varnullingrels 교차 검사. 동반 함수 **search_indexed_tlist_for_phv**와 **search_indexed_tlist_for_non_var**는 PHV와 자식 tlist에 push된 전체 표현식을 처리한다.
  • fix_join_expr / fix_join_expr_mutator — 외부 우선 내부 Var 검색. 미스 시 "variable not found in subplan target lists" 오류. fix_upper_expr / fix_upper_expr_mutator — 단일 입력 변형.
  • set_dummy_tlist_references — 출력이 입력과 정확히 동일한 노드(Sort, Material 등)용: tlist를 위치 기반 OUTER_VAR 참조 목록으로 재작성한다.
  • make_subplanSubLink를 플랜으로 변환하는 최상위 루틴. 서브쿼리 플래너를 실행하고 build_subplan을 호출한다.
  • build_subplan — initPlan 대 SubPlan 분류기. parParam/args를 구성하고, isInitPlan을 결정하며, generate_new_exec_param / generate_subquery_params로 출력 PARAM_EXEC를 할당하고, 선택적으로 useHashTable을 설정하거나 실체화하며, glob->subplans와(initPlan의 경우) root->init_plans에 등록하고 EXPLAIN을 위해 레이블을 붙인다.
  • generate_subquery_params / convert_testexpr — 서브쿼리 출력 컬럼을 대신하는 PARAM_EXEC 목록을 구성하고 테스트 표현식에 이어 붙인다.
  • SS_attach_initplans — 최상위 노드에 plan->initPlan = root->init_plans를 할당한다(create_plan에서 호출).
  • SS_process_ctesWITH CTE를 initPlan / CTE 스캔으로 변환한다.
  • SS_finalize_plan / finalize_plan — 각 노드의 extParam/allParam 집합을 계산하는 최종 재귀 패스(set_plan_references 이후 실행).

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

심볼파일
create_plansrc/backend/optimizer/plan/createplan.c337
create_plan_recursesrc/backend/optimizer/plan/createplan.c388
create_scan_plansrc/backend/optimizer/plan/createplan.c559
build_path_tlistsrc/backend/optimizer/plan/createplan.c825
use_physical_tlistsrc/backend/optimizer/plan/createplan.c865
get_gating_qualssrc/backend/optimizer/plan/createplan.c1002
create_gating_plansrc/backend/optimizer/plan/createplan.c1022
create_join_plansrc/backend/optimizer/plan/createplan.c1081
create_projection_plansrc/backend/optimizer/plan/createplan.c2015
inject_projection_plansrc/backend/optimizer/plan/createplan.c2117
change_plan_targetlistsrc/backend/optimizer/plan/createplan.c2149
create_seqscan_plansrc/backend/optimizer/plan/createplan.c2910
create_indexscan_plansrc/backend/optimizer/plan/createplan.c2999
create_nestloop_plansrc/backend/optimizer/plan/createplan.c4341
create_mergejoin_plansrc/backend/optimizer/plan/createplan.c4493
create_hashjoin_plansrc/backend/optimizer/plan/createplan.c4847
replace_nestloop_paramssrc/backend/optimizer/plan/createplan.c5036
replace_nestloop_params_mutatorsrc/backend/optimizer/plan/createplan.c5043
fix_indexqual_referencessrc/backend/optimizer/plan/createplan.c5121
copy_generic_path_infosrc/backend/optimizer/plan/createplan.c5514
copy_plan_costsizesrc/backend/optimizer/plan/createplan.c5530
make_seqscansrc/backend/optimizer/plan/createplan.c5643
make_nestloopsrc/backend/optimizer/plan/createplan.c6083
make_resultsrc/backend/optimizer/plan/createplan.c7129
set_plan_referencessrc/backend/optimizer/plan/setrefs.c288
add_rtes_to_flat_rtablesrc/backend/optimizer/plan/setrefs.c396
set_plan_refssrc/backend/optimizer/plan/setrefs.c619
set_indexonlyscan_referencessrc/backend/optimizer/plan/setrefs.c1333
trivial_subqueryscansrc/backend/optimizer/plan/setrefs.c1476
fix_param_nodesrc/backend/optimizer/plan/setrefs.c2125
fix_scan_exprsrc/backend/optimizer/plan/setrefs.c2212
fix_scan_expr_mutatorsrc/backend/optimizer/plan/setrefs.c2247
set_join_referencessrc/backend/optimizer/plan/setrefs.c2332
set_upper_referencessrc/backend/optimizer/plan/setrefs.c2481
set_dummy_tlist_referencessrc/backend/optimizer/plan/setrefs.c2692
build_tlist_indexsrc/backend/optimizer/plan/setrefs.c2759
build_tlist_index_other_varssrc/backend/optimizer/plan/setrefs.c2810
search_indexed_tlist_for_varsrc/backend/optimizer/plan/setrefs.c2868
search_indexed_tlist_for_phvsrc/backend/optimizer/plan/setrefs.c2933
search_indexed_tlist_for_non_varsrc/backend/optimizer/plan/setrefs.c2986
fix_join_exprsrc/backend/optimizer/plan/setrefs.c3104
fix_join_expr_mutatorsrc/backend/optimizer/plan/setrefs.c3126
fix_upper_exprsrc/backend/optimizer/plan/setrefs.c3278
fix_upper_expr_mutatorsrc/backend/optimizer/plan/setrefs.c3298
make_subplansrc/backend/optimizer/plan/subselect.c162
build_subplansrc/backend/optimizer/plan/subselect.c319
generate_subquery_paramssrc/backend/optimizer/plan/subselect.c582
convert_testexprsrc/backend/optimizer/plan/subselect.c644
SS_process_ctessrc/backend/optimizer/plan/subselect.c880
SS_attach_initplanssrc/backend/optimizer/plan/subselect.c2353
SS_finalize_plansrc/backend/optimizer/plan/subselect.c2368
INNER_VAR / OUTER_VAR / INDEX_VARsrc/include/nodes/primnodes.h242
IS_SPECIAL_VARNOsrc/include/nodes/primnodes.h247

아래 모든 주장은 2026-06-05에 커밋 273fe94의 REL_18 워킹 트리를 기준으로 검증했다.

  • 두 패스, 패스 1 전체에서 파서 번호 체계 보존. create_plan 헤더 주석(“Vars still correspond to the parser’s numbering. This will be fixed later by setrefs.c”)과, createplan.c의 어떤 루틴도 Var의 varnoINNER_VAR/OUTER_VAR를 쓰지 않는다는 사실로 검증했다. 이 상수들은 오직 setrefs.csearch_indexed_tlist_for_var / set_dummy_tlist_references에서만 작성된다.
  • CP_* 플래그. 네 플래그와 16진수 값들이 createplan.c 상단(70–73번 줄)에 인용한 대로 정확히 정의되어 있다. CP_IGNORE_TLIST는 헤더 주석 블록에 따라 0x0008이다.
  • 물리 tlist 게이트. use_physical_tlistCP_EXACT_TLIST | CP_SMALL_TLIST 설정 시와 시스템 컬럼/전체 행 Var 요건이 있을 때 false를 반환하는 것을 인용한 대로 확인했다. build_physical_tlistNIL(드롭된 컬럼)을 반환할 때 build_path_tlist로 폴백하는 것은 create_scan_plan에 있다.
  • NestLoop 파라미터 기계. create_nestloop_plan이 내부 자식에 재귀하기 전에 outerrelidsroot->curOuterRels에 push하고 이후 복원한다. replace_nestloop_params_mutatorbms_is_member(var->varno, root->curOuterRels)를 기준으로 교체한다. 둘 다 그대로 검증했다.
  • 특수 varno. INNER_VAR = -1, OUTER_VAR = -2, INDEX_VAR = -3, IS_SPECIAL_VARNO(varno) = ((int)(varno) < 0)primnodes.h (242–247번 줄)에서 확인했다.
  • Var → 슬롯 재작성. search_indexed_tlist_for_var가 매칭 분기에서 newvar->varno = newvarno(OUTER_VAR/INNER_VAR)와 newvar->varattno = vinfo->resno(자식 출력 위치)를 설정하는 것을 검증했다.
  • initPlan 대 SubPlan. build_subplanparParam == NIL이고 EXISTS/EXPR/ARRAY/ROWCOMPARE이거나 MULTIEXPRparParam == NIL 분기에서 isInitPlan = true를 설정한다. 비상관 ANY_SUBLINKuseHashTable을 설정할 수 있다. lappend(root->init_plans, splan)isInitPlan일 때만 실행된다. 검증 완료.
  • SS_attach_initplans는 한 줄짜리 함수로 plan->initPlan = root->init_plans를 할당한다. 2353번 줄에서 확인했다. create_plan의 주석은 initPlan이 쿼리 레벨의 최상위 노드에만 붙는다고 설명하며, SS_finalize_plan 헤더 주석도 같은 전제를 사용한다.
  • REL_18 특이 사항. fix_join_expr_mutatorvarreturningtype 검사(PG 18 MERGE/RETURNING 기능을 위한 RETURNING old/new Var)가 존재하며 인용했다. 제거된 기능 심볼(예: XLOG2 rmgr, B_DATACHECKSUMSWORKER_*)은 이 파일들에 나타나지 않아 REL_18 제약과 일치한다.

위치 표의 줄 번호는 이 리비전에 한정된 힌트다. 심볼 이름이 영속적인 앵커이며, 리포맷 커밋이 발생하면 이름은 그대로이고 번호만 바뀔 수 있다.

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

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

PostgreSQL의 “승자 Path를 낮추고, 이후 Var를 위치로 해결” 파이프라인은 더 넓은 설계 공간의 한 지점이다. 아래 비교는 무엇이 본질적인지와 무엇이 PostgreSQL만의 관례인지를 선명하게 한다.

Volcano/Cascades: 낮추기는 구현 규칙이지 별도 단계가 아니다

섹션 제목: “Volcano/Cascades: 낮추기는 구현 규칙이지 별도 단계가 아니다”

Cascades 방식 옵티마이저(Microsoft SQL Server, Greenplum의 ORCA, Apache Calcite의 Volcano planner)에는 명확한 “Path 트리 → Plan 트리” 낮추기 단계가 없다. 탐색 메모에 이미 물리적 그룹 표현식이 있고, 선택된 물리 플랜은 메모에서 직접 추출된다. 연산자 선택은 이후가 아니라 탐색 중에 구현 규칙으로 이루어진다. PostgreSQL은 의도적으로 단순한 System-R 방식의 상향식 조인 탐색(postgres-planner-overview.md)으로 경량 Path를 생성하고, “실행 가능하게 만들기” 작업을 모두 createplan.c에 집중시킨다. 그 트레이드오프는 명확하다. PostgreSQL의 낮추기는 읽고 추론하기 쉽지만, 전역 플랜 형태가 확정된 후에야 알 수 있는 속성에 의존하는 연산자 구현 결정은 내릴 수 없다.

위치 기반 바인딩 대 이름 기반 실행

섹션 제목: “위치 기반 바인딩 대 이름 기반 실행”

Var → (INNER_VAR/OUTER_VAR, resno) 재작성은 실행기의 성능 계약이다. 실행 시 컬럼을 읽는 것은 배열 인덱스 접근이며, 카탈로그 조회나 이름 비교가 없다. 일부 인터프리터형 또는 연구용 엔진은 유연성을 위해 컬럼을 이름으로 접근하고 접근마다 조회 비용을 지불한다. PostgreSQL은 플랜 시에 두 번째 전체 트리 순회(setrefs.c)를 수행해 실행 시 O(1) 컬럼 접근을 매 튜플마다 영구히 확보한다. 이는 postgres-expression-eval.md의 JIT 컴파일 튜플 deforming과 같은 철학이다. 플랜 시 사이클을 소비해 튜플당 사이클을 줄인다.

HyPer와 Umbra(Neumann, Efficiently Compiling Efficient Query Plans for Modern Hardware, VLDB 2011)는 이 개념을 더 밀고 나간다. 인터프리터 연산자 트리를 생성하는 대신 플랜을 기계 코드로 컴파일해 파이프라인을 융합함으로써 튜플이 연산자 체인을 연산자별 가상 호출 없이 통과하게 한다. 이 세계에서 “플랜”은 코드 생성 IR이고, setrefs.c의 위치 기반 바인딩에 해당하는 것은 생성된 코드에서의 레지스터/스택 슬롯 할당이다. PostgreSQL REL_18 기준선은 인터프리터 반복자 트리로 남아 있다(LLVM JIT는 표현식을 컴파일하지 연산자 트리를 컴파일하지 않는다). Plan 노드가 해석 가능한 영속 구조로 유지되는 이유가 여기에 있으며, 위치 기반 Var 해결을 플랜 시에 구워 넣어야 하는 이유이기도 하다.

플랜 캐싱과 읽기 전용 플랜 트리

섹션 제목: “플랜 캐싱과 읽기 전용 플랜 트리”

set_plan_references가 모든 참조를 오프셋으로 해결하고 범위 테이블을 평탄화·고정한 플랜을 생성하기 때문에, 완성된 플랜은 불변이고 재사용 가능하다. plancache.c가 준비된 문(prepared statement)의 플랜을 캐시하고 트랜잭션과 병렬 워커에 걸쳐 재실행할 수 있는 이유가 바로 이것이다. 각 실행은 공유된 읽기 전용 Plan 위에 새로운 가변 PlanState 트리(postgres-executor.md)를 구성한다. set_plan_references가 축적하는 의존성 목록(glob->relationOids, glob->invalItems)은 참조된 릴레이션이나 함수가 바뀔 때 플랜 캐시 무효화가 키로 삼는 것들과 정확히 일치한다. 별도의 불변 플랜 아티팩트 없이 매 실행마다 다시 플래닝하는 엔진은 이 bookkeeping을 피하지만, 캐싱 이점도 포기한다.

PostgreSQL의 initPlan/SubPlan 분리(subselect.c)는 로컬 결정이다. 서브쿼리가 이미 비상관일 때만 initPlan으로 호이스팅된다. 리라이터/플래너가 인식하는 경우(ANY/EXISTS → 세미조인)에 앞 단계에서 수행한 pull-up 변환이 있을 뿐, 상관 서브쿼리를 조인으로 적극 비상관화하지는 않는다. 연구·상용 시스템(고전적 작업은 Neumann & Kemper, Unnesting Arbitrary Queries, BTW 2015)은 임의의 중첩 쿼리를 단일 의존 조인으로 비상관화해 비용 옵티마이저가 자유롭게 플래닝하게 한다. 분석 워크로드에서 큰 이득을 얻는 경우가 많다. PostgreSQL의 튜플별 SubPlan은 비상관화가 작동하지 않을 때의 보수적 폴백이다. 항상 정확하지만, 잠재적으로 서브쿼리를 외부 행 수만큼 재실행하는 O(외부 행 수) 문제가 있다.

PostgreSQL은 융합 탐색·코드 생성 설계보다 읽기 쉽고 디버깅 가능한 두 패스 낮추기와 불변 캐시 가능한 출력을 의도적으로 선택한다. 대상 워크로드(중간 분석이 포함된 혼합 OLTP, 준비된 문 플랜 캐싱에 대한 강한 의존)를 고려할 때, createplan.c(구조)와 setrefs.c(바인딩)와 subselect.c(서브쿼리 연결)의 분리는 각 관심사를 독립적으로 테스트할 수 있게 해준다. 이는 이 code-analysis/postgres/ 시리즈에서 문서화된 나머지 트리의 공학적 가치와 일치한다.

  • 소스 트리: /data/hgryoo/references/postgres, 커밋 273fe94852b3a7e34fd171e8abdf1481beb302fa의 REL_18_STABLE, 2026-06-05 읽음. 코어 전용 (contrib 제외).
    • src/backend/optimizer/plan/createplan.c — Path → Plan 낮추기 (패스 1).
    • src/backend/optimizer/plan/setrefs.cset_plan_references와 위치 기반 Var 바인딩 (패스 2).
    • src/backend/optimizer/plan/subselect.c — initPlan/SubPlan 구성과 SS_attach_initplans.
    • src/include/nodes/primnodes.hINNER_VAR/OUTER_VAR/INDEX_VAR, IS_SPECIAL_VARNO, Var 구조체.
    • src/include/nodes/plannodes.h, pathnodes.hPlan / Path 노드 정의.
  • Silberschatz, Korth, Sudarshan, Database System Concepts, 7e, 15장 “Query Processing”과 16장 “Query Optimization” (§16.1 비용 기반 플랜 선택, §16.4.4 중첩 서브쿼리 최적화 중심). 캡처: knowledge/research/dbms-general/database-system-concepts.md.
  • Selinger et al. 1979, Access Path Selection in a Relational Database Management System (System R) — 이 모듈이 낮추는 상향식 조인 탐색의 Path. .omc/plans/postgres-paper-bibliography.mddbms-papers/systemr-optimizer.md 참조.
  • Neumann, Efficiently Compiling Efficient Query Plans for Modern Hardware, VLDB 2011 — 컴파일 우선 대비 사례.
  • Neumann & Kemper, Unnesting Arbitrary Queries, BTW 2015 — 서브쿼리 비상관화 대비 사례.
  • 이 시리즈 내 상호 참조: postgres-planner-overview.md (Path를 생성하는 조인 탐색), postgres-path-generation.md (개별 Path 구성 및 비용 계산), postgres-executor.md (완성된 Plan의 소비자), postgres-expression-eval.md (해결된 표현식의 실행 시 평가).