FE/Next.js

[Next.js] App Router에서의 CSS-in-JS 사용과 한계

unhandled 2025. 3. 11. 02:25

 

 

이 글은 제가 궁금했던 것에 대해서 테스트를 수행해 보고 그 과정에서 당면하게 된 현상 또는 새로 알게 된 것들을 기록해 나가기 위한 목적으로 작성되었습니다. 그러므로 당장의 기능 구현을 위해 또는 깔끔하고 간략한 정보만을 얻기 위해 이 페이지에 들어오신 분들에게는 적합하지 않을 수 있음을 미리 말씀드립니다. 

 

 

이 글을 작성하게 된 이유

지난 포스팅에서 Next.js에서의 CSS-in-JS 적용 관련 내용을 정리하겠다고 언급한 적이 있습니다. 

 

[Next.js] App Router에서는 어떤 CSS 방식을 사용해야 합니까?

 

[Next.js] App Router에서는 어떤 CSS 방식을 사용해야합니까?

이 글을 쓴 이유저는 Next.js 프로젝트를 여러 번 진행했었는데 그때마다 CSS 스타일링 방식 선정에 있어서 고민이 많았습니다. 그 당시 다른 프론트엔드 팀원들은 이전 리액트 프로젝트에서 CSS-in-

unhandled.tistory.com

 

이 글을 쓰는 이유는 전에 어떤 CSS를 채택할까 고민하던 과거의 제가 떠올랐기 때문입니다.

그 당시에 이러한 차이점을 시각적으로 비교한 게시글을 누군가 써줬으면 좋겠다는 생각이 들었거든요. 

 

 

CSS-in-JS란?

자바스크립트를 사용하여 구성요소의 스타일링을 지정하는 방법

 

 

다른 CSS방식(CSS Modules, Global CSS, Tailwind CSS)과 비교

 

공식 홈페이지에서 제시하는 여러 방법들(CSS Modules, Global CSS, Tailwind CSS)과

CSS in JS 방식에서 가장 대중적인 라이브러리들(@emotion, styled-components)을 예시로 들어 비교해 보겠습니다.

 

각 방식별로 스타일링은 글자크기는 32px로 고정하고 글자색만 다르게 했습니다.

 

 

page.tsx (클라이언트 컴포넌트)

"use client";

import { css as emotionCss } from "@emotion/css";
import styled from "styled-components";

import styles from "./page.module.css";

const emotionCssStyle = emotionCss`
  font-size: 32px;
  color: #CC67BC;
`;

const StyledComponentStyle = styled.div`
  font-size: 32px;
  color: #e4976f;
`;

export default function Page() {

  return (
    <div className={styles.container}>
      <div className={styles.board}>클라이언트 컴포넌트</div>

      {/* CSS Modules */}
      <div className={styles["css-modules-style"]}>CSS Modules</div>

      {/* Global CSS */}
      <div className="global-css-style">Global CSS</div>

      {/* Tailwind CSS */}
      <div className="text-[32px] text-[#00BCFF]">Tailwind CSS</div>

      {/* @emotion/css */}
      <div className={emotionCssStyle}>@emotion/css</div>

      {/* styled-components */}
      <StyledComponentStyle>styled-components</StyledComponentStyle>
    </div>
  );
}

 

 

 

클라이언트 컴포넌트에서의 결과

 

 

자 클라이언트 컴포넌트에서는 CSS-in-JS 형식의 CSS가 적용된 부분에서만 깜빡이는 현상이 발생합니다.

이를 FOUC (Flash Of Unstyled Content) 현상이라고 합니다.

하이드레이션 되기 전(자바스크립트가 활성화되기 전)에 CSS가 적용되지 않은 컨텐츠가 보이는 것입니다. 

 

그리고 너무 순식간이라 구별이 되실지 모르겠지만 @emotion/css 부분이 styled-components부분보다 조금 더 빠르게 스타일링이 적용됩니다. 

 

공식문서에서도 사용을 권했던 CSS Modules, Global CSS, Tailwind CSS는 문제없이 적용되었군요.

 

 

