N+1문제 해결의 모든 것

보람찬하루·2024년 10월 24일
1

배경

현재 사이드 프로젝트로 진행중인 픽플이 베타서비스 중에 있는데 동작을 확인하기 위해 jpa:show_sql: true로 설정이 되어있다! 그러던 와중 서버에 이상이 생길 때 마다 로그를 확인하는데 Hibernte가 생성한 sql 문이 너무 많이 찍혀있어서 확인이 어려웠다.(exception만 안잡고 docker logs 하면 백만년..) 아무튼 그래서 해당 문제의 원인을 파악하던 중 N+1 문제가 확인되었고 자세히 알아보려고 한다.


내용

N+1문제 분석, 해결방법, 프로젝트 적용

N+1문제 정의

객체를 DB에서 불러올 때 (JPA Repository를 활용해 인터페이스 메소드를 호출할 때) 1개의 쿼리가 아닌 연관관계 객체를 불러오기 위한 N개의 추가적인 쿼리가 생성되는 문제
즉, JPA의 Entity 조회시 Query 한번 내부에 존재하는 다른 연관관계에 접근할 때 또 다시 한번 쿼리가 발생하는 비효율적인 상황

  • 예시

    게시글이랑 댓글이랑 1대N으로 연관관계 및 JPA Fetch 전략을 LAZY 전략으로 해놓은 상황에서 게시글에 해당하는 댓글을 조회할 때 하나의 게시글에 3개의 댓글이 들어있으면 (db에서) article.getComment().getTitle() 상황에서 3번의 추가 쿼리가 발생한다.

    성능저하 이슈 발생!!


발생 원인 분석

언제?

  • 1:N 또는 N:1 관계를 가진 엔티티를 JPA Repository를 활용해 조회할때
  • 즉시 로딩으로 데이터를 가져오는 경우 JPA에서 Fetch 전략(즉시 로딩)을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회 ⇒ N + 1 문제 발생
  • 지연 로딩으로 데이터를 가져온 이후에 가져온 데이터에서 하위 엔티티를 다시 조회하는 경우
    비즈니스 로직에서 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생 ⇒ N + 1 문제 발생

왜?

  • 근본적인 원인은 자동화된 쿼리들이 날아가고 (픽플의 경우 JPA사용) select 문을 통해서만 연관 객체에 접근할 수 있는 RDB와 연관관계를 통해 레퍼런스를 가지고있으면 메모리 내에서 Random Access를 통해 접근할 수 있는 객체지향 언어간의 패러다임으로 인해 발생한다.
  • 즉 JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티 기준으로 쿼리를 조회하기 때문에 해당 엔티티를 조회하는 SELECT * FROM article 쿼리만 실행하게 된다. 그렇기 때문에 연관된 엔티티 데이터가 필요한 경우, FetchType으로 지정한 시점에 조회를 추가적으로 호출하게 되는것! ( 즉시로딩일 경우 바로 호출, 지연 로딩일 경우 하위 엔티티에서 호출 시 호출)

해결 방법

1. FETCH JOIN

사실 N+1 자체가 발생하는 이유는 두 테이블이 연결되어 있을 때 한쪽 테이블을 조회하고 다른 테이블을 따로 조회하기 때문에 발생하는 문제다. 이를 해결하기 위해 직접 최적회된 쿼리, 즉 FETCH JOIN를 직접 작성해주면 된다!

SELECT a FROM article c JOIN FETCH a.comment
  • fetch join이 무엇인가요?

    SQL에서 사용하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능으로 기본 JOIN은 연관된 엔티티의 정보를 가져오지 않고, 단순히 조건에 맞는 데이터를 필터링하는 역할을 한다. 반면, JOIN FETCH는 관련된 엔티티도 함께 조회(Fetching) 하여 데이터베이스에서 한 번에 불러온다.

  • 항상 fetch join으로 해결하면 되나요? NO

    아니다! fetch join에도 단점이 존재하기 때문에 특정 상황에선 사용이 불가능하다

    1. 우리가 설정해놓은 FetchType을 사용할 수 없다.

      데이터 호출 시점에 모든 연관 관계 데이터를 가져오기 때문에 FetchType을 lazy로 해놓은 것이 무의미

    2. Pagination이 불가능하다

      하나의 쿼리문으로 가져오다보니 페이징 단위로 데이터를 가져오는 것이 불가능

      실제로 구현하게되면 하나의 쿼리가 나가긴하지만 모든 값을 select해와서 인메모리에 저장하고 application단에서 필요한 페이지만큼만 변환해줌

      WARN 79170 --- [    Test worker] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

