들어가며

같은 SELECT를 1초에 100번 날리면 DB가 100번 일한다. 프록시 단에서 결과를 캐싱하면 DB 부하를 크게 줄일 수 있다.

이전 글에서 쿼리를 Writer/Reader로 분산하는 라우팅을 구현했다. 이번에는 한 단계 더 나아가서, 동일한 SELECT 결과를 캐싱하여 DB를 아예 거치지 않도록 만든다.

LRU 캐시 설계

왜 LRU인가

캐시 메모리는 유한하다. 가득 차면 뭘 버릴지 정해야 한다:

  • FIFO: 가장 먼저 들어온 것을 버린다 → 자주 쓰는 것도 버릴 수 있다
  • LRU: 가장 오래 안 쓴 것을 버린다 → 자주 쓰는 건 살아남는다
  • LFU: 가장 적게 쓴 것을 버린다 → 구현이 복잡하다

LRU가 구현 대비 효과가 가장 좋다.

Go container/list 기반 구현

Go 표준 라이브러리의 container/list(이중 연결 리스트)와 map을 조합하면 O(1) LRU를 만들 수 있다:

type Cache struct {
    mu         sync.RWMutex
    items      map[uint64]*list.Element  // 해시 → 리스트 노드
    evictList  *list.List                // LRU 순서 관리
    maxEntries int
    ttl        time.Duration
    maxSize    int                       // 결과 바이트 제한

    tableIndex map[string]map[uint64]struct{}  // 테이블 → 캐시 키 역인덱스
}
  • Get: map에서 O(1) 조회 → 리스트 맨 앞으로 이동 (최근 사용)
  • Set: map에 추가 + 리스트 맨 앞에 삽입 → 가득 차면 리스트 맨 뒤(LRU) 제거
  • Evict: 리스트 맨 뒤 노드를 O(1)로 제거

캐시 키 설계

같은 쿼리 + 같은 파라미터 = 같은 키:

func CacheKey(query string, params ...any) uint64 {
    h := fnv.New64a()
    h.Write([]byte(query))
    for _, p := range params {
        if s, ok := p.(string); ok {
            h.Write([]byte(s))
        }
    }
    return h.Sum64()
}

FNV-1a를 선택한 이유:

  • Go 표준 라이브러리에 포함 → 외부 의존 없음
  • 빠르다 — 벤치마크 15ns/op, 0 alloc
  • 캐시 키로는 충분한 분포도 (암호화 해시일 필요 없음)

캐싱 제한

모든 결과를 캐싱하면 안 된다:

func (c *Cache) Set(key uint64, result []byte, tables []string) {
    // 1. 결과가 너무 크면 스킵
    if c.maxSize > 0 && len(result) > c.maxSize {
        return
    }

    // 2. 가득 차면 LRU 제거
    if c.maxEntries > 0 && c.evictList.Len() >= c.maxEntries {
        c.evictOldest()
    }

    // 3. 저장 + TTL 설정
    e := &entry{
        key:       key,
        result:    result,
        tables:    tables,
        expiresAt: time.Now().Add(c.ttl),
    }
    elem := c.evictList.PushFront(e)
    c.items[key] = elem
}

세 가지 제한:

  • max_result_size: 큰 결과(예: 1MB)는 캐싱하지 않음 → 메모리 보호
  • max_entries: 총 항목 수 제한 → LRU로 오래된 것 제거
  • TTL: 시간 만료 → 오래된 데이터 자동 제거

테이블 기반 캐시 무효화

캐싱의 가장 어려운 부분은 언제 무효화할 것인가다.

역인덱스 전략

캐시 저장 시 “이 쿼리가 어떤 테이블을 참조하는지” 기록해둔다:

테이블 역인덱스:
  "users"  → [key1, key2, key5]
  "orders" → [key3, key4]

쓰기 쿼리가 오면 대상 테이블을 추출하고, 해당 테이블의 캐시를 전부 삭제한다:

func (c *Cache) InvalidateTable(table string) {
    keys := c.tableIndex[table]
    for key := range keys {
        if elem, ok := c.items[key]; ok {
            c.removeElement(elem)
        }
    }
    delete(c.tableIndex, table)
}

테이블명 추출

쓰기 쿼리에서 테이블명을 추출하는 건 간단하다:

// "INSERT INTO users ..."     → ["users"]
// "UPDATE orders SET ..."     → ["orders"]
// "DELETE FROM products ..."  → ["products"]

func ExtractTables(query string) []string {
    // INSERT INTO 뒤, UPDATE 뒤, DELETE FROM 뒤의 첫 단어를 추출
}

전체 흐름

SELECT * FROM users WHERE id = 1
  → 캐시 키 생성 (FNV hash)
  → 캐시 조회: Miss
  → DB에서 결과 가져옴
  → 캐시 저장 (key, result, tables=["users"])

SELECT * FROM users WHERE id = 1  (같은 쿼리)
  → 캐시 조회: Hit! → DB 거치지 않고 즉시 반환

INSERT INTO users (name) VALUES ('dave')
  → Writer로 전송
  → InvalidateTable("users") → users 관련 캐시 전부 삭제

SELECT * FROM users WHERE id = 1  (다시)
  → 캐시 조회: Miss → DB에서 새 결과 가져옴

벤치마크

BenchmarkCacheKey          15.0 ns/op    0 B/op    0 allocs/op
BenchmarkCacheGetHit       36.4 ns/op    0 B/op    0 allocs/op
BenchmarkCacheGetMiss       6.3 ns/op    0 B/op    0 allocs/op
BenchmarkCacheSet         106.7 ns/op   31 B/op    1 allocs/op
BenchmarkInvalidateTable  7815  ns/op    0 B/op    0 allocs/op

Cache Hit이 36ns면 네트워크 왕복(~0.5ms) 대비 10,000배 이상 빠르다. DB를 안 타는 것의 위력이다.

무효화는 100개 항목 기준 7.8μs로, 쓰기 쿼리에 추가되는 오버헤드가 거의 없다.

마무리

쿼리 캐싱은 “쉬운 것 같지만 무효화가 어렵다"는 게 정설이다. 테이블 기반 역인덱스는 완벽하지는 않지만 (JOIN 쿼리 등), 대부분의 CRUD 패턴에서 충분히 잘 동작한다.

다음 글(마지막!)에서는 전체 프로젝트의 성능을 측정하고 회고한다.