도입한 이유
쿼리 튜닝을 하는 과정에서 나간 쿼리의 개수를 측정하는 과정에서 불편함이 있었습니다.
특히 아이돔 프로젝트에서는 Spring-Data-JPA를 사용합니다. 그로 인해 JPA N+1 문제와 같이 의도와 다르게 발생한 쿼리의 개수를 모니터링할 필요가 있었습니다.
문제 정의
1. API의 실행 시간 측정
2. 쿼리가 실행된 개수 카운트
3. 실행된 쿼리들을 리스트업
4. N+1 문제가 의심되는 쿼리를 체크
→ 의심 조건 : 동일한 SELECT 문이 2개 이상 사용되었는가
구현 방식
일반적으로 쿼리 카운터를 구현하기 위해 고려하는 방법은 2가지 입니다. 두 가지 방식 모두 적용해 보겠습니다.
방식 1. 하이버네이트의 StatementInspector 인터페이스
먼저 ORM 프레임워크인 Hibernate에서 제공하는 기능입니다.
A very useful, yet lesser-known, Hibernate feature is the ability to intercept and modify any auto-generated SQL statement using the Hibernate StatementInspector utility.
In this article, we are going to see how the Hibernate StatementInspector mechanism works.
출처 : https://vladmihalcea.com/hibernate-statementinspector/
StatementInspector는 Hibernate 유틸리티를 사용해서 자동 생성된 SQL 문을 가로채고 수정할 수 있는 기능입니다.
1번 방식으로 사용하여 구현된 라이브러리는 해당 링크를 참고하시면 됩니다. ➡️ https://github.com/sogorae/jpa-query-counter
아래는 위의 라이브러리 내 코드 적용기입니다.
1) JpaInspector로 SQL을 가져옵니다.
StatementInspector 인터페이스
오버라이드한 inspect 메서드는 실행될 SQL 문을 가로챌 수 있습니다.
해당 메서드에서 API 당 발생하는 SQL 문을 저장하고 쿼리 개수를 카운트하는 작업을 하도록 했습니다.
2) QueryCountInterceptor로 JpaInspector을 관리하고 필요한 정보를 로깅합니다.
- QueryCountInterceptor의 preHandle()에서 api 요청이 올 때마다, jpaInspector의 start()를 호출하여 JpaInspector의 QueryCounter 객체를 초기화합니다.
- afterCompletion()에서 JpaInspector에서 필요한 정보를 가져와 로깅합니다.
3) 이후 WebMvcConfig에서 작성한 인터셉터를 등록합니다.
4) 실행 결과
방법 2. 다이나믹 프록시
이 방식으로 구현하기 위해 활용할 부분은 JPA가 JDBC API를 이용한다는 점입니다.
JPA를 사용하여 데이터베이스에 접근한다는 것은 Connection 객체를 통해 PrepareStatement 객체를 생성하여 쿼리를 실행합니다.
따라서, 만약 connection 객체가 prepareStatement() 메서드를 호출한다면 쿼리 카운트를 증가시키도록 합니다.
1) QueryCounter
쿼리의 개수를 저장하는 객체입니다.
2) ConnectionHandler
동적으로 proxy를 설정하는 핸들러로, InvocationHandler 인터페이스의 구현이 꼭 필요합니다.
이 중 invoke() 메서드가 핵심입니다. 해당 메서드는 다이나믹 프록시의 target의 메서드 호출을 가로챌 수 있습니다. 구현할 타겟 클래스의 모든 메서드에 직접 접근하고, 동적으로 리턴 값을 바꾸거나 부가관점을 추가할 수 있습니다.
해당 메서드에서는 connection 객체가 prepareStatement() 메서드를 호출할 때마다 쿼리 카운트를 증가시키도록 하였습니다.
3) PerformanceAspect
이제 위의 핸들러가 작동할 수 있는 Connection의 다이나믹 프록시를 만들어야 합니다. Connection 객체를 프록시 객체로 대체하는 것은 DataSource.getConnection() 메서드 호출 시 실행할 동작입니다.
이번 방식의 쿼리 카운터는 스프링 AOP 기반으로 구현을 합니다. AOP 사용 조건은 원 객체가 빈으로 등록된 싱글톤 객체여야 합니다. 이 방식에서 타겟으로 설정하려는 Connection은 싱글톤 객체가 아니지만, Connection은 Datasource에서 getConenction()으로 얻게 됩니다. 바로 이 Datasource가 Bean으로 등록되어 있기 때문에 문제없이 활용할 수 있습니다.
AOP : 기존 기능을 담은 객체(타겟)의 프록시에 부가 기능(관점, Aspect)을 추가해 주는 방식
DataSource는 스프링 Bean 이므로, 스프링 AOP 기반으로 쿼리 카운터를 구현해 보겠습니다. Datasource가 getConnection()을 호출할 때마다, 이를 낚아채서 쿼리 카운터 기능을 추가한 프록시 객체를 리턴하도록 합니다.
위의 클래스 속 getProxyConnection() 메서드의 구현이 아래와 같은데요.
return (Connection) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[]{Connection.class},
new ConnectionHandler(connection, counter)
);
다이나믹 프록시를 통해 커넥션의 프록시 객체를 생성하였기 때문에 다이나믹 프록시의 준비물인 클래스 로더, 원 타겟 클래스, 다이나믹 프록시가 필요합니다. 이 중에서 직접 구현이 필요한 다이나믹 프록시는 2번 ConnectionHandler를 통해 구현하였습니다.
1번의 QueryCounter 객체가 스레드별로 할당되게 하기 위해서는 ThreadLocal을 이용합니다.
스레드 영역에 변수를 설정할 수 있기 때문에, 특정 스레드가 실행하는 모든 코드에서 그 스레드에 설정된 변수 값을 사용할 수 있게 됩니다.
2번의 다이나믹 프록시에서, 커넥션 속에 close() 메서드가 실행되었을 때 쿼리 개수 로그가 찍히도록 구현하였습니다.
4) 실행 결과
아이돔이 선택한 방식
1번 방식의 StatementInspector 방식은 Hibernate를 사용하지 않는 쿼리인 경우에는 카운트에 포함되지 않는 문제가 있습니다. 만약 하이버네이트 이외의 구현체를 사용하거나 JdbcTemplate을 사용하는 경우와 같이 범용성 있는 쿼리 카운터를 구현하고 싶다면 2번 방식이 더 적합한 방식입니다.
하지만 현재 아이돔 프로젝트는 더 이상 기획 수정 사항이 없으며 구현도 마무리 단계이기 때문에 1번 방식을 사용해도 크게 문제가 없을 것이라 판단했습니다. 2번 방식을 1번 방식처럼 쿼리나 API에 대한 추가적인 것들을 출력하고 싶다면, 다이나믹 프록시를 한번 더 적용하는 방법 등을 고려해야 하므로 구현에 좀 더 편리한 1번 방식을 채택하였습니다.
참고 자료 🙇♀️ :
https://likispot.tistory.com/73
https://velog.io/@betterfuture4/속닥속닥-우당탕탕-쿼리-카운터-개발기
https://velog.io/@ohzzi/API의-쿼리-개수-세기-2-JDBC-Spring-AOP-Dynamic-Proxy를-활용한-카운팅
https://gong-check.github.io/dev-blog/BE/범고래/N+1/nplus1/
https://github.com/sogorae/jpa-query-counter
'Spring' 카테고리의 다른 글
[Spring] 이메일 인증 기능 비동기 처리와 주의사항: @Async, @EnableAsync (0) | 2024.04.10 |
---|---|
[Spring] 프록시 방식의 AOP 한계: self-invocation 이슈 (0) | 2024.04.09 |
[Spring] XToOne 조회 기능에 대한 쿼리 튜닝: N+1문제 해결, 페이징 최적화 (0) | 2024.04.04 |
[Spring] 헥사고날 아키텍처 도입기: 계층형에서 헥사고날 아키텍처로 (0) | 2024.03.22 |
[Spring] 컨트롤러 내 회원 인증 중복 코드 개선기 (2) | 2024.03.12 |