Spring의 Cache 추상화
개발을 하다 보면, 동적으로 변하진 않는데, 자주 필요한 데이터들을 조회하는 API를 호출할 일이 종종 있다.
간단한 예시로 아래와 같은 상황이 있을 수 있다.
- Frontend에서 select-box의 값을 채우기 위한 data List 조회
- 외부 API에서 특정 데이터를 조회하는데, 외부 API에서 제공하는 데이터의 갱신 주기가 길 때
- 자주 호출되는 데이터이지만, 실시간으로 변하는 값이 아닐 때
Spring은 자주 호출되는 메서드의 실행 결과를 캐시에 저장하고, 이후 동일한 요청이 들어오면 캐시 된 값을 반환하여 응답 속도를 높이고 시스템 부하를 줄일 수 있도록 캐싱 기능을 추상화하여 제공한다.
캐시 구현체와 무관하게 사용할 수 있도록 @Cacheable, @CacheEvict, @CachePut 등의 어노테이션 기반으로 캐시 설정을 지원하여, 실제 구현체를 선택할 수 있고, 이 글에서는 구현체로 Redis를 선택했다.
AOP 기반으로 동작하며 @Cacheable이 붙은 메서드를 호출하면, 메서드 실행 전 메서드에 명시된 Key로 캐시 된 데이터의 존재 여부를 조회하고, 캐시 된 값이 있으면 메서드를 실행하지 않고 바로 캐시 된 데이터를 반환한다.
@Cacheable 예제
@Cacheable(value = "main-map", key = "'air'", unless = "#result == null or #result.isEmpty()")
public List<MapAirDTO> getAirApiData() {
// 외부 API로 대기 정보를 조회한다.
}
getAirApiData() 호출 시
-> key="main-map::air"로 캐시 된 데이터가 있는지 조회
- 캐시 된 데이터가 없을 때 : 메서드를 실행하여 로직 수행 후 그 결과를 redis에 저장
- 캐시 된 데이터가 있을 때 : 메서드를 실행하지 않고, redis에 저장된 데이터를 반환
Spring Redis
Spring Redis 구조
Spring Cache 추상화를 Redis 구현체와 자동으로 연결해 준다.
간단한 Config 만 해주면 개발자는 어노테이션 기반의 간단한 메서드레벨 캐싱을 구현할 수 있다.
1. @Cacheable (@CacheEvict, @CachePut)
• 개발자가 메서드에 붙이는 어노테이션
• 캐시를 언제, 어떤 키로 사용할지 지정
• 실제 동작은 Spring AOP에 의해 가로채진다.
2. AOP Proxy (CacheInterceptor)
• @Cacheable이 붙은 메서드를 가로채서 캐시 조회 여부 판단
• 캐시에 값이 있으면 메서드 실행을 생략하고 캐시 된 데이터를 반환한다.
• 없으면 메서드 실행 후 캐시에 저장
3. CacheManager (RedisCacheManager)
• 캐시 저장소를 관리하는 인터페이스
• value = "main-map" 같은 캐시 이름을 기준으로 캐시 객체를 관리
• Redis 사용 시 자동으로 RedisCacheManager 구현체를 사용한다.
4. Cache (RedisCache)
• CacheManager가 리턴하는 실제 캐시 객체
• 내부적으로 RedisTemplate 또는 CacheWriter을 통한 connection을 사용해 Redis에 데이터 접근
5. RedisTemplate
• Spring Data Redis의 핵심 API
• Redis와의 get/set, delete, expire 등의 명령어를 수행
(대부분의 경우 개발자가 직접 다루지 않고, Spring이 내부적으로 사용)
• Cache 처리 과정에선 RedisTemplate을 사용하지 않고,
RedisCacheWriter가 RedisConnection을 사용해서 저수준 Redis 접근을 직접 처리한다.
Spring Redis 캐싱 과정
CacheInterceptor.invoke()
Spring AOP가 @Cacheable을 인식해서 진입하는 곳
CacheAspectSupport.execute()
캐시 키를 확인하여 캐시에서 데이터를 꺼낼지, 실행할지, 캐시에 넣을지 판단한다.
RedisCache.lookup(), CacheWriter.get()
CacheWriter.get() 메서드는 내부적으로 직접 RedisConnection을 이용해 Redis 명령(GET 등)을 수행한다.
Spring Redis 적용
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation("org.springframework.boot:spring-boot-starter-cache")
application.yml
spring:
redis:
host: localhost
port: 6379
RedisConfig
@EnableCaching: Spring Cache 기능 활성화
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
// ObjectMapper 커스텀 (Optional)
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// Json 직렬화
GenericJackson2JsonRedisSerializer serializer
= new GenericJackson2JsonRedisSerializer(objectMapper);
// config
RedisCacheConfiguration redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // TTL 설정
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(serializer)
)
.prefixCacheNameWith("cache:"); // 키 접두어
// 캐시 이름마다 다른 TTL 설정 Option
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("user-cache", redisCacheConfig.entryTtl(Duration.ofMinutes(30)));
cacheConfigurations.put("map-cache", redisCacheConfig.entryTtl(Duration.ofHours(1)));
// build CacheManager
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(redisCacheConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
메서드 캐싱 예제
@Cacheable(value = "main-map", key = "'air'")
public List<MapAirDTO> getAirData() {
// 외부 API or DB에서 데이터를 가져오는 비용이 큰 작업
return queryService.loadAirData();
}
@Cacheable 어노테이션 상세
Spring에서 @Cacheable은 메서드의 실행 결과를 캐시에 저장하고,
같은 파라미터로 다시 호출될 때는 메서드를 실행하지 않고 캐시 된 결과를 반환하는 기능을 제공한다.
@Cacheable - 캐시 키 설정
SpEL(Spring EL)로 캐시 키를 어노테이션에서 설정할 수 있다.
#param | 단일 파라미터 |
#param1 + '-' + #param2 | 파라미터 조합 |
'static-key' | 고정 문자열 키 |
#root.methodName | 현재 메서드 이름을 키로 사용 |
복합 파라미터 키 예제
@Cacheable(value = "air-quality", key = "#region + ':' + #date")
public AirData getAir(String region, LocalDate date) { ... }
커스텀 KeyGenerator 예제
보다 복잡한 key 생성 전략의 경우에 커스텀 KeyGenerator를 만들어서 사용할 수 있다.
@Bean("customKeyGen")
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
return Arrays.stream(params).map(Object::toString).collect(Collectors.joining("_"));
};
}
@Cacheable(value = "data", keyGenerator = "customKeyGen")
public Object getData(MyRequestDto dto) { ... }
@Cacheable - 조건부 캐싱
조건에 따라 캐시 저장 여부, 캐시 사용 여부를 결정할 수 있다.
- unless: 메서드 실행 전에 동작하며, 조건이 true일 때만 캐시를 사용한다.
- condition: 메서드 실행 후에 동작하며, 조건이 false일 때만 캐시를 저장한다.
condition 예제
region이 'Seoul'이 아닐 때만 캐싱한다.
@Cacheable(value = "region-air", key = "#region", condition = "#region != 'Seoul'")
public List<AirData> getAir(String region) { ... }
unless 예제
result == null 일 경우엔 저장하지 않는다.
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getUser(Long id) { ... }
Redis 캐시 설계
Spring에서 Redis를 캐시 저장소로 사용할 때, 단순히 @Cacheable만 붙인다고 끝나는 건 아니다.
파라미터 조합, TTL 설정, 캐시 키 개수 등을 신중하게 설계하지 않으면,
예상치 못한 Redis 메모리 과다 사용, 캐시 오염, 성능 저하 같은 문제가 생길 수 있다.
Redis의 장점
Redis는 인메모리 기반의 Key-Value 저장소로 싱글스레드로 동작하는 초고속 스토리지이다. DB보다는 캐시용으로 많이 사용한다.
- 빠른 속도 (메모리에 저장하기 때문)
- TTL 지원 (일정 시간 후 자동 삭제)
- LRU 정책 (메모리 초과 시 오래된 키부터 자동으로 선택되어 삭제, 다른 전략 선택 가능)
- 다양한 자료구조 지원(String, List, Set, Sorted Set, Hash 등)
- 수평 확장과 복제 지원(Redis Cluster, Sentinel)
Redis 설계 시 주의점
key-value쌍 개수 조절 필요
캐시에서 가장 중요한 건 “무엇을 얼마나 저장할 것인가”다.
예를 들어, 다음과 같은 상황을 생각해 보자
• 캐싱 대상 메서드의 파라미터가 2개 (region, date)
• 각 파라미터는 1000가지 값이 존재할 수 있음
이렇게 되면 총 1,000,000 개의 다른 key-value 쌍이 생성될 수 있다.
따라서 아래와 같은 원칙으로 키를 생성할 수 있을 것 같다.
- 불필요한 파라미터는 캐시 키에서 제외
- 키 접두어 or 해싱 전략으로 명확한 키 구분
- 자주 사용되는 파라미터 조합만 선별적으로 캐싱
- 조건부 캐싱으로 “정말 필요한 경우에만 캐싱”
TTL 설정의 중요성
TTL(Time-To-Live)은 캐시 유효 기간을 의미하며, Redis에선 설정한 시간이 지나면 자동으로 키가 삭제된다.
TTL이 없으면?
• Redis에 데이터가 무한정 쌓이게 됨
• 메모리 한도 도달 → Redis 성능 급락
• 오래된 데이터가 남아 신뢰도 있는 캐시로 동작하지 않음
TTL은 이런 문제를 방지하는 기본적이고 필수적인 안전장치다.
데이터의 종류에 따라 아래와 같은 원칙으로 TTL을 설정할 수 있을 것 같다.
- 자주 바뀌는 데이터 → 짧은 TTL (예: 5~10분)
- 거의 안 바뀌는 코드 테이블, 설정값 → 긴 TTL (예: 1일)
- API나 외부 연동 결과 → TTL + 조건부 캐싱 (unless) 조합
캐시 정리 전략: LRU 정책
Redis는 메모리 기반 저장소이기 때문에, 메모리가 가득 찼을 때 어떻게 데이터를 정리할지에 대한 정책이 반드시 필요하다.
이럴 때 사용되는 대표적인 정책이 바로 LRU(Least Recently Used)이다.
LRU는 가장 오랫동안 사용되지 않은 Key부터 제거하는 방식이다.
TTL과 함께 LRU 정책으로 더욱 안정적인 캐시 관리가 가능하다.
value 크기 및 Redis 메모리 관리
Redis의 캐시는 key 개수보다 value 크기가 훨씬 중요한 경우가 많다.
value가 클수록 Redis 메모리를 빠르게 소모하고, GC 성능에도 영향을 줄 수 있다.
예: 동일한 10,000개의 키일 때
• value가 평균 0.5KB → 총 약 5MB
• value가 평균 50KB → 총 약 500MB
메모리는 Redis의 가장 비싼 자원이므로 value 크기를 작게, 직렬화하여 효율적으로 유지하는 것이 핵심이다.
직렬화 전략
- String 값: 가능한 경우 단순 텍스트로 저장
- JSON 직렬화: GenericJackson2JsonRedisSerializer 사용
- Binary 직렬화: JdkSerializationRedisSerializer는 크기가 커질 수 있어 주의
- DTO 필드 중 필요한 정보만 담아서 value 구성하기
실제 사용 방안
캐시는 단순히 “빠르게 만들자”가 아니라
어떤 데이터를, 어떤 조건에서, 얼마나 오래, 어떻게 관리할 것인지에 대한 전략이 필요하다.
캐싱이 필요한 경우
캐시는 특히 자주 호출되지만 자주 바뀌지는 않는 데이터에 효과적이다.
예시:
• 지역별 날씨 정보 (getWeather("Seoul"))
• 공통 코드 테이블 (getCode("USER_STATUS"))
• 드롭다운 옵션 리스트 (getOptionList("product-category"))
이런 데이터는 사용자마다 동일하게 반환되고, 자주 변경되지 않기 때문에
DB나 외부 API 요청을 줄이면서 응답 속도를 향상시킬 수 있다.
캐시 무효화 전략
캐시 된 데이터가 변경되었을 경우, 반드시 기존 캐시를 삭제하거나 갱신해야 한다.
그렇지 않으면 오래된 값이 계속 반환되는 캐시 오염(Cache Pollution) 문제가 발생한다.
@CacheEvict
특정 메서드 실행 시 특정 캐시를 제거하도록 할 수 있다.
예를 들면 엔티티를 update 하는 메서드에 어노테이션을 붙여서 캐시를 제거할 수 있다.
@CacheEvict(value = "product-cache", key = "#productId")
public void updateProduct(Long productId, ProductDto dto) {
// DB 업데이트 후 캐시 제거
}
@CacheEvict + @Scheduled
Scheduling을 통해 주기적으로 캐시를 삭제할 수도 있다.
@Scheduled(cron = "0 0 * * * *") // 매시간 정각
@CacheEvict(value = "weather-cache", allEntries = true)
public void clearWeatherCache() {
// 날씨 데이터는 정기적으로 바뀌므로 주기적으로 비워줌
}
정리
Spring Redis 핵심
- @Cacheable을 이용하여 캐싱을 선언적으로 구현
- Config에서 TTL, Serializer, Key 전략 등을 설정
- @CacheEvict를 이용하여 캐시 무효화
캐시 적용 시 기억해야할 것들
- TTL: 캐시가 무한정 남아있지 않도록 꼭 TTL을 설정하자.
- 키 설계의 중요성: 키 수는 곧 메모리이다. 키수가 무한정 증폭되지 않도록 키 설계를 잘 해야한다.
- 캐시 무효화: 오래된 캐시는 꼭 정리해야 한다. @CacheEvict 또는 스케쥴링
- 캐시의 필요성 판단: 캐싱이 꼭 필요한지 여부를 판단해야 한다. 전략적으로 꼭 필요한 데이터만 캐싱한다.
관련 링크
Spring-data-redis gitub
https://github.com/spring-projects/spring-data-redis
Spring-data-redis DOCS
https://spring.io/projects/spring-data-redis#overview
Spring Redis Tutorial
https://www.baeldung.com/spring-data-redis-tutorial
'Spring' 카테고리의 다른 글
[DB / Spring ] @Transactional 세부 설정 - 격리 수준 / 전파 수준 설정 (0) | 2024.03.19 |
---|---|
[Spring] 빈 스코프 (Bean Scope) / 싱글톤, 프로토타입 등 (0) | 2024.02.27 |
[Spring] 빈 생명 주기 콜백 (Bean LifeCycle) @PostConstruct / @PreDestroy (0) | 2024.02.23 |
[Spring] 의존성 주입 방법 (DI : Dependency Injection), 생성자 주입 / 필드 주입 / setter 주입, 생성자 주입을 사용하는 이유 (0) | 2024.02.22 |
[Spring Security / JWT] Spring Security - JWT 토큰 인증/인가 (0) | 2024.01.12 |