Hits

1편에이어, Trouble Shooting, Feature에 대한 내용부터 시작합니다.

SSG가 왜 안되는걸까?

블로그 포스트페이지(/page/[id])는 빠른 제공을 위해 Static 하게 HTML파일을 만들어 제공하고 있습니다.
이를 위해 NextJS 공식문서에 나와있는대로 fetch에 option으로 cache : ‘force-cache’를 주고(주지 않아도 default로 force-cache임) build를 해보아도 HTML 파일이 만들어지지 않았습니다.

여러번 삽질과 Reference를 찾아본 끝에 아래 Reference를 찾을 수 있었습니다.

https://medium.com/@hafiz.k/next-js-13-a-deep-dive-7c8fd24fa1dd

Reference에는 아래와 같은 내용이 나와 있었습니다.
Dynamic Rendering (SSR): Next.js automatically switches to dynamic rendering of a component if:

  • A dynamic fetch request (with no-cache flag) is encountered
  • A dynamic function (like, the cookies ( …. ) or headers ( …. ) ) is encountered.

추가적으로, 공식문서에도 아래와 같은 내용이 있었습니다.

Switching to Dynamic Rendering

During rendering, if a dynamic function or uncached data request is discovered, Next.js will switch to dynamically rendering the whole route. This table summarizes how dynamic functions and data caching affect whether a route is statically or dynamically rendered:

이를 통해, SSG를 진행하는 과정에서 Dynamic Function을 만나거나, uncached data request(cache : “no store”)을 만나게 되면, SSG가 아닌 SSR이 이루어진다는 사실을 파악할 수 있었습니다.
공식문서에 따르면 Dynamic Function에는 아래와 같은 함수들이 있습니다.

  • cookies() and headers(): Using these in a Server Component will opt the whole route into dynamic rendering at request time.
  • useSearchParams():
    • In Client Components, it'll skip static rendering and instead render all Client Components up to the nearest parent Suspense boundary on the client.
    • We recommend wrapping the Client Component that uses useSearchParams() in a <Suspense/> boundary. This will allow any Client Components above it to be statically rendered.
  • searchParams: Using the Pages prop will opt the page into dynamic rendering at request time. 위 함수들은 빌드타임에 알 수 없는 정보(cookie, url 관련 정보)들을 제공하는 함수입니다.

위에 정보들을 바탕으로 기존 코드들에서 Dynamic Function과 uncached data request를 찾아보았습니다.
가장 먼저, root layout(app/layout.tsx)에서 사용자의 cookie에 저장된 테마를 사용하기 위해, cookies()가 사용되었습니다.

app/layout.tsx
const RootLayout = ({ children }: PropsWithChildren) => {
  const cookieStore = cookies();
  const theme = cookieStore.get("theme")?.value || "light";
 
  return (
    <html lang="en">
      <head>...</head>
      <body data-theme={theme}>...</body>
    </html>
  );
};
app/layout.tsx
const RootLayout = ({ children }: PropsWithChildren) => {
  const cookieStore = cookies();
  const theme = cookieStore.get("theme")?.value || "light";
 
  return (
    <html lang="en">
      <head>...</head>
      <body data-theme={theme}>...</body>
    </html>
  );
};

root layout에서 dynamic function이 사용되었기 때문에, 모든 페이지를 Static Generation이 될 수 없었습니다.
이 문제를 해결하기 위해, root layout(server-side)에서 cookie를 이용해 data-theme을 결정하는 로직을 제거하고, data-theme“dark”로 고정해주었습니다.
고정된 “dark”와 사용자의 cookie에 있는 테마가 다를 경우에 대해서는, 이를 변경하는 로직을 클라이언트로 위임했습니다.

themeContext의 useEffect에서 해당 로직 실행 → CODE

다음으로, 포스트의 조회수(ViewCount)를 가져오는 fetch에서 uncached date를 사용했습니다.
포스트의 조회수는 사용자가 조회를 할 때마다 변경되는 데이터였기 때문에, ssr에서 사용하는 cache : “no-store”를 사용했는데, 이게 문제가 되었습니다.

const PostViewCount = async ({ id }: Pick<PostType, "id">) => {
  const viewCount = await getPostViewCountAPI(id);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {Number(viewCount) + 1}</span>
    </div>
  );
};
 
