이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
기초개념 › Spring
AOP와 프록시
프록시는 무엇이고, 스프링은 어떻게 내 메서드 앞뒤에 끼어드는가
@Transactional 같은 애너테이션을 메서드에 붙이면, 우리가 쓰지 않은 일이 그 메서드를 부를 때 앞뒤에서 일어납니다. 스프링이 우리가 쓴 클래스로 만든 객체를 그대로 쓰지 않고, 그 객체 대신 호출을 받을 대리 객체(프록시)를 만들어 보관해 두기 때문입니다. 이 실습은 같은 일을 @Transactional 대신 직접 쓴 LogAspect로 보입니다. LogAspect는 @Aspect가 붙은 클래스로, 여러 메서드에 공통으로 끼워 넣을 일(여기서는 로그 한 줄)을 따로 적어 두는 곳입니다. @Before 안의 execution(* OrderService.order(..))는 OrderService 클래스의 order() 메서드를 고르는 식입니다. 이렇게 공통으로 끼워 넣을 일을 따로 적어 두는 방식을 AOP라고 합니다. 부르는 쪽 Caller의 orderService 칸에는 @Autowired가 붙어 있으면 스프링이 보관한 객체가 들어옵니다. 이 실습은 LogAspect를 지우거나 그 칸에 new OrderService()로 직접 만든 객체를 넣어 가며, 같은 order() 호출이 무엇을 찍는지와 호출을 받은 객체가 무엇인지를 봅니다.
실행으로 대조 · Spring 7.0.9와 AspectJ weaver 1.9.25.1에서 네 가지 조합을 실제 @Aspect 프록시로 실행해 찍힌 줄과 부른 객체의 클래스를 대조
이 실습에 나오는 개념
- 프록시(Proxy)
- 실제 객체를 대신해 호출을 받는 대리 객체다. 호출을 받으면 부가 작업을 수행한 뒤 실제 객체에 전달한다. 스프링은 AOP와 @Transactional 같은 기능을 적용할 빈을 프록시로 감싸 컨테이너에 등록하므로, 다른 빈이 주입받는 것은 실제 객체가 아니라 프록시다.
- AOP(관점 지향 프로그래밍)
- 로깅, 트랜잭션, 권한 검사처럼 여러 곳에 반복해서 나타나는 부가 기능(횡단 관심사)을 핵심 비즈니스 코드에서 분리해 한곳에 모아 두는 프로그래밍 기법이다. 부가 기능을 한 번만 작성하고 "어디에 적용할지"를 따로 지정하므로, 핵심 코드가 깔끔해지고 부가 기능의 수정이 한곳에서 끝난다.
- @Aspect와 @Before
- @Aspect는 클래스 위에 붙여, 그 클래스가 애스펙트(여러 메서드에 공통으로 끼워 넣을 일을 적어 둔 클래스)라고 표시하는 애너테이션이다. 애스펙트도 스프링이 관리하는 빈이어야 하므로, 클래스패스 스캔으로 찾게 하려면 @Aspect만으로는 부족하고 @Component 같은 애너테이션을 따로 붙여야 한다. @Before는 애스펙트의 메서드 위에 붙이고 괄호 안에 포인트컷 식을 적는다. 그 식이 고른 메서드가 실행되기 전에 이 메서드가 먼저 실행된다. 스프링이 어떤 빈에 애스펙트가 걸렸다고 판단하면 그 빈의 프록시를 자동으로 만든다(auto-proxying). 이 기능을 쓰려면 @AspectJ 지원을 켜야 하고 aspectjweaver 라이브러리가 클래스패스에 있어야 한다.
- @Autowired
- @Autowired는 필드나 생성자에 붙여, 스프링이 그 자리에 들어갈 타입에 맞는 빈을 컨테이너에서 찾아 넣어 주게 하는 애너테이션이다. 필드에 붙이면 그 필드에는 우리 코드가 값을 넣는 줄이 없어도 스프링이 객체를 채워 준다. 맞는 빈이 없으면 기본적으로 주입에 실패한다. 스프링이 찾는 곳은 컨테이너가 보관한 빈이므로, 코드에서 new로 직접 만든 객체는 컨테이너가 보관한 빈과 다른 객체다.
이렇게 바꿔 보세요
- 처음 설정으로 — LogAspect가 있고 스프링이 넣어 준 객체로 부릅니다. 부른 객체가 프록시라서 order() 앞에 [로그] 줄이 찍힙니다.
- LogAspect를 지우면 — 끼워 넣을 일이 없어 스프링이 프록시를 만들지 않습니다. "주문 처리"만 찍힙니다.
- new로 만든 객체로 부르면 — 스프링 안에는 프록시가 있지만 부른 것은 직접 만든 객체라서 [로그] 줄이 찍히지 않습니다.
- 둘 다 바꾸면 — 스프링이 끼어들 곳이 없는 평범한 자바 호출입니다. "주문 처리"만 찍힙니다.
처음 설정으로 실행하면
- 프로그램이 시작되면 스프링이 @Component나 @Service가 붙은 클래스를 찾아(컴포넌트 스캔) 그 클래스의 객체를 만들어 보관합니다. 이렇게 스프링이 만들어 보관하는 객체를 빈이라고 합니다. OrderService 클래스의 객체도 하나 만들어 둡니다. 우리가 쓴 코드에는 new OrderService()라는 줄이 없습니다. @Component가 붙은 LogAspect 클래스의 객체도 만들어 둡니다.
- 스프링이 @Aspect가 붙은 LogAspect를 찾습니다. @Before 안의 execution(* OrderService.order(..))는 OrderService 클래스의 order() 메서드(매개변수는 몇 개든)를 고르는 식입니다. 방금 만든 OrderService 객체에는 이 식에 맞는 order()가 있습니다. 그래서 스프링은 이 객체를 그대로 빈으로 쓰지 않고, 이 객체 대신 호출을 받을 대리 객체(프록시)를 만들어 빈으로 보관합니다. 프록시는 OrderService를 상속한 클래스 OrderService$SpringCGLIB$0의 객체이고, 진짜 OrderService 객체를 참조로 갖고 있습니다. 둘은 서로 다른 객체입니다. 프록시를 만드는 줄은 우리가 쓴 코드에 없습니다.
- 스프링이 Caller 클래스의 객체도 만듭니다. orderService 칸에는 @Autowired가 붙어 있어서, 스프링이 이 칸에 들어갈 OrderService 타입의 빈을 찾아 넣어 줍니다. 칸에 값을 넣는 줄은 우리가 쓰지 않았습니다. 지금 이 칸에 들어간 것은 스프링이 보관한 프록시입니다.
- Caller의 run()이 orderService.order()를 부릅니다. orderService 칸에 든 것이 프록시이므로, 호출은 진짜 OrderService가 아니라 프록시에 먼저 도착합니다. 프록시는 order()가 LogAspect의 @Before 식에 맞는 메서드라는 것을 알고, 진짜 order()를 부르기 전에 LogAspect의 log()를 먼저 실행합니다.
- 프록시가 LogAspect의 log()를 실행해 "[로그] order 호출됨"을 찍습니다. 이 줄은 order()가 아니라 LogAspect에 적혀 있습니다.
- 프록시가 참조로 갖고 있던 진짜 OrderService 객체의 order()를 부르고, order()가 "주문 처리"를 찍습니다. order() 안에는 로그를 찍는 줄이 없습니다.
- 이어서 run()이 orderService 칸에 든 객체의 클래스 이름을 getSimpleName() 메서드로 찍습니다. 찍힌 이름은 "OrderService$SpringCGLIB$0"입니다. OrderService가 아니라 스프링이 프로그램이 도는 중에 만든 다른 클래스입니다. 우리가 쓴 코드에는 이 이름이 없습니다. 프록시를 부르고 있다는 표시입니다.
실행 결과
- LogAspect는 @Aspect가 붙은 클래스이고, order()에 공통으로 끼워 넣을 일(로그 찍기)을 따로 적어 둔 곳입니다. 스프링이 OrderService 객체를 그대로 빈으로 쓰지 않고 그 객체 대신 호출을 받을 프록시를 만들어 보관했고, order()를 부르자 프록시가 먼저 받아 [로그]를 찍은 뒤 진짜 order()를 불렀습니다.
- order() 안에는 로그를 찍는 줄이 없습니다. 부른 객체의 클래스 이름이 OrderService가 아니라 OrderService$SpringCGLIB$0인 것이 프록시를 불렀다는 표시입니다. @Transactional을 붙인 빈의 클래스 이름이 같은 꼴로 바뀌는 것도 같은 일입니다.
코드
// 끼워 넣는 일: 스프링이 보관한 OrderService 빈의 order()를 부를 때마다 실행된다
@Aspect
@Component
class LogAspect {
@Before("execution(* OrderService.order(..))")
void log() {
System.out.println("[로그] order 호출됨");
}
}
@Service
class OrderService {
public void order() {
System.out.println("주문 처리");
}
}
@Component
class Caller {
// 스프링이 만들어 둔 빈을 찾아 넣어 준다
@Autowired
OrderService orderService;
void run() {
orderService.order();
System.out.println(orderService.getClass().getSimpleName());
}
}
이 모형의 전제
Spring Framework 7.0.9(AspectJ weaver 1.9.25.1)를 JDK 21에서 AnnotationConfigApplicationContext로 실행해 대조했습니다. 네 가지 조합을 모두, 화면의 코드와 같은 소스(package와 import 줄만 더함)를 컴파일하고 자식 자바 프로세스를 조합마다 새로 띄워 확인했습니다. 설정 클래스에는 @EnableAspectJAutoProxy와 @ComponentScan을 두었고, 클래스들은 스캔으로 찾게 했습니다(화면에는 이 설정이 보이지 않습니다). 대조한 것은 run()이 화면에 찍은 줄과 그 순서, 부른 객체의 클래스 이름과 AopUtils로 잰 프록시 여부, 스프링 컨테이너에서 꺼낸 OrderService 빈의 클래스와 프록시 여부, 부른 객체와 그 빈이 같은 객체인지, 컨테이너 안의 프록시 개수입니다. 단계 문장이 기대는 사실로, 프록시가 OrderService를 상속한 클래스이고 그 안에 진짜 OrderService 객체가 하나 들어 있다는 것, 스프링이 넣어 준 객체가 컨테이너에 보관된 그 빈과 같다는 것, new로 만든 객체는 그 빈과 다르다는 것도 실행으로 확인했습니다. 프록시의 클래스 이름 OrderService$SpringCGLIB$0는 이 PC에서 실제로 찍힌 이름입니다. 문서가 정한 것은 스프링 프레임워크 7.0 문서 Core의 AOP Proxies 절(스프링 AOP가 프록시 기반이라는 것, 인터페이스가 없는 객체는 기본으로 CGLIB로 프록시한다는 것), Understanding AOP Proxies 절(호출이 프록시에 닿아야 어드바이스가 실행된다는 것), Enabling @AspectJ Support 절(aspectjweaver가 클래스패스에 있어야 한다는 것, 어드바이스가 걸린 빈에는 프록시가 자동으로 만들어진다는 것), Declaring an Aspect 절(@Aspect만으로는 스캔되지 않고 @Component가 필요하다는 것)입니다. 다만 "프록시가 안의 진짜 객체를 부르기 전에 @Before를 실행한다"는 순서를 문서가 한 문장으로 쓰고 있지는 않아서, 그 순서는 Probe가 찍은 줄의 순서로 확인한 관찰입니다. 이 실습의 OrderService는 인터페이스가 없는 클래스라서 CGLIB 프록시가 만들어졌습니다. 인터페이스를 구현한 클래스에서 JDK 동적 프록시가 쓰이는 경우, 같은 클래스 안에서 부르는 경우(자기 호출), 여러 메서드와 여러 애스펙트를 다루는 경우는 다루지 않았고, 그림은 화면의 코드만 보여 주기 위해 줄였습니다. 스프링 부트의 자동 설정으로 AOP가 켜지는 경우도 실행하지 않았습니다. 화면의 코드는 package와 import 줄을 줄였습니다. @Before의 정확한 실행 시점과 포인트컷 식의 규칙은 "AOP: 어드바이스와 포인트컷" 실습에서, 자기 호출과 프록시 방식(JDK 동적 프록시, CGLIB)은 "프록시와 자기 호출" 실습에서 다룹니다.
앞 실습: 의존성 주입 · 다음 실습: 서블릿