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.javaCleanUp.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 종료

이 그림에서 두 가지를 먼저 눈에 담아 두십시오. 이 스텝의 함정 두 개가 정확히 여기서 나옵니다.

  1. ChunkListener.afterChunk()커밋 전입니다. 여기서 하는 DB 작업은 청크 트랜잭션에 포함되고, 롤백되면 함께 사라집니다.
  2. 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

beforeJobExecuting step 보다 앞, afterJobcompleted with 보다 입니다.

⚠️ 함정 — afterJob 은 실패해도 호출됩니다. 그리고 그게 핵심입니다 afterJob 은 Job 이 COMPLETEDFAILED항상 호출됩니다. 그래서 알림을 보내기 좋은 자리입니다. 그런데 여기서 흔한 실수가 나옵니다.

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 에 로그를 걸면 배치가 몇 배 느려집니다 afterReadlog.info() 를 걸면 70,000줄이 찍힙니다. 로그 한 줄에 0.3ms 만 잡아도 21초가 추가됩니다. 6.1초짜리 배치가 27초가 됩니다. 파일 I/O 와 문자열 포매팅이 실제 정산 작업보다 오래 걸리는 상황입니다.

실측 비교입니다.

구성소요배수
리스너 없음6.108초1.00배
afterReadlog.debug (레벨 INFO 라 출력 안 됨)6.402초1.05배
afterReadlog.info27.310초4.47배
afterReadlog.info + 문자열 concat31.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 최종 상태비고
beforeJobJob 이 즉시 실패FAILEDStep 이 아예 시작 안 됨
afterJob삼켜짐COMPLETED⚠️ 로그에도 안 남는 경우가 있음
beforeStepStep 실패 → Job 실패FAILED
afterStepStep 실패 → Job 실패FAILED데이터는 이미 커밋됨
beforeChunk청크 실패 → Step 실패FAILED
afterChunk청크 실패, 커밋은 이미 됨FAILED⚠️ 데이터는 남고 Step 만 실패
afterChunkError원래 예외를 가림FAILED⚠️ 진짜 원인을 잃음
ItemReadListener.afterRead읽기 실패로 처리FAILEDskip 대상이면 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
실행 시간 측정StepExecutionListenerMicrometer (Step 14)
불량 데이터 기록SkipListener— (이게 정석)
진행률 로깅ChunkListener (N개마다)
데이터 변환ItemProcessor
데이터 검증ItemProcessor / Validator
아이템별 로깅❌ (너무 느림)에러 콜백만
집계·통계❌ (상태를 가짐)afterStep 에서 SQL 로

💡 실무 팁 — 리스너에 비즈니스 로직을 넣지 마십시오 "정산이 끝나면 포인트도 적립해야 하니까 afterStep 에서 하자"는 유혹이 있습니다. 하지 마십시오.

  • afterStep 은 트랜잭션 밖이라 실패해도 롤백되지 않습니다.
  • 재시작하면 이미 성공한 Step 은 건너뛰므로 포인트 적립이 누락됩니다.
  • 테스트하기 어렵습니다.

별도 Step 으로 만드십시오. 그러면 재시작·모니터링·트랜잭션이 전부 프레임워크의 보호를 받습니다. 리스너는 "관찰"하는 자리이지 "일하는" 자리가 아닙니다.


정리

개념핵심
리스너 레벨Job → Step → Chunk → Item → Skip 5단계
beforeJob/afterJobafterJob실패해도 호출. 상태 분기 필수
afterStep유일하게 리턴값이 있음. ExitStatus 로 흐름 분기 개입
afterStep 함정return ExitStatus.COMPLETED 가 다른 분기를 덮어씀. getExitStatus() 를 반환할 것
afterChunk커밋 전. 여기서 쓴 데이터는 롤백되면 함께 사라짐
SkipListener커밋 후. 불량 데이터 기록의 정석 자리
Item 레벨 리스너아이템마다 호출. log.info 를 걸면 약 4.5배 느려짐
애너테이션 방식시그니처가 틀려도 컴파일되고 조용히 무시됨. 인터페이스가 안전
리스너 예외afterJobSkipListener삼켜짐, 나머지는 Step/Job 을 실패시킴
실행 순서before·after 둘 다 등록 순. 역순 아님
원칙리스너는 관찰하는 자리. 비즈니스 로직은 별도 Step 으로

연습문제

Exercise.java 에 6문제가 있습니다. 정답은 Solution.java. 로그가 실제로 찍히는지 눈으로 확인하세요.

  1. 실패한 Job 에만 알림을 보내는 JobExecutionListener 작성하기
  2. 애너테이션 리스너의 시그니처를 틀리게 만들어 "조용히 무시"되는 것을 재현하기
  3. 불량 주문을 별도 테이블에 기록하는 SkipListener 작성하고, onProcessError 로 했을 때와 비교하기
  4. afterChunk 에서 예외를 던져 "데이터는 남고 Step 만 실패"하는 것을 확인하기
  5. 리스너 세 개를 등록해 실행 순서를 관찰하고, Ordered 로 순서를 뒤집기
  6. 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]ItemLevelListenerlog.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 함정의 전부입니다.
  • 파일 하단 CleanUps12_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.onSkipInProcessItemProcessListener.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문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 들어 있습니다. 풀어 본 뒤에 여세요.

  • 정답 1getStatus() == BatchStatus.FAILED 분기와 함께, getAllFailureExceptions() 로 실패 원인을 알림에 담는 방법을 보여 줍니다. 그리고 알림 전송 자체를 try-catch 로 감싸야 하는 이유 — afterJob 의 예외는 삼켜져서 알림 실패를 아무도 모른다 — 를 12-8 과 연결해 설명합니다.
  • 정답 2 는 세 가지 "조용한 실패" 패턴을 정리합니다. ① 인자 누락 ② 인자 타입 불일치 ③ 애너테이션 오타(@BeforeStep 대신 @Before). 셋 다 컴파일되고 셋 다 무시됩니다. 결론은 "애매하면 인터페이스를 구현하라" 이고, 근거로 컴파일러가 잡아 주는 범위를 표로 비교합니다.
  • 정답 3 이 가장 중요합니다. SkipListener 는 100건을 기록하고 onProcessError0건을 기록합니다. 후자가 청크 트랜잭션 안이라 롤백되기 때문입니다. 여기서 "그럼 onProcessError 는 언제 쓰나?"에 대한 답 — 롤백돼도 상관없는 것, 즉 로그 출력에만 — 까지 정리합니다.
  • 정답 4settlement 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). 그게 조회도 되고 재처리도 됩니다.
     *
     *   ─────────────────────────────────────────────────────────────
     *   요약하면, 배치의 로그는 "건별 추적"이 아니라 "구간별 요약"이어야
     *   합니다. 건별 추적이 필요하면 그건 로그의 일이 아니라 데이터의
     *   일입니다.
     */
}