10만 건 상품 데이터 조회 성능 개선기 - 인덱스와 캐시 전략
문제 정의
상품 목록 조회 API가 느렸습니다. 10만 건의 데이터를 기준으로 브랜드별 가격 범위 필터링 + 정렬 + 페이징을 처리하는데 체감상 느리다는 피드백이 있었습니다. 측정을 시작했습니다.
현실적인 테스트 데이터 생성
성능 테스트는 실제 데이터와 유사한 분포로 해야 의미 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 10만 건 데이터 생성 프로시저
DELIMITER //
CREATE PROCEDURE generate_products()
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < 100000 DO
INSERT INTO product (name, brand, price, like_count, created_at)
VALUES (
CONCAT('상품_', i),
ELT(1 + FLOOR(RAND() * 10),
'Nike', 'Adidas', 'Puma', 'New Balance', 'Converse',
'Vans', 'Reebok', 'Asics', 'Saucony', 'Brooks'),
FLOOR(10000 + RAND() * 490000), -- 10,000 ~ 500,000원
FLOOR(RAND() * 10000),
DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY)
);
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
CALL generate_products();
브랜드 분포를 균등하게, 가격은 실제 상품처럼 분포시켰습니다.
1단계: 쿼리 분석
1
2
3
4
5
6
EXPLAIN SELECT *
FROM product
WHERE brand = 'Nike'
AND price BETWEEN 50000 AND 200000
ORDER BY price ASC
LIMIT 20 OFFSET 0;
EXPLAIN 결과를 보니 type: ALL이었습니다. 풀 테이블 스캔입니다. 10만 건을 모두 읽고 필터링하고 있었습니다.
2단계: 복합 인덱스 설계
단순 인덱스를 각각 거는 것보다, 쿼리 패턴에 맞는 복합 인덱스가 더 효과적입니다.
1
2
-- 브랜드 + 가격 복합 인덱스
CREATE INDEX idx_brand_price ON product(brand, price);
인덱스 컬럼 순서가 중요합니다. WHERE brand = ? 조건이 먼저 오고 ORDER BY price가 이어지는 패턴이므로 (brand, price) 순서가 맞습니다.
성능 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 인덱스 없이 강제 실행 (IGNORE INDEX)
-- 참고: SQL_NO_CACHE는 MySQL 5.7 이하에서 사용합니다.
-- MySQL 8.0 이상에서는 Query Cache가 제거되어 이 힌트가 불필요합니다.
SELECT SQL_NO_CACHE *
FROM product IGNORE INDEX (idx_brand_price)
WHERE brand = 'Nike'
AND price BETWEEN 50000 AND 200000
ORDER BY price ASC
LIMIT 20;
-- 실행 시간: 847ms
-- 인덱스 활용
SELECT SQL_NO_CACHE *
FROM product
WHERE brand = 'Nike'
AND price BETWEEN 50000 AND 200000
ORDER BY price ASC
LIMIT 20;
-- 실행 시간: 350ms
58.6% 개선. 그런데 아직 350ms는 느립니다. 상품 목록은 자주 조회되는 API이기 때문에 캐시를 적용하기로 했습니다.
3단계: Redis 캐시 적용
브랜드별 가격 범위 필터는 파라미터 조합이 많지만, 자주 사용되는 조합은 제한적입니다. Redis에 결과를 캐싱했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Service
@RequiredArgsConstructor
public class ProductQueryService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Object> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
public Page<ProductResponse> getProducts(ProductSearchRequest request, Pageable pageable) {
String cacheKey = buildCacheKey(request, pageable);
// 캐시 조회
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return (Page<ProductResponse>) cached;
}
// DB 조회
Page<ProductResponse> result = productRepository.findByCondition(request, pageable);
// 캐시 저장
redisTemplate.opsForValue().set(cacheKey, result, CACHE_TTL);
return result;
}
private String buildCacheKey(ProductSearchRequest request, Pageable pageable) {
return String.format("products:brand:%s:minPrice:%d:maxPrice:%d:page:%d:size:%d:sort:%s",
request.getBrand(),
request.getMinPrice(),
request.getMaxPrice(),
pageable.getPageNumber(),
pageable.getPageSize(),
pageable.getSort().toString()
);
}
}
캐시 적용 후 성능
1
2
인덱스만: 350ms
인덱스 + Redis 캐시 (캐시 히트): 72ms
79.4% 추가 개선. 인덱스 대비 총 91.5% 개선입니다.
4단계: 비정규화 - likeCount 컬럼
“좋아요 많은 순” 정렬 기능이 있었는데, 이 경우 like 테이블을 COUNT하는 쿼리가 실행됐습니다.
1
2
3
4
5
6
7
-- 느린 쿼리
SELECT p.*, COUNT(l.id) as like_count
FROM product p
LEFT JOIN product_like l ON p.id = l.product_id
WHERE p.brand = 'Nike'
GROUP BY p.id
ORDER BY like_count DESC;
product 테이블에 like_count 컬럼을 추가해 비정규화했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Product {
// ...
private int likeCount;
public void increaseLikeCount() {
this.likeCount++;
}
public void decreaseLikeCount() {
if (this.likeCount > 0) {
this.likeCount--;
}
}
}
JOIN 없이 단순 컬럼 정렬로 처리 가능해졌습니다.
5단계: 캐시 무효화 전략
캐시를 사용하면 데이터 일관성 문제가 생깁니다. 상품 정보나 좋아요 수가 바뀌면 캐시를 어떻게 처리할까요?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Object> redisTemplate;
@Transactional
public void updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.update(request);
// 해당 브랜드의 캐시 무효화
invalidateBrandCache(product.getBrand());
}
private void invalidateBrandCache(String brand) {
Set<String> keys = redisTemplate.keys("products:brand:" + brand + ":*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
KEYS 명령어는 Redis의 모든 키를 순회하므로 데이터가 많은 프로덕션 환경에서는 블로킹이 발생할 수 있습니다. 프로덕션에서는 SCAN 명령을 사용해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 프로덕션 권장: SCAN 기반 키 삭제
private void invalidateBrandCache(String brand) {
String pattern = "products:brand:" + brand + ":*";
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory()
.getConnection().scan(options)) {
List<String> keys = new ArrayList<>();
cursor.forEachRemaining(key -> keys.add(new String(key)));
if (!keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
SCAN은 커서 기반으로 동작해 한 번에 일부 키만 처리하므로 블로킹 없이 안전하게 패턴 삭제가 가능합니다. 큰 규모라면 캐시 키 구조를 변경해 태그 기반 무효화도 고려할 수 있습니다.
정리
성능 개선은 측정에서 시작합니다. 추측으로 최적화하면 의미 없는 작업이 됩니다.
적용한 전략 요약:
- 복합 인덱스: 쿼리 패턴에 맞는 (brand, price) 인덱스로 58.6% 개선
- Redis 캐싱: 캐시 히트 시 79.4% 추가 개선
- 비정규화: like_count 컬럼으로 JOIN 제거
- 캐시 무효화: 데이터 변경 시 관련 캐시 삭제
각 전략은 트레이드오프가 있습니다. 인덱스는 쓰기 성능에 영향을 주고, 캐시는 일관성 관리가 필요하며, 비정규화는 업데이트 복잡도를 높입니다. 측정하고 판단하는 과정이 중요합니다.