Go 1.21 제네릭으로 타입 안전한 캐시 레이어 구현

배경

사내 API 서버에서 Redis 캐싱 로직이 여러 곳에 중복되어 있었다. 각 엔드포인트마다 직렬화/역직렬화 코드가 반복되고, 에러 처리도 일관성이 없었다. Go 1.18 이후 제네릭이 안정화되었으니 이를 활용해 타입 안전한 캐시 레이어를 만들기로 했다.

구현

type Cache[T any] struct {
    client *redis.Client
    prefix string
    ttl    time.Duration
}

func NewCache[T any](client *redis.Client, prefix string, ttl time.Duration) *Cache[T] {
    return &Cache[T]{client: client, prefix: prefix, ttl: ttl}
}

func (c *Cache[T]) Get(ctx context.Context, key string) (*T, error) {
    val, err := c.client.Get(ctx, c.prefix+key).Result()
    if err == redis.Nil {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    
    var result T
    if err := json.Unmarshal([]byte(val), &result); err != nil {
        return nil, err
    }
    return &result, nil
}

func (c *Cache[T]) Set(ctx context.Context, key string, value T) error {
    data, err := json.Marshal(value)
    if err != nil {
        return err
    }
    return c.client.Set(ctx, c.prefix+key, data, c.ttl).Err()
}

사용은 이렇게 한다.

type User struct {
    ID   int64  `json:"id"`
    Name string `json:"name"`
}

userCache := NewCache[User](redisClient, "user:", 10*time.Minute)

// 캐시 조회
user, err := userCache.Get(ctx, "123")
if err != nil {
    return err
}

// 캐시 저장
err = userCache.Set(ctx, "123", User{ID: 123, Name: "John"})

배운 점

제네릭을 쓰니 타입 캐스팅 코드가 사라지고 컴파일 타임에 타입 안전성이 보장되었다. 다만 인터페이스와 달리 제네릭은 모노모픽화되어 각 타입마다 코드가 생성되므로 바이너리 크기는 약간 증가했다.

초기에는 Cache[T comparable] 제약을 사용하려 했으나, 구조체가 comparable하지 않은 경우가 많아 any로 완화했다. 실무에서는 완벽한 추상화보다 실용성이 중요하다는 걸 다시 느꼈다.

캐싱 로직이 한 곳으로 모이면서 모니터링도 쉬워졌다. 다음에는 GetOrSet 패턴과 캐시 워밍까지 추가할 예정이다.