본문 바로가기

Backend/Spring

[Spring] @Configuration의 숨겨진 마법: 순환 참조부터 CGLIB 프록시까지 딥 다이브

[Spring 딥다이브] @Configuration 안 쓰면 픽픽 쓰러질까..? 스프링 컨테이너와 CGLIB 프록시 파헤치기

스프링으로 개발을 하다 보면 정말 당연하게 @Configuration을 붙이고 내부에서 @Bean을 등록하게 되죠.
그런데 문득 이런 의문이 들 때가 있습니다. "어.. 이거 안 붙이고 그냥 @Component 쓰면 안 되나..?"

 

분명히 앱은 정상적으로 뜰 것 같은데, 왜 다들 굳이 @Configuration을 써야만 한다고 강조하는 걸까요? 만약 실수로 빼먹는다면 우리의 서비스는 트래픽을 맞고 픽픽 쓰러지는 대참사를 겪게 될까요?

 

이 글에서는 '스프링이 싱글톤을 보장해 준다'를 넘어, 그 아래에서 물리적으로 어떤 메커니즘이 동작하고 있는지 파헤쳐 보겠습니다.


1. 스프링의 가장 위대한 철학, POJO (Plain Old Java Object)

본격적인 딥다이브에 앞서, 아주 중요한 개념 하나는 POJO입니다.

 

직역하자면 "순수한 오래된 자바 객체"라는 뜻인데요.

2000년대 초반, EJB(Enterprise JavaBeans)라는 엄청나게 무겁고 복잡한 기술이 지배하던 시절이 있었습니다.

당시에는 내 비즈니스 로직 하나를 짜려면 프레임워크가 강제하는 무거운 클래스를 상속(extends)받고 인터페이스를 구현(implements)해야만 했죠.

// 과거 EJB 시절의 끔찍한 코드 (순수하지 않음!)
public class MemberService extends EJB어쩌구저쩌구 implements EJB복잡한인터페이스 {
    // 프레임워크 찌꺼기 코드가 수십 줄 섞여버림..
}

이런 지옥 속에서 개발자들은 "제발 프레임워크에 오염되지 않은 순수한 자바 객체(POJO)로 돌아가자!"라고 외쳤습니다.
특정 클래스를 상속받지 않아도 되고, 특정 환경에 얽매이지 않는 순도 100%의 자바 클래스 말입니다.

 

스프링의 좋은점은 바로 이 POJO 프로그래밍을 완벽하게 지원한다는 것입니다.

우리가 짠 순수한 비즈니스 로직(POJO)을 단 1줄도 건드리지 않은 채로, 스프링 컨테이너가 밖에서 DI(의존성 주입)와 CGLIB 같은 마법을

부려 겉을 싹~ 감싸주는 구조인 것이죠.

 

이 철학을 머릿속에 꼭 담아두고, 다음 단계로 넘어가 보겠습니다!

 

cf. pojo : 평범한 자바 클래스(근데 폼나게 부르면 POJO). Java Bean ( POJO + getter/setter 등). Spring Bean( POJO를 런타임에 스프링 컨테이너가 싱글톤기반으로 관리해줌) ( 링크: POJO vs Java Bean vs Spring Bean


2. 스프링은 싱글톤을 어떻게 보장할까? (Singleton Registry)

싱글톤 패턴(private 생성자와 static 팩토리 메서드)은 테스트의 어려움, 상속의 제한 등 '안티 패턴'으로 꼽히는 경우가 많습니다.

(서버든, 웹이든 모바일이든.. )

 

그래서 스프링은 우리가 작성한 평범한 클래스(POJO)의 코드를 오염시키지 않으면서도, 컨테이너가 앞장서서 싱글톤을 보장해 주는 싱글톤 레지스트리(Singleton Registry) 방식을 사용합니다.

 

핵심은 DefaultSingletonBeanRegistry 클래스 내부에 숨겨진 ConcurrentHashMap입니다.

 

// 스프링 컨테이너 내부의 개념적 형태
public class DefaultSingletonBeanRegistry {
    // 1차 캐시 역할을 하는 ConcurrentHashMap (스레드 세이프!!!!)
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

    public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
        synchronized (this.singletonObjects) {
            Object singletonObject = this.singletonObjects.get(beanName); // 1. 캐시 확인
            if (singletonObject == null) {
                // 2. 캐시에 없으면 팩토리를 통해 빈을 생성 후 맵에 저장
                singletonObject = singletonFactory.getObject();
                this.singletonObjects.put(beanName, singletonObject);
            }
            return singletonObject;
        }
    }
}

 

