코드 저장소.

[Coffies Vol.02]Jpa N+1 문제 본문

포폴/Coffies Vol.02

[Coffies Vol.02]Jpa N+1 문제

slown 2023. 5. 16. 22:00

목차

1. 문제 발생.

2. N+1 은 무엇인가?

3. 왜 N+1이 발생하는 것인가?

4. N+1 의 해결책

 

1. 문제 발생 

프로젝트를 진행을 하면서 자유게시판에서 게시글을 조회하는데 게시글과 관련된 회원조회 및 좋아요의 쿼리가 불필요하게 나오는 상황이었고 이와 같은 문제를 해결하기 위해서 Jpa의 N+1 문제를 알게 되었고 이를 해결하고자 한다. 문제가 되는 쿼리는 이러하다.  

id: DESC
direction:DESC
prop:id
orderByExpression:board.id
Hibernate: 
    select
        board0_.id as id1_0_,
        board0_.created_time as created_2_0_,
        board0_.updated_time as updated_3_0_,
        board0_.board_author as board_au4_0_,
        board0_.board_contents as board_co5_0_,
        board0_.board_title as board_ti6_0_,
        board0_.file_group_id as file_gro7_0_,
        board0_.useridx as useridx10_0_,
        board0_.pass_wd as pass_wd8_0_,
        board0_.read_count as read_cou9_0_ 
    from
        tbl_board board0_ 
    order by
        board0_.id desc limit ?
Hibernate: 
    select
        member0_.id as id1_9_0_,
        member0_.created_time as created_2_9_0_,
        member0_.updated_time as updated_3_9_0_,
        member0_.member_name as member_n4_9_0_,
        member0_.password as password5_9_0_,
        member0_.role as role6_9_0_,
        member0_.user_addr1 as user_add7_9_0_,
        member0_.user_addr2 as user_add8_9_0_,
        member0_.user_age as user_age9_9_0_,
        member0_.user_email as user_em10_9_0_,
        member0_.user_gender as user_ge11_9_0_,
        member0_.user_id as user_id12_9_0_,
        member0_.user_phone as user_ph13_9_0_ 
    from
        tbl_user member0_ 
    where
        member0_.id=?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_0_,
        likes0_.id as id1_5_0_,
        likes0_.id as id1_5_1_,
        likes0_.board_id as board_id2_5_1_,
        likes0_.useridx as useridx3_5_1_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id=?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_0_,
        likes0_.id as id1_5_0_,
        likes0_.id as id1_5_1_,
        likes0_.board_id as board_id2_5_1_,
        likes0_.useridx as useridx3_5_1_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id=?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_0_,
        likes0_.id as id1_5_0_,
        likes0_.id as id1_5_1_,
        likes0_.board_id as board_id2_5_1_,
        likes0_.useridx as useridx3_5_1_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id=?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_0_,
        likes0_.id as id1_5_0_,
        likes0_.id as id1_5_1_,
        likes0_.board_id as board_id2_5_1_,
        likes0_.useridx as useridx3_5_1_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id=?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_0_,
        likes0_.id as id1_5_0_,
        likes0_.id as id1_5_1_,
        likes0_.board_id as board_id2_5_1_,
        likes0_.useridx as useridx3_5_1_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id=?
id: DESC
direction:DESC
prop:id
orderByExpression:board.id
Hibernate: 
    select
        count(board0_.id) as col_0_0_ 
    from
        tbl_board board0_ 
    order by
        board0_.id desc limit ?

2. N+1은 무엇인가?

N+1은 연관관계에 설정된 엔티티를 조회를 할 때 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽는 현상을 N+1 이라고 한다.

3. 왜 N+1이 발생하는가?

JPA는 JpaRepository인터페이스에 정의한 메소드를 실행을 할 때 JPA는 메서드의 이름을 분석해서 JPQL을 생성하여 실행을 한다. 하지만 JPA는 조회하고자 하는 엔티티(객체)에 연관관계를 무시하고 조회하고자하는 엔티티를 기준으로 해서 조회를 한다. 그리고 연관된 엔티티의 정보가 필요할 때에는 설정된 fetchType에 따라 조회를 별도로 하게 된다.  

 

