[2024.04.15] Pagination을 이용한 커뮤니티 게시판 전체 조회 구현

아스라이지새는달·2024년 7월 17일
1

화반 프로젝트

목록 보기
3/5
post-thumbnail

어떤 게시판을 보면 일정 개수의 게시글들이 페이지 단위로 보여지는 게시판이 있다. 오늘은 이와 같은 게시판에 사용되는 페이지네이션에 대해 포스트 하려고 한다.

🤷 Pagination?

프로젝트를 진행하던 중 프론트엔드 측에서 API 명세서를 보고 다음과 같이 요청이 들어왔다. 커뮤니티 게시판 전체 조회의 API의 응답을 게시글에 대한 정보를 담은 List와 페이지에 대한 정보를 담은 pageInfo로 줄 수 있냐는 요청이었다.

와이어프레임을 확인해보니 레시피 전체 조회와 커뮤니티 전체 조회의 방식이 달랐다.

레시피 전체 조회 페이지커뮤니티 전체 조회 페이지

필자는 페이지네이션을 처음 들어봤기에 어떤 것을 말하는지 찾아보았다.

페이지네이션은 사용자가 데이터를 요청했을 때 모든 데이터를 조회하여 제공하는 것이 아니라 특정 구간에 대한 데이터를 요청하고 요청에 해당하는 데이터만을 추출하여 제공하는 기술을 말한다. 모든 데이터를 조회하여 제공하게 되면 데이터의 개수가 적을 때는 괜찮지만 데이터의 양이 방대할 때는 서버의 부하가 굉장히 크다. 페이지네이션은 필요한 구간의 데이터만을 제공하기에 이를 방지하여 서버의 부하를 줄일 수 있다.

처음 진행해보는 기술이었기에 사용하는 방법을 알아보는 과정 중 궁금한 것이 생겨 프론트 측에 다음과 같이 질문을 하였고 답변을 받았다.

  1. 프론트 측에서 넘겨주는 API의 request에 page(몇 번째 페이지인지), size(한 페이지 당 데이터 개수), sort(정렬 방식)를 넘겨주는게 맞는 것인지?
    -> 그렇다.
  1. 1번 사항이 맞다면 사용자가 페이지를 넘길 때마다 프론트 측에서 1번에 해당하는 정보가 담긴 request를 넘겨주는 것이 맞는 것인지?
    -> 그렇다.

해당 사항들과 찾아본 내용을 바탕으로 구현을 해보기로 했다.

여담으로 한 가지 아쉬운 점은 레시피 전체 조회를 페이지네이션으로 구현하지 않고 모든 데이터를 넘겨주는 방식으로 구현했다는 것이다. 당시에는 레시피 전체 조회가 페이지네이션 방식이 아니라고 생각했지만 더보기 버튼을 눌러 더 많은 데이터를 보여주는 것 또한 페이지네이션으로 구현할 수 있다.


🪄 사용 방법

사실 Spring Data JPA에서 페이지네이션에 대한 기능을 이미 제공하기에 방법에 따라 사용하기만 하면 된다.

Controller

우선 컨트롤러를 통해 페이지네이션에 필요한 정보들을 요청 받는다.

// controller/CommunityController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/communities")
public class CommunityController {

    private final CommunityService communityService;

    @GetMapping("/list")
    private ResponseEntity<? extends CommunityResponseDto> getAllCommunities(@RequestParam int page, @RequestParam int size) throws Exception {
        return communityService.getAllCommunities(page - 1, size);
    }

}

request로 받을 항목들은 세 가지로 다음과 같다.

page : 몇 번째 페이지인지
size : 한 페이지 당 데이터 개수
sort : 정렬 방식

필자는 @RequestParam 어노테이션을 통해 pagesize를 받았고 sort는 Repository단에서 쿼리 메소드로 설정할 예정이라 따로 받지 않았다.

또한 사용자가 생각하기엔 첫 페이지는 1페이지이지만 실제로는 인덱스처럼 0이 첫 페이지이기 때문에 프론트 측으로부터 받은 값에서 -1을 해주었다.

프론트 측과의 협의로 정렬 방식은 최신순으로 결정하여 따로 받지 않았지만 지금 생각해보면 코드의 확장성을 위해 정렬 방식을 받는 것이 낫다고 생각한다.

