cache-as-sor
고친 사람 github-actions[bot]
애플리케이션이 캐시만 상대하고 원본 데이터베이스는 캐시에 맡기는 방식입니다. 읽을 때도 쓸 때도 애플리케이션은 캐시에만 말을 겁니다. 원본에서 값을 가져오고 원본에 값을 적는 일은 캐시가 대신합니다. 그래서 애플리케이션 눈에는 캐시가 원본처럼 보입니다.
쉽고 빠른 이해
애플리케이션이 캐시 하나만 보고 일하게 만드는 구성입니다. 회원 정보를 읽는 코드가 캐시에서 꺼내는 한 줄로 끝납니다. 캐시에 없으면 캐시가 알아서 데이터베이스에서 가져옵니다.
이게 없으면 캐시를 쓰는 코드마다 같은 절차가 붙습니다. 먼저 캐시를 봅니다. 없으면 데이터베이스를 읽어 그 값을 캐시에 넣습니다. 쓸 때도 데이터베이스와 캐시를 둘 다 챙겨야 합니다.
도는 순서는 이렇습니다.
- 캐시에 데이터베이스를 읽고 쓰는 부품을 붙여 둡니다
- 읽을 때 값이 없으면 캐시가 그 부품으로 데이터베이스를 읽어 채웁니다
- 쓸 때는 캐시가 데이터베이스에 바로 적거나 모아 두었다가 나중에 적습니다
대가도 있습니다. 데이터베이스에 언제 무엇이 적히는지가 코드에 안 보입니다. 모든 쓰기가 캐시를 지나야 캐시와 데이터베이스가 어긋나지 않습니다.
상세
이 절은 이름의 뜻부터 보고 캐시가 떠맡는 일, 코드의 변화, cache-aside 와의 차이를 차례로 봅니다. 예로는 회원 정보를 읽고 고치는 서비스 하나를 끝까지 씁니다.
원본과 캐시
원본은 어떤 데이터의 정답을 들고 있는 저장소입니다. 회원 테이블이 있는 관계형 데이터베이스가 흔한 예입니다. 캐시와 원본의 값이 다르면 원본 쪽이 맞는 값입니다.
원본을 영어로 system of record라고 합니다. 줄여서 SoR 라고 씁니다. cache-as-sor 는 cache as system of record 를 줄인 이름입니다. 캐시를 원본인 것처럼 쓴다는 뜻입니다.
「처럼」이 붙은 까닭이 있습니다. 데이터의 정답은 여전히 데이터베이스에 있습니다. 애플리케이션 코드가 보기에만 원본이 있어야 할 곳에 캐시가 서 있습니다.
캐시가 떠맡는 두 가지 일
캐싱을 하는 서비스에는 원본을 상대하는 일이 둘 있습니다. 하나는 캐시에 없는 값을 원본에서 읽어 오는 일입니다. 다른 하나는 바뀐 값을 원본에 적는 일입니다.
cache-as-sor 는 이 두 일을 애플리케이션 코드에서 떼어 캐시에 맡깁니다. 애플리케이션은 캐시에 「이 키의 값을 달라」, 「이 키에 이 값을 넣어라」만 말합니다. 원본에 닿는 일은 전부 캐시 안쪽에서 일어납니다.
캐시가 이 일을 하려면 원본에 닿는 법을 알아야 합니다. 그래서 캐시를 만들 때 원본을 읽고 쓰는 부품을 붙여 둡니다. 읽는 쪽을 흔히 캐시 로더, 쓰는 쪽을 캐시 라이터라고 부릅니다. 둘을 한 부품으로 묶기도 합니다.
read-through 와 write-through 또는 write-behind
cache-as-sor 는 새로운 절차가 아닙니다. 캐시 쪽 읽기 패턴 하나와 쓰기 패턴 하나를 묶은 구성의 이름입니다. 읽기는 read-through 로 합니다. 쓰기는 write-through 와 write-behind 가운데 하나를 고릅니다.
| 쪽 | 패턴 | 캐시가 하는 일 |
|---|---|---|
| 읽기 | read-through | 값이 없으면 원본에서 읽어 채운 다음 돌려줍니다 |
| 쓰기 | write-through | 원본에도 적은 다음에 쓰기를 끝냅니다 |
| 쓰기 | write-behind | 쓰기를 먼저 끝내고 원본에는 나중에 모아 적습니다 |
쓰기 쪽 두 패턴은 무엇을 기다리느냐가 다릅니다. write-through 는 원본에 적힐 때까지 애플리케이션을 기다리게 합니다. 그래서 쓰기가 원본만큼 느립니다.
write-behind 는 애플리케이션을 기다리게 하지 않습니다. 대신 원본에 아직 못 간 쓰기를 캐시가 들고 있는 시간이 생깁니다. 그 사이 캐시가 죽으면 그 쓰기가 원본에 닿지 못하고 사라질 수 있습니다.
요청 하나가 지나는 길
회원 42번을 읽고 고치는 요청을 따라가 봅니다. 쓰기는 write-through 를 골랐다고 칩니다.
sequenceDiagram
participant 애플리케이션
participant 캐시
participant 원본
애플리케이션->>캐시: 회원 42번을 달라
Note over 애플리케이션,캐시: 값이 있으면 캐시가 여기서 돌려준다
캐시->>원본: 없으니 읽어 온다
원본-->>캐시: 회원 42번
캐시-->>애플리케이션: 채워 넣고 돌려준다
애플리케이션->>캐시: 회원 42번을 이 값으로
캐시->>원본: 적는다
캐시-->>애플리케이션: 쓰기 끝
그림에서 볼 것은 애플리케이션과 원본 사이에 화살표가 하나도 없다는 점입니다. 원본을 읽는 화살표도 적는 화살표도 전부 캐시에서 나갑니다. 애플리케이션은 캐시 하나만 알고 있으면 됩니다.
코드에서 달라지는 것
같은 읽기를 두 방식으로 써 보겠습니다. 먼저 애플리케이션이 원본을 직접 챙기는 코드입니다.
User u = cache.get(id);
if (u == null) {
u = db.findUser(id);
cache.put(id, u);
}
먼저 캐시를 봅니다. 없으면 원본을 읽어 그 값을 캐시에 넣습니다. 회원을 읽는 곳이 열 군데면 이 네 줄도 열 번 나옵니다.
cache-as-sor 에서는 같은 읽기가 한 줄입니다.
User u = cache.get(id);
원본을 읽는 코드가 사라진 것은 아닙니다. 캐시에 붙이는 부품 안으로 옮겨 갔습니다. 아래는 그 부품을 가상의 인터페이스 LoaderWriter 로 적은 것입니다.
class UserLoaderWriter
implements LoaderWriter<Long, User> {
public User load(Long id) {
return db.findUser(id);
}
public void write(Long id, User u) {
db.saveUser(u);
}
}
캐시는 찾는 값이 없을 때 load 를 부릅니다. 애플리케이션이 cache.put 을 부르면 캐시가 write 를 부릅니다. 원본을 읽고 쓰는 코드가 이 클래스 한 곳에 모입니다.
cache-aside 와 무엇이 다른가
cache-as-sor 와 맞서는 방식이 cache-aside 입니다. 앞 소절의 첫 코드가 cache-aside 입니다. 애플리케이션이 캐시를 옆에 두고 원본 읽기와 쓰기를 직접 합니다.
| cache-aside | cache-as-sor | |
|---|---|---|
| 원본을 읽는 쪽 | 애플리케이션 | 캐시 |
| 원본에 적는 쪽 | 애플리케이션 | 캐시 |
| 캐시가 원본을 아나 | 모른다 | 붙여 둔 부품으로 안다 |
| 캐시를 쓰는 코드 | 읽을 때마다 확인·읽기·넣기 | 캐시 호출 한 줄 |
cache-aside 에서 캐시와 원본은 서로 모르는 두 저장소입니다. 둘을 잇는 것은 애플리케이션 코드뿐입니다. cache-as-sor 에서는 그 잇는 일을 캐시가 합니다.
그래서 cache-as-sor 는 캐시가 부품을 받아 주어야 성립합니다. 캐시가 따로 떠 있는 서버라서 애플리케이션의 데이터베이스에 닿을 수 없다면 이 구성을 못 씁니다. 그럴 때는 애플리케이션이 그 일을 맡는 cache-aside 로 갑니다.
얻는 것
첫째는 원본을 다루는 코드가 한 곳에 모인다는 점입니다. 캐시를 부르는 곳이 몇 군데든 원본을 읽는 코드는 부품 하나에만 있습니다. 쿼리를 고칠 때도 그 한 곳만 고칩니다.
둘째는 쓰기 방식을 캐시마다 설정으로 고를 수 있다는 점입니다. 회원 정보는 write-through 로, 조회수는 write-behind 로 두는 식입니다. 애플리케이션 코드는 양쪽 다 cache.put 한 줄이라 바뀌지 않습니다.
셋째는 캐시 스탬피드를 캐시가 막을 수 있다는 점입니다. 캐시 스탬피드는 자주 읽히는 값이 캐시에서 빠진 순간 그 값을 찾던 요청이 한꺼번에 원본으로 몰리는 일입니다. cache-aside 에서는 요청마다 각자 원본을 읽으러 갑니다.
cache-as-sor 에서는 원본을 읽는 주체가 캐시 하나입니다. 그래서 캐시는 같은 키의 읽기를 원본에 한 번만 보낼 수 있습니다. 나머지 요청은 그 결과를 기다리게 합니다. 캐시가 이렇게 해 주는지는 캐시마다 다릅니다.
잃는 것
먼저 원본에 무엇이 언제 적히는지가 코드에 안 보입니다. cache.put 한 줄 뒤에 데이터베이스 쓰기가 숨어 있습니다. 장애를 쫓을 때 코드만 읽어서는 호출을 따라갈 수 없습니다. 캐시 설정까지 열어 봐야 합니다.
다음으로 모든 쓰기가 캐시를 지나야 한다는 전제가 붙습니다. 다른 서비스나 야간 배치가 데이터베이스를 직접 고치면 캐시는 그 사실을 모릅니다. 캐시에는 고치기 전 값이 남습니다. 그 값이 원본과 어긋난 낡은 데이터가 됩니다.
원본의 장애도 캐시 호출로 올라옵니다. 데이터베이스가 느려지면 캐시에서 값을 꺼내는 호출이 함께 느려집니다. 캐시 호출은 금방 끝난다고 여기고 짠 코드라면 여기서 예상과 어긋납니다.
마지막으로 write-behind 를 고르면 앞에서 본 대가가 더해집니다. 원본에 못 간 쓰기가 캐시에만 있는 동안 캐시가 죽으면 그 쓰기를 잃을 수 있습니다. 그동안 원본을 직접 읽는 다른 시스템은 옛 값을 봅니다.
쓰는 때와 안 쓰는 때
어느 쪽으로 기우는지는 원본에 누가 쓰느냐와 캐시가 원본에 닿을 수 있느냐로 갈립니다.
| 상황 | 기우는 쪽 |
|---|---|
| 원본을 읽고 쓰는 코드가 여러 곳에 흩어져 있다 | cache-as-sor |
| 원본에 쓰는 길이 이 애플리케이션 하나뿐이다 | cache-as-sor |
| 다른 서비스나 배치도 원본을 직접 고친다 | cache-aside |
| 캐시가 따로 떠 있는 서버라 원본에 닿지 못한다 | cache-aside |
| 쓰기마다 원본에 적는 순간을 코드에서 쥐어야 한다 | cache-aside |
관련 항목
이 구성을 이루는 캐시 쪽 읽기·쓰기 패턴
read-through · write-through · write-behind · refresh-ahead · cache-through
원본을 애플리케이션이 직접 챙기는 맞선 전략
cache-aside · write-around · 이중 쓰기
캐시에 원본 읽기·쓰기를 붙이는 부품과 표준
캐시 로더 · CacheLoaderWriter · CacheStore · JCache
원본 연결 부품을 받는 캐시 제품
Ehcache · Oracle Coherence · Hazelcast · Apache Ignite · 인메모리 데이터 그리드
이 구성이 막거나 떠안는 캐시 문제
캐시 스탬피드 · 썬더링 허드 · 낡은 데이터 · 캐시 일관성 · 캐시 미스
이 구성이 놓이는 캐싱 전반과 원본 저장소
캐싱 · 로컬 캐시 · 시스템 오브 레코드 · 데이터베이스 · 관계형 데이터베이스
다른 이름: cache-as-SoR · cache as SoR · cache-as-system-of-record · 캐시 애즈 SoR