개발/REDIS

#5 📬 Lists·Sets: 큐와 중복 방지

cedis 2026. 3. 19. 10:06
이메일 발송, 알림 처리 같은 비동기 작업을 어떻게 안전하게 처리할까요? List와 Set으로 큐와 중복 방지를 구현해 봅니다.

🤔 비동기 작업, 어떻게 처리하나요?

사용자가 회원가입을 하면 환영 이메일을 보내야 합니다. 그런데 이메일을 보내는 작업은 느립니다. 외부 메일 서버에 요청을 보내고, 응답을 기다리고, 실패하면 재시도해야 하죠. 만약 이 작업을 사용자가 회원가입 버튼을 누르는 순간에 동기적으로 처리한다면 어떻게 될까요?

사용자는 가입 완료 화면이 뜨기까지 수 초를 기다려야 합니다. 메일 서버가 잠깐 다운된다면 회원가입 자체가 실패할 수도 있습니다. 이런 상황을 막기 위해 "작업을 등록만 하고, 처리는 나중에"라는 비동기 큐 패턴을 사용합니다.

Redis의 List는 바로 이 큐 역할을 아주 쉽게 구현할 수 있게 해줍니다. 왼쪽에 넣고 오른쪽에서 꺼내거나(FIFO), 오른쪽에 넣고 왼쪽에서 꺼내는(LIFO) 방식 모두 지원합니다.

한편, 같은 사용자가 오늘 페이지를 여러 번 방문해도 "오늘 방문자 수"는 1로 계산해야 하는 경우가 있습니다. 중복을 제거해야 하는 상황이죠. Redis의 Set은 동일한 값을 두 번 넣어도 하나만 저장되는 중복 없는 집합입니다.

이번 편에서는 List로 작업 큐를 만들고, Set으로 중복을 방지하는 패턴인 멱등성(idempotency)을 구현해 봅니다. Lab 4의 코드를 직접 실행하며 확인해 보세요.

💡 멱등성(Idempotency)이란?
같은 작업을 여러 번 실행해도 결과가 달라지지 않는 성질입니다. 예를 들어 "이 사용자에게 환영 이메일 발송"이라는 작업이 네트워크 오류로 두 번 실행되어도 메일은 한 번만 발송되어야 합니다.

📋 핵심 개념 1: List — 줄 서기(큐/스택)

Redis의 List는 문자열의 연결 리스트(Linked List)입니다. 앞(Left)과 뒤(Right) 모두에서 추가하고 꺼낼 수 있어서, 큐(Queue)와 스택(Stack) 모두 구현할 수 있습니다.

기본 명령어

명령어 설명 예시
LPUSH key val 왼쪽에 추가 LPUSH queue:email "job1"
RPUSH key val 오른쪽에 추가 RPUSH queue:email "job2"
LPOP key 왼쪽에서 꺼내기 LPOP queue:email
RPOP key 오른쪽에서 꺼내기 RPOP queue:email
LLEN key 리스트 길이 LLEN queue:email
LRANGE key 0 -1 전체 조회(삭제 없음) LRANGE queue:email 0 -1
BRPOP key timeout 블로킹 꺼내기(대기) BRPOP queue:email 0

큐(FIFO) 패턴: LPUSH + RPOP

일반적인 큐(먼저 들어온 것이 먼저 나오는 구조)는 LPUSH로 왼쪽에 넣고, RPOP으로 오른쪽에서 꺼내는 방식으로 구현합니다.

redis-cli
# Lab 4 — 이메일 큐 실습
DEL queue:email           # 기존 키 초기화

# 프로듀서: 왼쪽에 작업 추가
LPUSH queue:email "send:kim@example.com"
LPUSH queue:email "send:lee@example.com"
LPUSH queue:email "send:park@example.com"

LLEN queue:email          # 3 (대기 중인 작업 수)
LRANGE queue:email 0 -1   # 전체 목록 조회

# 컨슈머: 오른쪽에서 작업 꺼내기 (FIFO)
RPOP queue:email          # "send:kim@example.com" (가장 먼저 들어온 것)
RPOP queue:email          # "send:lee@example.com"

LLEN queue:email          # 1 (아직 1개 남음)

BRPOP: 블로킹 팝 — 작업이 올 때까지 대기

