namalzi

← 글 목록

Spring 소스 파헤치기 9: @Conditional, @Profile, @Import — 조건에 따라 빈을 고르는 법

Spring · 게시

조건이 맞을 때만 빈을 등록하는 @Conditional과 @Profile, 다른 설정을 가져오는 @Import가 컨테이너 안에서 언제, 어떻게 평가되는지를 Spring을 처음 보는 사람 기준으로 따라갑니다. 조건이 세 번 평가되는 이유, @Import의 세 갈래, DeferredImportSelector가 "미루는" 이유, 순환 import를 잡는 ImportStack, 그리고 @EnableXxx가 전부 @Import의 응용인 까닭을 실제 소스와 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 아홉 번째 글입니다. 8편에서는 @Configuration 클래스와 @ComponentScan이 ConfigurationClassParser를 거쳐 빈의 설계도(BeanDefinition)가 되는 과정을 봤습니다. 그 과정에는 "이 설정은 조건에 따라 쓰거나 말거나 한다"와 "이 설정은 다른 설정을 가져온다"는 두 가지 일이 끼어 있습니다. 이번 글은 그 둘을 엽니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 Spring Framework 7.1.0-SNAPSHOT
커밋 f447f3c310 (2026-10-01, main 브랜치)
주로 읽는 모듈 spring-context (context.annotation 패키지), 조금의 spring-core와 spring-tx
주로 읽는 클래스 ConditionEvaluator, ConfigurationClassParser, ConfigurationClassBeanDefinitionReader, ProfileCondition

글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.

원래 공부 노트는 줄 단위로 훨씬 깊이 들어가지만, 이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.

먼저 알아 둘 말

설정 클래스는 @Configuration이 붙은 클래스처럼 "무엇을 빈으로 만들지"를 적어 둔 클래스입니다. 컨테이너는 설정 클래스를 파싱(읽어서 해석)해 빈의 설계도(BeanDefinition)를 만들고, 나중에 그 설계도로 객체를 만듭니다. 설계도는 3편, 파싱은 8편에 있습니다.

메타데이터(AnnotationMetadata, MethodMetadata)는 클래스나 메서드에 "어떤 어노테이션이 어떤 값으로 붙어 있는가"를 담은 객체입니다. 컨테이너는 클래스를 로딩하지 않고도 이 메타데이터로 어노테이션을 읽습니다. 이 글의 조건 평가는 전부 메타데이터를 보고 이뤄집니다.

조건(Condition)은 "이 빈을 등록해도 되는가"에 true/false로 답하는 작은 객체입니다. @Conditional(어떤조건.class)로 클래스나 @Bean 메서드에 붙입니다.

프로파일(profile)은 "dev", "prod"처럼 이름을 붙인 실행 환경 묶음입니다. 어떤 프로파일이 켜져 있느냐에 따라 다른 빈을 쓰고 싶을 때 @Profile("dev")를 붙입니다.

**Environment**는 컨테이너가 들고 있는 "지금 환경" 객체입니다. 켜진 프로파일 목록과 설정값(시스템 프로퍼티, 환경 변수 등)을 여기서 읽습니다. 자세한 것은 12편에서 다룰 예정이고, 이 글에서는 프로파일을 묻는 메서드만 봅니다.

메타 어노테이션은 어노테이션 위에 붙은 어노테이션입니다. @Profile의 선언에는 @Conditional(...)이 붙어 있고, @EnableAsync의 선언에는 @Import(...)가 붙어 있습니다. Spring은 "어노테이션에 붙은 어노테이션"까지 따라가서 읽기 때문에, 이 글의 주인공 둘(@Profile, @EnableXxx)은 사실 @Conditional과 @Import의 응용입니다.

1. 조건은 빈을 만들 때가 아니라 설계도를 만들 때 평가됩니다

@Conditional의 선언은 짧습니다. 값으로 Condition 클래스 배열을 받고, 클래스와 메서드에 붙일 수 있습니다(Conditional 59–68행). Condition은 boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) 하나뿐인 인터페이스입니다(Condition 48–61행). 두 인자가 조건이 볼 수 있는 전부입니다. metadata는 지금 검사하는 클래스나 메서드의 어노테이션 정보이고, context는 Environment, BeanDefinitionRegistry(지금까지 등록된 설계도), ResourceLoader, ClassLoader를 꺼내 주는 창구입니다(ConditionContext 41–65행). 주의할 점은 Condition의 설명 주석에 적혀 있습니다. 조건은 설계도가 등록되기 직전에 검사되며, BeanFactoryPostProcessor와 같은 제약을 지켜 빈 인스턴스를 건드리면 안 됩니다(26–33행). 조건이 평가되는 시점에는 아직 빈이 하나도 만들어지지 않았기 때문입니다.

실제로 조건을 평가하는 클래스는 ConditionEvaluator입니다. 패키지 밖에서는 보이지 않는 내부 클래스이고, 핵심은 shouldSkip 하나입니다.

	public boolean shouldSkip(@Nullable AnnotatedTypeMetadata metadata, @Nullable ConfigurationPhase phase) {
		if (metadata == null || !metadata.isAnnotated(Conditional.class.getName())) {
			return false;
		}

		if (phase == null) {
			if (metadata instanceof AnnotationMetadata annotationMetadata &&
					ConfigurationClassUtils.isConfigurationCandidate(annotationMetadata)) {
				return shouldSkip(metadata, ConfigurationPhase.PARSE_CONFIGURATION);
			}
			return shouldSkip(metadata, ConfigurationPhase.REGISTER_BEAN);
		}

		List<Condition> conditions = collectConditions(metadata);
		for (Condition condition : conditions) {
			ConfigurationPhase requiredPhase = null;
			if (condition instanceof ConfigurationCondition configurationCondition) {
				requiredPhase = configurationCondition.getConfigurationPhase();
			}
			if ((requiredPhase == null || requiredPhase == phase) && !condition.matches(this.context, metadata)) {
				return true;
			}
		}

		return false;
	}

spring-context/src/main/java/org/springframework/context/annotation/ConditionEvaluator.java 81–106행

위에서부터 읽으면 이렇습니다.

  1. @Conditional이 (메타 어노테이션으로라도) 붙어 있지 않으면 건너뛸 이유가 없으니 false입니다. 조건이 없는 평범한 빈은 여기서 끝납니다.
  2. phase(단계)가 지정되지 않았으면 메타데이터를 보고 추론합니다. 설정 클래스 후보(@Component·@ComponentScan·@Import·@ImportResource가 붙었거나 @Bean 메서드가 있는 클래스, ConfigurationClassUtils 174–189행)면 PARSE_CONFIGURATION, 아니면 REGISTER_BEAN입니다. 이 두 단계는 2절에서 설명합니다.
  3. @Conditional에 적힌 조건 클래스를 전부 모아 객체로 만들고 @Order 순으로 정렬합니다(collectConditions, 114–128행). 객체는 BeanUtils.instantiateClass로 그냥 new 합니다(137–140행). 조건 객체는 빈이 아닙니다. 의존성 주입도 받지 못합니다.
  4. 조건을 차례로 묻다가 하나라도 matches가 false를 돌려주면 "건너뛴다"(true)입니다. 모든 조건이 맞아야 등록됩니다.

shouldSkip(metadata, phase)의 판단 순서. @Conditional 이 (메타 어노테이션으로라도) 붙어 있지 않으면 false, 곧 건너뛰지 않는다. 붙어 있는데 phase 가 지정되지 않았으면, 설정 클래스 후보는 phase = PARSE_CONFIGURATION 으로, 그 밖은 phase = REGISTER_BEAN 으로 자기 자신을 다시 부른다. phase 가 있으면 collectConditions()가 조건 클래스를 new 로 만들고 @Order 순으로 정렬한다(조건 객체는 빈이 아니다). 조건마다 단계가 맞는데 matches()가 false 인 것이 하나라도 있으면 true, 곧 건너뛰어 설계도를 등록하지 않고, 모두 통과하면 false 다 위 코드와 1~4번 설명을 한 장으로 옮긴 것입니다. 초록이 "등록한다", 빨강이 "건너뛴다"입니다.

메서드 이름이 matches가 아니라 shouldSkip인 것도 눈여겨볼 만합니다. 부르는 쪽은 "등록할까?"가 아니라 "건너뛸까?"를 묻고, true면 그 자리에서 return 합니다.

2. 같은 조건을 세 번 묻습니다 — 등록할 때, 파싱할 때, 설계도로 옮길 때

shouldSkip은 한 군데서 불리지 않습니다. 설정이 설계도가 되는 길 위에 세 번 서 있습니다.

조건이 평가되는 세 시점. ① 등록할 때 register나 컴포넌트 스캔에서 단계를 추론해 묻고, 안 맞으면 설계도를 만들지 않는다. ② 파싱할 때 ConfigurationClassParser.processConfigurationClass가 PARSE_CONFIGURATION 단계로 묻고, 안 맞으면 설정 클래스를 읽지 않는다. ③ 설계도로 옮길 때 ConfigurationClassBeanDefinitionReader의 TrackedConditionEvaluator가 REGISTER_BEAN 단계로 묻고, 안 맞으면 설계도를 등록하지 않는다. 셋 다 맞으면 BeanDefinition이 등록된다. 파란 상자가 shouldSkip이 불리는 세 자리, 빨간 상자가 건너뛰었을 때의 결과입니다.

어디서 어느 단계로 건너뛰면
① 등록할 때 AnnotatedBeanDefinitionReader.doRegisterBean (257행), 컴포넌트 스캔의 isConditionMatch (554–560행) 추론 (설정 클래스면 PARSE_CONFIGURATION) 설계도 자체를 만들지 않는다
② 파싱할 때 ConfigurationClassParser.processConfigurationClass (249–251행) PARSE_CONFIGURATION 그 클래스의 @Bean·@Import·@ComponentScan을 전혀 읽지 않는다
③ 설계도로 옮길 때 ConfigurationClassBeanDefinitionReader — 클래스는 TrackedConditionEvaluator (135행), @Bean 메서드는 196행 REGISTER_BEAN 설계도를 등록하지 않는다 (이미 있던 것은 지운다)

①은 new AnnotationConfigApplicationContext(AppConfig.class)의 register가 하는 일이고, ②와 ③은 refresh()의 invokeBeanFactoryPostProcessors 단계(AbstractApplicationContext 604행, 7편)에서 ConfigurationClassPostProcessor가 파서와 리더를 차례로 돌릴 때 일어납니다(ConfigurationClassPostProcessor 452행, 464행).

두 단계: PARSE_CONFIGURATION과 REGISTER_BEAN

왜 "단계"가 있을까요. Condition의 확장인 ConfigurationCondition이 그 답입니다. 이 인터페이스는 getConfigurationPhase() 하나를 더 가지고, 그 반환형인 열거형 ConfigurationPhase에 값이 둘 있습니다(ConfigurationCondition 30–61행). 설명 주석이 두 단계를 이렇게 정의합니다(43–58행).

1절의 shouldSkip을 다시 보면, ConfigurationCondition이 아닌 평범한 Condition은 requiredPhase가 null이라 어느 단계에서 물어도 대답합니다. 반대로 ConfigurationCondition은 자기가 정한 단계에서만 대답하고 다른 단계에서는 무시됩니다. "어떤 빈이 이미 등록되어 있는가"를 보는 조건이라면 파싱 중에는 아직 다른 설정의 빈이 등록되지 않았을 수 있으니 REGISTER_BEAN을 골라야 합니다. 그래서 단계가 필요합니다.

