[Next.js] Server-side 캐시 vs Client-side 캐시




현대 웹 애플리케이션의 성능을 좌우하는 가장 중요한 요소 중 하나는 캐싱 전략입니다. 사용자는 빠른 로딩 시간을 기대하고, 개발자는 서버 비용을 절약하며 확장 가능한 시스템을 구축해야 합니다.

캐싱은 크게 Server-side 캐시와 Client-side 캐시로 나뉘며, 각각은 고유한 특성과 장단점을 가지고 있습니다. 이 글에서는 두 캐싱 방식의 차이점, 구현 방법, 그리고 Next.js 환경에서의 실제 활용 사례를 깊이 있게 살펴보겠습니다.


Server-side 캐시: 서버에서의 데이터 저장소

Server-side 캐시는 서버 측에서 데이터나 연산 결과를 임시로 저장하는 메커니즘입니다. 데이터베이스 쿼리 결과, API 응답, 렌더링된 페이지 등을 메모리나 별도 저장소에 보관하여 동일한 요청에 대해 빠르게 응답할 수 있습니다.

Server-side 캐시의 특징

1. 중앙집중식 관리 모든 사용자가 동일한 캐시를 공유하므로 일관성을 보장하기 쉽습니다.

2. 높은 적중률(Hit Rate) 여러 사용자가 비슷한 데이터를 요청할 때 캐시 효율이 극대화됩니다.

3. 서버 리소스 절약 데이터베이스 부하를 줄이고 CPU 집약적인 연산을 최소화합니다.

4. 즉시 무효화 가능 데이터가 변경될 때 캐시를 즉시 갱신하여 모든 사용자에게 최신 데이터를 제공할 수 있습니다.

Next.js에서의 Server-side 캐싱 구현

1. App Router의 기본 캐싱

Next.js 13+에서는 기본적으로 여러 레벨의 서버 캐싱을 제공합니다.

// app/products/[id]/page.js
import { cache } from 'react';

// React cache로 컴포넌트 레벨 캐싱
const getProduct = cache(async (id) => {
  console.log('DB 조회:', id); // 동일한 요청에 대해 한 번만 실행됨
  const response = await fetch(`${process.env.API_URL}/products/${id}`, {
    // Next.js 기본 캐싱 설정
    next: { 
      revalidate: 3600, // 1시간 캐시
      tags: ['product', `product-${id}`] // 캐시 태그
    }
  });
  return response.json();
});

// 동일한 렌더링 중 여러 번 호출되어도 한 번만 실행
async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  
  return (
    <div>
      <ProductInfo product={product} />
      <RelatedProducts productId={params.id} />
    </div>
  );
}

async function ProductInfo({ product }) {
  // 이미 캐시된 데이터 재사용
  const sameProduct = await getProduct(product.id);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  );
}

export default ProductPage;

2. 커스텀 서버 캐시 구현

Redis를 활용한 고급 캐싱 전략:

// lib/cache.js
import Redis from 'ioredis';

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

class CacheManager {
  constructor() {
    this.defaultTTL = 3600; // 1시간
  }

  // 캐시 키 생성
  generateKey(namespace, identifier, params = {}) {
    const paramString = Object.keys(params)
      .sort()
      .map(key => `${key}:${params[key]}`)
      .join('|');
    
    return `${namespace}:${identifier}${paramString ? `:${paramString}` : ''}`;
  }

  // 캐시 저장
  async set(key, data, ttl = this.defaultTTL) {
    const serialized = JSON.stringify({
      data,
      timestamp: Date.now(),
      ttl
    });
    
    await redis.setex(key, ttl, serialized);
    console.log(`캐시 저장: ${key}`);
  }

  // 캐시 조회
  async get(key) {
    const cached = await redis.get(key);
    if (!cached) return null;

    try {
      const { data, timestamp } = JSON.parse(cached);
      console.log(`캐시 히트: ${key} (저장시간: ${new Date(timestamp)})`);
      return data;
    } catch (error) {
      console.error('캐시 파싱 에러:', error);
      await this.delete(key);
      return null;
    }
  }

  // 캐시 삭제
  async delete(key) {
    await redis.del(key);
    console.log(`캐시 삭제: ${key}`);
  }

