개요
이전에 DB 타입의 결정으로 조회 성능을 대폭 향상시킬 수 있었다.
이번에는 페이지네이션의 방식 변경으로 조회 성능을 개선시켜보려 한다.
현재 프로젝트에는 상점을 별점, 생성일 등의 순서대로 정렬하거나 필터링하는 기능이 존재한다. 해당 기능의 결과는 페이지네이션을 통해 일부분의 데이터만 응답하게 된다.
현재는 구현이 간단한 offset 기반의 페이지네이션 방식을 사용하여 구현하고 있다. 인덱스를 생성하여 간단하게 조회 성능을 향상 시킬 수는 있지만 커서 기반 페이지네이션이라는 방식을 새로이 알게 되어 성능 비교를 해보려 한다.
Offset vs Cursor
페이지네이션을 구현하는 대표적인 방식에는 offset 기반, cursor 기반 페이지네이션이 존재한다.
Offset 기반 페이지네이션
offset 기반 페이지네이션은 query의 offset
, limit
절을 사용하여 페이징하는 방식이다.
select *
from p_shop s
order by s.rating desc, shop_id
**offset 500000 limit 50;**
위의 쿼리는 50만번째 데이터부터 50개의 데이터를 반환한다.
장점
- 구현이 간단하며 SQL 표준을 사용한다.
- 페이지 번호를 직접 지정할 수 있어 직관적이다.
- JPA, Spring 에서 지원하는 유틸 기능이 존재한다.
Spring Boot 에서는 Controller에서 간단하게 페이징 정보를 매핑할 수 있는 기능을 지원한다.
@RestController
public class Offset {
@GetMapping("/offset")
public void offset(@PageableDefault Pageable pageable) {
// ...
}
}
Spring Data JPA를 사용하면 Pageable 객체를 통해 쉽게 페이징 기능을 구현할 수 있다.
Page<OffsetEntity> findAll(Pageable pageable);
대용량 데이터 셋에서 성능이 저하되는 이유
offset 방식은 많은 데이터 셋에서 후미의 데이터를 조회할 때, 성능이 떨어진다는 단점이 존재한다.
이는 offset 기반 쿼리가 동작하는 방식때문에 발생하는 성능 저하인데, limit
와 offset
문법은 특정 범위의 데이터를 탐색하기 위해 조회된 집합 전체를 순차적으로 탐색한다.
select *
from p_shop s
order by s.rating desc, shop_id
offset 500000 limit 50;
즉, 위의 쿼리는 order by절 까지 진행되어 정렬된 후의 데이터 집합을 500,000번째 데이터까지 순차적으로 탐색한 뒤 앞의 데이터는 모두 버리고 이후 50개의 데이터만 반환하게 된다.
Cursor 기반 페이지네이션
cursor 방식은 마지막으로 조회한 레코드의 식별자, 특정 값을 기준으로 다음 데이터를 가져오는 방식이다.
select *
from p_shop s
where (s.rating < 3) OR (s.rating = 3 and s.shop_id > 5842798)
order by rating desc, shop_id
limit 50;
위의 쿼리는 별점이 3보다 작거나, 별점이 3이지만 마지막으로 조회한 데이터 보다 뒤에 있는 가게를 조회한 뒤 50개의 데이터를 반환한다.
Cursor 방식은 특정 데이터를 선택한 뒤 이후의 데이터를 가져올 수 있기에 성능상의 이점이 존재한다.
다만, 특정 데이터를 선택하기 위한 Cursor 값을 관리해줘야 한다는 단점이 존재한다.
또한, 이전 페이지로 돌아가기 등의 기능 구현이 어렵기에 페이지 번호로 이동하는 기능에서는 장점이 퇴색된다.
결론
페이지네이션 방식 선택 및 성능 개선 결과 (81% 성능 개선)
총 100만 건의 데이터에서 50만 번째 데이터부터 50개의 데이터를 조회할 때,
offset 기반 페이지네이션과 cursor 기반 페이지네이션의 비교 결과이다.
인덱스 미사용 | 인덱스 사용 | |
---|---|---|
offset 기반 페이지네이션 | 평균 2s 100ms | 평균 400ms |
cursor 기반 페이지네이션 | 평균 400ms | 평균 300ms |
기존의 offset 기반 페이지네이션(인덱스 미사용)에서 cursor 기반 페이지네이션 방식(인덱스 미사용)으로 변경하여
2s 100ms
→ 400ms
약 81%의 성능 개선이 이루어졌다.
인덱스를 사용하지 않은 이유
테스트 결과 offset 기반 페이지네이션에서 인덱스를 사용하는 방식이 가장 조회 효율이 좋다고 판단되었다.
다만, 위의 테스트는 수많은 필터링과 정렬의 조합 에서 한 가지 경우의 수 만을 테스트 한 결과이다.
offset 방식으로 모든 조회 쿼리에서 위와 같은 효율을 뽑아내기 위해서는 수많은 경우의 수에 모두 인덱스를 걸어야 한다. 따라서 인덱스를 사용하지 않음에도 조회 성능이 준수한 커서기반 페이지네이션을 채택했다.
만약 특정 경우의 수에 한해서 과반수 이상의 트래픽이 발생한다면 offset 방식을 사용하며, 트래픽이 발생하는 경우의 수에만 인덱스를 생성하는 방식도 괜찮다고 생각한다.
의문
cursor 기반 페이네이션도 이론대로라면 인덱스가 생성되었을 때, 효율이 크게 개선되어야 하는데, 미미하게 개선된 결과를 확인할 수 있었다. 실행 계획을 비교해보면 아래와 같은데
인덱스 미사용
Limit (cost=39023.02..39028.86 rows=50 width=668)
-> Gather Merge (cost=39023.02..83560.83 rows=381726 width=668)
Workers Planned: 2
-> Sort (cost=38023.00..38500.16 rows=190863 width=668)
"Sort Key: rating DESC, shop_id"
-> Parallel Seq Scan on p_shop s (cost=0.00..31682.67 rows=190863 width=668)
Filter: ((rating < '3'::double precision) OR ((rating = '3'::double precision) AND (shop_id > 5842798)))
인덱스 사용
Limit (cost=0.42..15.05 rows=50 width=668)
-> Index Scan using idx_rating_shop_id_desc_asc on p_shop s (cost=0.42..133979.00 rows=458072 width=668)
Filter: ((rating < '3'::double precision) OR ((rating = '3'::double precision) AND (shop_id > 5842798)))
유의 깊게 보여지는 부분은 인덱스 미사용 실행계획에서의 Parallel Seq Scan
이다. 병렬 처리로 인해 성능이 좋게 나오는 것 같은데, 병렬 처리는 여러 스레드를 사용하기에 트래픽이 증가함에 따라 성능 저하가 우려된다.
해당 테스트는 본의아니게 PostgreSQL에서 진행되었기에 추후 조금 더 학습을 해야될 것 가다.
'트러블슈팅' 카테고리의 다른 글
MSA에서 선착순 쿠폰 발급 서비스 설계하기 (0) | 2025.03.11 |
---|---|
템플릿 메소드 패턴으로 관리 포인트 줄이기 (0) | 2025.02.17 |
DB의 외부식별자와 내부식별자 분리 (Auto_Increment vs UUID) (0) | 2025.02.10 |
조건부 속성 문제 해결기 (DDD + Factory Method Pattern) (0) | 2025.01.19 |
Spring Event - Listener가 이벤트를 인식하지 못하는 문제 (Type Erasure) (0) | 2025.01.16 |