서버 컴포넌트에서의 결과

 

 

어.. 그런데 왜 서버 컴포넌트에서 styled-components는 없나요?

그것은 아예 해당 css가 적용된 텍스트가 표시되지 않는 게 아니라..

styled-components를 사용하려고 스타일 속성을 지정하는 순간 에러가 발생하기 때문에 테스트할 수 없었습니다. 

 

 

creatContext는 클라이언트 컴포넌트에서만 동작한다는 이야기군요. 

 

반면 @emotion/css 이 부분은 CSS가 잘 적용돼서 나오는 것처럼 보입니다.

그러면 서버 컴포넌트에서 @emotion을 사용할 수 있는 것일까요?

 

그런데 여기서 새로고침을 해보겠습니다. 

 

 

 

새로고침을 하자마자 @emotion으로 스타일이 적용되었던 CSS 속성들이 해제됩니다.

왜 그런 걸까요?

 

이는 Next.js의 캐시 정책 때문입니다.

 

  1. 방금 방문한 클라이언트 컴포넌트에서 생성된 동일한 클래스명과 스타일 내용을 가진 CSS속성이 브라우저 캐시에 남아있습니다.
  2. Next.js의 캐시 정책으로 인해 이 이전 스타일이 일시적으로 적용되어 마치 서버 컴포넌트에서도 @emotion/css가 정상적으로 적용된 것처럼 보입니다.
  3. 하지만 새로고침을 하면 이 임시 캐시가 초기화되고 서버컴포넌트에서 생성된 실제 스타일이 없기 때문에 모든 스타일이 사라집니다.

언뜻 보기에 처음에는 CSS가 잘 적용된 것처럼 보였지만 실제로는 정상적으로 적용되지 못한 것입니다.

(이 현상은 이전 CSS 속성과 현재 CSS 속성의 className과 CSS 적용 내용이 모두 동일할 때 발생합니다. CSS 내용 중 하나의 요소라도 다르면 잔상이 남지 않습니다.)

 

위의 예시는 클라이언트 컴포넌트에서 서버 컴포넌트 페이지로 이동할 때 <Link>를 이용한 것입니다. <Link>, router.push, router.replace 등의 클라이언트 사이드 네비게이션으로 이동하면 페이지 전환을 최적화하기 위해 이전 페이지의 상태와 스타일을 일시적으로 유지합니다. 그래서 위와 동일한 결과가 나오나 a태그를 이용해서 이동하면 서버에서 새로운 HTML을 가져와서 전체를 새로 로드하기 때문에 이러한 CSS 잔상이 남지 않습니다. 

 

 

클라이언트 컴포넌트에서 FOUC가 발생하는 이유

기존 리액트에서는 자바스크립트가 활성화되기 전까지는 컨텐츠가 아예 렌더링 되지 않았습니다. 반면에 Next.js의 클라이언트 컴포넌트에서는 자바스크립트가 활성화되기 전에 서버에서 생성해서 보낸 HTML 요소가 화면에 먼저 표시됩니다. CSS-in-JS는 자바스크립트 런타임에 스타일을 계산하고 주입하기에 자바스크립트가 활성화되기 전까지는 CSS가 적용되지 않은 상태의 HTML 요소들이 보이게 되는 것입니다.

 

 

FOUC를 해결하려면?

 

다행히도 둘 다 해결하는 방법을 각각 찾았습니다.

 

1. emotion/css emotion/react

저는 처음에 emotion/css 라이브러리를 설치했습니다. 그런데 공식문서에서 이는 프레임워크에 구애받지 않는 라이브러리이고 리액트를 사용하는 경우는 emotion/react의 사용을 권장하더군요. (공식문서에는 emotion/react를 사용하면 configuration이 필요 없는 Serverside rendering의 이점을 누릴 수 있다고 쓰여있는데.. 왜 안 되는 건지 이유를 모르겠습니다.)

 

둘 다 css라는 동일한 이름의 요소를 import 해서 사용하기 때문에 별칭을 지정해서 스타일링 속성을 정의해 봤습니다.  

 

 