※ 여기서 fetchType은  JPA가 하나의 Entity를 조회할 때, 연관관계가 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정값을 말합니다.  fetchType은 Eager 와 Lazy 두가지 종류가 있습니다.

 

Eager 는 연관관계에 있는 엔티티를 모두 가져오는 방식

 

Lazy 는 연관관계에 있는 엔티티를 바로 가져오지는 않지만 getter로 접근을 할때만 가져오는 방식을 말합니다.

  • fetchType이 Eager인 경우 : JPQL을 통한 SQL문으로 조회를 한 후, Fetch 전략을 가지고 하위 엔티티를 즉시 조회한다.
  • fetchType이 Lazy인 경우 :  JPQL을 통해서 SQL문으로 조회를 하면 바로 조회를 하지는 않지만,  추후 Getter와 같은 작업이 필요하게 되면, 하위 엔티티를 조회한다.

4. N+1의 해결책

4-1. fetchJoin

첫번째 해결책으로 찾은 것은 fetchJoin을 활용을 하는것이다.  

 

우선 fetchJoin은 연관된 엔티티를 같이 조회를 하는 방법이다. 이방법을 적용하면 조회하는 객체와 연관된 객체를 전부 조회를 할 수 있기 때문에 jpa의 N+1을 예방을 할 수 있고, 사용을 하면 쿼리에는 innerjoin으로 콘솔에 찍힌다. 사용하는 방법으로는 jpql로 하는 경우에는 직접 쿼리에 적어야 하고  QueryDsl로 사용하는 경우에는 .fetchJoin()으로 사용하면 된다. 하지만 fetchjoin이 완벽하게 N+1을 해결 할 수는 없다. 단점을 따지면 여러 이유가 있다.

  • 쿼리를 한번에 모든 데이터를 가져오기 때문에 Pageable이 사용 불가능하다. 
  • fetchType이 Lazy인 경우에는 모든 데이터를 가져오기 때문에 무의미하다.
  • 1:N의 관계가 2개 이상인 경우에는 사용을 할 수가 없다.
  • 패치 조인대상에 별칭 ('as')를 적용을 할 수가 없다.

4-2. @EntityGraph를 사용

두번째 방법으로는 @EntityGraph를 사용하는 방법이 있다. 이 방법을 사용하면 OuterJoin을 사용한다. 또한 이 어노테이션을 사용하면서 attributePaths에서 같이 조회를 할 엔티티를 여러개를 적용을 할 수가 있다. 그리고 이방법을 사용하면 fetchJoin에서 사용하지 못하는 Pageable을 사용을 할 수가 있다. 하지만 이 방법도 단점이 존재한다. 

  • 성능을 최적화하는데 있어서 OuterJoin은 InnerJoin에 비해서 성능이 떨어진다.

위의 fetchJoin과 EntityGraph는 jpql을 사용해서 join문을 사용한다는 공통점이 있다. 그리고  이 두가지 방법은 카타시안 곱이 발생을 하므로 중복된 데이터를 조회를 할 수가 있다.

 

※카타시안 곱: 집합이나 테이블 등에서 두 개 이상의 집합이나 테이블 간의 모든 가능한 조합을 생성하는 연산을 말합니다

 

이러한 문제를 해결하기 위해서  해결방안으로는 이러하다.

  • Distinct를 사용해서 중복되는 데이터를 조회를 할 수 없게 해야 한다.
  • @OneToMany의 연관관계의 타입을 Set으로 바꾼다.
  • 또한 Set의 경우에는 순서를 보장하지 않기에 순서를 보장을 하는 경우에는 LinkedHashSet을 사용하도록 하자.

4-3.@BatchSize

@BatchSize는 하이버네이트에서 제공하는 어노테이션으로 연관된 엔티티를 조회를 할 때 지정한 size만큼 sql의 in구문을 사용해서 조회를 한다. 사용하는 방법은 연관관계의 엔티티에서 @BatchSize를 선언하고 size를 설정하면 된다. 여기서 size는 sql문의 in절에 들어갈 요소의 최대 갯수를 말합니다. 만약에 in절에 size보다 많은 요소가 들어간다면 in쿼리를 여러번 날린다. size의 숫자를 크게 지정을 하면 데이터베이스의 성능에 문제가 있기에 100~1000까지로 숫자를 정해서 하도록 하자.

 

