NextJS에서 서버 사이드 캐싱으로 성능 최적화하기

ph8nt0m·2025년 3월 2일
0

API Architecture

목록 보기
3/4
post-thumbnail

안녕하세요! 이번 글에서는 NextJS의 서버 사이드 캐싱 기능을 활용하여 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 이전 글에서 다룬 Orval과 TanStack Query를 통한 클라이언트 측 캐싱과 함께, 서버 측에서도 효율적인 캐싱 전략을 구현하면 사용자 경험을 크게 향상시킬 수 있습니다.

NextJS 서버 컴포넌트와 데이터 페칭 이해하기

NextJS 13부터 도입된 React 서버 컴포넌트(RSC)는 데이터 페칭 방식에 혁신을 가져왔습니다. 서버 컴포넌트는 서버에서 렌더링되고, 클라이언트로 HTML과 최소한의 JavaScript만 전송되기 때문에 초기 로딩 성능이 크게 향상됩니다.

서버 컴포넌트 vs 클라이언트 컴포넌트

// 서버 컴포넌트 (app/users/page.tsx)
// 파일 상단에 'use client' 지시어가 없으면 기본적으로 서버 컴포넌트입니다.
export default async function UsersPage() {
  // 서버에서 직접 데이터 페칭
  const users = await fetch('https://api.example.com/users').then(res => res.json());
  
  return (
    <div>
      <h1>사용자 목록</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

// 클라이언트 컴포넌트 (app/users/client-component.tsx)
'use client'; // 이 지시어로 클라이언트 컴포넌트임을 명시

import { useState, useEffect } from 'react';

export default function ClientComponent() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetch('https://api.example.com/users')
      .then(res => res.json())
      .then(data => setUsers(data));
  }, []);
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

서버 컴포넌트에서는 async/await를 사용하여 직접 데이터를 페칭할 수 있으며, 이 과정에서 클라이언트로 전송되는 JavaScript 번들 크기를 줄일 수 있습니다.

서버 컴포넌트의 장점

  1. 번들 크기 감소: 서버 컴포넌트 코드는 클라이언트로 전송되지 않습니다.
  2. 직접적인 백엔드 리소스 접근: 데이터베이스, 파일 시스템 등에 직접 접근 가능합니다.
  3. API 요청 감소: 클라이언트에서 별도의 API 요청 없이 서버에서 데이터를 가져옵니다.
  4. SEO 향상: 완전히 렌더링된 HTML이 검색 엔진에 제공됩니다.
  5. 보안 강화: 민감한 정보(API 키, 토큰 등)를 클라이언트에 노출하지 않습니다.

캐시 옵션을 사용한 fetch 구현

NextJS는 fetch API를 확장하여 캐싱 옵션을 제공합니다. 이를 통해 서버 컴포넌트에서 데이터를 페칭할 때 캐싱 전략을 쉽게 구현할 수 있습니다.

기본 캐싱 동작

NextJS에서 fetch는 기본적으로 요청을 캐싱합니다:

// 이 요청은 자동으로 캐싱됩니다 (기본값: cache: 'force-cache')
const users = await fetch('https://api.example.com/users').then(res => res.json());

캐싱 옵션

NextJS의 fetch는 다음과 같은 캐싱 옵션을 제공합니다:

  1. force-cache (기본값): 가능한 한 캐시에서 응답을 반환합니다.
  2. no-store: 캐싱하지 않고 항상 새로운 데이터를 가져옵니다.
  3. revalidate: 지정된 시간(초) 후에 캐시를 재검증합니다.
// 캐싱하지 않고 항상 새로운 데이터 가져오기
const latestData = await fetch('https://api.example.com/latest', { cache: 'no-store' })
  .then(res => res.json());

// 10초마다 캐시 재검증
const revalidatedData = await fetch('https://api.example.com/data', { next: { revalidate: 10 } })
  .then(res => res.json());

동적 vs 정적 렌더링

