개발자를 위한 레디스 튜토리얼 01

레디스, 제대로 알고 쓰나요?

GARIMOO
garimoo
14 min readJan 15, 2020

--

레디스?

레디스는 REmote DIctionary Server 의 약자입니다. 아마 이 글을 읽으시는 대부분의 분들은 레디스를 사용해보신 경험이 있을 것이고, 적어도 어떤건지 들어는 보셨을 것이라고 생각합니다.

Who uses Redis?

레디스는 오픈소스이고, 다양한 서비스에서 레디스를 자유롭게 사용하고 있습니다. 위의 사진에서 볼 수 있듯이 Airbnb, Uber, Instagram도 레디스를 사용하고 있네요. 핑크다이어리, 토스트파일, 두레이 등 사내에서도 많은 팀들이 레디스를 사용하고 있습니다. 작년에 쿠팡에서 큰 장애가 있었는데, 그 원인이 레디스라고 밝혀지기도 했었죠.

Pareto principle

혹시 파레토의 법칙을 아시나요? 우리 사회에서 일어나는 현상의 80%는 20%의 원인으로 인해 발생됨을 뜻하는 법칙입니다. 웹 사이트에 대한 접근도 파레토의 법칙이 딱 들어맞아, 인터넷 통신의 80%가 불과 20%의 사이트에 대한 액세스로 추정되며, 이 20%의 웹사이트 데이터를 캐시해두면 효율을 극적으로 향상할 수 있다고 합니다. (인프라 엔지니어의 교과서 — 네트워크편, 2017, 길벗) 따라서 공통으로 사용되는 데이터는 레디스를 이용하여 캐시로 저장해 두는 것이 리소스를 효율적으로 이용할 수 있는 방법이 될 수 있을 것입니다.

왜 Collection이 중요한가요?

레디스는 In-Memory 데이터베이스입니다. 즉, 모든 데이터를 메모리에 저장하고 조회합니다. 기존 관계형 데이터베이스(Oracle, MySQL) 보다 훨씬 빠른데 그 이유는 메모리 접근이 디스크 접근보다 빠르기 때문입니다. 하지만 빠르다는 것은 레디스의 여러 특징 중 일부분입니다. 다른 In-Memory 데이터베이스(ex. Memcached) 와의 가장 큰 차이점은 다양한 자료구조 를 지원한다는 것입니다. 레디스는 아래처럼 다양한 자료구조를 Key-Value 형태로 저장합니다.

레디스는 기본적으로 String, Bitmap, Hash, List, Set, Sorted Set 를 제공했고, 버전이 올라가면서 현재는 Geospatial Index, Hyperloglog, Stream 등의 자료형도 지원하고 있습니다.

그렇다면 이렇게 다양한 자료구조를 제공하는게 왜 중요할까요?
바로 개발의 편의성과 난이도 때문입니다.

예를 들어 실시간 랭킹 서버를 구현할 때 관계형 DBMS를 이용한다면 DB에 데이터를 저장하고, 저장된 SCORE 값으로 정렬하여 다시 읽어오는 과정이 필요할 것입니다. 개수가 많아지면 속도가 느려질 텐데요, 이 과정에서 디스크를 사용하기 때문입니다. In-memory 기반으로 서버에서 데이터를 처리하도록 직접 코드를 짤 수도 있겠지만.. 레디스의 Sorted-Set을 이용하는게 더 빠르고 간단한 방법일 것입니다.

레디스는 트랜잭션의 문제도 해결해 줄 수 있습니다. 싱글 스레드로 동작하는 서버의 모든 자료구조는 atomic 하기 때문에, race condition을 피해 데이터의 정합성을 보장하기 쉽습니다.

즉, 외부의 Collections을 잘 이용하는 것만으로 개발 시간 단축이 가능하고, 생각하지 못한 여러가지 문제를 줄여줄 수 있으므로 개발자는 비즈니스 로직에 집중할 수 있다는 큰 장점이 존재합니다.

그렇다면 이제 레디스에서 제공하는 자료구조에 대해 살펴보겠습니다.

레디스 자료구조

string

