코딩못하는사람

N+1 쿼리 문제 해결로 성능개선하기 본문

스프링부트(SpringBoot)/활용

N+1 쿼리 문제 해결로 성능개선하기

공부절대안함 2021. 6. 7. 05:22

처음 웹서비스 제작할때는 생산성을 위해 Spring Data JPA를 사용하여 자동으로 쿼리문을 만들었다.

하지만 API하나하나 수행될때마다 연관된 쿼리가 너무 많이 수행되서 공부한 내용을 바탕으로 성능개선을 해보고자 한다.

 

 

이번에 제작한 웹서비스에는 회원간 메세지 기능이 있다.

메시지 엔티티에는 보내는회원과 받는회원이 회원 엔티티를 각각 1:1 단방향 연관관계로 가지고있다.

 

SpringDataJPA를 통해서 처음 만들어낸 Repository 메서드를 보자.

public interface MessageRepository extends JpaRepository<Message, Long> {
	List<Message> findBySentMember(Member member);
    	List<Message> findByReceivedMember(Member member);
}

위의 Repository와 쪽지함 이미지를 보고 쿼리 수를 예측해보자.

API는 Message Entity 에서 MessageReponseDTO로 변환되어서 리스트로 반환된다.

 

1.메시지 리스트 뽑아내기

우선 Message 테이블에서 ReceivedMember id가 내 세션 id와 똑같은 메시지를 검색해서 메시지 리스트를 만들 것이다.

이 쿼리가 날라가서 쪽지함 이미지의 3개 메시지를 찾아올 것이다.

이제 3개의 메시지를 DTO로 반환하기 위해서 3번 반복문을 돌면서 확인할 것이다.

쿼리 1번

 

2.1번 메시지 DTO변환

sentMember와 receivedMember를 Member테이블에서 각각 한번씩 쿼리를 날려서 해당 회원을 조회한다.

첫번째 쪽지를 DTO 변환하면서  sentMember와 receivedMember 때문에 위 쿼리가 두번 나간다.

sentMember 처음 발견된 'tester'

receivedMember 처음 발견된 '로그인 사용자인 나'로 쿼리가 2번 생성된다.

쿼리 1번+2번

 

3.두번째 쪽지 변환.

두번째 쪽지 볼필요없이 쪽지 3개 *(sent,received member) 2개 곱해서 1+6쿼리 아니냐 라고 생각할 수 도 있다.

하지만 그건 이번 사례와는 상관없고 만약 처음 Message를 뽑아오는 쿼리에서 sentMember와 receivedMember가 6명 모두 다른 사례였다면 맞는 경우이다. 왜 다를까?

JPA 특징을 기억해보자. JPA 트랜잭션 한번에 이미 찾았던 정보는 영속성컨텍스트에 저장해 놓고 필요한 정보가 있을때는 영속성 컨텍스트부터 뒤져보고 쿼리를 날린다. 이걸 기억하고 두번째 메시지를 보면 두번째 메시지는 첫번째 메시지와 발신자와 수신자가 똑같다. 그럼 당연히 쿼리가 안나갈 것이다.

쿼리 1번+2번+0번

 

4.마지막 쪽지를 변환.

위의 개념을 생각해본다면 sentMember만 새로운 정보이므로 한번의 쿼리만 추가될 것이라는 걸 유추 가능하다.

쿼리 1번+2번+0번+1번

 

따라서 받은 메시지를 조회하는 API를 사용하는대 1번 + 3번의 추가적인 쿼리가 생성되었다.

 

fetch join 사용하기

fetch join이란?

  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능

fetch join 을 사용하면 필요한 정보들이 sql 입장에서는 join이 되어 Select 절에서 한번에 긁어온다.(Lazy로 설정되어있어도 바로 값을 채워서 가져온다)

@Query 어노테이션을 사용해서 리파지토리 인터페이스에 쿼리를 직접 정의해주자

 

public interface MessageRepository extends JpaRepository<Message, Long> {
    @Query("select m from Message m " +
            "join fetch m.receivedMember " +
            "join fetch m.sentMember " +
            "where m.receivedMember.id=:sentMember_id")
    List<Message> findBySentMember(@Param("sentMember_id") Long id);
    @Query("select m from Message m " +
            "join fetch m.receivedMember " +
            "join fetch m.sentMember " +
            "where m.receivedMember.id=:receivedMember_id")
    List<Message> findByReceivedMember(@Param("receivedMember_id") Long id);
}

 

다음과 같이 직접 정의해주고 쿼리를 보자.(fetch는 jpql 전용 문법이다)

 

이전과 같은 API 요청을 날린다면 이제 쿼리 1번만 날리는 것을 확인할 수 있다.

 

 

느낀점.

이렇게 표본이 작은 예시로 성능개선 테스트를해서 쿼리수를 비교했는대도 체감이 되는대

N+1 쿼리 이름이 붙은 것처럼 N이 커지면 성능차이가 어마어마할 것 같다.

Comments