프로그래밍을 처음 배울 때 캐시는 자주 사용하는 데이터를 잠시 저장해 다음 요청에서 빠르게 꺼내 쓰는 공간이라고 배운다.
const cachedCourse = await cache.get(courseId);
if (cachedCourse) {
return cachedCourse;
}
const course = await courseRepository.findById(courseId);
await cache.set(courseId, course);
return course;
캐시에 데이터가 있으면 데이터베이스를 조회하지 않고, 없으면 데이터베이스에서 가져와 저장한다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 데이터를 저장하는 것보다 더 많은 판단이 필요하다.
캐시는 단순히 데이터를 잠시 저장하는 공간이 아니다.
캐시는 원본 데이터를 더 빠르게 제공하기 위해 일정 기간 오래된 값을 허용하고, 갱신·삭제·복구 책임을 함께 떠안는 속도와 최신성 사이의 설계다.
크리스가 온라인 강의 서비스를 개발한다고 생각해 보자. 사용자는 강의 목록을 보고 상세 페이지에 들어가 강의 소개와 가격을 확인한다.
async function getCourse(courseId: string) {
return courseRepository.findById(courseId);
}
강의 하나를 조회하는 쿼리가 충분히 빠르고 요청도 많지 않다면 캐시를 추가해도 사용자가 느끼는 차이는 작을 수 있다.
반면 홈 화면이 매 요청마다 여러 데이터를 조합한다면 비용이 커질 수 있다.
async function getHomePage() {
const featuredCourses =
await courseRepository.findFeatured();
const popularCourses =
await courseRepository.findPopular();
const categories =
await categoryRepository.findAll();
return {
featuredCourses,
popularCourses,
categories,
};
}
이 결과가 모든 비로그인 사용자에게 거의 동일하고 짧은 시간 동안 자주 요청된다면 캐시의 이점이 커질 수 있다.
캐시를 설계할 때는 먼저 다음을 확인해야 한다.
캐시는 느린 코드 위에 붙이는 일반적인 성능 옵션이 아니다. 반복되는 비용과 허용 가능한 최신성의 범위가 확인되었을 때 선택하는 데이터 접근 경로다.
강의 정보의 Source of Truth가 데이터베이스라고 가정해 보자.
UPDATE courses
SET price_cents = 4900,
updated_at = NOW()
WHERE id = $1;
강의 가격을 변경해도 이전에 저장한 캐시 값은 자동으로 바뀌지 않을 수 있다.
{
"id": "course-101",
"title": "NestJS Fundamentals",
"priceCents": 7900
}
데이터베이스 가격은 4,900센트인데 캐시는 여전히 7,900센트를 반환할 수 있다.
캐시는 원본 데이터에서 파생된 복사본이다.
Source of Truth
Database의 courses
↓
조회 결과 복사
↓
Cache
↓
사용자에게 응답
원본과 복사본이 동시에 존재하면 두 값이 달라지는 순간이 생길 수 있다. 따라서 캐시를 추가하는 순간 다음 책임도 생긴다.
캐시에서 읽은 값을 다시 데이터베이스의 원본처럼 취급해서는 안 된다. 캐시를 삭제하더라도 서비스의 비즈니스 사실은 데이터베이스에서 다시 구성할 수 있어야 한다.
가장 이해하기 쉬운 방식 중 하나는 애플리케이션이 캐시를 먼저 확인하는 Cache-Aside 방식이다.
async function getCourse(courseId: string) {
const cacheKey = `course:${courseId}`;
const cachedCourse =
await cache.get<Course>(cacheKey);
if (cachedCourse) {
return cachedCourse;
}
const course =
await courseRepository.findById(courseId);
if (!course) {
return null;
}
await cache.set(cacheKey, course, {
ttlSeconds: 300,
});
return course;
}
이 코드는 다음 순서로 동작한다.
하지만 실제 서비스에서는 캐시 저장 실패도 고려해야 한다.
async function getCourse(courseId: string) {
const cacheKey = `course:${courseId}`;
try {
const cachedCourse =
await cache.get<Course>(cacheKey);
if (cachedCourse) {
return cachedCourse;
}
} catch (error) {
logger.warn("Course cache read failed", {
courseId,
error,
});
}
const course =
await courseRepository.findById(courseId);
if (!course) {
return null;
}
try {
await cache.set(cacheKey, course, {
ttlSeconds: 300,
});
} catch (error) {
logger.warn("Course cache write failed", {
courseId,
error,
});
}
return course;
}
이 서비스에서 데이터베이스가 Source of Truth라면 캐시 장애가 강의 조회 전체의 장애가 되지 않도록 데이터베이스로 우회할 수 있다.
다만 모든 서비스가 같은 결정을 내리는 것은 아니다. 캐시 장애 시 데이터베이스로 모든 요청이 몰리면 데이터베이스가 감당하지 못할 수도 있다. 이 경우 요청 제한, 오래된 값 사용, 기능 축소 같은 별도의 보호 정책이 필요하다.
Cache-Aside는 캐시를 먼저 읽는 코드 패턴만 의미하지 않는다. 캐시 히트, 캐시 미스, 캐시 장애, 원본 장애 각각에서 어떤 결과를 반환할지 결정하는 읽기 정책이다.
강의 정보를 5분 동안 캐시한다고 생각해 보자.
await cache.set(cacheKey, course, {
ttlSeconds: 300,
});
TTL은 캐시 데이터를 자동으로 삭제하는 시간으로 설명할 수 있다. 실무에서는 다음 질문과 연결된다.
원본이 변경된 뒤 사용자가 최대 5분 동안 이전 데이터를 볼 수 있어도 되는가?
강의 설명이나 썸네일이라면 5분의 차이가 큰 문제가 아닐 수 있다. 하지만 가격, 수강 가능 여부, 결제 금액처럼 거래에 영향을 주는 값은 같은 기준을 적용하기 어렵다.
| 데이터 | 허용 가능한 오래됨의 예 | 처리 방향 |
|---|---|---|
| 강의 소개 | 수 분 | 비교적 긴 TTL 가능 |
| 인기 강의 목록 | 수 분~수십 분 | 주기적 갱신 가능 |
| 강의 평점 요약 | 수 분 | 지연된 반영 가능 |
| 현재 판매 가격 | 매우 짧음 | 무효화 또는 원본 재검증 |
| 수강 신청 가능 여부 | 매우 짧음 | 결제 시 원본 재검증 |
| 사용자의 수강 권한 | 거의 허용하기 어려움 | 짧은 TTL 또는 원본 확인 |
TTL은 모든 데이터에 동일하게 적용하는 숫자가 아니다. 잘못된 값이 노출되었을 때 발생하는 비즈니스 비용을 기준으로 결정해야 한다.
프론트엔드에서 캐시된 강의 가격을 보여주더라도 결제를 확정할 때는 서버가 현재 가격을 다시 확인해야 한다.
async function createEnrollment(
userId: string,
courseId: string
) {
const course =
await courseRepository.findPurchasableById(
courseId
);
if (!course) {
throw new CourseNotPurchasableError();
}
return enrollmentService.purchase({
userId,
courseId,
priceCents: course.priceCents,
});
}
화면에 보이는 캐시 값은 사용자 경험을 위한 데이터일 수 있다. 실제 거래의 Source of Truth는 결제 시점에 검증한 서버 데이터다.
운영자가 강의 제목과 가격을 수정한다고 생각해 보자.
async function updateCourse(
courseId: string,
input: UpdateCourseInput
) {
const course =
await courseRepository.update(courseId, input);
return course;
}
데이터베이스만 수정하면 이전 캐시가 TTL 동안 남을 수 있다.
가장 단순한 개선은 원본 변경 후 관련 캐시를 삭제하는 것이다.
async function updateCourse(
courseId: string,
input: UpdateCourseInput
) {
const course =
await courseRepository.update(courseId, input);
await cache.delete(`course:${courseId}`);
return course;
}
다음 조회는 캐시 미스가 되어 최신 데이터를 데이터베이스에서 가져온다.
캐시를 새로운 값으로 직접 갱신할 수도 있다.
await cache.set(`course:${courseId}`, course, {
ttlSeconds: 300,
});
두 방식은 서로 다른 특성을 가진다.
| 방식 | 장점 | 주의할 점 |
|---|---|---|
| 캐시 삭제 | 다음 조회가 원본에서 최신 값을 가져옴 | 다음 요청은 캐시 미스가 됨 |
| 캐시 갱신 | 다음 요청도 빠르게 처리 가능 | 일부 캐시를 빠뜨리면 값이 달라질 수 있음 |
| TTL에만 의존 | 구현이 단순함 | TTL 동안 오래된 데이터가 노출됨 |
강의 하나가 여러 캐시에 포함될 수도 있다.
course:course-101
home:featured-courses
category:backend:courses
instructor:chris:courses
search:nestjs:first-page
강의 제목이나 가격이 바뀌면 어느 캐시를 무효화해야 하는지 복잡해진다. 이를 캐시 무효화 문제라고 부른다.
캐시를 많이 만들수록 읽기는 빨라질 수 있지만 원본 변경이 영향을 미치는 복사본도 많아진다. 캐시 설계에는 읽기 경로뿐 아니라 변경 전파 경로도 포함되어야 한다.
다음 코드를 살펴보자.
await courseRepository.update(courseId, input);
await cache.delete(`course:${courseId}`);
데이터베이스 수정은 성공했지만 캐시 삭제가 실패할 수 있다. 반대로 캐시를 먼저 삭제한 뒤 데이터베이스 수정이 실패할 수도 있다.
await cache.delete(`course:${courseId}`);
await courseRepository.update(courseId, input);
데이터베이스와 외부 캐시 저장소는 일반적으로 하나의 데이터베이스 트랜잭션으로 묶이지 않는다.
따라서 캐시 무효화는 실패할 수 있다는 전제로 설계해야 한다.
async function updateCourse(
courseId: string,
input: UpdateCourseInput
) {
const course =
await courseRepository.update(courseId, input);
try {
await cache.delete(`course:${courseId}`);
} catch (error) {
logger.error("Course cache invalidation failed", {
courseId,
error,
});
await cacheInvalidationQueue.enqueue({
type: "course.updated",
courseId,
});
}
return course;
}
캐시 삭제 실패를 기록하고 재시도 작업을 남길 수 있다. TTL도 영구적으로 오래된 데이터가 남지 않도록 하는 마지막 안전장치가 된다.
더 많은 시스템이 연결된 구조에서는 데이터베이스 변경과 함께 캐시 무효화 이벤트를 저장한 뒤 별도의 작업자가 처리할 수 있다.
await database.transaction(async (tx) => {
await tx.courses.update(courseId, input);
await tx.outboxEvents.create({
type: "course.updated",
payload: { courseId },
});
});
작업자는 커밋된 이벤트를 읽어 관련 캐시를 삭제한다.
캐시 일관성은 “항상 동시에 성공한다”는 가정보다 실패하더라도 일정 시간 안에 최신 상태로 회복할 수 있도록 설계하는 편이 현실적이다.
크리스가 사용자별 추천 강의를 캐시한다고 생각해 보자.
const cacheKey = "recommended-courses";
모든 사용자에게 같은 키를 사용하면 첫 번째 사용자의 추천 결과가 다른 사용자에게 반환될 수 있다.
const cacheKey =
`user:${userId}:recommended-courses`;
사용자 ID를 포함하면 사용자별 데이터가 분리된다.
하지만 추천 결과가 언어, 지역, 구독 플랜에 따라 달라진다면 더 많은 조건이 필요할 수 있다.
const cacheKey = [
"recommended-courses",
`user:${userId}`,
`locale:${locale}`,
`plan:${subscriptionPlan}`,
"v2",
].join(":");
캐시 키에는 결과를 바꾸는 조건이 포함되어야 한다.
조건이 빠지면 서로 다른 요청이 같은 캐시를 공유하는 충돌이 발생한다. 반대로 지나치게 많은 값이 포함되면 거의 재사용되지 않는 캐시가 만들어진다.
캐시 키도 외부 입력의 영향을 받을 수 있다. 외부 입력은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const CourseListQuerySchema = z.object({
category: z
.enum(["frontend", "backend", "mobile", "design"])
.optional(),
sort: z
.enum(["popular", "newest", "rating"])
.default("popular"),
page: z.coerce.number().int().min(1).max(100),
});
const query =
CourseListQuerySchema.parse(request.query);
검증되지 않은 검색어 전체를 캐시 키로 사용하면 무제한의 서로 다른 키가 생성되어 저장 공간을 소모할 수 있다.
캐시 키는 단순한 문자열이 아니다. 어떤 요청들이 같은 결과를 공유할 수 있는지를 정의하는 데이터 경계다.
강의 상세 페이지에는 공개 정보와 사용자별 정보가 함께 표시될 수 있다.
type CoursePage = {
course: Course;
isEnrolled: boolean;
progressPercent: number;
};
전체 응답을 강의 ID만으로 캐시하면 문제가 발생한다.
const cacheKey = `course-page:${courseId}`;
크리스가 강의에 등록한 상태로 페이지를 조회한 뒤 결과가 캐시되었다면, 다른 사용자도 isEnrolled: true와 크리스의 진도율을 받을 수 있다.
공개 데이터와 개인 데이터를 분리해야 한다.
const course =
await getCachedPublicCourse(courseId);
const enrollment =
await enrollmentRepository.findByUserAndCourse({
userId: currentUser.id,
courseId,
});
return {
course,
isEnrolled: Boolean(enrollment),
progressPercent: enrollment?.progressPercent ?? 0,
};
강의 제목, 설명, 강사명처럼 모두에게 같은 정보는 공유 캐시에 저장할 수 있다. 수강 여부와 진도율은 사용자 범위에서 조회한다.
사용자별 데이터도 캐시할 수 있지만 키와 권한 경계가 정확해야 한다.
const progressCacheKey =
`user:${currentUser.id}:course:${courseId}:progress`;
캐시에 저장하기 전에 다음을 확인해야 한다.
캐시는 권한 검사를 대신하지 않는다. 빠르게 가져온 데이터도 현재 사용자가 볼 수 있는지 확인해야 한다.
존재하지 않는 강의 ID가 반복해서 요청될 수 있다.
const course =
await courseRepository.findById(courseId);
if (!course) {
return null;
}
공격이나 잘못된 링크 때문에 같은 ID가 반복되면 데이터베이스 조회도 반복된다. 존재하지 않는 결과를 짧게 캐시하는 방법을 고려할 수 있다.
const NOT_FOUND = "__NOT_FOUND__";
async function getCourse(courseId: string) {
const cacheKey = `course:${courseId}`;
const cached = await cache.get(cacheKey);
if (cached === NOT_FOUND) {
return null;
}
if (cached) {
return JSON.parse(cached) as Course;
}
const course =
await courseRepository.findById(courseId);
if (!course) {
await cache.set(cacheKey, NOT_FOUND, {
ttlSeconds: 30,
});
return null;
}
await cache.set(cacheKey, JSON.stringify(course), {
ttlSeconds: 300,
});
return course;
}
이를 부정 캐싱이라고 볼 수 있다.
하지만 이후 같은 ID의 강의가 생성되면 30초 동안 여전히 존재하지 않는다고 응답할 수 있다. 따라서 생성 시에도 관련 캐시를 삭제해야 한다.
await courseRepository.create({
id: courseId,
title,
});
await cache.delete(`course:${courseId}`);
존재하지 않음도 캐시된 데이터다. 생성, 복구, 공개 상태 변경이 발생할 때 어떻게 무효화할지 함께 결정해야 한다.
인기 강의의 캐시가 만료되는 순간 수천 개의 요청이 들어온다고 생각해 보자.
const cachedCourse = await cache.get(cacheKey);
if (!cachedCourse) {
const course =
await courseRepository.findById(courseId);
await cache.set(cacheKey, course, {
ttlSeconds: 300,
});
return course;
}
각 요청은 캐시 미스를 확인하고 동시에 데이터베이스를 조회할 수 있다.
캐시 만료
↓
요청 1 ─┐
요청 2 ─┼─> 동일한 DB 조회 반복
요청 3 ─┤
요청 N ─┘
이를 캐시 스탬피드 또는 Thundering Herd 문제라고 부른다.
하나의 요청만 원본 데이터를 다시 가져오도록 짧은 잠금을 사용할 수 있다.
const lockKey = `lock:course:${courseId}`;
const acquired =
await cache.tryAcquireLock(lockKey, {
ttlSeconds: 5,
});
if (acquired) {
try {
const course =
await courseRepository.findById(courseId);
await cache.set(cacheKey, course, {
ttlSeconds: 300,
});
return course;
} finally {
await cache.releaseLock(lockKey);
}
}
잠금을 얻지 못한 요청은 잠시 기다린 뒤 캐시를 다시 확인하거나, 허용된다면 이전 값을 반환할 수 있다.
모든 데이터의 만료 시점이 정확히 같아지는 것도 피할 수 있다.
const baseTtlSeconds = 300;
const jitterSeconds = Math.floor(Math.random() * 30);
await cache.set(cacheKey, course, {
ttlSeconds: baseTtlSeconds + jitterSeconds,
});
TTL에 작은 무작위 값을 더하면 많은 키가 같은 순간에 만료될 가능성을 줄인다.
캐시 미스는 단순히 느린 요청 하나를 의미하지 않는다. 많은 요청이 동시에 원본 저장소로 이동하는 트래픽 전환 사건이 될 수 있다.
데이터베이스가 일시적으로 느리거나 사용할 수 없는 상황을 생각해 보자. 캐시에 최근 강의 정보가 남아 있다면 서비스는 두 가지 선택을 할 수 있다.
최신 데이터를 보장할 수 없으므로 오류 반환
또는
오래된 데이터임을 감수하고 캐시 값 반환
강의 소개 페이지라면 잠시 오래된 설명을 보여주는 편이 전체 오류 화면보다 나을 수 있다. 반면 결제 가격이나 수강 권한은 오래된 값을 사용하면 안 될 수 있다.
오래된 값을 사용할 수 있는 데이터에는 두 개의 시간을 둘 수 있다.
type CachedValue<T> = {
data: T;
freshUntil: string;
staleUntil: string;
};
freshUntil 전에는 일반적인 최신 캐시로 사용한다.freshUntil 이후에는 백그라운드 갱신을 시도한다.staleUntil까지 이전 값을 사용할 수 있다.staleUntil도 지나면 오류를 반환한다.if (now < cached.freshUntil) {
return cached.data;
}
try {
const freshCourse =
await courseRepository.findById(courseId);
await refreshCache(freshCourse);
return freshCourse;
} catch (error) {
if (now < cached.staleUntil) {
return cached.data;
}
throw error;
}
이 정책은 가용성을 높이지만 오래된 데이터 노출을 허용한다.
따라서 “캐시가 있으면 반환한다”가 아니라 데이터 종류별로 다음을 정해야 한다.
캐시의 핵심 설계는 데이터가 빠른지가 아니라 최신 정보와 서비스 가용성 중 무엇을 어느 정도 우선할지 정하는 데 있다.
온라인 강의 서비스에는 여러 종류의 캐시가 존재할 수 있다.
| 위치 | 적합한 데이터 | 주요 책임 |
|---|---|---|
| 브라우저 메모리 | 현재 화면의 강의 목록 | 탭이 열려 있는 동안 재사용 |
| 브라우저 저장소 | 다시 사용할 수 있는 비민감 설정 | 만료와 사용자 전환 처리 |
| CDN | 공개 이미지와 공개 페이지 | URL·헤더 기반 무효화 |
| 애플리케이션 메모리 | 프로세스 안의 짧은 참조 데이터 | 서버별 데이터 차이 관리 |
| 분산 캐시 | 여러 서버가 공유하는 강의 데이터 | 키, TTL, 장애 대응 |
| 데이터베이스 내부 캐시 | 자주 읽는 페이지와 실행 계획 | 데이터베이스가 관리 |
애플리케이션 서버의 메모리에 강의를 저장하면 구현은 단순하다.
const courseCache = new Map<string, Course>();
하지만 서버가 여러 대라면 각 서버가 서로 다른 값을 가질 수 있다.
Server A cache: price 4900
Server B cache: price 7900
Server C cache: no value
분산 캐시를 사용하면 여러 서버가 같은 값을 공유할 수 있지만 네트워크 호출과 별도의 장애 지점이 생긴다.
캐시 기술을 선택하기 전에 다음을 확인해야 한다.
캐시 위치는 편의성으로 결정되지 않는다. 데이터의 공유 범위, 민감도, 생명주기와 실패 정책에 따라 결정된다.
캐시를 추가한 뒤에는 실제 효과를 측정해야 한다.
metrics.increment("course_cache_hit");
metrics.increment("course_cache_miss");
metrics.observe("course_cache_read_ms", duration);
기본적인 지표에는 다음이 포함될 수 있다.
히트율이 높아도 캐시 조회 자체가 느리거나, 거의 사용되지 않는 거대한 값이 메모리를 차지할 수 있다.
히트율이 낮다면 다음 원인을 확인해야 한다.
반대로 히트율이 높아도 오래된 가격이나 잘못된 권한 데이터가 반환된다면 좋은 캐시라고 할 수 없다.
캐시의 성공 기준에는 속도뿐 아니라 최신성, 정확성, 장애 영향, 운영 비용이 함께 포함되어야 한다.
온라인 강의 상세 정보의 전체 흐름을 정리하면 다음과 같다.
flowchart LR
A[강의 조회 요청] --> B{캐시 존재?}
B -->|예| C[캐시 값 반환]
B -->|아니오| D[데이터베이스 조회]
D --> E[캐시 저장]
E --> C
F[강의 수정 요청] --> G[입력·권한 검증]
G --> H[데이터베이스 수정]
H --> I[캐시 무효화 이벤트]
I --> J[관련 캐시 삭제]
조회 흐름에서는 빠른 응답과 캐시 미스 처리를 설계한다. 변경 흐름에서는 원본 수정 이후 어떤 캐시가 오래된 상태가 되는지 추적한다.
한쪽만 구현하면 문제가 생긴다.
캐시는 읽기 코드 한 줄이 아니라 조회, 변경, 장애, 복구가 연결된 데이터 흐름이다.
한 번만 사용되는 결과나 항상 달라지는 결과는 재사용 가능성이 낮다.
반복 비용과 최신성 요구를 확인한 뒤 캐시해야 한다.
강의 설명과 현재 가격은 오래된 값이 미치는 영향이 다르다.
TTL은 데이터별 비즈니스 위험을 기준으로 정해야 한다.
원본이 바뀌어도 캐시가 이전 값을 반환할 수 있다.
변경 경로에는 무효화 또는 갱신 정책이 포함되어야 한다.
수강 여부, 진도율, 추천 결과가 다른 사용자에게 노출될 수 있다.
공개 데이터와 개인 데이터를 분리하고 사용자 범위를 키에 포함해야 한다.
캐시가 최적화 계층이라면 원본 조회로 우회할 수 있는 경우가 있다.
다만 우회 트래픽으로 원본이 과부하되지 않도록 보호 정책도 필요하다.
인기 키가 만료되면 수많은 요청이 동시에 데이터베이스로 이동할 수 있다.
잠금, 만료 시간 분산, 이전 값 사용 같은 정책을 검토해야 한다.
잘못된 가격이나 다른 사용자의 데이터를 빠르게 반환하는 캐시는 성공한 캐시가 아니다.
성능과 함께 최신성, 권한 격리, 장애 영향을 측정해야 한다.
프로그래밍을 처음 배울 때는 캐시를 자주 사용하는 데이터를 잠시 보관하는 공간이라고 이해해도 충분하다.
const value =
(await cache.get(key)) ??
(await repository.findById(id));
하지만 실제 서비스에서 캐시를 설계할 때는 데이터를 저장하는 방법보다 복사본을 운영하는 책임이 중요하다.
캐시는 데이터를 잠시 저장하는 공간이 아니다.
캐시는 원본 데이터를 더 빠르게 제공하기 위해 일정 기간 오래된 값을 허용하고, 갱신·삭제·복구 책임을 함께 떠안는 속도와 최신성 사이의 설계다.