콘텐츠로 이동

(KO) PostgreSQL SPI — 서버 프로그래밍 인터페이스: PL과 확장이 백엔드 내부에서 SQL을 실행하는 방법

목차


모든 관계형 엔진은 결국 같은 설계 질문에 맞닥뜨린다. 서버 내부에서 실행 중인 코드가 어떻게 SQL을 제출하는가? 와이어 프로토콜(PQexec, 확장 질의 프로토콜의 Parse/Bind/Execute 메시지)은 외부 클라이언트, 즉 소켓으로 백엔드에 연결된 별개의 프로세스를 위한 답이다. 그러나 저장 프로시저나 트리거 함수, 또는 절차적 언어 핸들러는 별개의 프로세스가 아니다. 이들은 백엔드에 링크된 C 코드로서 백엔드 자신의 스택 위에서 실행되며, 백엔드의 트랜잭션·스냅샷·락 테이블·메모리 컨텍스트를 공유한다. 이런 코드가 소켓으로 되돌아가는 것은 비합리적이다. 질의를 바이트로 직렬화해 자기 자신에게 보내고 결과를 다시 파싱해야 하기 때문이다. 필요한 것은 이미 내부에 앉아 있는 파서/플래너/익스큐터 파이프라인에 대한 함수 호출 인터페이스다.

이것이 내장 질의(embedded query) 문제다. 이 문제는 저장 프로시저만큼이나 오래됐다. SQL 표준의 답은 내장 SQL(EXEC SQL 전처리기를 쓰는 ESQL/C)과 모듈 언어이며, 실용적 답은 모든 주요 엔진이 제공하는 세 가지 환원 불가능한 기본 기능을 갖춘 C 수준 라이브러리다.

  1. 세션/연결 핸들 — “나는 이제 질의를 발행하는 주체다; 필요한 상태를 할당하라”고 선언하는 방법과 그것을 해제하는 해체자(teardown). 내장 코드는 중첩될 수 있으므로(어떤 함수가 SQL을 발행하는 다른 함수를 호출) 이 핸들은 자연스럽게 스택이 된다.
  2. 준비/실행 분리 — 문장을 한 번 파스·플랜하고 다른 파라미터 값으로 여러 번 실행하는 능력. 전형적인 사용처는 같은 INSERT를 수만 번 실행하는 PL 함수 루프 본문이기 때문이다.
  3. 결과 커서 — 함수의 주소 공간에 수백만 행을 한꺼번에 적재하는 대신 한 번에 한 행(또는 한 배치)씩 대용량 결과셋을 순회하는 방법.

더 깊은 이론은 실행 컨텍스트 격리에 관한 것이다. 함수 f(질의 Q1 발행)가 함수 g(질의 Q2 발행)를 호출할 때, 두 질의는 “현재 결과”, “현재 스냅샷”, “내 튜플이 사는 메모리”에 대한 서로의 개념을 오염시키면 안 된다. 교재(Database System Concepts, 애플리케이션 개발과 저장/버퍼 인터페이스 챕터, dbms-general/database-system-concepts.md에 정리됨)는 이것을 공유 전역 상태에서의 재진입성(reentrancy) 문제라고 규정한다. API는 인체공학적으로 전역 변수로 “가장 최근 결과”를 노출하지만(SPI_tuptable은 단순 전역이어서 호출자가 핸들을 모든 행에 넘겨야 할 필요가 없다), 구현은 모든 중첩 경계에서 그 전역 변수를 저장·복원해야 한다. 이는 CPU가 피호출자 레지스터를 저장하는 것과 정확히 같다.

두 번째 이론축은 플랜 캐싱이다. 문장을 준비하면 재사용할 수 있는 범용 또는 커스텀 플랜이 생성되지만, DDL·search_path 변경·통계 변동이 끼어드는 실행들에 걸쳐 재사용하려면 재검증이 필요하다. 캐시된 플랜은 각 사용 시점에 확인·필요시 재플랜 과정을 거쳐야 한다. PostgreSQL은 이것을 별도의 plancache 서브시스템(postgres-portals-prepared.md)으로 팩터링했고, SPI는 그 주요 클라이언트 중 하나다. 따라서 “SPI가 SQL을 실행하는 방법”은 “플랜 캐시가 재사용과 재구축 중 어느 것을 선택하는지”와 분리할 수 없다.

세 번째 축 — SPI를 단순한 사무 처리 이상으로 만드는 진짜 어려움 — 은 내장 경계를 넘나드는 스냅샷·트랜잭션 관리다. 읽기 전용 함수(IMMUTABLE/STABLE)는 외부 질의가 지속되는 동안 안정된 스냅샷을 봐야 하므로 문장마다 새 스냅샷을 얻으면 안 된다. 반면 VOLATILE 함수는 자신의 이전 수정 결과를 봐야 하므로 커맨드 카운터를 전진시키고 다시 스냅샷을 찍는다. CALL로 호출한 프로시저(또는 DO 블록)는 본문 중간에 합법적으로 COMMIT할 수 있어, SPI가 프로시저 자신의 상태를 잃지 않으면서 주변 트랜잭션을 종료하고 재시작할 수 있어야 한다. 이 세 가지 스냅샷 체제와 트랜잭션 제어 케이스가 SPI 익스큐터 루프의 실질적 내용이다.


PostgreSQL의 심볼을 보기 전에, 모든 서버 측 SQL API가 수렴하는 관례를 짚어 두는 것이 좋다. 그래야 PostgreSQL의 구체적인 내용이 공유된 설계 공간 안의 선택으로 읽힌다.

서버 측 SQL API는 재진입 가능해야 한다. 내장 코드가 내장 코드를 호출하기 때문이다. 보편적 해결책은 실행 프레임의 스택이다. connect마다 하나를 쌓고 finish마다 하나를 팝하며, 각 프레임은 자신을 위해 할당된 리소스를 소유한다. 스택 구조를 쓰는 이유는 외부 프레임의 결과가 내부 프레임이 실행·해체되는 동안 온전히 유지되어야 하기 때문이다.

경계에서 전역 변수를 스냅샷한다

섹션 제목: “경계에서 전역 변수를 스냅샷한다”

인체공학적 이유로 이 API들은 “가장 최근 결과”를 주변 상태로 노출해 호출자가 모든 접근자에 핸들을 넘겨야 하는 부담을 없앤다. 구현 비용은 connect 경로가 외부 전역 변수를 스냅샷하고 finish 경로가 복원해야 한다는 점이다. 이렇게 해야 중첩 호출이 호출자에게 투명하게 된다. 이것은 내장 SQL 버전의 호출자/피호출자 레지스터 저장 규약이다.

두 가지 실행 모드가 보편적이다.

  • 단발성: 질의 문자열을 넘기면 API가 파스·플랜·실행 후 플랜을 버린다. 호출 비용은 낮지만 호출당 비용이 높다. 한 번만 실행할 문장에 적합하다.
  • 준비된: 한 번 파스·플랜해 재사용 가능한 핸들을 만들고, 이후 바인딩된 파라미터로 핸들을 반복 실행한다. 설정 비용은 높지만 반복 비용은 낮다. 루프 본문에 적합하다.

