Spring Boot AutoConfiguration 동작 원리

Realsy·2023년 9월 1일
1
post-thumbnail

Spring Boot의 자동 설정 기능은 정말 강력합니다. 의존성만 추가해주면 Spring Boot가 뒤에서 필요한 설정들(Bean 설정 및 생성)을 자동으로 해줍니다. 이러한 강력한 기능으로 우리의 개발 시간을 줄여주기도 하지만, 간혹 커스터마이징이 필요한 경우 그 원리를 모른다면 꽤나 애를 먹기도 합니다.

이번 포스트에서는 Spring Boot의 AutoConfiguration의 동작 원리에 대해 파헤쳐보고, 그 원리를 이용해 나만의 AutoConfiguration을 커스터마이징 해보도록 하겠습니다.

@SpringBootApplication

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
  @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
  @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication {
}

AutoConfiguration의 시작은 우리가 Spring Boot를 사용할 때 항상 보는 @SpringBootApplication 애노테이션입니다.
그 안을 들여다보면, @EnableAutoConfiguration 이라는 애노테이션이 있는데요. 이 친구가 바로 AutoConfiguration 활성화 시키는 애노테이션이죠.

무엇을 자동 구성할까?

Spring은 무엇을 자동 구성해야할지 어떻게 알 수 있을까요?
@EnableAutoConfiguration 에서 더 타고 들어와보면 AutoConfigurationImportSelector 라는 클래스를 볼 수 있습니다. 그 안에는 selectImport 라는 메서드가 있는데요. 이 메서드를 통해 Import할 클래스가 무엇인지 알 수 있게 됩니다.

	/* AutoConfigurationImportSelector*/
	@Override
	public String[] selectImports(AnnotationMetadata annotationMetadata) {
		if (!isEnabled(annotationMetadata)) {
			return NO_IMPORTS;
		}
		AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(annotationMetadata);
		return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
	}

getAutoConfigurationEntry() 메서드로 AutoConfigurationEntry를 반환받는군요. getAutoConfigurationEntry()를 한 번 보겠습니다.

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
		if (!isEnabled(annotationMetadata)) {
			return EMPTY_ENTRY;
		}
		AnnotationAttributes attributes = getAttributes(annotationMetadata);
		List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
		configurations = removeDuplicates(configurations);
		Set<String> exclusions = getExclusions(annotationMetadata, attributes);
		checkExcludedClasses(configurations, exclusions);
		configurations.removeAll(exclusions);
		configurations = getConfigurationClassFilter().filter(configurations);
		fireAutoConfigurationImportEvents(configurations, exclusions);
		return new AutoConfigurationEntry(configurations, exclusions);
	}

여기서는

  • AutoConfiguration의 후보를 뽑아서(getCantidateConfigurations()),
  • 중복을 제거하고(removeDuplicates()),
  • 자동 설정에서 제외되는 설정에 대한 정보를 가져와서(getExclusion()),
  • 제외되는 설정을 제거하고(configuration.removeAll()),
  • 필터를 적용하고(getConfigurationClassFilter().filter()),
  • 그 결과 남은 AutoConfiguration 목록을 담은 AutoConfigurationEntry를 반환합니다.

자동 구성 후보군 찾기

여기서 getCandidateConfigurations() 메서드는 애노테이션을 기반으로 자동 설정 후보군을 구성합니다.

	protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		List<String> configurations = new ArrayList<>(
				SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(), getBeanClassLoader()));
		ImportCandidates.load(AutoConfiguration.class, getBeanClassLoader()).forEach(configurations::add);
		Assert.notEmpty(configurations,
				"No auto configuration classes found in META-INF/spring.factories nor in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. If you "
						+ "are using a custom packaging, make sure that file is correct.");
		return configurations;
	}

ImportCandidates.load(AutoConfiguration.class, ..).forEach(configuration::add) 부분에서 알 수 있듯이, @AutoConfiguration 애노테이션이 붙은 클래스에 대한 정보를 모두 가져옵니다.

configuration에 담긴 정보들을 보니, @AutoConfiguration 이 설정된 클래스의 패키지를 포함한 Full Name을 가져오는 것을 알 수 있습니다.

	public static ImportCandidates load(Class<?> annotation, ClassLoader classLoader) {
		Assert.notNull(annotation, "'annotation' must not be null");
		ClassLoader classLoaderToUse = decideClassloader(classLoader);
		String location = String.format(LOCATION, annotation.getName());
		Enumeration<URL> urls = findUrlsInClasspath(classLoaderToUse, location);
		List<String> importCandidates = new ArrayList<>();
		while (urls.hasMoreElements()) {
			URL url = urls.nextElement();
			importCandidates.addAll(readCandidateConfigurations(url));
		}
		return new ImportCandidates(importCandidates);
	}

