Vanilla Extract 와 Zero Runtime 에 대하여 알아보자.

RookieAND·2024년 1월 17일
2

Solve My Question

목록 보기
23/27
post-thumbnail

최근 개인 프로젝트에서 Vanilla Extract 를 사용할 일이 생겨 부랴부랴 조사했습니다.

📖 Introduction

Nexters 24기 프로젝트를 시작한 후, 같은 팀의 프론트엔드 개발자 분과 회의를 거쳐 스타일링 라이브러리를 Vanilla Extract 로 정했다. 처음에는 내가 애용하는 tailwindCSS 를 제안했으나 상대 분이 Atomic CSS 에 강렬한 반대를 표하셔서 어쩔 수 없이 (...) CSS - in - JS를 사용해야 했다.

Emotion, styled-components 등 이미 유명하고 익숙한 선택지도 있었지만 내가 이 라이브러리를 추천한 이유는 나에게 프론트엔드를 알려준 친구가 추천을 해주기도 했고, 빌드 타임에서 CSS를 생성함으로서 얻을 수 있는 여러 이점들이 크다고 생각했다. 그리고 새로운 기술을 좀 써보고 싶기도 했다.

하지만 정작 나도 이 라이브러리를 한번도 써본적이 없어서 나름의 공부를 시작했고 그 결과를 글로서 정리하고자 한다.

✒️ Vanilla Extract

  • Runtime 이 아닌 Build 타임에 Typescript 로 작성된 코드를 CSS로 변환 (Zero - Runtime)
  • Type - Safe 하게 CSS 스타일을 작성할 수 있다.
    • 특히 Theme 기능 사용 시 사용자가 타입을 명시하지 않아도 알아서 타입을 체크한다.
  • sprinkle 라이브러리를 사용하여 Atomic CSS 를 사용할 수 있다.
  • recipe 라이브러리를 사용하여 Variant 기반의 스타일링을 구성할 수 있다.

✒️ Runtime CSS - in - JS

  • Emotion, styled-component 같은 라이브러리들은 런타임 환경에서 CSS를 생성한다.
    • Runtime 에 CSS 를 생성하기 때문에 스타일을 계산하는 비용이 클 수록 성능 이슈가 발생한다.
    • 컴포넌트 단에서 런타임 과정에 스타일이 수정된다면 그때마다 새롭게 CSS 를 생성해야 하고, 여기서 발생한 딜레이만큼 렌더링이 차단된다.
    • 이러한 라이브러리들은 Production Mode 에서는 DOM 이 아닌 CSSOM 을 수정하는 방식을 채택하여 DOM 트리를 다시 파싱하는 과정을 생략하였다.

❓ 왜 굳이 CSSOM 에 스타일을 추가하는 방식을 사용하는가?

  • 브라우저 렌더링 과정에서 DOM 트리의 루트에서 각 노드를 순회하고, 이후 각 노드에 대한 CSSOM 규칙을 찾는 과정을 거친다.
  • 이때 DOM 을 직접 수정할 경우 변경된 사항에 대해 DOM 트리를 다시 구축해야 하기 때문에, CSSOM 만을 수정하여 해당 작업을 생략하기 위함이다.
    • CSSStyleSheet.insertRule() 메서드를 사용하여 현재 스타일시트에 새로운 스타일을 삽입하는 방식을 쓴다.
  • Development Mode 에서는 <style> 태그를 주입하여 스타일을 수정하는 방식을 쓴다. (DOM 수정)

✒️ Critical CSS

  • 꼭 애플리케이션의 모든 CSS 를 초기 렌더링때 가져올 필요는 없다.
  • 초기 렌더링의 최적화를 위해 화면에 필요한 CSS 만 로딩하는 방식을 Critical CSS 라고 한다.
  • 정리
    • 두 방식 모두 서버 사이드 렌더링 (React.renderToString) 이 진행될 때 스타일시트를 수집하고 이를 주입하는 형식이다.
    • React 18에서 지원하는 Stream 기반의 렌더링 (renderToPipeableStream ) 에 대해서도 지원을 시작하고 있지만 아직 여러 이슈가 있는 것으로 파악되었다.

✒️ Zero Runtime

  • Build 타임에 미리 CSS 를 생성해두는 방식을 바로 Zero Runtime 이라고 한다.
    • 즉, 런타임 이전에 미리 필요한 CSS 를 빌드 시점에 생성하여 제공하는 형식이다.
    • 이렇게 생성된 CSS 파일은 <link> 태그를 기반으로 브라우저에 제공된다.
  • 동적인 스타일링은 사전에 정의한 스타일의 조합을 기반으로만 가능하다.
    • Runtime CSS - in - JS 의 경우 직접 StyleSheet 를 수정함으로서 동적인 스타일링을 가능하게 했다.
    • 하지만 Zero Runtime CSS - in - JS 의 경우 CSS Variable 을 빌드 타임에 미리 생성해두고, 이를 상황에 맞게 적용하는 방식을 사용한다.
    • 새로운 Variable 은 Runtime 환경에서 생성할 수 없기 때문에 동적인 스타일링에 제한이 있다.

✒️ 무조건 Zero Runtime 이 좋은가?

