본문 바로가기
Research Notes (개인)

Redis 운영 Best Practice


1. 데이터 설계 원칙

✅ Redis의 역할을 명확히 정의하라

❌ 잘못된 접근: Redis를 Primary DB처럼 사용
✅ 올바른 접근: 역할에 따라 명확히 구분

┌─────────────────────────────────────────────────────┐
│  Redis가 잘하는 것          │  RDBMS에 맡길 것        │
├─────────────────────────────────────────────────────┤
│  캐시 (Cache)               │  중요 금융 트랜잭션      │
│  세션 저장소                 │  복잡한 조인 쿼리        │
│  분산락                     │  정합성 중요 데이터       │
│  카운터/랭킹                 │  감사 로그(Audit Log)    │
│  임시 데이터, 큐             │  장기 보관 데이터         │
└─────────────────────────────────────────────────────┘

✅ TTL은 거의 항상 설정하라

# ❌ TTL 없이 저장 → 메모리 누수
SET user:session:123 "data"

# ✅ 항상 만료 시간 설정
SET user:session:123 "data" EX 3600   # 1시간
SET cache:product:456 "data" EX 300   # 5분

# TTL 없는 키 주기적 점검
redis-cli --scan --pattern "*" | xargs -L 1 redis-cli TTL

✅ 키 네이밍 컨벤션

# 규칙: {서비스}:{도메인}:{식별자}:{속성}
service:user:1001:session
service:product:456:stock
service:order:789:status

# 클러스터 환경: Hash Tag로 슬롯 그룹핑
{user:1001}:session
{user:1001}:profile
{user:1001}:cart
# → 세 키가 같은 슬롯, Lua/MULTI 사용 가능

2. 트랜잭션 / 원자성 설계 원칙

✅ 트랜잭션 방식 선택 기준을 명확히

비즈니스 로직 복잡도에 따라:

단순 명령 묶음
    └→ MULTI/EXEC

조건 분기 필요 (if-else)
    └→ Lua Script

충돌 감지 + 재시도 필요
    └→ WATCH + MULTI/EXEC

완전한 All or Nothing 필요 (금융 등)
    └→ Redis 단독 불가 → RDBMS 병행 설계

✅ Lua Script 설계 패턴 - "검증 먼저, 실행 나중"

-- ✅ 모든 검증을 앞에 몰아서 부분 실행 방지
local function validate(balance, amount)
    if not balance then return "KEY_NOT_FOUND" end
    if tonumber(balance) < tonumber(amount) then return "INSUFFICIENT" end
    return nil
end

local balance = redis.call('GET', KEYS[1])
local err = validate(balance, ARGV[1])
if err then
    return redis.error_reply(err)  -- 여기서 중단, 아무것도 변경 안 됨
end

-- 검증 완료 후 실행 (부분 실행 가능성 최소화)
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('LPUSH',  KEYS[2], ARGV[2])
return 1

✅ 중요 데이터는 RDBMS와 이중 기록

// 재고 차감 예시
@Transactional  // DB 트랜잭션
public void decreaseStock(Long productId, int quantity) {
    // 1. RDBMS에 먼저 기록 (Source of Truth)
    productRepository.decreaseStock(productId, quantity);
    
    // 2. Redis 캐시 갱신 (보조)
    try {
        redisStockService.decrease(productId, quantity);
    } catch (Exception e) {
        // Redis 실패해도 DB는 이미 커밋됨
        // 캐시 무효화로 다음 요청 시 DB에서 재로딩
        redisTemplate.delete("stock:" + productId);
        log.warn("Redis 캐시 갱신 실패, 캐시 무효화 처리", e);
    }
}

3. 분산락 Best Practice

✅ 분산락 구현 체크리스트

