본문 바로가기
스프링

[쿼리문 개선] 댓글/대댓글 조회 dto로 받기

by 순원이 2023. 11. 27.

기존코드

PostCommentGetService

@Service
@RequiredArgsConstructor
public class PostCommentGetService {

    private final PostCommentRepositoryCustom postCommentRepositoryCustom;

    @Transactional
    public List<PostCommentGetResponse> getPostComment(long postId) {
        List<PostComment> postCommentList = postCommentRepositoryCustom.findByPostId(postId);

        List<PostCommentGetResponse> responseList = new ArrayList<>();
        Map<Long, PostCommentGetResponse> responseHashMap = new HashMap<>();

        postCommentList.forEach(postComment -> {
            PostCommentGetResponse postCommentGetResponse = PostCommentGetResponse.convertCommentToDTO(postComment);
            responseHashMap.put(postCommentGetResponse.getPostCommentId(), postCommentGetResponse);

            // 대댓글인 경우 부모 댓글의 replies에 추가
            Optional.ofNullable(postComment.getParentComment())
                    .map(parent -> responseHashMap.get(parent.getId()))
                    .ifPresent(parentResponse -> parentResponse.getReplies().add(postCommentGetResponse));

            // 댓글인 경우 ResponseList에 추가
            if (postComment.getParentComment() == null) {
                responseList.add(postCommentGetResponse);
            }
        });
        return responseList;
    }
}

PostCommentRepositoryImpl

@Repository
@RequiredArgsConstructor
public class PostCommentRepositoryImpl implements PostCommentRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<PostComment> findByPostId(Long postId) {
        return queryFactory
                .selectFrom(postComment)
                .join(postComment.parentComment).fetchJoin()
                .where(postComment.post.id.eq(postId))
                .orderBy(postComment.parentComment.id.asc().nullsFirst(),
                        postComment.createdAt.asc())
                .fetch();
    }

}

PostCommentGetResponse

@Data
@AllArgsConstructor
public class PostCommentGetResponse {

    private long postCommentId;
    private String contents;
    private List<PostCommentGetResponse> replies = new ArrayList<>();

    public PostCommentGetResponse(long id, String contents) {
        this.postCommentId = id;
        this.contents = contents;
    }

    public static PostCommentGetResponse convertCommentToDTO(PostComment postComment) {
        return new PostCommentGetResponse(postComment.getId(), postComment.getContents());
    }
}

기존 로직 설명

댓글 1

댓글2

대댓글 1-1

대댓글 1-2

대댓글 2-1

대댓글 1-3

대댓글 2-2

PostCommentRepositoryImpl에서 이런 형식으로 엔티티를 반환합니다. 서비스 단에서 HashMap을 통해

PostCommentGetResponse로 변환해요 . 최종적으로 아래와 같은 Response로 반환돼요.

댓글 1

대댓글 1-1

대댓글 1-2

대댓글 1-3

댓글2

대댓글 2-1

대댓글 2-2



변경사항

  • Response에댓글 생성일자댓글 작성자 학교 logo URL 추가
  • 댓글 작성자 ID
  • 댓글 좋아요 수
  • 서비스 단에서 DTO로 변환 → 쿼리 반환값을 DTO로 받기
  • 페이징 형식으로

Response 먼저 변경할게요

변경후 PostCommentResponse

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostCommentResponse {

    private long postCommentId;
    private String contents;
    private Long likeCount;
    private LocalDateTime createdAt;
    private long memberId;
    private String logoUrl;
    private List<PostCommentChildrenResponse> replies = new Array리팩토링리팩토링 1리List<>리팩토링();

    @QueryProjection
    public PostCommentResponse(long postCommentId, String contents, LocalDateTime creat리팩토링1edAt, long memberId, Long likeCount, String logoUrl) {
        this.postCommentId = postCommentId;
        this.contents = contents;
        this.createdAt = createdAt;
        this.memberId = memberId;
        this.likeCount = likeCount;
        this.logoUrl = logoUrl;
    }

    public void setPostCommentChildrenResponse(List<PostCommentChildrenResponse> replies) {
        this.replies = replies;
    }
}ㅣ

쿼리dsl에서 DTO로 반환을 받기 위해서는

을 사용해야 해요

Projection

Projection을 사용하면 엔티티가 아니라 선택적으로 필드를 반환 받을 수 있어요

Projection을 통해 DTO로 반환을 받는 방법은 https://9hyuk9.tistory.com/85를 참고해보세요 ~

