| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- literal
- entity
- 전역변수
- Strict
- 타입스크립트
- 10px
- ES5
- 으
- ZOOM
- Websocket
- &연산
- github
- 서버리스 #
- 컴포넌튼
- 0.25px border
- angular
- npm
- 0.5px border
- TS
- TypeScript
- Props
- 1px border
- jwt
- 당근마켓
- es6
- 데이터베이스 #try #이중
- 문서번호
- 클론코딩
- 0.75px border
- font-size
- Today
- Total
복잡한뇌구조마냥
[Java] TTL 캐시를 직접 구현하며 이해한 Redis 내부 구조 본문
Redis를 사용하다 보면 TTL, 만료 키, eviction 같은 개념을 자연스럽게 접하게 된다.
하지만 Redis가 내부에서 어떤 기준으로 키를 만료시키고, 왜 그런 구조를 선택했는지까지 깊게 생각해볼 기회는 많지 않다.
이번 글에서는 Redis의 TTL 동작 방식을 이해하기 위해
TTL(Time To Live)을 지원하는 간단한 캐시를 Java로 직접 구현해보며,
그 설계 과정과 핵심 포인트를 정리해본다.
왜 TTL 캐시를 직접 구현해봤을까?
Redis는 단순한 Key-Value 저장소가 아니라,
- 만료 시간 관리
- 메모리 제한 하에서의 eviction 정책
- 성능을 고려한 lazy expiration 전략
같은 시스템적인 선택들이 녹아 있는 저장소다.
이런 개념들을 “문서로 읽는 것”보다
직접 작은 구현을 해보는 게 훨씬 이해에 도움이 된다고 느껴,
TTL 기반 캐시를 직접 만들어보았다.
전체 구조 개요
구현한 TTL 캐시는 다음 구조를 가진다.
class TtlCache {
private final Map<String, CacheEntry> store;
private final PriorityQueue<ExpireNode> expiryQueue;
private final Clock clock;
private final int maxSize;
}
핵심 구성 요소
- HashMap
- key → CacheEntry(value, expireAt)
- 빠른 조회(O(1))
- PriorityQueue
- expireAt 기준 오름차순 정렬
- 가장 빨리 만료될 엔트리를 빠르게 찾기 위함
- Clock(TimeProvider)
- 시스템 시간 의존성 제거
- 테스트 가능성을 고려한 설계
- maxSize
- 캐시 용량 제한
- eviction 정책 구현
핵심 설계 포인트
1️⃣ 시간 의존성 분리 (Clock 추상화)
interface Clock {
long nowMillis();
}
TTL 로직은 시간에 강하게 의존한다.
하지만 System.currentTimeMillis()를 직접 사용하면:
- 테스트가 어려워지고
- 시간 흐름을 제어할 수 없게 된다.
그래서 시간 접근을 Clock 인터페이스로 분리했다.
👉 단순 캐시 구현을 넘어
테스트 가능성과 설계 품질을 함께 고려한 구조가 되었다.
2️⃣ CacheEntry – TTL의 핵심
class CacheEntry {
final String value;
final long expireAt;
CacheEntry(String value, long expireAt) {
this.value = value;
this.expireAt = expireAt;
}
}
TTL은 상대 시간(ttl) 이 아니라
절대 만료 시점(expireAt) 으로 저장한다.
왜 절대 시간인가?
- 현재 시간과 비교만 하면 만료 여부 판단 가능
- eviction 우선순위 계산이 단순해짐
Redis 역시 내부적으로 만료 시점을 기준으로 키를 관리한다.
3️⃣ 만료 우선순위 관리 – ExpireNode + PriorityQueue
class ExpireNode {
final String key;
final long expireAt;
ExpireNode(String key, long expireAt) {
this.key = key;
this.expireAt = expireAt;
}
}
private final Map<String, CacheEntry> store = new HashMap<>();
PriorityQueue<ExpireNode> expiryQueue =
new PriorityQueue<>(Comparator.comparingLong(n -> n.expireAt));
| 자료구조 | 역할 |
| HashMap | 빠른 key 조회 |
| PriorityQueue | 가장 빨리 만료될 엔트리 탐색 |
PriorityQueue는 항상 가장 빨리 만료될 엔트리를 맨 앞에 둔다.
이 덕분에:
- 다음으로 제거할 후보를 빠르게 찾을 수 있고 ( O(1)에 가깝게 )
- eviction 로직을 단순하게 유지할 수 있다.
4️⃣ Lazy Expiration 구현
private void evictExpiredEntries() {
long now = clock.nowMillis();
while (!expiryQueue.isEmpty() && expiryQueue.peek().expireAt <= now) {
ExpireNode node = expiryQueue.poll();
CacheEntry current = store.get(node.key);
// stale node 방어
if (current != null && current.expireAt == node.expireAt) {
store.remove(node.key);
}
}
}
Lazy Expiration이란?
- 만료 즉시 삭제 ❌
- 접근 시점에 정리 ⭕
이 방식의 장점은:
- 불필요한 CPU 사용 감소
- 별도 백그라운드 스레드 불필요
Redis 역시 lazy expiration + 주기적 cleanup 전략을 함께 사용한다.
5️⃣ stale node 처리의 필요성
같은 key에 대해 set이 여러 번 호출되면:
- PriorityQueue에는 이전 expireAt 노드가 남아 있음
- Map에는 최신 값만 존재
그래서 eviction 시 반드시 다음 검사가 필요하다.
if (current != null && current.expireAt == node.expireAt) {
store.remove(node.key);
}
👉 eviction 체크가 없으면 이미 갱신된 최신 데이터를 잘못 삭제하는 버그가 발생한다.
TTL 캐시 구현에서 가장 중요한 포인트 중 하나였다.
6️⃣ set 로직 – 삽입과 eviction 흐름
public void set(String key, String value, long ttlMillis) {
if (key == null || value == null) throw new IllegalArgumentException();
if (ttlMillis <= 0) throw new IllegalArgumentException();
evictExpiredEntries();
long expireAt = addSafely(clock.nowMillis(), ttlMillis);
if (!store.containsKey(key) && store.size() >= maxSize) {
evictSoonestExpiring();
}
store.put(key, new CacheEntry(value, expireAt));
expiryQueue.offer(new ExpireNode(key, expireAt));
}
흐름 요약
- 만료된 엔트리 정리
- expireAt 계산
- 용량 초과 시 eviction
- 데이터 삽입
이 eviction 방식은 Redis의volatile-ttl 정책과 유사한 형태다.
7️⃣get 로직 – 방어적 만료 체크
public Optional<String> get(String key) {
evictExpiredEntries();
CacheEntry entry = store.get(key);
if (entry == null || entry.expireAt <= clock.nowMillis()) {
store.remove(key);
return Optional.empty();
}
return Optional.of(entry.value);
}
cleanup 이후에도 한 번 더 만료를 확인한다.
이중 체크는
경계 시점(race condition)에 대한 방어 역할을 한다.
8️⃣Eviction 로직 분리
private void evictSoonestExpiring() {
while (!expiryQueue.isEmpty()) {
ExpireNode node = expiryQueue.poll();
CacheEntry current = store.get(node.key);
if (current != null && current.expireAt == node.expireAt) {
store.remove(node.key);
return;
}
}
}
- eviction 책임을 명확히 분리
- stale node는 자연스럽게 스킵
9️⃣ overflow-safe 시간 계산
private long addSafely(long a, long b) {
try {
return Math.addExact(a, b);
} catch (ArithmeticException e) {
return Long.MAX_VALUE;
}
}
TTL이 매우 큰 값일 경우를 대비해long overflow를 안전하게 처리했다.
작은 구현이지만,
실제 시스템 코드에서는 이런 방어 로직이 중요하다.
Redis와 비교해보면
| 구현 요소 | Redis |
| TTL 관리 | expire dictionary |
| 만료 방식 | lazy + periodic |
| eviction | LRU / LFU / TTL 등 |
| 자료구조 | hash + 메타 구조 |
물론 Redis는 훨씬 복잡하고 최적화되어 있지만,
핵심 아이디어는 충분히 이 작은 구현으로 이해할 수 있었다.
정리하며
TTL 캐시를 직접 구현해보며 느낀 점은,
- Redis는 단순한 Key-Value 저장소가 아니라
- 성능, 메모리, 운영을 모두 고려한 시스템이라는 점이었다.
작은 코드지만,
- TTL
- eviction
- lazy expiration
- stale 데이터 처리
같은 개념을 직접 고민해볼 수 있었고,
Redis를 사용할 때도 내부 동작을 더 명확히 이해하게 되었다.
'BE > Spring' 카테고리의 다른 글
| [Spring] SSE는 WebFlux, WebSocket은 MVC를 선택한 이유 (0) | 2026.01.15 |
|---|---|
| [Spring] Flyway DB 스키마 관리 (0) | 2025.12.07 |
| [Spring] SMTP 기반 메일 인증 ( + 비동기 처리 ) (0) | 2025.11.30 |
| [Spring] Spring Batch + Scheduler 정리 (0) | 2025.11.27 |
| [JPA] OSIV ( Open Session In View ) (0) | 2025.11.25 |