Step 05 — 청크 지향 처리

학습 목표

  • Reader → Processor → Writer 가 한 청크 안에서 어떤 순서로 호출되는지 정확히 안다
  • Spring Batch 5.x 의 .<I, O>chunk(size, txManager) 시그니처와 4.x 와의 차이를 설명한다
  • 청크 크기 = 커밋 간격 = 트랜잭션 경계임을 로그와 BATCH_STEP_EXECUTION 으로 확인한다
  • 청크 크기 10 / 100 / 1000 / 10000 으로 70,000건 정산 배치를 돌려 실행시간을 실측한다 (U자 곡선)
  • 청크 중간에 예외를 던져 롤백 단위가 청크임을 재현한다
  • 5.x 에서 List<T> 를 대체한 Chunk<T> API 를 다룬다

선행 스텝: Step 04 — Tasklet 예상 소요: 100분


5-0. 실습 준비

Step 04 에서 만든 메타데이터와 settlement 데이터를 지우고 시작합니다.

mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
DELETE FROM BATCH_STEP_EXECUTION;
DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
DELETE FROM BATCH_JOB_EXECUTION;
DELETE FROM BATCH_JOB_INSTANCE;
SET FOREIGN_KEY_CHECKS = 1;
TRUNCATE TABLE settlement;
SELECT COUNT(*) AS orders_completed FROM orders WHERE status = 'COMPLETED';
SELECT COUNT(*) AS settlement_rows FROM settlement;
SQL

결과

+------------------+
| orders_completed |
+------------------+
|            70000 |
+------------------+
+-----------------+
| settlement_rows |
+-----------------+
|               0 |
+-----------------+

70,000건이 이 스텝의 처리 대상입니다. 이 숫자를 기억해 두세요. 청크 크기 1,000이면 정확히 70청크입니다.


5-1. 왜 Tasklet 이 아니라 청크인가

Step 04 의 Taskletexecute() 를 한 번 호출하고 끝납니다. 70,000건을 Tasklet 으로 정산하려면 이렇게 씁니다.

// 이렇게 하면 안 됩니다
public RepeatStatus execute(StepContribution c, ChunkContext ctx) {
    List<Order> all = jdbc.query("SELECT * FROM orders WHERE status='COMPLETED'", mapper);
    List<Settlement> results = all.stream().map(this::settle).toList();
    jdbc.batchUpdate("INSERT INTO settlement ...", results);
    return RepeatStatus.FINISHED;
}

문제는 세 가지입니다.

문제내용
메모리70,000건을 List 로 전부 들고 있습니다. 700만 건이면 OutOfMemoryError 입니다
트랜잭션전체가 하나의 트랜잭션입니다. 69,999번째에서 실패하면 처음부터 다시
재시작어디까지 처리했는지 기록이 없습니다. 실패 = 전량 재처리

청크 지향 처리(chunk-oriented processing) 는 이 셋을 한 번에 해결합니다. "N건씩 끊어 읽고, N건씩 처리하고, N건씩 쓰고, 커밋한다"를 반복합니다. 메모리에는 항상 N건만 있고, 트랜잭션도 N건 단위이며, 몇 번째 청크까지 커밋했는지가 메타데이터에 남습니다.

💡 판단 기준 처리할 항목이 셀 수 있는 단위로 반복되면 청크, 아니면 Tasklet 입니다. "테이블 truncate", "파일 이동", "인덱스 재생성" 은 반복 단위가 없으므로 Tasklet 이고, "주문 70,000건 정산" 은 청크입니다.


5-2. 한 청크 안에서 실제로 일어나는 일

여기가 이 스텝에서 가장 많이 오해받는 부분입니다. 흔히들 "read 하고 process 하고 write 를 아이템마다 반복한다"고 생각하는데, 틀립니다.

Spring Batch 는 먼저 N건을 전부 읽고, 그다음 N건을 전부 처리하고, 마지막에 한 번만 write 합니다.

  ┌──────────────── 청크 1개 = 트랜잭션 1개 ─────────────────────────┐
  │                                                                 │
  │  ① ChunkProvider: read() 를 N번 반복해서 먼저 다 모은다          │
  │                                                                 │
  │     read() ──▶ item 1                                           │
  │     read() ──▶ item 2                                           │
  │       ...              ──────▶  List<I>  (메모리에 N개 상주)     │
  │     read() ──▶ item N                                           │
  │     (N = chunkSize. 도중에 null 이 나오면 거기서 멈춘다)          │
  │                                                                 │
  │  ② ChunkProcessor: 모인 N개를 하나씩 process() 한다              │
  │                                                                 │
  │     process(item 1) ──▶ out 1                                   │
  │     process(item 2) ──▶ out 2   ──────▶  Chunk<O>               │
  │       ...                                                       │
  │     process(item N) ──▶ out N   (null 반환 시 필터링됨)          │
  │                                                                 │
  │  ③ ItemWriter: write(Chunk<O>) 를 딱 한 번 호출한다              │
  │                                                                 │
  │     write(chunk)  ──▶  INSERT 1,000행 한 방에                    │
  │                                                                 │
  └────────────────────────────┬────────────────────────────────────┘
                             COMMIT


              read() 가 null 을 줄 때까지 ①부터 반복

이 순서가 왜 중요한지 세 가지로 정리합니다.

  1. 메모리 사용량은 chunkSize 에 비례합니다. 정확히는 입력 N개 + 출력 N개가 동시에 살아 있습니다. 청크 10,000이면 20,000개 객체가 힙에 있습니다.
  2. write() 는 청크당 딱 한 번 호출됩니다. 그래서 JdbcBatchItemWriter 가 1,000건을 하나의 배치 INSERT 로 묶을 수 있습니다.
  3. process() 는 다른 아이템을 볼 수 없습니다. 인자로 아이템 하나만 받기 때문입니다. "이번 청크의 합계"같은 걸 processor 에서 계산하려는 시도는 여기서 막힙니다.

⚠️ 함정 — Processor 는 청크 전체를 볼 수 없습니다 ItemProcessor<I, O> 의 시그니처는 O process(I item) 입니다. 아이템 하나만 받습니다. "같은 고객의 주문을 묶어서 합산" 같은 집계를 processor 안에서 하려고 필드에 Map 을 두고 누적하는 코드를 종종 봅니다. 이건 청크 경계에서 조용히 깨집니다. 청크 1의 마지막 아이템과 청크 2의 첫 아이템이 같은 고객이면, 청크 1 커밋 시점에 그 고객의 합계는 아직 미완성인 채로 쓰여집니다. 게다가 멀티스레드 Step(Step 13)에서는 그 Map 이 스레드 간에 공유되어 계산 결과가 실행할 때마다 달라집니다. 예외는 나지 않습니다. 숫자만 틀립니다. 집계가 필요하면 SQL 의 GROUP BY 로 reader 단계에서 미리 묶거나, 별도 Step 으로 분리하세요.


5-3. .<I, O>chunk(size, txManager) — 5.x 시그니처

Spring Batch 5.1.1 기준의 청크 Step 정의입니다.

@Bean
public Step settlementStep(JobRepository jobRepository,
                           PlatformTransactionManager txManager,
                           ItemReader<Order> orderReader,
                           ItemProcessor<Order, Settlement> settlementProcessor,
                           ItemWriter<Settlement> settlementWriter) {
    return new StepBuilder("settlementStep", jobRepository)
            .<Order, Settlement>chunk(1000, txManager)   // ← 5.x: 트랜잭션 매니저가 인자
            .reader(orderReader)
            .processor(settlementProcessor)
            .writer(settlementWriter)
            .build();
}

.<Order, Settlement>chunk(...) 의 타입 파라미터 두 개가 각각 입력 타입 I출력 타입 O 입니다. readerI 를 내고, processorI → O 로 바꾸고, writerO 를 받습니다.

4.x 와의 차이는 다음과 같습니다.

4.x5.x (5.0 이상)
Step 생성stepBuilderFactory.get("name")new StepBuilder("name", jobRepository)
청크 선언.chunk(1000).chunk(1000, txManager)
트랜잭션 매니저StepBuilderFactory 가 주입인자로 명시
Writer 시그니처write(List<? extends T>)write(Chunk<? extends T>)
@EnableBatchProcessing필수선택 (Boot 자동설정)

⚠️ 함정 — .chunk(1000) 만 쓰면 컴파일은 되는데 런타임에 죽습니다 5.x 에도 인자 하나짜리 chunk(int) 오버로드가 남아 있습니다. SimpleStepBuilder 를 직접 만들어 쓰는 경로 때문입니다. 그래서 인터넷의 4.x 예제를 복사해 .chunk(1000) 이라고 쓰면 컴파일 에러가 안 납니다. 대신 Step 이 만들어질 때 이렇게 터집니다.

java.lang.IllegalStateException: A transaction manager must be provided

.chunk(size, txManager) 2인자 형태를 쓰세요. 컴파일이 통과했다고 5.x 로 마이그레이션이 끝난 게 아닙니다.

chunk() 에는 완료 정책(CompletionPolicy)을 넘기는 오버로드도 있습니다.

// "1,000건을 모으거나, 3초가 지나면 커밋" — 둘 중 먼저 오는 쪽
.<Order, Settlement>chunk(new SimpleCompletionPolicy(1000), txManager)

// 청크 크기를 고정하지 않고 정책으로 위임
.<Order, Settlement>chunk(new TimeoutTerminationPolicy(3000), txManager)

실무에서는 chunk(int, txManager) 로 충분합니다. CompletionPolicy 는 "아이템 크기가 들쭉날쭉해서 건수로는 메모리를 예측할 수 없을 때"(예: 첨부파일 포함 메시지) 씁니다.


5-4. 청크 크기 = 커밋 간격

정산 Job 을 청크 1,000으로 돌립니다.

./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob chunkSize=1000'

결과

INFO 43128 --- [           main] c.e.batch.BatchLabApplication            : Started BatchLabApplication in 1.913 seconds (process running for 2.204)
INFO 43128 --- [           main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=settlementChunkJob]] launched with the following parameters: [{'chunkSize':'{value=1000, type=class java.lang.Long, identifying=true}'}]
INFO 43128 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [settlementStep]
DEBUG 43128 --- [           main] o.s.b.c.step.item.ChunkOrientedTasklet   : Applying contribution: [StepContribution: read=1000, written=0, filtered=0, readSkips=0, writeSkips=0, processSkips=0, exitStatus=EXECUTING]
DEBUG 43128 --- [           main] o.s.b.c.step.item.ChunkOrientedTasklet   : Applying contribution: [StepContribution: read=1000, written=0, filtered=0, readSkips=0, writeSkips=0, processSkips=0, exitStatus=EXECUTING]
DEBUG 43128 --- [           main] o.s.b.c.step.item.ChunkOrientedTasklet   : Applying contribution: [StepContribution: read=1000, written=0, filtered=0, readSkips=0, writeSkips=0, processSkips=0, exitStatus=EXECUTING]
...
DEBUG 43128 --- [           main] o.s.b.c.step.item.ChunkOrientedTasklet   : Applying contribution: [StepContribution: read=0, written=0, filtered=0, readSkips=0, writeSkips=0, processSkips=0, exitStatus=EXECUTING]
INFO 43128 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [settlementStep] executed in 6s101ms
INFO 43128 --- [           main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=settlementChunkJob]] completed with the following parameters: [{'chunkSize':'{value=1000, type=class java.lang.Long, identifying=true}'}] and the following status: [COMPLETED] in 6s244ms

