쌩로그

전체조회시 QueryDSL 사용 본문

TroubleShooting & 고민/BE

전체조회시 QueryDSL 사용

.쌩수. 2023. 8. 3. 17:03
반응형

목록

  1. 포스팅 개요
  2. 본론
        2-1. N+1
        2-2. 그럼 N+1은 과연 왜 발생 하게 된 걸까?
        2-3. QueryDSL을 사용하므로 얻게 된 효과
  3. 요약

1. 포스팅 개요

저번에 N+1 문제가 터져서 QueryDSL을 해결했다고 했는데 자세히 들여다보니 N+1이 근본적으로 해결된 건 아니었다.
오히려 나의 無知(없을 무, 알 지)만 드러낸 포스팅이었다.
그래서 해당 포스팅은 살며시 삭제를 해줬다.
하지만, 나의 마크다운 폴더에는 남아있다... 그거도 나름 공들여서 쓴거라..

이번에 나의 오점을 제대로 짚어보고자 해당 포스팅을 하게되었다.

2. 본론

2-1. N+1

연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 된다.

N+1에 대한 정의를 봤을 때, 분명히 N+1은 맞다.

다음은 게시판 전체 조회 내용이다.

2023-08-03 14:06:44.599 DEBUG 20132 --- [nio-8080-exec-5] o.j.s.OpenEntityManagerInViewInterceptor : Opening JPA EntityManager in OpenEntityManagerInViewInterceptor
2023-08-03 14:06:44.600  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : method = tempGetBoards
2023-08-03 14:06:44.600  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : type = PageRequest
2023-08-03 14:06:44.600  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : value = Page request [number: 0, size 10, sort: UNSORTED]
2023-08-03 14:06:44.601 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(757773392<open>)] for JPA transaction
2023-08-03 14:06:44.601 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [wanderhub.server.domain.board.service.BoardService.findBoards]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2023-08-03 14:06:44.601 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@56fb0ab]
2023-08-03 14:06:44.601 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(757773392<open>)] for JPA transaction
2023-08-03 14:06:44.601 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
Hibernate: 
    select
        board0_.board_id as board_id1_2_,
        board0_.created_at as created_2_2_,
        board0_.modified_at as modified3_2_,
        board0_.content as content4_2_,
        board0_.local as local5_2_,
        board0_.member_id as member_i9_2_,
        board0_.nickname as nickname6_2_,
        board0_.title as title7_2_,
        board0_.view_point as view_poi8_2_ 
    from
        board board0_ limit ?
2023-08-03 14:06:44.602  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : log = Page wanderhub.server.domain.board.repository.BoardRepository.findAll(Pageable)
2023-08-03 14:06:44.602  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : timeMs = 1
2023-08-03 14:06:44.602  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : log = Page wanderhub.server.domain.board.service.BoardService.findBoards(Pageable)
2023-08-03 14:06:44.602  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : timeMs = 1
2023-08-03 14:06:44.602 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
2023-08-03 14:06:44.602 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(757773392<open>)]
2023-08-03 14:06:44.602 DEBUG 20132 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Not closing pre-bound JPA EntityManager after transaction
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
2023-08-03 14:06:44.604  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : log = List wanderhub.server.domain.board.mapper.BoardMapper.boardEntityListToBoardResponseDtoList(List)
2023-08-03 14:06:44.604  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : timeMs = 2
2023-08-03 14:06:44.604  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : log = ResponseEntity wanderhub.server.domain.board.controller.BoardController.tempGetBoards(Pageable)
2023-08-03 14:06:44.604  INFO 20132 --- [nio-8080-exec-5] w.server.global.logger.LogAspect         : timeMs = 4
2023-08-03 14:06:44.604 DEBUG 20132 --- [nio-8080-exec-5] o.j.s.OpenEntityManagerInViewInterceptor : Closing JPA EntityManager in OpenEntityManagerInViewInterceptor

위에는 전체 조회시 실행되는 쿼리문이고,
아래는 Postman으로 나오는 결과이다.

최초에 나오는 1 과 더불어서 N개의 쿼리가 왜 나오는지 봤더니 각 게시판마다 좋아요를 조회하면서 나오는 쿼리였다.

