본문 바로가기

크래프톤 정글/WIL

정글[Week_03]: Redis를 이해해보자

Redis를 사용하는 이유

Redis을 사용하는 이유는 다양하지만, 주로 트래픽 병목을 해결하기 위한 목적이 많다고 한다. 클라이언트가 요청을 하면, 서버에서는 DB를 조회해서 알맞은 데이터를 반환한다. DB는 영속성을 위해 보조 메모리장치(SSD, HDD)를 사용한다. 그러나, 저장 공간과 데이터 처리 속도는 반비례 한다는 것을 상기할 필요가 있다. 아래 그림의 메모리 계층에서 보듯, 저장 용량이 큰 SSD/HDD는 프로세서에 비해 속도가 매우 느리다.

메모리 계층, 속도와 규모가 반비례한다

반면 Redis는 휘발성이 있고, 저장공간이 작은 메모리이지만, RAM(주기억 장치)에서 데이터가 저장되는 만큼, 속도가 매우 빠르다. 개발자들은 이러한 Redis를 캐시 메모리로 활용하여 서버의 과부화를 해결하였다.

redis와 서버와의 상호작용 모식도

클라이언트가 서버에게 요청하는 get 메서드는 중복 요청 비율이 7할이 넘는다고 한다. 예를 들어, 맨 처음 사이트에 진입할 때, 실시간 랭킹이 보여지는 메뉴판이라고 가정해볼 경우, (동일한 화면이 그려지는) 메인 화면을 매번 DB에서 데이터를 긁어올 필요가 없다. 대신, redis에 임시로 저장을 한 다음에, 이를 매번 요청을 할 경우 반환을 하면 된다.

 

 

캐시 히트(hit)와 캐시 미스(miss)

캐시 또한 Redis에 저장하려면 로직이 필요하다. 먼저 서버가 redis에서 원하는 데이터가 없다 = 캐시 미스, 이럴 상황에서는 DB에서 데이터를 긁어와 Redis에 적재한다. 반대로 적재한 데이터가 이미 있어서 서버가 바로 찾는다면 이를 캐시 히트라고 한다.

캐시를 히트율이 높을 수록 성능이 좋은 서버라고 말을 한다. 캐시 히트율을 높이려면, zset를 비롯해 다양한 내부 로직을 구현해야 하는데, 과거 제가 구현한 몇몇 예시를 설명해보고자 한다.

개인 프로젝트에서 메인화면/상세페이지를 캐싱하기 위해 적용한 로직들

스캐줄러: 서버에서 주기적으로 redis내 캐시를 업데이트 한다. 예를 들어, 데이터가 실시간 랭킹이라면 (완벽히 실시간일 필요가 없다면) 30초 주기로 캐시를 업데이트 하는 방법을 적용하였다.
zset: 방문 수를 추적하여, 인기있는 자료의 데이터만 선별적으로 캐싱하는 방법, 데이터의 유효기간을 설정하여 시간경과 시 자동삭제 방법도 적용.

 

 

Redis를 직접 구현해보기: Mini Redis 프로젝트

이번 정글 프로젝트는 가상 redis를 구현해보는 것이다. 여기서 몇 가지 가이드가 주어졌는데, hash 함수가 적용되는 자료구조를 활용하는 것, 그리고 socket(TCP/IP)통신을 하는 레이어에서 구성할 것이 대표적이다. Redis는 특정 언어에 종속되지 않고 여러 서버와 소통을 하기 위해, RESP(Redis Serialization Protocol)규율을 따른다.

네트워크 계층

문제는.. 이 socket 통신은 다른 네트워크 계층에 있다는 것. 위의 그림에서 보시다시피, 네트워크에는 7계층, 요즘 TCP/IP 모델로는 4계층으로 이루어져 있다. 아래로 갈 수록 low level이고 직접 물리적 컴퓨터 기계와 상호작용 한다. 위로 올라갈 수록 어플리케이션에 가까워진다. 

가상 Redis를 구현하면서 사용한 프로토콜은 socket, 즉 4계층이다. 반대로 웹에서 사용하는 프레임워크(Spring, Flask, Node.js)들은 http 프로토콜을 따르는 최상위 7계층이다. 따라서, 웹 페이지(클라이언트)가 가상 Redis의 데이터를 저장/읽기 등을 하려면, 클라이언트 요청 → http 메시지 생성 → 소켓 통로 탑승.. 이라는 단계적 과정을 거쳐야 한다.

 

동시성 문제

Redis를 활용하면서 필연적으로 생기는 문제 중 하나이다. 캐시 특성 상 여러 서버에서 동시에 접근이 가능할 수가 있다. 만약 서버 여러 대에서 (각각 클라이언트의 요청대로) 동일한 데이터를 동일한 시점에 처리한다고 가정해보면 좋다.

서버 A: Redis에서 count 값(현재 10)을 GET
서버 B: Redis에서 count 값(현재 10)을 GET 
서버 A: 자기 메모리에서 10 - 1 = 9로 계산
서버 B: 자기 메모리에서 10 - 1 = 9로 계산
서버 A: Redis에 9라고 SET
서버 B: Redis에 9라고 SET

정상적이라면, count 값은 9 +1 + 1 = 11 이 되어야 한다. 하지만 동일한 값을 참조하였기 때문에 이러한 동시성 문제가 터진다. 이를 해결하기 위한 방법으로는 다음이 있다.

기술 명 작용 위치 설명
(낙관적/비관적) 락 데이터베이스 수준 누군가 읽을 때 타 프로세스 접근을 차단
뮤텍스 커널 수준 접근 열쇠를 만들고, 열쇠 보유 스레드만 허용
원자적 연산 분산 시스템 수준 Redis 서버 내 연산 처리 프로세스는 단일

 

해시 테이블

Redis에는 막대한 데이터가 key = value 형태로 저장된다. 만약 인덱스가 존재하지 않는다면, 원하는 데이터를 찾을 때 0 부터 n 까지 모두 순회하는 O(n)의 시간복잡도를 띈다. list 자료형일 경우 자체적인 index가 있기 때문에, B + tree 라는 구조를 이용하기 때문에 O(1)의 시간복잡도로 탐색이 가능하다.

B+ tree로 빠르게 자료를 찾는다

그럼, key = value 형태의 dict과 같은 자료 구조는 어떻게 찾아야 할까? 이러한 경우, 자체적인 index를 생성을 해야 하는데 해시 함수가 이러한 역할을 한다. 다만, 해시 함수를 이용한 인덱스는 해시 충돌이 일어날 수가 있다.

해시함수를 통해 인덱스가 생성된다

그럼 해시 충돌은 왜 일어날까? 먼저 일반적인 숫자의 경우 1대1로 매칭이 된다는 점을 상기할 필요가 있다. 숫자 1은 1번째, 2는 2번째.. 이렇게 매칭이 된다. 그러나 해시(hash)라는 것 특성 상, 다양한 길이의 문자열을 동일한 길이의 값을 생성한다. 어떻게 보면 경우의 수가 확 줄어들게 되는 건데, 다른 두 키가 동일한 해시값을 가지는 문제가 생기게 되는데 이를 해시 충돌이라 생각하면 좋겠다.

해시 충돌이 일어난다고 해서, 값을 찾지 못하는 것이 아니다. 동일한 index 값을 가지 데이터를 저장하는 공간이 있는데, 이를 버킷(bucket)이라 한다. 버킷에서 내부 순회를 하면서 원하는 값을 찾게 되는데, 시간이 조금 더 걸린다는 단점이 있다.

 

여기까지 정리를 마치고자 한다.

끝.