Spring Batch의 구조 - 청크, 파티셔닝, 재시작과 메타데이터
배치를 직접 만들면 결국 같은 것들을 다시 만들게 된다. 어디까지 했는지 기억하기, 실패한 지점부터 다시 하기, 몇 건씩 끊어서 커밋하기, 병렬로 돌리기. Spring Batch는 이 넷을 규격화한 프레임워크이고, 구조를 알면 “왜 이 설정이 있는가”가 설명된다.
Job, Step, 그리고 메타데이터
Job은 여러 Step의 나열이고, 실행 이력은 JobRepository가 DB에 기록한다. 이 메타데이터가 프레임워크의 절반이다.
| 테이블 | 담는 것 |
|---|---|
BATCH_JOB_INSTANCE | Job 이름 + 파라미터의 조합. “어떤 실행인가”의 정체 |
BATCH_JOB_EXECUTION | 그 인스턴스의 실제 실행. 상태, 시작·종료 시각 |
BATCH_STEP_EXECUTION | Step별 읽은 수, 쓴 수, 건너뛴 수, 커밋 수 |
BATCH_*_EXECUTION_CONTEXT | 재시작에 필요한 위치 정보 |
메타데이터에서 규칙 하나가 나온다. 같은 Job 이름 + 같은 파라미터는 한 번만 성공할 수 있다. 공식 문서는 이 관계를 JobInstance = Job + 식별용 JobParameters로 정의한다. 실행(JobExecution)은 실패할 수도 있지만, 실행이 성공으로 끝나기 전까지 그 JobInstance는 완료로 보지 않는다. 같은 파라미터로 다시 돌리면 새 JobExecution이 생길 뿐 JobInstance는 하나다(Spring Batch: Domain Language). 이미 성공한 조합으로 다시 실행하면 JobInstanceAlreadyCompleteException이다. 그래서 날짜를 파라미터로 넣어 하루에 한 인스턴스를 만드는 관행이 생겼다. 이것은 불편이 아니라 중복 실행 방지 장치다. 반대로 실행 시각을 넣으면 이 장치가 꺼진다(아래 “이 설명이 깨지는 곳”).
Tasklet과 Chunk
Step의 구현은 둘이다.
Tasklet은 “한 덩어리 작업”이다. 파일 이동, 테이블 정리, 프로시저 호출처럼 나눌 필요가 없는 일에 쓴다. 트랜잭션이 하나이므로 그 안에서 대량 처리를 하면 트랜잭션이 길어지고 메모리가 쌓인다.
Chunk는 ItemReader → ItemProcessor → ItemWriter를 N건씩 묶어 처리하고 묶음마다 커밋한다.
1
read read read ... (chunk size만큼) → process → write → commit → 다음 묶음
얻는 것이 셋이다. 트랜잭션이 짧아지고, 메모리에 올라오는 것이 묶음 크기로 제한되며, 실패해도 그 묶음 전까지는 커밋돼 있다.
Heap Dump가 가리킨 곳에서 Tasklet을 Chunk로 바꾼 이유가 이것이었다. 문제는 데이터 크기가 아니라 영속성 컨텍스트(JPA가 조회·저장한 엔티티를 트랜잭션 동안 들고 있는 공간)에 쌓인 엔티티였다. 묶음마다 커밋하면 그 컨텍스트가 비워진다.
청크 크기를 정하는 기준
크게 잡으면 커밋 횟수가 줄어 처리량이 오르고, 작게 잡으면 트랜잭션과 메모리가 가벼워진다. 교환의 양쪽 끝은 이렇다.
- 너무 크면: 트랜잭션이 길어져 DB 잠금 보유가 늘고, 메모리가 쌓이며, 실패 시 되돌리는 양이 커진다.
- 너무 작으면: 커밋과 flush(영속성 컨텍스트의 변경을 DB로 보내는 일) 왕복이 잦아 처리량이 떨어진다.
정답은 측정이다. 다만 JPA를 쓴다면 chunkSize와 하이버네이트의 batch_size를 맞추는 것이 흔한 출발점이다. 둘이 어긋나면 flush가 예상과 다른 시점에 일어난다.
실패 다루기: skip과 retry
faultTolerant()를 켜면 두 가지가 생긴다.
skip은 특정 예외를 만난 항목을 건너뛴다. 데이터 품질 문제(형식 오류, 참조 없음)에 쓴다. 한도(skipLimit)를 반드시 둔다. 한도는 읽기·처리·쓰기에서 일어난 skip 전체에 함께 적용되고, 한도에 닿은 다음 예외는 Step을 실패시킨다(Spring Batch: Configuring Skip Logic). 한도가 없으면 전부 실패해도 Job은 “성공”으로 끝난다.
retry는 일시적 실패를 다시 시도한다. 락 경합이나 순간적인 네트워크 오류에 쓴다.
retry나 skip이 걸리면 그 묶음은 항목 단위로 다시 처리된다. 어느 항목이 문제였는지 알아내기 위해 묶음을 쪼개 재실행하기 때문이다. 그래서 ItemProcessor가 멱등(같은 입력을 여러 번 처리해도 결과가 한 번과 같음)하지 않으면 부작용이 두 번 일어난다. 프로세서에 외부 호출이나 상태 변경을 넣지 않아야 하는 이유다.
병렬화의 세 층
| 방법 | 나누는 단위 | 적합한 경우 |
|---|---|---|
| Multi-threaded Step | 같은 Step 안에서 묶음을 여러 스레드가 | Reader가 스레드 안전할 때 |
| Parallel Steps | 서로 독립인 Step들을 동시에 | 단계 간 의존이 없을 때 |
| Partitioning | 데이터를 범위로 나눠 Step 인스턴스를 여럿 | 대량 데이터의 수평 분할 |
Partitioning이 가장 널리 쓰인다. Partitioner가 “1~10000번, 10001~20000번” 식으로 범위를 나누고, 각 파티션이 독립된 StepExecution을 갖는다. 메타데이터에 파티션별 진행이 기록되므로 재시작도 파티션 단위로 된다.
Multi-threaded Step은 버전에 따라 주의할 대상이 다르다. Spring Batch 5.2 문서는 reader와 writer 상당수가 상태를 갖고, Spring Batch가 제공하는 reader·writer 대부분이 멀티스레드용으로 설계되지 않았다고 적는다(5.2: Scaling and Parallel Processing). 상태를 가진 Reader(JpaPagingItemReader 등)를 여러 스레드가 쓰면 항목을 건너뛰거나 중복한다. 그래서 5.x에서 병렬이 필요하면 Partitioning으로 가는 것이 안전하다. 6.0 문서는 다르다. 읽기와 쓰기는 Step을 실행하는 메인 스레드가 직렬로 하므로 ItemReader와 ItemWriter는 스레드 안전할 필요가 없고, 대신 여러 스레드가 동시에 부르는 ItemProcessor가 스레드 안전해야 한다(Spring Batch: Scaling and Parallel Processing).
이 설명이 깨지는 곳
- 재시작이 항상 안전한 것은 아니다. 프레임워크는 “어디까지 커밋됐는가”를 기억할 뿐, 그 사이에 일어난 외부 부작용은 모른다. 재시작 가능한 배치는 처리 자체가 멱등해야 한다.
JobParameters에 실행 시각을 넣으면 중복 실행 방지가 사라진다. 매번 다른 인스턴스가 되므로 두 번 돌려도 막지 않는다. 의도한 것인지 확인해야 한다.- Tasklet이 항상 나쁜 것은 아니다. 나눌 필요 없는 작업에 Chunk를 씌우면 복잡도만 는다.
- 메타데이터 테이블도 자란다. 오래된 실행 이력의 정리 정책이 필요하다.
무엇을 재면 확인되는가
- 청크 크기를 100/500/1000/5000으로 바꿔 가며 처리 시간, 힙 사용량, DB 잠금 보유 시간을 함께 본다.
- Partitioning의 파티션 수를 바꿔 가며 처리량이 포화되는 지점을 찾는다. DB가 병목이면 파티션을 늘려도 안 는다.
- 재시작을 일부러 유발해(중간에 강제 종료) 재개 지점이 정확한지, 부작용이 두 번 일어나지 않는지 확인한다.
대량 배치 3부에서 청크 병렬 처리를 다뤘고, 그때 기준은 처리량이었다. 지금이라면 컨테이너 CPU 상한과 스로틀링을 함께 봤을 것이다.
실무와의 접점
대량 배치 시리즈에서 만든 것은 프레임워크가 주는 것과 같은 문제였다. 어디까지 했는지 기록하기, 실패 지점부터 재개하기, 상호 배제. 직접 만들면서 상태 저장소(DELETING·REGISTERING·COMPLETED)를 둔 것이 사실상 BATCH_STEP_EXECUTION의 축소판이었다. 그 경험에서 얻은 결론은 프레임워크를 쓰든 안 쓰든 배치에 필요한 것의 목록이 같다는 것이다.
정리
- 재시작과 중복 실행 방지는 모두 메타데이터에서 나온다. 파라미터에 시각을 넣으면 그 보호가 사라진다.
- 한도 없는 skip은 전부 실패한 Job을 성공으로 보고한다.
- retry·skip 시 묶음이 항목 단위로 재처리되므로
ItemProcessor는 멱등해야 한다. - Multi-threaded Step에서 스레드 안전해야 하는 쪽은 버전마다 다르다. 5.x는 Reader, 6.0은 Processor다.
댓글
아직 댓글이 없습니다