백엔드

Spring은 어떻게 객체를 생성하고 연결할까? — IoC 컨테이너 내부 동작 원리

소진2 2026. 2. 15. 22:39

안녕하세요! 오랜만입니다!!

벌써 2월의 반이 지나갔네요..... 시간이 넘 빨ㄹ라효.......

요즘 백엔드 공부를 하고있는데, 스프링 부트 관련된 입문 강의를 듣다가 이해 안 가는 부분이 좀 있었어서 먼저 정리하고 가려고합니다!

아마 프론트랑 백엔드의 관점 차이로인해서 헷갈리는 것 같아요..! 그래도! 배워가니 참 재밌네용 ㅎㅎㅎ


1. 개요

Spring을 사용하면 개발자는 new를 직접 호출하지 않아도 객체를 사용할 수 있습니다.
이때 의존성은 자동으로 주입되며, 객체 생명주기도 프레임워크가 관리합니다.

  • IoC(Inversion of Control)의 정확한 의미
  • ApplicationContext의 역할
  • Bean Definition과 Bean Instance의 차이
  • Spring이 객체를 생성하고 의존성을 연결하는 내부 동작 과정
  • 왜 getBean()을 직접 사용하지 말라고 할까

위 내용을 "공식 문서 기반"으로 내부 동작까지 설명해보겠습니다!


2. 정의 (What)

2.1 IoC (Inversion of Control)

IoC는 객체의 생성과 의존성 연결에 대한 제어권을 객체 자신이 아니라 외부 컨테이너에 위임하는 설계 원칙입니다.

Spring에서는 IoC를 DI(Dependency Injection) 방식으로 구현합니다.


2.2 ApplicationContext (IoC Container)

org.springframework.context.ApplicationContext는 Spring IoC Container를 나타내는 인터페이스입니다.

공식 문서에 따르면 Container는 다음 책임을 가집니다.

  • Bean을 생성한다 (instantiate)
  • Bean을 설정한다 (configure)
  • Bean을 조립한다 (assemble)
  • Bean의 생명주기를 관리한다

즉, ApplicationContext는 BeanFactory를 확장한 인터페이스이며,
Bean 생성 및 관리 기능 외에도 이벤트 발행, 국제화, 환경 추상화 기능을 포함합니다.


2.3 Bean Definition

Bean Definition은 Spring이 객체를 생성하기 위해 사용하는 메타데이터입니다.

여기서 중요하게 구분해야하는 부분이 있는데요!

  • Bean Definition → 객체를 만들기 위한 설계도
  • Bean Instance → 실제 생성된 객체

이렇게 컨테이너는 Definition을 먼저 읽고, 이후 필요 시 객체를 생성합니다


3. 왜 필요한가 (Why)

3.1 직접 객체를 생성하면 발생하는 문제

OrderService service = new OrderService(new OrderRepository());

이 코드는 다음과 같은 구조적 문제를 만듭니다.

 

1) 객체 생성 책임이 코드에 고정된다

OrderService가 OrderRepository 구현체를 직접 생성하면 두 클래스는 생성 단계에서 강하게 결합됩니다.

그렇게되면 아래와 같은 문제가 발생합니다.

  • 구현체가 변경되면 호출 지점을 모두 수정해야 함
  • 생성 로직이 여러 계층에 분산될 수 있음
  • 의존성 그래프를 한눈에 파악하기 어려움

2) 구현 교체가 어려워짐

private final PaymentService paymentService = new KakaoPayService();

결제 수단이 하나일 때는 문제가 없습니다.
그러나 만약 결제 수단에 네이버페이를 추가해야 한다면 어떻게 될까요?

  • KakaoPayService가 등장하는 모든 위치를 수정해야 함
  • 일부는 교체되고 일부는 남을 가능성이 있음
  • 기능이 분산된 만큼 수정 범위도 분산

즉, 변경 비용이 구조에 의해 증가하게 됩니다.

3) 테스트가 어려워짐

class OrderService {
    private final PaymentService paymentService =
        new KakaoPayService();
}

이 구조에서는 아래와 같은 문제가 발생합니다.

  • Mock 주입이 불가능
  • 외부 API 호출이 테스트에 포함
  • 단위 테스트가 통합 테스트로 변질

