Emotion으로 React 스타일링: CSS Prop, props 기반 스타일, 테마, SSR 설정

이 글의 핵심

Emotion의 CSS Prop과 styled API, props로 바뀌는 스타일, 테마와 TypeScript 타입 지정, 전역 스타일, Next.js에서 SSR 스타일이 깜빡이지 않게 설정하는 방법을 다룹니다.

이 글의 핵심

Emotion으로 React 컴포넌트를 스타일링하는 방법을 정리한 글입니다. Styled, CSS Prop, Theming, SSR, TypeScript를 예제로 다룹니다.

실무에서 마주치는 문제들

styled-components가 느려요

런타임 CSS-in-JS는 렌더링할 때 템플릿 문자열을 CSS로 직렬화하고, 해시를 계산해 클래스 이름을 만들고, <style> 태그에 규칙을 삽입하는 작업을 브라우저에서 수행합니다. Emotion도 같은 방식이라 오버헤드가 사라지지는 않지만, 같은 스타일은 캐시해 재사용하고 직렬화 단계가 가벼운 편이라 벤치마크에서 styled-components보다 빠르게 나오는 경우가 많습니다. 다만 차이는 대부분의 앱에서 체감되지 않는 수준이고, 스타일 계산이 렌더링마다 반복되는 구조 자체가 비용이라는 점은 두 라이브러리가 같습니다.

번들 크기가 커요

@emotion/react와 @emotion/styled는 필요한 것만 설치해 쓸 수 있고, CSS Prop만 쓴다면 @emotion/styled를 빼도 됩니다. 정확한 크기는 버전마다 다르므로 bundlephobia 같은 도구로 현재 버전을 확인하는 것이 정확합니다.

더 유연한 API가 필요해요

styled-components는 styled.button 형태로 컴포넌트를 먼저 만들어야 스타일을 입힐 수 있습니다. Emotion은 같은 styled API에 더해, 기존 JSX 요소에 바로 스타일을 붙이는 css prop을 제공합니다. 한 번만 쓰는 레이아웃 조정 때문에 이름 붙은 컴포넌트를 만들 필요가 없습니다.

한 가지 짚어 둘 점은 런타임 CSS-in-JS가 React Server Components와 잘 맞지 않는다는 것입니다. Emotion은 React Context로 테마와 캐시를 전달하므로 서버 컴포넌트에서는 쓸 수 없고, Next.js App Router에서는 Emotion을 쓰는 컴포넌트가 모두 클라이언트 컴포넌트여야 합니다. 새 프로젝트라면 Tailwind CSS나 빌드 타임에 CSS를 추출하는 Panda CSS, vanilla-extract 같은 방식도 함께 비교해 볼 만합니다. MUI처럼 Emotion 위에 만들어진 라이브러리를 쓰는 프로젝트라면 Emotion을 직접 쓰는 것이 여전히 자연스러운 선택입니다.


Emotion의 해시 클래스와 캐시 동작

핵심 특징

Emotion은 JavaScript 안에서 CSS를 작성하는 CSS-in-JS 라이브러리입니다. 주요 장점:

  • 캐시 기반 성능: 같은 스타일은 한 번만 직렬화·삽입
  • 모듈 분리: 필요한 패키지만 설치
  • 유연함: Styled + CSS Prop
  • TypeScript: 테마 타입 확장 지원
  • SSR: 서버 사이드 렌더링

Emotion이 만드는 클래스 이름은 css-1a2b3c처럼 스타일 내용의 해시입니다. 스타일이 같으면 같은 클래스가 재사용되고, props에 따라 스타일이 달라지면 새 해시와 새 규칙이 만들어집니다. 그래서 props 값이 수백 가지로 바뀌는 스타일(예: 마우스 위치에 따라 바뀌는 left 값)을 템플릿에 넣으면 규칙이 계속 쌓여 <style> 태그가 비대해집니다. 이런 값은 style 속성이나 CSS 변수로 넘기는 편이 맞습니다.


설치와 Styled API

설치

npm install @emotion/react @emotion/styled

Styled API

