Weekly Paper 16 - Spring Cache와 로컬 캐시, 분산 캐시

이번 Weekly Paper의 주제는 Spring Cache에서 자주 사용하는 @Cacheable, @CachePut, @CacheEvict의 차이와, 로컬 캐시와 분산 캐시를 어떤 기준으로 선택해야 하는지이다.

캐시는 처음 보면 단순히 “자주 쓰는 데이터를 저장해두는 것”처럼 느껴진다. 하지만 실제로는 언제 저장하고, 언제 다시 조회하고, 언제 지워야 하는지가 중요하다. 캐시를 잘못 사용하면 성능은 좋아질 수 있어도 오래된 데이터를 보여주는 문제가 생길 수 있다.

1. 캐시를 사용하는 이유

웹 애플리케이션은 같은 데이터를 반복해서 조회하는 경우가 많다.

예를 들어 다음과 같은 요청이 있다고 가정해보자.

  • 사용자 목록 조회
  • 특정 사용자의 채널 목록 조회
  • 알림 목록 조회
  • 상품 상세 정보 조회
  • 공지사항 목록 조회

이런 데이터는 매 요청마다 DB에서 조회해도 되지만, 요청이 많아지면 DB에 부담이 커진다. 특히 조회 결과가 자주 바뀌지 않는 데이터라면 매번 DB까지 갈 필요가 없다.

캐시는 이런 데이터를 메모리나 외부 저장소에 잠시 저장해두고, 다음 요청에서는 DB 대신 캐시에서 빠르게 꺼내 쓰는 방식이다.

캐시 없음:
Client -> Server -> DB -> Server -> Client

캐시 있음:
Client -> Server -> Cache -> Server -> Client

캐시를 사용하면 응답 속도가 빨라지고 DB 부하가 줄어든다. 하지만 캐시에 저장된 데이터가 원본 DB 데이터와 달라질 수 있기 때문에, 캐시를 언제 갱신하거나 삭제할지 함께 설계해야 한다.

2. Spring Cache란?

Spring Cache는 캐시 기술 자체라기보다, 캐시를 쉽게 사용할 수 있도록 도와주는 추상화 기능이다.

즉, 개발자는 @Cacheable, @CachePut, @CacheEvict 같은 어노테이션을 사용하고, 실제 캐시 저장소는 Caffeine, Redis, Ehcache 같은 구현체로 바꿀 수 있다.

@Cacheable(cacheNames = "users")
public List<UserDto> findAllUsers() {
    return userRepository.findAll().stream()
        .map(UserDto::from)
        .toList();
}

위 코드에서 메서드를 처음 호출하면 DB를 조회하고, 그 결과가 users 캐시에 저장된다. 이후 같은 조건으로 다시 호출하면 메서드 본문을 실행하지 않고 캐시된 값을 바로 반환할 수 있다.

이 구조 덕분에 비즈니스 코드는 크게 바꾸지 않으면서 캐시를 적용할 수 있다.

3. @Cacheable

@Cacheable은 “캐시에 값이 있으면 캐시에서 반환하고, 없으면 메서드를 실행한 뒤 결과를 캐시에 저장하라”는 의미이다.

가장 일반적인 조회 캐시에 사용한다.

@Cacheable(cacheNames = "users")
public List<UserDto> getUsers() {
    return userRepository.findAll().stream()
        .map(UserDto::from)
        .toList();
}

동작 흐름은 다음과 같다.

  1. 캐시에 값이 있는지 확인한다.
  2. 값이 있으면 메서드를 실행하지 않고 캐시 값을 반환한다.
  3. 값이 없으면 메서드를 실행한다.
  4. 메서드 결과를 캐시에 저장한다.
  5. 결과를 반환한다.
첫 번째 호출:
캐시 없음 -> DB 조회 -> 결과 캐시 저장 -> 응답

두 번째 호출:
캐시 있음 -> DB 조회 생략 -> 캐시 값 응답

@Cacheable은 조회 성능을 높이고 싶을 때 사용한다. 특히 같은 요청이 반복되고, 데이터 변경이 자주 일어나지 않는 경우에 적합하다.

예를 들어 다음과 같은 상황에서 사용할 수 있다.

  • 전체 사용자 목록 조회
  • 카테고리 목록 조회
  • 공통 코드 조회
  • 메인 페이지에 반복 노출되는 데이터 조회
  • 설정값 조회

단, 자주 바뀌는 데이터에 무작정 사용하면 사용자가 오래된 값을 볼 수 있다. 그래서 데이터 변경 시 @CacheEvict@CachePut으로 캐시를 함께 관리해야 한다.

4. @CachePut