ImportCandidates.load() 메서드에서는 특정 경로(location)에서 후보군을 읽어오는데요. 디버깅으로 확인해보니 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 경로입니다.(spring-boot-autoconfigure 라이브러리 내부)

## META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration
org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
...

해당 경로에는 자동 구성의 대상이 될 클래스들이 등록되어있습니다.

제외하기

protected Set<String> getExclusions(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		Set<String> excluded = new LinkedHashSet<>();
		excluded.addAll(asList(attributes, "exclude"));
		excluded.addAll(asList(attributes, "excludeName"));
		excluded.addAll(getExcludeAutoConfigurationsProperty());
		return excluded;
	}

자동 설정을 의도적으로 제외하는 경우도 있는데요. 그럴때 애노테이션에 exclude, excludeName 속성에 값을 지정합니다. 그리고 이 값은 getExclusion() 메서드에서 읽어들여서 자동 설정되지 않도록 하는 것이죠.

필터 적용

다시 getAutoConfigurationEntry() 메서드로 돌아가보겠습니다.

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
		if (!isEnabled(annotationMetadata)) {
			return EMPTY_ENTRY;
		}
		AnnotationAttributes attributes = getAttributes(annotationMetadata);
		List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
		configurations = removeDuplicates(configurations);
		Set<String> exclusions = getExclusions(annotationMetadata, attributes);
		checkExcludedClasses(configurations, exclusions);
		configurations.removeAll(exclusions);
		configurations = getConfigurationClassFilter().filter(configurations);
		fireAutoConfigurationImportEvents(configurations, exclusions);
		return new AutoConfigurationEntry(configurations, exclusions);
	}

getConfigurationClassFilter().filter(configuration) 에서는 AutoConfigurationImportFilter를 적용해 자동 구성에 포함시킬 클래스와 그렇지 않은 클래스를 필터링합니다.

getConfigurationClassFilter() 내부에서

  • getAutoConfigurationImportFilter()OnBeanCondition OnClassCondition OnWebApplicationCondition 라는 3개의 AutoConfigurationImportFilter 를 반환합니다.
  • 이 3개의 필터는 ConfigurationClassFilterConfigurationClassFilter 로 래핑됩니다.
private ConfigurationClassFilter getConfigurationClassFilter() {
		if (this.configurationClassFilter == null) {
			List<AutoConfigurationImportFilter> filters = getAutoConfigurationImportFilters();
			for (AutoConfigurationImportFilter filter : filters) {
				invokeAwareMethods(filter);
			}
			this.configurationClassFilter = new ConfigurationClassFilter(this.beanClassLoader, filters);
		}
		return this.configurationClassFilter;
	}
  • ConfigurationClassFilterfilter() 를 메서드로 갖고 있고, AutoConfigurationMetadata를 클래스 변수로 갖습니다.
  • AutoConfigurationMetadata 의 값은 META-INF/spring-autoconfigure-metadata.properties 의 값을 받아옵니다.
/* META-INF/spring-autoconfigure-metadata.properties */
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration=
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration$DataSourceInitializerConfiguration=
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration$DataSourceInitializerConfiguration.ConditionalOnBean=javax.sql.DataSource
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration$DataSourceInitializerConfiguration.ConditionalOnClass=org.springframework.jdbc.datasource.init.DatabasePopulator
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration.AutoConfigureAfter=org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration.ConditionalOnBean=org.springframework.batch.core.launch.JobLauncher
org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration.ConditionalOnClass=javax.sql.DataSource,org.springframework.batch.core.launch.JobLauncher

이 파일 안에는 ConditionalOnBean, ConditionalOnClass, AutoConfigureOrder, ConditionalOnMissingBean 과 같은 AutoConfiguration 클래스에 대한 메타데이터가 들어있습니다. filter() 메소드는 이러한 정보를 기반으로 필터링을 진행하는 것이죠.

위의 각 필터가 필터링하는 대상은 다음과 같습니다.

  • OnBeanCondition
    - @ConditionalOnBean @ConditionalOnMissingBean @ConditionalOnSingleCandidate
  • OnClassCondition
    - @ConditionalOnClass @ConditionalOnMissingClass
  • OnWebApplicationCondition
    - @ConditionalOnWebApplication @ConditionalOnNotWebApplication