성숙한 API는 일시적(transient) 준비 플랜(현재 작업이 끝나면 수명이 다함)과 저장된(saved) 플랜(오래 지속되는 캐시 컨텍스트에 부모를 둠, 호출 사이에 유지)을 추가로 구분한다. 수명 결정은 명시적이다. 왜냐하면 메모리와 재플랜 비용을 맞바꾸는 결정이기 때문이다.

두 가지 전달 형태가 반복된다. **구체화 수신기(materializing receiver)**는 전체 결과셋을 호출자 메모리의 테이블 형태 구조에 누적한다. 단순하지만 크기에 제한이 없다. 커서/포털은 플랜을 지연 실행해 호출자가 배치 단위로 행을 가져가게 한다. 메모리가 제한되며 대용량 결과를 루프로 스트리밍하기에 적합하다. 서버 측 API는 두 가지를 모두 제공하고 PL이 선택하게 한다.

내장 질의는 대개 호출자의 트랜잭션 안에서, 읽기 전용 호출자에게는 호출자의 스냅샷 아래에서 실행된다. 자신의 트랜잭션을 시작하지 않는다. 스냅샷을 찍어 자신의 이전 쓰기를 볼지, 기존 스냅샷을 재사용해 안정성을 유지할지는 단일한 가장 중요한 정확성 조절 장치로, SQL을 발행하는 함수의 휘발성(volatility) 분류에 의해 결정된다.

flowchart TD
  PL["PL handler / extension C code<br/>(already inside a backend)"] -->|"SPI_connect"| STK["push _SPI_connection frame<br/>(stack, snapshots globals)"]
  STK -->|"SPI_execute(src)"| ONE["one-shot:<br/>parse + rewrite + plan + execute"]
  STK -->|"SPI_prepare(src)"| PREP["build CachedPlanSource<br/>(transient SPIPlan)"]
  PREP -->|"SPI_keepplan"| SAVE["reparent into CacheMemoryContext<br/>(saved plan, reusable)"]
  SAVE -->|"SPI_execute_plan(plan, params)"| RUN["revalidate plan + execute"]
  STK -->|"SPI_cursor_open(plan)"| CUR["hand plan to Portal<br/>fetch incrementally"]
  ONE --> TT["SPITupleTable<br/>(SPI_processed / SPI_tuptable)"]
  RUN --> TT
  CUR -->|"SPI_cursor_fetch"| TT
  TT -->|"SPI_finish"| POP["pop frame, free contexts,<br/>restore outer globals"]

PostgreSQL은 위의 모든 관례를 단일 파일 src/backend/executor/spi.c에 구현했다. 작은 전용 헤더 src/include/executor/spi_priv.h가 이를 보조한다. 전체 설계는 네 개의 데이터 구조와 전역 스택을 중심으로 돌아간다.

연결 스택. SPI는 프로세스 전역의 동적 증가 배열 _SPI_stack_SPI_connection 프레임의 저장소로 사용한다. _SPI_connected가 스택 상단 인덱스를, _SPI_current가 그것을 가리키는 포인터다. SPI_connect마다 프레임을 하나 쌓고 SPI_finish마다 팝한다. 스택은 이동할 수 있다는 점이 중요하다. 용량이 부족하면 repalloc으로 재할당되므로, SPI에 재진입하는 코드는 중첩 호출 후 _SPI_current를 반드시 다시 가져와야 한다. 소스는 이 위험을 _SPI_cursor_operation에서 명시적으로 언급한다.

프레임당 두 메모리 컨텍스트. 모든 프레임은 procCxt(“SPI Proc”)와 execCxt(“SPI Exec”)를 소유한다. 이 분리가 SPI 수명 모델의 핵심이다. exec 컨텍스트는 모든 SPI 호출 후 리셋되어(파스/플랜/실행 스크래치를 담음) 한 번의 호출 범위로 제한되고, proc 컨텍스트는 SPI_finish까지 유지된다(호출자가 읽을 결과, 즉 SPITupleTable과 일시 플랜을 담음). 원자적(atomic) 컨텍스트에서는 둘 다 TopTransactionContext의 자식이고, 비원자적 컨텍스트(커밋할 수 있는 프로시저)에서는 트랜잭션 경계를 넘길 수 있도록 PortalContext 아래에 매달린다.

스냅샷하는 세 API 전역 변수. SPI_processed(행 수), SPI_tuptable(결과 테이블), SPI_result(오류 코드)는 단순 전역 변수다. SPI_connect_ext가 외부 값을 outer_processed, outer_tuptable, outer_result에 저장하고 현재 값을 초기화한다. SPI_finish가 이를 복원한다. 이 메커니즘이 중첩을 투명하게 만든다.

SPITupleTable. 결과는 SPITupleTable에 구체화된다. 이 구조체는 자체 자식 컨텍스트(tuptabcxt, “SPI TupTable”)에 palloc0으로 할당되며, 증가 가능한 vals[] HeapTuple 배열과 tupdesc를 보유한다. DestSPI 수신기 — spi_dest_startup이 테이블을 할당하고, spi_printtup이 각 튜플을 추가 — 가 익스큐터 출력을 여기로 스트리밍한다. 살아있는 모든 tuptable은 프레임의 tuptables slist에 연결되어 서브트랜잭션 abort 시 고아 객체를 해제할 수 있다.

플랜은 plancache 항목이다. SPIPlanPtr(_SPI_plan)은 CachedPlanSource 목록의 얇은 래퍼다. 일시적 플랜은 execCxt에 살며 호출 종료 시 소멸한다. _SPI_make_plan_non_temp가 그것을 procCxt로 복사하고, SPI_keepplan/_SPI_save_planCacheMemoryContext로 재부모화(reparent)해 호출 간 재사용과 트랜잭션 종료 면역성을 부여한다. 실행은 항상 GetCachedPlan을 거치며, 필요시 재검증과 재플랜이 수행된다.

스냅샷 체제. _SPI_execute_plan은 SPI의 복잡도가 집중된 함수다. 헤더 주석은 (스냅샷 공급 여부?) × (read_only?)로 키된 네 가지 동작을 열거한다. 읽기 전용 호출자에게는 활성 스냅샷을 재사용하고, 읽기-쓰기에게는 커맨드 카운터를 전진시켜 문장마다 새 트랜잭션 스냅샷을 찍으며, 비원자 실행에서는 포털의 스냅샷에 의존해 프로시저 중간 COMMIT이 가능하게 한다.

flowchart TD
  EP["_SPI_execute_plan(plan, options,<br/>snapshot, crosscheck)"] --> Q{"snapshot != InvalidSnapshot?"}
  Q -->|"yes, read_only"| PUSH1["PushActiveSnapshot(snapshot)<br/>one push for whole plan"]
  Q -->|"yes, read-write"| PUSH2["PushCopiedSnapshot(snapshot)<br/>advance CID per querytree"]
  Q -->|"no (InvalidSnapshot)"| LOOP["per CachedPlanSource:"]
  LOOP --> NEED{"stmt requires snapshot?"}
  NEED -->|"no"| SKIP["run with no snapshot"]
  NEED -->|"yes"| ENS["EnsurePortalSnapshotExists()"]
  ENS --> RW{"read-write &amp; atomic?"}
  RW -->|"yes"| FRESH["PushActiveSnapshot(GetTransactionSnapshot())<br/>fresh per statement-list"]
  RW -->|"no / nonatomic"| PORTAL["use Portal snapshot unmodified"]
  PUSH1 --> EXEC["foreach stmt: CreateQueryDesc + _SPI_pquery<br/>or ProcessUtility"]
  PUSH2 --> EXEC
  FRESH --> EXEC
  PORTAL --> EXEC
  SKIP --> EXEC