③에서 클래스에 쓰이는 TrackedConditionEvaluator는 한 가지를 더 봅니다.

		public boolean shouldSkip(ConfigurationClass configClass) {
			Boolean skip = this.skipped.get(configClass);
			if (skip == null) {
				if (configClass.isImported()) {
					boolean allSkipped = true;
					for (ConfigurationClass importedBy : configClass.getImportedBy()) {
						if (!shouldSkip(importedBy)) {
							allSkipped = false;
							break;
						}
					}
					if (allSkipped) {
						// ...
						skip = true;
					}
				}
				if (skip == null) {
					skip = conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN);
				}
				this.skipped.put(configClass, skip);
			}
			return skip;
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java 514–536행

이 설정 클래스가 다른 설정에서 @Import로 가져와진 것이면, 가져온 쪽이 전부 건너뛰어졌을 때 이 클래스도 건너뜁니다. @Conditional이 붙은 설정 A가 설정 B를 가져왔는데 A가 조건에 걸려 빠졌다면, B도 따라서 빠지는 것이 자연스럽기 때문입니다. 결과는 skipped 맵에 기억해 두어 같은 클래스에 두 번 묻지 않습니다.

예제: 조건이 몇 번 불리나

시스템 프로퍼티 ex.feature가 있을 때만 맞는 조건을 만들고, @Bean 메서드 하나와 설정 클래스 하나에 각각 걸어 봤습니다. matches가 불릴 때마다 무엇을 검사하는지 출력합니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
static class OnFeatureProperty implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        boolean present = context.getEnvironment().containsProperty("ex.feature");
        System.out.println("  OnFeatureProperty.matches -> " + present + " (checked on " + describe(metadata) + ")");
        return present;          // describe()는 메서드면 "method 이름", 클래스면 "class 이름"을 돌려준다
    }
}

@Configuration
static class FeatureConfig {
    @Bean @Conditional(OnFeatureProperty.class) Feature feature() { return new Feature(); }  // 메서드에 건 조건
    @Bean String always() { return "always"; }
}

@Configuration
@Conditional(OnFeatureProperty.class)       // 설정 클래스 전체에 건 조건
static class GatedConfig {
    @Bean String gated() { return "gated"; }
}

// main: new AnnotationConfigApplicationContext(FeatureConfig.class, GatedConfig.class) 를
//       프로퍼티 없이 한 번, System.setProperty("ex.feature", "on") 뒤에 한 번 만든다

출력은 이렇습니다.

1) without ex.feature
  OnFeatureProperty.matches -> false (checked on class ex.u07.ConditionalExample$GatedConfig)
  OnFeatureProperty.matches -> false (checked on method feature)
  Feature beans=[]
  String beans=[always]
2) with ex.feature=on
  OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
  OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
  OnFeatureProperty.matches -> true (checked on method feature)
  OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
  Feature beans=[feature]
  String beans=[always, gated]

위의 표와 정확히 맞습니다. 프로퍼티가 없을 때 GatedConfig는 ①에서 한 번 묻고 바로 빠져서 ②③까지 오지 않습니다. 프로퍼티가 있을 때는 ①(등록), ②(파싱), ③(설계도로 옮길 때) 세 번 불립니다. @Bean 메서드의 조건은 ③에서만, 한 번 불립니다. 세 번째와 네 번째 줄 사이에 feature 메서드가 끼어 있는 것은 리더가 FeatureConfig를 먼저 처리하기 때문입니다.

이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 위 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다).

헷갈리기 쉬운 곳

3. @Profile은 @Conditional의 응용입니다

@Profile의 선언을 보면 끝에 @Conditional이 붙어 있습니다.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(ProfileCondition.class)
public @interface Profile {

	// ...
	String[] value();

}

spring-context/src/main/java/org/springframework/context/annotation/Profile.java 107–118행

그러니 @Profile("dev")를 붙인 것은 곧 @Conditional(ProfileCondition.class)를 붙인 것이고, 2절의 세 시점에 똑같이 평가됩니다. 새 장치는 없고 ProfileCondition이라는 조건 하나가 더 있을 뿐입니다.

	public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
		MultiValueMap<String, @Nullable Object> attrs = metadata.getAllAnnotationAttributes(Profile.class.getName());
		if (attrs != null) {
			for (Object value : attrs.get("value")) {
				if (context.getEnvironment().matchesProfiles((String[]) value)) {
					return true;
				}
			}
			return false;
		}
		return true;
	}

spring-context/src/main/java/org/springframework/context/annotation/ProfileCondition.java 37–48행

메타데이터에서 @Profile의 value를 꺼내 Environment.matchesProfiles에게 넘기는 것이 전부입니다. 그 뒤는 spring-core의 일입니다.

@Profile이 평가되는 길. @Profile 자체가 @Conditional(ProfileCondition.class)를 달고 있어서 ProfileCondition.matches가 value를 읽고 Environment.matchesProfiles를 부른다. 그것은 Profiles.of로 표현식을 해석한 뒤 AbstractEnvironment.isProfileActive에게 이름마다 묻는다. 활성 프로파일에 있으면 맞음, 없으면 활성 프로파일이 비어 있고 기본 프로파일에 있을 때만 맞음, 아니면 건너뛴다. @Profile 한 줄이 Environment까지 내려가는 길. 회색 상자는 표현식 해석기로, 이 글에서는 이름만 적습니다.

	protected boolean isProfileActive(String profile) {
		validateProfile(profile);
		Set<String> currentActiveProfiles = doGetActiveProfiles();
		return (currentActiveProfiles.contains(profile) ||
				(currentActiveProfiles.isEmpty() && doGetDefaultProfiles().contains(profile)));
	}

