본문으로 건너뛰기

효과적인 Emotion(CSS in JS) 사용을 위한 6가지 방법

· 약 34분
Mitchell

현대의 웹을 아름답게 만들어 내는데 있어 CSS는 필수적인 요소입니다. 그러나 Vanilla CSS(순수 CSS 자체)가 가지고 있는 명확한 한계점이 존재합니다. 이에 따라 CSS를 효과적으로 사용하기 위한 도구들이 생겨났는데요. CSS 문법을 확장한 Sass와 같은 preprocessor, CSS도 JavaScript로 관리하겠다는 CSS in JS, 그러한 CSS in JS의 단점을 극복하면서 떠오르는 Zero-runtime CSS 등 이처럼 CSS를 효과적으로 작성하기 위한 선택지에는 여러가지가 있습니다.

특히 CSS in JS는 Trade-off가 확실한 CSS 작성 방법입니다. JavaScript로 함께 작성하면서 DX(Developer Experiece)에 이점을 취할 순 있지만, 런타임에서 스타일이 생성되어야 한다는 태생적인 한계 때문에 성능은 다른 선택지에 비해 확실히 좋지 않습니다.

그럼에도 CSS in JS를 사용하기로 결정했다면, 그 장점은 살리되 단점은 최소화하는 방법으로 접근해야합니다. 따라서 이 글은 최종적으로 어떻게 하면 더 효과적으로 사용할 수 있을지 Emotion(CSS in JS 라이브러리 중 하나) 사용을 중심으로 6가지 방법을 제안합니다.

긴 글을 읽을 시간이 없다면 효과적인 Emotion(CSS in JS) 사용을 위한 6가지 방법으로 바로 이동하세요.


정보

이 글에서는 Emotion 사용을 중심으로 설명합니다.

CSS in JS 장단점

장점은 살리고, 단점을 줄이기 위해서 장점과 단점에 대해서 먼저 살펴보아야 합니다. 장점은 우리가 왜 CSS in JS를 써야하는지에 대한 특징이며, 단점은 우리가 극복해야할 측면이기 때문입니다.

장점

  1. CSS는 기본적으로 전역으로 관리됩니다. 따라서 임의의 시점에 작성한 스타일에 대해 classname이 겹치거나 또는 작성된 스타일을 잊어버릴 수 있습니다. 이 때 의도치 않게 기존의 스타일을 덮어씌워서 스타일을 깨뜨려버리거나 같은 코드를 재작성하게 되면서 많은 코드의 중복이 발생할 수 있습니다. 그러나 Emotion에서는 CSS가 컴포넌트 레벨에서 관리되기 때문에 위와 같은 문제를 회피할 수 있습니다.
  2. 여러 브라우저에 대응할 수 있도도록 Vendor prefix를 자동으로 붙여줍니다.
  3. 컴포넌트와 같은 위치에 CSS를 작성할 수 있습니다. 일반적인 CSS는 별도의 파일로 분리되어야 합니다. 관련 있는 코드들을 가능한 가깝게 두는 것이 유지보수 관점에서 좋기 때문에 이 또한 Emotion의 장점이 될 수 있습니다. (Colocation)
  4. CSS를 JavaScript를 작성하는 것 처럼 작성할 수 있습니다. Vanilla CSS에서는 함수처럼 파라미터를 통해 스타일을 작성할 수 없으나, Emotion에서는 JavaScript 변수들을 활용하여 CSS를 완성할 수 있습니다.

단점

  1. Emotion은 결국 런타임에 CSS를 만듭니다. Vanilla CSS는 작성된 파일을 받았다면 그저 사용하기만 하면 됩니다. 그러나 Emotion은 컴포넌트의 실행과 함께 CSS를 생성하기 때문에 런타임에 오버헤드가 발생합니다. 결국 Vanilla CSS보다 렌더링이 늦을 수 밖에 없습니다.
  2. CSS in JS는 라이브러리에 의존적입니다. 따라서 라이브러리의 크기만큼 자연스럽게 번들사이즈는 커지게 됩니다.