// 올바른 분산락 구현
public boolean acquireLock(String key, String token, long ttlMs) {
    // ✅ 1. SET NX + TTL을 원자적으로 (두 명령 분리 금지!)
    Boolean result = redisTemplate.opsForValue()
        .setIfAbsent(key, token, Duration.ofMillis(ttlMs));
    return Boolean.TRUE.equals(result);
}

public boolean releaseLock(String key, String token) {
    // ✅ 2. 반드시 Lua로 토큰 비교 + DEL 원자적 처리
    String script =
        "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('DEL', KEYS[1]) " +
        "else " +
        "  return 0 " +
        "end";
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(script, Long.class),
        List.of(key), token
    );
    return Long.valueOf(1L).equals(result);
}
분산락 필수 체크리스트:
  ✅ 락 획득: SET NX PX {ttl} (원자적 획득 + 만료)
  ✅ 고유 토큰: UUID로 소유자 식별
  ✅ 락 해제: Lua Script로 토큰 비교 후 DEL
  ✅ TTL 설정: 데드락 방지 (프로세스 죽어도 자동 해제)
  ✅ 재시도 로직: 획득 실패 시 backoff 재시도
  ✅ TTL > 작업시간: 작업 중 락 만료 방지

4. 캐시 전략 Best Practice

✅ 캐시 패턴 선택

[Cache-Aside (Look-Aside)] ← 가장 일반적
  읽기: Redis Miss → DB 조회 → Redis 저장 → 반환
  쓰기: DB 저장 → Redis 삭제(무효화)

[Write-Through]
  쓰기: DB + Redis 동시 저장
  → 쓰기 지연 발생, 캐시 항상 최신

[Write-Behind]
  쓰기: Redis 먼저 → 비동기로 DB 저장
  → 빠른 쓰기, 데이터 유실 위험 ⚠️

✅ 캐시 스탬피드(Cache Stampede) 방지

// 문제: 캐시 만료 시 동시에 수백 요청이 DB로 몰림
// 해결 1: 뮤텍스 락
public Product getProduct(Long id) {
    String cacheKey = "product:" + id;
    Product cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) return cached;
    
    // ✅ 락으로 단 하나의 요청만 DB 조회
    String lockKey = "lock:product:" + id;
    String token = UUID.randomUUID().toString();
    
    if (acquireLock(lockKey, token, 3000)) {
        try {
            // Double-check (락 획득 사이 다른 스레드가 캐싱했을 수 있음)
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) return cached;
            
            Product product = productRepository.findById(id);
            redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(5));
            return product;
        } finally {
            releaseLock(lockKey, token);
        }
    }
    // 락 획득 실패 시 잠시 대기 후 재시도
    Thread.sleep(50);
    return getProduct(id);
}

// 해결 2: TTL Jitter (만료 시간 랜덤화)
long ttl = 300 + ThreadLocalRandom.current().nextInt(60); // 300~360초
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttl));

5. 성능 Best Practice

✅ Pipeline 사용 기준

// ❌ 루프에서 개별 호출 (N번의 RTT)
for (String key : keys) {
    redisTemplate.opsForValue().get(key);
}

// ✅ Pipeline으로 일괄 처리 (1번의 RTT)
List<Object> results = redisTemplate.executePipelined(
    (RedisCallback<Object>) connection -> {
        keys.forEach(key -> connection.get(key.getBytes()));
        return null;
    }
);

// ✅ 배치 크기 조절 (메모리 버퍼 주의)
int BATCH_SIZE = 500;  // 한 번에 너무 많이 X
for (int i = 0; i < keys.size(); i += BATCH_SIZE) {
    List<String> batch = keys.subList(i, Math.min(i + BATCH_SIZE, keys.size()));
    // pipeline 처리
}

✅ 절대 사용하지 말아야 할 명령어

# ❌ KEYS * → 전체 키 스캔, 프로덕션 Redis Hang
KEYS *
KEYS user:*

# ✅ SCAN으로 대체 (커서 기반, 논블로킹)
SCAN 0 MATCH user:* COUNT 100

