개발/REDIS

#4 List와 Queue 활용하기: 비동기 작업 큐 만들기

cedis 2026. 3. 17. 22:10

🔥 들어가며 — "회원가입 축하 이메일이 5분 뒤에 도착했어요"

회원가입 버튼을 눌렀는데 화면이 멈추고, 잠시 후 "가입 완료 + 환영 이메일 발송됨"이라는 메시지가 뜹니다. 이 경험, 답답하지 않으신가요? 이메일 발송에 2~3초가 걸린다면, 사용자는 그 시간 내내 버튼을 누른 채 기다려야 합니다.

더 나쁜 경우도 있습니다. 이메일 서버가 일시적으로 다운됐을 때 가입 자체가 실패하는 상황입니다. 이메일 발송은 가입의 핵심이 아닌데도 말이죠. 이런 문제를 해결하는 패턴이 비동기 작업 큐(Async Job Queue)입니다.

방식은 간단합니다. "이메일 발송"이라는 작업을 큐(Queue)에 넣어두고, 가입 API는 즉시 응답합니다. 별도로 돌고 있는 워커(Worker)가 큐에서 작업을 꺼내 이메일을 보내죠. 사용자는 0.1초 만에 가입 완료 화면을 보게 됩니다.

Redis의 List 타입은 이 큐 역할에 최적화되어 있습니다. LPUSH로 작업을 넣고, BRPOP으로 워커가 블로킹 방식으로 기다리다가 꺼냅니다. 이메일 발송 외에도 이미지 리사이징, 푸시 알림, 결제 후처리 등 수없이 많은 곳에서 이 패턴이 쓰입니다.

오늘은 Redis List의 핵심 명령어를 배우고, Producer(작업 생성자)와 Consumer(작업 처리자) 패턴을 직접 구현해봅니다.

💡 오늘의 핵심 한 줄: "Redis List는 LPUSH로 넣고 BRPOP으로 블로킹 대기하며 꺼내는 간단한 작업 큐다."

🧠 핵심 개념 1 — Redis List란? "줄 서기 자료구조"

Redis List는 순서가 있는 문자열의 연결 목록입니다. 마치 편의점 계산대 앞에 줄을 서는 것처럼, 먼저 들어온 데이터가 먼저 나갑니다. 이를 FIFO(First In, First Out)라고 부릅니다.

왼쪽(Left)과 오른쪽(Right) 양쪽에서 삽입/삭제가 가능하기 때문에, 쓰임새에 따라 다양하게 활용할 수 있습니다.

── Redis List 구조 ──
HEAD (왼쪽)  ←→  [ job3 | job2 | job1 ]  ←→  TAIL (오른쪽)
LPUSH → 왼쪽에 삽입  |  RPUSH → 오른쪽에 삽입
LPOP → 왼쪽에서 꺼냄  |  RPOP → 오른쪽에서 꺼냄
명령어 설명 큐 활용 패턴
LPUSH key v1 v2 왼쪽(앞)에 삽입 작업 추가 (Producer)
RPUSH key v1 v2 오른쪽(뒤)에 삽입 작업 추가 (순서 유지)
LPOP key 왼쪽에서 꺼냄 (즉시) 빠른 꺼내기
RPOP key 오른쪽에서 꺼냄 (즉시) FIFO 꺼내기
BRPOP key 초 오른쪽에서 블로킹 대기 후 꺼냄 워커 대기 (핵심!)
BLPOP key 초 왼쪽에서 블로킹 대기 후 꺼냄 워커 대기
LRANGE key 0 -1 범위 조회 (전체: 0 -1) 큐 내용 확인
LLEN key 리스트 길이 대기 중인 작업 수
LINDEX key N N번째 요소 조회 특정 위치 확인
LTRIM key s e s~e 범위만 남기고 삭제 최근 N개만 유지

🧠 핵심 개념 2 — BRPOP: 블로킹 대기의 마법

일반 RPOP은 리스트가 비어있으면 즉시 nil을 반환합니다. 워커가 이를 쓴다면 "큐가 비어있나? 비어있나? 비어있나?"를 계속 반복 조회해야 합니다. 이를 폴링(Polling)이라 하며, CPU를 낭비하고 Redis에 불필요한 부하를 줍니다.

BRPOP은 다릅니다. 리스트가 비어있으면 새 데이터가 들어올 때까지 연결을 유지하며 기다립니다. 데이터가 들어오는 순간 즉시 반환합니다. CPU를 낭비하지 않으면서도 실시간으로 반응합니다.

  RPOP (일반) BRPOP (블로킹)