레디스의 string은 키와 연결할 수 있는 가장 간단한 유형의 값입니다. 레디스의 키가 문자열이므로 이 구조는 문자열을 다른 문자열에 매핑하는 것이라고 볼 수 있습니다.

> set hello world
OK
> get hello
"world"

string 타입에는 모든 종류의 문자열(이진 데이터 포함) 을 저장할 수 있습니다. 따라서 JPEG 이미지를 저장하거나, HTML fragment 를 캐시하는 용도로 자주 사용합니다. 저장할 수 있는 최대 사이즈는 512MB입니다. string은 가장 기본적인 자료구조이기 때문에 다음과 같은 다양한 기능을 제공합니다.

  • string을 정수로 파싱하고, 이를 atomic하게 증감하는 커맨드
> set counter 100
OK
> incr counter
(integer) 101
> incr counter
(integer) 102
> incrby counter 50
(integer) 152
  • 키를 새 값으로 변경하고 이전 값을 반환하는 커맨드
> INCR mycounter
(integer) 1
> GETSET mycounter "0"
"1"
redis> GET mycounter
"0"
  • 키가 이미 존재하거나, 존재하지 않을 때에만 데이터를 저장하게 하는 옵션
> set mykey newval nx
(nil)
> set mykey newval xx
OK

list

레디스의 list는 일반적인 linked list 의 특징을 갖고 있습니다. 따라서 list 내에 수백만 개의 아이템이 있더라도 head와 tail에 값을 추가할 때 동일한 시간이 소요됩니다. 특정 값이나 인덱스로 데이터를 찾거나 삭제할 수 있습니다.

LPUSH mylist A   # now the list is "A"
LPUSH mylist B # now the list is "B","A"
RPUSH mylist A # now the list is "A","B","A" (RPUSH was used this time)

list는 여러 작업에 유용하지만, 대표적인 사용 사례는 Pub-Sub(생산자-소비자) 패턴입니다. 프로세스간의 통신 방법에서 생산자가 아이템을 만들어서 list에 넣으면 소비자가 꺼내와서 액션을 수행하는 식으로 동작합니다. 레디스에는 이를 좀 더 효율적이고 안정적으로 만들 수 있게 해줍니다.

트위터에서는 각 유저의 타임라인에 트윗을 보여주기 위해 레디스의 list를 사용합니다.

여기서 사용한 RPUSHX는 키가 이미 있을 때에만 데이터를 저장하기 때문에, 이를 이용하여 이미 캐시된(이미 키가 존재하는) 타임라인에만 데이터를 추가할 수 있습니다. 자세한 적용 방법은 (링크)에서 확인할 수 있습니다.

또한 일시적으로 list를 blocking하는 기능도 유용하게 사용될 수 있습니다. Pub-Sub 상황에서 list가 비어있을 때 pop을 시도하면 대개 NULL을 반환합니다. 이 경우 소비자는 일정시간을 기다린 후 다시 pop을 시도합니다(= polling). 레디스의 BRPOP을 사용하면 새로운 아이템이 리스트에 추가될 때에만 응답하므로 불필요한 polling 프로세스를 줄일 수 있습니다.

HASH

hash는 field-value 쌍을 사용한 일반적인 해시입니다. key에 대한 filed의 갯수에는 제한이 없으므로 여러 방법으로 사용이 가능합니다.

field와 value로 구성된다는 면에서 hash는 RDB의 table과 비슷합니다. hash key는 table의 PK, field는 column, value는 value로 볼 수 있습니다. key가 PK와 같은 역할을 하기 때문에 key 하나는 table의 한 row와 같습니다. 아래는 일반적으로 사용하는 RDB의 테이블을 레디스의 해시 구조로 나타낸 그림입니다.

> hmget user-2 email country
1) "giantpengsoo@ebs.com"
2) "Antarctica"

아래와 같이 개별 아이템을 atomic하게 조작할 수 있는 커맨드도 존재합니다.

> hincrby user:1000 birthyear 10
(integer) 1987
> hincrby user:1000 birthyear 10
(integer) 1997

set

