들어가며

처음에는 하나의 Next.js 앱으로 시작해 빠르게 기능을 붙여 나갔습니다. 하지만 기능이 늘어나면서 앱은 점점 무거워지고, 배포 시간도 길어졌어요. 특히 독립적으로도 잘 동작할 수 있는 기능이 메인 앱에 묶여 있다 보니, 사용자는 쓰지 않는 코드까지 함께 내려받아야 했습니다.

그래서 조금 다른 길을 선택해 보기로 했습니다. Micro Frontend Architecture(MFA)를 도입해 메인 앱과 뷰어를 분리하고, 경계에서 라우팅을 제어하도록 한 거죠. 덕분에 팀은 개발과 배포를 더 가볍게 할 수 있었고, 사용자에게는 꼭 필요한 코드만 전달할 수 있었습니다.


 

본문

1) 모노레포에서의 분리 원칙

  • 공통자원은 공통패키지로, 나머지는 앱별로 독립 유지
    예: 유틸/타입 등 만 공통패키지로 공유
  • 서로의 UI/상태 의존은 끊기
    메인앱과 뷰어앱이 각자 필요한 자원을 갖도록 선을 그었어요.
  • 이렇게 하면 번들이 자연스럽게 슬림해지고, CI에서도 앱 단위로 병렬 빌드/배포가 쉬워집니다.

2) 경계에서 라우팅: Middleware + Rewrite

메인 앱은 특정 경로를 기준으로 요청을 뷰어 앱으로 rewrite 합니다. URL은 유지되지만 응답은 뷰어가 담당해요.

// client/middleware.ts (요지)
import { NextResponse } from 'next/server';

export async function middleware(request: Request) {
  const url = new URL(request.url);
  const { pathname, search } = url;

  if (pathname.startsWith('/mf-viewer')) {
    // 예: ENV.VIEWER_URL = https://viewer.example.com
    const target = `${process.env.VIEWER_URL}${pathname}${search}`;
    return NextResponse.rewrite(new URL(target));
  }

  // 기본 흐름
  return NextResponse.next();
}

메인 앱에서 /mf-viewer/** 요청은 뷰어 앱으로 rewrite 합니다.


3) 인증 게이트는 뷰어 앱 입구에서 가볍게

보호가 필요한 경로(예: /mf-viewer)는 뷰어 앱의 미들웨어에서 인증 여부만 확인하고, 필요하면 /login으로 돌려보냅니다. 페이지 단에서는 “그려도 되는 상황”만 다루도록 흐름을 단순화했습니다.

// viewer/middleware.ts (요지)
import { NextResponse } from 'next/server';

export async function middleware(request: Request) {
  const url = new URL(request.url);
  const { pathname } = url;

  const isWorkspace = {/* /mf-viewer/preview ** */}
  const isAuthenticated = /* 로그인 유/무 */

  if (isWorkspace && !isAuthenticated) {
    // 메인 앱의 로그인 페이지로 보냄
    const loginUrl = new URL('/login', url);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

인증 판단은 뷰어 앱의 경계에서 처리하고, 페이지는 단순화합니다.


4) 화면 통합: iframe + postMessage

메인 앱은 뷰어를 iframe으로 느슨하게 포함해요. 서로의 렌더 트리를 얽지 않으면서도, 사용자는 하나의 제품처럼 자연스럽게 느낍니다.

// 예: client 측 포함 컴포넌트
export default function ViewerEmbed() {
  return (
    <iframe
      src={`/mf-viewer`}
      sandbox="allow-scripts allow-same-origin allow-popups allow-downloads allow-forms"
      title="Report Viewer"
    />
  );
}

필요한 화면만 iframe으로 포함해 느슨하게 결합합니다.

상호작용이 필요할 땐 postMessage로 얇은 메시지 프로토콜을 주고받습니다.

// 예: 메인 앱 측 메시지 수신
useEffect(() => {
  const onMessage = (event: MessageEvent) => {
    const expectedOrigin = window.location.origin;
    if (event.origin !== expectedOrigin) return;

    const msg = event.data;
    if (!msg?.type) return;

    switch (msg.type) {
      case 'EDITOR_SAVE':
        // 데이터 재조회 등
        break;
      case 'EDIT_SUB_PAGE':
        // 서브 페이지 편집 UI 열기 등
        break;
      default:
        break;
    }
  };

  window.addEventListener('message', onMessage);
  return () => window.removeEventListener('message', onMessage);
}, []);

origin을 확인하고, 메시지 타입을 좁혀 안전하게 처리합니다.


5) 보안·안정성 체크리스트

  • sandbox 최소 권한: allow-scripts, allow-same-origin을 기본으로, 필요한 경우에만 allow-downloads, allow-forms 등을 열어 주세요.
  • 메시지 검증: origin/type 체크는 필수입니다. payload는 꼭 필요한 필드만 주고받아요.

6) 우리가 얻은 것

  • 가벼운 번들: 각 앱이 자신에게 필요한 코드만 담게 되어, 사용자는 불필요한 자바스크립트를 내려받지 않습니다.
  • 개발 속도: 앱 단위로 병렬 개발/배포가 가능해지고, 리스크도 각자 관리할 수 있습니다.
  • 유연한 운영: 뷰어만 빠르게 핫픽스하거나 롤백하는 등, 운영 선택지가 넓어집니다.
  • 익숙한 경험: 사용자는 여전히 하나의 제품처럼 느끼되, 내부에선 역할이 잘 나뉩니다.

“Next+Next”만이 전부는 아니에요

특히 Multi-Zones 문서에는 “같은 도메인 안에 다른 프레임워크 앱을 함께 둘 수 있다”는 취지도 언급돼 있어요. 즉, 상황에 따라 Next + React/Vite 같은 조합도 충분히 가능합니다.

 