@CachePut은 “메서드를 항상 실행하고, 그 결과를 캐시에 저장하라”는 의미이다.

@Cacheable과 가장 큰 차이는 메서드 실행 여부이다.

@Cacheable은 캐시에 값이 있으면 메서드를 실행하지 않는다. 반면 @CachePut은 캐시에 값이 있어도 무조건 메서드를 실행한다.

@CachePut(cacheNames = "users", key = "#userId")
public UserDto updateUser(UUID userId, UserUpdateRequest request) {
    User user = userRepository.findById(userId)
        .orElseThrow();

    user.update(request.name());
    return UserDto.from(userRepository.save(user));
}

동작 흐름은 다음과 같다.

  1. 메서드를 실행한다.
  2. DB 데이터를 변경하거나 새 결과를 만든다.
  3. 메서드 반환값을 캐시에 저장한다.
  4. 결과를 반환한다.

@CachePut은 데이터를 수정한 직후, 수정된 결과를 캐시에 바로 반영하고 싶을 때 사용한다.

예를 들어 사용자 상세 조회가 users::userId 형태로 캐시되어 있다면, 사용자를 수정한 뒤 같은 키에 수정 결과를 다시 넣어줄 수 있다.

다만 실무에서는 @CachePut보다 @CacheEvict를 더 자주 쓰는 경우도 많다. 이유는 수정된 결과 하나만 캐시에 다시 넣는 것으로 충분하지 않을 때가 많기 때문이다.

예를 들어 사용자의 이름을 수정하면 다음 캐시들이 모두 영향을 받을 수 있다.

  • 사용자 상세 캐시
  • 사용자 목록 캐시
  • 채널 멤버 목록 캐시
  • 메시지 작성자 정보가 포함된 조회 캐시

이 경우 특정 캐시 하나를 @CachePut으로 갱신하는 것보다, 관련 캐시를 지우고 다음 조회 때 다시 만들게 하는 편이 더 단순하고 안전할 수 있다.

5. @CacheEvict

@CacheEvict는 캐시에 저장된 값을 삭제하는 어노테이션이다.

@CacheEvict(cacheNames = "users", key = "#userId")
public void deleteUser(UUID userId) {
    userRepository.deleteById(userId);
}

동작 흐름은 다음과 같다.

  1. 메서드를 실행한다.
  2. 지정된 캐시 값을 삭제한다.
  3. 다음 조회 때 다시 DB를 조회하고 캐시를 만든다.

@CacheEvict는 데이터가 변경되었고, 기존 캐시가 더 이상 신뢰할 수 없을 때 사용한다.

예를 들어 다음 상황에 적합하다.

  • 사용자가 추가, 수정, 삭제되었을 때 사용자 목록 캐시 삭제
  • 채널이 추가, 수정, 삭제되었을 때 채널 목록 캐시 삭제
  • 알림을 읽음 처리하거나 삭제했을 때 알림 목록 캐시 삭제
  • 게시글이 수정되었을 때 게시글 상세 캐시 삭제

여러 캐시를 한 번에 지워야 할 수도 있다.

@Caching(evict = {
    @CacheEvict(cacheNames = "users", allEntries = true),
    @CacheEvict(cacheNames = "channels", allEntries = true)
})
public UserDto updateUserRole(UUID userId, Role role) {
    // 사용자 권한 변경
}

allEntries = true를 사용하면 해당 캐시 이름 아래의 모든 데이터를 삭제한다. 편리하지만 캐시가 한 번에 많이 비워질 수 있으므로 사용 범위를 조심해야 한다.

6. 세 어노테이션의 차이 정리

@Cacheable은 조회 성능을 높이기 위한 어노테이션이다. 캐시에 값이 있으면 메서드 실행을 건너뛰고 캐시 값을 반환한다.

@CachePut은 메서드를 항상 실행하면서 그 결과를 캐시에 저장한다. 주로 수정 결과를 캐시에 바로 반영하고 싶을 때 사용한다.

@CacheEvict는 캐시를 삭제한다. 데이터가 변경되어 기존 캐시가 오래된 값이 되었을 가능성이 있을 때 사용한다.

표로 정리하면 다음과 같다.

어노테이션 메서드 실행 여부 주요 목적 적절한 상황
@Cacheable 캐시에 값이 있으면 실행 안 함 조회 결과 재사용 반복 조회가 많고 변경이 적은 데이터
@CachePut 항상 실행 실행 결과로 캐시 갱신 수정 결과를 특정 캐시 키에 바로 반영할 때
@CacheEvict 보통 실행 후 캐시 삭제 오래된 캐시 제거 생성, 수정, 삭제 후 관련 캐시 무효화