spring-core/src/main/java/org/springframework/core/env/AbstractEnvironment.java 407–412행

규칙은 두 줄입니다. 활성 프로파일에 있으면 켜진 것이고, 활성 프로파일이 하나도 없을 때만 기본 프로파일을 대신 봅니다. 기본 프로파일은 따로 바꾸지 않으면 "default" 하나입니다(100행, 250–252행). 활성 프로파일은 setActiveProfiles로 직접 넣거나, 비어 있으면 spring.profiles.active 프로퍼티에서 읽어 옵니다(272–283행).

예제: dev와 prod

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Configuration
static class StorageConfig {
    @Bean @Profile("dev")  Storage devStorage()  { return new DevStorage(); }   // dev 일 때만
    @Bean @Profile("prod") Storage prodStorage() { return new ProdStorage(); }  // prod 일 때만
    @Bean @Profile("!prod") String notProdMarker() { return "not-prod"; }       // prod 가 아닐 때
}

static void run(String... activeProfiles) {          // main: run("dev"); run("prod"); run();
    var context = new AnnotationConfigApplicationContext();
    context.getEnvironment().setActiveProfiles(activeProfiles);   // refresh() 전에 정해야 한다
    context.register(StorageConfig.class);
    context.refresh();
    // 켜진 프로파일과, Storage 타입·String 타입 빈의 이름을 출력한다
    context.close();
}
active=[dev] -> Storage beans=[devStorage], String beans=[notProdMarker]
active=[prod] -> Storage beans=[prodStorage], String beans=[]
active=[] -> Storage beans=[], String beans=[notProdMarker]

셋째 줄이 isProfileActive의 두 번째 조건입니다. 아무것도 켜지 않으면 활성 프로파일이 비어 기본 프로파일 "default"를 보는데, "dev"도 "prod"도 아니므로 둘 다 빠지고 "!prod"만 맞습니다. 이 예제도 Spring Framework 7.0.9로 실행했습니다.

헷갈리기 쉬운 곳

4. @Import의 세 갈래

@Import는 "이 클래스들도 설정으로 쳐 달라"는 표시입니다. 값은 Class<?>[]이고 설명 주석은 @Configuration 클래스, ImportSelector, ImportBeanDefinitionRegistrar, BeanRegistrar 네 종류와 평범한 컴포넌트 클래스를 받는다고 적고 있습니다(Import 33–36행, 72–77행). 이 글은 오래전부터 있던 세 갈래를 다루고, 7.0에 들어온 BeanRegistrar는 "뺀 것"에 적어 둡니다.

파서는 설정 클래스 하나를 읽는 doProcessConfigurationClass 안에서, @ComponentScan 처리가 끝난 다음 @Import를 처리합니다(ConfigurationClassParser 363–364행, 전체 순서는 8편). getImports가 클래스에 직접 붙은 @Import와 메타 어노테이션으로 붙은 @Import, 7.0부터는 구현한 인터페이스의 @Import까지 모아 오고(541–579행), processImports가 하나씩 갈래를 나눕니다. (순환 import를 잡는 부분과 예외 처리, 그리고 7.0에 들어온 BeanRegistrar 갈래는 // ...로 줄였습니다.)

	private void processImports(ConfigurationClass configClass, SourceClass currentSourceClass,
			Collection<SourceClass> importCandidates, Predicate<String> filter, boolean checkForCircularImports) {

		// ...
			this.importStack.push(configClass);
			try {
				for (SourceClass candidate : importCandidates) {
					if (candidate.isAssignable(ImportSelector.class)) {
						// ...
						if (selector instanceof DeferredImportSelector deferredImportSelector) {
							this.deferredImportSelectorHandler.handle(configClass, deferredImportSelector);
						}
						else {
							String[] importClassNames = selector.selectImports(currentSourceClass.getMetadata());
							Collection<SourceClass> importSourceClasses = asSourceClasses(importClassNames, filter);
							processImports(configClass, currentSourceClass, importSourceClasses, filter, false);
						}
					}
					// ...
					else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) {
						// ...
						configClass.addImportBeanDefinitionRegistrar(registrar, currentSourceClass.getMetadata());
					}
					else {
						// ...
						this.importStack.registerImport(
								currentSourceClass.getMetadata(), candidate.getMetadata().getClassName());
						processConfigurationClass(candidate.asConfigClass(configClass), filter);
					}
				}
			}
			// ...
			finally {
				this.importStack.pop();
			}
		// ...
	}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java 581–652행

@Import에 적힌 클래스 하나가 처리되는 갈래. ImportSelector면 다시 DeferredImportSelector인지 보고, 맞으면 DeferredImportSelectorHandler.handle로 미뤄 두고 아니면 selectImports를 바로 불러 돌려받은 이름으로 processImports를 다시 한다. ImportSelector가 아니고 ImportBeanDefinitionRegistrar면 객체를 만들어 configClass에 모아 두었다가 설계도로 옮길 때 registerBeanDefinitions를 부른다. 둘 다 아니면 importStack.registerImport로 누가 가져왔는지 기록하고 processConfigurationClass로 설정 클래스처럼 다룬다. 왼쪽부터 ImportSelector, ImportBeanDefinitionRegistrar, 설정 클래스의 갈래. 노란 상자는 "지금 하지 않고 모아 두는" 자리입니다.

