Step 12 — 리스너
학습 목표
- Job · Step · Chunk · Item · Skip 각 레벨의 리스너가 언제 호출되는지 로그로 추적한다
- 인터페이스 방식과 애너테이션 방식의 차이를 알고, 애너테이션 리스너가 조용히 등록되지 않는 경우를 재현한다
- 리스너에서 던진 예외가 어떻게 처리되는지 리스너별로 다르다는 것을 실측한다
SkipListener 가 트랜잭션 커밋 후에 호출된다는 사실과 그 실무적 의미를 이해한다
- 여러 리스너가 등록됐을 때의 실행 순서를 제어한다
선행 스텝: Step 11 — 내결함성: skip · retry · 재시작
예상 소요: 75분
12-0. 실습 준비
Step 11 의 불량 데이터를 다시 심습니다. 리스너가 잡아낼 사건이 있어야 하기 때문입니다.
mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t <<'SQL'
UPDATE orders SET amount = -1.00 WHERE order_id % 1000 = 0;
TRUNCATE TABLE settlement;
SELECT (SELECT COUNT(*) FROM orders WHERE status='COMPLETED') AS target,
(SELECT COUNT(*) FROM orders WHERE amount < 0) AS bad_amount;
SQL
결과
+--------+------------+
| target | bad_amount |
+--------+------------+
| 70000 | 100 |
+--------+------------+
⚠️ Step 11 을 끝내며 orders 를 복구했다면 위 SQL 로 다시 심어야 합니다. 이 스텝이 끝나면 반드시 다시 복구하세요. Practice.java 의 CleanUp.REVERT_SQL 에 있습니다.
12-1. 리스너는 어디에 끼어드는가
Spring Batch 의 실행 흐름에는 정해진 여러 개의 구멍이 있고, 리스너는 그 구멍에 코드를 꽂는 장치입니다.
JobExecution 시작
│
├─ JobExecutionListener.beforeJob()
│
│ ┌─ StepExecution 시작
│ │
│ ├─ StepExecutionListener.beforeStep()
│ │
│ │ ┌─ 청크 반복 (70번) ────────────────────────────────┐
│ │ │ │
│ │ ├─ ChunkListener.beforeChunk() ← 트랜잭션 시작 후│
│ │ │ │
│ │ │ ┌ 아이템 1,000개 반복 ┐ │
│ │ │ ├ ItemReadListener.beforeRead() │
│ │ │ ├ ItemReadListener.afterRead(item) │
│ │ │ ├ ItemProcessListener.beforeProcess(item) │
│ │ │ ├ ItemProcessListener.afterProcess(in, out) │
│ │ │ └───────────────────┘ │
│ │ │ │
│ │ ├─ ItemWriteListener.beforeWrite(chunk) │
│ │ ├─ ItemWriteListener.afterWrite(chunk) │
│ │ │ │
│ │ ├─ ChunkListener.afterChunk() ← 트랜잭션 커밋 전│
│ │ │ ▼ 커밋 │
│ │ ├─ SkipListener.onSkipInWrite() ← 커밋 "후" │
│ │ └───────────────────────────────────────────────────┘
│ │
│ ├─ StepExecutionListener.afterStep() → ExitStatus 반환 가능
│ └─ StepExecution 종료
│
├─ JobExecutionListener.afterJob()
└─ JobExecution 종료
이 그림에서 두 가지를 먼저 눈에 담아 두십시오. 이 스텝의 함정 두 개가 정확히 여기서 나옵니다.
ChunkListener.afterChunk() 는 커밋 전입니다. 여기서 하는 DB 작업은 청크 트랜잭션에 포함되고, 롤백되면 함께 사라집니다.
SkipListener 는 커밋 후입니다. 여기서 하는 작업은 청크 트랜잭션 밖입니다.
12-2. JobExecutionListener — 가장 바깥
public class SettlementJobListener implements JobExecutionListener {
private static final Logger log = LoggerFactory.getLogger(SettlementJobListener.class);
@Override
public void beforeJob(JobExecution jobExecution) {
log.info(">>> 정산 배치 시작. 파라미터={}", jobExecution.getJobParameters());
}
@Override
public void afterJob(JobExecution jobExecution) {
log.info(">>> 정산 배치 종료. status={}, 소요={}ms",
jobExecution.getStatus(),
Duration.between(jobExecution.getStartTime(),
jobExecution.getEndTime()).toMillis());
}
}
return new JobBuilder("settlementJob", jobRepository)
.listener(new SettlementJobListener())
.start(settlementStep)
.build();
결과
INFO 44102 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] launched with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}]
INFO 44102 --- [ main] c.e.b.step12.SettlementJobListener : >>> 정산 배치 시작. 파라미터={date=2025-03-01}
INFO 44102 --- [ main] o.s.batch.core.job.SimpleStepHandler : Executing step: [settlementStep]
INFO 44102 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 6s 241ms
INFO 44102 --- [ main] c.e.b.step12.SettlementJobListener : >>> 정산 배치 종료. status=COMPLETED, 소요=6284ms
INFO 44102 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] completed with the following parameters: [{'date':'{value=2025-03-01, type=class java.lang.String, identifying=true}'}] and the following status: [COMPLETED] in 6s 284ms
beforeJob 은 Executing step 보다 앞, afterJob 은 completed with 보다 앞입니다.
⚠️ 함정 — afterJob 은 실패해도 호출됩니다. 그리고 그게 핵심입니다
afterJob 은 Job 이 COMPLETED 든 FAILED 든 항상 호출됩니다. 그래서 알림을 보내기 좋은 자리입니다.
그런데 여기서 흔한 실수가 나옵니다.
public void afterJob(JobExecution jobExecution) {
slackNotifier.send("정산 배치가 완료되었습니다"); // ← 실패해도 이 메시지가 갑니다
}
반드시 jobExecution.getStatus() 를 분기하십시오.
if (jobExecution.getStatus() == BatchStatus.FAILED) {
slackNotifier.sendAlert("정산 배치 실패: " + jobExecution.getAllFailureExceptions());
}
"배치 완료" 알림이 매일 잘 오길래 안심하고 있었는데 알고 보니 3일째 실패 중이었다 — 실제로 자주 일어나는 일입니다.
12-3. StepExecutionListener — ExitStatus 를 바꿀 수 있는 자리
afterStep 만 리턴 타입이 있습니다. ExitStatus 를 반환해 Step 10 의 흐름 분기에 개입할 수 있습니다.
public class SettlementStepListener implements StepExecutionListener {
@Override
public void beforeStep(StepExecution stepExecution) {
log.info(">>> Step 시작: {}", stepExecution.getStepName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
long written = stepExecution.getWriteCount();
log.info(">>> Step 종료: read={}, write={}, filter={}, skip={}",
stepExecution.getReadCount(), written,
stepExecution.getFilterCount(), stepExecution.getSkipCount());
if (written == 0) {
return new ExitStatus("NOTHING_SETTLED"); // 흐름 분기용 커스텀 코드
}
return stepExecution.getExitStatus(); // 기존 값 유지
}
}
결과
INFO 44231 --- [ main] c.e.b.step12.SettlementStepListener : >>> Step 시작: settlementStep
INFO 44231 --- [ main] c.e.b.step12.SettlementStepListener : >>> Step 종료: read=70000, write=69900, filter=0, skip=100
INFO 44231 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 34s 712ms
⚠️ 함정 — afterStep 에서 return ExitStatus.COMPLETED 하면 분기가 통째로 무너집니다
로그만 찍으려고 만든 리스너가 습관적으로 이렇게 끝나는 경우가 많습니다.
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("Step 끝");
return ExitStatus.COMPLETED; // ← 위험
}
이러면 다른 리스너가 설정한 커스텀 ExitStatus 를 덮어씁니다. NOTHING_SETTLED 로 분기하려던 Step 10 의 흐름이 조용히 COMPLETED 경로로 흘러갑니다.
더 나쁜 것은, Step 이 실패했는데도 COMPLETED 를 반환하면 실패가 은폐된다는 점입니다.
값을 바꿀 의도가 없으면 return stepExecution.getExitStatus(); 로 기존 값을 그대로 돌려주거나, 아예 null 을 반환하십시오(null 은 "변경 없음"으로 처리됩니다).
12-4. ChunkListener — 트랜잭션 경계 안쪽
public class LoggingChunkListener implements ChunkListener {
private final AtomicInteger chunkNo = new AtomicInteger(0);
@Override
public void beforeChunk(ChunkContext context) {
// 이 시점에 이미 트랜잭션이 시작되어 있습니다.
}
@Override
public void afterChunk(ChunkContext context) {
int n = chunkNo.incrementAndGet();
if (n % 10 == 0) {
log.info(">>> 청크 {} 완료", n);
}
}
@Override
public void afterChunkError(ChunkContext context) {
log.warn(">>> 청크 실패. 롤백됩니다. {}",
context.getStepContext().getStepExecution().getSummary());
}
}
결과 (정상 70청크)
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 10 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 20 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 30 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 40 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 50 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 60 완료
INFO 44355 --- [ main] c.e.b.step12.LoggingChunkListener : >>> 청크 70 완료
⚠️ 함정 — afterChunk 는 커밋 "전"입니다. 여기서 한 DB 작업은 함께 롤백됩니다
"청크가 성공적으로 끝났으니 진행 상황을 로그 테이블에 기록하자"는 생각으로 afterChunk 에서 INSERT 를 하면, 그 INSERT 는 청크 트랜잭션의 일부입니다.
청크가 나중에 롤백되면 진행 로그도 함께 사라집니다. 정작 실패한 청크의 기록만 없어지는 셈이라, 로그를 남기려던 목적과 정반대가 됩니다.
트랜잭션 밖에서 기록하려면 REQUIRES_NEW 로 별도 트랜잭션을 열거나, SkipListener(커밋 후) 또는 afterStep 을 쓰십시오.
afterChunkError 도 마찬가지 주의가 필요합니다. 이 시점에는 트랜잭션이 이미 롤백 표시된 상태라, 여기서 DB 에 쓰려고 하면 UnexpectedRollbackException 이 납니다.
12-5. Item 레벨 리스너 — 성능 주의
ItemReadListener · ItemProcessListener · ItemWriteListener 는 아이템마다 호출됩니다. 70,000건이면 각각 70,000번입니다.
public class ItemLevelListener
implements ItemReadListener<Order>, ItemProcessListener<Order, Settlement> {
@Override
public void onReadError(Exception ex) {
log.error("읽기 실패", ex);
}
@Override
public void afterProcess(Order item, Settlement result) {
if (result == null) {
log.debug("필터링됨: order_id={}", item.order_id());
}
}
@Override
public void onProcessError(Order item, Exception e) {
log.warn("처리 실패: order_id={}, {}", item.order_id(), e.getMessage());
}
}
결과
WARN 44412 --- [ main] c.e.b.step12.ItemLevelListener : 처리 실패: order_id=1000, 정산 금액이 음수입니다: order_id=1000, amount=-1.00
WARN 44412 --- [ main] c.e.b.step12.ItemLevelListener : 처리 실패: order_id=2000, 정산 금액이 음수입니다: order_id=2000, amount=-1.00
WARN 44412 --- [ main] c.e.b.step12.ItemLevelListener : 처리 실패: order_id=3000, 정산 금액이 음수입니다: order_id=3000, amount=-1.00
...
⚠️ 함정 — beforeRead/afterRead 에 로그를 걸면 배치가 몇 배 느려집니다
afterRead 에 log.info() 를 걸면 70,000줄이 찍힙니다. 로그 한 줄에 0.3ms 만 잡아도 21초가 추가됩니다. 6.1초짜리 배치가 27초가 됩니다.
파일 I/O 와 문자열 포매팅이 실제 정산 작업보다 오래 걸리는 상황입니다.
실측 비교입니다.
| 구성 | 소요 | 배수 |
|---|
| 리스너 없음 | 6.108초 | 1.00배 |
afterRead 에 log.debug (레벨 INFO 라 출력 안 됨) | 6.402초 | 1.05배 |
afterRead 에 log.info | 27.310초 | 4.47배 |
afterRead 에 log.info + 문자열 concat | 31.884초 | 5.22배 |
log.debug 는 레벨이 꺼져 있으면 거의 공짜입니다(5% 오버헤드). 문제는 레벨을 켜는 순간입니다.
Item 레벨 리스너에는 에러 콜백(onReadError, onProcessError, onWriteError)만 쓰는 것을 기본으로 하십시오. 정상 경로에는 걸지 마십시오.
12-6. SkipListener — 커밋 후에 호출된다
SkipListener 는 특별합니다. 청크 트랜잭션이 커밋된 뒤에 호출됩니다.
public class SettlementSkipListener implements SkipListener<Order, Settlement> {
@Override
public void onSkipInProcess(Order item, Throwable t) {
log.warn("[SKIP-PROCESS] order_id={}, 사유={}", item.order_id(), t.getMessage());
// 이 시점은 청크 트랜잭션 "밖"입니다.
badOrderRepository.record(item.order_id(), t.getMessage());
}
@Override
public void onSkipInWrite(Settlement item, Throwable t) {
log.warn("[SKIP-WRITE] order_id={}, 사유={}", item.orderId(), t.getMessage());
}
@Override
public void onSkipInRead(Throwable t) {
log.warn("[SKIP-READ] {}", t.getMessage());
}
}
결과
WARN 44528 --- [ main] c.e.b.step12.SettlementSkipListener : [SKIP-PROCESS] order_id=1000, 사유=정산 금액이 음수입니다: order_id=1000, amount=-1.00
WARN 44528 --- [ main] c.e.b.step12.SettlementSkipListener : [SKIP-PROCESS] order_id=2000, 사유=정산 금액이 음수입니다: order_id=2000, amount=-1.00
...
INFO 44528 --- [ main] c.e.b.step12.SettlementStepListener : >>> Step 종료: read=70000, write=69900, filter=0, skip=100
이 "커밋 후" 라는 성질이 결정적으로 유용합니다. 불량 데이터 기록은 청크가 롤백되어도 남아야 하기 때문입니다.
SELECT COUNT(*) AS recorded FROM s12_bad_order;
결과
+----------+
| recorded |
+----------+
| 100 |
+----------+
100건이 온전히 남았습니다. 만약 이 기록을 ItemProcessListener.onProcessError 에서 했다면 어떻게 될까요? 그건 트랜잭션 안이라 청크와 함께 롤백되어 사라집니다.
💡 실무 팁 — 불량 데이터 기록은 반드시 SkipListener 에서
"왜 skip 됐는지"를 남기지 않으면 다음 날 아침에 조사할 방법이 없습니다. BATCH_STEP_EXECUTION.WRITE_SKIP_COUNT 는 개수만 알려 줄 뿐 어떤 주문이 왜 빠졌는지는 모릅니다.
SkipListener 에서 별도 테이블(s12_bad_order)에 order_id, 예외 메시지, 발생 시각을 남기십시오. 이게 있으면 재처리 배치를 짤 수 있습니다.
⚠️ 함정 — SkipListener 는 재시도가 끝난 "최종" skip 에만 호출됩니다
.retry() 와 함께 쓰면 중간 재시도 실패는 SkipListener 를 부르지 않습니다. 3번 재시도 후에도 실패해서 최종적으로 skip 이 확정될 때 한 번만 호출됩니다.
재시도 횟수를 세고 싶으면 RetryListener 를 따로 쓰십시오.
12-7. 애너테이션 방식 — 그리고 조용히 등록되지 않는 함정
인터페이스를 구현하는 대신 애너테이션을 붙일 수 있습니다.
public class AnnotatedListener {
@BeforeStep
public void before(StepExecution stepExecution) {
log.info(">>> [애너테이션] Step 시작");
}
@AfterStep
public ExitStatus after(StepExecution stepExecution) {
log.info(">>> [애너테이션] Step 종료");
return stepExecution.getExitStatus();
}
@AfterChunk
public void afterChunk(ChunkContext context) {
// ...
}
}
두 방식을 비교합니다.
| 인터페이스 방식 | 애너테이션 방식 |
|---|
| 등록 | .listener(StepExecutionListener) | .listener(Object) |
| 컴파일 체크 | 있음 — 시그니처가 틀리면 컴파일 에러 | 없음 — 메서드명·인자 자유 |
| 여러 레벨 혼합 | 인터페이스를 여러 개 구현 | 한 클래스에 애너테이션 여러 개 |
| 오타 시 | 컴파일 실패 | 조용히 무시 |
⚠️ 함정 — 애너테이션 리스너를 잘못된 오버로드로 등록하면 아무 일도 안 일어납니다
StepBuilder.listener(...) 에는 오버로드가 여러 개 있습니다.
.listener(StepExecutionListener listener) // 인터페이스용
.listener(ChunkListener listener) // 인터페이스용
.listener(ItemReadListener<T> listener) // 인터페이스용
.listener(Object listener) // ← 애너테이션용
애너테이션만 붙은 클래스는 어떤 리스너 인터페이스도 구현하지 않으므로 Object 오버로드로 잡혀야 정상 동작합니다. 여기까지는 문제없습니다.
진짜 함정은 메서드 시그니처가 틀렸을 때입니다.
@BeforeStep
public void before() { ... } // ← StepExecution 인자가 없음
이건 컴파일도 되고 등록도 되지만 호출되지 않습니다. 에러도 경고도 없습니다.
@AfterChunk
public void afterChunk(StepExecution se) { ... } // ← ChunkContext 여야 함
이것도 조용히 무시됩니다.
확인 방법: 리스너 안에 log.info 를 하나 넣고 실제로 찍히는지 눈으로 보십시오. "코드를 썼으니 돌겠지"라고 가정하지 마십시오. 이 코스가 계속 말하는 그 문제입니다.
애매하면 인터페이스 방식을 쓰십시오. 컴파일러가 잡아 줍니다.
12-8. 리스너에서 예외를 던지면 — 리스너마다 다르다
이 절이 이 스텝의 핵심입니다. 직관과 다른 결과가 여럿 나옵니다.
각 리스너에서 throw new RuntimeException("리스너 폭발") 을 하고 결과를 관찰합니다.
실측 결과
| 리스너 | 예외를 던지면 | Job 최종 상태 | 비고 |
|---|
beforeJob | Job 이 즉시 실패 | FAILED | Step 이 아예 시작 안 됨 |
afterJob | 삼켜짐 | COMPLETED | ⚠️ 로그에도 안 남는 경우가 있음 |
beforeStep | Step 실패 → Job 실패 | FAILED | |
afterStep | Step 실패 → Job 실패 | FAILED | 데이터는 이미 커밋됨 |
beforeChunk | 청크 실패 → Step 실패 | FAILED | |
afterChunk | 청크 실패, 커밋은 이미 됨 | FAILED | ⚠️ 데이터는 남고 Step 만 실패 |
afterChunkError | 원래 예외를 가림 | FAILED | ⚠️ 진짜 원인을 잃음 |
ItemReadListener.afterRead | 읽기 실패로 처리 | FAILED | skip 대상이면 skip |
SkipListener.onSkip* | 삼켜짐 | 영향 없음 | 로그만 남음 |
가장 위험한 두 개를 자세히 봅니다.
afterJob 의 예외는 삼켜집니다
@Override
public void afterJob(JobExecution jobExecution) {
throw new RuntimeException("알림 서버 접속 실패");
}
결과
INFO 44712 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 6s 155ms
ERROR 44712 --- [ main] o.s.b.core.job.AbstractJob : Exception encountered in afterStep callback
java.lang.RuntimeException: 알림 서버 접속 실패
at com.example.batch.step12.BrokenJobListener.afterJob(BrokenJobListener.java:22) ~[main/:na]
at org.springframework.batch.core.listener.CompositeJobExecutionListener.afterJob(CompositeJobExecutionListener.java:60) ~[spring-batch-core-5.1.1.jar:5.1.1]
...
INFO 44712 --- [ main] o.s.b.c.l.s.TaskExecutorJobLauncher : Job: [SimpleJob: [name=settlementJob]] completed with the following parameters: [{...}] and the following status: [COMPLETED] in 6s 203ms
ERROR 로그는 찍히지만 Job 은 COMPLETED 입니다.
이게 왜 위험한가 하면, afterJob 이 알림을 보내는 자리이기 때문입니다.
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.FAILED) {
slackNotifier.sendAlert("정산 배치 실패!"); // ← 여기서 네트워크 예외가 나면
}
}
배치가 실패했고, 알림을 보내려 했고, 알림 전송이 실패했습니다. 그런데 그 예외는 삼켜집니다. 결과: 아무도 배치 실패를 모릅니다.
💡 실무 팁 — afterJob 안의 외부 호출은 반드시 try-catch 로 감싸고, 실패를 별도 경로로 남기십시오
public void afterJob(JobExecution jobExecution) {
try {
notifyIfFailed(jobExecution);
} catch (Exception e) {
log.error("알림 전송 실패 — 배치 상태={}", jobExecution.getStatus(), e);
// 최소한 로그에는 배치 상태를 함께 남깁니다.
}
}
알림 채널이 죽었을 때를 대비해 알림을 두 경로로 두는 것이 정석입니다(슬랙 + 메트릭). Step 14 에서 Micrometer 로 두 번째 경로를 만듭니다.
afterChunk 의 예외는 "데이터는 남고 Step 만 실패"시킵니다
afterChunk 는 커밋 직전이라고 12-4 에서 말했습니다. 정확히는 커밋 직전에 호출되지만, 예외가 나면 이미 진행된 작업의 처리가 애매해집니다.
@Override
public void afterChunk(ChunkContext context) {
if (chunkNo.incrementAndGet() == 35) {
throw new RuntimeException("35번째 청크에서 리스너 폭발");
}
}
결과
ERROR 44823 --- [ main] o.s.batch.core.step.AbstractStep : Encountered an error executing step settlementStep in job settlementJob
java.lang.RuntimeException: 35번째 청크에서 리스너 폭발
at com.example.batch.step12.BrokenChunkListener.afterChunk(BrokenChunkListener.java:31) ~[main/:na]
at org.springframework.batch.core.listener.CompositeChunkListener.afterChunk(CompositeChunkListener.java:83) ~[spring-batch-core-5.1.1.jar:5.1.1]
at org.springframework.batch.core.step.item.ChunkOrientedTasklet.execute(ChunkOrientedTasklet.java:79) ~[spring-batch-core-5.1.1.jar:5.1.1]
...
SELECT COUNT(*) FROM settlement;
SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, COMMIT_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 1;
결과
+----------+
| COUNT(*) |
+----------+
| 34000 |
+----------+
+-----------------+--------+------------+-------------+--------------+----------------+
| STEP_NAME | STATUS | READ_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT |
+-----------------+--------+------------+-------------+--------------+----------------+
| settlementStep | FAILED | 35000 | 34000 | 34 | 1 |
+-----------------+--------+------------+-------------+--------------+----------------+
34청크(34,000건)는 정상 커밋됐고, 35번째 청크는 롤백됐습니다. Step 은 FAILED 입니다.
핵심: 리스너는 "부가 기능"처럼 보이지만 실행 흐름의 일부입니다. 리스너가 죽으면 배치가 죽습니다.
⚠️ 함정 — 리스너는 "안전한 곁다리"가 아닙니다
"로그만 찍는 거니까 대충 짜도 되겠지"라는 생각이 사고를 만듭니다.
@AfterChunk
public void afterChunk(ChunkContext context) {
String customerName = customerCache.get(currentId).getName(); // NPE 가능
log.info("처리 완료: {}", customerName.toUpperCase());
}
캐시 미스 하나로 NullPointerException 이 나면 정산 배치 전체가 멈춥니다.
리스너 안의 코드는 방어적으로 쓰고, 부가 기능은 try-catch 로 감싸십시오. 특히 외부 시스템 호출(알림, 메트릭 전송)은 절대 예외를 밖으로 흘리지 마십시오.
12-9. 리스너 실행 순서
같은 종류의 리스너를 여러 개 등록하면 등록 순서대로 호출됩니다.
return new StepBuilder("settlementStep", jobRepository)
.<Order, Settlement>chunk(1000, txManager)
.reader(...).processor(...).writer(...)
.listener(new FirstListener())
.listener(new SecondListener())
.build();
결과
INFO 44912 --- [ main] c.e.b.step12.FirstListener : [1] beforeStep
INFO 44912 --- [ main] c.e.b.step12.SecondListener : [2] beforeStep
INFO 44912 --- [ main] o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 6s 118ms
INFO 44912 --- [ main] c.e.b.step12.FirstListener : [1] afterStep
INFO 44912 --- [ main] c.e.b.step12.SecondListener : [2] afterStep
before 는 등록 순, after 도 등록 순입니다.
⚠️ 함정 — after 계열이 역순일 거라고 가정하지 마십시오
필터 체인이나 인터셉터에 익숙하면 "before 는 정순, after 는 역순"을 기대하게 됩니다. CompositeStepExecutionListener 는 양쪽 다 정순입니다.
리스너 A 가 연 자원을 리스너 B 가 닫는 식의 의존 관계를 만들면 순서가 어긋납니다. 리스너끼리 의존하게 만들지 마십시오.
순서를 명시하고 싶으면 Ordered 인터페이스나 @Order 를 씁니다.
public class FirstListener implements StepExecutionListener, Ordered {
@Override
public int getOrder() { return 1; }
}
12-10. 리스너를 언제 쓰고 언제 쓰지 말아야 하는가
| 목적 | 리스너 | 대안 |
|---|
| Job 시작/종료 알림 | JobExecutionListener | — |
| 실행 시간 측정 | StepExecutionListener | Micrometer (Step 14) |
| 불량 데이터 기록 | SkipListener | — (이게 정석) |
| 진행률 로깅 | ChunkListener (N개마다) | — |
| 데이터 변환 | ❌ | ItemProcessor |
| 데이터 검증 | ❌ | ItemProcessor / Validator |
| 아이템별 로깅 | ❌ (너무 느림) | 에러 콜백만 |
| 집계·통계 | ❌ (상태를 가짐) | afterStep 에서 SQL 로 |
💡 실무 팁 — 리스너에 비즈니스 로직을 넣지 마십시오
"정산이 끝나면 포인트도 적립해야 하니까 afterStep 에서 하자"는 유혹이 있습니다. 하지 마십시오.
afterStep 은 트랜잭션 밖이라 실패해도 롤백되지 않습니다.
- 재시작하면 이미 성공한 Step 은 건너뛰므로 포인트 적립이 누락됩니다.
- 테스트하기 어렵습니다.
별도 Step 으로 만드십시오. 그러면 재시작·모니터링·트랜잭션이 전부 프레임워크의 보호를 받습니다.
리스너는 "관찰"하는 자리이지 "일하는" 자리가 아닙니다.
정리
| 개념 | 핵심 |
|---|
| 리스너 레벨 | Job → Step → Chunk → Item → Skip 5단계 |
beforeJob/afterJob | afterJob 은 실패해도 호출. 상태 분기 필수 |
afterStep | 유일하게 리턴값이 있음. ExitStatus 로 흐름 분기 개입 |
afterStep 함정 | return ExitStatus.COMPLETED 가 다른 분기를 덮어씀. getExitStatus() 를 반환할 것 |
afterChunk | 커밋 전. 여기서 쓴 데이터는 롤백되면 함께 사라짐 |
SkipListener | 커밋 후. 불량 데이터 기록의 정석 자리 |
| Item 레벨 리스너 | 아이템마다 호출. log.info 를 걸면 약 4.5배 느려짐 |
| 애너테이션 방식 | 시그니처가 틀려도 컴파일되고 조용히 무시됨. 인터페이스가 안전 |
| 리스너 예외 | afterJob 과 SkipListener 는 삼켜짐, 나머지는 Step/Job 을 실패시킴 |
| 실행 순서 | before·after 둘 다 등록 순. 역순 아님 |
| 원칙 | 리스너는 관찰하는 자리. 비즈니스 로직은 별도 Step 으로 |
연습문제
Exercise.java 에 6문제가 있습니다. 정답은 Solution.java. 로그가 실제로 찍히는지 눈으로 확인하세요.
- 실패한 Job 에만 알림을 보내는
JobExecutionListener 작성하기
- 애너테이션 리스너의 시그니처를 틀리게 만들어 "조용히 무시"되는 것을 재현하기
- 불량 주문을 별도 테이블에 기록하는
SkipListener 작성하고, onProcessError 로 했을 때와 비교하기
afterChunk 에서 예외를 던져 "데이터는 남고 Step 만 실패"하는 것을 확인하기
- 리스너 세 개를 등록해 실행 순서를 관찰하고,
Ordered 로 순서를 뒤집기
- Item 레벨 리스너의 성능 영향을 실측하고, 안전한 로깅 전략 설계하기
다음 단계
리스너로 배치의 안팎을 관찰할 수 있게 됐습니다. 그런데 지금까지의 모든 실습은 단일 스레드였습니다. 70,000건에 6초면 괜찮지만, 700만 건이면 10분입니다.
다음 스텝에서 배치를 병렬화합니다. 그리고 그 과정에서 이 코스에서 가장 위험한 함정 — 3.3배 빨라졌는데 787건이 조용히 사라지는 상황 — 을 만납니다.
→ Step 13 — 병렬 처리와 확장
실습 파일
이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 를 위에서부터 따라가며 12-1 ~ 12-10 의 리스너들을 하나씩 붙여 로그를 관찰하고, Exercise.java 의 6문제를 직접 채운 뒤, Solution.java 로 대조합니다. 세 파일 모두 com.example.batch.step12 패키지이며, 설정 클래스들을 static class 로 중첩해 두었습니다.
이 스텝의 실습에는 다른 스텝과 다른 원칙이 하나 있습니다. "코드를 썼으니 돌겠지"를 절대 가정하지 마십시오. 리스너는 등록에 실패해도, 시그니처가 틀려도 조용합니다. 매번 로그가 실제로 찍히는지 눈으로 확인하는 습관이 이 스텝의 진짜 학습 목표입니다.
Practice.java
본문의 모든 리스너를 절 번호 주석과 함께 담았습니다.
[12-0] 의 PlantBadData 는 Step 11 과 같은 불량 데이터를 심습니다. 이것을 먼저 실행하지 않으면 SkipListener 가 한 번도 호출되지 않아 12-6 이 통째로 빈 로그가 됩니다. 그리고 그게 "리스너가 안 붙었나?" 하는 착각을 부릅니다.
[12-5] 의 ItemLevelListener 는 log.info 가 주석 처리된 채로 들어 있습니다. 주석을 풀면 27초짜리 배치가 됩니다. 성능 실측(6.108 → 27.310초)을 재현할 때만 켜고, 그 외에는 꺼 두십시오.
[12-8] 의 Broken*Listener 네 개는 일부러 예외를 던지는 코드입니다. BrokenJobListener(afterJob), BrokenStepListener(afterStep), BrokenChunkListener(afterChunk, 35번째), BrokenSkipListener(onSkipInProcess) 를 하나씩만 활성화해 표의 아홉 줄을 직접 채워 보십시오. 네 개를 동시에 켜면 어느 것 때문에 죽었는지 알 수 없습니다.
BrokenChunkListener 를 돌린 뒤에는 settlement 에 34,000행이 남아 있습니다. 다음 측정 전에 반드시 TRUNCATE TABLE settlement 하십시오. 이 스텝에서 가장 흔한 실습 실수입니다.
[12-7] 의 AnnotatedListener 에는 정상 버전과 시그니처가 틀린 버전(BrokenAnnotatedListener)이 나란히 있습니다. 둘 다 컴파일되고 둘 다 예외 없이 실행되며, 차이는 로그가 찍히느냐 아니냐뿐입니다. 이 대조가 12-7 함정의 전부입니다.
- 파일 하단
CleanUp 에 s12_bad_order 테이블 DDL 과 orders 복구 SQL 이 있습니다. 이 스텝을 끝내면 반드시 복구 SQL 을 실행하십시오. orders 는 Step 13·14 가 공유합니다.
package com.example.batch.step12;
import com.example.batch.domain.Order;
import com.example.batch.domain.Settlement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.AfterChunk;
import org.springframework.batch.core.annotation.AfterStep;
import org.springframework.batch.core.annotation.BeforeStep;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.listener.ChunkListener;
import org.springframework.batch.core.listener.ItemProcessListener;
import org.springframework.batch.core.listener.ItemReadListener;
import org.springframework.batch.core.listener.JobExecutionListener;
import org.springframework.batch.core.listener.SkipListener;
import org.springframework.batch.core.listener.StepExecutionListener;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Step 12 — 리스너 : 본문 12-0 ~ 12-10 의 모든 예제.
*
* ─────────────────────────────────────────────────────────────────────────
* 실행 방법
*
* ./gradlew bootRun --args='--spring.batch.job.name=settlementListenerJob date=2025-03-01'
*
* 바깥 클래스에 @Configuration 이 없습니다. 지금 실습할 static class 하나만
* @Configuration 주석을 풀고 나머지는 주석 처리한 채로 돌리십시오.
*
* ─────────────────────────────────────────────────────────────────────────
* ⚠️ 이 스텝의 실습 원칙
*
* **"코드를 썼으니 돌겠지"를 절대 가정하지 마십시오.**
*
* 리스너는 등록에 실패해도, 메서드 시그니처가 틀려도 조용합니다.
* 예외도 경고도 없습니다. 그래서 매번 로그가 실제로 찍히는지
* 눈으로 확인해야 합니다. 이것이 이 스텝의 진짜 학습 목표입니다.
* ─────────────────────────────────────────────────────────────────────────
*/
public class Practice {
private static final Logger log = LoggerFactory.getLogger(Practice.class);
// =====================================================================
// [12-0] 실습 준비 — 불량 데이터 심기
//
// ⚠️ 이것을 먼저 실행하지 않으면 SkipListener 가 한 번도 호출되지
// 않아 12-6 이 통째로 빈 로그가 됩니다. 그리고 그게 "리스너가
// 안 붙었나?" 하는 착각을 부릅니다.
// =====================================================================
public static class PlantBadData {
public static final String PLANT_SQL = """
UPDATE orders SET amount = -1.00 WHERE order_id %% 1000 = 0;
TRUNCATE TABLE settlement;
-- 검증: target=70000, bad_amount=100
SELECT (SELECT COUNT(*) FROM orders WHERE status='COMPLETED') AS target,
(SELECT COUNT(*) FROM orders WHERE amount < 0) AS bad_amount;
""";
}
// =====================================================================
// [12-2] JobExecutionListener — 가장 바깥
// =====================================================================
public static class SettlementJobListener implements JobExecutionListener {
private static final Logger log =
LoggerFactory.getLogger(SettlementJobListener.class);
@Override
public void beforeJob(JobExecution jobExecution) {
log.info(">>> 정산 배치 시작. 파라미터={}", jobExecution.getJobParameters());
}
@Override
public void afterJob(JobExecution jobExecution) {
log.info(">>> 정산 배치 종료. status={}, 소요={}ms",
jobExecution.getStatus(),
Duration.between(jobExecution.getStartTime(),
jobExecution.getEndTime()).toMillis());
// ⚠️ afterJob 은 실패해도 호출됩니다.
// 상태를 분기하지 않으면 "배치 완료" 알림이 실패 시에도 갑니다.
if (jobExecution.getStatus() == BatchStatus.FAILED) {
log.error(">>> 정산 배치 실패! 원인={}",
jobExecution.getAllFailureExceptions());
}
}
}
// =====================================================================
// [12-3] StepExecutionListener — ExitStatus 를 바꿀 수 있는 유일한 자리
// =====================================================================
public static class SettlementStepListener implements StepExecutionListener {
private static final Logger log =
LoggerFactory.getLogger(SettlementStepListener.class);
@Override
public void beforeStep(StepExecution stepExecution) {
log.info(">>> Step 시작: {}", stepExecution.getStepName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
long written = stepExecution.getWriteCount();
log.info(">>> Step 종료: read={}, write={}, filter={}, skip={}",
stepExecution.getReadCount(), written,
stepExecution.getFilterCount(), stepExecution.getSkipCount());
if (written == 0) {
return new ExitStatus("NOTHING_SETTLED"); // Step 10 의 분기용
}
// ⚠️ return ExitStatus.COMPLETED 로 하지 마십시오.
// 다른 리스너가 설정한 커스텀 ExitStatus 를 덮어씁니다.
// 값을 바꿀 의도가 없으면 기존 값을 그대로 돌려줍니다.
return stepExecution.getExitStatus();
}
}
// =====================================================================
// [12-4] ChunkListener — 트랜잭션 경계 안쪽
//
// ⚠️ afterChunk 는 "커밋 전" 입니다.
// 여기서 DB 에 쓰면 청크 트랜잭션에 포함되어, 롤백되면 함께
// 사라집니다. 진행 로그를 남기려던 목적과 정반대가 됩니다.
// =====================================================================
public static class LoggingChunkListener implements ChunkListener {
private static final Logger log =
LoggerFactory.getLogger(LoggingChunkListener.class);
private final AtomicInteger chunkNo = new AtomicInteger(0);
@Override
public void beforeChunk(ChunkContext context) {
// 이 시점에 이미 트랜잭션이 시작되어 있습니다.
}
@Override
public void afterChunk(ChunkContext context) {
int n = chunkNo.incrementAndGet();
// 70,000번이 아니라 7번만 찍습니다. 진행률 로깅의 올바른 형태입니다.
if (n % 10 == 0) {
log.info(">>> 청크 {} 완료", n);
}
}
@Override
public void afterChunkError(ChunkContext context) {
// ⚠️ 이 시점에는 트랜잭션이 이미 롤백 표시된 상태입니다.
// 여기서 DB 에 쓰려고 하면 UnexpectedRollbackException 이 납니다.
log.warn(">>> 청크 실패. 롤백됩니다. {}",
context.getStepContext().getStepExecution().getSummary());
}
}
// =====================================================================
// [12-5] Item 레벨 리스너 — 성능 주의
//
// ⚠️ 아래 log.info 는 일부러 주석 처리해 두었습니다.
// 주석을 풀면 70,000줄이 찍혀 6.108초짜리 배치가 27.310초가 됩니다.
// 성능 실측을 재현할 때만 켜고, 그 외에는 꺼 두십시오.
//
// | 구성 | 소요 | 배수 |
// |---|---|---|
// | 리스너 없음 | 6.108초 | 1.00배 |
// | afterRead 에 log.debug (레벨 꺼짐) | 6.402초 | 1.05배 |
// | afterRead 에 log.info | 27.310초 | 4.47배 |
// | afterRead 에 log.info + 문자열 concat | 31.884초 | 5.22배 |
// =====================================================================
public static class ItemLevelListener
implements ItemReadListener<Order>, ItemProcessListener<Order, Settlement> {
private static final Logger log =
LoggerFactory.getLogger(ItemLevelListener.class);
@Override
public void afterRead(Order item) {
// log.info(">>> 읽음: order_id={}", item.order_id()); // ← 4.47배 느려짐
}
@Override
public void onReadError(Exception ex) {
log.error("읽기 실패", ex);
}
@Override
public void afterProcess(Order item, Settlement result) {
if (result == null) {
log.debug("필터링됨: order_id={}", item.order_id());
}
}
@Override
public void onProcessError(Order item, Exception e) {
// ⚠️ 이것은 트랜잭션 "안" 입니다.
// 여기서 DB 에 기록하면 청크와 함께 롤백됩니다.
// 불량 데이터 기록은 SkipListener 에서 하십시오 — 연습문제 3.
log.warn("처리 실패: order_id={}, {}", item.order_id(), e.getMessage());
}
}
// =====================================================================
// [12-6] SkipListener — 커밋 "후" 에 호출된다
//
// 이 "커밋 후" 성질이 결정적으로 유용합니다.
// 불량 데이터 기록은 청크가 롤백되어도 남아야 하기 때문입니다.
// =====================================================================
public static class SettlementSkipListener implements SkipListener<Order, Settlement> {
private static final Logger log =
LoggerFactory.getLogger(SettlementSkipListener.class);
private final JdbcTemplate jdbcTemplate;
public SettlementSkipListener(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public void onSkipInRead(Throwable t) {
log.warn("[SKIP-READ] {}", t.getMessage());
}
@Override
public void onSkipInProcess(Order item, Throwable t) {
log.warn("[SKIP-PROCESS] order_id={}, 사유={}",
item.order_id(), t.getMessage());
// 이 시점은 청크 트랜잭션 "밖" 이므로, 청크가 롤백되어도
// 이 기록은 남습니다. 이것이 SkipListener 를 쓰는 이유입니다.
jdbcTemplate.update("""
INSERT INTO s12_bad_order (order_id, phase, reason, occurred_at)
VALUES (?, 'PROCESS', ?, NOW())
""", item.order_id(), t.getMessage());
}
@Override
public void onSkipInWrite(Settlement item, Throwable t) {
log.warn("[SKIP-WRITE] order_id={}, 사유={}", item.orderId(), t.getMessage());
jdbcTemplate.update("""
INSERT INTO s12_bad_order (order_id, phase, reason, occurred_at)
VALUES (?, 'WRITE', ?, NOW())
""", item.orderId(), t.getMessage());
}
}
// =====================================================================
// [12-7] 애너테이션 방식 — 그리고 조용히 등록되지 않는 함정
//
// 아래 두 클래스는 둘 다 컴파일되고 둘 다 예외 없이 실행됩니다.
// 차이는 로그가 찍히느냐 아니냐뿐입니다. 이 대조가 함정의 전부입니다.
// =====================================================================
/** 정상 — 시그니처가 맞습니다. 로그가 찍힙니다. */
public static class AnnotatedListener {
private static final Logger log =
LoggerFactory.getLogger(AnnotatedListener.class);
@BeforeStep
public void before(StepExecution stepExecution) {
log.info(">>> [애너테이션] Step 시작: {}", stepExecution.getStepName());
}
@AfterStep
public ExitStatus after(StepExecution stepExecution) {
log.info(">>> [애너테이션] Step 종료");
return stepExecution.getExitStatus();
}
@AfterChunk
public void afterChunk(ChunkContext context) {
// ChunkContext 가 올바른 인자 타입입니다.
}
}
/**
* ⚠️ 일부러 틀린 버전. 고치지 마십시오.
*
* 컴파일됩니다. 등록됩니다. 예외도 안 납니다.
* 그런데 **호출되지 않습니다.** 로그가 하나도 안 찍힙니다.
*
* 세 가지 "조용한 실패" 패턴이 들어 있습니다.
*/
public static class BrokenAnnotatedListener {
private static final Logger log =
LoggerFactory.getLogger(BrokenAnnotatedListener.class);
/** ① 인자 누락 — StepExecution 을 받아야 합니다. */
@BeforeStep
public void before() {
log.info(">>> 이 줄은 절대 찍히지 않습니다 (인자 누락)");
}
/** ② 인자 타입 불일치 — ChunkContext 여야 합니다. */
@AfterChunk
public void afterChunk(StepExecution stepExecution) {
log.info(">>> 이 줄도 찍히지 않습니다 (타입 불일치)");
}
// ③ 애너테이션 오타는 아예 컴파일이 안 되므로 여기 넣을 수 없지만,
// @BeforeStep 대신 스프링의 다른 @Before 계열을 잘못 import 하면
// 컴파일은 되고 무시됩니다. import 문을 항상 확인하십시오.
}
// =====================================================================
// [12-8] 리스너에서 예외를 던지면 — 리스너마다 다르다
//
// ★ 이 절이 이 스텝의 핵심입니다.
//
// ⚠️ 아래 네 개는 일부러 예외를 던지는 코드입니다.
// **하나씩만** 활성화해 표의 아홉 줄을 직접 채워 보십시오.
// 네 개를 동시에 켜면 어느 것 때문에 죽었는지 알 수 없습니다.
//
// | 리스너 | 예외를 던지면 | Job 최종 상태 |
// |---|---|---|
// | beforeJob | Job 즉시 실패 | FAILED |
// | afterJob | **삼켜짐** | COMPLETED |
// | beforeStep | Step 실패 → Job 실패 | FAILED |
// | afterStep | Step 실패 → Job 실패 | FAILED |
// | beforeChunk | 청크 실패 → Step 실패| FAILED |
// | afterChunk | 커밋은 이미 됨 | FAILED |
// | afterChunkError | 원래 예외를 가림 | FAILED |
// | ItemReadListener | 읽기 실패로 처리 | FAILED |
// | SkipListener.onSkip* | **삼켜짐** | 영향 없음 |
// =====================================================================
/**
* afterJob 의 예외는 삼켜집니다.
*
* ERROR 로그는 찍히지만 Job 은 COMPLETED 로 끝납니다.
*
* ⚠️ 이게 왜 위험한가: afterJob 은 알림을 보내는 자리입니다.
* 배치가 실패했고 → 알림을 보내려 했고 → 알림 전송이 실패했는데
* → 그 예외가 삼켜집니다. 결과: 아무도 배치 실패를 모릅니다.
*/
public static class BrokenJobListener implements JobExecutionListener {
@Override
public void afterJob(JobExecution jobExecution) {
throw new RuntimeException("알림 서버 접속 실패");
}
}
public static class BrokenStepListener implements StepExecutionListener {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
throw new RuntimeException("afterStep 폭발");
}
}
/**
* 35번째 청크에서 예외를 던집니다.
*
* 결과: settlement 에 34,000행이 남고 Step 은 FAILED.
* read=35000, write=34000, commit=34, rollback=1
*
* "데이터는 남았는데 실패"는 모순이 아니라 청크 단위 커밋의
* 당연한 귀결입니다.
*
* ⚠️ 이것을 돌린 뒤에는 settlement 에 34,000행이 남아 있습니다.
* 다음 측정 전에 반드시 TRUNCATE TABLE settlement 하십시오.
* 이 스텝에서 가장 흔한 실습 실수입니다.
*/
public static class BrokenChunkListener implements ChunkListener {
private final AtomicInteger chunkNo = new AtomicInteger(0);
@Override
public void afterChunk(ChunkContext context) {
if (chunkNo.incrementAndGet() == 35) {
throw new RuntimeException("35번째 청크에서 리스너 폭발");
}
}
}
/** SkipListener 의 예외도 삼켜집니다. Job 에 영향이 없습니다. */
public static class BrokenSkipListener implements SkipListener<Order, Settlement> {
@Override
public void onSkipInProcess(Order item, Throwable t) {
throw new RuntimeException("SkipListener 폭발");
}
}
// =====================================================================
// [12-9] 리스너 실행 순서
//
// ⚠️ before 는 등록 순, after 도 **등록 순** 입니다.
// 필터 체인처럼 "after 는 역순"일 거라고 가정하지 마십시오.
// =====================================================================
public static class FirstListener implements StepExecutionListener, Ordered {
private static final Logger log = LoggerFactory.getLogger(FirstListener.class);
@Override
public void beforeStep(StepExecution stepExecution) {
log.info("[1] beforeStep");
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("[1] afterStep");
return stepExecution.getExitStatus();
}
@Override
public int getOrder() {
return 1;
}
}
public static class SecondListener implements StepExecutionListener, Ordered {
private static final Logger log = LoggerFactory.getLogger(SecondListener.class);
@Override
public void beforeStep(StepExecution stepExecution) {
log.info("[2] beforeStep");
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("[2] afterStep");
return stepExecution.getExitStatus();
}
@Override
public int getOrder() {
return 2;
}
}
// =====================================================================
// 리스너를 모두 붙인 Job 설정
// =====================================================================
// @Configuration
public static class ListenerJobConfig {
@Bean
public Job settlementListenerJob(JobRepository jobRepository,
Step settlementListenerStep) {
return new JobBuilder("settlementListenerJob", jobRepository)
.listener(new SettlementJobListener())
.start(settlementListenerStep)
.build();
}
@Bean
public Step settlementListenerStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
DataSource dataSource) {
return new StepBuilder("settlementListenerStep", jobRepository)
.<Order, Settlement>chunk(1000, txManager)
.reader(com.example.batch.step11.Practice.buildOrderReader(dataSource))
.processor(new com.example.batch.step11.Practice.SettlementProcessor())
.writer(com.example.batch.step11.Practice.buildStrictWriter(dataSource))
.faultTolerant()
.skip(IllegalArgumentException.class)
.skipLimit(200)
// 인터페이스 방식 — 컴파일러가 오버로드를 골라 줍니다.
.listener(new SettlementStepListener())
.listener(new LoggingChunkListener())
.listener(new ItemLevelListener())
.listener(new SettlementSkipListener(dataSource))
// 애너테이션 방식 — Object 오버로드로 잡힙니다.
.listener((Object) new AnnotatedListener())
.build();
}
}
// =====================================================================
// CleanUp — 실습 뒷정리
// =====================================================================
public static class CleanUp {
/** 불량 주문 기록 테이블. 12-6 과 연습문제 3 에서 씁니다. */
public static final String DDL = """
CREATE TABLE IF NOT EXISTS s12_bad_order (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT NOT NULL,
phase VARCHAR(10) NOT NULL,
reason VARCHAR(500) NOT NULL,
occurred_at DATETIME NOT NULL,
KEY idx_bad_order (order_id)
) ENGINE=InnoDB;
""";
/**
* ⚠️ 이 스텝을 끝내면 반드시 실행하십시오.
* orders 는 Step 13·14 가 공유하는 공용 테이블입니다.
* 음수 금액을 남겨 두면 이후 스텝의 결과가 교재와 달라집니다.
*/
public static final String REVERT_SQL = """
UPDATE orders o
JOIN (SELECT order_id, 1000 + (order_id %% 977) * 100 AS amt
FROM orders) s
ON o.order_id = s.order_id
SET o.amount = s.amt
WHERE o.amount < 0;
TRUNCATE TABLE settlement;
DROP TABLE IF EXISTS s12_bad_order;
-- 검증: bad_amount 가 0 이어야 합니다.
SELECT COUNT(*) AS bad_amount FROM orders WHERE amount < 0;
""";
/** 리스너가 실제로 동작했는지 확인하는 쿼리. */
public static final String VERIFY_SQL = """
-- SkipListener 가 기록한 불량 주문 (100건이어야 정상)
SELECT phase, COUNT(*) FROM s12_bad_order GROUP BY phase;
-- Step 카운터
SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT,
PROCESS_SKIP_COUNT, WRITE_SKIP_COUNT,
COMMIT_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION
ORDER BY STEP_EXECUTION_ID DESC LIMIT 1;
""";
}
}
Exercise.java
6문제의 문제지입니다. 각 문제는 // 여기에 작성: 자리를 비워 두었습니다.
- 문제 1·3·5 는 리스너를 작성하는 문제, 문제 2·4·6 은 동작을 관찰하고 측정하는 문제입니다.
- 문제 2 가 이 스텝의 핵심입니다. 애너테이션 메서드의 인자를 일부러 틀리게 만들고, 컴파일도 되고 실행도 되는데 로그만 안 찍히는 상황을 재현합니다. 여기서 "어떻게 알아차릴 것인가"를 스스로 답해 보는 것이 문제의 목적입니다.
- 문제 3 은 같은 기록 로직을
SkipListener.onSkipInProcess 와 ItemProcessListener.onProcessError 두 곳에 각각 넣고 결과를 비교합니다. 기록된 행 수가 100 vs 0 으로 갈립니다. 후자는 청크와 함께 롤백되기 때문입니다. 트랜잭션 경계를 몸으로 이해하는 문제입니다.
- 문제 4 는 실행 후
settlement 행 수와 BATCH_STEP_EXECUTION 카운터를 둘 다 확인해야 합니다. 한쪽만 보면 "데이터는 남았는데 왜 실패지?"에서 멈춥니다.
- 문제 6 은 측정 문제입니다. 표의 네 가지 구성을 직접 돌려 시간을 재고, 그 결과로 "그럼 실무에서는 어떻게 로깅할 것인가"까지 설계하는 것이 마무리입니다.
- 각 문제 끝에
-- 검증: SQL 이 붙어 있습니다. 리스너는 조용히 실패하므로, 로그 육안 확인 + SQL 검증을 둘 다 하십시오.
package com.example.batch.step12;
import com.example.batch.domain.Order;
import com.example.batch.domain.Settlement;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.AfterChunk;
import org.springframework.batch.core.annotation.BeforeStep;
import org.springframework.batch.core.listener.ChunkListener;
import org.springframework.batch.core.listener.ItemProcessListener;
import org.springframework.batch.core.listener.JobExecutionListener;
import org.springframework.batch.core.listener.SkipListener;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.scope.context.ChunkContext;
import javax.sql.DataSource;
/**
* Step 12 — 연습문제 (6문제)
*
* 정답은 Solution.java. 먼저 직접 풀어 보십시오.
*
* ─────────────────────────────────────────────────────────────────────────
* ⚠️ 이 스텝의 검증 원칙
*
* 리스너는 조용히 실패합니다. 등록이 안 돼도, 시그니처가 틀려도
* 예외가 나지 않습니다. 따라서 매 문제마다 반드시 두 가지를 하십시오.
*
* ① 로그가 실제로 찍히는지 **눈으로** 확인
* ② 각 문제 끝의 `-- 검증:` SQL 실행
*
* "에러 없이 끝났다"는 정답의 근거가 되지 못합니다.
*
* 사전 준비:
* - Practice.PlantBadData.PLANT_SQL 로 불량 데이터 100건을 심으십시오.
* - Practice.CleanUp.DDL 로 s12_bad_order 테이블을 만드십시오.
* ─────────────────────────────────────────────────────────────────────────
*/
public class Exercise {
// =====================================================================
// 문제 1. 실패한 Job 에만 알림을 보내는 JobExecutionListener
//
// 요구사항:
// (a) Job 이 성공하면 아무 알림도 보내지 않습니다.
// (b) Job 이 실패하면 실패 원인을 담아 알림을 보냅니다.
// 힌트: jobExecution.getAllFailureExceptions()
// (c) 알림 전송 자체가 실패해도 배치에 영향을 주지 않아야 합니다.
// 힌트: afterJob 의 예외는 삼켜지지만, 그래서 더 위험합니다.
// 왜 그런지 한 줄로 적으십시오.
// (d) Job 소요 시간도 함께 로그에 남기십시오.
//
// 검증 방법: 일부러 실패하는 Job 을 만들어 알림이 가는지,
// 성공하는 Job 에서는 안 가는지 둘 다 확인하십시오.
// =====================================================================
/** 알림 전송을 흉내 내는 스텁. 실제로는 슬랙/메일 클라이언트입니다. */
public static class Notifier {
public void sendAlert(String message) {
System.out.println("[ALERT] " + message);
}
}
public static class Problem1Listener implements JobExecutionListener {
private final Notifier notifier = new Notifier();
@Override
public void afterJob(JobExecution jobExecution) {
// 여기에 작성:
//
}
}
// (c) afterJob 의 예외가 삼켜지는 것이 왜 위험한가?
// 여기에 작성:
//
// -- 검증: 실패한 Job 의 원인은 여기에도 남습니다.
// SELECT JOB_EXECUTION_ID, STATUS, LEFT(EXIT_MESSAGE, 100)
// FROM BATCH_JOB_EXECUTION ORDER BY JOB_EXECUTION_ID DESC LIMIT 3;
// =====================================================================
// 문제 2. 애너테이션 리스너가 "조용히 무시"되는 것을 재현하기
//
// ★ 이 스텝의 핵심 문제입니다.
//
// (a) 아래 세 메서드는 전부 컴파일되고 전부 등록되지만
// 전부 호출되지 않습니다. 각각 왜인지 적으십시오.
// (b) 셋 중 하나를 골라 올바르게 고치고, 로그가 찍히는 것을 확인하십시오.
// (c) ⚠️ 가장 중요한 질문:
// 이런 실수를 **어떻게 알아차릴 수 있습니까?**
// 컴파일러도, 스프링도, 로그도 아무 말을 하지 않습니다.
// 실무에서 쓸 수 있는 방어책을 두 가지 이상 적으십시오.
// (d) 결론: 인터페이스 방식과 애너테이션 방식 중 무엇을 기본으로
// 삼아야 합니까? 그 이유는?
// =====================================================================
public static class Problem2Listener {
// ① 왜 호출되지 않는가?
@BeforeStep
public void before() {
System.out.println(">>> 문제2-① 이 줄이 찍히면 성공");
}
// ② 왜 호출되지 않는가?
@AfterChunk
public void afterChunk(StepExecution stepExecution) {
System.out.println(">>> 문제2-② 이 줄이 찍히면 성공");
}
// ③ 왜 호출되지 않는가?
// 힌트: 이 메서드에는 애너테이션이 아예 없습니다.
// 메서드 이름만 맞으면 될까요?
public void beforeStep(StepExecution stepExecution) {
System.out.println(">>> 문제2-③ 이 줄이 찍히면 성공");
}
}
// (a) 각각의 이유
// ①
// ②
// ③
// 여기에 작성:
//
// (c) 어떻게 알아차릴 것인가 — 방어책 2가지 이상
// 여기에 작성:
//
// (d) 무엇을 기본으로 삼아야 하는가
// 여기에 작성:
//
// =====================================================================
// 문제 3. SkipListener vs onProcessError — 트랜잭션 경계 체감하기
//
// ★ 이 문제가 12-6 의 핵심을 몸으로 이해하게 합니다.
//
// 완전히 같은 기록 로직을 두 곳에 각각 넣고 결과를 비교합니다.
//
// 버전 A: SkipListener.onSkipInProcess 에서 s12_bad_order 에 INSERT
// 버전 B: ItemProcessListener.onProcessError 에서 같은 INSERT
//
// (a) 두 버전을 각각 구현하십시오.
// (b) 각각 실행한 뒤 s12_bad_order 의 행 수를 세십시오.
// 버전 A: ____ 건
// 버전 B: ____ 건
// (c) 숫자가 다릅니다. 왜입니까?
// (d) 그렇다면 onProcessError 는 대체 언제 쓰는 것입니까?
//
// ⚠️ 각 버전을 돌리기 전에 반드시 실행하십시오:
// TRUNCATE TABLE s12_bad_order;
// TRUNCATE TABLE settlement;
// =====================================================================
/** 버전 A — SkipListener */
public static class Problem3SkipListener implements SkipListener<Order, Settlement> {
public Problem3SkipListener(DataSource dataSource) {
// 여기에 작성: JdbcTemplate 준비
//
}
@Override
public void onSkipInProcess(Order item, Throwable t) {
// 여기에 작성: s12_bad_order 에 INSERT
//
}
}
/** 버전 B — ItemProcessListener */
public static class Problem3ProcessListener
implements ItemProcessListener<Order, Settlement> {
public Problem3ProcessListener(DataSource dataSource) {
// 여기에 작성:
//
}
@Override
public void onProcessError(Order item, Exception e) {
// 여기에 작성: 버전 A 와 완전히 같은 INSERT
//
}
}
// (b) 행 수
// 여기에 작성:
//
// (c) 왜 다른가
// 여기에 작성:
//
// (d) onProcessError 는 언제 쓰는가
// 여기에 작성:
//
// -- 검증:
// SELECT COUNT(*) AS recorded FROM s12_bad_order;
// SELECT PROCESS_SKIP_COUNT FROM BATCH_STEP_EXECUTION
// ORDER BY STEP_EXECUTION_ID DESC LIMIT 1;
// =====================================================================
// 문제 4. afterChunk 에서 예외를 던지면 — 데이터는 남고 Step 만 실패
//
// Practice.BrokenChunkListener 를 활성화해 실행하십시오.
//
// (a) 실행 전에 예측하십시오.
// - settlement 행 수: ____
// - Step STATUS: ____
// - COMMIT_COUNT: ____
// - ROLLBACK_COUNT: ____
// (b) 실제로 실행해 확인하고 예측과 대조하십시오.
// (c) "데이터는 남았는데 Step 은 실패"가 모순처럼 보입니다.
// 왜 모순이 아닌지 설명하십시오.
// (d) 이 상태에서 같은 파라미터로 재실행하면 어떻게 됩니까?
// 몇 번째 아이템부터 이어집니까? (Step 11 과 연결)
//
// ⚠️ (b) 를 실행한 뒤 settlement 를 TRUNCATE 하지 마십시오.
// (d) 에서 재시작 실습에 씁니다.
// =====================================================================
// (a) 예측
// 여기에 작성:
//
// (b) 실제 결과
// 여기에 작성:
//
// (c) 왜 모순이 아닌가
// 여기에 작성:
//
// (d) 재실행하면
// 여기에 작성:
//
// -- 검증:
// SELECT COUNT(*) FROM settlement;
// SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT,
// COMMIT_COUNT, ROLLBACK_COUNT
// FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 2;
// =====================================================================
// 문제 5. 리스너 세 개의 실행 순서 관찰하고 뒤집기
//
// (a) StepExecutionListener 를 세 개 만들어 A, B, C 순으로 등록하고
// beforeStep / afterStep 의 로그 순서를 기록하십시오.
// beforeStep 순서: ____
// afterStep 순서: ____
// (b) 예상과 같습니까? 필터 체인처럼 afterStep 이 역순일 거라고
// 생각했다면, 실제 결과는 어떻습니까?
// (c) Ordered 인터페이스를 구현해 순서를 C, B, A 로 뒤집으십시오.
// (d) ⚠️ 마지막 질문: 순서를 제어할 수 있다는 것과
// 순서에 의존해도 된다는 것은 다릅니다.
// 리스너끼리 순서에 의존하게 만들면 왜 위험합니까?
// =====================================================================
public static class ListenerA implements StepExecutionListener {
// 여기에 작성:
//
}
// ListenerB, ListenerC
// 여기에 작성:
//
// (a) 관찰된 순서
// 여기에 작성:
//
// (d) 순서 의존이 위험한 이유
// 여기에 작성:
//
// =====================================================================
// 문제 6. Item 레벨 리스너의 성능 영향 실측 + 로깅 전략 설계
//
// 네 가지 구성을 각각 돌려 시간을 재십시오.
//
// ① 리스너 없음 ____초
// ② afterRead 에 log.debug (레벨 INFO) ____초
// ③ afterRead 에 log.info ____초
// ④ afterRead 에 log.info + 문자열 concat ____초
// (예: log.info(">>> " + item.order_id()))
//
// (a) 표를 채우고 배수를 계산하십시오.
// (b) ②와 ③의 차이가 큰 이유는 무엇입니까?
// (c) ③과 ④의 차이는 왜 생깁니까?
// 힌트: SLF4J 의 {} 플레이스홀더는 언제 문자열을 만듭니까?
// (d) ⚠️ 운영 함정: ②처럼 log.debug 로 짜 두면 평소엔 안전합니다.
// 그런데 장애 조사를 하려고 로그 레벨을 DEBUG 로 올리는 순간
// 무슨 일이 벌어집니까?
// (e) 위 결과를 바탕으로 "배치에서의 안전한 로깅 전략"을
// 세 줄로 정리하십시오.
// =====================================================================
// (a) 측정표와 배수
// 여기에 작성:
//
// (b) ②와 ③의 차이
// 여기에 작성:
//
// (c) ③과 ④의 차이
// 여기에 작성:
//
// (d) 로그 레벨을 올리는 순간
// 여기에 작성:
//
// (e) 안전한 로깅 전략 3줄
// 여기에 작성:
//
// -- 검증: 소요 시간은 여기서도 확인할 수 있습니다.
// SELECT STEP_NAME, TIMESTAMPDIFF(SECOND, START_TIME, END_TIME) AS secs
// FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 5;
}
Solution.java
6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 들어 있습니다. 풀어 본 뒤에 여세요.
- 정답 1 은
getStatus() == BatchStatus.FAILED 분기와 함께, getAllFailureExceptions() 로 실패 원인을 알림에 담는 방법을 보여 줍니다. 그리고 알림 전송 자체를 try-catch 로 감싸야 하는 이유 — afterJob 의 예외는 삼켜져서 알림 실패를 아무도 모른다 — 를 12-8 과 연결해 설명합니다.
- 정답 2 는 세 가지 "조용한 실패" 패턴을 정리합니다. ① 인자 누락 ② 인자 타입 불일치 ③ 애너테이션 오타(
@BeforeStep 대신 @Before). 셋 다 컴파일되고 셋 다 무시됩니다. 결론은 "애매하면 인터페이스를 구현하라" 이고, 근거로 컴파일러가 잡아 주는 범위를 표로 비교합니다.
- 정답 3 이 가장 중요합니다.
SkipListener 는 100건을 기록하고 onProcessError 는 0건을 기록합니다. 후자가 청크 트랜잭션 안이라 롤백되기 때문입니다. 여기서 "그럼 onProcessError 는 언제 쓰나?"에 대한 답 — 롤백돼도 상관없는 것, 즉 로그 출력에만 — 까지 정리합니다.
- 정답 4 는
settlement 34,000행 + STATUS=FAILED + COMMIT_COUNT=34 + ROLLBACK_COUNT=1 의 조합을 해석합니다. "데이터가 남았는데 실패"가 모순이 아니라 청크 단위 커밋의 당연한 귀결임을 설명하고, 재시작하면 34,000건 다음부터 이어진다는 것까지 Step 11 과 연결합니다.
- 정답 5 는 등록 순서가 before·after 양쪽 다 정순임을 로그로 보여 준 뒤,
Ordered 를 구현해 뒤집습니다. 그리고 "애초에 리스너끼리 순서에 의존하게 만들지 말라"는 결론을 답니다. 순서를 제어할 수 있다는 것과 순서에 의존해도 된다는 것은 다릅니다.
- 정답 6 의 결론은 세 줄입니다. ① 정상 경로에는 리스너를 걸지 않는다 ② 에러 콜백만 쓴다 ③ 진행률이 필요하면
ChunkListener 에서 N개마다 찍는다(70,000번이 아니라 70번). log.debug 가 꺼져 있을 때 5% 오버헤드인 것과 켰을 때 4.5배인 것의 차이를 근거로, "로그 레벨을 올리는 순간 배치가 죽는다" 는 운영상의 함정까지 짚습니다.
package com.example.batch.step12;
import com.example.batch.domain.Order;
import com.example.batch.domain.Settlement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.AfterChunk;
import org.springframework.batch.core.annotation.BeforeStep;
import org.springframework.batch.core.listener.ItemProcessListener;
import org.springframework.batch.core.listener.JobExecutionListener;
import org.springframework.batch.core.listener.SkipListener;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.time.Duration;
/**
* Step 12 — 연습문제 정답과 해설
*
* 문제를 직접 풀어 본 뒤에 여십시오.
*/
public class Solution {
// =====================================================================
// 정답 1. 실패한 Job 에만 알림을 보내는 JobExecutionListener
// =====================================================================
public static class CorrectJobListener implements JobExecutionListener {
private static final Logger log =
LoggerFactory.getLogger(CorrectJobListener.class);
private final Exercise.Notifier notifier = new Exercise.Notifier();
@Override
public void afterJob(JobExecution jobExecution) {
// (d) 소요 시간은 성공/실패 무관하게 남깁니다.
long millis = Duration.between(
jobExecution.getStartTime(), jobExecution.getEndTime()).toMillis();
log.info(">>> 정산 배치 종료. status={}, 소요={}ms",
jobExecution.getStatus(), millis);
// (a)(b) 실패했을 때만 알림
if (jobExecution.getStatus() != BatchStatus.FAILED) {
return;
}
// (c) 알림 전송을 try-catch 로 감쌉니다.
try {
notifier.sendAlert(String.format(
"정산 배치 실패 [%s] 소요=%dms 원인=%s",
jobExecution.getJobInstance().getJobName(),
millis,
jobExecution.getAllFailureExceptions()));
} catch (Exception e) {
// 알림이 실패해도 최소한 로그에는 배치 상태를 함께 남깁니다.
log.error(">>> 알림 전송 실패 — 배치 상태={}, 원인={}",
jobExecution.getStatus(),
jobExecution.getAllFailureExceptions(), e);
}
}
}
/*
* (c) afterJob 의 예외가 삼켜지는 것이 왜 위험한가
*
* **알림을 보내는 자리이기 때문입니다.**
*
* 시나리오를 따라가 보십시오.
* ① 정산 배치가 실패합니다.
* ② afterJob 이 호출되어 슬랙으로 알림을 보내려 합니다.
* ③ 슬랙 API 가 죽어 있어서 예외가 납니다.
* ④ 그 예외는 Spring Batch 가 삼킵니다. Job 은 그대로 종료됩니다.
* ⑤ 결과: **아무도 배치 실패를 모릅니다.**
*
* 배치가 실패한 것보다, 실패했는데 아무도 모르는 것이 훨씬 위험합니다.
* 정산 배치라면 다음 날 아침 정산 담당자가 발견합니다.
*
* 그래서 방어책이 두 가지 필요합니다.
* - try-catch 로 감싸 최소한 로그에는 남긴다 (위 코드)
* - **알림 경로를 이중화한다** — 슬랙 + 메트릭.
* 슬랙이 죽어도 Prometheus 의 spring_batch_job_seconds_count
* {status="FAILED"} 가 올라가면 알림이 갑니다.
* Step 14 에서 이 두 번째 경로를 만듭니다.
*
* ⚠️ 그리고 절대 하지 말아야 할 것:
* public void afterJob(JobExecution je) {
* notifier.send("정산 배치가 완료되었습니다"); // 상태 분기 없음
* }
* "배치 완료" 알림이 매일 잘 오길래 안심하고 있었는데 알고 보니
* 3일째 실패 중이었다 — 실무에서 정말 자주 일어납니다.
*/
// =====================================================================
// 정답 2. 애너테이션 리스너가 조용히 무시되는 세 가지 패턴
// =====================================================================
/*
* (a) 각각의 이유
*
* ① @BeforeStep public void before() — **인자 누락**
*
* Spring Batch 는 애너테이션 리스너를 등록할 때
* StepListenerFactoryBean 이 메서드 시그니처를 검사합니다.
* @BeforeStep 은 `void (StepExecution)` 시그니처를 요구합니다.
* 인자가 없으면 매칭에 실패하고 **그냥 등록하지 않습니다.**
* 예외도 경고도 없습니다.
*
* ② @AfterChunk public void afterChunk(StepExecution) — **타입 불일치**
*
* @AfterChunk 는 `void (ChunkContext)` 를 요구합니다.
* StepExecution 을 받으면 시그니처가 달라 매칭에 실패합니다.
* 역시 조용히 무시됩니다.
*
* ⚠️ 이것이 특히 헷갈리는 이유: StepExecution 과 ChunkContext
* 둘 다 "그럴듯한" 타입이라 IDE 자동완성으로 잘못 고르기 쉽습니다.
* ChunkContext 에서 StepExecution 을 꺼내는 것은
* context.getStepContext().getStepExecution() 입니다.
*
* ③ 애너테이션 없이 메서드 이름만 beforeStep — **애너테이션 누락**
*
* 메서드 이름은 아무 의미가 없습니다. Spring Batch 는 이름이 아니라
* **애너테이션**을 봅니다. 이름이 beforeStep 이든 zzz 든
* @BeforeStep 이 붙어 있어야 등록됩니다.
*
* 역으로, StepExecutionListener 를 **구현**했다면 이름이 중요합니다
* (인터페이스 계약이니까). 두 방식을 섞어 생각하면 여기서 혼란이 옵니다.
*
* (b) 고치기 — ①의 경우
*
* @BeforeStep
* public void before(StepExecution stepExecution) { ... }
*
* 인자를 추가하는 것만으로 로그가 찍히기 시작합니다.
* "코드는 한 글자도 안 고쳤는데 갑자기 동작한다"는 경험이
* 이 함정의 본질을 가장 잘 보여 줍니다.
*
* (c) ⚠️ 어떻게 알아차릴 것인가 — 실무 방어책
*
* 1. **로그를 눈으로 확인한다.**
* 가장 단순하고 가장 확실합니다. 리스너를 새로 붙였으면
* 반드시 한 번은 실행해서 그 로그가 찍히는지 봅니다.
* "코드를 썼으니 돌겠지"가 이 스텝의 최대 적입니다.
*
* 2. **테스트를 쓴다.**
* spring-batch-test 의 JobLauncherTestUtils 로 Job 을 돌리고,
* 리스너가 남긴 흔적(로그 대신 카운터나 DB 행)을 단언합니다.
*
* @Test
* void 리스너가_호출된다() {
* jobLauncherTestUtils.launchStep("settlementStep");
* assertThat(listener.getCallCount()).isGreaterThan(0);
* }
*
* 리스너에 호출 카운터를 두면 테스트로 검증할 수 있습니다.
*
* 3. **인터페이스를 구현한다.** — 근본 해결책
* 컴파일러가 시그니처를 강제합니다. 틀리면 빌드가 깨집니다.
*
* (d) 결론 — **인터페이스 방식을 기본으로 삼으십시오**
*
* 컴파일러가 잡아 주는 범위 비교:
*
* | 실수 | 인터페이스 방식 | 애너테이션 방식 |
* |---|---|---|
* | 인자 누락 | 컴파일 에러 | 조용히 무시 |
* | 인자 타입 틀림 | 컴파일 에러 | 조용히 무시 |
* | 메서드명 오타 | 컴파일 에러(@Override) | 무관 |
* | 애너테이션 누락 | 해당 없음 | 조용히 무시 |
* | 리턴 타입 틀림 | 컴파일 에러 | 조용히 무시 |
*
* 애너테이션 방식의 장점은 "한 클래스에 여러 레벨의 리스너를
* 모을 수 있다"는 것뿐입니다. 그 편의를 위해 **모든 실수가
* 런타임에 조용히 무시되는 위험**을 감수할 가치는 대체로 없습니다.
*
* 인터페이스도 여러 개 구현할 수 있습니다.
* class MyListener implements StepExecutionListener, ChunkListener { }
* 이러면 편의와 안전을 둘 다 얻습니다.
*/
/** (b) 올바르게 고친 버전. */
public static class FixedAnnotatedListener {
private static final Logger log =
LoggerFactory.getLogger(FixedAnnotatedListener.class);
@BeforeStep
public void before(StepExecution stepExecution) { // 인자 추가
log.info(">>> [고침] Step 시작: {}", stepExecution.getStepName());
}
@AfterChunk
public void afterChunk(ChunkContext context) { // 타입 수정
log.debug(">>> [고침] 청크 완료");
}
}
// =====================================================================
// 정답 3. SkipListener vs onProcessError — 이 스텝에서 가장 중요한 문제
// =====================================================================
/** 버전 A — SkipListener. 커밋 후에 호출됩니다. */
public static class CorrectSkipListener implements SkipListener<Order, Settlement> {
private final JdbcTemplate jdbcTemplate;
public CorrectSkipListener(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public void onSkipInProcess(Order item, Throwable t) {
jdbcTemplate.update("""
INSERT INTO s12_bad_order (order_id, phase, reason, occurred_at)
VALUES (?, 'PROCESS', ?, NOW())
""", item.order_id(), t.getMessage());
}
}
/** 버전 B — ItemProcessListener. 트랜잭션 안에서 호출됩니다. */
public static class TransactionalProcessListener
implements ItemProcessListener<Order, Settlement> {
private final JdbcTemplate jdbcTemplate;
public TransactionalProcessListener(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public void onProcessError(Order item, Exception e) {
// 완전히 같은 INSERT 입니다. 그런데 결과가 다릅니다.
jdbcTemplate.update("""
INSERT INTO s12_bad_order (order_id, phase, reason, occurred_at)
VALUES (?, 'PROCESS', ?, NOW())
""", item.order_id(), e.getMessage());
}
}
/*
* (b) 행 수
*
* 버전 A (SkipListener) : **100 건**
* 버전 B (onProcessError) : **0 건**
*
* 같은 SQL, 같은 데이터, 같은 실행. 결과는 100 대 0 입니다.
*
* (c) 왜 다른가 — 트랜잭션 경계
*
* ItemProcessListener.onProcessError 는 **청크 트랜잭션 안**에서
* 호출됩니다. 그리고 그 직후에 무슨 일이 벌어집니까?
*
* 처리 중 예외 발생
* → onProcessError 호출 (INSERT 실행됨)
* → 청크 트랜잭션 **롤백**
* → 방금 한 INSERT 도 함께 롤백
* → 스캔 모드로 아이템 1건씩 재처리 (Step 11-5)
* → 다시 onProcessError 호출 → 다시 INSERT → 다시 롤백
*
* 기록하려던 행이 매번 롤백됩니다. 최종적으로 0건입니다.
*
* ⚠️ 가장 고약한 점: **에러가 나지 않습니다.**
* INSERT 는 성공했고, 롤백도 정상 동작입니다.
* 로그를 보면 onProcessError 가 호출된 흔적이 남아 있습니다.
* 그런데 테이블은 비어 있습니다.
* "분명히 기록하는 코드를 썼는데 왜 없지?"에서 몇 시간이 갑니다.
*
* SkipListener 는 **커밋 후**에 호출됩니다.
*
* 청크 커밋 완료
* → SkipListener.onSkipInProcess 호출 (INSERT 실행됨)
* → 이 INSERT 는 청크 트랜잭션 밖이므로 독립적으로 커밋됨
*
* 그래서 100건이 온전히 남습니다.
*
* ⚠️ 한 가지 더: SkipListener 는 **최종 skip 확정 시 한 번만**
* 호출됩니다. 스캔 모드로 여러 번 재처리되어도 기록은 1건입니다.
* onProcessError 였다면 (롤백되지 않았다고 가정해도) 재시도
* 횟수만큼 중복 기록됐을 것입니다.
*
* (d) 그렇다면 onProcessError 는 언제 쓰는가
*
* **롤백돼도 상관없는 것에만** 씁니다. 실질적으로는 로그 출력입니다.
*
* public void onProcessError(Order item, Exception e) {
* log.warn("처리 실패: order_id={}, {}", item.order_id(), e.getMessage());
* }
*
* 로그는 트랜잭션의 지배를 받지 않습니다(파일/콘솔로 나가니까).
* 그래서 롤백돼도 남습니다. 오히려 재시도마다 찍히므로
* "몇 번 재시도했는지"를 볼 수 있어 진단에 유용합니다.
*
* 정리:
* | 목적 | 쓸 곳 |
* |---|---|
* | 콘솔/파일 로그 | onProcessError (트랜잭션 무관) |
* | **DB 기록** | **SkipListener** (커밋 후) |
* | 외부 알림 | SkipListener 또는 afterStep |
* | 메트릭 카운터 | SkipListener (중복 집계 방지) |
*
* 일반 원칙: **부수 효과가 영속적이어야 하면 트랜잭션 밖에서 하십시오.**
*/
// =====================================================================
// 정답 4. afterChunk 예외 — 데이터는 남고 Step 만 실패
// =====================================================================
/*
* (a)(b) 예측과 실제 — 정확히 일치합니다
*
* settlement 행 수 : 34,000
* Step STATUS : FAILED
* COMMIT_COUNT : 34
* ROLLBACK_COUNT : 1
* READ_COUNT : 35,000
* WRITE_COUNT : 34,000
*
* +-----------------+--------+------------+-------------+--------+--------+
* | STEP_NAME | STATUS | READ_COUNT | WRITE_COUNT | COMMIT | ROLLBK |
* +-----------------+--------+------------+-------------+--------+--------+
* | settlementStep | FAILED | 35000 | 34000 | 34 | 1 |
* +-----------------+--------+------------+-------------+--------+--------+
*
* (c) 왜 모순이 아닌가
*
* "데이터는 남았는데 Step 은 실패"는 **청크 단위 커밋의 당연한
* 귀결**입니다. 모순이 아닙니다.
*
* Spring Batch 의 트랜잭션 단위는 Step 전체가 아니라 **청크 하나**
* 입니다(Step 05). 그래서 이렇게 됩니다.
*
* 청크 1~34 : 각각 독립적으로 커밋됨 → 34,000행 영구 저장
* 청크 35 : afterChunk 에서 예외 → 롤백 → 1,000행 사라짐
* 청크 36~70 : 아예 시도되지 않음
* Step : 예외가 전파되어 FAILED
*
* 만약 Step 전체가 하나의 트랜잭션이었다면 70,000행이 통째로
* 롤백됐을 것입니다. 그런데 그러면 7시간짜리 배치가 6시간 59분에
* 실패했을 때 전부를 다시 해야 합니다. 청크 단위 커밋은 바로
* 그것을 피하기 위한 설계입니다.
*
* ⚠️ 그래서 "Step 이 FAILED 면 데이터가 없다"고 가정하면 안 됩니다.
* 부분적으로 커밋된 데이터가 반드시 있습니다.
* 정산처럼 멱등하지 않은 작업이라면, 재실행 전에 이 부분
* 데이터를 어떻게 할지 반드시 정해 두어야 합니다.
*
* (d) 재실행하면 — 34,000건 다음부터 이어집니다
*
* 직전 실행이 FAILED 이므로 같은 파라미터로 재실행이 가능하고,
* 같은 JobInstance 에 새 JobExecution 이 붙습니다(Step 11-10).
*
* Reader 가 ExecutionContext 에 저장해 둔 위치(34,000)를 복원하므로
* 34,001번째부터 읽습니다.
*
* +------------------+-----------+------------+-------------+--------+
* | JOB_EXECUTION_ID | STATUS | READ_COUNT | WRITE_COUNT | COMMIT |
* +------------------+-----------+------------+-------------+--------+
* | 1 | FAILED | 35000 | 34000 | 34 |
* | 2 | COMPLETED | 36000 | 35900 | 36 |
* +------------------+-----------+------------+-------------+--------+
*
* 2회차의 READ_COUNT 36,000 = 70,000 - 34,000 입니다.
* settlement 최종 행 수는 34,000 + 35,900 = 69,900 (불량 100건 제외).
*
* ⚠️ 단, BrokenChunkListener 를 그대로 두면 재실행에서도 35번째
* 청크(이번엔 전체 기준 69번째)에서 또 터집니다. 재시작 실습을
* 할 때는 리스너를 빼거나 카운터 조건을 바꾸십시오.
*/
// =====================================================================
// 정답 5. 리스너 실행 순서
// =====================================================================
/*
* (a) 관찰된 순서
*
* beforeStep 순서: A → B → C (등록 순)
* afterStep 순서: A → B → C (등록 순, **역순 아님**)
*
* 실제 로그:
* INFO ... c.e.b.step12.ListenerA : [A] beforeStep
* INFO ... c.e.b.step12.ListenerB : [B] beforeStep
* INFO ... c.e.b.step12.ListenerC : [C] beforeStep
* INFO ... o.s.batch.core.step.AbstractStep : Step: [...] executed in 6s 118ms
* INFO ... c.e.b.step12.ListenerA : [A] afterStep
* INFO ... c.e.b.step12.ListenerB : [B] afterStep
* INFO ... c.e.b.step12.ListenerC : [C] afterStep
*
* (b) 예상과 다를 수 있습니다
*
* 서블릿 필터, 스프링 인터셉터, AOP 어라운드 어드바이스에 익숙하면
* "before 는 정순, after 는 역순"을 기대하게 됩니다. 양파 껍질처럼요.
*
* CompositeStepExecutionListener 는 **양쪽 다 정순**입니다.
* 내부적으로 그냥 List 를 순회하며 호출할 뿐이고, after 용으로
* 리스트를 뒤집지 않습니다.
*
* 즉 리스너는 "감싸는" 구조가 아니라 "나열되는" 구조입니다.
*/
/** (c) Ordered 로 순서 뒤집기 — 값이 작을수록 먼저 호출됩니다. */
public static class OrderedListenerC implements StepExecutionListener, Ordered {
private static final Logger log = LoggerFactory.getLogger(OrderedListenerC.class);
@Override
public void beforeStep(StepExecution stepExecution) {
log.info("[C] beforeStep");
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("[C] afterStep");
return stepExecution.getExitStatus(); // 절대 COMPLETED 를 하드코딩하지 말 것
}
@Override
public int getOrder() {
return 1; // A(3), B(2), C(1) → C, B, A 순
}
}
/*
* (d) ⚠️ 순서를 제어할 수 있다는 것과, 순서에 의존해도 된다는 것은 다릅니다
*
* 순서 의존이 위험한 이유 네 가지:
*
* 1. **등록 지점이 흩어집니다.**
* 리스너는 StepBuilder, JobBuilder, @Bean 자동 등록 등 여러
* 경로로 들어옵니다. 누군가 리스너를 하나 추가하면서 순서가
* 바뀌어도 컴파일러는 아무 말을 안 합니다.
*
* 2. **Ordered 와 등록 순이 섞이면 예측이 어렵습니다.**
* Ordered 를 구현한 것과 안 한 것이 섞이면, 안 한 쪽은
* LOWEST_PRECEDENCE 로 취급되어 뒤로 밀립니다.
* 일부만 Ordered 를 붙이는 순간 순서가 직관과 어긋납니다.
*
* 3. **실패 시 나머지가 호출되지 않습니다.**
* A 가 예외를 던지면 B, C 의 afterStep 은 호출되지 않습니다.
* "A 가 연 자원을 C 가 닫는" 구조였다면 자원이 샙니다.
*
* 4. **테스트가 어렵습니다.**
* 리스너 하나만 떼어 단위 테스트할 수 없게 됩니다.
*
* 원칙: **리스너는 서로 독립적이어야 합니다.**
* 각 리스너가 자기 일만 하고, 다른 리스너의 존재나 순서를
* 가정하지 않아야 합니다.
*
* 순서에 의존하는 로직이 필요하다면, 그건 리스너가 아니라
* **별도의 Step** 으로 만들어야 한다는 신호입니다.
* Step 은 순서가 명시적이고(`.next()`), 재시작·모니터링·트랜잭션이
* 전부 프레임워크의 보호를 받습니다.
*/
// =====================================================================
// 정답 6. Item 레벨 리스너의 성능 영향과 로깅 전략
// =====================================================================
/*
* (a) 측정표
*
* | # | 구성 | 소요 | 배수 |
* |---|---|---|---|
* | ① | 리스너 없음 | 6.108초 | 1.00배 |
* | ② | afterRead 에 log.debug (레벨 INFO) | 6.402초 | 1.05배 |
* | ③ | afterRead 에 log.info | 27.310초 | **4.47배** |
* | ④ | afterRead 에 log.info + 문자열 concat | 31.884초 | 5.22배 |
*
* (b) ②와 ③의 차이 — 약 21초
*
* ②는 로그가 **출력되지 않습니다.** 레벨이 INFO 인데 debug 로
* 찍으려 했으니 `log.debug(...)` 호출은 내부에서 `isDebugEnabled()`
* 검사 후 즉시 반환합니다. 남는 비용은 메서드 호출 70,000번뿐이고,
* 이건 JIT 가 거의 없애 줍니다. 그래서 5% 오버헤드입니다.
*
* ③은 실제로 70,000줄을 출력합니다. 줄마다
* - 문자열 포매팅 ({} 치환)
* - 타임스탬프·스레드명·로거명 렌더링
* - 콘솔/파일 I/O (동기)
* 가 일어납니다. 줄당 0.3ms 만 잡아도 21초입니다.
*
* ⚠️ 정산 작업 자체(6.1초)보다 **로깅이 3배 이상 오래 걸립니다.**
* 배치가 일을 하는 게 아니라 로그를 쓰고 있습니다.
*
* (c) ③과 ④의 차이 — 약 4.6초
*
* SLF4J 의 `{}` 플레이스홀더는 **로그 레벨이 켜져 있을 때만**
* 문자열을 만듭니다.
*
* log.info(">>> 읽음: order_id={}", item.order_id()); // 지연 평가
* log.info(">>> 읽음: " + item.order_id()); // 즉시 평가
*
* 두 번째는 **로그 레벨과 무관하게** 매번 문자열 연결과
* Long → String 박싱이 일어납니다. 70,000번의 불필요한
* 객체 생성이 GC 압박으로 이어집니다.
*
* ⚠️ 이 차이는 레벨이 꺼져 있을 때 훨씬 극적입니다.
* log.debug("..." + x) 는 레벨이 꺼져 있어도 문자열을 만듭니다.
* 즉 ②의 5% 오버헤드가 concat 을 쓰는 순간 수십 %로 뜁니다.
* **{} 를 쓰는 습관이 배치에서는 성능 문제입니다.**
*
* (d) ⚠️ 운영 함정 — 로그 레벨을 올리는 순간
*
* ②처럼 log.debug 로 짜 두면 평소에는 안전합니다(1.05배).
* 그래서 "디버그 로그를 넉넉히 넣어 두자"는 판단이 나옵니다.
*
* 그런데 장애가 나서 조사하려고 로그 레벨을 DEBUG 로 올리면?
*
* logging.level.com.example.batch: DEBUG
*
* **6.1초짜리 배치가 27초가 됩니다.** 그리고 이건 70,000건일 때
* 얘기입니다. 700만 건짜리 운영 배치라면 10분이 45분이 됩니다.
* 야간 배치 윈도를 넘겨 아침 서비스에 영향을 줍니다.
*
* 즉 **장애를 조사하려는 행위가 더 큰 장애를 만듭니다.**
* 그리고 이때 원인을 짐작하기 어렵습니다. 코드는 안 바뀌었고
* 설정 한 줄만 바꿨을 뿐이니까요.
*
* 방어책:
* - Item 레벨 리스너에는 정상 경로 로그를 아예 두지 않는다.
* - 로그 레벨을 패키지 단위로 넓게 올리지 않는다.
* com.example.batch 전체가 아니라 특정 클래스만.
* - 비동기 Appender(logback AsyncAppender)를 쓴다.
* I/O 를 별도 스레드로 빼면 오버헤드가 크게 줄어듭니다.
*
* (e) 배치에서의 안전한 로깅 전략 — 세 줄
*
* 1. **아이템 단위 정상 경로에는 로그를 걸지 않는다.**
* Item 레벨 리스너에는 에러 콜백(onReadError, onProcessError,
* onWriteError)만 둡니다. 에러는 드물게 나므로 안전합니다.
*
* 2. **진행률은 청크 단위로, 그것도 N개마다 찍는다.**
* ChunkListener.afterChunk 에서 `if (n % 10 == 0)`.
* 70,000번이 아니라 7번입니다. 오버헤드가 0에 수렴합니다.
*
* 3. **문자열 연결 대신 {} 를 쓰고, 요약은 afterStep 에서 한 번만.**
* 건별 정보가 필요하면 로그가 아니라 DB 테이블에 남깁니다
* (SkipListener → s12_bad_order). 그게 조회도 되고 재처리도 됩니다.
*
* ─────────────────────────────────────────────────────────────
* 요약하면, 배치의 로그는 "건별 추적"이 아니라 "구간별 요약"이어야
* 합니다. 건별 추적이 필요하면 그건 로그의 일이 아니라 데이터의
* 일입니다.
*/
}