프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[JAVA/SPRING] Spring AOP 이해하기: @Aspect로 횡단 관심사 처리하기

매운할라피뇨 2026. 2. 10. 08:54
반응형

Spring AOP 이해하기

개요

비즈니스 로직 구현 중 반복되는 코드가 있나요? 로깅, 트랜잭션 관리, 보안 검증, 성능 측정처럼 여러 메서드에서 같은 코드를 반복해야 한다면 Spring AOP(Aspect-Oriented Programming)가 답입니다.

AOP를 사용하지 않으면 비즈니스 로직이 횡단 관심사로 오염되어 코드가 복잡해지고 유지보수가 어려워집니다. AOP를 사용하면 이런 공통 관심사를 분리하여 깔끔한 구조를 유지할 수 있습니다. 이 글에서는 @Aspect 어노테이션을 사용해 Spring AOP를 구현하고, 실전 예제(로깅, 성능 측정, 예외 처리)로 즉시 활용할 수 있는 방법을 알아봅니다.


Spring AOP란?

AOP(Aspect-Oriented Programming)횡단 관심사(Cross-Cutting Concerns)를 분리하여 코드 중복을 제거하는 프로그래밍 패러다임입니다.

횡단 관심사란?

횡단 관심사는 애플리케이션의 여러 곳에서 반복되는 공통 로직입니다. 비즈니스 로직과는 무관하지만, 모든 메서드에서 필요한 작업들입니다.

📌 비즈니스 로직 vs 횡단 관심사

구분 비즈니스 로직 횡단 관심사
정의 기능의 핵심 목표 달성 모든 기능에 공통으로 필요한 작업
예시 주문 생성, 상품 조회 로깅, 성능 측정, 보안 검증
발생 빈도 메서드마다 다름 모든 메서드에서 반복
변경 시기 비즈니스 요구사항 변경 시 기술적 요구사항 변경 시
책임 도메인 로직 처리 기술적 횡단 관심사 처리

🔍 횡단 관심사의 종류

1. 로깅 (Logging)
   └─ 메서드 실행, 파라미터, 반환값, 예외 기록

2. 성능 측정 (Performance Monitoring)
   └─ 메서드 실행 시간, 느린 쿼리 감지, 응답 시간 기록

3. 보안 검증 (Security)
   └─ 권한 확인, 데이터 접근 제어, 암호화

4. 트랜잭션 관리 (Transaction Management)
   └─ 데이터 일관성 보장, 커밋/롤백

5. 감사 로깅 (Audit Logging)
   └─ 누가, 언제, 무엇을 변경했는지 기록

6. 캐시 관리 (Caching)
   └─ 캐시 저장, 무효화, 갱신

7. 예외 처리 (Exception Handling)
   └─ 공통 예외 처리, 에러 로깅

8. 재시도 로직 (Retry Logic)
   └─ 실패 시 자동 재시도

왜 분리해야 할까?

분리하지 않으면: 모든 메서드가 비즈니스 로직 + 로깅 + 성능 측정 + 보안 검증 코드로 복잡해집니다.

분리하면:

  • ✅ 비즈니스 로직이 깔끔해짐
  • ✅ 횡단 관심사는 한 곳에서 관리
  • ✅ 코드 중복 제거
  • ✅ 유지보수와 테스트가 쉬워짐

관심사의 분리

변경전: 비즈니스 로직에 횡단 관심사가 섞여 있음

// 로깅, 성능 측정, 예외 처리가 비즈니스 로직과 섞여 있음
@Service
public class OrderService {
    public void createOrder(Order order) {
        long startTime = System.currentTimeMillis();
        try {
            // 로깅
            logger.info("주문 생성 시작: " + order.getId());

            // 실제 비즈니스 로직
            orderRepository.save(order);

            // 성능 측정
            long elapsed = System.currentTimeMillis() - startTime;
            logger.info("주문 생성 완료. 소요 시간: " + elapsed + "ms");
        } catch (Exception e) {
            // 예외 처리
            logger.error("주문 생성 실패", e);
            throw new BusinessException("주문을 생성할 수 없습니다.");
        }
    }
}

변경후: AOP로 관심사 분리

// 비즈니스 로직만 깔끔하게
@Service
public class OrderService {
    public void createOrder(Order order) {
        orderRepository.save(order);
    }
}

