카테고리 없음

[배치] Spring Batch 가 뭘까? 주기적으로? "탈락"

소범범 2026. 4. 17. 17:23

 

 

Spring Batch가 뭘까?
주기적으로? "탈락"

스케줄러가 배치라고 생각했던 내가, Spring Batch를 만나고 깨달은 것들

 

TL;DR
스케줄러는 "언제 실행할지"를 정하는 것이고, 배치는 "대량 데이터를 안전하게 처리하는 것"이다. 둘은 독립적인 개념이다.
Spring Batch가 제공하는 청크 트랜잭션, Skip/Retry, 커서 스트리밍, 메타데이터를 스케줄러에서 직접 구현하면 결국 프레임워크를 재발명하는 꼴이 된다.
주간/월간 랭킹 배치를 직접 구현하면서 각 기능이 왜 필요한지 확인했다.

1. 스케줄러든 Spring Batch든, 같은줄 알았다.

회사에서는 주기적으로 도는 작업을 "스케줄러"라고 부르고, Jenkins에서 시간을 설정해서 Spring Batch Task를 실행시키는 걸 "배치"라고 불렀습니다. 스케줄러는 5분마다 캐시를 갱신하거나 상태를 체크하는 용도로, 배치는 매일 새벽에 통계를 집계하는 용도로 쓰이고 있었습니다.

저는 솔직히 이렇게 이해하고 있었습니다.

  • 스케줄러 = 주기별로 계속 도는 것 (5분마다, 1시간마다)
  • Spring Batch = 정해진 시간에 한 번 돌리는 것 (매일 새벽 2시)

둘 다 결국 "정해진 시간에 코드를 실행하는 것" 아닌가? 돌리는 주기가 다를 뿐이지.

그런데 배치를 직접 구현해보니 이 이해가 틀렸습니다.

"Spring Batch가 뭘까? 주기적으로? — 탈락."

배치의 핵심은 "언제 돌리느냐"가 아니었습니다.

Batch Processing = 모아놓고 한번에 처리하는 작업. 일괄처리.

Scheduling = 언제 실행하냐를 정하는 것.

배치는 "무엇을 어떻게", 스케줄링은 "언제"의 문제.

직접 만들어보니 이 구분이 체감됩니다.

Jenkins가 매일 새벽 2시에 배치를 트리거하는 건 스케줄링이고,
그 안에서 100만 건을 청크 단위로 읽고 집계해서 저장하는 건 배치 처리입니다.

Jenkins를 Cron으로 바꿔도 배치 로직은 그대로지만,
Spring Batch를 단순 스케줄러로 바꾸면 대량 처리 안전장치가 전부 사라집니다.

개념 역할 예시
배치(Batch) 무엇을, 어떻게 처리할지 100만 건 이벤트 로그를 집계해서 랭킹 생성
스케줄링(Scheduling) 언제 실행할지 매일 새벽 2시, 매주 월요일 01:00

@Scheduled, 크론, 젠킨스, 에어플로우는 전부 "언제"의 영역입니다. "어떻게"의 영역은 Spring Batch가 담당합니다.


2. 스케줄러로 대량 데이터를 처리하면 생기는 문제

데이터가 수백 건이면 스케줄러로 충분합니다. 문제는 데이터가 커질 때 시작됩니다.

100만 건의 이벤트 로그를 집계해서 랭킹을 만드는 시나리오를 생각해보겠습니다.

@Scheduled + JPA로 구현했다면

kotlin — ScheduledRanking.kt
@Scheduled(cron = "0 0 2 * * *")
fun aggregateRanking() {
    val events = eventLogRepository.findAll() // 100만 건 전부 메모리에
    val grouped = events.groupBy { it.productId }
    val rankings = grouped.map { (productId, events) →
        // 집계 로직...
    }
    rankingRepository.saveAll(rankings) // 한 트랜잭션에 전부
}

이 코드가 50만 건째에서 OOM으로 터지면:

  • 재시작 불가 — 어디까지 처리했는지 기록이 없으므로 처음부터 100만 건을 다시 읽어야 합니다. 이미 처리한 50만 건도 다시.
  • 전체 롤백saveAll()이 하나의 @Transactional 안에서 실행되므로, 50만 건째에서 터지면 이전에 저장한 것까지 전부 롤백됩니다. DB 입장에서는 아무 일도 안 한 것과 같습니다.
  • 부분 실패 처리 불가 — 100만 건 중 100건이 데이터 오류(null 필드, 제약조건 위반 등)여도 전체가 실패합니다. 정상인 999,900건도 같이 죽습니다.
  • 실행 이력 없음 — 지난번에 이 작업이 성공했는지, 몇 건을 처리했는지, 얼마나 걸렸는지 확인할 방법이 없습니다. 로그를 뒤져봐야 합니다.