일반적인 RPOP은 큐가 비어있으면 nil을 반환합니다. 그러면 컨슈머가 계속 반복 조회(폴링)해야 해서 CPU를 낭비합니다. BRPOP은 큐가 비어있으면 지정한 시간(초) 동안 블로킹하며 대기합니다. 새 작업이 들어오면 즉시 깨어납니다.

redis-cli
# 터미널 A: 컨슈머 (0 = 무한 대기)
BRPOP queue:email 0
# → 작업이 없으면 여기서 멈추고 기다림

# 터미널 B: 프로듀서 (터미널 A가 기다리는 동안)
LPUSH queue:email "send:new@example.com"
# → 터미널 A가 즉시 깨어나며 결과 반환:
# 1) "queue:email"
# 2) "send:new@example.com"
⚠️ BRPOP의 timeout 설정
BRPOP key 0은 무한 대기입니다. 실제 운영에서는 BRPOP key 30처럼 타임아웃을 설정해 연결이 끊기거나 Redis가 재시작될 때를 대비하세요.

🧺 핵심 개념 2: Set — 중복 없는 바구니

Redis의 Set은 순서 없이, 중복 없이 값을 저장하는 자료구조입니다. 동일한 값을 아무리 많이 추가해도 Set에는 단 하나만 저장됩니다.

기본 명령어

명령어 설명
SADD key member 멤버 추가 (이미 있으면 무시, 반환값: 추가된 수)
SISMEMBER key member 멤버 존재 여부 확인 (1=있음, 0=없음)
SMEMBERS key 모든 멤버 조회
SCARD key 멤버 수 조회
SREM key member 멤버 삭제
SUNION key1 key2 합집합
SINTER key1 key2 교집합
SDIFF key1 key2 차집합
redis-cli
# Lab 4 — 오늘의 방문자 중복 제거 실습
DEL uniq:visit:2026-03-17

# user1000이 오늘 3번 방문 (중복)
SADD uniq:visit:2026-03-17 user1000   # → 1 (추가됨)
SADD uniq:visit:2026-03-17 user1000   # → 0 (이미 있음, 무시)
SADD uniq:visit:2026-03-17 user1000   # → 0

# user2000도 방문
SADD uniq:visit:2026-03-17 user2000   # → 1

SCARD uniq:visit:2026-03-17           # → 2 (중복 제거 후 순방문자 수)
SMEMBERS uniq:visit:2026-03-17        # → {user1000, user2000}

# 특정 사용자가 오늘 방문했는지?
SISMEMBER uniq:visit:2026-03-17 user1000  # → 1 (방문함)
SISMEMBER uniq:visit:2026-03-17 user9999  # → 0 (방문 안 함)
💡 UV(순방문자) 집계 패턴
날짜별로 Set을 만들고 TTL을 설정하면 오늘의 UV를 빠르게 집계할 수 있습니다.
EXPIRE uniq:visit:2026-03-17 86400 → 24시간 후 자동 삭제

🔒 핵심 개념 3: 멱등성 — 중복 실행 방지

비동기 큐에서 작업자가 작업을 처리하다가 실패하면 재시도합니다. 그런데 재시도 중에 같은 작업이 두 번 실행될 수 있습니다. 예를 들어 이메일을 발송하는 도중 네트워크가 끊겼다면, 메일이 발송되었는지 아닌지 알 수 없어서 다시 시도합니다. 결과적으로 같은 사용자에게 환영 메일이 두 번 갈 수 있습니다.

이를 막기 위해 Set을 사용해 "이미 처리한 작업 ID"를 기록합니다. 작업을 실행하기 전에 Set에서 확인하고, 이미 있으면 건너뜁니다. 이것이 Redis Set으로 구현하는 멱등성 패턴입니다.

redis-cli
# 멱등성 패턴 시뮬레이션
DEL processed:email:jobs

# 작업 처리 전: Set에 job_id가 있는지 확인
SISMEMBER processed:email:jobs job_001  # → 0 (처음 처리 → 실행 OK)
SADD processed:email:jobs job_001       # 처리 완료 기록

# 나중에 같은 job_001이 다시 큐에 들어왔을 때
SISMEMBER processed:email:jobs job_001  # → 1 (이미 처리됨 → 건너뜀)

🐍 Lab 4: Python(redis-py)로 프로듀서/컨슈머 구현