import styled from '@emotion/styled';
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의 태그드 템플릿은 일반 CSS 문법과 같고, &는 이 컴포넌트에 붙는 클래스 자신을 가리킵니다. Sass처럼 중첩과 가상 선택자를 쓸 수 있습니다. 여기서 만든 Button은 실제 <button>을 렌더링하는 React 컴포넌트이므로 type, disabled, onClick 같은 속성을 그대로 넘길 수 있습니다. 컴포넌트는 반드시 렌더 함수 바깥(모듈 최상위)에서 정의해야 합니다. 함수 컴포넌트 안에서 styled.button을 호출하면 렌더링마다 새로운 컴포넌트 타입이 생겨 React가 매번 DOM을 새로 만들고, 입력 필드라면 타이핑할 때마다 포커스가 풀리는 증상이 나타납니다.


css prop 설정과 사용

설정 (Vite)

css prop은 React가 원래 아는 속성이 아니므로, JSX를 변환할 때 Emotion의 jsx 함수를 쓰도록 설정해야 합니다. 설정이 없으면 css 속성이 그대로 DOM에 전달되어 요소에 css="[object Object]"가 찍히고 스타일은 적용되지 않습니다. 또는 You have tried to stringify object returned from css function 경고가 콘솔에 나옵니다.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
  plugins: [
    react({
      jsxImportSource: '@emotion/react',
    }),
  ],
});

사용

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
const buttonStyle = css`
  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 css={buttonStyle}>Click me</button>;
}

파일 맨 위의 /** @jsxImportSource @emotion/react */ 주석은 파일 단위 설정이고, 앞의 Vite 설정은 프로젝트 전체 설정입니다. 둘 중 하나만 있으면 되며, 전역 설정을 했다면 주석은 필요 없습니다. TypeScript가 css prop을 모른다고 Property 'css' does not exist 에러를 낸다면 tsconfig.json의 compilerOptions에 "jsxImportSource": "@emotion/react"를 추가해야 합니다. 빌드 설정과 타입 설정이 별개라서, 화면에는 스타일이 잘 적용되는데 에디터에만 빨간 줄이 뜨는 상황이 자주 생깁니다.

css prop은 배열도 받습니다. css={[baseStyle, isActive && activeStyle]}처럼 쓰면 뒤쪽 스타일이 앞쪽을 덮어쓰고 false는 무시되므로, 조건부 스타일을 합칠 때 편합니다.


props에 따라 스타일 바꾸기

import styled from '@emotion/styled';
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>

제네릭 <ButtonProps>로 이 컴포넌트가 받을 추가 props를 선언하면, 템플릿 안의 함수에서 props.variant에 타입이 붙습니다. 보간 함수는 렌더링마다 실행되고, 반환값이 바뀌면 새 클래스가 만들어집니다.

여기서 주의할 부분은 props가 DOM으로 전달되는 규칙입니다. Emotion은 HTML 태그를 감쌀 때 @emotion/is-prop-valid로 유효한 HTML 속성만 DOM에 넘깁니다. variant는 HTML 속성이 아니라 걸러지지만, size는 <input size>에 쓰이는 유효한 속성 이름이라 걸러지지 않고 <button size="small">로 렌더링됩니다. 당장 문제는 없어도 의도하지 않은 속성이 DOM에 남는 셈입니다. 커스텀 React 컴포넌트를 styled(MyComponent)로 감쌀 때는 이 필터가 적용되지 않아 모든 props가 그대로 전달되고, 그 컴포넌트가 다시 DOM에 넘기면 React does not recognize the fullWidth prop on a DOM element 경고가 납니다. styled('button', { shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'size' })처럼 전달할 props를 직접 정하거나, 스타일 전용 props에 $size처럼 구분되는 이름을 붙이는 규칙을 두면 이 문제를 피할 수 있습니다.


ThemeProvider로 테마 적용

ThemeProvider는 React Context로 테마 객체를 전달하고, styled 컴포넌트와 css prop의 함수 형태에서 theme으로 꺼내 쓸 수 있습니다.

import styled from '@emotion/styled';
import { ThemeProvider } from '@emotion/react';
const theme = {
  colors: {
    primary: '#3498db',
    secondary: '#2ecc71',
    text: '#333',
    background: '#fff',
  },
  spacing: {
    sm: '0.5rem',
    md: '1rem',
    lg: '2rem',
  },
};
const Button = styled.button`
  background: ${(props) => props.theme.colors.primary};
  color: white;
  padding: ${(props) => props.theme.spacing.md};
