[Redis] 캐싱 전략

Look Aside(Lazy Loading), Write Through, Session Management, 여러가지 활용 사례

Posted by Wonyong Jang on May 10, 2021 · 10 mins read

캐싱 전략이란?

서비스 사용자가 증가했을 때, 모든 유저의 요청을 DB 접근으로만 처리하게 된다면 DB 서버에 무리가 갈 수 밖에 없다. 물론 데이터베이스는 데이터를 디스크에 저장하기 때문에 서버의 장애와는 별개로 데이터를 유지할 수 있지만, 요청이 증가하는 상황에서는 기존 성능을 기대하기 힘들다.

캐싱 전략은 최근 웹 서비스 환경에서 시스템 성능 향상을 위해 가장 중요한 기술이다. 캐시는 메모리를 사용함으로 디스크 기반 데이터 베이스 보다 훨씬 빠르게 데이터를 반환할 수 있고, 사용자에게 더 빠르게 서비스를 제공할 수 있다.

이러한 캐시가 동작할 수 있는 철학에는 파레토 법칙이 있다.
파레토 법칙이란 80퍼센트의 결과는 20퍼센트의 원인으로 인해 발생한다는 말이다.

20퍼센트의 고객이 백화점 전체 매출의 80퍼센트의 결과를 일으킨다를 예로 들수 있다.

이를 데이터베이스 성능 튜닝 세계에서 얘기해보면 데이터베이스에서 성능 문제를 일으키는 것은 20퍼센트의 쿼리들이다. 보통 20퍼센트는 자주 사용되는 쿼리일 것이다.

스크린샷 2021-05-12 오전 12 01 41

즉, 이것은 캐시가 효율적일 수 있는 이유가 될 수 있다. 모든 결과를 캐싱할 필요는 없으며, 우리는 서비스를 할 때 많이 사용되는 20%를 캐싱한다면 전체적으로 영향을 주어 효율을 극대화 할 수 있다는 말이다.

캐싱 전략을 구현 할 때 고려해야 할 점 중 하나는 캐시 데이터의 수명이다.

모든 데이터를 지워지지 않고 평생 캐시 저장소에 저장하는 것은 효율적이지 않다. 그렇기 때문에, 캐시 만료 정책을 적절하게 설정하고 오랜 시간이 지난 데이터는 캐시 저장소에서 제거될 수 있도록 운영해야 한다.


1. Look Aside(=Lazy Loading)

이름에서 알 수 있듯이 이 구조는 캐시를 옆에 두고 필요할 때만 데이터를 캐시에 로드하는 캐싱 전략이다.

자주 조회되는 데이터를 메모리에 캐시해놓고, 요청이 있을 때마다 레디스에 먼저 해당 데이터가 있는지 확인 후 있으면(이를 Hit되었다고 한다) 바로 메모리에 있는 데이터를 내보내고, 없으면 그 때 DB로 부터 읽어서 캐시한 후에 데이터를 내보낸다.

스크린샷 2021-05-12 오전 12 23 44

위 구조를 사용하면 실제로 사용되는 데이터만 캐시할 수 있고, 레디스의 장애가 어플리케이션에 치명적인 영향을 주지 않는다는 장점을 가지고 있다. (Failover 처리)
하지만 캐시에 없는 데이터를 쿼리할 때 더 오랜 시간이 걸린다는 단점과 함께 캐시가 최신 데이터를 가지고 있는 다는 것을 보장하지 못한다는 단점이 있다.
캐시에 해당 key 값이 존재하지 않을 때만 캐시에 대한 업데이트가 일어나기 때문에 데이터베이스에서 데이터가 변경될 때에는 해당 값을 캐시가 알지 못하기 때문이다.

이를 해결 하기 위해서는 database가 업데이트되면 redis도 같이 update하여 동기화가 필요하다.


2. Write-Back

데이터를 레디스에 전부 먼저 저장해놓았다가 특정 시점마다 한번씩 캐시 내 데이터를 DB에 insert 및 update하는 방법이다.

insert를 1개씩 500번 수행하는 것보다 500개를 한번에 삽입하는 동작이 훨씬 빠름에서 알 수 있듯, write back 방식도 성능면에서 뒤쳐지는 방식은 아니다.

