지난번에 이어 이번에는 댓글 조회 기능 을 구현할 생각이다. 페이징 이 들어가야 해서 querydsl 로 구현 해야한다. 

jpql 로 한다면 ㅎㅎ.... string 잘라서 이어붙이고 있을 나 를 생각해보면 끔찍하다.

 

지난글 pr 을 머지 하고 조회 쪽을 하자 .

 

지난번 pr 브런치 삭제해주고, pull master 땡겨주고 시작해보자.

On branch guiwoo
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   src/main/resources/application-dev.properties

no changes added to commit (use "git add" and/or "git commit -a")

바로 컨트롤러 테스트 작성 부터 가자

더보기
@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void successCommentList() throws Exception{
    mockMvc.perform(get("/api/v1/member/content/3/comment")
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            .andExpect(
                    jsonPath("$.success")
                            .value("T")
            )
            .andDo(print());
}

get 이니 따로 뭐 실패 케이스 테스트 할 그게 없다.

생각 해 보니깐 응답값이 저게 아니다. 커맨트 리스트로 돌려줘야 한다. 이걸위한 리스폰스 클래스를 만들어주자.

더보기
package com.workduo.member.content.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Builder;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@Builder
public class MemberContentCommentDto {
    private Long id;
    private Long memberId;
    private String username;
    private String content;
    private String nickname;
    private String profileImg;
    private Long likeCnt;
    private LocalDateTime createdAt;

    @QueryProjection
    public MemberContentCommentDto(Long id, Long memberId, String username, String content, String nickname, String profileImg, Long likeCnt, LocalDateTime createdAt) {
        this.id = id;
        this.memberId = memberId;
        this.username = username;
        this.content = content;
        this.nickname = nickname;
        this.profileImg = profileImg;
        this.likeCnt = likeCnt;
        this.createdAt = createdAt;
    }
}

이 클래스로 querydsl 의 응답값을 Page<MemberContentDto> 로 받을 생각이다.

이렇게 가공해서 프론트 로 뿌려주자 정책에 적힌 것처럼 반환이 될것으로 예상된다.

리턴 값도 바꿔주자

@GetMapping("{memberContentId}/comment")
public ResponseEntity<?> getCommentList(
        Pageable pageable,
        @PathVariable("memberContentId") Long memberContentId){
    ContentCommentList.Response result = 
            ContentCommentList.Response.from(
                    memberContentService
                            .getContentCommentList(memberContentId, pageable));

    return new ResponseEntity<>(
            result,
            HttpStatus.OK
    );
}


저 서비스 없으면 바로 통과된다. pageable 을 인자로 받아 주자.

 

그러면 pageable 객체를 생성한다. 궁금하면 그냥 디버그 찍어보자 ㅎㅎ

좋다 굳이 객체 생성 안해도 되니깐 인자로 넘겨서 제대로 들어가는지 한번더 해보자 해보니깐 노션에 정책중에 offset 대신에 size 로 바꾸어 주어야 할꺼같다. 

"/api/v1/member/content/3/comment?page=3&size=10&sort=test"

현재 디폴트 가 20 으로 되어있기 때문에 일단 진행하자.

 

페이저블 들어가는거 확인 했으니 서비스 테스트 코드 작성하러 가자.

 

1. 멤버 피드 가 존재하지 않는경우

더보기
PageRequest pageRequest = PageRequest.of(0, 20);

@Test
@DisplayName("멤버 피드 댓글 리스트 실패 [피드 가 존재하지 않는 경우]")
public void doesNotExistFeed() throws Exception{
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.getContentCommentList(12L,pageRequest));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DOES_NOT_EXIST
            ,exception.getErrorCode());
}

//service
@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);

    return null;
}

2. 멤버 피드 가 삭제된 경우

더보기
@Test
@DisplayName("멤버 피드 댓글 리스트 실패 [피드가 삭제된 경우]")
public void feedDeleted() throws Exception{
    //given
    doReturn(Optional.of(MemberContent.builder().deletedYn(true).build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()-> memberContentService.getContentCommentList(12L,pageRequest) );
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DELETED,
            exception.getErrorCode());
}

