Post

스프링 부트 6 - 조건부 자동 구성

스프링 부트가 제공하는 자동 구성 정보

전 게시물의 마지막 부분에서는 자동 구성 정보를 외부 파일에서 읽은 후, 동적으로 자동 구성을 등록하도록 했었다. 이 기능을 만들기 위해 MyAutoConfiguration 애노테이션 클래스를 만들고, 이 클래스의 full path+name 으로 만들어진 텍스트 파일(resources/META_INF/spring/{애노테이션 클래스 이름}.imports)을 생성했었다.

스프링 부트도 비슷한 방식으로 구현이 되어있다. 스프링 부트에 이 기능을 하는 클래스 애노테이션 이름은 AutoConfiguration 이다. 또한 자동 구성할 빈들의 목록이 저장되어 있는 텍스트 파일은 org.springframework.boot.autoconfigure.AutoConfiguration.imports 이다.

org.springframework.boot.autoconfigure.AutoConfiguration.imports

해당 파일을 열어보면 자동 구성이 되는 144개의 Configuration 클래스들을 확인할 수 있다(web 라이브러리 사용시). 이처럼 스프링 부트는 자기 주장이 강하다. 사용할 기술이나 설정, 라이브러리 버전 등을 이미 결정해놓았다.

각각의 Configuration 클래스들은 내부에 1개 이상의 빈 애노테이션이 붙은 팩토리 메소드를 가진다. 또한 Configuration 클래스 자체도 빈으로 등록된다. 우리가 애플리케이션을 시작할 때마다 기본적으로 400~500개 정도의 빈들을 다 생성한다고 생각해보자. 불필요한 리소스가 너무 많다.

따라서 실제 스프링 부트는 이런 방식으로 모든 빈들을 다 생성하진 않는다. 예를 들어, Thymeleaf를 사용하지 않는다고 가정하자. 그러면 불필요하게 ThymeleafConfiguration에 있는 빈들을 생성할 필요가 없다.

본 게시물에서는 조건을 걸어서 어떤 빈들의 생성 여부를 결정하는 방법을 살펴보고자 한다.
이 게시물의 예시로는 서블릿 컨테이너로 Tomcat과 Jetty를 선택적으로 사용하는 기능을 구현해보자.



Jetty 라이브러리 추가

Tomcat 라이브러리는 스프링 디펜전시로 web을 선택하면 자동으로 임폴트 된다.

Jetty 라이브러리는 spring-boot-starter가 지원하므로 아래와 같이 간단하게 임폴드 할 수 있다.

1
2
3
4
5
6
// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-jetty'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}


Tomcat과 Jetty를 빈으로 등록하기 위해 설정 클래스를 만들자.

1
2
3
4
5
6
7
@MyAutoConfiguration
public class TomcatWebServerConfig {
    @Bean("tomcatWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }
}
1
2
3
4
5
6
7
@MyAutoConfiguration
public class JettyWebServerConfig {
    @Bean("jettyWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new JettyServletWebServerFactory();
    }
}

자동 구성 정보에 추가하기 위해, 전 게시물에서 만든 ~.imports 파일에 아래와 같은 내용을 추가한다.

1
2
3
tobyspring.config.autoconfig.TomcatWebServerConfig
tobyspring.config.autoconfig.JettyWebServerConfig
tobyspring.config.autoconfig.DispatcherServletConfig


이대로 애플리케이션을 실행하면 Tomcat이 사용될까, Jetty가 사용될까?

정답은 애플리케이션이 실행되지 않는다.(에러)
에러 메시지는 대략 아래와 같다.

1
2
3
4
org.springframework.context.ApplicationContextException:
Unable to start web server;
nested exception is org.springframework.context.ApplicationContextException:
Unable to start ServletWebServerApplicationContext due to multiple ServletWebServerFactory beans

ServletWebServerFactory 빈이 여러개 존재하기 때문에 애플리케이션을 시작할 수 없다고 뜬다.

Tomcat과 Jetty를 자유롭게 바꿔가며 사용하고 싶다면 어떻게 해야할까? 자동구성할 빈들이 텍스트로 나열되어 있는 ~.imports 을 수정하지 않고, 자동 구성 빈 정보를 변경해보자.



@Conditional, Condition

스프링에서는 해당 클래스(또는 빈)를 생성할지 말지 동적으로 결정할 수 있는 애노테이션(@Conditional)을 지원한다.
먼저 코드로 확인하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@MyAutoConfiguration
@Conditional(JettyWebServerConfig.JettyCondition.class)
public class JettyWebServerConfig {
    @Bean("jettyWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new JettyServletWebServerFactory();
    }

