상품 페이징 조회 - ekdan38/HotDealService GitHub Wiki

테스트 환경

  • CPU : Intel i5-8250U 1.6GHz
  • RAM : 8GB
  • OS : Window10
  • Databse : MySQL 8.0
  • Test Tool : K6

상품 조회 성능 최적화 및 k6 테스트 비교

테스트를 위해 1만개의 핫딜, 10만개의 상품에 대한 더미 데이터를 생성하였다.


상품 페이징 조회

테스트 시나리오

1. 60% 확률로 검색어 검색

2. 모든 사용자는 반드시 첫 페이지 로드

3. 첫 페이지 조회 후, 70%의 확률로 다음 페이지 조회

4. 최대 3페이지까지만 탐색

5. 다음 페이지 조회 시 페이지 조회 후 1~4초의 랜덤한 대기 시간 부여

개선 전 k6 테스트

k6 테스트 결과
  • VU 100
    VU100 결과
  • VU 200
    VU200 결과
  • VU 300
    VU300 결과
상품 페이징 조회 (3분) 총 처리량 Latency(mean) Latency(P95) TPS
VU 100 47468 377ms 664ms 262
VU 200 42908 839ms 1.52s 237
VU 300 43301 1.24s 2.78s 238

테스트 결과 분석

  • VU 100 -> 200 증가 시, Latency(mean), Latency(P95) 모두 2배 이상 증가
  • TPS 는 VU 200에서 소폭 감소 후 VU 300에서 더 감소되는 모습을 보임

성능 개선 계획 

  1. DTO Proejction : 여러 Row를 조회 하기에 DTO Projection을 적용하여 필요한 필드만 선택적으로 조회
  2. 인덱싱 : 조회 성능 향상을 위해 적절한 인덱스를 생성하여 쿼리의 탐색 비용 감소
  3. 캐싱 : 반복적으로 조회되는 데이터를 캐시에 저장하여 DB 부하를 줄여 성능 개선

성능 개선

1. DTO Projection 적용

​ 여러 Row를 조회 하기에 DTO Projection을 적용하여 필요한 필드만 선택적으로 조회하여 성능 향상 기대 ​

수정 전

    @Query("SELECT hp " +
            "FROM HotDealProduct hp " +
            "WHERE hp.hotDeal.id = :hotDealId " +
            "AND hp.hotDeal.status = 'ACTIVE' " +
            "AND hp.hotDeal.deleted = false " +
            "AND hp.id < :cursor " +
            "AND (:search IS NULL OR hp.title LIKE %:search%) " +
            "ORDER BY hp.id DESC")
    List<HotDealProduct> findByCursorAndSearchAndSizeHotDealProducts(@Param("hotDealId") Long hotDealId,
                                                                     @Param("cursor") Long cursor,
                                                                     @Param("search") String search,
                                                                     Pageable pageable);

수정 후

    @Query("SELECT new com.hong.hotdealservice.dto.projection.ProductSimpleDto" +
            "(hp.id, hp.hotDeal.id, hp.price, hp.title)" +
            "FROM HotDealProduct hp " +
            "WHERE hp.hotDeal.id = :hotDealId " +
            "AND hp.hotDeal.status = 'ACTIVE' " +
            "AND hp.hotDeal.deleted = false " +
            "AND hp.id < :cursor " +
            "AND (:search IS NULL OR hp.title LIKE %:search%) " +
            "ORDER BY hp.id DESC")
    List<ProductSimpleDto> findByCursorAndSearchAndSizeHotDealProducts(@Param("hotDealId") Long hotDealId,
                                                                       @Param("cursor") Long cursor,
                                                                       @Param("search") String search,
                                                                       Pageable pageable);

수정 전 Entity의 모든 필드를 조회했지만, 수정 후에는 필요한 필드만 조회 (7개의 필드 -> 4개의 필드) 

DTO Projection 적용 후 k6 테스트

k6 테스트 결과
  • VU 100
    VU100 결과
  • VU 200
    VU200 결과
  • VU 300
    VU300 결과
상품 페이징 조회 (3분) 총 처리량 Latency(mean) Latency(P95) TPS
VU 100 51171 350ms 603ms 283
VU 200 50892 707ms 1.31s 281
VU 300 50142 1.07s 2.31s 276

테스트 결과분석

Latency

  • Latency(mean)
    • VU 100: 377ms → 350ms (약 7.2% 개선)
    • VU 200: 839ms → 707ms (약 15.7% 개선 )
    • VU 300: 1.24s → 1.07s (약 13.7% 개선)

Latency(mean) : 약 7 ~ 14% 개선

  • Latency(P95)
    • VU 100: 664ms → 603ms (약 9.2% 개선)
    • VU 200: 1.52s → 1.31s (약 13.8% 개선)
    • VU 300: 2.78s → 2.31s (약 16.9% 개선)

