현대 웹 애플리케이션의 성능을 좌우하는 가장 중요한 요소 중 하나는 캐싱 전략입니다. 사용자는 빠른 로딩 시간을 기대하고, 개발자는 서버 비용을 절약하며 확장 가능한 시스템을 구축해야 합니다.
캐싱은 크게 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 생태계에서 제공하는 다양한 캐싱 도구들을 활용하여 사용자에게는 빠른 응답을, 개발팀에게는 안정적인 시스템을, 비즈니스에게는 비용 효율성을 제공하는 캐싱 전략을 구축해보세요.