결론

이번 구조 개편은 거창한 게 아니고, 경계를 또렷하게 세운 선택에 가까웠어요. Next.js의 Middleware + rewrite, 그리고 iframe + postMessage 만으로도 충분히 깔끔한 마이크로 프런트엔드를 만들 수 있었습니다.

비슷한 고민을 하고 계시다면, 먼저 “어디까지를 이 앱의 책임으로 볼 것인가”부터 가볍게 나눠보세요. 필요한 부분부터 천천히 분리해도 충분히 큰 효과를 체감하실 거예요. 🙌

 

 

다국어 번역, 제대로 관리하고 계신가요?

웹 애플리케이션을 운영하다 보면 화면마다 문구가 계속 추가되고 수정되죠. 이때 다국어 번역 파일을 일일이 관리하는 건 정말 번거롭습니다. 언어가 많아질수록 번역 키를 누락하거나 파일이 꼬이는 일도 생기고요.

그래서 저는 next-intl, i18next-parser 를 활용하여 자동화된 다국어 처리 구조를 만들었습니다. 이 글에서는 그 구조를 차근차근 소개해 드릴게요. 😊


1. 시스템 아키텍처: 전체 플로우 한눈에 보기

1-1. 번역 흐름 요약

  1. i18next-parser가 코드에서 번역 키를 자동으로 추출 → JSON 파일로 저장
  2. 번역 키 파일을 빌드 시점에 백엔드 API로 전송자동 번역 수행 (DeepL)
  3. 어드민에서 수동으로 번역 수정 가능 (자동 번역 보완)
  4. 프론트엔드에서 번역본을 API로 받아오고 캐싱next-intl에 맞게 변환
  5. 번역본이 변경되면 캐시 무효화 요청으로 최신 번역을 반영

1-2. 시퀀스 다이어그램


2. 번역 키 관리: i18next-parser로 자동화하기

2-1. 번역 키 자동 추출하기

i18next-parser는 코드에서 번역 키를 자동으로 추출해서 네임스페이스별 JSON 파일로 만들어줍니다. 설정 파일 예시는 이렇게 되어 있어요:

export default {
  keySeparator: ":",
  defaultValue: (_, namespace, key) => `${namespace}:${key}`,
  lexers: {
    tsx: [
      { lexer: "JsxLexer", functions: ["t", "t.rich"], namespaceFunctions: ["useTranslations"] },
      { lexer: "JsxLexer", functions: ["commonT"], namespaceFunctions: ["useCommonTranslations"] }
    ]
  },
  output: "public/translations/$NAMESPACE.json",
  input: ["apps/client/src/**/*.{js,jsx,ts,tsx}"],
};

2-2. 네임스페이스와 파일 구조

translations/
├── 000.공통.json
...
├── 006.게시판.json
...
  • 숫자 prefix: 어드민에서 번역 키 그룹의 노출 순서를 제어
  • 한글 네임스페이스: 어떤 기능에서 쓰이는 번역인지 쉽게 파악

2-3. 번역 키 규칙

{ordering}.{namespace}:{content}
  • ordering: 어드민에서 주제 별 노출될 순서를 관리하기 위함
  • namespace: 주제
  • content: 내용

2-4. 한글 키값을 사용하게 된 배경

처음에 영어로 번역 키를 쓸지 고민했었어요.
근데 이런 대화가 오갔죠:

이렇게 해서 한글 키값을 네임스페이스와 함께 사용하기로 했어요.
이 덕분에 기획자, 디자이너, 번역가도 키값만 봐도 무슨 의미인지 쉽게 알 수 있답니다. 😊

또한, 이렇게 한글로 만든 키 값 덕분에 번역 본 수정 시 원문을 바로 알 수 있어 수정에 용이해요.




3. 번역 키를 백엔드로 보내는 과정

빌드 시점에 번역 키를 백엔드로 보내서 번역본을 관리할 수 있어요.
이 과정을 담당하는 스크립트가 바로 export-translation-file.js입니다!

3-1. 번역 파일 예시

000.공통.json

{
  "검색": "000.공통:검색"
}

006.게시판.json

{
  "게시글": "006.게시판:게시글",
  "최신기사": "006.게시판:최신기사",
  "제목을 입력해 주세요": "006.게시판:제목을 입력해 주세요",
  "첨부파일 {num}개 ({capacity}KB)": "006.게시판:첨부파일 {num}개 ({capacity}KB)"
}

이런 식으로 번역 키를 추출해서 JSON 파일로 저장하고, 빌드 시에 이 파일들을 백엔드로 전송합니다.
어드민에서는 이 번역본을 불러와서 수동으로 번역을 수정할 수도 있어요.
자동 번역과 수동 수정이 자연스럽게 조화를 이루는 구조랍니다.

3-2. 스크립트 흐름 (export-translation-file.js)

const fs = require('fs');
const path = require('path');

const translationsDir = path.join(__dirname, '../../public/translations');

// 백엔드에 원하는 형태로 보내기 위한 전처리 과정
const getAllTranslationFiles = () => {
  const files = fs.readdirSync(translationsDir);

  const translations = files
    .filter((file) => file.endsWith('.json'))
    .reduce((acc, file) => {
      const filePath = path.join(translationsDir, file);
      const fileContents = fs.readFileSync(filePath, 'utf-8');
      const parsedData = JSON.parse(fileContents);

      const formattedTranslations = Object.entries(parsedData).map(
        ([_, value]) => ({
          name: value,
        }),
      );

      acc.push(...formattedTranslations);
      return acc;
    }, []);

  return translations;
};

