백엔드 데이터 인프라 51편. Redis List — 양 끝이 빠른 큐·스택 자료구조. LPUSH·RPUSH·BLPOP·LMOVE·LTRIM 명령어와 producer-consumer 큐, 캡 리스트(latest N), at-least-once 작업 큐 패턴까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 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 end — start~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 조합.
여기서 시험 함정 — LMOVE 는 RPOPLPUSH 의 후속. 옛 명령어 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 를 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 46편 — 백업과 복구 pg_dump·PITR
- 47편 — Redis란 + PostgreSQL과의 역할 분담
- 48편 — Redis 데이터 타입 13종 한 번에 정리
- 49편 — Redis String 깊이 + 분산 락 패턴
- 50편 — Redis Hash + 객체 캐싱 패턴
다음 글: