본문 바로가기
project/withMe

SSG와 ISR을 활용한 캐싱 및 Redis를 이용한 동기화

by qjatjs123123 2025. 3. 2.

안녕하세요!

 

withMe 서비스의 프론트엔드는 NEXT.JS 프레임워크를 사용하여 웹 페이지를 개발하고 있습니다.

 

대규모 트래픽을 처리하거나 성능 개선에 도움이 될 수 있는 주제를 공유하려고 합니다.

 

많은 관심과 피드백 부탁드립니다. 🙏

 

 


 

 

서론

성능 향상 방법 중 하나인 캐싱을 Next.js는 다양한 방식으로 지원합니다.

예를 들어, 이미지 캐싱, API Routes 캐싱, 그리고 HTML 캐싱이 있습니다.

 

이번에는 HTML 캐싱 방법에 대해 공유하고자 합니다.

 

랜딩 페이지나 소개 페이지와 같은 경우 매번 서버에서 렌더링하여 클라이언트에 응답할 필요가 있을까요?

자주 변경되지 않는 뉴스나 블로그 콘텐츠도 매번 렌더링하여 클라이언트에 응답할 필요가 있을까요

 

바뀌지 않는 것은 한번 만든 정적 페이지를 보내주면 될 것이고 자주 변경되지 않는 페이지는 점진적으로 정적 페이지를 재생성해주면 됩니다.

 

이번 글에서는 Next.js에서 이러한 문제를 해결하기 위한 SSG와 ISR 기법에 대해 설명하고, 나아가 여러 대의 서버에서 분산 처리하는 로드밸런싱 환경에서 캐시 동기화 문제를 어떻게 해결했는지에 대해 다루고자 합니다.

 

 


 

 

CSR / SSR / SSG / ISR 이란?

먼저 브라우저가 렌더링 하는 방식에 대해 이해할 필요가 있습니다.

 

CSR이란?

 

CSR은 클라이언트 사이드 렌더링이고, 자바스크립트를 사용하여 브라우저에서 직접 페이지를 렌더링 하는 것을 의미합니다.

서버는 빈 HTML과 JS를 반환하고, 클라이언트는 이를 통해 데이터 패치, 렌더링 등 다양한 작업들을 하게 됩니다.

 

장점

  • 클라이언트에서 모든 처리가 이루어지기 때문에 서버 비용이 상대적으로 낮습니다
  • 초기 로딩 시 JS 번들 파일을 받아오기 때문에, 이후 페이지 전환 시 해당 파일을 이용해 빠르게 전환할 수 있습니다
  • 페이지 새로 고침 없이 동적 콘텐츠를 실시간으로 업데이트할 수 있습니다.

 

단점

  • 빈 HTML을 받기 때문에 검색 엔진 최적화가 잘 되지 않습니다.
  • 초기 로딩 때 용량이 큰 JS파일을 받기 떄문에 초기 로딩 시간이 길어질 수 있습니다.

 

SSR이란?

 

CSR의 단점을 해결하기 위해 나온 기술입니다. 브라우저에서 웹 페이지를 렌더링하는 대신 서버에서 웹 페이지를 생성하는 방법으로, 서버에서 렌더링 된 페이지를 클라이언트로 보내고, 클라이언트는 JS를 통해 정적인 HTML을 React 컴포넌트로 활성화하여 상호작용한 상태로 만듭니다.

 

장점

  • 서버에서 렌더링 된 페이지를 보내기 때문에 검색 엔진 최적화에 유리합니다.
  • 서버에서 렌더링한 페이지를 보내기 때문에 빠르게 첫 페이지를 로딩할 수 있습니다.
  • 페이지 새로 고침 없이 동적 콘텐츠를 실시간으로 업데이트 할 수 있습니다.

 

단점

  • SSR은 서버에서 처리되기 때문에 서버의 부하가 생길 수 있습니다.
  • HTML이 보여진 후 JS 로직이 실행되기 때문에, 그 사이간 응답할 수 없는 문제가 있습니다.
  • HTML이 먼저 표시되고, 그 후 JavaScript 로직이 실행되면서 화면이 변경될 수 있습니다.

 

SSG란?

 

완전히 정적인 HTML 웹 사이트를 생성하는 방법입니다. 웹 페이지를 빌드 시점에 미리 렌더링하여 정적인 HTML 파일로 생성합니다. 즉, 서버는 사용자가 페이지를 요청할 때 동적으로 페이지를 생성하는 것이 아니라 미리 준비된 정적 HTML을 클라이언트에 전달합니다.

 

장점

  • 이미 생성된 정적 HTML 파일을 제공하므로 빠른 페이지 로딩이 가능합니다.
  • 서버에 큰 부담이 없기 때문에 높은 성능을 가질 수 있습니다.
  • 검색 엔진 최적화도 가능합니다.

 

단점

  • 동적 컨텐츠나 실시간으로 변화하는 데이터를 반영하는 데 한계가 있습니다.
  • 빌드 시간이 커질 수 있습니다.

 

ISR란?

 

