๐Ÿš€ Next.js ์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ - ์‹ ์„ ๋„ & SSR ํŽ˜์ด์ง€ ์บ์‹ฑ

2025๋…„ 09์›” 01์ผ
17๋ถ„

์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•˜๋Š” ๊ฒƒ์€ ํ˜„๋Œ€ ์›น ๊ฐœ๋ฐœ์—์„œ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ณผ์ œ ์ค‘ ํ•˜๋‚˜์ž…๋‹ˆ๋‹ค. ํŠนํžˆ Server-Side Rendering(SSR)์„ ์‚ฌ์šฉํ•˜๋Š” Next.js ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์บ์‹ฑ์€ ์„ฑ๋Šฅ ํ–ฅ์ƒ์˜ ํ•ต์‹ฌ ์š”์†Œ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์บ์‹ฑ๊ณผ ๋ฐ์ดํ„ฐ ์‹ ์„ ๋„ ์‚ฌ์ด์˜ ๊ท ํ˜•์„ ๋งž์ถ”๋Š” ๊ฒƒ์€ ์‰ฝ์ง€ ์•Š์€ ์ผ์ž…๋‹ˆ๋‹ค.

์ด ๊ธ€์—์„œ๋Š” Next.js์—์„œ ์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ์„ ๊ตฌํ˜„ํ•˜๋ฉด์„œ๋„ ๋ฐ์ดํ„ฐ์˜ ์‹ ์„ ๋„๋ฅผ ์œ ์ง€ํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ์ด๋ž€?

์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ(Full Page Caching)์€ ์„œ๋ฒ„์—์„œ ๋ Œ๋”๋ง๋œ ์™„์ „ํ•œ HTML ํŽ˜์ด์ง€๋ฅผ ๋ฉ”๋ชจ๋ฆฌ๋‚˜ ๋””์Šคํฌ์— ์ €์žฅํ•˜์—ฌ, ๋™์ผํ•œ ์š”์ฒญ์ด ๋“ค์–ด์˜ฌ ๋•Œ ๋‹ค์‹œ ๋ Œ๋”๋ง ๊ณผ์ •์„ ๊ฑฐ์น˜์ง€ ์•Š๊ณ  ์บ์‹œ๋œ ํŽ˜์ด์ง€๋ฅผ ์ฆ‰์‹œ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ธฐ์ˆ ์ž…๋‹ˆ๋‹ค.

์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ์˜ ์žฅ์ 

  1. ๋น ๋ฅธ ์‘๋‹ต ์‹œ๊ฐ„: ์ด๋ฏธ ๋ Œ๋”๋ง๋œ ํŽ˜์ด์ง€๋ฅผ ๋ฐ”๋กœ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ ์‘๋‹ต ์†๋„๊ฐ€ ํฌ๊ฒŒ ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.
  2. ์„œ๋ฒ„ ๋ฆฌ์†Œ์Šค ์ ˆ์•ฝ: CPU์™€ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์„ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  3. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ถ€ํ•˜ ๊ฐ์†Œ: ๋ฐ˜๋ณต์ ์ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ฟผ๋ฆฌ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  4. ํ™•์žฅ์„ฑ ํ–ฅ์ƒ: ๋” ๋งŽ์€ ๋™์‹œ ์‚ฌ์šฉ์ž๋ฅผ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ์˜ ๋‹จ์ 

  1. ๋ฐ์ดํ„ฐ ์‹ ์„ ๋„ ๋ฌธ์ œ: ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜ค๋ž˜๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  2. ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ฆ๊ฐ€: ํŽ˜์ด์ง€๋ฅผ ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
  3. ๋ณต์žกํ•œ ๋ฌดํšจํ™” ๋กœ์ง: ์–ธ์ œ ์บ์‹œ๋ฅผ ๊ฐฑ์‹ ํ• ์ง€ ๊ฒฐ์ •ํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค.

Next.js์—์„œ์˜ ์บ์‹ฑ ์˜ต์…˜

1. Static Generation (SSG)

// pages/products/[id].js
export async function getStaticProps({ params }) {
  const product = await fetchProduct(params.id);
  
  return {
    props: {
      product,
    },
    // 60์ดˆ๋งˆ๋‹ค ์žฌ์ƒ์„ฑ
    revalidate: 60,
  };
}
 