  // 패턴 기반 캐시 삭제
  async deleteByPattern(pattern) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
      console.log(`패턴 삭제: ${pattern} (${keys.length}개 키)`);
    }
  }

  // 캐시 또는 데이터 조회 (Cache-aside 패턴)
  async getOrSet(key, fetcher, ttl = this.defaultTTL) {
    // 1. 캐시 확인
    let data = await this.get(key);
    if (data !== null) return data;

    // 2. 캐시 미스 - 데이터 조회
    console.log(`캐시 미스: ${key} - 데이터 조회 중...`);
    data = await fetcher();

    // 3. 캐시 저장
    if (data !== null && data !== undefined) {
      await this.set(key, data, ttl);
    }

    return data;
  }
}

export const cacheManager = new CacheManager();

// API 라우트에서 캐시 활용
// app/api/products/[id]/route.js
import { cacheManager } from '@/lib/cache';
import { NextResponse } from 'next/server';

export async function GET(request, { params }) {
  const { id } = params;
  
  try {
    const cacheKey = cacheManager.generateKey('product', id);
    
    const product = await cacheManager.getOrSet(
      cacheKey,
      async () => {
        // 실제 데이터베이스 쿼리
        const response = await fetch(`${process.env.DB_API}/products/${id}`);
        if (!response.ok) throw new Error('Product not found');
        return response.json();
      },
      1800 // 30분 캐시
    );

    return NextResponse.json(product);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch product' }, 
      { status: 500 }
    );
  }
}

// 제품 업데이트 시 캐시 무효화
export async function PUT(request, { params }) {
  const { id } = params;
  
  try {
    const body = await request.json();
    
    // 데이터베이스 업데이트
    const response = await fetch(`${process.env.DB_API}/products/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });

    if (!response.ok) throw new Error('Update failed');
    
    const updatedProduct = await response.json();

    // 관련 캐시 무효화
    await cacheManager.deleteByPattern(`product:${id}*`);
    await cacheManager.deleteByPattern('products:list*'); // 제품 목록 캐시도 무효화
    
    console.log(`제품 ${id} 업데이트 후 캐시 무효화 완료`);

    return NextResponse.json(updatedProduct);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to update product' }, 
      { status: 500 }
    );
  }
}

3. 데이터베이스 쿼리 캐싱

// lib/db-cache.js
import { cacheManager } from './cache';

export class DatabaseCache {
  static async getCachedQuery(queryKey, queryFunction, ttl = 3600) {
    return cacheManager.getOrSet(
      `db:${queryKey}`,
      queryFunction,
      ttl
    );
  }

  // 사용자별 데이터 캐싱
  static async getUserData(userId) {
    return this.getCachedQuery(
      `user:${userId}:profile`,
      async () => {
        const user = await db.user.findUnique({
          where: { id: userId },
          include: { 
            profile: true,
            preferences: true 
          }
        });
        return user;
      },
      1800 // 30분
    );
  }

  // 복잡한 집계 쿼리 캐싱
  static async getProductStats(categoryId) {
    return this.getCachedQuery(
      `stats:products:category:${categoryId}`,
      async () => {
        const stats = await db.product.aggregate({
          where: { categoryId },
          _count: { id: true },
          _avg: { price: true, rating: true },
          _min: { price: true },
          _max: { price: true }
        });
        
        return {
          totalProducts: stats._count.id,
          avgPrice: stats._avg.price,
          avgRating: stats._avg.rating,
          priceRange: {
            min: stats._min.price,
            max: stats._max.price
          }
        };
      },
      7200 // 2시간
    );
  }

  // 캐시 무효화 헬퍼
  static async invalidateUserCache(userId) {
    await cacheManager.deleteByPattern(`db:user:${userId}*`);
  }

  static async invalidateProductCache(categoryId = null) {
    if (categoryId) {
      await cacheManager.deleteByPattern(`db:*:category:${categoryId}*`);
    } else {
      await cacheManager.deleteByPattern('db:*:product*');
    }
  }
}

Client-side 캐시: 브라우저에서의 데이터 저장

Client-side 캐시는 브라우저나 클라이언트 애플리케이션에서 데이터를 저장하는 방식입니다. 사용자별로 개별적인 캐시를 가지며, 네트워크 요청을 줄여 응답속도를 향상시킵니다.

Client-side 캐시의 특징

1. 개인화된 캐시 각 사용자가 자신만의 캐시를 가지므로 개인화된 경험이 가능합니다.

2. 네트워크 요청 최소화 이미 캐시된 데이터는 서버 요청 없이 즉시 사용할 수 있습니다.

3. 오프라인 지원 적절히 구현하면 네트워크가 끊어진 상황에서도 기본적인 기능을 제공할 수 있습니다.

4. 브라우저 제한 브라우저의 저장 용량 제한과 사용자의 브라우저 설정에 영향을 받습니다.

React Query를 활용한 Client-side 캐싱

// lib/query-client.js
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,     // 5분 동안 fresh 상태 유지
      cacheTime: 10 * 60 * 1000,    // 10분 동안 캐시 보관
      retry: 2,                     // 실패 시 2번 재시도
      refetchOnWindowFocus: false,  // 창 포커스 시 자동 refetch 비활성화
    },
    mutations: {
      retry: 1,
    },
  },
});

// hooks/useProducts.js
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';

// 제품 목록 조회
export function useProducts(filters = {}) {
  return useQuery({
    queryKey: ['products', filters],
    queryFn: async () => {
      const params = new URLSearchParams(filters);
      const response = await fetch(`/api/products?${params}`);
      if (!response.ok) throw new Error('Failed to fetch products');
      return response.json();
    },
    staleTime: 2 * 60 * 1000, // 2분
    select: (data) => {
      // 데이터 변환 및 필터링
      return {
        products: data.products.filter(product => product.isActive),
        totalCount: data.totalCount,
        hasNextPage: data.hasNextPage
      };
    },
  });
}

// 개별 제품 상세 정보
export function useProduct(productId) {
  return useQuery({
    queryKey: ['product', productId],
    queryFn: async () => {
      const response = await fetch(`/api/products/${productId}`);
      if (!response.ok) throw new Error('Product not found');
      return response.json();
    },
    enabled: !!productId, // productId가 있을 때만 실행
    staleTime: 5 * 60 * 1000, // 5분
  });
}

// 제품 업데이트 뮤테이션
export function useUpdateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ productId, data }) => {
      const response = await fetch(`/api/products/${productId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      if (!response.ok) throw new Error('Update failed');
      return response.json();
    },
    onSuccess: (updatedProduct, { productId }) => {
      // 개별 제품 캐시 업데이트
      queryClient.setQueryData(['product', productId], updatedProduct);
      
      // 제품 목록 캐시 무효화 (다양한 필터 조건이 있을 수 있음)
      queryClient.invalidateQueries(['products']);
      
      // 관련 쿼리들도 무효화
      queryClient.invalidateQueries(['product-stats']);
    },
    onError: (error) => {
      console.error('제품 업데이트 실패:', error);
    },
  });
}

// 무한 스크롤을 위한 무한 쿼리
export function useInfiniteProducts(filters = {}) {
  return useInfiniteQuery({
    queryKey: ['products', 'infinite', filters],
    queryFn: async ({ pageParam = 1 }) => {
      const params = new URLSearchParams({
        ...filters,
        page: pageParam,
        limit: 20
      });
      const response = await fetch(`/api/products?${params}`);
      return response.json();
    },
    getNextPageParam: (lastPage) => {
      return lastPage.hasNextPage ? lastPage.nextPage : undefined;
    },
    staleTime: 2 * 60 * 1000,
  });
}

// 낙관적 업데이트 예시
export function useToggleFavorite() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ productId, isFavorite }) => {
      const response = await fetch(`/api/products/${productId}/favorite`, {
        method: isFavorite ? 'DELETE' : 'POST',
      });
      if (!response.ok) throw new Error('Failed to toggle favorite');
      return { productId, isFavorite: !isFavorite };
    },
    onMutate: async ({ productId, isFavorite }) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries(['product', productId]);

      // 이전 데이터 백업
      const previousProduct = queryClient.getQueryData(['product', productId]);

      // 낙관적 업데이트 (UI 즉시 반영)
      queryClient.setQueryData(['product', productId], (old) => ({
        ...old,
        isFavorite: !isFavorite
      }));

      return { previousProduct };
    },
    onError: (err, variables, context) => {
      // 에러 발생 시 이전 데이터로 롤백
      if (context?.previousProduct) {
        queryClient.setQueryData(
          ['product', variables.productId], 
          context.previousProduct
        );
      }
    },
    onSettled: (data, error, { productId }) => {
      // 성공/실패 관계없이 최종적으로 refetch
      queryClient.invalidateQueries(['product', productId]);
    },
  });
}

브라우저 캐시 전략

// components/CacheProvider.js
'use client';

import { createContext, useContext, useEffect } from 'react';

