[2024.04.29] 댓글 조회 구현 2/2 (List를 Page로)

아스라이지새는달·2024년 9월 10일
1

화반 프로젝트

목록 보기
5/5
post-thumbnail

지난 시간에 이어 댓글 조회에 대한 포스트이다. 다만 지난 시간에는 원하는 데이터를 뽑아 정렬하는 방법에 대해 적어보았다면 이번에는 이 List로 되어있는 데이터들을 Page로 변환하는 방법에 관해 적어보려 한다. 지난 시간만큼 복잡하지는 않지만 이 과정에서도 고민이 있었기에 추후 비슷한 문제를 겪었을 때 수월하게 해결하고자 글을 남겨본다.

🏞️ 구현 배경

우선 프론트 측과의 협의 내용을 통해 수정한 API 명세서를 다시 한 번 보자.

{
    "code": "코드",
    "message": "메시지",
    "comments": [
        {
            "comment_no": 댓글 번호,
            "comment_writer": "댓글 작성자",
            "comment_date": "yyyy-MM-dd HH:mm:ss",
            "comment_content": "댓글 내용",
            "user_file_sname": "프로필 S3 URL",
            "parent_no": null
        },
        {
            "comment_no": 댓글 번호,
            "comment_writer": "댓글 작성자",
            "comment_date": "yyyy-MM-dd HH:mm:ss",
            "comment_content": "댓글 내용",
            "user_file_sname": "프로필 S3 URL",
            "parent_no": 부모 댓글 번호
        }
    ],
    "pageInfo": {
        "page": 페이지 넘버,
        "size": 페이지 당 데이터 수,
        "totalPage": 총 페이지 수,
        "totalElement": 총 데이터 수
    }
}

pageInfo 키가 보일 것이다. 이는 프론트 측과 사전에 협의된 내용으로 댓글 조회를 페이지네이션으로 구현해야 한다는 것을 의미한다. 다음으로 지난 포스트에서 작성한 Repository의 쿼리 부분을 보자.

// repository/CommentRepository.java

@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {

    @Query(value = "WITH RECURSIVE CTE AS (" +
            "   SELECT comment_no, comment_content, comment_date, parent_no, community_no, recipe_no, user_no, convert(comment_no, char) as path " +
            "   FROM comment " +
            "   WHERE parent_no IS NULL " +
            "   AND recipe_no = :recipeNo " +
            "   UNION ALL " +
            "   SELECT c.comment_no, c.comment_content, c.comment_date, c.parent_no, c.community_no, c.recipe_no, c.user_no, concat(CTE.comment_no, '-', c.comment_no) AS path " +
            "   FROM comment c " +
            "   INNER JOIN CTE ON c.parent_no = CTE.comment_no " +
            "   WHERE c.recipe_no = :recipeNo" +
            ")" +
            "SELECT comment_no, comment_content, comment_date, parent_no, community_no, recipe_no, user_no, path " +
            "FROM CTE " +
            "ORDER BY CONVERT(SUBSTRING_INDEX(path, '-', 1), UNSIGNED) DESC, parent_no ASC, CONVERT(SUBSTRING_INDEX(path, '-', -1), UNSIGNED) DESC;", nativeQuery = true)
    List<Comment> findCommentsByRecipeNo(@Param("recipeNo") Long recipeNo);

    @Query(value = "WITH RECURSIVE CTE AS (" +
            "   SELECT comment_no, comment_content, comment_date, parent_no, community_no, recipe_no, user_no, convert(comment_no, char) as path " +
            "   FROM comment " +
            "   WHERE parent_no IS NULL " +
            "   AND community_no = :communityNo " +
            "   UNION ALL " +
            "   SELECT c.comment_no, c.comment_content, c.comment_date, c.parent_no, c.community_no, c.recipe_no, c.user_no, concat(CTE.comment_no, '-', c.comment_no) AS path " +
            "   FROM comment c " +
            "   INNER JOIN CTE ON c.parent_no = CTE.comment_no " +
            "   WHERE c.community_no = :communityNo" +
            ")" +
            "SELECT comment_no, comment_content, comment_date, parent_no, community_no, community_no, user_no, path " +
            "FROM CTE " +
            "ORDER BY CONVERT(SUBSTRING_INDEX(path, '-', 1), UNSIGNED) DESC, parent_no ASC, CONVERT(SUBSTRING_INDEX(path, '-', 2), UNSIGNED) DESC;", nativeQuery = true)
    List<Comment> findCommentsByCommunityNo(@Param("communityNo") Long communityNo);

}

