등장 배경

여러 클래스에 공통으로 들어가는 기능이 있다. 메서드가 호출될 때마다 로그를 남기거나, 트랜잭션을 시작하고 커밋하거나, 실행 시간을 측정하는 코드가 대표적이다. 이런 부가 기능을 핵심 비즈니스 로직 코드 사이사이에 직접 끼워 넣으면, 정작 중요한 로직은 부가 기능 코드에 파묻혀 알아보기 어려워지고, 같은 코드가 여러 클래스에 중복해서 흩어지게 된다. 이렇게 여러 모듈에 공통으로 걸쳐 있는 관심사를 횡단 관심사(Cross-Cutting Concern)라 부르는데, 상속이나 위임 같은 객체지향 기법만으로는 이 문제를 깔끔하게 해결하기 어렵다.

관점 지향 프로그래밍(AOP, Aspect Oriented Programming)은 이 횡단 관심사를 핵심 로직에서 분리해 하나의 모듈, 즉 관점(Aspect)으로 모듈화하는 프로그래밍 패러다임이다. 핵심 로직은 핵심 로직대로 깔끔하게 유지하고, 로깅이나 트랜잭션 같은 부가 기능은 별도로 관리하다가 필요한 시점에 자동으로 적용(위빙)한다.

핵심 용어

AOP를 이해하려면 몇 가지 용어를 먼저 알아야 한다. Aspect는 횡단 관심사를 모듈화한 하나의 단위로, Advice와 Pointcut을 함께 갖는다. Target은 Aspect가 적용될 대상, 즉 부가 기능이 부여될 원래의 객체를 말한다.

Advice는 Aspect가 실제로 수행할 부가 기능 자체이자 그 기능이 실행되는 시점을 가리키는데, 시점에 따라 다섯 가지로 나뉜다. Before는 대상 메서드가 호출되기 전에 실행되고, After는 대상 메서드의 결과(정상 종료든 예외든)와 무관하게 실행되며, After Returning은 대상 메서드가 정상적으로 값을 반환한 뒤에만 실행되고, After Throwing은 대상 메서드가 예외를 던졌을 때만 실행되며, Around는 대상 메서드 호출 전후를 모두 감싸 실행 자체를 제어할 수 있어 다섯 가지 중 가장 강력하고 유연하다.

JoinPoint는 Advice가 적용될 수 있는 모든 지점을 말한다. 메서드 호출, 필드 접근, 생성자 호출 등이 모두 JoinPoint가 될 수 있지만, Spring AOP는 이 중 메서드 실행 지점만 JoinPoint로 지원한다. Pointcut은 JoinPoint 중에서 실제로 Advice를 적용할 지점을 선별하는 조건식이다. 예를 들어 "service 패키지 하위의 모든 클래스에서 이름이 find로 시작하는 메서드"처럼 조건을 표현식으로 지정한다. Weaving은 Aspect를 Target에 적용해 새로운 프록시 객체를 만들어내는 과정을 말하며, 이 위빙이 언제 일어나는지에 따라 AOP 구현 방식이 나뉜다.

Weaving 시점에 따른 구현 방식

위빙은 크게 세 시점에 이루어질 수 있다. 컴파일 타임 위빙은 자바 소스 코드를 컴파일할 때 바이트코드에 직접 Aspect 코드를 삽입하는 방식으로, AspectJ가 대표적이다. 이 방식은 메서드 호출뿐 아니라 필드 접근, 생성자 호출 등 모든 JoinPoint에 적용할 수 있어 가장 강력하지만, 별도의 컴파일러가 필요하다는 부담이 있다. 로드 타임 위빙은 클래스 로더가 클래스 파일을 JVM에 로딩하는 시점에 바이트코드를 조작해 Aspect를 삽입하는 방식이다. 런타임 위빙은 애플리케이션이 실행되는 도중에 프록시 객체를 동적으로 생성해 Aspect를 적용하는 방식으로, Spring AOP가 이 방식을 쓴다.

Spring AOP의 특징

Spring AOP는 프록시 기반으로 동작한다. Target 객체를 감싸는 프록시 객체를 런타임에 생성한 뒤, 클라이언트가 이 프록시 객체를 통해 Target의 메서드를 호출하도록 만든다. 프록시는 실제 메서드를 호출하기 전후로 등록된 Advice를 실행하고, 이 과정을 개발자는 신경 쓸 필요가 없다. 컴파일 타임 위빙 방식인 AspectJ와 달리 별도의 컴파일 과정이 필요 없고 Spring의 Bean 설정만으로 적용할 수 있다는 점이 장점이지만, 그만큼 제약도 있다. 프록시가 메서드 호출을 가로채는 방식이다 보니 메서드 실행 지점만 JoinPoint로 지원하며, 필드 접근이나 생성자 호출에는 적용할 수 없다. 또한 같은 클래스 내부에서 메서드를 호출하는 **자기 자신 호출(self-invocation)**은 프록시를 거치지 않기 때문에 Advice가 적용되지 않는다는 점도 자주 걸려 넘어지는 함정이다.

Spring이 프록시 객체를 만드는 방식은 두 가지다. Target이 인터페이스를 구현하고 있다면 **JDK 동적 프록시(JDK Dynamic Proxy)**를 사용해 그 인터페이스를 구현하는 프록시를 만들고, 인터페이스가 없다면 CGLIB를 이용해 Target 클래스를 상속하는 프록시를 만든다. Spring Boot 2.x부터는 인터페이스 유무와 관계없이 기본적으로 CGLIB를 사용하도록 설정이 바뀌었다.

코드로 보는 Spring AOP

로그를 남기는 Aspect를 예로 들면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Aspect
@Component
public class LoggingAspect {

@Around("execution(* com.example.service..*.*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();

Object result = joinPoint.proceed(); // Target 메서드 실행

long executionTime = System.currentTimeMillis() - start;
System.out.println(joinPoint.getSignature() + " 실행 시간: " + executionTime + "ms");

return result;
}
}

@Aspect는 이 클래스가 Aspect임을 선언하고, @Around의 표현식은 com.example.service 패키지 하위의 모든 클래스, 모든 메서드를 Pointcut으로 지정한다. ProceedingJoinPointproceed()를 호출하는 시점이 실제 Target 메서드가 실행되는 지점이며, 그 앞뒤로 원하는 코드를 자유롭게 추가할 수 있다.

@Transactional 어노테이션도 내부적으로는 이 원리를 그대로 따른다. 트랜잭션을 시작하고 커밋 또는 롤백하는 코드를 매번 직접 작성하는 대신, 스프링이 프록시를 통해 메서드 실행 전후로 트랜잭션 처리 Aspect를 자동으로 적용해주는 것이다.

AOP를 쓰는 이유

AOP가 자주 쓰이는 대표적인 영역은 로깅, 트랜잭션 관리, 보안(인증·인가), 예외 처리, 성능 측정처럼 애플리케이션 전반에 반복적으로 등장하지만 핵심 비즈니스 로직과는 무관한 부가 기능들이다. 이런 기능을 Aspect로 분리해두면, 핵심 로직 코드는 그 자체의 관심사에만 집중할 수 있고, 부가 기능은 한 곳에서 관리되므로 수정이나 확장도 훨씬 쉬워진다.