케네스로그

JPA N+1, 불필요한 쿼리를 줄여보자 w. join fetch 본문

Dev/JPA

JPA N+1, 불필요한 쿼리를 줄여보자 w. join fetch

kenasdev 2023. 2. 23. 18:06
반응형

JPA N+1, 불필요한 쿼리를 줄여보자

이전 포스팅에서 트랜잭션을 고려한 리팩토링을 통해 불필요한 쿼리를 줄이는 작업을 진행했다.

모든 controller - service layer에서 예제 코드처럼 작성되었던 것은 아니었지만 몇몇 케이스가 존재했기에 개선작업을 수행했다.

그럼 이제 모두 끝난 것인가?🤔

 

여전히 동작만 간신히 하고 있는 나의 프로젝트

어림도 없다. 개선작업에는 끝이없다.

이번에 풀어볼 N+1문제 이외에도 캐싱을 사용한다거나 아키텍쳐 레벨에서도 개선이 진행가능할 것으로 보인다.

차차 해결하는 것으로 하고, 이번 포스팅에서는 엔티티 간의 관계에 따른 추가 쿼리가 발생하는 N+1이슈를 분석하고 개선하도록 하자.

 

프로젝트의 엔티티 관계

우리 팀에서 기획/개발했던 프로젝트는 간단한 형식의 회원제 게시판이다.

이 글에서는 기술적으로 분석하고 개선하는 과정을 담고 있음으로, 구체적인 비즈니스 요구사항은 생략하도록 한다. 자 이제 프로젝트의 엔티티 간 관계를 간단히 설명하면 다음과 같다.

기본적으로 회원(Member)가 존재하며, 회원은 인가를 위한 권한(Role)을 지니며, 프로필 이미지(Profile Image)를 가질 수 있으며, 해당 회원의 선호 기술스택(Stack)이 등록된다.

회원은 게시글(Post)을 작성할 수 있으며, 게시글에서 목표로 하는 기술스택(Stack)이 등록된다. 또한 회원은 게시글에 대한 덧글(Comment)을 작성할 수 있다.

 

프로젝트 정밀분석 시작!

모든 API와 서비스 메소드에 대해 검증을 수행하였으며, 개선과정을 문서로 남겨서 작업을 빠뜨리는 일이 없도록 했다.

가장 N+1문제와 그 해결과정을 풀어내는데에는 연관관계 매핑이 많이 잡혀있는 게시글 엔티티가 적절하다고 판단하였으며,

이번 포스팅에서는 게시글 상세조회에 대해 진행했던 개선작업에 대한 기록과 성찰을 남겨본다.

 

게시글 상세 조회를 위한 엔티티 관계 구조

먼저 N+1이슈로 인해 쿼리가 많이 발생하고 있을것으로 추정되는 게시글 상세 조회에 대해 다뤄보고자 한다.

게시글 상세조회를 위한 엔티티의 관계는 위와 같이 표현할 수 있다.

  • 게시글이 조회되면, 게시글 작성자와 등록된 덧글, 게시글에서 정의된 기술스택이 로드되어야 한다.
  • 게시글에 등록된 덧글들에 대해서는 게시글 작성자와 해당 작성자의 프로필이미지가 로드되어야 한다.

N+1 문제 상황의 파악

게시글 상세 조회 service method

@Transactional(readOnly = true)
public SearchPostResponse findPostById(Long postId) {
    Posts findPost = null;

    findPost = postRepository.findPostById(postId); // 1

    Member findPoster = findPost.getPoster(); // 2

    ImageDto findProfileImage = ImageDto.toDto(findPoster.getProfileImage()); // 3

    List<Comment> commentList = commentRepository.findCommentByPostId(postId); // 4

    for(Comment comment : commentList) {
        Member commentWriter = comment.getMember(); // 5
        ImageDto commenterProfile = ImageDto.toDto(commentWriter.getProfileImage()); // 6
        CommentDto.toDto(comment.getMember(), findPost, commenterProfile, comment)); // 7
    }
		
		..
}

N+1 이슈를 고려하지 않고 작성된 코드를 발췌해왔다. 예외처리 및 로그는 가독성을 위해 생략했으며, 부수적인 로직들 또한 생략했다.

