(번역) 우리가 CSS-in-JS와 헤어지는 이유

한정(Han Jung)
18 min readNov 7, 2022

--

원문: https://dev.to/srmagura/why-were-breaking-up-wiht-css-in-js-4g9b

안녕하세요. 저는 Sam입니다. Spot의 소프트웨어 엔지니어 이자 널리 사용되고 있는 CSS-in-JS 라이브러리인 Emotion의 두 번째로 활발하게 기여하고 있는 메인테이너입니다. 이 글은 원래 CSS-in-JS에 끌렸던 이유와 (나머지 Spot 팀과 함께) 제가 CSS-in-JS에서 벗어나기로 한 이유에 대해 탐구해보려 합니다.

CSS-in-JS의 개요부터 시작해 장단점에 대한 부분까지 이야기할 것입니다. 그리고 CSS-in-JS가 Spot에서 발생시킨 성능 문제와 이를 방지하는 방법에 대해 자세히 알아보겠습니다.

CSS-in-JS가 뭔가요?

이름에서 알 수 있듯이 CSS-in-JS를 사용하면 자바스크립트 또는 타입스크립트 코드에서 직접 CSS를 작성하여 리액트 컴포넌트 스타일을 지정할 수 있습니다.

styled-componentsEmotion은 리액트 커뮤니티에서 가장 인기 있는 CSS-in-JS 라이브러리입니다. 저는 Emotion만 사용했지만, 이 글의 거의 모든 요점은 styled-components에도 적용됩니다.

이 글은 styled-components와 Emotion을 모두 포함하는 카테고리인 런타임 CSS-in-JS에 초점을 맞춥니다. 런타임 CSS-in-JS는 단순하게 앱이 실행될 때 라이브러리가 스타일을 해석하고 적용하는 것을 의미합니다. 우리는 글의 끝에서 컴파일 타임 CSS-in-JS에 대해서도 간략하게 이야기할 것입니다.

CSS-in-JS의 좋은 점, 나쁜 점, 못난 점

특정 CSS-in-JS 코딩 패턴의 핵심과 성능에 미치는 영향에 대해 알아보기 전에, 이 기술을 채택할 수 있는 이유와 하지 않을 수 있는 이유에 대한 높은 수준의 개요부터 이야기하면서 시작해보겠습니다.

좋은 점

1️⃣. 지역 스코프 스타일. 일반 CSS를 작성할 때는 실수로 의도한 것보다 더 광범위하게 스타일을 적용하기가 쉽습니다. 예를 들어, 각 행에 패딩과 테두리가 있어야 하는 리스트 화면을 만들고 있다고 상상해보세요. 다음과 같이 CSS를 작성할 수 있습니다.

몇 달 후 리스트 뷰를 완전히 잊었을 때, 행이 있는 다른 컴포넌트를 만든다고 해보겠습니다. 자연스럽게 이런 요소에 className="row"를 설정할 수 있습니다. 이제 새로 생긴 컴포넌트의 행에 흉한 테두리가 있게 되고 이 테두리가 어디서 왔는지 알 수가 없게 됩니다! 이런 유형의 문제는 더 긴 클래스 이름이나 구체적인 선택자를 사용해 해결할 수 있지만, 클래스 이름 충돌이 없는지 확인하는 것은 개발자의 몫입니다.

CSS-in-JS는 기본적으로 스타일을 지역 스코프로 지정하여 이 문제를 완전히 해결합니다. 목록 화면을 다음과 같이 작성한다면,

패딩과 테두리가 관련되지 않은 요소에 실수로 적용되지 않을 것입니다.

참고: CSS 모듈도 지역 스코프 스타일을 제공합니다.

2️⃣. 코로케이션. 일반 CSS를 사용하는 경우 모든 리액트 컴포넌트가 src/components에 있는 동안 .css파일을 src/styles 디렉토리에 넣을 수 있습니다. 이런 경우 앱이 커짐에 따라 각 컴포넌트에서 어떤 스타일을 사용하는지 빠르게 구분하기 어려워집니다. 사용되지 않는 스타일을 쉽게 구분할 수 없기 때문에 CSS에 사용하지 않는 코드가 표시되는 경우가 많아집니다.

