
꼭 Spring을 쓸 필요는 없다
사실 Caching을 하겠다고 꼭 Spring을 쓸 필요는 없습니다. Caching의 핵심은 결국 실제 동작을 실행하면 결과를 어딘가에 저장해 두고, 동작을 실행할 때마다 어딘가에 저장된 값이 있는지 확인하고 값이 있으면 동작을 실행하지 않고, 값이 없으면 실행해서 저장하기만 하면 됩니다.
이런 간단한 동작은 그냥 직접 구현해도 됩니다. 하지만 이렇게 하면 2가지 문제점이 있습니다.
- 핵심 비즈니스 로직과 관련 없는 코드가 섞임 (Separation of Concerns)
- 같은 코드를 여러번 반복해서 써야 함 (Boilerplate)
그래서 극단적으로는 Spring을 쓰지 않고 간단히 아래와 같이 작성할 수도 있지만...
fun example(): Example {
val key = "example-key"
val existingCache = cacheRepository.findById(key)
if (existingCache != null) return existingCache
val result = run {
// 비즈니스 로직
}
cacheRepository.save(Cache(key = key, value = result))
return result
}
좀 더 우아하게 Spring Cache를 써서 아래와 같이 해 보겠습니다!
@Cacheable
fun example(): Example {
// 비즈니스 로직
}
Spring @Cacheable을 붙이면 일어나는 일
Spring에서는 @Cacheable을 method 위에 붙여서 Caching을 적용할 수 있습니다. 근데 이건 어떤 원리로 되는 걸까요? 사실 Spring Cache 내부를 까 보면 과할 정도의 추상화로 떡칠 돼 있지만, 기본적인 구조는 간단합니다. (spring-context의 cache package를 보시면 됩니다.)

