학습 목표
.on(...).to(...)/.from(...).on(...).to(...)로 Step 전이 그래프를 구성한다on()이 매칭하는 것은BatchStatus가 아니라ExitStatus라는 사실을 실측으로 확인한다*/?와일드카드와 가장 구체적인 패턴이 이기는 매칭 우선순위를 검증한다.end()/.fail()/.stopAndRestart()가 Job 의 최종 상태와 재시작 지점을 어떻게 바꾸는지 구분한다JobExecutionDecider로 주문 건수에 따라 정산 방식을 분기시킨다.split(new SimpleAsyncTaskExecutor())로 두 흐름을 병렬 실행하고, 로그의 스레드 이름으로 확인한다.on("FAILED").to(recoveryStep).end()가 실패를 은폐해 Job 을 COMPLETED 로 만드는 것을 재현하고 대응한다선행 스텝: Step 09 — ExecutionContext 와 스코프 예상 소요: 110분
여기까지의 Job 은 전부 직선이었습니다. start(a).next(b).next(c) — 앞이 성공하면 뒤가 돌고, 실패하면 거기서 끝납니다.
실무의 정산 배치는 그렇지 않습니다. "대상 건수가 많으면 벌크 방식으로", "집계와 리포트는 동시에", "실패하면 복구 Step 을 태우고 알림". 이번 스텝은 그 그래프를 만드는 법과, 그 과정에서 조용히 망가지는 지점을 다룹니다.
JobBuilder 는 .next() 를 만나는 순간 SimpleJob 을, .on() 을 만나는 순간 FlowJob 을 만듭니다. 전이 문법은 다음 세 조각뿐입니다.
// [10-1] 전이의 기본 문법
@Bean
public Job flowBasicJob(JobRepository jobRepository) {
return new JobBuilder("flowBasicJob", jobRepository)
.start(stepA()) // 시작점
.on("COMPLETED").to(stepB()) // stepA 가 COMPLETED 면 stepB
.from(stepA())
.on("FAILED").to(stepC()) // stepA 가 FAILED 면 stepC
.from(stepB())
.on("*").end() // stepB 뒤에는 무조건 종료
.from(stepC())
.on("*").fail() // stepC 뒤에는 Job 을 실패로
.end() // ← FlowBuilder 를 닫습니다
.build();
}| 조각 | 의미 |
|---|---|
.start(step) | 흐름의 시작 상태 |
.on("패턴") | 직전 상태의 ExitStatus 가 패턴에 맞으면 |
.to(step) / .to(flow) | 그 다음으로 갈 곳 |
.from(step) | "이 상태에서 나가는 전이를 추가로 정의한다" |
.end() (전이) | 이 지점에서 Job 을 COMPLETED 로 종료 |
.fail() | 이 지점에서 Job 을 FAILED 로 종료 |
.stopAndRestart(step) | Job 을 STOPPED 로 멈추고, 재시작하면 지정 Step 부터 |
.end() (빌더) | FlowBuilder 를 닫아 JobBuilder 로 돌아옴 |
⚠️ 함정 —
.end()가 두 가지 뜻으로 쓰입니다 위 코드에.end()가 두 번 나옵니다. 완전히 다른 것입니다.
.on("*").end()→ 전이 종료. "여기서 Job 을 COMPLETED 로 끝낸다."- 맨 마지막
.end()→ 빌더 종료.FlowBuilder를 닫고.build()를 부를 수 있게 만든다.마지막
.end()를 빼먹으면.build()가FlowBuilder에 없어서 컴파일 에러가 납니다. 이건 금방 고칩니다. 진짜 문제는 전이 쪽.end()입니다. 이것 때문에 실패한 Job 이 성공으로 보고되는 사고가 납니다. 10-5 에서 정면으로 다룹니다.
on() 이 보는 것은 ExitStatus 입니다이 스텝에서 딱 하나만 가져간다면 이 절입니다.
Spring Batch 에는 상태를 나타내는 값이 두 종류 있습니다.
BatchStatus | ExitStatus | |
|---|---|---|
| 타입 | enum (고정) | String 코드 (자유) |
| 값 | STARTING STARTED STOPPING STOPPED FAILED COMPLETED ABANDONED UNKNOWN | COMPLETED FAILED NOOP EXECUTING STOPPED + 여러분이 만든 값 |
| 용도 | 프레임워크가 실행 상태를 관리 | 흐름 제어의 입력값 |
| 컬럼 | BATCH_STEP_EXECUTION.STATUS | BATCH_STEP_EXECUTION.EXIT_CODE |
on() 이 보는 것 | ✗ | ✓ |
on("COMPLETED") 는 BatchStatus.COMPLETED 를 보는 것이 아닙니다. ExitStatus.getExitCode() 문자열을 봅니다. 둘은 기본값이 같아서 평소에는 구분할 일이 없지만, 다르게 만들 수 있고 그 순간 흐름이 바뀝니다.
// [10-2] ExitStatus 를 직접 바꿉니다
@Bean
public Step checkStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("checkStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
Long cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED' AND DATE(ordered_at)=?",
Long.class, date);
chunkContext.getStepContext().getStepExecution()
.getExecutionContext().putLong("targetCount", cnt);
return RepeatStatus.FINISHED;
}, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
long cnt = stepExecution.getExecutionContext().getLong("targetCount", 0L);
// BatchStatus 는 여전히 COMPLETED 입니다. ExitStatus 만 바꿉니다.
return cnt == 0 ? new ExitStatus("NO_DATA") : new ExitStatus("HAS_DATA");
}
})
.build();
}
@Bean
public Job exitStatusJob(JobRepository jobRepository) {
return new JobBuilder("exitStatusJob", jobRepository)
.start(checkStep(null, null, null))
.on("HAS_DATA").to(settleStep(null, null))
.from(checkStep(null, null, null))
.on("NO_DATA").to(skipNoticeStep(null, null))
.end()
.build();
}데이터가 있는 날로 실행합니다.
./gradlew bootRun --args='--spring.batch.job.name=exitStatusJob date=2025-03-01'결과
INFO 45011 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=exitStatusJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 45011 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [checkStep]
INFO 45011 --- [ main] c.e.batch.step10.ExitStatusConfig : [checkStep] date=2025-03-01, targetCount=389 → ExitStatus=HAS_DATA
INFO 45011 --- [ main] o.s.batch.core.step.AbstractStep : Step: [checkStep] executed in 38ms
INFO 45011 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settleStep]
INFO 45011 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settleStep] executed in 421ms
INFO 45011 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=exitStatusJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [COMPLETED] in 583ms데이터가 없는 날(시드 범위 밖)로 실행합니다.
./gradlew bootRun --args='--spring.batch.job.name=exitStatusJob date=2025-12-25'결과
INFO 45044 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [checkStep]
INFO 45044 --- [ main] c.e.batch.step10.ExitStatusConfig : [checkStep] date=2025-12-25, targetCount=0 → ExitStatus=NO_DATA
INFO 45044 --- [ main] o.s.batch.core.step.AbstractStep : Step: [checkStep] executed in 21ms
INFO 45044 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [skipNoticeStep]
INFO 45044 --- [ main] c.e.batch.step10.ExitStatusConfig : [skipNotice] 2025-12-25 정산 대상 없음. 건너뜁니다.
INFO 45044 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=exitStatusJob]] completed with the following parameters: [{'date':'{value=2025-12-25, ...}'}] and the following status: [COMPLETED] in 194ms메타데이터로 두 값이 어떻게 다른지 확인합니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, EXIT_CODE FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC LIMIT 4;"결과
+----------------+-----------+-----------+
| STEP_NAME | STATUS | EXIT_CODE |
+----------------+-----------+-----------+
| skipNoticeStep | COMPLETED | COMPLETED |
| checkStep | COMPLETED | NO_DATA |
| settleStep | COMPLETED | COMPLETED |
| checkStep | COMPLETED | HAS_DATA |
+----------------+-----------+-----------+STATUS 는 둘 다 COMPLETED 인데 EXIT_CODE 만 다릅니다. on() 은 오른쪽 컬럼을 봅니다.
⚠️ 함정 —
on("COMPLETED")가 갑자기 안 먹는 날이 옵니다 잘 돌던 Job 에StepExecutionListener를 하나 붙였습니다. 로그를 좀 더 남기려고요.@Override public ExitStatus afterStep(StepExecution stepExecution) { log.info("처리 건수 = {}", stepExecution.getWriteCount()); return ExitStatus.COMPLETED; // ← "성공했으니까" 라고 생각하고 넣은 한 줄 }이 리스너가 원래의 ExitStatus 를 덮어씁니다. 위 예의
NO_DATA/HAS_DATA는 사라지고, 흐름은 언제나HAS_DATA쪽 전이를 못 찾아NO_DATA쪽도 못 찾고 — 매칭되는 전이가 하나도 없어 Job 이 끝나 버립니다.
afterStep에서 값을 바꿀 생각이 없으면stepExecution.getExitStatus()를 그대로 돌려주거나null을 반환하세요.return stepExecution.getExitStatus(); // 또는 return null;리스너 여러 개가 붙어 있으면 뒤에 등록된 리스너의 반환값이 이깁니다. 로그용 리스너 하나가 분기 로직 전체를 무력화할 수 있습니다. Step 12 에서 리스너 순서를 다시 다룹니다.
패턴에는 두 가지 와일드카드를 쓸 수 있습니다.
| 기호 | 의미 | 예 |
|---|---|---|
* | 0개 이상의 임의 문자 | "COMPLETED*" 는 COMPLETED, COMPLETED_WITH_SKIPS 둘 다 매칭 |
? | 정확히 1개의 임의 문자 | "C?MPLETED" 는 COMPLETED 매칭, CMPLETED 는 불가 |
가장 흔한 쓰임은 on("*") — "무엇이든" 입니다.
// [10-3] 여러 패턴이 동시에 맞을 때
new JobBuilder("wildcardJob", jobRepository)
.start(checkStep)
.on("COMPLETED").to(exactStep) // (1) 정확 일치
.from(checkStep)
.on("COMPLETED*").to(prefixStep) // (2) 접두 와일드카드
.from(checkStep)
.on("*").to(catchAllStep) // (3) 전부
.end()
.build();checkStep 의 ExitStatus 가 COMPLETED 라면 셋 다 매칭됩니다. 어디로 갈까요?
결과
INFO 45102 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [checkStep]
INFO 45102 --- [ main] o.s.batch.core.step.AbstractStep : Step: [checkStep] executed in 19ms
INFO 45102 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [exactStep]
INFO 45102 --- [ main] c.e.batch.step10.WildcardConfig : [exactStep] 정확 일치 패턴이 선택되었습니다
INFO 45102 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=wildcardJob]] completed with ... and the following status: [COMPLETED] in 141ms가장 구체적인 패턴이 이깁니다. SimpleFlow 는 전이 목록을 정렬해 두고, 와일드카드가 적을수록 앞에 놓습니다.
ExitStatus = "COMPLETED_WITH_SKIPS" 일 때 매칭 순서
"COMPLETED" ← 매칭 실패 (정확히 같아야 함)
"COMPLETED_WITH_*" ← 매칭 성공. 여기서 멈춥니다
"COMPLETED*" ← (검사되지 않음)
"*" ← (검사되지 않음)
────────────────────
구체적 ──────────► 포괄적구체성 판단은 대략 "와일드카드가 적고 고정 문자가 많을수록 구체적"입니다. ""(빈 패턴)이 가장 구체적이고 "*" 가 가장 포괄적입니다.
⚠️ 함정 — 매칭되는 전이가 하나도 없으면 Job 이 조용히 멈춥니다
.start(checkStep) .on("COMPLETED").to(settleStep) .end()이 Job 에서
checkStep이FAILED로 끝나면?FAILED에 맞는 전이가 없습니다.결과
ERROR 45150 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step checkStep in job noMatchJob java.lang.IllegalStateException: 대상 조회 실패 ... INFO 45150 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=noMatchJob]] completed with the following parameters: [{...}] and the following status: [FAILED] in 231ms이 경우는 다행히
FAILED로 끝납니다. 하지만 커스텀 ExitStatus 를 쓰기 시작하면 이야기가 달라집니다.NO_DATA를 반환했는데 전이에on("NO_DATA")를 안 써 뒀다면,NoSuchElementException이나 흐름 중단으로 이어집니다. 커스텀 ExitStatus 를 쓰는 모든 Step 뒤에는on("*")로 안전망을 하나 두세요. 어떤 상태가 와도 갈 곳이 있어야 합니다..from(checkStep).on("*").to(unexpectedStatusStep) // 안전망
.end() / .fail() / .stopAndRestart()전이의 종착점은 세 가지입니다. Job 의 최종 BatchStatus 와 재시작 동작이 각각 다릅니다.
| 종착점 | Job BatchStatus | Job ExitStatus | 재시작하면 |
|---|---|---|---|
.end() | COMPLETED | COMPLETED | 불가. 같은 파라미터면 JobInstanceAlreadyCompleteException |
.end("코드") | COMPLETED | 코드 | 위와 동일 |
.fail() | FAILED | FAILED | 가능. 실패한 Step 부터 |
.stopAndRestart(step) | STOPPED | STOPPED | 가능. 지정한 Step 부터 |
// [10-4] 세 종착점
new JobBuilder("terminalJob", jobRepository)
.start(validateStep)
.on("VALID").to(settleStep)
.from(validateStep)
.on("INVALID").fail() // 검증 실패 → Job FAILED
.from(validateStep)
.on("NEEDS_APPROVAL").stopAndRestart(settleStep) // 사람 승인 대기 → STOPPED
.from(settleStep)
.on("*").end() // 정상 종료
.end()
.build();stopAndRestart 를 실행해 봅니다.
결과
INFO 45210 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [validateStep]
INFO 45210 --- [ main] c.e.batch.step10.TerminalConfig : [validate] 승인 필요 금액 감지 → ExitStatus=NEEDS_APPROVAL
INFO 45210 --- [ main] o.s.batch.core.step.AbstractStep : Step: [validateStep] executed in 33ms
INFO 45210 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=terminalJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [STOPPED] in 187msmysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT JOB_EXECUTION_ID, STATUS, EXIT_CODE FROM BATCH_JOB_EXECUTION
ORDER BY JOB_EXECUTION_ID DESC LIMIT 1;"결과
+------------------+---------+-----------+
| JOB_EXECUTION_ID | STATUS | EXIT_CODE |
+------------------+---------+-----------+
| 31 | STOPPED | STOPPED |
+------------------+---------+-----------+같은 파라미터로 다시 실행하면 validateStep 을 건너뛰고 settleStep 부터 시작합니다.
결과
INFO 45255 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=terminalJob]] launched with the following parameters: [{'date':'{value=2025-03-01, ...}'}]
INFO 45255 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settleStep]
INFO 45255 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settleStep] executed in 407ms
INFO 45255 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=terminalJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [COMPLETED] in 512ms💡 실무 팁 —
stopAndRestart는 "사람의 개입"을 배치에 넣는 방법입니다 정산 금액이 임계치를 넘으면 자동으로 진행하지 않고 멈추게 하는 패턴에 씁니다. 담당자가 확인한 뒤 같은 파라미터로 재실행하면 승인 이후 단계부터 이어집니다..fail()로 하면 안 되냐고 물을 수 있는데, 의미가 다릅니다.FAILED는 모니터링 알람을 울려야 하는 사건이고,STOPPED는 설계된 대기 상태입니다. 이 둘을 섞으면 알람이 늑대소년이 됩니다.
.end()복구 Step 을 붙이는 것은 좋은 습관입니다. 그런데 이렇게 쓰면 안 됩니다.
// [10-5] 겉보기에 멀쩡한, 그러나 위험한 Job
@Bean
public Job hidingJob(JobRepository jobRepository) {
return new JobBuilder("hidingJob", jobRepository)
.start(settleStep)
.on("COMPLETED").to(reportStep)
.from(settleStep)
.on("FAILED").to(recoveryStep).end() // ← 이 한 줄
.from(reportStep)
.on("*").end()
.end()
.build();
}settleStep 이 실패하도록 만들고 실행합니다.
결과
INFO 45301 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=hidingJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 45301 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settleStep]
ERROR 45301 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step settleStep in job hidingJob
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO settlement (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount) VALUES (?,?,?,?,?,?,?)]; Duplicate entry '17' for key 'settlement.uk_settlement_order'
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:243) ~[spring-jdbc-6.1.6.jar:6.1.6]
at org.springframework.batch.item.database.JdbcBatchItemWriter.write(JdbcBatchItemWriter.java:212) ~[spring-batch-infrastructure-5.1.1.jar:5.1.1]
at org.springframework.batch.core.step.item.SimpleChunkProcessor.writeItems(SimpleChunkProcessor.java:194) ~[spring-batch-core-5.1.1.jar:5.1.1]
at org.springframework.batch.core.step.item.ChunkOrientedTasklet.execute(ChunkOrientedTasklet.java:77) ~[spring-batch-core-5.1.1.jar:5.1.1]
...
INFO 45301 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settleStep] executed in 1s102ms
INFO 45301 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [recoveryStep]
INFO 45301 --- [ main] c.e.batch.step10.HidingConfig : [recovery] 부분 정산 데이터를 정리했습니다
INFO 45301 --- [ main] o.s.batch.core.step.AbstractStep : Step: [recoveryStep] executed in 64ms
INFO 45301 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=hidingJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [COMPLETED] in 1s388ms마지막 줄을 보세요. status: [COMPLETED] 입니다.
메타데이터로 확인합니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT je.JOB_EXECUTION_ID, je.STATUS AS job_status, je.EXIT_CODE AS job_exit,
se.STEP_NAME, se.STATUS AS step_status, se.EXIT_CODE AS step_exit
FROM BATCH_JOB_EXECUTION je JOIN BATCH_STEP_EXECUTION se USING (JOB_EXECUTION_ID)
WHERE je.JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY se.STEP_EXECUTION_ID;"결과
+------------------+------------+-----------+---------------+-------------+-----------+
| JOB_EXECUTION_ID | job_status | job_exit | STEP_NAME | step_status | step_exit |
+------------------+------------+-----------+---------------+-------------+-----------+
| 34 | COMPLETED | COMPLETED | settleStep | FAILED | FAILED |
| 34 | COMPLETED | COMPLETED | recoveryStep | COMPLETED | COMPLETED |
+------------------+------------+-----------+---------------+-------------+-----------+⚠️ 함정 —
.on("FAILED").to(recoveryStep).end()는 실패를 지웁니다 Step 은 분명히FAILED인데 Job 은COMPLETED입니다. 이게 왜 재앙인지 하나씩 봅니다.
- 모니터링이 침묵합니다. 배치 알람은 대부분
BATCH_JOB_EXECUTION.STATUS = 'FAILED'나 프로세스 종료 코드로 걸려 있습니다. 둘 다 정상입니다.System.exit(SpringApplication.exit(...))이 0 을 반환합니다. 스케줄러(Quartz, Airflow, 쿠버네티스 Job)가 성공으로 판정합니다. 후속 잡이 미완성 정산 데이터 위에서 돌기 시작합니다.- 재시작이 막힙니다. Job 이
COMPLETED이므로 같은 파라미터로 다시 돌리면JobInstanceAlreadyCompleteException이 납니다. 고치고 다시 돌리려면 파라미터를 바꾸거나 메타데이터를 손봐야 합니다.- 정산이 반쯤 된 채로 남습니다. 70,000건 중 40,000건만
settlement에 들어간 상태인데, 아무도 모릅니다.대응
원하는 것 이렇게 씁니다 복구 Step 을 돌리되 Job 은 실패로 .on("FAILED").to(recoveryStep).on("*").fail()복구 Step 을 돌리고 커스텀 실패 코드 .on("FAILED").to(recoveryStep).on("*").end("RECOVERED_BUT_FAILED")는 여전히 COMPLETED 입니다..fail()을 쓰세요실패를 정말 무시해도 되는 경우 .end()를 쓰되, 왜 무시해도 되는지 주석으로 남기세요고친 코드:
new JobBuilder("safeJob", jobRepository) .start(settleStep) .on("COMPLETED").to(reportStep) .from(settleStep) .on("FAILED").to(recoveryStep) // 복구는 하되 .from(recoveryStep) .on("*").fail() // Job 은 실패로 끝냅니다 .from(reportStep) .on("*").end() .end() .build();결과
INFO 45360 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [recoveryStep] INFO 45360 --- [ main] c.e.batch.step10.SafeConfig : [recovery] 부분 정산 데이터를 정리했습니다 INFO 45360 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=safeJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [FAILED] in 1s401ms+------------------+------------+----------+--------------+-------------+ | JOB_EXECUTION_ID | job_status | job_exit | STEP_NAME | step_status | +------------------+------------+----------+--------------+-------------+ | 35 | FAILED | FAILED | settleStep | FAILED | | 35 | FAILED | FAILED | recoveryStep | COMPLETED | +------------------+------------+----------+--------------+-------------+복구 Step 은 돌았고, Job 은 실패로 보고됐고, 재시작도 가능합니다. 문법 에러는 금방 고치지만, 이렇게 조용히 성공으로 보고되는 코드는 몇 달을 갑니다.
JobExecutionDecider — Step 없이 분기 판단만10-2 의 checkStep 은 "판단만 하는 Step" 이었습니다. 이런 목적이라면 Step 대신 JobExecutionDecider 가 더 적절합니다.
| ExitStatus 를 바꾸는 Step | JobExecutionDecider | |
|---|---|---|
BATCH_STEP_EXECUTION 에 행이 남습니까 | 남습니다 | 안 남습니다 |
| 트랜잭션 | 있습니다 | 없습니다 |
| 재시작 시 | Step 으로 취급 | 매번 다시 평가됩니다 |
| 적합한 용도 | 실제 작업 + 부수적 분기 | 순수한 판단 |
정산 방식을 주문 건수로 분기시킵니다.
// [10-6] 주문 건수에 따라 정산 방식을 고르는 Decider
public class SettleModeDecider implements JobExecutionDecider {
private static final long BULK_THRESHOLD = 1_000L;
private final JdbcTemplate jdbcTemplate;
public SettleModeDecider(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
String date = jobExecution.getJobParameters().getString("date");
Long count = (date == null)
? jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED'", Long.class)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED' AND DATE(ordered_at)=?",
Long.class, date);
long c = count == null ? 0L : count;
// 판단 결과를 Job Context 에 남깁니다 — Step 09 에서 배운 그대로입니다.
jobExecution.getExecutionContext().putLong("targetCount", c);
if (c == 0) return new FlowExecutionStatus("NO_DATA");
if (c >= BULK_THRESHOLD) return new FlowExecutionStatus("BULK");
return new FlowExecutionStatus("NORMAL");
}
}@Bean
public Job deciderJob(JobRepository jobRepository, SettleModeDecider settleModeDecider) {
return new JobBuilder("deciderJob", jobRepository)
.start(prepareStep)
.next(settleModeDecider)
.on("BULK").to(bulkSettleStep)
.from(settleModeDecider)
.on("NORMAL").to(normalSettleStep)
.from(settleModeDecider)
.on("NO_DATA").to(skipNoticeStep)
.from(settleModeDecider)
.on("*").fail() // 안전망 (10-3 의 교훈)
.end()
.build();
}하루치(389건)로 실행합니다.
./gradlew bootRun --args='--spring.batch.job.name=deciderJob date=2025-03-01'결과
INFO 45412 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [prepareStep]
INFO 45412 --- [ main] o.s.batch.core.step.AbstractStep : Step: [prepareStep] executed in 27ms
INFO 45412 --- [ main] c.e.batch.step10.SettleModeDecider : date=2025-03-01, targetCount=389 → NORMAL
INFO 45412 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [normalSettleStep]
INFO 45412 --- [ main] o.s.batch.core.step.AbstractStep : Step: [normalSettleStep] executed in 418ms
INFO 45412 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=deciderJob]] completed with ... and the following status: [COMPLETED] in 601ms전체(70,000건)로 실행합니다. date 파라미터를 빼면 됩니다.
./gradlew bootRun --args='--spring.batch.job.name=deciderJob run.id=2'결과
INFO 45450 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [prepareStep]
INFO 45450 --- [ main] o.s.batch.core.step.AbstractStep : Step: [prepareStep] executed in 24ms
INFO 45450 --- [ main] c.e.batch.step10.SettleModeDecider : date=null, targetCount=70000 → BULK
INFO 45450 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [bulkSettleStep]
INFO 45450 --- [ main] o.s.batch.core.step.AbstractStep : Step: [bulkSettleStep] executed in 8s114ms
INFO 45450 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=deciderJob]] completed with ... and the following status: [COMPLETED] in 8s390msmysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, READ_COUNT, WRITE_COUNT FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC LIMIT 3;"결과
+------------------+------------+-------------+
| STEP_NAME | READ_COUNT | WRITE_COUNT |
+------------------+------------+-------------+
| bulkSettleStep | 70000 | 70000 |
| prepareStep | 0 | 0 |
| normalSettleStep | 389 | 389 |
+------------------+------------+-------------+decider 자체는 BATCH_STEP_EXECUTION 에 행을 남기지 않습니다. 위 결과에 settleModeDecider 라는 이름이 없는 것이 그 증거입니다.
흐름 다이어그램:
┌──────────────┐
│ prepareStep │
└──────┬───────┘
│
┌──────▼────────────┐
│ SettleModeDecider │ (Step 아님. 행이 안 남음)
└──┬────┬────┬───┬──┘
"BULK" │ │ │ │ "*"
┌──────────────────┘ │ │ └──────────────┐
│ "NORMAL" │ │ "NO_DATA" │
│ ┌──────────────┘ └────────┐ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌────────────────┐ ┌──────┐
│bulkSettle │ │normalSettle │ │skipNoticeStep │ │fail()│
│Step (70000) │ │Step (389) │ │ │ │ │
└──────┬──────┘ └──────┬────────┘ └───────┬────────┘ └──────┘
└───────────────┴──────────────────┘
│
end()⚠️ 함정 — Decider 는 재시작할 때마다 다시 평가됩니다 Decider 는 Step 이 아니므로 실행 이력이 남지 않고, 따라서 재시작 시 이전 판단을 기억하지 않습니다. 1차 실행 때 389건이라
NORMAL로 갔는데, 그 사이에 데이터가 들어와 재시작 시점에 1,200건이 되었다면 이번에는BULK로 갑니다. 같은 JobExecution 의 이어서 실행인데 경로가 바뀝니다. 이미normalSettleStep이 부분 처리한 데이터 위에서bulkSettleStep이 돌면 무슨 일이 벌어질지는 상상에 맡기겠습니다.대응: 판단 근거를 첫 실행 때
jobExecution.getExecutionContext()에 저장하고, Decider 는 컨텍스트에 값이 있으면 DB 를 다시 조회하지 않고 그 값을 씁니다.ExecutionContext ctx = jobExecution.getExecutionContext(); long c = ctx.containsKey("targetCount") ? ctx.getLong("targetCount") // 재시작 — 이전 판단을 재사용 : queryCount(date); // 첫 실행 — 조회 후 저장 ctx.putLong("targetCount", c);Step 09 의 ExecutionContext 가 여기서 쓰입니다.
.split() — 병렬 흐름서로 의존하지 않는 두 흐름은 동시에 돌릴 수 있습니다.
// [10-7] 두 흐름을 병렬로
@Bean
public Flow customerAggFlow() {
return new FlowBuilder<Flow>("customerAggFlow")
.start(customerAggStep)
.build();
}
@Bean
public Flow cityAggFlow() {
return new FlowBuilder<Flow>("cityAggFlow")
.start(cityAggStep)
.build();
}
@Bean
public Job splitJob(JobRepository jobRepository) {
return new JobBuilder("splitJob", jobRepository)
.start(settleStep)
.next(new FlowBuilder<Flow>("parallelAgg")
.split(new SimpleAsyncTaskExecutor("agg-"))
.add(customerAggFlow(), cityAggFlow())
.build())
.next(reportStep)
.build()
.build();
}결과
INFO 45510 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=splitJob]] launched with the following parameters: [{'date':'{value=2025-03-01, ...}'}]
INFO 45510 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settleStep]
INFO 45510 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settleStep] executed in 412ms
INFO 45510 --- [ agg-1] o.s.batch.core.job.SimpleStepHandler : Executing step: [customerAggStep]
INFO 45510 --- [ agg-2] o.s.batch.core.job.SimpleStepHandler : Executing step: [cityAggStep]
INFO 45510 --- [ agg-2] c.e.batch.step10.SplitConfig : [cityAgg] 5개 도시 집계 완료 (1204ms)
INFO 45510 --- [ agg-2] o.s.batch.core.step.AbstractStep : Step: [cityAggStep] executed in 1s211ms
INFO 45510 --- [ agg-1] c.e.batch.step10.SplitConfig : [customerAgg] 1000명 고객 집계 완료 (1873ms)
INFO 45510 --- [ agg-1] o.s.batch.core.step.AbstractStep : Step: [customerAggStep] executed in 1s882ms
INFO 45510 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [reportStep]
INFO 45510 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 44ms
INFO 45510 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=splitJob]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [COMPLETED] in 2s491ms읽을 것이 두 가지 있습니다.
agg-1, agg-2 로 갈렸습니다. 진짜로 병렬입니다.reportStep 은 두 흐름이 모두 끝난 뒤에 시작합니다. split 은 조인 지점을 자동으로 만듭니다.순차로 돌렸다면 1,211ms + 1,882ms = 3,093ms 였을 것이 1,882ms 로 줄었습니다. 느린 쪽에 맞춰집니다.
settleStep
│
┌────┴────┐ split(SimpleAsyncTaskExecutor)
│ │
[agg-1] [agg-2]
customerAgg cityAgg
1882ms 1211ms
│ │
└────┬────┘ ← 둘 다 끝나야 통과 (join)
│
reportStep⚠️ 함정 —
split안의 흐름이 하나라도 실패하면 전체가 실패합니다. 그런데 나머지는 계속 돕니다.customerAggStep이 실패해도cityAggStep은 이미 다른 스레드에서 돌고 있으므로 중간에 멈추지 않습니다. 끝까지 돈 뒤에 전체 흐름이FAILED가 됩니다. 즉 "실패했는데 부수 효과는 다 일어난" 상태가 됩니다.split안에 넣는 Step 들은 서로, 그리고 이후 단계와 부수 효과가 독립이어야 합니다.그리고
SimpleAsyncTaskExecutor는 요청마다 새 스레드를 만들고 재사용하지 않습니다. 흐름이 두세 개일 때는 괜찮지만, 개수가 늘어나면ThreadPoolTaskExecutor로 바꾸세요.ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); exec.setCorePoolSize(4); exec.setMaxPoolSize(4); exec.setThreadNamePrefix("agg-"); exec.initialize();마지막으로 HikariCP 풀 크기를 확인하세요. 병렬 흐름 수보다 커넥션이 적으면 서로 기다리다 타임아웃이 납니다. 이 프로젝트는
maximum-pool-size: 20입니다.
전이 그래프가 커지면 Job 설정 하나가 수백 줄이 됩니다. Flow 를 빈으로 뽑아 재사용하면 됩니다.
// [10-8] 재사용 가능한 Flow
@Bean
public Flow settleFlow() {
return new FlowBuilder<Flow>("settleFlow")
.start(validateStep)
.on("VALID").to(settleStep)
.from(validateStep)
.on("*").fail()
.from(settleStep)
.on("*").to(verifyStep)
.build();
}
// 이 Flow 를 여러 Job 이 씁니다
@Bean
public Job dailyJob(JobRepository jobRepository, Flow settleFlow) {
return new JobBuilder("dailyJob", jobRepository)
.start(settleFlow)
.next(dailyReportStep)
.end()
.build();
}
@Bean
public Job monthlyJob(JobRepository jobRepository, Flow settleFlow) {
return new JobBuilder("monthlyJob", jobRepository)
.start(settleFlow)
.next(monthlyReportStep)
.next(archiveStep)
.end()
.build();
}FlowStep 은 한 걸음 더 나갑니다. Flow 전체를 하나의 Step 처럼 다룹니다.
// [10-8] Flow 를 Step 으로 감싸기
@Bean
public Step settleFlowStep(JobRepository jobRepository, Flow settleFlow) {
return new StepBuilder("settleFlowStep", jobRepository)
.flow(settleFlow)
.build();
}두 방식의 차이는 메타데이터에 나타납니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, EXIT_CODE FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY STEP_EXECUTION_ID;"결과 — .start(settleFlow) 로 직접 붙였을 때
+-----------------+-----------+-----------+
| STEP_NAME | STATUS | EXIT_CODE |
+-----------------+-----------+-----------+
| validateStep | COMPLETED | VALID |
| settleStep | COMPLETED | COMPLETED |
| verifyStep | COMPLETED | COMPLETED |
| dailyReportStep | COMPLETED | COMPLETED |
+-----------------+-----------+-----------+결과 — FlowStep 으로 감쌌을 때
+-----------------+-----------+-----------+
| STEP_NAME | STATUS | EXIT_CODE |
+-----------------+-----------+-----------+
| settleFlowStep | COMPLETED | COMPLETED |
| validateStep | COMPLETED | VALID |
| settleStep | COMPLETED | COMPLETED |
| verifyStep | COMPLETED | COMPLETED |
| dailyReportStep | COMPLETED | COMPLETED |
+-----------------+-----------+-----------+settleFlowStep 이라는 묶음 행이 하나 더 생깁니다. 그 행의 상태는 내부 Flow 전체의 결과를 요약합니다.
| 방식 | 언제 씁니까 |
|---|---|
.start(flow).next(step) | Flow 를 Job 구조의 일부로 그대로 펼칠 때 |
FlowStep | Flow 를 하나의 논리 단위로 다루고 싶을 때. 상위 Job 에서 on() 으로 전이시키거나, 그 묶음의 성패를 한 행으로 모니터링하고 싶을 때 |
💡 실무 팁 — Flow 빈은
@Bean이름 충돌에 주의하세요 여러 Job 이 같은Flow빈을 공유할 때, 그 Flow 안의 Step 이름은 전역에서 유일해야 합니다. 같은 Step 인스턴스가 두 Job 에서 돌면BATCH_STEP_EXECUTION에 같은 이름의 행이 섞여 남고, 재시작 시 어느 Job 의 것인지 헷갈립니다. Step 이름 앞에 도메인 접두사를 붙이는 관례(settle.validate,settle.write)를 권합니다.
지금까지의 요소를 하나로 묶습니다.
┌──────────────┐
│ prepareStep │ (대상 조회, Job Ctx 에 targetCount 승격)
└──────┬───────┘
│
┌────────▼──────────┐
│ SettleModeDecider │ ← 컨텍스트 우선, 없으면 DB 조회
└──┬──────┬──────┬──┘
"BULK" │ │ │ "NO_DATA"
┌─────────────┘ │ └──────────────┐
│ "NORMAL" │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│bulkSettle │ │normalSettle │ │skipNotice │
│Step │ │Step │ │Step │
└──┬───────┬──┘ └──┬────────┬──┘ └──────┬───────┘
"COMPLETED"│ "FAILED" │ │ │
│ └────────┬─────────┘ │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │recoveryStep │ │ │
│ └──────┬──────┘ │ │
│ │ on("*").fail() │ │
│ ▼ │ │
│ [ FAILED ] │ │
│ │ │
└──────────┬──────────────────┘ │
▼ │
┌───────────────────────┐ split(agg-) │
│ ┌─────────────────┐ │ │
│ │ customerAggStep │ │ │
│ ├─────────────────┤ │ │
│ │ cityAggStep │ │ │
│ └─────────────────┘ │ │
└───────────┬───────────┘ │
▼ │
┌─────────────┐ │
│ reportStep │ │
└──────┬──────┘ │
▼ ▼
end() end()결과 (하루치, date=2025-03-01)
INFO 45601 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=step10Job]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 45601 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [prepareStep]
INFO 45601 --- [ main] c.e.batch.step10.Step10Job : [prepare] date=2025-03-01, targetCount=389
INFO 45601 --- [ main] o.s.batch.core.step.AbstractStep : Step: [prepareStep] executed in 31ms
INFO 45601 --- [ main] o.s.b.c.l.ExecutionContextPromotionListener: Promoting keys [targetCount] from step [prepareStep] to job execution context
INFO 45601 --- [ main] c.e.batch.step10.SettleModeDecider : 컨텍스트에서 targetCount=389 재사용 → NORMAL
INFO 45601 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [normalSettleStep]
INFO 45601 --- [ main] o.s.batch.core.step.AbstractStep : Step: [normalSettleStep] executed in 402ms
INFO 45601 --- [ agg-1] o.s.batch.core.job.SimpleStepHandler : Executing step: [customerAggStep]
INFO 45601 --- [ agg-2] o.s.batch.core.job.SimpleStepHandler : Executing step: [cityAggStep]
INFO 45601 --- [ agg-2] o.s.batch.core.step.AbstractStep : Step: [cityAggStep] executed in 118ms
INFO 45601 --- [ agg-1] o.s.batch.core.step.AbstractStep : Step: [customerAggStep] executed in 164ms
INFO 45601 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [reportStep]
INFO 45601 --- [ main] c.e.batch.step10.Step10Job : [report] 2025-03-01 정산 389건 (NORMAL), 수수료 합계 484670.00
INFO 45601 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 27ms
INFO 45601 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [FlowJob: [name=step10Job]] completed with the following parameters: [{'date':'{value=2025-03-01, ...}'}] and the following status: [COMPLETED] in 934msmysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, EXIT_CODE, READ_COUNT, WRITE_COUNT
FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY STEP_EXECUTION_ID;"결과
+------------------+-----------+-----------+------------+-------------+
| STEP_NAME | STATUS | EXIT_CODE | READ_COUNT | WRITE_COUNT |
+------------------+-----------+-----------+------------+-------------+
| prepareStep | COMPLETED | COMPLETED | 0 | 0 |
| normalSettleStep | COMPLETED | COMPLETED | 389 | 389 |
| cityAggStep | COMPLETED | COMPLETED | 0 | 0 |
| customerAggStep | COMPLETED | COMPLETED | 0 | 0 |
| reportStep | COMPLETED | COMPLETED | 0 | 0 |
+------------------+-----------+-----------+------------+-------------+bulkSettleStep, recoveryStep, skipNoticeStep 은 실행되지 않았으므로 행 자체가 없습니다. Job 정의에 있다고 해서 행이 생기는 것이 아닙니다.
| 개념 | 핵심 |
|---|---|
.next() vs .on() | 전자는 SimpleJob, 후자는 FlowJob 을 만듭니다 |
on() 이 보는 값 | BatchStatus 가 아니라 ExitStatus(= EXIT_CODE 컬럼) |
afterStep 반환값 | 원래 값을 덮어씁니다. 바꿀 게 없으면 getExitStatus() 나 null 반환 |
| 와일드카드 | * = 0개 이상, ? = 정확히 1개 |
| 매칭 우선순위 | 가장 구체적인 패턴이 이깁니다 (와일드카드가 적을수록 앞) |
| 매칭 실패 | 갈 곳이 없으면 흐름이 끊깁니다. 커스텀 ExitStatus 뒤엔 on("*") 안전망 |
.end() (전이) | Job 을 COMPLETED 로 종료. 재시작 불가 |
.end() (빌더) | FlowBuilder 를 닫는 것. 전이의 .end() 와 다릅니다 |
.fail() | Job 을 FAILED 로 종료. 재시작 가능 |
.stopAndRestart(step) | Job 을 STOPPED 로. 재시작하면 지정 Step 부터. 사람 개입용 |
| 실패 은폐 함정 | .on("FAILED").to(recovery).end() → Step FAILED, Job COMPLETED. 알람 침묵 + 재시작 봉쇄 |
| 대응 | 복구 Step 뒤에 .on("*").fail() |
JobExecutionDecider | 순수 판단용. BATCH_STEP_EXECUTION 에 행이 안 남음 |
| Decider 함정 | 재시작마다 다시 평가됨 → 판단 근거를 Job Context 에 저장해 재사용 |
.split(executor) | 흐름 병렬 실행. 모두 끝나야 다음으로(join) |
split 함정 | 하나가 실패해도 나머지는 끝까지 돕니다. 부수 효과가 독립이어야 함 |
Flow 빈 | 여러 Job 이 재사용. Step 이름은 전역 유일하게 |
FlowStep | Flow 를 한 Step 으로 감쌉니다. 묶음 행이 하나 더 생김 |
Exercise.java 에 7문제가 있습니다. 정답은 Solution.java.
on() 이 매칭하는 값이 무엇인지 SQL 결과로 증명하기StepExecutionListener 작성하기.fail() 로 고치기JobExecutionDecider 로 세 갈래 분기 만들기 (재시작 안전성 포함).split() 으로 두 집계 흐름을 병렬화하고 스레드 이름으로 확인하기흐름 제어는 "Step 단위의 성공/실패"를 다뤘습니다. 하지만 70,000건을 처리하다 보면 한두 건이 문제인데 Step 전체가 죽는 일이 훨씬 많습니다. 잘못된 고객 ID 하나 때문에 나머지 69,999건의 정산이 멈추면 안 됩니다.
다음 스텝은 그 입자를 아이템 단위로 낮춥니다. faultTolerant(), skip(), retry(), 그리고 스킵 한도를 잘못 잡으면 불량 데이터가 조용히 사라지는 함정을 다룹니다.
이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 로 10-1 ~ 10-9 의 흐름을 순서대로 재현하고, Exercise.java 의 7문제를 풀고, Solution.java 로 대조합니다. 패키지는 com.example.batch.step10 이며, 각 절의 Job 설정을 static class 로 중첩해 두었습니다. 흐름 제어는 로그와 메타데이터를 함께 봐야 이해되므로, 각 실행 뒤에 파일 하단의 조회 SQL 을 반드시 돌려 보세요.
본문의 모든 Job 정의를 절 번호 주석(// [10-3] 형태)과 함께 담았습니다.
ExitStatusConfig(10-2) 가 이 스텝의 출발점입니다. checkStep 하나로 HAS_DATA / NO_DATA 를 나누고, BATCH_STEP_EXECUTION 의 STATUS 와 EXIT_CODE 가 서로 다른 값을 갖는 것을 확인합니다. date=2025-03-01(389건)과 date=2025-12-25(0건) 두 번을 모두 실행해야 양쪽 분기를 봅니다.WildcardConfig(10-3) 는 "COMPLETED" / "COMPLETED*" / "C?MPLETED" / "*" 네 개의 전이를 같은 Step 에서 내보냅니다. 어디로 갈지 먼저 예측한 뒤 실행하세요. 예측이 틀렸다면 그게 이 절의 학습 지점입니다.HidingConfig(10-5) 는 일부러 실패를 은폐하는 Job 입니다. 실행 전에 settlement 에 데이터를 남겨 두어야(=TRUNCATE 하지 않아야) DuplicateKeyException 이 재현됩니다. 실행 후 반드시 Job 상태를 SQL 로 확인하세요. 로그만 보면 COMPLETED 라서 아무 문제가 없어 보입니다.SafeConfig(10-5) 가 그 교정판입니다. 두 클래스를 나란히 놓고 .end() 하나가 .fail() 로 바뀐 것만으로 결과가 어떻게 달라지는지 비교하세요.SettleModeDecider(10-6) 는 Job Context 를 먼저 확인하고 없을 때만 DB 를 조회하는 형태로 작성돼 있습니다. 재시작 안전성을 위한 것이며, 본문 함정 블록과 짝을 이룹니다. 컨텍스트 캐시를 끄고 싶으면 USE_CONTEXT_CACHE 상수를 false 로 바꿔 차이를 관찰하세요.SplitConfig(10-7) 의 집계 Step 두 개는 실행 시간을 벌리려고 각각 Thread.sleep 없이 실제 집계 쿼리를 돕니다(고객 1,000명 / 도시 5개). 로그에서 스레드 이름 agg-1 / agg-2 가 보이는지가 확인 포인트입니다. main 스레드에서 둘 다 돈다면 split 이 아니라 next 로 이어졌다는 뜻입니다.Step10Job(10-9) 이 종합 예제입니다. Step 09 의 승격 리스너까지 함께 씁니다. 실행 전 TRUNCATE TABLE settlement; 를 권합니다.package com.example.batch.step10;
/*
* ============================================================================
* Step 10 — 흐름 제어 : 실습 파일
* ============================================================================
*
* 실행 방법
* ./gradlew bootRun --args='--spring.batch.job.name=step10Job date=2025-03-01'
*
* 이 스텝의 한 문장
* on() 이 매칭하는 것은 BatchStatus 가 아니라 ExitStatus(EXIT_CODE 컬럼)입니다.
*
* 주의
* - HidingConfig(10-5) 는 "일부러 실패를 은폐하는" Job 입니다.
* 재현하려면 settlement 테이블에 데이터가 남아 있어야 합니다(TRUNCATE 하지 마세요).
* - Step10Job(10-9) 실행 전에는 반대로 TRUNCATE TABLE settlement; 를 권합니다.
* ============================================================================
*/
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.batch.core.job.builder.FlowBuilder;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.Flow;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
import org.springframework.batch.core.listener.ExecutionContextPromotionListener;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
import java.math.BigDecimal;
public final class Practice {
private Practice() {
}
// ========================================================================
// [10-2] on() 이 보는 것은 ExitStatus 입니다
//
// checkStep 은 BatchStatus 는 COMPLETED 로 두고 ExitStatus 만 바꿉니다.
// BATCH_STEP_EXECUTION 에서 STATUS 와 EXIT_CODE 가 달라지는 것을 확인하세요.
//
// 실행 1: date=2025-03-01 → targetCount=389 → HAS_DATA → settleStep
// 실행 2: date=2025-12-25 → targetCount=0 → NO_DATA → skipNoticeStep
// ========================================================================
@Configuration
static class ExitStatusConfig {
private static final Logger log = LoggerFactory.getLogger(ExitStatusConfig.class);
@Bean
Job exitStatusJob(JobRepository jobRepository,
Step checkStep, Step esSettleStep, Step skipNoticeStep) {
return new JobBuilder("exitStatusJob", jobRepository)
.start(checkStep)
.on("HAS_DATA").to(esSettleStep)
.from(checkStep)
.on("NO_DATA").to(skipNoticeStep)
.from(checkStep)
.on("*").fail() // 안전망 (10-3 의 교훈)
.end() // ← FlowBuilder 를 닫습니다
.build();
}
@Bean
Step checkStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("checkStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
Long cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders "
+ "WHERE status='COMPLETED' AND DATE(ordered_at)=?",
Long.class, date);
chunkContext.getStepContext().getStepExecution()
.getExecutionContext().putLong("targetCount", cnt == null ? 0L : cnt);
return RepeatStatus.FINISHED;
}, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
long cnt = stepExecution.getExecutionContext().getLong("targetCount", 0L);
String code = (cnt == 0) ? "NO_DATA" : "HAS_DATA";
log.info("[checkStep] date={}, targetCount={} → ExitStatus={}",
stepExecution.getJobParameters().getString("date"), cnt, code);
// BatchStatus 는 건드리지 않습니다. ExitStatus 만 바뀝니다.
return new ExitStatus(code);
}
})
.build();
}
@Bean
Step esSettleStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("settleStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.build();
}
@Bean
Step skipNoticeStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("skipNoticeStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
log.info("[skipNotice] {} 정산 대상 없음. 건너뜁니다.", date);
return RepeatStatus.FINISHED;
}, tm)
.build();
}
}
// ========================================================================
// [10-3] 와일드카드와 매칭 우선순위
//
// 네 개의 전이가 모두 "COMPLETED" 에 매칭됩니다. 어디로 갈까요?
// → 실행하기 전에 먼저 예측하세요.
//
// 구체적 ────────────────────────► 포괄적
// "COMPLETED" "C?MPLETED" "COMPLETED*" "*"
// ========================================================================
@Configuration
static class WildcardConfig {
private static final Logger log = LoggerFactory.getLogger(WildcardConfig.class);
@Bean
Job wildcardJob(JobRepository jobRepository, PlatformTransactionManager tm) {
Step src = plain("wcSourceStep", jobRepository, tm, null);
return new JobBuilder("wildcardJob", jobRepository)
.start(src)
.on("COMPLETED").to(plain("exactStep", jobRepository, tm, "정확 일치 패턴이 선택되었습니다"))
.from(src)
.on("C?MPLETED").to(plain("questionStep", jobRepository, tm, "? 패턴이 선택되었습니다"))
.from(src)
.on("COMPLETED*").to(plain("prefixStep", jobRepository, tm, "접두 패턴이 선택되었습니다"))
.from(src)
.on("*").to(plain("catchAllStep", jobRepository, tm, "전부 패턴이 선택되었습니다"))
.end()
.build();
}
private Step plain(String name, JobRepository r, PlatformTransactionManager tm, String msg) {
return new StepBuilder(name, r)
.tasklet((c, cc) -> {
if (msg != null) {
log.info("[{}] {}", name, msg);
}
return RepeatStatus.FINISHED;
}, tm)
.build();
}
}
// ========================================================================
// [10-4] .end() / .fail() / .stopAndRestart()
//
// 종착점 Job BatchStatus 재시작
// .end() COMPLETED 불가 (JobInstanceAlreadyCompleteException)
// .fail() FAILED 가능 (실패한 Step 부터)
// .stopAnd... STOPPED 가능 (지정한 Step 부터)
// ========================================================================
@Configuration
static class TerminalConfig {
private static final Logger log = LoggerFactory.getLogger(TerminalConfig.class);
@Bean
Job terminalJob(JobRepository jobRepository,
Step validateStep, Step tcSettleStep) {
return new JobBuilder("terminalJob", jobRepository)
.start(validateStep)
.on("VALID").to(tcSettleStep)
.from(validateStep)
.on("INVALID").fail() // Job FAILED
.from(validateStep)
.on("NEEDS_APPROVAL").stopAndRestart(tcSettleStep) // Job STOPPED
.from(tcSettleStep)
.on("*").end() // Job COMPLETED
.end()
.build();
}
@Bean
Step validateStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("validateStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
BigDecimal total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(amount),0) FROM orders "
+ "WHERE status='COMPLETED' AND DATE(ordered_at)=?",
BigDecimal.class, date);
chunkContext.getStepContext().getStepExecution()
.getExecutionContext().put("dayTotal", total);
return RepeatStatus.FINISHED;
}, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
BigDecimal total = (BigDecimal) stepExecution.getExecutionContext().get("dayTotal");
if (total == null || total.signum() == 0) {
return new ExitStatus("INVALID");
}
// 하루 정산액이 1,500만 원을 넘으면 사람의 승인을 받습니다.
if (total.compareTo(new BigDecimal("15000000")) > 0) {
log.info("[validate] 승인 필요 금액 감지 → ExitStatus=NEEDS_APPROVAL");
return new ExitStatus("NEEDS_APPROVAL");
}
return new ExitStatus("VALID");
}
})
.build();
}
@Bean
Step tcSettleStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("settleStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.build();
}
}
// ========================================================================
// [10-5] 실패를 은폐하는 Job ← 이 스텝에서 가장 위험한 코드
//
// settleStep 이 FAILED 인데 Job 은 COMPLETED 로 끝납니다.
// 로그만 보면 아무 문제가 없어 보입니다. 반드시 SQL 로 확인하세요.
//
// SELECT je.STATUS job_status, se.STEP_NAME, se.STATUS step_status
// FROM BATCH_JOB_EXECUTION je JOIN BATCH_STEP_EXECUTION se USING (JOB_EXECUTION_ID)
// WHERE je.JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION);
// ========================================================================
@Configuration
static class HidingConfig {
private static final Logger log = LoggerFactory.getLogger(HidingConfig.class);
@Bean
Job hidingJob(JobRepository jobRepository,
Step hcSettleStep, Step hcRecoveryStep, Step hcReportStep) {
return new JobBuilder("hidingJob", jobRepository)
.start(hcSettleStep)
.on("COMPLETED").to(hcReportStep)
.from(hcSettleStep)
.on("FAILED").to(hcRecoveryStep).end() // ← 실패를 지우는 한 줄
.from(hcReportStep)
.on("*").end()
.end()
.build();
}
@Bean
Step hcSettleStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("settleStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
// settlement.uk_settlement_order 에 걸려 DuplicateKeyException 이 납니다.
jdbcTemplate.update(
"INSERT INTO settlement "
+ "(order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount) "
+ "SELECT order_id, customer_id, DATE(ordered_at), amount, 0.0300, "
+ " ROUND(amount*0.0300,2), amount - ROUND(amount*0.0300,2) "
+ " FROM orders WHERE status='COMPLETED' AND order_id <= 100");
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step hcRecoveryStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("recoveryStep", jobRepository)
.tasklet((c, cc) -> {
log.info("[recovery] 부분 정산 데이터를 정리했습니다");
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step hcReportStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("reportStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.build();
}
}
// ========================================================================
// [10-5] 교정판 — 복구는 하되 Job 은 실패로 끝냅니다
//
// HidingConfig 와 딱 한 곳만 다릅니다: .end() → .fail()
// 그 한 줄로 알람도 울리고 재시작도 가능해집니다.
// ========================================================================
@Configuration
static class SafeConfig {
private static final Logger log = LoggerFactory.getLogger(SafeConfig.class);
@Bean
Job safeJob(JobRepository jobRepository,
Step scSettleStep, Step scRecoveryStep, Step scReportStep) {
return new JobBuilder("safeJob", jobRepository)
.start(scSettleStep)
.on("COMPLETED").to(scReportStep)
.from(scSettleStep)
.on("FAILED").to(scRecoveryStep) // 복구는 하되
.from(scRecoveryStep)
.on("*").fail() // Job 은 실패로 끝냅니다
.from(scReportStep)
.on("*").end()
.end()
.build();
}
@Bean
Step scSettleStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("settleStep", jobRepository)
.tasklet((c, cc) -> {
jdbcTemplate.update(
"INSERT INTO settlement "
+ "(order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount) "
+ "SELECT order_id, customer_id, DATE(ordered_at), amount, 0.0300, "
+ " ROUND(amount*0.0300,2), amount - ROUND(amount*0.0300,2) "
+ " FROM orders WHERE status='COMPLETED' AND order_id <= 100");
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step scRecoveryStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("recoveryStep", jobRepository)
.tasklet((c, cc) -> {
log.info("[recovery] 부분 정산 데이터를 정리했습니다");
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step scReportStep(JobRepository jobRepository, PlatformTransactionManager tm) {
return new StepBuilder("reportStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.build();
}
}
// ========================================================================
// [10-6] JobExecutionDecider — 주문 건수로 정산 방식 분기
//
// Decider 는 BATCH_STEP_EXECUTION 에 행을 남기지 않습니다.
// 그리고 재시작할 때마다 "다시 평가"됩니다.
// 그래서 판단 근거를 Job Context 에 저장해 두고 재사용합니다.
// ========================================================================
static class SettleModeDecider implements JobExecutionDecider {
private static final Logger log = LoggerFactory.getLogger(SettleModeDecider.class);
/** 이 값을 false 로 바꾸면 재시작 때마다 DB 를 다시 조회합니다 (함정 재현용). */
static final boolean USE_CONTEXT_CACHE = true;
private static final long BULK_THRESHOLD = 1_000L;
private final JdbcTemplate jdbcTemplate;
SettleModeDecider(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
String date = jobExecution.getJobParameters().getString("date");
ExecutionContext ctx = jobExecution.getExecutionContext();
long count;
if (USE_CONTEXT_CACHE && ctx.containsKey("targetCount")) {
count = ctx.getLong("targetCount");
log.info("컨텍스트에서 targetCount={} 재사용 → {}", count, mode(count));
} else {
count = queryCount(date);
ctx.putLong("targetCount", count);
log.info("date={}, targetCount={} → {}", date, count, mode(count));
}
return new FlowExecutionStatus(mode(count));
}
private long queryCount(String date) {
Long c = (date == null)
? jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED'", Long.class)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED' AND DATE(ordered_at)=?",
Long.class, date);
return c == null ? 0L : c;
}
private String mode(long c) {
if (c == 0) return "NO_DATA";
return (c >= BULK_THRESHOLD) ? "BULK" : "NORMAL";
}
}
@Configuration
static class DeciderConfig {
@Bean
SettleModeDecider settleModeDecider(JdbcTemplate jdbcTemplate) {
return new SettleModeDecider(jdbcTemplate);
}
@Bean
Job deciderJob(JobRepository jobRepository, SettleModeDecider settleModeDecider,
Step dcPrepareStep, Step bulkSettleStep,
Step normalSettleStep, Step dcSkipNoticeStep) {
return new JobBuilder("deciderJob", jobRepository)
.start(dcPrepareStep)
.next(settleModeDecider)
.on("BULK").to(bulkSettleStep)
.from(settleModeDecider)
.on("NORMAL").to(normalSettleStep)
.from(settleModeDecider)
.on("NO_DATA").to(dcSkipNoticeStep)
.from(settleModeDecider)
.on("*").fail() // 안전망
.end()
.build();
}
@Bean
Step dcPrepareStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("prepareStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step bulkSettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("bulkSettleStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step normalSettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("normalSettleStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step dcSkipNoticeStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("skipNoticeStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// [10-7] .split() — 병렬 흐름
//
// 로그의 스레드 이름이 agg-1 / agg-2 로 갈리는지 확인하세요.
// main 스레드에서 둘 다 돈다면 split 이 아니라 next 로 이어진 것입니다.
// ========================================================================
@Configuration
static class SplitConfig {
private static final Logger log = LoggerFactory.getLogger(SplitConfig.class);
@Bean
Flow customerAggFlow(Step customerAggStep) {
return new FlowBuilder<Flow>("customerAggFlow").start(customerAggStep).build();
}
@Bean
Flow cityAggFlow(Step cityAggStep) {
return new FlowBuilder<Flow>("cityAggFlow").start(cityAggStep).build();
}
@Bean
Job splitJob(JobRepository jobRepository,
Step spSettleStep, Step spReportStep,
Flow customerAggFlow, Flow cityAggFlow) {
return new JobBuilder("splitJob", jobRepository)
.start(spSettleStep)
.next(new FlowBuilder<Flow>("parallelAgg")
.split(new SimpleAsyncTaskExecutor("agg-"))
.add(customerAggFlow, cityAggFlow)
.build())
.next(spReportStep)
.end()
.build();
}
/**
* 흐름 개수가 늘어나면 SimpleAsyncTaskExecutor 대신 이쪽을 쓰세요.
* SimpleAsyncTaskExecutor 는 요청마다 새 스레드를 만들고 재사용하지 않습니다.
* 그리고 병렬 흐름 수보다 HikariCP 풀(maximum-pool-size: 20)이 커야 합니다.
*/
static ThreadPoolTaskExecutor pooledExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(4);
exec.setMaxPoolSize(4);
exec.setThreadNamePrefix("agg-");
exec.initialize();
return exec;
}
@Bean
Step customerAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("customerAggStep", r)
.tasklet((c, cc) -> {
long t0 = System.currentTimeMillis();
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT customer_id FROM orders "
+ "WHERE status='COMPLETED' GROUP BY customer_id) x", Integer.class);
log.info("[customerAgg] {}명 고객 집계 완료 ({}ms)", n, System.currentTimeMillis() - t0);
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step cityAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("cityAggStep", r)
.tasklet((c, cc) -> {
long t0 = System.currentTimeMillis();
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT c.city FROM orders o "
+ "JOIN customers c ON c.customer_id=o.customer_id "
+ "WHERE o.status='COMPLETED' GROUP BY c.city) x", Integer.class);
log.info("[cityAgg] {}개 도시 집계 완료 ({}ms)", n, System.currentTimeMillis() - t0);
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step spSettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("settleStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step spReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("reportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// [10-8] Flow 재사용과 FlowStep
//
// .start(flow) → Flow 안의 Step 들이 그대로 펼쳐집니다
// FlowStep → 묶음 행이 하나 더 생깁니다 (settleFlowStep)
// ========================================================================
@Configuration
static class FlowReuseConfig {
@Bean
Flow settleFlow(Step frValidateStep, Step frSettleStep, Step frVerifyStep) {
return new FlowBuilder<Flow>("settleFlow")
.start(frValidateStep)
.on("VALID").to(frSettleStep)
.from(frValidateStep)
.on("*").fail()
.from(frSettleStep)
.on("*").to(frVerifyStep)
.build();
}
/** Flow 를 하나의 Step 으로 감쌉니다 (FlowStep). */
@Bean
Step settleFlowStep(JobRepository jobRepository, Flow settleFlow) {
return new StepBuilder("settleFlowStep", jobRepository)
.flow(settleFlow)
.build();
}
@Bean
Job dailyJob(JobRepository jobRepository, Flow settleFlow, Step dailyReportStep) {
return new JobBuilder("dailyJob", jobRepository)
.start(settleFlow)
.next(dailyReportStep)
.end()
.build();
}
@Bean
Job monthlyJob(JobRepository jobRepository, Step settleFlowStep,
Step monthlyReportStep, Step archiveStep) {
// 같은 Flow 를 FlowStep 으로 감싸서 재사용합니다.
return new JobBuilder("monthlyJob", jobRepository)
.start(settleFlowStep)
.next(monthlyReportStep)
.next(archiveStep)
.build();
}
@Bean
Step frValidateStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("settle.validate", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution se) {
return new ExitStatus("VALID");
}
})
.build();
}
@Bean
Step frSettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("settle.write", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step frVerifyStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("settle.verify", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step dailyReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("dailyReportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step monthlyReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("monthlyReportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step archiveStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("archiveStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// [10-9] 종합 — 정산 Job 의 완성형
//
// prepareStep ─► Decider ─┬─ BULK ─► bulkSettleStep ─┬─► split(agg) ─► reportStep ─► end()
// ├─ NORMAL ─► normalSettleStep ─┘
// └─ NO_DATA ─► skipNoticeStep ────────────────────────────► end()
// (실패 시 recoveryStep ─► fail())
//
// 실행 전: TRUNCATE TABLE settlement;
// ========================================================================
@Configuration
static class Step10Job {
private static final Logger log = LoggerFactory.getLogger(Step10Job.class);
@Bean
ExecutionContextPromotionListener s10Promotion() {
ExecutionContextPromotionListener l = new ExecutionContextPromotionListener();
l.setKeys(new String[]{"targetCount"});
l.setStatuses(new String[]{"COMPLETED", "FAILED"});
return l;
}
@Bean
Job step10Job(JobRepository jobRepository, SettleModeDecider settleModeDecider,
Step s10PrepareStep, Step s10BulkStep, Step s10NormalStep,
Step s10SkipStep, Step s10RecoveryStep, Step s10ReportStep,
Flow s10AggFlow) {
return new JobBuilder("step10Job", jobRepository)
.start(s10PrepareStep)
.next(settleModeDecider)
.on("BULK").to(s10BulkStep)
.from(settleModeDecider)
.on("NORMAL").to(s10NormalStep)
.from(settleModeDecider)
.on("NO_DATA").to(s10SkipStep)
.from(settleModeDecider)
.on("*").fail()
// 정산 Step 둘 다 성공하면 집계 흐름으로
.from(s10BulkStep).on("COMPLETED").to(s10AggFlow)
.from(s10NormalStep).on("COMPLETED").to(s10AggFlow)
// 실패하면 복구 후 Job 은 FAILED (실패를 은폐하지 않습니다)
.from(s10BulkStep).on("FAILED").to(s10RecoveryStep)
.from(s10NormalStep).on("FAILED").to(s10RecoveryStep)
.from(s10RecoveryStep).on("*").fail()
// 집계 후 리포트
.from(s10AggFlow).on("*").to(s10ReportStep)
.from(s10ReportStep).on("*").end()
.from(s10SkipStep).on("*").end()
.end()
.build();
}
@Bean
Flow s10AggFlow(Step customerAggStep, Step cityAggStep) {
return new FlowBuilder<Flow>("s10AggFlow")
.split(new SimpleAsyncTaskExecutor("agg-"))
.add(new FlowBuilder<Flow>("f1").start(customerAggStep).build(),
new FlowBuilder<Flow>("f2").start(cityAggStep).build())
.build();
}
@Bean
Step s10PrepareStep(JobRepository r, PlatformTransactionManager tm,
JdbcTemplate jdbc, ExecutionContextPromotionListener s10Promotion) {
return new StepBuilder("prepareStep", r)
.tasklet((contribution, chunkContext) -> {
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
Long cnt = jdbc.queryForObject(
"SELECT COUNT(*) FROM orders "
+ "WHERE status='COMPLETED' AND DATE(ordered_at)=?",
Long.class, date);
chunkContext.getStepContext().getStepExecution()
.getExecutionContext().putLong("targetCount", cnt == null ? 0L : cnt);
log.info("[prepare] date={}, targetCount={}", date, cnt);
return RepeatStatus.FINISHED;
}, tm)
.listener(s10Promotion)
.build();
}
@Bean
Step s10BulkStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("bulkSettleStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step s10NormalStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("normalSettleStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step s10SkipStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("skipNoticeStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step s10RecoveryStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("recoveryStep", r)
.tasklet((c, cc) -> {
log.info("[recovery] 부분 정산 데이터를 정리했습니다");
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step s10ReportStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("reportStep", r)
.tasklet((contribution, chunkContext) -> {
ExecutionContext jobCtx = chunkContext.getStepContext()
.getStepExecution().getJobExecution().getExecutionContext();
String date = (String) chunkContext.getStepContext()
.getJobParameters().get("date");
long cnt = jobCtx.getLong("targetCount", 0L);
BigDecimal fee = jdbc.queryForObject(
"SELECT COALESCE(SUM(fee_amount),0) FROM settlement WHERE settle_date=?",
BigDecimal.class, date);
log.info("[report] {} 정산 {}건 ({}), 수수료 합계 {}",
date, cnt, cnt >= 1000 ? "BULK" : "NORMAL", fee);
return RepeatStatus.FINISHED;
}, tm).build();
}
}
// ========================================================================
// 흐름 확인용 SQL 모음
// mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "..."
// ========================================================================
static final String FLOW_INSPECT_SQL = """
-- [10-2] STATUS 와 EXIT_CODE 는 다른 값입니다
SELECT STEP_NAME, STATUS, EXIT_CODE
FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC LIMIT 4;
-- [10-5] 실패 은폐 확인 — job_status 가 COMPLETED 인데 step_status 가 FAILED 라면 사고입니다
SELECT je.JOB_EXECUTION_ID, je.STATUS AS job_status, je.EXIT_CODE AS job_exit,
se.STEP_NAME, se.STATUS AS step_status, se.EXIT_CODE AS step_exit
FROM BATCH_JOB_EXECUTION je
JOIN BATCH_STEP_EXECUTION se USING (JOB_EXECUTION_ID)
WHERE je.JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY se.STEP_EXECUTION_ID;
-- 운영에서 주기적으로 돌려야 하는 감사 쿼리
SELECT je.JOB_EXECUTION_ID, je.STATUS, COUNT(*) AS failed_steps
FROM BATCH_JOB_EXECUTION je
JOIN BATCH_STEP_EXECUTION se USING (JOB_EXECUTION_ID)
WHERE je.STATUS = 'COMPLETED' AND se.STATUS = 'FAILED'
GROUP BY je.JOB_EXECUTION_ID, je.STATUS;
-- [10-6] Decider 는 행을 남기지 않습니다 (settleModeDecider 라는 이름이 없어야 정상)
SELECT STEP_NAME, READ_COUNT, WRITE_COUNT
FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC LIMIT 3;
-- [10-8] FlowStep 을 쓰면 묶음 행(settleFlowStep)이 하나 더 생깁니다
SELECT STEP_NAME, STATUS, EXIT_CODE
FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = (SELECT MAX(JOB_EXECUTION_ID) FROM BATCH_JOB_EXECUTION)
ORDER BY STEP_EXECUTION_ID;
""";
}
7문제의 문제지입니다. 각 문제는 // 여기에 작성: 자리를 비워 두었습니다.
Q4Job 은 실행하면 COMPLETED 로 끝납니다. 성공했다고 넘어가면 틀린 것입니다. BATCH_STEP_EXECUTION 을 조회해 FAILED 인 Step 이 있는지 확인해야 문제가 보입니다.decide() 안에서 COUNT(*) 를 부르면 절반만 맞은 답입니다.package com.example.batch.step10;
/*
* ============================================================================
* Step 10 — 흐름 제어 : 연습문제 (7문제)
* ============================================================================
*
* 각 문제의 "// 여기에 작성:" 자리를 채우세요. 정답은 Solution.java 입니다.
*
* 실행 전 준비
* mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -e "TRUNCATE TABLE settlement;"
* ============================================================================
*/
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.batch.core.job.builder.FlowBuilder;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.Flow;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
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.core.task.SimpleAsyncTaskExecutor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
public final class Exercise {
private Exercise() {
}
// ========================================================================
// 문제 1. [관찰 + SQL]
//
// "on() 이 매칭하는 것은 BatchStatus 가 아니라 ExitStatus 다" 를
// 메타데이터 한 줄로 증명하세요.
//
// 힌트: 어떤 두 컬럼의 값이 서로 다른 행을 보여 주면 증명이 됩니까?
//
// (a) 증명용 SQL
// 여기에 작성:
//
// (b) 그 결과가 왜 증명이 되는지 한 문장
// 여기에 작성:
// ========================================================================
// ========================================================================
// 문제 2.
// settlement 테이블에 정산 결과가 하나도 안 들어갔으면 "EMPTY",
// 1건 이상이면 "WRITTEN" 을 ExitStatus 로 내보내는 리스너를 완성하세요.
//
// 주의: BatchStatus 는 건드리면 안 됩니다.
// ========================================================================
@Configuration
static class Q2 {
@Bean
Step q2SettleStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("q2SettleStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
// 여기에 작성: settlement COUNT(*) 를 조회해 EMPTY / WRITTEN 반환
return null;
}
})
.build();
}
}
// ========================================================================
// 문제 3. [예측 문제 — 실행하기 전에 답을 적으세요]
//
// 아래 Job 에서 q3SourceStep 의 ExitStatus 가 "COMPLETED" 일 때
// 어느 Step 으로 갑니까?
//
// (a) 예측 (실행 전에 적을 것)
// 여기에 작성:
//
// (b) 그렇게 판단한 이유
// 여기에 작성:
//
// (c) 실행 결과
// 여기에 작성:
//
// (d) ExitStatus 가 "COMPLETED_WITH_SKIPS" 였다면 어디로 갑니까?
// 여기에 작성:
// ========================================================================
@Configuration
static class Q3 {
private static final Logger log = LoggerFactory.getLogger(Q3.class);
@Bean
Job q3Job(JobRepository jobRepository, PlatformTransactionManager tm) {
Step src = q3Step("q3SourceStep", jobRepository, tm);
return new JobBuilder("q3Job", jobRepository)
.start(src)
.on("COMPLETED*").to(q3Step("q3PrefixStep", jobRepository, tm))
.from(src)
.on("*").to(q3Step("q3AllStep", jobRepository, tm))
.from(src)
.on("COMPLETED").to(q3Step("q3ExactStep", jobRepository, tm))
.from(src)
.on("COMPLETED_WITH_*").to(q3Step("q3SkipStep", jobRepository, tm))
.end()
.build();
}
private Step q3Step(String name, JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder(name, r)
.tasklet((c, cc) -> {
log.info("[{}] 선택됨", name);
return RepeatStatus.FINISHED;
}, tm)
.build();
}
}
// ========================================================================
// 문제 4. [진단 + 수정]
//
// 아래 Job 을 실행하면 Job 은 COMPLETED 로 끝납니다.
// 그런데 이 Job 에는 심각한 문제가 있습니다.
//
// (a) 문제를 드러내는 SQL 을 쓰세요.
// 여기에 작성:
//
// (b) 이 상태가 운영에서 왜 위험한지 세 가지를 적으세요.
// 여기에 작성:
//
// (c) .end("RECOVERED") 로 바꾸면 해결됩니까? 왜 그렇습니까/아닙니까?
// 여기에 작성:
//
// (d) Job 정의를 고치세요.
// ========================================================================
@Configuration
static class Q4 {
private static final Logger log = LoggerFactory.getLogger(Q4.class);
@Bean
Job q4Job(JobRepository jobRepository,
Step q4SettleStep, Step q4RecoveryStep, Step q4ReportStep) {
return new JobBuilder("q4Job", jobRepository)
.start(q4SettleStep)
.on("COMPLETED").to(q4ReportStep)
.from(q4SettleStep)
.on("FAILED").to(q4RecoveryStep).end()
// 여기에 작성: 위 한 줄을 어떻게 고쳐야 합니까?
.from(q4ReportStep)
.on("*").end()
.end()
.build();
}
@Bean
Step q4SettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q4SettleStep", r)
.tasklet((c, cc) -> {
throw new IllegalStateException("정산 중 오류");
}, tm)
.build();
}
@Bean
Step q4RecoveryStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q4RecoveryStep", r)
.tasklet((c, cc) -> {
log.info("[q4Recovery] 정리 완료");
return RepeatStatus.FINISHED;
}, tm)
.build();
}
@Bean
Step q4ReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q4ReportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.build();
}
}
// ========================================================================
// 문제 5.
// 고객 등급별 정산 방식을 고르는 Decider 를 만드세요.
//
// 요구사항
// - VIP 고객의 주문이 100건 이상이면 "PRIORITY"
// - 1건 이상 100건 미만이면 "NORMAL"
// - 0건이면 "SKIP"
// - 재시작해도 첫 실행과 같은 경로로 가야 합니다 ← 이게 핵심입니다
//
// 힌트: 단순히 decide() 안에서 COUNT(*) 를 부르면 절반만 맞은 답입니다.
// ========================================================================
static class Q5Decider implements JobExecutionDecider {
private final JdbcTemplate jdbcTemplate;
Q5Decider(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
// 여기에 작성:
// 1) Job ExecutionContext 에 이미 판단 근거가 있으면 그것을 씁니다
// 2) 없으면 DB 를 조회하고 컨텍스트에 저장합니다
// 3) 건수에 따라 PRIORITY / NORMAL / SKIP 을 반환합니다
return null;
}
static final String VIP_COUNT_SQL = """
SELECT COUNT(*) FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'COMPLETED' AND c.grade = 'VIP'
AND DATE(o.ordered_at) = ?
""";
}
@Configuration
static class Q5 {
@Bean
Job q5Job(JobRepository jobRepository, Q5Decider q5Decider,
Step q5PrepareStep, Step q5PriorityStep,
Step q5NormalStep, Step q5SkipStep) {
return new JobBuilder("q5Job", jobRepository)
.start(q5PrepareStep)
// 여기에 작성: Decider 를 붙이고 세 갈래 + 안전망 전이를 만드세요
.build();
}
@Bean
Step q5PrepareStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q5PrepareStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step q5PriorityStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q5PriorityStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step q5NormalStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q5NormalStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step q5SkipStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q5SkipStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// 문제 6.
// q6StatusAggStep 과 q6GradeAggStep 을 병렬로 실행하고,
// 둘 다 끝난 뒤 q6ReportStep 이 돌게 만드세요.
//
// 검증 방법: 로그의 스레드 이름이 갈리는지 확인합니다.
// 둘 다 [main] 이면 병렬이 아닙니다.
// ========================================================================
@Configuration
static class Q6 {
private static final Logger log = LoggerFactory.getLogger(Q6.class);
@Bean
Job q6Job(JobRepository jobRepository,
Step q6StatusAggStep, Step q6GradeAggStep, Step q6ReportStep) {
return new JobBuilder("q6Job", jobRepository)
// 여기에 작성: split 으로 두 집계 Step 을 병렬 실행한 뒤 리포트로
.build();
}
@Bean
Step q6StatusAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("q6StatusAggStep", r)
.tasklet((c, cc) -> {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT status FROM orders GROUP BY status) x",
Integer.class);
log.info("[statusAgg] {}개 상태 집계", n); // 4
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step q6GradeAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("q6GradeAggStep", r)
.tasklet((c, cc) -> {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT grade FROM customers GROUP BY grade) x",
Integer.class);
log.info("[gradeAgg] {}개 등급 집계", n); // 4
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step q6ReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("q6ReportStep", r)
.tasklet((c, cc) -> {
log.info("[q6Report] 두 집계가 모두 끝난 뒤 실행됩니다");
return RepeatStatus.FINISHED;
}, tm).build();
}
}
// ========================================================================
// 문제 7. [설계 문제 — 다이어그램을 먼저 그리세요]
//
// 요구사항
// 1) importStep 으로 외부 파일을 적재한다
// 2) 적재 결과가 0건이면 알림만 보내고 정상 종료한다
// 3) 1건 이상이면 검증(validateStep)을 한다
// 4) 검증 실패면 알림을 보내되 Job 은 반드시 FAILED 로 끝나야 한다
// 5) 검증 성공이면 정산(settleStep)과 백업(backupStep)을 병렬로 돌린다
// 6) 둘 다 끝나면 리포트(reportStep) 후 정상 종료한다
//
// (a) ASCII 흐름도
// 여기에 작성:
//
// (b) 위 흐름도를 코드로
// ========================================================================
@Configuration
static class Q7 {
@Bean
Job q7Job(JobRepository jobRepository,
Step importStep, Step q7ValidateStep, Step q7NoticeStep,
Step q7AlertStep, Step q7SettleStep, Step q7BackupStep,
Step q7ReportStep) {
return new JobBuilder("q7Job", jobRepository)
// 여기에 작성:
.build();
}
// 아래 Step 들은 이미 준비되어 있습니다.
@Bean
Step importStep(JobRepository r, PlatformTransactionManager tm) {
return simple("importStep", r, tm);
}
@Bean
Step q7ValidateStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7ValidateStep", r, tm);
}
@Bean
Step q7NoticeStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7NoticeStep", r, tm);
}
@Bean
Step q7AlertStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7AlertStep", r, tm);
}
@Bean
Step q7SettleStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7SettleStep", r, tm);
}
@Bean
Step q7BackupStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7BackupStep", r, tm);
}
@Bean
Step q7ReportStep(JobRepository r, PlatformTransactionManager tm) {
return simple("q7ReportStep", r, tm);
}
private Step simple(String name, JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder(name, r).tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
}
7문제의 정답과 해설 주석입니다. 문제를 풀어 본 뒤에 여세요.
STATUS='COMPLETED' 이면서 EXIT_CODE='NO_DATA' 인 행이 존재한다는 것이 곧 "on() 은 EXIT_CODE 를 본다"의 증거입니다.afterStep 에서 원래 값을 보존하는 것까지가 정답입니다. return ExitStatus.COMPLETED 로 뭉개면 다른 리스너나 프레임워크가 설정한 값을 지웁니다. 리스너 여러 개가 붙었을 때 뒤에 등록된 것이 이긴다는 점도 함께 설명합니다."COMPLETED"(정확 일치)입니다. "C?MPLETED" 도 매칭되지만 ? 하나가 와일드카드라 덜 구체적입니다. 구체성 순서를 표로 정리했습니다..end() → .fail() 이 전부가 아닙니다. 왜 .end("코드") 로는 해결되지 않는지 — ExitStatus 만 바뀌고 BatchStatus 는 여전히 COMPLETED 라 알람도 재시작도 그대로 막힌다 — 를 설명합니다.jobExecution.getExecutionContext() 를 캐시로 쓰는 형태입니다. "첫 실행에는 조회, 재시작에는 재사용"이 왜 필요한지 실제 사고 시나리오(389건 → 1,200건으로 변해 경로가 바뀜)로 풉니다..on("*").fail() 로 어떻게 번역되는지가 핵심입니다.package com.example.batch.step10;
/*
* ============================================================================
* Step 10 — 흐름 제어 : 정답과 해설
* ============================================================================
* 문제를 직접 풀어 본 뒤에 여세요.
* ============================================================================
*/
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.StepExecutionListener;
import org.springframework.batch.core.job.builder.FlowBuilder;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.job.flow.Flow;
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
import org.springframework.batch.core.job.flow.JobExecutionDecider;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
public final class Solution {
private Solution() {
}
/*
* ========================================================================
* 정답 1.
*
* (a) 증명용 SQL
*
* SELECT STEP_NAME, STATUS, EXIT_CODE
* FROM BATCH_STEP_EXECUTION
* WHERE STATUS <> EXIT_CODE
* ORDER BY STEP_EXECUTION_ID DESC LIMIT 5;
*
* 결과 예시
* +-----------+-----------+-----------+
* | STEP_NAME | STATUS | EXIT_CODE |
* +-----------+-----------+-----------+
* | checkStep | COMPLETED | NO_DATA |
* | checkStep | COMPLETED | HAS_DATA |
* +-----------+-----------+-----------+
*
* (b) 왜 증명이 되는가
*
* 두 행 모두 STATUS 는 COMPLETED 로 같습니다. 그런데 흐름은 서로
* 다른 Step 으로 갈라졌습니다. STATUS 가 같은데 경로가 달라졌다면,
* 경로를 결정한 것은 STATUS 가 아니라 다른 값 — 즉 EXIT_CODE 입니다.
*
* 더 정확히 말하면 BatchStatus 는 enum 이라 값이 8개로 고정입니다.
* "NO_DATA" 같은 값은 애초에 표현할 수도 없습니다.
* on() 이 임의의 문자열을 받는다는 사실 자체가, 그것이 String 인
* ExitStatus 를 본다는 방증입니다.
* ========================================================================
*/
// ========================================================================
// 정답 2.
//
// 핵심은 "BatchStatus 는 건드리지 않는다" 입니다.
// afterStep 에서 새 ExitStatus 를 반환해도 BatchStatus 는 그대로
// COMPLETED 입니다. 둘은 별개의 값입니다.
//
// 주의할 점 하나 더 — 이 리스너가 원래 ExitStatus 를 덮어씁니다.
// Step 이 실패했을 때도 이 콜백은 호출되므로, 실패 상태를 뭉개지 않도록
// 방어해야 합니다. 아래 코드는 실패 시 원래 값을 그대로 돌려줍니다.
// 이 방어가 없으면 실패한 Step 이 "WRITTEN" 으로 보고되어
// on("FAILED") 전이를 영영 타지 못합니다. 조용히 틀리는 전형입니다.
// ========================================================================
@Configuration
static class A2 {
private static final Logger log = LoggerFactory.getLogger(A2.class);
@Bean
Step a2SettleStep(JobRepository jobRepository, PlatformTransactionManager tm,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("a2SettleStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
// 실패한 Step 의 상태는 절대 덮어쓰지 않습니다.
if (ExitStatus.FAILED.getExitCode()
.equals(stepExecution.getExitStatus().getExitCode())) {
return stepExecution.getExitStatus();
}
Long cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM settlement", Long.class);
long c = (cnt == null) ? 0L : cnt;
log.info("[a2SettleStep] settlement 행 수 = {}", c);
return (c == 0) ? new ExitStatus("EMPTY") : new ExitStatus("WRITTEN");
}
})
.build();
}
}
/*
* ========================================================================
* 정답 3.
*
* (a)(c) ExitStatus 가 "COMPLETED" 일 때 → q3ExactStep 으로 갑니다.
*
* (b) 이유
* 네 패턴 중 "COMPLETED", "COMPLETED*", "*" 세 개가 매칭됩니다.
* ("COMPLETED_WITH_*" 는 매칭되지 않습니다.)
* SimpleFlow 는 전이를 구체성 순으로 정렬해 두고 앞에서부터 검사하며,
* 가장 구체적인 것이 이깁니다.
*
* 구체적 ──────────────────────────────────► 포괄적
* "COMPLETED" 와일드카드 0개
* "C?MPLETED" ? 1개 (문자 수 고정)
* "COMPLETED_WITH_*" 고정 문자 많음 + * 1개
* "COMPLETED*" 고정 문자 적음 + * 1개
* "*" 전부
*
* 중요한 것은 "소스 코드에 적은 순서는 아무 상관이 없다"는 점입니다.
* Q3 에서는 일부러 "COMPLETED*" 를 맨 위에, "COMPLETED" 를 세 번째에
* 적었습니다. 그래도 정확 일치가 이깁니다.
*
* (d) ExitStatus 가 "COMPLETED_WITH_SKIPS" 였다면
* → q3SkipStep 으로 갑니다.
*
* "COMPLETED" 는 정확 일치가 아니므로 탈락.
* "C?MPLETED" 도 길이가 안 맞아 탈락.
* 남은 "COMPLETED_WITH_*" 와 "COMPLETED*" 중
* 고정 문자가 더 많은 "COMPLETED_WITH_*" 가 이깁니다.
*
* 참고 — COMPLETED_WITH_SKIPS 는 가상의 값이 아닙니다.
* Step 11 의 faultTolerant + skip 을 쓰고 afterStep 에서 스킵 건수를
* 확인해 이런 코드를 붙이는 것이 흔한 패턴입니다.
* ========================================================================
*/
// ========================================================================
// 정답 4.
//
// (a) 문제를 드러내는 SQL
//
// SELECT je.JOB_EXECUTION_ID, je.STATUS AS job_status,
// se.STEP_NAME, se.STATUS AS step_status
// FROM BATCH_JOB_EXECUTION je
// JOIN BATCH_STEP_EXECUTION se USING (JOB_EXECUTION_ID)
// WHERE je.STATUS = 'COMPLETED' AND se.STATUS = 'FAILED';
//
// 한 행이라도 나오면 "실패가 은폐된 Job" 입니다.
// 이 쿼리는 운영에서 주기적으로 돌릴 가치가 있습니다.
//
// (b) 왜 위험한가 (세 가지)
//
// 1. 모니터링이 침묵합니다.
// 알람은 대부분 BATCH_JOB_EXECUTION.STATUS='FAILED' 나
// 프로세스 종료 코드에 걸려 있습니다. 둘 다 정상값입니다.
//
// 2. 스케줄러가 성공으로 판정합니다.
// BatchLabApplication 의
// System.exit(SpringApplication.exit(SpringApplication.run(...)))
// 이 0 을 반환합니다. Quartz / Airflow / 쿠버네티스 Job 이
// 모두 성공으로 보고, 후속 잡이 미완성 데이터 위에서 돕니다.
//
// 3. 재시작이 막힙니다.
// Job 이 COMPLETED 이므로 같은 파라미터로 재실행하면
// JobInstanceAlreadyCompleteException 이 납니다.
// 고쳐서 다시 돌리려면 파라미터를 바꾸거나 메타데이터를
// 손대야 합니다. 사고 대응 시간이 그만큼 늘어납니다.
//
// (네 번째를 덧붙이면: 정산이 반쯤 된 채로 남습니다.
// 70,000건 중 일부만 settlement 에 들어간 상태인데 아무도 모릅니다.)
//
// (c) .end("RECOVERED") 로 바꾸면 해결됩니까? → 아닙니다.
//
// .end(String) 은 Job 의 ExitStatus(EXIT_CODE 컬럼)만 바꿉니다.
// BatchStatus 는 여전히 COMPLETED 입니다.
//
// BATCH_JOB_EXECUTION.STATUS = COMPLETED ← 그대로
// BATCH_JOB_EXECUTION.EXIT_CODE = RECOVERED ← 이것만 바뀜
//
// 따라서 (b)의 1·2·3 이 전부 그대로입니다.
// "실패처럼 보이는 문자열"을 넣는다고 실패가 되지 않습니다.
// 상태를 바꾸는 것은 .fail() 뿐입니다.
//
// (d) 고친 코드 ↓
// ========================================================================
@Configuration
static class A4 {
private static final Logger log = LoggerFactory.getLogger(A4.class);
@Bean
Job a4Job(JobRepository jobRepository,
Step a4SettleStep, Step a4RecoveryStep, Step a4ReportStep) {
return new JobBuilder("a4Job", jobRepository)
.start(a4SettleStep)
.on("COMPLETED").to(a4ReportStep)
.from(a4SettleStep)
.on("FAILED").to(a4RecoveryStep) // 복구 Step 은 돌리되
.from(a4RecoveryStep)
.on("*").fail() // Job 은 FAILED 로 끝냅니다
.from(a4ReportStep)
.on("*").end()
.end()
.build();
}
@Bean
Step a4SettleStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a4SettleStep", r)
.tasklet((c, cc) -> {
throw new IllegalStateException("정산 중 오류");
}, tm).build();
}
@Bean
Step a4RecoveryStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a4RecoveryStep", r)
.tasklet((c, cc) -> {
log.info("[a4Recovery] 정리 완료");
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step a4ReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a4ReportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// 정답 5.
//
// 절반짜리 답은 이것입니다.
//
// long c = jdbcTemplate.queryForObject(VIP_COUNT_SQL, Long.class, date);
// return new FlowExecutionStatus(c == 0 ? "SKIP" : c >= 100 ? "PRIORITY" : "NORMAL");
//
// 왜 절반인가 — Decider 는 Step 이 아니라서 실행 이력이 남지 않고,
// 재시작할 때마다 처음부터 다시 평가됩니다.
//
// 사고 시나리오
// 1차 실행: VIP 주문 87건 → NORMAL → q5NormalStep 이 절반 처리하다 실패
// 그 사이 데이터가 들어와 VIP 주문이 112건이 됨
// 재시작: 112건 → PRIORITY → q5PriorityStep 이 시작
// → 같은 JobExecution 의 이어서 실행인데 완전히 다른 경로를 탑니다.
// q5NormalStep 이 남긴 부분 데이터 위에서 다른 로직이 돕니다.
//
// 대응: 판단 근거를 Job ExecutionContext 에 저장하고, 있으면 재사용합니다.
// Job ExecutionContext 는 JobExecution 단위로 DB 에 영속되므로
// 재시작해도 살아 있습니다 (Step 09 참고).
//
// 참고 — 캐시하는 것은 "판단 결과"가 아니라 "판단 근거(건수)"입니다.
// 결과만 저장하면 임계치 정책이 바뀌었을 때 반영할 수 없습니다.
// 근거를 저장해 두면 코드를 고친 뒤 재시작했을 때 새 정책이
// 같은 근거에 적용됩니다.
// ========================================================================
static class A5Decider implements JobExecutionDecider {
private static final Logger log = LoggerFactory.getLogger(A5Decider.class);
private static final long PRIORITY_THRESHOLD = 100L;
private static final String CTX_KEY = "vipOrderCount";
private static final String VIP_COUNT_SQL = """
SELECT COUNT(*) FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'COMPLETED' AND c.grade = 'VIP'
AND DATE(o.ordered_at) = ?
""";
private final JdbcTemplate jdbcTemplate;
A5Decider(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
ExecutionContext ctx = jobExecution.getExecutionContext();
String date = jobExecution.getJobParameters().getString("date");
long count;
if (ctx.containsKey(CTX_KEY)) {
count = ctx.getLong(CTX_KEY);
log.info("재시작 — 컨텍스트의 {}={} 를 재사용합니다", CTX_KEY, count);
} else {
Long q = jdbcTemplate.queryForObject(VIP_COUNT_SQL, Long.class, date);
count = (q == null) ? 0L : q;
ctx.putLong(CTX_KEY, count); // ← 첫 실행에서만 저장
log.info("첫 실행 — VIP 주문 {}건 조회", count);
}
String status;
if (count == 0) {
status = "SKIP";
} else if (count >= PRIORITY_THRESHOLD) {
status = "PRIORITY";
} else {
status = "NORMAL";
}
log.info("VIP {}건 → {}", count, status);
return new FlowExecutionStatus(status);
}
}
@Configuration
static class A5 {
@Bean
A5Decider a5Decider(JdbcTemplate jdbcTemplate) {
return new A5Decider(jdbcTemplate);
}
@Bean
Job a5Job(JobRepository jobRepository, A5Decider a5Decider,
Step a5PrepareStep, Step a5PriorityStep,
Step a5NormalStep, Step a5SkipStep) {
return new JobBuilder("a5Job", jobRepository)
.start(a5PrepareStep)
.next(a5Decider)
.on("PRIORITY").to(a5PriorityStep)
.from(a5Decider)
.on("NORMAL").to(a5NormalStep)
.from(a5Decider)
.on("SKIP").to(a5SkipStep)
.from(a5Decider)
.on("*").fail() // 안전망 — 예상 못 한 코드가 오면 조용히 넘어가지 않습니다
.end()
.build();
}
@Bean
Step a5PrepareStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a5PrepareStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step a5PriorityStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a5PriorityStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step a5NormalStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a5NormalStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
@Bean
Step a5SkipStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a5SkipStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
// ========================================================================
// 정답 6.
//
// split 은 Flow 를 받습니다. Step 을 바로 넣을 수 없으므로
// FlowBuilder 로 한 겹 감쌉니다.
//
// 조인은 자동입니다. add() 에 넣은 모든 흐름이 끝나야 다음으로 갑니다.
//
// 확인 포인트: 로그의 스레드 이름이 agg-1 / agg-2 로 갈려야 합니다.
// INFO ... [ agg-1] ... Executing step: [q6StatusAggStep]
// INFO ... [ agg-2] ... Executing step: [q6GradeAggStep]
// INFO ... [ main] ... Executing step: [q6ReportStep]
//
// 둘 다 [main] 이면 split 이 아니라 next 로 이어진 것입니다.
//
// 주의사항 두 가지
// 1. SimpleAsyncTaskExecutor 는 요청마다 새 스레드를 만들고
// 재사용하지 않습니다. 흐름이 많아지면 ThreadPoolTaskExecutor 로.
// 2. 병렬 흐름 수보다 HikariCP 풀이 커야 합니다.
// 이 프로젝트는 maximum-pool-size: 20 이라 여유가 있습니다.
//
// 3. (가장 중요) split 안의 한 흐름이 실패해도 나머지는 끝까지 돕니다.
// 중단되지 않습니다. 따라서 split 에 넣는 Step 들은 서로,
// 그리고 이후 단계와 부수 효과가 독립이어야 합니다.
// "실패했는데 부수 효과는 다 일어난" 상태를 견딜 수 있어야 합니다.
// ========================================================================
@Configuration
static class A6 {
private static final Logger log = LoggerFactory.getLogger(A6.class);
@Bean
Job a6Job(JobRepository jobRepository,
Step a6StatusAggStep, Step a6GradeAggStep, Step a6ReportStep) {
Flow statusFlow = new FlowBuilder<Flow>("a6StatusFlow")
.start(a6StatusAggStep).build();
Flow gradeFlow = new FlowBuilder<Flow>("a6GradeFlow")
.start(a6GradeAggStep).build();
Flow parallel = new FlowBuilder<Flow>("a6ParallelAgg")
.split(new SimpleAsyncTaskExecutor("agg-"))
.add(statusFlow, gradeFlow)
.build();
return new JobBuilder("a6Job", jobRepository)
.start(parallel)
.next(a6ReportStep) // 두 흐름이 모두 끝나야 여기 옵니다
.end()
.build();
}
@Bean
Step a6StatusAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("a6StatusAggStep", r)
.tasklet((c, cc) -> {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT status FROM orders GROUP BY status) x",
Integer.class);
log.info("[statusAgg] {}개 상태 집계", n); // 4
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step a6GradeAggStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("a6GradeAggStep", r)
.tasklet((c, cc) -> {
Integer n = jdbc.queryForObject(
"SELECT COUNT(*) FROM (SELECT grade FROM customers GROUP BY grade) x",
Integer.class);
log.info("[gradeAgg] {}개 등급 집계", n); // 4
return RepeatStatus.FINISHED;
}, tm).build();
}
@Bean
Step a6ReportStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a6ReportStep", r)
.tasklet((c, cc) -> {
log.info("[a6Report] 두 집계가 모두 끝난 뒤 실행됩니다");
return RepeatStatus.FINISHED;
}, tm).build();
}
}
/*
* ========================================================================
* 정답 7.
*
* (a) ASCII 흐름도
*
* ┌────────────┐
* │ importStep │
* └─────┬──────┘
* "EMPTY" │ "LOADED"
* ┌───────────────┴───────────────┐
* ▼ ▼
* ┌──────────────┐ ┌────────────────┐
* │ q7NoticeStep │ │ q7ValidateStep │
* └──────┬───────┘ └───┬────────┬───┘
* │ "INVALID"│ │"VALID"
* │ ▼ │
* │ ┌──────────────┐ │
* │ │ q7AlertStep │ │
* │ └──────┬───────┘ │
* │ │ fail() │
* │ ▼ │
* │ [ FAILED ] │
* │ ▼
* │ ┌─────────────────────────┐
* │ │ split("wk-") │
* │ │ ┌───────────────────┐ │
* │ │ │ q7SettleStep │ │
* │ │ ├───────────────────┤ │
* │ │ │ q7BackupStep │ │
* │ │ └───────────────────┘ │
* │ └────────────┬────────────┘
* │ ▼
* │ ┌───────────────┐
* │ │ q7ReportStep │
* │ └───────┬───────┘
* ▼ ▼
* [ COMPLETED ] [ COMPLETED ]
*
* 요구사항 → 코드 번역표
*
* "0건이면 알림만 보내고 정상 종료"
* → .from(importStep).on("EMPTY").to(q7NoticeStep)
* .from(q7NoticeStep).on("*").end()
*
* "검증 실패면 알림을 보내되 Job 은 반드시 FAILED"
* → .from(q7ValidateStep).on("INVALID").to(q7AlertStep)
* .from(q7AlertStep).on("*").fail() ← .end() 였다면 실패 은폐
*
* 이 한 줄이 문제 4 와 같은 함정입니다.
* "알림 Step 을 태웠으니 흐름은 잘 끝난 것" 이라고 생각해
* .end() 를 쓰면 Job 이 COMPLETED 가 되어 알람이 안 울립니다.
* 알림 Step 을 태우는 것과 Job 을 실패로 끝내는 것은 별개입니다.
*
* "정산과 백업을 병렬로"
* → split(new SimpleAsyncTaskExecutor("wk-")).add(settleFlow, backupFlow)
*
* "둘 다 끝나면 리포트"
* → split 의 자동 조인 뒤에 .to(q7ReportStep)
*
* (b) 코드 ↓
* ========================================================================
*/
@Configuration
static class A7 {
@Bean
Job a7Job(JobRepository jobRepository,
Step a7ImportStep, Step a7ValidateStep, Step a7NoticeStep,
Step a7AlertStep, Step a7SettleStep, Step a7BackupStep,
Step a7ReportStep) {
Flow parallelWork = new FlowBuilder<Flow>("a7ParallelWork")
.split(new SimpleAsyncTaskExecutor("wk-"))
.add(new FlowBuilder<Flow>("a7SettleFlow").start(a7SettleStep).build(),
new FlowBuilder<Flow>("a7BackupFlow").start(a7BackupStep).build())
.build();
return new JobBuilder("a7Job", jobRepository)
.start(a7ImportStep)
.on("EMPTY").to(a7NoticeStep)
.from(a7ImportStep)
.on("LOADED").to(a7ValidateStep)
.from(a7ImportStep)
.on("*").fail() // 안전망
.from(a7NoticeStep)
.on("*").end() // 0건은 정상 종료
.from(a7ValidateStep)
.on("VALID").to(parallelWork)
.from(a7ValidateStep)
.on("*").to(a7AlertStep) // INVALID 포함 그 외 전부
.from(a7AlertStep)
.on("*").fail() // ← 알림은 보내되 Job 은 FAILED
.from(parallelWork)
.on("*").to(a7ReportStep)
.from(a7ReportStep)
.on("*").end()
.end()
.build();
}
@Bean
Step a7ImportStep(JobRepository r, PlatformTransactionManager tm, JdbcTemplate jdbc) {
return new StepBuilder("a7ImportStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution se) {
if (ExitStatus.FAILED.getExitCode().equals(se.getExitStatus().getExitCode())) {
return se.getExitStatus();
}
Long n = jdbc.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED'", Long.class);
return (n == null || n == 0) ? new ExitStatus("EMPTY") : new ExitStatus("LOADED");
}
})
.build();
}
@Bean
Step a7ValidateStep(JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder("a7ValidateStep", r)
.tasklet((c, cc) -> RepeatStatus.FINISHED, tm)
.listener(new StepExecutionListener() {
@Override
public ExitStatus afterStep(StepExecution se) {
if (ExitStatus.FAILED.getExitCode().equals(se.getExitStatus().getExitCode())) {
return se.getExitStatus();
}
return new ExitStatus("VALID");
}
})
.build();
}
@Bean
Step a7NoticeStep(JobRepository r, PlatformTransactionManager tm) {
return simple("a7NoticeStep", r, tm);
}
@Bean
Step a7AlertStep(JobRepository r, PlatformTransactionManager tm) {
return simple("a7AlertStep", r, tm);
}
@Bean
Step a7SettleStep(JobRepository r, PlatformTransactionManager tm) {
return simple("a7SettleStep", r, tm);
}
@Bean
Step a7BackupStep(JobRepository r, PlatformTransactionManager tm) {
return simple("a7BackupStep", r, tm);
}
@Bean
Step a7ReportStep(JobRepository r, PlatformTransactionManager tm) {
return simple("a7ReportStep", r, tm);
}
private Step simple(String name, JobRepository r, PlatformTransactionManager tm) {
return new StepBuilder(name, r).tasklet((c, cc) -> RepeatStatus.FINISHED, tm).build();
}
}
}