Remix와 Qwik 아키텍처: 웹 표준과 Resumability를 통한 SSR 혁신
Progressive Enhancement와 Zero Hydration 전략으로 Next.js의 복잡성과 성능 문제 극복하기
React에서 스타일을 관리하는 방법 중 하나인 CSS-in-JS는 JavaScript로 CSS를 작성하여 컴포넌트와 스타일을 함께 관리합니다. styled-components, Emotion 같은 라이브러리가 이 패턴을 구현합니다. 하지만 이들은 클라이언트 사이드 렌더링(CSR)에 최적화되어 있어, 서버 사이드 렌더링(SSR) 환경에서는 추가 설정이 필수입니다.
import styled from 'styled-components';
const Button = styled.button`
background: blue;
color: white;
padding: 10px 20px;
`;
function App() {
return <Button>클릭</Button>;
}클라이언트 렌더링 시:
sc-bdvvtL)<style> 태그를 <head>에 주입문제: SSR에서 발생하는 이슈
<!-- 서버에서 생성된 HTML (스타일 없음) -->
<html>
<head>
<!-- styled-components 스타일이 없음 -->
</head>
<body>
<button class="sc-bdvvtL">클릭</button>
</body>
</html>결과:
styled-components는 ServerStyleSheet를 사용하여 서버에서 생성된 스타일을 수집합니다.
1. 서버에서 React 컴포넌트 렌더링 시작
2. styled-components가 스타일 생성
3. ServerStyleSheet가 스타일을 메모리에 수집
4. 수집된 스타일을 <style> 태그로 변환
5. HTML에 스타일 태그 삽입
6. 클라이언트로 HTML 전송
7. 클라이언트에서 하이드레이션 (기존 스타일 재사용)
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 전용 훅typeof window !== 'undefined' 체크)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"
// 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;// .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 (서버/클라이언트 동일)일관성 보장 조건:
초기 렌더링에 필요한 스타일만 인라인으로 삽입:
// 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('');
}동일한 스타일을 재사용하여 중복 계산 방지:
import { cache } from 'react';
const getStyleSheet = cache(() => new ServerStyleSheet());
export function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
const styledComponentsStyleSheet = getStyleSheet();
// ...
}비중요 스타일은 비동기 로딩:
// 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 파일만) |
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는 빌드 타임에 CSS 파일로 추출하여 런타임 오버헤드가 없습니다.
import { styled } from '@linaria/react';
const Button = styled.button`
background: blue;
color: white;
`;
// → 빌드 시 styles.css 생성, 런타임 JS 0KB장점:
단점:
// 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>;장점:
단점:
.css.ts)| 라이브러리 | 런타임 | SSR 난이도 | 번들 크기 | 동적 스타일 | 타입 안전성 |
|---|---|---|---|---|---|
| styled-components | O | 중간 | 15KB | 우수 | 양호 |
| Emotion | O | 중간 | 11KB | 우수 | 우수 |
| Linaria | X | 낮음 | 0KB | 제한적 | 양호 |
| Vanilla Extract | X | 낮음 | 0KB | 보통 | 우수 |
| CSS Modules | X | 낮음 | 0KB | X | 제한적 |
Warning: Prop `className` did not match.
Server: "sc-bdvvtL kfAZGn"
Client: "sc-cKdPaL hxJQRz"
원인: 서버와 클라이언트의 스타일 해시가 다름
해결:
next.config.js에 styledComponents.ssr = true 설정증상: 페이지 로드 시 스타일 없는 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>
);
}원인: 클라이언트에서 스타일 시트에 접근 실패
해결:
// ServerStyleSheet를 클라이언트에서 생성하지 않도록 체크
if (typeof window !== 'undefined') return <>{children}</>;// 개발 환경에서 스타일 디버깅
if (process.env.NODE_ENV === 'development') {
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
console.log('서버에서 생성된 스타일:', styles);
return <>{styles}</>;
});
}// [주의] 나쁜 예: 매 페이지마다 GlobalStyle
export default function Page() {
return (
<>
<GlobalStyle />
<div>페이지 내용</div>
</>
);
}
// [권장] 좋은 예: layout에 한 번만
export default function RootLayout({ children }) {
return (
<html>
<body>
<StyledComponentsRegistry>
<GlobalStyle />
{children}
</StyledComponentsRegistry>
</body>
</html>
);
}// [주의] 나쁜 예: 매번 다른 스타일 생성
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>// [주의] 나쁜 예: 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에 전달되지 않음// [주의] 나쁜 예: 중복 스타일
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;
`;에러:
ReferenceError: document is not defined
원인: 서버 환경에서 document 접근
해결:
// [주의] 잘못된 예
const GlobalStyle = createGlobalStyle`
body {
margin: 0;
}
`;
// [권장] 올바른 예: 클라이언트 전용 체크
'use client';
const GlobalStyle = createGlobalStyle`
body {
margin: 0;
}
`;원인: Minification으로 클래스명 변경
해결:
// next.config.js
module.exports = {
compiler: {
styledComponents: {
minify: process.env.NODE_ENV === 'production',
// 프로덕션에서도 displayName 유지 (디버깅용)
displayName: true,
},
},
};증상: <style> 태그가 여러 개 생성됨
원인: Registry가 여러 번 렌더링됨
해결:
// [주의] 잘못된 예: 중첩된 Registry
<StyledComponentsRegistry>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</StyledComponentsRegistry>
// [권장] 올바른 예: Registry는 최상위에 한 번만
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>styled-components를 Next.js SSR 환경에서 사용하기 위한 핵심 사항:
useServerInsertedHTML 또는 _document.tsx 활용next.config.js에서 styledComponents.ssr = true 설정권장 사항:
Next.js 13+에서는 styled-components보다 CSS Modules 또는 Tailwind CSS를 공식 권장하지만, 기존 프로젝트나 팀 선호도에 따라 styled-components도 여전히 훌륭한 선택입니다.
Progressive Enhancement와 Zero Hydration 전략으로 Next.js의 복잡성과 성능 문제 극복하기
Rust의 타입 시스템과 소유권을 활용한 안전하고 효율적인 파일 I/O 처리, 동기/비동기 비교 및 성능 최적화