Database/redis

Redis 실무 가이드 2 - Pub/Sub, Stream, Redis Stack, 트랜잭션과 Lua Script

bluebamus 2026. 9. 8.

목차

Part 4. [Advanced] Redis 심화 및 최신 트렌드


Part 4. [Advanced] Redis 심화 및 최신 트렌드


4-1. Pub/Sub — 구독자에게 즉시 뿌리는 실시간 메시지

이 글에서 다룰 메시징 방식

  • Redis의 Pub/Sub의 동작 원리(발행/구독 모델)를 이해합니다.
  • 실시간 메시지 전송을 위한 핵심 명령어(PUBLISH, SUBSCRIBE)를 익힙니다.
  • Pub/Sub과 Lists(큐)의 결정적인 차이점과 실무 활용 사례를 배웁니다.

1. 방송국과 라디오 — 저장하지 않고 흘려보내는 구조

Pub/Sub은 Publish(발행)와 Subscribe(구독)의 약자로, 메시지를 보내는 쪽과 받는 쪽이 서로 누구인지 몰라도 통신할 수 있게 해주는 패턴입니다.

구조Publisher → Channel → Subscriber

  1. 발행자(Publisher)가 특정 채널(Channel)에 메시지를 던집니다.
  2. 채널을 구독하고 있던 모든 구독자(Subscriber)에게 메시지가 동시에 전달됩니다.
text
[ Publisher (발행자) ]
            │
            ▼ (PUBLISH news:sports "Son scores a goal!")
┌───────────────────────────┐
│   Redis Channel (채널)     │  <-- "news:sports" (주파수)
└───────────────────────────┘
      │          │          │
 (동시 전송)  (동시 전송)  (동시 전송)
      ▼          ▼          ▼
 [ Sub 1 ]  [ Sub 2 ]  [ Sub 3 ]
 (구독자1)   (구독자2)   (구독자3)

역할

  • 발행자(Publisher): 특정 채널을 지정하여 메시지 발행
  • 구독자(Subscriber): 관심 있는 주제에 대해 하나 이상의 채널을 구독

특징

구분 설명
실시간 전달 메시지 즉시 전달
메시지 저장 X 메시지를 저장하지 않음 (구독자 없으면 메시지 그냥 사라짐)
At-most-once 최대 한 번만 전달 (재전송 메커니즘 없음)
느슨한 결합 (Loose Coupling) Publisher는 누가 받는지 몰라도 되고, Subscriber는 누가 보냈는지 몰라도 됩니다.
브로커 역할 Redis가 메시지 중개

비유

'라디오 방송'과 같습니다.

  • 방송국(Publisher)은 주파수(Channel)에 맞춰 송출합니다.
  • 라디오를 켠 청취자(Subscriber)들은 주파수를 맞추면 그 내용을 실시간으로 들을 수 있습니다.
  • 단, 방송이 나갈 때 라디오를 끄고 있었다면 그 내용은 나중에 다시 들을 수 없습니다.

2. [실습] SUBSCRIBE·PUBLISH·PSUBSCRIBE 두드려보기

Pub/Sub 실습을 위해서는 터미널 창을 최소 2개 띄워야 합니다. (하나는 구독자, 하나는 발행자 역할을 합니다.)

① 메시지 기다리기 (SUBSCRIBE) — [터미널 A]

redis
# 'news:sports' 채널을 구독합니다. 동시에 여러 채널을 구독할 수 있음
SUBSCRIBE news:sports
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "news:sports"
3) (integer) 1

이 상태로 터미널은 메시지가 올 때까지 대기 모드에 들어갑니다. 구독을 종료하려면 UNSUBSCRIBE channel.

② 메시지 보내기 (PUBLISH) — [터미널 B]

redis
# 'news:sports' 채널에 메시지를 발행합니다.
PUBLISH news:sports "Son scores a goal!"
(integer) 1  # 메시지를 받은 구독자의 수가 출력됩니다.

③ 패턴으로 구독하기 (PSUBSCRIBE)

특정 키워드가 포함된 모든 채널을 한 번에 구독할 때 유용합니다. (패턴 기반 구독)

redis
# 'news:'로 시작하는 모든 채널(sports, tech, weather 등)을 구독
PSUBSCRIBE news:*

구독을 종료하려면 PUNSUBSCRIBE channel.

④ 운영/디버깅을 위한 상태 확인 (PUBSUB)

실무에서 "지금 어떤 방송(채널)이 켜져 있고, 몇 명이나 듣고 있는지" 확인할 때 유용합니다. (별도의 터미널 C를 열어서 확인해보세요.)

redis
# 현재 최소 1명 이상이 구독 중인 '활성화된 채널' 목록 보기
PUBSUB CHANNELS

# 특정 채널을 듣고 있는 구독자 수 확인
PUBSUB NUMSUB news:sports
1) "news:sports"
2) (integer) 1

Redis 공식 문서에서도 운영에서 남용하지 말 것이라고 합니다. Redis 내부 상태를 스캔해야 하기 때문에 운영 환경에서 자주 사용하면 성능에 영향을 줄 수 있음.

💡 대기 모드(Standby) 상태

SUBSCRIBE 명령어를 입력하는 순간, 해당 클라이언트(터미널)는 '구독 전용 상태' 로 전환됩니다.

  • Blocking 상태: 새로운 명령어를 입력할 수 있는 프롬프트(127.0.0.1:6379>)가 사라집니다. 클라이언트는 Redis 서버로부터 올 메시지만을 무한히 기다리며 연결을 유지합니다.
  • 제한된 명령어: 이 상태에서는 일반적인 GET, SET 같은 명령어를 사용할 수 없습니다. 오직 구독 관련 명령어(SUBSCRIBE, UNSUBSCRIBE 등)나 연결 종료(QUIT)만 가능합니다.
  • Push 방식: 우리가 데이터를 달라고 요청하는 게 아니라, 서버가 데이터가 생길 때마다 우리에게 밀어주는(Push) 방식으로 동작하게 됩니다.

3. 다른 메시지 시스템과 갈리는 결정적 지점

🆚 1) Pub/Sub과 List 큐, 무엇이 다른가

수강생들이 가장 많이 헷갈려 하는 부분입니다. "둘 다 메시지를 전달하는 것 아닌가요?"

구분 Lists (LPUSH/RPOP) Pub/Sub (PUBLISH/SUBSCRIBE)
비유 우체통 (편지가 쌓여 있음) 라디오 생방송 (지금 안 들으면 끝)
통신 방식 1:1 (Point-to-Point) 1:N (Broadcasting)
수신 방식 Pull (수신자가 원할 때 꺼내감) Push (송신자가 발행하면 자동으로 수신됨)
데이터 보관 꺼내갈 때까지 Redis에 보관됨 보관 안 됨 (Fire and Forget)
수신자 부재 시 나중에 들어온 수신자가 확인 가능 메시지 즉시 증발 (실시간성 강조)
적합한 사례 작업 큐, 순차적 업무 처리 실시간 채팅, 시스템 알림, 상태 업데이트

💡 [핵심 요약]

  • Lists: 메시지를 던져두면 누군가 가져갈 때까지 Redis에 저장됩니다. (작업의 안정성)
  • Pub/Sub: 메시지를 던지는 순간에 듣고 있는 사람이 없으면 메시지는 영원히 사라집니다. (데이터의 실시간성)

🆚 2) Kafka·RabbitMQ 자리에 Pub/Sub을 놓으면 안 되는 이유

실무 아키텍처 설계 시 반드시 알아야 할 중요한 차이입니다.

구분 Redis Pub/Sub Kafka / RabbitMQ
메시지 저장 없음 (휘발성) 있음 (디스크 보관 가능)
재전송 (Replay) 불가 가능
내구성 (Durability) 없음 (메시지 저장하지 않음) 있음 (안전함)
주요 용도 가벼운 실시간 알림 대규모 데이터 파이프라인 처리

💡 [아키텍처 선택 요약]

  • Redis Pub/Sub ➔ 잃어버려도 치명적이지 않은 가벼운 이벤트 알림
  • Kafka ➔ 절대로 유실되면 안 되는 핵심 데이터 파이프라인

Redis의 Pub/Sub는 메시지 큐가 아니라 '실시간 이벤트 알림 시스템' 입니다.

4. 채팅·알림·캐시 동기화에 쓰는 법

💬 1) 실시간 채팅 서비스

사용자가 채팅방(Channel)에 입장하면 SUBSCRIBE를 하고, 메시지를 보낼 때 PUBLISH를 합니다. 같은 채널을 구독 중인 모든 유저에게 즉시 메시지가 전달됩니다.

🔔 2) 실시간 시스템 알림 (Push Notification)

서비스 점검 공지나 대기 순번 알림처럼, 특정 이벤트가 발생했을 때 접속 중인 다수의 애플리케이션 서버들에게 동시에 신호를 보낼 때 사용합니다.

♻ 3) 캐시 무효화 (Cache Invalidation) 알림 🌟

여러 대의 웹 서버가 각자 '로컬 캐시'를 들고 있을 때 유용합니다. DB의 데이터가 변경되면, 수정한 서버가 Pub/Sub으로 "이 데이터 변경됐어!"라고 방송합니다. 메시지를 받은 나머지 서버들은 즉시 자신의 로컬 캐시를 지워 데이터 불일치를 방지합니다.

🔓 4) 분산 락 해제 알림 (Lock Release) 🌟

락을 얻지 못한 서버들이 무한정 재시도(Polling)하면 서버 자원이 낭비됩니다. 대신, "락이 풀릴 때 알림을 주는 채널"을 구독하고 대기합니다. 락을 쥐고 있던 서버가 작업을 마치고 PUBLISH로 해제 알림을 쏘면, 대기하던 서버들이 일제히 깨어나 락 획득을 시도합니다. (유명한 라이브러리인 Redisson의 락 구현 방식입니다.)

5. Pub/Sub 명령어 한 장 정리

명령어 설명 비고 / 주요 특징
PUBLISH 특정 채널에 메시지를 발행합니다. 실행 시 메시지를 수신한 구독자의 수를 반환합니다.
SUBSCRIBE 하나 이상의 채널을 구독합니다. 클라이언트가 수신 대기(Blocking) 모드로 전환됩니다.
UNSUBSCRIBE 구독 중인 채널을 취소합니다. 채널명을 생략하면 구독 중인 모든 채널을 취소합니다.
PSUBSCRIBE 패턴(*, ? 등)에 일치하는 채널을 구독합니다. 예: news:* 구독 시 news:sports, news:tech 등 모두 수신
PUNSUBSCRIBE 패턴 기반의 구독을 취소합니다. 패턴명을 생략하면 모든 패턴 구독을 취소합니다.
PUBSUB CHANNELS 현재 구독자가 최소 1명 이상 있는 채널 목록 조회 패턴 매칭 검색도 지원 (PUBSUB CHANNELS news:*)
PUBSUB NUMSUB 특정 채널들의 현재 구독자 수를 조회합니다. 패턴 구독(PSUBSCRIBE)으로 연결된 구독자는 제외하고 셉니다.

🚩 정리 — 유실을 감수해도 되는 곳에만

  • Pub/Sub은 메시지를 저장하지 않고 즉시 전달하는 실시간성에 최적화되어 있습니다.
  • 메시지가 유실되면 안 되는 중요한 비즈니스 로직(예: 결제 처리)에는 Pub/Sub보다 Lists나 다음에 배울 Streams가 더 적합합니다.
  • Redis 서버가 꺼지면 구독 정보와 전송 중이던 메시지는 모두 사라진다는 점을 명심하세요.

4-2. Stream — 지워지지 않는 로그로 이벤트 다루기

이 글에서 익힐 이벤트 저장소

  • Redis Streams의 정의와 핵심 구조(Log-structured)를 이해합니다.
  • Pub/Sub과 비교하여 Stream이 가진 '데이터 보존'의 강점을 파악합니다.
  • 핵심 명령어(XADD, XREAD, XGROUP)를 통해 데이터 스트림을 다루는 법을 익힙니다.

1. 흘려보내지 않고 append-only로 쌓는 자료형

Redis Stream은 Redis 5.0에서 추가된 강력한 자료구조 중 하나입니다. 한마디로 요약하자면

"메시지가 추가될 때마다 타임스탬프와 함께 기록되는 추가 전용 로그(Append-only Log)"

입니다.

text
  [ Producer (생산자) ]
             │ (XADD: 데이터 추가 (스트림에 이벤트 추가))
             ▼
 ┌────────────────────────────────────────────────────────┐
 │ Redis Stream (Append-only Log)                         │
 │ 1. 1715432100000-0 : {sensor-id: 1234, temp: 19.8}     │
 │ 2. 1715432105000-0 : {sensor-id: 5678, temp: 20.1}     │
 │ 3. 1715432110000-0 : {sensor-id: 1234, temp: 20.5} ... │
 └────────────────────────────────────────────────────────┘
             │ (XREAD / XRANGE: 데이터 읽기 (이벤트 읽기))
             ▼
  [ Consumer (소비자) ]
  (실시간 이벤트 수신 및 과거 이벤트 재처리 가능)
  (Consumer Group으로 병렬 처리 가능)

