CS/Spring

스프링 컨테이너와 IoC/DI/AOP — 빈이 태어나는 자리

dding-shark 2026. 4. 11. 23:23
728x90

스프링 컨테이너와 IoC/DI/AOP — 빈이 태어나는 자리


들어가며

스프링을 처음 열면 코드보다 단어가 먼저 막힌다. "컨테이너", "빈", "주입", "관심사 분리" — 어딘가에서 본 것 같지만 정의를 물으면 뚜렷하게 답하기 어려운 단어들이 한꺼번에 쏟아진다. 코드를 따라 치기 전에 이 단어들이 왜 필요한지부터 잡아야 다음이 편하다.

아래 다섯 개는 특히 처음에 밟기 쉬운 지점이다.

  • IoC와 DI를 같은 것으로 착각한다 — IoC는 원칙이고 DI는 그 원칙을 구현하는 수단 중 하나다
  • @Autowired 필드 주입을 쓰다가 테스트에서 NPE를 만난다 — 컨테이너 밖에서 new로 만들면 필드는 null이다
  • @Transactional을 같은 클래스 내부에서 호출하면 트랜잭션이 안 걸린다 — 프록시를 거치지 않는 self-invocation 문제다
  • 싱글톤 빈에 가변 상태를 두면 멀티스레드에서 값이 섞인다 — 싱글톤은 인스턴스가 하나라는 뜻이다
  • AOP는 OOP를 대체하는 게 아니라 보완한다 — 횡단 관심사만 분리하는 도구다

이 글은 왜(문제) → 컨테이너(그릇) → IoC(원칙) → DI(수단) → AOP(확장) → 통합 → 실무 순으로, Spring Framework 6.x / Java 17+ 기준으로 정리한다.


목차


1) 들어가며: 스프링을 처음 만났을 때의 낯섦

1-1) "컨테이너", "빈", "주입"… 이 단어들은 어디서 왔는가

"컨테이너"는 일상에서 화물을 담는 상자다. 스프링에서는 객체(빈)의 생명주기와 의존성을 관리하는 런타임 환경을 뜻한다. "빈(Bean)"은 원래 JavaBeans 스펙에서 온 단어로, getter/setter 규약을 따르는 재사용 가능한 컴포넌트를 가리켰다. 스프링에서는 좀 더 넓게, 컨테이너가 생성하고 관리하는 객체 전부를 빈이라 부른다. "주입(Injection)"은 의존하는 객체를 직접 만들지 않고 외부에서 넣어 주는 행위다.

1-2) 이 글이 답하려는 3가지 질문

  • Q1: 왜 객체를 직접 만들지 않는가?
  • Q2: 컨테이너는 정확히 뭘 하는가?
  • Q3: IoC/DI/AOP는 각각 어떤 역할이고, 어떻게 맞물리는가?

이 세 질문에 답하고 나면 스프링의 뼈대가 보인다. 나머지는 살을 붙이는 일이다.


2) 스프링이 없던 시절의 코드: 문제의 출발점

2-1) 객체가 객체를 직접 만든다는 것

스프링 없이 주문 서비스를 짠다고 해 보자.

public class OrderService {
    // OrderService가 JdbcOrderRepository를 직접 생성한다
    private final OrderRepository repository = new JdbcOrderRepository();

    public void placeOrder(Order order) {
        repository.save(order);
    }
}

OrderService는 자기가 쓸 OrderRepository직접 골라서 직접 만들고 있다. 이 코드만 보면 별문제 없어 보인다.

2-2) new 연산자 하나가 만드는 강한 결합

new가 나쁜 게 아니다. DTO나 VO처럼 순수한 데이터 객체를 new로 만드는 건 완전히 올바르다. 문제는 역할을 수행하는 컴포넌트를 new로 고정하는 것이다. 위 코드에서 JdbcOrderRepositoryJpaOrderRepository로 바꾸려면 OrderService 소스를 열어야 한다. 구현이 바뀔 때마다 사용하는 쪽도 함께 바뀐다 — 이게 **강한 결합(tight coupling)**이다.

