티스토리 뷰

Java/batch

Spring batch 테스트하기

LichKing 2022. 5. 28. 14:33

spring batch 를 이용해 많은 batch job 을 만들지만 배치를 테스트하기란 쉽지않다. 하지만 배치도 분명한 하나의 애플리케이션인만큼 테스트 작성에 대한 욕구가 있었는데 그것들을 정리해보고자한다.

 

먼저 spring batch reference 에서 테스트 코드에 대해 가이드하고있는 부분이 있는데 가이드 문서가 불친절해 제대로 테스트를 작성하기 힘들었다. 실전 엔터프라이즈 애플리케이션에서는 해당 가이드대로 했을때 도저히 테스트를 구동할 수 없었고, 많은 삽질끝에 얻은 결론을 정리하려한다.

 

# batch job 작성

@Configuration
class SampleBatch(
        private val jobBuilderFactory: JobBuilderFactory,
        private val stepBuilderFactory: StepBuilderFactory,
) {
    @Bean
    fun sampleJob(): Job {
        return jobBuilderFactory["sampleJob"]
                .incrementer(RunIdIncrementer())
                .start(sampleStep())
                .build()
    }

    @Bean
    fun sampleStep(): Step {
        var i = 0

        return stepBuilderFactory["sampleStep"]
                .chunk<Int, String>(200)
                .reader {
                    i++
                    when(i < 500) {
                        true -> i
                        false -> null
                    }
                }
                .writer { println(" >>> ${it.size}") }
                .build()
    }
}

간단한 batch job 을 하나 만든다. reader 에서 1 ~ 499 까지를 200 단위의 chunk 로 구분해 읽어들이고, writer 에서는 그냥 사이즈만 출력하는 단순한 코드다.

 

# 테스트 작성

먼저 배치 테스트를 작성하기위해서 spring-batch-test 의존성을 추가해준다.

testImplementation("org.springframework.batch:spring-batch-test")

해당 의존성을 추가해주면 @SpringBatchTest 애노테이션을 사용할 수 있게 된다. 테스트코드를 아래와 같이 작성해주자.

@SpringBootTest
@SpringBatchTest
internal class SampleBatchTest @Autowired constructor(
        private val jobLauncherTestUtils: JobLauncherTestUtils
) {

    @Test
    fun batch_test() {
        val jobExecution = jobLauncherTestUtils.launchJob()                 
                                                                    
        assertThat(jobExecution.exitStatus).isEqualTo(ExitStatus.COMPLETED) 
    }
}

@SpringBootTest, @SpringBatchTest 두 개의 애노테이션으로 인해 기본적인 스프링 컨텍스트 설정이 완료되며, JobLauncherTestUtils 빈을 주입받을 수 있게된다. JobLauncherTestUtils 이 배치 잡 테스트에 있어서 핵심 클래스이며, 지금 코드에서 보듯이 lauchJob() 메서드를 호출해주면 잡이 실행된다.

 

지금은 job parameter 를 하나도 받지않는다는 가정하에 작성한 테스트인데, parameter 를 보내야한다면 아래와같이 추가할 수 있다.

@Test                                                  
fun batch_test() {                                     
    val parameters = JobParametersBuilder()            
            .addLong("param", 1L)                      
            .toJobParameters()                         
    val jobExecution = jobLauncherTestUtils.launchJob()                 
                                                                    
    assertThat(jobExecution.exitStatus).isEqualTo(ExitStatus.COMPLETED) 
}

 

# 잡 추가

배치 애플리케이션을 작성한다면 그 애플리케이션에 당연히 잡이 하나만 있지는 않을 것이다. 위에서 소개한 방식은 잡이 하나일땐 잘 돌아가나 잡이 두개 이상이 되면 문제가 발생한다. JobLauncherTestUtils 안에 Job 빈이 주입되게 되는데 잡이 두개 이상이 되면 어떤 Job 빈을 넣을지 모르기때문이다. 사실 내가 찾아본 자료들에선 이 때에 대한 설명을 찾지못해서 고생했던것 때문에 포스팅까지 작성하고있는 것이다.

@SpringBootTest(classes = [SampleBatch::class])
@SpringBatchTest

먼저 특정한 하나의 Job 빈을 만들기위해 위처럼 해당 Job 을 만드는 클래스를 지정해줬다. 하지만 저렇게하면 다른 설정들까지 제외되기때문에 테스트 컨텍스트가 제대로 구동되지 못한다.

