스프링은 어떻게 @Bean의 싱글턴을 보장할까?

Jihoon Oh·2022년 11월 12일
2
post-thumbnail

스프링에는 싱글턴 레지스트리를 사용해서 실제로는 싱글턴 패턴이 아닌 빈의 싱글턴을 보장한다.라는 특징이 있습니다. (물론 프로토타입 빈의 경우 당연하게도 싱글턴이 아닙니다!) 싱글턴 패턴을 사용하게 되면 상속 불가, 테스트 어려움, 전역으로 상태 관리 등 다양한 단점이 있기 때문에 싱글턴 패턴의 장점을 유지하면서도 단점을 해소하기 위한 방법입니다.

다들 아시다시피 스프링 빈을 등록하는 방법으로는 클래스에 @Component 어노테이션을 붙이는 방법과 @Bean 어노테이션이 붙은 메서드를 작성하여 등록하는 두 가지 방법이 있습니다. 그런데 @Bean 어노테이션을 사용할 때, 이런 케이스가 있을 수 있습니다.

@Configuration
public class AppConfig {

    @Bean
    public MyRepository myRepository() {
        return new MyRepository();
    }

    @Bean
    public ServiceA serviceA() {
        return new ServiceA(myRepository());
    }
    
    @Bean
    public ServiceB serviceB() {
        return new ServiceB(myRepository());
    }
}

SerivceA와 ServiceB 모두 MyRepository를 필요로 합니다. 그래서 생성자에 MyRepository를 주입할 수 있도록 MyRepository를 반환하는 myRepository() 메서드를 호출해서 넣어주도록 하겠습니다. 이런 상황에서 얼핏 보면 serviceA 메서드와 serviceB 메서드가 각각 따로 myRepository 메서드를 호출하므로 매 번 새로운 MyRepository가 생성되어 주입될 수 있다고 생각할 수 있습니다. 그렇다면 싱글턴 보장이 깨지게 됩니다. 과연 그럴까요?

@SpringBootTest
class AppConfigTest {

    @Autowired
    private ServiceA serviceA;
    @Autowired
    private ServiceB serviceB;

    @Test
    void 싱글턴_테스트() {
        MyRepository myRepositoryA = serviceA.getMyRepository();
        MyRepository myRepositoryB = serviceB.getMyRepository();

        assertThat(myRepositoryA).isSameAs(myRepositoryB);
    }
}

만약 매 번 새로 myRepository 메서드를 호출해서 싱글턴 보장이 되지 않는다면 ServiceA와 ServiceB의 MyRepository의 주소값을 비교하는 위 테스트는 실패해야 합니다.

그러나 테스트를 성공합니다! 즉, myRepository 메서드를 여러번 호출하더라도 매 번 같은 MyRepository 인스턴스를 반환한다는 것을 알 수 있습니다.

어떻게 싱글턴을 보장하는지 확인해보기에 앞서, 조건을 달리해서 한 번 더 테스트를 진행해보도록 하겠습니다. 만약 @Bean 어노테이션을 사용하는 곳이 @Configuration이 아니라 @Component라면 어떻게 될까요?

@Component
public class AppConfig {

    @Bean
    public MyRepository myRepository() {
        return new MyRepository();
    }

    @Bean
    public ServiceA serviceA() {
        return new ServiceA(myRepository());
    }

    @Bean
    public ServiceB serviceB() {
        return new ServiceB(myRepository());
    }
}

친절하게도 인텔리제이가 경고를 띄워줍니다. 뭔가 싱글턴 보장을 못할 것 같다는 느낌이 오죠? 물론 컴파일은 되기 때문에 테스트를 진행해보도록 하겠습니다.

두 MyRepository가 서로 다른 객체여서 테스트에 실패합니다. 즉, @Component 안에서는 싱글턴 보장을 하지 않는다는 것을 알 수 있습니다. 그렇다면 @Configuration에 뭔가 특별한 처리를 해주기 때문에 싱글턴 보장이 된다는 것을 유추할 수 있습니다. 이를 알아보기 위해 @Configuration에 작성되어 있는 javadoc을 시작으로 타고 타고 들어가서 확인해보았습니다. 뭔가 빈 후처리를 통해 처리해줄 것 같다는 느낌이 드네요. javadoc에 적혀 있는 ConfigurationClassPostProcessor로 타고 들어가보겠습니다.