캐싱 옵션은 페이지의 렌더링 방식에도 영향을 미칩니다:

  • 정적 렌더링: cache: 'force-cache' 또는 revalidate 옵션을 사용하면 빌드 시 또는 지정된 간격으로 페이지가 렌더링됩니다.
  • 동적 렌더링: cache: 'no-store'를 사용하면 요청마다 페이지가 새로 렌더링됩니다.
// 동적 렌더링을 강제하는 방법
export const dynamic = 'force-dynamic';

// 또는 fetch 옵션으로 설정
const data = await fetch('https://api.example.com/data', { cache: 'no-store' });

재검증 전략 설정

NextJS에서는 두 가지 주요 재검증 전략을 제공합니다: 시간 기반 재검증과 온디맨드 재검증입니다.

시간 기반 재검증

특정 시간 간격으로 데이터를 자동으로 재검증합니다:

// 페이지 레벨에서 설정
export const revalidate = 60; // 60초마다 재검증

// 또는 fetch 요청별로 설정
const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 } });

온디맨드 재검증

특정 이벤트(예: 폼 제출, Webhook)가 발생할 때 데이터를 재검증합니다:

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request: NextRequest) {
  const { path, tag, secret } = await request.json();
  
  // 보안을 위한 시크릿 검증
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }
  
  if (path) {
    // 특정 경로 재검증
    revalidatePath(path);
    return NextResponse.json({ revalidated: true, path });
  }
  
  if (tag) {
    // 특정 태그가 있는 모든 데이터 재검증
    revalidateTag(tag);
    return NextResponse.json({ revalidated: true, tag });
  }
  
  return NextResponse.json({ message: 'No path or tag provided' }, { status: 400 });
}

태그 기반 재검증

관련 데이터를 그룹화하여 함께 재검증할 수 있습니다:

// 태그 추가
const data = await fetch('https://api.example.com/posts', { 
  next: { tags: ['posts'] } 
});

// 태그로 재검증
revalidateTag('posts');

서버 컴포넌트를 위한 프리페치 유틸리티 만들기

서버 컴포넌트에서 데이터 페칭을 더 효율적으로 관리하기 위한 유틸리티 함수를 만들어 보겠습니다:

// lib/fetch-utils.ts
type FetchOptions = RequestInit & {
  next?: {
    revalidate?: number | false;
    tags?: string[];
  };
};