Emotion은 어떤 과정으로 CSS를 생성하는가?

단점을 극복하기 위한 실마리를 찾기 위해서 Emotion이 어떤 방식으로 CSS을 생성하고 삽입하는지에 대해 이해해야 합니다. 이에 앞서 브라우저의 렌더링 과정을 살펴보면서 Emotion이 Vanilla CSS에 비해 어떤 지점에서 오버헤드가 발생하는지 살펴보겠습니다.

브라우저의 렌더링 과정 (React 기준)

  1. DOM (Document Object Model): 브라우저는 HTML을 파싱하여 DOM 트리를 구성합니다.
  2. CSSOM (CSS Object Model): CSS도 파싱하여 CSSOM 트리를 구성합니다.
  3. JavsScript 실행: 이 과정에서 Emotion과 React를 초기화 합니다.
  4. React 렌더: React Component를 렌더하고 가상 DOM을 생성합니다. Emotion 사용하는 컴포넌트는 여기에서 스타일을 생성합니다.
  5. Emotion 처리: 생성된 스타일을 CSS <style>태그에 주입합니다.
  6. DOM, CSSOM 업데이트: 3~5번 결과에 따라 DOM과 CSSOM을 업데이트 합니다.
  7. Render Tree: DOM과 CSSOM이 결합되어 렌더 트리를 형성합니다. 이 트리는 화면에 표시될 요소들을 포함합니다.
  8. Layout: 렌더 트리의 각 노드에 대해 화면상의 정확한 위치와 크기를 계산합니다.
  9. Paint: 각 요소의 픽셀을 화면에 그립니다.
  10. Composite: 여러 레이어를 합성하여 최종 이미지를 생성합니다.

위 과정 중 4번과 5번이 Emotion에서 발생하는 오버헤드입니다. 이 과정에서 스타일이 생성되고 삽입됩니다.

Emotion의 스타일 생성 및 삽입 원리

정보

Emotion의 상세한 동작원리를 이해하려면 'Emotion이 CSS를 생성하는 방법'을 살펴보세요.

그렇다면 브라우저 렌더링 과정 중 4번과 5번을 최대한 빠르게 처리하는 것이 곧 Emotion의 성능을 최대로 가져갈 수 있는 방법입니다. 간략히 정리하자면 Emotion은 아래와 같은 과정을 거쳐서 스타일을 생성하고 삽입하게 됩니다.

  1. @emotion/react에 의한 JSX 트랜스파일링
  2. 브라우저에서 React 렌더시작
  3. Emotion cache 생성
  4. cssProp에 전달된 스타일 직렬화
  5. 직렬화된 스타일을 캐시에 등록 및 삽입
  6. stylis로 스타일을 CSSRule로 컴파일
  7. 컴파일된 CSSrule을 HTML문서에 삽입

효율적인 스타일 생성을 위해 고려해야 하는 것

고려하지 않아도 되는 지점

  • 1번은 빌드타임에서 발생하기 때문에 런타임에서의 성능만 고려할 때 관심사가 아닙니다.
  • 2번의 React Component를 효율적으로 렌더하는 것은 Emotion 스타일 생성 측면에서 관심사가 아닙니다.
  • 3번은 캐시는 효율적인 스타일 생성을 위해 필수적인 과정입니다.

4번에서 7번의 과정에서부터 런타임에서 스타일을 직렬화, 컴파일, 삽입이 발생합니다. 여기에서 목표로 두어야하는 것은 우리가 Emotion으로 스타일을 작성하는 다양한 방법 중 최대한 런타임 성능이 빠른 방법을 발견해내는 것입니다.

4번에서 7번의 과정은 캐시에 의해서 간소화 되거나 생략될 수 있습니다. 하지만 'Emotion이 CSS를 생성하는 방법'에서 설명되어 있듯 결국 모든 컴포넌트는 다음의 과정을 거치게 됩니다.

  1. 스타일 직렬화: CSS를 Emotion에서 처리할 수 있는 형태로 직렬화합니다.
  2. <Insertion /> 렌더: 캐시에 등록하고 문서에 스타일을 삽입합니다.
  3. <WrappedComponent \> 렌더: 원래의 컴포넌트를 렌더합니다.

