[Spring] Aspect

측면 지향 프로그래밍(AOP)

핵심 기능에서 추가 기능을 분리하고 분리된 추가 기능을 Aspect라는 모듈 형태로 정의하여 코드를 설계하는 프로그래밍 기법.

Aspect 지향 프로그래밍은 도메인 간 문제를 모듈화하는 프로그래밍 기술입니다.

현재 흩어진 관심은 여러 클래스에 나타나는 반복적이고 공통적인 코드를 의미합니다.


AOP의 구조

-측면

Aspect는 흩어진 관심사를 모듈화한 것으로 주로 부가적인 기능을 의미한다.

-목표

Target은 Aspect를 적용하기 위한 핵심 기능을 수행하는 비즈니스 로직을 의미합니다.

-조언

Advice는 유용한 추가 기능을 포함하는 구현으로 연결 지점에서 수행할 작업을 의미합니다.

-직조

직조는 특정 지점에 면을 주입하는 과정을 의미합니다.

-연결점

조인포인트는 특정 작업이 수행되는 지점으로 보통 어드바이스를 적용할 수 있는 곳을 말한다.

-포인트 컷

Pointcut은 Jointpoint의 세부 사양을 정의하고 Advice가 실행되는 시점을 구체적으로 정의합니다.


스프링 AOP

Spring은 기본적으로 Spring AOP보다 AOP를 지원한다.

Spring AOP는 엔터프라이즈 애플리케이션의 중복 코드와 같은 일반적인 문제에 대한 솔루션을 지원하는 것을 목표로 합니다.
전체 AOP는 지원되지 않으며 AOP는 Bean에만 적용할 수 있습니다.

Spring AOP는 일반적으로 Spring 트랜잭션 처리에 사용됩니다.

트랜잭션이 적용될 때 일반적으로 데이터베이스 작업에 따라 커밋되거나 롤백되는데, 데이터베이스 작업을 제외한 트랜잭션 작업인 커밋과 롤백은 반복적이고 공통적인 코드가 된다.

이런 점에서 Spring은 데이터베이스 작업을 핵심 기능으로, 트랜잭션 작업을 부가 기능으로 간주하고 주석 기반 AOP 적용을 위한 하나의 측면으로 트랜잭션을 정의한다.

Spring에서는 @Transactional을 통해 트랜잭션을 적용할 수 있으며 프록시 패턴을 통해 @Transactional로 등록된 클래스나 메소드에 AOP를 적용하여 트랜잭션을 적용한다.


스프링 AOP의 원리


[Spring] Aspect 1
스프링 AOP의 구조

Spring AOP는 프록시 패턴을 통해 AOP를 구현합니다.

Spring AOP가 적용된 Bean에 대해 Spring은 실행시간에 내부적으로 해당 Bean에 대한 Proxy 객체를 자동으로 생성하고 Spring 컨테이너에 원래 Bean이 아닌 Bean으로 프록시 객체를 등록한다.

런타임에 AOP를 적용하는 것을 런타임 위빙(runtime weaving)이라고 합니다.

추가 기능에 해당하는 aspect는 프록시 객체에서 구현되며 프록시 객체를 통해 대상 객체를 호출한다.

런타임에 프록시 객체를 생성하는 JDK Dynamic Proxy 및 CGLib(Code Generator Library) 메서드가 있는데 Spring AOP는 기본적으로 CGLib 메서드를 통해 프록시 객체를 생성하여 런타임에 위빙을 구현한다.

동적 JDK 프록시

JDK Dynamic Proxy는 Java Reflection API Proxy 클래스를 통해 프록시 객체를 생성합니다.
프록시 객체는 대상 객체가 아닌 대상 객체를 추상화하는 인터페이스를 구현하여 생성됩니다.

이것의 단점은 JDK Dynamic Proxy를 통해 Spring AOP가 적용된 Bean 프록시 객체를 생성할 때 대상 객체가 인터페이스를 구현한 클래스여야 한다는 점이다.

public interface UserService {
}
@Service
class NaverUserService implements UserService {
}


[Spring] Aspect 2
UserService 계층 다이어그램

다음 코드에서는 NaverUserService에 대한 프록시 객체인 NaverUserServiceProxy를 JDK Dynamic Proxy를 통해 생성할 때 대상 객체가 아닌 대상 객체의 인터페이스를 상속받아 프록시 객체를 생성합니다.

@Controller
public class UserController {
    @Autowired
    private NaverUserService naverUserService;
}

이때 Spring 컨테이너에 빈으로 등록된 객체는 대상 객체가 아니라 인터페이스를 구현한 프록시 객체이다.

즉, 인터페이스인 UserService가 아닌 구현체인 NaverUserService로 의존성 주입을 시도하면 런타임 오류가 발생합니다.

