뒤로가기

Styled-components SSR: CSS-in-JS와 서버 사이드 렌더링의 통합 전략

nextjs

CSS-in-JS와 SSR의 문제: 스타일 손실과 깜빡임

React에서 스타일을 관리하는 방법 중 하나인 CSS-in-JS는 JavaScript로 CSS를 작성하여 컴포넌트와 스타일을 함께 관리합니다. styled-components, Emotion 같은 라이브러리가 이 패턴을 구현합니다. 하지만 이들은 클라이언트 사이드 렌더링(CSR)에 최적화되어 있어, 서버 사이드 렌더링(SSR) 환경에서는 추가 설정이 필수입니다.

CSS-in-JS의 기본 동작 (CSR)

import styled from 'styled-components';
 
const Button = styled.button`
  background: blue;
  color: white;
  padding: 10px 20px;
`;
 
function App() {
  return <Button>클릭</Button>;
}

클라이언트 렌더링 시:

  1. JavaScript 번들이 브라우저에 로드됨
  2. styled-components가 스타일 객체를 생성
  3. 고유한 클래스명 생성 (예: sc-bdvvtL)
  4. <style> 태그를 <head>에 주입
  5. 컴포넌트가 클래스명으로 렌더링

문제: SSR에서 발생하는 이슈

<!-- 서버에서 생성된 HTML (스타일 없음) -->
<html>
  <head>
    <!-- styled-components 스타일이 없음 -->
  </head>
  <body>
    <button class="sc-bdvvtL">클릭</button>
  </body>
</html>

결과:

  1. 초기 HTML에 스타일이 없어 FOUC(Flash of Unstyled Content) 발생
  2. JS 로드 후 스타일이 주입되며 화면 깜빡임
  3. 클래스명 불일치 시 하이드레이션 에러

styled-components SSR의 동작 원리

ServerStyleSheet: 스타일 수집 메커니즘

styled-components는 ServerStyleSheet를 사용하여 서버에서 생성된 스타일을 수집합니다.

내부 동작 과정

1. 서버에서 React 컴포넌트 렌더링 시작
2. styled-components가 스타일 생성
3. ServerStyleSheet가 스타일을 메모리에 수집
4. 수집된 스타일을 <style> 태그로 변환
5. HTML에 스타일 태그 삽입
6. 클라이언트로 HTML 전송
7. 클라이언트에서 하이드레이션 (기존 스타일 재사용)

Next.js App Router 구현

Next.js 13+ (App Router)에서는 useServerInsertedHTML 훅을 사용합니다.

// app/registry.tsx
'use client';
 
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
 
export function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  // 스타일 시트를 컴포넌트 수명 동안 유지
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
 
  useServerInsertedHTML(() => {
    // 서버에서 생성된 스타일 추출
    const styles = styledComponentsStyleSheet.getStyleElement();
 
    // 다음 렌더링을 위해 스타일 시트 초기화
    styledComponentsStyleSheet.instance.clearTag();
 
    return <>{styles}</>;
  });
 
  // 클라이언트에서는 그대로 렌더링
  if (typeof window !== 'undefined') return <>{children}</>;
 
  // 서버에서는 StyleSheetManager로 감싸서 스타일 수집
  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}
 
