명령어/DB

[PostgreSQL] 배치 동기화 ETL — COPY FROM PROGRAM + MERGE 다분기 + PROCEDURE 청크 커밋

jykim23 2026. 7. 21. 21:53
반응형

설치·접속: PostgreSQL 설치와 접속

부제: 매일 벤더가 주는 재고 파일을 본 테이블에 반영하는데, 신규는 INSERT·변경은 UPDATE·재고 0은 DELETE를 한 번에 처리하고 대량이면 트랜잭션이 몇 시간 잠길 때

매일 벤더 재고 스냅샷을 받아 재고 테이블과 동기화한다. 앱에서 파일을 파싱해 행마다 조회하고 분기 INSERT/UPDATE/DELETE를 돌리던 루프를, 세 가지 SQL 기능으로 갈아엎는다. 파일 적재는 COPY FROM PROGRAM, 다분기 반영은 MERGE, 대량일 때 롱 트랜잭션 회피는 PROCEDURE 청크 커밋이다.

1단계 — COPY FROM PROGRAM으로 스테이징 적재 (HEADER MATCH로 컬럼 검증)

COPY ... FROM PROGRAM은 서버가 셸 명령을 실행해 그 출력을 파이프로 직접 읽는다. 앱에서 압축 풀고 파싱해 INSERT 루프 도는 과정이 통째로 사라진다. HEADER MATCH는 파일 헤더가 테이블 컬럼명과 다르면 즉시 에러를 내 "컬럼 밀림" 사고를 막는다. (실무에선 'zcat /data/sales.csv.gz' 같은 명령을 쓰지만, 여기서는 재현을 위해 printf로 CSV를 만들어 넣는다.)

CREATE TABLE target  (winename text PRIMARY KEY, stock int);
CREATE TABLE staging (winename text, stock int);

INSERT INTO target VALUES
  ('Chateau Latour', 12), ('Opus One', 8), ('Sassicaia', 0);

COPY staging FROM PROGRAM
  'printf "winename,stock\nChateau Latour,15\nOpus One,0\nPenfolds Grange,20\n"'
  WITH (FORMAT csv, HEADER MATCH);

SELECT * FROM staging ORDER BY winename;
COPY 3
    winename     | stock
-----------------+-------
 Chateau Latour  |    15
 Opus One        |     0
 Penfolds Grange |    20

HEADER MATCH가 헤더 winename,stock을 테이블 컬럼과 대조해 통과시켰다. 헤더 순서가 뒤바뀐 파일이 오면 여기서 바로 실패해 조용한 데이터 오염을 차단한다.

2단계 — MERGE 다분기로 본 테이블에 반영

스테이징의 스냅샷을 본 테이블에 한 문장으로 반영한다. 매칭 안 되고 재고가 있으면 신규 INSERT, 재고 0이면 DELETE, 그 외는 UPDATE. ON CONFLICT로는 DELETE 분기를 표현할 수 없어 앱 루프를 돌던 걸 MERGE 하나로 대체한다.

MERGE INTO target t
USING staging s ON t.winename = s.winename
WHEN NOT MATCHED AND s.stock > 0 THEN
  INSERT (winename, stock) VALUES (s.winename, s.stock)
WHEN MATCHED AND s.stock = 0 THEN
  DELETE
WHEN MATCHED THEN
  UPDATE SET stock = s.stock;

SELECT * FROM target ORDER BY winename;
MERGE 3
    winename     | stock
-----------------+-------
 Chateau Latour  |    15
 Penfolds Grange |    20
 Sassicaia       |     0

한 문장에서 세 갈래가 다 돌았다. Chateau Latour는 12→15 UPDATE, Opus One은 재고 0이라 DELETE, Penfolds Grange는 신규 INSERT. 스테이징에 안 온 Sassicaia는 손대지 않았다.

3단계 — 대량이면 PROCEDURE 루프에서 청크 커밋