2번과 3번의 캐시 등록과 삽입 그리고 컴포넌트의 렌더링은 CSS를 어떤 방식으로 작성하는지와 관계 없이 Emotion의 컴포넌트에서는 무조건 실행되는 부분입니다. 결국 우리가 효율적을 만들 수 있는 부분은 1번의 스타일 직렬화 하나로 좁혀지게 됩니다.


효과적인 Emotion 사용을 위한 6가지 방법

이제부터 스타일 직렬화를 최대한 덜 발생키시면서도, Emotion의 DX를 살려서 스타일을 작성할 방법들에 대해서 소개하겠습니다. 또한 각 방법이 가지는 장점과 코드 작성예시 그리고 실제로 방법 간의 성능을 측정하고 비교하여 해당 방법에 대해서 검증하겠습니다.

정보

각 방법의 성능측정은 React.Profile 컴포넌트로 측정한 baseDuration을 기준으로 하며 측정 단위는 ms입니다.


1. 스타일을 컴포넌트 외부에 정의하기

스타일 코드를 컴포넌트 내부의 태그 안에 두는 것이 아니라 별도의 외부 변수에 저장하고 가져다 쓰는 방법입니다.

  • 같은 스타일을 사용하는 컴포넌트에서 재사용 가능합니다.
  • 컴포넌트 내에서 가독성의 이점을 챙길 수 있습니다.
  • 여러 컴포넌트에서 사용해도 스타일 직렬화는 단 한번만 발생하기 때문에 더 나은 성능을 기대할 수 있습니다.

아래의 코드예시는 12개의 토글버튼을 렌더링합니다.

export function Insides() {
return (
<Layout>
{Array.from({ length: 12 }).map((_, idx) => (
<label
css={{
display: 'inline-flex',

"& input[type='checkbox']": {
cursor: 'pointer',
position: 'relative',
appearance: 'none',
width: '48px',
height: '28px',
backgroundColor: 'lightgray',
borderRadius: '16px',
transition: 'background-color 0.2s ease-in-out',
overflow: 'hidden',

'&::before': {
content: '""',
position: 'absolute',
width: '20px',
height: '20px',
backgroundColor: 'gray',
borderRadius: '50%',
left: '4px',
top: '4px',
marginBlock: 'auto',
transition: 'transform 0.2s ease',
boxShadow: '-2px 2px 10px 0px rgba(0, 0, 0, 0.35)',
},

'&::after': {
content: '""',
position: 'absolute',
top: '0',
right: '0',
bottom: '0',
left: '0',
backgroundColor: 'transparent',
transition: 'background-color 0.2s ease-in-out',
},

'&:active': {
'&::after': {
backgroundColor: 'white',
},
},
},

"& input[type='checkbox']:checked": {
backgroundColor: 'pink',

'&::before': {
backgroundColor: 'white',
transform: 'translateX(100%)',
},
},
}}
key={idx}
>
<input type='checkbox' />
</label>
))}
</Layout>
)
}

위 코드를 기준으로 측정하였을 때, 외부에 정의하는 방법이 내부에 정의하는 방법보다 평균 89.96% 빠르게 렌더링 되는 것을 확인할 수 있습니다.
성능비교 직접보기

AttemptInsideOutside
10.500.10
20.600.00
30.400.10
40.300.10
50.500.00
Avg.0.460.06

2. Styled 보다는 CssProp을 사용하기

@emotion/styledstyled@emotion/reactcss를 모두 Emotion의 스타일을 생성할 수 있는 방법이지만 주로 css를 사용하는 것이 좋습니다.

  • styled보다는 css에서 작성해야할 보일러플레이트 코드가 적습니다.
  • styled에서는 동적스타일링에서 Props로 전달해야하는 부분에서 추가적인 오버헤드가 있습니다.
  • css에서는 스타일 관련된 코드만 재사용 가능하며, React.memo, useMemo 등과 함께 사용하여 스타일 재계산을 방지할 수 있습니다.
  • css로 스타일 작성시 렌더링이 더 빠릅니다.