page.tsx (클라이언트 컴포넌트)

 

(아래 코드에서 `이 부분 추가` 부분은 구문 오류가 안 나도록 주석처리가 필요한 부분에서는 주석처리를 했습니다. @emotion/react 부분은 따로 주석 처리를 안 해도 에러 없이 잘 동작하더군요. 그래도 만약 syntax error가 발생하면 저 부분을 삭제하세요.)

/** @jsxImportSource @emotion/react */     <---- ⯎ 이 부분 추가 ⯎
"use client";

import { css as emotionReact } from "@emotion/react";  //   <---- ☀︎ 이 부분 추가 ☀︎
import { css as emotionCss } from "@emotion/css";
import styled from "styled-components";
import styles from "./page.module.css";

const emotionReactStyle = emotionReact`     <---- ☀︎ 이 부분 추가 ☀︎
  font-size: 32px;
  color: #CC67BC;
`;

const emotionCssStyle = emotionCss`
  font-size: 32px;
  color: #CC67BC;
`;

const StyledComponentStyle = styled.div`
  font-size: 32px;
  color: #e4976f;
`;

export default function Page() {

  return (
    <div className={styles.container}>
      <div className={styles.board}>클라이언트 컴포넌트</div>

      {/* CSS Modules */}
      <div className={styles["css-modules-style"]}>CSS Modules</div>

      {/* Global CSS */}
      <div className="global-css-style">Global CSS</div>

      {/* Tailwind CSS */}
      <div className="text-[32px] text-[#00BCFF]">Tailwind CSS</div>

      {/* @emotion/css */}
      <div className={emotionCssStyle}>@emotion/css</div>

      {/* @emotion/react */}  
      <div css={emotionReactStyle}>@emotion/react</div>     {/* <---- ☀︎ 이 부분 추가 ☀︎ */}

      {/* styled-components */}
      <StyledComponentStyle>styled-components</StyledComponentStyle>
    </div>
  );
}

 

 

제일 상단의 이 부분을 추가해줘야 합니다. 

/** @jsxImportSource @emotion/react */     <---- ⯎ 이 부분 추가 ⯎

 

 

이 요소가 없으면 

 

지정한 CSS 속성도 적용이 되지 않을뿐더러 동시에 이렇게 빨간 밑줄이 죽 그어지는 모습을 볼 수가 있습니다. 

 

그래서 저 부분을 추가해 주면

 

 

@emotion/react 부분은 FOUC 현상 없이 CSS가 정상적으로 적용되는 것을 확인하실 수 있습니다.

 

정확한 이유는 모르겠으나 @emotion/css도 덩달아서 FOUC 현상이 사라졌습니다. 

 

 

인 줄 알았는데 @emotion/css 부분은 FOUC 현상이 다시 나타났습니다! 💫🤯💫 

제가 잠깐 CSS 캐시 관련 테스트를 위해 클라이언트 컴포넌트 내에 Link, router와 같은 기능과 함수들을 선언하고 코드를 추가했었는데 이러한 변화가 있었습니다. 문제는 다시 코드를 원래대로 기능하게끔 다 삭제했는데도 돌아오지 않았다는 것입니다. 

 

@emotion/react와 그를 제대로 동작시키기 위한 코드를 추가하면 @emotion/css의 FOUC 현상이 갑자기 사라질 수도 있고 아닐 수도(???) 있습니다.🤣

 

다른 결과가 나오는 이유를 찾기 위해 기능적으로 코드를 원복 시켜도 동일한 현상이 반복되는 것이 혼란스러웠습니다. 같은 코드임에도 이 일관되지 않은 결과가 납득가지 않는군요. 이는 예측 불가능한 자바스크립트의 특성 때문인 걸까요? 아니면 Next.js이기 때문인 것일까요? 혹은 그 둘 다? 테스트 중간에 일종의 하이젠 버그를 마주하게 된 것입니다. 

 

 

