Spring Boot + Kotlin에서 Redis 캐싱 완벽 가이드: 기본 전략부터 커스텀 AOP까지

웹 애플리케이션의 성능을 향상시키는 가장 효과적인 방법 중 하나는 캐싱입니다. 특히 Redis를 활용한 캐싱 전략은 데이터베이스 부하를 줄이고 응답 시간을 크게 개선할 수 있습니다. 이번 글에서는 Kotlin과 Spring Boot를 기반으로 실무에서 자주 사용하는 Redis 캐싱 전략들을 살펴보겠습니다.

1. 캐싱 전략의 종류

1.1 Cache-Aside (Lazy Loading)

가장 일반적인 캐싱 패턴으로, 애플리케이션이 캐시를 직접 관리하는 방식입니다.

@Service
class UserService(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val userRepository: UserRepository
) {
    
    fun getUser(userId: Long): User {
        val key = "user:$userId"
        
        // 1. 캐시에서 조회
        val cachedUser = redisTemplate.opsForValue()[key] as? User
        if (cachedUser != null) {
            return cachedUser
        }
        
        // 2. 캐시에 없으면 DB에서 조회
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found") }
        
        // 3. 캐시에 저장 (TTL 1시간)
        redisTemplate.opsForValue().set(key, user, Duration.ofHours(1))
        
        return user
    }
}

장점:

  • 필요한 데이터만 캐시에 저장
  • 구현이 단순하고 직관적

단점:

  • 캐시 미스 시 지연 발생
  • 캐시 무효화 로직이 복잡할 수 있음

1.2 Write-Through

데이터를 쓸 때 캐시와 데이터베이스를 동시에 업데이트하는 방식입니다.

@Service
class UserService(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val userRepository: UserRepository
) {
    
    @Transactional
    fun updateUser(userId: Long, request: UserUpdateRequest): User {
        val key = "user:$userId"
        
        // 1. DB 업데이트
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found") }
        
        user.updateInfo(request.name, request.email)
        val savedUser = userRepository.save(user)
        
        // 2. 캐시 업데이트
        redisTemplate.opsForValue().set(key, savedUser, Duration.ofHours(1))
        
        return savedUser
    }
}

장점:

  • 데이터 일관성 보장
  • 캐시가 항상 최신 상태

단점:

  • 쓰기 성능이 저하될 수 있음
  • 캐시 장애 시 전체 시스템 영향

1.3 Write-Behind (Write-Back)

데이터를 캐시에 먼저 쓰고, 일정 시간 후 데이터베이스에 비동기적으로 쓰는 방식입니다.

@Service
class UserService(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val userRepository: UserRepository,
    private val asyncUserWriter: AsyncUserWriter
) {
    
    fun updateUser(userId: Long, request: UserUpdateRequest): User {
        val key = "user:$userId"
        
        // 1. 캐시에서 조회
        val user = redisTemplate.opsForValue()[key] as? User
            ?: userRepository.findById(userId)
                .orElseThrow { UserNotFoundException("User not found") }
        
        // 2. 캐시 업데이트
        user.updateInfo(request.name, request.email)
        redisTemplate.opsForValue().set(key, user, Duration.ofHours(1))
        
        // 3. 비동기 DB 쓰기 스케줄링
        asyncUserWriter.scheduleWrite(user)
        
        return user
    }
}

@Component
class AsyncUserWriter(
    private val userRepository: UserRepository
) {
    
    @Async
    fun scheduleWrite(user: User) {
        // 일정 시간 후 DB에 쓰기
        userRepository.save(user)
    }
}

장점:

  • 쓰기 성능이 매우 빠름
  • 캐시 히트율이 높음

단점:

  • 데이터 손실 위험
  • 구현 복잡도 증가

2. Spring Boot에서의 캐싱 구현

2.1 Redis 설정

@Configuration
@EnableCaching
class RedisConfig {
    
    @Bean
    fun redisConnectionFactory(): RedisConnectionFactory {
        return LettuceConnectionFactory(
            RedisStandaloneConfiguration("localhost", 6379)
        )
    }
    
