Redis 연동 — go-redis로 캐싱과 세션 관리
Redis는 인메모리 데이터 구조 저장소로, Go 애플리케이션에서 캐싱, 세션 관리, Pub/Sub, Rate Limiting 등에 널리 활용됩니다.
go-redis 설치
go get github.com/redis/go-redis/v9
Redis 연결
package main
import (
"context"
"fmt"
"log"
"github.com/redis/go-redis/v9"
)
func newRedisClient() *redis.Client {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // 비밀번호 없으면 빈 문자열
DB: 0, // 기본 DB 번호
PoolSize: 10, // 커넥션 풀 크기
})
return rdb
}
func main() {
ctx := context.Background()
rdb := newRedisClient()
defer rdb.Close()
// 연결 확인
pong, err := rdb.Ping(ctx).Result()
if err != nil {
log.Fatal("Redis 연결 실패:", err)
}
fmt.Println("Redis 응답:", pong) // PONG
}
Redis Cluster 연결
func newRedisCluster() *redis.ClusterClient {
return redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
"localhost:7000",
"localhost:7001",
"localhost:7002",
},
})
}
기본 String 명령어
func stringExamples(ctx context.Context, rdb *redis.Client) {
// SET
err := rdb.Set(ctx, "username", "golang", 0).Err() // 0 = 만료 없음
if err != nil {
log.Fatal(err)
}
// SET with TTL (Time To Live)
err = rdb.Set(ctx, "token", "abc123", 30*time.Minute).Err()
if err != nil {
log.Fatal(err)
}
// GET
val, err := rdb.Get(ctx, "username").Result()
if err == redis.Nil {
fmt.Println("키 없음")
} else if err != nil {
log.Fatal(err)
} else {
fmt.Println("username:", val)
}
// GETSET (값을 가져오고 새 값으로 교체)
old, _ := rdb.GetSet(ctx, "username", "new_golang").Result()
fmt.Println("이전 값:", old)
// MSET / MGET (다중 키 작업)
rdb.MSet(ctx, "key1", "val1", "key2", "val2", "key3", "val3")
vals, _ := rdb.MGet(ctx, "key1", "key2", "key3").Result()
fmt.Println("다중 조회:", vals)
// INCR / INCRBY
rdb.Set(ctx, "counter", 10, 0)
rdb.Incr(ctx, "counter") // 11
rdb.IncrBy(ctx, "counter", 5) // 16
count, _ := rdb.Get(ctx, "counter").Int()
fmt.Println("카운터:", count)
// EXISTS
exists, _ := rdb.Exists(ctx, "username").Result()
fmt.Println("존재 여부:", exists) // 1
// DEL
rdb.Del(ctx, "username", "token")
// EXPIRE
rdb.Expire(ctx, "counter", 1*time.Hour)
// TTL 조회
ttl, _ := rdb.TTL(ctx, "counter").Result()
fmt.Println("남은 TTL:", ttl)
}
캐싱 패턴 — Cache-Aside
가장 일반적인 캐싱 전략입니다. "없으면 DB에서 읽어서 캐시에 저장"합니다.
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type UserCache struct {
rdb *redis.Client
ttl time.Duration
}
func NewUserCache(rdb *redis.Client, ttl time.Duration) *UserCache {
return &UserCache{rdb: rdb, ttl: ttl}
}
func (c *UserCache) cacheKey(id int64) string {
return fmt.Sprintf("user:%d", id)
}
// Cache-Aside 패턴: 캐시 확인 → 없으면 DB 조회 → 캐시 저장
func (c *UserCache) GetUser(ctx context.Context, id int64, fetchFromDB func(int64) (*User, error)) (*User, error) {
key := c.cacheKey(id)
// 1. 캐시에서 조회
data, err := c.rdb.Get(ctx, key).Bytes()
if err == nil {
// 캐시 히트
var user User
if err := json.Unmarshal(data, &user); err == nil {
return &user, nil
}
}
if err != redis.Nil {
// 캐시 오류 (Redis 다운 등) → DB 직접 조회 (graceful degradation)
fmt.Printf("캐시 조회 실패 (fallback to DB): %v\n", err)
}
// 2. 캐시 미스 → DB 조회
user, err := fetchFromDB(id)
if err != nil {
return nil, fmt.Errorf("DB 조회 실패: %w", err)
}
if user == nil {
return nil, nil
}
// 3. 캐시에 저장
if data, err := json.Marshal(user); err == nil {
c.rdb.Set(ctx, key, data, c.ttl) // 에러 무시 (캐시 실패해도 데이터는 반환)
}
return user, nil
}
// 캐시 무효화
func (c *UserCache) InvalidateUser(ctx context.Context, id int64) error {
return c.rdb.Del(ctx, c.cacheKey(id)).Err()
}
해시(Hash) — 구조체 저장
Redis Hash는 필드 단위로 읽고 쓸 수 있어 구조체 저장에 적합합니다.
func hashExamples(ctx context.Context, rdb *redis.Client) {
// HSET: 여러 필드 한번에 저장
rdb.HSet(ctx, "user:1001", map[string]interface{}{
"name": "김고랭",
"email": "golang@example.com",
"age": "30",
})
// HGET: 단일 필드 조회
name, _ := rdb.HGet(ctx, "user:1001", "name").Result()
fmt.Println("이름:", name)
// HGETALL: 전체 필드 조회
fields, _ := rdb.HGetAll(ctx, "user:1001").Result()
fmt.Println("전체:", fields)
// HMGET: 다중 필드 조회
vals, _ := rdb.HMGet(ctx, "user:1001", "name", "email").Result()
fmt.Println("이름, 이메일:", vals)
// HINCRBY: 숫자 필드 증가
rdb.HIncrBy(ctx, "user:1001", "login_count", 1)
// HDEL: 필드 삭제
rdb.HDel(ctx, "user:1001", "age")
// HEXISTS: 필드 존재 확인
exists, _ := rdb.HExists(ctx, "user:1001", "name").Result()
fmt.Println("name 존재:", exists)
}
리스트(List) — 작업 큐
func listExamples(ctx context.Context, rdb *redis.Client) {
queueKey := "task:queue"
// RPUSH: 오른쪽(끝)에 추가
rdb.RPush(ctx, queueKey, "task1", "task2", "task3")
// LPOP: 왼쪽(앞)에서 꺼내기 → FIFO 큐
task, err := rdb.LPop(ctx, queueKey).Result()
if err == redis.Nil {
fmt.Println("큐 비어있음")
} else {
fmt.Println("처리할 작업:", task)
}
// BLPOP: 블로킹 팝 (큐가 빌 때까지 대기, 타임아웃 설정)
result, err := rdb.BLPop(ctx, 5*time.Second, queueKey).Result()
if err == redis.Nil {
fmt.Println("타임아웃: 큐 비어있음")
} else if err == nil {
fmt.Printf("블로킹 팝: 큐=%s, 작업=%s\n", result[0], result[1])
}
// LLEN: 길이
length, _ := rdb.LLen(ctx, queueKey).Result()
fmt.Println("큐 길이:", length)
}
세트(Set) — 중복 없는 집합
func setExamples(ctx context.Context, rdb *redis.Client) {
// SADD: 멤버 추가
rdb.SAdd(ctx, "online:users", "user:1", "user:2", "user:3")
rdb.SAdd(ctx, "vip:users", "user:2", "user:3", "user:4")
// SMEMBERS: 전체 멤버
members, _ := rdb.SMembers(ctx, "online:users").Result()
fmt.Println("온라인 사용자:", members)
// SISMEMBER: 멤버 여부 확인
isOnline, _ := rdb.SIsMember(ctx, "online:users", "user:1").Result()
fmt.Println("user:1 온라인:", isOnline)
// SINTER: 교집합 (온라인 VIP)
vipOnline, _ := rdb.SInter(ctx, "online:users", "vip:users").Result()
fmt.Println("온라인 VIP:", vipOnline)
// SCARD: 집합 크기
count, _ := rdb.SCard(ctx, "online:users").Result()
fmt.Println("온라인 수:", count)
}
세션 관리
package session
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/google/uuid"
)
type Session struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
}
type SessionStore struct {
rdb *redis.Client
ttl time.Duration
}
func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore {
return &SessionStore{rdb: rdb, ttl: ttl}
}
func (s *SessionStore) Create(ctx context.Context, sess Session) (string, error) {
sessionID := uuid.New().String()
key := fmt.Sprintf("session:%s", sessionID)
data, err := json.Marshal(sess)
if err != nil {
return "", err
}
if err := s.rdb.Set(ctx, key, data, s.ttl).Err(); err != nil {
return "", fmt.Errorf("세션 저장 실패: %w", err)
}
return sessionID, nil
}
func (s *SessionStore) Get(ctx context.Context, sessionID string) (*Session, error) {
key := fmt.Sprintf("session:%s", sessionID)
data, err := s.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil // 세션 만료 또는 없음
}
if err != nil {
return nil, err
}
var sess Session
return &sess, json.Unmarshal(data, &sess)
}
func (s *SessionStore) Refresh(ctx context.Context, sessionID string) error {
key := fmt.Sprintf("session:%s", sessionID)
return s.rdb.Expire(ctx, key, s.ttl).Err()
}
func (s *SessionStore) Delete(ctx context.Context, sessionID string) error {
key := fmt.Sprintf("session:%s", sessionID)
return s.rdb.Del(ctx, key).Err()
}
Pub/Sub — 실시간 메시지 브로드캐스트
func pubSubExample(rdb *redis.Client) {
ctx := context.Background()
// 구독자 (별도 고루틴)
go func() {
pubsub := rdb.Subscribe(ctx, "notifications", "alerts")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
fmt.Printf("[수신] 채널=%s, 메시지=%s\n", msg.Channel, msg.Payload)
}
}()
time.Sleep(100 * time.Millisecond) // 구독자 준비 대기
// 발행자
rdb.Publish(ctx, "notifications", "새 댓글이 달렸습니다")
rdb.Publish(ctx, "alerts", "시스템 점검 예정")
time.Sleep(200 * time.Millisecond)
}
Rate Limiting — 슬라이딩 윈도우
func isRateLimited(ctx context.Context, rdb *redis.Client, userID string, limit int, window time.Duration) (bool, error) {
key := fmt.Sprintf("ratelimit:%s", userID)
now := time.Now()
pipe := rdb.TxPipeline()
// 현재 시간 이후의 요청 추가
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now.UnixNano()), Member: now.UnixNano()})
// 윈도우 이전 요청 삭제
pipe.ZRemRangeByScore(ctx, key, "-inf", fmt.Sprintf("%d", now.Add(-window).UnixNano()))
// 현재 요청 수 조회
countCmd := pipe.ZCard(ctx, key)
// 키 만료 설정
pipe.Expire(ctx, key, window)
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
count, _ := countCmd.Result()
return count > int64(limit), nil
}
파이프라이닝 — 배치 명령 최적화
func pipelineExample(ctx context.Context, rdb *redis.Client) {
pipe := rdb.Pipeline()
// 여러 명령을 한 번의 네트워크 왕복으로 실행
setCmd := pipe.Set(ctx, "key1", "val1", 0)
getCmd := pipe.Get(ctx, "key1")
incrCmd := pipe.Incr(ctx, "counter")
_, err := pipe.Exec(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("SET:", setCmd.Err())
fmt.Println("GET:", getCmd.Val())
fmt.Println("INCR:", incrCmd.Val())
}
핵심 정리
| 자료구조 | 활용 사례 |
|---|---|
| String | 캐시, 카운터, 세션 토큰 |
| Hash | 사용자 프로파일, 설정값 |
| List | 작업 큐, 최근 활동 목록 |
| Set | 온라인 사용자, 태그 |
| Sorted Set | 리더보드, Rate Limiting |
- Redis 장애 시에도 DB Fallback으로 서비스가 동작하도록 설계할 것
- 캐시 키에 네임스페이스(
user:,session:)를 부여해 충돌 방지 - TTL 없이 저장하면 메모리가 고갈될 수 있으므로 항상 만료시간 설정