Search
⚙️

스프링 배치와 성능 최적화 전략

Tags
Spring
Last edited time
2025/11/16 08:31
2 more properties

1. Spring Batch 개념

1.1. Spring Batch란?

대량의 데이터를 일괄 처리(Batch Processing)하기 위한 경량 프레임워크
특정 시간에 많은 데이터를 효율적으로 처리하는 것이 목적
실시간 서비스 대비 개발 부담이 적다는 인식이 있으나, 대량 데이터 처리 시 성능 최적화 필수

1.2. 주요 구성 요소

Job (잡)
전체 배치 작업을 의미하는 최상위 단위
여러 개의 Step으로 구성 가능
Job Repository에 메타 정보 저장
Step (스텝)
Job을 구성하는 단위 작업
Reader, Processor, Writer로 구성
Processor는 선택사항, Reader와 Writer는 필수
Reader (리더)
데이터를 읽어오는 역할
DB, 파일, API 등 다양한 소스에서 데이터 조회
한 건씩 데이터를 반환
Processor (프로세서)
Reader에서 받은 데이터를 가공/변환하는 역할
필수 구성 요소는 아님
단순 변환 로직만 있다면 사용 가능, IO 작업 포함 시 성능 저하 원인
Writer (라이터)
처리된 데이터를 저장하는 역할
DB, 파일, API 등으로 데이터 저장
Chunk 단위로 일괄 처리

1.3. Chunk Processing

1.3.1. 개념

대량 데이터를 Chunk Size 기준으로 분할하여 처리
메모리 부하 방지 및 커넥션 타임아웃 해결
100만 건을 한 번에 처리하지 않고, 1000건씩 1000번 나누어 처리

1.3.2. 동작 방식

1.
Reader가 데이터를 한 건씩 읽음
2.
Processor가 한 건씩 가공
3.
Chunk Size만큼 반복 (예: 1000건)
4.
Writer가 Chunk Size만큼 모인 데이터를 일괄 저장
5.
전체 데이터가 처리될 때까지 1~4 반복
코드 레벨 동작
외부 루프 (전체 데이터 크기만큼): 내부 루프 (Chunk Size만큼): - Reader에서 아이템 읽기 - Processor에서 가공 - 리스트에 추가 Writer에서 리스트 일괄 저장
Plain Text
복사

1.4. 일반적인 배치 사용 사례

일괄 생성
Read → Aggregation → Write
기존 데이터를 조합하여 새로운 데이터 생성
예: 주문 정보 + 사용자 정보 → 주문자 정보 생성
일괄 수정
Read → Update → Write
저장된 데이터를 일괄 수정
예: 배송 정보 기반 주문 정보 업데이트
통계 데이터 생성
Read → Aggregation → Write
데이터를 집계하여 통계 생성
예: 상품별 주문 금액 합산

2. Spring Batch 성능 저하 원인

2.1. 반복적인 IO 작업

배치 성능 저하의 주요 원인
Chunk Size만큼 IO 작업이 반복되면 성능 저하 발생
네트워크 IO와 데이터베이스 IO가 주요 병목 지점

2.2. 네트워크 IO 대기

문제점
Processor에서 API 호출 시 응답 대기 상태 발생
Chunk Size만큼 순차적으로 API 호출 반복
예: 150ms API × 1000건 = 150초 (2.5분) 대기
구조적 문제
Processor는 단건 처리 구조
API 응답 완료까지 대기 상태로 남음
병렬 처리 불가

2.3. 데이터베이스 IO 반복

문제점
Chunk Size만큼 개별 UPDATE/INSERT 쿼리 실행
IO 작업 빈도수가 높을수록 성능 저하
JPA Dirty Checking 사용 시 성능 손실 증가
예시
Chunk Size 1000일 때 최대 1000번의 DB IO 발생
데이터 증가에 비례하여 처리 시간 비선형적 증가

2.4. JPA Paging ItemReader의 LIMIT/OFFSET 문제

