김코딩

[Spring Batch] 1편. Spring Batch는 왜 필요할까? 본문

Spring Batch

[Spring Batch] 1편. Spring Batch는 왜 필요할까?

김코딩딩 2026. 4. 18. 15:59

들어가며

다음 주부터 실무에서 Spring Batch 작업을 진행하게 되었다. 이름만 들어봤을 뿐 실제로 써본 적은 없어서, 실무 투입 전에 개념부터 차근차근 정리하기로 했다.

이 시리즈는 내가 Spring Batch를 학습하며 정리한 기록이다. 단순히 "이렇게 쓰세요"가 아니라, 왜 이 프레임워크가 필요한지부터 이해하고 싶었다. 그래야 나중에 실무에서 문제가 생겼을 때 원인을 제대로 파악할 수 있을 것 같았기 때문이다.

💭 나의 생각
새로운 기술을 배울 때 "어떻게 쓰는지"보다 "왜 만들어졌는지"를 먼저 이해하는 습관은 웨이트리프팅 루틴에서 배운 것 같다. 어떤 동작을 하든 근본 원리를 모르면 부상으로 이어지듯, 기술도 원리를 모르면 예상치 못한 지점에서 장애로 이어진다.


단순 스케줄러로는 왜 부족한가

다음과 같은 요구사항이 들어왔다고 가정해보자.

매일 새벽 3시에 어제 가입한 회원 100만 명에게 환영 이메일을 발송하고, 발송 결과를 DB에 저장해주세요.

Spring Boot에 익숙한 개발자라면 @Scheduled 어노테이션으로 이렇게 작성할 수 있다.

@Scheduled(cron = "0 0 3 * * *")
public void sendWelcomeEmails() {
    List<Member> members = memberRepository.findYesterdayJoined();
    for (Member member : members) {
        emailService.send(member);
        logRepository.save(new EmailLog(member.getId(), "SUCCESS"));
    }
}

겉보기엔 문제없어 보인다. 하지만 실무에서 이 코드는 최소 5가지 문제를 안고 있다. 하나씩 살펴보자.


문제 1. 대용량 데이터를 한 번에 메모리에 올림 (OOM)

findYesterdayJoined()는 100만 건의 Member 객체를 한 번에 List로 반환한다. 이 데이터가 그대로 JVM 힙 메모리에 올라가는데, 회원 한 명당 1KB만 잡아도 1GB 이상이다. 실제 운영 환경에서는 회원 객체가 훨씬 무거우니 OOM(OutOfMemoryError) 으로 배치 프로세스가 죽는다.

💭 나의 생각
개발 환경에서 1만 건으로 테스트하면 문제없이 통과한다. 문제는 운영 배포 후 실제 트래픽이 들어왔을 때 터진다는 것이다. Flash Deal 프로젝트에서 TPS 튜닝하던 시절에도 비슷한 경험이 있었다. 부하 테스트 없이 "잘 되겠지" 하고 배포하면 꼭 터진다.

Spring Batch의 해결책: Chunk 단위로 데이터를 나눠서 처리한다. 예를 들어 Chunk size를 1,000으로 설정하면, 한 번에 1,000건만 읽고 처리하고 저장하고 → 다음 1,000건을 처리하는 식으로 반복한다. 메모리 사용량이 일정하게 유지된다.

Spring Batch uses a "chunk-oriented" processing style in its most common implementation. Chunk oriented processing refers to reading the data one at a time and creating 'chunks' that are written out within a transaction boundary.
Spring Batch Reference: Chunk-oriented Processing


문제 2. 실패 시 어디서 실패했는지 모름

100만 건 중 30만 번째에서 에러가 터져 프로세스가 죽었다고 하자. 위 코드는 어디까지 처리했는지에 대한 정보를 아무 데도 남기지 않는다.

결과적으로 재실행하면 1번부터 다시 시작한다. 이 경우:

  • 이미 메일 받은 30만 명에게 중복 발송 (사용자 컴플레인)
  • 시간 2배 소요
  • 외부 API(이메일 서비스) 비용 2배

Spring Batch의 해결책: JobRepository라는 메타데이터 저장소가 있다. DB의 BATCH_JOB_EXECUTION, BATCH_STEP_EXECUTION 테이블에 언제, 몇 건까지, 어떤 상태로 처리했는지를 자동으로 기록한다. 덕분에 실패한 지점부터 이어서 재시작할 수 있다.

이 개념(JobInstance, JobExecution)은 2편에서 자세히 다룰 예정이다.


문제 3. 실패 처리 전략이 없음

중간에 실패가 나면 for 루프는 그냥 예외가 터지고 끝난다. 하지만 실무에서 실패는 두 가지 성격으로 나뉜다.

(1) 일시적 실패 (Transient Failure)

  • 네트워크 일시 끊김, DB 커넥션 순간 포화
  • 다시 시도하면 성공할 가능성이 높음 → 재시도(Retry)가 답

