본문 바로가기
Redis

DB 인덱스 다음 단계, 캐시로 성능 높이기

by Soono991 2025. 8. 18.

캐시(Cache)

서비스의 성능을 개선하는 방법 중 하나로 캐시(Cache)가 있다.

트래픽이 많고, 동일한 데이터 조회가 반복되는 환경에서는 조회 성능을 개선하기 위해 캐시를 효과적으로 사용해 볼 수 있다.

하지만 캐시는 단순히 “빠르다”라는 장점만 있는 것이 아니라, 데이터 불일치·스템피드 등 고려해야 할 다양한 문제를 동반한다는 단점도 있다.

 

캐시를 적용하기 전에 주의할 점

캐시는 항상 첫 번째 선택지가 되어서는 안 된다.

캐시의 목적은 조회 성능을 높이는 것이지만, 조회 성능을 개선할 방법이 캐시만 있는 것은 아니기 때문이다.

먼저 내가 만든 조회 기능, 즉 쿼리 자체가 느린 것은 아닌지 점검해야 한다.

DB 인덱스를 활용해 성능을 개선할 수 있는지도 확인해야 한다.
또한 애플리케이션 로직에서 불필요한 N+1 쿼리나 성능 저하를 유발하는 코드가 없는지도 확인해야 한다.

DB, 애플리케이션 로직을 확인했음에도 여전히 조회 성능을 높이고 싶다면, 그때 비로소 캐시 도입을 고려하는 것이 맞다고 생각한다.

 

// 캐시를 도입하기 전에 점검해봐야 할 부분들
1. DB 쿼리의 성능을 점검한다. (인덱스, 쿼리 튜닝...)
2. 애플리케이션에서의 성능을 점검한다. (N+1과 같은 성능 저하를 유발하는 로직...)
3. 캐시를 고려해본다.

 

인덱스를 이용한 조회 성능 개선 내용은 아래 링크에 정리해두었다.

- 조회 성능 개선을 위한 반정규화 및 인덱스 설계 - Part. 1

- 조회 성능 개선을 위한 반정규화 및 인덱스 설계 - Part. 2

캐시란 무엇인가?

캐시는 자주 사용하는 데이터나 결과를 미리 저장해 두었다가, 재요청 시 빠르게 제공하는 기술이다.

일반적으로 우리는 데이터를 조회할 때 DB나 외부 API를 사용한다.
하지만 DB, 외부 API는 네트워크와 I/O를 거쳐야 하므로 메모리에 비해 상대적으로 느리다.
반면 캐시는 메모리 기반 저장소(예: Redis)에 데이터를 보관하여 훨씬 빠르게 데이터를 조회할 수 있다.

캐시를 적용해야 하는 이유

응답 속도 향상

캐시를 사용하면 DB, 외부 API를 거치지 않고 캐시에서 데이터를 가져오기 때문에 네트워크, Disk I/O 과정이 생략돼 응답 속도가 빠르다.

 

DB 부하 감소

캐시를 사용하지 않으면 사용자가 데이터를 조회할 때 매번 DB에 요청하게 된다.

이때 많은 사용자가 동일한 요청을 반복하게 되면 DB에 불필요한 쿼리, 부하가 발생한다.

캐시를 사용하게 되면 DB 부하를 크게 줄일 수 있고, DB의 리소스를 더 중요한 부분에 쓸 수 있어 서비스의 안정성이 높아진다.

 

비용 절감

외부 API를 호출하는 경우, 호출 횟수에 따라 과금이 된다면 사용자가 요청할 때마다 비용이 과금된다.

이때 캐시를 활용하면 일정 시간 동안은 외부 API를 호출하지 않고 캐시 데이터를 사용해 비용을 절감할 수 있다.

 

위와 같이 캐시는 단순하게 데이터를 빠르게 조회한다는 장점만 있는 것은 아니다.

다만 캐시를 적용한다고 무조건 좋아지는 것은 아니며 캐시를 적용할 때 발생할 수 있는 이슈에 대해서도 대응책과 그에 맞는 설계가 필요하다.

 

캐시 적용 시 발생할 수 있는 이슈

항목 문제 해결방안
데이터 불일치 - 캐시와 실제 원본 데이터(ex: DB)가 달라지는 현상이다.

- 예를 들어, 상품 가격을 변경했는데 해당하는 상품의 캐시를 만료, 갱신해주지 않아 변경 사항이 캐시에 반영되지 않아 발생하는 현상이다.
- 데이터 변경 시 관련 캐시 키를 삭제

- 데이터 변경 시 관련 캐시 갱신

- TTL을 짧게 설정하여 캐시를 만료 & 등록 처리
캐시 스템피드 - 캐시가 만료되는 시점에 동시에 다수의 요청이 발생했을 때 DB 에 부하가 발생하는 현상 - Singleflight: 락(lock)을 사용해 락을 획득한 경우에만 캐시 갱신 처리, 나머지는 대기, 실패 처리 (비추천)

- Early Refresh: TTL 만료 전에 캐시를 미리 갱신해 캐시가 만료되지 않고 유지되도록 함

- Soft TTL + Hard TTL: Hard TTL로 절대 만료 시간을 지정하고, Soft TTL 동안은 기존 데이터를 반환 + 백그라운드에서 캐시 갱신
메모리 관리 - 캐시 데이터가 많아 메모리가 꽉차게 되면 Redis의 경우 삭제 정책에 따라 기존 캐시 데이터를 삭제한다. - 삭제 정책을 적절히 설정한다. (LRU, LFU)

- 주기적으로 사용 빈도가 낮은 키는 삭제
의존성 - 캐시에 너무 의존하게 되면 캐시 장애 시 서비스 성능이 저하된다. - 캐시 장애 시 동작할 폴백 로직(fallback) 필요

- 멀티 레이어 캐시 구조 (L1, L2) 도입

 

위와 같이 캐시는 만능이 아니기 때문에, 위 이슈에 대해서 대응책과 그에 맞는 설계를 꼭 해야 한다.

무엇을 캐시 할 것인가?

캐시를 사용하기로 결정했다면, 다음으로는 "무엇을 캐시할 것인가?"를 정해야 한다.

아무 데이터나 무작정 캐싱하는 것은 단순한 메모리 낭비를 넘어, 경우에 따라 성능 저하로 이어질 수 있다.
그 이유를 이해하려면 먼저 메모리와 디스크의 차이를 알아야 한다.

 

메모리 vs 디스크

메모리(RAM)디스크(SSD/HDD)에 비해 속도가 수십~수백 배 빠르지만, 대신 비용이 훨씬 비싸다.
서버의 메모리는 용량이 제한적이기 때문에, 불필요한 데이터를 장기간 보관하면 비싼 자원을 낭비하게 된다.

그리고 디스크는 메모리에 비해 훨씬 저렴하고 용량이 크지만, 상대적으로 속도가 느려 캐시 목적에는 적합하지 않다.

 

휘발성

DB는 영속성이 중요하기 때문에 디스크 기반으로 데이터를 저장한다.

디스크는 전원이 꺼져도 데이터가 보존되지만 메모리에 비해 속도가 느리다.

 

캐시는 메모리에 데이터를 저장하기 때문에 디스크에 비해 데이터 조회 속도는 빠르다.

하지만 메모리에 저장된 데이터는 휘발성이므로, 서버 재시작이나 장애가 발생 시 데이터가 유실될 수 있다.

 

이런 이유로 캐시 대상은 많이 조회되지만, 잘 변하지 않는 데이터로 선별할 수 있다.

많이 변경되는 데이터를 캐시로 할 경우 캐시를 자주 갱신해야 하기 때문에 오히려 캐싱 효율이 떨어질 수 있다.

 

캐시로 저장할만한 데이 텅 유형

대상 예시 주의점
조회 빈도가 높고 변경이 적은 데이터 상품 상세 정보, 카테고리 목록.. 데이터 변경이 발생하면 해당 키를 무효화하거나 갱신하는 로직이 필요하다.
계산 비용이 큰 데이터 매출 집계, 랭킹, 통계... 정확성이 중요한 데이터라면 갱신 주기를 명확히 설정해야 한다.
페이지네이션 목록 최신 글, 인기 게시글 목록... 검색 조건과 페이지 정보에 따른 캐시 전략을 세워야 한다.
ex:
검색 조건이 있는 요청은 캐시 x
조건이 없는 요청만 캐시 o

 

Spring에서 Cache 사용

더보기

Spring의 cache를 사용하면 쉽게 cache를 적용할 수 있다.

 

package com.loopers.support.config.cache

// ...

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun redisCacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager {
        val objectMapper = ObjectMapper()
            .registerModule(JavaTimeModule())
            .registerKotlinModule()  // 코틀린 지원 추가
            .registerModule(JavaTimeModule()) // 날짜 및 시간 객체 지원 추가
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build(),
                ObjectMapper.DefaultTyping.EVERYTHING,
            )
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        // config
        val redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofSeconds(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer(objectMapper)),
            )
            .disableCachingNullValues()
            .entryTtl(Duration.ofSeconds(10))
        // 캐시 이름마다 다른 TTL 설정 Option
        val cacheConfigurations = mapOf(
            PRODUCT_DETAIL to redisCacheConfig.entryTtl(Duration.ofSeconds(30)),
            BRAND_DETAIL to redisCacheConfig.entryTtl(Duration.ofSeconds(30)),
            CacheNames.PRODUCT_LIKE_COUNT to redisCacheConfig.entryTtl(Duration.ofSeconds(30)),
        )

        // build CacheManager
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(redisCacheConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build()
    }

    object CacheNames {
        const val PRODUCT_FACADE_DETAIL = "product:facade:detail"
        const val PRODUCT_DETAIL = "product:detail"
        const val BRAND_DETAIL = "brand:detail"
    }
}

 

그리고 사용하는 쪽에서는 애노테이션만 설정해 주면 쉽게 redis를 이용한 cache를 적용할 수 있게 된다.

 

@Cacheable(value = [BRAND_DETAIL], key = "#brandId", unless = "#result == null")
fun findBrandBy(brandId: Long): BrandEntity? {
    return brandRepository.findById(brandId)
}
       
@Cacheable(value = [PRODUCT_DETAIL], key = "#id", unless = "#result == null")
fun findProductBy(id: Long): ProductEntity? {
    return productRepository.findById(id)
}

 

