6.8 KiB
title, source_type, status, tags, created
| title | source_type | status | tags | created | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Spring broad ComponentScan picks up test inner @Configuration — BeanDefinitionOverrideException + Flyway/JPA init cycle | error-note | raw |
|
2026-06-17 |
Spring broad ComponentScan + test inner @Configuration collision
Parent
raw/project-notes/ca-skeleton-operational-contract
현상 1 — BeanDefinitionOverrideException
SamplePortfolioApplication이 @ComponentScan(basePackages = "dev.caskeleton")을 사용한다. Gradle이 :sample-portfolio:test를 실행할 때 테스트 클래스패스에는 WorkLogRepositoryAdapterIntegrationTest$TestConfig, WorkLogAuthorizationContractTest$AuthzTestConfig 같은 nested inner @Configuration 클래스가 존재한다.
이들 각각이 @Bean Clock clock() 등 이름이 같은 빈을 등록하므로, 전체 컨텍스트(SampleApplicationContextTest)가 부트될 때:
BeanDefinitionOverrideException: Invalid bean definition with name 'clock'
defined in ... WorkLogRepositoryAdapterIntegrationTest$TestConfig:
Cannot register bean definition [... AuthzTestConfig] for bean 'clock':
There is already [... TestConfig] bound.
spring.main.allow-bean-definition-overriding=true로 회피 가능하지만, 이는 마지막 등록 빈이 이기므로 의도치 않은 설정 오염이 일어남(금지된 접근법).
현상 2 — Flyway ↔ JPA entityManagerFactory 초기화 순환
PostgreSqlPersistenceConfig(adapter-persistence-postgresql)는 @PersistenceContext EntityManager entityManager 필드와 @Bean FlywayConfigurationCustomizer 메서드를 동시에 가진다.
Spring Boot Flyway auto-configuration이 FlywayConfigurationCustomizer 빈을 수집할 때 PostgreSqlPersistenceConfig 인스턴스를 생성 → PersistenceAnnotationBeanPostProcessor가 @PersistenceContext를 처리하려고 entityManagerFactory를 요청 → entityManagerFactory는 flywayInitializer 완료를 기다림 → 순환:
flyway → collect customizers → instantiate PostgreSqlPersistenceConfig
→ @PersistenceContext → entityManagerFactory → flywayInitializer → flyway
spring.main.allow-circular-references=true(SampleApplicationContextTest에 이미 적용)가 임시 완화했지만 실질 순환은 남아 있음.
해결 1 — TestEnclosedConfigurationFilter
TypeFilter 구현. 클래스 binary name에 $가 있고 enclosing class 이름이 Test로 끝나면 match() = true (→ 컴포넌트 스캔에서 제외).
@ComponentScan(
basePackages = "dev.caskeleton",
excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TestEnclosedConfigurationFilter.class)
})
구현:
public class TestEnclosedConfigurationFilter implements TypeFilter {
@Override
public boolean match(MetadataReader reader, MetadataReaderFactory factory) {
String name = reader.getClassMetadata().getClassName();
int dollar = name.lastIndexOf('$');
if (dollar < 0) return false;
String enclosing = name.substring(0, dollar);
String simple = enclosing.substring(enclosing.lastIndexOf('.') + 1);
return simple.endsWith("Test");
}
}
프로덕션 소스에 테스트 프레임워크 의존 없음 — TypeFilter는 Spring Core의 org.springframework.core.type.filter 패키지.
해결 2 — SamplePostgreSqlPersistenceConfig의 static @Bean
SamplePostgreSqlPersistenceConfig를 신규 작성하고 PostgreSqlPersistenceConfig를 컴포넌트 스캔에서 제외(FilterType.ASSIGNABLE_TYPE).
핵심: FlywayConfigurationCustomizer 등록을 static @Bean으로 선언.
@Configuration(proxyBeanMethods = false)
@Import(PersistenceJpaConfig.class)
public class SamplePostgreSqlPersistenceConfig {
@PersistenceContext
private EntityManager entityManager;
// static: Spring이 owning class 인스턴스 없이 이 메서드를 호출
// → @PersistenceContext 필드 주입이 Flyway init 시점에 발생하지 않음
@Bean
public static FlywayConfigurationCustomizer postgreSqlFlywayLocationCustomizer() {
return cfg -> cfg.locations("classpath:db/migration/postgresql");
}
@Bean
public OutboxClaimRepository outboxClaimRepository() {
return new PostgreSqlOutboxClaimRepository(entityManager);
}
@Bean
public SqlStateErrorMapping postgreSqlSqlStateErrorMapping() {
return new PostgreSqlSqlStateErrorMapping();
}
}
Spring Framework 계약: static @Bean 메서드는 소유 @Configuration 클래스가 인스턴스화되기 전에 호출 가능 → BeanPostProcessor가 필드 주입을 수행할 기회가 없음 → Flyway 순환 차단.
해결 3 — EnvironmentPostProcessor safe no-op 설계
SampleTracingSamplingEnvironmentPostProcessor가 META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports에 등록되어 모든 컨텍스트에 누출됨. 좁은 슬라이스 테스트(@SpringBootTest(classes=LocalConfig.class))가 EPP가 내부에서 요청하는 빈을 갖지 않으면 컨텍스트 init 실패.
해결: EPP는 ConfigurableEnvironment만 사용하도록 설계 — Spring 빈 의존 없음. 프로필과 프로퍼티만 읽고 MapPropertySource만 추가. 따라서 어떤 컨텍스트에서도 안전한 no-op 수행 가능.
정리 (Lessons)
- 광범위 ComponentScan(
basePackages= 최상위 패키지)은 테스트 클래스패스의 inner @Configuration을 잡아 bean name 충돌을 일으킨다. TypeFilter 기반 제외 필터가 해결책. @PersistenceContext+FlywayConfigurationCustomizer @Bean을 같은 @Configuration에 두면 Flyway→JPA 순환이 발생한다. static @Bean으로 Flyway customizer 분리.- EnvironmentPostProcessor는 모든 ApplicationContext에 주입된다. Spring 빈에 의존하지 않는 순수 Environment 조작만 EPP 책임으로 둬야 한다.
spring.main.allow-bean-definition-overriding=true는 임시 방편이다. 실질 중복을 제거해야 한다.
재현 환경
- Spring Boot 3.5.x, Java 21, Gradle 9.0
:sample-portfolio:test— 106 run / 94 passed / 12 failed (회귀 발생 시점)- 해결 후: 136 tests / 0 failures / 0 errors
Evidence
actually-implemented: TestEnclosedConfigurationFilter, SamplePostgreSqlPersistenceConfig, SampleTracingSamplingEnvironmentPostProcessor safe no-op, SamplePseudonymizationConfig @ConditionalOnMissingBean, 및 프로덕션PostgreSqlPersistenceConfig내postgreSqlFlywayLocationCustomizer()의 static @Bean화 적용.locally-verified::sample-portfolio:test --rerun-tasks136/0/0,:app-bootstrap:test444/0, 전체./gradlew test성공, 애플리케이션 시작 시 Flyway ↔ JPA 순환 해결 완료.