    static class JettyCondition implements Condition {
        @Override
        public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
            return true; // 이 클래스를 빈으로 사용하겠다.
        }
    }
}

클래스 레벨에 @Conditional 을 붙이고, 그 내부에 Condition 을 구현한 클래스를 넣어준다. 일단 위 코드에서는 내부 static 클래스로 구현했다.

해당 인터페이스의 구현 메소드는 matches 는 이 클래스의 빈을 생성할지(true 반환), 말지(false 반환) 결정하는 역할을 한다. 그 인자로는 전체적인 어플리케이션 환경을 얻을 수 있는 context를 받아서 이용할 수 있다.

다음과 같이 톰켓 설정 클래스도 작성하면 서블릿 컨테이너로 Jetty를 사용하게 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@MyAutoConfiguration
@Conditional(TomcatWebServerConfig.TomcatCondition.class)
public class TomcatWebServerConfig {
    @Bean("tomcatWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }

    static class TomcatCondition implements Condition {
        @Override
        public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
            return false; // 이 클래스를 빈으로 사용 안하겠다.
        }
    }
}



ClassUtils 사용

그럼 자동 구성 빈들을 변경할 때마다 matches 메소드의 반환 값을 바꿔줘야 하는가?

이 문제를 해결하기 위해 스프링 부트가 사용하는 방법 중 하나는 ‘해당 라이브러리가 프로젝트에 존재하는가?’ 이다.

참고로 Tomcat(또는 Jetty)ServletWebServerFactory.class 는 스프링 부트에 들어 있는 클래스이다. 이 클래스가 존재한다고 해서 라이브러리가 존재하는 것은 아니다.

톰켓 라이브러리의 가장 메인이 되는 클래스는 “org.apache.catalina.startup.Tomcat”, 제티 라이브러리의 가장 메인이 되는 클래스는 “org.eclipse.jetty.server.Server” 이다. 프로젝트 내에 이 클래스들의 존재 여부를 이용하여 어떤 컨테이너를 사용할지 결정해보자.

어떤 라이브러리(클래스)가 이 프로젝트에 포함되어 있는지 존재 여부는 어떻게 아는가?
스프링이 제공하는 유틸리티 중 ClassUtils이 존재한다. 이 클래스의 메소드를 이용해 어떤 클래스가 이 프로젝트에 포함되어 있는지 구분이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@MyAutoConfiguration
@Conditional(TomcatWebServerConfig.TomcatCondition.class)
public class TomcatWebServerConfig {
    @Bean("tomcatWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }

    static class TomcatCondition implements Condition {
        @Override
        public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
            return ClassUtils.isPresent("org.apache.catalina.startup.Tomcat", context.getClassLoader());
            // jetty 클래스는 아래와 같이 작성.
            // return ClassUtils.isPresent("org.eclipse.jetty.server.Server", context.getClassLoader());
        }
    }
}

이제 프로젝트에 포함되어 있는 라이브러리에 따라서 서블릿 컨테이너가 자동 구성을 다르게 해줄 것이다.
확인을 위해 build.gradle에서 dependency를 조작하며, 애플리케이션 실행 시 어떤 컨테이너를 사용하는지 확인해보자.

1
2
3
4
5
6
7
// Jetty로 실행 (tomcat을 exclude)
dependencies {
    implementation ('org.springframework.boot:spring-boot-starter-web') {
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
    }
    implementation 'org.springframework.boot:spring-boot-starter-jetty'
}
1
2
3
4
// Tomcat로 실행
dependencies {
    implementation ('org.springframework.boot:spring-boot-starter-web')
}



메타 애노테이션으로 리팩토링

Condition을 사용하는 톰켓 설정 클래스와 제티 설정 클래스는 아주 유사하게 생겼다. ClassUtils의 메소드에 들어가는 문자열 값만 다른 것 뿐이다. 따라서 메타 어노테이션을 이용하여 이 부분을 공통 어노테이션으로 리팩토링 하자.

1
2
3
4
5
6
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Conditional(MyOnClassCondition.class)
public @interface ConditionalMyOnClass {
    String value();
}
1
2
3
4
5
6
7
8
public class MyOnClassCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        Map<String, Object> attrs = metadata.getAnnotationAttributes(ConditionalMyOnClass.class.getName());
        String value = (String) attrs.get("value");
        return ClassUtils.isPresent(value, context.getClassLoader());
    }
}

이제 자동 구성 설정 파일에서 다음과 같이 사용할 수 있다.

1
2
3
4
5
6
7
8
@MyAutoConfiguration
@ConditionalMyOnClass("org.apache.catalina.startup.Tomcat")
public class TomcatWebServerConfig {
    @Bean("tomcatWebServerFactory")
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }
}



자동 구성 정보를 대체하기

