next.js pre-rendering 방식의 종류와 hydration개념 및 관련 오류에 대해 정리해보자.
유저가 웹사이트 방문 → 브라우저에서 서버로 소스(컨텐츠) 요청 → 서버에서 브라우저로 빈 html로 연결된 js 링크를 보내줌 → 브라우저에서 js 파일 다운로드 후 동적 dom 생성
유저가 웹사이트 방문 → 브라우저에서 서버로 소스(컨텐츠) 요청 → 서버에서 브라우저로 렌더링 준비를 마친 html과 js code를 보내줌 → 브라우저는 전달받은 html 렌더하고, js code를 다운로드 후, html에 js로직을 연결
: 첫페이지는 서버에서 받아 렌더링하고, 뒤에 발생하는 라우팅은 next/link, next/router 를 활용해서 내부적으로 CSR 방식을 사용함(url창에 직접 주소를 치고 들어갔을 시에는 SSR or SSG인데 link, router를 이용하면 CSR)
: Next.js는 클라이언트에게 웹페이지를 보내기 전에 server side 단에서 Pre-Rendering 된 HTML 파일을 클라이언트에게 전송한다. 이후에 리액트가 번들링된 자바스크립트 코드들을 클라이언트에게 전송한다. 자바스크립트 코드 이전에 보내진 HTML DOM 요소 위에서 한번 더 렌더링을 하면서 각자 요소들을 찾아가며 매칭힌다.이 과정을 통해 웹 페이지가 정상적으로 동작을 하게 되고, 이 과정을 바로 ‘Hydration’ 이라고 한다.
→ 맨 처음 응답받는 요소 document Type의 HTML 파일-> css 파일 → react 코드들이 렌더링된 js파일
서버 단에서 한 번 클라이언트 단에서 한 번 더 렌더링하면 비효율적이라고 생각할 수 있지만, pre-rendering한 document는 모든 js 요소들이 빠진 굉장히 가벼운 상태이므로 클라이언트에게 빠른 로딩이 가능하다.
*각 DOM 요소에 자바스크립트 속성을 매칭 시키기 위한 목적이므로 실제 웹 페이지를 다시 그리는 paint 함수는 호출하지 않는다.
// server/render.tsx
const renderDocument = async () => {
// ...
async function loadDocumentInitialProps(
renderShell?: (_App: AppType, _Component: NextComponentType) => Promise<ReactReadableStream>
) {
// ...
const renderPage: RenderPage = (
options: ComponentsEnhancer = {}
): RenderPageResult | Promise<RenderPageResult> => {
// ...
const html = ReactDOMServer.renderToString(
<Body>
<AppContainerWithIsomorphicFiberStructure>
{renderPageTree(EnhancedApp, EnhancedComponent, {
...props,
router,
})}
</AppContainerWithIsomorphicFiberStructure>
</Body>
);
return { html, head };
};
}
ReactDOMServer.renderToString(element) : reactNode를 HTML 문자열로 반환
// CLIENT/NEXT.JS
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.render(element, container[, callback]) : ReactElement 를 렌더링
ReactDOM.hydrate(element, container[, callback]) : DOM요소에 이벤트 리스너를 적용하고 렌더링을 진행
-> Next.js는 서버에서 HTML 문서를 문자열로 가져온 후, 클라이언트에서는 ReactDom.render 함수를 이용하여 ReactElement 를 렌더링하고, , ReactDOM.hydrate라는 함수를 이용하여 DOM요소에 이벤트 리스너를 적용하고 렌더링을 진행한다.
: HTML을 빌드 타임에 생성한다. 그 이후에는 CDN으로 캐시되어지고, pre-render된 HTML은 매 요청마다 재사용 된다. 인터넷 연결이 느리거나 불안정한 경우에도 사용자에게 신속하게 제공될 수 있다.
ex)블로그 게시물, 제품 목록: 이커머스 사이트에서 상품 세부 정보, 포트폴리오
SSG 방식으로 데이터를 가져올 때 getStaticProps, getStaticPaths 사용한다.(*next.js 12 버전 기준)
function Blog({ posts }) {
return (
<ul>
{posts.map((post) => (
<li>{post.title}</li>
))}
</ul>
)
}
export async function getStaticProps() {
// 빌드 시, api 호출해서 html을 만들어준다.
const res = await fetch('https://.../posts');
const posts = await res.json();
return {
props: {
posts,
},
}
}
export default Blog
: 매 요청이 일어날 때 마다 HTML을 생성하는 방식이다. 데이터가 자주 업데이트되는 페이지에 사용된다. CDN에 캐쉬를 저장하지 않아 SSG와 비교해서 속도는 느리지만 항상 최신상태를 유지할 수 있다.
function DynamicPage({ data }) {
...jsx
}
export async function getServerSideProps() {
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
export default DynamicPage
: SSG의 장점을 살리면서 비교적 최신 데이터를 반영할 수 있다. SSG와 동일하게 빌드 시점에 페이지를 생성하지만, 일정 시간이 지난 후 페이지를 새로 생성한다. 빌드를 배포한 이후에도 설정한 주기마다 데이터의 갱신여부를 검사하고 업데이트된 데이터로 페이지를 다시 정적으로 생성한다. 빌드된 후 이 페이지에 재방문이 없으면, revalidate 시간이 경과했더라도 백그라운드에서 재 빌드되지 않는다.
export async function getStaticProps() {
const res = await fetch('https://.../posts');
const posts = await res.json();
return {
props: {
posts,
},
revalidate: 15,
}
}
서버에서 받아온 UI(pre-render)와 브라우저에서 자체적으로 렌더링한 UI tree 간의 차이때문에 hydration 오류가 발생한다. 구체적인 원인은 밑에 서술
node 서버에서 실행 시에는 최상위 객체가 window가 없고, 브라우저에서 실행 시에는 최상위 객체 window가 존재하기 때문에 서로 환경이 다르다.
function MyComponent() {
// This condition depends on `window`. During the first render of the browser the `color` variable will be different
const color = typeof window !== 'undefined' ? 'red' : 'blue'
// As color is passed as a prop there is a mismatch between what was rendered server-side vs what was rendered in the first render
return <h1 className={`title ${color}`}>Hello World!</h1>
}
<p>
<div>This is test</div>
</p>
실제 개발환경에서는 반응형을 위해 사용했던 ‘useMediaQuery’ hook 때문에 hydration 문제가 발생했다.useMediaQuery는 window.innerWidth 등과 같은 window 객체를 참조하는 코드를 가지고 있는데 서버에서 렌더링한 html에는 window 객체가 초기화되지 않았기에 오류가 났었다.
→ 컴포넌트가 마운트되고, 그 이후에 useEffect hook 내부에서 isMobile 변수를 업데이트시켜 컴포넌트를 재랜더링 시키는 방법으로 해결했다.
function App() {
const [isMobile, setIsMobile] = useState(false);
const mobile = useMediaQuery({ query: "(max-width: 600px)"});
useEffect(() => {
if(mobile) setIsMobile(mobile);
}, [mobile])
return <div style={ {
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
} } />
}