캐시란?
캐시란 데이터나 값을 미리 복사해놓는 임시 장소를 의미한다. 현대의 대부분 프로세서는 단일 칩에 여러 코어를 가지는 다중 코어 시스템을 가지는데,
위 그림과 같이 프로세서 내부에는 캐시 메모리가 존재하며 각 코어에는 L1 캐시, 프로세서 내에 L2 캐시가 존재한다.
이와 같은 캐시 메모리는 일반적인 메모리(RAM)보다 더 빠른 속도를 가지고, 위 그림의 L1 캐시(하위 캐시)가 L2 캐시(상위 캐시)보다 빠르다. 용량이 작지만 속도가 더 빠른 이러한 캐시 메모리의 특성 떄문에, 데이터가 사용될 때 더 빠른 저장 장치인 캐시에 일시적으로 복사해두고 메모리까지 가기 전 캐시 내부를 뒤져서 데이터가 있다면 꺼내서 사용한다. 이러한 작업을 캐싱(Caching)이라 부르는데, 운영체제와 시스템을 넘어 더 접근하기 쉬운(빠른) 저장장소에 데이터를 저장해두고 빠르게 데이터를 꺼내는 작업을 전부 캐싱이라 부른다.
이러한 캐싱은 웹 기술에서 많이 활용되는데, 활용되는 곳은 다음과 같다.
•
웹 브라우저가 웹 사이트를 더 빨리 로드하기 위해, HTML, JavaScript, 이미지 등을 로컬 세션에 캐싱
•
DNS 서버가 더 빨리 주소를 로드하기 위해 DNS 레코드를 캐싱
•
서버가 멀리 있는 지역에 콘텐츠를 더 빠르게 스트림하기 위해, 가까운 지역에 CDN 서버를 두어 자주 스트리밍되는 콘텐츠를 미리 캐싱
•
데이터베이스에서 처리속도를 향상시키기 위해 자주 사용되는 쿼리를 캐싱
우리가 애플리케이션을 서비스 하는 과정에서, 자주 조회되는 데이터를 미리 메모리나 Redis와 같은 더 빠른 저장장치에 저장해두어 DB의 조회 횟수를 줄이는 것도 캐싱이다.
캐시를 잘 활용하기
우리의 애플리케이션에서 사용되는 캐시는 주로 디스크 저장소보다 속도가 빠른 메모리에 저장되는데, 메모리 저장소에는 대표적으로 MemCached와 Redis, ValKey가 있다. 이러한 메모리는 일반적으로 디스크 저장소에 비해 적은 용량을 두기 때문에, 캐시는 제약적인 저장 공간을 잘 활용하는 것이 중요하다. 결국 어떤 데이터를 저장하고 어떤 데이터를 지울지에 대한 선택을 의미한다.
때문에 캐시에서는 자주 사용되는 데이터를 저장하고, 자주 사용되지 않는 데이터를 지우는 것이 좋다. 이 때 주로 사용되는 지표로 캐시 적중률(Cache Hit Rate)가 사용된다. 캐시 적중률은 받은 요청 중 캐시가 얼마나 적중(캐시에 데이터가 존재) 되었는지를 의미하는데, 일반적으로 캐시메모리의 경우 0.95이상 일 때 우수하다고 말한다. 반면 CDN 서버 같은 경우에는 캐시 누락(Cache Miss) 시 속도가 무척이나 느려지기 때문에, 경우에 따라서0.98~0.99 정도의 캐시 적중률을 요구하기도 한다.
이러한 캐시 적중률은 시간이나 환경에 따라 변할 수 있기 때문에, 어떤 데이터를 캐싱하는게 좋은지는 상황에 따라 유동적으로 변하게 된다. 일반적으로 웹 애플리케이션에서 캐시 적중률에 영향을 미치는 요소는 다음과 같다.
•
캐시 전략
◦
어떤 읽기/쓰기 전략 선택하는가에 따라 캐시 적중률에 영향을 미친다.
•
캐시 만료 정책
◦
TTL(Time To Live)를 설정하고, 캐시 용량이 부족할 때 어떤 캐시를 먼저 만료 시킬 것인지에 따라 캐시 적중률이 달라진다.
•
데이터 변경 빈도
◦
데이터가 자주 변경되는 경우 캐시 무효화가 잦아 캐시 적중률이 낮고, 자주 변경되지 않는 정적인 데이터일수록 캐시 적중률이 높다.
•
캐시의 키(Key) 설계
◦
캐시의 키로 어떤 값을 선택하는지, 해시 알고리즘을 선택한다면 어떤 알고리즘인지에 따라 캐시 적중률이 달라진다.
캐시 사용 시 유의사항
캐시는 언제든 데이터가 날아갈 수 있는 휘발성을 가진다는 점을 염두에 두어야한다. 캐시의 읽기/쓰기 전략을 선택할 때나, 데이터의 저장/수집 주기를 결정하는 경우에 이를 항상 전제로 두고 생각해야한다. 또한 데이터의 유실이 발생하거나, 데이터의 정합성이 맞지 않을 수 있다는 점도 유의해야한다.
캐시를 너무 빈번하게 저장/수집하게 되면 성능이 저하되지만, 정합성이 잘 지켜지고 데이터를 잃어버릴 가능성이 낮다. 반대로 너무 가끔 저장/수집하게 되면 성능은 향상되지만, 정합성이 맞지 않을 확률이 높아지고 데이터를 잃어버릴 가능성이 있다.
따라서 캐시에는 중요하거나 민감한 정보를 저장하는 것은 좋지 않으며, 캐시 장애 발생 시를 대비해 적절한 대응방안을 생각해두는 것이 좋다.
파레토의 법칙 - 8:2 법칙
파레토의 법칙은 전체 결과의 80%가 원인의 20%에서 발생하는 현상을 말한다. 캐시의 상황에 빗대어 보자면, 전체 활동의 80%는 20%의 유저가 하기 때문에 모든 데이터를 저장할 필요 없이 일부분의 데이터로도 대부분의 요청을 해결할 수 있다.
캐시 전략
캐시 읽기 전략
•
Look Aside
◦
Cache Aside라고도 불리며, 캐시에서 먼저 데이터를 찾아본 후 없다면 데이터베이스에서 조회하는 전략이다.
◦
장점
▪
반복적인 읽기가 많은 호출에 적합하다.
▪
캐시와 DB가 분리되어있기 때문에, 원하는 데이터만 별도로 구성해 저장하는게 가능하고 캐시 서버에 장애가 발생하더라도 서비스 자체에는 문제가 발생하지 않는다.
◦
단점
▪
캐시 미스 발생 시 총 3번의 처리 과정을 거치므로, 캐시 히트 시에 비해 속도가 눈에 띄게 저하된다.
▪
캐시 갱신 없이 데이터베이스 업데이트만 발생한다면 데이터 정합성이 맞지 않는 상태가 된다.
•
Read Through
◦
캐시를 통해서만 데이터를 읽어오는 전략으로, Look Aside와 비슷하지만 캐시 미스 발생 시 캐시 레이어에서 직접 데이터를 조회 후 캐싱하고 반환한다.
◦
장점
▪
반복적인 읽기가 많은 호출에 적합하다.
▪
항상 캐시를 통해 데이터를 조회하기 때문에, 특정 쓰기 전략과 같이 사용된다면 완벽한 데이터 정합성을 보장할 수 있다.
◦
단점
▪
Look Aside와 동일하게 캐시 미스 시, 캐시 히트에 비해 속도가 눈에 띄게 저하된다.
▪
데이터 조회를 전적으로 캐시에 의존하기 때문에, 캐시 서버가 다운되는 경우 서비스 장애로 이어진다.
캐시 쓰기 전략
•
Write-Back
◦
Write-Behind 이라고도 불리며, 쓰기 발생 시 일단 캐시에 저장해두고 일정 시간마다 스케줄러를 통해 저장된 캐시를 데이터베이스에 동기화한다.
◦
장점
▪
데이터를 캐시에 모아두었다가 한번에 저장하기 때문에 데이터베이스의 부하를 크게 줄일 수 있다.
▪
항상 캐시를 통해 저장하기 때문에, 캐시의 데이터 정합성이 지켜진다.
▪
데이터베이스에 장애가 발생하더라도 캐시를 통해 지속적인 서비스 제공이 가능하다.
◦
단점
▪
캐시에 자주 사용되지 않는 불필요한 리소스를 저장하게 된다.
▪
캐시 서버가 장애가 발생하면, 그 사이의 저장되지 않은 데이터가 유실될 수 있다.
•
Write-Through
◦
Read-Through의 쓰기 방식으로 캐시에 데이터를 저장 후, 캐시가 직접 데이터베이스에 쓰기 작업을 수행한다.
◦
장점
▪
캐시 서버 장애로 인해 데이터 유실이 발생하지 않는다.
▪
DB와 캐시가 항상 동기화 되어있어 캐시의 데이터가 항상 최신의 상태로 유지된다.
◦
단점
▪
캐시에 자주 사용되지 않는 불필요한 리소스를 저장하게 된다.
▪
매 요청마다 캐시 쓰기 + 데이터베이스 쓰기의 2번 요청이 발생하기 때문에, 쓰기 작업의 성능이 상대적으로 느리다.
•
Write-Around
◦
write-around는 쓰기 작업 시 캐시를 우회(around)하여 데이터베이스에 직접 접근하여 데이터 쓰기 작업을 수행한다.
◦
장점
▪
재사용되지 않거나 자주 사용되지 않는 데이터가 캐시에 차지되지 않는다.
▪
데이터베이스에 직접 저장하므로, 캐시 서버 장애가 발생하더라도 데이터 유실이 없으며 서비스 장애로 이어지지 않는다.
◦
단점
▪
쓰기 작업 시 캐시를 업데이트하지 않으므로, 해당 캐시가 만료되기까지 데이터 정합성이 맞지 않는 상태가 된다.
•
Write-Invalidate
◦
write-around 전략의 단점을 보완한 방식으로, 쓰기 작업 시 데이터베이스에 직접 저장 후 해당 데이터의 캐시를 무효화 시키는 전략이다.
◦
장점
▪
write-around의 장점을 그대로 가져가면서도, 다음 읽기 요청 시 캐시 미스를 강제로 유발시켜 최신 데이터를 읽을 수 있도록 유지한다.
◦
단점
▪
쓰기 작업 이후에는 해당 캐시 무효화 되기 때문에, 쓰기 작업이 자주 발생하는 경우 캐시 미스가 자주 발생할 수 있다.
캐시 만료 전략
캐시의 경우 만료 정책이 제대로 설정되어 있지 않다면, 데이터가 변경되었음에도 오래된 정보가 캐싱되어있을 가능성이 있다. 이를 대비해 캐시를 만료시켜, 원래의 데이터 저장소에서 데이터를 새로 캐싱하여 정합성을 맞추는 것이 좋다.
하지만 캐시 만료 주기가 너무 짧다면 데이터가 자주 제거 되고, 새로운 데이터를 자주 캐싱해야 하기 때문에 캐시를 사용하는 이점이 줄어든다. 반대로 너무 길다면 메모리가 부족해지거나, 오래된 캐시로 인해 정합성이 안맞는 등의 문제가 생길 수 있다. 따라서 적절한 캐시 만료 정책을 설정하는 것이 중요하다.
단순히 시간에 따라 만료를 정할수도 있지만, 다양한 알고리즘에 따라 구현될 수도 있다. 아래는 운영체제의 페이지 교체 알고리즘의 예시로, 이를 참조해 캐시 만료 전략을 선택하는 것도 가능하다.
•
Random : 임의의 데이터를 삭제/교체
•
FIFO(First-In, First-Out) : 캐시에 가장 오래있었던 데이터를 삭제/교체
•
LFU(Least Frequently Used) : 캐시 내 사용 횟수가 가장 적은 데이터를 삭제/교체
•
LRU(Least Recently Used) : 캐시 내 가장 오랫동안 사용되지 않은 데이터를 삭제/교체
•
NRU(Not Recently Used) : 참조와 수정의 플래그 비트를 두고, 참조+수정 비트로 00 - 01 - 10 - 11의 순으로 우선순위를 두고 데이터를 삭제/교체
•
SCR(Second Chance Replacement) : 참조 플래그 비트를 두고, FIFO 기법을 수행하며 참조 비트가 1이면 삭제/교체하는 대신 FIFO 리스트의 맨 뒤로 다시 넣기
•
Optimal : 향후 가장 사용되지 않을 데이터를 삭제/교체(이상적이지만 구현이 거의 불가능하다)
실제로 Redis의 경우 자체적으로 다음과 같이 다양한 알고리즘으로 만료 정책을 지원하고, 캐시 만료 알고리즘을 직접 구현할 필요없이 이 중 하나를 선택해 설정할 수 있다.
•
noeviction : 메모리가 고갈된 경우 새로운 쓰기 작업을 하려고 할 때 에러를 반환한다.
•
allkeys-lru : 가장 최근에 사용되지 않은 키를 삭제한다.
•
allkeys-random : 키 공간에서 임의의 키를 삭제한다.
•
allkeys-lfu : 사용빈도가 적은 키를 삭제한다.
•
volatile-lru : 만료기간이 설정된 키 중에서 가장 최근에 사용되지 않은 키를 삭제한다.
•
volatile-random : 만료기간이 설정된 키 중에서 임의의 키를 삭제한다.
•
volatile-lfu : 만료기간이 설정된 키 중에서 사용빈도가 적은 키를 삭제한다.
•
volatile-ttl : 만료기간이 설정된 키 중에서 ttl 이 가장 짧은것을 삭제한다.
캐시 문제
Cache Stempede(캐시 쇄도)
캐시 미스가 발생하면 데이터베이스에서 직접 조회하여 캐시에 저장하게 되는데, 위 그림처럼 동시에 많은 수의 키가 동시에 만료된다면 연이어서 캐시 미스가 발생해 데이터베이스를 조회하게 된다. 이렇게 데이터베이스에 순간적으로 부담이 몰리는 현상을 Cache Stempede라 부르고, 이러한 현상은 이벤트나 매일 자정 캐시를 갱신하는 스케줄러와 같이 동시에 많은 캐시가 만료되는 상황에서 발생한다.
이를 해결하기 위해 전자공학에서 짧은 지연을 의미하는 Jitter(지터) 개념 사용해, 캐시 만료 시간을 무작위로 조금 지연시켜 한 번에 다수의 키가 만료되지 않도록 하여 데이터베이스 부하를 균등하게 분산시킬 수 있다.
지터 시간이 너무 길어지면 그만큼 사용자가 오래된 데이터를 보게되고, 너무 짧으면 데이터베이스 부하 분산이 제대로 이뤄지지 않는다. 따라서 서비스별로 최대 지터 시간을 잘 설정할 필요가 있다.
핫키 만료
많은 요청이 몰려 캐시 적중률이 높은 키를 핫키라고 부르는데, 핫키가 만료되는 경우 순간적으로 여러 요청이 동시에 데이터베이스로 들어가 반복적으로 조회하고 캐시에 덮어쓰는 현상이 발생할 수 있다. 핫키 만료 상황도 Cache Stempede의 일종으로, 순간적으로 데이터베이스에 부하가 걸리고 캐시에 중복된 데이터 쓰기가 발생하게 된다.
이를 해결하는 방법은 다양한데, 핫키의 만료 기한을 없애거나, LRU/LFU의 캐시 만료 알고리즘을 사용하거나, 핫키에 대한 데이터베이스 조회에 분산 락을 적용하는 등으로 해결할 수 있다. 다만 핫키의 경우 시간이 지나거나 환경이 변하여 더 이상 핫키가 아니게 되는 경우가 있을 수 있기 때문에, 만료 기한을 없애는 것은 위험할 수 있다.
Cache Penetration(캐시 관통)
일반적으로 캐시 미스 시 반환 값으로 null을 받아 없음을 확인하고, 이후 데이터베이스에서 해당 값을 조회한다. 만약 데이터베이스에도 해당 값이 없어 null을 반환 받는다면, 대부분 캐시를 채우지 않도록 구현하는 경우가 많다. 하지만 그렇게되면 값이 없다는 것에 대한 정보가 캐시에 없으니, 해당 조회가 빈번하게 발생한다면 불필요한 데이터베이스 조회 요청이 자주 발생하게 된다.
캐시 시스템 장애
캐시 시스템에 장애가 발생한 경우, 데이터베이스를 통해 정상적으로 서비스 하는 것이 가능하다. 하지만 대부분의 캐시 시스템을 적용한 서비스는 데이터베이스의 부하를 줄이기 위해 사용하는 것이 목적이기 때문에, 트래픽이 몰리는 시간대라면 그로 인해 병목현상이나 서비스 장애까지 이어질 수 있다.
위 문제는 캐시 시스템에 Failover(대체 작동)을 적용하여, 캐시 시스템에 장애가 발생한다면 서킷 브레이커 등을 통해 캐시 시스템을 대체하지만 핵심 기능 위주로 더 적은 기능을 제공하는 대체 서버를 띄우는 것이다. 이를 통해 캐시 시스템의 장애가 해결될 때까지 서비스를 중단하지 않으면서도 시간을 벌 수 있게 된다.