[SpringBoot] 스크롤 API (Offset vs Keyset-Filtering)
Programming/Spring Boot

[SpringBoot] 스크롤 API (Offset vs Keyset-Filtering)

실무에서는 대량의 데이터를 조회한 뒤 수정하는 일이 종종 발생합니다. 예를 들어, 브랜드명이 매일우유에서 매일유업으로 바뀐다면 판매상품 내 brandName 필드값이 매일우유인 엔티티를 모두 찾아 브랜드명을 매일유업으로 바꿔주어야 합니다. 이때 변경해야할 엔티티가 수만건이라면 페이징 처리를 안할 시 Out-of-Memory가 발생할 수 있습니다. 따라서, 우리가 흔히 부르는 스크롤 방식으로 100건씩 끊어서 조회한 뒤 update를 하는 방법을 취하게 됩니다.

 

SpringBoot 3.1 버전부터는 스크롤 API를 공식적으로 지원합니다. Spring Data JPA 3.1 버전을 추가함으로써 우리는 스크롤 방식의 페이징 처리를 간단하게 구현할 수 있습니다.

 

이번 글에서는 Offset 방식과 Keyset-Filtering 방식의 차이에 대해 간단히 짚어보고, SpringBoot 3.1부터 지원하는 스크롤 API에 대해 알아보겠습니다.

 

모든 예제는 SpringBoot 3.1, Mysql 8.0 버전 기준으로 작성했습니다.

 

 

Offset 방식 vs Keyset-Filtering 방식

스크롤 방식에는 크게 2가지가 있습니다.

  • Offset 방식
  • Keyset-Filtering 방식

 

Offset 방식

Offset 방식은 데이터베이스에서 N개의 결과를 건너 뛰고 조회하는 방식을 말합니다.

SELECT *
FROM TABLE_NAME
LIMIT 10
OFFSET 110;

 

서버 단에서는 N개의 결과를 건너 뛰고 조회하지만, 데이터베이스는 N개 까지의 row를 모두 순서대로 읽는 과정을 거치게 됩니다. 이 과정에서 데이터베이스에 많은 부하가 가해지기 때문에 페이징 처리시 성능 저하가 발생합니다.

 

또 다른 문제점은 N개의 결과를 조회하다가 새로운 row가 추가되었을 때 발생합니다. Offset 방식으로 페이징 처리를 하는 도중 새로운 row가 앞에 추가되면 이전 페이징과 중복되는 결과가 조회될 수 있습니다. 단순 조회라면 상관 없겠지만 조회 후 update를 한다면 의도치 않은 사이드 이펙트가 발생할 위험이 존재합니다.

 

Offset 방식은 offset 키워드를 생략할 수 있습니다.

SELECT *
FROM TABLE_NAME
LIMIT 110, 10;

 

Keyset-Filtering 방식

Keyset-Filtering이란 특정 id 기준으로 where 절을 사용하여 데이터를 조회하는 방법을 말합니다. Keyset-Filtering을 사용하면 Offset 방식이 가지는 1)이전의 데이터를 반복적으로 읽는 문제2)중복되는 데이터 조회 문제 를 해결할 수 있습니다. no-offest 또는 더보기, 커서 방식으로도 불립니다.

SELECT *
FROM TABLE_NAME
WHERE id > 110
LIMIT 10
ORDER BY id;

 

Spring 공식 문서에 따르면 Keyset-Filtering에 사용하는 속성은 not-null이어야한다고 적혀 있습니다. 만약 nullable한 속성을 Keyset-Filtering 기준 속성으로 사용하면 예기치 않은 결과를 낳을 수 있습니다.

Keyset-Filtering requires the keyset properties (those used for sorting) to be non-nullable. This limitation applies due to the store specific null value handling of comparison operators as well as the need to run queries against an indexed source. Keyset-Filtering on nullable properties will lead to unexpected results.

 

Keyset-Filtering 방식은 임의의 특정 페이지로 바로 이동하는 것은 불가능하다는 단점이 존재합니다. 만약 일반적인 게시판처럼 1페이지에서 바로 5페이지로 이동해야 한다면 DB에 가해지는 부하를 감수하더라도 Offset 방식을 사용해야만 합니다.

 

 

Scroll API

Spring Data 3.1.0 버전부터 제공하는 스크롤 API는 대량의 결과를 작은 청크 단위로 iterate하는 기능을 제공합니다. 스크롤은 스크롤 타입(오프셋 방식 or 키셋 방식), sort, limit로 구성됩니다.

 

Spring Data JPA의 쿼리메서드 기능을 구현하면 정렬 기능을 손쉽게 구현할 수 있습니다. First 키워드를 이용해 아래 3개의 메서드를 Repository에 선언하겠습니다.

public interface BookReviewRepository extends Repository<BookReviewEntity, Long> {

    Window<BookReviewEntity> findFirst5ByBookRating(String bookRating, OffsetScrollPosition position);

    Window<BookReviewEntity> findFirst10ByBookRating(String bookRating, OffsetScrollPosition position);

    Window<BookReviewEntity> findFirst3ByBookRating(String bookRating, KeysetScrollPosition position);
}

 

 

엔티티는 다음과 같습니다.

@Getter
@Setter
@Entity
@Table(name = "book_review")
public class BookReviewEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reviewsId;

    private String userId;

    private String isbn;

    private String bookRating;
}

 

 

Repository를 자세히 보면 반환 타입이 Window인 것을 알 수 있습니다. Window의 설명을 보면 Slice와 유사하나 검색에 필요하면 몇몇 조건이 빠져있다는 점이 나와 있습니다.

org.springframework.data.domain.Window

 

 

 

Window 인터페이스를 살펴보면 Slice보다는 선언된 메서드가 적은 것을 직접 확인할 수 있습니다. 편의상 정적 팩토리 메서드와 default 메서드는 생략하겠습니다.

public interface Window<T> extends Streamable<T> {

    int size();
    
    boolean isEmpty();
    
    List<T> getContent();
    
    boolean hasNext();
    
    ScrollPosition positionAt(int index);
    
    <U> Window<U> map(Function<? super T, ? extends U> converter);
}

 

Service 단에서 Offset 방식과 Keyset-Filtering 방식이 어떻게 다른지 코드로 한번 살펴보겠습니다.

 

 

 

Scrolling using Offset

위의  See Also: ScrollPosition  에서도 알 수 있듯이, Scroll API는 ScrollPositon과 함께 사용합니다. ScrollPositon 인터페이스의 구현체에는 OffsetScrollPosition과 KeysetScrollPosition이 있습니다.

 

OffsetScroll을 서비스 단에서 구현한 예제입니다. 처음 Repository에서 조회할 땐 ScrollPosition.offset() 을 인자로 넘긴 뒤 그 후로 Window.positionAt()을 인자로 사용하는 것을 알 수 있습니다. 이 과정은 Window.hasNext()가 false 때까지 반복합니다.

 

@RequiredArgsConstructor
@Service
public class BookReviewService {

    private final BookReviewRepository repository;

    public List<BookReviewEntity> getBooksUsingOffset(String rating) {
        OffsetScrollPosition offset = ScrollPosition.offset();

        Window<BookReviewEntity> bookReviews = repository.findFirst5ByBookRating(rating, offset);
        List<BookReviewEntity> bookReviewsResult = new ArrayList<>();
        do {
            var content = bookReviews.getContent();
            bookReviewsResult.addAll(content);
            bookReviews = repository.findFirst5ByBookRating(rating, (OffsetScrollPosition) bookReviews.positionAt(bookReviews.size() - 1));
        } while (!bookReviews.isEmpty() && bookReviews.hasNext());

        return bookReviewsResult;
    }
}

 

 

WindowIterator를 사용하면 코드가 훨씬 간결해집니다.

    public List<BookReviewEntity> getBooksUsingOffSetFilteringAndWindowIterator(String rating) {
        WindowIterator<BookReviewEntity> bookReviews = WindowIterator.of(position -> repository
                        .findFirst5ByBookRating(rating, (OffsetScrollPosition) position))
                .startingAt(ScrollPosition.offset());
        List<BookReviewEntity> bookReviewsResult = new ArrayList<>();
        bookReviews.forEachRemaining(bookReviewsResult::add);

        return bookReviewsResult;
    }

 

SQL문을 확인하면 offset 방식으로 DB에 쿼리가 날라감을 알 수 있습니다.

 

 

 

Scrolling using Keyset-Filtering

다음으론 Keyset-Filtering 방식을 서비스단에서 구현한 예제입니다. WindowIterator.of().startingAt(ScrollPosition.keyset())으로 인스턴스를 만든 뒤, Window.hasNext()가 false일 때까지 결과리스트에 add합니다.

@RequiredArgsConstructor
@Service
public class BookReviewService {

    private final BookReviewRepository repository;

    public List<BookReviewEntity> getBooksUsingKeySetFiltering(String rating) {
        WindowIterator<BookReviewEntity> bookReviews = WindowIterator.of(position -> repository
                        .findFirst3ByBookRating(rating, (KeysetScrollPosition) position))
                .startingAt(ScrollPosition.keyset());
        List<BookReviewEntity> bookReviewsResult = new ArrayList<>();
        bookReviews.forEachRemaining(bookReviewsResult::add);

        return bookReviewsResult;
    }
}

 

SQL문을 확인하면 keyset-filtering 방식으로 DB에 쿼리가 날라감을 알 수 있습니다.

 

 

 

만약 단건씩 조회해서 특정한 로직을 수행하려면 아래와 같이 WindowIterator.hasNext()를 while문 안에 넣어주면 됩니다. 이때 쿼리는 Window의 크기마다 발생합니다. 즉, 여기선 3건당 1번씩 조회 쿼리가 날라가게 됩니다.

    public List<BookReviewEntity> getBooksUsingKeySetFiltering_refactoring(String rating) {

        WindowIterator<BookReviewEntity> bookReviews = WindowIterator.of(position -> repository
                        .findFirst3ByBookRating(rating, (KeysetScrollPosition) position))
                .startingAt(ScrollPosition.keyset());
        List<BookReviewEntity> bookReviewsResult = new ArrayList<>();

        while (bookReviews.hasNext()) {
            var entity = bookReviews.next();
            
            // (로직 수행)
           
            bookReviewsResult.add(entity);
        }
        return bookReviewsResult;
    }

 

 

 

마무리

이번 글에서는 SpringBoot 3.1부터 지원하는 스크롤 API에 대해 알아보았습니다. 스크롤 API가 도입되기 이전에도 where 조건절 내 id를 통해 커서 방식의 페이징 방식은 흔하게 사용되었습니다. 만약 최신 버전의 스프링부트를 사용하고 있다면 기존의 페이징 처리를 Keyset-Filtering로 전환하여 좀 더 쉽게 페이징 처리를 할 수 있을 것 같습니다.

 

 

 

참고 자료