Spring Batch 프로젝트 테스트코드 최적화 방안
배경
- 12월 4일 이후, Spring Batch 프로젝트의 테스트코드 양이 증가하며, OOM 문제로 인해, 테스트가 50분이 넘도록 돌아가지 않고 실패했습니다. 불과 100개도 안되는 테스트 였음에도 말이죠!! 무언가 이상했습니다. webapp은 300개가 넘는 테스트가 돌고 있음에도, 동일한 Heap Memory 스펙 하에서, 더 빠른 시간 안에 처리가 됐기 때문입니다.
- 뿐 만 아니라, 테스트가 중간에 끝났음에도 Heap Memory Size는 줄어들지 않고 있었습니다. 이로써, Batch 쪽 테스트코드 로직에 문제가 있음을 직감할 수 있었습니다.
문제
주요 이슈 요약
- Application Context 캐싱이 제대로 되지 않아 테스트 성능 저하
- 불필요한 의존성 주입으로 전체 컨텍스트를 로드하는 비효율적인 테스트 구성
- 중복 어노테이션 사용으로 인한 리소스 낭비
- Mock 객체 전략 부재로 Context 캐싱 방해
- Tasklet, Repository 단위 테스트에서 과도한 설정 사용
위와 같은 이유로 Spring 통합 테스트에서 성능 저하와 메모리 사용량 증가 문제가 발생하였습니다. 문제에 대한 자세한 설명은 아래 적어두었습니다.
문제 1) 무분별한 @MockkBean 사용으로 인한 Application Context 메모리 사용량 문제
Spring의 통합 테스트에서는 Application Context를 로드하는 과정이 필수적이며, 이는 많은 메모리를 차지합니다. 특히, Spring Context Caching이 제대로 활용되지 않을 경우, 테스트의 성능 저하와 메모리 누수 현상이 발생할 수 있습니다.
- Spring TestContext Framework는 Context 구분 키를 기준으로 ApplicationContext를 재사용하거나 새로 생성합니다.
- 설정 요소의 미세한 차이(예: @ContextConfiguration, @ActiveProfiles, @TestPropertySource 등)가 Context 캐싱을 방해하며, 불필요한 재생성으로 이어집니다.
- 캐시 제한(32개) 및 LRU Eviction 정책으로 인해 캐시를 초과하면 Context가 반복적으로 로드되기 때문입니다.
- 실제로 테스트가 중간 중간 끝났음에도, Heap Memory Size는 줄어들지 않고 계속 증가하는 추세를 보였습니다.
문제 2) 불필요한 의존성 주입으로 인한 메모리 사용량 증가
통합 테스트 시 불필요한 의존성을 포함한 전체 ApplicationContext를 로드하면 성능이 저하됩니다.
- 예를 들어, @SpringBootTest를 사용하면 모든 빈이 초기화되며, 필요하지 않은 빈도 함께 주입됩니다.
- 배치 테스트의 경우 Spring Batch 인프라(JobLauncher, JobRepository 등)를 불필요하게 로드하는 @SpringBatchTest가 남용됩니다.
문제 3) 불필요한 어노테이션 사용으로 인한 리소스 낭비
기존 테스트 환경에서는 @EnableAutoConfiguration, @ComponentScan 등 중복된 어노테이션이 선언되었습니다.
- @SpringBootTest 자체가 이미 자동 설정과 스캔을 지원하므로, 불필요한 어노테이션 선언은 메모리 낭비를 유발합니다.
- 단순한 단위 테스트에도 불필요하게 Spring Context가 로드되는 경우가 많았습니다.
문제 4) Mocking 전략 부재
테스트에서 @MockBean 또는 @MockKBean을 사용하면 Spring Context에 Mock 객체가 빈으로 등록되기 때문에 Context 캐싱이 어렵다.
- 단순한 단위 테스트에서 Context 로드가 불필요하지만, 편의성 때문에 자주 사용되었다.
문제 5) Repository 및 Tasklet 단위 테스트 전략 미흡
- JPA Repository 테스트: @DataJpaTest 대신 @SpringBootTest를 사용하면 전체 ApplicationContext가 로드되어 성능이 저하됩니다.
- Tasklet 단위 테스트: 기존에는 단일 Tasklet만 테스트할 경우에도 @SpringBatchTest를 사용하여, 불필요한 의존성이 포함되었습니다.
개선 요약
1. @MockkBean 사용을 지양하자
- Redis, 외부 API 호출 등 Mocking이 아닌, 실제 동작을 테스트하기 위해서는 통합 테스트가 필수입니다. 그렇지만 성능 관점에서 통합 테스트 시에 유의해야 할 점이 있습니다. Application Context는 굉장히 많은 메모리를 잡아 먹습니다.
- Spring의 TestContext Framework는 테스트를 실행할 때 ApplicationContext를 재사용하거나 새로 생성할지 결정합니다. 이를 판단하는 기준이 바로 "Context를 구분하는 고유한 키"입니다. 이 키는 특정한 설정 요소들의 조합에 따라 달라집니다. 쉽게 말해, 아래 설정이 조금이라도 다르면 다른 Context로 간주하고 새로 생성합니다. 메모리 성능을 개선하려면 아래 조합이 최대한 비슷하게 동작해야 합니다.
- Context Cache의 크기는 기본적으로 최대 크기인 32로 제한됩니다. 최대 크기에 도달할 때마다 가장 최근에 사용된(LRU) Eviction 정책이 사용되어 오래된 컨텍스트를 Evict 하고 닫습니다. 이 과정에서 캐시 범위를 넘어서면서, 테스트가 끝났음에도 메모리 사용량이 줄어들지 않은 것으로 확인됩니다.
Context 구분하는 키 목록
- locations (from @ContextConfiguration) : XML 파일로 설정한 스프링 설정 파일의 경로입니다. 설정 파일 경로가 다르면 다른 Context로 간주합니다.
- classes (from @ContextConfiguration) : 자바 기반 설정 클래스 (@Configuration)를 지정한 경우입니다. 설정 클래스가 달라지면 Context도 달라집니다.
- contextInitializerClasses (from @ContextConfiguration) : ApplicationContextInitializer를 사용하는 경우 지정된 초기화 클래스가 다르면 Context가 새로 생성됩니다.
- contextCustomizers (from ContextCustomizerFactory) : 테스트 시 동적으로 속성을 추가하는 요소입니다. 예: @DynamicPropertySource 메서드, 또는 Spring Boot의 @MockBean, @SpyBean 같은 기능이 포함됩니다. 이 내용이 다르면 Context가 달라집니다.
- contextLoader (from @ContextConfiguration) : Context를 로드하는 방식에 따라 달라집니다. 예를 들어, AnnotationConfigContextLoader와 같은 로더가 달라지면 다른 Context로 간주합니다.
- parent (from @ContextHierarchy) : 부모 컨텍스트 설정입니다. 부모 컨텍스트가 다르면 별도의 Context로 구분됩니다.
- activeProfiles (from @ActiveProfiles) : 활성화된 프로파일 (e.g., dev, test, prod)이 다르면 Context가 달라집니다.
- propertySourceDescriptors (from @TestPropertySource) : 테스트 중 추가된 프로퍼티 파일 경로입니다. 추가된 파일이 다르면 Context도 달라집니다.
- propertySourceProperties (from @TestPropertySource) : 프로퍼티 소스에 직접 정의된 속성값이 달라지면 별도의 Context가 생성됩니다.
- resourceBasePath (from @WebAppConfiguration) : 웹 애플리케이션의 기본 경로입니다. 기본 경로가 다르면 Context가 달라집니다.
- 이 중에서도 @MockKBean 은 Spring의 @MockBean과 유사하게 Spring Context에 Mock 객체를 빈으로 등록합니다. 컨텍스트가 필요 없이 단순히 단위 테스트를 할 때도 컨텍스트가 로드되어 시간이 더 소요되게 됩니다. 기존에는 이러한 @MockKBean 사용이 많았습니다. 왜냐면 사용하기 편리하기 때문이죠. 그렇지만 이로 인해, Application Context Caching을 하기 어려운 구조였습니다.
- 하지만 @MockKBean 대신, mockk() 을 사용하면 동일한 동작을 수행하면서도 컨텍스트와 무관하게 단순 Mock 객체를 만들 수 있어 테스트 범위가 더욱 명확하고 독립적이며, 결과적으로 Spring Context Caching 하기 쉬워집니다.
mockk란?
MockK 라이브러리에서 제공하는 Mock 객체 생성 함수입니다. MockK는 Kotlin에서 사용되는 강력하고 유연한 mocking 라이브러리이며, mockk()는 테스트 코드에서 객체의 동작을 가짜(Mock)로 대체할 때 사용됩니다. 스프링 컨텍스트와 무관한 순수 코틀린 객체이기 때문에, @SpringBootTest 없이도 어디서든 사용 가능합니다.
2. 필요한 의존성만 주입 받자.
불필요한 Integration 테스트를 지양하자.
아래와 같이, Redis 주입을 위해서 @SpringBootTest를 사용해야 할 경우, RedisServiceTestConfig 사용하여 처리하는 것이 좋습니다. 이를 통해, 전체 application context를 메모리에 올리지 않고 RedisService 만 @Autowired 할 수 있기 때문입니다.
@ActiveProfiles("test")
@SpringJUnitConfig(classes = \[RedisServiceTestConfig::class\])
class BoardContentsLastCreatedAtTaskletTest(
@Autowired
private val redisService: RedisService,
) : BehaviorSpec() {
....
}
불필요한 어노테이션은 사용하지 말자.
기존에는 아래와 같이, 여러 어노테이션들을 함께 BaseClass에 설정해두었습니다. 이와 같이, 여러 어노테이션을 한 번에 사용하는 경우도 메모리 사용과 성능에 영향을 줄 수 있습니다. @EnableAutoConfiguration (Spring Boot의 자동 설정을 활성화하는 어노테이션), @ComponentScan (해당 디렉토리의 컴포넌트를 스캔하는 어노테이션) 등은 @SpringBootTest에서 이미 제공하는 기능이기 때문에, 불필요하게 선언 됐었습니다.
@SpringBatchTest
@SpringBootTest(classes = \[TestDataSourceConfig::class, TestRedisEmbeddedConfig::class\])
@EnableAutoConfiguration
@ComponentScan(basePackages = \["com.weolbu.\*"\])
@EntityScan("com.weolbu.\*")
@ActiveProfiles("test")
class BatchJobBaseBehaviorSpec : BehaviorSpec({
...
})
하지만 아래와 같이 잘 개선됐습니다. (이 외에도 다른 부분들도 마찬가지로 개선 되었음)
@SpringBatchTest
@SpringBootTest(classes = \[WeolbuTestApplication::class\])
@ActiveProfiles("test")
class BatchJobBaseBehaviorSpec : BehaviorSpec({
...
})
@SpringJUnitConfig 역시 마찬가지입니다. @SpringBootTest와 @SpringJUnitConfig를 사용 할 필요가 없습니다. 기본적으로 @SpringJUnitConfig는 다음 두 가지를 조합한 단축 어노테이션이기 때문입니다.
@ExtendWith(SpringExtension.class) : JUnit 5에서 Spring의 테스트 컨텍스트를 통합하기 위해 사용하는 확장 기능입니다. Spring의 ApplicationContext를 로드하고 관리합니다.
@ContextConfiguration : 테스트에서 사용할 Spring 컨텍스트의 설정 정보를 지정합니다. XML 설정 파일, 자바 기반 설정 클래스, 또는 설정 정보를 직접 제공할 수 있습니다.
꼭 필요한 경우에만 @SpringBatchTest를 사용하자.
성능 상에 가장 드라마틱한 영향을 준 부분이었습니다. 실제로 해당 작업을 했을 때, 이전에 비해, 가장 많은 메모리 감축 효과가 있었습니다. 기존에는 Step, Reader/Processor/Writer를 테스트 함에 있어, 모든 경우에 @SpringBatchTest를 사용하였습니다. 이를 각각 필요한 의존성만 주입 받도록 개선하였습니다. Kotest를 위한 BehaviorSpec() 만을 공통으로 주입 받도록 변경하였습니다.
@SpringBatchTest가 메모리를 많이 잡아먹는 이유
1. 인메모리 데이터베이스 사용 : Spring Batch 메타데이터 테이블(JobInstance, JobExecution 등)을 포함한 데이터베이스 구조를 매번 메모리 내에 생성. 테스트 데이터가 많아질수록 메모리 사용량 증가.
2. 유틸리티 빈과 컨텍스트 로드 : JobLauncherTestUtils, JobRepositoryTestUtils, StepScope와 JobScope 관련 빈 등이 추가로 로드됨. 이는 일반적인 @SpringBootTest보다 많은 빈을 생성하기 때문에 메모리를 더 사용.
3. 전체 컨텍스트 초기화 : @SpringBatchTest는 Spring Batch와 관련된 모든 컨텍스트를 초기화하고, 인메모리 데이터베이스와 연동. 매 테스트마다 전체 컨텍스트를 다시 로드하면 속도와 메모리 소비에 부담.
4. 잡(Job) 실행 중 데이터 캐싱 : 배치 잡의 실행 도중 처리 중간 상태, JobExecution, StepExecution과 같은 메타데이터가 저장. 처리하는 데이터양이 많으면 이 부분에서도 메모리 사용량 증가.
5. SpringBootTest의 기본 특성 : @SpringBootTest 자체가 애플리케이션의 전체 컨텍스트를 로드하기 때문에 메모리 사용량이 기본적으로 큼.
특히 Repository 만 사용하는 테스트의 경우, @DataJpaTest를 통해, 아래와 같은 성능 개선 효과가 있었습니다.
@DataJpaTest로 변경했을 때의 이점
1. Context 로딩 감소
@SpringBootTest는 애플리케이션 컨텍스트 전체를 로드하며, 모든 빈, 설정, 의존성을 포함하므로 메모리 사용량이 많아질 수 있습니다.
@SpringBatchTest는 Spring Batch 인프라까지 로드하며, Job Repository, Job Launcher와 같은 배치 관련 빈도 포함합니다.
2. 집중된 테스트
@DataJpaTest는 JPA 레포지토리와 관련 컴포넌트를 테스트하기 위해 설계되었습니다.
EntityManager, DataSource, JPA 레포지토리 등 JPA 테스트에 필요한 빈만 로드하므로, 더 작은 애플리케이션 컨텍스트를 구성해 메모리 사용량을 줄입니다.
3. 불필요한 빈 제외
@DataJpaTest는 자동으로 인메모리 데이터베이스를 구성하며, JPA와 관련 없는 다른 빈은 제외합니다.
JPA 컴포넌트로 범위를 제한하므로, 테스트 실행 속도가 빨라지고 메모리 사용량이 줄어듭니다.
특히 애플리케이션 규모가 큰 경우, 전체 애플리케이션 컨텍스트를 로드하는 것보다 효율적입니다.
가능하다면 테스트 환경 구축을 위한 의존성 범위를 축소하자.
Job이 단일 Tasklet로 구성된 경우, Job 테스트를 할 필요가 없습니다. Job을 테스트하려면, @SpringBatchTest 테스트를 선언해야 하며, 이로 인해 위에 적은 것처럼 여러 의존성들이 추가로 주입 됩니다. 대부분의 배치 작업(Job)이 단일 Tasklet로 구성되어 있기 때문에, Tasklet 단위로 테스트를 구성하는 것이 좋습니다.
이를 통해 얻을 수 있는 장점은 @SpringBatchTest 없이 BaseIntegrationBehaviorSpec or BehaviorSpec 만으로 테스트 환경을 구성할 수 있다는 점입니다. 이를 통해, 조금 더 빠르게 동작하는 유닛테스트를 작성할 수 있기 때문입니다.
최종 결과
이번 성능 개선을 통해 힙 메모리를 1GB로 늘리지 않더라도, 아래와 같이, 상대적으로 작은 메모리 사용 만으로 동작할 수 있는 구조로 변경하였습니다.
- Size : 536870944 (byte) 기존 대비 34% 감소, 처음보다 50% 감소
- Used : 415108616 (byte) 기존 대비 32% 감소, 처음보다 45% 감소
결론
앞으로 저희 팀에서는 더욱 많은 테스트코드를 작성해야 할 것 입니다. 이 과정에서, 로직의 통합테스트를 작성할 것이냐, Mocking을 활용한 유닛 테스트를 작성할 것이냐, 또 둘을 혼합하여 작성할 것이냐 등 다양한 고민들을 하게 될 것입니다. 이 과정에서 이번 개선안이 팀에 조금이라도 도움이 되었으면 좋겠습니다.