const CacheContext = createContext();

export function useBrowserCache() {
  return useContext(CacheContext);
}

export function BrowserCacheProvider({ children }) {
  const cacheAPI = {
    // LocalStorage 캐시 (영속적)
    setLocalCache: (key, data, expiryMinutes = 60) => {
      const item = {
        data,
        expiry: Date.now() + (expiryMinutes * 60 * 1000),
        timestamp: Date.now()
      };
      localStorage.setItem(key, JSON.stringify(item));
    },

    getLocalCache: (key) => {
      const cached = localStorage.getItem(key);
      if (!cached) return null;

      try {
        const item = JSON.parse(cached);
        if (Date.now() > item.expiry) {
          localStorage.removeItem(key);
          return null;
        }
        return item.data;
      } catch {
        localStorage.removeItem(key);
        return null;
      }
    },

    // SessionStorage 캐시 (세션 동안만 유지)
    setSessionCache: (key, data) => {
      sessionStorage.setItem(key, JSON.stringify({
        data,
        timestamp: Date.now()
      }));
    },

    getSessionCache: (key) => {
      const cached = sessionStorage.getItem(key);
      if (!cached) return null;

      try {
        return JSON.parse(cached).data;
      } catch {
        sessionStorage.removeItem(key);
        return null;
      }
    },

    // IndexedDB 캐시 (대용량 데이터)
    setIndexedDBCache: async (storeName, key, data) => {
      return new Promise((resolve, reject) => {
        const request = indexedDB.open('AppCache', 1);
        
        request.onerror = () => reject(request.error);
        
        request.onsuccess = () => {
          const db = request.result;
          const transaction = db.transaction([storeName], 'readwrite');
          const store = transaction.objectStore(storeName);
          
          store.put({
            id: key,
            data,
            timestamp: Date.now()
          });
          
          transaction.oncomplete = () => resolve(true);
          transaction.onerror = () => reject(transaction.error);
        };
        
        request.onupgradeneeded = () => {
          const db = request.result;
          if (!db.objectStoreNames.contains(storeName)) {
            db.createObjectStore(storeName, { keyPath: 'id' });
          }
        };
      });
    },

    getIndexedDBCache: async (storeName, key) => {
      return new Promise((resolve, reject) => {
        const request = indexedDB.open('AppCache', 1);
        
        request.onerror = () => reject(request.error);
        
        request.onsuccess = () => {
          const db = request.result;
          const transaction = db.transaction([storeName], 'readonly');
          const store = transaction.objectStore(storeName);
          const getRequest = store.get(key);
          
          getRequest.onsuccess = () => {
            const result = getRequest.result;
            resolve(result ? result.data : null);
          };
          
          getRequest.onerror = () => reject(getRequest.error);
        };
      });
    },

    // 캐시 정리
    clearExpiredCache: () => {
      // LocalStorage 정리
      const keys = Object.keys(localStorage);
      keys.forEach(key => {
        try {
          const item = JSON.parse(localStorage.getItem(key));
          if (item.expiry && Date.now() > item.expiry) {
            localStorage.removeItem(key);
          }
        } catch {
          // 파싱 에러 시 삭제
          localStorage.removeItem(key);
        }
      });
    }
  };

  // 컴포넌트 마운트 시 만료된 캐시 정리
  useEffect(() => {
    cacheAPI.clearExpiredCache();
    
    // 주기적으로 만료된 캐시 정리 (5분마다)
    const interval = setInterval(() => {
      cacheAPI.clearExpiredCache();
    }, 5 * 60 * 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <CacheContext.Provider value={cacheAPI}>
      {children}
    </CacheContext.Provider>
  );
}

// 사용 예시
function ProductDetailsPage({ productId }) {
  const cache = useBrowserCache();
  const [productImages, setProductImages] = useState([]);

  useEffect(() => {
    const loadProductImages = async () => {
      // 1. 로컬 캐시 확인
      const cachedImages = cache.getLocalCache(`product-images-${productId}`);
      if (cachedImages) {
        setProductImages(cachedImages);
        return;
      }

      // 2. 서버에서 이미지 정보 로드
      const response = await fetch(`/api/products/${productId}/images`);
      const images = await response.json();
      
      // 3. 로컬 캐시에 저장 (1시간 캐시)
      cache.setLocalCache(`product-images-${productId}`, images, 60);
      setProductImages(images);
    };

    loadProductImages();
  }, [productId, cache]);

  return (
    <div>
      {productImages.map(image => (
        <img key={image.id} src={image.url} alt={image.alt} />
      ))}
    </div>
  );
}

서버 vs 클라이언트 캐시 비교 및 전략

특성 비교

특성Server-side 캐시Client-side 캐시
저장 위치서버 메모리/Redis브라우저 메모리/Storage
공유 범위모든 사용자개별 사용자
데이터 일관성높음상대적으로 낮음
네트워크 부하줄임 (서버 리소스)줄임 (네트워크 요청)
무효화 제어중앙집중적개별적/복잡함
개인화어려움쉬움
오프라인 지원불가능가능
보안높음상대적으로 낮음

하이브리드 캐싱 전략

실제 프로덕션 환경에서는 두 방식을 조합하여 사용합니다:

// 다층 캐싱 전략 구현
class MultiLevelCache {
  constructor() {
    this.l1Cache = new Map(); // 메모리 캐시 (가장 빠름)
    this.l2Cache = redis;      // Redis 캐시 (공유)
    this.l3Cache = database;   // 데이터베이스 (영속성)
  }

  async get(key) {
    // L1: 메모리 캐시 확인
    if (this.l1Cache.has(key)) {
      console.log(`L1 캐시 히트: ${key}`);
      return this.l1Cache.get(key);
    }

    // L2: Redis 캐시 확인
    const l2Data = await this.l2Cache.get(key);
    if (l2Data) {
      console.log(`L2 캐시 히트: ${key}`);
      const parsed = JSON.parse(l2Data);
      this.l1Cache.set(key, parsed); // L1에도 저장
      return parsed;
    }

    // L3: 데이터베이스에서 조회
    console.log(`캐시 미스: ${key} - DB 조회`);
    const dbData = await this.l3Cache.findByKey(key);
    if (dbData) {
      // 상위 캐시들에 저장
      this.l1Cache.set(key, dbData);
      await this.l2Cache.setex(key, 3600, JSON.stringify(dbData));
    }
    
    return dbData;
  }

  async invalidate(key) {
    // 모든 레벨의 캐시 무효화
    this.l1Cache.delete(key);
    await this.l2Cache.del(key);
    console.log(`다층 캐시 무효화: ${key}`);
  }
}

// 전자상거래 예시: 제품 정보 캐싱
export class ProductCacheStrategy {
  static async getProduct(productId) {
    const cacheKey = `product:${productId}`;

    // 서버 사이드 캐시에서 조회
    const serverCache = new MultiLevelCache();
    let product = await serverCache.get(cacheKey);

    if (!product) {
      // 데이터베이스에서 조회
      product = await db.product.findUnique({
        where: { id: productId },
        include: {
          category: true,
          images: true,
          reviews: {
            take: 5,
            orderBy: { createdAt: 'desc' }
          }
        }
      });

      if (product) {
        // 서버 캐시에 저장 (1시간)
        await serverCache.set(cacheKey, product, 3600);
      }
    }

    return product;
  }

  // 제품 업데이트 시 캐시 전략
  static async updateProduct(productId, updateData) {
    // 1. 데이터베이스 업데이트
    const updatedProduct = await db.product.update({
      where: { id: productId },
      data: updateData,
      include: {
        category: true,
        images: true,
        reviews: {
          take: 5,
          orderBy: { createdAt: 'desc' }
        }
      }
    });

    // 2. 서버 캐시 무효화
    const serverCache = new MultiLevelCache();
    await serverCache.invalidate(`product:${productId}`);
    await serverCache.invalidate('products:list:*'); // 목록 캐시도 무효화

    // 3. 클라이언트에 실시간 업데이트 알림 (WebSocket)
    broadcastToClients('product:updated', {
      productId,
      product: updatedProduct
    });

    return updatedProduct;
  }
}

// 클라이언트에서 실시간 캐시 업데이트
function useRealtimeProductCache() {
  const queryClient = useQueryClient();

  useEffect(() => {
    const socket = io();

    socket.on('product:updated', ({ productId, product }) => {
      // 클라이언트 캐시 즉시 업데이트
      queryClient.setQueryData(['product', productId], product);
      
      // 관련 쿼리 무효화
      queryClient.invalidateQueries(['products']);
      
      toast.success('제품 정보가 업데이트되었습니다.');
    });

    return () => socket.disconnect();
  }, [queryClient]);
}

캐시 무효화 전략

// 스마트 캐시 무효화 시스템
class CacheInvalidationStrategy {
  constructor() {
    this.dependencyGraph = new Map();
    this.invalidationQueue = [];
  }

  // 캐시 의존성 정의
  defineDependencies() {
    this.addDependency('product:*', ['products:list:*', 'categories:stats:*']);
    this.addDependency('user:*', ['user:profile:*', 'user:orders:*']);
    this.addDependency('order:*', ['user:orders:*', 'products:sales:*']);
  }

  addDependency(parentPattern, childPatterns) {
    this.dependencyGraph.set(parentPattern, childPatterns);
  }

  // 계단식 캐시 무효화
  async cascadeInvalidation(changedKey) {
    const invalidationSet = new Set([changedKey]);

    // 의존성 그래프 순회
    for (const [parent, children] of this.dependencyGraph) {
      if (this.matchesPattern(changedKey, parent)) {
        children.forEach(child => invalidationSet.add(child));
      }
    }

    // 배치 무효화 실행
    await this.batchInvalidate(Array.from(invalidationSet));
  }

  async batchInvalidate(patterns) {
    const promises = patterns.map(async (pattern) => {
      // 서버 캐시 무효화
      await cacheManager.deleteByPattern(pattern);
      
      // 클라이언트에 무효화 신호 전송
      this.notifyClientsInvalidation(pattern);
    });

    await Promise.all(promises);
    console.log(`배치 무효화 완료: ${patterns.join(', ')}`);
  }

  matchesPattern(key, pattern) {
    const regex = new RegExp(pattern.replace('*', '.*'));
    return regex.test(key);
  }

  notifyClientsInvalidation(pattern) {
    // WebSocket을 통해 클라이언트에 캐시 무효화 알림
    broadcastToClients('cache:invalidate', { pattern });
  }
}

// 사용 예시
const invalidationStrategy = new CacheInvalidationStrategy();
invalidationStrategy.defineDependencies();

// 제품 업데이트 시
await invalidationStrategy.cascadeInvalidation('product:123');

성능 모니터링 및 최적화

캐시 성능 메트릭

// 캐시 성능 모니터링 시스템
class CacheMetrics {
  constructor() {
    this.metrics = {
      hits: 0,
      misses: 0,
      evictions: 0,
      totalRequests: 0,
      responseTime: [],
      memoryUsage: 0
    };
  }

  recordHit(responseTime) {
    this.metrics.hits++;
    this.metrics.totalRequests++;
    this.metrics.responseTime.push(responseTime);
  }

  recordMiss(responseTime) {
    this.metrics.misses++;
    this.metrics.totalRequests++;
    this.metrics.responseTime.push(responseTime);
  }

  getHitRatio() {
    if (this.metrics.totalRequests === 0) return 0;
    return (this.metrics.hits / this.metrics.totalRequests) * 100;
  }

  getAverageResponseTime() {
    if (this.metrics.responseTime.length === 0) return 0;
    const sum = this.metrics.responseTime.reduce((a, b) => a + b, 0);
    return sum / this.metrics.responseTime.length;
  }

  getReport() {
    return {
      hitRatio: this.getHitRatio().toFixed(2) + '%',
      totalRequests: this.metrics.totalRequests,
      hits: this.metrics.hits,
      misses: this.metrics.misses,
      avgResponseTime: this.getAverageResponseTime().toFixed(2) + 'ms',
      memoryUsage: (this.metrics.memoryUsage / 1024 / 1024).toFixed(2) + 'MB'
    };
  }

  // 주기적 리포트 생성
  startPeriodicReporting(intervalMinutes = 5) {
    setInterval(() => {
      console.log('=== 캐시 성능 리포트 ===');
      console.table(this.getReport());
      
      // 메트릭 초기화
      this.resetMetrics();
    }, intervalMinutes * 60 * 1000);
  }

  resetMetrics() {
    this.metrics = {
      hits: 0,
      misses: 0,
      evictions: 0,
      totalRequests: 0,
      responseTime: [],
      memoryUsage: 0
    };
  }
}

// 성능 모니터링이 포함된 캐시 관리자
class MonitoredCacheManager extends CacheManager {
  constructor() {
    super();
    this.metrics = new CacheMetrics();
    this.metrics.startPeriodicReporting();
  }

  async get(key) {
    const startTime = Date.now();
    const result = await super.get(key);
    const responseTime = Date.now() - startTime;

    if (result !== null) {
      this.metrics.recordHit(responseTime);
    } else {
      this.metrics.recordMiss(responseTime);
    }

    return result;
  }

  // 캐시 최적화 제안
  analyzeAndOptimize() {
    const report = this.metrics.getReport();
    const recommendations = [];

    if (parseFloat(report.hitRatio) < 70) {
      recommendations.push('캐시 히트율이 낮습니다. TTL 값 증가를 고려해보세요.');
    }

    if (parseFloat(report.avgResponseTime) > 100) {
      recommendations.push('평균 응답 시간이 높습니다. 캐시 저장소 최적화가 필요합니다.');
    }

    if (parseFloat(report.memoryUsage) > 1000) {
      recommendations.push('메모리 사용량이 높습니다. LRU 정책 적용을 고려해보세요.');
    }

    return {
      currentMetrics: report,
      recommendations
    };
  }
}

적응형 캐싱 전략

// 사용 패턴에 따른 동적 TTL 조정
class AdaptiveCacheStrategy {
  constructor() {
    this.accessPatterns = new Map();
    this.baseTTL = 3600; // 기본 1시간
  }

  recordAccess(key) {
    const now = Date.now();
    if (!this.accessPatterns.has(key)) {
      this.accessPatterns.set(key, {
        accessCount: 0,
        lastAccessed: now,
        firstAccessed: now,
        accessTimes: []
      });
    }

    const pattern = this.accessPatterns.get(key);
    pattern.accessCount++;
    pattern.accessTimes.push(now);
    pattern.lastAccessed = now;

    // 최근 100개 접근만 보관
    if (pattern.accessTimes.length > 100) {
      pattern.accessTimes = pattern.accessTimes.slice(-100);
    }
  }

  calculateOptimalTTL(key) {
    const pattern = this.accessPatterns.get(key);
    if (!pattern || pattern.accessCount < 3) {
      return this.baseTTL;
    }

    // 접근 빈도 계산
    const timeSpan = Date.now() - pattern.firstAccessed;
    const accessFrequency = pattern.accessCount / (timeSpan / 1000 / 60); // 분당 접근 횟수

    // 빈도가 높을수록 TTL 증가
    let ttl = this.baseTTL;
    if (accessFrequency > 10) {
      ttl *= 4; // 4시간
    } else if (accessFrequency > 5) {
      ttl *= 2; // 2시간
    } else if (accessFrequency < 0.1) {
      ttl *= 0.5; // 30분
    }

    return Math.max(300, Math.min(ttl, 86400)); // 최소 5분, 최대 24시간
  }

  async getWithAdaptiveTTL(key, fetcher) {
    this.recordAccess(key);
    
    let data = await cacheManager.get(key);
    if (data !== null) return data;

    data = await fetcher();
    if (data !== null) {
      const optimalTTL = this.calculateOptimalTTL(key);
      await cacheManager.set(key, data, optimalTTL);
      console.log(`적응형 캐시: ${key} (TTL: ${optimalTTL}초)`);
    }

    return data;
  }
}

실제 사용 사례 및 베스트 프랙티스

전자상거래 사이트 캐싱 전략

// 전자상거래 플랫폼의 포괄적 캐싱 전략
class EcommerceCacheStrategy {
  constructor() {
    this.serverCache = new MonitoredCacheManager();
    this.adaptiveCache = new AdaptiveCacheStrategy();
  }

  // 제품 카탈로그 캐싱 (높은 읽기 빈도)
  async getProductCatalog(categoryId, filters = {}) {
    const cacheKey = `catalog:${categoryId}:${JSON.stringify(filters)}`;
    
    return this.adaptiveCache.getWithAdaptiveTTL(cacheKey, async () => {
      const products = await db.product.findMany({
        where: {
          categoryId,
          isActive: true,
          ...this.buildFilters(filters)
        },
        include: {
          images: { take: 1 },
          reviews: {
            select: {
              rating: true
            }
          }
        },
        orderBy: this.buildOrderBy(filters.sortBy)
      });

      return this.transformProductData(products);
    });
  }

  // 사용자별 개인화 데이터 (클라이언트 캐시)
  getUserPersonalizedData(userId) {
    // 클라이언트에서 React Query로 관리
    return useQuery({
      queryKey: ['user-personalized', userId],
      queryFn: async () => {
        const response = await fetch(`/api/users/${userId}/personalized`);
        return response.json();
      },
      staleTime: 10 * 60 * 1000, // 10분
      cacheTime: 30 * 60 * 1000, // 30분
    });
  }

  // 장바구니 상태 (하이브리드 접근)
  async syncCartState(userId, cartItems) {
    // 1. 클라이언트 상태 즉시 업데이트
    const cartStore = useCartStore.getState();
    cartStore.updateItems(cartItems);

    // 2. 서버에 동기화 (debounced)
    const debouncedSync = debounce(async () => {
      await fetch(`/api/users/${userId}/cart`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items: cartItems })
      });

      // 3. 서버 캐시 업데이트
      await this.serverCache.set(
        `cart:${userId}`, 
        cartItems, 
        24 * 3600 // 24시간
      );
    }, 1000);

    debouncedSync();
  }

  // 실시간 재고 정보 (짧은 TTL)
  async getInventoryStatus(productId) {
    const cacheKey = `inventory:${productId}`;
    
    return this.serverCache.getOrSet(cacheKey, async () => {
      const inventory = await db.inventory.findUnique({
        where: { productId },
        select: {
          quantity: true,
          status: true,
          lastUpdated: true
        }
      });
      return inventory;
    }, 30); // 30초만 캐시
  }

  // 가격 정보 (중간 TTL, 자주 변경됨)
  async getPricingInfo(productId) {
    const cacheKey = `pricing:${productId}`;
    
    return this.serverCache.getOrSet(cacheKey, async () => {
      const pricing = await db.pricing.findUnique({
        where: { productId },
        include: {
          discounts: {
            where: {
              isActive: true,
              validFrom: { lte: new Date() },
              validTo: { gte: new Date() }
            }
          }
        }
      });
      
      return this.calculateFinalPrice(pricing);
    }, 300); // 5분 캐시
  }

  // 추천 상품 (개인화 + 캐싱)
  async getRecommendations(userId, productId) {
    const baseKey = `recommendations:${productId}`;
    const userKey = `recommendations:user:${userId}:${productId}`;
    
    // 1. 개인화된 추천 확인
    let recommendations = await this.serverCache.get(userKey);
    if (recommendations) return recommendations;

    // 2. 일반 추천 확인
    const baseRecommendations = await this.serverCache.getOrSet(
      baseKey,
      async () => {
        return await this.generateBaseRecommendations(productId);
      },
      3600 // 1시간
    );

    // 3. 사용자별 개인화 적용
    recommendations = await this.personalizeRecommendations(
      baseRecommendations, 
      userId
    );

    // 4. 개인화된 결과 캐시 (짧은 TTL)
    await this.serverCache.set(userKey, recommendations, 600); // 10분

    return recommendations;
  }

  // 무효화 전략
  async invalidateProductData(productId) {
    const patterns = [
      `product:${productId}`,
      `catalog:*`, // 모든 카탈로그 캐시
      `recommendations:*:${productId}`,
      `pricing:${productId}`,
      `inventory:${productId}`
    ];

    await Promise.all(
      patterns.map(pattern => this.serverCache.deleteByPattern(pattern))
    );

    // 클라이언트에 실시간 알림
    this.notifyClientsInvalidation(productId);
  }
}