성능 저하
뒷 페이지로 갈수록 조회 속도 급격히 감소
MySQL이 5천만 번째 데이터를 찾는 데 큰 부담
데이터 증가에 비례하여 처리 시간 비선형적 증가
성능 측정 예시
10만 건: 18초
50만 건: 235초 (5배 증가 시 13배 시간 소요)
300만 건: 112분

2.5. 쿼리 의존적 Aggregation의 문제

GROUP BY SUM 쿼리의 한계
여러 테이블 조인 시 실행 계획 복잡도 증가
Temporary Table 생성 및 File Sort 발생
튜닝 난이도 높고, 카디널리티 변화 시 실행 계획 변경
인덱스 추가의 부작용
튜닝을 위한 인덱스 추가 시 INSERT/UPDATE 성능 저하
저장 용량 증가
Reader 개선 효과 무용지물

2.6. 배치 퍼포먼스에 대한 무관심

일반적인 문제
배치 개발을 쉽게 생각하는 경향
배포 후 모니터링 부족
배치 전용 APM 툴 부재로 문제 인지 어려움
데이터 증가의 영향
초기에는 문제 없던 배치가 데이터 증가 시 급격한 성능 저하
예: 25만 건 → 1억 건 증가 시, 개선 없이는 1시간 → 400시간 소요

3. 성능 최적화 전략: Read 개선

3.1. Zero Offset ItemReader

개념
OFFSET을 항상 0으로 유지하여 LIMIT/OFFSET 성능 저하 해결
PK 기준 오름차순 정렬 후, PK 조건을 자동 추가
동작 방식
1.
PK 기준 오름차순 정렬
2.
첫 페이지: WHERE PK > 0 LIMIT 1000
3.
두 번째 페이지: WHERE PK > 1000 LIMIT 1000
4.
세 번째 페이지: WHERE PK > 2000 LIMIT 1000
장점
뒷 페이지로 가도 OFFSET이 항상 0
조회 속도 일정 유지
데이터 증가에 비례한 선형적 성능 증가
구현
QueryDSL과 결합하여 쿼리 구현
Zero Offset 동작 방식으로 자동 변환
편리하고 안전한 쿼리 구현 가능
예시 코드 (QueryDSL Zero Offset)
fun zeroOffsetReader(lastId: Long?, chunkSize: Int): List<UserDto> = queryFactory .select( QUserDto( user.id, user.name, user.grade ) ) .from(user) .where(lastId?.let { user.id.gt(it) } ?: user.id.gt(0)) .orderBy(user.id.asc()) .limit(chunkSize.toLong()) .fetch()
Kotlin
복사

3.2. Cursor ItemReader

개념
LIMIT/OFFSET 대신 Cursor 사용
데이터가 없을 때까지 일정 개수씩 반복 제공
Chunk Processing에 적합
주의사항: JPA Cursor ItemReader 사용 금지
데이터를 모두 메모리에 로드 후 서버에서 Iterator로 커서 대량 데이터 처리 시 OOM 유발
절대 사용 금지
추천: JDBC/Hibernate Cursor ItemReader
MySQL 서버 커서 방식 동작
필요할 때마다 일정 개수만큼 데이터 가져옴
메모리 안정성 확보
단점
쿼리를 HQL 또는 Native Query 문자열로 구현 필요
가독성 및 유지보수성 저하
예시 코드 (Hibernate Cursor)
@Bean fun cursorItemReader(entityManagerFactory: EntityManagerFactory): HibernateCursorItemReader<UserEntity> = HibernateCursorItemReaderBuilder<UserEntity>() .name("userCursorReader") .sessionFactory(entityManagerFactory.unwrap(SessionFactory::class.java)) .queryString("select u from UserEntity u where u.status = 'ACTIVE'") .fetchSize(1000) .build()
Kotlin
복사

3.3. Exposed Cursor ItemReader