Latency(P95) : 약 9 ~ 17% 개선

TPS

  • VU 100: 262 → 283 (약 8.0% 개선)
  • VU 200: 237 → 281 (약 18.6% 개선)
  • VU 300: 238 → 276 (약 16.0% 개선)

TPS : 약 8 ~ 16% 개선

기존 쿼리는 Entity의 모든 필드인 7개를 조회 했지만, DTO Projection을 통해 4개의 필드를 조회하기 때문에 위와 같은 성능 개선 효과를 보임


2. 인덱싱

실제 실행되는 쿼리와 실행 계획을 통해 인덱스를 생성

2.1. 실행 계획 및 쿼리 실행 소요 시간

2.1.1. Title 포함 조회

[실행 계획]

EXPLAIN SELECT hdp1_0.product_id,hdp1_0.hotdeal_id,hdp1_0.price,hdp1_0.stock,hdp1_0.title
        FROM hot_deal_product hdp1_0
            JOIN hot_deal hd1_0 on hd1_0.hotdeal_id=hdp1_0.hotdeal_id
        WHERE hdp1_0.hotdeal_id=9715
          AND hd1_0.status='ACTIVE'
          AND hd1_0.deleted=0
          AND hdp1_0.product_id<100000
          AND ('인기' IS NULL OR hdp1_0.title LIKE '%인기%' ESCAPE '')
        ORDER BY hdp1_0.product_id
                DESC LIMIT 10;

Image

hotdeal 테이블

  • PK 인덱스 사용
  • const : 효율적인 단일 Row 조회
  • Using filesort : ORDER BY는 hotdeal_product 테이블 기준으로 실행되는데 Using filesort 발생 중

hotdeal_product 테이블

  • index_merge -> 2개 인덱스 사용중(PRIMARY, 외래키)
  • Using intersect: 외래키 인덱스와 PRIMARY 인덱스의 교집합 연산
  • filtered: 10.67%: 조건을 만족하는 행이 전체의 약 10.67%
  • Using where: 추가 필터링 조건들이 스토리지 엔진 레벨에서 처리

[소요 시간]

Image

소요 시간 : 약 42ms

2.1.2. Title 미포함 조회

[실행 계획]

EXPLAIN SELECT hdp1_0.product_id,hdp1_0.hotdeal_id,hdp1_0.price,hdp1_0.stock,hdp1_0.title
        FROM hot_deal_product hdp1_0
                 JOIN hot_deal hd1_0 on hd1_0.hotdeal_id=hdp1_0.hotdeal_id
        WHERE hdp1_0.hotdeal_id=9715
          AND hd1_0.status='ACTIVE'
          AND hd1_0.deleted=0
          AND hdp1_0.product_id<100000
          AND (NULL IS NULL OR hdp1_0.title LIKE NULL ESCAPE '')
        ORDER BY hdp1_0.product_id
                DESC LIMIT 10;

Image

Title 포함 조회와 같음

소요 시간

Image

소요 시간 : 약 42ms

분석

  • hot_deal 테이블 : PK 인덱스를 타고 있으며, const 타입으로 처리되어 효율적 다만, Using filesort 발생
    • 정렬 기준은 hot_deal_product.product.id DESC 인데 hot_deal 테이블에서 fileSort 가 발생하는게 의문
  • hot_deal_product 테이블 : PK, 외래키 인덱스를 따로 사용하고 교집합 사용
    • 복합 인덱스 생성 필요

2.2. 인덱스 생성

  • hot_deal 테이블
    • WHERE 절에서 사용중인 status, deleted 의 카디널리티는 각각 3, 2로 인덱스 사용시 의미 없음
    • const : 효율적인 단일 Row 조회
    • 종합적으로 복합 인덱스 생성 필요가 없다고 판단
  • hot_deal_product 테이블
    • (hotdeal_id, product_id DESC) 를 갖는 복합 인덱스 생성 필요
    • %title%의 경우 양방향 와일드 카드 사용으로 인덱스 사용 불가능
      • (hotdeal_id, product_id DESC) 복합 인덱스로 후보 탐색 후 양방향 와일드 카드 조건 검사를 위해 최소한의 원본 row 조회 하는 구조로 접근

2.2.1. 복합 인덱스 생성

[인덱스 생성]

hot_deal_product 테이블 : hotdeal_id, product_id DESC 를 갖는 복합 인덱스 생성

CREATE INDEX idx_hotdeal_product_hotdeal
    ON hot_deal_product (hotdeal_id, product_id DESC);

Imagetitle 포함 조회 실행 계획Image

title 미포함 조회 실행 계획Image분석

  • hotdeal 테이블
    • Using filesort 제거 됨
  • hot_deal_product 테이블
    • 새로 생성한 복합 인덱스 사용
    • Using index condition : Ttitle 조건에 상관 없이, 복합 인덱스 원활히 사용 중
    • Using where : Title 조건 존재 시, 인덱스로 필터링 후 원본 row 에 접근해서 title 조건 검사

2.2.2. 복합 인덱스 생성 후 쿼리 실행 소요 시간Title 포함 조회Image

소요 시간 : 약 30ms

Title 미포함 조회

Image

​ 소요 시간 : 약 28ms

분석

  • title 포함 조회 : 기존 42ms => 30ms, 약 28% 개선

  • title 미포함 조회 : 기존 43ms => 28ms, 약 35% 개선 ​ 복합 인덱스 (hotdeal_id, product_id DESC) 적용을 통해 정렬, 범위 조건, 페이징 처리를 인덱스 스캔 레벨에서 효율적으로 수행 ​

인덱스 적용 후 k6 테스트 결과

k6 테스트 결과
  • VU 100
    VU100 결과
  • VU 200
    VU200 결과
  • VU 300
    VU300 결과
상품 페이징 조회 (3분) 총 처리량 Latency(mean) Latency(P95) TPS
VU 100 50861 352ms 627ms 281
VU 200 51128 703ms 1.26s 282
VU 300 51588 1.04s 2.28s 284

테스트 결과 분석

Latency

  • Latency(mean)
    • VU 100: 377ms → 352ms (약 6.6% 개선)
    • VU 200: 839ms → 703ms (약 16.2% 개선)
    • VU 300: 1.24s → 1.04s (약 16.1% 개선) ​ Latency(mean) : 약 6 ~ 16% 개선 ​
  • Latency(P95)
    • VU 100: 664ms → 627ms (약 5.6% 개선)
    • VU 200: 1.52s → 1.26s (약 17.1% 개선)
    • VU 300: 2.78s → 2.28s (약 18.0% 개선) ​ Latency(P95) : 약 5 ~ 18% 개선 ​ TPS
  • VU 100: 262 → 281 (약 7.2% 개선)
  • VU 200: 237 → 282 (약 19.0% 개선)
  • VU 300: 238 → 284 (약 19.3% 개선) ​ TPS : 약 7 ~ 19% 개선 ​ 전체적인 성능 개선 효과를 보이지만 수치를 이전 단계인 DTO Projection과 비교했을때, 같은 성능 개선 수치가 나옴즉, 성능 개선 효과가 없음

2.3. 병목 지점 확인 및 해결

  • DB 조회 속도는 인덱스로 인해 향상 됨, 이전 단계인 Dto Projection 적용에 비해 성능 개선 효과가 없음
  • JVM 상태 분석 및 Connection Pool 분석으로 병목 지점 확인 필요VU 100 으로 테스트 진행하며 자원 사용률 확인

Image

Image분석

  • CPU
    • 약 20% 사용
    • 최대 30% 이상
    • CPU 사용률 평이
    • GC로 인한 병목 보이지 않음
  • Heap Memory
    • Max : 4GB
    • 사용량 : 약 125MB
    • 최대 사용량 : 약 160MB
    • 메모리 사용량 많지 않음
  • ConnectionPool
    • ActiveConnections = 10 으로 인해 요청 대기 중인 Connection이 90대로 유지
    • 병목 지점 의심 지점
  • 해결 시도 지점
    • HikariPool의 크기 증가 후 테스트 진행2.3.1 병목 지점 해결 시도 ​ MySQL의 MaxConnecitons 확인 ​

Image

​ 현재 사용중인 MySQL 의 MaxConnections 은 151 개로 확인됨, 가용 가능한 Connection의 개수는 여유로움

  • 기본 HikariPool의 크기가 10이어서 병목 지점이 생기는것으로 파악됨
  • ConnectionPool의 크기를 증가하면 성능 개선 기대ConnectionPool 크기 설정
  • ConnectionPool 의 크기 권장 사항 => "CPU 코어 x 2 + 디스크 개수"
  • 현재 PC는 4코어, 1개의 디스크 => 9개 정도가 적당한 크기
  • 권장 사항일뿐 ConnectionPool 의 크기를 증가하여 성능 개선의 효과를 얻을 수 있음
  • 15, 20 으로 늘리며 성능 개선이 있는지 확인 ​ ConnectionPool 증가 후 K6 테스트 결과 ​​
k6 테스트 결과
  • VU 100
    VU100 결과
  • VU 200
    VU200 결과
  • VU 300
    VU300 결과
Connetion Pool 총 처리량 Latency(mean) Latency(P95) TPS
10 50861 352ms 627ms 281
15 53951 332ms 571ms 299
20 56128 318ms 540ms 311

