[C-Lab Core Team (Members)] 페이지네이션 개선

전민주·2024년 5월 26일

C-Lab Core-Team

목록 보기
1/4

0. 들어가며

안녕하세요! 경기대학교 AI컴퓨터공학부의 과동아리 C-Lab의 CoreTeam Back-end 팀원 전민주입니다. Back-end에서는 데이터를 전달할 때 필요에 따라 “페이지네이션”이라는 기술을 사용하고 있는데요.

지금까지는 API마다 하나의 고정된 정렬 방식을 가지고 페이지네이션을 구현해왔습니다. 확장성과 유연성을 고려해서 API마다 다양한 정렬 방식을 원하는대로 설정할 수 있게끔 페이지네이션을 개선하고자 합니다!

1. 페이지네이션이란

Pagination은 데이터를 조회결과의 전부가 아닌 일부분을 가져오는 기법입니다.

일부분을 어떻게 가져올지에 따라서 Offset 방식과 Cursor 방식으로 구분됩니다.

📌 Offset 방식

“page번째 페이지에서 데이터 size개를 sortBy기준으로 sortDirection 정렬을 한 결과를 주세요”

몇 번째 페이지? page

페이지 당 담을 데이터 수? size

정렬 기준? sortBy와 sortDirection

Offset 방식은 SQL 쿼리에서 LIMIT와 OFFSET 절을 사용해 비교적 구현이 간단한 장점이 있습니다.

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 20;
//21번째의 데이터부터 10개의 데이터를 created_at 기준으로 DESC 정렬을 한 결과를 주세요

물론 실제로는 OFFSET 값을 (page - 1) * size로 계산해서 사용합니다.

또한 원하는 페이지로 바로 이동이 가능하기 때문에 페이지 번호를 클릭해서 이동하는 방식에서 사용합니다.

그러나, Offset 방식에는 뒤쪽 페이지를 조회할 수록 속도가 느려지는 단점이 있습니다.

페이지네이션을 쓰는 이유가 전체 조회를 하는 대신 조각을 조회해서 조회시간과 통신시간을 줄이는 것에 있는데 속도가 느리다는게 무슨 말일까요?

Offset 방식을 이해할 때 중요한 점은 0번째 페이지부터~page번째까지 데이터를 조회하고, 그 다음 필요 없는 앞부분을 잘라낸다는 것입니다. 즉 페이지가 뒤로 갈수록 조회해야하는 데이터의 양이 선형적으로 증가합니다.

📌 Cursor 방식

“cursor 기준으로 size개의 다음 데이터를 sorBy기준으로 sortDirection 정렬을 한 결과를 주세요”

어디까지 조회했나요? cursor

페이지 당 담을 데이터 수? size

정렬 기준? sortBy와 sortDirection

SELECT * FROM posts WHERE created_at < '2023-05-26T10:00:00Z' ORDER BY created_at DESC LIMIT 10;

커서 기반의 경우는 무한 스크롤 방식에서 방금 본 부분을 커서로 기억하고 그 다음 데이터를 요청하는 기술입니다.

따라서 클라이언트가 커서 데이터를 기억했다가 API요청 시에 포함해서 주어야 하고 정렬 기준이 복잡해지면 구현하기 어렵습니다.

그치만 Offset 방식과 다르게 첫페이지부터~커서까지의 불필요한 조회 없이 커서부터~다음 데이터만 조회할 수 있기 때문에 뒤쪽 페이지를 조회한다고 해서 속도가 느려지는 경우는 적습니다.

2. API 통신에서 페이지네이션이 주는 이점

clab.page에서 게시글 목록을 열람하고자 할 때 DB에 존재하는 모든 게시글을 한 번에 전달할 경우 오래 걸리겠죠? 그럼 로딩 시간이 길어지고 게시글 목록 하나 볼 때마다 답답하니 사용자 입장에서는 불편함을 느낍니다.

그래서 데이터의 일부분을 나눠서 가져오는 방법을 사용하면 빠르게 원하는 내용을 조회할 수 있습니다. 다음 페이지의 내용을 보고 싶을 때는 page 번호를 달리해서 요청을 하면 됩니다.

실제 clab.page에서는 Offset방식을 사용해서 page 번호에 따라 server에 요청을 보내 페이지네이션을 사용하고 있습니다.

3. 코어팀에서 사용하는 페이지네이션 방법

기존의 clab-server에서는 정렬 방법을 프론트엔드와 사전 협의하여 정적으로 수행하게 설계되어 있었습니다.

요구사항의 변경이 생길 경우 API를 수정해야하고 배포 역시 새로 진행해야 했죠.

그럼 비슷한 로직이어도 정렬 기준이 다르다는 이유로 여러 개의 API를 만들어야 하는 상황이 발생했습니다.

아래의 BoardRepository를 보면 조회를 할 때 JPA로 OrderBy 기준을 명시하고 있었는데요. 만약다른 정렬기준과 정렬방식을 사용하고 싶을 때는 똑같은 Where절을 사용하고 OrderBy 구문만 다르게 API를 작성해야 합니다.

그럼 코드 중복에 유지보수가 어렵고, 프론트에서도 사용해야 하는 API가 많아지기 때문에 API 요청시에 정렬 기준(컬럼)과 방식(오름차순, 내림차순)을 같이 요청받아 유동적으로 데이터 응답을 줄 수 있도록 개선하고자 했습니다.

4. 페이지네이션에 정렬 방법 추가하기

기존에는 page와 size만 전달했다면 이제는 sortBy와 sortDirection을 추가해서 직접 정렬 조건을 설정할 수 있도록 바꿨습니다!

그래서 결과적으로 Board를 조회하는 JPA 메소드명이 간결해지고 더욱 확장성 있게 사용할 수 있게 되었습니다.

현재 clab-server의 DB에는 약 40개의 테이블이 존재하기 때문에 전부 개선된 페이지네이션을 적용하려면 공통으로 사용할 수 있는 PageRequest.of 제조기가 필요했습니다.

그래서 PageableUtils를 만들고, 정렬기준에 사용할 칼럼이 존재하는지 확인해주는 ColumnValidator를 만들었습니다.

PageableUtils에서 가장 중요한 부분은 정렬 기준을 입력받아 Sort 객체를 만드는 것입니다. 이때 정렬기준을 여러개 둘 수 있도록 List로 받아 하나씩 적용해줍니다.

원래는page와 size로만 Pageable 인터페이스를 구현해서 사용했지만 여기에 정렬기준을 추가하는 것이 이번 작업의 핵심이었습니다.

PageRequest.of(page, size);
PageRequest.of(page, size, **sort**);

5. 트러블 슈팅

⚠️ JPA와 QueryDSL에서의 페이징 차이

분명 컨트롤러에 커스텀 정렬이 가능한 페이지네이션을 적용했는데, 입력받은 정렬 기준이 아니라 똑같은 정렬기준이 적용되는 경우가 있었는데요.

JPA와 QueryDSL의 차이를 몰랐던 저의 무지에서 발생한 이슈였습니다..

clab-server에서는 두 가지의 Repository를 필요에 따라 사용하고 있습니다.

JpaRepository를 확장한 것 VS QueryDSL을 사용해서 커스텀 한 것

public interface BoardRepository extends JpaRepository<Board, Long>

public class BookLoanRecordRepositoryImpl implements BookLoanRecordRepositoryCustom

JpaRepository를 확장한 것은 자체적으로 Pageable을 전달 받으면 sort를 진행했으나, QueryDSL을 사용하는 부분은 orderBy 쪽에 sort를 적용해주어야 했습니다.

  .orderBy(OrderSpecifierUtil.getOrderSpecifiers(pageable, "book"))

이를 위해서 OrderSpecifierUtil을 작성해서 전달받은 pageable을 QueryDSL에 적용할 수 있도록 했습니다. path를 찾기 위해서 테이블명을 전달 받아 칼럼을 찾고 주어진 direction대로 정렬하는 코드입니다.

pageable.getSort().stream().forEach(order -> {
            Order direction = order.isAscending() ? Order.ASC : Order.DESC;
            String property = order.getProperty();
            PathBuilder path = new PathBuilder(Object.class, tableName);
            orderSpecifierList.add(new OrderSpecifier(direction, path.get(property)));
        });

⚠️ 테이블명 대신 EntityPathBase!

페이지네이션에 정렬 방법을 적용하면서 똑같은 로직인데 될 때가 있고, 안 될 때가 생겼습니다..

똑같이 createdAt을 기준으로 desc하는 정렬 방법이었는데 어떤 테이블에서는 적용이 잘 되고, 어떤 테이블에서는 오류가 났었는데요.

또, 분명 존재하는 칼럼인데 못 찾는다고 에러가 났습니다..

Server Error: Could not interpret path expression 'position.year’

