이는 SPA 에서 사이트 렌더링에 필요한 태그를 JS 기반으로 렌더링 하기 때문이다.
페이지를 전환할 때도 새로운 HTML 페이지를 요청하는 것이 아닌, 다음 페이지 렌더링에 필요한 정보를 HTTP 요청으로 받아 DOM 을 수정하는 방식으로 전환된다.
애플리케이션의 성향마다 다르지만, 페이지 별 요청을 모두 서버에서 처리하는 과거의 방식은 유저로 하여금 페이지를 새로 구축하는 듯한 UX 를 제공한다.
하지만 SPA 의 경우 페이지 전환이 브라우저 내부에서 일어나므로, 보다 자연스러운 화면 전환을 제공하여 향상된 UX 를 제공한다.
과거와는 다르게 근래의 웹 애플리케이션의 경우 정보를 전달하기 위한 수단이 아닌, 하나의 독립적인 기능을 가진 경우가 많아졌다.
이는 필연적으로 애플리케이션을 구동하기 위한 코드의 증가로 이어졌고, 이를 위해 클라이언트 단에서 코드를 파싱하여 처리하는 시간 또한 늘어났다.
과거와는 다르게 "웹에서 보다 많은 작업을 수행한다" 는 추세가 두드러지게 보이는 상황에서, 기존의 브라우저에서 모든 일을 처리하는 방식을 타파하여 일부 작업의 경우 서버에서 사전에 처리하는 방식의 SSR 이 다시금 부상하고 있다.
SPA 와는 다르게 SSR 는 사용자에게 보여줄 페이지를 서버에서 렌더링하여 이를 제공하는 방식이다.
CSR 방식과는 다르게 렌더링에 필요한 작업을 서버에서 수행하기 때문에, 디바이스의 성능에 따라 렌더링 속도가 크게 달라지는 CSR 과 달리 네트워크 환경이 동일하면 일정한 성능을 보장한다.
FCP (First Contentful Paint) 의 경우 페이지에 최초의 Bit 가 Paint 되는 시점을 의미하는데, CSR 의 경우 서버로부터 애플리케이션 가동에 필요한 데이터를 받고 이후 렌더링을 시작하지만 SSR 의 경우 사전에 서버로부터 렌더링된 HTML 을 가져오므로 더 빠르다.
하지만 이는 어디까지나 서버가 사용자의 요청에 맞는 페이지를 수월하게 제공할 수 있는 리소스가 확보되었을 때의 이야기다.
검색 엔진의 경우 페이지의 정적인 정보를 가져오기 때문에 애플리케이션 구동에 필요한 JS 파일을 받거나 하지 않는다. 따라서 CSR 은 검색 엔진에게 전달할 정보가 제한적이지만 SSR 의 경우 서버 단에서 사전에 HTML 을 렌더링 하기에 괜찮다.
메타 데이터의 경우에도 검색 엔진에게 전달할 정보를 서버에서 가공하기에 훨씬 유연한 대처가 가능하다.
하지만 최신 검색 엔진의 경우 CSR 에 대한 조사도 충분히 가능하다.
구글 SEO 엔진의 경우 CSR 의 경우에도 JS 를 받아 실제 페이지를 렌더링하는 상황이며, 오히려 Pre-render 를 사용한다면 CSR 에서 사전에 렌더링된 요소를 캐싱함으로서 SEO 를 개선할 수 있다는 가능성이 더욱 높다. SSR 이 반드시 SEO 에 유리한 것은 아님을 유의하자.
누적 레이아웃 이동 (Cumulative Layout Shift) 이란 사용자에게 페이지를 보여준 후 뒤늦게 HTML 요소가 추가되어 화면이 덜걱거리는 UX 를 제공하는 현상을 의미한다.
즉 사용자가 예상치 못한 시점에서 페이지가 변경되는 상황이다.
CSR 의 경우 특정 데이터를 기반으로 페이지를 보여야 하는 경우, 만약 첫 번째 정보가 두 번째 정보보다 늦게 들어왔다면 이를 기반으로 화면을 렌더링하는 과정에서 페이지가 변경된다.
하지만 SSR 의 경우 사전에 서버로부터 이를 받아 렌더링 하기에 CLS 를 줄일 수 있다.
CSR 의 경우 브라우저 단에서 JS 파일을 받고 이를 기반으로 애플리케이션을 실행하기에 디바이스 성능에 따라 페이지 렌더링 속도가 좌우된다.
서버 사이드의 경우 렌더링에 필요한 작업을 사전에 서버에서 진행하므로 브라우저의 부담을 던다.
하지만 SSR 이 항상 CSR 보다 빠르거나 좋은 것은 아니다.
만약 서버가 클라이언트의 요청을 감당하지 못하거나 네트워크 상태가 좋지 못하다면 SSR 의 장점이 무색해진다. 또한 서버 단에서 렌더링 작업을 처리해야 하므로 이에 대한 부하도 고려해야 한다.
SSR 을 고려하여 페이지를 설계할 경우, 브라우저의 Web API 에 종속된 옵션을 쓰지 못함을 의미한다. 따라서 이를 개발자가 잘 판별하여 서버에 대한 고려를 잘 코드에 잘 녹여야 한다.
window, Web API, React 에서 제공하는 여러 Hook 을 사용할 수 없다는 점도 유의해야 한다.
CSR 의 경우 애플리케이션 구축에 필요한 코드를 번들링 하여 S3 같은 Object Storage 에 정적 배포를 하여 비용을 절감할 수 있으나, SSR 은 무조건 서버가 필요하기에 비용을 수반한다.
서버를 제대로 관리하지 않으면 페이지 요청에 대한 응답이 정상적으로 이루어지지 않아 사용자로 하여금 불편함을 초래할 수 있다.
서버와 클라이언트를 둘 다 관리해야 한다는 점에서 관리 포인트가 늘어날 뿐 아니라, 서버에서 정상적으로 렌더링을 진행하기 위해 필요한 시간이 길어진다면 이는 사용자에게 컨텐츠를 제공하기 위해 걸리는 시간이 증가함을 의미한다.
FCP 나 CLS 같이 사용자 경험에 중요하게 쓰이는 지표를 기준으로 SSR 이 유리한지, SPA 가 유리한지를 잘 판별하여 상황에 따라 유리한 방식을 쓰는 것이 좋다.
인자로 넘겨 받은 리액트 컴포넌트를 렌더링해 HTML 로 반환하는 함수다.
함수의 실행 결과로 받은 HTML 내 ChildComponent 에는 Custom Hook 이나 이벤트 핸들러가 부착되어 있지 않다.
renderToString 은 브라우저가 렌더링하는 HTML 을 빠르게 서빙하기 위함이기에, 애플리케이션 구동에 필요한 JS 코드는 별도로 전송하여 브라우저에 제공해야 한다 (hydrate)
div#root
내 data-reactroot
속성은 리액트 컴포넌트의 Root Element 를 알려준다. 이후 hydrate 함수에서 루트를 식별하는 기준이 된다.
renderToString 과 유사하나 React 에서 쓰이는 추가적인 DOM 속성을 붙이지 않는다.
해당 함수는 페이지를 말 그대로 정적 마크업으로 렌더링할 때 쓰이며, hydrate 를 고려하지 않었기에 정적인 페이지를 구축할 때 쓰인다.
이 API 는 곧 Deprecated 될 예정이니 대신 renderToPipeableStream 를 추천합니다.
실행 결과는 renderToString
와 동일하나 해당 API 의 경우 NodeJS 환경에서만 실행이 가능하다.
이는 renderToNodeStream
이 ReadableStream 을 반환하는데, 이는 브라우저에서 제작이 안되고 오직 NodeJS 환경에서만 생성이 가능하다.
renderToString 은 렌더링 결과를 문자열 (HTML) 로 반환하지만, 해당 메서드는 Stream 을 반환하므로 데이터가 클 경우 Chunk 단위로 이를 쪼개어 순차적으로 전송한다.
스트림을 활용할 경우 큰 데이터를 작은 단위로 쪼개 전송할 수 있으므로 HTML 을 작은 단위로 나누어 작성함으로서 즉각적인 응답을 보장하고 NodeJS 서버의 부하를 감소시킨다.
renderToStaticMarkup
과 같이 hydrate 를 고려하지 않은 정적 HTML 을 반환하지만, 해당 메서드는 Stream 을 반환하는 차이점이 존재한다.
renderToString
, renderToNodeStream
으로 생성된 HTML 에 이벤트 핸들러나 이벤트를 부착시키는 작업이다.
사용자와 애플리케이션 간의 상호작용을 위해 필요한 JS 파일을 받아 HTML 에 붙이는 작업을 한다.
Hydrate 과정에서 흔히 보는 Mismatch 에러는 언제 주로 발생할까.
서버 단에서 애플리케이션을 렌더링한 결과와 클라이언트에서 렌더링한 결과가 같지 않을 경우 발생한다.
보통 서버 단이 아닌 클라이언트 단에서 조건부 렌더링을 수행할 때 맞이하는 에러다.
하지만 서로 일치하지 않는 상황임에도 React 는 정상적으로 컴포넌트를 렌더링한다. hydrate 함수가 렌더링을 수행하면서 최종적으로 나온 결과물과 이전의 결과를 대조하는 순으로 동작하기 때문이다.
만약 에러가 불가피하게 발생할 경우 suppressHydrationWarning
을 추가하면 되지만, 굳이 서버에서 해당 함수를 실행시키지 않도록 막는 것이 더 좋다.
책에서는 httpCreateServer 를 기반으로 정말 바닥부터 서버를 구현했으나, 필자는 ExpressJS 를 활용하여 간단하게 아래와 같이 Stream 기반의 서버 사이드 렌더링을 구현했다.
import express from "express";
import renderMainPage from "./renderMainPage";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => {
console.log(`Express server starting... port on ${port}`);
});
// public, dist 폴더에 저장된 정적 파일을 사용하도록 세팅
app.use("/dist", express.static("dist"));
// index path 로 get 요청이 들어올 경우
app.get("/", renderMainPage);
/
경로에 GET 요청이 들어올 경우, renderMainPage 모듈을 실행하여 사용자에게 빌드된 React App 을 서빙한다.import type { Request, Response } from "express";
import { renderToPipeableStream } from "react-dom/server";
import App from "../App";
const renderMainPage = (req: Request, res: Response) => {
// renderToPipeableStream 은 HTML 태그를 포함한 Document 전체를 넣어야 한다.
const Root = () => (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css"></link>
<title>My app</title>
</head>
<body>
<App />
</body>
</html>
);
const { pipe, abort } = renderToPipeableStream(<App />, {
// 번들링된 JS 파일을 가져오는 script 태그에 표기할 URL 배열을 받는 bootstrapScripts
// 현재 클라이언트 단에서는 hydrateRoot 를 호출하는 script 를 포함해야 하므로 사용
bootstrapScripts: ['/main.js'],
// 전체 Shell 의 로딩이 끝났을 경우 실행되는 Callback onShellReady
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res);
},
// Shell 을 로딩하는 과정에서 에러가 발생할 경우 실행되는 Callback onShellError
onShellError() {
res.status(500).send(`res.send("<!doctype html><p>뭔가 문제가 생겼어요..</p>");`);
}
});
};
export default renderMainPage;
renderToPipeableStream
를 사용할 것을 권장하여 이를 채택했다.renderToPipeableStream
는 두 개의 인자를 받는데 첫번째는 HTML 로 렌더링하려는 React 노드, 두 번째는 Streaming 관련 Option 을 담은 객체를 받는다.React 의 서버 사이드 렌더링에서 언급되는 Shell 은 뭔가요?
React 에서는 Shell 을 <Suspense>
바운더리 외부에 존재하는 영역을 지칭한다.
아래 코드에서는 총 3개의 Shell 영역이 나뉘며, renderToPipeableStream 에서는 전체 Shell (앱 전체를 감싸는 Shell) 이 Render 된다면 onShellReady
콜백이 호출된다.
따라서 onShellReady 가 호출되어 렌더링이 시작되더라도, Suspense 바운더리 내부는 아직 데이터를 로드하고 있을 경우 fallback UI 를 보여준다.
function ProfilePage() {
return (
{ /** 여기서부터가 전체 Shell 의 시작이다. */ }
<ProfileLayout>
<ProfileCover />
{ /** 여기서부터가 2번째 Shell 의 시작이다. */ }
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
{ /** 여기서부터가 3번째 Shell 의 시작이다. */ }
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}
renderToPipeableStream 을 사용할 때 검색 엔진에 대한 대비는 어떻게 하나요?
만약 검색 엔진이 페이지를 로드할 경우, Stream 기반의 렌더링과는 다르게 모든 컨텐츠를 렌더링한 후 이를 한번에 엔진에게 전달해야 할 필요가 있다.
onShellReady
콜백이 아니라 모든 Shell 이 렌더링된 이후 실행되는 onAllReady
콜백을 사용하여 해결이 가능하다.import { hydrateRoot } from 'react-dom/client';
import App from "./App";
const rootNode = document.getElementById('root');
const root = hydrateRoot(rootNode, <App />);
const path = require("path");
const webpack = require("webpack");
// 외부 모듈의 의존성을 제거하기 위해 사용.
const nodeExternals = require("webpack-node-externals");
const clientConfig = {
mode: process.env.NODE_ENV,
target: "web",
entry: path.resolve(__dirname, "src/client/index.tsx"),
output: {
filename: "main.js",
path: path.resolve(__dirname, "public/dist/client"),
publicPath: "public",
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
alias: {
src: path.resolve(__dirname, "./src/"),
client: path.resolve(__dirname, "./src/client"),
},
},
module: {
rules: [
{
test: /\.tsx?$/i,
exclude: /node_modules/,
use: "babel-loader",
},
{
test: /\.tsx?$/i,
exclude: /node_modules/,
use: ["ts-loader"],
},
],
},
watchOptions: {
ignored: /node_modules/,
},
};
const serverConfig = {
mode: process.env.NODE_ENV,
target: "node",
externals: [nodeExternals()],
entry: path.resolve(__dirname, "src/server/index.ts"),
output: {
filename: "server.js",
path: path.resolve(__dirname, "public/dist/server"),
publicPath: "public",
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
alias: {
src: path.resolve(__dirname, "./src/"),
server: path.resolve(__dirname, "./src/server"),
},
},
module: {
rules: [
{
test: /\.tsx?$/i,
exclude: /node_modules/,
use: "babel-loader",
},
{
test: /\.tsx?$/i,
exclude: /node_modules/,
use: ["ts-loader"],
},
],
}
};
module.exports = [clientConfig, serverConfig];
tsx
, ts
빌드를 위해 babel-loader, ts-loader 모듈을 사용하여 module 옵션에 추가했다./** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
};
module.exports = nextConfig;
X-Powered-By
헤더를 제거하기 위해 쓰이는 정보책에서는 Page Router 를 기반으로 각 파일 별 역할을 서술했다.
NextJS 애플리케이션의 진입점을 담당하는 파일이다. 애플리케이션에서 전역으로 세팅해야 하는 옵션을 여기서 설정한다.
애플리케이션의 HTML 서식을 설정하는 파일이다. 서버 사이드에서 실행되며 템플릿을 생성하는 역할이기에 Data fetching 도 사용이 불가하다.
애플리케이션 전역에서 발생하는 에러를 일괄적으로 처리하기 위해 개발된 페이지다.
하위 페이지 혹은 컴포넌트로부터 발생한 에러가 최상단으로 전파될 경우 가장 마지막으로 실행된다.
404, 500 에러 발생 시 보여지는 fallback Page 를 커스텀할 수 있다.
NextJS 에서 제공하는 next/link
를 사용하여 <a>
태그와 비슷하게 페이지 이동을 할 수 있다.
<a>
태그의 경우 이동하려는 페이지의 HTML 및 기타 리소스들을 모두 요청한다. 이후 클라이언트 단에서의 hydration 도 진행된다. 이로 인해 약간의 깜빡임이 생긴다.
하지만 next/link
의 경우 클라이언트에서 페이지 렌더링을 위해 필요한 JS 파일만을 받아 라우팅이 동작하는 모습을 볼 수 있다.
책에서는 prefetch
옵션을 false 로 설정했는데, 이를 true 로 변경하면 Link 태그가 담긴 페이지를 렌더링 할때 Link 가 바라보는 페이지 렌더링에 필요한 데이터도 사전에 가져와 추후 라우팅을 더욱 빠르게 진행하도록 한다.
getServerSideProps 의 유무에 따라 빌드 결과물이 달라진다.
getServerSideProps 는 서버로부터 데이터를 받는 작업을 추가하는 메서드이므로, 만약 해당 메서드가 컴포넌트 모듈에 사용되었다면 SSR 이 발생한다.
빌드 시에도 이를 확인할 수 있는데, 서버 사이드 런타임 체크가 된 컴포넌트는 getServerSideProps 가 내부에 존재하는 경우다. 그렇지 않을 경우 SSR 이 필요치 않은 정적 페이지로 분류된다.
특정 페이지를 런타임 환경에 구축하지 않고 빌드 타임에 페이지들을 정적 빌드하여 추후 요청 시 사용자에게 빠르게 빌드된 데이터를 제공할 때 쓰인다.
getStaticPath 과 getStaticProps 은 같이 사용해야 하며, 아래 예시의 경우
// pages/post/[id]
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
fallback: false,
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const { id } = params;
const post = await fetchPost(id);
return {
props: {
post
}
}
}
pages/post/[id]
가 접근 가능한 주소를 정의하는 함수다. pages/post/1
, pages/post/2
만 사전에 빌드하기에 접근이 가능하다. 그 외는 모두 404를 반환한다.import { useRouter } from 'next/router'
function Post({ post }) {
const router = useRouter()
// 페이지가 아직 생성되지 않았을 경우 isFallback 옵션은 true 이다.
// getStaticProps 이 마저 동작하기 전까지는 true 를 반환한다.
if (router.isFallback) {
return <div>Loading...</div>
}
}
// getStaticPaths는 빌드 타임에 실행됨을 유의하자.
export async function getStaticPaths() {
return {
// `/posts/1` and `/posts/2` 만 빌드 타임에 생성된다.
paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
// 만약 paths 에 정의되지 않은 케이스가 있다면 이후 서버에서 생성한다.
fallback: true,
}
}
// getStaticProps 또한 빌드 타임에 실행됨을 유의해라.
export async function getStaticProps({ params }) {
const res = await fetch(`https://.../posts/${params.id}`)
const post = await res.json()
return {
props: { post },
// N초 단위로 생성된 페이지를 무효화하고 재생성하는 옵션. (ISR)
revalidate: 1,
}
}
export default Post
서버에서 실행되는 getServerSideProps 함수의 경우 페이지 진입 전에 무조건 실행되는 특징을 가진다.
getServerSideProps 의 응답 값에 따라 컴포넌트에 prop를 반환할수도, 리다이렉트를 진행할 수도 있다.
렌더링에 필요한 데이터는 __NEXT_DATA__
id 를 가진 script 에 저장되는데, 서버에서 데이터를 fetch 하여 렌더링에 필요한 정보를 클라이언트로 인계하는 역할을 한다. (window 객체에도 저장된다)
getServerSideProps 로 내려준 데이터의 결과는 JSON 형식으로 반환되며 이로 인해 getServerSideProps 에서 내려주는 값은 반드시 직렬화가 가능한 타입이어야 한다. (function, class 불가)
getServerSideProps 에서 내려주는 props 는 왜 script 형태로 저장되는가?
React 의 SSR 은 Data Fetch, HTML 생성, 클라이언트 전송, Hydration 순으로 진행하는데 각 작업 별로 fetch 시점에 따라 결과물이 불일치하는 현상이 생길 수 있다.
따라서 Data Fetch 작업이 진행된 이후에는 해당 결과를 Script 태그에 담아 내려주어 Hydration 이후 상황에서 같은 데이터를 다시 Fetch 할 필요가 없도록 한다. 또한 동일한 데이터를 사용하기에 Hydration Mismatch 에러도 피할 수 있다.
과거 SSR 을 지원하는 메서드가 세분화되기 전에 사용했던 메서드지만, 현재는 _app.tsx
외 특수 컴포넌트 내부에서만 getInitialProps 를 사용할 수 있다.
특정 브라우저에 기본으로 제공되는 스타일을 초기화하거나, 디자인 시스템 같이 애플리케이션 전체에 공통으로 적용하고 싶은 스타일을 명시하고 싶을 때 _app.tsx
를 활용하자.
CSS Module 시스템을 활용할 예정이라면 [Component].module.css
형식으로 CSS 를 작성한 후 컴포넌트 단에 import 하여 사용할 수 있다.
SCSS, SASS 패키지 사용 시에도 CSS Module 과 동일하게 import 하여 컴포넌트 단에 사용하도록 유도한다.
단, typescript 에서는 SCSS 와 SASS 모듈을 감지할 수 없으므로 타입을 추가하여 원활한 import 를 가능하도록 한다.
declare module "*.scss" {
const content: { [className: string]: string };
export = content;
}
책에서는 styled-component 를 사용하여 예시 코드를 작성했다.
자바스크립트 내부에서 스타일시트를 삽입하는 CSS - in - JS 라이브러리를 사용할 경우 _document.tsx
에 사전 생성된 style 태그를 추가하는 과정을 거처야 한다.
import type { DocumentContext, DocumentInitialProps } from "next/document";
import Document, { Html, Head, Main, NextScript } from "next/document";
import { ServerStyleSheet } from "styled-components";
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
};
} finally {
sheet.seal();
}
}
render() {
return (
<Html>
<Head />
<body>
<div id="modal" />
<div id="toolbar" />
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
sheet.collectStyles
를 실행한다.결론은 서버 사이드 렌더링 과정에서 애플리케이션에 쓰이는 스타일을 수집하고, 서버 단에서 이를 주입하는 과정을 거친다고 생각하면 된다.
페이지를 최초 방문한 경우에는 SSR 이 동작해야 하기에 페이지 전체를 요청하여 _app.tsx
내부에 정의된 getInitialProps 가 실행된다.
하지만 이후 CSR 의 경우에는 라우팅 수행을 위해 서버 관련한 로직이 있더라도 전체 페이지가 아닌, 해당 페이지의 getServerSideProps 등을 실행하기에 _app.tsx
내부의 getInitialProps 는 실행하지 않는다.
이를 활용하여 애플리케이션에 최초로 사용자가 접속할 경우 실행할 수 있는 내용을 _app.tsx
의 getInitialProps 에 정의할 수 있다.