2-3) 테스트가 어려워지는 이유

OrderService를 단위 테스트하고 싶다. 그런데 new JdbcOrderRepository()가 안에 박혀 있으니, 이 테스트는 실제 DB 연결이 있어야 돌아간다. 가짜(mock) 저장소를 끼워 넣을 방법이 없다. 객체 생성의 결정권이 OrderService 안에 있기 때문이다.

2-4) 변경이 전염되는 이유

JdbcOrderRepository의 생성자 시그니처가 바뀌면 — 예를 들어 DataSource를 받도록 변경되면 — new JdbcOrderRepository()를 쓰고 있는 모든 곳에서 컴파일 에러가 터진다. 생성 지점이 10곳이면 10곳 모두 고쳐야 한다. 이 전염이 곧 유지보수 비용이다.


3) 스프링 컨테이너: 이 모든 걸 담는 그릇

3-1) "컨테이너"라는 단어의 의미

Docker 컨테이너와는 다르다. 스프링 컨테이너는 빈의 생명주기(생성 → 초기화 → 사용 → 소멸)와 의존성을 관리하는 런타임 환경이다. 공식 문서는 이렇게 시작한다.

"The org.springframework.beans and org.springframework.context packages are the basis for Spring Framework's IoC container."

Spring Framework Reference: IoC Container

beanscontext 패키지가 컨테이너의 뼈대다.

3-2) BeanFactory와 ApplicationContext: 차이와 계층

스프링 컨테이너의 핵심 인터페이스는 두 개다.

항목 BeanFactory ApplicationContext
역할 빈 생성·조회의 최소 계약 BeanFactory + 이벤트, 국제화, 환경 추상화
BeanPostProcessor 수동 등록 필요 자동 등록
빈 로딩 Lazy (요청 시 생성) Eager (기동 시 생성)
실무 사용 거의 안 씀 이것을 쓴다

"Because an ApplicationContext includes all the functionality of a BeanFactory, it is generally recommended over a plain BeanFactory"

Spring Framework Reference: BeanFactory

실무에서 BeanFactory를 직접 다루는 일은 거의 없다. ApplicationContext가 상위 호환이다.

3-3) 컨테이너가 실제로 하는 일 3가지: 등록 → 해결 → 제공

  1. 등록: @Component, @Bean 등의 메타데이터를 스캔해서 "어떤 클래스를 빈으로 관리할지" 수집한다.
  2. 해결: 빈 사이의 의존 관계 그래프를 분석하고 생성 순서를 결정한다.
  3. 제공: getBean()이나 DI를 통해 완성된 빈을 사용자에게 건넨다.

3-4) 컨테이너 생성부터 빈이 손에 들어오기까지의 한 사이클

  1. ApplicationContext 생성 (예: SpringApplication.run())
  2. @ComponentScan 경로에서 빈 후보 클래스를 찾는다
  3. BeanDefinition 메타데이터를 등록한다
  4. 의존 관계를 분석해 생성 순서를 정한다
  5. 빈 인스턴스를 생성하고 의존성을 주입한다
  6. BeanPostProcessor가 후처리를 수행한다 (AOP 프록시 생성도 여기서)
  7. 초기화 콜백(@PostConstruct)을 호출한다

여기서 틀리기 쉬운 지점이 싱글톤 스코프다. 스프링의 싱글톤은 JVM 전체가 아니라 해당 Spring IoC 컨테이너당 하나다. 컨테이너가 두 개면 같은 클래스의 빈도 두 개 존재한다. 그리고 싱글톤 빈에 가변 인스턴스 필드를 두면, 여러 스레드가 같은 인스턴스를 공유하므로 값이 섞인다. 싱글톤 빈은 **무상태(stateless)**로 설계하는 것이 원칙이다.


4) IoC: 제어의 역전

4-1) "제어"란 무엇을 말하는가

여기서 말하는 "제어(Control)"는 객체의 생성, 의존성 결정, 생명주기 관리를 뜻한다. §2에서 OrderServicenew JdbcOrderRepository()를 직접 호출한 것 — 이게 "내가 제어하고 있다"는 상태다.

4-2) 할리우드 원칙: "부르지 마라, 우리가 부른다"

제어의 역전(IoC, Inversion of Control)은 이 제어의 방향을 뒤집는다. 내가 프레임워크를 호출하는 게 아니라, 프레임워크가 내 코드를 호출한다.

"The control is inverted — it calls me rather me calling the framework."

— Martin Fowler, InversionOfControl

일상으로 비유하면 이렇다. 식당에서 직접 재료를 사서 요리하는 게 전통적 제어다. 배달 앱에서 메뉴만 고르면 요리사가 만들어서 가져다주는 게 IoC다. 내가 결정하는 것은 **"무엇을 먹을지"**뿐이고, "어떻게 만들지"는 앱(프레임워크)이 알아서 한다.

4-3) 자주 생기는 오해: IoC ≠ DI

IoC와 DI를 같은 것으로 쓰는 글이 많지만, 정확히는 다르다.

"Inversion of Control is too generic a term... we settled on the name Dependency Injection."

— Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern

Fowler가 명시한 대로, IoC는 너무 넓은 개념이라 구체적인 패턴에 DI라는 이름을 따로 붙인 것이다. 스프링 공식 문서도 **"DI is a specialized form of IoC"**라고 정리한다. IoC의 다른 구현 형태로는 템플릿 메서드 패턴, 이벤트 콜백 등이 있다. DI는 그중 의존성을 외부에서 넣어 주는 방식에 해당한다.


5) DI: 의존성 주입

5-1) "의존성"이란 말의 정확한 뜻

OrderService가 동작하려면 OrderRepository가 있어야 한다. 이때 OrderServiceOrderRepository에 **의존(depend)**한다. 의존성(Dependency)이란 결국 **"내가 동작하려면 필요한 다른 객체"**다.

5-2) "주입"이라는 단어가 말하는 것

new로 직접 만드는 대신, 외부에서 생성자 파라미터로 넘겨받는 것 — 이게 주입이다.

// Before: 직접 생성
private final OrderRepository repository = new JdbcOrderRepository();

// After: 외부에서 주입
public OrderService(OrderRepository repository) {
    this.repository = repository;  // 누가 넘겨주든 받기만 한다
}

이렇게 바뀌면 OrderServiceJdbcOrderRepository를 알 필요가 없다. 인터페이스 OrderRepository만 알면 된다. 테스트에서는 가짜 구현을 넣고, 운영에서는 진짜 구현을 넣으면 된다.

5-3) 생성자 주입: 코드로 보기

@Component
public class OrderService {
    private final OrderRepository repository;

    // Spring 4.3+: 생성자가 하나면 @Autowired 생략 가능
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

"The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null."

Spring Framework Reference: Dependency Injection

final 필드 + 생성자 = 불변 객체. 생성 시점에 모든 의존성이 채워지므로 null이 들어올 여지가 없다.

5-4) 세터 주입: 코드로 보기

@Component
public class OrderService {
    private OrderRepository repository;

    @Autowired
    public void setRepository(OrderRepository repository) {
        this.repository = repository;
    }
}

"Setter injection should primarily only be used for optional dependencies..."

세터 주입은 선택적 의존성에 쓴다. 필수 의존성에 세터를 쓰면 주입이 빠졌을 때 NullPointerException이 런타임에 터진다.

5-5) 필드 주입: 코드로 보기

@Component
public class OrderService {
    @Autowired
    private OrderRepository repository;  // 필드에 바로 주입
}

