Vanilla Extract

January 10, 2025


도입 배경

Vanilla Extract을 사용해본 계기는 Next.js의 앱 라우터 환경에서 기존 CSS in js 라이브러리들의 한계를 느꼈기 때문입니다.

css in js 라이브러리는 CSR 환경에서 동작하도록 설계되었으므로 Next.js의 서버 컴포넌트에서 사용하지 못합니다. 그리고 다음으로 문제가 되는 것은 클라이언트 컴포넌트에 사용하더라도 SSR 환경에서 동작 할 때에 초기 하이드레이션 과정을 거칠때에 서버와 클라이언트의 렌더링 결과가 불일치하는 문제가 발생합니다.

SSR 환경에서의 문제점

서버 컴포넌트가 동작 할때에 하위 클라이언트 컴포넌트를 아예 렌더링하지 않는것이 아니라 정적으로 표현 가능한 부분은 같이 서버에서 렌더링해서 결과를 내려주게 됩니다.

그리고 전체 html 구조가 갖추어지고 클라이언트로 전달되어 클라이언트에서 렌더링한것과 서버의 렌더링 결과를 비교하면서 초기 하이드레이션을 진행하게 됩니다. 이때 CSS in js 라이브러리는 클래스명을 랜덤한 패턴으로 생성하게 되는데 이로 인해 서버와 클라이언트 간의 구조가 불일치하는 문제가 발생하여 하이드레이션의 문제가 생깁니다.

기존 해결책의 한계

이런 문제를 해결하기 위해서는 css in js 라이브러리와 SSR 프레임워크 둘다 최적화 하는 방법을 제공해야합니다.

예를 들어 Styled components는 서버 컴포넌트에 프로바이더를 선언하여 서버 사이드에서 클라이언트 스타일을 수집하고 스타일시트 인스턴스를 클라이언트로 내려주게 되어 해당 인스턴스로 클라이언트는 스타일으르 사용합니다.

그리고 클래스명이 랜덤하게 생기는 문제는 Next.js 설정에서 각 css in js 라이브러리에 대해 옵션을 제공하여 Next.js 컴파일 타임에 결정적으로 하나의 Styled component가 생성하는 클래스명이 서버와 클라이언트에서 동일한게 생성 되도록 보장하도록 알고리즘을 제공해아합니다.

SSR 환경에서 CSS-in-JS 사용의 어려움

이렇게 SSR 환경에서 css in js 라이브러리를 사용하는 것은 어렵습니다.

  1. 프레임워크 컴파일 과정에서 초기 하이드레이션 불일치 문제를 해결하기 위해 컴파일 옵션을 별도로 제공해야 합니다.
  2. 서버와 클라이언트 컴포넌트에서 동일한 스타일 정보를 활용할 수 있도록 서버에서 별도의 스타일 수집 방법을 제공해야 합니다.
  3. 서버 컴포넌트에서 css in js 라이브러리를 사용하려면 클라이언트 컴포넌트로 감싼 후 사용해야 합니다.

결국 이러한 문제들로 인해 회의감을 가지게 되었습니다. 이렇게 쓸것이라면 그냥 일반적인 sass를 이용해서 스타일을 사용하는 것이 훨씬 편하것이라는 생각이 들었습니다. 그리고 여러가지 방법을 찾던 중 css를 빌드 타임에 모두 정적으로 만들어서 서버와 클라이언트 모두 사용 할때 문제가 없게 하는 방법이 있었습니다. 그런 방법을 Vanilla Extract이 제공했습니다.

Vanilla Extract의 장점

SSR 환경에서 css in js 문제를 해결하고자 Vanilla Extract을 사용했지만 더 많은 이점들을 발견했습니다.

빌드 타임 스타일링의 과제

그런데 생각해보면 왜 Styled components 같은 라이브러리들은 빌드 타임에 정적으로 스타일을 생성하지 못하는 것일까 궁금해졌습니다.

왜냐하면 Styled components도 props를 받아 케이스에 따라 스타일을 정의하는 것이 가능하고 결국 이는 props에 따라 정적으로 컴파일 하는것이 가능하는 이야기 였습니다.

예를 들어 props로 타입을 'default' | 'outline' 이라는 타입을 선언하고 해당 타입에 따라 스타일을 정의한다면 결국 이것은 빌드타임에 예측가능한 클래스를 생성할수 있다 생각했습니다.