이처럼 테스트가 어려워지면 리팩토링 비용이 증가하고, 그 결과 구조는 더 고착될 수 있습니다.

4) 서버 환경에서는 문제가 증폭

서버 애플리케이션은 아래와 같은 특징이 있습니다.

  • 계층이 깊다.
  • 객체 수가 많다.
  • 기능이 반복적으로 확장된다.
  • 여러 개발자가 동시에 수정한다.

이 환경에서 객체 생성 책임이 코드 전반에 흩어지면 구조를 사람이 직접 통제하기 어렵습니다.

즉, 결과적으로 결합도가 증가하게되고, 유지보수 비용은 시간에 비례해 상승하게 됩니다.


3.2 IoC를 사용하는 이유

IoC는 객체 생성과 의존성 연결의 제어권을 애플리케이션 코드에서 제거하고 컨테이너로 이동시킵니다.

Spring에서는 ApplicationContext가 이 역할을 수행합니다.

이로인해 구조는 다음과 같이 바뀝니다.

1) 객체 생성 책임이 중앙화

  • 객체 생성은 컨테이너가 담당
  • 비즈니스 코드는 생성 로직을 포함하지 않음
  • 의존성 그래프가 한 지점에서 관리

즉, 생성 책임이 분산되지 않게됩니다.

2) 구현 교체가 구조적으로 가능

public OrderService(PaymentService paymentService)

OrderService는 구현체를 모르고 오직 인터페이스에만 의존합니다.

구현 교체는 Bean 설정 변경으로 해결됩니다.

  • 코드 수정 없이 교체 가능
  • 변경 범위 최소화
  • 확장에 유리한 구조

3) 테스트가 구조적으로 가능

OrderService service = new OrderService(new MockPaymentService());

의존성이 외부에서 주입되므로 Mock 교체가 자연스럽게 가능하게됩니다.

단위 테스트가 구조적으로 보장됩니다.


4. 동작 방식 (How)

이제 내부 동작 과정을 단계별로 설명해보겠습니다.

4.1 전체 흐름

Spring IoC Container의 동작 흐름은 다음과 같습니다.

1. 설정 메타데이터 로딩
2. BeanDefinition 생성
3. BeanFactory에 등록
4. Bean 생성
5. 의존성 주입
6. 초기화 콜백 실행
7. 사용

4.2 설정 메타데이터 로딩

Spring은 다음 중 하나의 형식을 읽습니다.

  • 애노테이션 기반 설정
  • Java 설정 클래스 (@Configuration)
  • XML 설정

이 설정 정보는 내부적으로 BeanDefinition 객체로 변환됩니다.

한 가지 중요한 점은, 이 단계에서는 아직 객체가 생성되지 않습니다!


4.3 BeanDefinition 생성

BeanDefinition에는 다음 정보가 저장됩니다.

1. ConfigurationClassPostProcessor가 설정 클래스 파싱
2. BeanDefinitionRegistry에 BeanDefinition 등록
3. DefaultListableBeanFactory가 BeanDefinition 보관

이 시점까지는 설계도만 존재합니다.


4.4 Bean 생성 (Singleton 기준)

ApplicationContext가 초기화되면 refresh() 과정에서 싱글톤 스코프의 Bean을 미리 생성하는 단계가 실행됩니다.

이 과정에서 preInstantiateSingletons()가 호출되며, 등록된 Singleton Bean들이 생성됩니다.

Bean의 실제 생성은 createBean()에서 시작되며, 내부적으로 다음 단계를 거칩니다.

  • 객체 인스턴스 생성
  • 의존성 주입
  • 초기화 및 후처리 적용

4.5 의존성 주입 과정

예시 코드를 봅시다!

public OrderService(OrderRepository repository)

컨테이너는 다음 순서로 동작합니다.

  1. 생성자 파라미터 분석
  2. 필요한 타입의 Bean 탐색
  3. 없으면 먼저 생성
  4. 생성된 객체를 주입

Spring은 생성자 분석 후 resolveDependency()를 통해 필요한 Bean을 탐색하고 주입합니다.

이 과정에서 타입 기반 탐색이 수행되며, 동일 타입이 여러 개인 경우 Qualifier 또는 Primary 설정이 사용됩니다.

이 과정을 DI(Dependency Injection)라고 합니다.


4.6 BeanPostProcessor 적용

Bean 생성 이후 다음 과정이 수행됩니다.

  • @Autowired 처리 (AutowiredAnnotationBeanPostProcessor)
  • AOP 프록시 생성 (AbstractAutoProxyCreator)
  • @Transactional 적용 (ProxyTransactionManagementConfiguration)
  • 초기화 콜백 실행

BeanPostProcessor는 Bean 초기화 전·후에 개입할 수 있는 확장 포인트이며,
Spring의 대부분의 부가 기능(@Autowired, AOP, @Transactional)은 이 메커니즘을 통해 구현됩니다.

즉, Spring의 확장 구조는 상속이 아니라 Bean 생성 과정에 끼어드는 후처리 체인 구조로 설계되어 있습니다.


5. 왜 getBean()을 직접 사용하면 안될까?

ApplicationContext는 getBean()을 제공하지만,
애플리케이션 코드에서 아래 예시처럼 직접 사용하는 것은 권장되지 않습니다.

context.getBean(OrderRepository.class);

 

이유는 아래와 같습니다.

  • 비즈니스 코드가 Spring API에 구조적으로 의존
    객체가 ApplicationContext에 직접 의존하게 되므로, 도메인 객체가 프레임워크 API에 결합됩니다.
  • 단위 테스트가 어려워짐
    객체를 생성하려면 ApplicationContext가 필요해지므로, 테스트가 Spring 환경에 종속됩니다.
  • IoC 원칙이 부분적으로 무너짐
    의존성을 주입받는 구조가 아니라, 객체가 직접 찾아오는 구조가 되기 때문입니다.
  • Service Locator 패턴이 되어 의존성이 숨겨짐
    생성자에 필요한 의존성이 명시되지 않으므로, 어떤 객체에 의존하는지 코드만 보고 파악하기 어렵습니다.

그럼 이제 Spring이 의도하는 방식을 알아봅시다!

@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

이 구조에서는 아래와 같은 특징을 가지고 있습니다.

  • OrderService는 Spring을 모름
  • 의존성만 선언
  • 생성 책임은 컨테이너가 가짐

이것이 IoC와 DI가 결합된 구조입니다.


6. 예시 (Example)

최소 단위 예시를 통해 전체 흐름을 정리해보겠습니다.

@Repository
public class OrderRepository {
}
@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

 

위 코드의 동작 순서는 아래와 같습니다.

1. 컴포넌트 스캔
2. BeanDefinition 생성
3. OrderRepository 생성
4. OrderService 생성
5. 생성자에 OrderRepository 주입
6. 초기화 완료

 

이렇게 개발자는 new를 호출하지 않고 컨테이너가 객체 그래프를 완성합니다.


7. 정리 (Summary)

  • IoC는 객체 생성 제어권을 컨테이너에 위임하는 설계 원칙이다.
  • ApplicationContext는 IoC를 수행하는 실행 주체이다.
  • BeanDefinition은 객체 생성 설계도이다.
  • 컨테이너는 Definition을 읽고 Bean을 생성·주입한다.
  • 애플리케이션 코드는 getBean()을 직접 호출하지 않아야 한다.

참고 자료 (References)


마무리 하며..

이렇게 Spring은 단순 객체 생성 도구가 아니라, 객체 그래프를 구성하고 생명주기를 관리하는 런타임 시스템이라는 점을 이해하게 되었습니다.

iOS 앱 개발을 할 때는 객체 생성부터 테스트까지 직접 통제해왔기 때문에, 처음에는 “왜 객체 생성을 굳이 위임해야 할까?”라는 의문이 들었었습니다.

그러나 백엔드는 한 번 배포되면 장기간 안정적으로 운영되어야 하고, 여러 계층과 기능이 유기적으로 연결되는 구조를 갖습니다. 이러한 환경에서는 객체 생성과 의존성 관리를 중앙에서 통제하는 구조가 유지보수성과 확장성을 확보하는 데 필수적이라는 점을 알게 되었습니다.

현재는 직접 코드를 작성해보며 구조를 체감하고 있으니! 다음 글에서는 실제 동작 원리를 더 기술적인 관점에서 다뤄보겠습니다ㅎㅎ

읽어주셔서 감사합니다아