export async function fetchWithCache<T>(
  url: string,
  options?: FetchOptions
): Promise<T> {
  try {
    const response = await fetch(url, options);
    
    if (!response.ok) {
      throw new Error(`API error: ${response.status} ${response.statusText}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error(`Failed to fetch from ${url}:`, error);
    throw error;
  }
}

// 자주 사용되는 패턴에 대한 헬퍼 함수
export function fetchWithRevalidation<T>(
  url: string,
  revalidateSeconds: number,
  tags?: string[]
): Promise<T> {
  return fetchWithCache(url, {
    next: {
      revalidate: revalidateSeconds,
      tags,
    },
  });
}

export function fetchDynamic<T>(url: string): Promise<T> {
  return fetchWithCache(url, { cache: 'no-store' });
}

이 유틸리티를 사용하면 서버 컴포넌트에서 일관된 방식으로 데이터를 페칭할 수 있습니다:

// app/posts/page.tsx
import { fetchWithRevalidation, fetchDynamic } from '@/lib/fetch-utils';

export default async function PostsPage() {
  // 10분마다 재검증되는 데이터
  const posts = await fetchWithRevalidation('https://api.example.com/posts', 600, ['posts']);
  
  // 항상 최신 데이터를 가져오는 동적 데이터
  const stats = await fetchDynamic('https://api.example.com/stats');
  
  return (
    <div>
      <h1>게시물 목록</h1>
      {/* 컴포넌트 내용 */}
    </div>
  );
}

최신성과 성능 간의 균형 맞추기

데이터의 최신성과 애플리케이션 성능 사이에서 적절한 균형을 찾는 것은 중요합니다. 다음은 데이터 유형별 권장 캐싱 전략입니다:

데이터 유형별 캐싱 전략

  1. 정적 데이터: 자주 변경되지 않는 데이터 (예: 블로그 포스트, 제품 설명)

    // 빌드 시 생성되고 ISR로 주기적으로 업데이트
    const posts = await fetch('https://api.example.com/posts', { 
      next: { revalidate: 3600 } // 1시간마다 재검증
    });
  2. 준정적 데이터: 가끔 변경되는 데이터 (예: 제품 가격, 카테고리)

    // 짧은 간격으로 재검증
    const products = await fetch('https://api.example.com/products', { 
      next: { revalidate: 300 } // 5분마다 재검증
    });
  3. 동적 데이터: 자주 변경되는 데이터 (예: 재고 수량, 사용자별 데이터)

    // 캐싱하지 않음
    const inventory = await fetch('https://api.example.com/inventory', { 
      cache: 'no-store' 
    });
  4. 개인화된 데이터: 사용자별로 다른 데이터

    // 서버 액션이나 API 라우트에서 처리
    async function getUserData(userId: string) {
      return fetch(`https://api.example.com/users/${userId}`, { 
        cache: 'no-store' 
      });
    }

하이브리드 접근 방식

페이지 내에서 다양한 캐싱 전략을 조합할 수 있습니다:

// app/dashboard/page.tsx
export default async function Dashboard() {
  // 정적 데이터 (1일마다 재검증)
  const siteConfig = await fetch('https://api.example.com/config', { 
    next: { revalidate: 86400 } 
  }).then(res => res.json());
  
  // 준정적 데이터 (1시간마다 재검증)
  const popularPosts = await fetch('https://api.example.com/posts/popular', { 
    next: { revalidate: 3600 } 
  }).then(res => res.json());
  
  // 동적 데이터 (항상 최신)
  const stats = await fetch('https://api.example.com/stats', { 
    cache: 'no-store' 
  }).then(res => res.json());
  
  return (
    <div>
      <Header config={siteConfig} />
      <PopularPosts posts={popularPosts} />
      <LiveStats stats={stats} />
    </div>
  );
}

서버 사이드 캐싱 문제 디버깅

서버 사이드 캐싱 관련 문제를 해결하기 위한 몇 가지 팁을 알아보겠습니다.

캐시 동작 확인하기

개발 환경에서 캐시 동작을 확인하는 방법:

// 캐시 동작을 로깅하는 래퍼 함수
async function loggedFetch(url: string, options?: RequestInit) {
  console.log(`Fetching ${url} with cache option:`, options?.cache || 'force-cache');
  const startTime = Date.now();
  const response = await fetch(url, options);
  const endTime = Date.now();
  console.log(`Fetch completed in ${endTime - startTime}ms`);
  
  // x-cache 헤더가 있다면 캐시 상태 확인 (일부 CDN에서 제공)
  const cacheStatus = response.headers.get('x-cache');
  if (cacheStatus) {
    console.log(`Cache status: ${cacheStatus}`);
  }
  
  return response;
}

일반적인 문제와 해결 방법

  1. 캐시가 예상대로 작동하지 않는 경우

    • 문제: 데이터가 캐싱되지 않거나 예상보다 자주 재검증됩니다.
    • 해결:
      • 개발 모드에서는 캐싱이 다르게 작동할 수 있습니다. 프로덕션 빌드로 테스트하세요.
      • 동적 함수(cookies(), headers() 등)를 사용하면 페이지가 동적으로 렌더링됩니다.
  2. 캐시 무효화가 작동하지 않는 경우

    • 문제: revalidatePath 또는 revalidateTag가 캐시를 무효화하지 않습니다.
    • 해결:
      • 올바른 경로나 태그를 사용하고 있는지 확인하세요.
      • 태그는 대소문자를 구분합니다.
  3. ISR이 작동하지 않는 경우

    • 문제: 지정된 간격으로 페이지가 재검증되지 않습니다.
    • 해결:
      • next.config.js에서 ISR이 비활성화되지 않았는지 확인하세요.
      • 호스팅 환경이 ISR을 지원하는지 확인하세요.

디버깅 도구

  1. NextJS 로그 확인

    # 자세한 로그 활성화
    NEXT_DEBUG=1 npm run dev
  2. 캐시 수동 지우기

    // 개발 중 캐시 지우기
    import { unstable_cache } from 'next/cache';
    
    // 캐시 무효화 함수
    export async function clearCache() {
      // 이 방법은 불안정하며 프로덕션에서는 사용하지 않는 것이 좋습니다
      await fetch('/api/revalidate?path=/&secret=YOUR_SECRET');
    }
  3. 캐시 상태 시각화

    • NextJS는 아직 내장된 캐시 시각화 도구를 제공하지 않지만, 커스텀 로깅을 통해 캐시 상태를 추적할 수 있습니다.

실제 사례: 블로그 애플리케이션

실제 블로그 애플리케이션에서 서버 사이드 캐싱을 구현하는 예제를 살펴보겠습니다:

// app/blog/page.tsx
import { fetchWithRevalidation } from '@/lib/fetch-utils';
import { BlogPostList } from '@/components/BlogPostList';

export default async function BlogPage() {
  // 블로그 포스트는 1시간마다 재검증
  const posts = await fetchWithRevalidation(
    'https://api.example.com/posts',
    3600,
    ['blog-posts']
  );
  
  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">블로그</h1>
      <BlogPostList posts={posts} />
    </div>
  );
}

