styled-components 사용법: props 기반 스타일, 상속과 as Prop, 테마, 전역 스타일, attrs

이 글의 핵심

styled-components의 기본 문법과 props로 바뀌는 스타일, 기존 컴포넌트 확장과 as Prop, ThemeProvider 테마, 전역 스타일, attrs로 기본 속성 지정하기를 다룹니다.

이 글의 핵심

styled-components로 CSS-in-JS를 구현하는 글입니다. Tagged Template Literals, Props, Theming, SSR을 예제로 정리했습니다. 본문은 styled-components v6 기준입니다.

실무에서 마주치는 문제들

클래스명 충돌이 발생해요

일반 CSS의 클래스는 모두 전역이라, 한 페이지에서 만든 .button 스타일이 다른 페이지의 .button을 덮어쓰는 일이 생깁니다. BEM 같은 명명 규칙으로 막을 수는 있지만 사람이 지켜야 하는 규칙입니다. styled-components는 스타일 내용으로 해시 기반 클래스명(sc-bdfBwQ 같은 형태)을 만들어 붙이므로, 컴포넌트끼리 스타일이 섞이지 않습니다.

동적 스타일이 필요해요

인라인 style 속성은 :hover 같은 가상 선택자, 미디어 쿼리, 자식 선택자를 쓸 수 없습니다. 조건부 클래스를 여러 개 조합하는 방식은 상태가 늘어날수록 클래스명 계산 로직이 복잡해집니다. styled-components는 props를 받아 CSS 값을 계산하는 함수를 템플릿 안에 바로 넣을 수 있어, 상태와 스타일이 한곳에 모입니다.

테마가 필요해요

색상 값을 파일마다 하드코딩하면 브랜드 색 하나를 바꿀 때 전체를 검색해 고쳐야 합니다. ThemeProvider로 테마 객체를 내려주면 모든 styled 컴포넌트가 props.theme으로 같은 값을 참조합니다.

도입 전에 알아 둘 흐름도 있습니다. styled-components는 렌더링할 때 스타일을 계산해 <style> 태그에 넣는 런타임 CSS-in-JS라서 React Server Components에서 쓸 수 없고, Next.js App Router에서는 클라이언트 컴포넌트로만 동작합니다. 이런 제약 때문에 React 생태계는 Tailwind CSS나 빌드 타임에 CSS를 추출하는 방식으로 옮겨 가는 추세이고, 2025년에는 메인테이너가 styled-components를 유지보수 모드로 전환한다고 밝히기도 했습니다. 이미 styled-components로 만든 프로젝트를 운영하는 데는 문제가 없지만, 새 프로젝트라면 대안과 함께 비교해 보는 것이 좋습니다.


styled-components란?

핵심 특징

styled-components는 React CSS-in-JS 라이브러리입니다. 주요 장점:

  • 컴포넌트 기반: CSS와 JS 통합
  • 자동 Vendor Prefix: 호환성
  • 동적 스타일: Props 기반
  • 테마: ThemeProvider
  • SSR: 서버 사이드 렌더링

설치 및 기본 사용

설치

npm install styled-components
npm install -D @types/styled-components

@types/styled-components는 v5까지 필요했던 타입 패키지입니다. v6부터는 타입 정의가 패키지에 포함되어 있어 따로 설치하지 않아야 하며, 설치해 두면 오래된 타입과 새 타입이 충돌해 제네릭 props 타입 에러가 나기도 합니다. v6를 쓴다면 첫 줄만 실행하면 됩니다.

기본 사용

import styled from 'styled-components';
const Button = styled.button`
  background: #3498db;
  color: white;
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &:hover {
    opacity: 0.8;
  }
`;
export default function App() {
  return <Button>Click me</Button>;
}

