
프론트엔드 개발에서 CSS를 다루는 방식은 끊임없이 달라지고 있습니다.
최근에는 React와 같은 컴포넌트 기반 아키텍처를 활용하여 개발하는 것이 주류가 되면서, 기존의 단순 CSS 방식은 아래 작성한 것처럼 여러 문제점과 한계를 나타내기 시작했습니다.
전역 네임스페이스 오염
CSS의 모든 클래스 선택자는 기본적으로 전역 범위에 속하기 때문에, 서로 다른 컴포넌트에서 같은 클래스명을 사용하면 스타일이 충돌하게 됩니다. 이를 해결하기 위해 BEM 명명 규칙 등을 활용하여 중복을 피할 방법을 찾아야만 했습니다.
코드 관리의 어려움
프로젝트 규모가 점점 커지면서 자연스럽게 .css 파일도 늘어났고, 관리 및 유지보수가 어려워지면서 파일 간 의존성이나 충돌 문제가 발생하기 쉬웠습니다.
JavaScript와의 상태 공유 어려움 (동적 스타일링의 필요성)
UI를 상태에 따라 스타일을 동적으로 변하게 하는 경우, 기존 CSS는 정적이기 때문에 클래스 이름을 조건문으로 추가/삭제 하는 등 복잡하게 제어해야 했습니다. (스타일 관련 로직이 파편화됨)
예측 불가능한 스타일 적용 순서
최근 많이 사용하는 SPA 환경에서는 성능 최적화를 위해 필요에 따라 CSS 파일을 비동기적으로 로드하는 경우가 존재합니다. 이 경우 어떤 파일이 먼저 도착할지 예상할 수 없으므로, 만약 하나의 요소에 동일한 명시도(specificity)를 가진 스타일 규칙이 적용된다면 최종적으로 적용되는 스타일을 예측할 수 없습니다.

이러한 문제점이 지속적으로 발생하자, 스타일링도 컴포넌트 단위로 관리하고자 하는 요구가 생겼고 CSS-in-JS가 등장하게 되었습니다. 이름 그대로 CSS-in-JS는 JavaScript 코드 내에서 CSS 스타일링을 작성하는 기법입니다.
컴포넌트 모듈 하나에서 스타일과 동작을 한번에 관리할 수 있다보니 유지보수가 용이하고, 컴포넌트 자체에 스타일을 지정하는 방식으로 스타일 충돌을 방지할 수 있다는 장점이 있습니다. 특히, JS 내부에서 CSS 코드를 작성할 수 있기 때문에 복잡한 동적 스타일링을 비교적 쉽게 작성할 수 있다는 점이 가장 큰 장점입니다.

그렇다고 CSS-in-JS가 장점만 존재하는 것은 아닙니다. 스타일이 런타임에 계산되고 적용되므로 초기 로딩 속도가 느리거나, 브라우저에서 스타일을 적용하는데 더 오랜 시간이 걸리기도 합니다.
일부 사람들은 '관심사 분리'라는 측면에서, 부정적인 의견을 가지고 있기도 합니다.
지금까지 HTML은 구조, CSS는 표현, JS는 동작을 담당한다는 깔끔한 구분을 해치는 건 아닐지 우려하는 목소리도 있습니다.
CSS-in-JS 방식을 채택한 다양한 라이브러리가 있지만, 이 글에서는 그 중 Emotion과 Vanilla-extract 이렇게 2개의 라이브러리만 다룰 예정입니다.

지금 크게 관련 있는 내용은 아니지만, 토스 프론트엔드 챕터 웹사이트에 접속하면 모바일 제품의 스타일에는 emotion을 사용하고 데스크탑 제품의 스타일은 vanilla-extract를 사용하고 있다는 점을 알 수 있습니다.
Emotion은 앞서 설명한 것처럼 JavaScript에 CSS 코드를 작성하기 위해 설계되었습니다. 현재 styled Components와 함께 가장 널리 사용되고 있는 CSS-in-JS 라이브러리입니다.
Emotion은 '런타임 CSS-in-JS' 방식으로 작동합니다. 애플리케이션이 브라우저에서 실행되는 동안 JavaScript가 스타일 코드를 생성하고, 고유한 클래스 이름을 생성하며, 이를 DOM에 주입합니다.