저는 그중 컴파일 시점에 타입 안정성을 확보할 수 있는 @QueryProjection 를 사용했어요.

@QueryProjection 의 타입 안전성

  1. 컴파일 시점의 검증: QueryDSL의 APT(Annotation Processing Tool)가 이를 처리하여 Q-타입 클래스를 생성합니다. 이 Q-타입 클래스는 DTO의 생성자 파라미터와 정확히 일치하는 필드와 생성자를 가집니다. 이로 인해, 쿼리에서 DTO를 생성할 때 발생할 수 있는 타입 불일치 문제를 컴파일 시점에 잡아낼 수 있습니다.
  2. 명시적인 생성자 매핑: Q-타입 클래스를 사용하면 생성자의 파라미터를 명시적으로 지정해야 합니다. 이는 개발자가 실수로 필드를 빠뜨리거나 순서를 잘못 지정하는 것을 방지합니다.

 

단점

  • DTO가 Querydsl에 대한 의존성이 생긴다는 단점이 있다.

 



  • Response에댓글 생성일자댓글 작성자 학교 logo URL 추가
  • 댓글 작성자 ID
  • 댓글 좋아요 수

고민

  1. 댓글 좋아요는 PostComment 테이블에 없고 PostCommentLike라는 테이블이 따로 있어요 그래서 select을 어떻게 해야할지 ?
  2. postComment의 속성들과 학교 logoURL의 테이블 관계가 멀다 댓글 작성자의 학교 logoURL도 반환해주어야 해요. PostComment → Member 와 IdCard → member를 조인해서 IdCard의 UniversityName을 구한 후, UniversityAsset 테이블에서 UniversityName과 일치하는 logoURL을 가져와야 해요. 보기만 해도 과정이 복잡해서 쿼리문을 작성하는데 두려움이 있고 엔티티 설계를 잘못했나라는 생각이 들기도 합니당…
  3. DTO 속성안에 DTO LIST가 있을 경우 하나의 쿼리로 가져올 수 있을까? 대댓글을 가져올 대댓글 엔티티 자체를 가져오고 싶은 게 아니라 댓글과 같이 DTO(댓글 좋아요 수 , 댓글 생성일자, 댓글 작성자 ID, 댓글 작성자 학교 logo URL 추가, 내용, 대댓글ID)를 가져오고 싶다

⇒ DTO로 반환받는 것이 아닌 엔티티로 반환 받고 DTO로 수정해야겠다.

  1. 부모(댓글)와 자식(대댓글)을 그룹으로 묶고 정렬하는 것을 쿼리문에서 할까 어플리케이션에서 할까?
  2. 엔티티의 그룹넘버, 깊이, 순서 속성을 추가할까? 기존 속성을 활용할까?

고민 1에 대한 해결법

댓글 좋아요를 구하는 방법에는 3가지 방법이 있습니다.

  1. join
  2. SubQuery
  3. 쿼리 분리

상황에 따라 3개를 선택해서 사용하시면 됩니다. 
저는 SubQuery를 선택했습니다.

혹시나 select에서 서브쿼리를 사용하는 경우 쿼리를 분리하는 거랑 결과가 똑같은 거 아냐? 라는 궁금증이 생길 수도 있습니다.

⇒ 그럼에도 서브쿼리를 사용하는 이유는 서브쿼리는 메인 쿼리와 함께 한 번의 데이터베이스 호출로 처리되어 데이터베이스 트래픽 부하를 줄이기 위함입니다!

(+) mysql. 5.6 이후 서브쿼리가 최적화 되었습니다!

JPQLQuery<Long> likeCountSubQuery = JPAExpressions
                .select(postCommentLike.count())
                .from(postCommentLike)
                .where(postCommentLike.postComment.eq(postComment));

고민 2에 대한 해결법

고민 2도 마찬가지로 서브쿼리로 해결했습니다.

필요한 속성들이 관계가 먼 경우 중복될지라도 테이블에 속성을 추가하는 방법도 있겠지만 기존 로직을 수정하는데 비용이 많이 들것이라 판단하여 2번의 조인을 거치는 걸로 결정했습니다. 후에 성능적인 퍼포먼스가 많이 떨어져 문제가 될 시 리팩토링 할 계획입니다 ~

JPQLQuery<String> logoUrlSubQuery = JPAExpressions
                .select(universityAsset.logoUrl)
                .from(postComment)
                .innerJoin(idCard).on(idCard.member.eq(postComment.member))
                .innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university));
                JPQLQuery<String> logoUrlSubQuery = JPAExpressions
                    .select(universityAsset.logoUrl)
                    .from(idCard)
                    .innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
                    .where(idCard.member.id.eq(postComment.member.id));

