복잡한뇌구조마냥

[Java] TTL 캐시를 직접 구현하며 이해한 Redis 내부 구조 본문

BE/Spring

[Java] TTL 캐시를 직접 구현하며 이해한 Redis 내부 구조

지금해냥 2025. 12. 26. 23:36

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));
}

흐름 요약

  1. 만료된 엔트리 정리
  2. expireAt 계산
  3. 용량 초과 시 eviction
  4. 데이터 삽입

이 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를 사용할 때도 내부 동작을 더 명확히 이해하게 되었다.

LIST