// app/blog/[slug]/page.tsx
import { fetchWithRevalidation, fetchDynamic } from '@/lib/fetch-utils';
import { notFound } from 'next/navigation';
import { CommentSection } from '@/components/CommentSection';

export async function generateStaticParams() {
  // 빌드 시 정적으로 생성할 경로 지정
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());
  return posts.map(post => ({ slug: post.slug }));
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  // 포스트 내용은 1시간마다 재검증
  const post = await fetchWithRevalidation(
    `https://api.example.com/posts/${params.slug}`,
    3600,
    ['blog-posts', `post-${params.slug}`]
  );
  
  if (!post) {
    notFound();
  }
  
  // 댓글은 항상 최신 데이터 (동적)
  const comments = await fetchDynamic(`https://api.example.com/posts/${params.slug}/comments`);
  
  return (
    <div className="container mx-auto py-8">
      <article>
        <h1 className="text-4xl font-bold">{post.title}</h1>
        <div className="prose mt-6" dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
      
      <CommentSection comments={comments} postId={post.id} />
    </div>
  );
}

이 예제에서는:
1. 블로그 목록 페이지는 1시간마다 재검증됩니다.
2. 개별 블로그 포스트도 1시간마다 재검증되지만, 댓글은 항상 최신 데이터를 가져옵니다.
3. 자주 방문하는 포스트는 generateStaticParams를 통해 빌드 시 미리 생성됩니다.

결론

NextJS의 서버 사이드 캐싱은 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 기능입니다. 데이터의 특성에 맞게 적절한 캐싱 전략을 선택하고, 최신성과 성능 사이의 균형을 맞추는 것이 중요합니다.

이 글에서 다룬 내용을 요약하면:

  1. NextJS 서버 컴포넌트는 서버에서 직접 데이터를 페칭하고 캐싱할 수 있습니다.
  2. fetch API의 캐싱 옵션을 통해 다양한 캐싱 전략을 구현할 수 있습니다.
  3. 시간 기반 재검증과 온디맨드 재검증을 통해 캐시를 관리할 수 있습니다.
  4. 데이터의 특성에 맞는 캐싱 전략을 선택하여 최신성과 성능 사이의 균형을 맞출 수 있습니다.
  5. 디버깅 도구와 기법을 활용하여 캐싱 관련 문제를 해결할 수 있습니다.

다음 글에서는 TanStack Query를 활용한 클라이언트 사이드 캐싱에 대해 더 자세히 알아보겠습니다. 서버 사이드 캐싱과 클라이언트 사이드 캐싱을 함께 활용하면 더욱 강력한 데이터 관리 전략을 구현할 수 있습니다.


이 블로그 시리즈가 여러분의 NextJS 프로젝트에 도움이 되길 바랍니다. 질문이나 의견이 있으시면 댓글로 남겨주세요!

0개의 댓글

관련 채용 정보