ISR은 SSG의 단점을 보완한 방식으로, 기존 SSG 처럼 정적 페이지를 제공하면서도, 일정 시간이 지나면 서버에서 재생성하여 최신 상태를 유지할 수 있도록 합니다.

 

장점

  • 초기 요청에서는 정적 파일을 제공하므로, SSG와 마찬가지로 빠른 응답이 가능합니다.
  • 주기마다 새 데이터를 반영할 수 있어, 콘텐츠가 변경되는 경우에도 활용할 수 있습니다.
  • 모든 요청마다 새롭게 렌더링 하는 SSR과는 다르게, 일정 주기로 페이지를 재생성하므로 부하가 줄어듭니다.

 

단점

  • 새로운 데이터가 반영되기 전까지 이전 캐시 데이터를 볼 수 있습니다.
  • 로드 밸런싱 환경에서 캐시 동기화 문제가 있습니다.

 


 

 

Next에서 CSR / SSR / SSG / ISR 사용법

Next.js에는 App RouterPages Router가 있으며, App Router는 Next.js 13에서 도입된 최신 라우팅 방식입니다.

두 방식은 SSR, SSG, ISR 사용법에서 약간의 차이가 있습니다.

본 설명은 Next.js 15 버전을 기반으로 App Router를 기준으로 진행하겠습니다.

 

Next에서 CSR 사용하기

 

'use client'

export default function Nav() {
  const [isLogin, setIsLogin] = useState(false);
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    const userDataCookie = document.cookie
      .split('; ')
      .find(row => row.startsWith('userData='));
    if (userDataCookie) {
      const userData = JSON.parse(decodeURIComponent(userDataCookie.split('=')[1]));
      setUserData(userData);
      setIsLogin(true);
    }
  }, []);

  //... 생략
}

 

Next.js에서 CSR을 사용하려면 맨 위에 'use client'를 선언해야 합니다.

이렇게 하면 해당 컴포넌트에서 React의 훅과 상태 관리 기능을 사용할 수 있습니다.


 

Next에서 SSR 사용하기

 

export const dynamic = 'force-dynamic';

export default async function ReadMe({ params }: Params) {
  const { id } = await params;
  let data = null;
  if (!(id && !isNaN(Number(id)))) return;

  try {
    const response = await axios.post(`${process.env.NEXT_PUBLIC_BACKEND_URL_D}/api/workspace/simple`, {
      workspace_id: id,
    });
    data = response.data;
  } catch (error) {
    console.error('Error fetching data:', error);
    return;
  }

 // ... 이하 생략
}

 

App Router 경우 2가지 방법이 있습니다.

  • export const dynamic = 'force-dynamic'  →   페이지 전체를 SSR로 렌더링
  • cache: "no-store"  →   특정 fetch 요청만 SSR 적용

저는 사용자 별 다른 페이지를 보여주어야 하기 때문에 SSR을 선택하였습니다.

 

Pages Router에서는 getServerSideProps를 사용하여 SSR을 구현할 수 있습니다.


 

Next에서 ISR 사용하기

 

