언어+프레임워크/Spring

[Spring/AOP] AOP의 거의 모든 것: 개념, 내부 구현 원리, 활용(트랜잭션, 로깅, 보안), 주의사항(self-invocation 등등)

깃짱 2025. 9. 16. 20:00
반응형
반응형

🌏 AOP (관점 지향 프로그래밍)

AOP는 핵심 비즈니스 로직에서 공통 관심사를 분리하기 위한 프로그래밍 패러다임입니다.

여러 모듈에서 반복되는 부가 기능(공통 관심사)을 따로 빼서 관리하고, 필요한 지점에 끼워 넣는 기법입니다.

예를 들자면,

  • 핵심 관심사: 주문 생성, 결제 처리
  • 공통 관심사: 로깅, 트랜잭션 관리, 보안 검사

✅ 필요성

  1. 중복 코드 제거: 로깅, 보안, 예외처리 같은 코드가 여러 클래스에 흩어져 있지 않음
  2. 관심사 분리(Separation of Concerns): 핵심 로직과 부가 로직을 분리해 가독성과 유지보수성을 높임
  3. 변경 용이성: 공통 기능을 한 곳에서만 수정하면 전체에 반영

✅ 핵심 용어 (Terms)

  • Aspect (관점)
    • 공통 관심사를 모듈화한 단위 (ex: 트랜잭션 관리, 로깅)
    • 예: 모든 팀이 출퇴근 기록을 해야 한다고 하면, 각 팀 코드(영업, 개발, 회계)에 중복해서 쓰는 것이 아니라 ‘출퇴근 관리 부서’를 따로 둬서 처리하는 것과 같습니당
  • Join Point (결합 지점)
    • AOP가 적용될 수 있는 모든 지점 (메서드 호출, 예외 발생 등)
    • 예: 직원이 회사에 출근할 때, 회의실 들어갈 때, 문서 결재할 때
  • Pointcut (선택 지점)
    • Join Point 중 실제로 Aspect를 적용할 지점을 표현 (조건식)
    • 예: execution(* com.example.service.*.*(..))
    • 예: 모든 행동 중에서 출근할 때만 출퇴근 체크기를 찍는다
  • Advice (실행 동작)
    • 실제로 끼워 넣을 부가기능 (메서드 실행 전/후/예외 시 동작 등)
    • 종류
      • @Before: 메서드 실행 전 (예: 출근 전 신분증 확인)
      • @AfterReturning: 메서드 성공 후 (예: 퇴근 후 정상 기록)
      • @AfterThrowing: 예외 발생 시 (예: 출근 중 사고 발생 시)
      • @After: 메서드 종료 시(성공/실패 무관) (예: 퇴근 시간 기록은 무조건 남김)
      • @Around: 메서드 실행 전후 전체 제어 (예: 출근부터 퇴근까지 전부 관리)
    • 예: 지문 인식
  • Weaving (위빙)
    • 실제 코드에 Aspect를 엮어서 넣는 과정
    • 시점: 컴파일 타임, 로드 타임, 런타임
    • 스프링은 주로 런타임 위빙 (프록시 기반), 프로그램이 돌면서 프록시를 씌우는 방식
  • Proxy (프록시 객체)
    • 스프링 AOP는 내부적으로 대상 객체를 감싸는 프록시를 만들어서 Aspect를 적용

✔️ Join Point vs Pointcut?

  • Join Point: AOP가 적용될 수 있는 모든 지점 (후보군)
  • Pointcut: Join Point 중 실제로 AOP를 적용하겠다고 선택한 지점 (실행군)

실행은 항상 Pointcut에서 일어나지만, 그 바탕에는 “전체 후보군”을 정의한 Join Point 개념이 필요함

✅ 특징

  • 프록시 기반 AOP: JDK 동적 프록시(인터페이스 있는 경우)나 CGLIB 프록시(인터페이스 없는 경우, 클래스 상속받은 가짜 자식을 만들어서 프록시 처리) 사용
  • 런타임 위빙: 실행 시점에 프록시 객체를 만들어 Aspect 적용
  • 메서드 실행 단위의 Join Point만 지원: Spring AOP는 메서드 호출에만 적용 가능 (AspectJ는 더 풍부한 Join Point 지원)