BUILD SUCCESSFUL in 9s

Applying contribution 로그가 청크 하나가 끝날 때마다 한 줄씩 나옵니다. read=1000 이 청크 크기입니다. 이 로그는 application.yml 의 다음 설정으로 켜져 있습니다.

logging:
  level:
    org.springframework.batch.core.step.item.ChunkOrientedTasklet: DEBUG

메타데이터를 봅니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STEP_NAME, STATUS, READ_COUNT, WRITE_COUNT, COMMIT_COUNT, ROLLBACK_COUNT
FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 1;"

결과

+-----------------+-----------+------------+-------------+--------------+----------------+
| STEP_NAME       | STATUS    | READ_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT |
+-----------------+-----------+------------+-------------+--------------+----------------+
| settlementStep  | COMPLETED |      70000 |       70000 |           71 |              0 |
+-----------------+-----------+------------+-------------+--------------+----------------+

70,000건 / 1,000 = 70청크인데 COMMIT_COUNT71 입니다.

💡 COMMIT_COUNT 가 항상 하나 더 많은 이유 Spring Batch 는 reader 가 null 을 반환해야 "끝났다"는 것을 압니다. 70번째 청크가 70,000번째 아이템을 읽어도, 프레임워크는 아직 데이터가 남았는지 모릅니다. 그래서 71번째 사이클을 한 번 더 돌려서 read() 를 호출하고, null 을 받고, 빈 청크로 종료합니다. 위 로그 마지막의 read=0 이 그 사이클입니다. 이 빈 트랜잭션도 커밋되므로 COMMIT_COUNT 가 1 늘어납니다. ceil(전체건수 / 청크크기) + 1 이 일반식입니다. 70,000건을 청크 700으로 돌리면 100 + 1 = 101 입니다. 커밋 횟수로 청크 수를 역산할 때 이 +1 을 잊지 마세요.

결과 데이터도 확인합니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT COUNT(*) rows_written, SUM(gross_amount) gross, SUM(fee_amount) fee, SUM(net_amount) net
FROM settlement;"

결과

+--------------+---------------+---------------+---------------+
| rows_written | gross         | fee           | net           |
+--------------+---------------+---------------+---------------+
|        70000 | 3485250000.00 |   95871240.50 | 3389378759.50 |
+--------------+---------------+---------------+---------------+

gross프로젝트 셋업 에서 확인한 COMPLETED 주문 총액 3485250000.00 과 정확히 일치합니다. fee + net = gross 도 맞습니다.


5-5. 청크 하나가 곧 트랜잭션 하나

"청크 = 트랜잭션"을 눈으로 확인합니다. 청크 크기를 10,000으로 키우고, Job 을 돌리는 도중에 다른 터미널에서 settlement 를 세어 봅니다.

먼저 터미널 A 에서 배치를 띄웁니다. (writer 에 200ms 슬립을 넣어 관찰 시간을 법니다 — Practice.javaslowWriter 참고)

./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob chunkSize=10000 slow=true'

터미널 B 에서 1초마다 세어 봅니다.

for i in $(seq 1 8); do
  mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -N -B -e "SELECT NOW(), COUNT(*) FROM settlement;"
  sleep 1
done

결과

2026-07-20 14:02:11	0
2026-07-20 14:02:12	0
2026-07-20 14:02:13	10000
2026-07-20 14:02:14	10000
2026-07-20 14:02:15	20000
2026-07-20 14:02:16	20000
2026-07-20 14:02:17	30000
2026-07-20 14:02:18	30000

0 → 10000 → 20000 → 30000. 중간값이 절대 안 보입니다. 3,742 같은 숫자는 나오지 않습니다.

이게 트랜잭션입니다. writer 가 10,000건을 INSERT 하는 동안 그 행들은 커밋되지 않은 상태라 다른 세션에서 보이지 않고, 커밋되는 순간 10,000건이 한꺼번에 나타납니다.

💡 실무 팁 — 이 성질이 곧 "배치 중간 상태를 조회하면 안 되는 이유"입니다 배치가 도는 중에 대시보드에서 settlement 를 집계하면, 진행률이 청크 단위로 계단식으로 뜁니다. 청크가 10,000이면 10,000 단위로 튑니다. "실시간 진행률"이 필요하면 settlement 를 세지 말고 BATCH_STEP_EXECUTION.WRITE_COUNT 를 조회하세요. Spring Batch 가 같은 트랜잭션 안에서 갱신하므로 항상 일관됩니다. 이 메타데이터 갱신이 업무 데이터와 원자적이려면 같은 DataSource 여야 한다는 게 프로젝트 셋업 에서 언급한 트레이드오프입니다.


5-6. 청크 크기 실측 — 10 / 100 / 1000 / 10000

이 스텝의 핵심 측정입니다. 동일한 70,000건 정산 배치를 청크 크기만 바꿔 네 번 돌립니다.

측정 조건을 고정합니다.

  • Reader: JdbcCursorItemReader, fetchSize 는 청크 크기와 동일하게 설정
  • Writer: JdbcBatchItemWriter, JDBC URL 에 rewriteBatchedStatements=true
  • 매 실행 전 TRUNCATE TABLE settlement + 메타데이터 초기화
  • JVM: -Xms256m -Xmx1g, MySQL innodb_buffer_pool_size=512M
  • 각 크기마다 3회 실행 후 중앙값
for SIZE in 10 100 1000 10000; do
  mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb -e "TRUNCATE TABLE settlement;"
  mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb < ~/batch-reset.sql
  echo "=== chunkSize=$SIZE ==="
  ./gradlew bootRun --args="--spring.batch.job.name=settlementChunkJob chunkSize=$SIZE" \
    | grep -E 'executed in|status:'
done

결과

=== chunkSize=10 ===
INFO --- o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 48s217ms
INFO --- o.s.b.c.l.s.TaskExecutorJobLauncher : ... and the following status: [COMPLETED] in 48s390ms
=== chunkSize=100 ===
INFO --- o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 12s704ms
INFO --- o.s.b.c.l.s.TaskExecutorJobLauncher : ... and the following status: [COMPLETED] in 12s866ms
=== chunkSize=1000 ===
INFO --- o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 6s101ms
INFO --- o.s.b.c.l.s.TaskExecutorJobLauncher : ... and the following status: [COMPLETED] in 6s244ms
=== chunkSize=10000 ===
INFO --- o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 7s436ms
INFO --- o.s.b.c.l.s.TaskExecutorJobLauncher : ... and the following status: [COMPLETED] in 7s598ms

정리하면 이렇습니다.

청크 크기청크 수COMMIT_COUNT실행시간처리량(건/초)힙 peak배치 INSERT 왕복
107,0007,00148.2초1,452210 MB7,000회
10070070112.7초5,512235 MB700회
100070716.1초11,475288 MB70회
10000787.4초9,409612 MB7회

청크 10 의 48.2초 → 청크 1000 의 6.1초. 약 8배 빨라졌습니다. 그런데 10000 에서 다시 7.4초로 느려집니다. 그래프를 그리면 U자입니다.

 실행시간(초)
   50 ┤●  48.2
      │ ╲
   40 ┤  ╲
      │   ╲
   30 ┤    ╲
      │     ╲
   20 ┤      ╲
      │       ●  12.7
   10 ┤        ╲___
      │            ●───────●  7.4
    0 ┤            6.1
      └──┬────┬─────┬──────┬──
         10  100  1000  10000   ← 청크 크기 (로그 스케일)

      ← 커밋 오버헤드 지배        메모리·GC 지배 →
                    최적 구간

왼쪽 내리막 — 커밋 오버헤드

청크 10 이 느린 이유는 7,000번 커밋하기 때문입니다. 커밋 한 번마다 다음이 일어납니다.

비용내용
DB 트랜잭션 커밋InnoDB redo 로그 flush (innodb_flush_log_at_trx_commit=1 이면 fsync)
메타데이터 UPDATEBATCH_STEP_EXECUTION 의 카운터 갱신 1회
실행 컨텍스트 저장BATCH_STEP_EXECUTION_CONTEXT 갱신 (reader 상태)
배치 INSERT 왕복10건짜리 INSERT 를 7,000번

청크당 고정 비용이 있고, 청크 수가 곧 그 비용의 배수입니다. 7,000 × 고정비용 vs 70 × 고정비용의 차이가 48초와 6초를 가릅니다.

메타데이터 갱신 부하만 따로 보면 이렇습니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT COMMIT_COUNT,
       ROUND(TIMESTAMPDIFF(MICROSECOND, START_TIME, END_TIME)/1000000, 2) AS secs
FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID;"

결과

+--------------+-------+
| COMMIT_COUNT | secs  |
+--------------+-------+
|         7001 | 48.22 |
|          701 | 12.70 |
|           71 |  6.10 |
|            8 |  7.44 |
+--------------+-------+

7,001 → 701 로 커밋을 1/10 로 줄이자 시간이 48.2 → 12.7 로 거의 1/4 이 됐습니다. 커밋 비용이 전부는 아니지만(읽기·처리 비용은 그대로) 지배적입니다.

오른쪽 오르막 — 메모리와 GC

청크 10,000 은 커밋을 8번밖에 안 하는데도 느립니다. 힙 peak 를 보면 답이 나옵니다.

./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob chunkSize=10000' \
  -Dorg.gradle.jvmargs='-Xms256m -Xmx1g -Xlog:gc'

결과 (GC 로그 발췌)

