Emotion 런타임 CSS-in-JS 동작 방식

Emotion은 컴포넌트가 렌더될 때 스타일을 문자열로 직렬화하고 해시해 class 이름을 만든 뒤, 처음 보는 해시만 style 요소에 규칙으로 넣는다. 합성은 배열 순서대로 하나의 규칙으로 합쳐지므로 뒤에 쓴 스타일이 이긴다.

Seobway · · 10분

1. 문제 상황

기본 스타일과 위험 상태 스타일을 class 두 개로 나눠 두고, 위험 상태에서는 두 class를 함께 붙였다.

.danger { color: red; }
.base { color: turquoise; background-color: lightgray; }
<p class="base danger">삭제할까요?</p>

글자는 빨간색이 아니라 청록색이다. class 속성의 순서는 결과에 영향을 주지 않는다.
두 선택자의 명시도가 (0,1,0) 으로 같아서 스타일시트에 나중에 나온 .base 가 이긴다[3].
.danger 를 아래로 옮기거나 !important 를 붙이면 해결되지만, 그러면 class를 조합하는 쪽이 아니라 CSS 파일 순서를 관리하는 쪽에서 우선순위를 정해야 한다.


2. 정의

2.1 CSS-in-JS

Emotion은 스타일을 JavaScript 값으로 다룬다. 스타일은 문자열(태그드 템플릿)이나 객체로 쓸 수 있고, 변수와 함수를 그대로 쓸 수 있다[1].

import { css } from "@emotion/react";

const base = css`
  padding: 8px 16px;
  color: white;
  background: hotpink;
`;

객체 스타일은 속성 이름을 camelCase 로 쓴다. TypeScript 와 함께 쓰면 속성 이름과 값의 타입을 검사할 수 있어 Emotion 문서는 객체 스타일을 권장한다[7].

2.2 패키지 구성

패키지 제공하는 것 React 필요
@emotion/react css prop, css, ThemeProvider, Global, keyframes 예
@emotion/styled styled.button 형태의 스타일 컴포넌트 예
@emotion/css class 이름을 돌려주는 css, class를 합성하는 cx 아니오

React 프로젝트는 @emotion/react 를 기본으로 쓰고, 필요하면 @emotion/styled 를 더한다. @emotion/css 는 프레임워크와 상관없이 class 이름 문자열만 필요할 때 쓴다[1].


3. 동작 원리

Emotion 런타임 처리 과정
그림 1. css prop 의 스타일은 직렬화, 해시, 캐시 확인, 규칙 삽입을 거쳐 css-해시 class 가 된다. 이미 삽입한 해시면 삽입을 건너뛴다.

3.1 렌더할 때 class 를 만든다

1장의 두 스타일을 Emotion으로 옮기면 다음과 같다.

const base = css`
  padding: 8px 16px;
  color: white;
  background: hotpink;
`;
const danger = css`background: crimson;`;

<p css={[base, danger]}>삭제할까요?</p>

컴포넌트가 렌더되면 Emotion은 다섯 단계를 거친다.

  1. 직렬화: 배열에 든 스타일을 순서대로 이어 붙여 문자열 하나로 만든다.
  2. 해시: 그 문자열의 MurmurHash2 값을 계산한다. 결과는 1gic02d 다.
  3. 캐시 확인: 이 해시의 규칙을 이미 문서에 넣었는지 확인한다.
  4. 규칙 삽입: 처음 보는 해시면 <style data-emotion="css"> 요소에 .css-1gic02d { … } 규칙을 넣는다.
  5. class 부여: 요소의 className 을 css-1gic02d 로 정한다.

같은 내용의 스타일은 해시가 같으므로, 같은 버튼을 100개 렌더해도 규칙은 하나만 들어간다.

인터랙티브 다이어그램: css prop 하나가 class 가 되는 과정 (단계별로 재생할 수 있다)

  1. 렌더가 시작된다. css prop 에 [base, danger] 배열이 들어 있다.
  2. 배열 순서대로 이어 붙여 문자열 하나로 직렬화하고, MurmurHash2 로 1gic02d 를 얻는다.
  3. 캐시에 1gic02d 가 없다. 해시를 기록하고 style 요소에 규칙을 넣는다.
  4. 요소에 class="css-1gic02d" 를 붙인다.
  5. 같은 스타일의 두 번째 p 가 렌더된다. 해시가 같으므로 캐시에서 찾고, 삽입 없이 class 만 붙인다.

