본문 바로가기
WEB/SpringBoot

Spring Batch와 스케줄링

by 정권이 내 2026. 2. 23.

오늘은 Spring Batch와 스케줄링(Scheduling)에 대해 이야기해 보려고 합니다.

🧐 Spring Batch와 스케줄링, 왜 쓸까?

백엔드 서버는 보통 사용자의 요청(Request)이 들어오면 그에 맞는 응답(Response)을 즉각적으로 반환합니다.

하지만 실무에서는 실시간으로 처리하기 어렵거나, 굳이 실시간으로 처리할 필요가 없는 거대한 작업들이 반드시 존재합니다.

💡 실무에서 배치가 필요한 아찔한 순간들

  • 매일 밤 12시에 이루어지는 일일 정산: 수백만 건의 결제 데이터를 모아서 통계를 내고 정산 금액을 계산해야 합니다.
  • 1년 이상 접속하지 않은 휴면 회원 전환: 새벽 3시에 수천만 명의 유저 테이블을 뒤져서 휴면 상태로 업데이트하고 메일을 발송해야 합니다.
  • 월말 급여 명세서 이메일 대량 발송: 특정 시간에 맞춰 전 직원에게 PDF를 생성해 이메일로 쏴야 합니다.

이런 작업들을 사용자가 API를 호출할 때마다 동기적으로 처리한다고 상상해 보세요. 서버는 응답 지연(Timeout)을 뱉어내고, 사용자는 떠나가며, DB는 과부하로 쓰러질 것입니다.

따라서 우리는 "특정 시간(스케줄링)에, 대용량 데이터를, 안전하고 효율적으로 나누어 처리(배치)"하는 기술이 필요합니다.

이것이 바로 Spring Batch와 스케줄링이 우리를 구원해 주는 지점입니다.

💥 실무에서 흔히 겪는 대참사 Best 3

Spring Batch를 배우기 전, 많은 개발자들이 단순 스케줄러만 믿고 시스템을 구축했다가 실수를 겪곤 합니다.

1️⃣ @Scheduled만 믿고 100만 건 조회하기 (OOM 대참사)

가장 흔한 실수입니다. Spring이 제공하는 @Scheduled 어노테이션을 달아두고, List<User> users = userRepository.findAll(); 처럼 데이터를 한 번에 메모리에 퍼 올립니다. 데이터가 1만 건일 때는 잘 돌던 코드가, 서비스가 성장해 100만 건이 되는 순간 OutOfMemoryError (OOM)를 뿜어내며 서버가 장렬히 전사합니다.

2️⃣ 중간에 실패하면 처음부터 다시? (복구 불가의 늪)

100만 건의 데이터를 루프 돌리며 처리하던 중, 99만 번째 데이터에서 네트워크 오류로 예외(Exception)가 발생해 로직이 뻗어버렸습니다. 단순 스케줄러로 짰다면? 어디서부터 실패했는지 알 길이 없어서 내일 다시 1번부터 100만 번까지 돌려야 합니다. 이미 처리된 99만 건이 중복 처리되는 끔찍한 데이터 오염이 발생할 수도 있죠.

3️⃣ 트랜잭션의 딜레마 (하나만 실패해도 전체 롤백?)

전체 데이터를 하나의 트랜잭션으로 묶어버리면, 단 한 건의 데이터에 에러가 나도 수십만 건의 업데이트가 전부 롤백(Rollback)됩니다. 그렇다고 트랜잭션을 걸지 않으면 데이터 정합성이 깨집니다. 이 미세한 컨트롤을 직접 구현하기란 여간 까다로운 일이 아닙니다.

이러한 시행착오를 완벽하게 해결하기 위해 탄생한 표준 프레임워크가 바로 'Spring Batch'입니다.

🚀 1. Spring Batch의 핵심 개념

이제 본격적으로 Spring Batch의 세계로 들어가 보겠습니다.

Spring Batch는 대용량 처리에 특화된 강력한 기능(Chunk 처리, 트랜잭션 관리, 실패 후 재시작 등)을 제공합니다.

img

🧱 Spring Batch의 메타 모델

Spring Batch의 구조는 공장의 컨베이어 벨트와 같습니다.

  • Job: 배치 작업의 전체 단위입니다. (예: '휴면 회원 전환 배치')
  • Step: Job을 구성하는 독립적인 하나의 단계입니다. 하나의 Job은 여러 개의 Step을 가질 수 있습니다.
  • ItemReader: 데이터를 읽어오는 역할 (DB, 파일 등에서 데이터를 가져옴)
  • ItemProcessor: 읽어온 데이터를 가공/처리하는 역할 (예: 일반 회원 -> 휴면 회원 객체로 변환)
  • ItemWriter: 가공된 데이터를 저장하는 역할 (DB에 Update 하거나 파일로 쓰기)

📦 가장 중요한 개념: Chunk 지향 처리 (Chunk-oriented Processing)

img

Spring Batch가 대용량 데이터를 메모리 낭비 없이 처리하는 비결입니다. 데이터를 한 번에 전부 읽는 것이 아니라, 설정한 'Chunk Size' 만큼만 끊어서 읽고, 처리하고, 기록하는 방식입니다.

예를 들어 총 10만 건의 데이터가 있고 Chunk Size가 1000이라면

  1. ItemReader가 데이터를 1건씩 읽어옵니다.
  2. ItemProcessor가 데이터를 1건씩 가공합니다.
  3. 가공된 데이터가 1000건(Chunk Size)이 모이면, ItemWriter한 번에 DB에 저장(Write)하고 트랜잭션을 커밋(Commit)합니다.
  4. 이 과정을 100번 반복합니다.

이렇게 하면 메모리에는 최대 1000건의 데이터만 올라가므로 OOM을 완벽히 방지할 수 있고, 중간에 실패하더라도 커밋된 Chunk 단위까지는 데이터가 안전하게 보존됩니다!

📖 2. 대용량 데이터를 가져오는 방법: 페이징(Paging) vs 커서(Cursor)

여기서 아주 중요한 의문이 생깁니다. "Chunk 사이즈가 1000건인 건 알겠는데, 그럼 처음에 DB에서 100만 건을 한 번에 조회해서 1000건씩 나누는 건가요?"

절대 아닙니다! 만약 그렇게 한다면 이미 데이터를 조회하는 순간 메모리가 터져버리겠죠. Spring Batch의 ItemReader는 서버의 메모리를 지키기 위해 DB에서 데이터를 가져올 때 크게 두 가지 방식을 사용합니다.

img

📝 페이징 방식 (Paging Reader)

JPA를 사용할 때 가장 대중적으로 쓰이는 방식입니다(예: JpaPagingItemReader). DB에 쿼리를 날릴 때 Chunk Size(또는 Page Size)만큼만 잘라서 여러 번 나누어 가져옵니다. 즉, 내부적으로 LIMITOFFSET을 사용하여 쿼리를 쪼개는 방식입니다.

  • 1번째 쿼리: SELECT * FROM users WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 0
  • 2번째 쿼리: SELECT * FROM users WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 1000
  • ... 계속 반복

작은 바가지로 물을 여러 번 퍼 나르는 것과 같습니다. 애플리케이션 메모리에는 항상 딱 1000건만 올라오기 때문에 아주 안전합니다.

단, 데이터가 수백만 건을 넘어가면 뒤로 갈수록 OFFSET 값이 커지면서 DB 조회 성능이 급격히 저하되는 이슈가 발생할 수 있습니다. 이때는 인덱스를 활용한 No-Offset 튜닝 기법을 적용하여 성능을 극대화하는 것이 팁입니다.

🚰 커서 방식 (Cursor Reader)

JDBC 기반(JdbcCursorItemReader) 등에서 자주 쓰이는 방식입니다. 이 방식은 DB에 100만 건을 조회하는 쿼리를 딱 1번만 날립니다.

"1번만 날리면 메모리 터지는 거 아닌가요?" 아닙니다! 쿼리는 1번 날리지만, 그 결과를 메모리에 한 번에 쏟아붓지 않습니다.

DB와 애플리케이션 사이에 데이터 통로(Cursor)를 열어두고, DB가 데이터를 가지고 있는 상태에서 애플리케이션이 ResultSet.next()를 호출해 스트리밍하듯 한 건씩(또는 Fetch Size만큼씩) 찔끔찔끔 가져오는 방식입니다.

호스를 연결해두고 필요한 만큼만 물을 틀어서 쓰는 것과 같죠. 성능이 뛰어나지만, 데이터를 모두 읽을 때까지 DB와의 Connection이 오랫동안 유지되어야 한다는 단점이 있어, 트랜잭션 관리와 TimeOut 설정에 각별히 주의해야 합니다.

