스프링이 빈을 컨테이너에 등록하는 방법 뜯어보기

Choi Wontak·2025년 9월 10일

뜯어보기

목록 보기
1/1
post-thumbnail

궁금한 점

횡단 관심사를 분리하여 처리하기 위해 스프링 AOP를 적용하다가, 문득 스프링이 이 과정을 어떻게 처리하는지에 대한 의문이 생겼다.

🧐 스프링은 AOP를 어떻게 구현할 수 있었을까?

해답

스프링은 왜 컨테이너를 사용하는가

IoC(제어의 역전, Inversion of Control)

보통을 객체를 만들 때 개발자가 new 해서 객체를 직접 생성하고 연결한다.
하지만 스프링에서는 컨테이너가 객체를 대신 만들고 주입한다.

개발자는 "어떤 객체가 필요하다" 정도만 선언하면, 컨테이너가 알아서 연결해준다.

즉, 객체의 생성 책임과 의존성 연결 책임을 컨테이너로 역전한다는 것이다.

이렇게 하면 좋은 건 객체 간 느슨한 결합을 가져가기 때문에, 변경의 전파를 막을 수 있다.

DI(의존성 주입, Dependency Injection)

IoC의 구현 방식 중 연결(주입) 부분에 해당하는 원칙이다.

A 객체가 B 객체를 필요로 할 때, 컨테이너가 B를 찾아서 A에 주입해준다.

이 덕분에 개발자는 객체 생성 로직에 신경 안 쓰고, 인터페이스 기반으로 프로그래밍을 할 수 있다.

스프링은 객체의 생명주기와 주입의 책임을 역전시키기 위한 역할로 "컨테이너"를 사용하였다.


스프링은 왜 컨테이너에 빈을 싱글톤으로 등록하는가

스프링은 컨테이너에 자바 객체인 빈을 등록한다.
그런데 왜 싱글톤으로 등록할까?

(1) 성능 최적화

객체를 매번 new 하면 메모리, GC, 생성 비용이 발생한다.

대부분의 서비스 객체는 상태를 가지지 않는 stateless 객체인데 매번 만들면 메모리 낭비가 발생한다.

이런 객체는 여러 요청에서 공유해도 문제가 없으니, 하나만 만들어 두고 계속 쓰는 게 효율적

(2) 일관된 의존성 관리

컨테이너가 하나의 객체만 관리하면, 어디서 가져다 쓰든 항상 같은 객체를 보게 된다.

예를 들어 OrderService가 UserRepository를 주입받았을 때, 다른 서비스에서도 같은 UserRepository를 보장하므로 데이터 접근 일관성이 보장된다.

(3) 객체 생명주기 제어

스프링이 빈을 싱글톤으로 보장하면, 개발자가 객체 생성과 파괴를 직접 관리하지 않아도 된다.

컨테이너가 애플리케이션 시작 시 객체를 미리 만들어두고, 애플리케이션 종료 시 정리하므로 안정적인 자원 관리가 가능하다.


스프링은 어떻게 컨테이너에 빈을 등록할까?

스프링이 빈을 컨테이너에 등록하게 하는 방법은 크게 세 가지가 있다.

XML
xml 파일을 통해 빈을 등록하는 방법이 있다.

<!-- applicationContext.xml -->
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans 
           http://www.springframework.org/schema/beans/spring-beans.xsd">

    <!-- MyService 빈 등록 -->
    <bean id="myService" class="com.example.MyService"/>

    <!-- OrderService 빈 등록, MyService 의존성 주입 -->
    <bean id="orderService" class="com.example.OrderService">
        <constructor-arg ref="myService"/>
    </bean>

</beans>

자바 코드가 아니라서 언어 독립적이라는 장점은 있으나
가독성도 떨어지고 타입 안정성도 없어서 요즘은 거의 사용되지 않는다고 한다.

@Component

어노테이션이 클래스 단위로 붙는다.

@ComponentScan에 의해 빈으로 자동 등록되며,
스프링이 알아서 new 처리를 해서 관리된다.

@Bean

@Configuration이 선언된 클래스 내에서 메서드를 통해 명시적으로 등록된다.

개발자가 new 해서 반환한 객체를 스프링이 관리한다.

그런데 꼭 @Configuration 어노테이션이 붙은 클래스 내에서만 사용 가능할까?