✅ Advice 실행 흐름

예를 들어 @Around 어드바이스가 붙은 경우, 대상 메서드의 실행 전, 후에 Advice가 실행됩니다. (API 성능 모니터링, 트랜잭션 관리 등에 활용됨)

@Around("execution(* com.example..*(..))")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
    // 1. 메서드 실행 전 로직
    System.out.println("Before: " + joinPoint.getSignature());

    // 2. 실제 대상 메서드 실행
    Object result = joinPoint.proceed();

    // 3. 메서드 실행 후 로직
    System.out.println("After: " + result);

    return result;
}

실행 흐름은 다음과 같습니다

Client → Proxy → [Before Advice] → Target Method → [After Advice] → Return

🌏 Spring AOP 내부 구현 원리

Spring AOP는 프록시 기반입니다. 즉, 원래 객체를 그대로 쓰지 않고 대리 객체(Proxy)를 앞에 세워서 부가기능을 끼워 넣습니다. 그리고 이 프록시 객체가 스프링 IoC 컨테이너 내부에서 관리됩니다.

✅ 프록시 객체 생성 과정

  1. BeanPostProcessor 등록
    • 스프링 컨테이너 초기화 단계에서 AnnotationAwareAspectJAutoProxyCreator 같은 특별한 BeanPostProcessor가 등록됩니다.
    • 이 후처리기가 AOP 어드바이스가 필요한 빈을 감지하고, 프록시를 만들어줍니다.
  2. 빈 생성 시점에 프록시로 대체
    • 스프링이 빈을 만들 때, 해당 빈이 AOP 대상(Pointcut 조건과 매칭)이라면, 원본 객체 대신 프록시 객체를 컨테이너에 등록합니다.
    • 이후 다른 빈에서 @Autowired로 주입받는 것은 원본이 아니라 프록시입니다.
  3. 메서드 호출 가로채기
    • 프록시 객체가 호출을 가로채서, Advice 실행 → 원본 메서드 실행 → Advice 후처리 순서로 실행됩니다.

✅ 프록시 객체 종류: JDK Dynamic Proxy vs CGLIB

우리가 생성한 빈이 인터페이스를 구현한 식이냐 아니냐에 따라서 생성되는 프록시 객체의 종류는 달라집니다.

스프링은 기본적으로 인터페이스 있으면 JDK 프록시, 없으면 CGLIB을 사용합니다.

  • JDK Dynamic Proxy
    • 인터페이스가 있으면 인터페이스 기반으로 프록시 생성
    • 원리: java.lang.reflect.Proxy + InvocationHandler
  • CGLIB Proxy
    • 인터페이스가 없으면 클래스 상속 기반으로 프록시 생성 (빈 클라스 앞에 private 같은거 붙이면 상속이 안되기 때문에 스프링부트에서 에러를 발생시키는 이유입니다)
    • 원리: net.sf.cglib.proxy.Enhancer + MethodInterceptor
    • 최종 클래스(final)나 final 메서드에는 적용 불가
    • (Spring Boot 2.x+에서는 항상 CGLIB 활성화 옵션을 켜두는 경우가 많음)

✅ 프록시 객체는 원래 메서드를 감싸버려~

예를 들어 로깅을 하려고 AOP를 사용했다면, 스프링은 이 “프록시 클래스”를 자동으로 만들어주고, 컨테이너에 주입해줍니다.

public class MemberServiceProxy implements MemberService {

    private final MemberService target;

    public MemberServiceProxy(MemberService target) {
        this.target = target;
    }

    @Override
    public void join() {
        System.out.println("로그 시작");
        target.join(); // 실제 메서드 실행
        System.out.println("로그 끝");
    }
}

🌏 스프링에서 활용되는 AOP

AOP는 “내가 직접 Aspect를 만든다”는 것보다는 “스프링이 제공하는 기능들이 다 AOP 위에 돌아간다”는 걸 이해하는 게 중요합니다.

✅ 1. 트랜잭션 관리 (@Transactional)

  • 데이터베이스 작업은 중간에 끊기면 안 되고, 전부 성공하거나 전부 실패해야 합니다.
    • 예를 들어, 계좌 이체를 한다면 “A 계좌에서 돈 빼기”와 “B 계좌에 돈 넣기”가 반드시 함께 성공해야 합니다.
  • @Transactional을 메서드에 붙이면, 스프링이 프록시를 만들어서 메서드 실행 전후에 트랜잭션을 시작하고, 끝내고, 예외 시 롤백합니다.

