광고
~ / posts / developer

스프링 배치 청크 크기 정하는 법, 재시작·skip 설정 판단표와 장애 점검

스프링 배치 청크 크기는 100~1,000 사이에서 시작해 커밋 비용과 재처리 양으로 정해요. 쓰기 skip 이 잦으면 청크를 줄이고, 재시작은 리더 이름·정렬 키·처리 표시 컬럼으로 고르는 법, 장애 확인 순서를 숫자와 함께 정리했어요.

배치를 짤 때 먼저 막히는 칸이 chunk(?) 안의 숫자예요. 결론부터 말하면 청크 크기는 100~1,000 사이에서 시작하고, 커밋 한 번의 고정 비용이 건당 처리 시간보다 훨씬 클 때만 키워요. 쓰기 단계에서 skip 이 자주 나는 데이터라면 오히려 줄여야 하고, 재시작이 제대로 되는지는 숫자보다 리더 이름·정렬 키·리더 종류가 결정해요.

이 글은 내부 순서 대신 운영에서 고를 값과 장애 때 볼 순서를 다뤄요. 기준은 Spring Batch 6.0.6 소스와 공식 레퍼런스의 청크 처리 장이고, 시간·트랜잭션 수는 조건을 밝힌 직접 계산이에요(2026-10-31).

광고

청크 크기 정하는 법, 쓰기 방식별 판단표

청크를 키우면 커밋·메타데이터 갱신이 줄고, 줄이면 실패 때 날리는 양과 메모리가 줄어요. 어느 쪽이 무거운지는 라이터가 정해요.

라이터가 하는 일시작값키워도 되는 신호줄여야 하는 신호
JDBC 배치 INSERT·UPDATE500~1,000커밋 대기가 시간의 절반 이상락 대기·복제 지연
JPA merge·persist100~500거의 없음플러시 시간이 급증
외부 API 한 건씩 호출10~100API 가 묶음 요청을 받음타임아웃, 재시작 때 중복 호출
파일 쓰기1,000 이상대부분 키워도 됨동기화 옵션을 켰을 때
메시지 발행(카프카 등)100~500프로듀서가 묶어 보냄재시작 때 중복 발행

외부 API·메시지 발행을 작게 시작하는 이유는 재시작 때 다시 일어나는 부수효과의 양 때문이에요. 롤백돼도 이미 나간 호출과 메시지는 남고, 재시작은 실패한 청크의 첫 아이템부터 다시 하므로 청크가 클수록 중복 호출이 많아요.

100만 건 처리 시 청크 크기별 총 시간 (예시 가정)
01020304050101005001000500010 · 총 처리 시간 41.7분100 · 총 처리 시간 11.7분500 · 총 처리 시간 9분1000 · 총 처리 시간 8.7분5000 · 총 처리 시간 8.4분8.4

10→100 에서 30분이 줄지만 500→5,000 은 0.6분만 줄어요. 고정 비용 20ms 는 예시값이라 실제 값을 재서 넣어야 해요

단위: 분 · 자료: 건당 처리 0.5ms, 청크마다 고정 비용(커밋·메타데이터 2회 갱신·왕복) 20ms 로 가정해 직접 계산
표로 보기
구분총 처리 시간
1041.7
10011.7
5009
10008.7
50008.4

계산식은 총 시간 = 건수 × 건당 시간 + (건수 ÷ 청크 크기) × 청크당 고정 비용 이에요. 앞 항은 청크와 상관없이 500초(8.3분)로 고정이고, 뒤 항은 청크 10에서 2,000초, 500에서 40초, 5,000에서 4초예요. 앞 항이 지배하는 구간에서 더 키우면 실패 범위만 넓어져요.

청크당 고정 비용은 같은 데이터를 청크 100과 1,000으로 돌려 총 시간 차이를 커밋 횟수 차이로 나누면 나와요. BATCH_STEP_EXECUTION 의 COMMIT_COUNT 와 시작·종료 시각을 쓰면 돼요.

재시작 방식 고르기, 리더 종류별 판단표

위치를 되찾는 방법이 리더마다 달라 재시작 비용과 데이터 변경 내성이 달라요.