`;
export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Button>Themed Button</Button>
    </ThemeProvider>
  );
}

테마를 쓰면 색상과 간격 같은 디자인 토큰이 한곳에 모여 일관성을 지키기 쉽습니다. css prop에서는 css={(theme) => ({ color: theme.colors.text })}처럼 함수를 넘기면 됩니다. ThemeProvider 바깥에서 렌더링된 컴포넌트는 빈 객체를 테마로 받기 때문에 Cannot read properties of undefined (reading 'primary') 에러가 납니다. 테스트나 Storybook에서 컴포넌트를 단독으로 렌더링할 때 이 에러를 가장 자주 만나므로, 테스트 유틸과 Storybook Decorator에도 같은 Provider를 감싸 둡니다.

다크 모드를 테마 객체 교체로 구현하면, 테마가 바뀔 때 테마를 읽는 모든 컴포넌트의 스타일이 다시 계산되고 새 클래스가 삽입됩니다. 컴포넌트가 많으면 전환 순간이 눈에 띄게 느려질 수 있습니다. 색상 값을 CSS 변수(var(--color-primary))로 두고 테마 전환은 <html>의 클래스만 바꾸면, 스타일 재계산 없이 브라우저가 변수 값만 교체하므로 훨씬 가볍습니다.


테마와 props에 TypeScript 타입 붙이기

import '@emotion/react';
declare module '@emotion/react' {
  export interface Theme {
    colors: {
      primary: string;
      secondary: string;
      text: string;
      background: string;
    };
    spacing: {
      sm: string;
      md: string;
      lg: string;
    };
  }
}

Emotion의 Theme 타입은 기본적으로 빈 인터페이스라서, 이 선언 병합(declaration merging)이 없으면 props.theme.colors가 타입 에러가 됩니다. 이 코드는 emotion.d.ts 같은 별도 선언 파일에 두고 tsconfig의 include 범위 안에 있는지 확인합니다. 파일 첫 줄의 import '@emotion/react'가 중요한데, 이 줄이 없으면 파일이 모듈이 아닌 전역 스크립트로 취급되어 기존 타입을 확장하는 대신 새 모듈 선언으로 덮어써 버립니다. 테마 객체를 직접 쓰고 typeof theme으로 타입을 뽑아 interface Theme extends MyTheme {}로 연결하면 값과 타입을 두 번 적지 않아도 됩니다.


Global 컴포넌트로 전역 스타일

import { Global, css } from '@emotion/react';
const globalStyles = css`
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
  body {
    font-family: Arial, sans-serif;
    line-height: 1.6;
  }
`;
export default function App() {
  return (
    <>
      <Global styles={globalStyles} />
      {/* 나머지 컴포넌트 */}
    </>
  );
}

<Global>은 렌더링되어 있는 동안에만 스타일을 적용하고 언마운트되면 제거합니다. 그래서 앱 최상위에 한 번만 두는 것이 일반적이며, 특정 페이지에서만 body 스타일을 바꾸고 싶다면 그 페이지에 <Global>을 두는 방식도 가능합니다. styles에 넘기는 값이 렌더링마다 새로 만들어지면 전역 스타일이 매번 다시 삽입되므로, 예제처럼 컴포넌트 밖에서 한 번 정의해 두어야 합니다. 폰트 @font-face 선언을 <Global>에 넣으면 스타일이 다시 삽입될 때 폰트가 깜빡이는 경우가 있어, 폰트는 일반 CSS 파일로 불러오는 편이 안정적입니다.


스타일 조합(Composition)

import { css } from '@emotion/react';
const baseStyle = css`
  padding: 0.5rem 1rem;
  border-radius: 4px;
`;
const primaryStyle = css`
  ${baseStyle}
  background: #3498db;
  color: white;
`;
const secondaryStyle = css`
  ${baseStyle}
  background: #2ecc71;
  color: white;
