본문 바로가기
개발/개발

상품 목록 조회 기능 개선 — 인덱스 → 캐시 → Redis 적용 과정

by 글쓰는 개발자 2026. 3. 13.

조회 기능 개선을 맡았을 때 처음 든 생각은 단순했다.

“인덱스 몇 개 추가하면 끝나겠지.”

예전에 이런 경험이 있었기 때문이다.

[MariaDB] OFFSET 페이징 & JOIN이 느릴 때 MariaDB 인덱스 최적화 경험

그때는 인덱스로 대부분 해결됐다.

그래서 이번에도 비슷할 거라고 생각했다.

하지만 실제로는 조회 API마다 해결 방법이 완전히 달랐다.


 

성능 개선 대상 API

내가 개선해야 하는 조회 API는 총 5개였다.

 
UC 설명 API
UC-1 전체 조회 + 최신순 /api/v1/products?page=0&size=20
UC-2 전체 조회 + 좋아요순 /api/v1/products?sort=likes_desc
UC-3 브랜드 필터 + 최신순 /api/v1/products?brandId=1
UC-4 브랜드 필터 + 좋아요순 /api/v1/products?brandId=1&sort=likes_desc
UC-5 브랜드 필터 + 가격순 /api/v1/products?brandId=1&sort=price_asc

 

테스트 환경

성능 개선 효과를 정확히 확인하기 위해 다음 환경에서 테스트를 진행했다.

  • 더미 데이터 81,000건
  • 부하 테스트 : K6
  • 동시 사용자 : VU 50
  • 테스트 시간 : 30초

AS-IS 성능

먼저 아무 것도 하지 않은 상태에서 현재 성능을 측정했다.

 
UC avg p95 p99 rps
UC-1 5,573ms 8,426ms 10,512ms 9.4
UC-2 5,764ms 8,762ms 9,175ms 9.4
UC-3 4,492ms 6,978ms 7,417ms 9.4
UC-4 4,261ms 6,647ms 7,184ms 9.4
UC-5 3,706ms 6,421ms 6,960ms 9.4

p99 기준으로 최대 10초가 걸렸다.

상품 목록 조회가 10초 걸리는 서비스는 사실상 사용할 수 없는 수준이다.

그래서 먼저 왜 느린지부터 확인했다.

EXPLAIN ANALYZE 결과

-> Limit: 20 row(s)
     -> Sort: p.created_at DESC
        -> Filter: status IN ('ACTIVE','OUT_OF_STOCK')
           -> Table scan on product
즉, 실제로는 이런 일이 벌어지고 있었다.
100,000 행 읽기
→ 정렬
→ 20개만 반환

상품 목록을 보여주기 위해 테이블 전체를 읽고 있었던 것이다.

이 상황에서 가장 먼저 떠올린 해결 방법은 당연히 인덱스였다.


1단계 — 인덱스 추가

조회 조건을 보면 정렬 방식이 여러 개 존재한다.

  • 최신순
  • 좋아요순
  • 가격순

그래서 각 케이스에 맞게 복합 인덱스를 설계했다.

핵심은 하나였다.

정렬 컬럼까지 인덱스에 포함해 filesort를 제거하는 것

@Table(indexes = {

@Index(name = "idx_product_status_created",
columnList = "status, created_at DESC"),

@Index(name = "idx_product_status_like",
columnList = "status, like_count DESC"),

@Index(name = "idx_product_brand_status_created",
columnList = "brand_id, status, created_at DESC"),

@Index(name = "idx_product_brand_status_like",
columnList = "brand_id, status, like_count DESC"),

@Index(name = "idx_product_brand_status_price",
columnList = "brand_id, status, price ASC")
})

 

이 인덱스가 실제로 얼마나 효과가 있는지 다시 EXPLAIN ANALYZE로 확인했다.

인덱스 적용 결과

UC 적용 전 적용 후 type
UC-1 82ms 94ms ALL
UC-2 143ms 76ms ALL
UC-3 62ms 14ms range
UC-4 67ms 13ms range
UC-5 65ms 12ms range

브랜드 필터가 있는 UC-3~5는

100,000 rows → 8,000 rows
 

로 크게 줄었다.

 

인덱스 적용 후 성능

UC 적용 전 avg 적용 후 avg 개선율
UC-1 5573ms 5139ms 0%
UC-2 5764ms 6082ms -3%
UC-3 4492ms 2089ms 51%
UC-4 4261ms 1894ms 45%
UC-5 3706ms 1574ms 51%

 

왜 전체 조회는 인덱스가 안 먹을까

UC-1 / UC-2는 브랜드 필터가 없다.

조건은 이것뿐이다.

status IN ('ACTIVE','OUT_OF_STOCK')
 문제는
전체 데이터의 약 80%가 이 상태
 

즉 옵티마이저 입장에서는 '어차피 대부분 읽어야 한다'

그래서 '인덱스 → 테이블 접근 (2번)' 보다 '풀스캔 (1번)' 이 더 빠르다고 판단한다.

