콘텐츠로 이동

(KO) PostgreSQL COPY — 대량 적재/추출, 포맷, 멀티-인서트

데이터베이스 엔진에는 테이블로 들어오고 나가는 두 개의 문이 있다. 좁은 문은 행 단위 INSERT/SELECT 경로다. 각 행은 파싱·계획·실행·로깅·인덱싱이라는 전체 SQL 기계를 통과하며, 문(statement)마다 고정 비용을 치른다. 넓은 문은 *대량 적재기(bulk loader)*다. 외부에서 들어오는 대규모 데이터셋을 테이블에 스트리밍하면서 그 고정 비용을 여러 행에 분산시킨다. PostgreSQL의 넓은 문이 COPY다.

대량 적재기가 해결하는 문제는 근본적으로 임피던스 불일치(impedance mismatch) 문제다. 한쪽에는 외부 바이트 스트림이 있다. 파일, 프로그램 파이프, 클라이언트 소켓이 직렬화 포맷(구분자 텍스트, CSV, 바이너리 튜플 인코딩)으로 바이트를 보내온다. 반대편에는 엔진 내부 행 표현이 있다. 테이블의 TupleDesc에 따라 타입이 지정된 Datum 값들의 TupleTableSlot이 테이블 접근 메서드를 거쳐 힙 페이지로 향한다. 적재기의 역할은 이 두 표현 사이를 정확성이 허락하는 한 가장 빠르게 변환하는 것이다. 추출기는 그 역방향을 수행한다.

Database System Concepts (Silberschatz, Korth, Sudarshan)은 스토리지와 물리적 데이터 구성을 다루는 장에서 적재 비용을 행 단위 I/O 및 부기(bookkeeping) 연산의 수로 정의한다. 행 하나씩 삽입하는 단순한 적재기는 행마다 튜플 삽입 호출, WAL 레코드, 행 단위 인덱스 업데이트, 자유공간맵(FSM) 탐색을 치른다. 잘 설계된 대량 적재기는 이를 압축한다. 튜플 삽입을 *배치(batch)*로 묶어 페이지 하나에 여러 행을 기록하고, 릴레이션이 새것임을 알면 FSM 탐색을 건너뛰고, 트랜잭션 규칙이 허락하면 튜플을 **미리 동결(pre-freeze)**해 나중에 VACUUM이 재방문하지 않아도 되게 한다. PostgreSQL의 COPY는 이 세 가지 최적화를 모두 구현한다.

대량 적재기가 선택하는 설계 공간은 네 개의 축으로 이루어진다.

  1. 포맷 협상. 단일 포맷을 하드코딩할지, 여러 포맷(사람이 읽을 수 있는 구분자 텍스트, RFC-4180 CSV, 콤팩트 바이너리 튜플 인코딩)을 공통 디스패치 계층 뒤에 둘지 결정한다. 플러그 가능한 포맷 계층이 있으면 같은 행 처리 코어로 매우 다른 직렬화를 처리할 수 있다.

  2. 파싱 전략. 텍스트 포맷은 인용(quoting)과 이스케이프 규칙을 준수하면서 행·필드 경계를 찾는 *상태 기계(state machine)*가 필요하다. 바이너리 포맷은 길이-접두 레이아웃을 신뢰하는 *프레임 리더(framed reader)*가 필요하다. 텍스트 적재기의 CPU 대부분은 파서에서 쓰이므로 파서는 집중적으로 튜닝된다.

  3. 배치와 내구성. 쓰기 전에 얼마나 많은 행을 축적할지, 어떤 트랜잭션 보장을 제공할지 결정한다. 배치는 메모리와 처리량을 교환하고, 동결은 MVCC 엄격성과 나중 재작성 제거를 교환한다.

  4. 오류 허용. 잘못된 형식의 행 하나가 전체 적재를 중단시킬지, 아니면 나쁜 행을 건너뛰고 한계까지 계속 진행할지 결정한다. 이것이 취약한 적재기와 운영 수준 적재기의 차이다.

PostgreSQL의 COPY는 단일 명령(DoCopy)으로 옵션 목록에서 네 가지 축을 모두 해결하고, 두 엔진 중 하나를 실행한다. 적재 엔진(CopyFrom) 또는 추출 엔진(DoCopyTo)이 공통 포맷 루틴 추상화를 공유한다.

여러 시스템의 대량 적재기는 공통된 엔지니어링 관례로 수렴한다. 이를 명명하면 PostgreSQL의 특정 심볼을 공유 설계 문서 안의 선택 집합으로 읽을 수 있다.

모든 함수에 if (csv) ... else if (binary) ...를 엮는 대신, 성숙한 적재기는 지원하는 각 포맷을 함수 포인터의 작은 테이블(start, per-field type setup, per-row, end)로 분리한다. 코어 엔진은 vtable로 호출하며 포맷을 다시 테스트하지 않는다. PostgreSQL은 이를 CopyToRoutineCopyFromRoutine(copyapi.h)으로 명명하고 CopyFromGetRoutine / CopyToGetRoutine으로 선택한다. 이 간접 참조 덕분에 코어 외부 익스텐션이 커스텀 COPY 포맷을 등록할 수 있다.

텍스트 파싱을 위한 단계 버퍼 파이프라인

섹션 제목: “텍스트 파싱을 위한 단계 버퍼 파이프라인”

텍스트/CSV 파싱은 각 단계 사이에 버퍼를 두어 여러 단계로 나뉜다. 각 단계는 하나의 역할만 맡고, 비용이 큰 단계(인코딩 변환, 행 분리, 필드 이스케이프 제거)는 독립적으로 튜닝할 수 있다. 보편적인 형태는 다음과 같다: 원시 바이트 읽기 → 서버 인코딩으로 변환 → 행으로 분리 → 각 행을 이스케이프 제거된 필드로 분리 → 타입 입력 함수 호출. PostgreSQL은 정확히 이 구조를 구현한다: raw_buf → input_buf → line_buf → attribute_buf.

단일 물리 연산당 여러 행을 삽입하는 것이 적재의 가장 중요한 최적화다. 적재기는 파싱된 행을 인메모리 버퍼에 쌓고 스토리지 계층에 배치로 플러시한다. 스토리지 계층은 하나(또는 소수)의 페이지와 모든 행을 포함하는 WAL 레코드 하나를 기록한다. 배치 크기는 메모리를 예측 가능하게 유지하기 위해 행 수와 바이트 수 두 가지로 제한된다. PostgreSQL의 버퍼는 CopyMultiInsertBuffer이고, 배치 플러시는 table_multi_insert다.

조건부 빠른 경로와 정확성 폴백

섹션 제목: “조건부 빠른 경로와 정확성 폴백”

배치는 적재 도중 어느 것도 부분적으로 적재된 테이블을 관찰하지 않을 때만 안전하다. 테이블을 쿼리하는 트리거, volatile 기본값 표현식, 특정 foreign-data-wrapper 제한은 모두 행 단위 삽입으로 폴백을 강제한다. 모든 배치 적재기는 이 탈출구를 갖는다. PostgreSQL은 이를 CopyInsertMethod 열거형(CIM_SINGLE, CIM_MULTI, CIM_MULTI_CONDITIONAL)으로 인코딩한다.

같은 트랜잭션에서 생성된 릴레이션을 대상으로 할 때, 엔진은 자유롭게 선택할 수 있다. FSM을 건너뛰고, 튜플을 이미 커밋-동결로 표시해 나중에 VACUUM이 재방문하지 않아도 된다. PostgreSQL은 이를 COPY ... FREEZE로 노출하고 TABLE_INSERT_FROZENTABLE_INSERT_SKIP_FSM 테이블-AM 플래그로 구현한다.

운영 수준의 적재기는 하드 오류(프로토콜 손상, I/O 실패 — 항상 치명적)와 소프트 오류(선언된 타입으로 파싱되지 않는 단일 필드)를 구분한다. 소프트 오류는 포착하고, 문제 행을 건너뛰고, 설정 가능한 한계까지 적재를 계속할 수 있다. PostgreSQL은 타입 입력 실패를 ErrorSaveContextInputFunctionCallSafe로 라우팅하며 ON_ERROR, REJECT_LIMIT, LOG_VERBOSITY로 제어한다.

DoCopy(copy.c)는 SQL 수준 진입점이다. 서버 측 파일 접근을 안전하게 하는 권한 검사를 수행하고, 릴레이션을 열어 잠그고, 선택적 WHERE 절을 변환한 뒤 방향에 따라 분기한다.

// DoCopy — src/backend/commands/copy.c
if (is_from)
{
CopyFromState cstate;
Assert(rel);
/* check read-only transaction and parallel mode */
if (XactReadOnly && !rel->rd_islocaltemp)
PreventCommandIfReadOnly("COPY FROM");
cstate = BeginCopyFrom(pstate, rel, whereClause,
stmt->filename, stmt->is_program,
NULL, stmt->attlist, stmt->options);
*processed = CopyFrom(cstate); /* copy from file to database */
EndCopyFrom(cstate);
}
else
{
CopyToState cstate;
cstate = BeginCopyTo(pstate, rel, query, relid,
stmt->filename, stmt->is_program,
NULL, stmt->attlist, stmt->options);
*processed = DoCopyTo(cstate); /* copy from database to file */
EndCopyTo(cstate);
}

권한 모델에는 한 가지 특이한 점이 있다. COPY ... TO STDOUT 또는 FROM STDIN은 누구나 사용할 수 있다. 데이터가 클라이언트 연결로 직접 흐르기 때문이다. 그러나 서버 측 파일이나 프로그램을 읽거나 쓰려면 pg_read_server_files / pg_write_server_files / pg_execute_server_program 멤버십이 필요하다. 두 번째 특이점은 행 수준 보안(RLS)이다. 대상에 RLS가 활성화된 경우 COPY relation TO는 묵시적으로 쿼리 기반 COPY (SELECT ...) TO로 재작성되어 재작성기가 RLS 조건을 주입한다. COPY FROM은 RLS 하에서 단순히 거부된다.

ProcessCopyOptions는 문법의 DefElem 목록을 걸으며 플랫한 CopyFormatOptions 구조체로 변환하고, 긴 자기 일관성 검사 시퀀스를 적용한다. FORMAT csv|binary, 구분자/인용/이스케이프 문자, HEADER, FREEZE, ON_ERROR, LOG_VERBOSITY, REJECT_LIMIT가 모두 여기서 해결되고 기본값이 채워진다.

// ProcessCopyOptions — src/backend/commands/copy.c
/* Set defaults for omitted options */
if (!opts_out->delim)
opts_out->delim = opts_out->csv_mode ? "," : "\t";
if (!opts_out->null_print)
opts_out->null_print = opts_out->csv_mode ? "" : "\\N";
opts_out->null_print_len = strlen(opts_out->null_print);
if (opts_out->csv_mode)
{
if (!opts_out->quote)
opts_out->quote = "\"";
if (!opts_out->escape)
opts_out->escape = opts_out->quote;
}

포맷별 기본값이 눈에 띈다. 필드 구분자는 텍스트 모드에서 탭이지만 CSV 모드에서는 쉼표다. NULL 표시자는 텍스트 모드에서 \N이지만 CSV에서는 빈 비인용 필드다. 후속 검사들은 한 모드에서만 허용되는 옵션이 다른 모드에서 거부되도록 강제한다. 예를 들어 BINARY 모드에서는 ON_ERROR STOP만 허용되고, REJECT_LIMITON_ERROR IGNORE와 함께만 설정할 수 있다.

세 가지 오류 처리 옵션은 키워드를 열거형 값으로 매핑하는 전용 헬퍼가 파싱한다.

// defGetCopyOnErrorChoice — src/backend/commands/copy.c
if (pg_strcasecmp(sval, "stop") == 0)
return COPY_ON_ERROR_STOP;
if (pg_strcasecmp(sval, "ignore") == 0)
return COPY_ON_ERROR_IGNORE;
// defGetCopyLogVerbosityChoice maps silent/default/verbose to
// COPY_LOG_VERBOSITY_{SILENT,DEFAULT,VERBOSE}

포맷 추상화는 현대 COPY 코드의 뼈대다. 각 내장 포맷은 static const 루틴 테이블이며, CopyFromGetRoutine이 해결된 옵션에 따라 하나를 선택한다.

// CopyFromRoutineCSV / CopyFromGetRoutine — src/backend/commands/copyfrom.c
static const CopyFromRoutine CopyFromRoutineCSV = {
.CopyFromInFunc = CopyFromTextLikeInFunc,
.CopyFromStart = CopyFromTextLikeStart,
.CopyFromOneRow = CopyFromCSVOneRow,
.CopyFromEnd = CopyFromTextLikeEnd,
};
static const CopyFromRoutine *
CopyFromGetRoutine(const CopyFormatOptions *opts)
{
if (opts->csv_mode)
return &CopyFromRoutineCSV;
else if (opts->binary)
return &CopyFromRoutineBinary;
return &CopyFromRoutineText; /* default is text */
}

텍스트와 CSV는 동일한 start, infunc, end 콜백을 공유하고 행별 콜백(CopyFromTextOneRow vs. CopyFromCSVOneRow)만 다르다. 두 콜백 모두 인라인된 CopyFromTextLikeOneRow(..., is_csv)의 얇은 래퍼다. 바이너리는 완전히 별개다. 타입의 receive 함수를 사용하고, start 콜백이 PGCOPY 파일 헤더를 읽고 검증한다. CopyToRoutine 쪽도 이를 그대로 반영한다.

CopyFromCOPY FROM의 심장이다. 대상이 일반/외부/파티션 테이블(또는 INSTEAD OF 트리거가 있는 뷰)임을 검증한 뒤, 삽입 방법을 결정하고 실행기 상태와 ResultRelInfo를 설정하고 오류-컨텍스트 콜백을 설치한 뒤 루프를 실행한다: NextCopyFrom → 선택적 소프트 오류 건너뛰기 → WHERE 필터 → 파티션 라우팅 → 트리거/제약 조건 → 삽입(배치 또는 단건).

flowchart TD
    A["DoCopy: COPY FROM"] --> B["BeginCopyFrom<br/>ProcessCopyOptions, 파일/파이프/소켓 열기,<br/>입력 함수 조회, 버퍼 할당"]
    B --> C["CopyFrom: CopyInsertMethod 선택<br/>CIM_SINGLE / CIM_MULTI / CIM_MULTI_CONDITIONAL"]
    C --> D{"각 행 반복"}
    D --> E["NextCopyFrom<br/>한 행 파싱 → values[]/nulls[]"]
    E --> F{"소프트 오류?<br/>(ON_ERROR IGNORE)"}
    F -->|yes| G["num_errors++, 행 건너뜀,<br/>REJECT_LIMIT 확인"]
    G --> D
    F -->|no| H{"WHERE 조건 충족?"}
    H -->|no| D
    H -->|yes| I{"파티션 테이블?"}
    I -->|yes| J["ExecFindPartition,<br/>루트 rowtype을 리프에 매핑"]
    I -->|no| K["BEFORE ROW 트리거,<br/>제약 조건, 생성 컬럼"]
    J --> K
    K --> L{"멀티-인서트<br/>활성화?"}
    L -->|yes| M["슬롯 버퍼링<br/>(CopyMultiInsertInfoStore)"]
    M --> N{"버퍼 가득 참?"}
    N -->|yes| O["CopyMultiInsertInfoFlush<br/>(table_multi_insert)"]
    N -->|no| D
    O --> D
    L -->|no| P["table_tuple_insert,<br/>인덱스 튜플, AFTER 트리거"]
    P --> D
    D -->|EOF| Q["남은 버퍼 플러시,<br/>건너뛴 행 NOTICE, 정리"]

배치가 허용되면 파싱된 튜플은 즉시 삽입되지 않는다. CopyMultiInsertBuffer에 보관된 슬롯에 구체화되고 일괄적으로 플러시된다. 버퍼는 두 가지 방식으로 제한된다. 넓은 행의 적재는 바이트 수로 플러시되고, 좁은 행의 적재는 카운트로 플러시된다.

// CopyMultiInsertInfoIsFull / limits — src/backend/commands/copyfrom.c
#define MAX_BUFFERED_TUPLES 1000
#define MAX_BUFFERED_BYTES 65535
static inline bool
CopyMultiInsertInfoIsFull(CopyMultiInsertInfo *miinfo)
{
if (miinfo->bufferedTuples >= MAX_BUFFERED_TUPLES ||
miinfo->bufferedBytes >= MAX_BUFFERED_BYTES)
return true;
return false;
}

파티션 대상의 경우 파티션별로 버퍼가 하나씩 있으며, 모두 CopyMultiInsertInfo->multiInsertBuffers에서 추적된다. 수천 개의 파티션에 걸친 적재 시 메모리를 제한하기 위해, 각 플러시 후 목록은 MAX_PARTITION_BUFFERS(32)로 다듬어지고 가장 오래 생성된 버퍼가 먼저 퇴출된다. 현재 사용 중인 버퍼는 퇴출하지 않는다. 이것이 소스 주석에서 MAX_BUFFERED_TUPLES에 적시된 이차 메모리 방어책이다.

COPY FROMti_options 비트마스크를 구성한다. 두 플래그가 대량 적재에서 중요하다. TABLE_INSERT_SKIP_FSM은 릴레이션의 스토리지가 이 (서브)트랜잭션에서 생성될 때마다 설정된다. 새 relfilenode에 FSM을 탐색할 이유가 없기 때문이다. TABLE_INSERT_FROZENFREEZE 옵션이다. 이미 동결 표시된 튜플을 기록하지만, 해당 행이 보면 안 되는 트랜잭션에 절대 보이지 않는다는 보장을 위한 엄격한 자격 검사 후에만 설정된다.

// CopyFrom (FREEZE path) — src/backend/commands/copyfrom.c
if (cstate->opts.freeze)
{
/* COPY FREEZE is disallowed on partitioned and foreign tables */
...
InvalidateCatalogSnapshot();
if (!ThereAreNoPriorRegisteredSnapshots() || !ThereAreNoReadyPortals())
ereport(ERROR, ... "cannot perform COPY FREEZE because of prior transaction activity");
if (cstate->rel->rd_createSubid != GetCurrentSubTransactionId() &&
cstate->rel->rd_newRelfilelocatorSubid != GetCurrentSubTransactionId())
ereport(ERROR, ... "the table was not created or truncated in the current subtransaction");
ti_options |= TABLE_INSERT_FROZEN;
}

서브트랜잭션 테스트는 주석이 말하듯 “정확성에 결정적”이다. 이 서브트랜잭션이 중단되면 동결된 행들은 relfilenode와 함께 사라진다. 따라서 FREEZE가 도입하는 MVCC 이례 현상(다른 세션이 행을 즉시 볼 수 있는 상황)은 롤백 시 살아남지 못할 릴레이션에 국한된다. TABLE_INSERT_FROZEN의 힙-AM 쪽은 postgres-heap-am.md에서 다룬다.

소프트 오류 처리: ON_ERROR / REJECT_LIMIT / LOG_VERBOSITY

섹션 제목: “소프트 오류 처리: ON_ERROR / REJECT_LIMIT / LOG_VERBOSITY”

기본적으로 타입 입력에 실패한 행은 COPY 전체를 중단시킨다(COPY_ON_ERROR_STOP). ON_ERROR ignore를 지정하면 BeginCopyFromErrorSaveContext를 할당하고, 행별 파서가 타입 입력 함수의 안전한 변형을 호출한다. 이 변형은 예외를 던지는 대신 소프트 오류를 기록한다.

// CopyFromTextLikeOneRow (soft error path) — src/backend/commands/copyfromparse.c
else if (!InputFunctionCallSafe(&in_functions[m], string, typioparams[m],
att->atttypmod,
(Node *) cstate->escontext, &values[m]))
{
Assert(cstate->opts.on_error != COPY_ON_ERROR_STOP);
cstate->num_errors++;
if (cstate->opts.log_verbosity == COPY_LOG_VERBOSITY_VERBOSE)
ereport(NOTICE, errmsg("skipping row due to data type incompatibility ..."));
return true; /* row returned with error_occurred set */
}

CopyFromescontext->error_occurred를 보고 이를 리셋하고 건너뛴 카운터를 올린다. REJECT_LIMIT를 초과하면 소프트 오류를 하드 ERROR로 전환한다. 적재 종료 시(LOG_VERBOSITY silent가 아니면) 건너뛴 행 수를 요약한 NOTICE를 내보낸다. 이 메커니즘 전체는 postgres-error-handling.md에서 설명하는 범용 소프트 오류 인프라 위에 구축되어 있다. COPY는 그 인프라의 가장 두드러진 소비자다.

COPY TO는 단순하다. BeginCopyTo는 테이블을 직접 받거나, COPY (query) TO의 경우 파싱 분석/재작성/계획을 실행하고 쿼리에 DestCopyOut 수신기를 연결한다. DoCopyTo는 릴레이션을 테이블-스캔하며 각 슬롯을 CopyOneRowTo로 포맷하거나, 계획을 실행하고 각 내보낸 튜플을 copy_dest_receive 콜백으로 포맷한다.

// DoCopyTo (table-scan path) — src/backend/commands/copyto.c
scandesc = table_beginscan(cstate->rel, GetActiveSnapshot(), 0, NULL);
slot = table_slot_create(cstate->rel, NULL);
processed = 0;
while (table_scan_getnextslot(scandesc, ForwardScanDirection, slot))
{
CHECK_FOR_INTERRUPTS();
slot_getallattrs(slot); /* deconstruct the tuple */
CopyOneRowTo(cstate, slot); /* format and send */
pgstat_progress_update_param(PROGRESS_COPY_TUPLES_PROCESSED, ++processed);
}

각 행은 매 반복마다 리셋되는 행별 메모리 컨텍스트에서 포맷된다. 타입 출력 함수가 자유롭게 메모리를 누수해도 무한 성장 없이 처리된다. 임의의 SELECT/INSERT ... RETURNINGDestReceiver로 실행하는 세부 사항은 postgres-ddl-execution.md와 실행기 문서에서 연결된다.

출력 쪽은 입력 쪽 vtable을 그대로 반영한다. CopyOneRowTo는 슬롯의 속성을 걷는다. 텍스트/CSV에서 null이 아닌 각 값은 타입의 출력 함수를 거쳐 CopyAttributeOutText(또는 CSV 모드의 CopyAttributeOutCSV)로 이스케이프된다. 이는 CopyReadAttributesText의 정확한 역방향이다. 제어 문자는 \n, \t, \r이 되고, 구분자는 백슬래시 이스케이프되고, NULL은 null_print 마커로 내보낸다. 바이너리 작성기는 바이너리 독자와 대칭이다. PGCOPY 서명을 한 번 내보내고, 행별로 int16 필드 수와 각 타입의 send 함수에서 나온 길이-접두 bytea 블롭을 내보낸다.

// CopyToBinaryOneRow — src/backend/commands/copyto.c
CopySendInt16(cstate, list_length(cstate->attnumlist)); /* field count */
foreach_int(attnum, cstate->attnumlist)
{
Datum value = slot->tts_values[attnum - 1];
bool isnull = slot->tts_isnull[attnum - 1];
if (isnull)
CopySendInt32(cstate, -1); /* NULL = length of -1 */
else
{
bytea *outputbytes = SendFunctionCall(&out_functions[attnum - 1], value);
CopySendInt32(cstate, VARSIZE(outputbytes) - VARHDRSZ); /* length */
CopySendData(cstate, VARDATA(outputbytes), VARSIZE(outputbytes) - VARHDRSZ);
}
}
CopySendEndOfRow(cstate);

CopyToBinaryStart는 고정된 19바이트 헤더를 기록한다. 11바이트 서명("PGCOPY\n\377\r\n\0"), 4바이트 플래그 워드, 4바이트 헤더 확장 길이다. CopyToBinaryEnd는 독자가 EOF 마커로 인식하는 -1 필드-카운트 트레일러를 기록한다. 동일한 BinarySignature 상수가 독자를 게이트하므로, 한 서버에서 생성된 바이너리 덤프는 다른 어느 서버에서도 재적재된다.

텍스트 파싱 파이프라인: 네 개의 버퍼, 네 개의 단계

섹션 제목: “텍스트 파싱 파이프라인: 네 개의 버퍼, 네 개의 단계”

COPY에서 가장 밀도가 높은 부분은 copyfromparse.c의 적재 측 파서다. 파일 헤더는 단계 파이프라인을 명시적으로 설명한다. 원시 바이트는 raw_buf로 읽히고, input_buf로 변환되고, line_buf에 하나의 행으로 분리되고, attribute_buf의 필드별 슬라이스로 이스케이프 제거된다. 영리한 최적화는 인코딩 변환이 필요 없을 때 raw_bufinput_buf동일한 버퍼가 되어 변환이 검증 패스로 줄어든다는 점이다.

flowchart TD
    SRC["소스<br/>파일 / PROGRAM 파이프 / 클라이언트 소켓"] --> CGD["CopyGetData<br/>바이트 읽기"]
    CGD --> RAW["raw_buf<br/>(RAW_BUF_SIZE)"]
    RAW --> CC{"need_transcoding?"}
    CC -->|yes| CONV["CopyConvertBuf<br/>인코딩 변환"]
    CC -->|no| ALIAS["input_buf가 raw_buf를 별칭<br/>(검증만 수행)"]
    CONV --> INP["input_buf<br/>(서버 인코딩)"]
    ALIAS --> INP
    INP --> CRL["CopyReadLineText<br/>인용/이스케이프 준수, EOL 탐색"]
    CRL --> LB["line_buf<br/>(하나의 논리 행)"]
    LB --> FMT{"포맷?"}
    FMT -->|text| CRAT["CopyReadAttributesText<br/>구분자로 분리, 이스케이프 제거"]
    FMT -->|csv| CRAC["CopyReadAttributesCSV<br/>인용 인식 분리"]
    CRAT --> AB["attribute_buf + raw_fields[]<br/>(이스케이프 제거된 필드 슬라이스)"]
    CRAC --> AB
    AB --> NCF["NextCopyFrom<br/>컬럼별 InputFunctionCall(Safe)"]
    NCF --> VALS["values[] / nulls[]<br/>슬롯을 위한 타입 지정 Datum"]

CopyLoadRawBuf는 스택의 바닥이다. raw_buf의 처리되지 않은 바이트를 앞으로 밀고, CopyGetData로 더 읽고, 소스가 소진되면 raw_reached_eof를 설정한다. CopyConvertBufneed_transcoding이 설정된 경우에만 인코딩 변환 루틴을 실행한다. 그렇지 않으면 헤더 주석이 설명하듯 버퍼가 별칭 관계이고 서버 인코딩에서 유효한 바이트인지만 확인한다.

// CopyLoadRawBuf — src/backend/commands/copyfromparse.c
nbytes = RAW_BUF_BYTES(cstate);
if (nbytes > 0 && cstate->raw_buf_index > 0)
memmove(cstate->raw_buf, cstate->raw_buf + cstate->raw_buf_index, nbytes);
cstate->raw_buf_len -= cstate->raw_buf_index;
cstate->raw_buf_index = 0;
/* Load more data */
inbytes = CopyGetData(cstate, cstate->raw_buf + cstate->raw_buf_len,
1, RAW_BUF_SIZE - cstate->raw_buf_len);
nbytes += inbytes;
cstate->raw_buf[nbytes] = '\0';
cstate->raw_buf_len = nbytes;
if (inbytes == 0)
cstate->raw_reached_eof = true;

CopyReadLineText는 바이트 스트림을 line_buf의 하나의 논리 행으로 변환하는 상태 기계다. 핵심 어려움은 인용(quoting)이다. CSV 모드에서는 인용된 필드 안의 \r이나 \n이 행 종료자가 아니라 데이터다. 루프는 in_quote/last_was_esc 플래그를 관리하며 인용 밖에 있을 때만 개행을 행 끝으로 처리한다. 두 번째 어려움은 인코딩 안전성이다. 지원되는 모든 서버 인코딩은 멀티바이트 문자의 모든 바이트에 상위 비트를 설정하므로, 루프는 바이트 단위로 스캔하면서도 연속 바이트를 구분자로 오인하지 않는다.