처음에 첫 번째 쿼리로 작성했습니다. 결과는 특정한 postComment에 대한 학교 logoURL이 아닌 모든 postComment에 대한 학교 logoURL이 반환됐습니다.

고민 3에 대한 해결법

  1. WITH RECURSIVE
  2. 애플리케이션에서 처리 디비에서 정렬이 안된 댓글,대댓글을 몽땅 가져온다 → 애플리케이션단에서 대댓글에게 댓글을 찾아줘서 정렬해준다 반복문을 쓰기보다 애플리케이션에서 처리하는 것이 성능적으로 좋은 퍼포먼스를 낼 것 같아 애플리케이션에서 처리하기로 하였습니다.

문제) 자식 엔티티를 받는 것이 아닌 자식 dto를 받는 것이기 때문에 애플리케이션에서 처리를 못함. 어차피 쿼리를 한 번 더 날려야 함

애플리케이션에서 처리방식:

쿼리문에서 dto받기(댓글,대댓글)

→ HashMap 자료구조를 통해 댓글 dto에 대댓글 dto 넣어주기

→ 반환을 위해 HashMap을 List형식으로 변환

  1. 부모댓글dto를 받는 쿼리랑 자식대댓글dto 받는 쿼리 분리 부모댓글dto 받은 후 → 자식 대댓글 dto 받기

⇒ 쿼리문에서 dto로 받기의 장점, 서비스코드에서의 간결한 코드가 사라진 것 같아 좋지 못한 것 같습니다. 다시 쿼리문에서의 정렬을 고민해보겠습니다.

⇒ WITH RECURSIVE 구문은 mysql이 최적화가 되어 있지 않고 남이 코드를 이해하는 데 힘들 것 같습니다. 앞서 언급했던 것 처럼 애플리케이션에서 처리하더라도 쿼리문을 한 번 더 날리기 때문에 깔끔한 코드를 위해 3번 방식을 선택하겠습니다.

리팩토링1

@Override
    public List<PostCommentResponse> findByPostId(Long postId) {
        JPQLQuery<Long> likeCountSubQuery = JPAExpressions
                .select(postCommentLike.count())
                .from(postCommentLike)
                .where(postCommentLike.postComment.eq(postComment));

                JPQLQuery<String> logoUrlSubQuery = JPAExpressions
                    .select(universityAsset.logoUrl)
                    .from(idCard)
                    .innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
                    .where(idCard.member.id.eq(postComment.member.id));

        List<PostCommentResponse> parentComments = queryFactory
                .select(new QPostCommentResponse(
                                                postComment.parentComment.id,
                        postComment.id,
                        postComment.contents,
                        postComment.createdAt,
                        postComment.member.id,
                        likeCountSubQuery,
                        logoUrlSubQuery))
                .from(postComment)
                .where(postComment.parentComment.isNull())
                .orderBy(postComment.createdAt.asc())
                .fetch();

        for (PostCommentResponse parentComment : parentComments) {
            List<PostCommentResponse> childComments = queryFactory
                    .select(new QPostCommentResponse(
                                                        postComment.parentComment.id,
                            postComment.id,
                            postComment.contents,
                            postComment.createdAt,
                            postComment.member.id,
                            likeCountSubQuery,
                            logoUrlSubQuery))
                    .from(postComment)
                    .where(postComment.parentComment.id.eq(parentComment.getPostCommentId()))
                    .orderBy(postComment.createdAt.asc())
                    .fetch();

            parentComment.setReplies(childComments);
        }
        return parentComments;
    }

쿼리가 다소 길어지긴 했지만 직관적이라 유지보수하기 쉬운 코드라고 생각합니다.

통합테스트를 돌려 쿼리문을 확인해 보겠습니다

Hibernate: 
    select
        p1_0.post_comment_id,
        p1_0.contents,
        p1_0.created_at,
        p1_0.member_id,
        (select
            count(p2_0.post_comment_like_id) 
        from
            post_comment_like p2_0 
        where
            p2_0.post_comment_id=p1_0.post_comment_id),
        (select
            u1_0.logo_url 
        from
            post_comment p4_0 
        join
            id_card i1_0 
                on i1_0.member_id=p4_0.member_id 
        join
            university_asset u1_0 
                on u1_0.university_name=i1_0.university) 
    from
        post_comment p1_0 
    where
        p1_0.parent_id is null 
    order by
        p1_0.created_at