그렇지 않다.
@Configuration은 내부적으로 CGLIB 프록시를 생성해서, @Bean 메서드를 호출할 때 싱글톤 보장을 해준다.

만약 @Configuration이 없다면, 빈은 등록되지만 Config 클래스 내부에서 다시 사용될 때 싱글톤이 아니라 호출될 때 마다 new를 하는 방식으로 반환된다.

@Component
public class AppConfig {
    @Bean
    public MyService myService() {
        return new MyService();
    }

    @Bean
    public OrderService orderService() {
        return new OrderService(myService());
    }
}

얘를 들면 이 상황에서,
OrderService를 등록할 때 주입되는 myService()의 경우
위에서 컨테이너에 등록된 MyService를 가져오는 게 아니라 단순히 myService()를 호출해 new를 한 번 더 해서 가져온다는 것이다.

이것을 방지하기 위해 CGLIB이 AppConfig의 프록시 객체를 만드는 방법을 알아보자.


스프링은 어떻게 설정 클래스를 찾아내는가

ConfigurationClassPostProcessor.java

postProcessBeanDefinitionRegistry() 메서드이다.
여기에서는 스프링 컨테이너 초기화 과정에서 다른 빈들이 인스턴스화되기 전에 빈 정의를 추가하거나 수정할 수 있는 권한을 갖는다.

모든 설정 클래스 처리의 시작 부분이다.

클래스 내부의 processConfigBeanDefinitions() 메서드에서
ConfigurationClassParserparse() 메서드를 호출한다.

ConfigurationClassParser.java

빈 대상인 클래스의 사전 정보를 분석한다.

parse() 메서드를 보면 전달 받은 Config 클래스 후보들을 순회한다.

  1. AnnotatedBeanDefinition
    클래스에 붙은 어노테이션 정보(AnnotationMetadata)를 이미 내부에 갖고 있는 경우이다.
    컴포넌트 스캔으로 찾은 @Component, @Configuration 클래스들이 여기에 해당한다.
    이 경우, 클래스를 또 로딩할 필요 없이 이미 준비된 어노테이션 메타데이터(annotatedBeanDef.getMetadata())를 직접 다음 parse 메서드로 넘긴다.

  2. AbstractBeanDefinition
    어노테이션 메타데이터는 없지만, BeanDefinition 내부에 이미 로딩된 Class 객체가 있는 경우이다.
    이때는 getBeanClass()로 Class 객체를 꺼내서 다음 parse 메서드로 전달한다.
    이 parse 메서드는 전달받은 Class 객체를 리플렉션을 통해 분석한다.

  3. 빈 이름만 있는 경우
    xml 처럼 빈 이름만 있고 클래스 정보는 직접 가져와야 하는 경우이다.
    parse()에서 클래스 로딩을 먼저 수행한 후 정보를 넘긴다.

마지막으로 DeferredImportSelectorHandler 진행 시켜 @Import가 붙어있는, 추가적으로 빈으로 등록해야되는 클래스들을 처리한다.

앞선 parse 메서드가 호출하는 같은 클래스의 processConfigurationClass() 메서드이다.

이미 parse 처리된 적이 있는 클래스의 경우 우선 순위에 의해 갈아끼운다.

우선순위 규칙
일반적으로 컴포넌트 스캔 등을 통해 직접 발견된 설정(non-imported)이 @Import를 통해 간접적으로 포함된 설정보다 우선순위가 높다.

이후 do-while 루프를 통해 doProcessConfigurationClass 메서드 작업을 마친 후, 자신이 파싱한 클래스의 부모 클래스를 반환한다.
while (sourceClass != null) 조건은 부모 클래스가 더 이상 없을 때까지(즉, java.lang.Object에 도달할 때까지) 루프를 계속 돌린다.

처리된 클래스는 configurationClasses 맵에 저장한다.

doProcessConfigurationClass()

특정 설정 클래스(sourceClass) 하나를 받아서,
그 안에 선언된 각종 스프링 설정 관련 어노테이션들(@PropertySource, @ComponentScan, @Import, @Bean 등)을 하나씩 순서대로 찾아내어 파싱하고,
그 결과를 configClass 객체에 누적하는 역할을 한다.