개발 중인 프로젝트의 상황에 따라서 적절한 것을 도입하는 게 좋다.

  1. PROS
    • CSS 와 JS Bundle 파일을 병렬로 가져올 수 있기 때문에 리소스 로딩 시간이 단축된다.
    • 런타임에서 스타일이 생성되지 않기 때문에 Bundle 사이즈가 상대적으로 더 작다
    • 복잡한 인터렉션이 추가된 애플리케이션일수록 런타임 성능 비용이 비싼데, Zero Runtime 의 경우 이러한 고민을 해결할 수 있다.
    • RSC (React Server Componnent) 와의 궁합이 Runtime CSS - in - JS 보다 더 낫다.
    • 런타임 환경에서 스타일 주입으로 인한 여러 문제점들을 고려하지 않아도 된다. (우선 순위 등..)
  2. CONS
    • CSS Variable 을 사용하다 보니 브라우저 호환성 문제가 있다
      • 근데 이건 IE 에 국한된 문제기 때문에 이제는 딱히 의미가 없다. (IE 왜 씀)
    • 빌드 타임에 CSS 변환이 필요하기 때문에 별도의 플러그인 설정이 필요하다.
      • 다만 Vanilla Extract 의 경우 Vite, ESBuild, Rollup 같은 각 번들러에 대한 Plugin 이 이미 준비되어있다.
    • 동적인 스타일링에 대한 제한이 있기 때문에 유연한 스타일링이 다소 어렵다.
      • 상황에 따라 필요한 스타일 변수를 사전에 전부 등록시켜야 하는 문제도 있더라.
      • Vanilla Extract 에서는 dynamic 모듈로 런타임 과정에서 CSS Variable 을 생성할 수 있다.
        • 성능 저하도 거의 없고, 번들 사이즈도 매우 작다 (1.3kb)

✒️ Usage of Vanilla Extract

Vanilla Extract 플러그인의 대략적인 사용 방법에 대해서 정리하였다.

  1. 스타일링 방식

    • 여타 다른 CSS - in - JS 와 다를 바가 없다. style 함수 내에 스타일 객체을 추가한다.
    • 생성된 변수는 빌드 타임에서 난수화된 className 으로 변환되어 만들어진다.
    import { style } from '@vanilla-extract/css';
    
    export const myStyle = style({
      display: 'flex',
      paddingTop: '3px'
    });
    • 다만 별도의 Extension 없이도 각 스타일 property 에 대한 타입 추론이 된다는 게 좋다.
      각 스타일 속성에 대한 정보를 MDN 문서 기반으로 제공해주기도 한다.
  1. 스타일 재사용
    • 스타일 객체를 배열 형태로도 제공할 수 있기 때문에 재사용이 비교적 용이하다.
      export const testStyle = style({
          display: 'flex',
          flexDirection: 'column'
      })
      
      export const mergeStyle = style([
          testStyle, {
              justifyContent: 'space-around',
              gap: '0px 8px'
          }
      ])
  1. Atomic CSS 부분 제공

    • sprinkles 라이브러리를 통해 CSS token 을 생성할 수 있다.
      • 디자인 시스템에 활용될 상수와 매칭될 속성을 지정하여 defineProperties 함수 내 객체에 정의한다.
      • createSprinkles 함수를 통해 유틸 함수 sprinkles 를 생성하고 이를 내보낸다.
    import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
    
    const colors = {
      'blue-50': '#eff6ff',
      'blue-100': '#dbeafe',
      'blue-200': '#bfdbfe',
      'gray-700': '#374151',
      'gray-800': '#1f2937',
      'gray-900': '#111827'
      // etc.
    };
    
    const colorProperties = defineProperties({
      conditions: {
        lightMode: {},
        darkMode: { '@media': '(prefers-color-scheme: dark)' }
      },
      defaultCondition: 'lightMode',
      properties: {
        color: colors,
        background: colors
      }
    });
    
    export type Sprinkles = Parameters<typeof sprinkles>[0];
    export const sprinkles = createSprinkles(
      colorProperties
    );
    • 이를 사용하는 단에서는 아래와 같이 사용할 수 있다.
      • sprinkles 함수를 호출하고, 인자로 지정한 property 에 맞는 값을 적용한다.
    import { style } from '@vanilla-extract/css';
    import { sprinkles } from './sprinkles.css.ts';
    
    export const container = style([
      sprinkles({
        color: 'gray-800',
        background: 'blue-50'
      }),
      {
        ':hover': {
          outline: '2px solid currentColor'
        }
      }
    ]);
  2. Variant 기반의 스타일링 시스템 제공

    • recipe 라이브러리를 활용하여 Variant 기반의 스타일링 시스템 제공
    • base 에는 항상 제공될 스타일을, Variant 는 각 속성 값에 따른 스타일을 지정한다.
    import { recipe } from '@vanilla-extract/recipes';
    
    export const button = recipe({
      base: {
        borderRadius: 6
      },
    
      variants: {
        color: {
          neutral: { background: 'whitesmoke' },
          brand: { background: 'blueviolet' },
          accent: { background: 'slateblue' }
        },
        size: {
          small: { padding: 12 },
          medium: { padding: 16 },
          large: { padding: 24 }
        },
        rounded: {
          true: { borderRadius: 999 }
        }
      },
    • 컴포넌트 단에서는 아래와 같이 적용이 가능하다.
      • recipes 에서 제공하는 RecipeVariants 유틸 타입을 활용하면 더 좋다.
    import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react';
    import type { RecipeVariants } from '@vanilla-extract/recipes';
    
    import * as styles from './style.css';
    
    // RecipeVariants 에 의해 style.css.ts 에 정의한 button Recipe 타입 추론
    type ButtonProps = RecipeVariants<typeof styles.button> &
      PropsWithChildren<ComponentPropsWithoutRef<'button'>>;
    
    const Button = ({
      children,
      color,
    	size,
      rounded,
      type = 'button',
    }: ButtonProps) => (
      <button className={styles.button({ color, size, rounded })}>
        {children}
      </button>
    );

📒 참고 문서

profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글