styled.button`...`은 태그드 템플릿 리터럴이라는 자바스크립트 문법입니다. 함수 이름 뒤에 백틱 문자열을 붙이면, 문자열 조각과 ${} 안의 값이 분리되어 함수에 전달됩니다. styled-components는 이것을 받아 CSS로 만들고, 고유 클래스명을 붙인 <button>을 렌더링하는 React 컴포넌트를 돌려줍니다. &는 그 클래스 자신을 가리키므로 &:hover는 이 버튼의 hover 상태입니다.

VS Code에서 템플릿 안의 CSS가 문자열로만 보여 자동완성이 안 된다면 vscode-styled-components 확장을 설치하면 구문 강조와 자동완성이 됩니다. styled 컴포넌트는 반드시 렌더 함수 바깥에서 정의해야 합니다. 컴포넌트 함수 안에서 styled.button을 호출하면 렌더링마다 새 컴포넌트 타입이 생겨 React가 DOM을 통째로 다시 만들고, 개발 모드에서는 The component styled.button with the id of "..." has been created dynamically 경고가 나옵니다. 입력 필드라면 한 글자 칠 때마다 포커스가 풀리는 증상으로 나타납니다.


Props 기반 스타일

interface ButtonProps {
  $variant?: 'primary' | 'secondary';
  $size?: 'small' | 'large';
}
const Button = styled.button<ButtonProps>`
  background: ${(props) => (props.$variant === 'primary' ? '#3498db' : '#2ecc71')};
  color: white;
  padding: ${(props) => (props.$size === 'small' ? '0.25rem 0.5rem' : '0.5rem 1rem')};
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;
// 사용
<Button $variant="primary" $size="small">Small Primary</Button>
<Button $variant="secondary">Secondary</Button>

props 이름 앞의 $는 transient props 표시입니다. $로 시작하는 props는 스타일 계산에만 쓰이고 실제 DOM 요소로는 전달되지 않습니다. v6에서 이 구분이 특히 중요해진 이유는, v5까지는 유효한 HTML 속성만 골라 DOM에 넘겼지만 v6는 이 자동 필터링을 없애고 모든 props를 그대로 전달하기 때문입니다. $ 없이 variant나 size를 쓰면 <button variant="primary">처럼 DOM에 알 수 없는 속성이 붙고, 불리언 값이면 Warning: Received `true` for a non-boolean attribute 같은 React 경고가 뜹니다. v5 코드를 v6로 올렸을 때 콘솔이 이런 경고로 가득 차는 것이 이 변경 때문입니다. 한꺼번에 고치기 어렵다면 StyleSheetManager의 shouldForwardProp에 @emotion/is-prop-valid를 연결해 v5 동작을 흉내 낼 수 있습니다.

보간 함수는 렌더링마다 실행되고, 계산 결과가 달라지면 새 클래스가 생성됩니다. 이 예제처럼 값의 종류가 몇 가지로 정해져 있으면 클래스도 몇 개만 생기지만, 마우스 좌표나 진행률처럼 연속적으로 바뀌는 값을 템플릿에 넣으면 클래스가 끝없이 늘어나며 Over 200 classes were generated for component 경고가 납니다. 자주 바뀌는 값은 .attrs로 style 속성에 넣거나 CSS 변수로 넘기는 것이 맞습니다.


상속

const Button = styled.button`
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;
const PrimaryButton = styled(Button)`
  background: #3498db;
  color: white;
`;
const SecondaryButton = styled(Button)`
  background: #2ecc71;
  color: white;
`;

styled(Button)은 기존 컴포넌트의 스타일을 모두 물려받고 새 스타일을 덧붙인 컴포넌트를 만듭니다. 같은 속성이 겹치면 확장한 쪽이 이깁니다. 확장한 컴포넌트의 스타일이 더 나중에 삽입되고, 요소에는 두 클래스가 모두 붙기 때문입니다.

