스프링 고급편
ThreadLocal
스프링에서는 빈에 객체를 등록할 때 싱글톤으로 등록한다.
그렇기 때문에 필드에 주입받은 객체들은 동시성 문제를 해결해야한다. 그때 사용가능한 것이 ThreadLocal이다.
쓰레드 로컬은 각 쓰레드만의 특별한 저장소를 말한다. 즉, 해당 쓰레드만 접근할 수 있는 전용 보관소를 만들어주는 것이다.
사용법
- 값 저장 : ThreadLocal.set()
- 값 조회 : ThreadLocal.get()
- 값 제거 : ThreadLocal.remove()
쓰레드 로컬 - 주의 사항
쓰레드 로컬의 값을 사용 후 제거하지 않으면 WAS처럼 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.
예를 통해 알아보자
템플릿 메서드 패턴과 콜백 패턴
- 우리의 애플리케이션은 대체로 핵심 기능과 부가 기능으로 나뉘어져있다.
핵심 기능
: 객체가 제공하는 고유의 기능이다. 보통 비즈니스 로직이라고 불리는 부분이다.부가 기능
: 핵심 기능을 보조하기 위한 기능이다. 예를 들어 트랜잭션이나 로그 추적 로직 등이 있다.- 보통 개발을 하다보면 핵심 기능을 제외한 부가 기능들을 위한 코드가 너무 많아져 핵심 기능에 집중이 잘 되지 않는 경우가 많다.
- 또 좋은 설계란
변하는 것과 변하지 않는 것을 분리
하는 것이다. - 보통 핵심 기능은 자주 변하고 부가 기능은 변하지 않는다. 이 둘을 분리해서 모듈화해야 한다.
- 이런 문제를 해결할 수 있는 것이 템플릿 메서드 패턴이다.
템플릿 메서드
템플릿 메서드 패턴은 이름 그대로 템플릿을 사용하는 방식이다.
템플릿은 기준이 되는 거대한 틀을 뜻한다.
템플릿이라는 틀에 변하지 않는 부분을 몰아두고, 일부 변하는 부분을 별도로 호출해서 해결한다.
예제 코드
1 2 3 4 5 6 7 8 9 10 11 12 13 14
public abstract class AbstractTemplate { public void execute() { long startTime = System.currentTimeMillis(); //비즈니스 로직 실행 call(); //상속 //비즈니스 로직 종료 long endTime = System.currentTimeMillis(); long resultTime = endTime - startTime; log.info("resultTime={}", resultTime); } protected abstract void call(); // 자식 클래스에서 상속해 구현 }
위 코드처럼 변하지 않는 하나의 탬플릿을 만들고 변하는 부분은 call() 메서드를 호출하는 방식으로 처리한다.
변하지 않는 부분은 부모 클래스에 두고, 변하는 부분은 자식 클래스에서 상속과 오버라이딩을 사용해서 처리한다.
문제
- 템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에 따라오는 문제를 모두 가지고 있다.
- 자식 클래스가 부모 클래스와 강하게 결합되는 문제가 있다. 의존관계에 대한 문제가 있는 것이다.
- 만약 부모 클래스의 기능을 자식 클래스에서 모두 사용하지 않다면?
- 그럼에도 부모 클래스를 알아야된다. 부모 클래스를 수정하면 자식 클래스도 영향을 줄 수도 있다.
- 이런 설계는 좋은 설계가 아니다.
- 이외에도 상속은 단점들이 많다 그래서 이런 문제를 해결하는 디자인 패턴이 바로 전략 패턴(Strategy Pattern)이다.
전략 패턴
전략 패턴은 변하지 않는 부분을
Context
에 두고, 변하는 부분을Strategy
라는 인터페이스를 만들고 구현하도록 해서 문제를 해결GOF 디자인 패턴이 정의한 전략 패턴의 의도
- 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.
전략 패턴 실행 그림 - Strategy 필드 주입
전략 패턴을 구현하는 방식에는 여러가지가 있다. 대표적으로 strategy 인터페이스를 필드에 주입 받는 방식이 있다.
하지만 필드 주입 방식은 Context와 Strategy를 한번 조립하고 나면 그 이후에 변경이 어렵다는 단점이 있다.
이런 문제를 함수형 인터페이스를 메서드 파라미터로 보내면 해결이 가능하다.
Strategy 인터페이스 파라미터로 보내기
템플릿 콜백 패턴
정의
- 프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. (위키백과 참고)
템플릿 콜백 패턴은 스프링 내부에서만 자주 사용되는 패턴이다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴
스프링 내부 코드를 잘 살펴보면 JdbcTemplate, RestTemplate 등 다양한 템플릿 콜백을 적용한 패턴이 있다.
그림을 보면 위에서 살펴본 Strategy 패턴과 유사하다. 이름만 다르고 거의 똑같다고 보면 된다.
프록시
애플리케이션은 항상 클라이언트와 서버가 존재한다.
클라이언트는 서버에게 요청을 보내고 서버는 그 요청을 처리한다.
이때 프록시는 클라이언트와 서버 사이에서 대신 어떤 일을 처리해주는
대리자(Proxy)
이다.프록시를 사용하면 얻을 수 있는 이점
접근 제어, 캐싱
부가 기능 추가
프록시 체인
대체 가능
- 프록시를 사용시 클라이언트는 요청한 객체가 프록시인지, 서버인지 몰라야한다. 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다. 즉, 프록시와 서버는 같은 인터페이스를 상속 받아야한다.
프록시 패턴 모양
프록시 주요 기능
프록시는 항상 target이라는 실제 구현체 혹은 다음 적용할 프록시를 내부에 가지고 있다.
프록시를 통해 할 수 있는 일은 크게 2가지로 구분 가능하다.
접근 제어
,부가 기능 추가
GOF 디자인 패턴에선 이 둘의
의도(intent)
에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.프록시 패턴
: 접근 제어가 목적데코레이터 패턴
: 새로운 기능 추가가 목적
프록시 패턴 vs 데코레이터 패턴
- 이 둘은 Proxy를 이용해 둘의 모양이 아주 유사하다. 이름만 다르고 거의 똑같다.
- 이 둘의 차이를 어떻게 구분할까?
- 중요한 것은
의도(intent)
다. 의도(intent)
프록시 패턴의 의도
: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공데코레이터 패턴의 의도
: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
동적 프록시
- 프록시 코드를 직접 작성하는 일은 개발자 입장에서 매우 번거롭다.
- 프록시를 적용해야될 클래스가 많아지면 많아질수록 그만큼의 일이 추가된다.
- 이런 부분을 해결해주는 것이
동적 프록시
다. - 동적 프록시는 프록시 객체를 동적으로 만들어주는 기술이다.
- 우리는 프록시를 적용할 코드를 하나만 만들어두고 나머지는 동적 프록시를 통해 프록시를 만들면 된다.
- 동적 프록시 기술에는 JDK 동적 프록시나 CGLIB가 있고 두 오픈소스 모두 리플렉션을 기반으로 동작한다.
리플렉션
리플렉션이란 클래스나 메서드의 메타정보를 이용해 메소드 호출이나 메서드 정보 처리 등을 가능하게 만들어주는 기술이다.
- 리플렉션을 사용하면 애플리케이션을 동적으로 유연하게 만들 수 있다.
- 하지만 리플렉션은 런타임에 동작해, 컴파일 시점에 오류를 잡을 수 없다는 단점이 있다.
- 그렇기 때문에 개발자가 직접 리플렉션을 사용하는 일은 드물다.
JDK 동적 프록시
동적 프록시 오픈소스 중 하나인 JDK는 기본적으로
인터페이스를 기반
으로 프록시를 만들어준다.인터페이스가 없으면 동작하지 않는다.
실행 순서 그림
JDK 동적 프록시 기술을 적용하면 서로 다른 클래스에 대해 각각 프록시 객체를 만들지 않아도 된다.
한계
- JDP 동적 프록시는 인터페이스가 필수다.
- 그렇기 때문에 구현 클래스만 있는 경우에는 적용이 안된다.
- 이런 부분은 CGLIB라는 오픈소스 라이브러리를 사용하면 된다.
CGLIB
- CGLIB : Code Generator Library
- CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리다.
- CGLIB는 인터페이스 없이 구현 클래스만 가지고도 동적 클래스를 만들어준다.
- JDK 동적 프록시과 가장 큰 차이점은 JDK 동적 프록시 = implement, CGLIB = extends 를 사용해 프록시를 만든다.
- CGLIB 제약
- 부모 클래스의 생성자를 체크해야 한다. CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다. (super()와 같은 부모 클래스의 생성자)
- 클래스에 final 키워드가 붙으면 상속 불가능 -> 예외 발생
- 메서드에 final 키워드가 붙으면 해당 메서드 오버라이딩 X -> CGLIB 프록시 로직 동작 X
프록시 팩토리
우리는 지금까지 동적 프록시에 대해 알아보았다.
하지만 각각 서로 다른 제약과 한계가 있었고 이런 부분을 개발자가 다 신경쓰기는 쉬운 일이 아니다.
그렇기 때문에 스프링은
동적 프록시 기술을 추상화
해서 제공하는데 그것이 바로프록시 팩토리
다.위 그림처럼 프록시 팩토리가 요청에 따라 알맞는 프록시 기술을 선택해 동적 프록시를 생성해준다.
그럼 두 기술은 부가 기능을 적용하기 위해 사용하는 것들이 다른데 어떻게 추상화했을까? (JDK 동적 프록시 = InvocationHandler, CGLIB = MethodInterceptor를 사용)
바로
Advice
라는 새로운 개념을 만들었다. 개발자는 그냥 Advice만 만들면 된다.또한 필터링이나 특정 조건에 맞을 때 프록시 로직을 적용하는
Pointcut
이라는 개념도 도입했다.이런 추상화 덕분에 CGLIB나 JDK 동적 프록시 기술에 의존하지 않고 동적 프록시를 적용 가능해졌다.
참고! 스프링은 기본적으로 항상 CGLIB를 사용하게 되어있다 그 이유는 나중에 살펴보자.
포인트컷, 어드바이스, 어드바이저
포인트컷(Pointcut)
: 어디에 부가 기능을 적용할지, 말지를 판단하는 필터링 로직. 주로 클래스와 메서드 이름으로 필터링한다.어드바이스(Advice)
: 프록시가 호출하는 부가 기능 로직어드바이저(Advisor)
: 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이렇게 분리함으로써 역할과 책임을 명확하게 분리 가능하다.
- 포인트컷은 대상 여부를 필터링만을
- 어드바이스는 부가 기능 로직만을
- 둘이 합쳐놓은 것을 어드바이저
- 포인트컷에 걸리는 경우 Advice를 호출하지 않고 실제 target을 호출한다.
Advisor 여러개 적용시
스프링 빈 후처리기
- 지금까지 인터페이스가 있을 때와 구체 클래스만 있을 때 동적 프록시를 적용하는 법에 대해 알아보았다.
- 그럼 우리가 자주 사용하는 컴포넌트 스캔을 이용해 스프링 빈에 등록하는 경우에는 어떻게 동적 프록시를 적용할까?
- 그 답은 스프링 빈 후처리기라는 기능에 있다.
- 스프링 부트를 사용하면 자동으로 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기가 등록되어있다.
빈 후처리기(BeanPostProcessor)
스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하는 기능이다.
빈을 조작하고 변경할 수 있는 후킹 포인트이다.
빈 후처리기 과정
빈 후처리기를 이용해 객체 바꾸기
이런 식으로 빈에 등록되기 직전에 원하는 로직을 적용할 수 있다. 즉, 객체가 등록되기 전에 프록시 객체로 바꿀 수 있다는 것이다.
우리가 만약 빈 후처리기로 프록시 생성을 적용하고 싶은 객체를 필터링하고 싶다면 어떻게 해야될까?
잘 생각해보면 Advisor에는 필터링 로직을 담당하는 포인트컷이 포함되어있다. 이걸 사용하면 좋을 것 같다.
결과적으로 포인트컷은 두 곳에서 사용된다.
프록시 적용 대상 여부를 체크하기 (빈 후처리기 - 자동 프록시 생성 시)
프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할지 (프록시 내부)
- 그림과 같이 스프링 컨테이너에의 모든 Advisor를 조회한 후 포인트컷을 사용해 객체를 필터링한다.
- 프록시를 모든 곳에서 생성하는 것은 비용 낭비이기 때문에 필요한 곳에만 적용하기 위해 스프링이 도입한 방법이다.
@Aspect
스프링에서 프록시를 적용하고 싶으면 포인트컷과 어드바이스로 구성되어 있는 어드바이저를 만들어서 스프링 빈으로 등록하면 된다.
그러면 스프링 컨테이너에서 자동 프록시 생성기를 통해 알아서 모든 Advisor를 찾아서 알맞는 프록시를 적용시켜준다.
스프링은 @Aspect 어노테이션으로 매우 편리하게 어드바이저를 생성할 수 있게 지원한다. (정확히 말하자면 AspectJ 프로젝트에서 제공하는 어노테이션)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
@Slf4j @Aspect public class LogTraceAspect { private final LogTrace logTrace; public LogTraceAspect(LogTrace logTrace) { this.logTrace = logTrace; } @Around("execution(* hello.proxy.app..*(..))") // 포인트컷 public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { // 어드바이스 로직 작성 joinPoint.proceed(); // 타겟 호출 } }
@Aspect 프록시
자동 프록시 생성기는 Advisor를 자동으로 찾아와서 프록시를 생성하고 적용해준다고 설명했다.
여기에 자동 프록시 생성기는 @Aspect를 찾아서 그 객체를 Advisor로 만들어준다.
@Aspect를 Advisor로 변환해서 저장하는 과정
@Aspect 어드바이저 빌더
BeanFactoryAspectJAdvisorsBuilde
클래스. @Aspect의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관한다.- @Aspect의 정보를 기반으로 어드바이저를 만들고, 빌더 내부에 캐시해 저장한다. 캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에서 꺼내서 반환
@Aspect 어드바이저 적용된 자동 프록시 생성기
실무에서는 프록시를 적용할 때 대부분 @Aspect 방식을 사용한다.
횡단 관심사
지금까지 알아본 프록시로 애플리케이션에 전반적으로 사용되는 기능들을 어떻게 추상화하고 핵심 기능에서 분리할 수 있는지 알아보았다.
이런 애플리케이션 여러 기능들 사이에 전반적으로 걸쳐서 들어가 있는 관심사를
횡단 관심사(cross-cutting concerns)
라고 한다.스프링에서는 스프링 AOP를 사용해 이런 횡단 관심사를 해결한다.
스프링 AOP
- 스프링 AOP는 부가 기능을 효과적으로 처리할 수 있는 기능이다.
- 우리가 AOP 없이 부가 기능을 적용하고 관리하기는 매우 번거롭다. 다음과 같은 문제가 발생한다.
- 부가 기능을 적용할 때 많은 반복이 필요
- 부가 기능이 여러 곳에 퍼져서 중복 코드 발생
- 부가 기능을 변경할 때 중복 때문에 많은 수정 필요
- 부가 기능의 적용 대상을 변경할 때 많은 수정 필요
- 이런 애플리케이션 전반에 부가 기능을 적용하는 문제는 일반적인 OOP 방식으로는 해결이 어렵다.
- 이런 문제를 해결하기 위해 고안한 방법이 AOP이다.
애스팩트
위의 문제를 해결하기 위해 부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리하고 어디에 적용할지 선택하는 기능을 만들었다.
이렇게 부가 기능과 부가 기능 적용 필터링 기능을 합해서 하나의 모듈로 만들었는데 그게 바로
애스팩트(aspect)
이다. (@Aspect가 바로 이 애스팩트)애스팩트는 관점이라는 뜻이 있는데 애플리케이션을 바라보는 관점을
하나하나의 기능 -> 횡단 관심사 관점
으로 달리 보는 것이다.이렇게 애스팩트를 사용한 프로그래밍 방식을
관점 지향 프로그래밍 - AOP(aspect-Oriented Programming)
이라고 부른다.AOP는 OOP를 대체하기 위한 것이 아니라 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.
AspectJ 프레임워크
- AOP의 대표적인 구현으로 AspectJ 프레임워크가 있다. 스프링도 AOP를 지원하지만 대부분 AspectJ의 문법을 차용하고, 기능 일부만 제공한다.
AOP 적용 방식
AOP를 사용하면 핵심 기능과 부가 기능이 코드상 완전히 분리되어서 관리된다.
AOP는 어떻게 부가 기능 로직을 실제 로직에 추가할까?
이렇게 애스팩트(부가 기능)이 실제 코드에 추가 되는 것을
위빙(Weaving)
이라 한다.3가지 방법이 있다.
- 컴파일 시점
- 클래스 로딩 시점
- 런타임 시점(프록시 사용)
컴파일 시점 - 위빙
소스코드를 컴파일 시점에 부가 기능 로직을 추가할 수 있다.
이떄는 AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다.
이 컴파일러는 Aspect를 확인해서 해당 클래스가 적용 대상인지 먼저 확인하고, 적용 대상인 경우 부가 기능 로직을 실제 컴파일된 코드에 붙여버린다.
단점
- AspectJ가 제공하는 컴파일러를 써야되고 복잡하다.
클래스 로딩 시점 - 위빙
- 자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관한다.
- 이때 중간에서 .class 파일을 조작한 다음 JVM에 올릴 수 있다. 이런 기능을 java Instrumentation이라고 한다.
- 이 시점에 애스팩트를 적용하는 것을 로드 타임 위빙이라고 한다.
단점
- 로드 타임 위빙은 자바를 실행할 때 java -javaagent 라는 옵션을 통해 클래스 로더 조작기를 지정해야되는데 이 부분이 번거롭고 운영하기 어렵다.
런타임 시점 - 위빙
- 우리가 위에서 학습한 그 프록시 방식의 AOP이다.
- 프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다. 하지만 컴파일러 위빙이나 로드 타임 위빙을 별도로 사용하지 않고 스프링만 있으면 AOP를 적용할 수 있다.
각 차이점
컴파일 시점
: 실제 대상 코드에 애스팩트 적용. AspectJ를 직접 사용클래스 로딩 시점
: 실제 대상 코드에 애스팩트 적용. AspectJ를 직접 사용런타임 시점
: 실제 대상 코드는 유지. 대신 프록시를 통해 부가 기능 적용. 스프링 AOP는 이 방식 사용
AOP 적용 위치
적용 가능 지점(조인 포인트)
: 생성자, 필드 값, static 메서드, 메서드 실행- AspectJ를 직접 사용하는 방식을 사용하면 바이트코드를 직접 조작하기 때문에 위 조인 포인트에 모두 적용할 수 있다.
- 하지만 프록시 방식은 메서드 실행 지점에만 AOP를 적용할 수 있다. (메서드 오버라이딩 개념으로 동작하기 때문)
참고!
: 스프링은 AspectJ의 문법을 차용하는거지 AspectJ를 직접 사용하는 것은 아니다. 또 AspectJ를 사용하면 더 다양한 기능을 사용 가능하지만 사용법이 복잡하고 별도의 옵션을 적용해야한다. 실무에서는 스프링 AOP(프록시 방식)로 대부분의 문제가 해결 가능하다.
AOP 용어 정리
조인 포인트(Join Point)
- 어드바이스가 적용될 수 있는 위치. 메소드 실행, 생성자 호출, 필드 값 등등
- 조인 포인트는 추상적인 개념. AOP를 적용할 수 있는 모든 지점이라고 생각하자.
포인트컷(Pointcut)
- 조인 포인트 중에서 어드바이스가 적용될 위치를 필터링하는 기능
- 주로 AspectJ 표현식을 사용해서 지정
타켓(Target)
- 어드바이스를 받는 객체. 포인트컷으로 결정
어드바이스(Advice)
- 부가 기능 로직
- 특정 조인 포인트에서 Aspect에 의해 취해지는 조치
- Around, Before, After와 같은 어드바이스가 있음
애스팩트(Aspect)
- 어드바이스 + 포인트컷을 모듈화 한 것
- @Aspect
- 여러 어드바이스와 포인트 컷이 함께 존재
어드바이저(Advisor)
- 하나의 어드바이스와 하나의 포인트 컷으로 구성
- 스프링 AOP에서만 사용되는 용어
위빙(Weaving)
- 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것
- AOP 적용을 위해 애스팩트를 객체 연결한 상태
AOP 프록시
- AOP 기능을 구현하기 위해 만든 프록시 객체 (JDK 동적 프록시, CGLIB)
스프링 AOP 구현
@Aspect로 Aspect 기본 구조
1 2 3 4 5 6 7 8 9 10
@Slf4j @Aspect public class AspectV1 { //hello.aop.order 패키지와 하위 패키지 @Around("execution(* hello.aop.order..*(..))") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처 return joinPoint.proceed(); } }
- @Around로 작성한 것이 포인트컷
- 실제 메서드 안에 있는 로직이 어드바이스가 된다.
@Aspect만 있으면 컴포넌트 스캔의 대상이 안된다. 그래서 직접 빈으로 등록하거나 @Componet를 사용해 등록해야한다.
포인트컷 분리
1 2 3 4 5 6 7 8 9 10 11 12
@Aspect public class AspectV2 { //hello.aop.order 패키지와 하위 패키지 @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression private void allOrder(){} //pointcut signature @Around("allOrder()") public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { log.info("[log] {}", joinPoint.getSignature()); return joinPoint.proceed(); } }
위의 코드같이 포인트컷을 메서드로 분리해서 사용할 수 있다.
메서드의 반환 타입은 void로 하고 내용은 비워야한다.
여러개의 포인트 컷을 만들고 조합해서도 사용할 수 있다.
1
@Around("allOrder() && allService()")
&&(AND), (OR), !(NOT) 3가지로 조합이 가능하다.
포인트컷 다른 클래스로 분리
포인트컷을 모아둔 클래스를 만들어놓고 관리하거나 사용할 수 있다.
1
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
사용시에는 패키지명까지 적어줘야한다.
어드바이스 순서 지정
어드바이스는 기본적으로 순서를 보장 X
순서를 지정하고 싶으면 @Aspect 적용 단위로 @Order 어노테이션을 적용해야한다.
근데 이 방식의 문제는 클래스 단위로 적용할 수 있다는 점이다.
만약 하나의 애스팩트에 여러 어드바이스가 있다면 순서가 보장이 안된다. 따라서 애스팩트를 별도의 클래스로 분리해야한다.
1 2 3 4 5 6 7 8 9
@Aspect @Order(2) public class LogAspect { } @Aspect @Order(1) public class TestAspect { }
어드바이스 종류
- 위의 예제에서는 보통 @Around를 사용했지만 그 외에도 여러가지 종류가 있다.
@Around
: 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능@Before
: 조인 포인트 실행 이전에 실행@AfterReturning
: 조인 포인트가 정상 완료 후 실행@AfterThrowing
: 메서드가 예외를 던지는 경우 실행@After
: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally- 특징 : @Around를 제외하고 나머지는 JoinPoint를 사용할 수 있다. @Around는 ProceedingJoinPoint를 사용한다.
JoinPoint 인터페이스의 주요 기능
- getArgs() : 메서드 인수를 반환
- getThis() : 프록시 객체를 반환
- getTarget() : 대상 객체를 반환
- getSignature() : 조언되는 메서드에 대한 설명을 반환
- toString() : 조언되는 방법에 대한 유용한 설명을 인쇄
ProceedingJoinPoint 인터페이스의 기능
- proceed() : 다음 어드바이스나 타겟을 호출
JoinPoint와 ProceedingJoinPoint의 차이
- JoinPoint는 proceed()을 호출할 수 없다. 그 반대로 ProceedingJoinPoint는 proceed()를 호출할 수 있다.
- @Around 사용 시 proceed()을 호출하지 않으면 다음 대상이 호출되지 않는다. 그 반면에 @Before와 같은 어드바이스는 proceed() 자체를 사용하지 않는다. 자동으로 다음 타겟이 호출된다. 즉, @Around를 제외한 다른 어드바이스들은 흐름을 직접 변경할 수 없다.
그럼 @Around만 사용하면 되는데 왜 다른 어드바이스가 필요할까?
- 그 이유는 한 문장으로 설명이 가능하다.
좋은 설계는 제약이 있는 것이다.
- @Aroud만 사용한다면 개발자가 한눈에 이 어드바이스가 어떤 역할을 하는지 명확하게 알기 힘들다.
- 또 @Around 사용시 proceed()를 호출하지 않으면 다음 타겟이 호출되지 않는 치명적인 버그가 발생한다. 즉, 실수할 가능성이 존재한다는 것이다.
- 그렇기 때문에 어드바이스의 역할을 명확히하고 의도가 뭔지 파악하기 쉽기 때문에 다른 어드바이스를 사용하는 것이다.
- 그 이유는 한 문장으로 설명이 가능하다.
스프링 AOP - 포인트컷
포인트컷 지시자
execution
: 메소드 실행 조인 포인트를 매칭, 가장 많이 사용within
: 특정 타입 내의 조인 포인트 매칭args
: 인자가 주어진 타입의 인스턴스인 조인 포인트this
: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트target
: Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트@target
: 실행 객체의 클래스에 주어진 타입의 어노테이션이 있는 조인 포인트@within
: 주어진 어노테이션이 있는 타입 내 조인 포인트@annotation
: 메서드가 주어진 어노테이션을 가지고 있는 조인포인트@args
: 전달된 실제 인수의 런타임 타입이 주어진 타입의 어노테이션을 갖는 조인 포인트bean
: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷 지정
execution 문법
1
execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern) throws-pattern?)
- ? 붙은 패턴들은 생략 가능
- *같은 패턴 사용 가능
.
: 정확하게 해당 위치의 패키지..
: 해당 위치의 패키지와 그 하위 패키지도 포함ex)
- 접근제어자?: public
- 반환타입: String
- 선언타입?: hello.aop.member.MemberServiceImpl
- 메서드이름: hello
- 파라미터: (String)
- 예외?: 생략
파라미터 매칭 규칙
(String)
: 정확하게 String 타입 파라미터()
: 파라미터가 없어야 한다.(*)
: 정확히 하나의 파라미터, 단 모든 타입을 허용한다. *(*, *)
: 정확히 두 개의 파라미터, 단 모든 타입을 허용한다.*(..)
: 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 참고로 파라미터가 없어도 된다. 0..* 로 이해하면 된다.(String, ..)
: String 타입으로 시작해야 한다. 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 예) (String) , (String, Xxx) , (String, Xxx, Xxx) 허용
주의! 다음 포인트컷 지시자는 단독으로 사용하면 안됨
args, @args, @target
args, @args, @target의 경우 실체 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다.
실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단이 가능하다. 프록시가 없다면 판단 자체가 불가능하다.
그런데 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점에 적용할 수 있다.
따라서 args, @args, @target 같은 포인트컷 지시자가 있으면 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다.
문제는 이렇게 모든 스프링 빈에 AOP를 적용하려고하면 스프링이 내부에서 사용 중인 빈 중에 final로 지정된 빈들도 있기 때문에 오류가 발생
따라서 이런 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야한다.
매개변수 전달
다음 포인트컷 지시자를 사용해서 어드바이스에 매개변수를 전달 가능하다.
this, target, args,@target, @within, @annotation, @args
1 2 3 4
@Before("allMember() && args(arg,..)") public void logArgs3(String arg) { log.info("[logArgs3] arg={}", arg); }
- 포인트컷의 이름과 매개변수의 이름을 맞추어야한다.
스프링 AOP - 주의 사항
- 스프링 AOP는 @Transactional과 마찬가지로 내부호출에 대한 문제가 발생할 수 있다. 주의하자.
타입 캐스팅
JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.
JDK 동적 프록시의 한계
CGLIB
캐스팅시
- CGLIB의 경우 캐스팅이 성공한다.
- 그 이유는 CGLIB는 구체클래스를 상속해서 Proxy를 만들기 때문에 캐스팅이 다 가능하다.
프록시 기술과 한계 - 의존관계 주입
위에서 살펴본 캐스팅에 진짜 문제는 의존관계 주입을 할 때 발생한다.
1 2 3 4 5 6 7 8 9 10
public class ProxyDITest { @Autowired MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK @Autowired MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK @Test void go() { log.info("memberService class={}", memberService.getClass()); log.info("memberServiceImpl class={}", memberServiceImpl.getClass()); memberServiceImpl.hello("hello"); } }
이때 JDK 동적 프록시를 사용하면 타입 관련 예외가 발생한다.
- @Autowired MemberServiceImpl memberServiceImpl 문제는 이 부분에서 발생한다.
- JDK Proxy는 인터페이스 기반으로 만들어져서 MemberServiceImpl 타입이 뭔지 전혀 모른다. 그래서 주입할 수 없다.
CGLIB 사용
- CGLIB의 경우 구체 클래스를 기반으로 만들어져서 주입이 가능하다.
프록시 기술의 한계 - CGLIB
CGLIB도 여러 문제점이 있다. 다음과 같다.
대상 클래스에 기본 생성자 필수
- 구체 클래스를 상속 받기 때문에 자식 클래스의 생성자에서 부모 클래스의 생성자를 호출해야한다. (super()와 같은)
- CGLIB는 프록시 팩토리가 만들어주기 때문에 기본 생성자를 필요로하고 기본 생성자를 필수로 만들어야한다.
생성자 2번 호출 문제
final 키워드 클래스, 메서드 사용 불가
스프링 해결책
- 일단 처음으로 CGLIB를 스프링 내부에 포함시켜 별도의 라이브러리를 설치해야하는 번거로움 제거
기본 생성자 필수 문제 해결
- 스프링 4.0부터
objenesis
라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체 생성이 가능해졌다. 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.
- 스프링 4.0부터
생성자 2번 호출 문제
- 이것도 위의
objenesis
라이브러리로 해결
- 이것도 위의
- 이런 문제들을 해결함으로써 스프링은 CGLIB를 기본 사용하도록 정책을 정했다.