스프링의 심장, 컨텍스트(Context) 완벽 해부: IoC 컨테이너부터 자동 설정까지

Jayson·2025년 6월 25일
0
post-thumbnail

스프링 프레임워크를 사용하다 보면 모든 것이 알아서 동작하는 듯한 느낌을 받을 때가 많습니다. 필요한 객체들이 어디선가 나타나 의존성이 주입되고, 트랜잭션은 어노테이션 하나로 처리됩니다. 하지만 이 "알아서"의 이면에는 '스프링 컨텍스트(Spring Context)' 또는 'IoC 컨테이너'라 불리는 강력하고 정교한 엔진이 자리 잡고 있습니다. 이 컨텍스트는 스프링 애플리케이션의 모든 구성 요소, 즉 '빈(Bean)'의 생명주기를 총괄하는 심장부라고도 볼 수 있을 것 같아요.

이 글의 목표는 "스프링의 알아서" 뒤에 숨겨진 원리를 파헤쳐 보는 것입니다. 더이상 프레임워크의 사용자에 머무르지 않고, 그 내부 동작을 이해함으로써 더 견고하고 유연한 애플리케이션을 설계할 수 있도록 돕고자 합니다. 우리는 스프링의 근본 철학인 제어의 역전(IoC)과 의존성 주입(DI)에서 시작하여, 컨텍스트의 핵심 구현체인 ApplicationContext를 살펴볼 것입니다. 그리고 컨텍스트가 관리하는 객체인 빈(Bean)의 생명주기와 스코프를 이해하고, 올바른 의존성 주입 방식에 대해 논의할 것입니다. 마지막으로, 스프링 AOP와 스프링 부트의 자동 설정(Auto-Configuration)과 같은 고급 메커니즘이 어떻게 이 컨텍스트 위에서 동작하는지 그 비밀을 밝혀낼 것입니다. 이 글을 통해 스프링의 핵심을 꿰뚫는 깊이 있는 이해를 얻어 봅시다.

제어의 역전(IoC)과 의존성 주입(DI): 스프링의 근본 철학

스프링을 이해하기 위한 첫걸음은 그 근간을 이루는 철학, 즉 제어의 역전(Inversion of Control, IoC)과 의존성 주입(Dependency Injection, DI)을 이해하는 것입니다. 이 개념들은 단순히 기술적인 패턴을 넘어, 객체지향 프로그래밍의 패러다임을 전환시킨 핵심 사상입니다.

스프링 이전의 문제점

과거의 자바 개발 방식에서는 객체가 자신의 의존성을 직접 생성하고 관리하는 것이 일반적이었습니다. 예를 들어, OrderService가 OrderRepository를 필요로 한다면, OrderService 클래스 내부에서 new OrderRepositoryImpl()과 같이 직접 인스턴스를 생성했습니다. 이러한 방식은 두 클래스 간의 강한 결합(Tight Coupling)을 야기합니다.  

OrderRepository의 구현체가 변경되면 OrderService의 코드도 반드시 수정해야만 했습니다. 이는 코드의 재사용성을 떨어뜨리고, 특히 의존 객체를 격리하여 테스트하는 것을 매우 어렵게 만들었습니다.

제어의 역전(IoC): 패러다임의 전환

IoC는 이러한 문제에 대한 해결책으로 등장한 설계 원칙입니다. 이름 그대로 '제어의 흐름'을 역전시키는 것을 의미합니다. 기존에는 개발자의 코드가 프로그램의 흐름을 제어하며 필요한 객체를 직접 생성하고 호출했습니다. 하지만 IoC 패러다임에서는 객체의 생성, 구성, 생명주기 관리의 책임이 개발자의 코드에서 외부의 독립적인 존재, 즉 프레임워크로 넘어갑니다.  

스프링 프레임워크에서는 이 역할을 '스프링 컨테이너' 또는 'IoC 컨테이너'가 담당합니다. 컨테이너는 애플리케이션의 설정 정보를 바탕으로 객체(스프링에서는 이를 '빈'이라 부릅니다)를 생성하고, 객체 간의 의존관계를 설정하며, 소멸에 이르기까지 전체 생명주기를 관리합니다. 이를 통해 개발자는 복잡한 객체 관리에서 벗어나 오직 비즈니스 로직에만 집중할 수 있게 됩니다.  