Repository

레포지토리에서는 서비스 단에서 사용할 메소드를 정의해주면 된다.

// repository/CommunityRepository.java

@Repository
public interface CommunityRepository extends JpaRepository<Community, Long> {

    Optional<Community> findByCommunityNo(Long communityNo);

    Page<Community> findAllByOrderByCommunityNoDesc(Pageable pageable);

}

정상적인 방법으로 커뮤니티 게시글을 작성하였다면 최신 게시글일수록 DB에서 자동적으로 증가하는 community_no가 크기 때문에 community_no를 기준으로 내림차순 정렬이 되도록 쿼리 메소드에 OrderByCommunityNoDesc를 붙여주었다.

특이한 점은 Pageable 인터페이스 타입으로 인자를 받는데 Pageable 인터페이스의 구현체 중 하나는 PageRequest 클래스이다. 우리는 컨트롤러 단에서 받은 pagesize를 가지고 서비스 단에서 이 PageRequest 객체를 만들 예정이다.

DTO

프론트 측과 협의한 내용을 바탕으로 서비스 단에서 사용할 DTO를 작성해준다.

// dto/response/community/GetAllCommunitiesSuResDto.java

@Getter
@NoArgsConstructor
public class GetAllCommunitiesSuResDto extends CommunityResponseDto {

    private List<GetAllCommunitiesDto> posts;

    private CommunityPageInfo pageInfo;

    public GetAllCommunitiesSuResDto(String code, String message, List<GetAllCommunitiesDto> posts, CommunityPageInfo pageInfo) {
        super(code, message);
        this.posts = posts;
        this.pageInfo = pageInfo;
    }

}
// dto/object/community/GetAllCommunitiesDto.java

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

    private Long community_no;

    private String community_title;

    private String community_writer;

    private LocalDate community_date;

    private Long community_views;

}
// dto/object/community/CommunityPageInfo.java

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

    private int page;

    private int size;

    private int totalPage;

    private long totalElement;

}

이렇게 응답 DTO를 작성해주면 다음과 같이 원하는 형식의 응답을 받을 수 있다.

{
    "code": "SU",
    "message": "success",
    "posts": [
        {
            "community_no": 5,
            "community_title": "테스트 커뮤니티 5",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        },
        {
            "community_no": 4,
            "community_title": "테스트 커뮤니티 4",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        },
        {
            "community_no": 3,
            "community_title": "테스트 커뮤니티 3",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        }
    ],
    "pageInfo": {
        "page": 1,
        "size": 3,
        "totalPage": 2,
        "totalElement": 5
    }
}

참고로 서비스 단의 메소드에서 반환 타입을 커스텀한 DTO로 설정하지 않고 Page<T>형식으로 설정할 경우 응답 형식은 기본 응답 형식으로 다음과 같다.

