Spring

Spring Batch - JobParameters

develua 2023. 2. 26. 19:22

JobParameters를 사용하기 위해선 반드시 @JobScope, @StepScope와 같은 어노테이션을 지정해 주어야 한다.
@JobScope, @StepScope 각각의 어노테이션이 무엇인지 알아보기에 앞서. 저 Scope가 의미하는 것에 대해서 먼저 알아보도록 하자.

Bean Scope

Scope는 범위란 의미를 지니고 있다. 즉, bean 이 생성하고, 소멸하는 시점까지의 활동 영역으로 재해석될 수 있는데, Spring 에서는 아래와 같은 5가지의 기본 Bean Scope 가 제공된다.

  1. Singleton Scope: 가장 기본 유형으로 스프링 컨테이너 내에 하나의 인스턴스만 존재하며, 항상 동일한 인스턴스를 반환한다.
  2. Prototype Scope: 스프링 컨테이너 내에서 빈의 새로운 인스턴스가 요청될 때마다 새로운 인스턴스가 생성되는 것을 의미한다.
  3. Request Scope: 웹 애플리케이션에서만 사용할 수 있으며, HTTP 요청마다 새로운 인스턴스가 생성된다.
  4. Session Scope: 웹 애플리케이션에서만 사용할 수 있으며, HTTP 세션이 생성될 때마다 새로운 인스턴스가 생성된다.
  5. Global Session Scope: Portlet 과 관련있는 Scope, Portlet 웹 애플리케이션에서만 사용할 수 있으며, Portlet 세션이 생성될 때마다 새로운 인스턴스가 생성된다.
    이 외에도 사용자 정의 스코프를 반들어 빈의 라이프 사이클을 유연하게 관리할 수 있다.

즉, Scope는 빈의 생성과 라이프사이클을 관리하는 방법으로 보다 스프링 컨테이너 내의 생성되는 다양한 빈들을 유연하게 관리할 수 있도록 돕는다.

JobScope, StepScope 의 의미

이러한 맥락에서 JobScope, StepScope 는 각각 Job, Step의 실행과 종료와 그 사이클을 같이한다.
즉, JobScope로 빈 스코프가 지정된 Job의 경우, Job 실행 시점에 빈으로 등록되고 Job의 종료 시점에 소멸된다.
StepScope로 빈 스코프가 지정된 Step의 경우에도 해당 Step이 실행되는 시점에 빈으로 등록되고, Step 종료 시점에 소멸된다.

Prototype Scope 와의 차이는?

위의 5가지 기본 스코프등 중 Prototype Scope와 그 성격이 유사하다고 느껴질 수 있다. 실제로 인스턴스 요청 시마다 인스턴스가 생성되는 부분에서는 유사하다고 볼 수 있으나, JobScope, StepScope 은 각각이 JobExecution 과 StepExecution 레벨에서만 생성되고, 각 Execution이 완료될 때, 인스턴스가 소멸된다는 점에서 다르다고 볼 수 있다. 반면에 Prototype Scope 은 소멸 시점을 스프링 컨테이너가 직접 관리하지 않기 때문에 따로 빈의 소멸 시점을 정의해야 한다. 즉, 두개의 스코프가 유사한 성격을 가지면서도 JobScope, StepScope은 좀 더 Batch에 특화되어 있다고 볼 수 있다.

싱글톤 스코프로 생성하지 않는 이유

상태 관리 및 동기화 문제

싱글톤 스코프를 사용하게 되는 경우, 모든 Job, Step 이 하나의 인스턴스를 바라보게 되기 때문에 JobParameters 를 통해 매번 다른 파라미터를 넘겨받는 Job, Step의 특성상 동일한 인스턴스를 공유할 수는 없다.

또한, 병렬적으로 Job이 실행될 때, 동시 상태 변경을 시도하는 경우 동기화 문제가 발생할 수 있다. 빈의 상태 변경은 다른 Job에 영향을 끼칠 수도 있다.

결과적으로, 각 Job, 혹은 Step 이 넘겨받은 파라미터를 기반으로 독립적으로 분리된 인스턴스를 생성하고 이용할 수 있도록 해야한다.



JobParameters를 이용한 Job 의 구현

이제, 직접 JobParameters 갖는 간단한 Job을 직접 구현해 보자.
만들고자 하는 Job 은 넘겨받은 요일(JobParameter)을 기준으로 잡고, 해당 요일 이전까지 만료일(expiredAt)을 갖는 쿠폰들에 대해서 상태를 주기적으로 비활성화하는 Job을 생성하고자 한다.


