본문 바로가기
Framework/Spring Boot

[Spring Boot] Spring Batch 입문

웹 서비스 백엔드 개발자들은 주로 사용자가 스마트폰에서 버튼을 누르면 0.1초 만에 응답(JSON)을 주는 '실시간(Real-time)' 처리에 익숙합니다. 그런데 실무에서는 이런 요구사항이 비일비재하게 떨어집니다. "매월 1일 자정마다, 전체 회원 1,000만 명의 지난 한 달 치 결제 내역을 싹 다 조회해서 합산한 뒤, 50만 원 이상 쓴 사람들의 등급을 VIP로 업데이트하고 쿠폰을 발급해 주세요." 이 작업을 일반적인 스프링 웹 컨트롤러 안에서 for문 1,000만 번을 돌려서 처리하면 어떻게 될까요? 99.9% 확률로 메모리 초과(OOM)로 서버가 죽거나, 중간에 DB 커넥션이 끊겨서 '500만 명까지만 처리되고 멈추는' 끔찍한 대참사가 벌어집니다. 500만 명을 다시 어떻게 롤백할까요? 상상만 해도 끔찍합니다.

 

이 거대하고 위험한 대용량 일괄 처리(Batch Processing)를 아주 안전하고 우아하게, 끊겨도 다시 시작할 수 있게 만들어주는 프레임워크가 바로 Spring Batch(스프링 배치)입니다.

이번에는 "백그라운드 데이터 처리의 지배자!" Spring Batch의 웅장한 아키텍처와 도메인 언어에 대하여 다뤄보겠습니다.

1. Spring Batch의 핵심 개념: Job과 Step

스프링 배치는 거대한 작업을 아주 세밀하게 쪼개서 관리합니다. 이 단어들을 모르면 코드를 단 한 줄도 짤 수 없습니다.

  • Job (작업): "월간 VIP 등급 업데이트 배치"라는 하나의 거대한 전체 프로젝트 단위입니다.
  • Step (단계): Job을 이루는 작은 블록들입니다. 예를 들어 Step 1: 휴면 회원 정리 -> Step 2: 결제액 합산 -> Step 3: VIP 등급 업데이트 처럼 여러 개의 Step을 조립해서 하나의 Job을 완성합니다.
  • JobInstance: "2023년 10월 1일 자정에 실행된 Job"처럼 특정 날짜/시간에 실행된 고유한 Job의 실체입니다. 만약 10월 1일 Job이 중간에 실패하면, 다음날 다시 실행할 때 새로운 Instance가 생기는 게 아니라 어제 실패했던 그 Instance의 실패한 지점부터 기가 막히게 이어서(Resume) 실행합니다! (이것이 배치를 쓰는 가장 큰 이유입니다)

 

2. Reader, Processor, Writer: 완벽한 분업화

각 Step 내부는 다시 3가지 역할로 완벽하게 분업(Chunk 지향 처리)되어 있습니다.

  • ItemReader: DB나 파일(CSV)에서 데이터를 '조금씩' 퍼 올립니다. (1,000만 건을 한 번에 메모리에 올리면 서버가 터지니까, 1,000건씩 쪼개서 가져옵니다.)
  • ItemProcessor: Reader가 퍼 올린 데이터를 '가공'합니다. (예: 결제액을 확인하고 VIP 여부를 true로 변경하 비즈니스 로직) 필수는 아닙니다.
  • ItemWriter: Processor가 예쁘게 가공해 준 데이터를 모아두었다가 한 방에 DB에 UPDATE 쿼리를 쏩니다.

 

3. 아주 심플한 Hello World Batch 코드

Spring Boot 3.x(Batch 5.x) 기준으로, 과거의 지저분했던 JobBuilderFactory가 사라지고 코드가 아주 깔끔해졌습니다.

@Configuration
@RequiredArgsConstructor
public class HelloWorldBatchConfig {

    private final JobRepository jobRepository; // 배치 작업의 성공/실패 기록을 DB에 저장하는 녀석
    private final PlatformTransactionManager transactionManager;

    // 1. 거대한 Job 만들기
    @Bean
    public Job helloJob() {
        return new JobBuilder("helloJob", jobRepository)
                .start(helloStep()) // helloStep부터 시작해!
                .build();
    }

    // 2. Job 안에서 실행될 Step 만들기 (여기서는 Reader/Writer 안 쓰고 단순 Tasklet(작은 일꾼) 사용)
    @Bean
    public Step helloStep() {
        return new StepBuilder("helloStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    System.out.println("===========================");
                    System.out.println("Hello Spring Batch!"); // 실제로는 여기에 무거운 로직이 들어감
                    System.out.println("===========================");
                    return RepeatStatus.FINISHED; // "나 일 다 끝났어!" 라고 보고
                }, transactionManager)
                .build();
    }
}

 

4. 마무리 및 주요 발전 방향

지금까지 실시간 API 처리가 감당할 수 없는 엄청난 무게의 데이터를 분업화된 구조(Step, Reader, Writer)로 쪼개어 안전하게 처리하고, 중간에 실패하더라도 완벽하게 이어서(Resume) 실행할 수 있게 해주는 거대한 인프라, Spring Batch의 핵심 도메인 개념에 대해 상세히 다루어 보았습니다. 이제 대용량 데이터를 다루는 것에 대한 두려움이 사라졌습니다.

그런데 방금 본 예제는 너무 간단한 Tasklet(한 번에 다 처리해 버리는 방식)이었습니다. 실제 1,000만 건의 데이터를 처리할 때 Tasklet을 쓰면 여전히 OOM(Out of Memory) 에러를 피할 수 없습니다. 데이터를 메모리가 터지지 않게 '조금씩 쪼개서(Chunk)' 읽고, 가공하고, DB에 안전하게 밀어 넣는 진짜 실무 배치의 핵심 기술이 필요합니다. 본 설명에서는 "수천만 건을 단 한 번의 메모리 터짐 없이 씹어 넘긴다!" Spring Batch의 꽃, Chunk(청크) 지향 처리 메커니즘과 PagingItemReader를 활용한 성능 최적화 비법에 대해 아주 심도 있게 살펴보겠습니다.


앞선 에서 우리는 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(청크, 덩어리) 지향 처리'입니다.

본 글에서는 "대용량 데이터 처리의 마법이자 배치 성능 튜닝의 핵심!" Chunk 사이즈 최적화와 JpaPagingItemReader의 실무 활용법에 대하여 다뤄보겠습니다.

1. Chunk(청크) 지향 처리의 경이로운 작동 원리

청크(Chunk)는 말 그대로 '데이터 덩어리'입니다. 만약 chunkSize를 1,000으로 설정했다고 가정해 봅시다.

  1. ItemReader: DB에서 데이터를 1,000건만 딱 끊어서 읽어옵니다. (메모리에는 1,000건만 올라가므로 절대 터지지 않습니다!)
  2. ItemProcessor: 읽어온 1,000건을 1건씩 순회하면서 비즈니스 로직(가공)을 처리합니다.
  3. 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초 만에 검색 결과를 띄워줄 순 없을까?"

 

다음에는 "RDB의 LIKE 검색의 성능 한계를 극복하고 포털 사이트급 초고속 풀텍스트(Full-Text) 검색을 선사하는 마법!" Elasticsearch(엘라스틱서치)의 역인덱스(Inverted Index) 원리와 Spring Data Elasticsearch 실무 연동 가이드에 대해 아주 심도 있게 살펴보겠습니다.