grep

Frontend

Next.js ISR 전환과 Redis 외부 캐싱 (SSR 지옥 탈출기 시리즈 1)

soobing.bak카카오

2025년 9월 9일

원문에서 보기 ↗

Next.js SSR 지옥 탈출기 시리즈


1. 들어가며

안녕하세요. 메이커스FE개발 수빙입니다. 이번 글에서는 카카오메이커스 이벤트 서비스를 Next.js SSR 기반으로 전환하면서 겪었던 이슈들과 , 이를 ISR + Redis 외부 캐시로 해결하게 된 과정을 공유드리려고 합니다.

1.1 개요 및 배경

카카오메이커스는 카카오 ESG 활동의 일환으로, 사회적 가치를 실현하는 동시에 이커머스의 성격을 가진 임팩트 커머스입니다. 이를 위해 농가돕기(제가버치), 새활용(새가버치), 환경보호 등 다양한 캠페인을 진행하고 있는데요, 이벤트 페이지를 통해 이를 소개하고 있습니다. 이벤트 페이지는 링크 공유가 자주 일어나기 때문에 오픈그래프(Open Graph, OG) 대응이 필요했고, 또 화면 로드 시간을 줄여 사용자 경험을 향상시키기 위해 SSR(Server-Side Rendering)을 적용하게 되었습니다.

카카오메이커스에서 진행중인 프로젝트

사진1. 메이커스 소개

1.2 개발 및 운영환경

카카오메이커스 프론트엔드는 기존에 Nuxt 기반 으로 개발되어 있었는데요, 이를 Next.js(v14, App Router) 기반으로 순차 전환 중입니다. 인프라는 카카오 자체 VM 환경 에서 운영되고 있으며, Node.js 애플리케이션은 PM2 클러스터 모드로 실행해 CPU 코어 수만큼 프로세스를 활용하고, 프로세스가 비정상 종료되면 자동으로 재시작되도록 설정했습니다.

1.3 SSR의 달콤함 뒤에 숨은 그림자

카카오메이커스의 이벤트 서비스를 Next.js로 전환하며, SSR을 적용한 결과는 상당히 만족스러웠습니다. 일단 첫 화면 로딩 시 표시되던 로딩 스피너가 사라지고, LCP(Largest Contentful Paint, 최대 콘텐츠 렌더링) 시간도 크게 단축되었습니다. 성능 측정 도구인 Lighthouse 테스트에서는 성능 점수가 60점에서 97점으로 비약적으로 개선되었습니다.

그러나 프로덕션 배포 이후 곧바로 한계에 부딪혔습니다. 카카오메이커스 트래픽의 대부분은 카카오톡 채널 메시지를 통해 유입되는데요, 대규모 채널 메시지를 발송하게 되면서 순간적으로 다량의 트래픽이 몰렸습니다. 서버는 응답 불능 상태에 빠졌고, 결국 서비스 장애로 이어졌습니다.

이는 저희만의 문제가 아니라, SSR 환경에서는 흔히 발생할 수 있는 현상이었습니다. 정적 리소스만 서빙하면 되는 CSR(Client-Side Rendering)과 달리, SSR은 데이터 패칭과 컴포넌트 렌더링 과정이 필요해 서버 리소스를 크게 요구합니다. 결국 더 나은 사용자 경험과 서버의 높은 부하라는 트레이드오프가 존재하는 셈이었습니다.

이를 해결하기 위해 ISR (Incremental Static Regeneration )을 도입 해 캐싱을 극대화했고, Next.js 프론트엔드 서버의 성능을 크게 개선할 수 있었습니다. 또한 프론트엔드 서버의 가시성을 개선하고자 모니터링 시스템 을 구축했으며, 이에 대한 내용은 2편 Next.js SSR 서버를 위한 모니터링 시스템 구축에서 설명드리겠습니다.

2. Next.js ISR 전환으로 성능 개선하기

저희가 경험한 성능 개선의 핵심은 Next.js의 캐싱 (ISR, Data Cache)을 제대로 이해하고, 서비스에 맞게 적용하는 것이었습니다. 단순히 기능을 켜는 수준이 아니라, 구조를 이해하고 제약 조건을 파악한 뒤 운영 환경에 맞게 튜닝하는 과정이 필요했습니다.

2.1 서버 구조와 캐싱 방식 이해하기

Next.js 서버는 크게 네 부분으로 나눌 수 있습니다.

사진2. Next.js 서버 구조