실제 애플리케이션에서는 큐에 작업을 넣는 쪽(프로듀서)과 꺼내서 처리하는 쪽(컨슈머)이 분리됩니다. 아래 코드로 두 역할을 모두 구현해 봅니다.

Python
# pip install redis
import redis
import time
import random

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

QUEUE_KEY = "queue:email"
PROCESSED_KEY = "processed:email:jobs"

# ── 프로듀서: 이메일 발송 작업을 큐에 추가 ──
def enqueue_email_job(job_id: str, email: str):
    """발송 작업을 큐에 추가"""
    job_data = f"{job_id}|{email}"
    r.lpush(QUEUE_KEY, job_data)
    print(f"[Producer] 작업 추가: {job_data}")

# ── 컨슈머: 큐에서 작업을 꺼내 처리 ──
def process_email_job():
    """큐에서 작업 꺼내기 (5초 타임아웃)"""
    result = r.brpop(QUEUE_KEY, timeout=5)  # 5초 기다림
    if result is None:
        print("[Consumer] 큐가 비어 있음, 대기 종료")
        return

    _, job_data = result
    job_id, email = job_data.split("|")

    # 멱등성 체크: 이미 처리한 작업인가?
    if r.sismember(PROCESSED_KEY, job_id):
        print(f"[Consumer] {job_id} 이미 처리됨, 건너뜀")
        return

    # 실제 처리 (이메일 발송 시뮬레이션)
    print(f"[Consumer] {email} 발송 중...")
    time.sleep(0.1)  # 발송 지연 시뮬레이션

    # 처리 완료 기록 (TTL 24시간)
    r.sadd(PROCESSED_KEY, job_id)
    r.expire(PROCESSED_KEY, 86400)
    print(f"[Consumer] {email} 발송 완료!")

# ── 실행 ──
if __name__ == "__main__":
    # 큐 초기화
    r.delete(QUEUE_KEY)

    # 작업 3개 추가 (job_001은 중복 포함)
    enqueue_email_job("job_001", "kim@example.com")
    enqueue_email_job("job_002", "lee@example.com")
    enqueue_email_job("job_001", "kim@example.com")  # 중복!
    enqueue_email_job("job_003", "park@example.com")

    print(f"\n현재 큐 크기: {r.llen(QUEUE_KEY)}\n")

    # 모든 작업 처리
    for _ in range(5):
        process_email_job()
✅ 예상 출력
job_001은 처음 처리 후, 두 번째 시도에서 "이미 처리됨, 건너뜀"이 출력되어야 합니다. 멱등성이 제대로 동작하는 것을 확인하세요.

🟢 Lab 4: Node.js(node-redis)로 구현

Node.js
// npm install redis
import { createClient } from "redis";

const client = createClient();
client.on("error", (err) => console.error("Redis Client Error", err));
await client.connect();

const QUEUE_KEY = "queue:email:node";
const PROCESSED_KEY = "processed:email:jobs:node";

// ── 프로듀서 ──
async function enqueueEmailJob(jobId, email) {
  const jobData = `${jobId}|${email}`;
  await client.lPush(QUEUE_KEY, jobData); // 왼쪽에 추가
  console.log(`[Producer] 작업 추가: ${jobData}`);
}

// ── 컨슈머 ──
async function processEmailJob() {
  // BRPOP: 왼쪽에서 블로킹 꺼내기 (3초 타임아웃)
  const result = await client.brPop(QUEUE_KEY, 3);
  if (!result) {
    console.log("[Consumer] 큐 비어 있음, 종료");
    return;
  }

  const [jobId, email] = result.element.split("|");

  // 멱등성 체크
  const alreadyProcessed = await client.sIsMember(PROCESSED_KEY, jobId);
  if (alreadyProcessed) {
    console.log(`[Consumer] ${jobId} 이미 처리됨, 건너뜀`);
    return;
  }

  // 발송 처리
  console.log(`[Consumer] ${email} 발송 중...`);
  await new Promise((r) => setTimeout(r, 100)); // 지연 시뮬레이션

  // 처리 완료 기록
  await client.sAdd(PROCESSED_KEY, jobId);
  await client.expire(PROCESSED_KEY, 86400); // 24시간 TTL
  console.log(`[Consumer] ${email} 발송 완료!`);
}