로그를 보니까 path expression을 인식하지 못하는 것 같았습니다. 테이블명으로 path를 찾으면 깨지는 것 아닐까? 라는 생각으로 정확히 DB 칼럼의 경로를 알 수 있는 방법을 찾아보면서 EntityPathBase를 사용해서 path를 찾자고 결론을 내리고 아래처럼 OrderSpecifierUtil을 수정했습니다.

pageable.getSort().stream().forEach(order -> {
            Order direction = order.isAscending() ? Order.ASC : Order.DESC;
            String property = order.getProperty();
            PathBuilder path = new PathBuilder(q.getType(), q.getMetadata());
            orderSpecifierList.add(new OrderSpecifier(direction, path.get(property)));
        });

그리고 QueryDSL을 작성하는 쪽에서는 Q엔티티명 으로 선언된 객체를 전달하도록 했습니다.

  .orderBy(OrderSpecifierUtil.getOrderSpecifiers(pageable, book))
  //  QBook book = QBook.book;

6. PageableUtils, ColumnValidator, OrderSpecifierUtil 코드

@Component
public class PageableUtils {

    private static ColumnValidator columnValidator;

    public PageableUtils(ColumnValidator columnValidator) {
        PageableUtils.columnValidator = columnValidator;
    }

    public static Pageable createPageable(int page, int size, List<String> sortByList, List<String> sortDirectionList, Class<?> entityClass) throws SortingArgumentException {
        if (sortByList.size() != sortDirectionList.size()) {
            throw new SortingArgumentException();
        }

        for (String sortBy : sortByList) {
            if (!columnValidator.isValidColumn(entityClass, sortBy)) {
                throw new NotFoundException(sortBy + "라는 칼럼이 존재하지 않습니다.");
            }
        }

        for (String direction : sortDirectionList) {
            if (!isValidateSortDirection(direction)) {
                throw new SortingArgumentException(direction + "은 지원하지 않는 정렬 방식입니다.");
            }
        }

        Sort sort = Sort.by(
                IntStream.range(0, sortByList.size())
                        .mapToObj(i -> new Sort.Order(Sort.Direction.fromString(sortDirectionList.get(i)), sortByList.get(i)))
                        .collect(Collectors.toList())
        );

        return PageRequest.of(page, size, sort);
    }

    private static boolean isValidateSortDirection(String direction) {
        return "asc".equalsIgnoreCase(direction) || "desc".equalsIgnoreCase(direction);
    }

}

@Component
public class ColumnValidator {

    @PersistenceContext
    private EntityManager entityManager;

    public boolean isValidColumn(Class<?> entityClass, String columnName) {
        Metamodel metamodel = entityManager.getMetamodel();
        EntityType<?> entityType = metamodel.entity(entityClass);
        return entityType.getAttributes().stream()
                .anyMatch(attribute -> attribute.getName().equals(columnName));
    }
}

@Component
public class OrderSpecifierUtil {

    public static OrderSpecifier<?>[] getOrderSpecifiers(Pageable pageable, EntityPathBase<?> q) {
        List<OrderSpecifier<?>> orderSpecifierList = new ArrayList<>();

        pageable.getSort().stream().forEach(order -> {
            Order direction = order.isAscending() ? Order.ASC : Order.DESC;
            String property = order.getProperty();
            PathBuilder path = new PathBuilder(q.getType(), q.getMetadata());
            orderSpecifierList.add(new OrderSpecifier(direction, path.get(property)));
        });
        return orderSpecifierList.toArray(new OrderSpecifier[0]);
    }

}

7. 마무리하면서

지금까지 코어팀에서 원래 사용하던 페이지네이션 정책과 제가 새롭게 개선한 과정에 대해 적어보았는데요. 원래 만들어져있던 코드들을 보면서 EntityPathBase, QueryDSL를 접할 수 있는 기회였습니다.

기존의 코드를 보며 이전의 개발을 이해하고 조금 더 발전시키는 과정은 항상 즐거운 것 같습니다.

다만, 저는 기존 페이지네이션을 사용하면서 충분히 편하고 좋다라는 생각만 했지, 어떤 점을 더 고쳐야할까? 라는 생각은 못했던 것 같습니다.

앞으로 다양한 기술을 접하고 코어팀에서 어떻게 적용할 수 있는지 생각할 줄 아는 팀원이 되도록 노력하겠습니다!

참고

Spring Data JPA에서의 페이지네이션과 정렬

[Spring Data JPA] - 정렬과 페이징 처리

https://velog.io/@nias0327/JPA와-Pageable을-이용한-페이징-처리-Sort-Paging

https://itkjspo56.tistory.com/314

profile
엉금엉금

0개의 댓글