2.2 ISR의 동작 원리와 캐싱 종류

ISR (Incremental Static Regeneration)는 정적 페이지를 필요할 때 부분적으로 다시 생성하는 방식입니다.

대신 아래 세 단계를 통해 SSG의 성능 + SSR의 유연성을 모두 얻습니다.

  1. 첫 요청 시 캐싱된 정적 HTML이 있으면 즉시 반환
  2. revalidate 시간이 지나면, STALE 데이터를 반환하면서 백그라운드에서 새 HTML 생성
  3. 이후 요청부터는 갱신된 HTML 반환

아래 그림을 통해 동작 방식을 설명드리겠습니다.

사진3. ISR 동작 방식 (1)

첫 번째 사용자 가 요청을 보내면 캐시에 MISS가 발생해 새로 렌더링된 결과를 응답받고, 동시에 해당 데이터가 캐시 스토리지에 저장됩니다. 이후 30초 뒤, 두 번째 사용자가 요청하면 렌더링 과정 없이 캐시된 데이터를 바로 응답받게 됩니다.

사진4. ISR 동작 방식 (2)

1분으로 설정된 Revalidate 시간이 지난 후 세 번째 사용자 가 요청하면, 이전에 저장된 STALE 데이터를 우선 응답하고, 백그라운드에서 캐시 갱신을 위해 다시 렌더링이 수행되어 스토리지가 최신 데이터로 업데이트됩니다. 그 이후 네 번째 사용자가 요청하면, 이미 갱신된 최신 데이터를 캐시에서 즉시 제공받을 수 있습니다.

저희가 직접 제어할 수 있는 Next.js 캐싱은 크게 두 종류가 있습니다.

상황에 따라 이 두 캐시는 동시에 동작할 수 있으며, 페이지 전체를 캐싱하는 Full Route Cache와 데이터 수준에서 중복 호출을 막아주는 Data Cache가 함께 적용되어 데이터 요청을 최소화합니다.

2.3 generateStaticParams로 정적 경로 확정하기

ISR(Full Route Cache)이 동작하려면, 해당 라우트가 ‘정적으로 렌더링 가능’해야 합니다. 예를 들어 app/promotion/[id]/page.tsx 같은 동적 세그먼트 가 있다면, generateStaticParams를 통해 생성 가능한 경로를 먼저 확정하고, 라우트에 revalidate를 선언해 ISR을 활성화해야 합니다.

// app/promotion/[id]/page.tsx
export const revalidate = 60; // 캐시 생성 후 60초 이후 요청 시 재생성

// 경로를 미리 알 수 없는 경우 빈 배열 반환
export async function generateStaticParams() {
  return [];
}

export default async function Page({ params }: { params: { id: string } }) {
 // ISR을 유지하려면 정적 데이터만 사용해야 함
  const data = await fetch(`${process.env.API}/promotions/${params.id}`, {
    next: { revalidate: 60 }, // Data Cache와 함께 사용 가능
  }).then(r => r.json());

  return ;
}

저희 서비스는 사전에 전체 Promotion ID를 가져올 수 있는 API가 없었기 때문에 , generateStaticParams에서 빈 배열을 반환하도록 구현했습니다. 이 경우 첫 요청 시 동적으로 생성된 데이터가 캐시로 저장되고, 이후 요청부터는 정적 캐시가 반환됩니다.

그러나 미리 생성할 수 있는 API가 존재한다면 빌드 시점에 경로를 확정하는 것이 이상적입니다. 이렇게 하면 첫 요청에서 발생하는 캐시 MISS를 줄일 수 있고, Thunder Herd 문제도 예방할 수 있습니다.

// CMS에 존재하는 공개 ID를 빌드 시점에 확정 (예: 상위 N개 혹은 전체)
export async function generateStaticParams() {
  const ids = await fetch(`${process.env.API}/promotions/ids`, { cache: 'no-store' })
    .then(r => r.json()); // 빌드/서버 시작 시 한 번만 조회
  return ids.map((id: string) => ({ id }));
}

2.4 Dynamic API가 한 줄이라도 섞이면 ISR이 해제

ISR을 적용하려면 Dynamic API 사용 여부를 반드시 점검해야 합니다. cookies, headers, searchParams 같은 값들은 요청마다 달라지기 때문에 Full Route Cache를 사용할 수 없고 SSR로 강제됩니다.