// CopyReadLineText — src/backend/commands/copyfromparse.c
if (is_csv)
{
quotec = cstate->opts.quote[0];
escapec = cstate->opts.escape[0];
/* ignore special escape processing if it's the same as quotec */
if (quotec == escapec)
escapec = '\0';
}
/* ... scan input_buf byte-by-byte, tracking in_quote, until an
* unquoted \n / \r / \r\n (or the \. end-marker) is found, copying
* the line — minus its terminator — into line_buf ... */

텍스트 행을 필드로 분리: CopyReadAttributesText

섹션 제목: “텍스트 행을 필드로 분리: CopyReadAttributesText”

line_buf에 행 하나가 담기면 CopyReadAttributesText가 왼쪽에서 오른쪽으로 걸으며 이스케이프 제거되지 않은 구분자로 분리하고, 백슬래시 시퀀스(\t, \n, 8진수 \013, 16진수 \x1b)를 attribute_buf로 이스케이프 제거하고, 각 필드를 raw_fields[]로 포인터 기록한다. 내부 루프의 주석에 중요한 순서 규칙이 있다. 스캐너는 이스케이프 제거 문법 오류를 던지기 전에 필드 끝을 찾아 NULL 마커와 비교해야 한다. \N과 같은 필드가 잘못된 이스케이프가 아니라 SQL NULL로 읽히게 하기 위해서다.

// CopyReadAttributesText — src/backend/commands/copyfromparse.c
for (;;) /* outer loop over fields */
{
start_ptr = cur_ptr;
cstate->raw_fields[fieldno] = output_ptr;
for (;;) /* inner loop scans + de-escapes one field */
{
c = *cur_ptr++;
if (c == delimc) { found_delim = true; break; }
if (c == '\\') { /* handle \n \t \013 \x1b ... */ }
else *output_ptr++ = c;
}
/* compare raw field to null_print BEFORE committing de-escaped bytes */
}

CopyReadAttributesCSV는 RFC-4180 변형이다. 인용 문자로 시작하는 필드는 닫는 인용 문자까지 있는 그대로 읽힌다. 내장된 구분자와 개행도 포함된다. 이중 인용부호("")는 하나로 축약된다. 두 함수 모두 필드 수를 반환하며, NextCopyFrom이 이를 컬럼 목록과 대조한다.

바이너리 파싱: 프레임, 길이-접두, 헤더 검증

섹션 제목: “바이너리 파싱: 프레임, 길이-접두, 헤더 검증”

바이너리 독자는 바이너리 작성기의 거울상이다. 적재는 ReceiveCopyBinaryHeader에서 19바이트 헤더를 검증하는 것으로 시작된다. 11바이트 서명은 BinarySignaturememcmp-일치해야 하고, 플래그 워드는 구 WITH-OIDS 비트를 설정하면 안 되고, 헤더 확장은 건너뛴다.

// ReceiveCopyBinaryHeader — src/backend/commands/copyfromparse.c
static const char BinarySignature[11] = "PGCOPY\n\377\r\n\0";
if (CopyReadBinaryData(cstate, readSig, 11) != 11 ||
memcmp(readSig, BinarySignature, 11) != 0)
ereport(ERROR, ... "COPY file signature not recognized");
if (!CopyGetInt32(cstate, &tmp)) ... ; /* flags */
if ((tmp & (1 << 16)) != 0)
ereport(ERROR, ... "invalid COPY file header (WITH OIDS)");

각 행은 int16 필드 수로 시작한다(-1은 EOF 마커). 각 필드는 CopyReadBinaryAttribute가 읽는다. 4바이트 길이(-1 = NULL), 그 후 그 바이트 수를 타입의 receive 함수에 전달한다. 두 가지 무결성 검사가 포맷을 자기 기술적으로 만든다. 음수이고 -1이 아닌 길이는 거부된다. receive 함수는 전체 버퍼를 소비해야 한다. 그렇지 않으면 행이 잘못된 것으로 선언된다.

// CopyReadBinaryAttribute — src/backend/commands/copyfromparse.c
if (!CopyGetInt32(cstate, &fld_size))
ereport(ERROR, ... "unexpected EOF in COPY data");
if (fld_size == -1) { *isnull = true; return ReceiveFunctionCall(flinfo, NULL, ...); }
if (fld_size < 0) ereport(ERROR, ... "invalid field size");
/* load fld_size bytes into attribute_buf, then: */
result = ReceiveFunctionCall(flinfo, &cstate->attribute_buf, typioparam, typmod);
if (cstate->attribute_buf.cursor != cstate->attribute_buf.len)
ereport(ERROR, ... "incorrect binary data format"); /* must eat whole field */

바이너리 필드에는 타입 이름도 행별 텍스트도 없으므로 바이너리 경로에는 소프트 오류 처리가 없다. ON_ERROR IGNORE는 옵션 파싱 시 BINARY에서 거부된다. 길이-프레임 스트림은 행 경계에서 텍스트 스트림처럼 다음 개행으로 재동기화할 수 없기 때문이다.

CopyFrom은 행 루프 전에 CopyInsertMethod를 한 번 해결한다. 각각 적재 도중 실행 중인 무언가가 버퍼링된 아직-삽입되지-않은 튜플을 관찰하거나 잘못 처리할 수 있는 경우를 나타내는 자격 박탈 조건의 계단식으로 처리된다.

// CopyFrom (insert-method cascade) — src/backend/commands/copyfrom.c
if (resultRelInfo->ri_TrigDesc != NULL &&
(resultRelInfo->ri_TrigDesc->trig_insert_before_row ||
resultRelInfo->ri_TrigDesc->trig_insert_instead_row))
insertMethod = CIM_SINGLE; /* trigger may query the table */
else if (resultRelInfo->ri_FdwRoutine != NULL && resultRelInfo->ri_BatchSize == 1)
insertMethod = CIM_SINGLE; /* FDW can't batch */
else if (cstate->volatile_defexprs)
insertMethod = CIM_SINGLE; /* volatile DEFAULT may query table */
else if (contain_volatile_functions(cstate->whereClause))
insertMethod = CIM_SINGLE; /* volatile WHERE */
else
insertMethod = proute ? CIM_MULTI_CONDITIONAL : CIM_MULTI;

CIM_MULTI_CONDITIONAL은 파티션 테이블 타협안이다. 부모는 배치에 적합하지만 각 리프 파티션은 첫 번째 행이 라우팅될 때만 검사된다. 리프가 외부 테이블이거나 자체 BEFORE-ROW 트리거를 달고 있으면 그 시점에서 파티션별로 leafpart_use_multi_insert가 결정된다.

배치 플러시: table_multi_insert와 인덱스/AFTER-트리거 팬아웃

섹션 제목: “배치 플러시: table_multi_insert와 인덱스/AFTER-트리거 팬아웃”

