본문 바로가기

자바 심화/TIL

페이지네이션 Offset vs Cursor

개요

팀 프로젝트에서 주문관리 시스템을 개발했는데 각종 정보에 대한 조회 기능이 필요했고, 프로젝트 요구사항에 QueryDSL 등을 활용한 pagination이 필요했다.

 

Pagination(페이지네이션)?

대량의 데이터를 효율적으로 나누어 제공하는 방법으로, 주로 웹 애플리케이션에서 한 번에 적절한 양의 데이터를 클라이언트에 제공하기 위해 사용된다.

이는 사용자가 데이터를 탐색하거나 검색 결과를 스크롤할 때 유용하다.

 

페이지네이션은 offset 기반과 cursor 기반 2가지가 있다.

 

Offset 기반 페이지네이션

  • 개념: 요청 시 특정 위치(offset)부터 지정한 개수(limit)의 데이터를 가져오는 방식
    • SELECT * FROM table LIMIT 10 OFFSET 20 (20번째부터 10개 데이터 반환)
  • 장점:
    • 1. 구현 간단: SQL 쿼리에서 바로 지원하며 많은 라이브러리에서 기본적으로 제공
    • 2. 임의 접근 용이: 사용자가 특정 페이지를 직접 선택할 때 유리
  • 단점:
    • 1. 성능 저하: 데이터가 많아질수록 OFFSET 처리 시 무시되는 행들을 읽어야 하므로 느려짐
    • 2. 데이터 불일치: 데이터가 실시간으로 변경되면 중복되거나 누락된 데이터가 발생할 수 있음

Cursor 기반 페이지네이션

  • 개념: 이전 요청에서 반환된 데이터의 커서(cursor)를 기준으로 다음 데이터를 가져오는 방식
    • 커서는 일반적으로 정렬된 컬럼 값(ID, 타임스탬프 등)을 이용한다.
    • SELECT * FROM table WHERE id > last_cursor_id LIMIT 10
  • 장점:
    • 1. 성능 우수: OFFSET 없이 특정 커서부터 데이터를 가져오므로 대량의 데이터 처리에 유리
    • 2. 일관성 유지: 데이터가 실시간으로 변경되어도 데이터 중복/누락 가능성이 낮음
  • 단점:
    • 1. 구현 복잡: 커서를 관리하고 저장해야 하며, 복잡한 정렬 조건이 있을 경우 설정이 어려움
    • 2. 임의 접근 어려움: 특정 페이지로 바로 이동이 불가능하며 연속적으로 데이터를 탐색해야함

 

 

프로젝트 적용

조회 기능을 일반 유저 및 관리자용으로 나누고 일반 유저용은 성능을 위해 Cursor 기반 페이지네이션을 관리자용은 모든 정보를 조회해야 하기 때문에 offset 기반 페이지네이션을 적용하기로 정했다.

 

QueryDSL 설정

QueryDSL을 사용하기 위해 gradle에 의존성을 추가하고 config 파일을 작성했다.

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

QueryDSL Config

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

페이지네이션 기본값 설정

페이지네이션에 사용할 기본값과 Cursor를 만들었다.

public interface Sort {

    public static final String DEFAULT_SORT = "created_at";

    boolean isDefaultSort();
}

 

import java.util.Objects;

public record Cursor(int size, String basedUuid, String basedValue, Sort sort) {

    public boolean isFirstPage() {
        return Objects.isNull(basedUuid) || basedUuid.isBlank();
    }

    public boolean isDefaultSort() {
        return sort.isDefaultSort();
    }
}

 

 

OFFSET 커스텀 페이지네이션 설계

Pageable 객체를 커스터마이징 하여 페이지네이션을 요청하기 위한 클래스를 만들었다.

