Spring batch

SIHA·2026년 5월 17일

Spring Batch의 개념

왜 Spring Batch를 쓰는가?

매일 새벽 수백만 건의 결제 정산, 대량 이메일 발송, 로그 집계처럼 정해진 시간에 대량의 데이터를 처리해야 할 때 REST API로는 감당이 안 된다. 이런 상황을 위한 게 Spring Batch다.

Spring Batch는 대용량 데이터를 일괄 처리(Batch Processing) 하기 위한 Spring 기반의 경량 프레임워크다.


핵심 아키텍처

Job
└── Step
      ├── chunk 1  →  Read → Process → Write → 커밋
      ├── chunk 2  →  Read → Process → Write → 커밋
      └── chunk 3  →  Read → Process → Write → 커밋

주요 구성 요소

구성 요소역할
Job배치 작업의 최상위 단위
StepJob을 구성하는 독립적인 단계
ItemReaderDB, 파일, API 등에서 데이터를 읽음
ItemProcessor읽은 데이터를 변환/필터링
ItemWriter처리된 데이터를 저장/출력
JobRepositoryJob/Step 실행 정보를 DB에 저장
JobLauncherJob을 실행시키는 인터페이스

Step의 두 가지 방식: Chunk vs Tasklet

Spring Batch의 Step은 두 가지 방식 중 하나를 선택한다.

Chunk 방식

대용량 데이터 처리에 사용한다. chunk-size만큼 읽고, 처리하고, 한 번에 저장하는 방식이다.

내부 동작을 정확히 보면:

  • read() 가 chunk-size만큼 반복 호출
  • process() 도 그만큼 반복 호출
  • write() 가 처리된 데이터를 List로 한 번에 받는 구조
read()   → process()
read()   → process()
read()   → process()
→ write(List [1, 2, 3]) → 커밋 ✅

트랜잭션은 chunk 단위로 걸린다.

Step 시작
  chunk 1 → read x3 → process x3 → write(List) → 커밋 ✅
  chunk 2 → read x3 → process x3 → write(List) → 커밋 ✅
  chunk 3 → read x3 → process x3 → write(List) → 💥 실패
  → chunk 3만 롤백! chunk 1, 2는 이미 커밋됐으니 안전 ✅
Step 종료

Tasklet 방식

파일 삭제, 알림 발송처럼 단순하고 가벼운 작업에 사용한다.
트랜잭션이 Step 전체에 걸린다.

@Bean
public Step cleanStep() {
    return stepBuilderFactory.get("cleanStep")
        .tasklet((contribution, chunkContext) -> {
            tempFileService.deleteAll();
            return RepeatStatus.FINISHED;
        })
        .build();
}

Tasklet으로 대용량 데이터를 처리하면 위험하다.

30만 건 처리 중...
29만 9999건 완료
마지막 1건 💥 실패
→ 30만 건 전부 롤백 ❌
→ 처음부터 다시 30만 건

언제 뭘 쓰나?

상황방식
대량 데이터 읽고 → 저장Chunk
파일 삭제, 알림 발송, 초기화Tasklet
API 한 번 호출하고 끝Tasklet
DB 100만 건 마이그레이션Chunk

한 Job 안에서 Tasklet과 Chunk를 자유롭게 조합할 수도 있다.

@Bean
public Job myJob() {
    return jobBuilderFactory.get("myJob")
        .start(cleanStep())       // Tasklet: 임시 파일 삭제
        .next(processStep())      // Chunk: 대량 데이터 처리
        .next(notifyStep())       // Tasklet: 완료 알림 발송
        .build();
}

JobRepository가 DB에 저장하는 이유

JobRepository는 Job/Step 실행 정보를 DB에 저장한다. 이유는 배치 작업은 실패했을 때 어떻게 복구하느냐가 가장 중요하기 때문이다.

1. 재시작(Restart) 지원

Spring Batch는 실패한 Job을 중단 지점부터 재시작할 수 있다.
단, Reader가 ExecutionContext에 상태를 저장해야 중간부터 재시작이 가능하다.

100만 건 처리 중...
70만 건 완료 ✅ (ExecutionContext에 진행 상태 저장)
30만 건 처리 중 → 💥 서버 다운