실제 AutoConfiguration 클래스의 내부를 들여다보면,

@AutoConfiguration(after = RepositoryRestMvcAutoConfiguration.class)
@EnableSpringDataWebSupport
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ PageableHandlerMethodArgumentResolver.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(PageableHandlerMethodArgumentResolver.class)
@EnableConfigurationProperties(SpringDataWebProperties.class)
public class SpringDataWebAutoConfiguration {
...

@Conditional.. 애노테이션이 붙어있고, 여기에 설정되어 있는 값을 기반으로 필터는 해당 클래스를 자동 설정에 포함시킬지, 제외할 지 결정하게 되는 것입니다.


Properties(또는 yaml) 의 원리

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root

우리가 스프링을 사용할 때 data source를 구성하기 위해 application(or yaml) 파일에 위와 같은 설정을 하곤 합니다. 이런 설정은 AutoConfiguration 클래스의 @ConfigurationProperties 와 관련이 있습니다.

@AutoConfiguration(before = SqlInitializationAutoConfiguration.class)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
@Import(DataSourcePoolMetadataProvidersConfiguration.class)
public class DataSourceAutoConfiguration {

DataSource 자동 구성과 관련된 DataSourceAutoConfiguration 클래스에는 @EnableConfigurationProperties 라는 애노테이션이 붙어있는데요. 이를 통해 설정 파일(properties)의 값을 자동 구성시에 사용할 수 있게됩니다.

@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean {

	...

	private String name;

	private Class<? extends DataSource> type;

	private String driverClassName;

	private String url;

	private String username;

	private String password;
    
    ...

DataSourceProperties 클래스를 보면 더 명확히 볼 수 있습니다. driverClassName, url, username, password 와 같은 속성들이 우리가 properties 파일에 작성한 속성들과 같죠?


AutoConfiguration 커스터마이징

이제 직접 AutoConfiguration을 구현해보겠습니다. 다음과 같은 과정으로 진행됩니다.

  • AutoConfiguration 으로 등록할 Class를 추가
  • AutoConfiguration 클래스 추가
  • 등록할 클래스와 AutoConfigure를 패키징

먼저, 멀티 모듈 프로젝트를 생성하여 4개의 모듈을 만듭니다.

  • ac-library (코어 로직이 담긴 모듈, AutoConfiguration으로 등록할 Class 추가)
  • ac-spring-boot-autoconfigure (ac-library를 사용하기 위한 자동 설정을 수행하는 모듈)
  • ac-spring-boot-starter (ac-library와 ac-spring-boot-autoconfigure를 패키징한 모듈)
  • ac-client (ac-library를 사용하여 기능을 수행하는 모듈)

네이밍 컨벤션
자동 설정 구성 시, xx-spring-boot-autoconfigure, xx-spring-boot-starter 를 모듈 이름으로 만들어야 하며 이 둘을 합쳐서 하나로 만든다면 xx-spring-boot-starter 라는 모듈 이름을 가져야합니다.

멀티모듈 구성

settings.gradle.kts

rootProject.name = "ac"
pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}

include("ac-library")
include("ac-client")
include("ac-spring-boot-autoconfigure")
include("ac-spring-boot-starter")

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.springframework.boot.gradle.tasks.bundling.BootJar

plugins {
	id("org.springframework.boot") version "2.7.15"
	id("io.spring.dependency-management") version "1.0.15.RELEASE"
	kotlin("jvm") version "1.6.21"
	kotlin("plugin.spring") version "1.6.21"
}


allprojects {
	repositories {
		mavenCentral()
	}
}

subprojects {
	apply(plugin = "java")

	apply(plugin = "io.spring.dependency-management")
	apply(plugin = "org.springframework.boot")
	apply(plugin = "org.jetbrains.kotlin.plugin.spring")

	apply(plugin = "kotlin")

	group = "realsy"
	version = "0.0.1-SNAPSHOT"

	java {
		sourceCompatibility = JavaVersion.VERSION_11
	}

	tasks.withType<KotlinCompile> {
		kotlinOptions {
			freeCompilerArgs += "-Xjsr305=strict"
			jvmTarget = "11"
		}
	}

	tasks.withType<Test> {
		useJUnitPlatform()
	}

	configurations {
		compileOnly {
			extendsFrom(configurations.annotationProcessor.get())
		}
	}

	dependencies {
		implementation("org.springframework.boot:spring-boot-starter")
		implementation("org.jetbrains.kotlin:kotlin-reflect")
		annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
		testImplementation("org.springframework.boot:spring-boot-starter-test")
	}

}

project(":ac-library") {
	val jar: Jar by tasks
	val bootJar: BootJar by tasks

	jar.enabled = true
	bootJar.enabled = false

}
project(":ac-client") {
	val jar: Jar by tasks
	val bootJar: BootJar by tasks

	jar.enabled = true
	bootJar.enabled = true

	dependencies {
		api(project(":ac-spring-boot-starter"))
	}

}
project(":ac-spring-boot-autoconfigure") {
	val jar: Jar by tasks
	val bootJar: BootJar by tasks

	jar.enabled = true
	bootJar.enabled = false

	dependencies {
		api(project(":ac-library"))
	}
}
project(":ac-spring-boot-starter") {
	val jar: Jar by tasks
	val bootJar: BootJar by tasks

	jar.enabled = true
	bootJar.enabled = false

	dependencies {
		api(project(":ac-spring-boot-autoconfigure"))
	}

}

ac-library

코어 로직을 수행하는 ac-library 를 작성합니다. 여기서는 간단한 도어락 기능을 가진 클래스를 구성해보겠습니다.

DoorLock.kt

class DoorLock(private val password: String) {

