Caffeine이란
Caffeine은 Java에서 가장 빠른 로컬 캐시 라이브러리다. Google Guava Cache의 후속작으로, 더 높은 적중률과 더 나은 성능을 제공한다. Spring Boot에서 로컬 캐시를 쓴다면 사실상 Caffeine이 표준이다.
왜 Caffeine이 빠른가? 내부적으로 W-TinyLFU라는 제거 알고리즘을 사용한다. 이건 LRU(최근 안 쓴 것 제거)와 LFU(적게 쓴 것 제거)의 장점을 결합한 것으로, 대부분의 워크로드에서 다른 캐시 대비 높은 적중률을 보인다. 별도 튜닝 없이도 좋은 성능을 낸다.
@Cacheable이 호출되면 요청이 어떤 레이어를 거쳐 Caffeine까지 도달하는지 흐름을 보면 이해가 쉽다.
어노테이션"] CM["CacheManager
(인터페이스)"] SCM["SimpleCacheManager
(구현체)"] end subgraph Adapter ["어댑터 영역"] CC["CaffeineCache
(Spring Cache 어댑터)"] end subgraph Native ["Caffeine 라이브러리"] Caf["Caffeine
(실제 로컬 캐시 엔진)"] end Ann -->|캐시 요청 위임| CM CM -.->|구현체 사용| SCM SCM -->|여러 캐시 인스턴스 관리| CC CC -->|실제 데이터 저장/조회| Caf style Ann fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style CM fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style SCM fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style CC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Caf fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
CacheManager: Spring Cache 추상화의 핵심 인터페이스.@Cacheable이 이걸 통해 캐시에 접근한다.SimpleCacheManager:CaffeineCache목록을 직접 등록해서 관리하는 구현체.CaffeineCache: Spring의Cache인터페이스를 Caffeine으로 감싼 어댑터.Caffeine: 실제 캐시 인스턴스를 빌드하는 빌더. 여기서 TTL, 크기, 통계 등을 설정한다.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
spring-boot-starter-cache: Spring Cache 추상화 (@Cacheable등)caffeine: 실제 캐시 구현체
Spring Boot는 classpath에 Caffeine이 있으면 자동으로 CaffeineCacheManager를 구성한다. 별도 설정 없이도 @Cacheable이 Caffeine 캐시를 사용한다.
application.yml로 설정하기
가장 간단한 설정 방법이다.
spring:
cache:
type: caffeine
cache-names:
- users
- channels
- notifications
caffeine:
spec: >
maximumSize=100,
expireAfterAccess=600s
| 설정 | 의미 |
|---|---|
type: caffeine | 캐시 구현체로 Caffeine을 사용 |
cache-names | 사용할 캐시 이름 목록. @Cacheable("users")의 "users"와 일치해야 함 |
caffeine.spec | Caffeine 설정을 문자열로 지정 |
spec에서 사용할 수 있는 주요 옵션은 다음과 같다.
| spec 옵션 | 의미 | 예시 |
|---|---|---|
maximumSize | 캐시 항목 최대 개수 | maximumSize=100 |
expireAfterWrite | 저장 후 일정 시간이 지나면 만료 | expireAfterWrite=300s |
expireAfterAccess | 마지막 접근 후 일정 시간이 지나면 만료 | expireAfterAccess=600s |
recordStats | 통계 수집 활성화 (Actuator 연동) | recordStats |
- expireAfterWrite : 데이터가 캐시에 저장된 시점부터 카운트. 자주 읽혀도 TTL이 지나면 만료.
- expireAfterAccess : 마지막으로 읽히거나 쓴 시점부터 카운트. 계속 읽히면 영원히 안 만료.
데이터 신선도가 중요하면 expireAfterWrite를 써야 한다. expireAfterAccess는 자주 조회되는 데이터가 오래된 채로 유지될 수 있다.
두 정책이 캐시 항목의 생명주기에 어떻게 다르게 작용하는지 나란히 놓으면 차이가 명확하다.
타이머 유지| Valid1 Valid1 -->|지정된 시간 경과| Expired1["만료됨 (Expired)"] end EAW ~~~ EAA subgraph EAA ["expireAfterAccess (최근 접근 기준 TTL)"] direction LR Start2((시작)) -->|데이터 저장| Valid2["유효 상태"] Valid2 -->|조회/읽기 발생
★ 타이머 초기화!| Valid2 Valid2 -->|지정된 시간 동안 미조회| Expired2["만료됨 (Expired)"] end style Valid1 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Valid2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style Expired1 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Expired2 fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style EAW fill:#FFFFFF,stroke:#CCC,stroke-dasharray: 5 5,color:#000 style EAA fill:#FFFFFF,stroke:#CCC,stroke-dasharray: 5 5,color:#000
expireAfterWrite: 읽기가 발생해도 타이머는 저장 시점에 고정. 신선도 보장.expireAfterAccess: 읽을 때마다 타이머 리셋. 계속 읽히면 영원히 유지될 수 있다.
Java Config로 설정하기
캐시마다 다른 설정을 적용하고 싶으면 Java Config가 필요하다. application.yml의 spec은 모든 캐시에 동일하게 적용되기 때문이다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(List.of(
buildCache("users", 200, 600),
buildCache("channels", 500, 300),
buildCache("notifications", 100, 60)
));
return cacheManager;
}
private CaffeineCache buildCache(String name, int maxSize, int ttlSeconds) {
return new CaffeineCache(name,
Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(ttlSeconds, TimeUnit.SECONDS)
.recordStats()
.build()
);
}
}
이제 각 캐시가 독립적인 설정을 가진다.
| 캐시 | 최대 크기 | TTL | 이유 |
|---|---|---|---|
users | 200 | 10분 | 사용자는 자주 안 바뀜. 오래 유지해도 됨 |
channels | 500 | 5분 | 채널 목록은 적당히 자주 바뀜 |
notifications | 100 | 1분 | 알림은 실시간성이 중요. 짧게 유지 |
- 모든 캐시에 같은 설정 → application.yml이 간단
- 캐시마다 다른 설정 → Java Config 필수
- 둘 다 있으면 Java Config가 우선
TTL과 최대 크기 전략
캐시 설정에서 가장 중요한 두 가지 결정은 "얼마나 오래 유지할 것인가(TTL)"와 "몇 개까지 저장할 것인가(maximumSize)"다.
TTL 결정 기준
데이터가 오래돼도 괜찮은 정도로 TTL을 정한다.
- "사용자 목록이 10분 동안 안 바뀌어도 괜찮은가?" → 괜찮다면 TTL 10분
- "알림이 1분 동안 안 바뀌어도 괜찮은가?" → 좀 아쉽지만 허용 가능하면 TTL 1분
- "결제 잔액이 1초라도 틀리면 안 되는가?" → 캐시 X
TTL이 너무 짧으면 캐시의 의미가 없고 (적중률 낮음), 너무 길면 오래된 데이터를 보여줄 위험이 있다.
maximumSize 결정 기준
JVM 힙 메모리와 데이터 크기를 고려한다. List<UserDto> 100개가 각각 1KB면, 캐시 100항목은 ~100KB를 차지한다. JVM 힙이 512MB면 여유가 넉넉하지만, 캐시에 이미지 바이너리 같은 큰 데이터를 넣으면 금방 메모리가 부족해진다.
maximumSize를 초과하면 Caffeine이 W-TinyLFU 알고리즘에 따라 가장 덜 유용한 항목을 자동으로 제거한다. 개발자가 직접 제거 대상을 선정할 필요가 없다.
정확한 값을 처음부터 알기 어렵다. maximumSize=500 정도로 시작하고, 5. Actuator로 캐시 모니터링에서 다루는 통계를 보면서 적중률과 제거 빈도를 확인한 뒤 조절하면 된다.
@CacheEvict 적용 패턴
캐시를 적용했으면, 데이터가 변경되는 모든 곳에 @CacheEvict를 빠짐없이 넣어야 한다. 이 시리즈에서 계속 강조한 점이다.
Discordit 미션을 예로 들면 이렇다.
// 조회 — 캐시 적용
@Cacheable("channels")
public List<ChannelDto> getChannelsByUserId(UUID userId) { ... }
// 변경 — 캐시 무효화
@CacheEvict(value = "channels", allEntries = true)
public ChannelDto createChannel(ChannelCreateRequest request) { ... }
@CacheEvict(value = "channels", allEntries = true)
public ChannelDto updateChannel(UUID channelId, ChannelUpdateRequest request) { ... }
@CacheEvict(value = "channels", allEntries = true)
public void deleteChannel(UUID channelId) { ... }
allEntries = true를 쓰는 이유는, 채널이 추가되면 어떤 사용자의 목록에 영향을 주는지 모르기 때문이다. 특정 키만 삭제하려면 영향을 받는 모든 사용자의 키를 알아야 하는데, 이는 복잡하다. allEntries = true가 더 안전하다.
Q&A
캐시가 하나이거나 모든 캐시에 같은 설정을 적용할 때는 application.yml이 더 간결하다. 캐시마다 TTL이나 maximumSize를 다르게 가져가야 한다면 Java Config가 필수다. 실무에서는 캐시별로 다른 전략이 필요한 경우가 대부분이라 Java Config를 주로 쓴다. 둘 다 있으면 Java Config가 우선된다.
[!QUESTION] maximumSize를 초과하면 어떻게 되나요?
Caffeine이 W-TinyLFU 알고리즘으로 가장 덜 유용한 항목을 자동으로 제거한다. 개발자가 직접 제거 대상을 선정할 필요가 없다. 단, 제거가 빈번하면 적중률이 낮아진다. 5. Actuator로 캐시 모니터링에서 cache.evictions 메트릭을 확인해 maximumSize 조정 여부를 판단하면 된다.
[!QUESTION] recordStats를 켜면 성능이 떨어지나요?
통계 수집이 약간의 오버헤드를 추가하는 건 맞다. 그러나 대부분의 애플리케이션에서 체감할 수 있는 수준이 아니다. 개발/스테이징 환경에서는 항상 켜두고 모니터링하는 것이 좋다. 극단적인 성능이 필요한 운영 환경에서만 선택적으로 끄면 된다.
[!QUESTION] expireAfterWrite와 expireAfterAccess를 동시에 설정하면?
동시에 설정할 수 없다. 둘 중 하나만 사용해야 한다. 설정 파일에서 spec에 둘 다 넣으면 마지막에 설정된 것이 적용된다. Java Config에서 빌더 메서드를 둘 다 호출하면 IllegalStateException이 발생한다. refreshAfterWrite와는 함께 쓸 수 있다.