[STOCAT] vanilla-extract에서 CVA 패턴 쓰기 : @vanilla-extract/recipes 도입기

 


이 글은 STOCAT 프로젝트에서 디자인 시스템 토큰을 정리하면서 발견한 코드 개선 경험을 기록한 글이다.

 


 

발단 : styleVariants가 슬슬 불편해지기 시작했다

이 프로젝트는 @vanilla-extract/css를 처음 도입하여, 스타일을 관리한다.

컴포넌트에 여러 외형이 필요할 때는 styleVariants를 써왔는데, MarketCard 같은 컴포넌트에서 이런 식으로 작성했었다.

// MarketCard.css.ts
const base = style({
  flexShrink: 0,
  borderRadius: 16,
  backgroundColor: vars.color.role.background2,
});

export const layout = styleVariants({
  horizontal: [base, { display: "flex", flexDirection: "row", width: 260 }],
  vertical: [base, { display: "flex", flexDirection: "column", width: 160, height: 160 }],
});

 

그리고 컴포넌트에서는,

<div className={styles.layout[variant]}>

 

딱 여기까지는 괜찮다.

 

근데 이번에 HomeExchange 컴포넌트를 3가지 variant로 만들어야 할 상황이었다.

 

 

만들어야 할 컴포넌트 이미지

 

varaint를 각각 default, compact, highlight라고 대강 이름을 지었다.

 

그리고 variant마다 달라져야 하는 게 container 레이아웃뿐만 아니라 아이콘 크기, 텍스트 그룹 정렬, 환율 텍스트 굵기까지 모두 달랐다.

styleVariants를 그대로 쓰면 이렇게 된다.

export const container = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const icon = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const textGroup = styleVariants({ default: {...}, compact: {...}, highlight: {...} });
export const rate = styleVariants({ default: {...}, compact: {...}, highlight: {...} });

 

그리고 컴포넌트에서는 styles.container[variant], styles.icon[variant], styles.textGroup[variant]... 이렇게 네 번 반복하게 된다.

실수할 여지도 있고, 새 variant가 추가되면 모든 styleVariants에 빠짐없이 추가해야해서 여러모로 유지보수에 불편하다.

 

이 상태에서 @vanilla-extract/recipes라는 패키지(?)를 발견했다.

- https://vanilla-extract.style/documentation/packages/recipes/


 

recipe가 뭔데?

@vanilla-extract/css와 별개로 설치하는 패키지다.

React 생태계에서 CVA(class-variance-authority)라는 라이브러리를 써본 적이 있다면 구조가 거의 비슷하다

 

CVA는 보통 Tailwind와 함께 이런 식으로 쓴다.

// CVA (class-variance-authority)
const button = cva("base-class", {
  variants: {
    intent: {
      primary: "bg-blue-500",
      danger: "bg-red-500",
    },
  },
  defaultVariants: {
    intent: "primary",
  },
});

// 사용: button({ intent: "primary" })

 

recipe는 이 패턴을 vanilla-extract 환경에서 그대로 쓸 수 있게 해준다.

차이가 있다면 클래스 이름 문자열이 아니라 vanilla-extract의 스타일 객체를 쓴다는 것,

그리고 빌드 타임에 CSS가 정적으로 생성된다는 것 정도.

