[UMC] AOP

Backend·2025-09-23·by 1000hyehyang

UMC 2주차 시니어 미션 마지막은 AOP 원리 탐구하기 부트캠프에서 스프링의 개념을 훑고 넘어갔을 때 'AOP'라는 단어를 듣기만 하고 집요하게 파고들진 않았다. (AOP에 대한 부정적 견해를 들은 기억이 나는 것 같기도 하다) 사실 웹 서비스를 만들기 위해 개념 자체를 아는 것보다는 실전 프로젝트 경험이 중요했기 때문에 개념을 정확히 숙지하지 못한 채 넘어간 것이 아쉬웠다. 이번 과제를 하며 몰랐던 개념인 'AOP'에 대해 탐구해보려 한다.

AOP와 OOP

OOP는 클래스를 단위로 책임을 나누는 방식이다. 그런데 실제 서비스 코드를 짜다 보면 클래스 경계를 가볍게 뛰어넘는 기능이 꼭 생긴다. 모든 메서드의 실행 시간을 측정한다든지, 예외가 나면 같은 포맷으로 에러 로그를 남긴다든지, 인증 체크를 공통으로 걸어둔다든지. 이런 걸 횡단 관심사(cross-cutting concerns)라고 부른다.

문제는, 이걸 OOP만으로 처리하면 모든 클래스에 같은 코드가 반복되기 쉽다. 유지보수의 지옥 문이 열린다. AOP(Aspect-Oriented Programming)는 핵심 로직은 그대로 두고, 이런 공통 기능을 별도의 모듈(Aspect) 로 빼서 “필요한 지점(Join Point)에 끼워 넣는” 아이디어다. 그래서 AOP는 OOP의 대체가 아니라 보완이다.

 

AOP의 핵심 개념

Join Point: “어디에 끼워 넣을 수 있는지.” 스프링 AOP는 오직 스프링 빈의 메서드 실행 시점만 Join Point로 본다.

Pointcut: Join Point 중에서 “어디에 적용할지” 고르는 조건.

// 서비스 계층 public 메서드 전부
@Around("execution(public * com.example..*Service.*(..))")

// 컨트롤러 패키지의 모든 public 메서드
@Around("execution(public * com.example.aopdemo..controller..*(..))")

// 특정 애노테이션이 붙은 메서드만
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")

execution(리턴타입 패키지.클래스.메서드(파라미터)) 구조

Advice: 실제로 끼워 넣는 코드. 실행 시점에 따라 @Before, @AfterReturning, @AfterThrowing, @After, @Around로 나뉜다.

Aspect: Pointcut + Advice 묶음. “로깅 Aspect”, “트랜잭션 Aspect” 이런 식으로 모듈화된다.

Weaving: Aspect를 실제 코드에 집어넣어 동작하게 만드는 과정.

 

Spring AOP의 동작 원리(Proxy Pattern)

스프링은 빈을 만들 때 프록시를 앞에 세워둔다. 우리가 메서드를 호출하면 사실은 프록시가 먼저 받아서 Advice를 실행한 다음 원본 메서드를 호출한다.

  • 인터페이스 있으면 → JDK 동적 프록시
  • 인터페이스 없으면 → CGLIB 프록시

한계도 있다. this.method()처럼 같은 객체 안에서 부르는 내부 호출은 프록시를 거치지 않아서 Advice가 적용되지 않는다. 또 final 메서드/클래스는 CGLIB이 프록시를 못 만들어서 적용 불가다.

self-invocation이 뭔지, 실제로 보자

[실패 케이스]

// OuterService.java
package com.example.selfinv;

import org.springframework.stereotype.Service;

@Service
public class OuterService {

    // 실패 케이스: 같은 빈(객체) 내부에서 직접 호출 → 프록시 안 거침
    public void outerInternal() {
        System.out.println("OuterService.outerInternal()");
        innerInternal();   // <<< self-invocation (프록시 우회)
    }

    // public 으로 해도 "같은 객체 내부 호출"이면 프록시를 못 탑니다.
    public void innerInternal() {
        sleep(120);
        System.out.println("OuterService.innerInternal() done");
    }

    // 해결 케이스에서 사용할: 분리된 빈에 위임
    private final InnerService innerService;
    public OuterService(InnerService innerService) {
        this.innerService = innerService;
    }

    public void outerDelegating() {
        System.out.println("OuterService.outerDelegating()");
        innerService.innerExternal();   // <<< 다른 빈 호출 → 프록시 경유 (AOP 적용)
    }