const getPostViewCountAPI = async (postId: number) =>
  request<{ viewCount: number }>({
    method: "get",
    url: `/post/load/viewCount/${postId}`,
    options: {
      cache: "no-store",
    },
  });
const PostViewCount = async ({ id }: Pick<PostType, "id">) => {
  const viewCount = await getPostViewCountAPI(id);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {Number(viewCount) + 1}</span>
    </div>
  );
};
 
const getPostViewCountAPI = async (postId: number) =>
  request<{ viewCount: number }>({
    method: "get",
    url: `/post/load/viewCount/${postId}`,
    options: {
      cache: "no-store",
    },
  });

SSG페이지를 제공할 때 server-component(server-side)에서 dynamic 하게 fetch후, 데이터를 제공하는 방법이 따로 없었기 때문에, 조회수(viewCount)데이터를 가져와 화면에 표시하는 로직을 client-side로 위임했습니다.

const PostViewCount = () => {
  const searchParams = useSearchParams();
  const id = searchParams?.get("id");
  const [viewCount, setViewCount] = useState<number | null>(null);
 
  useEffect(() => {
    getPostViewCountAPI(id).then((viewCount) => {
      setViewCount(viewCount);
    });
  }, [id]);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {viewCount ? Number(viewCount) + 1 : null}</span>
    </div>
  );
};
const PostViewCount = () => {
  const searchParams = useSearchParams();
  const id = searchParams?.get("id");
  const [viewCount, setViewCount] = useState<number | null>(null);
 
  useEffect(() => {
    getPostViewCountAPI(id).then((viewCount) => {
      setViewCount(viewCount);
    });
  }, [id]);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {viewCount ? Number(viewCount) + 1 : null}</span>
    </div>
  );
};

마지막으로, useSearchParams를 사용하는 컴포넌트가 문제가 되었습니다.
포스트의 조회수(ViewCount)를 가져오기 위해서는 포스트의 id가 필요했고, 해당 id를 useSearchParams 훅을 통해 가져왔습니다.

Static Generation을 하게되는 Page에 존재하는 Client Componet에서 해당 Hook을 사용하기 위해서는, Suspense를 이용해 감싸주어야 합니다. 그렇지 않을 경우 “Entire page deopted into client-side rendering”라는 워닝 메시지를 만나게 됩니다. → https://nextjs.org/docs/messages/deopted-into-client-rendering

이 문제를 해결하는 방법은 총 2가지가 있었습니다.

  • Root Component(Page)에서 Props로 받는 id를 내려주기
  • Suspense로 감싼 후, useSearchParams 사용

Suspense로 감싼 후, useSearchParams를 사용할 경우, 컴포넌트가 첫 렌더링 되었을 때, useSearchParams는 동기화되지 않아, 정확한 searchParams를 반환하지 않습니다.

그리고 조회수(ViewCount)를 보여주는 컴포넌트가 Root Component(Page)에서 1 deps에 위치했기 때문에, 1번을 통해 문제를 해결했습니다.

const PostViewCount = ({ id }: Pick<PostType, "id">) => {
  const [viewCount, setViewCount] = useState<number | null>(null);
 
  useEffect(() => {
    getPostViewCountAPI(id).then((viewCount) => {
      setViewCount(viewCount);
    });
  }, [id]);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {viewCount ? Number(viewCount) + 1 : null}</span>
    </div>
  );
};
const PostViewCount = ({ id }: Pick<PostType, "id">) => {
  const [viewCount, setViewCount] = useState<number | null>(null);
 
  useEffect(() => {
    getPostViewCountAPI(id).then((viewCount) => {
      setViewCount(viewCount);
    });
  }, [id]);
 
  return (
    <div className={styles.PostViewCount}>
      <span>조회수 : {viewCount ? Number(viewCount) + 1 : null}</span>
    </div>
  );
};

Server Action을 활용한 On-Demand Revalidation

App Router에서는 Server Action을 활용해, On Demand Revalidation이 가능합니다.
Pages Router기반에서는 Build 타임에 SSG를 통해 만들어진 페이지(HTML, JSON)를 Revalidation을 하기 위해서는 아래와 같은 단계를 거쳐야 했습니다.

  1. Browser에서 Backend Server에 데이터 수정 API를 요청한다.
  2. Backend Server에서 DataBase에 있는 데이터를 수정한다.
  3. Backend Server에서 Frontend Server(NextJS Server)Revalidation API를 요청한다.
  4. Frontend Server에서 요청받은 id값을 기반으로 해당하는 페이지를 Revalidation한다.
  5. 브라우저에 새롭게 생성한 페이지를 제공한다.

위 단계를 진행하기 위해서, Backend ServerFrontend Server에는 API통신을 위한 코드를 작성해야 했습니다.

Backend Server
const updatePost = async (req: Request, res: Response, next: NextFunction) => {
  // ...
  try {
    // ...
 
    // Revalidation을 위한 API 요청
    if (process.env.NODE_ENV === "production") {
      await axios.post(
        `${CLIENT_URL}/api/revalidate-post?secret=${process.env.SECRET_REVALIDATE_TOKEN}`,
        {
          id,
        }
      );
    }
    return res.json({
      message: MESSAGE.EDIT_POST_SUCCESS,
    });
  } catch (err) {
    console.error(err);
    next(err);
  }
};
Backend Server
const updatePost = async (req: Request, res: Response, next: NextFunction) => {
  // ...
  try {
    // ...
 
    // Revalidation을 위한 API 요청
    if (process.env.NODE_ENV === "production") {
      await axios.post(
        `${CLIENT_URL}/api/revalidate-post?secret=${process.env.SECRET_REVALIDATE_TOKEN}`,
        {
          id,
        }
      );
    }
    return res.json({
      message: MESSAGE.EDIT_POST_SUCCESS,
    });
  } catch (err) {
    console.error(err);
    next(err);
  }
};
Frontend Server
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // ...
  try {
    // ...
    const idToRevalidate = body.id;
    if (idToRevalidate) {
      // page revalidation
      await res.revalidate(`/post/${idToRevalidate}`);
      return res.json({ revalidated: true });
    }
  } catch (err) {
    return res.status(500).send("Error while revalidating");
  }
}
Frontend Server
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // ...
  try {
    // ...
    const idToRevalidate = body.id;
    if (idToRevalidate) {
      // page revalidation
      await res.revalidate(`/post/${idToRevalidate}`);
      return res.json({ revalidated: true });
    }
  } catch (err) {
    return res.status(500).send("Error while revalidating");
  }
}

위 과정이 진행된 이유는 revalidation(res.revalidate)을 위한 함수가 Server에서만 사용가능한 함수였기 때문입니다.
하지만, Server Action의 등장으로 API EndPoint를 만들고, 따로 API를 호출하지 않아도 빌드타임에 Static Generation된 페이지를 Revalidation 할 수 있게 되었습니다.

공식문서

에 따르면, Server Action을 이용하면 API EndPoint를 만들지 않고 Component에서 Direct하게 비동기 Server Function을 사용할 수 있습니다.

Server Action을 사용하는 방법은 2가지가 있습니다.

  • Server Component 내부에 정의 후 사용
  • Server Action을 위한 파일을 분리 후, 파일 최상단에 “use server” 지시문을 선언한 뒤, Client Component에서 import해서 사용

클라이언트에서 서버에 있는 함수를 호출할 수 있는 이유는 아래와 같습니다.

  1. 컴파일단계에서 “use server” 지시문이 있는 함수에 대한 고유 라우팅 경로를 할당. (ex, 12qwkebfkjbdfsbhbhj2) → 길이가 40인 유니크한 문자열
  2. 해당 함수를 Client에서 호출하면, 헤더에 Next-Action : 고유 라우팅 경로를 담아 POST 요청
  3. NextJS Server에서는 헤더에 있는 Next-Action : 고유 라우팅 경로 가 있다면, 미들웨어를 통해 Server Action으로 라우팅
  4. 함수를 실행 후 리턴값을 Response에 담아 보낸다

Server Action 덕분에 복잡하게 API를 호출하지 않고, 간단하게 Client Component에서 함수(Server Action) 호출을 통해 Revalidation을 할 수 있었습니다.
클라이언트 컴포넌트에서 Submit할 때, revalidatePost(Server Action) 호출 → https://github.com/BY-juun/Blog/blob/master/client/components/post/PostWriteForm.tsx