코드를 구성하는 더 나은 방법은 단일 컴포넌트에 관련된 모든 것을 같은 위치에 두는 것입니다. 코로케이션이라고 하는 이 방법은 Kent C. Dodds의 훌륭한 블로그 글에서 다뤘습니다.

일반 CSS를 사용할 때의 문제는 CSS와 자바스크립트가 별도의 파일에 있어야 하고 .css 파일의 위치와 관계없이 스타일이 전역으로 적용되기 때문에 코로케이션을 구현하기 어렵다는 점입니다. 반면에 CSS-in-JS를 사용하는 경우 스타일을 사용하는 리액트 컴포넌트 내부에 직접 스타일을 작성할 수 있습니다! 올바르게 사용한다면 앱의 유지보수가 크게 편해집니다.

참고: CSS 모듈을 사용하면 동일한 파일은 아니지만, 컴포넌트와 스타일을 함께 배치할 수도 있습니다.

3️⃣. 자바스크립트 변수를 style에 사용할 수 있습니다. CSS-in-JS를 사용하면 스타일 규칙을 작성할 때 자바스크립트 변수를 참조할 수 있습니다. 예를 살펴보면,

이 예제에서 볼 수 있듯이 CSS-in-JS 스타일에서 자바스크립트 상수(예: colors)와 리액트 props와 state(예: fontSize)를 모두 사용할 수 있습니다. 스타일에서 자바스크립트를 사용하는 것은 때에 따라 중복을 줄일 수 있습니다. 동일한 상수를 CSS 변수와 자바스크립트 상수로 나눠 정의할 필요가 없기 때문입니다. props와 state를 사용하면 인라인 스타일을 사용하지 않고도 고도의 사용자 정의 스타일 컴포넌트를 만들 수 있습니다. (인라인 스타일은 동일한 스타일이 여러 요소에 적용돼야 할 경우 성능상 좋지 않습니다.)

중립

  1. CSS-in-JS는 핫한 새로운 기술입니다. 저를 포함한 많은 웹 개발자들은 자바스크립트 커뮤니티에서 가장 핫한 새로운 트렌드를 빠르게 채택하고 있습니다. 많은 경우에 새로운 라이브러리와 프레임워크가 이전 라이브러리에 비해 크게 개선된 것이 입증되었을 때 이런 선택은 합리적입니다.(리액트가 jQuery와 같은 이전 라이브러리에 비해 생산성이 얼마나 향상하는지 생각해보세요) 반면에 반짝이는 새 도구에 대한 집착은 단지 집착일 수 있습니다. 새로운 큰 기술들을 놓치기를 두려워해 새로운 라이브러리나 프레임워크를 채택한다면 실제 단점을 간과할 수 있습니다. 저는 이것이 CSS-in-JS의 광범위한 채택에 확실히 요인이 되었다고 생각합니다. 적어도 저에게는 그렇습니다.

나쁜 점

  1. CSS-in-JS는 런타임 오버헤드를 더합니다. 컴포넌트가 렌더링 될 때 CSS-in-JS 라이브러리는 document에 삽입할 수 있는 일반 CSS로 스타일을 “직렬화”해야 합니다. 이런 부분이 추가 CPU를 차지한다는 것은 분명하지만 앱의 성능에 눈에 띄는 영향을 미치기에 충분한지는 확인해봐야 합니다. 다음 섹션에서 좀 더 자세히 조사해보도록 하겠습니다.
  2. CSS-in-JS는 번들 크기를 늘립니다. 이건 분명한 사실입니다. 사이트를 방문하는 각 사용자는 CSS-in-JS 라이브러리용 자바스크립트를 다운로드해야 합니다. Emotion은 압축되었을 때 7.9kB이고 styled-components는 12.7kB입니다. 두 라이브러리 모두 거대하지는 않지만 모두 추가됩니다.(비교를 위해 설명하자면 react + react-dom은 44.5KB입니다.)
  3. CSS-in-JS는 React DevTools를 어지럽힙니다. css 프로퍼티를 사용하는 각 요소에 대해 Emotion은 <EmotionCssPropInternal><Insertion> 컴포넌트를 렌더링합니다. 많은 요소에서 css 프로퍼티를 사용하는 경우 아래 그림처럼 Emotion의 내부 컴포넌트가 React DevTools를 어지럽힐 수 있습니다.