갈래 무엇을 하나 언제 실행되나 결과
ImportSelector selectImports(가져온 쪽의 메타데이터)가 클래스 이름 배열을 돌려준다 파싱 중 그 자리에서 (DeferredImportSelector면 미룸, 5절) 돌려받은 이름들로 processImports를 다시 돈다. 그래서 선택기가 선택기나 레지스트라를 돌려줘도 된다
ImportBeanDefinitionRegistrar registerBeanDefinitions(메타데이터, registry)에서 설계도를 직접 등록한다 파싱 중에는 객체만 만들어 모아 두고(ConfigurationClass 236행), 설계도로 옮기는 단계에서 부른다(ConfigurationClassBeanDefinitionReader 152행, 423–428행) @Bean으로는 못 하는 일(이름·스코프·속성을 손으로 정한 설계도, 프록시 생성기 등록 등)
그 밖의 클래스 processConfigurationClass로 설정 클래스처럼 파싱한다 파싱 중 그 자리에서 (재귀) 가져온 클래스 자체도 빈으로 등록되고, 그 안의 @Bean도 설계도가 된다

세 번째 갈래에서 가져온 클래스에는 "누가 가져왔는지"가 붙습니다. candidate.asConfigClass(configClass)가 만드는 ConfigurationClass는 importedBy에 가져온 쪽을 담고(ConfigurationClass 99–103행), 리더는 isImported()인 클래스를 빈으로 따로 등록합니다(144–146행, 159–184행). 빈 이름은 importBeanNameGenerator가 짓는데, 기본은 클래스의 전체 이름입니다(ConfigurationClassPostProcessor 165–166행, 뒤의 예제 출력에서 확인할 수 있습니다). 2절의 TrackedConditionEvaluator가 보던 getImportedBy()도 이 값입니다.

ImportSelector와 ImportBeanDefinitionRegistrar 객체는 빈이 아니지만, 줄인 자리의 ParserStrategyUtils.instantiateClass(598–599행, 626–628행)로 만들어지면서 Environment, BeanFactory, ClassLoader, ResourceLoader를 생성자 인자나 Aware 인터페이스로 받을 수 있습니다(ParserStrategyUtils 56–69행, ImportSelector 30–47행). 1절의 Condition이 그냥 new 되는 것과 다른 점입니다.

5. 미루는 선택기와 "누가 나를 가져왔나"

DeferredImportSelector: 모든 설정이 파싱된 뒤에

DeferredImportSelector는 ImportSelector를 상속한 인터페이스로, 메서드를 하나도 더 강제하지 않습니다(39–49행). 다른 것은 파서가 그것을 다루는 시점뿐입니다. 4절 코드에서 DeferredImportSelector면 selectImports를 부르지 않고 deferredImportSelectorHandler.handle에 넘겼습니다.

		void handle(ConfigurationClass configClass, DeferredImportSelector importSelector) {
			DeferredImportSelectorHolder holder = new DeferredImportSelectorHolder(configClass, importSelector);
			if (this.deferredImportSelectors == null) {
				DeferredImportSelectorGroupingHandler handler = new DeferredImportSelectorGroupingHandler();
				handler.register(holder);
				handler.processGroupImports();
			}
			else {
				this.deferredImportSelectors.add(holder);
			}
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java 813–823행

handle은 평소에는(목록이 null이 아닐 때) 선택기를 목록에 넣기만 합니다. 목록을 비우는 process()(825–839행)는 parse(후보들)가 후보 설정 클래스를 전부 돈 다음 마지막 줄에서 불립니다(200행). 그때 목록을 null로 바꾸고, 모아 둔 선택기를 @Order대로 정렬해 그룹으로 묶은 다음, 각 선택기의 selectImports를 부르고 돌려받은 이름으로 processImports를 돕니다(859–880행). 그 도중에 또 만난 DeferredImportSelector는 목록이 null이므로 handle의 첫 갈래대로 바로 처리됩니다.

왜 미룰까요. DeferredImportSelector의 설명 주석은 "가져올 설정이 @Conditional일 때 특히 유용하다"고 적고 있습니다(24–26행). "사용자가 DataSource 빈을 직접 정의하지 않았을 때만 기본 DataSource 설정을 가져온다" 같은 조건은 사용자의 설정이 전부 파싱된 뒤에 봐야 맞는 답이 나옵니다. 파싱 도중에 보면 아직 읽지 않은 설정 클래스의 빈이 안 보여서 잘못 판단합니다. 그래서 사용자 설정을 다 읽은 다음으로 미룹니다. (Spring Boot의 자동 설정이 이 장치 위에 있다고 알려져 있지만, Spring Boot는 별개의 프로젝트라 이 글에서 소스로 확인한 것은 아닙니다.)

예제: 세 갈래가 불리는 순서

설정 클래스 둘을 등록합니다. AppConfig는 DeferredImportSelector(LateSelector), 평범한 ImportSelector(MailSelector), ImportBeanDefinitionRegistrar(SchedulerRegistrar)를 가져오고, OtherConfig는 파싱 시점을 보여 주기 위한 OtherSelector를 가져옵니다. MailSelector가 돌려주는 MailConfig는 ImportAware를 구현해 자기를 가져온 쪽의 이름을 출력합니다.

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다). 각 메서드는 불리면 자기 이름과 가져온 쪽을 출력합니다
@Configuration
static class MailConfig implements ImportAware {               // 갈래 ① 설정 클래스
    @Override public void setImportMetadata(AnnotationMetadata importMetadata) { /* 출력 */ }
    @Bean Mailer mailer() { return new Mailer(); }
}

static class MailSelector implements ImportSelector {           // 갈래 ② 이름을 돌려준다
    @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[] {MailConfig.class.getName()};
    }
}

static class LateSelector implements DeferredImportSelector {   // 갈래 ② 의 미루는 변형
    @Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[0]; }
}

