aop 가 아래와 같은 상황에서 현실적으로 크게 와닿았기 때문에 이번 기회를 바탕으로 정리해보았습니다.
- @Transactional 어노테이션을 붙이더라도, 같은 클래스 내부의 다른 함수에서 호출된다면 transactional 이 제대로 일어나지 않는다는 걸 이해햐는 상황
- logger.log 를 매 메소드에 작성해서 로그를 쓰려니 로깅이 일관적이지 않고 번거로운 문제를 겪을 때.
Spring AOP(Aspect-Oriented Programming) 에 대해 알아보자!
AOP(Aspect-Oriented Programming)는 관점 지향 프로그래밍으로, 핵심 비즈니스 로직과 부가 기능을 분리하여 모듈화하는 프로그래밍 패러다임입니다.
Spring Framework에서는 AOP를 통해 트랜잭션 관리, 로깅 등의 공통 관심사를 비즈니스 로직으로부터 분리할 수 있습니다.
1. AOP 기본 개념
주요 용어 (구성요소)
- Aspect: 여러 객체에 공통적으로 적용되는 관심사(예: 로깅, 트랜잭션)를 모듈화한 것
- Join Point: 메소드 실행, 예외 처리 등 프로그램 실행 중의 특정 지점
- Advice: 특정 Join Point에서 Aspect가 취하는 행동(before, after, around 등)
- Pointcut: Advice를 적용할 Join Point를 선별하는 표현식
- Target Object: Aspect가 적용되는 객체
- Weaving: Aspect를 Target Object에 연결하는 과정
Advice 유형
- @Before: 메소드 실행 전에 실행
- @After: 메소드 실행 후에 실행(성공/예외 상관없이)
- @AfterReturning: 메소드가 정상적으로 리턴된 후 실행
- @AfterThrowing: 메소드가 예외를 던진 후 실행
- @Around: 메소드 실행 전후에 실행(가장 강력함)
2. Spring AOP 설정
AOP 활성화
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {
// 설정 클래스
}
Spring Boot에서는 spring-boot-starter-aop 의존성을 추가하면 자동으로 AOP가 활성화됩니다.
3. Aspect 생성
기본 로깅 Aspect
다음 공식문서를 참고하여, 원하는 service/ controller 를 지정하여 Pointcut 를 지정하여 Aspect 를 적용할 수 있습니다.
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class LoggingAspect {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
// service 패키지의 모든 메소드에 적용
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceMethods() {}
@Before("serviceMethods()")
public void logBefore(JoinPoint joinPoint) {
logger.info("Before executing: {}.{}",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName());
}
@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
logger.info("Method returned: {}.{} with result: {}",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName(),
result);
}
@AfterThrowing(pointcut = "serviceMethods()", throwing = "exception")
public void logAfterThrowing(JoinPoint joinPoint, Exception exception) {
logger.error("Exception in {}.{}: {}",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName(),
exception.getMessage());
}
}
메소드 실행 시간 측정 Aspect
각 클래스 내 메소드 별로 실행 시간 측정을 원할 경우, 다음과 같이 실행 완료까지 걸리는 시간을 측정하여 로깅할 수도 있습니다.
@Aspect
@Component
public class PerformanceAspect {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Around("@annotation(com.example.annotation.MeasureExecutionTime)")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
// 메소드 실행
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - start;
logger.info("{}.{} executed in {} ms",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName(),
executionTime);
return result;
}
}
// 커스텀 애노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MeasureExecutionTime {
}
트랜잭션 관리 Aspect
스프링에서는 @Transactional 애노테이션을 사용하여 트랜잭션을 관리하는데, 내부적으로 AOP를 사용하여 구현됩니다.
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public User createUser(UserDto userDto) {
// 사용자 생성 로직// 예외 발생 시 트랜잭션이 롤백됨
return userRepository.save(new User(userDto));
}
}
4. JoinPoint와 ProceedingJoinPoint
JoinPoint 클래스는 메소드 실행, 예외 처리 등 프로그램 실행 중의 특정 지점에 대한 정보를 가지고 있는 객체입니다.
대부분의 Advice 메소드에서 첫 번째 파라미터로 사용됩니다.
JoinPoint
@Before("execution(* com.example.service.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
// 메소드 시그니처 정보
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getSignature().getDeclaringTypeName();
// 메소드 인자
Object[] args = joinPoint.getArgs();
logger.info("Executing {}:{} with args: {}", className, methodName, Arrays.toString(args));
}
ProceedingJoinPoint
ProceedingJointPoint 의 경우 Around Advice에서만 사용 가능하며, 대상 메소드의 실행을 제어할 수 있습니다.
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
// 전처리
logger.info("Before method execution");
try {
// 메소드 실행
Object result = joinPoint.proceed();
// 후처리
logger.info("After method execution");
return result;
} catch (Exception e) {
// 예외 처리
logger.error("Exception occurred: {}", e.getMessage());
throw e;
}
}
6. Advice에서 파라미터 접근
aop 동작 중, 해당 JoinPoint 에 대해 어떤 파라미터가 들어왔는지 등등에 대한 정보를 얻을 수 있습니다.
1) JoinPoint를 통한 파라미터 접근
@Before("execution(* com.example.service.*.*(..))")
public void accessParameters(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof User) {
User user = (User) arg;
logger.info("User: {}", user.getUsername());
}
}
}
2) 애노테이션 값 접근
@Before("@annotation(loggable)")
public void beforeLoggableMethod(JoinPoint joinPoint, Loggable loggable) {
String level = loggable.level();
logger.info("Logging level: {}", level);
}
3) 특정 파라미터 바인딩
@Before("execution(* com.example.service.UserService.*(..)) && args(user,..)")
public void beforeUserMethods(JoinPoint joinPoint, User user) {
logger.info("Operating on user: {}", user.getUsername());
}
지금까지는 aop의 활용법에 대해 정리해보았습니다.
이제는 aop 의 구현과 그 메커니즘에 대해 알아봅시다.
AOP 프록시 메커니즘
Spring AOP는 프록시 기반의 AOP 구현을 사용합니다:
인터페이스를 구현한 클래스에는 JDK 동적 프록시가 적용됩니다. (참고)
인터페이스가 없는 클래스에는 CGLIB 프록시가 적용됩니다. (참고)
JDK 동적 프록시의 경우, 인터페이스를 구현한 클래스의 프록시를 생성할 수 있으며, 이 프록시는 특정 메서드 호출을 가로채 추가 로직을 수행한 후 원본 객체의 메서드를 호출할 수 있게 됩니다.
cglib 프록시의 경우 Code Generation Library 의 약자로, 클래스 상속 기반 프록시 생성을 사용합니다. 대상 클래스를 상속받은 서브클래스를 런타임에 생성하여 메서드를 오버라이드하는 방식으로 AOP를 적용합니다.
인터페이스가 없는 클래스도 프록시 생성이 가능하며, 바이트코드 조작을 통해 동적으로 클래스를 생성합니다.
@EnableAspectJAutoProxy(proxyTargetClass = true) 가 설정된 경우, CGLIB 프록시가 우선적으로 적용됩니다.
간략하게 둘을 비교하자면
- jdk 동적 프록시는 메모리 사용량이 적지만 인터페이스에 한해서만 적용이 가능합니다.
- CGLIB 프록시는 더 유연하고 성능이 좋지만 메모리를 더 사용합니다.
현대적인 애플리케이션에서는 CGLIB을 기본으로 사용하되, 특별한 요구사항이 있을 때만 JDK Dynamic Proxy를 선택하는 것이 일반적이라고 합니다. (참고 Stack Over Flow)
특히 선택 시에는 다음과 같은 고려사항이 있다고 합니다.
- 인터페이스를 사용하거나 클래스의 final 메서드나 private 메서드를 프록시해야 하는지에 따라 선택.
- JDK Dynamic Proxy는 가벼우며, 인터페이스 기반의 프록시가 필요할 때 유리.
- CGLIB은 클래스 레벨의 프록시가 필요하거나 인터페이스가 없는 클래스에 대해서도 프록시를 생성할 때 유리.
8.. AOP 사용 시 주의사항
Self-Invocation 문제
같은 클래스 안에서 메소드를 호출할 때 AOP가 적용되지 않습니다.
@Service
public class UserService {
@Transactional
public void createUser(UserDto dto) {
// 트랜잭션이 적용됨
}
public void registerUser(UserDto dto) {
// 여기서 createUser를 호출하면 트랜잭션이 적용되지 않음
this.createUser(dto);
}
}
바로 이런 이유로 인해서 transactional 어노테이션이 붙어있더라도, 다른 함수에서 호출되는 경우 트랜잭션이 적용되지 않습니다.
순환 참조 문제
@Aspect
@Component
public class ServiceAspect {
private final SomeService someService;
// 순환 참조가 발생할 수 있음
public ServiceAspect(SomeService someService) {
this.someService = someService;
}
@Before("execution(* com.example.service.SomeService.*(..))")
public void before() {
// Aspect가 적용되는 서비스를 참조하는 경우 순환 참조 발생
}
}
AOP를 적용할 때 순환 참조 문제는 Aspect 클래스가 자신이 Advice를 적용할 대상 빈을 참조하는 경우 주로 발생합니다.
Spring Container가 빈을 생성할 때는 의존성 주입을 위해 다른 빈들을 먼저 생성해야 하는데, Aspect가 타겟 빈을 참조하고 있으면서 동시에 그 타겟 빈에 AOP를 적용하려고 할 때 문제가 발생합니다.
구체적으로 설명하면, Spring은 빈을 생성할 때 1) 인스턴스 생성 → 2) 의존성 주입 → 3) AOP 프록시 생성 순서로 진행하는데, Aspect 자체가 타겟 빈을 의존성으로 가지고 있으면 타겟 빈이 완전히 초기화되기 전에 Aspect에서 해당 빈을 참조하려고 시도하게 됩니다.
이때 타겟 빈은 아직 AOP 프록시가 적용되지 않은 상태이므로 Spring은 프록시를 생성하기 위해 다시 Aspect를 참조해야 하는 순환 구조가 만들어집니다.
순환 참조 문제를 해결하는 가장 확실한 방법은 Aspect와 타겟 빈 간의 직접적인 의존성을 제거하는 것입니다.
Aspect에서 다른 서비스를 호출해야 한다면 ApplicationContext를 지연 로딩(Lazy Loading) 방식으로 사용하거나, @Lazy 어노테이션을 활용한 지연 의존성 주입을 적용할 수 있습니다.
더 근본적으로는, 순수하게 횡단 관심사만 담당하고, 실제 비즈니스 로직은 별도의 서비스 계층에서 처리하도록 분리해야 합니다!
이렇게 aop 를 이해하는데 필요한 개념과, 사용 방법, 구현된 원리, 사용 시 주의점에 대해서 알아보았습니다.
transactional 어노테이션의 동작과정을 통해 발생할 수 있는 애플리케이션 오류를 미리 알게 되었고,
server 로깅 시 일관된 방식으로 로그를 남길 수 있는 효과적인 방법을 알게 되었습니다.
'개발 > TIL' 카테고리의 다른 글
WebFlux 란 (3) | 2025.07.27 |
---|---|
RestController 의 응답 객체가 직렬화되는 방법 (1) | 2025.07.21 |
[sql 튜닝] Chapter 5. 소트 튜닝 (2) | 2025.05.14 |
[sql 튜닝] Chapter 4.3 해시 조인 4.4 서브쿼리 조인 (0) | 2025.05.08 |
[sql 튜닝 ]chapter 4.1 NL 조인, 4.2 소트 머지 조인 (0) | 2025.05.07 |