위 코드는 실제로 스프링 프레임워크에서 동작되는 코드를 요약한 것입니다.

 

여기서 잠깐 코드를 유심히 볼까요?

singletonObjects 맵의 값(Value) 타입이 <String, Object>로 되어 있습니다.

 

MemberRepository, MemberService, OrderService 등 우리가 만든 수많은 구체적인 타입의 빈들이 모두 최상위 부모인 Object 타입으로 업캐스팅(Upcasting)되어 단 하나의 맵 자료구조 안에 통합되어 저장되는 것입니다!

 

이게 바로 자바 객체지향이 자랑하는 다형성과 추상화의 장점입니다.

컨테이너에서 빈을 꺼낼 때는 스프링이 내부적으로 원래의 구체 타입으로 안전하게 다운캐스팅해서 우리에게 돌려준답니다.

 

웹 애플리케이션은 수많은 스레드가 동시에 접근하는 환경입니다. 스프링은 ConcurrentHashMap과 동기화 블록(synchronized)을 사용하여 여러 스레드가 동시에 같은 빈을 요청해도 딱 한 번만 생성되도록 스레드 안전성(Thread-Safety)까지 꽉 잡고 있습니다.

 

이런 포인트들은 단순한 이론적인 이야기가 아니라, 실제로 스프링 프레임워크의 오픈소스 코어 레벨을 뜯어보면 DefaultSingletonBeanRegistry 클래스 내부에 정확히 위와 같은 형태의 ConcurrentHashMap과 Lock 로직이 구현되어 있는 것을 두 눈으로 확인할 수 있죠! (자세한 링크는 글 맨 아래 레퍼런스에 남겨두겠습니다 😎)

[!NOTE]
Golden Rule
스프링 빈은 기본적으로 상태를 유지(Stateful)하면 안 됩니다.
싱글톤이기 때문에 전역적으로 공유되므로, 인스턴스 변수에 의존하는 코드를 작성하면 큰일 납니다!

3. Lite Mode vs Full Mode : @Configuration의 마법과 함정

우리의 큰 의문점이었던 @Configuration에 대해 알아볼 차례입니다.
결론부터 말씀드리면, @Configuration 없이 빈을 등록하는 것은 가능하지만, 치명적인 함정이 숨어 있습니다.

함정: Lite Mode (설정 어노테이션이 없는 경우)

아래처럼 @Component만 붙여놓고 빈을 등록하면 어떻게 될까요..?

이를 스프링에서는 'Lite Mode'라고 부릅니다.

@Component // @Configuration이 아닙니다! (Lite Mode)
public class AppConfig {
    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call MemberRepository");
        // 메모리 리포지토리 생성
        return new MemoryMemberRepository();
    }

    @Bean
    public MemberService memberService() {
        // 자바의 순수한 메서드 직접 호출!
        return new MemberServiceImpl(memberRepository()); 
    }

    @Bean
    public OrderService orderService() {
        // 여기도 직접 호출!
        return new OrderServiceImpl(memberRepository()); 
    }
}

 

언뜻 보면 잘 동작할 것 같죠?

하지만 콘솔을 찍어보면 call MemberRepository3번이나 출력되는 대참사가 발생합니다.

 

왜 그럴까요?

 


