[Spring] - Caffeine Cache (로컬 캐시)

CodeByHan·2025년 5월 27일

스프링

목록 보기
25/33

프로젝트때 빈번하게 조회되는 데이터들에게 캐시를 적용해보고 싶었다. 솔직히 많이 쓰는 캐시는 Redis겠지만 스프링에서 제공해주는 Caffeine Cache에 흥미가 생겼다. 내가 커피를 좋아하기때문에 끌린걸까?

캐시(Cache)

간단하게 캐시에 대해 설명하면 좋을 것 같다. 캐시(Cache)란 자주 사용하는 데이터나 값을 미리 복사해 놓는 임시 저장소를 말한다. 저장 공간이 작고 비용이 비싸지만, 빠른 성능을 제공한다. 캐시는 크게 로컬 캐시(애플리케이션 내부 메모리)와 여러 서버가 공유하는 분산 캐시로 나뉜다.

🤔 캐시는 언제 사용하는게 좋을까?

  • 자주 조회되는 데이터
  • 입력값과 출력값이 일정한 데이터

캐싱된 데이터는 데이터 갱신으로 인해 DB 불일치가 발생할 가능성이 있다. 따라서 Update가 자주 일어나면 캐싱 대상으로 적절하지 않을 수 있다.

분산 캐시(Global Cache)

우리가 자주 사용하는 대부분의 서비스는 사용자 수가 많고 트래픽이 높기 때문에, 하나의 서버만으로 감당하기 어려운 경우가 많다. 이럴 때는 Scale-Out 방식으로 서버를 확장한다. 즉, 동일한 애플리케이션을 여러 대의 서버에서 동시에 운영하는 구조로, 트래픽을 여러 서버에 분산시켜 처리 효율을 높인다.

그런데 이처럼 여러 대의 서버를 운영하면서 각 서버가 로컬 캐시(서버 메모리 내에만 저장된 데이터)를 사용하게 되면, 문제가 발생할 수 있다. 예를 들어, A 서버에서 데이터가 변경되었지만 B 서버는 여전히 이전 데이터를 캐시에 가지고 있는 상황이 생길 수 있다. 이렇게 되면 서버마다 서로 다른 데이터를 참조하게 되어 정합성 문제가 발생하고, 사용자는 오류가 발생한 것처럼 보이거나 일관되지 않은 정보를 보게 될 수 있다.

따라서 이 문제를 해결하기 위해 보통 Redis와 같은 캐시 서버(분산 캐시)를 사용한다. 이렇게 하면 모든 서버가 공통된 캐시 저장소를 사용하게 되어 데이터 일관성을 유지할 수 있다.

로컬 캐시(Local Cache)

🤔 그러면 분산 캐시(Global Cache)를 사용하는게 더 안전하지 않을까? 라고 많이 생각한다. 하지만 로컬 캐시(Local Cache) 만의 장점도 분명히 존재한다.

Spring에서는 애플리케이션이 실행될 때 JVM 메모리, 즉 서버의 힙 메모리를 활용하여 데이터를 캐싱할 수 있다. 이를 로컬 캐시(Local Cache)라고 한다.

이 방식의 가장 큰 장점은 DB외부 저장소에 접근하지 않고도 필요한 데이터를 즉시 가져올 수 있다는 점이다.

DB에 접근하는 경우에는 네트워크를 통해 요청을 보내고, 쿼리를 실행한 후 응답을 받아야 하므로 상대적으로 시간이 많이 걸린다.

반면, 로컬 캐시는 네트워크를 거치지 않고 메모리에서 직접 읽기 때문에 매우 빠른 속도를 자랑한다.

예를 들어, 자주 조회되는 사용자 설정 값이나 카테고리 목록 같은 데이터를 로컬 캐시에 저장해두면, DB에 요청하지 않고도 빠르게 응답할 수 있어 서비스의 전체적인 응답 속도와 처리 성능이 향상된다.

또한, 애플리케이션 내부에서 관리되기 때문에 설정이 간단하고 별도의 캐시 서버 없이도 사용할 수 있다는 점도 장점이다. 개발 초기나 소규모 서비스에서는 이런 단순한 구조의 로컬 캐시가 매우 유용하다. Spring에서는 로컬 캐시(Local Cache)카페인(Caffeine) 또는 Ehache를 많이 사용한다.

🤔 하나의 캐시만 사용하는 경우, 예를 들어 Redis는 네트워크 트래픽이 늘어나고, Caffeine은 애플리케이션 메모리를 많이 사용하는 등의 단점이 존재한다는 것을 알고있다. 하지만 이러한 문제를 해결하기 위해 1차 캐시로 로컬 캐시를 사용하고 2차 캐시로 Redis를 함께 사용하는 멀티 레벨 캐싱 전략(Multi-level Caching)도 존재한다.

카페인(Caffeine) ❓

카페인(Caffeine) 이라는 말을 처음 들었을 때, 무슨 커피도 아니고 내가 또 카페인을 좋아하긴 하지만 이걸 스프링에서 써? 라고 의아해했다. 아마 이걸 처음 들어보는 개발자들도 나랑 똑같은 생각을 할 것이다.

카페인(Caffeine)의 특징을 말하자면, local cache King 이라고 표현 하는 곳도 존재한다고 한다. Google 오픈 소스를 기반으로 탄생하였고 다양한 기능이 존재한다.

구조

구조가 상당히 복잡해 보이는데 간단하게 살펴보면