submit in client component
import { revalidatePost } from "@utils";
 
function handleSubmitPost() {
  submitRequest(data);
  revalidatePost(id);
}
submit in client component
import { revalidatePost } from "@utils";
 
function handleSubmitPost() {
  submitRequest(data);
  revalidatePost(id);
}

Server Action을 위한 file → https://github.com/BY-juun/Blog/blob/master/client/utils/revalidate.ts

"use server";
 
import { REVALIDATE_TAG } from "@constants";
import { revalidateTag } from "next/cache";
 
export async function revalidatePost(id: number) {
  revalidateTag(REVALIDATE_TAG.POST);
  revalidateTag(`${REVALIDATE_TAG.POST}${id}`);
}
"use server";
 
import { REVALIDATE_TAG } from "@constants";
import { revalidateTag } from "next/cache";
 
export async function revalidatePost(id: number) {
  revalidateTag(REVALIDATE_TAG.POST);
  revalidateTag(`${REVALIDATE_TAG.POST}${id}`);
}

Server Component Refetch

Client Component에서 특정 동작을 통해, Server Component를 다시 불러와야 하는 경우가 있습니다.

예를 들면, 아래와 같은 경우가 있습니다.
로그인폼을 통해 사용자가 로그인을 하면, server-side에서 사용자의 데이터를 가져오는 server component를 리렌더링해야되는 상황
이럴 경우, useRouter가 반환하는 refresh 함수를 사용할 수 있습니다.

공식문서

에는 다음과 같이 나와있습니다.

  • router.refresh(): Refresh the current route. Making a new request to the server, re-fetching data requests, and re-rendering Server Components. The client will merge the updated React Server Component payload without losing unaffected client-side React (e.g. useState) or browser state (e.g. scroll position).

현재 라우팅을 refresh하는데, 클라이언트 상태(useState, Browser State)에 영향을 주지 않고, 서버에 대한 작업(Server Component re-rendering, re-fetching data request)만 새롭게 다시 하게 됩니다.


Streaming With Suspense

공식문서 → https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#streaming-with-suspense

기존의 SSR에서 사용자가 PageInteraction하기 위해서는 다음과 같은 과정을 거쳐야 했습니다.

  1. 서버에서 필요한 데이터 fetching
  2. 데이터를 기반으로 HTML파일을 만든 후, JavaScript Bundle 파일, CSS파일을 클라이언트에 전송
  3. 클라이언트에서는 non-interactive한 화면을 보게 된다.
  4. 클라이언트에서는 받은 HTML, JavaScript Bundle파일을 이용해 Hydration을 하게 되고, Page는 사용자와 interaction을 하게된다.

위 과정은 Sequential하게 이루어집니다.
따라서, 서버에서 필요한 Data를 Fetching하는 과정에서 특정 Data Fetching이 오래 걸린다면, HTML파일을 만드는 과정이 Blocking된다는 이야기입니다.

Suspense와 함께 Streaming을 사용한다면, Page의 HTML을 작은 Chunk단위로 쪼개고, 동적으로 Chunk를 클라이언트로 전송합니다.
따라서, 서버에서 필요한 모든 Data가 Fetching되어 HTML파일을 만들기전에, 만들어진 HTML Chunk먼저 클라이언트로 전송되어, Hydration을 한 후 interaction을 할 수 있습니다.

(아래 영상은 의도적으로 특정 API가 5초정도 느리게 호출되도록 작성한 코드를 기반으로 촬영되었습니다)

Streaming With Suspense X

Streaming With Suspense O

실무에서도 특정 Data Fetch가 길어지는 상황을 만난적이 있는데, 이 경우에 사용하면 좋은 Feature로 보입니다.


좋았던 점

번들사이즈 감소

pages router 기반에서는 모든 컴포넌트가 client-component 였기 때문에, 모두 JavaScript Bundle 파일이 되었지만, App Router 기반에서는 server-componentJavaScript Bundle파일에 포함되지 않아, 번들사이즈가 상당히 감소했습니다.

Loading, Error, Layout 파일 분리

