[STEP13] 캐시가 무엇인가
0. 조회가 오래 걸리는 쿼리에 대한 캐싱, 혹은 Redis 를 이용한 로직 이관을 통해 성능 개선할 수 있는 로직을 분석
1. Redis를 이용한 로직 이관을 통해 성능을 개선 할 수 있는 로직 파악
e-commerce 프로젝트에 사용 되는 api들 중 아래 3가지가 캐싱 적용 혹은 레디스를 이용한 로직 이관으로 성능 개선이 가능할 것으로 보인다.
1. 선착순 쿠폰 발급
기존 로직은 쿠폰 발급시 쿠폰 개수보다 많은 요청이 들어왔을 경우 때문에 DB에 비관적 락을 거는 방식을 사용했다. 이를 레디스를 이용한 로직으로 성능 개선할 수 있을 것으로 판단하였다.
2. 인기 상품 목록 추가
결제 완료시 외부 api 인 인기상품 통계 입력를 호출하여 statics 테이블에 결제된 상품 데이터들을 입력하는 로직으로 만들어져 있다. "상품 정보"와 판매개수 기반 랭킹을 파악 위한 "상품 개수", 상품 들의의 3일 동안 데이터를 조회 하기위해 "추가된 시간"을 입력한다. 만약 이용자가 급증하여 동시에 수많은 결제가 완료된다면 (행복...?) 단순 통계를 위한 로직임에도 db 서버에 부하가 걸리거나 장애가 발생할 것으로 보인다.
3. 인기 상품 목록 조회
statics 에서 쿼리를 통해 3일간의 product 개별 판매 데이터가 입력된 통계 테이블에서 직접 몇백만건 이상의 데이터를 쿼리를 통해 랭킹으로 변경하여 조회한다고 가정 후 또한 수많은 클라이언트에서 조회 요청을 한다면 db에 부하가 걸릴 것으로 파악된다. 네이버 스마트 스토어 같은 경우는 이보다 더하지 않을까.
2. (레디스를 이용하여)어떤 식으로 로직을 변경하면 성능이 향상될까?
선착순 쿠폰 발급의 경우
기존의 비관적 락을 사용하여 충돌시
- 서버의 DB에서 처리 = (락-락해제) 시간 * 선착순 쿠폰 개수 의 시간이 걸릴 것이다. 이후 요청은 모두 실패처리
레디스를 이용한 로직 이관시
- 메모리위에 존재하는 레디스에서 처리 =
레디스 자료구조인 string을 이용하여 db에 등록된 쿠폰의 개수를 redis에 캐싱한다.
쿠폰발급 요청은 sorted set을 사용하여 zadd 명령어로 쿠폰명, 요청시간, 요청한 유저를 기록한다.
기록시 쿠폰 개수를 차감한다. (redis는 비동기방식이라 동시성 문제에서 자유롭다.)
zrange로 쿠폰수에 해당하는 유저의 데이터를 db로 넘긴다.
- memory 단에서 이뤄지고 db에는 한번만 업데이트 하므로 성능이 향상될 것으로 예상한다.
인기상품 목록 추가의 경우
기존 - 결제시 건마다 db에 직접 insert하는 방식
캐싱 전략 사용 - write-back 방식으로 cache에 먼저 데이터를 저장했다가 db에 부하가 가지 않는 선에서 특정 개수를 채우면 db에 나누어 insert 하는 방식을 사용하면 서버가 안정적일 수 있을 것으로 예상한다.
인기 상품 목록 조회
기존 - db에서 statics 테이블에 기록되어 있는 product 데이터들을 3일 내, 상위 5개 조회 후 랭크를 매겨 리턴
캐싱 전략 사용 - Cache-aside 전략을 사용하여 캐시에 3일간 최고 판매 상품을 캐싱해두고 매일 1번 랭크를 변경하여 캐시에 저장한다. 유저가 인기상품 목록을 요청하면 캐시에서 조회한다.
1. 캐시란?
캐시의 핵심 개념은 더 느린 저장소에서 자주 접근하는 데이터를 더 빠른 저장소에 임시로 저장하여 속도를 향상시키는 것이다. 더 빠르다는 뜻은 동일 시간상의 기술에서 그 만큼 용량이 적다는 뜻이다. 더 빠른 저장소는 용량이 더 적다. 아니면 값이 비싸다. 그렇기에 자주 접근하는 데이터를 '임시'로 저장하는 것이 필연이다. 그렇다면 자주 접근하는 데이터를 어떻게 판단할까?
2. 자주 접근하는 데이터 판단 알고리즘
자주 접근하는 데이터를 판단하는 알고리즘으로
- LRU(Least Recently Used) : 가장 최근에 사용된 데이터를 유지, 오랫동안 사용하지 않은 데이터부터 삭제
- LFU(Least Frequently Used) : 자주 사용되는 데이터를 캐시에 유지, 사용 횟수가 적은 데이터를 삭제
- ARC (Adaptive Replacement Cache, LRU + LFU 혼합형) : 위의 두 방식을 혼합하여 동적으로 적용
- FIFO(First In First Out) : 큐에 먼저 들어온 데이터부터 먼저 삭제하는 방식
등을 사용한다. 캐시는 컴퓨터의 여러 기능들에서 사용되는 개념이기 때문에 종류도 다양하다. 캐시의 종류에 대해 알아보자.
3. 캐시의 종류
1. CPU 캐시(L1 ,L2 ,L3) : cpu의 연산에 필요한 데이터들을 담아두는 캐시이다.
2. 디스크 캐시 (RAM 캐시, 페이지 캐시) : OS가 자주 사용하는 파일을 RAM에 저장해두고 HDD/SSD 접근을 최소화.
3. 웹 브라우저 캐시 : 웹사이트의 이미지, css, js 파일 등을 브라우저가 저장해둬서 페이지 로딩속도를 향상시킨다.
4. 데이터베이스 캐시 (Redis, Memcached 등) : db에서 자주 조회되는 데이터를 메모리에 저장하여 쿼리 성능을 높인다.
데이터베이스 캐시인 Redis를 사용하기 위해 캐시에 대해 알아보았다. 데이터베이스 캐시를 상황마다 효율적으로 사용하는 전략도 존재한다. 데이터베이스 캐시 전략은 크게 4가지가 있다.
4. 데이터베이스 캐시 전략 4가지
1. Cache-aside
- 읽기 작업이 많은 어플리케이션에 유용하다.
데이터가 캐시에 존재(캐시 히트)하면 데이터를 캐시로부터 읽어온다. 캐시에 데이터가 존재하지 않으면 DB로부터 데이터를 어플리케이션에 읽어오고 캐시에 업데이트 한다. 데이터 쓰기의 경우엔 db에 바로 쓴다. Cache-aside의 단점으로는 캐시와 데이터 불일치 문제가 있을 수 있다.
2. Read-through
- 읽기 작업이 많은 어플리케이션에 유용하다.
기본적으로 Cache-aside 방식과 비슷하지만 캐시에 데이터가 존재하지 않을경우 DB에서 캐시에 데이터를 업데이트하고 어플리케이션에 데이터를 가져온다. 데이터 쓰기는 db에 바로 쓰고 업데이트 하는 사이에 캐시와 데이터간 불일치 문제가 발생할 수 있다.
3. Write-through
- 쓰기 작업이 적고, 캐시와 데이터 불일치가 발생하지 않아야하는 경우 유용하다.
Write-through 방식은 데이터를 캐시에 먼저 쓰고, 동시에 DB에도 기록하는 방식이다. 이 방식은 데이터 일관성을 보장하는 데 유용하지만, 모든 쓰기 작업이 캐시와 DB에 동시 수행되므로 쓰기 성능이 저하될 수 있다. 데이터가 항상 최신 상태로 유지되므로 캐시와 DB 간 불일치 문제가 발생하지 않는다. 하지만, 쓰기 작업이 많을 경우 캐시의 성능 이점을 충분히 활용하지 못할 수 있다.
4. Write-back
- 쓰기작업이 빈번하게 발생할때, 약간 배치처리 하는 느낌?
데이터를 먼저 캐시에 저장한 후 일정 시간이 지나거나 특정 조건이 충족되면 DB에 반영하는 방식이다. 이 방식은 쓰기 성능을 크게 향상시킬 수 있으며, 캐시가 빈번한 데이터 변경을 처리할 때 효과적이다. 그러나 캐시에 저장된 데이터가 DB에 즉시 반영되지 않기 때문에, 캐시 장애나 서버 충돌 시 데이터 유실 위험이 존재한다.
번외) 5. Write-around
- 이게 왜 캐시전략일까. 그냥 DB에 바로 저장하는 방식인데..
Write-around 방식은 데이터를 캐시에 저장하지 않고 바로 DB에 기록하는 방식이다. 읽기 빈도가 낮은 데이터에 적합하며, 캐시의 불필요한 데이터 저장을 방지할 수 있다. 하지만 이 방식은 같은 데이터가 반복적으로 읽힐 경우 캐시에 저장되지 않기 때문에, Cache-aside나 Read-through 방식보다 읽기 성능이 저하될 수 있다.
앞서 캐시의 용량은 임시저장소(주기억장치 RAM)가 많기 때문에 영구저장소(보조기억장치 ssd,hhd 등)에 비해 매우 적다고 하였다. 영구저장소의 데이터를 캐시에 저장하다 보면 캐시에 데이터가 가득차게 될 가능성이 높은 것이다. 따라서 데이터 저장 공간을 확보하기 위해 Eviction 한다.Redis에서는 Eviction 정책을 설정할 수 있다.
5. Redis에서 Eviction
Redis는 메모리가 부족할 경우 특정 Eviction 정책을 사용하여 데이터를 제거한다. Redis 설정에서 maxmemory-policy 값을 지정하여 다양한 Eviction 전략을 적용할 수 있다.
Redis Eviction 정책 (maxmemory-policy)
- noeviction: 메모리 한도에 도달하면 새로운 값을 저장하지 않고 오류를 반환
- allkeys-lru: 모든 키 중에서 가장 오랫동안 사용되지 않은 키를 제거
- allkeys-lfu: 모든 키 중에서 가장 적게 사용된 키를 제거
- allkeys-random: 모든 키 중에서 무작위로 키를 제거
- volatile-lru: expire 필드가 설정된 키 중에서 가장 오랫동안 사용되지 않은 키를 제거
- volatile-lfu: expire 필드가 설정된 키 중에서 가장 적게 사용된 키를 제거
- volatile-random: expire 필드가 설정된 키 중에서 무작위로 키를 제거
- volatile-ttl: expire 필드가 설정된 키 중에서 남은 TTL(Time-To-Live)이 가장 짧은 키를 제거
6. 캐시 스탬피드 ( Cache Stampede )
캐시 스템피드(Cache Stampede)는 특정 데이터가 캐시에서 만료되었을 때, 동시에 여러 개의 요청이 DB로 몰려 성능 저하나 시스템 장애를 유발하는 현상을 의미한다.
1. 캐시 스탬피드(캐시 쇄도)가 발생하는 이유
- 특정 캐시가 만료되어 새로운 캐시를 생성하는 동안 요청이 몰리는 경우 : 인기 있는 데이터의 캐시가 만료되면, 캐시에 데이터가 없으므로 여러 요청들이 db를 직접 조회
- 캐시 갱신이 비효율적으로 이루어짐 : 캐시가 만료되었을때, 만료된 캐시마다 db 갱신을 하려고하면 db요청이 몰린다.
- 시스템 성능 저하 : 원래 처리 가능한 요청량도 시스템 성능이 저하되면 부하가 발생하고, db 가 다운될 수도 있다.
등의 이유들이 있다. 캐시 스템피드를 어느정도 예방(해결?)할 수 있는 방법 들에 대해 알아보자.
2. 캐시 스탬피드 해결(예방)하는 방법들.
0. 적절한 상황에 맞는 적절한 TTL 선정
1. 지터(Jitter)
TTL( Time To Live )이 동일하더라도 캐시 만료시간을 ttl + 랜덤 시간을 부여해서 db 요청 시간을 분산시키는 방법이다.
2. PER 알고리즘 (Probabilistic Early Recomputation)
지터방식과 비슷해보이지만 캐시만료시간이 가까워지면 미리 ttl을 PER 알고리즘을 통해 다시 계산하여 부여하는 방식이다.
3. 캐시 만료시간 선 변경
캐시 시간의 만료가 가까워지면 미리 갱신하는 방법이다.
캐시에 대해 조사하다보니 어느정도 database 캐싱에 대해 알 수 있게 된 기분이 든다. 상황별로 다른 캐싱 전략을 적절히 사용하면 서버 성능이 크게 개선될 것이다.
참조: [화해 테크 블로그] 캐시 스탬피드를 대응하는 성능 향상 전략, PER 알고리즘
https://blog.hwahae.co.kr/all/tech/14003
참조: [토스 테크 블로그] 캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁
https://toss.tech/article/cache-traffic-tip
참조 :Redis 홈페이지 Doc / Eviction policy
https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
참조 : 데이터베이스 캐시전략
https://www.prisma.io/dataguide/managing-databases/introduction-database-caching