내일배움캠프

[내일배움캠프] Redis 심화

munsik22 2026. 5. 12. 13:06

🧩 Redis 코어 아키텍처와 기초

캐시가 필요한 이유

  • 자주 쓰는 데이터를 조회하기 위해 모든 데이터를 보관하는 DB를 매번 조회하면 접근 시간이 오래 걸림
  • 단일 DB에 초과 트래픽이 발생하면 디스크 I/O의 한계로 인해 서버가 다운됨

Redis가 그래서 뭔데?

  • 파이썬의 dict()나 자바의 HashMap처럼 Key-Value 쌍으로 이루어진 데이터 구조
  • In-Memory가 빠른 이유: RAM(메모리)은 순수하게 논리회로를 통해 전자의 이동만으로 데이터를 읽고 쓰므로 ns 단위의 접근 속도를 가지지만, 디스크의 경우 아무리 빨라도 μs 단위임

왜 싱글 스레드를 쓴대?

  • 여러 스레드가 동시에 데이터에 접근하면 컨텍스트 스위칭 오버헤드와 락 경합 문제가 발생해서 데이터가 꼬일 수 있음
  • Redis는 메모리 연산 자체가 빠르기 때문에 락 걸고 기다린 바에는 혼자서 빨리 처리하는 싱글 스레드 방식을 채택힘

캐싱 전략 시나리오

  • Cache-Aside (Look-Aside)
    • Redis에서 먼저 찾고 없으면 DB에서 가져와 캐시에 저장하는 대중적인 Read 전략
  • Write-Back
    • 모든 데이터를 Redis에 몰아서 저장하고 주기적으로 모아서 DB에 한 번에 쓰는(Batch) 전략
    • 장애 발생 시 데이터 유실이 발생할 수 있음
  • 캐시 무효화: 수동 무효화 + TTL 또는 Write-Through 활용
    • TTL: 변동성이 낮은 데이터는 24시간~1주일, 변동이 잦은 데이터는 1~5초로 설정
    • 캐시 스탬피드(Cache Stampede): 캐시 TTL을 일괄적으로 설정했을 때, 캐시가 한꺼번에 사라지면서 수 만명의 요청이 동시에 Cache Miss를 발생, 그대로 DB가 다운되는 현상. 막기 위해서는 기본 TTL 값에 1~5분 사이의 랜덤한 난수(Jitter)를 설정해야 함

Redis에서 사용하는 자료구조

  • String: 단순 텍스트 또는 카운터 저장
  • List: Queue 용도로 사용
  • Set: 순서X, 중복X (※ SMEMBERS로 한번에 가져오는 대신 SSCAN으로 쪼개서 가져오는 게 안전함)
  • Sorted Set (ZSET): Set에 Score를 추가해 자동으로 정렬하는 집합 (※ Score는 Double 타입으로 저장됨)
  • Hash: 하나의 Key안에 여러개의 Field와 Value를 넣을 수 있는 구조

🧩 MSA 분산 환경의 동시성 제어와 비동기 통신

동시성 문제와 락

  • 한정판 상품 재고가 1개 뿐인 상황에서 동시 결제 요청이 100건 발생하면? → N명에게 결제 완료가 승인되는 Over-sell이 발생함
  • 분산 락(Distributed Lock): Redis에 설정된 열쇠를 가진 사람만 접근 가능
    • SENTX 방식의 문제점: 서버 A에서 락이 걸린 상태에서 서버가 죽은 경우 키가 Redis에 영원히 남게 되어 다른 모든 서버들이 데드락에 걸림
    • 이러한 문제를 해결하기 위해 락 획득과 만료시간 설정을 원자적으로 처리하는 SET key value NX EX 명령어가 도입됨
  • TTL의 딜레마
    • 너무 짧게 잡으면 락 시간보다 로직 수행 시간이 더 오래 걸려 락을 건 의미가 없어질 수 있음 (Mutual Exclusion 발생)
    • 락을 너무 오래 잡으면 오류가 발생했을 때 그동안 다른 서버들은 아무것도 못하고 계속 실패함
    • 이 문제를 해결하기 위해 Redisson의 Watchdog으로 해결 가능