이 문제들을 하나씩 해결하려고 체크포인트 테이블을 만들고, 트랜잭션을 분리하고, try-catch로 에러를 잡고, 실행 이력 테이블을 설계하다 보면... 결국 Spring Batch를 재발명하게 됩니다.


3. Spring Batch가 해결해주는 것들

직접 구현하면서 "이건 스케줄러로는 안 되겠다"고 느낀 지점들입니다.

3-1. 실패 복구

스케줄러로 짠 코드가 50만 건째에서 터지면, 처음부터 다시 돌려야 합니다. 100만 건을 다시 읽고, 이미 처리한 50만 건도 다시 계산합니다.

Spring Batch는 두 가지 재시작 전략을 제공합니다.

  • 체크포인트 기반 재시작 — 청크가 커밋될 때마다 BATCH_STEP_EXECUTION 테이블에 read_count, write_count를 기록합니다. 실패 후 재실행하면 마지막 커밋 지점부터 이어서 처리합니다. 대량 INSERT처럼 데이터를 누적하는 작업에 적합합니다.
  • Cleanup → Aggregate (전체 재실행) — 기존 데이터를 DELETE한 후 처음부터 다시 실행합니다. 집계/랭킹처럼 전체 데이터를 합산해야 결과가 의미 있는 작업에 적합합니다.

이번에 만든 랭킹 배치에서는 2번을 선택했습니다. Top 100 랭킹은 "4월 13일~19일의 모든 이벤트를 합산한 결과"이므로, 50만 건째에서 멈추고 이어서 처리하면 불완전한 랭킹이 됩니다. 깨끗하게 지우고 다시 만드는 게 정합성이 확실합니다.

어떤 전략을 선택하든 Spring Batch가 메타데이터 관리와 Job 실행 상태 추적을 제공하기 때문에 직접 구현할 필요가 없었습니다.

3-2. 청크 트랜잭션

100만 건을 한 트랜잭션으로 처리하면, 실패 시 100만 건 전체가 롤백됩니다. DB는 롤백을 위해 undo 로그를 보관하고 있어야 하므로, 트랜잭션이 길어질수록 DB 부담도 커집니다.

Spring Batch는 청크 단위(예: 500건)로 읽고 → 가공하고 → 저장하고 → 커밋합니다.

[Reader] 500건 읽기 → [Processor] 500건 가공 → [Writer] 500건 저장 → 커밋
[Reader] 500건 읽기 → [Processor] 500건 가공 → [Writer] 500건 저장 → 커밋
...

100만 건이면 2,000번의 커밋이 발생합니다. 1,500번째 청크에서 실패해도 이전 1,499번의 커밋은 이미 완료되어 있으므로, 롤백 범위는 마지막 500건뿐입니다.

실제로 chunk size를 500으로 설정하고 돌려보니 이 단위가 트랜잭션 경계가 되는 걸 확인했습니다. ChunkListener에서 매 청크마다 readCount, writeCount가 500씩 증가하는 로그가 찍힙니다.

3-3. Skip/Retry

1000만 건 중 100건이 데이터 오류(null 필드, 타입 불일치, 제약조건 위반 등)라고 해보겠습니다. 스케줄러에서 이걸 직접 처리하려면 건별 try-catch에 오류 로깅, 재시도 횟수 관리, 스킵 카운트 추적까지 직접 짜야 합니다.

Spring Batch는 설정 몇 줄로 됩니다.

kotlin — Step 설정
.faultTolerant()
.skipLimit(100)
.skip(DataIntegrityViolationException::class.java)
.retryLimit(3)
.retry(TransientDataAccessException::class.java)

DataIntegrityViolationException(제약조건 위반)은 100건까지 스킵하고, TransientDataAccessException(일시적 DB 연결 오류)은 3번까지 재시도합니다. 스킵된 건수는 BATCH_STEP_EXECUTIONskip_count에 자동 기록되므로 모니터링도 됩니다.

3-4. 커서 스트리밍

