[Spring] AOP - 관점 지향 프로그래밍과 프록시

2021. 5. 8. 22:20·Web Backend/Spring
반응형

스프링과 JPA를 개념 순서대로 정리하는 시리즈입니다. 1부 스프링 컨테이너 (4/5)

스프링 AOP와 프록시

이번에는 AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)의 개념과 스프링이 이를 구현하는 방식인 프록시에 대해 알아보겠습니다.

앞선 글들에서 빈 목록을 출력할 때마다 $$SpringCGLIB$$나 $Proxy가 붙은 클래스가 보였습니다.

컨테이너가 빈을 그대로 보관하지 않고 무언가로 감싼다는 것까지는 확인했습니다.

이번에는 그 감싸는 일이 무엇을 위한 것인지, 그리고 어떤 방식으로 이루어지는지 정리하겠습니다.

예제 코드는 Java 21, Spring Boot 3.5, Hibernate 6 환경에서 작성했습니다.


1. 횡단 관심사란?

운동 세션을 저장하는 메서드에 실행 시간을 기록하고 싶다고 해보겠습니다.

@Service
public class WorkoutService {

    public Long record(Member member, LocalDate date, String memo) {
        long start = System.nanoTime();

        WorkoutSession session = new WorkoutSession(member, date, memo);
        Long id = repository.save(session).getId();

        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("record() 실행 " + ms + "ms");
        return id;
    }
}

동작은 합니다.

하지만 이 메서드의 본래 역할은 운동 세션을 저장하는 것입니다.

시간을 재는 코드는 그 역할과 아무 관계가 없습니다.

게다가 다른 메서드에도 같은 것을 넣으려면 똑같은 코드를 계속 복사해야 합니다.

이렇게 여러 곳에 흩어져 반복되지만 핵심 기능은 아닌 관심사를 횡단 관심사(Cross-cutting Concern)라고 부릅니다.

로깅, 실행 시간 측정, 권한 검사, 트랜잭션 처리가 대표적입니다.

AOP는 이런 관심사를 한 곳에 모아 두고, 필요한 지점에 자동으로 끼워 넣는 방법입니다.


2. AOP 용어

용어가 많지만 하나씩 보면 어렵지 않습니다.

용어 뜻
Aspect횡단 관심사를 모아 놓은 모듈. 무엇을 어디에 적용할지가 함께 들어 있다
Advice실제로 끼워 넣을 동작. 시간을 재는 코드 자체
JoinPoint끼워 넣을 수 있는 지점. 스프링 AOP에서는 메서드 실행 시점만 해당된다
Pointcut그 지점들 중 실제로 적용할 대상을 고르는 조건
Target적용 대상이 되는 원래 객체
WeavingAdvice를 Target에 실제로 엮어 넣는 과정

정리하면 Pointcut으로 고른 JoinPoint에 Advice를 끼워 넣는 것이고, 그 둘을 담은 모듈이 Aspect입니다.


3. 실행 시간 측정 Aspect 만들기

먼저 의존성을 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-aop'

그리고 다음과 같이 Aspect를 작성합니다.

@Aspect
@Component
public class ExecutionTimeAspect {

    @Around("execution(* com.example.workoutlog.service..*(..))")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.printf("[AOP] %s.%s() 실행 %dms%n",
                    joinPoint.getTarget().getClass().getSimpleName(),
                    joinPoint.getSignature().getName(), ms);
        }
    }
}

@Aspect는 이 클래스가 Aspect임을 알리고, @Component는 빈으로 등록합니다.

두 개가 모두 필요합니다.

@Aspect만 붙이면 빈으로 등록되지 않아 동작하지 않습니다.

포인트컷 표현식 읽기

execution(* com.example.workoutlog.service..*(..))

앞에서부터 끊어 읽으면 다음과 같습니다.

부분 뜻
execution메서드 실행 시점을 지정한다
*반환 타입은 무엇이든
com.example.workoutlog.service..이 패키지와 그 하위 패키지
*메서드 이름은 무엇이든
(..)파라미터는 몇 개든

joinPoint.proceed()가 원래 메서드를 호출하는 부분입니다.

이 앞뒤에 원하는 코드를 넣으면 됩니다.

proceed()를 호출하지 않으면 원래 메서드는 아예 실행되지 않습니다.

실행 결과

WorkoutService의 메서드를 호출해 보면 다음과 같이 출력됩니다.

[AOP] WorkoutService.record() 실행 3ms