애노테이션 설명 주요 속성 사용 예시
@Cacheable 메서드 실행 결과를 캐시에 저장하고, 동일 파라미터 호출 시 캐시에서 반환. 캐시에 없을 때만 메서드 실행 value / cacheNames, key, condition, unless @Cacheable(value = "products", key = "#id")
@CachePut 메서드를 항상 실행하고, 실행 결과를 캐시에 갱신. 수정/등록 후 최신 상태 유지에 사용 value / cacheNames, key, condition, unless @CachePut(value = "products", key = "#product.id")
@CacheEvict 캐시에서 특정 키 또는 모든 항목 제거. 삭제·변경 시 캐시 무효화에 사용 value / cacheNames, key, allEntries, beforeInvocation @CacheEvict(value = "products", key = "#id")
@Caching 하나의 메서드에 여러 개의 @Cacheable, @CachePut, @CacheEvict 조합 적용 cacheable, put, evict @Caching(evict = {
   @CacheEvict(value=“products”, key=”#id”),
   @CacheEvict(value=“productDetails”, key=”#id”)})

 

 

@Cacheable 대신 RedisTemplate을 직접 쓰는 이유

위와 같이 @Cacheable, @CachePut, @CacheEvict 같은 Spring Cache 애노테이션을 사용하여 Spring Boot에서 캐시를 쉽게 적용할 수 있다.

하지만 이 방식에는 한계가 있어 실무에서는 RedisTemplate을 직접 사용하는 경우가 많다.

 

Spring Cache의 단점

항목 내용
제한된 키 전략 @Cacheable(key="..")로 커스터마이징이 가능하지만, 조건 분기나 복잡한 키 생성은 불편하다.
TTL 제어 한계 TTL을 조회 시 연장하는 정책을 구현하기 어렵다.
갱신 로직 제약 조건부 무효화, 백그라운드 갱신 같은 전략을 적용하기 어렵다.
자료구조 제한 key-value 구조만 기본 지원하며, Redis의 자료구조(Hash, Sorted Set, List ...)등을 활용하기 어렵다.
Redis 전용 기능 부족 분산 락, Streams, Pub/Sub 같은 Redis 고유 기능을 사용할 수 없다.

 

 

CacheKey, CacheRepository, CacheRedisRepository 구현

 

더보기
object CacheNames {
    const val PRODUCT_DETAIL_V1 = "product:detail:v1:"
    const val BRAND_DETAIL_V1 = "brand:detail:v1:"
}

class CacheKey(
    val prefix: String,
    val key: String,
    val ttl: Duration = Duration.ofSeconds(10), // TTL 10초는 실제 운영에 적용하기에는 매우 짧지만 테스용으로 설정
) {

    fun fullKey(): String {
        return "$prefix$key"
    }

}

interface CacheRepository {
    fun <T> get(cacheKey: CacheKey, clazz: Class<T>): T?

    fun <T> set(cacheKey: CacheKey, value: T)

    fun evict(key: String)
}

@Component
private class CacheRedisRepository(
    private val redisTemplate: RedisTemplate<String, String>,
) : CacheRepository {

    override fun <T> get(cacheKey: CacheKey, clazz: Class<T>): T? {
        val value = redisTemplate.opsForValue().get(cacheKey.fullKey()) ?: return null
        return runCatching { DataSerializer.deserialize(value, clazz) }.getOrNull()
    }

    override fun <T> set(cacheKey: CacheKey, value: T) {
        runCatching {
            DataSerializer.serialize(value)?.let {
                redisTemplate.opsForValue().set(cacheKey.fullKey(), it, cacheKey.ttl)
            }
        }.onFailure { e ->
            throw RuntimeException("Failed to serialize and set value for key: ${cacheKey.prefix}", e)
        }
    }

    override fun evict(key: String) {
        runCatching {
            redisTemplate.delete(key)
        }.onFailure { e ->
            throw RuntimeException("Failed to delete key: $key", e)
        }
    }

}

object DataSerializer {
    private val log = LoggerFactory.getLogger(DataSerializer::class.java)
    private val objectMapper = initialize()

    private fun initialize(): ObjectMapper {
        return ObjectMapper()
            .registerModule(JavaTimeModule())
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
    }

    fun <T> deserialize(data: String?, clazz: Class<T>): T? {
        try {
            return objectMapper.readValue(data, clazz)
        } catch (e: JsonProcessingException) {
            log.error("[DataSerializer.deserialize] data={}, clazz={}", data, clazz, e)
            return null
        }
    }

    fun <T> deserialize(data: Any?, clazz: Class<T>): T? {
        return objectMapper.convertValue(data, clazz)
    }

    fun <T> serialize(data: T): String? {
        try {
            return objectMapper.writeValueAsString(data)
        } catch (e: JsonProcessingException) {
            log.error("[DataSerializer.serialize] object={}", data, e)
            return null
        }
    }
}

 

CacheKey, CacheNames를 통해서 Redis에 저장될 키에 대해 관리를 하고,

DataSerializer를 통해서 Redis에 저장될 값에 대한 직렬화/역직렬화에 대해 관리를 해봤다.

상품 목록·상품 상세 조회별 캐시 전략

상품 목록 조회

상품 목록 조회는 검색 조건과 페이지 정보에 따라 조합의 수가 매우 많기 때문에 모든 경우를 캐싱하는 것은 비효율적이다.
따라서, 어디까지 캐시를 적용할지에 대한 선택이 필요하다.

1. 검색 조건이 포함된 요청 → 캐시하지 않음
2. 검색 조건이 없는 요청 → 정렬 조건을 기준으로 캐시 적용
3. 페이지 범위 → 1~2 페이지까지만 캐시 적용

 

목록 조회의 경우 대부분의 트래픽이 검색 조건이 없는 1~2 페이지 요청에 집중되므로,
이 구간에만 캐시를 적용하고 나머지는 직접 DB를 조회하도록 했다.

 

캐시에 저장할 데이터 결정

캐시에 저장할 데이터는 보통 다음 두 가지 방식 중에서 선택할 수 있다.

1. 상품 ID 목록을 저장한다.
2. 상품 목록 정보 전체를 저장한다. (적용)

 

이번 구현에서는 상품, 브랜드, 상품 좋아요 수를 모두 포함하는 별도의 조회 모델을 사용했다.
만약 1번처럼 상품 ID만 캐싱할 경우, 이를 다시 DB에서 조회해야 하므로 캐시 효과가 크지 않다.
따라서, 상품 목록 전체 데이터를 캐시에 저장하여 바로 반환할 수 있도록 했다.

 

캐싱이 가능한지 검증하는 정책 설정

 

더보기
object ProductListCachePolicy {
    private val ALLOWED_SORT_PROPERTIES = setOf("name", "price", "createdAt", "likeCount")

    // page 0,1만 캐시 & 정렬 허용 체크, 검색 조건이 비어있을 때만 캐시 가능
    fun isCacheable(condition: ProductSearchCondition, pageable: Pageable): Boolean {
        if (!condition.isEmpty()) return false
        if (pageable.pageNumber !in 0..1) return false
        val sortAllowed = pageable.sort == Sort.unsorted() ||
                pageable.sort.map { it.property }.all { it in ALLOWED_SORT_PROPERTIES }
        return sortAllowed
    }

    fun normalizeSort(sort: Sort): String =
        if (sort.isUnsorted) "unsorted"
        else sort.joinToString(",") { "${it.property}:${if (it.isAscending) "asc" else "desc"}" }

    // SHA-256으로 compact key 구성
    private fun sha256(s: String): String {
        val md = MessageDigest.getInstance("SHA-256")
        val dig = md.digest(s.toByteArray())
        return BigInteger(1, dig).toString(16).padStart(64, '0')
    }

    /**
     * CacheKey 생성 (TTL은 테스트에선 10초)
     */
    fun buildCacheKey(
        pageable: Pageable,
        ttl: Duration? = null,
    ): CacheKey {
        val sort = normalizeSort(pageable.sort)
        val base = "v1|p=${pageable.pageNumber}|s=${pageable.pageSize}|sort=$sort"
        val digest = sha256(base)
        return ttl
            ?.let { CacheKey(CacheNames.PRODUCT_LIST_V1, digest, it) }
            ?: CacheKey(CacheNames.PRODUCT_LIST_V1, digest)
    }
}

 

@Component
class ProductFacade(
	// ...
) {

    private val log = LoggerFactory.getLogger(ProductFacade::class.java)

    @Transactional(readOnly = true)
    fun searchProducts(condition: ProductSearchCondition, pageable: Pageable): Page<ProductInfo.ProductList> {
        // 페이지네이션과 정렬이 캐시 가능한지 확인
        val cacheable = ProductListCachePolicy.isCacheable(pageable)
        val cacheKey = if (cacheable) {
            ProductListCachePolicy.buildCacheKey(pageable, Duration.ofMinutes(3))
        } else null

        // 캐시 조회
        cacheKey?.let {
            cacheRepository.get(cacheKey, ProductListPageCacheValue::class.java)?.let { cached ->
                log.info("[Cache Hit] Product List: $it")
                return PageImpl(
                    cached.content.map { viewModel -> ProductInfo.ProductList.from(viewModel) },
                    pageable,
                    cached.totalElements,
                )
            }
        }

        // DB 조회
        val searchProductsPage = productQueryService.searchProducts(condition, pageable)
        val searchProductContentsMap = searchProductsPage.content.map(ProductInfo.ProductList::from)
        val pageInfo = PageImpl(searchProductContentsMap, pageable, searchProductsPage.totalElements)

        // 캐시 저장
        cacheKey?.let {
            log.info("[Cache Miss] Product List: $it")
            val data = ProductListPageCacheValue(
                searchProductsPage.content,
                pageable.pageNumber,
                pageable.pageSize,
                searchProductsPage.totalElements,
                ProductListCachePolicy.normalizeSort(pageable.sort),
            )
            cacheRepository.set(cacheKey, data)
        }

        return pageInfo
    }
}

 

상품 목록의 경우 CacheKey의 key로 쿼리 스트링을 해싱한 값을 사용하여 동일한 파라미터로 요청한 경우를 식별했다.

그리고 캐시에 목록과 페이징 정보를 함께 저장하기 위해 캐시용 객체 ProductListPageCacheValue를 생성했다.

상품 상세 조회

@Component
class ProductFacade(
	// ...
) {
    @Transactional(readOnly = true)
    fun getProduct(id: Long): ProductInfo.ProductDetail {
        val product = productService.findProductBy(id) ?: throw CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. $id")
        val brand = brandService.findBrandBy(product.brandId) ?: throw CoreException(
            ErrorType.NOT_FOUND,
            "브랜드를 찾을 수 없습니다. ${product.brandId}",
        )
        val productLikeCount = productLikeService.getProductLikeCount(product.id)
        return ProductInfo.ProductDetail.from(product, brand, productLikeCount)
    }
}

 

상품 상세 조회는 ProductFacade.getProduct(id) 메서드를 통해 구현했다.

이 메서드는 상품 정보(product), 브랜드 정보(brand), 상품 좋아요 수(product_like_count)를 조회한 뒤 조합하여 최종 ProductDetail 객체를 반환한다.

 

그래서 상품 상세 조회 시 캐싱을 적용하는 방법은 크게 두 가지가 있다.

1. ProductFacade.getProduct(id) 자체에 캐시 적용 (ProductDetail 저장)
2. 상품, 브랜드, 상품 좋아요 수 정보에 각각 캐시 적용
 - 상품: ProductService.findProductBy(productId)
 - 브랜드: BrandService.findBrandBy(brandId)
 - 상품 좋아요 수: ProductLikeService.getProductLikeCount(productId)

 

장단점

방법 장점 단점
ProductFacade.getProduct()에 캐시 적용 캐시 조회 시 1번만 요청하면 된다. 상품, 브랜드, 상품 좋아요 수 정보가 수정되었을 때 갱신 로직이 복잡하다.
각 서비스에 캐시 적용 상품, 브랜드, 상품 좋아요 수 정보가 수정되었을 때 갱신 로직이 비교적 수월하다. 캐시 조회 시 각 서비스별로 요청해야 한다.

 

조회만 보면 ProductFacade.getProduct()에 캐시를 적용하는 것이 합당해 보이지만, 캐시 갱신까지 고려하면 각 서비스에 캐시를 적용하는 것이 더 적절하다고 생각한다.

ProductFacade.getProduct() 캐시 갱신에 대해서는 아래와 같은 문제가 있다.

* ProductFacade.getProduct()는 상품, 브랜드, 상품 좋아요 수 정보가 포함되어 있다.
1. 상품 정보가 변경되었을 때 ProductService에서 상품, 브랜드, 상품 좋아요 수 정보를 갱신해야 하는데,
ProductService가 브랜드, 상품 좋아요 수 정보까지 갱신하는 것은 책임과 역할에서 어긋난다.
2. 브랜드, 상품 좋아요 수 정보가 변경되었을 때, 이 역시도 각 서비스에서 다른 정보까지 갱신하는 것은
책임과 역할에서 어긋난다.
3. 그렇다면 캐시 갱신을 하지 않고 캐시를 삭제하는 방법을 생각해볼 수 있는데, 그렇게 되면 상품, 브랜드, 상품
좋아요 수 정보가 변경될 때마다 캐시가 삭제되어 캐시를 다시 등록하는 과정에서 캐시 스템피드 현상이 발생할 수 있다.

 

 

각 서비스에 캐시 적용

여기서 한 가지 더 고려해야 할 점이 있다.
상품 좋아요 수(product_like_service)는 변경 빈도가 높아, 캐시를 적용하더라도 금방 무효화되는 경우가 많아 캐싱 효율이 떨어질 가능성이 크다.
따라서, 상품 좋아요 수에는 캐시를 적용하지 않는 것이 바람직하다고 판단했다.

반면, 브랜드(brand)는 데이터량이 많지 않고 변경도 드물어 캐시 적중률이 높을 것으로 예상된다.
따라서 브랜드 데이터에는 캐시를 적용하기로 했다.

상품(product)의 경우 브랜드에 비해 데이터량이 많아 캐시 적중률은 다소 낮을 수 있다.
그러나 변경 주기가 길어, 캐시를 적용하면 조회 성능 향상에 도움이 될 것으로 보여 캐시를 적용하기로 했다.

 

그래서 다음과 같이 브랜드, 상품에는 캐시 적용, 상품 좋아요 수는 캐시를 적용하지 않기로 결정했다.

 

더보기
// Spring Cache 사용
@Service
class BrandService(
    private val brandRepository: BrandRepository,
) {
    @Cacheable(value = [BRAND_DETAIL], key = "#brandId", unless = "#result == null")
    fun findBrandBy(brandId: Long): BrandEntity? {
        return brandRepository.findById(brandId)
    }
}

@Service
class ProductService(
    private val productRepository: ProductRepository,
) {
    @Cacheable(value = [PRODUCT_DETAIL], key = "#id", unless = "#result == null")
    fun findProductBy(id: Long): ProductEntity? {
        return productRepository.findById(id)
    }
}

// RedisTemplate 사용
@Service
class BrandService(
    private val brandRepository: BrandRepository,
    private val cacheRepository: CacheRepository,
) {

    private val log = LoggerFactory.getLogger(BrandService::class.java)

    @Transactional(readOnly = true)
    fun findBrandBy(brandId: Long): BrandEntity? {
        val cache = cacheRepository.get(
            CacheKey(CacheNames.BRAND_DETAIL_V1, brandId.toString()),
            BrandEntity::class.java,
        )
        // 캐시가 존재
        cache?.let {
            log.info("[Cache Hit] Brand: $cache")
            return it
        }
        val brand = brandRepository.findById(brandId)
        // 캐시 저장
        brand?.let {
            log.info("[Cache Miss] Brand: $it")
            cacheRepository.set(CacheKey(CacheNames.BRAND_DETAIL_V1, brandId.toString()), it)
        }
        return brand
    }
}

@Service
class ProductService(
    private val productRepository: ProductRepository,
    private val cacheRepository: CacheRepository,
) {

    private val log = LoggerFactory.getLogger(ProductService::class.java)

    @Transactional(readOnly = true)
    fun findProductBy(id: Long): ProductEntity? {
        val cache = cacheRepository.get(CacheKey(CacheNames.PRODUCT_DETAIL_V1, id.toString()), ProductEntity::class.java)
        // 캐시가 존재
        cache?.let {
            log.info("[Cache Hit] Product: $cache")
            return it
        }
        val product = productRepository.findById(id)
        // 캐시가 저장
        product?.let {
            log.info("[Cache Miss] Product: $it")
            cacheRepository.set(CacheKey(CacheNames.PRODUCT_DETAIL_V1, id.toString()), it)
        }
        return product
    }
}

 

캐시 적용 후 테스트 코드 작성

상품 목록 조회 캐시 테스트 코드

더보기
@DisplayName("상품 목록 조회 Facade 캐시 테스트, ")
    @Nested
    inner class ProductListCache {
        @DisplayName("상품 목록 조회 시 검색 조건이 있으면 캐시에 저장되지 않고 DB를 조회한다.")
        @Test
        fun searchProducts_WithCondition_CacheMiss() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())
            productJpaRepository.save(aProduct().brandId(createdBrand.id).build())
            val pageRequest = PageRequest.of(0, 10)
            val condition = ProductSearchCondition(brandId = createdBrand.id)

            // act
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)

            // assert
            verify(productQueryService, times(2)).searchProducts(
                condition,
                pageRequest,
            )
        }

        @DisplayName("상품 목록 조회 시 1 페이지는 캐시에 저장되어 이후 요청 시 캐시를 사용한다.")
        @Test
        fun searchProducts_Pageable_CacheHit() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())
            productJpaRepository.save(aProduct().brandId(createdBrand.id).build())
            val pageRequest = PageRequest.of(0, 10)
            val condition = ProductSearchCondition()

            // act
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)
            productFacade.searchProducts(condition, pageRequest) // 캐시 히트

            // assert
            verify(productQueryService, times(1)).searchProducts(condition, pageRequest)
        }

        @DisplayName("상품 목록 조회 시 2 페이지는 캐시에 저장되어 이후 요청 시 캐시를 사용한다.")
        @Test
        fun searchProducts_PageableSecondPage_CacheHit() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())
            productJpaRepository.save(aProduct().brandId(createdBrand.id).build())
            val pageRequest = PageRequest.of(1, 10)
            val condition = ProductSearchCondition()

            // act
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)
            productFacade.searchProducts(condition, pageRequest) // 캐시 히트

            // assert
            verify(productQueryService, times(1)).searchProducts(condition, pageRequest)
        }

        @DisplayName("상품 목록 조회 시 3 페이지부터는 캐시에 저장되지 않고 DB를 조회한다.")
        @Test
        fun searchProducts_PageableThirdPage_CacheMiss() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())
            productJpaRepository.save(aProduct().brandId(createdBrand.id).build())
            val pageRequest = PageRequest.of(2, 10)

            // act
            val condition = ProductSearchCondition()
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)

            // assert
            verify(productQueryService, times(2)).searchProducts(condition, pageRequest)
        }

        @DisplayName("상품 목록 조회 시 등록일 오름차순으로 정렬하면 캐시에 저장되어 이후 요청 시 캐시를 사용한다.")
        @Test
        fun searchProducts_SortedByCreatedAtAsc_CacheHit() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())
            productJpaRepository.save(aProduct().brandId(createdBrand.id).build())
            val pageRequest = PageRequest.of(0, 10, Sort.by("createdAt").ascending())
            val condition = ProductSearchCondition()

            // act
            productFacade.searchProducts(condition, pageRequest) // 캐시 미스 (DB 조회)
            productFacade.searchProducts(condition, pageRequest) // 캐시 히트

            // assert
            verify(productQueryService, times(1)).searchProducts(
                condition,
                pageRequest
            )
        }
    }
}

 

브랜드, 상품 상세 조회 캐시 테스트 코드

더보기
@SpringBootTest
class ProductCacheServiceIntegrationTest @Autowired constructor(
    private val productJpaRepository: ProductJpaRepository,
    private val databaseCleanUp: DatabaseCleanUp,
    private val redisCleanUp: RedisCleanUp,
    private val cacheRepository: CacheRepository,
) {
    @MockitoSpyBean
    lateinit var productRepository: ProductRepository

    @Autowired
    lateinit var productService: ProductService

    @AfterEach
    fun tearDown() {
        databaseCleanUp.truncateAllTables()
        redisCleanUp.truncateAll()
    }

    @DisplayName("상품 상세 조회 캐시 테스트, ")
    @Nested
    inner class ProductDetailCache {

        @DisplayName("상품 상세 조회 시 첫 호출은 DB를 조회하고, 두번째 호출은 캐시를 사용한다.")
        @Test
        fun getProductById_CacheHit() {
            // arrange
            val createdProduct = productJpaRepository.save(aProduct().build())

            // act
            productService.findProductBy(createdProduct.id) // 캐시 미스 (DB 조회)
            productService.findProductBy(createdProduct.id) // 캐시 히트

            // assert
            verify(productRepository, times(1)).findById(createdProduct.id)
        }

        @DisplayName("상품 상세 조회 시 캐시가 만료되면 다시 DB를 조회한다.")
        @Test
        // 메서드명은 영어로
        fun getProductById_CacheMiss() {
            // arrange
            val createdProduct = productJpaRepository.save(aProduct().build())

            // act
            productService.findProductBy(createdProduct.id) // 캐시 미스 (DB 조회)
            cacheRepository.evict("${CacheNames.PRODUCT_DETAIL_V1}${createdProduct.id}") // 강제 캐시 제거
            productService.findProductBy(createdProduct.id) // 캐시 미스 (DB 조회)

            // assert
            verify(productRepository, times(2)).findById(createdProduct.id)
        }
    }
}