예를 들어 아래와 같은 빈을 작성했다고 합시다.

@Service
public class AccountService {
    @Transactional
    public void transferMoney(String from, String to, int amount) {
        withdraw(from, amount);   // A 계좌 출금
        deposit(to, amount);      // B 계좌 입금
    }
}

스프링은 위 클래스를 그대로 쓰지 않고, 프록시 클래스를 만들어서 컨테이너에 등록합니다. 프록시는 내부에 트랜잭션 처리 로직을 넣고, 원래 AccountService를 위임 호출합니다.

즉, 클라이언트는 accountService.transferMoney()를 호출하더라도 사실상 호출되는 건 AccountServiceProxy.transferMoney()가 됩니다.

// 프록시로 감싸진 버전 (실제 개발자가 보는 건 아님, 개념 설명용)
public class AccountServiceProxy extends AccountService {

    private final AccountService target; // 실제 원본 객체
    private final PlatformTransactionManager txManager;

    public AccountServiceProxy(AccountService target, PlatformTransactionManager txManager) {
        this.target = target;
        this.txManager = txManager;
    }

    @Override
    public void transferMoney(String from, String to, int amount) {
        // 트랜잭션 시작
        TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 실제 원본 객체의 메서드 실행
            target.transferMoney(from, to, amount);

            // 정상 → 커밋
            txManager.commit(status);
        } catch (RuntimeException e) {
            // 예외 → 롤백
            txManager.rollback(status);
            throw e;
        }
    }
}

따라서 개발자는 그냥 비즈니스 로직만 작성하면 되고, 트랜잭션 시작/커밋/롤백은 AOP가 알아서 처리합니다.

  1. 프록시가 먼저 트랜잭션 매니저에게 “트랜잭션 시작” 요청
  2. 실제 비즈니스 메서드 실행
  3. 정상 종료 → 트랜잭션 커밋
  4. 예외 발생 → 트랜잭션 롤백

✅ 2. 보안 처리 (@Secured, @PreAuthorize)

  • 특정 기능은 관리자만 실행할 수 있어야 하거나, 로그인한 사용자만 접근 가능해야 합니다.
    • 권한 체크를 서비스 로직마다 직접 넣으면 코드가 지저분해집니다.
  • @Secured("ROLE_ADMIN") 같은 어노테이션을 붙이면, 메서드 실행 전에 권한을 검사합니다.
  • 권한이 없으면 로직 자체가 실행되지 않고, 바로 예외 발생 → 접근 차단.
@Service
public class AdminService {
    @Secured("ROLE_ADMIN")
    public void deleteUser(Long userId) {
        // 관리자만 가능한 작업
    }
}

스프링은 AdminService를 빈으로 등록할 때, 바로 쓰지 않고 프록시 클래스를 만들어서 컨테이너에 등록합니다. (아래는 개념 설명용 의사코드임)

public class AdminServiceProxy extends AdminService {

    private final AdminService target; // 원본 AdminService
    private final SecurityInterceptor securityInterceptor;

    public AdminServiceProxy(AdminService target, SecurityInterceptor securityInterceptor) {
        this.target = target;
        this.securityInterceptor = securityInterceptor;
    }

    @Override
    public void deleteUser(Long userId) {
        // 1. 권한 확인 (ROLE_ADMIN이 있는지 검사)
        if (!securityInterceptor.hasRole("ROLE_ADMIN")) {
            throw new AccessDeniedException("권한 없음");
        }

        // 2. 권한 통과 → 실제 메서드 실행
        target.deleteUser(userId);
    }
}

✅ 3. 캐싱 (@Cacheable)

  • 조회 쿼리나 외부 API 호출처럼 자주 요청되지만 결과가 자주 바뀌지 않는 데이터가 있습니다.
    • 매번 DB나 API를 호출하면 느려지고 비용이 큽니다.
  • @Cacheable을 붙이면, 메서드 실행 전에 캐시에 값이 있는지 확인합니다.
    • 있으면 DB를 건드리지 않고 캐시 값을 반환.
    • 없으면 실제 메서드를 실행하고 결과를 캐시에 저장.
