[성능개선] 쿼리 성능 개선기(N+1)

JeongMin·2024년 6월 14일
0
post-thumbnail

문제 상황

현재 진행하고 있는 프로젝트 기능 중, 게시글을 최신 순으로 보여주는 기능이 있습니다. 컬렉션을 Join해야하는 상황에서 N + 1문제가 발생합니다.
해당 문제를 해결하기 위해 3가지 방식을 사용했습니다.

  • Fetch Join
  • Batch Size 설정
  • IN절을 활용해 DTO로 조회하는 방식

현재 프로젝트에는 게시글 100만건, 회원 3만건, 댓글 3만건이 DB에 저장되어 있는 상태입니다.


코드

// Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BoardService {

    private final BoardRepository boardRepository;
    //...
    
    public List<BoardTotalInfoResponse> findLimitedBoardList(Pageable pageable) {
            return boardRepository.findBoardsJoinCommentsAndMembers(pageable)
                    .stream()
                    .map(board -> BoardTotalInfoResponse.of(board, board.getCommentList()))
                    .collect(Collectors.toList());
    }
}
// Repository
public interface BoardRepository extends JpaRepository<Board, Long> {

    @Query("select b " +
            "from Board b " +
            "join b.member " +
            "left join b.commentList c " +
            "left join c.member m " +
            "order by b.createdAt desc")
    List<Board> findBoardsJoinCommentsAndMembers(Pageable pageable);
}
@Getter
@Setter
@EqualsAndHashCode(of = "boardId")
@NoArgsConstructor
public class BoardTotalInfoResponse {

    private Long boardId;

    private String title;

    private String content;

    private String memberName;

    private LocalDateTime createdAt;

    private List<CommentResponse> comments;

    private int totalLikeCount = 0;
    
    ...
    
}
@Getter
@NoArgsConstructor
public class CommentResponse {

    private Long boardId;
    private String content;
    private String commenter;
    private LocalDateTime createdAt;
    
    ...
    
}

결과

[
    {
        "boardId": 1000002,
        "title": "안녕하세요",
        "content": "ㅇㅇㅇㅇㅇㅇ",
        "memberName": "User",
        "createdAt": "2024-02-06 18:05:29",
        "comments": [
            {
                "boardId": 1000002,
                "content": "This is a sample comment",
                "commenter": "User155_674513dd",
                "createdAt": "2023-01-01 12:00:00"
            },
            {
                "boardId": 1000002,
                "content": "댓글입니다1.",
                "commenter": "User1357_03d3c76d",
                "createdAt": "2023-05-03 12:00:00"
            }
        ],
        "totalLikeCount": 0
    },
    {
        "boardId": 553421,
        "title": "Title_a9389c67f31cd82",
        "content": "Content_40e9974a73895e6fbb8bd365531a1b4a",
        "memberName": "User2919_f06cb712",
        "createdAt": "2023-12-31 23:59:37",
        "comments": [
            {
                "boardId": 553421,
                "content": "댓글입니다.",
                "commenter": "User2_7e5c4a9a",
                "createdAt": "2023-12-03 12:00:00"
            }
        ],
        "totalLikeCount": 5
    },
  
  
  		...
  ]

결과는 정상적으로 반환되는 것을 볼 수 있습니다. 하지만 Hibernate에서 생성한 쿼리를 보면 N + 1 현상이 발생한 것을 알 수 있습니다.

Hibernate: 
    select
        b1_0.board_id,
        b1_0.content,
        b1_0.created_at,
        b1_0.member_id,
        b1_0.modified_at,
        b1_0.title,
        b1_0.total_like_count,
        b1_0.version 
    from
        board b1_0 
    join
        member m1_0 
            on m1_0.member_id=b1_0.member_id 
    left join
        comments c1_0 
            on b1_0.board_id=c1_0.board_id 
    order by
        b1_0.created_at desc limit ?,
        ?
        
 Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_at,
        m1_0.login_account_id,
        m1_0.modified_at,
        m1_0.name,
        m1_0.profile_image_url,
        m1_0.role_type 
    from
        member m1_0 
    where
        m1_0.member_id=?
 
 Hibernate: 
    select
        c1_0.board_id,
        c1_0.comments_id,
        c1_0.comment_content,
        c1_0.created_at,
        c1_0.member_id 
    from
        comments c1_0 
    where
        c1_0.board_id=?
 
 Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_at,
        m1_0.login_account_id,
        m1_0.modified_at,
        m1_0.name,
        m1_0.profile_image_url,
        m1_0.role_type 
    from
        member m1_0 
    where
        m1_0.member_id=?
 
Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_at,
        m1_0.login_account_id,
        m1_0.modified_at,
        m1_0.name,
        m1_0.profile_image_url,
        m1_0.role_type 
    from
        member m1_0 
    where
        m1_0.member_id=?
        
 		...
        
  1. 생성된 쿼리를 보면 Board 엔티티 조회 -> JoinMember 엔티티 조회
  2. Comments 엔티티 조회 -> JoinMember 엔티티 조회
  3. 다음 Board 엔티티JoinMember 엔티티 조회 -> 2번 과정 반복
  4. 3번 과정 반복