// AOP Aspect에서 로깅, 성능 측정, 예외 처리 담당
@Aspect
@Component
public class LoggingAspect {
    @Around("execution(* OrderService.*(..))")
    public Object logAndMeasure(ProceedingJoinPoint joinPoint) throws Throwable {
        // 로깅, 성능 측정 등 횡단 관심사 처리
    }
}

핵심 개념:

  • Aspect: 횡단 관심사를 구현한 클래스 (@Aspect)
  • Pointcut: AOP를 적용할 위치 (메서드, 클래스 등)
  • Advice: 특정 Pointcut에서 실행할 코드 (@Before, @After, @Around 등)
  • Join Point: Aspect가 적용될 수 있는 지점

@Aspect를 사용한 Spring AOP 구현

1단계: 의존성 추가

pom.xml에 Spring AOP 의존성을 추가합니다.

<!-- Spring Boot AOP 스타터 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2단계: Aspect 클래스 생성

Aspect 클래스에 횡단 관심사를 구현합니다.

// 로깅과 성능 측정을 담당하는 Aspect
@Aspect
@Component
public class PerformanceLoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger(PerformanceLoggingAspect.class);

    // Pointcut: OrderService의 모든 메서드
    @Pointcut("execution(* com.example.service.OrderService.*(..))")
    public void orderServiceMethods() {
    }

    // Advice: 메서드 실행 전후에 로깅과 성능 측정
    @Around("orderServiceMethods()")
    public Object measurePerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        long startTime = System.currentTimeMillis();

        logger.info("📝 메서드 시작: {}", methodName);

        try {
            // 실제 메서드 실행
            Object result = joinPoint.proceed();

            long elapsed = System.currentTimeMillis() - startTime;
            logger.info("✅ 메서드 완료: {} (소요 시간: {}ms)", methodName, elapsed);

            return result;
        } catch (Exception e) {
            long elapsed = System.currentTimeMillis() - startTime;
            logger.error("❌ 메서드 실패: {} (소요 시간: {}ms)", methodName, elapsed, e);
            throw e;
        }
    }
}

// 사용
@Service
public class OrderService {
    private OrderRepository orderRepository;

    public void createOrder(Order order) {
        orderRepository.save(order);
    }

    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElse(null);
    }
}

// 실행
orderService.createOrder(new Order(1L, "Product A"));
orderService.getOrder(1L);

Output:

📝 메서드 시작: createOrder
✅ 메서드 완료: createOrder (소요 시간: 12ms)
📝 메서드 시작: getOrder
✅ 메서드 완료: getOrder (소요 시간: 5ms)

실전 예제: 다양한 Advice 유형

1. @Before: 메서드 실행 전

@Aspect
@Component
public class AuthenticationAspect {

    // @RequestMapping이 있는 컨트롤러 메서드 실행 전 권한 검증
    @Before("execution(* com.example.controller..*(..))")
    public void validateAuthentication(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        String userRole = getCurrentUserRole();  // 현재 사용자 권한 조회

        if (!"ADMIN".equals(userRole)) {
            throw new UnauthorizedException("접근 권한이 없습니다.");
        }

        logger.info("✅ 권한 검증 완료: {}", methodName);
    }
}

2. @After: 메서드 실행 후

@Aspect
@Component
public class AuditAspect {

    // 모든 save 메서드 실행 후 감사 로그 기록
    @After("execution(* *.save(..))")
    public void logAudit(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        String userName = getCurrentUserName();
        LocalDateTime timestamp = LocalDateTime.now();

        logger.info("📋 감사 로그: {} 사용자가 {} 메서드를 실행했습니다. ({})",
            userName, methodName, timestamp);
    }
}

3. @AfterReturning: 메서드가 정상 반환된 후

@Aspect
@Component
public class CacheInvalidationAspect {

    @Autowired
    private CacheManager cacheManager;

    // save, update, delete 메서드 실행 후 캐시 무효화
    @AfterReturning("execution(* *.save(..)) || execution(* *.update(..)) || execution(* *.delete(..))")
    public void invalidateCache(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();

        // 캐시 전체 정리
        cacheManager.getCache("orders").clear();

        logger.info("🗑️ 캐시 무효화: {} 메서드 실행 후 캐시를 정리했습니다.", methodName);
    }
}

4. @AfterThrowing: 메서드에서 예외 발생 시

@Aspect
@Component
public class ExceptionHandlingAspect {