2-2. 그럼 N+1은 과연 왜 발생 하게 된 걸까?

해당 포스팅 최하단에 참고블로그 링크를 하나 달아둘 것이다.
아니다 그냥 여기도 달자..

참고블로그
이 링크를 참고해보면,
현재 내 상황에서
FetchTypeLAZY로 주든,
EAGER로 주든 결국엔 N+1이 발생하게 되어있었다.

EAGER로 주면, 즉시 발생하고,
LAZY로 주면, 프록시객체로 바인딩하다가 실제 데이터가 필요할 때 N+1이 발생하게 된다고 한다.

흔히 우리는
EAGER는 즉시 로딩
LAZY는 지연 로딩이라고 우리가 아는데,

현재 나의 문제를 보게 되면
EAGERN+1 즉시 발생,
LAZYN+1 지연 발생

결국엔 N+1은 터지게 되어있던 것이었다.

그럼 게시판을 조회하는데, 왜 좋아요를 조회하면서 N+1이 발생했을까??

@Entity  
@NoArgsConstructor  
@AllArgsConstructor  
@Getter  
public class Board extends Auditable {  

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)  
    @Column(name = "BOARD_ID")  
    private Long boardId;  

    @Column(name = "NICKNAME", length = 50, nullable = false)  
    private String nickName;    // 작성자  

    @Column(name = "TITLE", length = 100, nullable = false)  
    @Setter  
    private String title;  

    @Lob  
    @Column(name = "CONTENT", nullable = false)  
    @Setter  
    private String content;  

    @Enumerated(value = EnumType.STRING)  
    @Column(name = "LOCAL", length = 16)  
    @Setter  
    private Local local;  


    @Column(name = "VIEW_POINT")  
    @Setter  
    private long viewPoint;  

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "board", orphanRemoval = true)  
    private List<BoardHeart> boardHeartList = new ArrayList<>();  

    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "MEMBER_ID")  
    private Member member;  

    // 댓글  
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "board", orphanRemoval = true) // orphanRemoval 연관관계가 끊어지면 자동으로 삭제  
    private List<BoComment> boCommentList = new ArrayList<>();

위의 코드는 게시판(Board)의 Entity를 정의한 클래스이다.

보면, 다음과 같은 코드가 있다.

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "board", orphanRemoval = true)  
    private List<BoardHeart> boardHeartList = new ArrayList<>();  

List로 BoardHeart를 가지고 있다.

지금 생각난 건데, BoardHeart는 한 사람당 한개만 가지고 있으므로, Set을 이용해도 되었을지도...

게시판 좋아요(BoardHeart) Entity도 살펴보면 다음과 같다.

@Entity  
@NoArgsConstructor  
@AllArgsConstructor  
@Getter  
public class BoardHeart extends Auditable {  
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)  
    @Column(name = "BOARD_HEART_ID")  
    private Long boardHeartId;  

    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "MEMBER_ID")  
    private Member member;  

    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "BOARD_ID")  
    private Board board;  

    @Builder  
    public BoardHeart(Member member, Board board) {  
        this.member = member;  
        this.board = board;  
    }  
}

이처럼 fetch 타입은 다 LAZY로 이루어져있다.

그럼 N+1 은 어디서 발생한 걸까?
바로 그 답은 Mapper에 있었다.

조회해서 꺼내온 Entity 객체들을 그대로 응답하는 것이 아니라,
전체조회 목록DTO로 옮겨서 응답을 주게 되는데,
Entity를 DTO로 옮길 때 Mapper를 사용했다.

// 개시판 전체 조회용 Response Mapperdefault BoardTempDto.ListResponse boardEntityToBoardListResponseDto(Board board) {  
    if(Objects.nonNull(board)) {  
        BoardTempDto.ListResponse listCaseResponse = BoardTempDto.ListResponse.builder()  
                .boardId(board.getBoardId())  
                .nickName(board.getNickName())  
                .title(board.getTitle())  
                .local(board.getLocal().getLocalString())  
                .viewPoint(board.getViewPoint())  
                .likePoint(board.getBoardHeartList().size())  
                .createdAt(board.getCreatedAt())  
                .modifiedAt(board.getModifiedAt())  
                .build();  
        return listCaseResponse;  
    }  
    return null;  
}  