Exposed 소개
JetBrains 기반 ORM 프레임워크
DSL 방식과 DAO 방식 지원
코틀린 호환성 우수 (자바 사용 불가)
웬만한 RDBMS 지원
Exposed DSL 장점
쿼리를 코드로 구현 (문자열 아님)
코틀린 특성 활용한 세련된 쿼리 구현
배치 인서트 지원
구현
Exposed DSL로 쿼리 구현
JDBC Cursor ItemReader로 동작
성능과 개발 생산성 모두 확보
예시 코드 (Exposed Cursor)
object Users : Table("users") { val id = long("id") val name = varchar("name", 100) val grade = varchar("grade", 20) } fun exposedCursor(chunkSize: Int, lastId: Long?): List<ResultRow> = transaction { Users .select { Users.id greater (lastId ?: 0) } .orderBy(Users.id to SortOrder.ASC) .limit(chunkSize) .toList() }
Kotlin
복사

3.4. Reader 성능 비교

JPA Paging ItemReader는 데이터 증가 시 비선형적 성능 저하
QueryDSL Zero Offset과 Exposed Cursor는 선형 증가로 예측 가능한 성능
대량 처리 시 안정적인 메모리 사용 및 GC 발생 확인

4. 성능 최적화 전략: Write 개선

4.1. Batch Insert 사용

개념
여러 건의 INSERT를 묶어서 한 번에 전송
네트워크 레이턴시 최소화
Chunk Size 단위로 DB IO를 1회로 감소
성능 측정 결과
500만 건 저장:
JPA 단독 저장: 90분
JDBC Batch Insert: 2분 내외
45배 성능 향상
JDBC Batch Insert 구현
fun batchInsert(users: List<UserDto>, chunkSize: Int, dataSource: DataSource) { dataSource.connection.use { conn -> conn.autoCommit = false conn.prepareStatement( "INSERT INTO users(id, name, grade) VALUES (?, ?, ?)" ).use { ps -> users.chunked(chunkSize).forEach { chunk -> chunk.forEach { user -> ps.setLong(1, user.id) ps.setString(2, user.name) ps.setString(3, user.grade) ps.addBatch() } ps.executeBatch() } } conn.commit() } }
Kotlin
복사
유지보수성 문제
SQL 문자열 관리 부담
가독성 저하

4.2. Exposed Batch Insert

장점
쿼리를 코드로 구현 (DSL 방식)
가시적이고 깔끔한 구현
JDBC Batch Insert와 동일한 성능
추천
JDBC Batch Insert가 부담스럽다면 Exposed 사용 권장
코틀린 프로젝트에서 특히 유용
예시 코드 (Exposed Batch Insert)
fun exposedBatchInsert(users: List<UserDto>) { transaction { Users.batchInsert(users) { user -> this[Users.id] = user.id this[Users.name] = user.name this[Users.grade] = user.grade } } }
Kotlin
복사

4.3. 배치 환경에서 JPA가 부적합한 이유

Dirty Checking 및 영속성 관리 불필요
배치는 Read와 Write 구간이 명확히 분리
JPA의 복잡한 영속성 관리 및 Dirty Checking 불필요
스냅샷 비교 후 UPDATE SQL 생성하는 복잡한 체크 로직으로 성능 손실
→ 개선 방안
QueryDSL Projections로 DTO 조회
영속성 관리 및 Dirty Check 회피
성능 개선
불필요한 컬럼 업데이트
JPA는 특정 컬럼만 업데이트하는 것이 아니라 모든 컬럼 업데이트
배치 특성상 원하는 쿼리가 명확한데 다른 쿼리 실행은 리스크
Dynamic Update 기능도 쿼리 동적 생성으로 성능 저하 유발
→ 개선 방안
필요한 컬럼만 명시적으로 업데이트하는 쿼리 작성
JPA 대신 JDBC 또는 Exposed 사용
Batch Insert 지원 제한
ID 생성 전략이 IDENTITY일 경우 Batch Insert 미지원
실무에서 IDENTITY 사용 빈도가 높아 사실상 Batch Insert 불가
Batch Insert는 대량 처리 필수 기능
결론
대량 데이터 Write 시 JPA 포기 권장
JDBC Batch Insert 또는 Exposed Batch Insert 사용

5. 성능 최적화 전략: Network I/O 개선

5.1. Processor 제거 및 Parallel 처리