사용자가 인프라 빈을 직접 빈으로 생성하고 싶을 때는 자동 구성이 다음과 같이 진행되면 된다.

  1. 사용자가 설정 클래스에서 빈으로 직접 생성
  2. 자동 구성 시에
    1. 해당 타입이 이미 빈으로 생성되어 있다면 → 무시
    2. 해당 타입이 빈으로 생성되어 있지 않다면 → 빈으로 생성

먼저 사용자가 설정 클래스에서 빈으로 직접 생성하는 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
@Configuration(proxyBeanMethods = false)
public class WebServerConfiguration {
    @Bean
    ServletWebServerFactory customerWebServerFactory() {
        TomcatServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();
        serverFactory.setPort(9090);
        return serverFactory;
    }
}

톰켓 서블릿 컨테이너를 직접 생성 후, 포트를 9090으로 설정 후 빈으로 등록하는 코드이다.

이제 자동 구성을 제어해야 한다. 해당 타입이 이미 빈으로 등록 되었는지 여부는 어떻게 알 수 있을까?

이 부분은 꽤나 로직이 복잡하기 때문에, 스프링 부트가 제공하는 애노테이션(@ConditionalOnMissingBean)을 사용해보자.

1
2
3
4
5
6
7
8
9
@MyAutoConfiguration
@ConditionalMyOnClass("org.apache.catalina.startup.Tomcat")
public class TomcatWebServerConfig {
    @Bean("tomcatWebServerFactory")
    @ConditionalOnMissingBean
    public ServletWebServerFactory servletWebServerFactory() {
        return new TomcatServletWebServerFactory();
    }
}

이런 패턴은 자주 쓰인다. 클래스 레벨에서는 ‘해당 라이브러리가 프로젝트에 포함되어 있는가?’ 를 체크하고, 빈 팩토리 메서드에서는 ‘해당 타입의 빈을 개발자가 구성 정보로 만들었는가?’ 를 체크하는 방법이다.



@Conditional을 확장한 스프링 부트의 애노테이션

@Conditional은 스프링 4.0부터 추가된 애노테이션이다. 이 애노테이션을 이용하여 만들어진 스프링 부트의 애노테이션들을 살펴보자

  1. @Profile
    • 운영 환경(개발, 배포, 테스트)에 따라서 빈들을 다르게 구성하고 싶을 때 사용하는 애노테이션이다.
  2. @ConditionalOnClass (주로 클래스 레벨에서 체크)
    • 프로젝트 내에 지정한 클래스의 존재 여부를 확인 후, 존재하면 해당 클래스가 유효하게 된다.
    • 이를 응용하여 라이브러리의 메인 클래스를 지정하면, 해당 라이브러리의 존재 여부에 따라 설정 클래스의 포함 여부를 결정할 수 있다.
    • 주로 @Configuration 클래스 레벨에서 사용한다.
    • 반대로 지정한 클래스가 없다면, 애노테이션이 붙은 클래스를 포함시켜주는 @ConditionalOnMissingClass도 존재한다.
  3. @ConditionalOnMissingBean, @ConditionalOnBean
    • 빈의 존재 여부를 기준으로 포함 여부를 결정한다. 이때 빈의 타입 또는 이름으로 지정할 수 있다.(생략 시 리턴 타입을 기준으로)
    • 등록 당시에 컨테이너의 등록된 빈 정보를 기준으로 체크하기 때문에, 자동 구성 사이에 적용하려면 설정 클래스의 적용 순서가 중요하다.
    • 하지만 개발자가 직접 빈을 등록하는 로직은 자동 구성보다 더 우선적으로 실행되기 때문에, 순서를 크게 고려하지 않아도 된다.
    • 클래스 레벨에서 @ConditionalOnClass로 1차 체크하고, 빈 레벨에서 @ConditionalOnMissingBean로 2차 체크하는 조합은 가장 대표적으로 사용되는 방식이다.
  4. @ConditionalProperty
    • 지정된 프로퍼티가 존재하고 값이 false가 아니면 포함 대상이 된다.
  5. @ConditionalOnResource
    • 지정된 리소스의 존재 여부에 따라서 조건을 확인한다.
  6. @ConditionalOnWebApplication, @ConditionalOnNotWebApplication
    • 해당 프로젝트가 웹 기술을 사용하는지의 여부를 확인한다.
  7. @ConditionalOnExpression
    • 스프링 SpEL의 처리 결과를 기준으로 포함 여부를 판단한다. 매우 섬세하게 설정할 수 있다.



Reference

해당 게시물은 인프런 - 토비의 스프링 부트 이해와 원리을 기반으로 작성되었습니다.

This post is licensed under CC BY 4.0 by the author.