Step 03 — JobParameters 와 실행 식별
학습 목표
JobParameters 를 커맨드라인·프로그램·spring.batch.job.name 세 가지 경로로 전달한다
BATCH_JOB_INSTANCE / BATCH_JOB_EXECUTION 을 직접 SELECT 해서 JobInstance 와 JobExecution 의 차이를 눈으로 확인한다
- 같은 파라미터로 재실행해
JobInstanceAlreadyCompleteException 을 스택트레이스까지 재현하고, 세 가지 해결책의 차이를 판단한다
- identifying / non-identifying 파라미터를 구분하고, 5.x 의
JobParameter<T>(value, Class, identifying) 시그니처를 쓴다
RunIdIncrementer 와 JobParametersValidator 로 "매번 새 인스턴스" 와 "잘못된 입력 차단" 을 구현한다
LocalDate 등 5.x 에서 확장된 파라미터 타입 변환 규칙을 확인한다
선행 스텝: Step 02 — Job 과 Step 의 구조
예상 소요: 90분
3-0. 실습 준비
메타데이터를 깨끗이 비우고 시작합니다. 이 스텝은 재실행 실패를 일부러 만들어 보는 스텝이라, 초기화를 자주 하게 됩니다.
mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
DELETE FROM BATCH_STEP_EXECUTION;
DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
DELETE FROM BATCH_JOB_EXECUTION;
DELETE FROM BATCH_JOB_INSTANCE;
SET FOREIGN_KEY_CHECKS = 1;
TRUNCATE TABLE settlement;
SELECT (SELECT COUNT(*) FROM BATCH_JOB_INSTANCE) AS instances,
(SELECT COUNT(*) FROM BATCH_JOB_EXECUTION) AS executions,
(SELECT COUNT(*) FROM settlement) AS settlements;
SQL
결과
+-----------+------------+-------------+
| instances | executions | settlements |
+-----------+------------+-------------+
| 0 | 0 | 0 |
+-----------+------------+-------------+
이번 스텝의 Job 은 com.example.batch.step03 패키지에 만듭니다. 하는 일은 아주 단순합니다 — 파라미터로 받은 날짜의 주문 건수를 세어 로그로 찍습니다. 정산 로직 자체는 Step 05 부터이고, 지금은 파라미터가 어떻게 들어오고 어떻게 기록되는가에만 집중합니다.
3-1. JobParameters 는 Job 의 "입력"이자 "신분증"
배치 Job 은 대개 매일 돕니다. 어제 돌린 Job 과 오늘 돌린 Job 은 같은 코드, 다른 대상입니다. 그 "대상"을 알려주는 것이 JobParameters 입니다.
같은 Job 정의 (settlementJob)
│
┌─────────────┼─────────────┐
│ │ │
date=2025-03-01 date=2025-03-02 date=2025-03-03
│ │ │
JobInstance #1 JobInstance #2 JobInstance #3 ← 파라미터가 다르면 다른 "인스턴스"
여기서 중요한 것은 JobParameters 가 단순한 입력값이 아니라 실행의 신분증이라는 점입니다. Spring Batch 는 (Job 이름, identifying 파라미터 집합) 의 조합으로 JobInstance 를 유일하게 식별합니다. 그래서 같은 파라미터로 두 번 성공시킬 수 없습니다.
💡 주민등록번호 비유
Job 은 "이름"이고 JobParameters 는 "주민등록번호"입니다. 동명이인은 있어도 같은 주민번호는 없습니다.
settlementJob + date=2025-03-01 이라는 신분증은 세상에 딱 하나뿐이고, 그 신분증으로 이미 성공했다면 다시 발급되지 않습니다.
Job 정의부터 봅니다. Step 02 에서 배운 new JobBuilder(name, jobRepository) 형태 그대로입니다.
package com.example.batch.step03;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ParamJobConfig {
@Bean
public Job paramJob(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("paramJob", jobRepository)
.start(paramStep)
.build();
}
@Bean
public Step paramStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("paramStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
// JobParameters 는 StepContribution → StepExecution → JobExecution 경로로 닿습니다.
var params = chunkContext.getStepContext()
.getStepExecution().getJobParameters();
String date = params.getString("date");
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM orders
WHERE status = 'COMPLETED' AND DATE(ordered_at) = ?
""", Integer.class, date);
System.out.printf("[paramStep] date=%s, COMPLETED orders=%d%n", date, count);
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
3-2. 파라미터를 전달하는 세 가지 경로
(1) ./gradlew bootRun — 프로젝트 설정의 args 사용
build.gradle 에 이미 다음이 들어 있습니다(프로젝트 셋업 P-6).
bootRun {
if (project.hasProperty('args')) {
args project.property('args').split(',')
}
}
그래서 이렇게 넘깁니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-01"
결과
INFO 42817 --- [ main] c.e.batch.BatchLabApplication : Starting BatchLabApplication using Java 21.0.2
INFO 42817 --- [ main] com.zaxxer.hikari.HikariDataSource : batch-pool - Starting...
INFO 42817 --- [ main] com.zaxxer.hikari.HikariDataSource : batch-pool - Start completed.
INFO 42817 --- [ main] c.e.batch.BatchLabApplication : Started BatchLabApplication in 1.911 seconds (process running for 2.184)
INFO 42817 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 42817 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [paramStep]
[paramStep] date=2025-03-01, COMPLETED orders=389
INFO 42817 --- [ main] o.s.batch.core.step.AbstractStep : Step: [paramStep] executed in 41ms
INFO 42817 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] completed with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 78ms
BUILD SUCCESSFUL in 5s
389건. 프로젝트 셋업에서 확인한 "하루 약 555건, 그중 COMPLETED 약 389건"과 일치합니다.
여기서 두 인자의 성격이 완전히 다르다는 점을 짚고 갑니다.
| 인자 | 정체 | 소비자 |
|---|
--spring.batch.job.name=paramJob | Spring 프로퍼티. -- 로 시작 | JobLauncherApplicationRunner — "어떤 Job 을 돌릴지" 고름 |
date=2025-03-01 | JobParameter. -- 없음, key=value | DefaultJobParametersConverter — JobParameters 로 변환 |
⚠️ 함정 — --date=2025-03-01 이라고 쓰면 파라미터가 조용히 사라집니다
앞에 -- 를 붙이면 Spring 은 그것을 애플리케이션 프로퍼티로 해석합니다. JobParameters 에는 들어가지 않습니다.
그런데 에러가 나지 않습니다. Job 은 파라미터 0개로 정상 기동하고, params.getString("date") 가 null 을 돌려주며, SQL 은 DATE(ordered_at) = NULL 이 되어 0건을 셉니다.
INFO 42901 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{}]
[paramStep] date=null, COMPLETED orders=0
INFO 42901 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] ... and the following status: [COMPLETED] in 31ms
상태는 COMPLETED 입니다. 정산이 0건 되었는데 배치는 "성공"이라고 보고합니다. 이것이 이 코스가 말하는 에러 없이 조용히 틀리는 코드 의 전형입니다.
방어책은 launched with the following parameters: [{}] 로그를 확인하는 습관과, 3-8 의 JobParametersValidator 입니다.
(2) 실행 가능한 jar — java -jar
운영 배포 형태입니다.
./gradlew bootJar
java -jar build/libs/spring-batch5-lab-1.0.0.jar \
--spring.batch.job.name=paramJob date=2025-03-02
결과
INFO 43055 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=2025-03-02, type=class java.lang.String, identifying=true}'}]
INFO 43055 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [paramStep]
[paramStep] date=2025-03-02, COMPLETED orders=389
INFO 43055 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] completed with the following parameters: [{'date':'{value=2025-03-02, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 69ms
bootRun 은 -Dargs="a,b" 처럼 쉼표로 이어 붙여야 하지만, java -jar 는 그냥 공백으로 나열합니다. 이 차이 때문에 로컬에서 되던 명령이 운영 스크립트에서 안 되는 일이 흔합니다.
(3) 프로그램에서 JobLauncher 로 직접 실행
application.yml 의 spring.batch.job.enabled: false 로 자동 실행을 끄고, 코드에서 직접 띄우는 방식입니다. 테스트나 API 트리거에서 씁니다.
package com.example.batch.step03;
import org.springframework.batch.core.*;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.stereotype.Component;
@Component
public class ManualLauncher {
private final JobLauncher jobLauncher;
private final Job paramJob;
public ManualLauncher(JobLauncher jobLauncher, Job paramJob) {
this.jobLauncher = jobLauncher;
this.paramJob = paramJob;
}
public JobExecution run(String date) throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("date", date) // identifying = true (기본값)
.toJobParameters();
return jobLauncher.run(paramJob, params);
}
}
결과 (run("2025-03-03") 호출 시)
INFO 43188 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=2025-03-03, type=class java.lang.String, identifying=true}'}]
[paramStep] date=2025-03-03, COMPLETED orders=389
INFO 43188 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] completed with the following parameters: [{'date':'{value=2025-03-03, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 74ms
💡 실무 팁 — spring.batch.job.name 을 지정하지 않으면 등록된 Job 이 전부 돕니다
Boot 3.0 이전에는 spring.batch.job.names (복수형)였고, 3.0 부터 spring.batch.job.name (단수형) 으로 바뀌어 하나만 지정할 수 있습니다.
코스가 진행되며 step01Job, step02Job, paramJob … 이 계속 늘어나므로, 항상 --spring.batch.job.name= 을 붙이는 습관을 들이세요.
안 붙이면 Step 12 쯤에서 Job 열 개가 줄줄이 실행되며 왜 이렇게 오래 걸리나 헤매게 됩니다.
3-3. JobInstance vs JobExecution — 테이블로 확인한다
이 둘의 차이는 말로 설명하면 헷갈리고, 테이블을 보면 5초 만에 이해됩니다.
| JobInstance | JobExecution |
|---|
| 정의 | Job 이름 + identifying 파라미터의 논리적 실행 단위 | 그 인스턴스를 실제로 돌린 한 번의 시도 |
| 테이블 | BATCH_JOB_INSTANCE | BATCH_JOB_EXECUTION |
| 비유 | "3월 1일치 정산" 이라는 과제 | 그 과제를 푼 1차 시도, 2차 시도 |
| 개수 관계 | 1 | N (1:N) |
| 재실행하면 | 그대로 (새로 안 생김) | 새로 생김 |
지금까지 세 번 돌렸습니다(03-01, 03-02, 03-03). 확인합니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT JOB_INSTANCE_ID, JOB_NAME, JOB_KEY FROM BATCH_JOB_INSTANCE ORDER BY JOB_INSTANCE_ID;"
결과
+-----------------+----------+----------------------------------+
| JOB_INSTANCE_ID | JOB_NAME | JOB_KEY |
+-----------------+----------+----------------------------------+
| 1 | paramJob | 9a4f1e7c2b3d5a8f0c1e6b7d9f2a4c8e |
| 2 | paramJob | 3b7e0d2a5c9f1e4b8d6a0c3f7e2b5d91 |
| 3 | paramJob | c1d8f3a6b0e492d7f5a3c8e10b62d4f7 |
+-----------------+----------+----------------------------------+
JOB_KEY 가 핵심입니다. identifying 파라미터들을 정렬해 이어 붙인 뒤 MD5 로 해시한 값입니다(DefaultJobKeyGenerator). 이 컬럼에 UNIQUE KEY JOB_INST_UN (JOB_NAME, JOB_KEY) 가 걸려 있어서, DB 레벨에서 중복 인스턴스가 원천 차단됩니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT e.JOB_EXECUTION_ID, e.JOB_INSTANCE_ID, e.STATUS, e.EXIT_CODE,
DATE_FORMAT(e.START_TIME,'%H:%i:%s') st, DATE_FORMAT(e.END_TIME,'%H:%i:%s') et
FROM BATCH_JOB_EXECUTION e ORDER BY e.JOB_EXECUTION_ID;"
결과
+------------------+-----------------+-----------+-----------+----------+----------+
| JOB_EXECUTION_ID | JOB_INSTANCE_ID | STATUS | EXIT_CODE | st | et |
+------------------+-----------------+-----------+-----------+----------+----------+
| 1 | 1 | COMPLETED | COMPLETED | 14:02:11 | 14:02:11 |
| 2 | 2 | COMPLETED | COMPLETED | 14:05:47 | 14:05:47 |
| 3 | 3 | COMPLETED | COMPLETED | 14:07:03 | 14:07:03 |
+------------------+-----------------+-----------+-----------+----------+----------+
아직 1:1 입니다. 1:N 이 되는 순간은 "실패한 인스턴스를 재실행"할 때입니다. 그 장면은 Step 11 에서 정면으로 다루지만, 여기서 미리 한 번 만들어 봅니다. 존재하지 않는 형식의 날짜를 넣어 SQL 을 깨뜨립니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=NOT-A-DATE"
결과
INFO 43302 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=NOT-A-DATE, type=class java.lang.String, identifying=true}'}]
INFO 43302 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [paramStep]
ERROR 43302 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step paramStep in job paramJob
org.springframework.dao.DataIntegrityViolationException: PreparedStatementCallback; Incorrect DATE value: 'NOT-A-DATE'
at org.springframework.jdbc.support.SQLExceptionSubclassTranslator.doTranslate(SQLExceptionSubclassTranslator.java:79)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:661)
at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:407)
...
INFO 43302 --- [ main] o.s.batch.core.step.AbstractStep : Step: [paramStep] executed in 22ms
INFO 43302 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] completed with the following parameters: [{'date':'{value=NOT-A-DATE, type=class java.lang.String, identifying=true}'}] and the following status: [FAILED] in 45ms
FAILED 입니다. 이제 똑같은 파라미터로 다시 돌려 봅니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=NOT-A-DATE"
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT JOB_INSTANCE_ID, JOB_EXECUTION_ID, STATUS, VERSION FROM BATCH_JOB_EXECUTION
WHERE JOB_INSTANCE_ID = 4 ORDER BY JOB_EXECUTION_ID;"
결과
+-----------------+------------------+--------+---------+
| JOB_INSTANCE_ID | JOB_EXECUTION_ID | STATUS | VERSION |
+-----------------+------------------+--------+---------+
| 4 | 4 | FAILED | 2 |
| 4 | 5 | FAILED | 2 |
+-----------------+------------------+--------+---------+
JobInstance 는 4번 하나, JobExecution 은 4·5 두 개. 이것이 1:N 입니다.
그리고 여기가 결정적입니다 — FAILED 인스턴스는 같은 파라미터로 재실행이 허용됩니다. 재시작해서 이어가라는 뜻이니까요. 반면 COMPLETED 는 허용되지 않습니다. 다음 절이 그 이야기입니다.
3-4. JobInstanceAlreadyCompleteException 재현
3-2 에서 date=2025-03-01 은 COMPLETED 로 끝났습니다. 그대로 한 번 더 돌립니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-01"
결과
INFO 43451 --- [ main] c.e.batch.BatchLabApplication : Started BatchLabApplication in 1.877 seconds (process running for 2.146)
ERROR 43451 --- [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute ApplicationRunner
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:790)
at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:768)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:322)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1303)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1292)
at com.example.batch.BatchLabApplication.main(BatchLabApplication.java:14)
Caused by: org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException: A job instance already exists and is complete for identifying parameters={'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}. If you want to run this job again, change the parameters.
at org.springframework.batch.core.repository.support.SimpleJobRepository.createJobExecution(SimpleJobRepository.java:141)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:352)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)
at jdk.proxy2/jdk.proxy2.$Proxy71.createJobExecution(Unknown Source)
at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher$1.run(TaskExecutorJobLauncher.java:140)
at org.springframework.core.task.SyncTaskExecutor.execute(SyncTaskExecutor.java:50)
at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher.run(TaskExecutorJobLauncher.java:135)
at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.execute(JobLauncherApplicationRunner.java:213)
at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.executeLocalJobs(JobLauncherApplicationRunner.java:191)
at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.launchJobFromProperties(JobLauncherApplicationRunner.java:169)
at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner.run(JobLauncherApplicationRunner.java:164)
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:787)
... 5 common frames omitted
BUILD FAILED in 4s
메시지가 친절합니다. "If you want to run this job again, change the parameters."
호출 경로를 정리하면 이렇습니다.
JobLauncherApplicationRunner.run()
└─ TaskExecutorJobLauncher.run(job, params)
└─ jobRepository.getLastJobExecution(name, params) ← 기존 실행 조회
└─ jobRepository.createJobExecution(name, params)
└─ 마지막 실행이 COMPLETED 인가? → 예: JobInstanceAlreadyCompleteException
└─ 재시작 불가(Job.isRestartable()==false)인가? → 예: JobRestartException
└─ 아니면 새 JobExecution 생성
💡 이 예외는 축복입니다
"정산 배치를 두 번 돌려서 3월 1일 정산이 두 배가 되었다"는 사고는 실무에서 정말 흔합니다.
settlement.order_id 의 UNIQUE 키(프로젝트 셋업 P-4)와 이 예외는 같은 목적의 이중 방어입니다.
이 예외를 "귀찮은 에러"로 보고 무조건 우회하면, 그 방어를 스스로 걷어내는 셈입니다.
3-5. 해결책 세 가지 — 그리고 각각의 대가
(a) 파라미터를 바꾼다 — 가장 정직한 방법
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-04"
결과
INFO 43530 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=2025-03-04, type=class java.lang.String, identifying=true}'}]
[paramStep] date=2025-03-04, COMPLETED orders=389
INFO 43530 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] ... and the following status: [COMPLETED] in 71ms
날짜가 다르면 대상 데이터도 다르니 당연히 새 인스턴스여야 맞습니다. 일 배치의 정상 운영 형태입니다.
(b) JobParametersIncrementer — 매 실행마다 값을 하나 증가시킨다
@Bean
public Job incrementJob(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("incrementJob", jobRepository)
.incrementer(new RunIdIncrementer()) // run.id 를 1씩 증가시킴
.start(paramStep)
.build();
}
./gradlew bootRun -Dargs="--spring.batch.job.name=incrementJob,date=2025-03-01"
./gradlew bootRun -Dargs="--spring.batch.job.name=incrementJob,date=2025-03-01"
결과 (두 번째 실행)
INFO 43619 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=incrementJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}','run.id':'{value=2, type=class java.lang.Long, identifying=true}'}]
[paramStep] date=2025-03-01, COMPLETED orders=389
INFO 43619 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=incrementJob]] ... and the following status: [COMPLETED] in 68ms
run.id 가 1 → 2 로 올라갔습니다. date 는 같지만 identifying 파라미터 집합이 달라졌으므로 다른 JobInstance 입니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT p.JOB_EXECUTION_ID, p.PARAMETER_NAME, p.PARAMETER_TYPE, p.PARAMETER_VALUE, p.IDENTIFYING
FROM BATCH_JOB_EXECUTION_PARAMS p
JOIN BATCH_JOB_EXECUTION e ON e.JOB_EXECUTION_ID = p.JOB_EXECUTION_ID
JOIN BATCH_JOB_INSTANCE i ON i.JOB_INSTANCE_ID = e.JOB_INSTANCE_ID
WHERE i.JOB_NAME = 'incrementJob' ORDER BY p.JOB_EXECUTION_ID, p.PARAMETER_NAME;"
결과
+------------------+----------------+-------------------+-----------------+-------------+
| JOB_EXECUTION_ID | PARAMETER_NAME | PARAMETER_TYPE | PARAMETER_VALUE | IDENTIFYING |
+------------------+----------------+-------------------+-----------------+-------------+
| 7 | date | java.lang.String | 2025-03-01 | Y |
| 7 | run.id | java.lang.Long | 1 | Y |
| 8 | date | java.lang.String | 2025-03-01 | Y |
| 8 | run.id | java.lang.Long | 2 | Y |
+------------------+----------------+-------------------+-----------------+-------------+
⚠️ 함정 — BATCH_JOB_EXECUTION_PARAMS 스키마가 5.0 에서 바뀌었습니다
4.x 는 타입별로 컬럼이 나뉘어 있었습니다(STRING_VAL, DATE_VAL, LONG_VAL, DOUBLE_VAL, KEY_NAME, TYPE_CD).
5.0 은 PARAMETER_NAME / PARAMETER_TYPE(FQCN 문자열) / PARAMETER_VALUE(문자열) / IDENTIFYING 네 컬럼으로 통합됐습니다.
4.x 시절에 만든 메타데이터 조회 쿼리·모니터링 대시보드는 전부 깨집니다. 마이그레이션 시 반드시 확인하세요.
⚠️ 함정 — RunIdIncrementer 는 "중복 실행 방지" 를 통째로 무력화합니다
run.id 를 붙이면 몇 번을 돌려도 항상 새 인스턴스가 됩니다. 즉 JobInstanceAlreadyCompleteException 이 영원히 안 납니다.
정산 배치에 이걸 붙이면, 실수로 두 번 돌렸을 때 프레임워크는 아무 말도 안 하고 3월 1일 정산을 두 번 수행합니다.
settlement 의 UNIQUE 키가 있으면 → DuplicateKeyException 으로 시끄럽게 실패 (다행)
- UNIQUE 키가 없으면 → 정산 금액이 정확히 두 배가 된 채로 COMPLETED (재앙)
RunIdIncrementer 는 "몇 번을 돌려도 결과가 같은(멱등한) Job" 에만 쓰세요. 예를 들어 "전체 통계 테이블을 지우고 다시 채우는" Job 은 안전합니다.
날짜별로 누적 INSERT 하는 Job 에는 절대 붙이면 안 됩니다.
(c) allowStartIfComplete(true) — Step 레벨의 재실행 허용
@Bean
public Step alwaysRunStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("alwaysRunStep", jobRepository)
.tasklet((c, cc) -> RepeatStatus.FINISHED, txManager)
.allowStartIfComplete(true) // 이미 COMPLETED 여도 다시 실행
.build();
}
주의: 이것은 (a)·(b) 의 대안이 아닙니다. 적용 범위가 다릅니다.
| 옵션 | 적용 대상 | 해결하는 문제 |
|---|
| 파라미터 변경 | JobInstance | 새 인스턴스를 만든다 |
JobParametersIncrementer | JobInstance | 자동으로 새 인스턴스를 만든다 |
allowStartIfComplete(true) | Step | 이미 성공한 인스턴스를 재시작할 때, 성공했던 Step 을 건너뛰지 않고 다시 실행한다 |
allowStartIfComplete 는 JobExecution 이 새로 만들어진 뒤의 이야기입니다. JobInstanceAlreadyCompleteException 은 JobExecution 이 만들어지기도 전에 터지므로, 이 옵션으로는 막을 수 없습니다.
💡 allowStartIfComplete(true) 의 진짜 용도
"Step1(임시 테이블 초기화) → Step2(대량 처리)" 구조에서 Step2 만 실패해 재시작하는 상황을 생각해 보세요.
기본 동작은 성공한 Step1 을 건너뜁니다. 그러면 임시 테이블이 초기화되지 않은 채 Step2 가 돌아 데이터가 섞입니다.
Step1 에 allowStartIfComplete(true) 를 걸면 재시작 때도 반드시 다시 돕니다. "매번 돌아야 안전한 준비 Step" 에 씁니다. Step 11 에서 실측합니다.
3-6. identifying vs non-identifying 파라미터
모든 파라미터가 인스턴스를 식별할 필요는 없습니다. 예를 들면 이렇습니다.
date=2025-03-01 → identifying. 대상이 달라지므로 인스턴스가 달라야 합니다.
chunkSize=1000 → non-identifying. 성능 튜닝 값일 뿐, 처리 대상은 같습니다.
executedBy=jenkins-42 → non-identifying. 기록용 메타 정보입니다.
5.x 의 JobParameter<T> 시그니처
4.x 의 JobParameter 는 값 타입별 생성자(JobParameter(String), JobParameter(Long), …)를 갖는 비제네릭 클래스였습니다. 5.0 부터는 제네릭 레코드형으로 바뀌었습니다.
// Spring Batch 5.x
public class JobParameter<T> implements Serializable {
public JobParameter(T value, Class<T> type, boolean identifying) { ... }
public JobParameter(T value, Class<T> type) { this(value, type, true); }
}
즉 (값, 타입, identifying여부) 세 개를 명시합니다. 타입을 Class<T> 로 들고 다니기 때문에, 메타데이터 테이블에 FQCN 을 그대로 저장하고 복원할 수 있습니다.
JobParameters params = new JobParametersBuilder()
.addJobParameter("date", new JobParameter<>(LocalDate.of(2025, 3, 1), LocalDate.class, true))
.addJobParameter("chunkSize", new JobParameter<>(1000L, Long.class, false))
.addJobParameter("executedBy",new JobParameter<>("jenkins-42", String.class, false))
.toJobParameters();
빌더 편의 메서드로도 동일하게 됩니다.
JobParameters params = new JobParametersBuilder()
.addLocalDate("date", LocalDate.of(2025, 3, 1)) // identifying = true
.addLong("chunkSize", 1000L, false) // 마지막 인자가 identifying
.addString("executedBy", "jenkins-42", false)
.toJobParameters();
커맨드라인에서는 이름 뒤에 ,identifying=false
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-05,chunkSize=1000\,java.lang.Long\,false"
커맨드라인 파라미터의 완전한 문법은 다음과 같습니다.
name=value → String, identifying=true
name=value,type → 지정 타입, identifying=true
name=value,type,identifying → 전부 명시
결과
INFO 43744 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the following parameters: [{'date':'{value=2025-03-05, type=class java.lang.String, identifying=true}','chunkSize':'{value=1000, type=class java.lang.Long, identifying=false}'}]
[paramStep] date=2025-03-05, COMPLETED orders=389
INFO 43744 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] ... and the following status: [COMPLETED] in 72ms
이제 chunkSize 만 바꿔서 다시 돌려 봅니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-05,chunkSize=500\,java.lang.Long\,false"
결과
Caused by: org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException: A job instance already exists and is complete for identifying parameters={'date':'{value=2025-03-05, type=class java.lang.String, identifying=true}'}. If you want to run this job again, change the parameters.
at org.springframework.batch.core.repository.support.SimpleJobRepository.createJobExecution(SimpleJobRepository.java:141)
예외 메시지의 identifying parameters= 에 date 만 있고 chunkSize 는 없습니다. non-identifying 파라미터는 JOB_KEY 계산에서 빠지기 때문입니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT PARAMETER_NAME, PARAMETER_TYPE, PARAMETER_VALUE, IDENTIFYING
FROM BATCH_JOB_EXECUTION_PARAMS WHERE JOB_EXECUTION_ID = 9;"
결과
+----------------+------------------+-----------------+-------------+
| PARAMETER_NAME | PARAMETER_TYPE | PARAMETER_VALUE | IDENTIFYING |
+----------------+------------------+-----------------+-------------+
| date | java.lang.String | 2025-03-05 | Y |
| chunkSize | java.lang.Long | 1000 | N |
+----------------+------------------+-----------------+-------------+
저장은 됩니다. 식별에만 안 쓰일 뿐입니다. 즉 "무슨 값으로 돌렸는지 기록은 남기되 인스턴스는 나누지 않는다"가 non-identifying 의 정확한 의미입니다.
3-7. JobParametersIncrementer 를 직접 만든다
RunIdIncrementer 는 run.id 라는 의미 없는 숫자를 씁니다. 일 배치라면 날짜를 하루씩 증가시키는 편이 훨씬 자연스럽습니다.
public static class DailyDateIncrementer implements JobParametersIncrementer {
@Override
public JobParameters getNext(JobParameters parameters) {
// 최초 실행이면 오늘 날짜, 아니면 마지막 date + 1일
LocalDate next;
if (parameters == null || parameters.getString("date") == null) {
next = LocalDate.of(2025, 3, 1); // 실습용 고정 시작일
} else {
next = LocalDate.parse(parameters.getString("date")).plusDays(1);
}
return new JobParametersBuilder(parameters == null ? new JobParameters() : parameters)
.addString("date", next.toString())
.toJobParameters();
}
}
./gradlew bootRun -Dargs="--spring.batch.job.name=dailyJob"
./gradlew bootRun -Dargs="--spring.batch.job.name=dailyJob"
./gradlew bootRun -Dargs="--spring.batch.job.name=dailyJob"
결과 (세 번의 실행 로그를 이어 붙임)
INFO 43811 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=dailyJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
[paramStep] date=2025-03-01, COMPLETED orders=389
INFO 43877 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=dailyJob]] launched with the following parameters: [{'date':'{value=2025-03-02, type=class java.lang.String, identifying=true}'}]
[paramStep] date=2025-03-02, COMPLETED orders=389
INFO 43940 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=dailyJob]] launched with the following parameters: [{'date':'{value=2025-03-03, type=class java.lang.String, identifying=true}'}]
[paramStep] date=2025-03-03, COMPLETED orders=389
파라미터를 전혀 주지 않았는데 날짜가 하루씩 전진합니다.
⚠️ 함정 — Incrementer 는 JobLauncher.run() 에서 자동 호출되지 않습니다
getNext() 를 호출해 주는 주체는 JobLauncherApplicationRunner(Boot 자동 실행) 또는 JobOperator.startNextInstance(jobName) 입니다.
여러분이 코드에서 jobLauncher.run(job, params) 를 직접 부르면 Incrementer 는 무시됩니다. .incrementer() 를 붙였는데 왜 run.id 가 안 붙지? 하는 혼란의 원인입니다.
직접 실행할 때는 job.getJobParametersIncrementer().getNext(prev) 를 손으로 호출하거나 JobOperator 를 쓰세요.
3-8. JobParametersValidator — 잘못된 입력을 시작 전에 막는다
3-2 의 함정을 기억할 것입니다. date 를 안 넘겼는데 Job 이 COMPLETED 로 끝났습니다. 이걸 막는 표준 장치가 JobParametersValidator 입니다.
(1) DefaultJobParametersValidator — 필수/선택 키 선언
@Bean
public JobParametersValidator dateRequiredValidator() {
DefaultJobParametersValidator validator = new DefaultJobParametersValidator(
new String[]{"date"}, // requiredKeys
new String[]{"chunkSize", "executedBy"} // optionalKeys
);
validator.afterPropertiesSet();
return validator;
}
@Bean
public Job validatedJob(JobRepository jobRepository, Step paramStep,
JobParametersValidator dateRequiredValidator) {
return new JobBuilder("validatedJob", jobRepository)
.validator(dateRequiredValidator)
.start(paramStep)
.build();
}
date 를 빼고 돌립니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=validatedJob"
결과
ERROR 44012 --- [ main] o.s.boot.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute ApplicationRunner
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:790)
at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:768)
at com.example.batch.BatchLabApplication.main(BatchLabApplication.java:14)
Caused by: org.springframework.batch.core.JobParametersInvalidException: The JobParameters do not contain required keys: [date]
at org.springframework.batch.core.job.DefaultJobParametersValidator.validate(DefaultJobParametersValidator.java:120)
at org.springframework.batch.core.job.AbstractJob.execute(AbstractJob.java:311)
at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher$1.run(TaskExecutorJobLauncher.java:149)
at org.springframework.core.task.SyncTaskExecutor.execute(SyncTaskExecutor.java:50)
at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher.run(TaskExecutorJobLauncher.java:135)
... 6 common frames omitted
BUILD FAILED in 4s
COMPLETED 대신 JobParametersInvalidException 입니다. 조용한 성공이 시끄러운 실패로 바뀌었습니다.
⚠️ 함정 — optionalKeys 를 지정하면 "화이트리스트"가 됩니다
optionalKeys 를 비워 두면 어떤 추가 키든 허용됩니다. 그런데 하나라도 지정하는 순간, required + optional 에 없는 키는 전부 거부됩니다.
Caused by: org.springframework.batch.core.JobParametersInvalidException: The JobParameters contains keys that are not explicitly optional or required: [run.id]
특히 RunIdIncrementer 와 함께 쓰면 프레임워크가 자동으로 붙이는 run.id 가 거부되어 Job 이 시작조차 못 합니다.
둘을 같이 쓸 거라면 optionalKeys 에 "run.id" 를 반드시 넣으세요. 이건 로컬에서는 안 나고 운영 스케줄러에서만 나는 사고입니다.
(2) 커스텀 Validator — 값의 형식과 범위까지 검증
키의 존재 여부만으로는 부족합니다. date=NOT-A-DATE 는 여전히 통과합니다.
public static class SettlementDateValidator implements JobParametersValidator {
private static final LocalDate MIN = LocalDate.of(2025, 1, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public void validate(JobParameters parameters) throws JobParametersInvalidException {
if (parameters == null) {
throw new JobParametersInvalidException("JobParameters 가 null 입니다.");
}
String raw = parameters.getString("date");
if (raw == null || raw.isBlank()) {
throw new JobParametersInvalidException("필수 파라미터 'date' 가 없습니다. 예: date=2025-03-01");
}
LocalDate d;
try {
d = LocalDate.parse(raw); // ISO-8601 (yyyy-MM-dd) 만 허용
} catch (DateTimeParseException e) {
throw new JobParametersInvalidException(
"'date' 형식이 잘못되었습니다: '%s' (기대 형식: yyyy-MM-dd)".formatted(raw));
}
if (d.isBefore(MIN) || d.isAfter(MAX)) {
throw new JobParametersInvalidException(
"'date' 가 데이터 보유 기간(%s ~ %s)을 벗어났습니다: %s".formatted(MIN, MAX, d));
}
}
}
./gradlew bootRun -Dargs="--spring.batch.job.name=strictJob,date=2025-13-45"
결과
Caused by: org.springframework.batch.core.JobParametersInvalidException: 'date' 형식이 잘못되었습니다: '2025-13-45' (기대 형식: yyyy-MM-dd)
at com.example.batch.step03.Practice$SettlementDateValidator.validate(Practice.java:171)
at org.springframework.batch.core.job.AbstractJob.execute(AbstractJob.java:311)
at org.springframework.batch.core.launch.support.TaskExecutorJobLauncher$1.run(TaskExecutorJobLauncher.java:149)
./gradlew bootRun -Dargs="--spring.batch.job.name=strictJob,date=2024-12-31"
결과
Caused by: org.springframework.batch.core.JobParametersInvalidException: 'date' 가 데이터 보유 기간(2025-01-01 ~ 2025-06-29)을 벗어났습니다: 2024-12-31
at com.example.batch.step03.Practice$SettlementDateValidator.validate(Practice.java:175)
여러 Validator 를 묶고 싶으면 CompositeJobParametersValidator 를 씁니다.
@Bean
public JobParametersValidator compositeValidator() {
CompositeJobParametersValidator composite = new CompositeJobParametersValidator();
composite.setValidators(List.of(
new DefaultJobParametersValidator(new String[]{"date"}, new String[]{"run.id", "chunkSize"}),
new SettlementDateValidator()
));
return composite;
}
💡 실무 팁 — Validator 는 Job 이 아니라 파라미터의 계약서입니다
배치는 사람이 손으로 실행하는 일이 많고, 그때 오타가 납니다. date=2025-3-1(0 패딩 누락), date=20250301(구분자 누락)이 대표적입니다.
Validator 없이 이런 값이 들어가면 DATE(ordered_at) = '20250301' 이 0건을 반환하며 조용히 COMPLETED 됩니다.
"입력을 검증하지 않는 배치는 언젠가 0건을 정산한다" 고 외워 두세요.
3-9. 타입 변환 — 5.x 에서 무엇이 늘었나
4.x 의 JobParameter 가 지원한 타입은 네 개뿐이었습니다: String, Long, Double, java.util.Date.
5.0 부터는 JobParameter<T> 가 임의의 타입을 담을 수 있고, DefaultJobParametersConverter 가 Spring 의 ConversionService 를 통해 문자열 ↔ 타입 변환을 처리합니다.
| 표기 | 변환 결과 타입 | 비고 |
|---|
date=2025-03-01 | java.lang.String | 타입 미지정 시 기본은 String |
date=2025-03-01,java.time.LocalDate | java.time.LocalDate | 5.x 신규 |
ts=2025-03-01T09:30:00,java.time.LocalDateTime | java.time.LocalDateTime | 5.x 신규 |
chunkSize=1000,java.lang.Long | java.lang.Long | |
rate=0.025,java.lang.Double | java.lang.Double | |
dryRun=true,java.lang.Boolean | java.lang.Boolean | 5.x 신규 |
./gradlew bootRun -Dargs="--spring.batch.job.name=typedJob,date=2025-03-06\,java.time.LocalDate,dryRun=true\,java.lang.Boolean"
결과
INFO 44201 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=typedJob]] launched with the following parameters: [{'date':'{value=2025-03-06, type=class java.time.LocalDate, identifying=true}','dryRun':'{value=true, type=class java.lang.Boolean, identifying=true}'}]
[typedStep] date=2025-03-06 (LocalDate), dryRun=true, COMPLETED orders=389
INFO 44201 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=typedJob]] ... and the following status: [COMPLETED] in 75ms
읽는 쪽 코드입니다.
JobParameters params = chunkContext.getStepContext().getStepExecution().getJobParameters();
LocalDate date = params.getLocalDate("date"); // 5.x 신규 게터
Boolean dryRun = (Boolean) params.getParameter("dryRun").getValue();
Long chunk = params.getLong("chunkSize", 1000L); // 두 번째 인자는 기본값
⚠️ 함정 — 게터의 타입과 저장된 타입이 다르면 ClassCastException 입니다
date=2025-03-01 (타입 미지정 → String)로 넘겨 놓고 코드에서 params.getLocalDate("date") 를 부르면:
Caused by: java.lang.ClassCastException: class java.lang.String cannot be cast to class java.time.LocalDate (java.lang.String and java.time.LocalDate are in module java.base of loader 'bootstrap')
at org.springframework.batch.core.JobParameters.getLocalDate(JobParameters.java:141)
at com.example.batch.step03.Practice$TypedTasklet.execute(Practice.java:214)
커맨드라인 표기와 코드의 게터는 한 쌍입니다. 한쪽만 바꾸면 컴파일은 통과하고 런타임에 터집니다.
더 나쁜 경우도 있습니다 — params.getString("date") 로 읽으면 LocalDate 로 넘긴 값도 toString() 되어 "2025-03-01" 로 잘 나옵니다.
그런데 java.util.Date 로 넘겼다면 toString() 이 "Sat Mar 01 00:00:00 KST 2025" 가 되어, SQL 비교가 조용히 0건이 됩니다.
타입을 명시했다면 게터도 그 타입으로 통일하세요.
💡 실무 팁 — 커맨드라인에서는 String 으로 받고 코드에서 파싱하는 편이 안전합니다
,java.time.LocalDate 표기는 셸에서 쉼표를 이스케이프해야 하고(\,), Jenkins·Airflow 같은 스케줄러를 거치면 이스케이프가 또 한 번 꼬입니다.
실무에서는 date=2025-03-01 (String) 으로 받고, Validator 에서 LocalDate.parse() 로 검증한 뒤, 사용하는 쪽에서 다시 파싱하는 패턴이 흔합니다.
타입 안전성은 Validator 로 확보하고, 전달은 단순하게 가는 것이 트레이드오프의 실용적인 지점입니다.
3-10. 파라미터를 Bean 안에서 쓰기 — @StepScope 와 늦은 바인딩
Tasklet 람다 안에서는 ChunkContext 로 파라미터에 닿았습니다. 그런데 Reader/Writer 같은 Bean 안에서 쓰려면 문제가 생깁니다. Bean 은 애플리케이션 기동 시점에 만들어지는데, JobParameters 는 Job 실행 시점에야 정해지기 때문입니다.
해결책이 @StepScope 입니다. Bean 생성을 Step 실행 시점까지 미룹니다(늦은 바인딩, late binding).
@Bean
@StepScope
public Tasklet dateAwareTasklet(@Value("#{jobParameters['date']}") String date,
JdbcTemplate jdbcTemplate) {
return (contribution, chunkContext) -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE status='COMPLETED' AND DATE(ordered_at) = ?",
Integer.class, date);
System.out.printf("[dateAwareTasklet] date=%s, count=%d%n", date, count);
return RepeatStatus.FINISHED;
};
}
결과
INFO 44330 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=scopedJob]] launched with the following parameters: [{'date':'{value=2025-03-07, type=class java.lang.String, identifying=true}'}]
INFO 44330 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [scopedStep]
[dateAwareTasklet] date=2025-03-07, count=389
INFO 44330 --- [ main] o.s.batch.core.step.AbstractStep : Step: [scopedStep] executed in 44ms
@StepScope 를 빼면 기동 시점에 이렇게 터집니다.
Caused by: org.springframework.beans.factory.BeanExpressionException: Expression parsing failed; nested exception is org.springframework.expression.spel.SpelEvaluationException: EL1008E: Property or field 'jobParameters' cannot be found on object of type 'org.springframework.beans.factory.config.BeanExpressionContext' - maybe not public or not valid?
at org.springframework.context.expression.StandardBeanExpressionResolver.evaluate(StandardBeanExpressionResolver.java:165)
at org.springframework.beans.factory.support.AbstractBeanFactory.evaluateBeanDefinitionString(AbstractBeanFactory.java:1550)
이건 다행히 시끄럽게 실패합니다. @StepScope 는 Step 06~08 의 Reader/Writer 에서 계속 쓰게 되므로 여기서 이름만 기억해 두면 됩니다.
정리
| 개념 | 핵심 |
|---|
| JobParameters | Job 의 입력이자 신분증. identifying 집합이 인스턴스를 결정 |
| JobInstance | Job 이름 + identifying 파라미터 = 논리적 실행 단위. BATCH_JOB_INSTANCE |
| JobExecution | 그 인스턴스를 실제로 돌린 한 번의 시도. BATCH_JOB_EXECUTION. 1:N |
| JOB_KEY | identifying 파라미터의 MD5 해시. UNIQUE(JOB_NAME, JOB_KEY) 로 중복 차단 |
| 전달 문법 | name=value[,type[,identifying]]. -- 를 붙이면 Spring 프로퍼티가 되어 조용히 사라짐 |
| Job 선택 | --spring.batch.job.name= (Boot 3.0 부터 단수형) |
| 재실행 실패 | 마지막 실행이 COMPLETED 면 JobInstanceAlreadyCompleteException. FAILED 면 재실행 허용 |
| 해결 (a) | 파라미터 변경 — 가장 정직 |
| 해결 (b) | JobParametersIncrementer / RunIdIncrementer — 중복 방지를 무력화하므로 멱등한 Job 에만 |
| 해결 (c) | allowStartIfComplete(true) — Step 레벨. 인스턴스 문제를 푸는 게 아님 |
| non-identifying | 저장은 되지만 JOB_KEY 계산에서 제외. 튜닝값·메타정보용 |
5.x JobParameter<T> | (value, Class<T>, identifying). 4.x 의 타입별 생성자 폐기 |
| 5.x PARAMS 스키마 | PARAMETER_NAME/TYPE/VALUE/IDENTIFYING 4컬럼으로 통합 (4.x 쿼리 전부 깨짐) |
| Validator | DefaultJobParametersValidator(키) + 커스텀(형식·범위). optionalKeys 지정 시 화이트리스트가 됨 |
| 타입 변환 | 5.x 는 LocalDate/LocalDateTime/Boolean 등 확장. 게터 타입과 반드시 일치시킬 것 |
@StepScope | Bean 생성을 Step 실행 시점으로 미루는 늦은 바인딩. #{jobParameters['...']} |
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.
date 를 넘겼는데 [{}] 로 로그가 찍힐 때, 잘못된 명령줄을 찾아 고치기
- 주어진 파라미터 조합 4개가 각각 몇 개의 JobInstance 를 만드는지 판정
JobInstanceAlreadyCompleteException 이 나는 상황에서, 정산 배치에 써도 되는 해결책과 쓰면 안 되는 해결책을 근거와 함께 고르기
RunIdIncrementer + DefaultJobParametersValidator 를 함께 쓸 때 나는 예외를 예측하고 고치기
date(yyyy-MM-dd, 2025-01-01~2025-06-29 범위), dryRun(선택, Boolean) 을 검증하는 커스텀 Validator 작성
- 하루씩 전진하되 2025-06-29 를 넘으면 예외를 던지는
JobParametersIncrementer 작성
다음 단계
파라미터로 "무엇을 처리할지"를 정하고, 그 실행이 메타데이터에 어떻게 기록되는지까지 봤습니다.
하지만 지금까지의 Step 은 전부 람다 한 줄짜리였습니다. 다음 스텝에서 Tasklet 이라는 Step 구현체를 제대로 파고듭니다.
RepeatStatus 하나로 무한루프에 빠지는 함정, Tasklet 의 트랜잭션 경계, 그리고 "언제 Tasklet 을 쓰고 언제 청크를 써야 하는가"를 판단 기준까지 정리합니다.
→ Step 04 — Tasklet Step
실습 파일
이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 를 프로젝트의 src/main/java/com/example/batch/step03/ 에 놓고 본문 3-1 ~ 3-10 의 실행을 순서대로 재현한 뒤, Exercise.java 의 6문제를 직접 채워 보고, Solution.java 로 대조합니다. 세 파일 모두 하나의 최상위 클래스 안에 static class 로 Job 설정을 중첩하는 구조라, 파일 하나만 복사하면 바로 돌아갑니다.
Practice.java
본문에 나온 모든 Job 정의·Tasklet·Validator·Incrementer 를 // [3-1] 형태의 절 번호 주석과 함께 담았습니다.
- 최상위
Practice 클래스는 @Configuration 이 아니며, 안쪽의 ParamJobConfig, IncrementerConfig, ValidatorConfig, TypedConfig, ScopedConfig 다섯 개의 static class 가 각각 @Configuration 입니다. 특정 절만 실험하고 싶으면 나머지 클래스에 @Configuration 을 잠시 주석 처리하면 됩니다.
[3-2] 의 ManualLauncher 는 spring.batch.job.enabled: false 일 때만 의미가 있습니다. 자동 실행을 켠 채로 두면 Job 이 두 번 도는 것처럼 보이니 주의하세요.
[3-5] 의 RunIdIncrementer 블록에는 정산 배치에 붙이면 안 되는 이유가 주석으로 길게 달려 있습니다. 코드를 복사하기 전에 반드시 읽으세요.
[3-6] 의 커맨드라인 예시는 셸에서 쉼표를 \, 로 이스케이프해야 합니다. 파일 주석에 zsh / bash 각각의 표기를 적어 두었습니다.
[3-9] 의 TypedTasklet 은 params.getLocalDate("date") 를 씁니다. date 를 타입 없이 넘기면 ClassCastException 이 나는데, 이건 의도된 실습입니다. 일부러 한 번 틀려 보세요.
- 모든 Job 은
paramStep 을 공유하지만 Job 이름이 다르므로 JobInstance 는 Job 이름별로 따로 관리됩니다. 이 사실이 [3-3] 의 SELECT 결과를 읽을 때 중요합니다.
package com.example.batch.step03;
/*
* ============================================================================
* Step 03 — JobParameters 와 실행 식별 / 실습 코드
* ============================================================================
*
* 배치: Spring Boot 3.2.5 / Spring Batch 5.1.1 / Java 21 / MySQL 8.0.36(127.0.0.1:3308)
*
* 놓을 위치:
* src/main/java/com/example/batch/step03/Practice.java
*
* 실행 예 (zsh / bash 공통 — 쉼표는 bootRun 의 args 구분자이므로 값 안의 쉼표는 \, 로 이스케이프):
* ./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-01"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=incrementJob,date=2025-03-01"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=dailyJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=validatedJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=strictJob,date=2025-13-45"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=typedJob,date=2025-03-06\,java.time.LocalDate,dryRun=true\,java.lang.Boolean"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=scopedJob,date=2025-03-07"
*
* jar 로 실행할 때는 쉼표가 아니라 공백으로 나열합니다:
* java -jar build/libs/spring-batch5-lab-1.0.0.jar --spring.batch.job.name=paramJob date=2025-03-02
*
* 메타데이터 초기화:
* mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb < batch-reset.sql
*
* 이 파일은 최상위 클래스 Practice 안에 @Configuration 을 static class 로 중첩한 구조입니다.
* 특정 절만 실험하려면 나머지 static class 의 @Configuration 을 잠시 주석 처리하세요.
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameter;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.JobParametersIncrementer;
import org.springframework.batch.core.JobParametersInvalidException;
import org.springframework.batch.core.JobParametersValidator;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.CompositeJobParametersValidator;
import org.springframework.batch.core.job.DefaultJobParametersValidator;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
public class Practice {
/** 본문 전체에서 재사용하는 카운트 쿼리. 하루치 COMPLETED 는 약 389건입니다. */
static final String COUNT_SQL = """
SELECT COUNT(*) FROM orders
WHERE status = 'COMPLETED' AND DATE(ordered_at) = ?
""";
// ========================================================================
// [3-1] JobParameters 는 Job 의 "입력"이자 "신분증"
// [3-2] 파라미터 전달 경로 (1) bootRun (2) java -jar (3) JobLauncher 직접 호출
// ========================================================================
@Configuration
public static class ParamJobConfig {
@Bean
public Job paramJob(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("paramJob", jobRepository)
.start(paramStep)
.build();
}
@Bean
public Step paramStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("paramStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
// JobParameters 로 가는 경로:
// ChunkContext → StepContext → StepExecution → JobParameters
JobParameters params = chunkContext.getStepContext()
.getStepExecution().getJobParameters();
String date = params.getString("date");
// ⚠️ date 가 null 이면 SQL 은 DATE(ordered_at) = NULL 이 되어 0건입니다.
// 그런데 예외가 아니라 COMPLETED 로 끝납니다. 3-2 의 함정 블록 참고.
Integer count = jdbcTemplate.queryForObject(COUNT_SQL, Integer.class, date);
System.out.printf("[paramStep] date=%s, COMPLETED orders=%d%n", date, count);
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
/**
* [3-2] (3) 프로그램에서 JobLauncher 로 직접 실행.
*
* application.yml 의 spring.batch.job.enabled 를 false 로 둔 상태에서만 의미가 있습니다.
* 자동 실행을 켠 채로 두면 Job 이 두 번 도는 것처럼 보입니다.
*
* ⚠️ 이 경로로 실행하면 Job 에 붙인 JobParametersIncrementer 는 호출되지 않습니다. ([3-7] 함정)
*/
@Component
public static class ManualLauncher {
private final JobLauncher jobLauncher;
private final Job paramJob;
public ManualLauncher(JobLauncher jobLauncher, Job paramJob) {
this.jobLauncher = jobLauncher;
this.paramJob = paramJob;
}
public void run(String date) throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("date", date) // 3번째 인자를 생략하면 identifying = true
.toJobParameters();
jobLauncher.run(paramJob, params);
}
}
// ========================================================================
// [3-5] 해결책 (b) JobParametersIncrementer
// [3-6] identifying vs non-identifying
// [3-7] 커스텀 Incrementer
// ========================================================================
@Configuration
public static class IncrementerConfig {
/**
* [3-5] RunIdIncrementer — 매 실행마다 run.id 를 1 증가시켜 항상 새 JobInstance 를 만듭니다.
*
* ⚠️⚠️ 정산 배치에 절대 붙이지 마세요.
*
* run.id 가 붙으면 identifying 파라미터 집합이 매번 달라지므로,
* JobInstanceAlreadyCompleteException 이 영원히 발생하지 않습니다.
* 즉 "같은 날짜를 두 번 정산하는" 사고를 프레임워크가 더 이상 막아 주지 않습니다.
*
* - settlement 에 UNIQUE KEY uk_settlement_order (order_id) 가 있으면
* → DuplicateKeyException 으로 시끄럽게 실패합니다. (다행)
* - UNIQUE 키가 없으면
* → 3월 1일 389건이 두 번 INSERT 되어 778행이 되고, 상태는 COMPLETED 입니다. (재앙)
*
* RunIdIncrementer 는 "몇 번 돌려도 결과가 같은 멱등한 Job" 에만 씁니다.
* 예) 전체 통계 테이블을 TRUNCATE 한 뒤 다시 채우는 Job.
*/
@Bean
public Job incrementJob(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("incrementJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(paramStep)
.build();
}
/**
* [3-7] 날짜를 하루씩 전진시키는 커스텀 Incrementer.
*
* ⚠️ getNext() 를 호출해 주는 주체는 JobLauncherApplicationRunner 또는
* JobOperator.startNextInstance(jobName) 입니다.
* jobLauncher.run(job, params) 를 직접 부르면 이 클래스는 호출되지 않습니다.
*/
public static class DailyDateIncrementer implements JobParametersIncrementer {
private static final LocalDate START = LocalDate.of(2025, 3, 1);
@Override
public JobParameters getNext(JobParameters parameters) {
LocalDate next;
if (parameters == null || parameters.getString("date") == null) {
next = START;
} else {
next = LocalDate.parse(parameters.getString("date")).plusDays(1);
}
JobParameters base = (parameters == null) ? new JobParameters() : parameters;
return new JobParametersBuilder(base)
.addString("date", next.toString())
.toJobParameters();
}
}
@Bean
public Job dailyJob(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("dailyJob", jobRepository)
.incrementer(new DailyDateIncrementer())
.start(paramStep)
.build();
}
/**
* [3-5] 해결책 (c) allowStartIfComplete(true).
*
* 이것은 (a)·(b) 의 대안이 아닙니다. 적용 범위가 Step 이기 때문입니다.
* JobInstanceAlreadyCompleteException 은 JobExecution 이 만들어지기 "전"에 터지므로
* 이 옵션으로는 막을 수 없습니다.
*
* 진짜 용도: "Step1(임시테이블 초기화) → Step2(대량처리)" 에서 Step2 만 실패해 재시작할 때,
* 기본 동작은 성공한 Step1 을 건너뜁니다. 그러면 임시 테이블이 안 비워진 채
* Step2 가 돌아 데이터가 섞입니다. 이럴 때 Step1 에 붙입니다.
*/
@Bean
public Step alwaysRunStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("alwaysRunStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("[alwaysRunStep] 준비 작업은 재시작 때도 반드시 다시 돕니다.");
return RepeatStatus.FINISHED;
}, txManager)
.allowStartIfComplete(true)
.build();
}
/**
* [3-6] identifying / non-identifying 을 코드에서 만드는 두 가지 방법.
* 어느 쪽을 쓰든 결과는 동일합니다.
*/
public static JobParameters buildMixedParameters() {
// (1) JobParameter<T>(value, Class<T>, identifying) 를 직접 생성 — 5.x 시그니처
JobParameters explicit = new JobParametersBuilder()
.addJobParameter("date",
new JobParameter<>(LocalDate.of(2025, 3, 1), LocalDate.class, true))
.addJobParameter("chunkSize",
new JobParameter<>(1000L, Long.class, false))
.addJobParameter("executedBy",
new JobParameter<>("jenkins-42", String.class, false))
.toJobParameters();
// (2) 빌더 편의 메서드 — 마지막 boolean 인자가 identifying
JobParameters shorthand = new JobParametersBuilder()
.addLocalDate("date", LocalDate.of(2025, 3, 1))
.addLong("chunkSize", 1000L, false)
.addString("executedBy", "jenkins-42", false)
.toJobParameters();
System.out.println("explicit = " + explicit);
System.out.println("shorthand = " + shorthand);
return shorthand;
}
}
// ========================================================================
// [3-8] JobParametersValidator
// ========================================================================
@Configuration
public static class ValidatorConfig {
/**
* [3-8](1) DefaultJobParametersValidator — 키의 존재 여부만 검사합니다.
*
* ⚠️ optionalKeys 를 하나라도 지정하면 화이트리스트 모드가 됩니다.
* required + optional 에 없는 키는 전부 거부됩니다.
* RunIdIncrementer 와 함께 쓸 거라면 반드시 "run.id" 를 optionalKeys 에 넣으세요.
* (넣지 않으면 로컬에서는 멀쩡하고 운영 스케줄러에서만 터집니다.)
*/
@Bean
public JobParametersValidator dateRequiredValidator() {
DefaultJobParametersValidator validator = new DefaultJobParametersValidator(
new String[]{"date"}, // requiredKeys
new String[]{"run.id", "chunkSize", "executedBy"} // optionalKeys
);
validator.afterPropertiesSet();
return validator;
}
@Bean
public Job validatedJob(JobRepository jobRepository, Step paramStep,
JobParametersValidator dateRequiredValidator) {
return new JobBuilder("validatedJob", jobRepository)
.validator(dateRequiredValidator)
.start(paramStep)
.build();
}
/**
* [3-8](2) 커스텀 Validator — 값의 형식과 범위까지 검사합니다.
*
* 키 존재 검사만으로는 date=NOT-A-DATE 나 date=20250301 을 못 막습니다.
* 그런 값은 SQL 에서 0건을 반환하며 조용히 COMPLETED 됩니다.
*/
public static class SettlementDateValidator implements JobParametersValidator {
private static final LocalDate MIN = LocalDate.of(2025, 1, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public void validate(JobParameters parameters) throws JobParametersInvalidException {
if (parameters == null) {
throw new JobParametersInvalidException("JobParameters 가 null 입니다.");
}
String raw = parameters.getString("date");
if (raw == null || raw.isBlank()) {
throw new JobParametersInvalidException(
"필수 파라미터 'date' 가 없습니다. 예: date=2025-03-01");
}
LocalDate d;
try {
d = LocalDate.parse(raw); // ISO-8601(yyyy-MM-dd) 만 허용
} catch (DateTimeParseException e) {
throw new JobParametersInvalidException(
"'date' 형식이 잘못되었습니다: '%s' (기대 형식: yyyy-MM-dd)".formatted(raw));
}
if (d.isBefore(MIN) || d.isAfter(MAX)) {
throw new JobParametersInvalidException(
"'date' 가 데이터 보유 기간(%s ~ %s)을 벗어났습니다: %s".formatted(MIN, MAX, d));
}
}
}
/** 여러 Validator 를 묶습니다. 키 검사 → 값 검사 순으로 실행됩니다. */
@Bean
public JobParametersValidator compositeValidator() {
CompositeJobParametersValidator composite = new CompositeJobParametersValidator();
composite.setValidators(List.of(
new DefaultJobParametersValidator(
new String[]{"date"},
new String[]{"run.id", "chunkSize", "dryRun"}),
new SettlementDateValidator()
));
return composite;
}
@Bean
public Job strictJob(JobRepository jobRepository, Step paramStep,
JobParametersValidator compositeValidator) {
return new JobBuilder("strictJob", jobRepository)
.validator(compositeValidator)
.start(paramStep)
.build();
}
}
// ========================================================================
// [3-9] 타입 변환 — 5.x 에서 LocalDate / LocalDateTime / Boolean 등으로 확대
// ========================================================================
@Configuration
public static class TypedConfig {
/**
* 커맨드라인 표기:
* date=2025-03-06 → java.lang.String (타입 미지정 시 기본)
* date=2025-03-06,java.time.LocalDate → java.time.LocalDate
* dryRun=true,java.lang.Boolean → java.lang.Boolean
* chunkSize=1000,java.lang.Long,false → java.lang.Long, non-identifying
*
* ⚠️ 커맨드라인 표기와 아래 게터는 "한 쌍" 입니다.
* date 를 타입 없이(String) 넘겨 놓고 getLocalDate("date") 를 부르면 ClassCastException 입니다.
* 컴파일은 통과하고 런타임에 터집니다. 일부러 한 번 틀려 보세요.
*/
public static class TypedTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public TypedTasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(org.springframework.batch.core.StepContribution contribution,
org.springframework.batch.core.scope.context.ChunkContext chunkContext) {
JobParameters params = chunkContext.getStepContext()
.getStepExecution().getJobParameters();
LocalDate date = params.getLocalDate("date"); // 5.x 신규 게터
Boolean dryRun = (Boolean) params.getParameter("dryRun").getValue();
Long chunkSize = params.getLong("chunkSize", 1000L); // 2번째 인자는 기본값
Integer count = jdbcTemplate.queryForObject(
COUNT_SQL, Integer.class, date.toString());
System.out.printf("[typedStep] date=%s (LocalDate), dryRun=%s, chunkSize=%d, COMPLETED orders=%d%n",
date, dryRun, chunkSize, count);
return RepeatStatus.FINISHED;
}
}
@Bean
public Step typedStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("typedStep", jobRepository)
.tasklet(new TypedTasklet(jdbcTemplate), txManager)
.build();
}
@Bean
public Job typedJob(JobRepository jobRepository, Step typedStep) {
return new JobBuilder("typedJob", jobRepository)
.start(typedStep)
.build();
}
}
// ========================================================================
// [3-10] @StepScope 와 늦은 바인딩
// ========================================================================
@Configuration
public static class ScopedConfig {
/**
* Bean 은 애플리케이션 기동 시점에 만들어지는데 JobParameters 는 Job 실행 시점에야 정해집니다.
* @StepScope 는 Bean 생성을 Step 실행 시점까지 미뤄 그 간극을 메웁니다(늦은 바인딩).
*
* @StepScope 를 빼면 기동 시점에 BeanExpressionException 이 납니다:
* EL1008E: Property or field 'jobParameters' cannot be found on object of type
* 'org.springframework.beans.factory.config.BeanExpressionContext'
* 이건 다행히 시끄럽게 실패합니다.
*/
@Bean
@StepScope
public Tasklet dateAwareTasklet(@Value("#{jobParameters['date']}") String date,
JdbcTemplate jdbcTemplate) {
return (contribution, chunkContext) -> {
Integer count = jdbcTemplate.queryForObject(COUNT_SQL, Integer.class, date);
System.out.printf("[dateAwareTasklet] date=%s, count=%d%n", date, count);
return RepeatStatus.FINISHED;
};
}
@Bean
public Step scopedStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
Tasklet dateAwareTasklet) {
return new StepBuilder("scopedStep", jobRepository)
.tasklet(dateAwareTasklet, txManager)
.build();
}
@Bean
public Job scopedJob(JobRepository jobRepository, Step scopedStep) {
return new JobBuilder("scopedJob", jobRepository)
.start(scopedStep)
.build();
}
}
// ========================================================================
// [3-3] 메타데이터 확인용 SQL — mysql CLI 에 붙여 넣어 실행하세요.
// ========================================================================
/*
-- JobInstance: Job 이름 + identifying 파라미터의 MD5 해시(JOB_KEY)
-- UNIQUE KEY JOB_INST_UN (JOB_NAME, JOB_KEY) 로 중복이 원천 차단됩니다.
SELECT JOB_INSTANCE_ID, JOB_NAME, JOB_KEY
FROM BATCH_JOB_INSTANCE ORDER BY JOB_INSTANCE_ID;
-- JobExecution: 그 인스턴스를 실제로 돌린 한 번의 시도. JobInstance 와 1:N.
SELECT e.JOB_EXECUTION_ID, e.JOB_INSTANCE_ID, e.STATUS, e.EXIT_CODE,
DATE_FORMAT(e.START_TIME,'%H:%i:%s') st, DATE_FORMAT(e.END_TIME,'%H:%i:%s') et
FROM BATCH_JOB_EXECUTION e ORDER BY e.JOB_EXECUTION_ID;
-- 5.0 에서 통합된 파라미터 스키마 (4.x 의 STRING_VAL/DATE_VAL/LONG_VAL/DOUBLE_VAL 은 폐기)
SELECT p.JOB_EXECUTION_ID, p.PARAMETER_NAME, p.PARAMETER_TYPE, p.PARAMETER_VALUE, p.IDENTIFYING
FROM BATCH_JOB_EXECUTION_PARAMS p
JOIN BATCH_JOB_EXECUTION e ON e.JOB_EXECUTION_ID = p.JOB_EXECUTION_ID
JOIN BATCH_JOB_INSTANCE i ON i.JOB_INSTANCE_ID = e.JOB_INSTANCE_ID
WHERE i.JOB_NAME = 'incrementJob'
ORDER BY p.JOB_EXECUTION_ID, p.PARAMETER_NAME;
-- 인스턴스 1개에 실행이 여러 개 붙었는지 확인 (FAILED 재실행 시 1:N 이 됩니다)
SELECT i.JOB_NAME, i.JOB_INSTANCE_ID, COUNT(*) AS executions,
GROUP_CONCAT(e.STATUS ORDER BY e.JOB_EXECUTION_ID) AS statuses
FROM BATCH_JOB_INSTANCE i
JOIN BATCH_JOB_EXECUTION e ON e.JOB_INSTANCE_ID = i.JOB_INSTANCE_ID
GROUP BY i.JOB_NAME, i.JOB_INSTANCE_ID
ORDER BY i.JOB_INSTANCE_ID;
*/
}
Exercise.java
6문제의 문제지입니다. 각 문제는 // 여기에 작성: 자리를 비워 두었습니다.
- 문제 1·2·3 은 코드를 거의 쓰지 않고 판단하는 문제입니다. 답을 주석 문자열로 적고, 실제로 명령을 실행해 검증하세요. 특히 문제 2 는 답을 적은 뒤
SELECT COUNT(*) FROM BATCH_JOB_INSTANCE 로 반드시 확인해야 의미가 있습니다.
- 문제 4 는 파일이 이미 깨지는 조합(
RunIdIncrementer + optionalKeys 에 run.id 없음)을 만들어 둔 상태입니다. 실행하면 JobParametersInvalidException 이 납니다. 예외 메시지를 먼저 예측하고 나서 실행하세요.
- 문제 5·6 이 실제 코딩입니다. 문제 5 의
dryRun 은 선택 파라미터이므로 "없어도 통과, 있으면 true/false 만 허용" 이라는 두 조건을 모두 만족해야 합니다. Boolean.parseBoolean("yes") 가 예외 없이 false 를 돌려준다는 점이 이 문제의 함정입니다.
- 문제 6 의 경계값은
2025-06-29 입니다. 그날은 성공하고 그다음 날 호출에서 예외여야 합니다. off-by-one 을 조심하세요.
- 파일 하단의
main 은 없습니다. Spring 컨텍스트에서 Bean 으로 로드되어야 하므로, 실행은 ./gradlew bootRun -Dargs="--spring.batch.job.name=..." 으로 합니다.
package com.example.batch.step03;
/*
* ============================================================================
* Step 03 — 연습문제 (6문제)
* ============================================================================
*
* 정답은 Solution.java 에 있습니다. 먼저 직접 풀어 보세요.
*
* 놓을 위치: src/main/java/com/example/batch/step03/Exercise.java
* 실행: ./gradlew bootRun -Dargs="--spring.batch.job.name=<jobName>,date=..."
*
* 시작 전에 메타데이터를 초기화하는 것을 권합니다:
* mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
* SET FOREIGN_KEY_CHECKS = 0;
* DELETE FROM BATCH_STEP_EXECUTION_CONTEXT; DELETE FROM BATCH_STEP_EXECUTION;
* DELETE FROM BATCH_JOB_EXECUTION_CONTEXT; DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
* DELETE FROM BATCH_JOB_EXECUTION; DELETE FROM BATCH_JOB_INSTANCE;
* SET FOREIGN_KEY_CHECKS = 1;
* SQL
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.JobParametersIncrementer;
import org.springframework.batch.core.JobParametersInvalidException;
import org.springframework.batch.core.JobParametersValidator;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.DefaultJobParametersValidator;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
public class Exercise {
// ========================================================================
// 문제 1. 사라진 파라미터
// ------------------------------------------------------------------------
// 아래 명령으로 paramJob 을 실행했더니 로그가 이렇게 나왔습니다.
//
// ./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,--date=2025-03-01"
//
// ... TaskExecutorJobLauncher : Job: [SimpleJob: [name=paramJob]] launched with the
// following parameters: [{}]
// [paramStep] date=null, COMPLETED orders=0
// ... and the following status: [COMPLETED] in 31ms
//
// (a) 왜 파라미터가 [{}] 인가?
// (b) 이 상황이 "에러가 나는 것보다 위험한" 이유는?
// (c) 올바른 명령은?
//
// 답을 아래 문자열에 적고, 실제로 실행해 검증하세요.
// ========================================================================
static final String ANSWER_1_A = "여기에 작성: ";
static final String ANSWER_1_B = "여기에 작성: ";
static final String ANSWER_1_C = "여기에 작성: ";
// ========================================================================
// 문제 2. JobInstance 는 몇 개인가
// ------------------------------------------------------------------------
// 메타데이터를 완전히 비운 상태에서 paramJob 을 아래 네 번 실행합니다.
// (실행 순서대로입니다. 3번째와 4번째는 예외가 날 수도 있습니다.)
//
// ① date=2025-03-01
// ② date=2025-03-02
// ③ date=2025-03-01, chunkSize=1000(java.lang.Long, identifying=false)
// ④ date=2025-03-03, executedBy=jenkins-42(java.lang.String, identifying=false)
//
// (a) BATCH_JOB_INSTANCE 에는 몇 행이 남는가?
// (b) BATCH_JOB_EXECUTION 에는 몇 행이 남는가?
// (c) 예외가 나는 실행은 몇 번째이며, 예외 이름은?
//
// 답을 적은 뒤 반드시 SELECT COUNT(*) 로 검증하세요.
// ========================================================================
static final String ANSWER_2_A = "여기에 작성: ";
static final String ANSWER_2_B = "여기에 작성: ";
static final String ANSWER_2_C = "여기에 작성: ";
// ========================================================================
// 문제 3. 정산 배치에 써도 되는 해결책 고르기
// ------------------------------------------------------------------------
// 매일 도는 정산 배치(settlementJob, date 파라미터)가 어제치를 다시 돌려야 하는데
// JobInstanceAlreadyCompleteException 이 납니다. 아래 세 선택지 중에서
// "정산 배치에 써도 되는 것" 과 "쓰면 안 되는 것" 을 근거와 함께 고르세요.
//
// (A) 파라미터를 바꾼다
// (B) Job 에 .incrementer(new RunIdIncrementer()) 를 붙인다
// (C) Step 에 .allowStartIfComplete(true) 를 붙인다
//
// 힌트: settlement 테이블에는 UNIQUE KEY uk_settlement_order (order_id) 가 있습니다.
// 그 제약이 "없다면" 어떤 일이 벌어지는지도 함께 적으세요.
// ========================================================================
static final String ANSWER_3 = "여기에 작성: ";
// ========================================================================
// 문제 4. Incrementer 와 Validator 의 충돌
// ------------------------------------------------------------------------
// 아래 Job 을 실행하면 예외가 납니다.
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex4Job,date=2025-03-10"
//
// (a) 실행하기 "전에" 어떤 예외의 어떤 메시지가 날지 예측해 ANSWER_4_A 에 적으세요.
// (b) 실행해서 확인한 뒤, 아래 ex4Validator 를 최소한으로 고쳐 통과시키세요.
// ========================================================================
static final String ANSWER_4_A = "여기에 작성: ";
@Configuration
public static class Ex4Config {
@Bean
public JobParametersValidator ex4Validator() {
DefaultJobParametersValidator validator = new DefaultJobParametersValidator(
new String[]{"date"}, // requiredKeys
new String[]{"chunkSize"} // optionalKeys — 여기에 문제가 있습니다
);
// 여기에 작성: optionalKeys 를 고치세요.
validator.afterPropertiesSet();
return validator;
}
@Bean
public Job ex4Job(JobRepository jobRepository, Step paramStep,
JobParametersValidator ex4Validator) {
return new JobBuilder("ex4Job", jobRepository)
.incrementer(new RunIdIncrementer())
.validator(ex4Validator)
.start(paramStep)
.build();
}
}
// ========================================================================
// 문제 5. 커스텀 Validator 작성
// ------------------------------------------------------------------------
// 다음을 모두 만족하는 JobParametersValidator 를 완성하세요.
//
// - date : 필수. yyyy-MM-dd 형식. 2025-01-01 ~ 2025-06-29 범위 안.
// - dryRun : 선택. 있으면 문자열 "true" 또는 "false" 만 허용.
// (없어도 통과해야 합니다)
//
// ⚠️ 함정: Boolean.parseBoolean("yes") 는 예외 없이 false 를 돌려줍니다.
// "dryRun=yes 로 켰다고 믿었는데 실제로는 데이터가 쓰이는" 사고를 막아야 합니다.
//
// 검증 명령 (전부 예외가 나야 합니다):
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex5Job"
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex5Job,date=20250301"
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex5Job,date=2024-12-31"
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex5Job,date=2025-03-01,dryRun=yes"
// 그리고 이건 통과해야 합니다:
// ./gradlew bootRun -Dargs="--spring.batch.job.name=ex5Job,date=2025-03-11,dryRun=true"
// ========================================================================
public static class Ex5Validator implements JobParametersValidator {
private static final LocalDate MIN = LocalDate.of(2025, 1, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public void validate(JobParameters parameters) throws JobParametersInvalidException {
// 여기에 작성: date 필수 / 형식 / 범위 검증
// 여기에 작성: dryRun 선택 / "true" 또는 "false" 만 허용
}
}
@Configuration
public static class Ex5Config {
@Bean
public Job ex5Job(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("ex5Job", jobRepository)
.validator(new Ex5Validator())
.start(paramStep)
.build();
}
}
// ========================================================================
// 문제 6. 경계가 있는 Incrementer
// ------------------------------------------------------------------------
// 2025-03-01 부터 하루씩 전진하되, 2025-06-29 를 넘어가려 하면
// IllegalStateException("정산 가능한 마지막 날짜(2025-06-29)를 넘었습니다") 를 던지는
// Incrementer 를 완성하세요.
//
// ⚠️ 경계값 주의: date=2025-06-29 로 실행되는 것까지는 "성공" 이어야 합니다.
// 그다음 호출에서 예외입니다. off-by-one 을 조심하세요.
// ========================================================================
public static class Ex6Incrementer implements JobParametersIncrementer {
private static final LocalDate START = LocalDate.of(2025, 3, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public JobParameters getNext(JobParameters parameters) {
// 여기에 작성: 최초 실행이면 START, 아니면 이전 date + 1일.
// MAX 를 넘으면 IllegalStateException.
return new JobParametersBuilder().toJobParameters();
}
}
@Configuration
public static class Ex6Config {
@Bean
public Job ex6Job(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("ex6Job", jobRepository)
.incrementer(new Ex6Incrementer())
.start(paramStep)
.build();
}
}
}
Solution.java
6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 함께 들어 있습니다. 풀어 본 뒤에 여세요.
- 정답 1 의 핵심은
--date=... 의 -- 하나입니다. 이 한 글자 때문에 Job 이 파라미터 0개로 성공한다는 것 — 즉 실패가 아니라 성공하기 때문에 위험하다 는 점을 주석으로 강조합니다.
- 정답 2 는 4개 조합에 대해 JobInstance 수가
3 이라고 답합니다. non-identifying 파라미터만 다른 두 조합이 같은 인스턴스로 합쳐지기 때문입니다. JOB_KEY 계산 규칙(identifying 만, 이름순 정렬, MD5)까지 함께 설명합니다.
- 정답 3 은 "정산 배치에는 (a) 파라미터 변경만 쓴다" 입니다.
RunIdIncrementer 를 붙이면 settlement 의 UNIQUE 키가 없을 때 정확히 두 배의 정산 금액이 COMPLETED 상태로 남는다는 시나리오를 수치와 함께 적었습니다.
- 정답 4 는
optionalKeys 에 "run.id" 를 추가하는 것입니다. 더 근본적인 답으로 "optionalKeys 를 아예 지정하지 않아 화이트리스트 모드를 끄는" 선택지도 함께 제시하고, 둘의 트레이드오프(오타 방어 vs 유연성)를 비교합니다.
- 정답 5 는
dryRun 을 Boolean.parseBoolean 으로 파싱하지 않고, "true".equals(v) || "false".equals(v) 로 화이트리스트 검사합니다. parseBoolean("yes") 가 조용히 false 가 되는 것 — 사용자는 dry-run 을 켰다고 믿는데 실제로 데이터가 쓰이는 사고를 막기 위해서입니다.
- 정답 6 은
getNext() 에서 next.isAfter(MAX) 를 검사해 IllegalStateException 을 던집니다. 여기에 더해 "Incrementer 안에서 예외를 던지면 JobExecution 이 아예 생성되지 않아 메타데이터에 흔적이 남지 않는다"는 운영상의 단점과, 대안(마지막 날 이후에는 getNext() 가 이전 값을 그대로 돌려주어 JobInstanceAlreadyCompleteException 으로 자연스럽게 멈추게 하는 방식)을 함께 설명합니다.
package com.example.batch.step03;
/*
* ============================================================================
* Step 03 — 연습문제 정답 및 해설
* ============================================================================
* 문제를 직접 풀어 본 뒤에 여세요.
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.JobParametersIncrementer;
import org.springframework.batch.core.JobParametersInvalidException;
import org.springframework.batch.core.JobParametersValidator;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.DefaultJobParametersValidator;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
public class Solution {
// ========================================================================
// 정답 1. 사라진 파라미터
// ========================================================================
/*
* (a) 왜 [{}] 인가
*
* `--date=2025-03-01` 의 앞에 붙은 `--` 때문입니다.
* Spring Boot 의 커맨드라인 인자 규약에서 `--key=value` 는 "애플리케이션 프로퍼티"입니다.
* Environment 에 `date=2025-03-01` 이라는 프로퍼티로 들어갈 뿐,
* DefaultJobParametersConverter 는 `--` 로 시작하지 않는 인자만 JobParameter 로 변환합니다.
* 그래서 JobParameters 는 빈 상태이고 로그가 [{}] 입니다.
*
* (b) 왜 에러보다 위험한가 ★이 스텝에서 가장 중요한 한 문단★
*
* Job 이 "성공" 하기 때문입니다.
* - params.getString("date") → null
* - SQL 이 DATE(ordered_at) = NULL 로 평가됨 → NULL 비교는 항상 UNKNOWN → 0건
* - Tasklet 은 예외 없이 RepeatStatus.FINISHED 반환
* - JobExecution.STATUS = COMPLETED, EXIT_CODE = COMPLETED
*
* 모니터링은 초록불이고, 알림은 울리지 않고, 스케줄러는 성공으로 기록합니다.
* 그런데 3월 1일 정산 389건은 하나도 만들어지지 않았습니다.
* 문제는 며칠 뒤 "3월 정산 금액이 왜 이렇게 적지?" 라는 질문으로 발견되고,
* 그때는 이미 원인을 추적하기 어렵습니다.
*
* 문법 에러였다면 5초 만에 고쳤을 일입니다.
* 조용히 틀리는 코드가 진짜 위험하다는 말이 정확히 이 상황을 가리킵니다.
*
* 방어책 두 가지:
* 1) 실행 로그의 `launched with the following parameters:` 를 항상 눈으로 확인한다.
* [{}] 면 즉시 잘못된 것입니다.
* 2) JobParametersValidator 로 date 를 required 로 선언한다. (정답 5 참고)
* 그러면 0건 성공이 JobParametersInvalidException 실패로 바뀝니다.
*
* (c) 올바른 명령
*
* ./gradlew bootRun -Dargs="--spring.batch.job.name=paramJob,date=2025-03-01"
* ^^ 이건 프로퍼티라 -- 필요 ^^ 이건 JobParameter 라 -- 없음
*
* 두 인자의 성격이 다르다는 점이 헷갈림의 근원입니다.
* spring.batch.job.name 은 "어떤 Job 을 고를지" 정하는 Boot 설정이고,
* date 는 "그 Job 에 무엇을 넘길지" 정하는 배치 파라미터입니다.
*/
static final String ANSWER_1_A = "--- 를 붙이면 Spring 프로퍼티로 해석되어 JobParameters 에 들어가지 않는다";
static final String ANSWER_1_B = "예외가 아니라 COMPLETED 로 끝나서, 0건 정산이 성공으로 기록된다";
static final String ANSWER_1_C = "./gradlew bootRun -Dargs=\"--spring.batch.job.name=paramJob,date=2025-03-01\"";
// ========================================================================
// 정답 2. JobInstance 는 몇 개인가
// ========================================================================
/*
* (a) BATCH_JOB_INSTANCE = 3행
* (b) BATCH_JOB_EXECUTION = 3행
* (c) 3번째 실행에서 JobInstanceAlreadyCompleteException
*
* 계산 근거 — JOB_KEY 는 "identifying 파라미터만" 으로 만들어집니다.
*
* ① date=2025-03-01 → identifying 집합 {date=2025-03-01} → 새 인스턴스 #1
* ② date=2025-03-02 → {date=2025-03-02} → 새 인스턴스 #2
* ③ date=2025-03-01 + chunkSize(non-id) → {date=2025-03-01} ← ①과 동일!
* → 이미 COMPLETED → JobInstanceAlreadyCompleteException
* → JobExecution 도 생성되지 않음
* ④ date=2025-03-03 + executedBy(non-id) → {date=2025-03-03} → 새 인스턴스 #3
*
* 즉 ③ 은 인스턴스도 실행도 만들지 못하고 예외로 끝납니다.
* 그래서 인스턴스 3, 실행 3 입니다.
*
* DefaultJobKeyGenerator 의 규칙:
* 1) identifying = true 인 파라미터만 고른다
* 2) 파라미터 이름으로 정렬한다 (순서를 바꿔 넘겨도 같은 키가 나오도록)
* 3) "name=value;" 로 이어 붙여 MD5 해시를 만든다
* 4) BATCH_JOB_INSTANCE.JOB_KEY 에 저장. UNIQUE(JOB_NAME, JOB_KEY) 로 DB 가 최종 방어.
*
* 여기서 얻는 실무적 교훈:
* "값을 기록하고는 싶지만 인스턴스를 나누고 싶지는 않다" 는 요구가 non-identifying 입니다.
* executedBy(누가 돌렸나), buildNumber(CI 번호), chunkSize(성능 튜닝값) 가 전형적입니다.
* BATCH_JOB_EXECUTION_PARAMS 에 IDENTIFYING='N' 으로 저장은 되므로 감사 추적이 됩니다.
*
* 검증:
* SELECT (SELECT COUNT(*) FROM BATCH_JOB_INSTANCE) AS instances,
* (SELECT COUNT(*) FROM BATCH_JOB_EXECUTION) AS executions;
* +-----------+------------+
* | instances | executions |
* +-----------+------------+
* | 3 | 3 |
* +-----------+------------+
*/
static final String ANSWER_2_A = "3";
static final String ANSWER_2_B = "3";
static final String ANSWER_2_C = "3번째, JobInstanceAlreadyCompleteException";
// ========================================================================
// 정답 3. 정산 배치에 써도 되는 해결책
// ========================================================================
/*
* 결론: (A) 파라미터 변경만 쓴다. (B) 는 절대 쓰지 않는다. (C) 는 애초에 다른 문제를 푸는 도구다.
*
* ── (A) 파라미터를 바꾼다 : ✅ 정답
*
* "어제치를 다시 돌리고 싶다" 는 요구는 대개 둘 중 하나입니다.
* i) 어제 실행이 실패했다 → 그러면 STATUS 가 FAILED 이므로 같은 파라미터로 재실행이 이미 허용됩니다.
* JobInstanceAlreadyCompleteException 이 났다는 건 어제 실행이 "성공" 했다는 뜻입니다.
* ii) 어제는 성공했지만 원본 데이터가 보정되어 다시 계산해야 한다
* → 이건 "재실행" 이 아니라 "정정(correction)" 입니다.
* settlement 의 해당 날짜를 지우고 새 파라미터(예: date + reprocessSeq)로 돌리거나,
* 아예 별도의 정정 Job 을 만드는 것이 정석입니다.
*
* 어느 쪽이든 프레임워크의 방어를 우회하지 않습니다.
*
* ── (B) RunIdIncrementer : ❌ 절대 금지
*
* run.id 가 매번 증가하므로 identifying 집합이 항상 달라집니다.
* → JobInstanceAlreadyCompleteException 이 영원히 발생하지 않습니다.
* → "중복 실행 방지" 라는 기능 자체를 껐다는 뜻입니다.
*
* 구체적인 사고 시나리오 (2025-03-01, COMPLETED 389건):
*
* * settlement 에 UNIQUE KEY uk_settlement_order (order_id) 가 있는 경우:
* 2회차 실행이 첫 INSERT 에서 바로 터집니다.
* org.springframework.dao.DuplicateKeyException:
* Duplicate entry '60' for key 'settlement.uk_settlement_order'
* STATUS=FAILED. 시끄럽지만 데이터는 안전합니다. 이게 프로젝트 셋업 P-4 의 UNIQUE 키가 있는 이유입니다.
*
* * UNIQUE 키가 없는 경우:
* settlement 에 같은 order_id 가 두 번 들어갑니다.
* 389행 → 778행, 정산 총액 정확히 2배, STATUS=COMPLETED.
* 아무도 모릅니다. 월말 대사(reconciliation)에서야 발견됩니다.
*
* RunIdIncrementer 가 안전한 경우는 "멱등한 Job" 뿐입니다.
* 예) 통계 테이블을 TRUNCATE 하고 전체를 다시 채우는 Job — 몇 번을 돌려도 결과가 같습니다.
* 누적 INSERT 하는 Job 에는 붙이면 안 됩니다.
*
* ── (C) allowStartIfComplete(true) : ⚠️ 이 문제의 답이 아님
*
* 적용 대상이 Step 이고, 효과는 "이미 성공한 JobExecution 을 재시작할 때 성공했던 Step 을
* 건너뛰지 않고 다시 실행한다" 입니다.
* JobInstanceAlreadyCompleteException 은 JobExecution 이 만들어지기 "전" 단계에서
* SimpleJobRepository.createJobExecution() 이 던지는 예외이므로,
* Step 옵션으로는 도달조차 하지 않습니다. 붙여도 증상이 그대로입니다.
*
* 이 옵션의 진짜 용도는 "매번 돌아야 안전한 준비 Step" 입니다.
* Step1: 임시 테이블 TRUNCATE ← allowStartIfComplete(true)
* Step2: 대량 처리 ← 여기서 실패해 재시작
* 이때 Step1 을 건너뛰면 임시 테이블에 이전 데이터가 남은 채로 Step2 가 돌아 데이터가 섞입니다.
*/
static final String ANSWER_3 =
"(A)만 사용. (B)는 중복 방지를 무력화해 정산이 두 배가 될 수 있음. (C)는 Step 레벨이라 이 예외를 막지 못함";
// ========================================================================
// 정답 4. Incrementer 와 Validator 의 충돌
// ========================================================================
/*
* (a) 예측했어야 할 예외
*
* Caused by: org.springframework.batch.core.JobParametersInvalidException:
* The JobParameters contains keys that are not explicitly optional or required: [run.id]
* at org.springframework.batch.core.job.DefaultJobParametersValidator.validate(...)
* at org.springframework.batch.core.job.AbstractJob.execute(AbstractJob.java:311)
*
* 이유:
* DefaultJobParametersValidator 는 optionalKeys 를 "하나라도" 지정하면
* 화이트리스트 모드로 동작합니다. required + optional 에 없는 키는 전부 거부합니다.
* (optionalKeys 를 아예 비워 두면 추가 키를 자유롭게 허용합니다.)
*
* 그런데 RunIdIncrementer 는 프레임워크가 자동으로 run.id 를 "덧붙입니다".
* 개발자가 명시적으로 넘긴 적이 없어서 optionalKeys 에 넣는 걸 잊기 쉽습니다.
*
* 이 조합이 특히 고약한 이유:
* 로컬에서 `date=...` 만 넘겨 테스트하면 통과합니다. Incrementer 는 JobLauncher 직접 호출에서는
* 동작하지 않기 때문입니다(3-7 함정). 그런데 운영에서 JobLauncherApplicationRunner /
* JobOperator.startNextInstance() 로 돌리는 순간 run.id 가 붙고 Job 이 시작조차 못 합니다.
*
* (b) 최소 수정: optionalKeys 에 "run.id" 를 추가
*/
@Configuration
public static class Sol4Config {
@Bean
public JobParametersValidator sol4Validator() {
DefaultJobParametersValidator validator = new DefaultJobParametersValidator(
new String[]{"date"},
new String[]{"run.id", "chunkSize"} // ← run.id 추가
);
validator.afterPropertiesSet();
return validator;
}
/*
* 대안 — optionalKeys 를 아예 지정하지 않아 화이트리스트 모드를 끄는 방법:
*
* new DefaultJobParametersValidator(new String[]{"date"}, new String[]{});
*
* 트레이드오프:
* 화이트리스트 ON : 오타(dat=2025-03-01)를 잡아 줍니다. 대신 프레임워크나 운영팀이
* 추가하는 키(run.id, jobTriggeredBy 등)마다 목록을 갱신해야 합니다.
* 화이트리스트 OFF : 유연하지만 오타를 못 잡습니다. `dat=2025-03-01` 은
* "date 없음" 으로 걸리므로 required 검사에는 걸립니다만,
* 선택 파라미터의 오타(dryRunn=true)는 조용히 무시됩니다.
*
* 정산처럼 중요한 배치라면 화이트리스트를 켜고, 키가 늘어날 때마다 목록을 관리하는 쪽을 권합니다.
* 목록 관리 비용보다 오타로 인한 사고 비용이 훨씬 큽니다.
*/
@Bean
public Job sol4Job(JobRepository jobRepository, Step paramStep,
JobParametersValidator sol4Validator) {
return new JobBuilder("sol4Job", jobRepository)
.incrementer(new RunIdIncrementer())
.validator(sol4Validator)
.start(paramStep)
.build();
}
}
// ========================================================================
// 정답 5. 커스텀 Validator
// ========================================================================
public static class Sol5Validator implements JobParametersValidator {
private static final LocalDate MIN = LocalDate.of(2025, 1, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public void validate(JobParameters parameters) throws JobParametersInvalidException {
if (parameters == null) {
throw new JobParametersInvalidException("JobParameters 가 null 입니다.");
}
// ── date : 필수 + 형식 + 범위 ────────────────────────────────
String rawDate = parameters.getString("date");
if (rawDate == null || rawDate.isBlank()) {
throw new JobParametersInvalidException(
"필수 파라미터 'date' 가 없습니다. 예: date=2025-03-01");
}
LocalDate date;
try {
// LocalDate.parse 는 ISO-8601(yyyy-MM-dd) 만 받습니다.
// 그래서 20250301, 2025-3-1, 2025/03/01 이 전부 여기서 걸립니다.
// 이 세 가지가 실무에서 사람이 손으로 실행할 때 나오는 오타 1~3위입니다.
date = LocalDate.parse(rawDate);
} catch (DateTimeParseException e) {
throw new JobParametersInvalidException(
"'date' 형식이 잘못되었습니다: '%s' (기대 형식: yyyy-MM-dd)".formatted(rawDate));
}
// 범위 검사가 왜 필요한가:
// 형식이 맞아도 date=2024-12-31 은 orders 에 데이터가 없어 0건입니다.
// 형식 검사만 하면 이건 여전히 "0건 정산 후 COMPLETED" 로 조용히 지나갑니다.
// 데이터 보유 기간을 아는 것은 배치 자신이므로, 배치가 막아야 합니다.
if (date.isBefore(MIN) || date.isAfter(MAX)) {
throw new JobParametersInvalidException(
"'date' 가 데이터 보유 기간(%s ~ %s)을 벗어났습니다: %s".formatted(MIN, MAX, date));
}
// ── dryRun : 선택 + "true"/"false" 화이트리스트 ──────────────
String rawDryRun = parameters.getString("dryRun");
if (rawDryRun != null) { // null 이면 통과 (선택 파라미터)
// ★ 여기가 이 문제의 핵심 ★
//
// Boolean.parseBoolean(v) 를 쓰면 안 됩니다.
// parseBoolean 은 "true"(대소문자 무시)가 아닌 모든 문자열을 예외 없이 false 로 만듭니다.
// Boolean.parseBoolean("yes") → false
// Boolean.parseBoolean("1") → false
// Boolean.parseBoolean("TRUE") → true
// Boolean.parseBoolean("ture") → false ← 오타
//
// dryRun=yes 로 넘긴 사람은 "쓰기 없이 시뮬레이션만 한다" 고 믿습니다.
// 그런데 parseBoolean 이 false 를 주면 배치는 dry-run 이 아니라
// 실제 쓰기 모드로 돌아 settlement 에 389행을 INSERT 합니다.
// 예외도 없고 경고도 없습니다. 전형적인 "조용히 틀리는" 사고입니다.
//
// 그래서 파싱이 아니라 화이트리스트 검사를 합니다.
if (!"true".equals(rawDryRun) && !"false".equals(rawDryRun)) {
throw new JobParametersInvalidException(
"'dryRun' 은 'true' 또는 'false' 만 허용합니다: '%s'".formatted(rawDryRun));
}
}
}
}
@Configuration
public static class Sol5Config {
@Bean
public Job sol5Job(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("sol5Job", jobRepository)
.validator(new Sol5Validator())
.start(paramStep)
.build();
}
}
/*
* 실행 결과 요약
*
* (파라미터 없음) → JobParametersInvalidException: 필수 파라미터 'date' 가 없습니다. ...
* date=20250301 → JobParametersInvalidException: 'date' 형식이 잘못되었습니다: '20250301' ...
* date=2024-12-31 → JobParametersInvalidException: 'date' 가 데이터 보유 기간(2025-01-01 ~ 2025-06-29)을 ...
* date=2025-03-01,dryRun=yes
* → JobParametersInvalidException: 'dryRun' 은 'true' 또는 'false' 만 허용합니다: 'yes'
* date=2025-03-11,dryRun=true
* → COMPLETED, [paramStep] date=2025-03-11, COMPLETED orders=389
*/
// ========================================================================
// 정답 6. 경계가 있는 Incrementer
// ========================================================================
public static class Sol6Incrementer implements JobParametersIncrementer {
private static final LocalDate START = LocalDate.of(2025, 3, 1);
private static final LocalDate MAX = LocalDate.of(2025, 6, 29);
@Override
public JobParameters getNext(JobParameters parameters) {
LocalDate next;
if (parameters == null || parameters.getString("date") == null) {
next = START;
} else {
next = LocalDate.parse(parameters.getString("date")).plusDays(1);
}
// ★ off-by-one 주의 ★
//
// 검사는 "다음에 실행할 날짜(next)" 에 대해 합니다. 이전 날짜가 아닙니다.
// next = 2025-06-29 → isAfter(MAX) 가 false → 통과 → 그날 정산이 실행됨 (요구사항)
// next = 2025-06-30 → isAfter(MAX) 가 true → 예외 (요구사항)
//
// 흔한 실수는 `next.isAfter(MAX) || next.isEqual(MAX)` 나
// `!next.isBefore(MAX)` 로 쓰는 것입니다. 그러면 6-29 가 실행되지 못하고
// 하루치 정산이 통째로 누락됩니다. 그리고 이건 예외가 아니라
// "마지막 날만 빠진 정산 결과" 로 나타나므로 발견이 늦습니다.
if (next.isAfter(MAX)) {
throw new IllegalStateException(
"정산 가능한 마지막 날짜(%s)를 넘었습니다: %s".formatted(MAX, next));
}
JobParameters base = (parameters == null) ? new JobParameters() : parameters;
return new JobParametersBuilder(base)
.addString("date", next.toString())
.toJobParameters();
}
}
@Configuration
public static class Sol6Config {
@Bean
public Job sol6Job(JobRepository jobRepository, Step paramStep) {
return new JobBuilder("sol6Job", jobRepository)
.incrementer(new Sol6Incrementer())
.start(paramStep)
.build();
}
}
/*
* ── 이 답의 운영상 단점과 대안 ─────────────────────────────────────────
*
* getNext() 안에서 예외를 던지면 JobExecution 이 아예 생성되지 않습니다.
* 그 결과:
* - BATCH_JOB_EXECUTION 에 아무 흔적도 남지 않습니다.
* - 배치 모니터링 대시보드에는 "그날 아무 일도 없었던 것" 처럼 보입니다.
* - 스케줄러 로그를 직접 뒤져야 원인을 압니다.
*
* 실행 로그는 이렇게 나옵니다:
* ERROR 44502 --- [ main] o.s.boot.SpringApplication : Application run failed
* java.lang.IllegalStateException: 정산 가능한 마지막 날짜(2025-06-29)를 넘었습니다: 2025-06-30
* at com.example.batch.step03.Solution$Sol6Incrementer.getNext(Solution.java:...)
* at org.springframework.boot.autoconfigure.batch.JobLauncherApplicationRunner
* .getNextJobParameters(JobLauncherApplicationRunner.java:245)
*
* 대안 (프레임워크의 방어를 이용하는 방법):
*
* if (next.isAfter(MAX)) {
* return parameters; // 예외 대신 "이전 파라미터를 그대로" 반환
* }
*
* 그러면 이미 COMPLETED 인 인스턴스로 실행을 시도하게 되어
* JobInstanceAlreadyCompleteException 이 납니다.
* - 장점: 메시지에 어떤 파라미터가 문제인지 명확히 찍히고, 프레임워크 표준 예외라
* 운영 도구들이 이미 알고 있습니다.
* - 단점: 메시지가 "마지막 날짜를 넘었다" 는 도메인 의미를 전달하지 못합니다.
* 운영자는 "왜 이미 완료됐다는 거지?" 하고 한 번 더 헤맵니다.
*
* 어느 쪽이든 "조용히 아무 일도 안 일어나는" 것보다는 낫습니다.
* 가장 나쁜 구현은 아래처럼 경계를 넘으면 START 로 되돌아가는 것입니다.
*
* if (next.isAfter(MAX)) next = START; // ❌ 절대 금지
*
* 이렇게 하면 예외 없이 3월 1일부터 다시 돌기 시작하고,
* 이미 완료된 인스턴스라 JobInstanceAlreadyCompleteException 이 나거나,
* (Incrementer 를 쓰는 Job 이라면) 정산이 다시 한 번 수행됩니다.
*/
}