2. @Entity Graph

@EntityGraph 의 attributePaths에 같이 조회할 연관 엔티티명을 적어서 사용한다. ,(콤마)를 통해 여러 개를 줄 수도 있다! Fetch join과 동일하게 JPQL을 사용해 Query문을 작성하고 필요한 연관관계를 EntityGraph에 설정한다.

@EntityGraph(attributePaths = {"comments"})
@Query("select DISTINCT a from Article a")
List<Member> findAllEntityGraph();

이렇게 해주고 결과를 보면 쿼리가 1번만 발생하고 미리 comment와 Order를 조인(outer Join)해서 가져오는 것을 볼 수 있다.

SELECT DISTINCT a.* from article a LEFT JOIN comments c ON a.id = c.article_id

❓ DISTINCT는 무엇인가요?
Fetch JoinEntityGraph는 공통적으로 카테시안 곱(Cartesian Product)을 하기 때문에 결과가 늘어나서 중복된 결과가 나타날 수 있다. 이때 해결할 수 있는 방법으로 함께 사용해준다!

카테시안 곱을 한다 ?
: 두 개 이상의 테이블을 조인할 때, 각각의 행이 서로 조합된 모든 경우의 수를 반환하는 현상으로 일대다 관계에서 조인된 테이블의 데이터가 여러 개일 경우, 부모 엔티티의 데이터가 중복되어 나타날 수 있음

  1. JPQL에 DISTINCT 를 추가하여 중복 제거
@Query("SELECT DISTINCT a FROM article a JOIN FETCH a.comment")
List<Member> findAllJoinFetch();
////////////////////////////////////////////////////////////////
@EntityGraph(attributePaths = {"comments"})
@Query("select DISTINCT a from article a")
List<Member> findAllEntityGraph();
  1. OneToMany 필드 타입을 Set으로 선언하여 중복 제거
@Entity
public class Article {
@Id @GeneratedValue
private Long id;
	@OneToMany(mappedBy = "article", fetch = FetchType.LAZY) 
		private Set<Comments> comments = new LinkedHashSet<>();
}

이때 Set을 사용하게 된다면 HashSet으로는 순서가 중요한 데이터에는 순서를 보장할 수 없기 때문에 LinkedHashSet을 사용해야한다!!


3. Hibernate의 @BatchSize

JPA 의 성능 개선을 위해 하이버네이트가 제공하는 옵션 중 하나로 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.

@Entity
public class Article {
	@Id @GeneratedValue
	private Long id;
		@BatchSize(size = 100)
		@OneToMany(mappedBy = "article", fetch = FetchType.LAZY) // 지연 로딩
		private List<Comment> comment = new ArrayList<Comment>();
}

앞선 FETCH JOIN의 페이징이 불가능한 문제를 해결할 수 있다.

하지만 주의해야할 점은 batch size에 fetch join을 걸면 안된다.

fetch join이 우선시되어 적용되기 때문에 batch size가 무시되고 fetch join을 인메모리에서 먼저 진행하여 List가 MultipleBagFetchException가 발생하거나, Set을 사용한 경우에는 Pagination의 인메모리 로딩을 진행한다.

MultipleBagFetchException
fetch join을 할 때 ToMany의 경우 한번에 fetch join을 가져오기 때문에 collection join이 2개이상이 될 경우 너무 많은 값이 메모리로 들어와 exception 발생

collection join이 2개이상?
여러 개의 자식 엔티티를 담고 있는 필드가 또 여러개의 자식 엔티티를 담고 있는 경우
예를 들어 User가 여러 개의 Post와 여러 개의 Comment를 가질 수 있는 경우


