Step 04 — Tasklet Step
학습 목표
Tasklet 인터페이스의 계약(execute(StepContribution, ChunkContext))과 반환값의 의미를 이해한다
RepeatStatus.FINISHED 와 RepeatStatus.CONTINUABLE 의 차이를, 매 반복이 새 트랜잭션임을 로그로 확인하며 익힌다
CONTINUABLE 의 무한루프 함정을 재현하고, 왜 그것이 배치에서 특히 치명적인지 안다
StepContribution 으로 처리 건수를 보고하고 BATCH_STEP_EXECUTION 에서 확인한다
MethodInvokingTaskletAdapter, SystemCommandTasklet 의 용도와 한계를 파악한다
- Tasklet 이 적합한 경우와 청크가 적합한 경우를 판단 기준으로 정리하고, settlement 정리 Tasklet 을 실제로 만들어 70,000행을 지운다
선행 스텝: Step 03 — JobParameters 와 실행 식별
예상 소요: 90분
4-0. 실습 준비 — 지울 데이터를 먼저 만든다
이 스텝의 마지막 실습은 settlement 테이블 정리 Tasklet 입니다. 지우려면 먼저 있어야 하므로, Step 05 이후의 정산 결과를 SQL 로 미리 만들어 둡니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb <<'SQL'
TRUNCATE TABLE settlement;
INSERT INTO settlement (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount)
SELECT o.order_id,
o.customer_id,
DATE(o.ordered_at),
o.amount,
c.fee_rate,
ROUND(o.amount * c.fee_rate, 2),
o.amount - ROUND(o.amount * c.fee_rate, 2)
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'COMPLETED';
SQL
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT COUNT(*) rows_, SUM(gross_amount) gross, SUM(fee_amount) fee, SUM(net_amount) net FROM settlement;"
결과
+-------+---------------+--------------+---------------+
| rows_ | gross | fee | net |
+-------+---------------+--------------+---------------+
| 70000 | 3485250000.00 | 95844375.00 | 3389405625.00 |
+-------+---------------+--------------+---------------+
COMPLETED 70,000건이 그대로 정산되었습니다(프로젝트 셋업의 숫자와 일치). gross = fee + net 도 맞습니다.
이번 스텝의 코드는 com.example.batch.step04 패키지에 놓습니다.
4-1. Tasklet — Step 의 가장 단순한 구현
Spring Batch 의 Step 은 구현이 두 갈래입니다.
Step
│
┌────────────┴────────────┐
│ │
TaskletStep (파티셔닝/Flow 등 특수 Step)
│
┌────┴──────────────────────┐
│ │
Tasklet 직접 구현 ChunkOrientedTasklet
"한 덩어리 작업" "read → process → write 를 chunk 단위로"
(Step 04) (Step 05 ~ 08)
재미있는 사실이 있습니다. 청크 지향 처리도 결국 Tasklet 입니다. ChunkOrientedTasklet 이라는 이름의 Tasklet 구현체가 read-process-write 루프를 돌리는 구조입니다. 즉 Tasklet 을 이해하면 청크의 뼈대도 이해한 것입니다.
인터페이스는 메서드 하나뿐입니다.
package org.springframework.batch.core.step.tasklet;
@FunctionalInterface
public interface Tasklet {
RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception;
}
| 요소 | 의미 |
|---|
StepContribution | 이번 실행분의 처리 건수 누적기. read/write/filter/skip 카운트를 여기에 보고 |
ChunkContext | 현재 실행의 컨텍스트. JobParameters, ExecutionContext, StepExecution 에 접근 |
반환 RepeatStatus | FINISHED = 끝났다 / CONTINUABLE = 한 번 더 불러 달라 |
throws Exception | 예외를 던지면 트랜잭션이 롤백되고 Step 이 FAILED |
가장 단순한 Tasklet 을 만듭니다.
package com.example.batch.step04;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class HelloTaskletConfig {
@Bean
public Job helloTaskletJob(JobRepository jobRepository, Step helloTaskletStep) {
return new JobBuilder("helloTaskletJob", jobRepository)
.start(helloTaskletStep)
.build();
}
@Bean
public Step helloTaskletStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("helloTaskletStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("[helloTasklet] 한 번 실행되고 끝납니다.");
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
}
./gradlew bootRun -Dargs="--spring.batch.job.name=helloTaskletJob"
결과
INFO 45102 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=helloTaskletJob]] launched with the following parameters: [{}]
INFO 45102 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [helloTaskletStep]
[helloTasklet] 한 번 실행되고 끝납니다.
INFO 45102 --- [ main] o.s.batch.core.step.AbstractStep : Step: [helloTaskletStep] executed in 18ms
INFO 45102 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=helloTaskletJob]] completed with the following parameters: [{}] and the following status: [COMPLETED] in 43ms
⚠️ 함정 — .tasklet(...) 은 5.0 부터 PlatformTransactionManager 를 반드시 요구합니다
4.x 의 stepBuilderFactory.get("step").tasklet(tasklet).build() 를 그대로 붙여 넣으면 컴파일이 안 됩니다.
5.0 에서 StepBuilder 의 시그니처가 tasklet(Tasklet, PlatformTransactionManager) 로 바뀌었기 때문입니다.
트랜잭션 매니저를 "어딘가에서 알아서 찾는" 암묵적 동작을 없애고 명시하도록 강제한 것이며, 청크 Step 의 .chunk(size, txManager) 도 동일하게 바뀌었습니다.
4-2. RepeatStatus — FINISHED 와 CONTINUABLE
반환값의 의미는 딱 이것뿐입니다.
| 반환값 | 의미 | TaskletStep 의 동작 |
|---|
RepeatStatus.FINISHED | 할 일을 다 했다 | 트랜잭션 커밋 → Step 종료 |
RepeatStatus.CONTINUABLE | 아직 남았다, 다시 불러 달라 | 트랜잭션 커밋 → execute() 를 다시 호출 |
null | FINISHED 와 동일하게 취급 | Step 종료 |
핵심은 이것입니다.
CONTINUABLE 로 반복되는 매 회차는 "각각 독립된 트랜잭션" 입니다.
TaskletStep 의 실행 루프를 코드 수준으로 보면 이렇습니다.
TaskletStep.doExecute()
└─ stepOperations.iterate( ← RepeatTemplate
() -> {
transactionTemplate.execute(status -> { ← ★ 여기서 트랜잭션 시작
RepeatStatus rs = tasklet.execute(contribution, chunkContext);
jobRepository.update(stepExecution); ← 메타데이터도 같은 트랜잭션
return rs;
}); ← ★ 여기서 커밋
return rs; // CONTINUABLE 이면 iterate 가 위를 다시 실행
})
transactionTemplate.execute(...) 가 루프 안쪽에 있습니다. 그래서 반복 = 트랜잭션입니다.
4-3. 매 반복이 새 트랜잭션임을 로그로 확인한다
말로만 하면 믿기 어려우니 직접 봅니다. 트랜잭션 로그를 켭니다.
# application.yml — 이 절에서만 켰다가 끄세요. 로그가 매우 많아집니다.
logging:
level:
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
3회 반복 후 종료하는 Tasklet 을 만듭니다.
public static class CountingTasklet implements Tasklet {
private int count = 0; // ⚠️ 상태를 필드에 두는 것의 위험은 아래 함정 참고
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
count++;
System.out.printf("[countingTasklet] %d 회차 (thread=%s)%n",
count, Thread.currentThread().getName());
return count < 3 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
}
}
./gradlew bootRun -Dargs="--spring.batch.job.name=countingJob"
결과
INFO 45233 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [countingStep]
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@1893214716 wrapping com.mysql.cj.jdbc.ConnectionImpl@34c2b1] for JDBC transaction
[countingTasklet] 1 회차 (thread=main)
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@1893214716 wrapping com.mysql.cj.jdbc.ConnectionImpl@34c2b1]
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@1893214716 ...] after transaction
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@704531892 wrapping com.mysql.cj.jdbc.ConnectionImpl@34c2b1] for JDBC transaction
[countingTasklet] 2 회차 (thread=main)
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@704531892 ...]
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@704531892 ...] after transaction
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[countingTasklet] 3 회차 (thread=main)
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
DEBUG 45233 --- [ main] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection ... after transaction
INFO 45233 --- [ main] o.s.batch.core.step.AbstractStep : Step: [countingStep] executed in 61ms
Creating new transaction 이 3번, Committing JDBC transaction 이 3번. 반복 횟수와 정확히 같습니다.
메타데이터에도 흔적이 남습니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, COMMIT_COUNT, READ_COUNT, WRITE_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION WHERE STEP_NAME = 'countingStep';"
결과
+--------------+-----------+--------------+------------+-------------+----------------+
| STEP_NAME | STATUS | COMMIT_COUNT | READ_COUNT | WRITE_COUNT | ROLLBACK_COUNT |
+--------------+-----------+--------------+------------+-------------+----------------+
| countingStep | COMPLETED | 3 | 0 | 0 | 0 |
+--------------+-----------+--------------+------------+-------------+----------------+
COMMIT_COUNT = 3. 반복 3회 = 커밋 3회입니다. 이 사실이 실무에서 갖는 의미가 큽니다.
💡 CONTINUABLE 은 "커밋 지점을 내 손으로 만드는" 도구입니다
700만 행을 한 트랜잭션으로 지우면 InnoDB 의 언두 로그가 폭증하고, 락이 오래 잡히고, 롤백이 나면 지운 시간만큼 되돌리는 시간이 또 걸립니다.
CONTINUABLE 로 1만 행씩 700번 나누면 커밋이 700번 일어나 언두가 계속 정리되고 락 시간도 짧아집니다.
4-10 의 settlement 정리 Tasklet 이 정확히 이 패턴입니다.
⚠️ 함정 — Tasklet 의 인스턴스 필드는 재시작 시 초기화됩니다
위 CountingTasklet 은 count 를 필드에 들고 있습니다. Step 이 3회차에서 죽고 재시작하면 count 는 0부터 다시 시작합니다. Bean 이 새로 만들어지기 때문입니다.
게다가 Tasklet Bean 은 기본적으로 싱글턴이라, 같은 Job 을 한 JVM 에서 두 번 실행하면 count 가 이어집니다. 두 번째 실행은 1회차에 이미 count=4 가 되어 즉시 종료됩니다.
반복 상태는 필드가 아니라 ExecutionContext 에 저장해야 합니다(4-6). 재시작 이야기는 Step 09·11 에서 본격적으로 다룹니다.
4-4. 무한루프 — CONTINUABLE 의 진짜 함정
종료 조건을 잘못 쓰면 이렇게 됩니다.
// ❌ 절대 이렇게 쓰지 마세요
@Bean
public Step infiniteStep(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("infiniteStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
int deleted = jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = '2099-01-01' LIMIT 1000");
System.out.println("[infiniteStep] deleted=" + deleted);
return RepeatStatus.CONTINUABLE; // ← 종료 조건이 없다
}, txManager)
.build();
}
2099-01-01 짜리 데이터는 애초에 없으므로 deleted 는 항상 0입니다. 그런데 항상 CONTINUABLE 을 돌려주므로 영원히 돕니다.
결과
INFO 45341 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [infiniteStep]
[infiniteStep] deleted=0
[infiniteStep] deleted=0
[infiniteStep] deleted=0
[infiniteStep] deleted=0
... (Ctrl+C 로 죽일 때까지 초당 수천 줄)
⚠️ 함정 — 배치의 무한루프는 웹의 무한루프보다 훨씬 나쁩니다
웹 요청이라면 타임아웃이 끊어 줍니다. 배치는 아무도 안 끊습니다.
더 나쁜 것은 그 뒤에 남는 상태입니다. Ctrl+C 로 프로세스를 죽이면:
mysql> SELECT JOB_EXECUTION_ID, STATUS, START_TIME, END_TIME FROM BATCH_JOB_EXECUTION
-> WHERE JOB_EXECUTION_ID = 14;
+------------------+----------+---------------------+----------+
| JOB_EXECUTION_ID | STATUS | START_TIME | END_TIME |
+------------------+----------+---------------------+----------+
| 14 | STARTED | 2025-07-20 15:41:02 | NULL |
+------------------+----------+---------------------+----------+
STATUS = STARTED, END_TIME = NULL. 프레임워크 입장에서는 "아직 돌고 있는 Job" 입니다.
이 상태에서 같은 파라미터로 재실행하면 이렇게 거부됩니다.
Caused by: org.springframework.batch.core.repository.JobExecutionAlreadyRunningException: A job execution for this job is already running: JobInstance: id=11, version=0, Job=[infiniteJob]
at org.springframework.batch.core.repository.support.SimpleJobRepository.createJobExecution(SimpleJobRepository.java:130)
프로세스는 이미 죽었는데 DB 는 살아 있다고 믿습니다. 손으로 고쳐야 합니다.
UPDATE BATCH_STEP_EXECUTION SET STATUS='FAILED', END_TIME=NOW() WHERE JOB_EXECUTION_ID=14;
UPDATE BATCH_JOB_EXECUTION SET STATUS='FAILED', EXIT_CODE='FAILED', END_TIME=NOW() WHERE JOB_EXECUTION_ID=14;
새벽 3시에 이 UPDATE 를 치고 싶지 않다면, CONTINUABLE 을 쓸 때는 종료 조건을 먼저 쓰고 나서 나머지를 쓰세요.
안전한 형태는 이렇습니다. "진행이 없으면 종료" 를 항상 넣습니다.
return (contribution, chunkContext) -> {
int deleted = jdbcTemplate.update("DELETE FROM settlement WHERE settle_date = ? LIMIT 1000", date);
contribution.incrementWriteCount(deleted);
// ★ 진행이 없으면(0건) 반드시 종료한다
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
};
거기에 상한선을 하나 더 두면 이중 안전장치가 됩니다.
private static final int MAX_ITERATIONS = 10_000; // 1000행 × 10000회 = 1천만 행이면 충분
if (++iteration > MAX_ITERATIONS) {
throw new IllegalStateException(
"최대 반복 횟수 %d 를 초과했습니다. 종료 조건을 점검하세요.".formatted(MAX_ITERATIONS));
}
💡 실무 팁 — 반복은 "잔여 건수" 가 아니라 "이번에 처리한 건수" 로 판정하세요
SELECT COUNT(*) ... > 0 으로 종료를 판정하면, 지워지지 않는 행(예: FK 로 막힌 행)이 하나라도 있을 때 무한루프입니다.
DELETE 가 돌려주는 영향 행 수 로 판정하면 진행이 없을 때 반드시 멈춥니다.
4-5. StepContribution — 처리 건수를 보고한다
StepContribution 은 "이번 트랜잭션에서 몇 건을 처리했는지"를 담는 임시 집계기입니다. 트랜잭션이 커밋될 때 StepExecution 에 합산되고, BATCH_STEP_EXECUTION 에 반영됩니다.
Tasklet.execute()
└─ contribution.incrementWriteCount(1000)
│ (커밋 시점)
▼
StepExecution.writeCount += 1000
│
▼
BATCH_STEP_EXECUTION.WRITE_COUNT
| 메서드 | 반영 컬럼 | 용도 |
|---|
incrementReadCount() | READ_COUNT | 읽은 건수 (1씩) |
incrementWriteCount(int) | WRITE_COUNT | 쓴 건수 |
incrementFilterCount(int) | FILTER_COUNT | Processor 가 걸러낸 건수 |
setExitStatus(ExitStatus) | EXIT_CODE | Step 의 종료 코드. Flow 분기(Step 10)에 사용 |
⚠️ 함정 — 카운트를 보고하지 않으면 모니터링이 조용히 거짓말을 합니다
Tasklet 에서 70,000행을 지워도 contribution.incrementWriteCount() 를 호출하지 않으면 BATCH_STEP_EXECUTION.WRITE_COUNT 는 0 입니다.
+----------------+-----------+--------------+-------------+
| STEP_NAME | STATUS | COMMIT_COUNT | WRITE_COUNT |
+----------------+-----------+--------------+-------------+
| cleanupStep | COMPLETED | 8 | 0 | ← 실제로는 70000행 삭제
+----------------+-----------+--------------+-------------+
배치는 정상 동작했고 데이터도 맞습니다. 그런데 운영 대시보드에는 "0건 처리" 로 뜹니다.
그러면 "이 배치는 아무 일도 안 하네, 지워도 되겠다" 는 판단이 나오거나, 진짜로 0건이 처리된 장애를 구분하지 못합니다.
청크 Step 은 프레임워크가 자동으로 세어 주지만 Tasklet 은 여러분이 직접 보고해야 합니다. 이걸 잊는 것이 Tasklet 의 가장 흔한 실수입니다.
return (contribution, chunkContext) -> {
int deleted = jdbcTemplate.update("DELETE FROM settlement WHERE settle_date = ? LIMIT 1000", date);
contribution.incrementWriteCount(deleted); // ★ 반드시
if (deleted == 0) {
contribution.setExitStatus(new ExitStatus("NOTHING_TO_DELETE")); // Step 10 의 분기에 사용
return RepeatStatus.FINISHED;
}
return RepeatStatus.CONTINUABLE;
};
4-6. ChunkContext — 컨텍스트와 반복 상태 저장소
ChunkContext 를 통해 닿을 수 있는 것들입니다.
StepContext stepContext = chunkContext.getStepContext();
StepExecution stepExecution = stepContext.getStepExecution();
JobParameters params = stepExecution.getJobParameters(); // Step 03
ExecutionContext stepCtx = stepExecution.getExecutionContext(); // Step 범위 (재시작 시 복원됨)
ExecutionContext jobCtx = stepExecution.getJobExecution().getExecutionContext(); // Job 범위 (Step 간 공유)
String jobName = stepExecution.getJobExecution().getJobInstance().getJobName();
4-3 의 함정을 ExecutionContext 로 고치면 이렇게 됩니다.
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext ctx = chunkContext.getStepContext().getStepExecution().getExecutionContext();
int count = ctx.getInt("iteration", 0); // 없으면 0. 재시작하면 저장된 값이 복원됩니다.
count++;
ctx.putInt("iteration", count); // 같은 트랜잭션에서 BATCH_STEP_EXECUTION_CONTEXT 에 저장
System.out.printf("[ctxTasklet] %d 회차%n", count);
return count < 3 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
}
결과
[ctxTasklet] 1 회차
[ctxTasklet] 2 회차
[ctxTasklet] 3 회차
INFO 45412 --- [ main] o.s.batch.core.step.AbstractStep : Step: [ctxStep] executed in 57ms
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT c.SHORT_CONTEXT FROM BATCH_STEP_EXECUTION_CONTEXT c
JOIN BATCH_STEP_EXECUTION s ON s.STEP_EXECUTION_ID = c.STEP_EXECUTION_ID
WHERE s.STEP_NAME = 'ctxStep';"
결과
+---------------------------------------------------------------------+
| SHORT_CONTEXT |
+---------------------------------------------------------------------+
| {"@class":"java.util.HashMap","iteration":3,"batch.taskletType":...} |
+---------------------------------------------------------------------+
값이 DB 에 저장되었습니다. 재시작하면 여기서 복원됩니다. ExecutionContext 의 직렬화 규칙과 Job/Step 범위의 차이는 Step 09 에서 정면으로 다룹니다.
4-7. MethodInvokingTaskletAdapter — 기존 서비스 메서드를 그대로 쓴다
이미 잘 만들어 둔 서비스 메서드가 있는데, Tasklet 을 새로 구현하기가 아까울 때 씁니다.
@Component
public class SettlementReportService {
private final JdbcTemplate jdbcTemplate;
public SettlementReportService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** 배치를 전혀 모르는 평범한 메서드. Spring Batch 의존성이 하나도 없습니다. */
public long summarize(String settleDate) {
Long total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(net_amount), 0) FROM settlement WHERE settle_date = ?",
Long.class, settleDate);
System.out.printf("[report] %s 정산 순금액 합계 = %,d%n", settleDate, total);
return total;
}
}
@Bean
@StepScope
public MethodInvokingTaskletAdapter reportTasklet(SettlementReportService service,
@Value("#{jobParameters['date']}") String date) {
MethodInvokingTaskletAdapter adapter = new MethodInvokingTaskletAdapter();
adapter.setTargetObject(service);
adapter.setTargetMethod("summarize");
adapter.setArguments(new Object[]{date});
return adapter;
}
./gradlew bootRun -Dargs="--spring.batch.job.name=reportJob,date=2025-03-01"
결과
INFO 45520 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [reportStep]
[report] 2025-03-01 정산 순금액 합계 = 18,842,196
INFO 45520 --- [ main] o.s.batch.core.step.AbstractStep : Step: [reportStep] executed in 34ms
⚠️ 함정 — 메서드 이름을 문자열로 지정하므로 컴파일러가 오타를 못 잡습니다
setTargetMethod("summarise") 처럼 철자를 틀리면 기동 시점이 아니라 Step 실행 시점에 터집니다.
Caused by: java.lang.IllegalArgumentException: Unable to locate method: summarise
at org.springframework.util.Assert.notNull(Assert.java:172)
at org.springframework.beans.support.ArgumentConvertingMethodInvoker.prepare(ArgumentConvertingMethodInvoker.java:97)
at org.springframework.batch.core.step.tasklet.MethodInvokingTaskletAdapter.execute(MethodInvokingTaskletAdapter.java:56)
리팩터링으로 메서드 이름을 바꿔도 IDE 가 이 문자열은 안 바꿔 줍니다. 테스트 없이 배포하면 운영에서 처음 발견됩니다.
그래서 실무에서는 어댑터보다 람다로 서비스를 직접 호출하는 편을 더 많이 씁니다.
.tasklet((contribution, ctx) -> { service.summarize(date); return RepeatStatus.FINISHED; }, txManager)
어댑터가 유리한 경우는 XML 설정을 유지해야 하거나, 배치 의존성을 서비스 계층에 절대 넣지 않겠다는 방침이 있을 때입니다.
또 하나 주의할 점은 반환값이 ExitStatus 로 해석된다는 것입니다. summarize() 가 long 을 돌려주면 EXIT_CODE 가 그 숫자의 문자열이 됩니다.
+-------------+-----------+-----------+
| STEP_NAME | STATUS | EXIT_CODE |
+-------------+-----------+-----------+
| reportStep | COMPLETED | 18842196 |
+-------------+-----------+-----------+
Flow 분기(Step 10)에서 on("COMPLETED") 를 기대하고 있었다면 분기가 조용히 안 걸립니다. 반환 타입이 void 면 ExitStatus.COMPLETED 가 됩니다.
4-8. SystemCommandTasklet — 외부 명령 실행
셸 명령이나 외부 프로그램을 Step 으로 감쌉니다.
@Bean
public SystemCommandTasklet archiveTasklet() {
SystemCommandTasklet tasklet = new SystemCommandTasklet();
tasklet.setCommand("/bin/sh", "-c", "gzip -f /tmp/batch/settlement-2025-03-01.csv");
tasklet.setTimeout(60_000); // 60초. 필수는 아니지만 반드시 넣으세요.
tasklet.setWorkingDirectory("/tmp/batch");
tasklet.setInterruptOnCancel(true); // Step 중단 시 프로세스도 죽임
tasklet.setTerminationCheckInterval(1000);
return tasklet;
}
결과
INFO 45633 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [archiveStep]
DEBUG 45633 --- [ystemCommandTask] o.s.b.c.s.t.SystemCommandTasklet : Executing command: [/bin/sh, -c, gzip -f /tmp/batch/settlement-2025-03-01.csv]
INFO 45633 --- [ main] o.s.b.c.s.t.SystemCommandTasklet : Command exited with code 0
INFO 45633 --- [ main] o.s.batch.core.step.AbstractStep : Step: [archiveStep] executed in 218ms
종료 코드가 0이 아니면 Step 이 실패합니다.
ERROR 45701 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step archiveStep in job archiveJob
org.springframework.batch.core.step.tasklet.SystemCommandException: Execution of system command did not finish within the timeout of 60000ms
at org.springframework.batch.core.step.tasklet.SystemCommandTasklet.execute(SystemCommandTasklet.java:159)
at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:407)
⚠️ 함정 — setCommand("gzip -f a.csv") 처럼 한 문자열로 넣으면 동작하지 않습니다
5.0 에서 setCommand(String) 이 setCommand(String... command) 로 바뀌었습니다. 4.x 는 문자열 하나를 받아 공백으로 쪼갰지만, 5.x 는 각 인자를 배열 원소로 넘겨야 합니다.
한 문자열로 넣으면 셸이 "gzip -f a.csv" 라는 이름의 실행 파일을 찾다가 실패합니다.
그리고 파이프(|)·리다이렉션(>)·와일드카드(*)는 셸 기능이므로, /bin/sh -c "…" 로 감싸야 동작합니다.
💡 실무 팁 — setTimeout() 을 반드시 지정하세요
기본값은 0(무제한)입니다. 외부 명령이 응답 없이 멈추면 배치도 영원히 멈춥니다. 4-4 의 무한루프와 같은 결과입니다(STATUS=STARTED 로 방치).
외부 시스템을 부르는 모든 Step 에는 타임아웃이 있어야 합니다.
4-9. Tasklet vs 청크 — 무엇을 언제 쓰는가
| 기준 | Tasklet | 청크(Chunk-oriented) |
|---|
| 처리 모델 | "한 덩어리 작업" 을 내가 직접 | read → process → write 를 프레임워크가 반복 |
| 트랜잭션 | 반복 1회 = 1 트랜잭션 (내가 나눔) | 청크 1개 = 1 트랜잭션 (프레임워크가 나눔) |
| 카운트 집계 | 직접 StepContribution 에 보고 | 자동 |
| 재시작 지점 | 내가 ExecutionContext 로 관리 | Reader 가 자동으로 관리 |
| 스킵/재시도 | 직접 구현 | .faultTolerant().skip().retry() (Step 11) |
| 메모리 | 내가 로딩한 만큼 | 청크 크기만큼만 |
| 코드량 | 적음 | 많음 (Reader/Processor/Writer) |
Tasklet 이 적합한 경우
| 사례 | 이유 |
|---|
| 파일 이동·압축·삭제 | 항목 단위 반복이 아니라 단발성 작업 |
테이블 TRUNCATE / 대량 DELETE | SQL 한 줄이면 끝. 항목을 읽어올 이유가 없음 |
통계 집계 (INSERT ... SELECT 단일 SQL) | DB 안에서 처리하는 것이 압도적으로 빠름 |
| 외부 API 한 번 호출 (완료 알림 등) | 항목이 없음 |
| 선행 조건 검사 (원본 파일 존재 여부) | 실패 시 즉시 Job 중단 |
청크가 적합한 경우
| 사례 | 이유 |
|---|
| 항목마다 다른 계산이 필요 (등급별 수수료율) | Processor 가 항목 단위로 개입 |
| 항목 단위 스킵/재시도가 필요 | faultTolerant 가 항목 단위로 동작 |
| 데이터가 메모리에 다 안 들어감 | 청크 크기만큼만 로딩 |
| 소스와 타깃이 다름 (DB → 파일, 파일 → DB) | Reader/Writer 조합 |
| 중간 실패 후 이어서 재시작 | Reader 가 위치를 기억 |
💡 실무 팁 — "한 SQL 로 되는 일에 청크를 쓰지 마세요"
이 코스의 정산 로직은 청크로 만듭니다(Step 05~08). 하지만 정직하게 말하면, 이 정산은
INSERT INTO settlement SELECT ... FROM orders JOIN customers WHERE status='COMPLETED' 라는 SQL 한 줄로도 됩니다.
그리고 그게 훨씬 빠릅니다. 4-0 에서 그 SQL 로 70,000행을 만드는 데 1초도 안 걸렸습니다.
그럼에도 청크를 쓰는 이유는 이런 요구가 붙을 때입니다.
- 항목마다 외부 API 를 호출해야 한다 (SQL 로 불가능)
- 특정 항목만 스킵하고 나머지는 처리해야 한다 (SQL 은 전부 아니면 전무)
- 어디까지 처리했는지 재시작 지점이 필요하다
- 진행률을 항목 단위로 보고해야 한다
"SQL 한 줄로 되는가?"를 먼저 물으세요. 된다면 Tasklet 이 정답입니다.
배치 프레임워크를 배웠다고 모든 것을 청크로 만드는 것은, 망치를 든 사람이 모든 것을 못으로 보는 것과 같습니다.
4-10. 실전 — settlement 정리 Tasklet
이제 전부 합칩니다. 요구사항은 이렇습니다.
- 파라미터
settleDate 가 주어지면 그 날짜의 정산만, 주어지지 않으면 전체 삭제
- 한 번에 10,000행씩 나눠 지워 락을 오래 잡지 않는다
- 삭제 건수를
StepContribution 에 보고한다
- 진행이 없으면 반드시 종료한다 (무한루프 방지)
- 반복 상한을 둔다 (이중 안전장치)
package com.example.batch.step04;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.jdbc.core.JdbcTemplate;
public class SettlementCleanupTasklet implements Tasklet {
private static final int BATCH_SIZE = 10_000;
private static final int MAX_ITERATIONS = 1_000; // 10,000 × 1,000 = 1천만 행이면 충분
private final JdbcTemplate jdbcTemplate;
private final String settleDate; // null 이면 전체 삭제
public SettlementCleanupTasklet(JdbcTemplate jdbcTemplate, String settleDate) {
this.jdbcTemplate = jdbcTemplate;
this.settleDate = settleDate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
int iteration = ctx.getInt("iteration", 0) + 1;
if (iteration > MAX_ITERATIONS) {
throw new IllegalStateException(
"최대 반복 %d 회를 초과했습니다. 종료 조건을 점검하세요.".formatted(MAX_ITERATIONS));
}
int deleted = (settleDate == null)
? jdbcTemplate.update("DELETE FROM settlement LIMIT " + BATCH_SIZE)
: jdbcTemplate.update("DELETE FROM settlement WHERE settle_date = ? LIMIT " + BATCH_SIZE,
settleDate);
int total = ctx.getInt("deletedTotal", 0) + deleted;
ctx.putInt("iteration", iteration);
ctx.putInt("deletedTotal", total);
contribution.incrementWriteCount(deleted); // ★ 4-5 의 함정. 절대 빼먹지 마세요.
System.out.printf("[cleanup] %2d회차 deleted=%,d (누적 %,d)%n", iteration, deleted, total);
if (deleted == 0) {
contribution.setExitStatus(
total == 0 ? new ExitStatus("NOTHING_TO_DELETE") : ExitStatus.COMPLETED);
return RepeatStatus.FINISHED; // ★ 진행이 없으면 반드시 종료
}
return RepeatStatus.CONTINUABLE;
}
}
Step 과 Job 을 엮습니다. @StepScope 로 파라미터를 늦은 바인딩합니다(Step 03 의 3-10).
@Bean
@StepScope
public SettlementCleanupTasklet cleanupTasklet(
JdbcTemplate jdbcTemplate,
@Value("#{jobParameters['settleDate']}") String settleDate) {
return new SettlementCleanupTasklet(jdbcTemplate, settleDate);
}
@Bean
public Step cleanupStep(JobRepository jobRepository, PlatformTransactionManager txManager,
SettlementCleanupTasklet cleanupTasklet) {
return new StepBuilder("cleanupStep", jobRepository)
.tasklet(cleanupTasklet, txManager)
.build();
}
@Bean
public Job cleanupJob(JobRepository jobRepository, Step cleanupStep) {
return new JobBuilder("cleanupJob", jobRepository)
.start(cleanupStep)
.build();
}
하루치만 삭제
./gradlew bootRun -Dargs="--spring.batch.job.name=cleanupJob,settleDate=2025-03-01"
결과
INFO 45812 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=cleanupJob]] launched with the following parameters: [{'settleDate':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 45812 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [cleanupStep]
[cleanup] 1회차 deleted=389 (누적 389)
[cleanup] 2회차 deleted=0 (누적 389)
INFO 45812 --- [ main] o.s.batch.core.step.AbstractStep : Step: [cleanupStep] executed in 62ms
INFO 45812 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=cleanupJob]] completed with the following parameters: [{'settleDate':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 91ms
389건. Step 03 에서 센 하루치 COMPLETED 건수와 정확히 같습니다. 2회차에서 0건이 나와 종료했습니다.
2회차가 반드시 필요합니다. 1회차에 deleted(389) < BATCH_SIZE(10000) 이니 더 없다는 걸 추측할 수는 있지만, 그런 추측은 LIMIT 이 정확히 떨어지는 경우(예: 정확히 10,000행)에 틀립니다. "0건이 나올 때까지" 가 유일하게 안전한 종료 조건입니다.
전체 삭제 — 70,000행
settleDate 를 아예 넘기지 않으면 @Value("#{jobParameters['settleDate']}") 가 null 이 되어 전체 삭제 분기를 탑니다. 다만 파라미터가 하나도 없으면 앞선 실행과 JobInstance 가 겹칠 수 있으므로, 인스턴스 구분용으로 mode=full 을 줍니다.
./gradlew bootRun -Dargs="--spring.batch.job.name=cleanupJob,mode=full"
결과
INFO 45903 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=cleanupJob]] launched with the following parameters: [{'mode':'{value=full, type=class java.lang.String, identifying=true}'}]
INFO 45903 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [cleanupStep]
[cleanup] 1회차 deleted=10,000 (누적 10,000)
[cleanup] 2회차 deleted=10,000 (누적 20,000)
[cleanup] 3회차 deleted=10,000 (누적 30,000)
[cleanup] 4회차 deleted=10,000 (누적 40,000)
[cleanup] 5회차 deleted=10,000 (누적 50,000)
[cleanup] 6회차 deleted=10,000 (누적 60,000)
[cleanup] 7회차 deleted=9,611 (누적 69,611)
[cleanup] 8회차 deleted=0 (누적 69,611)
INFO 45903 --- [ main] o.s.batch.core.step.AbstractStep : Step: [cleanupStep] executed in 1s412ms
INFO 45903 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=cleanupJob]] completed with the following parameters: [{'mode':'{value=full, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 1s448ms
69,611행입니다. 70,000 이 아닌 이유는 앞에서 2025-03-01 의 389행을 이미 지웠기 때문입니다. 69,611 + 389 = 70,000. 숫자가 맞습니다.
메타데이터를 확인합니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, EXIT_CODE, COMMIT_COUNT, WRITE_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION WHERE STEP_NAME='cleanupStep' ORDER BY STEP_EXECUTION_ID;
SELECT COUNT(*) AS remaining FROM settlement;"
결과
+-------------+-----------+-----------+--------------+-------------+----------------+
| STEP_NAME | STATUS | EXIT_CODE | COMMIT_COUNT | WRITE_COUNT | ROLLBACK_COUNT |
+-------------+-----------+-----------+--------------+-------------+----------------+
| cleanupStep | COMPLETED | COMPLETED | 2 | 389 | 0 |
| cleanupStep | COMPLETED | COMPLETED | 8 | 69611 | 0 |
+-------------+-----------+-----------+--------------+-------------+----------------+
+-----------+
| remaining |
+-----------+
| 0 |
+-----------+
COMMIT_COUNT 가 반복 횟수와 정확히 일치하고(2회 / 8회), WRITE_COUNT 가 실제 삭제 건수와 일치합니다. 4-5 에서 incrementWriteCount() 를 호출했기 때문에 모니터링이 진실을 말합니다.
⚠️ 함정 — DELETE ... LIMIT 에 ORDER BY 가 없으면 삭제 순서가 보장되지 않습니다
지금은 "전부 지우는" 것이라 순서가 문제가 되지 않습니다. 하지만 일부만 지우고 멈출 수 있는 상황(예: 상한 초과로 예외)에서는 "무엇이 남았는지"를 예측할 수 없습니다.
재시작 가능한 정리 배치를 만들 거라면 DELETE FROM settlement WHERE settlement_id > ? ORDER BY settlement_id LIMIT 10000 처럼 커서 컬럼 기준으로 지우고, 마지막 id 를 ExecutionContext 에 저장하세요.
LIMIT 만 쓰는 방식은 "전부 지운다"는 전제에서만 안전합니다.
실습을 마쳤으니 다음 스텝을 위해 데이터를 복구해 둡니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -e "
SELECT COUNT(*) FROM settlement;" -- 0 이면 정상. Step 05 가 여기서부터 채웁니다.
정리
| 개념 | 핵심 |
|---|
Tasklet | execute(StepContribution, ChunkContext) 하나뿐인 함수형 인터페이스 |
| 청크와의 관계 | 청크 처리도 결국 ChunkOrientedTasklet 이라는 Tasklet 구현체 |
| 5.x 시그니처 | .tasklet(tasklet, txManager) — 트랜잭션 매니저 필수 |
FINISHED | 커밋하고 Step 종료. null 도 동일 |
CONTINUABLE | 커밋하고 다시 호출. 반복 1회 = 트랜잭션 1회 = COMMIT_COUNT 1 |
| 무한루프 | 종료 조건이 없으면 영원히 돎. 죽여도 STATUS=STARTED 로 남아 재실행 불가 |
| 안전한 종료 조건 | "이번에 처리한 건수가 0이면 종료" + 반복 상한 예외 |
StepContribution | 처리 건수를 직접 보고해야 함. 안 하면 WRITE_COUNT=0 으로 모니터링이 거짓말 |
ChunkContext | JobParameters / Step·Job ExecutionContext / StepExecution 접근 경로 |
| 반복 상태 | 인스턴스 필드 ❌ (싱글턴·재시작 문제) → ExecutionContext ⭕ |
MethodInvokingTaskletAdapter | 기존 서비스 메서드 재사용. 메서드명이 문자열이라 오타를 런타임에 발견 |
| 어댑터 반환값 | ExitStatus 로 해석됨. long 을 돌려주면 EXIT_CODE 가 숫자가 되어 분기가 깨짐 |
SystemCommandTasklet | 외부 명령. 5.x 는 setCommand(String...) 가변인자. setTimeout() 필수 |
| Tasklet 이 적합 | 파일 이동, TRUNCATE/대량 DELETE, 단일 SQL 집계, 외부 API 1회, 선행 조건 검사 |
| 청크가 적합 | 항목별 계산·스킵·재시도, 메모리 초과 데이터, 소스≠타깃, 재시작 지점 필요 |
| 판단 기준 | "SQL 한 줄로 되는가?" 를 먼저 묻는다. 되면 Tasklet |
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.
- 무한루프가 되는 Tasklet 을 찾아 최소 수정으로 고치기
- 반복 3회짜리 Tasklet 의
COMMIT_COUNT / WRITE_COUNT / 트랜잭션 로그 줄 수 예측
- 인스턴스 필드로 상태를 들고 있는 Tasklet 을
ExecutionContext 기반으로 리팩터링
WRITE_COUNT 가 0으로 남는 Tasklet 을 고치고, 왜 이것이 "조용한 버그"인지 서술
- 주어진 요구 6개를 Tasklet / 청크로 분류하고 근거 적기
orders 를 일자별로 집계해 daily_summary 에 넣는 작업을 단일 SQL Tasklet 으로 구현
다음 단계
Tasklet 으로 "한 덩어리 작업"을 다루는 법을 익혔습니다. 하지만 70,000건의 주문을 하나씩 읽어 등급별 수수료를 계산하고 정산 테이블에 넣는 일은, Tasklet 안에서 직접 루프를 돌리면 메모리도 트랜잭션도 감당이 안 됩니다.
다음 스텝에서 청크 지향 처리의 구조를 열어 봅니다. chunk(1000, txManager) 의 1000이 정확히 무엇을 나누는지, 70,000건이 왜 정확히 70청크가 되는지, 그리고 청크 크기를 바꿨을 때 커밋 횟수와 처리 시간이 어떻게 달라지는지를 실측합니다.
→ Step 05 — 청크 지향 처리
실습 파일
이 스텝은 Java 파일 세 개로 진행합니다. 먼저 4-0 의 SQL 로 settlement 70,000행을 만들어 두고, Practice.java 를 src/main/java/com/example/batch/step04/ 에 놓은 뒤 4-1 부터 순서대로 실행합니다. 그다음 Exercise.java 의 6문제를 풀고 Solution.java 로 대조합니다. 4-10 을 실행하면 settlement 가 비워지므로, 다시 실습하려면 4-0 의 INSERT 를 한 번 더 돌려야 합니다.
Practice.java
본문 4-1 ~ 4-10 의 모든 Tasklet 과 Step/Job 정의를 // [4-3] 형태의 절 번호 주석과 함께 담았습니다.
- 최상위
Practice 클래스 안에 HelloConfig, RepeatConfig, AdapterConfig, SystemCommandConfig, CleanupConfig 다섯 개의 static class 가 각각 @Configuration 입니다. 절별로 실험하려면 나머지의 @Configuration 을 주석 처리하세요.
[4-4] 의 infiniteStep 은 기본적으로 주석 처리되어 있습니다. 무한루프를 직접 보고 싶다면 주석을 풀되, Ctrl+C 로 죽인 뒤 반드시 파일 하단 주석의 복구 SQL 을 실행해야 합니다. 안 하면 STATUS=STARTED 가 남아 그 Job 을 다시 실행할 수 없습니다.
[4-3] 의 CountingTasklet 은 상태를 일부러 인스턴스 필드에 둡니다. 같은 JVM 에서 두 번 실행하면 두 번째는 1회차 만에 끝나는 것을 확인하기 위해서입니다. 올바른 형태는 바로 아래 [4-6] 의 ContextCountingTasklet 입니다. 두 개를 나란히 놓고 비교하세요.
[4-7] 의 SettlementReportService 는 Spring Batch 의존성이 하나도 없는 평범한 @Component 입니다. 이것이 MethodInvokingTaskletAdapter 의 존재 이유입니다. 어댑터 Bean 정의 바로 위 주석에 메서드명 오타 시 나는 예외를 적어 두었으니, 한 글자를 틀려 보고 실행해 보길 권합니다.
[4-8] 의 SystemCommandTasklet 은 /tmp/batch 디렉터리를 전제로 합니다. 실행 전에 mkdir -p /tmp/batch && touch /tmp/batch/settlement-2025-03-01.csv 를 해 두세요. 없으면 gzip 이 종료 코드 1로 실패하며 Step 이 FAILED 됩니다(이것도 유익한 실습입니다).
[4-10] 의 SettlementCleanupTasklet 이 이 스텝의 결론입니다. 무한루프 방지 조건 두 개(deleted == 0 종료, MAX_ITERATIONS 예외), ExecutionContext 상태 저장, incrementWriteCount 보고가 모두 들어 있습니다. 셋 중 하나라도 빼면 어떤 문제가 생기는지 주석에 적어 두었습니다.
package com.example.batch.step04;
/*
* ============================================================================
* Step 04 — Tasklet Step / 실습 코드
* ============================================================================
*
* 환경: 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/step04/Practice.java
*
* ── 실행 전 준비 (4-0) ──────────────────────────────────────────────────────
* mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb <<'SQL'
* TRUNCATE TABLE settlement;
* INSERT INTO settlement (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount)
* SELECT o.order_id, o.customer_id, DATE(o.ordered_at), o.amount, c.fee_rate,
* ROUND(o.amount * c.fee_rate, 2), o.amount - ROUND(o.amount * c.fee_rate, 2)
* FROM orders o JOIN customers c ON c.customer_id = o.customer_id
* WHERE o.status = 'COMPLETED';
* SQL
* → 70,000행 / gross 3,485,250,000.00 / fee 95,844,375.00 / net 3,389,405,625.00
*
* [4-8] 을 실행할 거라면:
* mkdir -p /tmp/batch && touch /tmp/batch/settlement-2025-03-01.csv
*
* ── 실행 ────────────────────────────────────────────────────────────────────
* ./gradlew bootRun -Dargs="--spring.batch.job.name=helloTaskletJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=countingJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=ctxJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=reportJob,date=2025-03-01"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=archiveJob"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=cleanupJob,settleDate=2025-03-01"
* ./gradlew bootRun -Dargs="--spring.batch.job.name=cleanupJob,mode=full"
*
* ── 트랜잭션 로그 켜기 ([4-3] 에서만) ────────────────────────────────────────
* application.yml:
* logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
* 확인이 끝나면 반드시 다시 끄세요. 로그가 매우 많아집니다.
* ============================================================================
*/
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.MethodInvokingTaskletAdapter;
import org.springframework.batch.core.step.tasklet.SystemCommandTasklet;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ExecutionContext;
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;
public class Practice {
// ========================================================================
// [4-1] 가장 단순한 Tasklet
// ========================================================================
@Configuration
public static class HelloConfig {
@Bean
public Job helloTaskletJob(JobRepository jobRepository, Step helloTaskletStep) {
return new JobBuilder("helloTaskletJob", jobRepository)
.start(helloTaskletStep)
.build();
}
/*
* ⚠️ 5.0 부터 .tasklet(Tasklet, PlatformTransactionManager) 로 시그니처가 바뀌었습니다.
* 4.x 의 stepBuilderFactory.get("step").tasklet(t).build() 는 컴파일되지 않습니다.
* 트랜잭션 매니저를 암묵적으로 찾던 동작을 없애고 명시하도록 강제한 변경입니다.
*/
@Bean
public Step helloTaskletStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("helloTaskletStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("[helloTasklet] 한 번 실행되고 끝납니다.");
return RepeatStatus.FINISHED; // null 을 반환해도 동일하게 취급됩니다.
}, txManager)
.build();
}
}
// ========================================================================
// [4-2] [4-3] RepeatStatus.CONTINUABLE — 매 반복이 새 트랜잭션
// [4-6] ExecutionContext 로 반복 상태 저장
// ========================================================================
@Configuration
public static class RepeatConfig {
/**
* [4-3] ⚠️ 일부러 잘못 만든 버전 — 상태를 인스턴스 필드에 둡니다.
*
* 문제 두 가지:
* ① Tasklet Bean 은 싱글턴입니다. 같은 JVM 에서 이 Job 을 두 번 실행하면
* 두 번째 실행은 count 가 이미 3 이라 1회차 만에 끝납니다.
* (bootRun 은 매번 새 JVM 이라 잘 안 보이지만, 테스트나 상주 프로세스에서는 바로 드러납니다.)
* ② Step 이 중간에 실패해 재시작하면 count 는 0부터 다시 시작합니다.
* 이미 처리한 구간을 다시 처리하게 됩니다.
*
* 올바른 형태는 아래 ContextCountingTasklet 입니다. 나란히 놓고 비교하세요.
*/
public static class CountingTasklet implements Tasklet {
private int count = 0;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
count++;
System.out.printf("[countingTasklet] %d 회차 (thread=%s)%n",
count, Thread.currentThread().getName());
return count < 3 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
}
}
@Bean
public Step countingStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("countingStep", jobRepository)
.tasklet(new CountingTasklet(), txManager)
.build();
}
@Bean
public Job countingJob(JobRepository jobRepository, Step countingStep) {
return new JobBuilder("countingJob", jobRepository)
.start(countingStep)
.build();
}
/*
* 실행 후 확인:
* SELECT STEP_NAME, STATUS, COMMIT_COUNT, WRITE_COUNT, ROLLBACK_COUNT
* FROM BATCH_STEP_EXECUTION WHERE STEP_NAME='countingStep';
* → COMMIT_COUNT = 3. 반복 횟수와 정확히 같습니다.
*
* 트랜잭션 로그(DEBUG)에서도 Creating new transaction 3줄 / Committing 3줄이 나옵니다.
* transactionTemplate.execute() 가 RepeatTemplate 의 루프 "안쪽" 에 있기 때문입니다.
*/
/**
* [4-6] 올바른 형태 — 반복 상태를 ExecutionContext 에 저장합니다.
*
* ExecutionContext 는 같은 트랜잭션에서 BATCH_STEP_EXECUTION_CONTEXT 에 직렬화되어
* 저장되므로, 재시작 시 그대로 복원됩니다.
*/
public static class ContextCountingTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
int count = ctx.getInt("iteration", 0) + 1;
ctx.putInt("iteration", count);
System.out.printf("[ctxTasklet] %d 회차%n", count);
return count < 3 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
}
}
@Bean
public Step ctxStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("ctxStep", jobRepository)
.tasklet(new ContextCountingTasklet(), txManager)
.build();
}
@Bean
public Job ctxJob(JobRepository jobRepository, Step ctxStep) {
return new JobBuilder("ctxJob", jobRepository)
.start(ctxStep)
.build();
}
/*
* ====================================================================
* [4-4] ❌ 무한루프 — 기본적으로 주석 처리해 두었습니다.
* ====================================================================
*
* 직접 보고 싶다면 @Bean 주석을 풀고 실행하세요. 단, 두 가지를 지키세요.
* 1) Ctrl+C 로 죽일 준비를 하고 실행할 것
* 2) 죽인 뒤 반드시 파일 하단의 "무한루프 뒤처리 SQL" 을 실행할 것
*
* 안 하면 BATCH_JOB_EXECUTION.STATUS 가 STARTED / END_TIME 이 NULL 로 남아
* 같은 파라미터 재실행 시 JobExecutionAlreadyRunningException 이 납니다.
* 프로세스는 죽었는데 DB 는 살아 있다고 믿는 상태입니다.
*
* @Bean
* public Step infiniteStep(JobRepository jobRepository, PlatformTransactionManager txManager,
* JdbcTemplate jdbcTemplate) {
* return new StepBuilder("infiniteStep", jobRepository)
* .tasklet((contribution, chunkContext) -> {
* int deleted = jdbcTemplate.update(
* "DELETE FROM settlement WHERE settle_date = '2099-01-01' LIMIT 1000");
* System.out.println("[infiniteStep] deleted=" + deleted);
* return RepeatStatus.CONTINUABLE; // ← 종료 조건이 없다
* }, txManager)
* .build();
* }
*
* 고치는 방법은 한 줄입니다:
* return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
*
* 판정은 반드시 "이번에 처리한 건수" 로 합니다.
* SELECT COUNT(*) > 0 으로 판정하면 FK 나 트리거로 지워지지 않는 행이 하나만 있어도
* 영원히 돕니다. DELETE 의 영향 행 수로 판정하면 진행이 없을 때 반드시 멈춥니다.
*/
}
// ========================================================================
// [4-7] MethodInvokingTaskletAdapter
// ========================================================================
/** Spring Batch 의존성이 하나도 없는 평범한 서비스. 이것이 어댑터의 존재 이유입니다. */
@Component
public static class SettlementReportService {
private final JdbcTemplate jdbcTemplate;
public SettlementReportService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public long summarize(String settleDate) {
Long total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(net_amount), 0) FROM settlement WHERE settle_date = ?",
Long.class, settleDate);
System.out.printf("[report] %s 정산 순금액 합계 = %,d%n", settleDate, total);
return total;
}
}
@Configuration
public static class AdapterConfig {
/*
* ⚠️ 함정 1 — 메서드 이름이 문자열입니다.
* setTargetMethod("summarise") 처럼 한 글자만 틀려도 컴파일은 통과하고,
* 기동도 되고, Step 실행 시점에야 터집니다:
*
* Caused by: java.lang.IllegalArgumentException: Unable to locate method: summarise
* at org.springframework.beans.support.ArgumentConvertingMethodInvoker.prepare(...)
* at org.springframework.batch.core.step.tasklet.MethodInvokingTaskletAdapter.execute(...)
*
* 한 번 틀려 보고 실행해 보길 권합니다. IDE 리팩터링도 이 문자열은 안 바꿔 줍니다.
*
* ⚠️ 함정 2 — 반환값이 ExitStatus 로 해석됩니다.
* summarize() 가 long 을 돌려주므로 EXIT_CODE 가 "18842196" 같은 숫자가 됩니다.
* Step 10 의 Flow 분기에서 .on("COMPLETED") 를 기대하고 있었다면 분기가 조용히 안 걸립니다.
* 반환 타입이 void 면 ExitStatus.COMPLETED 가 됩니다.
*
* 실무에서는 어댑터보다 람다로 서비스를 직접 호출하는 편이 더 안전합니다:
* .tasklet((c, ctx) -> { service.summarize(date); return RepeatStatus.FINISHED; }, txManager)
*/
@Bean
@StepScope
public MethodInvokingTaskletAdapter reportTasklet(
SettlementReportService service,
@Value("#{jobParameters['date']}") String date) {
MethodInvokingTaskletAdapter adapter = new MethodInvokingTaskletAdapter();
adapter.setTargetObject(service);
adapter.setTargetMethod("summarize");
adapter.setArguments(new Object[]{date});
return adapter;
}
@Bean
public Step reportStep(JobRepository jobRepository, PlatformTransactionManager txManager,
MethodInvokingTaskletAdapter reportTasklet) {
return new StepBuilder("reportStep", jobRepository)
.tasklet(reportTasklet, txManager)
.build();
}
@Bean
public Job reportJob(JobRepository jobRepository, Step reportStep) {
return new JobBuilder("reportJob", jobRepository)
.start(reportStep)
.build();
}
}
// ========================================================================
// [4-8] SystemCommandTasklet
// ========================================================================
@Configuration
public static class SystemCommandConfig {
/*
* 실행 전 준비:
* mkdir -p /tmp/batch && touch /tmp/batch/settlement-2025-03-01.csv
*
* ⚠️ 함정 — 5.0 에서 setCommand(String) 이 setCommand(String... command) 로 바뀌었습니다.
* 4.x 는 문자열 하나를 받아 공백으로 쪼갰지만 5.x 는 각 인자를 배열 원소로 넘겨야 합니다.
* setCommand("gzip -f a.csv") 로 넣으면 셸이 그 이름의 실행 파일을 찾다가 실패합니다.
* 그리고 파이프(|)·리다이렉션(>)·와일드카드(*)는 셸 기능이므로 /bin/sh -c 로 감싸야 합니다.
*
* 💡 setTimeout() 은 사실상 필수입니다. 기본값 0 은 무제한이라,
* 외부 명령이 응답 없이 멈추면 배치도 영원히 멈춥니다. [4-4] 의 무한루프와 같은 결과입니다.
*/
@Bean
public SystemCommandTasklet archiveTasklet() {
SystemCommandTasklet tasklet = new SystemCommandTasklet();
tasklet.setCommand("/bin/sh", "-c", "gzip -f /tmp/batch/settlement-2025-03-01.csv");
tasklet.setTimeout(60_000);
tasklet.setWorkingDirectory("/tmp/batch");
tasklet.setInterruptOnCancel(true); // Step 중단 시 자식 프로세스도 죽임
tasklet.setTerminationCheckInterval(1000);
return tasklet;
}
@Bean
public Step archiveStep(JobRepository jobRepository, PlatformTransactionManager txManager,
SystemCommandTasklet archiveTasklet) {
return new StepBuilder("archiveStep", jobRepository)
.tasklet(archiveTasklet, txManager)
.build();
}
@Bean
public Job archiveJob(JobRepository jobRepository, Step archiveStep) {
return new JobBuilder("archiveJob", jobRepository)
.start(archiveStep)
.build();
}
}
// ========================================================================
// [4-10] 실전 — settlement 정리 Tasklet
// ========================================================================
/**
* 요구사항
* 1. settleDate 가 있으면 그 날짜만, 없으면 전체 삭제
* 2. 10,000행씩 나눠 지워 락을 오래 잡지 않는다 (CONTINUABLE = 커밋 지점을 내가 만든다)
* 3. 삭제 건수를 StepContribution 에 보고한다
* 4. 진행이 없으면 반드시 종료한다 (무한루프 방지)
* 5. 반복 상한을 둔다 (이중 안전장치)
*
* 셋 중 하나라도 빼면:
* - 3 을 빼면 → WRITE_COUNT 가 0. 데이터는 맞는데 모니터링이 "0건 처리" 라고 거짓말합니다.
* - 4 를 빼면 → 무한루프. 죽여도 STATUS=STARTED 로 남아 재실행 불가.
* - 5 를 빼면 → 4 의 조건에 버그가 있을 때 잡아 줄 그물이 없습니다.
*/
public static class SettlementCleanupTasklet implements Tasklet {
private static final int BATCH_SIZE = 10_000;
private static final int MAX_ITERATIONS = 1_000; // 10,000 × 1,000 = 1천만 행
private final JdbcTemplate jdbcTemplate;
private final String settleDate; // null 이면 전체 삭제
public SettlementCleanupTasklet(JdbcTemplate jdbcTemplate, String settleDate) {
this.jdbcTemplate = jdbcTemplate;
this.settleDate = settleDate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
int iteration = ctx.getInt("iteration", 0) + 1;
if (iteration > MAX_ITERATIONS) {
throw new IllegalStateException(
"최대 반복 %d 회를 초과했습니다. 종료 조건을 점검하세요.".formatted(MAX_ITERATIONS));
}
int deleted = (settleDate == null)
? jdbcTemplate.update("DELETE FROM settlement LIMIT " + BATCH_SIZE)
: jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = ? LIMIT " + BATCH_SIZE,
settleDate);
int total = ctx.getInt("deletedTotal", 0) + deleted;
ctx.putInt("iteration", iteration);
ctx.putInt("deletedTotal", total);
contribution.incrementWriteCount(deleted); // ★ [4-5] 의 함정. 절대 빼먹지 마세요.
System.out.printf("[cleanup] %2d회차 deleted=%,d (누적 %,d)%n", iteration, deleted, total);
if (deleted == 0) {
// 0건이 나올 때까지 도는 것이 유일하게 안전한 종료 조건입니다.
// "deleted < BATCH_SIZE 면 끝" 이라는 추측은 정확히 BATCH_SIZE 만큼 남았을 때 틀립니다.
contribution.setExitStatus(
total == 0 ? new ExitStatus("NOTHING_TO_DELETE") : ExitStatus.COMPLETED);
return RepeatStatus.FINISHED;
}
return RepeatStatus.CONTINUABLE;
}
}
@Configuration
public static class CleanupConfig {
@Bean
@StepScope
public SettlementCleanupTasklet cleanupTasklet(
JdbcTemplate jdbcTemplate,
@Value("#{jobParameters['settleDate']}") String settleDate) {
// settleDate 를 넘기지 않으면 null 이 되어 전체 삭제 분기를 탑니다.
return new SettlementCleanupTasklet(jdbcTemplate, settleDate);
}
@Bean
public Step cleanupStep(JobRepository jobRepository, PlatformTransactionManager txManager,
SettlementCleanupTasklet cleanupTasklet) {
return new StepBuilder("cleanupStep", jobRepository)
.tasklet(cleanupTasklet, txManager)
.build();
}
@Bean
public Job cleanupJob(JobRepository jobRepository, Step cleanupStep) {
return new JobBuilder("cleanupJob", jobRepository)
.start(cleanupStep)
.build();
}
}
/*
* ============================================================================
* 확인용 SQL
* ============================================================================
*
* -- 반복 횟수 = COMMIT_COUNT 확인
* SELECT STEP_NAME, STATUS, EXIT_CODE, COMMIT_COUNT, WRITE_COUNT, ROLLBACK_COUNT
* FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID;
*
* -- ExecutionContext 에 저장된 반복 상태 확인
* SELECT s.STEP_NAME, c.SHORT_CONTEXT
* FROM BATCH_STEP_EXECUTION_CONTEXT c
* JOIN BATCH_STEP_EXECUTION s ON s.STEP_EXECUTION_ID = c.STEP_EXECUTION_ID
* WHERE s.STEP_NAME IN ('ctxStep', 'cleanupStep');
*
* -- 남은 정산 행 수
* SELECT COUNT(*) FROM settlement;
*
* ── 무한루프 뒤처리 SQL ([4-4] 를 실행했다면 반드시) ─────────────────────
* -- 먼저 매달린 실행을 찾습니다.
* SELECT JOB_EXECUTION_ID, STATUS, START_TIME, END_TIME
* FROM BATCH_JOB_EXECUTION WHERE STATUS = 'STARTED';
*
* -- 그 ID 로 강제 종료 처리합니다.
* UPDATE BATCH_STEP_EXECUTION
* SET STATUS='FAILED', EXIT_CODE='FAILED', END_TIME=NOW()
* WHERE JOB_EXECUTION_ID = <위에서 찾은 ID>;
* UPDATE BATCH_JOB_EXECUTION
* SET STATUS='FAILED', EXIT_CODE='FAILED', END_TIME=NOW()
* WHERE JOB_EXECUTION_ID = <위에서 찾은 ID>;
*
* ── 실습 데이터 복구 (settlement 를 비웠다면) ──────────────────────────
* TRUNCATE TABLE settlement;
* INSERT INTO settlement (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount)
* SELECT o.order_id, o.customer_id, DATE(o.ordered_at), o.amount, c.fee_rate,
* ROUND(o.amount * c.fee_rate, 2), o.amount - ROUND(o.amount * c.fee_rate, 2)
* FROM orders o JOIN customers c ON c.customer_id = o.customer_id
* WHERE o.status = 'COMPLETED';
* ============================================================================
*/
}
Exercise.java
6문제의 문제지입니다. 각 문제는 // 여기에 작성: 자리를 비워 두었습니다.
- 문제 1 의
Ex1Tasklet 은 실행하면 진짜로 무한루프에 빠집니다. 실행하기 전에 코드를 읽고 원인을 찾으세요. 실행해서 확인하고 싶다면 Ctrl+C 준비를 하고, 이후 복구 SQL 을 잊지 마세요.
- 문제 2 는 코드를 쓰지 않고 예측만 하는 문제입니다. 예측을 문자열로 적은 뒤 실행하고
BATCH_STEP_EXECUTION 을 SELECT 해서 맞춰 보세요. 트랜잭션 로그 줄 수를 세려면 DataSourceTransactionManager 로거를 DEBUG 로 올려야 합니다.
- 문제 3 은 리팩터링입니다. 필드
private int processed 를 없애고 ExecutionContext 의 getInt/putInt 로 바꾸면 됩니다. 키 이름은 자유이지만, ExecutionContext 는 Step 범위와 Job 범위가 다르다는 점을 기억하세요(문제는 Step 범위로 푸는 것이 맞습니다).
- 문제 4 는 고치는 것보다 왜 위험한지 서술하는 부분이 본체입니다. "데이터는 맞는데 지표가 틀린" 상황이 배치 운영에서 어떤 결과를 낳는지 두세 문장으로 적으세요.
- 문제 5 는 6개 요구를 분류하는 표 채우기입니다. 답이 갈릴 수 있는 항목이 하나 있으므로(대량 UPDATE), 근거를 반드시 함께 적으세요.
- 문제 6 은 실제 코딩입니다.
daily_summary 테이블 DDL 이 문제 주석에 있으니 먼저 만들고 시작하세요. INSERT ... SELECT 한 방이면 되며, jdbcTemplate.update() 의 반환값을 그대로 incrementWriteCount 에 넘기면 됩니다.
package com.example.batch.step04;
/*
* ============================================================================
* Step 04 — 연습문제 (6문제)
* ============================================================================
*
* 정답은 Solution.java 에 있습니다. 먼저 직접 풀어 보세요.
*
* 놓을 위치: src/main/java/com/example/batch/step04/Exercise.java
*
* 시작 전 준비 — settlement 를 70,000행으로 채워 둡니다:
* mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb <<'SQL'
* TRUNCATE TABLE settlement;
* INSERT INTO settlement (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount)
* SELECT o.order_id, o.customer_id, DATE(o.ordered_at), o.amount, c.fee_rate,
* ROUND(o.amount * c.fee_rate, 2), o.amount - ROUND(o.amount * c.fee_rate, 2)
* FROM orders o JOIN customers c ON c.customer_id = o.customer_id
* WHERE o.status = 'COMPLETED';
* SQL
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
public class Exercise {
// ========================================================================
// 문제 1. 무한루프 찾아 고치기
// ------------------------------------------------------------------------
// 아래 Tasklet 은 "REFUNDED 주문에 대한 정산 행을 지운다" 는 의도로 작성되었습니다.
// 그런데 실행하면 영원히 돕니다.
//
// (a) 왜 무한루프인가?
// (b) 최소 수정으로 고치세요. (한 줄이면 됩니다)
// (c) "남은 건수를 SELECT COUNT(*) 로 세서 0이면 종료" 로 고치는 것은 왜 나쁜 답인가?
//
// ⚠️ 실행하기 전에 코드를 먼저 읽으세요. 실행할 거라면 Ctrl+C 준비를 하고,
// 죽인 뒤에는 Practice.java 하단의 "무한루프 뒤처리 SQL" 을 반드시 실행하세요.
// ========================================================================
static final String ANSWER_1_A = "여기에 작성: ";
static final String ANSWER_1_C = "여기에 작성: ";
public static class Ex1Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Ex1Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
int deleted = jdbcTemplate.update("""
DELETE s FROM settlement s
JOIN orders o ON o.order_id = s.order_id
WHERE o.status = 'REFUNDED'
LIMIT 1000
""");
System.out.println("[ex1] deleted=" + deleted);
// 여기에 작성: 종료 조건을 넣으세요.
return RepeatStatus.CONTINUABLE;
}
}
// ========================================================================
// 문제 2. 예측하기 — 반복 · 트랜잭션 · 카운트의 대응 관계
// ------------------------------------------------------------------------
// 아래 Ex2Tasklet 을 실행합니다. 실행하기 "전에" 다음을 예측해 적으세요.
//
// (a) BATCH_STEP_EXECUTION.COMMIT_COUNT 는?
// (b) BATCH_STEP_EXECUTION.WRITE_COUNT 는?
// (c) DataSourceTransactionManager 를 DEBUG 로 켰을 때
// "Creating new transaction" 로그는 몇 줄 나오는가?
//
// 예측을 적은 뒤 실행하고 아래 SQL 로 확인하세요.
// SELECT STEP_NAME, COMMIT_COUNT, WRITE_COUNT FROM BATCH_STEP_EXECUTION
// WHERE STEP_NAME='ex2Step';
// ========================================================================
static final String ANSWER_2_A = "여기에 작성: ";
static final String ANSWER_2_B = "여기에 작성: ";
static final String ANSWER_2_C = "여기에 작성: ";
public static class Ex2Tasklet implements Tasklet {
private int round = 0;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
round++;
contribution.incrementWriteCount(500);
System.out.println("[ex2] round=" + round);
return round < 3 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
}
}
// ========================================================================
// 문제 3. 인스턴스 필드 → ExecutionContext 리팩터링
// ------------------------------------------------------------------------
// 아래 Tasklet 은 처리한 누적 건수를 인스턴스 필드에 들고 있습니다.
// ExecutionContext 를 쓰도록 고치세요.
//
// 함께 답할 것:
// 인스턴스 필드가 위험한 이유를 "두 가지" 로 나눠 적으세요.
// (하나는 같은 JVM 에서의 문제, 하나는 재시작에서의 문제입니다)
// ========================================================================
static final String ANSWER_3 = "여기에 작성: ";
public static class Ex3Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private int processed = 0; // 여기에 작성: 이 필드를 없애세요.
public Ex3Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
int deleted = jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = '2025-01-01' LIMIT 100");
// 여기에 작성: processed 대신 ExecutionContext 를 사용하도록 고치세요.
processed += deleted;
System.out.println("[ex3] 누적 " + processed);
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
}
}
// ========================================================================
// 문제 4. 조용한 버그 — WRITE_COUNT 가 0
// ------------------------------------------------------------------------
// 아래 Tasklet 은 정상 동작합니다. 데이터도 정확히 지워집니다.
// 그런데 BATCH_STEP_EXECUTION.WRITE_COUNT 는 0으로 남습니다.
//
// (a) 고치세요.
// (b) "데이터는 맞는데 지표가 0" 인 상황이 배치 운영에서 어떤 결과를 낳는지
// 두세 문장으로 서술하세요. ← 이쪽이 이 문제의 본체입니다.
// ========================================================================
static final String ANSWER_4_B = "여기에 작성: ";
public static class Ex4Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Ex4Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
int deleted = jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = '2025-01-02' LIMIT 1000");
// 여기에 작성: 처리 건수를 보고하세요.
System.out.println("[ex4] deleted=" + deleted);
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
}
}
// ========================================================================
// 문제 5. Tasklet 인가 청크인가
// ------------------------------------------------------------------------
// 아래 6개 요구를 Tasklet / 청크로 분류하고 근거를 적으세요.
// 답이 갈릴 수 있는 항목이 하나 있습니다. 근거를 반드시 함께 쓰세요.
//
// ① 어제 생성된 CSV 파일을 /archive 로 옮기고 gzip 압축한다
// ② orders 70,000건을 읽어 고객 등급별 수수료를 계산해 settlement 에 넣는다
// ③ 일자별 주문 건수·금액을 집계해 daily_summary 에 넣는다 (180일치)
// ④ 회원 100,000명에게 이메일을 발송한다. 실패한 건은 건너뛰고 계속 진행한다
// ⑤ 임시 테이블 tmp_settlement 를 비운다
// ⑥ orders 10만 건의 status 를 'PENDING' → 'EXPIRED' 로 일괄 변경한다
//
// ========================================================================
static final String ANSWER_5_1 = "여기에 작성: ";
static final String ANSWER_5_2 = "여기에 작성: ";
static final String ANSWER_5_3 = "여기에 작성: ";
static final String ANSWER_5_4 = "여기에 작성: ";
static final String ANSWER_5_5 = "여기에 작성: ";
static final String ANSWER_5_6 = "여기에 작성: ";
// ========================================================================
// 문제 6. 단일 SQL 집계 Tasklet
// ------------------------------------------------------------------------
// orders 를 일자별로 집계해 daily_summary 에 넣는 Tasklet 을 구현하세요.
//
// 먼저 테이블을 만듭니다:
// CREATE TABLE IF NOT EXISTS daily_summary (
// order_date DATE NOT NULL PRIMARY KEY,
// order_count INT NOT NULL,
// total_amount DECIMAL(14,2) NOT NULL,
// completed_count INT NOT NULL
// ) ENGINE=InnoDB;
//
// 요구사항:
// - 실행 전에 daily_summary 를 비운다 (몇 번을 돌려도 결과가 같아야 함 = 멱등)
// - orders 전체를 DATE(ordered_at) 으로 GROUP BY 해서 한 번에 INSERT
// - INSERT 된 행 수를 StepContribution 에 보고
// - 반복 없이 FINISHED
//
// 예상 결과: 180일치이므로 WRITE_COUNT = 180
// ========================================================================
public static class Ex6Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Ex6Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
// 여기에 작성: daily_summary 비우기
// 여기에 작성: INSERT ... SELECT ... GROUP BY 로 집계
// 여기에 작성: 건수 보고 후 FINISHED 반환
return RepeatStatus.FINISHED;
}
}
// ========================================================================
// Step / Job 배선 (문제 코드를 실행하기 위한 설정 — 수정할 필요 없습니다)
// ========================================================================
@Configuration
public static class ExerciseConfig {
@Bean
public Step ex2Step(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("ex2Step", jobRepository)
.tasklet(new Ex2Tasklet(), txManager).build();
}
@Bean
public Job ex2Job(JobRepository jobRepository, Step ex2Step) {
return new JobBuilder("ex2Job", jobRepository).start(ex2Step).build();
}
@Bean
public Step ex4Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("ex4Step", jobRepository)
.tasklet(new Ex4Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job ex4Job(JobRepository jobRepository, Step ex4Step) {
return new JobBuilder("ex4Job", jobRepository).start(ex4Step).build();
}
@Bean
public Step ex6Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("ex6Step", jobRepository)
.tasklet(new Ex6Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job ex6Job(JobRepository jobRepository, Step ex6Step) {
return new JobBuilder("ex6Job", jobRepository).start(ex6Step).build();
}
}
}
Solution.java
6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 함께 들어 있습니다. 풀어 본 뒤에 여세요.
- 정답 1 의 최소 수정은
return RepeatStatus.CONTINUABLE; 을 return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE; 로 바꾸는 한 줄입니다. 여기에 더해 "왜 SELECT COUNT(*) > 0 으로 판정하면 안 되는가" — FK 나 트리거로 지워지지 않는 행이 하나만 있어도 영원히 도는 시나리오 — 를 설명합니다.
- 정답 2 는
COMMIT_COUNT=3, WRITE_COUNT=1500(500×3), Creating new transaction 3줄, Committing JDBC transaction 3줄입니다. 반복·트랜잭션·커밋카운트가 1:1:1 로 대응한다는 것이 이 문제의 요지입니다.
- 정답 3 은 리팩터링 코드와 함께, 인스턴스 필드가 위험한 두 가지 이유를 구분해 설명합니다. ① Bean 이 싱글턴이라 같은 JVM 내 두 번째 실행에서 값이 이어진다 ② 재시작 시 값이 초기화되어 이미 처리한 구간을 다시 처리한다. 두 번째가 정산 배치에서는 중복 정산으로 이어집니다.
- 정답 4 는
contribution.incrementWriteCount(deleted) 한 줄을 추가하는 것이며, 서술 부분에서 "데이터는 맞는데 지표가 0" 인 상황의 실제 피해를 셋으로 정리합니다. ① 진짜 0건 장애와 구분 불가 ② SLA·처리량 리포트가 무의미 ③ "일 안 하는 배치" 로 오인되어 삭제 후보가 됨.
- 정답 5 의 분류에서 갈리는 항목은 "10만 건의
status 를 일괄 UPDATE" 입니다. 단일 UPDATE 로 되니 Tasklet 이 정답이지만, 락 시간이 길어지므로 CONTINUABLE + LIMIT 로 쪼개는 Tasklet 이어야 한다는 단서를 답니다. 청크로 만드는 것은 오버엔지니어링입니다.
- 정답 6 은
INSERT INTO daily_summary ... SELECT ... GROUP BY DATE(ordered_at) 단일 SQL 이며, 180일치가 한 번에 들어가 WRITE_COUNT=180 이 됩니다. 같은 일을 청크로 만들면 Reader 로 100,000건을 읽어 Processor 에서 집계해야 하는데, 집계는 스트리밍으로 안 되므로 결국 전부 메모리에 올려야 한다는 것 — 즉 청크의 최대 장점(메모리 상한)이 무너진다는 점을 설명합니다. 이 스텝의 "SQL 한 줄로 되는가?" 판단 기준을 코드로 확인하는 문제입니다.
package com.example.batch.step04;
/*
* ============================================================================
* Step 04 — 연습문제 정답 및 해설
* ============================================================================
* 문제를 직접 풀어 본 뒤에 여세요.
* ============================================================================
*/
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
public class Solution {
// ========================================================================
// 정답 1. 무한루프 찾아 고치기
// ========================================================================
/*
* (a) 왜 무한루프인가
*
* 반환값이 항상 RepeatStatus.CONTINUABLE 입니다. 종료 조건이 아예 없습니다.
* REFUNDED 주문에 해당하는 정산 행(10,000건 중 실제로는 0건 — settlement 에는
* COMPLETED 만 들어 있으므로)을 다 지운 뒤에도, deleted=0 을 찍으며 영원히 돕니다.
*
* 여기서 무서운 것은 "느려지지도 않는다" 는 점입니다.
* 매 반복이 DELETE 한 번 + 커밋 한 번이라 초당 수천 회 돌면서
* 로그를 쏟아내고 CPU 를 먹고 커넥션을 계속 빌렸다 돌려줍니다.
*
* 그리고 죽인 뒤가 더 문제입니다:
* BATCH_JOB_EXECUTION.STATUS = 'STARTED', END_TIME = NULL 로 남습니다.
* 재실행하면 JobExecutionAlreadyRunningException 이 나서 손으로 UPDATE 해야 합니다.
*
* (b) 최소 수정 — 한 줄
*/
public static class Sol1Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Sol1Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
int deleted = jdbcTemplate.update("""
DELETE s FROM settlement s
JOIN orders o ON o.order_id = s.order_id
WHERE o.status = 'REFUNDED'
LIMIT 1000
""");
contribution.incrementWriteCount(deleted);
System.out.println("[sol1] deleted=" + deleted);
// ★ 이 한 줄이 정답입니다.
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
}
}
/*
* (c) "SELECT COUNT(*) 로 세서 0이면 종료" 가 왜 나쁜 답인가
*
* 이렇게 고쳤다고 해 봅시다:
*
* Integer remaining = jdbcTemplate.queryForObject(
* "SELECT COUNT(*) FROM settlement s JOIN orders o ON ... WHERE o.status='REFUNDED'",
* Integer.class);
* return remaining > 0 ? RepeatStatus.CONTINUABLE : RepeatStatus.FINISHED;
*
* 겉보기에는 멀쩡합니다. 그런데 "남아 있지만 지워지지 않는 행" 이 하나라도 있으면
* remaining 은 영원히 1 이고, DELETE 는 영원히 0건이고, 루프는 영원히 돕니다.
* 그런 행이 생기는 경우:
* - 외래키 제약으로 삭제가 막힌 행
* - BEFORE DELETE 트리거가 조건부로 삭제를 막는 경우
* - DELETE 의 조인 조건과 COUNT 의 조인 조건이 미묘하게 다른 경우 (가장 흔함)
*
* 원칙:
* 종료 판정은 "아직 남았는가" 가 아니라 "이번에 진전이 있었는가" 로 합니다.
* 진전이 없으면 다음 반복도 진전이 없습니다. 그러면 멈춰야 합니다.
*
* 부가 효과로 쿼리도 하나 줄어듭니다. DELETE 는 이미 영향 행 수를 돌려주므로
* COUNT 를 따로 칠 이유가 없습니다.
*/
static final String ANSWER_1_A = "항상 CONTINUABLE 을 반환해 종료 조건이 없음";
static final String ANSWER_1_C = "지워지지 않는 행이 하나라도 있으면 COUNT 는 계속 >0 이라 여전히 무한루프";
// ========================================================================
// 정답 2. 반복 · 트랜잭션 · 카운트의 대응 관계
// ========================================================================
/*
* (a) COMMIT_COUNT = 3
* (b) WRITE_COUNT = 1500 (500 × 3)
* (c) "Creating new transaction" = 3줄
*
* 실제 확인 결과:
*
* +---------+-----------+--------------+------------+-------------+----------------+
* | STEP_NAME | STATUS | COMMIT_COUNT | READ_COUNT | WRITE_COUNT | ROLLBACK_COUNT |
* +---------+-----------+--------------+------------+-------------+----------------+
* | ex2Step | COMPLETED | 3 | 0 | 1500 | 0 |
* +---------+-----------+--------------+------------+-------------+----------------+
*
* 이 문제의 요지는 다음 셋이 정확히 1:1:1 로 대응한다는 것입니다.
*
* Tasklet.execute() 호출 횟수 = 트랜잭션 수 = COMMIT_COUNT
*
* 이유는 TaskletStep 의 구조에 있습니다.
*
* stepOperations.iterate(() -> { ← RepeatTemplate 의 반복
* transactionTemplate.execute(status -> { ← 트랜잭션이 반복 "안쪽" 에 있다
* RepeatStatus rs = tasklet.execute(contribution, chunkContext);
* jobRepository.update(stepExecution);
* return rs;
* });
* });
*
* transactionTemplate.execute 가 루프 안쪽이므로 반복 = 트랜잭션입니다.
* 만약 바깥쪽이었다면 CONTINUABLE 반복 전체가 하나의 거대한 트랜잭션이 되었을 것이고,
* "대량 작업을 잘게 커밋한다" 는 CONTINUABLE 의 존재 이유가 사라졌을 것입니다.
*
* WRITE_COUNT 가 1500 인 것도 같은 구조 때문입니다.
* StepContribution 은 트랜잭션 단위의 임시 집계기이고, 커밋 시점에 StepExecution 으로
* 합산됩니다. 3번 커밋되며 500씩 세 번 더해져 1500 이 됩니다.
*/
static final String ANSWER_2_A = "3";
static final String ANSWER_2_B = "1500";
static final String ANSWER_2_C = "3";
// ========================================================================
// 정답 3. 인스턴스 필드 → ExecutionContext
// ========================================================================
public static class Sol3Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
// private int processed = 0; ← 삭제
public Sol3Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
ExecutionContext ctx = chunkContext.getStepContext()
.getStepExecution().getExecutionContext();
int deleted = jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = '2025-01-01' LIMIT 100");
int processed = ctx.getInt("processed", 0) + deleted;
ctx.putInt("processed", processed);
contribution.incrementWriteCount(deleted);
System.out.println("[sol3] 누적 " + processed);
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
}
}
/*
* 인스턴스 필드가 위험한 이유 — 두 가지로 나뉩니다.
*
* ① 같은 JVM 안에서: Tasklet Bean 은 기본적으로 싱글턴입니다.
*
* 한 애플리케이션 컨텍스트에서 같은 Job 을 두 번 실행하면 두 번째 실행이
* 첫 번째 실행이 남긴 값을 이어받습니다.
* 문제 3 의 Ex3Tasklet 이라면 두 번째 실행의 "누적" 로그가 0 이 아니라
* 이전 값부터 시작합니다. 로그만 이상해지는 게 아니라, 만약 그 값으로
* 종료를 판정했다면 두 번째 실행이 즉시 끝나 버립니다.
*
* bootRun 은 매번 새 JVM 이라 잘 안 보입니다. 그래서 로컬에서는 멀쩡하다가
* 통합 테스트(@SpringBatchTest 로 한 컨텍스트에서 여러 번 실행)나
* 상주형 스케줄러(Quartz, Step 14)에서 처음 드러납니다. 발견이 늦습니다.
*
* ② 재시작에서: 필드는 재시작하면 0으로 초기화됩니다.
*
* ExecutionContext 는 BATCH_STEP_EXECUTION_CONTEXT 에 직렬화되어 저장되므로
* 재시작 시 복원됩니다. 필드는 그렇지 않습니다.
*
* 정산 배치에서 이것이 무슨 뜻인지 구체적으로 보면:
* "마지막으로 처리한 order_id" 를 필드에 들고 있다가 3만 건째에서 죽었다고 합시다.
* 재시작하면 필드가 0이 되어 1번 주문부터 다시 처리합니다.
* settlement 에 UNIQUE 키가 없다면 앞의 3만 건이 통째로 중복 정산됩니다.
* 예외도 없고, STATUS 는 COMPLETED 입니다.
*
* "재시작 가능한 배치" 를 만들고 싶다면 반복 상태는 반드시 ExecutionContext 입니다.
*
* 참고: ExecutionContext 는 두 종류입니다.
* - Step 범위: stepExecution.getExecutionContext() ← 이 문제의 정답
* - Job 범위: stepExecution.getJobExecution().getExecutionContext() ← Step 간 값 공유용
* Step 안의 반복 상태는 Step 범위가 맞습니다. Step 09 에서 자세히 다룹니다.
*/
static final String ANSWER_3 =
"① 싱글턴이라 같은 JVM 두 번째 실행에서 값이 이어짐 ② 재시작 시 초기화되어 처리 구간을 다시 처리함";
// ========================================================================
// 정답 4. 조용한 버그 — WRITE_COUNT 가 0
// ========================================================================
public static class Sol4Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Sol4Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
int deleted = jdbcTemplate.update(
"DELETE FROM settlement WHERE settle_date = '2025-01-02' LIMIT 1000");
contribution.incrementWriteCount(deleted); // ★ (a) 정답은 이 한 줄
System.out.println("[sol4] deleted=" + deleted);
return deleted == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
}
}
/*
* (b) "데이터는 맞는데 지표가 0" 이 낳는 결과
*
* 이것이 이 코스가 말하는 "에러 없이 조용히 틀리는" 의 한 형태입니다.
* 코드가 틀린 것도 아니고 데이터가 틀린 것도 아닙니다. 틀린 것은 "관측" 입니다.
* 그런데 배치 운영은 전적으로 관측에 의존합니다.
*
* 피해 세 가지:
*
* ① 진짜 장애와 구분할 수 없다.
* 어느 날 파라미터 실수로 정말 0건이 처리되었다고 합시다.
* 대시보드는 어제도 0, 오늘도 0 입니다. 아무도 이상하다고 느끼지 못합니다.
* "0건이 정상인 배치" 로 학습된 모니터링은 진짜 0건을 잡아내지 못합니다.
*
* ② 처리량 기반 알림·SLA 가 전부 무의미해진다.
* "WRITE_COUNT 가 평소의 50% 미만이면 알림" 같은 규칙을 걸 수 없습니다.
* Spring Batch 의 메타데이터를 그대로 지표로 쓰는 도구(Spring Batch Admin,
* Micrometer 의 spring.batch.item.write, Step 14 의 Prometheus 노출)가
* 전부 이 컬럼을 봅니다.
*
* ③ "일 안 하는 배치" 로 오인되어 정리 대상이 된다.
* 배치 목록을 정리할 때 "처리 건수 0인 Job" 은 1순위 삭제 후보입니다.
* 실제로는 매일 7만 건을 지우고 있는 배치가 그렇게 사라질 수 있습니다.
*
* 청크 Step 은 프레임워크가 read/write/filter 를 자동으로 세어 주기 때문에
* 이 문제가 없습니다. Tasklet 은 "무엇을 처리로 볼지" 를 프레임워크가 알 수 없으므로
* 개발자가 직접 보고해야 합니다. Tasklet 의 가장 흔한 실수가 이것입니다.
*/
static final String ANSWER_4_B =
"진짜 0건 장애와 구분 불가 / 처리량 기반 알림·SLA 무력화 / 일 안 하는 배치로 오인되어 삭제 후보가 됨";
// ========================================================================
// 정답 5. Tasklet 인가 청크인가
// ========================================================================
/*
* ① CSV 파일 이동 + gzip 압축 ................................ Tasklet
* 항목 단위 반복이 아니라 단발성 작업입니다. 읽을 "아이템" 자체가 없습니다.
* SystemCommandTasklet 이나 java.nio.file.Files 로 처리합니다.
* (setTimeout 을 잊지 마세요 — 4-8)
*
* ② orders 70,000건 → 등급별 수수료 계산 → settlement ......... 청크
* 항목마다 계산이 다릅니다(BRONZE 3.5% / SILVER 3.0% / GOLD 2.5% / VIP 2.0%).
* Processor 가 항목 단위로 개입해야 하고, 재시작 지점도 필요합니다.
* 청크 1,000 이면 정확히 70청크입니다. Step 05~08 에서 이걸 만듭니다.
*
* ※ 솔직히 말하면 이것도 INSERT ... SELECT JOIN 한 줄로 됩니다(4-0 에서 그렇게 했습니다).
* 청크로 만드는 이유는 "항목마다 외부 API 호출" 이나 "항목 단위 스킵" 같은
* 요구가 붙을 때를 대비한 구조를 배우기 위해서입니다.
*
* ③ 일자별 집계 → daily_summary (180일치) ................... Tasklet
* GROUP BY 한 방입니다. 집계는 DB 가 압도적으로 잘합니다. 문제 6 이 이것입니다.
*
* ④ 회원 100,000명 이메일 발송, 실패는 건너뛰기 .............. 청크
* "실패한 건은 건너뛰고 계속" 이 결정적입니다.
* .faultTolerant().skip(MailException.class).skipLimit(100) 이 필요하고,
* 이건 항목 단위로만 동작합니다(Step 11).
* 또한 외부 시스템 호출이라 SQL 로는 애초에 불가능합니다.
*
* ⑤ 임시 테이블 TRUNCATE .................................... Tasklet
* SQL 한 줄. 논쟁의 여지가 없습니다.
* 준비 Step 이므로 .allowStartIfComplete(true) 를 붙이는 것도 함께 고려하세요(3-5).
*
* ⑥ orders 10만 건 status 일괄 변경 ......................... Tasklet (단, 쪼개서)
* ★ 답이 갈리는 항목입니다.
*
* "10만 건이니까 청크" 라고 답하기 쉽습니다. 틀렸습니다.
* UPDATE orders SET status='EXPIRED' WHERE status='PENDING' 한 줄이면 됩니다.
* 청크로 만들면 10만 건을 읽어서(네트워크 왕복) 자바 객체로 만들고(GC)
* 다시 10만 건을 쓰는(네트워크 왕복) 셈이라, DB 안에서 끝날 일을
* 굳이 애플리케이션까지 왕복시키는 낭비입니다.
*
* 다만 "그냥 Tasklet" 도 정답이 아닙니다.
* UPDATE 한 방으로 10만 행에 락을 걸면 그동안 서비스 쿼리가 대기합니다.
* 운이 나쁘면 Lock wait timeout exceeded 로 서비스가 죽습니다.
*
* 정답은 CONTINUABLE + LIMIT 로 쪼개는 Tasklet 입니다:
*
* int updated = jdbcTemplate.update(
* "UPDATE orders SET status='EXPIRED' WHERE status='PENDING' LIMIT 5000");
* contribution.incrementWriteCount(updated);
* return updated == 0 ? RepeatStatus.FINISHED : RepeatStatus.CONTINUABLE;
*
* 20회 반복 = 20번 커밋. 락은 매번 짧게 잡혔다 풀립니다.
* "대량 DML 은 Tasklet 으로, 단 잘게 나눠서" 가 이 스텝의 결론 중 하나입니다.
*/
static final String ANSWER_5_1 = "Tasklet — 단발성 파일 작업, 아이템이 없음";
static final String ANSWER_5_2 = "청크 — 항목별 계산·재시작 지점 필요";
static final String ANSWER_5_3 = "Tasklet — GROUP BY 단일 SQL";
static final String ANSWER_5_4 = "청크 — 항목 단위 스킵 필요, 외부 시스템 호출";
static final String ANSWER_5_5 = "Tasklet — SQL 한 줄";
static final String ANSWER_5_6 = "Tasklet, 단 CONTINUABLE + LIMIT 로 쪼개서 락 시간을 줄일 것";
// ========================================================================
// 정답 6. 단일 SQL 집계 Tasklet
// ========================================================================
public static class Sol6Tasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
public Sol6Tasklet(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
// 멱등성 확보 — 몇 번을 돌려도 결과가 같아야 합니다.
// 이 한 줄 덕분에 이 Job 은 RunIdIncrementer 를 붙여도 안전합니다(3-5 참고).
jdbcTemplate.update("DELETE FROM daily_summary");
int inserted = jdbcTemplate.update("""
INSERT INTO daily_summary (order_date, order_count, total_amount, completed_count)
SELECT DATE(ordered_at) AS order_date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed_count
FROM orders
GROUP BY DATE(ordered_at)
""");
contribution.incrementWriteCount(inserted);
System.out.printf("[sol6] daily_summary %d행 생성%n", inserted);
return RepeatStatus.FINISHED;
}
}
/*
* 실행 결과
*
* INFO ... SimpleStepHandler : Executing step: [ex6Step]
* [sol6] daily_summary 180행 생성
* INFO ... AbstractStep : Step: [ex6Step] executed in 312ms
*
* +-----------+-----------+--------------+-------------+
* | STEP_NAME | STATUS | COMMIT_COUNT | WRITE_COUNT |
* +-----------+-----------+--------------+-------------+
* | ex6Step | COMPLETED | 1 | 180 |
* +-----------+-----------+--------------+-------------+
*
* mysql> SELECT * FROM daily_summary ORDER BY order_date LIMIT 3;
* +------------+-------------+--------------+-----------------+
* | order_date | order_count | total_amount | completed_count |
* +------------+-------------+--------------+-----------------+
* | 2025-01-01 | 555 | 27612500.00 | 389 |
* | 2025-01-02 | 556 | 27689000.00 | 389 |
* | 2025-01-03 | 555 | 27544500.00 | 389 |
* +------------+-------------+--------------+-----------------+
*
* 180일 × 약 555건 ≈ 100,000건. 프로젝트 셋업의 숫자와 맞습니다.
*
* ── 왜 이걸 청크로 만들면 안 되는가 ─────────────────────────────────────
*
* 같은 일을 청크로 만든다고 상상해 봅시다.
*
* Reader: orders 100,000건을 페이징으로 읽는다
* Processor: 일자별로 집계한다 ← ★ 여기서 무너집니다
* Writer: daily_summary 에 쓴다
*
* Processor 는 항목 하나를 받아 항목 하나를 돌려주는 인터페이스입니다.
* 집계는 "여러 항목을 하나로 접는" 연산이라 이 모델에 맞지 않습니다.
* 억지로 하려면 Processor 나 Writer 안에 Map<LocalDate, Summary> 를 들고
* 100,000건을 전부 누적한 뒤 마지막에 한꺼번에 써야 합니다.
*
* 그 순간 청크의 최대 장점인 "메모리 상한" 이 무너집니다.
* 청크 크기를 1,000 으로 잡아도 누적 Map 은 계속 커지므로 메모리는
* 데이터 전체 크기에 비례합니다. 청크를 쓰는 의미가 사라진 것입니다.
* (게다가 청크 경계에서 커밋되므로 중간에 실패하면 반쯤 집계된 상태가 남습니다.)
*
* DB 는 GROUP BY 를 정렬이나 해시로 스트리밍 처리하도록 수십 년간 최적화되어 있습니다.
* 애플리케이션이 그것보다 잘할 이유가 없습니다.
*
* 이 스텝의 판단 기준을 다시 적습니다:
*
* "SQL 한 줄로 되는가?" 를 먼저 물어라. 되면 Tasklet 이다.
*
* 청크는 강력하지만 공짜가 아닙니다. Reader/Processor/Writer 세 클래스,
* 재시작 상태 관리, 커밋 간격 튜닝이 따라옵니다.
* 그 비용을 낼 이유가 있을 때만 내는 것이 좋은 설계입니다.
*/
// ========================================================================
// Step / Job 배선
// ========================================================================
@Configuration
public static class SolutionConfig {
@Bean
public Step sol1Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("sol1Step", jobRepository)
.tasklet(new Sol1Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job sol1Job(JobRepository jobRepository, Step sol1Step) {
return new JobBuilder("sol1Job", jobRepository).start(sol1Step).build();
}
@Bean
public Step sol3Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("sol3Step", jobRepository)
.tasklet(new Sol3Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job sol3Job(JobRepository jobRepository, Step sol3Step) {
return new JobBuilder("sol3Job", jobRepository).start(sol3Step).build();
}
@Bean
public Step sol4Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("sol4Step", jobRepository)
.tasklet(new Sol4Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job sol4Job(JobRepository jobRepository, Step sol4Step) {
return new JobBuilder("sol4Job", jobRepository).start(sol4Step).build();
}
@Bean
public Step sol6Step(JobRepository jobRepository, PlatformTransactionManager txManager,
JdbcTemplate jdbcTemplate) {
return new StepBuilder("sol6Step", jobRepository)
.tasklet(new Sol6Tasklet(jdbcTemplate), txManager).build();
}
@Bean
public Job sol6Job(JobRepository jobRepository, Step sol6Step) {
return new JobBuilder("sol6Job", jobRepository).start(sol6Step).build();
}
}
/*
* ── 문제 6 실행 전 DDL ──────────────────────────────────────────────────
* CREATE TABLE IF NOT EXISTS daily_summary (
* order_date DATE NOT NULL PRIMARY KEY,
* order_count INT NOT NULL,
* total_amount DECIMAL(14,2) NOT NULL,
* completed_count INT NOT NULL
* ) ENGINE=InnoDB;
*/
}