import org.springframework.core.MethodParameter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class PaginationArgumentResolver extends PageableHandlerMethodArgumentResolver {

    @Override
    public Pageable resolveArgument(
        MethodParameter methodParameter, ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {

        final Pageable pageable =
            super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory);

        if (methodParameter.hasMethodAnnotation(PaginationConstraint.class)) {
            return validatePageableOrDefault(methodParameter, pageable);
        }
        return pageable;
    }

    private static Pageable validatePageableOrDefault(MethodParameter methodParameter, Pageable pageable) {

        PaginationConstraint paginationConstraint =
            methodParameter.getMethodAnnotation(PaginationConstraint.class);

        Sort sort = validateSortAndGet(pageable, paginationConstraint);
        int size = validateSizeAndGet(pageable, paginationConstraint);

        return PageRequest.of(pageable.getPageNumber(), size, sort);
    }

    private static Sort validateSortAndGet(Pageable pageable, PaginationConstraint paginationConstraint) {
        if (pageable.getSort().isSorted()) {
            return pageable.getSort();
        }
        return Sort.by(Sort.Direction.fromString(paginationConstraint.defaultDirection()), paginationConstraint.defaultSort());
    }

    private static int validateSizeAndGet(Pageable pageable, PaginationConstraint paginationConstraint) {
        int[] availableSizes = paginationConstraint.availableSizes();
        for (int availableSize : availableSizes) {
            if (pageable.getPageSize() == availableSize) {
                return pageable.getPageSize();
            }
        }
        return paginationConstraint.defaultSize();
    }
}
  • PageableHandlerMethodArgumentResolver 를 확장해 추가적인 검증과 기본값 설정을 제공받았다.

주요 기능 및 동작

  1. resolveArgument 메서드:
    • 컨트롤러 메서드의 @PaginationConstraint 애노테이션을 확인한다.
    • 애노테이션이 있다면 페이지네이션 요청을 검증(validatePageableOrDefault)한 후 적절히 수정된 Pageable 객체를 반환한다.
    • 그렇지 않으면 기본적으로 부모 클래스의 resolveArgument 메서드를 통해 Pageable 객체를 반환한다.
  2. validatePageableOrDefault 메서드:
    • 목적: 전달받은 Pageable 객체를 검증하거나, 기본값을 설정한 새로운 Pageable 객체를 생성.
    • 과정:
      • @PaginationConstraint에서 설정된 제약 조건을 가져온다.
      • 정렬(Sort) 검증 및 기본값 설정 (validateSortAndGet 호출).
      • 페이지 크기(Page Size) 검증 및 기본값 설정 (validateSizeAndGet 호출).
  3. validateSortAndGet 메서드:
    • 기능:
      • Pageable 객체에 정렬 조건이 존재하면 그대로 사용한다.
      • 없을 경우, @PaginationConstraint에서 정의된 기본 정렬 방향과 정렬 기준을 사용해 Sort 객체를 생성한다.
  4. validateSizeAndGet 메서드:
    • 기능:
      • Pageable의 페이지 크기(pageSize)가 @PaginationConstraint에서 허용된 크기(availableSizes) 중 하나인지 확인한다.
      • 유효하지 않으면 기본 크기(defaultSize)를 반환한다.

이 코드를 통해 @PaginationConstraint 애노테이션을 사용해 페이지 크기, 정렬, 기본값 등을 제약할 수 있게 되고, 정렬 조건 유효성 및 페이지 크기 유효성 검사를 할 수 있게 되었다.

 

적용

@PaginationConstraint
@GetMapping
@PreAuthorize("hasAnyRole('MANAGER', 'MASTER')")
public Page<OrderResponse> findAll(Pageable pageable) {

    return adminOrderService.findAll(pageable)
            .map(OrderResponse::from);
}
  • 관리자가 주문을 조회하는데 사용하는 API 메서드이다.

 

도메인에 맞는 정렬 데이터 검증을 위한 처리

import java.util.function.Function;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.mapping.PropertyReferenceException;

public class PageableSortProxy {