여기서 틀리기 쉽다. 컨테이너가 만든 빈이라면 @Autowired가 동작하지만, **테스트에서 new OrderService()로 직접 생성하면 repositorynull**이다. 리플렉션 없이는 필드에 값을 넣을 방법이 없기 때문이다. 이게 필드 주입의 가장 큰 함정이다.

5-6) 세 방식 비교표

항목 생성자 주입 세터 주입 필드 주입
불변성 final 가능 불가 불가
필수/선택 필수 의존성에 적합 선택 의존성에 적합 구분 불명확
테스트 new로 바로 생성 가능 setter 호출 필요 리플렉션 필요
null 안전성 컴파일 시점에 보장 런타임 NPE 가능 런타임 NPE 가능
순환 참조 기동 시 즉시 실패 (설계 문제 조기 발견) 허용됨 허용됨

5-7) 왜 생성자 주입이 표준인가

생성자 주입은 불변 → 스레드 안전, 필수 의존성 누락 → 컴파일 에러, 순환 의존성 → 기동 실패로 조기 발견이라는 세 가지 이점을 한꺼번에 준다. 순환 의존성이 뜨면 @Lazy로 우회하기 전에 설계를 먼저 의심하는 게 맞다. 순환이 생겼다는 건 두 클래스의 책임이 제대로 나뉘지 않았다는 신호일 수 있다.

Spring 4.3부터 생성자가 하나뿐이면 @Autowired를 생략할 수 있다. 실무에서는 Lombok의 @RequiredArgsConstructor로 보일러플레이트까지 제거하는 게 일반적이다.


6) AOP: 횡단 관심사란 무엇이고 왜 필요한가

6-1) "횡단 관심사"가 무엇인가

주문 서비스에는 "주문 저장"이라는 **핵심 관심사(core concern)**가 있다. 그런데 로깅, 트랜잭션 관리, 보안 검사 같은 코드가 주문뿐 아니라 결제, 배송, 회원 서비스에도 똑같이 반복된다. 이렇게 여러 모듈을 가로지르며(cross-cutting) 나타나는 관심사를 **횡단 관심사(cross-cutting concern)**라 한다.

6-2) 로깅 · 트랜잭션 · 보안이 어디서나 반복되는 이유

public void placeOrder(Order order) {
    log.info("placeOrder 시작");           // 로깅
    tx.begin();                            // 트랜잭션 시작
    repository.save(order);                // 핵심 로직
    tx.commit();                           // 트랜잭션 커밋
    log.info("placeOrder 끝");             // 로깅
}

cancelOrder(), refundOrder() 같은 다른 메서드에도 로깅과 트랜잭션 코드가 거의 복붙된다. 핵심 로직은 한 줄인데 부가 코드가 네 줄이다.

6-3) OOP만으로는 왜 부족한가

상속으로 공통 로직을 올리면? 다중 상속이 안 되니 한계가 뚜렷하다. 인터페이스 default 메서드? 트랜잭션처럼 메서드 실행 전후를 감싸야 하는 로직에는 맞지 않는다. AOP는 OOP를 대체하는 게 아니라, OOP만으로 깔끔하게 분리하기 어려운 횡단 관심사를 보완하는 도구다.


7) AOP 동작 원리와 함정: 프록시, self-invocation, 디버깅

지금까지 AOP가 "무엇"인지를 봤다. 이제 "어떻게" 동작하고, 어디서 틀리기 쉬운지를 본다.

7-1) 프록시 기반 AOP의 동작: "진짜 객체 앞에 가짜를 세운다"

"Spring AOP is proxy-based."

Spring Framework Reference: Proxying Mechanisms

스프링 AOP는 프록시 패턴으로 동작한다. 빈을 컨테이너에 등록할 때, 원본 객체(Target) 앞에 프록시 객체를 세워 놓는다. 클라이언트가 빈을 호출하면 실제로는 프록시가 먼저 받고, 부가 로직(Advice)을 실행한 뒤 원본 메서드를 호출한다.

