최근 LRU(Least Recently Used) 캐시를 구현하면서 캐시 관리의 세계에 본격적으로 발을 들이게 되었습니다. 성능 면에서 꽤 잘 작동했지만, 문득 이런 생각이 들었습니다. “더 빠르고 효율적인 캐시 제거(eviction) 알고리즘은 없을까?”
이 호기심은 저를 다양한 캐시 전략을 파고드는 리서치의 길로 이끌었고, 그 과정에서 눈에 띈 것이 바로 SIEVE 캐시였습니다.
전통적인 캐시 시스템이 LRU, LFU 같은 일반적인 정책을 기반으로 동작하는 반면, SIEVE 캐시는 필터링 규칙에 기반한 선택적이고 적응형 캐싱 전략에 초점을 둡니다. 사용 사례에 맞춰 정교하게 조정 가능한 구조라는 점이 특히 흥미로웠습니다.
📦 LRU(Least Recently Used) 캐시란?
LRU는 가장 오랫동안 사용되지 않은 데이터를 우선 제거하는 캐시 알고리즘입니다. 최근에 사용된 데이터는 캐시에 남기고, 사용되지 않은 데이터부터 제거하여 메모리를 확보합니다.
🔧 구현 방식
- 해시맵 + 이중 연결 리스트 조합이 일반적
- O(1) 시간 복잡도로 삽입, 조회, 삭제 가능
📈 장점
- 최근 사용 데이터를 효과적으로 유지
- 구현이 비교적 단순함
⚠️ 한계
- 접근 빈도가 아닌 사용 시간 순서만 고려됨
- 빈번히 사용되지만 최근에 접근하지 않은 데이터도 제거될 수 있음
🛠 사용 예시
- 웹 브라우저 방문 기록
- 메모리 기반 세션 관리
- DB 쿼리 결과 캐싱 등
Sieve 캐시는 자주 접근되는 콘텐츠를 더욱 효율적이고 선택적으로 제공하기 위해 설계된 캐싱 메커니즘입니다.
이 개념은 주로 특정 기준에 따라 데이터를 선별(filtering) 하거나 분할(partitioning) 해야 하는 시스템에서 사용됩니다. 이름처럼, 체(sieve)를 통해 불필요한 데이터를 거르고 유의미한 데이터만 통과시키는 방식에서 유래했습니다.
즉, 단순히 오래되었거나 적게 사용된 데이터를 제거하는 것이 아니라, 사용 목적과 조건에 따라 필요한 데이터만 선별적으로 캐시에 담는 전략입니다.
Sieve 캐시의 핵심 로직은 무엇을 캐싱할지 결정하는 사용자 정의 필터링 규칙에 기반합니다.
이번 구현에서는 Go의 sync.Map을 사용해 동시성-safe한 캐시 저장소를 구성하고,
"이 데이터를 캐시에 담을지 말지"를 판단하는 커스터마이징 가능한 필터 함수를 정의할 예정입니다.
즉, 단순한 키-값 저장소가 아닌, 입력 조건에 따라 데이터를 선별적으로 수용하는 지능형 캐시 구조를 만드는 것입니다.
아래는 Go로 구현한 Sieve 캐시의 예제 코드입니다. 이 캐시는 sync.Map을 기반으로 하고 있으며, 각 항목의 TTL(만료 시간)과 사용자 정의 필터 함수를 통해 선택적 캐싱을 구현합니다.
type CacheItem struct {
Value interface{}
Expiration int64
}
각 캐시 항목은 값(Value)과 만료 시간(Expiration)을 포함하는 구조체입니다. 만료 시간은 유닉스 타임스탬프로 계산되며, TTL(Time-To-Live) 기반입니다.
type SieveCache struct {
store sync.Map
ttl time.Duration
sieveFilter func(key string, value interface{}) bool
cleanupTicker *time.Ticker
stopCleanup chan struct{}
}
sync.Map: 고루틴-safe한 캐시 저장소ttl: 기본 TTL 설정값sieveFilter: 필터링 조건을 동적으로 지정 가능cleanupTicker: 주기적인 만료 데이터 정리를 위한 타이머stopCleanup: 정리 루틴 종료 신호 채널func (c *SieveCache) Set(key string, value interface{}) {
if c.sieveFilter(key, value) {
c.store.Store(key, CacheItem{
Value: value,
Expiration: time.Now().Add(c.ttl).Unix(),
})
}
}
func (c *SieveCache) Get(key string) (interface{}, bool) {
...
}
false를 반환합니다.func (c *SieveCache) cleanupExpiredItems() {
...
}
filter := func(key string, value interface{}) bool {
str, ok := value.(string)
return ok && len(str) > 3
}
위 예제에서는 문자열 길이가 3자를 초과할 경우에만 캐시하도록 필터를 정의했습니다.
cache.Set("key1", "short") // 캐싱 안됨
cache.Set("key2", "longerString") // 캐싱됨
이후, key2 항목은 10초 후 만료되며, 조회 시 사라집니다.
package main
import (
"fmt"
"sync"
"time"
)
// CacheItem represents an individual item in the cache
type CacheItem struct {
Value interface{}
Expiration int64 // Unix timestamp for expiration
}
// SieveCache represents the sieve cache
type SieveCache struct {
store sync.Map
ttl time.Duration
sieveFilter func(key string, value interface{}) bool // Custom filter logic
cleanupTicker *time.Ticker
stopCleanup chan struct{}
}
// NewSieveCache creates a new instance of SieveCache
func NewSieveCache(ttl time.Duration, sieveFilter func(key string, value interface{}) bool) *SieveCache {
cache := &SieveCache{
ttl: ttl,
sieveFilter: sieveFilter,
stopCleanup: make(chan struct{}),
}
cache.cleanupTicker = time.NewTicker(ttl / 2) // Cleanup runs at half the TTL interval
go cache.cleanupExpiredItems()
return cache
}
// Set adds a new item to the cache if it passes the sieve filter
func (c *SieveCache) Set(key string, value interface{}) {
if c.sieveFilter(key, value) {
c.store.Store(key, CacheItem{
Value: value,
Expiration: time.Now().Add(c.ttl).Unix(),
})
}
}
// Get retrieves an item from the cache if it exists and is not expired
func (c *SieveCache) Get(key string) (interface{}, bool) {
item, exists := c.store.Load(key)
if !exists {
return nil, false
}
cacheItem := item.(CacheItem)
if time.Now().Unix() > cacheItem.Expiration {
c.store.Delete(key)
return nil, false
}
return cacheItem.Value, true
}
// Delete removes an item from the cache
func (c *SieveCache) Delete(key string) {
c.store.Delete(key)
}
// cleanupExpiredItems removes expired items from the cache periodically
func (c *SieveCache) cleanupExpiredItems() {
for {
select {
case <-c.cleanupTicker.C:
c.store.Range(func(key, value interface{}) bool {
cacheItem := value.(CacheItem)
if time.Now().Unix() > cacheItem.Expiration {
c.store.Delete(key)
}
return true
})
case <-c.stopCleanup:
return
}
}
}
// Stop stops the cleanup goroutine
func (c *SieveCache) Stop() {
close(c.stopCleanup)
c.cleanupTicker.Stop()
}
func main() {
// Custom sieve filter: only cache strings with length > 3
filter := func(key string, value interface{}) bool {
str, ok := value.(string)
return ok && len(str) > 3
}
// Create a sieve cache with a 10-second TTL
cache := NewSieveCache(10*time.Second, filter)
defer cache.Stop()
// Add items to the cache
cache.Set("key1", "short") // Won't be cached (length <= 3)
cache.Set("key2", "longerString") // Will be cached
// Retrieve items from the cache
if value, ok := cache.Get("key2"); ok {
fmt.Println("Found key2:", value)
} else {
fmt.Println("key2 not found")
}
// Wait for 11 seconds to test expiration
time.Sleep(11 * time.Second)
if _, ok := cache.Get("key2"); !ok {
fmt.Println("key2 expired")
}
}import time
import threading
class CacheItem:
def __init__(self, value, expiration):
self.value = value
self.expiration = expiration
class SieveCache:
def __init__(self, ttl_seconds, sieve_filter):
self.ttl = ttl_seconds
self.sieve_filter = sieve_filter # 사용자 정의 필터 함수
self.store = {}
self.lock = threading.Lock()
self.cleanup_interval = ttl_seconds / 2
self.stop_event = threading.Event()
self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
self.cleanup_thread.start()
def set(self, key, value):
if self.sieve_filter(key, value):
with self.lock:
expiration = time.time() + self.ttl
self.store[key] = CacheItem(value, expiration)
def get(self, key):
with self.lock:
item = self.store.get(key)
if not item:
return None
if time.time() > item.expiration:
del self.store[key]
return None
return item.value
def delete(self, key):
with self.lock:
if key in self.store:
del self.store[key]
def stop(self):
self.stop_event.set()
self.cleanup_thread.join()
def _cleanup_loop(self):
while not self.stop_event.is_set():
time.sleep(self.cleanup_interval)
now = time.time()
with self.lock:
keys_to_delete = [key for key, item in self.store.items() if now > item.expiration]
for key in keys_to_delete:
del self.store[key]
# --- 사용 예시 ---
def custom_filter(key, value):
return isinstance(value, str) and len(value) > 3
cache = SieveCache(ttl_seconds=10, sieve_filter=custom_filter)
cache.set("key1", "hi") # 캐싱 안 됨 (길이 <= 3)
cache.set("key2", "longerData") # 캐싱 됨
val = cache.get("key2")
print("key2 found:", val) if val else print("key2 not found")
time.sleep(11)
val = cache.get("key2")
print("key2 expired") if not val else print("key2 still valid")
cache.stop()
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.BiPredicate;
public class SieveCache<K, V> {
private static class CacheItem<V> {
final V value;
final long expiration; // Unix timestamp in milliseconds
CacheItem(V value, long expiration) {
this.value = value;
this.expiration = expiration;
}
}
private final ConcurrentHashMap<K, CacheItem<V>> store = new ConcurrentHashMap<>();
private final long ttlMillis;
private final BiPredicate<K, V> sieveFilter;
private final ScheduledExecutorService scheduler;
public SieveCache(long ttlSeconds, BiPredicate<K, V> sieveFilter) {
this.ttlMillis = ttlSeconds * 1000;
this.sieveFilter = sieveFilter;
this.scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(this::cleanupExpiredItems, ttlMillis / 2, ttlMillis / 2, TimeUnit.MILLISECONDS);
}
public void set(K key, V value) {
if (sieveFilter.test(key, value)) {
long expiration = System.currentTimeMillis() + ttlMillis;
store.put(key, new CacheItem<>(value, expiration));
}
}
public V get(K key) {
CacheItem<V> item = store.get(key);
if (item == null) return null;
if (System.currentTimeMillis() > item.expiration) {
store.remove(key);
return null;
}
return item.value;
}
public void delete(K key) {
store.remove(key);
}
private void cleanupExpiredItems() {
long now = System.currentTimeMillis();
for (Map.Entry<K, CacheItem<V>> entry : store.entrySet()) {
if (now > entry.getValue().expiration) {
store.remove(entry.getKey());
}
}
}
public void stop() {
scheduler.shutdownNow();
}
// --- 테스트용 main ---
public static void main(String[] args) throws InterruptedException {
SieveCache<String, String> cache = new SieveCache<>(10, (key, value) -> value.length() > 3);
cache.set("key1", "hi"); // 캐싱 안 됨
cache.set("key2", "longerData"); // 캐싱 됨
String val = cache.get("key2");
System.out.println(val != null ? "key2 found: " + val : "key2 not found");
Thread.sleep(11_000);
val = cache.get("key2");
System.out.println(val != null ? "key2 still valid" : "key2 expired");
cache.stop();
}
}
type CacheItem<T> = {
value: T;
expiration: number; // Unix timestamp (ms)
};
export class SieveCache<T> {
private store: Map<string, CacheItem<T>> = new Map();
private ttl: number;
private sieveFilter: (key: string, value: T) => boolean;
private cleanupInterval: NodeJS.Timer;
constructor(ttlSeconds: number, sieveFilter: (key: string, value: T) => boolean) {
this.ttl = ttlSeconds * 1000;
this.sieveFilter = sieveFilter;
// 주기적 만료 정리 (TTL의 절반 주기)
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, item] of this.store.entries()) {
if (now > item.expiration) {
this.store.delete(key);
}
}
}, this.ttl / 2);
}
set(key: string, value: T): void {
if (this.sieveFilter(key, value)) {
const expiration = Date.now() + this.ttl;
this.store.set(key, { value, expiration });
}
}
get(key: string): T | undefined {
const item = this.store.get(key);
if (!item) return undefined;
if (Date.now() > item.expiration) {
this.store.delete(key);
return undefined;
}
return item.value;
}
delete(key: string): void {
this.store.delete(key);
}
stop(): void {
clearInterval(this.cleanupInterval);
}
}
💭 개인적인 고민 – Sieve 캐시와 Redis를 함께 활용할 수 있을까?
이 부분은 블로그 본문과는 별도로, 제가 실제로 고민했던 지점입니다.
Sieve 캐시는 애플리케이션 레벨에서 특정 데이터를 선별적으로 캐싱하는 구조이고,
Redis는 TTL 기반의 고성능 분산 캐시로 많이 사용됩니다.
그렇다면, Sieve 캐시의 로직은 앱에서 처리하고, Redis를 캐시 저장소로 활용할 수 있지 않을까?
이런 구조라면 캐싱 대상도 줄이고, TTL 관리도 Redis에 위임할 수 있어 유지 보수도 쉬워집니다.
나아가 멀티 노드 환경에서는 Redis를 통해 선별된 데이터를 공유하는 다단계 캐시 구조도 가능하겠다는 생각이 들었습니다.
이 아이디어는 아직 실험 단계지만, 실제로 적용해본다면 추후 별도 글로도 정리해보고 싶습니다.