• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    해결됨

계층형 테이블에 매핑된 엔티티 컬렉션을 fetch join으로 가져올 때 쿼리 개수 질문드립니다.

23.11.02 12:05 작성 23.11.02 16:52 수정 조회수 420

0

[질문 템플릿]
1. 강의 내용과 관련된 질문인가요? (예)
2. 인프런의 질문 게시판과 자주 하는 질문에 없는 내용인가요? (예)
3. 질문 잘하기 메뉴얼을 읽어보셨나요? (예)

[질문 내용]

코드는 마지막에 첨부되어 있습니다.
게시글 엔티티 Post가 게시글에 달린 댓글 엔티티 Comment의 컬렉션을 프로퍼티로 갖습니다. 댓글 엔티티 Comment는 COMMENT, REPLY 타입으로 구분되며, 프로퍼티로 parentComment와 replies를 갖습니다. COMMENT 타입은 parentComment == null이며, REPLY 타입은 replies == null입니다. Post.comments는 Comment의 타입을 고려하지 않고 모두 갖고 있습니다.

위 상황에서 게시물 상세 정보를 가져올 때, comments를 fetch join하여 Post 엔티티를 가져와 댓글들을 타입 계층에 따라 분리하는 작업을 합니다. 이때 REPLY 타입의 replies에 접근하지 않습니다. COMMENT 타입의 replies에는 접근하며 해당 replies는 모두 같은 게시물에 속해있습니다.

예상했던 쿼리의 개수는 1 혹은 (1 + COMMENT 타입 수) 였습니다. 애초에 둘 중 무엇인지 궁금해서 일을 진행했었습니다.

테스트를 실행해보면, post.comments의 요소에 처음 접근할 때 REPLY 타입을 가져오는 쿼리가 발생합니다. 그런데 이후에 다른 COMMENT 타입에 접근할 때는 REPLY를 가져오는 쿼리가 발생하지 않습니다. 만약 Post의 COMMENT 타입 Comment.replies에 접근할 때 쿼리가 발생하는 거라면 COMMENT 타입의 개수만큼 발생해야 하는게 아닌가요? 제가 무엇을 놓치고 있는지 궁금합니다.

논외로, Spring data JPA의 @Query를 통해 작성한 단순한 정적 쿼리가 계속 반복된다면 QeuryDSL로 옮겨 반복을 줄이는게 합리적일까요? 아니면 spring data jpa의 편리함을 유지하는게 합리적인가요?

 

감사합니다.


public class Post extends BaseEntity {

    public static final int TITLE_MAX_LENGTH = 50;
    public static final int CONTENT_MAX_LENGTH = 10000;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id", nullable = false, updatable = false)
    private Long id;

    @Column(name = "title", nullable = false, length = TITLE_MAX_LENGTH, updatable = false)
    private String title;

    @Column(name = "content", nullable = false, length = CONTENT_MAX_LENGTH)
    private String content;

    @Column(name = "hits", nullable = false)
    private Long hits;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "writer_id")
    private Member writer;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "post", orphanRemoval = true)
    @ToString.Exclude
    private List<Comment> comments = new ArrayList<>();
}
public class Comment extends BaseDeleteEntity {

    public static final int CONTENT_LENGTH = 1000;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id", nullable = false)
    private Long id;

    @Column(name = "content", nullable = false, length = CONTENT_LENGTH)
    private String content;

    @Column(name = "type", nullable = false, updatable = false)
    private CommentType type;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false, updatable = false)
    private Post post;

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

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "parent", orphanRemoval = true)
    private List<Comment> replies = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_comment_id", updatable = false)
    @ToString.Exclude
    private Comment parent;
}
    @Query(value = """
            SELECT p FROM Post p 
            LEFT JOIN FETCH p.writer AS w 
            LEFT JOIN FETCH w.profile
            LEFT JOIN FETCH p.comments  
            WHERE p.id = :postId
            """
    )
    Optional<Post> findPostById(@Param("postId") final Long id);
    @Autowired
    EntityManager em;
    @Autowired
    PostRepository postRepository;

    @Test
    public void 정상작동테스트_추가적인_쿼리_발생_x() throws Exception {
        //given
        Post post = PostTest.create("username", "nickname");
        Member member = post.getWriter();
        em.persist(member);
        em.persist(post);
        Long postId = post.getId();

        int commentCount = 10;
        int replyCount = 2;
        createComment(post, member, commentCount, replyCount);

        em.flush();
        em.clear();

        //when
        int totalCommentCount = commentCount * (replyCount + 1);
        Post findPost = postRepository.findPostById(postId).get();
        System.out.println();

        //then
        for (Comment comment : findPost.getComments().stream().filter(Comment::isCommentType).toList()) { // 이때 REPLY 타입을 조회하는 쿼리가 1회 발
            System.out.println("comment = " + comment);
            System.out.println("comment.getReplies().get(0) = " + comment.getReplies().get(0));
        }


        Assertions.assertThat(post.getComments().size()).isEqualTo(totalCommentCount);
    }

[테스트 실행 시 쿼리]

2023-11-02T11:45:00.154+09:00 DEBUG 51844 --- [ main] org.hibernate.SQL :

select

p1_0.post_id,

c1_0.post_id,

c1_0.comment_id,

c1_0.content,

c1_0.created_by,

c1_0.created_date,

c1_0.deleted_date,

c1_0.is_deleted,

c1_0.last_modified_by,

c1_0.last_modified_date,

c1_0.parent_comment_id,

c1_0.type,

c1_0.writer,

p1_0.content,