# ❌ HGETALL, SMEMBERS, LRANGE 0 -1 → 데이터 크면 블로킹
HGETALL huge_hash        # 수백만 필드면?
SMEMBERS huge_set

# ✅ 페이지네이션
HSCAN myhash 0 COUNT 100
SSCAN myset 0 COUNT 100
LRANGE mylist 0 99       # 범위 제한

6. 운영/모니터링 Best Practice

✅ 메모리 관리

# redis.conf 필수 설정
maxmemory 4gb                    # 메모리 상한 설정
maxmemory-policy allkeys-lru     # 상한 초과 시 LRU 방식 제거
                                 # (캐시 용도라면 allkeys-lru)
                                 # (세션 등 중요 데이터라면 volatile-lru)

# 메모리 정책 종류
# allkeys-lru      : 전체 키 중 LRU
# volatile-lru     : TTL 있는 키 중 LRU  ← 세션/중요 데이터
# allkeys-lfu      : 전체 키 중 LFU (접근 빈도)
# volatile-ttl     : TTL 짧은 것부터 제거
# noeviction       : 제거 안 함, 에러 반환 ← 절대 캐시에 쓰지 말 것

✅ 모니터링 핵심 지표

# 주기적으로 확인할 지표
redis-cli INFO stats

# ┌─────────────────────────────────────────────┐
# │  지표                  │  임계값             │
# ├─────────────────────────────────────────────┤
# │  used_memory           │  maxmemory 80% 경보 │
# │  keyspace_hits/misses  │  hit rate < 80% 점검 │
# │  connected_clients     │  비정상 급증 경보    │
# │  blocked_clients       │  0 이상이면 조사    │
# │  rejected_connections  │  0 이상이면 위험    │
# │  latency               │  p99 > 10ms 경보    │
# └─────────────────────────────────────────────┘

# Slow Query 로그
redis-cli CONFIG SET slowlog-log-slower-than 10000  # 10ms 이상
redis-cli SLOWLOG GET 10

✅ 장애 대비 설정

# AOF + RDB 함께 사용 (데이터 영속성)
save 900 1          # RDB: 900초 내 1번 변경 시 스냅샷
appendonly yes      # AOF 활성화
appendfsync everysec # 1초마다 fsync (성능 vs 안정성 균형)

# Replica 필수 구성
# Master 1 + Replica 2 이상
# Sentinel 또는 Cluster로 자동 Failover

7. 아키텍처 레벨 Best Practice

✅ 데이터 정합성 보장 패턴

중요 데이터 흐름:

[요청]
  │
  ├→ 1. RDBMS 트랜잭션 (Source of Truth)
  │       └→ 커밋 성공
  │
  └→ 2. Redis 갱신 (캐시 업데이트 or 무효화)
          └→ 실패해도 서비스 지속 가능
             (다음 요청에서 DB에서 재로딩)

핵심 원칙:
  Redis 실패 = 캐시 미스 (서비스 장애 X)
  RDBMS 실패 = 트랜잭션 롤백 (데이터 보호)

전체 요약 체크리스트

