[Spring][Core] 스프링 핵심 원리-고급편
들어가며
해당 게시글은 인프런 김영한 강사님의 스프링 핵심 원리 - 고급편 강의를 바탕으로 쓰였음을 미리 밝힙니다.
예제 만들기
로그 추적기 - 요구사항 분석
- 모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력
- 애플리케이션의 흐름을 변경하면 안됨(로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨)
- 메서드 호출에 걸린 시간
- 정상 흐름과 예외 흐름 구분(예외 발생시 예외 정보가 남아야 함)
- 메서드 호출의 깊이 표현 HTTP 요청을 구분
- HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함
- 트랜잭션 ID (DB 트랜잭션X), 여기서는 하나의 HTTP 요청이 시작해서 끝날 때 까지를 하나의 트랜잭션이라 함
- 예시
정상 요청
\[796bccd9] OrderController.request()
\[796bccd9] |-->OrderService.orderItem()
\[796bccd9] | |-->OrderRepository.save()
\[796bccd9] | |<--OrderRepository.save() time=1004ms
\[796bccd9] |<--OrderService.orderItem() time=1014ms
\[796bccd9] OrderController.request() time=1016ms
예외 발생
\[b7119f27] OrderController.request()
\[b7119f27] |-->OrderService.orderItem()
\[b7119f27] | |-->OrderRepository.save()
\[b7119f27] | |<X-OrderRepository.save() time=0ms ex=java.lang.IllegalStateException: 예외 발생!
\[b7119f27] |<X-OrderService.orderItem() time=10ms ex=java.lang.IllegalStateException: 예외 발생!
\[b7119f27] OrderController.request() time=11ms ex=java.lang.IllegalStateException: 예외 발생!
로그 추적기 V1 - 프로토타입 개발
- TraceId 클래스: 로그 추적기는 트랜잭션ID와 깊이를 표현하는 방법이 필요하다. 여기서는 트랜잭션ID와 깊이를 표현하는 level을 묶어서
TraceId
라는 개념을 만들었다.TraceId
는 단순히 id (트랜잭션ID)와 level 정보를 함께 가지고 있다.createNextId()
: 다음TraceId
를 만든다. 예제 로그를 잘 보면 깊이가 증가해도 트랜잭션ID는 같다. 대신에 깊이가 하나 증가한다
- TraceStatus 클래스: 로그의 상태 정보를 나타낸다. 로그를 시작할 때의 상태 정보를 가지고 있다. 이 상태 정보는 로그를 종료할 때 사용된다.
traceId
: 내부에 트랜잭션ID와 level을 가지고 있다.startTimeMs
: 로그 시작시간이다. 로그 종료시 이 시작 시간을 기준으로 시작~종료까지 전체 수행 시간을 구할 수 있다.message
: 시작시 사용한 메시지이다. 이후 로그 종료시에도 이 메시지를 사용해서 출력한다. 각 계층별로 다른TraceStatus
를 생성하므로 다른 메시지를 담고 있다.
- 전체적인 설명은 다음과 같다. 각 계층에서 startTime과 message 그리고 level과 id를 갖는 traceId를 갖는 traceStatus 인스턴스를 생성하고 각 계층 메소드의 처음과 마지막에
begin
과end
또는exception
을 호출하여 로그를 남긴다.
public TraceStatus begin(String message) {
TraceId traceId = new TraceId();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
로그 추적기 V1 - 적용
@GetMapping("/v1/request")
public String request(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderController.request()");
orderServiceV1.orderItem(itemId);
trace.end(status);
return "ok";
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
로그 추적기 V2 - 파라미터로 동기화 개발
public TraceStatus beginSync(TraceId beforeTraceId, String message) {
TraceId nextId = beforeTraceId.createNextId();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", nextId.getId(), addSpace(START_PREFIX, nextId.getLevel()), message);
return new TraceStatus(nextId, startTimeMs, message);
}
- 남은 문제
- HTTP 요청을 구분하고 깊이를 표현하기 위해서
TraceId
동기화가 필요하다.TraceId
의 동기화를 위해서 관련 메서드의 모든 파라미터를 수정해야 한다. - 만약 인터페이스가 있다면 인터페이스까지 모두 고쳐야 하는 상황이다.
- 로그를 처음 시작할 때는
begin()
을 호출하고, 처음이 아닐때는beginSync()
를 호출해야 한다. - 만약에 컨트롤러를 통해서 서비스를 호출하는 것이 아니라, 다른 곳에서 서비스를 처음으로 호출하는 상황이라면 파리미터로 넘길
TraceId
가 없다.
- HTTP 요청을 구분하고 깊이를 표현하기 위해서
쓰레드 로컬 - ThreadLocal
필드 동기화 - 개발
LogTrace
public interface LogTrace {
TraceStatus begin(String message);
void end(TraceStatus status);
void exception(TraceStatus status, Exception e);
}
FieldLogTrace
@Slf4j
public class FieldLogTrace implements LogTrace {
...
private TraceId traceIdHolder;
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder;
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
private void syncTraceId() {
if (traceIdHolder == null) {
traceIdHolder = new TraceId();
} else {
traceIdHolder = traceIdHolder.createNextId();
}
}
private void releaseTraceId() {
if (traceIdHolder.isFirstLevel()) {
traceIdHolder = null;
} else {
traceIdHolder = traceIdHolder.createPreviousId();
}
}
...
}
- 이제 직전 로그의
TraceId
는 파라미터로 전달되는 것이 아니라FieldLogTrace
의 필드인traceIdHolder
에 저장된다.
필드 동기화 - 적용
FieldLogTrace
를 빈으로 등록하여 주입을 받아Controller
,Service
,Repository
각 계층에서 주입을 받아 사용한다.
필드 동기화 - 동시성 문제
FieldLogTrace
는 싱글톤으로 등록된 스프링 빈이다. 이 객체의 인스턴스가 애플리케이션에 딱 1 존재한다는 뜻이다. 이렇게 하나만 있는 인스턴스의FieldLogTrace.traceIdHolder
필드를 여러 쓰레드가 동시에 접근하기 때문에 동시성 문제가 발생한다.- 결과적으로 각 Thread 입장에서는 저장한 데이터와 조회한 데이터가 다른 문제가 발생한다. 이처럼 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라 한다. 이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않고, 트래픽이 점점 많아질수록 자주 발생한다. 특히 스프링 빈 처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다.
ThreadLocal - 소개
- 쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.
- 자바는 언어차원에서 쓰레드 로컬을 지원하기 위한
java.lang.ThreadLocal
클래스를 제공한다.
ThreadLocal - 예제 코드
@Slf4j
public class ThreadLocalService {
private ThreadLocal<String> nameStore = new ThreadLocal<>();
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore.get());
nameStore.set(name);
sleep(1000);
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get();
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다.
쓰레드 로컬 동기화 - 개발
@Slf4j
public class ThreadLocalLogTrace implements LogTrace {
...
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder.get();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId == null) {
traceIdHolder.set(new TraceId());
} else {
traceIdHolder.set(traceId.createNextId());
}
}
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove();
} else {
traceIdHolder.set(traceId.createPreviousId());
}
}
...
}
ThreadLocal.remove()
: 추가로 쓰레드 로컬을 모두 사용하고 나면 꼭ThreadLocal.remove()
를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다. 쉽게 이야기해서 마지막 로그를 출력하고 나면 쓰레드 로컬의 값을 제거해야 한다. 제거해주지 않으면 같은 Thread를 할당 받은 다른 HTTP 요청에서 다른 사용자의 정보 조회가 가능해지기 때문이다.
쓰레드 로컬 동기화 - 적용
- 동시성 문제가 있는
FieldLogTrace
대신에 문제를 해결한ThreadLocalLogTrace
를 스프링 빈으로 등록하자.
템플릿 메서드 패턴과 콜백 패턴
템플릿 메서드 패턴
- 템플릿 메서드 패턴은 이름 그대로 템플릿을 사용하는 방식이다. 템플릿은 기준이 되는 거대한 틀이다. 템플릿이라는 틀에 변하지 않는 부분을 몰아둔다. 그리고 일부 변하는 부분을 별도로 호출해서 해결한다.
AbstractTemplate
코드를 보자. 변하지 않는 부분인 시간 측정 로직을 몰아둔 것을 확인할 수 있다. 이제 이것이 하나의 템플릿이 된다. 그리고 템플릿 안에서 변하는 부분은call()
메서드를 호출해서 처리한다. 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿 코드를 둔다. 그리고 변하는 부분은 자식 클래스에 두고 상속과 오버라이딩을 사용해서 처리한다. 익명 내부 클래스를 이용할 수도 있다. - 작업에서 알고리즘의 골격을 정의하고 일부 단계를 하위 클래스로 연기한다. 템플릿 메서드를 사용하면 하위 클래스가 알고리즘의 구조를 변경하지 않고도 알고리즘의 특정 단계를 재정의할 수 있다.
public abstract class AbstractTemplate<T> {
private final LogTrace trace;
public AbstractTemplate(LogTrace trace) {
this.trace = trace;
}
public T execute(String message) {
TraceStatus status = null;
try {
status = trace.begin(message);
T result = call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
protected abstract T call();
}
좋은 설계란?
좋은 설계라는 것은 무엇일까? 수 많은 멋진 정의가 있겠지만, 진정한 좋은 설계는 바로 변경이 일어날 때 자연스럽게 드러난다. 지금까지 로그를 남기는 부분을 모아서 하나로 모듈화하고, 비즈니스 로직 부분을 분리했다. 여기서 만약 로그를 남기는 로직을 변경해야 한다고 생각해보자. 그래서 AbstractTemplate
코드를 변경해야 한다 가정해보자. 단순히 AbstractTemplate
코드만 변경하면 된다.
로그를 남기는 부분에 단일 책임 원칙(SRP)을 지킨 것이다. 변경 지점을 하나로 모아서 변경에 쉽게 대처할 수 있는 구조를 만든 것이다.
템플릿 메서드 패턴의 문제점
템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점들을 그대로 안고간다. 특히 자식 클래스가 부모 클래스와 컴파일 시점에 강하게 결합되는 문제가 있다. 이것은 의존관계에 대한 문제이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는다. 그럼에도 불구하고 템플릿 메서드 패턴을 위해 자식 클래스는 부모 클래스를 상속 받고 있다. 상속을 받는다는 것은 특정 부모 클래스를 의존하고 있다는 것이다. 자식 클래스의 extends 다음에 바로 부모 클래스가 코드상에 지정되어 있다. 따라서 부모 클래스의 기능을 사용하든 사용하지 않든 간에 부모 클래스를 강하게 의존하게 된다. 여기서 강하게 의존한다는 뜻은 자식 클래스의 코드에 부모 클래스의 코드가 명확하게 적혀 있다는 뜻이다. UML에서 상속을 받으면 삼각형 화살표가 자식 -> 부모 를 향하고 있는 것은 이런 의존관계를 반영하는 것이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는데, 부모 클래스를 알아야한다. 이것은 좋은 설계가 아니다. 그리고 이런 잘못된 의존관계 때문에 부모 클래스를 수정하면, 자식 클래스에도 영향을 줄 수 있다. 추가로 템플릿 메서드 패턴은 상속 구조를 사용하기 때문에, 별도의 클래스나 익명 내부 클래스를 만들어야 하는 부분도 복잡하다.
전략 패턴 - 1
전략 패턴은 변하지 않는 부분을 Context
라는 곳에 두고, 변하는 부분을 Strategy
라는 인터페이스를 만들고 해당 인터페이스를 구현하도록 해서 문제를 해결한다. 상속이 아니라 위임으로 문제를 해결하는 것이다. 전략 패턴에서 Context
는 변하지 않는 템플릿 역할을 하고, Strategy
는 변하는 알고리즘 역할을 한다.
public interface Strategy {
void call();
}
@Slf4j
public class StrategyLogic1 implements Strategy {
@Override
public void call(){
log.info("비즈니스 로직1 실행");
}
}
@Slf4j
public class StrategyLogic2 implements Strategy {
@Override
public void call(){
log.info("비즈니스 로직2 실행");
}
}
@Slf4j
public class ContextV1 {
private Strategy strategy;
public ContextV1(Strategy strategy) {
this.strategy = strategy;
}
public void execute() {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
strategy.call(); // 위임
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
ContextV1
은 변하지 않는 로직을 가지고 있는 템플릿 역할을 하는 코드이다. 전략 패턴에서는 이것을 컨텍스트(문맥)이라 한다. 쉽게 이야기해서 컨텍스트(문맥)는 크게 변하지 않지만, 그 문맥 속에서strategy
를 통해 일부 전략이 변경된다 생각하면 된다.Context
는 내부에Strategy strategy
필드를 가지고 있다. 이 필드에 변하는 부분인Strategy
의 구현체를 주입하면 된다. 덕분에 Strategy 의 구현체를 변경하거나 새로 만들어도 Context 코드에는 영향을 주지 않는다.
전략 패턴 - 2
@Slf4j
public class ContextV2 {
public void execute(Strategy strategy) {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행
strategy.call(); // 위임
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime={}", resultTime);
}
}
ContextV2
는 전략을 필드로 가지지 않는다. 대신에 전략을execute(..)
가 호출될 때 마다 항상 파라미터로 전달 받는다.- 하나의
Context
만 생성한다. 그리고 하나의Context
에 실행 시점에 여러 전략을 인수로 전달해서 유연하게 실행하는 것을 확인할 수 있다.
템플릿 콜백 패턴
ContextV2
는 변하지 않는 템플릿 역할을 한다. 그리고 변하는 부분은 파라미터로 넘어온 Strategy
의 코드를 실행해서 처리한다. 이렇게 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백(callback)이라 한다. 프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.
자바 언어에서 콜백
자바 언어에서 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다. 자바8부터는 람다를 사용할 수 있다. 자바 8 이전에는 보통 하나의 메소드를 가진 인터페이스를 구현하고, 주로 익명 내부 클래스를 사용했다. 최근에는 주로 람다를 사용한다.
템플릿 콜백 패턴 - 적용
public interface TraceCallback<T> {
T call();
}
public class TraceTemplate {
private final LogTrace trace;
public TraceTemplate(LogTrace trace) {
this.trace = trace;
}
public <T> T execute(String message, TraceCallback<T> callback) {
TraceStatus status = null;
try {
status = trace.begin(message);
T result = callback.call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
Controller
,Service
,Repository
각 계층에서TraceTemplate
를 빈으로 공유해도 되고 아니면 같은LogTrace
를 담고 있는 다른TraceTemplate
를 가지고 있어도 된다. 즉TraceTemplate
를 생성할 때 빈으로 등록한ThreadLocalLogTrace
를 주입받기 때문에 각 계층에서 사용하는TraceTemplate
이 다르더라도 같은 HTTP 요청이라면 같은 Thread이고 따라서 Trace 동기화가 가능하다.
프록시 패턴과 데코레이터 패턴
- 인터페이스와 구현 클래스 - 스프링 빈으로 수동 등록
Controller
,Service
,Repository
각 계층을Interface
와 구현체로 나누고 각 계층에서 DI는 인터페이스로 주입 받는다.
- 인터페이스 없는 구체 클래스 - 스프링 빈으로 수동 등록
- 인터페이스 없이 각 계층을 구체 클래스로 구현하다. 각 계층 DI는 구체 클래스로 주입 받는다. 물론 해당 클래스를 상속한 자식 클래스도 주입받을 수 있다.
- 컴포넌트 스캔으로 스프링 빈 자동 등록
@Repository
,@ResetController
,@Service
애노테이션을 사용하여@Component
를 통해 빈으로 자동 등록한다.
프록시, 프록시 패턴, 데코레이터 패턴 - 소개
클라이언트(Client
)와 서버(Server
)라고 하면 개발자들은 보통 서버 컴퓨터를 생각한다. 사실 클라이언트와 서버의 개념은 상당히 넓게 사용된다. 클라이언트는 의뢰인이라는 뜻이고, 서버는 ‘서비스나 상품을 제공하는 사람이나 물건’을 뜻한다. 따라서 클라이언트와 서버의 기본 개념을 정의하면 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리하는 것이다.
이 개념을 우리가 익숙한 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되고, 요청을 처리하는 서버는 웹 서버가 된다. 이 개념을 객체에 도입하면, 요청하는 객체는 클라이언트가 되고, 요청을 처리하는 객체는 서버가 된다.
- 클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받는다. 이것을 직접 호출이라 한다.
- 그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 여기서 대리자를 영어로 프록시(Proxy)라 한다.
Client
입장에서는 실제 서버인지 아니면 프록시인지 몰라도 된다. 이를 위해서는 다형성을 이용해야 한다. 실제 서버와 프록시가 같은Interface
를 구현하고 해당Interface
로 DI를 받거나 구체 클래스의 상속으로 프록시를 구현하고 구체 클래스로 DI를 받는 방식으로 구현할 수 있다. 이때 프록시는 실제 서버의 참조(target)를 가지고 있어야 한다.
프록시의 주요 기능
- 접근 제어
- 권한에 따른 접근 차단
- 캐싱
- 지연 로딩
- 부가 기능 추가
- 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
- 예) 요청 값이나, 응답 값을 중간에 변형한다.
- 예) 실행 시간을 측정해서 추가 로그를 남긴다.
GOF 디자인 패턴
둘다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다. 둘다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 용어가 프록시 패턴이라고 해서 이 패턴만 프록시를 사용하는 것은 아니다. 데코레이터 패턴도 프록시를 사용한다.
- 프록시 패턴: 접근 제어가 목적
- 데코레이터 패턴: 새로운 기능 추가가 목적
프록시 패턴

@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객체 호출");
sleep(1000);
return "data";
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Slf4j
public class CacheProxy implements Subject {
private Subject target;
private String cacheValue;
public CacheProxy(Subject target) {
this.target = target;
}
@Override
public String operation() {
log.info("프록시 호출");
if (cacheValue == null) {
return cacheValue = target.operation();
} else {
return cacheValue;
}
}
}
public class ProxyPatternTest {
@Test
@DisplayName("cacheProxyTest")
public void cacheProxyTest() throws Exception {
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject);
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
}
데코레이터 패턴

@Slf4j
public class RealComponent implements Component {
@Override
public String operation() {
log.info("RealComponent 실행");
return "data";
}
}
@Slf4j
public class TimeDecorator implements Component {
private Component target;
public TimeDecorator(Component target) {
this.target = target;
}
@Override
public String operation() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = target.operation();
long endTime = System.currentTimeMillis();
log.info("TimeDecorator 종료 resultTime={}ms", endTime - startTime);
return result;
}
}
@Slf4j
public class MessageDecorator implements Component {
private Component target;
public MessageDecorator(Component target) {
this.target = target;
}
@Override
public String operation() {
log.info("MessageDecorator 실행");
String result = target.operation();
String decoResult = "****" + result + "****";
log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
return decoResult;
}
}
@Slf4j
public class DecoratorPatternTest {
...
@Test
@DisplayName("decorator2")
public void decorator2() throws Exception {
RealComponent target = new RealComponent();
Component messageDecorator = new MessageDecorator(target);
Component timeDecorator = new TimeDecorator(messageDecorator);
DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
client.execute();
}
...
}
- 여기서 생각해보면
Decorator
기능에 일부 중복이 있다. 꾸며주는 역할을 하는Decorator
들은 스스로 존재할 수 없다. 항상 꾸며줄 대상(target
)이 있어야 한다. 따라서 내부에 호출 대상인component
를 가지고 있어야 한다. 그리고component
를 항상 호출해야 한다. 이 부분이 중복이다. 이런 중복을 제거하기 위해component
를 속성으로 가지고 있는Decorator
라는 추상 클래스를 만드는 방법도 고민할 수 있다. 이렇게 하면 추가로 클래스 다이어그램에서 어떤 것이 실제 컴포넌트 인지, 데코레이터인지 명확하게 구분할 수 있다. 여기까지 고민한 것이 바로 GOF에서 설명하는 데코레이터 패턴의 기본 예제이다.
프록시 패턴 vs 데코레이터 패턴
Decorator
라는 추상 클래스를 만들어야 데코레이터 패턴일까? 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 비슷하다. 사실 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 같고, 상황에 따라 정말 똑같을 때도 있다. 그러면 둘을 어떻게 구분하는 것일까? 디자인 패턴에서 중요한 것은 해당 패턴의 겉모양이 아니라 그 패턴을 만든 의도가 더 중요하다. 따라서 의도에 따라 패턴을 구분한다.
- 프록시 패턴의 의도: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
- 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
인터페이스 기반 프록시 - 적용


- 같은 계층의 프록시 객체는 추가 기능을 부가하고 같은 계층의 실제 객체, 즉
target
을 참조하고 있다.target
은 다음 계층의 프록시 객체를 참조하고 있고 이와 같은 현상이 연쇄적으로 일어난다. 스프링에는 실제 객체가 아닌 프록시 객체를 빈으로 등록하여 어디서든 프록시 객체를 DI 받을수 있도록 한다.
@Configuration
public class InterfaceProxyConfig {
@Bean
public OrderControllerV1 orderController(LogTrace logTrace) {
OrderControllerV1Impl orderControllerImpl = new OrderControllerV1Impl(orderService(logTrace));
return new OrderControllerInterfaceProxy(orderControllerImpl, logTrace);
}
@Bean
public OrderServiceV1 orderService(LogTrace logTrace) {
OrderServiceV1Impl orderServiceImpl = new OrderServiceV1Impl(orderRepository(logTrace));
return new OrderServiceInterfaceProxy(orderServiceImpl, logTrace);
}
@Bean
public OrderRepositoryV1 orderRepository(LogTrace logTrace) {
OrderRepositoryV1Impl orderRepositoryImpl = new OrderRepositoryV1Impl();
return new OrderRepositoryInterfaceProxy(orderRepositoryImpl, logTrace);
}
}
구체 클래스 기반 프록시

public class ConcreteClient {
private ConcreteLogic concreteLogic;
public ConcreteClient(ConcreteLogic concreteLogic) {
this.concreteLogic = concreteLogic;
}
public void execute() {
concreteLogic.operation();
}
}
@Slf4j
public class ConcreteLogic {
public String operation() {
log.info("ConcreteLogic 실행");
return "data";
}
}
@Slf4j
public class TimeProxy extends ConcreteLogic {
private ConcreteLogic target;
public TimeProxy(ConcreteLogic target) {
this.target = target;
}
@Override
public String operation() {
log.info("TimeProxy 실행");
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = target.operation();
long endTime = System.currentTimeMillis();
log.info("TimeDecorator 종료 resultTime={}ms", endTime - startTime);
return result;
}
}
public class ConcreteProxyTest {
@Test
@DisplayName("addProxy")
public void addProxy() throws Exception {
TimeProxy timeProxy = new TimeProxy(new ConcreteLogic());
ConcreteClient client = new ConcreteClient(timeProxy);
client.execute();
}
}
구체 클래스 기반 프록시 - 적용
- 같은 계층의 프록시 객체는 추가 기능을 부가하고 같은 계층의 실제 객체, 즉
target
을 참조하고 있다.target
은 다음 계층의 프록시 객체를 참조하고 있고 이와 같은 현상이 연쇄적으로 일어난다. 스프링에는 실제 객체가 아닌 프록시 객체를 빈으로 등록하여 어디서든 프록시 객체를 DI 받을수 있도록 한다.
@Configuration
public class ConcreteProxyConfig {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
OrderControllerV2 orderControllerImpl = new OrderControllerV2(orderServiceV2(logTrace));
return new OrderControllerConcreteProxy(orderControllerImpl, logTrace);
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
OrderServiceV2 orderServiceImpl = new OrderServiceV2(orderRepositoryV2(logTrace));
return new OrderServiceConcreteProxy(orderServiceImpl, logTrace);
}
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
OrderRepositoryV2 orderRepositoryImpl = new OrderRepositoryV2();
return new OrderRepositoryConcreteProxy(orderRepositoryImpl, logTrace);
}
}
super(null)
: 자바 기본 문법에 의해 자식 클래스를 생성할 때는 부모 클래스의 멤버 변수 초기화를 위해 항상super()
로 부모 클래스의 생성자를 호출해야 한다. 이 부분을 생략하면 기본 생성자가 호출된다. 그런데 부모 클래스인OrderServiceV2
는 기본 생성자가 없고, 생성자에서 파라미터 1개를 필수로 받는다. 따라서 파라미터를 넣어서super(..)
를 호출해야 한다. 프록시는 부모 객체의 기능을 사용하지 않기 때문에super(null)
을 입력해도 된다. 인터페이스 기반 프록시는 이런 고민을 하지 않아도 된다.
동적 프록시 기술
리플렉션
리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다.
@Slf4j
public class ReflectionTest {
@Test
@DisplayName("reflection2")
public void reflection2() throws Exception {
Class<?> classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target);
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target);
log.info("result={}", result);
}
@Slf4j
static class Hello {
public String callA() {
log.info("A");
return "A";
}
public String callB() {
log.info("B");
return "B";
}
}
}
- 리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다. 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다. 예를 들어서 지금까지 살펴본 코드에서 getMethod(“callA”) 안에 들어가는 문자를 실수로 getMethod(“callZ”) 로 작성해도 컴파일 오류가 발생하지 않는다. 그러나 해당 코드를 직접 실행하는 시점에 발생하는 오류인 런타임 오류가 발생한다. 가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고, 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류다. 따라서 리플렉션은 일반적으로 사용하면 안된다.
JDK 동적 프록시
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어준다. 따라서 인터페이스가 필수이다.
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target;
public TimeInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = method.invoke(target);
long endTime = System.currentTimeMillis();
log.info("TimeProxy 종료 resultTime={}", endTime - startTime);
return result;
}
}
@Slf4j
public class jdkDynamicProxyTest {
@Test
@DisplayName("dynamicA")
public void dynamicA() throws Exception {
AInterface target = new AImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);
String result1 = proxy.callA();
String result2 = proxy.callB();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
@Test
@DisplayName("dynamicB")
public void dynamicB() throws Exception {
BInterface target = new BImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
BInterface proxy = (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(), new Class[]{BInterface.class}, handler);
String result = proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
}
target
을 담은TimeInvocationHandler
을 생성한다.TimeInvocationHandler
를 담은 프록시를 생성하고 프록시 메소드를 호출하면 프록시는 실행된 메소드 정보와 매개변수를TimeInvocationHandler
에 넘겨주면서invoke
를 호출한다. 여기서target
에 대한 참조를 가지고 있으므로 부가기능 외 실제 로직이 실행된다.- 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다. 만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한
InvocationHandler
만 만들어서 넣어주면 된다. 결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.


JDK 동적 프록시 - 적용
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String [] patterns;
public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
this.target = target;
this.logTrace = logTrace;
this.patterns = patterns;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
return method.invoke(target);
}
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
@Configuration
public class DynamicProxyFilterConfig {
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();
return (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class}, new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1Impl orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
return (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class}, new LogTraceFilterHandler(orderService, logTrace, PATTERNS));
}
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
return (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class}, new LogTraceFilterHandler(orderController, logTrace, PATTERNS));
}
}
PATTERNS
에 맞는 메소드만 로그가 출력된다.xxx
: xxx가 정확히 매칭되면 참xxx*
: xxx로 시작하면 참*xxx
: xxx로 끝나면 참*xxx*
: xxx가 있으면 참

