캐시 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술입니다. 일반적으로 캐시는 메모리를 사용하기 때문에 데이터베이스보다 훨씬 빠르게 데이터를 응답할 수 있어 이용자에게 빠르게 서비스를 제공할 수 있습니다. 하지만 기본적으로 RAM의 용량은 커봐야 16 ~32G 정도라, 데이터를 모두 캐시에 저장해 버리면 용량 부족 현상이 일어나 시스템이 다운될 수 있습니다. 따라서 어느 종류의 데이터를 캐시에 저장할지, 얼마큼 데이터를 캐시에 저장할지, 얼마나 오래된 데이터를 캐시에서 제거하는지에 대한 '지침 전략'을 숙지할 필요가 있습니다.
💡 참고
캐시를 효율적으로 이용하기 위해서는 캐시에 저장할 데이터의 특성도 고려해야 합니다. 예를 들어 자주 조회되는 데이터, 결과값이 자주 변동되지 않고 일정한 데이터, 조회하는데 연산이 필요한 데이터를 캐싱해 두면 좋습니다.
💡캐시 용어
- cache hit : 캐시 스토어(redis)에 데이터가 있을 경우 바로 가져옴 (빠름) - cache miss : 캐시 스토어(redis)에 데이터가 없을 경우 DB에서 가져옴 (느림)
2. 캐싱 전략 패턴 종류
캐시를 이용하게 되면 반드시 오는 문제점이 있는데 바로 데이터 정합성 문제입니다. 이전에는 그냥 DB에서 데이터 조회와 작성을 처리하였기 때문에 데이터 정합성 문제가 나타나지 않았지만, 캐시라는 또 다른 데이터 저장소를 이용하기 때문에, 결국 같은 종류의 데이터라도 두 저장소에서 저장된 값이 서로 다를 수 있는 현상이 일어날 수밖에 없습니다.
따라서 적절한 캐시 읽기 전략(Read Cache Strategy)와 캐시 쓰기 전략(Write Cache Strategy)을 통해, 캐시와 DB 간의 데이터 불일치 문제를 극복하면서도 빠른 성능을 잃지 않게 하기 위해 고심히 연구할 필요가 있습니다.
a. 캐시 읽기 전략 (Read Cache Strategy)
a-1. Look Aside 패턴
Cache Aside 패턴이라고도 불림
데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략 만일 캐시에 데이터가 없으면 DB에서 조회함
반복적인 읽기가 많은 호출에 적합
캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장
캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음 만일 redis가 다운되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체는 문제가 없음
대신에 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간 순각적으로 DB로 몰려서 부하 발생
Look Aside Cache 패턴은 애플리케이션에서 캐싱을 이용할 때 일반적으로 사용되는 기본적인 캐시 전략입니다.
이 방식은 캐시에 장애가 발생하더라도 DB에 요청을 전달함으로써 캐시 장애로 인한 서비스 문제는 대비할 수 있지만, Cache Store(Redis)와 Data Store(DB) 간 정합성 문제가 발생할 수 있으며, 초기 조회 시 무조건 Data Store를 호출해야 하므로 단건 호출 빈도가 높은 서비스에 적합하지 않습니다. 대신 반복적으로 동일 쿼리를 수행하는 서비스에 적합한 아키텍처입니다.
이런 경우 DB에서 캐시로 데이터를 미리 넣어주는 작업을 하기도 하는데 이를 Cache Warming이라고 합니다.
💡 Cache Warming
미리 cache로 db의 데이터를 밀어 넣어두는 작업을 의미합니다. 이 작업을 수행하지 않으면 서비스 초기에 트래픽 급증 시 대량의 cache miss 가 발생하여 데이터베이스 부하가 급증할 수 있습니다. 다만, 캐시 자체는 용량이 작아 무한정으로 데이터를 들고 있을 수는 없어 일정 시간이 지나면 expire 되는데 그러면 다시 Thundering Herd가 발생될 수 있기 때문에 캐시의 TTL을 잘 조정할 필요가 있습니다.
💡Thundering Herd
여러 프로세스나 클라이언트가 동시에 동일한 자원을 요청해 시스템 성능이 저하되는 현상입니다. 우연히 동시에 많은 사용자들이 캐시가 비워져있을 때 조회를 하게 되면 Cache Miss가 발생하고 동시에 RDB에 조회를 요청합니다. 이러면 데이터베이스에 높은 부하가 발생합니다.
a-2. Read Through 패턴
캐시에서만 데이터를 읽어오는 전략
Look Aside 와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이가 있음
따라서 데이터를 조회하는데 있어 전체적으로 속도가 느림
또한 데이터 조회를 전적으로 캐시에만 의지하므로, redis가 다운될 경우 서비스 이용에 차질이 생길 수 있음
대신에 캐시와 DB간의 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있음
역시 읽기가 많은 워크로드에 적합
Read Through 방식은 Cache Aside 방식과 비슷하지만, Cache Store에 저장하는 주체가 Server이냐 또는 Data Store 자체이냐에서 차이점이 있습니다. 이 방식은 직접적인 데이터베이스 접근을 최소화하고 Read에 대한 소모되는 자원을 최소화할 수 있습니다.
하지만 캐시에 문제가 발생했을 경우 이는 바로 서비스 전체 중단으로 빠질 수 있습니다. 그렇기 때문에 redis와 같은 구성 요소를 Replication 또는 Cluster로 구성하여 가용성을 높여야 합니다.
b. 캐시 쓰기 전략 (Write Cache Strategy)
b-1. Write Back 패턴
Write Behind 패턴이라고도 불림
캐시와 DB 동기화를 비동기하기 때문에 동기화 과정이 생략
데이터를 저장할 때 DB에 바로 쿼리하지 않고 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
캐시에 모아놨다가 DB에 쓰기 때문에 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
데이터 정합성 확보
자주 사용되지 않는 불필요한 리소스 저장
캐시에서 오류가 발생하면 데이터를 영구 소실함
Write Back 방식은 데이터를 저장할 때 DB가 아닌 먼저 캐시에 저장하여 모아놓았다가 특정 시점마다 DB로 쓰는 방식으로 캐시가 일종의 Queue 역할을 겸하게 됩니다. 캐시에 모았다가 한 번에 DB에 저장하기 때문에 DB 쓰기 횟수 비용과 부하를 줄일 수 있지만 데이터를 옮기기 전에 캐시 장애가 발생하면 데이터 유실이 발생할 수 있다는 단점이 존재합니다. 하지만 오히려 반대로 데이터베이스에 장애가 발생하더라도 지속적인 서비스를 제공할 수 있도록 보장하기도 합니다.
이 전략 또한 캐시에 Replication이나 Cluster 구조를 적용함으로써 Cache 서비스의 가용성을 높이는 것이 좋으며, 캐시 읽기 전략인 Read-Through와 결합하면 가장 최근에 업데이트된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합합니다.
b-2. Write Through 패턴
데이터베이스와 Cache에 동시에 데이터를 저장하는 전략
데이터를 저장할 때 먼저 캐시에 저장한 다음 바로 DB에 저장 (모아놓았다가 나중에 저장이 아닌 바로 저장)
Read Through 와 마찬가지로 DB 동기화 작업을 캐시에게 위임
DB와 캐시가 항상 동기화 되어 있어, 캐시의 데이터는 항상 최신 상태로 유지
캐시와 백업 저장소에 업데이트를 같이하여 데이터 일관성을 유지할 수 있어 안정적
데이터 유실이 발생하면 안되는 상황에 적합
자주 사용되지 않는 불필요한 리소스 저장
매 요청마다 두번의 Write가 발생하게 됨으로써 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈 발생
기억장치 속도가 느릴 경우, 데이터를 기록할 때 CPU가 대기하는 시간이 필요하기 때문에 성능 감소
Write Through 패턴은 Cache Store에도 반영하고 Data Store에도 동시에 반영하는 방식입니다. 그래서 항상 동기화가 되어 있어 항상 최신정보를 가지고 있다는 장점이 있습니다. 하지만 결국 저장할 때마다 2단계 과정을 거쳐가기 때문에 상대적으로 느리며, 무조건 일단 Cache Store에 저장하기 때문에 캐시에 넣은 데이터를 저장만 하고 사용하지 않을 가능성이 있어서 리소스 낭비 가능성이 있습니다.
💡 참고
write through 패턴과 write back 패턴 둘 다 모두 자주 사용되지 않는 데이터가 저장되어 리소스 낭비가 발생되는 문제점을 안고 있기 때문에 이를 해결하기 위해 TTL을 꼭 사용되지 않는 데이터를 반드시 삭제해야 합니다.
b-3. Write Around 패턴
Write Through 보다 훨씬 빠름
모든 데이터는 DB에 저장 (캐시를 갱신하지 않음)
Cache miss가 발생하는 경우에만 DB와 캐시에도 데이터를 저장
따라서 캐시와 DB 내의 데이터가 다를 수 있음 (데이터 불일치)
Write Around 패턴은 속도가 빠르지만 Cache miss 가 발생하기 전에 데이터베이스에 저장된 데이터가 수정되었을 때, 사용자가 조회하는 cache와 데이터베이스 간의 데이터 불일치가 발생하게 됩니다. 따라서 데이터베이스에 저장된 데이터가 수정, 삭제될 때마다 Cache 또한 삭제하거나 변경해야 하며 Cache의 expire를 짧게 조정하는 식으로 대처해야 합니다.
💡 참고
Write Around 패턴은 주로 Look Aside, Read Through 와 결합해서 사용됩니다. 데이터가 한 번 쓰여지고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공합니다.
c. 캐시 읽기 + 쓰기 전략 조합
c-1. Look Aside + Write Around 조합
가장 일반적으로 자주 쓰이는 조합
c-2. Read Through + Write Around 조합
항상 DB에 쓰고, 캐시에서 읽을 때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 완전 장치를 구성할 수 있음
c-3. Read Through + Write Through 조합
데이터를 쓸때 항상 캐시에 먼저 쓰므로, 읽어올 때 최신 캐시 데이터 보장
데이터를 쓸때 항상 캐시에서 DB로 보내므로, 데이터 정합성 보장
3. 캐시 지침
a. 캐시 저장 방식 지침
캐시 솔루션은 자주 사용되면서 자주 변경되지 않는 데이터의 경우 캐시 서버에 적용하여 반영할 경우 높은 성능 향상을 이뤄낼 수 있습니다. 이를 Cache Hit Rating이라고 합니다.
일반적으로 캐시는 메모리에 저장되는 형태를 선호합니다.
메모리 저장소로는 대표적으로 Redis와 MemCached가 있으며 이와 같은 솔루션은 메모리를 1차 저장소로 사용하기 때문에 디스크와 달리 제약적인 저장 공간을 사용하게 됩니다.
많아야 수십기가 정도의 저장소를 보유하게 되며, 이는 결국 자주 사용되는 데이터를 어떻게 뽑아 캐시에 저장하고 자주 사용하지 않는 데이터를 어떻게 제거해 나갈 것이냐를 지속적으로 고민해야 할 필요성이 있습니다. 따라서 캐시를 저장하는 시점은 자주 사용되며 자주 변경되지 않는 데이터를 기준으로 하는 것이 좋습니다.
또한 한가지 고민해야 할 사항은 캐시 솔루션은 언제든지 데이터가 날아갈 수 있는 휘발성을 기본으로 한다는 점입니다.
이는 데이터를 주기적으로 디스크에 저장함으로써 어느 정도 해결을 볼 수는 있지만, 실시간으로 모든 데이터를 디스크에 저장할 경우 성능 저하를 일으킬 수 있어 어느 정도 데이터 수집과 저장 주기를 가지도록 설계해야 합니다. 즉 데이터의 유실 또는 정합성이 일정 부분 깨질 수 있다는 점을 항상 고려해야 합니다.
따라서 캐시에 저장되는 데이터는 중요한 정보, 민감 정보 등은 저장하지 않는 것이 좋으며, 캐시 솔루션이 장애가 발생했을 경우 적절한 대응방안을 모색해 두는 것이 바람직합니다.
💡 1차 저장소 vs 2차 저장소
- 1차 저장소: 메모리(RAM) - 빠르지만 비싸고 제한적 - 2차 저장소: 디스크(HDD/SSD) - 느리지만 저렴하고 대용량
b. 캐시 제거 방식 지침
캐시 데이터의 경우 캐시 서버에만 단독으로 저장되는 경우도 있지만, 대부분 영구 저장소에 저장된 데이터의 복사본으로 동작하는 경우가 많습니다. 이는 2차 저장소(영구 저장소)에 저장되어 있는 데이터와 캐시 솔루션의 데이터를 동기화하는 작업이 반드시 필요함을 의미하며, 개발 과정에 고려사항이 늘어난다는 점을 반드시 기억해야 합니다. 예를 들어 캐시 서버와 데이터베이스에 저장되는 데이터의 commit 시점에 대한 고려 등이 예가 될 수 있습니다. 캐시의 만료 정책이 제대로 구현되지 않은 경우 클라이언트는 데이터가 변경되었음에도 오래된 정보가 캐싱 되어있어 오래된 정보를 사용할 수 있다는 문제점이 발생합니다.
따라서 캐시를 구성할 때 기본 만료 정책을 설정해야 합니다. 캐시된 데이터가 기간 만료 되면 캐시에서 데이터가 제거되고, 응용 프로그램은 원래 데이터 저장소에서 데이터를 검색해야 합니다. 그래서 캐시만료 주기가 너무 짧으면 데이터는 너무 빨리 제거되고 캐시를 사용하는 이점은 줄어듭니다. 반대로 너무 기간이 길면 데이터가 변경될 가능성과 메모리 부족 현상이 발생하거나, 자주 사용되어야 하는 데이터가 제거되는 등의 역효과를 나타낼 수도 있습니다.
Cache Stampede 현상
대규모 트래픽 환경에서 TTL 값이 너무 작게 설정하면 cache stampede 현상이 발생할 가능성이 있습니다. Look-Aside 패턴에서 redis에 데이터가 없다는 응답을 받은 서버(cache miss)가 직접 DB로 데이터 요청한 뒤, 다시 redis에 저장하는 과정을 거칩니다.
그런데 위 상황에서 key가 만료되는 순간 많은 서버에서 이 key를 같이 보고 있었다면 모든 어플리케이션 서버에서 DB로 가서 찾게 되는 duplicate read가 발생합니다. 또 읽어온 값을 각각 redis에 쓰는 duplicate write도 발생되어, 처리량도 다같이 느려질 뿐 아니라 불필요한 작업이 굉장히 늘어나 요청량 폭주로 장애가 이어질 가능성이 있습니다.
c. 캐시 공유 방식 지침
캐시는 애플리케이션의 여러 인스턴스에서 공유하도록 설계됩니다. 그래서 각 애플리케이션 인스턴스가 캐시에서 데이터를 읽고 수정할 수 있습니다. 따라서 어느 한 애플리케이션이 캐시에 보유하는 데이터를 수정해야 하는 경우, 애플리케이션의 한 인스턴스가 만드는 업데이트가 다른 인스턴스가 만든 변경을 덮어쓰지 않도록 해야 합니다. 그렇지 않으면 데이터가 정합성 문제가 발생하기 때문입니다. 데이터의 충돌을 방지하기 위해 다음과 같은 애플리케이션 개발 방식을 취해야 합니다.
먼저, 캐시 데이터를 변경하지 직전에 데이터가 검색된 이후 변경되지 않았는지 일일히 확인하는 방법입니다. 변경되지 않았다면 즉시 업데이트하고 변경되었다면 업데이트 여부를 애플리케이션 레벨에서 결정하도록 수정해야 합니다.
두 번째로 캐시 데이터를 업데이트하기 전에 Lock을 잡는 방식입니다. 이와 같은 경우 조회성 업무를 처리하는 서비스에 Lock으로 인한 대기현상이 발생합니다. 이 방식은 데이터의 사이즈가 작아 빠르게 업데이트가 가능한 업무와 빈번한 업데이트가 발생하는 상황에 적용하기 용이합니다.
d. 캐시 가용성 지침
캐시를 구성하는 목적은 빠른 성능 확보와 데이터 전달에 있으며, 데이터의 영속성을 보장하기 위함이 아니라는 점을 기억하고 설계해야 합니다. 데이터의 영속성은 기존 데이터 스토어에 위임하고, 캐시는 데이터 읽기에 집중하는 것이 성능 확보의 지침 사항이 될 수 있습니다. 또한, 캐시 서버가 장애로 인해 다운되었을 경우나 서비스가 불가능할 경우에도 지속적인 서비스가 가능해야 합니다. 이는 캐시에 저장되는 데이터는 결국 기존 영구 데이터 스토어에 동일하게 저장되고 유지된다는 점을 뒷받침 하는 설계방식입니다. (Write Through) 즉, 캐시 서버가 장애로 부터 복구되는 동안 성능상의 지연은 발생할 수 있지만, 서비스가 불가능한 상태가 되지 않도록 고려해야 한다는 말입니다.