Emotion을 활용하여 스타일링을 하는 방식은 크게 2가지로 나눌 수 있습니다. 우리는 개인적인 취향이나 프로젝트 상황에 따라 그에 맞춰 자유롭게 선택하면 됩니다. 이러한 유연함이 Emotion의 장점이 되기도 합니다.
1. Styled API (컴포넌트 기반 스타일링)
스타일이 적용된 컴포넌트를 만들어서 사용하는 방식입니다. 스타일과 컴포넌트가 강력하게 결합되어 직관적이고, 재사용 가능한 UI 라이브러리를 구축할 때 유용합니다. 사실상 템플릿 리터럴을 활용하여 기존 CSS 문법을 그대로 사용하기 때문에 어렵지 않게 코드를 작성할 수 있습니다.
import styled from '@emotion/styled';
// 스타일이 적용된 컴포넌트 생성
const Button = styled.button`
color: hotpink;
background: ${props => props.primary ? 'blue' : 'white'}; // props로 동적 스타일링
`;
<Button primary>클릭</Button>
CSS-in-JS 방식의 장점이었던 동적 스타일링도 컴포넌트에 Props를 전달하여 쉽게 구현할 수 있습니다.
2. css prop 또는 css() 함수 활용
Emotion의 css() 함수를 활용하여 스타일링하는 방식입니다. css() 함수의 인자로 CSS 스타일 선언 내용을 넣어주면 됩니다.
// 문자형 스타일
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
const color = 'white'
render(
<div
css={css`
padding: 32px;
background-color: hotpink;
font-size: 24px;
border-radius: 4px;
&:hover {
color: ${color};
}
`}
>
Hover to change color.
</div>
)
// 객체형 스타일
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
const color = 'white'
render(
<div
css={css({
padding: '32px',
backgroundColor: 'hotpink',
fontSize: '24px',
borderRadius: '4px',
cursor: 'pointer',
'&:hover': {
color: `${color}`,
},
})}
>
Hover to change color.
</div>
)
위 2개의 예제처럼 css() 코드에 문자형과 객체형을 넘길 수 있는데, 가급적 객체로 선언해서 넘기는 것을 권장하고 있습니다. 이 방법을 사용하면, css() 함수 호출을 생략하고 css prop에 바로 객체를 넘길 수 있으며, 특히 타입스크립트를 활용하면 타입 체킹을 통해 버그도 줄일 수 있습니다.
// 함수 호출 생략 (객체 직접 전달)
<div css={{ color: 'red' }} />
/** @jsxImportSource @emotion/react */
import { CSSObject } from '@emotion/react';
// 타입 명시
const buttonStyle: CSSObject = {
padding: '10px 20px',
borderRadius: '5px',
border: 'none',
cursor: 'pointer',
backgroundColor: 'royalblue',
color: 'white',
'&:hover': {
opacity: 0.9
}
};
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
// css 함수로 감싸면 타입 추론 자동 적용 (가장 간단함)
const buttonStyle = css({
padding: '10px 20px',
borderRadius: '8px',
backgroundColor: 'hotpink',
color: 'white',
border: 'none',
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
});
function App() {
return <button css={buttonStyle}>클릭</button>;
}
마찬가지로 prop을 이용해서 동적 스타일링도 쉽게 구현할 수 있습니다.
아래 예시 코드는 variant라는 prop에 따라서 색이 다른 버튼을 구현하고 있습니다.
const colors = {
default: 'black',
danger: 'red',
outline: 'blue',
}
function VariableBtn({ children, variant }) {
return (
<button
css={{
border: '1px solid gray',
borderRadius: '6px',
color: colors[variant],
fontSize: '14px',
padding: '10px 16px',
cursor: 'pointer',
appearance: 'none',
userSelect: 'none',
}}
>
{children}
</button>
)
}
export default VariableBtn
import VariableBtn from 'VariableBtn'
function App() {
return (
<>
<VariableBtn variant="default">default</VariableBtn>
<VariableBtn variant="danger">danger</VariableBtn>
<VariableBtn variant="outline">outline</VariableBtn>
</>
);
}
만약 prop에 따라 바꾸고 싶은 스타일 요소가 여러 개라면 아래처럼 코드를 작성할 수도 있습니다.
const colors = {
default: "rgb(36, 41, 47)",
danger: "rgb(207, 34, 46)",
outline: "rgb(9, 105, 218)",
};
const sizeStyles = {
sm: {
fontSize: "12px",
padding: "3px 12px",
},
md: {
fontSize: "14px",
padding: "5px 16px",
},
lg: {
fontSize: "16px",
padding: "9px 20px",
},
};
function Button({ children, size = "md", variant = "default" }) {
return (
<button
css={{
borderRadius: "6px",
border: "1px solid rgba(27, 31, 36, 0.15)",
backgroundColor: "rgb(246, 248, 250)",
color: colors[variant],
fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
fontWeight: "600",
lineHeight: "20px",
...sizeStyles[size],
textAlign: "center",
cursor: "pointer",
appearance: "none",
userSelect: "none",
}}
>
{children}
</button>
);
}
export default Button;
/** @jsxImportSource @emotion/react */
리액트에서 css prop을 제대로 사용하기 위해서는 파일 상단에 JSX Pragma를 선언해야 합니다. 기본적으로 React에서 JSX는 React.creteElement로 변환되는데, Emotion은 자체적인 기능을 위해 그 대신 자신들만의 함수를 사용해야 합니다.
간단히 정리하자면, 컴파일러에게 "이 파일의 JSX를 변환할 때, React의 jsx() 함수 말고 Emotion의 jsx() 함수를 사용해" 라고 알려주는 역할을 합니다.
Emotion은 JS와 CSS가 한 파일에 통합되어 있으므로, 코드의 유지보수성을 높일 수 있고 스타일이 컴포넌트에 캡슐화되어 스타일 충돌을 줄일 수 있다는 장점이 있습니다. 뿐만 아니라 CSS-in-JS 라이브러리 중에서도 빠른 런타임 성능과, 객체 스타일 문법을 지원하기 때문에 편리합니다.
다만, 스타일을 렌더링 시점에 생성하기 때문에 웹사이트의 복잡도에 따라 런타임 성능 저하 가능성과, 패키지 용량 증가로 인한 초기 로딩 속도 저하 등의 단점 또한 존재합니다.

