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

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이 된 상태에서)

정리