적용

현재 상황

모임에 해당하는 공지사항 전체 조회 API에 적용하여 N+1문제를 직접 해결해보겠다

    public List<NoticeListGetByMoimResponse> getNoticeListByMoimId(Long moimId, Long guestId) {

        boolean isAppliedUser = isUserAppliedToMoim(moimId, guestId);

        return noticeList.stream()
                .filter(notice -> canAccessNotice(notice, isAppliedUser))
                .map(oneNotice -> NoticeListGetByMoimResponse.builder()
                        .noticeId(oneNotice.getId())
                        .hostNickName(moim.getHost().getNickname())
                        .hostImageUrl(moim.getHost().getImageUrl())
                        .title(oneNotice.getTitle())
                        .content(oneNotice.getContent())
                        .date(DateTimeUtil.refineDateAndTime(oneNotice.getCreatedAt()))
                        .noticeImageUrl(oneNotice.getImageUrl())
                        .hostId(moim.getHost().getId())
                        .commentNumber(oneNotice.getComments().size())
                        .isPrivate(oneNotice.isPrivate())
                        .build())
                .collect(Collectors.toList());
    }
List<Notice> findNoticesByMoimIdOrderByCreatedAtDesc(Long moimId);

이렇게 구현을 진행했고 요청 시 아래와 같은 SQL문이 작성됐다.

Hibernate: 
    select
        g1_0.id,
        g1_0.created_at,
        g1_0.image_url,
        g1_0.nickname,
        g1_0.updated_at,
        g1_0.user_id 
    from
        guests g1_0 
    where
        g1_0.user_id=?
Hibernate: 
    select
        n1_0.id,
        n1_0.content,
        n1_0.created_at,
        n1_0.image_url,
        n1_0.is_private,
        n1_0.moim_id,
        n1_0.title,
        n1_0.updated_at 
    from
        notices n1_0 
    where
        n1_0.moim_id=?
Hibernate: 
    select
        m1_0.id,
        m1_0.account_list,
        m1_0.category_list,
        m1_0.created_at,
        m1_0.date_list,
        m1_0.description,
        m1_0.fee,
        m1_0.host_id,
        m1_0.image_list,
        m1_0.is_offline,
        m1_0.max_guest,
        m1_0.moim_state,
        m1_0.question_list,
        m1_0.spot,
        m1_0.title,
        m1_0.updated_at 
    from
        moims m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        h1_0.id,
        h1_0.created_at,
        h1_0.description,
        h1_0.image_url,
        h1_0.link,
        h1_0.nickname,
        h1_0.updated_at,
        h1_0.user_id,
        h1_0.user_keyword 
    from
        hosts h1_0 
    where
        h1_0.id=?
Hibernate: 
    select
        g1_0.id,
        g1_0.created_at,
        g1_0.image_url,
        g1_0.nickname,
        g1_0.updated_at,
        g1_0.user_id 
    from
        guests g1_0 
    where
        g1_0.id=?
Hibernate: 
    select
        ms1_0.id 
    from
        moim_submissions ms1_0 
    where
        ms1_0.moim_id=? 
        and ms1_0.guest_id=? 
    fetch
        first ? rows only
Hibernate: 
    select
        ms1_0.id,
        ms1_0.account_list,
        ms1_0.answer_list,
        ms1_0.created_at,
        ms1_0.guest_id,
        ms1_0.moim_id,
        ms1_0.moim_submission_state,
        ms1_0.updated_at 
    from
        moim_submissions ms1_0 
    where
        ms1_0.moim_id=? 
        and ms1_0.guest_id=?
Hibernate: 
    select
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at 
    from
        comments c1_0 
    where
        c1_0.notice_id=?
Hibernate: 
    select
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at 
    from
        comments c1_0 
    where
        c1_0.notice_id=?
Hibernate: 
    select
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at 
    from
        comments c1_0 
    where
        c1_0.notice_id=?
Hibernate: 
    select
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at 
    from
        comments c1_0 
    where
        c1_0.notice_id=?