전체적인 조회에 대한 비즈니스 로직 흐름은 아래와 같다.

  1. request의 post id를 기반으로 데이터베이스에서 조회
  2. 조회한 게시글의 작성자를 가져옴
  3. 게시글 작성자의 프로필 이미지를 DTO형태로 저장
  4. 해당 게시글의 덧글들을 모두 조회
  5. 각 덧글의 작성자 조회
  6. 덧글 작성자의 프로필 이미지 DTO형태로 저장
  7. 조회된 덧글 작성자의 닉네임, 프로필이미지 등을 저장

위의 service 메소드에서 사용된 Repository 메소드는 아래와 같다.

public Posts findOne(Long id) {
	return em.find(Posts.class, id);
}

public List<Comment> findCommentByPostId(Long postId) {
    return em.createQuery("select c from Comment c where c.post.postId = :postId order by c.createdTime asc", Comment.class)
            .setParameter("postId", postId)
            .getResultList();
}

위의 작업을 수행하기 위해 발생된 쿼리는 아래와 같다.

SELECT post WHERE post_id = ?
SELECT member WHERE member_id=?
SELECT comment WHERE post_id=?
SELECT member WHERE member_id=?
SELECT member WHERE member_id=?
SELECT member WHERE member_id=?
SELECT member WHERE member_id=?
SELECT stack WHERE post_id=?

3번째에서 덧글을 조회한 후, 각 덧글의 작성자에 대한 쿼리가 등록된 덧글의 갯수만큼 발생한다. 끝으로 해당 게시글에서 명시된 기술스택을 위한 조회 쿼리가 발생한다. 쿼리 리스트를 보면 알 수 있지만, 어떠한 join도 없이 단건 쿼리를 통해 진행되었음을 알 수 있다.

 

*가독성을 위해 별칭 및 전체적인 포맷은 어떤 쿼리인지 알 수 있을정도로만 축약했다.

*해당 게시글의 덧글은 4개이며, 모든 회원은 프로필 이미지가 없는 것으로 되어있다. 프로필 이미지가 있다면, 추가 쿼리가 발생했을 것이다.

 

현상 파악! 이녀석, 왜 이럴까? feat 지연로딩과 프록시

@Entity
public class Posts {

    private Long postId;
		
    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="member_id")
    private Member poster;

    @OneToMany(mappedBy="post", fetch = FetchType.LAZY, cascade = ALL, orphanRemoval = true)
    private List<StackRelation> designatedStack = new ArrayList<>();

    @OneToMany(mappedBy="post", fetch = FetchType.LAZY, cascade = ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

}

위의 코드 예시는 게시글 엔티티 클래스이며, 엔티티를 정의하면서 연관 엔티티에 대해 관계를 맺도록 해두었다.

Post와 연관이 있는 poster, designatedStack과 comments는 게시글이 조회될 때, 즉시 조회되는 것이 아니다. Post 엔티티를 조회한 후, 연관 엔티티들에 대한 필드를 Null 상태로 두는것이 아니라 JPA 구현체인 Hibernate가 해당 필드를 프록시의 상태로 만들어 둔다. 그 이후, 비즈니스 로직 등에서 해당 필드를 호출하면 그때 조회쿼리를 발생시켜 데이터를 로드해온다. 이를 지연로딩이라고 한다.

  • 왜 이런 방식을 사용할까?

엔티티를 조회할 때, 연관 엔티티를 모두 조회하는 것을 즉시로딩이라고 한다. 이러한 즉시로딩은 한번에 해결하니 좋을 수 있겠지만, 사용되지 않는 데이터들까지 모두 조회를 하기때문에 비효율적이다. 현재 프로젝트의 수준에서는 고작 몇개의 쿼리가 더 발생하는 것 뿐이겠지만 규모가 커지고 엔티티의 연관관계 복잡도가 높아질수록 리소스 낭비가 기하급수적으로 증대된다. 이러한 비효율성을 방지하기 위해 지연로딩 전략을 사용한다.

  • 즉시로딩을 피하기 위한 지연로딩, 그 다음은?

즉시로딩은 불필요한 엔티티에 대한 쿼리로 인한 리소스 낭비를 막기 위함이었다. 그런데 지연로딩에서는 필요한 엔티티에 대해서도 쿼리가 발생하지 않았다는 문제점이 있었다. 이런 문제를 해결하기 위해서는 필요한 엔티티를 처음 조회할 때 함께 가져오는 것이다.

 

연관된 것들 함께 가져오기, join fetch

JPQL의 join fetch을 통해서 연관 엔티티에 대한 쿼리를 한번에 수행할 수 있다.

- 게시글이 조회되면, 게시글 작성자와 등록된 덧글, 게시글에서 정의된 기술스택이 로드되어야 한다.

앞서 게시글 상세조회를 위한 로직에서 위와 같은 사항을 언급했었다. 그럼 위의 게시글 상세조회 코드에서 개선되어야 하는 점은 ‘게시글 조회 시 작성자, 덧글, 기술스택을 함께 조회’하는 것이다. 이러한 부분을 join fetch를 통해 해결해보았다.

 

join fetch 적용하기

@Transactional(readOnly = true)
public SearchPostResponse findPostById(Long postId) {
  Posts findPost = null;

  findPost = postRepository.findPostWithStackAndPoster(postId);

  Member findPoster = findPost.getPoster();
  ImageDto findProfileImage = ImageDto.toDto(findPoster.getProfileImage());

  List<Comment> commentList = commentRepository.findCommentByPostId(postId);
  List<CommentDto> commentDtoList = new ArrayList<>();

  for(Comment comment : commentList) {
      Member commentWriter = comment.getMember();
      ImageDto commenterProfile = ImageDto.toDto(commentWriter.getProfileImage());
      commentDtoList.add(CommentDto.toDto(comment.getMember(), findPost, commenterProfile, comment));
  }
		
	...
}

service 단에서의 변화는 거의 없다. 단지 엔티티를 조회하는 Repository 메소드를 join fetch가 적용된 것으로 변경하였다.

 

public Posts findPostWithStackAndPoster(Long postId) {
    return em.createQuery("select p from Posts p " +
                "join fetch p.designatedStack " +
                "join fetch p.poster " + 
                "where p.postId = :postId", Posts.class)
            .setParameter("postId", postId)
            .getSingleResult();
}

public List<Comment> findCommentByPostId(Long postId) {
    return em.createQuery("select c from Comment c " + 
                "join fetch c.member " +
                "where c.post.postId = :postId" + 
                "order by c.createdTime asc", Comment.class)
            .setParameter("postId", postId)
            .getResultList();
}

Repository의 게시글/덧글 조회 메소드를 위와 같이 변경하였다.

  • findPostWithStackAndPoster()에서 해당 게시글에서 선정된 기술스택들과 작성자를 함께 로드한다.
  • findCommentByPostId()에서 해당 게시글에 대한 덧글을 조회할 때, 덧글 작성자를 함께 로드한다.

변경이 적용된 이후로 발생된 쿼리는 아래와 같다.

 

SELECT post FROM post INNER JOIN stack INNER JOIN member WHERE post_id=?
SELECT comment FROM comment INNER JOIN member WHERE post_id=?

쿼리 내용을 보아도 앞서 언급한 2가지의 요구사항이 직관적으로 반영되어있음을 알 수 있었다.

 

쿼리 개선 작업, 그 이후

변경 전 수행 시간
변경 후 수행 시간

게시글 상세 조회를 위해 발생하던 최소 8개의 쿼리가 2개의 쿼리로 줄어들었으며, 조회 소요 시간은 111ms에서 26ms로 단축되었다.

 

JPA의 N+1문제 해결을 위한 전략

여러 레퍼런스를 참조하며 해결방법을 모색했었고, 다양한 방법이 소개되었다.

  • Join Fetch
  • @EntityGraph, @NamedEntityGraph
  • batch fetch size
  • QueryDSL
  • etc..

대표적으로 join fetch를 꼽을 수 있을것 같다. 하지만 이 방법의 치명적인(?) 단점은 연관 컬렉션에 대해서 오직 한 번의 join fetch만 수행할 수 있다는 것이다.

 

join fetch, 그리고 MultipleBagFetchException.

2개 이상의 컬렉션에 대해서는 join fetch을 수행하면 MultipleBagFetchException이 발생한다.

join된 두 컬렉션 사이에 카테시안 곱이 발생하기때문에 결과가 뻥튀기된다. 그 이유는 Hibernate에서 bag이라고 불리는 List는 결과에서의 중복제거가 불가능하기때문이다. 결과적으로 2개 이상의 List 컬렉션에 대해 join fetch를 하게 되면 카르테시안 곱이 발생하게 된다.

위의 예시에서 사실 post는 comment 컬렉션과 stack 컬렉션 2개를 필요로 한다. 그렇다면 2개에 대해 join fetch를 다음과 같은 코드로 시도할 수 있다.

