스프링 컨테이너와 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) 들어가며: 스프링을 처음 만났을 때의 낯섦
- 2) 스프링이 없던 시절의 코드: 문제의 출발점
- 3) 스프링 컨테이너: 이 모든 걸 담는 그릇
- 4) IoC: 제어의 역전
- 5) DI: 의존성 주입
- 6) AOP: 횡단 관심사란 무엇이고 왜 필요한가
- 7) AOP 동작 원리와 함정: 프록시, self-invocation, 디버깅
- 8) 셋이 어떻게 맞물리는가: 그림 하나로 보기
- 9) 실무에서 이렇게 읽고 쓴다
- 10) 한 줄 정리
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로 고정하는 것이다. 위
코드에서 JdbcOrderRepository를
JpaOrderRepository로 바꾸려면 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."
beans와 context 패키지가 컨테이너의
뼈대다.
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"
실무에서 BeanFactory를 직접 다루는 일은 거의 없다.
ApplicationContext가 상위 호환이다.
3-3) 컨테이너가 실제로 하는 일 3가지: 등록 → 해결 → 제공
- 등록:
@Component,@Bean등의 메타데이터를 스캔해서 "어떤 클래스를 빈으로 관리할지" 수집한다. - 해결: 빈 사이의 의존 관계 그래프를 분석하고 생성 순서를 결정한다.
- 제공:
getBean()이나 DI를 통해 완성된 빈을 사용자에게 건넨다.
3-4) 컨테이너 생성부터 빈이 손에 들어오기까지의 한 사이클
ApplicationContext생성 (예:SpringApplication.run())@ComponentScan경로에서 빈 후보 클래스를 찾는다BeanDefinition메타데이터를 등록한다- 의존 관계를 분석해 생성 순서를 정한다
- 빈 인스턴스를 생성하고 의존성을 주입한다
BeanPostProcessor가 후처리를 수행한다 (AOP 프록시 생성도 여기서)- 초기화 콜백(
@PostConstruct)을 호출한다
여기서 틀리기 쉬운 지점이 싱글톤 스코프다. 스프링의 싱글톤은 JVM 전체가 아니라 해당 Spring IoC 컨테이너당 하나다. 컨테이너가 두 개면 같은 클래스의 빈도 두 개 존재한다. 그리고 싱글톤 빈에 가변 인스턴스 필드를 두면, 여러 스레드가 같은 인스턴스를 공유하므로 값이 섞인다. 싱글톤 빈은 **무상태(stateless)**로 설계하는 것이 원칙이다.
4) IoC: 제어의 역전
4-1) "제어"란 무엇을 말하는가
여기서 말하는 "제어(Control)"는 객체의 생성, 의존성 결정,
생명주기 관리를 뜻한다. §2에서
OrderService가 new 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가
있어야 한다. 이때 OrderService는
OrderRepository에 **의존(depend)**한다.
의존성(Dependency)이란 결국 **"내가 동작하려면 필요한 다른
객체"**다.
5-2) "주입"이라는 단어가 말하는 것
new로 직접 만드는 대신, 외부에서 생성자
파라미터로 넘겨받는 것 — 이게 주입이다.
// Before: 직접 생성
private final OrderRepository repository = new JdbcOrderRepository();
// After: 외부에서 주입
public OrderService(OrderRepository repository) {
this.repository = repository; // 누가 넘겨주든 받기만 한다
}이렇게 바뀌면 OrderService는
JdbcOrderRepository를 알 필요가 없다. 인터페이스
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."
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()로 직접 생성하면
repository는 null**이다. 리플렉션 없이는
필드에 값을 넣을 방법이 없기 때문이다. 이게 필드 주입의 가장 큰
함정이다.
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."
스프링 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이 어떻게 다른지를 한 그림으로 보여 준다.
왼쪽 시퀀스에서 Client → Proxy → Target 순으로 호출이
들어간다. 프록시가 중간에서 @Transactional 같은 Advice를
실행한다. 오른쪽은 Target 내부에서 this.methodB()를
호출하는 경우다. this는 프록시가 아닌 원본 객체 자신이므로,
프록시를 거치지 않고 직접 호출된다. Advice가 적용되지
않는다.
여기서 틀리기 쉬운 게 하나 더 있다. CGLIB 프록시는 대상 클래스를
상속해서 만든다. 따라서 final 클래스나
final 메서드에는 프록시를 생성할 수 없다. 에러 없이 조용히
원본이 호출되므로 눈치채기 어렵다.
7-2) @Transactional이 동작하는 실제 흐름
@Transactional이 붙은 메서드를 호출하면 실제로는 이런
경로를 탄다.
- 클라이언트가 프록시의 메서드를 호출한다
- 프록시 안의
TransactionInterceptor가 가로챈다 PlatformTransactionManager를 통해 트랜잭션을 시작한다- 원본 메서드를 실행한다
- 정상이면 커밋, 예외가 나면 롤백한다
이 전체가 프록시 안에서 일어나기 때문에, 프록시를 거치지 않으면 트랜잭션이 걸리지 않는다.
7-3) self-invocation: 프록시가 못 잡는 호출
"...self-invocation does not lead to an actual transaction at runtime even if the invoked method is marked with @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."
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는 확장
IoC라는 원칙 위에서 DI가 의존성을 연결하고, AOP가 프록시로 횡단 관심사를 입힌다. 이 세 가지는 컨테이너라는 공간 안에서 동시에 작동할 때 비로소 완전해진다.
8-2) 컨테이너는 이 셋이 동시에 성립하는 유일한 공간
제목인 "빈이 태어나는 자리"는 바로 이 컨테이너를 가리킨다. 컨테이너가 없으면 IoC 원칙을 적용할 주체가 없고, DI를 수행할 장소가 없고, AOP 프록시를 끼울 시점이 없다. 컨테이너는 세 개념이 물리적으로 만나는 유일한 런타임 공간이다.
8-3) "빈"이라는 말이 이 시점에서 왜 필요한가
빈은 컨테이너가 관리하는 객체다. 단순한
new로 만든 객체가 아니라, IoC 원칙에 따라 생성되고, DI로
의존성이 채워지고, 필요하면 AOP 프록시까지 감싸진
완성품이다. 역할을 수행하는
컴포넌트(@Service, @Repository,
@Controller)가 빈 관리 대상이고, DTO나 VO는 빈이
아니다.
9) 실무에서 이렇게 읽고 쓴다
- 생성자 주입을 기본으로 쓴다. Lombok
@RequiredArgsConstructor와final필드 조합이면 보일러플레이트도 사라진다. @Transactional은public메서드에만 붙인다.private이나protected에 붙이면 프록시가 가로채지 못한다.- 같은 클래스 안에서
@Transactional메서드를 호출하지 않는다. self-invocation은 프록시를 우회한다. 필요하면 클래스를 분리한다. - 싱글톤 빈에 가변 인스턴스 필드를 두지 않는다.
상태가 필요하면 요청 스코프 빈이나
ThreadLocal을 쓴다. - AOP 어드바이스는 이름으로 역할을 드러낸다.
@Around("execution(...)")안에 무슨 일을 하는지 메서드 이름만 봐도 알 수 있어야 한다. - 순환 의존성이 뜨면
@Lazy로 우회하기 전에 설계를 의심한다. 두 클래스가 서로 의존한다면 책임 분리가 잘못된 것일 수 있다.
10) 한 줄 정리
스프링 컨테이너는 빈의 생성-연결-제공을 도맡는 런타임 공간이고, IoC라는 원칙 위에서 DI가 의존성을 주입하며, AOP가 횡단 관심사를 프록시로 분리한다. 이 셋은 컨테이너 안에서 동시에 작동할 때 비로소 "객체를 직접 만들고 관리하던 시절"의 문제를 해결한다. 생성자 주입을 기본으로, self-invocation과 싱글톤 상태 공유를 경계하면 대부분의 함정은 피할 수 있다.
태그: Spring Framework, 스프링 컨테이너, IoC, DI, 의존성 주입, AOP, 생성자 주입, ApplicationContext, 프록시, @Transactional
'CS > Spring' 카테고리의 다른 글
| 빈 초기화 순서: @DependsOn·@Lazy·@Order로 기동을 통제하기 (0) | 2026.04.12 |
|---|---|
| 조건부 빈 등록: @Conditional과 Auto-configuration의 뿌리 (1) | 2026.04.12 |
| 빈 스코프: singleton이 전부가 아니다 (0) | 2026.04.12 |
| 빈 라이프사이클: 태어나서 사라지기까지 (1) | 2026.04.12 |
| 빈이란 무엇인가: 객체에서 빈으로 (0) | 2026.04.12 |