백엔드 데이터 인프라 51편 — Redis List + 큐·캡 리스트 패턴

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 51편. Redis List — 양 끝이 빠른 큐·스택 자료구조. LPUSH·RPUSH·BLPOP·LMOVE·LTRIM 명령어와 producer-consumer 큐, 캡 리스트(latest N), at-least-once 작업 큐 패턴까지 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 51편 — Redis List + 큐·캡 리스트 패턴

이 글은 백엔드 데이터 인프라 시리즈 130편 중 51편이에요. 50편 에서 Redis Hash 로 객체 한 개를 통째로 담는 자료구조를 잡았다면, 이번 51편은 Redis List — 양 끝이 빠른 큐·스택 자료구조. 메시지 큐·최근 활동 피드·작업 대기열의 표준 도구예요.

Redis List가 어렵게 느껴지는 이유

이름이 List 라 가장 익숙해 보이지만, 처음 만지면 두 가지 지점에서 멈춰요.

첫째, Java ArrayList 와 모델이 달라요. Java List인덱스 접근(get(i)) 이 빠르고 중간 삽입 이 느린데, Redis List 는 반대 — 양 끝이 빠르고(O(1)) 중간 접근이 느려요(O(N)). 링크드 리스트 라 그래요. "Java ArrayList 처럼 쓰면 안 됩니다". 인덱스 자주 쓰면 Sorted Set 이나 String 배열 직렬화가 더 어울려요.

둘째, 명령어 이름이 헷갈립니다. L*Left(head), R*Right(tail). LPUSH왼쪽에서 밀어 넣기, RPUSH오른쪽에서 밀어 넣기. 그런데 큐인가 스택인가어느 쪽으로 넣고 어느 쪽으로 빼느냐 의 조합으로 결정돼요. 조합 4가지 — LPUSH + RPOP(FIFO[먼저 들어온 게 먼저 나감] 큐), LPUSH + LPOP(LIFO[나중에 들어온 게 먼저 나감] 스택), RPUSH + LPOP(FIFO 큐), RPUSH + RPOP(LIFO 스택). 처음에는 매번 "어느 조합이 큐였지?" 가 헷갈려요.

이 글에서는 List 의 핵심 명령어를 정리하고, 큐 vs 스택 의 조합을 외우는 룰을 잡고, 실무에서 자주 쓰는 producer-consumer(생산자-소비자) 큐 / 캡 리스트 / at-least-once(최소 한 번 보장) 작업 큐 세 가지 패턴을 풀어 가요.

List 기본 명령어 7종

LPUSH · RPUSH — 양 끝에 추가

> LPUSH mylist "a"        # mylist = [a]
(integer) 1
> LPUSH mylist "b"        # mylist = [b, a]
(integer) 2
> RPUSH mylist "c"        # mylist = [b, a, c]
(integer) 3
> LRANGE mylist 0 -1
1) "b"
2) "a"
3) "c"

복잡도 = O(1) 단일 값, O(N) 여러 값 동시 push. 여러 값 한 번에:

> RPUSH queue "job-1" "job-2" "job-3"
(integer) 3

LPOP · RPOP — 양 끝에서 제거 + 반환

> LPOP mylist           # 왼쪽 첫 원소 빼서 반환
"b"
> RPOP mylist           # 오른쪽 마지막 원소
"c"

여러 개 한 번에 빼기 = LPOP key count:

> RPUSH q "a" "b" "c" "d"
> LPOP q 3
1) "a"
2) "b"
3) "c"

LRANGE — 범위 조회 (POP 안 함)

> RPUSH q "a" "b" "c" "d"
> LRANGE q 0 -1         # 전체 (start=0, end=-1)
1) "a"
2) "b"
3) "c"
4) "d"
> LRANGE q 0 1          # 처음 두 개
1) "a"
2) "b"
> LRANGE q -2 -1        # 끝에서 두 개
1) "c"
2) "d"

Python slice 와 비슷하지만 end는 inclusive(끝값 포함). 0 -1전체 — 자주 쓰니 외워두세요. 복잡도 = O(S+N).

LLEN — 길이 (O(1))

> LLEN q
(integer) 4

큐 모니터링에 매번 쓰는 명령. 대기 중인 작업 수 같은 메트릭.

LINDEX — 인덱스 접근 (O(N), 주의)

> LINDEX q 0
"a"
> LINDEX q -1
"d"

여기서 시험 함정 — LINDEX 는 O(N). 링크드 리스트 라 i번째 원소를 찾으려면 i번 따라가야 해요. 인덱스 접근이 잦으면 List 가 잘못된 선택. 끝(0·-1) 만 O(1) 가깝게 빠르고, 중간은 느려요.

