
상품 선택 모달 창에서 상품 목록을 가져올 때 상품의 개수가 많아진다면 응답 시간이 높아질 것으로 예상했다. 이에 실제로 응답 시간이 얼마가 걸리는지 측정해보고 개선 방향을 모색해 개선 전후 응답 시간을 비교해보기로 하였다.
/api/live/product/list/api/live/product/list?page={page}/api/live/product/list?lastProductNo={lastProductNo}주의할 점 : 실행 직후는 JVM의 JIT 컴파일, 스프링 빈 초기화, 데이터베이스 캐시, TCP 연결 재사용 등으로 응답 시간이 높게 나올 수 있으므로 일관된 측정을 위해 테스트 전 몇 차례 워밍업 요청을 보낸 후 측정을 해야 함.
📈 평균 응답 시간(Avg) : 2038.37ms (약 2초)
📉 최소값(Min) : 1647ms
📈 최대값(Max) : 2993ms
📉 중앙값(Median) : 2043.50ms
📈 90th pct : 2240.40ms
📉 95th pct : 2606.35ms
📈 99th pct : 2993.00ms
// 변경 전 코드
public List<ProductItemDto> getProducts() {
List<Product> products = productRepository.findAll();
return products.stream().map(product -> {
Long productPrice = product.getProductPrice();
return ProductItemDto.builder()
.productNo(product.getProductNo())
.productName(product.getProductName())
.productPrice(productPrice)
.productImg(product.getProductImg())
.build();
}).toList();
}
// 변경 후 코드
public Page<ProductItemDto> getProducts(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("productNo").ascending());
return productRepository.findAll(pageable)
.map(ProductItemDto::fromEntity);
}
📈 평균 응답 시간(Avg) : 67.53ms (약 0.06초)
📉 최소값(Min) : 56ms
📈 최대값(Max) : 97ms
📉 중앙값(Median) : 65.50ms
📈 90th pct : 85.70ms
📉 95th pct : 93.15ms
📈 99th pct : 97.00ms
📈 평균 응답 시간(Avg) : 118.53ms (약 0.1초)
📉 최소값(Min) : 105ms
📈 최대값(Max) : 162ms
📉 중앙값(Median) : 115.00ms
📈 90th pct : 135.70ms
📉 95th pct : 157.05ms
📈 99th pct : 162.00ms
| 지표 | Before Paging | After offset Paging (First Page) | Improvement (First Page) | After offset Paging (Last Page) | Improvement (Last Page) |
|---|---|---|---|---|---|
| 평균 응답 시간 (Avg) | 2038.37ms | 67.53ms | 96.69% 감소 | 118.53ms | 94.19% 감소 |
| 최소값 (Min) | 1647.00ms | 56.00ms | 96.60% 감소 | 105.00ms | 93.62% 감소 |
| 최대값 (Max) | 2993.00ms | 97.00ms | 96.76% 감소 | 162.00ms | 94.59% 감소 |
| 중앙값 (Median) | 2043.50ms | 65.50ms | 96.79% 감소 | 115.00ms | 94.37% 감소 |
| 90th pct | 2240.40ms | 85.70ms | 96.17% 감소 | 135.70ms | 93.94% 감소 |
| 95th pct | 2606.35ms | 93.15ms | 96.43% 감소 | 157.05ms | 93.97% 감소 |
| 99th pct | 2993.00ms | 97.00ms | 96.76% 감소 | 162.00ms | 94.59% 감소 |
이 이유는 JPA 구현체(Hibernate)가 페이징 요청을 SQL레벨에서 LIMIT과 OFFSET으로 바꿔서 전송하기 때문이다. 이 때 OFFSET은 DB가 결과에서 앞의 N개 행을 건너뛰기 위해 실제로 그 N개를 읽고 버리기 때문에 뒷 페이지로 갈수록 읽어야 하는 행의 개수가 많아져 더 많은 응답 시간이 소요된다.
cursor 방식은 마지막으로 조회한 행의 키를 기준으로 WHERE절을 통해 직접 탐색하는 방법이다. 따라서 마지막 페이지를 조회할 때는 마지막직전에 조회한 마지막 행의 키를 안다는 가정하에 직접 입력하여 진행하였다.
// 변경 전 코드
public List<ProductItemDto> getProducts() {
public Page<ProductItemDto> getProducts(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("productNo").ascending());
return productRepository.findAll(pageable)
.map(ProductItemDto::fromEntity);
}
// 변경 후 코드
public GetProductListResponseDto getProducts(Long lastProductNo, int size) {
Pageable limit = PageRequest.of(0, size);
List<Product> entities;
if (lastProductNo == null) {
entities = productRepository.findInitial(limit);
} else {
entities = productRepository.findNext(lastProductNo, limit);
}
List<ProductItemDto> data = entities.stream()
.map(ProductItemDto::fromEntity)
.toList();
// 다음 마지막 ProductNo
Long nextLastProductNo = data.isEmpty() ? null : data.get(data.size() - 1).getProductNo();
// hasNext 여부 조회 건수가 설정한 size보다 작다면 다음 페이지가 없음
boolean hasNext = data.size() == size;
return new GetProductListResponseDto(data, hasNext, nextLastProductNo);
}
// ProductRepository
@Query("SELECT p " +
"FROM Product p " +
"ORDER BY p.productNo ASC")
List<Product> findInitial(Pageable pageable);
@Query("SELECT p " +
"FROM Product p " +
"WHERE p.productNo > :lastProductNo " +
"ORDER BY p.productNo ASC")
List<Product> findNext(@Param("lastProductNo") Long lastProductNo, Pageable pageable);
📈 평균 응답 시간(Avg) : 21.53ms (약 0.02초)
📉 최소값(Min) : 13ms
📈 최대값(Max) : 55ms
📉 중앙값(Median) : 20.50ms
📈 90th pct : 30.70ms
📉 95th pct : 45.65ms
📈 99th pct : 55ms
📈 평균 응답 시간(Avg) : 27.17ms (약 0.02초)
📉 최소값(Min) : 15ms
📈 최대값(Max) : 64ms
📉 중앙값(Median) : 22.50ms
📈 90th pct : 49.10ms
📉 95th pct : 59.05ms
📈 99th pct : 64.00ms
| 지표 | After offset Paging (First Page) | After cursor Paging (First Page) | Improvement (First Page) | After offset Paging (Last Page) | After cursor Paging (Last Page) | Improvement (Last Page) |
|---|---|---|---|---|---|---|
| 평균 응답 시간 (Avg) | 67.53ms | 21.53ms | 68.12% 감소 | 118.53ms | 27.17ms | 77.08% 감소 |
| 최소값 (Min) | 56.00ms | 13.00ms | 76.79% 감소 | 105.00ms | 15.00ms | 85.71% 감소 |
| 최대값 (Max) | 97.00ms | 55.00ms | 43.30% 감소 | 162.00ms | 64.00ms | 60.49% 감소 |
| 중앙값(Median) | 65.50ms | 20.50ms | 68.70% 감소 | 115.00ms | 22.50ms | 80.43% 감소 |
| 90th pct | 85.70ms | 30.70ms | 64.18% 감소 | 135.70ms | 49.10ms | 63.82% 감소 |
| 95th pct | 93.15ms | 45.65ms | 51.00% 감소 | 157.05ms | 59.05ms | 62.40% 감소 |
| 99th pct | 97.00ms | 55.00ms | 43.30% 감소 | 162.00ms | 64.00ms | 60.49% 감소 |
페이징을 적용하기 전과 비교하였을 때 offset 방식의 페이징을 적용한 후 전체적인 지표에서 약 95%가 개선된 것을 확인할 수 있었다.
cursor 방식의 페이징은 offset 방식의 페이징에 비해 평균 응답 시간에서는 약 68~77%가 개선된 것을, 95th pct에서는 약 51~62%가 개선된 것을 확인할 수 있었다.
| 항목 | Offset Paging | Cursor Paging |
|---|---|---|
| 기본 동작 원리 | 결과에서 앞의 N개를 건너뛰고 다음 K개를 반환 | 마지막으로 본 행의 키를 기준으로 WHERE로 다음 K개를 직접 탐색 |
| 성능(앞페이지) | 빠름 | 빠름 |
| 성능(뒷페이지) | 느려짐 | 일정 |
| 임의 페이지 점프 | 쉬움 | 어려움 |
| 장점 | 구현·사용 간단, 프레임워크(예: Spring Data)의 기본 지원, 임의 페이지 접근 용이 | 뒤로 가도 성능 안정적, 동시 삽입/삭제 시 중복/누락 적어 일관성 우수 |
| 단점 | 대규모 테이블에서 뒷페이지 성능 저하, 데이터 동시 변경 시 페이지 불안정 발생 가능. | 임의 페이지 점프 지원 약함 |
| 사용 권장 시나리오 | 사용자가 페이지 번호로 직접 점프하는 UI에 적합 | 무한 스크롤, 피드/타임라인 등 연속 조회가 중요한 경우에 적합 |
페이징을 적용하는 상품 선택 모달창이 무한 스크롤로 구현되어 있어 임의의 페이지로 이동할 필요가 없다는 점과 뒷 페이지로 가도 일관된 응답 시간을 보여준다는 점으로 인해 offset 방식의 페이징보다는 cursor 방식의 페이징을 적용하는 것이 적합하다고 판단하였다.
이러한 개선사항은 상품 개수가 무수히 많이 늘어나도 일관된 응답 시간을 사용자에게 제공함으로써 보다 나은 사용자 경험을 안겨줄 수 있다.