봉황대 in CS
[Server] Offset 기반 vs. Cursor 기반 Pagination (feat. nGrinder 부하 테스트) 본문
[Server] Offset 기반 vs. Cursor 기반 Pagination (feat. nGrinder 부하 테스트)
등 긁는 봉황대 2025. 1. 20. 00:25Pagination(또는 Paging)은 데이터를 조회하는 경우, 데이터 전체를 가져오는 것이 아니라 데이터를 페이지라는 일정량으로 쪼개어 요청한 페이지에 대한 데이터만 일부분 가져오는 기법이다. 전체 데이터가 매우 많은 경우에 Pagination을 통해 DB의 부하를 줄일 수 있으며, '무한 스크롤'을 위한 조회 API 구현 시 많이 사용하는 방법이다.
Pagination을 구현하는 방법에는 여러 가지가 존재하는데, 이 글에서는 Offset 기반 pagination과 Cursor 기반 pagination을 알아본다. 그리고 nGrinder를 통한 부하 테스트를 진행하여 서로를 비교하고 왜 이런 차이가 발생하는지를 정리한다.
예시 상황
- 상품(Product) table에는 300,000건의 데이터가 저장되어 있으며, ID 기준 오름차순 정렬되어 저장되어 있다.
- 한 페이지 당 100개의 데이터가 조회된다. 페이지는 ID를 기준으로 쪼갠다.
- 마지막 페이지인 2999번째 페이지를 조회하고자 한다.
구현
위쪽 코드는 Offset 기반, 아래쪽 코드는 Cursor 기반 pagination을 구현한 것이다.
Controller 코드
@Slf4j
@RequiredArgsConstructor
@RestController
public class ProductController {
private final ProductService productService;
@GetMapping("/api/products-offset")
public ResponseEntity<GetProductsResponse> getProductsByOffset(
@PageableDefault(size = 100) Pageable pageable) {
GetProductsResponse response = productService.getProductsByOffset(pageable);
return ResponseEntity.ok().body(response);
}
@GetMapping("/api/products-cursor")
public ResponseEntity<GetProductsResponse> getProductsByCursor(
@RequestParam(name = "startId") Long startId,
@RequestParam(name = "pageSize") int pageSize) {
GetProductsResponse response = productService.getProductsByCursor(startId, pageSize);
return ResponseEntity.ok().body(response);
}
}
API 요청 예시는 다음과 같다.
# Offset 기반
GET http://localhost:8080/api/products-offset?page=2999&size=100
# Cursor 기반
GET http://localhost:8080/api/products-cursor?startId=299900&pageSize=100
Service 코드
@RequiredArgsConstructor
@Service
public class ProductService {
private final ProductRepository productRepository;
public GetProductsResponse getProductsByOffset(Pageable pageable) {
List<Product> productPage = productRepository.findAll(pageable).getContent();
return GetProductsResponse.of(productPage);
}
public GetProductsResponse getProductsByCursor(Long startId, int pageSize) {
List<Product> productPage = productRepository.findAllByCursor(startId, pageSize);
return GetProductsResponse.of(productPage);
}
}
Repository 코드
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query(value = "select * from product p where p.id > :id limit :pageSize", nativeQuery = true)
List<Product> findAllByCursor(@Param("id") Long id, @Param("pageSize") int pageSize);
}
Query 비교
Offset 기반 pagination
이 쿼리의 의도는 299900번째 데이터에 바로 접근하여, 이후 100개의 데이터를 가져오도록 하는 것이다.
select *
from product
limit 100 offset 299900;
[참고] log를 찍어보면 쿼리가 limit [offset], [page size]로 나가는 것을 볼 수 있는데, 위 쿼리와 동일한 동작을 한다.
Cursor 기반 pagination
ID를 기준으로 페이징을 하기 때문에, ID에 대한 조건문을 두어 데이터를 가져오도록 하는 것이다.
select *
from product
where id > 299900
limit 100;
성능 테스트 결과 - Local
각각의 Service 코드를 100번 호출하여 평균 실행시간(latency)을 측정하였다.
@ActiveProfiles("test")
@SpringBootTest
public class GetProductsPerformanceTest {
private final static int ITERATION_COUNT = 100;
private final static int PAGE_SIZE = 100;
private final static int PAGE_NUMBER = 2999;
@Autowired
private ProductService productService;
@DisplayName("상품 목록 offset 기반 조회 성능 테스트")
@Test
void getProductsByOffset() {
// given
PageRequest pageRequest = PageRequest.of(PAGE_NUMBER, PAGE_SIZE);
// when
long totalTime = 0;
for (int i = 0; i < ITERATION_COUNT; i++) {
long startTime = System.currentTimeMillis();
productService.getProductsByCursor(pageRequest);
long endTime = System.currentTimeMillis();
totalTime += endTime - startTime;
}
// then
System.out.println("Total time: " + totalTime + " ms");
System.out.println("Average time: " + totalTime / ITERATION_COUNT + " ms");
}
@DisplayName("상품 목록 cursor 기반 조회 성능 테스트")
@Test
void getProductsByCursor() {
// when
long totalTime = 0;
for (int i = 0; i < ITERATION_COUNT; i++) {
long startTime = System.currentTimeMillis();
productService.getProductsByCursor((long) PAGE_NUMBER, PAGE_SIZE);
long endTime = System.currentTimeMillis();
totalTime += endTime - startTime;
}
// then
System.out.println("Total time: " + totalTime + " ms");
System.out.println("Average time: " + totalTime / ITERATION_COUNT + " ms");
}
}
테스트 결과는 다음과 같았다.
# Offset 기반
Total time: 22397 ms
Average time: 223 ms
# Cursor 기반
Total time: 1437 ms
Average time: 14 ms
왜 이런 결과가 ?
Cursor 기반 방법이 Offset 기반보다 약 16배 빠르다는 것인데 왜 !! 이런 결과가 나타날까?
그 이유는 각 쿼리의 동작 방식에 있다. 각 쿼리에 대해서 옵티마이저 실행 계획을 뽑아보자.
Offset 기반 pagination은 Table full scan이 발생하고 있다. 위 쿼리에 대한 나의 의도는 n번째 데이터에 곧바로 접근하여 그 뒤 데이터들을 반환하는 것이었다. 하지만 이와 다르게, 실제로는 테이블을 맨 위부터 훑으면서 n번째 데이터를 찾고, 그 뒤에 반환을 하고 있다.
반면에 Cursor 기반 pagination은 Index range scan이 발생하고 있다. MySQL은 PK를 기준으로 Clustered index를 기본으로 생성한다. 따라서 현재 ID를 기준으로 index가 존재하는 것이고, 이 index를 통해서 n번째 데이터를 찾은 후에 나머지 데이터를 반환하고 있는 것이다. 게다가 이는 모든 column의 데이터를 담고 있는 Covering index이기 때문에 Table에 따로 접근하지 않아도 된다.
정리하자면, index를 타느냐 안 타느냐에 따라서 성능 차이가 크게 발생하고 있는 것이다.
부하 테스트 결과 - nGrinder (with Promethues & Grafana monitoring)
nGrinder는 Java 기반 성능 테스트 도구로, 이를 통해 부하 테스트도 손쉽게 진행할 수 있다.
https://naver.github.io/ngrinder/
[참고] 설치와 테스트 진행 방법은 여기에 매우 잘 나와있다 :)
https://developbear.tistory.com/123
nGrinder의 테스트 방법
크게 2개의 구성 요소를 통해서 테스트를 진행하게 된다.
Controller는 테스트를 진행을 총괄하는 Web application이다. 개발자가 테스트 스크립트 작성하고 Controller에게 실행을 명령하면 Controller는 Agent를 생성하여 부하 발생을 명령한다. Agent는 개발자가 지정한 만큼의 Virtual user(가상 사용자)를 생성한다. Virtual user들은 각각 Server에게 요청을 보내 부하를 발생시키는 주체이다.
테스트 환경
nGrinder Controller에 접근하여 테스트 환경을 다음과 같이 설정하였다.
Agent는 1개, Agent의 Virtual user 개수는 (Process 개수) × (Process 별 Thread 개수) = 1 × 100 = 100개이다.
실행 횟수는 100으로 설정하여, 하나의 Virtual user가 100번의 API 요청을 보내도록 하였다.
즉, 총 API 호출 횟수는 (Virtual user #) × (Request # per virtual user) = 100 × 100 = 10,000번이다.
Server가 동작하고 있는 환경은 AWS EC2 t2.micro (프리티어쨩 ..), 연결된 DB는 MySQL이다.
Offset 기반 pagination 부하 테스트 결과
먼저, Offset 기반 pagination의 부하 테스트 결과는 다음과 같다. 사실 테스트가 도저히 끝날 기미가 보이지 않아 중간에 죽였다.
TPS, Transactions per second는 초당 처리할 수 있는 Transaction의 건수로, Server가 요청을 얼마만큼 빠르게 처리하였는지를 나타내는 지표이다. 여기서는 평균 9.9 TPS, Max 15 TPS라는 매우 낮은 수치로 측정되었다. 더 놀랍게도, 평균 테스트 시간(== 평균 응답 시간)은 9,992.94 ms로 찍혔다. 이상하지 않은가?? Local에서 시간을 측정해 보았을 때에는 평균 223 ms로 측정되었는데, 이에 비해서는 너무나도 높은 숫자이다.
이런 결과가 나온 원인은 DB connection을 얻기 위한 대기 또는 Tomcat thread를 얻기 위한 대기 때문이라고 예상했고, 이를 직접 눈으로 확인하기 위해 Prometheus와 Grafana를 통해서 모니터링을 진행하게 되었다.
DB connection pool의 크기는 10이었다. 한 번에 10개의 요청만 DB connection을 얻어서 query 할 수 있다는 것이다.
http://{ec2_ip}:8080/actuator/metrics/hikaricp.connections
즉, 한 번에 10개의 요청만 DB connection을 얻어서(Active 상태, 초록색 그래프) 조회를 진행하고, 나머지 90개는 Pending 상태(파란색 그래프)로, DB connection을 얻기 위해 대기하게 된다. 하지만 Offset 기반 pagination의 조회 query는 속도가 느리며 100개의 thread 각각이 100개의 요청을 계속 보내기 때문에 Pending 상태가 ±90개로 계속 유지되는 양상이 보이는 것이다.
Connection acquire time을 보면 9초대에서 떨어지지 않는 것으로 보이고, 이는 평균 테스트 시간과 동일하다. 즉, nGrinder에서 찍힌 그 기다란 평균 테스트 시간은 DB connection pool에서 connection을 얻기 위해서 기다린 시간이 드러난 수치인 것이다.
그렇다면 Tomcat thread를 대기하는 시간은 없었을까?
답은 노. Max thread 개수는 200이었기 때문에 이쪽은 영향을 주지 않았던 것으로 보인다.
http://{ec2_ip}:8080/actuator/metrics/tomcat.threads.config.max
Current thread (tomcat.threads.busy, 노란색 그래프)는 현재 생성된 thread의 개수를 말하며, Busy thread (tomcat.threads.current, 초록색 그래프)는 현재 사용 중인 thread의 개수를 말한다. tomcat.threads.config.max는 Thread pool의 최대 size를 말하는 것이므로 다음의 식이 성립된다.
tomcat.threads.busy ≤ tomcat.threads.current ≤ tomcat.threads.config.max
위 그래프를 보면, 초기에 10개의 thread가 생성되었으나 Virtual user 100개의 API 요청(즉, 총 100개의 요청)이 들어와 thread가 100개로 늘어난 것을 볼 수 있다. 그 후, 16:38:30부터 API 요청이 더 이상 들어오지 않으니, 16:37:15 쯤에서 thread의 개수를 80으로 줄인 것 또한 볼 수 있다. 현재의 요청 상황에 따라 Tomcat thread pool에서 생성되어 대기하는 thread의 개수를 동적으로 조절하고 있는 것이다.
그렇다면, 의도적으로 Max thread 개수보다 많은 요청을 주게 되면 어떻게 될까?
Virtual user 210개가 각각 20번의 요청을 보내도록 해보니 .. Promethues가 metric 수집도 못하게 되길래 (20:16:15 ~ 20:17:15 지점을 보면 그래프가 그려지지 않음) 황급히 죽였다. Server가 너무 과부하 돼서 추가적인 기능도 못하게 된 것이다.
따라서 Virtual user를 Tomcat max thread 개수와 동일한 수치인 200개로 설정하고 테스트하였다. 이 테스트도 끝날 기미가 보이질 않아 중간에 죽였다.
10개의 요청만이 DB connection을 얻을 수 있으므로 Pending 상태인 요청의 개수는 190개이다. 대기 중인 요청이 너무나도 많고, query 속도는 느리기 때문에 Connection acquire time이 선형적으로 증가하는 것을 볼 수 있다. 이 때문에 Connection timeout이 9건 발생하기도 하였다.
http://{ec2_ip}:8080/actuator/metrics/hikaricp.connections.timeout
Tomcat thread pool의 thread 개수는 max인 200개를 달성하였고, 그 이상으로 증가하지 못하는 것 또한 볼 수 있다.
Curosr 기반 pagination 부하 테스트 결과
대망의 Cursor 기반 pagination의 테스트 결과이다. 첫 테스트와 동일하게 Virtual user 100개가 각각 100개의 요청을 보내도록 하였는데, 30초 만에 테스트가 종료되었다. 평균 621.7 TPS, Max 722 TPS, 평균 테스트 시간 153.78 ms로, 앞서 진행한 Offset 기반 pagination 보다 약 57배 빠른 속도로 응답을 받는다.
SQL이 빠르게 실행되어 DB connection pool로 connection을 반환하는 것 또한 빠르게 진행되기 때문에, Connection acquire time이 선형적으로 감소하는 것을 볼 수 있다. 참고로, 아래 Connection acquire time 그래프에서 16:41:45 이전에 9 sec으로 찍히는 건 바로 직전에 실행한 Offset 기반 pagination 테스트의 영향이다.. (조금 더 기다렸다가 실행할걸 그랬다)
모두 매우 이상적인 그래프를 보이고 있다. Virtual user를 Tomcat max thread 개수와 동일한 수치인 200개로 설정하고 각각 20번씩 요청을 보내도록 하여 테스트를 진행해도 Offset 기반 때와는 다르게 30초 만에 모두 실행되어 종료되었다.
결론
Offset 기반 pagination과 Cursor 기반 pagination의 차이를 만들어내는 것은 SQL 밖에 없다. Query 속도에 의해서 전체 시스템이 영향을 크게 받는다는 것이다.
현재 DB connection pool size는 10으로 매우 작게 설정되어 있어, 이를 키우는 것으로 부하 문제를 어느 정도 해결할 수는 있을 것이다. 하지만 단순히 SQL 하나를 바꾸는 것만으로 커다란 성능 최적화를 이루어낼 수 있었다. 이번 테스트를 진행하면서 왜 반드시 Index를 타도록 SQL 튜닝을 진행하는지 몸소 깨달을 수 있었다.
여담으로 .. 단순히 nGrinder를 통해서 TPS와 평균 응답시간이 얼마만큼 나오는지만을 확인하고 끝내는 것이 아니라, 왜 이런 결과가 나왔는지를 예측하고 그 이유를 Prometheus와 Grafana를 통해 눈으로 직접 확인하니 보이게 되는 것이 훨씬 많아진 것 같다. Tomcat thread pool에 생성되어 있는 thread 개수가 현재의 상황에 따라 동적으로 변화하고 있다는 것을 발견한 것도 있고, 왜 이런 그래프가 나타나고 있는지를 고민하고 깨닫는 시간이 매우 값졌던 것 같다.
베리 굳뜨.
'Server' 카테고리의 다른 글
[Server] Redis Pub/Sub을 통해 Local cache 동기화하기 (0) | 2025.02.03 |
---|---|
REST API (0) | 2021.11.14 |
API / HTTP Packet / HTTP Method (0) | 2021.11.07 |
Key & Table 간의 관계 (0) | 2021.10.25 |
Proxy (0) | 2021.10.11 |