findAll()은 결과를 List로 한꺼번에 메모리에 올립니다. 100만 건이면 OOM입니다.

JdbcCursorItemReader는 DB 커서(ResultSet)를 열어두고 한 건씩 가져오되, fetchSize 단위로 네트워크 전송을 묶어서 효율적으로 스트리밍합니다.

처음에 List로 전체를 올리는 Reader를 만들었다가 전환했습니다. Chunk가 500건씩 끊어서 처리하는 구조인데, Reader가 이미 전부 올려버리면 청크의 의미가 사라지기 때문입니다.

kotlin — Before: 전체 메모리 적재
val results = jdbcTemplate.query(sql, params) { rs, _ → ... }
// results에 100만 건이 다 올라감 — Chunk의 의미가 없음
kotlin — After: 커서 스트리밍
JdbcCursorItemReaderBuilder<ProductRankAggregation>()
    .dataSource(dataSource)
    .sql(sql)
    .fetchSize(500) // DB → 앱 네트워크 전송을 500건 단위로 묶음
    .rowMapper { rs, _ → ... }
    .build()
// 메모리에는 청크 크기(500건)만큼만 존재

3-5. 메타데이터

Spring Batch는 실행할 때마다 메타데이터 테이블에 자동으로 기록합니다.

  • BATCH_JOB_EXECUTION — Job 시작/종료 시간, 상태(COMPLETED/FAILED), 파라미터
  • BATCH_STEP_EXECUTION — Step별 read/write/skip count, 커밋 횟수, 실행 시간

@Scheduled에서 이런 이력을 남기려면 실행 이력 테이블을 설계하고, 시작/종료 시간을 기록하고, 처리 건수를 카운팅하고, 에러 시 상태를 업데이트하는 코드를 직접 짜야 합니다.

배치 모니터링에서 중요한 지표는 실행시간(스케줄링 겹침 방지), TPS(처리량), read/write/skip count(데이터 정합성), error rate(품질)인데, Spring Batch는 이걸 기본으로 제공합니다.


4. 실제 구현에서 확인한 것들

주간/월간 랭킹 배치를 만들면서 추가로 확인한 포인트들입니다.

배치에서 JPA 대신 JDBC를 쓰는 이유

JPA saveAll()은 내부적으로 save()를 반복 호출하고, save()isNew() 상태에 따라 persist() 또는 merge()를 실행합니다. ID가 할당된 엔티티면 merge() 경로를 타서 건별 SELECT가 먼저 발생합니다.

100건이면 SELECT 100번 + INSERT 100번 = 200번 쿼리.
JDBC batchUpdate()는 같은 100건을 1번의 네트워크 전송으로 처리합니다.

구분 JPA saveAll() JDBC batchUpdate()
내부 동작 merge() → 건별 SELECT + INSERT INSERT N건을 한 번에 전송
100건 기준 쿼리 수 ~200회 ~1회 (batch)
1차 캐시 100건 엔티티 객체 보유 (메모리) 없음
더티 체킹 활성 (불필요한 오버헤드) 없음
용도 API 레이어 (단건 CRUD) 배치 레이어 (대량 처리)

집계 SQL — DB에서 최대한 계산하고 가져오기

Reader의 SQL은 ranking_event_log에서 상품별로 가중치를 적용한 점수를 합산하고, 상위 100건만 가져옵니다.

sql — 집계 쿼리
SELECT
    rel.product_id,
    SUM(CASE WHEN rel.event_type = 'VIEW'  THEN rel.event_value * ? ELSE 0 END) +
    SUM(CASE WHEN rel.event_type = 'LIKE'  THEN rel.event_value * ? ELSE 0 END) +
    SUM(CASE WHEN rel.event_type = 'ORDER' THEN rel.event_value * ? ELSE 0 END) AS total_score,
    COUNT(CASE WHEN rel.event_type = 'VIEW'  THEN 1 END) AS view_count,
    COUNT(CASE WHEN rel.event_type = 'LIKE'  THEN 1 END) AS like_count,
    COUNT(CASE WHEN rel.event_type = 'ORDER' THEN 1 END) AS order_count
FROM ranking_event_log rel
WHERE rel.occurred_date BETWEEN ? AND ?
GROUP BY rel.product_id
ORDER BY total_score DESC
LIMIT 100

