brunch

You can make anything
by writing

C.S.Lewis

by 이권수 Jan 19. 2020

Redis 기본 정리

캐시를 알아야 하는 순간!


캐시를 접하게 되는 순간


서비스를 처음 운영할 때는 WEB-WAS-DB의 전형적인 3티어 구조를 취하는 편이 보통입니다. 사용자가 몇 명 되지 않는 서비스의 경우에는 3티어 구조로도 충분히 서비스가 가능하지만, 사용자가 늘어나면 DB에 슬슬 무리가 가기 시작합니다. 데이터베이스는 데이터를 물리 디스크에 직접 쓰기 때문에 서버에 문제가 발생하여 죽더라도 데이터가 손실되지는 않습니다. 하지만, 매 transaction 마다 디스크에 접근해야하기 때문에 부하가 많아지만 상당히 느려집니다.


사용자가 늘어나면 DB만으로는 충분하지 않다!


DB만으로는 부하를 견딜 수 없다고 생각이 들면서부터 캐시서버 도입을 적극적으로 검토하게 됩니다. 캐시란 한 번 읽어온 데이터를 임의의 공간에 저장하여 다음에 읽을 때는 빠르게 결과값을 받을 수 있도록 도와주는 공간입니다. 그래서, 같은 요청이 여러번 들어오는 경우에는 캐시서버에서 바로 결과값을 반환해주기 때문에 DB부하를 줄일 수 있음과 동시에 서비스의 개선도 이룰 수 있습니다.


캐시를 사용하는 구조는 아래와 같습니다.

Cache 서버가 들어간 구조


클라이언트가 웹서버에 요청을 보내면, 웹서버는 데이터를 DB에서 가져오기 전에 Cache에 데이터가 있는지 확인합니다. Cache서버에 데이터가 있으면, 데이터를 DB에 데이터를 요청하지 않고 바로바로 클라이언트에 데이터를 반환합니다. 이를 cache Hit라고 합니다.

반대는 cache Miss인데, cache 서버에 데이터가 없으면 DB에 해당 데이터를 요청합니다. DB는 사용자가 원하는 데이터를 반환해주고, 웹서버는 반환된 데이터를 다음 사용을 위해 캐시에 저장한 후 클라이언트에 반환합니다. 따라서 이후에 같은 요청이 올 때는 Cache hit이 발생하는 구조입니다.


위의 구조가 대부분의 서비스에서 사용하는 캐시 사용 패턴입니다. 이런 구조는 DB를 위한 캐시 뿐만 아니라 Static한 파일을 캐시해주는 CDN서비스와도 동일한 구조입니다. Amazon CloudFront와 같이 CDN 서비스들은 데이터 원본을 가지고 있는 오리진(ex, Amazon S3)의 데이터를 캐시해서, 다음에 같은 요청이 있을 때는 굳이 오리진에 데이터를 요청하지 않고 바로 결과를 반환해줍니다. 우리가 해외 사이트인 아마존닷컴을 들어가도 금방 페이지가 나오는 이유가 바로 아마존에서 이런 정적 파일 캐시를 사용하기 때문입니다.(물론 더 많은 기능을 넣어서 개선했겠지요?)


또 다른 대표적인 패턴은 동시다발적인 쓰기가 발생하는 경우입니다. 예를 들어서 영어듣기평가를 온라인으로 진행하는 서비스가 있다고 할 때, 여러 학생들이 동시에 "제출" 버튼을 누르게 됩니다. 만약 캐시를 사용하지 않으면 DB에 한 번에 쓰게 되는데 갑자기 쓰기 요청이 몰리는 경우에는 DB가 터질 수도 있습니다. 만약 콘서트 티켓처럼 경쟁이 치열(?)한 부분에서 DB 에러로 결제가 되지 않으면 엄청난 손해를 발생시킬 수 있습니다. 이런 상황을 해결하기 위해서는 여러가지 방법을 사용할 수 있지만 대표적인 것 중에 하나가 바로 캐시를 사용하는 것입니다.


쓰기 요청을 받아주는 곳으로 사용되는 cache


클라이언트는 웹서버에 쓰기 요청을 하고 웹서버는 Cache에 데이터를 쓴 후 바로 결과를 리턴합니다. Cache는 보통 메모리를 사용하기 때문에 속도가 상당히 빠릅니다. 따로 작동하고 있는 워커(Worker) 서버들은 Cache 서버에 있는 데이터를 가져와서 작업을 수행하고 결과를 DB에 씁니다. 그러면 DB는 순차적으로 Transaction을 처리할 수 있게 됩니다.


