👀  개요

  • 게시글 전체조회 시 무한 스크롤을 적용할 경우 스크롤을 내리는 동안 필요한 콘텐츠를 동적으로 로드하여 빠른 콘텐츠 로딩 속도를 제공하기 때문에 사용자 경험 측면에서 좋을것으로 판단되어 무한 스크롤을 적용 후 페이징 처리 과정에서 맞이한 이슈


🚨  문제점

Offset 기반 페이징

  • JPA에서 제공하는 페이지네이션 방법은 페이지 번호 (offset) 와 페이지 사이즈 (limit) 를 기반으로 조회하는 offset 기반 페이징 방식으로 10건 조회기준 427ms 소요됨
  • 위 방식은 offset 10000, limit 20 이라 하면 최종적으로 10,020개의 행을 읽고 실제로 필요한 건 마지막 20개 이기 때문에 이 중 앞의 10,000 개 행을 버리게 되어 뒤로 갈수록 읽어야 할 행의 개수가 많아 느려짐


🛠️  해결과정

offset 란?

  • 페이지 번호를 이용해 가져올 데이터의 위치를 파악하는 방식으로 아래 코드는 수정 전 사용했던 코드로, Pageable 인터페이스를 활용해 조회하는 방식

    // Controller
    @GetMapping("/users")
    public ResponseEntity<UsersResponse> findAll(Pageable pageable) {
      UsersResponse usersResponse = userService.findAllUsers(pageable);
      return ResponseEntity.ok(usersResponse);
      }
    
    // Service  
    public UsersResponse findAllUsers(Pageable pageable) {
      Page<Task> findUsers = userRepository.findAllUsersWithPaging(pageable);
      return UsersResponse.from(findUsers);
      }
    
    // Repository
    Page<Task> findAllUsersWithPaging(Pageable pageable);
  • Pageable 인터페이스를 활용하면 쉽게 페이지네이션을 구현할 수 있지만, 데이터의 양이 많아지면 쿼리 속도가 확연히 느려짐

  • 이유는 오프셋의 동작방식과 실제 발생하는 쿼리를 보면 클라이언트에서 넘겨받는 페이지 번호와 페이지 사이즈를 가지고 서버에서 오프셋 값을 구함

  • 오프셋 = (페이지 번호 - 1) * 페이지 사이즈 이기 때문에 페이지 사이즈가 20인 경우 아래와 같이 오프셋 값이 늘어남

    // Query
    SELECT * FROM user LIMIT 페이지사이즈 OFFSET 오프셋;
    SELECT * FROM user LIMIT 20 OFFSET 0;       # 1~20 출력
    SELECT * FROM user LIMIT 20 OFFSET 20;      # 21~40 출력
    SELECT * FROM user LIMIT 20 OFFSET 40;      # 41~60 출력
    ...
    SELECT * FROM user LIMIT 20 OFFSET 999980;  # 999981~100000 출력

No offset 란?

  • offset을 사용하지 않고 데이터를 가져오는 방법으로 일반적으로 데이터베이스에서 이전 페이지의 마지막 아이템을 추적하고, 해당 아이템보다 큰 값을 기준으로 데이터를 가져오는 방식

  • No offset 페이징을 API로 구현된다면 다음과 같은 형태가 됨

  • Pageable 인터페이스를 활용한 위 코드를 QueryDSL을 통한 No Offset으로 변환

    public List<MapResponseDto> findAllByMap(Long mapId, String sort, int pagesize) {
        // 1. id < 파라미터를 첫 페이지에선 사용하지 않기 위한 동적 쿼리
        BooleanBuilder dynamicLtId = new BooleanBuilder();
        if (mapId != null) {
            dynamicLtId.and(map.id.lt(mapId));
        }
    
        return queryFactory
                    .select(Projections.fields(
                            MapResponseDto.class,
                            map.mapNo,
                            map.category,
                            map.title,
                            map.address,
                            map.view,
                            map.content,
                            mapReview.countDistinct().as("reviewCount"),
                            wish.countDistinct().as("wishCount"),
                            mapImg.mapImgUrl,
                            map.createdAt,
                            map.modifiedAt
                    ))
                    .from(map)
                    .leftJoin(mapImg)
                    .on(map.mapNo.eq(mapImg.map.mapNo))
                    .leftJoin(mapReview)
                    .on(map.mapNo.eq(mapReview.map.mapNo))
                    .leftJoin(wish)
                    .on(map.mapNo.eq(wish.map.mapNo))
                    .where(dynamicLtId)
                    .groupBy(map.mapNo)
                    .orderBy(boardSort(sort, "map"), map.mapNo.desc())
                    .limit(pagesize)
                    .fetch();
        }

비교

  • 동일조건에서 offset 에서 no offset 으로 변경 후 기존 427ms에서 82ms로, 약 80% 개선



🏁  마치며

  • Pageable 인터페이스를 활용하면 쉽게 offset 기반의 페이지네이션을 구현할 수 있지만, 데이터의 양이 많아질 경우 로딩 속도 저하, 데이터의 중복/누락이 발생할 수 있기 때문에 no offset 기반의 방식도 고려할 필요가 있음
  • no offset 기반 방식의 경우 offset 기반 방식보다 조회 시 뛰어난 성능을 보여주긴 했지만 구현이 조금 더 복잡하다는 단점이 있고 특히 정렬을 사용하는 경우 구현 난이도 향상
  • 두 방식 모두 각각의 장단점이 있기 때문에 서비스의 특징에 맞는 방법을 적절하게 선택해야함


📑  참고

https://jojoldu.tistory.com/528
https://wonit.tistory.com/483?category=853673

profile
응애 나 아기 개발자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN