Redis Streams와 Eviction Policy
결론부터
noeviction = 좋은 선택이 아닙니다
이유:
noeviction = 메모리 꽉 차면 쓰기 에러 반환
→ 새 메시지 자체를 추가 못 함
→ 프로듀서 장애 발생
더 나은 선택: volatile-lru + TTL 없는 Stream
Eviction Policy별 Stream 영향
┌──────────────────┬────────────────────────────────────────────┐
│ Policy │ Stream 영향 │
├──────────────────┼────────────────────────────────────────────┤
│ noeviction │ 메모리 꽉 차면 쓰기 에러 │
│ │ → 새 메시지 추가 불가 ❌ │
├──────────────────┼────────────────────────────────────────────┤
│ allkeys-lru │ Stream 키 자체가 통째로 삭제 가능 │
│ allkeys-lfu │ → 메시지 전체 유실 위험 ❌ │
│ allkeys-random │ │
├──────────────────┼────────────────────────────────────────────┤
│ volatile-lru │ TTL 있는 키만 삭제 │
│ volatile-lfu │ Stream에 TTL 설정 안 하면 │
│ volatile-ttl │ → Stream 삭제 안 됨 ✅ │
│ volatile-random │ │
└──────────────────┴────────────────────────────────────────────┘
noeviction의 실제 문제
메모리 사용량: ████████████ 100% (꽉 참)
Producer: XADD order:stream * orderId 123
│
▼
Redis: "OOM command not allowed when used memory > maxmemory"
│
▼
Producer 에러 발생! 메시지 추가 불가
Consumer는 대기 중인데 새 메시지 못 받음
→ Stream 자체는 살아있지만 쓸 수 없는 상태
올바른 설계: 3중 방어
방어 1: volatile-lru + Stream에 TTL 없음
# redis.conf
maxmemory 4gb
maxmemory-policy volatile-lru # TTL 있는 키만 삭제
# Stream 키에는 TTL 설정 안 함
# → 메모리 부족해도 Stream 키는 보존
// ✅ Stream은 TTL 없이 생성
redisTemplate.opsForStream()
.add("order:stream", message);
// EXPIRE 설정 안 함!
// ✅ 일반 캐시는 TTL 있게 (volatile-lru 대상)
redisTemplate.opsForValue()
.set("product:1", data, Duration.ofMinutes(30));
// → 메모리 부족 시 이 캐시 키들이 먼저 삭제됨
방어 2: MAXLEN으로 Stream 크기 제한 (핵심!)
// ✅ MAXLEN으로 메시지 수 제한
@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())
);
redisTemplate.opsForStream()
.add(
MapRecord.create("order:stream", message)
.withStreamKey("order:stream"),
// MAXLEN 1000: 최대 1000개 메시지 유지
// ~ (tilde): approximate trimming (성능 최적화)
StreamRecords.mapBacked(message)
);
}
}
# MAXLEN 직접 명령
XADD order:stream MAXLEN ~ 1000 * orderId 123
# ~ (approximate): 정확히 1000개가 아닌 근사치로 자름
# → 성능 최적화 (내부 노드 단위로 자름)
# = (exact): 정확히 1000개
# → 성능 비용 있음
# 현재 스트림 크기 확인
XLEN order:stream
방어 3: AOF 영속성
# redis.conf
appendonly yes
appendfsync everysec # 1초마다 디스크에 기록
# 최대 1초치 유실 가능
# Redis 재시작 시 AOF로 Stream 복구
실제 운영 설정
# redis.conf 권장 설정
# ① 메모리 설정
maxmemory 4gb
# ② Stream은 보존, 캐시만 삭제
maxmemory-policy volatile-lru
# ③ 영속성
appendonly yes
appendfsync everysec
# ④ 메모리 여유 확보
maxmemory-samples 10 # LRU 샘플링 정확도
// application.yml
spring:
data:
redis:
host: localhost
port: 6379
Redis Streams vs Kafka 선택 기준
Redis Streams 적합:
✅ 메시지 유실 허용 가능한 경우
✅ 단일 서비스 내부 이벤트
✅ 실시간성 중요 (낮은 레이턴시)
✅ 인프라 단순화 (Kafka 없이)
✅ 메시지량이 적은 경우
Kafka 적합:
✅ 절대 유실 불가 (금융, 결제)
✅ 장기 보관 필요 (Replay)
✅ 대용량 메시지
✅ 여러 서비스 간 이벤트
✅ Exactly Once 필요
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ Redis Streams Eviction Policy 결론 │
├─────────────────────────────────────────────────────────────┤
│ │
│ noeviction: │
│ 메시지 유실은 막지만 │
│ 새 메시지 추가가 막힘 → 프로듀서 장애 ❌ │
│ │
│ 권장: volatile-lru │
│ Stream에 TTL 없음 → 삭제 안 됨 │
│ 캐시 키(TTL 있는 것)들이 먼저 삭제됨 │
│ │
│ 추가 방어: │
│ MAXLEN으로 Stream 크기 제한 (메모리 제어) │
│ AOF 영속성으로 재시작 시 복구 │
│ 메모리 여유분 충분히 확보 (80% 경보) │
│ │
│ 근본 해결: │
│ 메시지 절대 유실 불가 → Kafka 사용 │
│ Redis Streams = "가벼운 메시징" 용도 │
│ │
└─────────────────────────────────────────────────────────────┘
Lettuce 실무 관점 중요 사항
이미 학습한 Lettuce 내용
✅ Netty 기반 비동기 논블로킹
✅ Thread-safe (단일 연결 공유)
✅ Connection Pool 설정
✅ ReadFrom (Master/Replica)
✅ ClientOptions
✅ ElastiCache 설정
✅ SessionCallback과 ThreadLocal 바인딩
추가로 알아야 할 것들
1. Lettuce의 핵심 특성: Multiplexing
Jedis:
Thread 1 → Connection 1 (전용)
Thread 2 → Connection 2 (전용)
Thread 3 → Connection 3 (전용)
→ 동시 스레드 수 = 필요한 연결 수
Lettuce:
Thread 1 ──┐
Thread 2 ──┤──→ Connection 1개 (공유, Multiplexing)
Thread 3 ──┘
→ 여러 스레드가 하나의 연결 공유
어떻게 가능한가?
Thread 1: SET k1 ──┐
Thread 2: GET k2 ──┤──→ [Netty Channel] ──→ Redis
Thread 3: INCR k3──┘
명령에 고유 ID 부여
응답 도착 시 ID로 매핑
→ 각 Thread에 정확히 응답 전달
2. Pool 사용 vs 미사용 선택 기준
// ============================================
// Pool 없이 (기본값)
// ============================================
@Bean
public RedisConnectionFactory noPoolConnectionFactory() {
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofMillis(500))
.build();
// → 단일 Connection을 모든 스레드가 공유
// → 낮은/중간 동시성에서 충분
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("localhost", 6379),
clientConfig
);
}
// ============================================
// Pool 사용
// ============================================
@Bean
public RedisConnectionFactory poolConnectionFactory() {
GenericObjectPoolConfig<?> poolConfig =
new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(50);
poolConfig.setMaxIdle(20);
poolConfig.setMinIdle(5);
LettucePoolingClientConfiguration clientConfig =
LettucePoolingClientConfiguration.builder()
.poolConfig(poolConfig)
.commandTimeout(Duration.ofMillis(500))
.build();
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("localhost", 6379),
clientConfig
);
}
Pool 없이 써도 되는 경우:
✅ 일반적인 캐시 읽기/쓰기
✅ 단순 명령 (GET, SET, INCR)
✅ 동시성이 극단적이지 않은 경우
Pool 써야 하는 경우:
✅ MULTI/EXEC (SessionCallback)
✅ BLPOP 등 블로킹 명령
✅ pub/sub 전용 연결 필요 시
✅ 매우 높은 동시성 (수만 TPS)
3. 타임아웃 설정 (중요!)
@Bean
public RedisConnectionFactory connectionFactory() {
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofMillis(1000)) // TCP 연결 수립 시간
.keepAlive(true) // TCP Keep-Alive
.build();
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(socketOptions)
.disconnectedBehavior(
// REJECT: 연결 끊기면 즉시 에러 반환
// DEFAULT: 재연결될 때까지 큐에 쌓음 (위험!)
ClientOptions.DisconnectedBehavior.REJECT_COMMANDS
)
.build();
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofMillis(500)) // 명령 실행 타임아웃
.clientOptions(clientOptions)
.build();
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("localhost", 6379),
clientConfig
);
}
타임아웃 종류:
connectTimeout (1000ms 권장):
TCP 연결 수립 시간
초과 시 ConnectionException
commandTimeout (500ms 권장):
명령 전송 ~ 응답 수신 시간
초과 시 RedisCommandTimeoutException
주의:
commandTimeout > Redis 처리 시간
너무 짧으면 정상 응답도 에러 처리됨
4. 자동 재연결 (Auto Reconnect)
ClientOptions clientOptions = ClientOptions.builder()
.autoReconnect(true) // 기본값 true
.disconnectedBehavior(
ClientOptions.DisconnectedBehavior.REJECT_COMMANDS
// DEFAULT: 재연결 중 명령을 큐에 쌓음
// REJECT: 재연결 중 즉시 에러 반환
)
.build();
DEFAULT (큐잉) 방식의 위험:
Redis 30초 다운 후 재시작
│
▼
재연결 성공
│
▼
30초 동안 쌓인 명령 한 번에 실행!
→ Redis 순간 부하 폭발
→ 타임아웃 에러 폭발
✅ 권장: REJECT_COMMANDS
재연결 중 명령 즉시 에러 반환
→ 애플리케이션이 직접 retry 처리
→ 예측 가능한 동작
5. 연결 유효성 검사
GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
// 빌릴 때 PING으로 검증
poolConfig.setTestOnBorrow(true);
// 반납 시 PING으로 검증 (dirty connection 방지)
poolConfig.setTestOnReturn(true);
// 유휴 연결 주기적 검증
poolConfig.setTestWhileIdle(true);
poolConfig.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
// 유휴 연결 최소/최대 유지
poolConfig.setMinEvictableIdleTime(Duration.ofMinutes(1));
testOnBorrow = true 흐름:
Pool에서 연결 빌리기 요청
│
▼
PING 전송 → Redis → PONG
│
├── PONG 정상 → 연결 반환 ✅
└── 실패 → 연결 버리고 새 연결 생성
6. 연결 수 계산
공식:
필요한 연결 수 = 동시 요청 수 / 명령당 평균 처리 시간(ms) × 응답 시간(ms)
예시:
동시 요청: 1000 TPS
평균 처리: 1ms
Redis 응답: 1ms
→ Pool 없이도 1개 연결로 처리 가능 (Multiplexing)
Pool이 필요한 경우:
블로킹 명령 (BLPOP, BRPOP)
MULTI/EXEC (SessionCallback)
Pub/Sub 전용 연결
실무 권장:
maxTotal = (평균 응답시간 × TPS) / 1000 + 여유분
maxIdle = maxTotal / 2
minIdle = 10 (미리 연결 유지)
7. 연결 모니터링
@Component
@Slf4j
@RequiredArgsConstructor
public class LettuceConnectionMonitor {
private final RedisConnectionFactory connectionFactory;
@Scheduled(fixedDelay = 30_000)
public void monitorConnections() {
if (connectionFactory instanceof LettuceConnectionFactory factory) {
// 연결 상태 확인
try {
factory.getConnection().ping();
log.info("Redis 연결 정상");
} catch (Exception e) {
log.error("Redis 연결 이상: {}", e.getMessage());
// 알람 발송
}
}
}
}
# Redis CLI로 연결 확인
CLIENT LIST
# 결과:
# id=1 addr=10.0.0.1:51001 fd=8 name=my-app age=100 idle=0 ...
# id=2 addr=10.0.0.1:51002 fd=9 name=my-app age=100 idle=5 ...
# ↑
# idle 시간 확인
# 너무 길면 연결 정리 대상
8. Pub/Sub 전용 연결
// Pub/Sub은 별도 연결 필요!
// (구독 중인 연결은 다른 명령 실행 불가)
@Configuration
public class PubSubConfig {
// Pub/Sub 전용 Connection Factory (Pool 없이)
@Bean
public RedisConnectionFactory pubSubConnectionFactory() {
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofMillis(500))
.build();
// Pool 없이 단독 연결 사용
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("localhost", 6379),
clientConfig
);
}
@Bean
public RedisMessageListenerContainer listenerContainer(
@Qualifier("pubSubConnectionFactory")
RedisConnectionFactory factory
) {
RedisMessageListenerContainer container =
new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
// 일반 RedisTemplate과 연결 분리!
return container;
}
}
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ Lettuce 실무 핵심 요약 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Multiplexing: │
│ 단일 연결을 여러 스레드가 공유 가능 │
│ 일반 명령은 Pool 없이도 충분 │
│ │
│ Pool 필요한 경우: │
│ MULTI/EXEC, 블로킹 명령, Pub/Sub │
│ │
│ 타임아웃: │
│ connectTimeout: TCP 연결 (1000ms) │
│ commandTimeout: 명령 실행 (500ms) │
│ │
│ 재연결: │
│ REJECT_COMMANDS 권장 │
│ DEFAULT는 큐 폭발 위험 │
│ │
│ 연결 검증: │
│ testOnBorrow/Return으로 dirty connection 방지 │
│ │
│ Pub/Sub: │
│ 반드시 전용 연결 분리 │
│ │
└─────────────────────────────────────────────────────────────┘
Redis Streams 메시지 유실 여부
결론
Redis Pub/Sub = At Most Once → 무조건 유실 가능
Redis Streams = At Least Once 가능 → 설정에 따라 다름
Redis Pub/Sub vs Streams 차이
[Pub/Sub - 무조건 유실 가능]
Publisher → 메시지 발행
│
├── 구독자 없으면 → 즉시 사라짐
├── 구독자 연결 끊기면 → 사라짐
└── Redis 재시작 → 사라짐
저장 자체를 안 함 → At Most Once
[Streams - 저장됨]
Publisher → 메시지 발행 → Stream에 저장 (영구적)
│
├── Consumer가 없어도 보관
├── Consumer 연결 끊겨도 보관
└── ACK 전까지 PEL에 보관
Streams 메시지 유실이 발생하는 케이스
케이스 1: ACK 없이 Consumer 장애
Consumer: XREADGROUP으로 메시지 수신
│
└── 처리 중 Consumer 장애!
│
▼
메시지는 PEL(Pending Entry List)에 남음
→ ACK 안 된 메시지 보관
→ XAUTOCLAIM으로 재처리 가능 ✅
[PEL 동작 원리]
Stream: [msg1] [msg2] [msg3] [msg4] [msg5]
│ │
Consumer A가 수신 Consumer B가 수신
PEL (미확인 메시지 목록):
Consumer A: msg1 (30초 경과, 장애 의심)
Consumer B: msg3 (정상 처리 대기)
→ XAUTOCLAIM으로 msg1 다른 Consumer에 재할당
// PEL에서 오래된 메시지 재처리
@Scheduled(fixedDelay = 30_000)
public void reclaimPendingMessages() {
// 60초 이상 ACK 안 된 메시지 재할당
PendingMessagesOptions options = PendingMessagesOptions
.range()
.idleTime(Duration.ofSeconds(60))
.count(10);
// XAUTOCLAIM: 다른 Consumer에게 재할당
redisTemplate.opsForStream()
.claim(
"order:stream",
Consumer.from("order-group", "consumer-1"),
Duration.ofSeconds(60),
RecordId.of("0-0")
)
.forEach(message -> {
processOrder(message.getValue());
redisTemplate.opsForStream()
.acknowledge("order:stream", "order-group",
message.getId());
});
}
케이스 2: Redis 재시작 (AOF 없을 때)
AOF 없이:
Redis 재시작 → Stream 전체 사라짐 ❌
AOF everysec:
재시작 → 최대 1초치 유실 ⚠️
AOF always:
재시작 → 거의 유실 없음 ✅ (성능 비용)
# redis.conf
appendonly yes
appendfsync always # 가장 안전 (성능 저하)
# appendfsync everysec # 균형 (1초 유실 가능)
케이스 3: Master → Replica Failover
Master: 메시지 발행
│
└── (비동기 복제 중...)
│
Master 다운!
│
▼
Replica 승격 (새 Master)
│
▼
복제 안 된 메시지 유실 ❌
→ 비동기 복제 특성상 완전 방지 불가
→ Redis만으로는 Exactly Once 보장 불가
케이스 4: MAXLEN 트리밍
# MAXLEN 설정 시 오래된 메시지 자동 삭제
XADD order:stream MAXLEN ~ 1000 * orderId 123
# ↑
# 1000개 초과 시 오래된 메시지 삭제
# Consumer가 처리 못 한 상태에서 MAXLEN 초과
# → 오래된 미처리 메시지 삭제 → 유실 ❌
// MAXLEN 없이 무제한 (메모리 주의)
redisTemplate.opsForStream()
.add("order:stream", message);
// MAXLEN 있음 (메모리 제어, 유실 가능성)
StreamRecords.mapBacked(message)
.withStreamKey("order:stream");
// MAXLEN은 Consumer 처리 속도 고려해서 설정
At Least Once 구현 (완전한 코드)
// 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())
);
// Stream에 저장 (DB 같은 개념)
RecordId recordId = redisTemplate.opsForStream()
.add("order:stream", message);
log.info("메시지 발행: {}", recordId);
}
}
// Consumer (At Least Once)
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventConsumer {
private final StringRedisTemplate redisTemplate;
@PostConstruct
public void init() {
try {
redisTemplate.opsForStream().createGroup(
"order:stream",
ReadOffset.from("0"), // 처음부터 읽기
"order-group"
);
} catch (Exception ignored) {}
}
@Scheduled(fixedDelay = 100)
public void consume() {
List<MapRecord<String, Object, Object>> messages =
redisTemplate.opsForStream().read(
Consumer.from("order-group", "consumer-1"),
StreamReadOptions.empty().count(10).block(Duration.ofMillis(100)),
StreamOffset.create("order:stream", ReadOffset.lastConsumed())
);
if (messages == null || messages.isEmpty()) return;
messages.forEach(message -> {
try {
// 비즈니스 로직 처리
processOrder(message.getValue());
// 처리 완료 ACK
redisTemplate.opsForStream()
.acknowledge("order:stream", "order-group",
message.getId());
} catch (Exception e) {
// ACK 안 함 → PEL에 남아서 재처리 가능
log.error("처리 실패 (재처리 예정): {}", message.getId(), e);
}
});
}
// PEL 재처리 (장애 복구)
@Scheduled(fixedDelay = 60_000)
public void recoverPendingMessages() {
PendingMessages pending = redisTemplate.opsForStream()
.pending(
"order:stream",
Consumer.from("order-group", "consumer-1"),
Range.unbounded(),
10L
);
pending.forEach(msg -> {
// 60초 이상 처리 안 된 메시지 재시도
if (msg.getElapsedTimeSinceLastDelivery()
.compareTo(Duration.ofSeconds(60)) > 0) {
List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream()
.range("order:stream",
Range.closed(
msg.getIdAsString(),
msg.getIdAsString()
)
);
records.forEach(record -> {
try {
processOrder(record.getValue());
redisTemplate.opsForStream()
.acknowledge("order:stream", "order-group",
record.getId());
} catch (Exception e) {
log.error("재처리 실패: {}", record.getId(), e);
}
});
}
});
}
}
Kafka vs Redis Streams 유실 가능성 비교
┌──────────────────┬─────────────────────┬─────────────────────┐
│ │ Kafka │ Redis Streams │
├──────────────────┼─────────────────────┼─────────────────────┤
│ 기본 저장 │ 디스크 (영구) │ 메모리 (휘발) │
│ 복제 │ 동기 (설정 시) │ 비동기 │
│ Failover 유실 │ 거의 없음 │ 가능성 있음 │
│ 재시작 유실 │ 없음 │ AOF 설정에 따라 │
│ At Least Once │ ✅ 완전 보장 │ ✅ 설정 시 가능 │
│ Exactly Once │ ✅ 가능 │ ❌ 불가 │
│ 장기 보관 │ ✅ 수일~수주 │ ❌ MAXLEN 제한 │
│ Replay │ ✅ Offset으로 │ ✅ 제한적 │
└──────────────────┴─────────────────────┴─────────────────────┘
결론
┌─────────────────────────────────────────────────────────────┐
│ Redis Streams 메시지 유실 결론 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 무조건 유실되는 구조는 아님 │
│ │
│ At Least Once 가능한 조건: │
│ ✅ Consumer Group + ACK 패턴 │
│ ✅ PEL + 재처리 로직 │
│ ✅ AOF 영속성 설정 │
│ ✅ volatile-lru (Stream에 TTL 없음) │
│ ✅ 적절한 MAXLEN 설정 │
│ │
│ 여전히 유실 가능한 상황: │
│ ❌ Master → Replica Failover (비동기 복제 lag) │
│ ❌ AOF everysec 설정 시 1초치 │
│ ❌ MAXLEN 초과 시 오래된 메시지 │
│ │
│ 선택 기준: │
│ 절대 유실 불가 (결제, 금융) → Kafka │
│ 적당한 신뢰성 + 단순함 → Redis Streams │
│ │
└─────────────────────────────────────────────────────────────┘
volatile-lru 네이밍 이유
단어 분리
volatile - lru
↑ ↑
"휘발성" "알고리즘"
volatile의 의미
프로그래밍에서 volatile = "언제든 사라질 수 있는"
Redis에서 volatile = "TTL이 설정된 키"
TTL이 있다 = 만료 시간이 정해져 있다
= 언젠가는 사라질 키
= 휘발성 키
TTL 유무로 구분
volatile (휘발성):
SET key value EX 3600 ← TTL 있음 = volatile
→ 언젠가 사라질 키
non-volatile (비휘발성):
SET key value ← TTL 없음 = non-volatile (persistent)
→ 영구적으로 남는 키
전체 Policy 네이밍 규칙
{대상} - {알고리즘}
volatile - lru : TTL 있는 키 중 LRU
volatile - lfu : TTL 있는 키 중 LFU
volatile - ttl : TTL 있는 키 중 만료 임박한 것
volatile - random : TTL 있는 키 중 랜덤
allkeys - lru : 전체 키 중 LRU
allkeys - lfu : 전체 키 중 LFU
allkeys - random : 전체 키 중 랜덤
noeviction: 삭제 안 함
LRU 알고리즘
LRU = Least Recently Used
가장 오래 전에 사용된 것 먼저 삭제
[접근 순서]
key1 → key2 → key3 → key1 → key4
LRU 순서 (오래된 것부터):
key2 (가장 오래 전 사용) → 삭제 대상
key3
key1
key4 (가장 최근 사용)
결론
┌─────────────────────────────────────────────────────────────┐
│ │
│ volatile = TTL이 설정된 키 (언젠가 사라질 휘발성) │
│ lru = 가장 오래 전에 사용된 것 먼저 삭제 │
│ │
│ volatile-lru = TTL 있는 키 중에서 │
│ 가장 오래 전에 사용된 것 먼저 삭제 │
│ │
└─────────────────────────────────────────────────────────────┘
Eviction Policy 선택 정리
용도별 선택 기준
캐시 전용 Redis:
→ allkeys-lru 또는 allkeys-lfu
캐시 + 중요 데이터 혼용:
→ volatile-lru
Redis Streams 사용:
→ volatile-lru (Stream에 TTL 없음)
메시지 절대 유실 불가:
→ Redis 부적합 → Kafka 사용
volatile-lru가 적합한 이유
[volatile-lru 동작]
메모리 부족 시:
TTL 있는 키 (캐시) ← 삭제 대상 ✅
"product:1" EX 1800
"user:1" EX 3600
TTL 없는 키 (Stream) ← 보존 ✅
"order:stream"
"lock:order:1"
allkeys-lru를 쓰면:
메모리 부족 시
Stream 키가 통째로 삭제될 수 있음 ❌
→ 메시지 전체 유실
실무 설정
# 캐시 전용 Redis
maxmemory-policy allkeys-lru
# 캐시 + Stream 혼용 Redis
maxmemory-policy volatile-lru
# 중요 데이터만 (캐시 없음)
maxmemory-policy noeviction
// volatile-lru 환경에서의 규칙
// ✅ 캐시 → TTL 반드시 설정 (삭제 대상)
redisTemplate.opsForValue()
.set("product:1", data, Duration.ofMinutes(30));
// ✅ Stream → TTL 없음 (보존 대상)
redisTemplate.opsForStream()
.add("order:stream", message);
// ✅ 분산락 → TTL 반드시 설정 (삭제 대상 허용)
redisTemplate.opsForValue()
.setIfAbsent("lock:order:1", token, Duration.ofSeconds(30));
결론
┌─────────────────────────────────────────────────────────────┐
│ │
│ Stream 사용 시 → volatile-lru 권장 │
│ │
│ 이유: │
│ TTL 없는 Stream → 메모리 부족해도 보존 │
│ TTL 있는 캐시 → 메모리 부족 시 먼저 삭제 │
│ │
│ 함께 필요한 설정: │
│ MAXLEN으로 Stream 크기 제한 │
│ AOF 영속성 설정 │
│ 메모리 여유 충분히 확보 (80% 알람) │
│ │
└─────────────────────────────────────────────────────────────┘
volatile-lru 동작 예시
기본 설정
# redis.conf
maxmemory 100mb
maxmemory-policy volatile-lru
시나리오: 메모리가 꽉 찰 때
# 현재 Redis에 저장된 키들
# TTL 있는 키들 (volatile - 삭제 대상)
SET product:1 "데이터" EX 1800 # 마지막 접근: 10분 전
SET product:2 "데이터" EX 1800 # 마지막 접근: 5분 전
SET product:3 "데이터" EX 1800 # 마지막 접근: 1분 전
SET user:1 "데이터" EX 3600 # 마지막 접근: 30분 전
SET user:2 "데이터" EX 3600 # 마지막 접근: 2분 전
# TTL 없는 키들 (non-volatile - 삭제 안 됨)
SET order:stream "스트림" # TTL 없음
SET config:app "설정" # TTL 없음
메모리 꽉 찼을 때 동작
[현재 메모리 상태]
████████████████████████ 100% (꽉 참!)
새 데이터 저장 시도:
SET product:100 "새 데이터" EX 1800
│
▼
Redis: "메모리 부족! LRU로 삭제 대상 선정"
│
▼
TTL 있는 키들 중 마지막 접근 시간 확인:
user:1 → 30분 전 접근 ← 가장 오래됨
product:1 → 10분 전 접근
product:2 → 5분 전 접근
user:2 → 2분 전 접근
product:3 → 1분 전 접근 ← 가장 최근
│
▼
user:1 삭제! (가장 오래 전에 접근)
│
▼
product:100 저장 성공
TTL 없는 키 (order:stream, config:app)
→ 건드리지 않음 ✅
단계별 삭제 흐름
초기 상태:
┌────────────────────────────────────────────┐
│ user:1 (TTL, 30분 전 접근) ← 삭제 1순위│
│ product:1 (TTL, 10분 전 접근) │
│ product:2 (TTL, 5분 전 접근) │
│ user:2 (TTL, 2분 전 접근) │
│ product:3 (TTL, 1분 전 접근) │
│ order:stream (TTL 없음) ← 삭제 안 됨│
│ config:app (TTL 없음) ← 삭제 안 됨│
└────────────────────────────────────────────┘
1번째 메모리 부족:
user:1 삭제 (30분 전 접근)
2번째 메모리 부족:
product:1 삭제 (10분 전 접근)
3번째 메모리 부족:
product:2 삭제 (5분 전 접근)
...계속
최악의 경우:
TTL 있는 키 전부 삭제됨
TTL 없는 키는 절대 삭제 안 됨
새 키 저장 시도
│
▼
"OOM - 삭제할 volatile 키 없음" 에러!
Java 코드로 확인
@SpringBootTest
class VolatileLruTest {
@Autowired StringRedisTemplate redisTemplate;
@Test
void volatileLruEvictionTest() {
// TTL 있는 키 (삭제 대상)
redisTemplate.opsForValue()
.set("product:1", "data1", Duration.ofMinutes(30));
redisTemplate.opsForValue()
.set("product:2", "data2", Duration.ofMinutes(30));
redisTemplate.opsForValue()
.set("user:1", "data3", Duration.ofHours(1));
// TTL 없는 키 (보존 대상)
redisTemplate.opsForValue()
.set("order:stream", "stream_data"); // TTL 없음
redisTemplate.opsForValue()
.set("config:app", "config_data"); // TTL 없음
// product:2 최근 접근 (LRU 뒤로 이동)
redisTemplate.opsForValue().get("product:2");
// product:1은 접근 안 함 (LRU 앞쪽 = 삭제 우선순위 높음)
// 메모리 꽉 찰 때:
// 1순위 삭제: product:1 (가장 오래 전 접근)
// 2순위 삭제: user:1
// 3순위 삭제: product:2
// 절대 삭제 안 됨: order:stream, config:app
}
}
실무에서 주의할 점
// ============================================
// ❌ 잘못된 패턴
// ============================================
// 중요한 데이터인데 TTL 설정 → 삭제될 수 있음!
redisTemplate.opsForValue()
.set("order:stream", // 중요 Stream 키인데
"data",
Duration.ofHours(24)); // TTL 설정 → volatile-lru 대상!
// ============================================
// ✅ 올바른 패턴
// ============================================
// 캐시 (삭제돼도 DB에서 재조회 가능) → TTL 설정
redisTemplate.opsForValue()
.set("product:1", data, Duration.ofMinutes(30));
// 중요 데이터 (Stream, 설정) → TTL 없음
redisTemplate.opsForStream()
.add("order:stream", message); // TTL 없음 → 절대 삭제 안 됨
전체 흐름 요약
┌─────────────────────────────────────────────────────────────┐
│ volatile-lru 삭제 흐름 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 메모리 부족 감지 │
│ │ │
│ ▼ │
│ TTL 있는 키만 후보로 선정 │
│ │ │
│ ▼ │
│ 후보 중 LRU 샘플링 │
│ (maxmemory-samples 수만큼 랜덤 샘플) │
│ │ │
│ ▼ │
│ 가장 오래 전에 접근한 키 삭제 │
│ │ │
│ ▼ │
│ 메모리 확보 완료 → 새 데이터 저장 │
│ │
│ TTL 없는 키 → 이 과정에서 절대 후보로 선정 안 됨 │
│ │
└─────────────────────────────────────────────────────────────┘
noeviction 메모리 꽉 찰 때 발생하는 에러
Redis 서버에서 반환하는 에러
# Redis CLI에서 직접 확인
SET key "value"
# 응답:
(error) OOM command not allowed when used memory > 'maxmemory'.
Use 'noeviction' to return errors instead of evicting keys.
Spring Boot에서 발생하는 예외
// noeviction + 메모리 꽉 찼을 때
redisTemplate.opsForValue().set("key", "value");
// 발생하는 예외 체인:
RedisSystemException
└── caused by: io.lettuce.core.RedisCommandExecutionException
└── message: "OOM command not allowed when used memory > maxmemory"
실제 예외 처리 코드
@Service
@RequiredArgsConstructor
@Slf4j
public class CacheService {
private final StringRedisTemplate redisTemplate;
public void saveData(String key, String value) {
try {
redisTemplate.opsForValue()
.set(key, value, Duration.ofMinutes(30));
} catch (RedisSystemException e) {
// OOM 에러 감지
if (isOomError(e)) {
log.error("Redis 메모리 부족 (noeviction) - key: {}", key);
// 알람 발송
// DB에만 저장하고 캐시 포기
throw new CacheUnavailableException("Redis 메모리 부족", e);
}
throw e;
} catch (RedisConnectionFailureException e) {
log.error("Redis 연결 실패 - key: {}", key);
throw e;
}
}
private boolean isOomError(RedisSystemException e) {
return e.getCause() != null &&
e.getCause().getMessage() != null &&
e.getCause().getMessage().contains("OOM");
}
}
읽기는 가능, 쓰기만 에러
# noeviction 메모리 꽉 찬 상태
GET key1 → "value1" ✅ 읽기는 정상
GET key2 → "value2" ✅ 읽기는 정상
SET key3 "v3" → OOM 에러 ❌
LPUSH list "v" → OOM 에러 ❌
INCR counter → OOM 에러 ❌
XADD stream * k v → OOM 에러 ❌ ← Stream도 못 씀!
에러 발생 명령 범위
❌ 메모리를 새로 사용하는 모든 쓰기 명령:
SET, MSET, SETEX
LPUSH, RPUSH, SADD, ZADD, HSET
INCR, INCRBY, APPEND
XADD (Stream)
✅ 에러 안 나는 명령:
GET, MGET, LRANGE, SMEMBERS
DEL (메모리 해제)
EXPIRE (메타데이터만 변경)
TTL, TYPE, EXISTS
Circuit Breaker 연동
@Service
@RequiredArgsConstructor
@Slf4j
public class ResilientCacheService {
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
@CircuitBreaker(name = "redis", fallbackMethod = "fallback")
public void saveCache(String key, String value) {
try {
redisTemplate.opsForValue()
.set(key, value, Duration.ofMinutes(30));
} catch (RedisSystemException e) {
if (isOomError(e)) {
// OOM은 Circuit Breaker 오픈 트리거
throw new RedisOomException("Redis OOM", e);
}
throw e;
}
}
// Circuit Breaker Open 시 폴백
public void fallback(String key, String value, Exception e) {
log.warn("Redis 불가 → 캐시 건너뜀: {}", e.getMessage());
// 캐시 없이 서비스 계속 (DB만 사용)
}
private boolean isOomError(RedisSystemException e) {
return e.getCause() != null &&
e.getCause().getMessage() != null &&
e.getCause().getMessage().contains("OOM");
}
}
결론
┌─────────────────────────────────────────────────────────────┐
│ noeviction 에러 정리 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Redis 반환 에러: │
│ "OOM command not allowed │
│ when used memory > maxmemory" │
│ │
│ Spring 예외: │
│ RedisSystemException │
│ └── RedisCommandExecutionException (OOM) │
│ │
│ 읽기: ✅ 정상 │
│ 쓰기: ❌ OOM 에러 │
│ │
│ 대응: │
│ OOM 에러 감지 → 알람 발송 │
│ Circuit Breaker로 Redis 우회 │
│ DB 직접 사용 (캐시 포기) │
│ │
└─────────────────────────────────────────────────────────────┘
noeviction 위험성 + Redis 샤딩
noeviction이 위험한 이유
일반 eviction policy:
메모리 꽉 참 → 오래된 캐시 삭제 → 계속 동작
noeviction:
메모리 꽉 참 → 쓰기 전체 에러
→ 캐시 저장 불가
→ 세션 저장 불가
→ Stream 메시지 추가 불가
→ 서비스 장애로 이어질 수 있음
→ 사실상 프로덕션 캐시에 noeviction은 금지
→ 중요 데이터 전용 Redis에만 제한적 사용
Redis 샤딩 = ✅ 됩니다
Redis 샤딩 방법 3가지:
1. Redis Cluster (공식 내장 샤딩) ← 가장 많이 사용
2. Client-side Sharding ← 애플리케이션에서 직접
3. Proxy 기반 샤딩 ← Twemproxy 등
1. Redis Cluster (내장 샤딩)
16384개의 Hash Slot을 여러 노드에 분산
Node A: slot 0 ~ 5460
Node B: slot 5461 ~ 10922
Node C: slot 10923 ~ 16383
키 → CRC16(키) % 16384 → 슬롯 번호 → 해당 노드
예:
"user:1" → slot 5649 → Node A
"product:1" → slot 9883 → Node B
"order:1" → slot 4396 → Node A
[Redis Cluster 구조]
┌─────────────────────────────────────┐
│ Redis Cluster │
│ │
│ Node A (Master) + Node A (Replica) │
│ slot 0 ~ 5460 │
│ │
│ Node B (Master) + Node B (Replica) │
│ slot 5461 ~ 10922 │
│ │
│ Node C (Master) + Node C (Replica) │
│ slot 10923 ~ 16383 │
└─────────────────────────────────────┘
장점:
✅ 자동 샤딩 (데이터 분산)
✅ 자동 Failover
✅ 수평 확장 (노드 추가)
단점:
❌ Lua Script, MULTI/EXEC 제약
(같은 슬롯 키만 가능)
❌ 운영 복잡도 증가
// Spring Boot Cluster 설정
@Bean
public RedisConnectionFactory clusterConnectionFactory() {
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration(
List.of(
"node-a:6379",
"node-b:6379",
"node-c:6379"
)
);
clusterConfig.setPassword(RedisPassword.of("password"));
clusterConfig.setMaxRedirects(3);
// 클러스터 토폴로지 자동 갱신
ClusterTopologyRefreshOptions topologyRefresh =
ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build();
ClusterClientOptions clientOptions =
ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefresh)
.autoReconnect(true)
.build();
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
2. Client-side Sharding
// 애플리케이션에서 직접 노드 선택
@Component
public class ClientSideSharding {
// 노드별 RedisTemplate
private final List<StringRedisTemplate> shards;
public ClientSideSharding(
StringRedisTemplate shard1,
StringRedisTemplate shard2,
StringRedisTemplate shard3
) {
this.shards = List.of(shard1, shard2, shard3);
}
// 키 → 노드 결정
private StringRedisTemplate getShard(String key) {
int index = Math.abs(key.hashCode()) % shards.size();
return shards.get(index);
}
public void set(String key, String value) {
getShard(key).opsForValue().set(key, value);
}
public String get(String key) {
return getShard(key).opsForValue().get(key);
}
}
단점:
❌ 노드 추가 시 키 재분배 복잡
❌ 애플리케이션 코드 복잡도 증가
→ 거의 사용 안 함
3. AWS ElastiCache Cluster Mode
# Cluster Mode Enabled
spring:
data:
redis:
cluster:
nodes:
- myredis.xxxxx.clustercfg.use1.cache.amazonaws.com:6379
max-redirects: 3
ElastiCache Cluster Mode:
내부적으로 Redis Cluster 사용
AWS가 노드 관리 자동화
샤딩 + 복제 + Failover 자동
Cluster Mode Disabled: 샤딩 없음 (단일 샤드)
Cluster Mode Enabled: 샤딩 있음 (다중 샤드)
Cluster 모드에서 Lua/MULTI/EXEC 주의
# ❌ 다른 슬롯의 키 접근 → 에러
EVAL "redis.call('GET', KEYS[1])
redis.call('GET', KEYS[2])" 2 user:1 product:1
# → CROSSSLOT Keys in request don't hash to the same slot
# ✅ Hash Tag로 같은 슬롯 강제
SET {user:1}:profile "data"
SET {user:1}:session "data"
SET {user:1}:cart "data"
# {user:1} 부분으로 슬롯 결정 → 모두 같은 노드
// Hash Tag 사용
String userId = "1";
String profileKey = "{user:" + userId + "}:profile"; // 같은 슬롯
String sessionKey = "{user:" + userId + "}:session"; // 같은 슬롯
String cartKey = "{user:" + userId + "}:cart"; // 같은 슬롯
// 이제 MULTI/EXEC, Lua 사용 가능
redisTemplate.execute(new SessionCallback<List<Object>>() {
public List<Object> execute(RedisOperations ops) {
ops.multi();
ops.delete(profileKey);
ops.delete(sessionKey);
ops.delete(cartKey);
return ops.exec();
}
});
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ │
│ noeviction: │
│ 메모리 꽉 차면 쓰기 에러 │
│ 프로덕션 캐시에 사용 금지 │
│ 중요 데이터 전용에만 제한적 사용 │
│ │
│ Redis 샤딩: │
│ ✅ Redis Cluster로 공식 지원 │
│ 16384 Hash Slot을 여러 노드에 분산 │
│ AWS = ElastiCache Cluster Mode Enabled │
│ │
│ Cluster 주의사항: │
│ Lua / MULTI/EXEC = 같은 슬롯 키만 가능 │
│ Hash Tag {}로 같은 슬롯 강제 지정 │
│ │
└─────────────────────────────────────────────────────────────┘
Hash Tag {} 로 Hash Slot 고정
핵심 원리
Redis Cluster 슬롯 계산:
일반 키: CRC16("user:1:profile") % 16384 → 슬롯 결정
Hash Tag: CRC16("user:1") % 16384 → {} 안의 내용으로만 결정
↑
{} 안의 내용만 사용
같은 슬롯 = 같은 노드
{user:1}:profile ─┐
{user:1}:session ─┤── CRC16("user:1") → slot 5649 → Node A
{user:1}:cart ─┘
{user:2}:profile ─┐
{user:2}:session ─┤── CRC16("user:2") → slot 1150 → Node B
{user:2}:cart ─┘
특정 노드에 고정되는 것은 아님
⚠️ 정확히는:
"특정 노드에 고정" ❌
"같은 슬롯에 고정" ✅
같은 슬롯 → 같은 노드에 배치
→ 결과적으로 같은 노드에 있게 됨
노드가 추가/제거되면 슬롯이 재분배됨
→ 슬롯은 같지만 노드는 바뀔 수 있음
Hash Tag 없을 때 문제
# ❌ Hash Tag 없이
SET user:1:profile "data" # slot 4512 → Node A
SET user:1:session "data" # slot 6743 → Node B ← 다른 노드!
SET user:1:cart "data" # slot 9821 → Node C ← 또 다른 노드!
# MULTI/EXEC 시도
MULTI
DEL user:1:profile # Node A
DEL user:1:session # Node B ← 다른 노드!
DEL user:1:cart # Node C ← 다른 노드!
EXEC
# → CROSSSLOT 에러!
Hash Tag 사용으로 해결
# ✅ Hash Tag 사용
SET {user:1}:profile "data" # CRC16("user:1") → slot 5649 → Node A
SET {user:1}:session "data" # CRC16("user:1") → slot 5649 → Node A
SET {user:1}:cart "data" # CRC16("user:1") → slot 5649 → Node A
# ↑ 모두 같은 슬롯!
# MULTI/EXEC 가능!
MULTI
DEL {user:1}:profile
DEL {user:1}:session
DEL {user:1}:cart
EXEC
# → 성공! (모두 같은 노드)
# Lua Script도 가능!
EVAL "redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
redis.call('DEL', KEYS[3])" 3
{user:1}:profile
{user:1}:session
{user:1}:cart
Java 코드
@Service
@RequiredArgsConstructor
public class UserCacheService {
private final StringRedisTemplate redisTemplate;
// Hash Tag로 키 생성
private String profileKey(Long userId) {
return "{user:" + userId + "}:profile";
}
private String sessionKey(Long userId) {
return "{user:" + userId + "}:session";
}
private String cartKey(Long userId) {
return "{user:" + userId + "}:cart";
}
// 모두 같은 슬롯 → MULTI/EXEC 가능
public void clearUserData(Long userId) {
redisTemplate.execute(new SessionCallback<List<Object>>() {
@Override
public List<Object> execute(RedisOperations ops) {
ops.multi();
ops.delete(profileKey(userId)); // 같은 슬롯
ops.delete(sessionKey(userId)); // 같은 슬롯
ops.delete(cartKey(userId)); // 같은 슬롯
return ops.exec(); // ✅ 성공
}
});
}
// Lua Script도 가능
private static final String CLEAR_USER_SCRIPT = """
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
redis.call('DEL', KEYS[3])
return 1
""";
public void clearWithLua(Long userId) {
redisTemplate.execute(
new DefaultRedisScript<>(CLEAR_USER_SCRIPT, Long.class),
List.of(
profileKey(userId),
sessionKey(userId),
cartKey(userId)
)
);
}
}
{} 위치에 따른 슬롯 결정
# {} 안의 내용으로 슬롯 결정
{user:1}:profile → CRC16("user:1")
{user:1}:session → CRC16("user:1") # 같은 슬롯 ✅
user:{1}:profile → CRC16("1")
user:{1}:session → CRC16("1") # 같은 슬롯 ✅
{user}:1:profile → CRC16("user")
{user}:2:profile → CRC16("user") # 같은 슬롯 (모든 유저가 같은 노드)
# ← 핫키 위험! ⚠️
# {} 비어있으면 전체 키로 계산
{}user:1 → CRC16("{}user:1") # {} 무시
주의: {} 범위 설계가 중요
너무 넓은 범위 → 핫키(Hot Key) 문제
# ❌ 모든 유저를 같은 슬롯에 → 특정 노드 과부하
{service}:user:1
{service}:user:2
{service}:user:3
...
{service}:user:100000
→ 전부 같은 노드에 집중!
# ✅ 유저별로 슬롯 분산
{user:1}:profile → 슬롯 5649 → Node A
{user:2}:profile → 슬롯 1150 → Node B
{user:3}:profile → 슬롯 8271 → Node C
→ 자연스럽게 분산됨
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ Hash Tag {} 핵심 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 목적: │
│ 관련 키들을 같은 슬롯 → 같은 노드에 배치 │
│ │
│ 효과: │
│ Lua Script 사용 가능 │
│ MULTI/EXEC 사용 가능 │
│ MGET/MSET 사용 가능 │
│ │
│ 정확한 의미: │
│ "특정 노드 고정" ❌ │
│ "같은 슬롯 고정" ✅ │
│ → 같은 슬롯이면 항상 같은 노드에 배치됨 │
│ │
│ 설계 주의: │
│ {} 범위가 너무 넓으면 핫키 문제 │
│ 유저ID, 주문ID 단위로 적절히 분산 │
│ │
└─────────────────────────────────────────────────────────────┘
Production Redis: Cluster vs Non-Cluster
결론
대부분의 서비스: Non-Cluster (Sentinel) 로 시작
트래픽/데이터 증가 시: Cluster로 전환
Cluster가 필수인 경우는 생각보다 많지 않음
실제 사용 패턴
스타트업 / 중소 서비스:
Non-Cluster (Primary + Replica 1~2개)
→ Sentinel로 자동 Failover
→ 충분히 처리 가능
중대형 서비스:
Non-Cluster로 시작
→ 메모리/트래픽 한계 도달 시 Cluster 전환
→ 또는 Redis 역할별 인스턴스 분리
대형 서비스 (카카오, 네이버급):
Cluster Mode
→ 수십~수백 GB 데이터
→ 수십만 TPS
Non-Cluster (Sentinel) 구성
[일반적인 Production 구성]
Client
│
▼
┌───────────────┐
│ Sentinel │ ← Failover 자동 감지
│ (3개 권장) │
└───────────────┘
│
┌───────┴────────┐
│ │
▼ ▼
Primary Replica 1
(쓰기) (읽기)
Replica 2
(읽기)
장점:
✅ 운영 단순
✅ Lua/MULTI/EXEC 제약 없음
✅ Hash Tag 불필요
✅ 설정 간단
단점:
❌ 메모리 = Primary 1대 한계
❌ 쓰기 = Primary 1대 한계
Cluster 구성
[Cluster 구성]
Client
│
▼
┌──────────────────────────┐
│ Redis Cluster │
│ │
│ Node A: slot 0~5460 │
│ Node B: slot 5461~10922 │
│ Node C: slot 10923~16383│
│ │
│ 각 노드마다 Replica 보유 │
└──────────────────────────┘
장점:
✅ 수평 확장 (노드 추가)
✅ 대용량 데이터 처리
✅ 쓰기 부하 분산
단점:
❌ Lua/MULTI/EXEC 제약
❌ Hash Tag 필수 관리
❌ 운영 복잡도 증가
❌ 비용 증가
AWS ElastiCache 기준
Cluster Mode Disabled (Non-Cluster):
Primary 1개 + Replica N개
Reader Endpoint로 읽기 부하 분산
→ 대부분의 서비스에서 충분
Cluster Mode Enabled (Cluster):
최소 노드 6개 (Master 3 + Replica 3)
→ 비용 최소 3배 이상
→ 정말 필요한 경우에만
Non-Cluster로 충분한 이유
Redis 단일 노드 처리 능력:
메모리: 수백 GB (EC2 r6g.16xlarge = 512GB RAM)
TPS: 초당 100,000+ 명령 처리
일반적인 서비스:
메모리 10~20GB 이내
TPS 1만 이하
→ Non-Cluster로 충분
Cluster가 필요한 시점:
메모리 > 수십 GB (단일 노드 한계)
쓰기 TPS > 수만 (Primary 한계)
무중단 확장 필요
역할별 인스턴스 분리 (현실적 대안)
Cluster 대신 역할별로 분리
Redis 1: 캐시 전용
maxmemory-policy allkeys-lru
Redis 2: 세션 전용
maxmemory-policy volatile-lru
Redis 3: 분산락 전용
maxmemory-policy noeviction
Redis 4: Stream/Queue 전용
maxmemory-policy volatile-lru
→ Cluster 없이도 수평 확장 효과
→ 운영 단순, 장애 격리
선택 기준 플로우
현재 데이터 크기 > 단일 노드 메모리 한계?
│
├── YES → Cluster Mode 고려
│
└── NO
│
쓰기 TPS > 단일 노드 한계?
│
├── YES → Cluster Mode 고려
│
└── NO
│
무중단 확장이 자주 필요?
│
├── YES → Cluster Mode
└── NO → Non-Cluster (Sentinel)
역할별 인스턴스 분리로 충분
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ │
│ 현실: │
│ 대부분의 서비스 → Non-Cluster로 충분 │
│ Cluster는 정말 필요할 때 도입 │
│ │
│ Non-Cluster 선택 이유: │
│ 운영 단순 │
│ Lua/MULTI/EXEC 제약 없음 │
│ 비용 효율 │
│ 역할별 인스턴스 분리로 확장 가능 │
│ │
│ Cluster 선택 시점: │
│ 단일 노드 메모리 한계 도달 │
│ 쓰기 TPS 한계 도달 │
│ 무중단 수평 확장 필수 │
│ │
│ AWS ElastiCache: │
│ 대부분 Cluster Mode Disabled 사용 │
│ Cluster Mode Enabled = 비용 3배 + 운영 복잡 │
│ │
└─────────────────────────────────────────────────────────────┘
Cluster Mode Eviction Policy 설정
Cluster Mode의 핵심 차이
Non-Cluster:
Redis 1대 → maxmemory 설정 1개
Cluster Mode:
Node A, B, C 각각 독립적으로 maxmemory 관리
→ 각 노드가 독립적으로 Eviction 실행
→ 노드별로 메모리 상황이 다름
각 노드가 독립적으로 Eviction
[Cluster 상태]
Node A: ████████░░ 80% 사용 ← 곧 Eviction 발생
Node B: ████░░░░░░ 40% 사용 ← 여유 있음
Node C: ██████░░░░ 60% 사용 ← 보통
Node A가 꽉 차면:
Node A에 저장된 키들만 Eviction
Node B, C는 영향 없음
→ 노드별로 독립적으로 동작
설정 원칙: 모든 노드 동일하게
# ❌ 노드마다 다른 정책 (위험)
Node A: maxmemory-policy allkeys-lru
Node B: maxmemory-policy volatile-lru ← 다름!
Node C: maxmemory-policy noeviction ← 다름!
# 같은 키라도 어느 슬롯에 있냐에 따라
# 다른 정책 적용 → 예측 불가능한 동작
# ✅ 모든 노드 동일하게
Node A: maxmemory-policy volatile-lru
Node B: maxmemory-policy volatile-lru
Node C: maxmemory-policy volatile-lru
용도별 Eviction Policy
캐시 전용 Cluster
# 모든 노드 동일 설정
maxmemory 4gb # 노드당 메모리 제한
maxmemory-policy allkeys-lru # 전체 키 중 LRU 삭제
# 캐시는 유실돼도 DB에서 재조회 가능
# → allkeys-lru가 가장 적합
캐시 + Stream 혼용 Cluster
# 모든 노드 동일 설정
maxmemory 4gb
maxmemory-policy volatile-lru # TTL 있는 것만 삭제
# Stream → TTL 없음 → 삭제 안 됨
# Cache → TTL 있음 → 삭제 대상
중요 데이터 전용 Cluster
maxmemory 4gb
maxmemory-policy noeviction # 삭제 안 함
# 단, 메모리 모니터링 필수
# 꽉 차면 쓰기 에러 → 즉각 대응 필요
Cluster 전용 주의사항
노드별 메모리 불균형 문제
Hash Slot 분배:
{user:1} → Node A
{user:2} → Node B
{user:3} → Node A ← A에 몰릴 수 있음
특정 노드에 트래픽 집중 (Hot Slot)
→ 해당 노드만 먼저 메모리 꽉 참
→ 해당 노드만 Eviction 발생
→ 다른 노드는 여유 있는데 특정 노드만 데이터 날아감
해결:
Hash Tag 설계 시 균등 분산 고려
노드별 메모리 모니터링 필수
// 노드별 메모리 모니터링
@Scheduled(fixedDelay = 30_000)
public void monitorClusterMemory() {
redisTemplate.execute((RedisCallback<Void>) connection -> {
ClusterInfo clusterInfo = ((RedisClusterConnection) connection)
.clusterGetClusterInfo();
// 각 노드 메모리 확인
((RedisClusterConnection) connection)
.clusterGetNodes()
.forEach(node -> {
Properties info = ((RedisClusterConnection) connection)
.clusterGetNodeInfo(node);
String usedMemory = info.getProperty("used_memory_human");
String maxMemory = info.getProperty("maxmemory_human");
log.info("Node {}: {}/{}", node.getHost(),
usedMemory, maxMemory);
// 80% 이상이면 알람
long used = Long.parseLong(
info.getProperty("used_memory"));
long max = Long.parseLong(
info.getProperty("maxmemory"));
if (max > 0 && (double) used / max > 0.8) {
log.warn("노드 메모리 80% 초과: {}", node.getHost());
alertService.sendAlert("Redis 노드 메모리 경고");
}
});
return null;
});
}
AWS ElastiCache Cluster Mode 설정
ElastiCache는 Parameter Group으로 모든 노드 일괄 설정
→ 노드마다 따로 설정 불가 (자동으로 동일하게 적용)
# AWS ElastiCache Parameter Group 설정
maxmemory-policy: volatile-lru
maxmemory-samples: 10 # LRU 샘플링 정확도
activedefrag: yes # 메모리 단편화 자동 정리
active-defrag-ignore-bytes: 100mb
active-defrag-threshold-lower: 10
// ElastiCache Cluster Mode Enabled 설정
@Bean
public RedisConnectionFactory elastiCacheClusterFactory() {
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration();
clusterConfig.addClusterNode(
new RedisNode(
"myredis.xxxxx.clustercfg.use1.cache.amazonaws.com",
6379
)
);
clusterConfig.setPassword(RedisPassword.of(authToken));
clusterConfig.setMaxRedirects(3);
ClusterTopologyRefreshOptions topologyRefresh =
ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build();
ClusterClientOptions clientOptions =
ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefresh)
.autoReconnect(true)
.build();
LettuceClientConfiguration clientConfig =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofMillis(500))
.readFrom(ReadFrom.REPLICA_PREFERRED)
.clientOptions(clientOptions)
.useSsl()
.build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
Cluster Eviction 설계 정리
┌─────────────────────────────────────────────────────────────┐
│ Cluster Mode Eviction Policy 결론 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 기본 원칙: │
│ 모든 노드 동일한 Policy 설정 │
│ (AWS ElastiCache = Parameter Group으로 자동 통일) │
│ │
│ 용도별: │
│ 캐시 전용 → allkeys-lru │
│ 캐시 + Stream → volatile-lru │
│ (Stream TTL 없음 → 보존) │
│ 중요 데이터 → noeviction + 메모리 모니터링 필수 │
│ │
│ Cluster 추가 주의: │
│ 노드별 메모리 독립적 관리 │
│ 특정 노드 메모리 집중 → Hot Slot 문제 │
│ 노드별 메모리 모니터링 필수 │
│ maxmemory는 전체가 아닌 노드당 설정 │
│ │
└─────────────────────────────────────────────────────────────┘
AWS ElastiCache Eviction Policy 설정 위치
Parameter Group에서 설정
AWS ElastiCache는 redis.conf 직접 수정 불가
→ Parameter Group으로 설정 관리
설정 경로
AWS Console:
ElastiCache
└── Parameter Groups
└── 파라미터 그룹 생성 또는 수정
└── maxmemory-policy 설정
단계별 설정 방법
1단계: Parameter Group 생성
AWS Console
→ ElastiCache
→ Parameter Groups
→ "Create parameter group" 클릭
→ Family: redis7.x (버전에 맞게)
→ 이름: my-redis-params
2단계: maxmemory-policy 수정
생성한 Parameter Group 선택
→ "Edit parameters" 클릭
→ maxmemory-policy 검색
→ 값 설정 (volatile-lru, allkeys-lru 등)
→ Save changes
3단계: 클러스터에 적용
ElastiCache 클러스터 선택
→ Modify
→ Parameter Group → 생성한 것으로 변경
→ Apply immediately 또는 다음 유지보수 창
⚠️ 주의: 적용 시 재시작 필요할 수 있음
주요 설정 파라미터
┌─────────────────────────────────┬─────────────────────────┐
│ 파라미터 │ 권장값 │
├─────────────────────────────────┼─────────────────────────┤
│ maxmemory-policy │ volatile-lru │
│ │ (캐시+Stream 혼용 시) │
│ │ allkeys-lru │
│ │ (캐시 전용 시) │
├─────────────────────────────────┼─────────────────────────┤
│ maxmemory-samples │ 10 │
│ (LRU 샘플링 정확도) │ │
├─────────────────────────────────┼─────────────────────────┤
│ activedefrag │ yes │
│ (메모리 단편화 자동 정리) │ │
├─────────────────────────────────┼─────────────────────────┤
│ lazyfree-lazy-eviction │ yes │
│ (비동기 Eviction) │ │
├─────────────────────────────────┼─────────────────────────┤
│ slowlog-log-slower-than │ 10000 (10ms) │
├─────────────────────────────────┼─────────────────────────┤
│ notify-keyspace-events │ Ex │
│ (키 만료 이벤트 사용 시) │ │
└─────────────────────────────────┴─────────────────────────┘
Terraform으로 설정 (IaC)
# Parameter Group 생성
resource "aws_elasticache_parameter_group" "redis_params" {
name = "my-redis-params"
family = "redis7"
parameter {
name = "maxmemory-policy"
value = "volatile-lru"
}
parameter {
name = "maxmemory-samples"
value = "10"
}
parameter {
name = "activedefrag"
value = "yes"
}
parameter {
name = "lazyfree-lazy-eviction"
value = "yes"
}
parameter {
name = "slowlog-log-slower-than"
value = "10000"
}
}
# ElastiCache 클러스터에 적용
resource "aws_elasticache_replication_group" "redis" {
replication_group_id = "my-redis"
description = "My Redis Cluster"
node_type = "cache.r6g.large"
num_cache_clusters = 2 # Primary + Replica 1개
# Parameter Group 연결
parameter_group_name = aws_elasticache_parameter_group.redis_params.name
# 기타 설정
engine_version = "7.0"
port = 6379
at_rest_encryption_enabled = true
transit_encryption_enabled = true
auth_token = var.redis_auth_token
automatic_failover_enabled = true
multi_az_enabled = true
}
maxmemory는 어디서 설정?
⚠️ maxmemory는 Parameter Group에서 설정 안 함
ElastiCache는 node_type으로 메모리 자동 결정
cache.r6g.large → 13.07 GB
cache.r6g.xlarge → 26.32 GB
cache.r6g.2xlarge→ 52.82 GB
→ 노드 타입 변경으로 메모리 조정
→ maxmemory 직접 설정 불필요
결론
┌─────────────────────────────────────────────────────────────┐
│ │
│ 설정 위치: │
│ AWS Console → ElastiCache → Parameter Groups │
│ 또는 Terraform aws_elasticache_parameter_group │
│ │
│ 적용 방법: │
│ Parameter Group 생성 │
│ → maxmemory-policy 설정 │
│ → 클러스터에 연결 │
│ │
│ maxmemory: │
│ 노드 타입(cache.r6g.large 등)으로 자동 결정 │
│ 직접 설정 불필요 │
│ │
│ Cluster Mode: │
│ Parameter Group 하나로 모든 노드에 동일하게 적용 │
│ │
└─────────────────────────────────────────────────────────────┘
AWS ElastiCache Eviction 장애 분석 보고서
한 줄 요약
서버 배포 시 메모리 초과 → volatile-lru Eviction 발생
→ 삭제되면 안 되는 캐시 데이터가 삭제됨
장애 흐름 이해
서버 배포 (Rolling Update)
│
▼
새 인스턴스 대량 생성
│
▼
각 인스턴스에서 cluster.fork() 실행
│
▼
Big Key를 인스턴스당 17번씩 호출
│ │
│ └── New Connection 급증
│ → 연결 자체가 메모리 사용
▼
ElastiCache 메모리 사용량 급증
maxmemory_human = 9.8GB (한계)
used_memory_peak = 13.55GB (실제 사용)
↑
한계 초과!
│
▼
volatile-lru Eviction 발동
│
▼
TTL 있는 키들 LRU 순으로 삭제
│
▼
삭제되면 안 되는 당일 데이터까지 삭제 ❌
우리가 배운 개념들이 그대로 등장
1. volatile-lru
우리가 배운 것:
"TTL 있는 키 중 LRU 삭제"
실제 장애:
Expire가 적용된 키 수가 많지 않음
→ LRU 삭제가 당일 데이터까지 도달
→ 오래된 것 다 지우고 나서 최신 것도 지움
2. Big Key 문제
우리가 배운 것:
"Big Key = 메모리 급증 원인"
"redis-cli --bigkeys로 탐지"
실제 장애:
Big Key가 대량 호출됨
→ 순간적으로 메모리 폭발
3. New Connection 메모리
우리가 배운 것:
Lettuce Connection Pool
연결 자체도 메모리 사용
실제 장애:
Rolling Update로 새 인스턴스 대량 생성
→ New Connection 급증
→ 연결당 메모리 사용 → 전체 메모리 증가
대응 방법 분석
대응 1: Read/Write 노드 분리
기존:
Primary ← 읽기 + 쓰기 모두 (Replica는 Failover용만)
변경:
Primary ← 쓰기만
Replica ← 읽기만
효과:
Primary 메모리/CPU 부하 분산
→ 메모리 사용률 감소
// 우리가 배운 코드와 동일!
@Qualifier("redisTemplate") // Primary (쓰기)
private final RedisTemplate<String, Object> writeTemplate;
@Qualifier("readRedisTemplate") // Replica (읽기)
private final RedisTemplate<String, Object> readTemplate;
대응 2: Big Key 분할
기존:
key:data → 하나의 거대한 값 (메모리 많이 차지)
→ 17번 호출 시 메모리 폭발
변경:
key:data:1 → 작은 값
key:data:2 → 작은 값 ← 여러 키로 분산
key:data:3 → 작은 값
→ 메모리 분산 + Cache Stampede 예방
근본 원인 요약
┌─────────────────────────────────────────────────────────────┐
│ 장애 원인 2가지 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Big Key 대량 호출 │
│ Rolling Update 시 cluster.fork() × 인스턴스 수 × 17회 │
│ → 순간적 메모리 폭발 │
│ │
│ 2. New Connection 급증 │
│ 새 인스턴스 대량 생성 │
│ → 연결 자체가 메모리 소비 │
│ │
│ 결과: │
│ maxmemory 초과 → volatile-lru 발동 │
│ TTL 있는 키 수가 적어 당일 데이터까지 삭제 │
│ │
│ 교훈: │
│ Big Key는 반드시 사전에 탐지/분산 │
│ 배포 시 메모리 급증 패턴 사전 파악 필요 │
│ Replica를 Failover용으로만 쓰는 것은 낭비 │
│ 메모리 80% 알람 설정 필수 │
│ │
└─────────────────────────────────────────────────────────────┘
KEYS 외 위험한 명령어들 이유
공통 원인
모두 같은 이유:
싱글 스레드 + O(N) 또는 대용량 처리
→ 실행 중 전체 큐 블로킹
SMEMBERS - O(N)
Set에 요소가 몇 개인지 모르는 상태에서
전체를 한 번에 가져옴
SMEMBERS user:followers
→ 팔로워가 100만명이면?
→ 100만개 요소 전체 반환
→ O(N) 블로킹 + 네트워크 폭발
실제 케이스:
인기 유저 팔로워 Set에 SMEMBERS
→ 수백만 요소 → Redis Hang
✅ SSCAN으로 대체
SSCAN user:followers 0 COUNT 100
→ 100개씩 커서 기반 조회
→ 중간에 다른 요청 끼어들 수 있음
HGETALL - O(N)
Hash의 모든 필드를 한 번에 가져옴
HGETALL product:catalog
→ 필드가 수만개면?
→ 전체 반환 → 블로킹
실제 케이스:
상품 카탈로그를 Hash 하나에 다 저장
HGETALL 호출 → 수만 필드 반환
✅ HSCAN으로 대체
HSCAN product:catalog 0 COUNT 100
→ 100개씩 커서 기반 조회
또는 필요한 필드만
HGET product:catalog name → 단일 필드
HMGET product:catalog name price stock → 필요한 것만
LRANGE 0 -1 - O(N)
-1 = 리스트 끝까지 전부 가져옴
LRANGE order:history 0 -1
→ 주문 이력이 100만건이면?
→ 100만개 전부 반환 → 블로킹
실제 케이스:
로그성 데이터를 List에 계속 쌓음
LRANGE 0 -1로 전체 조회
→ 시간이 지날수록 느려짐
✅ 페이지네이션
LRANGE order:history 0 99 → 최근 100개
LRANGE order:history 100 199 → 그 다음 100개
SORT - O(N log N) ~ O(N²)
List, Set, ZSet을 정렬해서 반환
SORT user:ids → user:ids의 모든 요소 정렬
SORT user:ids LIMIT 0 10 → 정렬 후 10개
문제:
정렬 자체가 O(N log N)
BY, GET 옵션으로 외부 키 조회 시 O(N²)까지
대용량 데이터 정렬 → 수초 블로킹 가능
✅ 애플리케이션에서 처리
List<String> ids = redisTemplate.opsForList()
.range("user:ids", 0, -1);
ids.sort(Comparator.naturalOrder()); // Java에서 정렬
또는 ZSet 사용 (이미 정렬된 자료구조)
ZADD user:ranking score userId
ZRANGE user:ranking 0 9 → 상위 10개 (이미 정렬됨)
FLUSHDB / FLUSHALL - O(N)
DB의 전체 키 삭제
FLUSHDB → 현재 DB 전체 삭제
FLUSHALL → 모든 DB 전체 삭제
문제:
키가 1000만개면 1000만개 전부 삭제
→ 수초간 블로킹
→ 그 동안 모든 요청 대기
✅ ASYNC 옵션
FLUSHDB ASYNC → 즉시 반환, 백그라운드 삭제
FLUSHALL ASYNC → 즉시 반환, 백그라운드 삭제
DEL (Big Key) - O(N)
키 삭제 시 내부 요소 전체 해제
DEL user:followers (요소 100만개인 Set)
→ 100만개 메모리 해제 = O(N)
→ 해제하는 동안 블로킹
String 같은 단순 키 DEL = O(1) 문제 없음
But 컬렉션(Set, List, Hash, ZSet) DEL = O(N) 위험
✅ UNLINK (비동기 삭제)
UNLINK user:followers
→ 즉시 반환
→ 실제 메모리 해제는 백그라운드 스레드가 처리
→ 싱글 스레드 블로킹 없음
// Java에서 UNLINK 사용
redisTemplate.unlink("user:followers");
// 또는
redisTemplate.execute((RedisCallback<Long>) connection ->
connection.unlink("user:followers".getBytes())
);
한눈에 비교
┌──────────────────┬──────────────┬────────────────────────────┐
│ 명령어 │ 복잡도 │ 위험한 이유 │
├──────────────────┼──────────────┼────────────────────────────┤
│ KEYS * │ O(N) │ 전체 키 순회 │
│ SMEMBERS │ O(N) │ Set 전체 요소 반환 │
│ HGETALL │ O(N) │ Hash 전체 필드 반환 │
│ LRANGE 0 -1 │ O(N) │ List 전체 요소 반환 │
│ SORT (대용량) │ O(N log N) │ 정렬 비용 │
│ FLUSHDB │ O(N) │ 전체 키 동기 삭제 │
│ DEL (Big Key) │ O(N) │ 내부 요소 전체 메모리 해제 │
└──────────────────┴──────────────┴────────────────────────────┘
모두 공통:
싱글 스레드 + O(N) = 실행 중 전체 큐 블로킹
결론
┌─────────────────────────────────────────────────────────────┐
│ │
│ 위험한 이유 한 줄: │
│ "데이터가 많을수록 실행 시간이 선형 증가" │
│ + "싱글 스레드라 실행 중 다른 요청 전부 블로킹" │
│ │
│ 안전하게 사용하는 원칙: │
│ 전체 조회 → 커서 기반 페이지네이션 (SCAN 계열) │
│ 동기 삭제 → 비동기 삭제 (UNLINK, ASYNC) │
│ 복잡한 연산 → 애플리케이션에서 처리 │
│ 컬렉션 크기 → MAXLEN, 분산 저장으로 제한 │
│ │
└─────────────────────────────────────────────────────────────┘
근데 사실상 Redis의 eviction policy를 None으로 사용하는건 불가능하고, Volatile-lru로 TTL을 설정하고 사용해서 eviction이 자동으로 되게 하는게 보편적인데, 그럼 세션정보를 Redis에 저장해서 로그인을 구현한다고 했을때 세션이 유실될수 있는 가능성도 있네?
Redis Eviction과 세션 유실 가능성
맞습니다. 정확하게 파악하신 거예요. 구조적으로 세션 유실 가능성은 존재합니다.
상황 정리
| Eviction Policy | 세션 유실 가능성 | 현실적 사용 여부 |
|---|---|---|
noeviction | 없음 | ❌ 메모리 초과 시 쓰기 에러 발생 |
volatile-lru | 있음 | ✅ 일반적으로 사용 |
allkeys-lru | 있음 (더 위험) | 세션 전용 Redis엔 부적합 |
volatile-lru에서 세션이 유실되는 시나리오
1. Redis 메모리가 maxmemory 한계에 도달
2. TTL이 설정된 키 중에서 LRU(최근에 가장 안 쓰인) 키를 evict
3. 세션 키도 TTL이 있으므로 → 유저가 로그인 중인데도 세션 삭제될 수 있음
즉, TTL이 남아있어도 메모리 압박 시 강제로 삭제될 수 있습니다.
실제 현업에서의 대응 방법
1. Redis를 세션 전용으로 분리 (가장 보편적)
- 세션용 Redis 인스턴스 ← maxmemory를 넉넉하게 + noeviction
- 캐시용 Redis 인스턴스 ← volatile-lru or allkeys-lru
세션 전용 인스턴스는 eviction보다 OOM 에러가 낫다는 판단 (쓰기 에러는 모니터링으로 감지 가능)
2. 세션 유실을 "로그아웃"으로 처리 (Graceful Degradation)
세션 조회 실패 → 로그인 페이지로 redirect
→ 유저 입장에서는 "세션 만료"로 인식하게 UX 처리
보안적으로도 문제없고, 대부분의 서비스가 이 방식으로 처리
3. DB Fallback (중요 서비스)
Redis 세션 miss → RDB(MySQL 등)에서 세션 조회
→ Redis에 다시 write (Cache-aside 패턴)
금융/의료 등 세션 유실이 치명적인 서비스에서 사용
4. Redis Cluster + Persistence 조합
- AOF(Append Only File) 또는 RDB persistence 활성화
- 메모리 초과가 아닌 장애(재시작 등)에 대한 복구 가능
- 단, eviction 자체는 막지 못함
결론
"세션 저장소로 Redis를 쓴다" = "세션은 언제든 사라질 수 있다"는 것을 전제로 설계해야 합니다.
Redis는 본질적으로 영속성보다 속도를 우선하는 저장소이기 때문에, 세션 유실 자체를 막으려 하기보다 유실됐을 때 어떻게 처리할 것인지 (재로그인 유도, fallback 등)를 설계하는 것이 현실적인 접근입니다.
Redis에 있는 데이터는 언제든지 휘발될수 있다는 점을 고려하고 언제든지 Fallback을 마련해야한다?
네, 정확합니다 — Redis는 "Best Effort" 저장소
Redis 데이터가 휘발될 수 있는 모든 경우
1. Eviction (메모리 부족)
volatile-lru → TTL 있는 키 삭제
allkeys-lru → 모든 키 삭제
→ 세션, 캐시, 카운터 등 모두 삭제 대상
2. TTL 만료
SET key value EX 3600
→ 3600초 후 자동 삭제
3. 서버 재시작 (AOF/RDB 미설정 시)
→ 메모리 전체 초기화
→ AOF 설정해도 fsync 정책에 따라 1초치 유실 가능
4. 비동기 복제 중 Failover
Master → Replica 복제는 비동기
→ Master 장애 시 최신 데이터 일부 유실
5. 네트워크 장애 / 연결 타임아웃
→ 쓰기 명령 실패 → 데이터 저장 안 됨
용도별 Fallback 전략
① 캐시 (가장 일반적)
// Cache-Aside 패턴
public UserDto getUser(Long userId) {
String key = "user:" + userId;
// 1. Redis에서 먼저 조회
UserDto cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// 2. Fallback → DB 조회
UserDto user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException());
// 3. Redis에 다시 저장 (재캐싱)
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
return user;
}
Redis 유실 → DB에서 재조회 → 재캐싱
캐시는 DB의 복사본이므로 유실돼도 무방
② 선착순 카운터 (쿠폰 재고 등)
// Redis 장애 시 DB로 Fallback
public CouponResult issueCoupon(Long userId, Long couponId) {
try {
Long result = redisLuaScript.execute(...);
if (result == 1L) {
// Redis 선점 성공 → DB 확정
couponRepository.save(new CouponIssue(userId, couponId));
return CouponResult.SUCCESS;
}
return CouponResult.SOLD_OUT;
} catch (RedisException e) {
// Redis 장애 시 → DB에서 직접 처리 (Fallback)
log.warn("Redis 장애, DB Fallback 실행");
return issueCouponFromDB(userId, couponId); // DB 락으로 처리
}
}
정상: Redis Lua Script → DB 확정 저장
장애: DB SELECT FOR UPDATE → DB 단독 처리 (느리지만 안전)
③ 세션
// Redis 세션 유실 → 재로그인 유도 (허용 가능한 Fallback)
@ExceptionHandler(SessionNotFoundException.class)
public ResponseEntity<?> handleSessionLost() {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("세션이 만료되었습니다. 다시 로그인해주세요.");
}
세션 유실 = 강제 로그아웃
→ 이건 UX 문제이므로 세션 전용 Redis + noeviction으로 예방
→ 유실 자체를 막는 게 목표
④ 분산 락
// 락 획득 실패 시 Fallback
public void processOrder(Long orderId) {
String lockKey = "lock:order:" + orderId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(10));
if (Boolean.FALSE.equals(acquired)) {
// Fallback: 재시도 or DB 레벨 락으로 전환
throw new RetryableException("잠시 후 다시 시도해주세요.");
}
try {
// 비즈니스 로직
} finally {
redisTemplate.delete(lockKey); // 락 해제
}
}
전체 설계 원칙 정리
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Redis의 역할에 따라 Fallback 전략이 다르다 │
│ │
│ 용도 │ 유실 허용? │ Fallback │
│ ─────────────┼─────────────┼──────────────────────────────── │
│ 캐시 │ O (허용) │ DB 재조회 → 재캐싱 │
│ 카운터/재고 │ △ (주의) │ Redis 선점 + DB 확정 저장 │
│ 세션 │ X (불허) │ 전용 Redis + noeviction │
│ 분산 락 │ △ (주의) │ 실패 시 재시도 / DB 락 전환 │
│ 메시지 큐 │ X (불허) │ Kafka 등 내구성 있는 큐 사용 │
│ │
│ 핵심 원칙: │
│ Source of Truth는 항상 DB │
│ Redis는 성능 향상을 위한 보조 저장소 │
│ Redis 없이도 서비스가 동작할 수 있도록 설계 │
│ │
└─────────────────────────────────────────────────────────────────┘
한 줄 요약
Redis는 "있으면 빠르고, 없어도 동작하는" 구조로 설계해야 한다.
단, 세션처럼 유실 자체가 치명적인 경우엔 전용 Redis + noeviction + 메모리 모니터링으로 유실을 사전에 예방해야 한다.
non TTL은 절대 사용하지 않는다(메모리를 계속 차지하고 있음). 메모리 점검은 필수. eviction 점검도 필수.
Redis 운영 필수 원칙 3가지
원칙 1: 모든 키에 TTL 필수
TTL 없는 키가 왜 위험한가
TTL 없는 키 = 영원히 메모리 점유
→ 시간이 지날수록 메모리 증가
→ 어느 순간 갑자기 Eviction 폭발 or OOM
실제 장애 패턴:
초기: 메모리 여유 있음 → 문제 없어 보임
6개월 후: Non-TTL 키가 쌓여서 메모리 80% 도달
1년 후: Eviction 발생 → 중요 데이터 삭제 → 장애
TTL 강제 적용 패턴
// ❌ 절대 금지
redisTemplate.opsForValue().set("user:123", userDto);
// ✅ 항상 TTL 명시
redisTemplate.opsForValue().set("user:123", userDto, Duration.ofMinutes(30));
// 용도별 TTL 상수 관리
public final class RedisTtl {
public static final Duration SESSION = Duration.ofHours(2);
public static final Duration USER_CACHE = Duration.ofMinutes(30);
public static final Duration COUPON_LOCK = Duration.ofSeconds(10);
public static final Duration RATE_LIMIT = Duration.ofMinutes(1);
public static final Duration TEMP_TOKEN = Duration.ofMinutes(5);
}
// 사용
redisTemplate.opsForValue().set(key, value, RedisTtl.USER_CACHE);
기존 TTL 없는 키 탐지
# TTL -1 = TTL 없는 키 탐지 (KEYS 대신 SCAN 사용)
redis-cli --scan --pattern "*" | while read key; do
ttl=$(redis-cli TTL "$key")
if [ "$ttl" == "-1" ]; then
echo "NO TTL: $key"
fi
done
// 애플리케이션 레벨에서 Non-TTL 키 탐지 배치
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void detectNonTtlKeys() {
ScanOptions options = ScanOptions.scanOptions()
.match("*")
.count(100) // SCAN은 cursor 기반이라 안전
.build();
List<String> noTtlKeys = new ArrayList<>();
try (Cursor<byte[]> cursor =
redisTemplate.getConnectionFactory()
.getConnection()
.scan(options)) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl != null && ttl == -1L) { // -1 = TTL 없음
noTtlKeys.add(key);
}
}
}
if (!noTtlKeys.isEmpty()) {
log.error("Non-TTL 키 발견: {}개 → {}", noTtlKeys.size(), noTtlKeys);
alertService.sendAlert("Non-TTL 키 발견: " + noTtlKeys.size() + "개");
}
}
원칙 2: 메모리 점검 필수
핵심 메트릭
# 메모리 상태 전체 확인
redis-cli INFO memory
# 핵심 항목
used_memory: 현재 사용 메모리
used_memory_peak: 최고 사용 메모리
maxmemory: 설정된 최대 메모리
mem_fragmentation_ratio: 단편화 비율 (1.5 이상이면 위험)
Spring 메모리 모니터링
@Component
@RequiredArgsConstructor
public class RedisMemoryMonitor {
private final RedisTemplate<String, Object> redisTemplate;
private final AlertService alertService;
private static final double WARN_THRESHOLD = 0.70; // 70% 경고
private static final double CRIT_THRESHOLD = 0.85; // 85% 위험
private static final double FRAG_THRESHOLD = 1.5; // 단편화 비율 위험
@Scheduled(fixedDelay = 30_000) // 30초마다
public void checkMemory() {
Properties memInfo = redisTemplate.execute(
(RedisCallback<Properties>) conn -> conn.serverCommands().info("memory")
);
long usedMemory = Long.parseLong(memInfo.getProperty("used_memory"));
long maxMemory = Long.parseLong(memInfo.getProperty("maxmemory"));
double fragRatio = Double.parseDouble(
memInfo.getProperty("mem_fragmentation_ratio")
);
// 메모리 사용률 체크
if (maxMemory > 0) {
double usageRatio = (double) usedMemory / maxMemory;
if (usageRatio >= CRIT_THRESHOLD) {
alertService.sendCritical(
String.format("Redis 메모리 위험! %.1f%% 사용 중", usageRatio * 100)
);
} else if (usageRatio >= WARN_THRESHOLD) {
alertService.sendWarning(
String.format("Redis 메모리 경고: %.1f%% 사용 중", usageRatio * 100)
);
}
}
// 단편화 비율 체크
if (fragRatio >= FRAG_THRESHOLD) {
alertService.sendWarning(
String.format("Redis 메모리 단편화 위험: %.2f (1.5 이상)", fragRatio)
);
}
}
}
원칙 3: Eviction 점검 필수
Eviction이 발생했다 = 이미 늦었다
Eviction 발생 시점 = 메모리가 이미 maxmemory 초과
→ 이 시점에서는 이미 데이터 삭제 중
→ Eviction 감지는 "예방"이 아니라 "사후 감지"
→ 메모리 모니터링(70~85%)으로 Eviction 이전에 잡아야 함
Eviction 모니터링
# Eviction 발생 횟수 확인
redis-cli INFO stats | grep evicted_keys
# 실시간 모니터링
redis-cli --stat # 초당 evicted_keys 변화 추이
@Scheduled(fixedDelay = 30_000)
public void checkEviction() {
Properties stats = redisTemplate.execute(
(RedisCallback<Properties>) conn -> conn.serverCommands().info("stats")
);
long evictedKeys = Long.parseLong(stats.getProperty("evicted_keys"));
// Eviction이 발생했다면 이미 메모리 초과 상태
if (evictedKeys > 0) {
alertService.sendCritical(
"Redis Eviction 발생! 삭제된 키: " + evictedKeys + "개 → 즉각 메모리 점검 필요"
);
}
}
전체 운영 원칙 요약
┌──────────────────────────────────────────────────────────────────┐
│ Redis 운영 필수 원칙 │
│ │
│ 1. TTL 없는 키 = 절대 금지 │
│ 모든 키에 TTL 명시 → RedisTtl 상수로 중앙 관리 │
│ Non-TTL 키 탐지 배치 → 매일 새벽 실행 │
│ │
│ 2. 메모리 점검 필수 │
│ 30초마다 used_memory / maxmemory 비율 체크 │
│ 70% → 경고 / 85% → 위험 알람 │
│ 단편화 비율 1.5 이상 → 경고 │
│ │
│ 3. Eviction 점검 필수 │
│ evicted_keys > 0 → 즉각 위험 알람 │
│ Eviction은 사후 감지 → 메모리 모니터링으로 사전 예방이 핵심 │
│ │
│ 우선순위: │
│ TTL 강제 적용 → 메모리 사전 모니터링 → Eviction 사후 감지 │
└──────────────────────────────────────────────────────────────────┘
핵심: Eviction이 발생했다면 이미 장애 상황. 메모리 70% 알람에서 미리 대응해야 Eviction 자체를 막을 수 있다.
Redis 운영 필수 원칙 3가지
원칙 1: 모든 키에 TTL 필수
TTL 없는 키가 왜 위험한가
TTL 없는 키 = 영원히 메모리 점유
→ 시간이 지날수록 메모리 증가
→ 어느 순간 갑자기 Eviction 폭발 or OOM
실제 장애 패턴:
초기: 메모리 여유 있음 → 문제 없어 보임
6개월 후: Non-TTL 키가 쌓여서 메모리 80% 도달
1년 후: Eviction 발생 → 중요 데이터 삭제 → 장애
TTL 강제 적용 패턴
// ❌ 절대 금지
redisTemplate.opsForValue().set("user:123", userDto);
// ✅ 항상 TTL 명시
redisTemplate.opsForValue().set("user:123", userDto, Duration.ofMinutes(30));
// 용도별 TTL 상수 관리
public final class RedisTtl {
public static final Duration SESSION = Duration.ofHours(2);
public static final Duration USER_CACHE = Duration.ofMinutes(30);
public static final Duration COUPON_LOCK = Duration.ofSeconds(10);
public static final Duration RATE_LIMIT = Duration.ofMinutes(1);
public static final Duration TEMP_TOKEN = Duration.ofMinutes(5);
}
// 사용
redisTemplate.opsForValue().set(key, value, RedisTtl.USER_CACHE);
기존 TTL 없는 키 탐지
# TTL -1 = TTL 없는 키 탐지 (KEYS 대신 SCAN 사용)
redis-cli --scan --pattern "*" | while read key; do
ttl=$(redis-cli TTL "$key")
if [ "$ttl" == "-1" ]; then
echo "NO TTL: $key"
fi
done
// 애플리케이션 레벨에서 Non-TTL 키 탐지 배치
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void detectNonTtlKeys() {
ScanOptions options = ScanOptions.scanOptions()
.match("*")
.count(100) // SCAN은 cursor 기반이라 안전
.build();
List<String> noTtlKeys = new ArrayList<>();
try (Cursor<byte[]> cursor =
redisTemplate.getConnectionFactory()
.getConnection()
.scan(options)) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl != null && ttl == -1L) { // -1 = TTL 없음
noTtlKeys.add(key);
}
}
}
if (!noTtlKeys.isEmpty()) {
log.error("Non-TTL 키 발견: {}개 → {}", noTtlKeys.size(), noTtlKeys);
alertService.sendAlert("Non-TTL 키 발견: " + noTtlKeys.size() + "개");
}
}
원칙 2: 메모리 점검 필수
핵심 메트릭
# 메모리 상태 전체 확인
redis-cli INFO memory
# 핵심 항목
used_memory: 현재 사용 메모리
used_memory_peak: 최고 사용 메모리
maxmemory: 설정된 최대 메모리
mem_fragmentation_ratio: 단편화 비율 (1.5 이상이면 위험)
Spring 메모리 모니터링
@Component
@RequiredArgsConstructor
public class RedisMemoryMonitor {
private final RedisTemplate<String, Object> redisTemplate;
private final AlertService alertService;
private static final double WARN_THRESHOLD = 0.70; // 70% 경고
private static final double CRIT_THRESHOLD = 0.85; // 85% 위험
private static final double FRAG_THRESHOLD = 1.5; // 단편화 비율 위험
@Scheduled(fixedDelay = 30_000) // 30초마다
public void checkMemory() {
Properties memInfo = redisTemplate.execute(
(RedisCallback<Properties>) conn -> conn.serverCommands().info("memory")
);
long usedMemory = Long.parseLong(memInfo.getProperty("used_memory"));
long maxMemory = Long.parseLong(memInfo.getProperty("maxmemory"));
double fragRatio = Double.parseDouble(
memInfo.getProperty("mem_fragmentation_ratio")
);
// 메모리 사용률 체크
if (maxMemory > 0) {
double usageRatio = (double) usedMemory / maxMemory;
if (usageRatio >= CRIT_THRESHOLD) {
alertService.sendCritical(
String.format("Redis 메모리 위험! %.1f%% 사용 중", usageRatio * 100)
);
} else if (usageRatio >= WARN_THRESHOLD) {
alertService.sendWarning(
String.format("Redis 메모리 경고: %.1f%% 사용 중", usageRatio * 100)
);
}
}
// 단편화 비율 체크
if (fragRatio >= FRAG_THRESHOLD) {
alertService.sendWarning(
String.format("Redis 메모리 단편화 위험: %.2f (1.5 이상)", fragRatio)
);
}
}
}
원칙 3: Eviction 점검 필수
Eviction이 발생했다 = 이미 늦었다
Eviction 발생 시점 = 메모리가 이미 maxmemory 초과
→ 이 시점에서는 이미 데이터 삭제 중
→ Eviction 감지는 "예방"이 아니라 "사후 감지"
→ 메모리 모니터링(70~85%)으로 Eviction 이전에 잡아야 함
Eviction 모니터링
# Eviction 발생 횟수 확인
redis-cli INFO stats | grep evicted_keys
# 실시간 모니터링
redis-cli --stat # 초당 evicted_keys 변화 추이
@Scheduled(fixedDelay = 30_000)
public void checkEviction() {
Properties stats = redisTemplate.execute(
(RedisCallback<Properties>) conn -> conn.serverCommands().info("stats")
);
long evictedKeys = Long.parseLong(stats.getProperty("evicted_keys"));
// Eviction이 발생했다면 이미 메모리 초과 상태
if (evictedKeys > 0) {
alertService.sendCritical(
"Redis Eviction 발생! 삭제된 키: " + evictedKeys + "개 → 즉각 메모리 점검 필요"
);
}
}
전체 운영 원칙 요약
┌──────────────────────────────────────────────────────────────────┐
│ Redis 운영 필수 원칙 │
│ │
│ 1. TTL 없는 키 = 절대 금지 │
│ 모든 키에 TTL 명시 → RedisTtl 상수로 중앙 관리 │
│ Non-TTL 키 탐지 배치 → 매일 새벽 실행 │
│ │
│ 2. 메모리 점검 필수 │
│ 30초마다 used_memory / maxmemory 비율 체크 │
│ 70% → 경고 / 85% → 위험 알람 │
│ 단편화 비율 1.5 이상 → 경고 │
│ │
│ 3. Eviction 점검 필수 │
│ evicted_keys > 0 → 즉각 위험 알람 │
│ Eviction은 사후 감지 → 메모리 모니터링으로 사전 예방이 핵심 │
│ │
│ 우선순위: │
│ TTL 강제 적용 → 메모리 사전 모니터링 → Eviction 사후 감지 │
└──────────────────────────────────────────────────────────────────┘
핵심: Eviction이 발생했다면 이미 장애 상황. 메모리 70% 알람에서 미리 대응해야 Eviction 자체를 막을 수 있다.
블로그 내용 분석 + 우리가 나눈 대화와 연결
블로그에서 다룬 핵심 내용
1. TTL 필수 설정 ← 우리가 방금 정리한 내용과 동일
2. Big Key Problem
3. Data Type 활용 (Sorted Set, Hash)
4. Hot Key 만료 문제 (Cache Stampede)
블로그에서 다루지 않은 것 (우리가 추가로 알고 있는 것)
| 주제 | 블로그 | 우리 대화 |
|---|---|---|
| TTL | 설정해야 한다 | Non-TTL 탐지 배치, RedisTtl 상수 관리까지 |
| Big Key | 위험하다 | UNLINK로 비동기 삭제, SCAN으로 탐지 |
| Eviction | 언급 없음 | volatile-lru vs allkeys-lru, 세션 유실 시나리오 |
| 원자성 | 언급 없음 | Lua Script / MULTI·EXEC / Pipeline 차이 |
| 장애 대응 | 언급 없음 | Fallback 패턴, DB First 설계 |
| 모니터링 | 언급 없음 | 메모리 70%/85% 알람, evicted_keys 감시 |
블로그에서 가장 중요한 추가 내용: Cache Stampede
블로그에서 새로 언급된 내용 중 실무에서 매우 중요한 것
Hot Key TTL 만료 순간:
동시에 수천 개 요청 → Redis Miss
→ 모두 DB로 직행
→ DB 과부하 → 장애
이걸 Cache Stampede (캐시 스탬피드) 라고 함
해결책 1: PER 알고리즘 (블로그 언급)
// Probabilistic Early Recomputation
// TTL이 완전히 만료되기 전에 확률적으로 미리 갱신
public UserDto getUserWithPER(Long userId) {
String key = "user:" + userId;
ValueWithTtl<UserDto> cached = getWithTtl(key);
if (cached != null) {
long remainingTtl = cached.getTtl(); // 남은 TTL (초)
double delta = 1.0; // 재계산 비용 (튜닝값)
double random = -Math.log(Math.random()) * delta;
// 남은 TTL이 random 값보다 작으면 확률적으로 미리 갱신
if (remainingTtl <= random) {
UserDto fresh = userRepository.findById(userId)
.orElseThrow();
redisTemplate.opsForValue()
.set(key, fresh, Duration.ofMinutes(30));
return fresh;
}
return cached.getValue();
}
// Cache Miss → DB 조회
return refreshCache(userId, key);
}
해결책 2: 분산 락으로 DB 요청 1개만 허용
public UserDto getUserWithLock(Long userId) {
String key = "user:" + userId;
String lockKey = "lock:refresh:" + key;
// 1. 캐시 조회
UserDto cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// 2. Cache Miss → 락 획득 시도
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(acquired)) {
try {
// 락 획득 성공 → DB 조회 후 캐시 갱신
UserDto fresh = userRepository.findById(userId)
.orElseThrow();
redisTemplate.opsForValue()
.set(key, fresh, Duration.ofMinutes(30));
return fresh;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 락 획득 실패 → 다른 스레드가 갱신 중 → 잠깐 대기 후 재조회
Thread.sleep(50);
return redisTemplate.opsForValue().get(key);
}
}
전체 Redis 실무 체크리스트
┌──────────────────────────────────────────────────────────────┐
│ Redis 실무 체크리스트 │
│ │
│ 저장 설계 │
│ □ 모든 키에 TTL 명시 (RedisTtl 상수로 중앙 관리) │
│ □ Big Key 금지 (String 1MB↑, Collection 10,000개↑) │
│ □ 객체 직렬화 대신 Hash 타입 사용 (스키마 변경 대응) │
│ □ 정렬 필요 시 Sorted Set 고려 │
│ │
│ 원자성 / 동시성 │
│ □ 단순 캐시 → 그냥 GET/SET │
│ □ 중복 방지 필요 → Lua Script │
│ □ 순서 보장 필요 → MULTI/EXEC + SessionCallback │
│ □ 분산 락 → SET NX EX + Lua 해제 │
│ │
│ Hot Key 대응 │
│ □ Cache Stampede 가능성 검토 │
│ □ PER 알고리즘 or 분산 락으로 DB 요청 1개 제한 │
│ □ TTL 만료 전 Background 갱신 스케줄러 고려 │
│ │
│ 운영 / 모니터링 │
│ □ 메모리 70% 경고 / 85% 위험 알람 │
│ □ evicted_keys > 0 즉시 알람 │
│ □ Non-TTL 키 탐지 배치 (매일 새벽) │
│ □ 세션 전용 Redis 분리 + noeviction │
│ □ Redis 장애 시 Fallback → DB 직접 조회 │
│ │
└──────────────────────────────────────────────────────────────┘
블로그 내용 요약: TTL, Big Key, Data Type, Cache Stampede — 모두 중요하지만 Cache Stampede는 실제 장애로 이어지는 빈도가 높아서 분산 락 or PER 알고리즘으로 반드시 대응해야 한다.
Redis를 사용해서 쿠폰 발급하는 것도 사실은 핵심은 Redis가 아니라 mysql의 unique key constraint다. Redis는 단지 재고차감 Validation을 앞단에서 1차적으로 체크함으로써 DB 부하를 낮추는 용도일뿐.
정확한 이해입니다
역할 분리를 명확하게 보면
Redis (1차 관문) MySQL (최종 보루)
───────────────────── ──────────────────────
재고 > 0 인지 체크 → 실제 쿠폰 발급 확정
이미 발급받은 유저인지 체크 → 중복 발급 방지 (Unique Key)
빠른 응답으로 DB 부하 차단 Source of Truth
Redis가 없어도 시스템은 정상 동작
단지 DB에 부하가 몰릴 뿐
실제 흐름으로 보면
// 1단계: Redis (빠른 선제 차단 - DB 부하 감소 목적)
Long result = redisLuaScript.execute(
ISSUE_COUPON_SCRIPT,
keys, userId
);
if (result == ALREADY_ISSUED) throw new AlreadyIssuedException();
if (result == SOLD_OUT) throw new SoldOutException();
// 2단계: MySQL (실제 확정 - 진짜 중복/재고 보장)
try {
couponIssueRepository.save(
CouponIssue.of(couponId, userId) // UNIQUE KEY (coupon_id, user_id)
);
} catch (DataIntegrityViolationException e) {
// MySQL Unique Key Constraint 위반
// → Redis가 뚫려도 여기서 최종 차단
redisTemplate.execute(COMPENSATE_SCRIPT, ...); // Redis 보상
throw new AlreadyIssuedException();
}
Redis가 없다면?
Redis 없는 경우:
1000명 동시 요청
→ 1000개 DB 커넥션 점유
→ 1000번 SELECT + INSERT 실행
→ DB 부하 폭발
Redis 있는 경우:
1000명 동시 요청
→ Redis에서 재고 0 확인 즉시 999명 차단 (수십 마이크로초)
→ DB에는 소수 요청만 도달
→ DB 부하 최소화
Redis가 틀려도 MySQL이 잡는다
시나리오: Redis 상태가 오염된 경우
Redis: 재고 5개 남음 (실제로는 0개)
→ 5명 Redis 통과
→ MySQL INSERT 시도
→ Unique Key 위반 or 재고 0 체크
→ 모두 차단 ✅
Redis가 잘못된 값을 들고 있어도
MySQL이 최종 방어선 역할
전체 구조 재정리
┌─────────────────────────────────────────────────────────────┐
│ 쿠폰 발급 아키텍처 │
│ │
│ 목적 │ 담당 │ 기술 │
│ ─────────────┼─────────────┼──────────────────────────── │
│ 속도 / 부하 │ Redis │ Lua Script, SADD, DECRBY │
│ 감소 │ (1차 관문) │ → 대부분의 요청 여기서 차단 │
│ │ │ │
│ 정합성 │ MySQL │ UNIQUE KEY (coupon, user) │
│ 보장 │ (최종 보루) │ → 중복 발급 물리적 불가 │
│ │ │ │
│ 재고 확정 │ MySQL │ stock 컬럼 UPDATE │
│ │ │ WHERE stock > 0 │
│ │
│ 핵심 원칙: │
│ Redis 없어도 동작은 한다 (단, DB 부하 폭증) │
│ MySQL 없으면 정합성 보장 불가 │
│ Source of Truth = MySQL │
│ │
└─────────────────────────────────────────────────────────────┘
이 관점으로 다른 Redis 사용 사례도 보면
캐시:
Redis → 빠른 조회 (부하 감소)
MySQL → Source of Truth
Redis 유실 시 → MySQL에서 재조회 (정상 동작)
세션:
Redis → 빠른 세션 조회 (부하 감소)
재발급 → 다시 로그인 (DB에서 유저 확인)
Redis 유실 시 → 강제 로그아웃 (동작은 함)
분산 락:
Redis → 빠른 락 획득 (동시성 1차 제어)
MySQL → SELECT FOR UPDATE (최종 동시성 보장)
Redis 락 유실 시 → DB 락이 최종 방어
결론: Redis는 항상 "DB 부하를 줄이기 위한 보조 장치" 이고,
비즈니스 정합성의 최종 책임은 MySQL의 제약 조건(Unique Key, FK, CHECK) 에 있다.
Redis를 설계할 때 "Redis가 없거나 틀렸을 때도 시스템이 정합성을 지킬 수 있는가?" 를 항상 먼저 물어봐야 한다.
예시 코드 보여줘봐.
쿠폰 발급 전체 예시 코드 (Redis + MySQL)
1. DB 스키마
-- 쿠폰 재고 테이블
CREATE TABLE coupon (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
);
-- 쿠폰 발급 이력 테이블
CREATE TABLE coupon_issue (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
issued_at DATETIME NOT NULL,
-- 핵심: 중복 발급 물리적 차단
UNIQUE KEY uq_coupon_user (coupon_id, user_id)
);
2. Redis Lua Script
@Component
public class CouponLuaScript {
/**
* KEYS[1] = stock:{couponId} 재고 키
* KEYS[2] = issued:{couponId} 발급 유저 Set 키
* ARGV[1] = userId
*
* 반환값:
* 1 = 선점 성공
* 0 = 재고 없음
* -1 = 이미 발급받은 유저
* -2 = Redis 내부 오류
*/
public static final String ISSUE_SCRIPT = """
local stock_key = KEYS[1]
local issued_key = KEYS[2]
local user_id = ARGV[1]
-- 재고 확인
local stock = tonumber(redis.call('GET', stock_key))
if not stock or stock <= 0 then
return 0
end
-- 중복 발급 확인 (SADD: 이미 있으면 0 반환)
local sadd_result = redis.pcall('SADD', issued_key, user_id)
if type(sadd_result) == 'table' and sadd_result['err'] then
return -2
end
if sadd_result == 0 then
return -1
end
-- 재고 차감
local decr_result = redis.pcall('DECRBY', stock_key, 1)
if type(decr_result) == 'table' and decr_result['err'] then
redis.call('SREM', issued_key, user_id) -- 보상
return -2
end
return 1
""";
// Redis 장애 시 SADD 보상용
public static final String COMPENSATE_SCRIPT = """
local issued_key = KEYS[1]
local stock_key = KEYS[2]
local user_id = ARGV[1]
redis.call('SREM', issued_key, user_id)
redis.call('INCR', stock_key)
return 1
""";
}
3. Service
@Service
@RequiredArgsConstructor
public class CouponService {
private final RedisTemplate<String, String> redisTemplate;
private final CouponIssueRepository couponIssueRepository;
private final CouponRepository couponRepository;
// Redis 결과 코드
private static final long SUCCESS = 1L;
private static final long SOLD_OUT = 0L;
private static final long ALREADY_ISSUED = -1L;
private static final long REDIS_ERROR = -2L;
private static final DefaultRedisScript<Long> ISSUE_SCRIPT;
private static final DefaultRedisScript<Long> COMPENSATE_SCRIPT;
static {
ISSUE_SCRIPT = new DefaultRedisScript<>();
ISSUE_SCRIPT.setScriptText(CouponLuaScript.ISSUE_SCRIPT);
ISSUE_SCRIPT.setResultType(Long.class);
COMPENSATE_SCRIPT = new DefaultRedisScript<>();
COMPENSATE_SCRIPT.setScriptText(CouponLuaScript.COMPENSATE_SCRIPT);
COMPENSATE_SCRIPT.setResultType(Long.class);
}
@Transactional
public void issueCoupon(Long couponId, Long userId) {
String stockKey = "stock:" + couponId;
String issuedKey = "issued:" + couponId;
// ── 1단계: Redis (선제 차단 - DB 부하 감소) ──────────────────
Long redisResult = redisTemplate.execute(
ISSUE_SCRIPT,
List.of(stockKey, issuedKey),
String.valueOf(userId)
);
if (SOLD_OUT == redisResult) {
throw new SoldOutException("재고가 없습니다.");
}
if (ALREADY_ISSUED == redisResult) {
throw new AlreadyIssuedException("이미 발급받은 쿠폰입니다.");
}
if (REDIS_ERROR == redisResult) {
log.warn("Redis 내부 오류 - couponId: {}, userId: {}", couponId, userId);
throw new RedisException("잠시 후 다시 시도해주세요.");
}
// ── 2단계: MySQL (최종 확정 - 정합성 보장) ───────────────────
try {
couponIssueRepository.save(
CouponIssue.builder()
.couponId(couponId)
.userId(userId)
.issuedAt(LocalDateTime.now())
.build()
);
// 재고 차감
int updated = couponRepository.decreaseStock(couponId);
if (updated == 0) {
// DB 재고도 0 → Redis 보상 후 예외
compensateRedis(issuedKey, stockKey, userId);
throw new SoldOutException("재고가 없습니다.");
}
} catch (DataIntegrityViolationException e) {
// MySQL Unique Key 위반 → Redis가 뚫렸어도 여기서 최종 차단
log.warn("Unique Key 위반 - Redis 보상 실행: couponId={}, userId={}", couponId, userId);
compensateRedis(issuedKey, stockKey, userId);
throw new AlreadyIssuedException("이미 발급받은 쿠폰입니다.");
}
}
private void compensateRedis(String issuedKey, String stockKey, Long userId) {
redisTemplate.execute(
COMPENSATE_SCRIPT,
List.of(issuedKey, stockKey),
String.valueOf(userId)
);
}
}
4. Repository
public interface CouponIssueRepository extends JpaRepository<CouponIssue, Long> {}
public interface CouponRepository extends JpaRepository<Coupon, Long> {
// 재고 > 0 인 경우에만 차감 (DB 레벨 안전장치)
@Modifying
@Query("""
UPDATE Coupon c
SET c.stock = c.stock - 1
WHERE c.id = :couponId AND c.stock > 0
""")
int decreaseStock(@Param("couponId") Long couponId);
}
5. 전체 흐름 정리
유저 1000명 동시 요청
│
▼
┌───────────────────┐
│ Redis Lua │ 재고 0이면 999명 즉시 차단 (수십 μs)
│ 재고/중복 체크 │ DB 요청 자체를 막음
└───────┬───────────┘
│ 통과한 소수 요청만
▼
┌───────────────────────────────────┐
│ MySQL INSERT coupon_issue │
│ UNIQUE KEY (coupon_id, user_id) │ Redis가 뚫려도 여기서 차단
│ UPDATE coupon SET stock - 1 │ WHERE stock > 0 으로 재고 안전 보장
│ WHERE stock > 0 │
└───────────────────────────────────┘
Redis 역할: DB 부하 감소 (1차 관문)
MySQL 역할: 정합성 보장 (최종 보루) ← 진짜 핵심
6. Redis 초기화 (서버 시작 시)
@Component
@RequiredArgsConstructor
public class CouponRedisInitializer implements ApplicationRunner {
private final RedisTemplate<String, String> redisTemplate;
private final CouponRepository couponRepository;
@Override
public void run(ApplicationArguments args) {
// DB의 실제 재고를 Redis에 동기화
couponRepository.findAll().forEach(coupon -> {
String stockKey = "stock:" + coupon.getId();
redisTemplate.opsForValue().set(
stockKey,
String.valueOf(coupon.getStock()),
Duration.ofDays(1) // TTL 필수
);
});
}
}
핵심 요약:
Redis가 틀려도 → MySQL Unique Key가 막는다
MySQL WHERE stock > 0 이 없으면 → 재고 음수 가능
Redis는 속도, MySQL은 정합성 — 역할이 명확히 분리되어 있다
SADD에 대해 설명
SADD (Set ADD)
기본 개념
SADD key member [member ...]
Redis Set 자료구조에 member를 추가하는 명령어
Set = 중복을 허용하지 않는 집합
SADD issued:coupon:1 "user:100" # 반환: 1 (추가됨)
SADD issued:coupon:1 "user:200" # 반환: 1 (추가됨)
SADD issued:coupon:1 "user:100" # 반환: 0 (이미 존재 → 추가 안 됨)
반환값이 핵심
1 → 새로 추가됨 (처음 발급 시도)
0 → 이미 존재함 (중복 발급 시도)
// 쿠폰 발급 Lua Script에서 활용한 방식
local sadd_result = redis.pcall('SADD', issued_key, user_id)
if sadd_result == 0 then
return -1 -- 이미 발급받은 유저 → 즉시 차단
end
Set의 특성
SADD users "alice"
SADD users "bob"
SADD users "alice" # 무시됨
SMEMBERS users # → ["alice", "bob"] 중복 없음
SCARD users # → 2 원소 개수
SISMEMBER users "alice" # → 1 (존재 여부 확인) O(1)
쿠폰 발급에서 SADD가 적합한 이유
요구사항: 유저가 쿠폰을 중복 발급받으면 안 된다
SADD의 특성:
① 이미 존재하면 추가 안 함 → 중복 방지
② 반환값으로 중복 여부 확인 → 별도 SISMEMBER 불필요
③ O(1) 시간 복잡도 → 빠른 처리
SADD 하나로 "중복 체크 + 추가" 를 원자적으로 처리
→ SISMEMBER로 체크 후 SADD 하면 두 번의 명령 = 레이스 컨디션 가능
→ SADD 하나로 해결하면 안전
SADD vs SISMEMBER + SADD 비교
❌ 잘못된 방식 (레이스 컨디션 발생 가능)
SISMEMBER issued:coupon:1 "user:100" → 0 (없음 확인)
↕ 이 사이에 다른 요청이 SADD 할 수 있음
SADD issued:coupon:1 "user:100" → 중복 발급 가능!
✅ 올바른 방식
SADD issued:coupon:1 "user:100" → 0이면 이미 존재
한 번의 명령으로 체크 + 추가 원자적 처리
(Lua Script 안에서 사용하면 완벽)
시간 복잡도
SADD O(N) N = 추가하는 member 수 (1개면 O(1))
SISMEMBER O(1) 존재 여부 확인
SCARD O(1) 원소 개수
SMEMBERS O(N) 전체 조회 ← Big Key 위험, 실무에서 주의
SREM O(N) N = 제거하는 member 수
핵심: SADD의 반환값(0 or 1)이 "이미 존재 여부"를 알려주기 때문에
중복 체크 + 추가를 단 한 번의 명령으로 처리할 수 있다.
쿠폰 발급에서 Lua Script 안에 SADD를 쓰는 이유가 바로 이것이다.
검증로직을 앞단의 저장소에 이관 해서 빠르게 했을뿐 사실상 골자는 Mysql Unique key constraint가 핵심이구나
정확합니다
그 관점으로 다시 보면
본질적으로 같은 로직:
MySQL만 있을 때:
INSERT INTO coupon_issue (coupon_id, user_id)
→ Unique Key 위반 시 차단
→ 재고 없으면 WHERE stock > 0 으로 차단
Redis + MySQL:
Redis에서 SADD → 0이면 차단 (중복 체크 이관)
Redis에서 DECRBY → 재고 0이면 차단 (재고 체크 이관)
MySQL은 최종 확인만
┌─────────────────────────────────────────────────┐
│ │
│ 검증 로직 자체는 동일 │
│ 실행 위치만 MySQL → Redis로 앞당긴 것 │
│ │
│ MySQL : 디스크 I/O, 락, 트랜잭션 오버헤드 │
│ Redis : 인메모리, 싱글스레드, 수십 마이크로초│
│ │
│ 같은 검증을 더 빠른 저장소에서 먼저 하는 것 │
│ │
└─────────────────────────────────────────────────┘
이 패턴은 쿠폰에만 국한되지 않는다
재고 차감 (이커머스)
MySQL : stock 컬럼 UPDATE WHERE stock > 0
Redis : DECR로 앞단 차단 → DB 부하 감소
좋아요 중복 방지
MySQL : UNIQUE KEY (post_id, user_id)
Redis : SADD liked:{postId} {userId} 로 앞단 차단
중복 요청 방지 (멱등성)