static class SchedulerRegistrar implements ImportBeanDefinitionRegistrar {   // 갈래 ③ 설계도를 직접
    @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        registry.registerBeanDefinition("scheduler", new RootBeanDefinition(Scheduler.class));
    }
}

@Configuration
@Import({LateSelector.class, MailSelector.class, SchedulerRegistrar.class})
static class AppConfig { }

@Configuration
@Import(OtherSelector.class)      // OtherSelector 는 LateSelector 와 같되 평범한 ImportSelector
static class OtherConfig { }

// main: new AnnotationConfigApplicationContext(AppConfig.class, OtherConfig.class)
creating context with AppConfig, OtherConfig
  MailSelector.selectImports (importing ex.u07.ImportExample$AppConfig)
  OtherSelector.selectImports (importing ex.u07.ImportExample$OtherConfig)
  LateSelector.selectImports (importing ex.u07.ImportExample$AppConfig)
  SchedulerRegistrar.registerBeanDefinitions (importing ex.u07.ImportExample$AppConfig)
  MailConfig.setImportMetadata <- imported by ex.u07.ImportExample$AppConfig
Mailer beans=[mailer]
Scheduler beans=[scheduler]
MailConfig beans=[ex.u07.ImportExample$MailConfig]

예제의 세 갈래가 불리는 시점. ConfigurationClassParser.parse 안에서 AppConfig를 파싱할 때 MailSelector.selectImports가 바로 불리고 LateSelector는 미뤄 두며 SchedulerRegistrar는 모아 둔다. OtherConfig를 파싱할 때 OtherSelector.selectImports가 불린다. 후보를 다 돈 뒤 deferredImportSelectorHandler.process에서 LateSelector.selectImports가 이제야 불린다. 그 뒤 ConfigurationClassBeanDefinitionReader.loadBeanDefinitions에서 SchedulerRegistrar.registerBeanDefinitions가 불리고, 빈을 만들 때 MailConfig.setImportMetadata가 불린다. 출력의 다섯 줄을 소스의 자리에 놓은 것. 노란 상자가 "미뤄 둔 것이 실행되는" 자리입니다.

@Import에는 LateSelector를 맨 앞에 적었지만, 실제로는 OtherConfig의 선택기보다도 뒤에 불렸습니다. 모든 후보의 파싱이 끝난 다음이기 때문입니다. 그리고 레지스트라는 그보다 더 뒤, 리더가 설계도를 옮기는 단계에서 불립니다. 이 예제도 Spring Framework 7.0.9로 실행했습니다.

ImportAware: 가져온 쪽의 메타데이터 받기

