본문 바로가기
Spring/SpringBoot

JPA 대용량 데이터 페이징, Offset보다 Keyset을 선택해야 하는 이유

by J4J 2025. 12. 30.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 jpa 대용량 데이터 페이징, offset보다 keyset을 선택해야 하는 이유에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

Offset 기반의 페이징

 

jpa를 이용하여 대량의 데이터를 조회해야 할 때 가장 많이 사용하는 방법이 페이징 처리를 하는 것입니다.

 

페이징 처리를 하게 된다면 필요한 데이터를 한 번에 조회하는 것이 아니고, 정해진 일정 개수만큼만 데이터를 조회하기에 데이터베이스의 조회 시간과 네트워크 전송량 등을 모두 포함하여 사용되는 리소스의 양이 눈에 띄게 줄어듭니다.

 

즉, 모든 데이터를 한 번에 확인할 수는 없지만 지금 페이지에 접속한 사용자가 필요로 하는 데이터를 빠른 시간 안에 확인할 수 있기에 대량의 데이터를 조회할 때 필수 요소가 됩니다.

 

 

 

페이징 처리를 하는 방법 중 많이 사용되는 가장 대표적인 방법은 offset 기반의 페이징입니다.

 

페이징 처리가 되고 싶은 offset, size 정보를 전달하여 offset 위치부터 size 값 만큼 데이터를 조회하는 방식이 됩니다.

 

하지만 데이터의 양이 단순히 많은 수준이 아니고, offset의 정보가 최소 몇 만건 이상으로 올라가는 수준이 된다면 페이징을 사용하지만 데이터 조회 속도가 느려지는 결과를 만듭니다.

 

 

 

데이터 조회 속도가 느려지는 이유는 index를 설정하거나 다른 방법을 적용하더라도 offset 만큼 항상 데이터를 읽기 때문입니다.

 

offset 값에 따라 읽는 데이터의 개수

 

 

즉, 단순히 size에 상관 없이 100/200 정도의 offset이라면 100/200개 만큼의 데이터를 읽지만 offset이 10000/20000으로 간다면 동일하게 데이터를 처음부터 10000/20000개 만큼 읽어야 합니다.

 

그래서 size 값이 작더라도 데이터 조회를 위해 사용되는 리소스가 많아지고, 조회 속도를 낮추며 성능적인 부분이 안 좋아지는 결과가 나오게 됩니다.

 

또한 count 정보를 항상 조회해야 하는 문제점도 존재합니다.

 

그러므로 데이터가 많지 않은 단순 조회, 관리자 페이지 등에 페이징이 필요한 경우는 offset을 사용하는 것이 맞고, 그 외 대용량 데이터 조회 방식에서는 다른 방식이 사용되는 것을 권장합니다.

 

 

 

jpa에서 offset 기반의 페이징을 처리하기 위해서는 다음과 같은 소스 코드를 작성할 수 있습니다.

 

// entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "keyset_table")
public class KeysetTableEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long no;
    private LocalDateTime createDate;
}


// jpa
@RestController
@RequiredArgsConstructor
public class KeysetTableController {
    private final KeysetTableJpaRepository keysetTableJpaRepository;

    @GetMapping("/offset")
    public ResponseEntity<Page<KeysetTableEntity>> getOffsetPaging(Pageable pageable) {
        Page<KeysetTableEntity> keysetTableEntityPage =
                keysetTableJpaRepository.findAllByOrderByCreateDateDescNoDesc(pageable);

        return ResponseEntity.ok(keysetTableEntityPage);
    }
}


// querydsl
@RestController
@RequiredArgsConstructor
public class KeysetTableController {
    private final JPAQueryFactory jpaQueryFactory;

    @GetMapping("/offset")
    public ResponseEntity<Page<KeysetTableEntity>> getOffsetPaging(Pageable pageable) {
        List<KeysetTableEntity> keysetTableEntities =
                jpaQueryFactory
                        .selectFrom(QKeysetTableEntity.keysetTableEntity)
                        .offset(pageable.getOffset())
                        .limit(pageable.getPageSize())
                        .orderBy(
                                QKeysetTableEntity.keysetTableEntity.createDate.desc(),
                                QKeysetTableEntity.keysetTableEntity.no.desc()
                        )
                        .fetch();

        JPAQuery<Long> keysetTableCountQuery =
                jpaQueryFactory
                        .select(QKeysetTableEntity.keysetTableEntity.count())
                        .from(QKeysetTableEntity.keysetTableEntity);

        return ResponseEntity.ok(
                PageableExecutionUtils
                        .getPage(
                                keysetTableEntities,
                                pageable,
                                keysetTableCountQuery::fetchOne
                        )
        );
    }
}

 

 

반응형

 

 

Keyset 기반의 페이징

 

keyset 기반의 페이징은 offset 기반의 페이징에서 처리할 수 없는 대용량 페이징을 적용할 때 활용할 수 있는 방법입니다.

 

