| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 템플릿 메서드 패턴
- 배치
- 트러블슈팅
- 김영한
- lv1
- 백엔드
- Effective Java
- 프로그래머스
- 코드카타
- 스프링
- 디자인 패턴
- 이펙티브 자바
- redis
- GoF 23
- spring boot
- 스프링 배치
- Til
- 성능 개선
- 토스
- java
- 로드밸런서
- Spring
- DB
- Spring Batch
- 계산기
- 빌더 패턴
- 프록시 패턴
- 자바
- 스케줄러
- 추상클래스
- Today
- Total
김코딩
[Spring Batch] 4편. 실전 코드 패턴과 Reader/Processor/Writer 본문
들어가며
시리즈 마지막 편이다. 3편까지 읽었으면 Spring Batch의 기본 구조는 모두 정리한 셈이다. 이번 편에서는 3편의 간단한 예제보다 한 걸음 더 실무에 가까운 코드를 다루면서, 운영에서 마주치는 대표적인 고민들을 정리한다.
이번 편에서 다룰 내용은 네 가지다.
- Processor가 포함된 실전 시나리오 코드
- Processor의 역할 (변환 vs 필터링)
- JPA Writer의 함정과 JdbcBatchItemWriter와의 비교
- 실제로 배치를 실행하는 방법
💭 나의 생각
배치를 학습하면서 느낀 건, 프레임워크의 추상화를 맹목적으로 쓰면 오히려 성능 문제를 만들 수 있다는 점이다. 특히 Writer 쪽이 그렇다. "saveAll 한 줄이면 끝이네"라고 생각하면 나중에 운영에서 크게 당할 수 있다. 그 지점을 이번 편에서 풀어본다.
3편 예제 vs 4편 예제
3편에서는 탈퇴 회원의 이메일을 익명화하는 배치를 만들었다. 단순 수정 작업이었기 때문에 Reader와 Writer만으로 충분했다.
이번 편에서는 한 단계 더 복잡한 시나리오를 다룬다.
어제 구매 금액이 10만원 이상인 회원의 등급을 VIP로 변경한다. 단, 이미 VIP 회원이라면 건너뛴다.
이 시나리오는 조건 필터링과 상태 변경이 함께 필요하다. Reader가 조회한 회원 중 일부만 저장해야 하고, 각 회원마다 도메인 로직(등급 변경)이 적용되어야 한다. 이럴 때 Processor가 등장한다.

3편 예제는 단순 수정이라 Reader → Writer만으로 충분했다. 4편 예제는 도메인 로직과 필터링이 필요하므로 Processor가 중간에 들어간다. 이 차이를 염두에 두고 코드를 보자.
Processor의 두 가지 역할
Processor는 두 가지 역할을 한다. 각각을 이해하면 언제 Processor를 넣고 언제 뺄지 감이 잡힌다.