┌─────────────────────────────────────────────────────────────┐
│                   Redis 운영 체크리스트                       │
├──────────────────────────────────────────────────────────────┤
│ 데이터 설계                                                   │
│   ✅ 역할 명확히 정의 (캐시 vs 저장소)                        │
│   ✅ 모든 키에 TTL 설정                                       │
│   ✅ 키 네이밍 컨벤션 통일                                    │
├──────────────────────────────────────────────────────────────┤
│ 트랜잭션                                                      │
│   ✅ 조건부 로직 → Lua Script                                 │
│   ✅ Lua에서 검증 먼저, 실행 나중 패턴                        │
│   ✅ All or Nothing → RDBMS 병행 설계                        │
├──────────────────────────────────────────────────────────────┤
│ 분산락                                                        │
│   ✅ SET NX PX (원자적 획득 + TTL)                           │
│   ✅ UUID 토큰으로 소유자 식별                                 │
│   ✅ Lua Script로 원자적 해제                                 │
├──────────────────────────────────────────────────────────────┤
│ 성능                                                          │
│   ✅ KEYS * 절대 금지 → SCAN 사용                            │
│   ✅ 대량 처리 → Pipeline (배치 단위)                         │
│   ✅ TTL Jitter로 캐시 스탬피드 방지                          │
├──────────────────────────────────────────────────────────────┤
│ 운영                                                          │
│   ✅ maxmemory + eviction policy 설정                        │
│   ✅ 핵심 지표 모니터링 (hit rate, latency, memory)           │
│   ✅ AOF + RDB 영속성 설정                                    │
│   ✅ Replica + Sentinel/Cluster 구성                         │
└──────────────────────────────────────────────────────────────┘

좋은 질문입니다! 결론부터:

redis.call() 사용 시, 그 시나리오는 발생하지 않습니다. 오히려 반대 방향이 위험합니다.


redis.call() 에러 동작 원리

redis.call('DECRBY', KEYS[1], ARGV[1])  -- ❌ 실패 시
redis.call('LPUSH',  KEYS[2], ARGV[2])  -- 🚫 여기까지 도달 자체를 안 함

redis.call()은 에러 발생 시 Lua 에러를 throw → 스크립트 즉시 중단합니다. DECRBY가 실패하면 LPUSH는 실행조차 되지 않습니다.


실제 위험한 방향은 반대입니다

redis.call('DECRBY', KEYS[1], ARGV[1])  -- ✅ 성공 → 잔액 차감됨
redis.call('LPUSH',  KEYS[2], ARGV[2])  -- ❌ 실패 → 로그 기록 안 됨
                                        -- 잔액은 이미 차감된 상태!
사용자 시나리오:    DECRBY 실패 → LPUSH 실행 X  ← redis.call() 로 막힘 ✅
실제 위험 시나리오: DECRBY 성공 → LPUSH 실패    ← 여전히 부분 실행 위험 ⚠️

redis.pcall() 을 쓰면 사용자 시나리오가 가능해짐

-- pcall은 에러를 잡고 계속 진행
local ok = redis.pcall('DECRBY', KEYS[1], ARGV[1])  -- ❌ 실패해도 계속!
redis.call('LPUSH',  KEYS[2], ARGV[2])              -- ✅ 실행됨 ← 위험!
redis.call()redis.pcall()
에러 발생 시즉시 중단에러 캡처 후 계속 실행
이후 명령 실행❌ 실행 안 됨✅ 실행됨
부분 실행 방향에러 이후 명령만 막힘모든 방향 가능

결국 남아있는 근본적인 한계

어떤 방법을 써도 "이미 실행된 명령의 롤백"은 불가능

DECRBY 성공
    │
    └→ LPUSH 실패  ←── 이 경우는 Lua도 막을 수 없음
                       DECRBY는 이미 Redis에 반영된 상태

실무에서의 대응 전략

-- 전략 1: 덜 중요한 작업을 뒤에 배치
redis.call('LPUSH',  KEYS[2], ARGV[2])  -- 로그 (덜 중요) → 먼저
redis.call('DECRBY', KEYS[1], ARGV[1])  -- 잔액 (핵심) → 나중
-- LPUSH 실패 시 잔액은 건드리지 않음

-- 전략 2: 멱등성 설계 (재시도해도 안전하게)
-- DECRBY 대신 SET으로 최종값을 명시적으로 저장
local new_balance = tonumber(balance) - tonumber(ARGV[1])
redis.call('SET', KEYS[1], new_balance)  -- 재시도해도 같은 결과
전략 3: Redis는 캐시/보조, RDBMS가 Source of Truth

[요청]
  ├→ RDBMS 트랜잭션 (완전한 All or Nothing) ← 핵심 데이터
  └→ Redis 업데이트 실패 시 캐시 무효화      ← 정합성 복구

요약: redis.call()은 "에러 이후 명령 실행"은 막아주지만, "에러 이전 명령의 롤백"은 불가능합니다. 완전한 원자성이 필요한 로직은 RDBMS와 함께 설계해야 합니다.


Redis 추가로 알아야 할 원리와 실용 사례


이미 학습한 내용

✅ Eviction Policy
✅ Lua Script / Pipeline / MULTI/EXEC
✅ Single Thread Queue 동작 원리
✅ Hash Slot (Cluster/Non-Cluster)
✅ Connection Pool (Lettuce)
✅ Master/Replica 분리
✅ Persistence (RDB/AOF)
✅ Distributed Lock
✅ WATCH/DISCARD
✅ SessionCallback 원리

추가로 알아야 할 것들


🔴 높은 우선순위 (실무 필수)

1. Hot Key / Big Key 문제
2. Cache 전략 패턴 (Cache-Aside, Write-Through 등)
3. Cache Stampede 방지
4. Keyspace Notification (키 만료 이벤트)
5. Redis Sentinel (HA)
6. Redis Streams (Kafka 유사 메시징)

🟡 중간 우선순위 (운영 필수)

7. Slow Log 분석
8. Memory 최적화 (자료구조 선택)
9. SCAN 패턴
10. Circuit Breaker (Redis 장애 시)
11. Redis Pub/Sub

🟢 낮은 우선순위 (상황별)

12. HyperLogLog
13. Bitmap
14. Geo (지리 데이터)
15. 무중단 데이터 마이그레이션

1. 🔴 Hot Key / Big Key 문제

Hot Key:
  특정 키에 트래픽 집중
  → Redis 단일 스레드 특성상 해당 키 처리에 CPU 집중
  → 다른 요청 지연 발생

  예: 인기 상품 상세 페이지
      이벤트 시 특정 쿠폰 키에 수만 TPS 집중

Big Key:
  값이 너무 큰 키
  → 직렬화/역직렬화 시간 증가
  → 네트워크 전송 지연
  → Eviction 시 메모리 급증

  기준:
    String > 10KB
    Collection > 5000개 요소
// Hot Key 해결: Local Cache 계층 추가
@Service
public class ProductService {

    // L1: 로컬 캐시 (Caffeine)
    private final Cache<Long, Product> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofSeconds(5))  // 짧은 TTL
        .build();

    // L2: Redis 캐시
    private final StringRedisTemplate redisTemplate;

    public Product getProduct(Long productId) {
        // L1 먼저 확인 (Redis 요청 자체를 줄임)
        Product local = localCache.getIfPresent(productId);
        if (local != null) return local;

        // L2 Redis 확인
        String cached = redisTemplate.opsForValue()
            .get("product:" + productId);
        if (cached != null) {
            Product product = deserialize(cached);
            localCache.put(productId, product);  // L1에 저장
            return product;
        }

        // DB 조회
        Product product = productRepository.findById(productId)
            .orElseThrow();
        redisTemplate.opsForValue()
            .set("product:" + productId, serialize(product),
                Duration.ofMinutes(30));
        localCache.put(productId, product);
        return product;
    }
}


// Big Key 해결: 데이터 분산
// ❌ 하나의 큰 Hash
HSET user:1:profile field1 val1 field2 val2 ... (1000개 필드)

// ✅ 여러 작은 Hash로 분산
HSET user:1:basic    name Alice age 30
HSET user:1:address  city Seoul
HSET user:1:settings theme dark

2. 🔴 Cache 전략 패턴