Hibernate: 
    select
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at 
    from
        comments c1_0 
    where
        c1_0.notice_id=?
  • 쿼리 최적화 전 (총 쿼리 수: 12)
    1. guests 테이블 조회 (1회)

    2. notices 테이블에서 공지사항 조회 (1회)

    3. moims 테이블에서 모임 정보 재조회 (1회)

    4. hosts 테이블 조회 (1회)

    5. guests 테이블에서 다른 게스트 정보 재조회 (1회)

    6. moim_submissions 테이블 조회 (1회)

    7. moim_submissions 테이블에서 다시 조회 (1회)

    8. comments 테이블에서 공지사항에 대한 댓글 수 조회 (5회)


  • 문제 포인트

    호스트 정보 조회 (oneNotice.getMoim().getHost().getNickname()moim.getHost().getImageUrl()):
    & 댓글 수 조회 (oneNotice.getComments().size()):

    각 공지사항(Notice)에 대해 호스트, 댓글 정보를 가져오는데, 이 과정에서 호스트, 댓글 테이블에 대해 쿼리가 여러 번 실행됐다. 이는 각 공지사항에 대해 매번 정보가 개별적으로 조회되기 때문이다
    즉, N+1 발생!


근데 왜 HOST는 1번의 쿼리가 나가나요?

호스트(Host)와 모임(Moim)의 관계:
- 모임(Moim)과 호스트(Host)는 1:1 관계다. 즉, 하나의 모임에는 하나의 호스트만 존재한다.
- JOIN FETCH m.host로 모임의 호스트 정보를 한 번만 가져오면 된다. 모임과 관련된 공지사항이 여러 개라 하더라도, 동일한 모임에 속한 공지사항이므로 동일한 호스트 정보가 공유된다.
- 따라서 Host에 대한 쿼리는 한 번만 실행된다.

댓글(Comment)과 공지(Notice)의 관계:
- 공지사항 하나에 여러 개의 댓글이 달릴 수 있으므로 1:N 관계다.
- LEFT JOIN FETCH n.comments는 각 공지사항에 달린 댓글을 모두 조회한다. 이 경우 공지사항마다 여러 댓글이 있을 수 있어, 공지사항마다 중복된 댓글 조회가 발생할 수 있다.
- 따라서 공지사항마다 댓글에 대해 여러 쿼리가 발생하여 공지사항 수만큼(N번) 쿼리가 발생한다.



JOIN FETCH 적용

    @Query("SELECT DISTINCT n FROM Notice n JOIN FETCH n.moim m JOIN FETCH m.host " +
            "LEFT JOIN FETCH n.comments WHERE m.id = :moimId ORDER BY n.createdAt DESC")
    List<Notice> findNoticesByMoimId(Long moimId);

위와같이 최적화를 위해 JOIN FETCH를 적용해준다.

Hibernate: 
    select
        g1_0.id,
        g1_0.created_at,
        g1_0.image_url,
        g1_0.nickname,
        g1_0.updated_at,
        g1_0.user_id 
    from
        guests g1_0 
    where
        g1_0.user_id=?
Hibernate: 
    select
        distinct n1_0.id,
        c1_0.notice_id,
        c1_0.id,
        c1_0.comment_content,
        c1_0.user_id,
        c1_0.created_at,
        c1_0.updated_at,
        n1_0.content,
        n1_0.created_at,
        n1_0.image_url,
        n1_0.is_private,
        m1_0.id,
        m1_0.account_list,
        m1_0.category_list,
        m1_0.created_at,
        m1_0.date_list,
        m1_0.description,
        m1_0.fee,
        h1_0.id,
        h1_0.created_at,
        h1_0.description,
        h1_0.image_url,
        h1_0.link,
        h1_0.nickname,
        h1_0.updated_at,
        h1_0.user_id,
        h1_0.user_keyword,
        m1_0.image_list,
        m1_0.is_offline,
        m1_0.max_guest,
        m1_0.moim_state,
        m1_0.question_list,
        m1_0.spot,
        m1_0.title,
        m1_0.updated_at,
        n1_0.title,
        n1_0.updated_at 
    from
        notices n1_0 
    join
        moims m1_0 
            on m1_0.id=n1_0.moim_id 
    join
        hosts h1_0 
            on h1_0.id=m1_0.host_id 
    left join
        comments c1_0 
            on n1_0.id=c1_0.notice_id 
    where
        n1_0.moim_id=? 
    order by
        n1_0.created_at desc