// app/layout.tsx
import { StyledComponentsRegistry } from './registry';
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        <StyledComponentsRegistry>
          {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

핵심 메커니즘:

  • useServerInsertedHTML: Next.js가 제공하는 SSR 전용 훅
  • 서버 렌더링 중 HTML에 스타일 태그 삽입
  • 클라이언트에서는 스킵 (typeof window !== 'undefined' 체크)

Next.js Pages Router 구현

Next.js 12 이하 또는 Pages Router 사용 시:

// pages/_document.tsx
import Document, { DocumentContext, DocumentInitialProps } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
 
export default class MyDocument extends Document {
  static async getInitialProps(
    ctx: DocumentContext
  ): Promise<DocumentInitialProps> {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;
 
    try {
      // renderPage를 오버라이드하여 스타일 수집
      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(); // 메모리 누수 방지
    }
  }
}

Pages Router vs App Router 비교:

측면 Pages Router App Router
설정 위치 _document.tsx registry.tsx + layout.tsx
API collectStyles() useServerInsertedHTML()
스트리밍 SSR X O (React 18+)
복잡도 중간 낮음

클래스 이름 일관성: 하이드레이션 문제 해결

CSS-in-JS는 스타일마다 고유한 해시 클래스명을 생성합니다. 서버와 클라이언트에서 동일한 클래스명을 생성해야 하이드레이션이 성공합니다.

문제: 클래스명 불일치

// 서버 렌더링
<button class="sc-bdvvtL kfAZGn">버튼</button>
 
// 클라이언트 하이드레이션
<button class="sc-cKdPaL hxJQRz">버튼</button>
// [주의] 클래스명 불일치 → React 경고 발생

React 경고:

Warning: Prop `className` did not match. Server: "sc-bdvvtL" Client: "sc-cKdPaL"

해결: Babel 플러그인 또는 SWC 컴파일러

방법 1: Next.js 내장 컴파일러 (권장)

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  compiler: {
    styledComponents: {
      // 개발 환경에서 디버깅용 클래스명
      displayName: process.env.NODE_ENV === 'development',
      // SSR 지원 활성화
      ssr: true,
      // 프로덕션에서 번들 크기 최소화
      minify: true,
      // 파일명을 해시에 포함 (고유성 보장)
      fileName: true,
      // 네임스페이스 (여러 앱 통합 시)
      namespace: '',
      // 불필요한 주석 제거
      meaninglessFileNames: ['index', 'styles'],
      // CSS 속성 순서 일관성 (cssnano)
      cssProp: true,
    },
  },
};
 
module.exports = nextConfig;

방법 2: Babel 플러그인 (레거시)

// .babelrc
{
  "presets": ["next/babel"],
  "plugins": [
    [
      "babel-plugin-styled-components",
      {
        "ssr": true,
        "displayName": true,
        "fileName": true
      }
    ]
  ]
}

클래스명 생성 알고리즘

// styled-components 내부 (단순화)
function generateClassName(componentId, templateStrings) {
  const hash = hashFunction(
    componentId +              // 컴포넌트 ID (파일명 기반)
    templateStrings.join('') + // 스타일 문자열
    namespace                  // 네임스페이스 (옵션)
  );
 
  return `sc-${componentId}-${hash}`;
}
 
// 예: Button 컴포넌트의 스타일
const Button = styled.button`
  color: blue;
`;
// → 생성: sc-Button-kfAZGn (서버/클라이언트 동일)

일관성 보장 조건:

  1. 동일한 컴파일러 설정 (ssr: true)
  2. 동일한 파일명 (fileName: true)
  3. 동일한 템플릿 문자열 (코드 일치)
  4. 동일한 React 버전 (해시 알고리즘)

성능 최적화 전략

1. Critical CSS 추출

초기 렌더링에 필요한 스타일만 인라인으로 삽입:

// app/registry.tsx (최적화 버전)
export function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
 
  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
 
    // Critical CSS만 추출 (옵션)
    if (process.env.NODE_ENV === 'production') {
      return (
        <>
          <style
            dangerouslySetInnerHTML={{
              __html: extractCriticalCSS(styles),
            }}
          />
        </>
      );
    }
 
    return <>{styles}</>;
  });
 
  // ...
}
 
function extractCriticalCSS(styles: React.ReactElement[]) {
  // Above-the-fold 컴포넌트의 스타일만 추출
  // 실제 구현은 라이브러리 사용 권장 (critical, critters 등)
  return styles.map(s => s.props.dangerouslySetInnerHTML.__html).join('');
}

2. 스타일 캐싱

동일한 스타일을 재사용하여 중복 계산 방지:

import { cache } from 'react';
 
const getStyleSheet = cache(() => new ServerStyleSheet());
 
export function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const styledComponentsStyleSheet = getStyleSheet();
  // ...
}

3. Async 스타일 로딩

비중요 스타일은 비동기 로딩:

// Non-critical 스타일은 Link preload로 지연 로드
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/styles/non-critical.css"
          as="style"
          onLoad="this.onload=null;this.rel='stylesheet'"
        />
      </head>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  );
}

성능 벤치마크