┌──────────────────┬────────────────────────────────────────────┐
│  패턴            │  동작                                       │
├──────────────────┼────────────────────────────────────────────┤
│ Cache-Aside      │ Miss → DB 조회 → Cache 저장 (가장 일반적)  │
│ Write-Through    │ 쓰기 시 Cache + DB 동시 저장               │
│ Write-Behind     │ Cache 먼저 → 비동기 DB 저장 (빠른 쓰기)   │
│ Read-Through     │ Cache가 직접 DB 조회 (투명한 캐싱)         │
└──────────────────┴────────────────────────────────────────────┘
// Cache-Aside (가장 일반적)
public Product getProduct(Long id) {
    // 1. Cache 확인
    String cached = redisTemplate.opsForValue().get("product:" + id);
    if (cached != null) return deserialize(cached);

    // 2. DB 조회
    Product product = productRepository.findById(id).orElseThrow();

    // 3. Cache 저장
    redisTemplate.opsForValue()
        .set("product:" + id, serialize(product), Duration.ofMinutes(30));

    return product;
}

// Write-Through
@Transactional
public Product updateProduct(Product product) {
    // DB 저장
    Product saved = productRepository.save(product);

    // Cache 동시 갱신
    redisTemplate.opsForValue()
        .set("product:" + saved.getId(), serialize(saved),
            Duration.ofMinutes(30));

    return saved;
}

// Write-Behind (비동기)
public void updateProductAsync(Product product) {
    // Cache 먼저
    redisTemplate.opsForValue()
        .set("product:" + product.getId(), serialize(product),
            Duration.ofMinutes(30));

    // DB는 비동기로
    applicationEventPublisher.publishEvent(
        new ProductUpdateEvent(product)
    );
}

@Async
@EventListener
public void handleProductUpdate(ProductUpdateEvent event) {
    productRepository.save(event.getProduct());
}

3. 🔴 Cache Stampede 방지

문제:
  인기 키 TTL 만료 시
  동시에 수천 요청이 DB로 몰림
  → DB 부하 폭발
@Service
public class CacheStampedeService {

    private final StringRedisTemplate redisTemplate;
    private final DistributedLockService lockService;

    // 해결책 1: 뮤텍스 락
    public Product getProductWithLock(Long productId) {
        String cacheKey = "product:" + productId;
        String lockKey  = "lock:product:" + productId;
        String token    = UUID.randomUUID().toString();

        // Cache 확인
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) return deserialize(cached);

        // 락 획득 시도
        if (lockService.acquireLock(lockKey, token, 3000)) {
            try {
                // Double Check (락 획득 사이 다른 스레드가 캐싱했을 수 있음)
                cached = redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) return deserialize(cached);

                // DB 조회 + 캐싱
                Product product = productRepository.findById(productId)
                    .orElseThrow();
                redisTemplate.opsForValue()
                    .set(cacheKey, serialize(product),
                        Duration.ofMinutes(30));
                return product;
            } finally {
                lockService.releaseLock(lockKey, token);
            }
        }

        // 락 획득 실패 → 잠시 대기 후 재시도
        Thread.sleep(50);
        return getProductWithLock(productId);
    }


    // 해결책 2: TTL Jitter (만료 시간 분산)
    public void setWithJitter(String key, String value, long baseTtlSeconds) {
        long jitter = ThreadLocalRandom.current().nextLong(60);
        redisTemplate.opsForValue()
            .set(key, value, Duration.ofSeconds(baseTtlSeconds + jitter));
    }


    // 해결책 3: 논리적 만료 (Logical Expiration)
    public Product getWithLogicalExpiration(Long productId) {
        String cacheKey = "product:" + productId;
        String cached   = redisTemplate.opsForValue().get(cacheKey);

        if (cached != null) {
            CacheWrapper wrapper = deserialize(cached, CacheWrapper.class);

            // 만료됐으면 비동기로 갱신 (일단 구버전 반환)
            if (wrapper.isExpired()) {
                refreshAsync(productId);       // 비동기 갱신
                return wrapper.getData();      // 구버전 즉시 반환
            }
            return wrapper.getData();
        }

        return loadFromDb(productId);
    }

    @Async
    public void refreshAsync(Long productId) {
        Product product = productRepository.findById(productId)
            .orElseThrow();
        CacheWrapper wrapper = new CacheWrapper(
            product,
            LocalDateTime.now().plusMinutes(30)
        );
        redisTemplate.opsForValue()
            .set("product:" + productId, serialize(wrapper),
                Duration.ofMinutes(60));  // TTL은 길게 (논리적 만료로 관리)
    }
}