ConfigurationClassPostProcessor를 살펴보다보니 250번 라인에 다음과 같은 내용을 발견할 수 있었습니다.


	/**
	 * Prepare the Configuration classes for servicing bean requests at runtime
	 * by replacing them with CGLIB-enhanced subclasses.
	 */
	@Override
	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
		int factoryId = System.identityHashCode(beanFactory);
		if (this.factoriesPostProcessed.contains(factoryId)) {
			throw new IllegalStateException(
					"postProcessBeanFactory already called on this post-processor against " + beanFactory);
		}
		this.factoriesPostProcessed.add(factoryId);
		if (!this.registriesPostProcessed.contains(factoryId)) {
			// BeanDefinitionRegistryPostProcessor hook apparently not supported...
			// Simply call processConfigurationClasses lazily at this point then.
			processConfigBeanDefinitions((BeanDefinitionRegistry) beanFactory);
		}

		enhanceConfigurationClasses(beanFactory);
		beanFactory.addBeanPostProcessor(new ImportAwareBeanPostProcessor(beanFactory));
	}

주석을 확인해보겠습니다. 빈 요청에 대해 런타임에 CGLIB으로 처리된 서브클래스(즉, 프록시를 의미하겠죠?)를 제공하기 위한 준비를 하는 메서드라고 합니다. 여기서 우리는 @Bean에 대한 싱글턴을 보장하기 위해 CGLIB을 사용한다는 것을 알 수 있습니다. 이번에는 마지막 부분에 호출하는 enhanceConfigurationClasses 메서드를 확인해보도록 하겠습니다. 해당 메서드에는 다음과 같은 javadoc 주석이 달려 있습니다.

Post-processes a BeanFactory in search of Configuration class BeanDefinitions; any candidates are then enhanced by a ConfigurationClassEnhancer. Candidate status is determined by BeanDefinition attribute metadata.

ConfigurationClassEnhancer를 사용한다고 합니다. 실제로 enhanceConfigurationClasses 메서드 내부에 ConfigurationClassEnhancer를 생성해서 호출하는 코드가 들어있습니다.

		ConfigurationClassEnhancer enhancer = new ConfigurationClassEnhancer();
		for (Map.Entry<String, AbstractBeanDefinition> entry : configBeanDefs.entrySet()) {
			AbstractBeanDefinition beanDef = entry.getValue();
			// If a @Configuration class gets proxied, always proxy the target class
			beanDef.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
			// Set enhanced subclass of the user-specified bean class
			Class<?> configClass = beanDef.getBeanClass();
			Class<?> enhancedClass = enhancer.enhance(configClass, this.beanClassLoader);
			if (configClass != enhancedClass) {
				if (logger.isTraceEnabled()) {
					logger.trace(String.format("Replacing bean definition '%s' existing class '%s' with " +
							"enhanced class '%s'", entry.getKey(), configClass.getName(), enhancedClass.getName()));
				}
				beanDef.setBeanClass(enhancedClass);
			}
		}

마지막으로 enhancer로 타고 들어가서 확인해 보겠습니다. 먼저 주석입니다.

Enhances Configuration classes by generating a CGLIB subclass which interacts with the Spring container to respect bean scoping semantics for @Bean methods. Each such @Bean method will be overridden in the generated subclass, only delegating to the actual @Bean method implementation if the container actually requests the construction of a new instance. Otherwise, a call to such an @Bean method serves as a reference back to the container, obtaining the corresponding bean by name

@Bean 메서드에 대한 빈 범위 지정 의미를 존중하기 위해 Spring 컨테이너와 상호 작용하는 CGLIB 서브클래스를 생성하여 Configuration 클래스를 향상시킵니다. 이러한 각 @Bean 메소드는 생성된 서브클래스에서 재정의되며 컨테이너가 실제로 새 인스턴스의 구성을 요청하는 경우에만 실제 @Bean 메소드 구현으로 위임합니다. 그렇지 않으면 그러한 @Bean 메소드에 대한 호출은 컨테이너에 대한 참조 역할을 하여 이름으로 해당 Bean을 얻습니다.