결론적으로 어떤 방식을 쓰든, "우리 백엔드 서버의 메모리에는 한 번에 Chunk Size만큼의 데이터만 존재하도록 철저하게 통제한다"는 본래의 목적을 완벽하게 달성하게 됩니다.

👑 실무에서 '페이징'을 쓰는 이유와 'No-Offset' 개념

실무에서 특히 JPA 기반의 환경에서는 '페이징(Paging) 방식'을 선호합니다. 그 이유는 명확합니다.

  1. DB 커넥션(Connection) 고갈 방지: 커서 방식은 수십 분~수 시간 동안 도는 배치 작업 내내 DB 커넥션을 물고 있어야 합니다. 중간에 ItemProcessor에서 무거운 연산이나 외부 API 호출이라도 섞여 있으면, 커넥션 타임아웃이 발생하거나 다른 서비스의 DB 연결까지 방해하게 됩니다. 반면 페이징 방식은 1000건을 읽어올 때만 잠깐 쿼리를 날리고 커넥션을 바로 반납(Stateless)하므로 시스템이 훨씬 안정적입니다.
  2. JPA 영속성 컨텍스트 관리의 용이성: 페이징은 Chunk 단위로 깔끔하게 읽고, 처리하고, DB에 반영(Flush/Clear)하기 때문에 메모리 누수 없이 JPA 영속성 컨텍스트를 다루기에 아주 찰떡궁합입니다.

🚨 하지만 일반 페이징은 실무 대용량 데이터에서 시한폭탄입니다! 데이터가 1000만 건인데 단순 OFFSET 기반 페이징을 쓴다면 어떻게 될까요?

  • SELECT * FROM users LIMIT 1000 OFFSET 9000000; 이 쿼리는 앞의 900만 건을 다 스캔한 뒤에 1000건을 가져오기 때문에 뒤로 갈수록 속도가 기하급수적으로 느려져 DB가 뻗어버립니다.

💡 실무 해결책: No-Offset 튜닝 기법 실무 대용량 배치에서는 OFFSET 대신, 인덱스(Index)를 타는 조건문을 활용해 다음 페이지를 바로 찾아가는 No-Offset 기법을 거의 표준처럼 사용합니다. 특히 동적 쿼리 작성이 용이한 QueryDSL과 결합하여 Reader를 커스텀하는 경우가 많습니다.

  • 일반 페이징 (느림): "앞에서부터 90만 번째 데이터까지 세어보고, 거기서부터 1000개 가져와!"
  • No-Offset 페이징 (빠름): "아까 마지막으로 처리한 유저 PK(ID)가 900,000이었지? 이번엔 WHERE id > 900000 LIMIT 1000 개 가져와!" (인덱스를 타서 0.01초 만에 조회 완료)

💻 2. 코드로 보는 Spring Batch 구현 예제

간단한 '휴면 회원 전환 Job'을 코드로 만들어보겠습니다. (Spring Boot 3.x, Spring Batch 5.x 기준)

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.database.JpaItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

import jakarta.persistence.EntityManagerFactory;

@Configuration
public class DormantUserBatchConfig {

    private final int CHUNK_SIZE = 1000;

    // 1. Job 설정: 전체 배치 작업의 뼈대
    @Bean
    public Job dormantUserJob(JobRepository jobRepository, Step dormantUserStep) {
        return new JobBuilder("dormantUserJob", jobRepository)
                .start(dormantUserStep)
                .build();
    }

    // 2. Step 설정: 읽기 -> 가공 -> 쓰기의 흐름 정의
    @Bean
    public Step dormantUserStep(JobRepository jobRepository, 
                                PlatformTransactionManager transactionManager,
                                EntityManagerFactory entityManagerFactory) {
        return new StepBuilder("dormantUserStep", jobRepository)
                .<User, User>chunk(CHUNK_SIZE, transactionManager) // Chunk 단위 지정
                .reader(userItemReader(entityManagerFactory))      // 읽기
                .processor(userItemProcessor())                    // 가공
                .writer(userItemWriter(entityManagerFactory))      // 쓰기
                .build();
    }