큐가 빈 경우 즉시 nil 반환 데이터 올 때까지 대기
워커 구현 while True 폴링 필요 한 줄로 대기 가능
CPU 사용 계속 조회 → 낭비 이벤트 기반 → 효율적
타임아웃 없음 초 단위 지정 (0 = 무한 대기)
ℹ️ BRPOP의 타임아웃
BRPOP queue:email 30 → 30초 동안 기다리다 없으면 nil 반환
BRPOP queue:email 0 → 무한 대기 (데이터 올 때까지)
실무에서는 30~60초 타임아웃을 주고 루프를 돌리는 것이 일반적입니다.

🧠 핵심 개념 3 — Producer / Consumer 패턴

작업 큐의 핵심 구조는 두 역할로 나뉩니다. Producer(생산자)는 처리할 작업을 큐에 넣고, Consumer(소비자, 워커)는 큐에서 작업을 꺼내 처리합니다. 이 둘은 완전히 독립적으로 동작합니다.

── Producer / Consumer 패턴 ──
API 서버
(Producer)

LPUSH로 작업 추가
Redis List
queue:email

[ job3 | job2 | job1 ]
워커 프로세스
(Consumer)

BRPOP으로 꺼내서 처리
워커는 여러 개 동시 실행 가능 → 수평 확장 용이

⚙️ Lab 4 — List 큐 실습

🎯 목표: LPUSH로 작업을 넣고, BRPOP으로 꺼내는 흐름을 직접 확인한다.

🖥️ redis-cli 실습

1
Redis 접속
TERMINAL
docker exec -it redis redis-cli
2
작업 큐에 데이터 넣기 (Producer)
REDIS-CLI
# 기존 큐 초기화
DEL queue:email

# 이메일 작업 3개를 큐에 추가 (LPUSH: 왼쪽에 삽입)
LPUSH queue:email "job:send_to:[email protected]"
# (integer) 1

LPUSH queue:email "job:send_to:[email protected]"
# (integer) 2

LPUSH queue:email "job:send_to:[email protected]"
# (integer) 3

# 큐 전체 내용 확인 (0 -1 = 처음부터 끝까지)
LRANGE queue:email 0 -1
# 1) "job:send_to:[email protected]"   ← 가장 최근 (LPUSH라서 앞에 쌓임)
# 2) "job:send_to:[email protected]"
# 3) "job:send_to:[email protected]"   ← 가장 먼저 들어온 것

# 대기 중인 작업 수
LLEN queue:email
# (integer) 3
3
작업 꺼내기 (Consumer) — RPOP으로 FIFO
REDIS-CLI
# RPOP: 오른쪽(가장 먼저 들어온 것)에서 꺼내기 → FIFO 순서
RPOP queue:email
# "job:send_to:[email protected]"   ← 가장 먼저 들어온 alice

RPOP queue:email
# "job:send_to:[email protected]"

RPOP queue:email
# "job:send_to:[email protected]"

# 비어있을 때 RPOP
RPOP queue:email
# (nil)
4
BRPOP — 블로킹 대기 체험
REDIS-CLI
# 터미널 A에서: 빈 큐에 BRPOP (최대 30초 대기)
BRPOP queue:email 30
# ... (대기 중)

# 터미널 B에서 (새 탭 열기): 작업 추가
# docker exec -it redis redis-cli
LPUSH queue:email "job:긴급작업"

# 터미널 A가 즉시 반응:
# 1) "queue:email"          ← 큐 이름
# 2) "job:긴급작업"          ← 꺼낸 값
# (소요시간: 약 0.001초)
5
LTRIM — 최근 N개만 유지하기 (로그 패턴)
REDIS-CLI
# 최근 활동 로그: 최대 5개만 유지
RPUSH log:user:1001 "로그인 2026-03-17 09:00"
RPUSH log:user:1001 "상품조회 2026-03-17 09:01"
RPUSH log:user:1001 "장바구니추가 2026-03-17 09:02"
RPUSH log:user:1001 "결제시도 2026-03-17 09:03"
RPUSH log:user:1001 "결제완료 2026-03-17 09:04"
RPUSH log:user:1001 "로그아웃 2026-03-17 09:05"

# 최근 5개만 남기고 오래된 것 삭제 (0 = 처음, 4 = 5번째)
LTRIM log:user:1001 0 4

LRANGE log:user:1001 0 -1
# 최근 5개만 남아있음

🐍 Python — Producer / Consumer 구현 (redis-py)

