백엔드 데이터 인프라 25편 — INSERT 깊이 Bulk·COPY·UPSERT 전략

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 25편. INSERT 깊이 — Bulk vs COPY 성능·UPSERT 전략·트리거 영향·동시성 함정 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 25편 — INSERT 깊이 Bulk·COPY·UPSERT 전략

이 글은 백엔드 데이터 인프라 시리즈 70편 중 25편이에요. 10편 INSERT 에서 "5가지 패턴" 을 다뤘으니, 이번 25편은 그 위에 — 성능·UPSERT 전략·운영 함정.

INSERT 의 세 가지 속도

같은 100,000건 입력 시 속도 차이.

방법 100,000건 입력 시간
단건 INSERT × 10만 ~10초
Bulk INSERT (1000건씩 × 100) ~2초
COPY (한 번) ~0.5초

20배 차이. 큰 데이터 입력은 "방법 선택" 이 결정적.

단건 INSERT 의 비용

INSERT INTO logs (level, msg) VALUES ('INFO', 'hello');

각 INSERT 마다: - 네트워크 왕복 1번 - 파싱·계획 1번 - 트랜잭션 시작·커밋 1번 - WAL fsync 1번 - 인덱스 갱신 - 트리거 발동

100,000번 = 위 모든 비용이 100,000번. 느림의 근본 원인.

Bulk INSERT — N 배 빠름

INSERT INTO logs (level, msg) VALUES
    ('INFO', 'msg 1'),
    ('INFO', 'msg 2'),
    ...
    ('INFO', 'msg 1000');

한 SQL = 한 파싱·한 계획·한 트랜잭션. WAL 도 묶여 fsync 횟수 감소.

chunk 사이즈 — 1000~5000 권장

너무 크면 — 메모리 부담·계획자 시간 폭주. 너무 작으면 — 단건과 다를 바 없음.

한국 회사 표준 = 1000~5000건/chunk.

JPA + Bulk

JPA saveAll() 도 Hibernate 설정에 따라 Bulk INSERT 박힘:

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000
        order_inserts: true
        order_updates: true

batch_size 박지 않으면 — saveAll() 도 단건 N번. 한국 회사 흔한 함정.

COPY — 가장 빠른 입력

-- 클라이언트 파일
\copy logs (level, msg) FROM 'logs.csv' WITH (FORMAT csv, HEADER true)

-- 또는 STDIN
\copy logs (level, msg) FROM STDIN WITH (FORMAT csv)
INFO,hello
INFO,world
\.

COPY 가 Bulk 보다 빠른 이유: - SQL 파싱 안 함 (별도 프로토콜) - 인덱스·제약 검사 일괄 - WAL 페이지 단위 기록

Spring 에서 COPY — PgCopy 또는 PostgreSQL JDBC

import org.postgresql.copy.CopyManager;
import org.postgresql.PGConnection;

@Transactional
public void bulkInsert(List<Log> logs) throws SQLException, IOException {
    PGConnection pgCon = connection.unwrap(PGConnection.class);
    CopyManager copyManager = pgCon.getCopyAPI();

    String csv = logs.stream()
        .map(l -> l.level() + "," + l.msg())
        .collect(Collectors.joining("\n"));

    copyManager.copyIn("COPY logs (level, msg) FROM STDIN WITH (FORMAT csv)",
                       new StringReader(csv));
}

JPA 표준 X — PostgreSQL JDBC 드라이버 직접. 100만건 입력 시나리오에 표준.

UPSERT 전략 4가지

10편 에서 ON CONFLICT 를 소개. 깊이.

전략 1 — ON CONFLICT DO UPDATE

INSERT INTO user_stats (user_id, visit_count)
VALUES (1, 1)
ON CONFLICT (user_id)
DO UPDATE SET visit_count = user_stats.visit_count + 1;

PG 9.5+ 표준. 가장 흔함.

전략 2 — ON CONFLICT DO NOTHING

INSERT INTO unique_emails (email, source)
VALUES ('alice@example.com', 'signup')
ON CONFLICT (email) DO NOTHING;

중복 = 그냥 패스. 멱등 입력에 표준.

전략 3 — MERGE (PG 15+)

MERGE INTO users target
USING (VALUES (1, 'Alice')) AS source(id, name)
ON target.id = source.id
WHEN MATCHED THEN UPDATE SET name = source.name
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (source.id, source.name);

복잡한 조건 분기·DELETE 가능한 시나리오.

전략 4 — 트랜잭션 + SELECT FOR UPDATE + 분기

BEGIN;
SELECT id FROM users WHERE email = ? FOR UPDATE;
-- 결과 있으면 UPDATE, 없으면 INSERT
COMMIT;

성능 X — ON CONFLICT 의 대체.

UPSERT 인덱스 요구

ON CONFLICT (col)   -- col 에 UNIQUE 또는 unique 인덱스 필수
ON CONFLICT (col) WHERE 조건   -- 부분 unique 인덱스
ON CONFLICT ON CONSTRAINT name   -- 명명된 제약

인덱스 없으면 — ERROR.

EXCLUDED — INSERT 하려던 값

INSERT INTO products (id, name, price, updated_at)
VALUES (1, 'Item', 1000, NOW())
ON CONFLICT (id)
DO UPDATE SET
    name = EXCLUDED.name,
    price = EXCLUDED.price,
    updated_at = EXCLUDED.updated_at;

EXCLUDED = "INSERT 하려던 그 값". UPDATE 안 박는 컬럼은 옛 값 유지.

조건부 UPDATE

ON CONFLICT (id)
DO UPDATE SET price = EXCLUDED.price
WHERE products.price <> EXCLUDED.price;

가격이 다를 때만 UPDATE — 불필요한 변경 회피, WAL 절약.