데이터가 추가되면 시간 기반 ID(밀리세컨드 타임스탬프-시퀀스)가 자동 생성되며 이벤트 로그처럼 시간 순서대로 누적됩니다.

특징

구분 설명
데이터 보존 메시지를 읽어가도 사라지지 않고 로그에 그대로 남습니다.
과거 조회 과거의 특정 시점부터 순차적으로 다시 읽어오는 것이 가능합니다.
구조화된 데이터 Hash와 유사하게 하나의 메시지 안에 여러 개의 Field-Value 쌍을 담을 수 있습니다.

비유 — 'CCTV 녹화 테이프'와 같습니다.

  • 실시간 모니터링: 방금 찍히고 있는 화면을 실시간으로 볼 수 있습니다. (Pub/Sub 기능)
  • 과거 돌려보기: 어제 오후 2시에 무슨 일이 있었는지 비디오를 되감아 다시 볼 수도 있습니다. (보관 기능)

2. [실습] XADD로 쌓고 XREAD·XRANGE로 꺼내기

① 데이터 추가 (XADD)

Producer가 Stream에 새로운 이벤트를 추가하는 명령어. Streams에 데이터를 넣을 때는 키 이름 뒤에 *(자동 ID 생성)와 Field-Value 쌍을 입력합니다.

redis
# 'mystream'에 센서 데이터 추가
XADD mystream * sensor-id 1234 temp 19.8
"1715432100000-0"   # 생성된 Entry ID (타임스탬프-순번)
  • mystream: 데이터를 저장할 Stream Key 이름
  • * : Redis가 자동으로 ID 생성
  • sensor-id 1234 temp 19.8: 스트림에 저장할 데이터(Field-Value 쌍)

(참고) * 대신 직접 ID를 넣을 수도 있음

redis
XADD mystream 1715432100000-0 sensor-id 1234 temp 19.8

단, 실무에서는 거의 사용하지 않음 (ID가 중복되거나 이전 ID보다 작으면 오류)

② 실시간 읽기 (XREAD)

Consumer가 Stream에서 새로운 이벤트가 들어올 때까지 기다렸다가 읽는 명령어.

redis
# 새로운 데이터($)가 들어올 때까지 0ms(무한정) 기다리기 (BLOCK)
XREAD BLOCK 0 STREAMS mystream $
  • BLOCK: 새 데이터가 들어올 때까지 기다림 (대기 모드 설정)
    • 0: 무한 대기. 새 이벤트가 들어올 때까지 계속 기다립니다. 이벤트가 들어오면 해당 데이터를 반환하고 명령은 종료됩니다.
    • 다른 값 지정 — 1000: 1초 대기. 최대 1초 동안 새로운 이벤트를 기다리고, 데이터가 없으면 빈 결과(nil)를 반환합니다.
  • STREAMS: 읽을 스트림을 지정 (띄어쓰기로 구분해서 여러 스트림 지정 가능)
  • $: 현재 Stream의 마지막 ID 이후부터 읽기. 지금까지의 데이터는 무시. 앞으로 들어올 데이터만 읽기
    • 다른 값 지정 — 0: 처음부터 읽기 / 특정 ID 지정: 이 ID 이후부터 읽기

③ 데이터 범위 조회 (XRANGE / XREVRANGE)

Consumer가 Stream에 저장된 데이터를 범위(특정 ID 범위)로 조회하는 명령어. 과거에 쌓인 데이터를 시간 순서대로 훑어볼 수 있습니다.

redis
# 처음(-)부터 끝(+)까지 모든 데이터 조회
XRANGE mystream - +

# LIMIT 옵션 (앞에서 10개만 조회)
XRANGE mystream - + COUNT 10

# 특정 시간대(ID) 범위 조회
XRANGE mystream 1715432100000-0 1715432100500-0

# 최신 데이터부터 역 조회
XREVRANGE mystream + -
  • - : Stream의 가장 작은 ID (Stream 시작)
  • + : Stream의 가장 큰 ID (Stream 끝)

특정 ID로 범위 조회도 가능

redis
XRANGE mystream 1715432100000-0 1715432100500-0
XRANGE mystream 1715432100000-0 +

3. 🌟 [핵심] 무한히 쌓이지 않게 하는 법 — MAXLEN과 XTRIM

수많은 개발자가 Redis를 처음 쓸 때 가장 많이 하는 오해가 있습니다.

👉 "Consumer가 메시지를 읽으면(XREAD) 삭제되나요?" ❌ 아닙니다!

Redis Stream은 Kafka와 동일한 추가 전용 로그(Append-only Log) 구조를 가집니다. 즉, 메시지는 한 번 쓰이면 누군가 읽었다고 해서 없어지지 않으며, 재처리 및 재조회가 언제든 가능합니다.

💡 Redis Stream의 메시지는 기본적으로 자동 삭제되지 않으며, 길이 제한(MAXLEN)이나 XTRIM 명령으로 명시적 관리를 해주어야 합니다.

① Stream 길이 제한 (MAXLEN) — 가장 많이 사용!

데이터를 추가(XADD)할 때 최대 길이를 제한하여 오래된 데이터를 밀어냅니다.

redis
# Stream 길이를 1000개로 유지. 1001번째가 들어오면 가장 오래된 메시지 삭제
XADD mystream MAXLEN 1000 * sensor-id 1234 temp 19.8

② 시간 기반 삭제 (XTRIM MINID)

Redis 6.2 이후부터는 ID(시간) 기준으로 삭제하는 보존(Retention) 정책 적용이 가능합니다.

redis
# 이 ID보다 오래된 메시지를 모두 삭제해라
XTRIM mystream MINID 1715432100000

③ 명령으로 직접 삭제 (XDEL)

특정 메시지 ID를 지정하여 삭제합니다. (실무에서는 굳이 건건이 지우지 않으므로 잘 쓰지 않습니다.)

redis
# 특정 메시지 삭제
XDEL mystream 1715432100000-0

# 여러 개 삭제
XDEL mystream 1715432100000-0 1715432100000-1 1715432100000-2

4. Pub/Sub과 Stream, 어느 쪽을 고를까

데이터를 다른 곳으로 전달한다는 점은 비슷하지만, 메시지 처리 방식에는 큰 차이가 있습니다.

구분 Pub/Sub Streams
데이터 보존 저장되지 않음 (메시지 전달 후 사라짐) Stream에 저장됨 (로그 형태로 유지)
과거 데이터 조회 불가능 언제든 다시 읽기 가능
소비자 그룹 지원 안 함 (모든 구독자가 메시지를 받음) Consumer Group 지원
메시지 보장 보장 안 됨 (Fire and Forget) ACK 기반 처리 가능

5. [심화] 여러 워커가 나눠 먹는 Consumer Group

Stream의 진짜 강력함은 '일을 나눠서 처리할 때' 나옵니다. 만약 100건의 작업이 들어왔을 때, 3대의 서버가 중복 없이 33건씩 나눠서 처리하게 하고 싶다면 소비자 그룹을 사용합니다.

📊 소비자 그룹 개념도

text
                  ┌───────────────────┐
                  │   Redis Stream    │ (작업 100개)
                  └─────────────────┬─┘
                                    │
                 ┌──────────────────▼──────────────────┐
                 │         Consumer Group              │ (작업 분배)
                 └─┬─────────────────────────────────┬─┘
 (1번, 3번 메시지)  │                                 │  (2번, 4번 메시지)
                   ▼                                 ▼
              [ Worker A ]                      [ Worker B ]
             (처리 후 XACK)                     (처리 후 XACK)

    (Consumer Group을 통해 여러 Consumer가 메시지를 나눠 처리 가능)

하나의 메시지는 같은 그룹 내에서 단 하나의 Consumer만 처리합니다.

💡 XGROUP·XREADGROUP·XACK 처리 흐름

① Consumer Group 생성 (XGROUP)

redis
# mystream이라는 Stream에 mygroup이라는 Consumer Group을 생성
XGROUP CREATE mystream mygroup $ MKSTREAM
  • $: 현재 Stream의 마지막 메시지 이후부터 읽기 시작 (0, 특정 ID 지정 가능)
  • MKSTREAM: Stream이 없으면 생성 (Create Stream)

② 나눠서 읽기 (XREADGROUP)

Consumer가 컨슈머 그룹으로부터 메시지를 읽는 명령어.

redis
# Consumer Group 'mygroup'의 consumer1이 mystream에서 새로운 메시지를 읽는다
XREADGROUP GROUP mygroup consumer1 BLOCK 0 STREAMS mystream >
  • BLOCK 0: 새 메시지가 들어올 때까지 무한 대기
  • > : 아직 어떤 Consumer도 읽지 않은 새로운 메시지

③ 확인 응답 (XACK)

Consumer가 메시지 처리를 완료했음을 알려주는 명령어.

redis
XACK mystream mygroup [ID]

처리가 끝나면 XACK mystream mygroup [ID]를 보내, 메시지 처리 완료를 Consumer Group에 알립니다. ("이 작업은 끝났으니 다시 안 줘도 돼"라고 알려줍니다.)

XACK를 해도 메시지는 Stream에서 삭제되지 않습니다.

6. 주문 이벤트를 여러 팀이 각자 속도로 처리하기

🪵 1) 분산 시스템의 통합 로그 저장소

수십 대의 서버에서 발생하는 에러 로그나 접속 기록을 하나의 Stream으로 모읍니다. 나중에 특정 시간대의 로그만 분석하거나 장애를 추적하기 매우 좋습니다.

💳 2) 주문 및 결제 이벤트 처리

주문이 발생하면 주문 스트림에 기록합니다. 결제 서버, 배송 서버, 알림 서버가 이 스트림을 각각 구독하여 자신의 업무를 처리합니다.

text
orders Stream
      │
      ├─ payment_group
      │      ├─ worker_1
      │      └─ worker_2
      │
      └─ shipping_group
             ├─ worker_A
             └─ worker_B

Consumer Group 내부에서는 작업 분배가 되고, Consumer Group 간에는 이벤트가 공유됩니다.

redis
# 주문이 발생하면 orders 스트림에 기록하고,
# 각 시스템들(결제, 배송)은 자신들의 속도에 맞춰 이벤트를 처리합니다.

# 1. 주문 발생 (주문 서버가 데이터 발행)
XADD orders * user_id 77 product_id 101 price 50000
"1715432200000-0"   # 주문 ID 생성

# 2. Consumer 그룹 생성 (결제팀, 배송팀 각자의 속도로 읽기 위함)
XGROUP CREATE orders payment_group 0
XGROUP CREATE orders shipping_group 0

# 3. 결제 서버가 주문 확인 (worker_1이 데이터 1개 읽기)
# worker_1은 결제 그룹에 속한 특정 서버(Consumer)를 식별하는 이름입니다.
XREADGROUP GROUP payment_group worker_1 COUNT 1 STREAMS orders >
# 결과: user_id 77의 결제 로직 수행...

# 4. 결제 완료 알림 (처리가 완전히 끝나면 XACK 전송)
XACK orders payment_group 1715432200000-0

장점: 결제 서버가 잠시 점검 중이어도 주문 데이터는 orders 스트림에 안전하게 쌓여 있습니다. 서버가 복구되면 중단된 시점부터 다시 결제를 처리할 수 있어 데이터 유실이 발생하지 않습니다.

7. Stream 명령어 한 장 정리

명령어 설명 비고 / 주요 특징
XADD 스트림에 새로운 메시지를 추가합니다. * 입력 시 고유 ID(타임스탬프) 자동 생성.
XRANGE / XREVRANGE 지정한 ID 범위 내의 메시지를 조회합니다. 과거 이력, 특정 시간대 로그 검색 시 사용.
XREAD 스트림에서 데이터를 읽거나 실시간 대기합니다. BLOCK 옵션으로 Pub/Sub처럼 대기 가능.
XLEN 스트림에 저장된 메시지의 총 개수를 반환합니다.  
XGROUP 소비자 그룹을 생성/수정/삭제합니다. 분산 처리를 위한 필수 셋업.
XREADGROUP 소비자 그룹의 일원으로서 메시지를 가져옵니다. 큐에서 작업을 '할당'받는 역할. (> 사용)
XACK 메시지 처리가 완료되었음을 레디스에 보고합니다. 미완료 목록(PEL)에서 해당 메시지를 제거.
XTRIM / XDEL 스트림의 길이를 자르거나 특정 메시지를 삭제합니다. 용량 관리 목적으로 사용.