출력의 마지막 줄 MailConfig.setImportMetadata가 가장 늦게 나온 이유는 그것이 빈을 만들 때의 일이기 때문입니다. 길은 세 토막입니다.

  1. 4절 코드의 세 번째 갈래에서 importStack.registerImport(가져온 쪽 메타데이터, 가져온 클래스 이름)로 누가 누구를 가져왔는지 기록합니다. ImportStack은 스택이면서 동시에 이 기록을 담는 ImportRegistry입니다(755–766행).
  2. 파싱이 다 끝나면 ConfigurationClassPostProcessor가 이 ImportRegistry를 싱글톤 빈으로 등록해 둡니다(493–496행).
  3. 빈을 만들 때 ImportAwareBeanPostProcessor가 "초기화 전" 훅(6편)에서 그 기록을 찾아 넣어 줍니다.
		@Override
		public Object postProcessBeforeInitialization(Object bean, String beanName) {
			if (bean instanceof ImportAware importAware) {
				ImportRegistry ir = this.beanFactory.getBean(IMPORT_REGISTRY_BEAN_NAME, ImportRegistry.class);
				AnnotationMetadata importingClass = ir.getImportingClassFor(ClassUtils.getUserClass(bean).getName());
				if (importingClass != null) {
					importAware.setImportMetadata(importingClass);
				}
			}
			return bean;
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java 607–617행

이 후처리기는 ConfigurationClassPostProcessor가 postProcessBeanFactory 끝에서 등록합니다(338행). ImportAware가 쓸모 있는 때는 7절의 @EnableXxx처럼 어노테이션의 속성값을 가져온 설정이 읽어야 할 때입니다.

헷갈리기 쉬운 곳

6. 서로를 가져오면 — ImportStack

4절 코드에서 processImports는 들어갈 때 importStack.push, 나올 때 pop을 합니다. 그 사이에 가져온 설정 클래스를 재귀로 파싱하므로, 스택에는 "지금 가져오는 중인 설정 클래스들"이 쌓입니다. 줄였던 앞부분이 이 스택으로 순환을 잡습니다.

		if (checkForCircularImports && isChainedImportOnStack(configClass)) {
			this.problemReporter.error(new CircularImportProblem(configClass, this.importStack));
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java 588–590행

	private boolean isChainedImportOnStack(ConfigurationClass configClass) {
		if (this.importStack.contains(configClass)) {
			String configClassName = configClass.getMetadata().getClassName();
			AnnotationMetadata importingClass = this.importStack.getImportingClassFor(configClassName);
			while (importingClass != null) {
				if (configClassName.equals(importingClass.getClassName())) {
					return true;
				}
				importingClass = this.importStack.getImportingClassFor(importingClass.getClassName());
			}
		}
		return false;
	}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java 654–666행

스택에 이미 있는 클래스라면, 5절에서 본 "누가 누구를 가져왔나" 기록을 거슬러 올라가 자기 자신이 나오는지 봅니다. 나오면 problemReporter.error를 부르고, 기본 리포터인 FailFastProblemReporter는 그 자리에서 BeanDefinitionParsingException을 던집니다(70–72행). 이 예외는 BeanDefinitionStoreException의 자식입니다(30행). checkForCircularImports가 false인 호출은 선택기가 돌려준 이름을 처리하는 재귀 호출(610행)과 미뤄 둔 선택기 처리(866–868행)입니다. 그때는 같은 설정 클래스가 이미 스택에 있는 것이 정상이기 때문입니다.

예제: A는 B를, B는 A를

// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@Configuration
@Import(ConfigB.class)        // A 는 B 를 가져오고
static class ConfigA { }

@Configuration
@Import(ConfigA.class)        // B 는 다시 A 를 가져온다
static class ConfigB { }

// main: new AnnotationConfigApplicationContext(ConfigA.class) 를 try 로 감싸고 예외의 클래스와 메시지를 출력
org.springframework.beans.factory.parsing.BeanDefinitionParsingException
Configuration problem: A circular @Import has been detected: Illegal attempt by @Configuration class 'CircularImportExample.ConfigB' to import class 'CircularImportExample.ConfigA' as 'CircularImportExample.ConfigA' is already present in the current import stack [CircularImportExample.ConfigB->CircularImportExample.ConfigA]
Offending resource: ex.u07.CircularImportExample$ConfigB

순환 import가 잡히는 순서. ConfigA의 @Import를 처리하며 스택에 ConfigA를 넣고 ConfigA가 ConfigB를 가져왔다고 기록한다. ConfigB를 설정 클래스로 파싱하며 스택에 ConfigB를 넣고 ConfigB가 ConfigA를 가져왔다고 기록한다. ConfigA를 다시 파싱하려 할 때 isChainedImportOnStack이 스택에 있고 가져온 사슬을 거슬러 가면 자기 자신이 나오는지 보고, 그렇다면 CircularImportProblem으로 BeanDefinitionParsingException을 던진다. 메시지의 [ConfigB->ConfigA]가 그때의 스택입니다. 스택은 가장 최근 것이 앞에 오므로 ConfigB가 먼저 적힙니다.

메시지를 소스와 맞춰 보면 이렇습니다(CircularImportProblem 1165–1174행). ConfigA를 파싱하다 ConfigB를 가져오고(스택 [A]), ConfigB를 파싱하다 ConfigA를 가져오려고 processConfigurationClass(A)에 들어갑니다(스택 [B, A]). 아직 A의 파싱이 끝나지 않아 "이미 처리한 클래스" 목록에는 없으므로 다시 doProcessConfigurationClass(A)가 돌고, 그 안의 processImports(A, …, true)에서 isChainedImportOnStack(A)가 참이 됩니다. "B가 A를 가져오려는 시도"로 보고된 것은 메시지가 스택 맨 위(importStack.element(), 곧 B)를 주어로 쓰기 때문입니다. 이 예제도 Spring Framework 7.0.9로 실행했습니다.

헷갈리기 쉬운 곳

7. @EnableXxx는 전부 @Import입니다

Spring에는 @EnableAsync, @EnableTransactionManagement, @EnableAspectJAutoProxy처럼 "기능을 켠다"는 어노테이션이 여럿 있습니다. 선언을 열어 보면 셋 다 같은 모양입니다. @Import 하나가 메타 어노테이션으로 붙어 있고, 그것이 전부입니다.

어노테이션 @Import에 적힌 것 갈래 그것이 하는 일
@EnableAspectJAutoProxy (122행) AspectJAutoProxyRegistrar ImportBeanDefinitionRegistrar (34행) 자동 프록시 생성기의 설계도를 등록하고, proxyTargetClass·exposeProxy 속성대로 설정한다 (42–57행)
@EnableAsync (167행) AsyncConfigurationSelector ImportSelector (AdviceModeImportSelector를 상속, 33행) mode가 PROXY면 ProxyAsyncConfiguration을, ASPECTJ면 AspectJ용 설정의 이름을 돌려준다 (45–50행)
@EnableTransactionManagement (spring-tx 162행) TransactionManagementConfigurationSelector ImportSelector (AdviceModeImportSelector를 상속, 38행) PROXY면 AutoProxyRegistrar와 ProxyTransactionManagementConfiguration 두 이름을 돌려준다 (47–53행)

세 @EnableXxx 어노테이션이 @Import로 이어지는 곳. @EnableAspectJAutoProxy는 ImportBeanDefinitionRegistrar인 AspectJAutoProxyRegistrar를 가져와 AnnotationAwareAspectJAutoProxyCreator의 설계도를 등록한다. @EnableAsync는 ImportSelector인 AsyncConfigurationSelector를 가져오고, mode가 PROXY면 @Configuration인 ProxyAsyncConfiguration을 고른다. @EnableTransactionManagement는 ImportSelector인 TransactionManagementConfigurationSelector를 가져오고, mode가 PROXY면 ImportBeanDefinitionRegistrar인 AutoProxyRegistrar와 @Configuration인 ProxyTransactionManagementConfiguration을 고른다. 회색이 어노테이션, 파란색이 4절의 두 갈래(선택기와 레지스트라)입니다. 선택기가 돌려준 이름이 다시 레지스트라일 수 있다는 것이 트랜잭션 쪽에 보입니다.

셋 가운데 하나는 레지스트라 갈래, 둘은 선택기 갈래이고, 선택기가 돌려준 ProxyAsyncConfiguration 같은 이름은 다시 설정 클래스 갈래로 처리되므로 4절의 갈래 셋이 모두 쓰입니다. 사용자는 @Configuration 클래스에 @EnableAsync 한 줄을 붙일 뿐이지만, 파서의 collectImports가 메타 어노테이션까지 따라가서 @Import(AsyncConfigurationSelector.class)를 찾아내고(571–577행), 그 뒤는 사용자가 직접 @Import를 쓴 것과 같습니다.

두 선택기가 상속하는 AdviceModeImportSelector의 selectImports(AnnotationMetadata)를 보면 "가져온 쪽의 메타데이터"라는 인자의 쓸모가 보입니다(66–84행). 제네릭 인자(AdviceModeImportSelector<EnableAsync>)로 어떤 어노테이션을 볼지 알아내고(68행), 가져온 쪽 클래스에 붙은 그 어노테이션의 mode 속성을 읽어(71행, 78행) 자식 클래스의 selectImports(AdviceMode)에 넘깁니다(79행). 사용자가 @EnableAsync(mode = ASPECTJ)라고 적으면 다른 설정 클래스가 선택되는 것입니다. 같은 이유로 선택된 ProxyAsyncConfiguration의 부모 AbstractAsyncConfiguration은 ImportAware를 구현해, 5절의 길을 통해 @EnableAsync의 나머지 속성을 받습니다(47행, 57행). 트랜잭션 쪽의 AbstractTransactionManagementConfiguration도 같습니다(49행).

정리하면 @EnableXxx는 새로운 장치가 아닙니다. 메타 어노테이션으로 숨긴 @Import, 그리고 속성값을 읽어 어느 설정을 가져올지 고르는 선택기, 또는 설계도를 직접 등록하는 레지스트라의 조합입니다. 이 글의 세 갈래를 알면 어떤 @EnableXxx든 선언을 열어 @Import를 따라가면 됩니다. 캐시·비동기·재시도의 스위치를 실제로 그렇게 따라가 보는 글이 27편입니다.

이 글에서 뺀 것

큰 줄기를 위해 다음은 이름과 위치만 적어 둡니다. 7.0에 들어온 @Import의 네 번째 갈래 BeanRegistrar(ConfigurationClassParser 613–621행). XML 설정을 가져오는 @ImportResource(367–375행). 설정 클래스 안의 중첩 클래스를 처리할 때도 같은 ImportStack을 쓰는 processMemberClasses(410–438행). 미뤄 둔 선택기들을 묶어 한꺼번에 정렬·필터링하는 DeferredImportSelector.Group과 getImportGroup(DeferredImportSelector 47–123행), 그리고 선택기가 가져올 후보를 걸러 내는 getExclusionFilter(ImportSelector 81–83행). REGISTER_BEAN 단계의 조건이 붙은 설정 클래스에는 @ComponentScan을 쓸 수 없다는 검사(340–345행). 조건에 걸려 빠진 @Bean 메서드 이름을 기억해 두어 같은 이름의 오버로드를 함께 빼는 skippedBeanMethods(ConfigurationClassBeanDefinitionReader 196–202행). 그리고 AOT 빌드에서 ImportAware를 지원하는 ImportAwareAotContribution(ConfigurationClassPostProcessor 621행부터)이 있습니다.

정리

개념 한 줄 요약
@Conditional / Condition "이 설계도를 등록해도 되나"에 true/false로 답하는 객체. 빈이 아니라 new로 만들어진다
ConditionEvaluator.shouldSkip 조건을 모아 하나라도 안 맞으면 "건너뛴다". @Conditional이 없으면 바로 false
세 시점 등록할 때(추론) → 파싱할 때(PARSE_CONFIGURATION) → 설계도로 옮길 때(REGISTER_BEAN). 클래스 조건은 최대 세 번 불린다
ConfigurationCondition 두 단계 중 어느 쪽에서 평가될지 고르는 조건. "다른 빈이 있나"를 보려면 REGISTER_BEAN
TrackedConditionEvaluator 가져온 쪽이 전부 빠졌으면 가져와진 설정도 뺀다. 결과를 기억한다
@Profile @Conditional(ProfileCondition.class)의 응용. Environment.matchesProfiles로 표현식을 묻는다
프로파일 규칙 활성 프로파일에 있으면 켜짐. 활성이 비어 있을 때만 기본 프로파일("default")을 본다
@Import 세 갈래 설정 클래스(바로 파싱), ImportSelector(이름을 돌려줌 → 다시 처리), ImportBeanDefinitionRegistrar(모아 뒀다가 설계도를 직접 등록)
DeferredImportSelector 모든 후보 설정이 파싱된 뒤 parse() 끝에서 실행. 조건이 걸린 설정을 가져올 때 쓴다
ImportAware / ImportRegistry 파서가 "누가 누구를 가져왔나"를 기록하고, 빈을 만들 때 후처리기가 그것을 넣어 준다
ImportStack 가져오는 중인 설정 클래스의 스택. 사슬을 거슬러 자기 자신이 나오면 BeanDefinitionParsingException
@EnableXxx 메타 어노테이션으로 숨긴 @Import. 속성값에 따라 설정을 고르는 선택기나 설계도를 등록하는 레지스트라

다음에 볼 것

여기까지가 분석 지도의 Phase 1(IoC 컨테이너)에서 주 경로에 든 단위의 마지막입니다. 설계도가 어떻게 생겼고(3편), 그것으로 객체가 어떻게 만들어지고(4편), 어노테이션이 어떻게 설계도가 되는지(8편)에 이어, 설계도를 고르고 가져오는 장치를 봤습니다.

주 경로는 여기서 AOP로 넘어갑니다. 7절의 @EnableAspectJAutoProxy와 @EnableTransactionManagement가 둘 다 등록하는 것이 자동 프록시 생성기였고, 4편에서 "초기화 후 훅에서 원본 대신 프록시를 돌려준다"고만 했던 그 후처리기입니다. 다음 글에서는 그 프록시가 실제로 어떻게 만들어지는지, 인터페이스를 쓰는 JDK 방식과 클래스를 상속하는 CGLIB 방식이 무엇이 다른지를 spring-aop에서 읽습니다.

다른 글