Spring은 캐싱 로직을 비즈니스 코드에서 분리할 수 있도록 Cache Abstraction(캐시 추상화) 을 제공한다.
핵심 아이디어는 간단하다 — 어노테이션을 지정해주면 메서드의 결과가 자동으로 캐시에 저장되고 꺼내진다.
Spring Cache는 특정 캐싱 라이브러리가 아니라, 다양한 캐시 구현체를 동일한 방식으로 다룰 수 있게 해주는 인터페이스(추상화 계층) 이다.
build.gradle 기준으로, Spring Data Redis 스타터 하나면 Spring Cache와 Redis 연동이 모두 준비된다.
// Spring Data Redis + Lettuce 클라이언트 포함
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring Cache 추상화 (위 starter에 포함되어 있으나, 명시적으로 쓰는 경우도 있음)
// implementation 'org.springframework.boot:spring-boot-starter-cache'
spring-boot-starter-data-redis를 추가하면 Lettuce 클라이언트가 기본으로 포함되며,
spring-boot-starter-cache는 별도로 추가하지 않아도 Spring Cache 어노테이션이 동작한다.
Spring Cache를 활성화하려면 @EnableCaching 어노테이션을 @Configuration 클래스에 붙여야 한다.
이 어노테이션이 없으면 @Cacheable 등의 어노테이션을 붙여도 아무것도 일어나지 않는다.
// CacheConfig.java
@Configuration
@EnableCaching // 어노테이션 기반 캐싱을 활성화하는 핵심 어노테이션
public class CacheConfig {
// CacheManager Bean이 여기에 정의됨 (아래에서 이어서 작성)
}
@EnableCaching은 내부적으로 AOP 프록시를 설정하고,
CacheManager 구현체(여기서는 RedisCacheManager)가 Bean으로 등록되어 있어야 실제로 동작한다.
RedisCacheManager는 Redis를 캐시 저장소로 사용할 때 Spring Cache와 Redis를 연결해주는 핵심 컴포넌트다.
// CacheConfig.java — RedisCacheManager 설정 전체 예시
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.time.Duration;
import static org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(
RedisConnectionFactory redisConnectionFactory // application.yml에 설정된 Redis 연결 정보를 가져옴
) {
RedisCacheConfiguration configuration = RedisCacheConfiguration
.defaultCacheConfig()
// null 값은 캐싱하지 않음 — NullPointerException 방지 및 불필요한 메모리 낭비 제거
.disableCachingNullValues()
// TTL(Time To Live): 캐시 엔트리가 자동 만료되는 시간 설정
.entryTtl(Duration.ofSeconds(60))
// 캐시 키 앞에 "cacheName::" 형태의 접두사(prefix)를 자동으로 붙임
.computePrefixWith(CacheKeyPrefix.simple())
// 캐시에 저장할 값의 직렬화/역직렬화 방식 지정
.serializeValuesWith(
SerializationPair.fromSerializer(RedisSerializer.java())
// 실제 환경에서는 아래 JSON 직렬화가 더 권장됨 (하단 직렬화 가이드 참고)
);
return RedisCacheManager
.builder(redisConnectionFactory)
.cacheDefaults(configuration) // 위에서 구성한 설정을 기본값으로 등록
.build();
}
}
| 구분 | 설명 | 동작 방식 |
|---|---|---|
| TTL (Time To Live) | 생성 시점부터 일정 시간 후 자동 삭제 | 조회 여부와 관계없이 만료 |
| TTI (Time To Idle) | 마지막 접근 시점부터 일정 시간 후 삭제 | 자주 조회되는 항목은 유지 |
Spring Data Redis의
RedisCacheConfiguration은 현재 TTL만 공식 지원한다.
TTI는 Redis 자체 기능으로는 구현이 복잡하며, 별도 라이브러리 없이 Spring Cache에서 직접 지원하지는 않는다.
Spring Cache의 핵심은 세 가지 어노테이션이다.
| 어노테이션 | 동작 | 주 사용 시점 |
|---|---|---|
@Cacheable | 캐시에 데이터가 있으면 반환, 없으면 메서드 실행 후 저장 | 조회 (Read) |
@CachePut | 항상 메서드를 실행하고 결과를 캐시에 저장/갱신 | 생성/수정 (Create/Update) |
@CacheEvict | 지정한 캐시 항목을 삭제 | 수정/삭제 후 캐시 무효화 |
메서드 결과를 캐시에 저장하고, 이후 동일한 키로 호출되면 메서드를 실행하지 않고 캐시에서 반환한다.
전형적인 Cache-Aside 패턴의 Read 측면이다.
// ItemService.java
// cacheNames: 이 캐시를 식별하는 논리적 이름 (Redis 키 접두사로 사용됨)
// key: 같은 캐시 안에서 개별 항목을 구분하는 기준값 — SpEL(Spring Expression Language) 사용
@Cacheable(cacheNames = "itemCache", key = "args[0]")
public ItemDto readOne(Long id) {
log.info("DB에서 조회: {}", id); // 캐시 HIT 시 이 로그는 출력되지 않음
return repository.findById(id)
.map(ItemDto::fromEntity)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
동작 흐름
첫 번째 호출 (id=1)
→ 캐시 MISS → DB 조회 → 결과를 "itemCache::1" 키로 Redis에 저장 → 반환
두 번째 호출 (id=1)
→ 캐시 HIT → Redis에서 바로 반환 (DB 조회 없음)
readAll()에 적용하는 경우, 인자가 없으므로 key를 메서드 이름으로 지정한다.
// 전체 목록 조회 — key를 메서드 이름으로 설정하여 단일 캐시 엔트리로 관리
@Cacheable(cacheNames = "itemAllCache", key = "methodName")
public List<ItemDto> readAll() {
return repository.findAll()
.stream()
.map(ItemDto::fromEntity)
.toList();
}
key 속성에는 SpEL 이라는 표현 언어를 사용한다.
메서드 인자, 반환값, 빈 속성 등을 동적으로 참조할 수 있다.
| 표현식 | 의미 |
|---|---|
#id | 메서드 파라미터 이름이 id인 값 |
args[0] | 첫 번째 인자 |
#result.id | 메서드 반환 객체의 id 필드 |
methodName | 메서드 이름 문자열 |
{ args[0], args[1].pageNumber } | 여러 값을 조합한 복합 키 |
#파라미터명방식이args[0]보다 가독성이 좋아 일반적으로 선호된다.
항상 메서드를 실행하고 그 결과를 캐시에 저장한다. 캐시에 데이터가 있어도 실행을 건너뛰지 않는다.
데이터를 생성하거나 수정할 때 캐시도 함께 최신 상태로 유지하기 위해 사용한다 (Write-Through 전략).
// create() — 새 아이템 생성 후, 생성된 결과를 즉시 캐시에도 저장
// key = "#result.id" : 메서드가 반환하는 ItemDto의 id 필드를 키로 사용
@CachePut(cacheNames = "itemCache", key = "#result.id")
public ItemDto create(ItemDto dto) {
return ItemDto.fromEntity(itemRepository.save(
Item.builder()
.name(dto.getName())
.description(dto.getDescription())
.price(dto.getPrice())
.stock(dto.getStock())
.build()
));
}
중요한 포인트: Redis에 저장되는 키 형식이 cacheName::key 이므로,
create()가 저장한 itemCache::1 과 readOne(id=1)이 찾는 itemCache::1 은 동일한 키다.
따라서 create() 이후 readOne(1) 을 호출하면 DB를 거치지 않고 캐시에서 바로 반환된다.
캐시에 저장된 데이터를 무효화(삭제) 한다.
데이터가 변경되었을 때 오래된 캐시가 남아있지 않도록 정리하는 역할이다.
// update() — 특정 아이템 수정 시:
// 1. 해당 아이템의 단건 캐시를 최신 값으로 갱신 (@CachePut)
// 2. 전체 목록 캐시는 더 이상 유효하지 않으므로 전부 삭제 (@CacheEvict)
@CachePut(cacheNames = "itemCache", key = "args[0]") // 단건 캐시 갱신
@CacheEvict(cacheNames = "itemAllCache", allEntries = true) // 전체 목록 캐시 전체 삭제
public ItemDto update(Long id, ItemDto dto) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
item.setName(dto.getName());
item.setDescription(dto.getDescription());
item.setPrice(dto.getPrice());
item.setStock(dto.getStock());
return ItemDto.fromEntity(itemRepository.save(item));
}
allEntries = true 는 해당 cacheNames 에 속하는 모든 키를 한 번에 삭제한다.
특정 키 하나만 삭제하려면 key = "#id" 처럼 지정하면 된다.
@CacheEvict에는beforeInvocation옵션도 있다.
기본값은false(메서드 실행 후 삭제)이며,true로 설정하면 메서드 실행 전에 캐시를 삭제한다.
메서드 실행 중 예외가 발생해도 캐시를 확실히 지워야 할 때 사용한다.
검색 기능에 캐싱을 적용하는 방법도 살펴보자.
핵심은 검색어 + 페이지 정보를 조합하여 복합 키를 만드는 것이다.
Page<T> 객체를 캐싱하려면, Page 직렬화 방식을 DTO 기반으로 설정해야 한다.
// RedisApplication.java
@SpringBootApplication
// Page 객체를 직렬화할 때 내부 구현체 대신 DTO 형태로 변환하도록 설정
// 이 설정 없이 Page를 캐싱하면 역직렬화 오류가 발생할 수 있음
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class RedisApplication {
public static void main(String[] args) {
SpringApplication.run(RedisApplication.class, args);
}
}
public interface ItemRepository extends JpaRepository<Item, Long> {
// 이름에 특정 문자열이 포함된 Item을 페이지 단위로 조회하는 JPA Query Method
Page<Item> findAllByNameContains(String name, Pageable pageable);
}
// 검색어(args[0])와 페이지 번호, 페이지 크기를 조합하여 복합 키 생성
// → 같은 검색어라도 페이지가 다르면 다른 캐시 엔트리로 저장됨
@Cacheable(
cacheNames = "itemSearchCache",
key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
)
public Page<ItemDto> searchByName(String query, Pageable pageable) {
return itemRepository.findAllByNameContains(query, pageable)
.map(ItemDto::fromEntity);
}
// q 파라미터로 검색어를 받고, Pageable로 페이지 정보를 자동 바인딩
// 예시 요청: GET /items/search?q=노트북&page=0&size=10
@GetMapping("search")
public Page<ItemDto> search(
@RequestParam(name = "q") String query, // 검색어
Pageable pageable // 페이지 정보 (Spring이 자동 바인딩)
) {
return itemService.searchByName(query, pageable);
}
Redis에 저장되는 키 형태는 아래처럼 만들어진다.
itemSearchCache::[노트북, 0, 20]
예제 코드에서는 RedisSerializer.java() (JDK 기본 직렬화)를 사용했지만,
실제 개발 환경에서는 JSON 직렬화가 더 일반적으로 권장된다.
| 직렬화 방식 | 장점 | 단점 |
|---|---|---|
RedisSerializer.java() (JDK 직렬화) | 설정 없이 모든 Serializable 객체 처리 | Redis CLI로 읽을 수 없음, 클래스 변경 시 역직렬화 오류 위험 |
GenericJackson2JsonRedisSerializer | JSON으로 저장되어 가독성 좋음, 타입 정보 포함 | @class 필드가 JSON에 포함됨, 제네릭 타입 처리 시 주의 필요 |
Jackson2JsonRedisSerializer | 특정 타입 전용으로 깔끔한 JSON | 타입마다 별도 인스턴스 필요, 타입 유연성 낮음 |
현재가장 많이 쓰이는 조합은 GenericJackson2JsonRedisSerializer다.
// 현재 개발 환경에서 권장되는 직렬화 설정
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
RedisCacheConfiguration configuration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(60))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeValuesWith(
// GenericJackson2JsonRedisSerializer: 타입 정보(@class)를 포함한 JSON으로 직렬화
// redis-cli로 저장된 값을 직접 확인할 수 있어 디버깅에 유리함
SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())
);
주의:
GenericJackson2JsonRedisSerializer는 저장된 JSON에"@class": "com.example.ItemDto"같은
타입 정보를 함께 저장한다. 패키지명이 바뀌거나 클래스가 이동하면 역직렬화 시 오류가 발생하므로,
리팩토링 시 기존 캐시를 먼저 비워줘야 한다는 점을 기억하자.
@Cacheable("cache") → 캐시 HIT 시 메서드 실행 안 함 (조회 최적화)
@CachePut("cache") → 항상 메서드 실행 + 캐시 갱신 (쓰기 동기화)
@CacheEvict("cache") → 캐시 삭제 (무효화)
{ args[0], args[1].pageNumber } 형태의 복합 키를 사용한다.#파라미터명 방식이 args[N] 방식보다 가독성이 좋아 유지보수에 유리하다.단건 조회 캐시: @Cacheable → 수정 시 @CachePut, 삭제 시 @CacheEvict(key="#id")
전체 목록 캐시: 데이터 추가/수정/삭제 시 @CacheEvict(allEntries = true)
1. CacheConfig 에서 RedisCacheManager 설정
└─ TTL, 직렬화 방식, 키 접두사 등 전역 설정
2. Service 메서드에 어노테이션 부착
├─ 조회: @Cacheable
├─ 생성/수정: @CachePut
└─ 삭제/무효화: @CacheEvict
3. Redis에 저장되는 키 구조: {cacheName}::{key}
예) itemCache::1, itemSearchCache::[노트북, 0, 20]
기존에 JdkSerializationRedisSerializer(기본 직렬화)로 운영 중인 시스템에서
직렬화 방식을 JSON으로 변경하면, 기존에 저장된 캐시를 읽지 못해 오류가 발생한다.
직렬화 방식 변경 시에는 반드시 캐시 플러시(FLUSHDB 또는 FLUSHALL)를 선행하거나,
키 네이밍 전략을 변경하여 기존 데이터와 충돌을 피해야 한다.
@Caching: 하나의 메서드에 @CacheEvict 여러 개를 동시에 적용해야 할 때 사용하는 묶음 어노테이션@CacheConfig: 클래스 레벨에 적용하여 cacheNames를 공통으로 지정, 메서드마다 반복 작성을 줄일 수 있음/actuator/caches 엔드포인트로 현재 캐시 상태를 모니터링할 수 있음RedisCacheManagerBuilderCustomizer 또는 withInitialCacheConfigurations()를 통해 캐시마다 다른 TTL을 부여할 수 있다// 캐시별 TTL 차등 설정 예시
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig) // 기본 TTL
.withInitialCacheConfigurations(Map.of(
"itemCache", defaultConfig.entryTtl(Duration.ofMinutes(10)), // 상품 캐시: 10분
"itemAllCache", defaultConfig.entryTtl(Duration.ofMinutes(5)), // 전체 목록: 5분
"itemSearchCache", defaultConfig.entryTtl(Duration.ofMinutes(3)) // 검색 결과: 3분
))
.build();