마무리

Server-side 캐시와 Client-side 캐시는 현대 웹 애플리케이션 성능 최적화의 핵심 요소입니다. 각각의 특성을 이해하고 적절히 조합하여 사용함으로써 다음과 같은 이점을 얻을 수 있습니다:

Server-side 캐시의 핵심 가치

  • 데이터베이스 부하 감소로 서버 비용 절약
  • 일관된 데이터 제공으로 신뢰성 확보
  • 중앙집중적 캐시 관리로 운영 효율성 극대화

Client-side 캐시의 핵심 가치

  • 네트워크 요청 최소화로 응답속도 향상
  • 개인화된 사용자 경험 제공
  • 오프라인 환경에서의 기본적 기능 지원

성공적인 캐싱 전략의 요소

  • 데이터 특성에 따른 적절한 캐시 위치 선택
  • 비즈니스 요구사항에 맞는 TTL 설정
  • 효과적인 무효화 전략 수립
  • 지속적인 성능 모니터링과 최적화

Next.js와 React 생태계에서 제공하는 다양한 캐싱 도구들을 활용하여 사용자에게는 빠른 응답을, 개발팀에게는 안정적인 시스템을, 비즈니스에게는 비용 효율성을 제공하는 캐싱 전략을 구축해보세요.




댓글 남기기