하이젠 버그(Heisen bug)

  • 하이젠 버그는 테스트를 수행할 때 디버깅을 하려고 하면 버그가 사라지거나 달라지는 현상을 의미합니다. 이는 개발환경의 복잡한 상호작용으로 인한 것이므로 정확한 원인을 특정하기 어렵습니다. 
  • 제 경우에서는 @emotion/css부분이 @emotion/react의 적용 과정에서 무언가의 영향을 받는다는 것은 분명한데 그 결과가 일관적이지 않았습니다. 제가 테스트 환경에서 한 번에 여러 CSS 적용방식을 혼용한 것이 예상치 못한 변화를 일으킨 것 같습니다. 

 

2. styled-components ➯ registry 컴포넌트로 최상위 chidren 요소 감싸주기

 

App Router에서의 CSS 사용 (공식문서)

[Next.js] How to use CSS in your application

 

위 링크의 styled-components 항목을 참고했습니다.

 

먼저 app 폴더 하위에 lib 폴더를 생성합니다.

lib 폴더 내부에 registry.tsx 파일을 생성하여 다음과 같은 코드를 작성합니다. 

 

app/lib/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 하여 {children}을 다음과 같이 감싸줍니다. 

 

 

app/layout.tsx (최상위 레이아웃 파일)

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

import StyledComponentsRegistry from "./lib/registry";     // <----- ☀︎ 이 부분 추가 ☀︎
import Navbar from ".././components/navbar/Navbar";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable}`}>
        <Navbar />
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>    {/* <----- ☀︎ 이 부분 추가 ☀︎ /}
      </body>
    </html>
  );
}

 

 

그리고 다시 클라이언트 컴포넌트를 새로고침 해볼까요?

 

 

이제 styled-components 또한 Next.js App Router의 클라이언트 컴포넌트에서 FOUC 현상이 사라졌군요!

 

 

그런데 공식문서를 보시면 아시겠지만.. 제가 지금 한 가지 과정을 위에서 적지 않았습니다.

 

공식문서에서는 처음부터 이러한 설정을 config에 추가해야 한다고 합니다. 

 

사실 저도 처음에 이 부분을 추가했었는데요.

 

 

@emotion/css는 다시 FOUC가 발생했고

@emotion/react는 적용될 CSS 속성이 2가지(글자 크기, 글자 색)인데 그중 하나(글자 색)만 적용 돼버렸네요. 

 

크롬 개발자 도구로 직접 style을 확인해 보면 이렇습니다. 

 

 

원래 

const emotionReactStyle = emotionReact`
  font-size: 32px;
  color: #CC67BC;
`;

 

이렇게 지정했는데 말입니다. 첫 번째 속성이 이상해졌군요.

 

const emotionReactStyle = emotionReact`
  font-size: 32px;
  color: #CC67BC;
  font-weight: bold;