하지만 여기서 데이터를 일정 기간동안 유지하고 있어야 하는데, 이때 이걸 유지하고 있는 storage는 메모리 공간이므로 서버 장애 상황에서 데이터가 손실될 수 있다는 단점이 있다. 그래서 다시 재생 가능한 데이터나, 극단적으로 heavy한 데이터는 write back 방식을 많이 사용한다.

스크린샷 2021-05-12 오전 12 23 38


3. Session Management

사용자가 로그인을 하게 되면 사용자 Session을 레디스에 저장 해두고 SessionID를 Cookie값으로 내려준다.
다시 사용자가 다른 요청을 보낼 때는 브라우저에 저장된 Cookie를 달고 요청을 하게 되고, 서버는 이 Cookie값과 sessionID를 비교하여 일치하면 로그인 될 걸로 판단한다.

따라서 사용자는 한 번 로그인 하면 다시 접속하거나 다른 요청을 보낼 때 다시 로그인 할 필요가 없다.

레디스를 사용하지 않을 경우는 보통 Session을 서버 메모리에 저장해놓고 사용하는데 서버를 재시작하게 되면, 세션 데이터가 모두 지워지게 되어 사용자는 다시 로그인을 해야하며, 서버가 클러스터링으로 구축되어 여러 개로 구동된다면, 세션을 공유해야 한다.

Session을 레디스에 저장하면 서버가 클러스터링으로 구동되어도 세션 공유가 가능하며, 서버를 재시작하여도 세션이 지워지지 않는다.

스크린샷 2021-05-12 오전 12 23 51


레디스 활용 사례

좋아요 처리하기

게시물에 달린 댓글에 좋아요를 표시하는 기능에 대해서 생각해보자.

가장 중요한 것은 한 사용자의 하나의 댓글에 한번만 좋아요를 할 수 있도록 제한 하는 것이다.

RDBMS에서는 유니크 조건을 생성해서 처리할 수 있다. 하지만 많은 입력이 발생하는 환경에서 RDBMS를 이용한다면 insert 와 update에 의한 성능 저하가 필연적으로 발생하게 된다.

레디스의 set을 이용하면 이 기능을 간단하게 구현할 수 있으며, 빠른 시간 안에 처리할 수 있다.
set은 순서가 없고, 중복을 허용하지 않는 집합이다. 댓글의 번호를 사용해서 key를 생성하고, 해당 댓글에 좋아요를 누른 사용자의 ID를 아이템으로 추가하면 동일한 ID값을 저장할 수 없으므로 한 명의 사용자는 하나의 댓글에 좋아요를 누를 수 있게 된다.

스크린샷 2021-05-10 오후 6 34 19

Jedis(Java의 redis 라이브러리)를 통한 파이프라인을 사용하여 이 기능을 구현한다고 가정했을 때, 초당 약 16만건의 커맨드를 처리할 수 있다. RDMBS와 비교했을 때 확인히 빠른 속도이다.

일일 순 방문자수(Unique Visitor) 구하기

순 방문자수(UV)는 서비스에 사용자가 하루에 여러번 방문했다 하더라도 하나만 카운팅 되는 값이다. 즉 중복 방문을 제거한 방문자의 지표라고 생각할 수 있다.

많은 서비스에서 이 수치를 이용해 사용자의 동향을 파악하고, 마케팅을 위한 자료로 활용하기도 한다.

그렇다면 이제 레디스의 비트 연산을 활용하여 간단하게 실시간 순 방문자를 저장하고 조회하는 방법을 알아보자. 서비스의 유저는 천만명이라고 가정하고, 일일 방문자 횟수를 집계하여 이 값은 0시를 기준으로 초기화 된다.

사용자 ID는 0부터 순차적으로 증가된다고 가정하고, string의 각 bit를 하나의 사용자로 생각할 수 있다.
사용자가 서비스에 방문할 때 사용자 ID에 해당하는 bit를 1로 설정한다.
1개의 bit가 1명을 의미하므로, 천만명의 유저는 천만개의 bit로 표현할 수 있고, 이는 곧 1.2MB 정도의 크기이다.