리더재시작 때 위치 찾기재시작 비용실패 후 원본이 바뀌면
FlatFileItemReader앞 n줄을 다시 읽고 버림파일 앞부분 재읽기파일 교체 시 위치 틀어짐
JdbcCursorItemReader쿼리 재실행 후 n행 넘김(기본 한 행씩)앞 n행이 다시 전송됨앞쪽 행 추가·삭제 시 엉뚱한 곳
JdbcPagingItemReader마지막 정렬 키 다음부터 조회거의 없음비교적 안전(정렬 키 유일 조건)
JpaPagingItemReader페이지 번호로 OFFSET 계산OFFSET 만큼 다시 훑음건너뜀·중복 가능
처리 표시 컬럼 + saveState(false)미처리 행만 다시 조회미처리 행만가장 안전(상태가 데이터에 있음)
ItemReader 만 구현한 직접 만든 리더없음처음부터 전부해당 없음

커서 리더가 "한 행씩"인 이유는 6.0.6 에서 driverSupportsAbsolute 기본값이 false 라 ResultSet.next() 를 n번 불러 위치를 맞추기 때문이에요. 80만 번째에서 실패했다면 앞 80만 행이 한 번 더 넘어와요.

처리 표시 컬럼은 공식 문서의 상태 저장 끄기 절이 소개하는 패턴이에요. 라이터가 플래그를 true 로 바꾸고 리더는 WHERE PROCESSED_IND = false 로 읽어요. 행 번호를 저장하면 줄어든 결과에서 또 건너뛰므로 saveState(false) 로 꺼요.

입력이 파일인지, 처리 표시 컬럼을 추가할 수 있는지, 유일한 정렬 키가 있는지를 차례로 물어 FlatFileItemReader, saveState false 와 처리 표시 컬럼, JdbcPagingItemReader, 스냅샷 테이블 위의 커서 리더 중 하나로 가는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸으로 가요

페이징 리더의 정렬 키는 데이터베이스 리더 문서가 "데이터가 빠지지 않도록 유일 키 제약이 있어야 한다"고 적어요. 생성일시만으로 정렬하면 페이지 경계의 같은 값 행이 빠지니 (created_at, id) 처럼 덧붙여요. 기본키 종류의 영향은 UUID vs 자동증가 정수 기본키 글에 정리했어요.

OFFSET 페이징 비용, 직접 계산

JpaPagingItemReader 는 6.0.6 에서 setFirstResult(page × pageSize), 즉 OFFSET 으로 읽어요. PostgreSQL 문서는 OFFSET 으로 건너뛴 행도 서버가 계산해야 해서 큰 OFFSET 은 비효율적이라고 적어요.

k번째 페이지(0부터)는 k × p 행을 건너뛰고 p 행을 읽으므로, 페이지 P개를 모두 읽을 때 훑는 행은 p × P × (P + 1) ÷ 2 예요. 100만 건 기준으로 계산하면 이래요.

100만 건을 끝까지 읽을 때 DB가 훑는 행 수
OFFSET pageSize 10OFFSET pageSize 10 · 50,000백만 행50,000백만 행OFFSET pageSize 100OFFSET pageSize 100 · 5,000백만 행5,000백만 행OFFSET pageSize 1000OFFSET pageSize 1000 · 500백만 행500백만 행키 기준 페이징키 기준 페이징 · 1백만 행1백만 행

pageSize 를 10에서 1,000으로 키우면 100분의 1로 줄지만, 여전히 실제 건수의 500배예요

단위: 백만 행 · 자료: OFFSET 으로 건너뛴 행도 서버가 모두 읽는다고 보고 p×P×(P+1)÷2 로 직접 계산 (인덱스로 시작 위치를 찾는 키 기준 페이징은 읽는 행 수 = 건수)
표로 보기
구분훑는 행
OFFSET pageSize 1050,000
OFFSET pageSize 1005,000
OFFSET pageSize 1000500
키 기준 페이징1

건수가 늘면 제곱으로 커지는 구조는 그대로라, 100만 건이 넘으면 JdbcPagingItemReader 로 바꾸거나 ID 범위로 파티셔닝해 파티션 하나를 작게 유지해요.

skip·retry 설정 판단표와 6.0 기본값

6.0.6 ChunkOrientedStepBuilder 소스 기준 기본값이에요.

설정6.0.6 빌더 기본값놓치기 쉬운 점
faultTolerant()꺼짐꺼져 있으면 retry·skip 이 무시돼요
retryLimit(n)0retry() 로 예외만 적어도 횟수 기본값이 0이라 retryLimit 을 같이 줘요
retry() 없이 retryLimit(n) 만모든 Exception 재시도Error(메모리 부족 등)는 재시도하지 않아요
skipLimit(n)10한도를 넘으면 SkipLimitExceededException 으로 스텝 실패
skip() 없이 skipLimit(n) 만아무 예외도 건너뛰지 않음건너뛸 예외 목록이 비어 있어서예요