버퍼가 가득 차거나(CopyMultiInsertInfoIsFull) 적재가 끝나면 CopyMultiInsertBufferFlush가 전체 슬롯 배열을 테이블 AM의 table_multi_insert에 한 번에 전달한다. AM은 힙 튜플만 기록하므로, 이후 삽입된 슬롯들을 순회하며 인덱스를 유지하고 AFTER-ROW 트리거를 발화한다. table_multi_insert가 메모리를 누수하는 것이 문서화되어 있으므로, 플러시는 먼저 단기 메모리 컨텍스트로 전환한다.

// CopyMultiInsertBufferFlush — src/backend/commands/copyfrom.c
oldcontext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
table_multi_insert(resultRelInfo->ri_RelationDesc,
slots, nused, mycid, ti_options, buffer->bistate);
MemoryContextSwitchTo(oldcontext);
for (i = 0; i < nused; i++)
{
if (resultRelInfo->ri_NumIndices > 0)
/* ExecInsertIndexTuples for slot i */ ;
/* ExecARInsertTriggers for slot i */
}

외부 대상의 경우 같은 함수가 대신 ri_BatchSize 크기의 청크로 ExecForeignBatchInsert를 실행한다. 이 FDW 배치 경로는 postgres-fdw.md에서 다룬다. table_multi_insert의 힙 쪽(heap_multi_insert, 많은 튜플을 포함하는 하나의 WAL 레코드)은 postgres-heap-am.md에 있다.