    /**
     * 쿼리 실행 중 정렬 타입 불일치로 예외가 발생하면 <br>
     * 기본 정렬 전략으로 재 실행하는 메서드 <br><br>
     * param: Pageable : 쿼리에 적용할 페이지네이션 정보 <br>
     * param: Function : 실행할 메서드
     * */
    public static <T> Page<T> executeWithFallback(
        Pageable pageable, Function<Pageable, Page<T>> function) {

        try {
            return function.apply(pageable);

        } catch (PropertyReferenceException exception) {

            Pageable fallbackPageable = PageRequest.of(
                pageable.getPageNumber(),
                pageable.getPageSize(),
                Sort.by(Direction.ASC, "createdAt")
            );
            return function.apply(fallbackPageable);
        }
    }
}
  • 프록시 클래스를 생성하여 페이지네이션 정보를 각각의 도메인에 맞는지 검증하여 Sort 문제로 예외가 발생하면 기본 정렬 값으로 다시 요청하도록 처리했다.
  • 위 방식의 문제점
    • 외부로부터 검증되지 않은 Sort 데이터가 비즈니스 로직 레이어로 넘어감
    • 정렬 값이 유효하지 않은 경우, 비교적 처리 비용이 높은 쿼리 생성 작업이 두 번 발생한다는 점

 

CURSOR 커스텀 페이지네이션 설계

import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class CursorParamHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(CursorPagination.class) &&
            parameter.hasParameterAnnotation(CursorRequest.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        String size = webRequest.getParameter("size");
        String basedUuid = webRequest.getParameter("basedUuid");
        String basedValue = webRequest.getParameter("basedValue");
        String sortedColumn = webRequest.getParameter("sortedColumn");

        return CursorPagination.of(size, basedUuid, basedValue, sortedColumn);
    }
}
  • OFFSET 방식 때와 마찬가지로 ArgumentResolver를 구현해서 검증과정을 거치도록 했다.

주요 동작 및 기능

 

  1. supportsParameter 메서드:
    • 컨트롤러 메서드의 특정 파라미터가 처리 가능한지 여부를 결정한다.
    • 조건:
      • 파라미터 타입이 CursorPagination이어야 한다.
      • 파라미터에 @CursorRequest 애노테이션이 선언되어 있어야 한다.
    • 의도: 특정 컨트롤러 메서드에서만 이 로직이 동작하도록 제한.
  2. resolveArgument 메서드:
    • 요청으로부터 파라미터를 추출하여 CursorPagination 객체를 생성한다.
    • 주요 파라미터:
      • size: 한 번에 조회할 데이터 개수.
      • basedUuid: 기준이 되는 UUID (커서 역할).
      • basedValue: 정렬 기준 값 (예: 시간, ID 등).
      • sortedColumn: 정렬할 컬럼 이름.
    • CursorPagination 객체 생성:
      • CursorPagination.of 정적 메서드를 호출해 위 파라미터를 사용하여 새로운 CursorPagination 인스턴스를 반환한다.

적용

@Operation(summary = "특정 키워드를 포함한 가게 리스트 조회", description = "커서 기반 페이지네이션")
@ResponseStatus(HttpStatus.OK)
@GetMapping("/search")
public List<ShopListResponse> findAllByKeyword(
    @RequestParam(name = "keyword") String keyword,
    @CursorRequest CursorPagination cursorPagination) {

    ShopSort shopSort = ShopSort.of(cursorPagination.getSortedColumn());
    Cursor cursor = cursorPagination.toCursor(shopSort);

    return shopPersistenceAdapter.findAllByKeyword(keyword, cursor).stream()
        .map(ShopListResponse::from).toList();
}

 

  • 특정 키워드를 포함한 가게 리스트 조회에 커서 기반 페이지네이션을 사용했다.

기준  값을 기반으로 정렬 및 데이터 탐색이 가능해져 OFFSET 보다 성능이 좋을 것으로 예상된다.

 

 

각 도메인에 맞는 VO 매핑

도메인마다 조회하는 방식의 차이가 있기 때문에 VO를 통해 매핑 시켰다.

@Getter
@AllArgsConstructor
public enum ShopSort implements Sort {

    CREATED_AT("created_at", LocalDate.class),
    RATING("rating", Double.class),
    SHOP_NAME("shop_name", String.class);

