운영 데이터 보정 작업에서 수백만 건을 한 번에 처리해야 하는 순간이 옵니다. 단건 루프를 그대로 돌리면 DB I/O와 락이 치솟아 서비스 쿼리까지 느려집니다. 벌크 처리 패턴을 알면 빠르게 처리하면서도 장애 위험을 줄일 수 있습니다.
벌크 연산은 단순히 '많이 넣는 방법'이 아닙니다. 트랜잭션 범위, WAL 쓰기, 잠금 전략을 함께 설계해야 운영 중인 서비스에 영향을 주지 않으면서 대량 데이터를 처리할 수 있습니다.
- 1단건 INSERT vs 벌크 INSERT — 커밋 오버헤드와 WAL 비용 차이
- 2Multi-row INSERT, PostgreSQL COPY, MySQL LOAD DATA INFILE
- 3ORM 벌크 연산 — SQLAlchemy bulk_insert_mappings, Prisma createMany, JPA saveAll
- 4대량 UPDATE/DELETE 배치 처리 — LIMIT + ORDER BY 루프 패턴
- 5UPSERT 패턴 — ON CONFLICT DO UPDATE, INSERT ON DUPLICATE KEY UPDATE
- 6벌크 연산 중 테이블 락 방지 전략
대량 데이터 처리 패턴 — Bulk Insert·Update·Delete
신입 개발자가 처음으로 대용량 데이터 마이그레이션을 맡았습니다. 100만 건의 레거시 CSV 데이터를 새 테이블로 옮기는 작업이었습니다. Python 스크립트를 작성했고, for 루프 안에서 한 건씩 INSERT 쿼리를 날렸습니다. 로컬에서 1,000건 테스트할 때는 문제없었습니다. 그런데 운영 환경에서 스크립트를 돌리자 2시간이 지나도 10%밖에 처리되지 않았고, DB 서버 I/O가 치솟으며 함께 뜨던 서비스 쿼리들이 느려지기 시작했습니다. 결국 스크립트를 강제 종료했지만 절반쯤 올라간 데이터는 정합성을 알 수 없는 상태가 됐습니다. 단건 루프가 왜 이런 결과를 낳는지, 그리고 어떻게 해야 했는지를 이 모듈에서 배웁니다.
단건 INSERT vs 벌크 INSERT — 왜 이렇게 차이가 나는가
단건 INSERT 루프가 느린 이유는 SQL 문법의 문제가 아닙니다. 매 INSERT마다 발생하는 시스템 수준의 오버헤드가 누적되기 때문입니다. 100만 건을 넣는다면 이 오버헤드가 100만 번 반복됩니다.