// ── 실행 ──
await client.del(QUEUE_KEY);

await enqueueEmailJob("job_001", "kim@example.com");
await enqueueEmailJob("job_002", "lee@example.com");
await enqueueEmailJob("job_001", "kim@example.com"); // 중복!
await enqueueEmailJob("job_003", "park@example.com");

console.log(`\n현재 큐 크기: ${await client.lLen(QUEUE_KEY)}\n`);

for (let i = 0; i < 5; i++) {
  await processEmailJob();
}

await client.quit();

⚠️ 실전 팁: List 큐의 한계를 알고 쓰기

Redis List로 큐를 구현하는 것은 빠르고 간단하지만, 한계도 분명합니다. 다음 상황에서는 다른 도구를 고려해야 합니다.

🚨 List 큐의 한계 3가지

한계 구체적인 문제 대안
내구성 Redis가 퍼시스턴스 없이 재시작되면 큐의 모든 작업이 사라짐 AOF 활성화 (9편), Streams (7편)
재처리 BRPOP으로 꺼낸 순간 데이터가 삭제됨 → 처리 실패 시 복구 불가 Streams의 소비자 그룹 (XACK 패턴)
순서 보장 여러 컨슈머가 동시에 꺼내면 순서가 뒤섞일 수 있음 단일 컨슈머 + FIFO 유지, 또는 Streams
🔗 다음 편 예고
Streams는 이 모든 한계를 해결하는 Redis의 고급 자료구조입니다. 7편 'Pub/Sub vs Streams'에서 자세히 다룹니다.

📌 5편 핵심 요약

  1. List는 큐/스택: LPUSH+RPOP(FIFO) 또는 LPUSH+LPOP(LIFO), BRPOP으로 블로킹 대기 가능
  2. Set은 중복 없는 집합: 같은 값을 여러 번 넣어도 하나만 저장, UV 집계에 유용
  3. 멱등성 패턴: Set으로 처리된 job_id를 기록해 중복 실행 방지
  4. List 큐의 한계: 내구성·재처리·순서 보장이 부족 → 고급 요구사항에는 Streams 사용
  5. TTL 설정 습관: 멱등성 Set에도 TTL을 설정해 메모리 누수 방지

✅ 5편 체크리스트

  • LPUSH, RPOP으로 FIFO 큐를 직접 만들어 봤다
  • BRPOP과 일반 RPOP의 차이를 이해했다 (블로킹 vs 즉시 반환)
  • SADD로 중복 없는 집합을 만들고 SISMEMBER로 확인했다
  • Set으로 멱등성 패턴을 구현해 중복 작업을 막았다
  • List 큐의 3가지 한계(내구성/재처리/순서)를 이해했다
  • Python 또는 Node.js로 프로듀서/컨슈머를 직접 실행했다

🧠 퀴즈 3문항

Q1. BRPOP queue:email 0 에서 숫자 0의 의미는?
무한 대기입니다. 큐에 새 아이템이 들어올 때까지 블로킹 상태를 유지합니다. 양수로 설정하면 해당 초 만큼만 대기 후 nil을 반환합니다.
Q2. Set에 같은 값을 10번 SADD하면 SCARD는 얼마가 될까요?
🔢 1입니다. Set은 중복을 허용하지 않습니다. SADD의 반환값은 실제로 추가된 수인데, 처음 한 번만 1을 반환하고 나머지는 0을 반환합니다.
Q3. List 큐와 Redis Streams 중 "처리 실패 시 재처리"가 가능한 것은?
📦 Redis Streams입니다. Streams의 소비자 그룹(Consumer Group)은 XACK로 처리 확인을 받기 전까지 메시지를 보관합니다. 반면 List는 BRPOP 순간 삭제되어 복구가 어렵습니다.

📝 과제 3가지

  1. 스택(LIFO) 구현: LPUSH와 LPOP을 사용해 스택을 만들고, "마지막에 넣은 것이 먼저 나온다"를 확인하세요.
  2. Set 집합 연산: 두 날짜의 방문자 Set을 만들고 SINTER(교집합)으로 이틀 연속 방문한 사용자를 구하세요.
  3. TTL이 있는 멱등성 Set: 처리 완료 Set에 TTL 600초를 설정하고, 만료 후 같은 job_id를 다시 처리할 수 있는지 확인하세요.