MUI + emotion에 vanilla-extract 사용하기 (WIP)

Sharlotte ·2023년 11월 7일
0

Introduction

참고
이 글은 아직 결론 문단이 부재중인 미완성 글입니다!
갑자기 끝이 사라지더라도 놀라지 말아주세요 :(

리액트는 UI를 다룰 때 발생하는 여러 문제들을 해결하기 위해 개발된 가장 유명한 UI 라이브러리 중 하나입니다. 리액트 생태계가 커짐에 따라, 리액트 개발자들에겐 부트스트랩과 같은 UI-KIT의 필요성이 일찍이 다가왔을 것입니다. 개발자는 바퀴의 재발명을 귀찮아하고, MVP는 빠를수록 좋으니깐요 :D

그리고 오늘에 이르어서, 리액트 UI-KIT의 갯수는 정말 많아졌습니다. 2023년 Top 5 리액트 UI-KIT 라이브러리들을 다루는 한 Dev 블로그 포스트에 따르면 상위 5개 이상의 리액트 UI-KIT 라이브러리로 HorizonUI, MaterialUI, ChakraUI, AntDesign, NextUI를 소개하고 있으며 추가로 제가 아는 UI-KIT들만 10개가 넘게 있습니다.

이처럼 수많은 UI 라이브러리들에서 가장 유명한 라이브러리 중 하나인 MaterialUI는 제 포트폴리오의 주력 UI 라이브러리로 사용되고 있습니다. MaterialUI는 구글의 Material Design 2를 리액트 컴포넌트로 구현한 오픈소스 UI 라이브러리입니다. 매우 거대한 커뮤니티, 안정적인 라이브러리, 머터리얼 디자인을 그대로 구현함으로써 생성된 엄청난 양의 UI 컴포넌트들은 리액트 입문자였던 저에게 좋은 스타트로 느껴졌습니다.

MUI의 수많은 UI 컴포넌트들은 개발을 보조해주면서 배워 만들고자 하는 것에 더 쉽게 도달하도록 도와줬었습니다. 물론 그 UI 컴포넌트들은 미래에 제가 직접 만들어봐야 할 또다른 과제지만 당장의 과제와 재미를 위해 빌려온 것입니다.

그러나 대여에 이자가 붙은 것일까요? 시간이 지남에 따라, MUI(Material UI)는 점점 부담스럽게 느껴졌습니다.

출항한 배는 어느새 고립지로 변모했습니다.

개발을 하면 할수록 하고자 하는 것과 MUI가 의도한 것이 충돌하는 경우가 많이 생겨났습니다. 그 중 일부는 지역적으로 해결할 수 있었지만, 일부는 구조적으로 불가능할 정도로 심각한 충돌이였습니다.

그래서 MUI로부터 탈출하려고 하니 프로젝트 전체가 이미 MUI에 잠식된 상태였습니다. 처음에는 MUI의 틀 내에서 어디로든 나아갈 수 있었지만, 역설적으로 MUI 틀 밖으로 나아갈 수 없는 상태인 것입니다. 처음부터 확장성이 낮은 라이브러리를 사용한 대가였습니다.

이렇게 하고자 하는 것과 MUI가 의도한 것이 부딪친 경우가 생각보다 많았습니다. 결국 MUI를 점진적으로 퇴출시킬 생각을 결심했고, 여러 충돌 케이스를 하나하나씩 해결하여 점진적 마이그레이션을 취하기로 결정했습니다.

스타일 시스템 마이그레이션

이번 글에선 그런 점진적 마이그레이션 중 첫번째인 스타일 시스템 마이그레이션에 대해 다루고자 합니다. 이 글을 시작한 계기기도 하고, 마이그레이션하면서 겪은 경험이 생각보다 다채롭고 재미있었기 때문입니다.

Emotion

Emotion is a library designed for writing css styles with JavaScript. It provides powerful and predictable style composition in addition to a great developer experience with features such as source maps, labels, and testing utilities. Both string and object styles are supported.
https://emotion.sh/

MUI는 스타일 시스템의 스타일 앤진으로 emotion이나 styled-component를 가지고 있습니다. (styled-component는 SSR를 지원하지 않는 이슈가 있어서 emotion를 권장한다고 합니다.) 두 라이브러리는 Runtime Css-In-Js입니다. 이들은 자바스크립트 범주에서 스타일링을 할 수 있음으로써 리액트 props와 state 또는 여러 함수 및 변수 등을 사용할 수 있고, 지역 스타일링으로 예기치 않은 클레스네임 중복을 방지할 수 있습니다.

제가 처음 MUI를 사용할 때에는 별다른 스타일 라이브러리를 잘 몰랐고, emotion로 런타임 CssInJs를 사용하는 것이 딱히 부담된다고 느껴지지 않았습니다. 아마 정말로 UX에 피해가 갈 정도로 부담되진 않았을 것입니다.

시간에 따라 개발 기술을 점점 익히면서 emotion의 단점이 시야에 들어왔었습니다. Emotion의 메인테이너 중 일인인 Sam Magura는 emotion의 장단점와 떠나야 하는 이유를 아티클로 투고했는데, 이에 따르면 emotion은 오버헤드, 번들 부담 그리고 데브툴 혼란 야기를 단점으로 가지고 있습니다.

사실 emotion이 런타임에서 DOM를 수정한다는 원리에서 이미 우리는 이 오버헤드와 번들 부담을 짐작했을 것입니다. 그럼에도 불구하고 계속 사용했던 이유는 달리 대체제가 없었기 때문입니다. 자바..아니 정확힌 타입스크립트의 매력을 유지하면서 인기 있으며 안정적인 emotion의 경쟁자를 찾을 수 없었습니다. 경쟁자가 동족인 styled-component인게 웃프죠.

Vanilla-Extract

Use TypeScript as your preprocessor. Write type‑safe, locally scoped classes, variables and themes, then generate static CSS files at build time.
https://vanilla-extract.style/

그러나 어느날 vanilla-extract라는 다른 스타일 라이브러리를 적극적으로 홍보하는 한 개발자를 목격했습니다. 런타임 없이 정적으로 css를 만들되 동적인 부분들은 css variable, function로 대처하는 모습이 emotion만 사용하던 저에겐 vanilla-extract에서 꽤나 매력적인 부분들이였습니다.

만약에 emotion에서 vanilla-extract로 이동한다면, 즉 emotion로 구성된 스타일을 VE(vanilla-extract)로 완벽히 재구성할 수 있다면 전 기꺼이 그럴 것입니다. 그러나 잠시 생각해보아야 할 것들이 있었습니다.

  • VE는 css module를 기본으로 삼습니다. emotion의 지역 스타일링 - css prop는 VE에 존재하지 않기 때문에 css module로 분리를 해야 할 것입니다.

  • styled-component처럼 emotion도 styled(elem) 함수를 제공해줍니다. 이 함수는 스타일링에 필요한 동적 값을 props로 받아와서 elem파라미터에 스타일을 입힌 컴포넌트를 반환합니다. 위 css prop에서도 생기는 경우인데, css variable로 해결할 수 있습니다.

  • emotion의 무한한 nested selector는 depth와 범주가 한정된 VE에서 진절머리가 날 것입니다. emotion은 "&" 선택자로 자식의 자식에 거듭된 자식들까지... 무한히 중첩 선택을 거듭할 수 있는 반면에, VE의 스타일 함수는 자식을 선택할 수 없기 때문입니다. 오직 자신의 가상 클래스/엘리먼트와 자신을 선택하는 복합 선택자들만 가능합니다.
    그러므로 nested selector들이 선택하는 엘리먼트들을 일일이 분리해야 할 것입니다. 귀찮지만 불가능한 점은 아닙니다. 예를 들자면...

    // Sidebar.styled.ts
    export const LinksContainer = styled("div")({
      display: "flex",
      justifyContent: "space-evenly",
      "& > a": {
        transition: "transform 200ms",
        transform: "translateY(0)",
        color: "inherit",
        "&:hover": { // 선택된 a tag element의 :hover 선택자입니다.
        	transform: "translateY(-5px)"
        }
      }
    });

    위 emotion 스타일 코드는 아래로 바뀌어야 할 것입니다.

    // Sidebar.css.ts
    export const linksContainer = style({
      display: "flex",
      justifyContent: "space-evenly",
    })
    export const link = style({
        transition: "transform 200ms",
        transform: "translateY(0)",
        color: "inherit",
      	selectors: {
        	"&:hover": {
        		transform: "translateY(-5px)"
        	}
        }
    })

    스타일 코드 수가 많아진다고 불평할 수도 있습니다. 그러나 한 스타일이 한 요소만을 스타일한다는 원칙을 강제받음으로써 엘리먼트 트리에서 좀 더 쉽게 엘리먼트의 스타일을 추적할 수 있습니다. (그리고 globalStyle로 우회할 수도 있습니다.)

  • 자바스크립트 함수를 emotion 스타일링에 직접적으로 사용하는 경우가 두번째로 까다로운데, 두가지 솔루션이 있습니다.

    • 첫째는 위 문제와 같이 css variable로 해결하는 것입니다.
    • 두번째는 css function를 사용하여 해결하는 것입니다. 종종 간단한 수학 함수들(min, max, 사칙연산 등)이나 다른 함수로 대체 가능한 경우가 있기 때문입니다. 가능한 css 계층에서 처리하는 것이 DX적으로, 미약하게 성능적으로 이점을 보기 때문에 이 방법을 가장 선호합니다.
  • 마지막으로 조건부 스타일링이 있습니다. 이 또한 두가지 해결책이 있습니다.

    • 고전적인 방법으로, CSS 속성 선택자로 조건부 스타일링을 한 다음 조건에 따라 요소에 속성을 부여하는 방법입니다. 대개 커스텀 속성은 data-* 속성으로 전달합니다. (물론, aria-* 속성 등 여러 속성들도 상황에 따라 두루 사용됩니다.)
    • 최신 방법으로, @container CSS @규칙이 있습니다. 그러나 조건에 사용할 수 있는게 제한적인 점, 아직 css variable의 동치 조건이 실험적인 점때문에 자주 사용하진 못할 것 같습니다.

이와 같이 걱정되는 부분에선 저마다의 해결책이 있습니다. 사실, 간단하게 말해서 동적인 부분만 CSS 변수로 빼다놓는게 대부분입니다. 그럼에도 불구하고 이것이 까다로운건 모든 동적 로직을 css변수로 빼다놓으면 난잡할게 뻔하기 때문에 css함수를 통해 해결하고자 하기 때문입니다.

이제 실제로 VE로 마이그레이션한 코드들과 함께 그 방법들을 소개해드립니다.

CSS Variable로 동적 값 전달하기

앞서 소개했다시피 VE는 zero runtime css in js 라이브러리고, 기본적으로 정적입니다. 그러나 @vanilla-extract/dynamicassignInlineVars는 css variable를 런타임에서 동적으로 받게끔 도와줍니다.

// Content.css.ts
export const i = createVar();
export const shower = style({
  //...
  selectors: {
    "&::before": {
      //...
      backgroundColor: `color-mix(
        in srgb, 
        ${variableMap.palette.primary.main},
        rgba(1,1,1,0) calc(100% - 100% * (0.4 + 0.3 * (2 - ${i})))
	  )`,
    }
  }
})
// Content.tsx
<div
  className={styles.shower}
  style={assignInlineVars({ [styles.i]: "2" })}
  />

그 원리는 생각보다 간단합니다. 해당 엘리먼트의 style 속성에 직접 css variable를 할당하는 스타일을 할당하는 것입니다. css variable은 css의 기본 기능이므로 트레이드 오프를 걱정할 필요가 없을 것입니다.

과연 트레이드 오프가 없을까요?

리액트 구 공식문서와 위 Sam Magura가 인라인 스타일은 동일한 스타일이 여러 요소에 적용돼야 할 경우 성능상 좋지 않습니다. 라고 말한 것이 거슬려서 저 공식문서 구절에 대한 레딧 포스트도 찾아봤지만 유의미한 성능 리스크는 볼 수 없었습니다. 다른 객체 prop에도 있을법한 이야기 뿐이였어요.
만약 inline style에 가시적인 리스크가 있다면, 매우 슬픈 소식이므로 프로파일러를 돌리고 메모라이징을 하거나 그 수를 최대한 줄이도록 노력해야 할 것입니다. 물론 평소에도 css variable의 의존성을 줄이는게 좋겠죠.

아무튼 vanilla-extract에서 동적 스타일링의 대부분은 이런 css variable로 대처할 수 있습니다. (나머지는 속성 선택자에요)

CSS Function으로 동적 로직 대체하기

그러나 앞서 말했듯이, 모든 동적 로직을 css variable로 대체하는 것은 마음에 들지 않습니다.
그래서 css function로 대체할 수 있는 로직은 css variable로 대체하지 않으려고 합니다.

투명도 동적 스타일링

특정 엘리먼트가 살짝 투명하게 만들되 내용물까지 투명해지지 않았으면 좋을 때 배경 색만 반투명화하는 방법은 괜찮은 선택입니다. 문제라 하자면 background-opacity 같은 속성은 없고, 대신 background-color의 alpha channel은 있지만 hex color인 값에 어떻게 float 타입의 alpha를 부여할지가 문제였죠.

아래 스타일 코드는 그러한 문제를 겪었던 제가 @mui/system/colorManipulator에서 제공하는 alpha 함수로 문제를 대신 해결한 코드입니다. (alpha@mui/system에서도 제공합니다.)

export const StyledAppBar = styled((props: AppBarProps & MotionProps) => (
    <AppBar {...props} component={motion.div} />
  ))<{
    alpha: number;
  }>(({ theme, alpha: alphaAmount }) =>
    theme.unstable_sx({
      transition: "all 300ms",
      backdropFilter: "blur(5px)",
      zIndex: Layouts.HEADER,
      left: 0,
      right: 0,
      boxShadow: `0px 2px 4px -1px rgba(0,0,0,${
        alphaAmount * 0.2
      }), 0px 4px 5px 0px rgba(0,0,0,${
        alphaAmount * 0.14
      }), 0px 1px 10px 0px rgba(0,0,0,${alphaAmount * 0.12})`,
      backgroundColor: (theme) =>
        alpha(theme.palette.primary.main, alphaAmount * 0.75),
     // more...
  }))

설명해 드리기 전에, 먼저 위 코드가 깔끔해진 결과부터 보여드리겠습니다.

export const alphaAmount = createVar();
export const appBar = style({
  position: "fixed", // StyledAppBar에서 position를 prop로 넘기던걸 style에 가져옴
  left: 0,
  right: 0,
  zIndex: Layouts.HEADER,
  boxShadow: `
    0px 2px 4px -1px rgba(0, 0, 0, calc(${alphaAmount} * 0.2)), 
    0px 4px 5px 0px rgba(0, 0, 0, calc(${alphaAmount} * 0.14)), 
    0px 1px 10px 0px rgba(0, 0, 0, calc(${alphaAmount} * 0.12))`,
  backgroundColor: `color-mix(in srgb,
    ${variableMap.palette.primary.main},
    transparent calc(${alphaAmount} * 0.75 * 100%)
  )`,
  backdropFilter: "blur(5px)",
  transition: "all 300ms",
  /// more...
})

스타일 자체의 양과 모습은 라이브러리의 변화에 큰 상관이 없지만, 전 선언부가 인상적이였습니다. styled 함수는 최소한 위 VE 코드처럼 될 수 있는 반면, 최대한 위 emotion 코드처럼 될 수 있기 때문에 그 편차가 생각보다 높았습니다. 그에 비해 VE는 하나로 일관적이니 많이 깔끔해보입니다.

앞서 말씀드렸다시피, 동적 값인 alphaAmount는 css variable로 처리했습니다. 그러나 똑같이 동적 값인 투명도는 그렇게 하지 않았습니다. 왜냐하면 이미 CSS에서 color-mix() 함수를 통해 충분히 상대적 색 투명도 부여가 가능하기 때문입니다.

참고로 실험하다 알게 된 것인데, 어쩐 영문인진 몰라도 % 단위가 아니면 color-mix가 제 기능을 못해서 100%를 의도적으로 곱했습니다.

color-mix() 함수 뿐만이 아니라, alphaAmount 값의 재가공을 위하여 calc() 함수도 사용했습니다. 사칙연산을 css에서 할 수 있게 해주는 아주 고마운 함수입니다.

---------------- WIP -----------------
이 글은 미완성입니다. 케이스들을 소개하다가 결론을 생각하고 있습니다.

profile
샤르르르

0개의 댓글