결론 : 전체 조회는 인덱스로 해결되지 않는다.



 

좋아요 정렬 구조 문제

좋아요 순 정렬도 문제였다. 초기 쿼리는 다음과 같았다.

SELECT p.*, COUNT(l.id) as like_count
FROM product p
LEFT JOIN likes l ON l.product_id = p.id
GROUP BY p.id
ORDER BY like_count DESC
LIMIT 20;

 

상품 10만, 좋아요 100만이면
JOIN
+
GROUP BY
가 정렬마다 발생한다.
이 구조에서는 인덱스를 추가해도 효과가 없다.

 

해결 방법 - 비정규화

두 가지 선택지가 있었다.

  1. 비정규화
  2.  Materialized View

그런데 MySQL은 Materialized View가 없다 그래서 비정규화를 선택하였다

그래서 Product 테이블에 like_count 컬럼을 추가하는 방식을 선택했다. 

@Column(name = "like_count")
private long likeCount = 0;
 

좋아요 등록 시 이벤트로 값을 갱신한다.

이벤트 기반 업데이트

public void addLike(Long userId, Long productId) {
    likeRepository.save(new Like(userId, productId));

    eventPublisher.publishEvent(
    	new LikeCreatedEvent(userId, productId)
    );
}
 

이벤트 리스너

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeCreated(LikeCreatedEvent event) {
    productRepository.findById(event.productId()).ifPresent(product -> {
        product.incrementLikeCount();
        productRepository.save(product);
    });
}

이 구조의 트레이드오프는 하나 있다.

좋아요 직후에는 'like_count가 바로 반영되지 않을 수 있다'

하지만 대부분 수백 ms 내 반영되므로 허용 가능한 범위라고 판단했다.


2단계 — 캐시 적용 ConcurrentHashMap으로 먼저 시도했다

전체 조회는 인덱스로 해결되지 않았기 때문에 다음으로 캐시를 적용했다.

먼저 가장 단순한 방법으로 먼저 ConcurrentHashMap 기반 캐시를 사용했다.

Spring이 기본 제공하는 ConcurrentMapCacheManager는 JVM 메모리에 저장하는 로컬 캐시다.

@Bean
public CacheManager cacheManager() {
	return new ConcurrentMapCacheManager("productList");
}

@Cacheable(cacheNames = "productList",
           key = "'' + #brandId + '_' + #sort + '_' + #page + '_' + #size")
public ProductListPage getProductList(...) { ... }

캐시 적용 후 K6 결과 (ConcurrentHashMap, VU 50, 30초)

UC avg p95 p99 rps
UC-1 124ms 40ms 4002ms 271
UC-2 132ms 22ms 4992ms 271

rps가 9.4 → 271로 29배 올랐다. p95도 수십 ms 수준. 근데 p99가 이상하다.

p95가 40ms인데

p99가 4,002ms. 100배 차이가 난다.

원인은 Cache Stampede 였다

캐시가  만료되는 순간

여러 스레드
→ 동시에 DB 요청
→ 풀스캔 폭발

이 현상이 발생한다. ConcurrentMapCacheManager는 중복 요청을 막는 Lock이 없어서 그냥 다 통과시킨다.

ConcurrentHashMap 문제

문제 설명
TTL 없음 캐시가 영구 유지
분산 불가 서버마다 캐시 다름
Stale 데이터 자동 만료 없음

분산 환경도 문제다. 서버가 2대라면 각자 JVM 메모리에 독립된 캐시를 가진다. 서버 A에서 상품을 수정해서 캐시를 지워도, 서버 B의 캐시는 그대로다.  로드밸런서가 요청을 서버 B로 보내면 stale 데이터가 반환된다.

결론은

빠르긴 한데 운영 환경에서 사용 불가
 

3단계 — Redis 캐시로 전환

그래서 최종적으로  Redis 캐시로 전환했다.

// CacheConfig.java
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
                                     ObjectMapper objectMapper) {
  RedisCacheConfiguration productListConfig = RedisCacheConfiguration.defaultCacheConfig()
      .entryTtl(Duration.ofMinutes(5)) // CacheManager 설정
      .serializeValuesWith(...serialize(ProductListPage.class));

  RedisCacheConfiguration productDetailConfig = RedisCacheConfiguration.defaultCacheConfig()
      .entryTtl(Duration.ofMinutes(5)) // CacheManager 설정
      .serializeValuesWith(...serialize(ProductDetailInfo.class));

  return RedisCacheManager.builder(connectionFactory)
      .withInitialCacheConfigurations(Map.of(
          "productList", productListConfig,
          "productDetail", productDetailConfig
      ))
      .build();
}
TTL은 5분이다.

여기서 한 가지 함정이 있었다. 처음엔 Page<ProductListInfo>를 그대로 캐시에 넣으려 했더니 이런 경고가 떴다.

Serializing PageImpl instances as-is is not supported,
meaning that there is no guarantee about the stability of the resulting JSON structure!

PageImpl은 Jackson이 역직렬화할 수 있는 생성자가 없기 때문이다. 해결책은 직렬화 안전한 레코드로 래핑하는 것.

 

public record ProductListPage(
  List<ProductListInfo> content,
  int page,
  int size,
  long totalElements
) {
  public Page<ProductListInfo> toPage() {
      return new PageImpl<>(content, PageRequest.of(page, size), totalElements);
  }
}

Redis MONITOR로 실제 동작도 확인했다. 

# 첫 번째 호출 — cache miss → master에 SET
"SET" "productList::null_latest_0_5"
  "{"content":[...],"page":0,"size":5,"totalElements":80000}"
  "PX" "300000"    ← TTL 300,000ms = 5분

# 두 번째 호출 — cache hit → replica에서 GET
# redis-master MONITOR에는 GET이 안 보임
# ReadFrom.REPLICA_PREFERRED 설정으로 읽기는 replica(6380)로 라우팅되기 때문
"GET" "productList::null_latest_0_5"   ← replica MONITOR 출력

쓰기는 master, 읽기는 replica로 자동 분리된다.

 

 
 
K6 결과 (Redis 캐시, VU 50, 30초)
UC    avg   p95     p99  rps
UC-1 272ms 1658ms 4426ms 145
UC-2 295ms 518ms 6137ms 145

 ConcurrentHashMap vs Redis 비교

항목 ConcurrentHashMap  Redis
avg 124ms 272ms
rps 271 145
TTL 없음 있음(5분)
분산 불가(수동 evict 필요) 가능(TTL 자동 만료)
Stale 관리 수동 자동
모니터링 불가 Prometheus 연동

 

 avg 응답 속도는 ConcurrentHashMap이 더 빠르다. Redis는 네트워크 I/O가 추가되기 때문이다.

그런데 운영 환경에서 ConcurrentHashMap은 TTL도 없고, 분산도 안 되고, stale 데이터가 언제까지 살아있는지 알 수도 없다.

로컬에서 빠른 것과 운영에서 쓸 수 있는 것은 다른 얘기다.

캐시 무효화 전략

캐시를 쓰면 항상 따라오는 질문: "데이터가 바뀌면 어떻게 할 건데?"

아래는 productList캐시와 productDetail 캐시를 사용했을 때의 차이를 표로 나타낸 것이다.

연산  productList 캐시 productDetail 캐시 
상품 생성 전체 삭제 -
상품 수정 전체 삭제 단건 삭제
상품 비활성화 전체 삭제 단건 삭제
좋아요 변경 유지 유지
표에서 좋아요 변경 시 캐시를 삭제하지 않는 건 의도적이다.
초당 수백 번 발생할 수 있는 좋아요마다 전체 캐시를 날리면 캐시를 쓰는 의미가 없어진다고 생각했기 때문이다.
TTL 5분 내 근사값을 허용하는 트레이드오프다
  @Caching(evict = {
      @CacheEvict(cacheNames = "productList", allEntries = true),
      @CacheEvict(cacheNames = "productDetail", key = "#productId")
  })
  public AdminProductInfo updateProduct(Long productId, ...) { ... }
 

Redis 장애 시에도 서비스는 살아있어야 한다

마지막으로 한 가지 더. Redis가 다운되면 어떻게 될까?

Spring Cache 기본 동작은 Redis 연결 실패 시 예외를 그대로 던진다. 캐시 때문에 서비스 전체가 500을 내보내는 상황이 된다. 본말이 전도된 것이다.

CacheErrorHandler를 등록하면 예외를 삼키고 자동으로 DB로 폴백할 수 있다.

 
@Override
public CacheErrorHandler errorHandler() {
  return new CacheErrorHandler() {
      @Override
      public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
          log.warn("[Cache] GET 실패 - cache={}, key={}, error={}",
                   cache.getName(), key, e.getMessage());
          // 예외를 삼킴 → 캐시 미스로 처리 → DB 조회로 자동 폴백
      }
      // PUT, EVICT, CLEAR도 동일
  };
}
 

다만 EVICT 실패 시에는 stale 캐시가 TTL까지 잔존할 수 있다는 트레이드오프는 알고 있어야 했다.


최종 정리


  단계  대상      방법      효과   
1 브랜드 조회 복합 인덱스 p95 50% 개선
2 전체 조회 Redis 캐시 avg 5.5s → 272ms
구조 개선 좋아요 정렬 비정규화 JOIN 제거
안정성 Redis 장애 CacheErrorHandler DB 폴백

처음엔 "인덱스 좀 추가하면 끝나겠지"라고 생각했다.

근데 막상 해보니 같은 조회 API인데 케이스마다 답이 달랐고, 로컬 캐시로 빠르게 만들었다가 운영 환경에선 쓸 수 없다는 걸 깨닫고 Redis로 갔다.

성능 개선은 도구를 아는 것보다 어떤 문제에 어떤 도구가 맞는지 판단하는 게 더 중요하다는 걸 깨달았다.

반응형