이전에 Spring의 핵심 요소인 빈(Bean)에 대해 정리해보았으니, 이번에는 Spring이 AOP(Aspect Oriented Programming)을 어떤식으로 지원하는지 알아보겠습니다.
Aspects enable the modularization of concerns (such as transaction management) that cut across multiple types and objects.
관점(Aspect)은 트랜잭션 관리와 같이 여러 타입과 객체에 걸쳐 공통적으로 나타나는 관심사를 모듈화할 수 있게 해준다.
출처 - https://docs.spring.io/spring-framework/reference/core/aop.html
우리 코드 내에 다양한 클래스가 다양한 비즈니스 로직을 처리하고 있습니다. 그런데 이때, 비즈니스 핵심 기능과는 직접적인 연관이 없는, 서비스 구현 및 유지보수 등을 위해 모든 비즈니스 로직 코드에 각각 트랜잭션, 로깅 등의 부가 기능을 추가해야 한다고 해보겠습니다.
이렇게 다양한 클래스를 걸쳐서 동일한 코드를 작성하게 되고, 만약 해당 부가기능의 요구사항이 변경되면 마찬가지로 서비스 전체에 흩뿌려져 있는 부가기능 코드를 수정해야할 것입니다.
이렇게 코드 전반에 걸쳐 중복되고, 강하게 결합되어 있는 로직을 횡단 관심사(cross-cutting concerns)라고 말합니다.
관점 지향 프로그래밍(Aspect Oriented Programming, AOP)는 이러한 횡단관심사를 애스팩트(Aspect)라고 부르는 것으로 캡슐화함으로써 모듈화, 즉, 기존 핵심 비즈니스 로직과 분리하고 재사용할 수 있도록 하는 것을 목표로 하는 프로그래밍 패러다임입니다.
이러한 AOP를 구현하는 방법에는 다양한 방법이 있습니다. 이들은 모두 기본적으로 비즈니스 로직 코드를 담은 클래스를 개발 시점에 가능한 한 원본 그대로의 상태로 둘 수 있는 방식을 구현하고자 합니다. 그리하여 공통 로직이 되는 횡단 관심사 로직을 별도의 모듈로 만들고, 이것이 런타임 시점에 적용되도록 합니다.
컴파일 시, 혹은 클래스 로딩 시에 공통 로직을 삽입하도록 할 수 있으며, 스프링에서 사용하는 방식인 런타임에 프록시(Proxy) 객체를 만들어 원본 객체와 바꿔치기 하는 방법도 있습니다. 여기서는 스프링에서 사용되는 프록시 기반 방식에 대해 더 알아보겠습니다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다.
출처 - 김영한 스프링 핵심 원리 - 고급편
프록시(Proxy)란 원본 객체(target)를 가지고서 원본 객체와의 상호작용을 대리하는 객체(혹은 뭐든 무언가)입니다. 자바에서 프록시 객체는 원본 객체와 같은(혹은 상속받은) 타입이기 때문에 원본 객체의 자리에 대신 들어가 앉을 수 있습니다.
그럼 클라이언트는 해당 객체가 프록시인지 모른채 자신의 로직을 유지할 수 있고, 스프링처럼 DI(Dependency Injection)의 힘을 받으면 코드를 전혀 고치지 않고, 기존 빈을 프록시 빈으로 교체할 수 있습니다.
이렇게 교체된 프록시는 클라이언트 대신 원본 객체(target)을 호출하는 역할을 위임받습니다. 이러한 방식을 활용하여 타겟을 호출하기 전과 후에 원하는 로직을 끼워넣을 수 있는 것입니다.
이런 프록시를 이용하는 디자인 패턴에 프록시 패턴(Proxy Pattern)과 데코레이터 패턴(Decorator Pattern) 등이 있습니다. 프록시 패턴은 원본 객체에 대한 접근을 제어하기 위한 패턴이며, 데코레이터 패턴은 원본 객체에 기능을 추가하기 위한 패턴입니다. 둘 모두 프록시의 개념을 알면 이해하기 한층 쉽습니다.
아래는 프록시를 활용하는 예시 코드입니다.
// 1. 서비스 인터페이스
interface Service {
void doSomething();
}
// 2. 실제 서비스 (핵심 기능)
class ServiceImpl implements Service {
public void doSomething() {
System.out.println("서비스 실행 중...");
}
}
// 3. 프록시 (공통 기능 추가)
class ServiceProxy implements Service {
private ServiceImpl target;
public ServiceProxy(ServiceImpl target) {
this.target = realService;
}
public void doSomething() {
System.out.println("🔍 로그: 실행 전");
target.doSomething();
System.out.println("✅ 로그: 실행 후");
}
}
// 4. 실행 코드
public class Main {
public static void main(String[] args) {
Service service = new ServiceProxy(new ServiceImpl());
service.doSomething();
}
}
Service 인터페이스를 마찬가지로 구현하는 ServiceProxy가 doSomething()
호출 로직 안에 원하는 서비스를 추가한 것을 볼 수 있습니다.
이렇게 프록시를 만들면 기존 로직을 수정하지 않고 원하는 로직을 추가할 수 있습니다. 그러나 아직 문제가 남아있는데, 그것은 로직을 추가하고자 하는 클래스 수만큼 프록시를 작성해주어야 한다는 것입니다. 이를 위해 동적 프록시 기술을 활용할 수 있습니다.
자바가 지원하는 JDK 동적 프록시 기술 혹은 CGLIB와 같은 프록시 생성 오픈소스 기술을 사용하면 프록시 객체를 동적으로 만들어낼 수 있습니다. 이 둘에 사용되는 기술인 리플렉션부터 간단하게 살펴보고 넘어가겠습니다.
자바에서는 리플렉션 기술을 통해 클래스나 메서드의 메타정보를 동적으로 획득하고, 마찬가지로 코드도 동적으로 호출할 수 있습니다.
import java.lang.reflect.Method;
class Hello {
public void sayHello() {
System.out.println("안녕하세요!");
}
}
public class ReflectionExample {
public static void main(String[] args) throws Exception {
// 1. 클래스 객체 획득
Class<?> clazz = Class.forName("Hello");
// 2. 인스턴스 생성
Object obj = clazz.getDeclaredConstructor().newInstance();
// 3. 메서드 정보 획득
Method method = clazz.getMethod("sayHello");
// 4. 메서드 실행
method.invoke(obj); // 출력: 안녕하세요!
}
}
이처럼 리플렉션을 활용하면 코드에 직접적으로 의존하지 않고도 클래스와 메서드를 조작할 수 있어, 프록시 구현이나 프레임워크 설계 시 유용하게 사용됩니다.
이러한 리플렉션을 바탕으로 아래 기술들은 동적으로 프록시를 생성할 수 있습니다.
JDK 동적 프록시는 인터페이스를 기반으로 런타임에 프록시 객체를 생성합니다. java.lang.reflect.Proxy와 InvocationHandler를 이용하여 구현하며, 공통 기능을 프록시 내부에서 동적으로 적용할 수 있습니다. 프록시 객체는 인터페이스 기반이기 때문에, 프록시 대상이 반드시 인터페이스를 구현하고 있어야 합니다.
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// 1. 서비스 인터페이스
interface Service {
void doSomething();
}
// 2. 실제 서비스
class ServiceImpl implements Service {
public void doSomething() {
System.out.println("서비스 실행 중...");
}
}
// 3. InvocationHandler 구현
class LogHandler implements InvocationHandler {
private final Object target;
public LogHandler(Object target) {
this.target = target;
}
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("🔍 로그: 실행 전");
Object result = method.invoke(target, args);
System.out.println("✅ 로그: 실행 후");
return result;
}
}
// 4. 실행 코드
public class Main {
public static void main(String[] args) {
Service target = new ServiceImpl();
Service proxy = (Service) Proxy.newProxyInstance(
Service.class.getClassLoader(),
new Class[]{Service.class},
new LogHandler(target)
);
proxy.doSomething(); // 동적으로 생성된 프록시가 호출
}
}
CGLIB(Code Generation Library)는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리입니다. 클래스 상속을 기반으로 프록시 객체를 생성합니다. 인터페이스가 없어도 동작하기 때문에 순수 클래스만 존재하는 경우에도 프록시를 생성할 수 있습니다. 내부적으로 바이트코드를 조작하여 새로운 클래스를 만들고, 그 안에 공통 로직을 삽입합니다.
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
// 1. 실제 서비스 (인터페이스 없음)
class ConcreteService {
public void doSomething() {
System.out.println("서비스 실행 중...");
}
}
// 2. MethodInterceptor 구현
class LogInterceptor implements MethodInterceptor {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("🔍 로그: 실행 전");
Object result = proxy.invokeSuper(obj, args);
System.out.println("✅ 로그: 실행 후");
return result;
}
}
// 3. 실행 코드
public class Main {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(new LogInterceptor());
ConcreteService proxy = (ConcreteService) enhancer.create();
proxy.doSomething(); // CGLIB 프록시가 호출
}
}
스프링의 AOP 기본 설정은 이 CGLIB를 통해 프록시를 생성하여 동작합니다. 또한 필요한 경우 스프링의 ProxyFactory
가 CGLIB 사용을 편리하게 도와줍니다.
스프링에서는 AOP 기능을 제공하기 위해서 정의한 몇 가지 개념들이 있습니다. 그 중 중요한 몇 가지를 살펴보겠습니다.
target.doSomething()
을 아무튼 호출해줘야 합니다). 따라서 Spring AOP에서 Join point는 메서드 실행 시점을 의미합니다.코드를 보기 전에 몇 가지를 정리해보겠습니다.
스프링은 AspectJ라는 자바 기반 AOP 프레임워크의 인터페이스(문법)를 차용하여 몇 가지 제한적인 기능을 자체적으로 구현하여 제공하고 있습니다. 몇 가지라고 했지만 대부분의 경우 스프링이 제공하는 기능으로 충분하다고 이야기 합니다.
즉, 정리하면 Spring AOP는 AspectJ 문법을 통해 CGLIB 또는 JDK 동적 프록시로 AOP를 구현하고 있습니다. 클라이언트로서 우리는 AspectJ 인터페이스를 사용하지만 내부적으로 프록시 처리는 저 두 방식이 이용되는 것입니다. 그러므로 스프링에 의해 프록시 객체가 생성되고 빈으로 등록되어 런타임에 사용되는 실제 객체를 대신하게 된다는 흐름을 염두에 두고 아래 코드를 살펴보시면 좋습니다.
public interface HelloService {
void doSomething();
}
@Slf4j
@Service
public class ServiceImpl implements HelloService {
@Override
public void doSomething() {
log.info("핵심 로직 실행");
}
}
@Aspect
@Slf4j
@Component
public class LoggingAspect {
// HelloService의 메서드
@Pointcut("execution(* hello.aop.service.code.HelloService.*(..))")
public void helloService() {}
@Around("helloService()")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[LOG] 호출 시작 {}", joinPoint.getSignature());
Object result = joinPoint.proceed(); // 실제 대상(target) 메서드 호출
log.info("[LOG] 호출 완료 {}", joinPoint.getSignature());
return result;
}
}
@Pointcut
으로 포인트컷을 정의해 사용할 수 있습니다. 이것은 어드바이스에 직접 입력해 사용하는 것도 가능합니다.@Around
는 어드바이스를 적용하는 여러 방법 중 가장 기본 방법입니다. ProceedingJoinPoint
로 넘어온 타겟 객체의 원본 메서드를 호출해주어야 하며, 해당 호출 앞뒤로 원하는 로직을 추가할 수 있습니다.@Before
, @AfterReturning
, @AfterThrowing
, @After
등이 있어 각각 원하는 제한된 위치에 기능을 적용할 수 있습니다. 추가적인 자세한 설명은 생략합니다.@Component
를 통해 컴포넌트 스캔 대상으로 지정하였습니다.@Slf4j
@SpringBootTest
public class AopTest {
@Autowired
HelloService helloService;
@Test
void testService() {
helloService.doSomething();
}
}
helloService
의 메서드를 호출했는데 메서드 호출 전후에 로그가 찍힌 것을 볼 수 있습니다. 이런식으로 부가 기능을 원하는 포인트컷에 적용하고 핵심 기능과 분리하여 개발할 수 있으며, 추가적으로 포인트컷은 어노테이션을 기반으로도 동작할 수 있기 때문에 @MyLogging
과 같이 커스텀 어노테이션을 만들어서 해당 어노테이션이 붙은 메서드 혹은 클래스에 어드바이스를 적용하도록 구현할 수 있습니다.
이를 활용한 스프링 AOP가 적용된 어노테이션으로 많이 쓰이는 @Transactional
, @Async
, @Validated
등이 있습니다.
Spring AOP를 알고 나니 평소에 사용하던 어노테이션의 기능이 어떤 식으로 동작하는지 알 수 있었습니다. 또한 기회가 되는데로 얼른 커스텀 어노테이션과 Aspect를 구현해 AOP 기능을 적용해보고 싶다는 욕심도 생깁니다. 다음에는 Spring AOP 기능이 적용된 @Transactional과 스프링 트랜잭션 등에 대해 다뤄볼까 고민만 하고 있습니다. 어찌될지 모릅니다.
혹시라도 내용에 오류가 있으면 얼마든지 알려주세요! 🙇♂️