클래스 내부에 정의된 중첩 클래스(nested class)나 내부 클래스(inner class)가 @Configuration 같은 설정 클래스일 수 있으므로, 이들을 먼저 재귀적으로 파싱한다.
@Component가 붙어있을 때까지만 호출하여
일반 자바 클래스의 중첩 클래스까지 모두 스캔하는 것을 방지한다.

@PropertySource 어노테이션을 찾아 .properties 파일의 위치를 알아낸다.
그리고 PropertySourceRegistry를 통해 해당 파일의 내용을 읽어와 스프링의 Environment 객체에 등록한다.
이를 통해 @Value("${...}") 구문으로 프로퍼티 값을 주입할 수 있게 된다.

@ComponentScan 어노테이션에 지정된 패키지 경로를 기반으로 컴포넌트 스캔을 실행한다.

componentScanParser.parse()를 호출하여 지정된 패키지 내의 @Component, @Service, @Repository 등을 찾아 BeanDefinition으로 만든다.

스캔을 통해 새로 발견된 클래스들 중에 @Configuration 같은 또 다른 설정 클래스가 있는지 확인한다.

만약 있다면, parse() 메서드(가장 처음 분석했던 진입점 메서드)를 재귀적으로 호출하여 새로운 설정 클래스의 파싱을 처음부터 다시 시작한다.

@Import: 다른 설정 클래스(@Configuration), ImportSelector, 또는 일반 클래스를 가져와 빈으로 등록하도록 처리한다.

@ImportResource: XML 설정 파일(applicationContext.xml 등)을 불러와 그 안에 정의된 빈들을 등록하도록 처리한다.

retrieveBeanMethodMetadata를 통해 클래스에 정의된 모든 메서드를 스캔하여 @Bean 어노테이션이 붙은 메서드만 찾아낸다.

찾아낸 각 @Bean 메서드의 메타데이터(메서드 이름, 반환 타입 등)를 BeanMethod 객체로 감싸서, configClass 객체 내부의 beanMethods 리스트에 차곡차곡 쌓아둔다.
이 정보는 나중에 ConfigurationClassBeanDefinitionReader가 실제 BeanDefinition을 생성할 때 사용한다.

processInterfaces: 클래스가 구현한 인터페이스에 @Bean이 붙은 default 메서드가 있을 수 있으므로, 이를 처리한다.

마지막으로, 클래스가 부모 클래스를 상속받았다면 부모 클래스의 SourceClass 객체를 반환한다.
이 반환 값은 이 메서드를 호출했던 processConfigurationClass의 do-while 루프의 조건이 되어 부모 클래스에 대한 파싱을 계속 이어나가게 만든다.

더 이상 거슬러 올라갈 부모 클래스가 없으면(java.lang.Object에 도달하면) null을 반환하여 do-while 루프를 종료시킨다.


스프링이 클래스를 빈으로 등록하는 방법

여기까지가 스프링이 설정 클래스를 찾아내고, 그 안에 빈이 될 클래스들(@Bean 메서드, @Import, @ComponentScan 등)을 찾아내는 과정이었다.
이제 찾아낸 설정 클래스들을 빈으로 등록하는 과정을 알아보자.

ConfigurationClassPostProcessor.java

다시 처음의 ConfigurationClassPostProcessor 클래스로 돌아가보자.
Parser의 parse 이후에는 ConfigurationClassBeanDefinitionReader라는 Reader가 등장한다.
여기서 Reader의 loadBeanDefinitions() 메서드를 호출하는데, 얘가 바로 Parser가 찾아온 클래스 정보를 바탕으로 빈을 만들어주는 부분이다.

ConfigurationClassBeanDefinitionReader.java

@ConditionalOnBean과 같은 조건이 있을 수 있기 때문에 shouldSkip 메서드로 검증한다.

만약 이 설정 클래스가 @ComponentScan이 아닌 @Import 어노테이션을 통해 포함된 것이라면, 설정 클래스 그 자체도 하나의 빈으로 컨테이너에 등록한다.

왜냐하면 @Import된 설정 클래스도 내부에 의존성을 주입받는 등 스프링 컨테이너의 관리가 필요할 수 있기 때문.
@ComponentScan으로 찾은 클래스는 이미 빈 정의가 등록된 상태이므로 이 로직이 필요 없다.

configClass 객체 안에 파서가 찾아두었던 모든 @Bean 메서드(BeanMethod) 목록을 순회한다.

각 BeanMethod에 대해 loadBeanDefinitionsForBeanMethod 메서드를 호출하여, 메서드 정보를 기반으로 BeanDefinition을 생성하고 레지스트리에 등록한다.

@ImportResource 어노테이션으로 지정된 XML 설정 파일이 있었다면, 해당 파일들을 읽어서 그 안에 bean 태그로 정의된 빈들을 모두 등록한다.

@Import를 통해 등록된 ImportBeanDefinitionRegistrar 구현체가 있다면 이를 실행한다.

ImportBeanDefinitionRegistrar는 사용자가 자바 코드를 통해 동적으로 빈 정의를 직접 등록할 수 있게 해주는 기능이라고 한다.
사용자가 작성한 해당 로직을 직접 실행시켜 주는 부분이 마지막에 등장한다.

loadBeanDefinitionsForBeanMethod()

굉장히 긴 메서드이지만, 중요한 부분만 뜯어보자.

@Bean(name = {"beanA", "aliasB"}) 와 같이 이름이 명시적으로 주어졌는지 확인한다.

이름이 있으면 첫 번째 이름을 기본 beanName으로 사용한다.

이름이 없으면, 메서드 이름을 beanName으로 사용한다.

name 속성에 여러 이름이 주어진 경우, 첫 번째를 제외한 나머지 이름들은 모두 별칭으로 컨테이너에 등록한다.

마침내 빈의 설계도인 BeanDefinition 객체를 생성하는 순간이다..!
이 객체는 어떤 클래스에서, 어떤 메서드를 통해 빈이 정의되었는지 등의 출처 정보를 담고 있다.

@Bean 메서드가 빈을 생성하는 "팩토리" 역할을 한다는 것을 명시하는 부분이다.

static 메서드인 경우: 팩토리는 클래스 자체. setBeanClass를 통해 클래스를 지정한다.

인스턴스 메서드인 경우: 팩토리는 설정 클래스의 인스턴스(객체). setFactoryBeanName을 통해 설정 클래스 빈의 이름을 지정해 나중에 스프링이 해당 빈의 인스턴스를 찾아 메서드를 호출하도록 한다.

@Bean 메서드에 함께 사용된 다른 중요한 어노테이션들의 속성을 BeanDefinition에 채워 넣는다.

processCommonDefinitionAnnotations: @Lazy, @Primary, @DependsOn 등 공통 어노테이션을 처리한다.

initMethod, destroyMethod: 빈의 생명주기 콜백 메서드 이름을 설정한다.

@Scope: @Scope("prototype")과 같이 스코프를 설정하고, 필요하다면 프록시(proxy) 객체를 생성하도록 처리한다.

모든 설정이 완료된 BeanDefinition 객체를 BeanDefinitionRegistry에 최종적으로 등록한다.

이 호출이 성공적으로 끝나면, 스프링 컨테이너는 beanName을 가진 빈의 존재를 공식적으로 인지하게 되며, 애플리케이션의 다른 곳에서 이 빈을 주입받아 사용할 수 있게 된다.

여기서 빈이 등록되는 registry는,
DefaultListableBeanFactory이다.

이 팩토리 클래스는 내부적으로 빈들을 싱글톤으로 관리한다.
빈을 넣을 때 말고 빈을 가져오기 위한 getBean이 호출될 때 싱글톤 캐시를 조회하고 없으면 생성한다.


@Configuration이 붙은 클래스가 등록될 때 벌어지는 일

ConfigurationClassPostProcessor에서 위의 과정을 모두 마치면
enhanceConfigurationClasses() 메서드를 실행한다.

설정파일의 beanDefinition에서 CONFIGURATION_CLASS_ATTRIBUTE 정보를 가져온다.

이 상태는 두 가지로 나뉘는데,

  • CONFIGURATION_CLASS_LITE
  • CONFIGURATION_CLASS_FULL

이다.

FULL 모드인 경우, configBeanDefs에 현재 처리하는 빈을 넣고

ConfigurationClassEnhancer라는 클래스를 통해 enhance 메서드를 실행한다.

ConfigurationClassEnhancer.java

@Configuration 어노테이션이 붙은 클래스의 프록시 객체 생성은
ConfigurationClassEnhancer가 관리한다.

@Bean이 붙은 메서드가 호출될 때 내부에서 등록된 BeanMethodInterceptor가 이를 가로채 실제 구현체를 생성한다.