Hibernate: 
    select
        p1_0.post_comment_id,
        p1_0.contents,
        p1_0.created_at,
        p1_0.member_id,
        (select
            count(p2_0.post_comment_like_id) 
        from
            post_comment_like p2_0 
        where
            p2_0.post_comment_id=p1_0.post_comment_id),
        (select
            u1_0.logo_url 
        from
            post_comment p4_0 
        join
            id_card i1_0 
                on i1_0.member_id=p4_0.member_id 
        join
            university_asset u1_0 
                on u1_0.university_name=i1_0.university) 
    from
        post_comment p1_0 
    where
        p1_0.parent_id=? 
    order by
        p1_0.created_at
Hibernate: 
    select
        p1_0.post_comment_id,
        p1_0.contents,
        p1_0.created_at,
        p1_0.member_id,
        (select
            count(p2_0.post_comment_like_id) 
        from
            post_comment_like p2_0 
        where
            p2_0.post_comment_id=p1_0.post_comment_id),
        (select
            u1_0.logo_url 
        from
            post_comment p4_0 
        join
            id_card i1_0 
                on i1_0.member_id=p4_0.member_id 
        join
            university_asset u1_0 
                on u1_0.university_name=i1_0.university) 
    from
        post_comment p1_0 
    where
        p1_0.parent_id=? 
    order by
        p1_0.created_at

보다시피 자식대댓글을 구할 때 부모댓글의 수만큼 쿼리가 나갑니다.

for문 때문에 불가피합니다. 댓글이 그리 많이 달리지 않겠지만 부모의 댓글의 수만큼 자식 대댓글 구하는 쿼리가 나가는 문제: N+1 문제 를 해결해보겠습니다

연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회하기

방법1.  Fetch join

페치 조인과 일반 조인의 차이

  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이다.
  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화

둘 이상의 컬렉션은 페치 조인 하면 좋지 않다.

~일 관계에서만 Fetch join 쓰기*

방법2. @BatchSize

지정된 size 만큼 SQL의 IN절을 사용해서 조회한다.

**batch size**를 사용하면 지정된 크기만큼의 자식 엔티티를 한 번의 쿼리로 로드합니다.

문제

위 방법들을 통해 가져오는 것은 연관된 엔티티입니다. dto를 가져와야하는 상황에서 위 방법들은 사용할 수 없다는 것을 깨달았습니다.

리팩토링3

for문으로 인해 select이 여러번 나가는 것을 제거하기 위해 댓글,대댓글 dto를 전부 쿼리문으로 받고 애플리케이션 단에서 부모댓글 dto에 자식대댓글 dto를 넣어주기로 결정했습니다.

PostCommentGetService

@Transactional(readOnly = true)
    public List<PostCommentResponse> getPostComment(long postId) {
        List<PostCommentResponse> commentResponses = postCommentRepositoryCustom.findByPostId(postId);

        Map<Long, PostCommentResponse> commentMap = new HashMap<>();
        List<PostCommentResponse> parentCommentResponses = new ArrayList<>();

        for (PostCommentResponse commentResponse : commentResponses) {
            commentMap.put(commentResponse.getPostCommentId(), commentResponse);
            if (commentResponse.getParentCommentId() == null) {
                parentCommentResponses.add(commentResponse);
            } else {
                commentMap.get(commentResponse.getParentCommentId()).getReplies().add(commentResponse);
            }
        }

        return parentCommentResponses;
    }

PostCommentRepositoryImpl

@Override
    public List<PostCommentResponse> findByPostId(Long postId) {
        JPQLQuery<Long> likeCountSubQuery = JPAExpressions
                .select(postCommentLike.count())
                .from(postCommentLike)
                .where(postCommentLike.postComment.eq(postComment));

                JPQLQuery<String> logoUrlSubQuery = JPAExpressions
                    .select(universityAsset.logoUrl)
                    .from(idCard)
                    .innerJoin(universityAsset).on(universityAsset.universityName.eq(idCard.university))
                    .where(idCard.member.id.eq(postComment.member.id));

        List<PostCommentResponse> comments = queryFactory
                .select(new QPostCommentResponse(
                        postComment.parentComment.id,
                        postComment.id,
                        postComment.contents,
                        postComment.createdAt,
                        postComment.member.id,
                        likeCountSubQuery,
                        logoUrlSubQuery))
                .from(postComment)
                .where(postComment.post.id.eq(postId))
                .orderBy(postComment.createdAt.asc())
                .fetch();

        return comments;
    }