// @vanilla-extract/recipes
export const container = recipe({
  base: { /* 공통 스타일 */ },
  variants: {
    variant: {
      default: { /* variant별 스타일 */ },
      compact: { /* ... */ },
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

// 사용: styles.container({ variant: "default" })

 

styleVariants와 비교하면 이렇다.

  styleVariants recipe
여러 prop이 같은 variant를 공유 각각 별도 styleVariants 정의 하나의 recipe로 묶음
기본값 지정 불가 defaultVariants로 지원
사용 방식 styles.layout[variant] styles.container({ variant })
새 variant 추가 시 styleVariants마다 수동 추가 한 곳에서 관리

 


 

실제 구현 화면

 

HomeExchange는 3가지 variant가 있다. 

  • default: 아이콘+제목이 좌측, 환율이 우측 (space-between 레이아웃)
  • compact: 아이콘 + "환전소 · 1,444.78" 합쳐진 텍스트를 나란히
  • highlight: 큰 이미지 아이콘이 좌상단에 절대 위치, 텍스트는 중앙 정렬

스타일 파일은 이렇게 구성했다.

// HomeExchange.css.ts
import { recipe, RecipeVariants } from "@vanilla-extract/recipes";
import { style } from "@vanilla-extract/css";
import { vars } from "@/shared/styles/vars.css";

export const container = recipe({
  base: {
    marginTop: 8,
    padding: "16px 30px",
    width: "100%",
    height: 70,
    backgroundColor: vars.color.role.background2,
    borderRadius: 16,
    overflow: "hidden",
  },
  variants: {
    variant: {
      default: {
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
      },
      compact: {
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        gap: 16,
      },
      highlight: {
        position: "relative",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 8,
      },
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

export type ContainerVariants = RecipeVariants<typeof container>;

export const icon = recipe({
  base: { flexShrink: 0 },
  variants: {
    variant: {
      default: { width: 28, height: 28 },
      compact: { width: 28, height: 28 },
      highlight: {
        position: "absolute",
        top: -20,
        left: 0,
        width: 100,
        height: 100,
      },
    },
  },
  defaultVariants: { variant: "default" },
});

export const rate = recipe({
  base: { color: vars.color.role.text },
  variants: {
    variant: {
      default: { ...vars.typography.body4 },
      compact: { ...vars.typography.body3 },
      highlight: { ...vars.typography.body3 },
    },
  },
});

 

컴포넌트에서는 이렇게 쓴다.

// HomeExchange.tsx
export default function HomeExchange({ variant, title, description, rate }: HomeExchangeProps) {
  return (
    <div className={styles.container({ variant })}>
      {variant === "default" && (
        <>
          <div className={styles.defaultIconWrapper}>
            <HomeCoinOne className={styles.icon({ variant })} />
            <div className={styles.textGroup({ variant })}>
              <span className={styles.subText}>{description}</span>
              <span className={styles.title}>{title}</span>
            </div>
          </div>
          <span className={styles.rate({ variant })}>{rate.toLocaleString()}</span>
        </>
      )}
      {variant === "compact" && (
        <>
          <HomeCoinOne className={styles.icon({ variant })} />
          <div className={styles.textGroup({ variant })}>
            <span className={styles.subText}>{description}</span>
            <span className={styles.title}>{title} · {rate.toLocaleString()}</span>
          </div>
        </>
      )}
    </div>
  );
}

 

styles.container({ variant }), styles.icon({ variant }),

styles.rate({ variant }) — variant 값 하나를 넘기면 각 recipe가 조합된 클래스 이름을 반환한다.

 


 

RecipeVariants가 하는 일

@vanilla-extract/recipes는 타입 유틸리티도 제공한다.

export type ContainerVariants = RecipeVariants<typeof container>;
// 결과:
// { variant?: "default" | "compact" | "highlight" }

 

recipe 정의에서 variant 목록을 자동으로 추출해서 타입으로 만들어준다.

variant를 추가하거나 이름을 바꾸면 CSS 파일 한 곳만 수정해도 props 타입까지 자동으로 반영된다.

지금은 HomeExchangeProps에서 직접 "default" | "compact" | "highlight"를 유니온으로 쓰고 있지만,

ContainerVariants를 확장하면 CSS와 타입이 항상 동기화된 상태를 유지할 수 있다.

 


 

직접 사용해보고 나서

좋았던 점은 관리하기 편하다는 점(?)이다.  variant가 달라질 때 영향을 받는 스타일들이 한 파일 안에서, 같은 recipe 블록 안에서 관리된다. MarketCardstyleVariants 방식도 나쁘지 않았지만, 스타일 요소가 많아질수록 "이 variant에 이 스타일이 빠진 건 아닐까" 하는 불안이 없어지는 게 체감됐다.

아쉬운 점이라면 recipe가 처음엔 좀 낯설다는 것. styleVariants는 결과가 객체라서 직관적으로 인덱싱할 수 있는데, recipe는 함수라서 "왜 호출해야 하지?"라는 의문이 드는 사람도 있을 것 같다. 빌드 타임에 정적 CSS를 생성하는 vanilla-extract 특성상 런타임 조합이 아니라 미리 정해진 클래스 이름을 반환하는 구조여서 그렇다. 함수처럼 보이지만 사실 사전에 만들어진 클래스 이름을 선택하는 것에 가깝다.

 

@vanilla-extract/css를 이미 쓰고 있다면 별도 패키지를 하나 더 설치하는 게 전부라 진입 장벽도 낮다. 같은 패키지 생태계 안에서 CVA 패턴을 쓸 수 있다는 게 생각보다 편했다.