WorkoutService의 코드는 한 줄도 건드리지 않았습니다.


4. 빈이 프록시로 바뀌는 순간

여기서 앞선 글의 출력과 비교해 보면 흥미로운 점이 보입니다.

Aspect를 만들기 전에 빈 목록을 출력했을 때는 다음과 같았습니다.

workoutService               -> com.example.workoutlog.service.WorkoutService

평범한 클래스였습니다.

그런데 Aspect를 하나 추가하고 다시 출력하면 다음과 같이 바뀝니다.

workoutService               -> com.example.workoutlog.service.WorkoutService$$SpringCGLIB$$0

같은 빈인데 클래스가 달라졌습니다.

스프링은 Aspect의 적용 대상이 되는 빈을 찾아, 원래 객체를 감싼 프록시 객체를 대신 등록합니다.

우리가 WorkoutService를 주입받아 호출하면 실제로는 이 프록시가 호출됩니다.

프록시는 Advice를 먼저 실행하고, 그 다음에 원래 객체의 메서드를 호출합니다.

호출하는 쪽 → 프록시 → (Advice 실행) → 원래 객체의 메서드

Aspect를 만들지 않았다면 프록시도 만들어지지 않습니다.

즉 필요할 때만 감쌉니다.


5. 두 가지 프록시

스프링이 프록시를 만드는 방법은 두 가지입니다.

JDK 동적 프록시는 자바 표준 기능이며, 인터페이스를 구현한 프록시를 만듭니다.

CGLIB은 대상 클래스를 상속받은 프록시를 만듭니다.

어느 쪽이 선택되는지 직접 확인해 보겠습니다.

인터페이스가 없는 WorkoutService와, 인터페이스를 구현한 EmailSender를 함께 출력해 보았습니다.

workoutService 클래스 : com.example.workoutlog.service.WorkoutService$$SpringCGLIB$$0
emailSender  클래스   : com.example.workoutlog.service.EmailSender$$SpringCGLIB$$0

인터페이스가 있는데도 CGLIB이 선택되었습니다.

스프링 부트가 spring.aop.proxy-target-class의 기본값을 true로 두고 있기 때문입니다.

이 값을 false로 바꾸면 결과가 달라집니다.

workoutService 클래스 : com.example.workoutlog.service.WorkoutService$$SpringCGLIB$$0
emailSender  클래스   : jdk.proxy2.$Proxy120

정리하면 다음과 같습니다.

대상 기본 설정 proxy-target-class: false
인터페이스 없는 클래스CGLIBCGLIB (다른 방법이 없다)
인터페이스 구현 클래스CGLIBJDK 동적 프록시

스프링 부트가 CGLIB을 기본으로 삼은 이유는 JDK 동적 프록시의 제약 때문입니다.

JDK 동적 프록시는 인터페이스에 선언된 메서드만 프록시할 수 있습니다.

구현 클래스에만 있는 메서드는 프록시를 통해 호출할 수 없고, 그 타입으로 주입받으려 하면 실패합니다.

CGLIB은 상속을 사용하므로 이런 제약이 없습니다.

다만 상속을 쓰기 때문에 `final` 클래스나 `final` 메서드에는 적용할 수 없습니다.


6. Advice의 종류

@Around 외에도 여러 종류가 있습니다.

애노테이션 실행 시점
@Before대상 메서드 실행 전
@AfterReturning대상 메서드가 정상적으로 끝난 후
@AfterThrowing대상 메서드가 예외를 던진 후
@After정상 종료와 예외 발생 모두에서 실행
@Around전후를 모두 감싼다. 반환값 변경과 실행 차단까지 가능

@Around가 가장 강력하지만 그만큼 실수할 여지도 큽니다.

proceed() 호출을 빠뜨리면 원래 메서드가 실행되지 않고, 그 사실이 조용히 지나갑니다.

단순히 앞이나 뒤에 무언가를 덧붙이는 것이 목적이라면 @Before나 @AfterReturning을 쓰는 편이 안전합니다.


7. 프록시 방식의 한계

스프링 AOP는 프록시를 통해 동작합니다.

바꿔 말하면 프록시를 거치지 않는 호출에는 적용되지 않습니다.

다음과 같은 코드를 보겠습니다.

@Service
public class WorkoutService {

    public void recordAll(List<Session> sessions) {
        for (Session s : sessions) {
            record(s);        // 같은 객체 안에서 직접 호출
        }
    }

    public void record(Session s) { }
}

