[Database] No Offset 를 이용한 페이징 조회 개선하기
🎯 기존 페이징 조회 문제와 No OffSet 을 통한 개선 방안
먼저 일반적으로 사용하는 페이징 조회 쿼리 입니다. Home 게시물에 대한 페이징 조회와 home_id 에 DESC 를 걸어 최신순으로 가져옵니다.
기본적인 페이징 조회 쿼리
SELECT *
FROM home
WHERE 필터링조건
ORDER BY home_id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈
이러한 페이징 조회 쿼리는 데이터가 적은 100, 10000건 정도의 환경에서는 큰 문제없이 동작합니다. 하지만, 100000 건 이상의 많은 양의 데이터에 대한 페이징 조회는 다음과 같은 이유때문에 성능이 저하됩니다.
만약 페이지 번호(offset) 이 10000 이고, 페이지 사이즈(limit) 가 10 이면 총 10,010 개의 행을 읽어야 합니다. 그중 10000 개의 행은 사용하지 않고 버리게 됩니다. 만약 10010, 10020, 10030 .. 처럼 계속해 페이징 조회가 발생하면 읽지 않는 행의 개수는 점진적으로 늘어나는 문제가 발생하는 것 입니다.
그래서 고안해낸 방법이 바로 No Offset 방식입니다. 이 방식은 조회 시작 부분을 인덱스로 빠르게 찾아 매번 첫 페이지만 읽도록 하는 방식입니다.
No Offset 을 이용한 페이징 조회 쿼리
SELECT *
FROM home
WHERE 필터링조건
AND id < 마지막조회 ID
ORDER BY home_id DESC
LIMIT 페이지사이즈
이처럼 마지막 조회 home_id 에 대한 조건문을 사용해 이전 페이지 전체를 건너뛰어 버려야할 행에 대해서는 조회하는 않는 방법으로 개선할 수 있습니다. 즉, 아무리 데이터가 많아지더라고 항상 동일한 성능을 보장할 수 있습니다.
✅ 기존 페이징 코드
@Override
public List<HomeOverviewResponse> findSellHomePage(Pageable pageable) {
return query
.select(qHome, qUser)
.from(qHome)
.join(qUser).on(qHome.userId.eq(qUser.id))
.leftJoin(qHome.images, qHomeImage).fetchJoin()
.where(qHome.homeStatus.eq(HomeStatus.FOR_SALE))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(qHome.id.desc())
.fetch()
.stream()
.map(tuple -> HomeMapper.INSTANCE.toOverviewResponse(
tuple.get(qHome), tuple.get(qUser))).toList();
}
.offset 을 이용해 페이지 번호와 .limit 를 통해 조회할 사이즈만큼 페이징 조회를 하는 쿼리 입니다. 이러한 방식은 데이터 양이 많아 질수록 버려야 하는 행 역시 많아 집니다.
즉, 모든 행에 접근 후 일부만 반환하기 때문에 데이터가 많아질수록 느려집니다.
✅ No Offset 방식의 페이징 조회
@Override
public List<HomeOverviewResponse> findSellHomePage(Long homeId, int pageSize) {
BooleanBuilder condition = new BooleanBuilder();
if (homeId != null) {
condition.and(qHome.id.lt(homeId)); // 내림차순 커서 페이징 조건
}
condition.and(qHome.homeStatus.eq(HomeStatus.FOR_SALE));
return query
.select(qHome, qUser)
.from(qHome)
.join(qUser).on(qHome.userId.eq(qUser.id))
.leftJoin(qHome.images, qHomeImage).fetchJoin()
.where(condition)
.orderBy(qHome.id.desc())
.limit(pageSize)
.fetch()
.stream()
.map(tuple -> HomeMapper.INSTANCE.toOverviewResponse(
tuple.get(qHome), tuple.get(qUser)))
.toList();
}
동적으로 homeId 를 제어하기 위해 BooleanBuilder 를 이용해 마지막에 조회한 homeId 보다 작은 것을 조회 합니다. (최신순 )
그리고 판매중인 집 상태에 대한
조건도 추가해줬습니다.
자, 이제 쿼리 튜닝 전, 튜닝 후에 대한 성능을 테스트 해보겠습니다.
✅ 쿼리 튜닝 전 테스트
@Test
void 집_페이징_조회_테스트() {
// when
long start = System.currentTimeMillis(); // 시작 시간 측정
List<HomeOverviewResponse> sellHomePage = homeRepository.findSellHomePage(PageRequest.of(10, 5));
long end = System.currentTimeMillis(); // 종료 시간 측정
// then
System.out.println("findSellHomePage 실행 시간: " + (end - start) + "ms");
Assertions.assertThat(sellHomePage.size()).isEqualTo(5);
}
✅ 결과
총 970 ms 의 시간이 걸렸습니다. DB 에 접근해 조회하는 순수 시간이 970ms 면 실제 운영환경에서는 API 요청, 응답, 네트워크 지연, 프론트 화면 렌더링 등을 고려하면 매우 긴 시간입니다.
✅ 쿼리 튜닝 후 테스트
@Test
void 집_페이징_조회_테스트() {
// when
long start = System.currentTimeMillis(); // 시작 시간 측정
List<HomeOverviewResponse> sellHomePage = homeRepository.findSellHomePage(10L, 5);
long end = System.currentTimeMillis(); // 종료 시간 측정
// then
System.out.println("findSellHomePage 실행 시간: " + (end - start) + "ms");
Assertions.assertThat(sellHomePage.size()).isEqualTo(5);
}
✅ 결과
총 100,000 건의 데이터로 테스트해본 결과 No OffSet 를 사용하지 않고 페이징 조회를 하면 970ms -> 220ms 로 약 약 77.32% 정도의 성능 개선이 되었음을 확인할 수 있습니다. 만약 1억건 이상의 데이터가 적재되어 있으면 수십, 수백배의 성능이 개선될 수 있음을 기대할 수 있습니다.
지금까지 No Offset 페이징 조회에서의 성능 개선 방법을 알아봤습니다. 규모가 적은 환경에서는 이와 같은 설계가 크게 의미 없을지는 몰라도 대용량의 데이터를 조회해야하는 환경에서는 도입하면 괜찮은 방법 처럼 보입니다.
하지만 이러한 NoOffset 은 순차적으로 다음 페이지 이동만 가능하며, 1페이지에서 갑자기 5페이지로 가는 기능등에 대해서는 처리할 수 없습니다. 다음 포스팅에서는 이를 해결하기 위한 방법에 대해 다시 알아보겠습니다.