set은 정렬되지 않은 문자열의 모음입니다. 일반적인 set이 그렇듯이, 아이템은 중복될 수 없습니다. 교집합, 합집합, 차집합 연산을 레디스에서 수행할 수 있기 때문에 set은 객체 간의 관계를 표현할 때 좋습니다.

set을 이용한 태그기능을 예로 들어 보겠습니다. 두레이 프로젝트에 태그를 지정할 때 ID가 1000인 프로젝트에 1,2,5,77번의 태그ID가 연결된 경우, set에서 이 관계를 표현하는 방법은 간단합니다. key값을 project:1000:tags 로 지정하고 여기에 태그를 모두 add 해주면 됩니다. (key는 항상 직관적인게 좋습니다. 프로젝트 -> ID 1000의 -> 태그)

> sadd project:1000:tags 1 2 5 77
(integer) 4
> smembers project:1000:tags
1. 5
2. 1
3. 77
4. 2

혹은 아래처럼 태그를 기준으로 저장할 수도 있습니다 (태그 -> ID 1을 갖고있는 -> 프로젝트). 1,2,10,27 태그를 가지고 있는 모든 프로젝트의 목록을 원할 때에는 SINTER 커맨드로 간단하게 확인할 수 있습니다.

> sadd tag:1:projects 1000
(integer) 1
> sadd tag:2:projects 1000
(integer) 1
> sadd tag:5:projects 1000
(integer) 1
> sadd tag:77:projects 1000
(integer) 1
> sinter tag:1:projects tag:2:projects tag:10:projects tag:27:projects
0) 1000
...

sorted set

sorted set은 set과 마찬가지로 key 하나에 중복되지 않는 여러 멤버를 저장하지만, 각각의 멤버는 스코어에 연결됩니다. 모든 데이터는 이 값으로 정렬되며, 스코어가 같다면 멤버값의 사전순서로 정렬됩니다. sorted set은 주로 sort가 필요한 곳에 사용됩니다.

sorted set은 정렬된 형태로 저장되기 때문에 때문에 인덱스를 이용하여 빠르게 조회할 수 있습니다. (인덱스를 이용하여 조회할 일이 많다면 list보다는 sorted set의 사용을 권장합니다.)

> zrange birthyear 2 3
2) "WILLIAM"
3) "BENTLEY"

스코어를 이용한 조회도 물론 가능합니다. 위의 예제 그림에서 멤버값은 이름, 스코어는 태어난 년도입니다. 예를 들어 2000년대에 모든 멤버를 조회하고 싶을 때에는 아래처럼 ZRANGEBYSCORE 커맨드를 사용해서 2000년부터 ~ 끝까지(+inf) 로 검색할 수 있습니다.

> zrangebyscore birthyear 2000 +inf
1) "PENGSOO"
2) "WILLIAM"
3) "BENTLEY"

그 외에도..

  • bit / bitmap: SETBIT, GETBIT 등의 커맨드로 일반적인 비트 연산이 가능합니다. 비트맵을 사용하면 공간을 크게 절약할 수 있다는 장점이 있는데요, 이 내용은 다음번 활용사례에서 자세하게 말씀드리겠습니다.
  • hyperloglogs: 집합의 카디널리티(원소의 갯수)를 추정하기 위한 데이터 구조입니다. (ex. 검색 엔진의 하루 검색어 수) 일반적으로 이를 계산할 때에는 데이터의 크기에 비례하는 메모리가 필요하지만, 레디스의 hyperloglogs를 사용하면 같은 데이터를 여러번 계산하지 않도록 과거의 항목을 기억하기 때문에 메모리를 효과적으로 줄일 수 있습니다. 메모리는 매우 적게 사용하고 오차는 적습니다.
  • Geospatial indexes: 지구상 두 지점의 경도(longitude)와 위도(latitude)를 입력하고, 그 사이의 거리를 구하는 데에 사용됩니다. 내부적으로는 Sorted Set Data Structure를 사용합니다.
  • Stream: 레디스 5.0에서 새로 도입된 로그를 처리하기 위해 최적화된 데이터 타입입니다. 차별화된 다양한 장점이 있지만, 가장 큰 특징은 소비자(Consumer)그룹을 지정할 수 있다는 것입니다. Stream에 대해서는 다음 기회에 더 자세하게 말씀드리겠습니다.