styled()는 styled 컴포넌트뿐 아니라 일반 React 컴포넌트도 감쌀 수 있습니다. 다만 그 컴포넌트가 className prop을 받아 최상위 DOM 요소에 넘겨야만 스타일이 적용됩니다. const Card = ({ children }) => <div>{children}</div>처럼 className을 무시하는 컴포넌트를 styled(Card)로 감싸면 에러 없이 스타일만 적용되지 않아, styled-components를 처음 쓸 때 원인을 찾느라 시간을 쓰게 되는 대표적인 경우입니다. 외부 UI 라이브러리 컴포넌트를 감쌀 때도 이 점을 먼저 확인합니다.


as Prop

const Button = styled.button`
  padding: 0.5rem 1rem;
  background: #3498db;
  color: white;
`;
// button으로 렌더링
<Button>Button</Button>
// a로 렌더링
<Button as="a" href="/about">Link</Button>

as prop은 스타일은 그대로 두고 렌더링할 HTML 태그나 컴포넌트만 바꿉니다. 버튼처럼 보이지만 실제로는 페이지 이동인 요소를 <a>로 렌더링하는 것은 접근성 측면에서도 올바른 선택입니다. 스크린 리더와 키보드 사용자에게 버튼과 링크는 다르게 동작하기 때문입니다. React Router나 Next.js의 Link 컴포넌트를 as={Link}로 넘길 수도 있습니다.

styled(Button)으로 확장한 컴포넌트에 as를 쓰면 원래 컴포넌트까지 교체되어 확장 전 컴포넌트가 가진 로직이 사라질 수 있습니다. 감싼 컴포넌트는 유지하고 그 안쪽 요소만 바꾸고 싶다면 as 대신 forwardedAs를 씁니다.


Theming

import { ThemeProvider } from 'styled-components';
const lightTheme = {
  colors: {
    primary: '#3498db',
    background: '#ffffff',
    text: '#333333',
  },
};
const darkTheme = {
  colors: {
    primary: '#3498db',
    background: '#1a1a1a',
    text: '#ffffff',
  },
};
const Button = styled.button`
  background: ${(props) => props.theme.colors.primary};
  color: ${(props) => props.theme.colors.text};
`;
export default function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <Button onClick={() => setIsDark(!isDark)}>Toggle Theme</Button>
    </ThemeProvider>
  );
}

ThemeProvider는 React Context로 테마 객체를 전달하고, 모든 styled 컴포넌트는 보간 함수의 props.theme으로 이를 받습니다. 두 테마 객체의 구조가 같아야 한다는 점이 중요합니다. 다크 테마에만 없는 키가 있으면 전환하는 순간 Cannot read properties of undefined 에러가 납니다. 예제에서 useState는 react에서 가져와야 하며, 테마 전환 시 테마를 읽는 모든 컴포넌트의 스타일이 다시 계산되어 새 클래스가 생성됩니다. 컴포넌트가 많은 앱이라면 색상을 CSS 변수로 정의하고 테마 전환은 <html>의 속성만 바꾸는 방식이 훨씬 가볍습니다.

TypeScript에서는 props.theme의 타입이 기본적으로 빈 객체(DefaultTheme)라 props.theme.colors에서 타입 에러가 납니다. styled.d.ts 파일에 declare module 'styled-components' { export interface DefaultTheme { colors: { primary: string; background: string; text: string } } }처럼 인터페이스를 확장해 두면 모든 styled 컴포넌트에서 테마 자동완성이 됩니다. typeof lightTheme으로 타입을 뽑아 확장하면 값과 타입을 두 번 적지 않아도 됩니다.


Global Styles

import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  body {
    font-family: Arial, sans-serif;
    background: ${(props) => props.theme.colors.background};
    color: ${(props) => props.theme.colors.text};
  }
