캐시는 데이터를 잠시 저장하는 공간이 아니다

vx_developer·1일 전

개발하다가

목록 보기
37/39
post-thumbnail

프로그래밍을 처음 배울 때 캐시는 자주 사용하는 데이터를 잠시 저장해 다음 요청에서 빠르게 꺼내 쓰는 공간이라고 배운다.

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,
  };
}

이 결과가 모든 비로그인 사용자에게 거의 동일하고 짧은 시간 동안 자주 요청된다면 캐시의 이점이 커질 수 있다.

캐시를 설계할 때는 먼저 다음을 확인해야 한다.

  • 어느 사용자 흐름이 느린가?
  • 데이터베이스 조회, 외부 API, 계산 중 어디에 시간이 걸리는가?
  • 같은 결과가 얼마나 자주 반복되는가?
  • 데이터가 얼마나 자주 변경되는가?
  • 몇 초 또는 몇 분 오래된 결과를 허용할 수 있는가?

캐시는 느린 코드 위에 붙이는 일반적인 성능 옵션이 아니다. 반복되는 비용과 허용 가능한 최신성의 범위가 확인되었을 때 선택하는 데이터 접근 경로다.


캐시된 값은 원본이 아니라 원본의 복사본이다

강의 정보의 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는 읽기 경로와 실패 경로를 함께 만든다

가장 이해하기 쉬운 방식 중 하나는 애플리케이션이 캐시를 먼저 확인하는 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;
}

이 코드는 다음 순서로 동작한다.

  1. 캐시에서 강의를 찾는다.
  2. 캐시 히트라면 바로 반환한다.
  3. 캐시 미스라면 데이터베이스를 조회한다.
  4. 조회 결과를 캐시에 저장한다.
  5. 이후 요청에서 캐시를 사용한다.

하지만 실제 서비스에서는 캐시 저장 실패도 고려해야 한다.

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는 캐시를 먼저 읽는 코드 패턴만 의미하지 않는다. 캐시 히트, 캐시 미스, 캐시 장애, 원본 장애 각각에서 어떤 결과를 반환할지 결정하는 읽기 정책이다.


TTL은 정리 시간이 아니라 오래된 데이터를 허용하는 시간이다

강의 정보를 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(":");

캐시 키에는 결과를 바꾸는 조건이 포함되어야 한다.

  • 데이터의 종류
  • 데이터의 ID
  • 사용자 또는 조직 범위
  • 언어와 지역
  • 필터와 정렬
  • 페이지 또는 커서
  • 응답 구조 버전

조건이 빠지면 서로 다른 요청이 같은 캐시를 공유하는 충돌이 발생한다. 반대로 지나치게 많은 값이 포함되면 거의 재사용되지 않는 캐시가 만들어진다.

캐시 키도 외부 입력의 영향을 받을 수 있다. 외부 입력은 검증 전까지 신뢰할 수 없다.

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);

기본적인 지표에는 다음이 포함될 수 있다.

  • 캐시 히트 수와 비율
  • 캐시 미스 수와 비율
  • 캐시 읽기·쓰기 지연 시간
  • 데이터베이스 우회 요청 수
  • 캐시 오류 수
  • 무효화 실패 수
  • 저장된 키 수와 메모리 사용량
  • 만료 또는 제거된 키 수
  • 오래된 값이 반환된 횟수

히트율이 높아도 캐시 조회 자체가 느리거나, 거의 사용되지 않는 거대한 값이 메모리를 차지할 수 있다.

히트율이 낮다면 다음 원인을 확인해야 한다.

  • TTL이 지나치게 짧은가?
  • 캐시 키에 불필요하게 많은 조건이 들어가는가?
  • 매 요청마다 고유한 키가 생성되는가?
  • 실제로 반복되는 조회가 아닌가?
  • 무효화가 너무 자주 발생하는가?

반대로 히트율이 높아도 오래된 가격이나 잘못된 권한 데이터가 반환된다면 좋은 캐시라고 할 수 없다.

캐시의 성공 기준에는 속도뿐 아니라 최신성, 정확성, 장애 영향, 운영 비용이 함께 포함되어야 한다.


캐시 흐름은 조회와 변경의 두 방향으로 설계해야 한다

온라인 강의 상세 정보의 전체 흐름을 정리하면 다음과 같다.

flowchart LR
    A[강의 조회 요청] --> B{캐시 존재?}
    B -->|예| C[캐시 값 반환]
    B -->|아니오| D[데이터베이스 조회]
    D --> E[캐시 저장]
    E --> C

    F[강의 수정 요청] --> G[입력·권한 검증]
    G --> H[데이터베이스 수정]
    H --> I[캐시 무효화 이벤트]
    I --> J[관련 캐시 삭제]

조회 흐름에서는 빠른 응답과 캐시 미스 처리를 설계한다. 변경 흐름에서는 원본 수정 이후 어떤 캐시가 오래된 상태가 되는지 추적한다.

한쪽만 구현하면 문제가 생긴다.

  • 조회 흐름만 있으면 오래된 캐시가 남는다.
  • 변경 흐름만 있고 장애 복구가 없으면 무효화 실패가 누적된다.
  • TTL만 있으면 허용 시간 동안 오래된 데이터가 노출된다.
  • 무효화만 있고 TTL이 없으면 실패한 캐시가 오래 남을 수 있다.
  • 캐시만 믿으면 원본과 다른 값으로 중요한 작업을 확정할 수 있다.

캐시는 읽기 코드 한 줄이 아니라 조회, 변경, 장애, 복구가 연결된 데이터 흐름이다.


캐시를 설계하기 전에 물어봐야 할 질문

무엇을 캐시하려는가

  1. 실제로 반복되는 조회인가?
  2. 데이터베이스, 외부 API, 계산 중 어떤 비용을 줄이려는가?
  3. 모든 사용자에게 같은 결과인가?
  4. 응답 전체가 필요한가, 일부 공개 데이터만 필요한가?
  5. 캐시를 추가하지 않고 쿼리나 구조를 개선할 수 있는가?

얼마나 오래된 값을 허용할 수 있는가

  1. 원본 변경 후 몇 초 또는 몇 분 동안 이전 값을 보여도 되는가?
  2. 가격, 권한, 재고처럼 다시 검증해야 하는 값인가?
  3. 원본 장애 시 오래된 값을 사용할 수 있는가?
  4. 오래된 값임을 사용자에게 표시해야 하는가?
  5. TTL은 비즈니스 위험을 기준으로 정했는가?

Source of Truth가 명확한가

  1. 원본 데이터는 데이터베이스인가, 외부 서비스인가?
  2. 캐시를 삭제한 뒤 원본에서 다시 만들 수 있는가?
  3. 캐시 값을 원본 데이터처럼 수정하고 있지 않은가?
  4. 원본과 캐시가 다를 때 어느 쪽을 기준으로 복구하는가?
  5. 거래를 확정하기 전에 원본을 다시 확인하는가?

무효화 경로가 존재하는가

  1. 원본이 변경되면 어떤 캐시가 영향을 받는가?
  2. 삭제, 갱신, TTL 중 어떤 방법을 사용할 것인가?
  3. 캐시 무효화 실패를 감지하고 재시도할 수 있는가?
  4. 생성과 삭제에도 부정 캐시를 제거하는가?
  5. 응답 구조가 바뀔 때 키 버전을 변경하는가?

키가 데이터 경계를 표현하는가

  1. 사용자나 조직별 데이터를 구분하는가?
  2. 언어, 지역, 필터, 정렬 조건이 결과에 영향을 주는가?
  3. 검증되지 않은 입력으로 무제한 키를 만들고 있지 않은가?
  4. 서로 다른 요청이 실수로 같은 키를 공유하지 않는가?
  5. 키에 개인정보나 비밀값이 포함되지 않는가?

장애 시 어떻게 동작하는가

  1. 캐시 장애 시 원본 저장소로 우회할 수 있는가?
  2. 우회 요청이 몰려도 원본이 감당할 수 있는가?
  3. 오래된 값을 반환할 수 있는가?
  4. 원본과 캐시가 모두 실패하면 어떤 기능을 축소할 것인가?
  5. 타임아웃과 실패 처리가 명시되어 있는가?