이 개념을 자동차 조립 라인에 비유할 수 있습니다. 과거의 방식이 엔진이 스스로 피스톤을 만들고 변속기가 스스로 기어를 만드는 것과 같다면, IoC는 중앙 조립 라인(IoC 컨테이너)이 설계도(설정 정보)를 보고 미리 만들어진 부품(객체/빈)들을 가져와 정교하게 조립하는 것과 같습니다.

의존성 주입(DI): IoC를 구현하는 방법

IoC가 '무엇을 할 것인가'에 대한 철학적 원칙이라면, DI는 '어떻게 할 것인가'에 대한 구체적인 구현 패턴입니다. DI는 IoC 원칙을 달성하기 위해 스프링이 채택한 핵심 기술입니다. 객체가 내부에서 자신의 의존 객체를 생성하는 대신, 외부(컨테이너)에서 의존 객체를 전달받아 '주입'받는 방식입니다.  

컨테이너는 빈 설정 정보(Bean Definition)를 바탕으로 각 클래스가 어떤 다른 클래스를 필요로 하는지 파악하고, 이 의존관계를 자동으로 연결해 줍니다. 이로써 객체 간의 결합도가 낮아지고(Loose Coupling) 모듈성이 향상됩니다. 예를 들어,  

MySqlRepository 구현체를 사용하다가 PostgresRepository로 변경해야 할 때, OrderService 코드는 전혀 수정할 필요 없이 설정 파일이나 설정 클래스만 변경하면 됩니다. 이는 테스트 용이성을 극대화하는데, 실제 데이터베이스에 접근하는 Repository 대신 가짜 객체(Mock Object)를 쉽게 주입하여 Service 계층의 로직만을 순수하게 테스트할 수 있게 해줍니다.  

결론적으로, IoC와 DI의 관계를 명확히 이해하는 것은 중요합니다. 많은 개발자들이 두 용어를 혼용하지만, IoC는 제어권이 프레임워크로 넘어가는 더 넓은 개념이며, DI는 그 제어권을 활용하여 컨테이너가 객체의 의존성을 해결해주는 핵심 메커니즘입니다. 이 철학적 기반을 이해할 때, 비로소 스프링이 왜 그토록 강력하고 유연한 프레임워크인지 진정으로 파악할 수 있습니다.

스프링 컨테이너의 두 얼굴: BeanFactory와 ApplicationContext

스프링 IoC 컨테이너는 스프링 애플리케이션의 핵심 엔진입니다. 이 컨테이너를 대표하는 두 개의 주요 인터페이스가 바로 BeanFactory와 ApplicationContext입니다. 둘은 밀접한 관련이 있지만, 기능과 사용 목적에서 중요한 차이를 보입니다.

기반: BeanFactory

BeanFactory는 스프링 컨테이너의 가장 기본적인 형태이자 최상위 인터페이스입니다. 그 이름처럼 빈(Bean)을 생성하고 관리하는 공장의 역할을 수행합니다.  

BeanFactory의 핵심 기능은 DI 컨테이너의 기본에 충실하며, getBean() 메서드를 통해 빈을 조회하고 반환하는 것입니다. 즉, 빈을 관리하는 데 필요한 최소한의 기능을 제공합니다.  

완성체: ApplicationContext

현대의 거의 모든 스프링 애플리케이션에서 실질적으로 사용되는 컨테이너는 ApplicationContext입니다. ApplicationContext는 BeanFactory 인터페이스를 상속받아 그 기능을 모두 포함하면서, 엔터프라이즈급 애플리케이션 개발에 필수적인 수많은 부가 기능을 추가로 제공하는 강력한 인터페이스입니다.  

ApplicationContext가 제공하는 주요 부가 기능은 다음과 같습니다:

  • 메시지 소스 처리 (Message Source Handling): 국제화(i18n) 기능을 지원하여, 국가별 언어에 맞는 메시지를 관리할 수 있습니다.  

  • 이벤트 발행 (Event Publication): ApplicationEventPublisher 인터페이스를 통해 빈들이 애플리케이션 내에서 발생하는 이벤트를 발행하고 구독할 수 있는 메커니즘을 제공합니다.  

  • 리소스 로딩 (Resource Loading): ResourceLoader로서 클래스패스, 파일 시스템 등 다양한 위치의 리소스를 일관된 방식으로 로드할 수 있는 포괄적인 방법을 제공합니다.  

  • AOP 연동: 관점 지향 프로그래밍(AOP) 기능을 자동으로 감지하고 통합합니다.

  • 웹 환경 지원: 웹 애플리케이션을 위한 WebApplicationContext와 같은 특화된 구현체를 제공합니다.  

