Files
llm-wiki/raw/errors/spring-componentcan-broad-scan-test-inner-config-collision-2026-06-17.md

131 lines
6.8 KiB
Markdown

---
title: "Spring broad ComponentScan picks up test inner @Configuration — BeanDefinitionOverrideException + Flyway/JPA init cycle"
source_type: error-note
status: raw
tags: [spring-boot, component-scan, bean-definition-override, flyway, jpa, testcontainers, sample-portfolio, TDD]
created: 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 (→ 컴포넌트 스캔에서 제외).
```java
@ComponentScan(
basePackages = "dev.caskeleton",
excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TestEnclosedConfigurationFilter.class)
})
```
구현:
```java
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`으로 선언.
```java
@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)
1. **광범위 ComponentScan(`basePackages` = 최상위 패키지)은 테스트 클래스패스의 inner @Configuration을 잡아 bean name 충돌을 일으킨다.** TypeFilter 기반 제외 필터가 해결책.
2. **`@PersistenceContext` + `FlywayConfigurationCustomizer @Bean`을 같은 @Configuration에 두면 Flyway→JPA 순환이 발생한다.** static @Bean으로 Flyway customizer 분리.
3. **EnvironmentPostProcessor는 모든 ApplicationContext에 주입된다.** Spring 빈에 의존하지 않는 순수 Environment 조작만 EPP 책임으로 둬야 한다.
4. **`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-tasks` 136/0/0, `:app-bootstrap:test` 444/0, 전체 `./gradlew test` 성공, 애플리케이션 시작 시 Flyway ↔ JPA 순환 해결 완료.