잡쇼퍼 프로젝트를 진행하면서 Next.js와 styled components를 함께 사용하면서 발생했던 렌더링 문제에 관해서 이야기해보려 한다.

Next.js와 styled components

기존의 EJS로 구성되어 있던 잡쇼퍼 서비스를 리액트 기반으로 리뉴얼하면서, EJS의 Server Side Rendering의 장점을 살려 구현 하기 위해 Next.js를 사용하였다.

Next.js는 리액트 기반이기 때문에 리액트를 사용했을 때 스타일링 하기 위한 여러 가지 방법중 하나로 styled components를 선택하였다.
styled components는 CSS 모델을 문서 레벨이 아닌 컴포넌트 레벨로 관리할 수 있고, JS와 CSS 간에 상수 및 기능을 쉽게 공유할 수 있는 등 여러 가지 장점이 있다.

Next.js에서 styled components 사용 시 문제점

SSR과 CSS-in-JS의 장점들을 취하여 개발을 하다 보니 하나의 문제점이 발생했다. 보이는 이미지와 같이, 바로 스타일이 적용되지 않은 채 HTML이 렌더링되는 현상을 볼 수 있었다.

ezgif.com-crop (5).gif

SSR은 서버에서 페이지마다 HTML을 미리 만들어놓고 자바스크립트 로딩이 끝나기 전에 바로 사용자들에게 화면을 보내줘서 사이트의 속도를 빠르게 한다.
그런데 CSS-in-JS를 사용한 모든 스타일은 자바스크립트에 들어가 있기 때문에 CSS가 static하게 추가되는 것이 아니라 자바스크립트에 의해 dynamic하게 추가되므로 자바스크립트가 로딩을 끝내기 전에 스타일이 적용되지 않은 HTML이 렌더링 되는 것이 문제였다.

해결방법

Next.js는 렌더링이 될 때 pages 디렉토리 안에 기본적으로 같이 렌더링 되는 컴포넌트가 있다. 이 컴포넌트를 커스터마이징하여 위에서 언급한 문제를 해결할 수 있었다.

Next.js에서 styled-components 같은 CSS-in-JS 라이브러리에 대한 SSR을 지원하기 위해서는 <html>, <body> 태그를 보강할 수 있는 커스텀 <Document>를 사용하여 HTML 구조를 확장해야 한다.
커스텀 <Document>는 Next.js의 /pages에 _document.js 파일을 만들어 정의할 수 있는데, _document.js는 HTML 문서로 커스텀 Document를 만들 때만 작성이 필요하며 생략된 경우 Next.js가 기본값을 사용한다.

1.커스터마이징을 위해 pages 디렉토리에 _document.js파일을 생성한다.

import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    //...
  }

   render() {
    return (
      <Html>
        <head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

왜 HTML 구조를 확장해야 할까?

React는 컴포넌트가 마운트 되는 최초 요소로써 <div id="root" />처럼 기본 HTML 구조를 가지며 style, script 등의 링크가 포함된 index.html 파일이 필요하다.
하지만 Next·js에서는 이러한 역할이 <Main>과 <NextScript>와 같이 클래스로 내장되어 있기 때문에 index.html 파일이 없다.

Next.js에 styled components를 적용하기 위해서는 HTML 구조에서 <body>에 <Main>과 <NextScript>를 넣고 <head>의 <style>에 styled components의 SSR을 도와주는 객체로 래핑된 로직을 삽입하여 styled components가 적용될 수 있게 해주어야 한다. 따라서 Next.js에서 index.html 역할을 하는 _document.js를 통해 <html>, <body>태그를 보강해주는 것이다.

<Main />은 각 라우트에 해당하는 페이지가 렌더링되는 부분이며,
<NextScript />는 Next.js 관련한 자바스크립트 파일이다.

_document.js는

  • Next.js는 언더바로 시작하는 파일들을 생략할 수 있다.
  • 이 파일을 수정하면 전체적인 페이지에 해당 수정을 적용할 수 있다.
  • index.html의 해당하는 정보를 내려주는 컴포넌트로 React.Component의 상속이 아닌 next/document 패키지의 Document 컴포넌트를 상속해서 만든다.
  • pages 디렉토리 내부에 존재하는 모든 페이지에 Global한 설정값을 줄 수 있는 파일이다.
  • React와 같은 동작을 수행할 수 있을 것처럼 보이지만 React Lifecycle과 Data Fetching이 불가능하다.
  • 서버 측에서만 렌더링 되며 클라이언트 측에서는 렌더링 되지 않는다.
    onClick과 같은 이벤트 핸들러는 이 파일에 추가할 수 없다.

Next.js에서 Global한 설정 값을 줄 경우

import { ServerStyleSheet, injectGlobal } from "styled-components";

injectGlobal`
  html {
    font-size: 10px;
  }
  body {
    font-family: "Merriweather", serif;
    font-size: 1.6em;
    line-height: 1.6;
  }
`;

class MyCustomDocument extends Document {
//....

전역 스타일을 적용해야 하는 경우 _document.js에 injectGlobal을 가져와서 커스텀 Document 컴포넌트를 정의하기 전에 스타일을 제공할 수 있다.

2. getInitialProps에 아래의 코드와 같이 정의한다.

import Document, { Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'

class MyDocument extends Document {
    static async getInitialProps(ctx: NextDocumentContext) {
      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>
                    <style />
                  </head>
                <body>
                    <Main />
                    <NextScript />
                </body>
            </Html>
        );
    }
}

ServerStyleSheet를 사용해 정의된 모든 스타일을 수집하여 페이지가 렌더링되기 전에 props로 채워지도록 반환한다. 그 다음 렌더함수 내부에서 {sheet.getStyleElement()}가 <Head> 엘리먼트의 <style>에 삽입되어 렌더링 될 때 styled-components가 적용되어 렌더링 될 수 있게 한다.

ServerStyleSheet는 HTML이 렌더됨과 동시에 스타일도 정적으로 렌더할 수있게 해주는 객체로 context API를 통해 받은 style을 React tree에 추가하는 방식으로 스타일 적용을 가능하게 해준다.
renderPage는 실제 React 렌더링 로직을 동기적으로 실행하는 콜백함수이다. 서버 렌더링 래퍼를 지원하기 위해 이 함수를 사용하는 것이 유용하다.

3.yarn add -D babel-plugin-styled-components하여 .babelrc를 설정한다.

  {
  "presets": [
    "next/babel",
    "@zeit/next-typescript/babel"
  ],
  "plugins": [["styled-components", {"ssr":true}]]
}

References

https://nextjs.org/docs#custom-document
https://www.merixstudio.com/blog/nextjs-features-overview/