@Service
public class ProductService {
    @Cacheable("products")
    public Product getProduct(Long id) {
        return productRepository.findById(id).get();
    }
}

프록시로 감으면 이런 느낌이겠죠..?

public class ProductServiceProxy extends ProductService {

    private final ProductService target; // 실제 ProductService
    private final CacheManager cacheManager;

    public ProductServiceProxy(ProductService target, CacheManager cacheManager) {
        this.target = target;
        this.cacheManager = cacheManager;
    }

    @Override
    public Product getProduct(Long id) {
        Cache cache = cacheManager.getCache("products");

        // 1. 캐시에 값 있는지 확인
        Product cached = cache.get(id, Product.class);
        if (cached != null) {
            System.out.println("캐시에서 조회: " + id);
            return cached;
        }

        // 2. 없으면 실제 메서드 실행
        Product result = target.getProduct(id);

        // 3. 실행 결과 캐시에 저장
        cache.put(id, result);
        System.out.println("DB 조회 후 캐시 저장: " + id);

        return result;
    }
}

✅ 4. 로깅 / 모니터링 (실행 시간 측정)

  • 실무에서는 “이 API가 몇 초 걸리는지”, “에러가 어디서 나는지” 같은 로깅/모니터링이 중요합니다.
    • 하지만 모든 메서드마다 System.out.println()이나 logger.info()를 넣는 건 비효율적입니다.
  • @Around 어드바이스를 사용하면, 메서드 실행 전후 시간을 측정하고 로그를 남길 수 있습니다.
@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(); // 실제 메서드 실행

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

        return result;
    }
}

이렇게 구현을 하는데 만약에 @Around 어드바이스 안에서 proceed()를 안 부르면 원본 메서드가 실행되지 않기 때문에 주의해야 합니다(ㅋㅋ..)

🌏 Spring AOP 주의사항!!!

프록시 기반 한계 (Self-invocation 문제)

  • 스프링 AOP는 프록시 객체를 통해 동작합니다.
  • 그런데 자기 자신 안에서 자기 메서드를 호출하면 프록시를 거치지 않습니다.
@Service
public class OrderService {
    @Transactional
    public void outer() {
        inner(); // 같은 클래스 메서드 호출 → 프록시 안 거침 → 트랜잭션 적용 안 됨
    }

    @Transactional
    public void inner() {
        // 트랜잭션 적용 안 됨
    }
}

트랜잭션을 적용하고 싶다면 구조를 분리해서 별도 빈으로 나누거나, AopContext.currentProxy() 활용해야 하는데 이 경우는 복잡도가 증가하니 비추합니다.

별도 빈으로 나누는 편이 좋습니다.

final 클래스 / final 메서드

프록시는 상속(CGLIB) 이나 인터페이스(JDK Dynamic Proxy) 기반으로 만들어지는데, final 클래스나 final 메서드는 오버라이드가 불가능해 AOP 적용이 안 됩니다.

final class PaymentService { ... } // 프록시 못 씌움

private 메서드에는 적용 불가

스프링 AOP는 public 메서드에만 적용하는 것이 원칙이기 땜시 private 메서드는 프록시 호출 경로에서 가려지기 때문에 Advice를 걸 수 없습니다.

체크 예외 vs 언체크 예외 (@Transactional 롤백 규칙)

@Transactional 기본 동작의 경우에

  • RuntimeException(언체크) → 롤백
  • Checked Exception → 롤백 안 함

헷갈려서 잘못 설정하면 데이터 정합성 문제 발생 가능할 수 있기 때문에 필요시 rollbackFor 속성으로 명시해야 함.

프록시 객체와 원본 객체의 타입 혼동

프록시는 원본 객체를 감싸고 있지만 실제로는 다른 클래스입니다. 특히 JDK Dynamic Proxy는 인터페이스 타입으로만 캐스팅 가능합니다.

// JDK Dynamic Proxy 사용 시
OrderServiceImpl order = (OrderServiceImpl) context.getBean("orderService");
// ❌ ClassCastException 발생 (프록시는 인터페이스 기반이라 Impl로 캐스팅 불가)

 

 

 

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!

 

반응형