IoC, DI, AOP는 왜 Spring을 읽는 세 가지 기준점일까요?¶
@Service하나 붙였을 뿐인데 객체가 생기고, 생성자에 값이 들어오고, 어떤 메서드는 호출 앞뒤로 추가 동작까지 붙어요.
처음 Spring Boot 코드를 보면 이상한 장면이 자주 나와요.
내가 new OrderService()를 쓴 적이 없는데 OrderService 객체가 쓰이고요. OrderRepository를 직접 넘긴 적도 없는데 생성자에 들어와요. 나중에는 @Transactional 같은 Annotation을 붙였을 뿐인데 트랜잭션이 시작되고 끝나요.
이쯤에서 이런 생각이 들어요.
"객체는 누가 만든 거지?"
"필요한 객체는 누가 넣어준 거지?"
"메서드 안에 없는 동작은 어디서 끼어든 거지?"
오늘 볼 제어의 역전(IoC), 의존성 주입(dependency injection), 관점 지향 프로그래밍(AOP)은 이 질문들에 답하는 세 가지 기준점이에요.
이 글의 범위
오늘은 IoC, DI, AOP의 모든 내부 구현을 파고들지는 않아요. 목표는 Spring Boot 코드를 읽을 때 "이건 객체 생성 책임 이야기구나", "이건 연결 방식 이야기구나", "이건 메서드 주변에 붙는 공통 규칙 이야기구나" 하고 구분하는 거예요. 다만 각 개념이 실무에서 어디서 깨지는지도 함께 볼게요.
먼저 직접 만든 코드부터 볼게요¶
주문 목록을 조회하는 작은 코드를 직접 조립한다고 해볼게요.
package com.example.order;
class OrderController {
private final OrderService orderService;
OrderController() {
OrderRepository orderRepository = new OrderRepository();
this.orderService = new OrderService(orderRepository);
}
String orders() {
return orderService.findOrders();
}
}
이 코드는 한눈에 이해되긴 해요. 컨트롤러가 저장소를 만들고, 서비스를 만들고, 그 서비스를 써요.
근데요, 역할을 자세히 보면 컨트롤러가 너무 많은 일을 하고 있어요.
| 코드가 하는 일 | 원래 컨트롤러의 책임일까요? |
|---|---|
| HTTP 요청을 받아 주문 목록을 돌려줌 | 맞아요 |
OrderRepository를 직접 생성함 |
애매해요 |
OrderService를 직접 생성함 |
애매해요 |
| 서비스가 어떤 저장소를 쓸지 결정함 | 점점 부담돼요 |
작을 때는 괜찮아요. 하지만 테스트에서 가짜 저장소를 넣고 싶거나, 저장소 구현이 바뀌거나, 여러 서비스가 같은 객체를 공유해야 하면 금방 불편해져요.
그래서 Spring은 질문을 바꿔요.
"컨트롤러가 객체를 직접 만들 필요가 있을까?"
IoC는 "내가 하던 제어를 컨테이너가 가져가는 것"이에요¶
제어의 역전(inversion of control, IoC)이라는 말은 처음 들으면 너무 거창해요.
여기서는 이렇게 잡아볼게요.
내가 직접 하던 객체 생성과 연결의 제어를 Spring 컨테이너에게 넘기는 것.
직접 제어할 때는 흐름이 이래요.
OrderRepository repository = new OrderRepository();
OrderService service = new OrderService(repository);
OrderController controller = new OrderController(service);
개발자 코드가 "무엇을 만들지", "어떤 순서로 만들지", "누구에게 넣을지"를 모두 결정해요.
Spring을 쓰면 클래스는 자기에게 필요한 것을 드러내고, 객체 생성과 연결은 Spring 컨테이너가 맡아요.
package com.example.order;
import org.springframework.stereotype.Service;
@Service
class OrderService {
private final OrderRepository orderRepository;
OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
String findOrders() {
return "orders";
}
}
OrderService는 이제 OrderRepository를 직접 만들지 않아요. 대신 "나는 OrderRepository가 필요해요"라고 생성자로 말해요.
Spring 컨테이너는 애플리케이션을 시작할 때 관리할 객체들을 만들고, 필요한 관계를 맞춰요. 이렇게 Spring이 관리하는 객체를 빈(bean)이라고 불러요.
flowchart LR
A[개발자 코드<br/><small>필요한 객체를 생성자로 표현</small>]
B[Spring 컨테이너<br/><small>빈 생성과 연결 제어</small>]
C[실행 중 객체 그래프<br/><small>Controller -> Service -> Repository</small>]
A --> B --> C
이 그림에서 방향이 바뀐 게 핵심이에요. 예전에는 개발자 코드가 객체를 직접 만들었고, 이제는 개발자 코드가 필요한 관계를 표현하면 컨테이너가 실제 객체 그래프를 만들어요.
DI는 IoC가 코드에 나타나는 가장 흔한 모양이에요¶
IoC가 큰 원칙이라면, 의존성 주입(dependency injection, DI)은 그 원칙이 코드에 나타나는 대표적인 방식이에요.
의존성이라는 말도 어렵게 들리지만 사실 단순해요.
OrderService가 일을 하려면 OrderRepository가 필요하죠. 그러면 OrderService는 OrderRepository에 의존한다고 말해요.
package com.example.order;
import org.springframework.stereotype.Repository;
@Repository
class OrderRepository {
String findAll() {
return "orders";
}
}
package com.example.order;
import org.springframework.stereotype.Service;
@Service
class OrderService {
private final OrderRepository orderRepository;
OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
String findOrders() {
return orderRepository.findAll();
}
}
여기서 OrderService는 저장소를 직접 만들지 않고, 생성자로 받아요. Spring 컨테이너가 OrderRepository 빈을 찾아서 OrderService를 만들 때 넣어줘요.
이게 의존성 주입이에요.
| 표현 | 뜻 |
|---|---|
| 의존성 | 이 클래스가 일하려고 필요한 다른 객체 |
| 주입 | 필요한 객체를 바깥에서 넣어주는 일 |
| 생성자 주입 | 생성자 파라미터로 필요한 객체를 받는 방식 |
| 빈 | Spring 컨테이너가 만들고 관리하는 객체 |
Spring Boot 코드에서 생성자만 있고 new가 없는데 객체가 들어온다면, 대부분 이 흐름을 보고 있는 거예요.
"직접 만들지 않는다"는 게 왜 중요할까요?¶
겉으로 보면 직접 new를 쓰는 편이 더 명확해 보일 수 있어요.
사실은, 애플리케이션이 커질수록 직접 만드는 코드가 오히려 중요한 결정을 숨겨요.
예를 들어 결제 서비스가 이런 모양이라고 해볼게요.
package com.example.order;
import org.springframework.stereotype.Service;
@Service
class PaymentService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
PaymentService(PaymentClient paymentClient, OrderRepository orderRepository) {
this.paymentClient = paymentClient;
this.orderRepository = orderRepository;
}
}
생성자를 보면 이 서비스가 무엇에 기대고 있는지 바로 보여요.
반대로 클래스 안에서 new PaymentClient()를 숨어서 호출하면, 테스트와 변경이 어려워져요. 외부 결제 API를 진짜로 부르지 않고 테스트하고 싶은데, 클래스가 이미 직접 만들어버렸으니까요.
DI를 쓰면 클래스는 "필요한 것"을 공개하고, 실행 환경은 "무엇을 넣을지"를 결정할 수 있어요.
flowchart TD
A[OrderService<br/><small>주문 규칙에 집중</small>]
B[OrderRepository<br/><small>저장소 역할</small>]
C[PaymentClient<br/><small>외부 결제 API 역할</small>]
D[Spring 컨테이너<br/><small>필요한 빈을 찾아 생성자에 주입</small>]
B --> D
C --> D
D --> A
이 구조에서는 OrderService가 조립 담당자가 아니에요. 서비스는 주문 규칙에 집중하고, Spring 컨테이너가 필요한 협력 객체를 넣어줘요.
처음에는 생성자를 먼저 보세요
Spring Boot 코드에서 어떤 클래스가 이해되지 않으면, 메서드보다 생성자를 먼저 보세요. 생성자 파라미터에는 이 클래스가 기대는 협력 객체가 드러나요.
AOP는 "여러 곳에 반복되는 규칙"을 메서드 주변에 붙여요¶
이제 세 번째 기준점인 관점 지향 프로그래밍(AOP)을 볼게요.
주문을 저장할 때 이런 일이 필요하다고 해볼게요.
- 메서드가 시작되면 트랜잭션을 열어요.
- 메서드가 정상 종료되면 커밋해요.
- 예외가 나면 롤백해요.
- 실행 시간을 기록해요.
- 권한이 있는지 확인해요.
이런 코드를 모든 서비스 메서드에 직접 쓰면 어떻게 될까요?
String createOrder() {
transaction.begin();
try {
String orderId = saveOrder();
transaction.commit();
return orderId;
} catch (RuntimeException ex) {
transaction.rollback();
throw ex;
}
}
업무 코드는 saveOrder()인데, 주변 코드가 훨씬 커져요. 게다가 이 패턴은 주문, 결제, 배송, 회원 서비스 곳곳에 반복돼요.
AOP는 이런 반복 규칙을 "업무 코드 바깥의 관점"으로 분리하려고 해요.
| 반복되는 관심사 | 흔한 예 |
|---|---|
| 트랜잭션 | 성공하면 커밋, 실패하면 롤백 |
| 보안 | 메서드 실행 전에 권한 확인 |
| 로깅 | 호출 시간, 요청 정보 기록 |
| 캐시 | 이미 계산한 결과 재사용 |
여기서 중요한 건 AOP가 비즈니스 로직을 대신 작성한다는 뜻이 아니라는 점이에요.
업무 메서드는 주문을 만들고, AOP는 그 메서드 주변에 공통 규칙을 붙여요.
flowchart LR
A[호출자]
B[프록시<br/><small>트랜잭션, 보안, 로깅 같은 공통 규칙</small>]
C[실제 서비스 메서드<br/><small>주문 생성 업무 코드</small>]
A --> B
B --> C
C --> B
B --> A
Spring AOP는 보통 프록시(proxy)를 통해 동작해요. 호출자가 실제 서비스 객체를 바로 부르는 것처럼 보여도, 중간에 프록시가 서서 메서드 앞뒤의 공통 규칙을 처리할 수 있어요.
그래서 Annotation 하나만 보고 끝내면 부족해요¶
Spring Boot를 쓰다 보면 Annotation 하나가 많은 일을 하는 것처럼 보여요.
package com.example.order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OrderService {
@Transactional
String createOrder() {
return "order-id";
}
}
처음에는 @Transactional이 메서드 안으로 들어와서 코드를 바꾸는 것처럼 느껴질 수 있어요.
사실은 그렇게 이해하면 나중에 헷갈려요. 핵심은 "메서드 내부가 바뀐다"가 아니라 "그 메서드를 호출하는 경로에 프록시가 끼어 공통 규칙을 적용한다"에 가까워요.
그래서 나중에 이런 질문들이 생겨요.
- 왜 같은 클래스 안에서 자기 메서드를 호출하면 트랜잭션이 기대처럼 안 걸릴 수 있을까요?
- 왜
final클래스나 메서드가 프록시와 충돌할 수 있을까요? - 왜 인터페이스가 있느냐 없느냐에 따라 프록시 방식 이야기가 나올까요?
오늘 전부 풀지는 않을게요. 다만 지금은 이것만 잡으면 돼요.
AOP를 만나면 "메서드 안에 없는 공통 동작이 호출 경로 어디에 붙었을까?"를 물어보면 돼요.
Annotation만 보고 내부 동작을 단정하면 위험해요
@Transactional, @Cacheable, 보안 Annotation처럼 메서드 주변 동작을 바꾸는 기능은 프록시와 호출 경로의 영향을 받아요. "붙였으니 무조건 된다"보다 "누가 이 메서드를 어떤 경로로 호출하나"를 같이 봐야 해요.
실무에서는 경계가 더 중요해져요¶
처음에는 IoC, DI, AOP를 각각 한 문장으로 이해해도 좋아요.
- IoC: 객체 생성 제어가 컨테이너로 이동해요.
- DI: 필요한 객체가 바깥에서 들어와요.
- AOP: 메서드 주변에 공통 규칙이 붙어요.
하지만 숙련자가 Spring 코드를 읽을 때는 보통 한 단계 더 깊게 봐요. "무슨 기능인가?"보다 "어느 경계에서 적용되는가?"를 보는 거예요.
| 기준점 | 처음 이해 | 더 깊게 볼 경계 | 자주 만나는 실패 |
|---|---|---|---|
| IoC | 컨테이너가 객체를 만들어요 | 빈 정의 등록, 실제 객체 생성, 초기화, 후처리 시점이 나뉘어요 | 생성 시점에 필요한 설정이나 빈이 아직 없어서 시작 실패 |
| DI | 생성자로 필요한 객체를 받아요 | 같은 타입 빈이 여러 개인지, 선택 가능한 빈인지, 순환 의존성이 있는지 봐야 해요 | NoSuchBeanDefinition, NoUniqueBeanDefinition, 순환 참조 |
| AOP | 공통 규칙이 메서드 앞뒤에 붙어요 | 호출이 프록시를 지나가는지, 어떤 메서드가 가로채지는지 봐야 해요 | 자기 자신 호출(self-invocation), final/private 메서드, 프록시 바깥 호출 |
예를 들어 @Transactional은 초보자에게는 "DB 작업을 하나로 묶어줘요"로 시작해도 충분해요. 그런데 실무에서는 이 설명만으로 부족해지는 순간이 와요.
package com.example.order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
public void createOrder() {
saveOrder();
}
@Transactional
public void saveOrder() {
// 주문 저장
}
}
겉으로는 saveOrder()에 @Transactional이 붙어 있어요. 그런데 createOrder()가 같은 객체 안에서 saveOrder()를 직접 부르면, 호출이 프록시를 지나가지 않는 구조가 될 수 있어요. 그러면 기대한 트랜잭션 경계가 생기지 않을 수 있죠.
이걸 외우는 규칙으로만 받아들이면 금방 헷갈려요. 원리는 더 단순해요.
AOP 기반 기능은 "Annotation이 붙은 메서드가 있느냐"만 보지 말고, "그 메서드 호출이 프록시를 통과하느냐"를 같이 봐야 해요.
트랜잭션 글에서는 여기에 롤백 규칙, 전파(propagation), 격리(isolation), 영속성 컨텍스트까지 붙어서 더 깊어질 거예요. 오늘은 그 전에 Spring을 읽는 기준점만 세우는 단계예요.
세 단어를 한 화면에 놓아볼게요¶
IoC, DI, AOP는 서로 같은 말이 아니에요. 하지만 Spring Boot 코드를 읽을 때는 자주 붙어서 보여요.
| 기준점 | 먼저 물어볼 질문 | 코드에서 보이는 모양 |
|---|---|---|
| IoC | 객체 생성과 연결의 제어권이 누구에게 있나요? | Spring 컨테이너, 빈 등록, 애플리케이션 컨텍스트 |
| DI | 이 클래스가 필요한 객체는 어떻게 들어오나요? | 생성자 파라미터, @Service, @Repository, @Bean |
| AOP | 메서드 안에 없는 공통 규칙은 어디서 적용되나요? | 프록시, @Transactional, 보안, 캐시, 로깅 |
한 번 더 코드에서 바로 보이는 기준으로 놓아볼게요.
| Spring이 대신 맡은 일 | 연결되는 기준점 |
|---|---|
| 객체를 만들고 관리할 위치가 개발자 코드 밖으로 옮겨감 | IoC |
| 클래스가 필요한 협력 객체를 생성자로 드러내고, 컨테이너가 넣어줌 | DI |
| 메서드 본문 밖에서 트랜잭션, 보안, 로깅 같은 공통 규칙이 적용됨 | AOP |
이 세 가지를 같이 보면 Spring Boot의 "내가 안 한 일"이 조금씩 나뉘어요.
flowchart TD
A[내가 작성한 클래스<br/><small>Controller, Service, Repository</small>]
B[IoC<br/><small>객체 생성 제어가 컨테이너로 이동</small>]
C[DI<br/><small>필요한 빈을 생성자에 연결</small>]
D[AOP<br/><small>메서드 주변에 공통 규칙 적용</small>]
E[실행 중 Spring Boot 애플리케이션]
A --> B
B --> C
C --> D
D --> E
이 그림은 정확한 실행 순서를 모두 표현한 내부 구조도는 아니에요. 대신 Spring Boot 코드를 읽을 때 질문을 나누는 지도에 가까워요.
Spring Boot 프로젝트에서는 어디서 처음 만나게 될까요?¶
지난 글에서 본 OrderApplication을 다시 떠올려볼게요.
package com.example.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
여기서 SpringApplication.run(...)은 그냥 객체 하나를 실행하는 호출이 아니에요. 뒤 글에서 더 자세히 보겠지만, 애플리케이션을 준비하고 Spring 컨테이너를 만들고 빈을 등록하는 흐름의 입구예요.
@SpringBootApplication은 자동 설정(auto-configuration), 컴포넌트 스캔(component scan), 설정 클래스 역할을 한곳에 묶어주는 대표 Annotation이에요.
그래서 우리가 만든 클래스가 이런 식으로 같은 루트 패키지 아래에 있으면요.
package com.example.order;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/orders")
String orders() {
return orderService.findOrders();
}
}
Spring은 컴포넌트 스캔으로 컨트롤러와 서비스 같은 후보를 찾고, 컨테이너에 빈으로 등록하고, 생성자에 필요한 빈을 연결해요.
이 한 장면 안에 오늘 본 세 기준점 중 IoC와 DI가 이미 들어 있어요. 여기에 트랜잭션, 보안, 캐시 같은 공통 규칙이 붙기 시작하면 AOP 질문까지 따라와요.
자, 정리해볼까요?¶
오늘 우리가 잡은 세 기준점
- IoC는 객체 생성과 연결의 제어가 개발자 코드에서 Spring 컨테이너로 이동하는 흐름이에요.
- DI는 필요한 객체를 클래스 안에서 직접 만들지 않고, 생성자 같은 통로로 바깥에서 넣어주는 방식이에요.
- AOP는 트랜잭션, 보안, 로깅처럼 여러 곳에 반복되는 공통 규칙을 메서드 주변에 붙이는 방식이에요.
- Spring Boot 코드를 읽을 때는 "누가 만들었지?", "누가 넣어줬지?", "메서드 밖의 동작은 어디서 붙었지?"를 나눠서 보면 덜 헷갈려요.
- 한 단계 깊게 보면 IoC는 생성 시점, DI는 선택 가능한 빈, AOP는 프록시를 지나는 호출 경계를 함께 읽어야 해요.
이제 @Service, 생성자, @Transactional 같은 조각들이 조금 다르게 보일 거예요.
다음 글에서는 이 기준점을 들고 main 메서드로 들어가 볼게요. SpringApplication.run(...) 한 줄이 어떻게 설정을 읽고, 컨테이너를 만들고, 실행 중 애플리케이션으로 이어지는지 살펴볼 차례예요.
참고한 링크¶
- Spring Framework Reference: The IoC Container
- Spring Framework Reference: Introduction to the Spring IoC Container and Beans
- Spring Framework Reference: Dependency Injection
- Spring Framework Reference: AOP Proxies
- Spring Framework Reference: Proxying Mechanisms
- Spring Boot Reference: Using the @SpringBootApplication Annotation
댓글