// 번역 데이터 전송
const translations = getAllTranslationFiles();

fetch(`<api-base-url>/<end-point>`, {
  method: 'POST',
  headers: {
    'X-API-KEY': ENV.X_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(translations),
});

3-3. 이 스크립트가 하는 일:

  • 번역 키 파일을 모두 읽고,
  • 백엔드에 번역 키를 전송해 번역 키를 저장하고 써드파티 번역 API를 통해 번역본을 저장해요.

이렇게 하면 번역 키를 코드에서 작성하는 것만으로 백엔드와 어드민까지 자동으로 연결돼요!


4. 프론트엔드에서 번역본 불러오기: getRequestConfig

프론트엔드에서는 next-intl을 사용해 번역본을 적용합니다.
이때 서버에서 번역본을 받아오고, 캐시를 활용해 성능을 높여요.

import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';

import { COOKIE_KEYS } from '@/constants/cookie-keys';
import { languageApi } from '@/generated/static-apis/Language/Language.query';

// 번역 본을 받아서 next-intl가 이해할 수 있는 형태로 변환
const transformData = (data) => {
  const result = {};
  for (const { key, value } of data) {
    const parts = key.split(/[:.]/);
    let obj = result;
    for (let i = 0; i < parts.length - 1; i++) {
      obj = obj[parts[i]] = obj[parts[i]] || {};
    }
    obj[parts[parts.length - 1]] = value;
  }
  return result;
};

export default getRequestConfig(async () => {
  const cookieLocale = cookies().get(COOKIE_KEYS.LOCALE)?.value || 'ko';
  const [languageList, languageTransList] = await Promise.all([
    languageApi.languageList({ params: { cache: 'force-cache' } }),
    languageApi.languageTransList({ code: cookieLocale, params: { cache: 'force-cache' } }),
  ]);
  const messages = transformData(languageTransList);
  const locales = languageList.map(({ code }) => code);
  if (!locales.includes(cookieLocale)) notFound();
  return { messages, locale: cookieLocale };
});

4-1. 흐름 설명:

  • 쿠키에서 **사용자의 언어(locale)**를 가져오고,
  • 백엔드 API에서 번역 데이터를 받아와서,
  • next-intl이 사용하는 messages 형태로 변환합니다.
  • **캐시(cache)**를 사용해 반복 호출 시 성능도 챙겼어요.

5. 변수 치환 문제: {name}도 번역된다고요?

자동 번역 API를 사용하면 변수도 같이 번역돼버릴 수 있어요.

예시:

{name}님, 안녕하세요!

→ 포르투갈어로 번역 시:

Olá, {nome}!

해결 방법:

  1. 번역 전에 내용에 사용되는 변수를 숫자 변수로 치환
  2. 번역이 끝난 후 다시 원래 변수로 복원

이렇게 하면 변수는 안전하게 보호할 수 있어요!


6. 캐시 전략: 읽기 성능 최적화

  • 번역 데이터는 읽기(Read)가 많고, 쓰기(Write)는 적어요.
  • 그래서 프론트엔드에서는 영구 캐시를 사용합니다.
  • 번역본이 업데이트되면 캐시 무효화 API를 호출해 최신 데이터를 유지해요.
  • 나중에는 번역본을 S3옮겨서 정적 파일로 서빙하는 것도 고려 중입니다.

다국어 관리 스트레스 탈출하기

i18next-parser자동 번역 키 추출 → 누락 걱정 NO

  • 백엔드 자동 번역 + 어드민 수동 수정 → 품질 보장
  • 프론트엔드 캐시 + 무효화 → 성능과 신뢰성 확보
  • 네임스페이스 구조화 → 확장성과 가독성 향상

Server Action And Mutations

  • server action을 실행하면 현재 경로로 네트워크 POST 요청이 실행됩니다.

server-actions

 


useOptimistic(실험 기능) + server action

useOptimistic + useTransition을 이용한 UI 동시성 처리

'use client';

import { useOptimistic, useTransition } from 'react';

import {
  AccordionPanel,
  AspectRatio,
  Button,
  Flex,
  Input,
  InputGroup,
  InputRightElement,
  Text,
} from '@chakra-ui/react';

import {
  IndividualInquiryRetrieveType,
  IndividualReplyType,
} from '@/swagger/@types/data-contracts';

import ImageAsNext from '@/components/ImageAsNext';
import { MyPageTranslations } from '@/containers/MyPage/MyPage';

import { AnswerIcon } from '@/icons';

import { createReply } from '../actions/create-reply';
import useQnaReplyForm from '../hooks/useQnaReplyForm';

interface QnaDetailProps {
  data: IndividualInquiryRetrieveType | undefined;
  qnaTranslations: MyPageTranslations['main']['qna'];
}
const QnaDetail = ({ data, qnaTranslations }: QnaDetailProps) => {
  const {
    handleSubmit,
    reset,
    register,
    formState: { isDirty },
  } = useQnaReplyForm();

  const [isPending, startTransition] = useTransition();
  const [optimisticReplies, addOptimisticReplies] = useOptimistic(
    // 초기 데이터 설정
    data?.replySet,
    (
      state: IndividualReplyType[] | undefined,
      newReply: IndividualReplyType,
    ) => {
      if (state) {
        state.push(newReply);
        return [...state];
      }
      return [newReply];
    },
  );

  const onSubmit = handleSubmit((res) => {
    if (!data) return;
    startTransition(async () => {
    // useOptimistic에서 반환한 addOptimisticReplies 로 낙관 업데이트 가능
    // 사용자의 작업을 실시간 반영
      addOptimisticReplies({
        id: optimisticReplies ? optimisticReplies[0].id + 1 : 0,
        isAdmin: false,
        body: res.reply,
        createdAt: res.createdAt,
      });
      reset();
      await createReply({
        data: {
          individualInquiry: data.id,
          body: res.reply,
        },
      });
    });
  });

  return (
    <AccordionPanel p="8px 16px" minH="65px">
      <Flex direction="column">
        {/* 답변 */}
        {data?.inquiryState === 2 && (
          <Flex direction="column">
            {optimisticReplies?.map(({ id, isAdmin, body }) => {
              return (
                <Flex
                  key={reply.id}
                  p={{ base: '0 0 20px', sm: '0 16px 20px' }}
                  gap="8px"
                >
                  <AnswerIcon boxSize="24px" fill="none" />
                  {isAdmin && (
                    <Text color="fanRed.500" textStyle="Body1_E">
                      {qnaTranslations.reply.admin}
                    </Text>
                  )}
                  <Text textStyle="Body1_R" whiteSpace="pre">
                    {body}
                  </Text>
                </Flex>
              );
            })}
          </Flex>
        )}
      </Flex>
    </AccordionPanel>
  );
};

export default QnaDetail;

useOptimistic + useTransition을 이용한 UI 동시성 처리

server action과 react api들을 조합해서 사용했을 때 UI 동시성 처리에 용이합니다.


server actions와 함께 useActionState, useFormStatus를 사용하면 react-hook-form을 대체할 수 있을까?

useActionState와 useFormStatus를 사용하면 자바스크립트가 실행되기 이전에 폼과 상호작용 하도록 할 수 있다는 큰 장점이 있습니다.
useActionState를 통하여 초기 상태 및 업데이트된 상태 표시가 가능하며, server action을 통한 응답 결과를 통해서 성공 및 에러 상태 표시가 가능합니다. 또한 useFormStatus의 pending을 통한 button disabled 및 loading 처리 또한 가능합니다.
위의 훅과 서버액션 조합을 사용하면 Core Web Vital 중 INP 수치가 향상되어 SEO에 좋은 영향을 줄 것 같습니다.

하지만 react-hook-form에서 제공하는 isValid, isDirty 등의 상태는 직접 구현해야 할 것으로 보이며 이벤트 핸들러 기반의 즉각적인 validation을 해야 할 경우라면 react-hook-form을 대체하긴 어려워 보입니다.

 


서버액션을 사용할 때 react에서 제공하는 API인 useTransition useOptimistic 등의 훅을 함께 사용하여 UI 동시성 처리가 가능하여 유저 경험을 향상할 수 있습니다.

서버액션은 내 현재 경로로 POST 요청이 가기 때문에 필요시에만 사용하는 것을 권장합니다.

 

📌 Only plain objects can be passed to Client Components from Server Components ⇒ server action의 반환값은 plain objects 이어야 합니다. 응답 전문을 그대로 클라이언트로 넘기면 안 됩니다.

react@canary에서 소개된 API들은 react 19에서 정식으로 릴리즈 됩니다.

React Server Component

정적 렌더링

정적 렌더링을 사용하면 경로는 빌드 시 렌더링 되거나 revalidate 후 백그라운드에서 렌더링 됩니다.

vercel을 사용하여 배포하면 개발자가 설정한 vercel function region (CDN)에 캐시 된 정적 콘텐츠가 푸시되며 해당 region에서 콘텐츠를 가져옵니다.


동적 렌더링

정적 렌더링은 빌드 시 렌더링 되지만, 동적 렌더링은 사용자가 요청 시 렌더링 됩니다.

여기에서 설명한 것처럼 cookies(), headers(), noStore() 등의 동적 함수를 만나면 동적 렌더링으로 전환됩니다.


스트리밍

  • html 스트리밍은 서버에서 콘텐츠가 다운로드될 때까지 웹 페이지를 사용자에게 점진적으로 렌더링 하는 기술입니다. 페이지의 모든 콘텐츠가 완전히 로드될 때까지 기다리는 대신, 일부 콘텐츠가 로드되면 사용자에게 더 빠르게 보여줄 수 있도록 합니다.

Suspense와 통합

import { Suspense } from 'react';

import { productList } from '@/apis/product/productApi';

import {
  PaginatedCategoryListType
} from '@/swagger/@types/data-contracts';

import ShopLayout from '@/components/@Layout/ShopLayout';
import ShopCategory from '@/components/Shop/ShopCategory';
import ShopCategorySkeleton from '@/components/Shop/ShopCategorySkeleton';
import ShopItemList from '@/components/Shop/ShopItemList';
import ShopItemSkeleton from '@/components/Shop/ShopItemSkeleton';

export default function ShopPage() {
  return (
    <ShopLayout>
      <Suspense fallback={<ShopCategorySkeleton />}>
	    // server component   
        <ShopCategory />
      </Suspense>
      <Suspense
        key={shopQueryParams.toString()}
        fallback={<ShopItemSkeleton listLength={12} />}
      >
        <ShopItemList
          productListPromise={productList({
            query,
          })}
        />
      </Suspense>
    </ShopLayout>
  );
}
import { CategoryType } from '@/swagger/@types/data-contracts';

export default async function ShopCategory() {
  const categories = categoryList({ query: { category_type: '1' } })
  return (
    <>
     {JSON.stringify(categories)}
    </>
  );
};

 

suspense

비동기 작업(data fetching.. 등)을 수행하는 동안 React Suspense의 fallback으로 대체 UI를 표시할 수 있습니다.

가령 A, B, C 컴포넌트가 각 각 다른 비동기 작업을 처리한다면, 모든 비동기 작업이 끝날 때까지 기다리지 않고 작업이 완료되는 순서대로 hydration을 시작할 수 있습니다.

suspense chunk

요청에 대한 응답 (분할된 chunk)을 점진적으로 서버에서 클라이언트로 전송합니다.

📌 Suspense는 일부 요청을 병렬화 시켜 전체 응답이 완료될 때까지 기다리지 않고 데이터 스트림을 수신하여 클라이언트 렌더링을 차단하지 않고, network waterfall를 방지합니다.
📌 loading.tsx를 추가해서 해당 경로의 모든 비동기 작업이 끝날 때까지 fallback UI를 표시할 수 있지만 suspense를 비동기 작업 단위로 감싸는 것이 유저 경험에 좋습니다.

 


부분 사전 렌더링

  • 정적 및 동적 콘텐츠가 혼합된 페이지의 경우, 전체가 동적으로 렌더링 되는 것이 아니라, 정적 및 동적 렌더링을 혼합해서 사용할 수 있습니다.
  • 현재 이 기능은 next.js 실험적인 기능으로, 아직 공식적으로 출시되지 않았습니다. 이 렌더링 모델을 사용하려면 동적 콘텐츠를 Suspense로 감싸기만 하면 됩니다. 이렇게 하면 PPR이 안정적 버전으로 변경될 때, 소스코드의 큰 변경 없이 기능의 이점을 누릴 수 있습니다.
  • PPR을 사용하려면 next.config.js 에서 아래와 같이 설정할 수 있습니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,
  },
}
 