동적 스칼라 값 처리의 문제

그러던 중 근본적으로 해결하지 못하는 문제를 발견했는데 그것은 바로 스칼라 값이 props로 넘어올때였습니다.

범위가 정해지지 않은 값, 즉 픽셀 단위의 값이 props로 전달되고 이 값으 스타일에 바로 바인딩 된다면 빌드타임에 예측 할 수 없었습니다. 그런데 이것은 결국 Vanilla Extract 라이브러리도 마찬가지라 생각했습니다.

Vanilla Extract의 해결책: CSS 변수 활용

그러면 이런 문제를 Vanilla Extract에서는 어떻게 해결 할까요? 이 문제를 해결하기 위한 아이디어가 놀라웠습니다.

바로 css 변수를 만들고 이 변수를 특정 스타일에 바인딩을 합니다. 그리고 픽셀 단위와 같은 범위가 정해지지 않은 값은 css 변수의 값으로 외부에서 주입해서 동적으로 스타일을 변경하게 만드는 전략이였습니다.

이는 빌드 할때에는 css 변수와 특정 스타일이 바인딩 된다면 빌드 하는데 아무런 문제가 되지 않았습니다. 다만 런타임에 해당 css 변수의 값을 변경해가면서 동적으로 스타일을 변경하면 그만이였습니다.

물론 런타임 비용이 제로는 아닙니다. 하지만 다른 라이브러리 들은 동적으로 props가 변경되면 새로운 스타일을 생성하는 문제가 있는데 이에 비해서는 상당히 효율적으로 동작합니다.

구조적 CSS 작성 유도

그리고 Vanilla Extract 라이브러리는 빌드 타임에 예측 가능하도록 작성해야 하므로, 대부분의 스타일을 Variant를 이용해서 케이스를 나누고 각 케이스에 대해 스타일을 선언하는 방식을 구성하도록 유도합니다.

이러한 아키텍처는 css를 구조적으로 만드는데 아주 좋은 방향성입니다. css는 작게 시작하지만 프로젝트가 커지면 점점 관리하기 어려워지는 것 중 대표적인 코드입니다. 그렇기에 처음부터 스타일을 구조적으로 선언하여 사용 할 수 있게 코드를 짜도록 유도하는 것은 좋습니다.

저도 작은 프로젝트를 시작하더라도 테마를 작게나마 만들고 그렇게 만들어진 테마를 이용해 ui 컴포넌트를 구성하는데, Vanilla Extract은 이런 저에게 잘 맞는 라이브러리였습니다.

스타일 캡슐화와 예측 가능성

그리고 Vanilla Extract의 방향성 중 하나는 상위 클래스에서 선택자로 특정 엘리먼트를 선택하여 가상 클래스의 스타일을 변경하는 것은 빌드 타임에 에러를 발생시킵니다. 상위에서 하위 선택자로 스타일을 변경하는 것은 스타일을 덮어 씌울수 있는 문제가 있습니다. 그래서 특정 클래스를 지정하고 해당 클래스의 가상 클래스의 스타일을 변경하도록 합니다.

레시피를 통한 구조적 스타일링

마지막으로 부가적인 패키지로 기능을 제공하는데 유용한 것 중 레시피 라는 패키지가 존재합니다. 이것은 위에서 말한 스타일을 Variant를 통한 구조적인 css 작성을 할 수 있게 만듭니다.

아래는 레시피를 이용한 스타일 정의하는 방법입니다. 코드를 보면 base 속성을 통해 모든 variant가 동일하게 가지는 스타일을 선언하고 variants 속성을 통해 각 variant에 대한 클래스를 선언합니다. 실제로 사용하는 방식은 함수의 인자로 variant 값을 전달하게 되면 그에 따라 동적으로 스타일이 변경됩니다.

const Link = recipe({
  base: {
    fontSize: themeVars.text.size.sm,
    color: themeVars.text.secondary,
    textDecoration: 'none',
  },
  variants: {
    active: {
      true: {
        fontWeight: themeVars.text.weight.medium,
        textDecoration: 'underline',
        textUnderlineOffset: '4px',
      },
    },
  },
});

<Link className={Link({ active: pathName === '/' })} href="/">Home</Link>