들어가며
@TestConfiguration 내부에서 Testcontainers와 @DynamicPropertySource를 사용해 테스트 환경을 구성했으나, @DynamicPropertySource가 의도대로 동작하지 않아 문제가 발생했다. 이 글은 해당 문제의 원인과 해결 방법을 정리한 내용이다.
문제가 발생한 테스트 환경
테스트환경에서는 TestContainers 를 이용해 Redis 를 사용하고 있다. RedisConnectionFactory 설정부분은 다음과 같다.
애플리케이션 코드의 RedisConfig 에서 생성자로 RedisProperties 를 주입받고 있고, 이 값을 바탕으로 Redis 서버와의 연결을 시도한다.
package org.springframework.boot.autoconfigure.data.redis;
@ConfigurationProperties(prefix="spring.data.redis")
public class RedisProperties {
private String host = "localhost";
...
private int port = 6379;
}
RedisProperties 는 위와 같이 구성되어 있으며 Spring Boot의 AutoConfiguration 에 의해 등록된다.
따라서 spring-boot-starter-data-redis 의존성만 추가해주면 RedisConnectionFactory, RedisTemplate, StringRedisTemplate 등의 Bean이 자동 등록되고, 별다른 설정을 하지 않는다면 localhost 와 6379 번 포트로 연결된다.
다음으로 TestContainer 를 이용해 Redis 에 접속하는 부분을 @TestConfiguration 으로 정의했다.
registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT));
여기서 @DynamicPropertySource 를 이용해 spring.data.redis.port 에 해당하는 값을 TestContainers 가 할당해주는 랜덤포트 값으로 변경시켜주었다. (아마 여기까지 보고 문제의 원인을 파악하신 분들도 있을 것이다)
마지막으로 테스트 클래스에서 @Import 를 이용해 위에서 만든 (TestContainers 설정이 있는) RedisTestConfig 를 Import 해,
테스트 클래스에서 Redis 가 실행되도록 구성했다.
위처럼 구성한 이유는 아래와 같다.
- TestRedisConfig를 여러 테스트 클래스에서 @Import 로 등록하더라도, static 블록은 클래스 로딩 시 한 번만 실행되므로 컨테이너가 중복 생성되지 않는다. 따라서 테스트 클래스가 끝나더라도 REDIS_CONTAINER는 JVM이 유지되는 한 살아있기 때문에 컨테이너가 내려갔다 올라가는 시간이 추가로 발생하지 않는다.
- @TestConfiguration은 @Configuration 과 달리 컴포넌트 스캔 대상이 아니다. @Import 로 등록하던가 테스트 클래스 내부에서 static inner class 로 정의해야 한다. Redis 를 사용하는 테스트들에서 재사용할 필요가 있어서 별도 클래스로 분리했다.
문제 상황
위에서처럼 테스트환경을 구성하고 돌려보면 호스트의 6379 번 포트로 접속을 시도한다.
TestContainers 의 hostPort 는 63341 번으로 매핑이 되어 실행되고 있기 때문에 접속을 할 수 없어 위처럼 에러로그가 출력된다.
이전에도 TestContainers 를 이용해 통합테스트 환경을 구성한 적이 있었다. 그 당시에는 IntegrationTest 라는 abstract 클래스를 만들어놓고 통합테스트 클래스들에서 상속받아 동작하도록 구성했고, 정상적으로 동작했다. 그러면 지금 상황에서 동작하지 않는 이유는 무엇일까?
디버그를 돌려보니
@TestConfiguration 클래스가 JVM에 로드되면서 static 블록이 실행된다. (<clinit> ) 이후 Spring 컨텍스트가 초기화되며 @Configuration 에 의해 빈이 등록되면서 redisConnectionFactory 메서드가 실행되고 host, port 가 매핑된다.
@DynamicPropertySource 가 붙은 configureProperties 메서드는 실행조차 되지 않았다.
원인 및 해결 방법
필자가 간과한 것이 있는데, @Configuration, @TestConfiguration 은 사용될 빈을 등록하기 위해 사용된다. @DynamicPropertySource 는 빈 등록을 위한 것이 아니기 때문에 (아래에서 나오겠지만, 그보다 더 일찍 처리된다.) @Configuration이나 @TestConfiguration 내부에 있다고 해서 즉, @Import(TestRedisConfig.class) 만 한다고 해서 내부의 @DynamicPropertySource가 호출되지 않는다. 따라서 목적에 맞지 않게 사용한 것이다. 그렇다면 올바르게 사용하려면 어떤식으로 해야 할까?
먼저 @DynamicPropertySource 가 어떻게 동작하는지 확인해보았다.
실제로 확인해보기 위해 테스트클래스에 @DynamicPropertySource 가 붙은 메서드에 브레이크포인트를 걸고 디버깅을 진행했다. 디버거는 코드를 중단점으로 안내한 실행경로를 역순으로 표시하므로, 보기 편하게 시작지점부터 나열해 보았다.
SpringApplication 의 run 이 실행되고, prepareContext 를 호출하는 부분부터 시작한다.
여기서 createApplicationContext() 로 만든 context 를 넣어 applyInitializers 메서드를 호출하게 되고
getInitializers() 로 ApplicationContextInitializer 를 구현한 클래스들을 가져와 initialize 를 하나씩 호출한다.
이중 ContextCustomizerAdapter 클래스의 initalize 가 호출되면 ContextCustomizer 를 구현한 클래스의 customizeContext 메서들을 호출한다.
DynamicPropertiesContextCustomizer 의 customizeContext 가 호출된 것을 확인할 수 있다. methods 변수에는 테스트 클래스에서 찾은 @DynamicPropertySource가 붙은 static 메서드가 저장되어 있다. (즉, configureProperties 메서드) Spring의 Environment는 여러 개의 PropertySource( application.yml, system properties 등)를 계층적으로 가지고 있다. 여기서는 그 중 MutablePropertySources를 가져와 추가해주는 구성인데 addFirst(...) 를 이용해 가장 우선순위가 높은 위치에 새 PropertySource를 삽입한다. 여기서는 DynamicValuesPropertySource 가 삽입되는데 두번째 인자인 Map<String, Supplier<Object>> 에 buildDynamicPropertisMap() 메서드의 호출결과를 넘겨준다.
buildDynamicPropertiesMap() 메서드에서는 결과를 담을 map 을 생성한 후 DynamicPropertyRegistry 를 람다 표현식으로 정의해, ReflectionUtils 의 invokeMethod 의 두 번째 파라미터인 args 에 넣어서 호출한다. (target이 null인 이유는, 호출하려는 @DynamicPropertySource 메서드가 static이기 때문이다.)
method.invoke(target,args) 는 Java Reflection API 의 메서드로 Method 객체를 런타임에 동적으로 호출한다. (여기서는 configureProperties 가 호출) args 에는 위에서 구성한 dynamicPropertyRegistry 가 담긴다.
configureProperties 메서드에서 registry.add(...)가 실행되면, 위에서 만든 map (Map<String, Supplier<Object>>)에 키-값 쌍이 등록된다.
따라서 "spring.data.redis.port" 가 name 으로 등록되고 valueSupplier 부분에 들어갈 람다표현식이 () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT) 에 해당한다. 결론적으로 동적 프로퍼티가 Environment에 반영되므로, 테스트 시점에서 @Value, @ConfigurationProperties 등으로 주입이 가능해진다.
prepareContext()는 아직 빈들이 생성되기 전에, 테스트 설정이나 프로파일 적용, 초기 설정자, 커스터마이저들(ApplicationContextInitializer 등) 을 컨텍스트에 반영하는 사전작업을 하는 단계다. @TestConfiguration 이나 @Configuration 클래스는 prepareContext()에서는 아직 등록되지 않고, refreshContext() 호출 이후에 BeanDefinition으로 등록되며, 그 안의 @Bean 메서드들이 처리되기 시작한다.
@Configuration 이 붙은 redisConnectionFactory 빈은 prepareContext() 에서 프로퍼티 설정이 끝난 이후에 실행되는 것을 확인할 수있다.
따라서, 올바르게 등록하려면 @SpringBootTest 의 ApplicationContext 의 prepareContext() 부분에서 설정한 변수값이 들어가야 하기 때문에 @DynamicPropertySource 붙은 메서드는 는 테스트 클래스 혹은 상위 클래스에 정의되어야 prepareContext() 에서 값을 정상적으로 설정할 수 있다.
과정을 요약해보면 아래와 같다.
- 테스트 클래스에서 @DynamicPropertySource 존재 확인
- DynamicPropertiesContextCustomizer가 해당 메서드를 수집
- 수집된 메서드는 DynamicPropertiesContextCustomizer에 담김 (Set<Method>)
- ContextCustomizerAdapter로 감싸짐 ( ApplicationContextInitializer<ConfigurableApplicationContext> 로 등록되기 위해)
- ApplicationContext 초기화 준비 (loadContext())
- applyInitializers()에서 ContextCustomizerAdapter.initialize() 호출
- 메서드 호출 시 DynamicPropertyRegistry를 통해 테스트 설정값이 Environment에 실제로 등록됨
- 이후 refresh() 호출되며 실제 빈 생성 및 테스트 실행 시작
@TestConfiguration 내에서 TestContainers 설정 방법
그렇다면 @TestConfiguration 내에서 TestContainers 설정을 할 수 있는 방법은 없을까?
static {
REDIS_CONTAINER = new GenericContainer<>(REDIS_DOCKER_IMAGE_TAG)
.withExposedPorts(REDIS_PORT);
REDIS_CONTAINER.start();
System.setProperty("spring.data.redis.port", REDIS_CONTAINER.getMappedPort(REDIS_PORT)
.toString());
}
static 블록을 활용해서 설정할 수 있다. static 블록은 JVM 클래스 로딩 시점에 한 번만 실행되고, 스프링 컨텍스트나 refresh()와는 무관하게 클래스가 처음 로딩되는 순간에 바로 수행되므로 가장 먼저 실행되게 된다. REDIS_CONTAINER.start() 호출 아래에서 getMappedPort 가져와서 설정해주면 된다.
References
- https://docs.spring.io/spring-framework/reference/testing/annotations/integration-spring/annotation-dynamicpropertysource.html
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/DynamicPropertySource.html
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/ReflectionUtils.html
- https://docs.oracle.com/javase/8/docs/api/java/lang/System.html#setProperties-java.util.Properties-
'Spring' 카테고리의 다른 글
[Spring] API 응답에서 직접 정의한 Error code 는 왜 사용할까? (0) | 2025.03.16 |
---|---|
[Spring] OpenApI 3.0 Swagger 문서 작성 및 설정 방법 (0) | 2025.02.11 |
[SpringBoot] MapStruct 제대로 활용하기 (0) | 2024.11.03 |
[SpringBoot] @DataJpaTest 테스트 클래스 간 데이터 충돌 문제 해결 (0) | 2024.10.30 |
[SpringBoot] 데이터베이스 마이그레이션 툴 Flyway 도입기 (0) | 2024.09.28 |