스프링 컨테이너가 빈을 팩토리 메서드로 생성하려고 memberService()를 부를 때, 그 안의 memberRepository()는 컨테이너를 거치지 않은 '순수한 자바 메서드'로서 실행되기 때문입니다.

결국 new MemoryMemberRepository()가 호출될 때마다 각각 다른 인스턴스가 힙(Heap) 메모리에 찍혀버립니다.

 

우리가 기대한 싱글톤이 완전히 깨지는 것이죠.

마법: Full Mode (CGLIB 프록시 가로채기)

요기요기!!!! 여기에 @Configuration을 붙이는 순간(Full Mode), 마법이 일어납니다.

 

스프링은 구동될 때 바이트코드 조작 라이브러리인 CGLIB을 사용해서, 우리의 AppConfig 클래스를 상속받는 가짜(Proxy) 자식 클래스를 메모리에 몰래 짜냅니다.

 

자, 이렇게 런타임의 환영술로 메모리에 무사히 안착한 프록시 클래스는 과연 어떤 모습일까요?

단순히 껍데기만 상속받은 빈 깡통이 아닙니다. 스프링은 이 프록시 내부에 메서드 호출을 낚아채기 위한 MethodInterceptor라는 특별한 콜백 객체를 심어놓습니다.

 


CGLIB의 MethodInterceptor는 모든 메서드 호출을 가로채는 intercept()라는 핵심 함수를 가지고 있습니다.

스프링은 이곳에 BeanMethodInterceptor라는 전용 구현체를 꽂아 넣어, "빈 생성 메서드가 불리면 무조건 스프링 컨테이너(BeanFactory)부터 뒤져라!"라고 지시를 내려둔 셈이죠.

 

이해를 돕기 위해, 프록시가 오버라이딩한 memberRepository() 내부가 대략 어떤 느낌으로 동작하는지 먼저 살펴볼까요?

// 프록시 클래스 내부의 아주 개념적인 흐름
@Override
public MemberRepository memberRepository() {
    if (스프링 컨테이너에 "memberRepository" 빈이 이미 있다면?) {
        return 컨테이너에서 꺼내서 반환; // 가로채기 성공! 진짜 코드는 실행 안 됨.
    } else {
        MemberRepository repo = super.memberRepository(); // 진짜 부모 코드 실행해서 객체 생성
        컨테이너에 저장(repo);
        return repo;
    }
}

 

느낌이 오시나요?

무조건 스프링 컨테이너(1차 캐시)부터 뒤져보는 'if문 분기 처리' 방어막이 쳐져 있는 셈입니다. 
코드에서 인스턴스가 필요한데, 스프링 컨테이너에 등록되어 있으면? 그 객체 반환, 없으면? AppConfig에서 의 생성로직 호출하고 스프링 컨테이너에 등록한 후에 반환.


그럼 여기서 한 걸음 더 깊게 들어가 볼까요?

 

실제 스프링 내부에서는 이 방어막 역할을 앞서 말씀드린 인터셉터(MethodInterceptor)가 공통으로 낚아채어 우아하게 처리합니다.

실제 내부 동작을 해부해 보겠습니다!

// CGLIB이 런타임에 만들어낸 가짜 클래스 (개념적 코드)
public class AppConfig$$EnhancerBySpringCGLIB extends AppConfig {

    private MethodInterceptor interceptor = new BeanMethodInterceptor(); 

    // 부모(AppConfig)의 메서드를 덮어씀(Override)
    @Override
    public MemberRepository memberRepository() {
        // 직접 로직을 실행하지 않고, 제어권을 특수 요원(인터셉터)에게 통째로 넘김!
        return (MemberRepository) interceptor.intercept(this, "memberRepository() 메서드 정보", ...);
    }
}

// --- [스프링의 BeanMethodInterceptor 내부 로직] ---
class BeanMethodInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) {

        // 1. 내가 지금 생성하려는 빈 이름 알아내기 ("memberRepository")
        String beanName = method.getName(); 