export async function getStaticPaths() {
  const products = await fetchAllProducts();
  
  const paths = products.map((product) => ({
    params: { id: product.id.toString() },
  }));
 
  return {
    paths,
    fallback: 'blocking',
  };
}

2. Incremental Static Regeneration (ISR)

ISR์€ SSG์˜ ์žฅ์ ์„ ์œ ์ง€ํ•˜๋ฉด์„œ ๋ฐ์ดํ„ฐ ์‹ ์„ ๋„ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

// pages/blog/[slug].js
export async function getStaticProps({ params }) {
  const post = await fetchBlogPost(params.slug);
  
  return {
    props: {
      post,
    },
    // 10๋ถ„๋งˆ๋‹ค ์žฌ์ƒ์„ฑ ์‹œ๋„
    revalidate: 600,
  };
}

3. Server-Side Rendering with Caching

SSR๊ณผ ์บ์‹ฑ์„ ๊ฒฐํ•ฉํ•œ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

// pages/api/products.js
import { NextApiRequest, NextApiResponse } from 'next';
 
const cache = new Map();
 
export default async function handler(req, res) {
  const cacheKey = `products_${JSON.stringify(req.query)}`;
  
  // ์บ์‹œ ํ™•์ธ
  if (cache.has(cacheKey)) {
    const { data, timestamp } = cache.get(cacheKey);
    
    // 5๋ถ„ ์ด๋‚ด์˜ ์บ์‹œ๋Š” ์œ ํšจ
    if (Date.now() - timestamp < 5 * 60 * 1000) {
      res.setHeader('X-Cache', 'HIT');
      return res.json(data);
    }
  }
  
  // ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
  const products = await fetchProducts(req.query);
  
  // ์บ์‹œ์— ์ €์žฅ
  cache.set(cacheKey, {
    data: products,
    timestamp: Date.now(),
  });
  
  res.setHeader('X-Cache', 'MISS');
  res.json(products);
}

Redis๋ฅผ ํ™œ์šฉํ•œ ์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ

๋” ๊ฐ•๋ ฅํ•œ ์บ์‹ฑ ์†”๋ฃจ์…˜์„ ์œ„ํ•ด Redis๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Redis ์บ์‹ฑ ๋ฏธ๋“ค์›จ์–ด ๊ตฌํ˜„

// lib/cache.js
import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
export class PageCache {
  constructor(defaultTTL = 300) { // 5๋ถ„ ๊ธฐ๋ณธ TTL
    this.defaultTTL = defaultTTL;
  }
 
  generateKey(req) {
    const { pathname, query } = req;
    const sortedQuery = Object.keys(query)
      .sort()
      .reduce((result, key) => {
        result[key] = query[key];
        return result;
      }, {});
    
    return `page:${pathname}:${JSON.stringify(sortedQuery)}`;
  }
 
  async get(req) {
    const key = this.generateKey(req);
    const cached = await redis.get(key);
    
    if (cached) {
      return JSON.parse(cached);
    }
    
    return null;
  }
 
  async set(req, data, ttl = null) {
    const key = this.generateKey(req);
    const cacheData = {
      html: data,
      timestamp: Date.now(),
      ttl: ttl || this.defaultTTL,
    };
    
    await redis.setex(key, ttl || this.defaultTTL, JSON.stringify(cacheData));
  }
 
  async invalidate(pattern) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
}

์บ์‹ฑ ๋ฏธ๋“ค์›จ์–ด ์ ์šฉ

// pages/products/index.js
import { GetServerSideProps } from 'next';
import { PageCache } from '../../lib/cache';
 
const cache = new PageCache(600); // 10๋ถ„ TTL
 