그러면 위의 방법을 토대로 프로젝트에 적용을 해서 문제를 해결해보도록 하자!!

 

우선은  N+1이 발생하는 query dsl로 작성한 쿼리이다. 

@Override
public Page<BoardDto.BoardResponseDto> boardList(Pageable pageable) {
    QBoard qBoard = QBoard.board;

    List<BoardDto.BoardResponseDto>result = jpaQueryFactory
            .select(new QBoardDto_BoardResponseDto(qBoard))
            .from(qBoard)
            .orderBy(getAllOrderSpecifiers(pageable.getSort()).toArray(OrderSpecifier[]::new))
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .distinct()
            .fetch();

    Integer totalCount = jpaQueryFactory
            .select(QBoard.board.count())
            .from(QBoard.board)
            .orderBy(getAllOrderSpecifiers(pageable.getSort()).toArray(OrderSpecifier[]::new))
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .distinct()
            .fetch()
            .size();

    return new PageImpl<>(result,pageable,totalCount);
}

변경된 소스

@Override
public Page<BoardDto.BoardResponseDto> boardList(Pageable pageable) {
    QBoard qBoard = QBoard.board;
    QMember qMember = QMember.member;
    QLike qLike = QLike.like;

    List<BoardDto.BoardResponseDto>result = jpaQueryFactory
            .select(new QBoardDto_BoardResponseDto(qBoard))
            .from(qBoard)
            .join(qBoard.member,qMember).fetchJoin()
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .distinct()
            .fetch();

    Integer totalCount = jpaQueryFactory
            .select(QBoard.board.count())
            .from(QBoard.board)
            .limit(pageable.getPageSize())
            .offset(pageable.getOffset())
            .distinct()
            .fetch()
            .size();

    return new PageImpl<>(result,pageable,totalCount);
}
@BatchSize(size = 100)
@OneToMany(mappedBy = "board",fetch = FetchType.LAZY,cascade = CascadeType.ALL)
@JsonIgnore
private Set<Like> likes = new LinkedHashSet<>();

 

변경점을 말하자면 이러하다.

  • 연관관계가 다대일로 되어있는 Member객체에 fetchJoin을 사용한다.
  • fetchJoin을 사용했기 때문에 distinct()를 적용한다.
  • 좋아요엔티티에서 @BatchSize를 적용하고 타입을 Set으로 지정을 했다.

하지만 위와같이 변경을 한뒤 실행을 하면   firstResult/maxResults specified with collection fetch; applying in   memory!라는 경고문구가 나온다. 이 에러의 내용은 fetchjoin()과 pagination을 같이 사용을 하면 모든 데이터를 전부 가져와 메모리에서 걸러낸다는 의미이다. 하지만 이 경고를 해결하는 방법은 간단했다.

위에 있는 목록 쿼리에서 limit 와 offset을 없애고 distinct()를 사용하면 된다.

수정후의 최종코드는 이러하다.

   @Override
    public Page<BoardDto.BoardResponseDto> boardList(Pageable pageable) {

        List<BoardDto.BoardResponseDto> boardList = new ArrayList<>();

        QBoard qBoard = QBoard.board;
        QMember qMember = QMember.member;
        QLike qLike = QLike.like;

        List<Board>result = jpaQueryFactory
                .select(qBoard)
                .from(qBoard)
                .join(qBoard.member,qMember).fetchJoin()
                .orderBy(getAllOrderSpecifiers(pageable.getSort()).toArray(OrderSpecifier[]::new))
                .distinct()
                .fetch();

        int totalCount = jpaQueryFactory
                .select(QBoard.board.count())
                .from(QBoard.board)
                .orderBy(getAllOrderSpecifiers(pageable.getSort()).toArray(OrderSpecifier[]::new))
                .limit(pageable.getPageSize())
                .offset(pageable.getOffset())
                .distinct()
                .fetch()
                .size();

        for (Board board : result) {
            BoardDto.BoardResponseDto responseDto = BoardDto.BoardResponseDto
                    .builder()
                    .id(board.getId())
                    .boardTitle(board.getBoardTitle())
                    .boardContents(board.getBoardContents())
                    .boardAuthor(board.getBoardAuthor())
                    .passWd(board.getPassWd())
                    .fileGroupId(board.getFileGroupId())
                    .readCount(board.getReadCount())
                    .createdTime(board.getCreatedTime())
                    .updatedTime(board.getUpdatedTime())
                    .build();

            boardList.add(responseDto);
        }

        return new PageImpl<>(boardList,pageable,totalCount);

}