한 줄 정리 — List 명령 룰: L = Left(head), R** = Right(tail). 양 끝 PUSH/POP = O(1), 중간 LINDEX = O(N).

큐 vs 스택 — 조합 외우기

처음 매번 헷갈리는 자리. 외우기 쉬운 룰 두 가지.

FIFO 큐 (먼저 들어온 게 먼저 나감)

한 쪽에서 넣고, 반대편에서 뺀다LPUSH + RPOP 또는 RPUSH + LPOP.

# producer
> RPUSH q "job-1"
> RPUSH q "job-2"
> RPUSH q "job-3"
# consumer
> LPOP q
"job-1"          # 가장 먼저 들어온 게 먼저 나감

LIFO 스택 (나중에 들어온 게 먼저 나감)

같은 쪽에서 넣고 뺀다LPUSH + LPOP 또는 RPUSH + RPOP.

> LPUSH stack "a"
> LPUSH stack "b"
> LPOP stack
"b"              # 마지막에 들어온 게 먼저 나감

실무에서는 FIFO 큐가 90%. 가장 흔한 조합이 RPUSH(producer) + LPOP(consumer) 인데, "오른쪽으로 넣고 왼쪽에서 빼는" 모양이 컨베이어 벨트 와 같다고 외워도 OK.

패턴 1: Producer-Consumer 큐 — BLPOP blocking

가장 흔한 패턴. 작업이 큐에 들어오면 워커가 가져가서 처리.

단순 polling(주기적 확인) 방식 (비추)

while True:
    job = r.lpop("queue:jobs")
    if job:
        process(job)
    else:
        time.sleep(0.1)   # 폴링 — Redis·네트워크 부담

문제 — 대기 중 빈번한 폴링 이 Redis CPU·네트워크에 부담. 워커가 100개면 더 심각.

Blocking(요소 들어올 때까지 대기) 방식 (BLPOP·BRPOP)

while True:
    # 대기 시간 0 = 영원히 대기
    result = r.blpop("queue:jobs", timeout=0)
    if result:
        _key, job = result
        process(job)
> BLPOP queue:jobs 0
# 큐가 비어 있으면 새 요소가 들어올 때까지 대기
# 다른 클라이언트가 RPUSH 쏘면 즉시 깨어남

핵심 — 큐가 비어 있으면 즉시 nil 반환하는 LPOP 과 달리, BLPOP요소가 들어올 때까지 기다림. 들어오면 대기 중인 워커 중 가장 오래 기다린 한 명 에게 atomic(쪼개지지 않는 한 동작) 하게 전달.

timeout 단위 = 초 (Redis 6.0+ 부터 소수점 OK). 0 = 영원히 대기.

여러 큐 동시 대기:

> BLPOP queue:high queue:low 0
1) "queue:high"      # 어느 큐에서 왔는지
2) "job-99"          # 실제 값

queue:high 가 비어 있고 queue:low 에 들어오면 low 에서 받음. 우선순위 큐 자연스럽게 구현.

패턴 2: 캡 리스트 (Capped List) — 최근 N개만 유지

활동 피드·로그 tail·최근 본 상품 같은 자리. "최근 N개만 기억해" 패턴.

# 새 활동 추가
> LPUSH activity:user42 "viewed:article99"
# 100개로 자르기
> LTRIM activity:user42 0 99

핵심 명령 = LTRIM start endstart~end 범위 외 모두 삭제. 인덱스 inclusive.

조합 패턴:

def add_activity(uid, event):
    pipe = r.pipeline()
    pipe.lpush(f"activity:{uid}", event)
    pipe.ltrim(f"activity:{uid}", 0, 99)
    pipe.execute()

PIPELINE 으로 두 명령을 한 왕복 에 묶음 (57편 pipelining). 무한히 증가하지 않고 항상 최근 100개 유지.

자주 쓰는 자리는 사용자 활동 피드(최근 100개 활동), 최근 본 상품(사용자별 최근 20개), 로그 tail(최근 1,000줄), 검색 기록(최근 검색어 50개) 정도예요. 모양은 다 같고 N만 달라요.

한 줄 정리 — 캡 리스트 = LPUSH + LTRIM 0 (N-1) 조합. 무한 증가 방지의 표준 패턴.

패턴 3: At-Least-Once 작업 큐 — LMOVE

여기서 정말 중요한 자리. 작업 처리 중 워커가 죽으면? 일반 LPOP 큐는 작업 손실 위험이 있어요.

문제 — LPOP 의 함정

job = r.lpop("queue:jobs")     # 큐에서 빼냄
process(job)                    # ← 여기서 워커가 죽으면 작업 영구 손실

LPOP 직후 워커가 죽으면 작업이 사라져 — 큐에서도 빠지고 처리도 안 됨. At-most-once(한 번 또는 0번).