4. 🔴 Keyspace Notification (키 만료 이벤트)

키가 만료되거나 삭제될 때 이벤트 발행
→ 만료 시 자동 처리 로직 구현 가능
# redis.conf 설정
notify-keyspace-events "Ex"  # E: keyevent, x: expired
// 만료 이벤트 리스너
@Configuration
public class RedisKeyExpirationConfig {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
        RedisConnectionFactory connectionFactory,
        KeyExpirationEventListener listener
    ) {
        RedisMessageListenerContainer container =
            new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);

        // 만료 이벤트 구독
        container.addMessageListener(
            listener,
            new PatternTopic("__keyevent@0__:expired")
        );
        return container;
    }
}


@Component
@Slf4j
public class KeyExpirationEventListener implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expiredKey = message.toString();
        log.info("키 만료: {}", expiredKey);

        // 만료된 키에 따라 처리
        if (expiredKey.startsWith("session:")) {
            handleSessionExpiration(expiredKey);
        } else if (expiredKey.startsWith("lock:")) {
            handleLockExpiration(expiredKey);
        }
    }

    private void handleSessionExpiration(String key) {
        // 세션 만료 시 로그아웃 처리
        String userId = key.replace("session:", "");
        log.info("세션 만료 → 로그아웃 처리: userId={}", userId);
    }

    private void handleLockExpiration(String key) {
        // 락 만료 로그 기록
        log.warn("락 TTL 만료 (비정상 종료 가능성): {}", key);
    }
}

5. 🔴 Redis Streams (Kafka 유사 메시징)

Redis Pub/Sub:  At Most Once (유실 가능)
Redis Streams:  At Least Once (Kafka와 유사)
               → Consumer Group, Offset 관리
// Producer
@Service
@RequiredArgsConstructor
public class OrderEventProducer {

    private final StringRedisTemplate redisTemplate;

    public void publishOrder(Order order) {
        Map<String, String> message = Map.of(
            "orderId",  String.valueOf(order.getId()),
            "userId",   String.valueOf(order.getUserId()),
            "amount",   String.valueOf(order.getAmount()),
            "status",   order.getStatus()
        );

        redisTemplate.opsForStream()
            .add("order:stream", message);

        log.info("주문 이벤트 발행: {}", order.getId());
    }
}


// Consumer
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventConsumer {

    private final StringRedisTemplate redisTemplate;

    @PostConstruct
    public void init() {
        // Consumer Group 생성
        try {
            redisTemplate.opsForStream()
                .createGroup("order:stream", "order-group");
        } catch (Exception e) {
            // 이미 존재하면 무시
        }
    }

    @Scheduled(fixedDelay = 100)
    public void consume() {
        // Consumer Group에서 읽기 (At Least Once)
        List<MapRecord<String, Object, Object>> messages =
            redisTemplate.opsForStream().read(
                Consumer.from("order-group", "consumer-1"),
                StreamReadOptions.empty().count(10),
                StreamOffset.create("order:stream",
                    ReadOffset.lastConsumed())
            );

        if (messages == null) return;

        messages.forEach(message -> {
            try {
                processOrder(message.getValue());

                // 처리 완료 ACK (Kafka commitOffset과 동일)
                redisTemplate.opsForStream()
                    .acknowledge("order:stream", "order-group",
                        message.getId());

            } catch (Exception e) {
                log.error("주문 처리 실패: {}", message.getId(), e);
                // ACK 안 함 → 재처리 가능
            }
        });
    }
}

