멘지의 기록장

No Offset을 적용한 무한 페이징 구현하기 (성능 비교) 본문

SpringBoot

No Offset을 적용한 무한 페이징 구현하기 (성능 비교)

멘지 2024. 12. 20. 17:21

프로젝트를 진행하면서 내가 작성한 모든 리뷰를 조회하는 API를 페이징 처리를 하여 제공해야 했습니다.

 

처음에는 offset 방식을 사용하여 개발하였습니다.

하지만, offset 방식은 매번 Full Scan을 하여 성능이 좋지 않다는 것을 알게 되었고, 가장 큰 문제인 중복된 데이터가 보인다는 문제가 발생하였습니다.

 

해당 API가 페이지 단위가 아닌 무한 스크롤 형식으로 최근 작성한 리뷰 순서로 데이터를 제공하는 특징과 함께 중복 데이터 문제를 해결하고자 no offset 방식을 도입하였습니다.

 

no offset 방식을 도입하여 개발한 코드와 함께

offset과 no offset 방식의 성능차이가 얼마나 나는지 한번 확인해보도록 하겠습니다.


Offset

페이징을 사용하는 방법으로 2가지가 존재합니다.

 

그중 offset을 사용한 페이징 방법은

전체 데이터를 각각의 페이지로 나누어 요청한 페이지에 해당하는 데이터를 제공하는 형식의 방법입니다.

(SQL로 Pagination을 구현하기 위해 사용됩니다.)

 

예를 들어, 총 100개의 데이터가 있을 때 각 페이지에 10개씩 데이터가 있다고 가정한다면 총 10페이지가 나오게 됩니다.

보통 페이지 버튼과 함께 쓰이게 됩니다.

 

 

offset을 사용하였을 때 가장 큰 문제점이라면 다음 페이지로 넘어가는 도중 새로운 값이 들어왔을 때 

다음 페이지에서 중복된 값이 보일 수 있다는 점입니다.

 

현재 프로젝트에서도 이러한 중복된 데이터가 보이는 문제가 발생하여 이를 해결하고자 no offset 방식을 적용하였습니다.


No Offset

No offset 방식은 커서 페이징 방식으로 프론트가 가져간 마지막 row의 순서상 다음 row들을 n개 요청/응답하게 구현하는 방식입니다. 

가장 마지막 id를 기준으로 그 다음 id부터 데이터를 제공하면 되기 때문에 데이터가 중복해서 나오는 문제를 해결할 수 있습니다.

 

이를 사용하면 무한 스크롤을 구현할 수 있다는 장점을 갖고 있습니다.

 

No offset 방식은 SQL문에서 offset을 사용하지 않고 where을 통해 조건을 작성하기 때문에 성능상에서도 큰 장점을 지닙니다.

 

일반적으로 select 쿼리의 실행 순서는 다음과 같습니다.

from -> where -> group by -> having -> order by -> select

 

offset의 경우 limit과 offset은 where 절 이후에 작성하며,

아래처럼 limit 10 offset 100 이면 100개의 row를 읽은 후 버리고 나서 이후 10개의 row를 가져옵니다.

select *
from review
limit 10
offset 100

 

그렇다면 100개의 row를 다 읽어야 하기 때문에 시간적 비용이 많이 들게 됩니다.

 

하지만, no offset을 사용한다면 where절에서 조건을 작성하여 조건에 해당하는 값인

id가 100 이상인 값부터 10개의 row를 읽기 때문에 시간적 비용을 없앨 수 있어 성능적으로 큰 이점을 가지게 됩니다.

select *
from review
where id > 100
limit 10

 

위의 No offset 방식으로 구현한 코드를 보여드리고,

offset을 사용했을 때와 no offset을 사용했을 때의 성능 차이를 비교해보겠습니다.


Controller

@Operation(summary = "내 리뷰 모아보기")
@GetMapping("/review")
public ResponseEntity<ResponseTemplate<?>> getMyReview(
        @AuthenticationPrincipal Long userId,
        @RequestParam Long lastReviewId,
        @RequestParam(defaultValue = "5") Long size) {

    MyReviewResponseList responseList = reviewService.getMyReview(userId, lastReviewId, size);

    return ResponseEntity
            .status(HttpStatus.OK)
            .body(ResponseTemplate.from(responseList));
}

 

Service

public MyReviewResponseList getMyReview(Long userId, Long lastReviewId, Long size) {

    List<MyReviewResponse> reviewSimpleResponse = (lastReviewId == 0)
            ? reviewRepository.getMyFirstReview(userId, size) : reviewRepository.getMyReview(userId, lastReviewId, size);

    Boolean hasNext = reviewRepository.hasNextMyReview(userId, lastReviewId, size);

    return MyReviewResponseList.of(hasNext, reviewSimpleResponse);
}

 

