Spring - @Configuration을 파헤쳐 보자!

DevSeoRex·2023년 3월 12일
2
post-thumbnail

👽 @Configuration은 무엇일까?

Spring Legacy Project를 사용하지 않고, Spring Boot를 사용하면서 XML 기반의 설정을 사용하기 보다는 대부분 애너테이션 기반의 설정을 이용해 대부분의 설정 작업을 해왔습니다.

@Configuration은 @Bean 애너테이션을 붙인 메서드들을 모아놓은 설정 클래스에 붙여주는 것으로만 알고 있었고, 어떻게 동작하는지에 대해서는 자세히 알지 못했습니다.

이번 포스팅에서는 @Configuration 애너테이션을 사용하는 방법과 그 동작 방식에 대해서 다뤄보고자 합니다.

👀 @Configuration

@Configuration 애너테이션의 소스를 확인해보면 아래와 같이 정의가 되어 있습니다.

  • @Target(ElementType.TYPE) : 클래스 레벨에만 붙일 수 있다는 것을 의미합니다. 메서드나 필드에는 이 애너테이션을 적용할 수 없다는 것을 의미합니다.
  • @Retention(RetenionPolicy.RUNTIME) : 이 애너테이션의 라이프 사이클을 정의하는 애너테이션입니다. RUNTIME 즉, 컴파일 이후 메모리에 클래스가 로딩 될때도 이 애너테이션이 유지되어야 한다는 의미입니다.
  • @Documented : 이 애너테이션의 정보가 javadoc으로 작성된 문서에 포함되게 한다는 의미입니다.
  • @Component : @Component 애너테이션은 이 애너테이션이 붙어있는 클래스의 경우 컴포넌트 스캐너의 스캔 대상으로 삼겠다는 의미입니다. 즉, @Configuration 애너테이션은 @Component 애너테이션을 메타 애너테이션으로 가지고 있으므로, @Configuration을 붙인 클래스는 컴포넌트 스캐너의 스캔 대상이 됩니다.

그렇다면 @Configuration이 어떻게 동작하게 되는지 테스트 코드를 작성해서 알아보겠습니다.

😛 @Configuration Test

먼저 테스트를 진행할 테스트 클래스를 하나 만들고, 그 내부에 클래스 3개를 정의하겠습니다.


public class TestConfiguration {


    // Bean1 클래스 정의
    static class Bean1 {

        private final Common common;

        Bean1(Common common) {
            this.common = common;
        }

    }


    // Bean2 클래스 정의
    static class Bean2 {

        private final Common common;

        Bean2(Common common) {
            this.common = common;
        }
    }

    // Common 클래스
    static class Common {}
}

Bean1과 Bean2 두개의 클래스를 정의하고, 각 클래스들은 Common 클래스를 의존하고 있습니다.

이제 Bean1과 Bean2 그리고 Common 클래스를 Bean으로 등록해주는 설정 클래스를 하나 만들겠습니다.

	@Configuration
    static class Config1 {

        @Bean
        Common common() {
            return new Common();
        }

        @Bean
        Bean1 bean1() {
            return new Bean1(common());
        }

        @Bean
        Bean2 bean2() {
            return new Bean2(common());
        }
    }

Bean1과 Bean2의 객체를 생성해주는 메서드인 bean1( )과 bean2( )는 내부적으로 Common( ) 메서드를 호출해 Common 객체를 주입받고 있습니다.

Spring Container의 도움없이 Config1의 객체를 생성해서, Bean1과 Bean2 내부에 있는 Common 객체가 같은 객체인지 테스트 하는 코드를 작성해보겠습니다.

	@Test
    void notUsingApplicationContext() {

        // Config1 클래스를 객체 생성
        Config1 config = new Config1();

        // Bean1 객체를 반환
        Bean1 bean1 = config.bean1();

        // Bean2 객체를 반환
        Bean2 bean2 = config.bean2();

        // Bean1이 가지고 있는 Common과 Bean2가 가지고 있는 Common이 같은지 비교
        Assertions.assertThat(bean1.common).isSameAs(bean2.common);
    }

작성한 테스트 코드를 실행해보겠습니다.


테스트는 당연히 실패합니다. 그 이유는 간단합니다. 내부적으로 Bean1과 Bean2 두 클래스에서 Common 객체를 생성하는 메서드를 각각 호출해서 Common 객체를 반환 받았기때문에 Bean1과 Bean2가 가지고 있는 Common 객체의 인스턴스는 다를 수 밖에 없습니다.

Spring에서는 따로 설정을 하지 않는 경우에는, 생성되는 객체를 싱글톤으로 관리하게 됩니다. 따라서 Spring의 도움을 받았다면 Common 객체는 한번만 생성되고 싱글톤 패턴에 의해 재사용되어야 하므로, 두 Common 객체의 인스턴스는 같아야합니다.

그렇다면 지금부터는 Spring의 도움을 받을 수 있는 테스트 코드를 작성해보겠습니다.

	@Test
    void usingApplicationContext() {

        // ApplicationContext 를 사용
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext();


        ac.register(Config1.class);
        ac.refresh();

        Bean1 bean1 = ac.getBean(Bean1.class);
        Bean2 bean2 = ac.getBean(Bean2.class);

        Assertions.assertThat(bean1.common).isSameAs(bean2.common);
    }

여기서 작성된 테스트 코드를 살펴보면 AnnotationConfigApplicationContext 클래스를 사용하게 됩니다.

AnnotationConfigApplicationContext는 애너테이션을 기반으로 설정 정보를 구성하여, XML을 사용하지 않고 스프링 컨테이너를 사용할 수 있도록 해주는 클래스입니다.

이전에 작성한 테스트 코드는 Config1 클래스의 객체를 생성해서 Bean1,Bean2의 생성 메서드를 호출해서 객체를 반환 받았지만, 지금 작성된 코드는 ApplicationContext의 getBean 메서드를 이용해서 객체를 반환 받았습니다.

테스트를 검증하는 코드는 이전에 작성했던 코드와 동일합니다. 테스트를 실행해보겠습니다.


테스트가 성공한 것을 볼 수 있습니다. Spring Container는 객체를 생성할때 싱글톤 스코프를 가지도록 객체를 관리하므로, 항상 동일한 인스턴스임을 보장받을 수 있습니다.

개발자가 따로 싱글톤으로 객체를 관리하는 코드를 작성하거나 다른 설정을 해야하는 노력 없이도 싱글톤으로 객체를 관리할 수 있는 방법을 Spring을 통해 이용할 수 있는 것입니다.

😝 @Configuration의 Proxy 사용

@Configuration의 클래스를 열어 보았을때 확인하지 않았던 부분이 있습니다.


바로 이 부분인데요 boolean 타입의 proxyBeanMethods라는 변수의 default 값이 true로 되어있습니다.
어느 정도 눈치 채셨겠지만 @Configuration을 클래스에 붙이게되면 CGLIB 라이브러리를 이용한 Proxy 객체를 사용합니다.

그렇다면 어떻게 Proxy를 이용해서 싱글톤을 보장받을 수 있는 걸까요?

내부적으로 인스턴스 호출시 메서드를 가로채서 객체 생성 메서드를 한번만 호출하고 싱글톤으로 관리하게 되는 것입니다.

내부 동작 방식과 100% 일치하지 않을 수 있지만 싱글톤패턴을 사용하는 Config1의 Proxy 클래스 ProxyConfig 클래스를 만들어 보았습니다.

	@Configuration
    static class ProxyConfig extends Config1 {

        private Common common;

        @Override
        Common common() {

            if(common == null) this.common = super.common();

            return common;
        }

    }

ProxyConfig 클래스는 내부에 가지고 있는 Common 객체가 null일 경우에만 부모 클래스인 Config1의 common( ) 메서드를 호출하고 그것이 아니라면, 생성되어있는 Common 객체를 재사용합니다.

ProxyConfig 클래스를 이용해 Bean1과 Bean2 클래스의 객체를 생성한다면 그 과정이 아래와 같을 것입니다.

  1. Bean1의 생성 메서드인 bean1( ) 메서드를 호출
  2. bean1( ) 메서드의 내부에서 common( ) 메서드를 호출
  3. 현재 오버라이딩 되어있는 ProxyConfig의 common( ) 메서드 호출
  4. 현재 ProxyConfig 내부의 Common이 null 이므로 Common 객체를 생성(super.common( ) 이용)
  5. Bean2의 생성 메서드는 bean2( ) 메서드를 호출
  6. bean2( ) 메서드의 내부에서 common( ) 메서드를 호출
  7. 현재 ProxyConfig 내부의 Common은 null이 아니므로 ProxyConfig 내부의 Common 객체를 반환

즉, Common 객체를 새로 생성해 반환해주는 Config1 클래스의 common( ) 메서드는 결과적으로 단 한번만 실행되게 됩니다. 따라서 Common 객체는 싱글톤으로 관리되고 있으므로, 늘 인스턴스가 동일해야 합니다.

예상한대로 동작하는지 테스트 코드를 통해 테스트를 해보도록 하겠습니다.

	@Test
    void usingProxy() {

        ProxyConfig config = new ProxyConfig();

        Bean1 bean1 = config.bean1();

        Bean2 bean2 = config.bean2();

        Assertions.assertThat(bean1.common).isSameAs(bean2.common);
    }

첫번째 작성했던 테스트 코드와 동일해보이지만, 이 테스트 코드는 프록시를 이용하여 Common 객체를 싱글톤으로 관리하고 있습니다. 테스트 코드를 실행해 보겠습니다.

테스트가 성공하고, Bean1.common과 Bean2.common의 인스턴스가 동일한 것을 확인할 수 있습니다.

🤖 @Configuration을 파헤쳐보며..

@Configuration의 동작원리를 공부하고 테스트 코드를 작성해보면서 Proxy 기술과 또 Proxy를 사용하기 위한 CGLIB 라이브러리등 많은 핵심 원리들이 들어가 있다는 것을 알게 되었습니다.

모든 기술은 사용법을 익히고 아는 것도 중요하지만, 그 내부에서 어떤 원리로 동작이 되고 무슨 일이 일어나고 있는지를 알아가는 것이 중요하다는 것을 깨닫게 되는 하루였습니다.

앞으로도 사용법을 넘어선 깊은 탐구를 하는 개발자로 성장하는 날까지 열심히 해보겠습니다!

출처 : 토비의 스프링 부트 이해와 원리 - 인프런

0개의 댓글