가중치(VIEW=0.1, LIKE=0.2, ORDER=0.6)는 ranking_score_config 테이블에서 읽어오므로 코드 변경 없이 동적으로 조정할 수 있습니다. WHERE occurred_date BETWEEN으로 날짜 범위를 고정하는 건 스냅샷 전략으로, 배치 실행 중에 새로 유입되는 이벤트는 다음 배치에서 처리됩니다.

Parallel Flow로 주간 + 월간 동시 집계

주간 집계와 월간 집계는 서로 다른 테이블(mv_product_rank_weekly, mv_product_rank_monthly)에 쓰기 때문에 데이터 의존성이 없습니다. 순서대로 실행하면 시간이 2배 걸립니다.

Spring Batch의 FlowBuilder.split(SimpleAsyncTaskExecutor())로 두 작업을 동시에 실행했습니다.

Job: rankingAggregationJob
  [Parallel Flow]
  ├── Weekly: Cleanup → Aggregation (Reader→Processor→Writer)
  └── Monthly: Cleanup → Aggregation (Reader→Processor→Writer)

각 Flow 안에서 Cleanup(DELETE) → Aggregation(READ→PROCESS→WRITE)이 순차적으로 실행되고, Weekly Flow와 Monthly Flow는 병렬로 실행됩니다. @StepScope Bean을 Flow별로 각각 생성해서 Reader/Writer의 상태가 겹치지 않도록 했습니다.

Cleanup → Aggregate 패턴으로 멱등성 확보

배치가 중간에 실패하고 다시 실행하면 데이터가 중복될 수 있습니다. 예를 들어 INSERT만 하는 구조에서 50건을 저장한 뒤 실패하면, 재실행 시 다시 100건을 INSERT하려고 하면서 UNIQUE 제약조건 위반이 발생하거나, 제약이 없다면 150건이 됩니다.

집계 전에 해당 기간의 기존 데이터를 먼저 삭제하면 이 문제가 해결됩니다.

1차 실행: DELETE (0건 삭제) → INSERT 50건
실패! (나머지 50건 미저장)
2차 실행: DELETE (50건 삭제) → INSERT 100건
성공!

DELETE가 항상 먼저 실행되기 때문에, 이전 실행에서 불완전하게 들어간 데이터가 있어도 싹 지우고 새로 시작합니다. 몇 번을 실행해도 결과가 동일합니다. 이게 멱등성이고, 실제로 E2E 테스트에서 동일 파라미터로 2회 실행해도 데이터가 정확히 유지되는 걸 확인했습니다.


5. 스케줄러가 나쁜 게 아니다

@Scheduled는 스케줄링 도구로서 충분합니다. 캐시 갱신, 상태 체크, 만료 데이터 삭제처럼 데이터 건수가 적고 실패해도 다음 주기에 다시 처리되는 작업에는 @Scheduled가 맞습니다.

문제는 대량 데이터 처리까지 스케줄러에 맡기는 것입니다.

상황 선택 이유
5분마다 캐시 갱신 (수십 건) @Scheduled 실패해도 다음 주기에 재갱신
매일 만료 쿠폰 삭제 (수백 건) @Scheduled 건수가 적고 단순 DELETE
매일 100만 건 이벤트 로그 집계 Spring Batch 메모리 관리, 청크 트랜잭션 필요
중간 실패 시 재시작이 필요 Spring Batch 체크포인트 / Cleanup-Aggregate
처리 이력과 모니터링이 필요 Spring Batch 메타데이터 테이블 자동 기록
부분 실패(Skip/Retry)가 필요 Spring Batch faultTolerant 설정

Spring Batch가 필요한 기준은 명확합니다. 데이터가 많거나, 실패 복구가 필요하거나, 실행 이력을 추적해야 할 때.


마무리

이전에는 스케줄러와 배치의 차이를 "돌리는 주기"로 구분했는데, 직접 Spring Batch로 랭킹 집계를 구현하면서 그 구분이 틀렸다는 걸 확인했습니다.

스케줄러는 "언제", 배치는 "어떻게"의 문제입니다. 그리고 Spring Batch가 제공하는 청크 트랜잭션, 커서 스트리밍, 실패 복구, 메타데이터는 대량 데이터를 다루는 순간 직접 만들기엔 부담이 큰 것들이었습니다.

다음에 대량 데이터 처리를 만나면 "이거 스케줄러로 충분한가, 배치 프레임워크가 필요한가"를 먼저 따져볼 수 있게 됐습니다.