@Transactional 하나 붙이면 트랜잭션이 관리된다는데, 그 안에서 도대체 무슨 일이 일어나고 있는 걸까?

스프링의 동작 원리를 이해하려면 IoC, DI, AOP 이 세 가지를 피해갈 수 없어요. 하나씩 풀어보겠습니다.

IoC (Inversion of Control) — 제어의 역전

제어의 역전이란

보통 코드를 짜면 개발자가 직접 객체를 생성하고, 의존성을 연결하고, 메서드를 호출합니다. 흐름의 제어권이 개발자한테 있는 거예요.

JAVA
// 전통적인 방식 — 개발자가 직접 다 한다
public class OrderService {
    private final OrderRepository repository = new OrderRepository();
    private final NotificationService notifier = new NotificationService();
}

IoC는 이 제어권을 프레임워크(스프링 컨테이너)에게 넘기는 것입니다. 객체의 생성, 의존성 주입, 생명주기 관리를 전부 프레임워크가 알아서 해줘요. 개발자는 "어떤 객체가 필요하다"고 선언만 하면 됩니다.

왜 이게 필요할까요? 이유는 단순합니다. 객체 간 결합도를 낮추기 위해서예요. OrderServiceOrderRepository의 구체 클래스를 직접 new로 만들면, 구현체를 바꿀 때마다 OrderService 코드도 수정해야 합니다. 테스트에서 Mock으로 바꾸는 것도 불편하고요. IoC를 적용하면 인터페이스에만 의존하게 되니까 구현체 교체가 자유롭습니다.

**한 줄 정리 **: IoC는 객체 생성과 의존 관계 설정의 제어권을 개발자가 아닌 프레임워크(스프링 컨테이너)에게 위임하는 설계 원칙이다.


Spring IoC Container

스프링에서 IoC를 구현하는 핵심 컴포넌트가 IoC 컨테이너 입니다. 빈(Bean)을 생성하고, 의존성을 주입하고, 생명주기를 관리하는 역할을 해요.

BeanFactory vs ApplicationContext

구분BeanFactoryApplicationContext
역할IoC 컨테이너의 최상위 인터페이스BeanFactory를 확장한 인터페이스
** 빈 로딩**Lazy Loading (요청 시 생성)Eager Loading (컨테이너 시작 시 싱글톤 빈 전부 생성)
** 부가 기능**기본적인 DI만 지원메시지 국제화, 이벤트 발행, AOP, 환경 변수 처리 등
** 실무 사용**거의 안 씀거의 100% 이쪽

실무에서 BeanFactory를 직접 쓸 일은 사실상 없습니다. ApplicationContextBeanFactory의 모든 기능을 포함하면서 훨씬 많은 걸 제공하기 때문이에요. 정리하면 — 둘 다 IoC 컨테이너인데, ApplicationContextBeanFactory를 상속하면서 국제화·이벤트·AOP 같은 엔터프라이즈 기능을 추가로 제공합니다.


Bean 생명주기

스프링 빈은 생성부터 소멸까지 정해진 라이프사이클을 거칩니다.

PLAINTEXT
컨테이너 시작 → 빈 인스턴스화 → 의존성 주입 → 초기화 콜백 → 사용 → 소멸 콜백 → 컨테이너 종료

초기화·소멸 콜백

JAVA
@Component
public class CacheService {

    @PostConstruct
    public void init() {
        // 빈 생성 + 의존성 주입이 끝난 직후 호출
        // 캐시 워밍업, 외부 리소스 연결 등
    }

    @PreDestroy
    public void cleanup() {
        // 컨테이너가 빈을 소멸하기 직전에 호출
        // 리소스 해제, 연결 종료 등
    }
}

@PostConstruct는 생성자 호출 → 의존성 주입 완료 → 그 다음에 실행됩니다. 생성자 안에서 주입된 의존성을 쓰려고 하면 아직 주입이 안 됐을 수 있으니까, 초기화 로직은 여기에 넣는 게 안전해요.