프록시는 두 종류다. 빈이 인터페이스를 구현하고 있으면 JDK 동적 프록시(dynamic proxy), 인터페이스가 없으면 CGLIB(바이트코드 생성)을 쓴다. Spring Boot 2.0에서 도입되어 현재 3.x / Spring Framework 6.x에서도 유지되는 기본 설정으로, CGLIB을 사용한다.

"Spring AOP currently supports only method execution join points."

스프링 AOP가 가로챌 수 있는 건 메서드 실행 지점뿐이다. 필드 접근이나 생성자 호출은 대상이 아니다.

아래는 정상 호출과 self-invocation이 어떻게 다른지를 한 그림으로 보여 준다.

spring-container-ioc-di-aop-01

왼쪽 시퀀스에서 Client → Proxy → Target 순으로 호출이 들어간다. 프록시가 중간에서 @Transactional 같은 Advice를 실행한다. 오른쪽은 Target 내부에서 this.methodB()를 호출하는 경우다. this는 프록시가 아닌 원본 객체 자신이므로, 프록시를 거치지 않고 직접 호출된다. Advice가 적용되지 않는다.

여기서 틀리기 쉬운 게 하나 더 있다. CGLIB 프록시는 대상 클래스를 상속해서 만든다. 따라서 final 클래스나 final 메서드에는 프록시를 생성할 수 없다. 에러 없이 조용히 원본이 호출되므로 눈치채기 어렵다.

7-2) @Transactional이 동작하는 실제 흐름

@Transactional이 붙은 메서드를 호출하면 실제로는 이런 경로를 탄다.

  1. 클라이언트가 프록시의 메서드를 호출한다
  2. 프록시 안의 TransactionInterceptor가 가로챈다
  3. PlatformTransactionManager를 통해 트랜잭션을 시작한다
  4. 원본 메서드를 실행한다
  5. 정상이면 커밋, 예외가 나면 롤백한다

이 전체가 프록시 안에서 일어나기 때문에, 프록시를 거치지 않으면 트랜잭션이 걸리지 않는다.

7-3) self-invocation: 프록시가 못 잡는 호출

"...self-invocation does not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional."

Spring Framework Reference: @Transactional

이게 가장 자주 밟는 함정이다. 코드로 보자.

@Service
public class OrderService {

    public void process(Order order) {
        // this.placeOrder()는 프록시를 거치지 않는다!
        this.placeOrder(order);
    }

    @Transactional
    public void placeOrder(Order order) {
        repository.save(order);  // 트랜잭션 없이 실행됨
    }
}

process()에서 this.placeOrder()를 호출하면, this는 프록시가 아니라 Target 자신이다. @Transactional이 붙어 있어도 트랜잭션이 시작되지 않는다. 해결 방법은 단순하다 — placeOrder()별도 클래스로 분리해서 프록시를 거치게 만든다.

7-4) AOP가 IoC/DI와 함께 쓰여야만 의미가 있는 이유

"...the aim is to provide a close integration between AOP implementation and Spring IoC."

Spring Framework Reference: AOP Introduction

AOP 프록시는 §3-4에서 본 BeanPostProcessor 단계에서 생성된다. 빈이 컨테이너에 등록되어야 프록시를 감쌀 수 있고, DI를 통해 주입되어야 클라이언트가 프록시를 받는다. 컨테이너 → IoC → DI → AOP 프록시가 한 줄로 엮여 있는 것이다.

다만 AOP를 남용하면 디버깅 가시성이 급격히 떨어진다. CGLIB 프록시가 생성한 클래스 이름은 OrderService$$EnhancerBySpringCGLIB$$3a7b2c1d 같은 형태인데, Aspect가 여러 개 겹치면 이런 프록시 프레임이 스택 트레이스에 10~15개씩 끼어든다. 예외의 실제 발생 지점을 찾으려면 프록시 레이어를 하나씩 걷어내야 하는데, 익숙하지 않으면 어디가 원본 코드이고 어디가 프록시인지 구분이 안 된다.