→ Restart 시 70만 1건부터 재시작 가능 ✅ (상태 저장된 경우)
→ Stateless Reader라면 처음부터 다시 시작 ❌

JpaPagingItemReader 같은 Spring 제공 Reader는 기본적으로 상태 저장이 되지만,
커스텀 Reader를 직접 구현하는 경우에는 ItemStreamReader를 구현해서 상태 저장 로직을 직접 작성해야 한다.

2. 중복 실행 방지

Spring Batch는 JobName + JobParameters 조합으로 JobInstance를 결정한다.
같은 파라미터로 실행하면 상태와 관계없이 실행 자체가 불가능하다.

// 매번 다른 파라미터를 넣어야 새로운 JobInstance로 인식
jobLauncher.run(settlementJob, new JobParametersBuilder()
    .addLong("time", System.currentTimeMillis()) // 이게 없으면 두 번째 실행 불가
    .toJobParameters());

실무에서 addLong("time", ...) 으로 타임스탬프를 넣는 이유가 바로 이것이다.

3. 실행 이력 모니터링

실행일시          | Job명          | 상태      | 처리건수
2026-05-17 02:00 | settlementJob  | COMPLETED | 980,000건
2026-05-16 02:00 | settlementJob  | FAILED    | 450,000건

Spring Batch는 자동으로 아래 메타 테이블들을 생성한다.

테이블저장 내용
BATCH_JOB_INSTANCEJobName + JobParameters 조합 (논리적 실행 단위)
BATCH_JOB_EXECUTIONJob의 실제 실행 기록 (시작/종료시간, 상태)
BATCH_STEP_EXECUTIONStep별 실행 기록 (읽은 건수, 처리 건수, 실패 건수)
BATCH_JOB_EXECUTION_PARAMSJob 실행 시 넘긴 파라미터

내결함성: Skip, Retry, Restart

.<ViewLog, AuthorSettlement>chunk(1000)
.reader(reader())
.processor(processor())
.writer(writer())
.faultTolerant()
    .skip(DataAccessException.class) // 이 예외는 skip
    .skipLimit(10)                   // 최대 10건까지 skip 허용
    .retry(DeadlockLoserDataAccessException.class) // 이건 재시도
    .retryLimit(3)
.build();
  • Skip: 특정 예외 발생 시 해당 건 건너뜀
  • Retry: 일시적 오류 시 재시도
  • Restart: 실패한 Job을 중단 지점부터 재시작 (Reader 상태 저장 필요)

Skip/Retry/Restart는 "다시 실행할 수 있게 해주는 것" 이고,
트랜잭션 롤백은 "데이터를 어디까지 되돌리냐" 의 문제다. 레이어가 다르다.


실제 구현 예시

웹툰 플랫폼의 작가 정산 배치를 예시로 보자.

시나리오: 매일 새벽 2시, 전날 열람 데이터를 집계해서 작가 정산 테이블에 저장

[서비스 운영 중 - 낮]
사용자가 웹툰 클릭 → 결제 완료 → ViewLog 테이블에 자동 저장

[새벽 2시 배치]
ViewLog에서 전날 데이터 읽기 (Reader)
    ↓
수수료 30% 제외하고 작가 수익 계산 (Processor)
    ↓
AuthorSettlement 테이블에 저장 (Writer)
@Configuration
@RequiredArgsConstructor
public class SettlementJobConfig {

    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;
    private final EntityManagerFactory entityManagerFactory;

    // Job
    @Bean
    public Job settlementJob() {
        return jobBuilderFactory.get("settlementJob")
            .start(settlementStep())
            .build();
    }

    // Step
    @Bean
    public Step settlementStep() {
        return stepBuilderFactory.get("settlementStep")
            .<ViewLog, AuthorSettlement>chunk(1000)
            .reader(viewLogReader())
            .processor(settlementProcessor())
            .writer(settlementWriter())
            .build();
    }

    // Reader: 전날 열람 로그 읽기
    @Bean
    public JpaPagingItemReader<ViewLog> viewLogReader() {
        return new JpaPagingItemReaderBuilder<ViewLog>()
            .name("viewLogReader")
            .entityManagerFactory(entityManagerFactory)
            .pageSize(1000)
            .queryString("SELECT v FROM ViewLog v WHERE v.createdAt >= :yesterday")
            .parameterValues(Map.of("yesterday", LocalDate.now().minusDays(1)))
            .build();
    }

