리스트

리스트 (lists-2)

Redis 리스트는 데이터를 순서가 있는 문자열 목록으로 저장하는 자료 구조예요. 큐나 스택처럼 활용할 수 있고, 백그라운드 워커 큐를 구현할 때도 자주 쓰이죠. 이 페이지는 Redis 공식 문서의 Lists 페이지를 한국어로 풀어드린 내용이에요.

출처: Redis 공식 문서 — Lists

Redis 리스트(list)는 **문자열 값들의 연결 리스트(linked list)**예요. 이름만 보면 배열처럼 생겼지만, 실제로는 앞뒤로 노드가 이어진 연결 리스트 구조라는 점이 핵심이에요. 주로 두 가지 용도로 많이 쓰여요.

  • 스택과 큐를 구현한다.
  • 백그라운드 워커 시스템의 큐 관리를 만든다.

예를 들어 널리 쓰이는 루비 배경 작업 라이브러리인 resquesidekiq는 내부적으로 Redis 리스트로 배경 작업 큐를 구현하고 있어요. 트위터(Twitter)도 사용자가 올린 최신 트윗을 Redis 리스트에 담아둔다고 알려져 있어요.

예제

리스트를 큐처럼 쓰기 (FIFO, 먼저 들어온 것 먼저 처리)

값을 왼쪽에 넣고 오른쪽에서 꺼내면 선입선출(FIFO) 큐가 돼요. **LPUSH**로 항목을 추가하고, **RPOP**으로 제거하면 들어온 순서대로 꺼내게 돼요.

res1 = r.lpush("bikes:repairs", "bike:1")
print(res1)  # >>> 1

res2 = r.lpush("bikes:repairs", "bike:2")
print(res2)  # >>> 2

res3 = r.rpop("bikes:repairs")
print(res3)  # >>> bike:1

res4 = r.rpop("bikes:repairs")
print(res4)  # >>> bike:2

bike:1을 먼저 넣었으니 RPOP으로 꺼낼 때도 bike:1이 먼저 나와요.

리스트를 스택처럼 쓰기 (LIFO, 나중에 들어온 것 먼저 처리)

값을 왼쪽에 넣고 왼쪽에서 꺼내면 후입선출(LIFO) 스택이 돼요. **LPUSH**로 추가하고 **LPOP**으로 제거하면 가장 최근에 넣은 것부터 처리돼요.

res5 = r.lpush("bikes:repairs", "bike:1")
print(res5)  # >>> 1

res6 = r.lpush("bikes:repairs", "bike:2")
print(res6)  # >>> 2

res7 = r.lpop("bikes:repairs")
print(res7)  # >>> bike:2

res8 = r.lpop("bikes:repairs")
print(res8)  # >>> bike:1

bike:2를 나중에 넣었으니 LPOP으로 꺼낼 때 bike:2가 먼저 나와요.

리스트 길이 확인

LLEN 명령으로 리스트에 들어 있는 요소 개수를 확인할 수 있어요.

res9 = r.llen("bikes:repairs")
print(res9)  # >>> 0

한 리스트에서 다른 리스트로 원자적으로 옮기기

LMOVE 명령은 한 리스트에서 요소를 꺼내 다른 리스트로 넣는 작업을 단일 원자(atomic) 연산으로 수행해요. 두 리스트 사이에서 항목을 옮겨야 할 때 경쟁 조건(race condition) 없이 안전하게 처리할 수 있어요.

res10 = r.lpush("bikes:repairs", "bike:1")
print(res10)  # >>> 1

res11 = r.lpush("bikes:repairs", "bike:2")
print(res11)  # >>> 2

res12 = r.lmove("bikes:repairs", "bikes:finished", "LEFT", "LEFT")
print(res12)  # >>> 'bike:2'

res13 = r.lrange("bikes:repairs", 0, -1)
print(res13)  # >>> ['bike:1']

res14 = r.lrange("bikes:finished", 0, -1)
print(res14)  # >>> ['bike:2']

LMOVE 뒤에 첫 번째 인자는 원본 키, 두 번째는 대상 키, 그다음 각각 어느 쪽(LEFT/RIGHT)에서 꺼내고 어느 쪽으로 넣을지를 지정해요. 위 예시에선 bikes:repairs의 왼쪽에서 꺼낸 bike:2bikes:finished의 왼쪽으로 옮겼어요.

리스트 길이 제한하기

LTRIM 명령으로 리스트를 특정 범위만 남기고 잘라낼 수 있어요. 고정 크기 리스트를 유지할 때 써요.

r.delete("bikes:repairs")

res48 = r.lpush("bikes:repairs", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5")
print(res48)  # >>> 5

res49 = r.ltrim("bikes:repairs", 0, 2)
print(res49)  # >>> True

res50 = r.lrange("bikes:repairs", 0, -1)
print(res50)  # >>> ['bike:5', 'bike:4', 'bike:3']

LTRIM 0 2는 인덱스 0부터 2까지(즉 원소 3개)만 남기고 나머지를 모두 지워요.

리스트의 주요 사용 사례

리스트가 유용한 대표적인 두 가지 사례를 꼽자면 다음과 같아요.

  • 소셜 네트워크에서 사용자가 올린 최신 업데이트를 기억하는 것.
  • 프로세스 간 통신 — 생산자-소비자(consumer-producer) 패턴에서 생산자가 리스트에 항목을 넣고, 소비자(보통 워커라고 불러요)가 그 항목을 꺼내 작업을 실행하는 것. Redis는 이 사용 사례를 더 안정적이고 효율적으로 만들기 위한 특수 리스트 명령을 제공해요.

예를 들어 인기 있는 루비 라이브러리 resquesidekiq는 배경 작업을 구현할 때 내부적으로 Redis 리스트를 사용해요. 또한 트위터(Twitter)는 사용자가 올린 최신 트윗을 Redis 리스트에 담아둔다고 알려져 있어요.

사용 사례를 단계별로 하나 그려볼게요. 사진 공유 소셜 네트워크의 홈페이지에 최근 게시된 사진이 보이고, 그 접근을 빠르게 하고 싶은 상황을 떠올려 보세요.

  • 사용자가 새 사진을 올릴 때마다 사진의 ID를 **LPUSH**로 리스트에 추가해요.
  • 사용자가 홈페이지를 방문할 때 **LRANGE 0 9**를 사용해 최신 10개 항목을 가져와요.

캡(capped) 리스트

많은 경우에 리스트는 최신 항목만 저장하는 용도로 쓰고 싶어져요. 소셜 네트워크 업데이트든, 로그든, 그 밖의 무엇이든 말이죠.

Redis는 리스트를 **캡된 컬렉션(capped collection)**처럼 쓸 수 있게 해 줘요. LTRIM 명령으로 최신 N개 항목만 기억하고 가장 오래된 항목은 모두 버리는 방식이에요.

LTRIM 명령은 LRANGE와 비슷하지만, 지정된 범위를 보여주는 대신 그 범위를 새 리스트 값으로 설정해요. 주어진 범위 밖의 요소는 모두 제거돼요.

예를 들어 수리 대기 리스트 끝에 자전거를 계속 추가하면서, 가장 오래 대기 중인 3개만 신경 쓰고 싶다면 이렇게 해요.

res27 = r.rpush("bikes:repairs", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5")
print(res27)  # >>> 5

res28 = r.ltrim("bikes:repairs", 0, 2)
print(res28)  # >>> True

res29 = r.lrange("bikes:repairs", 0, -1)
print(res29)  # >>> ['bike:1', 'bike:2', 'bike:3']

리스트란 무엇인가?

리스트(List) 데이터 타입을 설명하려면 약간의 이론부터 시작하는 게 좋아요. IT 분야에서 List라는 용어가 자주 부정확하게 쓰이거든요. 예를 들어 "Python Lists"는 이름이 암시하는 것(연결 리스트)이 아니라 사실 배열(Array)이에요 (루비에서는 같은 타입을 실제로 Array라고 불러요).

아주 일반적인 관점에서 리스트란 정렬된 요소의 나열이에요. 10, 20, 1, 2, 3도 리스트예요. 하지만 배열로 구현한 리스트의 속성과 연결 리스트로 구현한 리스트의 속성은 아주 달라요.

Redis 리스트는 연결 리스트로 구현돼요. 즉 리스트 안에 수백만 개의 요소가 있더라도, 리스트의 **머리(head)나 꼬리(tail)에 새 요소를 추가하는 연산은 *상수 시간(constant time)***에 수행돼요. LPUSH 명령으로 요소 10개짜리 리스트의 머리에 요소를 추가하는 속도는, 1,000만 개짜리 리스트의 머리에 추가하는 속도와 같다는 뜻이에요.

그럼 단점은 뭘까요? 배열로 구현된 리스트는 인덱스로 요소에 접근하는 게 아주 빠르지만(상수 시간), 연결 리스트로 구현된 경우엔 그렇지 않아요. 인덱스 접근은 접근 대상 요소의 인덱스에 비례하는 작업량이 필요해요.

Redis가 리스트를 연결 리스트로 구현한 이유는, 데이터베이스 시스템에서 매우 긴 리스트에 요소를 아주 빠르게 추가할 수 있어야 하는 게 중요하기 때문이에요. 또 한 가지 큰 장점은, 곧 보시겠지만, Redis 리스트를 상수 시간에 일정한 길이로 잘라낼 수 있다는 점이에요.

큰 컬렉션의 중간에 빠르게 접근해야 하는 게 중요하다면, 다른 데이터 구조인 **정렬 집합(sorted sets)**을 쓸 수 있어요. 정렬 집합은 Sorted sets 튜토리얼 페이지에서 다뤄요.

Redis 리스트 첫걸음

LPUSH 명령은 리스트에 새 요소를 **왼쪽(머리)**에 추가하고, RPUSH 명령은 **오른쪽(꼬리)**에 추가해요. 마지막으로 LRANGE 명령은 리스트에서 요소 범위를 추출해요.

r.delete("bikes:repairs")

res15 = r.rpush("bikes:repairs", "bike:1")
print(res15)  # >>> 1

res16 = r.rpush("bikes:repairs", "bike:2")
print(res16)  # >>> 2

res17 = r.lpush("bikes:repairs", "bike:important_bike")
print(res17)  # >>> 3

res18 = r.lrange("bikes:repairs", 0, -1)
print(res18)  # >>> ['bike:important_bike', 'bike:1', 'bike:2']

RPUSH는 요소를 리스트 오른쪽에, 마지막의 LPUSH는 요소를 왼쪽에 추가한 게 보이죠. LRANGE는 반환할 범위의 첫 번째와 마지막 요소의 인덱스 두 개를 인자로 받아요. 두 인덱스 모두 음수일 수 있는데, 이 경우 Redis는 끝에서부터 세기 시작해요. 그래서 -1은 마지막 요소, -2는 끝에서 두 번째 요소가 되는 식이에요.

LPUSHRPUSH는 모두 **가변 인자 명령(variadic commands)**이에요. 즉 한 번의 호출로 여러 요소를 리스트에 자유롭게 넣을 수 있어요.

r.delete("bikes:repairs")

res19 = r.rpush("bikes:repairs", "bike:1", "bike:2", "bike:3")
print(res19)  # >>> 3

res20 = r.lpush("bikes:repairs", "bike:important_bike", "bike:very_important_bike")
print(res20)  # >>> 5

res21 = r.lrange("bikes:repairs", 0, -1)
print(res21)  # >>> ['bike:very_important_bike', 'bike:important_bike', 'bike:1', 'bike:2', 'bike:3']

여러 요소를 한 번에 넣으면 서버와의 왕복(round trip) 횟수를 줄일 수 있어요.

요소 팝(pop)하기

Redis 리스트에서 중요한 연산 중 하나는 요소를 팝(pop)하는 능력이에요. 팝이란 리스트에서 요소를 꺼내면서 동시에 제거하는 연산이에요. 푸시를 양쪽에서 할 수 있는 것처럼, 팝도 왼쪽과 오른쪽에서 할 수 있어요. 아래처럼 요소 3개를 넣고 3개를 팝하면, 명령 시퀀스가 끝날 무렵엔 리스트가 비고 더 이상 팝할 요소가 없게 돼요.

r.delete("bikes:repairs")

res22 = r.rpush("bikes:repairs", "bike:1", "bike:2", "bike:3")
print(res22)  # >>> 3

res23 = r.rpop("bikes:repairs")
print(res23)  # >>> 'bike:3'

res24 = r.lpop("bikes:repairs")
print(res24)  # >>> 'bike:1'

res25 = r.rpop("bikes:repairs")
print(res25)  # >>> 'bike:2'

res26 = r.rpop("bikes:repairs")
print(res26)  # >>> None

마지막 RPOP에서 리스트가 비어 있음을 신호하기 위해 Redis가 NULL 값을 반환한 게 보이죠.

명령 요약

리스트 관련 명령 전체는 리스트 명령 문서에서 확인할 수 있어요 (24개 명령).

더 알아보기 (Learn more)

리스트에 대해 더 배우고 싶다면 아래 링크를 확인해 보세요.