        // 2. 스프링 컨테이너(BeanFactory)에게 "이 빈 혹시 만들어진 거 있어?" 하고 물어봄
        // (이 안에서 아까 봤던 ConcurrentHashMap 1차 캐시를 샅샅이 뒤집니다!)
        if (beanFactory.containsBean(beanName)) {
            return beanFactory.getBean(beanName); // 있으면 캐시된 진짜 객체 반환 (가로채기 성공!)
        } 

        // 3. 컨테이너에 없다면? 이제 진짜 객체를 만들어야 함.
        // CGLIB의 강력한 기능인 proxy.invokeSuper()를 통해 원본 AppConfig의 메서드를 호출!
        // 프록시의 부모인 AppConfig(우리가 작성한거) 호출을 안하면? 내 코드 호출한다는건데, 그럼 무한 루프가 발생~
        Object newBean = proxy.invokeSuper(obj, args); 

        // 4. 새로 만든 객체를 컨테이너 1차 캐시에 저장하고 반환
        return newBean; 
    }
}

 

이 프록시 객체는 우리가 작성한 메서드 호출을 중간에서 완벽하게 통제(Intercept)합니다.


memberService() 안에서 memberRepository()를 10번, 100번 호출하든 간에, CGLIB 프록시가 먼저 낚아채서 beanFactory.getBean()을 통해 컨테이너 1차 캐시를 확인한 뒤, 항상 동일한 싱글톤 인스턴스를 뱉어주는 구조입니다.

 

우리가 짠 비즈니스 로직(POJO)은 단 1줄도 바뀌지 않았는데, 프레임워크가 바이트코드 레벨에서 인터셉터를 꽂아 넣어 싱글톤을 든든하게 수호해 주는 것이죠! 정말 대단하다는 생각이 듭니다.

[!NOTE]
잠깐! 여기서 쓰인 '프록시 패턴(Proxy Pattern)'이란?

쉽게 말해 "연예인(원본 객체)과 매니저(프록시)"의 관계입니다.
우리가 연예인에게 사인을 받으려고 다가가면, 앞을 막아서고 있는 매니저(특수 요원)를 무조건 거쳐야 합니다. 매니저는 연예인(원본 코드)을 직접 부르지 않고도, 자기 가방에 미리 받아둔 사인이 있으면 대신 꺼내줍니다(캐싱 / 싱글톤 방어).

즉,
원본 코드(순수 자바 로직)는 단 1줄도 수정하지 않은 채 캐싱, 보안, 로깅 같은 부가 업무를 대리인에게 위임하는 디자인 패턴입니다!
스프링은 이 프록시 패턴을 활용해 우리 몰래 수많은 일들을 처리해주고 있습니다.

 

다시 돌아와서 이 과정을 한눈에 요약해 보겠습니다!

  1. @Bean 등록: 우리가 AppConfig 내부에 객체 생성 로직들을 @Bean 키워드로 등록합니다.
  2. 전면 오버라이딩: CGLIB 프록시는 이 메서드들을 전부 오버라이딩(Override)해서 자신만의 껍데기를 씌워버립니다.
    [기존 객체] : AppConfig 
    [Proxy 객체-자식객체] : AppConfig$$EnhancerBySpringCGLIB$$...
  3. 가로채기(Intercept): 같은 클래스 내부나 외부에서 해당 생성 함수를 호출하려고 하면, 원본 코드가 도는 것이 아니라 오버라이딩된 프록시 메서드가 잽싸게 튀어나와 요청을 낚아챕니다.

이렇게 동작하는 단 하나의 이유는 바로 클래스 상단에 @Configuration을 선언(Full Mode)했기 때문입니다.

런타임 시점에 스프링 컨테이너가 쥐고 있는 AppConfig는 진짜가 아니라, CGLIB이 몰래 바꿔치기한 프록시 자식 객체이기 때문이죠!

 