모든 발췌는 REL_18(커밋 273fe94, 2026-06-05 캡처)에서 축약한 것이다. 각 블록은 // symbol — path 주석으로 시작한다. 정확한 행 번호는 이 절 끝의 위치 힌트 표에 모았다.

1. 프레임 쌓기: SPI_connect / SPI_connect_ext

섹션 제목: “1. 프레임 쌓기: SPI_connect / SPI_connect_ext”

SPI_connect는 얇은 래퍼다. 실제 작업은 SPI_connect_ext에서 스택을 늘리고, 프레임을 초기화하며, 외부 전역 변수를 스냅샷한다.

// SPI_connect_ext — src/backend/executor/spi.c
int
SPI_connect_ext(int options)
{
int newdepth;
/* Enlarge stack if necessary */
if (_SPI_stack == NULL)
{
if (_SPI_connected != -1 || _SPI_stack_depth != 0)
elog(ERROR, "SPI stack corrupted");
newdepth = 16;
_SPI_stack = (_SPI_connection *)
MemoryContextAlloc(TopMemoryContext,
newdepth * sizeof(_SPI_connection));
_SPI_stack_depth = newdepth;
}
else
{
if (_SPI_stack_depth == _SPI_connected + 1)
{
newdepth = _SPI_stack_depth * 2;
_SPI_stack = (_SPI_connection *)
repalloc(_SPI_stack, newdepth * sizeof(_SPI_connection));
_SPI_stack_depth = newdepth;
}
}
/* Enter new stack level */
_SPI_connected++;
_SPI_current = &(_SPI_stack[_SPI_connected]);
_SPI_current->processed = 0;
_SPI_current->tuptable = NULL;
slist_init(&_SPI_current->tuptables);
_SPI_current->connectSubid = GetCurrentSubTransactionId();
_SPI_current->atomic = (options & SPI_OPT_NONATOMIC ? false : true);
_SPI_current->internal_xact = false;
_SPI_current->outer_processed = SPI_processed;
_SPI_current->outer_tuptable = SPI_tuptable;
_SPI_current->outer_result = SPI_result;

세 개의 outer_* 필드가 핵심이다. 호출자의 결과가 여기에 숨겨지므로 중첩된 SPI 사용자가 그것이 변경되는 것을 볼 수 없다. SPI_OPT_NONATOMIC에서 파생된 atomic 플래그는 이 프레임이 나중에 트랜잭션을 커밋할 수 있는지를 결정한다.

같은 함수에서 두 메모리 컨텍스트를 만드는데, 부모 선택이 SPI의 트랜잭션 경계 초과 능력의 핵심이다.

// SPI_connect_ext (continued) — src/backend/executor/spi.c
/*
* In atomic contexts (the normal case), we use TopTransactionContext,
* otherwise PortalContext, so that it lives across transaction
* boundaries.
*/
_SPI_current->procCxt = AllocSetContextCreate(
_SPI_current->atomic ? TopTransactionContext : PortalContext,
"SPI Proc", ALLOCSET_DEFAULT_SIZES);
_SPI_current->execCxt = AllocSetContextCreate(
_SPI_current->atomic ? TopTransactionContext : _SPI_current->procCxt,
"SPI Exec", ALLOCSET_DEFAULT_SIZES);
/* ... and switch to procedure's context */
_SPI_current->savedcxt = MemoryContextSwitchTo(_SPI_current->procCxt);
/* Reset API global variables ... */
SPI_processed = 0;
SPI_tuptable = NULL;
SPI_result = 0;
return SPI_OK_CONNECT;
}

비원자 프레임은 두 컨텍스트 모두 TopTransactionContext가 아닌 PortalContext에 부모를 둔다. 프로시저 본문이 COMMIT할 때 proc/exec 컨텍스트가 트랜잭션 리셋 후에도 살아남게 하기 위해서다.

SPI_finish는 거울상이다. 저장된 컨텍스트로 다시 전환하고, 두 컨텍스트를 삭제해(모든 tuptable과 일시 플랜을 해제) 외부 전역 변수를 복원한 뒤 스택 인덱스를 줄인다.

// SPI_finish — src/backend/executor/spi.c
int
SPI_finish(void)
{
int res;
res = _SPI_begin_call(false); /* just check we're connected */
if (res < 0)
return res;
/* Restore memory context as it was before procedure call */
MemoryContextSwitchTo(_SPI_current->savedcxt);
/* Release memory used in procedure call (including tuptables) */
MemoryContextDelete(_SPI_current->execCxt);
_SPI_current->execCxt = NULL;
MemoryContextDelete(_SPI_current->procCxt);
_SPI_current->procCxt = NULL;
/* Restore outer API variables ... */
SPI_processed = _SPI_current->outer_processed;
SPI_tuptable = _SPI_current->outer_tuptable;
SPI_result = _SPI_current->outer_result;
/* Exit stack level */
_SPI_connected--;
if (_SPI_connected < 0)
_SPI_current = NULL;
else
_SPI_current = &(_SPI_stack[_SPI_connected]);
return SPI_OK_FINISH;
}

procCxt가 모든 SPITupleTable을 소유하므로 이를 삭제하면 모든 결과 메모리가 한 번에 회수된다. 튜플별 장부 관리가 해체 시에는 필요 없다. “방금 삭제된 tuptable을 가리키고 있을 SPI_tuptable”이라는 주석이 복원을 삭제 이후에 수행하는 이유다.

3. 호출별 경계: _SPI_begin_call / _SPI_end_call

섹션 제목: “3. 호출별 경계: _SPI_begin_call / _SPI_end_call”

모든 공개 SPI 진입점은 작업을 이 두 헬퍼로 감싼다. 진입 시 execCxt로 전환하고, 종료 시 procCxt로 돌아와 execCxt를 리셋한다. 파스/플랜 스크래치의 수명을 단일 호출로 제한하는 메커니즘이다.

// _SPI_begin_call / _SPI_end_call — src/backend/executor/spi.c
static int
_SPI_begin_call(bool use_exec)
{
if (_SPI_current == NULL)
return SPI_ERROR_UNCONNECTED;
if (use_exec)
{
_SPI_current->execSubid = GetCurrentSubTransactionId();
_SPI_execmem(); /* switch to executor context */
}
return 0;
}
static int
_SPI_end_call(bool use_exec)
{
if (use_exec)
{
_SPI_procmem(); /* switch back to procedure context */
_SPI_current->execSubid = InvalidSubTransactionId;
MemoryContextReset(_SPI_current->execCxt); /* free executor memory */
}
return 0;
}

_SPI_execmem_SPI_procmem은 각각 execCxtprocCxt에 대한 MemoryContextSwitchTo 한 줄짜리 함수다. 그러나 이 둘이 “스크래치는 호출마다 소멸, 결과는 프레임마다 유지”라는 전체 규율을 인코딩한다.

