배치 스케줄러를 Spring Batch 6 으로 올린 뒤, 스레드 풀을 아무리 키워도 청크가 동시에 4개까지만 도는 현상을 만났다. 원인을 따라가 보니 버그가 아니라 20년 가까이 된 설계 결정과 프레임워크의 방향 전환이 겹친 자리였다. 이 글은 그 역사와, 우리가 고른 탈출구의 기록이다.

요약: TaskExecutorRepeatTemplate 의 throttle 기본값 4 는 2007년부터 있던 임의값이고, Batch 5.0 이 설정 통로를 deprecate, 6.0 이 제거했다. 클래스 자체도 “대체 없이” 7.0 에서 삭제 예정. 공식 진로는 멀티스레드 스텝을 버리고 파티셔닝으로 가는 것이지만, 과도기에는 한도만 받는 사본 클래스로 해결할 수 있다.


1. 증상 — 풀은 20인데 청크는 4개

멀티스레드 청크 스텝은 이렇게 구성한다. 스텝의 반복(청크 단위 실행)을 TaskExecutor 로 돌리는 방식이다.

.chunk<Item, Item>(1, transactionManager)
.taskExecutor(collectJobExecutor)   // ThreadPoolTaskExecutor, maxPoolSize = 20

그런데 운영에서 동시에 도는 청크를 세어 보면 늘 4개다. 스레드 16개는 만들어진 채 논다.

2. 범인 — DEFAULT_THROTTLE_LIMIT = 4

멀티스레드 스텝의 실제 반복기는 TaskExecutorRepeatTemplate 이고, Batch 6.0.x 소스를 열면 이렇게 되어 있다.

@Deprecated(since = "6.0", forRemoval = true)   // "with no replacement, scheduled for removal in 7.0"
public class TaskExecutorRepeatTemplate extends RepeatTemplate {

    public static final int DEFAULT_THROTTLE_LIMIT = 4;

    private int throttleLimit = DEFAULT_THROTTLE_LIMIT;   // setter 없음

두 가지가 눈에 띈다.

  1. throttleLimit 이 private 인데 setter 가 없다. 4 를 바꿀 방법이 없다.
  2. 클래스 전체가 “대체 없이” 7.0 에서 제거 예정이라는 deprecation 이 붙어 있다.

3. throttle 은 애초에 왜 필요했나

먼저 단어부터. throttle 의 원래 뜻은 엔진의 스로틀 밸브다 — 엔진이 낼 수 있는 힘(배기량)과 별개로, 지금 흘려보낼 양을 조절하는 꼭지. 소프트웨어에서도 같은 뜻으로 쓴다: 능력이 얼마든, 동시에 처리하도록 허용하는 양의 상한. 스레드 풀과 역할이 이렇게 갈린다.

  스레드 풀 (maxPoolSize) throttle (throttleLimit)
비유 엔진 배기량 (일꾼 수) 밸브 (한 번에 흘려보낼 양)
묻는 질문 동시에 일할 수 있는 사람이 몇 명인가 동시에 맡겨 둘 일이 몇 개인가
누가 세나 executor 반복기 쪽 세마포어

그리고 실효 동시성은 둘 중 작은 값이다.

동시에 도는 청크 = min(스레드 풀 크기, throttle)

풀 20 에 throttle 4 면 4가 된다 — 1장의 증상이 바로 이 식이다.

“executor 풀이 있는데 왜 반복기가 또 한도를 세나?” 가 자연스러운 의문인데, 거꾸로 생각하면 이해된다.

이 반복기는 루프를 돌며 청크 실행을 executor 에 계속 던지는 구조다. 자체 브레이크가 없으면, 큐가 무한한 보통의 ThreadPoolTaskExecutor 에서는 아이템을 끝없이 미리 읽어 청크를 큐에 쌓는다 — 메모리가 부풀고 리더 상태도 꼬인다. 그래서 “제출했지만 아직 안 끝난 것” 의 수를 세마포어로 세는 역압(backpressure) 장치가 필요했고, 그것이 throttle 이다.

Batch 4.x 까지는 이 값을 공식적으로 걸 수 있었다. 스텝 빌더에 전용 메소드까지 있었다.

// Batch 4.x — 당시의 정석
.taskExecutor(collectJobExecutor)
.throttleLimit(maxPoolSize)   // "풀 크기만큼 동시에"

4.x javadoc 에는 이런 N.B. 도 붙어 있다: “스레드 풀이 throttle 도달을 막을 수 있으니, 가능하면 코어 풀을 throttle 보다 크게 잡아라.” — 레버가 둘(풀 크기, throttle)이라 서로 간섭한다는 혼란의 흔적이다.

4. 왜 설정을 없앴나 — 프레임워크의 방향 전환

Batch 5.0 이 throttleLimit 을 deprecate 하며 밝힌 논리는 대략 이렇다.

