Cache 경계와 무효화
캐시는 느린 코드를 자동으로 빠르게 만드는 annotation이 아니라 원본 데이터의 두 번째 복사본입니다. 어떤 요청이 같은 결과를 공유하는지 key로 정의하고, 얼마나 오래된 값을 허용할지 정하며, 원본이 바뀌었을 때 언제 제거할지를 함께 설계해야 합니다.
캐시는 데이터의 두 번째 복사본이다
key에서 locale이나 권한처럼 결과를 바꾸는 입력을 빠뜨리면 다른 사용자의 결과가 섞일 수 있습니다. TTL은 메모리 정리 시간만이 아니라 업무가 허용하는 stale window입니다. 변경 이벤트에 의한 evict는 더 빠른 일관성을 주지만, transaction이 rollback되기 전에 제거하면 원본과 캐시의 순서가 뒤틀릴 수 있습니다.
그래서 예제는 AFTER_COMMIT 이벤트에서 과목별 key만 제거합니다. 전체 캐시를 비우는 구현은 간단하지만 관련 없는 과목까지 miss가 되어 순간 부하가 커집니다.
과목 요약에 @Cacheable과 evict를 연결한다
과목 이름을 key로 쓰는 읽기 서비스와, 커밋된 변경 뒤 해당 과목만 제거하는 invalidator를 작성합니다. 실제 운영 cache provider에서는 최대 크기와 TTL도 별도 구성해야 합니다.
package com.andongmin.studylog.cache;
public final class SubjectSummaryTypes {
private SubjectSummaryTypes() {
}
public record SubjectSummary(String subject, long sessions, long minutes) {
}
public record StudySessionChanged(String subject) {
}
public interface SubjectSummaryRepository {
SubjectSummary summarize(String subject);
}
}package com.andongmin.studylog.cache;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfiguration {
}package com.andongmin.studylog.cache;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import com.andongmin.studylog.cache.SubjectSummaryTypes.SubjectSummary;
import com.andongmin.studylog.cache.SubjectSummaryTypes.SubjectSummaryRepository;
@Service
public class SubjectSummaryService {
private final SubjectSummaryRepository repository;
public SubjectSummaryService(SubjectSummaryRepository repository) {
this.repository = repository;
}
@Cacheable(cacheNames = "subject-summary", key = "#subject")
public SubjectSummary find(String subject) {
return repository.summarize(subject);
}
}package com.andongmin.studylog.cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import com.andongmin.studylog.cache.SubjectSummaryTypes.StudySessionChanged;
@Component
public class SubjectCacheInvalidator {
private final CacheManager caches;
public SubjectCacheInvalidator(CacheManager caches) {
this.caches = caches;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onSessionChanged(StudySessionChanged event) {
var cache = caches.getCache("subject-summary");
if (cache != null) {
cache.evict(event.subject());
}
}
}@Cacheable은 Spring proxy가 메서드 호출을 가로챌 때 동작합니다. 같은 객체 안에서 this.find(subject)처럼 self-invocation하면 proxy를 지나지 않아 캐시가 적용되지 않습니다. annotation이 붙었다는 사실보다 호출 경로가 Bean proxy를 통과하는지가 중요합니다.
hit·miss·stale 가능성을 확인한다
같은 key를 두 번 읽었을 때 repository는 한 번만 호출되고, invalidator가 key를 제거한 뒤에는 다시 호출되어야 합니다. 다음 작은 Spring test context는 proxy까지 포함해 이 경계를 확인합니다.
package com.andongmin.studylog.cache;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import com.andongmin.studylog.cache.SubjectSummaryTypes.StudySessionChanged;
import com.andongmin.studylog.cache.SubjectSummaryTypes.SubjectSummary;
import com.andongmin.studylog.cache.SubjectSummaryTypes.SubjectSummaryRepository;
@SpringJUnitConfig(SubjectSummaryServiceTest.TestConfiguration.class)
class SubjectSummaryServiceTest {
@Autowired SubjectSummaryService service;
@Autowired SubjectSummaryRepository repository;
@Autowired SubjectCacheInvalidator invalidator;
@Test
void cachesBySubjectAndEvictsChangedSubject() {
when(repository.summarize("Spring"))
.thenReturn(new SubjectSummary("Spring", 2, 90))
.thenReturn(new SubjectSummary("Spring", 3, 140));
assertEquals(2, service.find("Spring").sessions());
assertEquals(2, service.find("Spring").sessions());
invalidator.onSessionChanged(new StudySessionChanged("Spring"));
assertEquals(3, service.find("Spring").sessions());
verify(repository, times(2)).summarize("Spring");
}
@Configuration(proxyBeanMethods = false)
@EnableCaching
static class TestConfiguration {
@Bean SubjectSummaryRepository repository() {
return mock(SubjectSummaryRepository.class);
}
@Bean CacheManager cacheManager() {
return new ConcurrentMapCacheManager("subject-summary");
}
@Bean SubjectSummaryService service(SubjectSummaryRepository repository) {
return new SubjectSummaryService(repository);
}
@Bean SubjectCacheInvalidator invalidator(CacheManager cacheManager) {
return new SubjectCacheInvalidator(cacheManager);
}
}
}./mvnw -q test -Dtest=SubjectSummaryServiceTest같은 subject를 연속 조회하면 repository는 한 번만 호출되고, 해당 key를 제거한 뒤 다음 조회는 새 값을 읽어야 합니다. `AFTER_COMMIT` 시점 자체는 transaction 통합 테스트에서 별도로 확인합니다.캐시로 읽기 횟수를 줄인 뒤에도 동시 대기 요청이 많다면, 다음 절에서 요청 스레드 비용과 외부 자원 한계를 분리해 봅니다.