Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

사부작사부작

페이지네이션 구현과 성능 개선 본문

프로젝트

페이지네이션 구현과 성능 개선

민철킴 2023. 5. 6. 02:26

페이지네이션 구현

페이지네이션은 데이터를 페이지 단위로 나누어 화면에 보여주는 기능이다. 전체 데이터가 아니라 필요한 페이지의 데이터만 불러오기 때문에 성능을 향상시킬 수 있다. 물론 사용자 경험도 훨씬 좋다.

밑의 화면처럼 페이지네이션을 화면에 만들기 위해선 토탈 페이지 카운트를 알아야한다. 그렇기에 페이지네이션이 이루어지려면 페이지당 몇 개의 데이터를 보여줄건지와 토탈 페이지 카운트를 알려줘야 한다.

<페이지네이션>

내 프로젝트의 프론트엔드는 <v-pagination> 컴포넌트를 이용해서 페이지네이션을 구현했다. 여기서 보다시피 length 속성으로 토탈 페이지 카운트를 입력받고 있다.

<v-pagination
      :length="getPageCount"
      ...
    />

프론트엔드에서 전체 데이터 개수로 토탈 카운트를 구할 수 있지만, 백엔드에서 토탈 카운트를 보내주는게 일반적이다.


백엔드에서는 Pageable 인터페이스를 구현한 PageRequest 클래스를 사용해서 요청을 입력받고 Page 인터페이스를 구현한 PageImpl 클래스로 응답을 해줬다.

Pageable는 페이지 번호, 페이지 크기, 정렬 기준 등의 요청 정보를 캡슐화한 인터페이스다. 반면 Page 인터페이스는 페이지 번호, 토탈 페이지 카운트, 페이지 내의 데이터 등의 응답 정보를 캡슐화한 인터페이스다.

public Page<?> getAllBook(final AllBookFilterDto condition, Pageable pageRequest) {
        ...
        List<AllBookResponseDto> allBooks = bookRepository.getAllBooks(condition, pageRequest);
        Long totalCount = fetchTotalCount(condition);

        return new PageImpl<> (allBooks, pageRequest, totalCount);
    }

요청에 맞는 페이지 당 데이터, 전체 데이터 수, 페이지 요청 객체를 사용하여 PageImpl 객체를 생성하고 이를 반환해주고 있다.

Limit과 Offset을 이용해서 쿼리를 보내고, 페이지 당 데이터를 가져온다. Limit은 한 페이지에 표시할 데이터의 수를 지정하는 것이며, Offset은 검색을 시작할 데이터의 위치를 지정하는 것이다. 그렇기에 Offset은 (현재 페이지 - 1) x (Limit)으로 계산됩니다.

예를 들어 10개씩 보여주는 화면에서 4페이지를 눌렀다면, Offset은 (4-1) x 10 으로 30이 되며, 30번째 데이터부터 39번째 데이터까지 가져온다.

개선점

1. Offset → No-Offset

Offset 기반 페이지네이션은 뒤 페이지로 갈수록 처리 시간이 증가하게 된다. 예를 들어, n번째 페이지를 눌렀다고 하자. 그러면 1페이지부터 n페이지까지의 전체 데이터를 스캔하고 n페이지에 해당하는 데이터만 읽어온다. 즉, Full Scan 방식으로 offset + limit 에 해당하는 레코드를 모두 읽은 뒤, 그 중 마지막 limit 수의 레코드만 가져오는 것이다.

그렇다면 이 단점을 보완한 No-Offset 방식도 있다. 가져올 데이터의 시작 부분을 인덱스로 찾아서 매번 Limit 수의 레코드만 읽는 방식이다.

SELECT *
FROM book
WHERE 조건문
AND id < 마지막 조회 id
LIMIT 페이지 사이즈

이처럼 특정 위치를 지정해서 매번 같은 사이즈의 데이터를 읽어온다.

처음에 페이지네이션 구현할 때, No-Offset 방식인 무한 스크롤을 가장 먼저 시도했었다. 하지만 내 경우엔 책 마다 차트를 포함하고 있다. 차트는 <canvas> 태그를 이용해서 그려준다. 무한 스크롤로 새로운 데이터를 렌더링 해줄 때, 이미 차트를 그릴 Canvas 태그가 사용 중이어서 렌더링이 안되는 에러가 발생했다. 백엔드에서 보내준 데이터는 잘 전달이 됬지만, 차트가 그려지지 않았다. 아래 사진처럼 <이순신>까지가 첫 페이지 데이터고 엑박 뜬 부분이 무한 스크롤로 불러온, 쉽게 말하면 2페이지 데이터다. 차트가 안 뜨면서 다른 데이터들도 화면에 제대로 나타나지 않았다. 여러 블로그를 참고해가며 수정하려 했지만 결국 차트를 그리지 못 했다.

그렇기에 No-Offset 방식의 페이지네이션은 구현하지 못 했고, Offset 방식의 페이지네이션을 사용하게 됐다.

2. Count 쿼리 최적화

Offset 방식은 데이터를 조회하는 쿼리와 count 쿼리가 동시에 발생한다. count 쿼리는 전체 데이터를 확인하기 때문에 데이터가 많아질수록 응답 시간은 늘어난다.

그렇다면 매번 count 쿼리가 필요할까?

첫 페이지 요청때 백엔드에서 넘겨준 토탈 카운트를 프론트에서 캐싱하고 있다가 2번째 페이지 요청부터는 토탈 카운트를 같이 백엔드에 보내게 변경을 했다.

프론트엔드 코드

백엔드 응답값에서 토탈 카운트를 저장한다.

그리고 페이징 요청시 카운트도 함께 보내게 변경했다.

백엔드 코드(condition안에 토탈 카운트 포함)

public Page<?> getAllBook(final AllBookFilterDto condition, Pageable pageRequest) {
        ...

        List<AllBookResponseDto> allBooks = bookRepository.getAllBooks(condition, pageRequest);
        Long totalCount = fetchTotalCount(condition);

        return new PageImpl<> (allBooks, pageRequest, totalCount);
    }

// 카운트 가져오는 메서드
private Long fetchTotalCount(AllBookFilterDto condition) {
        if (condition.getTotalCount() == null) {
            return bookRepository.countAllBooks(condition);
        }
        return condition.getTotalCount();
    }

이렇게 변경해서 첫 페이지 외의 요청에서 count쿼리를 호출하지 않게 된다.