6. 🟡 Slow Log 분석

// Slow Log 조회 및 모니터링
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisSlowLogMonitor {

    private final StringRedisTemplate redisTemplate;

    @Scheduled(fixedDelay = 60_000)  // 1분마다
    public void checkSlowLog() {
        redisTemplate.execute((RedisCallback<Void>) connection -> {
            // Slow Log 조회 (10ms 이상)
            List<Object> slowLogs = (List<Object>)
                connection.execute("SLOWLOG", "GET".getBytes(), "10".getBytes());

            if (slowLogs != null && !slowLogs.isEmpty()) {
                log.warn("Slow Query 감지: {}개", slowLogs.size());
                // 알람 발송
            }
            return null;
        });
    }
}
# redis.conf
slowlog-log-slower-than 10000  # 10ms 이상
slowlog-max-len 128

# 확인
SLOWLOG GET 10
SLOWLOG LEN
SLOWLOG RESET

7. 🟡 Circuit Breaker (Redis 장애 대응)

// Redis 장애 시 서비스 보호
@Service
@RequiredArgsConstructor
@Slf4j
public class ResilientCacheService {

    private final StringRedisTemplate redisTemplate;
    private final ProductRepository   productRepository;

    // Resilience4j Circuit Breaker 적용
    @CircuitBreaker(name = "redis", fallbackMethod = "getFromDb")
    public Product getProduct(Long productId) {
        String cached = redisTemplate.opsForValue()
            .get("product:" + productId);

        if (cached != null) return deserialize(cached);
        return loadAndCache(productId);
    }

    // Redis 장애 시 폴백 → DB 직접 조회
    public Product getFromDb(Long productId, Exception e) {
        log.warn("Redis 장애 → DB Fallback: {}", e.getMessage());
        return productRepository.findById(productId).orElseThrow();
    }
}
# application.yml
resilience4j:
  circuitbreaker:
    instances:
      redis:
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
        sliding-window-size: 10

8. 🟡 Redis Pub/Sub

// Publisher
@Service
@RequiredArgsConstructor
public class NotificationPublisher {

    private final StringRedisTemplate redisTemplate;

    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message);
        // At Most Once (구독자 없으면 유실!)
    }
}


// Subscriber
@Configuration
public class PubSubConfig {

    @Bean
    public RedisMessageListenerContainer listenerContainer(
        RedisConnectionFactory factory,
        NotificationSubscriber subscriber
    ) {
        RedisMessageListenerContainer container =
            new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        container.addMessageListener(
            subscriber,
            new ChannelTopic("notifications")
        );
        return container;
    }
}

@Component
@Slf4j
public class NotificationSubscriber implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        log.info("알림 수신: {}", message.toString());
        // 처리 로직
    }
}

전체 학습 로드맵

┌─────────────────────────────────────────────────────────────┐
│                  Redis 학습 로드맵                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ✅ 완료                                                     │
│    Single Thread / Queue / Event Loop                       │
│    Lua Script / Pipeline / MULTI/EXEC                       │
│    Connection Pool / SessionCallback                        │
│    Eviction Policy / Persistence                            │
│    Master/Replica / Hash Slot                               │
│    Distributed Lock / WATCH                                 │
│                                                             │
│  🔴 다음 학습 (필수)                                         │
│    Hot Key / Big Key                                        │
│    Cache 전략 패턴                                          │
│    Cache Stampede                                           │
│    Keyspace Notification                                    │
│    Redis Streams                                            │
│                                                             │
│  🟡 그 다음 (운영)                                           │
│    Slow Log 분석                                            │
│    Memory 최적화                                            │
│    Circuit Breaker                                          │
│    Pub/Sub                                                  │
│                                                             │
│  🟢 상황별 학습                                              │
│    HyperLogLog / Bitmap / Geo                               │
│    무중단 마이그레이션                                        │
│    Sentinel 구성                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