01 캐시 기초
02 Spring Cache 핵심
03 캐시 저장소
04 실전과 운영
- 면접 대비 ← 현재 편
이 편은 시리즈의 마지막이다. 지금까지 다룬 내용을 면접 질문 형태로 정리한다. 단순한 개념 질문부터 함정 질문까지 포함했으므로, 답변할 수 있는지 먼저 스스로 점검해보라.
기본 개념
자주 요청되는 데이터를 빠른 저장소에 임시 보관하여, 같은 요청에 대해 원본 저장소를 반복 조회하지 않도록 하는 기법이다. DB 조회나 외부 API 호출 같은 비용이 큰 연산의 결과를 메모리에 저장해두면 응답 속도가 빨라지고, 원본 저장소의 부하가 줄어든다.
핵심 포인트 : "반복되는 동일 요청"과 "비용이 큰 연산"이라는 두 조건이 캐시의 존재 이유다.
Cache Hit는 요청한 데이터가 캐시에 존재하는 상태이고, Cache Miss는 존재하지 않는 상태다. Hit이면 원본 저장소를 거치지 않고 바로 반환하므로 빠르다. Miss면 원본 저장소에서 데이터를 가져와 캐시에 저장한 뒤 반환한다.
캐시 성능은 적중률로 판단한다. 적중률 = Hit 횟수 / 전체 요청 횟수. 80% 이상이면 효과적인 캐시다.
적합한 데이터 : 읽기 빈도가 높고 쓰기 빈도가 낮은 데이터, 계산 비용이 큰 데이터, 약간의 지연이 허용되는 데이터.
적합하지 않은 데이터 : 실시간 정확성이 필수인 데이터(재고, 결제), 쓰기가 읽기보다 많은 데이터, 사용자별로 고유하면서 캐시 효율이 낮은 데이터.
함정 주의 : "사용자별 고유 데이터는 캐시할 수 없다"는 틀린 답이다. 키를 잘 설계하고 TTL을 짧게 잡으면 가능하다. 다만 메모리 효율을 반드시 고려해야 한다.
Spring Cache 동작 원리
Spring이 제공하는 캐시 추상화 계층이다. 특정 캐시 구현체에 종속되지 않도록 CacheManager와 Cache라는 인터페이스를 제공한다. 개발자는 @Cacheable 같은 어노테이션으로 캐시 로직을 선언적으로 적용하고, 실제 캐시 저장소는 설정으로 교체할 수 있다.
PSA의 대표적인 사례다. @Transactional이 트랜잭션 매니저를 추상화하듯, @Cacheable은 캐시 매니저를 추상화한다.
AOP 프록시 기반으로 동작한다. Spring이 @Cacheable이 붙은 빈의 프록시 객체를 생성하고, 메서드 호출을 가로챈다. 프록시는 CacheManager에서 캐시를 가져오고, 키로 조회하여 Hit이면 메서드를 실행하지 않고 캐시 값을 반환한다. Miss면 메서드를 실행하고 결과를 캐시에 저장한다.
함정 주의 : "Spring이 메서드에 직접 코드를 삽입한다"는 틀린 답이다. 바이트코드 조작이 아니라 프록시 패턴으로 동작한다.
동작하지 않는다. 내부 호출은 프록시를 거치지 않기 때문이다. this.getWeather()처럼 호출하면 실제 객체의 메서드가 직접 실행되어 캐시 로직이 적용되지 않는다.
해결 방법은 캐시 메서드를 별도 클래스로 분리하거나, 자기 자신을 @Lazy로 주입받아 프록시를 통해 호출하는 것이다. 클래스 분리가 가장 깔끔한 방법이다.
함정 주의 : "protected 메서드도 캐시가 동작한다"는 틀린 답이다. Spring AOP는 public 메서드에서만 동작한다.
어노테이션과 설정
- @Cacheable : 캐시를 먼저 조회한다. Hit이면 메서드를 실행하지 않고 캐시 값을 반환한다. Miss면 메서드를 실행하고 결과를 캐시에 저장한다.
- @CachePut : 캐시를 조회하지 않고 항상 메서드를 실행한다. 실행 결과를 캐시에 저장한다. 캐시 갱신 용도다.
- @CacheEvict : 캐시에서 항목을 제거한다. 데이터 변경 시 오래된 캐시를 무효화하는 용도다.
함정 주의 : "@CachePut은 캐시에 데이터가 있으면 그것을 반환한다"는 틀린 답이다. @CachePut은 항상 메서드를 실행한다.
- condition : 메서드 실행 전에 평가된다. true이면 캐시를 사용하고, false이면 캐시를 아예 무시한다. #result를 사용할 수 없다.
- unless : 메서드 실행 후에 평가된다. true이면 결과를 캐시에 저장하지 않는다. #result를 사용할 수 있다.
이름이 헷갈릴 수 있다. condition은 "조건이 참이면 캐시한다", unless는 "조건이 참이면 캐시하지 않는다"로 기억한다.
SimpleKeyGenerator가 기본이다. 파라미터가 없으면 SimpleKey.EMPTY, 하나면 그 값 자체, 여러 개면 SimpleKey 객체(모든 파라미터의 hashCode 조합)를 키로 사용한다.
함정 주의 : "파라미터가 없으면 키가 null이다"는 틀린 답이다. SimpleKey.EMPTY라는 빈 키 객체가 사용된다.
캐시 저장소
로컬 캐시 : 애플리케이션 프로세스 내부 메모리에 저장한다. 네트워크 호출이 없어 매우 빠르다. Caffeine이 대표적이다. 단일 서버 환경에 적합하다. 다중 서버에서는 각 서버의 캐시가 독립적이므로 일관성 문제가 발생한다.
분산 캐시 : 외부 서버에 저장한다. 여러 애플리케이션 인스턴스가 같은 캐시를 공유한다. Redis가 대표적이다. 다중 서버 환경에 적합하지만, 네트워크 지연이 추가된다.
JDK 직렬화는 세 가지 문제가 있다.
1. Redis CLI에서 값을 읽을 수 없다 (바이너리 데이터)
2. 클래스 구조가 바뀌면 역직렬화에 실패한다 (필드 추가/삭제 시)
3. 클래스 메타데이터까지 포함하므로 데이터 크기가 크다
JSON 직렬화는 가독성이 좋고, 클래스 구조 변경에 유연하며, 데이터 크기가 작다.
기본적으로 Redis 연결 실패 시 예외가 발생하고, @Cacheable 호출 자체가 실패한다. 캐시 장애가 서비스 장애로 이어진다.
CacheErrorHandler를 등록하면 캐시 오류를 무시하고 원본 메서드를 직접 실행할 수 있다. Redis가 다운되어도 DB에서 직접 조회하므로 응답은 느려지지만 서비스는 멈추지 않는다.
핵심 포인트 : 캐시는 성능을 위한 보조 수단이지 필수 인프라가 아니다. 캐시 장애가 서비스 장애로 이어지는 구조는 잘못된 설계다.
캐시 일관성과 갱신
읽기 : 캐시 조회 → Hit이면 반환, Miss면 DB 조회 → 캐시 저장 → 반환.
쓰기 : DB 업데이트 → 캐시 무효화.
애플리케이션이 캐시와 DB 사이의 중재자 역할을 한다. Spring Cache의 @Cacheable이 읽기 흐름을, @CacheEvict가 쓰기 흐름을 구현한다. 캐시 장애 시 DB에서 직접 조회할 수 있으므로 복원력이 높다.
캐시 먼저 삭제 → DB 업데이트 : 캐시 삭제 직후, 아직 업데이트되지 않은 DB 데이터를 다른 스레드가 읽어 캐시에 다시 넣을 수 있다. 결과적으로 캐시에 옛날 데이터가 남는다.
DB 먼저 업데이트 → 캐시 삭제 : DB가 먼저 최신 상태가 되고, 이후 캐시를 삭제한다. 캐시 삭제 전에 다른 스레드가 캐시에서 옛날 데이터를 읽을 수 있지만, 삭제 후에는 반드시 최신 데이터가 캐시된다.
DB 먼저 업데이트가 더 안전하다. Spring Cache의 @CacheEvict는 기본적으로 메서드 실행 후에 캐시를 제거하므로 이 순서를 지킨다.
캐시가 만료되는 순간에 동시에 많은 요청이 들어오면, 모든 요청이 캐시 미스를 경험하고 동시에 DB를 조회하는 현상이다. Thundering Herd 문제라고도 부른다.
방지 방법은 두 가지다.
1. @Cacheable(sync = true) : 같은 키에 대해 하나의 스레드만 메서드를 실행하고, 나머지는 결과를 기다린다.
2. 사전 갱신(Cache Warming) : 스케줄러로 TTL 만료 전에 미리 캐시를 갱신한다.
Write-Through : DB와 캐시를 동시에 갱신한다. 쓰기 시 캐시와 DB 모두에 저장하므로 읽기 시 항상 최신 데이터가 보장된다. 쓰기 성능이 떨어진다.
Write-Behind : 캐시에만 먼저 쓰고, DB에는 비동기로 나중에 반영한다. 쓰기 성능이 빠르지만, 캐시가 죽으면 아직 DB에 반영되지 않은 데이터가 유실될 수 있다.
함정 주의 : "Write-Through는 캐시에만 쓴다"는 틀린 답이다. 캐시와 DB 모두에 동기적으로 쓰는 것이 Write-Through다.
함정 질문
아니다. 캐시 적중률이 낮으면 오히려 성능이 저하된다. 모든 요청이 캐시 조회(Miss) → 원본 조회 → 캐시 저장이라는 추가 단계를 거치기 때문이다. 캐시 자체가 오버헤드가 되는 것이다.
또한 Redis 같은 분산 캐시는 네트워크 지연이 추가된다. 원본 조회가 이미 충분히 빠르다면 캐시를 추가하는 것이 오히려 느릴 수 있다.
캐시 구현체에 따라 다르다. Caffeine에서 expireAfterWrite(0, SECONDS)로 설정하면 저장 즉시 만료되어 캐시가 아무 효과가 없다. Redis에서 TTL을 0으로 설정하면 즉시 삭제된다.
TTL 0은 "캐시를 사용하지 않겠다"와 같은 의미지만, 캐시 조회/저장이라는 불필요한 연산은 여전히 수행된다. 캐시를 사용하지 않으려면 아예 @Cacheable을 제거하는 것이 올바르다.
결과적으로 같다. allEntries = true는 내부적으로 Cache.clear()를 호출한다. 해당 캐시 영역의 모든 항목을 제거한다.
단, Redis 환경에서는 주의가 필요하다. clear()가 해당 캐시 이름의 모든 키를 삭제하는데, 키가 수만 개라면 Redis가 KEYS 명령으로 전체를 스캔하면서 순간적으로 블로킹될 수 있다.
같은 CacheManager를 공유하는 모듈들이 같은 캐시 이름을 사용하면, 키가 같을 때 서로의 데이터를 읽거나 덮어쓴다. 모듈 A의 @Cacheable("data")와 모듈 B의 @Cacheable("data")가 같은 캐시 영역을 공유하게 된다.
해결 방법은 캐시 이름에 모듈 프리픽스를 붙이거나(moduleA:data), 모듈별로 별도의 CacheManager를 사용하는 것이다.