네이버 클라우드 캠프

Projection을 사용한 명명 메서드: 성능 비교와 느낀 점

99duuk 2024. 7. 29. 17:26

 배경

24년 05월 수행했던 비트캠프 3차 프로젝트에서 ORM에 관한 공부 없이 무작정 QueryDSL을 사용해 API를 만들었다.
 
연관관계도 제대로 이해하지 못하고 무작정 쿼리만 쓰다보니, 어떤 메서드에서는 프로젝션이 되고 어떤 메서드는 돌아가지도 않아서
급하게 프로젝션 적용하지 않고 새로 만들고 했다. 돌아보면 어떻게 작성해서 뭘 하려고 했던 건지도 헷깔릴 만큼 요상한 시도였다. 
 
 
당시 명명 메서드는 별로야! 라는 이상한 편견에 휩싸여서 모든 조회를 쿼리단에서 처리하려고 아둥바둥 했는데,
당시에 조회에 필요한 테이블이 4~5개였고, 지나고 나서 보니 그게 "한방 쿼리" 비슷한 걸 시도했던 거였다.
 
 
그래서 이번엔 최대한 명명 메서드를 사용해서 프로젝션도 사용하고 깔끔하게 코드를 작성해보고 싶었다. 
 
그래서 ...
"프로젝션 활용한 쿼리 최적화 (n+1 방지)" 라는 기세등등한 명분을 등에 업고 
 
 

 도입

 프로젝트 목표 및 접근 방식

Spring Data JPA의 명명 메서드와 프로젝션을 활용한 쿼리 최적화 시도
프로젝트 설계 단계에서부터 최대한 간단하게 ERD를 그리고, QueryDSL과 MyBatis의 사용은 배제했기 때문에, 쿼리 작성 없이 Spring data JPA로 기능을 구현하되, 이전 프로젝트에서 실패한 프로젝션을 제대로 사용해보고 싶었다. 
 

 

 프로젝션

프로젝션은 엔티티의 일부 속성만을 선택적으로 조회하는 기법이다. 데이터베이스에서 필요한 컬럼만 가져오므로 성능 향상을 기대할 수 있다.
이런 프로젝션은 대략 interface기반 프로젝션, 클래스 기반(DTO)프로젝션, 동적 프로젝션 정도로 구분할 수 있다. 
1. interface 기반 프로젝션
     - 인터페이스를 정의하여 필요한 속성만 선언
     - 간단하고 타입 안전하지만, 구현의 유연성이 제한적
 
2. 클래스 기반(DTO) 프로젝션 
     - 별도의 DTO 클래스를 정의하여 필요한 속성만 포함
     - 유연성이 높고 복잡한 매핑이 가능하지만, 추가 클래스 작성 필요
 
 
3. 동적 프로젝션
     - 런타임에 프로젝션 타입을 결정
     - 유연성이 높지만, 타입 안전성이 떨어질 수 있음
 

구현 과정

Spring data JPA 명명 메서드에 프로젝션하는 것에 대한 관련 정보가 예상보다 많이 없었던 게 고비였다.
 
가장 좋은 성능을 보이는 DTO 프로젝션은 포기했다.. 이것저것 웬만한 건 다 시도 해봤지만, 계속해서 에러가 생겼다. 팀원들과 약속한 개발 일정을 맞추어야 했기 때문에 일단 잘 돌아가는 interface 프로젝션으로 구현하기로 결정했고, 

인터페이스 프로젝션

@EntityGreaph 사용해 Reply 엔티티와 연관된 Board 및 Member 엔티티를 함께 조회Hibernate: select b1_0.id, b1_0.category_name, b1_0.content, b1_0.is_deleted, b1_0.reg_date, b1_0.title, b1_0.views, b1_0.writer_id from board b1_0 where b1_0.

99duuk.tistory.com

와 같이 쿼리가 줄어든 걸 눈으로 확인할 수 있었다.
 
 
 

성능 비교 