p1_0.created_by,

p1_0.created_date,

p1_0.hits,

p1_0.last_modified_by,

p1_0.last_modified_date,

p1_0.title,

w1_0.member_id,

w1_0.created_by,

w1_0.created_date,

w1_0.last_modified_by,

w1_0.last_modified_date,

w1_0.login_id,

w1_0.login_type,

w1_0.profile_id,

p2_0.profile_id,

p2_0.created_by,

p2_0.created_date,

p2_0.last_modified_by,

p2_0.last_modified_date,

p2_0.nickname,

w1_0.user_role,

w1_0.social_login_id,

w1_0.username

from

post p1_0

left join

member w1_0

on w1_0.member_id=p1_0.writer_id

left join

profile p2_0

on p2_0.profile_id=w1_0.profile_id

left join

comment c1_0

on p1_0.post_id=c1_0.post_id

where

p1_0.post_id=?

2023-11-02T11:45:00.162+09:00 INFO 51844 --- [ main] p6spy

2023-11-02T11:45:01.221+09:00 DEBUG 51844 --- [ main] org.hibernate.SQL :

select

r1_0.parent_comment_id,

r1_0.comment_id,

r1_0.content,

r1_0.created_by,

r1_0.created_date,

r1_0.deleted_date,

r1_0.is_deleted,

r1_0.last_modified_by,

r1_0.last_modified_date,

r1_0.post_id,

r1_0.type,

r1_0.writer

from

comment r1_0

where

array_contains(?,r1_0.parent_comment_id)


[반복되는 spring data jpa 쿼리]

public interface PostRepository extends JpaRepository<Post, Long>, PostQueryRepository {

    /**
     * Post 반환 시 Member, Profile을 fetch join한다. ~ToOne 매핑관계에 대한 fetch join은 별명을 사용할 수 있고, 연계하여 fetch
     * join할 수 있다.
     *
     * @param id must not be {@literal null}.
     * @return
     */
    @Query(value = """
            SELECT p FROM Post p 
            LEFT JOIN FETCH p.writer AS w 
            LEFT JOIN FETCH w.profile
            LEFT JOIN FETCH p.comments  
            WHERE p.id = :postId
            """
    )
    Optional<Post> findPostById(@Param("postId") final Long id);

    /**
     * Post를 페이징 처리하여 Page<Post>로 반환한다. 이때 Member와 Profile을 fetch join한다.
     *
     * @param pageable the pageable to request a paged result,
     *                 can be {@link Pageable#unpaged()},
     *                 must not be {@literal null}.
     */
    @Query(value = """
            SELECT p FROM Post p 
            LEFT JOIN FETCH p.writer AS w 
            LEFT JOIN FETCH w.profile
            """,
            countQuery = "SELECT count(p) FROM Post p"
    )
    @Override
    Page<Post> findAll(final Pageable pageable);

    /**
     * WriterId가 memberId와 같은 Post를 페이징 처리하여 Page<Post>로 반환한다. 이때 Member와 Profile을 fetch join한다.
     *
     * @param writerId writerId가 일치하는 Post들을 반환한다.
     * @param pageable 페이징 정보
     */
    @Query(value = """
            SELECT p FROM Post p 
            LEFT JOIN FETCH p.writer AS w 
            LEFT JOIN FETCH w.profile
            WHERE w.id = :writerId
            """,
            countQuery = "SELECT count(p) FROM Post p WHERE p.writer.id = :writerId"
    )
    Page<Post> findAllByWriterId(@Param("writerId") final Long writerId, final Pageable pageable);
}

답변 1

답변을 작성해보세요.

0

인프런 AI 인턴님의 프로필

인프런 AI 인턴

2023.11.04

안녕하세요 김민규 님, 인프런 AI 인턴이에요.

간단하게 설명해 드리자면, FETCH JOIN 쿼리를 실행할 때는 일반적으로 엔티티 사이의 관계에 따라 JOIN이 발생합니다. 하지만 해당 쿼리에서는 REPLY 타입의 replies에는 접근하지 않으며, COMMENT 타입의 replies에만 접근한다고 설명하셨습니다.

실제 테스트 결과를 보면 처음 post.comments 컬렉션에 접근할 때 REPLY 타입의 쿼리가 발생하였다는 것을 확인하였습니다. 그러나 그 이후에 다른 COMMENT 타입에 접근할 때는 REPLY를 가져오는 쿼리가 발생하지 않았습니다.

COMMENT 타입의 replies의 경우 모두 같은 게시물에 속해 있기 때문에 처음에 한번만 조회하여 재사용되는 것으로 보입니다. 따라서 예상하셨던 쿼리 개수는 (1 + COMMENT 타입의 개수)가 아니라 1과 같습니다.

또한, 귀하께서 Spring Data JPA의 @Query를 통해 작성한 단순한 정적 쿼리가 계속 반복된다면 QueryDSL로 변경해주는 것이 반복을 줄일 수 있는 합리적인 접근일 수 있습니다. QueryDSL은 동적 쿼리 작성에도 강력한 기능을 제공해주기 때문에 유용하게 활용될 수 있습니다. 하지만 Spring Data JPA는 CRUD 메소드를 자동으로 제공하고 메소드명으로 쿼리를 생성하는 등 개발 편의성을 제공하기 때문에 편리한 접근 방식이기도 합니다.

다시 한번, 질문에 대한 답변을 드려서 감사합니다. 추가적인 문의가 있다면 언제든지 물어보세요!

김민규님의 프로필

김민규

질문자

2023.11.04

고마워요! 나도 인턴하고 싶다