뒤로가기

Vanilla Extract: Next.js 서버 컴포넌트를 위한 빌드타임 CSS-in-JS

vanilla-extract

Next.js 앱 라우터 환경에서 서버 컴포넌트를 도입하면서 기존 CSS-in-JS 라이브러리들의 근본적인 한계를 명확히 인지하게 되었습니다. 런타임 스타일 생성 방식은 서버 컴포넌트와 호환되지 않으며, 클라이언트 컴포넌트에서조차 SSR 환경의 하이드레이션 불일치 문제를 야기합니다.

이 글에서는 이러한 문제들을 근본적으로 해결하는 Vanilla Extract의 빌드타임 CSS 생성 접근법과, 그 과정에서 발견한 구조적 스타일링의 장점을 살펴보겠습니다.

CSS-in-JS의 SSR 한계

서버 컴포넌트 비호환성

CSS-in-JS 라이브러리는 CSR 환경에서 동작하도록 설계되었으므로 Next.js의 서버 컴포넌트에서 사용할 수 없습니다. 런타임에 스타일을 생성하는 방식은 서버 환경에서 근본적으로 작동하지 않습니다.

하이드레이션 불일치 문제

클라이언트 컴포넌트에서 사용하더라도 SSR 환경에서 문제가 발생합니다. 서버 컴포넌트는 하위 클라이언트 컴포넌트를 완전히 건너뛰지 않고, 정적으로 표현 가능한 부분은 서버에서 렌더링하여 HTML을 생성합니다.

이후 클라이언트에서 하이드레이션을 진행할 때, CSS-in-JS 라이브러리가 생성하는 랜덤 클래스명으로 인해 서버와 클라이언트 간의 구조가 불일치하는 문제가 발생합니다. 이는 하이드레이션 오류를 초래하고 사용자 경험을 저해합니다.

기존 해결책의 복잡성

이러한 문제를 해결하기 위해서는 CSS-in-JS 라이브러리와 SSR 프레임워크 양쪽에서 최적화를 제공해야 합니다.

Styled Components를 예로 들면, 서버 컴포넌트에 프로바이더를 선언하여 서버 사이드에서 클라이언트 스타일을 수집하고, 스타일시트 인스턴스를 클라이언트로 전달하는 방식을 사용합니다. 또한 Next.js는 각 CSS-in-JS 라이브러리에 대한 컴파일 옵션을 제공하여, 서버와 클라이언트에서 동일한 클래스명이 생성되도록 보장합니다.

SSR 환경에서의 요구사항

이러한 방식은 다음과 같은 복잡성을 수반합니다:

  1. 프레임워크 설정: 하이드레이션 불일치 문제 해결을 위한 별도의 컴파일 옵션 설정이 필요합니다.
  2. 스타일 수집 메커니즘: 서버와 클라이언트에서 동일한 스타일 정보를 활용하기 위한 별도의 수집 방법이 필요합니다.
  3. 컴포넌트 래핑: 서버 컴포넌트에서 CSS-in-JS를 사용하려면 클라이언트 컴포넌트로 감싸야 합니다.

이러한 복잡성은 CSS-in-JS의 근본적인 설계 방향에서 비롯됩니다. 빌드타임에 CSS를 정적으로 생성한다면 이러한 문제들을 모두 해결할 수 있습니다.

Vanilla Extract의 접근법

Vanilla Extract는 CSS를 빌드타임에 정적으로 생성하여 서버와 클라이언트 양쪽에서 문제없이 사용할 수 있게 합니다. 이는 SSR 문제를 해결하는 것을 넘어서, 구조적 스타일링의 여러 장점을 제공합니다.

빌드타임 스타일 생성의 과제

빌드타임 생성 방식에는 근본적인 과제가 있습니다. Styled Components와 같은 라이브러리도 props를 받아 조건부 스타일을 적용할 수 있는데, 왜 빌드타임에 정적으로 생성하지 못하는 것일까요?

타입이 'default' | 'outline'처럼 명확히 정의된 경우, 각 케이스에 대한 스타일을 빌드타임에 예측 가능한 클래스로 생성할 수 있습니다. 하지만 범위가 정해지지 않은 스칼라 값이 문제입니다.

동적 값 처리: CSS 변수 활용

픽셀 단위와 같이 범위가 정해지지 않은 값이 props로 전달되고 스타일에 직접 바인딩된다면, 빌드타임에 예측할 수 없습니다. Vanilla Extract는 이 문제를 CSS 변수로 해결합니다.

핵심 아이디어는 다음과 같습니다:

  1. 빌드타임: CSS 변수를 선언하고 특정 스타일에 바인딩합니다.
  2. 런타임: 동적 값을 CSS 변수의 값으로 주입하여 스타일을 변경합니다.

이 방식은 빌드타임에 CSS 변수와 스타일 바인딩만 정의하면 되므로 정적 생성이 가능합니다. 런타임 비용이 완전히 제로는 아니지만, 동적으로 새로운 스타일을 생성하는 다른 라이브러리들에 비해 훨씬 효율적입니다.

구조적 스타일링의 강제

Vanilla Extract는 빌드타임에 예측 가능하도록 작성해야 하므로, 대부분의 스타일을 Variant 패턴으로 케이스를 나누고 각 케이스에 대한 스타일을 명시적으로 선언하도록 유도합니다.

이러한 아키텍처는 CSS를 구조적으로 작성하는 데 효과적입니다. CSS는 작게 시작하지만 프로젝트가 커지면 관리하기 어려워지는 대표적인 코드입니다. 처음부터 스타일을 구조적으로 선언하도록 유도하는 것은 장기적인 유지보수성을 향상시킵니다.

레시피 패턴

Vanilla Extract는 레시피(Recipe) 패키지를 통해 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>

base 속성은 모든 variant가 공통으로 가지는 스타일을 정의하고, variants 속성은 각 케이스별 스타일을 선언합니다. 함수 인자로 variant 값을 전달하면 해당하는 스타일이 적용됩니다.

스타일 캡슐화

Vanilla Extract는 상위 클래스에서 선택자로 하위 엘리먼트를 선택하여 스타일을 변경하는 것을 빌드타임에 에러로 처리합니다. 상위에서 하위 선택자로 스타일을 변경하는 것은 스타일 우선순위 문제를 야기하고 예측 가능성을 저해합니다.

대신 특정 클래스를 직접 지정하고 해당 클래스의 가상 클래스 스타일을 변경하도록 강제합니다. 이는 스타일의 캡슐화를 보장하고 의도하지 않은 스타일 오버라이드를 방지합니다.

결론

Vanilla Extract는 빌드타임 CSS 생성을 통해 Next.js 서버 컴포넌트의 SSR 문제를 근본적으로 해결합니다. CSS 변수를 활용한 동적 값 처리, Variant 기반의 구조적 스타일링, 그리고 스타일 캡슐화를 통해 확장 가능하고 유지보수 가능한 CSS 아키텍처를 제공합니다.

런타임 스타일 생성의 복잡성에서 벗어나 빌드타임에 모든 스타일을 정적으로 준비함으로써, 더 나은 성능과 명확한 코드 구조를 얻을 수 있습니다.