프로젝트 중에는 쿼리가 원했던 대로 줄어 들었기 때문에 더 나아졌겠거니 하고 나머지 개발을 진행했다. 
프로젝트가 끝난 뒤, 나름 초반에 고민하고 고생했던 프로젝션의 성능이 궁금해졌다. 
 

    /**
     * 인터페이스 프로젝션
     * 게시물 전체 조회
     */
    @Transactional(readOnly = true)
    @EntityGraph(attributePaths = {"writer"})
    Page<IBoardList> findAllProjectedByCategoryNameNotAndIsDeletedFalseOrderByRegDateDesc(String categoryName, Pageable pageable);



    /**
     * DTO 프로젝션
     * 게시물 전체 조회
     */
    @Transactional(readOnly = true)
    @EntityGraph(attributePaths = {"writer"})
    @Query("SELECT new com.luckyvicky.woosan.domain.board.dto.BoardListDTO(" +
            "b.id, b.writer.id, b.writer.nickname, b.title, b.regDate, b.views, b.likesCount, b.categoryName, b.replyCount) " +
            "FROM Board b " +
            "WHERE b.categoryName <> :categoryName AND b.isDeleted = false " +
            "ORDER BY b.regDate DESC")
    Page<BoardListDTO> findAllDtoProjectedByCategoryNameNotAndIsDeletedFalseOrderByRegDateDesc(@Param("categoryName") String categoryName, Pageable pageable);
    
    
    /**
     * JPA 명명 메서드
     * 게시물 전체 조회
     */
    @Transactional(readOnly = true)
    Page<Board> findByCategoryNameNotAndIsDeletedFalseOrderByRegDateDesc(String categoryName, Pageable pageable);


 	/**
     * QueryDSL 
     * 게시물 전체 조회
     */
    public Page<Board> findByCategoryNameNotAndIsDeletedFalseOrderByRegDateDesc(String categoryName, Pageable pageable) {
        QBoard board = QBoard.board;
        List<Board> results = queryFactory.selectFrom(board)
                .where(board.categoryName.ne(categoryName).and(board.isDeleted.isFalse()))
                .orderBy(board.regDate.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        long total = queryFactory.selectFrom(board)
                .where(board.categoryName.ne(categoryName).and(board.isDeleted.isFalse()))
                .fetchCount();
        return new PageImpl<>(results, pageable, total);
    }


 	/**
     * projected QueryDSL 
     * 게시물 전체 조회
     */
    public List<BoardListDTO> findAllDtoProjectedByCategoryNameNotAndIsDeletedFalseOrderByRegDateDesc(String categoryName, Pageable pageable) {
        QBoard board = QBoard.board;
        List<BoardListDTO> results = queryFactory.select(Projections.constructor(BoardListDTO.class,
                        board.id,
                        board.writer.id,
                        board.writer.nickname,
                        board.title,
                        board.regDate,
                        board.views,
                        board.likesCount,
                        board.categoryName,
                        board.replyCount))
                .from(board)
                .where(board.categoryName.ne(categoryName).and(board.isDeleted.isFalse()))
                .orderBy(board.regDate.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        return results;
    }


등과 같이 작성했고, 




20만 건의 샘플 데이터를 준비한 뒤 테스트 코드를 작성했고, 
게시글 전체 조회, 게시글 단건 조회에 대해 
    - 명명 메서드를 사용한 interface 프로젝션
    - @Query를 사용한 dto 프로젝션
    - Spring data JPA 명명 메서드
    - QueryDSL 
    - 프로젝션한 QueryDSL
 
각 조건에 따라
100회씩 총 3번, 전체조회를 비교했다. 
 
 
 

 성능 비교 결과 

당연하게 인터페이스 프로젝션 쪽의 결과가 좋을 거라 생각했는데.. 다음과 같은 결과를 얻었다.
 
프로젝트 구현에 사용한 인터페이스 프로젝션의 성능 향상은 미미하거나 오히려 반감됐다. 
 
 
 

 

 소감

깨달은 점

애당초 굳이 프로젝션을 사용해서 일부 속성을 선택적으로 조회할 필요가 없었다.
프로젝션에 집착할 게 아니라, 성능 향상을 도모했다면 특정 컬럼에 인덱스 사용 같은 다른 방법을 고려해야했던 게 적절하지 않았을까..
 
쿼리의 길이가 줄어드는게 항상 최적의 해결책이 아닐 수 있다.
쿼리가 줄어들테니, 프로젝션을 사용하기만 하면 어떻게든 더 나은 성능이 나올거라 막연하게 생각했는데, 
 
막연한 예상만으로 시간을 쏟고 구현을 완료하는 것이 능사는 아니다
기능이 제대로 작동하는지는 테스트 코드를 작성해서 확인했지만, 정작 나름대로 애정과 관심을 들였던 프로젝션은 구현 직후 테스트해보지 않았다. 먼저 테스트부터 진행해서 불필요하다는 것을 인지했다면, 이후에 프로젝션을 위한 인터페이스와 코드를 작성하는데 소모했던 추가 시간이 낭비되지 않았을 텐데.. 다른 부분을 더 개선할 수 있었을텐데.. 하는 아쉬움이 남는다. 
 

향후 개선 방향

성능 최적화는 단순히 한 가지 기술을 적용하는 것이 아니다. 
어차피 복잡한 쿼리의 경우 명명 메서드로 작성하기에 한계가 있으니, QueryDSL을 사용할 텐데.. 
그 때는 신경쓰지 않아도 프로젝션을 하게 되고, 그 때는 프로젝션이 아닌 다른 관점에서 성능 향상을 고민해야한다.
그때그때 상황에 맞는 적절한 기술 선택이 중요하다.
 
실제 적용 결과가 중요하다.
트렌드나 이론에 의존하지 않고, 실제 적용 결과를 바탕으로 판단해야 한다는 것을 배웠다. 
 
성능 비교 방법에 대하여 
현재 성능 비교 방법의 정확성이 의심된다. 결과의 정확도도 좀 떨어지는 것 같고, 해당 비교에 대해 나조차도 그닥 신뢰가 가지 않는다..
앞서 Spring data JPA 와 Elasticsearch의 검색 성능 비교를 했던 방법을 그대로 프로젝션 비교에 사용한 건데, QueryDSL이 가장 높은 성능을 보인다는 결과는 달라지지 않을 듯 하지만..
앞으로 성능 비교를 할 때는 좀 더 정확하고 믿을 수 있도록 작성해야할 것 같다.