(2) 영구적 실패 (Permanent Failure)

  • 이메일 주소 형식 오류, 탈퇴한 회원
  • 몇 번을 다시 해도 실패 → 건너뛰기(Skip)가 답

단순 for + try-catch로 이 로직을 구현하려면 코드가 지저분해진다. 또한 재시도 횟수 추적, 스킵된 건수 집계 같은 부가 로직도 직접 다 만들어야 한다.

Spring Batch의 해결책: 선언적으로 설정할 수 있다.

.faultTolerant()
.retry(NetworkException.class).retryLimit(3)
.skip(InvalidEmailException.class).skipLimit(100)

If a FlatFileParseException is encountered while reading, it is always thrown for that record. Resetting the ItemReader does not help. However, for other exceptions (such as a DeadlockLoserDataAccessException, which indicates that the current process has attempted to update a record that another process holds a lock on), waiting and trying again might result in success.
Spring Batch Reference: Configuring Retry Logic

💭 나의 생각
재시도와 건너뛰기를 구분하는 관점이 흥미롭다. 기존에 API 개발할 때는 "실패하면 예외 던지고 끝"이었는데, 배치는 "어떤 실패는 참고 넘어가고, 어떤 실패는 다시 해봐야 한다"는 더 섬세한 제어가 필요하다. Circuit Breaker 패턴을 공부했을 때와 비슷한 결의 철학이라고 느꼈다.


문제 4. 트랜잭션 경계가 극단적

트랜잭션을 어떻게 설정하느냐에 따라 두 가지 극단이 생긴다.

(1) 전체를 하나의 트랜잭션으로 묶는 경우: 999,999번째에서 실패 시 100만 건 전부 롤백. 2시간 돌린 게 날아간다.

(2) 트랜잭션 없이 건별로 커밋하는 경우: 중간에 서버가 죽으면 어디까지 반영됐는지 알 수 없고 정합성이 깨진다.

Spring Batch의 해결책: Chunk 단위가 곧 트랜잭션 단위다. Chunk size가 1,000이면 1,000건마다 커밋된다. 중간에 실패해도 이전에 커밋된 건은 안전하게 보존되고, 현재 Chunk만 롤백된다.

Once the number of items read equals the commit interval, the entire chunk is written out by the ItemWriter, and then the transaction is committed.
Spring Batch Reference: The Commit Interval


문제 5. 실행 이력이 남지 않음

"어제 새벽 배치 잘 돌았나요?", "얼마나 걸렸죠?", "몇 건 실패했나요?"

단순 스케줄러 방식에서는 이 질문에 답하려면 애플리케이션 로그를 뒤져야 한다. 로그 레벨을 제대로 설정해두지 않았거나, 로그 롤링으로 이미 삭제되었다면 답할 방법이 없다.

Spring Batch의 해결책: 모든 실행 이력이 메타데이터 테이블에 자동 저장된다.

Spring Batch를 적용하면 다음 9개의 테이블이 자동으로 생성된다.

  • BATCH_JOB_INSTANCE
  • BATCH_JOB_EXECUTION
  • BATCH_JOB_EXECUTION_PARAMS
  • BATCH_JOB_EXECUTION_CONTEXT
  • BATCH_STEP_EXECUTION
  • BATCH_STEP_EXECUTION_CONTEXT
  • BATCH_JOB_INSTANCE_SEQ
  • BATCH_JOB_EXECUTION_SEQ
  • BATCH_STEP_EXECUTION_SEQ

각 테이블에 언제 실행했는지, 몇 건 처리했는지, 어느 단계에서 실패했는지가 기록된다. SQL 한 번이면 이력 조회가 끝난다.

SELECT JOB_NAME, START_TIME, END_TIME, STATUS, EXIT_CODE
FROM BATCH_JOB_EXECUTION je
JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
ORDER BY START_TIME DESC;

각 테이블의 역할은 2편에서 자세히 다룰 예정이다.


정리: Spring Batch가 필요한 5가지 이유

한 줄로 정리하면:

Spring Batch는 대용량 데이터를 안전하고 재시작 가능하게 처리하기 위한 프레임워크다.

어떤 기능을 배우든 "이 기능이 안전성, 재시작성, 관찰 가능성 중 어디에 기여하는가?"로 연결해서 이해하면 머리에 잘 들어온다.

💭 나의 생각
처음에는 "그냥 for 루프로 해도 되지 않나?"라고 생각했다. 실제로 소규모 데이터나 단발성 작업은 그게 맞을 수도 있다. 하지만 데이터가 100만 건 이상, 외부 API 호출이 섞이고, 실패 시 금전적 손해가 생길 수 있는 환경이라면 이야기가 달라진다. 프레임워크의 존재 이유는 "개발자가 이 모든 엣지 케이스를 매번 다시 짜지 않게" 하는 것이다.


다음 편 예고

다음 편에서는 Spring Batch의 핵심 개념인 Job, JobInstance, JobExecution의 관계를 정리한다. 특히 "같은 배치를 두 번 실행하면 어떻게 될까?"라는 질문에 대한 답을 찾아가 볼 예정이다.


참고 자료