콘텐츠로 이동

(KO) PostgreSQL 트리거 — 정의, 발화 지점, After-Trigger 큐

목차

**트리거(trigger)**는 절차적 코드를 데이터 변경 이벤트에 묶어, 그 코드가 자동으로 그리고 이벤트와 같은 트랜잭션 안에서 실행되게 하는 데이터베이스 객체다. Database System Concepts(Silberschatz, 7판, 5장 “Advanced SQL”, §5.3 “Triggers”)는 이를 “데이터베이스 변경의 부수 효과로 시스템이 자동 실행하는 구문”으로 정의하고, 모든 트리거 기능이 명시해야 할 두 가지 설계 결정을 제시한다.

  1. 트리거를 실행시키는 이벤트와 조건. SQL 트리거는 트리거 이벤트(INSERT, UPDATE, DELETE), 선택적으로 UPDATE용 컬럼 목록, 그리고 본문 실행 전에 검사하는 선택적 WHEN 조건을 명시한다(§5.3.1). 이벤트가 발생하고 조건도 성립해야만 액션이 실행된다. 이것이 교재의 이벤트-조건-액션(ECA) 모델이다.
  2. 트리거가 실행될 때 취하는 액션, 그리고 액션이 얼마나 자주, 언제 실행되는지를 결정하는 두 가지 직교 축.
    • 그래뉼러티(granularity). FOR EACH ROW 트리거는 영향받은 행마다 한 번 발화하며 행의 OLD/NEW 이미지를 볼 수 있다. FOR EACH STATEMENT 트리거는 몇 행이 영향받았든 구문 전체에서 한 번만 발화한다(§5.3.1, “행 레벨” 대 “구문 레벨”).
    • 타이밍(timing). BEFORE 트리거는 변경이 적용되기 전에 실행되어 제안된 행을 검사하거나 변경하거나 연산을 취소할 수 있다. AFTER 트리거는 변경이 완료되어 새 상태가 보이는 이후에 실행된다(§5.3.1).

교재는 구현이 방어해야 할 위험도 솔직하게 언급한다. “트리거는 특정 무결성 제약을 구현하는 데 쓰일 수 있다”고 하면서도, “트리거 오류는 런타임에 감지되어 트리거를 발생시킨 … 구문의 실패를 야기한다”(§5.3.2)고 경고한다. 구체적으로 명시된 두 함정은 연쇄/비종료(cascading/non-termination) — 트리거의 액션이 다른 트리거를 발화시키고 이것이 무한히 반복되는 현상 — 과 의도치 않은 순서(unintended ordering) — 동일 이벤트에 여러 트리거가 걸렸을 때 결과가 발화 순서에 따라 달라지는 현상이다. SQL 표준은 순서를 거의 구현체에 맡기므로, 각 엔진이 발화 순서를 정하고 문서화해야 한다.

표준이 추가하고 교재가 비교적 간략히 다루는 세 번째 개념이 **전환 테이블(transition tables)**이다(REFERENCING OLD TABLE AS / NEW TABLE AS). 구문 레벨 AFTER 트리거는 구문이 변경한 전체 행 집합을 두 개의 읽기 전용 릴레이션으로 볼 수 있다. 이는 행별 방문자를 집합 지향 방문자로 바꾸는 것으로, 함수 호출 O(행 수)와 델타 전체에 집합 기반 SQL 구문 하나를 날리는 것의 차이다. 이것이 대량 무결성 유지에 구문 레벨 트리거가 쓸모 있는 이유다.

마지막으로 교재는 트리거를 선언적 무결성 제약과 비교한다. “시스템이 추론할 수 있는 [외래 키, 체크 제약 같은] 선언적 기능을 사용하는 편이 낫다”(§5.3.3). 트리거는 불투명한 절차 코드이기 때문이다. PostgreSQL이 이를 문자 그대로 구현한 결과가 제약 자체를 트리거로 구현하는 설계다. 외래 키는 내부 AFTER 트리거 쌍이고, DEFERRABLE 제약은 정확히 지연 가능한 AFTER 트리거다. 트리거 기계는 주변적 기능이 아니라 참조 무결성이 올라타는 기반 구조이며, 이는 큐 규율, 순서, 트랜잭션 통합의 공학적 기준을 높인다.

ECA 모델은 의미론을 제공한다. 실제 엔진들은 그 의미론을 빠르고 순서 있게 트랜잭션 안전하게 만들기 위한 몇 가지 공학적 관례로 수렴한다. 다음 절에서 다룰 PostgreSQL의 구체적 선택은 이 공유 설계 공간의 한 점으로 읽으면 된다.

1. 트리거를 카탈로그에 저장하고 관계별 디스패치 요약을 컴파일한다. 트리거 정의는 메타데이터다. 함수 이름, 이벤트 마스크, 타이밍/레벨 플래그, 선택적 조건을 담은 카탈로그 행이다. 행마다 카탈로그를 조회하면 성능이 무너지므로, 엔진은 관계의 인메모리 테이블 디스크립터에 붙어 카탈로그 변경 시 무효화되는 캐시된 관계별 디스크립터를 만든다. 핵심은 디스크립터가 담은 요약 불리언(“이 테이블에 BEFORE-INSERT-ROW 트리거가 있는가?”)이다. 핫 패스에서 단 하나의 불리언 검사로 빠져나올 수 있다. 해당 종류의 트리거가 없는 것이 일반적인 경우다.

2. DML 익스큐터에 고정된 발화 지점 집합. 엔진은 임의의 시점에 트리거를 “스캔”하지 않는다. insert/update/delete 코드의 하드코딩된 지점에서 고정된 훅 패밀리를 호출한다. (타이밍 × 이벤트 × 레벨) 조합마다 훅 하나다. 각 훅은 요약 플래그가 꺼져 있으면 불리언 검사 하나로 끝나는 아무것도 안 하는 함수(no-op)다. 트리거 서브시스템이 익스큐터와 뒤엉키는 대신 익스큐터에 플러그인되는 구조를 유지하는 방법이다.

3. BEFORE 행 트리거는 동기적이며 튜플을 변환할 수 있다. BEFORE FOR EACH ROW 트리거는 NEW를 재작성하거나 연산을 거부할 수 있으므로, 훅이 인라인으로 실행되어 후보 튜플을 받아 (달라지거나 null이 된) 튜플을 반환해야 한다. 익스큐터는 그것을 저장한다. 파이프라인 변환이지 큐에 넣은 이벤트가 아니다.

4. AFTER 트리거는 큐에 지연된다. AFTER 트리거는 변경 후의 최종 상태를 봐야 하고, DEFERRABLE 제약이라면 커밋까지 기다려야 할 수도 있다. AFTER 훅은 함수를 실행하지 않고 이벤트를 기록 — 최소한 어느 트리거어느 행인지 — 해 큐에 넣는다. 나중의 드레인 단계가 큐에 넣어진 이벤트를 순서대로 발화한다. 이 설계가 만드는 두 가지 어려운 문제는 (a) 큐가 불어나지 않도록 행을 컴팩트하게 식별하는 것(엔진은 튜플 복사본이 아닌 행 식별자/튜플 포인터를 저장)과, (b) 힙이 변경됐을 수 있으므로 발화 시점에 올바른 가시성 아래 행을 다시 찾는 것이다.