    // RuntimeException 발생 시 에러 로깅
    @AfterThrowing(pointcut = "execution(* com.example.service..*(..))", throwing = "ex")
    public void logException(JoinPoint joinPoint, RuntimeException ex) {
        String methodName = joinPoint.getSignature().getName();
        String className = joinPoint.getTarget().getClass().getSimpleName();

        logger.error("🔥 예외 발생: {}.{} - {}", className, methodName, ex.getMessage());

        // 예외 정보를 모니터링 시스템에 전송
        // sendToMonitoringSystem(className, methodName, ex);
    }
}

5. @Around: 메서드 실행 전후 모두 처리 (가장 강력함)

@Aspect
@Component
public class TransactionAndSecurityAspect {

    // 트랜잭션 관리 + 성능 모니터링 + 보안 검증
    @Around("execution(* com.example.service..*(..))")
    public Object handleTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();

        // 1️⃣ Before: 보안 검증
        if (!hasPermission()) {
            throw new SecurityException("접근 권한이 없습니다.");
        }

        // 2️⃣ Before: 트랜잭션 시작
        long startTime = System.currentTimeMillis();
        logger.info("🔄 트랜잭션 시작: {}", methodName);

        try {
            // 실제 메서드 실행
            Object result = joinPoint.proceed();

            // 3️⃣ After: 트랜잭션 커밋
            long elapsed = System.currentTimeMillis() - startTime;
            logger.info("✅ 트랜잭션 커밋: {} ({}ms)", methodName, elapsed);

            return result;
        } catch (Exception e) {
            // 4️⃣ AfterThrowing: 트랜잭션 롤백
            logger.error("⚠️ 트랜잭션 롤백: {} - {}", methodName, e.getMessage());
            throw e;
        }
    }
}

Pointcut 표현식 가이드

Pointcut은 정규식 같은 패턴으로 AOP를 적용할 위치를 지정합니다.

// 1. 특정 클래스의 모든 메서드
@Pointcut("execution(* com.example.service.UserService.*(..))")

// 2. 특정 패키지 아래 모든 클래스의 모든 메서드
@Pointcut("execution(* com.example.service..*(..))")

// 3. 특정 메서드명
@Pointcut("execution(* *..*.get*(..))")  // get으로 시작하는 모든 메서드

// 4. 특정 어노테이션이 있는 메서드
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")

// 5. 특정 파라미터 타입
@Pointcut("execution(* *..*(..)) && args(Long)")  // Long 파라미터를 받는 메서드

// 6. 조합 (OR)
@Pointcut("execution(* *.save(..)) || execution(* *.update(..))")

// 7. 조합 (AND, NOT)
@Pointcut("execution(* com.example.service..*(..)) && !@annotation(NoAop)")

성능 비교: AOP 적용 전후

// AOP 적용 전: 메서드마다 로깅/성능 측정 코드 중복
for (int i = 0; i < 1000; i++) {
    orderService.createOrder(order);
    orderService.getOrder(i);
}
// 결과: 1000개 메서드 × 10줄 = 10,000줄 중복 코드

// AOP 적용 후: Aspect 한 곳에서 관리
for (int i = 0; i < 1000; i++) {
    orderService.createOrder(order);  // Aspect가 자동으로 로깅, 성능 측정
    orderService.getOrder(i);
}
// 결과: 비즈니스 로직만 깔끔하게, Aspect에서 횡단 관심사 처리

효과:

  • 코드 라인 수: 10,000줄 → 100줄 (99% 감소!)
  • 유지보수성: 향상 (한 곳에서 관리)
  • 테스트 용이성: 향상 (로직과 관심사 분리)

맺음말

Spring AOP는 다음과 같은 상황에서 활용하세요:

  1. 로깅 - 모든 메서드 실행 추적
  2. 성능 측정 - 메서드 실행 시간 자동 기록
  3. 트랜잭션 관리 - @Transactional 어노테이션 처리
  4. 보안 검증 - 메서드 실행 전 권한 확인
  5. 캐시 관리 - 데이터 변경 시 캐시 무효화
  6. 예외 처리 - 공통 예외 처리 로직

@Aspect를 사용하면 이 모든 것을 깔끔하게 구현할 수 있습니다. 중요한 것은 적절한 Pointcut 설정입니다. 너무 광범위하게 설정하면 성능이 떨어질 수 있으므로, 필요한 메서드만 정확하게 지정하세요.

반응형