방법 FCP (First Contentful Paint) LCP (Largest Contentful Paint) 번들 크기
SSR 없이 (CSR) 2.8s 3.5s 45KB
SSR + 기본 설정 1.2s (57% 개선) 1.8s (49% 개선) 48KB
SSR + Critical CSS 0.9s (68% 개선) 1.4s (60% 개선) 35KB (critical) + 13KB (lazy)
Zero-runtime CSS 0.7s (75% 개선) 1.1s (69% 개선) 0KB (CSS 파일만)

다른 CSS-in-JS 솔루션 비교

Emotion

Emotion도 styled-components와 유사한 SSR 설정이 필요합니다.

// app/emotion-registry.tsx
'use client';
 
import { CacheProvider } from '@emotion/react';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';
import createCache from '@emotion/cache';
 
export function EmotionRegistry({ children }: { children: React.ReactNode }) {
  const [cache] = useState(() => {
    const cache = createCache({ key: 'css' });
    cache.compat = true;
    return cache;
  });
 
  useServerInsertedHTML(() => {
    return (
      <style
        data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
        dangerouslySetInnerHTML={{
          __html: Object.values(cache.inserted).join(' '),
        }}
      />
    );
  });
 
  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

styled-components vs Emotion:

특징 styled-components Emotion
API Tagged templates Tagged templates + Object styles
번들 크기 15KB 11KB (코어만)
SSR 설정 복잡도 중간 중간
TypeScript 지원 우수 우수
성능 빠름 약간 더 빠름
인기도 높음 높음

Linaria (Zero-runtime)

Linaria는 빌드 타임에 CSS 파일로 추출하여 런타임 오버헤드가 없습니다.

import { styled } from '@linaria/react';
 
const Button = styled.button`
  background: blue;
  color: white;
`;
// → 빌드 시 styles.css 생성, 런타임 JS 0KB

장점:

  • 런타임 성능 우수 (JS 없음)
  • SSR 설정 불필요 (일반 CSS 파일)
  • 번들 크기 0KB

단점:

  • 동적 스타일 제한적
  • 빌드 시간 증가
  • 테마 전환 복잡

Vanilla Extract (타입 안전)

// styles.css.ts
import { style } from '@vanilla-extract/css';
 
export const button = style({
  background: 'blue',
  color: 'white',
  ':hover': {
    background: 'darkblue',
  },
});
 
// Component.tsx
import * as styles from './styles.css';
 
export const Button = () => <button className={styles.button}>클릭</button>;

장점:

  • 완전한 타입 안전성
  • Zero-runtime (빌드 타임 CSS 생성)
  • SSR 추가 설정 불필요
  • Tree-shaking 가능

단점:

  • 별도 파일 필요 (.css.ts)
  • 학습 곡선
  • 에코시스템 작음

종합 비교

라이브러리 런타임 SSR 난이도 번들 크기 동적 스타일 타입 안전성
styled-components O 중간 15KB 우수 양호
Emotion O 중간 11KB 우수 우수
Linaria X 낮음 0KB 제한적 양호
Vanilla Extract X 낮음 0KB 보통 우수
CSS Modules X 낮음 0KB X 제한적

하이드레이션 오류 디버깅

일반적인 하이드레이션 에러

에러 1: 클래스명 불일치

Warning: Prop `className` did not match.
Server: "sc-bdvvtL kfAZGn"
Client: "sc-cKdPaL hxJQRz"

원인: 서버와 클라이언트의 스타일 해시가 다름

해결:

  1. next.config.jsstyledComponents.ssr = true 설정
  2. 서버/클라이언트 코드 일치 확인
  3. 환경 변수 일관성 체크

에러 2: 스타일 깜빡임 (FOUC)

증상: 페이지 로드 시 스타일 없는 HTML → JS 로드 후 스타일 적용

원인: 서버에서 스타일이 주입되지 않음

해결:

// [주의] 잘못된 예: Registry가 없음
export default function RootLayout({ children }) {
  return <html><body>{children}</body></html>;
}
 
// [권장] 올바른 예: Registry로 감싸기
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

에러 3: "Cannot read property 'getPropertyValue' of null"

원인: 클라이언트에서 스타일 시트에 접근 실패

해결:

// ServerStyleSheet를 클라이언트에서 생성하지 않도록 체크
if (typeof window !== 'undefined') return <>{children}</>;

디버깅 도구

// 개발 환경에서 스타일 디버깅
if (process.env.NODE_ENV === 'development') {
  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    console.log('서버에서 생성된 스타일:', styles);
    return <>{styles}</>;
  });
}

