9 minute read

1. 서론

CSS 스타일을 컴포넌트처럼 적용하는 라이브러리에는 대표적으로 styled-components가 있어요.

  • 이 라이브러리는 CSS-in-JS라는 방법으로 CSS 속성을 Javascript 코드로 편하게 작성할 수 있다는 장점이 있어요.
  • 또한, 적용하려는 HTML 요소에 이름을 붙일 수 있어서 의미론적으로 직관적이기도 해요.

이와 비슷하게 @emotion/styled라는 라이브러리도 존재해요. 패키지 이름만 바꿔도 될 정도로 API와 사용법이 styled-components와 상당히 비슷한 특징이 있어요.

기본적으로 두 라이브러리는 클라이언트 사이드 렌더링(CSR)에서 사용되지만, 서버 사이드 렌더링(SSR)에서도 CSS 스타일을 생성하는 방법을 지원해요.

평소에 두 라이브러리의 차이점에 대해 궁금했었는데, 이번에 kanji-yomi 프로젝트를 Next.js로 진행하면서 발견한 차이점에 대해 알아보려고 해요.

두 라이브러리가 서버 사이드 렌더링을 어떻게 다루는지를 중점으로 설명하겠습니다.

2. styled-components 라이브러리

먼저, styled-components 라이브러리에서 서버 사이드 렌더링을 어떻게 처리하는지 알아볼게요.

styled-components는 컴포넌트가 렌더링될 때마다 해당 컴포넌트에 필요한 스타일을 동적으로 생성하여 클라이언트에서 <style> 태그로 삽입하는 CSR 중심의 라이브러리에요.

Next.js처럼 SSR 환경에서는 클라이언트가 서버에서 렌더링된 HTML 파일을 전달받아 React에서 사용할 수 있도록 Hydration이라는 과정을 수행해야 해요.

이러한 과정에서 CSS 스타일과 관련된 다음과 같은 문제들이 발생할 수 있어요.

  1. className 불일치
  2. Flickering

className 불일치 문제는 Next.js 서버에서 렌더링을 할 때 선언한 className 체크섬과 클라이언트에서 동적으로 생성한 CSS 스타일의 className 체크섬이 일치하지 않는 문제에요. 기준이 되는 서버에서 생성된 체크섬에 클라이언트에서 생성된 체크섬이 매핑되지 않아서 스타일을 적용할 수 없는 문제에요.

className 체크섬이 매핑이 되었다고 하더라도, CSS 스타일을 클라이언트에서 생성했다면 Flickering(깜빡임) 문제가 발생할 여지가 있어요. 서버에서 CSS 스타일을 생성하지 않기 때문에, 초기 HTML에는 CSS 스타일이 적용되지 않아요. 이후 클라이언트에서 동적으로 CSS 스타일을 생성하고 화면에 적용이 될 때까지 시간이 걸리기 때문에, 화면이 변경되면서 깜빡임이 발생하는 것처럼 느껴져요. 이를 Flickering이라고 하고 아래에서 다시 설명을 할 예정이에요.

2-1. Next.js에서 기존과 같은 방법으로 사용해보기

특별한 설정을 따로 적용하지 않고, Next.js에서 styled-components를 사용하면 아래와 같이 스타일이 전혀 적용되지 않는 것을 확인할 수 있어요.

styled

스타일이 적용되지 않는 이유는 서버에서 생성된 jvrypf className이 <style> 태그 내에 존재하는 어느 className과도 매핑되지 않기 때문이에요. 클라이언트에서 CSS 스타일이 동적으로 생성되었지만, 제대로 연결되지 않아서 발생한 이슈에요.

classname

2-2. className 불일치 문제

먼저, 위에서 발생한 className 불일치 문제는 Next.js의 next.config.mjs 파일에서 해결할 수 있어요.

📂 next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  compiler: {
    styledComponents: true,
  },
};

export default nextConfig;

설정을 적용하면 자연스럽지는 않지만, CSS 스타일이 적용되는 것을 확인할 수 있어요. 요소들에 CSS 스타일이 적용되면서 자신의 자리를 찾아가는 현상이 발생하는데, 이를 Flickering(깜빡임)이라고 해요. 클라이언트에서 CSS 스타일을 동적으로 생성하고 적용하는 과정에서 리페인팅이 일어나면서 깜빡이는 현상이 발생하고 있어요.

styled2