// 게시판 전체 조회 List형식으로 mappingdefault List<BoardTempDto.ListResponse> boardEntityListToBoardResponseDtoList(List<Board> boardList) {  
    if(Objects.nonNull(boardList)) {  
        List<BoardTempDto.ListResponse> list = new ArrayList<BoardTempDto.ListResponse>(boardList.size());  
        for(Board board : boardList) {  
            BoardTempDto.ListResponse response = boardEntityToBoardListResponseDto(board);  
            list.add(response);  
        }  
        return list;  
    }  
    return null;  
}

위의 코드는 N+1을 발생시키는 원인(?)을 가진 코드이다.

서비스계층에서 List<Board> 로 받은 데이터를 DTO로 옮기기 위해 boardEntityListToBoardResponseDtoList(List<Board> boardList) 메서드를 이용한다.

그리고 중간에 List를 순회하면서 전체 조회시 필요한 데이터만 받아서 응답하는 응답 객체로 변환하게 되는데, 이 때 boardEntityToBoardListResponseDto(Board board) 메서드를 사용한다.

바로 이 때!!!
.likePoint(board.getBoardHeartList().size()) 여기서 N+1과 연관된 쿼리가 나간다.

확인 들어가보자

전체 조회시 필요한 데이터만 받아서 응답하도록 Entity를 DTO로 변환하는 과정에서 사용하는 메서드 boardEntityToBoardListResponseDto(Board board)를 다음과 같이 개조(?)해보았다.

@Slf4j가 안된다.,,;; 아마 interface라 그런가.. 중요한 건 아니라서 일단 skip..

    // 개시판 전체 조회용 Response Mapper    default BoardTempDto.ListResponse boardEntityToBoardListResponseDto(Board board) {  
        if(Objects.nonNull(board)) {    
            BoardTempDto.ListResponse.ListResponseBuilder listResponse = BoardTempDto.ListResponse.builder()  
                    .boardId(board.getBoardId())  
                    .nickName(board.getNickName())  
                    .title(board.getTitle())  
                    .local(board.getLocal().getLocalString());  
            System.out.println("========================================쿼리와 상관없음");  
            System.out.println("========================================쿼리와 상관없음");  
            System.out.println("========================================쿼리와 상관없음");  
            System.out.println("========================================쿼리와 상관없음");  
                    listResponse.viewPoint(board.getViewPoint());  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            listResponse.likePoint(board.getBoardHeartList().size());  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            System.out.println("좋아요 시점 N+1 연관된 쿼리나가는 거 확인");  
            listResponse.createdAt(board.getCreatedAt());  
            System.out.println("========================================쿼리와 상관없음");  
            System.out.println("========================================쿼리와 상관없음");  
            BoardTempDto.ListResponse listCaseResponse = listResponse.modifiedAt(board.getModifiedAt())  
                    .build();  

            return listCaseResponse;  
        }  
        return null;  
    }

이렇게 했을 때, 다음과 같은 결과가 나온다.

Hibernate: 
    select
        board0_.board_id as board_id1_2_,
        board0_.created_at as created_2_2_,
        board0_.modified_at as modified3_2_,
        board0_.content as content4_2_,
        board0_.local as local5_2_,
        board0_.member_id as member_i9_2_,
        board0_.nickname as nickname6_2_,
        board0_.title as title7_2_,
        board0_.view_point as view_poi8_2_ 
    from
        board board0_ limit ?