5. 결정론적 발화 순서. 표준이 침묵한 자리를 엔진이 채운다. 지배적 관례는 같은 (타이밍, 이벤트) 클래스 안에서 트리거 이름 알파벳 순이다. 임의적이지만 안정적이고 검사 가능하며, 사용자에게 실제로 필요한 속성이다.

6. 트랜잭션과 서브트랜잭션 통합. AFTER 큐는 트랜잭션 범위다. 트랜잭션의 여러 구문에 걸쳐 살아남고(지연 제약을 위해), 커밋 시 드레인되고, 중단 시 폐기되며, 서브트랜잭션 중단 시 부분 롤백돼야 한다. 저장 지점이 롤백되면 그 동안 큐에 넣어진 이벤트는 사라지고 이전 이벤트는 살아남는다. “저장된 위치로 잘라 내기”가 저렴한 큐 구조가 필요하다.

7. 재진입과 재귀 제어. 트리거 함수가 DML을 발행해 추가 트리거를 발화시킬 수 있다. 큐 드레인은 루프여야 한다(“발화 → 새 이벤트가 생겼을 수 있음 → 빌 때까지 반복”). 엔진은 폭주 재귀를 바운딩하고 구문 타임아웃과 연동하기 위해 중첩 깊이를 추적한다.

PostgreSQL은 이 관례를 모두 구현한다. 이 문서의 나머지는 어떻게에 대한 탐방이다. pg_trigger + TriggerDesc가 (1)과 (5)를, ExecBR/AR/IR/BS/AS 패밀리가 (2)를, 튜플을 반환하는 ExecBRInsertTriggers가 (3)을, AfterTriggerSaveEvent + 청크 단위 AfterTriggerEventList가 (4)와 (6)을, 발화 루프와 MyTriggerDepth가 (7)을 담당한다.

트리거는 pg_trigger 행이고 릴캐시 TriggerDesc로 컴파일된다

섹션 제목: “트리거는 pg_trigger 행이고 릴캐시 TriggerDesc로 컴파일된다”

트리거의 영속 형태는 pg_trigger 시스템 카탈로그의 행 하나다. CREATE TRIGGER(trigger.cCreateTriggerFiringOn)는 구문을 파싱하고 검증하며 함수를 조회한 뒤 그 행을 삽입한다. 가장 중요한 필드는 tgtype — 타이밍, 레벨, 이벤트를 함께 인코딩한 압축 int16 비트마스크 — 이고, pg_trigger.h에 정의된 비트를 사용한다.

// tgtype bit layout — src/include/catalog/pg_trigger.h
#define TRIGGER_TYPE_ROW (1 << 0) /* else STATEMENT */
#define TRIGGER_TYPE_BEFORE (1 << 1) /* else AFTER (=0) */
#define TRIGGER_TYPE_INSERT (1 << 2)
#define TRIGGER_TYPE_DELETE (1 << 3)
#define TRIGGER_TYPE_UPDATE (1 << 4)
#define TRIGGER_TYPE_TRUNCATE (1 << 5)
#define TRIGGER_TYPE_INSTEAD (1 << 6) /* INSTEAD OF, views only */

독자가 자주 혼동하는 두 가지 인코딩을 짚어 두자. 첫째, AFTERSTATEMENT비트가 아니다. 각각 TRIGGER_TYPE_BEFORE/INSTEADTRIGGER_TYPE_ROW부재로 표현된다. 둘째, pg_trigger 행 하나에 여러 이벤트 비트가 동시에 설정될 수 있다(INSERT OR UPDATE). 이는 항상 정확히 하나의 연산을 나타내는 런타임 TriggerEvent와 다르다(trigger.h 주석 참고).

백엔드가 관계에 처음 접근할 때 릴캐시는 해당 테이블의 pg_trigger를 스캔해 TriggerDesc를 만든다. RelationBuildTriggers이름 순서로 행을 읽는다(TriggerRelidNameIndexId로 스캔). 이것이 PostgreSQL의 “알파벳 발화 순서” 관례를 구현하는 방식이다. 배열 순서 자체가 발화 순서다.

// RelationBuildTriggers — src/backend/commands/trigger.c
/*
* Note: since we scan the triggers using TriggerRelidNameIndexId, we will
* be reading the triggers in name order ... This in turn
* ensures that triggers will be fired in name order.
*/
ScanKeyInit(&skey, Anum_pg_trigger_tgrelid,
BTEqualStrategyNumber, F_OIDEQ,
ObjectIdGetDatum(RelationGetRelid(relation)));
tgrel = table_open(TriggerRelationId, AccessShareLock);
tgscan = systable_beginscan(tgrel, TriggerRelidNameIndexId, true, NULL, 1, &skey);
while (HeapTupleIsValid(htup = systable_getnext(tgscan)))
{
Form_pg_trigger pg_trigger = (Form_pg_trigger) GETSTRUCT(htup);
/* ... copy tgname, tgfoid, tgtype, tgenabled, tgdeferrable, ... into build ... */
}

결과로 만들어지는 인메모리 형태는 reltrigger.h의 두 구조체다. Trigger는 트리거 하나(주로 pg_trigger 행의 복사본과 결정된 OID)이고, TriggerDesc는 관계별 배열과 요약 불리언의 집합이다.

// TriggerDesc — src/include/utils/reltrigger.h
typedef struct TriggerDesc
{
Trigger *triggers; /* array, in name order */
int numtriggers;
bool trig_insert_before_row; /* one flag per (event,timing,level) */
bool trig_insert_after_row;
bool trig_insert_instead_row;
bool trig_insert_before_statement;
bool trig_insert_after_statement;
/* ... update_*, delete_*, truncate_* ... */
bool trig_insert_new_table; /* any NEW TABLE transition table? */
bool trig_update_old_table;
bool trig_update_new_table;
bool trig_delete_old_table;
} TriggerDesc;

이 플래그들은 SetTriggerFlags가 채운다. 각 트리거의 분류를 디스크립터에 OR한다. 요점은 부정 검사다. 익스큐터의 발화 훅은 if (!trigdesc->trig_insert_before_row) return;으로 시작하므로, 해당 클래스의 트리거가 없는 테이블은 불리언 로드 한 번으로 끝나고 배열을 순회하지 않는다.

// SetTriggerFlags — src/backend/commands/trigger.c
trigdesc->trig_insert_before_row |=
TRIGGER_TYPE_MATCHES(tgtype, TRIGGER_TYPE_ROW,
TRIGGER_TYPE_BEFORE, TRIGGER_TYPE_INSERT);
trigdesc->trig_insert_after_row |=
TRIGGER_TYPE_MATCHES(tgtype, TRIGGER_TYPE_ROW,
TRIGGER_TYPE_AFTER, TRIGGER_TYPE_INSERT);
/* ... and so on for every (event, timing, level) combination ... */
trigdesc->trig_insert_new_table |=
(TRIGGER_FOR_INSERT(tgtype) &&
TRIGGER_USES_TRANSITION_TABLE(trigger->tgnewtable));

발화 지점 패밀리: (타이밍, 레벨, 이벤트)당 훅 하나

섹션 제목: “발화 지점 패밀리: (타이밍, 레벨, 이벤트)당 훅 하나”