    // 3. ItemReader: 마지막 접속일이 1년이 지난 유저를 1000건씩 Paging 조회
    @Bean
    public JpaPagingItemReader<User> userItemReader(EntityManagerFactory entityManagerFactory) {
        return new JpaPagingItemReaderBuilder<User>()
                .name("userItemReader")
                .entityManagerFactory(entityManagerFactory)
                .pageSize(CHUNK_SIZE)
                .queryString("SELECT u FROM User u WHERE u.lastLoginDate < :oneYearAgo AND u.status = 'ACTIVE' ORDER BY u.id ASC")
                // 실제 파라미터 바인딩은 생략 (예제용)
                .build();
    }

    // 4. ItemProcessor: 유저의 상태를 '휴면(DORMANT)'으로 변경
    @Bean
    public ItemProcessor<User, User> userItemProcessor() {
        return user -> {
            user.setDormantStatus(); // 객체 상태 변경
            return user; 
        };
    }

    // 5. ItemWriter: 변경된 영속성 객체를 DB에 Flush (Chunk 사이즈만큼 모아서 한 번에 Update)
    @Bean
    public JpaItemWriter<User> userItemWriter(EntityManagerFactory entityManagerFactory) {
        JpaItemWriter<User> writer = new JpaItemWriter<>();
        writer.setEntityManagerFactory(entityManagerFactory);
        return writer;
    }
}

🔍 코드 핵심 포인트:

  • JpaPagingItemReader: 대용량 데이터를 페이징 처리하여 메모리 부하 없이 읽어옵니다. Batch에서는 필수적인 Reader입니다.
  • chunk(CHUNK_SIZE, transactionManager): 여기서 지정한 수만큼 데이터가 쌓일 때까지 기다렸다가 트랜잭션을 한 번에 Commit 합니다.

⏰ 3. 스케줄링 (Scheduling) 연동하기

Job을 만들었으니, 이제 "언제" 실행할지 정해줘야겠죠? Spring의 @Scheduled를 활용하여 매일 새벽 3시에 이 배치를 실행하도록 스케줄러를 달아보겠습니다.

import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;

@Component
public class BatchScheduler {

    private final JobLauncher jobLauncher;
    private final Job dormantUserJob;

    public BatchScheduler(JobLauncher jobLauncher, Job dormantUserJob) {
        this.jobLauncher = jobLauncher;
        this.dormantUserJob = dormantUserJob;
    }

    // 매일 새벽 3시에 실행 (Cron 표현식 사용)
    @Scheduled(cron = "0 0 3 * * *")
    public void runDormantUserBatch() throws Exception {
        // JobParameters를 통해 배치 실행 시각을 파라미터로 넘겨줌 (중복 실행 방지 및 이력 관리)
        JobParameters jobParameters = new JobParametersBuilder()
                .addString("datetime", LocalDateTime.now().toString())
                .toJobParameters();

        // Job 실행!
        jobLauncher.run(dormantUserJob, jobParameters);

        System.out.println("휴면 회원 전환 배치 실행 완료: " + LocalDateTime.now());
    }
}

⚠️ 스케줄링 실무 팁:

  • 단일 서버 환경이라면 @Scheduled로 충분하지만, 서버가 여러 대(Scale-out)로 구성된 환경에서는 이대로 쓰면 여러 대의 서버가 동시에 배치를 실행하므로 데이터 중복 현상이 발생할수 있습니다.
  • 다중 서버 환경에서는 Jenkins, Quartz, 혹은 Spring Cloud Data Flow 같은 외부 스케줄링 툴이나 분산 락(Distributed Lock)을 도입하여 단일 실행을 보장해야 합니다.

🏁 오늘 내용의 '3줄 요약'

Spring Batch와 스케줄링의 여정을 3줄로 핵심만 요약해 드리겠습니다!

  1. 대용량 데이터를 다룰 때는 for문과 단순 스케줄러를 버리고, 안전한 트랜잭션과 메모리 관리를 제공하는 Spring Batch를 사용하자.
  2. Spring Batch의 핵심은 Chunk 지향 처리이며, Reader -> Processor -> Writer 구조를 통해 데이터를 지정한 단위(Chunk)로 끊어서 효율적으로 처리한다.
  3. 작성한 배치 Job은 Spring의 @Scheduled나 외부 스케줄러(Jenkins, Quartz 등)를 통해 원하는 시간에 자동화하여 실행할 수 있다.
반응형

댓글