즉, generateStaticParams과 revalidate를 적용했더라도 Dynamic API가 섞이는 순간 ISR이 동작하지 않습니다.

저희도 처음에는 이벤트 페이지에서 쿠키와 함께 API를 호출하도록 구현했는데, 이 때문에 자동으로 SSR이 적용되고 있었습니다. ISR로 전환하기 위해 정적 데이터와 사용자 종속 데이터를 분리하여, 쿠키 기반 개인화 데이터 API는 클라이언트에서 처리하도록 하고 Dynamic API에의 의존성을 제거했습니다.

2.5 캐싱 재검증 전략: 시간 기반 vs 온디맨드

캐시는 크게 두 가지 방식으로 재검증할 수 있습니다.

시간 기반(Time-based)

stale-while-revalidate와 유사합니다. 지정 시간이 지나면 요청 시 STALE 데이터를 먼저 반환하고, 동시에 백그라운드에서 새 데이터를 생성합니다.

// fetch 응답을 1시간(3600초) 동안 캐싱 후 재검증
fetch('https://...', { next: { revalidate: 3600 } })

온디맨드(On-demand)

외부 이벤트(예: CMS 수정)가 발생했을 때 캐시를 직접 무효화시키는 방식입니다. Next.js는 두 가지 API를 제공합니다.

온디맨드 방식은 기존 캐시를 즉시 제거 하기 때문에, 이후 요청에서는 MISS가 발생하며 새 데이터를 생성하게 됩니다. 따라서 시간 기반 재검증처럼 STALE 데이터가 남아 있지 않고, 동시에 MISS하는 Thunder Herd 위험이 있습니다.

저희 서비스는 CMS 형태의 이벤트 페이지였기 때문에, 백오피스에서 수정이 발생하면 곧바로 캐시를 갱신해야 했습니다. 그래서 Next.js의 API Router를 활용해 revalidatePath를 실행하고, 이어서 curl을 활용한 Prefetch 로직을 추가했습니다. 이렇게 하면 캐시 무효화 직후 곧바로 페이지를 한 번 찔러 새 데이터를 캐싱해두고, 실제 사용자가 접속할 때는 MISS가 아니라 HIT을 받도록 보완할 수 있었습니다.

// app/api/revalidate/route.ts
// API Router에서 On-demand Revalidate + Prefetch 처리

import { revalidatePath } from 'next/cache';
import type { NextRequest } from 'next/server';
import { exec } from 'child_process';

function prefetchPage(url: string) {
  const userAgent = 'PrefetchBot';
  exec(
    `curl -A "${userAgent}" -H "Accept: text/html" -s -o /dev/null ${url}`,
    (error) => {
      if (error) console.error(`[Prefetch error] ${error.message}`);
      else console.log(`[Prefetch success] ${url}`);
    },
  );
}
// GET /api/revalidate?id=1234
// → 특정 promotion ID에 대한 캐시 무효화 + 사전 프리패치 실행
export async function GET(request: NextRequest) {
  const id = request.nextUrl.searchParams.get('id');
  const host = request.headers.get('host');
  if (!id || !host) {
    return Response.json({ revalidated: false, now: Date.now() });
  }
  const protocol = host.includes('localhost') ? 'http' : 'https';
  const path = `/promotion/${id}`;
  const fullUrl = `${protocol}://${host}${path}`;

  // 1. 캐시 무효화
  revalidatePath(path);
  // 2. 사전 프리패치
  prefetchPage(fullUrl);

  return Response.json({ revalidated: true, now: Date.now() });
}

보안 측면에서도 주의할 점이 있었습니다. 외부에서 온디맨드 재검증 API를 호출해 캐시를 무단으로 삭제하면 큰 문제가 생길 수 있기 때문에, Nginx 레벨에서 사내망 IP만 허용하도록 제한을 걸어두었습니다.

location /api/ {
  proxy_pass ;
  allow 192.168.0.0/16;
  deny all;
}

이렇게 온디맨드 재검증을 안정적으로 적용할 수 있었고, 덕분에 시간 기반 재검증에서는 캐싱 타임을 서비스 운영 기준 최대치 (1개월 )로 잡는 전략을 쓸 수 있었습니다. 즉, 평소에는 오래된 캐시를 적극 활용하면서도, 백오피스 수정이 일어나면 곧바로 최신 데이터를 반영하는 하이브리드 전략을 구축할 수 있었습니다.