이러한 풍부한 기능 때문에, 특별한 경우가 아니라면 항상 ApplicationContext를 사용하는 것이 표준입니다.  

가장 결정적인 차이: 빈 로딩 전략

두 인터페이스의 가장 중요한 차이점은 빈을 로딩하는 시점과 전략에 있습니다.

  • BeanFactory (지연 로딩, Lazy Loading): BeanFactory는 빈이 실제로 getBean()을 통해 요청될 때 비로소 해당 빈을 생성하고 초기화합니다. 이는 'on-demand' 방식으로, 컨테이너 자체의 시작 속도는 빠를 수 있습니다. 하지만 의존성 설정 오류와 같은 문제가 애플리케이션 실행 중 해당 빈이 사용되는 시점에야 발견될 수 있는 단점이 있습니다.  

  • ApplicationContext (즉시 로딩, Eager Loading): 반면, ApplicationContext는 기본적으로 컨테이너가 시작되는 시점에 설정 파일에 정의된 모든 싱글톤(Singleton) 스코프의 빈을 미리 인스턴스화하고 설정합니다. 이로 인해 애플리케이션 시작 시간은 다소 길어질 수 있지만, 이는 단순한 단점이 아니라 의도된 설계입니다.  

ApplicationContext의 즉시 로딩 전략은 'Fail-Fast' 철학의 구현입니다. 애플리케이션이 시작될 때 모든 빈의 의존성 관계를 점검하고 설정 오류나 순환 참조 같은 문제가 있다면 즉시 예외를 발생시켜 애플리케이션 구동을 중단시킵니다. 이는 잘못 설정된 애플리케이션이 배포되어 런타임에 예측 불가능한 오류를 일으키는 것을 원천적으로 방지합니다. 개발 단계나 CI/CD 파이프라인에서 문제를 조기에 발견할 수 있게 해주는 이 특성은 애플리케이션의 안정성과 신뢰성을 크게 높여주는 매우 중요한 기능입니다.

스프링 빈(Bean)의 모든 것: 정의, 생명주기, 스코프

스프링 컨텍스트의 핵심 역할은 '빈(Bean)'을 관리하는 것입니다. 빈의 개념과 그 동작 방식을 깊이 이해하는 것은 스프링을 제대로 활용하기 위한 필수 조건입니다.

빈이란 무엇이며 어떻게 정의하는가?

빈은 단순히 자바 객체(POJO)를 의미하는 것이 아니라, 스프링 IoC 컨테이너에 의해 인스턴스화되고, 조립되며, 관리되는 객체를 지칭합니다. 컨테이너는 빈을 생성하기 위한 '설계도' 또는 '레시피'를 가지고 있는데, 이를  

BeanDefinition이라고 부릅니다.  

이 BeanDefinition에는 빈을 만드는 데 필요한 모든 메타데이터가 포함됩니다. 예를 들어, 빈의 전체 클래스 이름, 활동 범위(스코프), 생성자 인수, 설정할 프로퍼티 값, 그리고 생명주기 콜백 메서드 등이 여기에 해당합니다.  

현대 스프링에서는 주로 두 가지 방식으로 빈을 정의합니다.

  1. 컴포넌트 스캔 (@Component): 개발자가 직접 작성하는 클래스에 @Component나 이를 특화시킨 @Service, @Repository, @Controller 같은 스테레오타입 어노테이션을 붙이는 방식입니다. 스프링 컨테이너는 지정된 패키지 이하를 스캔하여 이 어노테이션이 붙은 클래스들을 자동으로 찾아 BeanDefinition을 생성하고 빈으로 등록합니다. 이 방식은 애플리케이션의 주요 구성 요소를 등록할 때 매우 편리합니다.  

  2. 자바 기반 설정 (@Configuration과 @Bean): @Configuration 어노테이션이 붙은 설정 클래스 내부에, @Bean 어노테이션이 붙은 메서드를 작성하는 방식입니다. 이 메서드가 반환하는 객체가 컨테이너에 빈으로 등록됩니다. 이 방식은 주로 소스 코드를 직접 수정할 수 없는 외부 라이브러리의 객체를 빈으로 등록해야 할 때 유용합니다.  

빈의 탄생과 죽음: 생명주기 완벽 분석

