TL;DR
- 10만 건의 상품 데이터를 효율적으로 처리하기 위해 DB 인덱스를 최적화하고, Redis 캐시를 도입했다.
들어가며
'상품 목록 조회'와 '상품 상세 조회'는 이커머스 서비스에서 가장 빈번하게 호출되면서도 성능에 민감한 기능이다.
사용자가 원하는 상품을 찾기 위해 브랜드별로 필터링하고, 가격순/최신순/좋아요순으로 정렬한다. 데이터가 적을 떄는 문제가 없지만, 데이터가 10만 건, 100만 건으로 늘어난다면 조회 성능에 문제가 될 것이다.
이번 글에서는 10만 건의 상품 데이터 환경에서 DB 인덱스 튜닝과 Redis 캐싱 전략을 전용해서, 조회 성능을 개선하는 과정과 시행착오를 공유하고자 한다.
성능 개선 대상 API 및 테스트 데이터 설명
1. 상품 목록 조회 (/products)
- 필터링 : 특정 브랜드(Brand ID) 상품만 필터링해서 조회
- 정렬 : 최신순(기본), 가격 낮은순, 좋아요 많은순
- 페이지네이션
2. 상품 상세 조회 (/products/{id})
- 상품 단건 조회
테스트 데이터 스펙
부하 테스트를 위해 다음과 같이 더미 데이터를 구성했다.
- 브랜드 : 100개
- 상품 : 100,000개 (각 브랜드당 약 1,000개의 상품이 있음)
- 가격 : 1,000원 ~ 1,000,000원
- 좋아요 수 : 0 ~ 100,000개
- 생성일 : 최근 1년간 데이터 분포
1. 인덱스 튜닝
DB는 인덱스가 없으면 10만 개의 데이터를 Full Table Scan 해야 한다.
이를 방지하기 위해 인덱스를 추가했다.
초기 인덱스 설계
처음에는 모든 정렬과 필터 조건을 조합해 6개의 복합 인덱스를 만들었다.
@Table(
name = "products",
indexes = {
@Index(name = "idx_products_brand_deleted_created", columnList = "brand_id,is_deleted,created_at"),
@Index(name = "idx_products_brand_deleted_price", columnList = "brand_id,is_deleted,price"),
@Index(name = "idx_products_brand_deleted_likes", columnList = "brand_id,is_deleted,like_count"),
@Index(name = "idx_products_deleted_created", columnList = "is_deleted,created_at"),
@Index(name = "idx_products_deleted_price", columnList = "is_deleted,price"),
@Index(name = "idx_products_deleted_likes", columnList = "is_deleted,like_count")
}
)
즉 브랜드 + 정렬 조건 3개, 전제 조회 + 정렬 조건 3개 = 총 6개 모두 인덱스를 만들었다.
// 좋아요 수 내림차순
mysql> EXPLAIN
-> SELECT * FROM products
-> WHERE brand_id = 90 AND is_deleted = false
-> ORDER BY like_count DESC
-> LIMIT 80, 20;
+----+-------------+----------+------------+------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+---------+-------------+------+----------+---------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+---------+-------------+------+----------+---------------------+
| 1 | SIMPLE | products | NULL | ref | idx_products_brand_deleted_created,idx_products_brand_deleted_price,idx_products_brand_deleted_likes,idx_products_deleted_created,idx_products_deleted_price,idx_products_deleted_likes | idx_products_brand_deleted_likes | 9 | const,const | 1000 | 100.00 | Backward index scan |
+----+-------------+----------+------------+------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+---------+-------------+------+----------+---------------------+
1 row in set, 1 warning (0.02 sec)
하지만 EXPLAIN 분석 결과
좋아요 수 내림차순으로 조회할 때나, 생성일자 내림차순으로 조회할 때
인덱스를 거꾸로 읽는 현상이 발생해 효율이 떨어졌다. (Backward Index Scan)
또한, 상품을 등록하거나 수정할 때마다 6개의 인덱스를 갱신해야 한다는 점에서 쓰기 성능이 저하될 것이라고 생각했다.
최종 인덱스 구조
@Table(
name = "products",
indexes = {
@Index(name = "idx_brand_id", columnList = "brand_id"), // 브랜드 필터링
@Index(name = "idx_global_price", columnList = "is_deleted, price"), // 가격순
@Index(name = "idx_global_likes", columnList = "is_deleted, like_count DESC"), // 좋아요순 (내림차순)
@Index(name = "idx_global_latest", columnList = "is_deleted, created_at DESC") // 최신순 (내림차순)
}
)
브랜드 필터링은 별도 인덱스만 유지하는 방식으로 변경했고, 좋아요순과 최신순은 내림차순으로 정렬될 수 있도록 했다.
EXPLAIN 분석 결과
mysql> EXPLAIN
-> SELECT * FROM products
-> WHERE is_deleted = false
-> ORDER BY like_count DESC
-> LIMIT 80, 20;
+----+-------------+----------+------------+------+-----------------------------------------------------+------------------+---------+-------+-------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+-----------------------------------------------------+------------------+---------+-------+-------+----------+-------+
| 1 | SIMPLE | products | NULL | ref | idx_global_price,idx_global_likes,idx_global_latest | idx_global_likes | 1 | const | 49794 | 100.00 | NULL |
+----+-------------+----------+------------+------+-----------------------------------------------------+------------------+---------+-------+-------+----------+-------+
1 row in set, 1 warning (0.01 sec)
DESC 인덱스를 명시해서 정렬 방향이 일치하기 때문에 인덱스를 정방향으로 스캔한다. (Backward Index Scan 아님!)
그리고 브랜드 필터링과 정렬을 같이 했을 경우는 다음과 같다.
mysql> EXPLAIN
-> SELECT * FROM products
-> WHERE is_deleted = false AND brand_id = 90
-> ORDER BY like_count DESC
-> LIMIT 80, 20;
+----+-------------+----------+------------+------+------------------------------------------------------------------+--------------+---------+-------+------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+------------------------------------------------------------------+--------------+---------+-------+------+----------+-----------------------------+
| 1 | SIMPLE | products | NULL | ref | idx_brand_id,idx_global_price,idx_global_likes,idx_global_latest | idx_brand_id | 8 | const | 1000 | 50.00 | Using where; Using filesort |
+----+-------------+----------+------------+------+------------------------------------------------------------------+--------------+---------+-------+------+----------+-----------------------------+
1 row in set, 1 warning (0.01 sec)
Extra가 Using where; Using filesort로 나온다.
Filesort가 발생하지만, 브랜드당 상품 데이터가 약 1,000건 수준이므로 DB 메모리 내에서 충분히 빠르게 처리 가능하다고 판단했다. 인덱스를 더 늘리기보다 쓰기 성능과의 트레이드 오프를 고려해서 결정했다.
2. Redis 캐시 도입
인덱스로 조회 속도는 개선되었지만, 트래픽이 몰리면 DB I/O 자체가 병목이 될 수 있다.
이를 해결하기 위해 Redis를 통해 캐시를 도입했다.
캐시 전략 Cache Aside
Cache Aside 전략을 적용했고, 데이터 조회 시 로직은 아래와 같다.
- 상품 목록 및 상품 상세 조회 시 먼저 캐시(레디스)를 확인한다.
- 만약 캐시에 원하는 데이터가 있다면 DB를 거치지 않고 바로 반환한다.
- 캐시에 데이터가 없다면 DB에서 조회한 뒤, 레디스에 저장하고 클라이언트에게 반환한다.
TTL 설정
private static final Duration PRODUCT_DETAIL_TTL = Duration.ofMinutes(1);
private static final Duration PRODUCT_LIST_TTL = Duration.ofMinutes(1);
private static final long REFRESH_THRESHOLD_SECONDS = 10;
데이터의 성격을 고려해서 만료 시간을 설정했다.
- 상품 상세 : 1분
- 상품 목록 : 1분
- 갱신 임계값 : 10초
상품 정보나 목록은 1분 정도의 갱신 지연이 비즈니스에 치명적이지 않다고 판단했다.
무효화 전략 Stale-While-Revalidate
Cache Aside 전략의 문제 중 하나가 Cache Stampede 문제이다.
인기 상품의 캐시가 만료되는 순간, 많은 요청이 동시에 DB로 몰릴 수 있다. 이를 막기 위해 Stale-While-Revalidate 전략을 사용했다.
Stale-While-Revalidate이란, 캐시 TTL이 만료되거나 임계값에 도달했을 때, 기존 캐시 데이터를 사용자에게 반환하고, 백그라운드에서 데이터를 갱신하는 전략이다.
즉, 만료가 임박했다면, 일단 옛날 데이터를 주고 뒤에서 갱신한다! 이다.
이러한 무효화 전략은 다음과 같이 구현했다.
Long ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
if (ttl != null && ttl < REFRESH_THRESHOLD_SECONDS) {
log.info("키 {}의 TTL이 임계값 이하입니다. 비동기 새로고침을 실행합니다.", cacheKey);
refreshProductCache(productId, cacheKey, dbSupplier, PRODUCT_DETAIL_TTL);
}
@Async("cacheRefreshExecutor")
public void refreshProductCache(Long productId, String cacheKey, Supplier<ProductInfo> dbSupplier, Duration ttl) {
String lockKey = "lock:" + cacheKey;
DistributedLock.LockHandle lockHandle = distributedLock.tryLock(lockKey, Duration.ofSeconds(1), Duration.ofSeconds(10));
if (lockHandle == null) {
log.debug("다른 스레드가 이미 캐시를 갱신 중입니다: cacheKey={}", cacheKey);
return;
}
try {
log.info("캐시 갱신을 위한 락 획득: cacheKey={}", cacheKey);
ProductInfo productInfo = dbSupplier.get();
redisTemplateMaster.opsForValue().set(cacheKey, productInfo, ttl);
log.info("상품 상세 캐시 갱신 완료: productId={}", productId);
} catch (Exception e) {
log.error("캐시 갱신 실패: cacheKey={}", cacheKey, e);
} finally {
distributedLock.releaseLock(lockHandle);
}
}
- 조회한 캐시 데이터의 TTL을 체크해서 수명이 얼마나 남았나 체크한다.
- 만약 TTL이 위에서 설정해준 갱신 임계값인 10초보다 덜 남았다면
- 사용자에게 현재 캐시 데이터를 반환하고
- 동시에 캐시 갱신 작업을 수행한다.
- 캐시 갱신 작업을 할 땐, 분산락을 통해 사용자들이 동시에 갱신 구간에 진입하더라도 하나의 스레드만 DB를 조회하도록 redisTemplateMaster를 통해 락을 건다.
좋아요와 정합성
상품 엔티티에는 like_count 필드가 포함되어 있어, 좋아요를 등록하거나 취소하면 상품 데이터 자체를 갱신해줘야 한다.
처음에는 좋아요 등록/취소 요청이 들어오면 DB 데이터를 업데이트하고 -> 관련 캐시를 모두 삭제(Evict)하는 방식으로 구현했다.
하지만 이렇게 하니까 좋아요를 누를 때마다 삭제해야 할 캐시 키가 너무 많고 복잡했다. 그리고 캐시 삭제 직후 조회가 몰리면 DB 부하도 급증할 수 있다.
그래서 다음과 같이 해결했다.
- 좋아요 등록/취소 시 캐시를 삭제하지 않는다.
- 1분의 TTL이 지나거나, Stale-While-Revalidate 로직이 동작할 때 최신 좋아요 수가 반영되도록 한다.
즉, 실시간 정합성을 포기하는 대신, 시스템의 복잡도를 낮추고 조회 성능을 확보하는 최종적 일관성을 선택했다.
조회 플로우 요약
1. Cache Hit(TTL >= 10초)
- Redis 조회 -> 즉시 반환
2. Cache Hit (TTL < 10초 - 갱신 임박)
- Redis 조회 -> 기존 데이터 즉시 반환
- (백그라운드) 분산 락 획득 -> DB 조회 -> Redis 갱신
3. Cache Miss
- Redis 조회 -> DB 조회 -> Redis 저장 -> 반환
성능 검증 (K6 부하 테스트)
테스트 시나리오
1. 최신순 조회 (sort=LATEST)
2. 가격순 조회 (sort=PRICE_ASC)
3. 좋아요순 조회 (sort=LIKES_DESC)
4. 브랜드 필터링 : 특정 브랜드 상품만 조회
5. 페이지네이션
최대 100명의 동시 접속자가 상품을 조회하는 상황을 가정해서 부하 규모를 산정했다.
- 최대 VU : 100명
- 테스트 지속 시간 : 총 5분 (최대 부하 구간 2분)
- 1회 순회 시간 : 약 10초
- 예상 요청량
- 최대 부하 구간 (2분, 100VU)
- 1인당 수행 횟수 : 약 12회
- 총 Iteration : 100명 X 12회 = 1,200회
- API 호출 수 : 약 12,000건
- 전체 테스트 (5분)
- 총 예상 API 호출 : 약 15,000 ~ 20,000건
- 최대 부하 구간 (2분, 100VU)
인덱스와 캐시 도입 전
HTTP
http_req_duration..............: avg=57.76ms min=26.7ms med=41.6ms max=845.75ms p(90)=81.99ms p(95)=122.56ms
{ expected_response:true }...: avg=57.76ms min=26.7ms med=41.6ms max=845.75ms p(90)=81.99ms p(95)=122.56ms
...
CUSTOM
response_time_brand_filter.....: avg=59.89802 min=27 med=43 max=845 p(90)=85 p(95)=125
response_time_latest...........: avg=57.224448 min=28 med=41 max=846 p(90)=80 p(95)=117.85
response_time_likes_desc.......: avg=57.121317 min=27 med=42 max=835 p(90)=80 p(95)=118
response_time_pagination.......: avg=58.765654 min=27 med=42 max=778 p(90)=84.9 p(95)=128.45
response_time_price_asc........: avg=57.027855 min=27 med=41 max=830 p(90)=83 p(95)=126
...
http_reqs......................: 19548 70.30443/s
- 평균 응답 속도 : 57.76 ms
- P95 : 122.56 ms
- RPS : 70.3
인덱스와 캐시 도입 후
테스트 시나리오 추가
좋아요 등록 트래픽이 추가되었다.
1. 최신순 조회 (sort=LATEST)
2. 가격순 조회 (sort=PRICE_ASC)
3. 좋아요순 조회 (sort=LIKES_DESC)
4. 브랜드 필터링 : 특정 브랜드 상품만 조회
5. 페이지네이션
6. 좋아요 등록
HTTP
http_req_duration..............: avg=21.13ms min=750µs med=8.76ms max=1.38s p(90)=36.81ms p(95)=55.15ms
{ expected_response:true }...: avg=21.13ms min=750µs med=8.76ms max=1.38s p(90)=36.81ms p(95)=55.15ms
...
CUSTOM
response_time_brand_filter.....: avg=22.272077 min=1 med=9 max=1390 p(90)=37 p(95)=57
response_time_latest...........: avg=18.642329 min=1 med=7 max=1334 p(90)=30 p(95)=45
response_time_likes_desc.......: avg=18.911199 min=1 med=8 max=1322 p(90)=31 p(95)=45
response_time_pagination.......: avg=18.457326 min=1 med=8 max=1214 p(90)=35 p(95)=52
response_time_price_asc........: avg=19.345585 min=1 med=7 max=1379 p(90)=32 p(95)=47
...
http_reqs......................: 20272 73.063164/s
- 평균 응답 속도 : 21.13 ms (약 2.7배 향상)
- P95 : 55.15 ms (약 2.2배 향상)
- RPS : 73.0
결론
튜닝 후 시나리오에 '좋아요 등록'이라는 DB 쓰기 부하가 추가되었음에도 불구하고, 응답 속도는 3배 가까이 빨라졌다.
대부분의 조회 요청을 레디스가 처리해주면서 DB 부하가 감소했고,
캐시가 없을 때(Cache Miss) 발생하는 DB 조회도 인덱스를 통해 빠르게 처리되었다.