조회 기능 개선을 맡았을 때 처음 든 생각은 단순했다.
“인덱스 몇 개 추가하면 끝나겠지.”
예전에 이런 경험이 있었기 때문이다.
[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%가 이 상태
즉 옵티마이저 입장에서는 '어차피 대부분 읽어야 한다'
결론 : 전체 조회는 인덱스로 해결되지 않는다.
좋아요 정렬 구조 문제
좋아요 순 정렬도 문제였다. 초기 쿼리는 다음과 같았다.
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
해결 방법 - 비정규화
두 가지 선택지가 있었다.
- 비정규화
- 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();
}
여기서 한 가지 함정이 있었다. 처음엔 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로 자동 분리된다.
| 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 캐시 |
| 상품 생성 | 전체 삭제 | - |
| 상품 수정 | 전체 삭제 | 단건 삭제 |
| 상품 비활성화 | 전체 삭제 | 단건 삭제 |
| 좋아요 변경 | 유지 | 유지 |
@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로 갔다.
성능 개선은 도구를 아는 것보다 어떤 문제에 어떤 도구가 맞는지 판단하는 게 더 중요하다는 걸 깨달았다.
'개발 > 개발' 카테고리의 다른 글
| 포인트는 왜 안 쌓였을까 — @TransactionalEventListener 함정 파헤치기 (0) | 2026.03.27 |
|---|---|
| [Java / Spring Boot] 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!" - 5편 (0) | 2026.03.06 |
| [Java / Spring Boot] 쿠폰 사용에 대한 처리는 어떤 락이 적절할까 - 4편 (0) | 2026.03.06 |
| [Java / Spring Boot] 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 - 3편 (0) | 2026.03.06 |
| [Java / Spring Boot] 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편 (1) | 2026.03.06 |