런타임에 트리거는 이벤트를 “감시”하지 않는다. 익스큐터 — 주로 nodeModifyTable.c, 그리고 COPY, ExecuteTruncate, RI 코드 — 가 튜플 연산이 발생하는 정확한 시점에 훅을 호출한다. 훅은 Exec + 타이밍 + 레벨 + 이벤트 + Triggers 형태로 이름이 붙은 규칙적인 격자를 이룬다.

타이밍 \ 이벤트INSERTUPDATEDELETE
BEFORE statementExecBSInsertTriggersExecBSUpdateTriggersExecBSDeleteTriggers
BEFORE rowExecBRInsertTriggersExecBRUpdateTriggersExecBRDeleteTriggers
INSTEAD OF rowExecIRInsertTriggersExecIRUpdateTriggersExecIRDeleteTriggers
AFTER rowExecARInsertTriggersExecARUpdateTriggersExecARDeleteTriggers
AFTER statementExecASInsertTriggersExecASUpdateTriggersExecASDeleteTriggers

(B=before, A=after, S=statement, R=row, I=instead.) TRUNCATE에는 ExecBSTruncateTriggers/ExecASTruncateTriggers만 있다. TriggerDesc 주석이 말하듯 행 레벨 TRUNCATE 트리거는 없다. 타이밍에 따라 계약이 크게 달라지는 것이 이 설계의 핵심이다.

flowchart TD
    subgraph row["per affected row, inside nodeModifyTable"]
        BR["ExecBRInsertTriggers<br/>runs function NOW<br/>returns tuple or NULL"]
        STORE["heap_insert / table_tuple_update<br/>apply the change"]
        AR["ExecARInsertTriggers<br/>does NOT run function<br/>queues an event"]
    end
    BR -->|"NULL = skip this row"| SKIP["row discarded"]
    BR -->|"tuple"| STORE
    STORE --> AR
    AR --> SAVE["AfterTriggerSaveEvent<br/>append AfterTriggerEventData"]
    SAVE --> Q[("per-query<br/>AfterTriggerEventList")]
    Q -.->|"AfterTriggerEndQuery"| FIRE["afterTriggerInvokeEvents<br/>fire immediate-mode events"]
    Q -.->|"deferrable events<br/>moved to xact list"| DEF["AfterTriggerFireDeferred<br/>at commit"]

BEFORE 행 훅은 함수를 동기적으로 실행하며 튜플을 변환한다. ExecBRInsertTriggers는 트리거 배열을 순회하며 매칭되고 활성화된 트리거마다 ExecCallTriggerFunc를 호출한다. 반환된 튜플이 다음 트리거의 입력이 되어 체이닝된다. NULL 반환은 “이 행을 건너뛰어라”를 의미한다. NULL이 아닌 다른 튜플은 슬롯에 다시 저장된다.

// ExecBRInsertTriggers — src/backend/commands/trigger.c
newtuple = ExecCallTriggerFunc(&LocTriggerData, i,
relinfo->ri_TrigFunctions,
relinfo->ri_TrigInstrument,
GetPerTupleMemoryContext(estate));
if (newtuple == NULL)
{
if (should_free)
heap_freetuple(oldtuple);
return false; /* "do nothing" — skip this row */
}
else if (newtuple != oldtuple)
{
newtuple = check_modified_virtual_generated(RelationGetDescr(...), newtuple);
ExecForceStoreHeapTuple(newtuple, slot, false); /* trigger rewrote NEW */
/* ... partition-fit recheck for cloned triggers ... */
}

AFTER 행 훅은 발화 시점에 거의 아무것도 하지 않는다. ExecARInsertTriggers는 after-row 트리거나 전환 테이블이 관련된 경우에만 AfterTriggerSaveEvent를 호출하고 바로 반환한다. 사용자 코드는 여기서 실행되지 않는다.

// ExecARInsertTriggers — src/backend/commands/trigger.c
if ((trigdesc && trigdesc->trig_insert_after_row) ||
(transition_capture && transition_capture->tcs_insert_new_table))
AfterTriggerSaveEvent(estate, relinfo, NULL, NULL,
TRIGGER_EVENT_INSERT,
true /* row_trigger */, NULL, slot,
recheckIndexes, NULL,
transition_capture, false);

구문 레벨 AFTER 훅도 유사하다. ExecASInsertTriggersrow_trigger = falseAfterTriggerSaveEvent를 한 번 호출한다. 구문 레벨 BEFORE 훅(ExecBSInsertTriggers)은 BEFORE 행처럼 동기적으로 실행되지만 값을 반환할 수 없다(BEFORE STATEMENT 트리거가 non-NULL을 반환하면 오류다). INSTEAD OF 훅(ExecIR*, 뷰 전용)은 동기적으로 실행되고 연산 전체를 대체한다.

모든 트리거 함수가 실제로 호출되는 단일 초크포인트는 ExecCallTriggerFunc다. 이 함수는 튜플별 메모리 컨텍스트로 전환하고, TriggerData를 fmgr 컨텍스트로 담은 fcinfo를 설정하고, MyTriggerDepth(재귀 계정)를 증가시키고, fmgr로 호출한다.

// ExecCallTriggerFunc — src/backend/commands/trigger.c
oldContext = MemoryContextSwitchTo(per_tuple_context);
InitFunctionCallInfoData(*fcinfo, finfo, 0, InvalidOid, (Node *) trigdata, NULL);
pgstat_init_function_usage(fcinfo, &fcusage);
MyTriggerDepth++;
PG_TRY();
{
result = FunctionCallInvoke(fcinfo);
}
PG_FINALLY();
{
MyTriggerDepth--;
}
PG_END_TRY();

함수는 SQL 인수를 받지 않는다. 이벤트 타입, OLD/NEW 슬롯, Trigger 구조체, 전환 튜플스토어 등 모든 정보는 fcinfo->contextTriggerData 노드에 담겨 전달된다. PL 트리거는 CALLED_AS_TRIGGER 매크로와 언어별 글루로 이를 읽는다.

실제로 ExecCallTriggerFunc를 호출하기 전에, 각 훅은 후보 트리거마다 TriggerEnabled를 통과시킨다. 이 함수는 카탈로그 플래그만으로는 답할 수 없는 세 가지 “이 트리거가 이 행/이벤트에서 발화해야 하는가” 검사를 묶는다.

// TriggerEnabled — src/backend/commands/trigger.c
/* 1. session_replication_role vs. tgenabled */
if (SessionReplicationRole == SESSION_REPLICATION_ROLE_REPLICA)
{
if (trigger->tgenabled == TRIGGER_FIRES_ON_ORIGIN ||
trigger->tgenabled == TRIGGER_DISABLED)
return false;
}
/* 2. column-specific UPDATE trigger: skip if no listed column changed */
if (trigger->tgnattr > 0 && TRIGGER_FIRED_BY_UPDATE(event))
{
/* ... return false unless some tgattr[] member is in modifiedCols ... */
}
/* 3. WHEN (...) qualifier */
if (trigger->tgqual)
{
econtext->ecxt_innertuple = oldslot; /* OLD -> INNER_VAR */
econtext->ecxt_outertuple = newslot; /* NEW -> OUTER_VAR */
if (!ExecQual(*predicate, econtext))
return false;
}

