Spring 무한스크롤 페이징 기능 구현 + no offset(Querydsl)

서규범·2023년 1월 28일
2

팀 프로젝트를 하던 중 프론트 단에서 무한 스크롤을 적용한 조회 api를 구현해야 했다. 스프링 데이터 jpa의 Page 기능을 통해 구현할 수도 있지만, no offset 기능을 적용하기 위해 Querydsl을 사용하였다.

no offset을 적용하지 않을 경우, 데이터 값이 늘어나면 늘어날수록, 전체 데이터를 조회하는 과정에서 성능 저하가 늘어나기 때문이다.

추가적으로 연관된 데이터를 함께 조회하기 위해서, 성능을 고려해 패치 조인을 사용하려 했지만, OneToMany 형식의 연관관계를 가지고 있어 패치 조인이 불가능했다.(~ToMany의 경우, jpa에서 중복을 고려하여 limit, offset을 걸지 않고 일단 인메모리에 다 가져온 후, 어플리케이션 단에서 처리하므로 성능 저하는 그대로이다, ~ToOne은 중복이 발생하지 않으므로 페이징 처리를 해도 괜찮다.)

따라서 일단 페이징을 통해 데이터 리스트를 가져온 후, 연관관계를 가진 데이터에 대해 BatchSize 설정을 통해 n+1문제를 해결하기로 했다.

또한 조회한 데이터를 바로 Page 형식으로 반환하는게 아닌, 연관된 테이블의 데이터를 추가해서 반환해야 했기 때문에, 직접 생성한 DTO 형태로 변환하여 프론트단에 반환하였다.

Controller단 코드

AuctionController.java

    // 지난 경매 작품 조회
    @GetMapping("/auction/period-over/{auctionId}")
    public ResponseEntity<ArtWorkTerminatedListResponseDto> getTerminatedAuctionArtWorkList(Pageable pageable,
                                                                                            @PathVariable("auctionId") Long auctionId,
                                                                                            @RequestParam(value = "artWorkId", required = false) Long artWorkId) {

        ArtWorkTerminatedListResponseDto terminatedAuctionArtWorkList = artWorkService.getTerminatedAuctionArtWorkList(auctionId, artWorkId, pageable);

        return ResponseEntity.status(HttpStatus.OK).body(terminatedAuctionArtWorkList);
    }
  • Controller의 매개변수는 Pageable객체, 쿼리문의 where절을 위한 auctionId, no offset을 위해 필요한 마지막으로 조회한 artWorkId가 필요하다.

ArtWorkTerminatedListResponseDto.java

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ArtWorkTerminatedListResponseDto {

    private boolean nextPage;
    private List<ArtWorkDto> artWorkList;

    @Data
    @Builder
    public static class ArtWorkDto {
        private Long id;
        private String mainImage;
        private String title;
        private String material;
        private Long topPrice;
        private Integer biddingCount;
        private String status;

        public static ArtWorkDto from(ArtWork artWork, Long topPrice, Integer biddingCount) {
            return ArtWorkDto.builder()
                    .id(artWork.getId())
                    .mainImage(artWork.getMainImage())
                    .title(artWork.getTitle())
                    .material(artWork.getMaterial())
                    .topPrice(topPrice)
                    .biddingCount(biddingCount)
                    .status(artWork.getSaleStatus())
                    .build();
        }
    }
}
  • 프론트 단에 반환하기 위한 DTO이다.

Repository 단 코드

ArtWorkCustomRepositoryImpl.java

    @Override
    public List<ArtWork> findTerminatedAuctionArtWorkList(Long auctionId, Long artWorkId, Pageable pageable) {
        List<ArtWork> results =  queryFactory
                .select(artWork)
                .from(artWork)
                .where(
                        ltArtWorkId(artWorkId),
                        artWork.auction.id.eq(auctionId),
                        artWork.saleStatus.ne(ArtWorkStatus.REGISTERED.getType()),
                        artWork.saleStatus.ne(ArtWorkStatus.PROCESSING.getType())
                )
                .orderBy(artWork.id.desc())
                .limit(pageable.getPageSize()+1)
                .fetch();

        return results;
    }

    private BooleanExpression ltArtWorkId(Long artWorkId) {

        if (artWorkId == null) {
            return null;
        }

        return artWork.id.lt(artWorkId);
    }
  • Querydsl을 통해, no offset을 적용하여 작성한 쿼리문이다.
  • where 절에서, ltArtWorkId(artWorkId) <- 해당 부분이 핵심이다.
  • 프론트 측에서 넘어온 artWorkId를 통해 그 이후의 데이터만 검색하도록 설정해, 전체 row를 검색함으로써 나타나는 성능 저하를 방지하였다.
  • 초기 요청시에는 artWorkId가 없으므로 null을 반환하여, orderBy를 통해 정렬된 데이터 중, 가장 최근의 데이터를 반환하도록 구현하였다.
  • limit는 프론트가 요청한 size보다 1크게 설정함으로써, 이후에 다음 페이지 존재 여부에 활용하였다.