역할 1. 변환 (Transform)
Reader가 준 데이터를 가공해서 반환한다. 같은 타입으로 상태만 변경할 수도 있고, 완전히 다른 타입으로 바꿀 수도 있다.
// Member의 등급을 VIP로 변경해서 같은 타입으로 반환
ItemProcessor<Member, Member> processor = member -> {
member.upgradeToVip();
return member;
};
// Member를 MemberGradeHistory DTO로 변환
ItemProcessor<Member, MemberGradeHistory> processor = member ->
new MemberGradeHistory(member.getId(), OLD, VIP, now());
역할 2. 필터링 (Filter)
조건을 만족하지 않는 데이터는 null을 반환한다. null이 반환된 건은 Writer로 전달되지 않는다.
If, while processing the item, it is determined that the item is not valid, returning null indicates that the item should not be written out.
— Spring Batch Reference: The Domain Language of Batch
ItemProcessor<Member, Member> processor = member -> {
// 이미 VIP면 건너뜀
if (member.isVip()) {
return null;
}
member.upgradeToVip();
return member;
};
실무 팁: 언제 Processor를 넣나
Reader와 Writer 사이에 비즈니스 로직이나 필터링이 필요하면 Processor를 추가한다. 반대로 "DB에서 읽은 걸 그대로 다른 곳에 쓴다" 같은 단순 이동 작업은 Processor 없이도 충분하다.
💭 나의 생각
처음에는 "Processor를 쓰면 뭐가 좋지?"라고 생각했다. Writer 안에서 if로 걸러내도 되잖아? 하지만 관점을 바꿔보면 명확하다. Reader는 "데이터 가져오기", Processor는 "비즈니스 로직", Writer는 "저장"이라는 단일 책임을 지킬 수 있다. 덕분에 각 Bean을 독립적으로 테스트하기도 쉬워진다. 결국 객체지향 설계 원칙을 Spring Batch 레벨로 확장한 것이라고 이해했다.
실전 코드: VIP 등급 변경 배치
이제 실제 코드를 보자. 3편 예제와 구조가 거의 같지만 Processor가 추가되어 있다.
@Configuration
@RequiredArgsConstructor
public class MemberVipBatchConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final MemberRepository memberRepository;
// ① Job 정의
@Bean
public Job memberVipJob() {
return new JobBuilder("memberVipJob", jobRepository)
.start(upgradeVipStep())
.build();
}
// ② Step 정의 (Processor 포함)
@Bean
public Step upgradeVipStep() {
return new StepBuilder("upgradeVipStep", jobRepository)
.<Member, Member>chunk(1000, transactionManager)
.reader(yesterdayBuyerReader())
.processor(vipUpgradeProcessor()) // Processor 추가
.writer(memberWriter())
.build();
}
// ③ Reader - 어제 구매 금액 10만원 이상 회원 조회
@Bean
public ItemReader<Member> yesterdayBuyerReader() {
return new RepositoryItemReaderBuilder<Member>()
.name("yesterdayBuyerReader")
.repository(memberRepository)
.methodName("findByYesterdayPurchaseOver")
.arguments(List.of(100_000))
.pageSize(1000)
.sorts(Map.of("id", Sort.Direction.ASC))
.build();
}
// ④ Processor - 이미 VIP면 필터링, 아니면 등급 변경
@Bean
public ItemProcessor<Member, Member> vipUpgradeProcessor() {
return member -> {
if (member.isVip()) {
return null; // 필터링: Writer로 전달 안 됨
}
member.upgradeToVip(); // 변환: 상태 변경
return member;
};
}
// ⑤ Writer - JPA saveAll
@Bean
public ItemWriter<Member> memberWriter() {
return items -> memberRepository.saveAll(items);
}
}
3편 코드와 거의 같은 구조에, vipUpgradeProcessor() 하나만 추가되었을 뿐이다. Step 빌더에 .processor(...) 한 줄이 추가되었고, Processor 내부에서 필터링과 변환이 함께 일어난다.
JPA Writer의 함정
여기서 주의할 점이 있다. 위 코드의 Writer는 memberRepository.saveAll(items) 이지만, 이게 생각보다 느릴 수 있다.