캐시 영역 (ConcurrentHashMap 기반)

  • Window Cache: 새로운 항목이 처음 저장되는 공간으로, LRU 정책을 사용하여 관리

  • Probation Cache: Window Cache에서 이동된 항목들이 저장되며, 사용 빈도에 따라 승격 여부가 결정

  • Protected Cache: 자주 사용되는 항목들이 저장되는 공간으로, 캐시에서 가장 오래 유지

  • ConcurrentHashMap : 실제 캐시 데이터를 저장하는 동시성 해시맵

Caffeine Cache란 무엇이고 왜 빠르게 동작하는가?
이 분이 진짜 구조에 대해 잘 정리해주신 것 같다. 많이 도움이 된 글이다.

Spring 에서의 사용

스프링에서 간단하게 사용하는 방법을 일단 알아보자! 일단 Spring 에서 사용하기 위해서는 의존성을 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

크게 이거는 설명 할 것이 없을거 같고 간단하게 코드로 살펴보면

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
            .maximumSize(1000) // 엔티티수 
            .expireAfterWrite(10, TimeUnit.MINUTES) // TTL 10분후 만료
            .recordStats();

        CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products"); // 2개의 캐시 이름
        cacheManager.setCaffeine(caffeine);
        return cacheManager;
    }
}

카페인(Caffeine)는 이렇게 최대 엔티티수와 TTL을 적용할 수있다. 그리고 캐시 이름을 지정하여 이후 서비스에서 @Cacheable("users"), @Cacheable("products") 등을 사용 가능하다. 예를 들어 조회에서 캐시를 적용하고 싶다면

@Cacheable(value = "users", key = "'user:' + #userId")
public User getUserById(Long userId) {
     return userRepository.findById(userId)
              .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
    }

이렇게 DB 조회가 필요한 경우에 userId를 기준으로 캐시 처리를 할 수있다. 그리고 만약 수정, 삭제 , 등록이 발생하여 데이터가 변경되면 캐시에 저장된 데이터를 무효화(삭제) 해야한다. 그럴 경우 간단한 방법으로는 @CacheEvict 어노테이션을 적용하면 된다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 캐시 저장
    @Cacheable(value = "users", key = "'user:' + #userId")
    public User getUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
    }

    // 캐시 제거 (무효화)
    @CacheEvict(value = "users", key = "'user:' + #userId")
    public void updateUser(Long userId, UserUpdateDto dto) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        user.setName(dto.getName());
        userRepository.save(user); // 저장
    }

    // 삭제할 때도 캐시 제거
    @CacheEvict(value = "users", key = "'user:' + #userId")
    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
    }
}

이 방법들은 특정 키를 삭제할 수있지만 만약 캐시의 모든 항목을 제거 하고 싶다면 allEntries = true을 사용하면 된다.

@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
    // 
}

물론 @CacheEvict 을 사용하지 않고 캐시 키의 접두사(prefix) 를 기준으로 직접 코드로 캐시를 제어하는 동적(dynamically controlled) 방식도 존재한다. 왜냐하면 @CacheEvict는 단일 키만 제거 가능하고 여러 키가 연관된 데이터일 때, 부분 캐시 무효화가 필요하다 예를 들어 팀 정보를 변경하면 "team:1", "team:1:members", "team:1:expenses" 등 다양한 캐시를 비워야 하는 경우가 있는데 이럴 때 team:1 이라는 접두사를 기준으로 전부 제거하는 방식이 필요한 경우도 존재한다.

코드 예시

@Service
@Slf4j
@RequiredArgsConstructor
public class CacheEvictService {

  
  private final CacheManager cacheManager;

  
  public void evictByPrefix(String cacheName, String prefix) {
    // 캐시 이름으로 캐시 가져오기
    Cache cache = cacheManager.getCache(cacheName);
    if (cache == null) {
      log.warn("캐시 '{}'를 찾을 수 없습니다. 무효화 작업을 건너뜁니다.", cacheName);
      return;
    }

    // Caffeine 캐시일 경우에만 내부 nativeCache 접근 가능
    if (cache instanceof CaffeineCache caffeineCache) {
      com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
          caffeineCache.getNativeCache();

      // prefix로 시작하는 키들 필터링
      List<String> keysToRemove = nativeCache.asMap().keySet().stream()
          .map(Object::toString)
          .filter(key -> key.startsWith(prefix))
          .toList();

      // 해당 키들 무효화
      keysToRemove.forEach(nativeCache::invalidate);

      log.info("Caffeine 캐시 '{}'에서 '{}'로 시작하는 {}건 무효화 완료.",
          cacheName, prefix, keysToRemove.size());
      return;
    }

    // Caffeine이 아닌 경우 전체 캐시 삭제
    cache.clear();
    log.info("Caffeine 외 캐시 구현체라 전체 clear() 호출 – 네임스페이스='{}'", cacheName);
  }
}

카페인(Caffeine) 캐시 정리

항목설명
성능매우 빠른 읽기/쓰기 성능 제공
메모리 관리다양한 제거 전략으로 효율적인 메모리 사용
동시성다중 스레드 환경에서도 안전한 캐시 접근 지원
구성 유연성다양한 설정 옵션으로 요구사항에 맞는 캐시 구성 가능
분산 캐시미지원 (다중 인스턴스 환경에서는 별도의 분산 캐시 솔루션 필요)
메모리 의존성JVM 메모리를 사용하므로 적절한 캐시 크기 설정 필요
무효화 관리복잡한 무효화 로직이 필요한 경우 별도 구현 필요

참고 :
분산 시스템에서 로컬 캐시 활용하기
카페인 캐시 공식문서

profile
노력은 배신하지 않아 🔥

0개의 댓글