야간 배치가 아침 업무 시작 전에 끝나지 않으면 "파티셔닝을 붙이면 빨라질까"를 따지게 돼요. 결론부터 말하면 파티셔닝은 읽기와 쓰기 자체가 병목이고, 입력을 겹치지 않게 자를 기준(ID 범위·날짜·파일)이 있을 때만 이득이에요. 붙인다면 gridSize는 스레드 수의 4배 안팎, 스레드 수는 DB 커넥션 풀이 받쳐 주는 만큼, 그리고 파티션 이름은 재시작 때 똑같이 나오게 정하는 것이 설정의 거의 전부예요.
이 글은 내부 구조 설명 대신 운영에서 고를 숫자와 순서를 다뤄요. 기준은 Spring Batch 6.0.6 소스와 공식 문서의 확장·병렬 처리 장이고, 시간·커넥션 수는 모두 직접 계산한 예시예요(2026-10-27).
스프링 배치 병렬 처리 방식 고르기, 병목별 판단표
먼저 "어디가 느린가"를 재야 해요. 스텝 하나의 시간을 읽기·가공·쓰기로 나눠 보면 답이 거의 정해져요. 스텝 실행 기록의 readCount·writeCount 와 시작·종료 시각, DB 쪽 쿼리 시간으로 대략 나눌 수 있어요.
| 느린 곳 | 먼저 쓸 방법 | 리더·라이터가 스레드 안전해야 하나 | 실패 시 다시 도는 범위 | 전제 조건 |
|---|---|---|---|---|
| 가공(프로세서)만 | 멀티스레드 스텝 | 아니요(읽기·쓰기는 메인 스레드) | 스텝 전체 | 아이템끼리 순서 무관 |
| 쓰기(외부 API·느린 INSERT) | 로컬 청킹(6.0) | 아니요 | 스텝 전체 | 청크끼리 순서 무관 |
| 읽기와 쓰기 모두 | 로컬 파티셔닝 | 아니요(파티션마다 새 인스턴스) | 실패한 파티션만 | 겹치지 않는 분할 기준 |
| 서로 무관한 일 여러 개 | 병렬 스텝(split) | 아니요 | 실패한 스텝만 | 스텝끼리 의존 없음 |
| 한 서버 CPU·메모리로 부족 | 원격 파티셔닝 | 아니요 | 실패한 파티션만 | 공유 메타데이터 DB, 메시지 브로커 |
공식 문서는 멀티스레드 스텝에서도 아이템을 읽고 쓰는 일은 스텝을 실행하는 메인 스레드가 순서대로 한다고 적어요. 그래서 DB 읽기가 느린 배치에 taskExecutor 만 붙이면 기대만큼 빨라지지 않아요. 로컬 청킹은 Spring Batch 6 새 기능 문서에 들어간 기능으로, 같은 JVM 안에서 청크를 여러 스레드에 나눠 처리해요.
판단표에서 가장 자주 빠뜨리는 칸이 "전제 조건"이에요. 파티셔닝은 입력을 미리 잘라 둘 수 있어야 해요. 큐에서 하나씩 꺼내는 입력, 앞 행의 결과로 다음 행을 정하는 누적 계산처럼 자를 수 없는 일에는 맞지 않아요.
파티션 분할 기준 정하기, ID 범위·날짜·파일
분할 기준은 세 가지를 만족해야 해요. 겹치지 않을 것, 빠지는 행이 없을 것, 같은 입력이면 같은 이름이 나올 것. 세 번째가 재시작을 좌우해요. 재시작 때 스플리터는 대상스텝:파티션이름 으로 지난 실행 기록을 찾기 때문이에요(SimpleStepExecutionSplitter 소스).
| 기준 | 장점 | 약점 | 맞는 데이터 |
|---|---|---|---|
| PK 범위(min~max 등분) | 쿼리가 단순, 인덱스 범위 스캔 | ID 구간마다 행 수가 다르면 쏠림 | 자동증가 PK, 삭제 적음 |
| 행 수 기준 경계(미리 경계값 조회) | 파티션 크기가 고름 | 경계 조회 쿼리 비용 | 쏠림이 큰 테이블 |
모듈로(id % n = k) | 쏠림이 적음 | 인덱스를 못 타면 파티션마다 전체 스캔 | 함수 인덱스가 있을 때 |
| 날짜(일·시간 단위) | 업무 단위와 맞음, 이름이 자연스러움 | 날짜별 건수 편차 | 거래·로그 테이블 |
| 파일 목록 | 파일 하나가 파티션 하나 | 파일 크기 편차 | 수신 파일 처리 |
UUID 처럼 순서 없는 키는 범위로 자르기 어려워요. 기본키 종류가 분할에 미치는 영향은 UUID vs 자동증가 정수 기본키 글에서 다뤘어요. 날짜로 자를 때는 경계를 KST 로 볼지 UTC 로 볼지 먼저 맞춰야 하루치가 두 파티션에 갈라지지 않아요. 이 함정은 타임존 저장 원칙 글에 정리돼 있어요.
등분 계산에서 흔한 버그는 나눗셈 나머지 누락이에요. 최대 ID 1,000,003 을 8개로 나누며 size = max / gridSize 를 정수로 구하면 125,000 이 되고, 마지막 파티션의 상한이 1,000,000 에서 끝나요. ID 1,000,001~1,000,003 세 건은 어느 파티션에도 들어가지 않고 잡은 COMPLETED 로 끝나요. 마지막 파티션의 상한은 계산값이 아니라 실제 최대값으로 두는 게 안전해요.
gridSize 정하는 법, 데이터 쏠림으로 직접 계산
gridSize 는 만들 파티션 개수예요. 공식 문서는 스레드 수에 맞춰도 되고, 더 크게 잡아 작업 덩어리를 작게 만들어도 된다고 안내해요. 어느 쪽이 나은지는 데이터가 얼마나 쏠렸는지에 달려 있어요.
계산 조건은 이래요. 행의 절반이 ID 공간의 앞 80%에, 나머지 절반이 뒤 20%에 몰려 있어요(최근 데이터가 많은 거래 테이블에서 흔한 모양). ID 범위를 gridSize 개로 등분하고, 스레드 8개가 먼저 끝난 순서대로 다음 파티션을 가져가요. 처리 시간은 행 수에 비례한다고 놓고, 모든 스레드가 똑같이 일했을 때를 1.0 으로 봤어요. 실행기에 들어가는 파티션 순서는 정해져 있지 않아서, 무거운 구간이 마지막에 들어가는 경우(최악)와 먼저 들어가는 경우를 둘 다 계산했어요.
gridSize 가 스레드 수와 같으면 가장 무거운 파티션 하나가 전체 시간을 정해요. 위 조건에서는 마지막 파티션 하나가 전체 행의 31%를 맡아서(이상적인 몫 12.5%의 2.5배), 나머지 스레드가 일찍 놀고도 2.5배가 걸려요. 스레드 수의 4배(32개)쯤 되면 무거운 구간이 여러 조각으로 갈라져 1.09배까지 내려가고, 그 뒤로는 거의 줄지 않아요.
그렇다고 gridSize 를 수천으로 올리면 다른 비용이 커져요. 파티션 하나마다 스텝 실행 기록과 실행 컨텍스트가 메타데이터 테이블에 한 벌씩 생기고, 청크 커밋마다 갱신돼요. 파티션마다 리더를 열고 쿼리를 새로 시작하는 비용도 붙어요. 실무 출발점은 이렇게 잡으면 무난해요.
- 쏠림이 거의 없는 데이터: gridSize = 스레드 수 ~ 스레드 수 × 2
- 쏠림을 모르거나 큰 데이터: gridSize = 스레드 수 × 4 안팎
- 파티션 하나가 수십 초 안에 끝날 만큼 작아지면 더 늘리지 않아요
하나 더 기억할 점은, 재시작 때는 gridSize 를 바꿔도 무시된다는 거예요. 스플리터가 첫 실행 때 매니저 컨텍스트에 SimpleStepExecutionSplitter.GRID_SIZE 를 적어 두고 재시작 때 그 값을 다시 읽어요. 크기를 바꾸려면 새 잡 파라미터로 새 인스턴스를 시작해야 해요.
스레드 풀과 커넥션 풀 크기 계산
파티션을 동시에 몇 개 돌릴지는 gridSize 가 아니라 실행기 스레드 수가 정해요. PartitionStepBuilder 소스를 보면 빌더 기본 gridSize 는 6이고, 실행기를 주지 않으면 SyncTaskExecutor 를 써요. 이 경우 파티션 로그가 여러 개 찍혀도 호출한 스레드 하나에서 차례로 돌아요.
| 실행기 | 동시 실행 수 | 쓸 때 |
|---|---|---|
지정 안 함(SyncTaskExecutor) | 1 | 파티션별 재시작만 필요하고 속도는 상관없을 때 |
SimpleAsyncTaskExecutor | 파티션 수만큼(상한 없음) | gridSize 가 작고 고정일 때만 |
ThreadPoolTaskExecutor(core = max = n) | n | 대부분의 운영 환경 |
| 가상 스레드 실행기 | 파티션 수만큼 | 커넥션 풀로 동시 수를 따로 막을 수 있을 때 |
SimpleAsyncTaskExecutor 는 일마다 새 스레드를 만들어서, gridSize 를 32로 올리면 32개가 한꺼번에 DB 에 붙어요. 스레드 수를 정하려면 상한이 있는 풀을 써요.
다음은 커넥션이에요. 공식 문서도 DataSource 같은 풀 자원을 동시 스레드 수 이상으로 잡으라고 권해요. 실제로 필요한 수는 리더 종류에 따라 두 배까지 차이 나요.
- 커서 리더(
JdbcCursorItemReader등): 기본 설정이면open()에서dataSource.getConnection()으로 커넥션을 따로 받아 스텝이 끝날 때까지 쥐고 있어요(AbstractCursorItemReader 소스). 청크 트랜잭션용 커넥션이 하나 더 필요하니 파티션 하나에 2개예요. - 페이징 리더(
JdbcPagingItemReader등): 같은DataSource면 페이지 쿼리가 청크 트랜잭션의 커넥션을 같이 써서 파티션 하나에 1개예요.
HikariCP 설정 문서 기준으로 maximumPoolSize 기본값은 10이고, 풀이 차면 getConnection() 이 connectionTimeout(기본 30초) 동안 막혔다가 예외를 던져요. 스레드 8개에 커서 리더를 쓰는데 풀이 10이면, 리더 8개가 커넥션 8개를 쥔 뒤 청크 트랜잭션 8개가 남은 2개를 두고 기다려요. 증상은 "느리다가 30초쯤 뒤 SQLTransientConnectionException"이에요.
DB 서버 쪽 한도도 같이 봐요. 배치 서버 한 대가 커넥션 64개를 쥐면 같은 DB 를 쓰는 API 서버들의 몫이 줄어요. 스레드 풀 크기를 요청률과 대기 시간으로 어림하는 방법은 리액터 스케줄러 크기 계산 글의 리틀의 법칙 부분과 같은 원리예요.
파티셔닝 운영 문제, 확인 순서 체크리스트
증상별로 먼저 볼 곳을 순서대로 정리했어요. 앞쪽일수록 확인이 빨라요.
- 파티션이 병렬로 안 돈다 → 스레드 이름을 로그에 찍어 봐요. 모두 같은 이름이면 실행기를 안 준 거예요. 실행기를 줬는데도 동시 수가 적으면 풀의 core 크기와 큐 설정을 봐요.
- 재시작했는데 끝난 파티션까지 다시 돈다 → 메타데이터 테이블에서 지난 실행과 이번 실행의 스텝 이름을 비교해요. 이름이 다르면 분할 기준이 결정적이지 않은 거예요.
- 재시작이 예외로 막힌다 → 지난 워커 기록이
STARTED·STARTING·STOPPING이면 스플리터가 시작을 거부해요. 프로세스가 강제 종료돼서 남은 기록이라면, 정말 멈췄는지 확인한 뒤 6.0에 추가된JobOperator.recover()로 잡 실행과 실행 중인 스텝 기록을FAILED로 바꾸고 다시 시작해요.UNKNOWN은 recover 대상이 아니라 데이터를 직접 확인해야 해요. - 커넥션 타임아웃 → 위 커넥션 계산을 다시 해요. 리더가 커서형인지부터 봐요.
- 잡은 성공했는데 건수가 모자란다 → 파티션별
readCount합계와 원본 테이블 건수를 비교해요. 나머지 누락이나 경계값</<=실수가 흔해요. - 한 파티션만 오래 걸린다 → 파티션별 시작·종료 시각을 뽑아 가장 긴 것과 평균의 비율을 봐요. 2배를 넘으면 gridSize 를 늘리거나 행 수 기준 경계로 바꿔요.
- 같은 행이 두 번 처리된다 → 경계가 겹치는지, 처리 중에 원본 행이 다른 파티션 범위로 옮겨 가는지(상태 컬럼으로 자른 경우) 확인해요.
원격 파티셔닝 도입 전에 따질 것
한 서버의 CPU·메모리로 부족할 때만 원격으로 가요. 구조는 PartitionHandler 를 spring-batch-integration 의 MessageChannelPartitionHandler 로 바꾸는 것이고, 메시지에는 스텝 이름과 스텝 실행 ID 만 실려요(MessageChannelPartitionHandler 소스). 그래서 운영 부담은 메시지보다 메타데이터 DB 쪽에 생겨요.
| 점검 항목 | 확인할 값 | 왜 |
|---|---|---|
| 메타데이터 DB | 매니저·워커가 같은 JDBC 저장소를 보는가 | 워커가 ID 로 실행 기록을 꺼내요 |
| 기본 저장소 | 6.0 DefaultBatchConfiguration 기본값은 리소스리스 저장소 | 파티셔닝에는 JDBC 저장소를 명시해요 |
| 결과 수집 방식 | 응답 채널 vs 저장소 폴링 | 폴링 간격 기본값이 10초예요 |
| 대기 시간 상한 | timeout 기본값 -1 | 기본은 무한 대기라 워커가 죽으면 매니저가 멈춰요 |
| JMS 헤더 | correlationId·sequenceNumber·sequenceSize 매핑 | 빠지면 응답 집계가 멈춰요 |
| 워커 수 | 워커 서버 수 × 서버당 동시 수 | 커넥션 계산을 서버마다 다시 해요 |
폴링 간격 10초는 파티션이 몇 분씩 걸리는 배치에선 작은 값이지만, 파티션이 몇 초면 끝나는 짧은 잡에선 끝을 알아채는 데만 최대 10초가 더 걸려요. 잡이 짧으면 간격을 줄이거나 응답 채널을 써요.
JMS 를 쓸 때 헤더 문제는 외부 실행(원격 청킹·파티셔닝) 문서에 따로 경고돼 있어요. 응답 수신 어댑터의 DefaultJmsHeaderMapper 에 세 헤더를 허용하지 않으면 Null correlation not allowed 로 실패하거나 응답을 기다리며 멈춰요. 반대로 메시지 자체는 내구성이나 전달 보장이 없어도 된다고 문서가 설명해요. 한 번만 실행되게 하는 일은 메타데이터가 맡아요.
비용으로 따지면 원격 파티셔닝은 "서버 한 대로 몇 시간"이 "서버 n대로 몇 시간 ÷ n"이 되는 구조라, 늘어나는 건 실행 시간이 아니라 동시에 켜 둔 서버 수예요. 다만 메타데이터 DB 와 원본 DB 가 서버 수만큼 늘어난 동시 쿼리를 받아야 하니, 스케일아웃 전에 DB 쪽 한도부터 계산해야 해요.
자주 묻는 질문
스프링 배치 gridSize 는 몇으로 잡나요?
쏠림이 거의 없으면 스레드 수와 같거나 두 배, 쏠림이 있거나 모르면 스레드 수의 4배 안팎에서 시작해요. 위 계산에서 스레드 8개 기준 gridSize 8은 이상적인 시간의 2.5배, 32는 1.09배였어요. 파티션 하나가 수십 초 안에 끝날 만큼 작아지면 더 늘려도 메타데이터 비용만 늘어요.
파티셔닝을 붙였는데 왜 빨라지지 않나요?
가장 흔한 원인은 실행기를 지정하지 않은 거예요. 빌더 기본값이 SyncTaskExecutor 라 파티션이 한 스레드에서 차례로 돌아요. 실행기를 줬다면 커넥션 풀이 스레드 수보다 작은지, 한 파티션에 행이 몰렸는지 순서로 봐요.
커넥션 풀은 얼마나 잡아야 하나요?
같은 DataSource 기준으로 커서 리더는 동시 파티션 수 × 2, 페이징 리더는 × 1 이상에, 메타데이터 저장소와 다른 작업용 여유분을 더해요. HikariCP 기본값 10이면 커서 리더로는 스레드 5개부터 모자라요.
재시작할 때 gridSize 나 분할 로직을 바꾸면 반영되나요?
반영되지 않아요. gridSize 는 첫 실행 때 매니저 컨텍스트에 저장된 값을 다시 쓰고, 실패한 파티션은 지난 실행의 컨텍스트를 이어받아요. 분할을 바꾸려면 새 잡 파라미터로 새 인스턴스를 시작해야 해요.
강제 종료 뒤 재시작하면 예외가 나요. 어떻게 하나요?
워커 기록이 STARTED 로 남아 있어서 그래요. 프로세스가 정말 멈췄는지 확인한 뒤 Spring Batch 6.0 의 JobOperator.recover() 로 잡 실행과 실행 중이던 스텝 기록을 FAILED 로 바꾸면 다시 시작할 수 있어요. 5.x 이하에서는 메타데이터 테이블을 직접 고쳐야 해요.
원격 파티셔닝에 메시지 브로커 내구성이 필요한가요?
공식 문서는 필요 없다고 설명해요. 워커가 잡 실행마다 한 번만 도는 것은 공유 메타데이터 저장소가 보장해요. 대신 매니저와 워커가 같은 JDBC 저장소를 봐야 하고, JMS 라면 응답 헤더 세 개를 매핑해야 해요.
정리
- 병목이 가공이면 멀티스레드 스텝, 쓰기면 로컬 청킹, 읽기와 쓰기 모두면 파티셔닝이에요. 파티셔닝은 겹치지 않는 분할 기준이 있어야 하고, 이름이 결정적이어야 재시작이 제대로 돼요.
- gridSize 는 스레드 수의 4배 안팎에서 시작하고, 동시 실행 수는 상한 있는 스레드 풀로 정해요. 재시작 때는 둘 다 첫 실행 값을 따라가요.
- 커넥션은 커서 리더면 스레드 × 2, 페이징 리더면 스레드 × 1 이상이에요. 나머지 누락과 경계값은 파티션별 건수 합계로 검증해요.
- 원격 파티셔닝은 공유 JDBC 저장소, 폴링 간격 10초, 무한 대기 기본값, JMS 헤더를 먼저 정리한 뒤 도입해요.
배치 일정을 크론식으로 걸 때는 크론 표현식 해석기로 실행 시각을 확인하고, 자주 틀리는 크론 문법은 크론 표현식 읽는 법에서 볼 수 있어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 배치 파티셔닝 원리 — 매니저·워커 스텝과 재시작에서 도식과 코드로 단계별로 풀었어요.