saveAll(items)는 이름만 보면 한 번에 전부 저장하는 Bulk Insert처럼 보이지만, 실제로는 그렇지 않다.
JPA는 영속성 컨텍스트 안에서 각 엔티티의 변경 여부를 추적한다 (Dirty Checking). 1,000건을 saveAll에 넘기면 JPA는 각 엔티티를 하나씩 순회하면서 변경된 필드를 감지하고, 변경된 것에 대해 개별 UPDATE 쿼리를 생성한다. 결과적으로 1,000번의 UPDATE 쿼리가 DB로 날아간다.
UPDATE member SET grade = 'VIP' WHERE id = 1;
UPDATE member SET grade = 'VIP' WHERE id = 2;
UPDATE member SET grade = 'VIP' WHERE id = 3;
-- ... (1,000번 반복)
이게 JPA Writer의 함정이다. 코드 한 줄로 끝나는 것처럼 보이지만 내부에서는 개별 쿼리가 반복 실행된다.
💭 나의 생각
Flash Deal 프로젝트에서 TPS 최적화를 할 때도 비슷한 경험이 있었다. "한 줄 코드"라고 해서 "한 번의 DB 작업"이 되는 건 아니다. 특히 ORM을 쓸 때는 추상화 뒤에서 실제로 어떤 쿼리가 나가는지 반드시 확인해야 한다. show_sql=true 설정이나 p6spy 같은 도구가 필수인 이유가 여기에 있다.
해결책: JdbcBatchItemWriter
대량 insert/update가 필요할 때는 JdbcBatchItemWriter를 쓴다. JPA를 우회하고 JDBC 배치 업데이트로 한 번에 여러 건을 처리한다.

@Bean
public ItemWriter<Member> jdbcMemberWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Member>()
.dataSource(dataSource)
.sql("UPDATE member SET grade = :grade WHERE id = :id")
.beanMapped()
.build();
}
이렇게 하면 JDBC 배치 업데이트가 적용되어, 1,000건을 처리하는 데 드는 쿼리 횟수가 획기적으로 줄어든다.
어떤 Writer를 써야 할까
두 Writer는 트레이드 오프 관계에 있다.
JPA Writer (saveAll)를 쓰는 경우
- 데이터 양이 적고 성능이 크리티컬하지 않을 때
- 엔티티의 라이프사이클(영속성 컨텍스트, 이벤트, 캐스케이드)이 중요할 때
- 도메인 로직을 엔티티 메서드로 활용해야 할 때
JdbcBatchItemWriter를 쓰는 경우
- 대량 insert/update로 성능이 크리티컬할 때
- 단순 데이터 이동 작업이라 엔티티 라이프사이클이 필요 없을 때
- 메모리 사용량을 최소화해야 할 때
💭 나의 생각
이번 예제 코드에서는 편의상 saveAll을 썼지만, 실제 100만 건 처리 배치라면 JdbcBatchItemWriter를 쓰는 게 맞다. 실무에서는 "간결한 코드"와 "실행 시간"을 저울질해야 하는 상황이 많다. 특히 SLA가 있는 배치라면 더더욱.
배치 실행하는 방법
지금까지는 Job을 Bean으로 정의하는 것까지만 다뤘다. 실제로 이 배치를 어떻게 실행하는지는 다루지 않았는데, 이 부분으로 시리즈를 마무리한다.

