성능 최적화 4편 - Spring Batch 청크 지향 처리로 대량 데이터 안전하게 다루기
지금까지 다룬 Fetch Join, @BatchSize, Redis 캐싱은 모두 조회 성능을 개선하는 방법이었습니다. 이번 글에서는 조회가 아니라, 대량의 데이터를 한 번에 읽고 가공하고 저장해야 하는 배치성 작업을 안정적으로 처리하는 방법인 Spring Batch의 청크(Chunk) 지향 처리를 정리합니다.
1. 왜 그냥 for문으로 돌리면 안 될까
수만 건의 데이터를 처리해야 할 때, 단순하게 전체를 조회해서 for문으로 하나씩 처리하면 다음과 같은 문제가 생깁니다.
List<Order> orders = orderRepository.findAll(); // 데이터가 많으면 메모리 부담
for (Order order : orders) {
order.markAsExported();
orderRepository.save(order);
}
- 전체 데이터를 한 번에 메모리에 올려서 OOM(OutOfMemory) 위험이 있습니다.
- 트랜잭션이 하나로 묶여 있어서, 중간에 실패하면 처음부터 다시 실행해야 합니다.
- 실패한 지점을 알 수 없어 재시작이나 재처리가 어렵습니다.
Spring Batch의 청크 지향 처리는 이 문제를 일정 단위(청크)로 나눠서 읽고, 처리하고, 저장하고, 커밋하는 방식으로 해결합니다.
2. 청크 지향 처리 흐름
Reader가 1건씩 읽음 -> 청크 사이즈(예: 100건)만큼 쌓임
|
Processor가 1건씩 가공 -> 100건 가공 완료
|
Writer가 100건을 한 번에 저장
|
트랜잭션 커밋
|
(다음 100건으로 반복, 전체 데이터가 끝날 때까지)10,000건의 데이터를 청크 사이즈 100으로 처리하면, 전체가 하나의 트랜잭션이 아니라 100번의 트랜잭션으로 나뉘어 처리됩니다. 그래서 중간에 실패해도 실패한 청크부터 재시작할 수 있습니다.
3. 의존성 및 Job 설정
spring:
batch:
job:
enabled: false # 애플리케이션 실행 시 배치가 자동 실행되지 않도록 설정
jdbc:
initialize-schema: always
@Configuration
public class OrderExportJobConfig {
@Bean
public Job orderExportJob(JobRepository jobRepository, Step orderExportStep) {
return new JobBuilder("orderExportJob", jobRepository)
.start(orderExportStep)
.build();
}
}
4. Step 설정 - 청크 사이즈 지정
@Bean
public Step orderExportStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ItemReader<Order> orderReader,
ItemProcessor<Order, OrderExportDto> orderProcessor,
ItemWriter<OrderExportDto> orderWriter) {
return new StepBuilder("orderExportStep", jobRepository)
.<Order, OrderExportDto>chunk(100, transactionManager)
.reader(orderReader)
.processor(orderProcessor)
.writer(orderWriter)
.faultTolerant()
.retryLimit(3)
.retry(DataAccessException.class)
.build();
}
chunk(100, transactionManager)가 청크 사이즈를 지정하는 핵심 부분입니다. faultTolerant()와 retry()를 함께 설정해두면, 일시적인 DB 오류가 발생했을 때 해당 청크만 재시도합니다.
5. Reader - 페이징 기반으로 조회
@Bean
@StepScope
public JpaPagingItemReader<Order> orderReader(EntityManagerFactory emf) {
return new JpaPagingItemReaderBuilder<Order>()
.name("orderReader")
.entityManagerFactory(emf)
.queryString("select o from Order o where o.status = 'PENDING' order by o.id")
.pageSize(100)
.build();
}
JpaPagingItemReader는 전체 데이터를 한 번에 조회하지 않고, pageSize 단위로 나눠서 조회합니다. 그래서 데이터가 아무리 많아도 메모리 부담 없이 처리할 수 있습니다.
6. Processor - 건별 가공
@Bean
public ItemProcessor<Order, OrderExportDto> orderProcessor() {
return order -> {
if (order.getTotalPrice() <= 0) {
return null; // null을 반환하면 해당 건은 Writer로 넘어가지 않고 제외됨
}
return new OrderExportDto(order.getId(), order.getTotalPrice(), order.getCreatedAt());
};
}
Processor에서 null을 반환하면 해당 아이템은 필터링되어 Writer로 전달되지 않습니다. 유효하지 않은 데이터를 걸러내는 용도로 유용합니다.
7. Writer - 청크 단위로 일괄 저장
@Bean
public ItemWriter<OrderExportDto> orderWriter(JdbcTemplate jdbcTemplate) {
return chunk -> {
String sql = "update orders set exported = true where id = ?";
List<Object[]> batchArgs = chunk.getItems().stream()
.map(dto -> new Object[]{dto.getId()})
.toList();
jdbcTemplate.batchUpdate(sql, batchArgs);
};
}
Writer는 건별로 호출되지 않고, 청크 사이즈만큼 모인 데이터를 한 번에 받아서 처리합니다. batchUpdate를 사용하면 100건을 한 번의 쿼리 왕복으로 처리할 수 있어 훨씬 효율적입니다.
8. 청크 사이즈는 어떻게 정할까
- 너무 작으면(예: 10) 트랜잭션 커밋이 자주 발생해 오버헤드가 커집니다.
- 너무 크면(예: 5000) 한 번에 메모리에 올라가는 데이터가 많아지고, 실패 시 롤백되는 범위도 커집니다.
보통 100~1000 사이에서 시작해서, 실제 데이터 처리 속도와 서버 리소스를 보면서 조정하는 것이 일반적입니다. 이 부분은 앞선 글에서 다룬 @BatchSize의 사이즈 설정과 사고방식이 동일합니다.
9. @BatchSize와의 차이 정리
| 구분 | @BatchSize | Spring Batch 청크 |
|---|---|---|
| 목적 | 지연 로딩 시 조회 쿼리를 IN 절로 묶기 | 대량 데이터를 읽고 가공하고 저장 |
| 대상 | SELECT 조회 | 읽기 + 처리 + 쓰기 전체 |
| 트랜잭션 | 조회와 무관 | 청크 단위로 커밋 |
| 재시작 | 해당 없음 | 실패 지점부터 재시작 가능 |
10. 정리
- 대량 데이터를 다룰 때는 전체를 한 번에 메모리에 올리지 않고, 청크 단위로 나눠서 읽고 처리하고 저장한다
- 청크 단위로 트랜잭션이 커밋되기 때문에, 실패해도 처음부터 다시 실행할 필요 없이 실패 지점부터 재시작할 수 있다
- 청크 사이즈는 데이터 특성과 서버 리소스를 고려해 100~1000 사이에서 조정하는 것이 일반적이다
지금까지 조회 성능(Fetch Join, @BatchSize, Redis 캐싱)과 처리 성능(Spring Batch)까지 정리하면서, 서비스의 성능은 단일 기법 하나가 아니라 데이터의 조회, 가공, 저장이라는 각 단계에서 적절한 전략을 조합할 때 개선된다는 것을 다시 한번 확인할 수 있었습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 6 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기 (0) | 2026.07.06 |
|---|---|
| 성능 최적화 STEP 5 - 인덱스 설계와 튜닝으로 쿼리 속도 개선하기 (0) | 2026.07.06 |
| 성능 최적화 STEP 3 - Redis를 활용한 캐싱 전략 (0) | 2026.07.03 |
| 성능 최적화 STEP 2 - N+1 문제 해결 2편 - @BatchSize와 default_batch_fetch_size로 컬렉션 조회 최적화하기 (0) | 2026.07.02 |
| 성능 최적화 STEP 1 - JPA Fetch Join으로 쿼리 최적화하기 (0) | 2026.07.02 |