3.2 합성: 배열 뒤쪽이 이긴다

[base, danger] 를 실제로 렌더하면 두 스타일이 규칙 하나로 합쳐진다[3].

.css-1gic02d{padding:8px 16px;color:white;background:hotpink;background:crimson;}

한 규칙 안에 background 가 두 번 있으면 뒤의 선언이 적용된다. 그래서 배열 뒤쪽에 쓴 danger 가 이긴다.
1장처럼 스타일시트 순서를 신경 쓸 필요가 없다. 우선순위를 코드에 적은 배열 순서로 정한다.

프레임워크와 상관없는 @emotion/css 의 cx 도 같은 방식이다. Emotion이 만든 class 들을 받아 인자 순서대로 합친 새 class를 만든다.

import { css, cx } from "@emotion/css";

const a = css`padding: 4px; color: red;`; // "css-11885fe"
const b = css`color: blue;`;              // "css-14jcta0"

cx(a, b); // "css-p2t3w5"  → .css-p2t3w5{padding:4px;color:red;color:blue;}
cx(b, a); // "css-1eb3fbe" → .css-1eb3fbe{color:blue;padding:4px;color:red;}

cx(a, b) 는 파란색, cx(b, a) 는 빨간색이다. 인자 순서를 바꾸면 결과가 바뀐다.

3.3 css prop 을 쓰려면 JSX 변환을 바꾼다

css 는 HTML 속성이 아니다. JSX가 React의 기본 jsx 함수로 변환되면 React는 css 를 알 수 없는 속성으로 보고 그대로 출력한다.
변환 설정 없이 렌더하면 결과는 <p css="[object Object]"> 이고 스타일은 적용되지 않는다.

JSX 를 Emotion의 jsx 함수로 변환하도록 설정해야 한다. 방법은 둘이다[2].

/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

export function Alert() {
  return <p css={css`color: crimson;`}>삭제할까요?</p>;
}

TypeScript 는 tsconfig.json 의 "jsxImportSource": "@emotion/react" 로 같은 설정을 한다. @emotion/styled 와 @emotion/css 는 일반 함수 호출이라 JSX 설정이 필요 없다.

3.4 값이 바뀌면 새 규칙이 생긴다

styled 의 보간 함수는 props 를 받아 값을 정한다[4].

const Button = styled.button`
  padding: 8px 16px;
  color: ${(p) => (p.primary ? "white" : "black")};
`;

<Button primary>저장</Button>   // class="css-3swxnk"
<Button>취소</Button>           // class="css-dz4ebc"

보간 결과가 다르면 직렬화한 문자열이 다르고, 해시도 다르다. 값이 다른 경우마다 규칙이 하나씩 추가된다.
두 가지 값이면 문제가 없지만, 너비를 ${p => p.w}% 로 받아 10%, 20%, 30%, 40%, 50% 로 렌더하면 규칙이 5개 생긴다.
같은 막대를 style={{ width }} 로 렌더하면 규칙은 1개다.

Emotion 문서는 자주 바뀌거나 요소마다 다른 값은 css 나 styled 가 아니라 style 속성으로 넘기라고 권한다[7].

3.5 테마

ThemeProvider 에 넘긴 객체는 styled 의 props.theme, 또는 css prop 에 넘긴 함수의 인자로 받는다[5].

import { ThemeProvider } from "@emotion/react";

<ThemeProvider theme={{ color: { brand: "#7c3aed" } }}>
  <span css={(theme) => ({ color: theme.color.brand })}>테마 색</span>
</ThemeProvider>

