“배치가 12시간이 넘게 걸려요. 새벽에 시작해도 업무 시작 전에 안 끝나요.”
실무에서 흔히 겪는 문제다. 여기서 배치란 사람이 하나씩 버튼을 눌러 처리하는 대신, 정해진 데이터 묶음을 정해진 순서대로 한 번에 처리하도록 짜 둔 작업을 말한다 — 예를 들어 매일 새벽 전체 고객 데이터를 훑어 포인트를 계산해 반영하는 작업이 배치다.
이 글에서 「코드」는 프로그램 코드가 아니라 고객을 나누는 분류값이다. DB는 데이터를 엑셀 표 같은 테이블에 담고, 표의 가로 한 줄을 행, 세로 한 줄을 칸(컬럼)이라 부른다. 고객 표인 customer 테이블에 code라는 칸이 있고, 고객마다 A부터 G까지 일곱 값 중 하나가 들어 있다고 보면 된다. 배치는 이 일곱 묶음을 하나씩 차례로 처리하는데, 이렇게 차례로 돌리면 가장 느린 묶음 하나가 전체 배치 시간을 좌우한다.
이 글에서는 Spring Batch의 Partitioning과 Multi-threading으로 처리 시간을 줄이는 방법을 실전 사례와 함께 설명한다. Partitioning은 일을 여러 조각으로 나눠 조각마다 따로 돌리는 방식이고(2장), Multi-threading은 조각을 나누지 않은 한 작업 안에서 여러 스레드가 묶음을 나눠 처리하는 방식이다(8장). 이 글이 실제로 쓴 것은 Partitioning이고, Multi-threading은 8장에서 비교만 한다.
1. 문제 상황: 순차 처리의 한계
현재 시스템의 문제점
코드 A부터 G까지 일곱 묶음을 처리하는 배치가 있다. 묶음마다 걸리는 시간은 이렇다.
순차 처리 방식:
코드 A 처리 (30분)
↓
코드 B 처리 (4시간) ← 병목 지점
↓
코드 C 처리 (45분)
↓
코드 D 처리 (1.5시간)
↓
코드 E 처리 (1시간)
↓
코드 F 처리 (2시간)
↓
코드 G 처리 (2.5시간)
↓
보고서 생성 (10분)
총 처리 시간: 12시간 25분
일곱 묶음과 보고서 시간을 더하면 30 + 240 + 45 + 90 + 60 + 120 + 150 + 10 = 745분, 곧 12시간 25분이다.
핵심 문제:
- 각 코드를 1건씩 DB 조회 → 수십만 번의 네트워크 왕복. 배치 프로그램과 DB는 보통 서로 다른 프로그램(대개 다른 컴퓨터)이라, 조회 한 번마다 요청을 네트워크로 보내고 응답이 돌아올 때까지 기다린다. 이 한 번의 오가기를 「왕복」이라 부른다
- 순차 처리 → CPU와 메모리가 놀고 있음. 프로그램은 시간 대부분을 DB 응답을 기다리며 보낸다(I/O 집약). 한 묶음씩 차례로 돌리면 그 기다리는 동안 CPU가 할 일이 없다
- 가장 느린 코드 B(4시간)가 전체 시간을 지배
2. 해결 전략: 2단계 최적화
1단계: 청크 기반 페이징 처리
페이징은 결과를 한 번에 다 가져오지 않고 1000건씩처럼 묶음(페이지)으로 끊어 가져오는 방식이다. 「청크 기반」은 그렇게 가져온 묶음 단위로 가공하고 저장까지 한다는 뜻이다(청크는 3장 Step 2에서 다시 나온다).
아래 코드에 나오는 SELECT * FROM customer WHERE id = ?는 DB에 보내는 SQL 문장이다. SELECT는 「행을 꺼내 와라」, FROM customer는 「customer 테이블에서」, WHERE id = ?는 「id가 ?인 행만」이라는 뜻이다. *는 모든 칸을 가져오라는 뜻이다.
변경 전 (1건씩 조회):
for (int i = 1; i <= 100000; i++) {
// 100,000번 DB 호출!
Customer customer = jdbcTemplate.queryForObject(
"SELECT * FROM customer WHERE id = ?",
new Object[]{i},
customerMapper
);
processCustomer(customer);
}
한 줄씩 따라가기:
jdbcTemplate은 JdbcTemplate 객체다. SQL 한 줄을 DB에 보내고 결과를 자바 객체로 받아 오는 일을 대신해 준다.queryForObject(sql, 파라미터, 매퍼)는 “이 SQL을 실행해서 결과를 딱 한 건만 돌려 달라”는 뜻이다.new Object[]{i}가 SQL의?자리에 들어갈 값이고,customerMapper가 DB에서 온 한 행을Customer객체로 바꿔 주는 역할을 한다.processCustomer(customer)는 꺼내 온 고객 한 명을 가공하는 메서드 자리다. 무엇을 하는지는 여기서 중요하지 않아 생략했고, 바뀐 뒤의 코드에서는 4장의CustomerProcessor가 이 일을 맡는다.- 이 코드는
for문이 10만 번 도는 동안queryForObject를 10만 번 호출한다. 한 번 호출할 때마다 애플리케이션과 DB 사이를 네트워크로 한 번 왕복하므로, 그 왕복이 10만 번 쌓이는 것이 느려지는 진짜 이유다.
변경 후 (1000건씩 페이징):
@Bean
public JdbcPagingItemReader<Customer> customerReader(DataSource dataSource) {
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("code", "A");
return new JdbcPagingItemReaderBuilder<Customer>()
.name("customerReader")
.dataSource(dataSource)
.selectClause("select id, name, code, status")
.fromClause("from customer")
.whereClause("where code = :code")
.sortKeys(Map.of("id", Order.ASCENDING))
.beanRowMapper(Customer.class) // 한 행을 Customer 객체로 바꾼다
.parameterValues(parameterValues)
.pageSize(1000) // 1000건씩 페이징
.build();
}
한 줄씩 따라가기:
@[Bean](/term/bean-annotation)은 “이 메서드가 돌려주는 객체를 스프링이 관리하는 빈으로 등록해 달라”는 표시다.customerReader()메서드가 돌려주는 Reader 객체 하나가 빈이 된다.- 매개변수
DataSource dataSource는 DB에 연결하는 길을 들고 있는 객체(DataSource)다. 우리가 만들지 않고 스프링이 넣어 준다. 스프링은@Bean메서드를 부를 때 매개변수 타입에 맞는 빈을 찾아 넣어 주는데, 이것을 의존성 주입이라 한다. Reader는 이 DataSource를 통해 SQL을 DB에 보낸다. JdbcPagingItemReader는 ItemReader의 한 종류로, DB 테이블을 페이지 단위로 나눠 읽는 역할을 한다.JdbcPagingItemReaderBuilder는 그JdbcPagingItemReader를 조립해 주는 빌더다. 빌더는 설정 메서드(.name(),.dataSource()…)가 저마다 빌더 자기 자신을 돌려주기 때문에 점을 이어 부를 수 있고, 마지막.build()가 모은 설정으로 Reader 객체를 만들어 돌려준다..name("customerReader")는 이 Reader의 이름이다. Reader는 어디까지 읽었는지를 Step의 ExecutionContext 상자에 적어 두고(실패 후 다시 돌릴 때 이어 읽으려고), 그 상자는 3장에 나올 JobRepository가 저장한다. 이 이름은 상자 안에서 그 기록을 찾는 키로 쓰인다..selectClause(...)·.fromClause(...)·.whereClause(...)는 페이지마다 실행할 SQL을 통째 문자열로 쓰지 않고 조각으로 나눠 주는 부분이다. DB는 MySQL · PostgreSQL · Oracle처럼 제품이 여럿이고, 「몇 번째부터 몇 건만」 끊어 가져오는 SQL 문법이 제품마다 다르다. 그래서 빌더가.dataSource()로 지금 붙은 DB가 어느 제품인지 알아낸 뒤, 그 제품에 맞는 페이징 SQL을 이 조각들로 완성한다.select id, name, code, status는 이 네 칸만 가져오라는 뜻이다.where code = :code의:code는 빈자리다. 위에서parameterValues.put("code", "A")로 맵에 넣은 값이 이 자리에 들어가서, 실제로는where code = 'A'가 된다. 이 코드는 아직 코드 A 하나만 읽는 예시다..sortKeys(Map.of("id", Order.ASCENDING))는 id 오름차순으로 정렬해서 읽으라는 뜻이다. 페이지를 나눠 읽으려면 “어느 기준으로 몇 번째 페이지인지”를 셀 수 있는 정렬 기준이 있어야 하기 때문에 꼭 넣는다..beanRowMapper(Customer.class)는 DB에서 온 한 행을Customer객체로 바꾸는 방법을 정한다. 변경 전 코드의customerMapper가 하던 일이다. 행의 칸 이름과Customer의 setter 이름을 맞춰서 값을 넣는다 —name칸은setName()으로,processed_date처럼 밑줄이 있는 칸은setProcessedDate()처럼 낙타 표기로 바꿔 찾는다..pageSize(1000)은 한 번 DB에 갈 때마다 1000건씩 가져오라는 뜻이다. 10만 건을 1000건씩 나누면 왕복 횟수가 100번으로 줄어든다. 1000이라는 숫자는 한 번에 메모리에 올려도 괜찮은 크기와, DB 왕복 횟수를 줄이는 효과 사이에서 정한 값이다.
효과:
- 100,000번 DB 호출 → 100번으로 감소 (호출 횟수가 1000분의 1)
- 네트워크 오버헤드 대폭 감소 — 데이터 자체가 아니라 왕복마다 드는 연결 · 대기 비용이 줄어든다
공식 문서 참고:
Spring Batch 공식 문서 - JdbcPagingItemReader
2단계: Partitioning으로 병렬 처리
7개 코드를 동시에 처리하도록 변경:
다이어그램에 나오는 두 개념을 먼저 짚고 넘어간다. Partitioner는 하나의 작업을 7개 조각(파티션)으로 나누는 역할을 한다. 그리고 이 7개 조각을 실제로 동시에 돌리려면 스레드가 여러 개 필요한데, 스레드를 미리 여러 개 만들어 두고 필요할 때 빌려 쓰는 것이 스레드 풀이고, 스프링에서 이 스레드 풀을 설정하고 다루는 인터페이스가 TaskExecutor다. 아래 그림에서 ThreadPoolTaskExecutor가 그 TaskExecutor의 구현체이고, Partitioner가 나눈 7개 조각을 스레드 7개가 하나씩 맡는다. 그림의 「Partitioning Manager」는 직접 데이터를 처리하지 않고, Partitioner를 불러 조각을 나누고 조각마다 Step을 나눠 맡기는 관리자 Step이다(코드는 3장 Step 3의 managerStep). 조각을 받아 실제로 읽고 쓰는 쪽은 Worker Step이라 부른다(3장 Step 2).
7개 코드 병렬 처리"] --> B["managerStep
Partitioning Manager"] B --> C["Partitioner
7개로 분할"] C --> D["ThreadPoolTaskExecutor
7개 스레드"] D --> E1["Thread 1
코드 A 처리
30분"] D --> E2["Thread 2
코드 B 처리
4시간"] D --> E3["Thread 3
코드 C 처리
45분"] D --> E4["Thread 4
코드 D 처리
1.5시간"] D --> E5["Thread 5
코드 E 처리
1시간"] D --> E6["Thread 6
코드 F 처리
2시간"] D --> E7["Thread 7
코드 G 처리
2.5시간"] E1 --> F["result_A 테이블"] E2 --> G["result_B 테이블"] E3 --> H["result_C 테이블"] E4 --> I["result_D 테이블"] E5 --> J["result_E 테이블"] E6 --> K["result_F 테이블"] E7 --> L["result_G 테이블"] F --> M["reportStep
보고서 생성"] G --> M H --> M I --> M J --> M K --> M L --> M M --> N["Job 완료
총 4시간 10분"] style A fill:#2d3748,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style B fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style C fill:#1a202c,stroke:#f6ad55,stroke-width:2px,color:#e2e8f0 style D fill:#2d3748,stroke:#68d391,stroke-width:2px,color:#e2e8f0 style E1 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style E2 fill:#1a202c,stroke:#fc8181,stroke-width:2px,color:#e2e8f0 style E3 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style E4 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style E5 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style E6 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style E7 fill:#1a202c,stroke:#68d391,stroke-width:1px,color:#e2e8f0 style M fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style N fill:#2d3748,stroke:#68d391,stroke-width:2px,color:#e2e8f0
처리 시간 비교:
- 순차 처리: 12시간 25분
- 병렬 처리: 4시간 10분 (가장 느린 코드 B + 보고서 생성)
- 약 3배 단축
이 계산은 1장의 코드별 시간을 그대로 겹쳐 놓은 것이다. 1장의 시간이 1단계 페이징을 적용하기 전 값인지 후 값인지는 원래 기록에 남아 있지 않고, 페이징과 병렬화가 각각 시간을 얼마나 줄였는지도 나눠 재지 않았다. 그래서 「3배」는 병렬화만 놓고 본 계산이다.
그림의 result_A~result_G 결과 테이블은 배치가 만들지 않는다. 4장의 Writer는 이미 있는 테이블에 쓰기만 하므로, 일곱 테이블은 미리 만들어 둬야 한다. 5장의 batch_report 테이블도 같다.
그림 위쪽의 managerStep과 아래쪽의 reportStep은 Job을 이루는 두 Step이다. 다음 3장의 소제목 「Step 1 · 2 · 3」은 Spring Batch의 Step이 아니라 구현 순서를 매긴 번호다.
3. Partitioning 구현: 단계별 가이드
Step 1: Partitioner 정의
7개 코드를 각각의 파티션으로 분할한다. 클래스에 붙은 @Component는 이 클래스를 스프링 빈으로 만들라는 표시다.
@Component
public class CodePartitioner implements Partitioner {
@Override
public Map<String, ExecutionContext> partition(int gridSize) {
Map<String, ExecutionContext> partitions = new HashMap<>();
// 7개 코드 정의
String[] codes = {"A", "B", "C", "D", "E", "F", "G"};
for (int i = 0; i < codes.length; i++) {
ExecutionContext context = new ExecutionContext();
context.putString("code", codes[i]);
context.putInt("partitionNumber", i);
// 각 파티션에 고유 이름 부여 (partitionA, partitionB, ...)
partitions.put("partition" + codes[i], context);
}
return partitions;
}
}
한 줄씩 따라가기:
partition(int gridSize)의gridSize는 “조각을 몇 개로 나눠야 하는가”를 Spring Batch가 Partitioner에게 알려 주는 숫자다. 이 값은 나중에 managerStep 설정의.gridSize(7)에서 정한다(Step 3에서 다룬다). 그런데 이 코드는gridSize를 쓰지 않는다. 나눌 기준이 코드 A~G 일곱 개로 이미 정해져 있어서, 받은 숫자와 상관없이 늘 7개를 만든다.Partitioner인터페이스가 이 매개변수를 정해 두었기 때문에 받기만 한다. 건수로 범위를 자르는 Partitioner(예: id 1~10만을 n등분)라면 이 숫자로 몇 등분할지 정한다.- ExecutionContext는 값을 담아 두는 상자다.
context.putString("code", codes[i])로 “이 파티션은 코드 A를 맡는다” 같은 정보를 상자에 넣는다. 상자는 파티션마다 하나씩 따로 만든다. -
partitions.put("partition" + codes[i], context)로 “파티션 이름 → 그 파티션의 ExecutionContext” 짝을 만든다. 이름은partitionA~partitionG가 된다. 이 메서드가 돌려주는Map하나가 곧 “7개로 나눈 작업 목록”이고, Spring Batch는 이 맵의 크기만큼 Step을 독립적으로 실행한다. 이때 조각마다 생기는 Step 실행의 이름은Worker Step 이름 + ":" + 파티션 이름, 곧workerStep:partitionA꼴이 된다. 5장 보고서 코드가 이 이름으로 코드별 결과를 찾는다. context.putInt("partitionNumber", i)는 파티션 번호(0~6)도 상자에 넣어 둔다. 이 글의 다른 코드는 이 값을 꺼내 쓰지 않는다. 로그 등에서 파티션을 번호로 구분하고 싶을 때 꺼내 쓸 수 있게 넣어 둔 값이다.
주요 포인트:
ExecutionContext에 각 코드 정보 저장- 각 파티션이 독립적으로 처리할 데이터 범위 정의
Step 2: Worker Step 구성
각 파티션에서 실행될 실제 처리 로직이다. 매개변수의 CustomerProcessor와 CustomerWriter는 4장에서 만드는 클래스다.
@Configuration
public class BatchConfiguration {
@Bean
public Step workerStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
JdbcPagingItemReader<Customer> reader,
CustomerProcessor processor,
CustomerWriter writer) {
return new StepBuilder("workerStep", jobRepository)
.<Customer, Customer>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
@Bean
@StepScope // 중요: 각 파티션마다 독립적인 인스턴스
public JdbcPagingItemReader<Customer> reader(
@Value("#{stepExecutionContext['code']}") String code,
DataSource dataSource) {
Map<String, Object> parameterValues = new HashMap<>();
parameterValues.put("code", code);
return new JdbcPagingItemReaderBuilder<Customer>()
.name("customerReader")
.dataSource(dataSource)
.selectClause("select id, name, code, status")
.fromClause("from customer")
.whereClause("where code = :code")
.sortKeys(Map.of("id", Order.ASCENDING))
.beanRowMapper(Customer.class) // 한 행을 Customer 객체로 바꾼다
.parameterValues(parameterValues)
.pageSize(1000)
.build();
}
}
한 줄씩 따라가기:
- 클래스에 붙은 @Configuration은 “이 클래스 안의
@Bean메서드들로 빈을 만들어 달라”는 설정 클래스 표시다. - 아래
reader()는 2장의customerReader()를 바꾼 것이다. 2장 것은"A"를 박아 둔 예시였고, 여기서는 파티션마다 다른 코드를 받는다. 그래서 파티션 구성에서는 2장의customerReader()를 지우고 이reader()하나만 둔다.workerStep의 매개변수JdbcPagingItemReader<Customer> reader에는 이 빈이 들어온다. 그런데workerStep은 애플리케이션이 뜰 때 만들어지고, 이 Reader는 아래@StepScope때문에 Step이 시작할 때에야 만들어진다. 이 어긋남은 대리 객체로 푼다.@StepScope는 진짜 Reader 대신 Reader 모양을 한 대리 객체(프록시)를 먼저 넣어 두고, Step이 돌기 시작해 대리 객체가 불리면 그때 그 파티션의 진짜 Reader를 만들어 일을 넘긴다. workerStep메서드의 매개변수들은 전부 스프링이 빈으로 주입해 주는 객체다. JobRepository는 이 Step이 어디까지 처리했는지 기록을 남기는 저장소이고, PlatformTransactionManager는 트랜잭션을 실제로 열고 커밋하는 객체다.StepBuilder는 이 둘을 받아 Step 하나를 조립해 주는 빌더다..<Customer, Customer>chunk(...)의<Customer, Customer>는 제네릭 메서드에 타입을 직접 적어 주는 문법이다. 앞의 것은 Reader가 읽어 오는 타입, 뒤의 것은 Processor를 거쳐 Writer에 넘기는 타입이다. 이 글은 읽은Customer를 가공해서 그대로Customer로 쓰기 때문에 둘이 같다..chunk(1000, transactionManager)는 1000건을 하나의 트랜잭션으로 묶어 처리하라는 뜻이다. 1000건을 다 읽고 가공해서 쓸 때까지가 한 트랜잭션이므로, 그 1000건 중간에 실패하면 1000건 전체가 롤백된다..reader(reader).processor(processor).writer(writer)는 이 Step이 데이터를 읽고(reader) → 가공하고(processor) → 저장하는(writer) 순서로 동작한다고 연결해 주는 부분이다.- 두 번째 빈
reader()에 붙은@[StepScope](/term/step-scope-annotation)는 이 Reader 빈을 애플리케이션이 뜰 때 하나 만들어 두지 않고, Step이 실행을 시작할 때 파티션마다 새로 만들게 한다. 아래@Value("#{stepExecutionContext['code']}")의 값은 Step이 돌기 시작해야 생기므로, 빈을 그때 만들어야 값을 넣을 수 있다. Spring Batch 문서도 이렇게 늦게 값을 받는 빈은 반드시 step 스코프로 선언하라고 적는다. 파티션마다 Reader가 따로 생기니, 코드 A를 맡은 Reader와 코드 B를 맡은 Reader가 서로 섞이지 않는다. @Value("#{stepExecutionContext['code']}")에서#{ }부분은 SpEL 문법이다. “지금 이 파티션의 ExecutionContext에서code라는 키의 값을 꺼내code매개변수에 넣어라”는 뜻이고, 이 값이 바로 1단계의CodePartitioner가context.putString("code", codes[i])로 넣어 둔 그 값이다.
핵심 개념:
@StepScope: 각 파티션이 독립적인 Reader 인스턴스를 가짐#{stepExecutionContext['code']}: Partitioner에서 전달한 코드 값 주입chunk(1000): 1000건씩 청크 처리
Step 3: Manager Step과 TaskExecutor 설정
@Configuration
public class PartitionJobConfiguration {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(7); // 기본 스레드 7개
executor.setMaxPoolSize(10); // 최대 스레드 10개
executor.setQueueCapacity(10); // 대기 큐 크기
executor.setThreadNamePrefix("batch-");
executor.initialize();
return executor;
}
@Bean
public Step managerStep(
JobRepository jobRepository,
Step workerStep,
Partitioner partitioner,
TaskExecutor taskExecutor) {
return new StepBuilder("managerStep", jobRepository)
.partitioner("workerStep", partitioner)
.step(workerStep)
.gridSize(7) // 7개 파티션
.taskExecutor(taskExecutor)
.build();
}
@Bean
public Job partitionJob(JobRepository jobRepository, Step managerStep, Step reportStep) {
return new JobBuilder("partitionJob", jobRepository)
.start(managerStep) // 병렬 처리
.next(reportStep) // 보고서 생성
.build();
}
}
공식 문서 참고:
Spring Batch 공식 문서 - Partitioning Configuration
한 줄씩 따라가기:
taskExecutor()에서executor.setCorePoolSize(7)은 기본으로 스레드 7개를 미리 띄워 둔다는 뜻이다. 7개 코드를 동시에 처리하려면 최소 7개의 스레드가 필요하므로 이 숫자를 7로 맞췄다.setQueueCapacity(10)은 스레드 7개가 모두 바쁠 때 새 작업을 대기시켜 둘 수 있는 자리 수다.setMaxPoolSize(10)은 스레드를 최대 10개까지 늘릴 수 있다는 상한인데, 스레드가 7개를 넘어 늘어나는 것은 대기 자리 10개가 다 찬 뒤다(자바ThreadPoolExecutor의 규칙이다). 이 배치는 작업이 7개뿐이라 실제로는 7개를 넘지 않는다.setThreadNamePrefix("batch-")는 이 풀이 만드는 스레드 이름 앞에batch-를 붙인다. 로그에 찍힌 스레드 이름으로 배치 스레드인지 알아보려고 쓴다.executor.initialize()는 지금까지 넣은 설정값으로 실제 스레드 풀을 만든다.managerStep()과partitionJob()의 매개변수에는Step타입이 셋(workerStep·managerStep·reportStep) 나온다. 같은 타입의 빈이 여럿이면 스프링은 매개변수 이름과 같은 이름의 빈을 고른다.@Bean메서드로 만든 빈의 이름은 메서드 이름이라, 매개변수workerStep에는workerStep()메서드가 만든 빈이 들어간다(스프링 6.1부터는 컴파일할 때-parameters옵션이 있어야 매개변수 이름을 읽는다).Partitioner partitioner에는Partitioner를 구현한 빈이CodePartitioner하나뿐이라 그것이 들어간다.managerStep()의.partitioner("workerStep", partitioner)는 “앞서 만든partitioner가 나눠 준 조각 수만큼 Worker Step을 실행하라”고 등록하는 부분이다. 첫 인자"workerStep"은 Worker Step의 이름으로, 조각마다 생기는 Step 실행의 이름(workerStep:partitionA)을 지을 때 쓰인다..step(workerStep)은 각 조각이 실제로 실행할 Step 객체를 지정한다. 이름과 객체를 둘 다 주면 Spring Batch는 Step 객체의 이름(StepBuilder("workerStep", ...)에 준 이름)을 우선 쓴다. 이 글에서는 둘 다"workerStep"이라 결과가 같다..gridSize(7)은 1단계partition(int gridSize)에 전달될 조각 개수를 7로 정한다(코드가 7개이므로 7)..taskExecutor(taskExecutor)는 이 7개 조각을 순서대로 하나씩 돌리지 않고, 방금 만든 스레드 풀로 동시에 돌리라는 뜻이다.partitionJob()의JobBuilder는 전체 작업(Job)의 실행 순서를 조립하는 빌더다..start(managerStep)은 가장 먼저 실행할 Step을 지정하고,.next(reportStep)은 그 Step이 끝난 뒤에 이어서 실행할 Step을 지정한다. 즉 이 Job은 “managerStep(7개 파티션 병렬 처리)을 전부 끝낸 뒤 reportStep(보고서 생성)을 실행한다”는 순서를 나타낸다.
TaskExecutor 설정 가이드:
corePoolSize: 동시 실행할 파티션 수maxPoolSize: 최대 스레드 수 (대기 큐가 다 찬 뒤에만 늘어난다)queueCapacity: 대기 작업 큐 크기
4. ItemProcessor와 ItemWriter 구현
ItemProcessor: 데이터 검증 및 변환
@Component
@StepScope
public class CustomerProcessor implements ItemProcessor<Customer, Customer> {
private static final Logger log = LoggerFactory.getLogger(CustomerProcessor.class);
@Value("#{stepExecutionContext['code']}")
private String code;
@Override
public Customer process(Customer customer) throws Exception {
// 데이터 검증
if (customer.getStatus() == null) {
log.warn("코드 {}: 고객 {} 상태가 null입니다.", code, customer.getId());
return null; // null 반환 시 해당 아이템은 Writer로 전달되지 않음
}
// 비즈니스 로직 처리
customer.setProcessedDate(LocalDateTime.now());
customer.setProcessedBy(code);
return customer;
}
}
한 줄씩 따라가기:
- 클래스에 @Component와
@StepScope를 같이 붙였다.@Component는 이 클래스를 빈으로 만들라는 표시이고,@StepScope는 3장의 Reader와 같은 이유로 붙였다 — 아래code필드에 파티션마다 다른 값을 넣으려면 빈을 Step이 시작할 때 파티션마다 새로 만들어야 한다. - 필드
code에 붙은@Value("#{stepExecutionContext['code']}")는 Reader 매개변수에 붙였던 것과 같다. Partitioner가 이 파티션 상자에 넣어 둔 코드 값(A~G)이 이 필드에 들어간다. LoggerFactory.getLogger(CustomerProcessor.class)는 이 클래스 전용 기록 도구(Logger)를 하나 만든다.log.warn("코드 {}: 고객 {} 상태가 null입니다.", code, customer.getId())에서{}는 빈자리이고, 뒤에 적은 값이 순서대로 들어간다. 코드가 B, 고객 id가 7이면 「코드 B: 고객 7 상태가 null입니다.」가 경고로 남는다.process()는 Reader가 읽은 고객 한 명을 받는다. 상태(status)가 비어 있으면null을 돌려주는데, 그러면 그 고객은 Writer로 넘어가지 않고 건너뛴다. 정상이면 처리 시각과 처리한 코드를 적어서 돌려준다. 처리 시각(processedDate)은 아래 Writer가processed_date칸에 저장한다. 처리한 코드(processedBy)는 객체에 적기만 하고 Writer가 저장하지 않는다. 어느 코드의 결과인지는 결과 테이블 이름(result_A…)으로도 알 수 있다. 저장하려면 Writer의 SQL에 칸을 더해야 한다.
ItemWriter: 각 코드별 독립된 테이블에 저장
@Component
@StepScope
public class CustomerWriter implements ItemWriter<Customer> {
private final DataSource dataSource;
private final String code;
private final AtomicInteger insertCount = new AtomicInteger(0);
private final AtomicInteger updateCount = new AtomicInteger(0);
public CustomerWriter(
DataSource dataSource,
@Value("#{stepExecutionContext['code']}") String code) {
this.dataSource = dataSource;
this.code = code;
}
@Override
public void write(Chunk<? extends Customer> chunk) throws Exception {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
for (Customer customer : chunk) {
String tableName = "result_" + code; // result_A, result_B, ...
// UPSERT 로직 (존재하면 UPDATE, 없으면 INSERT)
int updated = jdbcTemplate.update(
"UPDATE " + tableName + " SET name = ?, status = ?, processed_date = ? WHERE id = ?",
customer.getName(), customer.getStatus(), customer.getProcessedDate(), customer.getId()
);
if (updated == 0) {
jdbcTemplate.update(
"INSERT INTO " + tableName + " (id, name, status, processed_date) VALUES (?, ?, ?, ?)",
customer.getId(), customer.getName(), customer.getStatus(), customer.getProcessedDate()
);
insertCount.incrementAndGet();
} else {
updateCount.incrementAndGet();
}
}
}
// Step 완료 후 카운트 로깅을 위한 Listener
@AfterStep
public ExitStatus afterStep(StepExecution stepExecution) {
stepExecution.getExecutionContext().putInt("insertCount", insertCount.get());
stepExecution.getExecutionContext().putInt("updateCount", updateCount.get());
return ExitStatus.COMPLETED;
}
}
한 줄씩 따라가기:
insertCount·updateCount는 넣은 건수와 고친 건수를 세는 숫자다. 그냥int대신AtomicInteger를 썼다.incrementAndGet()은 1을 더하는 일을 다른 스레드가 끼어들 수 없게 한 번에 끝내서(원자적 연산), 여러 스레드가 동시에 더해도 빠지는 숫자가 없다. 이 구성에서는 Writer가@StepScope로 파티션마다 따로 생기고 한 파티션은 한 스레드가 맡으므로int로도 틀리지 않는다. 여러 스레드가 Writer 하나를 나눠 쓰는 구성이 되어도 셈이 맞게 해 둔 것이다.write(Chunk<? extends Customer> chunk)의 매개변수 타입은 제네릭 와일드카드(? extends)다. “Customer 타입이거나 그 하위 타입을 담은 Chunk라면 뭐든 받는다”는 뜻이고, 여기서Chunk는 앞서.chunk(1000, transactionManager)로 설정한 1000건 묶음 그 자체를 가리키는 타입이다.for (Customer customer : chunk)로 그 1000건을 한 건씩 꺼내면서, 각 건마다 먼저 UPDATE를 실행하고 바뀐 행 수(updated)를 확인한다.UPDATE result_A SET name = ?, status = ?, processed_date = ? WHERE id = ?는 「result_A 테이블에서 id가 ?인 행의 name · status · processed_date 칸을 이 값들로 바꿔라」는 뜻이고,?네 개에는update()에 뒤이어 넘긴 값이 순서대로 들어간다.jdbcTemplate.update()는 실제로 바뀐 행이 몇 개인지를 돌려준다.updated == 0이면 그 id가 테이블에 아직 없다는 뜻이므로 그제서야 INSERT를 실행한다.INSERT INTO result_A (id, name, status, processed_date) VALUES (?, ?, ?, ?)는 「result_A 테이블에 이 네 칸을 채운 새 행을 하나 넣어라」는 뜻이다. 이렇게 UPDATE를 먼저 시도하고 없으면 INSERT하는 방식이 UPSERT다.@[AfterStep](/term/step-execution-listener)은 StepExecutionListener 인터페이스를 구현하는 대신, 메서드 하나에 어노테이션만 붙여서 “이 Step이 끝난 직후 호출해 달라”고 표시하는 방법이다. 메서드가 받는StepExecution은 Step 한 번 실행의 기록이다 — 시작 시각, 읽고 쓴 건수, 상태, 그리고 그 Step의ExecutionContext상자를 들고 있다. 7장의 리스너는.listener(...)로 Step에 붙여야 한다고 했는데, 이 Writer는 따로 붙이지 않아도 된다. Step을 조립하는 빌더가.reader()·.processor()·.writer()로 받은 객체를 살펴, 리스너 인터페이스를 구현했거나@AfterStep같은 표시가 있으면 리스너로 함께 등록하기 때문이다. 여기서는 Step이 끝난 뒤 그동안 센 INSERT·UPDATE 건수를ExecutionContext에 저장해서, 5장의 보고서 생성 Step이 그 숫자를 꺼내 쓸 수 있게 한다. 마지막 줄의return ExitStatus.COMPLETED는 Step이 끝난 뒤의 종료 코드다. 종료 코드는 Step이 끝난 뒤 다음에 어느 Step으로 갈지 정할 때 쓰인다(7장에서BatchStatus와 함께 다시 본다). 리스너는 이 값을 돌려주는 것으로 Step의 종료 코드를 바꿀 수 있는데, 여기서는 「정상 완료」를 뜻하는COMPLETED를 돌려준다.
독립 테이블 저장의 장점:
- 자물쇠를 두고 다투지 않음(락 경합 없음): DB는 누가 행을 고치는 동안 다른 쪽이 같은 행을 못 고치게 잠근다. 스레드들이 같은 테이블에 쓰면 이 잠금이 풀리길 서로 기다릴 수 있는데, 여기서는 각 스레드가 다른 테이블에 쓰므로 기다릴 일이 없다
- 실패가 번지지 않음: 파티션마다 트랜잭션이 따로라, 한 파티션이 실패해 롤백돼도 다른 파티션이 이미 쓴 것은 그대로 남는다
- 재처리 용이: 실패한 코드만 독립적으로 재실행
5. 보고서 생성 Step
모든 파티션이 완료된 후 실행:
@Configuration
public class ReportStepConfiguration {
@Bean
public Step reportStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
DataSource dataSource) {
return new StepBuilder("reportStep", jobRepository)
.tasklet(reportTasklet(dataSource), transactionManager)
.build();
}
@Bean
public Tasklet reportTasklet(DataSource dataSource) {
return (contribution, chunkContext) -> {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
// Step 실행 정보 조회
JobExecution jobExecution = chunkContext.getStepContext()
.getStepExecution()
.getJobExecution();
StringBuilder report = new StringBuilder();
report.append("=== 배치 처리 결과 보고서 ===\n");
report.append("실행 시간: ").append(jobExecution.getStartTime()).append("\n\n");
String[] codes = {"A", "B", "C", "D", "E", "F", "G"};
int totalInsert = 0;
int totalUpdate = 0;
// 각 코드별 처리 결과 집계
for (String code : codes) {
Map<String, Object> result = jdbcTemplate.queryForMap(
"SELECT COUNT(*) as total FROM result_" + code
);
// StepExecution에서 INSERT/UPDATE 건수 조회
int insertCount = getCountFromContext(jobExecution, code, "insertCount");
int updateCount = getCountFromContext(jobExecution, code, "updateCount");
totalInsert += insertCount;
totalUpdate += updateCount;
report.append(String.format("코드 %s: INSERT %d건, UPDATE %d건, 총 %d건\n",
code, insertCount, updateCount, result.get("total")));
}
report.append("\n총계: INSERT ").append(totalInsert)
.append("건, UPDATE ").append(totalUpdate).append("건\n");
// 보고서 테이블에 저장
jdbcTemplate.update(
"INSERT INTO batch_report (job_id, report_content, created_at) VALUES (?, ?, ?)",
jobExecution.getJobId(), report.toString(), LocalDateTime.now()
);
return RepeatStatus.FINISHED;
};
}
private int getCountFromContext(JobExecution jobExecution, String code, String key) {
return jobExecution.getStepExecutions().stream()
.filter(step -> step.getStepName().equals("workerStep:partition" + code))
.findFirst()
.map(step -> step.getExecutionContext().getInt(key, 0))
.orElse(0);
}
}
한 줄씩 따라가기:
reportTasklet()이 돌려주는 타입은ItemReader·ItemWriter조합이 아니라 Tasklet이다. 데이터를 한 건씩 반복해서 읽고 쓰는 작업이 아니라 “보고서 하나를 만든다”는 단발성 작업이라서,reportStep()에서도.reader()·.writer()대신.tasklet(reportTasklet(dataSource), transactionManager)로 연결한다.(contribution, chunkContext) -> { ... }는 람다 문법이다.Tasklet인터페이스의execute메서드를 별도 클래스 없이 그 자리에서 바로 구현한 것이고,contribution과chunkContext는 execute 메서드가 원래 받는 두 매개변수 이름을 그대로 받아 쓴 것뿐이다.chunkContext.getStepContext().getStepExecution().getJobExecution()은 메서드를 네 번 연달아 호출한다.chunkContext에서StepContext를 얻고, 거기서 지금 실행 중인StepExecution을 얻고, 그 Step이 속한JobExecution(Job 전체의 실행 정보)을 얻는다. 이렇게 체인으로 호출하는 이유는 “Step 하나의 실행 정보”가 아니라 “그 Step이 속한 Job 전체의 실행 정보”(시작 시간 등)가 필요하기 때문이다.jdbcTemplate.queryForMap("SELECT COUNT(*) as total FROM result_" + code)는 결과 테이블의 행 수를 센다.COUNT(*)는 행이 몇 개인지 세라는 뜻이고(COUNT 쿼리),as total은 그 결과 칸에total이라는 이름을 붙인다.queryForMap은 결과 한 행을 「칸 이름 → 값」 맵으로 돌려주므로,result.get("total")로 센 숫자를 꺼낸다.getCountFromContext의jobExecution.getStepExecutions().stream()은 스트림으로 이 Job에 속한 모든 Step 실행 기록을 훑는다. 3장에서 본 대로 파티션 Step의 이름은workerStep:partitionA꼴이다..filter(step -> step.getStepName().equals("workerStep:partition" + code))는 그중 해당 코드(A, B, …)를 맡았던 Step만 남기고,.findFirst()는 그렇게 걸러진 것 중 첫 번째를 꺼낸다..map(...)은 그 Step에서 insertCount나 updateCount 값을 꺼내고,.orElse(0)은 혹시 해당 Step을 못 찾았을 때 기본값 0을 쓰라는 뜻이다.- 보고서 문자열을 다 만들면
jdbcTemplate.update("INSERT INTO batch_report ...", jobExecution.getJobId(), report.toString(), LocalDateTime.now())로batch_report테이블에 한 행을 넣는다.getJobId()는 이 실행이 속한 JobInstance의 id를 돌려준다. 그래서 같은 JobInstance를 다시 실행해 보고서가 또 생기면 같은job_id로 쌓인다. - 마지막
return RepeatStatus.FINISHED는 「이 Tasklet의 일이 끝났다」는 신호다. Spring Batch는 Tasklet이FINISHED를 돌려줄 때까지execute를 되풀이해 부르므로, 보고서를 한 번만 만들려면 여기서FINISHED를 돌려준다.
보고서 예시:
=== 배치 처리 결과 보고서 ===
실행 시간: 2026-02-02 02:00:00
코드 A: INSERT 1,234건, UPDATE 567건, 총 1,801건
코드 B: INSERT 5,678건, UPDATE 2,345건, 총 8,023건
코드 C: INSERT 2,345건, UPDATE 987건, 총 3,332건
코드 D: INSERT 3,456건, UPDATE 1,234건, 총 4,690건
코드 E: INSERT 1,890건, UPDATE 765건, 총 2,655건
코드 F: INSERT 2,789건, UPDATE 1,098건, 총 3,887건
코드 G: INSERT 3,210건, UPDATE 1,456건, 총 4,666건
총계: INSERT 20,602건, UPDATE 8,452건
이 보고서의 건수는 보고서가 어떤 모양으로 나오는지 보여 주는 예시다. 2장의 「10만 건」이나 1장의 코드별 시간과 맞춘 숫자가 아니다.
6. 불균등 처리 시간 최적화
문제: 가장 느린 작업이 전체 시간 결정
Thread 1: 코드 A (30분) 끝남 → 대기 중
Thread 2: 코드 B (4시간) 진행 중 ← 병목
Thread 3: 코드 C (45분) 끝남 → 대기 중
...
스레드 풀 설정을 다듬어도 코드 B의 4시간은 줄지 않는다
먼저 짚어 둘 것이 있다. 아래 설정은 일이 끝나 놀고 있는 스레드를 정리하고, 풀이 꽉 찼을 때의 동작을 정해 둘 뿐이다. 코드 B 한 조각이 4시간 걸리는 것은 그대로다. 코드 B는 스레드 하나가 처음부터 끝까지 맡기 때문에, 다른 스레드가 일찍 끝나 놀아도 코드 B를 거들 수 없다. 병목을 실제로 줄이는 방법은 이 절 끝의 「추가 최적화 전략」이다.
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(7);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(10);
executor.setKeepAliveSeconds(60);
executor.setAllowCoreThreadTimeOut(true); // 유휴 스레드 자동 정리
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("batch-");
executor.initialize();
return executor;
}
한 줄씩 따라가기 (3장에서 본 줄은 뺀다):
setKeepAliveSeconds(60)은 일 없이 노는(유휴) 스레드를 60초 뒤에 없앤다는 뜻이다. 원래는 코어 수(7)를 넘어 늘어난 스레드에만 적용된다.setAllowCoreThreadTimeOut(true)는 그 60초 규칙을 기본 스레드 7개에도 적용한다. 코드 A가 30분 만에 끝나면, 그 스레드는 60초 더 놀다가 없어진다.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())는 대기 자리도 다 차고 스레드도 최대치라 풀이 작업을 더 못 받을 때, 작업을 버리지 않고 작업을 넘긴 쪽 스레드가 직접 실행하게 한다. 이 배치는 작업이 7개라 실제로 이 상황까지 가지 않는다.
동작 순서:
- 7개 코드가 7개 스레드에 할당
- 빨리 끝난 스레드(A, C 등)는 풀로 돌아가고, 60초 동안 일이 없으면 없어진다
- 새로운 작업이 있다면 놀고 있는 스레드가 처리한다 — 이 배치에는 더 들어올 작업이 없다
- 모든 파티션이 끝날 때까지 대기 — 결국 가장 긴 코드 B(4시간)를 기다린다
추가 최적화 전략:
코드 B: 4시간"] --> B{최적화
전략 선택} B -->|전략 1| C["작업을 더 세분화
코드 B를 4개로 분할
각 1시간"] B -->|전략 2| D["청크 사이즈 튜닝
1000건 → 500건
메모리 vs 속도"] B -->|전략 3| E["인덱스 최적화
DB 쿼리 개선
N+1 문제 해결"] B -->|전략 4| F["Multi-threading
코드 B 내부도
병렬 처리"] C --> G["코드 B 시간 단축
전략 1 계산: 4시간 → 1시간"] D --> G E --> G F --> G style A fill:#1a202c,stroke:#fc8181,stroke-width:2px,color:#e2e8f0 style B fill:#2d3748,stroke:#f6ad55,stroke-width:2px,color:#e2e8f0 style C fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style D fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style E fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style F fill:#1a202c,stroke:#90cdf4,stroke-width:2px,color:#e2e8f0 style G fill:#2d3748,stroke:#68d391,stroke-width:2px,color:#e2e8f0
그림의 네 전략은 모두 코드 B 한 조각을 짧게 만드는 방법이다. 숫자가 있는 것은 전략 1뿐이고, 그것도 코드 B를 고르게 4등분할 수 있다고 가정한 계산이다(4시간 ÷ 4 = 1시간). 나머지 셋은 얼마나 줄어드는지 재 보지 않았다.
- 전략 1은 Partitioner를 고쳐 코드 B를 예컨대 id 범위로 네 조각 내는 것이다. 그러면 코드 B가 스레드 넷에 나뉘어 돈다
- 전략 2의 청크 크기는 한 트랜잭션에 묶는 건수다(3장 Step 2). 줄이면 한 번에 메모리에 올리는 양은 줄고 커밋 횟수는 는다. 커밋이 늘어나는 만큼 오히려 느려질 수도 있어서, 이 전략은 시간보다 메모리를 아끼는 쪽에 가깝다. 코드 B의 시간이 줄어드는지는 재 보지 않았다
- 전략 3의 인덱스는 DB가 조건에 맞는 행을 빨리 찾게 해 주는 찾아보기다.
where code = :code로 고르는 칸에 인덱스가 없으면 DB가 테이블 전체를 훑는다. N+1 문제는 목록 조회 1번 뒤에 건마다 쿼리가 하나씩 더 나가는 문제로, 2장의 1건씩 조회와 같은 종류의 낭비다 - 전략 4는 8장의 Multi-threading을 코드 B의 Worker Step 안에 거는 것이다
7. 실전 운영 팁
모니터링과 에러 처리
@Component
public class PartitionStepListener implements StepExecutionListener {
private static final Logger log = LoggerFactory.getLogger(PartitionStepListener.class);
@Override
public void beforeStep(StepExecution stepExecution) {
String code = stepExecution.getExecutionContext().getString("code");
log.info("코드 {} 처리 시작 - Thread: {}",
code, Thread.currentThread().getName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
String code = stepExecution.getExecutionContext().getString("code");
long duration = Duration.between(
stepExecution.getStartTime(), LocalDateTime.now()).toMillis();
log.info("코드 {} 처리 완료 - 소요 시간: {}ms, 읽기: {}건, 쓰기: {}건",
code, duration,
stepExecution.getReadCount(),
stepExecution.getWriteCount());
// 실패 시 에러 로깅
if (stepExecution.getStatus() == BatchStatus.FAILED) {
log.error("코드 {} 처리 실패: {}",
code, stepExecution.getFailureExceptions());
}
return ExitStatus.COMPLETED;
}
}
한 줄씩 따라가기:
- 이 클래스는 StepExecutionListener를 구현한다. Step이 시작하기 직전에
beforeStep이, 끝난 직후에afterStep이 불린다. 리스너는 만들기만 해서는 안 불리고 Step에 붙여야 한다. 3장 Step 2의workerStep메서드에PartitionStepListener partitionStepListener매개변수를 하나 더 받고(이 클래스도@Component라 스프링이 넣어 준다), 조립할 때.listener(partitionStepListener)를 넣으면 파티션 일곱 개 각각의 시작과 끝에 불린다.managerStep에는 붙이지 않는다 — 관리자 Step의 상자에는code가 없다. beforeStep은 이 파티션이 맡은 코드와 지금 스레드 이름(batch-1같은)을 남긴다. 어느 코드가 어느 스레드에서 돌았는지 로그로 확인하려는 것이다.afterStep의Duration.between(시작 시각, 지금).toMillis()는 Step이 시작한 때부터 지금까지 걸린 시간을 밀리초로 센다. Spring Batch 5에서getStartTime()은LocalDateTime을 돌려준다.stepExecution.getStatus() == BatchStatus.FAILED는 이 Step이 실패했는지 본다. 실패했으면getFailureExceptions()로 이 Step 실행 중에 난 예외 목록을 꺼내 로그에 남긴다.BatchStatus는 Spring Batch가 Job과 Step의 실행 상태를 기록하는 값이고, 4장의ExitStatus는 Step이 끝난 뒤의 종료 코드라 다음에 어느 Step으로 갈지 정할 때 쓴다. 기본으로는 둘이 같은 값이지만 리스너가ExitStatus를 바꿀 수 있다.
재시작 전략
Spring Batch에서 실패한 Job을 다시 실행하면, 이미 성공한 Step은 건너뛰고 실패한 Step만 다시 돈다. 파티션 하나하나가 Step 실행이므로, 코드 B 파티션만 실패했다면 코드 B만 다시 돈다.
여기에는 조건이 하나 있다. 실패했던 실행과 같은 JobInstance로 다시 실행해야 한다. JobInstance는 「Job + 그 실행을 구분하는 JobParameters(실행할 때 넘기는 값들, 예: 날짜)」로 정해진다. 같은 JobParameters로 다시 실행하면 같은 JobInstance로 이어 돌고, 다른 JobParameters로 실행하면 새 JobInstance라 처음부터 돈다.
@Bean
public Job partitionJob(JobRepository jobRepository, Step managerStep, Step reportStep) {
return new JobBuilder("partitionJob", jobRepository)
.start(managerStep)
.next(reportStep)
.incrementer(new RunIdIncrementer()) // 새로 실행할 때마다 run.id를 1씩 올린다
.build();
}
한 줄씩 따라가기:
- 3장 Step 3의
partitionJob에.incrementer(...)한 줄을 더한 것이다..start(managerStep).next(reportStep)순서는 그대로다. RunIdIncrementer는 Job을 새로 시작할 때 이전 JobParameters의run.id값에 1을 더한 새 JobParameters를 만든다. 값이 달라지니 새 JobInstance가 된다. 같은 날짜로 배치를 하루에 두 번 새로 돌리고 싶을 때 쓴다.- 그래서 이 incrementer가 붙은 채로 「새 실행」을 하면 실패한 코드 B만 이어 도는 것이 아니라 일곱 개가 처음부터 다시 돈다. 코드 B만 다시 돌리려면 실패했던 실행과 같은 JobParameters로 다시 실행한다.
- 이 글은 Job을 누가 언제 띄우는지(새벽 스케줄러 등)와, 같은 JobParameters로 다시 띄우는 구체적인 명령은 다루지 않는다. 띄우는 방식마다 달라서, 여기서는 「같은 JobParameters면 이어 돈다」는 규칙까지만 적는다.
재시작 시나리오:
첫 실행:
코드 A 성공, 코드 B 실패, 코드 C 성공 ... 코드 G 성공
재시작:
코드 B만 재실행
성능 튜닝 체크리스트
| 항목 | 권장값 | 비고 |
|---|---|---|
| 청크 사이즈 | 100~1000 | 데이터 크기에 따라 조정 |
| 스레드 풀 크기 | CPU 코어 수 × 2 | I/O 작업이 많으면 더 크게 |
| 페이지 사이즈 | 청크와 동일 | Reader 성능 최적화 |
| Connection Pool | 스레드 수 + 여유분 | DB 커넥션 부족 방지 |
| JVM Heap | 처리 데이터 × 2배 이상 | OutOfMemory 방지 |
표에 처음 나오는 말을 짚는다. 코어는 CPU 안에서 명령을 실제로 실행하는 단위이고, 코어가 4개면 일 4개를 같은 순간에 돌린다. I/O 작업은 DB 응답처럼 프로그램 밖과 데이터를 주고받는 일이라, 기다리는 동안 CPU가 놀기 때문에 스레드를 코어보다 많이 두어도 이득이 난다. 「× 2」라는 배수는 출발점으로 적은 값이고 이 배치에서 재 본 숫자는 아니다. Connection Pool(커넥션 풀)은 DB 연결을 미리 모아 두고 빌려 주는 곳이다. 스레드마다 연결이 하나씩 필요해서, 연결이 스레드보다 적으면 남은 스레드는 연결을 기다린다. JVM Heap(힙)은 new로 만든 객체가 들어가는 메모리 영역이고, 여기가 모자라면 OutOfMemoryError가 나서 배치가 멈춘다.
8. Partitioning vs Multi-threading 비교
Multi-threading(멀티스레드 Step)은 Partitioning처럼 조각을 나누지 않는다. Step 하나에 TaskExecutor를 붙이면, 그 Step 안에서 여러 스레드가 청크를 하나씩 맡아 동시에 처리한다. 모양은 3장의 Worker Step에 한 줄을 더한 것이다.
// Multi-threading: Step 하나에 TaskExecutor를 붙인다
return new StepBuilder("multiThreadStep", jobRepository)
.<Customer, Customer>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.taskExecutor(taskExecutor) // 이 한 줄로 청크들이 여러 스레드에서 돈다
.build();
여기서는 Reader 하나를 여러 스레드가 같이 쓴다. 그런데 Spring Batch 문서는 Spring Batch의 Reader · Writer 대부분이 여러 스레드가 동시에 쓰도록 만들어지지 않았다고 적는다(스레드 안전하지 않다). 그래서 Reader를 감싸 한 번에 한 스레드만 읽게 하는 등의 처리가 따로 필요하다. Partitioning은 파티션마다 Reader가 따로라 이 걱정이 없다.
표에 나오는 「분산 처리」는 조각을 다른 컴퓨터(다른 JVM)에 보내 돌리는 것이고, Partitioning은 조각을 나누는 구조라 그렇게 넓힐 수 있다. Multi-threading은 한 Step 안의 스레드들이라 한 JVM 안에서만 돈다.
「모니터링」 줄의 차이도 같은 데서 온다. Partitioning은 파티션마다 Step 실행 기록(StepExecution)이 따로 생겨서, 7장 리스너처럼 코드별 읽은 건수 · 걸린 시간 · 실패 여부를 따로 본다. Multi-threading은 Step 실행이 하나라 기록도 하나이고, 어느 스레드가 몇 건을 처리했는지는 그 기록에 나뉘어 있지 않다.
| 비교 항목 | Partitioning | Multi-threading |
|---|---|---|
| 사용 사례 | 데이터를 논리적으로 분할 가능 | 단일 데이터 소스를 병렬 읽기 |
| 구현 복잡도 | 중간 (Partitioner 필요) | 낮음 (TaskExecutor만) |
| 확장성 | 우수 (분산 처리 가능) | 제한적 (단일 JVM) |
| 독립성 | 완전 독립 (실패 격리) | 부분 독립 (Reader 공유) |
| 모니터링 | 파티션별 추적 가능 | 스레드 레벨 추적 어려움 |
선택 가이드:
- Partitioning: 7개 코드처럼 논리적으로 분할 가능한 경우
- Multi-threading: 같은 테이블의 대량 데이터를 빠르게 읽기
9. 전체 코드 구조
앞 장들의 클래스를 한자리에 모으면 이렇다.
CodePartitioner (3장 Step 1) — A~G로 분할
BatchConfiguration (3장 Step 2)
├─ Step: workerStep
└─ ItemReader: reader (JdbcPagingItemReader, @StepScope)
PartitionJobConfiguration (3장 Step 3)
├─ TaskExecutor: taskExecutor (ThreadPoolTaskExecutor, 7 threads)
├─ Step: managerStep (Partitioner)
└─ Job: partitionJob (managerStep → reportStep)
CustomerProcessor (4장) — ItemProcessor
CustomerWriter (4장) — ItemWriter
ReportStepConfiguration (5장)
└─ Step: reportStep (Tasklet)
실행 흐름:
1. partitionJob 시작
2. managerStep이 CodePartitioner 호출
3. 7개 파티션 생성 (A~G)
4. ThreadPoolTaskExecutor가 7개 스레드로 병렬 실행
5. 각 스레드가 workerStep 실행
- Reader: 1000건씩 페이징
- Processor: 검증 및 변환
- Writer: 각 result 테이블에 저장
6. 모든 파티션 완료 대기
7. reportStep 실행 (집계 및 보고서 생성)
결론: 핵심 요약
최적화 효과
| 구분 | Before | After | 개선율 |
|---|---|---|---|
| 처리 방식 | 1건씩 조회 | 1000건씩 페이징 | 1000배 |
| 실행 방식 | 순차 처리 | 병렬 처리 | 동시 7개 |
| 총 시간 | 12시간 25분 | 4시간 10분 | 약 3배 |
일곱 개를 동시에 돌려도 7배가 아니라 3배인 이유는 2장에서 본 대로다 — 전체 시간이 가장 긴 코드 B(4시간)에 보고서 10분을 더한 값으로 정해진다.
핵심 개념 정리
- 청크 기반 페이징: 1건씩 조회하던 낭비(N+1 문제와 같은 종류) 해결, 네트워크 오버헤드 감소
- Partitioning: 논리적 데이터 분할, 완전 독립 실행
- TaskExecutor: 스레드 풀로 7개 파티션을 동시에 실행
- 독립 테이블 저장: 락 경합 없음, 한 파티션의 실패가 번지지 않음
- ExecutionContext: Partitioner가 파티션마다 따로 만든 상자로 맡을 코드를 건네고, Step이 끝나면 건수를 남겨 보고서가 모은다
적용 시 주의사항
- 메모리: 스레드 수 × 청크 사이즈만큼 메모리 사용
- DB 커넥션: Connection Pool 크기를 스레드 수보다 크게
- 트랜잭션: 각 청크는 독립 트랜잭션 (롤백 범위 고려)
- 재시작: 실패한 파티션만 다시 돌리려면 같은 JobInstance(같은 JobParameters)로 다시 실행한다(7장)
참고 자료
- Spring Batch 공식 문서 - Scalability
- Spring Batch 공식 문서 - JdbcPagingItemReader
- Spring Batch Samples - Local Partitioning
자신만의 철학을 만들어가는 중입니다.
댓글남기기