“SELECT post FROM post JOIN FECTH post.comments JOIN FETCH post.designatedStack … ”

간단하게 생각하면 간편하게 해결될 수 있을 것 같지만, 2개이상의 테이블이 join 되면서 결과는 n(덧글의 갯수) * m(스탯의 갯수)만큼 발생하며 중복정보로 가득차게 된다. 덧글이 100개이며, 기술스택이 5개라면 500개의 결과가 나오게 된다.

 

MultipleBagFetchException을 피하기위한 Set, instead of List

Hibernate에서 List 컬렉션은 bag type으로 정의되며, 복수의 List 컬렉션(bag type) join fetch은 중복제거가 불가하기때문에 예외가 발생한다. 그럼 중복제거가 가능하면 예외가 발생하지 않는것 아닌가? 여러 컬렉션을 함께 조회할 때, 단 1개의 List 컬렉션(bag type)과 나머지는 Set 컬렉션(set type)으로 지정하면 join fetch를 사용할 수 있다.

하지만 만능 키는 존재하지 않듯이, Set을 사용할 경우엔 순서에 대한 보장을 할 수 없다는 단점이 존재한다. 현재 내 프로젝트에서는 순서가 요구되진 않지만, 이후 상황에서는 설계에 따라 다양하게 적용해볼 수 있을것으로 보인다.

 

Join Fetch, 컬렉션이 아닌 경우엔?

또다시 언급되지만, join fetch에서 복수의 bag(List 컬렉션)에 대해서는 예외가 발생한다. 하지만, 컬렉션이 아닌 단일 엔티티에 대해서는 join fetch가 중복하여 사용할 수 있다. 카르테시안 곱은 복수의 테이블에서 발생되며, 단일 엔티티(레코드)에 대해서는 카르테시안 곱이 일어날리 없기 때문이다.

게시글 상세조회에서 사용된 post 조회 메소드를 보면 아래와 같다.

“SELECT p from Post p JOIN FETCH p.designatedStack JOIN FETCH p.poster … ”

게시글에 대한 연관관계에서 스택은 1:N 관계이며, 작성자는 1:1관계에 해당된다. 따라서 게시글 작성자(poster)에 대해 join fetch 수행이 가능한 것이다.

 

결론과 느낀 점

JPA에서 1개의 동작을 수행하기 위해 1개 이상의 쿼리(N)가 발생하는 것을 N+1문제라고 이야기한다. 이를 해결하기 위한 방법은 다양하게 존재함을 알 수 있었다. 처음 개발단계에서는 강의를 통해 배웠던 N+1을 해결 전략을 어느정도 적용은 해두었었지만, 막상 주어진 프로젝트의 상황에 맞춰서 개선 작업을 하며 적용했던 전략들은 새롭게 다가왔다. 무엇보다도 개선 이전의 상황과 이후의 상황을 실제 코드 결과물로 보면서 작업을 진행한 것이 크게 와닿았다.

아직 N+1에 대해 완전히 해결된 상황은 아니기에 다음 포스팅에서는 2차 개선작업을 짧게 포스팅하고자 한다. QueryDSL이나 NamedQuery는 아직 배우지못했기에 차차 배우고 적용해가며 익혀 나갈 예정이다.

 

“No Silver Bullet. 은탄이란 없다.”

튜링상을 받은 프레드 브룩스가 쓴 소프트웨어 엔지니어링에 대한 논문의 제목이다. 기술분야에서 두루 소개되는 문장이기도 한다.

요즘 여러 기술들을 배우고 딥다이브를 진행하면서 정말 느끼게 되는 문장이다. 모든 것을 한 번에 해결할 수 있는 만능키는 없다. 각각의 장단점이 명확히 존재하며 상황에 따라 적절히, 그리고 유동적으로 적용할 수 있는 사람이야말로 진정한 엔지니어(기술인)이라고 할 수 있을 것 같다.

 

참조:

  • Mihalcea, V. The best way to fix the Hibernate MultipleBagFetchException. http://vladmihalcea.com/hibernate-multiplebagfetchexception/
  • Stankowski, A. Hibernate Catesian Product Problem. https://allaroundjava.com/hibernate-cartesian-product-problem/
  • Jojoldu. JPA N+1 문제 및 해결방안. https://jojoldu.tistory.com/165
  • Jojoldu. MultipleBagFetchException 발생 시 해결 방법. https://jojoldu.tistory.com/457
반응형