Spring Cache 추상화란
Spring Cache Abstraction은 메서드 레벨에서 캐싱을 선언적으로 적용하는 프레임워크다. 비즈니스 로직을 수정하지 않고 @Cacheable, @CacheEvict, @CachePut 어노테이션만으로 캐시를 도입할 수 있다. 캐시 구현체(ConcurrentMap, Caffeine, Redis, EhCache)와 비즈니스 코드가 완전히 분리되어 구현체를 교체해도 서비스 코드는 변하지 않는다.
DB 조회가 빈번한 API에서 캐시 도입만으로 응답 시간을 10배 이상 줄이는 것이 가능하며, 특히 읽기 비율이 높은 서비스에서는 필수적인 성능 최적화 기법이다.
기본 설정과 활성화
// Application 클래스에서 캐시 활성화
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// application.yml — Caffeine 캐시 설정
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
cache-names:
- users
- products
- categories
@Cacheable: 캐시 조회/저장
메서드 호출 전 캐시를 확인하고, 존재하면 메서드를 실행하지 않고 캐시 값을 반환한다. 캐시에 없으면 메서드를 실행하고 결과를 저장한다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 기본: 파라미터를 키로 사용
@Cacheable("users")
public User findById(Long id) {
log.info("DB 조회: userId={}", id);
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
// SpEL로 커스텀 키 생성
@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow();
}
// 조건부 캐싱: null 결과는 캐시하지 않음
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User findByIdOrNull(Long id) {
return userRepository.findById(id).orElse(null);
}
// 조건부 캐싱: 특정 조건에서만 캐시
@Cacheable(value = "users", condition = "#id > 0")
public User findByIdConditional(Long id) {
return userRepository.findById(id).orElseThrow();
}
}
@CacheEvict: 캐시 무효화
데이터가 변경될 때 캐시를 제거하여 일관성을 유지한다. 캐시 무효화 전략이 캐시 설계의 핵심이다.
@Service
public class UserService {
// 단일 항목 제거
@CacheEvict(value = "users", key = "#user.id")
public User update(User user) {
return userRepository.save(user);
}
// 전체 캐시 제거
@CacheEvict(value = "users", allEntries = true)
public void deleteAll() {
userRepository.deleteAll();
}
// 메서드 실행 전에 캐시 제거 (beforeInvocation)
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void delete(Long id) {
// 예외 발생해도 캐시는 이미 제거됨
userRepository.deleteById(id);
}
// 여러 캐시 동시 무효화
@Caching(evict = {
@CacheEvict(value = "users", key = "#user.id"),
@CacheEvict(value = "userList", allEntries = true)
})
public User save(User user) {
return userRepository.save(user);
}
}
@CachePut: 캐시 갱신
@CachePut은 항상 메서드를 실행하고 결과를 캐시에 저장한다. @Cacheable과 달리 메서드 실행을 건너뛰지 않는다.
// 저장과 동시에 캐시 갱신
@CachePut(value = "users", key = "#result.id")
public User create(CreateUserRequest request) {
User user = User.builder()
.name(request.getName())
.email(request.getEmail())
.build();
return userRepository.save(user);
}
// update 후 캐시도 최신 상태로 갱신
@CachePut(value = "users", key = "#id")
public User update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id).orElseThrow();
user.updateName(request.getName());
return userRepository.save(user);
}
커스텀 KeyGenerator
복잡한 키 생성 로직이 반복될 때 커스텀 KeyGenerator를 정의한다.
@Configuration
public class CacheConfig {
@Bean
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(".");
sb.append(method.getName());
for (Object param : params) {
sb.append("_");
sb.append(param != null ? param.toString() : "null");
}
return sb.toString();
};
}
}
// 사용
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public List<Product> search(String keyword, int page, String sort) {
return productRepository.search(keyword, PageRequest.of(page, 20, Sort.by(sort)));
}
Caffeine 캐시 세밀한 설정
Caffeine은 Google Guava Cache의 후속으로, JVM 내 캐시 중 최고 성능을 자랑한다. 캐시별로 다른 TTL과 크기를 적용하려면 CacheManager를 직접 구성한다.
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(List.of(
buildCache("users", 5000, 10, TimeUnit.MINUTES),
buildCache("products", 20000, 30, TimeUnit.MINUTES),
buildCache("categories", 500, 1, TimeUnit.HOURS),
buildCache("config", 100, 6, TimeUnit.HOURS)
));
return manager;
}
private CaffeineCache buildCache(String name, int maxSize,
long duration, TimeUnit unit) {
return new CaffeineCache(name,
Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(duration, unit)
.recordStats() // 모니터링용 통계
.build());
}
}
Redis 캐시 구성
분산 환경에서는 Redis가 필수다. 인스턴스 간 캐시를 공유하고, 서버 재시작 후에도 캐시가 유지된다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
// application.yml
spring:
data:
redis:
host: redis-master
port: 6379
cache:
type: redis
redis:
time-to-live: 600000 # 10분 (밀리초)
key-prefix: "app:cache:"
use-key-prefix: true
cache-null-values: false # null 캐시 방지
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
// 기본 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
// 캐시별 TTL 커스터마이징
Map<String, RedisCacheConfiguration> configs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
"products", defaultConfig.entryTtl(Duration.ofHours(1)),
"sessions", defaultConfig.entryTtl(Duration.ofMinutes(5))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configs)
.transactionAware() // 트랜잭션과 동기화
.build();
}
}
캐시 계층화: L1 + L2
고성능 시스템에서는 Caffeine(L1, 로컬) + Redis(L2, 분산)를 계층화하여 로컬 캐시의 속도와 분산 캐시의 일관성을 동시에 확보한다.
@Component
@RequiredArgsConstructor
public class TieredCacheService {
private final Cache<String, Object> localCache; // Caffeine
private final RedisTemplate<String, Object> redisTemplate;
public <T> T get(String key, Class<T> type, Supplier<T> loader) {
// L1: 로컬 캐시 확인
Object local = localCache.getIfPresent(key);
if (local != null) return type.cast(local);
// L2: Redis 확인
Object remote = redisTemplate.opsForValue().get(key);
if (remote != null) {
localCache.put(key, remote); // L1에 승격
return type.cast(remote);
}
// Cache miss: DB 조회 후 양쪽에 저장
T value = loader.get();
if (value != null) {
localCache.put(key, value);
redisTemplate.opsForValue()
.set(key, value, Duration.ofMinutes(30));
}
return value;
}
public void evict(String key) {
localCache.invalidate(key);
redisTemplate.delete(key);
}
}
캐시 스탬피드 방지: @Cacheable + sync
캐시가 만료되는 순간 동일 키에 대한 다수 요청이 동시에 DB를 조회하는 Cache Stampede 문제가 발생할 수 있다. Redis 캐시 스탬피드 방지 글에서 다룬 것처럼, Spring에서는 sync = true로 간단히 방지할 수 있다.
// sync=true: 동일 키에 대해 하나의 스레드만 DB 조회
@Cacheable(value = "products", key = "#id", sync = true)
public Product findById(Long id) {
return productRepository.findById(id).orElseThrow();
}
캐시 모니터링: Micrometer 연동
캐시 히트율, 미스율, 제거 횟수를 모니터링해야 적절한 TTL과 크기를 튜닝할 수 있다. Spring Boot Micrometer 가이드의 메트릭 수집과 자연스럽게 연동된다.
// Caffeine + Micrometer 자동 연동
@Bean
public CacheMetricsRegistrar cacheMetrics(
CacheManager cacheManager, MeterRegistry registry) {
CacheMetricsRegistrar registrar =
new CacheMetricsRegistrar(registry, List.of());
cacheManager.getCacheNames().forEach(name ->
registrar.bindCacheToRegistry(
cacheManager.getCache(name)));
return registrar;
}
// Prometheus에서 확인 가능한 메트릭:
// cache_gets_total{cache="users",result="hit"}
// cache_gets_total{cache="users",result="miss"}
// cache_evictions_total{cache="users"}
// cache_size{cache="users"}
테스트 전략
@SpringBootTest
class UserServiceCacheTest {
@Autowired UserService userService;
@Autowired CacheManager cacheManager;
@MockBean UserRepository userRepository;
@BeforeEach
void clearCache() {
cacheManager.getCacheNames()
.forEach(name -> cacheManager.getCache(name).clear());
}
@Test
void 동일_ID_두번_조회시_DB는_한번만_호출() {
User user = new User(1L, "Alice");
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
userService.findById(1L); // DB 조회
userService.findById(1L); // 캐시 히트
verify(userRepository, times(1)).findById(1L);
}
@Test
void update_후_캐시가_무효화됨() {
User user = new User(1L, "Alice");
when(userRepository.findById(1L))
.thenReturn(Optional.of(user));
when(userRepository.save(any()))
.thenReturn(user);
userService.findById(1L);
userService.update(user); // 캐시 evict
userService.findById(1L); // 다시 DB 조회
verify(userRepository, times(2)).findById(1L);
}
}
정리: 캐시 설계 체크리스트
- 읽기 비율 확인: 읽기 >> 쓰기인 데이터에만 캐시 적용
- TTL 설계: 데이터 변경 빈도에 맞는 만료 시간 설정
- 무효화 전략: @CacheEvict로 데이터 변경 시 즉시 제거
- sync = true: 트래픽 높은 키에 스탬피드 방지 적용
- null 캐싱 주의: unless = “#result == null”로 null 저장 방지
- 모니터링: 히트율 < 80%면 TTL이나 키 전략 재검토
- 단일 → 분산: 개발은 Caffeine, 운영은 Redis로 무중단 전환 가능