AOP 란


AOP 가 필요한 상황

예를 들어, 모든 메서드의 호출 시간을 측정하고 싶다고 가정해보자. 그렇다면 위 처럼 모든 메서드에 시간 측정 로직을 추가하는 작업을 수행해야 할 것이다.

public class MemberService {
 
    public Long join(Member member) {
        long start = System.currentTimeMillis();
        try {
            validateDuplicateMember(member); //중복 회원 검증
            memberRepository.save(member);
            return member.getId();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("join " + timeMs + "ms");
        }
    }
 
    public List<Member> findMembers() {
        long start = System.currentTimeMillis();
        try {
            return memberRepository.findAll();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("findMembers " + timeMs + "ms");
        }
    }
}

요구사항을 충족하기 위해선 위처럼 코드가 변경되어야 할 것이다. 이렇게 코드가 변경된다면 어떤 문제점들이 있을까?

  • 회원가입, 회원 조회에 시간을 측정하는 기능은 핵심 관심 사항이 아니다.
  • 시간을 측정하는 로직은 공통 관심 사항이다.
  • 시간을 측정하는 로직과 비즈니스 로직이 섞여 유지보수가 어렵다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들기 매우 어렵다.
  • 시간을 측정하는 로직을 변경할 때 모든 로직을 찾아가 변경해야 한다. 이를 해결하기 위해 나온 것이 AOP 이다.

AOP 적용

AOP 란 Aspect Oriented Programming 즉, 관점 지향 프로그래밍이다. AOP 는 공통 관심 사항(cross-cutting concern) 과 핵심 관심 사항(core concern) 을 분리하여 원하는 곳에만 공통 관심 사항을 적용하는 방법이다.

AOP 빈 등록

package hello.hellospring;
 
import hello.hellospring.aop.TimeTraceAop;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class SpringConfig {
 
    private final MemberRepository memberRepository;
 
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
 
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository);
    }
 
    @Bean
    public TimeTraceAop timeTraceAop() {
        return new TimeTraceAop();
    }
}

시간 측정 AOP 등록

package hello.hellospring.aop;
 
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
 
@Aspect
public class TimeTraceAop {
 
//    @Around("execution(* hello.hellospring..*(..))")
    @Around("execution(* hello.hellospring..*(..)) && !target(hello.hellospring.SpringConfig)")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println("START: " + joinPoint.toString());
        try {
            return joinPoint.proceed();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("END: " + joinPoint.toString() + " " + timeMs + "ms");
        }
    }
}

AOP 등록 시, @Component 애너테이션을 통해 Bean 등록을 수행해도 되지만, 정형적인 Component 가 아니기 때문에, 대부분 SpringConfig 에서 Bean 등록을 따로 해주어야 명시적으로 AOP 가 적용되고 있다는 것을 알 수 있다. @Around("execution(* hello.hellospring..*(..))") 코드를 통해 주어진 경로에 해당하는 모든 메서드 실행 시 해당 메서드를 실행하라는 의미가 되는데, 이렇게 되면 SpringConfig 의 timeTraceAop() 메서드도 AOP 로 처리하게 된다. 이럴 경우, 자기 자신을 생성하는 메서드에서도 AOP 가 적용되기 때문에 순환참조가 발생할 수 있다. 때문에, 바로 아래 있는 @Around 코드를 통해 SpringConfig 를 AOP 대상에서 제외해주면 된다.

6.1 트랜잭션 코드의 분리


6.1.1 메소드 분리

  • 두 가지 종류의 코드가 구분되어 있고, 코드 간에 서로 주고받는 정보가 없음
  • 따라서 이 두 가지 코드는 성격이 다를 뿐 아니라, 완벽하게 독립적인 코드

6.1.2 DI를 이용한 클래스의 분리

  • 트랜잭션 코드를 UserService 밖으로 빼면 트랜잭션 기능이 빠진 UserService를 사용하게 될 것
  • 인터페이스로 만들고 기존 코드를 구현 클래스에 넣어두자
  • UserServiceTxUserServiceImpl 의 모든 기능을 위임
  • 추가로 트랜잭션 기능을 추가함
  • XML 설정을 통해 분리된 트랜잭션 기능을 위와 같이 적용