1번과 동일하게 12개의 토글버튼 렌더링하는 코드로 측정하였습니다. styled와 cssProp으로 Object 형식으로 스타일을 작성하는 방식과 Tagged Template Literal 형식으로 작성했을 때로 나누어 각각의 성능을 비교합니다.

Object 스타일로 작성시 비교
cssstyled 보다 평균 66.67% 더 빠르게 렌더링합니다.
성능비교 직접보기

AttemptStyledCssProp
10.500.10
20.300.00
30.200.10
40.400.30
50.400.10
Avg.0.360.12

Template Litral로 작성시 비교
cssstyled 보다 평균 50% 더 빠르게 렌더링합니다.
성능비교 직접보기

AttemptStyledCssProp
10.100.10
20.200.20
30.500.00
40.000.10
50.200.10
Avg.0.200.10

3. Template Literal 보다는 Object로 정의하기

Emotion에서는 css`color: blue;`와 같은 Tagged template literal 형식과 css({ color: 'blue' })와 같은 Object 형식을 모두 지원하지만, Object 형식으로 사용하는 것을 다음과 같은 이유로 추천합니다.

  • Template literal로 작성하면 string으로 작성된 스타일에 대한 minify가 이루어지지 않아 번들사이즈를 줄이는데 적절하지 않다.
  • Template literal로 작성하면 각 스타일 속성마다 '세미콜론'을 붙여줘야하는데, 에러를 코드단에서 잡을 수 없어 런타임 에러 발생위험이 있다.
  • Object로 작성하면 TypeScript와 함께 사용하면 타입체킹과 인텔리센스를 통해 버그를 미리 방지하면서도 생산성을 높일 수 있다.
  • Object로 작성하면 스타일의 일부를 미리 정의된 스타일과 조합하거나 조건부로 조합하는 등 유연하고 확장성 있게 스타일을 작성할 수 있다.
  • 두 방식의 성능상의 차이는 거의 없지만 Object 아주 조금 빠르다.

아래의 코드예시 또한 1번과 같은 12개 토글버튼을 렌더링합니다.

const literalCss = css`
display: inline-flex;

& input[type='checkbox'] {
cursor: pointer;
position: relative;
appearance: none;
width: 48px;
height: 28px;
background-color: lightgray;
border-radius: 16px;
transition: background-color 0.2s ease-in-out;
overflow: hidden;

&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
background-color: gray;
border-radius: 50%;
left: 4px;
top: 4px;
margin-block: auto;
transition: transform 0.2s ease;
box-shadow: -2px 2px 10px 0px rgba(0, 0, 0, 0.35);
}

/* hover, active content */
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: transparent;
transition: background-color 0.2s ease-in-out;
}

&:active {
&::after {
background-color: white;
}
}
}

& input[type='checkbox']:checked {
background-color: pink;

&::before {
background-color: white;
transform: translateX(100%);
}
}
`

export function ToggleLiteral() {
return (
<Layout>
{Array.from({ length: 12 }).map((_, idx) => (
<label css={literalCss} key={idx}>
<input type='checkbox' />
</label>
))}
</Layout>
)
}

30회 정도 실시한 결과, Object 형식으로 스타일을 정의하는 코드의 렌더링 속도가 Literal 형식에 비해 0.01ms 차이로 거의 성능차이는 없습니다.
성능비교 직접보기

AttemptLiteralObject
10.100.20
20.100.20
30.000.10
.........
300.200.10
Avg.0.080.07

4. 하나의 컴포넌트 안에서는 className을 사용하기