문제점
Processor는 단건 처리 구조
API 호출 시 응답 대기로 인한 시간 낭비
Chunk Size만큼 순차적 API 호출 반복
해결 방안
Processor 제거
Writer에서 Processor 역할 수행
API 호출을 병렬 처리로 전환
병렬 처리 구현
RxKotlin, Reactor, Coroutine 등 활용
아이템 리스트를 여러 레일(Rail)로 분할
각 레일을 병렬 처리 후 Sequencer로 병합
suspend fun parallelWrite( users: List<UserDto>, httpClient: HttpClient, degree: Int = Dispatchers.IO.limitedParallelism(8).parallelism ) = coroutineScope { users.chunked(degree).map { chunk -> async(Dispatchers.IO) { chunk.map { user -> httpClient.get("<https://api.example.com/orders/${user.id}>") } } }.awaitAll().flatten() }
Kotlin
복사
레일 개수 결정
시스템에 적절한 개수를 자동 설정 (예: Scheduler.io())
DB 커넥션 개수, CPU 리소스 고려
무작정 늘린다고 빨라지지 않음 (레일 분할/병합 리소스 소모)
성능 결과
10개 레일 사용 시 약 10배 성능 개선
JPA 사용 시 권장사항
영속성 컨텍스트의 성능 단점이 큼
Projection 사용하여 Dirty Checking 회피 권장

5.2. Processor 사용 가이드

단순 데이터 변환 또는 애플리케이션 로직만 있다면 사용 가능
IO 작업 포함 시 성능 저하 발생하므로 제거 권장

6. 성능 최적화 전략: DB I/O 최소화

6.1. WHERE IN 기반 업데이트

기존 문제
Chunk Size만큼 개별 UPDATE 쿼리 실행
1000건 처리 시 최대 1000번 DB IO 발생
해결 방안
등급별로 그룹화
UPDATE table SET grade = 'VIP' WHERE id IN (1, 2, 3, ...)
Chunk Size와 무관하게 최대 등급 개수만큼만 DB IO 발생
효과
1000건 기준: 최대 1000번 → 3번 (등급이 3개일 경우)
DB IO 압도적 감소
적용 가능 조건
업데이트 대상을 그룹화할 수 있을 때
모든 레코드에 동일한 값을 설정하는 경우
예시 코드 (등급별 IN 업데이트)
fun updateGradeByGroup( grouped: Map<String, List<Long>>, dataSource: DataSource ) { dataSource.connection.use { conn -> conn.autoCommit = false grouped.forEach { (grade, ids) -> conn.prepareStatement( "UPDATE users SET grade = ? WHERE id IN (${ids.joinToString(",")})" ).use { ps -> ps.setString(1, grade) ps.executeUpdate() } } conn.commit() } }
Kotlin
복사

6.2. JDBC Batch를 통한 DB I/O 최소화

한계 상황
유저마다 다른 값 업데이트 시 WHERE IN 불가
예: 유저별 등급 포인트 점수가 모두 다른 경우
JDBC Batch 동작
여러 건의 UPDATE/INSERT를 묶어서 한 번에 전송
Chunk Size 단위로 DB IO를 단 1회로 감소
JDBC 2.0 Batch 기능 활용
코드 구현
fun batchUpdatePoints( users: List<UserPointDto>, chunkSize: Int, dataSource: DataSource ) { dataSource.connection.use { conn -> conn.autoCommit = false conn.prepareStatement( "UPDATE users SET point = ? WHERE id = ?" ).use { ps -> users.chunked(chunkSize).forEach { chunk -> chunk.forEach { user -> ps.setLong(1, user.point) ps.setLong(2, user.id) ps.addBatch() } ps.executeBatch() } } conn.commit() } }
Kotlin
복사
성능 측정 결과 (순수 UPDATE만)
1만 건: 12분 → 2분
5만 건: 65분 → 10분
10만 건: 120분 → 21분
Exposed를 통한 Batch Update
SQL 문자열 관리 부담 해소
유지보수성 향상
코틀린 프로젝트에서 권장

7. 성능 최적화 전략: Aggregation 개선

7.1. 쿼리 의존적 Aggregation 문제