이 절은 COPY를 구성하는 안정적인 심볼을 호출 흐름별로 그룹화해 색인한다. 심볼 이름을 지속적인 앵커로 사용하고, 마지막 표의 (파일, 줄) 쌍은 updated: 날짜 기준의 힌트로 취급하라.

  • DoCopy — SQL 수준 진입점. pg_read_server_files / pg_write_server_files / pg_execute_server_program 권한을 해결하고, 릴레이션을 열어 잠그고, 필요 시 RLS 재작성을 적용하고, WHERE 절을 변환하고, 적재 또는 추출 엔진으로 분기한다.
  • ProcessCopyOptions — 문법의 DefElem 목록을 플랫한 CopyFormatOptions로 접고, 포맷별 기본값(탭 vs 쉼표 구분자, \N vs 빈 NULL 마커, CSV의 " 인용/이스케이프)을 채우고, 옵션 간 합법성을 강제한다.
  • defGetCopyOnErrorChoice / defGetCopyLogVerbosityChoice / defGetCopyRejectLimit — 오류 처리 옵션의 키워드-열거형 매퍼.

적재 엔진과 포맷 vtable (copyfrom.c)

섹션 제목: “적재 엔진과 포맷 vtable (copyfrom.c)”
  • CopyFromGetRoutine — 해결된 옵션에서 CopyFromRoutineCSV, CopyFromRoutineBinary, CopyFromRoutineText 중 하나를 선택한다.
  • CopyFromText / CSV / BinaryStart / OneRow / End 콜백 — 세 vtable. 텍스트와 CSV는 start/infunc/end를 공유하고 행별 콜백만 다르다. 바이너리는 receive 함수를 사용하고 CopyFromBinaryStartReceiveCopyBinaryHeader에서 PGCOPY 헤더를 검증한다.
  • BeginCopyFrom — 소스를 열고, 컬럼별 입력(또는 receive) 함수와 typioparams를 조회하고, 네 개의 파싱 버퍼를 할당하고, 기본값 표현식을 평가하고, ON_ERROR IGNORE 하에서 ErrorSaveContext를 할당한다.
  • CopyFrom — 적재 드라이버. CopyInsertMethod를 결정하고, ti_options(TABLE_INSERT_SKIP_FSM, TABLE_INSERT_FROZEN)를 설정하고, CopyFromErrorCallback을 설치하고, 행별 루프를 실행한다.
  • CopyMultiInsertInfoInit / …SetupBuffer / …Store / …IsFull / …Flush / CopyMultiInsertBufferFlush — 배치 서브시스템. MAX_BUFFERED_TUPLES(1000), MAX_BUFFERED_BYTES(65535), MAX_PARTITION_BUFFERS(32)가 한계를 정한다.
  • CopyFromErrorCallback — 적재 도중 발생한 모든 오류에 COPY rel, line N, column C(텍스트 모드에서는 위반 값도)를 추가하는 errcontext 콜백.
  • CopyGetData — 최저 수준 리더. 파일 vs PROGRAM 파이프 vs 프론트엔드 소켓을 추상화한다.
  • CopyLoadRawBufraw_buf를 채우고, 처리되지 않은 바이트를 밀고, raw_reached_eof를 설정한다.
  • CopyConvertBuf — 인코딩 변환 raw_buf → input_buf(또는 버퍼가 별칭일 때 검증 전용 패스).
  • CopyReadLine / CopyReadLineTextline_buf를 채우는 인용 인식 EOL 상태 기계.
  • CopyReadAttributesText / CopyReadAttributesCSV — 한 행을 이스케이프 제거된 raw_fields[] 슬라이스로 분리한다.
  • NextCopyFromRawFields / NextCopyFromRawFieldsInternal — 공개 원시 필드 리더(익스텐션도 사용). HEADER / HEADER MATCH 검증을 처리한다.
  • NextCopyFrom — 원시 필드를 InputFunctionCallSafe(또는 기본값 컬럼의 ExecEvalExpr)로 타입 지정된 values[]/nulls[]로 변환하고, 소프트 오류를 ErrorSaveContext로 라우팅한다.
  • CopyFromTextLikeOneRow / CopyFromCSVOneRow / CopyFromBinaryOneRow — 행별 vtable 콜백.
  • ReceiveCopyBinaryHeader / CopyReadBinaryAttribute / CopyGetInt16 / CopyGetInt32 / CopyReadBinaryData — 바이너리 프레이밍 계층.
  • CopyToGetRoutineCopyToRoutine vtable을 선택한다.
  • BeginCopyTo — 목적지를 열고. COPY (query) TO의 경우 파싱 분석/재작성/계획을 실행하고 DestCopyOut 수신기를 연결한다.
  • DoCopyTo — 추출 드라이버: 테이블 스캔 + CopyOneRowTo, 또는 계획 실행과 copy_dest_receive.
  • CopyOneRowTo — 슬롯 하나를 포맷하고 포맷의 행별 콜백을 호출한다.
  • CopyAttributeOutText / CopyAttributeOutCSV — read-attributes 함수의 이스케이프 역방향.
  • CopyToBinaryStart / CopyToBinaryOneRow / CopyToBinaryEnd / CopySendInt16 / CopySendInt32 / CopySendData — 바이너리 작성기. BinarySignature 헤더, 길이-접두 필드, -1 트레일러.

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

섹션 제목: “위치 힌트 (2026-06-05, REL_18 273fe94 기준)”
심볼파일
DoCopysrc/backend/commands/copy.c62
ProcessCopyOptionssrc/backend/commands/copy.c536
CopyFromGetRoutinesrc/backend/commands/copyfrom.c156
CopyFromBinaryStartsrc/backend/commands/copyfrom.c221
CopyFromErrorCallbacksrc/backend/commands/copyfrom.c254
CopyMultiInsertInfoSetupBuffersrc/backend/commands/copyfrom.c380
CopyMultiInsertInfoIsFullsrc/backend/commands/copyfrom.c425
CopyMultiInsertBufferFlushsrc/backend/commands/copyfrom.c446
CopyMultiInsertInfoFlushsrc/backend/commands/copyfrom.c662
CopyMultiInsertInfoStoresrc/backend/commands/copyfrom.c756
CopyFromsrc/backend/commands/copyfrom.c779
BeginCopyFromsrc/backend/commands/copyfrom.c1529
MAX_BUFFERED_TUPLES (=1000)src/backend/commands/copyfrom.c63
MAX_BUFFERED_BYTES (=65535)src/backend/commands/copyfrom.c69
MAX_PARTITION_BUFFERS (=32)src/backend/commands/copyfrom.c75
`ti_options= TABLE_INSERT_SKIP_FSM`src/backend/commands/copyfrom.c
`ti_options= TABLE_INSERT_FROZEN`src/backend/commands/copyfrom.c
BinarySignaturesrc/backend/commands/copyfromparse.c139
ReceiveCopyBinaryHeadersrc/backend/commands/copyfromparse.c190
CopyConvertBufsrc/backend/commands/copyfromparse.c400
CopyLoadRawBufsrc/backend/commands/copyfromparse.c590
NextCopyFromRawFieldssrc/backend/commands/copyfromparse.c747
NextCopyFromsrc/backend/commands/copyfromparse.c871
CopyFromTextLikeOneRowsrc/backend/commands/copyfromparse.c937
CopyFromBinaryOneRowsrc/backend/commands/copyfromparse.c1086
CopyReadLineTextsrc/backend/commands/copyfromparse.c1234
CopyReadAttributesTextsrc/backend/commands/copyfromparse.c1564
CopyReadAttributesCSVsrc/backend/commands/copyfromparse.c1818
CopyReadBinaryAttributesrc/backend/commands/copyfromparse.c2013
CopyToBinaryStartsrc/backend/commands/copyto.c314
CopyToBinaryOneRowsrc/backend/commands/copyto.c345
BeginCopyTosrc/backend/commands/copyto.c623
DoCopyTosrc/backend/commands/copyto.c1026
CopyOneRowTosrc/backend/commands/copyto.c1122
CopyAttributeOutTextsrc/backend/commands/copyto.c1147
CopyAttributeOutCSVsrc/backend/commands/copyto.c1300
  • DoCopy는 정확히 두 엔진으로 분기하고 RLS 재작성/거부를 적용한다. DoCopy(copy.c)에서 검증됨: is_fromBeginCopyFrom/CopyFrom/EndCopyFrom, 그 외 BeginCopyTo/DoCopyTo/EndCopyTo. 서버 측 파일/프로그램 접근은 pg_read_server_files / pg_write_server_files / pg_execute_server_program 역할로 게이트된다. RLS 하의 COPY rel TOCOPY (SELECT …) TO로 재작성되고 COPY FROM은 거부된다.

  • 세 가지 포맷은 static const CopyFromRoutine / CopyToRoutine vtable로 디스패치된다. CopyFromGetRoutine(csv → CSV 루틴, binary → Binary 루틴, 그 외 Text)과 대칭적인 CopyToGetRoutine에서 검증됨. 텍스트와 CSV는 start/infunc/end를 공유하고 행별 콜백만 다르다.

  • 텍스트 적재는 네 버퍼 파이프라인 raw_buf → input_buf → line_buf → attribute_buf를 실행하며, 변환이 필요 없을 때 처음 두 개는 별칭이 된다. copyfromparse.c 파일 헤더와 CopyLoadRawBuf(raw_buf == input_buf 별칭 단언)와 CopyConvertBuf에서 검증됨.

  • 멀티-인서트 배치는 튜플 수와 바이트 수 두 가지로 제한되며 파티션 버퍼는 상한이 있다. MAX_BUFFERED_TUPLES = 1000, MAX_BUFFERED_BYTES = 65535, MAX_PARTITION_BUFFERS = 32에서 검증됨. CopyMultiInsertInfoIsFull이 처음 두 개를 테스트하고 CopyMultiInsertInfoFlush가 버퍼 목록을 세 번째로 다듬는다.

  • 삽입 방법은 자격 박탈 계단식으로 한 번 선택된다. CopyFrom에서 검증됨: BEFORE/INSTEAD-OF 행 트리거, 비배치 FDW(ri_BatchSize == 1), 파티션 문장 수준 삽입 트리거, volatile 기본값 표현식, volatile WHERE가 각각 CIM_SINGLE을 강제한다. 파티션 대상은 CIM_MULTI_CONDITIONAL, 일반 대상은 CIM_MULTI를 받는다.

  • COPY FREEZE는 이전 활동 검사와 같은 서브트랜잭션 검사 후에만 TABLE_INSERT_FROZEN을 설정한다. CopyFrom에서 검증됨: InvalidateCatalogSnapshot(), ThereAreNoPriorRegisteredSnapshots() / ThereAreNoReadyPortals() 가드, rd_createSubid / rd_newRelfilelocatorSubid == GetCurrentSubTransactionId() 테스트가 모두 ti_options |= TABLE_INSERT_FROZEN을 게이트한다. TABLE_INSERT_SKIP_FSM은 이 서브트랜잭션에서 relfilenode가 새것일 때 독립적으로 설정된다.

  • 소프트 오류는 InputFunctionCallSafe + ErrorSaveContext를 거쳐 num_errors에 누적되며, BINARY에서는 ON_ERROR STOP만 허용된다. CopyFromTextLikeOneRow(InputFunctionCallSafe(...) 소프트 오류 분기, num_errors 증가, 조건부 VERBOSE NOTICE 발화)와 ProcessCopyOptions의 옵션 검사에서 검증됨. 바이너리 행별 콜백 CopyFromBinaryOneRow에는 소프트 오류 분기가 없다.

  • 바이너리 포맷은 헤더 검증 및 길이-프레임 방식이다. ReceiveCopyBinaryHeader(BinarySignature memcmp, WITH-OIDS 플래그 거부), CopyReadBinaryAttribute(-1 길이 = NULL; receive 함수가 전체 필드를 소비했는지 확인하는 “incorrect binary data format” 검사), 대칭 작성기 CopyToBinaryStart / CopyToBinaryOneRow / CopyToBinaryEnd에서 검증됨.

  1. MAX_PARTITION_BUFFERS 버퍼 교체의 실질적 상한. 수천 개의 파티션을 라운드로빈하는 COPY는 멀티-인서트 버퍼를 반복적으로 퇴출하고 재구축한다(32버퍼 상한, 오래된 것 먼저 퇴출). 적대적 파티션 키 순서 하의 버퍼 설정/해제 상각 비용은 코드에서 명확하지 않다. CopyMultiInsertInfoFlush의 다듬기 루프를 합성된 인터리브 적재로 계측하는 것이 탐색 경로다.

  2. COPY FREEZE와 병렬 안전 테이블 AM의 상호작용. 동결 자격 검사는 힙-AM 형태(같은 서브트랜잭션 relfilenode)다. 힙의 t_infomask 동결 비트로 가시성을 확립하지 않는 미래의 커스텀 테이블 AM이 TABLE_INSERT_FROZEN을 어떻게 준수할지는 postgres-table-am.md에 남겨진다.

PostgreSQL 너머 — 비교 설계와 연구 방향

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

PostgreSQL의 COPY는 대량 적재 설계 공간에서 의도적으로 검소한 위치에 자리잡고 있다. 하나의 COPY 문 안에 병렬성 없이 행 단위 파서가 배치된 삽입기를 공급하는 단일 프로세스 구조다. 비교 환경은 넓다.

외부 대량 적재기와 \copy 클라이언트 변형. psql의 \copy는 서버 기능이 아니다. COPY … FROM STDIN을 실행하고 클라이언트 로컬 파일을 프로토콜로 스트리밍한다. 서버 파일 권한이 필요 없는 이유다. pg_bulkload(코어 외부, 여기서 다루지 않음) 같은 도구는 버퍼 관리자와 WAL을 우회해 힙 페이지를 직접 작성함으로써 충돌 안전성과 속도를 교환한다. 이는 다른 시스템이 “직접 경로” 적재로 노출하는 것과 같은 교환이다.

다른 시스템의 직접 경로 및 최소 로깅 적재. Oracle의 SQL*Loader 직접 경로와 TABLOCK + 최소 로깅을 쓰는 SQL Server의 BULK INSERTCOPY FREEZE가 가리키기만 하는 것을 공식화한다. 새로 생성되거나 빈 객체에 대한 독점 테이블 잠금 하에서 포맷된 페이지를 데이터 파일에 직접 쓰고 행별 로그 대신 할당 수준 redo 레코드만 내보낸다. PostgreSQL의 WAL 설계(wal_level = minimal이 같은 트랜잭션에서 생성된 릴레이션의 WAL을 완전히 건너뛸 수 있게 함)는 다른 경로로 비슷한 지점에 도달한다. COPY FREEZE는 그 위에 가시성-건너뛰기를 더한다.

병렬 및 분산 수집. Greenplum과 Citus는 입력을 분할해 세그먼트/워커별로 하나의 적재기를 실행한다. 클라우드 웨어하우스(Snowflake, BigQuery, Redshift COPY)는 여러 계산 노드에서 많은 입력 파일을 동시에 파싱하고 매니페스트를 커밋한다. PostgreSQL 코어에는 문 내 COPY 병렬성이 없다. 파서는 직렬 상태 기계다. 그러나 단계 버퍼 설계(네 버퍼 파이프라인)는 정확히 스레드에 걸쳐 파이프라인화할 수 있는 형태이고, 포맷 vtable은 정확히 병렬 파서가 연결될 이음새다. 관련 이론적 근거는 Architecture of a Database System 서베이(Hellerstein, Stonebraker, Hamilton; dbms-papers/fntdb07-architecture.md에 수록)의 수집과 적재가 파싱/변환에서 CPU 처리량 제한이 된다는 고전적 관찰이다. 모든 심각한 웨어하우스가 삽입기가 아니라 파서를 병렬화하는 이유가 바로 그 때문이다.

익스텐션 이음새로서의 포맷 vtable. 최근 PostgreSQL 버전은 CopyFromRoutine / CopyToRoutine을 확장 가능한 표면(copyapi.h)으로 만들었다. 익스텐션이 커스텀 COPY 포맷(Arrow, Parquet-row, JSON-lines)을 등록할 수 있고, 전체 행 처리 코어(파티션 라우팅, 배치, FREEZE, 소프트 오류)를 재사용하면서 start/infunc/onerow/end만 제공한다. 이것은 테이블 AM(postgres-table-am.md)과 인덱스 AM(postgres-index-am.md)의 좁은 vtable이 코어 외부 코드가 코어 내 기계를 타게 하는 것과 같은 플러그-포인트 철학이다. COPY 서브시스템 형태의 가장 결과적인 최근 변화다.

일급 기능으로서의 오류 허용 수집. ON_ERROR IGNORE + REJECT_LIMIT + LOG_VERBOSITY는 PostgreSQL을 웨어하우스가 오랫동안 제공해 온 것(ON_ERROR = CONTINUE / MAXERROR / 거부 테이블)에 가깝게 한다. PostgreSQL 구현의 특징은 COPY 전용 오류 경로를 새로 만드는 대신 범용 소프트 오류 인프라(InputFunctionCallSafe + ErrorSaveContext, postgres-error-handling.md 참조)를 재사용한다는 점이다. COPY는 단순히 그 기계의 가장 두드러진 소비자다. 자연스러운 방향은 거부-행-테이블 싱크다. 현재 행은 카운트되고 선택적으로 기록될 뿐 캡처되지 않는다. ErrorSaveContext는 이미 그 역할을 담기에 충분한 구조를 갖추고 있다.

  • PostgreSQL 소스 (REL_18_STABLE, commit 273fe94, 2026-06-05 기준):
    • src/backend/commands/copy.cDoCopy, ProcessCopyOptions, 옵션 열거형 매퍼, 권한/RLS 처리.
    • src/backend/commands/copyfrom.cCopyFrom, CopyFromRoutine vtable들, BeginCopyFrom, CopyMultiInsert* 배치 서브시스템, COPY FREEZE 자격.
    • src/backend/commands/copyfromparse.c — 네 버퍼 파싱 파이프라인(CopyLoadRawBuf, CopyConvertBuf, CopyReadLineText, CopyReadAttributesText/CSV, NextCopyFrom)과 바이너리 프레이밍(ReceiveCopyBinaryHeader, CopyReadBinaryAttribute).
    • src/backend/commands/copyto.cDoCopyTo, BeginCopyTo, CopyOneRowTo, CopyAttributeOutText/CSV, 바이너리 작성기.
    • src/include/commands/copyfrom_internal.h, src/include/commands/copyapi.h, src/include/commands/copy.hCopyFromState/CopyToState 구조체, 포맷 루틴 vtable 타입, 공개 CopyFormatOptions.
  • 이론/교과서 근거:
    • Database System Concepts (Silberschatz, Korth, Sudarshan) — 행 단위 vs 대량 적재의 스토리지와 물리적 구성 비용 모델.
    • Architecture of a Database System (Hellerstein, Stonebraker, Hamilton; knowledge/research/dbms-papers/fntdb07-architecture.md) — 프로세스 모델과 파싱/수집의 CPU 제한 특성.
  • KB 내 상호 참조:
    • postgres-heap-am.mdtable_multi_insert / heap_multi_insert, TABLE_INSERT_FROZEN 힙 의미론.
    • postgres-ddl-execution.mdDestReceiver를 통한 COPY (query) TO 실행, 파티션 라우팅 설정.
    • postgres-error-handling.mdErrorSaveContext / InputFunctionCallSafe 소프트 오류 인프라.
    • postgres-fdw.mdExecForeignBatchInsert 배치 경로.
    • postgres-table-am.md, postgres-index-am.md — COPY 포맷 루틴이 따르는 vtable/익스텐션 이음새 설계 패턴.