🚩 정리 — Stream을 선택하는 기준

  • Streams는 메시지 보관 기능과 작업 분배 기능(Consumer Group)이 추가된 현대적인 메시지 브로커입니다.
  • Entry ID는 시간 정보를 담고 있어, 데이터가 생성된 시점을 별도의 필드 없이도 알 수 있습니다.
  • 단순한 채팅이나 가벼운 알림이라면 Pub/Sub을, 유실되면 안 되는 주문/결제나 로그 처리라면 Streams를 선택하세요.

4-3. Redis Stack — JSON 저장과 인덱스 검색 붙이기

이 글에서 확장할 기능

  • Redis Stack의 개념과 멀티 모델(Multi-model) 데이터베이스로서의 가치를 이해합니다.
  • RedisJSON을 통해 복잡한 중첩(Nested) 객체 구조를 효율적으로 다루는 법을 배웁니다.
  • RedisSearch를 활용하여 레디스 내 데이터에 대한 실시간 고속 검색 인덱스를 만드는 원리를 익힙니다.

1. 모듈이 기본 탑재된 '풀 옵션' Redis

기본 Redis가 아주 빠른 단순 Key-Value 저장소라면, Redis Stack은 여기에 강력한 확장 기능들을 추가하여 하나의 데이터 플랫폼처럼 사용할 수 있도록 만든 공식 패키지입니다.

  • 기본 Redis + 여러 고급 기능 = 복잡한 데이터 모델 지원
  • JSON 문서, 검색 인덱스, 시계열 데이터, 확률 자료구조 등 다양한 기능을 함께 사용할 수 있습니다.
  • 과거에는 이러한 기능들을 각각 별도의 모듈로 설치해서 사용해야 했으나, 현재는 Redis Stack 패키지로 함께 제공됩니다.

💡 구조 및 포함된 주요 모듈

text
 ┌────────────────────────────────────────────────────────┐
 │                   Redis Stack                          │
 │                                                        │
 │  [ Standard Redis ]  +  [ Advanced Modules ]           │
 │  (String, Hash, Set)    (JSON, Search, TimeSeries...)  │
 └────────────────────────────────────────────────────────┘
  • RedisJSON: 중첩된 JSON 문서를 원본 그대로 저장하고 부분 수정/조회.
  • RedisSearch: 검색엔진 기능(Full-text search), 복합 조건(숫자, 태그) 인덱싱 및 쿼리 수행.
  • RedisTimeSeries: 시계열 데이터(주식, 로그, IoT 등) 처리.
  • RedisBloom: 수억 개의 데이터 중복 여부를 적은 메모리로 판별(확률적 자료구조).
  • RedisInsight: GUI 관리 툴 (웹 기반 UI)

이처럼 Redis Stack은 Redis를 단순 캐시 시스템을 넘어 다양한 데이터 모델을 처리할 수 있는 멀티 모델 데이터 플랫폼으로 확장해 줍니다.

💡 비유

순정 자동차(Standard Redis)에 내비게이션, 자율주행, 서라운드 뷰 등 최신 편의 기능(Modules)을 모두 장착하여 출시한 '풀 옵션 모델'과 같습니다.

🗣 Redis Stack의 탄생 배경과 최신 트렌드 (Redis 8.0)

탄생 목적: 예전에는 JSON 등을 쓰려면 모듈을 따로 설치해야 했습니다. 이를 해결하고자 가장 인기 있는 모듈들을 기본 엔진에 합쳐 패키징한 것이 Redis Stack(2022년 출시)입니다.

🚨 최신 동향 (중요!): Redis 7.x 시리즈까지는 이러한 기능들이 '별도 모듈'로 분리되어 있었습니다. 하지만 Redis 8.0부터는 Redis Open Source 배포판 자체에 Redis Query Engine(구 RediSearch), JSON, TimeSeries, Probabilistic(Bloom 등) 모듈들이 기본 번들(Bundled)로 포함되었습니다.

기능이 코어 엔진에 완전히 흡수되어 모듈 자체가 사라진 것은 아닙니다. 내부적으로는 여전히 모듈 형태로 로드되어 동작하지만(MODULE LIST 명령어로 확인 가능), 이제는 별도의 설치나 설정 없이 순수 Redis 이미지(redis:8.x) 하나만 띄워도 JSON.SET, FT.SEARCH 등의 명령어가 즉시 동작합니다. 즉, 모듈 관리의 오버헤드(Zero module management overhead)가 사라진 것이 핵심 변화입니다!

2. 모듈 확인과 RedisInsight 띄우기

우리는 이미 앞선 레슨에서 최신 버전(8.0 이상)의 redis 컨테이너(my-redis)를 띄워두었습니다. 본격적인 실습 전에, 방금 배운 대로 정말로 모듈이 기본 탑재되어 있는지 직접 확인해 봅시다.

① Redis 버전 확인

bash
docker exec -it my-redis redis-cli --version
redis
# 또는 redis-cli에서는
127.0.0.1:6379> INFO server

② 내 Redis 모듈 확인하기 (MODULE LIST)

터미널에서 기존에 띄워둔 my-redis의 CLI로 접속하여 다음 명령어를 입력합니다.

redis
# 현재 로드된 모듈 목록을 확인합니다.
MODULE LIST

# [출력 결과 예시] search, ReJSON(JSON) 등의 모듈이 기본으로 로드된 것을 볼 수 있습니다.
# 1) 1) "name"
#    2) "search"
#    ...
# 2) 1) "name"
#    2) "ReJSON"
#    ...

직접 확인하셨나요? 8.0부터는 모듈이 이미 내장되어 있으므로 기존 컨테이너를 그대로 사용해도 JSON과 Search 명령어가 완벽하게 동작합니다.

③ RedisInsight 데스크톱 앱 설치

명령어는 완벽하게 동작하겠지만, 복잡한 JSON 구조나 검색 결과를 터미널(CLI)의 까만 화면과 텍스트로만 보면 이해하기 어려울 수 있습니다. 이번 실습에서는 데이터를 시각적으로 예쁘게 확인하기 위해 RedisInsight라는 공식 GUI 툴만 추가로 설치해 보겠습니다.

  1. RedisInsight 공식 다운로드 페이지에 접속하여 본인의 OS(Windows/Mac)에 맞는 버전을 다운로드하고 설치합니다.
  2. 설치된 앱을 실행하고 [Add Redis Database] 버튼을 클릭합니다.
  3. Host에 127.0.0.1, Port에 6379를 입력하고 연결(Add)합니다. (우리가 도커로 띄워둔 my-redis와 연결됩니다.)

⚠ [참고] 구버전(Redis 7.x 이하) 사용자용 Fallback 가이드

만약 구버전 Redis를 사용 중이라서 JSON.SET 명령어 입력 시 "unknown command" 에러가 난다면, 모듈이 없는 것입니다. 이 경우 기존 환경과 포트 충돌이 나지 않도록 redis-stack 이미지를 6380 포트로 따로 띄워서 실습하세요!

bash
# 기존 컨테이너를 건드리지 않고, 새로운 포트(6380)로 Redis Stack을 실행합니다.
# (-p 6380:6379는 호스트의 6380 포트를 컨테이너의 기본 포트 6379로 연결한다는 뜻입니다)
# (-p 8001:8001은 내장된 GUI 툴인 RedisInsight 접속용 포트입니다)
docker run -d --name my-redis-stack -p 6380:6379 -p 8001:8001 redis/redis-stack:latest

# CLI 접속 (컨테이너 내부로 들어가는 것이므로 명령어는 기존과 동일합니다)
docker exec -it my-redis-stack redis-cli

(이 방식으로 띄운 분들은 브라우저에서 http://localhost:8001에 접속하면 설치 없이 내장된 웹 버전 RedisInsight를 사용할 수 있습니다.)

3. RedisJSON — 중첩 객체를 경로로 다루기

우리가 배운 Hash 자료구조는 평면적인 구조(Field-Value)만 저장할 수 있었습니다. 하지만 현실의 데이터는 배열이나 객체가 겹겹이 쌓인 중첩 구조(Nested JSON)인 경우가 많습니다.

① JSON 문서 저장 및 수정 (JSON.SET)

전체 JSON 문서를 특정 Key에 한 번에 저장합니다.

redis
# 'user:json:1' Key에 중첩 구조의 JSON 저장
# 구조: JSON.SET <key> <path> <JSON 데이터>
JSON.SET user:json:1 $ '{"name":"Kim", "age":30, "address":{"city":"Seoul", "zip":"12345"}, "items":["Apple", "Banana"]}'
OK

🔍 $ 기호는 무엇인가요?

$는 JSON 문서의 루트(Root, 최상위 경로)를 의미합니다. JSON.SET에서 $를 지정하는 것은 "문서의 시작점부터 데이터를 통째로 넣겠다"는 뜻입니다. 나중에 내부 데이터에 접근할 때도 $.address.city처럼 $를 기준으로 JSON Path를 작성합니다.

특정 필드만 저장할 경우: JSON.SET key $.address.city "Busan"

실제 저장되는 JSON 문서

json
{
  "name": "Kim",
  "age": 30,
  "address": {
    "city": "Seoul",
    "zip": "12345"
  },
  "items": [
    "Apple",
    "Banana"
  ]
}

② 부분 조회 및 수정 (JSON.GET, JSON.NUMINCRBY, JSON.ARRAPPEND)

전체 데이터를 메모리로 불러올 필요 없이, 특정 경로(Path)의 값만 즉시 조회하거나 핀포인트로 수정할 수 있습니다. (네트워크 낭비 최소화)

redis
# 1. JSON 문서 전체 조회
JSON.GET user:json:1     # JSON 루트 객체 자체를 반환
# 또는
JSON.GET user:json:1 $   # JSON Path 조회 결과(배열 형태)

# 2. 특정 경로의 값만 가져오기 (주소의 도시 이름)
JSON.GET user:json:1 $.address.city
"[\"Seoul\"]"  # 결과는 항상 배열 형태로 반환됩니다.

# 3. 기존 문서에 새로운 필드 추가하기
# $.tier라는 새로운 경로를 지정하면 필드가 생성됩니다. 문자열은 내부에 ""를 포함해야 합니다.
JSON.SET user:json:1 $.tier '"Gold"'
OK

# 4. 숫자 필드만 즉시 1 증가시키기 (나이 먹음)
JSON.NUMINCRBY user:json:1 $.age 1
"[31]"

# 5. 배열에 아이템 하나 추가하기 (쇼핑 장바구니 등)
JSON.ARRAPPEND user:json:1 $.items '"Cherry"'
1) (integer) 3  # 배열의 총 길이가 3이 됨

③ 👀 [시각화] RedisInsight에서 JSON 트리 확인하기