export default function ProductsPage({ products, fromCache }) {
  return (
    <div>
      <h1>์ƒํ’ˆ ๋ชฉ๋ก {fromCache && <span>(์บ์‹œ๋จ)</span>}</h1>
      <div>
        {products.map(product => (
          <div key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
 
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  // ์บ์‹œ ํ™•์ธ
  const cachedData = await cache.get(req);
  
  if (cachedData) {
    // ์บ์‹œ ํžˆํŠธ
    res.setHeader('X-Cache', 'HIT');
    return {
      props: {
        products: cachedData.products,
        fromCache: true,
      },
    };
  }
 
  // ์บ์‹œ ๋ฏธ์Šค - ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
  const products = await fetchProducts();
  
  // ์บ์‹œ์— ์ €์žฅ
  await cache.set(req, { products });
  
  res.setHeader('X-Cache', 'MISS');
  
  return {
    props: {
      products,
      fromCache: false,
    },
  };
};

์Šค๋งˆํŠธ ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต

1. ํƒœ๊ทธ ๊ธฐ๋ฐ˜ ๋ฌดํšจํ™”

// lib/smartCache.js
export class SmartCache extends PageCache {
  async setWithTags(req, data, tags = [], ttl = null) {
    const key = this.generateKey(req);
    const cacheData = {
      html: data,
      timestamp: Date.now(),
      tags,
      ttl: ttl || this.defaultTTL,
    };
    
    // ๋ฉ”์ธ ์บ์‹œ ์ €์žฅ
    await redis.setex(key, ttl || this.defaultTTL, JSON.stringify(cacheData));
    
    // ํƒœ๊ทธ๋ณ„ ํ‚ค ๋งคํ•‘ ์ €์žฅ
    for (const tag of tags) {
      await redis.sadd(`tag:${tag}`, key);
      await redis.expire(`tag:${tag}`, (ttl || this.defaultTTL) + 60);
    }
  }
 
  async invalidateByTag(tag) {
    const keys = await redis.smembers(`tag:${tag}`);
    if (keys.length > 0) {
      await redis.del(...keys);
      await redis.del(`tag:${tag}`);
    }
  }
}

2. ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ๋ฌดํšจํ™”

// lib/timeBasedCache.js
export class TimeBasedCache extends PageCache {
  constructor(defaultTTL = 300) {
    super(defaultTTL);
    this.scheduleCleanup();
  }
 
  scheduleCleanup() {
    setInterval(async () => {
      await this.cleanupExpired();
    }, 60000); // 1๋ถ„๋งˆ๋‹ค ์‹คํ–‰
  }
 
  async cleanupExpired() {
    const pattern = 'page:*';
    const keys = await redis.keys(pattern);
    
    for (const key of keys) {
      const data = await redis.get(key);
      if (data) {
        const parsed = JSON.parse(data);
        const age = (Date.now() - parsed.timestamp) / 1000;
        
        if (age > parsed.ttl) {
          await redis.del(key);
        }
      }
    }
  }
}

์กฐ๊ฑด๋ถ€ ์บ์‹ฑ ๊ตฌํ˜„

๋ชจ๋“  ํŽ˜์ด์ง€๊ฐ€ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์บ์‹ฑ๋˜์–ด์•ผ ํ•˜๋Š” ๊ฒƒ์€ ์•„๋‹™๋‹ˆ๋‹ค. ์กฐ๊ฑด๋ถ€ ์บ์‹ฑ์„ ํ†ตํ•ด ๋” ์ •๊ตํ•œ ์บ์‹ฑ ์ „๋žต์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// lib/conditionalCache.js
export class ConditionalCache extends PageCache {
  constructor(options = {}) {
    super(options.defaultTTL);
    this.rules = options.rules || [];
  }
 
  shouldCache(req, res) {
    // ๊ธฐ๋ณธ ์กฐ๊ฑด๋“ค
    if (req.method !== 'GET') return false;
    if (req.headers.cookie?.includes('admin=true')) return false;
    if (res.statusCode !== 200) return false;
 
    // ์‚ฌ์šฉ์ž ์ •์˜ ๊ทœ์น™ ์ ์šฉ
    for (const rule of this.rules) {
      if (!rule(req, res)) return false;
    }
 
    return true;
  }
 
  getTTL(req) {
    const { pathname } = req;
    
    // ๊ฒฝ๋กœ๋ณ„ TTL ์„ค์ •
    if (pathname.startsWith('/api/')) return 60; // API๋Š” 1๋ถ„
    if (pathname.startsWith('/blog/')) return 3600; // ๋ธ”๋กœ๊ทธ๋Š” 1์‹œ๊ฐ„
    if (pathname.startsWith('/products/')) return 600; // ์ƒํ’ˆ์€ 10๋ถ„
    
    return this.defaultTTL;
  }
 
  async get(req, res) {
    if (!this.shouldCache(req, res)) return null;
    return super.get(req);
  }
 
  async set(req, res, data) {
    if (!this.shouldCache(req, res)) return;
    
    const ttl = this.getTTL(req);
    return super.set(req, data, ttl);
  }
}

์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์ œ

์ „์ฒด ์บ์‹ฑ ์‹œ์Šคํ…œ ํ†ตํ•ฉ

// lib/cacheManager.js
import { ConditionalCache } from './conditionalCache';
import { SmartCache } from './smartCache';
 
export class CacheManager {
  constructor() {
    this.cache = new ConditionalCache({
      defaultTTL: 300,
      rules: [
        // ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๊ฐœ์ธ ํŽ˜์ด์ง€๋Š” ์บ์‹ฑํ•˜์ง€ ์•Š์Œ
        (req) => !req.url.includes('/my-account'),
        // ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ๊ฐ€ ์žˆ๋Š” ํŽ˜์ด์ง€๋Š” ๋” ์งง์€ TTL
        (req) => {
          if (req.query.search) {
            req._customTTL = 60;
          }
          return true;
        },
      ],
    });
  }
 
  async middleware(req, res, next) {
    // ์บ์‹œ ํ™•์ธ
    const cached = await this.cache.get(req, res);
    
    if (cached) {
      res.setHeader('X-Cache', 'HIT');
      res.setHeader('X-Cache-Time', new Date(cached.timestamp).toISOString());
      return res.send(cached.html);
    }
 
    // ์›๋ณธ res.send ๋ฉ”์„œ๋“œ ๋ฐฑ์—…
    const originalSend = res.send.bind(res);
    
    // res.send ๋ฉ”์„œ๋“œ ๋ž˜ํ•‘
    res.send = async (body) => {
      // ์บ์‹œ์— ์ €์žฅ
      if (res.statusCode === 200 && typeof body === 'string') {
        const ttl = req._customTTL || this.cache.getTTL(req);
        await this.cache.set(req, res, body, ttl);
        res.setHeader('X-Cache', 'MISS');
      }
      
      return originalSend(body);
    };
 
    next();
  }
}

Next.js API ๋ฏธ๋“ค์›จ์–ด๋กœ ์ ์šฉ

// middleware.js
import { NextResponse } from 'next/server';
import { CacheManager } from './lib/cacheManager';
 
const cacheManager = new CacheManager();
 
export async function middleware(request) {
  // ์ •์  ํŒŒ์ผ์€ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
  if (request.nextUrl.pathname.startsWith('/_next/')) {
    return NextResponse.next();
  }
 
  // ์บ์‹œ ํ™•์ธ
  const cached = await cacheManager.cache.get(request);
  
  if (cached) {
    const response = new NextResponse(cached.html);
    response.headers.set('X-Cache', 'HIT');
    response.headers.set('Content-Type', 'text/html');
    return response;
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ ์ตœ์ ํ™”

์บ์‹œ ์„ฑ๋Šฅ ๋ฉ”ํŠธ๋ฆญ

// lib/cacheMetrics.js
export class CacheMetrics {
  constructor() {
    this.stats = {
      hits: 0,
      misses: 0,
      errors: 0,
      totalRequests: 0,
    };
  }
 
  recordHit() {
    this.stats.hits++;
    this.stats.totalRequests++;
  }
 
  recordMiss() {
    this.stats.misses++;
    this.stats.totalRequests++;
  }
 
  recordError() {
    this.stats.errors++;
  }
 
  getHitRate() {
    if (this.stats.totalRequests === 0) return 0;
    return (this.stats.hits / this.stats.totalRequests) * 100;
  }
 
  getStats() {
    return {
      ...this.stats,
      hitRate: this.getHitRate(),
    };
  }
 
  reset() {
    this.stats = {
      hits: 0,
      misses: 0,
      errors: 0,
      totalRequests: 0,
    };
  }
}

์บ์‹œ ์›Œ๋ฐ

// scripts/warmCache.js
import { CacheManager } from '../lib/cacheManager';
 
const cacheManager = new CacheManager();
 
const POPULAR_PAGES = [
  '/',
  '/products',
  '/about',
  '/blog',
];
 
async function warmCache() {
  console.log('์บ์‹œ ์›Œ๋ฐ ์‹œ์ž‘...');
  
  for (const page of POPULAR_PAGES) {
    try {
      const response = await fetch(`${process.env.SITE_URL}${page}`);
      if (response.ok) {
        console.log(`โœ… ${page} ์บ์‹œ ์™„๋ฃŒ`);
      } else {
        console.log(`โŒ ${page} ์‹คํŒจ: ${response.status}`);
      }
    } catch (error) {
      console.error(`โŒ ${page} ์˜ค๋ฅ˜:`, error.message);
    }
  }
  
  console.log('์บ์‹œ ์›Œ๋ฐ ์™„๋ฃŒ!');
}
 
warmCache();

๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

1. ์ ์ ˆํ•œ TTL ์„ค์ •

  • ์ •์  ์ฝ˜ํ…์ธ : 1์‹œ๊ฐ„ ์ด์ƒ
  • ๋™์  ์ฝ˜ํ…์ธ : 5-15๋ถ„
  • ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ: 30์ดˆ-2๋ถ„
  • API ์‘๋‹ต: 1-5๋ถ„

2. ์บ์‹œ ํ‚ค ์ „๋žต

// ์ข‹์€ ์บ์‹œ ํ‚ค ์˜ˆ์ œ
function generateCacheKey(req) {
  const { pathname, query } = req;
  const { page, sort, filter } = query;
  
  // ์ •๋ ฌ๋œ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋งŒ ํฌํ•จ
  const relevantParams = { page, sort, filter };
  const queryString = new URLSearchParams(relevantParams).toString();
  
  return `${pathname}${queryString ? `?${queryString}` : ''}`;
}

3. ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ

// ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ๋ชจ๋‹ˆํ„ฐ๋ง
function monitorMemoryUsage() {
  const used = process.memoryUsage();
  console.log('๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰:');
  for (let key in used) {
    console.log(`${key}: ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`);
  }
}
 
setInterval(monitorMemoryUsage, 60000); // 1๋ถ„๋งˆ๋‹ค ์ฒดํฌ

4. ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต

// ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ๋ฌดํšจํ™”
import { EventEmitter } from 'events';
 
class CacheInvalidator extends EventEmitter {
  constructor(cache) {
    super();
    this.cache = cache;
    this.setupEventListeners();
  }
 
  setupEventListeners() {
    this.on('product:updated', (productId) => {
      this.cache.invalidate(`page:/products/${productId}:*`);
      this.cache.invalidate('page:/products:*');
    });
 
    this.on('user:updated', (userId) => {
      this.cache.invalidate(`page:/users/${userId}:*`);
    });
  }
}

App Router์—์„œ์˜ ์บ์‹ฑ

Next.js 13+ App Router์—์„œ๋Š” ๋” ๊ฐ•๋ ฅํ•œ ์บ์‹ฑ ์˜ต์…˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

1. ํŽ˜์ด์ง€ ์บ์‹ฑ with App Router

// app/products/page.tsx
import { unstable_cache } from 'next/cache';
 
const getCachedProducts = unstable_cache(
  async () => {
    const products = await fetchProducts();
    return products;
  },
  ['products'],
  {
    revalidate: 3600, // 1์‹œ๊ฐ„
    tags: ['products']
  }
);
 
export default async function ProductsPage() {
  const products = await getCachedProducts();
  
  return (
    <div>
      <h1>์ƒํ’ˆ ๋ชฉ๋ก</h1>
      {products.map(product => (
        <div key={product.id}>
          <h2>{product.name}</h2>
          <p>{product.price}</p>
        </div>
      ))}
    </div>
  );
}

2. ๋ฐ์ดํ„ฐ ์บ์‹œ ๋ฌดํšจํ™”

// app/admin/actions.ts
import { revalidateTag } from 'next/cache';
 
export async function updateProduct(productId: string, data: any) {
  await updateProductInDB(productId, data);
  
  // ํŠน์ • ํƒœ๊ทธ ๋ฌดํšจํ™”
  revalidateTag('products');
  revalidateTag(`product-${productId}`);
}

3. ๋ผ์šฐํŠธ ์บ์‹œ ์„ค์ •

// app/api/products/route.ts
export const dynamic = 'force-dynamic'; // ๋˜๋Š” 'force-static'
export const revalidate = 3600; // 1์‹œ๊ฐ„
 
export async function GET() {
  const products = await fetchProducts();
  return Response.json(products);
}

๊ณ ๊ธ‰ ์บ์‹ฑ ํŒจํ„ด

1. ๊ณ„์ธตํ˜• ์บ์‹ฑ

// lib/hierarchicalCache.js
export class HierarchicalCache {
  constructor() {
    this.l1Cache = new Map(); // ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ
    this.l2Cache = redis; // Redis ์บ์‹œ
  }
 
  async get(key) {
    // L1 ์บ์‹œ ํ™•์ธ
    if (this.l1Cache.has(key)) {
      const data = this.l1Cache.get(key);
      if (this.isValid(data)) {
        return data.value;
      }
    }
 
    // L2 ์บ์‹œ ํ™•์ธ
    const l2Data = await this.l2Cache.get(key);
    if (l2Data) {
      const parsed = JSON.parse(l2Data);
      // L1์— ๋ณต์‚ฌ
      this.l1Cache.set(key, {
        value: parsed.value,
        timestamp: parsed.timestamp,
        ttl: Math.min(300, parsed.ttl), // L1์€ ์ตœ๋Œ€ 5๋ถ„
      });
      return parsed.value;
    }
 
    return null;
  }
 
  async set(key, value, ttl = 3600) {
    const data = {
      value,
      timestamp: Date.now(),
      ttl,
    };
 
    // L1์— ์ €์žฅ (์งง์€ TTL)
    this.l1Cache.set(key, {
      ...data,
      ttl: Math.min(300, ttl),
    });
 
    // L2์— ์ €์žฅ
    await this.l2Cache.setex(key, ttl, JSON.stringify(data));
  }
 
  isValid(data) {
    return (Date.now() - data.timestamp) / 1000 < data.ttl;
  }
}

2. ์ ์‘ํ˜• ์บ์‹ฑ

// lib/adaptiveCache.js
export class AdaptiveCache extends PageCache {
  constructor() {
    super();
    this.hitRates = new Map();
    this.accessCounts = new Map();
  }
 
  async get(key) {
    this.incrementAccess(key);
    return super.get(key);
  }
 
  incrementAccess(key) {
    const count = this.accessCounts.get(key) || 0;
    this.accessCounts.set(key, count + 1);
  }
 
  adaptTTL(key, baseTTL) {
    const accessCount = this.accessCounts.get(key) || 0;
    const hitRate = this.hitRates.get(key) || 0;
 
    // ์ž์ฃผ ์ ‘๊ทผ๋˜๊ณ  ํžˆํŠธ์œจ์ด ๋†’์€ ํŽ˜์ด์ง€๋Š” ๋” ์˜ค๋ž˜ ์บ์‹œ
    if (accessCount > 100 && hitRate > 0.8) {
      return baseTTL * 2;
    }
 
    // ๊ฑฐ์˜ ์ ‘๊ทผ๋˜์ง€ ์•Š๋Š” ํŽ˜์ด์ง€๋Š” ์งง๊ฒŒ ์บ์‹œ
    if (accessCount < 10) {
      return baseTTL / 2;
    }
 
    return baseTTL;
  }
}

์‹ค์ œ ํ”„๋กœ๋•์…˜ ๊ณ ๋ ค์‚ฌํ•ญ

1. ์บ์‹œ ํฌ๊ธฐ ์ œํ•œ

// lib/boundedCache.js
export class BoundedCache extends PageCache {
  constructor(maxSize = 1000, defaultTTL = 300) {
    super(defaultTTL);
    this.maxSize = maxSize;
    this.accessOrder = [];
  }
 
  async set(key, data, ttl) {
    await super.set(key, data, ttl);
    
    // LRU ์ •์ฑ…์œผ๋กœ ํฌ๊ธฐ ๊ด€๋ฆฌ
    this.updateAccessOrder(key);
    
    const currentSize = await this.getSize();
    if (currentSize > this.maxSize) {
      await this.evictLRU();
    }
  }
 
  updateAccessOrder(key) {
    const index = this.accessOrder.indexOf(key);
    if (index > -1) {
      this.accessOrder.splice(index, 1);
    }
    this.accessOrder.push(key);
  }
 
  async evictLRU() {
    const lruKey = this.accessOrder.shift();
    if (lruKey) {
      await redis.del(lruKey);
    }
  }
 
  async getSize() {
    const keys = await redis.keys('page:*');
    return keys.length;
  }
}

2. ์บ์‹œ ์••์ถ•

// lib/compressedCache.js
import { gzip, gunzip } from 'zlib';
import { promisify } from 'util';
 
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
 
export class CompressedCache extends PageCache {
  async set(key, data, ttl) {
    const compressed = await gzipAsync(JSON.stringify(data));
    const cacheData = {
      compressed: compressed.toString('base64'),
      timestamp: Date.now(),
      ttl: ttl || this.defaultTTL,
    };
    
    await redis.setex(key, ttl || this.defaultTTL, JSON.stringify(cacheData));
  }
 
  async get(key) {
    const cached = await redis.get(key);
    
    if (cached) {
      const data = JSON.parse(cached);
      const compressed = Buffer.from(data.compressed, 'base64');
      const decompressed = await gunzipAsync(compressed);
      return JSON.parse(decompressed.toString());
    }
    
    return null;
  }
}

๋งˆ์น˜๋ฉฐ

Next.js์—์„œ ์ „์ฒด ํŽ˜์ด์ง€ ์บ์‹ฑ์„ ๊ตฌํ˜„ํ•  ๋•Œ๋Š” ์„ฑ๋Šฅ ํ–ฅ์ƒ๊ณผ ๋ฐ์ดํ„ฐ ์‹ ์„ ๋„ ์‚ฌ์ด์˜ ๊ท ํ˜•์„ ์ž˜ ๋งž์ถ”๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

  1. ์ ์ ˆํ•œ ์บ์‹ฑ ์ „๋žต ์„ ํƒ: ISR, SSR+์บ์‹ฑ, ์กฐ๊ฑด๋ถ€ ์บ์‹ฑ ๋“ฑ
  2. ์Šค๋งˆํŠธํ•œ ๋ฌดํšจํ™” ๋กœ์ง: ํƒœ๊ทธ ๊ธฐ๋ฐ˜, ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ๋ฌดํšจํ™”
  3. ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง: ์บ์‹œ ํžˆํŠธ์œจ, ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ 
  4. ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค ์ ์šฉ: TTL ์„ค์ •, ์บ์‹œ ํ‚ค ์ „๋žต, ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ

์ด๋Ÿฌํ•œ ์š”์†Œ๋“ค์„ ์ข…ํ•ฉ์ ์œผ๋กœ ๊ณ ๋ คํ•˜์—ฌ ๊ตฌํ˜„ํ•˜๋ฉด, ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํฌ๊ฒŒ ๊ฐœ์„ ํ•˜๋ฉด์„œ๋„ ์„œ๋ฒ„ ๋ฆฌ์†Œ์Šค๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ณ ์„ฑ๋Šฅ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์บ์‹ฑ์€ ๊ฐ•๋ ฅํ•œ ์ตœ์ ํ™” ๋„๊ตฌ์ด์ง€๋งŒ, ์ž˜๋ชป ๊ตฌํ˜„ํ•˜๋ฉด ์˜คํžˆ๋ ค ๋ฌธ์ œ๊ฐ€ ๋  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์ถฉ๋ถ„ํ•œ ํ…Œ์ŠคํŠธ์™€ ๋ชจ๋‹ˆํ„ฐ๋ง์„ ํ†ตํ•ด ์ ์ง„์ ์œผ๋กœ ๊ฐœ์„ ํ•ด๋‚˜๊ฐ€๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

ํ˜„๋Œ€ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์บ์‹ฑ์€ ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜ ์ด๊ธฐ์— Next.js์˜ ๊ฐ•๋ ฅํ•œ ์บ์‹ฑ ๊ธฐ๋Šฅ๋“ค์„ ์ ์ ˆํžˆ ํ™œ์šฉํ•˜์—ฌ ๋” ๋‚˜์€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ œ๊ณตํ•˜๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค.

(์ถœ์ฒ˜ : https://blog.melvinprince.io/full-page-caching-in-next-js-how-to-cache-ssr-pages-without-losing-freshness-b68699905314)