못난 점

1️⃣. CSS 규칙을 자주 삽입하면 브라우저에서 더 많은 추가 작업을 수행해야 합니다. 리액트 코어 팀 멤버이자 리액트 훅의 디자이너였던 Sebastian Markbåge는 리액트 18 작업 그룹에서 CSS-in-JS 라이브러리가 리액트 18과 함께 작동하도록 변경해야 하는 방법과 미래에 대해 매우 유익한 토론을 작성했습니다. 그는 일반적으로 런타임 CSS-in-JS의 경우 특히 다음과 같이 해야 한다고 말합니다.

동시(concurrent) 렌더링에서, 리액트는 렌더링 사이에 브라우저에 양보할 수 있습니다. 컴포넌트에 새 규칙을 삽입하면 리액트가 생성되고, 브라우저는 해당 규칙이 기존 트리에 적용되어 있는지를 다시 확인합니다. 이후 스타일 규칙을 다시 계산하게 됩니다. 그런 다음 리액트는 다음 컴포넌트를 렌더링 하고, 해당 컴포넌트는 새로운 규칙을 발견하고 다시 실행하기를 반복합니다.

이런 부분은 리액트가 렌더링 하는 동안 모든 프레임에 대해 모든 CSS 규칙을 효과적으로 재계산하게 합니다. 하지만 매우 느립니다.

2022–10–25 업데이트: Sebastian의 이 인용문은 특히 useInsertionEffect가 없는 리액트 동시(concurrent) 모드의 성능을 말합니다. 이에 대한 심층적인 이해를 원한다면 전체 토론을 읽는 것이 좋습니다. 트위터에서 이런 부정확성을 지적해준 Dan Abramov에게 감사합니다.

이 문제의 가장 나쁜 점은 (런타임 CSS-in-JS 컨텍스트 내에서) 해결할 수 있는 문제가 아니라는 것입니다. CSS-in-JS 라이브러리는 컴포넌트가 렌더링 될 때 새로운 스타일 규칙을 삽입해 작동하며 이는 근본적으로 성능에 좋지 않습니다.

2️⃣. CSS-in-JS를 사용하면 특히 SSR 및 혹은 또는 컴포넌트 라이브러리를 사용할 때 잘못될 수 있는 부분이 훨씬 더 많습니다. Emotion 깃헙 저장소에서 우리는 다음과 같은 수많은 이슈를 제보받습니다.

MUI, Mantine, 그리고 또 다른 Emotion 기반 컴포넌트 라이브러리와 함께 Emotion을 서버 사이드 렌더링에 사용할 때 제대로 동작하지 않습니다. 왜냐하면…

근본 원인은 문제마다 다르지만 몇 가지 공통 주제가 있습니다.

  • Emotion의 여러 인스턴스가 한 번에 로드됩니다. 이는 여러 인스턴스가 모두 동일한 버전의 Emotion인 경우에도 문제를 일으킬 수 있습니다. 예시 이슈
  • 컴포넌트 라이브러리는 스타일이 삽입되는 순서를 완전히 제어할 수 없는 경우가 많습니다. 예시 이슈
  • Emotion의 SSR지원은 리액트 17과 리액트 18에서 다르게 작동합니다. 이는 리액트 18의 스트리밍 SSR과의 호환성을 위해 필요했습니다. 예시 이슈

저를 믿으세요. 이러한 복잡성의 원인은 빙산의 일각에 불과합니다. (용기가 난다면, 한 번 @emotion/styled에 대한 타입스크립트 정의를 살펴보세요.)

성능 심층 분석

이 시점에서 런타임 CSS-in-JS에는 상당한 장단점이 있다는 것은 확실히 이해되셨을 겁니다. 우리 팀이 CSS-in-JS 기술에서 멀어지려는 이유를 이해하려면 이 기술이 실제 성능에 미치는 영향을 알아야 합니다.