@SpringBootTest
class BrandCacheServiceIntegrationTest @Autowired constructor(
    private val brandJpaRepository: BrandJpaRepository,
    private val databaseCleanUp: DatabaseCleanUp,
    private val redisCleanUp: RedisCleanUp,
    private val cacheRepository: CacheRepository,
) {
    @MockitoSpyBean
    lateinit var brandRepository: BrandRepository

    @Autowired
    lateinit var brandService: BrandService

    @AfterEach
    fun tearDown() {
        databaseCleanUp.truncateAllTables()
        redisCleanUp.truncateAll()
    }

    @DisplayName("브랜드 상세 조회 캐시 테스트, ")
    @Nested
    inner class BrandDetailCache {
        @DisplayName("브랜드 상세 조회 시 첫 호출은 DB를 조회하고, 두번째 호출은 캐시를 사용한다.")
        @Test
        fun getBrandById_CacheHit() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())

            // act
            brandService.findBrandBy(createdBrand.id) // 캐시 미스 (DB 조회)
            brandService.findBrandBy(createdBrand.id) // 캐시 히트

            // assert
            verify(brandRepository, times(1)).findById(createdBrand.id)
        }

        @DisplayName("브랜드 상세 조회 시 캐시가 만료되면 다시 DB를 조회한다.")
        @Test
        fun getBrandById_CacheMiss() {
            // arrange
            val createdBrand = brandJpaRepository.save(aBrand().build())

            // act
            brandService.findBrandBy(createdBrand.id) // 캐시 미스 (DB 조회)
            cacheRepository.evict("${CacheNames.BRAND_DETAIL_V1}${createdBrand.id}") // 캐시 제거
            brandService.findBrandBy(createdBrand.id) // 캐시 미스 (DB 조회)

            // assert
            verify(brandRepository, times(2)).findById(createdBrand.id)
        }
    }
}

 

 