세 가지를 짚어 두자. 첫째, tgenabled는 불리언이 아니다. Origin/Replica/Always/Disabled 네 가지 상태로 session_replication_role과 상호작용한다. 로지컬 리플리케이션 적용 워커가 원본 측 트리거를 억제하는 메커니즘이며, pg_dumppglogical도 같은 방식에 의존한다. 둘째, 컬럼 목록(UPDATE OF col1, col2)은 큐에 넣을 때가 아니라 여기서 구문의 modifiedCols 비트맵과 대조한다. 수정되지 않은 컬럼에 걸린 컬럼별 UPDATE 트리거는 큐에 닿기도 전에 걸러진다. 셋째, WHEN 조건은 지연 컴파일된다. 쿼리당 첫 발화 시 stringToNodetgqual을 파싱하고, OLD/NEW Var 참조를 INNER_VAR/OUTER_VAR로 재작성한 뒤 ExprStateri_TrigWhenExprs[]에 캐시해 OLD/NEW 슬롯을 대상으로 평가한다. AFTER 트리거에서 중요한 점은 WHEN 조건이 발화 시점이 아닌 저장 시점에 평가된다는 것이다. OLD/NEW 이미지가 모두 있는 유일한 순간이 그때이기 때문이다.

동작 예시: 순서, 재귀, 발화 루프

섹션 제목: “동작 예시: 순서, 재귀, 발화 루프”

조각들을 하나의 구문으로 엮어 보자. 테이블 t에 BEFORE 행 트리거 a_stamp, AFTER 행 트리거 b_audit, AFTER 구문 트리거 c_summary가 있다고 하고 UPDATE t SET x = x + 1 WHERE x < 100을 실행해 40행이 영향받는다고 하자.

flowchart TD
    START["ExecutorStart -> AfterTriggerBeginQuery<br/>query_depth++"]
    BS["ExecBSUpdateTriggers<br/>(no BS trigger here: flag clear, return)"]
    LOOP["for each of the 40 matching rows"]
    BR["ExecBRUpdateTriggers<br/>run a_stamp NOW, may rewrite NEW"]
    UPD["table_tuple_update applies the row"]
    AR["ExecARUpdateTriggers<br/>queue b_audit event (ctid1=old, ctid2=new)"]
    AS["ExecASUpdateTriggers<br/>queue ONE c_summary statement event"]
    END["ExecutorFinish -> AfterTriggerEndQuery"]
    MARK["afterTriggerMarkEvents: stamp firable events<br/>with firing_id = counter++"]
    INV["afterTriggerInvokeEvents: fire in queue order<br/>40x b_audit, then c_summary"]
    START --> BS --> LOOP --> BR --> UPD --> AR --> LOOP
    LOOP -->|"all rows done"| AS --> END --> MARK --> INV
    INV -.->|"a fired trigger queued more?"| MARK

소스에서 드러나는 몇 가지 불변식을 이 추적에서 확인할 수 있다. 40개의 b_audit 이벤트는 행 처리 순서대로 큐에 들어가며, 단 하나의 AfterTriggerSharedData 레코드(동일한 tgoid, relid, rolid)를 공유한다. 큐에는 40개의 1-CTID 이벤트 레코드와 디스크립터 하나가 있다. 단 하나의 c_summary 이벤트는 ExecASUpdateTriggers가 구문 끝에서 한 번 실행되므로 모든 행 이벤트 이후에 큐에 들어간다. cancel_prior_stmt_triggers(AfterTriggerSaveEvent가 구문 이벤트 처리 시 호출)는 쓰기 가능한 CTE 재진입이 있어도 구문 트리거가 정확히 한 번만 발화되도록 보장한다. AfterTriggerEndQuery에서 이벤트에 새 firing_id를 찍고 큐 순서대로 발화한다. 행 트리거가 구문 트리거보다 먼저 발화된다. 바깥의 for (;;) 루프가 b_audit이나 c_summary 자체가 DML을 발행해 이벤트를 더 큐에 넣은 경우 afterTriggerMarkEvents를 다시 실행한다. 폭주 재귀는 정적 분석이 아니라 ExecCallTriggerFunc에서 증가하는 MyTriggerDepthmax_stack_depth, 구문 타임아웃의 상호작용으로 제한된다.

AFTER 트리거를 인라인으로 실행하지 않는 이유는 구문의 최종 상태를 봐야 하기 때문이다. 모든 행이 수정되고, BEFORE 트리거가 실행되고, 제약이 적용된 이후의 상태다. 지연 가능 제약이라면 커밋까지 기다려야 할 수도 있다. PostgreSQL은 따라서 발화당 작은 이벤트 하나를 기록하고 나중에 큐를 드레인한다. 레코드는 의도적으로 최소화했다. 튜플 복사본이 아닌 플래그 워드 하나와 한두 개의 아이템 포인터(CTID)다.

// AfterTriggerEventData — src/backend/commands/trigger.c
typedef struct AfterTriggerEventData
{
TriggerFlags ate_flags; /* status bits + offset to shared data */
ItemPointerData ate_ctid1; /* inserted/deleted/old-updated tuple */
ItemPointerData ate_ctid2; /* new updated tuple */
Oid ate_src_part; /* cross-partition update only */
Oid ate_dst_part;
} AfterTriggerEventData;

영리한 부분은 트리거별 메타데이터 — 어느 트리거인지, 어느 관계인지, 어느 롤인지, 수정된 컬럼 집합 — 가 별도의 AfterTriggerSharedData 레코드로 분리되어 있고 여러 이벤트가 하나의 공유 레코드를 가리킬 수 있다는 점이다. ate_flags의 하위 27비트가 이벤트에서 공유 레코드까지의 바이트 오프셋을 담는다(GetTriggerSharedData). 상위 비트는 크기 클래스와 상태(AFTER_TRIGGER_1CTID, _2CTID, _CP_UPDATE, 그리고 AFTER_TRIGGER_IN_PROGRESS/_DONE)를 인코딩한다. 구문의 큐에 들어간 이벤트 대부분이 동일한 트리거와 관계를 공유하므로, 이벤트당 비용은 대략 AfterTriggerEventDataOneCtid 크기 — 플래그 워드 하나와 6바이트 CTID 하나 — 로 줄어든다. FK 체크 트리거 하나를 발화하는 백만 행 UPDATE라면 큐는 디스크립터 하나를 공유하는 12바이트짜리 레코드 백만 개가 된다. 튜플 복사본 백만 개가 아니다.

이벤트는 AfterTriggerEventList에 산다. 기하급수적으로 증가하는 청크들의 연결 목록이다(1 KB에서 1 MB까지 두 배씩). 각 청크는 양방향 아레나다. AfterTriggerEventData 레코드는 freeptr에서 위로 증가하고, AfterTriggerSharedData 레코드는 endfree에서 아래로 증가하며, 오프셋 링크가 양쪽을 연결한다.

flowchart LR
    subgraph chunk["AfterTriggerEventChunk (arena)"]
        direction TB
        E1["event[0]<br/>flags+ctid1"]
        E2["event[1]<br/>flags+ctid1"]
        EDOTS["..."]
        FREE["free space"]
        SDOTS["..."]
        S1["shared[1]"]
        S0["shared[0]<br/>tgoid, relid, firing_id"]
    end
    E1 -.->|"ate_flags & OFFSET"| S0
    E2 -.->|"ate_flags & OFFSET"| S0
    L["AfterTriggerEventList<br/>head / tail / tailfree"] --> chunk

