Spring Batch Q&A — 선택지 8개, 실패 모드 8가지, 25문답
앞의 네 편이 쌓아 온 판단을 세 벌로 다시 짠다. 아직 무엇으로 읽고 어떻게 벌릴지 고르는 중이라면 선택지 표를, 이미 도는 배치가 윈도우를 넘기거나 숫자가 안 맞는다면 증상에서 출발하는 실패 모드 표를, 그 선택의 근거를 대야 한다면 25문답을 쓴다.
앞의 네 편은 배치를 만드는 순서를 따랐다. 왜 배치로 넘기는지 정하고, 프레임워크가 무엇을 기억해 주는지 보고, 한 Step 안의 흐름을 따라간 다음 병렬로 벌렸다. 이 글은 그 순서를 버린다 — 이 지식이 필요해지는 순간은 만드는 순서대로 오지 않는다. 아직 아무것도 정하지 않았다면 선택지 표에서, 이미 도는 배치가 안 끝나거나 건수가 안 맞는다면 실패 모드 표의 증상 열에서 거꾸로 올라가고, 문답은 그 선택의 근거를 대야 할 때 쓴다.
| 편 | 그 편이 세운 축 | 이 글에서 받는 묶음 |
|---|---|---|
| 1. 실시간으로 하면 안 되는 일 | 이 일을 왜 배치로 하는가 | 운영과 규모 5문답 |
| 2. 아키텍처와 메타데이터 | 어디까지 했는지 누가 기억하는가 | 프레임워크와 메타데이터 4문답 · 방어와 멱등성 3문답 |
| 3. Chunk 지향 처리와 Reader / Writer | 한 Step 안의 데이터 흐름 | chunk와 Reader 4문답 · JPA 3문답 |
| 4. 성능 최적화와 병렬화 | 느릴 때 손대는 순서 | 병렬화 6문답 |
오른쪽 열이 이 글의 목차다.
선택지 8개 — 무엇을 얻고 무엇을 내주는가
| 선택지 | 장점 | 단점 | 언제 쓰나 |
|---|---|---|---|
| Cursor Reader | 메모리 적음, 인덱스 불요 | 커넥션 장시간 점유, 재시작 어려움 | 데이터가 작고 짧게 끝나는 잡 |
| Paging Reader | 재시작 용이, 커넥션 짧게 점유 | 페이지마다 쿼리 오버헤드, 정렬·인덱스 필요 | 대용량 표준 선택 |
| 작은 chunk | 롤백 범위 좁음, 메모리 안전 | 커밋 횟수 증가로 느림 | 처리 실패가 잦은 데이터 |
| 큰 chunk | 처리량 높음 | 메모리 압박, 롤백 비용 큼, 락 유지 김 | 데이터 품질이 검증된 정기 배치 |
| Multi-threaded Step | 구현 간단, 즉효 | Reader thread-safety, 재시작 정확도 상실 | 처리·쓰기가 병목이고 재시작을 포기할 수 있을 때 |
| Parallel Step | 독립 작업 동시 수행 | 단일 작업 가속에는 무용 | 서로 다른 보고서를 동시에 만들 때 |
| Partitioning | 재시작 정확, 확장성 | Partitioner 설계 비용, skew 관리 | 대용량 정기 배치의 정답 |
| JPA 사용 | 도메인 재사용, 생산성 | 영속성 컨텍스트·더티체킹·N+1 비용 | 쓰기량이 크지 않거나 도메인 로직이 복잡할 때 |
여덟 줄의 성격이 셋으로 갈린다. 위의 넷은 양자택일, 가운데 셋은 선택지가 아니라 사다리, 마지막 JPA는 무엇을 고르든 그 위에 따로 얹힌다. 단점 열의 굵은 항목이 하나뿐인 것도 우연이 아니다 — 나머지는 정도의 문제인데 재시작 정확도 상실은 성질의 소멸이다.
실패 모드 8가지 — 증상에서 대응으로
| 실패 모드 | 증상 | 원인 | 대응 |
|---|---|---|---|
| OOM | 배치 중반 힙 고갈 | chunk 과대, JPA 영속성 컨텍스트 누적, Cursor로 대량 fetch | chunk 축소, clear(), Paging 전환 |
| 배치 윈도우 초과 | 새벽 배치가 오전까지 이어짐 | 데이터 증가에 병렬화가 못 따라감 | 파티셔닝, 증분 처리(전체 재계산 → 델타) |
| 중복 처리 | 같은 데이터가 두 번 반영 | 멱등하지 않은 배치를 재실행 | UPSERT / delete-then-insert |
| 데이터 누락 | 처리 건수가 소스보다 적음 | 멀티스레드 Reader 경합, 무제한 Skip | Reader 동기화 또는 파티셔닝, skipLimit·스킵 로그 |
| 재시작 불가 | 「이미 완료됨」 또는 매번 새 인스턴스 | JobParameters 설계 오류 | 파라미터에 비즈니스 키(대상 일자)만 사용 |
| 온라인 지연 | 배치 시간대에 API 응답 저하 | 락·I/O 경합 | 배치 윈도우 분리, 읽기 복제본, 잠금 전략 재선택 |
| 파티션 straggler | 대부분 끝났는데 하나가 남아 전체 지연 | 데이터 skew | 파티션 수를 스레드 수보다 크게, 키 분포 기반 분할 |
| flush 시점 예외 폭발 | 어느 행이 문제인지 특정 불가 | JDBC batch의 지연 실행 | 사전 검증(Validation) 단계 추가, faultTolerant + 로깅 |
증상 열을 세로로 읽으면 여덟이 둘로 갈린다. 여섯은 시스템이 스스로 신고하지만 중복 처리와 데이터 누락은 배치가 정상 종료한 뒤에 값만 틀려 있다. 두 번째 줄의 「증분 처리」는 이 시리즈가 본문에서 다루지 않은 유일한 대응이다 — 병렬화가 같은 양을 더 빨리 처리한다면 증분 처리는 양 자체를 줄인다.
증상에서 출발하면 순서가 있다
새벽에 알림을 받는 사람에게 먼저 오는 것은 실패 모드가 아니라 증상이다. 신고는 대개 셋 중 하나다.
「배치가 안 끝난다」 — 어제도 이 시간이었는지부터 묻는다. 어제까지 멀쩡했다면 데이터가 급증했거나 다른 것이 같은 시간대에 돌기 시작한 것이고, 아니면 원래 좁았다.
「숫자가 안 맞는다」 — 소스보다 적으면 누락이니 Reader 경합과 무제한 Skip을, 많으면 중복이니 재실행 이력과 멱등성을 본다. 건수는 맞는데 값이 틀렸다면 누적 연산이 두 번 반영됐다.
「재시작이 안 된다」 — 거부되는가, 처음부터 다시 도는가로 갈린다. 증상은 정반대인데 원인은 하나, JobParameters다.
프레임워크와 메타데이터 4문답
| # | 질문 | 답 |
|---|---|---|
| 1 | Spring Batch를 쓰는 이유는. 스크립트로 짜면 안 되나 | 메타데이터 기반 재시작·상태 추적, 트랜잭션 관리, Skip/Retry, 병렬화 인프라를 표준화해 제공한다. 직접 짜면 이 전부를 재발명해야 한다 |
| 2 | Job · JobInstance · JobExecution의 차이는 | Job은 정의, JobInstance는 Job 이름과 JobParameters로 식별되는 논리적 실행, JobExecution은 그 인스턴스의 물리적 1회 시도다. 재시작하면 JobExecution만 늘어난다 |
| 3 | JobRepository가 없으면 무엇이 불가능해지나 | 재시작·중복 실행 방지·처리 이력 추적 전부다. 배치의 신뢰성이 여기서 나온다 |
| 4 | JobParameters 설계를 잘못하면 무슨 일이 나나 | 타임스탬프를 넣으면 매번 새 인스턴스라 재시작이 불가하고, 날짜만 넣으면 같은 날 재실행이 거부된다. 비즈니스 키에 필요 시 강제 재실행 플래그를 더해 절충한다 |
네 문항이 한 사실을 네 각도에서 묻는다. 배치의 신뢰성은 코드가 아니라 테이블에서 온다 — 3번이 정면으로, 2번은 스키마를, 4번은 그 키를 묻는다(편2).
chunk와 Reader 4문답
| # | 질문 | 답 |
|---|---|---|
| 5 | chunk size는 무엇을 결정하나 | 커밋 간격이다. 메모리·롤백 범위·I/O 왕복 횟수의 트레이드오프가 이 값 하나에 매달린다 |
| 6 | chunk size를 무한정 키우면 어떻게 되나 | 메모리 압박, 롤백 비용 증가, 락 보유 시간 증가가 온다. 처리량 이득은 어느 지점부터 사라진다 |
| 7 | Cursor Reader와 Paging Reader를 언제 나눠 쓰나 | 커넥션 점유·메모리·재시작 용이성·인덱스 필요 여부로 판단한다. 대용량 정기 배치는 Paging이다 |
| 8 | Paging Reader의 함정은 | OFFSET이 커질수록 느려지고, 페이지 사이에 데이터가 변하면 중복이나 누락이 생긴다. 키 기반 페이징이나 파티셔닝으로 해결한다 |
6번이 「어느 지점부터 사라진다」로 끝나는 것이 이 묶음의 요지다 — chunk size에는 정답이 없고 측정으로 좁히는 값만 있다. 8번의 함정 둘은 무게가 달라서, OFFSET 지연은 느려질 뿐이지만 페이지 간 변경은 데이터를 틀어 놓는다(편3).
병렬화 6문답
| # | 질문 | 답 |
|---|---|---|
| 9 | Multi-threaded Step과 Partitioning의 차이는 | 전자는 chunk 단위 병렬 + Reader 공유, 후자는 파티션 단위 병렬 + Reader·StepExecution 독립이다 |
| 10 | Multi-threaded Step에서 Reader가 위험한 이유는 | 대부분의 Reader가 읽기 위치를 상태로 들고 있다. 동시 read()가 중복과 누락을 만든다 |
| 11 | 그 상태 저장 Reader 때문에 재시작에는 무슨 일이 생기나 | ExecutionContext에 어떤 스레드의 위치를 저장할지 정의할 수 없어 진행 위치가 부정확해진다. 실무에서는 saveState(false)로 재시작을 포기하거나 파티셔닝으로 전환한다 |
| 12 | Parallel Step은 언제 쓰나 | 같은 데이터를 쪼개는 것이 아니라 독립적인 작업을 동시에 돌릴 때다. 여러 종류의 일별 보고서를 동시에 만드는 경우가 이에 해당한다 |
| 13 | Partitioner는 무엇을 만들어 넘기나 | Map<String, ExecutionContext> — 파티션 이름과 경계 정보다. worker step이 이 컨텍스트로 자기 범위만 읽는다 |
| 14 | 파티션 수는 스레드 수와 같게 하면 되나 | 아니다. 넉넉히 크게 잡아야 skew로 생기는 straggler를 흡수한다 |
여섯 중 9·10·11이 한 줄로 이어진다. Reader 인스턴스를 공유하는가가 정해지면 thread-safety가 필수인지, 진행 위치가 정의되는지가 따라 정해진다. 14번은 파티션 수가 병렬도가 아니라 작업 단위의 세밀함을 정한다는 데서 나온다(편4).
13번의 파티셔닝은 실행 한 번의 입력 범위를 나누는 것이라 테이블을 물리적으로 나누는 파티셔닝과 이름만 같다 — 저쪽은 저장 구조를 영구히 바꾸고 이쪽은 잡이 끝나면 사라진다.
JPA 3문답
| # | 질문 | 답 |
|---|---|---|
| 15 | 배치에 JPA를 쓸 때 가장 먼저 터지는 문제는 | 영속성 컨텍스트 누적으로 인한 OOM이다. chunk마다 clear()가 필요하다 |
| 16 | 더티 체킹이 배치에서 왜 비싼가 | 커밋할 때 영속 엔티티 전부를 스냅샷과 대조한다. 컨텍스트가 클수록 커밋이 지연된다 |
| 17 | 배치에서 N+1은 어떻게 드러나나 | chunk size × 연관 개수만큼 쿼리가 폭증한다. fetch join·@EntityGraph·DTO 프로젝션으로 대응한다 |
세 답이 전부 chunk size를 곱셈 계수로 데리고 나오고, 원인은 하나다 — 영속성 컨텍스트가 요청 하나의 수명에 맞춰 설계됐는데 배치에서는 몇 시간을 산다. 17번만 발견 방식이 달라서, 아무도 기다리지 않는 배치는 N+1이 있어도 윈도우를 넘긴 어느 날 아침에야 보인다.
방어와 멱등성 3문답
| # | 질문 | 답 |
|---|---|---|
| 18 | Skip과 Retry의 판단 기준은 | 결정적 실패는 Skip, 일시적 실패(네트워크·DB 락·외부 API)는 Retry다 |
| 19 | skipLimit을 크게 잡으면 어떤 위험이 있나 | 데이터 유실이 조용히 진행된다. 한도와 스킵 로그·알림이 함께 있어야 한다 |
| 20 | 배치의 멱등성을 어떻게 확보하나 | delete-then-insert 또는 UPSERT다. 누적 증분 UPDATE는 재실행 시 이중 반영되므로 쓰지 않는다 |
18번과 20번은 층이 다르다. Skip과 Retry가 실행 하나 안의 방어라면 멱등성은 실행과 실행 사이의 방어다. 19번은 그 사이에 낀 경고다 — 한도 없는 Skip은 실패를 성공으로 바꿔 놓는다.
운영과 규모 5문답
| # | 질문 | 답 |
|---|---|---|
| 21 | 배치 메트릭을 Prometheus로 어떻게 수집하나 | 배치는 단명 프로세스라 scrape가 성립하지 않는다. Pushgateway로 push하고 Prometheus가 저장하며 Grafana가 시각화한다 |
| 22 | 배치가 온라인 서비스에 영향을 주지 않게 하려면 | 배치 윈도우 분리, 읽기 복제본, 잠금 전략(낙관·비관) 선택, chunk와 스레드 수로 부하 조절이다 |
| 23 | 낙관적 잠금과 비관적 잠금을 어떻게 고르나 | 경합이 적고 읽기가 많으면 낙관적, 충돌이 잦으면 비관적이다. 배치와 온라인이 동시에 도는 시간대에 특히 중요하다 |
| 24 | Spring Batch로 감당이 안 되는 규모는 어떻게 하나 | 원격 파티셔닝으로 다중 노드까지 확장하거나 Spark 등 분산 처리로 이관한다. Airflow는 실행기가 아니라 오케스트레이션 담당이다 |
| 25 | Tasklet은 언제 쓰나 | Reader-Processor-Writer 구조가 어울리지 않는 단발성 작업(파일 삭제·프로시저 호출·전후 처리)에 쓴다 |
22번이 나열한 넷에는 순서가 있다. 잠금 전략은 떼어 놓기에 실패했을 때의 차선책이다 — 시간대를 나누거나 읽기를 복제본으로 돌리면 같은 행을 다툴 일이 없고, 그것이 안 될 때에야 23번이 문제가 된다(트랜잭션과 동시성 제어).
24번은 이 시리즈의 경계를 긋는다. Spring Batch의 자리는 이미 있는 도메인 코드와 트랜잭션 매니저를 그대로 쓰는 것이라, 데이터가 단일 JVM을 넘어서면 맞는 답이 아니게 된다(편1의 도구 지형).
Q. 배치가 느리다며 스레드를 늘려 달라는 요청이 오면 어떻게 대응하는가
늘리기 전에 어디가 느린지부터 잰다. 읽기·처리·쓰기 단계별 시간을 분리해 재고 CPU·메모리·디스크 I/O·네트워크 사용률을 함께 본다. 단계별 시간이 어디가, 리소스 지표가 왜 느린지를 알려 준다.
병목이 쓰기나 처리에 있고 Reader가 여유롭다면 Multi-threaded Step이 즉효다. 다만 Reader를 여러 스레드가 공유한다는 뜻이라, 상태를 가진 Reader라면 중복·누락과 재시작 정확도 상실이 따라온다.
병목이 읽기에 있다면 스레드를 늘려도 Reader 동기화에서 직렬화될 뿐이므로 Partitioning으로 간다. 입력을 키 범위로 쪼개 파티션마다 독립된 Reader와 StepExecution을 주면 thread-safety 문제가 구조적으로 사라진다.
병렬화보다 먼저 확인할 것이 셋 있다. chunk size가 적정한지, 쓰기가 JDBC batch로 묶여 있는지, 인덱스가 페이징 정렬 키를 타는지다. 이 셋이 잘못돼 있으면 스레드를 늘려도 낭비만 배로 는다.
Q. Multi-threaded Step에서 재시작이 왜 부정확해지고, 그럼에도 쓴다면 무엇을 보완해야 하는가
Spring Batch의 재시작은 ExecutionContext에 저장된 「어디까지 읽었는가」에 의존한다. 단일 스레드에서는 명확하지만, 여러 스레드가 Reader를 공유하면 특정 시점의 진행 위치가 하나로 정의되지 않는다.
스레드 A가 10만 번째를 처리하는 중이고 스레드 B가 12만 번째를 커밋했다고 하자. 저장된 위치가 12만이 되는 순간 A가 처리하던 구간이 유실된다. 그래서 상태 저장을 아예 끄는 saveState(false)로 재시작을 명시적으로 포기하기도 한다. 설정 한 줄처럼 보이지만 운영 전제를 바꾸는 결정이다.
포기한 재시작을 대신할 안전장치는 멱등성이다. 대상 범위를 파라미터로 고정하고 결과를 delete-then-insert나 UPSERT로 반영해 전체 재실행을 언제나 안전하게 만든다.
반대로 재시작 정확도를 포기할 수 없다면 처음부터 Partitioning을 고른다 — 파티션마다 StepExecution이 분리돼 실패한 파티션만 재개된다.
Q. 상품 1천만 건을 CSV로 등록하는 배치를 설계한다면 어떤 순서로 결정하는가
첫째, 제약을 확정한다. 배치 윈도우가 몇 분인지, 그 시간대에 온라인 트래픽이 있는지다. 목표가 「10분 내 1천만 건」이라면 초당 약 1.7만 건이 필요하고, 이 숫자가 이후 선택을 규정한다. 측정값이 아니라 요구사항을 나눈 값이라는 점이 중요하다 — 설계 시작 시점에 알 수 있는 유일한 성능 수치다.
둘째, 단일 스레드로 먼저 완주시켜 기준선을 만든다. 기준선 없이는 개선 여부를 증명할 수 없다.
셋째, 쓰기를 JdbcBatchItemWriter로 묶고 chunk size를 조정한다. 파일 읽기는 싸고 DB 쓰기가 비싸므로 여기서 가장 큰 이득이 난다.
넷째, 그래도 부족하면 병렬화로 간다. 파일 하나를 여러 스레드가 읽는 것은 상태 공유 문제가 크므로, 원본이 택한 것은 파일을 물리적으로 분할해 파티셔닝하는 쪽이다. 파티션마다 자기 파일과 Reader를 갖는다.
마지막이 멱등성이다. 상품 자연키 기준 UPSERT로 만들어 어떤 파티션이 재실행돼도 중복이 생기지 않게 한다. 다섯 단계에 규칙이 하나 있다 — 뒤로 갈수록 되돌리기 어렵다. chunk size는 내일 다시 정하면 되지만, 멱등하지 않은 쓰기는 이중 반영된 데이터를 남긴 뒤에 발견된다.
Q. 배치 메트릭 수집은 웹 서버와 무엇이 다른가
웹 서버는 계속 살아 있으므로 Prometheus가 주기적으로 /actuator/prometheus를 긁어 가면 된다. 그런데 배치는 몇 분 돌고 죽는 단명 프로세스다. 처리량이 가장 궁금한 순간이 잡이 끝나는 지점인데, 정확히 그때 긁으러 올 대상이 사라진다.
그래서 Pushgateway를 쓴다. 배치가 종료 직전 자기 메트릭을 push하면 Prometheus는 계속 살아 있는 Pushgateway를 scrape한다. 아키텍처는 배치 → Pushgateway → Prometheus → Grafana 순이고, 도식은 편1과 편4에 있다.
다만 Pushgateway는 받은 메트릭을 계속 들고 있다. 잡별 라벨 관리와 오래된 메트릭 정리를 하지 않으면 어제 실패한 잡의 숫자를 오늘의 현황으로 읽게 된다. 단명 프로세스를 감시하려고 들여온 중계가 메트릭의 수명을 관리 대상으로 만든다는 것이 청구서다.
정리
이 시리즈의 판단을 네 줄로 줄이면 이렇다.
- 배치를 쓸지는 데이터가 많은가로 정하지 않는다. 정답으로 인정할 데이터의 최대 나이를 먼저 정하고, 그 값이 0이 아닐 때 배치가 가능해진다.
- 신뢰성은 테이블에서 온다. 재시작의 근거가
JobRepository의 기록이라, JobParameters 설계가 재시작 가능 여부를 통째로 결정한다. - chunk size는 커밋 간격이다. 메모리·롤백 범위·왕복 횟수·락 보유 시간이 손잡이 하나에 매달려 있다.
- 병렬화의 갈림길은 Reader를 공유하는가다. 공유하면 싸고 위험하며, 나누면 비싸고 안전하다.
넷을 관통하는 것이 멱등성이다. 재시작을 쓰든 포기하든, 두 번 돌려도 결과가 같은 배치만이 실패해도 되는 배치다.