1
라이브러리 설치
TERMINAL
pip install redis
2
Producer (작업 생성자) — producer.py
PYTHON
import redis
import json
import time

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

QUEUE_NAME = "queue:email"

def enqueue_email_job(to: str, subject: str, body: str) -> int:
    """이메일 발송 작업을 큐에 추가하고 현재 큐 길이를 반환"""
    job = json.dumps({
        "to": to,
        "subject": subject,
        "body": body,
        "created_at": time.time()
    })
    # LPUSH: 왼쪽에 삽입 (BRPOP이 오른쪽에서 꺼내면 FIFO)
    queue_len = r.lpush(QUEUE_NAME, job)
    print(f"[Producer] 작업 추가: to={to}, 현재 큐 길이={queue_len}")
    return queue_len

# ── 테스트: 이메일 작업 5개 추가 ──
if __name__ == "__main__":
    users = [
        ("[email protected]", "환영합니다!", "가입을 축하합니다."),
        ("[email protected]",   "주문 확인",    "주문이 접수됐습니다."),
        ("[email protected]", "배송 시작",    "상품이 출발했습니다."),
        ("[email protected]",  "결제 완료",    "결제가 완료됐습니다."),
        ("[email protected]",   "리뷰 요청",    "리뷰를 남겨주세요!"),
    ]
    for to, subject, body in users:
        enqueue_email_job(to, subject, body)
        time.sleep(0.1)

    print(f"\n총 대기 중인 작업: {r.llen(QUEUE_NAME)}개")
3
Consumer (워커) — worker.py
PYTHON
import redis
import json
import time

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

QUEUE_NAME = "queue:email"

def send_email(job: dict) -> bool:
    """실제 이메일 발송 로직 (여기서는 흉내만)"""
    print(f"  [이메일 발송] to={job['to']}, subject={job['subject']}")
    time.sleep(0.2)  # 발송 시간 흉내
    return True  # 성공

def run_worker():
    """무한 루프로 큐에서 작업을 꺼내 처리"""
    print(f"[Worker] 시작 — '{QUEUE_NAME}' 큐 대기 중...")

    while True:
        # BRPOP: 최대 30초 대기 후 작업 꺼내기
        # 반환값: (큐이름, 값) 튜플 or None (타임아웃)
        result = r.brpop(QUEUE_NAME, timeout=30)

        if result is None:
            # 30초 동안 작업이 없었음 → 계속 루프
            print("[Worker] 대기 중... (큐 비어있음)")
            continue

        _, job_str = result  # 큐 이름과 값을 언패킹
        job = json.loads(job_str)

        print(f"[Worker] 작업 수신: to={job['to']}")

        try:
            success = send_email(job)
            if success:
                print(f"[Worker] ✅ 완료: {job['to']}")
        except Exception as e:
            # 실패 시 처리 (실무: Dead Letter Queue로 이동)
            print(f"[Worker] ❌ 실패: {e}")

# ── 실행 ──
if __name__ == "__main__":
    run_worker()
4
두 터미널에서 각각 실행
TERMINAL A (워커 먼저 실행)
python worker.py
# [Worker] 시작 — 'queue:email' 큐 대기 중...
# [Worker] 대기 중... (큐 비어있음)
TERMINAL B (작업 추가)
python producer.py
# [Producer] 작업 추가: [email protected], 현재 큐 길이=1
# [Producer] 작업 추가: [email protected], 현재 큐 길이=2
# ...

# Terminal A에서 즉시 반응:
# [Worker] 작업 수신: [email protected]
#   [이메일 발송] [email protected], subject=환영합니다!
# [Worker] ✅ 완료: [email protected]
# ...

🟢 Node.js — Producer / Consumer 구현 (node-redis)

NODE.JS — producer.mjs
import { createClient } from 'redis';

const client = createClient({ url: 'redis://localhost:6379' });
client.on('error', (err) => console.error('Redis 오류:', err));
await client.connect();

const QUEUE = 'queue:email';

async function enqueueEmailJob(to, subject, body) {
  const job = JSON.stringify({
    to, subject, body,
    createdAt: Date.now()
  });
  const queueLen = await client.lPush(QUEUE, job);
  console.log(`[Producer] 작업 추가: to=${to}, 큐 길이=${queueLen}`);
  return queueLen;
}

// 이메일 작업 5개 추가
const jobs = [
  ['[email protected]', '환영합니다!', '가입을 축하합니다.'],
  ['[email protected]',   '주문 확인',   '주문이 접수됐습니다.'],
  ['[email protected]', '배송 시작',   '상품이 출발했습니다.'],
];