6.2 고립된 단위 테스트


6.2.1 복잡한 의존관계 속의 테스트

  • 현제 UserDao, TransactionManager, MailSender 라는 3가지 의존관계를 가지고 있음
  • 따라서 UserService 를 테스트하는 것처럼 보이지만 사실은 그 뒤에 존재하는 훨씬 더 많은 오브젝트와 환경, 서비스, 서버, 심지어 네트워크까지 함께 테스트하는 셈이 됨

6.2.2 테스트 대상 오브젝트 고립시키기

  • 테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 테스트를 위한 대역을 사용하는 것
  • 두 개의 목 오브젝트만 의존하는, 완벽하게 고립된 테스트 대상으로 만들 수 있음
  • UserDao 를 위와 같이 만들어서 테스트에 사용
  • 벌써 불편함…
  • 고립된 테스트를 만들려면 목 오브젝트 작성과 같은 약간의 수고가 더 필요하지만 테스트 성능을 확실하게 향상된다.

6.2.3 단위 테스트와 통합 테스트

단위 테스트와 통합 테스트 중에 어떤 방법을 쓸 것인가?

  • 항상 단위 테스트를 먼저 고려
  • 단위 테스트는 테스트 작성도 간단하고 실행 속도도 빠르며 테스트 대상 외의 환경으로부터 테스트 결과에 영향을 받지도 않기 때문에 효과적인 테스트 작성에 유리함
  • 외부 리소스를 사용해야만 가능한 테스트는 통합 테스트
  • 단위 테스트로 만들기 어려운 코드. 대표적으로 DAO
  • DAO 테스트는 DB라는 외부 리소스를 사용하기 때문에 통합 테스트로 분류됨
  • 여러 개의 단위가 의존관계를 가지고 동작한다면 통합 테스트 필요
  • 단위 테스트를 만들기 너무 복잡하다고 판단되면 통합 테스트 고려

6.2.4 목 프레임워크

  • 단위 테스트를 만들 때 스텁이나 목 오브젝트 사용은 필수적
  • 특히 목 오브젝트를 만드는 일이 가장 큰 짐
  • Mockito 프레임워크를 사용해 목 클래스를 일일이 준비하는 수고를 덜어보자.

6.3 다이내믹 프록시와 팩토리 빈


6.3.1 프록시와 프록시 패턴, 데코레이터 패턴

  • 단순 확장성을 고려해 전략 패턴을 사용해도 트랜잭션과 같은 부가적인 기능을 위임을 통해 외부로 분리했을 뿐 핵심 코드와 함께 남아 있음
  • 트랜잭션은 비즈니스 로직과는 성격이 다르기 때문에 적용 사실 자체를 밖으로 분리할 수 있음
  • UserServiceTxUserServiceImpl 을 의존하도록 변경
  • 즉, 부가기능이 핵심기능을 사용하는 구조
  • 하지만, 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가기능이 적용될 기회가 없음
  • 때문에 부가기능을 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐 핵심기능을 사용하도록 만들어야 함
  • 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서 프록시라고 부름
  • 프록시의 특징
    • 타깃과 같은 인터페이스를 구현함
    • 프록시가 타깃을 제어할 수 있음
  • 프록시의 사용 목적
    • 클라이언트가 타깃에 접근하는 방법을 제어하기 위함
    • 타깃에 부가적인 기능을 부여해주기 위함
    • 두 가지 모두 대리 오브젝트라는 개념의 프록시를 사용한다는 점은 동일하지만, 목적에 따라 디자인 패턴에서는 다른 패턴으로 구분됨

데코레이터 패턴

  • 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위함
  • 다이내믹하게 기능을 부여한다는 의미는 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않음
  • 프록시로서 동작하는 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 모름

프록시 패턴

  • 타깃에 대한 접근 방법을 제어하려는 목적
  • CollectionsunmodifiableCollection()을 통해 만들어지는 오브젝트가 전형적인 접근관한 제어용 프록시라고 볼 수 있음