단건 INSERT 1회당 발생하는 비용:
1. 네트워크 왕복 (애플리케이션 → DB → 애플리케이션)
2. SQL 파싱 및 실행 계획 수립
3. WAL(Write-Ahead Log) 레코드 기록 + fsync
4. 트랜잭션 커밋 (autocommit 환경에서 매 건마다 커밋)
5. 버퍼 풀 업데이트
벌크 INSERT는 이 비용을 N건에 대해 단 1회로 압축합니다. 아래 수치는 실제 테스트 환경 기준 참고치입니다.
방식 10만 건 소요 시간 비율
─────────────────────────────────────────────────
단건 INSERT 루프 (autocommit) 120~180초 기준
단건 INSERT (하나의 트랜잭션) 15~25초 8~10배 빠름
Multi-row INSERT 3~8초 25~50배 빠름
COPY FROM (PostgreSQL) 0.5~2초 80~200배 빠름
LOAD DATA INFILE (MySQL) 0.5~2초 80~200배 빠름
WAL 비용: PostgreSQL은 모든 변경을 WAL에 순서대로 기록합니다. 단건 커밋은 매 건마다 WAL을 디스크에 flush하는데, 이 fsync 호출 자체가 수ms 단위로 소요됩니다. 벌크 연산은 flush 횟수를 줄여 전체 I/O를 대폭 감소시킵니다.
-- 나쁜 예: autocommit 단건 루프 (Python pseudocode)
for row in data:
cursor.execute("INSERT INTO products (name, price) VALUES (%s, %s)", row)
# 매 건마다 커밋 발생 → 100만 번 fsync
-- 개선 1: 하나의 트랜잭션으로 묶기 (바로 효과)
conn.begin()
for row in data:
cursor.execute("INSERT INTO products (name, price) VALUES (%s, %s)", row)
conn.commit() # 딱 1번 커밋
실행 완료 또는 조회 결과가 표시됩니다.
- 처리 건수—INSERT/COPY 후 COUNT 결과가 입력 파일 또는 배치 크기와 일치하는지 확인합니다.
- 실행 시간—단건 루프보다 왕복과 커밋 횟수가 줄어든 효과를 봅니다.
- 잠금 영향—대량 작업 중 서비스 쿼리가 막히지 않도록 배치 크기를 조정합니다.
실습: Multi-row INSERT, COPY FROM, LOAD DATA INFILE
Multi-row INSERT — 가장 범용적인 방법
-- 기본 Multi-row INSERT (PostgreSQL / MySQL 동일)
INSERT INTO products (name, price, stock)
VALUES
('상품A', 10000, 100),
('상품B', 20000, 50),
('상품C', 15000, 200),
('상품D', 8000, 300);
-- 실무 권장: 배치 크기 1,000~5,000건으로 나눠서 실행
-- 한 번에 너무 많으면 메모리 부담, 너무 적으면 오버헤드 이득 감소
-- 보통 1,000건 단위가 균형점
배치 크기 선택 기준: 단건보다 최소 10배 이상 빨라지는 지점이 1,000건 근처인 경우가 많습니다. 단, 행 크기(컬럼 수, TEXT 컬럼 등)에 따라 달라지므로 실제 데이터로 측정해야 합니다.
PostgreSQL COPY FROM — 가장 빠른 로딩
# CSV 파일 준비 (헤더 없이)
# products.csv:
# 상품A,10000,100
# 상품B,20000,50
# psql에서 COPY 실행
psql -U postgres -d mydb -c "\COPY products (name, price, stock) FROM '/tmp/products.csv' CSV;"
-- SQL 콘솔에서 직접 실행 (슈퍼유저 권한)
COPY products (name, price, stock)
FROM '/tmp/products.csv'
WITH (FORMAT CSV, HEADER FALSE, DELIMITER ',');
-- 결과 확인
SELECT COUNT(*) FROM products;
-- 클라이언트 측 파일 (서버 파일 접근 불가 환경)
-- \COPY는 psql 메타커맨드로 클라이언트 파일을 읽어 서버로 전송
\COPY products (name, price, stock) FROM 'products.csv' CSV;
COPY가 INSERT보다 빠른 이유: WAL을 minimal 모드로 기록하고, 제약 체크를 배치 단위로 수행하며, 파싱 오버헤드가 없습니다. 단, FK 제약이 있으면 로딩 후 검사가 발생하므로 FK를 임시 비활성화하면 더 빨라집니다(단, 정합성 보장 책임은 직접).
MySQL LOAD DATA INFILE
-- MySQL: CSV 파일 직접 로딩
LOAD DATA INFILE '/var/lib/mysql-files/products.csv'
INTO TABLE products
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\n'
(name, price, stock);
-- 로컬 클라이언트 파일 사용 (--local-infile 옵션 필요)
LOAD DATA LOCAL INFILE '/tmp/products.csv'
INTO TABLE products
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\n'
(name, price, stock);
-- MySQL 서버에서 local_infile 활성화 확인
SHOW VARIABLES LIKE 'local_infile';
-- OFF이면: SET GLOBAL local_infile = ON;
ORM 벌크 연산 — 편의성과 성능의 균형
ORM을 쓰면 개별 save() 호출이 단건 INSERT로 변환됩니다. 1,000건을 orm.save()로 루프 돌리면 1,000번 쿼리가 날아갑니다. ORM마다 벌크 연산 API가 따로 있습니다.

