[2025.09.04] 상품 선택 모달 페이징 적용 성능 테스트(벤치마크 테스트)

아스라이지새는달·2025년 9월 4일
0
post-thumbnail

상품 선택 모달 창에서 상품 목록을 가져올 때 상품의 개수가 많아진다면 응답 시간이 높아질 것으로 예상했다. 이에 실제로 응답 시간이 얼마가 걸리는지 측정해보고 개선 방향을 모색해 개선 전후 응답 시간을 비교해보기로 하였다.

📝 테스트 개요

  • 테스트 유형 : 벤치마크 테스트
  • 테스트 목표 :
    1. 페이징 적용 전후 응답 시간 비교
    2. offset 방식의 페이징과 cursor 방식의 페이징 응답 시간 비교
    3. 응답시간 개선
  • 테스트 환경
    • 테스트 기기 : Apple M1 맥북에어, RAM 8GB
    • 테스트 Tool : Apache JMeter 5.6.3

⚙️ JMeter 테스트 환경 설정

  • Xms(JVM 초기 힙 사이즈) : 1GB
  • Xmx(JVM 최대 힙 사이즈) : 1GB
  • Number of Threads (users) : 30
  • Ramp-up period (seconds) : 30
  • Loop Count : 1

💻 테스트 과정

  • 테스트 기준 데이터 : 임의의 상품 120,120개
  • 상품 모달 API 호출(HTTP Request)
    1. 페이징 적용 전 API : /api/live/product/list
    2. offset 방식의 페이징 적용 후 : /api/live/product/list?page={page}
    3. cursor 방식의 페이징 적용 후 : /api/live/product/list?lastProductNo={lastProductNo}
  • Listener로 Summary Report를 추가하여 평균값(Average), 중앙값(Median), 95th를 측정

    주의할 점 : 실행 직후는 JVM의 JIT 컴파일, 스프링 빈 초기화, 데이터베이스 캐시, TCP 연결 재사용 등으로 응답 시간이 높게 나올 수 있으므로 일관된 측정을 위해 테스트 전 몇 차례 워밍업 요청을 보낸 후 측정을 해야 함.

🧪 테스트 1 (페이징 적용 X)

  • 페이징없이 약 12만개의 상품 목록 전체를 불러오는데 걸리는 응답 시간 측정

✅ 결과

Image

📈 평균 응답 시간(Avg) : 2038.37ms (약 2초)

📉 최소값(Min) : 1647ms

📈 최대값(Max) : 2993ms

📉 중앙값(Median) : 2043.50ms

📈 90th pct : 2240.40ms

📉 95th pct : 2606.35ms

📈 99th pct : 2993.00ms

📊 응답 시간 분포

Image

🔎 분석

  • 상품 모달을 열었을 때 상품을 불러와 화면을 구성하는데 평균 약 2초가 걸리고 최악의 경우 약 3초가 걸린다는 점에서 개선이 필요하다고 생각함.
  • 또한 해당 테스트는 로컬에서 실행했을 때의 응답 시간들로 서버 환경에서 측정 시 네트워크 구간 지연, 동시 요청 시 큐잉 지연 등으로 인해 응답 시간이 더 악화될 것으로 예상하여 개선이 필요하다고 생각함.

🧪 테스트 2 (OFFSET 페이징)

  • offset 방식의 페이징을 적용하여 상품 목록을 불러오는데 걸리는 응답 시간 측정 (페이지당 20개의 상품을 불러옴)
  • 첫 페이지와 마지막 페이지를 나누어서 측정

💻 코드 변경 사항

// 변경 전 코드
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);
}

✅ 결과 (첫 번째 페이지)

Image

📈 평균 응답 시간(Avg) : 67.53ms (약 0.06초)

📉 최소값(Min) : 56ms

📈 최대값(Max) : 97ms

📉 중앙값(Median) : 65.50ms

📈 90th pct : 85.70ms

📉 95th pct : 93.15ms

📈 99th pct : 97.00ms

📊 응답 시간 분포 (첫 번째 페이지)

Image

✅ 결과 (마지막 페이지)

Image

📈 평균 응답 시간(Avg) : 118.53ms (약 0.1초)

📉 최소값(Min) : 105ms

📈 최대값(Max) : 162ms

📉 중앙값(Median) : 115.00ms

📈 90th pct : 135.70ms

📉 95th pct : 157.05ms

📈 99th pct : 162.00ms

📊 응답 시간 분포 (마지막 페이지)

Image

🔎 분석

지표Before PagingAfter offset Paging (First Page)Improvement (First Page)After offset Paging (Last Page)Improvement (Last Page)
평균 응답 시간 (Avg)2038.37ms67.53ms96.69% 감소118.53ms94.19% 감소
최소값 (Min)1647.00ms56.00ms96.60% 감소105.00ms93.62% 감소
최대값 (Max)2993.00ms97.00ms96.76% 감소162.00ms94.59% 감소
중앙값 (Median)2043.50ms65.50ms96.79% 감소115.00ms94.37% 감소
90th pct2240.40ms85.70ms96.17% 감소135.70ms93.94% 감소
95th pct2606.35ms93.15ms96.43% 감소157.05ms93.97% 감소
99th pct2993.00ms97.00ms96.76% 감소162.00ms94.59% 감소
Image
  • 상품 전체를 불러올 때보다 페이징을 적용하여 20개씩 불러올 때 응답 시간이 전체적으로 약 95% ± 1~2% 개선된 것을 확인할 수 있다.
  • 하지만 마지막 페이지의 경우 첫 번째 페이지에 비해 응답 시간이 약 1.7배 정도 높은 것을 확인할 수 있다.

    이 이유는 JPA 구현체(Hibernate)가 페이징 요청을 SQL레벨에서 LIMIT과 OFFSET으로 바꿔서 전송하기 때문이다. 이 때 OFFSET은 DB가 결과에서 앞의 N개 행을 건너뛰기 위해 실제로 그 N개를 읽고 버리기 때문에 뒷 페이지로 갈수록 읽어야 하는 행의 개수가 많아져 더 많은 응답 시간이 소요된다.

  • 이에 다른 방식(Cursor 방식)의 페이징을 적용하여 응답 시간을 측정해보려 한다.