이처럼 최신 게시글 목록 10개를 조회하는 쿼리를 실행했는데, 수 많은 쿼리가 발생한 것을 확인할 수 있었습니다.
데이터가 많이 쌓인 실무 환경이라면 성능 저하를 일으킬 수 있는 상황이기 때문에 이에 대한 해결이 필요합니다.


해결 (Fetch Join, Batch Size 설정)

N + 1 문제를 해결하기 위해서 Fetch Join을 사용하거나 Batch Size를 설정하는 방법이 있습니다.

  • 먼저 Fetch Join은 해당 기능에서 페이징을 사용하기 때문에 적절하지 않습니다.
  • Fetch Join을 사용하면, 데이터베이스가 조인된 모든 데이터를 메모리에 올려놓고 페이징 처리를 하기 때문에 성능 문제가 발생할 수 있습니다.
  • 현재 프로젝트 DB에는 게시글 100만건, 회원 수 3만건, 댓글 3만건이 있기 때문에 Fetch Join으로 조회할 경우, 50초가 넘는 조회 시간이 걸렸습니다.
  • 그래서 Batch Size를 설정하여 N + 1 현상을 해결하겠습니다.
## yml
spring: 
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

적절한 Batch Size를 설정하는 것이 최적화에 도움이 됩니다.
크기가 너무 작을 경우 많은 쿼리를 발생시켜 오버헤드를 증가합니다. 또한, 크기가 너무 클 경우 많은 데이터를 로딩하여 쿼리 성능이 저하됩니다.
적절한 크기를 찾기 위해서는 프로파일링이나 모니터링, 테스트 등이 필요합니다.
여기서는 권장되는 Batch Size인 100~1000 사이로 설정했습니다.


결과

결과는 위와 동일하게 나오는 것을 확인할 수 있었습니다.

Hibernate에서 생성한 쿼리를 확인했을 때, N + 1 현상을 해결한 것을 확인할 수 있었습니다.

Hibernate: 
    select
        b1_0.board_id,
        b1_0.content,
        b1_0.created_at,
        b1_0.member_id,
        b1_0.modified_at,
        b1_0.title,
        b1_0.total_like_count,
        b1_0.version 
    from
        board b1_0 
    join
        member m1_0 
            on m1_0.member_id=b1_0.member_id 
    left join
        comments c1_0 
            on b1_0.board_id=c1_0.board_id 
    order by
        b1_0.created_at desc limit ?,
        ?
        
Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_at,
        m1_0.login_account_id,
        m1_0.modified_at,
        m1_0.name,
        m1_0.profile_image_url,
        m1_0.role_type 
    from
        member m1_0 
    where
        m1_0.member_id in(?,?,?,?,?,?,?,?,?)

Hibernate: 
    select
        c1_0.board_id,
        c1_0.comments_id,
        c1_0.comment_content,
        c1_0.created_at,
        c1_0.member_id 
    from
        comments c1_0 
    where
        c1_0.board_id in(?,?,?,?,?,?,?,?,?)
        
Hibernate: 
    select
        m1_0.member_id,
        m1_0.created_at,
        m1_0.login_account_id,
        m1_0.modified_at,
        m1_0.name,
        m1_0.profile_image_url,
        m1_0.role_type 
    from
        member m1_0 
    where
        m1_0.member_id in(?,?,?,?)

default_batch_fetch_size 설정에 따라 Hibernate에서 효율적으로 데이터를 페치하고 성능을 최적화합니다. IN절을 사용하여 한 번에 여러 엔티티를 로딩하고 N + 1 현상을 해결합니다.


다른 해결 방법 (IN절을 활용해 DTO로 조회하는 방식)

