WEB/Spring

스프링 핵심 원리 - 고급편 9) 실전, 실무 주의 사항

Tony Lim 2022. 6. 13. 12:24
728x90
@Repository
public class ExamRepository {

    private static int seq = 0;

    /**
     * 5번에 1번 실패하는 요청
     */
    @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 Retry {
    int value() default 3;
}

 

@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;
    }
}

@Retry annotation이 있는 경우에는 RetryAspect가 적용이 되는것이다.

default = 3까지 retry를 시도하고 그래도 실패하면 exceptionHolder 에 담아논 exception을 던진다.

 


 

프록시와 내부호출 문제

@Aspect
public class CallLogAspect {

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

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

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

internal 앞에 this 를 안붙여도 내부의 메소드를 호출하는것이면 this가 붙은상태로 호출되는것 처럼 동작한다.

@Import(CallLogAspect.class)
@SpringBootTest
class CallServiceV0Test {

    @Autowired CallServiceV0 callServiceV0;

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

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

callServiceV0는 프록시 객체가 올라간다. Pointcut을 보면 하위패키지 모든 클래스, 메소드에 프록시를 적용하고 있기 때문이다.

 

external을 통해 internal 을 호출하면 external 은 프록시를 통해 호출해서 Aspect의 advice가 적용이 되는데 그안의 internal 의 메소드를 호출할때는 target 안에서 호출하기에 advice가 적용이 되지 않는다.

AspectJ를 쓰면 바이트코드를 internal에 넣어버리기 떄문에 external -> internal 인 상황에도 그냥 다 advice가 적용이 된다.

 


 

해결방안들

external에서 this.internal을 호출하는것이 아니라 주입받은 proxy를 통해 proxy의 internal을 호출해서 해결하는 방법

@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");
    }
}

ApplicationContext를 써도 되지만 너무나 거대하기에 우리가 필요한 기능만 가지고 있는 ObjectProvider를 쓰면 된다.

하지만 거추장 스럽다. 애초에 내부호출이 일어나지 않게 구조 자체를 변경하는 것이 가장 좋은 방법이다.

다음과 같이 새로운 클래스를 만들어서 external호출하는 인스턴스의 필드로 주입해주어서 주입된 인스턴스를 통해 호출할 수 있도록 변경하자

 


 

JDK 동적 프록시 한계

인터페이스 기반으로 프록시를 생성하기에 구체 클래스로 타입 캐스팅이 불가능하다.

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

    //프록시를 인터페이스로 캐스팅 성공
    MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();

    //JDK 동적 프록시를 구현 클래스로 캐스팅 시도 실패, ClassCastException 예외 발생
    assertThrows(ClassCastException.class, () -> {
        MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
    });
}

구체 클래스로 캐스팅이 불가능한것을 확인 

해당 프록시는 인터페이스를 구현한것이지 실제 target 클래스를 상속하고 있지 않는다.

CGLIB로 만들어진 프록시는 인터페이스, 구체target 클래스 둘다 캐스팅이 된다. 둘다 구현 및 상속을 한 존재이니까

근데 이게 왜 문제가 될까..?

 

의존관계 주입시 문제점 발생하게 된다. 왠만하면 인터페이스로 주입을 받는것이 다형성 관점에서 좋다.

그렇다면 CGLIB는 단점이 없을까?

 

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

대상 클래스가 부모가 되기때문에 상속하는 프록시의 경우 super를 호출해야하기 때문이다.

 

생성자 2번 호출 하는 문제 

타켓을 생성할 때 한번 , 프록시에서 super를 통해 호출할 때 한번 해서 생성자를 총 2번을 호출하게 된다.

 

final 키워드를 클래스, 메소드에 적용불가

오버라이딩 및 상속이 불가능해지기 때문에 무의미해지게 된다.

 

스프링이 objenesis 라는 라이브러리를 통해서 기본생성자 문제와 생성자 2번 호출하는 문제가 해결하였다.

 

 

 

 

728x90