스프링 컨테이너 내에서 싱글톤 빈은 다음과 같은 명확한 생명주기를 따릅니다.  

  1. 스프링 컨테이너 생성: ApplicationContext 인스턴스가 생성됩니다.

  2. 빈 정의(BeanDefinition) 로드: 컨테이너가 XML, 자바 설정 클래스 등에서 설정 메타데이터를 읽어 BeanDefinition을 생성하고 저장합니다.  

  3. 빈 인스턴스화: BeanDefinition을 바탕으로, 리플렉션을 통해 빈의 생성자를 호출하여 객체 인스턴스를 생성합니다.  

  4. 의존성 주입: 컨테이너가 @Autowired와 같은 어노테이션을 처리하여 빈이 필요로 하는 다른 빈들을 주입합니다.

  5. 초기화 콜백: 모든 의존성 주입이 완료된 후, 빈이 실질적인 작업을 수행하기 전에 추가적인 초기화 로직을 실행할 수 있는 콜백 메서드가 호출됩니다. 생성자 시점에는 의존성이 주입되지 않았을 수 있으므로(생성자 주입 제외), 의존성을 활용하는 초기화 작업은 이 단계에서 수행하는 것이 안전합니다.  

  6. 빈 사용: 모든 준비를 마친 빈은 이제 ApplicationContext에서 사용 가능한 상태가 되어 애플리케이션 로직에 의해 활용됩니다.

  7. 소멸 전 콜백: ApplicationContext가 종료될 때, 컨테이너는 빈이 소멸되기 직전에 소멸 콜백 메서드를 호출합니다. 이 단계에서 빈은 사용하던 리소스(예: 데이터베이스 커넥션, 파일 핸들)를 정리할 기회를 갖습니다.  

  8. 빈 소멸: 빈 객체가 가비지 컬렉션의 대상이 되거나 컨테이너에 의해 제거됩니다.

특히, 객체 생성과 초기화를 분리하는 것은 매우 중요한 설계 원칙입니다. 생성자의 역할은 객체를 일관성 있는 유효한 상태로 만드는 것에 국한되어야 합니다. 데이터베이스 연결이나 외부 시스템과의 통신 설정과 같은 무거운 초기화 작업은 생성자가 아닌,  

@PostConstruct와 같은 초기화 콜백 메서드에서 수행하는 것이 바람직합니다.

빈의 활동 범위: 스코프의 이해와 활용

빈 스코프(Scope)는 컨테이너 내에서 생성된 빈 인스턴스의 생명주기와 가시성, 즉 활동 범위를 정의합니다.  

핵심 스코프

  • singleton (기본값): 스프링 컨테이너당 단 하나의 인스턴스만 생성됩니다. 어떤 컴포넌트가 해당 빈을 주입받든, 모두 동일한 단일 객체를 공유하게 됩니다. 이는 스프링의 기본 스코프이며 가장 널리 사용됩니다.  

  • prototype: 컨테이너에 빈을 요청할 때마다 매번 새로운 인스턴스가 생성되어 반환됩니다.  

prototype 스코프를 사용할 때 주의해야 할 중요한 특징이 있습니다. 컨테이너는 프로토타입 빈을 생성하고, 의존성을 주입하며, 초기화 콜백까지만 호출한 후 클라이언트에게 전달합니다. 그 이후의 생명주기는 전적으로 클라이언트의 책임이 됩니다. 이는  

@PreDestroy와 같은 소멸 콜백 메서드가 프로토타입 빈에 대해서는 절대 호출되지 않음을 의미합니다. 만약 프로토타입 빈이 중요한 리소스를 점유하고 있다면, 해당 빈을 요청한 클라이언트 코드에서 직접 리소스를 해제하는 로직을 구현해야 합니다. 이는 프로토타입 스코프의 유연성을 위한 의도된 트레이드오프입니다.

웹 전용 스코프

웹 환경에서만 동작하는 특수한 스코프들도 존재합니다.  

  • request: 각각의 HTTP 요청마다 새로운 빈 인스턴스가 생성되고, 해당 요청이 끝날 때 소멸됩니다.

  • session: 각각의 HTTP 세션마다 새로운 빈 인스턴스가 생성되고, 세션이 만료될 때 소멸됩니다.

  • application: ServletContext의 생명주기와 동일하게, 웹 애플리케이션 전체에 걸쳐 단 하나의 인스턴스만 생성됩니다.

의존성 주입, 제대로 사용하기: 생성자 주입을 써야 하는 이유

