스프링 핵심 원리 — IoC, DI, AOP를 면접에서 설명하는 법
@Transactional하나 붙이면 트랜잭션이 관리된다는데, 그 안에서 도대체 무슨 일이 일어나고 있는 걸까?
스프링의 동작 원리를 이해하려면 IoC, DI, AOP 이 세 가지를 피해갈 수 없어요. 하나씩 풀어보겠습니다.
IoC (Inversion of Control) — 제어의 역전
제어의 역전이란
보통 코드를 짜면 개발자가 직접 객체를 생성하고, 의존성을 연결하고, 메서드를 호출합니다. 흐름의 제어권이 개발자한테 있는 거예요.
// 전통적인 방식 — 개발자가 직접 다 한다
public class OrderService {
private final OrderRepository repository = new OrderRepository();
private final NotificationService notifier = new NotificationService();
}
IoC는 이 제어권을 프레임워크(스프링 컨테이너)에게 넘기는 것입니다. 객체의 생성, 의존성 주입, 생명주기 관리를 전부 프레임워크가 알아서 해줘요. 개발자는 "어떤 객체가 필요하다"고 선언만 하면 됩니다.
왜 이게 필요할까요? 이유는 단순합니다. 객체 간 결합도를 낮추기 위해서예요. OrderService가 OrderRepository의 구체 클래스를 직접 new로 만들면, 구현체를 바꿀 때마다 OrderService 코드도 수정해야 합니다. 테스트에서 Mock으로 바꾸는 것도 불편하고요. IoC를 적용하면 인터페이스에만 의존하게 되니까 구현체 교체가 자유롭습니다.
**한 줄 정리 **: IoC는 객체 생성과 의존 관계 설정의 제어권을 개발자가 아닌 프레임워크(스프링 컨테이너)에게 위임하는 설계 원칙이다.
Spring IoC Container
스프링에서 IoC를 구현하는 핵심 컴포넌트가 IoC 컨테이너 입니다. 빈(Bean)을 생성하고, 의존성을 주입하고, 생명주기를 관리하는 역할을 해요.
BeanFactory vs ApplicationContext
| 구분 | BeanFactory | ApplicationContext |
|---|---|---|
| 역할 | IoC 컨테이너의 최상위 인터페이스 | BeanFactory를 확장한 인터페이스 |
| ** 빈 로딩** | Lazy Loading (요청 시 생성) | Eager Loading (컨테이너 시작 시 싱글톤 빈 전부 생성) |
| ** 부가 기능** | 기본적인 DI만 지원 | 메시지 국제화, 이벤트 발행, AOP, 환경 변수 처리 등 |
| ** 실무 사용** | 거의 안 씀 | 거의 100% 이쪽 |
실무에서 BeanFactory를 직접 쓸 일은 사실상 없습니다. ApplicationContext가 BeanFactory의 모든 기능을 포함하면서 훨씬 많은 걸 제공하기 때문이에요. 정리하면 — 둘 다 IoC 컨테이너인데, ApplicationContext는 BeanFactory를 상속하면서 국제화·이벤트·AOP 같은 엔터프라이즈 기능을 추가로 제공합니다.
Bean 생명주기
스프링 빈은 생성부터 소멸까지 정해진 라이프사이클을 거칩니다.
컨테이너 시작 → 빈 인스턴스화 → 의존성 주입 → 초기화 콜백 → 사용 → 소멸 콜백 → 컨테이너 종료
초기화·소멸 콜백
@Component
public class CacheService {
@PostConstruct
public void init() {
// 빈 생성 + 의존성 주입이 끝난 직후 호출
// 캐시 워밍업, 외부 리소스 연결 등
}
@PreDestroy
public void cleanup() {
// 컨테이너가 빈을 소멸하기 직전에 호출
// 리소스 해제, 연결 종료 등
}
}
@PostConstruct는 생성자 호출 → 의존성 주입 완료 → 그 다음에 실행됩니다. 생성자 안에서 주입된 의존성을 쓰려고 하면 아직 주입이 안 됐을 수 있으니까, 초기화 로직은 여기에 넣는 게 안전해요.
Bean Scope
| 스코프 | 설명 | 생명주기 |
|---|---|---|
| singleton (기본값) | 컨테이너에 하나만 존재 | 컨테이너 시작 ~ 종료 |
| prototype | 요청할 때마다 새로 생성 | 컨테이너가 생성·주입까지만 관여, 소멸은 관리 안 함 |
| request | HTTP 요청당 하나 | 요청 시작 ~ 응답 완료 |
| session | HTTP 세션당 하나 | 세션 생성 ~ 세션 만료 |
대부분의 빈은 singleton입니다. prototype은 상태를 가지는 빈이 필요할 때 가끔 쓰는데, 주의할 점이 있어요. 싱글톤 빈이 프로토타입 빈을 주입받으면, 주입 시점에 딱 한 번만 프로토타입이 생성됩니다. 매번 새 인스턴스가 필요하면 ObjectProvider나 Provider<T>를 써야 해요.
@Component
public class OrderService {
private final ObjectProvider<PrototypeBean> prototypeBeanProvider;
public void process() {
// 호출할 때마다 새 프로토타입 빈을 받음
PrototypeBean bean = prototypeBeanProvider.getObject();
}
}
DI (Dependency Injection) — 의존성 주입
IoC의 구체적인 구현 방법이 DI입니다. 객체가 자기 의존성을 직접 만들지 않고, 외부에서 주입받아요.
세 가지 주입 방식
1. 생성자 주입 (Constructor Injection)
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
// 생성자가 하나면 @Autowired 생략 가능
public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
}
2. 필드 주입 (Field Injection)
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
}
3. Setter 주입 (Setter Injection)
@Service
public class OrderService {
private OrderRepository orderRepository;
@Autowired
public void setOrderRepository(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
왜 생성자 주입을 권장하는가
스프링 공식 문서에서도 생성자 주입을 권장합니다. 이유가 명확해요.
** 불변성 보장 **: 필드를 final로 선언할 수 있습니다. 한 번 주입되면 바뀌지 않으니 런타임에 의존성이 바뀌는 사고를 원천 차단해요. 필드 주입이나 setter 주입은 final을 못 씁니다.
** 테스트 용이성 **: 생성자 파라미터로 Mock 객체를 넘기면 끝이에요. 필드 주입은 리플렉션을 쓰거나 @InjectMocks 같은 걸 끌어와야 해서 번거롭습니다.
** 순환 참조 감지 **: 생성자 주입을 쓰면 순환 참조가 있을 때 애플리케이션이 ** 시작 단계에서 바로 실패 **합니다. 필드 주입은 실제로 해당 빈을 사용하는 시점까지 순환 참조를 모를 수 있어요. 빨리 터지는 게 낫습니다.
** 필수 의존성 명시 **: 생성자 파라미터에 있으면 "이 빈이 없으면 객체 자체를 못 만든다"는 게 코드 레벨에서 보입니다. 필드 주입은 null인 채로 객체가 만들어질 수 있어서 NPE 위험이 있어요.
참고로 스프링 부트 3.x + Spring Framework 6.x부터는 생성자가 하나뿐이면
@Autowired어노테이션을 아예 안 붙여도 자동으로 주입해 준다.
@Autowired, @Qualifier, @Primary
같은 타입의 빈이 여러 개 등록되어 있으면 스프링이 어떤 걸 주입해야 할지 모릅니다. 이럴 때 쓰는 게 @Qualifier와 @Primary예요.
public interface NotificationService { void send(String message); }
@Component("emailNotification")
public class EmailNotificationService implements NotificationService { ... }
@Primary
@Component("smsNotification")
public class SmsNotificationService implements NotificationService { ... }
@Service
public class OrderService {
// @Primary가 붙은 SmsNotificationService가 주입됨
public OrderService(NotificationService notificationService) { ... }
}
@Service
public class AlertService {
// @Qualifier로 특정 빈을 명시적으로 지정
public AlertService(@Qualifier("emailNotification") NotificationService notificationService) { ... }
}
우선순위는 @Qualifier > @Primary > 타입 매칭 > 이름 매칭 순서입니다. @Qualifier가 가장 구체적이기 때문에 항상 이겨요.
AOP (Aspect Oriented Programming) — 관점 지향 프로그래밍
횡단 관심사
비즈니스 로직과 직접 관련은 없지만, 여러 곳에서 반복적으로 나타나는 코드가 있습니다. 로깅, 트랜잭션 관리, 인증/인가, 예외 처리 같은 것들이 대표적이에요. 이런 걸 ** 횡단 관심사(Cross-cutting Concern)**라고 합니다.
서비스 메서드 10개에 전부 try-catch로 로깅 코드를 넣는다고 생각해 보세요. 중복 코드가 미친 듯이 늘어나고, 로깅 방식을 바꾸려면 10군데를 다 고쳐야 합니다. AOP는 이 횡단 관심사를 비즈니스 로직에서 분리해서 모듈화하는 프로그래밍 패러다임이에요.
동작 원리 — Proxy 패턴
스프링 AOP는 ** 프록시 기반 **으로 동작합니다. 타겟 객체를 직접 호출하는 게 아니라, 프록시 객체가 중간에 끼어서 부가 기능을 수행한 다음 타겟을 호출하는 구조예요.
클라이언트 → [프록시 객체] → (부가 로직 실행) → [타겟 객체]
스프링은 빈을 등록할 때 AOP가 적용되어야 하는 빈이면 ** 원본 대신 프록시 객체를 컨테이너에 등록 **합니다. 클라이언트 코드는 프록시인지 원본인지 모르고 그냥 쓰기만 하면 돼요.
AOP 핵심 용어
| 용어 | 설명 | 예시 |
|---|---|---|
| Aspect | 횡단 관심사를 모듈화한 것 | @Aspect 클래스 자체 |
| Advice | 실제 부가 로직. 언제 실행할지도 정의 | @Before, @After, @Around |
| Pointcut | 어떤 JoinPoint에 Advice를 적용할지 선별하는 표현식 | @Pointcut("execution(* com.example.service.*.*(..))") |
| JoinPoint | Advice가 적용될 수 있는 지점 | 스프링 AOP에서는 ** 메서드 실행 시점 **만 지원 |
| Weaving | Aspect를 타겟 객체에 적용하는 과정 | 스프링은 ** 런타임 위빙** (프록시 생성) |
Advice 타입을 좀 더 세분화하면 이렇습니다.
| Advice 타입 | 실행 시점 |
|---|---|
@Before | 타겟 메서드 실행 전 |
@After | 타겟 메서드 실행 후 (성공/실패 무관) |
@AfterReturning | 타겟 메서드 정상 리턴 후 |
@AfterThrowing | 타겟 메서드 예외 발생 후 |
@Around | 타겟 메서드 실행 전후 모두 제어 가능. 가장 강력함 |
@Aspect
@Component
public class ExecutionTimeAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed(); // 타겟 메서드 실행
long elapsed = System.currentTimeMillis() - start;
log.info("{} 실행 시간: {}ms", joinPoint.getSignature(), elapsed);
return result;
}
}
실무 AOP 사례
로깅
위의 예제처럼 @Around로 메서드 실행 시간을 측정하거나, 파라미터와 리턴값을 로깅하는 데 자주 씁니다. 서비스 레이어 전체에 일괄 적용해놓으면 디버깅할 때 편해요.
트랜잭션 (@Transactional)
@Transactional이 AOP의 가장 대표적인 활용 사례입니다. 뒤에서 자세히 다루겠지만, 프록시가 메서드 호출 전에 트랜잭션을 시작하고, 정상 리턴하면 커밋, 예외가 터지면 롤백하는 구조예요.
인증/인가
스프링 시큐리티의 @PreAuthorize, @Secured 같은 어노테이션도 AOP 기반입니다. 메서드 실행 전에 권한 체크를 하고, 권한이 없으면 AccessDeniedException을 던져요.
@Transactional 동작 원리
@Transactional은 스프링에서 가장 많이 쓰이는 AOP 기반 기능입니다. 동작 원리를 제대로 이해하지 못하면 실무에서 삽질하게 되는 대표적인 케이스이기도 해요.
프록시 기반 동작
@Service
public class OrderService {
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(new Order(request));
paymentService.process(request.getPaymentInfo());
}
}
이 코드에서 OrderService 빈은 실제로는 프록시 객체가 컨테이너에 등록됩니다. 외부에서 createOrder()를 호출하면 이런 흐름이 돼요.
호출자 → [프록시: 트랜잭션 시작] → [실제 OrderService.createOrder()] → [프록시: 커밋 or 롤백]
Self-Invocation 문제
프록시 기반이기 때문에 생기는 유명한 함정이 있습니다. 같은 클래스 내부에서 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않아요.
@Service
public class OrderService {
public void processOrder(OrderRequest request) {
// 같은 객체의 메서드를 호출 → this.createOrder()
// 프록시를 거치지 않으므로 @Transactional이 무시됨!
createOrder(request);
}
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(new Order(request));
}
}
왜 그럴까요? processOrder()를 호출할 때는 프록시를 통해 들어오지만, 내부에서 createOrder()를 호출하는 건 this.createOrder()이기 때문입니다. this는 프록시가 아니라 실제 객체를 가리키니까 AOP가 끼어들 틈이 없어요.
해결 방법은 몇 가지가 있습니다.
- ** 메서드를 별도 클래스로 분리 **: 가장 깔끔한 방법이에요.
createOrder()를 다른 서비스로 빼면 프록시를 통해 호출됩니다. - **
self주입 **: 자기 자신을 주입받아서 프록시를 통해 호출하는 방법입니다. 동작은 하지만 좀 어색해요. AopContext.currentProxy(): 현재 프록시 객체를 가져와서 호출합니다. 비추예요.
주의할 점
Spring vs Spring Boot 차이
이건 거의 무조건 나옵니다.
| 구분 | Spring | Spring Boot |
|---|---|---|
| ** 설정** | XML 또는 Java Config로 수동 설정 | Auto Configuration으로 자동 설정 |
| ** 내장 서버** | 외부 WAS(Tomcat 등)에 WAR 배포 | 내장 Tomcat으로 JAR 실행 |
| ** 의존성 관리** | 개별 라이브러리 버전 직접 관리 | spring-boot-starter로 호환 버전 자동 관리 |
| ** 목적** | 프레임워크 자체 | 스프링을 쉽고 빠르게 쓰기 위한 도구 |
핵심은 Spring Boot가 별도의 프레임워크가 아니라 ** 스프링 위에 얹혀진 편의 계층 **이라는 점입니다. 내부적으로는 똑같은 스프링이 돌아가고 있어요.
빈 순환 참조 해결
A가 B를 주입받고, B가 A를 주입받는 상황입니다.
A → B → A → B → ... (무한 루프)
스프링 부트 2.6부터는 순환 참조를 기본적으로 ** 금지 **합니다. 애플리케이션 시작 시점에 에러를 던져요. 해결 방법은 다음과 같습니다.
- ** 설계 변경 **: 순환 참조 자체가 설계 문제인 경우가 대부분이에요. 공통 로직을 별도 클래스로 분리하는 게 정답입니다.
@Lazy: 주입 시점을 지연시켜서 순환을 끊는 방법입니다. 임시방편에 가까워요.- ** 이벤트 기반 처리 **: A → B 호출 대신 A가 이벤트를 발행하고 B가 구독하는 구조로 바꾸면 의존 관계 자체가 사라집니다.
CGLIB vs JDK Dynamic Proxy
스프링 AOP가 프록시를 만드는 방식은 두 가지입니다.
| 구분 | JDK Dynamic Proxy | CGLIB |
|---|---|---|
| ** 조건** | 타겟이 인터페이스를 구현한 경우 | 인터페이스 없이 클래스만 있는 경우 |
| ** 방식** | 인터페이스 기반으로 프록시 생성 | 클래스를 상속(바이트코드 조작)해서 프록시 생성 |
| ** 제약** | 인터페이스에 정의된 메서드만 프록시 가능 | final 클래스/메서드는 프록시 불가 |
스프링 부트 2.0부터는 기본 프록시 방식이 CGLIB으로 바뀌었습니다. spring.aop.proxy-target-class=true가 디폴트예요. 인터페이스가 있어도 CGLIB을 쓴다는 뜻입니다. JDK Dynamic Proxy를 쓰고 싶으면 명시적으로 설정을 바꿔야 해요.
핵심만 정리하면 — 스프링 부트는 기본적으로 CGLIB을 사용하고, CGLIB은 클래스를 상속해서 프록시를 만들기 때문에 final 클래스에는 적용이 안 됩니다.
파생 개념 — 여기서 더 파면 좋은 것들
스프링 핵심 원리를 이해하고 나면 자연스럽게 연결되는 주제들이 있습니다.
- ** 스프링 MVC**:
DispatcherServlet이 IoC 컨테이너에서 핸들러 매핑, 뷰 리졸버 등의 빈을 가져와서 요청을 처리해요. IoC/DI 없으면 이 구조 자체가 성립하지 않습니다. - ** 스프링 시큐리티 **: 필터 체인 기반의 인증·인가 처리가 전부 스프링 빈으로 관리되고,
@PreAuthorize같은 메서드 보안은 AOP 기반이에요. - JPA:
EntityManager가 스프링 빈으로 관리되고,@Transactional과 결합해서 영속성 컨텍스트의 범위가 결정됩니다. 트랜잭션이 끝나면 영속성 컨텍스트도 같이 닫히는 게 기본 동작이에요. - ** 디자인 패턴 **: IoC 컨테이너는 팩토리 패턴, AOP는 프록시 패턴, 템플릿 콜백 패턴(
JdbcTemplate,RestTemplate)까지 — 스프링은 디자인 패턴의 종합 전시장입니다.
정리
스프링 핵심 원리는 결국 이 세 가지로 귀결됩니다.
| 개념 | 한 줄 정리 |
|---|---|
| IoC | 객체 생성·관리의 제어권을 스프링 컨테이너에 위임하는 것 |
| DI | IoC를 구현하는 방법. 생성자 주입이 표준 |
| AOP | 횡단 관심사를 프록시 기반으로 분리하는 것. @Transactional이 대표적 |
외울 게 아니라 "왜 그렇게 동작하는지"를 설명할 수 있어야 합니다. IoC가 왜 필요한지, 생성자 주입을 왜 쓰는지, 프록시 기반이라서 생기는 제약이 뭔지. 원리를 알면 꼬리 질문이 와도 당황하지 않아요.