@EnableBatchProcessing
@EnableAutoConfiguration
@SpringBootTest(classes = [SampleBatch::class])
@SpringBatchTest

그래서 위와 같이 다른 설정들을 해줄 수 있는 애노테이션들을 추가했다. 이제 잡이 여러개가 되면 @SpringBootTest 에 테스트할 잡만 넣어주면 해당 잡만 주입받아서 테스트를 할 수 있다.

 

# 패키지 구조

내가 만났던 문제중엔 패키지 구조에 대한 문제도 있었다. 요즘엔 그레이들 멀티 모듈 프로젝트가 많다보니 발생한것 같은데 가령 아래와 같은 상태인 것이다.

 

- 배치 애플리케이션 최상위 패키지

com.company.www.batch

- JPA repository 패키지(JPA 는 예시일뿐 꼭 JPA 가 아닐 수 있음)

com.company.www.jpa

 

사실 위와 같은 구조에서 복수의 Job 빈으로 인한 @SpringBootTest 에 특정 파라미터를 넣어주지만 않았으면 문제가 없었을 수 있다. 하지만 안타깝게도 문제가 발생해버렸으니 해결해야한다. 나같은 경우는 @ComponentScan 애노테이션을 활용하고자했다.

// batch 와 jpa 패키지 모두를 포용할 수 있게 그 상위 패키지 지정
@ComponentScan(basePackages = ["com.company.www"])
@EnableBatchProcessing
@EnableAutoConfiguration
@SpringBootTest(classes = [SampleBatch::class])
@SpringBatchTest

이러면 당연하게도(?) 문제가 발생하는데 기껏 @SpringBootTest 의 파라미터를 이용해서 제외했던 Job 들까지 스캔대상이 되어 Job 주입 문제가 다시 발생하게된다.

 

정말 다행히도 @ComponentScan 은 exclude 옵션을 제공하는데 아래와 같이 설정했다.

@ComponentScan(
        basePackages = ["com.company.www"],
        excludeFilters = [
            ComponentScan.Filter(
                    type = FilterType.REGEX, pattern = [".*(Batch).*"]
            )
        ]
)
@EnableBatchProcessing
@EnableAutoConfiguration
@SpringBootTest(classes = [SampleBatch::class])
@SpringBatchTest

우리는 ~Batch 형태로 배치 클래스들을 만들고 있었기 때문에 @ComponentScan 에서 ~Batch 는 제외하도록했다. 이러면 @ComponentScan 은 모든 ~Batch 빈을 제외하고 스캔하게되고, @SpringBootTest 로 인해서 내가 지정한 ~Batch 클래스만 빈 등록이 되어 문제가 해소된다. 조직마다 다르겠지만 보통은 배치 클래스를 만들때 동일한 postfix 를 붙일텐데 이를 이용하면 될것 같다.

 

다만 정말 혹시나해서 이 내용까지 덧붙이는데 배치 클래스를 ~Job 으로 만들면 해당 설정이 예상했던대로 돌지않으니 주의하길 바란다.(아마도 Job 에 대한건 배치 설정 어딘가에서 또 스캔을 하고있는 것 같다)

 

오랜 삽질을 통해 위와 같은 퍼펙트한(?) 설정을 찾아냈고, 배치 테스트 클래스마다 저 애노테이션을 복붙해다닐순 없으니 메타 애노테이션으로 이용하여 추출했다.

@ComponentScan(
        basePackages = ["com.company.www"],
        excludeFilters = [
            ComponentScan.Filter(
                    type = FilterType.REGEX, pattern = [".*(Batch).*"]
            )
        ]
)
@EnableBatchProcessing
@EnableAutoConfiguration
@SpringBatchTest
annotation class BatchTest

배치 테스트를 작성할땐 이제 이렇게 작성하면 된다.

@BatchTest
@SpringBootTest(classes = [SampleBatch::class])

 

# Assertion

일단 테스트 클래스까지 만들었다면 잡이 실제 잡이 돌아가는걸 볼 수 있을 것이다. 다만 위에서 작성한 배치 잡은 너무나도 간단한 애플리케이션이라 특별히 뭘 검증할 건덕지가 없다. exitStatus 를 이용한 어설션을 넣어주긴 했으나, BatchStatus 나 ExitStatus 만 가지고 배치 애플리케이션이 정상이라고 보기는 어렵다. 배치 자체는 잘 돌았는데 로직을 잘못짜서 의도한대로 돌지않는걸 검증할 수는 없기때문이다.

 