Connection Pool에 따른 테스트 결과 분석

Latency

  • Latency(mean)
    • 352ms => 332ms => 318ms
    • 개선 모습을 보이지만 미비한 수치
  • Latency(P95)
    • 627ms => 571ms => 540ms
    • 개선 모습을 보이지만 미비한 수치 ​ TPS
  • 281 => 299 => 311
  • 개선 모습을 보이지만 미비한 수치 ​

JVM 자원 분석

ConnectionPool : 15ImageConnectionPool : 20Image분석

  • CPU : Max 구간이 변동하는 시점이 있지만 일시적인 문제로 보임
  • Heap Memory : 약 125MB => 150MB 증가
    • 아직 여유가 많이 있음 ​ ConnectionPool 병목 해결 결론
  • ConnectionPool 크기 증가로 성능 개선 효과가 크기를 기대했지만 미비
  • 현재 개발 환경의 HikariPool의 크기는 10개가 권장
  • JVM 사용률은 여유 있음
  • 다만, 현재 상품 조회 API 성능 개선을 위해 ConnectionPool 더 늘리기 보단 추후 사용자 흐름 테스트에서 늘리는게 효율적이라 판단
  • 따라서 ConnectionPool은 15개로 설정

3. 캐싱

​ 캐싱을 통해 DB에 직접 접근하는 대신, In-Memory 수준에서 빠르게 데이터 I/O 처리를 수행함으로써 응답 속도 향상 및 부하 감소 등 성능 개선 효과를 기대 ​

3.1. 캐싱 적용

    @Cacheable(cacheNames = "getProducts"
            , key = "'hotdeal:' + #hotDealId + ':products:cursor:' +" +
            " (#cursor == null ? '' : #cursor) + ':size:' + #size + ':search:' + (#search == null ? '' : #search)"
            , cacheManager = "HotDealCacheManager")

​ @Cacheable 을 사용하여 Redis 캐싱 적용 ​

  • 핫딜은 자주 변경되는 데이터가 아니라고 판단, TTL 30분 적용
  • 일관성을 위해 핫딜에 대한 수정 삭제 발생시 캐시 무효화 처리(@CacheEvict) ​

캐싱 적용 후 k6 테스트 결과 ​​

k6 테스트 결과
  • VU 100
    VU100 결과
  • VU 200
    VU200 결과
  • VU 300
    VU300 결과
상품 페이징 조회 (3분) 총 처리량 Latency(mean) Latency(P95) TPS
VU 100 73409 219ms 669ms 407
VU 200 102069 348ms 921ms 568
VU 300 110661 487ms 952ms 612

테스트 결과 분석

Latency

  • Latency(mean)
    • VU 100: 377ms → 219ms (약 41.9% 개선)
    • VU 200: 839ms → 348ms (약 58.5% 개선)
    • VU 300: 1.24s → 487ms (약 60.7% 개선) ​ Latency(mean) : 약 42 ~ 61% 개선 ​
  • Latency(P95)
    • VU 100: 664ms → 669ms (유사)
    • VU 200: 1.52s → 921ms (약 39.4% 개선)
    • VU 300: 2.78s → 952ms (약 65.7% 개선) ​ Latency(P95) : 약 40 ~ 66% 개선 ​

TPS

  • VU 100: 262 → 407 (약 55.3% 개선)
  • VU 200: 237 → 568 (약 139.7% 개선)
  • VU 300: 238 → 612 (약 157.1% 개선) ​ TPS : 약 55 ~ 157% 개선 ​

캐싱을 통해 Latency가 40 ~ 61% 개선 되었으며 (최대 절반 이상 개선), TPS는 55 ~ 157% 개선 (최대 2.5배 향상)


4. 상품 페이징 조회 성능 개선 최종 정리

항목(각 개선 단계는 이전 단계 포함) DTO Projection 적용 인덱스 적용 캐싱 적용
평균 Latency 감소율 7 ~ 14% 6 ~ 16% 40 ~ 60%
TPS 증가율 8 **~ 16%** 7~ 19% 55 ~ 157%
  • Latency(mean) : 약 **40 ~** 60% 이상 단축
  • TPS : 약 1.5 ~ 2.5배 증가 ​ 인덱싱으로 인해 쿼리 자체의 속도는 향상 되었지만, 요청 수가 많아 connectionPool 부족으로 인한 병목으로 성능 향상에 영향을 주진 않음 ​ ConnectionPool 을 늘려도 성능 개선의 수치는 미비
    => 15개로 두고 추후 사용자 흐름 테스트에서 필요하다면 증가 예정 ​ (현재 개발 환경에서의 권장 개수는 약 10개)
⚠️ **GitHub.com Fallback** ⚠️