이 섹션은 Spot 코드 베이스에서 사용된 Emotion의 성능 영향에 중점을 둡니다. 따라서 아래 제시된 성능 수치가 당신의 코드 베이스에도 적용된다고 가정하는 것은 실수입니다. Emotion을 사용하는 방법에는 여러 가지가 있으며 각각 고유한 성능 특성이 있습니다.

렌더링 내부와 외부의 직렬화

스타일 직렬화는 Emotion이 CSS 문자열 또는 객체 스타일을 가져와 document에 삽입할 수 있는 일반 CSS 문자열로 변환하는 과정을 말합니다. Emotion은 또한 직렬화 중에 일반 CSS의 해시를 계산합니다. 이 해시는 생성된 클래스 이름에서 확인할 수 있습니다. (예시: css-15nl2r3)

이 부분을 측정하지는 않았지만, Emotion이 작업을 수행하는 방식 중 가장 중요한 요소 중 하나는 스타일 직렬화가 리액트 렌더링 주기 내부에서 수행되는지 외부에서 수행되는지에 대한 여부입니다.

Emotion 문서의 예시는 다음과 같이 리액트 렌더링 내부에서 직렬화를 수행합니다.

MyComponent가 렌더링 될 때마다 객체 스타일은 다시 직렬화됩니다. MyComponent가 자주 렌더링 되는 경우(예시: 모든 키 입력 시) 반복되는 직렬화는 높은 성능비용을 초래할 수 있습니다.

성능이 더 좋은 접근 방식은 컴포넌트 외부로 스타일을 이동해 렌더링이 발생할 때가 아닌 모듈이 로드될 때 한 번만 직렬화가 발생하도록 하는 것입니다. @emotion/reactcss 함수로 이 작업을 수행할 수 있습니다.

물론 이렇게 하면 스타일에서 프로퍼티에 접근할 수 없으므로 CSS-in-JS의 주요 셀링 포인트 중 하나를 놓치게 됩니다.

Spot에서는 렌더링 단계에서 스타일 직렬화를 수행했으므로, 다음 성능 분석에는 이 경우를 기준으로 작성되었습니다.

멤버 브라우저 화면 벤치마킹

마침내 Spot의 실제 컴포넌트를 프로파일링 해 지금까지 한 이야기를 구체화할 때입니다. 팀의 모든 사용자를 보여주는 매우 간단한 목록 화면인 멤버 브라우저 화면을 사용할 것입니다. 멤버 브라우저 화면 대부분 스타일은 Emotion, 특히 css 프로퍼티를 사용합니다.

테스트를 위해,

  • 멤버 브라우저 화면은 20명의 유저를 보여줄 것이며,
  • 리스트 아이템을 감싸던 React.memo를 제거했습니다. 그리고,
  • 맨 위에 있는 <BrowseMembers> 컴포넌트가 1초마다 강제로 렌더링하도록 하고, 처음 10개의 렌더링 시간을 기록합니다.
  • 또한, 리액트 엄격 모드가 꺼져있습니다.(프로파일러에서 볼 수 있는 렌더링 시간을 효과적으로 두 배 늘렸습니다)

저는 React DevTools를 사용해 페이지를 프로파일링 했고 처음 10번의 렌더링 시간 평균은 54.3ms 였습니다.

제 개인적인 경험에서 초당 60 프레임인 경우 1 프레임이 16.67ms이기 때문에 리액트 컴포넌트를 렌더링 하는데 16ms 이하가 소요되어야 한다는 규칙이 존재했습니다. 멤버 브라우저 화면은 이 수치에 3배 이상이므로 상당히 무거운 컴포넌트입니다.

이 테스트는 일반 사용자가 환경보다 훨씬 빠른 M1 Max CPU에서 수행되었습니다. 제가 얻은 54.3ms라는 렌더링 시간은 덜 강력한 환경에서 200ms가 되는 것이 어렵지 않을 것입니다.

프레임 그래프(Flamegraph) 분석하기

다음은 위 테스트의 단일 리스트 항목에 대한 프레임 그래프입니다.

보시다시피 엄청난 수의 <Box><Flex> 컴포넌트가 렌더링 되고 있습니다. 이 컴포넌트는 css 프로퍼티를 사용하는 "style primitives"입니다. 각 <Box>를 렌더링 하는데 0.1 ~ 0.2ms밖에 걸리지 않지만 <Box> 컴포넌트의 총개수가 방대하기 때문에 큰 시간이 추가됩니다.