skip 설정 문서는 skip 여부를 데이터의 의미를 아는 사람이 정할 일이라며, 송금 같은 금융 데이터는 skip 하면 안 된다는 예를 들어요. 실패 종류로 나누면 이래요.

실패 종류예고를 처리
일시적커넥션 끊김, 락 타임아웃, API 503retry (2~3회, 대기 간격)
결정적파싱 오류, 필수 값 누락, 제약 위반skip + SkipListener 로 기록
정확성이 절대 조건송금, 재고 차감skip 금지, 실패 후 수정·재시작
시스템 전체 문제DB 다운, 디스크 가득빨리 실패

쓰기 skip 비용, 청크가 클수록 커지는 이유

6.0.6 의 ChunkOrientedStep 은 쓰기에서 skip 가능한 예외가 나면 청크를 롤백하고 "스캔 모드"로 들어가 트랜잭션 하나에 아이템 하나씩 다시 처리하고 써요. 목록 중 어느 건이 불량인지 모르니 하나씩 확인하는 거예요.

그래서 크기 c 인 청크에 불량이 섞이면 단건 트랜잭션이 c번 생기고, 기본 설정이면 프로세서도 다시 불려요. processorNonTransactional() 로 첫 결과를 재사용할 수 있지만 JPA 엔티티처럼 라이터가 객체를 바꾸면 켜지 말라고 주석이 경고해요.

불량 100건이 섞인 100만 건, 청크 크기별 단건 재처리 트랜잭션 수
0100,000200,000300,000400,00010 · 단건 트랜잭션 1,000건1,00010100 · 단건 트랜잭션 9,951건9,951100500 · 단건 트랜잭션 48,773건48,7735001000 · 단건 트랜잭션 95,167건95,16710005000 · 단건 트랜잭션 393,485건393,4855000

청크 5,000이면 100만 건 중 약 39%가 한 건씩 다시 써져요

단위: 건 · 자료: 불량이 고르게 흩어졌다고 보고 (청크 수 × 불량이 섞일 확률 1-(1-0.0001)^c × c) 로 직접 계산
표로 보기
구분단건 트랜잭션
101,000
1009,951
50048,773
100095,167
5000393,485

청크 1,000이면 청크 95개에 불량이 섞여 9만 5천 건이 한 건씩 다시 써져요. 앞 절 가정(트랜잭션당 고정 비용 20ms)이면 약 32분이 더 들어, 8.7분 배치가 약 40분이 돼요. 대책은 둘이에요.

광고
광고

재시작 장애 확인 순서, 증상별 체크리스트

메타데이터 테이블 BATCH_STEP_EXECUTION 과 BATCH_STEP_EXECUTION_CONTEXT 를 먼저 열어 보는 게 가장 빨라요.

증상흔한 원인고치는 법
재시작했는데 처음부터 읽음컨텍스트에 <이름>.read.count 없음, 리더 이름 변경리더 name 고정
키가 아예 안 생김@StepScope 빈이 ItemReader 타입 반환반환 타입을 구현 클래스로
감싼 리더의 위치가 안 남음안쪽 리더가 스텝에 등록 안 됨빌더 .stream(안쪽리더)
일부 행이 빠지거나 두 번 처리커서·OFFSET 리더인데 원본이 바뀜, 정렬 키 중복키 기준 페이징·처리 표시 컬럼
재시작 거부, 상태가 STARTED 그대로강제 종료로 상태가 안 바뀜6.0 JobOperator.recover() 후 재시작
같은 파라미터로 실행 안 됨이미 COMPLETED(JobInstanceAlreadyCompleteException)식별 파라미터 바꾸기
재시작 때 메일·API 가 또 나감롤백돼도 외부 호출은 남음멱등 키, 발송 기록을 같은 트랜잭션에
PostgreSQL 커서 리더 메모리 부족드라이버가 결과 전체를 올림fetchSize + connectionAutoCommit(false)

@StepScope 반환 타입 문제는 late binding 문서가 명시해요. 반환 타입이 ItemReader 면 프록시가 ItemStream 이 아니라서 open·update 가 불리지 않고, 에러 없이 재시작만 조용히 안 돼요. 래퍼 등록은 ItemStream 등록 문서에 예제가 있어요.