렌더 결과는 .css-nd7dc1{color:#7c3aed;} 다. 테마 값은 렌더할 때 CSS 규칙에 숫자와 색으로 들어간다.
CSS 변수(var(--brand))로 바꾸는 방식과 달리, 테마 객체가 바뀌면 그 테마를 쓰는 컴포넌트가 다시 렌더되어 새 규칙을 만든다.

3.6 서버 렌더링

@emotion/react 와 @emotion/styled 만 쓰면 서버 렌더링에 추가 설정이 필요 없다. renderToString 결과에서 스타일을 쓰는 요소 바로 앞에 <style> 이 들어간다[6].

<style data-emotion="css 3swxnk">.css-3swxnk{padding:8px 16px;color:white;}</style>
<button class="css-3swxnk">저장</button>

이 방식은 스트리밍 렌더링과도 동작한다. 다만 본문 사이에 <style> 요소가 끼므로 :nth-child 같은 선택자의 계산이 달라진다.
그런 선택자를 써야 하면 @emotion/server 로 렌더 결과에서 쓰인 규칙만 뽑아 <head> 에 넣는 방식을 쓴다. 이 방식은 스트리밍과 함께 쓸 수 없다[6].


4. 예제

4.1 값마다 규칙이 쌓이는 모습

아래 예제는 막대 두 개의 너비를 슬라이더로 바꾼다. 분홍 막대는 너비를 styled 보간으로, 보라 막대는 style 속성으로 넘긴다.
슬라이더를 움직이면서 문서에 삽입된 Emotion 규칙 수를 본다.

import { useState, useEffect } from "react";
import styled from "https://esm.sh/@emotion/styled@11.14.1?deps=react@18.3.1";

// 너비가 바뀔 때마다 새 규칙이 생긴다
const BarByCss = styled.div`
  height: 12px;
  border-radius: 6px;
  background: #d26ac2;
  width: ${(p) => p.w}%;
`;

// 규칙은 하나, 너비는 style 속성으로
const BarByStyle = styled.div`
  height: 12px;
  border-radius: 6px;
  background: #7c3aed;
`;

function countRules() {
  let n = 0;
  for (const s of document.querySelectorAll("style[data-emotion]")) {
    n += s.sheet.cssRules.length;
  }
  return n;
}

export default function App() {
  const [w, setW] = useState(30);
  const [rules, setRules] = useState(0);
  useEffect(() => setRules(countRules()), [w]);

  return (
    <div style={{ display: "grid", gap: 12 }}>
      <input type="range" min="10" max="100" value={w}
        onChange={(e) => setW(Number(e.target.value))} />
      <BarByCss w={w} />
      <BarByStyle style={{ width: `${w}%` }} />
      <p>너비 {w}% · 삽입된 규칙 {rules}개</p>
    </div>
  );
}

처음에는 규칙이 2개다. 슬라이더를 처음 보는 값으로 옮길 때마다 분홍 막대의 규칙이 하나씩 늘어난다. 이미 지나간 값으로 돌아가면 캐시에 있으므로 늘지 않는다.

4.2 같은 버튼을 세 API로

/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

const button = css({
  padding: "8px 16px",
  borderRadius: 8,
  background: "#7c3aed",
  color: "white",
});

export const Save = () => <button css={button}>저장</button>;

borderRadius: 8 처럼 단위 없는 숫자는 px 로 출력된다. 객체 스타일 { padding: 8, fontSize: 14 } 는 padding:8px;font-size:14px; 가 된다.


5. 자주 하는 실수


6. 정리

Emotion은 렌더 시점에 스타일을 직렬화하고 MurmurHash2 로 해시해 css-해시 class 를 만들고, 처음 보는 해시만 <style data-emotion> 에 규칙으로 넣는다.
합성은 규칙 하나로 합쳐지므로 배열이나 cx 인자의 뒤쪽이 이긴다. 대신 스타일 계산이 브라우저에서 일어나므로 값이 자주 바뀌는 스타일은 style 속성으로 넘기고, 서버 컴포넌트에서는 쓸 수 없다.

Sass Tailwind CSS Emotion
CSS를 만드는 시점 빌드 빌드 렌더(런타임)
JavaScript 값 사용 불가 클래스 이름 매핑만 그대로 사용
우선순위 결정 스타일시트 순서 생성된 CSS 순서 합성 배열 순서
서버 컴포넌트 가능 가능 클라이언트 컴포넌트만

참고

  1. [1] Introduction — Emotion
  2. [2] The css Prop — Emotion
  3. [3] Composition — Emotion
  4. [4] Styled Components — Emotion
  5. [5] Theming — Emotion
  6. [6] Server Side Rendering — Emotion
  7. [7] Best Practices — Emotion
  8. [8] How to use CSS-in-JS libraries — Next.js

관련 글