cache-aside
애플리케이션이 캐시를 직접 챙기는 방식입니다. 먼저 캐시에 물어보고 없으면 원본에서 가져옵니다. 가져온 값을 캐시에 넣는 일도 애플리케이션이 합니다. 캐시는 원본이 어디 있는지 모릅니다.
쉽고 빠른 이해
애플리케이션이 캐시를 직접 챙기는 방식입니다. 값을 읽을 때 캐시에 먼저 물어보고, 없으면 데이터베이스에서 가져와 캐시에 넣어 둡니다.
캐시가 원본을 모르면 그 사이를 누군가는 이어야 합니다. 캐시에 그 일을 하는 부품이 없으면 남는 것은 애플리케이션뿐입니다.
어떻게 도나:
- 캐시에 물어봅니다. 값이 있으면 그대로 씁니다
- 없으면 데이터베이스에서 가져와 캐시에 넣고 돌려줍니다
- 값을 고칠 때는 데이터베이스에 쓰고, 캐시의 그 항목은 지우거나 새 값으로 갈아 둡니다
대가도 있습니다. 캐시에 없는 값을 처음 읽는 요청은 캐시와 데이터베이스와 캐시로 세 번을 오갑니다. 값을 고친 직후에 읽는 쪽은 그 항목이 비어 있어 한 번 더 기다립니다.
상세
cache-aside 에서는 애플리케이션 코드가 캐시를 직접 씁니다. 시스템 오브 레코드에 접근하는 코드가 먼저 캐시에 물어야 한다는 뜻입니다. 캐시에 값이 있으면 그것을 돌려주고, 없으면 원본에서 가져와 캐시에 넣습니다. 데이터는 미리 실리지 않고 필요해진 시점에 실립니다. 같은 절차를 lazy loading 이라는 다른 이름으로 부르기도 합니다.
이 방식이 필요한 까닭은 캐시가 원본을 모르기 때문입니다. 많은 상용 캐싱 시스템은 read-through 와 write-through 또는 write-behind 를 제공합니다. 그런 시스템에서 애플리케이션은 캐시만 참조하고, 캐시가 데이터 저장소에서 값을 가져와 채우고 캐시에 가한 변경을 데이터 저장소로 되씁니다. 그 조합을 cache-as-sor 라 부릅니다. 그런 부품이 없는 캐시에서는 애플리케이션이 그 일을 직접 맡습니다. 그 자리가 cache-aside 입니다.
읽기 경로는 이렇게 돕니다.
sequenceDiagram
autonumber
participant 앱
participant 캐시
participant 저장소
앱->>캐시: 읽어 본다
Note over 앱,캐시: 값이 있으면 여기서 끝난다
캐시-->>앱: 없다
앱->>저장소: 가져온다
저장소-->>앱: 값
앱->>캐시: 넣어 둔다
애플리케이션은 캐시에서 읽어 보는 것으로 항목이 캐시에 있는지 판정합니다. 항목이 캐시에 없는 것이 캐시 미스입니다. 미스면 애플리케이션이 데이터 저장소에서 항목을 가져옵니다. 가져온 항목을 캐시에 더한 다음 호출자에게 돌려줍니다. 캐시는 미스일 때 널을 돌려줍니다.
쓰기 경로는 모양이 다릅니다. 애플리케이션이 정보를 갱신하면 변경을 데이터 저장소에 씁니다. 그다음 캐시를 어떻게 하느냐는 문서가 갈립니다. 한쪽은 캐시의 해당 항목을 무효화합니다. 그 항목이 다시 필요해지면 데이터 저장소에서 갱신된 값을 가져와 캐시에 더합니다. 캐시에 새 값을 곧바로 써 넣지 않고, 지우고 다음 읽기가 채우게 두는 것입니다. 다른 쪽은 쓰기에서 데이터 저장소와 캐시를 함께 갱신해야 한다고 적고, 저장소에 쓴 다음 캐시에도 넣는 의사코드를 싣습니다.
무효화하는 이 성질이 cache-aside 를 write-through 캐싱과 가르는 지점입니다. write-through 는 한 번의 쓰기에서 데이터 저장소와 캐시를 함께 갱신하기 때문입니다. 두 문서가 이 이름으로 가리키는 쓰기가 다릅니다.
대가
얻는 것
- 요청된 데이터만 캐시에 들어옵니다. 대부분의 데이터는 한 번도 요청되지 않으므로, 요청되지 않는 데이터로 캐시가 차는 것을 피합니다.
- 어느 데이터가 필요할지 미리 정하지 않아도 됩니다. 수요를 미리 알 수 없는 자리에서도 캐시가 채워집니다.
- 노드가 죽고 새 빈 노드로 바뀌어도 애플리케이션은 계속 동작합니다. 캐시에 없으면 데이터 저장소에서 가져오는 경로가 원래 서 있기 때문입니다. 대신 그동안 지연이 늘어납니다.
- 원본을 읽는 부품을 캐시에 설정할 수 없는 자리에서도 섭니다. 그 일을 애플리케이션 코드가 하기 때문입니다.
내주는 것
- 캐시를 채우고 지우는 일이 애플리케이션 코드로 들어옵니다. read-through 를 제공하는 캐시라면 캐시가 하던 일입니다. 캐시를 읽는 자리마다 미스 분기가 붙고, 데이터 저장소에 쓰는 자리마다 무효화 호출이 붙습니다.
- 미스에는 값이 붙습니다. 캐시 미스마다 세 번을 오갑니다. 캐시에 처음 물어보고, 데이터베이스에 질의하고, 받아온 데이터를 캐시에 씁니다. 그 왕복이 데이터가 애플리케이션에 닿는 데 눈에 띄는 지연을 만들 수 있습니다. 미스가 잦을수록 왕복도 늘어납니다.
- 낡은 데이터가 따라옵니다. 캐시 미스일 때만 캐시에 데이터를 쓰면, 데이터베이스에서 값이 바뀌어도 캐시는 갱신되지 않습니다. 이 문제는 write-through 와 TTL(Time To Live, 생존 시간) 전략으로 다룰 수 있습니다.
- 일관성을 보장하지 않습니다. 외부 프로세스가 언제든 데이터 저장소의 항목을 바꿀 수 있고, 그 변경은 항목이 다시 실릴 때까지 캐시에 나타나지 않습니다. 여러 데이터 저장소에 복제하는 시스템에서는 동기화가 잦을수록 지키기가 까다로워집니다.
- 쓰기 직후의 신선도가 사라집니다. 쓰기에서 캐시 항목을 무효화하고 다음 읽기에서 다시 채우기 때문입니다. 읽기가 많은 경로에서 그 신선도가 필요하면 write-through 캐싱이 대안입니다.
예시
Amazon ElastiCache 의 lazy loading 의사코드
get_customer(customer_id)
customer_record = cache.get(customer_id)
if (customer_record == null)
customer_record = db.query("SELECT * FROM Customers WHERE id = {0}", customer_id)
cache.set(customer_id, customer_record)
return customer_record
캐시에서 먼저 꺼내 봅니다. 널이면 데이터베이스에 질의하고 그 결과를 캐시에 넣습니다. 호출하는 쪽
코드는 customer_record = get_customer(12345) 한 줄입니다. 부르는 쪽에는 캐시가 보이지 않습니다.
Azure 문서의 StackExchange.Redis 구현
var key = $"MyEntity:{id}";
var cache = Connection.GetDatabase();
var json = await cache.StringGetAsync(key).ConfigureAwait(false);
var value = string.IsNullOrWhiteSpace(json)
? default(MyEntity)
: JsonConvert.DeserializeObject<MyEntity>(json);
if (value == null) // Cache miss
{
value = ...;
if (value != null)
{
await cache.StringSetAsync(key, JsonConvert.SerializeObject(value)).ConfigureAwait(false);
await cache.KeyExpireAsync(key, TimeSpan.FromMinutes(DefaultExpirationTimeInMinutes)).ConfigureAwait(false);
}
}
키는 MyEntity:{id} 처럼 메서드와 인자로 만듭니다. StringGetAsync 가 빈 값을 주면 미스입니다.
데이터 저장소에서 읽는 코드는 저장소마다 달라 예제에서 빠져 있습니다. 널은 캐시에 넣지 않습니다.
넣은 항목에는 KeyExpireAsync 로 만료를 걸어 둡니다. 예제의 기본값은 5분입니다.
갱신은 반대편입니다.
await this.store.UpdateEntityAsync(entity).ConfigureAwait(false);
var cache = Connection.GetDatabase();
var key = $"MyEntity:{entity.Id}";
await cache.KeyDeleteAsync(key).ConfigureAwait(false);
원래 데이터 저장소를 먼저 갱신합니다. 그 다음 KeyDeleteAsync 로 캐시 항목을 지웁니다.
Ehcache 의 cache-aside 의사코드
v = cache.get(k)
if (v == null) {
v = sor.get(k)
cache.put(k, v)
}
v = newV
sor.put(k, v)
cache.put(k, v)
읽기는 캐시를 먼저 보고 없으면 시스템 오브 레코드에서 가져와 캐시에 넣습니다. Ehcache 문서는 쓰기에서
시스템 오브 레코드와 캐시를 함께 갱신하는 의사코드를 적습니다. 같은 문서가 read-through 와
write-through 를 원하면 CacheLoaderWriter 를 쓰는 cache-as-SoR 로 가라고 갈래를 나눕니다.
Django 의 저수준 캐시 API
cache.set("my_key", "hello, world!", 30)
cache.get("my_key")
cache.get_or_set("my_new_key", "my new value", 100)
cache.set 의 세 번째 인자가 값을 담아 둘 초입니다. 생략하면 CACHES 설정의 백엔드 타임아웃을
씁니다. None 을 넘기면 영원히 캐시하고 0 을 넘기면 캐시하지 않습니다. 객체가 캐시에 없으면
cache.get 은 None 을 돌려줍니다. 이 조합이 cache-aside 의 최소 형태입니다. cache.get_or_set 은
키가 없을 때 기본값을 그 키의 새 캐시 값으로 넣습니다.
실패
무효화 순서가 뒤집히면 낡은 값이 캐시로 도로 들어옵니다. Cloud Design Patterns 는 순서가 중요하다고
못 박습니다.
데이터 저장소를 갱신한 다음에 캐시 항목을 지워야 합니다. 캐시 항목을 먼저 지우면 데이터 저장소가
갱신되기 전에 클라이언트가 항목을 가져갈 작은 시간 창이 생깁니다.
sequenceDiagram
participant 앱
participant 캐시
participant 저장소
participant 클라이언트
앱->>캐시: 항목 삭제
클라이언트->>캐시: 항목 읽기
캐시-->>클라이언트: 없음
클라이언트->>저장소: 항목 조회
저장소-->>클라이언트: 아직 안 바뀐 값
클라이언트->>캐시: 낡은 값 넣기
앱->>저장소: 항목 갱신
항목이 캐시에 없으므로 그 가져가기는 캐시 미스가 됩니다. 미스 때문에 애플리케이션은 데이터 저장소에서 낡은 항목을 읽어 캐시에 도로 넣습니다. 이 순서가 캐시에 낡은 데이터를 남깁니다.
깨지는 조건들입니다.
| 조건 | 그때 벌어지는 일 |
|---|---|
| 쓰기와 다음 읽기 사이에 읽는다 | 읽는 쪽이 캐시 미스를 겪거나 잠깐 낡은 값을 봅니다 |
| 외부 프로세스가 데이터 저장소를 바꾼다 | 그 변경은 항목이 다시 실릴 때까지 캐시에 나타나지 않습니다 |
| 캐시를 지우고 데이터 저장소를 나중에 고친다 | 그 사이 읽기가 낡은 값을 캐시에 도로 넣습니다 |
| 인스턴스마다 로컬 인메모리 캐시를 둔다 | 인스턴스마다 같은 데이터의 사본을 따로 갖습니다. 캐시 사이가 금세 어긋날 수 있습니다 |
| 노드가 죽고 빈 노드로 교체된다 | 애플리케이션은 계속 동작합니다. 대신 지연이 늘어납니다 |
| 요청 대부분이 캐시 히트를 겪지 못한다 | 캐시를 확인하고 채우는 부담이 캐싱의 이점을 넘어설 수도 있습니다 |
로컬 캐시는 사설입니다. 그래서 서로 다른 애플리케이션 인스턴스가 같은 캐시 데이터의 사본을 각자 가질 수 있습니다. 같은 문서는 이런 자리에서 사설 캐시의 데이터를 더 자주 만료시키고 갱신해야 할 수 있다고 적고, 공유 캐시나 분산 캐시를 쓰는 것을 고려하라고도 적습니다.
민감하거나 보안에 걸리는 데이터는 애초에 이 방식에 맞지 않습니다. 여러 애플리케이션이나 여러 사용자가 캐시를 공유할 때 특히 그렇습니다. Azure Architecture Center 는 이런 데이터는 언제나 원본에서 가져오라고 적습니다.
같은 항목이 만료된 순간 여럿이 한꺼번에 다시 채우려 드는 자리는 캐시 스탬피드와 썬더링 허드라는 이름 으로 따로 다룹니다. 이 항목의 출처들은 그 조건을 적지 않습니다.
운영
TTL 이 첫 손잡이입니다. ElastiCache 문서는 각 쓰기에 TTL 값을 붙이면 lazy loading 과 write-through 두 전략의 이점을 함께 가질 수 있다고 적습니다. 동시에 쓸데없는 데이터로 캐시가 어질러지는 것을 대체로 피할 수 있다고 적습니다. TTL 은 키가 만료되기까지 남은 초를 지정하는 정수 값입니다. Valkey 와 Redis 오픈 소스판은 이 값을 초나 밀리초로, Memcached 는 초로 지정합니다. 만료된 키를 읽으려 하면 키를 못 찾은 것처럼 취급됩니다. 데이터베이스에 질의가 가고 캐시가 갱신됩니다. 이 방식이 값이 낡지 않음을 보장하지는 않습니다. 다만 데이터가 너무 낡지 않게 하고 캐시의 값이 이따금 데이터베이스에서 갱신되도록 합니다.
만료 기간은 양쪽이 다 아픕니다. 만료 기간을 너무 짧게 잡으면 애플리케이션이 데이터 저장소에서 계속
데이터를 다시 가져와 캐시에 넣게 됩니다. 너무 길게 잡으면 캐시된 데이터가 낡습니다. Cloud Design Patterns 는 만료 정책을 그 데이터를 쓰는 애플리케이션의 접근 패턴에 맞추라고 적습니다. 캐싱은 비교적 정적인 데이터나
애플리케이션이 자주 읽는 데이터에 가장 잘 맞습니다. Azure Architecture Center 의 예제가 박아 둔 기본
만료는 5분입니다.
설정 단위도 정해야 합니다. 캐시 동작은 전역으로도, 캐시된 항목마다도 설정할 수 있습니다. 전역 축출 정책 하나가 모든 항목에 맞지 않을 수 있습니다. 가져오는 값이 비싼 항목은 개별로 설정합니다. 덜 자주 접근되더라도 캐시에 남겨 두는 편이 말이 되는 자리가 있습니다.
캐시 크기는 대개 원본보다 작습니다. 크기 한도를 넘으면 캐시가 데이터를 축출합니다. 대부분의 캐시는
가장 오래 안 쓰인 것을 고르는 정책을 씁니다. Redis 에서 그 손잡이는 maxmemory 와
maxmemory-policy 입니다.
maxmemory 100mb
CONFIG SET maxmemory 100mb
maxmemory 는 캐시 데이터에 쓸 메모리 상한입니다. redis.conf 에 적거나 CONFIG SET 으로 런타임에
바꿉니다. 0 으로 두면 상한이 없습니다. 64비트 시스템의 기본이 그것이고 32비트 시스템은 암묵적으로
3GB 를 씁니다. 상한에 닿으면 maxmemory-policy 가 무엇을 버릴지 정합니다. allkeys-lru 는 가장 오래
안 쓰인 키를, volatile-ttl 은 만료가 붙은 키 중 남은 TTL 이 가장 짧은 것을 버립니다. volatile- 로
시작하는 정책들은 만료가 붙은 키가 하나도 없으면 noeviction 처럼 동작합니다. Redis 문서는 일부
항목이 나머지보다 훨씬 자주 접근될 것으로 보면 allkeys-lru 를 쓰라고 적습니다. 어느 키가 축출 후보인지
코드가 짐작할 수 있고 거기에 짧은 TTL 을 붙일 수 있으면 volatile-ttl 을 쓰라고 적습니다.
시작할 때 캐시를 미리 채우는 자리도 있습니다. 많은 솔루션이 시작 처리의 일부로 애플리케이션이 쓸 법한 데이터를 캐시에 미리 넣습니다. 그 데이터의 일부가 만료되거나 축출된 뒤에도 cache-aside 는 여전히 쓸모가 있습니다.
관련 항목
cache-aside 대신 쓸 수 있는 캐싱 전략
read-through · write-through · write-behind · cache-as-SoR · CacheLoaderWriter
cache-aside 를 구현·채택한 소프트웨어
Redis · Valkey · Memcached · Ehcache · Amazon ElastiCache · Django · StackExchange.Redis
cache-aside 에서 자주 나는 오류·장애
캐시 미스 · 낡은 데이터 · 캐시 스탬피드 · 썬더링 허드
cache-aside 에 적용되는 규칙·원칙
cache-aside 운영이 고르는 축출 정책
maxmemory-policy · allkeys-lru · volatile-ttl · noeviction
다른 이름: 캐시 어사이드 · cache aside · lazy loading