CLI에서 작업한 데이터가 실제로 어떻게 들어갔는지 앱을 열어 눈으로 확인해 봅시다.

  1. 설치해둔 RedisInsight 앱을 엽니다. (도커 컨테이너로 설치한 경우, 웹 브라우저를 열고 http://localhost:8001에 접속합니다)
  2. 좌측 데이터 브라우저(Data Browser) 메뉴에서 방금 생성한 user:json:1 키를 클릭합니다.
  3. 터미널에서는 복잡한 문자열로 보이던 데이터가, 예쁜 트리(Tree) 구조로 접었다 펼칠 수 있게 시각화된 것을 볼 수 있습니다. (특정 필드만 수정하거나 아이템을 추가하는 기능도 UI에서 직접 클릭으로 지원합니다!)

4. RediSearch — 인덱스 걸고 조건 검색하기

Redis에 저장된 수많은 데이터 중 "가격이 5,000원 이하이고 이름에 '커피'가 들어간 제품" 을 찾으려면 어떻게 해야 할까요? 일반 Redis에서는 모든 키를 뒤져야 하지만, RedisSearch는 인덱스를 만들어 아주 빠르고 효율적으로 찾아냅니다.

📊 인덱싱 및 검색 개념도

text
 [ 데이터 저장 ] (JSON.SET item:001 ...)
       │
       ▼ (실시간 자동 인덱싱)
 ┌──────────────────────────────────────────────┐
 │ RedisSearch Index (idx:products)             │
 │ - TEXT 인덱스: "Americano", "Latte", "Coffee"│
 │ - NUMERIC 인덱스: 4500, 5500, 6000           │
 └──────────────────────────────────────────────┘
       │
       ▼ (조건 검색)
 [ FT.SEARCH idx:products "@name:Coffee @price:[0 5000]" ]

① 검색 인덱스 생성 (FT.CREATE)

JSON 문서 안의 특정 필드를 검색 가능한 인덱스로 등록. 데이터를 넣기 전(또는 후)에, 어떤 기준으로 데이터를 찾을지 인덱스(Index)를 만들어야 합니다.

text
# 구조
FT.CREATE <index_name>
  ON <data_type>                                  # 검색대상 타입(Hash, JSON 지원)
  PREFIX <prefix_count> <prefix1> <prefix2> ...   # Key Prefix
  SCHEMA <field_definition>                       # 검색할 필드 정의
redis
# 제품명(TEXT)과 가격(NUMERIC)에 대한 인덱스 만들기
FT.CREATE idx:products ON JSON PREFIX 1 item: SCHEMA $.name AS name TEXT $.price AS price NUMERIC
OK

🗣 [상세 설명] 인덱스 구문 분석

  • FT.CREATE idx:products : idx:products라는 이름의 인덱스를 만듭니다.
  • ON JSON : RedisJSON으로 저장된 데이터를 추적합니다. (ON HASH를 쓰면 기존 Hash 데이터도 검색 가능)
  • PREFIX 1 item: : 키 이름이 item:으로 시작하는 데이터들만 감시하고 인덱싱합니다. (1은 Prefix 개수)
  • Prefix를 지정하지 않으면 RedisSearch는 기본적으로 "모든 key"를 인덱싱 대상으로 봅니다.
  • → 인덱스 메모리 증가. CPU 사용 증가. 의도치 않은 데이터 검색 → 실무 비추
  • SCHEMA : 검색 필드 지정.
  • $.name AS name TEXT : JSON 안의 name을 '텍스트 검색'용으로 인덱싱.
  • $.price AS price NUMERIC : JSON 안의 price를 '숫자 비교(크다/작다)'용으로 인덱싱.

② 데이터 추가 및 검색 (FT.SEARCH)

먼저 검색 대상이 되는 데이터를 몇 개 넣어봅니다. (Prefix item: 일치)

redis
JSON.SET item:001 $ '{"name": "Iced Americano Coffee", "price": 4500}'
JSON.SET item:002 $ '{"name": "Hot Cafe Latte", "price": 5000}'
JSON.SET item:003 $ '{"name": "Vanilla Bean Coffee", "price": 6000}'

# 인덱스 대상이 아닌 데이터 (Prefix 불일치 - 검색되지 않음)
JSON.SET user:1 $ '{"name": "Kim", "age": 30}'

이제 복합 조건으로 검색을 수행합니다.

redis
# 이름에 'coffee'가 들어있고 가격이 4000원 ~ 5000원 사이인 제품 검색
FT.SEARCH idx:products "@name:coffee @price:[4000 5000]"
1) (integer) 1          # 찾은 개수
2) "item:001"           # 매칭된 키 이름
3) 1) "$"
   2) "{\"name\":\"Iced Americano Coffee\",\"price\":4500}" # 실제 데이터

검색 패턴

검색 패턴 SQL (RDBMS) RedisSearch
정확히 일치 WHERE name = 'Coffee' TAG 사용
인덱스 생성: $.name AS name TAG
검색: @name:{Coffee}
단어 포함 검색 LIKE '%Coffee%' @name:Coffee
접두어 검색 LIKE 'Coffee%' @name:Coffee*
여러 단어 AND 검색 LIKE '%Coffee%' AND '%Latte%' @name:(Coffee Latte)
여러 단어 OR 검색 LIKE '%Coffee%' OR '%Latte%' @name:(Coffee \| Latte)
제외 검색 NOT LIKE '%Coffee%' -@name:Coffee
숫자 범위 검색 price BETWEEN 4000 AND 5000 @price:[4000 5000]
이상 검색 price >= 5000 @price:[5000 inf]
이하 검색 price <= 5000 @price:[-inf 5000]

③ 👀 [시각화] RedisInsight에서 인덱스 검색하기

오타가 나기 쉬운 복잡한 검색 쿼리는 시각화 툴에서 테스트하는 것이 훨씬 직관적입니다.

  1. RedisInsight 앱 좌측 메뉴에서 Search 아이콘(돋보기 모양)을 클릭합니다.
  2. 방금 생성한 idx:products 인덱스를 선택합니다.
  3. 상단 검색창에 @name:coffee @price:[4000 5000]을 입력하고 검색(엔터)을 누르면, 일치하는 데이터가 깔끔한 테이블 형태로 즉시 필터링되어 나타납니다!

5. 애플리케이션 코드가 줄어드는 지점

비교 항목 Standard Redis Redis Stack
복잡한 객체 JSON 문자열로 변환(Strings)하거나 Hashes로 쪼개 사용 RedisJSON으로 원본 중첩 구조 그대로 저장 및 수정
검색 기능 키 이름(SCAN)으로만 제한적 조회 가능 RedisSearch로 내부 필드 값 기준의 복합 쿼리 가능
집계 및 통계 애플리케이션 코드로 직접 데이터 분석 필요 내장된 Aggregation 프레임워크로 그룹화, 평균 계산
데이터 역할 단순 캐싱 위주 캐시를 넘어선 주 데이터베이스(Primary DB) 역할

6. 상품 카탈로그·프로필 검색에 쓰는 법

🛍 1) e커머스 상품 카탈로그 및 필터

상품의 상세 정보(옵션, 리뷰 등 중첩 구조)를 JSON으로 저장하고, 사용자가 "빨간색 셔츠, 3만원 이하, 별점 4점 이상"이라는 필터를 걸었을 때 RedisSearch를 통해 DB 부하 없이 1ms 내에 결과를 보여줍니다.

🕵 2) 실시간 사기 탐지 (Fraud Detection)

유저의 로그인 IP, 접속 시간, 활동 패턴 정보를 JSON 검색 인덱스로 관리합니다. "평소 접속하지 않던 국가에서 1분 이내에 연속으로 5회 이상 발생한 결제" 같은 수상한 활동 패턴을 복합 쿼리로 즉시 감지하여 차단합니다.

⚠ 지식 박스: RedisSearch 도입 시 고려사항

RedisSearch는 인메모리 기반으로 매우 빠르지만, 만능은 아닙니다. Elasticsearch(ES)와 같은 전문 검색 엔진과 비교할 때 다음 사항을 고려해야 합니다.

  1. 메모리 비용 (RAM vs Disk) — RedisSearch의 인덱스는 모두 비싼 메모리(RAM)에 상주합니다. 수십 테라바이트에 달하는 거대한 과거 로그를 검색해야 한다면 디스크 기반인 Elasticsearch가 경제적입니다. RedisSearch는 '현재 활발히 사용되는 일부 데이터'나 '초고속 응답이 필수적인 데이터' 검색에 사용하는 것이 좋습니다.
  2. 검색 기능의 깊이 — 정교한 한국어 형태소 분석이나 동의어 처리 등 깊이 있는 전문(Full-text) 검색 기능은 아직 전문 검색 엔진(ES)이 더 강력할 수 있습니다.
  3. 인덱싱 오버헤드 — 데이터가 추가/수정될 때마다 실시간으로 인덱스를 업데이트하므로, 쓰기(Write) 작업이 많은 환경에서는 CPU 사용량을 면밀히 모니터링해야 합니다.

🚩 정리 — Stack이 필요한 순간과 아닌 순간

  • Redis Stack은 Redis를 단순 캐시가 아닌 멀티 모델(Multi-model) 데이터베이스로 진화시켜 줍니다.
  • RedisJSON은 복잡한 계층형 데이터를 다루고, RedisSearch는 그 데이터에 대한 초고속 조건 조회를 담당합니다.
  • 실시간성(초저지연)이 최우선이라면 Redis Stack을, 방대한 데이터 보관과 복잡한 분석이 우선이라면 Elasticsearch를 고려하는 것이 실무자의 안목입니다.

4-4. Redis 7.x·8.0에서 실제로 달라진 것들

이 글에서 따라잡을 변화

  • Redis 최신 버전의 변화와 트렌드를 이해합니다.

1. 버전 차이가 실무에 미치는 영향

인터넷에 널리 퍼져 있는 Redis 관련 블로그 글이나 강의들은 Redis 5.0 ~ 6.0 시절에 작성된 것들이 많습니다. 하지만 최근 출시된 Redis 7.x와 8.0에서는 내부 아키텍처와 기능에 많은 변화가 있었습니다.

이러한 최신 기술 트렌드를 알고 있는 개발자와 모르는 개발자는 "아키텍처를 효율적으로 설계할 수 있는가?"에서 큰 차이를 보이게 됩니다. 기술 면접이나 아키텍처 회의에서 여러분을 돋보이게 만들어 줄 핵심 변화 몇 가지를 짚어보겠습니다.

📅 Redis 주요 버전별 출시 시기 요약

  • Redis 5.x ~ 6.x (2018~2021년): Streams 도입, Listpack 등장, Redis 모듈 생태계 확장
  • Redis 7.x (2022~2023년): 핵심 기능 및 내부 아키텍처 개선 (Multi-part AOF, Functions, Sharded Pub/Sub)
  • Redis 8.0 (2025년 5월~): 멀티 모델 데이터 플랫폼 (주요 Redis Stack 기능 기본 번들 제공 (bundled distribution))

Redis는 버전이 올라갈수록 "단순 캐시 서버 → 데이터 플랫폼" 방향으로 진화하고 있습니다.

2. ⚡ 메모리와 백업이 달라진 지점 (7.0+)

① Ziplist를 대체한 Listpack

과거 Redis는 Hash나 Sorted Set의 데이터가 작을 때 메모리를 절약하기 위해 Ziplist라는 압축 구조를 사용했습니다.

text
Ziplist (Redis ≤6)                        Listpack (Redis ≥7)
┌───────────────────────────────┐         ┌───────────────────────────────┐
│ data1  │ data2 │ data3 │ data4│         │ data1 │ data2 │ data3 │ data4 │
└───┬────┴───┬───┴───┬───┴───┬──┘         └───────────────────────────────┘
    │        │       │       │
 prevlen  prevlen prevlen prevlen

data1 크기 변경                            data1 크기 변경
      ↓                                          ↓
data2 메타정보 수정                          영향 없음
      ↓
data3 메타정보 수정
      ↓
data4 메타정보 수정
      ↓
➡ Cascading Update 발생                    ✔ 연쇄 업데이트 없음
  • 과거의 문제점: Ziplist는 구조적 특성(가변 길이 인코딩) 때문에 데이터 하나가 수정되면 뒤의 데이터들이 연쇄적으로 재배치되는 '캐스케이딩 업데이트(Cascading Update)'라는 성능 문제가 있었습니다.
  • 최신 트렌드: Redis 5.0부터 새로운 구조인 Listpack이 도입되었고, Redis 7.0에서는 기존 Ziplist가 완전히 제거되면서 Listpack으로 전면 전환되었습니다. 개발자가 사용하는 명령어는 동일하지만, Redis 내부적으로는 더 빠르고 안정적인 메모리 구조로 개선되었습니다.

② 스냅샷과 로그를 합친 Multi-part AOF

(섹션 6의 Redis 영속성에서 다룹니다.)

  • 과거의 문제점: 과거 AOF(Append Only File)는 모든 쓰기 명령을 하나의 AOF 파일에 계속 추가하는 방식이었습니다. 이 파일의 크기가 커지면 AOF Rewrite 과정에서 디스크 I/O 부하와 서버 Latency가 증가할 수 있었고 서버 재시작 시 AOF 로그 전체를 다시 실행해야 하므로 복구 시간이 길어지는 문제가 있었습니다.
  • 최신 트렌드: Redis 7.0부터는 AOF를 Base RDB(기본 스냅샷)와 Incremental AOF(추가 로그) 파일로 나누어 관리하는 Multi-part AOF 구조가 도입되었습니다. 이 덕분에 Rewrite 작업이 훨씬 가벼워지고, 서버 Latency와 복구 시간이 크게 개선되었습니다.

3. 🚀 EVAL을 대체하는 Redis Functions (7.0+)

실무에서는 동시성 문제를 완벽히 통제하기 위해, 여러 개의 명령어를 하나로 묶어서 실행하는 Lua(루아) 스크립트를 많이 사용했습니다.

⚠ 기존 EVAL (Lua Script)의 한계

  • 스크립트 자체가 클라이언트(앱) 쪽에 저장되어 있어서 매번 네트워크로 긴 코드를 전송해야 했습니다.
  • 코드를 수정하면 모든 서버(클라이언트)를 다시 배포해야 했습니다.

✨ 새로운 대안: Redis Functions

Redis 7.0부터는 코드를 Redis 서버에 '사용자 정의 함수(Function)' 형태로 아예 등록해 두고 쓸 수 있게 되었습니다.

  • 데이터베이스의 '저장 프로시저(Stored Procedure)'와 같은 역할을 합니다.
  • Redis Functions는 Redis 서버 내부에 로직을 저장하기 때문에 네트워크 왕복(latency)을 줄이고 로직을 중앙에서 관리할 수 있습니다.
  • 로직이 Redis 내부에 저장되므로, 여러 애플리케이션이 동일한 로직을 쉽게 공유하고 재사용할 수 있게 되었습니다.

4. 🌐 클러스터에서 브로드캐스트 비용 줄이기 — Sharded Pub/Sub (7.0+)