캐시 적용 테스트는 캐시가 존재할 경우 DB 조회를 하지 않는지, 캐시가 존재하지 않을 경우 DB 조회를 하는지 여부를 검증하면 되기 때문에 Spy를 이용하여 실제 DB(repository) 호출 횟수를 검증했다.

 

캐시 적용 전·후 성능 비교

상품 목록 조회

상품 목록 조회 성능 테스트의 경우 먼저 캐시를 등록하기 위한 warm up 요청 후에 성능 테스트를 할 수 있도록 했다.

 

더보기

 

import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
    stages: [
        { duration: '2s', target: 1 },   // 워밍업: 1 VU로 2초
        { duration: '30s', target: 100 }, // 본 테스트: 100 VU로 30초
    ]
};

// 실행 시 환경변수로 파라미터를 주입
const params = {
    name: __ENV.NAME,           // 예: --env NAME=Pro
    brandId: __ENV.BRAND_ID,    // 예: --env BRAND_ID=1
    sort: __ENV.SORT,           // 예: --env SORT=latest
    page: __ENV.PAGE,           // 예: --env PAGE=0
    size: __ENV.SIZE,           // 예: --env SIZE=20
};

// 쿼리스트링 생성 함수
function buildUrl(baseUrl, queryParams) {
    const entries = Object.entries(queryParams).filter(
        ([, value]) => value !== undefined && value !== null && value !== ''
    );
    if (entries.length === 0) {
        return baseUrl;
    }
    const queryString = entries
        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
        .join('&');
    return `${baseUrl}?${queryString}`;
}

