Backend/SpringBoot

Cursor 기반 Paging 추가 정리(QueryDsl 기준)

Dean83 2026. 2. 3. 16:11

https://dean83.tistory.com/285 여기에서 Paging 에 대한걸 간략하게 정리 하였다. 그러나 실제로는 Cursor 기반을 많이 사용하기도 하고, 좀 더 까다롭기도 하다.  이 부분 또한 위의 페이지에 정리 하려 하다가 너무 복잡해져 따로 정리한다.

예는 유저를 pageing 하는 예 이다.

 

중요한 내용 정리 먼저, 

  • 엔티티에 1:N, N:M 등 List 형태의 컬럼이 있을경우 해당 테이블과 fetch join을 하면 안된다. 
    • fetch join으로 인해 row의 갯수가 증가해 버린다 -> limit에 의해 의도치 않은 데이터 갯수가 생성된다.
    • 예 ) 유저 조회가 메인인 경우, 유저에 roles 라는 List 컬럼이 있을경우
      • 동일 유저인데 role이 다를 경우 row 갯수 증가 -> 그러나 페이지네이션에선 동일한 유저이므로 limit보다 적은 유저 리턴
      • distinct를 걸어도 row 자체는 중복이 아니기 때문에 소용 없음.
    • 이후 별도로 ids를 통해 fetch join을 수행해야 한다.
  • Cursor은 정렬 조건과 동일한 필드를 사용한다.
    • 만일 정렬 조건 필드가 name, email 등 여러개 일 경우, cursor 또한 각 조건과 부합한다.
    • 즉, 클라이언트에서 정렬조건으로 name을 주었다면,  cursor 또한 클라이언트에서 name 값을 준다.
  • Distinct를 사용하지 않는다

 

 

Service에서 호출하는 Repository 메서드 예 (메인 진입점)

public List<User> findAllUsers(UserCursorRequest request) {

        return findUserExpression(true)
                .where(
                        getEmailExpression(request),
                        getRoleExpression(request),
                        getLockedExpression(request),
                        cursorExpression(request)
                )
                .orderBy(orderByDirection(request), orderByDirection(request.sortDirection(), user.uuid))
                .limit(request.limit()+1)
                .fetch();
    }
  • Where 조건문을 완성해 주는 메서드를 호출하여 사용한다.
    • 각 조건문이 null 일 경우에도 NPE 터지지 않고 처리해 줘서 상관없다.
  • orderby 조건문을 완성해주는 메서드를 호출하여 사용한다. 또한 id를 보조 커서로 사용한다.
  • limit 은 +1을 하여 nextCursor 정보 등을 담을 수 있도록 한다.
  • Slice가 아니라 List로 리턴하고, Service에서 Dto를 생성하여 컨트롤러로 리턴한다.
  • 예에서는 일반쿼리(fetch join필요), Slice쿼리 두 경우가 다 있다보니 따로 Select 쿼리를 메소드로 분리하였다.

 

Repository의 Select 부분 예

private JPAQuery<User> findUserExpression(boolean isSlice)
    {
        JPAQuery<User> query = jpaQueryFactory
                                .select(user)
                                .from(user);

        //list 인 경우, slice 할때에는 fetch join을 쓰면 안된다. (limit 등 쿼리 select 오동작)
        if(isSlice)
        {
            query.join(user.userRoles, userRole);
            query.join(user.profile,profile);
            query.join(userRole.role,role);
        }
        else
        {
            query.join(user.userRoles, userRole).fetchJoin();
            query.join(user.profile,profile).fetchJoin();
            query.join(userRole.role,role).fetchJoin();
            query.distinct();
        }

        return  query;
    }
  • 만일 Repository 에서 일반 select와 Paging을 둘 다 쓰고 있을경우, 그리고 엔티티에 1:N 관계의 List 멤버변수가 있을경우 fetch join을 하지 않기 위해 위와 같이 메서드로 따로 분리해 주었다. 

일반적인 Where 조건절 예

   private BooleanExpression getEmailExpression(UserCursorRequest request)
    {
        return StringUtils.hasText(request.emailLike()) ? user.email.like(request.emailLike()) : null;
    }
  • BooleanExpression을 리턴한다. 
  • null이 아닌경우 조건에 맞는 문장리턴, null일 경우 null을 리턴한다.

 

Cursor Where 조건절 예

private BooleanExpression cursorExpression(UserCursorRequest request) {
        if (request.cursor() == null || request.idAfter() == null) {
            return null; // 최초 페이지 → 커서 조건 없음
        }

        if(request.sortDirection() == UserSortDirection.ASCENDING)
            return cursorExpressionAsc(request);

        return cursorExpressionDesc(request);
    }

    private BooleanExpression cursorExpressionDesc(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return profile.name.lt(request.cursor())
                        .or(
                                profile.name.eq(request.cursor())
                                        .and(user.uuid.lt(request.idAfter()))
                        );
            case email:
                return user.email.lt(request.cursor()).
                        or(
                                user.email.eq(request.cursor())
                                        .and(user.uuid.lt(request.idAfter())
                        ));
            case role:
            case isLocked:
                return user.uuid.lt(request.idAfter());
            case createdAt:
                Instant cursorTime = Instant.parse(request.cursor());
                return user.createdAt.lt(cursorTime)
                        .or(
                                user.createdAt.eq(cursorTime)
                                        .and(user.uuid.lt(request.idAfter())
                        ));
            default:
                return null;
        }
    }

    private BooleanExpression cursorExpressionAsc(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return profile.name.gt(request.cursor())
                        .or(
                                profile.name.eq(request.cursor())
                                        .and(user.uuid.gt(request.idAfter()))
                        );
            case email:
                return user.email.gt(request.cursor()).
                        or(
                                user.email.eq(request.cursor())
                                        .and(user.uuid.gt(request.idAfter())
                                        ));
            case role:
            case isLocked:
                return user.uuid.gt(request.idAfter());
            case createdAt:
                Instant cursorTime = Instant.parse(request.cursor());
                return user.createdAt.gt(cursorTime)
                        .or(
                                user.createdAt.eq(cursorTime)
                                        .and(user.uuid.gt(request.idAfter())
                                        ));
            default:
                return null;
        }
    }
  • 정렬 조건이 Desc 이냐, Asc 이냐에 따라 검색 조건이 달라진다. (값이 커야 하는지 작아야 하는지 조건이 달라지기 때문)
  • 또한 정렬 조건이 여러개 일 경우, Cursor 또한 여러 조건값이 될 수 있기 때문에 이를 고려해야 한다.
  • Cursor 비교값과 결과가 동일할 경우가 있을 수 있기 때문에 후행에 or 절을 이용하여 보조커서(보통 ID)를 이용해 조건을 걸어준다.
  • 최초 검색 조건에는 Cursor 등이 없으므로 예외처리를 해주어야 한다.  

 

OrderBy 예

private OrderSpecifier orderByDirection(UserCursorRequest request)
    {
        switch (request.sortBy())
        {
            case name:
                return orderByDirection(request.sortDirection(), profile.name);
            case email:
                return orderByDirection(request.sortDirection(), user.email);
            case role:
                return orderByDirection(request.sortDirection(), role.name);
            case isLocked:
                return orderByDirection(request.sortDirection(), user.locked);
            case createdAt:
                return orderByDirection(request.sortDirection(), user.createdAt);
            default:
                return null;
        }
    }

    private OrderSpecifier orderByDirection(UserSortDirection direction, ComparableExpressionBase field)
    {
        if(direction == UserSortDirection.ASCENDING)
            return field.asc();

        return field.desc();
    }
  • OrderSpecifier를 리턴한다.
  • 어떤 필드든 정렬 조건을 리턴하는 메소드를 이용한다. (정렬 조건이 여러개 올 수 있기 때문에 각 이 메소드를 호출한다)

Service 예