이 주석만으로도 모든 의문이 해결되었습니다! ConfigurationClassEnhancer가 @Configuration이 붙은 빈을 CGLIB을 통해 내부 @Bean 메서드에 대한 특별한 후처리가 된 빈으로 만드는 것이었습니다! 그리고 이 특별한 후처리가 바로 @Bean메서드가 새 인스턴스를 요구하는 요청일 때만 실제 @Bean 메서드를 호출하고, 이후 호출 시에는 이미 만들어서 컨테이너에 등록한 빈을 반환해주는 역할을 하는 것이었습니다.


	/**
	 * Loads the specified class and generates a CGLIB subclass of it equipped with
	 * container-aware callbacks capable of respecting scoping and other bean semantics.
	 * @return the enhanced subclass
	 */
	public Class<?> enhance(Class<?> configClass, @Nullable ClassLoader classLoader) {
		if (EnhancedConfiguration.class.isAssignableFrom(configClass)) {
			if (logger.isDebugEnabled()) {
				logger.debug(String.format("Ignoring request to enhance %s as it has " +
						"already been enhanced. This usually indicates that more than one " +
						"ConfigurationClassPostProcessor has been registered (e.g. via " +
						"<context:annotation-config>). This is harmless, but you may " +
						"want check your configuration and remove one CCPP if possible",
						configClass.getName()));
			}
			return configClass;
		}
		Class<?> enhancedClass = createClass(newEnhancer(configClass, classLoader));
		if (logger.isTraceEnabled()) {
			logger.trace(String.format("Successfully enhanced %s; enhanced class name is: %s",
					configClass.getName(), enhancedClass.getName()));
		}
		return enhancedClass;
	}

이 역할은 위에 보이는 enhance 메서드에서 진행하는 것을 볼 수 있습니다.

이렇게 Configuration 클래스 내부에서 생성하는 빈들에 대해서 스프링이 어떻게 싱글턴을 보장하는지에 대해 알아보았습니다. 내부적으로 더 많은 자세한 코드들이 있어서 모든 부분을 이해하지는 못했지만, 최소한 공식적으로 CGLIB을 통해 후처리하여 최초 빈 생성 요청시에만 빈 생성 메서드를 호출한다라는 것을 알게 되었습니다.

그런데 @Configuration을 사용하면서 이 후처리를 진행하지 않을 수는 없을까요? 스프링 5.2부터는 @Configuration 어노테이션 안의 속성값인 proxyBeanMethods를 false로 지정하면 된다고 합니다. 실제로 false로 지정하고 테스트 해보면 테스트가 실패하는 것을 볼 수 있습니다.

Specify whether @Bean methods should get proxied in order to enforce bean lifecycle behavior, e.g. to return shared singleton bean instances even in case of direct @Bean method calls in user code. This feature requires method interception, implemented through a runtime-generated CGLIB subclass which comes with limitations such as the configuration class and its methods not being allowed to declare final.
The default is true, allowing for 'inter-bean references' via direct method calls within the configuration class as well as for external calls to this configuration's @Bean methods, e.g. from another configuration class. If this is not needed since each of this particular configuration's @Bean methods is self-contained and designed as a plain factory method for container use, switch this flag to false in order to avoid CGLIB subclass processing.
Turning off bean method interception effectively processes @Bean methods individually like when declared on non-@Configuration classes, a.k.a. "@Bean Lite Mode" (see @Bean's javadoc). It is therefore behaviorally equivalent to removing the @Configuration stereotype.
Since:
5.2

이 주석에서 알 수 있는 또 하나의 사실은, Configuration 클래스와 빈 생성 메서드들이 final이면 안된다는 것입니다. final이게 될 경우 서브클래스를 만들 수 없기 때문에 당연한 일이라고 생각합니다.

참고 자료

http://www.javabyexamples.com/cglib-proxying-in-spring-configuration

함께 고민해준 수달, 루키 감사합니다 :)

profile
Backend Developeer

2개의 댓글

comment-user-thumbnail
2022년 11월 12일

끝도 없는 프록시의 늪..

답글 달기
comment-user-thumbnail
2022년 11월 23일

세 분 너무 멋지네요..👍

답글 달기