Bean Scope

스코프설명생명주기
singleton (기본값)컨테이너에 하나만 존재컨테이너 시작 ~ 종료
prototype요청할 때마다 새로 생성컨테이너가 생성·주입까지만 관여, 소멸은 관리 안 함
requestHTTP 요청당 하나요청 시작 ~ 응답 완료
sessionHTTP 세션당 하나세션 생성 ~ 세션 만료

대부분의 빈은 singleton입니다. prototype은 상태를 가지는 빈이 필요할 때 가끔 쓰는데, 주의할 점이 있어요. 싱글톤 빈이 프로토타입 빈을 주입받으면, 주입 시점에 딱 한 번만 프로토타입이 생성됩니다. 매번 새 인스턴스가 필요하면 ObjectProviderProvider<T>를 써야 해요.

JAVA
@Component
public class OrderService {

    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public void process() {
        // 호출할 때마다 새 프로토타입 빈을 받음
        PrototypeBean bean = prototypeBeanProvider.getObject();
    }
}

DI (Dependency Injection) — 의존성 주입

IoC의 구체적인 구현 방법이 DI입니다. 객체가 자기 의존성을 직접 만들지 않고, 외부에서 주입받아요.

세 가지 주입 방식

1. 생성자 주입 (Constructor Injection)

JAVA
@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)

JAVA
@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private PaymentService paymentService;
}

3. Setter 주입 (Setter Injection)

JAVA
@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예요.

JAVA
public interface NotificationService { void send(String message); }

@Component("emailNotification")
public class EmailNotificationService implements NotificationService { ... }

@Primary
@Component("smsNotification")
public class SmsNotificationService implements NotificationService { ... }
JAVA
@Service
public class OrderService {

    // @Primary가 붙은 SmsNotificationService가 주입됨
    public OrderService(NotificationService notificationService) { ... }
}
JAVA
@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는 ** 프록시 기반 **으로 동작합니다. 타겟 객체를 직접 호출하는 게 아니라, 프록시 객체가 중간에 끼어서 부가 기능을 수행한 다음 타겟을 호출하는 구조예요.

PLAINTEXT
클라이언트 → [프록시 객체] → (부가 로직 실행) → [타겟 객체]

스프링은 빈을 등록할 때 AOP가 적용되어야 하는 빈이면 ** 원본 대신 프록시 객체를 컨테이너에 등록 **합니다. 클라이언트 코드는 프록시인지 원본인지 모르고 그냥 쓰기만 하면 돼요.


AOP 핵심 용어

용어설명예시
Aspect횡단 관심사를 모듈화한 것@Aspect 클래스 자체
Advice실제 부가 로직. 언제 실행할지도 정의@Before, @After, @Around
Pointcut어떤 JoinPoint에 Advice를 적용할지 선별하는 표현식@Pointcut("execution(* com.example.service.*.*(..))")
JoinPointAdvice가 적용될 수 있는 지점스프링 AOP에서는 ** 메서드 실행 시점 **만 지원
WeavingAspect를 타겟 객체에 적용하는 과정스프링은 ** 런타임 위빙** (프록시 생성)

Advice 타입을 좀 더 세분화하면 이렇습니다.

Advice 타입실행 시점
@Before타겟 메서드 실행 전
@After타겟 메서드 실행 후 (성공/실패 무관)
@AfterReturning타겟 메서드 정상 리턴 후
@AfterThrowing타겟 메서드 예외 발생 후
@Around타겟 메서드 실행 전후 모두 제어 가능. 가장 강력함
JAVA
@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 기반 기능입니다. 동작 원리를 제대로 이해하지 못하면 실무에서 삽질하게 되는 대표적인 케이스이기도 해요.

프록시 기반 동작