더 위험한 경우는 커스텀 Aspect가 예외를 삼키는 상황이다. @Around 어드바이스에서 try-catch로 예외를 잡아 로그만 남기고 정상 리턴하면, @Transactional이 예외를 감지하지 못해 롤백이 누락된다. 트랜잭션은 정상 종료로 판단하고 커밋해 버린다. 이런 버그는 데이터가 꼬인 뒤에야 발견되기 때문에 원인 추적이 극히 어렵다.

기준은 단순하다 — 로깅, 트랜잭션, 보안처럼 횡단 관심사가 명확할 때만 AOP를 쓴다. "편하니까" AOP로 감싸는 순간 디버깅 비용이 편의를 금방 추월한다.


8) 셋이 어떻게 맞물리는가: 그림 하나로 보기

8-1) IoC는 원칙, DI는 수단, AOP는 확장

spring-container-ioc-di-aop-02

IoC라는 원칙 위에서 DI가 의존성을 연결하고, AOP가 프록시로 횡단 관심사를 입힌다. 이 세 가지는 컨테이너라는 공간 안에서 동시에 작동할 때 비로소 완전해진다.

8-2) 컨테이너는 이 셋이 동시에 성립하는 유일한 공간

제목인 "빈이 태어나는 자리"는 바로 이 컨테이너를 가리킨다. 컨테이너가 없으면 IoC 원칙을 적용할 주체가 없고, DI를 수행할 장소가 없고, AOP 프록시를 끼울 시점이 없다. 컨테이너는 세 개념이 물리적으로 만나는 유일한 런타임 공간이다.

8-3) "빈"이라는 말이 이 시점에서 왜 필요한가

빈은 컨테이너가 관리하는 객체다. 단순한 new로 만든 객체가 아니라, IoC 원칙에 따라 생성되고, DI로 의존성이 채워지고, 필요하면 AOP 프록시까지 감싸진 완성품이다. 역할을 수행하는 컴포넌트(@Service, @Repository, @Controller)가 빈 관리 대상이고, DTO나 VO는 빈이 아니다.


9) 실무에서 이렇게 읽고 쓴다

  • 생성자 주입을 기본으로 쓴다. Lombok @RequiredArgsConstructorfinal 필드 조합이면 보일러플레이트도 사라진다.
  • @Transactionalpublic 메서드에만 붙인다. private이나 protected에 붙이면 프록시가 가로채지 못한다.
  • 같은 클래스 안에서 @Transactional 메서드를 호출하지 않는다. self-invocation은 프록시를 우회한다. 필요하면 클래스를 분리한다.
  • 싱글톤 빈에 가변 인스턴스 필드를 두지 않는다. 상태가 필요하면 요청 스코프 빈이나 ThreadLocal을 쓴다.
  • AOP 어드바이스는 이름으로 역할을 드러낸다. @Around("execution(...)") 안에 무슨 일을 하는지 메서드 이름만 봐도 알 수 있어야 한다.
  • 순환 의존성이 뜨면 @Lazy로 우회하기 전에 설계를 의심한다. 두 클래스가 서로 의존한다면 책임 분리가 잘못된 것일 수 있다.

10) 한 줄 정리

스프링 컨테이너는 빈의 생성-연결-제공을 도맡는 런타임 공간이고, IoC라는 원칙 위에서 DI가 의존성을 주입하며, AOP가 횡단 관심사를 프록시로 분리한다. 이 셋은 컨테이너 안에서 동시에 작동할 때 비로소 "객체를 직접 만들고 관리하던 시절"의 문제를 해결한다. 생성자 주입을 기본으로, self-invocation과 싱글톤 상태 공유를 경계하면 대부분의 함정은 피할 수 있다.


태그: Spring Framework, 스프링 컨테이너, IoC, DI, 의존성 주입, AOP, 생성자 주입, ApplicationContext, 프록시, @Transactional

728x90