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

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
spring-boot
component-scan
bean-definition-override
flyway
jpa
testcontainers
sample-portfolio
TDD
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를 요청 → entityManagerFactoryflywayInitializer 완료를 기다림 → 순환:

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 설계

SampleTracingSamplingEnvironmentPostProcessorMETA-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, 및 프로덕션 PostgreSqlPersistenceConfigpostgreSqlFlywayLocationCustomizer()의 static @Bean화 적용.
  • locally-verified: :sample-portfolio:test --rerun-tasks 136/0/0, :app-bootstrap:test 444/0, 전체 ./gradlew test 성공, 애플리케이션 시작 시 Flyway ↔ JPA 순환 해결 완료.