개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Spring › AOP와 프록시

프록시와 자기 호출

애너테이션을 붙였는데 왜 동작하지 않나

@Transactional이나 @Async는 메서드 자체를 바꾸지 않습니다. 스프링이 그 빈 대신 프록시(대리 객체)를 등록해 두고, 프록시를 거쳐 들어오는 호출에만 부가 기능을 끼워 넣습니다. 그래서 어디서 어떻게 호출하느냐에 따라 같은 애너테이션이 적용되기도 하고 무시되기도 합니다.

실행으로 대조 · 스프링 7.0.9에서 열 가지 조합을 실행해 대조

이 실습에 나오는 개념

프록시(Proxy)
실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.
자기 호출(Self-invocation)
같은 객체 안에서 자신의 다른 메서드를 직접 호출하는 것이다. 프록시 기반의 기능은 외부에서 프록시를 거쳐 들어오는 호출에만 적용되므로, 내부에서 this로 호출한 메서드에는 @Transactional, @Async, @Cacheable 같은 설정이 적용되지 않는다. 해당 메서드를 다른 빈으로 분리하는 것이 일반적인 해결책이다.
AOP(관점 지향 프로그래밍)
로깅, 트랜잭션, 권한 검사처럼 여러 곳에 반복해서 나타나는 부가 기능(횡단 관심사)을 핵심 비즈니스 코드에서 분리해 한곳에 모아 두는 프로그래밍 기법이다. 부가 기능을 한 번만 작성하고 "어디에 적용할지"를 따로 지정하므로, 핵심 코드가 깔끔해지고 부가 기능의 수정이 한곳에서 끝난다.
JDK 동적 프록시와 CGLIB
스프링이 프록시를 만드는 두 가지 방식이다. JDK 동적 프록시는 대상 객체가 구현한 인터페이스를 구현하는 프록시를 만든다. CGLIB는 대상 클래스를 상속한 하위 클래스를 실행 중에 만들어 프록시로 쓴다. Spring Boot는 기본적으로 CGLIB를 사용한다. 상속 방식이므로 final 클래스와 final·private 메서드에는 적용할 수 없다.
@Async
메서드를 호출한 스레드가 아닌 별도의 스레드 풀에서 실행하게 하는 애너테이션이다. 호출한 쪽은 결과를 기다리지 않고 바로 다음으로 진행한다. 프록시 기반이므로 같은 클래스 안에서 호출하면 적용되지 않고, 다른 스레드에서 실행되므로 호출한 쪽의 트랜잭션이 이어지지 않는다.
@Transactional
메서드나 클래스에 붙여 그 범위를 하나의 트랜잭션으로 실행하게 하는 애너테이션이다. 프록시가 메서드 실행 전에 트랜잭션을 시작하고, 정상적으로 끝나면 커밋, 예외가 발생하면 규칙에 따라 롤백한다. 트랜잭션을 시작하고 끝내는 코드를 직접 쓰지 않아도 되어 선언적 트랜잭션이라고 한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 애플리케이션이 뜰 때 스프링은 @Transactional이 붙은 메서드를 가진 빈을 발견하면, 원래 객체 대신 호출을 받을 프록시를 만들어 빈으로 등록합니다. 다른 빈이 주입받는 것은 이 프록시입니다. 그런 메서드가 없는 빈은 원래 객체 그대로 등록됩니다.
  2. 컨트롤러가 orderService.placeOrder()를 호출합니다. 프록시가 받지만 placeOrder()에는 애너테이션이 없으므로 그대로 실제 객체에 넘깁니다.
  3. 실제 객체 안에서 saveAll()을 호출합니다. 이 호출은 this.saveAll()입니다. this는 프록시가 아니라 실제 객체 자신이므로 프록시를 거치지 않습니다.
  4. 트랜잭션을 시작해 줄 프록시가 없습니다. 주문 A를 INSERT합니다. 트랜잭션이 없으므로 이 INSERT는 실행되자마자 커밋됩니다(자동 커밋).
  5. 주문 B를 INSERT합니다. 이것도 바로 커밋됩니다.
  6. 예외가 발생했지만 롤백해 줄 트랜잭션이 없습니다. 이미 커밋된 주문 A와 B는 DB에 그대로 남습니다. 실패한 작업의 데이터가 반쯤 저장된 상태가 됩니다.

실행 결과

코드

@Service class OrderService {
    public void placeOrder() {                 // 컨트롤러가 호출
        saveAll();                             // this.saveAll()과 같다
    }

    @Transactional
    public void saveAll() {
        orderDao.insert(a);
        orderDao.insert(b);
        throw new IllegalStateException();   // 도중에 실패
    }
}

이 모형의 전제

스프링의 기본 방식인 프록시 기반 AOP를 기준으로 했습니다(AspectJ 위빙을 쓰면 내부 호출에도 적용할 수 있지만 흔하지 않습니다). @Async는 @EnableAsync가 켜져 있다고 가정했습니다. 트랜잭션이 없을 때 INSERT가 각각 바로 커밋된다는 것은 JDBC 커넥션의 자동 커밋 기본값을 따른 것입니다. 열 가지 조합 모두를 실제 스프링(스프링 프레임워크 7.0.9)의 @Transactional, @Async 빈으로 실행해 확인했습니다. 프록시를 거친 호출에서만 메서드 안에 트랜잭션이 열려 있었고 예외 뒤에 남은 행이 없었으며, 내부 호출과 private 메서드에서는 두 행이 그대로 남았습니다. @Async도 프록시를 거친 호출만 별도 스레드(task-1)에서 실행됐고, 내부 호출은 호출한 스레드에서 끝까지 실행됐습니다(메일 발송 3초는 0.3초로 줄여 시험).

앞 실습: 빈 생명주기 콜백 · 다음 실습: AOP: 어드바이스와 포인트컷

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요