Redis의 근본 실행 원리
먼저: MVCC가 왜 필요한지 복습
[RDBMS - 다중 스레드 환경]
Thread A: READ balance ──┐
Thread B: WRITE balance ──┤── 동시 접근 → 충돌 위험!
Thread C: READ balance ──┘
→ 이 문제를 해결하기 위해 MVCC 필요
(각 트랜잭션마다 데이터의 "버전(스냅샷)"을 따로 보여줌)
Redis의 근본 해결책: 문제 자체를 없앰
[Redis - 단일 스레드]
Client A: SET balance 800 ──┐
Client B: GET balance ──┤── 순서대로 하나씩만 실행
Client C: INCR counter ──┘
→ 동시 접근이 구조적으로 불가능
→ MVCC 자체가 필요 없음
Redis는 MVCC 대신 "동시성 문제 자체가 발생하지 않는 구조" 를 선택했습니다.
Redis의 핵심 실행 원리: Reactor 패턴 (이벤트 루프)
┌─────────────────────────────────────────────────────────┐
│ Redis Reactor 패턴 │
│ │
│ Client A ──┐ │
│ Client B ──┤──→ [I/O Multiplexing] ──→ [Event Queue] │
│ Client C ──┘ (epoll / kqueue) │ │
│ ▼ │
│ [Single Thread] │
│ 명령1 처리 │
│ 명령2 처리 │
│ 명령3 처리 │
│ (순차 실행) │
└─────────────────────────────────────────────────────────┘
핵심 구성 요소
① I/O Multiplexing (epoll/kqueue)
- 수천 개의 클라이언트 연결을 단 하나의 스레드로 관리
- 이벤트(데이터 수신, 연결 등) 발생 시 감지
② Event Queue
- 처리할 명령들이 순서대로 대기
③ Single Thread Event Loop
- 큐에서 하나씩 꺼내서 처리
- 처리 중에는 절대 다른 명령 끼어들 수 없음
MVCC vs Redis 실행 모델 비교
[MVCC - PostgreSQL 방식]
동시 접근 허용
│
├→ 각 트랜잭션마다 데이터 버전(스냅샷) 생성
├→ 버전 충돌 감지 및 해결
└→ 오래된 버전 정리 (Vacuum)
복잡하지만 동시 처리량 높음
[Redis - Single Thread 방식]
동시 접근 자체를 차단
│
└→ 한 번에 하나의 명령만 실행
(버전 관리, 충돌 감지 불필요)
단순하지만 명령 자체는 초고속
| 항목 | MVCC (RDBMS) | Redis Single Thread |
|---|---|---|
| 동시성 해결 방법 | 버전 관리 | 직렬화 |
| 읽기-쓰기 충돌 | 버전으로 분리 | 순서대로 처리 |
| 구현 복잡도 | 높음 | 낮음 |
| 메모리 사용 | 다중 버전 저장 | 단일 버전 |
| 명령 처리 속도 | 상대적으로 느림 | 초고속 |
Redis에도 "MVCC 유사 개념"이 있나?
WATCH = Redis의 낙관적 버전 체크
[MVCC 스냅샷 격리] [Redis WATCH]
트랜잭션 시작 시 스냅샷 생성 WATCH key (감시 시작)
│ │
내 스냅샷 기준으로 읽기 현재 값 읽기
│ │
커밋 시 변경 여부 확인 EXEC 시 변경 여부 확인
│ │
변경됐으면 → 롤백/재시도 변경됐으면 → nil 반환(재시도)
개념적으로 유사하지만 구현 방식은 완전히 다릅니다. MVCC는 버전 데이터 자체를 유지, WATCH는 변경 감지 플래그만 사용
Redis 6.0+: 멀티스레드 도입 (주의!)
Redis 6.0 이전:
[네트워크 I/O] + [명령 실행] = 모두 단일 스레드
Redis 6.0 이후:
[네트워크 I/O 읽기/쓰기] → 멀티스레드 ← 변경됨
[명령 실행] → 여전히 단일 스레드 ← 변경 없음
┌── I/O Thread 1 ── 소켓 읽기
클라이언트들 ──→ ├── I/O Thread 2 ── 소켓 읽기 → [명령 큐]
└── I/O Thread 3 ── 소켓 읽기 │
▼
[Main Thread - 단일]
명령 실행 (여전히 직렬)
│
┌── I/O Thread 1 ←── 소켓 쓰기 ←────┘
├── I/O Thread 2 ←── 소켓 쓰기
└── I/O Thread 3 ←── 소켓 쓰기
핵심: 네트워크 병목을 멀티스레드로 해결했지만 명령 실행은 여전히 단일 스레드 → 동시성 문제 없음 유지
전체 실행 원리 흐름
┌────────────────────────────────────────────────────────────┐
│ Redis 전체 실행 흐름 │
│ │
│ ① 클라이언트 연결 (epoll이 감지) │
│ │ │
│ ② 네트워크에서 명령 수신 (6.0+: I/O 스레드) │
│ │ │
│ ③ 명령 파싱 → Event Queue에 적재 │
│ │ │
│ ④ Main Thread (단일)가 큐에서 명령 꺼냄 │
│ │ │
│ ⑤ 메모리에서 직접 실행 (ns~μs 단위) │
│ │ ├── String: O(1) │
│ │ ├── Hash/Set: O(1)~O(N) │
│ │ └── Sorted Set: O(log N) │
│ ⑥ 결과 반환 (6.0+: I/O 스레드로 전송) │
└────────────────────────────────────────────────────────────┘
요약
Redis의 근본 전제와 설계 철학
Kafka와 비교로 시작
Kafka가 강제하는 전제:
"메시지는 중복 전달될 수 있다 (At Least Once)"
→ 설계 대응: 멱등성 소비자, Outbox, Processed Event 중복 제거
Redis가 강제하는 전제:
"Redis는 Source of Truth가 아니다"
→ 설계 대응: RDBMS 우선, 캐시 재로딩, 멱등성 연산
Redis에서 받아들여야 할 핵심 전제들
전제 1: "데이터는 언제든 사라질 수 있다" (Data Loss Tolerance)
유실 발생 시나리오:
① Redis 서버 재시작
→ RDB/AOF 없으면 전체 유실
→ AOF everysec이면 최대 1초치 유실
② Maxmemory 초과 → Eviction
→ LRU/LFU 정책으로 키 자동 삭제
③ TTL 만료
→ 의도적 유실 (설계의 일부)
④ 클러스터 Failover
→ Master → Replica 비동기 복제 특성상
최근 쓰기 유실 가능
// 설계 대응: Cache-Aside 패턴
// "없으면 DB에서 가져온다"를 항상 가정
public Product getProduct(Long id) {
String key = "product:" + id;
// Redis에서 먼저 조회
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// ← 유실/만료/미존재 → DB에서 재로딩 (항상 가능해야 함)
Product product = productRepository.findById(id);
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(5));
return product;
}
Redis에 저장된 데이터는 DB에서 언제든 재생성 가능해야 합니다.
전제 2: "부분 실행은 발생할 수 있다" (Partial Execution Tolerance)
이미 논의했던 내용:
DECRBY balance 200 → ✅ 성공 (반영됨)
LPUSH log "data" → ❌ 실패
→ 롤백 없음 → 부분 적용 상태 발생 가능
Kafka의 At Least Once처럼
Redis의 부분 실행도 "발생할 수 있다"를 전제로 설계해야 함
-- 설계 대응 1: 멱등성 연산으로 설계
-- "몇 번 실행해도 결과가 같다"
-- ❌ 비멱등 (중복 실행 시 문제)
redis.call('INCRBY', KEYS[1], ARGV[1]) -- 중복 실행 시 두 번 증가
-- ✅ 멱등 (중복 실행해도 안전)
redis.call('SET', KEYS[1], ARGV[1]) -- 중복 실행해도 같은 결과
redis.call('SETNX', KEYS[1], ARGV[1]) -- 최초 1회만 적용
-- 설계 대응 2: 검증 먼저 → 핵심 명령 마지막
-- 실패 가능성 높은 것을 앞에, 핵심 명령을 뒤에 배치
-- ❌ 핵심 명령이 앞에
redis.call('DECRBY', KEYS[1], ARGV[1]) -- 성공
redis.call('LPUSH', KEYS[2], ARGV[2]) -- 실패 → 잔액만 차감됨
-- ✅ 검증 후 핵심 명령
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance < tonumber(ARGV[1]) then -- 검증
return redis.error_reply('INSUFFICIENT')
end
-- 이 아래 실패해도 재시도 가능하게 설계
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('LPUSH', KEYS[2], ARGV[2])
전제 3: "Redis는 보조 저장소다" (Not Source of Truth)
Kafka의 구조:
RDBMS → Outbox → Kafka → Consumer
(RDBMS가 진실의 원천, Kafka는 전달 수단)
Redis의 구조:
RDBMS → Redis (캐시/보조)
(RDBMS가 진실의 원천, Redis는 가속 수단)
// 설계 대응: DB First, Redis Second
@Transactional
public void processOrder(Order order) {
// ① RDBMS 먼저 (Source of Truth)
orderRepository.save(order); // DB 커밋 보장
stockRepository.decrease(order); // DB 커밋 보장
// ② Redis는 나중에, 실패해도 서비스 지속
try {
redisStockCache.decrease(order.getProductId(), order.getQuantity());
redisOrderCache.save(order);
} catch (Exception e) {
// Redis 실패 = 캐시 미스 (서비스 장애 X)
redisTemplate.delete("stock:" + order.getProductId()); // 무효화
log.warn("Redis 캐시 갱신 실패, 무효화 처리", e);
}
}
전제 4: "복제는 비동기다" (Eventual Consistency)
Redis 복제 구조:
Master ──→ (비동기) ──→ Replica 1
──→ (비동기) ──→ Replica 2
Failover 발생 시:
Master: SET balance 800 (최근 쓰기, 아직 Replica 미전파)
Master 다운!
Replica 1이 새 Master로 승격
→ balance = 1000 (이전 값 그대로) ← 유실!
설계 대응:
강한 일관성이 필요한 데이터 → Redis에 두지 말 것
RDBMS에서 처리
약한 일관성으로 충분한 데이터 → Redis 적합
(캐시, 세션, 카운터, 랭킹 등)
Kafka vs Redis 전제 비교
┌────────────────────┬───────────────────────┬───────────────────────┐
│ │ Kafka │ Redis │
├────────────────────┼───────────────────────┼───────────────────────┤
│ 핵심 전제 │ At Least Once │ Best Effort │
│ │ (중복 전달 허용) │ (유실/부분실패 허용) │
├────────────────────┼───────────────────────┼───────────────────────┤
│ 데이터 유실 │ 없음 (디스크 영속) │ 있을 수 있음 │
├────────────────────┼───────────────────────┼───────────────────────┤
│ 중복 처리 │ 멱등성 소비자 │ 멱등성 연산 │
├────────────────────┼───────────────────────┼───────────────────────┤
│ 실패 시 대응 │ Retry + Processed │ Cache Miss │
│ │ Event 중복 제거 │ → DB 재로딩 │
├────────────────────┼───────────────────────┼───────────────────────┤
│ 재처리 가능성 │ Offset으로 Replay 가능 │ 불가 (소멸됨) │
├────────────────────┼───────────────────────┼───────────────────────┤
│ Source of Truth │ 이벤트 로그 자체 │ RDBMS │
└────────────────────┴───────────────────────┴───────────────────────┘
Redis 전제를 반영한 설계 체계
[Kafka 설계 체계]
전제: At Least Once
│
├→ Outbox Pattern (발행 보장)
├→ Idempotent Consumer (중복 처리)
└→ Processed Event (중복 제거)
[Redis 설계 체계]
전제: Best Effort (유실/부분실패 허용)
│
├→ DB First (유실 대응: RDBMS가 원천)
├→ Cache-Aside (유실 대응: Miss 시 DB 재로딩)
├→ Idempotent 연산 (부분실패 대응: 재시도해도 안전)
├→ TTL 설정 (Eviction 대응: 주기적 재로딩)
└→ 캐시 무효화 (정합성 대응: 의심되면 삭제)
실무 판단 기준
Redis에 저장해도 괜찮은 데이터:
✅ 유실돼도 DB에서 재생성 가능한 것
✅ 약간 stale해도 괜찮은 것 (캐시)
✅ 임시 데이터 (세션, 락, 카운터)
✅ 성능을 위한 중복 저장 데이터
Redis에 단독으로 두면 안 되는 데이터:
❌ 유실 시 복구 불가능한 것 (금융 원장)
❌ 강한 일관성이 필요한 것
❌ 감사 로그 (Audit Log)
❌ 유일한 재고/잔액 데이터
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ Kafka처럼 Redis도 근본 전제를 받아들이고 설계해야 한다 │
│ │
│ Kafka : "At Least Once를 받아들여라" │
│ → 멱등성 소비자, 중복 제거로 대응 │
│ │
│ Redis : "Best Effort를 받아들여라" │
│ → 데이터는 언제든 사라질 수 있다 │
│ → 부분 실행은 발생할 수 있다 │
│ → Redis는 보조다, RDBMS가 원천이다 │
│ │
│ 대응 설계: │
│ ① DB First (RDBMS를 Source of Truth로) │
│ ② Cache-Aside (Miss는 정상, DB에서 재로딩) │
│ ③ 멱등성 연산 (재시도해도 안전하게) │
│ ④ 캐시 무효화 (의심스러우면 삭제, 다음 요청에서 재로딩) │
└─────────────────────────────────────────────────────────────┘
Redis 동작원리 - 실무 관점 완전 정리
1. 단일 스레드 이벤트 루프 (핵심 중의 핵심)
왜 단일 스레드인데 빠른가?
일반적인 오해:
"단일 스레드 = 느리다" ❌
Redis가 빠른 진짜 이유:
① 모든 데이터가 메모리에 있음 (디스크 I/O 없음)
② 단순한 자료구조 (해시, 리스트, 셋 등)
③ 컨텍스트 스위칭 비용 없음
④ Lock/Mutex 오버헤드 없음
[Redis 이벤트 루프 구조]
Client 1 ──┐
Client 2 ──┤──→ [epoll/kqueue] ──→ [Event Queue]
Client N ──┘ (I/O 감지) │
▼
[Main Thread]
┌─────────────────┐
│ 명령 파싱 │
│ 명령 실행 │ ← μs 단위
│ 응답 생성 │
└─────────────────┘
│
[응답 전송]
실무에서 중요한 함의
단일 스레드이므로:
하나의 Heavy 명령 = 전체 Redis Hang
❌ 절대 사용 금지 (O(N) ~ O(N²) 명령)
KEYS * → 전체 키 스캔
FLUSHALL/FLUSHDB → 동기 실행 시
SORT (대용량) → 정렬 비용
LRANGE 0 -1 → 거대한 리스트 전체 조회
SMEMBERS (대용량) → 거대한 셋 전체 조회
HGETALL (대용량) → 거대한 해시 전체 조회
DEL (대용량 컬렉션) → 내부 요소 전체 삭제
✅ 대안
KEYS * → SCAN 0 MATCH * COUNT 100 (커서 기반)
DEL bigkey → UNLINK (비동기 삭제)
FLUSHDB → FLUSHDB ASYNC
[SCAN 커서 기반 동작 원리]
SCAN 0 MATCH user:* COUNT 100
→ 커서 42 반환 (0이 나올 때까지 반복)
SCAN 42 MATCH user:* COUNT 100
→ 커서 156 반환
...
SCAN N MATCH user:* COUNT 100
→ 커서 0 반환 (완료)
핵심: 한 번에 일부만 처리 → 다른 명령 끼어들 수 있음
→ Redis Hang 방지
2. 메모리 관리 (실무 운영 핵심)
내부 인코딩 최적화
Redis는 데이터 크기에 따라 내부 자료구조를 자동으로 변환합니다.
[String]
정수값 → int 인코딩 (별도 메모리 효율적)
44바이트 이하 → embstr (연속 메모리)
44바이트 초과 → raw (동적 할당)
[Hash]
필드 수 ≤ 128 AND 값 크기 ≤ 64 → listpack (연속 메모리, 매우 효율적)
초과 시 → hashtable (자동 변환)
[List]
요소 수 ≤ 512 AND 요소 크기 ≤ 64 → listpack
초과 시 → quicklist
[ZSet (Sorted Set)]
요소 수 ≤ 128 AND 요소 크기 ≤ 64 → listpack
초과 시 → skiplist + hashtable
[Set]
정수만 있고 수 ≤ 512 → intset
초과 시 → hashtable
// 실무 활용: Hash로 객체 저장 시 메모리 최적화
// ❌ String으로 개별 저장 (키 오버헤드 큼)
redisTemplate.opsForValue().set("user:1:name", "Alice");
redisTemplate.opsForValue().set("user:1:age", "30");
redisTemplate.opsForValue().set("user:1:email", "alice@example.com");
// ✅ Hash로 묶어서 저장 (listpack 인코딩 적용)
// 필드 수가 적으면 연속 메모리로 저장 → 메모리 효율 ↑
Map<String, String> user = Map.of(
"name", "Alice",
"age", "30",
"email", "alice@example.com"
);
redisTemplate.opsForHash().putAll("user:1", user);
메모리 단편화 (Fragmentation)
[단편화 발생 원리]
시간이 지남에 따라:
키 생성/삭제 반복
→ 메모리 블록이 들쭉날쭉
→ 실제 사용량보다 더 많은 메모리 점유
모니터링:
INFO memory
→ mem_allocator_frag_ratio (1.5 이상이면 단편화 심각)
→ used_memory (실제 데이터 크기)
→ used_memory_rss (OS가 보는 Redis 메모리)
대응:
CONFIG SET activedefrag yes (자동 조각 모음)
CONFIG SET active-defrag-ignore-bytes 100mb
CONFIG SET active-defrag-threshold-lower 10
Eviction (메모리 초과 시 키 제거)
maxmemory 설정 초과 시 정책:
정책 동작 사용 케이스
─────────────────────────────────────────────────────────
noeviction │ 쓰기 에러 반환 │ ❌ 캐시에 절대 사용 금지
allkeys-lru │ 전체 키 중 LRU 제거 │ ✅ 일반 캐시
volatile-lru │ TTL 있는 키 중 LRU 제거 │ ✅ 중요 데이터 보호 필요 시
allkeys-lfu │ 전체 키 중 LFU 제거 │ ✅ 접근 빈도 기반 (Redis 4.0+)
volatile-ttl │ TTL 짧은 키부터 제거 │ 특수 케이스
allkeys-random│ 전체 키 중 랜덤 제거 │ 거의 안 씀
# 실무 설정
maxmemory 4gb
maxmemory-policy allkeys-lfu # LFU 권장 (실제 사용 빈도 기반)
maxmemory-samples 10 # LRU/LFU 샘플링 수 (높을수록 정확, 느림)
3. 만료(Expiration) 동작 원리
Redis TTL 처리 방식: 두 가지 병행
① Lazy Expiration (지연 만료)
키에 접근할 때 만료 여부 확인
→ 접근 안 하면 메모리에 계속 남아있음!
② Active Expiration (능동 만료)
100ms마다 TTL 있는 키 중 랜덤 20개 샘플링
→ 만료된 것 제거
→ 만료 비율 25% 이상이면 즉시 재샘플링 반복
실무 함의:
TTL이 지났다고 즉시 메모리에서 사라지지 않음!
극단적 예:
TTL 있는 키 10만개 → 동시에 TTL 만료
→ Active Expiration이 따라가지 못함
→ 메모리 일시적 급증 가능
대응:
TTL Jitter 적용 (만료 시간 랜덤화)
→ 동시 만료 분산
// ❌ 동시 만료 위험
redisTemplate.opsForValue().set("cache:1", data, Duration.ofMinutes(5));
redisTemplate.opsForValue().set("cache:2", data, Duration.ofMinutes(5));
redisTemplate.opsForValue().set("cache:3", data, Duration.ofMinutes(5));
// 5분 후 모두 동시 만료 → Cache Stampede + 메모리 급증
// ✅ TTL Jitter 적용
long baseTtl = 300; // 5분
long jitter = ThreadLocalRandom.current().nextLong(60); // 0~60초 랜덤
redisTemplate.opsForValue().set(key, data, Duration.ofSeconds(baseTtl + jitter));
4. 영속성 (Persistence)
RDB vs AOF 동작 원리
[RDB - 스냅샷 방식]
Redis ──fork()──→ Child Process
│
├→ 현재 메모리 전체 스냅샷 → dump.rdb 파일
│ (Copy-on-Write 활용)
└→ 완료 후 종료
특성:
✅ 파일 크기 작음 (압축)
✅ 재시작 복구 빠름
❌ 마지막 스냅샷 이후 데이터 유실
❌ fork() 시 메모리 사용량 일시적 2배 가능
[AOF - 명령 로그 방식]
명령 실행 → AOF 버퍼 기록 → 디스크 flush
fsync 옵션:
always → 명령마다 fsync (가장 안전, 느림)
everysec → 1초마다 fsync (권장, 최대 1초 유실)
no → OS에 맡김 (빠름, 유실 범위 불명확)
특성:
✅ 데이터 유실 최소화
❌ 파일 크기 커짐 → AOF Rewrite로 압축
❌ 재시작 복구 느림
[실무 권장 설정]
# RDB + AOF 동시 사용 (Redis 7.0+: RDB-AOF 혼합 모드 권장)
save 3600 1 # 1시간 내 1번 변경
save 300 100 # 5분 내 100번 변경
save 60 10000 # 1분 내 10000번 변경
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100 # AOF 크기 2배되면 rewrite
auto-aof-rewrite-min-size 64mb
[Copy-on-Write 원리 - fork() 시 메모리 관리]
fork() 직후:
Parent(Redis) ──┐
├── 같은 메모리 페이지 공유 (복사 아님)
Child(RDB저장) ──┘
Parent에서 쓰기 발생 시:
해당 페이지만 복사 → Parent는 새 페이지에 씀
Child는 원본 페이지 읽어서 스냅샷 생성
실무 주의:
쓰기가 많은 Redis에서 RDB fork 시
→ 메모리 사용량 최대 2배 급증 가능
→ maxmemory를 물리 메모리의 50%로 제한하는 이유
5. 복제 (Replication) 동작 원리
[최초 복제 - Full Sync]
Master Replica
│ │
│←────── PSYNC ? -1 ────────────────│ (최초 연결)
│ │
│──fork() → RDB 생성────────────────│
│ (이 동안 새 명령은 버퍼에 저장) │
│ │
│──────── RDB 전송 ─────────────────→│ (Full Sync)
│ │ RDB 로드
│──────── 버퍼 명령 전송 ────────────→│
│ │
│──────── 이후 실시간 전파 ──────────→│ (Partial Sync)
[재연결 - Partial Sync (Redis 2.8+)]
Replica 재연결 시:
→ replication offset + replication ID 전송
→ Master의 repl_backlog에 offset 이후 명령이 있으면
→ 해당 부분만 전송 (Full Sync 방지)
→ backlog에 없으면 (너무 오래 끊김)
→ Full Sync 재실행
# 실무 설정
repl-backlog-size 256mb # 재연결 시 Full Sync 방지 버퍼
repl-backlog-ttl 3600 # Replica 없으면 1시간 후 해제
min-replicas-to-write 1 # Replica 1개 이상 동기화 시만 쓰기 허용
min-replicas-max-lag 10 # Replica lag 10초 이하 조건
[비동기 복제의 실무 함의]
Master: SET balance 800 (성공 응답)
│
└→ Replica로 비동기 전파 중...
(이 시점에 Master 다운!)
Replica 승격 후:
GET balance → 1000 ← 유실! (800이어야 하는데)
이것이 바로 앞서 이야기한 "Redis는 Source of Truth가 아니다"의 이유
6. 클러스터 (Cluster) 동작 원리
[Hash Slot 분산]
Redis Cluster = 16384개의 슬롯
CRC16(key) % 16384 → 슬롯 번호 결정
예:
"user:1" → CRC16 → slot 5649 → Node A
"product:1"→ CRC16 → slot 9883 → Node B
"order:1" → CRC16 → slot 4396 → Node A
[Hash Tag - 슬롯 강제 지정]
{} 안의 내용으로만 슬롯 계산
"{user:1}:profile" ─┐
"{user:1}:session" ─┤── 모두 slot 5649 (Node A)
"{user:1}:cart" ─┘
→ Lua Script, MULTI/EXEC, MGET 함께 사용 가능
[클러스터 리다이렉션]
Client → Node A: GET product:1
Node A → MOVED 9883 Node B (영구 이동)
Client → Node B: GET product:1
Node B → "data"
MOVED: 슬롯이 영구적으로 다른 노드에 있음
ASK: 리샤딩 중 일시적으로 다른 노드에 있음
[클러스터 Failover]
Node A (Master) 다운
│
├→ Node A의 Replica들이 감지 (gossip protocol)
├→ Replica 간 투표 (과반수 동의)
└→ 새 Master 선출 → 서비스 재개
소요 시간: 기본 15~30초
cluster-node-timeout 5000 # 5초로 줄이면 빠른 감지
7. 연결 관리 (Connection)
[Connection Pool 동작]
애플리케이션 ──→ [Connection Pool] ──→ Redis
┌─────────────────┐
│ conn1 (사용 중) │
│ conn2 (유휴) │
│ conn3 (유휴) │
└─────────────────┘
핵심 설정:
maxTotal : 최대 연결 수 (기본 8 → 실무 50~200)
maxIdle : 최대 유휴 연결 수
minIdle : 최소 유휴 연결 수 (미리 열어둠)
maxWaitMillis : 연결 대기 최대 시간
// Spring Boot + Lettuce 연결 풀 설정
@Configuration
public class RedisConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration serverConfig =
new RedisStandaloneConfiguration("localhost", 6379);
LettucePoolingClientConfiguration poolConfig =
LettucePoolingClientConfiguration.builder()
.poolConfig(buildPoolConfig())
.commandTimeout(Duration.ofMillis(500)) // 명령 타임아웃
.shutdownTimeout(Duration.ofMillis(200))
.build();
return new LettuceConnectionFactory(serverConfig, poolConfig);
}
private GenericObjectPoolConfig<?> buildPoolConfig() {
GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(100); // 최대 연결
config.setMaxIdle(50); // 최대 유휴
config.setMinIdle(10); // 최소 유휴 (미리 열어둠)
config.setMaxWait(Duration.ofMillis(1000));
config.setTestOnBorrow(true); // 빌릴 때 연결 검증
config.setTestWhileIdle(true); // 유휴 중 주기적 검증
return config;
}
}
[실무 연결 문제 패턴]
문제 1: Connection Timeout
원인: Pool 고갈 (모든 연결 사용 중)
확인: redis-cli INFO clients → connected_clients
대응: maxTotal 증가, 명령 최적화 (느린 명령 제거)
문제 2: Too Many Connections
원인: 연결 미반환, Pool 설정 오류
확인: INFO clients → connected_clients 급증
대응: maxTotal 제한, 연결 반환 로직 확인
문제 3: Connection Reset
원인: Redis timeout 설정 vs 연결 유휴 시간 불일치
대응: tcp-keepalive 300 설정
testWhileIdle + timeBetweenEvictionRuns 설정
8. 자료구조별 실무 선택 기준
┌──────────────┬────────────┬──────────────────────────────────┐
│ 자료구조 │ 시간복잡도 │ 실무 사용 케이스 │
├──────────────┼────────────┼──────────────────────────────────┤
│ String │ O(1) │ 캐시, 카운터, 분산락 │
│ Hash │ O(1) │ 객체 저장, 필드별 업데이트 │
│ List │ O(1)/O(N) │ 큐, 최근 N개 기록 │
│ Set │ O(1) │ 중복 제거, 태그, 팔로워 │
│ ZSet │ O(log N) │ 랭킹, 순서 있는 데이터 │
│ Bitmap │ O(1)/O(N) │ 출석 체크, 일별 활성 사용자 │
│ HyperLogLog │ O(1) │ 유니크 방문자 수 (근사치) │
│ Stream │ O(log N) │ 메시지 큐 (Kafka 유사) │
└──────────────┴────────────┴──────────────────────────────────┘
// ZSet으로 실시간 랭킹 구현
// ZADD: O(log N), ZRANGE: O(log N + M)
// 점수 업데이트
redisTemplate.opsForZSet().add("ranking:game", "user:1", 1500.0);
// 상위 10명 조회 (내림차순)
Set<ZSetOperations.TypedTuple<String>> top10 =
redisTemplate.opsForZSet()
.reverseRangeWithScores("ranking:game", 0, 9);
// Bitmap으로 일별 출석 체크
// SETBIT: O(1), BITCOUNT: O(N)
String key = "attendance:2026:03:25";
redisTemplate.opsForValue().setBit(key, userId, true); // 출석
Long totalAttendance = redisTemplate.execute(
(RedisCallback<Long>) conn -> conn.bitCount(key.getBytes())
);
9. 실무 모니터링 핵심 지표
# 한 번에 전체 상태 확인
redis-cli INFO all
# 핵심 지표별 확인
redis-cli INFO stats # 처리량, hit/miss
redis-cli INFO memory # 메모리 사용
redis-cli INFO clients # 연결 현황
redis-cli INFO replication # 복제 상태
redis-cli INFO persistence # RDB/AOF 상태
┌─────────────────────────┬────────────┬──────────────────────┐
│ 지표 │ 위험 기준 │ 대응 │
├─────────────────────────┼────────────┼──────────────────────┤
│ used_memory │ max 80%↑ │ TTL 점검, 불필요 키 │
│ mem_fragmentation_ratio │ 1.5↑ │ activedefrag 활성화 │
│ keyspace_hit_rate │ 80%↓ │ TTL/캐시 전략 점검 │
│ connected_clients │ 급증 │ Pool 설정 점검 │
│ blocked_clients │ 0 이상 │ 원인 명령 조사 │
│ rejected_connections │ 0 이상 │ maxclients 조정 │
│ master_repl_offset lag │ 급증 │ 네트워크/부하 점검 │
│ rdb_last_bgsave_status │ err │ 디스크/메모리 점검 │
└─────────────────────────┴────────────┴──────────────────────┘
# Slow Query 모니터링 (실무 필수)
CONFIG SET slowlog-log-slower-than 10000 # 10ms 이상 기록
CONFIG SET slowlog-max-len 128
SLOWLOG GET 10 # 최근 10개 확인
# Big Key 탐지 (메모리 급증 원인)
redis-cli --bigkeys # 자료구조별 가장 큰 키
redis-cli --memkeys # 메모리 사용량 기준
MEMORY USAGE key # 특정 키 메모리 사용량
10. 실무 운영 핵심 체크리스트
┌─────────────────────────────────────────────────────────────┐
│ Redis 실무 운영 체크리스트 │
├──────────────────────────────────────────────────────────────┤
│ 설계 │
│ ✅ 모든 키에 TTL 설정 (메모리 누수 방지) │
│ ✅ Big Key 방지 (컬렉션 요소 수 제한) │
│ ✅ Hash Tag로 관련 키 슬롯 그룹핑 │
│ ✅ RDBMS를 Source of Truth로, Redis는 보조 │
├──────────────────────────────────────────────────────────────┤
│ 성능 │
│ ✅ KEYS * → SCAN 으로 교체 │
│ ✅ DEL (big key) → UNLINK 로 교체 │
│ ✅ 대량 처리 → Pipeline 사용 │
│ ✅ 조건부 로직 → Lua Script 사용 │
├──────────────────────────────────────────────────────────────┤
│ 메모리 │
│ ✅ maxmemory + eviction policy 설정 │
│ ✅ activedefrag 활성화 │
│ ✅ fork() 고려해 maxmemory ≤ 물리메모리 50% │
├──────────────────────────────────────────────────────────────┤
│ 가용성 │
│ ✅ Sentinel 또는 Cluster 구성 (단일 노드 금지) │
│ ✅ Replica 최소 1개 이상 │
│ ✅ repl-backlog-size 충분히 설정 │
├──────────────────────────────────────────────────────────────┤
│ 모니터링 │
│ ✅ hit rate, memory, latency, connection 알람 설정 │
│ ✅ slowlog 주기적 확인 │
│ ✅ bigkey 주기적 스캔 │
└──────────────────────────────────────────────────────────────┘
Redis는 Queue로 하나씩 처리하는가?
결론
네, 맞습니다.
단, 정확히는 "Event Queue + 단일 스레드" 구조입니다.
전체 구조
Client 1: SET key1 val1 ──┐
Client 2: GET key2 ──┤
Client 3: INCR counter ──┤──→ [epoll] ──→ [Event Queue] ──→ [Main Thread]
Client 4: LPUSH list 1 ──┤ ┌──────────┐ 하나씩 처리
Client 5: HSET hash f v ──┘ │ 명령 1 │──→ 실행 → 응답
│ 명령 2 │──→ 실행 → 응답
│ 명령 3 │──→ 실행 → 응답
│ 명령 4 │──→ 실행 → 응답
│ 명령 5 │──→ 실행 → 응답
└──────────┘
정확한 처리 흐름
① 클라이언트들이 동시에 명령 전송
② epoll/kqueue가 "어떤 소켓에 데이터 왔는지" 감지
→ 이벤트 발생한 소켓들을 Event Queue에 적재
③ Main Thread가 Queue에서 하나씩 꺼내서 처리
→ 명령 읽기 → 실행 → 응답
→ 완전히 끝나야 다음 명령 처리
④ 처리 중에는 다른 명령 절대 끼어들기 불가
"Queue처럼 동작한다"는 표현이 맞지만 주의할 점
1. 명령 실행 순서는 "도착 순서"가 아님
Client 1: SET key1 val1 → 전송
Client 2: GET key2 → 전송
동시에 전송했을 때 순서는
네트워크 상태, OS 스케줄링에 따라 결정됨
"먼저 보냈다" ≠ "먼저 처리된다"
→ Redis는 "먼저 도착한 순서대로" 처리
2. Queue에 쌓이는 단위는 "명령" 단위
Client 1: MULTI ┐
SET k1 v1 ├─ 이 4개가 연속으로 Queue에 쌓임
SET k2 v2 │
EXEC ┘
중간에 다른 클라이언트 명령이 끼어들 수 있음!
(MULTI~EXEC 사이에도 마찬가지)
예:
Queue: [MULTI] [SET k1] [GET other] [SET k2] [EXEC]
↑
다른 클라이언트 명령 끼어듦!
→ 이것이 Lua Script / MULTI/EXEC가 필요한 이유
3. 실제로 "Queue"라는 자료구조를 쓰는 건 아님
정확한 구조:
epoll이 이벤트 발생한 소켓 목록을 반환
→ Main Thread가 소켓에서 명령 읽기
→ 즉시 실행
→ 다음 소켓 처리
별도의 Queue 자료구조가 있는 게 아니라
이벤트 루프 자체가 순차 처리를 보장
실제로 어떻게 "동시처럼" 보이는가
Client 1: SET (1ms 걸림) ──→ 처리
Client 2: GET (0.1ms) ──→ 처리 ← Client 1 완료 후 즉시
Client 3: INCR (0.1ms) ──→ 처리
총 소요: 1.2ms
Client 1 입장: "즉시 응답 받음"
Client 2 입장: "즉시 응답 받음"
Client 3 입장: "즉시 응답 받음"
→ 각 명령이 μs 단위로 빠르기 때문에
"동시에 처리되는 것처럼" 보임
실제로는 순차 처리
Heavy 명령이 있으면 어떻게 되는가
Queue: [KEYS *] [GET k1] [GET k2] [SET k3]
↑
10만개 키 순회 (100ms 걸린다고 가정)
Client 1: KEYS * → 100ms 후 응답
Client 2: GET k1 → 100ms + 0.1ms 후 응답 (기다려야 함)
Client 3: GET k2 → 100ms + 0.2ms 후 응답 (기다려야 함)
Client 4: SET k3 → 100ms + 0.3ms 후 응답 (기다려야 함)
→ KEYS * 하나가 전체 Redis를 블로킹!
→ 단일 스레드의 치명적 단점
→ KEYS * 금지 이유
Redis 6.0+ 에서 달라진 점
Redis 6.0 이전:
네트워크 읽기/쓰기 + 명령 실행 = 모두 단일 스레드
Redis 6.0 이후:
네트워크 읽기/쓰기 → I/O Thread (멀티)
명령 실행 → Main Thread (여전히 단일)
[I/O Thread 1] 소켓 읽기 ──┐
[I/O Thread 2] 소켓 읽기 ──┤──→ [명령 Queue] ──→ [Main Thread 단일]
[I/O Thread 3] 소켓 읽기 ──┘ 하나씩 실행
→ "명령 실행은 여전히 하나씩"
→ 네트워크 병목만 멀티스레드로 해결
최종 요약
┌─────────────────────────────────────────────────────────────┐
│ │
│ Redis = "Queue + 단일 스레드" 구조 │
│ │
│ ✅ 맞는 말: │
│ 명령은 순차적으로 하나씩 실행됨 │
│ 실행 중 다른 명령 끼어들기 불가 │
│ │
│ ⚠️ 정확히 이해할 것: │
│ Queue 자료구조가 있는 게 아니라 │
│ 이벤트 루프가 순차 처리를 보장 │
│ │
│ 명령 도착 순서 = 네트워크 도착 순서 │
│ (보낸 순서와 다를 수 있음) │
│ │
│ 💡 실무 함의: │
│ Heavy 명령 1개 = 전체 블로킹 │
│ → O(N) 명령 금지 이유 │
│ → Lua Script 원자성 보장 이유 │
│ │
└─────────────────────────────────────────────────────────────┘
MULTI ~ EXEC 사이에 끼어들기
핵심 이해
MULTI ~ EXEC 사이에 있는 명령들은
"즉시 실행"이 아니라 "큐에 저장"됩니다.
→ 저장되는 동안 다른 클라이언트 명령은 정상 실행됨
타임라인으로 보기
Client A Client B
────────────────────────────────────────────────────────
MULTI
→ "OK"
SET key1 "A가 저장"
→ "QUEUED" (실행 안 됨! 큐에 저장만)
SET key1 "B가 끼어듦"
→ "OK" (즉시 실행됨!)
SET key2 "A데이터"
→ "QUEUED" (큐에 저장만)
EXEC
→ key1 = "A가 저장" (A의 SET이 B를 덮어씀)
→ key2 = "A데이터"
실제 Redis에 저장된 최종 값:
key1 = "A가 저장" ← B가 저장했지만 A의 EXEC가 덮어씀
key2 = "A데이터"
B 입장:
분명히 SET key1 "B가 끼어듦" 성공했는데
A의 EXEC 이후 key1이 바뀌어 있음
더 위험한 시나리오: 잔액 이체
초기값: balance = 1000
Client A (이체 로직) Client B (다른 작업)
────────────────────────────────────────────────
GET balance
→ 1000 (현재 잔액 확인)
MULTI
SET balance 500 ← 끼어들기!
→ "OK" (즉시 실행)
(balance = 500)
DECRBY balance 200
→ "QUEUED"
EXEC
→ balance = 300 ← ???
결과:
원래 의도: 1000 - 200 = 800
실제 결과: 500 - 200 = 300 ← 잘못된 값!
왜 이렇게 됐나?
① GET balance → 1000 (A가 확인)
② B가 balance = 500으로 변경
③ EXEC → DECRBY balance 200 실행
(현재 balance = 500에서 200 차감)
④ 결과 = 300
A는 1000 기준으로 판단했지만
실제 DECRBY는 500에서 실행됨!
이것을 막는 방법들
방법 1: WATCH (변경 감지)
Client A
────────────────────────────────────────
WATCH balance ← 감시 시작
GET balance → 1000
MULTI
DECRBY balance 200
EXEC
→ nil ← B가 중간에 변경했으므로 실패!
재시도 필요
→ 적어도 "잘못된 값"으로 처리되는 건 막음
방법 2: Lua Script (완전한 원자 보장)
-- GET ~ DECRBY 전체가 하나의 원자 단위
-- 이 실행 중에는 Client B 끼어들기 자체가 불가능
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
end
return 0
Client A (Lua) Client B
────────────────────────────────────────────────
EVAL script 시작
GET balance → 1000
SET balance 500
→ 대기!! (Lua 실행 중 불가)
DECRBY balance 200
EVAL script 종료
→ 이제 실행됨
(이미 800이 된 상태에서)