[SpringBoot] BidderOwn No offset 적용기 및 서비스 개선

이의찬·2023년 7월 17일
0

Springboot

목록 보기
7/12

🐱 Github
https://github.com/BidderOwn/BidderOwn_BE

BidderOwn 프로젝트를 진행하며 홈 화면에 보여주는 데이터가 최신순으로 정렬되기 때문에 paging 방식에서 no offset으로 변경하였다.

No offset

일반적인 페이징 쿼리는 다음 같은 형태이다.

SELECT *
FROM items
WHERE ...
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이즈사이즈

이 쿼리의 문제점은 페이지 번호가 커질 수록 이전에 읽었던 행을 다시 읽어야 하는 문제때문에 뒤로 갈수록 성능이 좋지 않다.

예를 들어 1000번 페이지를 읽게 되면 1010번까지 모두 읽게 된다.

No offset 방식은 말그대로 offset을 사용하지 않고 유니크하고 중복되지 않은 값으로 조건을 걸고 limit만큼 가져오기 때문에 이전까지 데이터를 건너뛸 수 있기때문에 성능적인 이점을 볼 수 있다.

SELECT *
FROM items
WHERE ...
AND id < 마지막조회 id
ORDER BY id DESC
LIMIT 페이지사이즈

이제 프로젝트에 적용해보자.

적용해보기

쿼리 플랜

먼저 실제 프로젝트에 적용하기 전에 쿼리플랜으로 확인해보자
item row의 개수는 10만개이다.

1. 페이징 방식

2. no offset

페이징 방식의 type을 보면 index라고 한다. index는 인덱스가 스캔되는 것을 제외하면 풀스캔과 같다.
마찬가지로 no offset 방식의 type을 보면 range라고 적혀있다. range는 인덱스를 사용하여 주어진 범위 내에 있는 행들만 추출한다.

또한 쿼리 수행을 위해 찾아야하는 row의 개수도 2배정도 차이가 난다.(rows)

실제로 단순하게 SELECT 만 수행해봐도 페이징 방식은 0.056, no offset 방식은 0.001정도로 큰 차이가 난다.

아마도 데이터가 더 늘어나면 더 큰 차이를 보일 것이다.

프로젝트

public List<Item> findItems(Long lastItemId, int sortCode, String searchText, Pageable pageable) {
    return queryFactory
            .selectFrom(item)
            .where(
                    eqNotDeleted(),
                    ltItemId(lastItemId, pageable.getPageSize()),
                    eqToSearchText(searchText)
            )
            .orderBy(orderBySortCode(sortCode))
            // 최신순 이외에 기준에는 offset 적용
            .offset(pageable.getOffset()) 
            .limit(pageable.getPageSize())
            .fetch();
}

private BooleanExpression ltItemId(Long itemId) {
    if (itemId == null) {
        return null;
    }
    return item.id.lt(itemId);
}

간단하게 마지막 itemId 기준으로 가져오게 적용하면 된다.

실제로 빨라졌는지 테스트를 해보자.

테스트

아이템 10만개, 댓글, 좋아요, 입찰건 10개

Paging

TPS가 47로 좋지 않은 성능을 보여줬다.

No offset 적용

No offset 적용 후, TPS 가 144 로 약 3배정도 개선됨을 알 수 있다.

하지만 참고한 jojoldu 님의 블로그에서 no offset 방식으로 수 백배정도 성능 개선이 되었지만 현재 방식은 약 3배로 생각보다 성능이 많이 개선되지 않아서 의문이 들었다.

혹시 데이터 양의 차이인가 싶었지만 Postman으로 단건 조회를 하며 문제를 발견하였다.

문제점

  1. /api/v1/item/list?id=11&size=10

id가 작은 11번을 전송했을 때는 30ms정도 걸렸다.

  1. /api/v1/item/list-v2?id=100001&size=10

id가 비교적 큰 100001번을 전송하였을 때는 114ms정도 걸렸다.

분명 no offset 방식은 id 번호에 크기와 상관없이 성능이 잘 나와야하지만 id값이 클 수록 느려진다. 문제는 order by 쿼리에 있었다.

기존에 querydsl의 정렬 조건이다.

private OrderSpecifier<?>[] orderBySortCode(int sortCode) {
    List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();
    switch (sortCode) {
        case 2 -> orderSpecifiers.add(item.bids.size().desc());
        case 3 -> orderSpecifiers.add(item.expireAt.asc());
    }
    orderSpecifiers.add(item.id.desc());
    orderSpecifiers.add(item.itemStatus.desc());
    return orderSpecifiers.toArray(new OrderSpecifier[0]);
}

기본 정렬 기준은 다음과 같다.

  1. id 최신순으로 정렬
  2. 상태에 따라 정렬 (경매중 - 경매종료 순)

성능이 느린 이유는 바로 Mariadb의 SQL 동작방식에 있다.

위 쿼리를 간단하게 정리하면 다음과 같다.

SELECT *
FROM item
WHERE item.id < 100001
ORDER BY item.item_status DESC
LIMIT 9

쿼리가 동작할 때 아래와 같은 순서로 동작한다.

  1. FROM (+ JOIN)
  2. WHERE
  3. GROUP BY
  4. HAVING
  5. SELECT
  6. ORDER BY
  7. LIMIT / OFFSET

여기서 WHERE 후 ORDER BY가 적용되기 때문에 item.id < 100001인 데이터를 전부 가져온 후, 정렬을 하게 된다.
따라서 가져와야 할 데이터가 많아지는 만큼 정렬해야 되는 데이터가 많아지기 때문에 성능적으로 큰 개선이 이루어지지 않았다.

해결1 - WHERE 조건 구체화

단순하게 최신순으로 가져오는 것이기 때문에 다음과 같이 WHERE 조건을 좀 더 구체화할 수 있을 것 같다.
item.id < 100001 AND item.id > 99990

// querydsl
private BooleanExpression betweenItemId(Long itemId, int size) {
    if (itemId == null) {
        return null;
    }
    // itemId 99990 < itemId < 100001
    return item.id.lt(itemId).and(item.id.gt(itemId - size - 1));
}

확실히 TPS가 719정로로 성능은 개선됨을 알 수 있다.

문제는 99990 ~ 100001 사이에 아이템 하나가 삭제될 경우에 예상한 대로 9개가 아닌 8개를 가져오면서 정상적으로 동작하지 않게 된다.

이 문제는 서비스적으로 개선하면서 동시에 성능 또한 개선할 수 있을 것 같다.

해결2 - 서비스 개선

기존에는 경매 중인 상품 -> 경매 종료 상품 순으로 정렬하지만 사실 사용자 입장에서는 경매 종료된 상품까지 다 찾아보고 싶은 경우는 많지 않을 것이다.

따라서 아래 이미지 같이 필터 조건을 추가하여서 서비스 적으로 개선하면서 WHERE 조건을 구체화하여서 성능을 개선하였다.

// querydsl
public List<Item> findItems(
		...
        boolean isAll
) {
    return queryFactory
            .selectFrom(item)
            .where(
                    ltItemId(lastItemId),
                    ...
                    eqBidding(isBidding)
            )
            ...
}

isBidding 조건을 url query parameter로 받아서 사용자의 필요에 따라 상품 리스트를 볼 수 있게 구현하였다.

마지막으로 똑같은 조건으로 테스트하였다.

비슷한 성능을 보여주었다.

회고

인덱스를 적용하여 해결할 수 있겠지만 카디널리티가 낮고, 서비스적으로 풀어낼 수 있기 때문에 이렇게 해결해보았다. 이 문제를 해결하며 데이터베이스 쿼리 순서 등 기본적인 원리를 깊게 학습할 필요성을 느꼈다.

참고

https://jojoldu.tistory.com/528

1개의 댓글

comment-user-thumbnail
2023년 7월 17일

좋은 글 잘 읽었습니다, 감사합니다.

답글 달기