module.exports = nextConfig

 

2024.06.19 - [Next.js] - Next.js에서 Data Fetching & Caching 알아보기

이전 글에서는 Next.js에서 data fetching과 caching에 대해서 다뤘습니다.


이번 글에서는 Next.js 에서 데이터 페칭 시 고민한 문제들에 대해서 이야기해 보겠습니다.

App Router를 사용하면서 기존에 사용하던 tanstack-query를 제거하고 오직 fetch 만을 사용하여 데이터 캐시 관리를 했습니다.

  1. fetch의 기능이 강력하므로 tanstack-query가 꼭 필요하지 않았습니다.
  2. tanstack-query 공식 문서에서 아래의 권장사항을 발견했습니다.

It's hard to give general advice on when it makes sense to pair React Query with Server Components and not. If you are just starting out with a new Server Components app, we suggest you start out with any tools for data fetching your framework provides you with and avoid bringing in React Query until you actually need it. This might be never, and that's fine, use the right tool for the job!

🤔 위와 같은 이유로 라이브러리 도움 없이 fetch를 사용하다가 다음과 같은 고민이 생겼습니다.

 


1. API 호출 비용을 최대한 줄이면서 유저에게 빠르게 데이터를 보여줄 수 있는 방법이 있을까?

  • 이전 글에서 언급한 대로, 개인화된 요청은 원격 서버에 캐싱되어서는 안 됩니다. 따라서 fetch를 사용할 때 no-store 옵션을 설정합니다. 이 경우, 새로고침 및 라우트 캐시가 만료될 때마다 API 호출이 발생하므로 client-side에서 tanstack-query를 사용하여 브라우저 메모리에 개인화된 요청에 대한 응답을 캐시하고 queryKey와 staleTime으로 캐시를 관리합니다.
  • 개인화되지 않은 요청은 server-side에서 force-cache 옵션을 사용하여 API를 호출하고, 적절한 방법을 통해 갱신합니다.
  개인화 되지 않은 요청  개인화된 요청