recordAll()은 프록시를 거쳐 호출되므로 Advice가 적용됩니다.

하지만 그 안에서 호출하는 record()는 프록시를 거치지 않습니다.

이미 원래 객체 안으로 들어와 있기 때문에 this.record()가 되기 때문입니다.

따라서 record()에는 Advice가 적용되지 않습니다.

이것을 자기 호출(self-invocation) 문제라고 부릅니다.

실행 시간 측정처럼 빠져도 큰 문제가 없는 경우라면 넘어갈 수 있습니다.

하지만 트랜잭션처럼 반드시 적용되어야 하는 기능이라면 심각한 버그가 됩니다.

다음 글에서 이 문제를 자세히 다루겠습니다.


마무리

이번에는 AOP의 개념과 스프링이 이를 구현하는 방식에 대해 알아봤습니다.

여러 곳에 흩어져 반복되지만 핵심 기능은 아닌 관심사를 횡단 관심사라고 하며, AOP는 이를 한 곳에 모아 필요한 지점에 자동으로 끼워 넣습니다.

스프링은 이를 프록시로 구현합니다.

Aspect를 하나 추가하자 WorkoutService 빈이 원래 클래스에서 $$SpringCGLIB$$가 붙은 프록시로 바뀌는 것을 직접 확인했습니다.

앞선 글들에서 계속 보였던 프록시의 정체가 이것이었습니다.

또한 스프링 부트는 인터페이스가 있어도 기본적으로 CGLIB을 사용하며, 상속 기반이므로 final 클래스에는 적용할 수 없다는 점도 함께 살펴봤습니다.

다음 글에서는 @Transactional을 다루겠습니다.

트랜잭션 역시 AOP로 동작하기 때문에, 이번에 살펴본 자기 호출 문제가 그대로 나타납니다.

분명히 @Transactional을 붙였는데 트랜잭션이 걸리지 않는 상황을 직접 재현해 보겠습니다.

반응형
'Web Backend/Spring' 카테고리의 다른 글
  • [Spring] @Transactional이 동작하지 않는 이유 - 프록시 자기 호출
  • [Spring] 의존성 주입 - 생성자 주입을 권장하는 이유
  • [Spring] 빈 등록과 생명주기 - @Component, @Bean, @Configuration
  • [Spring] IoC와 DI - 제어의 역전이란 무엇인가
seunghwaan
seunghwaan
공부한 내용을 정리하는 개발 기록 블로그
    반응형
  • seunghwaan
    SH's Devlog
    seunghwaan
  • 전체
    오늘
    어제
    • 분류 전체보기 (164)
      • Android (62)
        • Basic (17)
        • Kotlin(Java) (14)
        • UI & Animation (1)
        • Compose (2)
        • Coroutines (1)
        • Dependency Injection (6)
        • RxJava (8)
        • BLE (3)
        • TDD (2)
        • JetPack (1)
        • NextStep (4)
        • Error Log (3)
      • Flutter (14)
        • Basic (5)
        • Dart (1)
        • State Management (2)
        • Widgets (4)
        • Error and Tips (2)
      • iOS (8)
        • Basic (0)
        • Swift (8)
      • Web Frontend (16)
        • Basic (0)
        • JavaScript (5)
        • TypeScript (0)
        • React (11)
      • Web Backend (5)
        • Spring (5)
      • CS(Computer Science) (18)
        • Network (4)
        • Database (10)
        • Design Pattern (1)
        • Computer Architecture (3)
        • Operating System (0)
      • Cloud (6)
        • AWS (6)
      • DevOps (25)
        • GIT (4)
        • CI CD (8)
        • Linux (4)
        • Docker (9)
        • Error Log (0)
      • 코딩테스트 (10)
        • DB (6)
        • 알고리즘 (4)
      • Mac Tip (0)
      • Language (0)
        • English (0)
        • Japanese (0)
      • Temporary (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    IOS
    cs
    Jenkins
    docker
    JavaScript
    error
    Swift
    Computer Science
    Dagger
    Algorithm
    spring
    AWS
    Kotlin
    컴퓨터공학
    Linux
    di
    database
    CICD
    Android
    FLUTTER
    BLE
    Network
    Dependency Injection
    MySQL
    시작하세요! 도커
    gradle
    Web Frontend
    React
    RxJava
    Web Backend
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
seunghwaan
[Spring] AOP - 관점 지향 프로그래밍과 프록시
상단으로

티스토리툴바