6.3.2 다이내믹 프록시

  • 프록시는 기존 코드에 영향을 주지 않으면서 타깃의 기능을 확장하거나 접근 방법을 제어할 수 있는 유용한 방법
  • 하지만 프록시를 만드는 일이 상당히 귀찮음
  • Reflection 을 통해 프록시를 만들 수 있음
  • 프록시의 역할은 위임과 부가작업
  • 프록시를 만들기 번거로운 이유는 무엇일까?
    • 부가기능이 필요 없는 메소드도 구현해서 타깃으로 위임하는 코드를 일일이 만들어줘야 함
    • 부가기능 코드가 중복될 가능성이 많음
  • 이때 유용한 것이 바로 다이내믹 프록시
    • 다이내믹 프록시는 Reflection 을 이용해 프록시를 만듬

리플렉션

  • 자바의 모든 클래스는 그 클래스 자체의 구성정보를 담은 Class 타입의 오브젝트를 하나씩 가지고 있음
  • getClass() 를 통해 클래스 오브젝트를 가져오고, 이 클래스 오브젝트를 통해 클래스 코드에 대한 메타정보를 가져오거나 오브젝트를 조작할 수 있음

다이내믹 프록시 적용

  • 다이내믹 프록시는 프록시 팩토리에 의해 런타임 시 다이내믹하게 만들어지는 오브젝트
  • 다이내믹 프록시 오브젝트는 타깃의 인터페이스와 같은 타입으로 만들어짐
  • 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 클래스의 오브젝트를 자동으로 만들어줌
  • 다이내믹 프록시가 인터페이스 구현 클래스의 오브젝트는 만들어주지만, 프록시로서 필요한 부가기능 제공 코드는 직접 작성해야 함
  • 부가기능은 프록시 오브젝트와 독립적으로 InvocationHandler 를 구현한 오브젝트에 담음
  • 다이내믹 프록시 오브젝트는 클라이언트의 모든 요청을 리플렉션 정보로 변환해서 InvocationHandler 구현 오브젝트의 invoke() 메서드로 넘김
  • 타깃 인터페이스의 모든 메서드 요청이 하나의 메서드로 집중되기 때문에 중복되는 기능을 효과적으로 제공할 수 있음
  • 다이내믹 프록시로부터 요청을 전달받으려면 InvocationHandler 를 구현해야 함
  • 다이내믹 프록시의 생성은 Proxy.newProxyInstance() 를 사용하면 됨
  • Hello 인터페이스의 메서드가 많아져도 어차피 invoke() 메서드로 처리되기 때문에 확장에 유연
  • 하지만 스트링 외의 리턴 타입을 갖는 메서드가 추가된다면?
    • Method 를 이용한 타깃 오브젝트의 메서드 호출 후 리턴 타입을 확인해서 적용하자.
  • 또한, Method 인터페이스를 이용해 타깃의 메서드를 호출하기 때문에 Hello 타입의 타깃으로 제한할 필요도 없음

6.3.3 다이내믹 프록시를 이용한 트랜잭션 부가기능

  • UserServiceTx 를 다이내믹 프록시 방식으로 변경
  • 주의할 점은 RuntimeException 대신 InvocationTargetException 으로 잡아서 처리

6.3.4 다이내믹 프록시를 위한 팩토리 빈

  • 다이내믹 프록시 오브젝트는 일반적인 스프링의 빈으로 등록할 수 없음
  • 스프링 빈은 기본적으로 클래스 이름과 프로퍼티로 정의됨
  • 다이내믹 프록시 오브젝트는 클래스 자체가 내부적으로 다이내믹하게 새롭게 정의되서 사용되기 때문에 사전에 프록시 오브젝트의 클래스 정보를 미리 알아내서 스프링의 빈에 정의할 방법이 없음
  • 다이내믹 프록시는 Proxy.newProxyInstance() 라는 정적 팩토리 메서드를 통해서만 만들 수 있음

팩토리 빈

  • 스프링을 대신해서 오브젝트 생성 로직을 담당하도록 만들어진 특별한 빈
  • FactoryBean 을 구현한 클래스를 스프링 빈으로 등록하면 팩토리 빈으로 동작함
  • 스프링은 FactoryBean 을 구현한 클래스가 빈의 클래스로 지정되면, 팩토리 빈 클래스의 오브젝트를 getObject() 를 통해 오브젝트를 가져오고, 이를 빈 오브젝트로 사용