CGLIB - 소개
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다. CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다. CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다. 참고로 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory
라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에, 너무 깊이있게 파기 보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다.
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private Object target;
public TimeMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable { // obj는 CGLIB가 적용된 객체
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = methodProxy.invoke(target, args); // `method`보다 성능상 이점이 있다고 한다.
long endTime = System.currentTimeMillis();
log.info("TimeProxy 종료 resultTime={}", endTime - startTime);
return result;
}
}
public class CglibTest {
@Test
@DisplayName("cglib")
public void cglib() throws Exception {
ConcreteService target = new ConcreteService();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(new TimeMethodInterceptor(target));
ConcreteService proxy = (ConcreteService) enhancer.create();
proxy.call();
}
}
target
을 담은TimeMethodInterceptor
을 생성한다.Enhancer
를 통해ConcreteService
를 상속한 프록시 객체를 만든다. 프록시 메소드 호출시 메소드 정보와 매개변수 등을TimeMethodInterceptor
의intercept
를 호출하면서 넘겨준다.TimeMethodInterceptor
은target
참조를 가지므로 부가기능 후 실제 로직을 호출한다.

스프링이 지원하는 프록시
프록시 팩토리 - 소개
스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다. 스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory
)라는 기능을 제공한다. 이전에는 상황에 따라서 JDK 동적 프록시를 사용하거나 CGLIB를 사용해야 했다면, 이제는 이 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다.
프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용한다. 그리고 이 설정을 변경할 수도 있다.

- 두 기술을 함께 사용할 때 부가 기능을 적용하기 위해 JDK 동적 프록시가 제공하는
InvocationHandler
와 CGLIB가 제공하는MethodInterceptor
를 각각 중복으로 따로 만들어야 하나 의문이 들수 있다. 스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때Advice
라는 새로운 개념을 도입했다. 개발자는InvocationHandler
나MethodInterceptor
를 신경쓰지 않고,Advice
만 만들면 된다. 결과적으로InvocationHandler
나MethodInterceptor
는Advice
를 호출하게 된다. 프록시 팩토리를 사용하면Advice
를 호출하는 전용InvocationHandler
,MethodInterceptor
를 내부에서 사용한다.

프록시 팩토리 - 예제 코드
Advice
:Advice
는 프록시에 적용하는 부가 기능 로직이다. 이것은 JDK 동적 프록시가 제공하는InvocationHandler
와 CGLIB가 제공하는MethodInterceptor
의 개념과 유사한다. 둘을 개념적으로 추상화 한 것이다. 프록시 팩토리를 사용하면 둘 대신에Advice
를 사용하면 된다.Advice
를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 다음 인터페이스를 구현하면 된다.MethodInterceptor
- 스프링이 제공하는 코드
package org.aopalliance.intercept;
public interface MethodInterceptor extends Interceptor {
@Nullable
Object invoke(@Nonnull MethodInvocation invocation) throws Throwable;
}
- 내부에는 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스,
args
, 메서드 정보 등이 포함되어 있다. 기존에 파라미터로 제공되는 부분들이 이 안으로 모두 들어갔다고 생각하면 된다. MethodInterceptor
는Interceptor
를 상속하고Interceptor
는Advice
인터페이스를 상속한다.
@Slf4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
log.info("TimeProxy 종료 resultTime={}", endTime - startTime);
return result;
}
}
invocation.proceed()
를 호출하면target
클래스를 호출하고 그 결과를 받는다.
@Slf4j
public class ProxyFactoryTest {
@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
public void interfaceProxy() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
}
@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
public void concreteProxy() throws Exception {
ConcreteService target = new ConcreteService();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB 사용, 클래스 기반 프록시 사용")
public void proxyTargetClass() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
}
- 프록시 팩토리의 기술 선택 방법
- 대상에 인터페이스가 있으면: JDK 동적 프록시, 인터페이스 기반 프록시
- 대상에 인터페이스가 없으면: CGLIB, 구체 클래스 기반 프록시
proxyTargetClass=true
: CGLIB, 구체 클래스 기반 프록시, 인터페이스 여부와 상관없음
- 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고, 매우 편리하게 동적 프록시를 생성할 수 있다. 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게
Advice
하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우InvocationHandler
가Advice
를 호출하도록 개발해두고, CGLIB인 경우MethodInterceptor
가Advice
를 호출하도록 기능을 개발해두었기 때문이다. - 스프링 부트는 AOP를 적용할 때 기본적으로
proxyTargetClass=true
로 설정해서 사용한다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.
포인트컷, 어드바이스, 어드바이저 - 소개
- 포인트컷(
Pointcut
): 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링 한다. 이름 그대로 어떤 포인트(Point)에 기능을 적용할지 하지 않을지 잘라서(cut) 구분하는 것이다. - 어드바이스(
Advice
): 이전에 본 것 처럼 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각하면 된다. - 어드바이저(
Advisor
): 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이다. 쉽게 이야기해서 포인트컷1 + 어드바이스1이다.

Pointcut
을 통해Advice
부가기능 적용 여부를 판단하고 false인 경우 바로target
을 호출하고 true이면Advice
를 호출한다.
예제 코드1 - 어드바이저
@Slf4j
public class AdvisorTest {
@Test
@DisplayName("advisorTest1")
public void advisorTest1() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
@Test
@DisplayName("직접 만든 PointCut")
public void advisorTest2() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointCut(), new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
@Test
@DisplayName("스프링이 만든 PointCut")
public void advisorTest3() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
NameMatchMethodPointcut nameMatchMethodPointcut = new NameMatchMethodPointcut();
nameMatchMethodPointcut.setMappedName("save");
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(nameMatchMethodPointcut, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
static class MyPointCut implements Pointcut {
@Override
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
@Override
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save";
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);
log.info("포인트컷 호출 method={} targetClass={}", method.getName(), targetClass);
log.info("포인트컷 결과 result={}", result);
return result;
}
@Override
public boolean isRuntime() {
return false;
}
@Override
public boolean matches(Method method, Class<?> targetClass, Object... args) {
return false;
}
}
}
MyMethodMatcher
isRuntime()
,matches(... args)
:isRuntime()
이 값이 참이면matches(... args)
메서드가 대신 호출된다. 동적으로 넘어오는 매개변수를 판단 로직으로 사용할 수 있다.isRuntime()
이 false 인 경우 클래스의 정적 정보만 사용하기 때문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만,isRuntime()
이 true 인 경우 매개변수가 동적으로 변경된다고 가정하기 때문에 캐싱을 하지 않는다.
- 스프링이 제공하는 포인트컷
NameMatchMethodPointcut
: 메서드 이름을 기반으로 매칭한다. 내부에서는PatternMatchUtils
를 사용한다. 예)*xxx*
허용JdkRegexpMethodPointcut
: JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.TruePointcut
: 항상 참을 반환한다.AnnotationMatchingPointcut
: 애노테이션으로 매칭한다.AspectJExpressionPointcut
: aspectJ 표현식으로 매칭한다.
여러 어드바이저 함께 적용
@Slf4j
public class MultiAdvisorTest {
@Test
@DisplayName("여러 프록시")
public void multiAdvisorTest1() throws Exception {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
proxyFactory2.addAdvisor(advisor2);
ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
proxy2.save();
proxy2.find();
}
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
public void multiAdvisorTest12() throws Exception {
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvisor(advisor2); // 등록 순서대로 작동
proxyFactory.addAdvisor(advisor1);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
static class Advice1 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice1 호출");
return invocation.proceed();
}
}
static class Advice2 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice2 호출");
return invocation.proceed();
}
}
}
- 등록하는 순서대로
advisor
가 호출된다. - 스프링의 AOP를 처음 공부하거나 사용하면, AOP 적용수 만큼 프록시가 생성된다고 착각하게 된다. 스프링은 AOP를 적용할 때, 최적화를 진행해서 지금처럼 프록시는 하나만 만들고, 하나의 프록시에 여러 어드바이저를 적용한다. 정리하면 하나의
target
에 여러 AOP가 동시에 적용되어도, 스프링의 AOP는target
마다 하나의 프록시만 생성한다.
프록시 팩토리 - 적용1(JDK)
public class LogTraceAdvice implements MethodInterceptor {
private final LogTrace logTrace;
public LogTraceAdvice(LogTrace logTrace) {
this.logTrace = logTrace;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
TraceStatus status = null;
try {
Method method = invocation.getMethod();
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
Object result = invocation.proceed();
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();
ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderRepositoryV1) proxyFactory.getProxy();
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1Impl orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderService);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderServiceV1) proxyFactory.getProxy();
}
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderController);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderControllerV1) proxyFactory.getProxy();
}
private Advisor getAdvisor(LogTrace logTrace) {
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
프록시 팩토리 - 적용2(CGLIB)
@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
OrderRepositoryV2 orderRepository = new OrderRepositoryV2();
ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderRepositoryV2) proxyFactory.getProxy();
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
OrderServiceV2 orderService = new OrderServiceV2(orderRepositoryV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderService);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderServiceV2) proxyFactory.getProxy();
}
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderController);
proxyFactory.addAdvisor(getAdvisor(logTrace));
return (OrderControllerV2) proxyFactory.getProxy();
}
private Advisor getAdvisor(LogTrace logTrace) {
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
빈 후처리기
빈 후처리기 - 소개
스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용하면 된다. 빈 포스트 프로세서(BeanPostProcessor
)는 번역하면 빈 후처리기인데, 이름 그대로 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다. 객체를 조작할 수도 있고, 완전히 다른 객체로 바꿔치기 하는 것도 가능하다. @Postconstruct
는 스프링이 기본으로 제공하는 BeanPostProcessor
구현체이다. (CommonAnnotationBeanPostProcessor
)
빈 후처리기 - 예제 코드
- 빈 후처리기를 사용하려면
BeanPostProcessor
인터페이스를 구현하고, 스프링 빈으로 등록하면 된다. postProcessBeforeInitialization
: 객체 생성 이후에@PostConstruct
같은 초기화가 발생하기 전에 호출되는 포스트 프로세서이다.postProcessAfterInitialization
: 객체 생성 이후에@PostConstruct
같은 초기화가 발생한 다음에 호출되는 포스트 프로세서이다.
public class BeanPostProcessorTest {
@Test
@DisplayName("basicConfig")
public void basicConfig() throws Exception {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
B b = applicationContext.getBean("beanA", B.class);
b.helloB();
assertThatThrownBy(() -> applicationContext.getBean(A.class)).isInstanceOf(NoSuchBeanDefinitionException.class);
}
@Slf4j
@Configuration
static class BeanPostProcessorConfig {
@Bean("beanA")
public A a() {
return new A();
}
@Bean
public AToBPostProcessor aToBPostProcessor() {
return new AToBPostProcessor();
}
}
@Slf4j
static class A {
public void helloA() {
log.info("A");
}
}
@Slf4j
static class B {
public void helloB() {
log.info("B");
}
}
@Slf4j
static class AToBPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("beanName={} bean={}", beanName, bean);
if (bean instanceof A) {
return new B();
}
return bean;
}
}
}
- 빈 후처리기는 빈을 조작하고 변경할 수 있는 후킹 포인트이다. 이것은 빈 객체를 조작하거나 심지어 다른 객체로 바꾸어 버릴 수 있을 정도로 막강하다. 일반적으로 스프링 컨테이너가 등록하는, 특히 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작할 방법이 없는데, 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있다. 이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다.
빈 후처리기 - 적용
@Slf4j
public class PackageLogTracePostProcessor implements BeanPostProcessor {
private final String basePackage; // 프록시를 생성할 클래스의 기준 패키지
private final Advisor advisor; // 프록시 생성시에는 advisor 필요
public PackageLogTracePostProcessor(String basePackage, Advisor advisor) {
this.basePackage = basePackage;
this.advisor = advisor;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("param beanName={} bean={}", beanName, bean.getClass());
String packageName = bean.getClass().getPackageName();
if (!packageName.startsWith(basePackage)) {
return bean;
}
ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(advisor);
Object proxy = proxyFactory.getProxy();
log.info("create proxy: target={} proxy={}", bean.getClass(), proxy.getClass());
return proxy;
}
}
@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class}) // version별로 구현체가 컨테이너에 등록이 되어 있어야 각각 프록시 객체로 변환 후 컨테이너에 등록이 된다.
public class BeanPostProcessorConfig {
@Bean
public PackageLogTracePostProcessor logTracePostProcessor(LogTrace logTrace) {
return new PackageLogTracePostProcessor("hello.proxy.app", getAdvisor(logTrace));
}
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor = pointcut + advice
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
- v1: 인터페이스가 있으므로 JDK 동적 프록시가 적용된다.
- v2: 구체 클래스만 있으므로 CGLIB 프록시가 적용된다.
- v3: 구체 클래스만 있으므로 CGLIB 프록시가 적용된다.
- 여기서 중요한 포인트는 v1, v2와 같이 수동으로 등록한 빈 뿐만 아니라 컴포넌트 스캔을 통해 등록한 v3 빈들도 프록시를 적용할 수 있다는 점이다. 이것은 모두 빈 후처리기 덕분이다.
Pointcut
프록시의 적용 대상 여부를 여기서는 간단히 패키지를 기준으로 설정했다. 그런데 잘 생각해보면 포인트컷을 사용하면 더 깔끔할 것 같다. 포인트컷은 이미 클래스, 메서드 단위의 필터 기능을 가지고 있기 때문에, 프록시 적용 대상 여부를 정밀하게 설정할 수 있다. 참고로 어드바이저는 포인트컷을 가지고 있다. 따라서 어드바이저를 통해 포인트컷을 확인할 수 있다. 결과적으로 포인트컷은 다음 두 곳에 사용된다.
- 프록시 적용 대상 여부를 체크해서 꼭 필요한 곳에만 프록시를 적용한다. (빈 후처리기 - 자동 프록시 생성)
- 프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할 지 판단한다. (프록시 내부)
스프링이 제공하는 빈 후처리기1
implementation 'org.springframework.boot:spring-boot-starter-aop'
- 이 라이브러리를 추가하면
aspectjweaver
라는aspectJ
관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다. 스프링 부트가 활성화하는 빈은AopAutoConfiguration
를 참고하자.
자동 프록시 생성기 - AutoProxyCreator
- 앞서 이야기한 스프링 부트 자동 설정으로
AnnotationAwareAspectJAutoProxyCreator
라는 빈 후처리기가 스프링 빈에 자동으로 등록된다. 이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기이다. - 이 빈 후처리기는 스프링 빈으로 등록된
Advisor
들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. Advisor
안에는Pointcut
과Advice
가 이미 모두 포함되어 있다. 따라서Advisor
만 알고 있으면 그 안에 있는Pointcut
으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다. 그리고Advice
로 부가 기능을 적용하면 된다.AnnotationAwareAspectJAutoProxyCreator
는@AspectJ
와 관련된 AOP 기능도 자동으로 찾아서 처리해준다.Advisor
는 물론이고,@Aspect
도 자동으로 인식해서 프록시를 만들고 AOP를 적용해준다.- 앞서 조회한
Advisor
에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 이때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 그래서 조건이 하나라도 만족하면 프록시 적용 대상이 된다. 예를 들어서 10개의 메서드 중에 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
@Bean
public Advisor advisor1(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor = pointcut + advice
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
- 빈 후처리기는 이제 등록하지 않아도 된다. 스프링은 자동 프록시 생성기라는 (
AnnotationAwareAspectJAutoProxyCreator
) 빈 후처리기를 자동으로 등록해준다.
스프링이 제공하는 빈 후처리기2
애플리케이션 서버를 실행해보면, 스프링이 초기화 되면서 기대하지 않은 로그들이 올라온다. 그 이유는 지금 사용한 포인트컷이 단순히 메서드 이름에 "request*", "order*", "save*"
만 포함되어 있으면 매칭 된다고 판단하기 때문이다. 결국 스프링이 내부에서 사용하는 빈에도 메서드 이름에 request
라는 단어만 들어가 있으면 프록시가 만들어지고 되고, 어드바이스도 적용되는 것이다. 결론적으로 패키지에 메서드 이름까지 함께 지정할 수 있는 매우 정밀한 포인트컷이 필요하다.
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
@Bean
public Advisor advisor2(LogTrace logTrace) {
//pointcut
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..))");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor = pointcut + advice
return new DefaultPointcutAdvisor(pointcut, advice);
}
@Bean
public Advisor advisor3(LogTrace logTrace) {
//pointcut
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor = pointcut + advice
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
AspectJExpressionPointcut
: AspectJ 포인트컷 표현식을 적용할 수 있다.execution(* hello.proxy.app..*(..))
: AspectJ가 제공하는 포인트컷 표현식이다.*
: 모든 반환 타입hello.proxy.app..
: 해당 패키지와 그 하위 패키지*(..)
:*
모든 메서드 이름,(..)
파라미터는 상관 없음- 쉽게 이야기해서
hello.proxy.app
패키지와 그 하위 패키지의 모든 메서드는 포인트컷의 매칭 대상이 된다.
하나의 프록시, 여러 Advisor 적용
예를 들어서 어떤 스프링 빈이 advisor1
, advisor2
가 제공하는 포인트컷의 조건을 모두 만족하면 프록시 자동 생성기는 프록시를 몇 개 생성할까? 프록시 자동 생성기는 프록시를 하나만 생성한다. 왜냐하면 프록시 팩토리가 생성하는 프록시는 내부에 여러 advisor
들을 포함할 수 있기 때문이다. 따라서 프록시를 여러 개 생성해서 비용을 낭비할 이유가 없다.
- 프록시 자동 생성기 상황별 정리
advisor1
의 포인트컷만 만족 프록시1개 생성, 프록시에advisor1
만 포함advisor1
,advisor2
의 포인트컷을 모두 만족 프록시1개 생성, 프록시에advisor1
,advisor2
모두 포함advisor1
,advisor2
의 포인트컷을 모두 만족하지 않음 프록시가 생성되지 않음
@Aspect AOP
@Aspect 프록시 - 적용
스프링 애플리케이션에 프록시를 적용하려면 포인트컷과 어드바이스로 구성되어 있는 어드바이저 (Advisor
)를 만들어서 스프링 빈으로 등록하면 된다. 그러면 나머지는 앞서 배운 자동 프록시 생성기가 모두 자동으로 처리해준다. 자동 프록시 생성기는 스프링 빈으로 등록된 어드바이저들을 찾고, 스프링 빈들에 자동으로 프록시를 적용해준다. (물론 포인트컷이 매칭되는 경우에 프록시를 생성한다.)
스프링은 @Aspect
애노테이션으로 매우 편리하게 포인트컷과 어드바이스로 구성되어 있는 어드바이저 생성 기능을 지원한다.
@Aspect
는 관점 지향 프로그래밍(AOP)을 가능하게 하는 AspectJ 프로젝트에서 제공하는 애노테이션이다. 스프링은 이것을 차용해서 프록시를 통한 AOP를 가능하게 한다.
@Slf4j
@Aspect // Advisor로 등록되기 위해서는 빈으로 등록되어야 한다.
public class LogTraceAspect {
private final LogTrace logTrace;
public LogTraceAspect(LogTrace logTrace) {
this.logTrace = logTrace;
}
@Around("execution(* hello.proxy.app..*(..))") // Pointcut
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { // Advice
TraceStatus status = null;
try {
String message = joinPoint.getSignature().toShortString();
status = logTrace.begin(message);
Object result = joinPoint.proceed();
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
@Aspect
: 애노테이션 기반 프록시를 적용할 때 필요하다.@Around("execution(* hello.proxy.app..*(..))")
@Around
의 값에 포인트컷 표현식을 넣는다. 표현식은 AspectJ 표현식을 사용한다.@Around
의 메서드는 어드바이스(Advice
)가 된다.
ProceedingJoinPoint joinPoint
: 어드바이스에서 살펴본MethodInvocation invocation
과 유사한 기능이다. 내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지 정보가 포함되어 있다.joinPoint.proceed()
: 실제 호출 대상(target
)을 호출한다.
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AopConfig {
@Bean
public LogTraceAspect logTraceAspect(LogTrace logTrace) {
return new LogTraceAspect(logTrace);
}
}
@Aspect 프록시 - 설명
자동 프록시 생성기(AnnotationAwareAspectJAutoProxyCreator
)는 Advisor
를 자동으로 찾아와서 필요한 곳에 프록시를 생성하고 적용해준다고 했다. 자동 프록시 생성기는 여기에 추가로 하나의 역할을 더 하는데, 바로 @Aspect
를 찾아서 이것을 Advisor
로 만들어준다. 쉽게 이야기해서 @Aspect
를 Advisor
로 변환해서 저장하는 기능도 한다. 그래서 이름 앞에 AnnotationAware
(애노테이션을 인식하는)가 붙어 있는 것이다.



@Aspect
어드바이저 빌더:BeanFactoryAspectJAdvisorsBuilder
클래스이다.@Aspect
의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관하는 것을 담당한다.@Aspect
의 정보를 기반으로 어드바이저를 만들고,@Aspect
어드바이저 빌더 내부 저장소에 캐시한다. 캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에 저장된 어드바이저를 반환한다.
스프링 AOP 개념
AOP 소개 - 애스펙트
핵심 기능과 부가 기능을 분리
부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리하도록 했다. 그리고 해당 부가 기능을 어디에 적용할지 선택하는 기능도 만들었다. 이렇게 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만들었는데 이것이 바로 애스펙트(aspect)이다. 애스펙트는 쉽게 이야기해서 부가 기능과, 해당 부가 기능을 어디에 적용할지 정의한 것이다. 예를 들어서 로그 출력 기능을 모든 컨트롤러에 적용해라 라는 것이 정의되어 있다. 바로 우리가 이전에 알아본 @Aspect
바로 그것이다. 그리고 스프링이 제공하는 어드바이저도 어드바이스(부가 기능)과 포인트컷(적용 대상)을 가지고 있어서 개념상 하나의 애스펙트이다. 애스펙트는 우리말로 해석하면 관점이라는 뜻인데, 이름 그대로 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사(cross-cutting concerns) 관점으로 달리 보는 것이다. 이렇게 애스펙트를 사용한 프로그래밍 방식을 관점 지향 프로그래밍 AOP(Aspect-Oriented Programming)이라 한다. 참고로 AOP는 OOP를 대체하기 위한 것이 아니라 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.

AspectJ 프레임워크
AOP의 대표적인 구현으로 AspectJ 프레임워크(https://www.eclipse.org/aspectj/)가 있다. 물론 스프링도 AOP를 지원하지만 대부분 AspectJ의 문법을 차용하고, AspectJ가 제공하는 기능의 일부만 제공한다.
- 컴파일 시점:
.java
소스 코드를 컴파일러를 사용해서.class
를 만드는 시점에 부가 기능 로직을 추가할 수 있다. 이때는 AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다. 컴파일 된.class
를 디컴파일 해보면 애스펙트 관련 호출 코드가 들어간다. 이해하기 쉽게 풀어서 이야기하면 부가 기능 코드가 핵심 기능이 있는 컴파일된 코드 주변에 실제로 붙어 버린다고 생각하면 된다. AspectJ 컴파일러는 Aspect를 확인해서 해당 클래스가 적용 대상인지 먼저 확인하고, 적용 대상인 경우에 부가 기능 로직을 적용한다. 참고로 이렇게 원본 로직에 부가 기능 로직이 추가되는 것을 위빙(Weaving)이라 한다. - 클래스 로딩 시점: 자바를 실행하면 자바 언어는
.class
파일을 JVM 내부의 클래스 로더에 보관한다. 이때 중간에서.class
파일을 조작한 다음 JVM에 올릴 수 있다. 자바 언어는.class
를 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다. 참고로 수 많은 모니터링 툴들이 이 방식을 사용한다. 이 시점에 애스펙트를 적용하는 것을 로드 타임 위빙이라 한다. - 런타임 시점(프록시): 런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을 말한다. 자바의 메인(
main
) 메서드가 이미 실행된 다음이다. 따라서 자바 언어가 제공하는 범위 안에서 부가 기능을 적용해야 한다. 스프링과 같은 컨테이너의 도움을 받고 프록시와 DI, 빈 포스트 프로세서 같은 개념들을 총 동원해야 한다. 이렇게 하면 최종적으로 프록시를 통해 스프링 빈에 부가 기능을 적용할 수 있다.
AOP 적용 위치
- AOP는 메서드 실행 위치 뿐만 아니라 다음과 같은 다양한 위치에 적용할 수 있다.
- 적용 가능 지점(조인 포인트): 생성자, 필드 값 접근, static 메서드 접근, 메서드 실행
- 이렇게 AOP를 적용할 수 있는 지점을 조인 포인트(Join point)라 한다.
- AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트코드를 실제 조작하기 때문에 해당 기능을 모든 지점에 다 적용할 수 있다.
- 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
- 프록시는 메서드 오버라이딩 개념으로 동작한다. 따라서 생성자나 static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없다.
- 프록시를 사용하는 스프링 AOP의 조인 포인트는 메서드 실행으로 제한된다.
- 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.
AOP 용어 정리

- 조인 포인트(Join point)
- 어드바이스가 적용될 수 있는 위치, 메소드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 같은 프로그램 실행 중 지점
- 조인 포인트는 추상적인 개념이다. AOP를 적용할 수 있는 모든 지점이라 생각하면 된다.
- 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메소드 실행 지점으로 제한된다.
- 포인트컷(Pointcut)
- 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
- 주로 AspectJ 표현식을 사용해서 지정
- 프록시를 사용하는 스프링 AOP는 메서드 실행 지점만 포인트컷으로 선별 가능
- 타켓(Target)
- 어드바이스를 받는 객체, 포인트컷으로 결정
- 어드바이스(Advice)
- 부가 기능
- 특정 조인 포인트에서 Aspect에 의해 취해지는 조치
- Around(주변), Before(전), After(후)와 같은 다양한 종류의 어드바이스가 있음
- 애스펙트(Aspect)
- 어드바이스 + 포인트컷을 모듈화 한 것
@Aspect
를 생각하면 됨- 여러 어드바이스와 포인트 컷이 함께 존재
- 어드바이저(Advisor)
- 하나의 어드바이스와 하나의 포인트 컷으로 구성
- 스프링 AOP에서만 사용되는 특별한 용어
- AOP 프록시
- AOP 기능을 구현하기 위해 만든 프록시 객체, 스프링에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.
스프링 AOP 구현
스프링 AOP 구현1 - 시작
@Slf4j
@Aspect
public class AspectV1 {
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Around
애노테이션의 값인execution(* hello.aop.order..*(..))
는 포인트컷이 된다.@Around
애노테이션의 메서드인doLog
는 어드바이스(Advice
)가 된다.execution(* hello.aop.order..*(..))
는hello.aop.order
패키지와 그 하위 패키지(..
)를 지정하는 AspectJ 포인트컷 표현식이다.- 이제
OrderService
,OrderRepository
의 모든 메서드는 AOP 적용의 대상이 된다. 참고로 스프링은 프록시 방식의 AOP를 사용하므로 프록시를 통하는 메서드만 적용 대상이 된다. - 스프링 AOP는 AspectJ의 문법을 차용하고, 프록시 방식의 AOP를 제공한다. AspectJ를 직접 사용하는 것이 아니다. 스프링 AOP를 사용할 때는
@Aspect
애노테이션을 주로 사용하는데, 이 애노테이션도 AspectJ가 제공하는 애노테이션이다. @Aspect
를 포함한org.aspectj
패키지 관련 기능은aspectjweaver.jar
라이브러리가 제공하는 기능이다.build.gradle
에spring-boot-starter-aop
를 포함했는데, 이렇게 하면 스프링의 AOP 관련 기능과 함께aspectjweaver.jar
도 함께 사용할 수 있게 의존 관계에 포함된다. 그런데 스프링에서는 AspectJ가 제공하는 애노테이션이나 관련 인터페이스만 사용하는 것이고, 실제 AspectJ가 제공하는 컴파일, 로드타임 위버 등을 사용하는 것은 아니다. 스프링은 프록시 방식의 AOP를 사용한다.@Aspect
는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다. 따라서AspectV1
를 AOP로 사용하려면 스프링 빈으로 등록해야 한다.
스프링 AOP 구현2 - 포인트컷 분리
@Slf4j
@Aspect
public class AspectV2 {
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder() {}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Pointcut
에 포인트컷 표현식을 사용한다.- 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
- 메서드의 반환 타입은 void 여야 한다.
- 코드 내용은 비워둔다.
- 포인트컷 시그니처는
allOrder()
이다. 이름 그대로 주문과 관련된 모든 기능을 대상으로 하는 포인트컷이다. @Around
어드바이스에서는 포인트컷을 직접 지정해도 되지만, 포인트컷 시그니처를 사용해도 된다. 여기서는@Around("allOrder()")
를 사용한다.private
,public
같은 접근 제어자는 내부에서만 사용하면private
을 사용해도 되지만, 다른 애스팩트에서 참고하려면public
을 사용해야 한다.
스프링 AOP 구현3 - 어드바이스 추가
@Slf4j
@Aspect
public class AspectV3 {
//hello.aop.order 패키지와 하위 패키지
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder() {
}
//클래스 이름 패턴이 *Service
@Pointcut("execution(* *..*Service.*(..))")
private void allService() {
}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("allOrder() && allService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
allOrder()
포인트컷은hello.aop.order
패키지와 하위 패키지를 대상으로 한다.allService()
포인트컷은 타입 이름 패턴이*Service
를 대상으로 하는데 쉽게 이야기해서XxxService
처럼Service
로 끝나는 것을 대상으로 한다.*Servi*
과 같은 패턴도 가능하다. 여기서 타입 이름 패턴이라고 한 이유는 클래스, 인터페이스에 모두 적용되기 때문이다.@Around("allOrder() && allService()")
- 포인트컷은 이렇게 조합할 수 있다.
&&
(AND),||
(OR),!
(NOT) 3가지 조합이 가능하다. hello.aop.order
패키지와 하위 패키지 이면서 타입 이름 패턴이*Service
인 것을 대상으로 한다. 결과적으로doTransaction()
어드바이스는OrderService
에만 적용된다.
- 포인트컷은 이렇게 조합할 수 있다.
doLog()
어드바이스는OrderService
,OrderRepository
에 모두 적용된다.
스프링 AOP 구현4 - 포인트컷 참조
public class Pointcuts {
@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder() {
}
@Pointcut("execution(* *..*Service.*(..))")
public void allService() {
}
@Pointcut("allOrder() && allService()")
public void orderAndService() {
}
}
@Slf4j
@Aspect
public class AspectV4Pointcut {
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
스프링 AOP 구현5 - 어드바이스 순서
어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect
적용 단위로 org.springframework.core.annotation.@Order
애노테이션을 적용해야 한다. 문제는 이것을 어드바이스 단위가 아니라 클래스 단위로 적용할 수 있다는 점이다. 그래서 지금처럼 하나의 애스펙트에 여러 어드바이스가 있으면 순서를 보장 받을 수 없다. 따라서 애스펙트를 별도의 클래스로 분리해야 한다.
@Slf4j
public class AspectV5Order {
@Aspect
@Order(2)
public static class LogAspect {
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Aspect
@Order(1)
public static class TxAspect {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
}
}
@Around
와 같은 동일한 어드바이스에게 순서를 매기기 위해서는 각 어드바이스별로 내부 클래스를 만들어서@Order
를 사용한다.
스프링 AOP 구현6 - 어드바이스 종류
@Around
: 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능@Before
: 조인 포인트 실행 이전에 실행@AfterReturning
: 조인 포인트가 정상 완료후 실행@AfterThrowing
: 메서드가 예외를 던지는 경우 실행@After
: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)
@Slf4j
@Aspect
public class AspectV6Advice {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
//@Before
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
//@AfterReturning
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
//@AfterThrowing
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally {
//@After
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doBefore(JoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
@AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning = "result")
public void doReturn(JoinPoint joinPoint, Object result) {
log.info("[return] {} return={}", joinPoint.getSignature(), result);
}
@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
log.info("[ex] {} message={}", joinPoint.getSignature(), ex.getMessage());
}
@After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAfter(JoinPoint joinPoint) {
log.info("[after] {}", joinPoint.getSignature());
}
}
- 모든 어드바이스는
org.aspectj.lang.JoinPoint
를 첫번째 파라미터에 사용할 수 있다. (생략해도 된다.) 단@Around
는ProceedingJoinPoint
을 사용해야 한다.ProceedingJoinPoint
는org.aspectj.lang.JoinPoint
의 하위 타입이다.
@Before
@Around
와 다르게 작업 흐름을 변경할 수는 없다. @Around
는 ProceedingJoinPoint.proceed()
를 호출해야 다음 대상이 호출된다. 만약 호출하지 않으면 다음 대상이 호출되지 않는다. 반면에 @Before
는 ProceedingJoinPoint.proceed()
자체를 사용하지 않는다. 메서드 종료시 자동으로 다음 타켓이 호출된다. 물론 예외가 발생하면 다음 코드가 호출되지는 않는다.
@AfterReturning
returning
속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다. returning
절에 지정된 타입의 값을 반환하는 메서드만 대상으로 실행한다. (부모 타입을 지정하면 모든자식 타입은 인정된다.) @Around
와 다르게 반환되는 객체를 변경할 수는 없다. 반환 객체를 변경하려면 @Around
를 사용해야 한다. 참고로 반환 객체를 조작할 수 는 있다.
@AfterThrowing
throwing
속성에 사용된 이름은 어드바이스 메서드의 매개변수 이름과 일치해야 한다. throwing
절에 지정된 타입과 맞은 예외를 대상으로 실행한다. (부모 타입을 지정하면 모든 자식 타입은 인정된다.)
@After
메서드 실행이 종료되면 실행된다. (finally를 생각하면 된다.) 정상 및 예외 반환 조건을 모두 처리한다. 일반적으로 리소스를 해제하는 데 사용한다.
순서
- 스프링은 5.2.7 버전부터 동일한
@Aspect
안에서 동일한 조인포인트의 우선순위를 정했다. - 실행 순서:
@Around
,@Before
,@After
,@AfterReturning
,@AfterThrowing
- 물론
@Aspect
안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다. 이 경우 앞서 배운 것 처럼@Aspect
를 클래스 단위로 분리하고@Order
를 적용하자.
스프링 AOP - 포인트컷
포인트컷 지시자
애스펙트J는 포인트컷을 편리하게 표현하기 위한 특별한 표현식을 제공한다. 예) @Pointcut("execution(* hello.aop.order..*(..))")
포인트컷 표현식은 AspectJ pointcut expression 즉 애스펙트J가 제공하는 포인트컷 표현식을 줄여서 말하는 것이다.
- 포인트컷 지시자: 포인트컷 표현식은
execution
같은 포인트컷 지시자(Pointcut Designator)로 시작한다. 줄여서 PCD라 한다. - 포인트컷 지시자의 종류 종류
execution
: 메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용하고, 기능도 복잡하다.within
: 특정 타입 내의 조인 포인트를 매칭한다.args
: 인자가 주어진 타입의 인스턴스인 조인 포인트this
: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트target
: Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트@target
: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트@within
: 주어진 애노테이션이 있는 타입 내 조인 포인트@annotation
: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭@args
: 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트bean
: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ClassAop {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodAop {
String value();
}
@ClassAop
@Component
public class MemberServiceImpl implements MemberService {
@Override
@MethodAop(value = "test value")
public String hello(String param) {
return "ok";
}
public String internal(String param) {
return "ok";
}
}
execution
execution(접근제어자? 반환타입 선언타입?메서드이름(파라미터) 예외?)
- 메소드 실행 조인 포인트를 매칭한다.
- ?는 생략할 수 있다.
*
같은 패턴을 지정할 수 있다.
@Slf4j
public class ExecutionTest {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
Method helloMethod;
@BeforeEach
public void init() throws NoSuchMethodException {
helloMethod = MemberServiceImpl.class.getMethod("hello", String.class);
}
@Test
@DisplayName("printMethod")
public void printMethod() throws Exception {
// helloMethod=public java.lang.String hello.aop.member.MemberServiceImpl.hello(java.lang.String)
log.info("helloMethod={}", helloMethod);
}
@Test
@DisplayName("exactMatch")
public void exactMatch() throws Exception {
pointcut.setExpression("execution(public String hello.aop.member.MemberServiceImpl.hello(String))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("allMatch")
public void allMatch() throws Exception {
pointcut.setExpression("execution(* *(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("nameMatch")
public void nameMatch() throws Exception {
pointcut.setExpression("execution(* hello(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("nameMatchStar1")
public void nameMatchStar1() throws Exception {
pointcut.setExpression("execution(* hel*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("nameMatchStar2")
public void nameMatchStar2() throws Exception {
pointcut.setExpression("execution(* *el*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("packageExactMatch1")
public void packageExactMatch1() throws Exception {
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl.hello(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("packageExactMatch2")
public void packageExactMatch2() throws Exception {
pointcut.setExpression("execution(* hello.aop.member.*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("packageExactMatch3")
public void packageExactMatch3() throws Exception {
pointcut.setExpression("execution(* hello.aop.*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
@Test
@DisplayName("packageMatchSubPackage1")
public void packageMatchSubPackage1() throws Exception {
pointcut.setExpression("execution(* hello.aop.member..*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("packageMatchSubPackage2")
public void packageMatchSubPackage2() throws Exception {
pointcut.setExpression("execution(* hello.aop..*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("typeExactMatch")
public void typeExactMatch() throws Exception {
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("typeMatchSuperType")
public void typeMatchSuperType() throws Exception {
pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void typeMatchInternal() throws NoSuchMethodException {
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl. * (..))");
Method internalMethod = MemberServiceImpl.class.getMethod("internal", String.class);
assertThat(pointcut.matches(internalMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("typeMatchNoSuperTypeMethodFalse")
public void typeMatchNoSuperTypeMethodFalse() throws Exception {
pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
Method internalMethod = MemberServiceImpl.class.getMethod("internal", String.class);
assertThat(pointcut.matches(internalMethod, MemberServiceImpl.class)).isFalse();
}
@Test
@DisplayName("argsMatch")
public void argsMatch() throws Exception {
pointcut.setExpression("execution(* *(String))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("argsMatchNoArgs")
public void argsMatchNoArgs() throws Exception {
pointcut.setExpression("execution(* *())");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
@Test
@DisplayName("argsMatchStar")
public void argsMatchStar() throws Exception {
pointcut.setExpression("execution(* *(*))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("argsMatchAll")
public void argsMatchAll() throws Exception {
pointcut.setExpression("execution(* *(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("argsMatchComplex")
public void argsMatchComplex() throws Exception {
pointcut.setExpression("execution(* *(String, ..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
}
within
within
지시자는 특정 타입 내의 조인 포인트에 대한 매칭을 제한한다. 쉽게 이야기해서 해당 타입이 매칭되면 그 안의 메서드(조인 포인트)들이 자동으로 매칭된다. 문법은 단순한데execution
에서 타입 부분만 사용한다고 보면 된다.- 표현식에 부모 타입을 지정하면 안되고 정확하게 타입이 맞아야 한다. 이 부분에서
execution
과 차이가 난다.
public class WithinTest {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
Method helloMethod;
@BeforeEach
public void init() throws NoSuchMethodException {
helloMethod = MemberServiceImpl.class.getMethod("hello", String.class);
}
@Test
void withinExact() {
pointcut.setExpression("within(hello.aop.member.MemberServiceImpl)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinStar() {
pointcut.setExpression("within(hello.aop.member.*Service*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void withinSubPackage() {
pointcut.setExpression("within(hello.aop..*)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
@DisplayName("타켓의 타입에만 직접 적용, 인터페이스를 선정하면 안된다.")
void withinSuperTypeFalse() {
pointcut.setExpression("within(hello.aop.member.MemberService)");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
@Test
@DisplayName("execution은 타입 기반, 인터페이스를 선정 가능.")
void executionSuperTypeTrue() {
pointcut.setExpression("execution(* hello.aop.member.MemberService. * (..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
}
args
args
: 인자가 주어진 타입의 인스턴스인 조인 포인트로 매칭- 기본 문법은
execution
의args
부분과 같다. execution
은 파라미터 타입이 정확하게 매칭되어야 한다.execution
은 클래스에 선언된 정보를 기반으로 판단한다.args
는 부모 타입을 허용한다.args
는 실제 넘어온 파라미터 객체 인스턴스를 보고 판단한다.
public class ArgsTest {
Method helloMethod;
@BeforeEach
public void init() throws NoSuchMethodException {
helloMethod = MemberServiceImpl.class.getMethod("hello", String.class);
}
private AspectJExpressionPointcut pointcut(String expression) {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(expression);
return pointcut;
}
@Test
void args() {
assertThat(pointcut("args(String)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(Object)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args()").matches(helloMethod, MemberServiceImpl.class)).isFalse();
assertThat(pointcut("args(..)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(*)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(String,..)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void argsVsExecution() {
assertThat(pointcut("args(String)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(java.io.Serializable)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("args(Object)").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("execution(* *(String))").matches(helloMethod, MemberServiceImpl.class)).isTrue();
assertThat(pointcut("execution(* *(java.io.Serializable))").matches(helloMethod, MemberServiceImpl.class)).isFalse();
assertThat(pointcut("execution(* *(Object))").matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
}
- 자바가 기본으로 제공하는
String
은Object
,java.io.Serializable
의 하위 타입이다. - 정적으로 클래스에 선언된 정보만 보고 판단하는
execution(* *(Object))
는 매칭에 실패한다. - 동적으로 실제 파라미터로 넘어온 객체 인스턴스로 판단하는
args(Object)
는 매칭에 성공한다. (부모 타입 허용)
@target, @within
@target
: 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트@within
: 주어진 애노테이션이 있는 타입 내 조인 포인트@target(hello.aop.member.annotation.ClassAop)
@within(hello.aop.member.annotation.ClassAop)
@target
은 인스턴스의 모든 메서드를 조인 포인트로 적용한다.@within
은 해당 타입 내에 있는 메서드만 조인 포인트로 적용한다. 쉽게 이야기해서@target
은 부모 클래스의 메서드까지 어드바이스를 다 적용하고,@within
은 자기 자신의 클래스에 정의된 메서드에만 어드바이스를 적용한다.- 다음 포인트컷 지시자는 단독으로 사용하면 안된다.
args
,@args
,@target
은 실제 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다. 실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단할 수 있다. 프록시가 없다면 판단 자체가 불가능하다. 그런데 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점에 적용할 수 있다. 따라서args
,@args
,@target
같은 포인트컷 지시자가 있으면 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다. 프록시가 없으면 실행 시점에 판단 자체가 불가능하다. 문제는 이렇게 모든 스프링 빈에 AOP 프록시를 적용하려고 하면 스프링이 내부에서 사용하는 빈 중에는final
로 지정된 빈들도 있기 때문에 오류가 발생할 수 있다. 따라서 이러한 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야 한다.
@annotation
@annotation
: 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭@annotation(hello.aop.member.annotation.MethodAop)
@Slf4j
@SpringBootTest
@Import(AtAnnotationTest.AtAnnotationAspect.class)
public class AtAnnotationTest {
@Autowired
MemberService memberService;
@Test
@DisplayName("success")
public void success() throws Exception {
log.info("memberService Proxy={}", memberService.getClass());
memberService.hello("helloA");
}
@Slf4j
@Aspect
static class AtAnnotationAspect {
@Around("@annotation(hello.aop.member.annotation.MethodAop)")
public Object doAtAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[@annotation] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
}
bean
bean
: 스프링 전용 포인트컷 지시자, 빈의 이름으로 지정한다.- 스프링 빈의 이름으로 AOP 적용 여부를 지정한다. 이것은 스프링에서만 사용할 수 있는 특별한 지시자이다.
bean(orderService) || bean(*Repository)
*
과 같은 패턴을 사용할 수 있다.
매개변수 전달
- 포인트컷의 이름과 매개변수의 이름을 맞추어야 한다.
- 추가로 타입이 메서드에 지정한 타입으로 제한된다.
@Slf4j
@SpringBootTest
@Import(ParameterTest.ParameterAspect.class)
public class ParameterTest {
@Autowired
MemberService memberService;
@Test
@DisplayName("success")
public void success() throws Exception {
log.info("memberService Proxy={}", memberService.getClass());
memberService.hello("helloA");
}
@Slf4j
@Aspect
static class ParameterAspect {
@Pointcut("execution(* hello.aop.member..*.*(..))")
private void allMember() {
}
@Around("allMember()")
public Object logArgs1(ProceedingJoinPoint joinPoint) throws Throwable {
Object arg1 = joinPoint.getArgs()[0];
log.info("[logArgs1]{}, arg={}", joinPoint.getSignature(), arg1);
return joinPoint.proceed();
}
@Around("allMember() && args(arg,..)")
public Object logArgs2(ProceedingJoinPoint joinPoint, Object arg) throws Throwable {
log.info("[logArgs2]{}, arg={}", joinPoint.getSignature(), arg);
return joinPoint.proceed();
}
@Before("allMember() && args(arg,..)")
public void logArgs3(String arg) {
log.info("[logArgs3] arg={}", arg);
}
@Before("allMember() && this(obj)")
public void thisArgs(JoinPoint joinPoint, MemberService obj) {
log.info("[this]{}, obj={}", joinPoint.getSignature(), obj.getClass());
}
@Before("allMember() && target(obj)")
public void targetArgs(JoinPoint joinPoint, MemberService obj) {
log.info("[target]{}, obj={}", joinPoint.getSignature(), obj.getClass());
}
@Before("allMember() && @target(annotation)")
public void atTarget(JoinPoint joinPoint, ClassAop annotation) {
log.info("[@target]{}, obj={}", joinPoint.getSignature(), annotation);
}
@Before("allMember() && @within(annotation)")
public void atWithin(JoinPoint joinPoint, ClassAop annotation) {
log.info("[@within]{}, obj={}", joinPoint.getSignature(), annotation);
}
@Before("allMember() && @annotation(annotation)")
public void atAnnotation(JoinPoint joinPoint, MethodAop annotation) {
log.info("[@annotation]{}, annotationValue={}", joinPoint.getSignature(), annotation.value());
}
}
}
this, target
this
: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트target
: Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트this
,target
은 다음과 같이 적용 타입 하나를 정확하게 지정해야 한다.
this(hello.aop.member.MemberService)
target(hello.aop.member.MemberService)
this(hello.aop.member.MemberServiceImpl)
target(hello.aop.member.MemberServiceImpl)
*
같은 패턴을 사용할 수 없다.- 부모 타입을 허용한다.
- JDK 동적 프록시의 경우 인터페이스를 구현한 프록시 객체를 생성하고 CGLIB은 구체 클래스를 상속 받아서 프록시를 상속받는다. 따라서 인터페이스를 지정하느냐 아니면 구체 클래스를 지정하느냐에 따라서 어드바이스 적용 여부가 달라질 수 있다. 예를 들어 JDK 동적 프록시에서 구체 클래스로 지정하는 경우
this(hello.aop.member.MemberServiceImpl)
에서는 어드바이스가 적용되지 않는다.
@Slf4j
@SpringBootTest(properties = "spring.aop.proxy-target-class=true")
@Import(ThisTargetTest.ThisTargetAspect.class)
public class ThisTargetTest {
@Autowired
MemberService memberService;
@Test
@DisplayName("success")
public void success() throws Exception {
log.info("memberService Proxy={}", memberService.getClass());
memberService.hello("helloA");
}
@Slf4j
@Aspect
static class ThisTargetAspect {
@Around("this(hello.aop.member.MemberService)")
public Object doThisInterface(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[this-interface] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("target(hello.aop.member.MemberService)")
public Object doTargetInterface(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[target-interface] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("this(hello.aop.member.MemberServiceImpl)") // JDK 동적 프록시의 경우 어드바이스 적용되지 않는다.
public Object doThis(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[this-impl] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
@Around("target(hello.aop.member.MemberServiceImpl)")
public Object doTarget(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[target-impl] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
}
스프링 AOP - 실전 예제
- 메소드 포인트컷 지시자인
@annotation
을 이용하여log
를 찍고 예외 발생시 일정 횟수 만큼 반복하는retry
를 적용한다.
@Service
@RequiredArgsConstructor
public class ExamService {
private final ExamRepository examRepository;
@Trace
public void request(String itemId) {
examRepository.save(itemId);
}
}
@Repository
public class ExamRepository {
private static int seq = 0;
@Trace
@Retry(value = 4)
public String save(String itemId) {
seq++;
if (seq % 5 == 0) {
throw new IllegalStateException("예외 발생");
}
return "ok";
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Trace {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int value() default 3;
}
@Slf4j
@Aspect
public class RetryAspect {
@Around("@annotation(retry)")
public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
log.info("[retry] {} retry={}", joinPoint.getSignature(), retry);
int maxRetry = retry.value();
Exception exceptionHolder = null;
for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
try {
log.info("[retry] try count={}/{}", retryCount, maxRetry);
return joinPoint.proceed();
} catch (Exception e) {
exceptionHolder = e;
}
}
throw exceptionHolder;
}
}
스프링 AOP - 실무 주의사항
프록시와 내부 호출 - 문제
스프링은 프록시 방식의 AOP를 사용한다. 따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다. 이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다. AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
this.internal();
}
public void internal() {
log.info("call internal");
}
}
@Aspect
@Slf4j
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
callServiceV0.external()
을 실행할 때는 프록시를 호출한다. 따라서CallLogAspect
어드바이스가 호출된 것을 확인할 수 있다. 그리고 AOP Proxy는target.external()
을 호출한다. 그런데 여기서 문제는callServiceV0.external()
안에서internal()
을 호출할 때 발생한다. 이때는CallLogAspect
어드바이스가 호출되지 않는다. 자바 언어에서 메서드 앞에 별도의 참조가 없으면this
라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는this.internal()
되는데, 여기서this
는 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.
프록시와 내부 호출 - 대안1 자기 자신 주입
@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
this.callServiceV1 = callServiceV1;
}
public void external() {
log.info("call external");
callServiceV1.internal();
}
public void internal() {
log.info("call internal");
}
}
callServiceV1
를 수정자를 통해서 주입 받는 것을 확인할 수 있다. 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.external()
을 호출하면callServiceV1.internal()
를 호출하게 된다. 주입받은callServiceV1
은 프록시이다. 따라서 프록시를 통해서 AOP를 적용할 수 있다. 참고로 이 경우 생성자 주입시 오류가 발생한다. 본인을 생성하면서 주입해야 하기 때문에 순환 사이클이 만들어진다. 반면에 수정자 주입은 스프링 커테이너가 생성된 이후에 주입할 수 있기 때문에 오류가 발생하지 않는다.- 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 스프링 부트 2.6 이상의 버전에서 실행하면 정상 실행되지 않는다. 이 문제를 해결하려면
application.properties
에 다음을 추가해야 한다.spring.main.allow-circular-references=true
프록시와 내부 호출 - 대안2 지연 조회
@Slf4j
@Component
public class CallServiceV2 {
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal();
}
public void internal() {
log.info("call internal");
}
}
프록시와 내부 호출 - 대안3 구조 변경(권장 방법)
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal();
}
}
@Component
@Slf4j
public class InternalService {
public void internal() {
log.info("call internal");
}
}
프록시 기술과 한계 - 타입 캐스팅 / 의존관계 주입
JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다. JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다. CGLIB는 구체 클래스를 기반으로 프록시를 생성한다. 물론 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB를 사용해야 한다. 그런데 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘중에 하나를 선택할 수 있다. 스프링이 프록시를 만들때 제공하는 ProxyFactory 에 proxyTargetClass 옵션에 따라 둘중 하나를 선택해서 프록시를 만들 수 있다.
proxyTargetClass=false
: JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성proxyTargetClass=true
: CGLIB를 사용해서 구체 클래스 기반 프록시 생성- 참고로 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB를 사용한다.
@Slf4j
public class ProxyCastingTest {
@Test
@DisplayName("jdkProxy")
public void jdkProxy() throws Exception {
MemberServiceImpl target = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(false);
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
assertThatThrownBy(() -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
}).isInstanceOf(ClassCastException.class);
}
// CGLIB의 경우 구체 클래스를 상속한 프록시를 사용하므로 인터페이스 뿐만 아니라 구체적인 타겟 클래스로도 캐스팅이 된다.
}
- 따라서 의존관계를 주입 받을때 인터페이스로 받는다면 상관없지만 타겟인 구체 클래스로 받는다면 JDK와 CGLIB 여부에 따라 결과가 달라진다. 즉 JDK 동적 프록시라면 구체 클래스로 의존관계 주입시 예외가 발생한다.
프록시 기술과 한계 - CGLIB
- 기본 생성자 필수: CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되어 있다면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는
super()
가 자동으로 들어간다.) 이 부분은 자바 문법 규약이다. CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 따라서 대상 클래스에 기본 생성자를 만들어야 한다. - 생성자 2번 호출 문제: CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다.
- 실제 target의 객체를 생성할 때
- 프록시 객체를 생성할 때 부모 클래스의 기본 생성자 호출
- final 키워드 클래스, 메서드 사용 불가:
final
키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다. 프레임워크 같은 개발이 아니라 일반적인 웹 애플리케이션을 개발할 때는final
키워드를 잘 사용하지 않는다. 따라서 이 부분이 특별히 문제가 되지는 않는다.
프록시 기술과 한계 - 스프링의 해결책
CGLIB 기본 생성자 필수 문제 해결
스프링 4.0부터 대상 클래스의 기본 생성자가 필수인 문제가 해결되었다. objenesis
라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다. 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.
생성자 2번 호출 문제
스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다. 이것도 역시 objenesis
라는 특별한 라이브러리 덕분에 가능해졌다. 이제 생성자가 1번만 호출된다.
스프링 부트 2.0 - CGLIB 기본 사용
스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다. 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다. 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true
로 설정해서 사용한다. 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다.
물론 스프링은 우리에게 선택권을 열어주기 때문에 다음과 깉이 설정하면 JDK 동적 프록시도 사용할 수 있다.
spring.aop.proxy-target-class=false
댓글남기기