캐시(Cache)란?
캐시(Cache)는 자주 조회하는 데이터를 더 빠르게 제공하기 위해 임시로 저장하는 데이터 저장소다. 데이터베이스에 반복적으로 접근하는 비용을 줄이고, 응답 시간을 단축하며, 시스템 전체의 처리량을 높이는 데 사용한다.
일반적으로 캐시는 메모리 기반 저장소인 Redis를 활용한다. 메모리 접근은 디스크 기반 데이터베이스 조회보다 빠르며, 네트워크를 통한 데이터베이스 접근과 쿼리 실행 비용도 줄일 수 있다.
예를 들어 게임 서버에서 플레이어의 프로필을 조회한다고 가정하자.
캐시가 없다면 다음과 같은 요청이 반복된다.
클라이언트
|
v
게임 서버
|
v
MySQL 조회
|
v
플레이어 프로필 반환
동일한 플레이어의 프로필을 수천 번 조회하더라도 매번 데이터베이스에 접근해야 한다. 사용자가 많아지면 데이터베이스에 읽기 부하가 집중된다.
캐시를 도입하면 다음과 같이 처리할 수 있다.
클라이언트
|
v
게임 서버
|
v
Redis 조회
|
+-- 캐시 적중(Cache Hit)
| |
| v
| 데이터 반환
|
+-- 캐시 미스(Cache Miss)
|
v
MySQL 조회
|
v
Redis 저장
|
v
데이터 반환
캐시에 데이터가 존재하면 데이터베이스를 조회하지 않는다. 데이터가 없으면 데이터베이스에서 조회한 뒤 캐시에 저장한다.
다만 캐시는 데이터베이스를 대체하는 저장소가 아니다. 일반적으로 데이터베이스를 영속적인 원본 데이터(Source of Truth)로 유지하고, 캐시는 성능 향상을 위한 보조 저장소로 사용한다.
이러한 구조에서 핵심은 데이터를 얼마나 빠르게 조회하느냐뿐만 아니라, 캐시와 데이터베이스의 데이터가 서로 어긋나지 않도록 관리하는 것이다.
Cache-Aside 패턴
Cache-Aside란?
Cache-Aside는 애플리케이션이 캐시 조회, 데이터베이스 조회, 캐시 저장을 직접 제어하는 방식이다.
Lazy Loading 방식이라고도 부른다. 실제로 데이터가 필요해졌을 때 캐시에 적재하기 때문이다.
동작 과정은 다음과 같다.
- 애플리케이션이 캐시에서 데이터를 조회한다.
- 캐시에 데이터가 존재하면 즉시 반환한다.
- 캐시에 데이터가 없으면 데이터베이스를 조회한다.
- 조회한 데이터를 캐시에 저장한다.
- 조회 결과를 클라이언트에 반환한다.
Cache-Aside 조회 예시
Node.js, TypeScript, Redis, MySQL 환경을 기준으로 살펴보자.
import { createClient } from "redis";
import mysql from "mysql2/promise";
const redis = createClient({
url: process.env.REDIS_URL,
});
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10,
});
type UserProfile = {
id: number;
nickname: string;
level: number;
};
async function getUserProfile(
userId: number
): Promise<UserProfile | null> {
const cacheKey = `user:profile:${userId}`;
// 캐시 조회
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as UserProfile;
}
// 캐시 미스: 데이터베이스 조회
const [rows] = await pool.execute(
`SELECT id, nickname, level
FROM users
WHERE id = ?`,
[userId]
);
const users = rows as UserProfile[];
const user = users[0] ?? null;
if (user !== null) {
// 조회 결과를 캐시에 저장
await redis.set(
cacheKey,
JSON.stringify(user),
{ EX: 300 }
);
}
return user;
}
이 코드는 캐시가 없으면 MySQL에서 데이터를 조회하고, 결과를 Redis에 300초 동안 저장한다.
캐시가 적중하면 MySQL 조회를 생략한다. 반면 캐시가 만료되었거나 존재하지 않으면 다시 데이터베이스를 조회한다.
실제 서비스에서는 Redis 연결 오류, JSON 파싱 오류, 데이터베이스 타임아웃 등을 별도로 처리해야 한다. 또한 캐시 오류가 발생했을 때 데이터베이스로 대체 조회할 것인지, 요청 자체를 실패시킬 것인지도 서비스의 요구사항에 따라 결정해야 한다.
Cache-Aside의 장점
필요한 데이터만 캐싱할 수 있다.
실제로 조회되는 데이터만 캐시에 저장하므로 사용되지 않는 데이터를 미리 적재할 필요가 없다.
캐시와 데이터베이스의 역할이 분리된다.
데이터베이스는 원본 데이터를 관리하고, 애플리케이션은 캐시 정책을 관리한다. Redis를 도입하거나 제거하는 과정에서도 데이터베이스의 기본 저장 구조를 크게 변경하지 않아도 된다.
읽기 부하를 효과적으로 줄일 수 있다.
반복 조회가 많은 데이터라면 캐시 적중률이 높아져 데이터베이스 부하를 크게 줄일 수 있다.
Cache-Aside의 단점
최초 조회는 느릴 수 있다.
캐시가 비어 있다면 데이터베이스를 조회하고 캐시까지 적재해야 하므로 최초 요청에 추가 비용이 발생한다.
캐시 미스가 집중되면 데이터베이스 부하가 증가한다.
캐시가 만료된 직후 동일한 키에 요청이 집중되면 여러 요청이 동시에 데이터베이스를 조회할 수 있다.
캐시 무효화 정책이 필요하다.
데이터베이스의 데이터가 변경되었는데 기존 캐시가 유지되면 오래된 데이터를 반환할 수 있다.
Cache-Aside는 구현이 간단하고 범용적이지만, 캐시를 사용하는 것만으로 데이터 정합성이 자동으로 보장되지는 않는다.
Write-Through 패턴
Write-Through란?
Write-Through는 데이터를 변경할 때 데이터베이스와 캐시를 함께 갱신하는 방식이다.
일반적인 구현에서는 애플리케이션이 데이터베이스에 변경 사항을 기록한 다음 캐시를 갱신한다. 두 저장소에 모두 반영되어야 쓰기 작업이 완료된 것으로 간주한다.
클라이언트
|
v
애플리케이션
|
+-------> MySQL 갱신
|
+-------> Redis 갱신
|
v
쓰기 완료
이 방식의 핵심은 데이터가 변경될 때 캐시도 함께 갱신하여 오래된 데이터가 남아 있을 가능성을 줄이는 것이다.
Write-Through 구현 예시
async function updateUserLevel(
userId: number,
level: number
): Promise<void> {
const cacheKey = `user:profile:${userId}`;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
`UPDATE users
SET level = ?
WHERE id = ?`,
[level, userId]
);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
// DB 커밋 이후 캐시 갱신
try {
const [rows] = await pool.execute(
`SELECT id, nickname, level
FROM users
WHERE id = ?`,
[userId]
);
const users = rows as UserProfile[];
const user = users[0];
if (user) {
await redis.set(
cacheKey,
JSON.stringify(user),
{ EX: 300 }
);
} else {
await redis.del(cacheKey);
}
} catch (error) {
// 캐시 갱신 실패를 별도로 기록하고 대응해야 한다.
console.error("Cache update failed", error);
}
}
이 예시는 데이터베이스 변경 후 캐시를 갱신하는 흐름을 보여준다. 엄밀한 의미에서 모든 작업이 하나의 원자적 Write-Through 트랜잭션으로 묶인 것은 아니다. MySQL 커밋 이후 Redis 갱신에 실패할 수 있기 때문이다.
따라서 운영 환경에서는 캐시 갱신 실패 시 재시도, 캐시 삭제, 이벤트 기반 갱신 등의 복구 전략을 함께 설계해야 한다.
Write-Through의 장점
- 쓰기 작업 이후 캐시에 최신 데이터를 반영할 수 있다.
- 이후 조회에서 오래된 캐시가 반환될 가능성을 줄일 수 있다.
- 읽기 요청이 많은 데이터에 적합하다.
Write-Through의 단점
- 쓰기 요청마다 캐시 갱신 비용이 추가된다.
- 캐시와 데이터베이스를 모두 다뤄야 하므로 쓰기 경로가 복잡해진다.
- 두 저장소를 원자적으로 갱신하지 않으면 부분 실패가 발생할 수 있다.
특히 Write-Through라는 이름만으로 분산 트랜잭션 수준의 원자성이 보장되는 것은 아니다. 실제 일관성 수준은 구현 방식과 실패 복구 정책에 달려 있다.
Write-Behind 패턴
Write-Behind란?
Write-Behind는 캐시를 먼저 갱신하고 데이터베이스 반영을 나중으로 미루는 방식이다. Write-Back이라고도 부른다.
쓰기 요청을 받을 때마다 데이터베이스에 즉시 기록하지 않고, 변경 사항을 메모리나 큐 등에 누적한 뒤 일정 시점에 데이터베이스에 반영한다.
클라이언트
|
v
애플리케이션
|
v
Redis 또는 쓰기 버퍼 갱신
|
v
요청에 빠르게 응답
|
v
변경 사항 누적
|
v
비동기 작업자
|
v
MySQL에 일괄 반영
예를 들어 게임에서 실시간으로 발생하는 임시 점수나 통계 데이터를 매번 데이터베이스에 기록하면 쓰기 부하가 커질 수 있다.
변경 사항을 일정 시간 동안 모은 뒤 한꺼번에 기록하면 데이터베이스에 발생하는 쓰기 작업을 줄일 수 있다.
Write-Behind 구현 개념
type ScoreChange = {
userId: number;
score: number;
};
async function updateCachedScore(
userId: number,
score: number
): Promise<void> {
const cacheKey = `score:${userId}`;
// 캐시의 점수를 먼저 갱신
await redis.set(cacheKey, String(score));
// 실제 서비스에서는 변경 사항을
// 내구성 있는 큐나 로그에도 기록해야 한다.
await enqueueScorePersistence({
userId,
score,
});
}
async function enqueueScorePersistence(
change: ScoreChange
): Promise<void> {
// 개념 예시:
// Redis Streams, Kafka, RabbitMQ 등의
// 메시지 시스템에 변경 사항을 기록한다.
console.log("Persist score change", change);
}
enqueueScorePersistence는 실제 큐 구현이 아닌 개념적인 함수다. 운영 환경에서는 큐 저장 성공 여부, 중복 메시지, 재시도, 순서 보장, 장애 복구 등을 구현해야 한다.
또한 Redis에 점수를 기록한 뒤 큐 저장에 실패하면 캐시에는 변경 사항이 있지만 데이터베이스에는 영구 반영되지 않을 수 있다. 따라서 단순히 캐시에 기록한 후 나중에 DB에 저장하는 것만으로는 안전한 Write-Behind 구조가 완성되지 않는다.
데이터 손실이 허용되지 않는다면 변경 사항을 내구성 있는 로그나 큐에 먼저 기록하고, 해당 기록이 안전하게 보존된 뒤 요청을 완료하는 구조를 검토해야 한다.
Write-Behind의 장점
- 데이터베이스 쓰기 요청을 줄일 수 있다.
- 여러 변경 사항을 묶어 일괄 처리할 수 있다.
- 높은 빈도로 발생하는 쓰기 작업을 효율적으로 처리할 수 있다.
Write-Behind의 단점
- 데이터베이스에 반영되기 전까지 캐시와 DB의 값이 다를 수 있다.
- 장애 발생 시 아직 영구 저장되지 않은 데이터가 손실될 수 있다.
- 재시도와 중복 처리, 변경 순서 관리가 필요하다.
- 캐시와 큐의 데이터가 서로 어긋나지 않도록 관리해야 한다.
따라서 포인트 잔액, 결제, 아이템 구매, 유료 재화 지급처럼 데이터 손실이나 중복 처리가 치명적인 기능에는 단순한 Write-Behind를 적용해서는 안 된다.
반면 일부 지연을 허용할 수 있는 조회수, 분석용 집계, 비핵심 통계 등에는 적절한 내구성과 복구 정책을 전제로 유용하게 활용할 수 있다.
Cache-Aside, Write-Through, Write-Behind 비교
| 구분 | Cache-Aside | Write-Through | Write-Behind |
|---|---|---|---|
| 주요 목적 | 읽기 최적화 | 캐시 최신성 유지 | 쓰기 부하 감소 |
| 데이터 조회 | 캐시 우선 | 구현에 따라 다름 | 캐시 우선인 경우가 많음 |
| 데이터 기록 | 일반적으로 DB 기록 후 캐시 적재 또는 무효화 | DB와 캐시를 함께 갱신 | 캐시에 먼저 기록하고 DB에 나중 반영 |
| DB 반영 시점 | 쓰기 로직에 따라 다름 | 일반적으로 즉시 | 지연 또는 일괄 처리 |
| 데이터 손실 위험 | 일반적인 DB 우선 쓰기에서는 낮음 | 부분 실패 복구가 필요함 | 영구 저장 전 장애에 취약할 수 있음 |
| 주요 활용 | 프로필, 설정, 조회 데이터 | 자주 조회되는 변경 데이터 | 통계, 집계, 고빈도 쓰기 |
이 세 가지 패턴은 반드시 서로 배타적인 것은 아니다. Cache-Aside를 조회에 사용하고, 데이터 변경 시에는 캐시를 무효화하는 전략을 결합할 수도 있다.
캐시 무효화(Cache Invalidation)
캐시 무효화란?
캐시 무효화는 원본 데이터가 변경되었을 때 기존 캐시를 더 이상 유효한 데이터로 취급하지 않도록 처리하는 것이다.
캐시 설계에서 어려운 문제 중 하나는 데이터를 저장하는 것보다 언제 캐시를 갱신하거나 제거할지 결정하는 것이다.
예를 들어 플레이어의 레벨이 10에서 11로 변경되었다고 가정하자.
MySQL
userId = 100
level = 11
Redis
user:profile:100
level = 10
데이터베이스는 최신 값인 11을 가지고 있지만, Redis에는 이전 값인 10이 남아 있다. 이 상태에서 캐시 조회가 발생하면 사용자에게 잘못된 레벨을 반환할 수 있다.
캐시 무효화는 이러한 문제를 해결하기 위한 핵심 전략이다.
캐시 무효화 전략
캐시 삭제(Invalidate on Write)
데이터베이스를 변경한 뒤 관련 캐시를 삭제한다.
async function updateNickname(
userId: number,
nickname: string
): Promise<void> {
await pool.execute(
`UPDATE users
SET nickname = ?
WHERE id = ?`,
[nickname, userId]
);
await redis.del(`user:profile:${userId}`);
}
다음 조회에서 캐시가 없으므로 데이터베이스에서 최신 데이터를 가져와 캐시에 저장한다.
이 방식은 캐시 전체 객체를 직접 갱신하는 것보다 단순하다. 특히 여러 필드가 포함된 객체에서는 DB 변경 후 캐시를 삭제하는 전략이 유용하다.
단, DB 업데이트 성공 후 Redis 삭제가 실패하면 오래된 캐시가 남는다. 따라서 캐시 삭제 실패에 대한 재시도나 TTL 같은 보조 장치가 필요하다.
캐시 직접 갱신(Update on Write)
데이터베이스를 변경한 뒤 캐시에도 최신 데이터를 기록한다.
await pool.execute(
`UPDATE users
SET nickname = ?
WHERE id = ?`,
[nickname, userId]
);
const cacheKey = `user:profile:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const user = JSON.parse(cached) as UserProfile;
user.nickname = nickname;
await redis.set(
cacheKey,
JSON.stringify(user),
{ EX: 300 }
);
}
캐시가 존재할 때 최신 값으로 갱신하면 다음 조회에서 캐시 미스가 발생하는 것을 줄일 수 있다.
하지만 이 예시에서는 UserProfile에 nickname만 반영하므로 다른 필드가 동시에 변경되는 상황까지 안전하게 처리하지는 못한다. 실제 구현에서는 최신 객체를 다시 조회하거나 필드별 갱신을 원자적으로 처리하는 방식을 고려해야 한다.
TTL을 이용한 만료
TTL(Time To Live)은 캐시 데이터가 유효한 시간을 의미한다.
await redis.set(
"user:profile:100",
JSON.stringify(user),
{ EX: 300 }
);
위 코드는 캐시를 300초 동안 유지한다. 만료 후에는 Redis에서 해당 키가 더 이상 유효한 캐시 데이터로 제공되지 않으며, 다음 조회에서 다시 적재할 수 있다.
TTL은 캐시가 무효화되지 못하는 상황에서 오래된 데이터가 영구적으로 남는 것을 방지한다. 하지만 TTL이 있다고 해서 데이터 정합성이 즉시 보장되는 것은 아니다.
예를 들어 TTL이 5분이라면 데이터 변경 후 최대 수분 동안 오래된 값을 읽을 가능성이 있다. 따라서 허용 가능한 데이터 지연 시간에 맞춰 TTL을 설정해야 한다.
캐시 무효화에서 주의할 점
- DB 변경과 캐시 삭제 사이에 실패가 발생할 수 있다.
- 동일한 데이터가 여러 캐시 키에 저장되어 있다면 모든 관련 키를 관리해야 한다.
- TTL이 너무 길면 오래된 데이터가 오래 유지될 수 있다.
- TTL이 너무 짧으면 캐시 미스와 DB 조회가 증가한다.
- 캐시 갱신 순서가 뒤바뀌면 최신 값이 다시 오래된 값으로 덮일 수 있다.
캐시 무효화는 단순히 DEL 명령을 호출하는 문제가 아니다. 어떤 데이터 변경이 어떤 캐시 키에 영향을 주는지 추적하고, 실패 상황에서도 복구할 수 있도록 설계하는 문제다.
Cache Stampede
Cache Stampede란?
Cache Stampede는 캐시가 만료되거나 삭제되었을 때 다수의 요청이 동시에 캐시 미스를 일으키면서 데이터베이스에 부하가 집중되는 현상이다.
Thundering Herd 문제의 한 형태로 볼 수 있다.
예를 들어 인기 있는 게임 아이템 정보가 Redis에 저장되어 있고, 해당 캐시의 TTL이 만료되었다고 가정하자.
요청 A ----+
요청 B ----+
요청 C ----+----> Redis 캐시 미스
요청 D ----+ |
요청 E ----+ v
MySQL 조회
MySQL 조회
MySQL 조회
MySQL 조회
MySQL 조회
다섯 요청이 동시에 캐시 미스를 일으키면 동일한 데이터에 대한 DB 조회가 다섯 번 발생할 수 있다.
평소에는 Redis가 요청을 처리하므로 문제가 없더라도 캐시 만료 시점에 DB 부하가 급격히 증가할 수 있다. 부하가 더 커지면 데이터베이스 응답 지연과 타임아웃이 발생하고, 재시도 요청까지 겹치면서 장애가 확대될 수 있다.
Cache Stampede 방지 전략
분산 락을 이용한 단일 조회
동일한 캐시 키에 대한 DB 조회를 한 요청만 수행하도록 제한한다.
요청 A ----> 캐시 미스 ----> 락 획득 ----> DB 조회 ----> 캐시 저장
요청 B ----> 캐시 미스 ----> 락 대기 -----------------> 캐시 조회
요청 C ----> 캐시 미스 ----> 락 대기 -----------------> 캐시 조회
Redis의 SET key value NX PX 등을 이용해 일정 시간 동안 유효한 락을 획득할 수 있다.
개념적인 흐름은 다음과 같다.
async function getCachedData(
key: string
): Promise<string | null> {
const cached = await redis.get(key);
if (cached !== null) {
return cached;
}
const lockKey = `lock:${key}`;
const lockToken = crypto.randomUUID();
const acquired = await redis.set(
lockKey,
lockToken,
{ NX: true, PX: 5000 }
);
if (acquired === "OK") {
try {
// 락을 획득한 요청만 DB를 조회한다.
const value = await loadFromDatabase(key);
if (value !== null) {
await redis.set(key, value, { EX: 300 });
}
return value;
} finally {
// 실제 구현에서는 소유권 확인 후 원자적으로 삭제한다.
await releaseLockIfOwner(lockKey, lockToken);
}
}
// 다른 요청이 캐시를 생성할 때까지 잠시 대기한다.
await delay(50);
return redis.get(key);
}
loadFromDatabase, releaseLockIfOwner, delay는 개념 예시의 보조 함수이며 별도로 구현해야 한다.
특히 락 해제는 단순한 DEL로 처리하면 안 된다. 락이 만료된 뒤 다른 요청이 새 락을 획득했는데 이전 요청이 늦게 종료되어 새 락까지 삭제할 수 있기 때문이다. 락 토큰을 비교하면서 삭제하는 Lua 스크립트 등의 원자적 처리가 필요하다.
또한 락을 기다리는 요청이 재조회했을 때도 캐시가 없다면 제한된 재시도, 대기 시간 증가, DB 직접 조회 등의 대체 정책이 필요하다. 락을 사용한다고 해서 모든 동시성 문제가 자동으로 해결되는 것은 아니다.
TTL에 무작위 지연 추가
많은 키의 만료 시점이 동시에 몰리는 것을 줄이기 위해 TTL에 무작위 값을 더한다.
const baseTtl = 300;
const jitter = Math.floor(Math.random() * 60);
await redis.set(
"item:100",
JSON.stringify(item),
{ EX: baseTtl + jitter }
);
이 경우 캐시 만료 시간이 300~359초 사이로 분산된다.
TTL 지터는 여러 캐시가 동시에 만료되는 문제를 완화하지만, 인기 있는 단일 키에 요청이 집중되는 현상을 완전히 해결하지는 못한다.
사전 갱신(Refresh Ahead)
캐시가 만료되기 전에 백그라운드에서 데이터를 갱신한다.
캐시 만료 전
|
v
백그라운드에서 DB 조회
|
v
Redis 캐시 갱신
|
v
기존 요청은 캐시를 계속 사용
자주 조회되는 데이터에 적합하다. 다만 갱신 주기를 잘못 설정하면 필요하지 않은 DB 조회가 증가할 수 있다.
오래된 캐시의 일시적 제공
캐시가 만료되었더라도 일정 시간 동안 이전 데이터를 반환하고, 백그라운드에서 최신 데이터를 갱신할 수 있다.
이 방식은 일부 데이터의 일시적인 오래됨을 허용하는 서비스에서 유용하다. 단, 잔액이나 결제 상태처럼 최신성이 중요한 데이터에는 적용하기 어렵다.
캐시와 DB 간 정합성
정합성이란?
캐시와 DB 간 정합성은 두 저장소가 서로 모순되지 않는 데이터를 제공하도록 관리하는 것을 의미한다.
데이터베이스를 원본으로 사용하고 Redis를 캐시로 사용하는 구조에서는 DB 변경과 캐시 갱신이 서로 다른 작업으로 실행되는 경우가 많다. 이때 두 작업 사이에 실패나 동시 요청이 발생하면 데이터가 어긋날 수 있다.
예를 들어 다음 상황을 살펴보자.
1. Redis에 level = 10 저장
2. 요청 A가 MySQL의 level을 11로 변경
3. 요청 B가 MySQL의 level을 12로 변경
4. 요청 B가 Redis에 level = 12 저장
5. 요청 A가 늦게 Redis에 level = 11 저장
최종 상태는 다음과 같다.
MySQL: level = 12
Redis: level = 11
각 요청이 정상적으로 실행되었더라도 처리 순서가 뒤바뀌면 오래된 데이터가 최신 데이터를 덮어쓸 수 있다.
따라서 캐시 정합성은 단순히 DB 업데이트 이후 Redis를 갱신하는 것으로 끝나지 않는다. 동시성, 실패 복구, 이벤트 순서, 재시도 정책까지 함께 고려해야 한다.
DB 우선 갱신 후 캐시 삭제
일반적인 Cache-Aside 구조에서는 다음 전략을 많이 사용한다.
DB 데이터 변경
|
v
DB 트랜잭션 커밋
|
v
관련 캐시 삭제
|
v
다음 조회에서 최신 데이터 적재
이 방식은 캐시 전체 객체를 직접 수정하는 것보다 단순하고, 오래된 데이터가 남아 있는 시간을 줄이는 데 효과적이다.
하지만 다음과 같은 경쟁 상황이 발생할 수 있다.
요청 A: 캐시 미스
요청 A: DB에서 오래된 값 10 조회
요청 B: DB 값을 11로 변경
요청 B: Redis 캐시 삭제
요청 A: 오래된 값 10을 Redis에 저장
최종적으로 캐시에 오래된 값이 다시 저장된다. 이 문제를 Cache-Aside의 경쟁 조건(Race Condition)으로 볼 수 있다.
캐시 삭제만으로 모든 정합성 문제가 해결되는 것은 아니다.
정합성 문제를 줄이는 방법
캐시 갱신 대신 무효화를 우선 고려한다.
변경된 전체 객체를 직접 갱신하는 방식은 동시 업데이트 상황에서 오래된 필드가 남을 수 있다. 최신 객체를 다시 조회하거나 캐시를 삭제하여 다음 조회에서 재적재하도록 하면 관리가 단순해진다.
캐시 TTL을 설정한다.
캐시 무효화가 실패하더라도 일정 시간이 지나면 오래된 데이터가 제거되도록 한다. TTL은 복구 수단이지, 즉각적인 정합성 보장 수단은 아니다.
버전 정보를 활용한다.
데이터에 버전 번호나 증가하는 변경 순번을 부여하고, 캐시를 갱신할 때 기존 버전보다 오래된 값이 최신 캐시를 덮지 못하도록 설계할 수 있다.
type VersionedProfile = {
id: number;
nickname: string;
version: number;
};
다만 버전 비교와 저장이 별도 작업으로 수행되면 그 사이에 다른 요청이 끼어들 수 있다. 실제 적용에서는 Redis Lua 스크립트 등을 활용해 비교와 갱신을 원자적으로 처리하거나, 다른 동기화 전략과 결합해야 한다.
Transactional Outbox를 활용한다.
DB 트랜잭션 안에서 데이터 변경과 이벤트 기록을 함께 수행한 뒤, 별도의 작업자가 이벤트를 읽어 캐시를 무효화할 수 있다.
DB 트랜잭션
|
+-- 사용자 데이터 변경
|
+-- Outbox 이벤트 기록
|
v
이벤트 처리 작업자
|
v
Redis 캐시 무효화
데이터 변경과 이벤트 기록을 같은 DB 트랜잭션에 포함하면, DB 변경은 성공했지만 이벤트 기록이 누락되는 문제를 줄일 수 있다.
단, Outbox 이벤트 처리와 Redis 갱신은 별도로 진행되므로 즉각적인 일관성을 보장하는 것은 아니다. 이벤트 중복 처리, 재시도, 순서 역전 등을 고려해야 한다.
중요한 데이터는 DB를 기준으로 검증한다.
포인트 잔액, 결제, 아이템 구매, 보상 지급처럼 데이터 정합성이 중요한 기능에서는 캐시를 최종적인 판단 근거로 사용하지 않는 것이 일반적이다.
예를 들어 게임 클라이언트가 캐시에서 포인트 잔액을 조회할 수는 있지만, 아이템 구매 가능 여부를 최종적으로 결정할 때는 DB 트랜잭션과 원자적 잔액 차감, 유니크 제약 조건 등을 통해 정확성을 보장해야 한다.
캐시는 조회 성능을 높이는 도구다. 비즈니스 규칙의 정확성을 대신 보장하지 않는다.
게임 서버에서의 캐시 설계
게임 서버는 데이터의 특성에 따라 서로 다른 캐시 전략을 적용해야 한다.
| 데이터 | 권장 접근 | 주요 고려 사항 |
|---|---|---|
| 플레이어 프로필 | Cache-Aside | 캐시 삭제, TTL |
| 아이템 정보 | Cache-Aside 또는 사전 갱신 | 높은 조회 빈도, 변경 빈도 |
| 랭킹 | Redis Sorted Set 등 | 점수 갱신 순서, 재계산 |
| 조회수 및 통계 | 집계 후 비동기 반영 가능 | 중복 이벤트, 데이터 손실 |
| 포인트 잔액 | DB 중심 트랜잭션 | 원자적 차감, 중복 요청 방지 |
| 구매 내역 | DB 트랜잭션 | 유니크 제약, 멱등성 |
| 매치메이킹 대기열 | Redis 등 실시간 상태 저장소 | 만료, 중복 등록, 장애 복구 |
여기서 중요한 점은 모든 데이터를 동일한 방식으로 캐싱하지 않는다는 것이다.
프로필처럼 잠시 오래된 데이터가 노출되어도 큰 문제가 없는 정보와, 포인트처럼 잘못된 값이 경제적 손실을 유발하는 데이터는 요구되는 정합성 수준이 다르다.
캐시 전략은 성능뿐만 아니라 데이터의 중요도와 허용 가능한 오류 범위를 기준으로 선택해야 한다.