레디스 string의 최대 길이는 512MB이므로 천만명의 사용자를 나타내는 건 충분하다.

스크린샷 2021-05-10 오후 7 00 07

2020년 1월 29일에 ID가 7인 사용자가 방문했다면 위 그림처럼 일곱번째 인덱스를 1로 설정한다. 이 날에 서비스에 방문한 총 방문자 수를 조회하기 위해서는 이 문자열에 1로 설정된 bit의 갯수를 구하는 BITCOUNT 연산을 이용하여 간단히 구할 수 있다.

출석 이벤트 구현하기

만약 출석 이벤트 등을 진행하기 위해 정해진 기간동안 매일 방문한 사용자를 구하고 싶을 수 있다.
이 때는 레디스의 BITOP 커맨드를 사용하면 간단하다. 레디스 서버에서 바로 AND, OR, XOR, NOT 연산을 할 수 있으므로, 레디스에서 개별 비트를 가져와서 서버에서 처리하는 번거로움을 줄여준다.

스크린샷 2021-05-10 오후 7 00 17

2020년 1월 29일부터 31일까지 매일 접속한 사용자는 id가 7인 사용자와 11인 사용자라는 것을 BITOP를 이용한 AND 연산을 통해 쉽게 구할 수 있다.

최근 검색 목록 표시하기

사용자별로 최근 검색했던 목록을 조회하는 것도 레디스로 간단하게 구현 가능하다.

이 기능을 관계형 데이터베이스를 이용하여 구현하려면 아래와 비슷한 쿼리문이 필요하다.

select * from KEYWORD where ID = 123 order by reg_date desc limit 5;   

이 쿼리는 사용자가 최근에 검색했던 테이블에서 최근 5개 데이터를 조회한다. 하지만 이렇게 RDBMS의 테이블을 이용해서 데이터를 저장한다면 중복도 제거해야 하고, 멤버별로 저장된 데이터의 개수를 확인하고 오래된 검색어는 삭제하는 작업까지 이루어 져야 한다.

따라서 애초에 중복을 허용하지 않고, 정렬되어 저장되는 레디스의 sorted set을 사용하면 간단하게 구현할 수 있다.
sorted set은 가중치를 기준으로 오름차순으로 정렬되기 때문에, 가중치로 시간(유닉스 시간)을 사용한다면 시간 별로 정렬해서 관리 할 수 있다.

스크린샷 2021-05-10 오후 7 49 37

id가 123인 사람이 최근에 검색한 내용은 위 그림처럼 정렬되어 저장된다. 50번 데이터를 가장 마지막으로 검색했고 51인 데이터를 검색하면 아래처럼 마지막 데이터가 추가된다.

스크린샷 2021-05-10 오후 7 49 54

항상 다섯개의 데이터만 저장되기 때문에 인덱스가 0인 아이템을 지우면 된다. 하지만 아이템 개수가 6보다 작을 때에는 0번째 인덱스를 삭제하면 안되기 때문에 매번 아이템의 수를 먼저 확인해야 하는 번거로움이 있다. 이 때 sorted set의 음수 인덱스를 사용하면 더 간단해 진다. 음수 인덱스의 마지막부터 큰 값을 작은 값으로 매겨지는데 아래 그림과 같다.

스크린샷 2021-05-10 오후 7 50 06

ZREMRANGEBYRANK recent:member:123 -6 -6

데이터를 추가한 뒤, 항상 -6번째 아이템을 지운다면 특정 개수 이상의 데이터가 저장되는 것을 방지 할 수 있게 된다.
인덱스로 아이템을 지우려면 ZREMRANGEBYRANK 커맨드를 사용하면 간단하다. 이렇게 레디스의 sorted set을 이용하면 많은 공수를 들이지 않고도 최근 검색한 담당자를 보여줄 수 있는 기능을 구현할 수 있게 된다.


Reference

https://www.happykoo.net/@happykoo/posts/40
https://wnsgml972.github.io/database/2020/12/13/Caching/
https://meetup.toast.com/posts/225
https://velog.io/@litien/%EB%A6%AC%EB%B7%B0-%EC%9A%B0%EC%95%84%ED%95%9C-Redis