Styled-components — CSS-in-JS의 선구자
프론트엔드 개발 · 스타일링 & UI 시리즈 #3
컴포넌트와 스타일을 한 파일에서, JavaScript의 모든 힘을 빌려 CSS를 작성하는 패러다임. Styled-components의 철학, 내부 동작, 장단점을 깊이 탐구합니다.
1. CSS-in-JS란 무엇인가
CSS-in-JS는 CSS를 별도 파일이 아닌 JavaScript 코드 안에서 작성하는 패러다임입니다. 2014년 Facebook 엔지니어 Christopher Chedeau(@vjeux)의 발표에서 본격적으로 논의되기 시작했습니다.
그가 제시한 CSS의 7가지 근본적 문제는 지금도 유효합니다.
- 전역 네임스페이스: 모든 CSS 클래스는 전역 스코프를 공유한다
- 의존성 관리: 어떤 CSS가 어떤 컴포넌트에 필요한지 추적하기 어렵다
- 데드 코드 제거: 사용하지 않는 CSS를 찾아서 지우기 어렵다
- 최소화: 클래스 이름 압축이 어렵다
- 상수 공유: JS 변수를 CSS에서 쓰기 어렵다
- 비결정적 해결: 로드 순서에 따라 스타일이 달라질 수 있다
- 격리: 컴포넌트 간 스타일 충돌을 막기 어렵다
CSS-in-JS는 스타일을 컴포넌트에 완전히 캡슐화함으로써 이 문제들을 해결합니다.
2. Styled-components 소개
Styled-components는 2016년 Glen Maddern과 Max Stoiber가 만든 React 전용 CSS-in-JS 라이브러리입니다. ES6의 Tagged Template Literal 문법을 활용해 컴포넌트 정의와 스타일링을 하나로 통합합니다.
npm install styled-components
import styled from 'styled-components';
// HTML 요소를 확장한 스타일드 컴포넌트
const Button = styled.button`
padding: .5rem 1.25rem;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background .2s;
&:hover {
background: #2980b9;
}
`;
// React 컴포넌트처럼 사용
function App() {
return <Button>클릭하세요</Button>;
}
3. 핵심 문법 완전 가이드
Props 기반 동적 스타일
JavaScript props를 받아 스타일을 동적으로 변경하는 것이 styled-components 최대의 강점입니다.
const Button = styled.button`
padding: .5rem 1.5rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
border: 2px solid transparent;
transition: all .2s;
/* props에 따른 동적 스타일 */
background: ${({ variant }) =>
variant === 'outline' ? 'transparent' :
variant === 'danger' ? '#e74c3c' : '#3498db'
};
color: ${({ variant }) =>
variant === 'outline' ? '#3498db' : 'white'
};
border-color: ${({ variant }) =>
variant === 'outline' ? '#3498db' : 'transparent'
};
/* size prop */
font-size: ${({ size }) =>
size === 'lg' ? '1.1rem' :
size === 'sm' ? '.8rem' : '1rem'
};
`;
// 사용
<Button>기본 버튼</Button>
<Button variant="outline">아웃라인</Button>
<Button variant="danger" size="lg">삭제</Button>
컴포넌트 확장 (Extending)
기존 styled-component를 확장해 새 스타일을 추가합니다.
const BaseButton = styled.button`
display: inline-flex;
align-items: center;
padding: .5rem 1.25rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
`;
// 확장: 기본 스타일 + 추가 스타일
const PrimaryButton = styled(BaseButton)`
background: #3498db;
color: white;
border: none;
&:hover { background: #2980b9; }
`;
const GhostButton = styled(BaseButton)`
background: transparent;
color: #3498db;
border: 2px solid #3498db;
&:hover {
background: #3498db;
color: white;
}
`;
as prop — 렌더링 요소 변경
styled-component를 다른 HTML 요소나 React 컴포넌트로 렌더링할 수 있습니다.
const Button = styled.button`
padding: .5rem 1.25rem;
background: #3498db;
color: white;
border-radius: 6px;
text-decoration: none;
display: inline-block;
`;
// <button> 대신 <a> 태그로 렌더링
<Button as="a" href="/dashboard">대시보드 이동</Button>
// React Router Link로 렌더링
<Button as={Link} to="/profile">프로필</Button>
중첩 선택자와 &
SCSS와 유사하게 중첩 선택자를 사용합니다.
const Card = styled.div`
padding: 1.5rem;
border-radius: 12px;
border: 1px solid #e0e0e0;
transition: all .2s;
/* 자식 요소 스타일 */
h2 {
font-size: 1.2rem;
margin-bottom: .5rem;
color: #1a1a2e;
}
p {
color: #666;
line-height: 1.6;
}
/* &는 컴포넌트 자신을 참조 */
&:hover {
box-shadow: 0 8px 24px rgba(0,0,0,.1);
transform: translateY(-2px);
}
/* 특정 클래스가 있을 때 */
&.featured {
border-color: #3498db;
border-width: 2px;
}
/* 다른 스타일드 컴포넌트를 타겟 */
${Button} {
margin-top: 1rem;
width: 100%;
}
`;
createGlobalStyle — 전역 스타일
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Pretendard', -apple-system, sans-serif;
background: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
`;
// App.jsx
function App() {
return (
<ThemeProvider theme={theme}>
<GlobalStyle />
<Router>...</Router>
</ThemeProvider>
);
}
keyframes — 애니메이션
import styled, { keyframes } from 'styled-components';
const fadeInUp = keyframes`
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const Modal = styled.div`
animation: ${fadeInUp} .3s ease-out;
background: white;
padding: 2rem;
border-radius: 12px;
`;
4. 내부 동작 원리
Styled-components가 어떻게 동작하는지 이해하면 디버깅과 성능 최적화에 도움이 됩니다.
Tagged Template Literal 파싱
styled.button`
color: ${props => props.primary ? 'white' : '#333'};
background: ${props => props.primary ? '#3498db' : 'transparent'};
`;
JavaScript 엔진이 이 코드를 파싱하면, styled-components는 정적 문자열 배열과 동적 interpolation 배열을 받아 처리합니다.
런타임 클래스 생성
- 컴포넌트가 렌더링될 때, props를 기반으로 CSS 문자열 생성
- CSS 문자열을 해시해 고유한 클래스명 생성 (예: sc-bdXxxt)
- <style> 태그에 해당 CSS를 삽입
- 컴포넌트에 생성된 클래스명을 적용
<!-- 런타임에 주입되는 스타일 -->
<style data-styled="active">
.sc-bdXxxt { padding: .5rem 1.25rem; border-radius: 6px; }
.bPNpEe { background: #3498db; color: white; }
.fJMzMO { background: transparent; color: #3498db; }
</style>
<!-- 렌더링된 HTML -->
<button class="sc-bdXxxt bPNpEe">Primary</button>
<button class="sc-bdXxxt fJMzMO">Ghost</button>
Babel 플러그인 (권장)
개발 환경에서 디버깅을 쉽게 하기 위해 babel-plugin-styled-components를 사용합니다. 컴포넌트 이름이 클래스명에 포함되어 DevTools에서 확인하기 쉬워집니다.
npm install --save-dev babel-plugin-styled-components
// .babelrc
{
"plugins": [
["babel-plugin-styled-components", {
"displayName": true,
"fileName": true
}]
]
}
5. 테마 시스템 구축
ThemeProvider를 활용한 전역 테마 시스템은 styled-components의 핵심 기능 중 하나입니다.
// theme.js
export const lightTheme = {
colors: {
primary: '#3498db',
secondary: '#2ecc71',
background: '#ffffff',
surface: '#f8f9fa',
text: '#1a1a2e',
textMuted: '#6c757d',
border: '#dee2e6',
danger: '#e74c3c',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '48px',
},
borderRadius: {
sm: '4px',
md: '8px',
lg: '12px',
full: '9999px',
},
typography: {
fontFamily: "'Pretendard', -apple-system, sans-serif",
fontSizes: {
sm: '.875rem',
md: '1rem',
lg: '1.125rem',
xl: '1.5rem',
},
},
};
export const darkTheme = {
...lightTheme,
colors: {
...lightTheme.colors,
background: '#0f1923',
surface: '#1a2535',
text: '#e8eaf0',
textMuted: '#9aa3b0',
border: '#2a3545',
},
};
// App.jsx — ThemeProvider 적용
import { ThemeProvider } from 'styled-components';
import { useState } from 'react';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
<GlobalStyle />
<Layout>
<button onClick={() => setIsDark(d => !d)}>
{isDark ? '라이트 모드' : '다크 모드'}
</button>
{/* ... */}
</Layout>
</ThemeProvider>
);
}
// 컴포넌트에서 테마 사용
const Card = styled.div`
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: ${({ theme }) => theme.borderRadius.lg};
padding: ${({ theme }) => theme.spacing.lg};
color: ${({ theme }) => theme.colors.text};
`;
// useTheme 훅으로도 접근 가능
import { useTheme } from 'styled-components';
function Component() {
const theme = useTheme();
return <div style={{ color: theme.colors.primary }}>...</div>;
}
6. 성능 이슈와 해결책
Styled-components는 런타임에 스타일을 생성하기 때문에 성능 이슈가 발생할 수 있습니다.
주요 성능 문제
CSS 직렬화 비용: 컴포넌트가 렌더링될 때마다 CSS 문자열을 생성하고 파싱합니다.
스타일 주입 비용: <style> 태그에 동적으로 CSS를 삽입하는 작업이 매 렌더링마다 발생할 수 있습니다.
SSR 이슈: 서버에서 렌더링 시 스타일 수집과 클라이언트 hydration 과정이 복잡합니다.
해결책
1. .attrs() 메서드로 prop 최소화
// Bad: 렌더링마다 새 class 생성 가능성
const Input = styled.input`
width: ${({ width }) => width}px;
`;
<Input width={300} />
// Good: .attrs()로 static attrs 분리
const Input = styled.input.attrs(props => ({
type: props.type || 'text',
}))`
border: 1px solid #ddd;
border-radius: 6px;
padding: .5rem .75rem;
`;
2. 동적 스타일은 CSS 변수로
// Bad: props마다 새 클래스 생성
const Box = styled.div`
width: ${({ width }) => width}px;
height: ${({ height }) => height}px;
`;
// Good: CSS 변수 활용으로 단일 클래스 유지
const Box = styled.div`
width: var(--box-width);
height: var(--box-height);
`;
<Box style={{ '--box-width': '300px', '--box-height': '200px' }} />
3. SSR 설정
// Next.js _document.jsx
import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
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();
}
}
}
7. 장단점 정리
장점
- 완전한 컴포넌트 캡슐화: 스타일이 컴포넌트와 함께 존재해 응집도가 높음
- 동적 스타일링: props와 JS 로직으로 강력하게 스타일 제어 가능
- 자동 CSS 스코핑: 전역 네임스페이스 충돌 없음
- TypeScript 지원: props 타입 정의로 스타일 타입 안전성 확보
- 데드 코드 제거: 컴포넌트와 스타일이 같은 파일, 삭제 시 함께 제거
- 테마 시스템: ThemeProvider로 전역 디자인 토큰 관리
단점
- 런타임 오버헤드: 스타일 생성과 주입이 런타임에 발생
- 번들 크기: 라이브러리 자체가 ~12KB (gzipped)
- SSR 복잡성: 서버 사이드 렌더링 설정이 번거로움
- CSS 표준과의 거리: 일반 CSS 도구(lint, browser DevTools) 활용 제한적
- React 전용: Vue, Svelte 등에서 사용 불가 (Emotion은 가능)
- 새로운 패러다임 학습: 팀 전체의 학습 비용
8. 결론 — 지금도 좋은 선택인가?
Styled-components는 CSS-in-JS 패러다임을 대중화시킨 역사적인 라이브러리입니다. 그러나 2023년 이후 트렌드는 런타임 CSS-in-JS에서 빌드 타임 솔루션으로 이동하고 있습니다.
프로젝트 상황 권장 선택
| 기존 styled-components 프로젝트 | 유지 (마이그레이션 비용 큼) |
| React SPA, 성능 중요도 낮음 | Styled-components 또는 Emotion |
| Next.js App Router | Tailwind CSS 또는 CSS Modules |
| 성능 민감한 SSR 앱 | CSS Modules, Tailwind, Vanilla Extract |
| 강력한 동적 스타일 필요 | Styled-components 또는 Emotion |
한 줄 요약: CSS-in-JS의 철학과 동적 스타일링의 힘을 배우기에 최고의 라이브러리이지만, 새 프로젝트에서는 런타임 오버헤드와 SSR 복잡성을 감수할 만한 이유가 있는지 먼저 따져보세요.
댓글