CSS-in-JS 솔루션
Styled Components와 Emotion의 동적 스타일 방식을 비교하고 App Router의 서버 렌더링 경계에 맞춰 통합합니다.
이전 글들에서 CSS 모듈과 Sass로 Next.js 프로젝트의 스타일을 효율적으로 관리하는 방법을 배웠습니다. 이 방법들은 전통적인 CSS 작성 방식의 단점을 보완하고 컴포넌트 기반 개발에 알맞습니다. 하지만 React 생태계에는 CSS-in-JS라는 또 다른 스타일링 방식이 있습니다. CSS-in-JS는 말 그대로 CSS 코드를 JavaScript 파일 안에 작성하는 방식으로, 컴포넌트와 스타일을 한 JavaScript 파일에서 관리해 개발 경험을 더 통합적이고 동적으로 만듭니다. 이 글에서는 CSS-in-JS의 개념, 주요 라이브러리, 장단점, 그리고 Next.js App Router에서 CSS-in-JS를 통합하는 방법을 정리합니다.
CSS-in-JS란 무엇인가요?
CSS-in-JS는 JavaScript로 컴포넌트의 스타일을 정의하고 관리하는 기술입니다. CSS 코드를 .css나 .scss 같은 별도 파일로 나누는 대신, React 컴포넌트 파일인 .tsx(또는 .jsx) 안에 JavaScript 객체나 템플릿 리터럴 형태로 작성합니다. 이 글의 Styled Components와 Emotion은 런타임에 규칙을 만드는 방식입니다. CSS-in-JS 전체로 보면 빌드할 때 CSS를 추출하는 방식도 있으므로, 라이브러리마다 처리 시점을 구분해야 합니다.
CSS-in-JS에서는 스타일이 특정 컴포넌트와 밀접하게 묶여 있어, 그 컴포넌트의 로직과 스타일을 한곳에서 관리할 수 있습니다. JavaScript의 기능을 활용해 조건부 스타일, props 기반 스타일, 테마 변경처럼 동적인 스타일링을 할 수 있습니다. 대부분의 CSS-in-JS 라이브러리는 스타일 충돌을 막으려고 고유한 클래스 이름을 자동으로 만들거나 인라인 스타일을 적용하며, 작성한 JavaScript 스타일 정의를 바탕으로 실제 CSS가 런타임이나 빌드 시점에 만들어집니다.
주요 CSS-in-JS 라이브러리
React 생태계에는 여러 CSS-in-JS 라이브러리가 있으며, 각각 고유한 특징과 사용법을 가집니다. 아래에서는 널리 쓰이는 Styled Components와 Emotion을 비교합니다. 둘 다 JavaScript 파일 안에 CSS를 적고 props로 스타일을 바꿀 수 있다는 점은 같지만, App Router에서 공식 통합 방법이 안내되었는지는 다릅니다. 그래서 먼저 두 라이브러리의 문법을 보고, 이어지는 절에서는 통합 방법이 안내된 Styled Components로 실습합니다.
Styled Components
Styled Components는 가장 널리 쓰이는 CSS-in-JS 라이브러리 가운데 하나로, 태그된 템플릿 리터럴(tagged template literals)로 CSS를 작성합니다. 아래 코드에서 styled.button 바로 뒤에 붙은 백틱 문자열이 태그된 템플릿이며, 그 안의 CSS가 적용된 <button> 컴포넌트가 만들어집니다. ${props => ...} 자리에는 함수를 넣어 props에 따라 다른 값을 고릅니다. prop 이름 앞의 $는 이 값을 실제 DOM 속성으로 넘기지 않게 하는 표시(transient prop)라서, 브라우저의 <button>에는 $primary 속성이 붙지 않습니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import styled from 'styled-components';
// styled.button을 사용하여 <button> 요소를 기반으로 하는 스타일링된 컴포넌트 생성
const StyledButton = styled.button`
background-color: ${props => (props.$primary ? '#007bff' : '#f0f0f0')};
color: ${props => (props.$primary ? 'white' : '#333')};
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s ease;
&:hover {
background-color: ${props => (props.$primary ? '#0056b3' : '#e0e0e0')};
}
`;
export default function MyStyledButton({ label, primary, onClick }) {
return (
<StyledButton $primary={primary} onClick={onClick}>
{label}
</StyledButton>
);
}Styled Components는 스타일이 적용된 HTML 요소를 나타내는 React 컴포넌트를 만들며, 컴포넌트의 props에 따라 스타일을 동적으로 바꾸기 쉽습니다. styled-components v6에서는 벤더 프리픽스(vendor prefix) 자동 추가가 기본적으로 꺼져 있으며, 대상 브라우저에 필요하다면 StyleSheetManager의 enableVendorPrefixes로 켤 수 있습니다. Next.js 같은 SSR 환경에서 초기 로딩 때 스타일이 올바르게 적용되도록 서버 사이드 렌더링도 지원합니다.
Emotion
Emotion은 styled와 css prop 같은 API로 스타일을 작성하는 CSS-in-JS 라이브러리입니다. 다음은 API 문법을 비교하려는 예제이며, css prop에는 Emotion용 JSX 변환 설정이 필요합니다. 이 파일만 추가하면 App Router 통합 실습이 된다고 생각하지 않습니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import { css } from '@emotion/react'; // css 헬퍼 함수
import styled from '@emotion/styled'; // styled 헬퍼 함수
// styled 함수 사용 (styled components와 유사)
const StyledEmotionButton = styled.button`
background-color: ${props => (props.$primary ? '#28a745' : '#f0f0f0')};
color: ${props => (props.$primary ? 'white' : '#333')};
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s ease;
&:hover {
background-color: ${props => (props.$primary ? '#218838' : '#e0e0e0')};
}
`;
// css 헬퍼 함수 사용 (클래스 기반으로 스타일 적용)
const dangerButtonStyle = css`
background-color: #dc3545;
color: white;
&:hover {
background-color: #c82333;
}
`;
export default function MyEmotionButton({ label, primary, danger, onClick }) {
if (danger) {
return (
<button css={dangerButtonStyle} onClick={onClick}>
{label}
</button>
);
}
return (
<StyledEmotionButton $primary={primary} onClick={onClick}>
{label}
</StyledEmotionButton>
);
}Emotion은 styled API뿐 아니라, css prop으로 인라인 스타일처럼 객체를 넘기거나 css 헬퍼 함수로 클래스 기반 스타일을 적용할 수 있어 유연합니다. Styled Components와 마찬가지로 props에 따른 동적 스타일링도 가능합니다. 성능 면에서 Emotion은 런타임 CSS-in-JS 라이브러리이며, @emotion/babel-plugin은 라벨과 소스 맵, 일부 최적화를 돕지만 런타임 자체를 없애지는 않습니다.
App Router에서는 제약이 있습니다. Next.js 16의 공식 CSS-in-JS 안내는 Emotion을 App Router 지원 작업이 진행 중인 라이브러리로 분류합니다. 그래서 새 App Router 실습에서는 지원 상태를 먼저 확인하며, 이 글에서는 현재 통합 방법이 안내된 Styled Components를 씁니다.
앞의 Emotion 문법 예제에서 분기를 먼저 읽어 두겠습니다. MyEmotionButton에 danger=true를 넘기면 danger용 css를 붙인 일반 button을 바로 돌려줍니다. 이 분기는 StyledEmotionButton을 거치지 않으므로, 그 컴포넌트에만 선언한 padding과 border-radius가 저절로 합쳐지지 않습니다. danger와 primary를 모두 true로 넘겨도 먼저 돌려주는 danger 분기를 따릅니다. 공통 모양을 두 분기에서 유지하려면 공통 스타일을 따로 공유하거나, 같은 스타일 컴포넌트 안에서 변형을 표현하도록 설계해야 합니다.
스타일 컴포넌트의 정의는 React 컴포넌트 함수 바깥에 둡니다. 렌더링 함수 안에서 매번 새 styled.button 정의를 만들면 React가 이를 새 컴포넌트 유형으로 볼 수 있어, 자식의 상태나 입력 포커스가 다시 만들어지는 문제가 생길 수 있습니다. 예제처럼 모듈 수준에서 한 번 정의하고 props를 바꾸어 쓰세요. styled-components 사용 주의점에서도 정의 위치를 구분해 설명합니다.
App Router에서 CSS-in-JS 통합하기
Next.js App Router는 기본적으로 React 서버 컴포넌트를 쓰므로, CSS-in-JS 라이브러리를 쓸 때는 몇 가지 특별한 설정이 필요합니다. 이 글의 런타임 스타일 컴포넌트는 "use client" 파일 안에서 정의하며, 그 컴포넌트를 가져와 배치하는 상위 페이지까지 클라이언트 컴포넌트가 될 필요는 없습니다. 또한 SSR에서 스타일이 올바르게 추출되어 초기 HTML에 들어가도록 추가 설정을 합니다. 여기서는 Styled Components를 예로 통합 방법을 설명합니다.
같은 v6 안에서도 지원 경로가 나뉩니다. 이 글의 코드는 클라이언트 컴포넌트의 스타일을 SSR에서 수집하는 Registry 경로입니다. styled-components 공식 RSC 안내는 6.3.0부터 서버 컴포넌트에서 직접 쓰는 스타일의 네이티브 RSC 지원도 설명합니다. 그 서버 전용 경로에서는 스타일 정의 때문에 "use client"나 이 Registry를 추가할 필요가 없지만, 클라이언트 컴포넌트 스타일의 SSR에는 여전히 Registry가 필요합니다. 따라서 "CSS-in-JS는 언제나 클라이언트 컴포넌트에서만 쓸 수 있다"거나 "v6이면 Registry가 언제나 필요 없다"라고 일반화하지 않습니다.
이 글에서는 기존의 클라이언트 컴포넌트 예제를 유지합니다. 설치한 세부 버전, App Router 여부, 컴포넌트의 서버와 클라이언트 위치를 먼저 확인하고 그에 맞는 공식 통합 방법을 고릅니다. 서버 네이티브 경로의 ThemeProvider는 Context를 제공하지 않고 그대로 통과시키는(pass-through) 동작이므로, 클라이언트 Context 테마 예제를 그 경로로 그대로 옮겨 같은 결과를 기대하지 않습니다.
이 실습의 Registry와 enableVendorPrefixes 설명은 styled-components v6을 기준으로 합니다. 현재 API 안내는 v7에서 접두사 설정이 플러그인 방식으로 바뀐 조건을 따로 설명하므로, 버전을 지정하지 않은 설치 명령이 앞으로도 같은 주 버전을 고른다고 가정하지 마세요. 루트 레이아웃, lib/registry, MyStyledButton, 페이지, 컴파일러 설정을 모두 준비한 뒤 /css-in-js에서 확인합니다. 레이아웃만 먼저 저장하면 아직 만들지 않은 registry를 import하는 오류가 날 수 있습니다. 앞의 Emotion 코드는 문법을 비교하는 부분 예제일 뿐 이 styled-components Registry를 대신하는 파일이 아니며, 공식 지원 상태가 바뀌기 전까지는 아래 통합 절차의 대안으로 쓰지 않습니다.
Styled Components 설치
먼저 Styled Components를 설치합니다. 이 라이브러리는 실행 중에도 스타일을 만들어 문서에 넣으므로, 개발 의존성이 아니라 일반 의존성으로 설치합니다.
npm install styled-components
# 또는
yarn add styled-components위 명령은 원래의 설치 명령입니다. 이 글의 v6을 골라 실습을 재현할 때는, 위의 npm이나 yarn 설치 명령 대신 다음 가운데 자신의 패키지 관리자에 맞는 한 줄을 실행합니다. 두 버전을 같은 프로젝트에 차례로 설치하는 절차가 아니며, 이미 다른 주 버전을 쓰는 프로젝트라면 먼저 의존성 버전과 마이그레이션 범위를 확인합니다.
npm install styled-components@6
# yarn을 사용하는 프로젝트의 대안: yarn add styled-components@6Next.js는 기본 컴파일러로 SWC를 쓰며, Styled Components 변환도 SWC 옵션으로 켜므로 Babel 플러그인은 설치하지 않습니다.
Next.js 컴파일러 설정
프로젝트 루트의 next.config.ts에서 Styled Components 변환을 켭니다. 기존 nextConfig를 통째로 버리지 말고, compiler.styledComponents 필드를 기존 설정에 합칩니다. 이미 있는 다른 compiler 옵션과 앞 장에서 고른 설정도 그대로 둡니다. 이 변환 설정은 스타일 식별자 등을 다루는 컴파일 작업이며, 뒤의 Registry가 하는 렌더링 중 수집과 HTML 주입을 대신하지 않습니다.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
compiler: {
styledComponents: true,
},
};
export default nextConfig;커스텀 Babel 설정을 추가하면 그 파일은 SWC 변환을 쓰지 못하므로, 특별한 이유가 없다면 만들지 않습니다.
Styled Components Provider 설정
이 실습에서는 루트 레이아웃(src/app/layout.tsx)이 StyledComponentsRegistry로 children을 감쌉니다. 루트 레이아웃은 서버 컴포넌트로 남으며, 서버에서 넘긴 children을 감싸는 것만으로 그 아래 전체가 클라이언트 모듈로 바뀌지는 않습니다.
import './globals.css'; // 전역 CSS 임포트 (필요시)
import StyledComponentsRegistry from './lib/registry'; // 새로 생성할 레지스트리 파일 임포트
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
{/* Styled Components를 위한 레지스트리 Provider로 감싸기 */}
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
);
}Styled Components Registry 파일 생성
SSR 환경에서 Styled Components가 스타일을 올바르게 추출하고 주입하도록 돕는 유틸리티 컴포넌트를 만듭니다. Styled Components는 렌더링하면서 필요한 CSS 규칙을 모으는데, 서버 렌더링에서 이 규칙을 HTML에 넣어 주지 않으면 첫 화면이 스타일 없이 보였다가 나중에 바뀔 수 있습니다. 아래 Registry는 서버에서 렌더링하는 동안 규칙을 한 시트에 모으고, Next.js의 useServerInsertedHTML로 그 스타일을 HTML에 끼워 넣습니다.
"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;
}) {
// SSR 환경에서 한 번만 시트를 생성
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
// 서버에서 렌더링 시 스타일을 추출하여 HTML에 삽입
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag(); // 추출 후 시트 초기화
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
// 서버에서 스타일시트 매니저로 children을 감싸 렌더링
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}Registry 코드의 분기와 스타일 처리
서버 registry가 style rule을 모으고 useServerInsertedHTML에서 style 결과를 얻은 뒤 clearTag로 버퍼를 비우면서 얻은 결과는 유지합니다. 이 서버 버퍼 상태를 단계로 추적하고 브라우저 직접 children 분기는 위 관계도와 표에서 독립으로 보존합니다.
NEXT · 9.3 · CSS-in-JS
Registry는 서버에서 스타일을 수집합니다
예제의 Registry는 인스턴스 상태에 시트를 보관합니다. 서버의 수집·HTML 주입과 브라우저의 children 반환은 실행 환경에 따라 나뉩니다.
| 처리 구분 | 코드의 실행 위치 | 처리 내용 |
|---|---|---|
| 시트 준비 | Registry의 useState 초기화 | 새 ServerStyleSheet를 만들어 해당 Registry 인스턴스의 상태로 보관 |
| 서버 렌더 | window가 없는 서버 분기 | StyleSheetManager에 시트를 전달하여 렌더 중 스타일 규칙 수집 |
| HTML 주입 | useServerInsertedHTML 콜백 | getStyleElement로 스타일을 가져온 뒤 clearTag로 수집 태그를 비우고 스타일 반환 |
| 브라우저 전환 | window가 존재하는 분기 | Registry는 children을 그대로 반환; 이후 동적 스타일은 styled-components가 처리 |
- 시트 준비
- 코드의 실행 위치: Registry의 useState 초기화처리 내용: 새 ServerStyleSheet를 만들어 해당 Registry 인스턴스의 상태로 보관
- 서버 렌더
- 코드의 실행 위치: window가 없는 서버 분기처리 내용: StyleSheetManager에 시트를 전달하여 렌더 중 스타일 규칙 수집
- HTML 주입
- 코드의 실행 위치: useServerInsertedHTML 콜백처리 내용: getStyleElement로 스타일을 가져온 뒤 clearTag로 수집 태그를 비우고 스타일 반환
- 브라우저 전환
- 코드의 실행 위치: window가 존재하는 분기처리 내용: Registry는 children을 그대로 반환; 이후 동적 스타일은 styled-components가 처리
컴파일러의 styledComponents 옵션과 런타임 Registry는 역할이 다릅니다. 이 표는 styled-components 예제의 분기이며, Emotion의 App Router 통합까지 구현한 예제가 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 노드 6개와 관계 5개입니다. window가 없는 서버 실행과 있는 브라우저 실행은 선택 분기이며 모바일의 아래 배치가 두 분기를 차례로 실행한다는 뜻은 아닙니다. 서버에서는 StyleSheetManager가 렌더 중 규칙을 수집하고 useServerInsertedHTML이 결과를 얻어 수집 태그를 비운 뒤 스타일을 반환합니다. 브라우저 분기는 children을 그대로 반환하고 이후 라이브러리가 동적 스타일을 처리합니다. clearTag는 이미 얻은 style 결과를 취소하지 않습니다. 그림은 원 styled-components 6 Registry 예제이며 다른 버전·Emotion 통합을 대신 구현하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 노드 6개와 관계 5개입니다. window가 없는 서버 실행과 있는 브라우저 실행은 선택 분기이며 모바일의 아래 배치가 두 분기를 차례로 실행한다는 뜻은 아닙니다. 서버에서는 StyleSheetManager가 렌더 중 규칙을 수집하고 useServerInsertedHTML이 결과를 얻어 수집 태그를 비운 뒤 스타일을 반환합니다. 브라우저 분기는 children을 그대로 반환하고 이후 라이브러리가 동적 스타일을 처리합니다. clearTag는 이미 얻은 style 결과를 취소하지 않습니다. 그림은 원 styled-components 6 Registry 예제이며 다른 버전·Emotion 통합을 대신 구현하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 서버 규칙 수집 window 없는 서버 분기에서 StyleSheetManager가 렌더 중 규칙을 모읍니다. | 실행: 서버 분기 · sheet: 규칙 수집 · 브라우저 분기: 독립 | 서버 수집 버퍼가 채워집니다. |
| style 결과 캡처 useServerInsertedHTML이 현재 style 결과를 얻습니다. | 결과: getStyleElement · 버퍼: 수집 규칙 있음 | 반환할 style 결과가 버퍼와 별도로 확보됩니다. |
| 수집 태그 비우기 clearTag가 수집 태그를 비우되 얻은 style 결과를 취소하지 않습니다. | 버퍼: clearTag · 확보한 결과: 유지 | 다음 수집을 위한 버퍼 상태가 정리됩니다. |
| style 반환·삽입 확보한 style을 반환해 서버 HTML에 삽입합니다. | 반환: 확보한 style · 대상: 서버 HTML · 버전: styled-components 6 예제 | 버퍼 정리 뒤에도 style 출력이 전달됩니다. |
Registry 코드의 분기와 스타일 처리
서버 registry가 style rule을 모으고 useServerInsertedHTML에서 style 결과를 얻은 뒤 clearTag로 버퍼를 비우면서 얻은 결과는 유지합니다. 이 서버 버퍼 상태를 단계로 추적하고 브라우저 직접 children 분기는 위 관계도와 표에서 독립으로 보존합니다.
NEXT · 9.3 · CSS-in-JS
Registry는 서버에서 스타일을 수집합니다
예제의 Registry는 인스턴스 상태에 시트를 보관합니다. 서버의 수집·HTML 주입과 브라우저의 children 반환은 실행 환경에 따라 나뉩니다.
| 처리 구분 | 코드의 실행 위치 | 처리 내용 |
|---|---|---|
| 시트 준비 | Registry의 useState 초기화 | 새 ServerStyleSheet를 만들어 해당 Registry 인스턴스의 상태로 보관 |
| 서버 렌더 | window가 없는 서버 분기 | StyleSheetManager에 시트를 전달하여 렌더 중 스타일 규칙 수집 |
| HTML 주입 | useServerInsertedHTML 콜백 | getStyleElement로 스타일을 가져온 뒤 clearTag로 수집 태그를 비우고 스타일 반환 |
| 브라우저 전환 | window가 존재하는 분기 | Registry는 children을 그대로 반환; 이후 동적 스타일은 styled-components가 처리 |
- 시트 준비
- 코드의 실행 위치: Registry의 useState 초기화처리 내용: 새 ServerStyleSheet를 만들어 해당 Registry 인스턴스의 상태로 보관
- 서버 렌더
- 코드의 실행 위치: window가 없는 서버 분기처리 내용: StyleSheetManager에 시트를 전달하여 렌더 중 스타일 규칙 수집
- HTML 주입
- 코드의 실행 위치: useServerInsertedHTML 콜백처리 내용: getStyleElement로 스타일을 가져온 뒤 clearTag로 수집 태그를 비우고 스타일 반환
- 브라우저 전환
- 코드의 실행 위치: window가 존재하는 분기처리 내용: Registry는 children을 그대로 반환; 이후 동적 스타일은 styled-components가 처리
컴파일러의 styledComponents 옵션과 런타임 Registry는 역할이 다릅니다. 이 표는 styled-components 예제의 분기이며, Emotion의 App Router 통합까지 구현한 예제가 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 노드 6개와 관계 5개입니다. window가 없는 서버 실행과 있는 브라우저 실행은 선택 분기이며 모바일의 아래 배치가 두 분기를 차례로 실행한다는 뜻은 아닙니다. 서버에서는 StyleSheetManager가 렌더 중 규칙을 수집하고 useServerInsertedHTML이 결과를 얻어 수집 태그를 비운 뒤 스타일을 반환합니다. 브라우저 분기는 children을 그대로 반환하고 이후 라이브러리가 동적 스타일을 처리합니다. clearTag는 이미 얻은 style 결과를 취소하지 않습니다. 그림은 원 styled-components 6 Registry 예제이며 다른 버전·Emotion 통합을 대신 구현하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 노드 6개와 관계 5개입니다. window가 없는 서버 실행과 있는 브라우저 실행은 선택 분기이며 모바일의 아래 배치가 두 분기를 차례로 실행한다는 뜻은 아닙니다. 서버에서는 StyleSheetManager가 렌더 중 규칙을 수집하고 useServerInsertedHTML이 결과를 얻어 수집 태그를 비운 뒤 스타일을 반환합니다. 브라우저 분기는 children을 그대로 반환하고 이후 라이브러리가 동적 스타일을 처리합니다. clearTag는 이미 얻은 style 결과를 취소하지 않습니다. 그림은 원 styled-components 6 Registry 예제이며 다른 버전·Emotion 통합을 대신 구현하지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 서버 규칙 수집 window 없는 서버 분기에서 StyleSheetManager가 렌더 중 규칙을 모읍니다. | 실행: 서버 분기 · sheet: 규칙 수집 · 브라우저 분기: 독립 | 서버 수집 버퍼가 채워집니다. |
| style 결과 캡처 useServerInsertedHTML이 현재 style 결과를 얻습니다. | 결과: getStyleElement · 버퍼: 수집 규칙 있음 | 반환할 style 결과가 버퍼와 별도로 확보됩니다. |
| 수집 태그 비우기 clearTag가 수집 태그를 비우되 얻은 style 결과를 취소하지 않습니다. | 버퍼: clearTag · 확보한 결과: 유지 | 다음 수집을 위한 버퍼 상태가 정리됩니다. |
| style 반환·삽입 확보한 style을 반환해 서버 HTML에 삽입합니다. | 반환: 확보한 style · 대상: 서버 HTML · 버전: styled-components 6 예제 | 버퍼 정리 뒤에도 style 출력이 전달됩니다. |
위 그림은 Registry 코드의 분기와 스타일 처리를 보여 줍니다. Registry 인스턴스는 useState의 지연 초기화에서 새 ServerStyleSheet를 만들어 자신의 상태에 보관합니다. 그다음 window가 있는지에 따라 분기합니다. window가 없는 서버에서는 StyleSheetManager가 렌더링 중에 시트에 CSS 규칙을 수집하고, useServerInsertedHTML 콜백이 getStyleElement()로 스타일을 얻은 뒤 clearTag()로 수집 태그를 비우고 그 스타일을 돌려주어 서버 HTML에 넣습니다. window가 있는 브라우저에서는 children을 그대로 돌려주고, 이후의 동적 스타일은 라이브러리가 처리합니다. 그림의 단계도 서버 규칙 수집, 스타일 결과 확보, 수집 태그 비우기, 스타일 반환과 삽입의 순서입니다.
두 분기는 실행 환경에 따라 하나만 선택되며, 화면에서 아래로 배치된 것이 두 분기를 차례로 실행한다는 뜻은 아닙니다. 그림의 설명처럼 컴파일러의 styledComponents 옵션과 런타임 Registry는 역할이 다르고, 이 그림은 styled-components 예제의 분기일 뿐 Emotion의 App Router 통합까지 구현한 것이 아닙니다.
초기 서버 렌더링에서는 클라이언트 컴포넌트도 초기 HTML을 만드는 과정에 참여합니다. 그래서 "use client"라는 선언과 typeof window 조건을 같은 구분으로 읽지 않습니다. clearTag는 이미 얻은 스타일 결과를 취소하는 코드가 아니라, 다음 주입에서 같은 수집 태그를 반복하지 않도록 비우는 단계입니다. 브라우저 분기에서 Registry가 children을 그대로 돌려준다고 해서, 서버가 prop으로 넘긴 children의 모든 모듈이 클라이언트 모듈로 바뀌는 것은 아닙니다. 이 구성의 단계와 지원 조건은 Next CSS-in-JS 공식 안내에서 확인할 수 있습니다.
CSS-in-JS 컴포넌트 사용
이제 Styled Components로 컴포넌트를 스타일링할 수 있습니다. "use client"는 서버 컴포넌트에서 직접 가져올 클라이언트 진입 파일에 두며, 그 파일 안에서 가져오는 모든 파일에 반복해 붙일 필요는 없습니다. 다음 서버 페이지는 클라이언트 컴포넌트인 MyStyledButton을 렌더링합니다.
import MyStyledButton from './MyStyledButton'; // 클라이언트 컴포넌트 임포트
export default function CssInJsPage() {
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', textAlign: 'center', border: '1px solid #ccc', borderRadius: '8px' }}>
<h1>CSS-in-JS (Styled Components) 예제</h1>
<p>아래 버튼은 Styled Components로 스타일링되었습니다.</p>
<div style={{ display: 'flex', gap: '20px', justifyContent: 'center', marginTop: '30px' }}>
<MyStyledButton label="기본 버튼" />
<MyStyledButton label="강조 버튼" primary={true} />
</div>
</div>
);
}페이지는 서버 컴포넌트로 남고, 스타일을 만드는 코드는 모두 가져온 MyStyledButton 안에 있습니다. 페이지가 넘기는 primary={true}는 일반 prop이며, 버튼 안에서 $primary로 이름을 바꿔 스타일 컴포넌트에 넘깁니다. MyStyledButton은 모듈 수준에서 스타일 컴포넌트를 정의하고, $primary prop으로 색상을 바꿉니다. TypeScript에서는 styled.button<{ $primary?: boolean }>처럼 제네릭으로 이 prop의 타입을 알려 줍니다.
"use client"; // 🚨 반드시 필요
import styled from 'styled-components';
const ButtonContainer = styled.button<{ $primary?: boolean }>`
background-color: ${props => (props.$primary ? '#007bff' : '#f0f0f0')};
color: ${props => (props.$primary ? 'white' : '#333')};
padding: 12px 25px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1.1em;
font-weight: bold;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease, transform 0.1s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
`;
interface MyStyledButtonProps {
label: string;
primary?: boolean;
onClick?: () => void;
}
export default function MyStyledButton({ label, primary = false, onClick }: MyStyledButtonProps) {
return (
<ButtonContainer $primary={primary} onClick={onClick}>
{label}
</ButtonContainer>
);
}앞에서 설명한 Styled Components 설치, Next.js 컴파일러 설정, Registry 설정을 마치고, src/app/css-in-js 폴더에 위 page.tsx와 MyStyledButton.tsx를 만듭니다. 개발 서버(npm run dev)를 실행한 뒤 http://localhost:3000/css-in-js에 접속하면 버튼들이 Styled Components로 스타일링되어 나타납니다. 페이지 소스 보기에서 초기 HTML에 Styled Components가 주입한 <style> 태그가 들어 있는지 확인하면, SSR이 올바르게 동작하는지 점검할 수 있습니다.
CSS-in-JS의 장단점
CSS-in-JS의 장점부터 보겠습니다. JavaScript를 활용해 조건부 스타일과 props 기반 스타일을 구현할 수 있고, 스타일과 컴포넌트 로직이 한 파일에 있어 관련 코드를 찾고 관리하기 쉽습니다. 스코프가 자동으로 나뉘므로 스타일 충돌을 걱정하지 않고 클래스 이름을 자유롭게 지을 수 있습니다. 클라이언트 컴포넌트에서는 Context API로 테마를 넘겨 테마 시스템을 쉽게 만들 수 있지만, 앞에서 구분한 서버 네이티브 RSC 경로의 ThemeProvider는 Context 테마를 제공하지 않으므로 같은 사용법으로 일반화하지 않습니다. 컴포넌트와 스타일 정의가 가까이 있어 사용처를 찾기도 쉽지만, 실제로 번들에서 빠지는지는 import 구조와 빌드 최적화에 달려 있습니다.
단점도 있습니다. 새로운 문법과 개념을 배워야 하고, 스타일을 JavaScript로 파싱해 CSS로 바꾸는 과정에서 약간의 런타임 성능 저하가 생길 수 있으며 최적화 옵션으로 줄일 수 있습니다. 서버에서 보낼 스타일이 빠지면 스타일이 늦게 적용되는 FOUC(Flash Of Unstyled Content)가 생길 수 있으므로, Registry를 설정한 뒤에도 새로고침, 스트리밍, 클라이언트 전환에서 스타일 누락과 하이드레이션 경고를 확인합니다. 개발자 도구에서 실제 CSS 클래스 이름이 해시로 바뀌어 디버깅이 다소 어려울 수 있는데, styled-components의 displayName 옵션으로 개선할 수 있습니다. 그리고 CSS-in-JS 라이브러리 자체의 크기만큼 번들이 커집니다.
어떤 스타일링 방식을 선택해야 할까요?
Next.js에는 여러 스타일링 방식이 있으며, 프로젝트의 요구사항, 팀의 선호, 개발자의 숙련도에 따라 알맞은 선택이 달라집니다. CSS 모듈과 Sass 모듈은 컴포넌트 단위 스타일링과 충돌 방지를 원하고 CSS 문법에 익숙한 경우 좋은 선택이며, 별도의 런타임 비용이 거의 없습니다. 런타임 CSS-in-JS는 props와 테마에 따라 스타일을 만들어야 할 때 고려하되, App Router 지원 여부와 라이브러리별 서버 스타일 수집 방법을 확인합니다. Tailwind CSS는 HTML에 직접 클래스를 더해 스타일을 적용하는 유틸리티 우선(utility-first) CSS 프레임워크로, 빠른 프로토타이핑과 일관된 디자인 시스템 구축에 알맞으며 다음 글에서 다룹니다.
정리
- CSS-in-JS는 컴포넌트 파일 안에서 JavaScript로 스타일을 정의하며, Styled Components와 Emotion은 런타임에 규칙을 만듭니다.
- 스타일 컴포넌트는 모듈 수준에서 한 번 정의하고, 렌더링 함수 안에서 매번 새로 만들지 않습니다.
- App Router에서 클라이언트 컴포넌트의 스타일을 SSR에 넣으려면
useServerInsertedHTML과StyleSheetManager를 쓰는 Registry로 루트 레이아웃의children을 감쌉니다. compiler.styledComponents옵션은 컴파일 변환일 뿐이며, 렌더링 중 스타일 수집과 HTML 주입은 Registry가 맡습니다.- Emotion은 App Router 지원이 진행 중이므로, 지원 상태와 설치한 라이브러리 버전에 맞는 공식 통합 방법을 먼저 확인합니다.
연습 문제
1. 최종 버튼 코드로 결과 예상하기
앞의 StyledButton.jsx 문법 소개와 MyStyledButton.tsx 실습은 서로 다른 코드입니다. 최종 MyStyledButton에 primary=true를 넘기고 외부 규칙이 없다고 가정합니다. 기본 배경과 글자색, 마우스를 올릴 때 바뀌는 속성을 적어 보세요. $primary가 DOM의 일반 속성으로 전달되는지, SWC 옵션만 켜고 Registry를 준비하지 않았을 때 서버 스타일 수집까지 구현했다고 말할 수 있는지도 판단해 보세요.
해설 보기
최종 버튼의 배경은 #007bff, 글자색은 white입니다. 이 코드의 hover는 이동과 그림자를 바꿀 뿐 배경색을 다시 선언하지 않으므로, 앞의 문법 소개에서 본 #0056b3가 최종 코드에도 있다고 가정하지 않습니다. $primary는 styled-components의 transient prop이므로 스타일 계산에는 쓰이지만, 실제 DOM에 같은 이름의 속성으로 전달되지 않습니다.
컴파일러 설정과 서버 수집은 서로 다른 일입니다. SWC 옵션을 켰다는 사실만으로 Registry의 StyleSheetManager와 useServerInsertedHTML을 이용한 수집과 주입 절차까지 준비되었다고 말할 수 없습니다. 실제 초기 HTML과 스트리밍, 브라우저 전환의 결과는 고른 버전 조합에서 따로 확인해야 합니다.
2. Emotion 버튼의 분기
MyEmotionButton에 danger=true와 primary=true를 함께 넘깁니다. 어떤 요소를 돌려주고, StyledEmotionButton에만 정의된 padding과 border-radius가 이 분기에도 저절로 적용될까요? 외부 CSS는 없다고 가정합니다.
해설 보기
먼저 검사하는 danger 분기의 일반 button을 돌려줍니다. danger용 css의 빨간 배경과 그 hover 규칙은 적용되지만, StyledEmotionButton을 렌더링하지 않으므로 그 정의에만 있는 공통 padding과 border-radius는 합쳐지지 않습니다. primary=true가 뒤의 분기로 넘어가게 만들지도 않습니다.
3. 스트리밍 중의 두 번 주입
하나의 Registry가 서버 스트림의 첫 주입 직전에 A 규칙을, 두 번째 주입 직전에 B 규칙을 수집합니다. 각 getStyleElement 뒤에 clearTag를 호출했을 때 각 주입의 규칙은 무엇일까요? 첫 HTML로 이미 보낸 A가 취소되는지, 서로 다른 요청의 Registry 시트를 전역 하나로 함께 써도 되는지도 설명해 보세요.
해설 보기
첫 주입은 A이고, clearTag 뒤에 새로 수집한 것을 얻는 두 번째 주입은 B입니다. 이미 첫 HTML로 보낸 A를 브라우저에서 지우는 동작은 아닙니다. 예제의 Registry처럼 렌더링 인스턴스마다 시트를 만들어, 요청 사이에 규칙이 섞이지 않게 해야 합니다. 스트리밍을 이어 갈 시트를 주입할 때마다 봉인(seal)하는 방식으로 바꾸지도 않습니다.
4. 서버 네이티브 경로와 Registry 경로
styled-components 6.3 이상에서 서버 전용 스타일 컴포넌트를 쓰는 경우와, 이 글처럼 클라이언트 컴포넌트의 스타일을 초기 서버 HTML에 수집하는 경우를 비교합니다. 두 경우 모두 Registry가 필요 없을까요? 서버 네이티브 경로의 ThemeProvider로 클라이언트 Context 테마와 같은 동작을 기대할 수 있을까요?
해설 보기
서버 전용 스타일의 네이티브 RSC 지원 경로는 스타일만을 위해 Registry를 요구하지 않습니다. 반면 클라이언트 컴포넌트 스타일의 SSR 수집은 여전히 Registry를 쓰므로, 둘 다 필요 없다는 답은 틀립니다. 서버 네이티브 경로의 ThemeProvider는 값을 그대로 통과시킬 뿐이므로, 클라이언트 Context로 테마를 공급하는 것과 같은 동작을 기대하지 않습니다.
5. 렌더링 함수 안의 styled 정의
입력창이 있는 자식의 스타일 컴포넌트를 부모의 렌더링 함수 안에서 매번 정의합니다. 부모가 다시 렌더링될 때 자식의 상태와 포커스가 다시 만들어질 위험의 원인을 쓰고, 예제의 정의 위치를 기준으로 고쳐 보세요. CSS 색이 바뀌는 것과 컴포넌트 유형을 다시 만드는 것을 구별합니다.
해설 보기
매번 새로 만든 styled 정의가 새 React 컴포넌트 유형이 되어 기존 자식을 대체할 수 있다는 것이 원인입니다. 모듈 수준에서 안정적으로 정의한 컴포넌트를 쓰고, 스타일 값은 props로 넘깁니다. 색상이 달라졌다는 사실만으로는 컴포넌트 유형이 바뀌지 않습니다.
다음 글 Tailwind CSS 설정 및 사용에서는 유틸리티 우선 CSS 프레임워크인 Tailwind CSS를 Next.js에 설정하고 쓰는 방법을 알아봅니다.