Hibernate: 
    select
        p1_0.parent_id,
        p1_0.post_comment_id,
        p1_0.contents,
        p1_0.created_at,
        p1_0.member_id,
        (select
            count(p3_0.post_comment_like_id) 
        from
            post_comment_like p3_0 
        where
            p3_0.post_comment_id=p1_0.post_comment_id),
        (select
            u1_0.logo_url 
        from
            post_comment p5_0 
        join
            id_card i1_0 
                on i1_0.member_id=p5_0.member_id 
        join
            university_asset u1_0 
                on u1_0.university_name=i1_0.university) 
    from
        post_comment p1_0 
    where
        p1_0.post_id=? 
    order by
        p1_0.created_at

쿼리문이 하나로 줄여졌습니다! 쿼리문이 줄어드니 리팩토링1 코드보다 거의 두배는 빨라졌습니다 ㅎㅎ

최종응답값

{
  "statusResponse": {
    "resultCode": "F000",
    "resultMessage": "요청 정상 처리"
  },
  "data": [
    {
      "parentCommentId": null,
      "postCommentId": 1,
      "contents": "테스트 1 댓글내용입니다.",
      "createdAt": "2023-11-26T17:58:58.484093",
      "memberId": 1,
      "likeCount": 2,
      "logoUrl": "/가천",
      "replies": [
        {
          "parentCommentId": 1,
          "postCommentId": 2,
          "contents": "테스트 1-1 대댓글내용입니다.",
          "createdAt": "2023-11-26T17:58:58.498568",
          "memberId": 2,
          "likeCount": 1,
          "logoUrl": "/가천",
          "replies": []
        },
        {
          "parentCommentId": 1,
          "postCommentId": 3,
          "contents": "테스트 1-2 대댓글내용입니다.",
          "createdAt": "2023-11-26T17:58:58.508543",
          "memberId": 3,
          "likeCount": 0,
          "logoUrl": "/가천",
          "replies": []
        }
      ]
    },
    {
      "parentCommentId": null,
      "postCommentId": 4,
      "contents": "테스트 2 댓글내용입니다.",
      "createdAt": "2023-11-26T17:58:58.560915",
      "memberId": 2,
      "likeCount": 0,
      "logoUrl": "/가천",
      "replies": [
        {
          "parentCommentId": 4,
          "postCommentId": 5,
          "contents": "테스트 2-1 댓글내용입니다.",
          "createdAt": "2023-11-26T17:58:58.565903",
          "memberId": 1,
          "likeCount": 0,
          "logoUrl": "/가천",
          "replies": []
        }
      ]
    }
  ]
}

+

FetchType.EAGER로 설정시 실행 시간이 단축될까?

FetchType을 달리하더라도, 모든 댓글(댓글,대댓글)을 조회하는 쿼리문에서 sql문이 똑같이 하나로 나간다. 그렇다면 실행시간이 차이가 날까?? 아래 링크에서 자세한 설명

@OneToMany(fetch = FetchType.EAGER, mappedBy = "parentComment", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostComment> replies;
@OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostComment> replies;


페이징

  1. Offset방식: Offset과 limit 예약어를 통하여 select의 전체 결과 중 일부만 가져오는 방법이다.
  2. Cursor 방식 cursor는 어떠한 레코드를 가르키는 포인터이고 이 Cursor가 가르키는 레코드부터 일정 개수만큼 가져오는 방식이다. Sek Method, Keyset Pagination이라고도 한다.

페이징 방식에 대해서 아래 링크를 통해 자세히 살펴보세요!

https://betterdev.tistory.com/17

댓글과 대댓글에서는 페이징을 사용하지 않을 겁니다. 페이징은 대량의 데이터를 부분으로 쪼개 가져오는 방식입니다. 만들고 있는 서비스랑 비슷한 에브리타임을 선례로 살펴보겠습니다. 에브리타임에서 글에 댓글 같은 경우 대부분 1000개가 넘지 않아요. 이정도는 페이징을 쓰지 않아도 될 것 같아요. 또 댓글과 대댓글을 테이블에서 가져올 때 페이징을 통해 잘라서 가져오면 댓글3, 대댓글3-1은 가져왔으나 대댓글3-2은 안 가져올 경우의 수가 있기 때문에(물론 부모댓글을 기준으로 가져오면 되겠지만 구현이 복잡할 것 같습니다!) 페이징을 쓰지 않았습니다!