⚠ 기존 Pub/Sub의 클러스터 문제

기존 Redis Pub/Sub을 Redis Cluster 환경에서 사용하면 한 노드에서 발행된 메시지가 클러스터의 모든 노드로 전달되는 구조였습니다. 즉 메시지가 클러스터 전체로 브로드캐스트(Broadcast)되기 때문에, 노드 수가 증가할수록 네트워크 트래픽과 내부 메시지 전달 비용이 급격히 증가하는 문제가 있었습니다.

✨ 해결책: Sharded Pub/Sub (SSUBSCRIBE, SPUBLISH)

Redis 7.0에서는 이 문제를 해결하기 위해 샤드(Shard) 기반의 Pub/Sub 기능을 추가했습니다.

text
┌───────────────────────────────┬───────────────────────────────┐
│            Pub/Sub            │       Sharded Pub/Sub         │
│          (Broadcast)          │          (Routing)            │
│                               │                               │
│           Publisher           │           Publisher           │
│               │               │               │               │
│               ▼               │               ▼               │
│         Redis Cluster         │        hash(channel)          │
│               │               │               │               │
│     ┌─────────┼─────────┐     │               ▼               │
│     ▼         ▼         ▼     │         Redis Cluster         │
│  Node A    Node B    Node C   │     ┌──────┬──────┬──────┐    │
│  Subscr    Subscr    Subscr   │     │Node A│Node B│Node C│    │
│                               │     │      │Subscr│      │    │
│                               │     └──────┴──────┴──────┘    │
│  메시지 → 모든 노드 전달        │  메시지 → 특정 shard만 전달    │
└───────────────────────────────┴───────────────────────────────┘
  • Sharded Pub/Sub은 채널 이름을 Redis Cluster의 Key와 동일한 방식으로 해싱하여 특정 shard에서 메시지를 처리하는 구조입니다.
  • 따라서 메시지가 클러스터 전체로 브로드캐스트되지 않고 해당 shard 내부에서만 전달됩니다.
  • 이 덕분에 대규모 Redis Cluster 환경에서도 Pub/Sub을 훨씬 효율적으로 사용할 수 있습니다.
redis
SSUBSCRIBE event:{order}:completed
SPUBLISH event:{order}:completed "order_id=123"
  • Redis Cluster는 채널 이름에서 {} 안의 문자열만 해싱(Hash Tag)해서 메시지를 처리할 shard를 결정합니다.
  • event:{order}:created, event:{order}:completed, event:{order}:cancelled 이 채널들은 모두 동일한 shard로 라우팅 됩니다.
  • 따라서 불필요한 cluster broadcast가 발생하지 않고 주문 관련 이벤트는 모두 동일한 shard에서만 처리되도록 할 수 있습니다.

5. 🛠 왕복 한 번을 줄여주는 원자적 편의 명령어

Redis는 네트워크 기반의 데이터베이스입니다. 따라서 클라이언트와 서버 사이를 왔다 갔다 하는 네트워크 왕복 시간(RTT, Round Trip Time)에 드는 비용이 매우 큽니다.

최근 Redis 팀은 이 RTT를 최적화하고 동시성 문제를 완벽히 차단하기 위해, 과거에 여러 번 나누어 보내던 명령들을 하나로 합친 방향으로 진화하고 있습니다.

① [핵심 변화] Hash 필드마다 TTL을 거는 HEXPIRE 계열

과거 레디스의 가장 큰 아쉬움 중 하나는 "만료 시간(TTL)은 무조건 Key 단위로만 설정할 수 있다"는 점이었습니다. 하지만 Redis 7.x(정확히는 7.4) 이후 버전부터는 드디어 Hash 내부의 각 필드(Field)마다 개별적으로 TTL을 설정할 수 있게 되었습니다!

redis
# 새로운 HEXPIRE 명령어 사용 예시
# user:session 키 안에 있는 'otp'라는 필드만 180초 뒤에 만료시킵니다.
127.0.0.1:6379> HEXPIRE user:session 180 FIELDS 1 otp

# 과거: user:session 전체가 만료됨
# 현재: Hash 내부 필드마다 개별 생명주기 관리 가능!
text
user:session
 ├ last_access  (TTL 300초)
 ├ token        (TTL 3600초)
 └ otp          (TTL 180초)

그리고 Redis 8.0에서는 Hash를 다루는 강력한 편의 명령어들이 추가되었습니다.

명령어 기능 설명 기존 방식 (비효율) 실무 주요 사용 사례
HGETDEL 필드 조회와 동시에 삭제 HGET + HDEL OTP, 1회성 임시 토큰
HGETEX 필드 조회와 동시에 TTL 갱신 HGET + HEXPIRE Sliding Session (로그인 연장)
HSETEX 필드 저장과 동시에 TTL 설정 HSET + HEXPIRE 임시 데이터, 빠른 부분 캐싱

🗣 Redis 7.4에서 Hash 필드 단위 TTL이 도입되었고, 이어지는 Redis 8.0에서는 HGETDEL, HGETEX, HSETEX처럼 여러 명령을 하나로 합친 Atomic Convenience Commands가 추가되었습니다. 이 명령들은 네트워크 Round Trip 비용을 줄이고, 두 명령 사이에 다른 요청이 끼어드는 동시성 문제를 완벽하게 방지합니다.

② 알아두면 코드가 짧아지는 신규 명령어

명령어 도입 버전 기능 설명 기존 방식
GETDEL Redis 6.2 문자열(String) 조회 후 즉시 삭제 GET + DEL
GETEX Redis 6.2 문자열(String) 조회 시 TTL 재설정 GET + EXPIRE
SMISMEMBER Redis 6.2 여러 개의 멤버가 Set에 있는지 한 번에 확인 SISMEMBER 여러 번 반복
LMPOP / BLMPOP Redis 7.0 여러 개의 List에서 한 번에 데이터 Pop (블로킹 지원) LPOP / BLPOP 반복
ZMPOP / BZMPOP Redis 7.0 여러 개의 Sorted Set에서 한 번에 Pop ZPOPMIN 반복

💡 Tip

LMPOP, ZMPOP 같은 다중 Pop 명령어들은 메시지 큐(Queue) 시스템이나 분산 워커(Worker) 태스크 처리 아키텍처를 설계할 때 RTT(왕복 시간)를 극적으로 줄여줍니다.

그리고 Redis에서는 Pipeline으로 여러 명령을 묶어 보내 RTT를 줄일 수도 있지만, Atomic Convenience Commands는 RTT 감소와 동시에 명령 실행의 원자성(Atomicity) 을 보장한다는 점에서 더 안전한 방법입니다.

6. 👑 모듈이 코어에 들어온 Redis 8.0

  • 초창기 (개별 모듈 시대) — JSON이나 검색(Search), 시계열(TimeSeries) 같은 고급 기능을 쓰려면 외부에서 각 '모듈'을 따로 다운로드하고 빌드해서 레디스에 일일이 끼워 넣어야(Load) 했습니다.
  • 과도기 (Redis Stack 통합) — 2022년부터는 개별 설치의 불편함을 해소하기 위해, 인기 있는 핵심 모듈들을 하나로 묶어 제공하는 'Redis Stack'이라는 패키지가 등장하여 널리 쓰였습니다.
  • 현재 (Redis 8.0 기본 탑재) — Redis 8에서는 과거 Redis Stack으로 별도 제공되던 JSON, Search, TimeSeries, Probabilistic(Bloom 등) 기능이 아예 Redis 기본 배포판(Redis Open Source)에 함께 제공(Bundled)됩니다.
  • 실무적 의미 — 이제 백엔드 개발자는 복잡한 모듈 셋업이나 Stack 전용 버전을 따로 찾을 필요 없이, 기본 레디스만 설치해도 JSON 문서를 그대로 넣고 초고속으로 검색하는 강력한 멀티 모델(Multi-model) 데이터 플랫폼을 즉시 다룰 수 있게 된 것입니다.

🚩 정리 — 지금 버전에서 챙길 것

  • 내부 최적화: Listpack과 Multi-part AOF 덕분에 최신 레디스는 과거 버전에 비해 메모리를 적게 먹고 훨씬 안정적으로 동작합니다.
  • 기능의 진화: 복잡한 로직은 Functions로 서버에 내장하고, 대규모 알림은 Sharded Pub/Sub으로 네트워크 낭비 없이 설계합니다.
  • 원자성 강화와 RTT 최적화: Redis 7.x 이후에서는 대망의 Hash 필드 단위 TTL이 도입되었으며, 네트워크 왕복 시간을 줄이고 동시성을 완벽 제어하는 Atomic Convenience Commands(HGETDEL, GETEX 등)가 대거 추가되었습니다.
  • 멀티 모델 데이터 플랫폼: Redis 8.0의 주요 기능 기본 제공(Bundled)으로, Redis는 이제 단순 캐시를 넘어 검색, 문서 저장, 실시간 처리까지 지원하는 멀티 모델 데이터 플랫폼으로 진화했습니다.

4-5. [보너스] MULTI·EXEC·WATCH로 보는 Redis 트랜잭션의 실체

섹션 4. [Advanced] Redis 심화 및 최신 트렌드 · 유닛 23 / 48 · 보충 학습 자료

이 글에서 바로잡을 통념

  • MULTI / EXEC / DISCARD로 명령을 묶어 실행하는 방법을 익힙니다.
  • Redis 트랜잭션의 원자성이 RDBMS의 ACID와 어떻게 다른지, 특히 롤백이 없다는 사실과 그 이유를 이해합니다.
  • WATCH를 이용한 낙관적 락(Optimistic Lock)으로 동시성 문제를 해결합니다.
  • 파이프라인 · 트랜잭션 · Lua 스크립트의 역할 차이를 구분하고 상황에 맞게 선택합니다.

1. 왜 명령어를 묶어야 하는가

Redis는 싱글 스레드로 명령을 하나씩 처리하므로, 개별 명령 하나는 이미 원자적입니다. INCR 한 번은 절대 반쪽만 실행되지 않습니다. 문제는 여러 명령을 하나의 논리 단위로 묶어야 할 때 발생합니다.

1.1 포인트 이체가 중간에 끊기면 벌어지는 일

사용자 A의 포인트 100점을 사용자 B에게 옮기는 로직을 생각해 봅시다.

redis
DECRBY point:userA 100     # (1) A에서 차감
INCRBY point:userB 100     # (2) B에 적립

(1)과 (2) 사이에 다른 클라이언트의 명령이 끼어들 수 있습니다. 더 나쁜 경우, (1)만 실행되고 애플리케이션이 죽으면 100점이 증발합니다. 두 명령을 "중간에 아무도 끼어들 수 없는 한 덩어리"로 실행할 방법이 필요합니다. 그것이 Redis 트랜잭션입니다.

2. MULTI / EXEC — 큐에 쌓았다가 한 번에 실행

Redis 트랜잭션은 명령을 즉시 실행하지 않고 큐에 쌓아 두었다가, EXEC 시점에 한꺼번에 순서대로 실행하는 구조입니다.

text
클라이언트                         Redis 서버
   |                                 |
   |--- MULTI --------------------->  |  트랜잭션 모드 시작
   |<-- OK ------------------------- |
   |--- DECRBY point:userA 100 ---->  |  실행하지 않고 큐에 저장
   |<-- QUEUED --------------------- |
   |--- INCRBY point:userB 100 ---->  |  큐에 저장
   |<-- QUEUED --------------------- |
   |--- EXEC ---------------------->  |  [이 구간은 다른 클라이언트 개입 불가]
   |                                 |  큐의 명령을 순서대로 전부 실행
   |<-- 1) (integer) 900 ----------- |
   |<-- 2) (integer) 1100 ---------- |
redis
127.0.0.1:6379> SET point:userA 1000
OK
127.0.0.1:6379> SET point:userB 1000
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECRBY point:userA 100
QUEUED                                  # 실행된 것이 아니라 "예약"된 상태
127.0.0.1:6379(TX)> INCRBY point:userB 100
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 900                        # 큐에 넣은 순서대로 결과 반환
2) (integer) 1100

2.1 DISCARD — 쌓아둔 큐 버리기

redis
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET temp "hello"
QUEUED
127.0.0.1:6379(TX)> DISCARD           # 큐를 비우고 트랜잭션 모드 종료
OK
127.0.0.1:6379> GET temp
(nil)                                  # 아무것도 실행되지 않았음

핵심 포인트

EXEC가 실행되는 동안 Redis는 다른 클라이언트의 명령을 처리하지 않습니다. 싱글 스레드이기 때문에 가능한 보장입니다. 즉, 트랜잭션 안의 명령들 사이에는 어떤 명령도 끼어들 수 없습니다. 이것이 Redis 트랜잭션이 제공하는 격리성(Isolation) 입니다.

3. 착각하기 쉬운 지점 — Redis에는 롤백이 없다