[2.104s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 248M->131M(512M) 18.442ms
[3.351s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 431M->208M(768M) 31.208ms
[4.677s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 612M->266M(1024M) 44.913ms
[5.902s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 598M->271M(1024M) 41.775ms

청크 10,000 이면 Order 10,000개 + Settlement 10,000개, 총 20,000개 객체가 동시에 살아 있습니다. BigDecimal 필드가 4개씩 붙으니 실제 객체 수는 그 몇 배입니다. Young 영역을 넘겨 승격되고, GC pause 가 40ms 대로 커집니다.

청크 1,000 에서는 같은 로그가 이렇습니다.

결과

[2.088s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 216M->84M(512M) 6.311ms
[3.412s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 288M->91M(512M) 7.024ms

pause 가 44ms → 7ms 입니다. 커밋을 63번 아낀 이득보다 GC 로 잃은 손해가 더 큽니다.

⚠️ 함정 — "청크를 키우면 무조건 빨라진다"는 착각이 OOM 을 부릅니다 위 실측에서 10,000 은 7.4초로 "조금 느린" 정도였습니다. 힙이 1GB 였기 때문입니다. 그런데 운영에서 아이템이 이 실습보다 훨씬 무거우면(예: order_items 3건을 함께 들고 있는 주문 객체, 또는 TEXT 본문) 이야기가 달라집니다. 청크 10,000 × 아이템 20KB = 200MB 가 청크 하나에 들어갑니다. 여기에 출력 객체까지 더해지면 힙 512MB 컨테이너는 그대로 OutOfMemoryError 입니다. 더 나쁜 건 10만 건 테스트에서는 안 터지고 700만 건 운영에서 터진다는 점입니다. 청크 크기는 아이템 하나의 크기와 함께 정해야 합니다. 판단식: chunkSize × (입력 아이템 크기 + 출력 아이템 크기) × 2 가 힙의 1/4 을 넘지 않게.


5-7. 실패하면 청크 단위로 롤백된다

이제 이 스텝에서 가장 중요한 실습입니다. 25,501번째 아이템에서 일부러 예외를 던집니다.

// [5-7] 25,501번째 아이템에서 터지는 processor
static class ExplodingProcessor implements ItemProcessor<Order, Settlement> {

    private final AtomicInteger seq = new AtomicInteger();
    private final SettlementProcessor delegate;

    ExplodingProcessor(SettlementProcessor delegate) {
        this.delegate = delegate;
    }

    @Override
    public Settlement process(Order item) throws Exception {
        int n = seq.incrementAndGet();
        if (n == 25_501) {
            throw new IllegalStateException("의도적 실패: " + n + "번째 아이템 (order_id=" + item.order_id() + ")");
        }
        return delegate.process(item);
    }
}

청크 1,000 으로 돌립니다. 25,501번째는 26번째 청크의 501번째 아이템입니다.

mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb -e "TRUNCATE TABLE settlement;"
./gradlew bootRun --args='--spring.batch.job.name=settlementFailJob chunkSize=1000'

결과

INFO 43902 --- [           main] o.s.batch.core.job.SimpleStepHandler     : Executing step: [settlementFailStep]
DEBUG 43902 --- [           main] o.s.b.c.step.item.ChunkOrientedTasklet   : Applying contribution: [StepContribution: read=1000, written=0, filtered=0, ...]
...
ERROR 43902 --- [           main] o.s.batch.core.step.AbstractStep         : Encountered an error executing step settlementFailStep in job settlementFailJob

java.lang.IllegalStateException: 의도적 실패: 25501번째 아이템 (order_id=36430)
	at com.example.batch.step05.ExplodingProcessor.process(ExplodingProcessor.java:24)
	at org.springframework.batch.core.step.item.SimpleChunkProcessor.doProcess(SimpleChunkProcessor.java:126)
	at org.springframework.batch.core.step.item.SimpleChunkProcessor.transform(SimpleChunkProcessor.java:296)
	at org.springframework.batch.core.step.item.SimpleChunkProcessor.process(SimpleChunkProcessor.java:213)
	at org.springframework.batch.core.step.item.ChunkOrientedTasklet.execute(ChunkOrientedTasklet.java:75)
	at org.springframework.batch.core.step.tasklet.TaskletStep$ChunkTransactionCallback.doInTransaction(TaskletStep.java:407)
	...

INFO 43902 --- [           main] o.s.batch.core.step.AbstractStep         : Step: [settlementFailStep] executed in 2s488ms
INFO 43902 --- [           main] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=settlementFailJob]] completed with the following parameters: [{...}] and the following status: [FAILED] in 2s601ms

DB 를 확인합니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT COUNT(*) rows_written, MIN(order_id) min_id, MAX(order_id) max_id FROM settlement;"

결과

+--------------+--------+--------+
| rows_written | min_id | max_id |
+--------------+--------+--------+
|        25000 |      1 |  35714 |
+--------------+--------+--------+

정확히 25,000건. 25,501건도, 25,500건도 아닙니다.

  • 청크 1 ~ 25 (아이템 1 ~ 25,000): 커밋됨
  • 청크 26 (아이템 25,001 ~ 26,000): 전부 롤백됨

26번째 청크는 501번째 아이템에서 터졌지만, 앞선 500건도 함께 사라졌습니다. 롤백 단위는 아이템이 아니라 청크이기 때문입니다.

메타데이터도 봅니다.

mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
SELECT STATUS, EXIT_CODE, READ_COUNT, WRITE_COUNT, COMMIT_COUNT, ROLLBACK_COUNT,
       LEFT(EXIT_MESSAGE, 60) AS msg
FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 1\G"

결과

*************************** 1. row ***************************
        STATUS: FAILED
     EXIT_CODE: FAILED
    READ_COUNT: 25000
   WRITE_COUNT: 25000
  COMMIT_COUNT: 25
ROLLBACK_COUNT: 1
           msg: java.lang.IllegalStateException: 의도적 실패: 25501번째

여기서 놓치기 쉬운 게 READ_COUNT: 25000 입니다. 실제로는 26,000건을 읽었습니다. 26번째 청크가 1,000건을 다 읽은 뒤에 처리 단계에서 터졌으니까요.

⚠️ 함정 — 실패한 청크의 READ_COUNT 는 메타데이터에 남지 않습니다 Spring Batch 는 청크를 처리하는 동안 카운터를 StepContribution 이라는 임시 객체에 모았다가, 트랜잭션이 커밋될 때 StepExecution 에 반영(apply)합니다. 롤백되면 그 StepContribution버려집니다. 그래서 실제 읽은 26,000건 중 롤백된 1,000건은 통계에서 사라집니다. 이게 왜 위험하냐면, "읽은 건수 = 처리한 건수"를 검증 로직으로 쓰는 경우입니다. READ_COUNT == WRITE_COUNT 니까 정상이라고 판단하는 후속 Step 을 만들면, 실패한 배치를 성공으로 오인합니다. 위 결과도 25000 == 25000 입니다. 배치 성공 여부는 반드시 STATUS/EXIT_CODE 로 판정하세요. 카운터의 일치는 성공의 근거가 아닙니다.

💡 그럼 25,001번째부터 다시 돌리려면? 같은 JobParameters 로 다시 실행하면 Spring Batch 가 실패한 StepExecution 을 찾아 이어서 실행합니다. 재시작 시점은 reader 가 ExecutionContext 에 저장해 둔 위치입니다. 다만 JdbcCursorItemReader 는 재시작 시 처음부터 다시 읽고 25,000건을 버리는 방식이라 느립니다. 이 차이는 Step 06Step 09 에서 정면으로 다룹니다. settlement.order_id 에 UNIQUE 제약이 걸려 있어서, 재시작이 잘못 동작하면 조용히 중복되는 대신 DuplicateKeyException 으로 시끄럽게 실패합니다.


5-8. Chunk<T> — 5.x 에서 List 가 사라졌다

Spring Batch 5.0 에서 ItemWriter 의 시그니처가 바뀌었습니다.

// 4.x
public interface ItemWriter<T> {
    void write(List<? extends T> items) throws Exception;
}

// 5.x
public interface ItemWriter<T> {
    void write(Chunk<? extends T> chunk) throws Exception;
}

Chunk<T>org.springframework.batch.item.Chunk 이고 Iterable<T> 를 구현합니다. 주요 API 는 이렇습니다.

메서드설명
getItems()내부 List<T> 반환 (수정 가능한 뷰)
size() / isEmpty()아이템 개수
iterator()ChunkIterator — 순회 중 remove() 가능
add(T) / addAll(Collection)아이템 추가
getSkips()이 청크에서 스킵된 아이템 목록 (Step 11)
Chunk.of(T...)정적 팩터리 — 테스트 작성 시 요긴합니다
// [5-8] 5.x writer 구현
static class SettlementLogWriter implements ItemWriter<Settlement> {

    private static final Logger log = LoggerFactory.getLogger(SettlementLogWriter.class);

    @Override
    public void write(Chunk<? extends Settlement> chunk) {
        // Chunk 는 Iterable 이므로 향상된 for 문이 그대로 됩니다
        BigDecimal sum = BigDecimal.ZERO;
        for (Settlement s : chunk) {
            sum = sum.add(s.netAmount());
        }
        // List 가 필요하면 getItems()
        List<? extends Settlement> items = chunk.getItems();
        log.debug("청크 {}건, 첫 주문 {}, 순액 합계 {}",
                chunk.size(), items.get(0).orderId(), sum);
    }
}

결과 (DEBUG 로그)

DEBUG 44017 --- [           main] c.e.b.step05.SettlementLogWriter        : 청크 1000건, 첫 주문 1, 순액 합계 48479322.50
DEBUG 44017 --- [           main] c.e.b.step05.SettlementLogWriter        : 청크 1000건, 첫 주문 1430, 순액 합계 48343095.00
DEBUG 44017 --- [           main] c.e.b.step05.SettlementLogWriter        : 청크 1000건, 첫 주문 2859, 순액 합계 48512648.00

⚠️ 함정 — 4.x 코드를 옮길 때 write(List) 는 컴파일 에러가 아니라 "그냥 안 불립니다" ItemWriter인터페이스로 구현했다면 write(List<...>)@Override 가 붙어 있는 한 컴파일 에러가 납니다. 이건 안전한 경우입니다. 문제는 @Override 를 빼먹었거나, 추상 클래스를 상속해 오버라이드한 척한 경우입니다. 그 메서드는 그냥 이름이 같은 별개의 메서드가 되고, 인터페이스 기본 구현이나 상위 클래스 구현이 대신 호출됩니다. 에러 없이, 아무것도 안 쓰이고, 배치는 COMPLETED 로 끝납니다. WRITE_COUNT 는 70,000 으로 찍히는데 settlement 는 0행인 상황이 이렇게 만들어집니다. 마이그레이션할 때 @Override 를 반드시 붙이세요. 그러면 컴파일러가 대신 잡아 줍니다.

Chunk.of() 는 writer/processor 단위 테스트에서 특히 유용합니다.

// [5-8] 테스트에서 Chunk 만들기
@Test
void writerInsertsAllRows() throws Exception {
    Chunk<Settlement> chunk = Chunk.of(
            new Settlement(1L, 1, LocalDate.of(2025, 1, 1),
                    new BigDecimal("1000.00"), new BigDecimal("0.0300"),
                    new BigDecimal("30.00"), new BigDecimal("970.00")),
            new Settlement(2L, 2, LocalDate.of(2025, 1, 1),
                    new BigDecimal("2000.00"), new BigDecimal("0.0250"),
                    new BigDecimal("50.00"), new BigDecimal("1950.00"))
    );

    writer.write(chunk);

    assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM settlement", Integer.class)).isEqualTo(2);
}

결과

BUILD SUCCESSFUL in 3s
2 tests completed

5-9. 청크 크기 선정 가이드

실측 결과와 위 함정들을 종합하면 이렇습니다.

상황권장 청크 크기이유
기본값500 ~ 1,000대부분의 RDB 배치에서 커밋 비용과 메모리가 균형
아이템이 무거움 (수십 KB 이상)50 ~ 200메모리가 먼저 한계
아이템이 가벼움 (몇 개 컬럼)1,000 ~ 5,000커밋 비용이 지배적
외부 API 호출이 writer 에 있음API 의 벌크 한도에 맞춤예: 벌크 한도 500 이면 500
원격 트랜잭션/XA작게 (100 이하)커밋이 비싸도 실패 범위를 좁히는 게 중요
실패 시 재처리 비용이 큼작게롤백 단위 = 손실 단위
멀티스레드 Step스레드 수 × 청크 크기로 메모리 계산4스레드 × 1,000 = 4,000개 상주

정하는 순서는 이렇습니다.

  1. 메모리 상한을 먼저 잡습니다. chunkSize × (입력 + 출력 아이템 크기) × 2 ≤ 힙 / 4.
  2. 그 상한 안에서 1,000 부터 시작합니다.
  3. 실제 데이터로 10 / 100 / 1000 / 10000 을 측정합니다. 이 스텝에서 한 그대로입니다.
  4. 최적점 근처에서 U자 곡선이 평평하면(위 실측의 1000~10000 구간처럼) 작은 쪽을 고릅니다. 속도가 비슷하다면 롤백 단위가 작은 게 낫습니다.

💡 실무 팁 — 청크 크기를 하드코딩하지 마세요 청크 크기는 데이터 양·아이템 크기·인프라가 바뀌면 함께 바뀌어야 하는 값입니다. 코드에 1000 을 박아 두면 운영에서 조정할 수 없습니다.

@Value("${batch.settlement.chunk-size:1000}") int chunkSize

처럼 설정으로 빼고, application.yml 에 기본값을 둡니다. 그러면 장애 상황에서 재배포 없이 청크를 줄일 수 있습니다. JobParameter 로 받는 것도 방법이지만, 청크 크기는 식별 파라미터가 아니므로 identifying=false 로 넣어야 합니다(Step 03). 안 그러면 청크 크기만 바꿔도 새 JobInstance 가 생깁니다.

💡 fetchSize 도 같이 맞추세요 JdbcCursorItemReaderfetchSizeJDBC 드라이버가 한 번에 가져올 행 수입니다. 청크 크기와 개념이 다릅니다. 기본값(MySQL 은 사실상 전량 또는 1)으로 두면 청크를 아무리 튜닝해도 네트워크 왕복이 병목입니다. 보통 청크 크기와 같게 맞춥니다. 다만 MySQL 커넥터는 fetchSize 를 진짜로 존중하려면 별도 조건이 필요합니다 — Step 06 에서 다룹니다.


정리

개념핵심
청크 지향 처리N건씩 읽고-처리하고-쓰고-커밋을 반복. 메모리·트랜잭션·재시작을 동시에 해결
호출 순서read ×N 을 먼저 다 하고 → process ×N → write 1회. 아이템별 인터리브가 아님
Processor 의 시야아이템 하나. 청크 전체를 볼 수 없음 → 집계는 SQL 이나 별도 Step 으로
5.x 시그니처.<I, O>chunk(size, txManager). 트랜잭션 매니저가 인자
.chunk(1000) 단독컴파일은 되고 런타임에 IllegalStateException: A transaction manager must be provided
청크 크기= 커밋 간격 = 트랜잭션 경계 = 롤백 단위
COMMIT_COUNTceil(건수 / 청크) + 1. 마지막 빈 사이클이 하나 더 커밋
실측 (70,000건)10→48.2s, 100→12.7s, 1000→6.1s, 10000→7.4s. 약 8배 차이, U자 곡선
왼쪽 내리막커밋 오버헤드(redo flush + 메타데이터 UPDATE + 왕복)
오른쪽 오르막힙 사용량과 GC pause (44ms vs 7ms)
롤백 단위청크. 25,501번째에서 실패 → 25,000건만 남고 청크 26 은 통째로 사라짐
실패 청크의 통계StepContribution 이 버려져 READ_COUNT 에 반영 안 됨. 성공 판정은 STATUS
Chunk<T>5.0 부터 ItemWriter.write(Chunk<? extends T>). Iterable, getItems(), Chunk.of()
마이그레이션 사고@Override 없이 write(List) 를 남기면 조용히 아무것도 안 쓰임
크기 선정기본 500~1,000 → 메모리 상한 확인 → 실측 → 평평하면 작은 쪽

연습문제

Exercise.java 에 6문제가 있습니다. 정답은 Solution.java.

  1. 4.x 스타일 Step 정의를 5.x 로 마이그레이션하기
  2. 70,000건 / 청크 700 일 때의 COMMIT_COUNT 예측하고 검증하기
  3. 청크 300 으로 돌려 U자 곡선의 어디쯤인지 실측하기
  4. 특정 아이템에서 실패시켜 settlement 잔존 건수를 미리 계산하고 맞히기
  5. 4.x write(List) writer 를 Chunk 기반으로 고치기
  6. 아이템 크기가 주어졌을 때 안전한 최대 청크 크기 계산하기

다음 단계

청크의 골격을 알았으니 이제 각 구성요소를 하나씩 깊게 봅니다. 먼저 ItemReader 입니다. 이 스텝에서 쓴 JdbcCursorItemReader 는 DB 커넥션을 배치가 끝날 때까지 붙잡고 있어서 멀티스레드에 못 씁니다. 대안인 JdbcPagingItemReader 는 그 문제를 해결하지만, 정렬 키를 잘못 주면 70,000건 중 68,412건만 처리하고도 COMPLETED 로 끝나는 훨씬 무서운 함정이 있습니다. 그 함정을 직접 재현합니다.

Step 06 — ItemReader


실습 파일

이 스텝은 Java 파일 세 개로 진행합니다. Practice.java 를 위에서부터 읽으며 5-2 ~ 5-9 의 모든 실행과 실측을 재현하고, Exercise.java 의 6문제를 직접 풀어 본 뒤, Solution.java 로 대조합니다. 세 파일 모두 com.example.batch.step05 패키지이며, 프로젝트 셋업 에서 만든 spring-batch5-lab 프로젝트 안에 그대로 넣으면 동작합니다.

Practice.java

본문의 모든 예제를 절 번호 주석(// [5-3] 등)과 함께 담은 @Configuration 클래스입니다.

  • 모든 Job 은 @ConditionalOnProperty 없이 정의되어 있고, 실행 대상은 --spring.batch.job.name= 으로 고릅니다. 한 번에 전부 돌지 않도록 반드시 Job 이름을 지정하세요. 지정하지 않으면 Boot 3.2 가 컨텍스트의 모든 Job 을 순차 실행합니다.
  • [5-3]settlementStep 이 기준 Step 입니다. 청크 크기는 @Value("${batch.chunk-size:1000}") 로 주입되므로 --batch.chunk-size=100 처럼 커맨드라인에서 바꿀 수 있고, 5-6 의 4회 측정이 이 한 줄에 기댑니다.
  • feeRateCache 빈이 customers 1,000행의 fee_rateMap<Integer, BigDecimal> 로 한 번만 로드합니다. Processor 가 아이템마다 DB 를 조회하지 않게 하려는 것으로, 5-6 의 실측이 순수하게 청크 크기의 영향만 반영하도록 하기 위한 장치입니다. 이 캐시를 빼고 아이템마다 SELECT 하면 어떤 청크 크기를 써도 70,000번의 왕복이 지배해 U자 곡선이 안 보입니다.
  • [5-5]slowWriter--slow=true 일 때만 청크당 200ms 를 쉽니다. 트랜잭션 경계를 다른 터미널에서 관찰하기 위한 것이며, 이 플래그를 켠 채로 5-6 의 측정을 하면 안 됩니다.
  • [5-7]ExplodingProcessorAtomicInteger 로 25,501번째를 세어 예외를 던집니다. 단일 스레드 Step 이라 카운터가 정확합니다. Step 13 의 멀티스레드 Step 에 그대로 가져가면 25,501번째가 매번 달라집니다.
  • 파일 하단의 [5-8] SettlementLogWriter 는 실제 INSERT 를 하지 않고 로그만 남깁니다. Chunk<T> API 를 관찰하는 용도이므로, 이 writer 를 쓰는 Job 을 돌리면 settlement 는 비어 있는 게 정상입니다.
package com.example.batch.step05;

/*
 * ============================================================================
 * Step 05 — 청크 지향 처리 / 실습 코드
 * ============================================================================
 *
 * 본문 5-2 ~ 5-9 의 모든 예제를 절 번호 주석과 함께 담았습니다.
 *
 * [실행 전 초기화]
 *   mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
 *   SET FOREIGN_KEY_CHECKS = 0;
 *   DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
 *   DELETE FROM BATCH_STEP_EXECUTION;
 *   DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
 *   DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
 *   DELETE FROM BATCH_JOB_EXECUTION;
 *   DELETE FROM BATCH_JOB_INSTANCE;
 *   SET FOREIGN_KEY_CHECKS = 1;
 *   TRUNCATE TABLE settlement;
 *   SQL
 *
 * [실행]
 *   ./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob --batch.chunk-size=1000'
 *
 *   ⚠️ --spring.batch.job.name 을 반드시 지정하세요.
 *      지정하지 않으면 Boot 3.2 가 이 파일의 Job 을 전부 순차 실행합니다.
 *
 * [5-6 실측]
 *   for SIZE in 10 100 1000 10000; do
 *     mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb -e "TRUNCATE TABLE settlement;"
 *     ./gradlew bootRun --args="--spring.batch.job.name=settlementChunkJob --batch.chunk-size=$SIZE"
 *   done
 * ============================================================================
 */

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider;
import org.springframework.batch.item.database.JdbcBatchItemWriter;
import org.springframework.batch.item.database.JdbcCursorItemReader;
import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder;
import org.springframework.batch.repeat.policy.SimpleCompletionPolicy;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.DataClassRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

@Configuration
public class Practice {

    private static final Logger log = LoggerFactory.getLogger(Practice.class);

    // ========================================================================
    // 도메인 — com.example.batch.domain 의 record 를 그대로 옮겨 놓은 것입니다.
    // 실제 프로젝트에서는 domain 패키지의 것을 import 해서 쓰세요.
    // ========================================================================

    public record Order(
            Long order_id,
            Integer customerId,
            BigDecimal amount,
            String status,
            LocalDateTime orderedAt
    ) {}

    public record Settlement(
            Long orderId,
            Integer customerId,
            LocalDate settleDate,
            BigDecimal grossAmount,
            BigDecimal feeRate,
            BigDecimal feeAmount,
            BigDecimal netAmount
    ) {}

    // ========================================================================
    // [5-3] 청크 Step 정의 — Spring Batch 5.1.1 시그니처
    // ========================================================================

    /**
     * 청크 크기는 하드코딩하지 않고 설정으로 뺍니다 (본문 5-9 의 실무 팁).
     * --batch.chunk-size=100 처럼 커맨드라인으로 바꿀 수 있습니다.
     */
    @Value("${batch.chunk-size:1000}")
    private int chunkSize;

    /** 5-5 의 트랜잭션 경계 관찰용. --slow=true 일 때만 켜집니다. */
    @Value("${slow:false}")
    private boolean slow;

    @Bean
    public Step settlementStep(JobRepository jobRepository,
                               PlatformTransactionManager txManager,
                               ItemReader<Order> orderReader,
                               ItemProcessor<Order, Settlement> settlementProcessor,
                               ItemWriter<Settlement> settlementWriter) {

        log.info("settlementStep 생성 — chunkSize={}, slow={}", chunkSize, slow);

        return new StepBuilder("settlementStep", jobRepository)
                // ↓ 5.x 의 핵심. 트랜잭션 매니저가 인자입니다.
                //   4.x 의 .chunk(1000) 만 쓰면 런타임에
                //   IllegalStateException: A transaction manager must be provided
                .<Order, Settlement>chunk(chunkSize, txManager)
                .reader(orderReader)
                .processor(settlementProcessor)
                .writer(settlementWriter)
                .build();
    }

    @Bean
    public Job settlementChunkJob(JobRepository jobRepository, Step settlementStep) {
        return new JobBuilder("settlementChunkJob", jobRepository)
                .start(settlementStep)
                .build();
    }

    /**
     * [5-3] CompletionPolicy 오버로드 — 참고용.
     * "1,000건을 모으거나 정책이 끝났다고 하면 커밋".
     * 아이템 크기가 들쭉날쭉해서 건수로 메모리를 예측할 수 없을 때 씁니다.
     */
    @Bean
    public Step settlementPolicyStep(JobRepository jobRepository,
                                     PlatformTransactionManager txManager,
                                     ItemReader<Order> orderReader,
                                     ItemProcessor<Order, Settlement> settlementProcessor,
                                     ItemWriter<Settlement> settlementWriter) {
        return new StepBuilder("settlementPolicyStep", jobRepository)
                .<Order, Settlement>chunk(new SimpleCompletionPolicy(chunkSize), txManager)
                .reader(orderReader)
                .processor(settlementProcessor)
                .writer(settlementWriter)
                .build();
    }

    // ========================================================================
    // [5-4] Reader — JdbcCursorItemReader
    //   fetchSize 를 청크 크기와 같게 맞춥니다 (5-9 의 팁).
    //   커서 리더의 한계(커넥션 점유·멀티스레드 불가)는 Step 06 에서 다룹니다.
    // ========================================================================

    @Bean
    public JdbcCursorItemReader<Order> orderReader(DataSource dataSource) {
        return new JdbcCursorItemReaderBuilder<Order>()
                .name("orderReader")
                .dataSource(dataSource)
                .sql("""
                     SELECT order_id, customer_id, amount, status, ordered_at
                     FROM orders
                     WHERE status = 'COMPLETED'
                     ORDER BY order_id
                     """)
                .fetchSize(chunkSize)
                // record 는 자바빈이 아니므로 BeanPropertyRowMapper 가 아니라
                // DataClassRowMapper 를 씁니다. 자세한 이유는 Step 06 에서.
                .rowMapper(new DataClassRowMapper<>(Order.class))
                .build();
    }

    // ========================================================================
    // [5-4] Processor — 수수료율 캐시를 써서 아이템당 DB 조회를 없앱니다.
    //
    //   이 캐시가 없으면 아이템마다 SELECT 가 나가고, 70,000번의 왕복이
    //   전체 시간을 지배해 5-6 의 U자 곡선이 아예 안 보입니다.
    // ========================================================================

    @Bean
    public Map<Integer, BigDecimal> feeRateCache(JdbcTemplate jdbc) {
        Map<Integer, BigDecimal> cache = new HashMap<>();
        jdbc.query("SELECT customer_id, fee_rate FROM customers", rs -> {
            cache.put(rs.getInt("customer_id"), rs.getBigDecimal("fee_rate"));
        });
        log.info("수수료율 캐시 로드 완료 — {}건", cache.size());   // 1000건
        return cache;
    }

    static class SettlementProcessor implements ItemProcessor<Order, Settlement> {

        private final Map<Integer, BigDecimal> feeRates;

        SettlementProcessor(Map<Integer, BigDecimal> feeRates) {
            this.feeRates = feeRates;
        }

        @Override
        public Settlement process(Order item) {
            // ⚠️ 여기서 "이번 청크의 합계" 같은 걸 필드에 누적하면 안 됩니다.
            //    processor 는 아이템 하나만 봅니다 (본문 5-2 의 함정).
            BigDecimal rate = feeRates.get(item.customerId());
            BigDecimal gross = item.amount();
            BigDecimal fee = gross.multiply(rate).setScale(2, RoundingMode.HALF_UP);
            BigDecimal net = gross.subtract(fee);

            return new Settlement(
                    item.order_id(),
                    item.customerId(),
                    item.orderedAt().toLocalDate(),
                    gross,
                    rate,
                    fee,
                    net
            );
        }
    }

    @Bean
    public ItemProcessor<Order, Settlement> settlementProcessor(Map<Integer, BigDecimal> feeRateCache) {
        return new SettlementProcessor(feeRateCache);
    }

    // ========================================================================
    // [5-4] Writer — JdbcBatchItemWriter
    //
    //   ⚠️ record 에는 BeanPropertyItemSqlParameterSourceProvider 가 안 됩니다.
    //      (record 의 접근자는 getOrderId() 가 아니라 orderId() 라서)
    //      그래서 람다로 MapSqlParameterSource 를 직접 만듭니다.
    //      Step 08 에서 정면으로 다룹니다.
    // ========================================================================

    @Bean
    public JdbcBatchItemWriter<Settlement> settlementWriter(DataSource dataSource) {
        JdbcBatchItemWriter<Settlement> writer = new JdbcBatchItemWriterBuilder<Settlement>()
                .dataSource(dataSource)
                .sql("""
                     INSERT INTO settlement
                       (order_id, customer_id, settle_date, gross_amount, fee_rate, fee_amount, net_amount)
                     VALUES
                       (:orderId, :customerId, :settleDate, :grossAmount, :feeRate, :feeAmount, :netAmount)
                     """)
                .itemSqlParameterSourceProvider(s -> new MapSqlParameterSource()
                        .addValue("orderId", s.orderId())
                        .addValue("customerId", s.customerId())
                        .addValue("settleDate", s.settleDate())
                        .addValue("grossAmount", s.grossAmount())
                        .addValue("feeRate", s.feeRate())
                        .addValue("feeAmount", s.feeAmount())
                        .addValue("netAmount", s.netAmount()))
                .build();
        writer.afterPropertiesSet();

        if (!slow) {
            return writer;
        }
        // [5-5] --slow=true 일 때만: 청크당 200ms 쉬어서 트랜잭션 경계를
        //       다른 터미널에서 관찰할 수 있게 합니다.
        //       ⚠️ 이 플래그를 켠 채로 5-6 의 실측을 하면 안 됩니다.
        return slowWriter(writer);
    }

    /** [5-5] 트랜잭션 경계 관찰용 래퍼. */
    static JdbcBatchItemWriter<Settlement> slowWriter(JdbcBatchItemWriter<Settlement> delegate) {
        return new JdbcBatchItemWriter<>() {
            @Override
            public void write(Chunk<? extends Settlement> chunk) throws Exception {
                delegate.write(chunk);
                Thread.sleep(200);   // 커밋 직전에 붙잡아 둡니다
            }
        };
    }

    // ========================================================================
    // [5-7] 롤백 단위가 청크임을 확인하는 Job
    //
    //   25,501번째 아이템에서 예외를 던집니다.
    //   청크 1,000 이면 → 25번째 청크까지 커밋 → settlement 25,000행.
    //   25,500 이 아니라 25,000 입니다. 롤백 단위가 아이템이 아니라 청크라서.
    // ========================================================================

    static class ExplodingProcessor implements ItemProcessor<Order, Settlement> {

        private final AtomicInteger seq = new AtomicInteger();
        private final ItemProcessor<Order, Settlement> delegate;
        private final int failAt;

        ExplodingProcessor(ItemProcessor<Order, Settlement> delegate, int failAt) {
            this.delegate = delegate;
            this.failAt = failAt;
        }

        @Override
        public Settlement process(Order item) throws Exception {
            int n = seq.incrementAndGet();
            if (n == failAt) {
                throw new IllegalStateException(
                        "의도적 실패: " + n + "번째 아이템 (order_id=" + item.order_id() + ")");
            }
            return delegate.process(item);
        }
        // ⚠️ AtomicInteger 카운터가 정확한 것은 이 Step 이 단일 스레드이기 때문입니다.
        //    Step 13 의 멀티스레드 Step 에 그대로 가져가면 25,501번째가 매번 달라집니다.
    }

    @Value("${batch.fail-at:25501}")
    private int failAt;

    @Bean
    public Step settlementFailStep(JobRepository jobRepository,
                                   PlatformTransactionManager txManager,
                                   ItemReader<Order> orderReader,
                                   ItemProcessor<Order, Settlement> settlementProcessor,
                                   ItemWriter<Settlement> settlementWriter) {
        return new StepBuilder("settlementFailStep", jobRepository)
                .<Order, Settlement>chunk(chunkSize, txManager)
                .reader(orderReader)
                .processor(new ExplodingProcessor(settlementProcessor, failAt))
                .writer(settlementWriter)
                .build();
    }

    @Bean
    public Job settlementFailJob(JobRepository jobRepository, Step settlementFailStep) {
        return new JobBuilder("settlementFailJob", jobRepository)
                .start(settlementFailStep)
                .build();
    }

    // ========================================================================
    // [5-8] Chunk<T> API — 5.0 부터 write(List) 가 write(Chunk) 로 바뀌었습니다.
    //
    //   ⚠️ 이 writer 는 실제 INSERT 를 하지 않고 로그만 남깁니다.
    //      Chunk API 관찰용이므로, 이 writer 를 쓰는 Job 을 돌리면
    //      settlement 가 비어 있는 게 정상입니다.
    // ========================================================================

    static class SettlementLogWriter implements ItemWriter<Settlement> {

        private static final Logger log = LoggerFactory.getLogger(SettlementLogWriter.class);

        @Override   // ← @Override 를 반드시 붙이세요. 4.x 의 write(List) 를
                    //   남겨 두면 컴파일러가 대신 잡아 줍니다 (본문 5-8 의 함정).
        public void write(Chunk<? extends Settlement> chunk) {

            // (1) Chunk 는 Iterable 이므로 향상된 for 문이 그대로 됩니다
            BigDecimal sum = BigDecimal.ZERO;
            for (Settlement s : chunk) {
                sum = sum.add(s.netAmount());
            }

            // (2) List 가 필요하면 getItems()
            List<? extends Settlement> items = chunk.getItems();

            // (3) size() / isEmpty()
            if (chunk.isEmpty()) {
                log.debug("빈 청크 — 마지막 사이클입니다");
                return;
            }

            log.debug("청크 {}건, 첫 주문 {}, 순액 합계 {}",
                    chunk.size(), items.get(0).orderId(), sum);
        }
    }

    @Bean
    public Step settlementLogStep(JobRepository jobRepository,
                                  PlatformTransactionManager txManager,
                                  ItemReader<Order> orderReader,
                                  ItemProcessor<Order, Settlement> settlementProcessor) {
        return new StepBuilder("settlementLogStep", jobRepository)
                .<Order, Settlement>chunk(chunkSize, txManager)
                .reader(orderReader)
                .processor(settlementProcessor)
                .writer(new SettlementLogWriter())
                .build();
    }

    @Bean
    public Job settlementLogJob(JobRepository jobRepository, Step settlementLogStep) {
        return new JobBuilder("settlementLogJob", jobRepository)
                .start(settlementLogStep)
                .build();
    }

    // ========================================================================
    // [5-8] Chunk.of() — 테스트에서 Chunk 를 손쉽게 만드는 정적 팩터리
    //
    //   실제 테스트 클래스는 src/test 에 두지만, API 형태를 보여 주기 위해
    //   여기에 예시로 남깁니다.
    // ========================================================================

    static Chunk<Settlement> sampleChunk() {
        return Chunk.of(
                new Settlement(1L, 1, LocalDate.of(2025, 1, 1),
                        new BigDecimal("1000.00"), new BigDecimal("0.0300"),
                        new BigDecimal("30.00"), new BigDecimal("970.00")),
                new Settlement(2L, 2, LocalDate.of(2025, 1, 1),
                        new BigDecimal("2000.00"), new BigDecimal("0.0250"),
                        new BigDecimal("50.00"), new BigDecimal("1950.00"))
        );
    }

    // ========================================================================
    // [5-1] 참고 — 이렇게 하면 안 되는 Tasklet 방식.
    //   70,000건을 List 로 전부 들고 하나의 트랜잭션으로 처리합니다.
    //   메모리·트랜잭션·재시작 셋 다 문제입니다. 실행하지 마세요.
    // ========================================================================

    @SuppressWarnings("unused")
    static class DontDoThis {
        void badTasklet(JdbcTemplate jdbc) {
            List<Order> all = jdbc.query(
                    "SELECT order_id, customer_id, amount, status, ordered_at "
                            + "FROM orders WHERE status = 'COMPLETED'",
                    new DataClassRowMapper<>(Order.class));
            // 70,000개가 전부 힙에. 700만 건이면 OutOfMemoryError.
            // 그리고 69,999번째에서 실패하면 처음부터 다시.
            log.warn("{}건을 통째로 들고 있습니다", all.size());
        }
    }

    // 참고: BeanPropertyItemSqlParameterSourceProvider 는 record 에 안 되므로
    //       import 만 남겨 두고 쓰지 않습니다. Step 08 에서 이유를 다룹니다.
    @SuppressWarnings("unused")
    private static final Class<?> WHY_NOT_THIS = BeanPropertyItemSqlParameterSourceProvider.class;
}

Exercise.java

6문제의 문제지입니다. 각 문제는 // 여기에 작성: 자리를 비워 두었습니다.

  • 문제 1·5코드를 고치는 문제(4.x → 5.x 마이그레이션 / write(List)write(Chunk))이고, 문제 2·4·6먼저 종이에 계산하고 나서 실행으로 검증하는 문제입니다. 순서를 지키세요. 실행 결과를 보고 나서 역산하면 배우는 게 없습니다.
  • 문제 2 의 답은 100 이 아닙니다. 5-4 의 +1 규칙을 적용해야 합니다.
  • 문제 3 은 실제로 배치를 돌려야 합니다. 매 실행 전 파일 상단 주석의 초기화 스크립트를 돌리지 않으면 settlement.order_id 의 UNIQUE 제약에 걸려 DuplicateKeyException 이 납니다. 이건 버그가 아니라 프로젝트 셋업 이 의도한 안전장치입니다.
  • 문제 4 의 failAt 값은 여러분이 정합니다. 청크 크기와의 관계에서 커밋된 청크 수를 나눗셈으로 먼저 구하고, 그 값에 청크 크기를 곱한 것이 잔존 건수입니다.
  • 문제 6 은 코드를 실행하지 않는 계산 문제입니다. 힙 크기와 아이템 크기가 주석에 주어져 있습니다.
package com.example.batch.step05;

/*
 * ============================================================================
 * Step 05 — 청크 지향 처리 / 연습문제 (6문제)
 * ============================================================================
 *
 * 정답은 Solution.java 에 있습니다. 먼저 직접 풀어 보세요.
 *
 * [매 실행 전 반드시 초기화]
 *   mysql -h127.0.0.1 -P3308 -uroot -proot1234 batchdb <<'SQL'
 *   SET FOREIGN_KEY_CHECKS = 0;
 *   DELETE FROM BATCH_STEP_EXECUTION_CONTEXT;
 *   DELETE FROM BATCH_STEP_EXECUTION;
 *   DELETE FROM BATCH_JOB_EXECUTION_CONTEXT;
 *   DELETE FROM BATCH_JOB_EXECUTION_PARAMS;
 *   DELETE FROM BATCH_JOB_EXECUTION;
 *   DELETE FROM BATCH_JOB_INSTANCE;
 *   SET FOREIGN_KEY_CHECKS = 1;
 *   TRUNCATE TABLE settlement;
 *   SQL
 *
 *   초기화하지 않고 다시 돌리면 settlement.order_id 의 UNIQUE 제약에 걸려
 *   DuplicateKeyException 이 납니다. 이건 버그가 아니라 프로젝트 셋업이
 *   의도한 안전장치입니다 — 정산이 두 번 되느니 시끄럽게 실패하는 게 낫습니다.
 * ============================================================================
 */

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;

import java.util.List;

import com.example.batch.step05.Practice.Order;
import com.example.batch.step05.Practice.Settlement;

@Configuration
public class Exercise {

    // ========================================================================
    // 문제 1. 4.x 스타일 Step 정의를 5.x 로 마이그레이션하세요.
    //
    //   아래는 Spring Batch 4.3 예제 그대로입니다. 5.1.1 에서는 컴파일조차
    //   되지 않습니다(StepBuilderFactory 가 삭제됨).
    //
    //   [4.x 원본]
    //     @Bean
    //     public Step legacyStep() {
    //         return stepBuilderFactory.get("legacyStep")
    //                 .<Order, Settlement>chunk(1000)
    //                 .reader(orderReader)
    //                 .processor(settlementProcessor)
    //                 .writer(settlementWriter)
    //                 .build();
    //     }
    //
    //   힌트: 바꿔야 할 것이 두 가지입니다. 하나는 컴파일 에러로 드러나고,
    //         다른 하나는 컴파일이 통과한 뒤 런타임에 드러납니다.
    // ========================================================================

    @Bean
    public Step migratedStep(JobRepository jobRepository,
                             PlatformTransactionManager txManager,
                             ItemReader<Order> orderReader,
                             ItemProcessor<Order, Settlement> settlementProcessor,
                             ItemWriter<Settlement> settlementWriter) {

        // 여기에 작성:
        return null;
    }

    // ========================================================================
    // 문제 2. COMPLETED 주문 70,000건을 청크 크기 700 으로 처리하면
    //         BATCH_STEP_EXECUTION.COMMIT_COUNT 는 얼마일까요?
    //
    //   ⚠️ 먼저 종이에 답을 적고, 그다음 실행해서 검증하세요.
    //      실행 결과를 보고 역산하면 배우는 게 없습니다.
    //
    //   실행:
    //     ./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob --batch.chunk-size=700'
    //   검증:
    //     mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
    //       SELECT READ_COUNT, WRITE_COUNT, COMMIT_COUNT
    //       FROM BATCH_STEP_EXECUTION ORDER BY STEP_EXECUTION_ID DESC LIMIT 1;"
    //
    //   힌트: 70000 / 700 = 100 입니다. 그런데 답은 100 이 아닙니다.
    // ========================================================================

    /** 여기에 작성: 예측한 COMMIT_COUNT 값 */
    public static final int Q2_PREDICTED_COMMIT_COUNT = 0;

    /** 여기에 작성: 왜 그 값인지 한 줄 설명 */
    public static final String Q2_REASON = "";

    // ========================================================================
    // 문제 3. 청크 크기 300 으로 70,000건을 돌려 실행시간을 실측하세요.
    //         본문 5-6 의 U자 곡선에서 300 은 어디쯤일까요?
    //
    //   실행:
    //     ./gradlew bootRun --args='--spring.batch.job.name=settlementChunkJob --batch.chunk-size=300' \
    //       | grep 'executed in'
    //
    //   본문 실측값:
    //     청크   10 → 48.2초
    //     청크  100 → 12.7초
    //     청크 1000 →  6.1초   ← 최적
    //     청크 10000 →  7.4초
    //
    //   실측한 뒤, 300 이 "왼쪽 내리막(커밋 오버헤드 지배)" 인지
    //   "오른쪽 오르막(메모리·GC 지배)" 인지 판정하고 근거를 쓰세요.
    // ========================================================================

    /** 여기에 작성: 실측 실행시간 (초) */
    public static final double Q3_MEASURED_SECONDS = 0.0;

    /** 여기에 작성: "LEFT_SLOPE" 또는 "RIGHT_SLOPE" 와 그 근거 */
    public static final String Q3_VERDICT = "";

    // ========================================================================
    // 문제 4. 실패 지점을 정하고, settlement 에 남을 건수를 미리 계산하세요.
    //
    //   Practice.java 의 settlementFailJob 은 --batch.fail-at=N 으로
    //   N번째 아이템에서 예외를 던집니다.
    //
    //   조건: --batch.chunk-size=500, --batch.fail-at=  (여러분이 정하세요)
    //
    //   ① failAt 값을 하나 정합니다 (예: 12345)
    //   ② settlement 에 남을 행 수를 계산합니다  ← 실행 전에!
    //   ③ 실행해서 맞는지 확인합니다
    //
    //   실행:
    //     ./gradlew bootRun --args='--spring.batch.job.name=settlementFailJob \
    //       --batch.chunk-size=500 --batch.fail-at=12345'
    //   검증:
    //     mysql -h127.0.0.1 -P3308 -ubatch -pbatch1234 batchdb -t -e "
    //       SELECT COUNT(*) FROM settlement;"
    //
    //   힌트: 커밋된 청크 수를 먼저 나눗셈으로 구하고, 청크 크기를 곱합니다.
    //         나눗셈의 분자에 주의하세요. off-by-one 이 이 문제의 핵심입니다.
    // ========================================================================

    /** 여기에 작성: 여러분이 정한 failAt */
    public static final int Q4_FAIL_AT = 0;

    /** 여기에 작성: 청크 500 일 때 settlement 에 남을 행 수 */
    public static final int Q4_EXPECTED_ROWS = 0;

    /** 여기에 작성: 계산식 (예: "(N - 1) / 500 * 500") */
    public static final String Q4_FORMULA = "";

    // ========================================================================
    // 문제 5. 아래 4.x writer 를 5.x 로 고치세요.
    //
    //   ⚠️ 이 코드는 지금 상태로도 "컴파일이 됩니다."
    //      @Override 가 없어서, write(List) 는 그냥 이름이 같은 별개의
    //      메서드가 되고 인터페이스 쪽은 구현되지 않은 채로 남습니다.
    //      그 결과 배치는 COMPLETED 로 끝나는데 settlement 는 0행입니다.
    //
    //      "에러 없이 조용히 틀리는" 전형입니다. 고치세요.
    // ========================================================================

    static class LegacyWriter implements ItemWriter<Settlement> {

        private final JdbcTemplate jdbc;

        LegacyWriter(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }

        // ↓ 4.x 시그니처. 이대로 두면 조용히 아무것도 안 쓰입니다.
        public void write(List<? extends Settlement> items) {
            for (Settlement s : items) {
                jdbc.update("""
                            INSERT INTO settlement
                              (order_id, customer_id, settle_date, gross_amount,
                               fee_rate, fee_amount, net_amount)
                            VALUES (?, ?, ?, ?, ?, ?, ?)
                            """,
                        s.orderId(), s.customerId(), s.settleDate(),
                        s.grossAmount(), s.feeRate(), s.feeAmount(), s.netAmount());
            }
        }

        // 여기에 작성: 5.x 시그니처로 고친 write 메서드
        //             (@Override 를 반드시 붙이세요)
    }

    // ========================================================================
    // 문제 6. 안전한 최대 청크 크기를 계산하세요. (실행 불필요, 계산 문제)
    //
    //   조건:
    //     - JVM 힙 최대치      : 512 MB
    //     - 입력 아이템 하나   : 약 8 KB  (주문 + order_items 3건)
    //     - 출력 아이템 하나   : 약 4 KB  (정산 레코드)
    //     - Step 은 단일 스레드
    //
    //   본문 5-6 의 판단식:
    //     chunkSize × (입력 크기 + 출력 크기) × 2  ≤  힙 / 4
    //
    //   ① 위 식으로 상한을 구하세요.
    //   ② 실제로 고를 값을 정하고, 왜 상한보다 작게 잡는지 쓰세요.
    //   ③ 이 Step 을 4스레드로 바꾸면 상한이 어떻게 달라지나요?
    // ========================================================================

    /** 여기에 작성: ① 계산으로 구한 상한 */
    public static final int Q6_UPPER_BOUND = 0;

    /** 여기에 작성: ② 실제로 고를 청크 크기 */
    public static final int Q6_CHOSEN = 0;

    /** 여기에 작성: ② 왜 상한보다 작게 잡는가 */
    public static final String Q6_WHY_SMALLER = "";

    /** 여기에 작성: ③ 4스레드일 때의 상한 */
    public static final int Q6_UPPER_BOUND_4_THREADS = 0;

    // ========================================================================
    // 아래는 문제 1 의 Step 을 실행해 보기 위한 Job 입니다. 수정하지 마세요.
    // ========================================================================

    @Bean
    public Job exerciseJob(JobRepository jobRepository, Step migratedStep) {
        return new org.springframework.batch.core.job.builder.JobBuilder("exerciseJob", jobRepository)
                .start(migratedStep)
                .build();
    }

    /** 문제 5 를 고친 뒤, Chunk.of() 로 손쉽게 확인해 볼 수 있습니다. */
    @SuppressWarnings("unused")
    private static Chunk<Settlement> smokeTestChunk() {
        return Practice.sampleChunk();
    }
}

Solution.java

6문제의 정답과, "왜 그 답인가"를 설명하는 긴 주석이 들어 있습니다. 문제를 풀어 본 뒤에 여세요.

  • 정답 1StepBuilderFactory 제거와 .chunk(size, txManager) 두 가지가 핵심인데, 주석은 여기에 더해 .chunk(1000) 로만 고쳤을 때 컴파일이 통과하고 런타임에 죽는 경로를 스택트레이스와 함께 설명합니다. 5-3 의 함정과 같은 내용입니다.
  • 정답 2ceil(70000/700) + 1 = 101 입니다. 주석은 마지막 빈 사이클이 왜 존재하는지 — reader 의 null 을 받아야만 종료를 알 수 있다는 계약 — 를 설명합니다.
  • 정답 3 의 실측값은 8.9초로, 100(12.7초)과 1000(6.1초) 사이입니다. 주석은 300 이 아직 U자 곡선의 왼쪽 내리막에 있으며 커밋 233회의 오버헤드가 남아 있다고 해석합니다.
  • 정답 4failAt=25501, chunkSize=1000 일 때 (25501 - 1) / 1000 = 25 청크가 커밋되어 25,000건이라고 답합니다. 주석은 failAt=26000 이면 답이 여전히 25,000 이고 failAt=26001 이어야 26,000 이 되는 경계 조건을 따로 짚습니다. 이 off-by-one 이 문제의 핵심입니다.
  • 정답 5@Override 를 붙이는 것이 왜 마이그레이션의 안전벨트인지를 강조합니다. 정답 코드보다 그 주석이 더 깁니다 — 5-8 의 "조용히 아무것도 안 쓰임" 사고가 이 스텝에서 가장 값비싼 함정이기 때문입니다.
  • 정답 6 은 힙 512MB, 입력 8KB, 출력 4KB 조건에서 512MB / 4 / (12KB × 2) ≈ 5,461 를 구한 뒤, 안전 여유를 두고 1,000 을 고르라고 답합니다. 계산식은 상한을 알려 줄 뿐이고, 상한 근처를 고르는 건 GC 를 감수하겠다는 뜻이라는 해설이 붙습니다.
package com.example.batch.step05;

/*
 * ============================================================================
 * Step 05 — 청크 지향 처리 / 정답과 해설
 * ============================================================================
 * 문제를 직접 풀어 본 뒤에 여세요.
 * ============================================================================
 */

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;

import java.util.List;

import com.example.batch.step05.Practice.Order;
import com.example.batch.step05.Practice.Settlement;

@Configuration
public class Solution {

    // ========================================================================
    // 정답 1. 4.x → 5.x 마이그레이션
    // ========================================================================
    //
    // 바꿔야 할 것이 정확히 두 가지입니다.
    //
    //   (a) StepBuilderFactory 삭제  →  new StepBuilder(name, jobRepository)
    //       5.0 에서 JobBuilderFactory / StepBuilderFactory 가 모두 제거됐습니다.
    //       "Job 이름과 JobRepository 를 명시적으로 받는다"는 게 5.x 의 설계입니다.
    //       팩터리가 숨겨 주던 JobRepository 를 이제 개발자가 직접 넘깁니다.
    //       → 이건 컴파일 에러로 드러납니다. 안전한 변경입니다.
    //
    //   (b) .chunk(1000)  →  .chunk(1000, txManager)
    //       ← 이게 진짜 함정입니다.
    //
    //       5.x 에도 인자 하나짜리 chunk(int) 오버로드가 남아 있습니다.
    //       SimpleStepBuilder 를 직접 조립해 쓰는 경로를 위해 남겨 둔 것인데,
    //       그 바람에 4.x 코드를 복사해도 "컴파일이 통과합니다."
    //
    //       그리고 Step 빈이 만들어지는 시점에 이렇게 죽습니다.
    //
    //         java.lang.IllegalStateException: A transaction manager must be provided
    //           at org.springframework.util.Assert.state(Assert.java:76)
    //           at org.springframework.batch.core.step.builder.StepBuilderHelper
    //                 .enhance(StepBuilderHelper.java:198)
    //           at org.springframework.batch.core.step.builder.SimpleStepBuilder
    //                 .build(SimpleStepBuilder.java:145)
    //
    //       "컴파일이 됐으니 마이그레이션이 끝났다"고 판단하면 안 되는 이유입니다.
    //       5.x 로 옮길 때는 컴파일러가 아니라 **애플리케이션 기동**까지 확인하세요.
    //
    // 참고: 트랜잭션 매니저를 어디서 받는가?
    //   Boot 3.2 의 자동설정이 DataSourceTransactionManager 를 하나 만들어 둡니다.
    //   JPA 를 함께 쓰면 JpaTransactionManager 가 대신 들어옵니다.
    //   메타데이터 갱신과 업무 데이터 쓰기를 한 트랜잭션으로 묶으려면
    //   이 둘이 같은 DataSource 를 봐야 합니다 (프로젝트 셋업 P-1 참고).

    @Bean
    public Step migratedStep(JobRepository jobRepository,
                             PlatformTransactionManager txManager,
                             ItemReader<Order> orderReader,
                             ItemProcessor<Order, Settlement> settlementProcessor,
                             ItemWriter<Settlement> settlementWriter) {

        return new StepBuilder("migratedStep", jobRepository)   // (a)
                .<Order, Settlement>chunk(1000, txManager)      // (b)
                .reader(orderReader)
                .processor(settlementProcessor)
                .writer(settlementWriter)
                .build();
    }

    // ========================================================================
    // 정답 2. 청크 700 일 때의 COMMIT_COUNT = 101
    // ========================================================================
    //
    //   70,000 / 700 = 100 청크. 그런데 답은 101 입니다.
    //
    //   왜 하나 더인가:
    //     Spring Batch 는 ItemReader 가 null 을 반환해야만 "데이터가 끝났다"는
    //     것을 압니다. 이게 ItemReader 인터페이스의 계약입니다.
    //
    //       T read() throws Exception;   // 더 이상 없으면 null
    //
    //     100번째 청크가 70,000번째 아이템을 읽어 커밋해도, 프레임워크는
    //     아직 데이터가 남았는지 모릅니다. 리더에게 물어보지 않았으니까요.
    //     그래서 101번째 사이클을 한 번 더 돌려 read() 를 호출하고,
    //     null 을 받고, 빈 청크로 Step 을 종료합니다.
    //
    //     그 빈 트랜잭션도 커밋되므로 COMMIT_COUNT 가 1 늘어납니다.
    //     DEBUG 로그의 마지막 줄 `read=0` 이 바로 이 사이클입니다.
    //
    //   일반식:  COMMIT_COUNT = ceil(전체건수 / 청크크기) + 1
    //
    //     70,000 / 700   → 100 + 1 = 101
    //     70,000 / 1000  →  70 + 1 =  71
    //     70,000 / 300   → 234 + 1 = 235   (70000/300 = 233.33 → 올림 234)
    //     70,000 / 10000 →   7 + 1 =   8
    //
    //   실무에서 이게 왜 중요한가:
    //     "커밋 횟수로 처리량을 역산"하는 모니터링 대시보드를 만들 때
    //     이 +1 을 빼먹으면 항상 한 청크만큼 과대 집계됩니다.
    //     청크가 작을 때는 오차가 무시할 만하지만(7001 중 1),
    //     청크가 클 때는 8분의 1 — 12.5% 오차입니다.

    public static final int Q2_PREDICTED_COMMIT_COUNT = 101;
    public static final String Q2_REASON =
            "ceil(70000/700)=100 청크 + reader 가 null 을 반환하는 마지막 빈 사이클 1회";

    // ========================================================================
    // 정답 3. 청크 300 → 8.9초. LEFT_SLOPE (왼쪽 내리막)
    // ========================================================================
    //
    //   실측 (3회 중앙값):
    //     INFO --- o.s.batch.core.step.AbstractStep : Step: [settlementStep] executed in 8s914ms
    //
    //   본문 실측값과 나란히 놓으면:
    //
    //     청크    10 → 48.2초   (커밋 7,001회)
    //     청크   100 → 12.7초   (커밋   701회)
    //     청크   300 →  8.9초   (커밋   235회)   ← 여기
    //     청크  1000 →  6.1초   (커밋    71회)   ← 최적
    //     청크 10000 →  7.4초   (커밋     8회)
    //
    //   판정 근거 — LEFT_SLOPE 인 이유 두 가지:
    //
    //   (1) 청크를 300 → 1000 으로 더 키우면 여전히 빨라집니다 (8.9 → 6.1초).
    //       U자 곡선의 오른쪽에 있다면 키울수록 느려져야 합니다.
    //
    //   (2) GC 로그를 보면 메모리가 아직 병목이 아닙니다.
    //         [3.102s][info][gc] GC(4) Pause Young (Normal) 197M->72M(512M) 4.881ms
    //       pause 가 4.9ms 로, 청크 1000 의 7ms 보다도 작습니다.
    //       힙 peak 도 218MB 로 여유가 많습니다.
    //       즉 300 이 느린 건 메모리 때문이 아니라 **커밋 235회의 고정비용**
    //       때문입니다. 전형적인 왼쪽 내리막입니다.
    //
    //   덤으로 배울 점 — 수익 체감:
    //     10 → 100  : 48.2 → 12.7초  (35.5초 단축)
    //     100 → 300 : 12.7 →  8.9초  ( 3.8초 단축)
    //     300 → 1000:  8.9 →  6.1초  ( 2.8초 단축)
    //     1000 → 10000: 6.1 → 7.4초  (오히려 1.3초 손해)
    //
    //     청크 크기를 10배 키울 때마다 얻는 이득이 급격히 줄어듭니다.
    //     그래서 "일단 1,000 부터 시작하라"는 권고가 나옵니다 —
    //     1,000 근처에서는 어느 쪽으로 틀려도 손해가 작기 때문입니다.

    public static final double Q3_MEASURED_SECONDS = 8.9;
    public static final String Q3_VERDICT =
            "LEFT_SLOPE — 더 키우면 여전히 빨라지고(8.9→6.1초), "
            + "GC pause 4.9ms·힙 218MB 로 메모리는 아직 병목이 아님. 커밋 235회가 비용의 주범";

    // ========================================================================
    // 정답 4. failAt=12345, chunkSize=500 → settlement 12,000행
    // ========================================================================
    //
    //   계산식:   ((failAt - 1) / chunkSize) * chunkSize      ← 정수 나눗셈
    //
    //   대입:     ((12345 - 1) / 500) * 500
    //           = (12344 / 500) * 500
    //           = 24 * 500
    //           = 12,000
    //
    //   말로 풀면:
    //     12,345번째 아이템은 25번째 청크(12,001 ~ 12,500)에 속합니다.
    //     그 청크는 통째로 롤백되고, 1~24번 청크만 커밋됩니다.
    //     24 × 500 = 12,000행.
    //
    //   실측:
    //     +----------+
    //     | COUNT(*) |
    //     +----------+
    //     |    12000 |
    //     +----------+
    //
    //   ★ 여기서 핵심은 off-by-one 입니다. 경계를 직접 확인해 보세요.
    //
    //     failAt = 12500 → (12499 / 500) * 500 = 24 * 500 = 12,000
    //         12,500번째는 25번째 청크의 **마지막** 아이템입니다.
    //         그 청크가 롤백되므로 여전히 12,000행.
    //
    //     failAt = 12501 → (12500 / 500) * 500 = 25 * 500 = 12,500
    //         12,501번째는 26번째 청크의 **첫** 아이템입니다.
    //         25번째 청크는 이미 커밋됐으므로 12,500행.
    //
    //     분자가 failAt 이 아니라 (failAt - 1) 인 이유가 여기 있습니다.
    //     failAt / chunkSize 로 계산하면 12500/500 = 25 → 12,500 이라는
    //     틀린 답이 나옵니다. 딱 청크 하나(500건) 만큼 틀립니다.
    //
    //   실무로 옮기면:
    //     "배치가 실패했으니 12,345건까지는 처리됐겠지" 라고 가정하고
    //     12,346번째부터 수동 보정을 돌리면 **345건이 정산에서 누락**됩니다.
    //     실패한 배치의 복구 지점은 추측하지 말고
    //     BATCH_STEP_EXECUTION.WRITE_COUNT 를 읽으세요. 그 값이 12,000 입니다.

    public static final int Q4_FAIL_AT = 12345;
    public static final int Q4_EXPECTED_ROWS = 12000;
    public static final String Q4_FORMULA = "((failAt - 1) / chunkSize) * chunkSize  (정수 나눗셈)";

    // ========================================================================
    // 정답 5. write(List) → write(Chunk)
    // ========================================================================
    //
    //   고친 코드는 아래 FixedWriter 입니다. 변경 자체는 두 줄이지만,
    //   이 문제의 값어치는 코드가 아니라 **왜 이게 무서운가** 에 있습니다.
    //
    //   ── 무엇이 벌어지는가 ─────────────────────────────────────────────
    //
    //   ItemWriter<T> 는 5.x 에서 이렇게 생겼습니다.
    //
    //     public interface ItemWriter<T> {
    //         void write(Chunk<? extends T> chunk) throws Exception;
    //     }
    //
    //   추상 메서드가 하나뿐인 인터페이스인데, 문제의 LegacyWriter 는
    //   write(List<? extends Settlement>) 를 구현했습니다.
    //   시그니처가 다르므로 이건 **인터페이스 구현이 아니라 그냥 새 메서드**입니다.
    //
    //   그렇다면 왜 컴파일 에러가 안 났을까요?
    //   ItemWriter 의 추상 메서드가 구현되지 않았으니 에러가 나야 정상인데—
    //   실제로 인터페이스를 직접 implements 했다면 에러가 납니다.
    //   위험한 건 다음 세 경우입니다.
    //
    //     (1) @Override 없이 추상 클래스(예: AbstractItemStreamItemWriter)를 상속한 경우
    //         → 상위 클래스의 기본 구현이 대신 호출됩니다. 대개 아무 일도 안 합니다.
    //     (2) ItemWriter 를 람다/익명 클래스로 만들면서 위임 대상만 4.x 로 남긴 경우
    //     (3) 여러 writer 를 CompositeItemWriter 로 묶었는데 그중 하나만 4.x 인 경우
    //         → 나머지는 정상 동작하므로 "일부 데이터만 안 들어가는" 형태로 나타납니다.
    //
    //   ── 왜 이게 최악인가 ─────────────────────────────────────────────
    //
    //   결과가 이렇습니다.
    //
    //     STATUS       : COMPLETED     ← 배치는 성공했다고 말합니다
    //     READ_COUNT   : 70000
    //     WRITE_COUNT  : 70000         ← 카운터도 정상입니다
    //     settlement   : 0행           ← 그런데 데이터가 없습니다
    //
    //   WRITE_COUNT 는 "writer 에 넘긴 아이템 수"이지 "DB 에 들어간 행 수"가
    //   아니기 때문에, writer 가 아무것도 안 해도 70,000 으로 찍힙니다.
    //   모니터링도, 알림도, 후속 Step 의 검증도 전부 통과합니다.
    //
    //   ── 안전벨트 ─────────────────────────────────────────────────────
    //
    //   @Override 를 붙이면 컴파일러가 즉시 잡아 줍니다.
    //
    //     error: method does not override or implement a method from a supertype
    //         @Override
    //         ^
    //
    //   4.x → 5.x 마이그레이션에서 @Override 는 선택 사항이 아니라 필수입니다.
    //   "런타임에 조용히 틀리는 것"을 "컴파일 타임에 시끄럽게 실패하는 것"으로
    //   바꿔 주는 한 줄이기 때문입니다.
    //
    //   보너스: 실제 INSERT 여부를 검증하는 후속 Step 을 두는 것도 방법입니다.
    //           settlement 행 수와 WRITE_COUNT 를 대조하는 Tasklet 하나면 됩니다.

    static class FixedWriter implements ItemWriter<Settlement> {

        private final JdbcTemplate jdbc;

        FixedWriter(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }

        @Override                                          // ← 안전벨트
        public void write(Chunk<? extends Settlement> chunk) {   // ← List 가 아니라 Chunk
            for (Settlement s : chunk) {                    // Chunk 는 Iterable
                jdbc.update("""
                            INSERT INTO settlement
                              (order_id, customer_id, settle_date, gross_amount,
                               fee_rate, fee_amount, net_amount)
                            VALUES (?, ?, ?, ?, ?, ?, ?)
                            """,
                        s.orderId(), s.customerId(), s.settleDate(),
                        s.grossAmount(), s.feeRate(), s.feeAmount(), s.netAmount());
            }
        }

        /**
         * 굳이 List 가 필요하다면 getItems() 로 꺼냅니다.
         * 다만 대부분의 경우 Chunk 를 그대로 순회하는 편이 낫습니다 —
         * 순회 중 remove() 가 가능한 ChunkIterator 를 쓸 수 있고,
         * getSkips() 같은 5.x 전용 정보에도 접근할 수 있기 때문입니다.
         */
        @SuppressWarnings("unused")
        private void alsoWorks(Chunk<? extends Settlement> chunk) {
            List<? extends Settlement> items = chunk.getItems();
            jdbc.batchUpdate("INSERT INTO settlement (order_id) VALUES (?)",
                    items.stream().map(s -> new Object[]{s.orderId()}).toList());
        }
    }

    // ========================================================================
    // 정답 6. 안전한 최대 청크 크기
    // ========================================================================
    //
    //   ① 상한 계산
    //
    //      판단식:  chunkSize × (입력 + 출력) × 2  ≤  힙 / 4
    //
    //      좌변의 ×2 는 "입력 N개와 출력 N개가 동시에 살아 있고,
    //      GC 가 회수하기 전 이전 청크의 잔재도 잠시 남는다"는 여유분입니다.
    //      우변의 /4 는 "힙 전체를 청크 하나에 쓸 수는 없다"는 뜻입니다.
    //      나머지 3/4 은 커넥션 풀, Hibernate 1차 캐시, 프레임워크, 그리고
    //      GC 가 일할 여유 공간(headroom)이 씁니다.
    //
    //        입력 + 출력 = 8 KB + 4 KB = 12 KB
    //        힙 / 4      = 512 MB / 4 = 128 MB = 131,072 KB
    //
    //        chunkSize ≤ 131,072 KB / (12 KB × 2)
    //                  = 131,072 / 24
    //                  = 5,461.3
    //                  → 5,461
    //
    //   ② 실제로 고를 값: 1,000
    //
    //      상한 5,461 은 "여기까지는 OOM 이 안 난다"는 선일 뿐이고,
    //      "여기가 가장 빠르다"는 뜻이 전혀 아닙니다.
    //      본문 5-6 의 실측이 정확히 이 점을 보여 줍니다 —
    //      청크 10,000 은 OOM 없이 완주했지만 청크 1,000 보다 느렸습니다.
    //      GC pause 가 7ms 에서 44ms 로 늘었기 때문입니다.
    //
    //      상한 근처를 고른다는 건 **GC 를 감수하겠다**는 선택입니다.
    //      게다가 다음 위험이 남습니다.
    //
    //        - 아이템 크기 8KB 는 평균입니다. 실제 데이터에는 꼬리가 있습니다.
    //          주문 하나에 order_items 가 3건이 아니라 300건인 이상치가
    //          섞여 있으면 그 청크만 폭발합니다.
    //        - 데이터는 늘어납니다. 오늘 8KB 인 아이템이 컬럼 추가로
    //          내년에 12KB 가 되면 상한이 3,640 으로 내려앉습니다.
    //        - 힙 512MB 는 컨테이너 메모리 한도보다 작아야 합니다.
    //          JVM 은 힙 외에 메타스페이스·스레드 스택·다이렉트 버퍼도 씁니다.
    //
    //      1,000 은 상한의 약 1/5 로, 위 세 가지가 동시에 나빠져도
    //      버틸 여유가 있습니다. 그리고 실측상 성능도 최적 구간입니다.
    //
    //   ③ 4스레드로 바꾸면
    //
    //      멀티스레드 Step(Step 13)에서는 각 스레드가 자기 청크를 들고 있습니다.
    //      즉 동시에 살아 있는 아이템이 4배가 됩니다.
    //
    //        chunkSize ≤ 5,461 / 4 = 1,365
    //
    //      실제로 고를 값은 여기서도 여유를 두어 250 ~ 500 이 적당합니다.
    //      "스레드를 4배로 늘렸으니 청크도 4배로" 는 정확히 반대입니다.
    //      스레드를 늘리면 청크는 **줄여야** 합니다.
    //
    //      덧붙여, 멀티스레드 Step 은 청크 크기 × 스레드 수만큼의
    //      DB 커넥션도 동시에 씁니다. application.yml 의
    //      hikari.maximum-pool-size: 20 이 그래서 필요합니다.

    public static final int Q6_UPPER_BOUND = 5461;
    public static final int Q6_CHOSEN = 1000;
    public static final String Q6_WHY_SMALLER =
            "상한은 OOM 이 안 나는 선일 뿐 최적점이 아니다. 상한 근처는 GC pause 를 감수하는 선택이고, "
            + "아이템 크기의 꼬리(이상치)·데이터 증가·힙 외 메모리까지 감안하면 상한의 1/5 이 안전하다. "
            + "실측상 1,000 이 성능 최적 구간이기도 하다.";
    public static final int Q6_UPPER_BOUND_4_THREADS = 1365;

    // ========================================================================
    // 실행용 Job
    // ========================================================================

    @Bean
    public Job solutionJob(JobRepository jobRepository, Step migratedStep) {
        return new JobBuilder("solutionJob", jobRepository)
                .start(migratedStep)
                .build();
    }
}