JDK Dynamic Proxy는 이러한 점을 포함한 인터페이스 구현을 강제하는 단점이 있으며, 이러한 단점을 보완하기 위한 방법이 CGLib(Code Generator Library)를 통한 프록시 객체 생성 방식이다.

코드 생성기 라이브러리(CGLib)

CGLib(Code Generator Library)는 바이트코드를 조작하여 프록시 객체를 생성하는 라이브러리입니다.

CGLib는 JDK Dynamic Proxy와 달리 대상 객체를 상속받아 프록시 객체를 생성하므로 인터페이스를 구현하지 않아도 되는 장점이 있다.

Reflection이 아닌 상속을 통해 Proxy 패턴을 구현하기 때문에 JDK Dynamic Proxy보다 성능이 우수하다.

public interface UserService {
}
@Service
class NaverUserService implements UserService {
}


[Spring] Aspect 3
UserService 계층 다이어그램

다음 코드에서는 CGLib를 통해 NaverUserService에 대한 프록시 객체인 NaverUserServiceProxy를 생성할 때 대상 객체를 상속받아 프록시 객체를 생성합니다.

@Controller
public class UserController {
    @Autowired
    private NaverUserService naverUserService;
}

Spring 컨테이너에 bean으로 등록된 프록시 객체는 대상 객체를 직접 상속받아 생성되기 때문에, JDK 동적 프록시와 달리 구현체인 NaverUserService로 의존성 주입을 시도해도 문제가 없다.

CGLib는 상속을 통해 프록시 객체를 생성하므로 대상 객체의 클래스나 메서드에 final 키워드가 포함되어 있으면 오류가 발생합니다.

실행시간에 직접 반영을 통해 Spring 컨테이너에 등록된 Bean을 검사하여 Spring AOP가 프록시 기반의 런타임 위빙을 통해 작동함을 알 수 있다.

AOP가 적용되지 않은 UserService와 Spring AOP를 통해 AOP가 적용된 UserServiceAOP가 있다고 가정하자.

다음 코드는 Spring 컨테이너에 등록된 UserService와 UserServiceAOP를 리플렉션을 통해 가져와서 방출하는 코드이다.

@Test
void test() {
    UserService userService= applicationContext.getBean(UserService.class);
    UserServiceAOP userServiceAOP = applicationContext.getBean(UserServiceAOP.class);
    System.out.println("UserService: " + userService.class);
    System.out.println("UserServiceAOP: " + userServiceAOP.class);
}

UserService: class com.example.demo.user.UserService
UserServiceAOP: class com.example.demo.user.UserServiceAOP$$EnhancerBySpringCGLIB$$28edc8c

결과를 보면 AOP가 적용된 UserService와 달리 AOP가 적용된 UserServiceAOP는 Spring 컨테이너에서 UserServiceAOP와 다른 클래스로 등록된다.

이를 통해 Spring AOP가 런타임 시 CGLib 메서드에서 프록시 개체를 생성하고 프록시 개체를 대상 개체 대신 bean으로 등록하는지 확인할 수 있습니다.


스프링 AOP 사용

Spring AOP는 애노테이션 기반의 AOP를 지원하기 때문에 개발자는 로직에서 AOP를 쉽게 사용할 수 있다.

예를 들어 AOP를 통해 메서드의 실행 시간을 출력하는 코드를 작성해 봅시다.

AOP를 사용할 때 메소드가 수행하는 작업은 핵심 기능이고 실행 시간 측정은 부가 기능이며 Aspect로 모듈화할 수 있다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
}

먼저 메서드에 등록할 임의의 주석을 정의하여 실행 시간을 내보냅니다.

@Target은 애노테이션의 등록 대상을 메소드로 정의하고, @Retention은 런타임까지 애노테이션의 메모리 보유 범위를 정의한다.

Annotation의 메모리 유지 범위는 Annotation이 유지되는 기간인데 Spring AOP는 런타임에 weaving으로 적용되기 때문에 주석이 런타임 동안 지속되는지 확인해야 합니다.

@Component
@Aspect
public class LogAspect {
    @Around("@annotation(LogExecutionTime)")
    public Object logging(ProceedingJoinPoint joinPoint) {
        Long begin = System.currentTimeMillis();
        Object obj = joinPoint.proceed();
        Long end = System.currentTimeMillis();
        System.out.println("ExecutionTime: " + (end - begin));
        return obj;
    }
}

다음으로 @Component를 통해 aspect를 bean으로 정의합니다.

이때 @Around를 통해 @LogExecutionTime에 등록된 메서드를 대상으로 정의한다.

LogAspect 내부의 핵심 기능을 실행하는 코드는 joinPoint.proceed()입니다.

@LogExecutionTime
void test() {
    Thread.sleep(1000);
    System.out.println("Test");
}
Test
ExecutionTime: 1001

일반적으로 실행 시간은 Spring AOP를 통해 측정되며 출력을 볼 수 있습니다.