백엔드 데이터 인프라 25편. INSERT 깊이 — Bulk vs 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 + 임시 비활성화.
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 무조건
시리즈 다른 편
- Part 2 SQL Language 깊이: 21편 DDL 개요 · 22편 CREATE TABLE 깊이 · 23편 제약 깊이 · 24편 DML 개요 · 25편 (현재 글)
시리즈 다음 글
다음 글(26편)에서는 UPDATE 깊이 — FROM JOIN·CASE·HOT UPDATE·fillfactor 운영 최적.
공식 문서: PostgreSQL 18 — DML: Inserting Data에서 더 자세한 사양을 확인할 수 있어요.