(KO) PostgreSQL 포털, 준비된 문장, 플랜 캐시
목차
- 이론적 배경
- DBMS 공통 설계 관례
- PostgreSQL의 접근 방식
- 소스 워크스루
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”SQL 문장은 텍스트에서 튜플로 곧장 이동하지 않는다. Database System Concepts(Silberschatz, 7판, 15장 “Query Processing”, §15.1)는 질의 처리를 세 단계 파이프라인으로 규정한다. 파싱 및 변환 단계에서 SQL 텍스트를 관계 대수 파스 트리로 변환하고, 최적화 단계에서 그 트리를 주석이 달린 평가 플랜 — “질의를 평가하는 데 사용할 수 있는 기본 연산의 순서” — 으로 변환하며, 평가 단계에서 저장된 데이터에 플랜을 실행해 결과를 생성한다. 교재는 중간 단계가 비싸다고 명시한다. 최적화기는 “여러 플랜을 검토하고 각각의 비용을 추정해 가장 저렴한 것을 선택한다”(§15.1, §16.5). 비용 추정과 플랜 열거가 사소한 질의를 제외한 모든 질의에서 컴파일 시간 예산의 대부분을 차지한다.
이 비용 비대칭이 이 문서에서 다루는 구조들이 존재하는 이유다. 동일한 질의 형태가 반복 실행될 때 — 애플리케이션이 수천 가지 id 값마다 SELECT ... WHERE id = $1을 발행하는 전형적인 경우 — 매 실행마다 파싱과 계획 비용을 지불하는 것은 낭비다. 교재는 이 해답을 저장된 / 파라미터화된 질의라 부른다. 질의를 한 번 컴파일해 변하는 부분(상수)에 플레이스홀더를 둔 플랜을 만들고, 이후의 각 플레이스홀더 값 집합마다 컴파일된 플랜을 재사용하는 방식이다. ANSI/ISO SQL 표준은 이것을 PREPARE / EXECUTE 문 쌍으로 노출하며, 주요 클라이언트 프로토콜(JDBC PreparedStatement, libpq의 확장 질의 프로토콜, ODBC)은 모두 그 위에 준비된 문장 API를 계층화한다.
“준비된 문장”에는 서로 다른 두 개념이 섞여 있으며, 이 둘을 분리하는 것이 PostgreSQL 소스를 읽는 핵심이다.
- 컴파일된 질의 — 파스 분석과 재작성을 마친 질의 트리와, 계획을 세우는 데 필요한 메타데이터(파라미터 타입, 결과 행의 컬럼 타입).
PREPARE가 한 번 생성하며, 참조하는 스키마가 변경되지 않는 한 논리적으로 불변이다. - 실행 플랜 — 익스큐터가 실제로 실행하는 물리 연산자 트리. 교재가 흘려 넘긴 진짜 선택지가 여기에 있다. 플랜을 한 번, 파라미터 독립적으로 구축해 재사용(재사용 비용이 적지만 최적화기가 실제 상수 값을 모른 채 연산자를 결정해야 함)하거나, 각 실행마다 실제 상수를 대입해 새로 구축(구축 비용이 크지만 선택도 추정과 인덱스 선택에 상수를 활용)할 수 있다.
두 번째 선택지는 세부 사항이 아닌 실질적인 비용/편익 절충이다. 99%의 행이 status = 'active'인 컬럼에서 WHERE status = $1을 생각해 보자. $1을 모르는 상태로 구축된 플랜은 평균 선택도를 가정해 순차 스캔을 선택할 수 있다. 반면 $1 = 'closed'(희귀한 1%)를 알고 구축된 플랜은 인덱스를 쓸 수 있다. 하지만 애플리케이션이 항상 흔한 값을 전달한다면 파라미터 독립적 플랜도 충분하며 재최적화 비용을 아낄 수 있다. 어떤 정적 규칙도 항상 이긴다는 보장이 없다. PostgreSQL의 플랜 캐시가 관찰된 비용을 바탕으로 런타임에 적응적으로 결정을 내리는 이유가 바로 이것이다. 이 적응적 범용-vs-커스텀 결정이 이 문서의 지적 핵심이다.
세 번째 구조인 포털은 교재에 직접적인 이름이 없다. 관계 대수 개념이 아닌 엔지니어링 추상이기 때문이다. 포털은 실행 중인 명령 하나의 런타임 상태를 담는 컨테이너다. 어느 플랜인지, 파라미터는 무엇인지, 현재 커서 위치는 어디인지, 결과 컬럼은 어떻게 생겼는지를 보유한다. 교재의 이터레이터 모델(§15.7.2.1, postgres-executor.md에서 깊이 다룸)은 “파이프라인 상단 연산으로부터 시스템이 반복적으로 튜플을 요청한다”고 가정하지만, 요청 사이에 이터레이터를 소유하고, 이번 커서에서 50번째 FETCH라는 사실을 기억하며, 모든 요청에 걸쳐 스냅샷을 핀으로 고정할 무언가가 있어야 한다. 그것이 포털이다. SQL 커서(DECLARE ... CURSOR, FETCH, MOVE)는 포털의 사용자 가시 측면이고, 와이어 프로토콜의 Bind/Execute 메시지는 다른 측면이다.
DBMS 공통 설계 관례
섹션 제목: “DBMS 공통 설계 관례”교재는 이유를 제시한다. 한 번 컴파일하고, 재사용하며, 비용으로 플랜을 선택한다. 이 절은 상용 엔진이 준비된 문장, 플랜 캐싱, 실행 컨테이너를 구현하기 위해 채택하는 엔지니어링 관례를 정리한다. ## PostgreSQL의 접근 방식에서 나오는 PostgreSQL의 구체적 선택은 이 공통 설계 공간 안의 한 지점이다.
컴파일된 질의와 실행 플랜을 분리한다
섹션 제목: “컴파일된 질의와 실행 플랜을 분리한다”단순한 플랜 캐시는 “SQL 텍스트 → 플랜”을 저장한다. 이는 두 가지 이유로 잘못됐다. 첫째, 컴파일된 질의(파스 트리 + 파라미터 타입)는 플랜과 다른 이벤트 집합에 의해 무효화된다. SET search_path나 역할 변경은 파스 분석을 건드리지 않고 계획 결정을 무효화할 수 있고, DROP INDEX는 파스 트리를 건드리지 않고 플랜을 무효화한다. 둘째, 컴파일된 질의 하나가 생애 동안 여러 플랜을 낳을 수 있다 — 범용 플랜 하나와 일련의 커스텀 플랜들 — 따라서 플랜은 질의의 필드가 될 수 없다. 관례는 두 개의 참조 카운팅 객체다. 파스 트리와 의존성 목록을 소유하는 오래 지속되는 컴파일된 질의 객체, 그리고 그에 매달려 독립적으로 폐기·재구축될 수 있는 수명이 짧은 플랜 객체다.
범용 vs. 커스텀 플랜, 적응적으로 결정한다
섹션 제목: “범용 vs. 커스텀 플랜, 적응적으로 결정한다”파라미터가 주어지면 엔진은 범용(단일 타입으로서 $1을 처리, 한 번 계획, 영구 재사용)으로 계획하거나 커스텀(리터럴 값을 대입, 매번 재계획)으로 계획할 수 있다. 성숙한 관례는 정적으로 하나를 선택하는 것이 아니라 측정하는 것이다. 워밍업 기간 동안 커스텀 플랜을 실행하고, 계획 비용을 포함한 평균 비용을 기록하며, 범용 플랜이 재최적화보다 명백히 저렴할 때만 전환한다. 계획 비용을 커스텀 플랜 측에 포함시키는 것이 미묘한 핵심이다. 범용 플랜이 순수 실행 비용이 약간 높더라도 재계획 비용을 전혀 치르지 않기 때문에 이기게 만드는 장치다.
스키마 변경 시 플랜을 지연 무효화한다
섹션 제목: “스키마 변경 시 플랜을 지연 무효화한다”캐시된 플랜은 테이블, 인덱스, 함수, 연산자를 OID로 참조한다. 그 중 어느 하나를 건드리는 DDL — DROP INDEX, ALTER TABLE, 함수 교체 — 은 플랜을 잘못되게 만들 수 있다. 관례는 의존성 추적과 무효화 메시지다. 플랜이 구축될 때 의존하는 OID를 기록하고, DDL이 커밋되면 공유 무효화(sinval) 메시지를 브로드캐스트하며, 해당 OID를 키로 하는 콜백이 영향받는 모든 캐시 객체의 is_valid 플래그를 false로 뒤집는다. 무효화는 지연된다. 플래그만 설정하고, 실제 재분석과 재계획은 다음 사용 시까지 미뤄지므로, 아무도 실행하지 않는 질의 때문에 DDL 폭풍이 재계획 폭풍을 일으키지 않는다.
멈출 수 있는 실행 컨테이너
섹션 제목: “멈출 수 있는 실행 컨테이너”한 번에 한 행씩 소비하는 경우 — 커서, 행 제한이 있는 프로토콜의 Execute — 엔진은 플랜을 완료까지 실행해 모든 것을 버퍼에 저장할 수 없다. 이터레이터를 스트림 중간에 멈추고 다음 요청에 재개할 수 있어야 한다. 이를 위한 컨테이너는 요청 사이에 살아남아 라이브 익스큐터 상태, 등록된 스냅샷(첫 FETCH부터 마지막까지 MVCC가 일관된 행 집합을 보도록), 커서 위치, 결과 디스크립터를 보유해야 한다. 수정 명령(INSERT/UPDATE/DELETE)은 일반적으로 안전하게 멈출 수 없다. AFTER 트리거와 규칙 재작성기가 문장이 원자적으로 실행된다고 가정하기 때문이다. 따라서 컨테이너는 내용을 분류하고 순수 SELECT에 대해서만 멈춤을 허용한다.
스냅샷과 리소스 수명을 컨테이너에 묶는다
섹션 제목: “스냅샷과 리소스 수명을 컨테이너에 묶는다”컨테이너는 실행 가능한 동안 스냅샷과 락/버퍼 집합을 소유한다. 관례는 컨테이너 범위의 리소스 오너에 이것들을 부착하고, vacuum이 커서가 아직 볼 수 있는 행 버전을 회수하지 못하도록 스냅샷을 등록하는 것이다. 컨테이너를 해제할 때 모두 한 번에 해제된다. 개별 락의 소매 장부 기록이 없다.
이론 ↔ PostgreSQL 매핑
섹션 제목: “이론 ↔ PostgreSQL 매핑”## 소스 워크스루에서 심볼 이름을 마주치기 전에, 그것이 어떤 종류의 것인지 미리 알아야 한다.
| 이론 / 관례 | PostgreSQL 이름 |
|---|---|
| 컴파일된 질의 (파스 트리 + 파라미터 타입) | CachedPlanSource (헤더는 “CachedQuery”라고도 부름) |
| 참조 카운팅 실행 플랜 객체 | CachedPlan (PlannedStmt 목록) |
| 컴파일된 질의의 범용 플랜 슬롯 | CachedPlanSource.gplan |
| 범용-vs-커스텀 결정 | choose_custom_plan (GetCachedPlan에서 호출) |
| 커스텀 플랜 워밍업 횟수 | num_custom_plans < 5 |
| 커스텀 비용에 포함되는 계획 비용 부과 | cached_plan_cost(plan, include_planner = true) |
| 캐시된 범용 플랜 비용 | CachedPlanSource.generic_cost |
| 지연 무효화 플래그 | CachedPlanSource / CachedPlan의 is_valid |
| 다음 사용 시 재분석/재계획 | RevalidateCachedQuery + CheckCachedPlan |
| sinval 기반 무효화 콜백 | PlanCacheRelCallback / PlanCacheObjectCallback |
| 실행 컨테이너 | Portal (PortalData) |
| 컨테이너 분류 | ChoosePortalStrategy → PortalStrategy |
| 포털을 위한 “기계 구축” | PortalStart |
| 포털에서 “튜플 당기기 / 실행” | PortalRun / PortalRunSelect / PortalRunFetch |
| 컨테이너 해제 | PortalDrop |
| 준비된 문장 레지스트리 | prepare.c의 prepared_queries 해시 테이블 |
PREPARE / EXECUTE 유틸리티 명령 | PrepareQuery / ExecuteQuery |
익스큐터 자체 — ExecutorStart/Run/Finish/End, PlanState 트리, TupleTableSlot — 는 postgres-executor.md가 담당한다. 이 문서는 ExecutorStart/ExecutorRun 호출 지점까지만 다루며 그 내부를 재설명하지 않는다. 백엔드가 와이어 메시지를 읽고 PortalStart/PortalRun을 호출하기로 결정하는 과정은 postgres-wire-protocol.md와 postgres-backend-lifecycle.md가 담당한다. 이 문서는 컨테이너, 준비된 문장 레지스트리, 플랜 캐시의 범용-vs-커스텀 로직을 다룬다.
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL은 위의 관례들을 각자 소스 파일을 가진 세 개의 협력 서브시스템으로 구현한다.
plancache.c— 컴파일된 질의(CachedPlanSource), 플랜 객체(CachedPlan), 범용-vs-커스텀 결정, 무효화를 소유한다.prepare.c— SQL 레벨PREPARE/EXECUTE/DEALLOCATE명령과 명명된 준비된 문장의 백엔드별 해시 테이블을 소유한다.plancache.c의 얇은 클라이언트다.pquery.c(포털 메모리 할당은portalmem.c) — 포털(실행 컨테이너), 다섯 가지 전략, 실행 루프를 소유한다.
실행 컨테이너로서의 포털
섹션 제목: “실행 컨테이너로서의 포털”PortalData가 구조체다. 실행에 중요한 필드는 플랜(stmts — PlannedStmt 목록 — 과 그것이 나온 cplan), 파라미터(portalParams), 결과 디스크립터(tupDesc), 커서 위치(atStart / atEnd / portalPos), 리소스 오너와 스냅샷이다.
// PortalData — src/include/utils/portal.h (condensed)typedef struct PortalData{ const char *name; /* portal's name */ MemoryContext portalContext; /* subsidiary memory for portal */ ResourceOwner resowner; /* resources owned by portal */ List *stmts; /* list of PlannedStmts */ CachedPlan *cplan; /* CachedPlan, if stmts are from one */ ParamListInfo portalParams; /* params to pass to query */ PortalStrategy strategy; /* PORTAL_ONE_SELECT, ... */ PortalStatus status; /* PORTAL_NEW ... PORTAL_DONE/FAILED */ QueryDesc *queryDesc; /* executor invocation state, or NULL */ TupleDesc tupDesc; /* descriptor for result tuples */ Snapshot portalSnapshot; /* active snapshot, or NULL */ Tuplestorestate *holdStore; /* store for held / non-suspendable results */ bool atStart, atEnd; /* cursor position flags */ uint64 portalPos; /* rows fetched so far */} PortalData;포털은 작은 상태 기계를 거친다. CreatePortal이 PORTAL_NEW 상태를 만들고, PortalDefineQuery가 플랜 목록을 붙여 PORTAL_DEFINED로 이동하며, PortalStart가 분류하고 준비해 PORTAL_READY에 도달한다. PortalRun은 실행 중에 PORTAL_ACTIVE로 표시하고 이후 PORTAL_READY(멈춤, 재개 가능) 또는 PORTAL_DONE으로 전환한다. 오류 발생 시 PORTAL_FAILED가 된다. PortalDrop이 해제한다.
flowchart TB NEW["PORTAL_NEW<br/>CreatePortal"] DEF["PORTAL_DEFINED<br/>PortalDefineQuery: stmts + cplan 부착"] RDY["PORTAL_READY<br/>PortalStart: ChoosePortalStrategy,<br/>스냅샷, ExecutorStart (ONE_SELECT)"] ACT["PORTAL_ACTIVE<br/>PortalRun / PortalRunSelect"] DONE["PORTAL_DONE<br/>완료까지 실행 / MULTI_QUERY"] FAIL["PORTAL_FAILED<br/>미처리 오류"] NEW --> DEF --> RDY --> ACT ACT -->|"순방향 fetch, 행 더 있음"| RDY ACT -->|"atEnd 또는 MULTI_QUERY"| DONE ACT -->|"오류"| FAIL DONE --> DROP["PortalDrop:<br/>정리 훅 (ExecutorEnd),<br/>cplan 참조 카운트 감소, 컨텍스트 해제"] FAIL --> DROP
그림 1 — 포털 상태 기계. ACTIVE→READY 역방향 전이가 커서 FETCH n과 프로토콜 Execute의 행 제한을 가능하게 한다. PORTAL_ONE_SELECT 포털은 n행을 반환한 뒤 멈추고 재개될 수 있다.
다섯 가지 전략, 한 번 결정
섹션 제목: “다섯 가지 전략, 한 번 결정”PortalStart는 플랜 목록에 ChoosePortalStrategy를 호출해 다섯 가지 PortalStrategy 값 중 하나를 선택한다. 전략은 포털이 멈출 수 있는지(PORTAL_ONE_SELECT만 익스큐터를 증분 방식으로 실행), 결과가 어디에 위치하는지(직접 스트리밍 또는 holdStore 튜플스토어 버퍼링)를 결정한다. 분류는 순전히 구조적이다. 명령 타입, canSetTag, 수정-CTE 및 RETURNING 플래그를 검사한다.
// ChoosePortalStrategy — src/backend/tcop/pquery.c (condensed)if (list_length(stmts) == 1){ /* ... for a single PlannedStmt that canSetTag ... */ if (pstmt->commandType == CMD_SELECT) { if (pstmt->hasModifyingCTE) return PORTAL_ONE_MOD_WITH; /* SELECT with data-modifying CTE */ else return PORTAL_ONE_SELECT; /* the suspendable case */ } if (pstmt->commandType == CMD_UTILITY) { if (UtilityReturnsTuples(pstmt->utilityStmt)) return PORTAL_UTIL_SELECT; /* EXPLAIN, SHOW, ... */ return PORTAL_MULTI_QUERY; }}/* exactly one canSetTag query with a RETURNING list: */if (nSetTag == 1) return PORTAL_ONE_RETURNING;/* everything else (multi-statement, plain DML, ...) */return PORTAL_MULTI_QUERY;PORTAL_ONE_SELECT만 증분 방식으로 읽힌다. 나머지는 첫 호출에 완료까지 실행된다. PORTAL_ONE_RETURNING, PORTAL_ONE_MOD_WITH, PORTAL_UTIL_SELECT는 출력을 포털의 holdStore 튜플스토어에 덤프한다. 데이터 수정 또는 RETURNING 질의를 부분 실행하면 AFTER 트리거가 깨지기 때문이다. PORTAL_MULTI_QUERY는 모든 문장을 순서대로 실행한다. portal.h 헤더는 규칙을 직접 명시한다. 멈춤은 “단일 SELECT 타입 질의를 담은 포털에만 허용된다. 클라이언트가 수정 질의를 중간에 멈추게 두고 싶지 않다!”
범용-vs-커스텀 결정
섹션 제목: “범용-vs-커스텀 결정”파라미터화된 준비된 문장에서 핵심 작업은 GetCachedPlan → choose_custom_plan 내부에서 일어난다. 교재의 절충에 대한 PostgreSQL의 적응적 답이다. 처음 다섯 번은 커스텀 플랜을 구축해 비용 데이터를 모으고, 이후에는 범용 플랜의 비용이 커스텀 플랜의 평균 비용(계획 노력 비용 포함)보다 나쁘지 않을 때만 범용 플랜을 유지한다. 결정의 구조는 다음과 같다.
flowchart TB GET["GetCachedPlan(plansource, boundParams)"] REV["RevalidateCachedQuery:<br/>!is_valid이면 재분석/재계획;<br/>플래너 락 획득"] CC["choose_custom_plan?"] GET --> REV --> CC CC -->|"파라미터 없음, 또는 FORCE_GENERIC"| GEN CC -->|"is_oneshot, 또는 num_custom_plans < 5,<br/>또는 FORCE_CUSTOM"| CUST CC -->|"num_custom_plans >= 5:<br/>generic_cost < 평균 커스텀 비용?"| DECIDE DECIDE -->|"예: 범용이 더 저렴"| GEN["범용 플랜:<br/>CheckCachedPlan이 gplan 재사용,<br/>없으면 BuildCachedPlan(NULL params)"] DECIDE -->|"아니오"| CUST["커스텀 플랜:<br/>BuildCachedPlan(boundParams),<br/>total_custom_cost 누적"] GEN --> RET["참조 카운트 증가, CachedPlan 반환"] CUST --> RET
그림 2 — GetCachedPlan 내부의 범용-vs-커스텀 결정. 워밍업 게이트(num_custom_plans < 5)와, 범용을 선택한 후에도 generic_cost가 알려지면 GetCachedPlan이 결정을 재확인해 비싸다고 판명된 범용 플랜을 이번 실행에서 커스텀으로 교체하는 구조에 주목하라.
소스 워크스루
섹션 제목: “소스 워크스루”파라미터화된 준비된 문장의 생애를 처음부터 끝까지 따라간다. PREPARE가 컴파일된 질의를 구축하고, EXECUTE가 파라미터를 바인딩해 플랜 캐시에 플랜을 요청하며, 플랜 캐시가 범용-vs-커스텀을 결정하고, 포털이 선택된 플랜을 실행하며, PortalDrop과 무효화가 정리한다. 심볼이 정식 앵커다. 위치 힌트 표는 문서의 updated: 날짜 기준으로 각 심볼의 (파일, 줄) 쌍을 제공한다.
PREPARE: CachedPlanSource 구축 (prepare.c + plancache.c)
섹션 제목: “PREPARE: CachedPlanSource 구축 (prepare.c + plancache.c)”PrepareQuery는 PREPARE name (types) AS query 핸들러다. 순서가 의도적이며 주석에도 명시돼 있다. CachedPlanSource는 먼저 원시(분석 전) 파스 트리로 생성된다. 플랜캐시가 수정되지 않은 트리를 소유하고자 하기 때문이다. 그 뒤에만 선언된 파라미터 타입으로 파스 분석이 실행된다.
// PrepareQuery — src/backend/commands/prepare.c (condensed)plansource = CreateCachedPlan(rawstmt, pstate->p_sourcetext, CreateCommandTag(stmt->query));/* ... build argtypes[] from the declared TypeNames ... */query_list = pg_analyze_and_rewrite_varparams(rawstmt, pstate->p_sourcetext, &argtypes, &nargs, NULL);CompleteCachedPlan(plansource, query_list, NULL, argtypes, nargs, NULL, NULL, CURSOR_OPT_PARALLEL_OK, /* allow parallel mode */ true); /* fixed_result */StorePreparedStatement(stmt->name, plansource, true);CreateCachedPlan은 개인 메모리 컨텍스트(질의 텍스트를 이름으로 사용, pg_backend_memory_contexts에 질의 문자열이 보이는 이유)와 초기화된 카운터를 가진 CachedPlanSource를 할당한다. generic_cost = -1(“아직 미지”), num_custom_plans = 0, total_custom_cost = 0으로 초기화된다.
// CreateCachedPlan — src/backend/utils/cache/plancache.c (condensed)source_context = AllocSetContextCreate(CurrentMemoryContext, "CachedPlanSource", ALLOCSET_START_SMALL_SIZES);plansource = (CachedPlanSource *) palloc0(sizeof(CachedPlanSource));plansource->magic = CACHEDPLANSOURCE_MAGIC;plansource->raw_parse_tree = copyObject(raw_parse_tree);plansource->query_string = pstrdup(query_string);plansource->generic_cost = -1; /* generic plan cost not yet known */plansource->total_custom_cost = 0;plansource->num_generic_plans = 0;plansource->num_custom_plans = 0;CompleteCachedPlan은 분석·재작성된 query_list, 파라미터 타입, 결과 tupdesc를 저장한다. 무효화될 수 있는 문장에 대해서는 extract_query_dependencies를 실행해 질의가 의존하는 OID를 relationOids / invalItems에 기록한다. is_complete = is_valid = true로 설정된다. 마지막으로 StorePreparedStatement가 백엔드별 prepared_queries 해시 테이블에 PreparedStatement 항목을 삽입하고 SaveCachedPlan을 호출해 소스를 트랜잭션이 끝나도 살아남는 장기 CacheMemoryContext로 이동시킨다.
// StorePreparedStatement — src/backend/commands/prepare.c (condensed)entry = (PreparedStatement *) hash_search(prepared_queries, stmt_name, HASH_ENTER, &found);if (found) ereport(ERROR, (errcode(ERRCODE_DUPLICATE_PSTATEMENT), errmsg("prepared statement \"%s\" already exists", stmt_name)));entry->plansource = plansource;entry->from_sql = from_sql;entry->prepare_time = cur_ts;SaveCachedPlan(plansource); /* now safe to move to permanent memory */프로토콜 레벨 Parse 메시지(postgres.c의 exec_parse_message, postgres-wire-protocol.md 담당)는 약간 다른 경로로 동일한 CachedPlanSource 상태에 도달한다. 결과 객체는 동일하다.
EXECUTE: 파라미터 바인딩과 플랜 획득 (prepare.c)
섹션 제목: “EXECUTE: 파라미터 바인딩과 플랜 획득 (prepare.c)”ExecuteQuery는 EXECUTE name (params)를 처리한다. 준비된 문장을 조회하고 파라미터 표현식을 ParamListInfo로 평가하며, 포털을 생성하고 — 핵심적으로 — 포털의 질의를 정의하기 전에 GetCachedPlan을 호출해 플랜을 획득한다. GetCachedPlan과 PortalDefineQuery 사이에 오류를 던지지 말라는 주석은 의미가 있다. GetCachedPlan이 이미 플랜의 참조 카운트를 증가시켰고, PortalDefineQuery만이 그 참조 카운트를 포털 수명에 묶기 때문이다. 그 사이 오류가 발생하면 참조 카운트가 누수된다.
// ExecuteQuery — src/backend/commands/prepare.c (condensed)entry = FetchPreparedStatement(stmt->name, true);if (entry->plansource->num_params > 0){ estate = CreateExecutorState(); estate->es_param_list_info = params; paramLI = EvaluateParams(pstate, entry, stmt->params, estate);}portal = CreateNewPortal();/* Replan if needed, and increment plan refcount for portal */cplan = GetCachedPlan(entry->plansource, paramLI, NULL, NULL);plan_list = cplan->stmt_list;/* DO NOT add logic that could throw between GetCachedPlan and PortalDefineQuery */PortalDefineQuery(portal, NULL, query_string, entry->plansource->commandTag, plan_list, cplan);/* ... */PortalStart(portal, paramLI, eflags, GetActiveSnapshot());(void) PortalRun(portal, count, false, dest, dest, qc);PortalDrop(portal, false);EvaluateParams는 파라미터 개수를 확인하고, 각 파라미터 표현식을 파스 분석하며, 선언된 타입으로 강제 변환한다. 값을 변환할 수 없으면 ERRCODE_DATATYPE_MISMATCH를 발생시킨다. 결과 ParamListInfo가 GetCachedPlan이 커스텀 플랜이 가치 있는지 판단하는 데 사용하는 것이다. 파라미터가 없으면 커스텀은 절대 선택되지 않는다.
GetCachedPlan: 재검증 후 결정 (plancache.c)
섹션 제목: “GetCachedPlan: 재검증 후 결정 (plancache.c)”GetCachedPlan은 서브시스템 전체의 핵심이다. 먼저 RevalidateCachedQuery를 호출해(무효화가 발생했으면 질의 트리를 재분석/재계획하고 파스 타임 락을 획득), 그 다음 choose_custom_plan을 호출해 범용-vs-커스텀 결정을 내린다.
// GetCachedPlan — src/backend/utils/cache/plancache.c (condensed)qlist = RevalidateCachedQuery(plansource, queryEnv);customplan = choose_custom_plan(plansource, boundParams);
if (!customplan){ if (CheckCachedPlan(plansource)) plan = plansource->gplan; /* reuse existing valid generic plan */ else { plan = BuildCachedPlan(plansource, qlist, NULL, queryEnv); /* NULL = generic */ plansource->gplan = plan; plan->refcount++; /* Update generic_cost whenever we make a new generic plan */ plansource->generic_cost = cached_plan_cost(plan, false); /* Re-check now that generic_cost is known */ customplan = choose_custom_plan(plansource, boundParams); qlist = NIL; }}if (customplan){ plan = BuildCachedPlan(plansource, qlist, boundParams, queryEnv); /* with params */ plansource->total_custom_cost += cached_plan_cost(plan, true); /* include planner */ plansource->num_custom_plans++;}else plansource->num_generic_plans++;
plan->refcount++; /* flag the plan as in use by caller */두 가지 미묘함이 있다. (1) choose_custom_plan을 두 번 호출한다. 첫 번째 호출은 범용을 시도할지 여부를 결정한다. 범용을 선택했지만 범용 플랜이 없으면 BuildCachedPlan(NULL)이 하나를 만들고, 그때 비로소 generic_cost가 알려진다. choose_custom_plan이 다시 호출된다. 새로 측정된 범용 플랜이 형편없다면 이번 실행에 커스텀 플랜을 구축하고 범용 플랜의 역할을 폐기한다. (2) 커스텀 플랜의 cached_plan_cost(plan, true)는 include_planner = true를 전달하므로 커스텀 측 비교에 계획 비용이 추가된다. 범용 측(cached_plan_cost(plan, false))은 그렇지 않다.
choose_custom_plan은 정책 자체이며 전체를 읽을 만큼 짧다.
// choose_custom_plan — src/backend/utils/cache/plancache.c (condensed)if (plansource->is_oneshot) return true; /* one-shot ⇒ always custom */if (boundParams == NULL) return false; /* no params ⇒ generic is just as good */if (!StmtPlanRequiresRevalidation(plansource)) return false; /* planning would be a no-op */
if (plan_cache_mode == PLAN_CACHE_MODE_FORCE_GENERIC_PLAN) return false;if (plan_cache_mode == PLAN_CACHE_MODE_FORCE_CUSTOM_PLAN) return true;/* ... CURSOR_OPT_GENERIC_PLAN / CURSOR_OPT_CUSTOM_PLAN forcing ... */
/* Generate custom plans until we have done at least 5 (arbitrary) */if (plansource->num_custom_plans < 5) return true;
avg_custom_cost = plansource->total_custom_cost / plansource->num_custom_plans;/* Prefer generic plan if it's less expensive than the average custom plan. */if (plansource->generic_cost < avg_custom_cost) return false;return true;< 5 워밍업이 유명한 “다섯 번 실행” 규칙이다. 파라미터화된 문장은 처음 다섯 번의 EXECUTE에서 무조건 재계획되어 total_custom_cost를 쌓는다. 여섯 번째부터 비용 비교가 시작된다. plan_cache_mode GUC(auto / force_generic_plan / force_custom_plan)는 운영자가 비용 모델보다 더 잘 안다고 판단할 때 휴리스틱을 무력화한다.
계획 비용 부과는 cached_plan_cost에 있다. 유틸리티가 아닌 모든 PlannedStmt의 total_cost를 합산하고, include_planner가 설정된 경우 관계 개수당 계획 페널티를 추가한다.
// cached_plan_cost — src/backend/utils/cache/plancache.c (condensed)result += plannedstmt->planTree->total_cost;if (include_planner){ /* crude estimate of planning effort: linear in rangetable size */ int nrelations = list_length(plannedstmt->rtable); result += 1000.0 * cpu_operator_cost * (nrelations + 1);}주석은 솔직하다. “현재 승수인 1000 * cpu_operator_cost는 아마 낮은 편일 것이다.” 이 상수 하나가 “재계획 비용이 실행 비용 대비 얼마인가”의 전체 모델이다. 관계 테이블이 많은 질의(rangetable이 큰 경우)가 더 빨리 범용 플랜으로 기울어지는 이유다.
재검증과 무효화 (plancache.c)
섹션 제목: “재검증과 무효화 (plancache.c)”RevalidateCachedQuery는 지연 무효화 응답자다. 질의가 여전히 유효하면 플래너 락만 획득하고 재확인한다(유효성 검사와 락 사이에 sinval 메시지가 도착하는 경쟁 조건을 커버). 무효화됐으면 질의 트리를 버리고 파스 분석과 재작성을 다시 실행한다. 무효화될 수 없는 문장(예: 트랜잭션 제어 명령)의 빠른 종료로 카탈로그 접근 자체를 피한다.
// RevalidateCachedQuery — src/backend/utils/cache/plancache.c (condensed)if (plansource->is_oneshot || !StmtPlanRequiresRevalidation(plansource)) return NIL; /* nothing to revalidate */if (plansource->is_valid){ AcquirePlannerLocks(plansource->query_list, true); if (plansource->is_valid) /* still valid after locking? */ return NIL; /* yes: reuse query tree as-is */ AcquirePlannerLocks(plansource->query_list, false); /* race: undo locks */}/* invalid: discard and re-analyze below ... */plansource->is_valid = false;plansource->query_list = NIL;CheckCachedPlan은 플랜(질의가 아님)의 유효성 게이트다. 범용 플랜에 익스큐터 락을 획득하고, is_valid를 재확인하며, TransactionXmin이 플랜의 saved_xmin을 지난 경우 일시 플랜을 무효화한다. 아직 커밋되지 않은 카탈로그 변경을 전제로 구축된 플랜은 그 변경이 전역적으로 가시화되면 재구축돼야 한다.
// CheckCachedPlan — src/backend/utils/cache/plancache.c (condensed)if (plan->is_valid){ AcquireExecutorLocks(plan->stmt_list, true); if (plan->is_valid && TransactionIdIsValid(plan->saved_xmin) && !TransactionIdEquals(plan->saved_xmin, TransactionXmin)) plan->is_valid = false; /* transient plan aged out */ if (plan->is_valid) return true; /* good, locked, reusable */ AcquireExecutorLocks(plan->stmt_list, false); /* race: release */}ReleaseGenericPlan(plansource); /* drop the dead generic plan */return false;실제 무효화는 InitPlanCache에서 등록한 공유 무효화 콜백이 구동한다. PlanCacheRelCallback(관계 OID 변경)과 PlanCacheObjectCallback(기타 카탈로그 객체 변경)이 저장된 플랜 목록을 순회하며 relationOids / invalItems가 일치하는 CachedPlanSource / CachedPlan의 is_valid = false를 설정한다. ## DBMS 공통 설계 관례의 지연 관례가 구체화된 것이다. 콜백은 플래그만 설정하고, RevalidateCachedQuery / CheckCachedPlan이 다음 사용 시 재구축을 수행한다. sinval 메커니즘 자체는 postgres-cache-invalidation.md가 담당한다.
GetCachedPlan에는 핫 패스를 위한 저비용 형제가 있다. CachedPlanAllowsSimpleValidityCheck / CachedPlanIsSimplyValid는 이미 플랜 참조를 보유한 호출자가 락 재획득 없이 재검증할 수 있게 한다. is_valid, gplan 동일성, search_path만 확인하며 SPI와 새 방식의 SQL 함수 익스큐터에서 사용된다.
PortalStart: 분류, 스냅샷, ExecutorStart (pquery.c)
섹션 제목: “PortalStart: 분류, 스냅샷, ExecutorStart (pquery.c)”포털로 돌아온다. PortalStart는 ChoosePortalStrategy를 실행하고, 멈출 수 있는 PORTAL_ONE_SELECT의 경우 스냅샷을 푸시하고, QueryDesc를 구축하며, ExecutorStart를 호출한다. 하지만 익스큐터를 아직 실행하지는 않는다. 결과 tupdesc를 기록하고 커서 위치를 초기화해 포털을 PORTAL_READY로 남긴다.
// PortalStart — src/backend/tcop/pquery.c (condensed, PORTAL_ONE_SELECT arm)portal->strategy = ChoosePortalStrategy(portal->stmts);switch (portal->strategy){ case PORTAL_ONE_SELECT: if (snapshot) PushActiveSnapshot(snapshot); else PushActiveSnapshot(GetTransactionSnapshot()); queryDesc = CreateQueryDesc(linitial_node(PlannedStmt, portal->stmts), portal->sourceText, GetActiveSnapshot(), InvalidSnapshot, None_Receiver, params, portal->queryEnv, 0); if (portal->cursorOptions & CURSOR_OPT_SCROLL) myeflags = eflags | EXEC_FLAG_REWIND | EXEC_FLAG_BACKWARD; else myeflags = eflags; ExecutorStart(queryDesc, myeflags); /* build PlanState tree; don't run */ portal->queryDesc = queryDesc; portal->tupDesc = queryDesc->tupDesc; portal->atStart = true; portal->atEnd = false; portal->portalPos = 0; PopActiveSnapshot(); break; /* ONE_RETURNING / ONE_MOD_WITH: set tupDesc only, run later */ /* UTIL_SELECT: tupDesc from UtilityTupleDescriptor */ /* MULTI_QUERY: tupDesc = NULL, nothing to do now */}portal->status = PORTAL_READY;CURSOR_OPT_SCROLL 분기가 스크롤 가능 커서에 힘을 부여하는 지점이다. 익스큐터에 EXEC_FLAG_REWIND | EXEC_FLAG_BACKWARD가 전달되어 플래너/익스큐터가 역방향 및 재위치 fetch를 지원하게 된다. 필요하다면 플래너가 플랜 위에 Material 노드를 쌓는다. PortalStart 전체는 PG_TRY/PG_CATCH로 감싸여 있어 오류 발생 시 포털을 PORTAL_FAILED로 표시한다.
PortalRun과 PortalRunSelect: 멈출 수 있는 풀 (pquery.c)
섹션 제목: “PortalRun과 PortalRunSelect: 멈출 수 있는 풀 (pquery.c)”PortalRun은 fetch당 드라이버다. 단일 질의 전략 네 가지는 PortalRunSelect를 호출하고, PORTAL_MULTI_QUERY는 PortalRunMulti를 한 번 호출하고 포털을 완료 처리한다. 일회성 실행과의 결정적 차이는 PORTAL_ONE_SELECT 전략에서 튜플스토어를 채우지 않는다는 점이다. 익스큐터를 count행만큼 직접 실행하고 포털을 재개 가능 상태로 남긴다.
// PortalRun — src/backend/tcop/pquery.c (condensed, single-query arm)MarkPortalActive(portal);switch (portal->strategy){ case PORTAL_ONE_SELECT: case PORTAL_ONE_RETURNING: case PORTAL_ONE_MOD_WITH: case PORTAL_UTIL_SELECT: /* non-SELECT strategies materialize into holdStore first */ if (portal->strategy != PORTAL_ONE_SELECT && !portal->holdStore) FillPortalStore(portal, isTopLevel); nprocessed = PortalRunSelect(portal, true, count, dest); /* ... copy command tag ... */ portal->status = PORTAL_READY; /* resumable: back to READY */ result = portal->atEnd; /* DONE only if we hit the end */ break; case PORTAL_MULTI_QUERY: PortalRunMulti(portal, isTopLevel, false, dest, altdest, qc); MarkPortalDone(portal); result = true; break;}PortalRunSelect에서 이터레이터가 실제로 전진한다. 홀드 커서나 완료된 유틸리티 질의의 경우 RunFromStore로 튜플스토어에서 읽는다. 그 외에는 포털의 스냅샷을 푸시하고 요청된 방향과 개수로 ExecutorRun을 호출한 다음 es_processed로 커서 위치를 갱신한다.
// PortalRunSelect — src/backend/tcop/pquery.c (condensed, forward arm)queryDesc = portal->queryDesc;if (forward){ if (portal->atEnd || count <= 0) direction = NoMovementScanDirection, count = 0; else direction = ForwardScanDirection; if (count == FETCH_ALL) count = 0; /* executor: 0 = all rows */ if (portal->holdStore) nprocessed = RunFromStore(portal, direction, (uint64) count, dest); else { PushActiveSnapshot(queryDesc->snapshot); ExecutorRun(queryDesc, direction, (uint64) count); nprocessed = queryDesc->estate->es_processed; PopActiveSnapshot(); } if (!ScanDirectionIsNoMovement(direction)) { if (nprocessed > 0) portal->atStart = false; /* can go backward now */ if (count == 0 || nprocessed < (uint64) count) portal->atEnd = true; /* retrieved them all */ portal->portalPos += nprocessed; }}ExecutorRun이 튜플 개수를 준수하고 익스큐터의 PlanState 트리가 호출 사이에 반복 상태를 유지하기 때문에(postgres-executor.md 참조), 동일한 PORTAL_ONE_SELECT 포털에 PortalRun(portal, n, ...)을 반복 호출하면 결과 집합을 n행씩 전진한다. 커서 FETCH n과 프로토콜 Execute(n) 의미 구조가 정확히 그것이다. 역방향 arm은 CURSOR_OPT_NO_SCROLL일 때 역방향 fetch에 오류를 발생시킨다.
비순차 커서 연산(FETCH ABSOLUTE, FETCH BACKWARD ALL, MOVE)은 PortalRunFetch → DoPortalRunFetch를 거친다. FetchDirection과 개수를 순방향/역방향 PortalRunSelect 호출 시퀀스로 변환하고, 시작 위치로 재이동할 때는 DoPortalRewind를 사용한다. 이 모두가 동일한 멈춤/재개 기본 요소 위에 구축된다.
PortalDrop: 해제 (portalmem.c)
섹션 제목: “PortalDrop: 해제 (portalmem.c)”PortalDrop은 대칭적인 해제 함수다. 핀 고정되거나 아직 PORTAL_ACTIVE 상태인 포털의 해제를 거부하고, 정리 훅을 실행하며(익스큐터가 살아있으면 ExecutorEnd를 호출해 PlanState 트리 해제), 보유된 스냅샷을 등록 해제하고, 캐시드 플랜 참조 카운트를 감소시키며, 포털의 메모리 컨텍스트를 삭제한다.
// PortalDrop — src/backend/utils/mmgr/portalmem.c (condensed)if (portal->portalPinned) ereport(ERROR, (errcode(ERRCODE_INVALID_CURSOR_STATE), errmsg("cannot drop pinned portal \"%s\"", portal->name)));if (portal->status == PORTAL_ACTIVE) ereport(ERROR, (errcode(ERRCODE_INVALID_CURSOR_STATE), errmsg("cannot drop active portal \"%s\"", portal->name)));if (PointerIsValid(portal->cleanup)){ portal->cleanup(portal); /* PortalCleanup → ExecutorEnd */ portal->cleanup = NULL;}PortalHashTableDelete(portal);PortalReleaseCachedPlan(portal); /* drop the CachedPlan refcount */PortalReleaseCachedPlan은 ExecuteQuery의 참조 카운트 계약의 나머지 절반이다. GetCachedPlan이 증가시키고 PortalDefineQuery가 소유권을 가져간 참조 카운트가 여기서 해제된다. 마지막 참조였다면 CachedPlan이 해제된다. CachedPlanSource가 여전히 링크하는 범용 플랜은 소스 자신의 참조가 다음 EXECUTE를 위해 살아있게 유지한다.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
PortalData (struct) | src/include/utils/portal.h | 115 |
PortalStrategy (enum) | src/include/utils/portal.h | 89 |
PortalStatus (enum) | src/include/utils/portal.h | 103 |
CachedPlanSource (struct) | src/include/utils/plancache.h | 105 |
CachedPlan (struct) | src/include/utils/plancache.h | 159 |
PlanCacheMode (enum) | src/include/utils/plancache.h | 31 |
ChoosePortalStrategy | src/backend/tcop/pquery.c | 210 |
PortalStart | src/backend/tcop/pquery.c | 434 |
PortalRun | src/backend/tcop/pquery.c | 685 |
PortalRunSelect | src/backend/tcop/pquery.c | 864 |
FillPortalStore | src/backend/tcop/pquery.c | 995 |
RunFromStore | src/backend/tcop/pquery.c | 1056 |
PortalRunMulti | src/backend/tcop/pquery.c | 1185 |
PortalRunFetch | src/backend/tcop/pquery.c | 1377 |
DoPortalRunFetch | src/backend/tcop/pquery.c | 1475 |
DoPortalRewind | src/backend/tcop/pquery.c | 1669 |
PrepareQuery | src/backend/commands/prepare.c | 59 |
ExecuteQuery | src/backend/commands/prepare.c | 150 |
EvaluateParams | src/backend/commands/prepare.c | 281 |
StorePreparedStatement | src/backend/commands/prepare.c | 392 |
FetchPreparedStatement | src/backend/commands/prepare.c | 434 |
DropPreparedStatement | src/backend/commands/prepare.c | 519 |
CreateCachedPlan | src/backend/utils/cache/plancache.c | 183 |
CompleteCachedPlan | src/backend/utils/cache/plancache.c | 391 |
SaveCachedPlan | src/backend/utils/cache/plancache.c | 530 |
RevalidateCachedQuery | src/backend/utils/cache/plancache.c | 667 |
CheckCachedPlan | src/backend/utils/cache/plancache.c | 935 |
BuildCachedPlan | src/backend/utils/cache/plancache.c | 1019 |
choose_custom_plan | src/backend/utils/cache/plancache.c | 1158 |
cached_plan_cost | src/backend/utils/cache/plancache.c | 1215 |
GetCachedPlan | src/backend/utils/cache/plancache.c | 1280 |
ReleaseCachedPlan | src/backend/utils/cache/plancache.c | 1403 |
CachedPlanIsSimplyValid | src/backend/utils/cache/plancache.c | 1563 |
PlanCacheRelCallback | src/backend/utils/cache/plancache.c | 2098 |
PlanCacheObjectCallback | src/backend/utils/cache/plancache.c | 2182 |
CreatePortal | src/backend/utils/mmgr/portalmem.c | 175 |
PortalDefineQuery | src/backend/utils/mmgr/portalmem.c | 282 |
PortalDrop | src/backend/utils/mmgr/portalmem.c | 468 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”아래 모든 항목은 /data/hgryoo/references/postgres의 REL_18_STABLE 작업 트리(커밋 273fe94852b3a7e34fd171e8abdf1481beb302fa)에서 확인했다.
- 다섯 가지
PortalStrategy값 —portal.h의PortalStrategy열거형에 정확히PORTAL_ONE_SELECT,PORTAL_ONE_RETURNING,PORTAL_ONE_MOD_WITH,PORTAL_UTIL_SELECT,PORTAL_MULTI_QUERY가 있다. 확인됨. - 여섯 가지
PortalStatus값 —PORTAL_NEW,PORTAL_DEFINED,PORTAL_READY,PORTAL_ACTIVE,PORTAL_DONE,PORTAL_FAILED. ACTIVE→READY 역방향 전이는portal.h헤더 주석에 문서화돼 있다(“It is possible to transit from ACTIVE back to READY if the query is not run to completion”). 확인됨. PORTAL_ONE_SELECT만 증분 실행 —PortalRun이PORTAL_ONE_SELECT를 제외한 모든 단일 질의 전략에FillPortalStore를 호출한다(portal->strategy != PORTAL_ONE_SELECT && !portal->holdStore).pquery.c에서 확인됨.- “5” 워밍업 상수 —
choose_custom_plan이plansource->num_custom_plans < 5인 동안true를 반환한다. 리터럴5와 “Generate custom plans until we have done at least 5 (arbitrary)” 주석이 있다. 확인됨. generic_cost < avg_custom_cost일 때만 범용 선택 —choose_custom_plan의 최종 비교는if (plansource->generic_cost < avg_custom_cost) return false;이며avg_custom_cost = total_custom_cost / num_custom_plans다. 확인됨.- 계획 비용 부과 —
cached_plan_cost는include_planner가 true일 때만1000.0 * cpu_operator_cost * (nrelations + 1)를 추가한다.GetCachedPlan은 커스텀 플랜에true, 범용 플랜에false로 호출한다. 확인됨. choose_custom_plan이중 호출 —GetCachedPlan이 아무것도 구축하기 전 한 번, 범용 플랜을 구축하고generic_cost를 설정한 직후 두 번째로choose_custom_plan을 호출한다. 확인됨.plan_cache_modeGUC —PlanCacheMode열거형에PLAN_CACHE_MODE_AUTO,PLAN_CACHE_MODE_FORCE_GENERIC_PLAN,PLAN_CACHE_MODE_FORCE_CUSTOM_PLAN이 있으며,guc_tables.c에 기본값PLAN_CACHE_MODE_AUTO로 등록돼 있다. 확인됨.- 참조 카운트 계약 —
GetCachedPlan이 반환 전plan->refcount++를 수행하고,ExecuteQuery에 “DO NOT add any logic that could possibly throw an error between GetCachedPlan and PortalDefineQuery” 경고 주석이 있으며,PortalDrop이PortalReleaseCachedPlan을 호출한다. 확인됨. - 지연 무효화 —
PlanCacheRelCallback/PlanCacheObjectCallback이is_valid = false를 설정한다(재구축하지 않음).RevalidateCachedQuery와CheckCachedPlan이 다음 사용 시 재구축을 수행한다. 확인됨. - PREPARE의
fixed_result—PrepareQuery가CompleteCachedPlan에fixed_result로true를 전달하고,ExecuteQuery가 false인 경우 “EXECUTE does not support variable-result cached plans” 오류를 발생시킨다. 확인됨.
별도로 재검증하지 않은 항목(소스 주석이 주장하는 수준에서 설명, 형제 문서로 위임): ExecutorStart/ExecutorRun 내부(postgres-executor.md), 플랜 캐시 콜백 뒤의 sinval 전달 메커니즘(postgres-cache-invalidation.md), 동일 함수에 도달하는 와이어 프로토콜 Parse/Bind/Execute 경로(postgres-wire-protocol.md).
PostgreSQL 너머 — 비교 설계와 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”PostgreSQL의 플랜 캐시는 엔진 내에서 가장 흥미로운 설계 선택 중 하나이며, 여러 활발한 연구·엔지니어링 흐름의 교차점에 있다. 이 절은 범용-vs-커스텀 휴리스틱을 문맥에 놓는다.
잘못된 결정의 비용: 파라미터 민감 플랜
섹션 제목: “잘못된 결정의 비용: 파라미터 민감 플랜”범용-vs-커스텀 문제 전체는 문헌에서 파라미터 민감 질의(PSQ) 또는 파라미터 질의 최적화(PQO) 문제로 불리는 것의 특수한 경우다. 최적 플랜이 바인드 값에 따라 달라지는 질의 템플릿이다. 전형적인 실패 모드는 “편향된 선택도”다. 평균 파라미터에 맞게 선택된 범용 플랜이 아웃라이어 파라미터에서 재앙이 되거나 그 반대가 된다. PostgreSQL의 휴리스틱은 의도적으로 단순하다. 어떤 파라미터가 관찰됐는지를 모델링하지 않고 구축한 커스텀 플랜의 평균 비용만 추적한다. 따라서 실행 절반은 플랜 A를, 나머지 절반은 플랜 B를 원하는 상황을 감지할 수 없다. 평균 비용을 내어 어느 쪽도 잘 서비스하지 못하는 평범한 범용 플랜에 정착할 수 있다. 상용 시스템은 이를 더 공격적으로 다룬다.
- SQL Server는 파라미터 스니핑을 사용한다. 첫 번째 바인드 값으로 계획하고 그 플랜을 캐싱한다. 빠르지만 첫 값이 비대표적일 때 병리적이다(악명 높은 “파라미터 스니핑 문제”). 최근 버전에서 Query Store와 적응형 조인으로 완화하고, 범용 플랜을 강제하는 것과 도덕적으로 동등한
OPTIMIZE FOR UNKNOWN힌트를 추가했다. - Oracle은 적응형 커서 공유와 카디널리티 피드백을 제공한다. 동일한 SQL이 서로 다른 바인드에서 매우 다른 카디널리티를 생성함을 관찰하면, 단일 공유 커서에서 바인드 인식 커서로 에스컬레이션해 바인드 값 버킷으로 키된 문장당 여러 플랜을 캐싱한다. PostgreSQL의 단일 범용 플랜보다 엄밀히 더 강력하지만, 더 큰 플랜 캐시와 더 많은 부기 비용이 든다.
- DB2 LUW의 바인드 변수 피킹도 유사한 영역이다.
PostgreSQL은 pgsql-hackers에서 파라미터 범위로 키된 다중 플랜 캐싱을 주기적으로 논의했다. 반복되는 반론은 부기와 캐시 비대 비용이 단순한 “5회 이상이면 평균 비교” 규칙 대비 정당화하기 어렵다는 것이다. 상수가 리터럴 5이고 주석이 “(arbitrary)“인 것은 이것이 최적화된 수치가 아닌 엔지니어링 실용주의임을 솔직하게 인정한다.
연구: 학습과 피드백 기반 플랜 선택
섹션 제목: “연구: 학습과 피드백 기반 플랜 선택”학문적 전선은 정적 비용 비교를 훨씬 넘어섰다. 적응형 질의 처리(Deshpande, Ives, Raman, FnT in Databases 2007)는 문제 전체를 재구성한다. 하나의 플랜에 커밋하는 대신, 관찰된 선택도를 기반으로 런타임에 연산자 사이에서 튜플을 재라우팅한다. eddy 연산자가 대표 예다. 학습된 비용 모델과 학습된 최적화기(Marcus 등, “Neo”, VLDB 2019; “Bao”, SIGMOD 2021)는 분석적 비용 모델을 과거 실행으로 훈련된 모델로 완전히 대체하며, PostgreSQL의 평균화가 버리는 파라미터 민감성을 원칙적으로 학습할 수 있다. 이것은 코어 PostgreSQL에 없지만, pg_hint_plan과 AQO 확장(aqo, 실제 카디널리티를 비용 모델에 피드백), 그리고 choose_custom_plan이 도달 가능한 플래너 훅 표면이 PostgreSQL을 이런 실험의 공통 호스트로 만든다. CachedPlanSource(템플릿)와 CachedPlan(구체적 플랜)을 깔끔하게 분리한 플랜 캐시 구조가 바로 그런 확장이 필요로 하는 접합부다.
컨테이너 설계: 포털 vs. 다른 엔진의 서버 측 커서
섹션 제목: “컨테이너 설계: 포털 vs. 다른 엔진의 서버 측 커서”포털 추상화 — 자체 리소스 오너와 스냅샷을 가진 명명된 멈출 수 있는 실행 컨테이너 — 는 PostgreSQL에서 유독 명시적이다. 많은 엔진이 “커서”, “준비된 문장 핸들”, “활성 문장”을 단일 클라이언트 가시 핸들로 합치고 멈춤/재개 상태를 네트워크 레이어 내부에 유지한다. PostgreSQL이 포털을 1등급 서버 객체로 만든 선택이 SQL DECLARE CURSOR, 프로토콜의 익명 및 명명 포털, PL/pgSQL의 암묵적 커서를 동일한 기계로 균일하게 서비스하게 한다. pg_cursors가 이것들을 시스템 뷰로 노출할 수 있는 이유다. 비용은 PortalDrop과 HoldPinnedPortals에 보이는 신중한 스냅샷 및 리소스 오너 부기다. HoldPinnedPortals는 핀 고정 포털을 홀드 커서 — 튜플스토어에 물질화된 결과 — 로 변환해 트랜잭션 커밋을 살아남게 한다. WITH HOLD 커서가 의존하는 기능이다.
준비된 문장과 커넥션 풀러
섹션 제목: “준비된 문장과 커넥션 풀러”실용적인 전선은 엔진 외부에 있다. 커넥션 풀러다. prepared_queries가 백엔드별 해시 테이블이기 때문에, 풀링된 백엔드 하나에서 생성된 준비된 문장은 다른 백엔드에는 존재하지 않는다. 트랜잭션 풀링 풀러(PgBouncer transaction 모드)는 이 이유로 역사적으로 서버 측 준비된 문장을 깨뜨렸다. PgBouncer 1.21+는 프로토콜 레벨에서 Parse/Bind/Close를 추적하고 실제 백엔드에서 재준비하는 준비된 문장 에뮬레이션 레이어를 추가했다. 여기서 문서화한 설계의 직접적 결과다. 플랜 캐시의 수명은 백엔드의 수명이며, plancache.c의 어느 것도 버퍼 풀이나 락 테이블과 달리 백엔드 사이에 공유되지 않는다. 여러 차례 제안된 진정한 공유 플랜 캐시는 백엔드 간 무효화와 메모리 계산 문제를 해결해야 하며, 그것이 아직 구현되지 않은 이유다.
PostgreSQL이 의도적으로 하지 않는 것
섹션 제목: “PostgreSQL이 의도적으로 하지 않는 것”- 문장 간 플랜 공유 없음. 서로 다른 백엔드에서 온 텍스트가 동일한 두 임시 질의는 각자 처음부터 계획한다. Oracle의 공유 풀이나 SQL Server의 플랜 캐시 방식의 전역 SQL 텍스트 → 플랜 캐시가 없다. 플랜 캐시는 PREPARE / 확장 프로토콜 / SPI 경로에서만 선택적으로 동작하며, 단순 질의 경로에는 자동으로 적용되지 않는다.
- 플랜 안정성 / 플랜 기준선 없음. 알려진 좋은 플랜을 회귀에 대비해 고정하는 내장 메커니즘이 없다(Oracle의 SQL Plan Management). 가장 가까운 레버는
plan_cache_mode와pg_hint_plan이다. - 파라미터 버킷별 플랜 없음. 앞서 논의한 대로 범용 플랜 하나만 존재한다.
이것들은 PostgreSQL의 일반적 철학과 일관된다. 큰 기능 표면 대신 확장 지점을 가진 작고 예측 가능한 코어. 익스큐터 훅 설계(postgres-executor.md)에서 보이는 것과 같은 철학이다.
- 코드 (REL_18_STABLE, 커밋
273fe94,/data/hgryoo/references/postgres):src/backend/tcop/pquery.c— 포털 실행 루프:ChoosePortalStrategy,PortalStart,PortalRun,PortalRunSelect,PortalRunMulti,PortalRunFetch,FillPortalStore.src/backend/commands/prepare.c— SQLPREPARE/EXECUTE/DEALLOCATE:PrepareQuery,ExecuteQuery,EvaluateParams,StorePreparedStatement,FetchPreparedStatement,prepared_queries해시 테이블.src/backend/utils/cache/plancache.c— 플랜 캐시:CreateCachedPlan,CompleteCachedPlan,GetCachedPlan,choose_custom_plan,cached_plan_cost,RevalidateCachedQuery,CheckCachedPlan,BuildCachedPlan, 무효화 콜백.src/backend/utils/mmgr/portalmem.c— 포털 할당과 해제:CreatePortal,PortalDefineQuery,PortalDrop,HoldPinnedPortals.src/include/utils/portal.h,src/include/utils/plancache.h—PortalData,CachedPlanSource,CachedPlan구조체와 헤더 주석 (헤더 주석 자체가 설계 문서다).
- 이론 앵커: Database System Concepts(Silberschatz, Korth, Sudarshan, 7판),
15장 “Query Processing”(§15.1 파스/최적화/평가 파이프라인; §15.7.2 파이프라인 평가)과
16장 “Query Optimization”(§16.5 비용 기반 플랜 선택) —
knowledge/research/dbms-general/에 수록..omc/plans/postgres-paper-bibliography.md도 참조. - 교차 참조 (이 KB):
postgres-executor.md(포털 내에서ExecutorStart/ExecutorRun이 하는 일),postgres-backend-lifecycle.md(백엔드가PortalRun으로 디스패치하는 방법),postgres-wire-protocol.md(동일 플랜 캐시에 도달하는 Parse/Bind/Execute 확장 질의 경로),postgres-cache-invalidation.md(플랜 캐시 콜백 뒤의 sinval 메커니즘),postgres-planner-overview.md(BuildCachedPlan이 호출하는 것). - 비교 / 전선 독서 (인용이 아닌 참조): Deshpande, Ives, Raman, “Adaptive Query Processing”
(Foundations and Trends in Databases, 2007); Marcus 등, “Neo: A Learned Query Optimizer”
(VLDB 2019) 및 “Bao” (SIGMOD 2021); PostgreSQL
aqo와pg_hint_plan확장; SQL Server 파라미터 스니핑 / Query Store 및 Oracle 적응형 커서 공유 문서.