Service 단 코드

ArtWorkService.java

    public ArtWorkTerminatedListResponseDto getTerminatedAuctionArtWorkList(Long auctionId, Long artWorkId, Pageable pageable) {

        Auction auction = auctionRepository.findById(auctionId)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_AUCTION_TURN));

        if (!auction.getStatus().equals(AuctionStatus.TERMINATED.getType())) {
            throw new CustomException(ErrorCode.IS_NOT_TERMINATED_AUCTION);
        }

        List<ArtWork> results = artWorkRepository.findTerminatedAuctionArtWorkList(auctionId, artWorkId, pageable);
        List<ArtWorkTerminatedListResponseDto.ArtWorkDto> artWorkDtoList = transferToTerminatedArtWorkDto(results);

        boolean hasNext = false;

        // 조회한 결과 개수가 요청한 페이지 사이즈보다 클 경우, next = true
        if (artWorkDtoList.size() > pageable.getPageSize()) {
            hasNext = true;
            artWorkDtoList.remove(pageable.getPageSize());
        }

        ArtWorkTerminatedListResponseDto artWorkTerminatedListResponseDto = ArtWorkTerminatedListResponseDto.builder()
                .nextPage(hasNext)
                .artWorkList(artWorkDtoList)
                .build();

        return artWorkTerminatedListResponseDto;
    }

    private List<ArtWorkTerminatedListResponseDto.ArtWorkDto> transferToTerminatedArtWorkDto(List<ArtWork> artWorkList) {

        List<ArtWorkTerminatedListResponseDto.ArtWorkDto> artWorkDtoList = new ArrayList<>();

        for (ArtWork artWork : artWorkList) {

            List<Long> priceList = artWork.getBiddingList().stream().map(m -> m.getPrice()).collect(Collectors.toList());
            Long topPrice = artWork.getPrice();
            if (!priceList.isEmpty()) {
                topPrice = Collections.max(priceList);
            }

            ArtWorkTerminatedListResponseDto.ArtWorkDto artWorkDto = ArtWorkTerminatedListResponseDto.ArtWorkDto.from(artWork, topPrice, artWork.getBiddingList().size());
            artWorkDtoList.add(artWorkDto);
        }

        return artWorkDtoList;
    }
  • 프론트 측에서 넘어온 데이터들의 유효성을 검증한 후에, 위에서 작성한 쿼리문을 통해 검색된 데이터들을 가져온다.
  • 이 때, 다음 페이지 여부를 프론트 측에 알려줘야 하는데, 기존 쿼리에서 프론트가 요청한 size보다 1크게 조회 하였음으로, 검색된 size가 요청한 size보다 클 경우는 true, 그렇지 않을 경우는 false로 클라이언트 측에 전달한다.
  • transferToTerminatedArtWorkDto 메서드를 통해 DTO로 변환하는 과정에서, ArtWork 엔티티의 연관관계를 가진 Bidding 테이블의 정보도 가져와야 했다. 해당 부분에서는 지연로딩을 통한 n+1문제를 방지하기 위해 BatchSize를 설정해서 해결하였다.

구현 결과

  • artWorkId에 마지막으로 조회된 데이터의 id 값을 넣어주고 요청할 경우, 위와 같이 해당 id 값 이후(정렬을 통해 정확히는 이전, 최근 값 부터)로 조회할 수 있다.

결론

  • 조회 기능을 구현할 때, 연관관계가 OneToMany임에 따라, Pagination + Fetch Join을 동시에 적용할 수 없었다. 따라서 이런 경우에는 위와 같은 방식으로, Paging과 BatchSize 설정을 통해 해당 기능을 구현할 수도 있음을 확인할 수 있었다.
profile
하려 하자

1개의 댓글

comment-user-thumbnail
2024년 1월 1일

많은 도움 받았습니다 감사합니다

답글 달기