이 사실은 코드로도 쉽게 증명됩니다.

AppConfig 인스턴스를 스프링 컨테이너에서 꺼내어 출력해 보면, 우리가 만든 프로젝트명.패키지명.AppConfig가 나오는 것이 아니라, 그 뒤에 $$EnhancerBySpringCGLIB...라는 꼬리표가 붙어 있는 것을 두 눈으로 직접 확인할 수 있습니다. 👀

 

 

그러니까..

@Configuration 키워드는 단순히 "이 클래스는 설정 파일입니다~"라고 꼬리표를 달아주는 편의 기능이 절대 아닙니다.
CGLIB 프록시를 발동시켜 AppConfig'DI 컨테이너'로서의 역할을 온전히 수행하게 만드는 아주 핵심적인 필수 스위치인 것이죠! 만약 이 스위치를 켜지 않는다면, 스프링 빈에 함수들이 등록은 되겠지만 런타임에 가장 중요한 싱글톤 보장이 전혀 되지 않는 무용지물이 되어버립니다.

 


cf.. CS 딥다이브: 바이트코드 조작은 도대체 어느 시점에 일어나는 걸까?

C/C++/Swift 같은 언어들은 컴파일 언어라서 소스 코드를 추상 구문 트리(AST)로 파싱하고 컴파일한 뒤, 스태틱 링킹(Static Linking)을 거쳐 빌드 시점에 실행 파일을 완성합니다. 반면 JavaScript나 Python은 런타임에 코드를 한 줄씩 읽는 인터프리터 언어죠.
(그렇다면 Java는..?!! 컴파일 + 인터프리터의 혼종입니다!)

 

자바는 1차로 .java를 바이트코드(.class)로 컴파일해 두고, 2차로 실행 시점(Run-time)에 JVM이 이를 인터프리팅하며 엮어냅니다.

바로 이 느슨하고 유연한 특징 덕분에 CGLIB 바이트코드 조작은 빌드 시점에 딱딱하게 굳어버리는 것이 아닙니다.

 

애플리케이션이 실행된 후, 스프링 컨테이너가 초기화되는 바로 그 시점에 다이내믹하게 벌어집니다!
스프링이 런타임 메모리(RAM) 상에서 프록시 클래스의 바이트코드 배열을 생성하고 ClassLoader#defineClass()를 통해 JVM에 동적으로 로드합니다.

이후 해당 클래스는 일반 클래스와 동일하게 JVM의 클래스 로딩 절차(Loading → Linking → Initialization)를 거쳐 사용할 수 있게 됩니다.

물리적인 하드 디스크에 가짜 .class 파일을 남기지도 않는, 그야말로 실시간 매직이죠.

 

흐름을 요약하자면 이렇습니다:

  1. .java 코딩 ➔ javac 컴파일 ➔ .class 빌드
  2. JVM 실행 (main()SpringApplication.run())
  3. Spring ApplicationContext 생성 ➔ 빈 스캔 중 @Configuration 발견
  4. CGLIB 발동 (메모리에서 프록시 바이트코드 배열 byte[] 생성)

 

"그냥 오버라이딩(상속)하는 프록시 아니야? 굳이 왜 바이트코드를 직접 만들어?"
맞습니다. 개념적으로는 단순히 자식 클래스를 만들어 부모 메서드를 오버라이딩하는 것입니다. 하지만 자바는 정적 컴파일 언어입니다. 런타임에 갑자기 자바 소스 코드(.java)를 텍스트로 새로 짜서 자바 컴파일러(javac)를 돌리려면 너무 무겁고 어마어마하게 느립니다.

 

그래서 CGLIB은 ASM이라는 저수준 라이브러리를 사용해 파싱, 컴파일 같은 무거운 과정을 완전히 건너뛰고, JVM이 곧바로 읽을 수 있는 바이트코드 배열(byte[])을 다이렉트로 조립해 버립니다.

이 방식이 압도적으로 빠르기 때문이죠!

 