RDBMS에 익숙한 개발자가 가장 많이 착각하는 부분입니다. Redis 트랜잭션은 실패한 명령이 있어도 이미 실행된 명령을 되돌리지 않습니다.

3.1 EXECABORT와 WRONGTYPE, 두 에러의 차이

구분 문법 오류 (큐잉 단계 실패) 런타임 오류 (실행 단계 실패)
발생 시점 명령을 큐에 넣을 때 EXEC로 실제 실행할 때
예시 존재하지 않는 명령, 인자 개수 오류 String 키에 LPUSH 시도(타입 불일치)
Redis 동작 트랜잭션 전체 취소. EXECEXECABORT 에러 해당 명령만 에러, 나머지는 그대로 실행. 롤백 없음

사례 A. 문법 오류 — 전체 취소됨

redis
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET k1 "a"
QUEUED
127.0.0.1:6379(TX)> SETT k2 "b"                    # 오타: 없는 명령
(error) ERR unknown command 'SETT'
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET k1
(nil)                                              # SET k1도 실행되지 않음

사례 B. 런타임 오류 — 나머지는 실행됨

redis
127.0.0.1:6379> SET mystr "hello"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> INCR counter                   # 정상
QUEUED
127.0.0.1:6379(TX)> LPUSH mystr "world"            # String에 리스트 명령 (큐잉은 성공)
QUEUED
127.0.0.1:6379(TX)> INCR counter                   # 정상
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 1
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 2                                     # 2번이 실패해도 3번은 실행됨
127.0.0.1:6379> GET counter
"2"                                                # 롤백되지 않았다

반드시 기억할 것

Redis에는 ROLLBACK이 존재하지 않습니다. 공식 문서의 설명은 다음과 같습니다.

  • 런타임 오류는 대부분 프로그래밍 실수(잘못된 타입에 잘못된 명령)이며, 이는 개발 단계에서 잡아야 할 문제다.
  • 롤백 기능을 넣으면 Redis의 단순성과 속도가 훼손된다.

결론적으로 "트랜잭션 안에 넣기 전에 타입과 인자를 애플리케이션에서 검증한다" 가 Redis에서의 정석입니다. 진짜 조건 분기가 필요하면 4-6에서 배울 Lua 스크립트를 사용합니다.

3.2 RDB 트랜잭션과 ACID를 나란히 비교하면

속성 Redis 트랜잭션 설명
Atomicity(원자성) 부분 보장 "전부 실행되거나 전부 안 되거나"가 아니라 "중간에 끼어들지 않고 전부 시도됨". 실패한 명령의 롤백은 없음
Consistency(일관성) 애플리케이션 책임 제약 조건 개념이 없음. 값의 정합성은 코드가 보장해야 함
Isolation(격리성) 완전 보장 싱글 스레드 + EXEC 구간 독점. 가장 강력한 직렬화 수준
Durability(지속성) 설정 의존 RDB/AOF 설정에 따름(6-4 참고). appendfsync always가 아니면 최근 쓰기 유실 가능

4. WATCH — 충돌을 감지해 실행을 취소하는 낙관적 락

트랜잭션만으로는 해결되지 않는 문제가 있습니다. "현재 값을 읽고, 그 값에 따라 다른 명령을 실행" 하는 경우입니다. MULTI 안에서는 명령이 큐잉만 되기 때문에 결과를 읽어서 분기할 수 없습니다.

4.1 문제 상황: 두 요청이 같은 재고를 깎는다

python
# 잘못된 시도 - MULTI 안에서는 값을 읽어 판단할 수 없다
stock = r.get("stock:1001")      # 이 시점에 10
if int(stock) > 0:               # 다른 클라이언트가 그 사이에 0으로 만들 수 있다
    r.decr("stock:1001")         # 재고가 -1이 될 수 있음

4.2 WATCH로 감시하고 EXEC에서 걸러내기

WATCH key는 "이 키를 감시하다가, EXEC 전에 다른 클라이언트가 이 키를 변경하면 트랜잭션을 실패시켜 달라"는 요청입니다. 실패 시 EXECnil을 반환합니다.

text
클라이언트 A                                  클라이언트 B
  WATCH stock:1001
  GET stock:1001  ->  10
  (재고 있음 판단)
                                              SET stock:1001 0   <- 감시 대상 변경
  MULTI
  DECR stock:1001
  EXEC  ->  (nil)   [실패: 감시 키가 바뀜]
  (재시도 루프로 복귀)
redis
127.0.0.1:6379> SET stock:1001 10
OK
127.0.0.1:6379> WATCH stock:1001                # 감시 시작
OK
127.0.0.1:6379> GET stock:1001
"10"                                            # 재고 확인 후 판단 가능
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECR stock:1001
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 9                                  # 아무도 안 건드렸으면 성공

# 만약 EXEC 직전에 다른 클라이언트가 stock:1001을 수정했다면
127.0.0.1:6379(TX)> EXEC
(nil)                                           # 실패 -> 애플리케이션이 재시도

주의

  • EXEC, DISCARD, 연결 종료 시 모든 WATCH는 자동 해제됩니다. 수동 해제는 UNWATCH입니다.
  • WATCH는 값이 실제로 달라졌는지가 아니라 키가 수정되었는지를 봅니다. 같은 값으로 다시 SET 해도 트랜잭션은 실패합니다.
  • 경합이 심한 핫키에 WATCH 재시도 루프를 걸면 재시도가 폭증합니다. 이럴 때는 Lua 스크립트(4-6) 또는 분산 락(5-8)이 더 낫습니다.

5. 실습 — redis-py로 직접 구현하기

5.1 파이프라인 트랜잭션 기본형

python
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
r.mset({"point:userA": 1000, "point:userB": 1000})

# pipeline(transaction=True)가 MULTI/EXEC로 감싼다 (기본값이 True)
with r.pipeline() as pipe:
    pipe.decrby("point:userA", 100)
    pipe.incrby("point:userB", 100)
    results = pipe.execute()          # EXEC

print(results)                        # [900, 1100]

5.2 WATCH 재고 차감과 재시도 루프

python
import redis
from redis.exceptions import WatchError

r = redis.Redis(host="localhost", port=6379, decode_responses=True)


def decrease_stock(product_id: str, qty: int = 1, max_retry: int = 5) -> bool:
    """재고를 안전하게 차감한다. 성공 True / 재고 부족 또는 경합 초과 시 False."""
    key = f"stock:{product_id}"

    for attempt in range(max_retry):
        with r.pipeline() as pipe:
            try:
                # 1) 감시 시작 (이 시점부터 pipe는 즉시 실행 모드)
                pipe.watch(key)

                # 2) 현재 값을 읽고 애플리케이션에서 판단
                current = int(pipe.get(key) or 0)
                if current < qty:
                    pipe.unwatch()
                    print(f"재고 부족: {current}개")
                    return False

                # 3) 트랜잭션 모드로 전환 후 명령 큐잉
                pipe.multi()
                pipe.decrby(key, qty)

                # 4) 실행. 감시 키가 변경됐으면 WatchError
                pipe.execute()
                print(f"차감 성공 (시도 {attempt + 1}회): 남은 재고 {current - qty}개")
                return True

            except WatchError:
                # 다른 클라이언트가 먼저 값을 바꿨다 -> 처음부터 재시도
                print(f"경합 발생, 재시도 {attempt + 1}/{max_retry}")
                continue

    print("재시도 횟수 초과")
    return False


if __name__ == "__main__":
    r.set("stock:1001", 10)
    decrease_stock("1001", 3)

코드 핵심 포인트

  • pipe.watch(key)를 호출하면 파이프라인이 즉시 실행 모드로 바뀝니다. 그래서 그 안에서 get()의 결과를 읽어 분기할 수 있습니다.
  • pipe.multi() 이후부터 다시 큐잉 모드가 되어 execute() 때 한꺼번에 실행됩니다.
  • WatchError는 정상적인 경합 신호입니다. 예외를 삼키지 말고 재시도 루프로 처리해야 합니다.
  • 재시도 횟수에 상한을 두어 무한 루프와 CPU 낭비를 막습니다.

6. 파이프라인·트랜잭션·Lua, 무엇을 언제 쓰나

세 가지는 자주 혼동되지만 목적이 다릅니다. 실무에서는 "무엇을 보장받고 싶은가"로 선택합니다.

구분 파이프라인 트랜잭션(MULTI/EXEC) Lua 스크립트
목적 네트워크 왕복 절감 중간 개입 차단 서버 측 로직 실행 + 원자성
원자성 없음(다른 명령 끼어듦 가능) EXEC 구간 보장 스크립트 전체 보장
중간 값 읽고 분기 불가 불가(WATCH 우회 필요) 가능
실패 시 롤백 없음 없음 없음(단, 조건 검사로 사전 차단 가능)
주 사용처 대량 SET/GET, 배치 적재 단순한 복수 쓰기 묶음 재고 차감, 락 해제, Rate Limit

실무 선택 기준

  • 단순히 빠르게 여러 명령을 보내고 싶다 → 파이프라인
  • 여러 쓰기를 끼어듦 없이 묶고 싶다 → MULTI/EXEC
  • 값을 읽고 판단해서 쓰는 로직을 원자적으로 → Lua 스크립트(4-6)
  • 여러 Redis 노드/서비스에 걸친 상호 배제가 필요 → 분산 락(5-8)

7. 운영에서 트랜잭션이 발목 잡는 지점

항목 내용
클러스터 환경 트랜잭션 안의 모든 키는 같은 해시 슬롯에 있어야 합니다. 해시 태그({user1}:point 형태)로 슬롯을 묶습니다
긴 트랜잭션 EXEC 구간 동안 서버 전체가 대기합니다. 수천 개 명령을 한 트랜잭션에 넣으면 다른 요청의 지연이 급증합니다
WATCH 남용 경합이 심한 키에서는 재시도가 폭발합니다. 실패율이 높다면 설계를 Lua 또는 락으로 전환하십시오
복제(Replication) 트랜잭션은 복제본에도 MULTI/EXEC로 전파되어 원자성이 유지됩니다
모니터링 INFO commandstats에서 cmdstat_exec의 호출 수와 평균 소요 시간을 확인합니다

8. 실습 가이드 — 경합을 일부러 만들어 확인하기

1️⃣ 터미널 2개로 redis-cli 접속

bash
# 터미널 1
docker exec -it redis redis-cli

# 터미널 2
docker exec -it redis redis-cli

2️⃣ 터미널 1에서 감시 후 대기

redis
SET seat:A1 "empty"
WATCH seat:A1
GET seat:A1            # "empty" 확인
MULTI
SET seat:A1 "user-1"
# 아직 EXEC 하지 않고 대기

3️⃣ 터미널 2에서 같은 키를 선점

redis
SET seat:A1 "user-2"

4️⃣ 터미널 1에서 EXEC 실행

redis
EXEC
(nil)                  # 실패
GET seat:A1
"user-2"               # 먼저 잡은 사람이 지켰다

확인 결과

  • 결과: 터미널 1의 EXEC(nil)을 반환하며 좌석 예약에 실패했습니다.
  • 의미: WATCH가 중복 예약(Double Booking)을 막았습니다. 애플리케이션은 (nil)을 받으면 "이미 선점됨"으로 처리하고 사용자에게 다른 좌석을 안내하거나 재시도해야 합니다. 이것이 락을 걸지 않고도 동시성을 제어하는 낙관적 동시성 제어의 기본형입니다.

9. 정리 — Redis 트랜잭션의 정확한 보장 범위

명령 역할
MULTI 트랜잭션 시작. 이후 명령은 실행되지 않고 큐에 쌓임(QUEUED)
EXEC 큐의 명령을 중간 개입 없이 순서대로 실행하고 결과 배열 반환
DISCARD 큐를 비우고 트랜잭션 종료
WATCH key [key ...] 키를 감시. EXEC 전에 변경되면 트랜잭션 실패(nil 반환)
UNWATCH 모든 감시 해제

한 줄 요약

Redis 트랜잭션은 "끼어들기 방지 + 일괄 실행"이지 "실패 시 되돌리기"가 아닙니다. 값을 읽어 판단해야 하는 로직은 WATCH 재시도로 풀거나, 경합이 크다면 다음 유닛(4-6)의 Lua 스크립트와 Redis Functions로 서버 안에서 원자적으로 처리하십시오.


4-6. Lua Script와 Functions — 서버에서 통째로 실행하는 원자 연산

섹션 4. [Advanced] Redis 심화 및 최신 트렌드 · 유닛 24 / 48 · 보충 학습 자료

