--- 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 순환 해결 완료.