라우팅을 위한 파일이 분리된점은 단점이자 장점으로 생각합니다. 기존에는 아래와 같이 Loading, Layout을 위한 코드가 한 곳에 모여있었습니다.

AS-IS
//url을 기반으로 현재 페이지의 레이아웃 결정
const PageLayout = ({ children, url }: Props) => {
  const contentLayoutClassName = getClassNameFromUrl(url);
  return (
    <section className={styles.PageLayout}>
      <div className={contentLayoutClassName}>{children}</div>
    </section>
  );
};
 
//url을 기반으로 loading(skeleton) 결정
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <WithRouteChange routeChangeFallback={(url) => <PageSkeleton url={url} />}>
      <CommonSEO />
      <Component {...pageProps} />
      <Modals />
      <MyToastContainer />
      <ReactQueryDevtools initialIsOpen={false} />
    </WithRouteChange>
  );
}
AS-IS
//url을 기반으로 현재 페이지의 레이아웃 결정
const PageLayout = ({ children, url }: Props) => {
  const contentLayoutClassName = getClassNameFromUrl(url);
  return (
    <section className={styles.PageLayout}>
      <div className={contentLayoutClassName}>{children}</div>
    </section>
  );
};
 
//url을 기반으로 loading(skeleton) 결정
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <WithRouteChange routeChangeFallback={(url) => <PageSkeleton url={url} />}>
      <CommonSEO />
      <Component {...pageProps} />
      <Modals />
      <MyToastContainer />
      <ReactQueryDevtools initialIsOpen={false} />
    </WithRouteChange>
  );
}

App Router 기반에서는 위 처럼 코드를 작성할 필요없이, 각 Routing 폴더에 loading.tsx, layout.tsx를 만들어 분리할 수 있었습니다.
덕분에, 코드 가독성이 좋아졌습니다.


아쉬웠던 점

라이브러리 지원

제 블로그는 PlayWright을 이용한 e2e테스트를 하고 있었고, PlayWright에서 제공하는 API Intercept(route)기능을 이용하고 있었습니다.
아쉽게도 Server-Component(Server-Side)에서 요청하는 Data Fetch에 대해서는 Intercept가 되지 않았습니다.

관련 ISSUE(https://github.com/microsoft/playwright/issues/27947)를 남겨봤지만, 지원하지 않으며 앞으로도 지원하지 않을 것으로 생각됩니다.

이 부분은 추후에 MSW와 연동하는 방식이나, 서버에 테스트용 데이터를 만드는 방식으로 개선할 예정입니다.

다음으로, 제 블로그는 css module system을 사용해 문제가 없었지만, emtion, styled-componentcss-in-js 라이브러리를 사용할 경우 지원하지 않는것으로 확인됩니다.

클라이언트에서 서버상태관리를 완전히 버릴수 없음

server-component가 등장하면서 server 상태관리를 완전히 server-component로 이관하는것을 기대했습니다.
하지만, 어쩔수없이 클라이언트에서 서버상태를 관리해야하는 경우가 있었습니다.
ex) SSG 페이지에서 동적인 서버 데이터 관리

이런 경우로 인해, react-query와 같은 서버상태관리 라이브러리를 완전히 떼어낼 수 없는 점이 아쉬웠습니다.

공식문서를 자세히 보기전에 알 수 없는 것들

라이브러리, 프레임워크를 이용해 개발을 하려면 공식문서를 떼려야 뗄 수 없습니다.
App Router 기반에서는 개발을 진행하며 원하는대로 동작하지 않았을 때 참고할 수 있는 레퍼런스가 많지 않았습니다.
그리고 어떤점을 정확히 수정해야하는지 개발과정에서 터미널, 런타임 에러 등을 통해 알 수 없어, 공식문서를 자세히보지 않으면 알 수 없는것들이 너무 많았습니다. (ex, 왜 SSG가 안될까?)

지금까지 블로그를 Pages Router기반에서 App Router기반으로 전환하며 있었던 과정에 대해 다루어보았습니다.
전체적으로 만족스럽지만, server-component가 새롭게 추가되어 개발 사고체계를 바꾸는점이 가장 힘들었던것 같습니다.
이상으로 글을 마칩니다.