  • 레버가 둘인 것 자체가 문제다. 동시성 제어는 executor 의 영역(풀 크기 + 큐 용량 + 거부 정책)인데, 반복기가 또 하나의 한도를 가지니 “풀을 키웠는데 왜 안 빨라지죠?” 가 수년간 반복됐다.
  • 구현이 유물이다. 내부의 결과 큐는 java.util.concurrent 가 성숙하기 전의 수제 동시성 코드다.

그리고 6.0 은 한 발 더 가서 멀티스레드 청크 스텝 모델 자체를 버리기로 했다. 여러 스레드가 하나의 리더 상태를 공유하는 이 모델은 재시작 안전성이 없고 (ExecutionContext 에 저장되는 리더 위치가 스레드 안전하지 않다), skip/retry 의 chunk scanning 과도 충돌하는, 태생적으로 반쪽인 기능이었기 때문이다. 공식 진로는 파티셔닝과 원격 청킹이다.

다만 6.0 은 과도기라 클래스를 지우지는 못하고 setter 만 제거했다. 그 결과가 지금의 어정쩡한 상태 — 역사적 기본값 4가 박힌 채 바꿀 수 없는 모습이다. 사실상 “이 클래스는 떠나라” 는 신호다.

5. 잊지 말 것 — 스레드 풀과 DB 커넥션 풀은 한 몸이다

동시성 얘기가 나온 김에 같이 기록한다. 청크 하나는 도는 동안 DB 트랜잭션 하나(= 커넥션 하나) 를 잡는다. 그래서 커넥션 풀은 이 공식으로 정한다.

HikariCP maximum-pool-size
  = (모든 Job 의 동시 청크 수 합) × 1
  + (상시 도는 보조 Job 수)
  + (여유분)

예컨대 수집 20 + 통합 10 이 동시에 돌고 보조 Job 5개가 있으면 30 + 5 + 5 = 40. 배치 메타 DB 를 분리했다면 그쪽 풀도 같은 공식이 적용된다 — 청크 커밋마다 StepExecution 갱신이 메타 DB 로 가기 때문이다. 동시 청크 수를 올리면 커넥션 풀 두 개도 같이 올려야 한다. 안 올리면 스레드는 돌 준비가 됐는데 커넥션 대기로 묶여, throttle 과 똑같은 병목이 DB 쪽에서 재현된다.

6. 탈출구 세 개

① 4 수용 — 운영 동시성이 4 이하라면 아무 문제 없다. 우리는 운영 수집이 20 동시라 논외.

② 파티셔닝 전환 (정석) — 마스터 스텝이 데이터를 gridSize 조각으로 나누고, TaskExecutorPartitionHandler 가 조각마다 단일 스레드 워커 스텝을 병렬로 돌린다. 동시성 = gridSize 로 설정 가능하고, 파티션마다 독립 StepExecution 이라 재시작도 안전하다. 다만 Reader/Writer 를 파티션 경계에 맞게 재설계하는 구조 변경이라, 잘 돌고 있는 Job 에 지금 들이댈 비용은 아니다. Batch 7 업그레이드 때 묶어서 가는 게 맞다.

③ 한도만 받는 사본 (과도기 해법, 우리의 선택) — TaskExecutorRepeatTemplate 과 같은 방식으로 동작하되 한도를 생성자로 받는 클래스를 만들어 stepOperations 에 끼운다.

class BoundedTaskExecutorRepeatTemplate(
    private val taskExecutor: TaskExecutor,
    private val throttleLimit: Int,          // 풀 크기를 그대로 넣는다
) : RepeatTemplate() { /* 세마포어 + 결과 큐 */ }

// 스텝 빌더에서
.stepOperations(BoundedTaskExecutorRepeatTemplate(collectJobExecutor, maxPoolSize))

이 사본의 생존성이 핵심인데, deprecated 된 것은 TaskExecutorRepeatTemplate 한 클래스뿐이고 사본이 딛고 있는 SPI (RepeatTemplate, RepeatInternalStateSupport, RepeatSynchronizationManager) 는 전부 멀쩡하다. 즉 7.0 에서도 컴파일된다. 또 하나 — 우리 스텝은 chunk(1) 이라 “여러 스레드가 리더 상태를 공유” 하는 멀티스레드 스텝의 고질병에서 비교적 자유롭다. 행 하나가 트랜잭션 하나이고 실패 행은 큐의 재시도 플래그로 다시 잡히는 구조라, Batch 의 재시작 메커니즘에 기대지 않기 때문이다. 멀티스레드 스텝을 계속 쓸 거라면 이 조건(상태 공유가 없는 리더)은 꼭 확인할 것.

7. 교훈

  • “기본값 4” 는 2007년의 보수적 임의값이다. 프레임워크 기본값에는 종종 역사가 묻어 있고, 그 역사를 모르면 “왜 안 빨라지지” 에서 멈춘다.
  • deprecation 메시지는 끝까지 읽자. “with no replacement” 한 구절이 “설정만 없어진 게 아니라 모델 자체가 버려진다” 는 전혀 다른 결론을 만든다.
  • 프레임워크 내부를 복제할 때는 딛는 SPI 의 수명부터 확인한다. deprecated 클래스를 베끼더라도 non-deprecated 토대 위에 세우면 과도기를 안전하게 건널 수 있다.
  • 동시성 레버는 혼자 움직이지 않는다. 스레드 풀을 올리면 커넥션 풀(업무 DB 와 배치 메타 DB 둘 다)이 공식대로 따라가야 한다.