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를 사용하게 될 것

- 인터페이스로 만들고 기존 코드를 구현 클래스에 넣어두자

UserServiceTx는UserServiceImpl의 모든 기능을 위임- 추가로 트랜잭션 기능을 추가함

- 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 프록시와 프록시 패턴, 데코레이터 패턴

- 단순 확장성을 고려해 전략 패턴을 사용해도 트랜잭션과 같은 부가적인 기능을 위임을 통해 외부로 분리했을 뿐 핵심 코드와 함께 남아 있음

- 트랜잭션은 비즈니스 로직과는 성격이 다르기 때문에 적용 사실 자체를 밖으로 분리할 수 있음
UserServiceTx→UserServiceImpl을 의존하도록 변경- 즉, 부가기능이 핵심기능을 사용하는 구조
- 하지만, 클라이언트가 핵심기능을 가진 클래스를 직접 사용해버리면 부가기능이 적용될 기회가 없음

- 때문에 부가기능을 마치 자신이 핵심기능을 가진 클래스인 것처럼 꾸며서, 클라이언트가 자신을 거쳐 핵심기능을 사용하도록 만들어야 함
- 마치 자신이 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 것을 대리자, 대리인과 같은 역할을 한다고 해서
프록시라고 부름
- 프록시의 특징
- 타깃과 같은 인터페이스를 구현함
- 프록시가 타깃을 제어할 수 있음
- 프록시의 사용 목적
- 클라이언트가 타깃에 접근하는 방법을 제어하기 위함
- 타깃에 부가적인 기능을 부여해주기 위함
- 두 가지 모두 대리 오브젝트라는 개념의 프록시를 사용한다는 점은 동일하지만, 목적에 따라 디자인 패턴에서는 다른 패턴으로 구분됨
데코레이터 패턴
- 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위함
- 다이내믹하게 기능을 부여한다는 의미는 코드상에서는 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용되는지 정해져 있지 않음

- 프록시로서 동작하는 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 모름
프록시 패턴
- 타깃에 대한 접근 방법을 제어하려는 목적
Collections의unmodifiableCollection()을 통해 만들어지는 오브젝트가 전형적인 접근관한 제어용 프록시라고 볼 수 있음
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으로 포장되어 나오는 것이 아니기 때문에 그대로 잡아서 처리하면 됨
정리
트랜잭션 경계설정 코드 분리
- 메서드 분리
- DI 를 통한 클래스 분리
- 같은 인터페이스를 구현한 프록시 객체를 중간에 끼움
- 데코레이터 패턴과 프록시 패턴
- 데코레이터 패턴 - 부가기능을 런타임 시 다이내믹하게 부여
- 프록시 패턴- 타깃에 대한 접근 방법을 제어
- 다이내믹 프록시
- 프록시를 만드는 과정이 번거로움
- 부가기능이 필요없는 메서드도 구현해서 위임해야 함
- 부가기능 코드가 중복될 가능성이 높음
- 이때 다이내믹 프록시 사용
- 리플렉션을 통해 런타임 시 다이내믹하게 오브젝트 생성
InvocationHandler를 구현해 부가기능을 추가
- 프록시 객체 → 다이내믹 프록시 객체로 변경
- 프록시를 만드는 과정이 번거로움
- 팩토리 빈
- 다이내믹 프록시 객체는 일반적인 스프링 빈으로 등록할 수 없음
- 빈으로 등록해야 클라이언트가 사용할 수 있음
- 팩토리 빈 인터페이스를 통해 다이내믹 프록시 빈 등록
- 팩토리 빈 방식의 한계
- 비슷한 프록시 팩토리 빈의 설정이 중복
- 부가기능의 개수만큼 프록시 팩토리 빈 설정이 추가
- TransactionHandler 오브젝트가 프록시 팩토리 빈 개수만큼 추가
- 스프링의 프록시 팩토리 빈
ProxyFactoryBean으로 순수 프록시 생성- 싱글톤으로 공유 가능
MethodInterceptor를 구현해 부가기능 부여- 싱글톤으로 공유 가능
MethodInvocation으로 타깃 오브젝트 메서드 실행- 싱글톤으로 공유 가능
- 어드바이스와 포인트컷
- 어드바이스 - 부가기능 제공 오브젝트
- 포인트컷 - 메서드 선정 알고리즘
- 어드바이저 = 어드바이스 + 포인트컷
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);