for (const [to, subject, body] of jobs) {
  await enqueueEmailJob(to, subject, body);
}

console.log(`\n총 대기 중인 작업: ${await client.lLen(QUEUE)}개`);
await client.disconnect();
NODE.JS — worker.mjs
import { createClient } from 'redis';

const client = createClient({ url: 'redis://localhost:6379' });
client.on('error', (err) => console.error('Redis 오류:', err));
await client.connect();

const QUEUE = 'queue:email';

async function sendEmail(job) {
  console.log(`  [이메일 발송] to=${job.to}, subject=${job.subject}`);
  await new Promise(r => setTimeout(r, 200)); // 발송 흉내
  return true;
}

async function runWorker() {
  console.log(`[Worker] 시작 — '${QUEUE}' 큐 대기 중...`);

  while (true) {
    // BRPOP: 최대 30초 대기
    // 반환값: { key: '큐이름', element: '값' } or null
    const result = await client.brPop(QUEUE, 30);

    if (!result) {
      console.log('[Worker] 대기 중... (큐 비어있음)');
      continue;
    }

    const job = JSON.parse(result.element);
    console.log(`[Worker] 작업 수신: to=${job.to}`);

    try {
      await sendEmail(job);
      console.log(`[Worker] ✅ 완료: ${job.to}`);
    } catch (err) {
      console.error(`[Worker] ❌ 실패: ${err.message}`);
    }
  }
}

await runWorker();
TERMINAL
# 각각 다른 터미널에서 실행
node worker.mjs
node producer.mjs

💼 실무 팁 — List 큐의 한계와 주의사항

⚠️ 한계 1: 작업 처리 중 워커가 죽으면 데이터가 사라진다
BRPOP으로 꺼낸 순간 데이터는 리스트에서 삭제됩니다. 워커가 처리 중에 갑자기 죽으면 해당 작업은 그냥 사라집니다. 이를 At-most-once(최대 1번) 보장이라고 합니다. 중요한 작업(결제, 주문)은 반드시 Streams(7편)나 전용 큐 솔루션(Celery, BullMQ 등)을 사용하세요.
⚠️ 한계 2: 중복 처리 방지가 없다
같은 작업을 실수로 두 번 넣으면 두 번 처리됩니다. 멱등성(동일 작업을 여러 번 처리해도 결과가 같음)이 보장되지 않는 작업은 별도 중복 체크 로직이 필요합니다.
⚠️ 한계 3: 재처리(Retry) 로직이 없다
작업 처리에 실패해도 자동으로 다시 시도하지 않습니다. 실패한 작업을 별도 Dead Letter Queue(DLQ)에 넣거나, 재시도 횟수를 job 데이터에 포함시키는 패턴을 직접 구현해야 합니다.
⚠️ 한계 4: Redis가 재시작되면 큐 내용이 사라질 수 있다
기본 설정에서는 Redis를 재시작하면 메모리 데이터가 모두 사라집니다. 큐의 내구성이 필요하다면 AOF 퍼시스턴스(9편)를 설정하거나, 처음부터 RabbitMQ·Kafka 같은 전용 메시지 브로커를 고려하세요.

✅ Redis List 큐를 써도 되는 경우 vs 더 나은 선택이 있는 경우

상황 추천
이메일 발송, 푸시 알림 (실패해도 큰 문제 없음) ✅ Redis List 큐 적합
이미지 리사이징, 썸네일 생성 ✅ Redis List 큐 적합
결제, 주문 처리 (절대 유실 불가) ❌ Kafka / RabbitMQ 권장
작업 재처리·모니터링 필요 ❌ BullMQ / Celery 권장
메시지 소비 이력 보관 필요 ❌ Redis Streams(7편) 권장

✅ LTRIM으로 최근 N개 로그 유지하기

활동 로그나 알림 내역처럼 "최근 N개만 보여주는" 기능에 List + LTRIM 조합이 매우 유용합니다.

PYTHON
def add_activity_log(user_id: int, activity: str, max_logs: int = 20):
    """최근 활동 로그 추가, 최대 max_logs개 유지"""
    key = f"log:activity:{user_id}"
    pipe = r.pipeline()
    pipe.lpush(key, activity)        # 앞에 새 로그 추가
    pipe.ltrim(key, 0, max_logs - 1) # 최근 N개만 유지
    pipe.execute()                   # 두 명령 한 번에 실행