@Transactional(readOnly = true)
    public CursorResponseUserDto getAllUsers(UserCursorRequest request)
    {
        //QueryDsl 메서드 호출
        List<User> users = userRepository.findAllUsers(request);
        boolean hasNext = false;
        String nextCursor = null;
        UUID idAfter = null;
        //JpaRepository 기본 메서드 count 호출
        Long totalCount = userRepository.count();
        
         if(users.size() > request.limit())
        {
            hasNext = true;
            users.remove(users.size() - 1);
            idAfter = users.get(users.size() - 1).getUuid();
            nextCursor = getNextCursor(request, users);
        }
        return CursorResponseUserDto.builder()
                .data(orderedUsers.stream()
                        .map(x -> UserDto.builder()
                                .user(x)
                                .build()).toList())
                .hasNext(hasNext)
                .nextCursor(nextCursor)
                .nextIdAfter(idAfter)
                .totalCount(totalCount)
                .sortDirection(request.sortDirection().name())
                .sortBy(request.sortBy().name())
                .build();

    }

    private String getNextCursor(UserCursorRequest request, List<User> users)
    {
        switch (request.sortBy())
        {
            case name:
                return users.get(users.size() - 1).getProfile().getName();
            case email:
                return users.get(users.size() - 1).getEmail();
            case role:
                return users.get(users.size() - 1).getUserRoles().get(0).getRole().getName().name();
            case isLocked:
                return users.get(users.size() - 1).getLocked().toString();
            case createdAt:
                return users.get(users.size() - 1).getCreatedAt().toString();
            default:
                return null;
        }
    }
  • Slice를 이용하지 않고 따로 Dto를 통해 결과를 리턴한다.
  • Repository 조회 수가 limit 보다 적을경우 next 는 없다고 간주하여 처리한다.
  • Cursor의 경우 정렬조건이 여러개 일 수 있으므로 이에 각각 해당하는 값을 넣어준다.

Fetch Join을 위한 처리

  • 일반적인 Paging 에선 Fetch join을 하면 안된다. 따라서 이후 fetch join을 통해 데이터를 한번 더 가져와야 한다. (N+1 방지)
  • 결국 Entity에서 List 형태로 멤버변수를 갖고 있다면 2번의 쿼리를 하게 되는 셈이다.

 

Repositoty 예

    //N+1 을 해결하기 위한 페이지네이션 후속 쿼리
    @Override
    public List<User> findUsersByIds(List<UUID> ids) {
        return findUserExpression(false)
                .where(user.uuid.in(ids))
                .fetch();
    }
  • ids를 통해 in 을 이용하여 모든 항목을 fetch join 하여 가져온다.

 

Service 예 (Fetch Join  예)

@Transactional(readOnly = true)
    public CursorResponseUserDto getAllUsers(UserCursorRequest request)
    {
        //QueryDsl 메서드 호출
       ...

        //userroles n+1 해결을 위해 fetch join을 해오기 위한 부분
        List<UUID> ids = users.stream().map(User::getUuid).toList();
        if(ids.isEmpty())
            return new CursorResponseUserDto(
                    null,
                    nextCursor,
                    idAfter,
                    hasNext,
                    totalCount,
                    request.sortBy().name(),
                    request.sortDirection().name());

        //fetch join 후 정렬이 깨짐
        List<User> fetchedUsers = userRepository.findUsersByIds(ids);

        //uuid를 key로 하는 userMap을 생성 -> 정렬된 리스트를 재구성할떄 사용
        Map<UUID, User> userMap = fetchedUsers.stream()
                .collect(Collectors.toMap(User::getUuid, u -> u));

        //ids 는 paging 조건에 맞는 정렬형태. userMap에서 가져와 기존 정렬된 형태로 복구
        List<User> orderedUsers = ids.stream().map(userMap::get).toList();

       ...

    }
  • 1차로 Repository에서 Paging된 데이터를 조회한다.
  • 이 List 에서 id 값만 따로 추출한다.
  • 추출한 id값들을 인자로 하여 fetch join을 진행하는 repository 메서드를 호출한다.
  • fetch join된 결과는 정렬되지 않으므로, 기존 id List 순서 기준으로 값을 재배치 하여 원본 순서를 맞춘다.
  • 이를 이용해 controller에 리턴한다.