// API에서 데이터 가져오는 함수
const fetchDataFromAPI = async () => {
  try {
    const keyword = '';
    const response = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL_D}/api/readme/search?keyword=${keyword}`, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
      next: {
        revalidate: 60, 
      },
    });

 

App Router에서는 ISR을 적용하는 두 가지 방법이 있습니다.

 

  • export const revalidate = 60;  →   페이지 전체를 ISR
  • fetch의 next.revalidate 옵션    →   특정 fetch만 ISR

저는 두 번째 방법을 사용했으며, 최대 60초 동안 데이터를 캐싱한 후, 새로운 요청이 들어오면 데이터를 다시 가져오도록 설정했습니다.

 

page Router에서 getStaticProps + revalidate 옵션을 사용하면 됩니다.


 

Next에서 SSG 사용하기

 

export const dynamic = 'force-static';

export default function AboutUs() {
  return (
    <div className="responsive_aboutResponsive ">
      <div className="flex aboutus-responsive-image justify-between my-[100px] w-full gap-[100px]" style={{height:'50%'}}>
        <div className="flex flex-col h-100 mr-[100px]" style={{flex : 1}}>
          <span className="text-5xl">
            Build perfect <br />
            Readme, together.
          </span>{' '}
          <br />

  // ... 이하 생략
}

 

App router에서 SSG는 기본적으로 정적파일을 생성합니다.
하지만 export const dynamic = 'force-static'; 를 통해 명시할 수 있습니다.

 

page Router에서 getStaticProps  옵션을 사용하면 됩니다.

 

 


 

 

 

그래서 CSR / SSR / SSG / ISR을 언제 사용할까?

CSR은 사용자의 상호작용에 따라 동적으로 변하는 컴포넌트에 사용하면 좋습니다.

 

예로 들자면 로그인 버튼, 검색 버튼이 있겠습니다.

사용자 상호작용에 따라 동적으로 변해야 하기 때문입니다.


 

SSR은 최신 데이터를 보여주어야 하고, 사용자 별 다른 페이지를 보여주어야 할 때 사용합니다.

 

 

대시보드와 같이 사용자별로 다른 페이지를 보여주어야 할 때 SSR을 사용하는 것이 좋습니다.

또한 SEO도 필요한 페이지에 사용하는 것이 좋습니다.


 

SSG는 정적 페이지에 사용하면 좋습니다.

 

해당 페이지는 렌딩 페이지로, 내용에 변화가 없기 때문에 SSG(정적 사이트 생성)를 사용합니다.


 

ISR은 실시간 변경이 필요 없지만, 주기적으로 페이지를 업데이트해야 하는 곳에 적합합니다.

 

 

해당 페이지는 최신 컨텐츠를 50개를 보여주는 페이지입니다.

실시간 변경은 필요 없지만 주기적으로 업데이트가 필요하므로 ISR을 사용합니다.

 

 


 

 

얼마나 빨라졌을까?

이렇게 페이지마다 적절히 사용하면 페이지 성능을 극대화 할 수 있습니다.

 

 

캐싱 처리가 되서 상당히 빠르게 가져오는 것을 볼 수 있습니다.

 

 


 

 

로드 밸런싱 환경에서 캐싱 동기화 문제 Redis로 해결하기

(Redis 사용 전)

 

위 그림과 같이 로드 밸런싱 환경에서는 각 서버에 ISR로 캐싱된 데이터가 서버마다 다를 수 있습니다.

 

만약 3개의 pod가 존재한다고 가정했을 때

  • Pod A -> server A running -> .next/server/app/${pagename}.html
  • Pod B -> server B running -> .next/server/app/${pagename}.html
  • Pod C -> server B running -> .next/server/app/${pagename}.html

이렇게 별도의 3개의 pod가 떠있을 경우 pod마다 각각의 캐시가 존재합니다.

 

만약 On-demand 방식으로 Pod A의 ${pagename}.html을 업데이트 한다고 가정해 봅시다.

 

그러면 pod B, pod C는 이전 캐싱된 html을 보여주게 됩니다.

 

이런 문제를 해결하기 위해 redis를 이용해 동기화 시켜주는 작업을 해야 합니다.

 

 

 

 

(Redis 사용 후)

 

제가 사용한 방법은 redis의 메시지 브로커입니다.

 

특정 서버에서 On-demand 요청이 발생하거나, revalidate 주기가 만료되어 캐시를 재검증하게 되면, 해당 서버는 Redis를 통해 다른 서버에 메시지를 브로드캐스트합니다.

 

메시지를 수신한 다른 서버들은 이를 감지하고 캐시를 재검증하는 방식으로 동작합니다.

 

코드를 통해 살펴 봅시다.

export default async function ReadMe({ params }: Params) {

  
  if (!global.hasSubscribed) {
    console.log("서버 시작 시 Redis 구독 시작...");
    await subscriber.subscribe('workspace_channel');
    subscriber.on('message', async (channel, message) => {
      console.log(`Redis 채널 ${channel}에서 메시지 받음: ${message}`);

      await fetch(`http://localhost:3000/api/revalidate`);
    });
    global.hasSubscribed = true; // 구독 상태 설정
  } else {
  }
  const sendMessageToSubscribers = async () => {
    await publisher.publish('workspace_channel', "revalidate");
    console.log('메시지를 Redis 채널로 발행했습니다:');
  };

  await sendMessageToSubscribers()
  const workspaces = await fetchDataFromAPI();
  
  return (
    <div className="responsive_container">
      <header className="pt-[55px] "></header>
      <div className="grid_mainGrid ">
        <WorkSpaceContainer workspaces={workspaces} />
      </div>
      <Footer />
    </div>
  );
}


// API에서 데이터 가져오는 함수
const fetchDataFromAPI = async () => {
  try {
    const keyword = '';
    const response = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL_D}/api/readme/search?keyword=${keyword}`, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
      next: {
        revalidate: 60, 
      },
    });
    
    if (!response.ok) throw new Error('Network response was not ok');

    const data = await response.json();
    return data?.data || null;
  } catch (error) {
    console.error('Error fetching data from API:', error);
    return null;
  }
};

 

 

 


 

 

다른 방법

On-demand ISR로 인해 캐싱 동기화 문제가 발생하는 문제는 막을 수 있습니다.

 

하지만 근본적으로 Redis에 캐시를 관리를 하는 방법이 있는데요

 

next.config.js 파일에서 기존 서버에서 캐시를 관리하는 설정을 없애고, 커스텀 캐시 핸들러 함수로 관리할 수 있습니다.

 

 

관련해서 블로그 글을 공유합니다.

 

Next.js App With Redis Cache Handler | by Mohsen Mahoski | Medium

 

Next.Js Redis Cache Handler

Share Next.js cache data with Redis

medium.com

 

 

참고로 이 방법은 Next 15버전에서는 지원을 안하니 주의하시기 바랍니다.

 

 

 

(테스트)

 

 

테스트는 다음과 같이 여러 개의 서버를 실행하여 진행합니다.

 

 

이러한 방법으로 대규모 트래픽을 처리하는 데 효과적일 것 같습니다.

 

피드백, 댓글 환영입니다.

 

감사합니다.