동시에 많은 요청이 들어오면 안전한가

  1. 인기 키가 만료될 때 요청이 한꺼번에 원본으로 이동하지 않는가?
  2. 하나의 요청만 캐시를 갱신하도록 조정할 필요가 있는가?
  3. TTL에 작은 차이를 둘 필요가 있는가?
  4. 존재하지 않는 데이터도 반복 조회되고 있지 않은가?
  5. 캐시 갱신 작업이 중복되어도 결과가 안전한가?

효과를 측정하고 있는가

  1. 히트율과 미스율을 확인하는가?
  2. 캐시가 실제 응답 시간을 줄였는가?
  3. 데이터베이스 부하가 감소했는가?
  4. 캐시 오류와 무효화 실패를 관찰하는가?
  5. 사용되지 않는 키와 캐시 코드를 제거할 수 있는가?

흔한 실수는 캐시를 빠른 데이터베이스처럼 사용하는 것이다

모든 조회 결과를 캐시한다

한 번만 사용되는 결과나 항상 달라지는 결과는 재사용 가능성이 낮다.

반복 비용과 최신성 요구를 확인한 뒤 캐시해야 한다.

모든 데이터에 같은 TTL을 사용한다

강의 설명과 현재 가격은 오래된 값이 미치는 영향이 다르다.

TTL은 데이터별 비즈니스 위험을 기준으로 정해야 한다.

데이터베이스만 수정하고 캐시는 그대로 둔다

원본이 바뀌어도 캐시가 이전 값을 반환할 수 있다.

변경 경로에는 무효화 또는 갱신 정책이 포함되어야 한다.

사용자별 결과를 공용 키에 저장한다

수강 여부, 진도율, 추천 결과가 다른 사용자에게 노출될 수 있다.

공개 데이터와 개인 데이터를 분리하고 사용자 범위를 키에 포함해야 한다.

캐시가 실패하면 요청도 바로 실패시킨다

캐시가 최적화 계층이라면 원본 조회로 우회할 수 있는 경우가 있다.

다만 우회 트래픽으로 원본이 과부하되지 않도록 보호 정책도 필요하다.

캐시 미스를 하나의 느린 요청으로만 본다

인기 키가 만료되면 수많은 요청이 동시에 데이터베이스로 이동할 수 있다.

잠금, 만료 시간 분산, 이전 값 사용 같은 정책을 검토해야 한다.

캐시 히트율만 높으면 성공이라고 생각한다

잘못된 가격이나 다른 사용자의 데이터를 빠르게 반환하는 캐시는 성공한 캐시가 아니다.

성능과 함께 최신성, 권한 격리, 장애 영향을 측정해야 한다.


캐시의 핵심은 속도와 최신성 사이에서 책임을 선택하는 데 있다

프로그래밍을 처음 배울 때는 캐시를 자주 사용하는 데이터를 잠시 보관하는 공간이라고 이해해도 충분하다.

const value =
  (await cache.get(key)) ??
  (await repository.findById(id));

하지만 실제 서비스에서 캐시를 설계할 때는 데이터를 저장하는 방법보다 복사본을 운영하는 책임이 중요하다.

  • 어떤 반복 비용을 줄이려는가?
  • 얼마 동안 오래된 값을 허용할 수 있는가?
  • 원본 데이터의 Source of Truth는 어디인가?
  • 원본이 바뀌면 어떤 캐시를 무효화해야 하는가?
  • 데이터베이스 변경과 캐시 무효화가 따로 실패할 때 어떻게 복구하는가?
  • 사용자와 권한에 따라 캐시가 안전하게 분리되는가?
  • 캐시 미스가 동시에 발생해도 원본이 버틸 수 있는가?
  • 캐시 장애 시 원본 조회나 오래된 값으로 우회할 수 있는가?
  • 거래를 확정하기 전에 중요한 값을 다시 검증하는가?
  • 캐시가 실제로 성능을 개선했는지 측정하는가?

캐시는 데이터를 잠시 저장하는 공간이 아니다.

캐시는 원본 데이터를 더 빠르게 제공하기 위해 일정 기간 오래된 값을 허용하고, 갱신·삭제·복구 책임을 함께 떠안는 속도와 최신성 사이의 설계다.

profile
Vision eXperience Developer

0개의 댓글