트리거 영향

INSERT 시 트리거 발동: - BEFORE INSERT — 값 수정·검증 - AFTER INSERT — 감사 로그·캐시 갱신

CREATE TRIGGER log_insert
AFTER INSERT ON users
FOR EACH ROW EXECUTE FUNCTION log_user_creation();

대량 INSERT 시 — 트리거가 "각 행마다" 발동 → 큰 부담. chunk 처리 + 임시 비활성화 가 표준.

ALTER TABLE users DISABLE TRIGGER log_insert;

COPY users FROM 'data.csv' ...;

ALTER TABLE users ENABLE TRIGGER log_insert;

시퀀스와 INSERT

CREATE TABLE users (id BIGSERIAL PRIMARY KEY, ...);
INSERT INTO users (...) VALUES (...);
-- 자동으로 nextval('users_id_seq')

시퀀스 동작 — 트랜잭션 무관

BEGIN;
INSERT INTO users VALUES (...) RETURNING id;   -- id = 100
ROLLBACK;

INSERT INTO users VALUES (...) RETURNING id;   -- id = 101 (100 안 재사용)

ROLLBACK 해도 시퀀스 값은 소비됨. ID 가 "건너뛰는" 게 정상.

currval·lastval

SELECT currval('users_id_seq');     -- 현 세션 마지막
SELECT lastval();                    -- 세션 마지막 (어느 시퀀스든)

INSERT RETURNING 이 더 깔끔.

동시성 함정

동시 INSERT + UNIQUE

-- 트랜잭션 A
SELECT 1 FROM users WHERE email = 'alice@x.com';   -- 없음
INSERT INTO users (email, ...) VALUES ('alice@x.com', ...);

-- 트랜잭션 B (동시)
SELECT 1 FROM users WHERE email = 'alice@x.com';   -- 없음
INSERT INTO users (email, ...) VALUES ('alice@x.com', ...);   -- UNIQUE 위반!

해결: - ON CONFLICT DO NOTHING (UPSERT) - SELECT FOR UPDATE (락) - 예외 처리 + 재시도

시퀀스 경합 — 거의 없음

PG 시퀀스 = 락 거의 없음. 100스레드 동시 INSERT 도 빠름.

함정 5가지

(1) 단건 N번 호출

JPA for (User u : list) repo.save(u) = 단건 N번. saveAll(list) + batch_size 박기.

(2) chunk 너무 큼

100,000건 한 INSERT = 메모리 폭발. 1000~5000 권장.

(3) UPSERT 인덱스 누락

ON CONFLICT (col) 시 col 에 UNIQUE 필요. ERROR 만나면 인덱스 확인.

(4) EXCLUDED 안 박음

ON CONFLICT (id) DO UPDATE SET name = 'fixed'   -- ❌ 항상 'fixed'

SET name = EXCLUDED.name 가 정상.

(5) 트리거 무시 대량 INSERT

각 행 트리거 발동 = 대량 시 폭발. chunk + 임시 비활성화.

⚡ 100K 행 입력 룰

10K 이하 = Bulk INSERT (chunk 1000). 10K ~ 100K = COPY. 100K+ = COPY + 트리거 일시 비활성화 + ANALYZE 후처리. JPA 는 batch_size 박기.

한 줄 정리 — INSERT 속도 = 단건 < Bulk < COPY (20배 차이). ON CONFLICT 4전략 (DO UPDATE·DO NOTHING·MERGE·트랜잭션). EXCLUDED 로 INSERT 값 참조. 시퀀스는 ROLLBACK 무관 — ID 건너뜀 정상. 대량은 트리거 일시 비활성화 + ANALYZE.

시험 직전 한 번 더 — INSERT 깊이 입문자가 매번 헷갈리는 것

  • 속도 = 단건 < Bulk < COPY (수십 배 차이)
  • 단건 비용 = 네트워크·파싱·트랜잭션·WAL 모두 N번
  • Bulk = 한 SQL 여러 VALUES (chunk 1000~5000)
  • COPY = 가장 빠름 (별도 프로토콜)
  • COPY STDIN = 스트림 입력
  • Spring CopyManager = PostgreSQL JDBC API
  • JPA batch_size 박아야 saveAll Bulk
  • order_inserts·order_updates 옵션
  • UPSERT 4전략 = DO UPDATE·DO NOTHING·MERGE·SELECT FOR UPDATE
  • EXCLUDED = INSERT 하려던 값
  • 조건부 UPDATE = WHERE 기존 <> EXCLUDED
  • UPSERT 대상 컬럼 = UNIQUE 인덱스 필수
  • 부분 UNIQUE 인덱스 = WHERE 조건
  • ON CONFLICT ON CONSTRAINT name = 명명 제약
  • 트리거 = 각 행마다 발동 (대량 시 부담)
  • 대량 INSERT = 트리거 일시 비활성화
  • ALTER TABLE DISABLE TRIGGER name·ENABLE
  • 시퀀스 = ROLLBACK 무관 (ID 건너뜀)
  • currval·lastval = 세션 마지막 값
  • RETURNING id 가 더 깔끔
  • 동시 INSERT 경합 = ON CONFLICT DO NOTHING 으로 안전
  • SELECT-INSERT 분리 = race condition 위험
  • 100K+ = COPY + 트리거 OFF + ANALYZE
  • 운영 = Bulk 또는 COPY 무조건

시리즈 다른 편

시리즈 다음 글

다음 글(26편)에서는 UPDATE 깊이 — FROM JOIN·CASE·HOT UPDATE·fillfactor 운영 최적.

공식 문서: PostgreSQL 18 — DML: Inserting Data에서 더 자세한 사양을 확인할 수 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!