🧪 테스트 3 (CURSOR 페이징)

  • cursor 방식의 페이징을 적용하여 상품 목록을 불러오는데 걸리는 응답 시간 측정 (페이지당 20개의 상품을 불러옴)
  • 첫 페이지와 마지막 페이지를 나누어서 측정

    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);

✅ 결과 (첫 번째 페이지)

Image

📈 평균 응답 시간(Avg) : 21.53ms (약 0.02초)

📉 최소값(Min) : 13ms

📈 최대값(Max) : 55ms

📉 중앙값(Median) : 20.50ms

📈 90th pct : 30.70ms

📉 95th pct : 45.65ms

📈 99th pct : 55ms

📊 응답 시간 분포 (첫 번째 페이지)

Image

✅ 결과 (마지막 페이지)

Image

📈 평균 응답 시간(Avg) : 27.17ms (약 0.02초)

📉 최소값(Min) : 15ms

📈 최대값(Max) : 64ms

📉 중앙값(Median) : 22.50ms

📈 90th pct : 49.10ms

📉 95th pct : 59.05ms

📈 99th pct : 64.00ms

📊 응답 시간 분포 (마지막 페이지)

Image

🔎 분석

지표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.53ms21.53ms68.12% 감소118.53ms27.17ms77.08% 감소
최소값 (Min)56.00ms13.00ms76.79% 감소105.00ms15.00ms85.71% 감소
최대값 (Max)97.00ms55.00ms43.30% 감소162.00ms64.00ms60.49% 감소
중앙값(Median)65.50ms20.50ms68.70% 감소115.00ms22.50ms80.43% 감소
90th pct85.70ms30.70ms64.18% 감소135.70ms49.10ms63.82% 감소
95th pct93.15ms45.65ms51.00% 감소157.05ms59.05ms62.40% 감소
99th pct97.00ms55.00ms43.30% 감소162.00ms64.00ms60.49% 감소
Image Image
  • 평균 응답 시간을 봤을 때 마지막 페이지의 경우 약 77%가 감소하여 유의미한 결과를 보여주었다. 또한 마지막 페이지뿐 아니라 첫 번째 페이지에서도 약 68%가 감소하는 결과를 얻을 수 있었다.
  • 95th pct 또한 첫 번째 페이지의 경우 약 51%가 감소하였고 마지막 페이지의 경우 약 62%가 감소한 것을 볼 수 있다.
  • 마지막 페이지에서 첫 번째 페이지에 비해 약 1.7배 높은 응답 시간을 보여준 offset 방식과는 달리 cursor 방식의 페이징에서는 약 1.26배로 크지 않은 차이를 보여준다.

📍 테스트 결론

  • 페이징을 적용하기 전과 비교하였을 때 offset 방식의 페이징을 적용한 후 전체적인 지표에서 약 95%가 개선된 것을 확인할 수 있었다.

  • cursor 방식의 페이징은 offset 방식의 페이징에 비해 평균 응답 시간에서는 약 68~77%가 개선된 것을, 95th pct에서는 약 51~62%가 개선된 것을 확인할 수 있었다.

항목Offset PagingCursor Paging
기본 동작 원리결과에서 앞의 N개를 건너뛰고 다음 K개를 반환마지막으로 본 행의 키를 기준으로 WHERE로 다음 K개를 직접 탐색
성능(앞페이지)빠름빠름
성능(뒷페이지)느려짐일정
임의 페이지 점프쉬움어려움
장점구현·사용 간단, 프레임워크(예: Spring Data)의 기본 지원, 임의 페이지 접근 용이뒤로 가도 성능 안정적, 동시 삽입/삭제 시 중복/누락 적어 일관성 우수
단점대규모 테이블에서 뒷페이지 성능 저하, 데이터 동시 변경 시 페이지 불안정 발생 가능.임의 페이지 점프 지원 약함
사용 권장 시나리오사용자가 페이지 번호로 직접 점프하는 UI에 적합무한 스크롤, 피드/타임라인 등 연속 조회가 중요한 경우에 적합
  • 페이징을 적용하는 상품 선택 모달창이 무한 스크롤로 구현되어 있어 임의의 페이지로 이동할 필요가 없다는 점과 뒷 페이지로 가도 일관된 응답 시간을 보여준다는 점으로 인해 offset 방식의 페이징보다는 cursor 방식의 페이징을 적용하는 것이 적합하다고 판단하였다.

  • 이러한 개선사항은 상품 개수가 무수히 많이 늘어나도 일관된 응답 시간을 사용자에게 제공함으로써 보다 나은 사용자 경험을 안겨줄 수 있다.

profile
웹 백엔드 개발자가 되는 그날까지

0개의 댓글