server-side fetch x
client-side x tanstack-query

1번 고민에 대한 결과를 정리하면, 개인화된 요청은 검색 엔진에 노출될 필요가 없으므로 client-side에서 tanstack-query로 요청 후 캐시를 관리합니다.

그 반대의 경우 server-side에서 fetch 하면 첫 번째 요청 시 데이터가 원격 서버에 캐싱되며 Suspense의 fallback을 이용하여 유저에게 데이터를 가져오는 중임을 알립니다. 두 가지 방법을 적절히 섞어 사용하면 API 호출 비용을 줄이고, 유저에게 빠르게 데이터를 보여줄 수 있습니다.

이때, 2번의 고민이 발생합니다.


2. server-side에서 fetching 한 데이터를 사용하는 곳까지 props drilling 하는 것을 최소화할 수 있는 방법이 있을까?

props drilling 방식으로 데이터를 전달하면 상위 컴포넌트가 변경되면 하위 컴포넌트까지 변경해야 하는 문제가 있습니다. 이는 유지보수 관점에서 좋지 않습니다.

 

2-1. tanstack-query를 사용하여 prefetch 및 de/hydrating 하기

개발환경

(next14.2.2, react18^, typescript^5.1.3, tanstack-query^5.40.0)

초기 설정

import { Query, defaultShouldDehydrateQuery } from "@tanstack/react-query";

const queryClientOptions = {
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
    },
    dehydrate: {
      // per default, only successful Queries are included,
      // this includes pending Queries as well
      shouldDehydrateQuery: (query: Query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === "pending",
    },
  },
};

export default queryClientOptions;

import { QueryClient } from "@tanstack/react-query";
import queryClientOptions from "@/configs/tanstack-query/query-client-options";

function makeQueryClient() {
  return new QueryClient(queryClientOptions);
}

let browserQueryClient: QueryClient | undefined = undefined;
export default function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

"use client";

import { PropsWithChildren } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import getQueryClient from "@/configs/tanstack-query/get-query-client";