// service

@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);
    isContentDeleted(mc);

    return null;
}

이 번 페이지는 회원이 아니더라도 누구나 접근이 가능한 요청 이기에  멤버의 발리드를 뺴주면 간단하게 해결된다.

만약 테스트 코드를 복붙한다면 에러난다 왜 ? 필요없는 걸 모킹 했다고 불평한다 .

 

2개의 테스트 가 모두 통과했다. 

 

서비스  코드 의 성공 케이스를 작성해보자.

 

@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void sucess() throws Exception{
    //given
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    memberContentService.getContentCommentList(12L,pageRequest);
    //then
    verify(memberContentQueryRepository,times(1))
            .getCommentByContent(any(),any());
}

단순히 쿼리디에스엘 함수가 호출 되는 여부만 파악하면 이 서비스는 테스트가 완료된다.

 

서비스 코드 작성해주러 가자.

    @Override
    @Transactional(readOnly = true)
    public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
        MemberContent mc = getContent(memberContentId);
        isContentDeleted(mc);

        return memberContentQueryRepository.getCommentByContent(memberContentId,pageable);
    }

 

쿼리 만들러가자.

 

@Override
public Page<MemberContentCommentDto> getCommentByContent(Long memberContentId, Pageable pageable) {
    List<MemberContentCommentDto> list = jpaQueryFactory.select(
                    new QMemberContentCommentDto(
                            memberContentComment.id,
                            member.id,
                            member.username,
                            memberContentComment.content,
                            member.nickname,
                            member.profileImg,
                            Expressions.as(
                                    select(memberContentCommentLike.count())
                                            .from(memberContentCommentLike)
                                            .where(
                                                    memberContentCommentLike
                                                            .memberContentComment
                                                            .eq(memberContentComment)
                                            ), "likeCnt"),
                            memberContentComment.createdAt
                    )
            )
            .from(memberContentComment)
            .join(memberContentComment.member,member)
            .join(memberContentComment.memberContent,memberContent)
            .where(
                    memberContentComment.memberContent.id.eq(memberContentId),
                    memberContentComment.deletedYn.eq(false)
            )
            .groupBy(memberContentComment)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(findByContentComment().toArray(OrderSpecifier[]::new))
            .fetch();

    JPAQuery<Long> countQuery = jpaQueryFactory
            .select(memberContentComment.count())
            .from(memberContentComment)
            .where(memberContentComment.memberContent.id.eq(memberContentId));

    return PageableExecutionUtils.getPage(
            list,
            pageable,
            countQuery::fetchOne);
}

음 쿼리 프로젝션 위에 dto 아래 붙인거 기억하는가 ? 그걸 만들고 querydsl compile 을 우리 코끼리 눌러주면

Q"dto 이름" 요런식으로 클래스를 생성해주는데 이 생성자를 이용해서 쿼리로 받을 데이터 일치시켜주면된다.

사실 엔티티 로 그냥 싸그리 받아서 돌려줘도 되지만, 그러면 tuple 의 혼합된 값으로 받는데 어차피 데이터 가공이 필요한 부분이다.

 

추가적인 쿼리프로젝션 을 생성해서 했다 이게 싫으면
1. Projection.bean() 을 이용해  세터를 이용해 인자들 을 뽑아오면 된다.
("기본생성자 가 없으면 에러나니 주의하자 맨날 이거 안해서 에러난다. ㅎㅎ")

 

2. Projection.filed() 를 이용해 field 이름 별로 필드 찍어서 넣어주면된다.
("게터 세터 무시하고 그냥 필드에 박힘")

 

3. Projection.constructor() 를 이용해 생성자 함수 만들어서 필요인자 넣어주면 된다.

("필요 이상의 인자들도 넣어서 받아 버릴 수 있기 때문에 정확하게 dto 내에 필요한 데이터만 호출해야함")

 

우리 는 Q파일 을 따로 만들어서 진행하기 에 정책상 따라 갔다. 그 이유로는 컴파일 오류 로 인자 오류를 엮어낼수 있다.