의존성 주입(DI)은 스프링의 핵심 기능이지만, 어떻게 사용하느냐에 따라 코드의 품질이 크게 달라질 수 있습니다. 스프링은 주로 세 가지 DI 방식을 제공하며, 그중에서도 생성자 주입(Constructor Injection)은 현대적인 스프링 개발에서 가장 강력하게 권장되는 방식입니다.

세 가지 주요 DI 방식
1. 필드 주입 (Field Injection): 필드에 직접 @Autowired를 선언하는 방식입니다. 코드가 간결해 보이지만 심각한 단점들을 내포하고 있습니다.  

  1. 수정자 주입 (Setter Injection): setter 메서드에 @Autowired를 선언하는 방식입니다. 선택적이거나 변경 가능한 의존성을 주입할 때 사용될 수 있습니다.  

  2. 생성자 주입 (Constructor Injection): 클래스의 생성자에 @Autowired를 선언하는 방식입니다. 스프링 팀과 대다수 전문가가 권장하는 표준 방식입니다. 스프링 4.3부터는 생성자가 하나만 있을 경우  
    @Autowired를 생략할 수 있습니다.  

생성자 주입을 선택해야 하는 명백한 이유들

생성자 주입이 다른 방식에 비해 우월한 이유는 명확하며, 이는 좋은 객체지향 설계 원칙과도 맞닿아 있습니다.

  • 불변성(Immutability) 보장: 생성자 주입을 사용하면 의존성을 final 키워드로 선언할 수 있습니다. 이는 객체가 생성될 때 단 한 번만 의존성이 할당되고, 이후에는 절대 변경되지 않음을 보장합니다. 불변 객체는 상태가 예측 가능하고, 멀티스레드 환경에서 더 안전하며, 코드의 안정성을 크게 높여줍니다. 필드 주입이나 수정자 주입 방식으로는 final 필드를 사용할 수 없습니다.

  • Null 안전성(Null Safety) 확보: 생성자를 통해 필수 의존성을 전달받기 때문에, 해당 의존성 없이는 객체 인스턴스화 자체가 불가능합니다. 이는 의존성이 누락되어 발생하는 NullPointerException을 런타임이 아닌 컴파일 시점이나 애플리케이션 구동 시점에 방지해 줍니다.  

  • 순환 참조 조기 발견: 생성자 주입의 가장 강력한 장점 중 하나입니다. 만약 A 빈이 생성자에서 B 빈을 필요로 하고, B 빈이 다시 생성자에서 A 빈을 필요로 하는 순환 참조가 발생하면, 스프링 컨테이너는 빈을 생성하는 과정에서 이 순환 고리를 감지하고 BeanCurrentlyInCreationException을 발생시키며 애플리케이션 구동을 즉시 중단시킵니다. 반면, 필드 주입이나 수정자 주입은 이 문제를 숨기고 있다가, 실제 메서드가 호출되는 런타임에 StackOverflowError를 일으켜 디버깅을 훨씬 어렵게 만듭니다.  

  • 테스트 용이성 향상: 생성자 주입을 사용하는 클래스는 스프링 프레임워크에 대한 의존 없이 순수 자바(POJO)로 단위 테스트를 쉽게 작성할 수 있습니다. 테스트 코드에서 new 키워드로 객체를 생성하면서 필요한 의존성을 모의(Mock) 객체로 간단히 전달하면 됩니다. 필드 주입을 사용하면 리플렉션이나 스프링 테스트 컨텍스트를 사용해야만 의존성을 주입할 수 있어 테스트가 복잡해지고 느려집니다.  

  • 프레임워크 비침투적 코드: 생성자 주입을 사용하면 클래스의 핵심 로직이 스프링의 @Autowired 같은 특정 어노테이션에 의존하지 않게 됩니다. 클래스의 필수 의존성은 생성자 시그니처 자체에 명확하게 드러나므로, 코드의 가독성과 유지보수성이 향상됩니다.  

이러한 이유들로 인해, 의존성 주입 시에는 항상 생성자 주입을 기본으로 사용하고, 선택적인 의존성이 필요한 매우 드문 경우에만 수정자 주입을 고려하는 것이 바람직합니다. 필드 주입은 테스트 코드나 아주 간단한 구성 외에는 사용을 지양해야 합니다.

스프링 컨텍스트의 고급 메커니즘과 문제 해결

