Step 02 — Job 과 Step 의 구조
학습 목표
- Job → Step → Tasklet → (ItemReader/Processor/Writer) 계층 구조를 그림과 코드로 연결한다
- Spring Batch 5.0 에서
JobBuilderFactory / StepBuilderFactory 가 왜 사라졌는지 이해한다
- 4.x 코드와 5.x 코드를 before/after 로 나란히 놓고 마이그레이션 규칙을 손에 익힌다
JobRepository 를 빌더 생성자로 넘기는 새 방식과, PlatformTransactionManager 를 Step 에 넘겨야 하는 이유를 안다
@EnableBatchProcessing 을 붙였을 때 Boot 자동설정이 통째로 꺼지는 것을 직접 재현한다
- Step 빈이 싱글턴이라는 사실이 만드는 상태 공유 사고를 확인한다
선행 스텝: Step 01 — 환경 구축과 첫 Job
예상 소요: 80분
2-0. 이 스텝이 필요한 이유
인터넷에서 Spring Batch 예제를 검색하면 90% 는 이렇게 시작합니다.
@Autowired private JobBuilderFactory jobBuilderFactory;
@Autowired private StepBuilderFactory stepBuilderFactory;
그리고 여러분의 프로젝트에서는 컴파일이 안 됩니다.
error: cannot find symbol
symbol: class JobBuilderFactory
location: class com.example.batch.step02.LegacyJobConfig
이 스텝은 그 에러를 고치는 방법이 아니라, 왜 그렇게 바뀌었고 그 변화가 무엇을 요구하는지를 다룹니다. 기계적으로 치환하면 컴파일은 되지만 조용히 잘못 도는 경우가 있기 때문입니다.
2-1. Job / Step / Tasklet 계층 구조
먼저 전체 그림을 고정합니다.
┌──────────────────────────────────────────────────────────────────────┐
│ Job "settlementJob" │
│ · 배치 작업 전체. 이름을 가진다. │
│ · Step 들의 실행 순서(또는 흐름)를 정의한다. │
│ · JobRepository 에 JobInstance / JobExecution 을 남긴다. │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Step 1 "prepareStep" ← TaskletStep │ │
│ │ · 독립적인 처리 단계. 자기만의 트랜잭션 경계를 가진다. │ │
│ │ · StepExecution 을 남긴다 (모든 카운트의 출처). │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Tasklet (인터페이스, 메서드 하나) │ │ │
│ │ │ RepeatStatus execute(StepContribution, │ │ │
│ │ │ ChunkContext) │ │ │
│ │ │ → FINISHED 를 반환할 때까지 반복 호출된다 │ │ │
│ │ │ → 호출 1회 = 트랜잭션 1개 = 커밋 1회 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ next │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Step 2 "settlementStep" ← 같은 TaskletStep 이다! │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ ChunkOrientedTasklet (Tasklet 의 구현체) │ │ │
│ │ │ ┌────────────┐ ┌───────────────┐ ┌────────┐│ │ │
│ │ │ │ ItemReader │→│ ItemProcessor │→│ Writer ││ │ │
│ │ │ └────────────┘ └───────────────┘ └────────┘│ │ │
│ │ │ chunk(1000) 만큼 읽고 처리한 뒤 한 번에 쓴다 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
여기서 놓치기 쉬운 사실 하나를 강조합니다.
청크 지향 Step 도 결국 TaskletStep 입니다.
.chunk(1000, txManager) 로 만든 Step 은 특별한 종류의 Step 이 아니라, ChunkOrientedTasklet 이라는 Tasklet 을 품은 TaskletStep 입니다.
그래서 Step 01 에서 배운 "Tasklet 호출 1회 = 트랜잭션 1개" 규칙이 그대로 적용되고, 그 결과 청크 하나 = 트랜잭션 하나가 됩니다. Step 05 의 청크 커밋 동작이 여기서 유도됩니다.
| 계층 | 인터페이스 | 대표 구현 | 메타데이터 |
|---|
| Job | Job | SimpleJob, FlowJob | BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION |
| Step | Step | TaskletStep, PartitionStep, FlowStep, JobStep | BATCH_STEP_EXECUTION |
| Tasklet | Tasklet | 람다, ChunkOrientedTasklet, SystemCommandTasklet | 없음 (Step 에 집계) |
| 청크 3요소 | ItemReader/Processor/Writer | JdbcPagingItemReader 등 | 없음 (카운트로 반영) |
2-2. 4.x 의 방식 — 무엇이 문제였나
Spring Batch 4.3 코드는 이렇게 생겼습니다.
// ⚠️ Spring Batch 4.3 — 5.1 에서는 컴파일되지 않습니다
package com.example.batch.legacy;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableBatchProcessing // 4.x 에서는 필수였습니다
public class LegacySettlementJobConfig {
private final JobBuilderFactory jobBuilderFactory;
private final StepBuilderFactory stepBuilderFactory;
public LegacySettlementJobConfig(JobBuilderFactory jbf, StepBuilderFactory sbf) {
this.jobBuilderFactory = jbf;
this.stepBuilderFactory = sbf;
}
@Bean
public Job settlementJob() {
return jobBuilderFactory.get("settlementJob")
.start(settlementStep())
.build();
}
@Bean
public Step settlementStep() {
return stepBuilderFactory.get("settlementStep")
.tasklet((contribution, chunkContext) -> {
System.out.println("정산 중...");
return RepeatStatus.FINISHED;
})
.build(); // 트랜잭션 매니저를 안 넘깁니다
}
}
JobBuilderFactory.get(name) 은 사실 이 한 줄이 전부였습니다.
// Spring Batch 4.3 의 JobBuilderFactory (요약)
public class JobBuilderFactory {
private final JobRepository jobRepository;
public JobBuilder get(String name) {
return new JobBuilder(name).repository(this.jobRepository);
}
}
팩토리가 한 일은 "JobRepository 를 주입해 주는 것" 하나뿐입니다. 그런데 이 편의를 위해 치른 대가가 컸습니다.
| 문제 | 설명 |
|---|
| 숨은 의존성 | JobRepository 가 어디서 왔는지 코드에 안 보입니다. @EnableBatchProcessing 이 등록한 것을 팩토리가 물고 있습니다 |
| 커스터마이징 어려움 | 다른 JobRepository(예: 다른 DataSource)를 쓰려면 팩토리 자체를 갈아치워야 합니다 |
@EnableBatchProcessing 강제 | 팩토리 빈이 그 애너테이션 없이는 존재하지 않습니다 |
| Step 의 트랜잭션 매니저가 암묵적 | .tasklet(t) 만 쓰면 컨텍스트에 하나뿐인 PlatformTransactionManager 를 자동으로 씁니다. 여러 개면? |
마지막 항목이 특히 위험했습니다. JPA + JDBC 를 함께 쓰는 프로젝트에서 JpaTransactionManager 와 DataSourceTransactionManager 가 둘 다 있으면, 어느 쪽이 잡힐지 코드만 봐서는 알 수 없었습니다.
2-3. 5.x 로 옮기기 — before / after
같은 설정을 5.1 로 옮기면 이렇습니다.
// ✅ Spring Batch 5.1
package com.example.batch.step02;
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.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SettlementJobConfig { // @EnableBatchProcessing 없음
@Bean
public Job settlementJob(JobRepository jobRepository, Step settlementStep) {
return new JobBuilder("settlementJob", jobRepository)
.start(settlementStep)
.build();
}
@Bean
public Step settlementStep(JobRepository jobRepository,
PlatformTransactionManager txManager) {
return new StepBuilder("settlementStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("정산 중...");
return RepeatStatus.FINISHED;
}, txManager) // 트랜잭션 매니저를 명시
.build();
}
}
변경점을 한 줄씩 대응시킵니다.
| # | 4.x | 5.x | 왜 |
|---|
| 1 | @EnableBatchProcessing 필수 | 제거 | Boot 3 자동설정이 대신함. 붙이면 오히려 손해 (2-6) |
| 2 | JobBuilderFactory jbf 필드 주입 | 삭제 | JobRepository 를 @Bean 메서드 파라미터로 |
| 3 | jobBuilderFactory.get("x") | new JobBuilder("x", jobRepository) | 의존성을 눈에 보이게 |
| 4 | stepBuilderFactory.get("x") | new StepBuilder("x", jobRepository) | 동일 |
| 5 | .tasklet(t) | .tasklet(t, txManager) | 트랜잭션 매니저를 명시 |
| 6 | .<I,O>chunk(1000) | .<I,O>chunk(1000, txManager) | 동일 |
| 7 | settlementStep() 직접 호출 | Step settlementStep 파라미터 주입 | 컨테이너가 만든 빈 보장 |
| 8 | javax.* | jakarta.* | Jakarta EE 9+ |
⚠️ 함정 — "deprecated 라고 나왔는데 왜 컴파일이 안 되죠?"
정확한 타임라인은 이렇습니다.
| 버전 | JobBuilderFactory / StepBuilderFactory |
|---|
| 4.3 | 정상 API |
| 5.0 | @Deprecated — 경고만 뜨고 동작은 함 |
| 5.1 | 완전 삭제 — 컴파일 에러 |
이 코스는 Spring Batch 5.1.1 이므로 삭제된 상태입니다.
그래서 "5.0 으로 올렸을 때는 경고만 떴는데 5.1 로 올리니 빌드가 깨졌다"가 정상적인 경험입니다.
5.0 으로 올릴 때 경고를 무시하고 넘어간 팀이 5.1 에서 한꺼번에 터집니다. deprecation 경고는 청구서가 미뤄진 것이지 면제된 게 아닙니다.
두 코드를 실제로 돌려 결과가 같은지 확인합니다.
./gradlew bootRun --args='--spring.batch.job.name=settlementJob'
결과
INFO 42107 --- [ main] c.e.batch.BatchLabApplication : Started BatchLabApplication in 1.913 seconds (process running for 2.188)
INFO 42107 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] launched with the following parameters: [{}]
INFO 42107 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settlementStep]
정산 중...
INFO 42107 --- [ main] o.s.batch.core.job.SimpleStepHandler : Step: [settlementStep] executed in 19ms
INFO 42107 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 44ms
BUILD SUCCESSFUL in 5s
로그는 4.x 와 완전히 동일합니다. 동작이 바뀐 게 아니라 의존성을 표현하는 방식이 바뀐 것입니다.
2-4. JobRepository 를 생성자로 넘긴다는 것의 의미
new JobBuilder("settlementJob", jobRepository) 는 단순한 문법 변경이 아닙니다.
// Spring Batch 5.1 의 JobBuilder 생성자
public class JobBuilder extends JobBuilderHelper<JobBuilder> {
public JobBuilder(String name, JobRepository jobRepository) {
super(name, jobRepository); // JobRepository 가 필수 인자
}
@Deprecated(since = "5.0")
public JobBuilder(String name) { ... } // 5.1 에서도 남아 있지만 쓰지 마세요
}
이름 하나로 만드는 생성자는 여전히 존재합니다. 그런데 그걸 쓰면:
// ⚠️ 컴파일도 되고 IDE 도 조용하지만
return new JobBuilder("settlementJob")
.start(settlementStep)
.build();
결과
INFO 42155 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] launched with the following parameters: [{}]
ERROR 42155 --- [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalArgumentException: JobRepository must be set
at org.springframework.util.Assert.state(Assert.java:76)
at org.springframework.batch.core.job.AbstractJob.afterPropertiesSet(AbstractJob.java:141)
...
다행히 시끄럽게 실패합니다. AbstractJob.afterPropertiesSet() 이 검사하기 때문입니다. 이건 좋은 설계입니다.
💡 실무 팁 — 여러 DB 를 쓰는 배치에서 진짜 이득이 나옵니다
생성자로 넘기게 되면서 "이 Job 은 어떤 JobRepository 를 쓰는가"를 Job 마다 다르게 지정할 수 있습니다.
메타데이터 DB 를 업무 DB 와 분리한 환경(프로젝트 셋업 P-1 의 실무 구성)에서는 다음처럼 씁니다.
@Bean
public Job settlementJob(@Qualifier("metaJobRepository") JobRepository repo, Step s) {
return new JobBuilder("settlementJob", repo).start(s).build();
}
4.x 에서는 JobBuilderFactory 를 통째로 커스텀 빈으로 대체해야 했습니다. 지금은 @Qualifier 한 줄입니다.
2-5. StepBuilder 와 PlatformTransactionManager
Step 쪽 변화가 더 중요합니다. .tasklet(t) 에서 .tasklet(t, txManager) 로 바뀐 이유를 봅니다.
// Spring Batch 5.1 의 StepBuilder
public class StepBuilder extends StepBuilderHelper<StepBuilder> {
public StepBuilder(String name, JobRepository jobRepository) { ... }
public TaskletStepBuilder tasklet(Tasklet tasklet, PlatformTransactionManager txManager) { ... }
public <I, O> SimpleStepBuilder<I, O> chunk(int chunkSize, PlatformTransactionManager txManager) { ... }
public <I, O> SimpleStepBuilder<I, O> chunk(CompletionPolicy policy, PlatformTransactionManager txManager) { ... }
}
Step 은 트랜잭션 경계의 주인입니다. 청크 하나가 하나의 트랜잭션이고, 그 트랜잭션을 여는 것이 PlatformTransactionManager 입니다. 즉 트랜잭션 매니저는 Step 의 부수적 설정이 아니라 필수 구성요소인데, 4.x 는 그걸 "알아서 찾아 주는" 방식으로 숨겼습니다.
프로젝트 셋업에서 spring-boot-starter-data-jpa 를 넣었으므로 우리 컨텍스트에는 트랜잭션 매니저 후보가 있습니다. 무엇이 주입됐는지 확인해 봅니다.
@Bean
public Step whichTxStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("whichTxStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> txManager = " + txManager.getClass().getName());
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
결과
INFO 42203 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [whichTxStep]
>>> txManager = org.springframework.orm.jpa.JpaTransactionManager
INFO 42203 --- [ main] o.s.batch.core.job.SimpleStepHandler : Step: [whichTxStep] executed in 20ms
JpaTransactionManager 입니다. DataSourceTransactionManager 가 아닙니다. spring-boot-starter-data-jpa 가 있으면 JpaTransactionManager 가 우선 등록되기 때문입니다.
⚠️ 함정 — 트랜잭션 매니저가 둘인데 JDBC 쓰기가 커밋되지 않는다
JpaTransactionManager 는 EntityManager 의 트랜잭션을 관리합니다. 같은 DataSource 를 공유하므로 대개 JdbcTemplate 쓰기도 같은 커넥션·같은 트랜잭션에 참여합니다. 대개는요.
문제는 DataSource 가 둘 이상일 때입니다. JpaTransactionManager 는 A 커넥션의 트랜잭션을 열었는데 JdbcBatchItemWriter 는 B DataSource 를 쓰도록 설정돼 있으면, 쓰기는 자기 오토커밋으로 나가고 롤백에 참여하지 않습니다.
청크가 실패해 롤백돼도 이미 쓴 데이터는 남습니다. 예외는 정상적으로 나므로 로그만 보면 "실패했으니 롤백됐겠지"라고 믿게 됩니다. 그리고 재시작하면 같은 데이터가 한 번 더 들어갑니다.
5.0 이 트랜잭션 매니저를 인자로 승격시킨 이유가 정확히 이것입니다. "어느 트랜잭션 매니저인가"를 Step 마다 눈으로 확인하게 만든 것입니다.
JPA 를 쓰지 않는 순수 JDBC 배치라면 명시적으로 선택하세요.
@Bean
public Step jdbcOnlyStep(JobRepository jobRepository,
@Qualifier("jdbcTxManager") PlatformTransactionManager tx) { ... }
@Bean("jdbcTxManager")
public PlatformTransactionManager jdbcTxManager(DataSource ds) {
return new DataSourceTransactionManager(ds);
}
💡 트랜잭션이 필요 없는 Step 도 있습니다
파일 이동, API 호출, 알림 발송처럼 DB 를 안 건드리는 Tasklet 에도 PlatformTransactionManager 는 문법상 필수입니다.
그런 경우 ResourcelessTransactionManager 를 넘기면 실제 DB 트랜잭션을 열지 않아 커넥션을 낭비하지 않습니다.
.tasklet(notifyTasklet, new ResourcelessTransactionManager())
단, JobRepository 의 메타데이터 갱신은 여전히 진짜 DB 트랜잭션으로 나갑니다. 헷갈리지 마세요.
2-6. @EnableBatchProcessing — 붙이면 손해입니다
여기가 이 스텝의 핵심입니다.
Spring Boot 3 + Spring Batch 5 에서 @EnableBatchProcessing 은 필수가 아닙니다. 그런데 4.x 습관으로 붙이는 사람이 많고, 붙이면 조용히 나빠집니다.
Boot 의 BatchAutoConfiguration 선언을 보면 이유가 즉시 보입니다.
@AutoConfiguration(after = { HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class })
@ConditionalOnClass({ JobLauncher.class, DataSource.class, DatabasePopulator.class })
@ConditionalOnBean({ DataSource.class, PlatformTransactionManager.class })
@ConditionalOnMissingBean(value = DefaultBatchConfiguration.class,
annotation = EnableBatchProcessing.class) // ★
@EnableConfigurationProperties(BatchProperties.class)
public class BatchAutoConfiguration { ... }
★ 줄이 전부입니다. "@EnableBatchProcessing 이 붙은 빈이 있거나 DefaultBatchConfiguration 을 상속한 빈이 있으면, 이 자동설정은 통째로 물러난다."
BatchAutoConfiguration 이 물러나면 무엇이 없어지는지 표로 봅니다.
| 없어지는 것 | 결과 |
|---|
JobLauncherApplicationRunner | 부팅 시 Job 이 자동 실행되지 않음 |
BatchDataSourceScriptDatabaseInitializer | spring.batch.jdbc.initialize-schema 가 무시됨 → BATCH_* 테이블 미생성 |
spring.batch.job.name 처리 | 실행할 Job 지정 불가 |
spring.batch.jdbc.table-prefix 반영 | application.yml 설정이 안 먹음 |
BatchConversionServiceCustomizer | 커스텀 JobParameter 타입 변환 설정 무시 |
직접 재현합니다. @EnableBatchProcessing 을 아무 @Configuration 에나 붙입니다.
@Configuration
@EnableBatchProcessing // ⚠️ 이 한 줄만 추가
public class TrapConfig { }
메타데이터를 전부 지우고(P-10 (b) 볼륨 초기화) 다시 띄웁니다.
./gradlew bootRun --args='--spring.batch.job.name=settlementJob'
결과
INFO 42311 --- [ main] c.e.batch.BatchLabApplication : Starting BatchLabApplication using Java 21.0.2
INFO 42311 --- [ main] com.zaxxer.hikari.HikariDataSource : batch-pool - Starting...
INFO 42311 --- [ main] com.zaxxer.hikari.HikariDataSource : batch-pool - Start completed.
ERROR 42311 --- [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback;
bad SQL grammar [SELECT JOB_INSTANCE_ID, JOB_NAME from BATCH_JOB_INSTANCE where JOB_NAME = ? and JOB_KEY = ?];
Table 'batchdb.BATCH_JOB_INSTANCE' doesn't exist
테이블이 없다고 합니다. initialize-schema: always 를 분명히 써 뒀는데도 그렇습니다. 스키마 초기화 빈이 BatchAutoConfiguration 안에 있었기 때문입니다.
더 나쁜 시나리오를 봅니다. BATCH_* 테이블이 이미 존재하는 상태에서 @EnableBatchProcessing 을 붙이면:
./gradlew bootRun --args='--spring.batch.job.name=settlementJob'
결과
INFO 42355 --- [ main] c.e.batch.BatchLabApplication : Started BatchLabApplication in 1.877 seconds (process running for 2.144)
BUILD SUCCESSFUL in 4s
⚠️ 함정 — 에러도 없고, 로그도 깨끗하고, Job 만 안 돕니다
이것이 이 스텝에서 가장 위험한 상황입니다.
- 애플리케이션은 정상 기동합니다.
BUILD SUCCESSFUL 이고 종료 코드는 0 입니다.
Job: [...] launched 로그가 없습니다.
BATCH_JOB_EXECUTION 에 행이 안 늘어납니다.
스케줄러는 성공으로 봅니다. 알람도 안 옵니다. 정산이 안 돌았는데 아무도 모릅니다.
JobLauncherApplicationRunner 가 등록되지 않았으니 아무도 Job 을 실행하지 않은 것뿐인데, 그 사실을 알려 주는 로그가 한 줄도 없습니다.
탐지 방법: 로그에서 Job: [...] launched with the following parameters 줄이 있는지 확인하세요. 없으면 Job 이 안 돈 것입니다. 운영에서는 이 문자열을 로그 알림 조건으로 걸어 두는 것이 좋습니다.
검증 방법: 자동설정이 물러났는지 직접 확인할 수 있습니다.
./gradlew bootRun --args='--debug' 2>&1 | grep -A2 'BatchAutoConfiguration'
결과
BatchAutoConfiguration:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.batch.core.configuration.support.DefaultBatchConfiguration; annotations: org.springframework.batch.core.configuration.annotation.EnableBatchProcessing) found beans of annotation type EnableBatchProcessing 'trapConfig' (OnBeanCondition)
Did not match 와 원인 빈 이름(trapConfig)까지 나옵니다. --debug 로 자동설정 리포트를 읽는 습관은 Boot 를 쓰는 한 계속 쓸모 있습니다.
그럼 @EnableBatchProcessing 은 언제 쓰나
Spring Boot 를 안 쓰는 순수 Spring 프로젝트이거나, 배치 인프라 빈을 통째로 직접 구성하고 싶을 때입니다. 5.x 에서는 후자를 위해 더 나은 선택지가 생겼습니다.
| 방법 | 용도 | Boot 자동설정 |
|---|
| 아무것도 안 붙임 | Boot 3 + Batch 5 의 기본. 이 코스가 쓰는 방식 | 살아 있음 |
@EnableBatchProcessing(속성...) | 순수 Spring, 또는 tablePrefix/isolationLevelForCreate 등을 애너테이션으로 지정 | 꺼짐 |
extends DefaultBatchConfiguration | 인프라 빈을 메서드 오버라이드로 세밀 조정 | 꺼짐 |
@EnableBatchProcessing 은 5.0 에서 속성이 늘었습니다. 참고로 적어 둡니다.
@EnableBatchProcessing(
dataSourceRef = "batchDataSource",
transactionManagerRef = "batchTxManager",
tablePrefix = "BATCH_",
isolationLevelForCreate = "ISOLATION_SERIALIZABLE",
taskExecutorRef = "batchTaskExecutor"
)
하지만 Boot 를 쓴다면 이 값들은 전부 application.yml 로 설정 가능합니다. 애너테이션을 붙여 자동설정을 끄고, 그 대가로 러너와 스키마 초기화를 직접 만드는 것은 손해입니다.
2-7. 여러 Step 을 이어 붙이기
SimpleJob 은 Step 을 등록된 순서대로 실행합니다.
@Bean
public Job pipelineJob(JobRepository jobRepository,
Step extractStep, Step transformStep, Step loadStep) {
return new JobBuilder("pipelineJob", jobRepository)
.start(extractStep)
.next(transformStep)
.next(loadStep)
.build();
}
결과
INFO 42402 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=pipelineJob]] launched with the following parameters: [{}]
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [extractStep]
>>> extract : orders 에서 COMPLETED 70000건 확인
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Step: [extractStep] executed in 34ms
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [transformStep]
>>> transform : 수수료율 적용 규칙 로드
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Step: [transformStep] executed in 16ms
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [loadStep]
>>> load : settlement 적재 준비 완료
INFO 42402 --- [ main] o.s.batch.core.job.SimpleStepHandler : Step: [loadStep] executed in 17ms
INFO 42402 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=pipelineJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 106ms
SELECT STEP_EXECUTION_ID, STEP_NAME, STATUS, COMMIT_COUNT, EXIT_CODE
FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY STEP_EXECUTION_ID;
결과
+-------------------+---------------+-----------+--------------+-----------+
| STEP_EXECUTION_ID | STEP_NAME | STATUS | COMMIT_COUNT | EXIT_CODE |
+-------------------+---------------+-----------+--------------+-----------+
| 1 | extractStep | COMPLETED | 1 | COMPLETED |
| 2 | transformStep | COMPLETED | 1 | COMPLETED |
| 3 | loadStep | COMPLETED | 1 | COMPLETED |
+-------------------+---------------+-----------+--------------+-----------+
JobExecution 은 1개, StepExecution 은 3개입니다. Job 소요 106ms 는 각 Step(34+16+17=67ms)에 메타데이터 갱신 시간이 더해진 값입니다.
.next() 는 앞 Step 이 COMPLETED 일 때만 다음으로 갑니다. transformStep 이 실패하면:
결과
INFO 42451 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [extractStep]
INFO 42451 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [transformStep]
ERROR 42451 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step transformStep in job pipelineJob
INFO 42451 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=pipelineJob]] completed with the following parameters: [{}] and the following status: [FAILED] in 71ms
loadStep 의 Executing step: 줄이 아예 없습니다. 실행되지 않았고, BATCH_STEP_EXECUTION 에 행도 안 생깁니다.
| 상황 | .next() 의 동작 |
|---|
| 앞 Step COMPLETED | 다음 Step 실행 |
| 앞 Step FAILED | Job 을 FAILED 로 끝냄. 다음 Step 미실행 |
| 앞 Step 이 커스텀 ExitStatus 반환 | .next() 는 무시합니다. 분기하려면 .on(...).to(...) (Step 10) |
세 번째 줄이 중요합니다. Step 01 에서 만든 NO_DATA ExitStatus 는 .next() 체인에서는 아무 영향이 없습니다. BatchStatus 가 COMPLETED 이면 그냥 다음으로 갑니다. "ExitStatus 를 바꿨는데 왜 흐름이 안 바뀌지?" 의 답입니다.
2-8. Step 빈은 싱글턴입니다
@Bean 으로 만든 Step 은 싱글턴입니다. 그런데 StepExecution 은 실행마다 새로 만들어집니다. 이 둘의 수명 차이가 사고를 만듭니다.
// ⚠️ 위험한 코드 — 컴파일도 되고 한 번은 잘 돕니다
@Configuration
public class StatefulStepConfig {
private int counter = 0; // 설정 클래스의 필드
@Bean
public Step statefulStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("statefulStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
counter++;
System.out.println(">>> counter = " + counter);
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
같은 JVM 에서 Job 을 두 번 실행하면(파라미터를 달리해서):
결과
--- 1회차 ---
>>> counter = 1
--- 2회차 (같은 프로세스, 파라미터만 다름) ---
>>> counter = 2
counter 가 1 로 초기화되지 않습니다. 람다가 캡처한 것은 설정 클래스 인스턴스이고, 그것은 싱글턴이기 때문입니다.
⚠️ 함정 — 로컬에서는 절대 안 재현되는 버그
로컬에서는 bootRun 한 번에 Job 한 번만 돌고 JVM 이 죽습니다. counter 는 항상 1 입니다. 테스트도 통과합니다.
그런데 운영에서 스케줄러가 같은 JVM 안에서 하루에 24번 Job 을 돌리면(Quartz 방식, Step 14) counter 가 계속 누적됩니다.
"왜 오후 배치만 결과가 이상하지?" 가 됩니다. 재현이 안 되니 원인을 찾는 데 며칠이 걸립니다.
상태를 두어야 한다면 자리는 두 곳뿐입니다.
StepExecution / JobExecution 의 ExecutionContext — 재시작 시 복원까지 됩니다 (Step 09)
@StepScope / @JobScope 빈 — 실행마다 새로 만들어집니다 (Step 03)
올바른 버전:
.tasklet((contribution, chunkContext) -> {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
long counter = ctx.getLong("counter", 0L) + 1;
ctx.putLong("counter", counter);
System.out.println(">>> counter = " + counter);
return RepeatStatus.FINISHED;
}, txManager)
이러면 실행마다 0 부터 시작하고, 값이 BATCH_STEP_EXECUTION_CONTEXT 에 저장되어 재시작 시 이어집니다.
2-9. 빌더가 제공하는 나머지 옵션
자주 쓰는 것만 정리합니다. 각각 어느 스텝에서 본격적으로 다루는지 표시했습니다.
JobBuilder
| 메서드 | 설명 | 스텝 |
|---|
.start(Step) | 첫 Step. SimpleJob 을 만듭니다 | 02 |
.next(Step) | 다음 Step | 02 |
.start(Flow) / .on(...).to(...) | 조건 분기. FlowJob 을 만듭니다 | 10 |
.incrementer(JobParametersIncrementer) | 실행마다 파라미터 자동 증가 | 03 |
.validator(JobParametersValidator) | 파라미터 검증 | 03 |
.listener(JobExecutionListener) | Job 전후 훅 | 12 |
.preventRestart() | 재시작 자체를 금지 | 11 |
StepBuilder
| 메서드 | 설명 | 스텝 |
|---|
.tasklet(Tasklet, PlatformTransactionManager) | Tasklet Step | 04 |
.<I,O>chunk(int, PlatformTransactionManager) | 청크 Step | 05 |
.listener(...) | Step/Chunk/Item 리스너 | 12 |
.allowStartIfComplete(true) | 이미 성공한 Step 도 재실행 | 11 |
.startLimit(int) | 이 Step 의 최대 시도 횟수(기본 Integer.MAX_VALUE) | 11 |
.faultTolerant() | skip / retry 설정 진입 | 11 |
.taskExecutor(...) | 멀티스레드 Step | 13 |
💡 .allowStartIfComplete(true) 는 신중하게
Step 01 의 "이미 COMPLETED 인 Step 은 건너뛴다"를 무력화하는 옵션입니다. 파일 정리·임시 테이블 truncate 처럼 몇 번 실행해도 결과가 같은(멱등) Step 에만 쓰세요.
정산처럼 실행할 때마다 결과가 쌓이는 Step 에 붙이면 재시작할 때마다 정산이 중복됩니다. 에러 없이요.
2-10. 마이그레이션 체크리스트
4.x → 5.1 로 실제 프로젝트를 옮길 때 순서대로 확인할 목록입니다.
| # | 항목 | 확인 방법 | 증상 |
|---|
| 1 | JobBuilderFactory / StepBuilderFactory 제거 | grep -rn 'BuilderFactory' src/ | 컴파일 에러 (시끄러움) |
| 2 | .tasklet(t) → .tasklet(t, tx) | 컴파일러가 잡아 줌 | 컴파일 에러 |
| 3 | .chunk(n) → .chunk(n, tx) | 컴파일러가 잡아 줌 | 컴파일 에러 |
| 4 | @EnableBatchProcessing 제거 | grep -rn 'EnableBatchProcessing' src/ | 조용히 Job 미실행 ★ |
| 5 | javax.* → jakarta.* | grep -rn 'javax\.persistence|javax\.sql' src/ | 컴파일 에러 |
| 6 | ItemWriter.write(List) → write(Chunk) | 컴파일 에러 | Step 08 |
| 7 | JobParameter 타입 파라미터화 | 컴파일 에러 | Step 03 |
| 8 | BATCH_JOB_EXECUTION_PARAMS 스키마 변경 | DESC BATCH_JOB_EXECUTION_PARAMS; | 런타임 SQL 에러 |
| 9 | BATCH_JOB_EXECUTION.JOB_CONFIGURATION_LOCATION 제거 | SHOW COLUMNS FROM BATCH_JOB_EXECUTION; | 커스텀 쿼리 깨짐 |
| 10 | 트랜잭션 매니저가 의도한 것인지 | Step 에서 txManager.getClass() 출력 | 조용히 롤백 안 됨 ★ |
★ 표시된 4번과 10번이 컴파일러가 안 잡아 주는 항목입니다. 나머지는 빌드가 깨져서 알게 되지만, 이 둘은 배포 후에 알게 됩니다.
기존 메타데이터를 그대로 쓰는 경우 8번의 마이그레이션 DDL 은 이렇습니다.
-- 4.x → 5.x 파라미터 테이블 마이그레이션 (기존 데이터를 버려도 된다면 DROP/CREATE 가 간단합니다)
ALTER TABLE BATCH_JOB_EXECUTION_PARAMS
DROP COLUMN TYPE_CD,
DROP COLUMN DATE_VAL,
DROP COLUMN LONG_VAL,
DROP COLUMN DOUBLE_VAL,
CHANGE COLUMN KEY_NAME PARAMETER_NAME VARCHAR(100) NOT NULL,
CHANGE COLUMN STRING_VAL PARAMETER_VALUE VARCHAR(2500),
ADD COLUMN PARAMETER_TYPE VARCHAR(100) NOT NULL AFTER PARAMETER_NAME;
ALTER TABLE BATCH_JOB_EXECUTION DROP COLUMN JOB_CONFIGURATION_LOCATION;
결과
Query OK, 0 rows affected (0.09 sec)
Records: 0 Duplicates: 0 Warnings: 0
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0
⚠️ 함정 — 마이그레이션 후 옛 JOB_KEY 는 재계산되지 않습니다
파라미터 저장 방식이 바뀌었어도 BATCH_JOB_INSTANCE.JOB_KEY 는 과거에 계산된 값 그대로 남아 있습니다. 5.x 의 해시 계산 로직은 파라미터 타입을 포함하므로, 같은 파라미터로 다시 돌려도 다른 JOB_KEY 가 나올 수 있습니다.
즉 마이그레이션 직후 첫 실행에서 "이미 성공한 Job 인데 새 JobInstance 로 다시 실행"되는 일이 벌어집니다.
정산 배치라면 중복 정산입니다. 마이그레이션 전에 진행 중인 Job 을 모두 마무리하고, 첫 실행은 반드시 사람이 지켜보세요.
정리
| 개념 | 핵심 |
|---|
| 계층 | Job → Step → Tasklet → (청크면 ItemReader/Processor/Writer) |
| 청크 Step 의 정체 | ChunkOrientedTasklet 을 품은 TaskletStep. 청크 1개 = 트랜잭션 1개 |
JobBuilderFactory | 5.0 deprecated → 5.1 삭제. new JobBuilder(name, jobRepository) |
StepBuilderFactory | 동일. new StepBuilder(name, jobRepository) |
| 팩토리를 없앤 이유 | 숨은 의존성 제거, Job 별 JobRepository 지정 가능 |
.tasklet(t, tx) | 트랜잭션 매니저가 선택이 아니라 인자로 승격 |
| 트랜잭션 매니저 확인 | JPA 스타터가 있으면 JpaTransactionManager 가 잡힘 |
@EnableBatchProcessing | 붙이면 BatchAutoConfiguration 이 통째로 물러남 |
| 물러나면 없어지는 것 | 러너(=Job 자동실행), 스키마 초기화, spring.batch.* 설정 반영 |
| 최악의 증상 | 에러 없이, 종료코드 0 으로, Job 만 안 도는 것 |
| 진단 도구 | --debug 자동설정 리포트의 Did not match |
.next() | 앞 Step 이 COMPLETED 일 때만 진행. ExitStatus 분기는 못 함 |
| Step 빈 | 싱글턴. 설정 클래스 필드에 상태를 두면 실행 간에 누적됨 |
| 상태를 둘 곳 | ExecutionContext 또는 @StepScope/@JobScope |
| 마이그레이션 위험 항목 | @EnableBatchProcessing 잔존, 트랜잭션 매니저 오지정 (둘 다 컴파일러가 못 잡음) |
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.
- 주어진 4.x 설정 클래스를 5.1 로 마이그레이션하기 (
JobBuilderFactory + @EnableBatchProcessing + .tasklet(t) 전부 포함)
new JobBuilder("x") 만 쓴 Job 이 왜 실패하는지 예측하고, 예외 메시지와 발생 지점을 적기
@EnableBatchProcessing 을 붙였을 때 사라지는 빈 3개를 나열하고, 각각이 사라지면 어떤 증상이 나오는지 매칭
- Step 세 개를
.next() 로 잇되, 두 번째 Step 이 ExitStatus("WARN") 을 반환해도 세 번째가 실행되는지 확인하고 이유 적기
- 설정 클래스 필드에 상태를 둔 Step 을 ExecutionContext 기반으로 고치기
- Step 이 어떤
PlatformTransactionManager 를 쓰는지 출력하고, ResourcelessTransactionManager 로 바꿨을 때 메타데이터 갱신은 어떻게 되는지 확인
다음 단계
Job 과 Step 을 5.x 방식으로 만들 수 있게 됐습니다. 하지만 지금까지 만든 Job 은 전부 파라미터가 없어서 딱 한 번밖에 성공할 수 없습니다.
다음 스텝에서는 JobParameters 를 다룹니다. identifying / non-identifying 의 구분, 5.0 에서 타입 파라미터화된 JobParameter<T>, @StepScope 와 #{jobParameters['...']} 의 지연 바인딩, 그리고 RunIdIncrementer 를 무심코 붙였다가 날짜별 정산이 중복 실행되는 사고를 다룹니다.
→ Step 03 — JobParameters
실습 파일
이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 에는 4.x 코드(주석 처리)와 5.x 코드가 나란히 들어 있어 diff 를 눈으로 보게 되어 있고, Exercise.java 의 6문제로 마이그레이션을 손으로 해 본 뒤, Solution.java 로 대조합니다. 세 파일 모두 com.example.batch.step02 패키지이며 static class 중첩을 씁니다.
Practice.java
본문 2-2 ~ 2-9 의 모든 예제를 절 번호 주석과 함께 모아 둔 파일입니다.
[2-2] LegacyJobConfig 는 통째로 주석 처리되어 있습니다. Spring Batch 5.1 에서는 JobBuilderFactory 타입 자체가 없어 컴파일이 안 되기 때문입니다. 주석을 풀면 빌드가 깨지는 것이 정상이며, 그것을 확인하는 것도 실습의 일부입니다. [2-3] SettlementJobConfig 가 같은 설정의 5.1 판이므로 두 블록을 위아래로 놓고 비교하세요.
[2-5] WhichTxJobConfig 는 주입된 PlatformTransactionManager 의 실제 클래스명을 출력합니다. 이 프로젝트는 spring-boot-starter-data-jpa 를 포함하므로 org.springframework.orm.jpa.JpaTransactionManager 가 나와야 정상입니다. DataSourceTransactionManager 가 나온다면 JPA 스타터가 빠진 것이고, 그 상태로 Step 06 의 JpaPagingItemReader 실습이 안 됩니다.
[2-6] TrapConfig 는 기본적으로 주석 처리되어 있습니다. 주석을 풀면 @EnableBatchProcessing 이 활성화되어 이 프로젝트의 모든 Job 이 부팅 시 실행되지 않게 됩니다. 함정을 재현했다면 반드시 다시 주석으로 되돌리세요. 되돌리는 것을 잊고 Step 03 으로 넘어가면 "왜 아무것도 안 돌지?" 로 한참 헤매게 됩니다. 이 파일에서 가장 주의할 부분입니다.
[2-8] StatefulStepConfig 와 [2-8'] StatefulFixedConfig 는 같은 기능을 잘못된 방식(설정 클래스 필드)과 올바른 방식(ExecutionContext)으로 구현한 쌍입니다. 같은 JVM 에서 두 번 실행해야 차이가 드러납니다. bootRun 은 매번 새 JVM 이라 차이가 안 보이므로, 파일 주석에 적어 둔 --spring.batch.job.name 없이 두 Job 을 함께 돌리는 방법이나 Step 14 의 Quartz 실습에서 다시 확인하세요.
package com.example.batch.step02;
/*
* ============================================================================
* Step 02 — Job 과 Step 의 구조 : 본문 예제 모음
* ============================================================================
*
* 실행
* ./gradlew bootRun --args='--spring.batch.job.name=settlementJob'
* ./gradlew bootRun --args='--spring.batch.job.name=whichTxJob'
* ./gradlew bootRun --args='--spring.batch.job.name=pipelineJob'
* ./gradlew bootRun --args='--spring.batch.job.name=statefulJob'
* ./gradlew bootRun --args='--spring.batch.job.name=statefulFixedJob'
*
* ⚠️ 이 파일에서 가장 주의할 것
* [2-6] TrapConfig 의 주석을 풀면 @EnableBatchProcessing 이 켜져
* BatchAutoConfiguration 이 통째로 물러나고 "모든 Job 이 부팅 시 실행되지 않습니다".
* 함정을 재현한 뒤에는 반드시 다시 주석 처리하세요.
* ============================================================================
*/
import org.springframework.batch.core.ExitStatus;
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.ExecutionContext;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
public final class Practice {
private Practice() {
}
// =========================================================================
// [2-2] Spring Batch 4.3 의 방식 — 5.1 에서는 컴파일되지 않습니다
// -------------------------------------------------------------------------
// 주석을 풀면 다음 에러가 납니다. 그것을 확인하는 것도 실습입니다.
//
// error: cannot find symbol
// symbol: class JobBuilderFactory
// symbol: class StepBuilderFactory
//
// 확인했으면 다시 주석으로 되돌리세요.
// =========================================================================
/*
@Configuration
@EnableBatchProcessing // (1) 4.x 에서는 필수
public static class LegacySettlementJobConfig {
private final JobBuilderFactory jobBuilderFactory; // (2) 팩토리 필드
private final StepBuilderFactory stepBuilderFactory; // (3) 팩토리 필드
public LegacySettlementJobConfig(JobBuilderFactory jbf, StepBuilderFactory sbf) {
this.jobBuilderFactory = jbf;
this.stepBuilderFactory = sbf;
}
@Bean
public Job settlementJob() {
return jobBuilderFactory.get("settlementJob") // (4) get(name)
.start(settlementStep()) // (5) 메서드 직접 호출
.build();
}
@Bean
public Step settlementStep() {
return stepBuilderFactory.get("settlementStep") // (6) get(name)
.tasklet((contribution, chunkContext) -> {
System.out.println("정산 중...");
return RepeatStatus.FINISHED;
}) // (7) 트랜잭션 매니저 없음
.build();
}
}
*/
// =========================================================================
// [2-3] 같은 설정의 Spring Batch 5.1 판 — 위 블록과 나란히 놓고 비교하세요
// -------------------------------------------------------------------------
// (1) @EnableBatchProcessing 삭제 → Boot 자동설정이 대신
// (2)(3) 팩토리 필드 삭제 → JobRepository 를 파라미터로
// (4)(6) get(name) → new XxxBuilder(name, jobRepository)
// (5) 메서드 직접 호출 → Step 을 파라미터로 주입
// (7) .tasklet(t) → .tasklet(t, txManager)
// =========================================================================
@Configuration
public static class SettlementJobConfig {
@Bean
public Job settlementJob(JobRepository jobRepository, Step settlementStep) {
return new JobBuilder("settlementJob", jobRepository)
.start(settlementStep)
.build();
}
@Bean
public Step settlementStep(JobRepository jobRepository,
PlatformTransactionManager txManager) {
return new StepBuilder("settlementStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("정산 중...");
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
// =========================================================================
// [2-4] JobRepository 없이 만들면? — 시끄럽게 실패합니다
// -------------------------------------------------------------------------
// 주석을 풀고 실행하면 빈 초기화 시점에 다음이 납니다.
//
// java.lang.IllegalArgumentException: JobRepository must be set
// at org.springframework.batch.core.job.AbstractJob.afterPropertiesSet(...)
//
// Job 이 "잘못 도는" 것이 아니라 "아예 못 뜨는" 것이므로 좋은 실패입니다.
// =========================================================================
/*
@Configuration
public static class NoRepositoryJobConfig {
@Bean
public Job brokenJob(Step settlementStep) {
return new JobBuilder("brokenJob") // deprecated 생성자 — JobRepository 없음
.start(settlementStep)
.build();
}
}
*/
// =========================================================================
// [2-5] 주입된 PlatformTransactionManager 가 무엇인지 눈으로 확인
// -------------------------------------------------------------------------
// 이 프로젝트는 spring-boot-starter-data-jpa 를 포함하므로 결과는
// >>> txManager = org.springframework.orm.jpa.JpaTransactionManager
// 입니다. DataSourceTransactionManager 가 나오면 JPA 스타터가 빠진 것입니다.
//
// 왜 확인해야 하는가: DataSource 가 둘 이상인 프로젝트에서 Step 의 트랜잭션과
// ItemWriter 가 쓰는 커넥션이 어긋나면, 청크가 롤백돼도 쓰기가 남습니다.
// 예외는 정상적으로 나므로 로그만 보면 롤백된 줄 압니다.
// =========================================================================
@Configuration
public static class WhichTxJobConfig {
@Bean
public Job whichTxJob(JobRepository jobRepository, Step whichTxStep) {
return new JobBuilder("whichTxJob", jobRepository).start(whichTxStep).build();
}
@Bean
public Step whichTxStep(JobRepository jobRepository,
PlatformTransactionManager txManager) {
return new StepBuilder("whichTxStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> txManager = " + txManager.getClass().getName());
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
// =========================================================================
// [2-5'] DB 를 건드리지 않는 Step — ResourcelessTransactionManager
// -------------------------------------------------------------------------
// 알림 발송, 파일 이동 같은 Tasklet 에 진짜 DB 트랜잭션을 열 이유가 없습니다.
// 단, JobRepository 의 메타데이터 갱신은 여전히 진짜 DB 트랜잭션으로 나갑니다.
// (JobRepository 는 자기 트랜잭션 매니저를 별도로 갖고 있습니다)
// =========================================================================
@Configuration
public static class ResourcelessJobConfig {
@Bean
public Job notifyJob(JobRepository jobRepository, Step notifyStep) {
return new JobBuilder("notifyJob", jobRepository).start(notifyStep).build();
}
@Bean
public Step notifyStep(JobRepository jobRepository) {
return new StepBuilder("notifyStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> 정산 완료 알림 발송 (DB 미사용)");
return RepeatStatus.FINISHED;
}, new ResourcelessTransactionManager())
.build();
}
}
// =========================================================================
// [2-6] ★ 함정 재현용 ★ @EnableBatchProcessing
// -------------------------------------------------------------------------
// 주석을 풀면:
// - BatchAutoConfiguration 이 통째로 물러납니다.
// - JobLauncherApplicationRunner 가 없어져 "모든 Job 이 실행되지 않습니다".
// - BATCH_* 테이블이 없는 상태라면 BadSqlGrammarException 으로 시끄럽게 죽고,
// 테이블이 이미 있다면 아무 에러 없이 종료코드 0 으로 조용히 끝납니다. ← 최악
//
// 확인 방법:
// ./gradlew bootRun --args='--debug' 2>&1 | grep -A2 'BatchAutoConfiguration'
// → "Did not match: @ConditionalOnMissingBean ... found beans of annotation
// type EnableBatchProcessing 'trapConfig'"
//
// ⚠️ 재현이 끝나면 반드시 다시 주석 처리하세요.
// =========================================================================
/*
@Configuration
@EnableBatchProcessing
public static class TrapConfig {
}
*/
// =========================================================================
// [2-7] Step 세 개를 .next() 로 잇기
// -------------------------------------------------------------------------
// 로그에서 "Executing step:" 이 3번 나와야 정상입니다.
// BATCH_JOB_EXECUTION 은 1행, BATCH_STEP_EXECUTION 은 3행입니다.
//
// ⚠️ transformStep 이 ExitStatus("WARN") 을 반환해도 loadStep 은 실행됩니다.
// .next() 는 ExitStatus 가 아니라 BatchStatus 를 보기 때문입니다.
// ExitStatus 로 분기하려면 Step 10 의 .on("WARN").to(...) 가 필요합니다.
// =========================================================================
@Configuration
public static class PipelineJobConfig {
@Bean
public Job pipelineJob(JobRepository jobRepository,
Step extractStep, Step transformStep, Step loadStep) {
return new JobBuilder("pipelineJob", jobRepository)
.start(extractStep)
.next(transformStep)
.next(loadStep)
.build();
}
@Bean
public Step extractStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("extractStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> extract : orders 에서 COMPLETED 70000건 확인");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
public Step transformStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("transformStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> transform : 수수료율 적용 규칙 로드");
// ExitStatus 를 바꿔도 .next() 흐름은 그대로 진행됩니다.
contribution.setExitStatus(new ExitStatus("WARN", "환율 테이블이 어제 것입니다"));
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
public Step loadStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("loadStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> load : settlement 적재 준비 완료");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// [2-8] ⚠️ 잘못된 방식 — 설정 클래스 필드에 상태를 둔다
// -------------------------------------------------------------------------
// Step 빈은 싱글턴입니다. 람다가 캡처한 것은 이 설정 클래스 인스턴스이고,
// 그것은 JVM 이 살아 있는 동안 하나뿐입니다.
//
// 로컬에서는 bootRun 한 번 = JVM 한 번이라 counter 가 항상 1 입니다. 절대 재현 안 됩니다.
// 운영에서 Quartz 로 같은 JVM 안에서 하루 24번 돌리면 counter 가 24까지 올라갑니다.
// =========================================================================
@Configuration
public static class StatefulStepConfig {
private int counter = 0;
@Bean
public Job statefulJob(JobRepository jobRepository, Step statefulStep) {
return new JobBuilder("statefulJob", jobRepository).start(statefulStep).build();
}
@Bean
public Step statefulStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("statefulStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
counter++;
System.out.println(">>> [BAD] counter = " + counter);
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// [2-8'] ✅ 올바른 방식 — ExecutionContext 에 둔다
// -------------------------------------------------------------------------
// 실행마다 0 부터 시작하고, 값이 BATCH_STEP_EXECUTION_CONTEXT 에 저장되어
// 재시작 시 이어집니다.
//
// SELECT STEP_EXECUTION_ID, SHORT_CONTEXT FROM BATCH_STEP_EXECUTION_CONTEXT;
// → {"@class":"java.util.HashMap","counter":1, ...}
// =========================================================================
@Configuration
public static class StatefulFixedConfig {
@Bean
public Job statefulFixedJob(JobRepository jobRepository, Step statefulFixedStep) {
return new JobBuilder("statefulFixedJob", jobRepository)
.start(statefulFixedStep)
.build();
}
@Bean
public Step statefulFixedStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("statefulFixedStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
long counter = ctx.getLong("counter", 0L) + 1;
ctx.putLong("counter", counter);
System.out.println(">>> [GOOD] counter = " + counter);
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// [2-10] 마이그레이션 확인용 명령/SQL 모음
// =========================================================================
public static final String MIGRATION_CHECKS = """
# 1. 삭제된 팩토리가 남아 있는가 (컴파일러가 잡아 주지만 먼저 확인)
grep -rn 'JobBuilderFactory\\|StepBuilderFactory' src/
# 2. ★ 컴파일러가 못 잡는 항목 — 반드시 grep 할 것
grep -rn 'EnableBatchProcessing' src/
# 3. jakarta 전환 누락
grep -rn 'javax\\.persistence\\|javax\\.annotation' src/
# 4. 자동설정이 살아 있는지 확인
./gradlew bootRun --args='--debug' 2>&1 | grep -A2 'BatchAutoConfiguration'
-- 5. 5.x 스키마인지 확인 (PARAMETER_NAME/TYPE/VALUE 3컬럼이어야 함)
DESC BATCH_JOB_EXECUTION_PARAMS;
-- 6. 4.x 잔재 컬럼이 남아 있는지
SHOW COLUMNS FROM BATCH_JOB_EXECUTION LIKE 'JOB_CONFIGURATION_LOCATION';
-- 7. 4.x → 5.x 파라미터 테이블 마이그레이션 DDL
ALTER TABLE BATCH_JOB_EXECUTION_PARAMS
DROP COLUMN TYPE_CD,
DROP COLUMN DATE_VAL,
DROP COLUMN LONG_VAL,
DROP COLUMN DOUBLE_VAL,
CHANGE COLUMN KEY_NAME PARAMETER_NAME VARCHAR(100) NOT NULL,
CHANGE COLUMN STRING_VAL PARAMETER_VALUE VARCHAR(2500),
ADD COLUMN PARAMETER_TYPE VARCHAR(100) NOT NULL AFTER PARAMETER_NAME;
ALTER TABLE BATCH_JOB_EXECUTION DROP COLUMN JOB_CONFIGURATION_LOCATION;
-- 8. Step 실행 이력 확인
SELECT STEP_EXECUTION_ID, STEP_NAME, STATUS, COMMIT_COUNT, EXIT_CODE
FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY STEP_EXECUTION_ID;
""";
}
Exercise.java
6문제의 문제지입니다. // 여기에 작성: 자리를 채우면 됩니다.
- 문제 1이 이 스텝의 본체입니다. 4.x 설정 클래스 원본이 주석 블록으로 통째로 들어 있고, 그 아래 빈 5.x 클래스를 채우는 구조입니다. 치환해야 할 곳이 다섯 군데(애너테이션, 필드 2개,
get() 2개, .tasklet() 인자)이며, 그중 하나는 컴파일러가 안 잡아 줍니다. 어느 것인지 찾는 것이 목적입니다.
- 문제 2는 코드를 실행하기 전에 예외 클래스명과 발생 지점을 먼저 적으라고 요구합니다.
IllegalStateException 인지 NullPointerException 인지, 빈 생성 시점인지 Job 실행 시점인지를 예측해 보세요. Spring Batch 가 이 실수를 "시끄럽게" 잡아 주는지 확인하는 문제입니다.
- 문제 4는 함정입니다.
ExitStatus("WARN") 을 반환해도 .next() 는 그냥 다음 Step 을 실행합니다. 예측을 적고 실행해서 확인한 뒤, .next() 가 무엇을 보고 판단하는지 한 문장으로 정리하세요.
- 문제 6은
ResourcelessTransactionManager 로 바꿔도 BATCH_STEP_EXECUTION 에 행이 정상적으로 남는지 확인하는 문제입니다. "트랜잭션 매니저를 리소스 없는 것으로 바꿨으니 메타데이터도 안 남겠지"라는 예상이 틀리는 이유를 설명해야 합니다.
package com.example.batch.step02;
/*
* ============================================================================
* Step 02 — 연습문제 (6문제)
* ============================================================================
*
* 규칙
* - "// 여기에 작성:" 자리를 채웁니다.
* - 예측을 요구하는 문제는 반드시 먼저 적고 나서 실행하세요.
* 예측이 틀린 지점이 이 스텝의 학습 포인트입니다.
* - 정답은 Solution.java.
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
public final class Exercise {
private Exercise() {
}
// =========================================================================
// 문제 1. 4.x 설정 클래스를 5.1 로 마이그레이션하기 ★ 이 스텝의 본체
// -------------------------------------------------------------------------
// 아래 주석 블록이 Spring Batch 4.3 기준 원본입니다.
// 그 아래 Ex1Config 를 5.1 방식으로 완성하세요.
//
// 치환해야 할 곳이 다섯 군데입니다. 그중 "컴파일러가 안 잡아 주는 것"이 하나 있습니다.
// 어느 것인지 찾아서 아래에 적으세요.
//
// 컴파일러가 안 잡아 주는 항목:
// →
// 그것을 놓치면 나타나는 증상:
// →
//
// ── 원본 (4.3) ──────────────────────────────────────────────────────────
//
// @Configuration
// @EnableBatchProcessing
// public class DailyCloseJobConfig {
//
// private final JobBuilderFactory jobBuilderFactory;
// private final StepBuilderFactory stepBuilderFactory;
//
// public DailyCloseJobConfig(JobBuilderFactory jbf, StepBuilderFactory sbf) {
// this.jobBuilderFactory = jbf;
// this.stepBuilderFactory = sbf;
// }
//
// @Bean
// public Job dailyCloseJob() {
// return jobBuilderFactory.get("dailyCloseJob")
// .start(closeStep())
// .next(archiveStep())
// .build();
// }
//
// @Bean
// public Step closeStep() {
// return stepBuilderFactory.get("closeStep")
// .tasklet((contribution, chunkContext) -> {
// System.out.println("일마감 처리");
// return RepeatStatus.FINISHED;
// })
// .build();
// }
//
// @Bean
// public Step archiveStep() {
// return stepBuilderFactory.get("archiveStep")
// .tasklet((contribution, chunkContext) -> {
// System.out.println("아카이브");
// return RepeatStatus.FINISHED;
// })
// .build();
// }
// }
// ────────────────────────────────────────────────────────────────────────
// =========================================================================
@Configuration
public static class Ex1Config {
// 여기에 작성: dailyCloseJob (closeStep → archiveStep)
// 여기에 작성: closeStep
// 여기에 작성: archiveStep
}
// =========================================================================
// 문제 2. JobRepository 없는 JobBuilder
// -------------------------------------------------------------------------
// new JobBuilder("ex2Job") ← deprecated 생성자, JobRepository 없음
// .start(someStep)
// .build();
//
// ★ 실행하기 전에 예측을 적으세요.
//
// (a) 컴파일은 되는가? →
// (b) 예외가 난다면 클래스명은? →
// (c) 예외 메시지는? →
// (d) 발생 시점은 "빈 초기화" 인가 "Job 실행" 인가? →
// (e) 이 실패는 시끄러운 실패인가 조용한 실패인가? →
//
// 확인한 뒤 아래에 실제 결과를 적으세요.
// 실제:
// →
// =========================================================================
// =========================================================================
// 문제 3. @EnableBatchProcessing 을 붙이면 사라지는 빈 3개
// -------------------------------------------------------------------------
// Practice.java 의 [2-6] TrapConfig 주석을 풀고 아래를 실행해 보세요.
//
// ./gradlew bootRun --args='--debug' 2>&1 | grep -A2 'BatchAutoConfiguration'
//
// 사라지는 빈과 그 증상을 매칭하세요.
//
// (1) 빈: ____________________________________
// 증상: ______________________________________________________
//
// (2) 빈: ____________________________________
// 증상: ______________________________________________________
//
// (3) 설정 바인딩: ___________________________
// 증상: ______________________________________________________
//
// 세 증상 중 "조용한" 것은 몇 번인가? →
// 그것을 탐지하려면 로그에서 무슨 문자열을 찾아야 하나? →
//
// ⚠️ 확인이 끝나면 TrapConfig 를 반드시 다시 주석 처리하세요.
// =========================================================================
// =========================================================================
// 문제 4. ExitStatus 를 바꿔도 .next() 는 진행되는가
// -------------------------------------------------------------------------
// 요구사항
// - Job 이름: ex4Job
// - Step 3개: ex4StepA → ex4StepB → ex4StepC
// - ex4StepB 는 contribution.setExitStatus(new ExitStatus("WARN", "..."))
// 를 호출한 뒤 정상 종료한다.
//
// ★ 실행 전 예측
// ex4StepC 는 실행되는가? →
// 그 이유는? →
//
// 실행 후 확인
// SELECT STEP_NAME, STATUS, EXIT_CODE FROM BATCH_STEP_EXECUTION
// WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION);
//
// 실제 결과와, .next() 가 무엇을 보고 판단하는지 한 문장으로 정리하세요.
// →
// =========================================================================
@Configuration
public static class Ex4Config {
@Bean
public Job ex4Job(JobRepository jobRepository,
Step ex4StepA, Step ex4StepB, Step ex4StepC) {
// 여기에 작성:
return null;
}
@Bean
public Step ex4StepA(JobRepository jobRepository, PlatformTransactionManager tx) {
// 여기에 작성:
return null;
}
@Bean
public Step ex4StepB(JobRepository jobRepository, PlatformTransactionManager tx) {
// 여기에 작성: ExitStatus 를 "WARN" 으로 설정한 뒤 정상 종료
return null;
}
@Bean
public Step ex4StepC(JobRepository jobRepository, PlatformTransactionManager tx) {
// 여기에 작성:
return null;
}
}
// =========================================================================
// 문제 5. 상태를 가진 Step 을 ExecutionContext 기반으로 고치기
// -------------------------------------------------------------------------
// 아래는 "이 Step 이 몇 번째로 처리한 파일인지" 를 세는 코드입니다.
// 지금은 설정 클래스 필드를 쓰고 있어 같은 JVM 에서 여러 번 실행하면 값이 누적됩니다.
//
// ExecutionContext 를 쓰도록 고치세요.
// - JobExecution 의 컨텍스트가 아니라 StepExecution 의 컨텍스트를 쓸 것
// - 왜 StepExecution 쪽인지 이유를 주석으로 적을 것
// - 실행 후 BATCH_STEP_EXECUTION_CONTEXT.SHORT_CONTEXT 에 어떤 JSON 이
// 저장되는지 확인해 아래에 붙여 넣을 것
//
// 저장된 JSON:
// →
// =========================================================================
@Configuration
public static class Ex5Config {
private int fileCount = 0; // ⚠️ 이 필드를 없애야 합니다
@Bean
public Job ex5Job(JobRepository jobRepository, Step ex5Step) {
// 여기에 작성:
return null;
}
@Bean
public Step ex5Step(JobRepository jobRepository, PlatformTransactionManager tx) {
// 여기에 작성: fileCount 를 ExecutionContext 로 옮긴 버전
return null;
}
}
// =========================================================================
// 문제 6. ResourcelessTransactionManager 와 메타데이터
// -------------------------------------------------------------------------
// 요구사항
// - Job 이름: ex6Job / Step 이름: ex6Step
// - Step 의 트랜잭션 매니저로 new ResourcelessTransactionManager() 를 쓴다
// - Tasklet 은 주입된 txManager 의 클래스명을 출력한다
//
// ★ 실행 전 예측
// (a) BATCH_STEP_EXECUTION 에 행이 남는가? →
// (b) 남는다면/안 남는다면 그 이유는? →
//
// 실행 후 확인
// SELECT STEP_NAME, STATUS, COMMIT_COUNT FROM BATCH_STEP_EXECUTION
// WHERE STEP_NAME = 'ex6Step';
//
// 실제 결과:
// →
// Step 의 트랜잭션 매니저와 JobRepository 의 트랜잭션 매니저의 관계를 한 문장으로:
// →
// =========================================================================
@Configuration
public static class Ex6Config {
@Bean
public Job ex6Job(JobRepository jobRepository, Step ex6Step) {
// 여기에 작성:
return null;
}
@Bean
public Step ex6Step(JobRepository jobRepository) {
// 여기에 작성:
return null;
}
}
}
Solution.java
6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석입니다. 문제를 풀어 본 뒤에 여세요.
- 정답 1 은 다섯 군데 치환을 하나씩 짚고, 그중
@EnableBatchProcessing 제거만이 컴파일러가 못 잡는 항목임을 강조합니다. 나머지 넷은 빌드가 깨져서 알게 되지만, 이것 하나는 빌드가 통과하고 배포도 되고 나서 "Job 이 안 돈다"로 나타납니다. 마이그레이션 PR 리뷰에서 가장 먼저 grep 해야 할 문자열이라는 결론으로 이어집니다.
- 정답 2 는
IllegalArgumentException: JobRepository must be set 이며, 발생 지점은 AbstractJob.afterPropertiesSet() — 즉 빈 초기화 시점입니다. Job 을 실행하기도 전에 애플리케이션 기동이 실패하므로 좋은 실패입니다. "왜 deprecated 생성자를 남겨 뒀는데도 안전한가"에 대한 답이기도 합니다.
- 정답 3 은
JobLauncherApplicationRunner(→ Job 미실행), BatchDataSourceScriptDatabaseInitializer(→ 테이블 미생성), BatchProperties 바인딩(→ spring.batch.* 무시) 셋을 증상과 매칭합니다. 그리고 세 증상 중 첫 번째만 조용하다는 점을 지적합니다. 테이블이 없으면 시끄럽게 실패하지만, 러너가 없으면 아무 일도 안 일어납니다.
- 정답 4 는
.next() 가 ExitStatus 가 아니라 BatchStatus 를 본다는 것입니다. 정확히는 SimpleJob 이 각 Step 실행 후 stepExecution.getStatus() 를 확인하고 COMPLETED 가 아니면 중단합니다. ExitStatus 로 분기하려면 FlowBuilder 의 .on("WARN").to(...) 가 필요하며 그것이 Step 10 의 주제입니다.
- 정답 5 는
ExecutionContext#getLong(key, default) 를 쓰는 버전이고, 왜 JobExecution 이 아니라 StepExecution 의 컨텍스트를 골랐는지(Step 단위 재시작 지점이므로), 그리고 이 값이 BATCH_STEP_EXECUTION_CONTEXT.SHORT_CONTEXT 에 어떤 JSON 으로 저장되는지 실제 문자열로 보여 줍니다.
- 정답 6 은
ResourcelessTransactionManager 를 쓰더라도 JobRepository 는 자기 트랜잭션 매니저를 따로 갖고 있어서 메타데이터가 정상적으로 저장된다는 것입니다. Step 의 트랜잭션 매니저와 JobRepository 의 트랜잭션 매니저는 별개의 설정이며, 이 분리를 모르면 "메타데이터도 리소스리스로 날아가는 것 아닌가" 하는 잘못된 걱정을 하게 됩니다. 반대로 정말 위험한 조합(Step 의 tx 와 JobRepository 의 tx 가 서로 다른 DataSource 를 가리키는 경우)이 무엇인지도 함께 적었습니다.
package com.example.batch.step02;
/*
* ============================================================================
* Step 02 — 연습문제 정답 및 해설
* ============================================================================
* Exercise.java 를 먼저 풀어 본 뒤에 보세요.
* ============================================================================
*/
import org.springframework.batch.core.ExitStatus;
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.ExecutionContext;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
public final class Solution {
private Solution() {
}
// =========================================================================
// 정답 1. 4.3 → 5.1 마이그레이션
// =========================================================================
//
// 치환한 다섯 군데
//
// ① @EnableBatchProcessing → 삭제 ★ 컴파일러가 못 잡음
// ② JobBuilderFactory 필드/생성자 → 삭제, 파라미터 주입으로
// ③ StepBuilderFactory 필드/생성자 → 삭제, 파라미터 주입으로
// ④ xxxBuilderFactory.get(name) → new XxxBuilder(name, jobRepository)
// ⑤ .tasklet(t) → .tasklet(t, txManager)
//
// 그리고 부수적으로 closeStep() / archiveStep() 메서드 직접 호출을
// @Bean 메서드 파라미터 주입으로 바꿨습니다.
//
// ★ 컴파일러가 못 잡는 항목은 ① 하나뿐입니다.
//
// ②③④⑤ 는 타입이 없어졌거나 시그니처가 바뀌었으므로 빌드가 깨집니다.
// 빌드가 깨지는 것은 좋은 실패입니다. 고칠 수밖에 없으니까요.
//
// 그런데 ① 은 애너테이션이 여전히 존재하고, 붙여도 컴파일이 됩니다.
// 심지어 애플리케이션도 정상 기동합니다.
// 결과는 "BatchAutoConfiguration 이 물러나 JobLauncherApplicationRunner 가
// 등록되지 않고, 부팅 시 Job 이 하나도 실행되지 않는" 것입니다.
// 에러 없이, 로그도 깨끗하게, 종료 코드 0 으로 끝납니다.
//
// 즉 마이그레이션 PR 은 "빌드가 통과했으니 됐다" 로 끝나고,
// 배포된 뒤 첫 배치 시간에 아무 일도 일어나지 않습니다.
// 그리고 아무도 모릅니다. 알람이 안 오니까요.
//
// → 마이그레이션 PR 에서 가장 먼저 실행할 명령:
// grep -rn 'EnableBatchProcessing' src/
// 결과가 비어 있어야 합니다.
//
@Configuration
public static class Sol1Config {
@Bean
public Job dailyCloseJob(JobRepository jobRepository, Step closeStep, Step archiveStep) {
return new JobBuilder("dailyCloseJob", jobRepository)
.start(closeStep)
.next(archiveStep)
.build();
}
@Bean
public Step closeStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("closeStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("일마감 처리");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
public Step archiveStep(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("archiveStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("아카이브");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// 정답 2. JobRepository 없는 JobBuilder
// =========================================================================
//
// (a) 컴파일은 됩니다. new JobBuilder(String) 생성자는 5.1 에도 남아 있습니다
// (@Deprecated(since = "5.0") 로 표시만 되어 있습니다).
//
// (b) java.lang.IllegalArgumentException
//
// (c) "JobRepository must be set"
//
// (d) 빈 초기화 시점입니다.
// 스택트레이스의 맨 위가 이렇습니다.
// at org.springframework.util.Assert.state(Assert.java:76)
// at org.springframework.batch.core.job.AbstractJob.afterPropertiesSet(AbstractJob.java:141)
// AbstractJob 은 InitializingBean 을 구현하고 있어서,
// 컨테이너가 빈을 만들 때 afterPropertiesSet() 이 호출되며 거기서 검사합니다.
// Job 을 실행하기도 전에 애플리케이션 기동이 실패합니다.
//
// (e) 시끄러운 실패입니다. 그리고 이것이 옳습니다.
//
// 왜 좋은 설계인가:
// JobRepository 가 없는 Job 은 메타데이터를 남길 수 없습니다.
// 즉 "어디까지 처리했는지 기억하지 못하는 배치" 입니다.
// 그런 Job 이 조용히 돌아서 절반쯤 처리하고 죽는다면 복구할 방법이 없습니다.
// 그러느니 애플리케이션이 아예 안 뜨는 게 낫습니다.
//
// deprecated 생성자를 남겨 둔 것도 이 검사 덕에 안전합니다.
// "잘못 쓸 수 있게 열어 두되, 잘못 쓰면 즉시 크게 실패하게 한다" 는 패턴입니다.
// =========================================================================
// 정답 3. @EnableBatchProcessing 을 붙이면 사라지는 것
// =========================================================================
//
// (1) 빈: JobLauncherApplicationRunner
// 증상: 부팅 시 Job 이 자동 실행되지 않는다.
// 에러 없음, 로그 없음, 종료 코드 0. ← ★ 조용한 증상
//
// (2) 빈: BatchDataSourceScriptDatabaseInitializer
// 증상: spring.batch.jdbc.initialize-schema 가 무시되어 BATCH_* 테이블이
// 생성되지 않는다. 테이블이 없는 상태라면
// BadSqlGrammarException: Table 'batchdb.BATCH_JOB_INSTANCE' doesn't exist
// 로 시끄럽게 죽는다. ← 시끄러움, 그나마 다행
//
// (3) 설정 바인딩: BatchProperties (spring.batch.*)
// 증상: table-prefix, job.name 등 application.yml 설정이 반영되지 않는다.
// table-prefix 를 커스텀으로 쓰고 있었다면 기본값 BATCH_ 로 되돌아가
// "테이블이 없다" 또는 "빈 메타데이터를 보고 처음부터 다시 실행" 이 된다.
//
// 조용한 것은 (1) 입니다.
//
// 탐지 문자열: "launched with the following parameters"
// 이 줄이 로그에 없으면 Job 이 한 번도 실행되지 않은 것입니다.
// 운영에서는 이 문자열의 부재를 알림 조건으로 거는 것이 좋습니다.
// (배치 로그는 "무엇이 있었는가" 보다 "무엇이 없었는가" 가 더 중요할 때가 많습니다)
//
// 진단 명령:
// ./gradlew bootRun --args='--debug' 2>&1 | grep -A2 'BatchAutoConfiguration'
//
// BatchAutoConfiguration:
// Did not match:
// - @ConditionalOnMissingBean (... annotations: ...EnableBatchProcessing)
// found beans of annotation type EnableBatchProcessing 'trapConfig'
//
// 원인 빈 이름까지 알려 줍니다.
// =========================================================================
// 정답 4. ExitStatus 를 바꿔도 ex4StepC 는 실행됩니다
// =========================================================================
//
// 결과
// +--------------+-----------+-----------+
// | STEP_NAME | STATUS | EXIT_CODE |
// +--------------+-----------+-----------+
// | ex4StepA | COMPLETED | COMPLETED |
// | ex4StepB | COMPLETED | WARN | ← ExitStatus 만 WARN
// | ex4StepC | COMPLETED | COMPLETED | ← 실행됨
// +--------------+-----------+-----------+
//
// 왜인가:
// SimpleJob 은 각 Step 실행 후 stepExecution.getStatus() — 즉 BatchStatus 를 봅니다.
// COMPLETED 이면 다음 Step 으로 진행하고, 아니면 Job 을 중단합니다.
// ExitStatus 는 아예 보지 않습니다.
//
// .next() 가 보는 것 = BatchStatus (enum, 프레임워크가 정함)
// .on(...) 가 보는 것 = ExitStatus (문자열, 개발자가 정할 수 있음)
//
// 흔한 오해와 그 결과:
// "ExitStatus 를 SKIPPED 로 바꿨으니 뒤 Step 은 안 돌겠지" 라고 믿고
// 휴일 처리 로직을 만들면, 휴일에도 정산이 그대로 돕니다.
// 에러는 안 납니다. 로그도 COMPLETED 입니다.
// ExitStatus 로 흐름을 바꾸려면 반드시 FlowBuilder 를 써야 합니다(Step 10).
//
// new JobBuilder("ex4Job", jobRepository)
// .start(ex4StepA)
// .next(ex4StepB)
// .on("WARN").end() // ← 이렇게 해야 흐름이 바뀝니다
// .from(ex4StepB).on("*").to(ex4StepC)
// .end()
// .build();
//
@Configuration
public static class Sol4Config {
@Bean
public Job ex4Job(JobRepository jobRepository,
Step ex4StepA, Step ex4StepB, Step ex4StepC) {
return new JobBuilder("ex4Job", jobRepository)
.start(ex4StepA)
.next(ex4StepB)
.next(ex4StepC)
.build();
}
@Bean
public Step ex4StepA(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("ex4StepA", jobRepository)
.tasklet((c, cc) -> {
System.out.println(">>> A");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
public Step ex4StepB(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("ex4StepB", jobRepository)
.tasklet((c, cc) -> {
System.out.println(">>> B (ExitStatus=WARN)");
c.setExitStatus(new ExitStatus("WARN", "환율 테이블이 어제 것입니다"));
return RepeatStatus.FINISHED;
}, tx)
.build();
}
@Bean
public Step ex4StepC(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("ex4StepC", jobRepository)
.tasklet((c, cc) -> {
System.out.println(">>> C (실행됩니다)");
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// 정답 5. 상태를 ExecutionContext 로 옮기기
// =========================================================================
//
// 왜 StepExecution 의 컨텍스트인가 (JobExecution 이 아니라):
//
// - 세는 대상이 "이 Step 이 처리한 파일 수" 이므로 수명이 Step 과 같아야 합니다.
// - 재시작 시 Spring Batch 는 실패한 StepExecution 의 컨텍스트를 복원해
// 같은 Step 의 새 실행에 넘겨 줍니다. 즉 "이어서 세기" 가 공짜로 됩니다.
// - JobExecution 컨텍스트에 두면 Job 안의 다른 Step 도 볼 수 있어
// 의도치 않은 공유가 생깁니다. 공유가 필요할 때만 그쪽을 쓰세요(Step 09).
//
// 저장되는 JSON (BATCH_STEP_EXECUTION_CONTEXT.SHORT_CONTEXT):
//
// {"@class":"java.util.HashMap","fileCount":1,
// "batch.taskletType":"com.example.batch.step02.Solution$Sol5Config$$Lambda/0x...",
// "batch.stepType":"org.springframework.batch.core.step.tasklet.TaskletStep"}
//
// batch.* 두 개는 프레임워크가 넣은 것이고, fileCount 가 우리 값입니다.
// @class 가 붙는 이유는 4.3 부터 기본 직렬화가 Jackson JSON 이기 때문입니다.
//
// ⚠️ 여기서 putLong 을 쓴 이유:
// ExecutionContext 는 put(String, Object) 도 받습니다. 아무 객체나 넣을 수 있습니다.
// 넣는 순간에는 에러가 안 납니다. 커밋 시 직렬화하다가, 또는 재시작 시
// 역직렬화하다가 터집니다. 즉 "정상 실행 때는 멀쩡하고 장애 복구 때만 터지는"
// 최악의 타이밍입니다. putLong / putString / putDouble 처럼 타입이 명시된
// 메서드만 쓰는 습관이 이 사고를 막습니다.
//
@Configuration
public static class Sol5Config {
@Bean
public Job ex5Job(JobRepository jobRepository, Step ex5Step) {
return new JobBuilder("ex5Job", jobRepository).start(ex5Step).build();
}
@Bean
public Step ex5Step(JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder("ex5Step", jobRepository)
.tasklet((contribution, chunkContext) -> {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
long fileCount = ctx.getLong("fileCount", 0L) + 1;
ctx.putLong("fileCount", fileCount);
System.out.println(">>> fileCount = " + fileCount);
return RepeatStatus.FINISHED;
}, tx)
.build();
}
}
// =========================================================================
// 정답 6. ResourcelessTransactionManager 를 써도 메타데이터는 정상 저장됩니다
// =========================================================================
//
// (a) BATCH_STEP_EXECUTION 에 행이 정상적으로 남습니다.
//
// +-----------+-----------+--------------+
// | STEP_NAME | STATUS | COMMIT_COUNT |
// +-----------+-----------+--------------+
// | ex6Step | COMPLETED | 1 |
// +-----------+-----------+--------------+
//
// (b) 이유: 트랜잭션 매니저가 두 군데에 따로 있기 때문입니다.
//
// ┌─────────────────────────────────────────────────────────┐
// │ Step 의 트랜잭션 매니저 │
// │ = .tasklet(t, txManager) 로 넘긴 것 │
// │ = "업무 데이터" 쓰기의 트랜잭션 경계 │
// │ → ResourcelessTransactionManager 로 두면 실제 DB 트랜잭션│
// │ 을 열지 않습니다 (커넥션을 낭비하지 않습니다) │
// └─────────────────────────────────────────────────────────┘
// ┌─────────────────────────────────────────────────────────┐
// │ JobRepository 의 트랜잭션 매니저 │
// │ = Boot 자동설정이 DataSource 로 만들어 준 것 │
// │ = "메타데이터" 갱신의 트랜잭션 경계 │
// │ → Step 설정과 무관하게 진짜 DB 트랜잭션으로 나갑니다 │
// └─────────────────────────────────────────────────────────┘
//
// 이 분리를 모르면 "리소스리스로 바꿨으니 메타데이터도 안 남겠지" 라는
// 잘못된 걱정을 하게 됩니다. 남습니다.
//
// ⚠️ 반대로 정말 위험한 조합은 이것입니다.
//
// Step 의 tx → DataSource A (업무 DB)
// JobRepository 의 tx → DataSource B (메타데이터 DB)
//
// 이러면 청크 커밋과 메타데이터 갱신이 서로 다른 트랜잭션입니다.
// 청크는 커밋됐는데 메타데이터 갱신 직전에 프로세스가 죽으면,
// "처리는 됐지만 처리했다는 기록은 없는" 상태가 됩니다.
// 재시작하면 같은 데이터를 한 번 더 처리합니다. 중복 정산입니다.
// 에러는 나지 않습니다. 그저 숫자가 두 배일 뿐입니다.
//
// 프로젝트 셋업(P-1)이 "한 트랜잭션으로 묶고 싶다면 같은 DataSource 여야 한다"
// 고 적은 이유가 이것이고, Step 11 에서 이 트레이드오프를 다시 다룹니다.
//
@Configuration
public static class Sol6Config {
@Bean
public Job ex6Job(JobRepository jobRepository, Step ex6Step) {
return new JobBuilder("ex6Job", jobRepository).start(ex6Step).build();
}
@Bean
public Step ex6Step(JobRepository jobRepository) {
PlatformTransactionManager resourceless = new ResourcelessTransactionManager();
return new StepBuilder("ex6Step", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println(">>> step tx = " + resourceless.getClass().getName());
System.out.println(">>> DB 를 건드리지 않는 Step 입니다");
return RepeatStatus.FINISHED;
}, resourceless)
.build();
}
}
}