`;
export default function App() {
  return (
    <>
      <GlobalStyle />
      {/* 나머지 컴포넌트 */}
    </>
  );
}

createGlobalStyle은 클래스가 아닌 전역 선택자(*, body)에 스타일을 적용하는 특수 컴포넌트로, 렌더링되어 있는 동안에만 스타일이 적용됩니다. 여기서 이 예제를 그대로 실행하면 에러가 납니다. GlobalStyle이 props.theme.colors를 읽는데, App이 ThemeProvider로 감싸져 있지 않아 theme이 빈 객체이기 때문입니다. GlobalStyle도 테마를 읽는 컴포넌트이므로 반드시 <ThemeProvider theme={...}><GlobalStyle />...</ThemeProvider>처럼 Provider 안쪽에 둬야 합니다. 6장과 7장의 코드를 합칠 때 순서를 바꿔 두는 실수가 흔합니다.

전역 스타일에 @import로 웹폰트를 불러오면 스타일이 다시 삽입될 때마다 폰트 요청이 반복되거나 순서 경고가 날 수 있으므로, 폰트는 HTML의 <link>나 별도 CSS 파일로 불러오는 편이 안정적입니다.


CSS Helper

import styled, { css } from 'styled-components';
const sharedStyles = css`
  padding: 0.5rem 1rem;
  border-radius: 4px;
`;
const Button = styled.button`
  ${sharedStyles}
  background: #3498db;
`;
const Input = styled.input`
  ${sharedStyles}
  border: 1px solid #ccc;
