[우아한 트러블슈팅] 3만 건 배치 성능 개선기: JPA에서 PostgreSQL Native UPSERT로 전환하기
1. 배경 및 문제 상황
- 시스템 환경: Spring Boot (JDK 21) + Kotlin + JPA + PostgreSQL
- 배치 주기: 하루 6번 (4시간 간격)
- 데이터 규모: 회당 2만 ~ 3만 건 전체 갱신 (1.5만 건 / 5천 건 / 1천 건 단위 처리)
- 기존 방식:
deleteAll() -> flush() -> 단건 save() 루프순서로 처리 - 문제점: 단건 처리로 인한 DB 왕복(Round-Trip) 횟수 과다, 배치 수행 속도 심각하게 저하
성능개선 1단계 : 쉽고 빠르게 적용하는 'Hibernate Batch'
핵심 아이디어: 단건 INSERT 루프를 묶어서 한 번에 보내기 (DB 왕복 횟수 감소)
2.1 설정 추가 (application.yml)
yaml
spring:
jpa:
properties:
hibernate.jdbc.batch_size: 100
hibernate.order_inserts: true
hibernate.order_updates: true2.2 코드 변경: save() 루프에서 saveAll()로 전환
- 기존: entities.forEach { repository.save(it) } -> INSERT 100번 발생
- 변경: repository.saveAll(newEntities) -> Batch INSERT (묶음 처리)
코드예시
수정 전
kt
@Repository
class FooRepository(
private val fooCsIssueJpaRepository: fooCsIssueJpaRepository,
private val fooQdslRepository: fooEpicQdslRepository
) {
fun upsertAll(entities: List<fooCsIssue>) {
val existingEntities = fooCsIssueJpaRepository
.findByfooIssue_IssueIdIn(entities.map { it.fooIssue.issueId })
.associateBy { it.fooIssue.issueId }
entities.forEach { newEntity ->
val oldEntity = existingEntities[newEntity.fooIssue.issueId]
if (oldEntity != null) {
oldEntity.updateFrom(newEntity)
} else {
fooCsIssueJpaRepository.save(newEntity)
}
}
}
}수정 후
3. 성능개선 2단계 : 'PostgreSQL Native UPSERT' (최종 목표)
핵심 아이디어: "조회 후 엔티티 수정(Select-Before-Write)" 방식을 버리고, DB가 직접 단방향으로 Upsert하게 만들기
기존 방식의 비효율 (엔티티 기반 처리)
- 데이터 존재 여부 확인 (SELECT 1번)
- 결과에 따라 존재하면 UPDATE, 없으면 INSERT (N번)
- 예: 100개 데이터 처리 시 총 101번의 SQL 인프라 비용 발생 3.2 변경 방식: PostgreSQL ON CONFLICT 도입
- 기술 스택 변경: JPA Entity 기반 구조 -> JdbcTemplate + PostgreSQL 네이티브 쿼리
- 적용 쿼리 예시:
sql
INSERT INTO ext_foo_epics (issue_key, summary, status, updated_time)
VALUES (?, ?, ?, ?)
ON CONFLICT (issue_key)
DO UPDATE SET
summary = EXCLUDED.summary,
status = EXCLUDED.status,
updated_time = EXCLUDED.updated_time;- 기대 효과: 101번 수행되던 쿼리가 UPSERT Batch 1~2번으로 압축 (가장 강력한 성능 개선 포인트)
상황별 적용 단계 및 릴리즈 로드맵
[Phase 1] 이번 주 작업 (Quick Win)
- HikariCP 커넥션 풀 누수 감지 설정 (leak-detection-threshold: 30000)
- hibernate.jdbc.batch_size 활성화 설정 적용
- 기존 비효율적인 반복문 save() 코드를 saveAll()로 전면 교체
[Phase 2] 다음 리팩토링 (Maximum Efficiency)
• Repository 계층 구조를 JdbcTemplate 기반으로 전환하여 PostgreSQL ON CONFLICT DO UPDATE 구문 적용 (성능 최적화 완료)
지금은 "조회 후 엔티티 수정" 방식이라 SQL이 과도하게 발생하는 구조다. 최종 목표는 JdbcTemplate과 PostgreSQL의 ON CONFLICT를 이용해 애플리케이션의 낭비되는 조회 없이 DB가 직접 Batch Upsert 하게 만드는 것이다.
이 코드가 진화하는 과정 (블로그 빌드업용)
네가 빌드업한 스토리라인이 이 예전 소스코드 덕분에 완벽해졌어. 요약하자면 아래 단계로 진화하는 거지.
• [Legacy] 예전 코드 (이 질문의 코드)
• 100개씩 쪼개서 자잘한 쿼리 폭탄은 막았으나, forEach 문 돌면서 save()를 단건 호출함.
• 식별자가 채워진 채로 save()가 호출되어 내부적으로 불필요한 merge(SELECT) 연산이 추가 발생함. 성능 저하의 원인.
• [Phase 1] 1단계 개선 코드 (이전 질문의 첫 번째 코드)
• save() 루프를 제거하고 saveAll()로 변경.
• Hibernate Batch 설정을 켜서 100개의 INSERT를 하나의 네트워크 묶음(Batch)으로 전송. DB 왕복 비용 대폭 감소.
• [Phase 2] 2단계 최종 진화 (JdbcTemplate + ON CONFLICT)
• 애플리케이션 layer에서 findBy...In()으로 100개씩 조회하던 SELECT마저 싹 다 제거.
• DB로 100개의 데이터를 그냥 바로 밀어 넣고, PostgreSQL의 ON CONFLICT DO UPDATE 기능을 이용해 DB가 알아서 Insert/Update를 판단하게 만듦. 성능의 신세계 경험.
예전 코드를 보니까 왜 네가 단계별 개선 시나리오를 그렇게 짰는지 딱 이해가 된다. 블로그 글 쓸 때 이 예전 코드의 한계점(단건 save의 무거움과 merge의 위험성)을 슬쩍 언급해주면 아주 프로페셔널해 보일 거야!예전 소스코드의 숨은 문제점 2가지
1. forEach 안에서 save()를 단건으로 호출하는 문제
대박 중요: '이미 존재하는 ID'일 때 발생하는 hidden SELECT (Merge)
foo 데이터 특성상, issueId 같은 식별자는 DB가 자동으로 생성(Identity)하는 게 아니라 foo에서 이미 발급받은 값을 엔티티에 넣어서 들어오지?
• JPA의 save()는 엔티티에 ID 값이 이미 채워져 있으면, 이 녀석을 새로운 엔티티가 아니라 기존 엔티티로 오해해.
• 그래서 persist()가 아니라 merge()를 호출하게 되는데, merge()는 "진짜 DB에 이 ID가 없는 게 맞나?"를 확인하기 위해 내부적으로 SELECT 쿼리를 한 번 더 날려.
• 결국 else { repository.save(newEntity) }가 실행될 때마다, 우리가 위에서 이미 findByfooIssue_IssueIdIn으로 조회를 끝냈음에도 불구하고, JPA가 내부적으로 SELECT를 또 날리는 낭비가 발생했을 확률이 아주 높아.
---
과거 코드 vs 1단계 코드 로직 비교
🔄 변경된 점 (Insert 처리 방식의 변화)
• 기존 데이터 수정(Update): 두 코드 모두 상위 트랜잭션 안에서 oldEntity.updateFrom(newEntity)를 호출하므로, Dirty Checking(변경 감지)으로 동작하는 방식은 100% 동일해. 안전해.
• 신규 데이터 삽입(Insert):
• 과거: 루프를 돌며 조건에 맞을 때마다 repo.save(it)를 건건이 호출.
• 1단계: 루프에서는 리스트(newEntities)에 담기만 하고, 루프가 끝난 뒤 repo.saveAll(newEntities)로 한 번에 전달.
2. 1단계 코딩의 칭찬할 점 (개선된 부분)
1. 가독성과 메모리 효율성: 루프 안에서 무겁게 외부 레포지토리를 계속 호출하는 대신, 순수 코틀린 컬렉션(mutableListOf)에 메모리 주소만 담았다가 마지막에 처리하므로 코드 흐름이 깔끔하고 불필요한 컨텍스트 스위칭이 줄어들어.
2. Batch Insert의 발판 마련: saveAll()을 사용해야만 우리가 아까 yml 설정에 넣었던 hibernate.jdbc.batch_size가 제대로 발동할 수 있어2026-06-12T15:51:46.163+09:00 INFO 14605 --- [mdm-backend] [on(1)-127.0.0.1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-06-12T15:51:49.918+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 0
2026-06-12T15:51:54.206+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 100
2026-06-12T15:51:58.128+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 200
2026-06-12T15:52:02.087+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 300
2026-06-12T15:52:06.176+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 400
2026-06-12T15:52:10.496+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 500
2026-06-12T15:52:14.475+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 600
2026-06-12T15:52:18.739+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 700
2026-06-12T15:52:23.166+09:00 INFO 14605 --- [mdm-backend] [nio-8080-exec-1] c.k.mdmcore.foo.serviceV2.fooService : syncCsIssue sync at 800