컨스터럭터 방식은 컴파일 시점에서는 불평없다가 런타임 시점에 문제가 발생하기 떄문에 이 차이가 있기에 우리는 안정성 있는 방식을 

추구하고자 Q파일 을 컴파일하는 방법으로 진행했다. (그외의 선택지 에는 위의 3가지 방법이 있고 개인적으로 생성자 를 선호한다.)

 

그 외에는 일반적인 쿼리와 다를바 없다. 크 쿼리디에스엘 이 정말 직관적이여서 코드가 길어도 한번에 이해가 간다.

 

밑에는 리턴 값을 타입에 맞춰주기 위해 가공을 해서 반환을 하게 된다.

 

저기서 orderby 부분 에 함수를 한번 보자.

private List<OrderSpecifier> findByContentComment() {
    StringPath aliasCommentLikes = Expressions.stringPath("likeCnt");

    List<OrderSpecifier> orders = new ArrayList<>(List.of(
            new OrderSpecifier<>(DESC, aliasCommentLikes),
            new OrderSpecifier<>(DESC, memberContentComment.createdAt)
    ));

    return orders;
}

List<OrderSpecifier> 를 이용해 원하는 오더 방식을 여기에 넣어주면 된다. 디폴트 값으로 좋아요 순으로 정렬되도록 넣어주었다.

추후 시간순의 정렬이 필요한 부분이면 if 분기 태워서 추가해주면 될꺼같다.

 

자 테스트 를 돌리러 가보자 

더보기
@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void sucess() throws Exception{
    //given
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    memberContentService.getContentCommentList(12L,pageRequest);
    //then
    verify(memberContentQueryRepository,times(1))
            .getCommentByContent(any(),any());
}

//service

@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);
    isContentDeleted(mc);

    return memberContentQueryRepository.getCommentByContent(memberContentId,pageable);
}

쿼리가 코드가 길어서 그렇지 조회 함수 자체는 엄청 심플하다.

 

테스트 는 모두 통과 한다. 역시 초록불 보면 기분이 매우 좋다.

 

실제 서버 띄워서 테스트 를 해보자.

 

select
    membercont0_.member_content_comment_id as col_0_0_,
    member1_.member_id as col_1_0_,
    member1_.username as col_2_0_,
    membercont0_.content as col_3_0_,
    member1_.nickname as col_4_0_,
    member1_.profile_img as col_5_0_,
    (select
        count(membercont3_.member_content_comment_like_id) 
    from
        member_content_comment_like membercont3_ 
    where
        membercont3_.member_content_comment_id=membercont0_.member_content_comment_id) as col_6_0_,
    membercont0_.created_at as col_7_0_ 
from
    member_content_comment membercont0_ 
inner join
    member member1_ 
        on membercont0_.member_id=member1_.member_id 
inner join
    member_content membercont2_ 
        on membercont0_.member_content_id=membercont2_.member_content_id 
where
    membercont0_.member_content_id=? 
    and membercont0_.deleted_yn=? 
group by
    membercont0_.member_content_comment_id 
order by
    col_6_0_ desc,
    membercont0_.created_at desc limit ?

크 왕쿼리 한방으로 댓글로 예쁘게 가져온다. 쿼리는 총 2방, 컨탠트 찾는 쿼리, 이렇게 조인으로 보내서 퍼올리는 쿼리

 

사실 이렇게 쿼리로 나오기 까지 mysql 에서 쿼리 를 엄청 셀렉트해서 보냈다. 일 대 다의 관계 에서는 데이터가 다에 맞춰서 펌핑 된다. 

즉 일 은 한개의 데이터 지만 다에 맞춰 그냥 전부 중복으로 매꿔 로우로 보내준다. 매우 좋지 않다.

그렇게 되면 페이징 이 매우어렵다 왜 ? 일 로 페이징 하고 싶지만  다 에 맞춰서 줘서 오기떄문이다.

 

결론 페이징,다이나믹 쿼리 ? 쿼리디에스엘 짱이다. 

 

긴글 읽어주셔서 감사합니다.

+ Recent posts