Emotion 없이 작성된 멤버 브라우저 벤치마킹

이 값비싼 렌더링의 상당 부분의 Emotion으로 인한 것인지 확인하기 위해 Sass 모듈을 사용해 멤버 브라우저 스타일을 다시 작성했습니다.(Sass 모듈은 빌드 시 일반 CSS로 컴파일되므로 사용 시 성능 저하가 거의 없습니다.)

위에서 설명한 것과 동일한 테스트를 반복했고 처음 10개 렌더링의 평균으로 27.7ms를 얻었습니다. 이는 원래 시간보다 48% 감소한 값입니다!

이것이 우리가 CSS-in-JS와 헤어지는 이유입니다. 런타임 성능비용이 너무 높습니다.

위의 면책 조항을 반복해보면, 이 결과는 Spot 코드 베이스에서 나온 결과이며 우리가 Emotion을 사용하는 방법에만 적용되는 결과입니다. 당신이 Emotion을 훨씬 성능이 좋은 방식으로 사용하는 경우(예: 렌더링 외부에서 스타일을 직렬화) CSS-in-JS를 제거했을 때 훨씬 적은 이점을 볼 수 있습니다.

궁금해하시는 분들을 위해 결과 데이터를 그대로 보여드리겠습니다.

우리의 새로운 스타일링 시스템

CSS-in-JS에서 벗어나기로 한 후의 질문은 분명합니다. 대신 무엇을 사용하면 좋을까요? 이상적으로 CSS-in-JS의 이점을 최대한 유지하면서 일반 CSS와 유사한 성능을 가진 스타일링 시스템을 원합니다. 다음은 “좋은 점” 영역에서 설명한 CSS-in-JS의 주요 이점입니다.

  1. 스타일은 지역 스코프입니다.
  2. 스타일은 적용되는 컴포넌트와 함께 배치됩니다.
  3. 스타일은 자바스크립트 변수를 사용할 수 있습니다.

해당 섹션에 세심한 주의를 기울였다면 CSS 모듈이 지역 스코프 스타일과 코로케이션을 제공한다고 말한 것을 기억할 겁니다. 그리고 CSS 모듈은 일반 CSS 파일로 컴파일되므로 사용에 따른 런타임 성능비용이 없습니다.

제가 생각하는 CSS 모듈의 주요 단점은 여전히 일반 CSS이며, 일반 CSS에는 DX를 개선하고 코드 중복을 줄이는 기능이 아직은 부족하다는 점입니다. 중첩 선택자는 삶의 질을 크게 향상해주지만, 아직 등장 예정일뿐 제공되지 않습니다.

다행히도 이 문제에는 쉬운 해결책이 있습니다. Sass 모듈은 단순히 Sass로 작성된 CSS 모듈입니다. 기본적으로 런타임 비용 없이 지역 스코프의 CSS 모듈 스타일과 Sass의 강력한 빌드 시간 장점을 누릴 수 있습니다. 이것이 Sass 모듈이 앞으로 우리의 범용 스타일링 솔루션이 될 이유입니다.

참고: Sass 모듈을 사용하면 CSS-in-JS의 이점 3(스타일에서 자바스크립트 변수를 사용하는 기능)을 잃게 됩니다. 하지만, Sass 파일에서 :export 블록을 사용하면 Sass 코드의 상수를 자바스크립트에서 사용할 수 있도록 할 수 있습니다. 편리하진 않지만 DRY 하게 코드를 작성할 수 있게 도와줍니다.

유틸리티 클래스

팀이 Emotion에서 Sass 모듈로 전환하는 것에 있어 한 가지 우려하는 지점이 존재했습니다. display: flex 같은 매우 일반적인 스타일을 적용하는 것이 덜 편리하다는 것입니다. 이전에는 아래처럼 작성했습니다.

<FlexH alignItems="center">...</FlexH>

Sass 모듈을 사용해 이 작업을 수행하려면 .module.scss 파일을 열고 display: flexalign-items: center 스타일을 적용하는 클래스를 만들어야 합니다. 못 쓸 정도는 아니지만, 확실히 덜 편리합니다.