recover() 는 6.0 에 새로 생겼어요. 6.0 새 기능 문서는 그 전에는 DB를 직접 고쳐야 했다고 설명해요. 5.x 라면 상태를 고치는 SQL 을 운영 문서에 미리 적어 둬요.

PostgreSQL 항목은 PostgreSQL JDBC 드라이버 문서 때문이에요. 자동 커밋이 꺼져 있고 fetch size 가 있어야 커서로 나눠 받고, 아니면 결과 전체를 한 번에 가져와요. HikariCP 의 autoCommit 기본값은 true 라 그대로면 걸려요.

재시작 장애를 확인하는 다섯 단계: 메타데이터 상태 확인, read.count 키 확인, 리더 빈 반환 타입과 등록 확인, 리더 종류와 정렬 키 확인, 라이터 부수효과 확인
위에서 아래로, 앞 단계에서 원인이 나오면 멈춰요

도입 전에 따질 것, 청크 처리가 맞지 않는 경우

청크 처리는 아이템끼리 독립적이고 한 번에 못 올리는 양에 맞아요. 아래라면 다른 방식을 먼저 따져요.

  1. SQL 한 문장으로 끝나는 변환: INSERT ... SELECT 로 되는 일을 리더·라이터로 옮기면 데이터가 네트워크를 두 번 건너요. 한 문장이 너무 크면 ID 범위로 나눠 돌리는 태스크릿이 더 단순해요.
  2. 앞 아이템 결과로 다음을 계산: 누적 잔액처럼 순서에 의존하면 누적값을 ExecutionContext 에 따로 저장해야 하고, 틀리면 뒤가 전부 틀려요.
  3. 처음부터 다시 돌려도 되는 소량 작업: 수천 건이면 "실패하면 전체 롤백 후 다시"가 운영이 쉬워요.
  4. 실시간에 가까운 처리: 몇 초 안에 반영돼야 하면 배치보다 메시지 소비자가 맞아요.

실행 시각을 크론식으로 잡는다면 크론 표현식 해석기로 다음 실행 시각을 확인해 타임존 실수를 막아요. 재시작은 실제 DB로 실패를 일부러 내 봐야 확인되니, CI 비용은 Testcontainers 수명 고르는 법을 참고해요.

광고
광고

자주 묻는 질문

스프링 배치 청크 크기 기본값이 있나요?

없어요. chunk(int) 에 직접 넣어야 하고 0 이하면 오류가 나요. 위 판단표의 시작값에서 출발해 커밋 횟수와 총 시간을 재며 조정해요.

재시작하면 실패한 아이템 하나만 다시 하나요?

아니요. 실패한 청크의 첫 아이템부터 다시 해요. 청크 1,000에서 990번째가 실패했다면 그 청크의 앞 989건도 다시 처리되니, 부수효과가 있는 라이터는 멱등하게 만들어요.

skip 이 발생했는데 처리 속도가 갑자기 느려졌어요.

쓰기 단계 skip 이면 정상이에요. 청크를 롤백하고 한 건씩 따로 커밋하며 불량을 찾기 때문이에요. 검증을 프로세서로 옮기거나 청크를 줄여요.

saveState(false) 는 언제 쓰나요?

처리 표시 컬럼처럼 데이터 상태로 진행을 표시할 때 써요. 이때 위치를 저장하면 줄어든 결과에서 n행을 또 건너뛰어 데이터를 빠뜨려요.

5.x 의 chunk(int, transactionManager) 를 그대로 써도 되나요?

6.0.6 에서도 동작하지만 제거 예정으로 표시돼 있고 예전 SimpleStepBuilder 를 돌려줘요. 새 코드는 chunk(int) 뒤에 .transactionManager(tm) 을 붙이고, 옮길 때 위 skip·retry 기본값 표를 다시 확인해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 배치 청크 처리 원리 — Reader·Writer와 재시작에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#스프링 배치 청크 크기#chunk size 정하는 법#스프링 배치 재시작#skipLimit 설정#JdbcPagingItemReader pageSize#saveState false#Spring Batch 6#배치 중복 처리
← 이전 글리랭커 고르는 법, 크로스 인코더 API·직접 서빙·LLM 비용과 지연 비교다음 글 →Mockito doReturn vs when 고르는 법, spy·매처·strict 스텁 점검표
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →