JPA 쿼리 개선기1

yboy·2022년 10월 2일
2

Learning Log 

목록 보기
22/41
post-thumbnail

학습 동기

우아한테크코스 프로젝트의 기능이 얼추 마무리되고 드디어 리팩토링을 하게 됐다. 리팩토링 주제는 쿼리 개선하기!! 예전부터 하고 싶었던 주제였는데 드디어 기회가 왔다. 쿼리에 대해서는 그저 JPA에서 제공해주는 메서드를 이용해서 모두 처리하고 있었다.... JPQL을 직접 정의해 준다던지 하는 것 조차 안했어서 내부적으로 어떤 쿼리가 나가는지 궁금했는데 쿼리를 개선해보면서 한 경험을 정리하기 위해 글쓰기를 다짐했다.

학습 내용

쿼리 개수 로깅하기

리팩토링을 하면서 처음으로 한 일은 각 페이지(메인 페이지, 내 정보 보기 등등) 별로 한 요청마다 나가는 쿼리 개수를 로깅해 확인해 보는 것이었다. 이 부분은 우리팀 열정맨 아키가 사전에 처리해주었다,.(땡스 아키🥲) StatementInspector를 이용해서 이를 처리했는데 간단하게 알아보자.

StatementInspector

아래 링크들에 적용법에 대해 자세하게 나와있으니 여기선 간단하게 알아보자.
아키가 정리해 둔 자료
오찌의 벨로그

Inspect the given SQL, possibly returning a different SQL to be used instead.
Note that returning null is interpreted as returning the same SQL as was passed.

주어진 SQL을 검사하거나, 다른 SQL문을 대신 반환할 수 있다.
null을 반환하는 것은 동일한 SQL문을 전달하는 것을 의미한다.

하이버네이트 공식문서에서는 StatementInspector를 위와 같이 정의하고 있다. 이를 통해 JPA가 자동으로 생성하는 SQL문을 중간에 조회하거나 수정할 수 있다.

1. StatementInspector를 구현한 구현체를 bean으로 동록한다.
2. 구현체를 HibernateProperties에 등록한다.

StatementInspector의 구현체를 만들었다고 쿼리를 바로 가로챌 수 있는 것은 아니다. 이 구현체를 하이버네이트가 사용하도록 설정해주어야 한다.
3. 인터셉터나 필터에서 로그 기록

이렇게 요청마다 발생하는 쿼리를 카운팅할 수 있게 되었다. 실제 운영서버로 들어가 log파일을 살펴보면

다음과 같이 쿼리 개수와 쿼리문들을 확인할 수 있다.(n+1 바로 보인다....🥲)

JPQL 정의하기

요청마다 쿼리 개수를 확인하고 다음에 한 일은 JPQL을 정의하는 일이었다. 정의하기 전에 적용하는 기준을 먼저 정했는데 다음과 같다.

where문에 올 조건이 3개 이상일 경우 (order by 포함)

위와 같이 기준을 정한 이유는 메서드의 가독성을 위해서다. 조건이 세개 이상이면
findByCrewIdOrderByScheduleLocalDateTimeDesc
위와 같이 가독성이 많이 저하된다. Spring data jpa에서 네이밍에 따라 자동으로 쿼리를 생성해 준다고 해도 저런 네이밍은 가독성이 많이 떨어진다고 판단했다. @Query 에노테이션을 사용해 JPQL을 정의해준다면 저 메서드의 이름을
findAllByCrewIdLatestOrder
다음과 같이 좀 더 가독성있게 바꿀 수 있다.

join 타입은 꼭 명시해주기

이제 위와 같이 조건이 많은 쿼리에 대해 JPQL로 쿼리문을 정의해 준 후, RepositoryTest를 다시 해보며 실제 날라가는 sql문을 분석해 봤다.

@Query("SELECT r FROM Reservation AS r "
        + "WHERE r.schedule.coach.id = :coachId "
        + "AND r.reservationStatus NOT IN :status")
List<Reservation> findByCoachIdAndStatusNotIn(Long CoachId, ReservationStatus status)

위와 같이 cross join이 날아가는 것을 확인해 볼 수 있었다. inner join이나 outer join에 대해서는 많이 들어봤는데 cross join이 뭘까?