다이내믹 프록시를 만들어주는 팩토리 빈

  • Proxy.newProxyInstance() 메서드를 통해서만 생성이 가능한 다이내믹 프록시 오브젝트를 팩토리 빈을 통해 스프링 빈으로 만들어줄 수 있음
  • 이미 스프링 빈으로 만들어진 트랜잭션 프록시 오브젝트의 타깃을 변경해주기 어려움
  • TxProxyFactory 빈을 직접 가져와서 해보자

6.3.5 프록시 팩토리 빈 방식의 장점과 한계

프록시 팩토리 빈 방식의 장점

  • 하나의 핸들러 메서드를 통해 수많은 메서드에 부가기능을 부여해줄 수 있음
  • 다이내믹 프록시 생성 코드도 제거할 수 있음

프록시 팩토리 빈의 한계

  • 한 번에 여러 개의 클래스에 공통적인 부가기능을 제공하는 일은 지금까지 살펴본 방법으론 불가능
  • 비슷한 프록시 팩토리 빈의 설정이 중복됨
  • 프록시 팩토리 빈 설정이 부가기능의 개수만큼 따라 붙어야 함
  • TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 만들어짐
  • 같은 트랜잭션 부가기능을 제공하는 동일한 코드임에도 타깃이 달라지면 새로운 TransactionHandler 오브젝트를 만들어야 함

6.4 스프링의 프록시 팩토리 빈


6.4.1 ProxyFactoryBean

  • 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공
  • 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록해주는 팩토리 빈이며, 순수하게 프록시를 생성하는 작업만을 담당
  • 부가기능은 MethodInterceptor 를 구현해서 만들며, 타깃 오브젝트에 상관없이 독립적으로 만들어질 수 있기에 싱글톤 빈으로 등록 가능

어드바이스: 타깃이 필요 없는 순수한 부가기능

  • MethodInvocation 은 타깃 오브젝트의 메서드를 실행할 수 있는 기능이 있음
  • 때문에 MethodInterceptor 는 부가기능을 제공하는 데만 집중할 수 있음
  • MethodInvocation 을 싱글톤으로 두고 공유할 수 있음
  • ProxyFactoryBean 하나만으로 여러 개의 부가기능을 제공해주는 프록시를 만들 수 있음
    • 이를 통해 새로운 부가기능을 추가할 때마다 프록시와 프록시 팩토리 빈도 추가해줘야 한다는 문제를 해결
  • ProxyFactoryBean 에 있는 인터페이스 자동검출 기능을 사용해 타깃 오브젝트가 구현하고 있는 인터페이스 정보를 알아낸 후 타깃 오브젝트가 구현하고 있는 모든 인터페이스를 동일하게 구현하는 프록시를 만들어줌
  • 기본적으로 JDK 가 제공하는 다이내믹 프록시를 만들고, 경우에 따라 CGLib 이라는 바이트코드 생성 프레임워크를 이용해 프록시를 만들기도 함

포인트컷: 부가기능 적용 대상 메서드 선정 방법

  • MethodInterceptor 오브젝트는 타깃 정보를 갖고 있지 않도록 만들었음
  • 여러 프록시가 공유하는 MethodInterceptor 에 특정 프록시에만 적용되는 패턴을 넣으면 문제가 될 수 있음
  • 어드바이스 - 부가기능을 제공하는 오브젝트
  • 포인트컷 - 메서드 선정 알고리즘을 담은 오브젝트
  • 왜 굳이 별개의 오브젝트로 묶어서 등록하는가?
    • ProxyFactoryBean 에는 여러 개의 어드바이스와 포인트컷이 추가될 수 있음
  • 어드바이저 = 포인트컷 + 어드바이스

6.4.2 ProxyFactoryBean 적용

  • 타깃 메서드가 던지는 예외도 InvocationTargetException 으로 포장되어 나오는 것이 아니기 때문에 그대로 잡아서 처리하면 됨

정리


트랜잭션 경계설정 코드 분리

  1. 메서드 분리
  2. DI 를 통한 클래스 분리
    • 같은 인터페이스를 구현한 프록시 객체를 중간에 끼움
  3. 데코레이터 패턴과 프록시 패턴
    • 데코레이터 패턴 - 부가기능을 런타임 시 다이내믹하게 부여
    • 프록시 패턴- 타깃에 대한 접근 방법을 제어
  4. 다이내믹 프록시
    • 프록시를 만드는 과정이 번거로움
      • 부가기능이 필요없는 메서드도 구현해서 위임해야 함
      • 부가기능 코드가 중복될 가능성이 높음
    • 이때 다이내믹 프록시 사용
      • 리플렉션을 통해 런타임 시 다이내믹하게 오브젝트 생성
      • InvocationHandler 를 구현해 부가기능을 추가
    • 프록시 객체 → 다이내믹 프록시 객체로 변경
  5. 팩토리 빈
    • 다이내믹 프록시 객체는 일반적인 스프링 빈으로 등록할 수 없음
    • 빈으로 등록해야 클라이언트가 사용할 수 있음
    • 팩토리 빈 인터페이스를 통해 다이내믹 프록시 빈 등록
  6. 팩토리 빈 방식의 한계
    • 비슷한 프록시 팩토리 빈의 설정이 중복
    • 부가기능의 개수만큼 프록시 팩토리 빈 설정이 추가
    • TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 추가
  7. 스프링의 프록시 팩토리 빈
    • ProxyFactoryBean 으로 순수 프록시 생성
      • 싱글톤으로 공유 가능
    • MethodInterceptor 를 구현해 부가기능 부여
      • 싱글톤으로 공유 가능
    • MethodInvocation 으로 타깃 오브젝트 메서드 실행
      • 싱글톤으로 공유 가능
  8. 어드바이스와 포인트컷
    • 어드바이스 - 부가기능 제공 오브젝트
    • 포인트컷 - 메서드 선정 알고리즘
    • 어드바이저 = 어드바이스 + 포인트컷

AOP Interceptor 로 인가 처리


AOP 로 적용

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface HostOnly {
}
@Aspect
@Component
public class HostVerifier {
 
    private final AuthenticationContext authenticationContext;
 
    public HostVerifier(final AuthenticationContext authenticationContext) {
        this.authenticationContext = authenticationContext;
    }
 
    @Before("@annotation(com.woowacourse.gongcheck.application.HostOnly)")
    public void checkHost() {
        final Authority authority = authenticationContext.getAuthority();
        if (!authority.equals(Authority.HOST)) {
            throw new UnauthorizedException("호스트만 입장 가능합니다.");
        }
    }
}

문제점

  • @Valid 가 먼저 터짐
    • 왜? AOP proxy 가 호출되기 전에 ArgumentResolver (JacksonMapper) 가 먼저 동작
  • 인가가 먼저 터져야 함

Interceptor 로 적용

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler)
        throws Exception {
    if (CorsUtils.isPreFlightRequest(request)) {
        return true;
    }
 
    String token = AuthorizationTokenExtractor.extractToken(request)
            .orElseThrow(() -> new UnauthorizedException("헤더에 토큰 값이 정상적으로 존재하지 않습니다."));
 
    String subject = jwtTokenProvider.extractSubject(token);
    authenticationContext.setPrincipal(subject);
 
    Authority authority = jwtTokenProvider.extractAuthority(token);
    if (HandlerMethod.class.isAssignableFrom(handler.getClass())) {
        authorize((HandlerMethod) handler, authority);
    }
 
    return HandlerInterceptor.super.preHandle(request, response, handler);
}
 
private void authorize(HandlerMethod handlerMethod, Authority authority) {
    if (handlerMethod.getMethodAnnotation(HostOnly.class) != null) {
        if (!authority.isHost()) {
            throw new UnauthorizedException("호스트만 입장 가능합니다.");
        }
    }
}

Documentation Mocking 처리

when(jwtTokenProvider.extractAuthority(anyString())).thenReturn(Authority.HOST);

AOP 를 활용한 로깅