[스프링] 스프링 배치 기초
스프링 배치 기본 동작 구조
인프라 구조
스프링 배치는 잡(job)을 시작하고 잡 실행 메타데이터를 데이터베이스 저장하는 관련 컴포넌트를 제공한다. 이러한 컴포넌트들은 배치 애플리케이션의 실행을 도와주기 때문에 개발 시 반드시 직접 사용할 필요는 없지만, 애플리케이션 구동 시 관련 컴포넌들이 적어도 한 번, 그리고 정상적으로 구성되어야 한다.
잡 실행 관련 컴포넌트는 Job
, JobParameters
, JobInstance
, JobExecution
이며, 잡 실행 메타데이터를 데이터베이스에 저장하는 관련 컴포넌트는 JobRepository
이다.
컴포넌트
Job
과 JobParameters
는 런타임 로직 파라미터화를 위한 것이다. 파라미터에 따른 JobInstance
객체를 인스턴스화를 함으로써 서로 다른 잡 실행 구별이 가능하다. 즉, JobInstance
는 Job
JobParameter
의 결합으로 서로 구별되며 동일한 Job
에 대해 서로 다른 JobParameters
가 결합되어 서로 다른 JobInstance
가 생성된다.
서로 다른 파라미터로 실행한 잡은 서로 다른 잡이다. 동일한 파라미터로 실행한 잡은 동일한 잡이다. 서로 다른 파라미터로 잡을 실행할 때마다 서로 다른 JobInstance
가 생성되며 동일한 파라미터로 잡을 실행할 때마다 서로 다른 JobExecution
이 생성된다. 즉, 다음과 같이 구별할 수 있다.
- 서로 다른 잡 실행 구별을 위한 객체 =
JobInstance
- 동일한 잡의 실행 구별을 위한 객체 =
JobExecution
JobExecution
은 잡의 런타임 컨텍스트다. JobInstance
가 실행될 때마다 JobExecution
이 생성된다. 잡 실행이 성공적이라면 JobInstance
하나당 JobExecution
하나가 생성되지만 잡 실행 도중 에러 발생 시 잡을 재시작(restart)하는 경우 JobExecution
은 추가적으로 생성될 수 있다.
서로 다른 잡을 구별하기 위해 JobInstance
를 사용하고, 동일한 잡의 라이프사이클 및 버전 관리를 위해 JobExecution
을 사용한다.
JobRepository
는 메타데이터 저장을 위한 인터페이스이다. 스프링 배치는 잡의 실행과 관련된 메타데이터를 저장하기 위해 데이터베이스 연결을 필요로 한다.
스프링 배치 구성 및 동작 흐름
ItemReader
와ItemWriter
정의ItemProcessor
정의Step
정의Tasklet
기반Step
구성 또는 청크 기반Step
구성- 정의한
ItemReader
,ItemProcessor
,ItemWriter
설정 Step
빌드
Job
정의- 정의한
Step
설정 Job
빌드
- 정의한
Job
실행
잡과 스텝
잡은 하나 또는 여러 스텝(step)으로 구성된다. 스텝은 잡을 수행하는 가장 작은 단위이다. 스텝은 데이터를 입력(read)하는 과정, 처리된 데이터를 출력(write)하는 과정을 포함한다. 데이터 입력 과정과 출력 과정 중간에 처리(process) 과정을 포함시킬 수 있다.
잡이 여러 스텝으로 구성되는 경우 다음과 같은 방법으로 스텝 실행을 제어할 수 있다.
- 순차 스텝: 스텝들을 순차적으로 수행한다.
- 조건부 스텝: 조건(스텝의 상태)에 따라 서로 다른 스텝을 수행한다.
- 동시성 스텝: 의존관계가 없는 스텝을 동시에 수행한다.
ItemReader와 ItemWriter
데이터의 입력과 출력은 각각 ItemReader
와 ItemWriter
가 담당한다. ItemReader
가 입력값에 대한 처리를 수행한 후 ItemWriter
에 넘겨준다.
ItemWriter
는 ItemReader
로부터 값을 받은 후 입력값들(아이템 컬렉션)을 한번에 모아서 처리한다. 청크 지향 처리(chunk-oriented processing)를 사용하면 ItemReader
가 입력값에 대한 처리를 수행한 후 애그리게이션한 결과를 처리 단위(청크 단위)에 따라 ItemWriter
에 보낸다.
스프링 배치는 기본적으로 입력값을 캐시한 후 ItemReader
로 전달하며 청크 엘리먼트에 값을 캐시하지 않도록 설정 가능하다.
ItemProcessor
ItemProcessor
는 ItemReader
가 읽어들인 데이터에 원하는 로직을 적용함으로써 출력 전 입력 데이터를 처리 및 가공하는 기능 수행한다. ItemProcessor
의 로직에는 데이터 유효성 검증(validation) 과정이 포함된다.
여러 종류의 ItemProcessor
를 정의하고 ItemProcessor
들을 서로 연결하여 데이터 검증 및 처리 작업을 나누어 진행할 수 있다.
스프링 배치의 자동 구성과 잡 실행
스프링 배치는 애플리케이션을 실행하기 위한 기본 구성을 자동으로 설정하고 여러 기능을 제공하기 위해 @EnableBatchProcessing
을 제공한다. Job
, Step
등의 빈을 정의하는 구성 클래스에 해당 어노테이션을 지정하면 된다.
스프링 배치를 활성화하고 사용하기 위한 공통 구조를 제공하는 베이스 구성 클래스는 AbstractBatchConfiguration
이며 BatchConfigurationSelector
는 @EnableBatchProcessing
지정 구성 클래스를 확인한다. 적절한 배치 구성 클래스에 의해 다음 빈들이 자동 구성된다.
JobRepository
JobLauncher
JobRegistry
JobExplorer
JobBuilderFactory
StepBuilderFactory
PlatformTransactionManager
구성 클래스에서 위 빈들을 직접 정의하고 컨텍스트에 등록할 수도 있다.
@EnableBatchProcessing
은 JDBC 기반으로 배치 애플리케이션의 인프라를 구성한다. 따라서 구성 클래스에는 Job
과 Step
빈 뿐만 아니라 JobRepository
설정을 위해 적절한 DataSource
빈 정의가 필요하다. DataSource
빈을 직접 정의하는 대신 구성 클래스가 DefaultBatchConfigurer
클래스를 상속하도록 하고 createJobRepository()
메서드를 재정의하여 JobRepository
빈을 직접 정의할 수도 있다. 애플리케이션 컨텍스트에 DataSource
빈이 등록되어 있지 않으면 로컬 메모리를 사용하는 맵 기반의 JobRepository
가 구성된다.
구성 클래스가 여러 개 정의되어 있는 경우 하나의 구성 클래스에만 @EnableBatchProcessing
이 지정되어야 한다.
스프링 배치는 빈의 스코프를 잡 또는 스텝으로 지정하는 @JobScope
와 @StepScope
를 제공한다. 이 어노테이션은 런타임에 잡 또는 스텝 실행 컨텍스트로부터 값을 가져와 빈에 주입하는 지연 바인딩(late binding)을 가능하게 한다.
정의된 스프링 배치 애플리케이션을 실행하는 ApplicationRunner
는 JobLauncherApplicationRunner
이다. JobLauncherCommandLineRunner
는 스프링 부트 2.3.0 버전부터 제거되었다. JobLauncherApplicationRunner
는 기본적으로 컨텍스트에 존재하는 모든 잡을 실행한다. setJobNames()
메서드를 통해 잡 이름을 지정하여 특정 잡을 실행할 수도 있다. JobLauncherApplicationRunner
는 JobLauncher
, JobExplorer
, JobRepository
구현체를 의존성 주입 받으며 이 의존 객체들은 기본적으로 @EnableBatchProcessing
에 의해 자동 구성 및 빈 등록된다. 스프링 배치 애플리케이션은 JobLauncher
인터페이스의 run()
메서드를 호출함으로써 실행할 수도 있다. 메인 메서드에서 SpringApplication
의 run()
메서드를 호출하여 ApplicationRunner
인터페이스 구현체인 JobLauncherApplicationRunner
를 사용하는 경우에도 내부적으로는 JobLauncher
인터페이스의 run()
메서드를 호출함으로써 배치 애플리케이션을 실행한다. 별도 구성을 하지 않을 경우 JobLauncher
인터페이스 구현체로 SimpleJobLauncher
가 사용된다.
스프링 배치 애플리케이션의 자동 구성은 BatchAutoConfiguration
에 의해 수행된다. BatchAutoConfiguration
은 애플리케이션 프로퍼티 속성을 읽고 잡 실행을 처리한다. 스프링 배치와 관련된 스프링 부트의 프로퍼티는 BatchProperties
링크에서 처리된다. spring.batch.job.enabled
속성을 true
로 설정하면 애플리케이션 실행 시 정의된 모든 Job
(애플리케이션 컨텍스트에 등록된 모든 Job
컴포넌트)이 실행된다. spring.batch.job.enabled
속성을 true
로 직접 설정하는 대신 스프링 배치가 제공하는 @EnableBatchProcessing
설정 시 자동으로 처리된다. spring.batch.job.enabled
속성을 false
로 설정하면 이 동작을 비활성화할 수 있다. 여러 잡이 정의되어 있다면 애플리케이션을 시작할 때 실행할 잡 이름을 spring.batch.job.name
속성에 지정하여 특정 잡만 실행할 수 있다. 정의된 여러 잡을 실행하려면 spring.batch.job.names
속성에 여러 잡 이름을 설정한다. JobLauncherApplicationRunner
는 먼저 빈으로 등록된 잡을 실행한 후 JobRegistry
에서 잡을 조회하여 실행한다.
기본적인 잡 실행에 필요한 객체는 Job
, JobLauncher
이다. 잡 실행 결과로 반환되는 JobExecution
은 잡의 상태 정보를 가지고 있다. 잡의 상태 정보란 잡 생성/시작/종료 시간, 실행/종료 상태, 최종 수정 날짜 등을 말한다.
서로 다른 Job
인스턴스를 생성하기 위해서는 파라미터가 필요하다. 파라미터에 따라 서로 다른 Job
인스턴스의 식별자 키를 생성함으로써 서로 다른 잡 실행을 구별할 수 있다. 잡 실행 시점에 Job
과 JobParameters
를 합쳐 런타임 로직을 파라미터화할 수 있다.
JobParameters
잡 실행을 파라미터화할 수 있다. 잡 실행을 파라미터화한다는 것은 각각의 잡 실행을 서로 구별짓는 것을 말한다. 중복된 데이터 처리를 방지하기 위해 잡 실행은 적절히 파라미터화 되어야 한다. 스프링 배치는 잡 실행 시 전달할 파라미터를 구성하기 위해 JobParameters
를 사용한다. JobParameters
를 통해 잡을 파라미터화하고 스텝에서 파라미터를 사용한다. JobLauncher
의 run()
메서드는 실행될 잡인 Job
과 잡 실행 시 사용되는 파라미터인 JobParameters
를 인자로 받아 잡 인스턴스인 JobInstance
를 생성한다. 동일한 Job
의 서로 다른 실행을 구별짓는 JobParameter
에 의해 서로 다른 JobInstance
가 생성된다.
컴파일 시점이 아닌 런타임에 JobParameters
를 구성하는 방법에는 여러가지가 있다.
- 애플리케이션 실행 시 인자로 전달받은 값으로
JobParameters
구성 - 애플리케이션 프로퍼티(또는 환경 변수)에 설정된 값으로
JobParameters
구성 - 외부 요청 파라미터로
JobParameters
구성
런타임에 JobParameters
를 구성하였다면 파라미터 값을 사용하는 곳에서의 바인딩 시점이 중요하다. 스프링 배치는 Job
과 Step
의 다양한 속성들에 대해 잡 실행 시점에 지연 바인딩을 가능하게 한다.
잡과 스텝 속성의 지연 바인딩
스프링 배치는 잡 또는 스텝 실행 시 특정 빈을 인스턴스화할 수 있도록 빈 스코프 설정 어노테이션인 @StepScope
와 @JobScope
를 제공한다. @StepScope
는 해당 빈을 스텝 스코프로 지정한다. 스텝이 시작될 때까지 빈은 실제로 인스턴스화될 수 없다. 스텝 실행 시 스텝 컨텍스트에서 접근 가능한 참조의 지연 바인딩을 사용하기 위해 @StepScope
를 사용할 수 있다.@StepScope
는 스텝 컨텍스트에 대한 스코프를 설정하므로 실행 중인 스텝 별로 해당 빈 인스턴스는 하나만 존재한다. 스프링 배치 3 버전 부터 도입된 @JobScope
는 @StepScope
와 적용 및 빈 구성 방식이 유사하다. @JobScope
는 잡 컨텍스트에 대한 스코프이므로 실행 중인 잡 별로 해당 빈 인스턴스는 하나만 존재한다. 잡 실행 시 잡 컨텍스트에서 접근 가능한 참조의 지연 바인딩을 위해 @JobScope
를 사용할 수 있다.
빈 정의 시 @StepScope
, @JobScope
를 설정하는 것은 각각 @Scope(value="step", proxyMode=대상클래스명)
, @Scope(value="job", proxyMode=대상클래스명)
를 지정하는 것과 동일하다. 두 어노테이션은 모든 빈 정의에 명시적으로 지정할 필요가 없도록 프록시 모드를 기본값으로 설정한다. @Values
을 사용하여 스텝/잡 컨텍스트로부터 값을 주입해야 하는 빈에 대해, 그리고 스텝/잡 실행과 라이프사이클을 공유해야 하는 모든 빈에 대해 이 어노테이션을 사용할 수 있다.
@StepScope
, @JobScope
와 SpEL을 사용하면 잡 실행 시 스텝/잡 실행 컨텍스트인 ExecutionContext
에 접근하여 잡 실행과 관련된 다양한 속성값들을 참조하여 데이터의 지연 바인딩이 가능하다. 이를 통해 스텝/잡, 스텝/잡 실행 컨텍스트, 그리고 잡 실행 파라미터로부터 속성값을 가져올 수 있다. 잡이 실행되면 실행 컨텍스트인 ExecutionContext
는 잡과 연관되며, 잡을 구성하는 스텝이 실행되면 ExecutionContext
는 잡 뿐만 아니라 스텝과도 연관된다.
스텝 실행 시 사용되는 Reader
, Processor
등의 빈 정의 시 @StepScope
와 SpEL을 사용하면 스텝 실행 시점에 해당 빈에 속성값의 지연 바인딩이 가능하다.
@StepScope
@Bean
public FlatFileItemReader flatFileItemReader(@Value("#{jobParameters[input.file.name]}") String name) {
return new FlatFileItemReaderBuilder<Foo>()
.name("flatFileItemReader")
.resource(new FileSystemResource(name))
...
}
@JobScope
를 사용하는 예는 다음과 같다.
@JobScope
@Bean
public FlatFileItemReader flatFileItemReader(@Value("#{jobExecutionContext['input.name']}") String name) {
return new FlatFileItemReaderBuilder<Foo>()
.name("flatFileItemReader")
.resource(new FileSystemResource(name))
...
}
멀티스레드 사용 스텝 또는 파티션된 스텝에서 @JobScope
가 지정된 빈을 사용하는 데에는 제한이 있다. 스프링 배치는 이러한 경우에 스레드를 제어하지 않으므로 @JobScope
가 지정된 빈을 사용하면 의도치 않은 상황이 발생할 수 있으므로 주의해야 한다.
잡의 비동기 실행
TaskExecutor
를 이용하여 요청 스레드와 상관없이 잡을 비동기로 실행 가능하다. 이 경우 해당 빈을 스레드 논블로킹으로 관리한다.
트랜잭션
스프링 배치는 스텝 수준에서 트랜잭션을 처리한다. 스텝이 Tasklet
으로 구성되는 경우 execute()
메서드에 트랜잭션이 적용된다. exexute()
메서드는 반복 실행 가능하며 각각의 실행은 독립적인 트랜잭션으로 처리된다. exexute()
메서드 호출 시마다 새로운 트랜잭션이 생성된다.
스텝이 청크 지향 스텝인 경우 설정된 사이즈의 청크 별로 트랜잭션이 적용된다. 아이템(레코드) 별 트랜잭션이 아닌 청크 단위로 트랜잭션을 적용하여 처리 효율을 높이고 스텝 실행 시 발생하는 에러가 모든 아이템이 아닌 현재 처리 중인 청크에만 영향을 미치는 견고성을 제공할 수 있다. 스텝 실행 중 오류 발생 시 청크 단위의 롤백을 수행한다. ItemWriter
및 ItemProcessor
에서 예외 발생 시 청크 단위의 트랜잭션 롤백을 할 수 있다. 기본적으로 ItemReader
에서 예외가 발생하는 경우에는 스텝 실행의 트랜잭션 롤백은 일어나지 않는다. 예외 발생으로 인한 트랜잭션 롤백 동작은 재시도 및 건너뛰기 구성에 관계없이 적용된다.
트랜잭션은 스텝 내 프로세스 뿐만 아니라 스텝 실행 전후 프로세스에 대해서 적용 가능하며 스텝 실행 전후 동작하는 리스너에 대해서도 적용이 가능하다. 리스너 동작 또한 스텝이 처리하는 데이터와 연관되어 있을 수 있으므로 이 경우 스텝 실행 도중 예외 발생 시 적절한 트랜잭션 처리가 필요하다.
스프링 배치는 배치 잡 내 이벤트에 반응하는 다양한 형태의 리스너를 제공한다. 처리 도중 아이템을 건너뛰는 경우 로그를 남기려고 한다면 ItemSkipListener
리스너를 스텝에 적용하면 된다. 스프링 배치는 이러한 리스너에 대해 트랜잭션을 적용할 수 있지만 상황에 따라 달라진다. 리스너의 메서드가 트랜잭션이 적용되는지 그렇지 않은지에 대한 정해진 규칙은 없으며 해당 리스너에 대한 명세를 살펴봐야 한다.
스프링 배치는 데이터의 입력, 처리, 출력 단계에 반응하는 리스너도 제공한다. 각 단계를 처리하기 전 후에 리스너를 실행하거나 처리 도중 에러가 발생하였을 때 리스너를 실행할 수 있다. 에러 발생 시 실행되는 콜백은 스프링 배치가 롤백하려는 트랜잭션에 한해서 트랜잭션이 적용된다. 만약 에러에 대한 로그를 데이터베이스에 기록하려면 REQUIRES_NEW
전파 수준을 사용하여 트랜잭션을 직접 처리해야 한다. 이렇게 함으로써 로깅 트랜잭션을 청크 트랜잭션과 독립적으로 만들고 롤백 가능하도록 만들 수 있다.
트랜잭션 관련 속성에는 전파 수준, 격리 수준, 타임아웃이 있다. 기본적으로 속성값이 설정되어 있으며 이를 재정의할 수 있다. 전파 수준의 기본값은 REQUIRED
이며 격리 수준의 기본값은 READ_COMMITED
이다. 배치 작업이 해당 데이터를 처리하는 유일한 프로세스인 경우에는 데이터의 동시 접근 문제에 대해 신경 쓸 필요가 없으므로 격리 수준을 낮추면 기본 격리 수준 보다 더 빠른 처리가 가능하다. 배치 작업이 다른 애플리케이션과 동시에 동작하는 경우 데이터를 적절하게 조회하고 수정하기 위해서 격리 수준을 높일 필요가 있지만 격리 수준을 높일수록 데이터 처리 성능이 저하될 수 있다.
스프링 배치 애플리케이션에서 @Transactional
어노테이션을 사용하여 스프링의 선언적 트랜잭션 관리 기능을 사용할 수 있다. @Transactional
어노테이션이 지정된 애플리케이션 코드를 호출하면 해당 코드의 트랜잭션은 스프링이 아닌 스프링 배치가 관리하는 트랜잭션을 사용한다. 이때 기본 전파 수준은 REQUIRED
이므로 @Transactional
이 사용하는 트랜잭션은 스프링 배치의 트랜잭션과 동일하다. 그러나 스프링이 관리하는 트랜잭션과 스프링 배치가 관리하는 트랜잭션에 충돌이 발생할 수 있으며 이를 피해야 한다. 예를 들어, @Transactional
어노테이션이 적용된 애플리케이션 코드가 스프링 배치 작업의 청크 트랜잭션을 방해할 수 있다. 트랜잭션 전파 수준 속성에 따라 스프링이 관리하는 트랜잭션이 스프링 배치가 관리하는 트랜잭션에 관여할 수 있다. 트랜잭션 관리 충돌을 막기 위해서는 @Transactional
어노테이션을 사용한 스프링의 선언적 트랜잭션 관리 기능 자체를 비활성화하거나 사용한다면 전파 수준 사용에 주의 해야한다. 트랜잭션을 매번 새로 생성하는 REQUIRES_NEW
전파 수준을 사용하면 애플리케이션 코드가 스프링 배치의 트랜잭션과 독립적으로 자체 트랜잭션에서 실행되기 때문에 문제를 일으킬 수 있다.
애플리케이션이 둘 이상의 데이터 소스(데이터베이스, 메시지 큐 등)에 대한 트랜잭션 관리를 수행해야 할 수도 있다. 이러한 트랜잭션을 글로벌(global) 트랜잭션 또는 분산(distributed) 트랜잭션이라고 한다. 데이터 소스가 하나일 경우 트랜잭션은 로컬(local) 트랜잭션이라고 한다. 단일 데이터 소스와 마찬가지로 멀티 데이터 소스에 대한 트랜잭션 연산들도 모두 ACID 특성을 만족해야 한다. 예를 들어, 하나의 스텝 실행에서 먼저 첫 번째 데이터베이스에서 데이터를 처리한 후 두 번째 데이터베이스에 데이터를 처리하는 경우 두 연산은 원자성을 가져야 하며 첫 번째 작업은 성공하였지만 두 번째 작업 중 에러가 발생한다면 첫 번째 연산은 커밋되지 않고 롤백되어야 한다.
스프링 배치는 스텝 수준에서 트랜잭션을 처리하지만 스텝 실행 중 ItemReader
에서 예외가 발생하는 경우 스텝 실행의 트랜잭션 롤백은 일어나지 않는다. 스프링 배치는 최적화를 위해 아이템 출력 도중 발생하는 재시도 가능한 에러를 대비하여 청크 단위로 아이템을 읽고 버퍼에 저장한다. 출력 도중 에러 발생 시 트랜잭션을 롤백하고 실행을 재시도할 때 ItemReader
로부터 아이템을 다시 읽는 대신 캐시로부터 읽은 아이템을 가져와 ItemWriter
에게 전달할 수 있다. 스프링 배치가 ItemReader
에 캐싱을 적용하는 기능은 데이터베이스로부터 아이템을 읽는 경우에는 문제가 되지 않는다. 아이템을 읽는 도중 에러가 발생하여 트랜잭션이 롤백되더라도 데이터베이스의 데이터는 영향을 받지 않기 때문이다. 하지만 JMS의 경우 메시지를 읽고(queue) 제거(dequeue)하는 과정이 하나의 트랜잭션으로 처리되어야 하는 경우가 있다. 이 경우 트랜잭션이 롤백된다면
이처럼 캐싱 기능을 비활성화하여 트랜잭션이 롤백되더라도 배치 처리에 문제가 없도록 할 수 있다. 또는 다음과 같이 트랜잭션의 롤백 자체가 일어나지 않도록 할 수도 있다.
스프링 배치에서 멀티 데이터소스 구성
스프링 배치는 기본적으로 잡 실행과 관련된 메타데이터를 테이블에 저장하여 관리하기 위해 애플리케이션 컨텍스트에서 하나의 DataSource
빈을 탐색한다. DataSource
빈이 정의되지 않은 경우에는 맵 기반의 JobRepository
가 사용된다. DataSource
빈이 두 개 이상 존재할 경우 스프링 배치는 어떤 DataSource
빈을 배치 처리에 사용할지 모르기 때문에 빈 정의 중복으로 인해 UnsatisfiedDependencyException
예외가 발생하게 된다. 따라서 DataSource
빈 중 하나에 @Primary
를 사용하여 스프링 배치가 사용할 빈을 지정해야 한다. @Primary
를 지정한 빈이 정의되지 않은 경우 이름이 지정된 DataSource
빈이 사용된다.
배치 처리가 아닌 다른 용도의 데이터베이스 접근을 위한 @Primary
가 지정된 DataSource
빈이 이미 정의되어 있는 경우 배치 처리용 DataSource
빈을 정의하는 @Bean
메서드에 @BatchDataSource
어노테이션을 사용하면 멀티 데이터소스 구성을 할 수 있다. @BatchDataSource
어노테이션은 배치 자동 구성 시 주입되는 DataSource
빈에 대한 빈 의존성 주입 어노테이션이다.
참고
- 스프링 배치 공식 문서 - 배치 도메인 언어
- 스프링 5 레시피 (4판) : 스프링 애플리케이션 개발에 유용한 161가지 문제 해결 기법 (전2권) / 마틴 데니엄, 다니엘 루비오, 조시 롱 공저/이일웅 역
- Spring Batch in Action - ARNAUD COGOLUEGNES, THIERRY TEMPLIER, GARY GREGORY, OLIVIER BAZOUD
- https://docs.spring.io/spring-batch/reference/step/late-binding.html
Comments