⚠️ 주의: App Router의 Data Cache는 개발 모드에서 HMR 재사용만 하며, 실제 ISR 캐싱·재검증은 동작하지 않습니다. 따라서 next build → next start로 프로덕션 서버를 실행해야 ISR 동작을 정확히 확인할 수 있습니다.

2.6 ISR 동작 확인하기 (빌드 출력 & 응답 헤더)

2.6.1 빌드 출력으로 정적/동적 확인

next build 결과에서 SSG로 프리렌더된 라우트는 ○ , ISR로 프리렌더된 라우트는 ⦁ , 동적 라우트는 ƒ 로 표시됩니다. ISR이 적용 되었다면 ⦁로 빌드 결과가 나와야 합니다.

사진5. 빌드 결과

2.6 2 응답 헤더로 캐시 동작 확인

프로덕션 모드( next build && next start )에서 라우트 요청 시 X-Nextjs-Cache 헤더로 HIT / MISS / STALE 상태를 확인할 수 있습니다.

사진6. 응답 헤더

2.7 운영 환경에서의 문제와 Redis 도입

로컬 환경에서는 ISR이 잘 동작했지만, 운영 환경에서는 문제가 있었습니다. 저희는 PM2 클러스터 모드로 여러 인스턴스를 띄우고 있었는데, 각 인스턴스의 인메모리 캐시가 분리되어 디스크에는 최신 데이터가 있어도 다른 인스턴스는 STALE로 판정하는 문제가 발생했습니다.

사진7: develop 서버에서 makers-event-web-service가 클러스터 모드로 2개 인스턴스 실행 중

사진8: Redis 적용 이전

사진9: Redis 적용 이후

이를 해결하기 위해 인메모리 캐시를 꺼버리고, 외부 공유 캐시인 Redis 를 도입했습니다. @neshca/cache-handler 라이브러리를 활용해 Redis를 캐시 핸들러로 붙였고, 여러 인스턴스 간 캐시 동기화가 가능해졌습니다. 그 결과 두 번째 요청부터도 STALE 대신 HIT을 안정적으로 보장할 수 있었습니다.

const nextConfig = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0,
}
/*  
 * Dependencies (Versions Used)
 * ----------------------------
 * @neshca/cache-handler : ^1.9.0  
 * redis                 : ^4.7.0  
 */

const NodeVault = require('node-vault');
const { CacheHandler } = require('@neshca/cache-handler');
const createRedisCache = require('@neshca/cache-handler/redis-strings').default;
const { createClient } = require('redis');