💡 TTL이 3초고 결제 로직 수행이 5초면 서버 A와 B가 동시에 결제하는 문제 발생 가능
  락의 value에 고유 UUID를 넣어 락을 지울 때 검증하는 로직이 필요함

동시성 제어 기술 비교

비교 항목 DB 비관적 락 DB 낙관적 락 Redis 기본 분산 락 Redisson (RLock)
원리 SELECT FOR UPDATE Version 컬럼 확인 싱글 스레드 Spinlock Pub/Sub 기반 이벤트 대기
성능 (처리량) 최하 상 (충돌X) 중~상 최상
데드락 위험 X 하 (TTL 방어 필요) 하 (Watchdog 내장)
구현 난이도 최상
대표 시나리오 금융 코어 회원정보 수정 가벼운 동시성 제어 선착순 쿠폰, 타임세일
  • 일반적으로 재고 영역에 대해서는 낙관적 락을 사용하지만, 쿠폰 등의 도메인에서는 비관적 락을 사용함
  • 물론 어떤 방법이든지 기술적인 부채가 있기 때문에 한가지 기술만으로 해결하려고 하면 안 됨

예시: 한정 수략 선착순 쿠폰 발급

  • 직접 구현한 Redis 분산 락
    • 락을 얻을 때까지 무한 루프를 도는 Spinlock: Thread.sleep 없이 돌면 Redis에 초당 수만 번 요청을 때려 Redis CPU가 터짐
    • 락의 TTL(3초)보다 비즈니스 로직이 길어지면 락이 풀려버림
    • try-if문 로직이 트랜잭션으로 묶여 있으면 락이 해제된 이후에 DB 커밋이 발생해 동시성이 깨질 수 있음
    • Exception이 터져도 락이 풀리도록 반드시 finally 안에서 해제해야 함
    • 남의 락을 내가 풀 수도 있기 때문에 Lua 스크립트로 value 검증이 필요함