스냅샷이 수천만 건이면 한 트랜잭션 MERGE는 롱 트랜잭션·락·WAL 폭증을 부른다. FUNCTION은 호출자 트랜잭션 안에서 원자적으로 돌아 중간 커밋이 불가능하지만, PROCEDURE는 본문에서 COMMIT을 실행할 수 있다. 청크마다 커밋하며 진행률을 남긴다.

CREATE PROCEDURE sync_chunked(chunk int) LANGUAGE plpgsql AS $$
DECLARE n int; total int := 0;
BEGIN
  LOOP
    UPDATE dst d SET val = s.val, done = true
    FROM src s
    WHERE d.id = s.id AND d.done = false
      AND d.id IN (SELECT id FROM dst WHERE done = false ORDER BY id LIMIT chunk);
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    total := total + n;
    COMMIT;                        -- 청크 단위 커밋
    RAISE NOTICE 'committed chunk of % rows (total %)', n, total;
  END LOOP;
END $$;

CALL sync_chunked(5000);       -- src/dst 각 25000행
NOTICE:  committed chunk of 5000 rows (total 5000)
NOTICE:  committed chunk of 5000 rows (total 10000)
NOTICE:  committed chunk of 5000 rows (total 15000)
NOTICE:  committed chunk of 5000 rows (total 20000)
NOTICE:  committed chunk of 5000 rows (total 25000)
CALL
 synced | total
--------+-------
  25000 | 25000

2.5만 건을 5천 건씩 다섯 청크로 나눠 각각 커밋했다. 중간에 죽어도 이미 커밋된 청크는 살아 있고, done = false 조건 덕에 재실행하면 남은 것부터 이어서 처리한다.

결론 — 언제 이 조합인가

외부 파일을 주기적으로 받아 본 테이블과 동기화하는 배치 ETL의 정석이다. 적재(COPY FROM PROGRAM + HEADER MATCH)로 파싱 코드와 컬럼 밀림을 없애고, 반영(MERGE)으로 다분기 upsert/delete를 한 문장에 담고, 규모가 커지면 PROCEDURE 청크 커밋으로 롱 트랜잭션을 피한다. 데이터가 작아 한 트랜잭션으로 충분하면 3단계는 생략하고 MERGE 한 방으로 끝내면 된다.

이렇게도 쓴다

실전에서는 gzip CSV를 서버가 직접 스트리밍한다.

COPY staging FROM PROGRAM 'zcat /data/sales_20260718.csv.gz'
  WITH (FORMAT csv, HEADER MATCH, FORCE_NULL(discount));

 

MERGE에 DO NOTHING 분기를 둬 변경 없는 행은 건드리지 않는다. (조합: MERGE DO NOTHING)

MERGE INTO t t USING s s ON t.k = s.k
WHEN MATCHED THEN DO NOTHING
WHEN NOT MATCHED THEN INSERT VALUES (s.k, s.v);

 

MERGE를 청크 프로시저 안에 넣어 대량 upsert를 나눠 커밋한다. (조합: MERGE + PROCEDURE)

LOOP
  MERGE INTO target t USING (SELECT * FROM staging LIMIT 10000) s
  ON t.winename = s.winename
  WHEN MATCHED THEN UPDATE SET stock = s.stock
  WHEN NOT MATCHED THEN INSERT VALUES (s.winename, s.stock);
  EXIT WHEN NOT FOUND;
  COMMIT;
END LOOP;

 

FORCE_NULL로 따옴표 빈 문자열("")을 진짜 NULL로 적재한다.

COPY staging FROM PROGRAM 'cat /data/f.csv'
  WITH (FORMAT csv, HEADER MATCH, FORCE_NULL(stock));

 

적재 후 스테이징을 TRUNCATE해 다음 배치를 깨끗이 받는다. (조합: 스테이징 재활용)

TRUNCATE staging;
반응형