async function getRedisCache() {
  const vault = NodeVault({
    apiVersion: 'v1',
    endpoint: '',
    token: process.env.MAKERS_FRONT_VAULT_TOKEN,
  });

  const {
    data: { username, host, port, password },
  } = await vault.read(`secret/smith-makers/redis`);

  const client = createClient({
    url: `redis://${username}:${password}@${host}:${port}/${process.env.REDIS_DB}`,
  });

  client.on('error', (err) => {
    console.error(
      '🔴 Redis Error',
      err,
      `redis://default:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
    );
  });

  await client.connect();
  console.log('🟢 Redis connected');

  return createRedisCache({
    client,
    timeoutMs: 5000,
  });
}

CacheHandler.onCreation(async () => {
  const redisHandler = await getRedisCache();

  const defaultStaleAge = 60 * 60 * 24 * 30; // 1 month로 설정
  return {
    handlers: [redisHandler],
    ttl: {
      defaultStaleAge,
      estimateExpireAge: (staleAge) => Math.min(staleAge, defaultStaleAge),
    },
  };
});

module.exports = CacheHandler;

다만, Redis는 관리 포인트와 비용이 추가된다는 단점이 있습니다. 성능 자체만 놓고 보면 자체 인메모리 캐시를 활용하는 것이 더 빠르기 때문에, 운영 환경의 규모, 인스턴스 수, 리소스 상황에 따라 선택이 달라질 수 있을 것 같습니다.

3. 정적 리소스 서빙 최적화로 성능 개선하기

ISR과 캐싱 구조 개선이 성능 향상에 가장 큰 효과를 주었지만, 그 외에도 몇 가지 시도를 병행했습니다. 그중 하나가 정적 리소스를 효율적으로 서빙하는 작업이었는데, 성능 개선에 큰 임팩트는 없었지만 과정에서 얻은 인사이트를 공유드리고자 합니다.

3.1 정적 리소스 CDN에 업로드

처음에는 정적 리소스를 CDN에 업로드 하는 방식을 시도했습니다. 빌드 시 사내에서 개발한 Tenth2UploadPlugin을 Webpack에 적용해 자동 업로드되도록 했고, next.config.js에서 assetPrefix를 설정해 HTML에 CDN 경로를 주입했습니다.

하지만 실제로 적용해보니 관리 포인트가 늘어나고, 주기적으로 리소스를 삭제/갱신해 줘야 하는 불편이 있었습니다. 또한 CDN에서 정적 리소스를 가져올 때마다 추가적인 네트워크 요청 및 응답 과정이 발생해 기대 만큼의 성능 향상을 체감하기 어려웠습니다.

3.2 NginX에서 서빙

결국 최종적으로는 Nginx에서 정적 리소스를 직접 서빙하는 방식으로 정리했습니다.

_next/static/ 경로로 들어오는 요청을 Next.js 애플리케이션까지 보내지 않고, Nginx가 로컬 파일을 바로 서빙하도록 설정했습니다.

location /_next/static/ {
    alias /var/www/next-app/.next/static/;
    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

이 방식은 요청이 애플리케이션 서버까지 가지 않으므로 불필요한 오버헤드를 줄일 수 있고, Nginx가 즉시 응답을 처리해 훨씬 단순하면서도 효과적이었습니다.

실제 성능 테스트에서도 CDN보다 Nginx 서빙 방식이 더 안정적이고 빠른 응답을 보였으며, 관리 부담도 크게 줄었습니다. 큰 임팩트를 주는 개선은 아니었지만, 정적 리소스는 결국 서버와 가까운 곳에서 직접 서빙하는 것이 가장 효율적이라는 점을 확인할 수 있었습니다.

4. 마무리

앞에서 설명드린 ISR 전환과 Redis 기반 캐싱 구조 도입을 통해 TPS는 4.6배 향상되고 응답 시간(MTT)은 4배 가까이 단축되었으며, Nginx 정적 리소스 서빙 최적화를 통해서도 TPS 28% 향상과 응답 시간(MTT) 36% 단축이라는 운영 환경에서 체감할 수 있는 성능 개선을 할 수 있었습니다.

그러나 이번 개선 과정에서 많은 문제를 해결했지만, 여전히 남은 과제들도 있습니다.

Redis Pub/Sub 적용 검토

현재는 캐싱 타임 관리가 서버별로 분리되는 이슈로 모든 캐싱 데이터를 Redis에 저장하고 있습니다. 하지만 성능만 놓고 보면 각 서버 인메모리 캐시를 활용하는 편이 더 유리합니다. 왜냐하면 Next.js 애플리케이션이 Redis 서버와 통신하는 과정에서 네트워크 지연이 추가로 발생하기 때문입니다. 따라서 데이터 자체는 서버 인메모리에 두고, 캐싱 여부만 Redis Pub/Sub으로 공유하는 구조를 고려하고 있습니다. 이렇게 하면 중복 생성을 줄이면서도 성능을 유지할 수 있을지 검토해 볼 예정입니다.

Thunder Herd 문제 보완

CMS 수정 시에는 revalidate와 curl 기반 Prefetch를 통해 대부분 해결했지만, 배포 시점 에는 여전히 Redis 캐시가 리셋되면서 Thunder Herd가 완전히 해소되지 않았습니다. 현재는 빌드 파이프라인에서 최신 100개 데이터를 미리 curl로 생성하도록 해뒀지만, 앞으로는 generateStaticParams에서 API를 호출해 최신 100개의 ID를 받아 빌드 시 SSG 형태로 미리 캐싱 데이터를 생성하는 방법을 시도해볼 계획입니다. 다만 이 방식은 빌드 속도에 영향을 줄 수 있어, 어느 정도의 트레이드 오프를 감수할지 테스트가 필요할 것 같습니다.

처음에는 SSR이 성능과 사용자 경험을 동시에 해결해 줄 만능 열쇠처럼 보였습니다. 그러나 실제 운영 환경에 적용해 보니 서버 리소스 부담과 개발자가 감당해야 할 복잡성이 함께 뒤따른다는 사실을 알게 되었습니다. 결국 성능을 얻는 만큼 운영과 관리 비용도 함께 늘어난다는 점을 깨달았습니다. 앞으로는 이 성능과 비용 사이의 균형을 잘 잡아가는 것도 프론트엔드 개발자의 주요한 과제가 되리라 생각합니다.