스프링 컨텍스트의 기본 원리를 이해했다면, 이제 더 깊은 수준의 메커니즘과 이를 활용한 문제 해결 방법을 살펴볼 차례입니다. BeanPostProcessor와 ObjectProvider는 스프링의 고급 기능을 이해하고 일반적인 개발 난제를 해결하는 데 핵심적인 역할을 합니다.

빈 후처리기(BeanPostProcessor): 프록시와 AOP의 비밀

BeanPostProcessor는 스프링 컨테이너의 가장 강력한 확장 지점 중 하나입니다. 이 인터페이스를 구현하면, 컨테이너가 생성하는 모든 빈의 초기화 과정에 개입하여 빈 인스턴스를 조작하거나 심지어 완전히 다른 객체로 교체할 수 있습니다.  

BeanPostProcessor는 두 개의 메서드를 제공합니다: postProcessBeforeInitialization과 postProcessAfterInitialization. 이들은 이름에서 알 수 있듯이, 각 빈의 고유한 초기화 메서드(@PostConstruct 등)가 호출되기 직전과 직후에 각각 실행됩니다.  

이 BeanPostProcessor가 바로 스프링 AOP와 @Transactional 같은 선언적 기능의 비밀을 푸는 열쇠입니다. 이들이 동작하는 과정은 다음과 같습니다.

  1. 개발자가 특정 서비스 클래스의 메서드에 @Transactional 어노테이션을 추가합니다.  

  2. 애플리케이션이 시작되면서 스프링 컨테이너는 해당 서비스 클래스의 인스턴스를 생성합니다.

  3. 생성된 원본 빈 객체는 최종적으로 컨테이너에 등록되기 전에, 등록된 모든 BeanPostProcessor에게 전달됩니다.  

  4. 이때, 스프링에 내장된 AnnotationAwareAspectJAutoProxyCreator라는 특별한 BeanPostProcessor가 이 빈을 검사합니다. 이 후처리기는 빈의 클래스에 AOP 포인트컷(예: @Transactional 어노테이션이 붙은 메서드)과 일치하는 부분이 있는지 확인합니다.

  5. 일치하는 메서드를 발견하면, 이 후처리기는 원본 빈 객체를 그대로 반환하지 않습니다. 대신, 원본 빈을 감싸는 동적 프록시(Dynamic Proxy) 객체를 생성합니다. 이 프록시 객체 안에는 트랜잭션을 시작하고, 커밋하거나 롤백하는 로직이 포함되어 있습니다.  

  6. 최종적으로 컨테이너에 저장되고 다른 곳에 주입되는 것은 원본 객체가 아닌 바로 이 프록시 객체입니다.  

  7. 다른 컴포넌트에서 이 서비스를 호출하면, 실제로는 프록시 객체의 메서드를 호출하게 됩니다. 프록시는 트랜잭션을 시작한 뒤, 실제 로직 수행을 원본 객체에게 위임하고, 로직이 끝나면 트랜잭션을 마무리합니다.  

이처럼 AOP는 별개의 마법적인 시스템이 아니라, BeanPostProcessor라는 핵심적인 빈 생명주기 콜백을 통해 컨테이너에 깊숙이 통합된 기능입니다. 이 원리를 이해하면, 왜 같은 클래스 내에서 @Transactional이 붙은 다른 메서드를 호출할 때 트랜잭션이 적용되지 않는지(프록시를 거치지 않고 this를 통해 원본 객체를 직접 호출하기 때문)와 같은 고질적인 문제를 명확하게 파악하고 해결할 수 있습니다.  

싱글톤과 프로토타입의 동거: ObjectProvider로 문제 해결

개발 중에 흔히 마주치는 문제 중 하나는 singleton 스코프의 빈에 prototype 스코프의 빈을 주입할 때 발생합니다. 싱글톤 빈은 컨테이너 시작 시 단 한 번만 생성되고, 이때 의존성 주입도 한 번만 일어납니다. 따라서 싱글톤 빈은 단 하나의 프로토타입 인스턴스에 대한 참조를 계속 유지하게 되어, 요청할 때마다 새로운 객체를 얻고자 했던 프로토타입 스코프의 목적이 무의미해집니다.  

이 문제의 올바른 해결책은 의존성 주입(DI) 패러다임을 의존성 조회(Dependency Lookup, DL)로 전환하는 것입니다. 즉, 생성 시점에 '밀어 넣어주는(push)' 방식이 아니라, 필요한 시점에 '가져오는(pull)' 방식으로 변경하는 것입니다.  

