앞선 33단계에서 우리는 Spring Batch의 기본 뼈대인 Job과 Step을 배웠습니다. 하지만 실무에서 주어지는 '1,000만 명의 VIP 등급 업데이트' 같은 거대한 과제를 단일 Tasklet으로 처리하려 든다면, List<User> users = userRepository.findAll()을 호출하는 순간 1,000만 개의 자바 객체가 메모리(RAM)에 올라가며 1초 만에 OOM(OutOfMemory) 에러와 함께 서버가 폭발하게 됩니다. 그렇다고 1건씩 DB에서 읽어와서 1건씩 UPDATE 쿼리를 날린다면? 1,000만 번의 네트워크 I/O(통신 비용)가 발생하여 배치가 끝나는 데 일주일이 넘게 걸릴 것입니다. 메모리를 터뜨리지 않으면서도 미친 듯이 빠른 속도를 내는, 이 두 마리 토끼를 잡기 위해 고안된 Spring Batch의 궁극적인 아키텍처가 바로 'Chunk(청크, 덩어리) 지향 처리'입니다.
이번 [Spring Boot 실무 완벽 가이드] 시리즈에서는 "대용량 데이터 처리의 마법이자 배치 성능 튜닝의 핵심!" Chunk 사이즈 최적화와 JpaPagingItemReader의 실무 활용법에 대하여 다뤄보겠습니다.

🧱 1. Chunk(청크) 지향 처리의 경이로운 작동 원리
청크(Chunk)는 말 그대로 '데이터 덩어리'입니다. 만약 chunkSize를 1,000으로 설정했다고 가정해 봅시다.