아래 사진을 통해서도, 서버와 클라이언트 CSS 스타일의 className이 서로 잘 매핑된 것을 볼 수 있어요. 다시 말해서, className 불일치 문제는 해결되었어요.

classname2

서버에서 클라이언트로 전달된 HTML 파일(Page Source)를 확인해보면, hbpnQW className은 찾을 수 있지만 CSS 스타일의 실체를 확인할 수는 없어요. 즉, 서버에서 CSS 스타일을 생성해서 클라이언트로 전달하는 것은 아니라는 뜻이에요.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="/_next/static/css/app/layout.css?v=1732771235506"
      data-precedence="next_static/css/app/layout.css"
    />
    <!-- ... -->
  </head>
  <body class="__variable_f92059 __variable_013f1d">
    <!-- ... -->
    <div class="Test__Test1-sc-8ae5b2a9-0 hbpnQW">
      <div class="content-box">
        <main class="PageWithBottomNav__MainContainer-sc-8893f0af-0 jKFWJB">
          <!-- ... -->
        </main>
      </div>
    </div>
    <!-- ... -->
  </body>
</html>

Flickering은 클라이언트에서 동적으로 CSS 스타일을 생성할 때 나타날 수 밖에 없는 필연적인 현상이에요. 이를 해결하기 위해서는 서버에서 CSS 스타일을 생성하고 클라이언트로 전달하는 과정이 필요해요.

2-3. Flickering 문제

Next.js 공식 문서에도 설명되어 있듯이, ServerStyleSheet를 통해 Flickering 문제를 해결할 수 있어요.

ServerStyleSheet를 추가하는 과정은 다음과 같아요.

📂 Registry.tsx
"use client";

import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== "undefined") return <>{children}</>;

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}
📂 layout.tsx
import StyledComponentsRegistry from "./registry";

// ...

return (
  <html lang="en">
    <body className={`${openSans.variable} ${openSansBold.variable}`}>
      <StyledComponentsRegistry>
        <div className="content-box">{children}</div>
      </StyledComponentsRegistry>
    </body>
  </html>
);
  1. style registry to collect all CSS rules in a render.

    • 렌더링 중에 모든 CSS 스타일을 수집하는 registry 컴포넌트를 생성해요.
  2. The new useServerInsertedHTML hook to inject rules before any content that might use them.

    • ServerStyleSheet 객체를 생성하여 서버 렌더링 중 생성할 CSS 스타일을 수집해요.
    • useServerInsertedHTML 훅을 통해 렌더링 되기 전에 CSS 스타일을 서버에서 HTML의 <style> 태그에 삽입해요.
  3. A Client Component that wraps your app with the style registry during initial server-side rendering.

    • 클라이언트는 Hydration 시 서버로부터 전달받은 CSS 스타일을 재사용해요.

위의 설정을 적용한 결과, 새로고침을 눌러서 서버로부터 새로 HTML을 받아와도 Flickering이 발생하지 않는 것을 확인할 수 있어요.

styled3

서버에서 렌더링한 HTML 파일에서도 CSS 스타일이 포함되어 클라이언트로 전달되는 것을 확인할 수 있어요. styled-components<head> 태그 내부에 CSS 스타일을 주입해요.

styled-components: Page Source

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="/_next/static/css/app/layout.css?v=1732771235506"
      data-precedence="next_static/css/app/layout.css"
    />
    <!-- ... -->
    <style data-styled="" data-styled-version="6.1.13">
      .fGOcmy {
        display: flex;
        flex-direction: column;
        gap: 8px;
        padding: 16px;
        border-radius: 16px;
      } /*!sc*/
      .fGOcmy div {
        justify-content: center;
      } /*!sc*/
      .esUmar {
        display: flex;
        flex-direction: column;
        gap: 32px;
        padding: 16px;
        border-radius: 16px;
      } /*!sc*/
      .esUmar div {
        justify-content: center;
      } /*!sc*/
      data-styled.g13[id="sc-dpBQxM"] {
        content: "fGOcmy,esUmar,";
      } /*!sc*/
      /* ... */
    </style>
  </head>
  <body class="__variable_f92059 __variable_013f1d">
    <!-- ... -->
    <div class="sc-fwzISk bHRhac">
      <div class="content-box">
        <main class="sc-geXuza jnLiZN">
          <!-- ... -->
        </main>
      </div>
    </div>
    <!-- ... -->
  </body>
</html>

이후 클라이언트에서 Hydration이 실행되면, <head> 태그 내부에 CSS 스타일이 잘 정리되는 것을 확인할 수 있어요.

styled-components: Elements 탭

<html lang="en">
  <head>
    <!-- ... -->
    <style data-emotion="css g7laag-MuiAppBar-root" data-s="">
      .css-g7laag-MuiAppBar-root {
        display: -webkit-box;
        display: -webkit-flex;
        display: -ms-flexbox;
        display: flex;
        -webkit-flex-direction: column;
        -ms-flex-direction: column;
        flex-direction: column;
        width: 100%;
        box-sizing: border-box;
        -webkit-flex-shrink: 0;
        -ms-flex-negative: 0;
        flex-shrink: 0;
        position: fixed;
        z-index: 1100;
        top: 0;
        left: auto;
        right: 0;
        --AppBar-background: #1976d2;
        --AppBar-color: #fff;
        background-color: var(--AppBar-background);
        color: var(--AppBar-color);
      }
      @media print {
        .css-g7laag-MuiAppBar-root {
          position: absolute;
        }
      }
    </style>
    <style data-emotion="css zanzjh-MuiPaper-root-MuiAppBar-root" data-s="">
      .css-zanzjh-MuiPaper-root-MuiAppBar-root {
        background-color: #fff;
        color: rgba(0, 0, 0, 0.87);
        -webkit-transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
        transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
        box-shadow: var(--Paper-shadow);
        background-image: var(--Paper-overlay);
        display: -webkit-box;
        display: -webkit-flex;
        display: -ms-flexbox;
        display: flex;
        -webkit-flex-direction: column;
        -ms-flex-direction: column;
        flex-direction: column;
        width: 100%;
        box-sizing: border-box;
        -webkit-flex-shrink: 0;
        -ms-flex-negative: 0;
        flex-shrink: 0;
        position: fixed;
        z-index: 1100;
        top: 0;
        left: auto;
        right: 0;
        --AppBar-background: #1976d2;
        --AppBar-color: #fff;
        background-color: var(--AppBar-background);
        color: var(--AppBar-color);
      }
      @media print {
        .css-zanzjh-MuiPaper-root-MuiAppBar-root {
          position: absolute;
        }
      }
    </style>
    <!-- ... -->
  </head>
  <!-- ... -->
</html>

이렇게 서버에서 CSS 스타일을 생성해서 클라이언트는 별도의 CSS 파일이나 동적인 스타일 삽입 없이도 서버에서 제공된 완전한 HTML과 스타일을 한 번에 받아 화면에 표시할 수 있게 하는 방법을 Single Render Pass라고 해요.

다음과 같은 장점이 있어요.

  1. CSS 스타일과 HTML 렌더링을 한 번의 렌더링 과정에서 처리할 수 있기 때문에, HTML이 완전히 화면에 보여지기까지 시간이 단축되고 Flickering을 해결할 수 있어요.

  2. 이러한 특징 때문에, 서버에서 클라이언트로 응답을 분할해서 전달하는 스트리밍 기법이 가능해요. 클라이언트는 HTML을 조금씩 받을 수 있어 초기 렌더링 속도를 빠르게 할 수 있어요.

3. @emotion/styled 라이브러리

  • 다음으로, @emotion/styled 라이브러리에서 서버 사이드 렌더링을 어떻게 처리하는지 알아볼게요.

3-1. Next.js에서 기존과 같은 방법으로 사용해보기

특별한 설정을 따로 적용하지 않아도, CSS 스타일이 서버 사이드에서 잘 렌더링되는 것을 볼 수 있어요. 새로고침을 연속으로 해보아도, Flickering 현상 없이 자연스럽게 렌더링됩니다.

emotion

styled-components 처럼 ServerStyleSheet 파일을 생성하지 않아도 되기 때문에 상대적으로 사용하기에 편한 장점이 있어요.

서버에서 전달해주는 HTML 파일에서도 생성된 CSS 스타일을 확인할 수 있어요. 서버에서 생성된 CSS 스타일이 <body> 태그 내, 사용할 요소 위에 선언되어 있는 것을 확인할 수 있어요.

styled-components가 CSS 스타일을 <head> 태그 내에 삽입했던 것과 차이가 있어요.

@emotion/styled: Page Source

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="/_next/static/css/app/layout.css?v=1732771235506"
      data-precedence="next_static/css/app/layout.css"
    />
    <!-- ... -->
  </head>
  <body class="__variable_f92059 __variable_013f1d">
    <!-- ... -->
    <style data-emotion="css 1qy3yb1">
      .css-1qy3yb1 {
        -webkit-flex: 1;
        -ms-flex: 1;
        flex: 1;
        display: -webkit-box;
        display: -webkit-flex;
        display: -ms-flexbox;
        display: flex;
        -webkit-box-pack: center;
        -ms-flex-pack: center;
        -webkit-justify-content: center;
        justify-content: center;
        -webkit-align-items: center;
        -webkit-box-align: center;
        -ms-flex-align: center;
        align-items: center;
        overflow: auto;
      }
    </style>
    <div class="css-1qy3yb1">
      <div class="content-box">
        <style data-emotion="css 4b375e">
          .css-4b375e {
            height: 100%;
          }
        </style>
        <main class="css-4b375e">
          <!-- ... -->
        </main>
      </div>
    </div>
    <!-- ... -->
  </body>
</html>

이후, styled-components와 마찬가지로 클라이언트에서 Hydration이 실행될 때, <head> 태그 내부에 CSS 스타일이 잘 정리되는 것을 확인할 수 있어요.

@emotion/styled: Elements 탭

<html lang="en">
  <head>
    <!-- ... -->
    <style data-emotion="css 1qy3yb1" data-s="">
      .css-1qy3yb1 {
        -webkit-flex: 1;
        -ms-flex: 1;
        flex: 1;
        display: -webkit-box;
        display: -webkit-flex;
        display: -ms-flexbox;
        display: flex;
        -webkit-box-pack: center;
        -ms-flex-pack: center;
        -webkit-justify-content: center;
        justify-content: center;
        -webkit-align-items: center;
        -webkit-box-align: center;
        -ms-flex-align: center;
        align-items: center;
        overflow: auto;
      }
    </style>
    <style data-emotion="css 4b375e" data-s="">
      .css-4b375e {
        height: 100%;
      }
    </style>
    <!-- ... -->
  </head>
  <!-- ... -->
</html>

4. 공통점

@emotion/styledstyled-components 둘 다 위와 같이 CSS 스타일을 서버 사이드에서 사용하기 위해서는, 파일 최상단에 use client를 사용해 클라이언트 컴포넌트로 만들어야 해요.

언뜻 보면 클라이언트 사이드에서 CSS 스타일을 생성하게 만드는 코드라고 생각할 수도 있지만, 그렇지 않아요. 두 라이브러리 모두 use client가 선언되었더라도, 서버에서 CSS 스타일을 생성하고 초기 렌더링에서 CSS 스타일을 HTML에 삽입한다는 것에 유의하고 사용해야 해요.

📂 Test.tsx
"use client";
import styled from "styled-components";
import { ReactNode } from "react";

const TestComponent = ({ children }: { children: ReactNode }) => {
  return <StyledTest>{children}</StyledTest>;
};

export default TestComponent;

const StyledTest = styled.div`
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: auto;
`;
📂 layout.tsx
import StyledComponentsRegistry from "./registry";
import TestComponent from "./Test";

// ...

return (
  <html lang="en">
    <body>
      <StyledComponentsRegistry>
        <TestComponent>
          <div className="content-box">{children}</div>
        </TestComponent>
      </StyledComponentsRegistry>
    </body>
  </html>
);

use client로 선언한 TestComponent 컴포넌트의 StyledTest의 CSS 스타일이 HTML에 포함된 것을 확인할 수 있어요.

useclient

use client를 선언해도 서버에서 CSS 스타일 생성이 되기 때문에 styled-components@emotion/styled를 사용할 때, 필요하면 use client를 자유롭게 사용해도 돼요.

하지만 use client를 남용하게 되면, 클라이언트 렌더링되는 부분이 증가하기 때문에 SEO에 악영향이 있거나 Javascript 번들 최적화가 어려워지는 단점이 있어요. 따라서 반드시 필요한 부분에만 use client를 사용하는 것이 좋아요.

그리고 서버 컴포넌트에서 두 라이브러리를 사용하기 위해서는, 위의 TestComponent의 경우처럼 파일을 분리해야 할 수도 있어요. 이런 과정이 번거로울 때에는 라이브러리 대신, Next.js의 CSS 모듈이나 Global CSS처럼 다른 방법을 사용하는 방법도 있어요.

