고양이hyebin
Next.js 서버의 문제를 캐싱으로 해결하기
Next.js 서버의 문제를 캐싱으로 해결하기
September 24, 2025

Next.js SSR의 성능 고려사항

JavaScript는 싱글 스레드이기에 한번에 하나의 일을 처리할 수 있어요.

요청 1 → 처리 중... (처리 중이기 때문에 다른 요청들 대기)
요청 2 → 대기 중...
요청 3 → 대기 중...
요청 4 → 대기 중...

Node는 일반적으로 단일 스레드에서 실행하기 때문에 서버 사이드 렌더링시 요청이 순차적으로 처리하게 됩니다. 이때 동시 요청이 많으면 병목 현상 발생해요. 하나의 요청 처리가 오래 걸리면 다른 요청들이 모두 대기해야 합니다.

Next.js는 서버 사이드 렌더링을 통해 초기 로딩 성능과 SEO를 개선할 수 있지만, 서버 과부하로 지연된 시간은 사용자 경험을 저하시킵니다. 특히 운영 서버에서 발생할 경우 이탈률이 급격하게 올라가며 서비스에 타격을 입을 수 있게 되죠.

이렇게 설명하는 이유는 Next.js가 최신 기술로 인식되어 쉽게 사용되고 있지만, 실제로는 SSR을 효율적으로 관리하는 방향에 대해서는 잘 모르는 경우가 많습니다. 프레임워크를 선택할 때는 신중해야 합니다. Next.js가 생각보다 적은 트래픽에도 성능 저하가 발생할 수 있으므로, 캐싱 전략이나 로드 밸런싱 등의 최적화 방안을 미리 계획하는 것이 중요합니다.

캐싱 전략 해결책

캐싱이란?

캐싱은 이전에 계산한 결과를 저장해두고, 같은 요청이 오면 저장된 결과를 바로 반환하는 기술입니다. 모든 사용자가 같은 캐시된 페이지를 받게되죠.

캐싱의 효과

캐싱 없음:
요청 → 데이터베이스 조회 → 계산 → 렌더링 → 응답 (느림)

캐싱 있음:
요청 → 캐시 확인 → 저장된 결과 반환 (빠름)

캐싱을 적용하면 서버 부하를 크게 줄일 수 있으며, 응답 시간이 대폭 단축됩니다. 특히 자주 요청되는 페이지의 경우 캐싱 효과가 더욱 커지게 됩니다.

Next.js 캐싱 방법들

방법 1: Fetch 를 이용한 데이터 캐싱

// 시간 기반 재검증
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 } // 1시간마다 재검증
})

// 캐시 비활성화
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// 캐싱 명시적 설정
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
})

⚠️  Next.js 15에서는 기본적으로 캐시가 되지 않기 때문에 캐싱을 원할 경우 명시적으로 설정해줘야 해요.

방법 2: 페이지 레벨 캐싱 (ISR)

Pages Router 방식

// pages/posts.js
export async function getStaticProps() {
  const data = await fetch('https://api.example.com/posts')
  
  return {
    props: { posts: await data.json() },
    revalidate: 60 // 60초마다 재생성
  }
}

export default function Posts({ posts }) {
  return <div>{/* 페이지 내용 */}</div>
}

App Router 방식

// 1시간 동안 페이지 전체 캐싱
export const revalidate = 3600;

export default async function Page() {
  const posts = await fetch('https://api.example.com/posts');
  return <div>포스트 목록</div>;
}

방법 3: 태그 기반 캐싱

revalidateTag는 특정 태그의 데이터 캐시를 무효화시킵니다.

// 데이터에 태그 붙이기
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
});

// 필요할 때 특정 태그만 캐시 무효화
import { revalidateTag } from 'next/cache';

export async function createPost() {
  // 새 포스트 생성 로직
  revalidateTag('posts'); // 'posts' 태그 캐시만 새로고침
}

방법 4: 경로 기반 캐싱

revalidatePath는 특정 경로의 캐시를 무효화시킵니다.

import { revalidatePath } from 'next/cache'
 
export async function updateUser(id: string) {
  // Mutate data
  revalidatePath('/profile')
 
 }

방법 5: unstable_cache 함수 래핑하기

import { db } from '@/lib/db'
export async function getUserById(id: string) {
  return db
    .select()
    .from(users)
    .where(eq(users.id, id))
    .then((res) => res[0])
}
const getCachedUser = unstable_cache(
  async () => {
    return getUserById(userId)
  },
  [userId],
  {
    tags: ['user'],
    revalidate: 3600,
  }
)

unstable_cache데이터베이스 쿼리 및 비동기 함수의 결과를 캐시할 수 있습니다. 사용하려면 unstable_cache함수를 래핑하면 됩니다.

Next.js ISR 캐시는 어디에 저장될까?

Next의 ISR을 사용할 때 캐시가 정확히 어디에 저장되는지, 그리고 서버가 여러 개일 때 어떤 문제가 발생할 수 있는지 알아보겠습니다.

캐시는 "파일 시스템"에 저장됩니다

ISR을 사용하면 캐시는 서버가 실행되는 환경의 파일 시스템에 저장됩니다. 메모리가 아닌 실제 파일로 생성되죠.

// Pages Router
.next/server/pages/[pagename].html
.next/server/pages/[pagename].json

// App Router  
.next/server/app/[pagename]/page.html
.next/server/app/[pagename]/page.json

이 파일을 열어보면 ISR로 생성된 HTML이 미리 그려져 있는 것을 확인할 수 있습니다.

ISR이 캐시 파일을 생성하는 과정