@Query 어노테이션을 가지고 작성한 findCommentsByRecipeNo() 함수와 findCommentsByCommunityNo() 함수의 return 타입을 보면 List<Comment>로 되어 있는 것을 볼 수 있다.

여기서 문제가 발생한다. request로 받은 page(몇 번째 페이지인지)와 size(한 페이지 당 데이터 개수)에 해당하는 데이터만을 추출해 response로 전달해야 하는데 위의 함수로는 page와 size에 해당하는 데이터만을 보낼 수 없기 때문이다. 가공이 절대적으로 필요하였고 다음과 같은 방안들을 생각해보았다.

  1. 쿼리문에서 LIMIT과 OFFSET을 사용하기
  2. 위의 함수로 반환된 리스트에서 원하는 데이터를 추출하는 로직을 구현하기

우선 1번의 경우 LIMIT에서 변수 사용이 불가하다고 알고 있어 해당 방법으로는 구현하지 못했다. 2번의 경우에는 효율적이지 않을 것 같아 시도를 하지 않았다.

글을 작성하는 현 시점에서 시도는 해보지 않았지만 SQL의 SET으로 변수를 설정하고 PREPARE문과 EXECUTE문으로 1번이 될 수도 있을 거 같다는 생각이 들었다.

좀 더 간단한 방법이 없을까 고민하던 중 List를 Page로 변환하는 방법이 있지 않을까? 라는 생각이 떠올라 검색을 해보았고 역시나 해당 방법이 있었다. 역시 내가 직면한 문제들은 다른 사람들이 이미 직면했던 문제들이고 해결책을 다 마련해놓았다.


🪄 구현 방법

구현 방법은 간단하다. Page 인터페이스 구현체 중 하나인 PageImpl 클래스를 통해 List를 가지고 Page 객체를 만들어주면 된다. 코드를 보며 차근차근 설명하도록 하겠다.

DTO

우선 API 명세서에 맞게 응답으로 건내 줄 DTO를 작성해준다.

// dto/response/comment/GetCommentsSuResDto.java

@Getter
@NoArgsConstructor
public class GetCommentsSuResDto extends CommentResponseDto {

    private List<GetCommentsDto> comments;

    private CommentPageInfo pageInfo;

    public GetCommentsSuResDto(String code, String message, List<GetCommentsDto> comments, CommentPageInfo pageInfo) {
        super(code, message);
        this.comments = comments;
        this.pageInfo = pageInfo;
    }

}

응답 DTO는 댓글 정보가 담긴 GetCommentsDto로 구성된 List와 페이지 정보가 담긴 CommentPageInfo로 이루어져 있다. GetCommentsDto와 CommentPageInfo는 다음과 같다.

// dto/object/comment/GetCommentsDto.java

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GetCommentsDto {

    private Long comment_no;

    private String comment_writer;

    private String comment_date;

    private String comment_content;

    private String user_file_sname;

    private Long parent_no;

}
// dto/object/comment/CommentPageInfo.java

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CommentPageInfo {

    private int page;

    private int size;

    private int totalPage;

    private long totalElement;

}

Service

핵심 부분인 서비스 단이다.

int page = request.getPage() - 1;
int size = reqeust.getSize();

PageRequest pageRequest = PageRequset.of(page, size);