베스트 프랙티스

1. GlobalStyle은 layout에 한 번만

// [주의] 나쁜 예: 매 페이지마다 GlobalStyle
export default function Page() {
  return (
    <>
      <GlobalStyle />
      <div>페이지 내용</div>
    </>
  );
}
 
// [권장] 좋은 예: layout에 한 번만
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          <GlobalStyle />
          {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

2. 동적 스타일 최소화

// [주의] 나쁜 예: 매번 다른 스타일 생성
const Button = styled.button<{ color: string }>`
  background: ${props => props.color}; // 매번 새 클래스 생성
`;
 
// [권장] 좋은 예: CSS 변수 사용
const Button = styled.button`
  background: var(--button-color);
`;
 
<Button style={{ '--button-color': color } as React.CSSProperties}>
  클릭
</Button>

3. Transient Props 사용

// [주의] 나쁜 예: DOM에 전달되는 props
const Button = styled.button<{ $primary: boolean }>`
  background: ${props => props.$primary ? 'blue' : 'gray'};
`;
// Warning: React does not recognize the `$primary` prop on a DOM element
 
// [권장] 좋은 예: $ 접두사로 transient props
const Button = styled.button<{ $primary: boolean }>`
  background: ${props => props.$primary ? 'blue' : 'gray'};
`;
// $ 접두사는 DOM에 전달되지 않음

4. 스타일 재사용

// [주의] 나쁜 예: 중복 스타일
const PrimaryButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  background: blue;
`;
 
const SecondaryButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
  background: gray;
`;
 
// [권장] 좋은 예: 베이스 스타일 공유
const BaseButton = styled.button`
  padding: 10px 20px;
  border-radius: 4px;
`;
 
const PrimaryButton = styled(BaseButton)`
  background: blue;
`;
 
const SecondaryButton = styled(BaseButton)`
  background: gray;
`;

트러블슈팅 가이드

문제 1: "document is not defined"

에러:

ReferenceError: document is not defined

원인: 서버 환경에서 document 접근

해결:

// [주의] 잘못된 예
const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
  }
`;
 
// [권장] 올바른 예: 클라이언트 전용 체크
'use client';
 
const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
  }
`;

문제 2: 스타일이 프로덕션에서만 안 나옴

원인: Minification으로 클래스명 변경

해결:

// next.config.js
module.exports = {
  compiler: {
    styledComponents: {
      minify: process.env.NODE_ENV === 'production',
      // 프로덕션에서도 displayName 유지 (디버깅용)
      displayName: true,
    },
  },
};

문제 3: CSS가 중복 로드됨

증상: <style> 태그가 여러 개 생성됨

원인: Registry가 여러 번 렌더링됨

해결:

// [주의] 잘못된 예: 중첩된 Registry
<StyledComponentsRegistry>
  <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</StyledComponentsRegistry>
 
// [권장] 올바른 예: Registry는 최상위에 한 번만
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>

정리

styled-components를 Next.js SSR 환경에서 사용하기 위한 핵심 사항:

  1. ServerStyleSheet 설정: useServerInsertedHTML 또는 _document.tsx 활용
  2. 클래스명 일관성: next.config.js에서 styledComponents.ssr = true 설정
  3. 성능 최적화: Critical CSS 추출, 스타일 캐싱, 동적 스타일 최소화
  4. 대안 고려: Linaria, Vanilla Extract 같은 zero-runtime 솔루션 검토

권장 사항:

  • 새 프로젝트: Vanilla Extract 또는 Tailwind CSS 고려 (zero-runtime)
  • 기존 프로젝트: styled-components v6+ 사용 (React 18 지원)
  • 마이그레이션 시: Emotion으로 점진적 전환 (API 유사)

Next.js 13+에서는 styled-components보다 CSS Modules 또는 Tailwind CSS를 공식 권장하지만, 기존 프로젝트나 팀 선호도에 따라 styled-components도 여전히 훌륭한 선택입니다.

관련 아티클