Redis Key

지금까지는 키에 매핑되는 자료구조에 대해서 알아봤는데요, 이번에는 레디스의 키 자체에 대해서 생각해 보겠습니다.

레디스의 키는 문자열이기 때문에 ‘abc’부터 JPEG 파일까지 모든 이진 시퀀스를 키로 사용할 수 있습니다. 빈 문자열도 키가 될 수 있습니다. string 타입과 마찬가지로 허용되는 최대 키 크기는 512MB입니다.

키를 조회할 때의 비용을 생각하면, 키를 너무 길게 사용하는 것은 권장하지 않습니다. 만약 그런 키를 저장해야 한다면 차라리 hash의 member로 저장하는 것이 더 좋은 방법입니다. 하지만 그렇다고 해서 가독성이 좋은 ‘user:1000:followers’를 ‘u1000flw’로 줄이는 건 그다지 의미있어 보이지는 않습니다.

레디스의 키를 잘 설계하는 것도 중요합니다. 어떻게 키를 생성하느냐에 따라 분산이 몰릴수도, 아닐 수도 있게 됩니다. 보통 스키마를 사용해서 레디스의 키를 설계하는 것이 좋은데, 예를 들어 ‘user:1000’ 처럼 object-type:id 의 형태를 권장합니다. 'comment:reply.to' 또는 'comment:reply-to'와 같이 ., -, :등의 부호를 사용해서 관계를 나타낼 수 있습니다.

키에 대한 커맨드는 데이터 타입에 국한되지 않고 사용할 수 있습니다. SORT는 입력된 키에 해당하는 아이템을 정렬하여 보여줍니다. 기존에 정렬되지 않은 상태로 저장된 set같은 경우, 커맨드를 이용한 정렬이 가능하므로 유용하게 사용될 수 있습니다. EXISTS 커맨드는 해당 키가 레디스에 있는지 확인하고, DEL 커맨드는 값에 관계없이 키를 삭제합니다. TYPE 커맨드는 해당 키에 연결된 자료구조가 어떤 형태인지 반환합니다.

Expire 기능

레디스를 사용하려면 Expire 기능에 대해서 꼭 알고 있어야 합니다. 레디스는 in-memory DB인 만큼, 메모리에 저장될 수 있는 데이터는 한정적입니다. 더이상 메모리에 데이터를 저장할 수 없는 경우 레디스에서는 가장 먼저 들어온 데이터를 삭제하거나, 가장 최근에 사용되지 않은 데이터를 삭제하거나, 혹은 더이상 데이터를 입력받지 못하게 됩니다.

가장 좋은 방법은 삭제되는 데이터를 레디스가 선택하도록 맡기지 않고, 직접 설정하는 것입니다. 데이터를 입력할 때 이 데이터의 사용 기한이 언제까지인지를 직접 설정해 줌으로서, 어플리케이션이 직접 해당 데이터가 삭제되는 타이밍을 제어할 수 있게 됩니다. 간단히 키에 대한 timeout을 설정하는 것입니다. 설정된 timeout 시간이 경과하면 키에 대해 DEL 명령어를 호출한 것처럼 키가 자동으로 삭제됩니다. 몇 초 뒤에 삭제되어야 하는지를 입력하거나, 혹은 유닉스의 timestamp를 이용하여 삭제되어야 하는 시각을 정확하게 설정할 수도 있습니다.

동일한 키가 다시 들어오면 이 timeout은 재설정됩니다. 따라서 자주 사용되는 데이터는 계속 남아있고, 사용되지 않는 데이터는 설정한 시간에 따라 삭제됩니다. 따라서 레디스를 사용하실 때, 모든 키에 expire 값을 추가하는 것을 권장합니다. 물론 너무 짧은 시간은 레디스에 오히려 부하를 줄 것이고, 너무 길다면 이 기능에 대한 의미가 없게 되니, 적절한 timeout 시간을 고려해야 합니다.

References

--

--