콘텐츠로 이동

Caffeine Cache: 고성능 Java 로컬 캐시 가이드

Caffeine은 Java 8 이상을 위한 고성능 캐싱 라이브러리입니다. Google Guava 캐시를 영감으로 삼아 재설계되었으며, 현재 Java 생태계에서 가장 빠르고 효율적인 로컬 캐시로 평가받습니다.


1. Caffeine Cache의 핵심 특징

  1. 압도적 성능: 인메모리 캐시 중 거의 이론적 한계에 가까운 속도를 제공합니다.
  2. W-TinyLFU 알고리즘: 기존의 LRU(Least Recently Used)나 LFU(Least Frequently Used)보다 진화된 알고리즘을 사용하여 캐시 적중률(Hit Rate)을 극대화합니다.
  3. 다양한 만료 정책: 시간 기반, 크기 기반, 참조 기반 만료를 지원합니다.
  4. 통계 수집: 캐시 적중률, 에러율 등 운영에 필요한 다양한 지표를 제공합니다.

2. 핵심 알고리즘: W-TinyLFU

대부분의 캐시는 LRU(최근에 안 쓴 것 삭제)를 사용하지만, Caffeine은 W-TinyLFU를 사용합니다.

  • TinyLFU: 적은 메모리로 각 항목의 접근 빈도를 추적합니다.
  • Window: 최근에 들어온 데이터가 바로 쫓겨나지 않도록 보호하는 구역을 둡니다.
  • 효과: 빈도가 높으면서 최근성도 유지하는 데이터 위주로 캐시를 유지하여 성능을 높입니다.

3. 기본 사용법 (Java)

3.1 의존성 추가 (Gradle)

dependencies {
    implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
}

3.2 캐시 생성 예시

Cache<String, DataObject> cache = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofMinutes(5)) // 쓰기 후 5분 뒤 만료
    .maximumSize(10_000)                    // 최대 1만 개 항목 유지
    .recordStats()                          // 통계 수집 활성화
    .build();

// 값 저장
cache.put("key1", dataObject);

// 값 조회 (없으면 null)
DataObject data = cache.getIfPresent("key1");

// 값 조회 (없으면 생성하여 반환 - 추천 방식)
DataObject data = cache.get("key2", k -> createData(k));

4. 실전 활용 예제: DB 데이터 캐싱 (Get-or-Compute)

캐시에 데이터가 있으면 반환하고, 없으면 DB에서 조회하여 캐시에 저장한 뒤 반환하는 전형적인 패턴입니다. Caffeine의 get 메서드는 이 과정을 원자적(Atomic)으로 처리해 줍니다.

4.1 구현 코드

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class UserDataService {

    // 1. 캐시 초기화 (최대 1000개, 10분 후 만료)
    private final Cache<String, List<String>> userCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();

    public List<String> select(String key) {
        // 2. get(key, mappingFunction) 호출
        // - key가 존재하면 즉시 반환
        // - key가 없으면 람다식(DB 조회) 실행 후 결과를 캐시에 넣고 반환
        return userCache.get(key, this::fetchFromDatabase);
    }

    private List<String> fetchFromDatabase(String key) {
        System.out.println("🔍 DB에서 데이터를 조회합니다: " + key);

        // 실제 DB 조회 로직 (예시 데이터)
        if ("admin".equals(key)) {
            return List.of("ROLE_ADMIN", "ROLE_USER", "DASHBOARD_ACCESS");
        }
        return List.of("ROLE_USER");
    }

    public static void main(String[] args) {
        UserDataService service = new UserDataService();

        // 첫 번째 호출: DB 조회 발생
        System.out.println("Result 1: " + service.select("admin"));

        // 두 번째 호출: 캐시 히트 (DB 조회 발생 안 함)
        System.out.println("Result 2: " + service.select("admin"));
    }
}

4.2 주요 이점

  • 원자성 보장: 동일한 키로 여러 스레드가 동시에 접근할 때, DB 조회가 중복으로 발생하지 않도록 Caffeine이 내부적으로 제어합니다.
  • 간결함: if (cache.get() == null) 같은 복잡한 분기문 없이 한 줄로 로직을 완성할 수 있습니다.

5. Spring Boot 연동

Spring Boot에서는 @Cacheable 추상화와 함께 사용하여 매우 간편하게 적용할 수 있습니다.

4.1 설정 (CacheManager)

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products");
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .maximumSize(500));
        return cacheManager;
    }
}

4.2 서비스 적용

@Service
public class UserService {
    @Cacheable(cacheNames = "users", key = "#id")
    public User getUserById(Long id) {
        // 실제 DB 조회 로직 (캐시 히트 시 실행 안 됨)
        return userRepository.findById(id).orElseThrow();
    }
}

5. 로컬 캐시 사용 시 주의사항

  • 메모리 관리: 로컬 캐시는 JVM 힙(Heap) 메모리를 사용하므로 maximumSize를 적절히 설정하여 OOM(Out Of Memory)을 방지해야 합니다.
  • 데이터 일관성: 분산 환경(여러 대의 서버)에서는 각 서버마다 캐시 내용이 다를 수 있습니다. 데이터의 엄격한 일관성이 중요하다면 Redis 같은 분산 캐시를 고려해야 합니다.

6. 결론

빠른 응답 속도가 중요하고 서버 간의 데이터 불일치가 큰 문제가 되지 않는 영역(예: 설정 정보, 빈번한 공통 코드 조회 등)에서 Caffeine은 가장 강력한 무기입니다.