이전 문서에서 피드 조회 한 번에 쿼리가 120회 넘게 날아가는 문제를 확인했다. 이제 이 쿼리들을 줄이기 위한 도구, 캐시에 대해 알아본다. 캐시가 무엇이고, 어떤 패턴으로 동작하며, 데이터가 바뀌었을 때 어떻게 갱신하는지까지 이 문서에서 정리한다.
캐시가 뭔가
캐시는 자주 읽히지만 잘 바뀌지 않는 데이터를 메모리에 임시 저장해두는 기법이다. DB는 디스크에서 데이터를 읽어오기 때문에 아무리 빨라도 1~10ms 정도 걸린다. 반면 Redis 같은 인메모리 저장소는 데이터가 RAM에 있기 때문에 0.1ms 이하로 응답한다. 10배에서 100배 차이다.
핵심은 "자주 읽히지만 잘 바뀌지 않는" 데이터라는 조건이다. 좋아요 수를 예로 들면, 피드 목록을 조회할 때마다 매번 COUNT 쿼리를 날리지만 실제로 좋아요가 추가되는 빈도는 조회 빈도보다 훨씬 낮다. 이런 데이터가 캐시의 이상적인 후보다.
캐시를 다룰 때 반드시 알아야 하는 용어 네 가지가 있다.
- Cache Hit : 요청한 데이터가 캐시에 있는 경우. DB를 거치지 않고 바로 응답한다.
- Cache Miss : 요청한 데이터가 캐시에 없는 경우. DB에서 가져온 뒤 캐시에 저장한다.
- TTL : Time To Live. 캐시 항목이 자동 삭제되기까지의 시간이다. 10분으로 설정하면 10분 뒤 해당 항목이 사라진다.
- Eviction : 캐시 용량이 가득 찼을 때 오래된 항목을 밀어내는 정책이다. LRU, LFU 같은 알고리즘이 있다.
매번 다른 결과를 반환하는 데이터, 실시간 정합성이 필수인 데이터는 캐시에 적합하지 않다. "읽기 비율이 쓰기 비율보다 압도적으로 높은가?"가 캐시 적용 판단의 출발점이다.
Cache-Aside 패턴
Spring의 @Cacheable이 구현하는 패턴이 바로 Cache-Aside다. "Aside"라는 이름은 캐시가 DB 옆에서 보조하는 위치에 있다는 뜻이다. 애플리케이션이 캐시와 DB를 직접 관리하며, 캐시는 DB 앞에서 데이터를 가로채는 게 아니라 옆에서 필요할 때만 참여한다.
Cache Hit 흐름
캐시에 데이터가 있을 때의 흐름을 보면 구조가 직관적이다.
초록 영역에서 보이듯, Cache Hit이면 DB에 접근하지 않는다. 1~10ms 걸리던 DB 조회가 0.1ms로 줄어든다. 피드 20개를 조회할 때 좋아요 수와 댓글 수의 COUNT 쿼리 40개가 전부 캐시에서 처리된다면, 그 효과는 어마어마하다.
Cache Miss 흐름
반대로 캐시에 데이터가 없을 때의 흐름이다. 위의 Cache Hit과 비교하면 차이가 뚜렷하다.
주황 영역의 4~6번 단계가 추가됐다. Cache Miss가 발생하면 DB에서 데이터를 가져오고, 그 결과를 캐시에 저장한 뒤 응답한다. 다음에 같은 요청이 오면 Cache Hit이 되므로, 첫 번째 요청만 느리고 이후는 빠르다.
Cache-Aside는 "읽기 최적화" 패턴이다. 쓰기 시 캐시를 먼저 갱신하는 Write-Through, 캐시에만 쓰고 나중에 DB에 반영하는 Write-Behind 같은 패턴도 존재한다. 하지만 Spring Cache 추상화가 기본으로 제공하는 건 Cache-Aside이고, 대부분의 웹 서비스에서 가장 많이 쓰인다.
Spring Cache 추상화
Spring은 캐시 구현체에 상관없이 동일한 코드를 사용할 수 있는 추상화 레이어를 제공한다. Redis든 Caffeine이든 EhCache든, 어노테이션 세 개로 캐시를 제어한다.
@Cacheable
메서드 결과를 캐시에 저장하고, 다음 호출부터는 메서드를 실행하지 않고 캐시된 값을 반환한다.
@Cacheable(value = "weathers", key = "#request.latitude() + ':' + #request.longitude()")
public List<WeatherResponse> create(WeatherRequest request) {
// 이 로직은 캐시 미스일 때만 실행된다
return weatherApiCallService.getWeather(request);
}
value는 캐시의 이름이다. Redis에서는 이 이름이 키의 접두어가 된다. key는 같은 캐시 안에서 개별 항목을 구분하는 키다. 위 예시에서는 좌표 조합이 키가 되므로, 서울과 부산의 날씨가 각각 별도로 캐시된다.
@CacheEvict
캐시에서 데이터를 삭제한다. 데이터가 변경되었을 때 기존 캐시를 무효화하는 용도다.
@CacheEvict(value = "weathers", allEntries = true)
public void deleteAllWeatherCache() {
// 메서드 본문이 비어 있어도 된다
// 이 메서드가 호출되면 "weathers" 캐시의 모든 항목이 삭제된다
}
allEntries = true는 해당 캐시의 모든 항목을 한꺼번에 날린다. 특정 키만 지우고 싶으면 key 속성을 쓴다.
@CachePut
메서드를 항상 실행하고, 그 결과를 캐시에 덮어쓴다. @Cacheable과 달리 메서드 실행을 건너뛰지 않는다는 점이 다르다. 데이터를 갱신하면서 동시에 캐시도 최신 상태로 유지하고 싶을 때 쓴다.
이 세 어노테이션의 핵심은 구현체를 바꿔도 코드가 변하지 않는다는 점이다. 개발 환경에서는 Caffeine으로 테스트하고, 운영 환경에서는 Redis를 쓰더라도 서비스 코드를 수정할 필요가 없다. 설정 클래스에서 CacheManager 빈만 교체하면 된다.
WeatherFit의 CacheConfig 현재 상태
현재 프로젝트의 캐시 설정을 보면 구조가 잘 보인다.
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(mapper, Object.class);
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(serializer))
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
몇 가지 포인트를 짚어보면 이렇다.
- 키 직렬화 :
StringRedisSerializer로 사람이 읽을 수 있는 문자열 키를 사용한다 - 값 직렬화 :
Jackson2JsonRedisSerializer로 객체를 JSON 형태로 저장한다.JavaTimeModule을 등록해서LocalDateTime같은 Java 8 시간 타입도 처리한다. - TTL : 기본 10분. 모든 캐시에 동일하게 적용된다.
- null 값 캐싱 금지 :
disableCachingNullValues()로 null 결과는 캐시하지 않는다
날씨 데이터와 좋아요 수는 갱신 주기가 다르다. 날씨는 3시간 단위로 바뀌니 30분 TTL이면 충분하지만, Presigned URL은 만료 시간이 있으니 그보다 짧아야 한다. 캐시 이름별로 다른 TTL을 설정하는 방법은 다음 문서에서 다룬다.
무효화 전략 세 가지
캐시를 도입하면 반드시 따라오는 질문이 있다. 데이터가 바뀌었을 때 캐시는 언제, 어떻게 갱신하는가? 이게 무효화 전략이다. 변경의 주체가 누구인지에 따라 전략이 달라진다.
다음 흐름을 따라가면 어떤 전략을 써야 하는지 판단할 수 있다.
전체 무효화"] E_Act["TTL 만료 대기"] Start --> Q1 Q1 -->|"사용자"| U Q1 -->|"내부 배치"| B Q1 -->|"외부 API"| E U --> U_Act B --> B_Act E --> E_Act style Start fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style Q1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style U fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style B fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style E fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style U_Act fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style B_Act fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style E_Act fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000
다이아몬드 노드에서 세 갈래로 나뉘는 게 보인다. 각각을 자세히 보자.
사용자 액션 — 즉시 @CacheEvict
사용자가 좋아요를 누르거나 댓글을 작성하면, 그 즉시 해당 캐시 항목을 삭제한다. 다음 조회 때 Cache Miss가 발생하면서 DB에서 최신 값을 가져온다.
@CacheEvict(value = "likeCounts", key = "#feedId")
public void toggleLike(Long feedId, Long userId) {
// 좋아요 토글 로직
}
일관성이 가장 높은 전략이다. 사용자가 좋아요를 누른 직후 피드를 새로고침하면 즉시 반영된 값이 보인다. 대신 캐시 삭제와 DB 쓰기가 동시에 일어나므로 쓰기 성능에 약간의 오버헤드가 있다.
배치 — allEntries 전체 무효화
주기적으로 대량 데이터가 갱신될 때 사용한다. WeatherFit에서는 날씨 데이터가 이 케이스다. 배치가 새 날씨 정보를 DB에 저장한 뒤, 날씨 캐시 전체를 비운다.
@CacheEvict(value = "weathers", allEntries = true)
public void deleteAllWeatherCache() {
// 본문 없이 어노테이션만으로 동작한다
}
프로젝트에서 WeatherUpdateTasklet과 WeatherScheduler 모두 이 방식을 쓰고 있다. 배치가 완료되는 시점에 한 번만 호출하면 되므로 단순하고 확실하다.
외부 시스템 — TTL 만료 대기
변경 시점을 우리가 통제할 수 없을 때의 전략이다. 외부 날씨 API가 언제 데이터를 갱신하는지 우리는 모른다. 이런 경우 TTL을 적절히 설정해서 일정 시간 뒤 자동으로 캐시가 만료되게 한다.
일관성은 가장 낮다. TTL이 10분이면 최대 10분간 오래된 데이터가 보일 수 있다. 하지만 성능 측면에서는 가장 효율적이다. 외부 API 호출 빈도를 줄여 비용과 응답 시간을 동시에 절약한다.
사용자 액션 전략은 일관성이 높지만 매번 캐시를 무효화해야 한다. TTL 전략은 성능이 좋지만 일정 시간 동안 오래된 데이터가 노출된다. 어떤 데이터가 "몇 분 정도 오래된 값이어도 괜찮은가"를 판단하는 게 캐시 설계의 핵심이다.
자주 하는 실수
가변 객체를 캐시 키로 사용
List나 Map 같은 가변 컬렉션을 캐시 키로 사용하면, 내용이 같아도 인스턴스가 다르면 캐시를 찾지 못한다. 캐시 키는 반드시 String, Long 같은 불변 타입이나, hashCode()와 equals()를 올바르게 구현한 객체를 써야 한다. record 타입을 쓰면 이 문제를 자연스럽게 피할 수 있다.
같은 클래스 내부 호출
@Cacheable은 Spring AOP 프록시를 통해 동작한다. 같은 클래스의 메서드를 this.method()로 호출하면 프록시를 거치지 않기 때문에 캐시가 적용되지 않는다. @Transactional과 동일한 문제다. 캐시 메서드는 반드시 외부에서 호출되어야 한다.
이 구조를 다이어그램으로 보면 왜 내부 호출이 안 되는지 명확해진다.
캐시 어노테이션 무시됨 Target->>Target: 메서드 직접 실행 end
초록 영역은 외부에서 호출할 때다. 호출이 프록시를 거치므로 1~3번 단계에서 캐시가 정상 동작한다. 반면 분홍 영역은 같은 클래스 내부에서 this로 호출할 때다. 프록시를 거치지 않으므로 Redis에 접근조차 하지 않고 메서드가 그냥 실행된다.
TTL 미설정
TTL을 설정하지 않으면 캐시 항목이 영원히 살아있다. Redis 메모리가 계속 쌓이다가 maxmemory에 도달하면 Eviction 정책에 따라 예측 불가능한 항목이 삭제된다. 반드시 모든 캐시에 적절한 TTL을 설정해야 한다. WeatherFit의 CacheConfig에서 entryTtl(Duration.ofMinutes(10))으로 기본 TTL을 10분 잡은 건 이 때문이다.
캐시 스탬피드
수천 명이 동시에 조회하는 인기 피드의 캐시가 만료되면 어떻게 될까? 모든 요청이 동시에 Cache Miss를 맞고, 전부 DB로 쿼리를 날린다. 이게 캐시 스탬피드다. 해결 방법으로는 TTL에 랜덤 편차를 주거나, 캐시 갱신을 한 스레드만 수행하도록 잠금을 거는 방법이 있다. Spring에서는 @Cacheable의 sync = true 옵션으로 후자를 간단히 구현할 수 있다.