afterTriggerAddEvent가 할당자다. 꼬리 청크에서 공간을 찾거나(AfterTriggerEvents 메모리 컨텍스트에서 더 큰 청크를 malloc), 청크의 기존 공유 레코드에서 일치하는 것을 스캔하고 재사용하거나 새 것을 복사한 뒤, 이벤트를 memcpy하고 오프셋 링크를 연결한다.

// afterTriggerAddEvent — src/backend/commands/trigger.c
/* try to locate a matching shared-data record already in the chunk */
for (newshared = (AfterTriggerShared) chunk->endfree;
(char *) newshared < chunk->endptr; newshared++)
{
if (newshared->ats_tgoid == evtshared->ats_tgoid &&
newshared->ats_event == evtshared->ats_event &&
newshared->ats_firing_id == 0 &&
/* ... relid, rolid, modifiedcols all equal ... */ )
break;
}
/* ... allocate a new shared record if none matched ... */
newevent = (AfterTriggerEvent) chunk->freeptr;
memcpy(newevent, event, eventsize);
newevent->ate_flags &= ~AFTER_TRIGGER_OFFSET;
newevent->ate_flags |= (char *) newshared - (char *) newevent; /* link */
chunk->freeptr += eventsize;

큐는 2레벨 구조다. afterTriggers.query_stack[query_depth].events는 현재 실행 중인 쿼리의 이벤트를 담고, afterTriggers.events는 트랜잭션 전역 지연 목록이다. 이 분리가 즉시 모드 대 지연 모드와 서브트랜잭션 롤백을 다루기 쉽게 만든다. AfterTriggersData의 긴 주석이 설명한다.

// AfterTriggersData — src/backend/commands/trigger.c
typedef struct AfterTriggersData
{
CommandId firing_counter; /* next firing-cycle ID to assign */
SetConstraintState state; /* active SET CONSTRAINTS state */
AfterTriggerEventList events; /* transaction-global deferred list */
MemoryContext event_cxt; /* memory context for events */
AfterTriggersQueryData *query_stack; /* per-query-level events */
int query_depth; /* current index; -1 when empty */
int maxquerydepth;
AfterTriggersTransData *trans_stack; /* per-subxact saved pointers */
int maxtransdepth;
} AfterTriggersData;

라이프사이클 훅들(모두 xact.c/익스큐터에서 호출됨, 사용자 코드가 아님)은 다음과 같다.

  • AfterTriggerBeginXact — 트랜잭션 시작 시 상태 초기화. firing_counter = 1, query_depth = -1.
  • AfterTriggerBeginQueryquery_depth++. standard_ExecutorStart/ExecutorStart에서 호출된다. 저렴하다. 실제 할당은 지연된다.
  • AfterTriggerEndQuery — 즉시 모드 이벤트의 드레인. ExecutorFinish에서 호출된다. afterTriggerMarkEvents를 호출해 발화 가능한 이벤트에 다음 발화 사이클 ID를 찍고(지연 이벤트는 여기서 전역 목록으로 이동), 남은 이벤트가 없을 때까지 afterTriggerInvokeEvents를 루프한다. 발화된 트리거가 같은 레벨에 이벤트를 더 큐에 넣을 수 있기 때문이다.
// AfterTriggerEndQuery — src/backend/commands/trigger.c
qs = &afterTriggers.query_stack[afterTriggers.query_depth];
for (;;)
{
if (afterTriggerMarkEvents(&qs->events, &afterTriggers.events, true))
{
CommandId firing_id = afterTriggers.firing_counter++;
AfterTriggerEventChunk *oldtail = qs->events.tail;
if (afterTriggerInvokeEvents(&qs->events, firing_id, estate, false))
break; /* all fired */
qs = &afterTriggers.query_stack[afterTriggers.query_depth]; /* may have moved */
/* drop fully-fired leading chunks to speed the rescan */
while (qs->events.head != oldtail)
afterTriggerDeleteHeadEventChunk(qs);
}
else
break;
}
  • AfterTriggerFireDeferred — 트랜잭션 전역 지연 목록의 드레인. 커밋 직전 CommitTransaction에서 호출된다. 스냅샷을 푸시한 뒤 빌 때까지 mark+invoke를 루프한다. 지연 트리거도 이벤트를 더 큐에 넣을 수 있기 때문이다.

firing_counter/firing_id 체계가 SET CONSTRAINTS ... IMMEDIATE를 온전하게 유지하는 방법이다. 각 드레인 패스는 발화하려는 이벤트에 고유한 사이클 ID를 찍는다. afterTriggerInvokeEvents는 현재 사이클과 ats_firing_id가 일치하고 AFTER_TRIGGER_IN_PROGRESS 비트가 설정된 이벤트만 발화한다. 트리거가 발행한 중첩 SET CONSTRAINTS는 이미 스케줄링된 이벤트를 발화하지 않는다.

발화 시점에 이벤트는 CTID만 담고 있으므로, afterTriggerInvokeEvents(AfterTriggerExecute 경유)는 SnapshotAny 아래 CTID로 튜플을 재조회한다. 트리거는 변경 트랜잭션을 대신해 작동하므로 MVCC 가시성과 무관하게 행을 찾아야 한다.

// AfterTriggerExecute — src/backend/commands/trigger.c (default, heap case)
if (!table_tuple_fetch_row_version(src_rel, &(event->ate_ctid1),
SnapshotAny, src_slot))
elog(ERROR, "failed to fetch tuple1 for AFTER trigger");
LocTriggerData.tg_trigtuple =
ExecFetchSlotHeapTuple(LocTriggerData.tg_trigslot, false, &should_free_trig);

(외부 테이블 이벤트는 다른 경로를 따른다. CTID로 튜플을 재조회할 수 없으므로, 저장 시점에 FDW 튜플스토어로 스풀되고 여기서 읽어 들인다. AFTER_TRIGGER_FDW_FETCH/_REUSE 플래그로 식별된다.)

서브트랜잭션 롤백은 trans_stack이 처리한다. 서브트랜잭션 시작 시 현재 events head/tail 포인터를 저장하고, 중단 시 afterTriggerRestoreEventList가 목록을 저장된 위치로 잘라 낸다. 중단된 서브트랜잭션이 추가한 청크만 정확히 버린다. 전체 이벤트 수가 아닌 추가된 청크 수에 비례한다.

전환 테이블은 별도로 튜플스토어로 포착된다

섹션 제목: “전환 테이블은 별도로 튜플스토어로 포착된다”

REFERENCING OLD TABLE/NEW TABLE은 AFTER 기계의 라이프사이클을 공유하지만 CTID 이벤트 표현은 공유하지 않는 별도 메커니즘이다. 관계에 전환 테이블 트리거가 하나라도 있으면, 익스큐터(nodeModifyTable의 설정 등)가 MakeTransitionCaptureStateTransitionCaptureState를 만들고 (서브)트랜잭션의 CurTransactionContexttuplestore 객체를 할당한다.

// MakeTransitionCaptureState — src/backend/commands/trigger.c
if (need_old_upd && upd_table->old_tuplestore == NULL)
upd_table->old_tuplestore = tuplestore_begin_heap(false, false, work_mem);
if (need_new_upd && upd_table->new_tuplestore == NULL)
upd_table->new_tuplestore = tuplestore_begin_heap(false, false, work_mem);
/* ... old_del, new_ins similarly; keyed by (relid, cmdType) ... */
state = (TransitionCaptureState *) palloc0(sizeof(TransitionCaptureState));
state->tcs_update_old_table = need_old_upd;
state->tcs_update_new_table = need_new_upd;
state->tcs_update_private = upd_table;