SPI_execute는 스택 로컬 _SPI_plan을 만들고, 이를 oneshot 플랜으로 파스해 InvalidSnapshot(즉, “SPI가 read_only 플래그에 따라 스냅샷을 직접 관리하라”)과 함께 _SPI_execute_plan에 넘긴다.

// SPI_execute — src/backend/executor/spi.c
int
SPI_execute(const char *src, bool read_only, long tcount)
{
_SPI_plan plan;
SPIExecuteOptions options;
int res;
if (src == NULL || tcount < 0)
return SPI_ERROR_ARGUMENT;
res = _SPI_begin_call(true);
if (res < 0)
return res;
memset(&plan, 0, sizeof(_SPI_plan));
plan.magic = _SPI_PLAN_MAGIC;
plan.parse_mode = RAW_PARSE_DEFAULT;
plan.cursor_options = CURSOR_OPT_PARALLEL_OK;
_SPI_prepare_oneshot_plan(src, &plan);
memset(&options, 0, sizeof(options));
options.read_only = read_only;
options.tcount = tcount;
res = _SPI_execute_plan(&plan, &options,
InvalidSnapshot, InvalidSnapshot, true);
_SPI_end_call(true);
return res;
}

플랜은 C 스택에 살고 보조 데이터는 execCxt에 있으므로 _SPI_end_call에서 모두 증발한다. CURSOR_OPT_PARALLEL_OK는 단발성 SELECT에서 병렬 워커 사용을 허용한다.

5. 재사용 가능한 플랜 준비: SPI_prepare_SPI_make_plan_non_temp

섹션 제목: “5. 재사용 가능한 플랜 준비: SPI_prepare와 _SPI_make_plan_non_temp”

SPI_prepare(내부적으로 SPI_prepare_cursor를 경유)는 일시적 _SPI_plan으로 파스한 뒤 이를 procCxt로 복사해 호출이 끝나도 살아남게 한다.

// SPI_prepare_cursor — src/backend/executor/spi.c
SPIPlanPtr
SPI_prepare_cursor(const char *src, int nargs, Oid *argtypes,
int cursorOptions)
{
_SPI_plan plan;
SPIPlanPtr result;
if (src == NULL || nargs < 0 || (nargs > 0 && argtypes == NULL))
{
SPI_result = SPI_ERROR_ARGUMENT;
return NULL;
}
SPI_result = _SPI_begin_call(true);
if (SPI_result < 0)
return NULL;
memset(&plan, 0, sizeof(_SPI_plan));
plan.magic = _SPI_PLAN_MAGIC;
plan.parse_mode = RAW_PARSE_DEFAULT;
plan.cursor_options = cursorOptions;
plan.nargs = nargs;
plan.argtypes = argtypes;
_SPI_prepare_plan(src, &plan);
/* copy plan to procedure context */
result = _SPI_make_plan_non_temp(&plan);
_SPI_end_call(true);
return result;
}

_SPI_make_plan_non_temp는 재부모화(reparenting) 기법을 쓴다. 플랜 트리를 깊은 복사하는 대신, procCxt 아래에 “SPI Plan” 컨텍스트를 만들고 기존 CachedPlanSource를 그 안으로 재부모화한다. 동시에 임시 플랜에서 연결을 끊어 _SPI_end_call의 execCxt 리셋이 그것을 건드리지 못하게 한다.

// _SPI_make_plan_non_temp — src/backend/executor/spi.c
static SPIPlanPtr
_SPI_make_plan_non_temp(SPIPlanPtr plan)
{
SPIPlanPtr newplan;
MemoryContext parentcxt = _SPI_current->procCxt;
MemoryContext plancxt;
ListCell *lc;
Assert(plan->magic == _SPI_PLAN_MAGIC);
Assert(!plan->oneshot); /* one-shot plans can't be saved */
plancxt = AllocSetContextCreate(parentcxt, "SPI Plan",
ALLOCSET_SMALL_SIZES);
MemoryContextSwitchTo(plancxt);
newplan = (SPIPlanPtr) palloc0(sizeof(_SPI_plan));
newplan->magic = _SPI_PLAN_MAGIC;
newplan->plancxt = plancxt;
/* ... copy parse_mode, cursor_options, argtypes ... */
foreach(lc, plan->plancache_list)
{
CachedPlanSource *plansource = (CachedPlanSource *) lfirst(lc);
CachedPlanSetParentContext(plansource, parentcxt); /* reparent */
newplan->plancache_list = lappend(newplan->plancache_list, plansource);
}
/* For safety, unlink the CachedPlanSources from the temporary plan */
plan->plancache_list = NIL;
return newplan;
}

이 시점에서 플랜은 미저장(unsaved) 상태다. procCxt에 살며 SPI_finish에서 소멸한다. 호출 사이에 재사용하려면(PL/pgSQL 플랜 캐시가 함수 본문의 모든 문장에 이를 적용) 호출자가 SPI_keepplan을 호출한다.

6. 저장된 플랜으로 승격: SPI_keepplan

섹션 제목: “6. 저장된 플랜으로 승격: SPI_keepplan”

SPI_keepplan은 플랜 컨텍스트를 CacheMemoryContext까지 재부모화해 트랜잭션 수명을 완전히 초월하게 하고, 각 CachedPlanSource를 저장 상태로 표시해 plancache가 무효화를 추적하게 한다.

// SPI_keepplan — src/backend/executor/spi.c
int
SPI_keepplan(SPIPlanPtr plan)
{
ListCell *lc;
if (plan == NULL || plan->magic != _SPI_PLAN_MAGIC ||
plan->saved || plan->oneshot)
return SPI_ERROR_ARGUMENT;
/*
* Mark it saved, reparent it under CacheMemoryContext, and mark all the
* component CachedPlanSources as saved. This sequence cannot fail
* partway through, so there's no risk of long-term memory leakage.
*/
plan->saved = true;
MemoryContextSetParent(plan->plancxt, CacheMemoryContext);
foreach(lc, plan->plancache_list)
{
CachedPlanSource *plansource = (CachedPlanSource *) lfirst(lc);
SaveCachedPlan(plansource);
}
return 0;
}

“중간에 실패할 수 없다”는 주석이 중요하다. 절반만 저장된 플랜은 CacheMemoryContext에 영구적으로 누수되므로, 이 시퀀스는 의도적으로 할당이 없다.

7. 저장된 플랜 실행: SPI_execute_plan

섹션 제목: “7. 저장된 플랜 실행: SPI_execute_plan”

SPI_execute_plan은 호출자의 위치 기반 Values/Nulls 배열을 ParamListInfo로 변환하고 — 다시 InvalidSnapshot과 함께 — _SPI_execute_plan으로 디스패치한다.

