분산 시스템에서 캐시를 설계할 때 해결해야 할 핵심 문제는 크게 두 가지입니다. 첫째, 캐시 스탬피드 현상으로도 알려진 '캐시 관통(Cache Penetration)'을 방지해야 하며, 둘째, 여러 노드 간 캐시 일관성을 유지해야 합니다. 이 글에서는 Go 언어의 singleflight 패키지와 Redis의 Publish/Subscribe 기능을 결합하여 이 문제를 해결하는 실용적인 방법을 소개합니다.
캐시 계층 구조
구현할 캐시 계층은 다음과 같은 3단계로 구성됩니다:
- 1차 캐시 (L1): 인메모리 캐시 (go-cache 사용, TTL 5분)
- 2차 캐시 (L2): Redis (TTL 5분)
- 3차 저장소: TiDB (영구 저장소)
데이터 조회 시 L1 → L2 → TiDB 순으로 탐색하며, 캐시 관통 방지를 위해 singleflight.Group을 활용해 동시 요청을 중복 제거합니다. 캐시 갱신이 필요할 경우 Redis Pub/Sub을 통해 모든 노드에 L1 캐시 무효화 신호를 전파합니다.
실제 코드 구현
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/patrickmn/go-cache"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
"time"
)
var (
localCache *cache.Cache
redisClient *redis.Client
pubSubChan = "cache:invalidate"
)
func init() {
localCache = cache.New(5*time.Minute, 10*time.Minute)
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
ctx := context.Background()
if _, err := redisClient.Ping(ctx).Result(); err != nil {
panic(err)
}
// Redis Pub/Sub 구독 시작
sub := redisClient.Subscribe(ctx, pubSubChan)
go processInvalidationMessages(sub)
}
func processInvalidationMessages(sub *redis.PubSub) {
ch := sub.Channel()
for msg := range ch {
key := msg.Payload
if key == "" {
continue
}
// 로컬 캐시와 Redis 캐시 모두 제거
localCache.Delete(key)
redisClient.Del(context.Background(), key)
fmt.Printf("캐시 무효화 처리 완료: %s\n", key)
}
}
// GetData 제네릭 함수: L1 → L2 → TiDB 순으로 데이터 조회
func GetData[T any](ctx context.Context, key string, dest T, fetchFn func() (T, error)) error {
// 1. 로컬 캐시 조회
if cached, found := localCache.Get(key); found {
if result, ok := cached.(T); ok {
fmt.Printf("L1 캐시 히트: %s\n", key)
dest = result
return nil
}
}
// 2. singleflight로 중복 요청 방지
var group singleflight.Group
result, err, _ := group.Do(key, func() (interface{}, error) {
// 2-1. Redis 캐시 조회
data, err := redisClient.Get(ctx, key).Result()
if err == nil && len(data) > 0 {
if err := json.Unmarshal([]byte(data), &dest); err != nil {
return nil, err
}
localCache.Set(key, dest, 1*time.Minute)
fmt.Printf("L2 캐시 히트: %s\n", key)
return dest, nil
}
// Redis 누락키(Nil) 외의 에러 처리
if err != nil && err != redis.Nil {
return nil, err
}
// 2-2. TiDB에서 데이터 로드
loaded, err := fetchFn()
if err != nil {
return nil, err
}
fmt.Printf("TiDB 로드: %s\n", key)
// Redis에 캐시 저장 (5분 TTL)
jsonBytes, err := json.Marshal(loaded)
if err != nil {
return nil, err
}
if err := redisClient.Set(ctx, key, string(jsonBytes), 5*time.Minute).Err(); err != nil {
return nil, err
}
return loaded, nil
})
if err != nil {
return err
}
// 결과를 로컬 캐시에도 저장
dest = result.(T)
localCache.Set(key, dest, 1*time.Minute)
return nil
}
// InvalidateCache 캐시 무효화 요청 (Pub/Sub 발행)
func InvalidateCache(ctx context.Context, key string) error {
if err := redisClient.Publish(ctx, pubSubChan, key).Err(); err != nil {
return err
}
fmt.Printf("캐시 무효화 메시지 발행: %s\n", key)
return nil
}
중요 설계 포인트
- singleflight 범위: Redis 조회와 TiDB 로딩을 모두
singleflight.Group내부에 배치하여, 동일 키에 대한 모든 동시 요청이 실제로는 한 번만 데이터를 로드하도록 합니다. - 캐시 무효화 전파:
InvalidateCache함수가 호출되면 Redis Pub/Sub 채널로 메시지를 발행하고, 다른 노드들은 구독 핸들러에서 로컬 캐시와 Redis 캐시를 모두 삭제합니다. - TTL 관리: L1 캐시는 1분, L2 캐시는 5분으로 설정하여 캐시 스탬피드를 추가로 방지합니다.
사용 예시
type Person struct {
Name string
Age int
}
func loadPerson() (*Person, error) {
return &Person{Name: "Alice", Age: 30}, nil
}
func main() {
ctx := context.Background()
key := "person:1"
var p *Person = new(Person)
// 첫 조회: TiDB 로드 → Redis + 로컬 캐시 저장
GetData(ctx, key, p, loadPerson)
// 두 번째 조회: 로컬 캐시 히트
GetData(ctx, key, p, loadPerson)
// 캐시 무효화 (다른 노드에도 전파)
InvalidateCache(ctx, key)
// 세 번째 조회: Redis 히트 (다른 노드가 Redis는 남겨둔 경우)
GetData(ctx, key, p, loadPerson)
// 네 번째 조회: 로컬 캐시 (새로 저장된)
GetData(ctx, key, p, loadPerson)
}
실행 결과 확인
다중 노드 환경에서 Redis Pub/Sub이 정상적으로 작동하는지 확인하려면 각 노드의 콘솔 출력을 관찰하세요. 예를 들어 노드 A가 InvalidateCache("person:1")를 호출하면, 노드 B는 구독 핸들러에서 "캐시 무효화 처리 완료: person:1" 메시지를 출력해야 합니다. 이를 통해 분산 캐시 무효화 메커니즘이 올바르게 동작함을 검증할 수 있습니다.