각 행이 ExecAR*AfterTriggerSaveEvent를 흐를 때, OLD/NEW 슬롯도 이벤트 큐와 별도로 매칭되는 튜플스토어에 추가된다(TransitionTableAddTuple). 결정적인 설계 제약은 MakeTransitionCaptureStateAfterTriggersData 주석 모두에 명시되어 있다. 전환 테이블은 지연 불가능하다. 튜플스토어는 AfterTriggerEndQuery까지만 살아 있으므로, 지연 가능 트리거는 전환 테이블을 참조할 수 없다. 그래서 튜플스토어가 지연 이벤트 목록처럼 커밋까지 살아남지 않고 쿼리 레벨이 팝될 때 CurTransactionContext에서 해제될 수 있다. 트리거 함수는 이 테이블을 TriggerDatatg_oldtable/tg_newtable로 보며, SQL에서는 명명된 OLD/NEW 릴레이션으로 노출된다.

이 절은 트리거가 카탈로그에서 실행까지 가는 경로를 기준으로 안정적인 심볼들을 정리한다. 이 훅들을 호출하는 DML 노드(nodeModifyTable.c), CREATE TRIGGER 유틸리티 플러밍, fmgr 호출 관례는 각각 postgres-executor.md, postgres-ddl-execution.md, postgres-fmgr.md에서 다루며, 여기서는 트리거 서브시스템 내부에 머문다.

정의와 카탈로그 → 릴캐시 디스크립터.

  • CreateTrigger / CreateTriggerFiringOnCREATE TRIGGER 구현. 검증, 함수 조회, pg_triggerCatalogTupleInsert. 새 트리거의 ObjectAddress를 반환한다.
  • RemoveTriggerById, renametrig, EnableDisableTrigger — 나머지 DDL 표면(drop, rename, ENABLE/DISABLE).
  • Form_pg_trigger / tgtype 비트 매크로(TRIGGER_TYPE_ROW, _BEFORE, _INSERT, …, _INSTEAD)와 TRIGGER_TYPE_MATCHES — 압축된 온디스크 분류와 테스트 매크로.
  • RelationBuildTriggers — 릴캐시 훅. TriggerRelidNameIndexId(이름 순서 = 발화 순서)로 pg_trigger를 스캔하고 TriggerDesc를 채운다.
  • SetTriggerFlags — 각 트리거를 TriggerDesc 요약 불리언(trig_insert_before_row, …, trig_*_old_table)에 OR한다.
  • CopyTriggerDesc, FreeTriggerDesc, equalTriggerDescs — 릴캐시가 사용하는 디스크립터 라이프사이클.
  • Trigger, TriggerDesc(reltrigger.h 소재), TriggerData, TransitionCaptureState(trigger.h 소재) — 인메모리 구조체.

행/구문 발화 지점(익스큐터가 호출).

  • ExecBSInsertTriggers / ExecBSUpdateTriggers / ExecBSDeleteTriggers / ExecBSTruncateTriggers — BEFORE STATEMENT. 동기 실행, 값 반환 불가. before_stmt_triggers_fired로 이중 발화를 방지한다.
  • ExecBRInsertTriggers / ExecBRUpdateTriggers / ExecBRDeleteTriggers — BEFORE ROW. 동기 실행, (재작성됐을 수도 있는 / NULL) 튜플을 반환한다. ExecBRUpdateTriggers/ExecBRDeleteTriggers는 먼저 GetTupleForTrigger로 이전 행을 가져온다.
  • ExecIRInsertTriggers / ExecIRUpdateTriggers / ExecIRDeleteTriggers — INSTEAD OF ROW(뷰). 연산을 대체한다.
  • ExecARInsertTriggers / ExecARUpdateTriggers / ExecARDeleteTriggers — AFTER ROW. AfterTriggerSaveEvent를 호출하는 얇은 가드.
  • ExecASInsertTriggers / ExecASUpdateTriggers / ExecASDeleteTriggers / ExecASTruncateTriggers — AFTER STATEMENT. row_trigger = falseAfterTriggerSaveEvent를 호출한다.
  • ExecCallTriggerFunc — 단일 fmgr 초크포인트. TriggerData를 설정하고, MyTriggerDepth를 증가시키며, 튜플별 컨텍스트에서 함수를 호출한다.
  • TriggerEnabledtgenabled(세션 리플리케이션 역할)와 WHEN 조건을 평가해 이 행/이벤트에서 트리거를 발화할지 반환한다.

After-trigger 큐(이벤트 레코드, 청크, 드레인).

  • AfterTriggerEventData(+ …NoOids, …OneCtid, …ZeroCtids 크기 변형), AfterTriggerSharedData, AfterTriggerEventChunk, AfterTriggerEventList — 큐 상의 표현.
  • SizeofTriggerEvent, GetTriggerSharedData, for_each_event/for_each_chunk 이터레이터 매크로, AFTER_TRIGGER_OFFSET/_IN_PROGRESS/_DONE/_1CTID/_2CTID/_CP_UPDATE 플래그 비트.
  • AfterTriggersData, AfterTriggersQueryData, AfterTriggersTransData, AfterTriggersTableData와 파일 정적 afterTriggers — 전역 상태.
  • AfterTriggerSaveEvent — 모든 ExecAR*/ExecAS*의 진입점. 이벤트를 검증하고, 전환 튜플을 포착하며, 플래그를 계산하고, afterTriggerAddEvent를 호출한다.
  • afterTriggerAddEvent — 청크 아레나 할당자와 공유 레코드 중복 제거.
  • afterTriggerMarkEvents — 발화 가능한 이벤트에 현재 firing_id를 찍고 지연 이벤트를 이동 목록으로 마이그레이션한다.
  • afterTriggerInvokeEventsAfterTriggerExecuteSnapshotAny 아래 CTID로 튜플을 재조회하거나 FDW 튜플스토어에서 읽어 ExecCallTriggerFunc를 호출한다.
  • afterTriggerCheckState, SetConstraintsCommand, SetConstraintStateCreateSET CONSTRAINTS(지연) 상태.
  • afterTriggerFreeEventList, afterTriggerRestoreEventList, afterTriggerDeleteHeadEventChunk — 해제와 서브트랜잭션 중단 시 잘라 내기.

라이프사이클 훅(xact.c/익스큐터에서 호출).

  • AfterTriggerBeginXact, AfterTriggerBeginQuery, AfterTriggerEndQuery, AfterTriggerFireDeferred, AfterTriggerEndXact, AfterTriggerBeginSubXact, AfterTriggerEndSubXact — 큐의 트랜잭션 통합.
  • AfterTriggerEnlargeQueryState — 필요 시 query_stack을 늘린다.

전환 테이블.

  • MakeTransitionCaptureState(relid, cmdType) 키로 OLD/NEW 튜플스토어를 할당한다. 전환 테이블이 필요 없으면 NULL을 반환한다.
  • GetAfterTriggersTableData, GetAfterTriggersTransitionTable, GetAfterTriggersStoreSlot, TransitionTableAddTuple — 테이블별 데이터를 찾거나 만들고 행을 추가한다.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