`;
// 사용
<button css={primaryStyle}>Primary</button>
<button css={secondaryStyle}>Secondary</button>

css 결과를 다른 css 안에 보간하면 스타일이 합쳐진 새 스타일이 되고, 뒤에 쓴 속성이 앞의 같은 속성을 덮어씁니다. 이 방식은 CSS 클래스를 여러 개 붙이는 것과 다릅니다. 일반 CSS에서 class="base primary"는 두 규칙의 우선순위가 스타일시트 순서로 결정되어 예측하기 어렵지만, Emotion 합성은 하나의 클래스로 병합되므로 작성한 순서가 곧 우선순위가 됩니다. 스타일 충돌을 디버깅할 때 이 점을 알면 원인을 찾기 쉽습니다.


Next.js SSR에서 스타일 깜빡임 막기

서버에서 HTML을 렌더링할 때 스타일을 함께 보내지 않으면, 브라우저는 스타일 없는 HTML을 먼저 그렸다가 JavaScript가 실행된 뒤에야 스타일을 입힙니다. 이 순간의 깜빡임(FOUC)을 막으려면 서버 렌더링 중에 사용된 스타일을 추출해 HTML의 <head>에 넣어야 합니다. 아래 코드는 Pages Router의 _document.tsx에서 이를 처리하는 방식입니다.

_document.tsx

import React from 'react';
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';
import { CacheProvider } from '@emotion/react';
import createEmotionServer from '@emotion/server/create-instance';
import createCache from '@emotion/cache';
function createEmotionCache() {
  return createCache({ key: 'css' });
}
export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const originalRenderPage = ctx.renderPage;
    const cache = createEmotionCache();
    const { extractCriticalToChunks } = createEmotionServer(cache);
    ctx.renderPage = () =>
      originalRenderPage({
        enhanceApp: (App: any) => (props) => (
          <CacheProvider value={cache}>
            <App {...props} />
          </CacheProvider>
        ),
      });
    const initialProps = await Document.getInitialProps(ctx);
    const emotionStyles = extractCriticalToChunks(initialProps.html);
    const emotionStyleTags = emotionStyles.styles.map((style) => (
      <style
        data-emotion={`${style.key} ${style.ids.join(' ')}`}
        key={style.key}
        dangerouslySetInnerHTML={{ __html: style.css }}
      />
    ));
    return {
      ...initialProps,
      styles: [...React.Children.toArray(initialProps.styles), ...emotionStyleTags],
    };
  }
  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

흐름을 따라가 보면, 요청마다 새 Emotion 캐시를 만들고 enhanceApp으로 앱 전체를 CacheProvider로 감싼 뒤 렌더링합니다. 렌더링이 끝난 HTML을 extractCriticalToChunks에 넘기면 실제로 쓰인 스타일만 골라내고, 이를 <style data-emotion="..."> 태그로 만들어 styles에 추가합니다. 클라이언트의 Emotion은 hydration 때 data-emotion 속성을 보고 이미 삽입된 스타일을 재사용하므로 같은 규칙을 두 번 넣지 않습니다. 캐시를 요청마다 새로 만드는 이유는 서버에서 캐시를 전역으로 공유하면 요청이 쌓일수록 모든 사용자의 스타일이 섞여 메모리가 계속 늘기 때문입니다.

@emotion/server 패키지를 따로 설치해야 하고, 캐시의 key는 클라이언트 쪽 캐시와 같아야 합니다. 클라이언트에서 다른 key로 캐시를 만들면 서버가 넣은 스타일을 인식하지 못해 스타일이 중복 삽입되거나, 클래스 이름이 달라져 hydration 경고가 납니다. MUI와 함께 쓴다면 createCache({ key: 'css', prepend: true })처럼 prepend 옵션으로 Emotion 스타일을 <head> 앞쪽에 넣어야 MUI 스타일을 CSS 파일로 덮어쓸 수 있습니다.

App Router에서는 _document가 없으므로 이 방식을 쓸 수 없습니다. 클라이언트 컴포넌트로 만든 레지스트리에서 useServerInsertedHTML 훅으로 렌더링 중 삽입된 스타일을 모아 스트리밍 응답에 넣는 방식을 써야 하며, MUI라면 @mui/material-nextjs의 AppRouterCacheProvider가 이 작업을 대신해 줍니다.


variant·size를 갖는 버튼 컴포넌트 만들기

import styled from '@emotion/styled';
import { css } from '@emotion/react';
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  fullWidth?: boolean;
}
const Button = styled.button<ButtonProps>`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s;
  ${(props) => {
    const variants = {
      primary: css`
        background: #3498db;
        color: white;
        &:hover {
          background: #2980b9;
        }
      `,
      secondary: css`
        background: #2ecc71;
        color: white;
        &:hover {
          background: #27ae60;
        }
      `,
      danger: css`
        background: #e74c3c;
        color: white;
        &:hover {
          background: #c0392b;
        }
      `,
    };
    return variants[props.variant || 'primary'];
  }}
  ${(props) => {
    const sizes = {
      small: css`
        padding: 0.25rem 0.5rem;
        font-size: 0.875rem;
      `,
      medium: css`
        padding: 0.5rem 1rem;
        font-size: 1rem;
      `,
      large: css`
        padding: 0.75rem 1.5rem;
        font-size: 1.125rem;
      `,
    };
    return sizes[props.size || 'medium'];
  }}
  ${(props) =>
    props.fullWidth &&
    css`
      width: 100%;
    `}
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;
// 사용
<Button variant="primary" size="small">Small Primary</Button>
<Button variant="secondary">Medium Secondary</Button>
<Button variant="danger" size="large" fullWidth>Large Danger Full Width</Button>