방법 1. 스케줄러에서 실행
가장 흔한 방법은 @Scheduled로 정해진 시간에 JobLauncher를 호출하는 것이다.
@Component
@RequiredArgsConstructor
public class MemberVipScheduler {
private final JobLauncher jobLauncher;
private final Job memberVipJob;
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void runVipJob() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("date", LocalDate.now().minusDays(1).toString())
.addLong("timestamp", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(memberVipJob, params);
}
}
2편에서 다룬 내용이 여기서 등장한다.
date파라미터로 의미 있는 JobInstance 구분 (어제 날짜 기준)timestamp파라미터는 강제로 매번 다른 Instance를 만들기 위한 값. 개발/테스트 단계에서 유용하지만 운영에서는 주의해서 사용해야 한다 (중복 실행 방지 기능이 무력화되기 때문에)
방법 2. 수동 실행 (REST API 등)
운영 중 수동으로 실행해야 할 때는 REST 컨트롤러를 열어두기도 한다.
@RestController
@RequiredArgsConstructor
public class BatchController {
private final JobLauncher jobLauncher;
private final Job memberVipJob;
@PostMapping("/admin/batch/vip")
public ResponseEntity<String> runVipJob(@RequestParam String date) throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("date", date)
.addLong("timestamp", System.currentTimeMillis())
.toJobParameters();
JobExecution execution = jobLauncher.run(memberVipJob, params);
return ResponseEntity.ok("실행됨: " + execution.getStatus());
}
}
실행 결과 확인하기
배치가 끝나면 메타데이터 테이블에 결과가 기록된다. 2편에서 다룬 그 테이블들이다.
-- 이 Job의 최근 실행 이력
SELECT JOB_EXECUTION_ID, 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
WHERE ji.JOB_NAME = 'memberVipJob'
ORDER BY START_TIME DESC
LIMIT 10;
-- 특정 실행의 Step별 처리 건수
SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, FILTER_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = ?;
여기서 FILTER_COUNT가 주목할 만한 지표다. Processor가 null을 반환해서 걸러낸 건수가 이 컬럼에 기록된다. VIP 배치라면 "이미 VIP였던 회원"이 몇 명이었는지를 이 값으로 확인할 수 있다.
💭 나의 생각
메타데이터 테이블의 위력이 이 지점에서 실감난다. 운영 중 "어제 VIP 배치에서 얼마나 걸렀어?"라는 질문에 쿼리 한 번으로 답할 수 있다. 로그를 뒤지는 것보다 훨씬 빠르다. 2편에서 메타데이터 테이블의 존재 이유를 다뤘을 때는 감이 잘 안 왔는데, 4편에서 실제 운영 쿼리를 써보니 왜 이 테이블들이 자동으로 만들어지는지 이해가 됐다.
시리즈 마무리
4편의 여정이 끝났다. 시리즈 전체에서 다룬 내용을 정리하면 다음과 같다.
- 1편: Spring Batch가 해결하는 5가지 문제
- 2편: Job, JobInstance, JobExecution의 관계와 메타데이터
- 3편: Chunk 기반 처리의 원리, Job/Step 구조, Processor 없는 예제
- 4편: Processor가 포함된 실전 코드, JPA Writer의 함정, 실행 방법
이 시리즈는 Spring Batch 전체를 다루는 가이드가 아니라, 실무 투입 전에 반드시 알아야 할 기초를 정리한 것이다. 이 위에 쌓아야 할 주제는 아직 많다.
- Retry / Skip 전략의 실전 활용
- JobParameters 검증과
@JobScope,@StepScope - Reader 종류별 성능 비교 (JpaPagingItemReader vs JdbcCursorItemReader)
- Chunk size 튜닝, JDBC Writer 최적화
- 조건부 Step Flow 제어
- Listener를 통한 모니터링 및 알림
- 대용량 파티셔닝과 병렬 처리
💭 나의 생각
여기서 일단 멈추기로 했다. 위 주제들을 지금 다 정리하려면 분량도 방대하고, 아직 실무에서 부딪혀보지 않은 내용이 많아서 깊이가 부족해진다. 현업에서 실제로 Spring Batch를 돌려보고, 장애를 겪어보고, 성능 이슈를 마주친 뒤에 그 경험을 글로 정리하는 것이 나한테도 독자한테도 더 가치 있을 것이다.웨이트리프팅에서도 똑같다. 100kg 들어본 적 없는 사람이 150kg 스쿼트 팁을 정리할 수는 없다. 배운 만큼, 해본 만큼 기록한다.
다음 주부터 실무에서 Spring Batch를 실제로 돌려보고, 거기서 나오는 문제와 해결 과정을 또 글로 남기려 한다. 시리즈는 여기서 마무리하지만, Spring Batch 학습은 계속된다.
참고 자료
'Spring Batch' 카테고리의 다른 글
| [Spring Batch] 3편. Chunk 기반 처리의 원리와 Job/Step 구조 (0) | 2026.04.18 |
|---|---|
| [Spring Batch] 2편. Job, JobInstance, JobExecution의 관계 (1) | 2026.04.18 |
| [Spring Batch] 1편. Spring Batch는 왜 필요할까? (1) | 2026.04.18 |