`;

css 헬퍼는 컴포넌트가 아닌 스타일 조각을 만듭니다. Button과 Input처럼 서로 다른 태그가 같은 스타일을 공유해야 할 때 상속(styled(Button))은 쓸 수 없으므로 이렇게 조각을 만들어 끼워 넣습니다. 조각 안에 props를 쓰는 보간 함수가 있다면 반드시 css로 감싸야 합니다. css 없이 일반 템플릿 문자열로 만들면 함수가 호출되지 않고 함수의 소스 코드 문자열이 그대로 CSS에 들어가 스타일이 깨집니다. 조건부 스타일 블록(${(p) => p.$active && css`...`})을 만들 때도 같은 이유로 css를 씁니다.


Attrs

const Input = styled.input.attrs<{ $size?: 'small' | 'large' }>((props) => ({
  type: 'text',
  placeholder: 'Enter text',
  size: props.$size === 'small' ? 10 : 20,
}))`
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
`;

.attrs는 컴포넌트가 렌더링하는 DOM 요소에 기본 속성을 붙입니다. 여기서는 type과 placeholder를 고정했고, $size에 따라 HTML size 속성(입력란의 글자 수 너비)을 계산해 넣었습니다. 사용하는 쪽에서 같은 속성을 넘기면 어느 쪽이 이기는지는 버전마다 달라서 헷갈리기 쉬운데, v6에서는 attrs가 반환한 값이 전달된 props보다 우선합니다. 그래서 위 코드에서 <Input placeholder="이메일" />로 넘겨도 "Enter text"가 표시됩니다. 사용자가 바꿀 수 있는 기본값으로 쓰려면 placeholder: props.placeholder ?? 'Enter text'처럼 전달된 값을 먼저 확인해야 합니다.

attrs의 또 다른 용도는 앞에서 말한 자주 바뀌는 값을 처리하는 것입니다. .attrs((p) => ({ style: { width: `${p.$progress}%` } }))처럼 인라인 style로 넘기면 새 클래스를 만들지 않으므로 진행률 표시줄이나 드래그 위치에 적합합니다.


SSR (Next.js)

서버 렌더링 시 스타일을 HTML에 포함하지 않으면 스타일 없는 화면이 잠깐 보였다가 JavaScript 로드 후 스타일이 입혀지는 깜빡임(FOUC)이 생깁니다. ServerStyleSheet는 렌더링 중 사용된 스타일을 모아 <style> 태그로 만들어 줍니다. 아래는 Pages Router의 _document.tsx 방식입니다.

_document.tsx

import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    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();
    }
  }
  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

enhanceApp으로 앱 전체를 sheet.collectStyles()로 감싸 렌더링하면, 그 과정에서 생성된 모든 스타일이 sheet에 모입니다. getStyleElement()로 이를 <style> 요소로 만들어 Next.js가 원래 넣는 스타일(initialProps.styles) 뒤에 붙입니다. finally의 sheet.seal()은 요청이 끝난 뒤 시트를 정리해 서버 메모리 누수를 막는 코드라 빠뜨리지 않아야 합니다.

이 설정만으로는 해결되지 않는 문제가 하나 더 있습니다. 서버와 클라이언트가 만든 클래스명이 다르면 hydration 때 Warning: Prop `className` did not match. Server: "sc-abc" Client: "sc-xyz" 경고가 나고 스타일이 어긋납니다. 클래스명이 컴포넌트 정의 순서에 따라 만들어지는데, 서버와 클라이언트 번들에서 순서가 달라질 수 있기 때문입니다. Next.js에서는 next.config.js에 compiler: { styledComponents: true }를 켜면 SWC가 파일 경로 기반의 안정적인 ID를 붙여 이 문제를 없애고, 개발 도구에서 보이는 클래스명에 컴포넌트 이름도 붙여 줍니다. 처음 SSR을 붙일 때 가장 흔히 겪는 문제가 이 경고입니다.

App Router에는 _document가 없으므로, 클라이언트 컴포넌트로 만든 스타일 레지스트리에서 useServerInsertedHTML 훅으로 ServerStyleSheet의 스타일을 스트리밍 응답에 넣고, 루트 레이아웃을 이 레지스트리로 감싸는 방식을 씁니다. Next.js 공식 문서에 이 레지스트리 예제가 있습니다.


정리 및 체크리스트

핵심 요약

  • styled-components: CSS-in-JS
  • 컴포넌트 기반: CSS와 JS 통합
  • 동적 스타일: Props 기반
  • 테마: ThemeProvider
  • 자동 Prefix: 호환성
  • SSR: 서버 사이드 렌더링

구현 체크리스트

  • styled-components 설치
  • 기본 스타일 작성
  • Props 기반 스타일
  • 상속 활용
  • Theming 구현
  • Global Styles
  • SSR 설정
  • TypeScript 타입

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Emotion과 비교하면 어떤가요?

A. styled API는 거의 같아서 서로 옮기기 쉽습니다. Emotion은 JSX 요소에 바로 스타일을 붙이는 css prop을 함께 제공하고 MUI의 기본 스타일 엔진이라는 점이 다릅니다. 두 라이브러리 모두 런타임에 스타일을 계산한다는 구조적 특성과 서버 컴포넌트 제약은 같습니다.

Q. 성능은 어떤가요?

A. 대부분의 화면에서는 체감되지 않지만, 렌더링마다 스타일을 계산하고 삽입하는 비용이 있어 컴포넌트가 아주 많은 목록이나 연속으로 바뀌는 값에서는 차이가 드러납니다. 이 경우 자주 바뀌는 값을 style이나 CSS 변수로 빼는 것이 효과가 큽니다.

Q. Tailwind CSS와 함께 사용할 수 있나요?

A. 기술적으로는 가능하고, 점진적으로 옮기는 과정에서 한동안 섞어 쓰는 팀도 있습니다. 다만 스타일 우선순위가 두 체계에 나뉘어 디버깅이 어려워지므로, 장기적으로는 한쪽으로 통일하는 것이 좋습니다.

Q. v5에서 v6로 올릴 때 주의할 점은?

A. DOM으로 전달되는 props 필터링이 사라진 것이 가장 큰 변화라, 스타일용 props를 $ 접두사로 바꾸거나 shouldForwardProp을 설정해야 합니다. @types/styled-components를 제거하고, 벤더 프리픽스 자동 추가가 기본으로 꺼진 점(enableVendorPrefixes로 켤 수 있음)도 확인합니다.