우선, 위 내용 테스트를 위해서 데이터를 Batch Job을 이용해 먼저 초기화해보자.
아래와 같이 데이터 초기화를 목적으로 갖는 Job을 생성해 본다.

@Configuration  
class DataInitializedBatch(  
    private val jobBuilderFactory: JobBuilderFactory,  
    private val stepBuilderFactory: StepBuilderFactory,  
    private val couponRepository: CouponRepository  
) {  

    @Bean  
    fun dataInitializedJob(): Job {  
        return jobBuilderFactory["dataInitializedJob"]  
            .start(dataInitializedStep())  
            .build()  
    }  

    @Bean  
    fun dataInitializedStep(): Step {  
        val baseDate = LocalDateTime.now().minusDays(2)  
        return stepBuilderFactory["dataInitializedStep"].tasklet { _, _ ->  
            val couponList = mutableListOf<CouponEntity>()  
            // 오늘을 기준으로 2일 전부터 2일 후까지 총 5개의 요일로 만료 시간 세팅  
            (0..4).map { day ->  
                (0..9).map {  
                    val coupon = CouponEntity(  
                        exiredAt = baseDate.plusDays(day.toLong())  
                    )  
                    couponList.add(coupon)  
                }  
            }           
            couponRepository.saveAll(couponList)  
            RepeatStatus.FINISHED  
        }.build()  
    }  
}

물론, 해당 Job 만 실행되도록 하기 위해 application.yaml 파일에 아래와 같이 세팅해 준다.

############
job.name: dataInitializedJob  
############

spring:  
  datasource:  
    url: jdbc:mysql://localhost:3306/spring_batch  
    username: root  
    password:  -
  batch:  
    jdbc:  
      initialize-schema: always  
    ############
    job:  
      names: ${job.name:NONE}
    ############

spring.batch.job.name 설정 값에 대해서는 아래와 같이 설명되어 있고, 따로 설정해주지 않았을 때는 컨텍스트에 있는 모든 Job을 실행한다.

Comma-separated list of job names to execute on startup (for instance, 'job1,job2'). By default, all Jobs found in the context are executed.


자, Job이 준비되었으니 실행하여 데이터를 세팅해보자.
아래와 같이 BATCH_STEP_EXECUTION table에 정상적으로 dataInitializedStep 이 실행된 것이 확인되었으며, 데이터도 정상적으로 세팅되었다.

이제, 본격적으로 JobParameter 로 넘겨받은 기준일을 기점으로 쿠폰을 만료시키는 Job을 생성하고, 실행해보자.


Step 은 Chuck 지향 처리로 구현하였으며, Chuck Size는 5개로 지정했다.

