본문 바로가기

Spring 정리

스프링 AOP (3) - 실무 주의사항

728x90
반응형

프록시 내부호출 문제

스프링은 프록시 방식의 AOP를 사용합니다.  따라서 AOP를 적용하려면 항상 프록시를 통해서 대상객체(Target)을 호출해야 합니다. 그래야 어드바이를 통해서 정상적으로 호출되며 직접 호출하면 어드바이스가 적용되지 않습니다.

 

하지만, 대상 객체(Target) 내부에서 메서드 호출이 발생하면 프록시를 거치지 않아 직접 호출을 하고 제대로 AOP가 적용되지 않습니다.

 

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

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

 

 

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceTest {
    @Autowired
    CallService callService;
    
    @Test
    void external() {
        callService.external();
    }
    
    @Test
    void internal() {
    	callService.internal();
    }
}

 

  • internal() 내부 호출 vs 외부호출

테스트 external() 호출에서는 "call external" 로그가 호출되지만, "call internal"이 호출되지 않습니다.

internal()이 내부호출이라서 프록시를 통하지 않고 직접 호출되기 때문입니다.

내부에서 internal() 호출

 

테스트 internal() 호출에서는 "call internal"로그가 호출됩니다. 프록시를 통해서 호출되기 때문입니다.

외부에서 internal() 호출

 

 

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


@Slf4j
@Component
public class CallService {
    private CallService callService;

    @Autowired
    public void setCallService(CallService callService) {
        this.callService = callService;
    }

    public void external() {
        log.info("call external");
        callService.internal();
    }
    
    public void internal() {
        log.info("call internal");
    }
}

 

자기 자신의 internal() 호출

 

자기 자신을 주입하여 프록시에서 호출되도록 하면 AOP가 적용됩니다.

 

 

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


@Slf4j
@Component
public class CallService {
    //private final ApplicationContext applicationContext; <- 너무 많은 기능이 있어서 무겁다
    private final ObjectProvider<CallService> callServiceProvider;
    
    public CallService callService(ObjectProvider<CallService> callServiceProvider) {
        this.callServiceProvider = callServiceProvider;
    }

    public void external() {
        log.info("call external");
        //CallService callService = applicationContext.getBean(CallService.class);
        CallService callService = callServiceProvider.getObject();
        callService.internal();
    }
    
    public void internal() {
        log.info("call internal");
    }
}

 

대안1 자기 자신 주입의 문제점은 스프링 부트 2.6 이상부터 순환 참조를 기본적으로 금지로 실행 시 오류가 발생합니다. 따라서, 지연 조회를 하면 스프링 컨테이너 빈 생성 시점이 아닌 실제 객체를 사용하는 시점으로 조회를 늦출 수 있습니다. 이 경우 순환 참조 문제가 발생하지 않습니다.

 

 

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


@Slf4j
@Component
@RequiredArgsConstructor
public class CallService {
    private final InternalService internalService;

    public void external() {
        log.info("call external");
        internalService.internal();
    }
}

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

 

외부 서비스의 internal() 호출

 

 권장하는 방법은 메서드를 별도의 클래스로 분리하는 것입니다. 이를 통해 자연스럽게 로직을 분리하여 가독성이 높아집니다. 또한 대안1과 대안2 방법보다 오류를 발생 할 확률이 확연하게 줄어듭니다.

 

 

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


  • JDK 동적 프록시 적용 방식

JDK 동적 프록시는 인터페이스가 필수이고, 인터페이스 기반 프록시를 생성합니다.

JDK 동적 프록시

 

 하지만, JDK 동적 프록시는 MemberService 인터페이스를 기준으로 프록시를 생성하므로 아래와 같이 MemberServiceImpl는 알지 못하므로 타입 변환이 불가능합니다.

 

 

  • CGLIB 적용 방식

CGLIB는 구체 클래스 기반으로 프록시를 생성합니다.

CGLIB 프록시 타입 변환

 

 

CGLIB는 구체 클래스인 MemberServiceImpl 클래스를 기준으로 프록시를 생성하므로 MemberService와 MemberServiceImpl 모두 타입 변환이 가능합니다.

CGLIB 타입 변환

 

 

  • JDK 동적 프록시와 CGLIB의 의존관계 주입 차이점

JDK 동적 프록시는 인터페이스 MemberService만 의존관계 주입이 가능합니다.

CGLIB는 인터페이스 MemberService와 구체클래스 MemberServiceImpl 모두 의존관계 주입이 가능합니다.

 

 

  • 어떤 방식으로 AOP를 적용해야 할까?

 좋은 설계는 인터페이스를 활용해 클라이언트의 코드 변경 없이 구현 클래스를 변경하는 것입니다. 따라서 인터페이스 MemberService를 활용해 JDK 동적 프록시로 AOP를 만드는 것이 좋다고 생각 할 수 있습니다. 올바르게 잘 설계되면 상관없지만, 그럼에도 불구하고 구체 클래스에 의존하여 AOP를 사용해야 하는 경우가 있습니다. (이 경우 구현이 변경된다면 의존관계 주입을 받는 클라이언트도 변경되는 문제가 생깁니다.) 따라서, CGLIB를 사용하는 것이 범용적으로 좋습니다. 물론 항상 CGLIB가 좋은 것은 아닙니다. CGLIB의 문제점에 대해 알아보겠습니다.

 

 

CGLIB 한계점과 극복


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

 CGLIB는 구체 클래스를 상속합니다. 자바 규칙 상 구체 클래스를 상속하면 자식 클래스의 생성자를 호출할 때, 부모 클래스의 생성자도 호출해야 합니다. (따라서 자식 생성자의 첫줄에 부모 클래스의 기본 생성자를 호출하는 super()가 필수로 있어야 합니다.)

 

따라서, 해당 클래스에 기본 생성자가 있어야 CGLIB에 의한 프록시 생성자가 호출 될 때 super()로 호출 할 수 있습니다.

 

 

  • 생성자 2번 호출 문제

 생성자는 실제 target 객체를 생성할 때와 CGLIB 프록시 객체를 생성하면서 부모 클래스의 생성자를 호출할 때 총 2번을 호출합니다.

 

 

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

final 키워드가 클래스에 있으면 상속이 불가능하고 메서드에 있으면 오버라이딩이 불가능합니다. CGLIB는 상속을 기반으로 하기 때문에 대상 클래스에 final 키워드가 있으면 상속받을 수 없습니다.

 

 

문제해결

 스프링 4.0부터 CGLIB 대상 클래스에 기본 생성자 필수가 해결되었습니다. objenesis라는 라이브러리로 기본 생성자를 만들지 않고도 객체가 생성 가능합니다.

 스플이 4.0부터 생성자 2번 호출 필수가 해결되었습니다. 마찬가지로 objenesis라는 라이브러리로 생성자가 1번만 호출되면서 CGLIB 프록시 객체 생성 및 사용이 가능합니다.

 AOP 사용 시 남은 문제는 final 키워드 인데, AOP를 적용할 대상에는 final을 사용하지 않음으로 간단하게 해결 할 수 있습니다.

728x90
반응형

'Spring 정리' 카테고리의 다른 글

스프링 AOP (2) - AOP 구현 및 예제  (0) 2023.01.12
스프링 트랜잭션 이해  (0) 2022.12.01
트랜잭션의 역사  (0) 2022.09.04
@Controller @RestContrller  (0) 2022.08.25
@Controller, @Service, @Repository 차이  (0) 2022.08.25