    private static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }
}

이미지

  • outerInternal() 은 AOP 로그가 찍히는데,
  • innerInternal() 은 AOP 로그가 없다! (같은 빈 내부 호출=프록시 우회 → Advice 미적용)

[해결 케이스]

// InnerService.java
package com.example.selfinv;

import org.springframework.stereotype.Service;

@Service
public class InnerService {

    // 분리된 빈의 public 메서드 → 컨테이너가 프록시를 만들 수 있음
    public void innerExternal() {
        sleep(150);
        System.out.println("InnerService.innerExternal() done");
    }

    private static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }
}

이미지

  • outerDelegating() 에도 AOP,
  • 분리된 빈 InnerService.innerExternal() 에도 AOP가 찍힌다! (프록시 경유 성공)

➡ 실패 케이스에는 innerInternal() 앞뒤로 AOP 라인이 없음 / 해결 케이스에는 innerExternal() 앞뒤로 AOP 라인이 있음


이론만 보아서는 정확히 무엇을 이야기 하고 싶은 건지 감이 오지 않는다. 예제를 만들어서 알아보자...!!

목표: 컨트롤러에서 서비스 메서드를 호출할 때마다 실행 시간을 자동으로 로그로 남긴다. 의도: 로깅을 각 서비스에 일일이 쓰지 않고, AOP로 한 방에 적용해보자!

이미지

HelloService

이미지

TimeLoggingAspect

➡ 여기서 execution(..)이 Pointcut, @Around 메서드가 Advice, TimeLoggingAspect 클래스가 Aspect다. Join Point는 HelloService의 메서드 실행 순간이라고 보면 된다.

이미지 ➡ 프록시(Proxy)가 원래 메서드 실행 전에 끼어들어서 Advice(@Around)가 실행되었다.

이미지

➡ HelloService.explode()는 무조건 IllegalStateException("Boom!")을 던지도록 작성했는데,

[ERR] String com.example.umc_week2_mission_3.HelloService.explode() args=[] ex=java.lang.IllegalStateException: Boom!

요런 로그가 찍힌 걸 볼 수 있다. 즉, Advice(@Around)가 예외를 가로채서 로깅까지 한 뒤, 다시 예외를 던졌다!

➡ 로그에는 잘 찍혔지만, 결국 브라우저에서는 500 에러(Internal Server Error)를 받았다. 이유는 Advice에서 catch (Exception ex)로 잡은 후 throw ex;로 다시 던졌기 때문이다. 즉, AOP는 "예외를 완전히 삼켜서 없애는 도구"가 아니라, 횡단 관심사(로깅, 트랜잭션, 권한체크 등)를 추가하면서도 원래 메서드의 실행 흐름(성공/실패)을 그대로 유지한다는 점을 보여주는 것!

➡ 에러 로그 보면 HelloService$$SpringCGLIB$$0.explode() 같은 이상한 클래스가 찍혔는데, 이게 바로 스프링이 CGLIB 프록시를 씌웠다는 증거다. HelloService가 인터페이스 없이 클래스만 있으니까 스프링은 JDK 동적 프록시가 아니라 CGLIB 프록시를 쓴 것.

 

런타임 위빙 vs 컴파일 타임 위빙

Spring AOP는 기본적으로 런타임 위빙이다. 빈 생성 시점에 프록시를 씌워버리는 방식. 배우기 쉽고 설정도 단순하다. 대신 self-invocation 문제가 따라온다.

반대로 AspectJ는 컴파일 타임 위빙이나 로드 타임 위빙을 지원한다. 바이트코드에 직접 코드를 주입하니까 훨씬 강력하다. 필드 접근, 내부 호출도 가로챌 수 있다. 하지만 설정이 무겁고 진입 장벽이 높다.

 

어노테이션이 실제로 동작하는 과정

“@Aspect만 붙였는데 왜 로깅이 다 찍히지?” 궁금할 수 있다. 사실은 스프링 컨테이너가 뒷단에서 일을 다 해주고 있다.

  1. Spring AOP 스타터가 들어오면 AOP 자동 설정이 켜진다.
  2. 스프링은 AnnotationAwareAspectJAutoProxyCreator라는 빈 후처리기를 등록한다.
  3. 이 후처리기가 컨테이너 안의 모든 빈을 살펴보고, @Aspect가 붙은 클래스를 찾아 Advisor(Advice + Pointcut)로 등록한다.
  4. 해당 Advisor에 걸리는 빈은 프록시 객체로 교체된다.
댓글 로딩 중...