public void issueCouponDirect(String userId, String couponId) {
    String lockKey = "lock:coupon:" + couponId;
    String uuid = UUID.randomUUID().toString();

    while (!redisTemplate.opsForValue().setIfAbsent(lockKey, uuid, Duration.ofSeconds(3))) {
        try { Thread.sleep(50); } catch (Exception e) {}
    }

    try {
        if (couponRepository.countById(couponId) > 0) {
            couponRepository.issue(userId, couponId);
        }
    } finally {
        if (uuid.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}
  • Redisson을 활용한 분산 락
    • Redisson은 위에서 언급된 문제점을 알아서 처리하기 때문에 많이들 사용함
public void issueCouponRedisson(String userId, String couponId) {
    String lockKey = "lock:coupon:" + couponId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        // 💡 [사용법 1] Watchdog이 동작하는 방식 (기본 권장)
        // 매개변수: (최대 대기 시간, 시간 단위)
        // 10초 동안 락 획득을 대기. 락 획득 시 작업이 끝날 때까지
        // Redisson의 Watchdog이 기본 30초마다 만료 시간을 자동 연장함
        boolean isLocked = lock.tryLock(10, TimeUnit.SECONDS);

        /*
        // 💡 [사용법 2] Watchdog이 동작하지 않는 방식 (주의 요망!)
        // 매개변수: (최대 대기 시간, 임대 시간(leaseTime), 시간 단위)
        // 10초 대기 후 락을 획득하면, 비즈니스 로직이 끝나든 말든 무조건 3초 뒤에 락이 해제됨
        // 작업이 3초를 넘어가면 상호 배제가 깨질 수 있으므로, 장애 시 강제 해제용으로만 써야함
        boolean isLocked = lock.tryLock(10, 3, TimeUnit.SECONDS);
        */

        if (!isLocked) {
            throw new RuntimeException("대기열 초과. 잠시 후 다시 시도해주세요.");
        }

        // 안전한 비즈니스 로직 수행
        if (couponRepository.countById(couponId) > 0) {
            couponRepository.issue(userId, couponId);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        // 현재 스레드가 락을 쥐고 있는지 자체 검증 후 안전하게 해제
        if (lock.isLocked() && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
[분산 락 (Distributed Lock)]
여러 대의 서버(프로세스)가 공유 자원에 동시에 접근할 때 데이터의 정합성을 유지하기 위해 오직 하나의 프로세스만 작업할 수 있도록 제한하는 잠금 메커니즘
▪ Race Condition 해결: 한 번에 단 하나의 프로세스만 임계 영역에 진입하도록 보장함
▪ 동시성 이슈 해결: 여러 노드가 동시에 같은 DB를 수정하는 상황 방지
▪ Redis를 활용해서 락 획득 대기 관리 가능
▪ 주의점: 락 획득 타임아웃, 락 해제 보장(데드락 방지) 처리 중요

[Lettuce]
▪ SETNX, SETEX를 사용한 스핀 락 방식: 락을 획득할 때까지 Redis에 반복적으로 요청을 보냄
▪ 락을 얻을 때까지 계속 요청을 보내므로 CPU 자원을 많이 소모함
▪ 락을 가진 서버가 다운되면 Redis에 키가 유지되어 데드락 상태에 빠질 수 있음
▪ 타임아웃, 재시도, 락 해제 로직을 직접 구현해야 함
▪ 매우 간단한 작업 또는 저트래픽 환경 (예: 배치 작업 중복 실행 방지, API 중복 요청 차단 등)

[Redisson]
▪ Redis Pub/Sub을 사용하는 구조: 락이 해제되면 대기 중인 클라이언트에게 알림을 보냄
▪ 알림을 받을 때만 재시도를 하므로 Redis 서버 부담이 줄어듦
▪ 락 획득 대기 시간, 만료 시간 설정 및 자동 연장(Watchdog) 기능을 제공함
▪ 일반적인 기업형 서비스, 고트래픽 환경 (예: 선착순 이벤트 및 티켓팅, 주문/결제 시스템 등)
▪ 예: 마켓컬리에서도 Redisson을 사용함

[RedLock]
▪ 다중화 구조: 독립된 5대 이상의 Redis 노드에 순차적으로 락을 요청함
▪ 락 획득 한 번에 여러 대의 Redis 노드와 통신해야 하므로 서버 부하가 높음
▪ 과반수 이상의 노드가 살아 있으면 락의 안정성이 보장됨
▪ 여러 개의 독립된 Redis 클러스터를 관리해야 하므로 인프라가 복잡해짐
▪ 금융, 결제, 자산 관리 등 데이터 오류가 절대 용납되지 않는 환경

서비스 간 통신: Redis Pub/Sub vs Message Queue

  • Redis Pub/Sub: Publisher가 채널에 메시지를 보내면, 연결된 Subscriber만 메시지를 받을 수 있음
    • 배포를 위해 1분간 연결이 끊긴다면 그 1분동안 발송된 메시지는 영원히 사라짐 (Fire-and-Forget)
    • 결제 완료 알림 발송 서비스에서 Redis Pub/Sub를 단독으로 쓰게 되면 유실 시 추적이 불가능해짐
  • Message Queue (Kafka, RabbitMQ, Redis Stream): 메시지를 안전하게 보관하고 수신 확인(ACK)을 받아야만 삭제함
    • 여기서는 Kafka를 메시지를 저장하는 디스크 정도로만 이해하면 됨
비교 항목 Redis Pub/Sub Kafka
메시지 보관 여부 X (즉시 휘발) O (디스크 또는 메모리에 보관)
수신 확인 (Ack) X O (Consumer가 Ack를 보내야 지움)
처리보장 수준 낮음 (유실 가능성 큼) 매우 높음 (At lease once)
성능 속도 최상
운영 복잡도 최하 최상
사용 예시 실시간 채팅, 주식 호가 갱신 결제 완료 이벤트, 절대 유실되면 안 되는 데이터 스트림
 

우리 팀은 카프카를 어떻게 사용하고 있을까 | 우아한형제들 기술블로그

누가 읽으면 좋을까? 카프카(Kafka)가 무엇인지 알고 있는 독자를 대상으로 합니다. 기술적 구현방식을 다루기보단 카프카를 기반으로 한 다양한 기술적 개념에 대해서 얇고 넓게 소개하고, 우리

techblog.woowahan.com

실무 사용 예시

  • 배민 창고 물류 동시성 제어
    • Problem: 재고 할당과 취소가 동시 발생 시 실제 재고와 DB가 불일치됨. DB 락 사용 시 전체 시스템 마비 위험
    • Solution: 가벼운 Redis 분산 락(SETNX) 적용
  • 토스 외화 예금 입출금
    • Problem: 0.1초만에 여러 결제/환전 요청이 동일 계좌로 쏟아짐 → 초과 출금 발생
    • Solution: Redis 분산 락(Redisson) + MySQL DB 락
  • 올리브영 선착순 쿠폰
    • 기존: Redis Pub/Sub + (Kafka 대신) Redis List 사용 (Pub/Sub은 비동기 Worker 트리거 용도로만 사용)
    • 문제: 검증 지연 시간으로 인한 Time Gap 발생 및 이중 검증 오버헤드 발생
    • 해결: 발급 요청 수량 체크 용도의 별도 Redis Key 추가 관리 (이중 카운터)
 

올영세일 선착순 쿠폰, 미발급 0%를 향한 여정 | 올리브영 테크블로그

Redis와 Message Queue로 구축한 비동기 시스템의 정합성 개선기

oliveyoung.tech

분산 락의 함정

  • 분산 락이 항상 정답은 아님
  • 락은 획득-작업-해제의 네트워크 왕복이 3번이나 발생하지만, 단순한 카운팅이나 재고 차감이라면 락 없이 Redis의 원자적 명령어(INCR, DECR)를 사용하는 것이 훨씬 빠름
  • 복잡한 로직도 Lua 스크립트를 쓰면 싱글 스레드 환경에서 원자적으로 실행되어 락 없이 제어가 가능함

🧩 운영과 장애 방지를 위한 인프라 생존 가이드

운영 환경에서 절대 사용하면 안되는 명령어들

  • KEYS * : 전체 키 스캔
  • FLUSHALL, FLUSHDB : 전체 데이터 삭제
  • SAVE : 동기식 스냅샷, 블로킹
  • DEBUG SLEEP : 강제 일시정지

💡 운영 환경에서는 조금씩 나눠서 스캔하는 SCAN 명령어를 사용해야 한다.

메모리 절벽

  • 메모리 모니터링의 핵심 지표
지표 의미 위험 신호
used_memory Redis가 실제 사용 중인 메모리 maxmemory의 80% 초과 시 경고
used_memory_rss OS가 보는 실제 점유 메모리 (RSS) used_memory와 큰 차이 시 단편화 의심
mem_fragmentation_ratio RSS / used_memory 1.5 이상 시 단편화 심각
swap 디스크로 밀려난 메모리 0 이상이면 즉시 조치

💡 used_memory는 실제 사용 데이터만 보여주지만, RSS는 파편화된 빈 공간까지 포함한 실제 점유 메모리를 나타낸다.

  • OS가 Redis의 일부 메모리를 디스크로 옮기는 순간(Swap) 해당 데이터에 접근할 때 디스크 I/O가 발생하며 응답시간이 100배 이상 느려짐
  • Redis는 메모리 사용량이 높아서 OOM Killer의 주요 타겟이 될 수 있어 주의해야 함

Redis의 영속성 전략: RDB와 AOF

  • RDB: 특정 시점의 메모리를 스냅샷 형태로 저장해 빠른 복구가 가능하지만 스냅샷 사이의 데이터는 유실 가능
  • AOF: 모든 쓰기 명령을 순서대로 로그에 기록해 데이터 유실 최소화
  • appendfsync 옵션
# redis.conf

# 옵션 1: 매 명령마다 fsync — 가장 안전하지만 가장 느림
appendfsync always

# 옵션 2: 1초마다 fsync — 균형 잡힌 기본값 (권장)
appendfsync everysec

# 옵션 3: fsync를 OS에 위임 — 빠르지만 위험
appendfsync no
  • 실무에서는 RDB + AOF 혼합 모드를 권장함
    • aof-use-rdb-preamble yes: 하나의 파일에 두가지 형식을 이어 붙여 RDB 스냅샷은 0.1초만에 일괄 로딩, 남은 짧은 구간만 AOF 로그로 순차 진행

Sentinel

  • Master Redis가 죽었을 때 Replica 중 하나를 자동으로 새 Master로 승격시키는 도구
  • 일부 자료에서 사용된 SLAVEOF는 deprecated되었고 REPLICA가 표준임
  • Sentinel은 3~5대의 홀수 구성을 권장함 (1대만 있으면 그 Sentinel이 잘못 판단해도 제동을 걸 수단이 없음)
  • 단계: 한 Sentinel이 Master에게 Ping을 보냈을 때 응답이 없으면, 다른 Sentinel들의 합의 하에 Master가 죽은 것으로 합의, 한 명의 리더를 선출해 그 리더가 새로운 Master를 선출함

🧩 Redis 심화 및 최적화 기법

Redis 내부 인코딩

Redis는 메모리 효율을 위해 같은 자료구조더라도 데이터 크기에 따라 내부 인코딩을 자동으로 바꾼다.

자료구조 작은 데이터 큰 데이터 전환 기준
String embstr (≤44바이트) raw / int 44바이트 초과 / 숫자 여부
List listpack (구 ziplist) quicklist 자동
Hash listpack hashtable hash-max-listpack-entries
Set intset (숫자만) hashtable set-max-intset-entries
Sorted Set listpack skiplist + hashtable zset-max-listpack-entries

Skiplist

 

[자료구조] Skiplist란?

이번 학기에 자료구조론을 재수강하면서 열심히 이론을 익히고 있는데, 문득 이대로 흘려보내기엔 아쉽다는 생각이 들었다. 그래서 이미 후반부에 접어들었지만 자료구조론에서 배운 내용을

velog.io

  • O(log N) 검색을 보장하는 자료구조로, Sorted Set이 큰 데이터에서 사용한다.
  • Sorted Set이 빠른 이유는 Skiplist의 다층 구조 덕분이다.
연산 시간 복잡도 비고
ZADD O(log N) 스킵리스트에 삽입
ZRANGE O(log N + M) M은 반환 개수
ZRANK O(log N) 순위 조회
ZSCORE O(1) 해시 테이블 활용

Ziplist에서 Skiplist로의 전환 배경

  • Ziplist의 문제점: 각 항목이 이전 항목의 길이를 저장 → 이전 항목의 길이가 변하면 다음 항목도 연쇄적으로 메모리 재할당이 발생하는 Cascade Update 문제 발생
  • Listpack의 해결: 자신의 길이를 자신의 데이터 끝에 저장해서 변경 사항이 격리됨. 추가로 Ziplist에서 발생한 보안 취약점도 해결함

Lua 스크립트

  • Lua: C/C++ 프로그램 내부에 임베드하기 위해 가볍게 제작된 스크립트 언어
  • Lua 스크립트는 그 자체로 하나의 명령어처럼 취급되어(EVAL) 스크립트가 끝날 때까지 다른 명령어가 끼어들지 않아(싱글 스레드 블로킹) 원자성이 자동으로 보장됨
  • Fuctions는 라이브러리 단위 관리와 영속성이 있는 저장이 장점이지만, 라이브러리 지원 및 호환성 측면에서 여전히 EVAL 기반 Lua 스크립트가 표준임
  • Lua 스크립트 예시
-- KEYS[1]: 쿠폰 재고 Key, KEYS[2]: 유저 잔액 Key
-- ARGV[1]: 쿠폰 가격
local stock = tonumber(redis.call('get', KEYS[1]))
local funds = tonumber(redis.call('get', KEYS[2]))
local price = tonumber(ARGV[1])

if stock and stock > 0 and funds and funds >= price then
    redis.call('decr', KEYS[1])
    redis.call('decrby', KEYS[2], price)
    return 1
else
    return 0
end
  • Spring Boot에서 Lua 사용하는 예시
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText("... 위의 Lua 코드 ...");
script.setResultType(Long.class);

Long result = redisTemplate.execute(
    script,
    Arrays.asList("coupon:stock", "user:1000:balance"),  // KEYS
    "5000"  // ARGV
);
  • 잘못 작성한 Lua 스크립트(무한 루프, 무한 재귀 등)로 인해 Redis가 멈춰버리면, 스크립트가 한번이라도 데이터를 수정한 경우 SCRIPT KILL가 작동하지 않아 Redis를 강제 종료해야 함
  • 이 문제를 사전에 방지하려면 lua-time-limit 설정을 하고(기본 5초), 외부 입력으로 루프 조건을 만들면 안 됨

실전 최적화 기법

  • Pipeline: 네트워크 왕복 최소화
    • Pipeline을 사용하면 속도 측면에서 수십 배 이상 향상되지만, 트랜잭션이 아니기 때문에 명령어 사이에 다른 클라이언트의 명령어가 끼어들 수 있음
    • Pipeline과 MULTI/EXEC의 차이점
      • Pipeline은 명령어 묶음을 한번에 전송에 네트워크 RTT를 줄이는 것이 목적. 명령어 사이에 다른 명령어가 끼어들 수 있음
      • MULTI/EXEC은 명령어들을 큐에 모은 후 EXEC 시 한꺼번에 실행해 원자성이 보장됨. 다만 실행 중 에러 발생 시 롤백되지 않음
# 일반 방식
Client → SET k1 v1 → Redis → OK → Client (1 RTT)
Client → SET k2 v2 → Redis → OK → Client (1 RTT)
Client → SET k3 v3 → Redis → OK → Client (1 RTT)
= 3 RTT
# Pipeline 방식
Client → {SET k1 v1, SET k2 v2, SET k3 v3} → Redis
Client ← {OK, OK, OK}
= 1 RTT
  • Big Key: 단일 키의 데이터가 매우 크거나 항목 수가 매우 많은 키
    • 위험성: 해당 키 삭제/조회 시 다른 명령어가 모두 대기, Cluster 환경에서 특정 노드만 과부하되므로 메모리 분산 불균형, 네트워크 대역폭 점유
    • 진단법: redis-cli --bigkeys으로 스캔 또는 redis-cli MEMORY USAGE key:name으로 특정 키 메모리 사용량 측정
    • 해결 방법
      • 샤딩: 큰 Hash를 여러 작은 Hash로 분할 (예: user:1user:1:profile, user:1:posts)
      • UNLINK: 동기 삭제(블로킹) DEL 대신 비동기 백드라운드 삭제 UNLINK 사용
      • TTL 분산: Jitter 추가로 Big Key들이 동시 만료되는 것을 방지
  • Hot Key: 특정 키에만 트래픽이 집중되는 현상
    • 해결 방법
      • 로컬 캐시 도입: 애플리케이션 메모리에 1차 캐시
      • 키 복제: 같은 데이터를 여러 키로 분산
      • 읽기 전용 Replica로 트래픽 분산

실무 최적화 사례

  • 메모리 효율 개선
    • Problem: 유저당 Hash 1개 → 1억 명의 유저 설정 저장으로 메모리 200GB 소요
    • Solution: 1000명 단위로 묶어 Hash 1개로 샤딩 + hash-max-listpack-entries 1024로 튜닝 → 80GB로 절감
    • 원리: 작은 Hash는 listpack 인코딩을 사용해 압축적으로 저장됨
  • 선착순 이벤트 QPS 향상
    • Problem: WATCH/MULTI/EXEC 기반 → 1000 QPS 정도에서 재시도 폭발
    • Solution: Lua 스크립트로 전환 후 50000 QPS를 안정적으로 처리
    • 원리: WATCh는 낙관적 락이라 동시 요청이 많으면 재시도 폭발함. Lua 스크립트는 원자적이라 재시도 필요 없음
  • 랭킹 보드 정밀도
    • Problem: Sorted Set의 Score에 Long.MAX_VALUE를 넣었더니 순위가 뒤죽박죽이 됨
    • Cause: Sorted Set의 Score는 double형이라 53비트까지만 정수를 정확히 표현할 수 있음. 반면 Long은 64비트 정수임
    • Solution: Custom Epoch를 도입해 현재 연도 기준 밀리초로 Score 크기를 줄임