해법 — LMOVE 로 처리 중 보관

> LMOVE queue:jobs queue:processing LEFT RIGHT
# queue:jobs 의 왼쪽에서 빼서, queue:processing 의 오른쪽으로 옮김
# 두 동작이 atomic
# 워커 시작
job = r.lmove("queue:jobs", "queue:processing", "LEFT", "RIGHT")
try:
    process(job)
    r.lrem("queue:processing", 1, job)   # 완료 시 processing 에서 제거
except:
    # 실패: 그대로 두면 다른 워커가 다시 처리 가능
    pass

워커가 죽어도 작업이 queue:processing 에 남아 있어 다른 워커가 재처리 할 수 있어요. At-least-once (한 번 이상, 중복 가능 — 멱등성 필요).

Blocking 변형 — BLMOVE

> BLMOVE queue:jobs queue:processing LEFT RIGHT 0

LMOVE 의 blocking 변형. 큐가 비어 있으면 대기. Producer-Consumer + At-Least-Once 조합.

여기서 시험 함정 — LMOVERPOPLPUSH 의 후속. 옛 명령어 RPOPLPUSH오른쪽에서 빼서 왼쪽에 넣는 한 가지만 가능했는데, LMOVE양 끝 조합 4가지 다 지원해요. Redis 6.2+ 부터 LMOVE 사용 권장, RPOPLPUSH 는 deprecated(폐기 예정).

한계 — List 가 잘못된 선택일 때

여기까지 따라오셨다면 한 가지 의문이 들 거예요 — "List 가 만능 같은데 단점은 없나요?". 두 가지가 핵심.

(1) 인덱스 접근 O(N)

> LINDEX big-list 50000        # 5만 번째 원소 → 5만 번 link 따라감

"i번째 원소를 자주 접근" 하는 자리라면 List 가 잘못된 선택. Sorted Set (점수로 정렬·랭킹 접근) 또는 String 배열 직렬화 (JSON array) 가 더 어울려요.

(2) 중간 삽입·삭제도 O(N)

LINSERT (특정 값 앞·뒤에 삽입) 가 있지만 링크드 리스트 탐색 이라 O(N). 자주 쓰면 부담.

(3) 우선순위 큐는 List 만으로 한계

"긴급 작업이 일반 작업보다 먼저 처리되어야" 는 List 한 개로 안 풀려요. 옵션 두 가지:

  • 여러 List + BLPOP 다중 키BLPOP queue:high queue:medium queue:low 0 (위에서 본 패턴)
  • Sorted Set — 점수에 우선순위 박고 ZPOPMIN (52편에서 풀어요)

시험 직전 한 번 더 — Redis List 함정 압축 노트

  • List = 링크드 리스트, 양 끝 O(1)·중간 O(N)
  • Java ArrayList 와 모델 다름 — 인덱스 접근 잦으면 Sorted Set 또는 직렬화로
  • 한 List 최대 = 2^32 - 1 (~42억) 원소
  • 명령어 룰 — L = Left(head), R** = Right(tail)
  • FIFO 큐 = 한 쪽 PUSH + 반대편 POP (RPUSH + LPOP 가 표준)
  • LIFO 스택 = 같은 쪽 PUSH/POP (LPUSH + LPOP)
  • 실무 90% = FIFO 큐 (컨베이어 벨트 모양)
  • LPUSH·RPUSH = O(1) 단일, O(N) 여러 값
  • LPOP·RPOP = O(1) 단일, count 옵션 O(N)
  • LRANGE 0 -1 = 전체 조회 (end inclusive)
  • LLEN = O(1), 큐 모니터링에 자주 쓰임
  • LINDEX = O(N), 양 끝(0·-1)만 빠름
  • BLPOP·BRPOP = blocking 변형, 요소 들어올 때까지 대기
  • BLPOP 여러 키 동시 대기 = 우선순위 큐 자연스럽게 구현
  • timeout=0 = 영원히 대기
  • 캡 리스트 = LPUSH + LTRIM 0 (N-1) 조합
  • LTRIM = start~end 범위 외 모두 삭제, inclusive
  • 자주 쓰는 자리 = 활동 피드·최근 본 상품·로그 tail·검색 기록
  • At-least-once 큐 = LMOVE jobs processing 으로 처리 중 보관
  • LMOVE = RPOPLPUSH 의 후속 (Redis 6.2+), 양 끝 조합 4가지 다 가능
  • BLMOVE = blocking 변형
  • 작업 완료 = LREM processing 1 job 으로 제거
  • 처리 중 워커 사망 = queue:processing 에 남아 다른 워커가 재처리
  • 우선순위 큐 = 여러 List + BLPOP 또는 Sorted Set (52편)

공식 문서: Redis Lists 에서 List 명령어 전체 reference 를 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!