스프링 핵심 원리 - 고급편 - (35) 스프링 AOP - 실무 주의 사항 - 1
인프런 스프링 핵심 원리 - 고급편을 학습하고 정리한 내용 입니다.
프록시와 내부 호출 - 문제
스프링은 프록시 방식의 AOP를 사용한다.
따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP는 당연히 적용이 안되고, 어드바이스도 호출되지 않는다.
AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
예제를 통해서 내부 호출이 발생할 때 어떤 문제가 발생하는지 알아보자. 먼저 내부 호출이 발생하는 예제를 만들어보자.
예제 - CallServiceV0

hello.aop.internalcall.CallServiceV0
1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
CallServiceV0.external()을 호출하면 내부에서 internal()이라는 자기 자신의 메서드를 호출한다.
자바는 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this가 붙게 된다. 그러니까 여기서는 this.internal()이라고 이해하면 된다.
이제 애스펙트를 만들어서 프록시를 적용해보자.
애스펙트 - CallLogAspect
hello.aop.internalcall.aop.CallLogAspect
1
2
3
4
5
6
7
8
9
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop = {}", joinPoint.getSignature());
}
}
CallServiceV0에 AOP를 적용하기 위해서 간단한 Aspect를 하나 만들었다. @Before로 간단히 로그 정도만 남겼다.
이제 앞서 만든 CallServiceV0을 실행할 수 있는 테스트 코드를 만들자.
테스트 - CallServiceV0Test

CallServiceV0Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
@Autowired CallServiceV0 callServiceV0;
@Test
void external() {
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
@Import(CallLogAspect.class): 앞서 만든 간단한Aspect를 스프링 빈으로 등록한다. 이렇게 해서CallServiceV0에 AOP 프록시를 적용한다.@SpringBootTest: 내부에 컴포넌트 스캔을 포함하고 있다.CallServiceV0에@Component가 붙어있으므로 스프링 빈 등록 대상이 된다.
먼저 callServiceV0.external()을 실행해보자. 이 부분이 중요하다.

실행 결과를 보면 callServiceV0.external()을 실행할 때는 프록시를 호출한다. 따라서 CallLogAspect어드바이스가 호출된 것을 확인할 수 있다.

그리고 AOP Proxy는 target.external()을 호출한다. 그런데 여기서 문제는 callServiceV0.external()안에서 internal()을 호출할 때 발생한다. 이때는 CallLogAspect어드바이스가 호출되지 않는다.
자바 언어에서 메서드 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킨다.
결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()이 되는데, 여기서 this는 프록시 객체가 아니라 실제 대상 객체(target)의 인스턴스를 뜻한다.
결과적으로 이런 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다.
이번에는 internal()을 외부에서 호출해보자.

당연히 AOP가 잘 적용된다.

외부에서 호출하는 경우 프록시를 거치기 때문에 internal()도 CallLogAspect어드바이스가 적용된 것을 확인 할 수 있다.
프록시 방식의 AOP 한계
스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
참고
실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다. 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙어있기 때문에 내부 호출과 무관하게 AOP를 적용할수 있다. 하지만 로드 타임 위빙 등을 사용해야 하는데, 설정이 복잡하고 JVM 옵션을 주어야 하는 부담이 있다. 그리고 다음에 배울 프록시 방식 AOP에서 내부 호출에 대응할 수 있는 대안들도 있다.
이런 이유로 AspectJ를 직접 사용하는 방법은 실무에서 거의 사용하지 않는다.
스프링 애플리케이션과 함께 직접 AspectJ 사용하는 방법은 스프링 공식 메뉴얼을 참고하자.
프록시와 내부 호출 대안 1 - 자기 자신 주입
내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존 관계 주입 받는 것이다.

이 그림을 만들면 될 것 같다.
CallServiceV1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 참고: 생성자 주입은 순환 사이클을 만들기 때문에 실패한다.
*/@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
@Autowired
public CallServiceV1(CallServiceV1 callServiceV1) {
this.callServiceV1 = callServiceV1;
}
public void external() {
log.info("call external");
internal(); //내부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
자 이렇게 생성자를 통해서 자기 자신을 주입 받으면 순환 참조되기 때문에 오류가 발생한다.

그래서 setter를 구현해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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(); //외부 메서드 호출(this.internal())
}
public void internal() {
log.info("call internal");
}
}
callServiceV1를 수정자를 통해서 주입 받는 것을 확인할 수 있다. 스프링에서 AOP가 적용된 대상을 의존관계 주입 받으면 주입 받은 대상은 실제 자신이 아니라 프록시 객체이다.
1
2
3
4
public void external() {
log.info("call external");
callServiceV1.internal(); //외부 메서드 호출(this.internal())
}
이 부분이 중요하다. 보면 callServiceV1이 인스턴스는 setter를 통해 주입받은 프록시 객체 인스턴스이다.
주의
스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다. 따라서 이번 예제를 스프링 부트 2.6 이상의 버전에서 실행하면 다음과 같은 오류 메시지가 나오면서 정상 실행되지 않는다.Error creating bean with name 'callServiceV1': Requested bean is currently in creation: Is there an unresolvable circular reference?
이 문제를 해결하려면application.properties에 다음을 추가해야 한다.spring.main.allow-circular-references=true
앞으로 있을 다른 테스트에도 영향을 주기 때문에 스프링 부트 2.6 이상이라면 이 설정을 꼭 추가해야 한다.
이제 테스트 해보자.
CallServiceV1Test
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV1Test {
@Autowired
CallServiceV1 callServiceV1;
@Test
void external() {
callServiceV1.external();
}
}

실행 결과를 보면 이제는 internal()을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있다. 당연히 AOP도 잘 적용된다.
프록시와 내부 호출 대안 2 - 지연 조회
앞서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다. 이 경우 수정자 주입을 사용하거나 지금부터 설명하는 지연 조회를 사용하면 된다.
스프링 빈을 지연해서 조회하면 되는데, ObjectProvider(Provider), ApplicationContext를 사용하면 된다.
CallServiceV2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
*/
@Slf4j
@Component
public class CallServiceV2 {
private final ApplicationContext applicationContext;
public CallServiceV2(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
callServiceV2.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
지금처럼
1
2
3
4
5
private final ApplicationContext applicationContext;
public CallServiceV2(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
스프링 컨테이너를 직접 사용해서
1
2
3
4
5
public void external() {
log.info("call external");
CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
callServiceV2.internal(); //외부 메서드 호출
}
나중에 꺼내서 사용하면 프록시 객체를 사용할 수 있게 된다.
CallServiceV2Test
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV2Test {
@Autowired
CallServiceV2 callServiceV2;
@Test
void external() {
callServiceV2.external();
}
}
이렇게 해서 실행해보면

잘 된다.
하지만! ApplicationContext 스프링 컨테이너는 너무 거대하고 많은 기능을 제공한다.
ObjectProvider를 사용하여 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@Component
public class CallServiceV2 {
//private final ApplicationContext applicationContext;
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void external() {
log.info("call external");
// CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal(); //외부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
그래서 이렇게 바꿔줘야 한다.
1
2
3
4
5
private final ObjectProvider<CallServiceV2> callServiceProvider;
public CallServiceV2(ObjectProvider<CallServiceV2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
주입 받고
1
CallServiceV2 callServiceV2 = callServiceProvider.getObject();
.getObject()로 꺼내온다.

잘 된다.
프록시와 내부 호출 대안 3 - 구조 변경
앞선 방법들은 자기 자신을 주입하거나 또는 Provider를 사용해야 하는 것처럼 조금 어색한 모습을 만들었다.
가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.
CallServiceV3, InternalService
CallServiceV3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 구조를 변경(분리)
*/@Slf4j
@RequiredArgsConstructor
@Component
public class CallServiceV3 {
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal(); // //외부 메서드 호출
}
}
내부 호출을 InternalService라는 별도의 클래스로 분리했다.
InternalService
1
2
3
4
5
6
7
8
@Slf4j
@Component
public class InternalService {
public void internal() {
log.info("call internal");
}
}
이제 테스트를 해보자.
CallServiceV3Test
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV3Test {
@Autowired
CallServiceV3 callServiceV3;
@Test
void external() {
callServiceV3.external();
}
}


내부 호출 자체가 사라지고, callService → internalService 이런 흐름으로 구조가 변경되었다.
덕분에 자연스럽게 AOP가 적용된다.
여기서 구조를 변경한다는 것은 이렇게 단순하게 분리하는 것 뿐만 아니라 다양한 방법들이 있을 수 있다.
예를 들어서 다음과 같이 클라이언트에서 둘 다 호출하는 것이다.
클라이언트 → external()
클라이언트 → internal()
물론 이 경우 external()에서 internal()을 내부 호출하지 않도록 코드를 변경해야 한다. 그리고 클라이언트가 external(), internal()을 모두 호출하도록 구조를 변경하면 된다. (물론 가능한 경우에 한해서)
참고
AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 더 풀어서 이야기하면 AOP는public메서드에만 적용한다.private메서드 처럼 작은 단위에는 AOP를 적용하지 않는다.
AOP 적용을 위해private메서드를 외부 클래스로 변경하고public으로 변경하는 일은 거의 없다. 그러나 위 예제와 같이public메서드에서public메서드를 내부 호출하는 경우에는 문제가 발생한다. 실무에서 꼭 한번은 발생하니 기억하자. AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.
댓글남기기