Trigger (struct)src/include/utils/reltrigger.h23
TriggerDesc (struct)src/include/utils/reltrigger.h47
TriggerData (struct)src/include/commands/trigger.h31
TransitionCaptureState (struct)src/include/commands/trigger.h56
TRIGGER_EVENT_* flagssrc/include/commands/trigger.h94
TRIGGER_TYPE_ROW_INSTEADsrc/include/catalog/pg_trigger.h93
TRIGGER_TYPE_MATCHESsrc/include/catalog/pg_trigger.h141
CreateTriggersrc/backend/commands/trigger.c161
CreateTriggerFiringOnsrc/backend/commands/trigger.c178
RelationBuildTriggerssrc/backend/commands/trigger.c1862
SetTriggerFlagssrc/backend/commands/trigger.c2014
CopyTriggerDescsrc/backend/commands/trigger.c2091
ExecCallTriggerFuncsrc/backend/commands/trigger.c2310
ExecBSInsertTriggerssrc/backend/commands/trigger.c2402
ExecASInsertTriggerssrc/backend/commands/trigger.c2453
ExecBRInsertTriggerssrc/backend/commands/trigger.c2466
ExecARInsertTriggerssrc/backend/commands/trigger.c2544
ExecIRInsertTriggerssrc/backend/commands/trigger.c2570
ExecBRDeleteTriggerssrc/backend/commands/trigger.c2702
ExecARDeleteTriggerssrc/backend/commands/trigger.c2802
ExecBRUpdateTriggerssrc/backend/commands/trigger.c2972
ExecARUpdateTriggerssrc/backend/commands/trigger.c3145
ExecBSTruncateTriggerssrc/backend/commands/trigger.c3281
TriggerEnabledsrc/backend/commands/trigger.c3483
AFTER_TRIGGER_* flag bitssrc/backend/commands/trigger.c3682
AfterTriggerSharedDatasrc/backend/commands/trigger.c3694
AfterTriggerEventDatasrc/backend/commands/trigger.c3707
SizeofTriggerEvent / GetTriggerSharedDatasrc/backend/commands/trigger.c3743
AfterTriggerEventChunksrc/backend/commands/trigger.c3762
AfterTriggerEventListsrc/backend/commands/trigger.c3774
AfterTriggersDatasrc/backend/commands/trigger.c3880
afterTriggerCheckStatesrc/backend/commands/trigger.c4008
afterTriggerAddEventsrc/backend/commands/trigger.c4078
afterTriggerRestoreEventListsrc/backend/commands/trigger.c4226
AfterTriggerExecutesrc/backend/commands/trigger.c4328
afterTriggerMarkEventssrc/backend/commands/trigger.c4614
afterTriggerInvokeEventssrc/backend/commands/trigger.c4698
GetAfterTriggersTableDatasrc/backend/commands/trigger.c4867
MakeTransitionCaptureStatesrc/backend/commands/trigger.c4958
AfterTriggerBeginXactsrc/backend/commands/trigger.c5084
AfterTriggerBeginQuerysrc/backend/commands/trigger.c5116
AfterTriggerEndQuerysrc/backend/commands/trigger.c5136
AfterTriggerFireDeferredsrc/backend/commands/trigger.c5287
AfterTriggerEndXactsrc/backend/commands/trigger.c5343
GetAfterTriggersTransitionTablesrc/backend/commands/trigger.c5536
AfterTriggerSaveEventsrc/backend/commands/trigger.c6169
before_stmt_triggers_firedsrc/backend/commands/trigger.c6584

REL_18_STABLE 커밋 273fe94의 /data/hgryoo/references/postgres를 대상으로 확인. 확인된 사실들:

  • PG18 비호환 심볼 없음. 제거된 rmgr(XLOG2)나 B_DATACHECKSUMSWORKER_* 워커 상태를 참조하지 않는다. 인용된 모든 트리거 심볼은 REL_18 트리에 존재한다.
  • tgtype 비트 레이아웃 (TRIGGER_TYPE_ROW=1<<0 … TRIGGER_TYPE_INSTEAD=1<<6, STATEMENT/AFTER가 제로 비트 경우) src/include/catalog/pg_trigger.h 93–98번째 줄에서 확인. TRIGGER_TYPE_MATCHES는 141번째 줄.
  • 이름 순서 발화 TriggerRelidNameIndexId 스캔과 RelationBuildTriggers의 설명 주석으로 확인(src/backend/commands/trigger.c).
  • 15+2 발화 지점 함수 (ExecBR/AR/IR × INSERT/UPDATE/DELETE, ExecBS/AS × INSERT/UPDATE/DELETE/TRUNCATE, 행 레벨 TRUNCATE 없음) 모두 인용된 시그니처로 존재 확인. ExecBRInsertTriggers는 튜플을 반환하고, ExecARInsertTriggersAfterTriggerSaveEvent 호출 후 void를 반환한다.
  • 이벤트 레코드 크기. AfterTriggerEventDataate_flags, ate_ctid1, ate_ctid2, ate_src_part, ate_dst_part를 담는다. 네 가지 크기 변형과 SizeofTriggerEvent, 하위 27비트(AFTER_TRIGGER_OFFSET = 0x07FFFFFF) 오프셋 링크와 GetTriggerSharedData를 확인.
  • 청크 증가 1 KB → 1 MB(MIN_CHUNK_SIZE 1024, MAX_CHUNK_SIZE 1024*1024)와 afterTriggerAddEvent의 두 배/절반 발견적 방법을 확인.
  • 발화 시 SnapshotAny 재조회 AfterTriggerExecutetable_tuple_fetch_row_version으로 확인. FDW 경로는 AFTER_TRIGGER_FDW_FETCH/_REUSE와 튜플스토어를 사용한다.
  • 라이프사이클 순서AfterTriggerEndQuery가 즉시 이벤트를 발화하고 지연 이벤트를 마이그레이션. AfterTriggerFireDeferred(커밋 전)가 전역 목록을 트랜잭션 스냅샷 아래 드레인 — 인용된 본문과 헤더 주석으로 확인.
  • 전환 테이블은 절대 지연 불가능MakeTransitionCaptureStateAfterTriggersData 주석에 명시. 튜플스토어는 CurTransactionContext에 할당되어 AfterTriggerFreeQuery에서 해제된다.
  • 줄 번호 주의사항. 위치 힌트 표의 줄 번호는 273fe94 기준이다. AfterTriggerExecute(4328)와 CreateTriggerFiringOn(178)은 함수 정의 줄이다. 심볼이 지속적인 기준점이며, 줄 번호는 재포맷 시 변한다.

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

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

PostgreSQL의 트리거 아키텍처는 SQL 표준이 부분적으로만 규정하는 설계 공간의 한 검증된 지점이다. 다른 엔진과 연구 문헌과 비교하면 이 선택들이 왜 그 자리에 놓이는지 더 선명해진다.