GROUP BY SUM의 한계
여러 테이블 조인 시 실행 계획 복잡
Temporary Table 생성 및 File Sort 발생
튜닝 난이도 높음
카디널리티 변화 시 실행 계획 변경 위험
인덱스 추가의 부작용
INSERT/UPDATE 성능 저하
저장 용량 증가
Reader 개선 효과 무용지물

7.2. Redis를 활용한 직접 Aggregation

개념
GROUP BY 포기, 직접 Aggregation
1000만 건을 Chunk 단위로 읽어 Redis에 합산
최종 결과(예: 50만 건)를 DB에 저장
Redis 도입 이유
1.
연산 API 지원
HINCRBY, HINCRBYFLOAT 제공
메모리 수준에서 매우 빠른 정수/실수 합산
2.
넉넉한 메모리
50만 건 데이터는 수십~수백 GB Redis 메모리에 충분히 저장 가능
3.
빠른 연산
중간 저장 결과가 디스크 아닌 메모리에 저장
연산 속도 매우 빠름
영구 저장 불필요
아키텍처
1.
1000만 건 데이터를 1000개씩 Chunk 분할 (총 1만 개 Chunk)
2.
각 Chunk마다 Redis SUM 연산 요청
3.
반복 합산 결과 (50만 개)를 최종 저장소에 저장
예시 코드 (Redis 누적)
suspend fun accumulateToRedis( key: String, entries: List<Pair<String, Long>>, redis: StatefulRedisConnection<String, String> ) { val commands = redis.reactive() entries.forEach { (field, delta) -> commands.hincrby(key, field, delta).subscribe() } commands.close() }
Kotlin
복사

7.3. Redis Pipeline을 통한 레이턴시 해결

문제점
1000만 건 합산 시 최소 1000만 번 Redis 연산 요청
최소 1ms 레이턴시 가정 시 1000만 ms = 약 3시간 소요
오히려 성능 저하
Redis Pipeline 해결책
여러 개의 Redis 커맨드를 묶어서 처리
Chunk당 1번씩만 연산 요청
1000만 번 네트워킹 → 1만 번으로 감소
Aggregation 시간 대폭 단축
구현
Spring Data Redis로는 처리 불가
배치 전용 대량 처리 라이브러리 자체 개발 필요
예시 코드 (Lettuce 파이프라인)
suspend fun pipelineIncr( key: String, entries: List<Pair<String, Long>>, redis: StatefulRedisConnection<String, String> ) { redis.async().let { async -> async.setAutoFlushCommands(false) entries.forEach { (field, delta) -> async.hincrby(key, field, delta) } async.flushCommands() } }
Kotlin
복사

8. 성능 최적화 전략: 구동 환경 개선

8.1. 기존 스케줄 툴의 한계

일반적인 스케줄 툴
Cron, Jenkins, Airflow, Luigi 등
실행 요청, 스케줄 관리, 워크플로우 관리, 모니터링 제공
놓치고 있는 부분
1.
자원 관리의 어려움
배치는 특정 시간에만 동작 후 자원 반납
자원 사용 시점 판단 어려움
한 번에 많은 배치 실행 시 자원 관리 어려움
물리 서버에서 OOM 발생 위험
2.
배치 상태 파악의 어려움
배치 과정이 길지만 로그 정보 빈약
서비스 상태를 로그로 판단하는 것은 시각적이지 않음

8.2. Spring Cloud Data Flow (SCDF) 도입