이렇게 지정을 하고 난뒤에 쿼리를 조회를 하면 이렇다.

id: DESC
direction:DESC
prop:id
orderByExpression:board.id
Hibernate: 
    select
        distinct board0_.id as id1_0_0_,
        member1_.id as id1_9_1_,
        board0_.created_time as created_2_0_0_,
        board0_.updated_time as updated_3_0_0_,
        board0_.board_author as board_au4_0_0_,
        board0_.board_contents as board_co5_0_0_,
        board0_.board_title as board_ti6_0_0_,
        board0_.file_group_id as file_gro7_0_0_,
        board0_.useridx as useridx10_0_0_,
        board0_.pass_wd as pass_wd8_0_0_,
        board0_.read_count as read_cou9_0_0_,
        member1_.created_time as created_2_9_1_,
        member1_.updated_time as updated_3_9_1_,
        member1_.member_name as member_n4_9_1_,
        member1_.password as password5_9_1_,
        member1_.role as role6_9_1_,
        member1_.user_addr1 as user_add7_9_1_,
        member1_.user_addr2 as user_add8_9_1_,
        member1_.user_age as user_age9_9_1_,
        member1_.user_email as user_em10_9_1_,
        member1_.user_gender as user_ge11_9_1_,
        member1_.user_id as user_id12_9_1_,
        member1_.user_phone as user_ph13_9_1_ 
    from
        tbl_board board0_ 
    inner join
        tbl_user member1_ 
            on board0_.useridx=member1_.id 
    order by
        board0_.id desc
id: DESC
direction:DESC
prop:id
orderByExpression:board.id
Hibernate: 
    select
        distinct count(board0_.id) as col_0_0_ 
    from
        tbl_board board0_ 
    order by
        board0_.id desc limit ?
Hibernate: 
    select
        likes0_.board_id as board_id2_5_1_,
        likes0_.id as id1_5_1_,
        likes0_.id as id1_5_0_,
        likes0_.board_id as board_id2_5_0_,
        likes0_.useridx as useridx3_5_0_ 
    from
        tbl_like likes0_ 
    where
        likes0_.board_id in (
            ?, ?, ?, ?, ?
        )

 

이렇게 설정을 하면 처음에 조회를 했을 때 8번 쿼리를 날렸지만 3번으로 대폭 줄어들었고 조회속도도 처음에 조회된 속도가 460ms이었는데 적용후에는 290ms로 속도가 대폭 개선이 되었다.  

 

참고

https://incheol-jung.gitbook.io/docs/q-and-a/spring/n+1#n+1

 

N+1 문제 - Incheol's TECH BLOG

Query를 실행하도록 지원해주는 다양한 플러그인이 있다. 대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

incheol-jung.gitbook.io

https://haenny.tistory.com/425

 

[JPA] N+1 발생 원인과 해결책

JPA N+1문제 1번의 쿼리를 조회하기 위해 설계하였으나, 의도하지 않은 N번의 쿼리가 추가적으로 실행되는 문제 When . 언제 발생하는가 ? JPA Repository를 활용해 find 인터페이스 메소드를 호출할 때

haenny.tistory.com

https://dmaolon00.tistory.com/entry/Spring-JPA-N-1-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D-%EC%9B%90%EC%9D%B8-%EB%B0%8F-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EC%95%88

 

[Spring] JPA N + 1 문제 발생 원인 및 해결 방안

📌 JPA N + 1 문제란?조회된 데이터 개수만큼, 연관 관계의 조회 쿼리가 추가로 발생하는 문제를 의미한다. EX) 카테고리와 게시글@Entity @NoArgsConstructor @Setter @Getter public class Board { @Id @GeneratedValue(str

dmaolon00.tistory.com