[프레임워크 없이 만드는 SSR]을 읽고 SSR로 유명한 Next.js가 어떤 방식으로 SSR과 SSG를 하는지 알아보았다. SSG, SSR의 개념에서 build time이나 run time 등에 관한 개념이 나오므로 이 들의 개념을 먼저 알아보았다.
build time, 혹은 build step은 어플리케이션 코드가 배포되기 위한 일련의 단계를 말한다.
어플리케이션을 빌드할 때, Next.js는 코드를 production에 최적화된 파일로 변환하여 서버에 배포하고 사용자가 사용할 수 있도록 만든다. 이 최적화된 파일에는 다음과 같은 것들이 포함된다.
run time 혹은 request time은 어플리케이션이 빌드되고 배포된 후에, 사용자의 요청에 의해 어플리케이션이 실행되는 기간을 말한다.
compile time이란 프로그램을 컴파일하는 동안 소요되는 시간을 뜻한다. 여기서 compile이란 소스 코드를 기계어로 변환하는 과정을 말한다. (A언어 -> 컴파일 -> B언어로 변환하는 과정)
컴파일 시간은 개발자에게 중요한 요소 중 하나이다. 긴 컴파일 시간은 개발자의 생산성을 저하시킬 수 있고, 코드 변경 후 효괄르 바로 확인하기 어렵게 만들 수 있다. 따라서 개발자들은 컴파일 시간을 최적화하기 위해 다양한 방법을 사용하기도 한다.
참고) 컴파일 시간은 프로그램 실행 시간과는 별개의 개념이다.
컴파일 시간은 소스 코드를 변환하는 컴파일 과정에서 발생한 시간이고 실행 시간은 프로그램을 실행 시켜 실제로 동작하는 동안 소요되는 시간을 의미한다.
참고)자바스크립트는 인터프리터 언어로 컴파일 언어가 아니다.
자바스크립트는 컴파일 타임을 거치지 않는 인터프리터 언어이다. 하지만 v8엔진 같은 현대 자바스크립트 엔진은 일부 코드를 컴파일 하여 최적화 해준다. 따라서, 자바스크립트 자체는 인터프리터 언어이나, 자바스크립트 엔진은 컴파일을 해주고 있다.
이 둘의 주요한 차이점은 오류가 발생하는 시점에 있다. 컴파일 타임과 런타임의 개념을 생각해 보면 이 둘의 차이점을 잘 알 수 있다.
컴파일러는 소스 코드를 분석하고 구문 오류, 타입 오류 같은 문법적으로 이상이 없는지 확인 하고 문제가 있다면 컴파일을 중단한다. 따라서 컴파일 타임 에러란, 문법적으로 이상이 없는지 검사하는 단계이다.
예를 들면 다음과 같은 경우를 들 수 있다.
compile time error
console.log(name}
// 문법에 맞지 않는 구문 사용
런타임 오류는 프로그램이 실행되는 도중에 발생하는 오류다. 런타임 오류는 컴파일 단계에서는 감지되지 않고 프로그램이 실행되는 동안 발생한다. 예를 들어, 예외 처리되지 않은 상황이나 메모리 오버플로우 등 다양한 상황에서 발생할 수 있다.
run time error
const a = 0;
const b = 1;
console.log(b / a); // 문법적으로 문제는 없으나 0으로 나눔
정리
- 컴파일 타임 에러 : 코드 컴파일 중 발생. 문법적 에러
- 런 타임 에러 : 프로그램 실행 중 발생. 실행 흐름이나 잘못된 동작 발생한 경우
pre-rendering은 페이지를 미리 생성하여 정적인 HTML 파일로 변환하는 과정을 의미한다. pre-rendering은 서버 측에서 미리 페이지를 렌더링하여 정적 파일로 생성하거나, 빌드 시점에서 사전에 페이지를 렌더링하는 방식으로 이루어진다.
pre-rendering은 next.js와 같은 프레임워크에서 SSG(Static Site Generation) 또는 SSR(Server-side Rendering)과 함께 사용되는 기술이다. SSG에서는 빌드 시점에서 사전에 페이지를 렌더링하여 정적 HTML 파일을 생성하고, SSR에서는 요청 시점에 서버에서 페이지를 렌더링하여 동적으로 HTML을 생성한다.
SSG는 사전에, build time에 정적인 HTML 파일을 생성하는 방식이다. SSG를 사용하면data를 사용하는 페이지 혹은 데이터를 사용하지 않은 페이지를 정적으로 생성할 수 있다. next.js에서는 데이터를 가져오기 위해 getStaticProps
또는 getStaticPaths
라는 메서드를 사용하여 빌드 시점에서 데이터를 가져오는 로직을 정의할 수 있다.
SSG는 사전에 페이지를 렌더링하여 정적인 HTML 파일로 생성한다. 이렇게 생성된 정적 파일은 서버에 배포되어 CDN(Content Delivery Network)을 통해 최종 사용자에게 제공된다. 정적 파일은 CDN의 캐싱 기능을 활용하여 성능을 향상시킬 수 있다.
페이지를 servier-side rendering 한다면, 페이지의 HTML은 매 요청마다 생성된다. 페이지를 server-side rendering 하기 위해서는 getServerSideProps
라는 async 함수를 export 해야 한다. 매 요청마다 서버에서 이 함수를 호출할 것이다. 앞서 말한 getStaticProps
와 얼핏 비슷한 것 같지만 getServerSideProps
는 빌드 타임 대신에 매 요청시 호출된다는 것이 다르다.
//...
export async function renderToHTML(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
query: NextParsedUrlQuery,
renderOpts: RenderOpts,
): Promise<RenderResult | null> {
// ...
const renderDocument = aysnc () => {
// ...
async function loadDocumentInitialProps(
renderShell?: (_App: AppType, _Component: NextComponentType) => Promise<ReactReadableStream>
) {
// ...
const html = ReactDOMServer.renderToString(/*⭐️*/
<Body>
<AppContainerWithIsomorphincFiberStructure>
{renderPageTree(EnhancedApp, EnhancedComponent, {
...props,
router,
})}
</AppContainerWithIsomorphincFiberStructure>
</Body>
);
return { html, head };
}
};
}
우선 서버에서 어떻게 HTML을 렌더링하고 있는지 살펴 보았다. /server/render.tsx
에서 ReactDOMServer 메서드를 사용하여 렌더링하고 있었다. ReactDOMServer는 React component를 문자열로 변환하는 메서드를 제공한다. renderToString
은 주어진 React 엘리먼트를 문자열로 변환해 완전히 렌더링된 HTML을 반환한다.
이어서 client쪽에선 어떻게 동작하는지 알아봤다.
initialize({})
.then(() => hydrate())
.catch(console.error);
initialize()
가 실행되고 hydrate()
가 실행된다.
export async function initialize(opts: { webpackHMR?: any } = {}): Promise<{
assetPrefix: string;
}> {
initialData = JSON.parse(document.getElementById('__NEXT_DATA__')!.textContent!);
window.__NEXT_DATA__ = initialData;
const prefix: string = initialData.assetPrefix || '';
appElement = document.getElementById('__next__');
return { assetPredix: prefix };
}
initialize()
는 서버에서 렌더링한 HTML에서 __NEXT_DATA_
를 id로 갖는 엘리먼트를 window 객체에 저장한다.
이 부분은 vanilla ssr에서 다음 부분과 일치한다.
/src/ssr.js
import { App } from "./components.js";
export const generateHTML = ({ todoItems }) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Todo List</title>
</head>
<body>
<div id="app">
${App(todoItems)}
</div>
<script>window.__INITIAL_MODEL__ = ${JSON.stringify({ todoItems })}</script>
<script src="./src/main.js" type="module"></script>
</body>
</html>
`;
<script>window.__INITIAL_MODEL__ = ${JSON.stringify({ todoItems })}</script>
vanilla ssr에서 한 방법과 동일하게 Next.js에서도 서버에서 렌더링한 HTML을 window 객체에 담아 client에서도 사용할 수 있게 한 것을 알 수 있었다.
export const hydrate(opts?: { beforeRender?: () => Promise<void> }) {
// ...
const renderCtx: RenderRouteInfo = {
App; CachedApp,
initial: true,
Component: CachedComponent,
props: initialData.props,
error: initialErr,
};
render(renderCtx):
}
hydrate()
는 실행하려는 페이지의 에러를 validate하고 렌더링할 때 필요한 context를 render()
의 인자로 전달한다.
let shouldHydrate: boolean = true;
function renderReactElement(domEl: HTMLElement, fn: (cb: () => void) => JSX.Element): void {
// ...
const reactEl = fn(shouldHydrate ? markHydrateComplete : markRenderComplete);
// ...
if (shouldHydrate) {
ReactDOM.hydrate(reactEl, domEl);
shouldHydrate = false;
} else {
ReactDOM.render(reactEl, domEl);
}
}
ReactDOM.hydrate(element, container[, callback]);
그리고 ReactDOMServer로 렌더링된 HTML에 이벤트 리스터를 연결해준다. ReactDOM.hydrate()
는 일반적으로 서버 사이드 렌더링(SSR)된 React 애플리케이션을 클라이언트에서 재사용할 때 사용된다. 서버에서 렌더링된 HTML과 클라이언트에서 생성된 React 컴포넌트를 일치시켜주는 역할을 한다. 즉, 서버에서 전달받은 HTML에 이미 렌더링된 상태가 포함되어 있으며, 클라이언트에서는 해당 HTML을 기반으로 컴포넌트를 초기화하여 이전에 렌더링된 상태를 유지한다.
이렇게 Next.js 내부에서 어떻게 hydrate 하는지 알아보았다. 블로그에서 vanilla js로 hydrate를 구현한 것과 크게 다르지 않게 구현되었다는 것을 알게 되었다.