    @Bean
    fun redisTemplate(): RedisTemplate<String, Any> {
        val template = RedisTemplate<String, Any>()
        template.connectionFactory = redisConnectionFactory()
        
        // JSON 직렬화 설정
        val serializer = Jackson2JsonRedisSerializer(Any::class.java)
        val mapper = ObjectMapper()
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)
        mapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL
        )
        serializer.setObjectMapper(mapper)
        
        template.valueSerializer = serializer
        template.keySerializer = StringRedisSerializer()
        template.hashKeySerializer = StringRedisSerializer()
        template.hashValueSerializer = serializer
        
        return template
    }
    
    @Bean
    fun cacheManager(): CacheManager {
        val config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(1))
            .disableCachingNullValues()
        
        return RedisCacheManager.builder(redisConnectionFactory())
            .cacheDefaults(config)
            .build()
    }
}

2.2 어노테이션 기반 캐싱

@Service
class ProductService(
    private val productRepository: ProductRepository
) {
    
    @Cacheable(value = ["products"], key = "#id")
    fun getProduct(id: Long): Product {
        return productRepository.findById(id)
            .orElseThrow { ProductNotFoundException("Product not found") }
    }
    
    @CacheEvict(value = ["products"], key = "#id")
    fun deleteProduct(id: Long) {
        productRepository.deleteById(id)
    }
    
    @CachePut(value = ["products"], key = "#result.id")
    fun updateProduct(id: Long, request: ProductUpdateRequest): Product {
        val product = productRepository.findById(id)
            .orElseThrow { ProductNotFoundException("Product not found") }
        
        product.updateInfo(request.name, request.price)
        return productRepository.save(product)
    }
    
    @Cacheable(value = ["product-lists"], key = "#category + '_' + #page")
    fun getProductsByCategory(category: String, page: Int): Page<Product> {
        val pageable = PageRequest.of(page, 20)
        return productRepository.findByCategory(category, pageable)
    }
}

3. 커스텀 캐시 어노테이션과 AOP

3.1 커스텀 캐시 어노테이션 정의

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CacheableWithTtl(
    val key: String,
    val ttl: Long = 3600, // 기본 1시간
    val timeUnit: TimeUnit = TimeUnit.SECONDS,
    val condition: String = "",
    val unless: String = ""
)

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CacheEvictWithPattern(
    val pattern: String,
    val allEntries: Boolean = false
)

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
    val key: String,
    val waitTime: Long = 3000,
    val leaseTime: Long = 30000,
    val timeUnit: TimeUnit = TimeUnit.MILLISECONDS
)

3.2 캐시 AOP 구현

@Aspect
@Component
class CacheAspect(
    private val redisTemplate: RedisTemplate<String, Any>,
    private val distributedLock: RedisDistributedLock
) {
    
    private val logger = LoggerFactory.getLogger(CacheAspect::class.java)
    
    @Around("@annotation(cacheableWithTtl)")
    fun cacheableWithTtl(joinPoint: ProceedingJoinPoint, cacheableWithTtl: CacheableWithTtl): Any? {
        val key = parseKey(cacheableWithTtl.key, joinPoint)
        val ttl = Duration.of(cacheableWithTtl.ttl, cacheableWithTtl.timeUnit.toChronoUnit())
        
        // 조건 확인
        if (cacheableWithTtl.condition.isNotEmpty() && !evaluateCondition(cacheableWithTtl.condition, joinPoint)) {
            return joinPoint.proceed()
        }
        
        // 캐시에서 조회
        val cachedValue = redisTemplate.opsForValue()[key]
        if (cachedValue != null) {
            // unless 조건 확인
            if (cacheableWithTtl.unless.isNotEmpty() && evaluateCondition(cacheableWithTtl.unless, joinPoint)) {
                return joinPoint.proceed()
            }
            logger.debug("Cache hit for key: $key")
            return cachedValue
        }
        
        // 캐시 미스 시 실제 메서드 실행
        val result = joinPoint.proceed()
        
        // 결과를 캐시에 저장
        if (result != null) {
            redisTemplate.opsForValue().set(key, result, ttl)
            logger.debug("Cache stored for key: $key, ttl: $ttl")
        }
        
        return result
    }
    
    @Around("@annotation(cacheEvictWithPattern)")
    fun cacheEvictWithPattern(joinPoint: ProceedingJoinPoint, cacheEvictWithPattern: CacheEvictWithPattern): Any? {
        val result = joinPoint.proceed()
        
        if (cacheEvictWithPattern.allEntries) {
            // 패턴에 맞는 모든 키 삭제
            val pattern = parseKey(cacheEvictWithPattern.pattern, joinPoint)
            val keys = redisTemplate.keys(pattern)
            if (keys.isNotEmpty()) {
                redisTemplate.delete(keys)
                logger.debug("Cache evicted for pattern: $pattern, keys: ${keys.size}")
            }
        } else {
            // 특정 키만 삭제
            val key = parseKey(cacheEvictWithPattern.pattern, joinPoint)
            redisTemplate.delete(key)
            logger.debug("Cache evicted for key: $key")
        }
        
        return result
    }
    
    @Around("@annotation(distributedLock)")
    fun distributedLock(joinPoint: ProceedingJoinPoint, distributedLock: DistributedLock): Any? {
        val key = parseKey(distributedLock.key, joinPoint)
        val lockValue = UUID.randomUUID().toString()
        
        return try {
            if (this.distributedLock.tryLock(key, lockValue, Duration.of(distributedLock.waitTime, distributedLock.timeUnit.toChronoUnit()))) {
                joinPoint.proceed()
            } else {
                throw LockAcquisitionException("Failed to acquire lock for key: $key")
            }
        } finally {
            this.distributedLock.unlock(key, lockValue)
        }
    }
    
    private fun parseKey(keyExpression: String, joinPoint: ProceedingJoinPoint): String {
        return if (keyExpression.contains("#")) {
            // SpEL 파싱 (간단한 구현)
            parseSpELExpression(keyExpression, joinPoint)
        } else {
            keyExpression
        }
    }
    
    private fun parseSpELExpression(expression: String, joinPoint: ProceedingJoinPoint): String {
        val methodSignature = joinPoint.signature as MethodSignature
        val parameterNames = methodSignature.parameterNames
        val args = joinPoint.args
        
        var result = expression
        parameterNames.forEachIndexed { index, paramName ->
            result = result.replace("#$paramName", args[index].toString())
        }
        
        return result
    }
    
    private fun evaluateCondition(condition: String, joinPoint: ProceedingJoinPoint): Boolean {
        // 간단한 조건 평가 (실제로는 SpEL을 사용)
        return true
    }
}

3.3 분산 락 구현

@Component
class RedisDistributedLock(
    private val redisTemplate: RedisTemplate<String, Any>
) {
    
    companion object {
        private const val LOCK_PREFIX = "lock:"
    }
    
    fun tryLock(key: String, value: String, waitTime: Duration): Boolean {
        val lockKey = LOCK_PREFIX + key
        val endTime = System.currentTimeMillis() + waitTime.toMillis()
        
        while (System.currentTimeMillis() < endTime) {
            val result = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, value, Duration.ofSeconds(30))
            
            if (result == true) {
                return true
            }
            
            try {
                Thread.sleep(50)
            } catch (e: InterruptedException) {
                Thread.currentThread().interrupt()
                return false
            }
        }
        
        return false
    }
    
    fun unlock(key: String, value: String) {
        val lockKey = LOCK_PREFIX + key
        val script = """
            if redis.call('get', KEYS[1]) == ARGV[1] then 
                return redis.call('del', KEYS[1]) 
            else 
                return 0 
            end
        """.trimIndent()
        
        redisTemplate.execute(
            RedisScript.of(script, Long::class.java),
            listOf(lockKey),
            value
        )
    }
}

3.4 커스텀 어노테이션 사용 예시

@Service
class UserService(
    private val userRepository: UserRepository,
    private val redisTemplate: RedisTemplate<String, Any>
) {
    
    @CacheableWithTtl(
        key = "user:#userId",
        ttl = 30,
        timeUnit = TimeUnit.MINUTES,
        condition = "#userId > 0"
    )
    fun getUser(userId: Long): User {
        return userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found") }
    }
    
    @CacheableWithTtl(
        key = "user:profile:#userId",
        ttl = 1,
        timeUnit = TimeUnit.HOURS
    )
    fun getUserProfile(userId: Long): UserProfile {
        return userRepository.findUserProfileById(userId)
    }
    
    @CacheEvictWithPattern(pattern = "user:#userId")
    fun updateUser(userId: Long, request: UserUpdateRequest): User {
        val user = userRepository.findById(userId)
            .orElseThrow { UserNotFoundException("User not found") }
        
        user.updateInfo(request.name, request.email)
        return userRepository.save(user)
    }
    
    @CacheEvictWithPattern(pattern = "user:*", allEntries = true)
    fun deleteUser(userId: Long) {
        userRepository.deleteById(userId)
    }
    
    @DistributedLock(
        key = "user:update:#userId",
        waitTime = 5000,
        leaseTime = 10000
    )
    fun updateUserWithLock(userId: Long, request: UserUpdateRequest): User {
        return updateUser(userId, request)
    }
}

4. 고급 캐싱 패턴

4.1 계층화된 캐싱

@Component
class TieredCacheService(
    private val localCache: CacheManager,
    private val redisTemplate: RedisTemplate<String, Any>
) {
    
    @CacheableWithTtl(key = "product:#id", ttl = 300) // Redis 캐시 5분
    fun getProductFromRedis(id: Long): Product? {
        return redisTemplate.opsForValue()["product:$id"] as? Product
    }
    
    @Cacheable(value = ["local-products"], key = "#id") // 로컬 캐시
    fun getProduct(id: Long): Product {
        // 1. 로컬 캐시 확인 (어노테이션으로 처리)
        
        // 2. Redis 캐시 확인
        val redisProduct = getProductFromRedis(id)
        if (redisProduct != null) {
            return redisProduct
        }
        
        // 3. DB에서 조회
        val product = productRepository.findById(id)
            .orElseThrow { ProductNotFoundException("Product not found") }
        
        // 4. Redis 캐시에 저장
        redisTemplate.opsForValue().set("product:$id", product, Duration.ofMinutes(5))
        
        return product
    }
}

4.2 캐시 워밍업

@Component
class CacheWarmupService(
    private val userService: UserService,
    private val productService: ProductService
) {
    
    @EventListener(ApplicationReadyEvent::class)
    fun warmupCache() {
        // 인기 상품 미리 캐시
        warmupPopularProducts()
        
        // 자주 조회되는 설정 미리 캐시
        warmupSystemConfigs()
    }
    
    @Async
    fun warmupPopularProducts() {
        val popularProductIds = productService.getPopularProductIds()
        popularProductIds.forEach { productId ->
            try {
                productService.getProduct(productId)
            } catch (e: Exception) {
                // 워밍업 실패는 무시
            }
        }
    }
    
    @Async
    fun warmupSystemConfigs() {
        val configKeys = listOf("app.version", "feature.flags", "rate.limits")
        configKeys.forEach { key ->
            try {
                systemConfigService.getConfig(key)
            } catch (e: Exception) {
                // 워밍업 실패는 무시
            }
        }
    }
}

4.3 캐시 통계 및 모니터링

@Component
class CacheStatsService(
    private val meterRegistry: MeterRegistry
) {
    
    private val cacheHitCounter = Counter.builder("cache.hits")
        .description("Cache hit count")
        .register(meterRegistry)
    
    private val cacheMissCounter = Counter.builder("cache.misses")
        .description("Cache miss count")
        .register(meterRegistry)
    
    fun recordCacheHit(cacheName: String, key: String) {
        cacheHitCounter.increment(
            Tags.of(
                "cache", cacheName,
                "key", key
            )
        )
    }
    
    fun recordCacheMiss(cacheName: String, key: String) {
        cacheMissCounter.increment(
            Tags.of(
                "cache", cacheName,
                "key", key
            )
        )
    }
    
    @Scheduled(fixedRate = 60000)
    fun reportCacheStats() {
        val hitRate = cacheHitCounter.count() / (cacheHitCounter.count() + cacheMissCounter.count())
        meterRegistry.gauge("cache.hit.rate", hitRate)
    }
}

5. 실무 적용 시 고려사항

5.1 캐시 키 네이밍 컨벤션

object CacheKeyConvention {
    
    // 도메인:엔티티:식별자 형태
    fun userKey(userId: Long) = "user:profile:$userId"
    fun productKey(productId: Long) = "product:detail:$productId"
    
    // 리스트는 조건을 포함
    fun productListKey(category: String, page: Int) = "product:list:$category:$page"
    
    // 검색 결과는 해시 사용
    fun searchKey(query: String) = "search:${query.hashCode()}"
    
    // 임시 데이터는 접두사로 구분
    fun sessionKey(sessionId: String) = "session:$sessionId"
    fun tokenKey(token: String) = "token:$token"
}

5.2 캐시 장애 대응

@Component
class CacheCircuitBreaker(
    private val meterRegistry: MeterRegistry
) {
    
    private val circuitBreakerRegistry = CircuitBreakerRegistry.of(
        CircuitBreakerConfig.custom()
            .failureRateThreshold(50.0f)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .slidingWindowSize(10)
            .build()
    )
    
    fun <T> executeWithCircuitBreaker(
        name: String,
        cacheOperation: () -> T?,
        fallbackOperation: () -> T
    ): T {
        val circuitBreaker = circuitBreakerRegistry.circuitBreaker(name)
        
        return try {
            val result = circuitBreaker.executeSupplier {
                cacheOperation()
            }
            result ?: fallbackOperation()
        } catch (e: Exception) {
            meterRegistry.counter("cache.circuit.breaker.fallback", "cache", name).increment()
            fallbackOperation()
        }
    }
}

5.3 캐시 성능 최적화

@Service
class OptimizedCacheService(
    private val redisTemplate: RedisTemplate<String, Any>
) {
    
    // 배치 조회로 성능 개선
    fun getUsers(userIds: List<Long>): Map<Long, User> {
        val keys = userIds.map { "user:$it" }
        
        val pipeline = redisTemplate.executePipelined { connection ->
            keys.forEach { key ->
                connection.get(key.toByteArray())
            }
            null
        }
        
        val result = mutableMapOf<Long, User>()
        val missingIds = mutableListOf<Long>()
        
        userIds.forEachIndexed { index, userId ->
            val cachedUser = pipeline[index] as? User
            if (cachedUser != null) {
                result[userId] = cachedUser
            } else {
                missingIds.add(userId)
            }
        }
        
        // 캐시 미스 된 데이터는 DB에서 일괄 조회
        if (missingIds.isNotEmpty()) {
            val dbUsers = userRepository.findAllById(missingIds)
            dbUsers.forEach { user ->
                result[user.id] = user
                // 캐시에 저장
                redisTemplate.opsForValue().set("user:${user.id}", user, Duration.ofHours(1))
            }
        }
        
        return result
    }
}

마무리

Redis를 활용한 캐싱은 웹 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 특히 AOP와 커스텀 어노테이션을 활용하면 비즈니스 로직과 캐싱 로직을 깔끔하게 분리할 수 있어 유지보수성이 크게 향상됩니다.

핵심 포인트:

  • 데이터 특성에 맞는 캐싱 전략 선택
  • AOP를 활용한 관심사 분리
  • 적절한 TTL 설정과 무효화 전략
  • 캐시 장애 시 Circuit Breaker 패턴 적용
  • 지속적인 모니터링과 성능 최적화

실무에서는 단일 전략보다는 데이터 특성에 따라 여러 전략을 혼합하여 사용하는 것이 효과적입니다. 또한 커스텀 어노테이션을 통해 팀의 캐싱 정책을 표준화하고, 모니터링을 통해 지속적으로 최적화하는 것이 중요합니다.