이때 사용되는 것이 바로 ObjectProvider입니다. 프로토타입 빈을 직접 주입받는 대신, ObjectProvider을 주입받습니다.  

ObjectProvider는 필요한 빈을 찾아주는 일종의 팩토리 역할을 합니다. 싱글톤 빈 내에서 프로토타입 객체가 필요할 때마다, 주입받은 ObjectProvider의 getObject() 메서드를 호출합니다. 이 메서드는 호출될 때마다 스프링 컨테이너에 새로운 프로토타입 빈을 요청하여, 항상 새로운 인스턴스를 반환해 줍니다.  

예를 들어, 다음과 같은 코드를 통해 문제를 해결할 수 있습니다.


@Component
public class SingletonBean {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    @Autowired
    public SingletonBean(ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int usePrototype() {
        // getObject() 호출 시 항상 새로운 PrototypeBean 인스턴스가 생성됨
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

이처럼 ObjectProvider를 사용하면 싱글톤 빈의 생명주기 내에서도 프로토타입 빈의 스코프를 올바르게 유지하며 사용할 수 있습니다.

스프링 부트: 자동 설정의 원리

스프링 부트(Spring Boot)의 가장 큰 매력은 복잡한 설정을 대신 해주는 '자동 설정(Auto-Configuration)' 기능입니다. 하지만 이 역시 마법이 아니라, 지금까지 살펴본 스프링 컨텍스트의 핵심 기능들을 매우 영리하게 조합하여 구현된 것입니다.

자동 설정의 원리를 이해하는 것은 코어 스프링 프레임워크의 개념이 현대적인 애플리케이션 개발에서 어떻게 활용되는지 보여주는 좋은 예시입니다. 자동 설정의 핵심 요소는 다음과 같습니다.

  • @EnableAutoConfiguration: 모든 스프링 부트 애플리케이션의 시작점에 존재하는 @SpringBootApplication 어노테이션 내부에 포함되어 있으며, 자동 설정 프로세스를 활성화하는 트리거 역할을 합니다.  

  • spring.factories: 스프링 부트는 애플리케이션의 클래스패스에 포함된 모든 JAR 파일에서 META-INF/spring.factories라는 특별한 파일을 찾습니다. 이 파일에는 org.springframework.boot.autoconfigure.EnableAutoConfiguration 키 아래에 자동 설정 후보인 @Configuration 클래스들의 목록이 정의되어 있습니다.  

  • 조건부 어노테이션 (@ConditionalOn...): 자동 설정의 '지능'을 담당하는 부분입니다. spring.factories에 등록된 각 설정 클래스는 다양한 @Conditional 어노테이션을 통해 특정 조건이 만족될 때만 활성화되도록 설계되었습니다.

    • @ConditionalOnClass: 특정 클래스가 클래스패스에 존재할 때만 설정을 활성화합니다. 예를 들어, DataSource.class가 존재해야 DataSourceAutoConfiguration이 동작을 시도합니다.  

    • @ConditionalOnMissingBean: 특정 타입의 빈이 사용자에 의해 이미 등록되지 않았을 경우에만 빈을 등록합니다. 이는 항상 사용자의 수동 설정이 자동 설정보다 우선순위를 갖도록 보장하는 매우 중요한 장치입니다.  

    • @ConditionalOnProperty: application.properties나 application.yml 파일에 특정 프로퍼티가 설정되어 있을 때만 설정을 활성화합니다.  

이 요소들이 결합하여 정교한 BeanDefinition 등록 과정을 만들어냅니다. 예를 들어, spring-boot-starter-data-jpa 의존성을 추가했을 때 DataSource가 자동으로 설정되는 과정은 다음과 같습니다.

  1. 의존성 추가로 인해 클래스패스에 DataSource.class (Tomcat JDBC 등)와 같은 관련 라이브러리가 포함됩니다.

  2. 애플리케이션 시작 시 @EnableAutoConfiguration이 spring-boot-autoconfigure.jar 내의 spring.factories 파일을 읽어 DataSourceAutoConfiguration을 자동 설정 후보로 인식합니다.  

  3. 컨테이너는 DataSourceAutoConfiguration을 평가합니다. 이 클래스에는 @ConditionalOnClass({ DataSource.class,... }) 어노테이션이 붙어 있습니다. 클래스패스에 DataSource.class가 존재하므로 이 조건은 통과됩니다.

  4. DataSourceAutoConfiguration 내부에는 DataSource를 생성하는 @Bean 메서드가 있으며, 이 메서드에는 @ConditionalOnMissingBean(DataSource.class) 어노테이션이 붙어 있습니다.  

  5. 컨테이너는 "사용자가 직접 DataSource 타입의 빈을 정의했는가?"를 확인합니다. 만약 사용자가 자신만의 DataSource 빈을 설정했다면, 자동 설정에 의한 빈 등록은 건너뜁니다. 그렇지 않은 경우에만, 컨테이너는 기본 DataSource 빈을 생성하고 등록합니다.

이 과정을 통해 스프링 부트는 자바 기반 설정(@Configuration, @Bean)과 강력한 조건부 로직을 활용하여, 런타임에 BeanDefinition의 등록을 지능적으로 제어합니다. 이는 별개의 프레임워크가 아니라, 핵심 스프링 컨텍스트를 매우 효율적이고 주도적으로 사용하는 방식입니다. 개발자의 편의를 극대화하면서도, 언제든지 사용자가 직접 설정을 재정의할 수 있는 유연성을 보장하는 것이 스프링 부트 자동 설정의 진정한 가치입니다.

결론: 스프링 컨텍스트

지금까지 우리는 스프링의 심장부인 '컨텍스트'를 해부하며 알아보았습니다. 스프링의 근본 철학인 제어의 역전(IoC)과 의존성 주입(DI)에서 시작하여, 컨테이너의 핵심 구현체인 ApplicationContext의 역할과 기능을 살펴보았습니다. 또한, 컨테이너가 관리하는 가장 기본적인 단위인 '빈'의 정교한 생명주기와 스코프를 이해하고, 생성자 주입 방식이 왜 현대적인 개발의 표준이 되었는지에 대해 논의했습니다.

나아가 BeanPostProcessor를 통해 스프링 AOP와 @Transactional의 '자동'이 어떻게 구현되는지 그 비밀을 파헤쳤고, ObjectProvider를 활용하여 싱글톤과 프로토타입 스코프 간의 고질적인 문제를 해결하는 방법을 배웠습니다. 마지막으로, 이 모든 핵심 원리들이 어떻게 스프링 부트의 강력한 '자동 설정' 기능으로 집대성되었는지 확인했습니다.

알겠습니다. 여러 번 요청하게 해드려 죄송합니다.

이전에 정리해 드렸던 내용처럼, 전체 목록에서 핵심 주제별로 엄선한 참고 자료 목록을 다시 만들어 드리겠습니다.

레퍼런스

1. IoC 컨테이너 (BeanFactory와 ApplicationContext)

  • BeanFactory와 Application Context 빈 로딩시점의 차이 - velog
  • [Spring] IoC 컨테이너 (Inversion of Control) 란? - 슬기로운 개발생활 - 티스토리
  • [Spring Framework]IoC와 DI 컨테이너 개념정리 - BELKLOG - 티스토리

2. 빈(Bean) 생명주기 및 스코프

  • [Spring Core] Bean Scope(빈 스코프)와 Bean의 LifeCycle(빈의 생명주기) - DEVLOG
  • [Spring] 빈 생명 주기 콜백 (Bean LifeCycle) @PostConstruct / @PreDestroy - HS_dev_log
  • [Spring]프로토 타입 빈 사용시 생기는 문제점 해결하기(ObjectProvider와 JSR-330 Provider)

3. 의존관계 주입 (Dependency Injection)

  • [SPRING] 생성자 주입을 사용해야 하는 이유, 필드인젝션이 좋지 않은 이유
  • Spring 의존성 주입 방법 중 생성자 주입을 사용해야 하는 이유 - 화음을 좋아하는 리차드
  • [Spring] 의존성 주입 3가지 방법 - (생성자 주입, Field 주입, Setter 주입) - 슬기로운 개발생활

4. 스프링 부트 자동 설정 (Spring Boot Auto-Configuration)

  • Spring Boot AutoConfiguration 동작 원리 - velog
  • 스프링 부트의 Autoconfiguration 원리 및 만들어 보기 - 민동현

5. AOP와 @Transactional

  • [Spring] @Transactional 간략 정리 ( AOP, Proxy, 동작 원리, 특이사항 ) - 좋아질거야
  • @Transactional과 스프링 트랜잭션 AOP - 꿈찾원 - 티스토리

6. 순환 참조 (Circular Dependencies)

  • 스프링 순환 참조(spring circular reference) - Catsbi's DLog
profile
Small Big Cycle

0개의 댓글