들어가며
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: app3
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: TRACEdocker-compose up -dRedis 설정
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 | 근거 |
|---|---|---|
| products | 30분 | 상품 기본 정보, 자주 안 바뀜 |
| product-details | 1시간 | 상세 설명, 거의 안 바뀜 |
| view-counts | 5분 | 자주 바뀜, 정확도보다 성능 우선 |
도메인 설정
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를 쓰면 문제가 생깁니다:
- equals/hashCode:
id가 포함되는데, 영속화 전후로 달라짐 - 프록시 문제:
final class라서 Lazy Loading 프록시 생성 불가 - 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): Product1편에서 배운 것처럼 @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 패턴을 구현합니다.
시리즈 목차
- 캐시 패턴 개요 및 비교
- Spring Boot + Redis 설정 및 Cache-Aside 구현 (현재 글)
- Write-Through / Write-Behind 패턴 구현
- Cache Invalidation 전략
- Thundering Herd / Cache Stampede 해결
- 실전 시나리오별 캐시 전략 가이드