Skip to main content

캐싱이 필요한 이유

운세 데이터는 자연스러운 캐싱 기회를 제공합니다:
데이터 유형캐시 기간이유
사주 계산영구결정론적 - 동일 입력 = 동일 출력
일일 운세24시간날짜에 따라 매일 변경
성격 프로필영구생년월일 기반, 절대 변경 안됨

캐싱 아키텍처

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   클라이언트 │────▶│   백엔드    │────▶│  API        │
│   캐시      │     │   캐시      │     │  (Redis)    │
│ (localStorage)│   │  (Redis)    │     │             │
└─────────────┘     └─────────────┘     └─────────────┘
      ↓                   ↓                   ↓
   1시간              24시간             내장

클라이언트 캐싱

브라우저 localStorage

const CACHE_VERSION = 1;
const CACHE_TTL = 60 * 60 * 1000; // 1시간

interface CacheEntry<T> {
  data: T;
  expiry: number;
}

function getCached<T>(key: string): T | null {
  const cacheKey = `fortune_v${CACHE_VERSION}_${key}`;
  const cached = localStorage.getItem(cacheKey);

  if (!cached) return null;

  const entry: CacheEntry<T> = JSON.parse(cached);
  if (Date.now() > entry.expiry) {
    localStorage.removeItem(cacheKey);
    return null;
  }

  return entry.data;
}

function setCache<T>(key: string, data: T, ttlMs = CACHE_TTL): void {
  const cacheKey = `fortune_v${CACHE_VERSION}_${key}`;
  const entry: CacheEntry<T> = {
    data,
    expiry: Date.now() + ttlMs,
  };
  localStorage.setItem(cacheKey, JSON.stringify(entry));
}

캐싱을 사용한 React Hook

function useCachedFortune(saju: Saju, userName: string) {
  const [fortune, setFortune] = useState<FortuneData | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const today = new Date().toISOString().split('T')[0];
    const cacheKey = `${saju.dayMaster.stem}_${today}`;

    // 캐시 먼저 확인
    const cached = getCached<FortuneData>(cacheKey);
    if (cached) {
      setFortune(cached);
      setIsLoading(false);
      return;
    }

    // API에서 조회
    fetchFortune(saju, userName)
      .then(data => {
        setCache(cacheKey, data);
        setFortune(data);
      })
      .finally(() => setIsLoading(false));
  }, [saju, userName]);

  return { fortune, isLoading };
}

서버 캐싱

Redis 구현

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function getCachedFortune(
  dayMaster: string,
  date: string
): Promise<FortuneData | null> {
  const key = `fortune:${dayMaster}:${date}`;
  const cached = await redis.get(key);
  return cached ? JSON.parse(cached) : null;
}

async function cacheFortune(
  dayMaster: string,
  date: string,
  data: FortuneData
): Promise<void> {
  const key = `fortune:${dayMaster}:${date}`;
  const ttl = getSecondsUntilMidnight();
  await redis.setex(key, ttl, JSON.stringify(data));
}

function getSecondsUntilMidnight(): number {
  const now = new Date();
  const midnight = new Date(now);
  midnight.setHours(24, 0, 0, 0);
  return Math.floor((midnight.getTime() - now.getTime()) / 1000);
}

캐시 키 전략

일관된 키 형식을 사용하세요:
// 좋음: 모든 관련 변수 포함
const key = `fortune:${dayMaster}:${date}:${model}`;

// 나쁨: 날짜 누락 - 오래된 데이터 반환
const key = `fortune:${dayMaster}`;
키 구성요소:
  • dayMaster - 사용자의 일간(日干) (10가지 가능한 값)
  • date - 오늘 날짜 (YYYY-MM-DD 형식)
  • model - 사용된 AI 모델 (haiku, sonnet, gpt4o)

캐시 워밍

사용자 요청 전에 운세를 미리 생성하세요:
// cron으로 매일 자정에 실행
async function warmCache() {
  const dayMasters = ['갑', '을', '병', '정', '무', '기', '경', '신', '임', '계'];
  const today = new Date().toISOString().split('T')[0];

  for (const dayMaster of dayMasters) {
    const fortune = await generateFortune(dayMaster, today);
    await cacheFortune(dayMaster, today, fortune);

    // API 과부하 방지를 위한 요청 제한
    await sleep(1000);
  }

  console.log(`${dayMasters.length}개 일간에 대한 캐시 워밍 완료`);
}
10개의 일간만 있으므로, 하루의 모든 가능한 운세를 단 10번의 API 호출로 미리 생성할 수 있습니다.

Upstash Redis (서버리스)

서버리스 배포에는 Upstash Redis를 사용하세요:
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

// ioredis와 동일한 API
await redis.set('key', 'value', { ex: 86400 });
const value = await redis.get('key');
환경 변수:
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxx

캐시 무효화

시간 기반 (권장)

자정에 만료되도록 TTL 설정:
const ttl = getSecondsUntilMidnight();
await redis.setex(key, ttl, data);

수동 무효화

관리자 재설정 또는 콘텐츠 업데이트용:
async function invalidateFortuneCache(dayMaster?: string) {
  if (dayMaster) {
    // 특정 일간 무효화
    const keys = await redis.keys(`fortune:${dayMaster}:*`);
    if (keys.length) await redis.del(...keys);
  } else {
    // 모든 운세 무효화
    const keys = await redis.keys('fortune:*');
    if (keys.length) await redis.del(...keys);
  }
}

버전 관리 캐싱

캐시 형식 변경을 우아하게 처리하세요:
const CACHE_VERSION = 2; // 형식 변경 시 증가

function getCacheKey(base: string): string {
  return `v${CACHE_VERSION}:${base}`;
}

// 이전 키 (v1:fortune:...)는 자동으로 무시됨
// 새 키 (v2:fortune:...)가 사용됨

비용 절감 효과

적절한 캐싱 사용 시:
시나리오일일 API 호출일일 비용
캐싱 없음100,000~$130
클라이언트 캐시만50,000~$65
+ 서버 캐시10,000~$13
+ 캐시 워밍10~$0.01
10개 일간으로 캐시 워밍 시 트래픽에 관계없이 하루 약 $0.01만 소요됩니다.

캐시 디버깅

캐시 상태 확인

// API가 meta에 캐시 상태 반환
const response = await fetch('/api/daily-fortune', { ... });
const { data, meta } = await response.json();

console.log('캐시에서:', meta.cached);
console.log('캐시 키:', meta.cacheKey);

새 데이터 강제 조회

// 캐시 무효화 파라미터 추가
const response = await fetch('/api/daily-fortune?refresh=true', { ... });

모범 사례

권장

  • 일간 + 날짜로 캐시
  • 데이터 신선도에 맞는 TTL 사용
  • 캐시 워밍 구현
  • 캐시 키 버전 관리

비권장

  • 공유 캐시에 사용자별 데이터 저장
  • 시간에 민감한 데이터에 무한 TTL 사용
  • 개발 환경에서 캐시 건너뛰기
  • 캐시 미스 처리 누락