SCDF
데이터 파이프라인 생성 및 오케스트레이션 툴
Task 기능으로 배치 처리 지원
K8s 연동
K8s와 완벽 연동
컨테이너 오케스트레이션처럼 배치 오케스트레이션
리소스 사용 및 반납 조율
다수 배치 동시 실행 시에도 안정적 리소스 컨트롤
Spring Batch 호환성
스프링 배치와 완벽 호환
유용한 정보를 시각적으로 모니터링
자체 대시보드 제공
Grafana 연동 가능
SCDF Task의 구체적 기능
K8s 활용
K8s CronJob으로 스케줄 관리
K8s 요청으로 애플리케이션 실행 및 배포
Pod 상태 모니터링
워크플로우 및 리소스 설정
배치 실행 순서를 그래프 형태로 설정
CPU 개수, 메모리 할당량 등 자원 설정 가능
데이터 많거나 멀티스레드 작업 시 리소스 추가 할당
그래프 기반 워크플로우
여러 배치를 그래프로 묶음
상태에 따라 실행할 배치 결정
예: 실패 시 Task A 실행, 완료 시 Task B 실행
스케줄 등록
Cron 표현식으로 스케줄 설정
Arguments와 Properties로 K8s 설정 및 배치 파라미터 조정
모니터링 대시보드
Step 정보
배치의 모든 Step 표시
전체 수행 시간 확인
Step 상세
읽은 개수, 쓴 개수
커밋 개수, 롤백 개수
수행 시간, 최종 상태
Step 누적 히스토리
전체 소요 시간
읽기/쓰기 횟수
최소, 최대, 평균, 표준편차 제공
Grafana 연동
모니터링 내용 및 리소스 상태를 Grafana에 연동

9. 성능 최적화 단계적 접근 방법론

9.1. 1단계: 직관적이고 예측 가능한 최적화

특징
유지보수 하기 좋은 코드
직관적이고 예측 가능
기존 구조를 크게 변경하지 않음
적용 기법
WHERE IN 기반 업데이트
JDBC Batch Insert/Update
Zero Offset ItemReader
Cursor ItemReader
우선순위
먼저 1단계를 적용하여 성능 개선 시도
대부분의 경우 1단계만으로도 충분한 성능 확보

9.2. 2단계: 고급 최적화 기법

적용 시점
1단계로도 이슈 해결 안 될 경우
더 빠른 성능이 필요한 경우
특징
직관적이지 않음
예상하기 어려운 방식
코드 복잡도 증가
적용 기법
멀티스레드/병렬 처리
Redis를 활용한 직접 Aggregation
복잡한 아키텍처 변경
주의사항
처음부터 고급 기법 사용 시 나중에 더 큰 최적화 필요할 때 어려움
현재 구조를 최대한 유지하면서 최적화할 방법 먼저 검토

9.3. 최적화 핵심 원칙

반복적인 IO 작업 그룹화
성능 저하의 근본 원인은 반복적인 IO 작업
IO 작업을 모아서 처리하는 구조로 변경
병렬 처리 및 벌크 IO 처리 활용
책임과 역할 분리
Reader, Processor, Writer 역할 명확히 분담
개선 지점 파악 용이
단계별 최적화 가능
선형적 성능 증가 확보
데이터 증가에 비례하여 선형적으로 처리 시간 증가
처리 시간 예측 가능
안정적인 운영 가능

10. 결론

10.1. 핵심 요약

1.
Reader 개선: Zero Offset 또는 Cursor 방식으로 선형적 성능 확보
2.
Writer 개선: JPA 포기, Batch Insert/Update로 DB IO 최소화
3.
Network IO 개선: Processor 제거, 병렬 처리 적용
4.
DB IO 개선: WHERE IN 또는 JDBC Batch로 벌크 처리
5.
Aggregation 개선: Redis + Pipeline으로 직접 Aggregation
6.
구동 환경 개선: SCDF + K8s로 리소스 관리 및 모니터링 강화

10.2. 최적화 접근 전략

1.
반복적인 IO 작업을 그룹화하여 최소화
2.
단계적 접근: 직관적 방법 우선, 고급 기법은 필요 시에만
3.
현재 구조를 크게 변경하지 않는 선에서 최적화
4.
데이터 증가에 비례한 선형적 성능 증가 확보

10.3. 범용성

스프링 배치뿐만 아니라 모든 애플리케이션의 대량 처리에 적용 가능
IO 작업 최소화는 성능 최적화의 핵심 원칙

10.4. 지속적 개선

배치 퍼포먼스 모니터링 지속
데이터 증가 추이 파악
병목 지점 발견 시 즉시 개선
APM 툴 또는 SCDF 활용한 지속적 모니터링 체계 구축

11. 참고 자료