JAVA
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderRequest request) {
        orderRepository.save(new Order(request));
        paymentService.process(request.getPaymentInfo());
    }
}

이 코드에서 OrderService 빈은 실제로는 프록시 객체가 컨테이너에 등록됩니다. 외부에서 createOrder()를 호출하면 이런 흐름이 돼요.

PLAINTEXT
호출자 → [프록시: 트랜잭션 시작] → [실제 OrderService.createOrder()] → [프록시: 커밋 or 롤백]

Self-Invocation 문제

프록시 기반이기 때문에 생기는 유명한 함정이 있습니다. 같은 클래스 내부에서 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않아요.

JAVA
@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가 끼어들 틈이 없어요.

해결 방법은 몇 가지가 있습니다.

  1. ** 메서드를 별도 클래스로 분리 **: 가장 깔끔한 방법이에요. createOrder()를 다른 서비스로 빼면 프록시를 통해 호출됩니다.
  2. **self 주입 **: 자기 자신을 주입받아서 프록시를 통해 호출하는 방법입니다. 동작은 하지만 좀 어색해요.
  3. AopContext.currentProxy(): 현재 프록시 객체를 가져와서 호출합니다. 비추예요.

주의할 점

Spring vs Spring Boot 차이

이건 거의 무조건 나옵니다.

구분SpringSpring Boot
** 설정**XML 또는 Java Config로 수동 설정Auto Configuration으로 자동 설정
** 내장 서버**외부 WAS(Tomcat 등)에 WAR 배포내장 Tomcat으로 JAR 실행
** 의존성 관리**개별 라이브러리 버전 직접 관리spring-boot-starter로 호환 버전 자동 관리
** 목적**프레임워크 자체스프링을 쉽고 빠르게 쓰기 위한 도구

핵심은 Spring Boot가 별도의 프레임워크가 아니라 ** 스프링 위에 얹혀진 편의 계층 **이라는 점입니다. 내부적으로는 똑같은 스프링이 돌아가고 있어요.

빈 순환 참조 해결

A가 B를 주입받고, B가 A를 주입받는 상황입니다.

PLAINTEXT
A → B → A → B → ... (무한 루프)

스프링 부트 2.6부터는 순환 참조를 기본적으로 ** 금지 **합니다. 애플리케이션 시작 시점에 에러를 던져요. 해결 방법은 다음과 같습니다.

  • ** 설계 변경 **: 순환 참조 자체가 설계 문제인 경우가 대부분이에요. 공통 로직을 별도 클래스로 분리하는 게 정답입니다.
  • @Lazy: 주입 시점을 지연시켜서 순환을 끊는 방법입니다. 임시방편에 가까워요.
  • ** 이벤트 기반 처리 **: A → B 호출 대신 A가 이벤트를 발행하고 B가 구독하는 구조로 바꾸면 의존 관계 자체가 사라집니다.

CGLIB vs JDK Dynamic Proxy

스프링 AOP가 프록시를 만드는 방식은 두 가지입니다.

구분JDK Dynamic ProxyCGLIB
** 조건**타겟이 인터페이스를 구현한 경우인터페이스 없이 클래스만 있는 경우
** 방식**인터페이스 기반으로 프록시 생성클래스를 상속(바이트코드 조작)해서 프록시 생성
** 제약**인터페이스에 정의된 메서드만 프록시 가능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객체 생성·관리의 제어권을 스프링 컨테이너에 위임하는 것
DIIoC를 구현하는 방법. 생성자 주입이 표준
AOP횡단 관심사를 프록시 기반으로 분리하는 것. @Transactional이 대표적

외울 게 아니라 "왜 그렇게 동작하는지"를 설명할 수 있어야 합니다. IoC가 왜 필요한지, 생성자 주입을 왜 쓰는지, 프록시 기반이라서 생기는 제약이 뭔지. 원리를 알면 꼬리 질문이 와도 당황하지 않아요.

댓글 로딩 중...