Vanilla Extract와 앞서 알아본 Emotion의 가장 큰 차이점은 Vanilla Extract는 'Zero-runtime CSS-in-JS'라는 점입니다. 우리가 Emotion을 다루며 계속 언급했던 런타임에 스타일을 생성하고 주입함으로써 파생되는 문제점들을 해결하기 위해 등장했습니다.
Vanilla Extract는 스타일 과정을 JS 또는 TS에서 작성할 수 있게 해주면서도, 스타일이 런타임이 아닌 빌드 타임에 CSS 파일로 컴파일 됩니다. 따라서 런타임에 추가적인 작업 없이 순수 CSS 파일로 컴파일하는 방식을 제공하기 때문에, 성능 개선 및 초기 로드 시간 단축이 가능해집니다.
추가로 Vanilla Extract는 타입스트립트 기반으로 type-safe하게 CSS 스타일을 작성할 수 있습니다.
.css.ts ?Vanilla Extract의 가장 큰 특징은 .css.ts (.css.js) 라는 특이한 별도의 파일 확장자를 사용한다는 점입니다. 이 파일들을 빌드 과정에서 실행되어 정적인 CSS 파일로 변환되고, 실제 JS 번들에는 생성된 CSS 클래스 이름만 남게 됩니다.
// Button.css.ts
import { style } from '@vanilla-extract/css';
// 빌드 시 고유한 해시 클래스 이름(예: Button_container__1hioa8b0)이 생성됩니다.
export const container = style({
padding: '12px 20px',
borderRadius: '8px',
backgroundColor: 'hotpink',
color: 'white',
border: 'none',
cursor: 'pointer',
':hover': {
opacity: 0.8
}
});
// Button.tsx
import * as styles from './Button.css';
function Button() {
return <button className={styles.container}>클릭</button>;
}
스타일 객체를 배열 형태로도 만들 수 있으므로, 재사용도 비교적 용이합니다.
export const testStyle = style({
display: 'flex',
flexDirection: 'column'
})
export const mergeStyle = style([
testStyle, {
justifyContent: 'space-around',
gap: '0px 8px'
}
])
styleVariants (동적 스타일링)styleVariants를 제공합니다.// Button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
const base = style({
padding: '12px 20px',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
});
// variant에 따른 색상 조합을 미리 정의합니다.
export const variants = styleVariants({
primary: [base, { backgroundColor: 'blue', color: 'white' }],
secondary: [base, { backgroundColor: 'gray', color: 'black' }],
danger: [base, { backgroundColor: 'red', color: 'white' }],
});
// App.tsx
import { variants } from './Button.css';
function App() {
return (
<>
<button className={variants.primary}>Primary</button>
<button className={variants.danger}>Danger</button>
</>
)
}
이렇게 동적 스타일링을 할 때, Recipes 라이브러리를 활용하면 여러개의 변형을 하나의 객체에서 정의하여 사용할 수 있습니다. 아래 예시를 보면, 나머지는 위 코드와 유사하지만 compoundVariant에서 color가 danger, size가 large일 때만 border 스타일링을 하고 있습니다.
이처럼 2가지 이상의 조건이 겹칠 때 특수한 스타일을 기존 styleVariants만 활용해서 구현하려면 복잡하지만, recipe을 사용하면 비교적 쉽게 구현할 수 있습니다. 또한 recipe에서는 기본값을 설정할 수 있어서, 아무런 옵션을 넘기지 않았을 때 기본 스타일도 관리할 수 있습니다.
import { recipe } from '@vanilla-extract/recipes';
export const buttonRecipe = recipe({
// 기본 스타일
base: {
border: 'none',
borderRadius: '8px',
},
// 변형 가능한 스타일 조합
variants: {
color: {
primary: { backgroundColor: 'blue', color: 'white' },
secondary: { backgroundColor: 'gray', color: 'black' },
danger: { backgroundColor: 'red', color: 'white' }
},
size: {
small: { padding: '8px 12px', fontSize: '14px' },
medium: { padding: '12px 20px', fontSize: '16px' },
large: { padding: '16px 24px', fontSize: '20px' }
}
},
// 특정 조합에서만 적용되는 특수 스타일
compoundVariants: [
{
variants: { color: 'danger', size: 'large' },
style: { border: '2px solid darkred' }
}
],
// 기본값 설정
defaultVariants: {
color: 'primary',
size: 'medium'
}
});
3. createTheme 활용 테마 생성
createTheme을 활용하면 공유할 디자인 토큰을 CSS 변수 세트로 만들 수 있습니다.
const [themeClass, vars] = createTheme({ ... });
createTheme은 위와 같은 형태로 사용합니다.
첫 번째 반환 값인 themeClass는 정의한 테마 변수들의 범위를 지정하는 CSS 클래스 이름입니다. 이 클래스를 특정 태그에 적용하면, 그 하위 요소들은 정의된 CSS 변수 (vars)를 사용할 수 있습니다.
두 번째 반환 값인 vars는 실제 스타일 정의에서 사용하는 변수 객체입니다.
// theme.css.ts
import { createTheme } from '@vanilla-extract/css';
export const [themeClass, vars] = createTheme({
color: {
brand: 'blue',
text: '#333',
background: '#fff'
},
space: {
small: '8px',
medium: '16px',
large: '24px'
}
});
// Box.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css';
export const box = style({
backgroundColor: vars.color.brand,
padding: vars.space.medium,
});
// App.tsx
import { themeClass } from './theme.css';
import { box } from './Box.css';
function App() {
return (
<div className={themeClass}>
<div className={box}>테마가 적용된 박스</div>
</div>
);
}
Vanilla Extract는 빌드 시점에 CSS 파일로 컴파일되므로, 런타임에 스타일을 생성하는 기존 CSS-in-JS 라이브러리와 비교하여 성능 저하가 거의 없습니다. 뿐만 아니라 타입스크립트 기반이기 때문에 타입 안정성이 보장되어 컴파일 시점에 미리 오류를 잡아낼 수 있다는 장점을 가집니다.
그러나 런타임에 스타일이 동적으로 변경되는 동적 스타일링에는 일부 제약이 있을 수 있어 단점 또한 존재합니다.
이번 글에서는 CSS-in-JS의 탄생 배경, 그리고 Runtime CSS-in-JS 라이브러리인 Emotion과 Zero-Runtime CSS-in-JS 라이브러리인 Vanilla Extract의 기본적인 개념과 기초 사용법을 다뤘습니다.
이 글에서 다루지 않은 내용도 많고 더 좋은 라이브러리에 대한 정답은 없기 때문에, 프로젝트의 규모와 요구사항, 개인의 선호도 등을 고려하여 가장 적합한 것을 선택하면 좋을 것 같습니다!
Emotion과 Vanilla-Extract에 대해 잘 정리해주신것 같아요. emotion은 어떻게 동작하는지도 모르고 사용했었고 Vanilla-Extract는 zero-runtime이라고는 듣기만 했고 그냥 사용만 했던것 같아요. 이 아티클을 읽고 emotion은 런타임에 적용이 되고, Vanilla-Extract는 빌드 타임에 적용이 된다는점과 각 css라이브러리의 사용법을 알아갈 수 있어서 좋네요. 덕분에 나중에 CSS라이브러리를 선택할 때 명확한 기준을 가지고 선택할 수 있을것 같아요. 감사합니다! 잘 봤어요!!
이전에는 CSS in JS 방식에 대해서 "존재" 정도만 알고 있었지만 이번 아티클을 통해 배경부터 각 라이브러리에 대한 설명을 통해 어떤 장점이 있는지 알 수 있었습니다. 그리고 테일윈드와 다르게 스타일 코드가 블록이나 별도 .ts, / .js 파일에 정의되어 구조에 대한 가독성 향상과 캡슐화를 통해 안전한 코드 관리를 할 수 있다는 장점이 있어 다음 프로젝트나 학습에 있어서 여러 선택지를 고려하게 된 거 같습니다. 좋은 아티클 공유 감사합니다!!
저는 이번 과제를 진행하면서 스타일링 라이브러리로 Vanilla Extract를 처음 사용해봤습니다 ㅎㅎ
사실 특별한 이유가 있었던 건 아니고, 지난 기수에서 유행했던 CSS 라이브러리라고 해서 그냥 한 번 써본 게 계기였어요... 그래서인지 과제를 진행할 때 솔직히 기존의 CSS나 Emotion 대비해서 “와, 이게 훨씬 좋다!” 라는 느낌을 크게 받지는 못했습니다;; 😅
이번 글을 읽으면서, Vanilla Extract가 단순히 CSS-in-JS의 또 다른 버전이 아니라 “Zero-runtime CSS-in-JS” 라는 걸 알게 되었어요! 덕분에, 스타일이 런타임에 계산되는 게 아니라 빌드 시점에 CSS로 미리 컴파일된다는 점이 가장 큰 차별점이라는 걸 이해하게 되었습니다 ㅎ.ㅎ
뭔가 읽고 나니 갑자기 초기 로딩 속도가 빨라지고, 런타임 성능 저하가 거의 없는 게 느껴지는 기분...
또! 이번 글을 통해 createTheme을 활용하면 디자인 토큰 전체를 세트로 관리할 수도 있다는 걸 알게 되었어요.
현재는 색상 같은 공통 변수를 vars로만 관리하고 있었는데, 나중에 앱잼이나 규모 있는 프로젝트에서 Vanilla Extract를 사용한다면 space, font, theme까지 같이 정리해보면 좋을 것 같네요!!!
궁금한 점은 Vanilla Extract는 빌드 타임에 스타일이 정해진다고 하던데, 그럼 런타임에서 다크 모드 전환처럼 사용자 설정에 따라 실시간으로 스타일을 바꾸는 경우는 어떻게 처리하는 게 좋을까요?
Emotion처럼 상태에 따라 스타일을 바꾸는 게 어렵다면, 이런 상황에서 Vanilla Extract로 가능한 범위가 어디까지인지 궁금하네요!!
CSS-in-JS의 탄생 배경부터 런타임과 제로-런타임의 차이점까지, 정말 명확하게 정리해주셔서 감사합니다. CSS-in-JS의 런타임 성능에 대한 생각만 가지고 있었는데 이 글을 읽고 Vanilla Extract 같은 제로-런타임 방식이 주목받은 이유를 알게되었습니다. 그리고 각 방식의 장단점과 코드 예시까지 꼼꼼히 짚어주셔서 도움이 되었습니다.
특히 토스가 모바일(Emotion)과 데스크톱(Vanilla Extract)에서 다른 전략을 취하는 실제 사례를 짚어주신 부분이 기억에 남습니다. 모바일 환경에서는 동적 스타일링을 우선시 하기 때문에 Emotion을 사용하고, 데스크톱 환경에서는 강력한 빌드 타임 성능이 중요해서 Vanilla Extract을 썼다고 생각합니다. 실제 기업의 사례로 보니 훨씬 더 와닿고 이해가 잘되었습니당. 감사합니다!!
라이브러리 사용하면서 왜 특정 라이브러리 사용하는지에 대해 명확히 알지 못했는데 CSS-in-JS 배경부터 Emotion, Vanilla Extract까지 깔끔히 정리되어있어 라이브러리를 왜 사용해야 하는지에 대한 이유를 알게 되었습니다. 특히 요즘 기술이니까가 아니라 해결하고자 하는 문제가 무엇인지에 대한 설명도 있어 이해하기 좋았습니다. 마지막으로 Emotion과 Vanilla Extract의 가장 큰 차이인 스타일 생성 시점의 장단점이 대비되어 있어 프로젝트의 요구사항(초기 로딩 성능 vs 런타임 동적 유연성)에 따라 어떤 라이브러리를 선택해야 할지 기준이 생긴 것 같습니다.