- ItemReader: DB에서 데이터를 1,000건만 딱 끊어서 읽어옵니다. (메모리에는 1,000건만 올라가므로 절대 터지지 않습니다!)
- ItemProcessor: 읽어온 1,000건을 1건씩 순회하면서 비즈니스 로직(가공)을 처리합니다.
- ItemWriter: 가공이 끝난 데이터가 1,000건이 꽉 찰 때까지 조용히 바구니에 모아둡니다. 1,000건이 다 모이는 순간, DB에 한 방에 트랜잭션을 묶어서
UPDATE(벌크 연산)를 때려버립니다!
이 과정을 데이터가 끝날 때까지 무한 반복합니다. 즉 1,000만 건의 데이터를 1,000건씩 10,000번의 트랜잭션으로 잘게 썰어서 처리하는 기적의 메커니즘입니다.
⚙️ 2. 실전 코드: JpaPagingItemReader와 Chunk 구성
Spring Boot 3.x 환경에서 JPA를 활용하여 10년 된 휴면 회원의 상태를 INACTIVE로 변경하는 배치를 짜보겠습니다.
@Configuration
@RequiredArgsConstructor
public class InactiveUserBatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final EntityManagerFactory entityManagerFactory; // JPA 통신용
private static final int CHUNK_SIZE = 1000; // 🌟 황금 숫자: 한 번에 1,000건씩 처리
@Bean
public Step inactiveUserStep() {
// Chunk의 타입(Input, Output)을 지정합니다. (User 읽어서 User로 뱉음)
return new StepBuilder("inactiveUserStep", jobRepository)
.<User, User>chunk(CHUNK_SIZE, transactionManager)
.reader(inactiveUserReader()) // 1. 읽기
.processor(inactiveUserProcessor()) // 2. 가공
.writer(inactiveUserWriter()) // 3. 쓰기
.build();
}
// 1. Reader: JPA 페이징 기술로 DB에서 1,000건씩 쪼개어 SELECT 해옵니다.
@Bean
public JpaPagingItemReader<User> inactiveUserReader() {
return new JpaPagingItemReaderBuilder<User>()
.name("inactiveUserReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(CHUNK_SIZE) // 🌟 중요: 보통 pageSize와 chunkSize는 동일하게 맞춥니다.
.queryString("SELECT u FROM User u WHERE u.lastLoginDate < :oneYearAgo")
.parameterValues(Collections.singletonMap("oneYearAgo", LocalDateTime.now().minusYears(1)))
.build();
}
// 2. Processor: 읽어온 User를 1건씩 받아서 상태를 INACTIVE로 바꿉니다.
@Bean
public ItemProcessor<User, User> inactiveUserProcessor() {
return user -> {
user.setStatus("INACTIVE"); // 더티 체킹(Dirty Checking) 준비!
return user;
};
}
// 3. Writer: JPA를 쓰면 JpaItemWriter를 씁니다. Processor가 1,000건을 모아주면 한 번에 영속성 컨텍스트에 flush()를 때립니다.
@Bean
public JpaItemWriter<User> inactiveUserWriter() {
return new JpaItemWriterBuilder<User>()
.entityManagerFactory(entityManagerFactory)
.build();
}
}
💀 3. 실무 장애 포인트: PagingReader와 변경 감지의 저주
위 코드를 실무에 배포하면 데이터가 듬성듬성 스킵되어 절반만 처리되는 기괴한 버그가 발생합니다. 왜 그럴까요?
JpaPagingItemReader는 내부적으로 Limit X Offset Y (0번부터 1000개, 1000번부터 1000개...) 방식으로 데이터를 긁어옵니다. 그런데 Processor에서 상태를 INACTIVE로 바꿔버렸죠? 그러면 다음 루프를 돌 때 WHERE 조건(활성 유저 찾기)에 맞는 데이터 모수가 줄어들어 버립니다! 즉, 조회하는 조건 필드 자체를 Processor에서 수정해 버리면 페이징 쿼리의 Offset(페이지 번호)이 꼬이면서 데이터가 누락되는 최악의 버그가 발생합니다.
해결책: 조회 데이터의 변경이 일어나는 배치를 짤 때는 JpaPagingItemReader 대신 JpaCursorItemReader(커서 기반으로 한 줄씩 스트리밍 하듯 훑고 지나감)를 쓰거나, 아예 쿼리를 WHERE status = 'ACTIVE' 없이 다른 불변의 기준(예: ID 범위)으로 잘라서 조회해야만 이 저주를 피할 수 있습니다.
🎯 4. 마무리 및 다음 단계
지금까지 단일 쓰레드 환경에서 메모리 터짐(OOM)을 완벽하게 방어하고 1,000만 건의 거대한 데이터를 pageSize라는 바구니에 담아 벌크로 삽입하는 미친 효율의 Chunk 지향 아키텍처와, PagingReader 사용 시 데이터 누락(Skip)이 발생하는 끔찍한 실무 장애 포인트에 대해 상세히 다루어 보았습니다. 이제 여러분은 밤새 돌아가는 무거운 통계/정산 배치를 안심하고 돌릴 수 있습니다.
대용량 처리까지 마스터했습니다. 그런데 쇼핑몰에 상품이 100만 개가 넘어가니, 사용자가 검색창에 "나이키 검정 운동화"라고 쳤을 때 MySQL의 LIKE '%나이키 검정 운동화%' 쿼리가 무려 5초나 걸리기 시작합니다. RDB(관계형 데이터베이스)는 테이블을 처음부터 끝까지 다 뒤지는(Full Scan) '문장 검색'에 태생적으로 극도로 취약하기 때문입니다. "책의 맨 뒤에 있는 '찾아보기(색인)' 페이지처럼, 단어를 쪼개서 미리 정리해 두고 0.01초 만에 검색 결과를 띄워줄 순 없을까?" 이어지는 35단계 포스팅에서는 "RDB의 LIKE 검색 한계를 부수고 포털 사이트급 초고속 풀텍스트(Full-Text) 검색을 선사하는 마법!" Elasticsearch(엘라스틱서치)의 역인덱스(Inverted Index) 원리와 Spring Data Elasticsearch 실무 연동 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] ELK 스택 (Elasticsearch, Logstash, Kibana): 중앙 집중형 로그 파이프라인 (0) | 2026.08.05 |
|---|---|
| [Spring Boot] Elasticsearch 연동: RDB의 LIKE 검색 한계를 부수다 (0) | 2026.08.05 |
| [Spring Boot] Spring Batch 입문: 1,000만 건의 데이터를 씹어 삼키다 (0) | 2026.08.05 |
| [Spring Boot] STOMP와 RabbitMQ: 엔터프라이즈급 실시간 채팅의 완성 (0) | 2026.08.05 |
| [Spring Boot] WebSocket 실시간 채팅: 양방향 통신의 서막 (0) | 2026.08.05 |