BeanMethodInterceptor.java

내부가 살짝 복잡하긴 한데 가장 중요한 intercept() 메서드를 들여다보면, 분기가 세 개로 나뉘는 것을 알 수 있다.

팩토리 빈을 처리하는 부분이다.
현재 처리하는 메서드가 팩토리 빈을 처리하고 있다면, 내부의 빈을 프록시로 감싸 등록한다.

팩토리 빈
팩토리 빈이란, 다른 빈을 생성하는 '팩토리 클래스'가 빈으로 등록된 경우이다.

보통 스프링이 빈을 생성하는 방법은 리플렉션을 이용하여 디폴트 생성자에 접근해 객체를 만들어 등록한다.

그런데 생성자가 private이거나 동적으로 프록시 되는 경우 스프링이 강제로 접근할 수 없다.

그래서 해당 클래스를 빈으로 만들어주기 위해서는 팩토리 클래스가 필요하다.
팩토리 클래스의 getObject() 메서드가 반환하는 클래스를 빈으로 등록해준다.

ScopedProxy
코드 속 두 번째 if문을 보면, ScopedProxy 라는 개념이 나온다.
이건 빈과 빈 사이의 생명 주기가 다른 경우에도 싱글톤 객체를 주입하기 위해 속이 비어있는 가짜 객체를 넣어주는 상황이다.

즉, ScopedProxyFactoryBean은 현재 상황에서 결정할 수 없는 객체를 동적으로 생성하기 위한 팩토리 빈이므로,
여기서 생성하는 빈은 등록 시점에 실제 클래스가 사용되지 않는다.
그런 상황에서 팩토리 빈은 사용 시점에 반환되는 클래스가 동적으로 지정되기 때문에, 컨테이너에 싱글톤으로 등록되지 않도록 if 문을 통해 싱글톤 프록시 처리를 무시한다.

갑작스러운 처음 보는 개념들의 등작으로 좀 정리해보았는데,
이제 다시 코드로 돌아오자...

isCurrentlyInvokedFactoryMethod를 통해 현재 생성되어야 하는 빈인지를 확인하고, 맞다면 super (원본 Configuration 클래스의 @Bean 메서드)를 실행한다.

일반적인 빈 참조의 경우, resolveBeanReference를 실행하여 컨테이너에 있는 빈을 반환한다.

잠시 isCurrentlyInvokedFactoryMethod를 알아보자.
스프링이 @Bean 메서드를 호출해서 빈을 생성할 때 어떤 메서드를 실행 중인지 ThreadLocal에 저장해 둔다.
그래서 해당 메서드 명이 '지금' 처리 중인 메서드 명과 동일한지 확인한다.

예를 들어 위의 예시에서 @Bean에 의해 myService()가 실행되는 경우 현재 처리 중인 메서드도 myService이기 때문에 true,
orderService()가 myService()를 실행한 경우, 현재 실행 중인 메서드와 처리 중인 메서드의 이름이 다르기 때문에 false를 반환한다.

이제 대망의 resolveBeanReference() 메서드를 보자.

resolveBeanReference()

빈의 순환 참조를 허용하기 위한 코드도 있지만, 메인이 되는 내용은 아니기 때문에 나중에 정리하겠다.

이 메서드는 위에서 보았듯이, 일반적인 빈 참조의 경우에 호출된 빈을 컨테이너에서 가져오기 위한 메서드이다.

대부분의 경우 @Bean 메서드 호출 시 인자(beanMethodArgs)가 없으므로 beanFactory.getBean(beanName)을 통해 이름으로 빈을 조회한다.

만약 프로토타입 빈처럼 @Bean 메서드에 인자를 전달하는 경우 인자를 사용하는 getBean 오버로딩 메서드를 사용한다.

이제 위에서 가져온 빈과, 현재 실행 중인 @Bean 메서드의 이름을 통해 의존 관계를 스프링 컨테이너에 공식적으로 등록한다.


최종 정리

아직 AOP 근처에도 못 갔다..
스프링이 POJO로 설정 클래스들을 만드는 과정이 복잡하지만
자바의 정수를 보는 느낌이라 새로운 것 같다

다음 포스팅에서 계속...

profile
백엔드 주니어 주니어 개발자

0개의 댓글