function AppProvider({ children }: PropsWithChildren) {
  const queryClient = getQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default AppProvider;

Prefetch + de/hydrating data

서버에서는 마크업을 생성/렌더링 하기 전에 data를 prefetch 하고, 해당 데이터를 마크업에 포함할 수 있는 직렬화 가능한 형식으로 de/hydration 하며, 클라이언트에서는 해당 데이터를 React 쿼리 캐시로 hydration 합니다.

PhotoPrefetchQuery 클래스를 생성하고, QueryClient 인스턴스를 받습니다.

import { QueryClient } from "@tanstack/react-query";
import {
  RequestFnReturn,
  QueryHookParams,
} from "../@types/tanstack-query-type";
import { todoApi } from "./Todo.api";
import { QUERY_KEY_TODO_API } from "./Todo.query";

export class TodoPrefetchQuery {
  private queryClient: QueryClient;

  constructor(queryClient: QueryClient) {
    this.queryClient = queryClient;
  }

  /**
   * No description
   *
   * @tags todos
   * @name TodosList
   * @summary Todos 목록 조회
   * @request GET:/todos
   * @secure */

  public useTodoListPrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoList>
  >(
    params?: QueryHookParams<typeof todoApi.todoList, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.LIST(params?.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoList(params?.variables),
      ...params?.options,
    });
  };

  /**
   * No description
   *
   * @tags todos
   * @name TodosRetrieve
   * @summary Todos 상세 조회
   * @request GET:/todos/{id}
   * @secure */

  public useTodoRetrievePrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoRetrieve>
  >(
    params: QueryHookParams<typeof todoApi.todoRetrieve, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.RETRIEVE(params.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoRetrieve(params.variables),
      ...params?.options,
    });
  };
}

"use server";

import { TodoPrefetchQuery } from "@/apis/Todo/Todo.prefetchQuery";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import QueryTodoList from "@/components/Todo/QueryTodoList";

export default async function HydratedTodoList() {
	// Next.js는 이미 fetch()를 활용하는 요청을 중복 제거하므로 data를 fetch 하는 각 서버 컴포넌트에
	// 새로운 queryClient를 만듭니다.
  const queryClient = new QueryClient();
  const todoPrefetchQuery = new TodoPrefetchQuery(queryClient);
  todoPrefetchQuery.useTodoListPrefetchQuery({
    variables: {
      params: {
        cache: "force-cache",
      },
    },
  });

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <QueryTodoList isSuspense />
    </HydrationBoundary>
  );
}

"use client";

import { useTodoListQuery } from "@/apis/Todo/Todo.query";
interface QueryTodoListProps {
  isSuspense?: boolean;
}
const QueryTodoList = ({ isSuspense }: QueryTodoListProps) => {	
  // This useQuery could just as well happen in some deeper
  // child to <TodoList>, data will be available immediately either way
  const { data: todos } = useTodoListQuery({
    options: {
      staleTime: 1000 * 5,
      suspense: isSuspense,
    },
  });

  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};
import ListSkeleton from "@/components/ListSkeleton";
import ParentA from "app/dehydrate-with-streaming/_source/components/ParentA";
import HydratedPhotoList from "app/dehydrate-with-streaming/_source/components/HydratedPhotoList";
import Link from "next/link";
import { Suspense } from "react";

export default async function DehydrateWithStreamingPage() {
  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">dehydrate with streaming</p>
      <Link href="/" className="text-blue-500 mb-8 hover:underline">
        Go to Home
      </Link>
      <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Todo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <ParentA />
          </Suspense>
        </div>
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Photo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <HydratedPhotoList />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

"use server";
import ParentB from "./ParentB";

export default function ParentA() {
  return <ParentB />;
}

///////////////////////////////////////////////////////////
"use server";
import ParentC from "./ParentC";

export default function ParentB() {
  return <ParentC />;
}

///////////////////////////////////////////////////////////
"use server";
import HydratedTodoList from "./Todo/hydratedTodoList";

export default function ParentC() {
  return <HydratedTodoList />;
}

ParentC 컴포넌트(사용하는 곳과 가까운 위치)에서 prefetch 및 de/hydrate를 수행하여 props drilling을 피하고 코드를 더 깔끔하게 유지할 수 있습니다.

하지만 이 방법은 보일러 플레이트 코드가 길어지고, fetch와 tanstack-query 두 곳에서 캐시 관리를 해줘야 하는 단점이 있습니다.

그렇다면 좀 더 좋은 방법이 있을까요?

 

2-2. 서버와 클라이언트 컴포넌트 인터리빙 하기

이 접근 방식을 사용하면 <ClientComponent>및 <ServerComponent>가 분리되어 독립적으로 렌더링 될 수 있습니다.

이 경우 children은 클라이언트에서 렌더링 되기 훨씬 전에 서버에서 렌더링 될 수 있습니다.

import { Suspense } from "react";
import { revalidateTag } from "next/cache";
import Link from "next/link";

import ClientComponent from "app/fetch-with-streaming/_source/components/ClientComponent";
import ListSkeleton from "@/components/ListSkeleton";
import FetchPhotoList from "@/components/Photo/FetchPhotoList";
import PromiseResolveHelper from "@/components/PromiseResolveHelper";
import RevalidateButton from "app/fetch-with-streaming/_source/components/RevalidateButton";
import FetchTodoList from "@/components/Todo/FetchTodoList";
import { todoApi } from "@/apis/Todo/Todo.api";
import { PhotoType } from "@/apis/Todo/types/model/photo";
import { photoApi } from "@/apis/Photo/Photo.api";

export default async function FetchWithStreamingPage() {
  async function revalidateTodo() {
    "use server";
    revalidateTag("TODO_LIST");
  }

  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">fetch with streaming</p>
      <Link href="/" className="text-blue-500 mb-6">
        go to example list
      </Link>
      <ClientComponent>
        <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
          <div className="flex flex-col items-center w-full max-w-md">
            <div className="flex flex-col items-center gap-4 w-full mb-4">
              <p className="text-2xl font-medium">Todo List</p>
              <div className="flex gap-2">
                <RevalidateButton revalidate={revalidateTodo} />
              </div>
            </div>
            <Suspense fallback={<ListSkeleton />}>
              <FetchTodoList
                todosPromise={todoApi.todoList({
                  params: {
                    cache: "force-cache",
                  },
                })}
              />
            </Suspense>
          </div>
          <div className="flex flex-col items-center w-full max-w-md">
            <p className="text-2xl font-medium mb-4">Photo List</p>
            <Suspense fallback={<ListSkeleton />}>
              <FetchPhotoList />
            </Suspense>
          </div>
        </div>
      </ClientComponent>
    </div>
  );
}
"use client";

