🤔 비동기 작업, 어떻게 처리하나요?
사용자가 회원가입을 하면 환영 이메일을 보내야 합니다. 그런데 이메일을 보내는 작업은 느립니다. 외부 메일 서버에 요청을 보내고, 응답을 기다리고, 실패하면 재시도해야 하죠. 만약 이 작업을 사용자가 회원가입 버튼을 누르는 순간에 동기적으로 처리한다면 어떻게 될까요?
사용자는 가입 완료 화면이 뜨기까지 수 초를 기다려야 합니다. 메일 서버가 잠깐 다운된다면 회원가입 자체가 실패할 수도 있습니다. 이런 상황을 막기 위해 "작업을 등록만 하고, 처리는 나중에"라는 비동기 큐 패턴을 사용합니다.
Redis의 List는 바로 이 큐 역할을 아주 쉽게 구현할 수 있게 해줍니다. 왼쪽에 넣고 오른쪽에서 꺼내거나(FIFO), 오른쪽에 넣고 왼쪽에서 꺼내는(LIFO) 방식 모두 지원합니다.
한편, 같은 사용자가 오늘 페이지를 여러 번 방문해도 "오늘 방문자 수"는 1로 계산해야 하는 경우가 있습니다. 중복을 제거해야 하는 상황이죠. Redis의 Set은 동일한 값을 두 번 넣어도 하나만 저장되는 중복 없는 집합입니다.
이번 편에서는 List로 작업 큐를 만들고, Set으로 중복을 방지하는 패턴인 멱등성(idempotency)을 구현해 봅니다. Lab 4의 코드를 직접 실행하며 확인해 보세요.
같은 작업을 여러 번 실행해도 결과가 달라지지 않는 성질입니다. 예를 들어 "이 사용자에게 환영 이메일 발송"이라는 작업이 네트워크 오류로 두 번 실행되어도 메일은 한 번만 발송되어야 합니다.
📋 핵심 개념 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으로 오른쪽에서 꺼내는 방식으로 구현합니다.
# 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은 큐가 비어있으면 지정한 시간(초) 동안 블로킹하며 대기합니다. 새 작업이 들어오면 즉시 깨어납니다.
# 터미널 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 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 |
차집합 |
# 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 (방문 안 함)
날짜별로 Set을 만들고 TTL을 설정하면 오늘의 UV를 빠르게 집계할 수 있습니다.
EXPIRE uniq:visit:2026-03-17 86400 → 24시간 후 자동 삭제🔒 핵심 개념 3: 멱등성 — 중복 실행 방지
비동기 큐에서 작업자가 작업을 처리하다가 실패하면 재시도합니다. 그런데 재시도 중에 같은 작업이 두 번 실행될 수 있습니다. 예를 들어 이메일을 발송하는 도중 네트워크가 끊겼다면, 메일이 발송되었는지 아닌지 알 수 없어서 다시 시도합니다. 결과적으로 같은 사용자에게 환영 메일이 두 번 갈 수 있습니다.
이를 막기 위해 Set을 사용해 "이미 처리한 작업 ID"를 기록합니다. 작업을 실행하기 전에 Set에서 확인하고, 이미 있으면 건너뜁니다. 이것이 Redis Set으로 구현하는 멱등성 패턴입니다.
# 멱등성 패턴 시뮬레이션
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)로 프로듀서/컨슈머 구현
실제 애플리케이션에서는 큐에 작업을 넣는 쪽(프로듀서)과 꺼내서 처리하는 쪽(컨슈머)이 분리됩니다. 아래 코드로 두 역할을 모두 구현해 봅니다.
# 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)로 구현
// 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편 핵심 요약
- List는 큐/스택:
LPUSH+RPOP(FIFO) 또는LPUSH+LPOP(LIFO),BRPOP으로 블로킹 대기 가능 - Set은 중복 없는 집합: 같은 값을 여러 번 넣어도 하나만 저장, UV 집계에 유용
- 멱등성 패턴: Set으로 처리된 job_id를 기록해 중복 실행 방지
- List 큐의 한계: 내구성·재처리·순서 보장이 부족 → 고급 요구사항에는 Streams 사용
- 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의 의미는?
Q2. Set에 같은 값을 10번 SADD하면 SCARD는 얼마가 될까요?
Q3. List 큐와 Redis Streams 중 "처리 실패 시 재처리"가 가능한 것은?
📝 과제 3가지
- 스택(LIFO) 구현: LPUSH와 LPOP을 사용해 스택을 만들고, "마지막에 넣은 것이 먼저 나온다"를 확인하세요.
- Set 집합 연산: 두 날짜의 방문자 Set을 만들고 SINTER(교집합)으로 이틀 연속 방문한 사용자를 구하세요.
- TTL이 있는 멱등성 Set: 처리 완료 Set에 TTL 600초를 설정하고, 만료 후 같은 job_id를 다시 처리할 수 있는지 확인하세요.
'개발 > REDIS' 카테고리의 다른 글
| #7 📡 Pub/Sub vs Streams: 실시간 메시징 (0) | 2026.03.19 |
|---|---|
| #6🏆 Sorted Sets: 랭킹/리더보드 (1) | 2026.03.19 |
| #4 List와 Queue 활용하기: 비동기 작업 큐 만들기 (0) | 2026.03.17 |
| #3 Strings: 카운터·간단 캐시 만들기 (0) | 2026.03.17 |
| #2 키·TTL·만료: 캐시의 핵심 규칙 (0) | 2026.03.17 |