    private static final ShopSort DEFAULT_SORT = CREATED_AT;

    private String columnName;
    private Class<?> valueType;

    public static ShopSort of(String basedColumn) {
        for (ShopSort shopSort : ShopSort.values()) {
            if (Objects.equals(shopSort.columnName, basedColumn)) {
                return shopSort;
            }
        }
        return DEFAULT_SORT;
    }

    @Override
    public boolean isDefaultSort() {
        return Objects.equals(columnName, Sort.DEFAULT_SORT);
    }
}
@Getter
@AllArgsConstructor
public enum ReviewSort implements Sort {

    CREATED_AT("created_at", LocalDateTime.class),
    RATING("rating", Integer.class);

    private final String columnName;
    private final Class<?> valueType;

    public static ReviewSort of(String basedColumn) {
        for (ReviewSort reviewSort : ReviewSort.values()) {
            if (Objects.equals(reviewSort.columnName, basedColumn)) {
                return reviewSort;
            }
        }
        return CREATED_AT;
    }

    @Override
    public boolean isDefaultSort() {
        return Objects.equals(columnName, Sort.DEFAULT_SORT);
    }
}
@Getter
@AllArgsConstructor
public enum ProductSort implements Sort {

    CREATED_AT("created_at", LocalDate.class),
    PRODUCT_NAME("product_name", String.class);

    private String columnName;
    private Class<?> valueType;

    public static ProductSort of(String baseColumn) {
        for (ProductSort productSort : ProductSort.values()) {
            if (Objects.equals(productSort.columnName, baseColumn)) {
                return productSort;
            }
        }

        return CREATED_AT;
    }

    @Override
    public boolean isDefaultSort() {
        return Objects.equals(columnName, Sort.DEFAULT_SORT);
    }
}

 

 

커스터마이즈 HandlerMethodArgumentResolver 등록

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final PaginationArgumentResolver paginationArgumentResolver;
    private final CursorParamHandlerMethodArgumentResolver cursorParamHandlerMethodArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(cursorParamHandlerMethodArgumentResolver);
        resolvers.add(paginationArgumentResolver);
    }
}

 

 

OFFSET vs CURSOR 기반 페이지네이션 성능 비교 테스트

60만 건의 데이터에서 각 쿼리의 성능 차이를 비교 (50만 번째의 데이터부터 50개의 데이터 페이징하는 것을 기준으로 측정)

 

OFFSET 기반 페이지네이션(4s 647ms)

 

CURSOR 기반 페이지네이션 - index 미사용(2s 567ms)

  • offset 대비 45% 성능 개선

 

CURSOR 기반 페이지네이션 - index 사용(413ms)

  • offset 대비 약 91%, index 미사용 대비 83% 성능 개선

  • 조회만을 고려했기 때문에 데이터 수정 작업의 성능까지 고려할 때 무분별한 인덱스의 생성은 피해야 함

 

정리

  • 대용량 데이터를 효율적으로 클라이언트에 전달하기 위해서는 페이지네이션을 사용해야 함
  • Offset과 Cursor 기반 페이지네이션 중 프로젝트에 더 적합한 방식을 고려해서 적용해야 함
  • 커스터마이즈 할 경우 구현이 복잡해질 수 있기 때문에 페이징 처리 시 필요한 사항을 고려해서 하는 것이 좋음

  • offset은 구현이 쉽지만 대용량 데이터에서 성능이 떨어짐, cursor는 대용량 데이터에서 성능적 이점이 있지만 구현이 어려움

 

느낀 점

  • 단순히 조회에서 성능 개선을 위해 페이지네이션을 하더라도 고려해야 할 것이 많고 요구 사항에 맞게 개발하기 위해서는 구현의 어려움도 많다는 것을 배웠다.

'자바 심화 > TIL' 카테고리의 다른 글

클린 코드 -2  (1) 2024.11.20
클린 코드 1  (2) 2024.11.19
결제 기능 구현  (0) 2024.11.16
PostgreSQL 기초  (1) 2024.11.14
아키텍처(Architecture)  (0) 2024.11.12