우선 Cache라는 interface가 내가 Cache 해야 하는 것을 나타냅니다. A, B, C method에 대해서 각각 Caching 처리를 한다면 A, B, C method 각각에 Cache를 만들어 줘야 합니다. 그리고 각각의 Cache에 CRUD 동작을 구현합니다.
public interface Cache {
String getName();
Object getNativeCache();
ValueWrapper get(Object key);
<T> T get(Object key, @Nullable Class<T> type);
<T> T get(Object key, Callable<T> valueLoader);
void put(Object key, @Nullable Object value);
default ValueWrapper putIfAbsent(Object key, @Nullable Object value) {}
void evict(Object key);
default boolean evictIfPresent(Object key) {}
void clear();
default boolean invalidate() {}
}
당연히 서비스에 여러 종류의 Cache가 있을 거기 때문에 이 모든 걸 관리해 주는 게 바로 CacheManager입니다. 각 Cache에 이름을 부여하고, 그 이름으로 Cache를 가져올 수 있는 간단한 동작을 합니다.
public interface CacheManager {
Cache getCache(String name);
Collection<String> getCacheNames();
}
이 외에도 Cache에서 사용할 key를 만들어 주는 역할을 하는 KeyGenerator나 적정한 기본값을 줘 설정을 편하게 해 주는 @EnableCaching 등 다양한 요소가 있지만 결국은 Cache와 CacheManager가 핵심입니다.
실제로 AOP에서도 결국 Cache를 호출합니다. (CacheAspectSupport 참고)
@Nullable
private Cache.ValueWrapper findInCaches(CacheOperationContext context, Object key) {
for (Cache cache : context.getCaches()) {
Cache.ValueWrapper wrapper = doGet(cache, key);
if (wrapper != null) {
if (logger.isTraceEnabled()) {
logger.trace("Cache entry for key '" + key + "' found in cache '" + cache.getName() + "'");
}
return wrapper;
}
}
return null;
}
나만의 Cache, CacheManager 구현하기
이제 Spring Cache 내부에서 어떤 일이 일어나는지 대충 알았으니 해야 할 건 간단합니다. 나만의 Cache를 구현하고, 그 Cache를 가지고 있는 CacheManager를 구현해서 가장 높은 우선 순위의 Bean으로 등록해 주면 됩니다. (아니면 @Cacheable을 쓸 때 cacheManager를 명시할 수도 있습니다.)
그전에 DB에 저장할 Cache를 JPA를 사용해 간단히 구현해 줍니다.
@Table(name = "cache")
@Entity
data class CacheEntity(
@Id
@Column(name = "cache_key")
val key: String,
@Column(name = "cache_value", columnDefinition = "MEDIUMTEXT")
val value: String?,
@Column(name = "reg_ts")
val regTs: LocalDateTime = LocalDateTime.now(),
@Column(name = "exp_ts")
val expTs: LocalDateTime?,
)
interface CacheEntityRepository : JpaRepository<CacheEntity, String>
다음으로 Cache를 구현해야 하는데, Spring에서는 interface -> AbstractClass -> 실제 구현체의 형식으로 AbstractClass에서 많은 공통 구현을 제공하고, 필요한 부분만 Customize 할 수 있는 패턴을 쓰는 경우가 많은데 확인해 보니 Cache를 그렇지는 않았습니다. (CacheManager는 이 패턴이 맞았습니다!)
그래서 그냥 쌩으로 구현해 줬습니다. 실제 값을 DB에 저장할 때는 JSON 형식으로 저장해 줬습니다.
class DBCache(
private val name: String,
private val expirationType: ExpirationType,
private val cacheRepository: CacheEntityRepository,
) : Cache {
// 구현
}
Cache 이름, 유효 기간, 위에서 구현한 Cache JPA Repository를 생성자에서 받고, 이걸 활용해 구현을 했습니다. 이 생성자에서 받는 값들은 CacheManager가 Spring의 Bean이기 때문에 CacheManager가 주입받아서 넘길 예정입니다.
CacheManager는 말씀드렸듯이 AbstractCacheManager라는 Abstract Class가 존재합니다. 이 구현체는 Runtime에 Cache 원천이 바뀌지 않는 경우에 쓸 수 있습니다. 제 상황이니 이걸 활용해서 아래 method 1개만 구현해서 구현을 마칠 수 있습니다.
override fun loadCaches(): Collection<Cache?> {
DBCache(
name = "best-seller",
expirationType = DBCache.ExpirationType.ONE_WEEK,
cacheRepository = cacheRepository,
),
// 등등 다른 Cache 들
}
문제점: Type 정보가 사라진다!
여기서 쉽게 끝날 줄 알았더니만! 문제점이 있습니다. DB에 Value를 JSON으로 저장하는 게 문제입니다. (JSON으로 저장을 안 하더라도) 결국 DB에 저장할 때는 String으로 저장하는데, Cache를 구현할 때는 Type 정보가 없는 method를 구현해야 합니다. 여기서 아무런 처리도 안 하고 쌩 JSON 형식의 String을 반환하면 당연히 Runtime에 ClassCastException이 터집니다...
override fun get(key: Any): Cache.ValueWrapper? {}
이건 사실 해결 자체는 간단합니다. Jackson 같은 JSON Serialization Library를 쓰고, Cache 객체를 만들 때 생성자에 Type 정보를 받아서 Cast 해 주면 끝납니다. (Generic은 Runtime에 Type Erase가 일어나기 때문에 쓸 수 없습니다.)
class DBCache(
private val name: String,
private val cacheRepository: CacheEntityRepository,
private val valueType: com.fasterxml.jackson.databind.JavaType,
) : Cache {
override fun get(key: Any): Cache.ValueWrapper? {
val entity = getOrDeleteExpired(key) ?: return null
val json = entity.value?.takeIf { it.isNotBlank() } ?: return SimpleValueWrapper(null)
val value: Any = objectMapper.readValue(json, valueType)
return SimpleValueWrapper(value)
}
}
그리고 Type 정보를 넘겨줄 때는 ObjectMapper의 TypeFactory를 활용했습니다.
private val tf get() = mapper.typeFactory
private fun <E : Any> listOf(elem: Class<E>) = tf.constructCollectionType(List::class.java, elem)
private fun <T : Any> jType(type: Class<T>) = tf.constructType(type)
override fun loadCaches(): Collection<Cache?> {
DbCache(
name = "best-seller",
cacheRepository = cacheRepository,
valueType = listOf(BestSeller::class.java),
),
DbCache(
name = "search",
cacheRepository = cacheRepository,
valueType = jType(SearchResponse::class.java),
)
}
해결 자체는 됐지만 확장성도 없고, 코드도 더럽게 됐습니다. 물론 여기서 Custom Annotation을 만들고, AOP를 쓰고 하면 조금 더 우아한 코드가 되겠지만 현재 시점에서는 과한 거 같아 이대로 냅 뒀습니다.
다른 곳에서도 이런 식으로 했다고?!
이렇게 구현은 했지만 다른 Cache 구현체도 이런 방식으로 했는지 궁금해졌습니다. 그래서 찾아보니 현재 제가 쓰고 있는 라이브러리들 중에서는 7개의 Cache 구현체가 있었습니다.