export default function () {
    const baseUrl = 'http://localhost:8080/api/v1/products';
    const url = buildUrl(baseUrl, params);

    const res = http.get(url);

    check(res, {
        'status is 200': (r) => r.status === 200,
    });

    sleep(1);
}

 

캐시 미적용
캐시 적용

 

케이스 TPS p(90) p(95)
캐시 미적용 약 26 약 3s 약 3s
캐시 적용 약 46 약 10ms 약 12ms

 

TPS는 약 1.5배, p(90)은 약 300배, p(95)의 약 250배가 차이가 나는 것을 확인할 수 있다.

이처럼 목록의 경우 캐시를 하기에는 까다롭긴 하지만 캐시를 적용하고 나서는 굉장한 조회 성능을 확인할 수 있으므로 캐시 범위를 정책적으로 잘 고려해서 적용하는 것이 좋다.

상품 상세 조회

상품 상세 조회 성능 테스트의 경우 무작위 상품으로 테스트 시 캐시 적중률이 낮아 캐시 미적용과 성능이 크게 다르지 않았다.

그래서 특정 상품 Id로 고정하여 첫 요청에만 캐시 미스, 그 후에는 캐시 히트로 캐시 적용에 대한 테스트를 하는 것이 의미가 있다고 판단하여 아래와 같이 특정 상품 Id를 고정한 후 테스트를 진행해 봤다.

 

더보기
export let options = {
    vus: 1000, // 동시에 실행할 가상 사용자 수
    duration: '30s' // 테스트 지속 시간
};

export default function () {
    // const productId = Math.floor(Math.random() * 1000) + 1; // 예시로 1부터 1000까지의 ID를 사용
    // const url = `http://localhost:8080/api/v1/products/${productId}`;
    const url = 'http://localhost:8080/api/v1/products/1';

    const res = http.get(url);

    check(res, {
        'status is 200': (r) => r.status === 200,
    });

    sleep(1); // 요청 간 대기 시간
}

 

캐시 미적용
ProductFacade.getProduct(id) 캐시 적용
ProductService.findProductBy(id) BrandService.findBrandBy(id) 캐시 적용

 

케이스 TPS p(90) p(95)
캐시 미적용 약 941 약 76ms 약 199ms
ProductFacade.getProduct(id)
캐시 적용
약 976 약 10ms 약 24ms
ProductService.findProductBy(id)
BrandService.findBrandBy(id)
캐시 적용
약 963 약 33ms 약 87ms

 

확실히 ProductFacade.getProduct(id)에 캐시를 적용했을 때 성능 지표가 가장 좋았다.
다만, 앞서 언급했듯이 이 방식은 상품, 브랜드, 상품 좋아요 수 변경 시 캐시 갱신 로직을 반드시 고려해야 한다.

현재는 이러한 갱신 요구사항이 없어 각 서비스 단에서 개별적으로 캐시를 적용하고 있지만,
만약 향후 해당 요구사항이 추가된다면 조회 성능이 가장 좋은 ProductFacade.getProduct(id)에 캐시를 적용한 뒤,
상품·브랜드·상품 좋아요 수 변경 이벤트를 발행하여 캐시를 갱신하는 방식으로 구현해 볼 것 같다.

 

더 나아가기

멀티 레이어 캐시(Multi-layer Cache)

캐시를 한 단계 더 고도화하려면 멀티 레이어 캐시(Multi-layer Cache) 전략을 고려해 볼 수 있다.

 

캐시 특징 장점 단점 예시
L1 - Local 애플리케이션 인스턴스 내부 메모리에 저장되며, 같은 인스턴스 내 요청에서만 사용 가능 초고속 응답(메모리 접근), 네트워크 오버헤드 없음 인스턴스가 다르면 데이터가 공유되지 않음, 재시작 시 데이터 초기화 Guava Cache, Caffeine ...
L2 - Global Redis, Memcached와 같이 네트워크를 통해 여러 인스턴스가 공유하는 캐시 인스턴스 간 데이터 일관성 유지, 서버 재시작에도 데이터 유지 가능(조건부) L1에 비해 접근 속도가 느림(네트워크 I/O), 운영 복잡도 증가 Redis,
Memcached ...

 

멀티 레이어 캐시 적용 흐름

1. L1 캐시 조회
 - 로컬 메모리에서 데이터를 조회
 - 존재하면 응답
2. L1 미스
 - L1에 없다면 L2에서 조회
 - 존재하면 L1에 저장 후 응답
3. L2 미스
 - L2에도 없다면 DB에서 데이터를 읽고, L1과 L2에 모두 저장.

 

멀티 레이어 캐시의 장단점

장점 단점
속도와 일관성: L1에서 빠른 응답, L2에서 데이터 공유 가능 동기화: L1과 L2간 데이터 불일치 가능성 고려
DB 부하 최소화: 두 단계의 캐시를 거쳐 DB 접근 횟수 최소화 메모리 관리: L1은 서버 메모리, L2는 Global(Redis) 메모리를 차지하므로 저장 용량과 TTL 관리 필요
장애 대응력 향상: L2 장애 시에도 L1이 버퍼 역할을 수행 복잡도 증가: 단일 캐시 구조보다 운영과 디버깅 난이도가 높음

 

정리

멀티 레이어 캐시는 처음부터 도입하기보다는, 단일 캐시 구조를 먼저 적용한 후 확장하는 방식이 좋다.

 

참고 링크

https://mangkyu.tistory.com/371

 

캐시 스템피드 현상 방지

캐시 스템피드는 간단하게 말해 “캐시가 만료되는 순간, 평소에는 캐시가 막아주던 트래픽이 한 번에 원본 서버로 몰려드는 폭주 상황"이다.

캐시가 만료되는 순간 캐시를 갱신해야 하는데, 이때 수많은 요청이 동시에 접근하게 되면서 동시에 캐시를 갱신하기 위해 원본(DB)을 조회하기 때문에 많은 부하가 발생하며 이는 서비스 장애로도 이어질 수 있다.

 

캐시 스템피드 현상은 여러 개의 캐시키, 하나의 캐시키의 경우로 나눠서 볼 수 있다.

1. 여러 개의 캐시가 동시에 만료되었을 때, 이를 갱신하기 위한 동시에 수많은 요청이 폭주할 경우
2. 하나의 캐시가 만료되었을 때, 이를 갱신하기 위해 동시에 수많은 요청이 폭주할 경우

 

여러 개의 캐시키

여러 개의 캐시키의 경우 캐시키마다 만료 시간을 다르게 지정하여 갱신 요청에 대한 부하를 분산해 볼 수 있다.

이를 위해서 지터(jitter) 개념을 활용하는데, 지터는 전자 신호를 읽는 과정에서 발생하는 짧은 지연 시간을 의미한다.

지터처럼 짧은 시간을 각 캐시 만료 시간에 더해서 캐시마다 만료 시간을 다르게 설정할 수 있다.

 

Jitter 코드 적용

@Component
private class CacheRedisRepository(
    private val redisTemplate: RedisTemplate<String, String>,
) : CacheRepository {

	// ...
    
    override fun <T> set(cacheKey: CacheKey, value: T) {
        runCatching {
            val jitteredTtl = jitteredTtl(cacheKey.ttl)
            log.info("[jitteredTtl] key: ${cacheKey.fullKey()}, baseTTL: ${cacheKey.ttl.toMillis()}, jitteredTTL: ${jitteredTtl.toMillis()}")
            DataSerializer.serialize(value)?.let {
                redisTemplate.opsForValue().set(cacheKey.fullKey(), it, jitteredTtl)
            }
        }.onFailure { e ->
            throw RuntimeException("Failed to serialize and set value for key: ${cacheKey.prefix}", e)
        }
    }

    private fun jitteredTtl(baseTTL: Duration): Duration {
        val baseTTLMs = baseTTL.toMillis()
        val delta = (baseTTLMs * JITTER_PERCENTAGE).toLong() // 지터 최대 폭
        val offset = Random.nextLong(-delta, delta + 1)
        val jittered = (baseTTLMs + offset).coerceAtLeast(MINIMUM_TTL_MINUTES.toMillis())

        return Duration.ofMillis(jittered)
    }

    companion object {
        private val JITTER_PERCENTAGE = 0.15 // ±15%의 지터를 추가하여 TTL을 설정
        private val MINIMUM_TTL_MINUTES = Duration.ofMinutes(5) // 최소 TTL은 5분으로 설정
    }

}
// Jitter 추가 후 ttl 확인
[jitteredTtl] key: brand:detail:v1:1, baseTTL: 600000, jitteredTTL: 560493
[jitteredTtl] key: product:detail:v1:2, baseTTL: 600000, jitteredTTL: 526124
[jitteredTtl] key: product:detail:v1:3, baseTTL: 600000, jitteredTTL: 670978
[jitteredTtl] key: product:detail:v1:4, baseTTL: 600000, jitteredTTL: 561386
[jitteredTtl] key: product:detail:v1:5, baseTTL: 600000, jitteredTTL: 594051

 

 

하나의 캐시키

하나의 캐시키의 경우 모든 요청이 동일 캐시키를 참조하기 때문에 TTL이 아무리 랜덤이라고 해도 해당 키는 하나뿐이므로 만료 순간에는 캐시 스템피드 현상이 발생한다.

따라서 하나의 캐시키에서는 "만료 순간에도 기존 값을 사용할 수 있도록" 구조를 잡아야 한다.

이를 위해서 TTL을 Hard TTL, Soft TTL로 나누는 방법을 고려해 볼 수 있다.

 

TTL 목적
Hard TTL 물리적 TTL을 의미하며 Hard TTL이 만료될 경우 Redis에서 데이터가 삭제된다.
Soft TTL 내부에서 사용할 논리적 TTL을 의미하며 Soft TTL이 만료될 경우 내부에서 캐시 갱신을 시도하며
사용자에게는 해당 캐시 데이터를 응답한다.

 

 

TODO: Hard TTL, Soft TTL 적용

 

'Redis' 카테고리의 다른 글

Redis를 사용하여 상품 랭킹 구현하기  (1) 2025.09.12
Redis 설치  (0) 2023.04.23

댓글