Cross join(교차 결합)

카티션곱이 라고도 불리며 곱집합으로 계산되는 조인 방식

간단하게 말하면 조인할 테이블1에 20만개의 데이터가 있고 테이블2에 20만개의 데이터가 있다면
20만개 X 20만개 = 400억개의 데이터가 조인문을 통해 생성된다.(성능상 매우 안 좋은 방식이다.)

위에서 처럼 조인 타입을 명시 하지 않고 r.schedule.coach.id와 같이 경로 표현식으로 JPQL을 작성하면 자동으로 cross join방식으로 조인이 이루어진다.

⭐️ 따라서 JPQL로 조인문을 작성할 때는 경로 표현식을 사용하지 말고 아래와 같이 조인 타입을 명시해 주어야한다.

    @Query("SELECT r FROM Reservation AS r "
            + "INNER JOIN r.schedule AS s "
            + "INNER JOIN s.coach AS c "
            + "ON c.id = :coachId "
            + "WHERE r.reservationStatus NOT IN :status")
    List<Reservation> findAllByCoachIdAndStatusNot(Long coachId, ReservationStatus status);

명시해 주면 위와 같이 명시해준 대로 inner join으로 sql이 생성되는 것을 확인할 수 있다.

Spring data jpa deleteAll 메서드 개선

이어서 개선한 쿼리는 Spring data jpa를 사용하면 디폴트로 제공해 주는 deleteAll 메서드이다. 이 메서는 언뜻보기에는 delete from Reservation 같이 테이블의 모든 정보를 한 번에 삭제해주는 것처럼 보이지만 사실은 아니다.
이는 잉과 페퍼의 JPA 삽질일지 테코톡에서 다루고 있기도 한데 한 번보면 도움이 될 것같기도 하다.(내가 해서 홍보하는 거 아님 ㅋㅋㅎ)

deletAll의 동작 방식
1. Spring data jpa의 findAll 메서드를 실행하여 db에서 테이블의 모든 데이터를 가져온다.
2. 데이터를 영속성 컨텍스트 1차 캐시에 저장해 둔다.
3. 1차 캐시에 있는 데이터의 수 만큼 delete from Reservation where id = ? 같은 delete문을 쓰기 지연 저장소에 저장해 둔다.
4. flush가 일어날 때 delete문들이 db에 반영된다.

문제

문제는 3번에서 1차 캐시에 있는 데이터의 수 만큼 delete문이 생성된다는 것이다.

void deleteAllByCoachIdAndLocalDateTimeBetween(Long coachId, LocalDateTime start, LocalDateTime end);

위가 @Query 에노테이션으로 JPQL을 정의하지 않은 문제의 메서드이다. 실행을 해보면

위와 같이 delete문이 데이터의 수 만큼 기하 급수적으로 생성되는 것을 확인할 수 있다.

해결

@Modifying
@Query("DELETE FROM Schedule AS s "
            + "WHERE s.coach.id = :coachId "
            + "AND s.localDateTime > :start "
            + "AND s.localDateTime < :end")
    void deleteAllByCoachIdAndTimeBetween(Long coachId, LocalDateTime start, LocalDateTime end);


다음과 같이 JPQL을 정의해 줌으로써 해결할 수 있었다. 여기서 주의해야 할 점이 있는데 벌크 업데이트임을 명시하는 @Modifying 에노테이션을 명시해 주지 않으면

위와 같은 hibernate에러가 발생하게 된다. 벌크 업데이트를 할 때는 @Modifying 에노테이션을 꼭 명시해 주자!

마무리

JPA를 이용한 쿼리를 개선해보면서 JPA를 사용하면 개발자를 SQL에서 자유롭게 해줄 수 있다. 는 이전에 했던 생각이 틀렸음을 인지하게 되었다. 자유로운게 아니라 JPA를 사용하면 SQL을 몰라도 웹 개발을 할 수는 있다.가 맞는 표현일 것이다. 앞으로 n+1문제 같은 개선이 시급한 쿼리들도 차근차근 개선해 나갈 생각이다. 2탄도 기대해 주세요🙏🏻

0개의 댓글