이렇게 유용하게 쓰이는 캐시에는 물론 단점이 존재합니다. 캐시서버는 속도를 위해서 주로 메모리를 사용하기 때문에 서버에 장애가 나면 메모리가 날라가서 데이터가 손실될 수 있습니다. 그래서 때로는 디스크를 사용하도록 구성하거나, replication을 구성해서 고가용성을 확보하기도 합니다. 다만, 이렇게 구성을 하기 위해서는 어느 정도 속도를 포기하거나 또는 비용이 생각보다(?) 많이 들 수도 있습니다.


본 포스트에서는 캐시서버로 널리 쓰이고 있는 redis에 대해서 알아보도록 하겠습니다.




Redis, 너는 누구냐?


아래는 Redis에 대한 사전적 정의입니다.

Redis is an in-memory data structure project implementing a distributed, in-memory key-value database with optional durability.

(출처 :https://en.wikipedia.org/wiki/Redis)

간단히 말하면, Redis는 키-값 기반의 인-메모리 데이터 저장소입니다. 키-값 기반이기 때문에 쿼리를 따로 할 필요없이 결과를 바로 가져올 수 있습니다. 또한 디스크에 데이터를 쓰는 구조가 아니라 메모리에서 데이터를 처리하기 때문에 속도가 상당히 빠릅니다.


Redis에서는 다양한 데이터 구조(Collection)를 제공합니다.

1. Strings : 단순한 키-값 매핑 구조입니다.

2. Lists : Array형식의 데이터구조입니다. List를 사용하면 처음과 끝에 데이터를 넣고 빼는 것은 속도가 빠르지만 중간에 데이터를 삽입할 때는 어려움이 있습니다.

3. Sets : 순서가 없는 Strings 데이터 집합입니다. Sets에서는 중복된 데이터는 하나로 처리하기 때문에, 중복에 대한 걱정을 할 필요가 없습니다.

4. Sorted Sets : 위의 Sets와 같은 구조이지만, Score를 통해서 순서를 정할 수 있습니다. Sorted Sets를 사용하면 Leaderboard와 같은 기능을 손쉽게 구현하실 수 있습니다.

5. Hashes : 키-값의 구조를 여러개 가진 object 타입을 저장하기 좋은 구조입니다.


다양한 데이터 구조를 지원하는 덕에 Redis는 여러가지 용도로 사용됩니다. 캐시 데이터 저장은 물론이고, 인증 토큰 저장, Ranking Board 등으로 주로 사용됩니다. 이렇게 여러가지 용도로 쓰일 정도로 상당히 좋은 오픈소스이지만 인-메모리 특성상 관리를 잘 해주는 것이 중요합니다..!




Redis 관리하기


Redis의 대표적인 특징은 Single threaded라는 점입니다. 다시 말해 Redis는 한 번에 딱 하나의 명령어만 실행할 수 있다는 뜻입니다. 만약 명령어를 포함한 Packet이 MTU(Maximum Trasmission Unit)보다 크면 Packet이 쪼개져서 올 수 있는데, Redis는 쪼개진 명령어를 합쳐서 하나의 명령어가 되는 순간 그 명령어를 실행합니다. Single Thread라서 느리다고 생각할 수도 있지만, 평균적으로 Get/Set 명령어같은 경우 초당 10만개 정도까지도 처리할 수 있다고 합니다. 다만 조심할 점은 처리시간이 긴 명령어를 중간에 넣으면 그 뒤에 있는 명령어들은 전부 기다려야 한다는 것입니다. 대표적으로 전체 키를 불러오는 Keys 명령어가 처리가 상당히 오래걸리는데, 만약 중간에 Keys 명령어를 실행하면 그 뒤에 오는 Get/Set 명령어들은 타임아웃이 나서 요청에 실패할 수도 있습니다.


Redis를 사용하여 서비스를 운영하다보면 Redis 서버의 메모리가 한계에 도달할 수 있습니다. 메모리의 한계는 maxmemory 값으로 설정할 수 있습니다. maxmemory 수치까지 메모리가 다 차는 경우 Redis는max memory policy에 따라서 추가 메모리를 확보합니다.


| maxmemory-policy 설정값

1. noeviction : 기존 데이터를 삭제하지 않습니다. 메모리가 꽉 찬 경우에는 OOM(Out Of Memory) 오류 반환하고 새로운 데이터는 버리게 됩니다.

2. allkeys-lru : LRU(Least Recently Used)라는 페이지 교체 알고리즘을 통해 데이터를 삭제하여 공간을 확보합니다.

3. volatile-lru : expire set을 가진 것 중 LRU로 삭제하여 메모리 공간을 확보합니다.

4. allkeys-random : 랜덤으로 데이터를 삭제하여 공간을 확보합니다.

5. volatile-random : expire set을 가진 것 중 랜덤으로 데이터를 삭제하여 공간을 확보합니다.

6. volatile-ttl : expire set을 가진 것 중 TTL(Time To Live) 값이 짧은 것부터 삭제합니다.

7. allkeys-lfu : 가장 적게 액세스한 키를 제거하여 공간을 확보합니다.

7. volatile-lfu : expire set을 가진 것 중 가장 적게 액세스한 키부터 제거하여 공간을 확보합니다.


Maxmemory 초과로 인해서 데이터가 지워지게 되는 것을 eviction이라고 합니다. Redis에 들어가서 INFO 명령어를 친 후 evicted_keys 수치를 보면 eviction이 발생했는지 알 수 있습니다. Amazon Elasticache를 사용하는 경우에는 monitoring tab에 들어가면 eviction에 대한 그래프가 있는데, 이를 통해 Eviction 여부에 대한 알람을 받을 수도 있습니다.


# evicted_keys 값은 evicted된 키들의 count이므로 크면 클수록 많은 데이터가 메모리에서 삭제되었다는 뜻입니다.
evicted_keys:0


그런데, Maxmemory가 설정된 대로 작동하면 좋겠지만, 그렇지 않은 경우가 있습니다. Redis는 쓰기 요청이 발생하면 COW(Copy On Write) 방식을 통해 작동합니다. 쓰기 요청이 오면 OS는 fork()를 통해서 자식 프로세스를 생성합니다. fork() 시에는 다른 가상 메모리 주소를 할당받지만 물리 메모리 블록을 공유합니다. 쓰기 작업을 시작하는 순간에는 수정할 메모리 페이지를 복사한 후에 쓰기 작업을 진행합니다. 즉, 기존에 쓰던 메모리보다 추가적인 메모리가 필요합니다. 다만 전체 페이지 중에서 얼마나 작업이 진행될지를 모르기 때문에 fork시에는 기본적으로 복사할 사이즈만큼의 free memory가 필요합니다.


Redis를 직접 설치할 때 "/proc/sys/vm/overcommit_memory" 값을 1로 설정하지 않아 장애가 날 때가 있습니다. overcommit_memory=0 이면 OS는 주어진 메모리량보다 크게 할당할 수가 없습니다. 즉, fork()시에 OS가 충분한 메모리가 없다고 판단하기 때문에 에러를 발생시킵니다. overcommit_memory=1 로 설정해서 OS한테 일단 over해서 메모리를 할당할 수 있도록 한 후에 max memory에 도달한 경우 policy에 따라 처리되도록 설정하는 것이 좋습니다.


또한 redis에서는 memory 수치 중에서 used_memory_rss 값을 잘 살펴볼 필요가 있습니다.  RSS 값은 데이터를 포함해서 실제로 redis가 사용하고 있는 메모리인데, 이 값은 실제로 사용하고 있는 used_memory 값보다 클 수 있습니다. 이러한 현상이 발생하는 이유는 OS가 메모리를 할당할 때 page 사이즈의 배수만큼 할당하기 때문입니다. 예를 들어서 page size = 4096 인데, 요청 메모리 사이즈가 10이라고 하면 OS는 4096만큼을 할당합니다. 이를 Fragmentation(파편화) 현상이라고 하는데, 이것이 실제 사용한 메모리랑 할당된 메모리가 다른 원인이 됩니다.




Redis Replication


Redis를 구성하는 방법 중에서 Read 분산과 데이터 이중화를 위한 Master/Slave 구조가 있습니다. Master 노드는 쓰기/읽기를 전부 수행하고, Slave는 읽기만 가능합니다. 이렇게 하려면 Slave는 Master 의 데이터를 전부 가지고 있어야 합니다. 이럴 때 발생하는 것이 Replication입니다.


Replication은 마스터에 있는 데이터를 복제해서 Slave로 옮기는 작업입니다. Slave가 싱크를 받는 작업은 다음과 같습니다.


| Master-Slave 간 Replication 작업 순서

1. Slave Configuration 쪽에 "replicaof <master IP> <master PORT>"설정을 하거

나 REPLICAOF 명령어를 통해 마스터에 데이터 Sync를 요청합니다.

2. Master는 백그라운드에서 RDB파일(현재 메모리 상태를 담은 파일) 생성을 위한 프로세스를 진행합니다. 이 때 Master는 fork를 통해 메모리를 복사합니다. 이후에 fork한 프로세스에서 현재 메모리 정보를 디스크에 덤프뜨는 작업을 진행합니다.

3. 2번 작업과 동시에 Master는 이후부터 들어오는 쓰기 명령들을 Buffer에 저장해 놓습니다.

4. 덤프작업이 완료되면 Master는 Slave에 해당 RDB 파일을 전달해주고, Slave는 디스크에 저장한 후에 메모리로 로드합니다.

5. 3번에서 모아두었던 쓰기 명령들을 전부 slave로 보내줍니다.


눈치채신 분들은 아시겠지만, Master가 fork하는 부분에서 자신이 쓰고 있는 메모리만큼 추가로 필요해집니다. 따라서 Replication을 할 때 OOM이 발생하지 않도록 주의하는 것이 중요합니다. 성공적으로 replication을 마쳤다고 하더라도 개선할 점은 아직 남아 있습니다. 바로 Master 노드가 죽게 되는 시나리오입니다.


Master가 죽은 경우에는 Slave는 마스터를 잃어버리고 Sync 에러를 냅니다. 이 상태에서는 쓰기는 불가능하고 읽기만 가능합니다. 따라서 Slave를 Master로 승격시켜야 합니다. 이런 작업을 매번 장애마다 할 수는 없기 때문에 다양한 방법을 통해서 failover에 대응할 수 있습니다. 하나 예시를 들면, DNS기반으로 failover에 대응할 수 있습니다. client는 master 도메인을 계속 바라보게 한 후에, 만약 마스터에 장애가 발생하면 Master DNS를 slave에 매핑합니다. 그러면 client는 특별한 작업 없이도 slave쪽으로 붙게 됩니다.




Redis Cluster


Redis Cluster는 failover를 위한 대표적인 구성방식 중 하나입니다. Redis Cluster는 여러 노드가 Hash 기반의 Slot을 나눠가지면서 클러스터를 구성하여 사용하는 방식입니다. 전체 slot은 16384이며 hash 알고리즘은 CRC16을 사용합니다. Key를 CRC16으로 해시한 후에 이를 16384로 나누면 해당 key가 저장될 slot이 결정됩니다.

Cluster를 구성하는 각 노드들은 master 노드로, 자신만의 특정 slot range를 갖습니다. 다만 데이터를 이중화하기 위해서 위에서 설명한 slave 노드를 가질 수 있습니다. 즉, 하나의 클러스터는 여러 master 노드로 구성할 수 있고, 한 master 노드가 여러 slave를 가지는 구조입니다. 만약 특정 master 노드가 죽게 되면, 해당 노드의 slave 중 하나가 master로 승격하여 역할을 수행하게 됩니다.




마치며...


Redis는 인-메모리 방식의 데이터 저장소로 캐시서버를 구성하는데 사용되는 오픈소스입니다. 캐시서버를 구성하기 위한 논의를 시작하게 되면 보통 제일 먼저 생각할만큼 널리 쓰이고 있습니다. 캐시서버를 구성하는 순간 비용도 생각보다 많이 들고, 관리도 어렵습니다. 하지만 서비스 개선을 위해서 반드시 사용해야하기 때문에 이번 기회에 간단하게나마 redis에 대해 알아보았습니다.


| 참고자료

[우아한테크세미나] 191121 우아한레디스 by 강대명님

Redis에 대해 굉장히 상세하게 설명해주십니다. 저도 이 포스트 작성할 때 많은 도움이 된 영상입니다.

https://www.youtube.com/watch?v=mPB2CZiAkKM 





## 잘못된 내용은 피드백주시면 더 좋은 글로 보답하겠습니다.








매거진의 이전글 MySQL 내부 구조
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari