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()
andheaders()
: 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()
가 사용되었습니다.
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>
);
};
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
을 하기 위해서는 아래와 같은 단계를 거쳐야 했습니다.
Browser
에서Backend Server
에 데이터 수정 API를 요청한다.Backend Server
에서DataBase
에 있는 데이터를 수정한다.Backend Server
에서Frontend Server(NextJS Server)
에Revalidation API
를 요청한다.Frontend Server
에서 요청받은 id값을 기반으로 해당하는 페이지를Revalidation
한다.- 브라우저에 새롭게 생성한 페이지를 제공한다.
위 단계를 진행하기 위해서, Backend Server
와 Frontend Server
에는 API통신을 위한 코드를 작성해야 했습니다.
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);
}
};
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);
}
};
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");
}
}
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해서 사용
클라이언트에서 서버에 있는 함수를 호출할 수 있는 이유는 아래와 같습니다.
- 컴파일단계에서
“use server”
지시문이 있는 함수에 대한 고유 라우팅 경로를 할당. (ex, 12qwkebfkjbdfsbhbhj2) → 길이가 40인 유니크한 문자열 - 해당 함수를 Client에서 호출하면, 헤더에
Next-Action : 고유 라우팅 경로
를 담아 POST 요청 - NextJS Server에서는 헤더에 있는
Next-Action : 고유 라우팅 경로
가 있다면, 미들웨어를 통해 Server Action으로 라우팅 - 함수를 실행 후 리턴값을 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
import { revalidatePost } from "@utils";
function handleSubmitPost() {
submitRequest(data);
revalidatePost(id);
}
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
기존의 SSR
에서 사용자가 Page
와 Interaction
하기 위해서는 다음과 같은 과정을 거쳐야 했습니다.
- 서버에서 필요한 데이터 fetching
- 데이터를 기반으로
HTML
파일을 만든 후,JavaScript Bundle
파일,CSS
파일을 클라이언트에 전송 - 클라이언트에서는
non-interactive
한 화면을 보게 된다. - 클라이언트에서는 받은 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-component
가 JavaScript Bundle
파일에 포함되지 않아, 번들사이즈가 상당히 감소했습니다.
Loading, Error, Layout 파일 분리
라우팅을 위한 파일이 분리된점은 단점이자 장점으로 생각합니다.
기존에는 아래와 같이 Loading
, Layout
을 위한 코드가 한 곳에 모여있었습니다.
//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>
);
}
//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-component
등 css-in-js
라이브러리를 사용할 경우 지원하지 않는것으로 확인됩니다.
클라이언트에서 서버상태관리를 완전히 버릴수 없음
server-component
가 등장하면서 server 상태관리를 완전히 server-component로 이관하는것을 기대했습니다.
하지만, 어쩔수없이 클라이언트에서 서버상태를 관리해야하는 경우가 있었습니다.
ex) SSG 페이지에서 동적인 서버 데이터 관리
이런 경우로 인해, react-query
와 같은 서버상태관리 라이브러리를 완전히 떼어낼 수 없는 점이 아쉬웠습니다.
공식문서를 자세히 보기전에 알 수 없는 것들
라이브러리, 프레임워크를 이용해 개발을 하려면 공식문서를 떼려야 뗄 수 없습니다.
App Router
기반에서는 개발을 진행하며 원하는대로 동작하지 않았을 때 참고할 수 있는 레퍼런스가 많지 않았습니다.
그리고 어떤점을 정확히 수정해야하는지 개발과정에서 터미널, 런타임 에러 등을 통해 알 수 없어, 공식문서를 자세히보지 않으면 알 수 없는것들이 너무 많았습니다. (ex, 왜 SSG가 안될까?)
지금까지 블로그를 Pages Router기반에서 App Router기반으로 전환하며 있었던 과정에 대해 다루어보았습니다.
전체적으로 만족스럽지만, server-component가 새롭게 추가되어 개발 사고체계를 바꾸는점이 가장 힘들었던것 같습니다.
이상으로 글을 마칩니다.