스타일이 적용되야 하는 모든 태그에 마다 cssProp을 적용하기 보다는 하나의 컴포넌트에서는 최상위 부모태그에 하나의 cssProp을 적용하고, 하위 태그들에는 className을 추가하여 스타일을 적용하는 것이 좋습니다.

  • 관련된 스타일 코드들의 응집도를 높일 수 있습니다.
  • 쪼개면 쪼갠 만큼 스타일 직렬화가 발생해야 하기 때문에 하나의 cssProp이 있는 편이 더 빠릅니다.

아래 코드예시는 정보를 간단히 나열하는 컴포넌트입니다.

const info = [
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
]

const ulCss = css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})

const liCss = css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})

const headCss = css({
fontWeight: 'bold',
})

const descCss = css({
color: 'gray',
})

export function SeparateInfo() {
return (
<ul css={ulCss}>
{info.map((data) => (
<li key={data.head} css={liCss}>
<span css={headCss}>{data.head}</span>
<p css={descCss}>{data.desc}</p>
</li>
))}
</ul>
)
}

각 태그 마다 cssProp을 별도로 주는 것 보다 className을 활용하는 방식이 평균 60% 더 빠릅니다.

AttemptSeparateClassName
10.400.10
20.100.00
30.300.10
40.200.00
50.000.20
Avg.0.200.08

5. 동적 스타일링에서는 Css Variable이나 className을 사용하기

동적 스타일링을 두가지 측면에서 분리해서 생각해 볼 수 있습니다.

01 애니메이션이나 트랜지션 등 UI 자체가 동적으로 변경될 여지가 많은 경우
Emotion의 Best Practice에서 설명된 것 처럼 style prop과 css variable을 활용하여 동적으로 처리되어야 하는 부분을 더 효율적으로 다룰 수 있습니다.

02 컴포넌트 사용자로부터 전달받는 Props 값에 따라 스타일이 변경되야 하는 경우
사용자에 의해서 컴포넌트 스타일이 완성되는 경우이지만, 컴포넌트가 렌더링된 후 동적으로 바뀌는 상황은 아닙니다. 가능하다면 컴포넌트의 Variation을 미리 정의하고 className으로 그 속성을 부여하는 방식으로 접근한다면 더 효율적으로 렌더링 할 수 있을 것입니다. 더불어 아래 예제와 같이 간단한 color 적용에서도 빠르지만, 다양한 속성을 변경해야하는 경우에서 더 빛을 발하게 됩니다.

4번과 같이 아래 코드예시는 정보를 간단히 나열하는 컴포넌트이지만, color prop에 따라 red, green, blue 색상을 적용할 수 있도록 하였습니다.

const info = [
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
{ head: '이름', desc: '홍길동' },
{ head: '닉네임', desc: '홍길동과아버지' },
{ head: '전화번호', desc: '02-8282-8282' },
{ head: '성별', desc: '남성' },
]

const infoSeparateCss = css({
display: 'flex',
flexDirection: 'column',
gap: '8px',

'& .infoItem': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',

'& .head': {
fontWeight: 'bold',
},

'& .desc': {
color: 'gray',
},
},
})

function DynamicInfo({ color }: { color: 'red' | 'green' | 'blue' }) {
return (
<ul css={infoSeparateCss}>
{info.map((data) => (
<li key={data.head} className='infoItem' css={{ color }}>
<span className='head'>{data.head}</span>
<p className='desc'>{data.desc}</p>
</li>
))}
</ul>
)
}

export function DynamicInfos() {
return (
<div>
{Array.from({ length: 3 }).map((_, idx) => (
<>
<DynamicInfo color='red' key={`red-${idx}`} />
<DynamicInfo color='green' key={`blue-${idx}`} />
<DynamicInfo color='blue' key={`green-${idx}`} />
</>
))}
</div>
)
}

className으로 적용하였을 때가 동적 스타일링으로 적용할 때 보다 평균 62.5% 더 빠릅니다.

AttemptDynamicClassName
10.400.10
20.800.20
30.500.20
40.400.20
50.300.20
Avg.0.480.18

6. Theme보다는 JavaScript 상수와 Css Variable 사용하기