ISR을 통해 전체 사이트를 다시 빌드할 필요 없이 페이지별로 정적 생성을 사용할 수 있습니다.

사용자 요청이 있을 경우 캐시가 있는지 확인합니다. 없거나 만료되면 revalidate 트리거를 동작시킵니다. 사용자 요청 → 캐시 확인 → 없거나 만료됨

사용자는 기다릴 필요가 없습니다! revalidate 시간이 지나도 기존 페이지를 즉시 받습니다. 그리고 백그라운드에서는 조용히 새 페이지를 준비하는데, 그 이후 사용자부터는 새 페이지를 받을 수 있게 되는거죠.

이게 ISR의 stale-while-revalidate전략입니다: 오래된 것을 제공하면서 동시에 새로운 것을 준비하는 방식이죠.

💡
Nzext.js 서버 내부 프로세스
  1. 1.사용자 요청 수신
  2. 2.캐시 만료 확인 (revalidate 시간 체크)
  3. 3.기존 캐시 파일 즉시 응답
  4. 4.동시에 백그라운드에서:
    • 컴포넌트 함수 실행
    • fetch() 호출
    • React 렌더링
    • HTML/JSON 파일 생성
    • 파일 시스템에 저장

이후에는 동일한 페이지 요청이 올 경우 파일 시스템에서 읽고 즉시 응답을 보냅니다.

예시로 알아보는 ISR

간단한 예시를 들어보겠습니다.

export const revalidate = 60; // 60초마다 재검증

개발자가 revalidate를 60으로 설정하면, 모든 방문자는 1분 동안 동일하게 생성된 버전의 사이트를 보게 됩니다. 캐시를 무효화하는 유일한 방법은 1분이 지난 후 누군가가 해당 페이지를 방문하는 것입니다.

60초 후 첫 방문자가 올 때 사용자는 지연 없이 캐싱된 페이지를 바로 받게 됩니다. 그러면서 동시에 백그라운드에서 재빌드를 트리거를 진행하면서 새로운 콘텐츠가 원활하게 준비됩니다. 새 파일이 완성되면 기존 파일을 교체하고 다음 방문자는 업데이트 된 새 페이지를 제공받을 수 있게 됩니다 .

ISR을 통해 개발자는 전체 사이트를 다시 빌드할 필요 없이 필요한 페이지만 선택적으로 빌드하여 파일로 저장할 수 있습니다.

실제 프로덕션에서 발생하는 ISR 문제 (멀티 서버)

지금까지 ISR의 동작 원리를 살펴봤다면, 이제 실제 서비스 운영에서 마주하게 되는 현실적인 문제들을 알아보겠습니다.

개발 환경과 프로덕션 환경의 차이

단일 서버 개발 환경에서는 ISR이 완벽하게 동작합니다. 모든 요청이 같은 서버의 같은 파일 시스템을 사용하므로 문제가 전혀 없죠.

하지만 실제 프로덕션에서는 트래픽을 처리하기 위해 여러 대의 서버를 운영하게 되고, 여기서 예상치 못한 문제가 발생합니다. 여러 개의 서버 인스턴스가 실행되는 환경에서는 각각의 서버가 독립적인 파일 시스템을 가지게 되면서 상황이 달라집니다.

멀티 서버 환경에서의 캐시 분산 문제

시나리오 예시 - 2개의 서버가 존재하는 경우:

서버 A → server A running → .next/server/app/${pagename}.html
서버 B → server B running → .next/server/app/${pagename}.html

각 서버마다 별도의 캐시 파일이 생성됩니다. n개의 서버가 있다면 캐시도 n개 존재하게 되죠.

지속적인 Cache Miss 문제

이렇게 될 경우 발생할 수 있는 문제는 지속적인 Cache Miss입니다.

다음 조건을 가정해보겠습니다:

  • revalidate: 60 설정 (60초마다 재검증)
  • 사용자가 30초마다 꾸준히 방문
  • 로드밸런서가 트래픽을 순차적으로 분산

실제 발생 상황:

0~30초   → 사용자 A 방문 → 서버 A → cache miss (서버 A에서 캐시 생성)
31~60초  → 사용자 B 방문 → 서버 B → cache miss (서버 B에서 캐시 생성)
61~120초 → 사용자 C 방문 → 서버 A → cache miss (60초 지나서 서버 A에서 다시 캐시 생성)
121~180초 → 사용자 D 방문 → 서버 B → cache miss (서버 B에서 다시 캐시 생성)

결과적으로 ISR의 핵심 장점인 "60초마다 한 번만"이 무의미해집니다. 서버마다 매번 페이지를 새로 생성하는 상황이 발생하게 되죠.

해결방안: Redis를 활용한 통합 캐시 관리

이런 문제를 해결하기 위해 Redis 캐싱 서버를 도입할 수 있습니다. Redis 같은 외부 캐시 저장소를 사용하면 분산된 여러 서버가 하나의 통합된 캐시를 바라보게 할 수 있습니다.

Redis 도입 후 개선된 플로우

0~30초  → 사용자 A 방문 → 서버 A → Redis cache miss (캐시 생성)
31~60초 → 사용자 B 방문 → 서버 B → Redis cache hit ✅
61초 후  → 사용자 C 방문 → 서버 A → Redis cache hit ✅

이제 어떤 서버로 요청이 들어와도 60초마다 한 번씩만 캐시가 생성되어 ISR이 원래 의도한 대로 동작하게 됩니다.

참고

https://www.youtube.com/watch?v=5SojnABKBqA

https://nextjs.org/docs/app/getting-started/caching-and-revalidating#unstable_cache