[고급편] 스프링 AOP - 실무 주의사항
포스트
취소

[고급편] 스프링 AOP - 실무 주의사항

프록시와 내부 호출 - 문제

  • 스프링은 프록시 방식의 AOP를 사용한다.
    • AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.
    • 이렇게 해야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다.
  • 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 어드바이스도 호출되지 않는다.
  • AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. -따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다.
    • 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.
    • 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
  • 실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 반드시 이해하는 것이 좋다.

서비스

package com.example.internalcall.app;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CallServiceV0 {
    public void external() {
        log.info("call external");
        internal(); //내부 메서드 호출(this.internal())
    }
    
    public void internal() {
        log.info("call internal");
    }
}

애스팩트

package com.example.internalcall.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Slf4j
@Aspect
public class CallLogAspect {
    @Before("execution(* com.example.internalcall..*.*(..))")
    public void doLog(JoinPoint joinPoint) {
        log.info("aop={}", joinPoint.getSignature());
    }
}

테스트

  • external()은 CallLogAspect가 적용된다.
    • 그러나 external() 내부의 internal()은 CallLogAspect가 적용되지 않는다.
  • 단순히 internal()을 호출하는 것은 CallLogAspect가 적용된다.
  • CallServiceV0의 external()에 보면 internal()가 실행된다.
    • 자바에서는 메소드 앞에 별도의 참조가 없으면 자동으로 this를 추가한다.
    • 즉, internal()this.internal()가 된다.
    • this는 실제 대상 객체(target)의 인스턴스를 뜻한다.
    • 그래서 this를 통한 내부 호출은 프록시를 거치지 않는다.
package com.example.internalcall.app;

import com.example.internalcall.aop.CallLogAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {
    @Autowired
    private CallServiceV0 callServiceV0;

    @Test
    void external() {
        callServiceV0.external();
    }

    //AOP 적용 X
    @Test
    void internal() {
        callServiceV0.internal();
    }
}

프록시 방식의 AOP 한계

  • 스프링은 프록시 방식의 AOP를 사용한다.
  • 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다.
  • 실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다.
    • AspectJ를 사용하면 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드가 붙는다.
    • 그래서 내부 호출과 무관하게 AOP를 적용할 수 있다.
    • 하지만 로드 타임 위빙 등을 사용해야 하다 보니 설정이 복잡하고 JVM 옵션을 주어야 하는 부담이 있다.
  • 프록시 방식의 AOP에서 내부 호출에 대응할 수 있는 대안들도 있기 때문에
    이런 이유로 AspectJ를 직접 사용하는 방법은 실무에서는 거의 사용하지 않는다.

프록시와 내부 호출 - 대안1 자기 자신 주입

  • 내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.

서비스

package com.example.internalcall.app;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * 참고: 생성자 주입은 순환 사이클을 만들기 때문에 실패한다.
 */
@Slf4j
@Component
public class CallServiceV1 {
    private CallServiceV1 callServiceV1;

    /**
     * 이름을 자세히 보면 Setter 방식인 것을 알 수 있다.
     */
    @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");
    }
}

테스트

  • 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었다.
  • 스프링 부트 2.6 이상일 경우에는 해당 테스트가 실패할테니 설정 파일에 해당 값을 설정해주자.
    • spring.main.allow-circular-references=true
package com.example.internalcall.app;

import com.example.internalcall.aop.CallLogAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV1Test {
    @Autowired
    private CallServiceV1 callServiceV1;

    @Test
    void external() {
        callServiceV1.external();
    }
}

프록시와 내부 호출 - 대안2 지연 조회

  • 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 때문이다.
  • 이 경우 수정자 주입을 사용하거나 지연 조회를 사용하면 된다.
  • ObjectProvider(Provider)와 ApplicationContext를 통해서 지연 조회를 사용해보자.

서비스

  • ApplicationContext 는 너무 많은 기능을 제공한다.
  • ObjectProvider는 객체를 스프링 컨테이너에서 조회하는 것을
    스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.
  • callServiceProvider.getObject()를 호출하는 시점에 스프링 컨테이너에서 빈을 조회한다.
    • 여기서는 자기 자신을 주입 받는 것이 아니기 때문에 순환 사이클이 발생하지 않는다.
package com.example.internalcall.app;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;