keyset 페이징은 cursor 페이징 이라고도 불리는 것으로 알고 있고, offset 처럼 페이지 번호 등을 이용하지 않고 기준점에 활용되는 필드 정보들을 정렬 키로 구성하는 방식입니다.

 

단순하게 표현하면 어디까지 읽었는지를 확인하고, 다음 페이징에는 읽은 곳 다음부터 데이터를 바로 조회하는 것이라고 얘기할 수 있습니다.

 

기준점에 따라 읽는 데이터의 개수

 

 

 

이 방식이 대용량 조회에 적합하다고 얘기할 수 있는 이유는 기준점 이후에 필요한 size 만큼만 데이터를 읽기 때문입니다.

 

offset처럼 처음부터 데이터를 읽는 방식이 아니고, 기준점부터 데이터를 읽기에 페이지의 위치에 상관없이 거의 비슷한 성능을 제공합니다.

 

또한 기준점을 찾아갈 때는 index를 활용하여 빠르게 접근할 수도 있습니다.

 

 

 

 

이런 keyset 방식도 항상 장점만 존재하는 것은 아닙니다.

 

가장 큰 단점 중 하나는 특정 페이지로 점프하여 이동할 수 없는 것 입니다.

 

offset 방식은 항상 페이지 정보가 따라오게 됩니다.

 

하지만 keyset 방식은 페이지 정보가 항상 첫 번째 페이지이며, 가장 마지막에 조회된 데이터가 무엇인지를 이용하여 페이징을 처리하는 방식입니다.

 

그래서 웹 애플리케이션에서 자주 볼 수 있는 pagination UI에서 페이지 정보를 선택하여 바로 이동할 수 있는 기능을 제공하는 것이 불가능합니다.

 

이러한 이유로, 주로 무한 스크롤 (infinite scroll) 에 많이 사용됩니다.

 

 

 

다음으로는 구현 난이도를 얘기해 볼 수 있습니다.

 

구현 난이도에서 가장 중요하게 생각해야 하는 것은 기준점에 사용되는 데이터 필드가 order by에도 사용되어야 하는 것입니다.

 

예를 들어, 생성 일자를 기준으로 정렬하여 데이터를 조회하고 있는데 기준점에 사용되는 데이터가 생성 일자 없이 key 정보만 활용하게 된다면 정렬이 깨질 수 있습니다.

 

keyset 방식에서 정렬에 사용되는 데이터가 cursor로 관리되지 않는 경우

 

 

 

그래서 order by에 사용되는 필드는 항상 기준 데이터에 활용되어야 의도한 것처럼 페이징 처리가 정상 적용됩니다.

 

그리고 이런 방식은 정렬되는 방식이 변경되면 기준점이 되는 데이터도 함께 수정이 되어야 한다는 문제를 발생시키게 됩니다.

 

최종적으로 keyset을 이용하는 경우에는 order by에 해당되는 데이터가 기준점에 항상 사용되어야 하며, 기준점에 담겨 있는 데이터로 정렬 순서를 명확히 파악할 수 없다면 순서를 파악할 수 있는 별도의 필드 (ex, key 값 / 순차적인 정렬 순서를 저장하는 필드 등)가 필수적으로 존재해야 합니다.

 

 

 

jpa에서 keyset 기반의 페이징을 처리하기 위해서는 다음과 같은 소스 코드를 작성할 수 있습니다.

 

// entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "keyset_table")
public class KeysetTableEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long no;
    private LocalDateTime createDate;
}


// repository
public interface KeysetTableJpaRepository extends JpaRepository<KeysetTableEntity, Long> {
    @Query("""
            select k from KeysetTableEntity k
            where (k.createDate < :lastCreateDate)
               or (k.createDate = :lastCreateDate and k.no < :lastNo)
            order by k.createDate desc, k.no desc
            """)
    Slice<KeysetTableEntity> findAllKeyset(Long lastNo, LocalDateTime lastCreateDate, Pageable pageable);

    @Query("""
            select k from KeysetTableEntity k
            order by k.createDate desc, k.no desc
            """)
    Slice<KeysetTableEntity> findAllKeyset(Pageable pageable);
}


// cursor
public record Cursor(
        Long no,
        LocalDateTime createDate
) {
}


// dto
public record KeysetResponseDto(
        List<KeysetTableEntity> keysetTableEntities,
        boolean hasNext,
        Cursor nextCursor
) {
}


// jpa
@RestController
@RequiredArgsConstructor
public class KeysetTableController {
    private final KeysetTableJpaRepository keysetTableJpaRepository;

    @GetMapping("/keyset")
    public ResponseEntity<KeysetResponseDto> getKeysetPaging(Cursor nextCursor, Pageable pageable) {
        int pageSize = pageable.getPageSize();
        // hasNext를 판단하기 위해 요청된 size보다 1개 더 조회
        Pageable nextPageable = PageRequest.of(0, pageSize + 1);

        Slice<KeysetTableEntity> keysetTableEntitySlice =
                (nextCursor.no() == null || nextCursor.createDate() == null)
                        ? keysetTableJpaRepository.findAllKeyset(nextPageable)
                        : keysetTableJpaRepository.findAllKeyset(nextCursor.no(), nextCursor.createDate(), nextPageable);

        List<KeysetTableEntity> keysetTableEntities = keysetTableEntitySlice.getContent();
        boolean hasNext = false;
        // 조회 된 데이터가 요청한 size보다 큰 경우 추가 조회해야 되는 데이터가 있다고 판단
        if (keysetTableEntities.size() > pageSize) {
            keysetTableEntities = keysetTableEntities.subList(0, pageSize);
            hasNext = true;
        }

        return ResponseEntity.ok(
                new KeysetResponseDto(
                        keysetTableEntities,
                        hasNext,
                        keysetTableEntities.isEmpty()
                                ? null
                                : new Cursor(
                                keysetTableEntities.getLast().getNo(),
                                keysetTableEntities.getLast().getCreateDate()
                        )
                )
        );
    }
}


// querydsl
@RestController
@RequiredArgsConstructor
public class KeysetTableController {
    private final JPAQueryFactory jpaQueryFactory;

    @GetMapping("/keyset")
    public ResponseEntity<KeysetResponseDto> getKeysetPaging(Cursor nextCursor, Pageable pageable) {
        int pageSize = pageable.getPageSize();
        // hasNext를 판단하기 위해 요청된 size보다 1개 더 조회
        Pageable nextPageable = PageRequest.of(0, pageSize + 1);

        BooleanBuilder booleanBuilder = new BooleanBuilder();
        if (nextCursor.no() != null && nextCursor.createDate() != null) {
            booleanBuilder.and(
                    QKeysetTableEntity.keysetTableEntity.createDate.lt(nextCursor.createDate()).or(
                            QKeysetTableEntity.keysetTableEntity.createDate.eq(nextCursor.createDate()).and(
                                    QKeysetTableEntity.keysetTableEntity.no.lt(nextCursor.no())
                            )
                    )
            );
        }

        List<KeysetTableEntity> keysetTableEntities =
                jpaQueryFactory
                        .selectFrom(QKeysetTableEntity.keysetTableEntity)
                        .where(booleanBuilder)
                        .offset(nextPageable.getOffset())
                        .limit(nextPageable.getPageSize())
                        .orderBy(
                                QKeysetTableEntity.keysetTableEntity.createDate.desc(),
                                QKeysetTableEntity.keysetTableEntity.no.desc()
                        )
                        .fetch();

        boolean hasNext = false;
        // 조회 된 데이터가 요청한 size보다 큰 경우 추가 조회해야 되는 데이터가 있다고 판단
        if (keysetTableEntities.size() > pageSize) {
            keysetTableEntities = keysetTableEntities.subList(0, pageSize);
            hasNext = true;
        }

        return ResponseEntity.ok(
                new KeysetResponseDto(
                        keysetTableEntities,
                        hasNext,
                        keysetTableEntities.isEmpty()
                                ? null
                                : new Cursor(
                                keysetTableEntities.getLast().getNo(),
                                keysetTableEntities.getLast().getCreateDate()
                        )
                )
        );
    }
}

 

 

 

 

페이징 비교 정리

 

최종적으로 offset 기반의 페이징과 keyset 기반의 페이징을 비교 정리 해보겠습니다.

 

표를 이용하여 정리해 보면 다음과 같습니다.

 

구분 Offset 페이징 Keyset 페이징
페이징 개념 page를 선택하여 페이징 조회 마지막으로 본 데이터 이후 값으로 페이징 조회
요청 parameter page, size cursor, size
페이지의 깊이에 따른 성능 점점 느려짐 거의 동일함
대용량 데이터 조회에 적합 여부 미 적합 적합
주 사용처 page 점프가 가능한 pagination UI 무한 스크롤 (infinite scroll) UI
인덱스 활용 제한적으로 적용 (offset은 미 적용) 최적화 가능
다음 페이지 존재 여부 판단 Page가 자동 계산하여 알려줌 size+1개 만큼 조회하여 판단
정렬 변경 유연성 자유롭게 변경 가능 정렬이 변경될 때마다 cursor도 고려 필수 (nextCursor를 전달하면 조금 더 유연함)
구현 난이도 쉬움 복잡함

 

 

 

페이징의 방식마다 모두 각자의 장/단점이 존재합니다.

 

사용자에게 제공해야 하는 UI/UX가 어떤 지부터 시작하여, 우리가 가지고 있는 데이터의 개수가 얼마나 되는지 판단이 필요한 영역이 될 수 있습니다

 

 

 

 

 

 

 

이상으로 jpa 대용량 데이터 페이징, offset보다 keyset을 선택해야 하는 이유에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

 

 

728x90
반응형

댓글