// SPI_execute_plan — src/backend/executor/spi.c
int
SPI_execute_plan(SPIPlanPtr plan, Datum *Values, const char *Nulls,
bool read_only, long tcount)
{
SPIExecuteOptions options;
int res;
if (plan == NULL || plan->magic != _SPI_PLAN_MAGIC || tcount < 0)
return SPI_ERROR_ARGUMENT;
if (plan->nargs > 0 && Values == NULL)
return SPI_ERROR_PARAM;
res = _SPI_begin_call(true);
if (res < 0)
return res;
memset(&options, 0, sizeof(options));
options.params = _SPI_convert_params(plan->nargs, plan->argtypes,
Values, Nulls);
options.read_only = read_only;
options.tcount = tcount;
res = _SPI_execute_plan(plan, &options,
InvalidSnapshot, InvalidSnapshot, true);
_SPI_end_call(true);
return res;
}

8. 엔진 핵심부: _SPI_execute_plan 스냅샷 관리

섹션 제목: “8. 엔진 핵심부: _SPI_execute_plan 스냅샷 관리”

이 함수가 네 가지 스냅샷 체제를 구현한다. 앞부분은 호출자 제공 스냅샷(SPI_execute_snapshot 등 내부에서 사용)을 처리한다.

// _SPI_execute_plan — src/backend/executor/spi.c
static int
_SPI_execute_plan(SPIPlanPtr plan, const SPIExecuteOptions *options,
Snapshot snapshot, Snapshot crosscheck_snapshot,
bool fire_triggers)
{
bool allow_nonatomic;
bool pushed_active_snap = false;
ResourceOwner plan_owner = options->owner;
CachedPlan *cplan = NULL;
ListCell *lc1;
allow_nonatomic = options->allow_nonatomic &&
!_SPI_current->atomic && !IsSubTransaction();
if (snapshot != InvalidSnapshot)
{
Assert(!options->allow_nonatomic);
if (options->read_only)
{
PushActiveSnapshot(snapshot); /* use exactly this snapshot */
pushed_active_snap = true;
}
else
{
PushCopiedSnapshot(snapshot); /* private copy to advance CID */
pushed_active_snap = true;
}
}
/* ... ensure resource owner iff plan->saved ... */

스냅샷이 InvalidSnapshot인 경우(공개 API 경로), 문장별 루프가 각 문장 목록에 스냅샷이 필요한지 판단하고, 읽기-쓰기 원자 실행에서는 문장 목록마다 새 트랜잭션 스냅샷을 찍는다.

// _SPI_execute_plan (per-plansource loop) — src/backend/executor/spi.c
foreach(lc1, plan->plancache_list)
{
CachedPlanSource *plansource = (CachedPlanSource *) lfirst(lc1);
List *stmt_list;
/* ... one-shot plans get parse-analyzed here via CompleteCachedPlan ... */
/* Replan if needed, and increment plan refcount. */
cplan = GetCachedPlan(plansource, options->params,
plan_owner, _SPI_current->queryEnv);
stmt_list = cplan->stmt_list;
if (snapshot == InvalidSnapshot &&
(list_length(stmt_list) > 1 ||
(list_length(stmt_list) == 1 &&
PlannedStmtRequiresSnapshot(linitial_node(PlannedStmt,
stmt_list)))))
{
/* back-fill a Portal snapshot in case prior op was COMMIT/ROLLBACK */
EnsurePortalSnapshotExists();
if (!options->read_only && !allow_nonatomic)
{
if (pushed_active_snap)
PopActiveSnapshot();
PushActiveSnapshot(GetTransactionSnapshot());
pushed_active_snap = true;
}
}
/* ... inner loop over individual PlannedStmts ... */
}

내부 문장 루프에서는 읽기-쓰기 실행이 커맨드 카운터를 전진시켜 각 문장이 이전 문장의 효과를 보게 한 뒤, 익스큐터(_SPI_pquery) 또는 ProcessUtility로 디스패치한다.

// _SPI_execute_plan (inner stmt loop) — src/backend/executor/spi.c
foreach(lc2, stmt_list)
{
PlannedStmt *stmt = lfirst_node(PlannedStmt, lc2);
DestReceiver *dest;
_SPI_current->processed = 0;
_SPI_current->tuptable = NULL;
/* read-only callers may not run a volatile command */
if (options->read_only && !CommandIsReadOnly(stmt))
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("%s is not allowed in a non-volatile function",
CreateCommandName((Node *) stmt))));
/* read-write: advance command counter + snapshot CID per command */
if (!options->read_only && pushed_active_snap)
{
CommandCounterIncrement();
UpdateActiveSnapshotCommandId();
}
if (!stmt->canSetTag)
dest = CreateDestReceiver(DestNone);
else if (options->dest)
dest = options->dest;
else
dest = CreateDestReceiver(DestSPI);
if (stmt->utilityStmt == NULL)
{
QueryDesc *qdesc;
Snapshot snap = ActiveSnapshotSet() ? GetActiveSnapshot()
: InvalidSnapshot;
qdesc = CreateQueryDesc(stmt, plansource->query_string,
snap, crosscheck_snapshot, dest,
options->params,
_SPI_current->queryEnv, 0);
res = _SPI_pquery(qdesc, fire_triggers,
stmt->canSetTag ? options->tcount : 0);
FreeQueryDesc(qdesc);
}
else
/* ProcessUtility(...) for DDL/utility statements */ ;
}

CommandIsReadOnly 검사가 SPI의 함수 휘발성 강제다. STABLE/IMMUTABLE 함수가 INSERT를 실행하려 하면 “비휘발성 함수에서는 허용되지 않음” 오류로 여기서 차단된다.

9. 결과 전달: spi_dest_startupspi_printtup

섹션 제목: “9. 결과 전달: spi_dest_startup과 spi_printtup”

DestSPI 수신기는 익스큐터 출력을 SPITupleTable에 구체화한다. spi_dest_startup은 procCxt의 새 자식 컨텍스트에 테이블을 할당하고 프레임의 tuptables 목록에 연결한다.

// spi_dest_startup — src/backend/executor/spi.c
void
spi_dest_startup(DestReceiver *self, int operation, TupleDesc typeinfo)
{
SPITupleTable *tuptable;
MemoryContext oldcxt, tuptabcxt;
if (_SPI_current == NULL)
elog(ERROR, "spi_dest_startup called while not connected to SPI");
if (_SPI_current->tuptable != NULL)
elog(ERROR, "improper call to spi_dest_startup");
oldcxt = _SPI_procmem(); /* switch to procedure memory context */
tuptabcxt = AllocSetContextCreate(CurrentMemoryContext, "SPI TupTable",
ALLOCSET_DEFAULT_SIZES);
MemoryContextSwitchTo(tuptabcxt);
_SPI_current->tuptable = tuptable = (SPITupleTable *)
palloc0(sizeof(SPITupleTable));
tuptable->tuptabcxt = tuptabcxt;
tuptable->subid = GetCurrentSubTransactionId();
/* put it on the SPI context's tuptables list (so abort can free it) */
slist_push_head(&_SPI_current->tuptables, &tuptable->next);
tuptable->alloced = 128;
tuptable->vals = (HeapTuple *) palloc(tuptable->alloced * sizeof(HeapTuple));
tuptable->numvals = 0;
tuptable->tupdesc = CreateTupleDescCopy(typeinfo);
MemoryContextSwitchTo(oldcxt);
}

spi_printtup은 결과 행마다 한 번 호출된다. vals[]를 기하급수적으로 늘리고 슬롯의 튜플을 tuptable 컨텍스트로 복사한다.

// spi_printtup — src/backend/executor/spi.c
bool
spi_printtup(TupleTableSlot *slot, DestReceiver *self)
{
SPITupleTable *tuptable = _SPI_current->tuptable;
MemoryContext oldcxt;
if (tuptable == NULL)
elog(ERROR, "improper call to spi_printtup");
oldcxt = MemoryContextSwitchTo(tuptable->tuptabcxt);
if (tuptable->numvals >= tuptable->alloced)
{
uint64 newalloced = tuptable->alloced * 2; /* double the array */
tuptable->vals = (HeapTuple *)
repalloc_huge(tuptable->vals, newalloced * sizeof(HeapTuple));
tuptable->alloced = newalloced;
}
tuptable->vals[tuptable->numvals] = ExecCopySlotHeapTuple(slot);
(tuptable->numvals)++;
MemoryContextSwitchTo(oldcxt);
return true;
}

모든 튜플이 tuptabcxt복사되므로 결과 테이블은 자기 완결적이다. 익스큐터 해체 후에도 살아남으며, 컨텍스트가 삭제될 때(SPI_freetuptable, SPI_finish, 또는 abort)만 해제된다.

SPI_freetuptable은 호출자가 결과 테이블 하나를 일찍 회수하게 한다. 현재 프레임의 tuptables 목록에 테이블이 있는지 확인해 이중 해제를 방지한 뒤 컨텍스트를 삭제한다.

// SPI_freetuptable — src/backend/executor/spi.c
void
SPI_freetuptable(SPITupleTable *tuptable)
{
bool found = false;
if (tuptable == NULL)
return;
if (_SPI_current != NULL)
{
slist_mutable_iter siter;
slist_foreach_modify(siter, &_SPI_current->tuptables)
{
SPITupleTable *tt = slist_container(SPITupleTable, next, siter.cur);
if (tt == tuptable)
{
slist_delete_current(&siter);
found = true;
break;
}
}
}
if (!found)
{
elog(WARNING, "attempt to delete invalid SPITupleTable %p", tuptable);
return;
}
/* reset globals that might point at it, then free its memory */
if (tuptable == _SPI_current->tuptable)
_SPI_current->tuptable = NULL;
if (tuptable == SPI_tuptable)
SPI_tuptable = NULL;
MemoryContextDelete(tuptable->tuptabcxt);
}

커서는 모든 것을 구체화하는 대신 플랜을 포털(Portal) 구조(postgres-portals-prepared.md)에 넘겨 PL이 점진적으로 가져올 수 있게 한다. SPI_cursor_open_internal은 포털을 만들고 플랜을 복사하며 스냅샷을 설정한 뒤 PortalStart를 호출한다.

// SPI_cursor_open_internal — src/backend/executor/spi.c
Portal
SPI_cursor_open_internal(const char *name, SPIPlanPtr plan,
ParamListInfo paramLI, bool read_only)
{
CachedPlanSource *plansource;
CachedPlan *cplan;
List *stmt_list;
Portal portal;
Snapshot snapshot;
if (!SPI_is_cursor_plan(plan))
ereport(ERROR,
(errcode(ERRCODE_INVALID_CURSOR_DEFINITION),
errmsg("cannot open non-SELECT as cursor")));
plansource = (CachedPlanSource *) linitial(plan->plancache_list);
if (_SPI_begin_call(true) < 0)
elog(ERROR, "SPI_cursor_open called while not connected");
SPI_processed = 0;
SPI_tuptable = NULL;
/* create the portal (named or anonymous) */
if (name == NULL || name[0] == '\0')
portal = CreateNewPortal();
else
portal = CreatePortal(name, false, false);
/* revalidate plan, take a refcount for the portal */
cplan = GetCachedPlan(plansource, paramLI, NULL, _SPI_current->queryEnv);
stmt_list = cplan->stmt_list;
/* ... copy unsaved plans into portalContext; PortalDefineQuery(...) ... */
/* Set up the snapshot exactly like the executor would. */
if (read_only)
snapshot = GetActiveSnapshot();
else
{
CommandCounterIncrement();
snapshot = GetTransactionSnapshot();
}
PortalStart(portal, paramLI, 0, snapshot);
Assert(portal->strategy != PORTAL_MULTI_QUERY);
_SPI_end_call(true);
return portal;
}

가져오기는 _SPI_cursor_operation이 담당한다. 여기에는 유명한 재페치 위험이 있다. 포털을 실행하면 SPI를 사용하는 PL 코드가 실행될 수 있고, 그로 인해 _SPI_stackrepalloc으로 이동할 수 있다. 따라서 PortalRunFetch 이후 _SPI_current를 반드시 다시 읽어야 한다.

// _SPI_cursor_operation — src/backend/executor/spi.c
static void
_SPI_cursor_operation(Portal portal, FetchDirection direction, long count,
DestReceiver *dest)
{
uint64 nfetched;
if (!PortalIsValid(portal))
elog(ERROR, "invalid portal in SPI cursor operation");
if (_SPI_begin_call(true) < 0)
elog(ERROR, "SPI cursor operation called while not connected");
SPI_processed = 0;
SPI_tuptable = NULL;
_SPI_current->processed = 0;
_SPI_current->tuptable = NULL;
nfetched = PortalRunFetch(portal, direction, count, dest);
/*
* ... the portal may move _SPI_stack around; re-fetch _SPI_current
* after the call before storing into it.
*/
_SPI_current->processed = nfetched;
if (dest->mydest == DestSPI && _SPI_checktuples())
elog(ERROR, "consistency check on SPI tuple count failed");
SPI_processed = _SPI_current->processed;
SPI_tuptable = _SPI_current->tuptable;
_SPI_current->tuptable = NULL; /* now caller's responsibility */
_SPI_end_call(true);
}

12. 트랜잭션 경계 정리: AtEOXact_SPI

섹션 제목: “12. 트랜잭션 경계 정리: AtEOXact_SPI”

트랜잭션 종료 시, 열린 채로 남아있는 SPI 프레임(오류로 SPI_finish를 건너뛴 PL)이 여기서 팝된다. 루프는 internal_xact 프레임에서 멈춘다. 이 프레임은 SPI_commit/SPI_rollback 호출자에 속하며 자신의 수명을 스스로 관리한다.

// AtEOXact_SPI — src/backend/executor/spi.c
void
AtEOXact_SPI(bool isCommit)
{
bool found = false;
while (_SPI_connected >= 0)
{
_SPI_connection *connection = &(_SPI_stack[_SPI_connected]);
if (connection->internal_xact)
break; /* belongs to SPI_commit/SPI_rollback caller */
found = true;
/* contexts go away with their parent; just restore globals + pop */
SPI_processed = connection->outer_processed;
SPI_tuptable = connection->outer_tuptable;
SPI_result = connection->outer_result;
_SPI_connected--;
_SPI_current = (_SPI_connected < 0) ? NULL
: &(_SPI_stack[_SPI_connected]);
}
if (found && isCommit)
ereport(WARNING,
(errcode(ERRCODE_WARNING),
errmsg("transaction left non-empty SPI stack"),
errhint("Check for missing \"SPI_finish\" calls.")));
}