우선 컨트롤러 단에서 전달받은 request에서 page와 size를 가지고 와 PageRequest 객체를 만들어준다.

List<Comment> commentList = commentRepository.findCommentsByRecipeNo(postNo);
또는
List<Comment> commentList = commentRepository.findCOmmentsByCommunityNo(postNo);

그 후 일전에 작성한 findCommentsByRecipeNo() 또는 findCommentsByCommunityNo() 함수로 List<Comment>를 가져온다.

int start = (int) pageRequest.getOffset();
int end = Math.min((start + pageRequest.getPagesize()), commentList.size());

Page<Comment> commentPage = new PageImpl<>(commentList.subList(start, end), pageRequest, commentList.size());

이 부분이 오늘 목표인 List를 Page로 변환하는 작업을 하는 부분이다.

PageRequest 객체의 getOffset() 메소드를 통해 offset을 가져온다. 이 offset이 원하는 데이터의 시작이 된다. offset은 상대적인 값을 의미하고 pageNumber와 pageSize를 곱한 값이다.
예를 들어 pageSize가 10인 페이징에서 첫 번째 페이지라면 pageNumber는 0이기 때문에 offset은 0이 될 것이고 두 번째 페이지라면 pageNumber 1이기 때문에 offset은 10이 될 것이다.

원하는 데이터의 끝은 시작 값과 pageSize를 더한 값과 리스트의 전체 크기 중 작은 것으로 결정된다.
예를 들어 pageSize를 10, 리스트의 전체 크기가 22라고 하자. 두 번째 페이지라면 시작과 pageSize를 더한 값은 20이 되므로 이 값과 리스트 전체 크기 중 작은 값은 20이므로 끝은 20이 된다.
반면 세 번째 페이지라면 시작과 pageSize를 더한 값은 30이 되므로 이 값과 리스트 전체 크기 중 작은 값은 22로 끝은 22가 된다.

위에서 정한 시작 값과 끝 값은 인덱스로 이 인덱스들을 가지고 List의 subList를 지정한다. 위에서 가정한 상황에서 두 번째 페이지는 10부터 20 전까지의 subList, 세 번째 페이지는 20부터 22전까지의 subList가 되는 것이다.

subList() 메소드는 시작 인덱스부터 끝 인덱스 전까지의 List를 반환한다.

List로부터 추출해낸 subList, PageRequest 객체, 리스트의 전체 크기를 가지고 Page 객체를 만들어내는 것이다.
이후 이 Page 객체를 가지고 응답 DTO를 구성하는 등 원하는 작업을 하면 된다.

아래는 댓글 조회 전체 코드이다.

// service/implement/CommentServiceImpl.java

@Service
@Slf4j
@RequiredArgsConstructor
public class CommentServiceImpl implements CommentService {

    private final CommentRepository commentRepository;
    private final UserRepository userRepository;
    private final RecipeRepository recipeRepository;
    private final CommunityRepository communityRepository;
    