이 글에서 확보할 원자성

  • MULTI/EXEC로 풀 수 없는 문제를 정의하고, 왜 서버 측 스크립팅이 필요한지 이해합니다.
  • EVAL / EVALSHA의 문법, KEYS·ARGV 규약, 타입 변환 규칙을 익힙니다.
  • 재고 차감, 분산 락 안전 해제, Rate Limiting을 원자적 Lua 스크립트로 구현합니다.
  • Redis 7.0의 Redis Functions(FUNCTION LOAD / FCALL)로 스크립트를 코드 자산으로 관리합니다.
  • 스크립트가 서버 전체를 멈출 수 있다는 위험과 그 방어책(lua-time-limit, SCRIPT KILL)을 이해합니다.

1. MULTI/EXEC로는 안 되는 것 — 중간 값 판단

4-5에서 본 것처럼 MULTI는 명령을 큐에 쌓았다가 한꺼번에 실행할 뿐입니다. 큐잉 시점에는 값을 알 수 없으므로 "읽고 → 판단하고 → 쓰는" 로직을 담을 수 없습니다.

text
[MULTI/EXEC로 불가능한 것]
    현재 재고를 읽는다  ->  0보다 크면 차감한다  ->  아니면 아무것도 안 한다
                          ^^^^^^^^^^^^^^^^^^^^ 이 "판단"이 서버 안에서 일어나야 한다

[해결책 3가지]
  1) WATCH + 재시도        : 경합이 심하면 재시도 폭증, 낭비
  2) 분산 락               : 락 획득/해제 비용 + 락 자체의 정합성 문제
  3) Lua 스크립트          : 읽기·판단·쓰기를 서버에서 한 번에, 원자적으로   <-- 최적

Redis는 Lua 인터프리터를 내장하고 있습니다. 스크립트는 서버 내부에서 다른 명령의 개입 없이 처음부터 끝까지 실행됩니다. 즉 스크립트 전체가 하나의 거대한 원자적 명령이 됩니다.

핵심 포인트

  • 원자성: 스크립트 실행 중 다른 클라이언트의 명령은 대기합니다.
  • 네트워크 왕복 제거: 읽기-판단-쓰기를 1회 왕복으로 처리합니다.
  • 조건 분기 가능: if, 반복문, 계산 등 로직을 서버에서 수행합니다.
  • 재사용: SHA1로 캐시되어 이후에는 해시만 보내면 됩니다.

2. EVAL 기본 문법과 KEYS·ARGV

text
EVAL "<lua script>" <numkeys> [key1 key2 ...] [arg1 arg2 ...]
요소 설명
script 실행할 Lua 코드 문자열
numkeys 뒤따르는 인자 중 몇 개가 키인지. 이 숫자를 기준으로 KEYS와 ARGV가 나뉜다
KEYS[n] Lua 안에서 키에 접근하는 배열. 1부터 시작(Lua는 1-base)
ARGV[n] 키가 아닌 일반 인자 배열

2.1 첫 스크립트 실행해보기

redis
# 인자 그대로 반환
127.0.0.1:6379> EVAL "return {KEYS[1], KEYS[2], ARGV[1]}" 2 k1 k2 hello
1) "k1"
2) "k2"
3) "hello"

# Redis 명령 호출
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 greeting "hi"
OK
127.0.0.1:6379> GET greeting
"hi"

# 값을 읽어 계산 후 반환
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> EVAL "local v = redis.call('GET', KEYS[1]) return tonumber(v) * 2" 1 counter
(integer) 20

2.2 redis.call과 redis.pcall의 에러 처리 차이

함수 에러 발생 시 동작 사용 시점
redis.call() 즉시 스크립트를 중단하고 클라이언트에 에러를 전파 기본. 실패하면 멈춰야 하는 경우
redis.pcall() 에러를 Lua 테이블로 반환. 스크립트는 계속 진행 에러를 직접 처리하고 싶은 경우
lua
-- pcall로 에러를 직접 처리하는 예
local ok = redis.pcall('INCR', KEYS[1])
if ok and ok.err then
    return {err = 'INCR 실패: ' .. ok.err}
end
return ok

2.3 타입 변환 규칙 — 여기서 대부분 실수한다

Lua와 Redis는 타입 체계가 다릅니다. 변환 규칙을 모르면 값이 조용히 잘려 나갑니다.

Lua 반환값 클라이언트가 받는 값 주의
number Integer 소수점 이하가 버려진다. 3.9 → 3
string Bulk String 가장 안전. 실수는 tostring()으로 문자열화해 반환
table (배열) Array 첫 nil에서 잘린다. {1, nil, 3}[1]
table 안 ok 필드 Simple String(상태 응답) {ok = 'DONE'} → DONE
table 안 err 필드 Error {err = '메시지'} → 에러 응답
false nil (Null) Redis의 nil은 Lua에서 false로 들어온다
true Integer 1 불리언은 1로 변환된다

주의

GET이 키를 못 찾으면 Lua에서는 nil이 아니라 false가 반환됩니다. 따라서 if v == nil then이 아니라 if not v then 또는 if v == false then으로 검사해야 합니다. 또한 GET 결과는 항상 문자열이므로 산술 연산 전에 tonumber()가 필요합니다.

3. 매번 전문을 보내지 않는 법 — SCRIPT LOAD와 EVALSHA

스크립트를 매번 통째로 전송하면 네트워크가 낭비됩니다. Redis는 실행한 스크립트를 SHA1 해시로 캐싱하므로, 이후에는 해시만 보내면 됩니다.

redis
# 1) 스크립트를 캐시에 등록하고 SHA1을 받는다 (실행은 하지 않음)
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"4e6d8fc8bb01276962cce5371fa795a7763657ae"

# 2) 해시로 실행
127.0.0.1:6379> EVALSHA 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 greeting
"hi"

# 3) 캐시 존재 여부 확인
127.0.0.1:6379> SCRIPT EXISTS 4e6d8fc8bb01276962cce5371fa795a7763657ae
1) (integer) 1

# 4) 캐시 비우기 (운영 중 주의)
127.0.0.1:6379> SCRIPT FLUSH
OK

NOSCRIPT 에러 처리

스크립트 캐시는 서버 재시작이나 SCRIPT FLUSH 시 사라집니다. 이때 EVALSHANOSCRIPT 에러를 냅니다. 실무 클라이언트는 이 에러를 잡아 자동으로 EVAL로 재전송해야 합니다. 다행히 redis-py의 register_script(), Lettuce, ioredis 등 주요 클라이언트는 이 폴백을 내장하고 있으므로 직접 EVALSHA를 다루지 말고 클라이언트 래퍼를 쓰는 것이 정석입니다.

4. 실전 1 — 재고를 확인하고 같은 호출에서 차감하기

가장 전형적인 사례입니다. "재고가 충분할 때만 차감"을 한 번의 원자적 연산으로 처리합니다.

decr_stock.lua

lua
-- KEYS[1] : 재고 키           예) stock:1001
-- ARGV[1] : 차감 수량          예) 3
-- 반환     : 남은 재고(성공) / -1(재고 부족) / -2(키 없음)

local stock = redis.call('GET', KEYS[1])
if not stock then
    return -2
end

local current = tonumber(stock)
local qty = tonumber(ARGV[1])

if current < qty then
    return -1
end

return redis.call('DECRBY', KEYS[1], qty)
python
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

DECR_STOCK = """
local stock = redis.call('GET', KEYS[1])
if not stock then return -2 end
local current = tonumber(stock)
local qty = tonumber(ARGV[1])
if current < qty then return -1 end
return redis.call('DECRBY', KEYS[1], qty)
"""

# register_script는 EVALSHA를 쓰고, NOSCRIPT 시 자동으로 EVAL로 폴백한다
decr_stock = r.register_script(DECR_STOCK)


def purchase(product_id: str, qty: int) -> dict:
    result = decr_stock(keys=[f"stock:{product_id}"], args=[qty])
    if result == -2:
        return {"ok": False, "reason": "상품이 존재하지 않습니다"}
    if result == -1:
        return {"ok": False, "reason": "재고가 부족합니다"}
    return {"ok": True, "remaining": result}


if __name__ == "__main__":
    r.set("stock:1001", 5)
    print(purchase("1001", 3))   # {'ok': True, 'remaining': 2}
    print(purchase("1001", 3))   # {'ok': False, 'reason': '재고가 부족합니다'}

이 코드가 해결한 것

  • 1000개의 요청이 동시에 들어와도 재고는 절대 음수가 되지 않습니다.
  • WATCH 재시도 루프가 필요 없으므로 경합 상황에서 CPU와 왕복이 낭비되지 않습니다.
  • 반환값을 코드(-1, -2)로 구분해 애플리케이션이 사유별로 분기할 수 있습니다.

5. 실전 2 — 내가 잡은 락만 안전하게 푸는 법

분산 락(5-8)의 가장 위험한 버그는 남의 락을 해제하는 것입니다. A가 획득한 락이 TTL로 만료되고 B가 새로 획득했는데, 뒤늦게 A가 DEL을 호출하면 B의 락이 풀려 버립니다.

text
[위험한 구현]
  A: SET lock "A" NX EX 10
  A: (작업이 10초를 초과)           -> 락 자동 만료
  B: SET lock "B" NX EX 10          -> B가 락 획득
  A: DEL lock                       -> B의 락을 A가 지워버림 (치명적)

[안전한 구현: 소유자 확인 후 삭제를 원자적으로]
  A: GET lock == "A"인 경우에만 DEL   <- 이 두 단계가 원자적이어야 한다

unlock.lua

lua
-- KEYS[1] : 락 키         예) lock:order:1001
-- ARGV[1] : 내 토큰        예) uuid4 문자열
-- 반환     : 1(해제 성공) / 0(내 락이 아님)

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
python
import uuid
import redis
from contextlib import contextmanager

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