2023-08-03 15:02:36.169  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : log = Page wanderhub.server.domain.board.repository.BoardRepository.findAll(Pageable)
2023-08-03 15:02:36.169  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : timeMs = 15
2023-08-03 15:02:36.169  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : log = Page wanderhub.server.domain.board.service.BoardService.findBoards(Pageable)
2023-08-03 15:02:36.169  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : timeMs = 15
2023-08-03 15:02:36.169 DEBUG 14372 --- [io-8080-exec-10] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
2023-08-03 15:02:36.169 DEBUG 14372 --- [io-8080-exec-10] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(2056270487<open>)]
2023-08-03 15:02:36.170 DEBUG 14372 --- [io-8080-exec-10] o.s.orm.jpa.JpaTransactionManager        : Not closing pre-bound JPA EntityManager after transaction
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
========================================쿼리와 상관없음
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
Hibernate: 
    select
        boardheart0_.board_id as board_id4_3_0_,
        boardheart0_.board_heart_id as board_he1_3_0_,
        boardheart0_.board_heart_id as board_he1_3_1_,
        boardheart0_.created_at as created_2_3_1_,
        boardheart0_.modified_at as modified3_3_1_,
        boardheart0_.board_id as board_id4_3_1_,
        boardheart0_.member_id as member_i5_3_1_ 
    from
        board_heart boardheart0_ 
    where
        boardheart0_.board_id=?
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
좋아요 시점 N+1 연관된 쿼리나가는 거 확인
========================================쿼리와 상관없음
========================================쿼리와 상관없음
2023-08-03 15:02:36.180  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : log = List wanderhub.server.domain.board.mapper.BoardMapper.boardEntityListToBoardResponseDtoList(List)
2023-08-03 15:02:36.180  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : timeMs = 9
2023-08-03 15:02:36.181  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : log = ResponseEntity wanderhub.server.domain.board.controller.BoardController.tempGetBoards(Pageable)
2023-08-03 15:02:36.181  INFO 14372 --- [io-8080-exec-10] w.server.global.logger.LogAspect         : timeMs = 28
2023-08-03 15:02:36.184 DEBUG 14372 --- [io-8080-exec-10] o.j.s.OpenEntityManagerInViewInterceptor : Closing JPA EntityManager in OpenEntityManagerInViewInterceptor

builder를 쪼개서 중간중간 sout을 넣어놨따.

이처럼 좋아요 리스트를 조회하는 시점.
바로 .likePoint(board.getBoardHeartList().size()) 이 코드에서
N+1이 발생하게 된다는 것을 확인할 수 있다.

정리를 해보자면,
연관관계에서 fetchType을 LAZY로 주고, 데이터를 얻어왔지만,
실제 데이터가 아니라, 프록시객체로 바인딩되었고,
실제 데이터가 필요한 순간 (지금 이 순간에는 좋아요를 얻어오는 시점)에 SQL을 실행시켜서 N+1이 발생하게 되는 것이다.

좀 더 자세히 설명해보자면, 게시판의 전체 Entity들을
전체 조회시 필요한 응답객체로 변환하는 과정이 있다.
이 때 좋아요 개수를 확인하는 과정에서 좋아요 리스트를 얻어온다.
그런데 좋아요 List에 바인딩 되었던 객체는 프록시 객체였다.
때문에 실제 데이터가 필요한 상황에서 데이터를 얻어오기 위해서 각 게시판을 순회할 때 좋아요를 조회하는 쿼리를 날리게 되는 것이다.

만약 게시판이 3개라면, 각 게시판마다 좋아요를 조회하기 때문에 3개의 좋아요 조회 쿼리를 날리게 된다.

2-3. QueryDSL을 사용하므로 얻게 된 효과

물론 N+1이 발생했을 때, 해결할 수 있는 방법이 있다.
fetch join, EntityGraph, BatchSize 등등이 있다.

하지만 현재 상황에서 필요한 건 좋아요 데이터가 아니라, 게시판마다 가지고 있는 좋아요의 개수가 필요했다.

이를 SQL로 옮기면 다음과 같다.
select count(*) from boardHeart where boardHeart_board_id = board_id

생각해볼 점은
게시판의 Entity에는 좋아요를 List타입으로 가지고 있고,
Dto에는 좋아요 개수를 단순히 Long타입으로 가지고 있다.

Entity에서 가져와서 DTO로 변환하는 과정에서 count쿼리로 날린다고 하더라도 결국 게시판 개수만큼 count 쿼리를 날려서 결과적으로 N+1은 해결되지 않는다.