ClassLoader를 통한 메모리 적재. 이렇게 조립된 byte[] 배열은 ClassLoader.defineClass()라는 JVM의 네이티브(Native) 메커니즘을 거쳐 즉석에서 Class 객체로 변환됩니다.

그리고 JVM의 메서드 영역(Metaspace)에 진짜 클래스처럼 당당하게 등재되어 다이내믹 링킹이 완료됩니다.

4. 런타임 제국의 딜레마: 순환 참조와 3단계 캐시

Dagger 2나 Google Guice 같은 다른 DI 컨테이너들은 의존성 맵(DI Graph)을 분석하여 객체 생성 순서를 완벽하게 세팅합니다.

스프링 역시 메타데이터(BeanDefinition)를 읽어 런타임 의존성 그래프를 그리지만, 가끔 객체끼리 서로를 참조하는 순환 참조(Circular Dependency)라는 끔찍한 데드락에 빠질 때가 있습니다. (A -> B -> A)

 

이 난제를 스프링은 어떻게 풀었을까요..? 바로 3단계 캐시(Three-level Cache)라는 기법을 도입했습니다.

캐시 단계 이름 역할
1차 캐시 singletonObjects 의존성 주입까지 완벽하게 끝난 "진짜 싱글톤" 객체 저장소
2차 캐시 earlySingletonObjects 메모리 할당(new)은 되었지만 주입이 덜 끝난 "미완성 껍데기" 저장소
3차 캐시 singletonFactories 프록시 생성을 위한 팩토리(ObjectFactory) 임시 저장소

 

 

이 모든 마법이 탄생하게 된 가장 근본적인 배경이 있습니다.

 

스프링 컨테이너는 런타임 초기화 시점에 단 하나의 메인 스레드가 순차적(동기적)으로 빈을 생성하고 조립합니다.

스레드가 A를 조립하다가 아직 만들어지지 않은 B가 필요해지면, 하던 일을 멈추고 B를 먼저 만들러 달려가야만 하죠.

이런 상황에서 순환 참조를 만나 스레드가 영원히 갇혀버리는 문제를 해결하기 위한 스프링의 핵심 아이디어가 바로 "일단 껍데기(메모리 주소)라도 먼저 3차 캐시에 던져두고, 서로 멱살 잡고 의존성을 주입시켜 사이클을 강제로 끊어버린다"는 것입니다.

자바의 객체 변수가 결국 '주소값(포인터)'이므로 빈 껍데기 주소만 먼저 넘겨줘도 나중에 내용물이 채워진다는 점을 활용한 기법입니다!

 

3단계 캐시의 역할을 흐름대로 종합해 볼까요?

  1. 3차 캐시 (singletonFactories): 스레드가 빈을 new 하자마자 가장 먼저 발급해 두는 "번호표(생성 공장)"입니다. 순환 참조 때문에 누군가 다급하게 나를 찾으면, 이 공장을 돌려 미완성 껍데기를 내어줍니다. (순환 참조 데드락을 끊어내는 1등 공신!)
  2. 2차 캐시 (earlySingletonObjects): 3차 캐시에서 껍데기를 한 번 꺼내간 후 임시로 보관하는 "미완성 보관소"입니다. 만약 AOP 프록시 처리가 필요한 객체라면, 여기에 '아직 속이 덜 찬 프록시 객체'가 임시로 머물게 됩니다.
  3. 1차 캐시 (singletonObjects): 모든 의존성 주입(DI)과 초기화 조립이 끝난 "최종 완성품 진열장"입니다. 스프링이 정상적으로 구동된 후에는 무조건 이 1차 캐시에서만 완벽한 싱글톤 객체를 꺼내 씁니다. (이때 임무를 다한 2, 3차 캐시의 임시 데이터는 깔끔하게 비워집니다.)