이와 관련해 DX를 개선하기 위해 유틸리티 클래스 시스템을 도입하기로 했습니다. 유틸리티 클래스는 요소에 단일 CSS 프로퍼티를 설정하는 CSS 클래스를 의미합니다. 일반적으로 여러 유틸리티 클래스를 결합해 원하는 스타일을 얻습니다. 위의 예시는 아래처럼 작성합니다.

<div className="d-flex align-items-center">...</div>

BootstrapTailwind는 유틸리티 클래스를 제공하는 가장 인기 있는 CSS 프레임워크입니다. 이 라이브러리는 유틸리티 시스템 설계에 큰 노력을 기울였으므로 자체적으로 작성하는 대신 이 중 하나를 채택하는 것이 합리적이었습니다. 저는 이미 몇 년 동안 Bootstrap을 사용하고 있었기 때문에 Bootstrap을 사용했습니다. Bootstrap의 유틸리티 클래스를 미리 빌드된 CSS 파일로 가져올 수 있지만, 기존 스타일 시스템에 맞게 클래스를 사용자 정의해야 했기 때문에 소스 코드와 관련된 일부 부분을 프로젝트에 복사해서 사용했습니다.

우리는 몇 주 동안 새 컴포넌트에 대해 Sass 모듈과 유틸리티 클래스를 사용했으며 매우 만족합니다. DX는 Emotion과 유사하며 런타임 성능이 훨씬 뛰어납니다.

참고: 저희는 typed-scss-modules 패키지를 사용해 Sass 모듈에 대한 타입스크립트 정의를 생성합니다. 아마도 유효한 유틸리티 클래스 이름만 인수로 허용하는 것을 제외하고 이 기술의 가장 큰 이점은 classnames럼 작동하는 utils() 헬퍼 함수를 정의할 수 있다는 것입니다.

컴파일 타임 CSS-in-JS에 대한 참고 사항

이 글은 Emotion 및 styled-components 같은 런타임 CSS-in-JS 라이브러리에 초점을 맞췄습니다. 최근에 저희는 컴파일 타임에 스타일을 일반 CSS로 변환하는 CSS-in-JS 라이브러리가 증가하는 것을 보았습니다. 예를 들어,

이런 라이브러리는 성능비용 없이 런타임 CSS-in-JS와 유사한 이점을 제공한다고 주장합니다.

컴파일 타임 CSS-in-JS 라이브러리를 직접 사용하지는 않았지만, Sass 모듈과 비교할 때 여전히 단점이 있다고 생각합니다. 특히 Compiled를 볼 때 느꼈던 단점이 있습니다.

  • 컴포넌트가 처음 마운트 될 때 스타일이 계속 삽입되어 브라우저가 모든 DOM 노드에서 스타일을 다시 계산하도록 합니다.(이 단점은 “못난 점” 섹션에서 논의되었습니다.)
  • 이 예제color 프로퍼티와 같은 동적 스타일은 빌드 시 추출할 수 없으므로 Compiled는 style 프로퍼티(일명 인라인 스타일)을 사용해 값을 CSS 변수로 추가합니다. 인라인 스타일은 많은 요소를 적용할 때 좋지 않은 성능을 유발하는 것으로 알려져 있습니다.
  • 라이브러리는 여기에 표시된 대로 여전히 보일러 플레이트 컴포넌트를 리액트 트리에 삽입합니다. 이것은 런타임 CSS-in-JS처럼 React DevTools를 복잡하게 만듭니다.

결론

런타임 CSS-in-JS에 대한 자세한 내용을 읽어줘서 고맙습니다. 모든 기술과 마찬가지로 장단점이 있습니다. 궁극적으로 이런 장단점을 평가한 다음 해당 기술이 사용 사례에 적합한지 여부에 관해 결정을 내리는 것은 개발자의 몫입니다. Spot의 경우, Emotion의 런타임 성능비용이 DX의 이점을 훨씬 능가했습니다. 특히 Sass Modules + 유틸리티 클래스라는 대안이 여전히 우수한 DX를 제공하면서 훨씬 우수한 성능을 제공한다는 점을 보면 더욱 그렇습니다.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

--

--