Spring Boot + Redis 캐시 설정과 Cache-Aside 구현

2025년 12월 05일

experience

시리즈캐시 패턴#2
# Cache# Redis# Spring Boot# Kotlin# Cache-Aside

들어가며

1편에서 캐시 패턴의 개념을 살펴봤습니다. 이번 편에서는 실제로 Spring Boot + Redis 환경을 구성하고, 가장 기본적인 Cache-Aside 패턴을 구현해봅니다.

ℹ️

이번 글의 범위

  • 다루는 것: Redis 설정, Cache-Aside 수동 구현, @Cacheable 사용법 비교
  • 다루지 않는 것: Write-Through/Behind (3편), 클러스터 구성
💡

전체 코드는 GitHub에서 확인할 수 있습니다.


프로젝트 설정

1
의존성 추가
Spring Boot, Redis, Redisson 의존성을 추가합니다.
// build.gradle.kts
dependencies {
    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    implementation("org.springframework.boot:spring-boot-starter-cache")

    // Kotlin
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // Redisson (분산락용)
    implementation("org.redisson:redisson-spring-boot-starter:3.24.3")

    // Database
    runtimeOnly("com.mysql:mysql-connector-j")
}
2
Docker Compose 설정
Redis와 MySQL을 Docker로 실행합니다.
# docker-compose.yml
version: '3.8'

services:
  redis:
    image: redis:7-alpine
    container_name: cache-pattern-redis
    ports:
      - "6380:6379"
    command: redis-server --appendonly yes

  mysql:
    image: mysql:8.0
    container_name: cache-pattern-mysql
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: cache_pattern
      MYSQL_USER: app
      MYSQL_PASSWORD: app
3
application.yml 설정
Spring Boot 애플리케이션 설정을 구성합니다.
spring:
  datasource:
    url: jdbc:mysql://localhost:3307/cache_pattern
    username: app
    password: app

  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

  data:
    redis:
      host: localhost
      port: 6380

  cache:
    type: redis

logging:
  level:
    com.example.cachepattern: DEBUG
    org.springframework.cache: TRACE
docker-compose up -d

Redis 설정

RedisTemplate 설정

@Configuration
class RedisConfig {

    @Bean
    fun objectMapper(): ObjectMapper {
        val typeValidator = BasicPolymorphicTypeValidator.builder()
            .allowIfBaseType(Any::class.java)
            .build()

        return ObjectMapper().apply {
            registerModule(KotlinModule.Builder().build())
            registerModule(JavaTimeModule())
            activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL)
        }
    }

    @Bean
    fun redisTemplate(
        connectionFactory: RedisConnectionFactory,
        objectMapper: ObjectMapper
    ): RedisTemplate<String, Any> {
        return RedisTemplate<String, Any>().apply {
            setConnectionFactory(connectionFactory)
            keySerializer = StringRedisSerializer()
            valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper)
            hashKeySerializer = StringRedisSerializer()
            hashValueSerializer = GenericJackson2JsonRedisSerializer(objectMapper)
        }
    }
}

기본 Java Serialization 대신 JSON을 쓰는 이유:

장점
  • 가독성 - Redis CLI에서 데이터 확인 가능
  • 호환성 - 클래스 구조 변경에 유연함
  • 언어 독립 - 다른 언어 서비스와 데이터 공유 가능
단점
  • 약간의 오버헤드 - JSON 파싱 비용이 있지만 대부분의 경우 무시할 만함
❌ Before
# Java Serialization
> GET product:1
"\xac\xed\x00\x05sr\x00\x1dcom.example..."
# 읽을 수 없음
✅ After
# JSON Serialization
> GET product:1
{"id":1,"name":"맥북","price":2390000}
# 읽기 쉬움
JSON은 사람이 읽을 수 있고 디버깅이 쉽습니다

CacheManager 설정

@Bean
fun cacheManager(
    connectionFactory: RedisConnectionFactory,
    objectMapper: ObjectMapper
): CacheManager {
    val defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .serializeKeysWith(
            RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
        )
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(
                GenericJackson2JsonRedisSerializer(objectMapper)
            )
        )

    // 캐시별 TTL 설정
    val cacheConfigs = mapOf(
        "products" to defaultConfig.entryTtl(Duration.ofMinutes(30)),
        "product-details" to defaultConfig.entryTtl(Duration.ofHours(1)),
        "view-counts" to defaultConfig.entryTtl(Duration.ofMinutes(5))
    )

    return RedisCacheManager.builder(connectionFactory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(cacheConfigs)
        .build()
}
캐시 이름TTL근거
products30분상품 기본 정보, 자주 안 바뀜
product-details1시간상세 설명, 거의 안 바뀜
view-counts5분자주 바뀜, 정확도보다 성능 우선

도메인 설정

Product 엔티티

@Entity
@Table(name = "products")
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @Column(nullable = false)
    var name: String,

    @Column(nullable = false)
    var price: Int,

    @Column(nullable = false)
    var viewCount: Long = 0,

    @Column(nullable = false)
    var stock: Int = 0,

    @Column(nullable = false)
    var description: String = ""
) {
    // JPA 엔티티는 data class 대신 일반 class 사용

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Product) return false
        if (id == 0L) return false
        return id == other.id
    }

    override fun hashCode(): Int = id.hashCode()
}
⚠️

왜 data class를 안 쓰나요?

JPA 엔티티에 data class를 쓰면 문제가 생깁니다:

  1. equals/hashCode: id가 포함되는데, 영속화 전후로 달라짐
  2. 프록시 문제: final class라서 Lazy Loading 프록시 생성 불가
  3. copy(): 의도치 않게 같은 ID로 새 객체 생성 가능

Repository

interface ProductRepository : JpaRepository<Product, Long> {

    @Modifying
    @Query("UPDATE Product p SET p.price = :price WHERE p.id = :id")
    fun updatePrice(@Param("id") id: Long, @Param("price") price: Int): Int

    @Modifying
    @Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity")
    fun decreaseStock(@Param("id") id: Long, @Param("quantity") quantity: Int): Int
}

Cache-Aside 패턴 구현

가장 직접적인 방법입니다. 캐시 로직을 명시적으로 코드에 작성합니다.

📦
캐시 확인
Redis에서 키 조회
📦
캐시 히트?
데이터 있으면 바로 반환
📦
DB 조회
캐시 미스 시 DB에서 조회
📦
캐시 저장
조회 결과를 캐시에 저장
@Service
class CacheAsideProductService(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>
) {
    private val log = LoggerFactory.getLogger(javaClass)

    companion object {
        private const val CACHE_KEY_PREFIX = "cache-aside:product:"
        private val CACHE_TTL = Duration.ofMinutes(30)
    }

    /**
     * 상품 조회 - Cache-Aside 읽기 패턴
     */
    fun getProduct(id: Long): Product? {
        val cacheKey = "$CACHE_KEY_PREFIX$id"

        // 1. 캐시 확인
        val cached = redisTemplate.opsForValue().get(cacheKey)
        if (cached != null) {
            log.debug("Cache HIT - key: {}", cacheKey)
            return cached as Product
        }

        log.debug("Cache MISS - key: {}", cacheKey)

        // 2. DB 조회
        val product = productRepository.findById(id).orElse(null)
            ?: return null

        // 3. 캐시 저장
        redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
        log.debug("Cache SET - key: {}, ttl: {}", cacheKey, CACHE_TTL)

        return product
    }

    /**
     * 상품 가격 수정 - Cache-Aside 쓰기 패턴
     */
    @Transactional
    fun updatePrice(id: Long, price: Int): Product? {
        val cacheKey = "$CACHE_KEY_PREFIX$id"

        // 1. DB 업데이트
        val updated = productRepository.updatePrice(id, price)
        if (updated == 0) {
            log.warn("Product not found - id: {}", id)
            return null
        }

        // 2. 캐시 삭제 (갱신이 아닌 삭제!)
        val deleted = redisTemplate.delete(cacheKey)
        log.debug("Cache DELETE - key: {}, deleted: {}", cacheKey, deleted)

        return productRepository.findById(id).orElse(null)
    }
}
수동 구현의 장단점
장점
  • 명시적 - 캐시 로직이 코드에서 직접 보임
  • 유연성 - 세밀한 제어 가능 (조건부 캐싱 등)
  • 디버깅 - 문제 발생 시 추적이 쉬움
단점
  • 보일러플레이트 - 반복적인 코드 증가
  • 실수 가능성 - 캐시 삭제 깜빡할 수 있음

@CachePut vs @CacheEvict

❌ Before
// @CachePut: 항상 메서드 실행하고 결과를 캐시에 저장
@CachePut(cacheNames = ["products"], key = "'product:' + #id")
fun updateAndCache(id: Long, data: UpdateRequest): Product
✅ After
// @CacheEvict: 캐시 삭제
@CacheEvict(cacheNames = ["products"], key = "'product:' + #id")
fun updateAndEvict(id: Long, data: UpdateRequest): Product
1편에서 배운 것처럼 @CacheEvict (삭제)가 더 안전합니다
ℹ️
  • @CachePut은 동시성 문제로 stale 데이터가 캐시에 남을 수 있음
  • @CacheEvict는 순서가 꼬여도 캐시가 비어있으니 다음 조회 시 최신 데이터 로드

방법 비교

🌳
캐시 로직이 복잡한가요?
💡

실무에서는 보통 @Cacheable을 기본으로 쓰고, 특수한 경우에만 수동 구현을 합니다.


API 테스트

Controller

@RestController
@RequestMapping("/api/products")
class ProductController(
    private val cacheAsideService: CacheAsideProductService,
    private val readThroughService: ReadThroughProductService
) {

    // Cache-Aside (수동 구현)
    @GetMapping("/cache-aside/{id}")
    fun getProductCacheAside(@PathVariable id: Long): ResponseEntity<Product> {
        val product = cacheAsideService.getProduct(id)
        return product?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
    }

    @PutMapping("/cache-aside/{id}/price")
    fun updatePriceCacheAside(
        @PathVariable id: Long,
        @RequestBody request: UpdatePriceRequest
    ): ResponseEntity<Product> {
        val product = cacheAsideService.updatePrice(id, request.price)
        return product?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
    }

    // Read-Through (@Cacheable)
    @GetMapping("/read-through/{id}")
    fun getProductReadThrough(@PathVariable id: Long): ResponseEntity<Product> {
        val product = readThroughService.getProduct(id)
        return product?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
    }
}

data class UpdatePriceRequest(val price: Int)

테스트 시나리오

T1
첫 번째 조회
캐시 미스 → DB 조회
T2
두 번째 조회
캐시 히트
T3
가격 수정
DB 업데이트 → 캐시 삭제
T4
다시 조회
캐시 미스 → 새로운 가격 로드

Redis CLI로 확인

docker exec -it cache-pattern-redis redis-cli

# 캐시 확인
127.0.0.1:6379> GET "cache-aside:product:1"
"{\"@class\":\"com.example.cachepattern.domain.Product\",\"id\":1,\"name\":\"맥북 프로 14인치\",\"price\":2500000...}"

# TTL 확인
127.0.0.1:6379> TTL "cache-aside:product:1"
(integer) 1756  # 남은 초

# 캐시 삭제
127.0.0.1:6379> DEL "cache-aside:product:1"

주의사항


마치며

수동 구현
RedisTemplate
명시적이고 유연함
선언적 구현
@Cacheable
간결하고 실수 방지
쓰기 전략
삭제
@CacheEvict 권장

다음 편에서는 Write-Through와 Write-Behind 패턴을 구현합니다.


시리즈 목차

  1. 캐시 패턴 개요 및 비교
  2. Spring Boot + Redis 설정 및 Cache-Aside 구현 (현재 글)
  3. Write-Through / Write-Behind 패턴 구현
  4. Cache Invalidation 전략
  5. Thundering Herd / Cache Stampede 해결
  6. 실전 시나리오별 캐시 전략 가이드
© 2025, 미나리와 함께 만들었음