/**
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {
    //private final ApplicationContext applicationContext;
    private final ObjectProvider<CallServiceV2> 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");
    }
}

테스트 생성

package com.example.internalcall.app;


import com.example.internalcall.aop.CallLogAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV2Test {
    @Autowired
    private CallServiceV2 callServiceV2;

    @Test
    void external() {
        callServiceV2.external();
    }
}

프록시와 내부 호출 - 대안3 구조 변경

  • 앞선 방법들은 자기 자신을 주입하거나, Provider를 사용해야 하는 것처럼 조금 어색한 모습을 만들었다.
  • 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다.
  • 실제로 이 방법이 가장 권장되는 방식이다.
  • 보통 2가지 방식이 있다.
    • 클래스 분리
      • 내부에서 호출할 메소드를 별도의 클래스로 분리하는 방식
    • 메소드 별도 호출
      • 만약에 서비스.메소드1에서 서비스.메소드2를 내부로 호출하는 게 문제된다면?
      • 그냥 해당 서비스를 호출하는 쪽에서 서비스.메소드1과 서비스.메소드2를 각각 호출하게 바꾸면 된다.

서비스

package com.example.internalcall.app;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
 * 구조를 변경(분리)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal(); //외부 메서드 호출
    }
}
package com.example.internalcall.app;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class InternalService {
    public void internal() {
        log.info("call internal");
    }
}

테스트 생성

package com.example.internalcall.app;

import com.example.internalcall.aop.CallLogAspect;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV3Test {
    @Autowired
    public CallServiceV3 callServiceV3;

    @Test
    void external() {
        callServiceV3.external();
    }
}

프록시가 적용되지 않을 때 확인해야 할 사항

AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. - 즉, 인터페이스에 메소드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. - 그래서 보통 AOP는 public 메소드에만 적용한다. - private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다.

  • AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다.
    • 만약에 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다.
  • AOP가 잘 적용되지 않으면 내부 호출인지 확인해보자.

프록시 기술과 한계 - 타입 캐스팅

  • JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.
    • JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스를 기반으로 프록시를 생성한다.
    • CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
  • 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB만 사용해야 한다.
  • 하지만 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘 중에 하나를 선택할 수 있다.
  • 스프링이 프록시를 만들때 제공하는 ProxyFactory에 proxyTargetClass 옵션에 따라
    둘중 하나를 선택해서 프록시를 만들 수 있다.

JDK 동적 프록시 한계

  • 인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.
  • 확인을 위해 테스트를 생성해보자.
import com.example.app.MemberService;
import com.example.app.MemberServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import static org.junit.jupiter.api.Assertions.assertThrows;

@Slf4j
public class ProxyCastingTest {
    @Test
    void jdkProxy() {
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(false);//JDK 동적 프록시

        //프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
        log.info("proxy class={}", memberServiceProxy.getClass());

        //JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
        assertThrows(ClassCastException.class, () -> {
            MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
        });
    }
}
  • JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성한다.
    • 그래서 MemberService로는 캐스팅이 가능하다.
    • 하지만 MemberServiceImpl가 어떤 것이지 알 수 없어서 ClassCastException.class 예외가 발생한다.

CGLIB로 변경

  • 직전의 ProxyCastingTest에 테스트를 추가해보자.
@Test
    void cglibProxy() {
        MemberServiceImpl target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(true);//CGLIB 프록시

        //프록시를 인터페이스로 캐스팅 성공
        MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
        log.info("proxy class={}", memberServiceProxy.getClass());

        //CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공
        MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
    }
  • CGLIB는 구체 클래스를 기반으로 프록시를 생성한다.
    • 그래서 MemberServiceImpl로 캐스팅이 가능하다.

프록시 기술과 한계 - 의존관계 주입

  • 타입 캐스팅이 문제가 되는 이유가 뭘까?
  • 그 원인은 스프링의 3대 요소 중 하나인 DI(의존성 주입)에 있다.

애스팩트

package aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Slf4j
@Aspect
public class ProxyDIAspect {
    @Before("execution(* com.example..*.*(..))")
    public void doTrace(JoinPoint joinPoint) {
        log.info("[proxyDIAdvice] {}", joinPoint.getSignature());
    }
}

테스트 생성

import aop.ProxyDIAspect;
import com.example.Chapter3Application;
import com.example.app.MemberService;
import com.example.app.MemberServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시, DI 예외 발생
//@SpringBootTest(properties = {"spring.aop.proxy-target-class=true"}) //CGLIB 프록시, 성공
@Import(ProxyDIAspect.class)
@ContextConfiguration(classes = Chapter3Application.class)
public class ProxyDITest {
    @Autowired
    private MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
    @Autowired
    private 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 동적 프록시의 경우

  • 테스트를 실행해보면 타입과 관련된 예외가 발생한다.
    • 기댓값
      • com.example.app.MemberServiceImpl
    • 결과값
      • jdk.proxy3.$Proxy67
  • 왜 예외가 발생할까?
    • @Autowired MemberService memberService
      • JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다.
      • 따라서 MemberService로 캐스팅 할 수 있다.
      • 그래서 MemberService = JDK 프록시가 성립한다.
    • @Autowired MemberServiceImpl memberServiceImpl
      • JDK Proxy는 MemberService 인터페이스를 기반으로 만들어진다.
      • 따라서 인터페이스 기반인 JDK 동적 프록시는 MemberServiceImpl가 무엇인지 이해할 수 없다.
        • MemberServiceImpl에 주입할 수 없는 이유
      • 그래서 MemberServiceImpl = JDK 프록시가 성립하지 않는다.

CGLIB의 경우

  • 테스트를 실행해보면 정상적으로 동작한다.
  • 왜 성공했을까?
    • @Autowired MemberService memberService
      • CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 만들어진다.
      • MemberServiceImpl은 MemberService 인터페이스를 구현했기 때문에 해당 타입으로 캐스팅 할 수 있다.
      • 따라서 해당 타입으로 캐스팅 할 수 있다.
      • 그래서 MemberService = CGLIB의 프록시가 성립한다.
    • @Autowired MemberServiceImpl memberServiceImpl
      • CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 만들어진다.
      • 따라서 MemberServiceImpl로 캐스팅 할 수 있다.
      • 그래서 MemberServiceImpl = CGLIB의 프록시가 성립한다.

실제 개발할 때는 무엇을 사용해야 할까?

  • . 실제로 개발할 때는 인터페이스가 있으면 인터페이스를 기반으로 의존관계 주입을 받는 것이 맞다.
  • 의존성 주입을 받는 클라이언트 코드의 변경 없이 구현 클래스를 변경할 수 있기 때문이다.
    • 이렇게 하려면 인터페이스를 기반으로 의존관계를 주입 받아야 한다.
  • MemberServiceImpl 타입으로 의존관계 주입을 받는 것 처럼 구현 클래스에 의존관계를 주입하면
    향후 구현 클래스를 변경할 때 의존관계 주입을 받는 클라이언트의 코드도 함께 변경해야 한다.
  • 그래서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다.
  • 그럼에도 불구하고 테스트나 여러가지 이유로
    AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다.
    • 이 때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.
  • 여기까지 듣고보면 CGLIB를 사용하는 것이 좋아보인다.
    • 실제로 CGLIB를 사용하면 사실 이런 고민 자체를 하지 않아도 된다.
    • 하지만 그런 CGLIB에도 단점은 있다.

프록시 기술과 한계 - CGLIB

  • 스프링에서 CGLIB는 구체 클래스를 상속 받아서 AOP 프록시를 생성할 때 사용한다.
  • CGLIB는 구체 클래스를 상속 받기 때문에 다음과 같은 문제가 있다.
    • 대상 클래스에 기본 생성자 필수
    • 생성자 2번 호출 문제
    • final 키워드 클래스, 메서드 사용 불가

대상 클래스에 기본 생성자 필수

  • CGLIB는 구체 클래스를 상속 받는다.
  • 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때
    자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다.
  • 부모 클래스의 생성자를 호출하는 부분이 생략되어 있다면
    자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super() 가 자동으로 들어간다. (자바 문법 규약)
  • CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다.
    • CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다.
    • 따라서 대상 클래스에 기본 생성자를 만들어야 한다. (생성자가 없을 경우 자동으로 생성된다.)

생성자 2번 호출 문제

  • CGLIB는 구체 클래스를 상속 받는다.
  • 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다.
  • 생성자가 2번 호출되는 이유
    1. 실제 target의 객체를 생성할 때 생성자 호출
    2. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출

final 키워드 클래스, 메서드 사용 불가

  • final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다.
    • CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.
  • 프레임워크 같은 개발이 아니라 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않는다.
    • 그래서 이 부분이 특별히 문제가 되지는 않는다.

프록시 기술과 한계 - 스프링의 해결책

  • 스프링은 AOP 프록시 생성을 편리하게 제공하기 위해 오랜 시간 고민하고 문제들을 해결해왔다.

스프링 3.2 (CGLIB를 스프링 내부에 함께 패키징)

  • CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다.
  • 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다.

스프링 4.0 (CGLIB 기본 생성자 필수 문제 해결)

  • 스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다.
  • objenesis라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다.
    • 이 라이브러리를 통해 생성자 호출 없이 객체를 생성할 수 있게 해준다.
  • 또한 스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다.
    • 해당 사항도 objenesis 라는 특별한 라이브러리 덕분에 가능해졌다.
    • 덕분에 이제는 생성자가 1번만 호출된다.

스프링 부트 2.0 (CGLIB 기본 사용)

  • 스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다.
  • 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다.
  • 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다.
    • 인터페이스가 있어도 JDK 동적 프록시를 사용하지 않는다.
    • 항상 CGLIB를 사용해서 구체클래스를 기반으로 프록시를 생성한다.
  • 설정 파일에 spring.aop.proxy-target-class=false를 작성하면 JDK 동적 프록시도 사용할 수 있다.

CGLIB의 아직 남은 문제점?

  • final 클래스나 final 메서드에는 AOP를 적용할 수 없다.
  • AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.