UNLOCK = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
"""
unlock_script = r.register_script(UNLOCK)


@contextmanager
def redis_lock(name: str, ttl: int = 10):
    """소유권을 검증하는 안전한 분산 락."""
    key = f"lock:{name}"
    token = str(uuid.uuid4())          # 소유자 식별용 고유 토큰

    acquired = r.set(key, token, nx=True, ex=ttl)
    if not acquired:
        raise RuntimeError(f"락 획득 실패: {name}")

    try:
        yield token
    finally:
        # 내 토큰일 때만 삭제 (원자적)
        released = unlock_script(keys=[key], args=[token])
        if released == 0:
            # 이미 만료되어 다른 소유자에게 넘어간 상태
            print("경고: 락이 만료되어 다른 프로세스가 소유 중이었습니다")


if __name__ == "__main__":
    with redis_lock("order:1001", ttl=5):
        print("임계 구역 작업 수행")

6. 실전 3 — 토큰 버킷 방식 Rate Limiter

고정 윈도우 카운터는 경계 시점에 2배 트래픽을 허용하는 약점이 있습니다. 토큰 버킷은 시간에 비례해 토큰을 채워 넣어 이 문제를 완화합니다. 다중 키 읽기·계산·쓰기가 필요하므로 Lua가 가장 적합합니다.

token_bucket.lua

lua
-- KEYS[1] : 버킷 키                예) rl:user:42
-- ARGV[1] : 버킷 최대 용량          예) 10
-- ARGV[2] : 초당 리필 토큰 수       예) 2
-- ARGV[3] : 현재 시각(초, 실수)     예) 1735800000.123
-- ARGV[4] : 이번에 소모할 토큰      예) 1
-- 반환     : {허용여부(1/0), 남은토큰(정수)}

local capacity    = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now         = tonumber(ARGV[3])
local requested   = tonumber(ARGV[4])

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local last   = tonumber(bucket[2])

if tokens == nil then           -- 최초 요청: 가득 찬 버킷으로 시작
    tokens = capacity
    last = now
end

-- 경과 시간만큼 토큰 보충 (용량 상한)
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * refill_rate)

local allowed = 0
if tokens >= requested then
    tokens = tokens - requested
    allowed = 1
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- 버킷이 완전히 차는 데 걸리는 시간 + 여유만큼만 보관
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill_rate) + 10)

return {allowed, math.floor(tokens)}

Python (FastAPI 미들웨어 적용)

python
import time
import redis
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
token_bucket = r.register_script(open("token_bucket.lua", encoding="utf-8").read())

app = FastAPI()

CAPACITY = 10       # 순간 최대 10회
REFILL = 2.0        # 초당 2개씩 회복


@app.middleware("http")
async def rate_limit(request: Request, call_next):
    client_ip = request.client.host
    allowed, remaining = token_bucket(
        keys=[f"rl:{client_ip}"],
        args=[CAPACITY, REFILL, time.time(), 1],
    )

    if allowed == 0:
        return JSONResponse(
            status_code=429,
            content={"detail": "요청이 너무 많습니다"},
            headers={"Retry-After": "1", "X-RateLimit-Remaining": "0"},
        )

    response = await call_next(request)
    response.headers["X-RateLimit-Remaining"] = str(remaining)
    return response


@app.get("/api/items")
async def items():
    return {"items": ["a", "b", "c"]}

현재 시각을 서버가 아니라 클라이언트에서 넘기는 이유

Lua 안에서 redis.call('TIME')을 쓸 수도 있지만, 과거 Redis(3.2 이전)에서는 비결정적 명령 사용에 제약이 있었습니다. 또한 여러 애플리케이션 서버의 시계가 동기화되어 있다면 시각을 인자로 넘기는 편이 테스트하기 쉽습니다(시간을 주입해 단위 테스트 가능). 반대로 서버 간 시계 오차가 우려되면 redis.call('TIME')을 써서 Redis의 시계를 단일 기준으로 삼으십시오.

7. 원자성의 대가 — 스크립트가 도는 동안 서버는 멈춘다

Lua 스크립트가 실행되는 동안 Redis는 다른 어떤 요청도 처리하지 못합니다. 이는 원자성의 근거이자 가장 큰 위험 요소입니다.

절대 금지

  • 스크립트 안에서 수십만 건을 순회하는 반복문 (전체 키 스캔, 대량 ZRANGE 후 루프)
  • 무한 루프 가능성이 있는 조건문
  • 스크립트 내부의 KEYS * 호출
  • 스크립트 하나에서 수천 개 키를 한꺼번에 갱신하는 배치 작업

스크립트는 수 밀리초 안에 끝나는 작은 로직이어야 합니다.

7.1 폭주한 스크립트를 SCRIPT KILL로 끊기

항목 내용
busy-reply-threshold (구 lua-time-limit) 기본 5000ms. 이 시간을 넘기면 Redis는 다른 클라이언트에게 BUSY 에러를 응답하기 시작합니다(스크립트를 죽이지는 않음)
SCRIPT KILL 실행 중인 스크립트를 강제 종료. 단, 쓰기 명령을 이미 수행한 스크립트는 죽일 수 없습니다(데이터가 반쪽 상태가 되므로)
SHUTDOWN NOSAVE 쓰기를 수행한 스크립트가 폭주한 최후의 수단. 저장하지 않고 서버를 내립니다. 데이터 유실 각오 필요
redis
# 다른 터미널에서 상태 확인
127.0.0.1:6379> PING
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
127.0.0.1:6379> SCRIPT KILL
OK

8. 복제로 무엇이 전달되는가 — 결정성과 effect replication

Redis 3.2 이전에는 스크립트가 복제본에 그대로 전송되었기 때문에, math.random()이나 시간에 의존하는 스크립트는 마스터와 복제본의 결과가 달라질 수 있었습니다.

버전 복제 방식 영향
Redis 3.2 이전 스크립트 자체를 복제(script replication) 비결정적 명령 사용 시 데이터 불일치 위험. math.random 시드를 강제 고정
Redis 5.0 이후(기본) 효과 복제(effect replication) — 스크립트가 실제로 수행한 쓰기 명령만 복제 비결정성 문제 해소. 랜덤·시간 사용이 자유로움

실무 지침

현행 버전(7.x, 8.x)에서는 효과 복제가 기본이므로 redis.replicate_commands()를 호출할 필요가 없습니다. 다만 같은 입력에 같은 결과를 내는 스크립트를 작성하는 습관은 디버깅과 테스트를 훨씬 쉽게 만듭니다.

9. Redis Functions — 스크립트를 라이브러리로 관리하기 (7.0+)

EVAL의 근본적인 불편함은 스크립트가 애플리케이션 코드 안에 문자열로 흩어진다는 점입니다. 서버를 재시작하면 캐시가 날아가고, 어떤 스크립트가 등록되어 있는지 서버 쪽에서는 알 수 없습니다. Redis 7.0은 이를 해결하기 위해 Functions를 도입했습니다.

비교 항목 EVAL / EVALSHA Redis Functions
저장 위치 애플리케이션 코드(문자열) Redis 서버 자체. 데이터베이스의 일부
재시작 후 캐시 소멸 → NOSCRIPT 폴백 필요 RDB/AOF에 함께 저장되어 유지됨
복제 · 백업 대상 아님 복제본과 백업에 함께 전파됨
호출 방식 SHA1 해시 이름으로 호출 (FCALL myfunc)
구성 단위 스크립트 1개 라이브러리 단위(함수 여러 개 묶음)
읽기 전용 호출 구분 없음 FCALL_RO로 복제본에서 실행 가능

9.1 라이브러리 파일 작성하기

mylib.lua

lua
#!lua name=mylib

-- 함수 1: 조건부 재고 차감
local function decr_stock(keys, args)
    local stock = redis.call('GET', keys[1])
    if not stock then return -2 end
    local current = tonumber(stock)
    local qty = tonumber(args[1])
    if current < qty then return -1 end
    return redis.call('DECRBY', keys[1], qty)
end

-- 함수 2: 소유권 검증 락 해제
local function safe_unlock(keys, args)
    if redis.call('GET', keys[1]) == args[1] then
        return redis.call('DEL', keys[1])
    end
    return 0
end

-- 함수 3: 읽기 전용 - 재고 조회
local function get_stock(keys, args)
    local v = redis.call('GET', keys[1])
    if not v then return -2 end
    return tonumber(v)
end

redis.register_function('decr_stock', decr_stock)
redis.register_function('safe_unlock', safe_unlock)
redis.register_function{
    function_name = 'get_stock',
    callback      = get_stock,
    flags         = {'no-writes'}    -- FCALL_RO로 호출 가능해진다
}

문법 주의

  • 첫 줄의 #!lua name=<라이브러리명>은 필수 셔뱅입니다.
  • 함수 시그니처는 function(keys, args)입니다. EVAL의 전역 KEYS/ARGV와 달리 인자로 전달되며, 이름이 소문자입니다.
  • 라이브러리 안에서 전역 변수를 선언하면 로드가 거부됩니다. 반드시 local을 사용하십시오.

9.2 FUNCTION LOAD로 등록하고 FCALL로 호출하기

bash
# 라이브러리 등록 (기존 라이브러리 갱신은 REPLACE)
$ redis-cli -x FUNCTION LOAD REPLACE < mylib.lua
"mylib"

# 등록 확인
$ redis-cli FUNCTION LIST
1)  1) "library_name"
    2) "mylib"
    3) "engine"
    4) "LUA"
    5) "functions"
    6) 1) 1) "name"
          2) "decr_stock"
          ...
redis
127.0.0.1:6379> SET stock:2001 5
OK

# FCALL <함수명> <numkeys> [keys...] [args...]
127.0.0.1:6379> FCALL decr_stock 1 stock:2001 3
(integer) 2
127.0.0.1:6379> FCALL decr_stock 1 stock:2001 3
(integer) -1                     # 재고 부족

# 읽기 전용 함수는 FCALL_RO로 (복제본에서도 실행 가능)
127.0.0.1:6379> FCALL_RO get_stock 1 stock:2001
(integer) 2

# 쓰기 함수를 FCALL_RO로 부르면 거부된다
127.0.0.1:6379> FCALL_RO decr_stock 1 stock:2001 1
(error) ERR Can not execute a script with write flag using *_ro command.

9.3 배포·조회·삭제 운영 명령

명령 용도
FUNCTION LIST [WITHCODE] 등록된 라이브러리와 함수 목록 조회
FUNCTION STATS 현재 실행 중인 함수와 엔진 정보
FUNCTION DUMP / RESTORE 전체 라이브러리 백업 및 복구(다른 서버로 이관 시 사용)
FUNCTION DELETE <lib> 라이브러리 삭제
FUNCTION FLUSH 모든 라이브러리 제거(운영 중 주의)
FUNCTION KILL 폭주 중인 함수 강제 종료(쓰기 전 상태에서만 가능)

Python (Functions 호출)

python
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# 배포 시점에 1회만 로드하면 이후 재시작해도 유지된다
with open("mylib.lua", encoding="utf-8") as f:
    r.function_load(f.read(), replace=True)

r.set("stock:2001", 5)

result = r.fcall("decr_stock", 1, "stock:2001", 3)
print(result)                       # 2

remaining = r.fcall_ro("get_stock", 1, "stock:2001")
print(remaining)                    # 2

10. 클러스터에서 키가 흩어지면 생기는 문제

반드시 지킬 것

  • 스크립트가 접근하는 모든 키는 반드시 KEYS로 선언해야 합니다. 하드코딩하거나 ARGV로 몰래 넘기면 클러스터가 슬롯을 계산하지 못해 오작동합니다.
  • 한 스크립트의 키들은 같은 해시 슬롯에 있어야 합니다. 해시 태그를 사용하십시오.
  • {order:1001}:stock, {order:1001}:lock → 중괄호 안이 같으므로 같은 슬롯
  • 슬롯이 다르면 CROSSSLOT Keys in request don't hash to the same slot 에러가 발생합니다.

11. 문제가 생겼을 때 들여다보는 법

bash
# Lua 디버거 (중단점 지원)
$ redis-cli --ldb --eval decr_stock.lua stock:1001 , 3
lua debugger> step        # 한 줄씩 실행
lua debugger> print       # 지역 변수 출력
lua debugger> continue    # 계속 실행

# 느린 스크립트 확인
$ redis-cli SLOWLOG GET 10

# 명령별 통계 (eval / fcall 호출 수와 평균 시간)
$ redis-cli INFO commandstats | grep -E "eval|fcall"
cmdstat_eval:calls=1520,usec=45120,usec_per_call=29.68
cmdstat_fcall:calls=884,usec=21030,usec_per_call=23.79

성능 기준선

잘 작성된 스크립트의 usec_per_call은 보통 수십 마이크로초 수준입니다. 1000us(1ms)를 넘어가기 시작하면 로직이 과하거나 순회 대상이 크다는 신호이니 즉시 점검하십시오.

12. 실습 가이드 — 원자성을 눈으로 확인하기

1️⃣ 비원자적 구현과 원자적 구현 비교 스크립트 작성

race_test.py

python
import threading
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

DECR_STOCK = """
local stock = redis.call('GET', KEYS[1])
if not stock then return -2 end
if tonumber(stock) < tonumber(ARGV[1]) then return -1 end
return redis.call('DECRBY', KEYS[1], ARGV[1])
"""
decr_stock = r.register_script(DECR_STOCK)


def unsafe_purchase():
    """GET -> 판단 -> DECR : 사이에 다른 스레드가 끼어든다"""
    stock = int(r.get("stock:unsafe") or 0)
    if stock > 0:
        r.decr("stock:unsafe")


def safe_purchase():
    """Lua로 한 번에 : 끼어들 수 없다"""
    decr_stock(keys=["stock:safe"], args=[1])


def run(fn, key, threads=100):
    r.set(key, 50)                      # 재고 50개, 요청 100건
    workers = [threading.Thread(target=fn) for _ in range(threads)]
    for w in workers:
        w.start()
    for w in workers:
        w.join()
    print(f"{key} 최종 재고: {r.get(key)}")


if __name__ == "__main__":
    run(unsafe_purchase, "stock:unsafe")
    run(safe_purchase, "stock:safe")

2️⃣ 실행

bash
$ python race_test.py
stock:unsafe 최종 재고: -7      <- 재고가 음수가 되었다 (오버셀)
stock:safe 최종 재고: 0         <- 정확히 50개만 팔렸다

확인 결과

  • 결과: 비원자적 구현은 재고가 음수로 떨어졌고, Lua 스크립트 구현은 정확히 0에서 멈췄습니다. (음수 값은 실행 환경과 타이밍에 따라 매번 달라집니다.)
  • 의미: GETDECR 사이의 아주 짧은 틈에도 수십 개의 요청이 끼어들 수 있습니다. 실제 커머스에서 이 틈은 오버셀(초과 판매) 이라는 금전적 사고로 이어집니다. Lua 스크립트는 이 틈 자체를 없앱니다.

13. 정리 — 스크립트를 꺼낼 때와 참을 때

상황 권장 방식 이유
단순 복수 쓰기 파이프라인 가장 가볍고 빠름. 원자성 불필요
끼어듦만 막으면 됨 MULTI / EXEC 추가 학습 비용 없음
읽고 판단해서 쓰기 (경합 낮음) WATCH 재시도 스크립트 없이 해결 가능
읽고 판단해서 쓰기 (경합 높음) Lua 스크립트 재시도 없이 1회 왕복으로 완결
여러 서비스가 공유하는 표준 로직 Redis Functions 서버에 상주. 재시작·복제·백업에 포함
수만 건 배치 갱신 스크립트 금지 서버 전체가 멈춤. 파이프라인으로 나눠 처리

한 줄 요약

Lua 스크립트는 "Redis를 잠깐 내 것으로 독점하는 대가로 완벽한 원자성을 얻는" 도구입니다. 독점 시간이 짧을수록 안전하고, 길수록 위험합니다. 로직은 작게 유지하고, 반복 사용하는 스크립트는 Redis Functions로 승격시켜 서버 자산으로 관리하십시오.

댓글