보통은 배치가 돌면서 데이터베이스 데이터를 변경하는 잡이 많을텐데 해당 테스트를 반복가능한 테스트로 만들기 위해선 더미 데이터를 넣어주는 코드를 넣는게 좋다.

@Test                                                                 
fun batch_test() {                                                    
    // 더미 데이터 삽입                                                      
                                                                      
    val jobExecution = jobLauncherTestUtils.launchJob()               
                                                                      
    assertThat(jobExecution.status).isEqualTo(BatchStatus.COMPLETED)  
                                                                      
    // Assertion 으로 잡이 제대로 처리됐는지 검증                                   
}

테스트 코드가 돌아갈때 어떤 DB 를 대상으로 할지는 테스트를 작성하는 조직마다 다르겠지만 이번엔 실제 테스트 서버의 DB 를 대상으로 한다고 가정한다. 테스트 서버의 DB 를 대상으로 돌아간다면 삽입한 더미 데이터를 대상으로 로직 검증을 한 후 해당 데이터를 지워줘야한다. 이게 참 골치아픈데 일반 스프링 부트 애플리케이션에서는 @Transactional 애노테이션만 달아주면 걱정할 필요가 없지만 배치는 두 개의 DataSource 를 사용하기때문에 이 기능을 지원하지 않는다. 그래서 더미 데이터를 지우는 코드도 직접 넣어줬다.

@Test                                                                 
fun batch_test() {                                                    
    // 더미 데이터 삽입                                                      
                                                                      
    val jobExecution = jobLauncherTestUtils.launchJob()               
                                                                      
    assertThat(jobExecution.status).isEqualTo(BatchStatus.COMPLETED)  
                                                                      
    // Assertion 으로 잡이 제대로 처리됐는지 검증                                   
    // 더미 데이터 제거                                                      
}

지금이야 주석으로 한줄쓰고 땡쳐서 체감하지어렵지만 위에 주석처리한 내용을 실제 코드로 구현하게 된다면 테스트 코드가 굉장히 보기싫어지는 코드가 된다. 더미 데이터를 넣고 제거하는 코드를 어떤 방식으로 작성하는지도 고민되고, 경우에 따라선 테스트 데이터 삽입을 위한 코드가 프로덕션 코드에 들어가는 경우도 발생하게 된다. 그리고 배치 특성상 대상 데이터를 조회할때 created_date 와 같은 필드를 이용해서 조회하는 경우가 상당히 잦은데 더미 데이터 삽입/제거를 JPA 를 이용해서 한다면 created_date 를 맘대로 조작하기가 어려운 경우도 현업에선 분명히 있을것이다.(JPA Audit 기능을 이용하고있을경우 프레임워크와 강결합으로 인해 맘대로 조작하기가 어려워짐)

 

물론 testable 코드를 작성하기위해 구조를 뜯어고치는 선택을 할 수도 있겠지만 그러면 일이 너무 커진다. 그래서 내가 생각한건 native sql 을 이용하는 것이다.

@Test                                                                                                         
@SqlGroup(                                                                                                    
        Sql(scripts = ["classpath:dummy-insert.sql"], executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
        Sql(scripts = ["classpath:dummy-delete.sql"], executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)  
)                                                                                                             
fun batch_test() {                                                                                            
    val jobExecution = jobLauncherTestUtils.launchJob()                                                       
                                                                                                              
    assertThat(jobExecution.status).isEqualTo(BatchStatus.COMPLETED)                                          
                                                                                                              
    // Assertion 으로 잡이 제대로 처리됐는지 검증                                                                           
}

Sql 애노테이션을 이용해 scripts 필드를 활용하면 sql 을 별도 파일로 분리할 수도 있어 테스트 코드에 sql 이 노출될 일도 없고 테스트하고자하는 검증만 코드에 남겨둘 수 있다. test/resources 아래에 디렉토리별로 잘 구분하면 sql 파일들도 잘 관리할 수 있게 되어 개인적으론 매우 만족스러웠다.

 

# 정리

배치 테스트를 작성하기위한 여정이 너무 고난스러웠어서 정리해놓는다. 차후 다른곳에서 또 다시 처음부터 배치 테스트를 작성하게된다면 위 내용을 완전히 외우고 있을 자신도 없고... 같은 고생을 하고있는 누군가에게 도움이 됐으면 좋겠다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함