SQLAlchemy (Python) — bulk_insert_mappings
from sqlalchemy.orm import Session
from sqlalchemy import text
# 나쁜 예: 단건 add 루프
with Session(engine) as session:
for row in data_list:
product = Product(name=row['name'], price=row['price'])
session.add(product) # 단건 INSERT 예약
session.commit() # 1,000건이면 1,000번 INSERT
# 좋은 예: bulk_insert_mappings
with Session(engine) as session:
session.bulk_insert_mappings(
Product,
[{'name': row['name'], 'price': row['price']} for row in data_list]
)
session.commit()
# 내부적으로 executemany → 훨씬 적은 왕복
# SQLAlchemy 2.0 이후 권장 방식
with Session(engine) as session:
session.execute(
insert(Product),
[{'name': row['name'], 'price': row['price']} for row in data_list]
)
session.commit()
Prisma (Node.js/TypeScript) — createMany
// 나쁜 예: create 루프
for (const row of dataList) {
await prisma.product.create({ data: row });
// 1,000건이면 1,000번 쿼리
}
// 좋은 예: createMany
await prisma.product.createMany({
data: dataList,
skipDuplicates: true, // 중복 키 무시 (선택)
});
// 주의: createMany는 nested create (relation 생성)를 지원하지 않음
// 관계 데이터가 있으면 createManyAndReturn 또는 트랜잭션 사용
JPA/Spring Boot (Java) — saveAll과 배치 설정
// application.properties — 배치 INSERT 활성화 필수
spring.jpa.properties.hibernate.jdbc.batch_size=1000
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
// Repository 사용
@Service
public class ProductService {
@Transactional
public void bulkInsert(List<Product> products) {
// saveAll은 batch_size 설정이 있어야 실제로 배치 동작
productRepository.saveAll(products);
// 설정 없으면 내부적으로 단건 루프와 동일
}
}
// 더 빠른 방법: JdbcTemplate batchUpdate
@Autowired
JdbcTemplate jdbcTemplate;
public void bulkInsertWithJdbc(List<Product> products) {
jdbcTemplate.batchUpdate(
"INSERT INTO products (name, price, stock) VALUES (?, ?, ?)",
products,
1000, // 배치 크기
(ps, product) -> {
ps.setString(1, product.getName());
ps.setInt(2, product.getPrice());
ps.setInt(3, product.getStock());
}
);
}
실습: 대량 UPDATE/DELETE — LIMIT + ORDER BY 루프 패턴
운영 중인 테이블에서 단일 UPDATE로 수백만 건을 한 번에 처리하면 그 시간 동안 해당 행에 잠금이 걸립니다. 서비스 쿼리가 같은 행을 건드리면 대기하게 됩니다. 청크 단위로 나눠서 각각 커밋하면 잠금 범위를 최소화할 수 있습니다.
-- PostgreSQL: 청크 분할 UPDATE (1만 건씩 처리)
DO $$
DECLARE
updated_count INT;
BEGIN
LOOP
UPDATE products
SET updated_at = NOW()
WHERE id IN (
SELECT id FROM products
WHERE updated_at < '2024-01-01'
ORDER BY id
LIMIT 10000
);
GET DIAGNOSTICS updated_count = ROW_COUNT;
EXIT WHEN updated_count = 0; -- 더 이상 업데이트할 행이 없으면 종료
-- 선택: 잠시 대기해 다른 쿼리에 CPU/I/O 양보
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
-- MySQL: 청크 분할 UPDATE
SET @batch_size = 10000;
SET @last_id = 0;
REPEAT
UPDATE products
SET updated_at = NOW()
WHERE id > @last_id
AND updated_at < '2024-01-01'
ORDER BY id
LIMIT 10000;
SET @affected = ROW_COUNT();
SELECT MAX(id) INTO @last_id
FROM (
SELECT id FROM products
WHERE updated_at >= NOW() - INTERVAL 1 SECOND
ORDER BY id DESC
LIMIT 10000
) AS sub;
UNTIL @affected = 0
END REPEAT;
# Python에서 청크 분할 DELETE (대량 삭제 패턴)
import time
import psycopg2
conn = psycopg2.connect(dsn)
cursor = conn.cursor()
batch_size = 10000
deleted_total = 0
while True:
cursor.execute("""
DELETE FROM logs
WHERE id IN (
SELECT id FROM logs
WHERE created_at < NOW() - INTERVAL '90 days'
ORDER BY id
LIMIT %s
)
""", (batch_size,))
deleted = cursor.rowcount
conn.commit()
deleted_total += deleted
print(f"삭제 완료: {deleted_total}건")
if deleted < batch_size:
break # 마지막 배치
time.sleep(0.05) # 50ms 대기로 I/O 분산
print(f"전체 삭제 완료: {deleted_total}건")
ORDER BY + LIMIT 조합의 중요성: LIMIT만 쓰면 DB가 매 배치마다 같은 행을 반복해서 선택할 수 있습니다. ORDER BY id를 함께 쓰면 이미 처리된 범위를 건너뜁니다. id 기반 범위 쿼리(WHERE id > @last_id)로 변환하면 인덱스를 효율적으로 사용할 수 있습니다.
UPSERT 패턴 — 존재하면 UPDATE, 없으면 INSERT
애플리케이션에서 SELECT 후 INSERT or UPDATE를 결정하는 패턴은 동시 요청이 생기면 경쟁 조건이 발생합니다. 두 요청이 동시에 SELECT를 실행하면 둘 다 "행이 없다"고 판단하여 INSERT를 시도하고, 두 번째 INSERT가 중복 키 에러를 냅니다. UPSERT는 이를 DB 레벨에서 원자적으로 처리합니다.
실무 사용 사례:
- 일별/시간별 통계 집계 테이블 (
UPDATE count = count + 1, 없으면 INSERT) - 외부 시스템 데이터 동기화 (ID 기준으로 있으면 업데이트, 없으면 삽입)
- 사용자 설정/프로필 저장 (처음이면 생성, 이후엔 업데이트)
-- PostgreSQL: ON CONFLICT DO UPDATE
-- conflict_target: 중복을 판단할 컬럼 (UNIQUE 제약이나 PK여야 함)
INSERT INTO daily_stats (date, product_id, view_count)
VALUES ('2024-01-15', 42, 1)
ON CONFLICT (date, product_id)
DO UPDATE SET
view_count = daily_stats.view_count + EXCLUDED.view_count,
updated_at = NOW();
-- EXCLUDED: INSERT하려고 했던 값 (충돌로 무시된 행)
-- daily_stats.view_count: 현재 테이블에 있는 값
-- 충돌 시 아무것도 하지 않기 (중복 무시)
INSERT INTO products (id, name, price)
VALUES (1, '상품A', 10000)
ON CONFLICT (id) DO NOTHING;
-- 벌크 UPSERT: 여러 행 한 번에
INSERT INTO daily_stats (date, product_id, view_count)
VALUES
('2024-01-15', 42, 10),
('2024-01-15', 43, 5),
('2024-01-15', 44, 20)
ON CONFLICT (date, product_id)
DO UPDATE SET
view_count = daily_stats.view_count + EXCLUDED.view_count;
-- MySQL: INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO daily_stats (date, product_id, view_count)
VALUES ('2024-01-15', 42, 1)
ON DUPLICATE KEY UPDATE
view_count = view_count + VALUES(view_count),
updated_at = NOW();
-- MySQL 8.0.20 이후: VALUES() 대신 별칭 사용 (권장)
INSERT INTO daily_stats (date, product_id, view_count) AS new_row
VALUES ('2024-01-15', 42, 1)
ON DUPLICATE KEY UPDATE
view_count = view_count + new_row.view_count,
updated_at = NOW();
-- 벌크 UPSERT
INSERT INTO daily_stats (date, product_id, view_count) AS new_row
VALUES
('2024-01-15', 42, 10),
('2024-01-15', 43, 5),
('2024-01-15', 44, 20)
ON DUPLICATE KEY UPDATE
view_count = view_count + new_row.view_count;
주의: ON DUPLICATE KEY UPDATE는 UNIQUE 제약이 걸린 컬럼이 2개 이상일 때 어느 것과 충돌했는지 예측이 어렵습니다. 가능하면 단일 UNIQUE 컬럼 또는 복합 UNIQUE 인덱스를 명확히 지정하세요.
실습: 벌크 연산 중 테이블 락 방지 전략
대용량 INSERT/UPDATE는 테이블 레벨 또는 행 레벨 잠금을 오래 유지합니다. 서비스 중인 테이블이라면 다음 전략을 조합합니다.
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
-- 전략 1: 스테이징 테이블을 거쳐 교체
-- 직접 운영 테이블에 INSERT하는 대신:
-- (1) 임시 테이블에 벌크 INSERT (서비스에 영향 없음)
CREATE TABLE products_staging (LIKE products INCLUDING ALL);
COPY products_staging (name, price, stock)
FROM '/tmp/new_products.csv' CSV;
-- (2) 검증 후 운영 테이블에 병합
BEGIN;
INSERT INTO products SELECT * FROM products_staging
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
price = EXCLUDED.price;
COMMIT;
DROP TABLE products_staging;
-- 전략 2: 인덱스 비활성화 후 COPY, 재활성화 (PostgreSQL)
-- 대량 초기 로딩 시에만 적합 (운영 중 비활성화는 위험)
ALTER INDEX idx_products_name RENAME TO idx_products_name_disabled;
-- ... COPY 실행 ...
ALTER INDEX idx_products_name_disabled RENAME TO idx_products_name;
-- 더 안전한 방법: 인덱스 삭제 후 COPY 후 재생성
DROP INDEX CONCURRENTLY idx_products_name; -- 잠금 없이 삭제
-- COPY 실행
CREATE INDEX CONCURRENTLY idx_products_name ON products(name); -- 잠금 없이 생성
운영 데이터에 적용하면 되돌리기 어려운 변경입니다. 실행 전 대상 테이블, WHERE 조건, 백업 또는 롤백 경로를 반드시 확인하세요.
-- 전략 3: 파티셔닝으로 배치 대상 파티션만 처리 (고급)
-- 월별 파티션이 있을 때 오래된 파티션만 교체
-- DETACH PARTITION → 수정 → ATTACH PARTITION 패턴
ALTER TABLE logs DETACH PARTITION logs_2023_01;
-- logs_2023_01에 수정 작업 (서비스 logs 테이블에 영향 없음)
ALTER TABLE logs ATTACH PARTITION logs_2023_01
FOR VALUES FROM ('2023-01-01') TO ('2023-02-01');
-- 전략 4: lock_timeout으로 오래 기다리는 쿼리 실패 처리 (PostgreSQL)
-- 운영 환경에서 배치 실행 전 설정
SET lock_timeout = '5s'; -- 5초 이상 락 대기 시 에러 발생
SET statement_timeout = '60s'; -- 단일 쿼리 60초 초과 시 종료
-- 이렇게 하면 배치 중 잠금 충돌이 서비스 전체를 멈추지 않고
-- 배치 쿼리 자체가 타임아웃으로 실패함 → 재시도 가능
벌크 연산 중 데드락 / 잠금 대기 타임아웃
# PostgreSQL 데드락 에러
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 6789;
blocked by process 9876.
Process 9876 waits for ShareLock on transaction 12345;
blocked by process 12345.
HINT: See server log for query details.
# MySQL 잠금 대기 타임아웃
ERROR 1205 (HY000): Lock wait timeout exceeded;
try restarting transaction
원인 1 — 배치 처리 순서 불일치: 두 배치 프로세스가 서로 다른 순서로 같은 행 집합을 처리하면 데드락이 발생합니다. 예를 들어 프로세스 A가 id=1 → id=2 순서로 잠금을 걸고, 프로세스 B가 id=2 → id=1 순서로 잠금을 걸면 서로 기다리게 됩니다.
-- 해결: 모든 배치 프로세스에서 동일한 ORDER BY로 정렬 후 처리
-- A, B 프로세스 모두 ORDER BY id ASC 사용
UPDATE products
SET price = price * 1.1
WHERE id IN (
SELECT id FROM products
WHERE category = 'electronics'
ORDER BY id -- 항상 같은 순서 보장
LIMIT 1000
);
원인 2 — 배치 크기가 너무 큼: 하나의 트랜잭션이 너무 많은 행에 잠금을 걸면 다른 쿼리와 충돌 확률이 높아집니다.
# 해결: 배치 크기를 줄이고, 실패 시 재시도 로직 추가
import time
def batch_update_with_retry(conn, batch_size=1000, max_retries=3):
last_id = 0
while True:
for attempt in range(max_retries):
try:
cursor = conn.cursor()
cursor.execute("""
UPDATE products SET price = price * 1.1
WHERE id > %s AND id <= %s + %s
AND category = 'electronics'
""", (last_id, last_id, batch_size))
affected = cursor.rowcount
conn.commit()
last_id += batch_size
break # 성공 시 재시도 루프 탈출
except Exception as e:
conn.rollback()
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt) # 지수 백오프
if affected == 0:
break
원인 3 — MySQL InnoDB 갭 락: REPEATABLE READ 격리 수준에서 범위 쿼리는 갭 락(Gap Lock)을 걸어 해당 범위에 INSERT를 차단합니다. 벌크 INSERT와 범위 UPDATE가 겹치면 데드락이 발생합니다.
-- 해결: MySQL에서 격리 수준을 READ COMMITTED로 낮추면 갭 락 비활성화
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- READ COMMITTED는 갭 락을 사용하지 않아 삽입 충돌이 줄어듦
-- 단, 팬텀 리드 가능성은 허용해야 함
실무에서 이 패턴이 쓰이는 곳
정산 배치 — 월말 대용량 집계 UPDATE
정산 시스템은 보통 월말에 수백만 건의 거래를 집계해 정산 테이블을 업데이트합니다. 전체를 하나의 트랜잭션으로 처리하면 수십 분간 잠금이 걸려 서비스가 멈춥니다. 실무에서는 판매자 ID 기준으로 청크를 나눠 1,000~5,000건씩 처리하고, 각 배치가 완료되면 중간 상태를 별도 테이블에 기록합니다. 중간에 실패해도 처리된 청크는 재처리하지 않고 미처리 청크만 재시작합니다.
# 정산 배치 — 청크 분할 + 체크포인트 패턴
def run_settlement_batch(settlement_date: str):
# 처리할 판매자 목록 조회
sellers = get_sellers_for_settlement(settlement_date)
for i in range(0, len(sellers), CHUNK_SIZE):
chunk = sellers[i:i + CHUNK_SIZE]
seller_ids = [s['id'] for s in chunk]
# 이미 처리된 청크는 건너뜀 (체크포인트)
if is_chunk_processed(settlement_date, seller_ids):
continue
with db.transaction():
# 청크 내 거래 집계 및 정산 테이블 UPSERT
db.execute("""
INSERT INTO settlements (date, seller_id, amount)
SELECT %s, seller_id, SUM(amount)
FROM transactions
WHERE date = %s AND seller_id = ANY(%s)
GROUP BY seller_id
ON CONFLICT (date, seller_id) DO UPDATE
SET amount = EXCLUDED.amount,
updated_at = NOW()
""", (settlement_date, settlement_date, seller_ids))
# 체크포인트 기록
mark_chunk_processed(settlement_date, seller_ids)
데이터 마이그레이션 — 스키마 변경 후 데이터 백필
컬럼을 추가하거나 데이터 포맷을 변환할 때, 기존 수천만 건 데이터를 새 포맷으로 채워야 합니다. 한 번에 전체를 UPDATE하면 테이블 락이 수십 분 지속됩니다. 실무에서는 WHERE new_column IS NULL ORDER BY id LIMIT 10000 루프로 야간에 조금씩 처리하고, 새 코드는 old_column과 new_column을 모두 읽을 수 있게 배포합니다. 백필이 완료된 후 old_column을 제거합니다. 이것이 Expand-Contract 패턴의 데이터 마이그레이션 응용입니다.
이벤트 로그 아카이빙 — 오래된 데이터 대량 삭제
90일 지난 로그를 주기적으로 삭제하는 배치는 DELETE를 한 번에 실행하면 안 됩니다. 수천만 건 DELETE는 언두 로그(MySQL) 또는 dead tuple(PostgreSQL)을 급격히 늘려 이후 VACUUM/OPTIMIZE TABLE 비용을 높입니다. 청크 단위 삭제 후 VACUUM ANALYZE를 주기적으로 실행하는 방식이 표준입니다.
정리
| 상황 | 권장 방법 |
|---|---|
| 수천~수만 건 INSERT | Multi-row INSERT (배치 크기 1,000~5,000) |
| 수십만~수백만 건 초기 로딩 | PostgreSQL COPY, MySQL LOAD DATA INFILE |
| ORM 환경 벌크 INSERT | bulk_insert_mappings, createMany, JdbcTemplate.batchUpdate |
| 운영 중 대량 UPDATE/DELETE | LIMIT + ORDER BY 청크 루프 + 중간 커밋 |
| 존재하면 UPDATE, 없으면 INSERT | ON CONFLICT DO UPDATE (PG), ON DUPLICATE KEY UPDATE (MySQL) |
| 테이블 락 방지 | 스테이징 테이블 → 병합, CONCURRENTLY 인덱스, lock_timeout 설정 |
| 데드락 방지 | 일관된 ORDER BY, 배치 크기 축소, 재시도 로직 |
다음 모듈에서는 Prisma, JPA, TypeORM, SQLAlchemy 등 ORM의 성능 차이와 올바른 사용 패턴을 다룹니다.