만약 3단계 캐시가 없었다면? (순환 참조 발생)
오직 '완제품'만 담아두는 1차 캐시밖에 없었다고 가정해 봅시다.

  1. 스프링이 new A()를 하고 A에 B를 주입하려고 합니다.
  2. 1차 캐시에 B가 없으니, 스프링은 급하게 B를 만들러 갑니다. (new B())
  3. B에 A를 주입하려고 1차 캐시를 봅니다. 하지만 A는 아직 조립 중이므로 1차 캐시에 없습니다!
  4. 스프링은 "어? A가 없네? A 만들어야겠다!" 하고 또 다시 new A()를 호출합니다.
  5. A 만들다 B 부르고, B 만들다 A 부르고... 영원히 끝나지 않는 핑퐁 무한 루프(StackOverflow)에 빠져 앱이 터져버립니다!!!

스프링은 이 무서운 데드락을 끊어내기 위해, 미완성 껍데기라도 미리 담아두는 3단계 캐시를 도입했습니다.

 

 

cf. 생성자 주입(Constructor Injection)을 사용하면 객체를 인스턴스화하는 그 시점(new)에 즉시 의존성이 필요하므로, 위와 같은 3단계 캐시 마법이 통하지 않습니다. 그래서 무조건 에러가 터집니다. (이것이 생성자 주입을 써야 하는 이유 중 하나이기도 합니다!)

5. 컴파일(Dagger) vs 런타임(Spring), 왜 스프링은 에러를 나중에 뱉을까?

이쯤 되면 한 가지 궁금증이 생길 수 있습니다. "Dagger 2 같은 애들은 컴파일 타임에 DI 그래프를 그려서 순환 참조나 빈 주입 실패를 에러(Build Failed)로 빡! 잡아주는데.. 스프링은 왜 굳이 서버를 띄워야만(런타임) 에러를 뱉는 거죠?"

 

분명히 컴파일 단에서 잡아주면 우리 개발자들은 훨씬 편하게 꿀잠을 잘 수 있을 텐데 말입니다. 그 이유는 스프링이 추구하는 '압도적인 유연성' 때문입니다.

  • 조건부 빈 등록 (@Conditional): OS가 맥이냐 윈도우냐에 따라 분기(다르게 등록 가능)!
  • 프로필 (@Profile): 실행 환경(Dev, Prod, test ...)에 따라 DB 구현체 바꿔치기!
  • CGLIB 바이트코드 조작: 런타임에 없던 클래스를 깎아내기!

컴파일러는 코드를 빌드하는 시점이라 실행 환경이 어떤지, 어떤 프로필 변수가 들어올지 전혀 알 수가 없습니다.


스프링은 앱이 켜지고 메모리가 올라가는 그 런타임 시점에 맞춰 환경을 분석하고, 가장 알맞은 메타데이터(BeanDefinition) 조각들을 끼워 맞춰 완벽한 객체 생태계를 구축하는 방식을 택한 것입니다.

 

대신 단점을 보완하기 위해 런타임 구동 초기에 빠르게 뻗어버리도록 설계되어 있죠.

 

마무리하며

지금까지 단순해 보였던 @Configuration 뒤에 숨겨진 거대한 스프링의 세계를 파헤쳐 보았습니다.

  • EJB의 지옥에서 벗어나 순수한 자바 객체를 지켜낸 POJO 철학
  • 싱글톤을 보장하는 ConcurrentHashMap 기반의 든든한 레지스트리
  • POJO 코드를 오염시키지 않고 호출을 가로채는 CGLIB 프록시의 마법
  • 순환 참조를 끊어내는 3단계 캐시 (Early Reference)
  • 컴파일의 안정성을 포기하고 런타임의 극강의 유연성을 선택한 스프링의 결단

이제 누군가 "스프링 빈 관리가 뭡니까?"라고 묻는다면, 자신 있게 프록시와 바이트코드 관계까지 생각하면서 답변을 하실 수 있을 겁니다!
다음에도 더 흥미로운 내용으로 찾아올게요!

 

References