`;

 

CSS 속성(font-weight: bold;)을 하나 더 추가해서 확인해 보겠습니다.

 

 

 

첫 번째 지정한 CSS 요소만 적용이 안 되는군요.

원래 font-size: 32px; 이어야 될 부분이 styles: font-size: 32px; 이렇게 돼있는 것을 보니 속성을 문자열로 이리저리 파싱하는 과정에서 뭔가 내부적인 문제가 생긴 것 같습니다.

 

@emotion 설정과 관련하여 바뀐 부분은 없었습니다.

 

 

이는 아마도 제가 테스트 환경에서 여러 CSS-in-JS 방식을 혼용했기 때문에 발생한 것일 겁니다. 제가 next.config.ts 파일에서 compiler :  { styled-component: true, } 속성을 추가한 순간 CSS 처리 관련 로직의 어떠한 우선순위가 styled-components이 적용되는 부분이 높아졌을 것이고 그래서 @emotion 관련 기능이 제대로 동작하지 않게 된 것 같습니다. 

 

이러한 테스트 환경이 아닌 일반적인 개발환경에서는 여러 CSS-in-JS를 혼용할 일은 거의 없을 겁니다.

그때는 공식문서에 나와있는 대로 config에 저 속성을 추가해 주시는 게 맞습니다.

저는 테스트 중이므로 일단 이 옵션을 삭제하겠습니다. 

 

 

 

 

FOUC를 해결한 @emotion/react를 서버 컴포넌트에 적용한다면?

 

안타깝지만 바로 에러가 떴습니다. 

 

 

 

아까 styled-components를 서버 컴포넌트에서 사용하려고 했을 때 뜬 에러와 문장은 좀 다르지만 결국 createContext를 서버 컴포넌트에서 하려는 시도가 문제였다!라는 것은 동일한 것 같습니다.

 

 

테스트 결과 정리

 

Next.js에서 공식적으로 권장하는 다른 방법들(CSS Modules, Global CSS, Tailwind CSS)은 클라이언트 컴포넌트에서도 서버 컴포넌트에서도 문제없이 사용할 수 있습니다. 

 

가장 대중적인 CSS-in-JS 라이브러리인 @emotionstyled-components는 

클라이언트 컴포넌트에서

  • 사용이 가능하나 FOUC 현상을 없애려면 추가적인 설정이 필요합니다.

서버 컴포넌트에서 

  • 곧바로 에러가 발생하므로 사용이 불가능합니다.

 

전에 @emotionstyled-components보다 Next.js와 같이 사용하기에 더 적합하다는 글을 봤었는데 이 결과 또한 그를 말해주는 것 같습니다. (그 글은 Pages Router에 관한 내용이었던 걸로 기억하지만요.)

클라이언트 컴포넌트에서 비록 FOUC가 발생하는 상황임에도 상대적으로 빠르게 CSS속성이 적용되는 것, styled-components는 서버 컴포넌트에서 에러가 발생하지만 @emotion은 그렇지는 않았던 것이 저 문장을 대변해 주는 것 같습니다. (결국 사용할 수 없다는 부분은 둘 다 동일하지만 말입니다.)

 

 

개인적인 궁금증

구글 확장 프로그램 중에 Wappalyzer라는 것이 있습니다. 이는 현재 접속해 있는 웹사이트가 어떤 기술 스택으로 만들어졌는지 알려줍니다.(100% 정확하지는 않습니다.) 그런데 관심 있는 기업의 웹사이트가 Next.js 기반으로 구축되었는데 emotion이나 styled-components를 같이 사용하는 경우를 꽤나 여러 번 봤습니다. 이때 첫 번째로 의문이 들었고

(근데.. 다시 들어가 보니 Next.js에서 Remix로 바뀐 곳도 있군요 하하)

 

나중에 여러 채용공고를 봤을 때에도 Next.js를 기술 스택으로 채택하면서도 emotion이나 styled-components 같은 CSS-in-JS를 메인 스타일링 방식으로 사용한다고 적어놓은 곳들이 꽤 있었습니다. 그래서 궁금증은 더더욱 증폭됐습니다.

 

만약 그들이

 

App Router 방식을 채택한 것이라면

  • CSS-in-JS은 클라이언트 컴포넌트에서만 사용하고 서버 컴포넌트에서는 다른 CSS 방식을 사용하는 등 CSS 적용방식을 혼용하고 있는 것
  • 서버 컴포넌트를 활용하지 않고 Next.js의 다른 이점만을 위해 사용한 것(그런데 이것 때문일 가능성은 낮습니다.)

 

Pages Router 방식을 채택한 것이라면

Pages Router에서 ServerSideProps 등으로 SSR를 구현할 때 이러한 CSS-in-JS를 같이 사용하는 게 문제가 되지 않았던 것이라는 가정을 해본다면 (제가 Pages Router에서의 CSS 스타일링에 관해서는 잘 알지 못하기에 가정을 해본 것입니다.)

  1. 그 당시 좀 더 안정적인 Page Router 방식을 채택한 것
  2. 기존의 서비스가 Pages Router로 구축되어 있어서 App Router로의 마이그레이션을 거치지 않은 것

 

이 정도로 추측이 됩니다. 정확한 답은 아직도 모르지만요..

항상 궁금하기도 했었고 이 궁금증 또한 이 글을 쓰게 된 이유 중 하나이기도 합니다.