Post

10만 건 상품 데이터 조회 성능 개선기 - 인덱스와 캐시 전략

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은 커서 기반으로 동작해 한 번에 일부 키만 처리하므로 블로킹 없이 안전하게 패턴 삭제가 가능합니다. 큰 규모라면 캐시 키 구조를 변경해 태그 기반 무효화도 고려할 수 있습니다.

정리

성능 개선은 측정에서 시작합니다. 추측으로 최적화하면 의미 없는 작업이 됩니다.

적용한 전략 요약:

  1. 복합 인덱스: 쿼리 패턴에 맞는 (brand, price) 인덱스로 58.6% 개선
  2. Redis 캐싱: 캐시 히트 시 79.4% 추가 개선
  3. 비정규화: like_count 컬럼으로 JOIN 제거
  4. 캐시 무효화: 데이터 변경 시 관련 캐시 삭제

각 전략은 트레이드오프가 있습니다. 인덱스는 쓰기 성능에 영향을 주고, 캐시는 일관성 관리가 필요하며, 비정규화는 업데이트 복잡도를 높입니다. 측정하고 판단하는 과정이 중요합니다.

This post is licensed under CC BY 4.0 by the author.