import { Fragment, PropsWithChildren, useState } from "react";

export default function ClientComponent({ children }: PropsWithChildren) {
  const [count, setCount] = useState(0);
  return (
    <Fragment>
      <p>{count}</p>
      <button
        className="bg-blue-500 text-white px-4 py-2 rounded"
        onClick={() => setCount((prev) => prev + 1)}
      >
        up
      </button>
      {children}
    </Fragment>
  );
}

use를 사용하여 클라이언트 컴포넌트에서 프로미스 해제하기

컴포넌트 내부에서 아래 항목을 사용하면 클라이언트 컴포넌트로 전환되어 use를 사용하여 프로미스를 해제합니다.

  • Event listeners
  • State and Lifecycle Effects
  • Use of browser-only APIs
  • Custom hooks that depend on state, effects, or browser-only APIs
  • React Class components
"use client";

import { TodoType } from "@/apis/Photo/types/model/todo";
import { use } from "react";

interface FetchTodoListProps {
  todosPromise: Promise<TodoType[]>;
}

const FetchTodoList = ({ todosPromise }: FetchTodoListProps) => {
  // Streaming data from the server to the client
  const todos = use(todosPromise);
  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};

export default FetchTodoList;

서버 컴포넌트에서 프로미스 해제하기

서버 컴포넌트를 사용할 수 있는 제약 조건에 위배되지 않는 경우 서버에서 프로미스를 해제합니다. 해당 코드는 자바스크립트 번들에 포함되지 않습니다.

import { PhotoType } from "@/apis/Todo/types/model/photo";
import Image from "next/image";

/**
 * @description
 * Restricted Items in Server Component
 *
 * - Event listeners
 * - State and Lifecycle Effects
 * - Use of browser-only APIs
 * - Custom hooks that depend on state, effects, or browser-only APIs
 * - React Class components
 */
export default async function FetchPhotoList() {
  const photos = await photoApi.photoList();
  return (
    <div className="flex flex-col gap-2">
      {photos?.map(({ id, albumId, title, url }) => (
        <div
          key={id}
          className="flex flex-row h-36 border border-gray-400 rounded justify-between p-5 items-center gap-4"
        >
          <div className="flex flex-col justify-between flex-1 h-full">
            <p>albumId: {albumId}</p>
            <p>title: {title}</p>
          </div>
          <div className="relative w-16 h-16">
            <Image src={url} alt="image" fill sizes="60px" />
          </div>
        </div>
      ))}
    </div>
  );
}

결론

여러 가지 시행착오를 겪은 후 fetch와 tanstack-query를 Win-Win 하면서 사용하는 방법은 아래와 같습니다.

  • Case 1 - Authorization을 포함한 GET 요청
    • fetch cache option no-store + tanstack-query staleTime 설정 및 Client-Side에서 useQuery 사용을 권장합니다.
  • Case 2 - Authorization을 포함하지 않는 GET 요청
    • fetch를 독립적으로 사용하는 것을 권장합니다.
  • Case 3 - mutate 요청
    • fetch cache option no-store + Client-Side에서 useMutation 사용을 권장합니다.
  • Case 4 - props drilling 깊은 컴포넌트
    • 서버와 클라이언트 컴포넌트의 InterLeaving을 권장합니다.
    • tanstack-query Dehydrate
      • 해당 방식은 사용 가능하지만, 보일러 플레이트 코드가 길어지고, 별도의 캐시 관리가 필요하므로 권장하지 않습니다.
  • 다만 Case 1Case 3의 경우 상황(ex. 다국적 서비스)에 따라서 모두 fetch 만을 사용할 수도 있을 것 같습니다.
    만약 백엔드와 프런트엔드 서버가 서울이고 엔드 유저가 해외에 있거나 네트워크 환경이 좋지 않다면 latency가 발생합니다.
    이 경우 fetch나 server action을 사용하면 내 서버에 요청하기 때문에 latency가 줄어듭니다.

이와 같이, 상황에 맞게 적절한 전략을 선택할 수 있을 것 같습니다.

 

좀 더 자세한 코드는 아래에서 확인하실 수 있습니다. 

https://github.com/Eunkyung-Son/fetch-with-tanstack-query-practice

1. Fetching

개인화되지 않은 요청

  • fetch의 기본 캐시 옵션은 force-cache로 요청의 응답이 원격 서버에 캐싱됩니다
    • force-cache는 개인화되지 않고 GET 메서드로 요청할 때에만 사용합니다.

개인화된 요청

‼ 기존에는 tanstack-query를 사용하여 client-side에서 백엔드 API 호출 시 사용자의 네트워크 비용이 발생했습니다. 그러나 서버 컴포넌트에서 fetch를 사용한 요청은 프런트 서버 비용이 발생합니다. 캐시 관리를 효율적으로 하지 않으면, 불필요한 프론트 서버 비용과 백엔드 API 호출 비용이 동시에 발생할 수 있습니다.

 


 

2. Caching

Data Cache

💡 Next.js 에서는 서버 요청 및 배포 전반에 걸쳐 fetch 요청에 대한 결과를 유지하는 데이터 캐시가 내장되어 있습니다.
이 데이터 캐시는 클라이언트 캐시가 아닌 서버 캐시를 의미합니다.

 

 

Duration

fetch의 캐시 옵션을 force-cache로 설정 후 배포하게 되면, revalidate 하거나 opt-out 하지 않는 이상 모든 배포에서 캐싱된 데이터가 유지됩니다.

  • Vercel에서 Project 캐시를 삭제하는 방법

Vercel Purge Cache
Purge Everything 버튼을 클릭해 캐시된 데이터를 삭제할 수 있습니다. 하지만 적절한 갱신 방법을 통해 API를 갱신하는 것이 바람직합니다.

 

Revalidating

revalidate 요청이 들어오면 Vercel 인프라의 게이트웨이에서 서버리스 함수를 호출하여 revalidate 후 새로운 데이터를 반환합니다.

 

Time-Based

revalidate 주기를 설정하여 API를 백그라운드에서 revalidate 시킬 수 있습니다.

// Revalidate at most every hour
  fetch('https://...', { next: { revalidate: 3600 } })
  

 

On-Demand

  • revalidateTag를 이용하여 사용자의 동작에 따라 API를 백그라운드에서 revalidate 할 수 있습니다.
fetch(
    `https://.../v1/individual_inquiry/?${individualInquiryQueryParams?.toString()}`,
    {
      next: {
        revalidate: 60,
        // fetch tags에 api 고유의 키 + query params 조합의 키 추가
        tags: [FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST,`${
            FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST
          }${individualInquiryQueryParams?.toString()}`],
      },
    },
  );
  • FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST가 삽입된 모든 API revalidate
revalidateTag(FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST)

 

 

  • FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST + query params 가 삽입된 API revalidate
revalidateTag(`${FETCH_TAGS_INDIVIDUAL_INQUIRY_API.LIST
  }${individualInquiryQueryParams?.toString()}`)
  


  • revalidatePath를 이용하여 해당 경로의 데이터 캐시와, 경로 캐시를 revalidate 할 수 있습니다.

revalidatePath('/ko');
  


  • route handlers를 생성하고 API 갱신하는 로직을 구현합니다.
  • CMS에서 새로운 콘텐츠가 업로드될 때 route API(ex. {FRONT_DOMAIN}/api/revalidate/{revalidateName})를 호출하여 데이터를 갱신합니다.
  • 해당 방법은 읽기 요청이 쓰기(갱신 후 data cache set)요청보다 많을 때 api 요청 비용이 효과적으로 절감됩니다.

 

import { revalidateTag } from 'next/cache';
  import { NextRequest } from 'next/server';

  import { ENV } from '@/configs/env';

  const API_KEY = ENV['X-API-KEY'];

  async function invalidateCache(revalidateName: string, id: string | null) {
    if (id) {
      revalidateTag(`${revalidateName}${id}`);
      revalidateTag(revalidateName);
    } else {
      revalidateTag(revalidateName);
    }

    return new Response(JSON.stringify({}), {
      status: 200,
      statusText: 'success',
    });
  }

  async function invalidRequest() {
    return new Response(JSON.stringify({}), {
      status: 401,
      statusText: 'unauthorized',
    });
  }

  export async function DELETE(
    req: NextRequest,
    {
      params,
    }: {
      params: {
        revalidateName: string;
      };
    },
  ) {
    const isValidApiKey = req.headers.get('X-API-KEY') === API_KEY;

    if (!isValidApiKey) {
      return invalidRequest();
    }

    const { revalidateName } = params;

    const searchParams = new URLSearchParams(req.nextUrl.searchParams);
    const id = searchParams.get('id');

    try {
      return invalidateCache(revalidateName, id);
    } catch (error) {
      return new Response(JSON.stringify({}), {
        status: 500,
        statusText: 'internal server error',
      });
    }
  }

 

Opting out

 

  • 요청 단위의 데이터 캐시 해제
// Opt out of caching for an individual `fetch` request
  fetch(`https://...`, { cache: 'no-store' })
  
  • 경로 단위의 모든 요청 데이터 캐시 해제
// Opt out of caching for all data requests in the route segment
  export const dynamic = 'force-dynamic'
  

 

Request Memoization

  • fetch를 사용할 때 동일한 url & option의 Request를 자동으로 메모합니다.
  • layout page component generateMetadata generateStaticParams에서 같은 요청 시 첫 번째 Request에서 캐싱되고, 후속 Request 시 함수를 실행하지 않고 메모리에서 반환합니다.
  • fetch를 사용한 Request Memoization은 GET method에서만 적용됩니다.
// a
  <Suspense fallback={...}>
      <CelebSection
        data={celebList({
          query: celebQueryParams,
        })}
      />
  </Suspense>
        
  // a
  <Suspense fallback={...}>
      <CelebSection
        data={celebList({
          query: celebQueryParams,
        })}
      />
  </Suspense>
        
  // a-1
  <Suspense fallback={...}>
      <CelebSection
          data={celebList({
            query: celebQueryParams1,
          })}
      />
  </Suspense>

같은 경로에서 a api 2번 호출, query params가 다른 a-1 api 1번 호출

Duration

요청 메모는 서버 요청 간에 공유되지 않고 React 컴포넌트 트리 안에서 렌더링 중에만 적용되므로 재검증할 필요가 없습니다.

Opting out

fetch를 사용한 요청에서 아래의 방법으로 요청 메모를 해제할 수 있습니다.

const { signal } = new AbortController()
  fetch(url, { signal })
  

+ Recent posts