@Configuration  
class CouponExpiredBatch(  
    private val jobBuilderFactory: JobBuilderFactory,  
    private val stepBuilderFactory: StepBuilderFactory,  
    private val entityManagerFactory: EntityManagerFactory  
) {  

    @Bean  
    fun couponExpiredBatchJob(): Job {  
        return jobBuilderFactory["couponExpiredBatchJob"]  
            .start(couponExpiredBatchStep()).build()  
    }  

    @Bean  
    fun couponExpiredBatchStep(): Step {  
        return stepBuilderFactory["couponExpiredBatchStep"]  
            .chunk<CouponEntity, CouponEntity>(5)  
            .reader(couponReader())  
            .processor(couponProcessor(""))  
            .writer(couponWriter()).build()  

    }  

    @Bean  
    fun couponReader(): ItemReader<CouponEntity> {  
        return JpaPagingItemReaderBuilder<CouponEntity>()  
            .name("couponJpaPagingItemReader")  
            .entityManagerFactory(entityManagerFactory)  
            .pageSize(5)  
            .queryString("SELECT p FROM coupon as p")  
            .build()  
    }  

    @Bean  
    @StepScope    
    fun couponProcessor(  
        @Value("#{jobParameters[expiredAt]}") expiredAt: String  
    ): ItemProcessor<CouponEntity, CouponEntity?> {  
       // String 값으로 받고, 내부적으로 형변환하여 사용
       val targetDate = LocalDateTime.parse(expiredAt, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
        return ItemProcessor<CouponEntity, CouponEntity?> {  
            if(it.exiredAt!!.isBefore(targetDate) && it.state == CouponState.ACTIVE) it  
            else null// null 반환 시 Writer에 전달하지 않음  
        }  
    }  

    @Bean  
    fun couponWriter(): ItemWriter<CouponEntity> {  
        return ItemWriter {  
            it.map {  
                coupon -> coupon.state = CouponState.INACTIVE  
            }  
        }    
    }  

}

일단, 위와 같이 JobParameters가 지원하지 않는 타입인 LocalDateTime을 넘겨 받아 사용하고자 할 때는 JobParameters 로는 기본 인자인 String 값을 받고, 내부적으로 형변환하여 사용하도록 구현했다.


Program Arguments 를 아래와 같이 2023-02-24 로 설정한뒤, couponExpiredBatchJob Job을 실행해 본다.

아래와 같이 2023-02-23 일이 expiredAt 인 coupon 들에 대해서 성공적으로 status 변환이 완료되었다.

JobParameters 구현의 문제

하지만, 위와 같이 String 타입으로 넘겨 받아 형변환해주는 코드는 다음과 같은 문제가 있다.

  1. 관심사가 다른 형변환 코드가 Job 이나 Step의 생성 코드에 추가된다.
  2. 만약, 앞서 작업한 코드와 같이 LocalDateTime 으로 변환하는 작업을 여러개의 Job에서 필요로 한다면, 형변환 코드가 이곳저곳에 반복되어 사용되어야 한다
    즉, 해당 코드는 관심사의 분리가 필요하고, 빈번하게 변환 가능성이 있는 경우 코드의 중복을 예방해 줄 필요가 있다.

개선 방법

위와 같은 문제를 해당 글 에서는 다음과 같이 제언한다.

JobParameter에 관한 모든 기능을 담당할 Job Parameter 클래스를 생성한다.



즉, 동일한 형변환 작업이 필요한 경우, 이미 정의해 놓은 클래스를 이용해 해당 Job의 생명주기와 동일한 빈으로 등록하고, 주입받아 사용한다.

아래와 같이 구현할 수 있다.

[코드 1] - LocalDateTimeJobParameter 클래스의 구현

// (1) 
interface JobParameter<T> {  
    val target: T  
}  
open class LocalDateTimeJobParameter(  
    override val target: LocalDateTime  
): JobParameter<LocalDateTime> {  
    // (2)
    constructor(needToConvert: String): this(LocalDateTime.parse(needToConvert, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))  

    @PostConstruct  
    fun init(){  
        // print-1
        println("target successfully saved: $target")  
    }  
}

(1) : 인터페이스를 선언해 변환 후, 최종 타입을 제네릭으로 선언해 준다. 다른 JobParameter 변환 클래스에서도 해당 인터페이스를 구현하기 위함이다.
(2): 생성자(constructor)를 이용해 넘겨 받은 String 타입을 필요한 타입으로 변환해 저장해 준다.


위와 같이 클래스 형태로 선언해준 JobParameter Class 를 해당 형변환이 필요한 Job 에서 사용할 수 있도록 아래와 같이 JobScope를 가진 Bean으로 등록해 준다.

[코드 2] - JobScope 를 가진 Bean으로 등록

@Configuration  
class ParameterConfiguration {  
    private val logger: Logger = LoggerFactory.getLogger(this::class.java)  

    @Bean  
    @JobScope    
    fun localDateTimeJobParameter(  
        @Value("#{jobParameters[expiredAt]}") targetDate: String  
    ): JobParameter<LocalDateTime>  {  
        // logger-1
        logger.info("hello here")  
        return LocalDateTimeJobParameter(targetDate)  
    }  
}

JobScope를 사용하려면, 반드시 어떤 Job과 그 생명주기를 같이할 것인지 특정해야 할텐데, 이는 실행할 Job, Step 에 JobParameter 관련 의존성을 추가해줌으로써 연관지을 수 있다.
즉, ParameterConfiguration 내에 모아둔 JobParameter 를 변환해주는 빈들은 해당 빈을 의존성으로 추가해 주면, 어떤 Job이든 가져다 사용하게 될 수 있다.


JobScope 을 가진 빈으로 등록해 주고, 해당 JobParameter 빈을 의존성으로 추가해주면, 각 Step 에서 아래와 같이(1) 직접 접근이 가능하다.

[코드 3] - 의존성 추가

// Autowired 로 의존성 주입
@Autowired  
lateinit var jobParameter: JobParameter<LocalDateTime> 

// . . . . (코드 생략)

@Bean  
@StepScope  
fun couponProcessor1(): ItemProcessor<CouponEntity, CouponEntity?> {  
    // logger-1
    logger.info("test: ${jobParameter.target}")  
    return ItemProcessor<CouponEntity, CouponEntity?> {  
        // (1) 의존성 주입받은 jobParameter 의 멤버 변수에 직접 접근
        if(it.exiredAt!!.isBefore(jobParameter.target) && it.state == CouponState.ACTIVE) it  
        else null //null 반환 시 Writer에 전달하지 않음  
    }  
}

아래와 같이 정상적으로 값이 출력됨을 확인해 볼 수 있다.