여기서 CacheCache는 그냥 Wrapper이고, NoOpCache는 Cache를 안 쓸 때 쓰는 구현체니 제외하고 ConcurrentMapCache를 봤습니다.
ConcurrentMapCache는 Cache를 그대로 구현하는 게 아니라 AbstractValueAdaptingCache를 사용합니다. AbstractValueAdaptingCache를 가 보면 lookup이라는 method를 호출하고...
@Override
@Nullable
public ValueWrapper get(Object key) {
return toValueWrapper(lookup(key));
}
lookup 자체는 구현체가 없는 abstract method 입니다.
@Nullable
protected abstract Object lookup(Object key);
다시 ConcurrentMapCache에 돌아가서 lookup을 살펴보니...
private final ConcurrentMap<Object, Object> store;
@Override
@Nullable
protected Object lookup(Object key) {
return this.store.get(key);
}
Type Casting 같은 건 전혀 하고 있지 않습니다. 그도 그럴게 ConcurrentMapCache는 모든 값을 Java Memory에 가지고 있으니 Type 정보가 지워지지 않기 때문에 애초에 제 경우랑 정말 달랐습니다.
이걸 어떻게 처리할지 보기 위해서는 Redis 같이 외부에 데이터를 Serialize 해서 저장하는 경우를 봐야 할 거 같아 Redis 라이브러리를 다운로드 받았습니다. (Spring Data Redis)
그랬더니 여기에서는 deserializeCacheValue를 반환 전에 호출하고 있었고...
@Override
protected Object lookup(Object key) {
byte[] value = cacheWriter.get(name, createAndConvertCacheKey(key));
if (value == null) {
return null;
}
return deserializeCacheValue(value);
}
이 안에서는 RedisCacheConfiguration에서 Type 정보를 들고 있었습니다.
@Nullable
protected Object deserializeCacheValue(byte[] value) {
if (isAllowNullValues() && ObjectUtils.nullSafeEquals(value, BINARY_NULL_VALUE)) {
return NullValue.INSTANCE;
}
return cacheConfig.getValueSerializationPair().read(ByteBuffer.wrap(value));
}
Spring Data Redis에서는 보통 RedisTemplate을 써서 Type을 넘기거나, String으로 값을 가져오고 이걸 ObjectMapper로 Deserialization을 하니, 선택지는 결국 Type 정보를 Cache 설정하는 시점에 넘겨 주니냐, 아니면 사용처에서 잘 사용하느냐 (이렇게 하면 Cacheable은 못 쓰겠죠...?) 인 걸로 보입니다.
'👨💻 프로그래밍 > 📦 Backend' 카테고리의 다른 글
| Transaction Outbox Pattern (0) | 2025.10.03 |
|---|---|
| AWS Lambda를 Spring 기반 Batch로 사용하기 (0) | 2025.08.23 |
| MySQL에서 Index가 사용되지 않는 대표적인 5가지 경우와 이유 알아보기 (0) | 2025.07.20 |
| MySQL의 실행 계획을 보기 위한 최소한의 지식 (0) | 2025.07.06 |
| IntelliJ 디버거 사용하기 (0) | 2025.03.22 |