Light와 Dark 테마에 대응하기 위해 Emotion에서 제공하는 ThemeProvider, withTheme, useTheme을 사용하는 것 보다는 var(--color-bg)와 같이 Css Variable과 JavaScript로 미리 정의한 상수들을 사용하는 것이 좋다.

  • Theme을 위한 별도의 타입확장을 안해도 된다.
  • Theme 적용을 위한 보일러플레이트 코드들이 모두 필요가 없다. (ThemeProvider 랩핑, cssProp안에서 함수로 theme 전달하기 등)
  • ThemeProvider로 적용시 theme이 적용되야 하는 스타일 코드들은 매 컴포넌트 렌더마다 재계산 되어야 한다.
  • 따라서 JavaScript 상수로 적용시 성능이 더 빠르다.

아래 코드예시는 다양한 색상을 적용해야 하는 아티클 UI입니다. 예시에서는 하나의 테마인 경우만 가정하였습니다. (여러 테마로 해야할 시 각 테마별 색상을 정의하거나 그에 맞는 Css Var를 정의해야합니다.)

import { Theme, ThemeProvider, css } from '@emotion/react'

declare module '@emotion/react' {
export interface Theme {
color: {
primary: string
secondary: string
success: string
info: string
warn: string
border: string
}
}
}

const theme = {
color: {
primary: 'hotpink',
secondary: 'pink',
success: 'green',
info: 'skyblue',
warn: 'orange',
border: 'lightgray',
},
}

const withTheme = ({ color }: Theme) =>
css({
padding: '16px',
border: `1px solid ${color.border}`,

h3: {
fontSize: '20px',
fontWeight: 'bold',
marginBottom: '8px',
color: color.primary,
},
p: {
color: color.secondary,
marginBottom: '8px',
},
'& .actions': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
button: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingBlock: '8px',
color: 'white',
},
'& .cancel': {
background: color.warn,
},
'& .save': {
background: color.success,
},
},
})

export function WithTheme() {
return (
<ThemeProvider theme={theme}>
{Array.from({ length: 10 }).map((_, idx) => (
<div css={(emotionTheme) => withTheme(emotionTheme)} key={idx}>
<h3>Performance</h3>
<p>
orem Ipsum is simply dummy text of the printing and typesetting
industry...
</p>
<div className='actions'>
<button className='cancel'>Cancel</button>
<button className='save'>Save</button>
</div>
</div>
))}
</ThemeProvider>
)
}

JavaScript 상수를 이용해 색상을 입혔을 때가 평균 55.56% 더 빠릅니다.

AttemptThemeConstants
10.300.10
20.300.10
30.400.20
40.200.10
50.400.20
Avg.0.320.14


정리

성능과 관련된 도전을 계속 받고 있는 CSS in JS이지만, 뛰어난 개발자 경험과 생산성으로 아직 많은 사람들에게 사랑을 받고 있는 것도 사실입니다. 결국 개발자 경험을 위해 Emotion을 사용할 때 더 나은 사용법이 무엇인지 고민하게 됩니다.

위에서 정리된 방법은 선택의 순간에서 고민하는 시간을 줄여주고 더 나은 선택을 할 수 있도록 도움을 줄 것입니다. 제시된 6가지 방법 외에도 더 다양한 성능 최적화 방법(캐시를 더 고도화하여 이용한다던지)도 존재합니다. 그리고 명심해야할 것은 CSS in JS는 개발자 경험과 성능의 Trade-off 관계가 어느정도 있다는 것입니다. 따라서 6가지 방법을 적용할 때 해당 방법이 주는 성능상의 이점이 더 큰지 이에 따른 개발자 경험의 하락이 더 큰지 판단하여 사용해야 한다는 것입니다.

확실한 것은 성능이 더 개선된 여러 컴포넌트들이 모여 하나의 페이지를 구성할 때 그렇지 않은 페이지 보다는 더 나은 렌더링 성능을 보여준다는 것입니다.

Emotion 사용에 대해 더 나은 접근방법과 이 글에 대해 보충, 보완해야할 내용이 있다면 댓글로 자유롭게 남겨주세요!