variant와 size별 스타일을 객체로 매핑해 두면 새 variant를 추가할 때 조건문을 늘리지 않고 객체에 항목만 더하면 됩니다. 다만 매핑 객체를 보간 함수 안에서 만들고 있어 렌더링할 때마다 css 호출이 여섯 번 다시 실행됩니다. Emotion이 결과를 캐시하므로 치명적이지는 않지만, 매핑 객체를 컴포넌트 밖으로 빼 두면 불필요한 계산이 사라지고 읽기도 쉬워집니다. props.variant에 잘못된 값이 들어오면 undefined가 반환되어 해당 스타일이 조용히 빠지는데, TypeScript 유니언 타입이 이 실수를 컴파일 단계에서 막아 줍니다.

transition: all은 편하지만 레이아웃 속성까지 애니메이션 대상이 되어 의도하지 않은 움직임이 생길 수 있으므로, 실제 디자인 시스템에서는 transition: background-color 0.2s처럼 대상 속성을 좁히는 편이 좋습니다. 앞에서 말한 것처럼 size prop은 DOM으로 전달되므로, 이 버튼 시스템을 그대로 쓴다면 shouldForwardProp을 설정해 두는 것을 권합니다.


Emotion 사용 요약과 점검 목록

핵심 요약

  • Emotion: 런타임 CSS-in-JS
  • Styled + CSS Prop: 유연한 API
  • 캐시: 같은 스타일은 한 번만 삽입
  • TypeScript: Theme 인터페이스 확장
  • SSR: 요청별 캐시와 스타일 추출
  • App Router: 클라이언트 컴포넌트에서만 사용 가능

도입 점검 목록

  • Emotion 설치
  • Styled API 사용
  • CSS Prop 활용
  • Props 기반 스타일
  • Theming 구현
  • Global Styles
  • SSR 설정
  • TypeScript 타입

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. styled-components와 비교하면 어떤가요?

A. styled API는 거의 같아서 옮기기 쉽습니다. Emotion은 css prop과 객체 스타일을 함께 쓸 수 있고 패키지가 나뉘어 있다는 점이 다릅니다. 두 라이브러리 모두 런타임에 스타일을 계산한다는 구조적 특성은 같습니다.

Q. CSS Prop이 뭔가요?

A. 별도 컴포넌트를 만들지 않고 JSX 요소에 바로 스타일을 붙이는 기능입니다. JSX 변환 설정(jsxImportSource)이 있어야 동작합니다.

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

A. MUI v5 이상은 기본 스타일 엔진으로 Emotion을 쓰므로, MUI 테마와 Emotion 테마를 하나의 ThemeProvider로 공유할 수 있습니다. 캐시 설정(prepend)은 MUI와 맞춰 두는 것이 좋습니다.

Q. Emotion을 프로덕션에 올리기 전에 무엇을 점검해야 하나요?

A. Next.js App Router를 쓴다면 서버 컴포넌트에서 쓸 수 없다는 제약을 먼저 고려해야 하고, SSR 캐시 설정과 DOM으로 새는 props를 배포 전에 점검하는 것이 좋습니다.