8. Scaling and Parallel Processing
많은 배치 프로세스가 해결하려는 문제는 싱글 스레드, 싱글 프로세스 잡으로도 충분히 해결할 수 있으므로, 더 복잡한 구현에 대해 생각하기 전에 요구 사항을 충족하는지 항상 적절하게 확인하는 것이 좋다. 현실적인 잡의 성능을 측정하고 먼저 가장 간단한 구현체가 요구 사항을 충족하는지 확인하자. 기본적인 표준 하드웨어로도 수백 메가바이트의 파일을 1분 이내에 읽고 쓸 수 있다.
병렬 프로세스로 잡 구현을 시작할 준비가 되면, 스프링 배치는 이 장에서 설명하는 다양한 옵션을 제공하지만, 일부 기능은 다른 곳에서 다룬다. 높은 레벨에서 두 가지 병렬 프로세싱(parallel processing) 모드가 있다:
- 싱글 프로세스, 멀티 스레드(Single-process, multi-threaded)
- 멀티 프로세스(Multi-process)
위 내용은 다음과 같은 범주로 나뉜다:
- Multi-threaded Step (single-process)
- Parallel Steps (single-process)
- Remote Chunking of Step (multi-process)
- Partitioning a Step (single or multi-process)
먼저, 싱글 프로세스 옵션을 검토한다. 그런 다음 다중 프로세스 옵션을 검토한다.
8.1. Multi-threaded Step
병렬 프로세스를 시작하는 가장 간단한 방법은 태스크익스큐터(TaskExecutor)
를 스텝 구성에 추가하는 것이다.
예를 들어, 다음과 같이 tasklet
에 애트리뷰트을 추가할 수 있다:
<step id="loading">
<tasklet task-executor="taskExecutor">...</tasklet>
</step>
자바 구성을 사용하는 경우, 다음 예제와 같이 스텝에 태스크익스큐터(TaskExecutor)
를 추가할 수 있다: 자바 구성
@Bean
public TaskExecutor taskExecutor() {
return new SimpleAsyncTaskExecutor("spring_batch");
}
@Bean
public Step sampleStep(TaskExecutor taskExecutor, JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("sampleStep", jobRepository)
.<String, String>chunk(10, transactionManager)
.reader(itemReader())
.writer(itemWriter())
.taskExecutor(taskExecutor)
.build();
}
이 예제에서, taskExecutor
는 태스크익스큐터(TaskExecutor)
인터페이스를 구현하는 빈에 대한 참조이다. 태스크익스큐터(TaskExecutor)
는 표준 스프링 인터페이스이므로, 사용 가능한 구현체에 대한 자세한 내용은 스프링 사용자 가이드를 참고하자. 가장 간단한 멀티 스레드 태스크익스큐터(TaskExecutor)
는 심플에이싱크태스트익스큐터(SimpleAsyncTaskExecutor)
이다.
위 구성의 결과는 스텝
이 별도의 실행 스레드에서 각 아이템 청크(각 커밋 간격)를 읽고, 처리하고, 쓰는 방식으로 실행된다는 것이다. 이는 처리할 아이템에 고정된 순서가 없으며, 청크에 단일 스레드 사례와 비교하여 비연속적인 아이템이 포함될 수 있음을 의미한다. 잡 익스큐터가 설정한 제한(예: 스레드 풀 지원 여부) 외에도, tasklet 구성에는 스로틀(throttle) 제한(기본값: 4)이 있다. 스레드 풀이 완전히 사용되도록 하려면 이 제한(limit)을 늘려야 할 수 있다.
예를 들어, 다음과 같이 스로틀 제한(throttle-limit)를 늘릴 수 있다:
<step id="loading">
<tasklet task-executor="taskExecutor" throttle-limit="20">...</tasklet>
</step>
자바 구성에서는, 다음과 같이 스로틀 제한(throttle-limit)를 늘릴 수 있다: 자바 구성
@Bean
public Step sampleStep(TaskExecutor taskExecutor, JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("sampleStep", jobRepository)
.<String, String>chunk(10, transactionManager)
.reader(itemReader())
.writer(itemWriter())
.taskExecutor(taskExecutor)
.throttleLimit(20)
.build();
}
또한 데이터소스(DataSource)
와 같이, 스텝에서 사용되는 풀 리소스(pooled resources)에 의해 동시성(concurrency) 제한이 있을 수 있다. 해당 리소스의 풀을 최소한 스텝에서 원하는 동시 스레드 수만큼 크게 만들어야 한다.
일부 배치 사례에 멀티 스레드 스텝
을 사용하는 데는 몇 가지 제한이 있다. 스텝
의 많은 참여자(예: 리더(reader) 및 라이더(writer))는 상태를 저장한다. 상태가 스레드로 분리되지 않은 경우, 해당 컴포넌트는 멀티 스레드 스텝에서 사용할 수 없다. 특히 스프링 배치의 대부분의 리더(reader)와 라이터(writer)는 멀티 스레드 사용을 위해 설계되지 않았다. 그러나, 상태 비저장 또는 스레드 세이프 리더(reader) 및 라이터(writer)와 함께 작업할 수 있으며 아이템을 추적하기 위해 프로세스 표시기(process indicator)(Preventing State Persistence)의 사용을 보여주는 스프링 배치에 샘플(parallelJob
이라고 함)이 있다. 데이터베이스 입력 테이블에서 처리됐다.
스프링 배치는 아이템라이터(ItemWriter)
및 아이템리더(ItemReader)
의 구현체을 제공한다. 일반적으로, 자바독(Javadoc)에는 스레드 세이프 여부 또는 동시성(concurrent) 환경에서 문제를 피하기 위해 수행해야 하는 작업 정보가 있다. 자바독(Javadoc)에 정보가 없으면, 구현을 확인하여 상태가 있는지 확인할 수 있다. 리더(reader)가 스레드 세이프하지 않은 경우 제공된 싱크로나이즈아이템스트림리더(SynchronizedItemStreamReader)
로 데코레이트하거나 자체 싱크로나이징 델리게이트(synchronizing delegator)에서 사용할 수 있다. read()
에 대한 호출을 동기화(synchronize)할 수 있으며, 처리 및 쓰기가 청크에서 가장 비용이 많이 드는 스텝에서는 싱글 스레드보다 훨씬 빠르게 완료할 수 있다.
8.2. Parallel Steps
병렬화해야 하는 애플리케이션 로직을 분할하고 개별 스텝에 할당할 수 있는, 싱글 프로세스에서 병렬화할 수 있다. 병렬 스텝 익스큐션은 구성 및 사용이 쉽다.
예를 들어, 다음과 같이 스텝 3과 병렬로 스텝 (스텝 1, 스텝 2)를 실행하는 것은 간단하다:
<job id="job1">
<split id="split1" task-executor="taskExecutor" next="step4">
<flow>
<step id="step1" parent="s1" next="step2"/>
<step id="step2" parent="s2"/>
</flow>
<flow>
<step id="step3" parent="s3"/>
</flow>
</split>
<step id="step4" parent="s4"/>
</job>
<beans:bean id="taskExecutor" class="org.spr...SimpleAsyncTaskExecutor"/>
자바 구성을 사용하는 경우 스텝 3과 병렬로 스텝 (스텝 1, 스텝 2)를 실행하는 것은 간단하다: 자바 구성
@Bean
public Job job(JobRepository jobRepository) {
return new JobBuilder("job", jobRepository)
.start(splitFlow())
.next(step4())
.build() //플로우잡빌더(FlowJobBuilder) 인스턴스 빌드
.build(); //잡 인스턴스 빌드
}
@Bean
public Flow splitFlow() {
return new FlowBuilder<SimpleFlow>("splitFlow")
.split(taskExecutor())
.add(flow1(), flow2())
.build();
}
@Bean
public Flow flow1() {
return new FlowBuilder<SimpleFlow>("flow1")
.start(step1())
.next(step2())
.build();
}
@Bean
public Flow flow2() {
return new FlowBuilder<SimpleFlow>("flow2")
.start(step3())
.build();
}
@Bean
public TaskExecutor taskExecutor() {
return new SimpleAsyncTaskExecutor("spring_batch");
}
구성 가능한 태스크 익스큐터는 개별 흐름을 실행해야 하는 태스크익스큐터(TaskExecutor)
구현체를 지정하는 데 사용된다. 기본값은 싱크태스크익스큐터(SyncTaskExecutor)
이지만, 스텝를 병렬로 실행하려면 비동기(asynchronous) 태스크익스큐터(TaskExecutor)
가 필요하다. 잡은 종료 상태를 집계하고 전환하기 전에 분할된 모든 흐름(flow))이 완료되도록 한다.
자세한 내용은 Split Flows 절을 참고하자.
8.3. Remote Chunking
리모트 청킹에서, 스텝 처리는 여러 프로세스로 분할되어 일부 미들웨어를 통해 서로 통신한다. 다음 이미지는 패턴을 보여준다:
이미지 23. 리모트 청킹
매니저 컴포넌트는 싱글 프로세스이고, 워커는 여러 원격 프로세스이다. 이 패턴은 매니저가 병목 상태(bottleneck)가 아닌 경우에 가장 잘 작동하므로 처리 비용이 아이템을 읽는 것보다 더 비싸다(실제로 자주 발생).
매니저는 아이템 청크를 미들웨어에 메시지로 보내는 방법을 아는 아이템라이터(ItemWriter)
로 구성된 스프링 배치 스텝
의 구현체이다. 워커는 사용 중인 모든 미들웨어에 대한 표준 리스너(standard listeners)(예: JMS의 경우 메세지리스너(MesssageListener)
구현)이며, 해당 역할은 청크프로세서(ChunkProcessor)
인터페이스를 통해 아이템프로세서(ItemProcessor)
와 아이템라이터(ItemWriter)
를 사용하여 아이템 청크를 처리하는 것이다.
이 패턴을 사용하는 이점 중 하나는 리더(reader), 프로세서(processor) 및 라이터(writer) 컴포넌트가 기성품(off-the-shelf)이라는 것이다(스텝의 로컬 실행에 사용되는 것과 동일). 아이템은 동적으로 분할되고, 작업은 미들웨어를 통해 공유되므로, 리스너가 모두 소비자인 경우, 로드 밸런싱이 자동으로 수행된다.
미들웨어는 전달이 보장되어야하며, 각 메시지에 대해 단일 소비자가 있는 안정성이 있어야 한다. JMS가 확실한 후보이지만, 그리드 컴퓨팅 및 공유 메모리 제품 공간에는 다른 옵션(예: JavaSpaces)이 있다.
자세한 내용은 스프링 배치 인테그레이션 - 원격 청킹 절을 참고하자.
8.4. Partitioning
스프링 배치는 또한 스텝
실행을 분할하고 원격으로 실행하기 위한 SPI를 제공한다. 이 경우 원격 참여자는 로컬 처리를 위해 쉽게 구성하고 사용할 수 있는 스텝
인스턴스이다. 다음 이미지는 사용 패턴을 보여준다:
이미지 24. 파티셔닝
잡
은 연속된 스텝
인스턴스로 왼쪽에서 실행되며, 스텝
인스턴스 중 하나는 매니저로 레이블이 지정된다. 이 그림의 워커는 모두 동일한 스텝
인스턴스이며, 실제로 매니저를 대신할 수 있으므로, 잡
은 동일한 결과가 발생한다. 워커는 일반적으로 원격 서비스가 되지만 로컬 실행 스레드가 될 수도 있다. 이 패턴에서 매니저가 워커에게 보내는 메시지는 안정성이 있거나 전달이 보장될 필요가 없다. 잡리포지터리(JobRepository)
의 스프링 배치 메타데이터는 각 워커가 각 잡
을 실행하는동안 한 번만 실행되도록 한다.
스프링 배치의 SPI는 스텝의 특수 구현(파티션스템(PartitionStep)
이라고 함)과 특정 환경에 대해 구현해야 하는 두 가지 전략 인터페이스로 구성된다. 전략 인터페이스는 파티션핸들러(PartitionHandler)
및 스텝익스큐션스플리터(StepExecutionSplitter)
이며 다음 시퀀스 다이어그램은 해당 역할을 보여준다:
이미지 25. 파티셔닝 SPI
이 경우 오른쪽에 있는 스텝은 “원격” 워커이므로, 잠재적으로 이 역할을 수행하는 많은 객체 및/또는 프로세스가 있으며, 실행을 주도하는 파티션스텝(PartitionStep)
이 표시된다.
다음 예는 XML 구성을 사용할 때 파티션스텝(PartitionStep)
구성을 보여준다:
<step id="step1.manager">
<partition step="step1" partitioner="partitioner">
<handler grid-size="10" task-executor="taskExecutor"/>
</partition>
</step>
멀티 스레드 스텝의 스로틀 제한(throttle-limit) 애트리뷰트과 마찬가지로 그리드 사이즈(grid-size) 애트리뷰트는 태스크 익스큐터가 싱글 스텝의 요청으로 포화되는 것을 방지한다.
다음 예는 자바 구성을 사용할 때 파티션스텝(PartitionStep)
구성을 보여준다: 자바 구성
@Bean
public Step step1Manager() {
return stepBuilderFactory.get("step1.manager")
.<String, String>partitioner("step1", partitioner())
.step(step1())
.gridSize(10)
.taskExecutor(taskExecutor())
.build();
}
멀티 스레드 스텝의 throttleLimit
메서드와 유사하게 gridSize
메서드는 태스크 익스큐터가 싱글 스텝의 요청으로 포화되는 것을 방지한다.
스프링 배치 샘플 단위 테스트 모음(partition*Job.xml
구성 참고)에는 복사 및 확장할 수 있는 간단한 예제가 있다.
스프링 배치는 step1:partition0
등의 파티션에 대한 스텝 익스큐션을 생성한다. 많은 사람들이 일관성을 위해 매니저 스텝를 step1:manager
라고 부르는 것을 선호한다. id
애트리뷰트 대신 name
애트리뷰트을 지정하여 스텝에 대한 별칭(alias)을 사용할 수 있다.
8.4.1. PartitionHandler
파티션핸들러(PartitionHandler)
는 원격 또는 그리드 환경의 패브릭(fabric)에 대해 알고 있는 컴포넌트이다. DTO와 같은 일부 특정 패브릭 포맷으로, 래핑된 원격 스텝
인스턴스에 스텝익스큐션(StepExecution)
요청을 보낼 수 있다. 입력 데이터를 분할하는 방법이나 여러 스텝
익스큐션를 집계(aggregate)하는 방법은 알 필요가 없다. 일반적으로 말하면, 레질리언스(resilience)나 페일오버(failover)에 대해서도 알 필요가 없다. 대부분의 경우 패브릭의 기능이기 때문이다. 어쨌든 스프링 배치는 패브릭과 독립적으로 항상 재시작 기능을 제공한다. 실패한 잡은 항상 재시작할 수 있으며, 이 경우 실패한 스텝
만 재실행된다.
파티션핸들러(PartitionHandler)
인터페이스는 간단한 RMI 원격, EJB 원격, 커스텀 웹 서비스, JMS, 자바 스페이스, 공유 메모리 그리드(예: Terracotta 또는 Coherence) 및 그리드 실행 패브릭(예: GridGain)을 비롯한 다양한 패브릭 타입에 대한 특수 구현을 가질 수 있다. 스프링 배치는 독점 그리드 또는 원격 패브릭에 대한 구현을 포함하고 있지 않다. 그러나, 스프링 배치는 스프링의 태스크익스큐터(TaskExecutor)
전략을 사용하여 별도의 스레드에서 로컬로 스텝 인스턴스를 실행하는 파이션핸들러(PartitionHandler)
의 구현체를 제공한다. 구현체는 태스크익스큐터파이션해들러(TaskExecutorPartitionHandler)
라고 한다.
태스크익스큐터파티션핸들러(TaskExecutorPartitionHandler)
는 이전에 표시된 XML 네임스페이스로 구성된 스텝의 기본값이다. 다음과 같이 명시적으로 구성할 수도 있다:
<step id="step1.manager">
<partition step="step1" handler="handler"/>
</step>
<bean class="org.spr...TaskExecutorPartitionHandler">
<property name="taskExecutor" ref="taskExecutor"/>
<property name="step" ref="step1" />
<property name="gridSize" value="10" />
</bean>
다음과 같이 자바 구성으로 태스크익스큐터파티션핸들러(TaskExecutorPartitionHandler)
를 명시적으로 구성할 수 있다: 자바 구성
@Bean
public Step step1Manager() {
return stepBuilderFactory.get("step1.manager")
.partitioner("step1", partitioner())
.partitionHandler(partitionHandler())
.build();
}
@Bean
public PartitionHandler partitionHandler() {
TaskExecutorPartitionHandler retVal = new TaskExecutorPartitionHandler();
retVal.setTaskExecutor(taskExecutor());
retVal.setStep(step1());
retVal.setGridSize(10);
return retVal;
}
gridSize
애트리뷰트는 생성할 별도의 스텝 익스큐션 수를 결정하므로 태스크익스큐터(TaskExecutor)
의 스레드 풀 크기와 일치할 수 있다. 또는 사용 가능한 스레드 수보다 크게 설정하여 작업 블록을 더 작게 만들 수 있다.
태스크익스큐터파티션핸들러(TaskExecutorPartitionHandler)
는 많은 수의 파일을 복사하거나, 파일 시스템을 콘텐츠 관리 시스템으로 복제하는 것과 같은 IO 집약적 스텝 인스턴스에 유용하다. 또한 원격 호출(예: 스프링 리모팅(Spring Remoting) 사용)에 대한 프록시인 스텝 구현체를 제공하여 원격 실행에 사용할 수 있다.
8.4.2. Partitioner
파티셔너의 책임은 더 간단하다. 즉, 새 스텝 익스큐션에 대해서만 입력 파라미터로 익스큐션 컨텍스트를 생성하는 것입니다(재시작에 대해 걱정할 필요 없음). 다음 인터페이스 정의와 같이 싱글 메서드가 있다.
public interface Partitioner {
Map<String, ExecutionContext> partition(int gridSize);
}
이 메서드의 반환 값은 각 스텝 익스큐션(스트링(String))의 고유한 이름을 익스큐션컨텍스트(ExecutionContext)
형식의 입력 파라미터와 연결한다. 이름은 분할된 스텝익스큐션(StepExecution)
의 스텝명으로 나중에 배치 메타데이터에 표시된다. 익스큐션컨텍스트(ExecutionContext)
는 이름 값 쌍의 모음일 뿐이므로, 기본 키 범위, 라인 번호 또는 입력 파일의 위치를 포함할 수 있다. 그런 다음 원격 스텝는 일반적으로 다음 절에 표시된 대로 #{…} 자리 표시자(placeholders: 스텝 스코프의 레이트 바인딩)를 사용하여 컨텍스트 입력에 바인딩한다.
스텝 익스큐션의 이름(파티셔너가 반환한 맵의 키)은 잡의 스텝 익스큐션 간에 고유해야 하지만 다른 특별한 요구 사항은 없다. 이를 수행하고 사용자에게 의미 있는 이름을 지정하는 가장 쉬운 방법은 접두사+접미사 명명 규칙을 사용하는 것이다. 여기서 접두사는 실행 중인 스텝명(잡에서 고유함)이고 접미사는 카운터 이다. 이 규칙을 사용하는 프레임워크에 심플파티셔너(SimplePartitioner)
가 있다.
파티션네임프로바이더(PartitionNameProvider)
라는 옵셔널한 인터페이스를 사용하여 파티션 자체와 별도로 파티션명을 제공할 수 있다. 파티셔너(Partitioner)가 이 인터페이스를 구현하는 경우 재시작할 때 이름만 쿼리된다. 파티셔닝 비용이 많이 든다면, 이는 유용한 최적화가 될 수 있다. 파티션네임프로바이더(PartitionNameProvider)
에서 제공하는 이름은 파티셔너(Partitioner)
에서 제공하는 이름과 일치해야 합니다.
8.4.3. Binding Input Data to Steps
파티션핸들러(PartitionHandler)
에 의해 실행되는 스텝이 동일한 구성을 갖고 해당 입력 파라미터가 런타임에 익스큐션컨텍스트(ExecutionContext)
에서 바인딩되는 것이 매우 효율적이다. 이는 스프링 배치의 스텝스코프(StepScope)
기능으로 쉽게 수행할 수 있다(레이트 바인딩 절에서 자세히 설명). 예를 들어, 파티셔너가 각 스텝 호출에 대해 다른 파일(또는 디렉토리)을 가리키는 fileName이라는 애트리뷰트 키를 사용하여 익스큐션컨텍스트(ExecutionContext)
인스턴스를 생성하는 경우 파티셔너 출력은 다음 표의 내용과 유사할 수 있다.
테이블 17. Example step execution name to execution context provided by Partitioner targeting directory processing
스텝 익스큐션명(Step Execution Name) (key) | 익스큐션컨텍스트(ExecutionContext) (value) |
---|---|
filecopy:partition0 | fileName=/home/data/one |
filecopy:partition1 | fileName=/home/data/two |
filecopy:partition2 | fileName=/home/data/three |
그런 다음 익스큐션 컨텍스트에 대한 레이트 바인딩을 사용하여 파일명을 스텝에 바인딩할 수 있다..
다음 예에서는 XML에서 레이트 바인딩을 정의하는 방법을 보여준다: XML 구성
<bean id="itemReader" scope="step" class="org.spr...MultiResourceItemReader">
<property name="resources" value="#{stepExecutionContext[fileName]}/*"/>
</bean>
다음 예에서는 자바에서 레이트 바인딩을 정의하는 방법을 보여준다: 자바 구성
@Bean
public MultiResourceItemReader itemReader(@Value("#{stepExecutionContext['fileName']}/*") Resource [] resources) {
return new MultiResourceItemReaderBuilder<String>()
.delegate(fileReader())
.name("itemReader")
.resources(resources)
.build();
}