Redis Pub/Sub을 활용한 분산 Go 캐시 설계

분산 시스템에서 캐시를 설계할 때 해결해야 할 핵심 문제는 크게 두 가지입니다. 첫째, 캐시 스탬피드 현상으로도 알려진 '캐시 관통(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" 메시지를 출력해야 합니다. 이를 통해 분산 캐시 무효화 메커니즘이 올바르게 동작함을 검증할 수 있습니다.

태그: go singleflight redis-cache pub-sub cache-invalidation

6월 22일 00:59에 게시됨