    fun unlock(password: String) {
        runCatching {
            check(this.password == password)
        }.onFailure { println("Wrong Password!")
        }.onSuccess { println("Welcome! (you entered $password)") }
    }
}

비밀번호가 일치하는 지 여부에 따라 다른 메시지를 출력하는 메서드를 하나 만듭니다.

ac-spring-boot-autoconfigure

여기서는 두 가지 클래스를 작성합니다.

  1. DoorLockProperties : 도어락의 비밀번호 초기 값을 위한 클래스
  2. DoorLockAutoConfiguration : 도어락 클래스를 자동 설정하기 위한 클래스

DoorLockProperties.kt

@ConfigurationProperties(prefix = "doorlock")
@ConstructorBinding
data class DoorLockProperties(val password: String)

DoorLockAutoConfiguration.kt

@AutoConfiguration
@ConditionalOnClass(DoorLock::class)
@EnableConfigurationProperties(DoorLockProperties::class)
class DoorLockAutoConfiguration(private val doorLockProperties: DoorLockProperties) {

    @Bean
    @ConditionalOnMissingBean
    fun doorLock() = DoorLock(doorLockProperties.password)
}
  • @AutoConfiguration : 자동 설정할 클래스로 지정합니다.
  • @ConditionalOnClass : DoorLock 클래스가 있을 때만 자동 설정 합니다.
  • @EnableConfigurationProperties : 설정파일(properties/yaml)에서 초기 값을 불러와 설정하기 위한 애노테이션
  • @ConditionalOnMissingBean : 빈이 등록되지 않은 경우에만 자동 설정으로 빈을 등록합니다.

/resource/META-INF/spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  sy.jin.acspringbootautoconfigure.DoorLockAutoConfiguration

위에서 본 것 처럼 스프링은 자동설정 시 spring.factories에서 자동 설정 정보를 가져오기 때문에 지정해줍니다.

ac-client

이제 ac-library ac-spring-boot-autoconfigure 를 하나로 묶은 ac-spring-boot-starter를 사용해 자동등록하고, 기능을 실행시켜 보겠습니다.

application.properties

doorlock.password=12345

도어락 초기 비밀번호를 12345로 설정합니다.

@SpringBootApplication
class AcClientApplication(private val unlocker: Unlocker) : CommandLineRunner {
    override fun run(vararg args: String?) {
        unlocker.tryUnlock("123456")
    }
}

fun main(args: Array<String>) {
    runApplication<AcClientApplication>(*args)
}

@Service
class Unlocker(private val doorLock: DoorLock) {

    fun tryUnlock(password: String) {
        doorLock.unlock(password)
    }
}

결과(123456 입력 시)

Wrong Password!

결과(12345 입력 시)

Welcome! (you entered 12345)

TL;DR

  1. 스프링 자동설정 대상은 AutoConfigurationImportSelector 에서 선정한다.
  2. AutoConfigurationImportSelector 는 내부적으로 spring.factories 를 사용하여 자동설정 대상을 찾는다.
  3. properties를 통한 자동 설정 초기값 역시 자동 설정 클래스(@AutoConfiguration) 에서 @ConfigurationProperties 로 설정한다.

참고

- 스프링 부트의 Autoconfiguration 원리 및 만들어 보기

- Spring Boot 자동 구성 AutoConfiguration 동작하는 원리 파헤치기

profile
Real Dev'log

0개의 댓글