    // Processor: 수수료 계산
    @Bean
    public ItemProcessor<ViewLog, AuthorSettlement> settlementProcessor() {
        return viewLog -> {
            int authorRevenue = (int) (viewLog.getPrice() * 0.7); // 30% 수수료
            return new AuthorSettlement(viewLog.getWebtoonId(), authorRevenue);
        };
    }

    // Writer: 정산 테이블에 저장
    @Bean
    public JpaItemWriter<AuthorSettlement> settlementWriter() {
        return new JpaItemWriterBuilder<AuthorSettlement>()
            .entityManagerFactory(entityManagerFactory)
            .build();
    }
}

제네릭 타입의 흐름을 정리하면:

JpaPagingItemReader<ViewLog>              // Reader: 읽을 타입
ItemProcessor<ViewLog, AuthorSettlement>  // Processor: 입력 → 출력 타입
JpaItemWriter<AuthorSettlement>           // Writer: 쓸 타입

.<ViewLog, AuthorSettlement>chunk(1000)   // <Reader 타입, Writer 타입>

타입이 체인처럼 연결되며, 중간에 타입이 맞지 않으면 컴파일 에러가 나기 때문에 타입 안정성도 보장된다.

Job 하나당 Config 파일 하나로 관리하는 게 일반적인 패턴이다.

batch
├── SettlementJobConfig.java     // 작가 정산
├── StatisticsJobConfig.java     // 열람 통계
└── CleanUpJobConfig.java        // 오래된 로그 삭제

스케줄링

Spring Batch 자체는 "어떻게 처리하냐"만 담당하고, "언제 실행하냐"는 별개다.

// 새벽 2시에 자동 실행
@Scheduled(cron = "0 0 2 * * *")
public void run() {
    jobLauncher.run(settlementJob, new JobParametersBuilder()
        .addLong("time", System.currentTimeMillis())
        .toJobParameters());
}

보통 새벽에 배치를 실행하는 이유는 트래픽이 없는 시간대여야 하기 때문이다. 배치는 DB를 대량으로 읽고 쓰기 때문에 서비스 운영 중에 돌리면 DB 부하 급증, 테이블 Lock 등 장애로 이어질 수 있다.

스케줄링 외에도 다양한 실행 방법이 있다.

방식설명
@Scheduled정해진 시간 자동 실행
REST API 트리거관리자 페이지 버튼으로 수동 실행
앱 시작 시 실행spring.batch.job.enabled=true
외부 스케줄러Jenkins, Kubernetes CronJob 등

내 프로젝트에서의 활용

현재 개발 중인 AI 영어 학습 프로젝트에서는 실시간 피드백은 API로 처리하고, Spring Batch는 아래 두 가지 작업에 활용할 예정이다.

1. 주간 리포트 생성

일주일치 Feedback 데이터 읽기 (Reader)
    ↓
주간 평균 점수, 약점 패턴 집계 (Processor)
    ↓
WeeklyReport 테이블에 저장 (Writer)

2. 학습 목표 달성 여부 판단

user_goals 테이블 읽기 (Reader)
    ↓
current_value >= target_value 체크 (Processor)
    ↓
is_achieved 업데이트 (Writer)

실시간 처리가 필요한 건 API로, 주기적인 집계가 필요한 건 Spring Batch로 분리한 구조다.


정리

  • Spring Batch는 대용량 데이터 일괄 처리 프레임워크
  • Chunk 방식: read() / process() 는 반복 호출, write() 는 List로 한 번에 처리
  • Chunk vs Tasklet: 트랜잭션 범위가 다름 (chunk 단위 vs Step 전체)
  • JobRepository: JobName + JobParameters로 JobInstance 결정, 실행 이력 저장
  • Restart: Reader가 ExecutionContext에 상태를 저장해야 중간부터 재시작 가능
  • 스케줄링: Spring Batch와 별개, 새벽에 실행하는 게 일반적
profile
뭐라도 해보자

0개의 댓글