    @Override
    public ResponseEntity<? extends CommentResponseDto> getComments(GetCommentReqParam request) throws Exception {
        try {
            // 날짜 포맷 변환
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

            int page = request.getPage() - 1;
            int size = request.getSize();

            // parameter로 받은 page(페이지 번호 - 1), size(페이지 당 데이터 개수)로 PageRequest 생성
            PageRequest pageRequest = PageRequest.of(page, size);

            Long postType = request.getType();
            Long postNo = request.getPost_no();

            Page<Comment> commentPage;

            if (Objects.equals(postType, 1L)) { // 게시판 종류가 레시피인 경우
                List<Comment> commentList = commentRepository.findCommentsByRecipeNo(postNo);

                int start = (int) pageRequest.getOffset();
                int end = Math.min((start + pageRequest.getPageSize()), commentList.size());

                commentPage = new PageImpl<>(commentList.subList(start, end), pageRequest, commentList.size());
            } else if (Objects.equals(postType, 2L)) { // 게시판 종류가 커뮤니티인 경우
                List<Comment> commentList = commentRepository.findCommentsByCommunityNo(postNo);

                int start = (int) pageRequest.getOffset();
                int end = Math.min((start + pageRequest.getPageSize()), commentList.size());

                commentPage = new PageImpl<>(commentList.subList(start, end), pageRequest, commentList.size());
            } else {
                throw new WrongBoardTypeException();
            }

            // 요청한 페이지 번호가 존재하는 페이지 개수를 넘을 때 Exception throw
            if (commentPage.getTotalPages() != 0 && page >= commentPage.getTotalPages()) {
                throw new PageNotFoundException();
            }

            // 조회한 댓글 목록을 순회하며 각각의 댓글 정보로 comments를 빌드
            List<GetCommentsDto> comments = commentPage.stream().map((comment -> {
                return GetCommentsDto.builder()
                        .comment_no(comment.getCommentNo())
                        .comment_writer(comment.getUser().getUserNickname())
                        .comment_date(comment.getCommentDate().format(formatter))
                        .comment_content(comment.getCommentContent())
                        .user_file_sname(comment.getUser().getUserFileSname())
                        .parent_no(comment.getParentNo())
                        .build();
            })).toList();

            // 조회한 댓글 목록에 대한 정보로 페이지 정보 빌드
            CommentPageInfo commentPageInfo = CommentPageInfo.builder()
                    .page(page + 1)
                    .size(size)
                    .totalPage(commentPage.getTotalPages())
                    .totalElement(commentPage.getTotalElements())
                    .build();

            GetCommentsSuResDto responseBody = new GetCommentsSuResDto(ResponseCode.SUCCESS, ResponseMessage.SUCCESS, comments, commentPageInfo);
            return ResponseEntity.status(HttpStatus.OK).body(responseBody);
        } catch (WrongBoardTypeException e) {
            logPrint(e);

            GetCommentsFaResDto responseBody = new GetCommentsFaResDto(ResponseCode.WRONG_BOARD_TYPE, ResponseMessage.WRONG_BOARD_TYPE);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(responseBody);
        } catch (PageNotFoundException e) {
            logPrint(e);

            GetCommentsFaResDto responseBody = new GetCommentsFaResDto(ResponseCode.NOT_EXIST_PAGE, ResponseMessage.NOT_EXIST_PAGE);
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(responseBody);
        } catch (Exception e) {
            logPrint(e);

            GetCommentsFaResDto responseBody = new GetCommentsFaResDto(ResponseCode.INTERNAL_SERVER_ERROR, ResponseMessage.INTERNAL_SERVER_ERROR);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(responseBody);
        }
    }

}

레시피 게시글 댓글 테이블과 커뮤니티 게시글 댓글 테이블을 분리시키지 않고 하나의 댓글 테이블로 기획을 했기 때문에 해당 댓글이 어떤 종류의 게시글의 댓글인지 구분할 필요가 있어 request로 Type을 받았다. Type이 1이면 레시피 게시글임을 2이면 커뮤니티 게시글임을 나타낸다.


🧪 테스트

지난 포스트에서는 정렬에 초점을 맞추어 테스트를 진행했다면 이번 포스트에서는 페이징에 초점을 맞추어 테스트를 진행한다.
데이터는 지난 포스트와 동일하다.

레시피 게시글 중 2번 레시피 게시글의 댓글 조회를 해볼 예정이므로 request의 parameter로 type은 1, post_no는 2로 설정한다. 그 후 한 페이지 당 3개씩 가져온다고 가정하고 조회를 한다. 우선은 첫 번째 페이지이다.

다음은 두 번째 페이지이다.

마지막으로 세 번째 페이지다.

세 개의 페이지 모두 올바른 댓글 데이터와 페이지 정보가 넘어오는 것을 확인할 수 있다.


🔍 Reference

https://yjkim-dev.tistory.com/48

profile
웹 백엔드 개발자가 되는 그날까지

0개의 댓글