배치를 짤 때 먼저 막히는 칸이 chunk(?) 안의 숫자예요. 결론부터 말하면 청크 크기는 100~1,000 사이에서 시작하고, 커밋 한 번의 고정 비용이 건당 처리 시간보다 훨씬 클 때만 키워요. 쓰기 단계에서 skip 이 자주 나는 데이터라면 오히려 줄여야 하고, 재시작이 제대로 되는지는 숫자보다 리더 이름·정렬 키·리더 종류가 결정해요.
이 글은 내부 순서 대신 운영에서 고를 값과 장애 때 볼 순서를 다뤄요. 기준은 Spring Batch 6.0.6 소스와 공식 레퍼런스의 청크 처리 장이고, 시간·트랜잭션 수는 조건을 밝힌 직접 계산이에요(2026-10-31).
청크 크기 정하는 법, 쓰기 방식별 판단표
청크를 키우면 커밋·메타데이터 갱신이 줄고, 줄이면 실패 때 날리는 양과 메모리가 줄어요. 어느 쪽이 무거운지는 라이터가 정해요.
| 라이터가 하는 일 | 시작값 | 키워도 되는 신호 | 줄여야 하는 신호 |
|---|---|---|---|
| JDBC 배치 INSERT·UPDATE | 500~1,000 | 커밋 대기가 시간의 절반 이상 | 락 대기·복제 지연 |
JPA merge·persist | 100~500 | 거의 없음 | 플러시 시간이 급증 |
| 외부 API 한 건씩 호출 | 10~100 | API 가 묶음 요청을 받음 | 타임아웃, 재시작 때 중복 호출 |
| 파일 쓰기 | 1,000 이상 | 대부분 키워도 됨 | 동기화 옵션을 켰을 때 |
| 메시지 발행(카프카 등) | 100~500 | 프로듀서가 묶어 보냄 | 재시작 때 중복 발행 |
외부 API·메시지 발행을 작게 시작하는 이유는 재시작 때 다시 일어나는 부수효과의 양 때문이에요. 롤백돼도 이미 나간 호출과 메시지는 남고, 재시작은 실패한 청크의 첫 아이템부터 다시 하므로 청크가 클수록 중복 호출이 많아요.
10→100 에서 30분이 줄지만 500→5,000 은 0.6분만 줄어요. 고정 비용 20ms 는 예시값이라 실제 값을 재서 넣어야 해요
표로 보기
| 구분 | 총 처리 시간 |
|---|---|
| 10 | 41.7 |
| 100 | 11.7 |
| 500 | 9 |
| 1000 | 8.7 |
| 5000 | 8.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) 로 꺼요.
페이징 리더의 정렬 키는 데이터베이스 리더 문서가 "데이터가 빠지지 않도록 유일 키 제약이 있어야 한다"고 적어요. 생성일시만으로 정렬하면 페이지 경계의 같은 값 행이 빠지니 (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만 건이 넘으면 JdbcPagingItemReader 로 바꾸거나 ID 범위로 파티셔닝해 파티션 하나를 작게 유지해요.
skip·retry 설정 판단표와 6.0 기본값
6.0.6 ChunkOrientedStepBuilder 소스 기준 기본값이에요.
| 설정 | 6.0.6 빌더 기본값 | 놓치기 쉬운 점 |
|---|---|---|
faultTolerant() | 꺼짐 | 꺼져 있으면 retry·skip 이 무시돼요 |
retryLimit(n) | 0 | retry() 로 예외만 적어도 횟수 기본값이 0이라 retryLimit 을 같이 줘요 |
retry() 없이 retryLimit(n) 만 | 모든 Exception 재시도 | Error(메모리 부족 등)는 재시도하지 않아요 |
skipLimit(n) | 10 | 한도를 넘으면 SkipLimitExceededException 으로 스텝 실패 |
skip() 없이 skipLimit(n) 만 | 아무 예외도 건너뛰지 않음 | 건너뛸 예외 목록이 비어 있어서예요 |
skip 설정 문서는 skip 여부를 데이터의 의미를 아는 사람이 정할 일이라며, 송금 같은 금융 데이터는 skip 하면 안 된다는 예를 들어요. 실패 종류로 나누면 이래요.
| 실패 종류 | 예 | 고를 처리 |
|---|---|---|
| 일시적 | 커넥션 끊김, 락 타임아웃, API 503 | retry (2~3회, 대기 간격) |
| 결정적 | 파싱 오류, 필수 값 누락, 제약 위반 | skip + SkipListener 로 기록 |
| 정확성이 절대 조건 | 송금, 재고 차감 | skip 금지, 실패 후 수정·재시작 |
| 시스템 전체 문제 | DB 다운, 디스크 가득 | 빨리 실패 |
쓰기 skip 비용, 청크가 클수록 커지는 이유
6.0.6 의 ChunkOrientedStep 은 쓰기에서 skip 가능한 예외가 나면 청크를 롤백하고 "스캔 모드"로 들어가 트랜잭션 하나에 아이템 하나씩 다시 처리하고 써요. 목록 중 어느 건이 불량인지 모르니 하나씩 확인하는 거예요.
그래서 크기 c 인 청크에 불량이 섞이면 단건 트랜잭션이 c번 생기고, 기본 설정이면 프로세서도 다시 불려요. processorNonTransactional() 로 첫 결과를 재사용할 수 있지만 JPA 엔티티처럼 라이터가 객체를 바꾸면 켜지 말라고 주석이 경고해요.
청크 1,000이면 청크 95개에 불량이 섞여 9만 5천 건이 한 건씩 다시 써져요. 앞 절 가정(트랜잭션당 고정 비용 20ms)이면 약 32분이 더 들어, 8.7분 배치가 약 40분이 돼요. 대책은 둘이에요.
- 검증을 프로세서로 옮기기: 필수 값·형식 불량은 프로세서에서
null을 돌려주거나 프로세서 skip 으로 처리해요. 프로세서 skip 은 롤백·스캔을 일으키지 않아요. - 청크 크기 낮추기: 쓰기에서만 드러나는 불량(제약 위반)이 잦으면 위 공식에 실제 비율을 넣어 100~200 과 비교해요.
재시작 장애 확인 순서, 증상별 체크리스트
메타데이터 테이블 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 라 그대로면 걸려요.
도입 전에 따질 것, 청크 처리가 맞지 않는 경우
청크 처리는 아이템끼리 독립적이고 한 번에 못 올리는 양에 맞아요. 아래라면 다른 방식을 먼저 따져요.
- SQL 한 문장으로 끝나는 변환:
INSERT ... SELECT로 되는 일을 리더·라이터로 옮기면 데이터가 네트워크를 두 번 건너요. 한 문장이 너무 크면 ID 범위로 나눠 돌리는 태스크릿이 더 단순해요. - 앞 아이템 결과로 다음을 계산: 누적 잔액처럼 순서에 의존하면 누적값을 ExecutionContext 에 따로 저장해야 하고, 틀리면 뒤가 전부 틀려요.
- 처음부터 다시 돌려도 되는 소량 작업: 수천 건이면 "실패하면 전체 롤백 후 다시"가 운영이 쉬워요.
- 실시간에 가까운 처리: 몇 초 안에 반영돼야 하면 배치보다 메시지 소비자가 맞아요.
실행 시각을 크론식으로 잡는다면 크론 표현식 해석기로 다음 실행 시각을 확인해 타임존 실수를 막아요. 재시작은 실제 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 기본값 표를 다시 확인해요.
정리
- 청크 크기는
건수 × 건당 시간 + 커밋 수 × 커밋당 비용으로 따지고, 뒤 항이 작아진 뒤엔 더 키우지 않아요. - 재시작은 리더가 좌우해요. 처리 표시 컬럼과
saveState(false), 유일 키 페이징 순으로 고르고, 커서·OFFSET 리더는 원본이 안 바뀔 때만 써요. - 6.0 에서
skip()없는skipLimit은 아무것도 건너뛰지 않고, 쓰기 skip 은 청크 크기만큼 단건 트랜잭션을 만들어요. - 재시작이 안 되면
read.count키 → 리더 빈 반환 타입·등록 → 정렬 키 → 라이터 부수효과 순으로 봐요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 배치 청크 처리 원리 — Reader·Writer와 재시작에서 도식과 코드로 단계별로 풀었어요.