참고 자료 - AOP, ThreadLocal을 사용하여 N+1 detector 만들어보기
[https://c-king.tistory.com/entry/N1-detector-%EB%A7%8C%EB%93%A4%EC%96%B4%EB%B3%B4%EA%B8%B0]
가끔씩 다른 개발 블로그들을 보면서 다른 사람들이 어떤 생각을 하고 어떤 코딩 스타일을 가지고 있는지 구경을 한다. 아무래도 랜덤한 블로그들을 다 뒤지고 다니진 않고 나와 비슷한 위치에 있지만 조금 더 실력이 좋고 나은 사람들을 위주로 구경하려고 하는데, 우테코 같은 유명한 코스를 밟고있는 사람들이 자극 받기 딱 좋다. 위 레퍼런스는 AOP 에 대해서 아예 모르던 때에 처음 본 포스팅인데 글 솜씨나, 아이디어에서 큰 자극을 받았고 본 프로젝트에도 AOP 를 적용해보자 는 생각으로 프로젝트에 도입했었다.
(N+1 Detector 를 구현하는 기본적인 과정은 레퍼런스 참조)
그런데 만들고 나서 보니 N+1 Detector 가 아니라, QueryCounter, QueryProcessingTimer 뭐 이런식의 명칭이 더 맞지 사실상 N+1 을 '감지' 해주는 기능은 아니었다. 같은 백엔드 팀원 분과 N+1 을 감지하는 기능을 만들어야 하는거 아닌가? 라고 말만 던졌놨다가, AOP 를 좀 더 깊이 학습하고(토비의 스프링) 나니 자신이 생겨서 시도했고 많은 시행착오 끝에... 완전히 근본적이진 해결책은 아니어도 간단하게나마 이를 구현 수 있었다.
사실 결과물만 간단하게 올려볼 수도 있겠지만 삽질을 하며 공부한 것이 많았어서, 기록해두려고 한다. 나중에는 이런 삽질에 대해서 좀 더 깊이 이해할 수 있을까 ? 라는 기대를 하면서...
1. N+1 문제 정의
우선 N+1 이 어떤 상황에서 일어나는 지를 인식해야 이를 감지할 수 있을 것이다. 나는 아래와 같이 N+1 의 발생 흐름(또는 조건)을 정리했다.
① Repository 에 조회 쿼리를 날려서 Dto 또는 Entity 를 가지고 온다.
② 이때, 반환 객체의 필드는 String, Long 과 같은 단순 객체가 아닌 다른 연관 Entity 객체를 포함하고 있다.
③ 해당 연관 객체는 Hibernate 의 Lazy 로딩 설정에 따라 온전한 객체가 아닌 Id 값만 가지고 있는 프록시 객체이다.
④ 해당 프록시 객체에 접근한다.
⑤ Hibernate 는 프록시 객체를 원본 객체로 초기화하기 위해 다시 한번 조회 쿼리를 보낸다.
⑥ 쿼리가 전송되며 N+1 문제가 발생 한다.
위의 문제 발생 과정에서 밑줄 친 부분에 집중해서 N+1 문제의 판단 기준을 세웠고, 이에 맞추어 스프링 AOP 와 Reflection API 를 적절히 활용해 가면서 구현해보았다.
2. 시행 착오
1) Repository 의 반환 객체에서 프록시 객체에 접근
N+1 이 발생하는 근본적인 원인에는 '프록시 객체에 대한 접근' 이 있었다. 나는 프록시 객체에 접근하는 메서드(getter)호출 여부가 곧 N+1 의 판단기준이라고 생각하여, 아래와 같이 AOP 를 적용하였다.
Aspect 클래스 - Repository 메서드 콜을 포인트 컷으로한 Around 메서드
@Around("execution(* com.example..*Repository.*(..))")
public Object captureProxy (final ProceedingJoinPoint joinPoint) throws Throwable {
LoggingForm loggingForm = getLogger();
Object target = joinPoint.proceed();
if(target == null) return null;
if(isSimpleTypeObject(target)) return target;
if(target instanceof Optional<?> targetOptional){
if(targetOptional.isEmpty()) return targetOptional;
Object proxy = new EntityProxyHandler(targetOptional.get(), loggingForm).getProxy();
return Optional.ofNullable(proxy);
}
if(target instanceof List<?> targetList){
return targetList.stream()
.map(t -> new EntityProxyHandler(t, loggingForm).getProxy()).toList();
}
return new EntityProxyHandler(target, loggingForm).getProxy();
}
모든 Repository 의 메서드 콜을 포인트 컷으로 잡고, 해당 메서드의 반환 객체 대신 프록시 객체를 반환하도록 하였다. 이렇게 하면 해당 반환객체의 메서드 콜을 추적(?) 할 수 있게 된다. 반환객체가 없거나, 단순 타입이거나, Optional, List 인 경우 마다 로직을 나누어 놓았다.
EntityProxyHandler 클래스 (MethodInterceptor)의 Invoke 메서드
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Field[] fields = target.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
Object hibernateProxy = field.get(target);
String instanceName = hibernateProxy.getClass().getName();
if(instanceName.contains("HibernateProxy")){
String[] tmp = instanceName.split("\\$");
String entityName = tmp[0].substring(tmp[0].lastIndexOf(".") + 2);
String invocationMethodName = invocation.getMethod().getName();
if(invocationMethodName.startsWith("get") && invocationMethodName.endsWith(entityName)){
loggingForm.setProblemOccurFlag(true);
}
}
}
return invocation.proceed();
}
반환되는 프록시 객체의 로직은 이렇다. 모든 필드를 뒤져서 클래스 이름에 "HibernateProxy" 가 들어가 있는 지를 찾는다. 만약 호출된 메서드가 해당 클래스의 이름으로 끝나면 (getField 중 ield 가 겹치는 지를 확인했다) 해당 필드에 접근한 것을 판단하여 ProblemOccur플래그를 true 로 해놓는 것이다.
문제점
여기에는 두 가지 문제가 있다. 첫째, 해당 프록시 객체에 접근한다고 해도, 초기화가 완료된 필드에 접근하는 경우 N+1 이 발생하지 않는다. 둘째, getter 가 아닌 다른 메서드로 접근하는 경우는 아예 프록시 객체에 접근했는지를 추적할 수 없다. 두번째 같은 경우는 사실 프로젝트에서 getter 이외의 메서드에서 연관 객체에 접근하는 메서드가 없기 때문에 마이너한 문제라고 해도, 첫번째 문제는 거의 결정적으로 오작동을 일으킬 여지가 있었다.
2) 프록시 객체에서 필드가 초기화 되지 않은 get 함수 호출
위 판단 기준이 실패하여 사실상 가장 확실한 판단 기준을 정했다. 레이지 로딩의 근본적인 원인이라고 할 수 있다. 이걸 가능하게 하는 방법이 두가지가 있었다.
1. 위 로직에서 HibernateProxy 객체를 get으로 호출했을 때, HibernateProxy 객체를 다시 프록시로 한번 감싸서 반환하기
2. AspectJ 를 이용하여 Entity 로 어노테이션 된 클래스의 메서드 콜을 포인트 컷으로 지정하기
두 방법은 모두 각자의 이유로 실패하였다. 아래 이유만 남기고 코드는 생략하겠다.
문제점
1번. HibernateProxy 객체는 어떤 이유에서인지 ProxyFactory 가 프록시 객체를 생성해주지 못했다. 아마, 리플렉션이 제대로 먹히지 않은 모양인 듯 했다. (이때는 다이내믹 프록시 / CGLIB 를 몰랐어서 그냥 ProxyFactory 에 타겟 설정만했었는데, 다른 방식으로 프록시 객체를 만들 수 있을 지도 모르겠다. 그렇지만 기본적으로 이런 프록시 방식은 너무 무모하고 예외 상황이 많을 것 같다.)
2번. AspectJ 가 클래스의 바이트 코드를 뜯어 고치는 것을 Weaving 이라고 하는데, Entity 의 경우 Load-Time Weaving 이라는 런타임시에 클래스를 조작해주는 기술이 필요하다. 이건 내가 아직 엄두를 못내는 고급기술이라 패스하기로 했다. 나중에 시간이 되면 해보려고한다. ㅠㅠ
3) Repository 의 반환 객체에서 프록시 객체에 접근했을 때, Repository 의 호출 없이 쿼리 실행
이번엔 약간 생각을 조금 전환 했다. 단순히 HibernateProxy 의 접근 기준으로만 판단하는 것이 아니라 N+1 문제가 발생하는 과정에서 Repository 를 경유하지 않은 Query 의 실행을 판단 기준에 추가해 준 것이다. 로직은 이렇다.
1. HibernateProxy 에 접근(get 메서드)하면 Logger 에서 setHibernateProxyAcessFlag(true) 라는 메서드를 실행하여 프록시 객체 접근을 체크해둠
2. Repository 를 Invoke 하면 다시 setHibernateProxyAcessFlag(false) 로 되돌림
3. hibernateProxyAcess 가 true 인 상태에서 쿼리가 실행되면 (PreparedStatement 의 프록시 객체는 이미 있다. 레퍼런스 참고) setProblemOccurFlag(true) 로 전환 하여 문제 발생을 판단함
문제점
1. 더티체킹으로 객체 업데이트 시 Repository 를 공유하지 않고도 쿼리가 나갈 수 있다.
2. 여전히 get 요청으로 접근 하는 것만 추적함
3. 엔티티들을 일괄적으로 프록시 하는 것이 거의 불가능함
1 번은 해결할 수 있는 문제였고, 2번은 타협할 수 있는 문제였으나, 3번은 해결과 타협 둘 다 안되는 문제였다. 클래스로더의 불일치로 Cast 예외를 던지기도 하고, 상속 엔티티들이 부모 엔티티의 서브클래스 아니라고 예외를 반환하기도 하고... 여튼 이때 ProxyFactory 의 한계를 느꼈다.
3. 최종 구현
위의 방법들의 공통점은 Hibernate 가 생성한 프록시 객체를 추적한다는 데에 있었다. 사실 이 방법은 N+1 의 발생 원인을 직접적으로 추적하기 위한 시도인데, Spring AOP 의 한계를 느끼고 포기했다. 결국 스프링 빈에 등록 되어있는 객체들을 가지고 추론하는 방법밖에는 없다는 결론을 내렸다.
최종 : Repository 의 호출 없이 Select 쿼리 실행
좀 전의 3번 항목에서 HibernateProxy 의 접근을 추적하는 것을 제외한 로직이다. 현재 PreparedStatement 는 DB에 쿼리를 전송할 때 excute, excuteQuery, executeUpdate 라는 세가지 메서드를 이용한다. excuteQuery 는 Select 쿼리, executeUpdate는 update /delete 쿼리 보낼때 사용되고 execute 는 전부 해당되나 리턴 값이 해당 메서드로 인하여 변경되거나 선택되는 row 의 수를 반환한다. 그러니까 확실하게 LazyLoading 에서 객체를 초기화 할 때는 executeQuery 라는 메서드를 이용하게 된다.
이 방법은 위의 1번 방법에서 더티체킹이라는 Repository 를 경유하지 않고 발생되는 쿼리를 필터링 해줄 수 있으니 꽤 신뢰할만한 방법이다. 하지만 Repository 를 경유하지 않고 Select 쿼리가 나가는 경우가 Lazyloading 이외에도 있다면(나는 아직 파악하지 못했다.) 적절하지 못하게 작동할 것이다.
아래는 최종 코드이다.
@Pointcut("execution(* com.example..*Repository.*(..))")
public void condition1(){}
@Pointcut("execution(* com.example..*RepositoryImpl.*(..))")
public void condition2(){}
@Pointcut("execution(* com.example..*RepositoryCustom.*(..))")
public void condition3(){}
@Around("condition1()||condition2()||condition3()")
public Object entityProxy (final ProceedingJoinPoint joinPoint) throws Throwable {
LoggingForm loggingForm = getLoggingForm();
loggingForm.setRepositoryInvocationFlag(true);
loggingForm.addCalledMethod(joinPoint.getSignature().getName());
return joinPoint.proceed();
}
@Nullable
@Override
public Object invoke(@NonNull final MethodInvocation invocation) throws Throwable {
final Method method = invocation.getMethod();
if (JDBC_QUERY_METHOD.contains(method.getName())) {
if(!loggingForm.isRepositoryInvocationFlag() && method.getName().equals("executeQuery")) {
loggingForm.setProblemOccurFlag(true);
}
final long startTime = System.currentTimeMillis();
final Object result = invocation.proceed();
final long endTime = System.currentTimeMillis();
loggingForm.addQueryTime(endTime - startTime);
loggingForm.queryCountUp();
loggingForm.setRepositoryInvocationFlag(false);
return result;
}
return invocation.proceed();
}
Repository 의 메서드를 invoke 한 경우 RepositoryInvocationFlag 가 true 가 된다. 그리고 DB 로 Query 를 실행하게 되면(excute, executeQuery, executeUpdate 메서드 invoke) 다시 RepositoryInvocationFlag 는 false 로 돌아간다. 이 상태에서 만약 executeQuery 가 추가적으로 실행된다면 Lazy Loading 이라 판단하고 ProblemOccurFlag 가 올라가는 것이다.
결론
현재까지 내가 배우고 이해한 AOP 지식을 총 동원하여 리팩터링을 하였는데, 다소 한계점이 있다고 하더라도 만족할만한 결과를 얻은 것 같아서 뿌듯하다. 사실 그보다 AOP 관련 공부를 깊게 할 수 있어서 좋았다. 추후 기회가 된다면 꼭 Load-Time Weaving 을 이용하여 구현해보고 싶다.