5. 차이점

별도의 설정 여부

  • @emotion/styled: 복잡한 ServerStyleSheet 설정이 필요없이, 편하게 사용할 수 있어요.

  • styled-components: ServerStyleSheet과 기타 파일 설정이 필요해요.

동작 방식

  • @emotion/styled: 서버에서 렌더링된 스타일을 <head> 태그 내에 포함시켜 전달해요.

  • styled-components: 서버에서 렌더링된 스타일을 <body> 태그 내에 포함시켜 전달해요.

Material UI와의 호환성

Material UIstyled-components와 호환되지 않는다고 Material UI 공식 문서에 나와 있어요.

Material UI는 서버 사이드 렌더링을 위하여 내부적으로 @emotion을 사용하기 때문에 두 라이브러리 중에서는 @emotion/styled가 더 적합한 선택이에요.

styled-componentsMaterial UI를 함께 사용하면서 아래와 같은 버그를 발견했어요.

mui
import Slider from "@mui/material/Slider";
import styled from "styled-components";

<QuizOptionLayout title="Rounds" spacing="large">
  <SliderWrapper
    aria-label="quiz-round"
    defaultValue={round}
    step={10}
    min={10}
    valueLabelDisplay="on"
    marks={roundMarks}
    onChange={handleRoundChange}
  />
</QuizOptionLayout>;

const SliderWrapper = styled(Slider)`
  width: 80%;
`;

styled(Slider)처럼 두 라이브러리를 함께 사용하게 되면, Flickering 현상이 발생해요. 서버에서 생성된 HTML을 확인하면 분명히 서버에서 CSS 스타일이 생성되고 전달되는데 여전히 Flickering이 나타나요.

.hjUJLN{width:80%;}/*!sc*/
data-styled.g17[id="QuizOptions__SliderWrapper-sc-753c67a3-2"]{content:"hjUJLN,"}/*!sc*/
<span
  class="MuiSlider-root MuiSlider-marked MuiSlider-colorPrimary MuiSlider-sizeMedium sc-cEzcPc hjUJLN css-1e45yg4-MuiSlider-root"
>
  // ...
</span>

아래처럼 구조를 변경했더니 Flickering 문제가 해결되었어요. 두 라이브러리가 서버 사이드 렌더링을 처리하는 방법이 서로 다르기 때문에 오류가 발생했을 수도 있어요. 라이브러리 간의 구분을 명확하게 하거나, 사용하는 라이브러리를 @emotion/styled로 변경하여 해결할 수 있어요.

import Slider from "@mui/material/Slider";
import styled from "styled-components";

<QuizOptionLayout title="Rounds" spacing="large">
  <SliderWrapper>
    <Slider
      aria-label="quiz-round"
      defaultValue={round}
      step={10}
      min={10}
      valueLabelDisplay="on"
      marks={roundMarks}
      onChange={handleRoundChange}
    />
  </SliderWrapper>
</QuizOptionLayout>;

const SliderWrapper = styled.div`
  width: 80%;
`;

6. 프로젝트에서의 선택: @emotion/styled

  1. 진행하는 프로젝트에서 Material UI를 사용하기 때문에, 내부적으로 사용하는 라이브러리인 @emotion와 호환이 되는 라이브러리로 선택하였어요.

  2. styled-components처럼 서버 사이드 렌더링을 적용하기 위해 작성해야 하는 파일이 없다는 점이 사용하기에 더 편하기 때문에 선택하였어요.

7. 결론

styled-components

  • 서버 사이드 렌더링을 사용하기 위해서, next.config.mjsServerStyleSheet 설정이 필요해요.
    • next.config.mjs: 클래스네임 불일치 문제를 해결해요.
    • ServerStyleSheet: Flickering 문제를 해결해요.

@emotion/styled

  • 별도의 설정 없이도 서버 사이드 렌더링을 지원해요.

공통점

  • styled 등 API를 사용하기 위해서는 use client 선언이 필요해요.
  • 클라이언트 컴포넌트로 만들어도 서버에서 CSS 스타일이 생성돼요.

차이점

  • 서버 사이드 렌더링을 하기 위해 별도의 설정 여부가 달라요.
  • 내부적인 동작 방식이 달라요.
  • Material UI와의 호환성에 차이가 있어요.

Reference

Leave a comment