현재 사용한 최적화 방식은 default_batch_fetch_size 설정을 통해 Hibernate에서 자동으로 성능을 최적화한 것입니다. 조회된 엔티티를 DTO로 변환하는 과정에서 추가적인 쿼리가 발생하여 총 4번의 쿼리가 발생한 것을 볼 수 있습니다.

이것을 직접 IN절을 활용하여(DTO로 조회) 메모리에서 미리 조회해서 최적화하는 방식으로 변경해 조회하는 쿼리 수를 줄여보겠습니다.


코드

// Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BoardService {

	...
    
	private final BoardQueryRepository boardQueryRepository;
    
    ...
    
    public List<BoardTotalInfoResponse> findLimitedBoardList_v1(Pageable pageable) {
        return boardQueryRepository.findBoardTotalInfo(pageable);
    }
}
// Repository
@Repository
@RequiredArgsConstructor
public class BoardQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public List<BoardTotalInfoResponse> findBoardTotalInfo(Pageable pageable) {
        List<BoardTotalInfoResponse> boards = findBoards(pageable);
        List<Long> boardIds = boards.stream()
                .map(BoardTotalInfoResponse::getBoardId)
                .toList();
        Map<Long, List<CommentResponse>> commentsMap = findComments(boardIds).stream()
                .collect(Collectors.groupingBy(CommentResponse::getBoardId));
        boards.forEach(b -> b.setComments(commentsMap.getOrDefault(b.getBoardId(), new ArrayList<>())));
        return boards;
    }

    public List<BoardTotalInfoResponse> findBoards(Pageable pageable) {
        return jpaQueryFactory.select(
                        Projections.constructor(
                                BoardTotalInfoResponse.class,
                                board.id,
                                board.title.value,
                                board.content.value,
                                board.member.name,
                                board.createdAt,
                                board.totalLikeCount
                        )
                )
                .from(board)
                .join(board.member, member)
                .orderBy(board.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    }

    public List<CommentResponse> findComments(List<Long> boardIds) {
        return jpaQueryFactory.select(
                        Projections.constructor(CommentResponse.class,
                                comment.board.id,
                                comment.commentContent.value,
                                comment.member.name,
                                comment.createdAt
                        )
                )
                .from(comment)
                .join(comment.board, board)
                .join(comment.member, member)
                .where(comment.board.id.in(boardIds))
                .fetch();
    }
}

결과

결과는 앞서 작성했던 코드와 동일한 결과가 나왔습니다.

하지만 루트에서 1번, 컬렉션에서 1번을 조회하여 총 2번의 쿼리가 발생했습니다. Batch Size 설정을 통해 조회하는 방법에 비해 쿼리가 2번 줄어든 것을 확인할 수 있습니다.

Hibernate: 
    select
        b1_0.board_id,
        b1_0.title,
        b1_0.content,
        m1_0.name,
        b1_0.created_at,
        b1_0.total_like_count 
    from
        board b1_0 
    join
        member m1_0 
            on m1_0.member_id=b1_0.member_id 
    order by
        b1_0.created_at desc limit ?,
        ?
        
Hibernate: 
    select
        c1_0.board_id,
        c1_0.comment_content,
        m1_0.name,
        c1_0.created_at 
    from
        comments c1_0 
    join
        board b1_0 
            on b1_0.board_id=c1_0.board_id 
    join
        member m1_0 
            on m1_0.member_id=c1_0.member_id 
    where
        c1_0.board_id in(?,?,?,?,?,?,?,?,?,?)

Batch Size를 설정했을 때 보다 코드는 복잡해졌지만 쿼리가 간결해진 것을 확인할 수 있습니다.
해당 방식은 XToOne 관계에서 Join 할 수 있는 경우는 미리 Join합니다. 그리고 식별자인 boardId를 통해 XToMany 관계인 Comments 테이블을 한 번에 조회합니다.


고민해 볼 내용

이렇게 최적화 하는 방식이 여러 방법이 있습니다.
Batch Size를 설정하여 최적화하는 방식은 코드가 간단한 대신 추가적인 쿼리가 발생하고, 직접 IN절을 사용해서 DTO로 조회하는 방식코드가 복잡하지만 쿼리 수를 줄일 수 있습니다.

구현을 우선적으로 해야 하는 상황이라면 Batch Size를 사용하여 간결한 코드를 작성해야 합니다.
사용자의 요청이 많아 성능을 개선해야 하는 상황이라면 직접 IN절을 사용해 DTO로 조회하는 방식을 선택하여 성능 개선하는 방식을 선택해야 합니다.

0개의 댓글