처음 캐시를 적용할 때는 @Cacheable로 조회 캐시를 만들고, 데이터 변경 지점에서 @CacheEvict로 지우는 방식이 이해하기 쉽다. 이후 특정 키만 정확히 갱신해도 되는 구조가 보이면 @CachePut을 고려하면 된다.

7. 캐시 키를 조심해야 하는 이유

캐시는 “어떤 값을 어떤 키로 저장할 것인가”가 중요하다.

예를 들어 사용자별 알림 목록을 캐시한다면 사용자 ID가 캐시 키에 들어가야 한다.

@Cacheable(cacheNames = "notifications", key = "#userId")
public List<NotificationDto> getNotifications(UUID userId) {
    return notificationRepository.findAllByReceiverId(userId);
}

만약 사용자 ID를 키로 사용하지 않으면 A 사용자의 알림 목록이 B 사용자에게 보이는 심각한 문제가 생길 수 있다.

인증 사용자별 데이터, 권한별 데이터, 요청 조건별 데이터는 반드시 그 조건이 캐시 키에 반영되어야 한다.

8. 로컬 캐시란?

로컬 캐시는 애플리케이션 서버 내부 메모리에 저장되는 캐시이다.

대표적인 예로 Caffeine, Ehcache가 있다. Spring Boot에서는 Caffeine을 사용해 로컬 캐시를 쉽게 구성할 수 있다.

Server A
- local cache
- application

DB

로컬 캐시의 가장 큰 장점은 빠르다는 것이다. 같은 JVM 안의 메모리에서 값을 꺼내기 때문에 네트워크 통신이 필요 없다.

또한 설정이 비교적 단순하고, Redis 같은 외부 인프라를 따로 운영하지 않아도 된다.

하지만 단점도 분명하다. 서버가 여러 대가 되면 각 서버가 자기만의 캐시를 따로 가진다.

Server A cache: users = old data
Server B cache: users = new data
Server C cache: users = empty

이런 상황에서는 서버마다 다른 데이터를 보여줄 수 있다. 이를 캐시 불일치 문제라고 볼 수 있다.

9. 로컬 캐시의 장단점

로컬 캐시의 장점은 다음과 같다.

  • 매우 빠르다.
  • 외부 네트워크 호출이 없다.
  • 설정과 운영이 비교적 단순하다.
  • 외부 캐시 서버 장애의 영향을 받지 않는다.

단점은 다음과 같다.

  • 서버 인스턴스마다 캐시가 따로 존재한다.
  • 여러 서버 간 캐시 동기화가 어렵다.
  • 서버가 재시작되면 캐시가 사라진다.
  • 캐시 데이터가 많아지면 애플리케이션 메모리를 많이 사용한다.

그래서 로컬 캐시는 단일 서버이거나, 서버마다 캐시가 조금 달라도 큰 문제가 없는 데이터에 적합하다.

예를 들면 다음과 같다.

  • 공통 코드
  • 자주 바뀌지 않는 설정값
  • 짧은 시간 동안만 재사용할 조회 결과
  • 서버별로 달라도 큰 문제가 없는 데이터

10. 분산 캐시란?

분산 캐시는 애플리케이션 서버 밖에 별도의 캐시 저장소를 두고 여러 서버가 함께 사용하는 방식이다.

대표적인 예로 Redis, Memcached가 있다.

Server A -> Redis
Server B -> Redis
Server C -> Redis

Redis -> DB

서버가 여러 대여도 같은 Redis를 바라보기 때문에 캐시 데이터를 공유할 수 있다.

예를 들어 Server A에서 사용자 목록 캐시를 삭제하면, Server B도 같은 Redis를 사용하므로 삭제된 상태를 기준으로 다시 조회하게 된다.

이 방식은 여러 인스턴스로 운영되는 실무 환경에서 유용하다.

11. 분산 캐시의 장단점

분산 캐시의 장점은 다음과 같다.

  • 여러 서버가 같은 캐시 데이터를 공유할 수 있다.
  • 서버를 재시작해도 캐시가 유지될 수 있다.
  • 애플리케이션 서버 메모리 부담을 줄일 수 있다.
  • 여러 인스턴스 환경에서 캐시 일관성을 관리하기 좋다.

단점은 다음과 같다.

  • Redis 같은 외부 인프라를 운영해야 한다.
  • 네트워크 통신 비용이 있다.
  • 캐시 서버 장애에 대한 대응이 필요하다.
  • 직렬화, 역직렬화 설정이 필요하다.
  • 로컬 캐시보다 느릴 수 있다.

분산 캐시는 로컬 캐시보다 무조건 좋은 것이 아니다. 대신 서버가 여러 대이고, 여러 서버가 같은 캐시 상태를 공유해야 할 때 필요성이 커진다.

12. 로컬 캐시와 분산 캐시 비교

구분 로컬 캐시 분산 캐시
저장 위치 애플리케이션 서버 메모리 외부 캐시 서버
대표 기술 Caffeine, Ehcache Redis, Memcached
속도 매우 빠름 네트워크 비용 때문에 상대적으로 느림
서버 여러 대 지원 서버마다 캐시가 따로 있음 여러 서버가 같은 캐시 공유
운영 난이도 낮음 상대적으로 높음
장애 영향 애플리케이션 내부로 한정 캐시 서버 장애 대응 필요
적합한 상황 단일 서버, 짧은 캐시, 불일치 허용 다중 서버, 공유 캐시, 일관성 중요

13. 실무에서는 어떤 기준으로 선택할까?

캐시를 선택할 때는 먼저 “서버가 몇 대인가?”를 생각할 수 있다.

단일 서버이거나 학습용 프로젝트라면 로컬 캐시로도 충분한 경우가 많다. 설정이 단순하고 빠르기 때문이다.

하지만 서버가 여러 대라면 분산 캐시를 고려해야 한다. 특히 로그인 상태, 사용자별 데이터, 권한, 알림처럼 여러 서버가 같은 기준으로 판단해야 하는 데이터는 로컬 캐시만으로는 위험할 수 있다.

두 번째 기준은 “데이터가 조금 달라도 괜찮은가?”이다.

예를 들어 공지사항 목록이 몇 초 늦게 갱신되는 것은 허용될 수 있다. 하지만 결제 상태, 재고 수량, 권한 정보처럼 정확성이 중요한 데이터는 캐시를 더 신중하게 다뤄야 한다.

세 번째 기준은 “캐시 무효화가 단순한가?”이다.

캐시는 저장하는 것보다 지우는 것이 더 어렵다. 어떤 데이터가 바뀌었을 때 어떤 캐시를 지워야 하는지 명확하지 않다면 캐시 적용 범위를 줄이는 것이 낫다.

네 번째 기준은 “운영 복잡도를 감당할 수 있는가?”이다.

Redis를 도입하면 분산 캐시를 구성할 수 있지만, Redis 서버 운영, 장애 대응, 모니터링, 직렬화 설정 같은 추가 작업이 생긴다. 트래픽이 크지 않거나 단일 서버라면 처음부터 Redis를 도입하는 것이 과할 수도 있다.

14. 캐시를 적용할 때의 기본 원칙

캐시는 다음 순서로 생각하면 좋다.

  1. 정말 반복 조회가 많은 데이터인가?
  2. 캐시해도 되는 데이터인가?
  3. 캐시 키를 어떻게 만들 것인가?
  4. 데이터가 변경되면 어떤 캐시를 지울 것인가?
  5. 오래된 데이터가 잠깐 보여도 괜찮은가?
  6. 로컬 캐시로 충분한가, 분산 캐시가 필요한가?

처음부터 모든 조회에 캐시를 붙이는 것은 좋은 방법이 아니다. 캐시는 성능을 높여주지만, 동시에 데이터 정합성 문제를 만들 수 있기 때문이다.

그래서 실무에서는 병목이 되는 조회, 반복 호출이 많은 조회, 변경 빈도가 낮은 조회부터 캐시를 적용하는 것이 좋다.

15. 정리

@Cacheable은 조회 결과를 캐시에 저장하고 재사용할 때 사용한다. 캐시에 값이 있으면 메서드를 실행하지 않는다.

@CachePut은 메서드를 항상 실행하고 그 결과를 캐시에 다시 저장한다. 수정 결과를 특정 캐시 키에 즉시 반영하고 싶을 때 사용할 수 있다.

@CacheEvict는 캐시를 삭제한다. 데이터 생성, 수정, 삭제로 인해 기존 캐시가 오래된 값이 되었을 때 사용한다.

로컬 캐시는 애플리케이션 서버 내부 메모리를 사용하는 캐시이다. 빠르고 단순하지만 서버가 여러 대일 때 캐시 불일치가 생길 수 있다.

분산 캐시는 Redis 같은 외부 저장소를 여러 서버가 함께 사용하는 방식이다. 여러 인스턴스 환경에서 캐시를 공유할 수 있지만, 외부 인프라 운영과 네트워크 비용이 추가된다.

결국 캐시는 “빠르게 만들기 위한 기술”이면서 동시에 “언제 버릴지 정해야 하는 기술”이다. 캐시를 적용할 때는 저장보다 무효화 전략을 더 신중하게 설계해야 한다.