본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : 성능 측정과 확장

Cache 경계와 무효화

캐시는 느린 코드를 자동으로 빠르게 만드는 annotation이 아니라 원본 데이터의 두 번째 복사본입니다. 어떤 요청이 같은 결과를 공유하는지 key로 정의하고, 얼마나 오래된 값을 허용할지 정하며, 원본이 바뀌었을 때 언제 제거할지를 함께 설계해야 합니다.


캐시는 데이터의 두 번째 복사본이다

key에서 locale이나 권한처럼 결과를 바꾸는 입력을 빠뜨리면 다른 사용자의 결과가 섞일 수 있습니다. TTL은 메모리 정리 시간만이 아니라 업무가 허용하는 stale window입니다. 변경 이벤트에 의한 evict는 더 빠른 일관성을 주지만, transaction이 rollback되기 전에 제거하면 원본과 캐시의 순서가 뒤틀릴 수 있습니다.

그래서 예제는 AFTER_COMMIT 이벤트에서 과목별 key만 제거합니다. 전체 캐시를 비우는 구현은 간단하지만 관련 없는 과목까지 miss가 되어 순간 부하가 커집니다.


과목 요약에 @Cacheable과 evict를 연결한다

과목 이름을 key로 쓰는 읽기 서비스와, 커밋된 변경 뒤 해당 과목만 제거하는 invalidator를 작성합니다. 실제 운영 cache provider에서는 최대 크기와 TTL도 별도 구성해야 합니다.

src/main/java/com/andongmin/studylog/cache/SubjectSummaryTypes.java
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);
    }
}
src/main/java/com/andongmin/studylog/cache/CacheConfiguration.java
package com.andongmin.studylog.cache;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableCaching
public class CacheConfiguration {
}
src/main/java/com/andongmin/studylog/cache/SubjectSummaryService.java
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);
    }
}
src/main/java/com/andongmin/studylog/cache/SubjectCacheInvalidator.java
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까지 포함해 이 경계를 확인합니다.

src/test/java/com/andongmin/studylog/cache/SubjectSummaryServiceTest.java
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
Evict 확인 결과
같은 subject를 연속 조회하면 repository는 한 번만 호출되고, 해당 key를 제거한 뒤 다음 조회는 새 값을 읽어야 합니다. `AFTER_COMMIT` 시점 자체는 transaction 통합 테스트에서 별도로 확인합니다.

캐시로 읽기 횟수를 줄인 뒤에도 동시 대기 요청이 많다면, 다음 절에서 요청 스레드 비용과 외부 자원 한계를 분리해 봅니다.