Repository

@Override
public List<MyReviewResponse> getMyFirstReview(Long userId, Long size) {
    return jpaQueryFactory.select(Projections.constructor(MyReviewResponse.class,
                    review.id,
                    review.place.category,
                    review.place.name,
                    review.content,
                    review.rating,
                    review.date,
                    review.reviewImageUrl
            ))
            .from(review)
            .where(review.user.id.eq(userId))
            .orderBy(review.id.desc())
            .limit(size)
            .fetch();
}

@Override
public List<MyReviewResponse> getMyReview(Long userId, Long lastReviewId, Long size) {
    return jpaQueryFactory.select(
                    Projections.constructor(MyReviewResponse.class,
                            review.id,
                            review.place.category,
                            review.place.name,
                            review.content,
                            review.rating,
                            review.date,
                            review.reviewImageUrl
                    )
            )
            .from(review)
            .where(review.user.id.eq(userId), review.id.lt(lastReviewId)) // lastReviewId를 기준으로 필터링
            .orderBy(review.id.desc())
            .limit(size) // 가져올 리뷰 수 제한
            .fetch();
}

@Override
public Boolean hasNextMyReview(Long userId, Long lastReviewId, Long size) {
    return jpaQueryFactory.selectOne()
            .from(review)
            .where(review.user.id.eq(userId), review.id.lt(lastReviewId - size))
            .fetchFirst() != null;
}

 

DTO

public record MyReviewResponse(
        Long reviewId,
        Category category,
        String name,
        String content,
        BigDecimal rating,
        @JsonFormat(pattern = "yyyy-MM-dd")
        LocalDateTime date,
        String reviewImageUrl
) {
    @JsonProperty("reviewImageUrl")
    public String getFormattedImageUrl() {
        return reviewImageUrl == null ? "" : reviewImageUrl;
    }
}

 

DTO List

public record MyReviewResponseList(
        Boolean hasNext,
        List<MyReviewResponse> myReviewResponsesList
) {
    public static MyReviewResponseList of(Boolean hasNext, List<MyReviewResponse> myReviewResponsesList) {
        return new MyReviewResponseList(hasNext, myReviewResponsesList);
    }
}

 

가장 처음 API를 호출할 때는 lastReviewId에 0을 넣어주면 되고,

이후 API를 다시 호출할 경우에는 lastReviewId에 프론트가 가져간 가장 마지막 리뷰 id를 넣고, 필요한 리뷰의 갯수를 size에 넣어서 요청하면 됩니다. 

 

 해당 기능에 QueryDsl을 사용함으로써 컴파일 시점에 SQL문에 문제가 있는지를 확인하고, SQL문을 편하게 작성하고자 하였습니다.

 

hasNext의 경우에 다음 페이지에 데이터가 있는지를 알려주는 변수로, 

false일 경우에는 다음 제공할 데이터가 없다는 것이기 때문에

프론트에서 추가적인 API 요청을 하지 않고 처리하면 되기에 불필요한 요청을 하지 않아도 되는 장점을 갖고 있습니다.


성능 비교

성능을 비교하기 위해 약 100,000개의 더미 데이터를 넣어 테스트를 진행하였습니다.

 

offset을 사용했을 때의 성능 비교를 위한 SQL문입니다.

offset 이후의 숫자를 바꾸어가며 테스트를 진행하였습니다.

explain analyze
    select *
    from review
    limit 10
    offset 10

 

no offset을 사용했을 때의 성능 비교를 위한 SQL문입니다.

where 절에서 사용한 숫자를 바꾸어가며 테스트를 진행하였습니다.

explain analyze
    select *
    from review
    where id > 10
    limit 10

 

 

1. 11~20번의 데이터를 확인하고 싶은 경우

  • offset 사용했을 때
-> Limit/Offset: 10/10 row(s)  (cost=10221 rows=10) (actual time=0.191..0.193 rows=10 loops=1)
    -> Table scan on review  (cost=10221 rows=99642) (actual time=0.183..0.189 rows=20 loops=1)

 

  • no offset일 때
-> Limit: 10 row(s)  (cost=10011 rows=10) (actual time=0.0835..0.159 rows=10 loops=1)
    -> Filter: (review.id > 10)  (cost=10011 rows=49821) (actual time=0.0819..0.156 rows=10 loops=1)
        -> Index range scan on review using PRIMARY over (10 < id)  (cost=10011 rows=49821) (actual time=0.0783..0.15 rows=10 loops=1)

 

offset을 사용했을 때는 10개의 데이터를 읽고 버린 후 10개의 데이터를 가져오기 때문에

모든 row를 읽은 후 제외를 하므로 Table scan을 사용한 것을 확인할 수 있다.

 

no offset일 때는 primary key (id)에 index가 설정되어 있어 이를 사용하여 where 절을 실행하므로

Index range scan을 사용하는 것을 확인할 수 있다.

 

 

2. 101~111번의 데이터를 확인하고 싶은 경우

  • offset 사용했을 때
-> Limit/Offset: 10/100 row(s)  (cost=10215 rows=10) (actual time=0.247..0.252 rows=10 loops=1)
    -> Table scan on review  (cost=10215 rows=99642) (actual time=0.153..0.241 rows=110 loops=1)

 

  • no offset일 때
-> Limit: 10 row(s)  (cost=10010 rows=10) (actual time=0.0405..0.06 rows=10 loops=1)
    -> Filter: (review.id > 100)  (cost=10010 rows=49821) (actual time=0.0387..0.0573 rows=10 loops=1)
        -> Index range scan on review using PRIMARY over (100 < id)  (cost=10010 rows=49821) (actual time=0.0353..0.0531 rows=10 loops=1)

 

 

3. 1001~1011번의 데이터를 확인하고 싶은 경우

  • offset 사용했을 때
-> Limit/Offset: 10/1000 row(s)  (cost=10215 rows=10) (actual time=0.607..0.609 rows=10 loops=1)
    -> Table scan on review  (cost=10215 rows=99642) (actual time=0.0685..0.561 rows=1010 loops=1)

 

  • no offset일 때
-> Limit: 10 row(s)  (cost=10010 rows=10) (actual time=0.0365..0.0485 rows=10 loops=1)
    -> Filter: (review.id > 1000)  (cost=10010 rows=49821) (actual time=0.035..0.0464 rows=10 loops=1)
        -> Index range scan on review using PRIMARY over (1000 < id)  (cost=10010 rows=49821) (actual time=0.0319..0.0425 rows=10 loops=1)

 

 

4. 10001~10011번의 데이터를 확인하고 싶은 경우

  • offset 사용했을 때
-> Limit/Offset: 10/10000 row(s)  (cost=10210 rows=10) (actual time=30.1..30.1 rows=10 loops=1)
    -> Table scan on review  (cost=10210 rows=99642) (actual time=0.0992..29.6 rows=10010 loops=1)

 

  • no offset일 때
-> Limit: 10 row(s)  (cost=10004 rows=10) (actual time=0.0515..0.0816 rows=10 loops=1)
    -> Filter: (review.id > 10000)  (cost=10004 rows=49821) (actual time=0.0502..0.0792 rows=10 loops=1)
        -> Index range scan on review using PRIMARY over (10000 < id)  (cost=10004 rows=49821) (actual time=0.0472..0.0734 rows=10 loops=1)

 

 

5. 10001~10011번의 데이터를 확인하고 싶은 경우

  • offset 사용했을 때
-> Limit/Offset: 10/100000 row(s)  (cost=10102 rows=0) (actual time=78.5..78.5 rows=10 loops=1)
    -> Table scan on review  (cost=10102 rows=99653) (actual time=0.07..73.4 rows=100010 loops=1)

 

  • no offset일 때
-> Limit: 10 row(s)  (cost=2.47 rows=10) (actual time=0.0231..0.0364 rows=10 loops=1)
    -> Filter: (review.id > 100000)  (cost=2.47 rows=11) (actual time=0.0222..0.0346 rows=10 loops=1)
        -> Index range scan on review using PRIMARY over (100000 < id)  (cost=2.47 rows=11) (actual time=0.02..0.0319 rows=10 loops=1)

 

 

위의 성능을 비교해봤을 때 다음과 같은 결과가 나오는 것을 확인할 수 있다.

 

offset을 사용했을 때 : 0.191ms -> 0.247ms -> 0.607ms -> 30.1ms -> 78.5ms

no offset일 때 : 0.083ms -> 0.0405ms -> 0.0365ms -> 0.0515ms -> 0.0231ms

 

offset은 읽어야 하는 데이터의 수가 많을수록 급진적으로 조회 시간이 늘어나 성능이 안좋아지는 반면,

no offset은 where 절을 사용하기 때문에 균일하게 일정한 성능을 보이는 것을 확인할 수 있습니다.


결과 분석

결과를 토대로 그래프를 그리면 다음과 같습니다.

 

데이터의 수가 더 많아질수록 성능 측면에서 큰 차이 (약 86% 성능 향상)를 보이기 때문에 no offset을 사용함으로써 큰 이점을 얻을 수 있다는 것을 확인할 수 있었습니다.