커밋 시에는 경고, abort 시에는 침묵한다는 설계가 계약을 인코딩한다. 성공적인 트랜잭션에서 프레임을 열린 채 남기는 것은 PL/확장의 버그다. 오류 시에 열린 채로 남기는 것은 예상된 동작이다(오류가 SPI_finish 호출을 단락시킨 것).

동반 함수 AtEOSubXact_SPI는 서브트랜잭션 경계에서 같은 작업을 수행하지만, 각 프레임의 exec/proc 컨텍스트를 명시적으로 삭제한다. 부모 컨텍스트가 사라지는 것에 의존할 수 없는 서브트랜잭션 abort 특성 때문이다. 또한 각 tuptable에 찍힌 subid를 사용해 중단 중인 서브트랜잭션에서 생성된 것만 해제한다.

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

섹션 제목: “위치 힌트 (2026-06-05, REL_18 273fe94 기준)”
심볼파일
SPI_connectsrc/backend/executor/spi.c94
SPI_connect_extsrc/backend/executor/spi.c100
SPI_finishsrc/backend/executor/spi.c182
AtEOXact_SPIsrc/backend/executor/spi.c428
AtEOSubXact_SPIsrc/backend/executor/spi.c482
SPI_executesrc/backend/executor/spi.c596
SPI_execute_extendedsrc/backend/executor/spi.c637
SPI_execute_plansrc/backend/executor/spi.c672
SPI_preparesrc/backend/executor/spi.c860
SPI_prepare_cursorsrc/backend/executor/spi.c866
SPI_keepplansrc/backend/executor/spi.c976
SPI_saveplansrc/backend/executor/spi.c1003
SPI_copytuplesrc/backend/executor/spi.c1047
SPI_getvaluesrc/backend/executor/spi.c1220
SPI_freetuptablesrc/backend/executor/spi.c1386
SPI_cursor_opensrc/backend/executor/spi.c1445
SPI_cursor_open_internalsrc/backend/executor/spi.c1577
SPI_cursor_fetchsrc/backend/executor/spi.c1806
spi_dest_startupsrc/backend/executor/spi.c2123
spi_printtupsrc/backend/executor/spi.c2171
_SPI_prepare_plansrc/backend/executor/spi.c2221
_SPI_execute_plansrc/backend/executor/spi.c2399
_SPI_cursor_operationsrc/backend/executor/spi.c3007
_SPI_begin_callsrc/backend/executor/spi.c3077
_SPI_end_callsrc/backend/executor/spi.c3101
_SPI_make_plan_non_tempsrc/backend/executor/spi.c3141
_SPI_save_plansrc/backend/executor/spi.c3209
SPI_register_relationsrc/backend/executor/spi.c3297
_SPI_connection (구조체)src/include/executor/spi_priv.h22
_SPI_plan (구조체)src/include/executor/spi_priv.h90

이 문서의 주장은 /data/hgryoo/references/postgres의 REL_18 트리(커밋 273fe94, 2026-06-05 캡처)에서 다음과 같이 확인했다.

  • 연결 스택은 증가 가능한 전역 배열이다. 확인: _SPI_stack, _SPI_stack_depth, _SPI_connected, _SPI_currentspi.c의 파일 범위 정적 변수다. SPI_connect_ext는 초기에 16개 프레임을 할당하고 repalloc으로 두 배씩 늘린다. “스택이 이동할 수 있다”는 위험은 _SPI_cursor_operation의 주석에 명시되어 있다.
  • 프레임당 두 컨텍스트, 부모는 원자성에 따라 결정. 확인: SPI_connect_ext에서 procCxt/execCxt가 원자적이면 TopTransactionContext, 비원자적이면 PortalContext/procCxt를 부모로 생성된다.
  • 경계에서 전역 변수를 스냅샷. 확인: _SPI_connectionouter_processed, outer_tuptable, outer_result 필드(spi_priv.h)가 SPI_connect_ext에서 저장되고 SPI_finish, AtEOXact_SPI, AtEOSubXact_SPI에서 복원된다.
  • 단발성 경로는 InvalidSnapshot을 사용. 확인: SPI_executeSPI_execute_extended는 모두 _SPI_prepare_oneshot_plan을 호출한 뒤 _SPI_execute_plan(..., InvalidSnapshot, InvalidSnapshot, true)를 호출한다.
  • 네 가지 스냅샷 체제. 확인: _SPI_execute_plan 내부의 헤더 주석이 (스냅샷 공급 여부?) × (read_only?) 행렬을 열거하고, 코드 분기로 확인된다.
  • 휘발성 강제. 확인: _SPI_execute_planoptions->read_only && !CommandIsReadOnly(stmt)ERRCODE_FEATURE_NOT_SUPPORTED를 발생시킨다.
  • SPITupleTable 기하급수적 증가 + 튜플별 복사. 확인: spi_dest_startupalloced = 128로 설정하고, spi_printtuprepalloc_huge로 두 배씩 늘리며 ExecCopySlotHeapTuple로 복사한다.
  • SPI_keepplan이 CacheMemoryContext로 재부모화. 확인: MemoryContextSetParent(plan->plancxt, CacheMemoryContext) + SaveCachedPlan 루프, “중간에 실패할 수 없다” 주석 확인.
  • 트랜잭션 제어는 비원자·비서브트랜잭션으로 제한. 확인: _SPI_commit_SPI_current->atomic 또는 IsSubTransaction()ERRCODE_INVALID_TRANSACTION_TERMINATION을 발생시킨다.

위치 힌트 표의 행 번호는 2026-06-05에 파일에서 직접 읽었다. 이후 커밋에서 어긋날 수 있으며 심볼 이름이 안정적인 참조다.

범위 주의: 이 문서는 src/backend/executor/spi.c와 그 전용 헤더만 다룬다. PL/pgSQL의 내부 상태 기계(pl_exec.c), 익스큐터의 플랜 노드 구조, SPI 접점 너머의 포털/준비된 문장 수명 주기에 대해서는 어떠한 주장도 하지 않는다. 이들은 각각 postgres-plpgsql.md, postgres-executor.md, postgres-portals-prepared.md로 위임된다. contrib/는 범위 밖이다.


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

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

SPI vs. 와이어 프로토콜 — 하나의 엔진으로 가는 두 입구

섹션 제목: “SPI vs. 와이어 프로토콜 — 하나의 엔진으로 가는 두 입구”

SPI를 자리매김하는 가장 깔끔한 방법은 PostgreSQL의 다른 질의 제출 경로인 확장 질의 프로토콜(postgres-wire-protocol.md, postgres-portals-prepared.md)과 대비하는 것이다. 와이어 경로는 exec_parse_messageexec_bind_messageexec_execute_message를 실행하며 외부 클라이언트가 구동한다. SPI는 SPI_prepareSPI_execute_plan을 실행하며 백엔드 내부 C 코드가 구동한다. 두 경로 모두 궁극적으로 같은 plancache와 같은 익스큐터를 호출한다. SPI는 소켓 없이 무거운 구조를 재사용하는 얇은 인프로세스 퍼사드로 이해하는 것이 가장 적절하다. 주목할 비대칭은 스냅샷 소유권이다. 와이어 경로는 항상 자체 커맨드와 스냅샷을 시작하는 반면, SPI는 대개 호출자의 트랜잭션을, 읽기 전용 호출자에게는 호출자의 스냅샷까지 상속한다. 이 상속이 STABLE 함수가 외부 질의가 지속되는 동안 일관된 데이터베이스 뷰를 볼 수 있게 하는 정확한 이유다.

다른 엔진의 서버 측 SQL API와의 비교

섹션 제목: “다른 엔진의 서버 측 SQL API와의 비교”
  • Oracle (OCI / PL/SQL EXECUTE IMMEDIATE). Oracle의 PL/SQL은 SQL 엔진과 같은 프로세스에서 실행되며 SQL 텍스트를 키로 하는 공유 커서 캐시에서 커서를 공유한다. PostgreSQL의 CachedPlanSource + SPI_keepplan이 그 유사체이지만, PostgreSQL의 플랜 캐시는 백엔드당(세션 간 플랜 공유 없음)이어서 메모리와 격리성을 맞바꾼다. Oracle의 이름 기반 바인딩 vs. PostgreSQL의 위치 기반 $1..$n/Values[]는 표면적 차이다. 더 깊은 차이는 Oracle이 공유 풀에서 세션 간에 컴파일된 플랜을 공유한다는 점이며, PostgreSQL이 의도적으로 피하는 설계다.
  • SQL Server (SQLCLR SqlContext/컨텍스트 연결). SQL Server 내부에서 실행되는 관리 코드는 호스트의 트랜잭션과 세션을 재사용하는 “컨텍스트 연결”을 얻는다. 개념적으로 SPI가 백엔드의 트랜잭션을 상속하는 것과 동일하다. SPI_OPT_NONATOMIC 구분(이 코드가 커밋할 수 있는가?)은 SQL Server의 어떤 CLR 컨텍스트가 트랜잭션을 제어할 수 있는지에 관한 규칙과 대응한다.
  • SQLite (sqlite3_exec / 가상 테이블). SQLite는 별도 프로세스 경계가 전혀 없으므로 “내장 SQL”이 유일한 API다. 준비된 문장 객체(sqlite3_stmt)가 SPIPlanPtr의 도덕적 동등물이고, sqlite3_step 커서가 SPI_cursor_fetch에 대응한다. 연결 스택이 없다는 것은 SQLite의 단일 쓰기 모델이 PostgreSQL의 재진입 PL-호출-PL 중첩과 다름을 반영한다.

전역 변수를 가진 연결 스택 설계가 유지되는 이유

섹션 제목: “전역 변수를 가진 연결 스택 설계가 유지되는 이유”

SPI에 대한 반복적인 비판은 프로세스 전역 변수(SPI_tuptable 등) 의존이다. 이로 인해 저장/복원 처리가 강제되고 API가 본질적으로 스레드 안전하지 않게 된다. 정당화 근거는 인체공학적 이유다. PL 핸들러 작성자는 모든 행에 핸들을 넘기지 않고 SPI_execute("..."); n = SPI_processed;처럼 쓴다. PostgreSQL의 연결당 하나의 백엔드 프로세스 모델은 주소 공간당 SPI 사용자가 항상 하나뿐임을 보장한다. 프레임 스택 패턴은 중첩 아래에서 전역 변수를 안전하게 만드는 최소한의 구조다. 이 프로세스 모델이 스레드 안전성 압박을 없애주기 때문에 이 실용적 선택이 20년 가까이 거의 변하지 않고 살아남은 이유다.

  • SPI 경계의 컴파일 제거. SQL-to-네이티브 컴파일 프로젝트(HyPer/Umbra 스타일의 데이터 중심 코드 생성, PostgreSQL 자체의 표현식 평가 JIT, postgres-expression-eval.md)는 parse→plan→SPITupleTable→copy 파이프라인을 PL 루프 본문과 내장 질의를 하나의 컴파일 단위로 융합해 행당 ExecCopySlotHeapTuple을 제거할 수 있는지 질문을 제기한다. 현재 프로덕션 PostgreSQL에서는 이를 수행하지 않지만, PL 집약적 워크로드의 명백한 다음 효율성 전선이다.
  • 플랜 캐시 공유. 백엔드당 플랜 캐시를 Oracle 공유 풀처럼 공유 메모리에 두어야 하는지는 반복적인 설계 논쟁이다. SPI 계층이 모든 PL 문장이 SPI_keepplan 소비자이므로 주요 수혜자가 될 것이다. 반대 논거 — 무효화 복잡도와 세션 간 경합 — 가 PostgreSQL이 이 방향으로 이동하지 않은 이유다.
  • 비원자 실행과 저장 프로시저. SPI_OPT_NONATOMIC 경로는 트랜잭션 제어 프로시저(CALL)를 지원하기 위해 추가되었다. 프로시저 중간 COMMIT이 핀된 포털, 보유 스냅샷(HoldPinnedPortals, ForgetPortalSnapshots), 병렬 워커와 상호 작용하는 방식은 지속적인 개선 영역으로 남아있다.

1차 소스 (REL_18, 커밋 273fe94, 2026-06-05 캡처):

  • src/backend/executor/spi.c — 전체 SPI 구현: 연결 스택, prepare/execute/cursor 경로, SPITupleTable 수신기, 스냅샷 및 트랜잭션 관리.
  • src/include/executor/spi.h — 공개 API 면, SPITupleTable, SPIExecuteOptions/SPIPrepareOptions, 반환 코드 상수.
  • src/include/executor/spi_priv.h_SPI_connection_SPI_plan(SPIPlanPtr) 전용 구조체, _SPI_PLAN_MAGIC.
  • src/backend/utils/cache/plancache.cGetCachedPlan, SaveCachedPlan, CachedPlanSetParentContext; SPI는 주요 클라이언트(postgres-portals-prepared.md 참조).
  • src/backend/tcop/pquery.c — SPI 커서 경로에서 사용하는 PortalStart, PortalRunFetch.

이 지식베이스 내 이론 및 교차 참조:

  • knowledge/research/dbms-general/database-system-concepts.md — 애플리케이션 개발 / 내장 SQL / 저장 프로시저 프레임.
  • knowledge/research/dbms-general/database-internals.md — 실행 컨텍스트와 버퍼/저장소 인터페이스 배경.
  • knowledge/code-analysis/postgres/postgres-portals-prepared.md — 포털과 준비된 문장 수명 주기 (SPI 커서가 여기로 넘어감).
  • knowledge/code-analysis/postgres/postgres-executor.md — SPI가 CreateQueryDesc/_SPI_pquery로 구동하는 익스큐터 본체.
  • knowledge/code-analysis/postgres/postgres-plpgsql.md — 주요 SPI 클라이언트; PL/pgSQL 상태 기계는 여기로 위임.
  • knowledge/code-analysis/postgres/postgres-mvcc-snapshots.md — SPI의 네 체제가 조작하는 스냅샷 의미론.
  • knowledge/code-analysis/postgres/postgres-memory-contexts.md — procCxt/execCxt/tuptabcxt 수명 모델의 기반.
  • knowledge/code-analysis/postgres/postgres-extensions.md — 확장이 SPI API에 링크하고 호출하는 방법.