Hibernate: 
    select
        m1_0.id,
        m1_0.account_list,
        m1_0.category_list,
        m1_0.created_at,
        m1_0.date_list,
        m1_0.description,
        m1_0.fee,
        m1_0.host_id,
        m1_0.image_list,
        m1_0.is_offline,
        m1_0.max_guest,
        m1_0.moim_state,
        m1_0.question_list,
        m1_0.spot,
        m1_0.title,
        m1_0.updated_at 
    from
        moims m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        g1_0.id,
        g1_0.created_at,
        g1_0.image_url,
        g1_0.nickname,
        g1_0.updated_at,
        g1_0.user_id 
    from
        guests g1_0 
    where
        g1_0.id=?
Hibernate: 
    select
        ms1_0.id 
    from
        moim_submissions ms1_0 
    where
        ms1_0.moim_id=? 
        and ms1_0.guest_id=? 
    fetch
        first ? rows only
Hibernate: 
    select
        ms1_0.id,
        ms1_0.account_list,
        ms1_0.answer_list,
        ms1_0.created_at,
        ms1_0.guest_id,
        ms1_0.moim_id,
        ms1_0.moim_submission_state,
        ms1_0.updated_at 
    from
        moim_submissions ms1_0 
    where
        ms1_0.moim_id=? 
        and ms1_0.guest_id=?

  • 최적화 후 쿼리 (총 쿼리 수: 6)
    • 제거된 쿼리
      Hibernate: 
          select
              h1_0.id,
              h1_0.created_at,
              h1_0.description,
              h1_0.image_url,
              h1_0.link,
              h1_0.nickname,
              h1_0.updated_at,
              h1_0.user_id,
              h1_0.user_keyword 
          from
              hosts h1_0 
          where
              h1_0.id=?
      Hibernate: 
          select
              c1_0.notice_id,
              c1_0.id,
              c1_0.comment_content,
              c1_0.user_id,
              c1_0.created_at,
              c1_0.updated_at 
          from
              comments c1_0 
          where
              c1_0.notice_id=?
    1. Guests 테이블 조회 (1회)
    2. Notices, Moims, Hosts, Comments를 조회 (1회)
    3. Moims 테이블 재조회 (1회)
    4. Guests 테이블에서 ID로 조회 (1회)
    5. Moim Submissions 테이블 조회 (1회)
    6. Moim Submissions 테이블 재조회 (1회)


JOIN FETCH vs LEFT JOIN FETCH

  • JOIN FETCH
    • JOIN FETCH의 동작: 연관된 엔티티가 반드시 존재해야 한다. 즉, JOIN FETCH는 조인된 테이블에 일치하는 데이터가 있어야만 결과가 반환된다.
  • LEFT JOIN FETCH
    • LEFT JOIN과의 차이: LEFT JOIN FETCH는 조인된 테이블에 데이터가 없더라도 주 테이블의 데이터를 반환한다. 즉, 왼쪽 테이블(Notice)의 데이터는 모두 반환하고, 관련된 엔티티(Comments 등)가 없는 경우에는 null 값을 반환한다.
    • LEFT JOIN FETCH의 동작: 연관된 엔티티가 없어도 상관없이 주 엔티티의 데이터를 가져오고, 관련된 엔티티는 null로 채워질 수 있다.

없어도 되는 관계가 허용될 경우(공지사항에 댓글이 안달려도 될 경우)엔 무조건 LEFT JOIN FETCH로 N+1을 해결하자!


결론

N+1문제는 데이터가 많이 쌓일수록 성능에 많은 차이를 발생시킬 것이다.
안정성 있는 서버를 위해 신경써서 구현하자.

profile
를 만들어 가자

0개의 댓글