📋 오늘의 핵심 요약 5줄

  1. Redis List는 순서 있는 문자열 목록으로, LPUSH/RPUSH로 넣고 LPOP/RPOP으로 꺼낸다.
  2. BRPOP은 큐가 빌 때 블로킹 대기하므로 폴링 없이 실시간으로 작업을 처리할 수 있다.
  3. Producer는 LPUSH, Consumer는 BRPOP으로 비동기 작업 큐 패턴을 구현한다.
  4. LTRIM으로 리스트를 일정 크기로 잘라 최근 N개 로그 기능을 쉽게 구현할 수 있다.
  5. List 큐는 간단하지만 작업 유실·재처리 보장이 없으므로 중요 작업은 Streams(7편)나 전용 솔루션을 고려해야 한다.

☑️ 오늘의 체크리스트

  • LPUSH / RPUSH의 차이를 이해하고 각각 실행해봤다
  • LRANGE queue 0 -1로 큐 전체 내용을 확인했다
  • BRPOP으로 빈 큐에서 대기하다가 데이터가 들어오는 순간 반응하는 것을 확인했다
  • Python 또는 Node.js로 Producer와 Consumer를 각각 구현했다
  • 두 터미널에서 동시에 실행해 실시간으로 작업이 처리되는 것을 확인했다
  • List 큐의 한계(데이터 유실 위험)를 이해했다

🧩 확인 퀴즈

  1. RPOPBRPOP의 차이는 무엇인가요? 워커를 구현할 때 왜 BRPOP이 더 좋을까요?
  2. LPUSH로 작업을 넣고 BRPOP으로 꺼내면 어떤 순서로 처리될까요? (FIFO인가요, LIFO인가요?)
  3. Redis List 큐의 가장 큰 한계는 무엇이며, 이를 해결하려면 어떤 방법을 써야 하나요?
▶ 정답 확인 (클릭)
  1. RPOP은 큐가 비면 즉시 nil을 반환합니다. BRPOP은 데이터가 들어올 때까지 대기합니다. 워커에서 RPOP을 쓰면 "큐가 비었나?"를 계속 반복 조회(폴링)해야 하지만, BRPOP은 이벤트 방식으로 데이터가 오는 순간 즉시 반응하여 CPU 낭비가 없습니다.
  2. FIFO(First In, First Out)입니다. LPUSH는 왼쪽에 삽입하고, BRPOP(= BRPOP은 오른쪽에서 꺼냄)은 가장 먼저 들어온 데이터를 꺼냅니다.
  3. 가장 큰 한계는 작업 유실입니다. BRPOP으로 꺼낸 순간 데이터가 삭제되기 때문에 워커가 처리 중 죽으면 작업이 사라집니다. 해결책으로는 Redis Streams(7편), BullMQ, Celery 같은 재처리·ACK 기능이 있는 솔루션을 사용합니다.
📝 도전 과제 (다음 편 전까지 해보세요!)

1. 워커 코드에서 실패 시 queue:email:failed라는 별도 큐에 실패한 작업을 LPUSH하는 Dead Letter Queue 로직을 추가해보세요.

2. LTRIM을 활용해 "최근 10개 검색어 기록"을 저장하는 함수를 만들어보세요. 중복 검색어는 어떻게 처리할지도 고민해보세요.

3. 워커를 2개 동시에 실행(터미널 2개)하고 Producer로 10개 작업을 추가하면, 두 워커가 어떻게 작업을 나눠 처리하는지 관찰해보세요.
📚 Redis 입문 시리즈

✅ #1 Redis 시작하기: 설치·실행·첫 명령(PING)
✅ #2 키·TTL·만료: 캐시의 핵심 규칙
✅ #3 Strings: 카운터·간단 캐시 만들기
#4 List와 Queue 활용하기: 비동기 작업 큐 만들기 ← 현재 글
⬜ #5 Hashes: 세션·프로필 저장소 만들기
⬜ #6 Sorted Sets: 랭킹/리더보드
⬜ #7 Pub/Sub vs Streams: 실시간 메시징
⬜ #8 동시성 제어: 트랜잭션(MULTI/EXEC)·WATCH
⬜ #9 퍼시스턴스: RDB·AOF·복구 체크
⬜ #10 복제·고가용성: Replication·Sentinel
⬜ #11 클러스터: 샤딩·해시 슬롯·제약
⬜ #12 보안·성능·관측: ACL·eviction·latency
# Redis # Redis List # Redis 큐 # BRPOP # 작업 큐 # Producer Consumer # 비동기 처리 # LTRIM # redis-py # 백엔드 입문

📌 Redis 입문 시리즈 #4  |  난이도: ⭐⭐⭐☆☆ 초중급  |  기준 버전: Redis Open Source 8.6.1