{
    "content": [
        {
            "community_no": 5,
            "community_title": "테스트 커뮤니티 5",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        },
        {
            "community_no": 4,
            "community_title": "테스트 커뮤니티 4",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        },
        {
            "community_no": 3,
            "community_title": "테스트 커뮤니티 3",
            "community_writer": "방문자",
            "community_date": "2024-07-16",
            "community_views": 0
        }
    ],
    "pageable": {
        "pageNumber": 0,
        "pageSize": 3,
        "sort": {
            "empty": true,
            "sorted": false,
            "unsorted": true
        },
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "last": false,
    "totalPages": 2,
    "totalElements": 5,
    "first": true,
    "size": 3,
    "number": 0,
    "sort": {
        "empty": true,
        "sorted": false,
        "unsorted": true
    },
    "numberOfElements": 3,
    "empty": false
}

Service

서비스 단에서는 컨트롤러 단에서 전달받은 pagesize를 가지고 PageRequest 객체를 만들어 위에서 정의한 Repository의 쿼리 메소드에 argument로 전달한다. 쿼리 메소드의 반환값으로 Page<T> 객체를 받을 수 있다. DTO를 빌드하는데 필요한 정보들을 이 Page<T> 클래스의 메소드들로 채워넣으면 된다.

// service/implement/CommunityServiceImpl.java

@Service
@Slf4j
@RequiredArgsConstructor
public class CommunityServiceImpl implements CommunityService {

    private final CommunityRepository communityRepository;
    private final CommunityFileRepository communityFileRepository;
    private final UserRepository userRepository;
    
    @Override
    public ResponseEntity<? extends CommunityResponseDto> getAllCommunities(int page, int size) throws Exception {
        try {
            // parameter로 받은 page(페이지 번호 - 1), size(페이지 당 데이터 개수)로 PageRequest 생성
            PageRequest pageRequest = PageRequest.of(page, size);

            // page, size 정보를 가지고 해당하는 커뮤니티 게시글 조회
            Page<Community> communities = communityRepository.findAllByOrderByCommunityNoDesc(pageRequest);

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

            // 조회한 커뮤니티 게시글 목록을 순회하며 각각의 게시글의 정보로 posts를 빌드
            List<GetAllCommunitiesDto> posts = communities.stream().map((community -> {
                return GetAllCommunitiesDto.builder()
                        .community_no(community.getCommunityNo())
                        .community_title(community.getCommunityTitle())
                        .community_writer(community.getCommunityWriter())
                        .community_date(community.getCommunityDate())
                        .community_views(community.getCommunityViews())
                        .build();
            })).toList();

            // 조회한 커뮤니티 게시글 목록에 대한 정보로 페이지 정보 빌드
            CommunityPageInfo communityPageInfo = CommunityPageInfo.builder()
                    .page(page + 1)
                    .size(size)
                    .totalPage(communities.getTotalPages())
                    .totalElement(communities.getTotalElements())
                    .build();

            GetAllCommunitiesSuResDto responseBody = new GetAllCommunitiesSuResDto(ResponseCode.SUCCESS, ResponseMessage.SUCCESS, posts, communityPageInfo);
            return ResponseEntity.status(HttpStatus.OK).body(responseBody);
        } catch (PageNotFoundException e) {
            logPrint(e);

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

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

CommunityPageInfo를 빌드할 때 page값을 page + 1이 아닌 communities.getNumber() + 1로, size값을 size가 아닌 communities.getSize()로 작성해도 된다. 추가적인 메소드 호출을 하지 않기 위해 기존 값들로 빌드하였다.


🧪 테스트

테스트를 위해 DB에 다음과 같이 5개의 데이터를 넣어주었다.

올바른 요청

그 후 page는 첫 번째 페이지인 1로, size는 3으로 설정하여 요청을 보내본다. 최신순이므로 5번 게시글, 4번 게시글, 3번 게시글 순서로 응답되어야 할 것이다.

정상적으로 5번 게시글, 4번 게시글, 3번 게시글 순서로 응답이 넘어왔고 pageInfo 또한 올바르게 넘어온 것을 확인할 수 있다.

다음은 page는 마지막 페이지인 2으로, size는 그대로 3으로 설정하여 요청을 보낸다. 5개 중 남은 2개의 게시글이 응답으로 넘어와야 하고 2번 게시글, 1번 게시글 순서로 넘어와야 할 것이다.

이 또한 올바르게 넘어온 것을 확인할 수 있다.

잘못된 요청

코드를 작성할 때 현재 존재하는 페이지 외의 페이지를 요청했을 때 예외처리를 한 부분이 있다. 이 부분이 의도한대로 작동하는지 테스트 해본다.

if (communities.getTotalPages() != 0 && page >= communities.getTotalPages()) {
    throw new PageNotFoundException();
}

size를 3으로 했을 때 3페이지는 존재할 수 없는 페이지이므로 page를 3으로 설정해서 요청을 보내본다.

의도한 에러 메시지가 넘어오는 것을 확인할 수 있다.


🔍 Reference

https://velog.io/@stay136/pagination-%EA%B0%9C%EB%85%90

https://velog.io/@sangjin0308/SpringBoot-Pagination-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0Rest-Api%ED%98%95%EC%8B%9D#%EC%8A%AC%EB%9D%BC%EC%9D%B4%EC%8B%B1slicing

https://devlog-wjdrbs96.tistory.com/414

https://velog.io/@dani0817/Spring-Boot-%ED%8E%98%EC%9D%B4%EC%A7%95Paging-%EC%A0%81%EC%9A%A9#-pageable%EA%B3%BC-pagerequest

https://velog.io/@bagt/0704-Spring-Pagination-API-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98

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

0개의 댓글