발화 순서와 표준의 침묵. SQL 표준은 동일 이벤트에 여러 트리거가 걸렸을 때의 발화 순서를 거의 규정하지 않으며 엔진마다 다르다. PostgreSQL은 트리거 이름 알파벳 순으로 발화한다(RelationBuildTriggersTriggerRelidNameIndexId 스캔의 결과다). 임의적이지만 안정적이고 검사 가능하다. Database System Concepts(§5.3.2)가 “의도치 않은 발화 순서”를 위험으로 경고할 때 암묵적으로 요구하는 속성이다. Oracle은 과거에 같은 타이밍 내 순서를 미정의로 두다가 11g에서 명시적 순서를 위한 FOLLOWS/PRECEDES 절을 추가했다. SQL Server는 AFTER 트리거를 sp_settriggerorder로 설정할 수 있는 첫/마지막을 제외하면 미정의 순서로 발화한다. PostgreSQL의 “이름 순서”는 셋 중 가장 예측 가능하지만 가장 표현력이 낮다. 트리거별 순서 절이 없어 사용자가 이름에 순서를 인코딩한다(01_audit, 02_fk). 의도적인 단순성의 절충이다.

BEFORE 행 튜플 재작성 대 표준의 SET 모델. PostgreSQL은 BEFORE FOR EACH ROW 트리거가 변경된 NEW 튜플을 반환하게 하고, 반환된 튜플이 저장된다(ExecBRInsertTriggers). SQL 표준은 트리거 본문 내 NEW.col에 대한 대입으로 행 수정을 모델링한다. 관측 결과는 비슷하지만, PostgreSQL의 “튜플 반환” 관례가 BEFORE 트리거를 파이프라인으로 구성할 수 있게 하는 것이다. 각 트리거의 출력이 다음 트리거의 입력이 된다. BEFORE 트리거가 NULL을 반환해 행을 거부하는 기능도 같은 관례에서 나온다. 대입 모델로는 어색하게밖에 표현하기 어렵다.

제약을 트리거로 구현하는 깊은 설계 선택. PostgreSQL은 외래 키와 지연 가능 유일성을 같은 큐를 타는 내부 AFTER 트리거로 구현한다(RI_FKey_* 트리거 함수, F_UNIQUE_KEY_RECHECK). 교재가 §5.3.3에서 선언적 제약과 트리거를 대비시키는 두 가지를 하나로 통합한 것이다. 그 결과 after-trigger 큐의 정확성이 곧 참조 무결성의 정확성이 된다. AfterTriggerSaveEvent에 RI 전용 건너뛰기 로직(RI_FKey_trigger_type, 교차 파티션 업데이트 특수 케이스)이 가득 찬 것이 그 비용이다. 제약을 별도 경로로 처리하는 엔진은 더 단순한 트리거 큐를 갖지만 두 가지 무결성 메커니즘을 일관성 있게 유지해야 한다. 이 설계의 연구 계보는 어설션/무결성 제약 유지(1990년대 초 Ceri & Widom의 제약 유지를 위한 프로덕션 규칙 도출 연구)다. 선언적 규칙의 운용 형태로 트리거를 자리매김했다. 정확히 PostgreSQL의 입장이다.

능동 데이터베이스와 ECA 계보. 트리거는 능동 데이터베이스 연구 프로그램(HiPAC, Ariel, Starburst, 1980년대 말~1990년대 초)에서 살아남은 상용 단편이다. 이 프로그램은 이벤트-조건-액션 규칙을 범용 반응 메커니즘으로 연구했다. 복합 이벤트, 결합 모드(즉시/지연/분리), 규칙 실행 의미론(종료성, 합류성)이 주제였다. PostgreSQL은 그 중 실용적인 부분집합을 구현한다. immediatedeferred 결합이 즉시 모드와 지연 가능 이벤트에 각각 대응하고, 복합 이벤트와 분리(별도 트랜잭션) 결합은 없다. 능동 DB 문헌이 형식적으로 연구한 종료 문제는 이 구현에서 런타임 가드로 나타난다. MyTriggerDepthmax_stack_depth/구문 타임아웃의 상호작용이며, 정적 합류성 분석은 없다.

집합 지향 대 행 지향 반응. 전환 테이블(MakeTransitionCaptureState의 튜플스토어 포착)은 행 레벨 트리거에 대한 오랜 비판에 대한 PostgreSQL의 답이다. 행 레벨 트리거는 함수 호출이 O(행 수)인 반면, 전환 테이블 위의 구문 레벨 트리거는 전체 델타에 집합 기반 SQL 구문 하나를 실행할 수 있다. 이는 증분 뷰 유지보수(DRed와 카운팅 알고리즘, Gupta & Mumick)의 델타 릴레이션 접근과 같다. 변경을 튜플별 이벤트 대신 삽입/삭제 집합으로 표현한다. 미결 과제 하나는 전환 테이블 포착 경로가 사용자 트리거 대신 IVM 엔진에 직접 피드될 수 있는지다. PostgreSQL의 증분 매트뷰 노력과 관련된 전선이다.

푸시다운과 스트리밍 전선. 현대 시스템은 반응 로직을 트리거 큐 으로 밀어낸다. 변경 데이터 캡처와 로지컬 리플리케이션(PostgreSQL 자체의 logical_decoding, postgres-logical-decoding.md에서 다룸)은 커밋 이후 WAL에서 행 델타를 재구성해 반응을 쓰기 트랜잭션과 완전히 분리한다. 능동 DB 문헌이 예측한 “분리 결합 모드”다. 대량 감사/리플리케이션 워크로드에서는 행당 AFTER 트리거보다 비용이 낮다. 쓰기 경로에서 아무것도 소비하지 않기 때문이다. 트리거 큐가 여전히 적합한 경우는 반응이 변경과 동기적이고 트랜잭션적이어야 할 때다. 큐가 구축된 근본 이유인 FK 강제 사례가 정확히 그것이다.

  • 소스 트리. REL_18_STABLE 커밋 273fe94의 /data/hgryoo/references/postgres(PG 18.x). 주 파일: src/backend/commands/trigger.c. 헤더: src/include/commands/trigger.h, src/include/utils/reltrigger.h, src/include/catalog/pg_trigger.h. 호출자(교차 참조만, 재분석 없음): src/backend/executor/nodeModifyTable.c, src/backend/utils/cache/relcache.c(RelationBuildTriggers 훅), src/backend/access/transam/xact.c(라이프사이클 호출).
  • 교재 기준. Silberschatz, Korth & Sudarshan, Database System Concepts, 7판, 5장 “Advanced SQL”, §5.3 “Triggers”(ECA 모델, 그래뉼러티, 타이밍, 전환 테이블, 제약 대 트리거 지침, 연쇄/순서 위험). knowledge/research/dbms-general/ 아래 포착됨.
  • 비교/역사. 능동 데이터베이스 ECA 계보(HiPAC, Ariel, Starburst). Ceri & Widom의 무결성 제약 유지를 위한 프로덕션 규칙. Gupta & Mumick의 증분 뷰 유지보수와 델타 릴레이션 — 맥락을 위한 인용, PG 소스로 소비하지 않음.
  • 이 KB 내 교차 참조. knowledge/code-analysis/postgres/postgres-executor.md(요구 풀 노드 트리와 AfterTriggerEndQuery를 구동하는 ExecutorFinish), postgres-ddl-execution.md(CREATE TRIGGER 유틸리티 경로), postgres-fmgr.md(ExecCallTriggerFunc 뒤의 함수 호출 관례), postgres-mvcc-snapshots.md(발화 시 재조회가 SnapshotAny를 쓰는 이유), postgres-logical-decoding.md(AFTER 트리거의 커밋 후 분리 대안).