그런데 QueryDSL을 사용함으로써 N+1 문제가 사라졌다.

QueryDSL을 사용해서 전체 조회 응답 DTO를 받아서 넘겨주도록 했는데,
실행되는 쿼리를 보면, 게시판 좋아요를 조회하는 것이 아니라,
게시판 좋아요의 count 쿼리를 날려서 내가 원하던 결과를 가져왔다.

다음은 QueryDSL의 게시판 전체 조회 코드이다.

public PageResponseDto<BoardListResponseDto> searchBoard(Integer page) {  
    List<BoardListResponseDto> boardDtoList;  
    boardDtoList = queryFactory  
            .select(new QBoardListResponseDto(  
                    board.boardId.as("boardId"),  
                    board.nickName,  
                    board.title,  
                    board.local.stringValue(),  
                    board.viewPoint,  
                    board.boardHeartList.size().longValue(),  
                    board.createdAt  
            ))  
            .from(board)  
            .offset(page * 10 - 10)  
            .limit(10)  
            .fetch();  
    Long totalElements = queryFactory  
            .select(board.count())  
            .from(board)  
            .fetchOne();  

    Long totalPage = (totalElements / 10) + (totalElements % 10 > 0 ? 1 : 0);  // totalPage  

    // 마지막 페이지보다 작으면 10 아니라면, 총 요소갯수에서 % 10(페이지 사이즈)  
    Long currentPageElements = page < totalPage ? 10 : totalElements % 10;  

    return PageResponseDto.of(boardDtoList, totalPage, totalElements, currentPageElements, page);  
}

select 절에 들어가 있는 코드 중 다음과 같은 코드가 있는데,
board.boardHeartList.size().longValue()
이 코드에 의해서 게시판 좋아요에 count 쿼리를 날리도록 되어있다.

결과를 보면 다음과 같다.

Hibernate: 
    select
        board0_.board_id as col_0_0_,
        board0_.nickname as col_1_0_,
        board0_.title as col_2_0_,
        cast(board0_.local as character varying) as col_3_0_,
        board0_.view_point as col_4_0_,
        cast((select
            count(boardheart1_.board_id) 
        from
            board_heart boardheart1_ 
        where
            board0_.board_id = boardheart1_.board_id) as bigint) as col_5_0_,
        board0_.created_at as col_6_0_ 
    from
        board board0_ limit ?
Hibernate: 
    select
        count(board0_.board_id) as col_0_0_ 
    from
        board board0_

중간에 cast되는 곳이 있는데,

cast((select
            count(boardheart1_.board_id) 
        from
            board_heart boardheart1_ 
        where
            board0_.board_id = boardheart1_.board_id)

바로 여기서 count 쿼리를 날려서 좋아요의 개수를 얻어온다.

그래서 더 이상 N+1 문제가 발생하지 않게 된 것이다!!

3. 요약

정리해보자면,
게시판 전체 조회를 하는데,
전체 목록을 보여주기 위해서 하나의 단일 게시판의 모든 정보를 비쳐줄 필요가 없다.
댓글, 본문 수정 일자 등 굳이 필요없는 정보를 제외해서 응답 데이터로 주려고 했다.

그런데,,,

게시판의 좋아요 개수를 응답 DTO에 넣는 과정에서 좋아요를 조회하는 쿼리가 게시판의 수만큼 발생해서 N+1이 발생했다.

fetch 타입을 LAZY로 주었지만, 실데 데이터가 필요한 상황에서 지연 로딩이 아니라, 지연 N+1이 발생했던 것이다..

왜 그런가 보았더니 List로 존재하는 게시판의 좋아요 개수를 응답DTO로 넣는 과정에서 좋아요를 게시판의 수만큼 조회하게 되면서 해당 문제가 발생하게 된 것이다.

이를 QueryDSL을 이용해서 해결하게 되었고,
QueryDSL에서 게시판의 좋아요 개수를 구하기 위해서 좋아요를 조회하는 쿼리가 아니라, count 쿼리를 날리게 됨으로써 N+1 문제가 자연스럽게 없어지게 되었던 것이다.

참고블로그

728x90
Comments