Vite에서 CSS 우선순위를 지키는 법: 우아한공방의 문제 해결기

3 weeks ago 8

들어가며

우아한공방은 배달의민족을 비롯해 다양한 우아한형제들 서비스 전반에서 일관된 UI/UX를 제공하기 위해 구축된 디자인 시스템입니다.

참고: "우아한형제들의 새로운 디자인 시스템 ‘우아한공방’을 소개합니다: 개발 편", WOOWACON 2023 발표 영상

이번 글에서는 Vite 환경에서 발생한 CSS 우선순위 문제를 어떻게 해결해 나갔는지 그 과정을 자세히 풀어보려고 하는데요, 실제로 우아한공방에서 겪은 스타일 우선순위 충돌 이슈가 무엇이었는지, 이를 해결하기 위해 어떤 접근과 시행착오를 거쳤는지를 조금 더 생생하게 이야기해 보려고 해요.

어느 날 갑자기 컴포넌트의 스타일이 깨져 있다면 꽤 당황스러울 거예요. 심지어 코드 수정조차 없었다면 더 혼란스러울 거예요.

아래 이미지는 실제 서비스에 제공되는 우아한공방의 Chip 컴포넌트입니다.

왼쪽처럼 보여야 하는 컴포넌트가, 스타일이 깨지면서 오른쪽처럼 나타나 버렸어요. 신뢰를 기반으로 해야 하는 디자인 시스템에서 이런 식으로 깨진 컴포넌트를 제공해서는 안 되겠죠?

왜 이런 문제가 발생하는지, 또 어떻게 해결할 수 있는지 import 순서에 따른 CSS 우선순위 문제를 해결한 이야기를 통해 소개해 보고자 합니다.

문제 및 요구사항

앞에서 본 Chip 컴포넌트 문제는 정확히 어떤 스타일 문제가 있는 걸까요? 아래 이미지로 살펴보겠습니다.

이미지 예시와 같이 Chip 관련 클래스가 HTML 기본 스타일을 제거하는 reset CSS보다 상위에 있으면 CSS가 덮어써지며 의도한 스타일로 표현되지 않게 돼요.

컴포넌트 추가나 빌드 개선 과정에서 종종 발생하던 이 문제는, 패키지 빌드 시 생성되는 CSS 파일 내 스타일 배치 순서로 인해 스타일 클래스가 역전되는 현상으로 이어졌습니다.

최상단 코드 예시

이를 해결하기 위해 스타일 우선순위 이슈가 생길 때마다 import 및 build 엔트리 순서를 조정하는 방식으로 대응해 왔습니다.

이러한 임시 조치는 코드에 그대로 누적되어 리팩터링을 어렵게 만드는 요인이 되었습니다.

따라서, 근본적인 해결을 위해 두 가지를 목표로 작업을 시작했습니다.

  1. import 순서에 따라 스타일 우선순위가 바뀌는 이유를 명확히 파악한다.
  2. 파악된 이유를 바탕으로, 주석으로 import 순서를 강제하던 임시 코드를 제거한다.

현상 및 원인 파악

Step 1. 현상 파악

우아한공방은 코어 패키지를 중심으로, 테마 패키지(클레이블루·클레이민트)와
이를 확장한 특정 서비스 타겟 패키지(몰드) 구조로 구성되어 있습니다.

우아한공방 계층도

그렇기 때문에 패키지가 확장되면 자연스럽게 style.css 파일도 확장되는 구조를 이루고 있습니다.

이제 패키지가 어떻게 빌드 되는지 살펴보겠습니다.

우아한공방 흐름도

Core 패키지의 흐름도

우아한공방은 Vite 5 버전을 사용하고 있습니다. 코드 단계에서는 Vite 빌드 엔트리를 기준으로 빌드 결과물이 생성되고 있습니다.

  1. 우아한공방 코어 패키지는 멀티 엔트리(vite:entry)를 가지고 있습니다.
    • 외부로 노출되어야 하는 값만 가지고 있는 public
    • 모든 값을 가지고 있는 index
  2. 각 엔트리에서 저마다의 방식으로 스타일을 export 하고 있습니다.
  3. 빌드 결과물은 style.css 한 벌이어야 합니다.
    • public 혹은 index 모든 경우에서 동일한 style을 지정해야 합니다.
  4. VanillaExtract CSS를 사용하고 있으므로 모듈 방식으로 CSS를 관리하고 있습니다.

멀티 엔트리로 인해 빌드 시작점이 달라진다는 것과 스타일이 모듈로 분리되어 있다는 것을 알게 되었습니다.

분리된 스타일은 빌드 시 통합되는데요, 이는 모듈(컴포넌트)의 호출 순서가 중요해진다는 것을 유추할 수 있습니다.

Step 2. 문제 재현

정말 호출 순서에 따라 최종 스타일 파일이 달라지는지 확인해 보겠습니다.

문제 재현

이미지 처럼 빌드가 시작되는 엔트리 지점에서 import(*export)의 순서에 따라 최종 번들 파일인 style.css 파일의 스타일이 변경되는 문제가 있다는 것을 확인할 수 있습니다.

정상적인 스타일 순서는 reset, shared, Flex순입니다. 스타일 초기화가 진행된 이후 공용 스타일이 적용되고 Flex와 같은 컴포넌트 특화 클래스가 적용되어야 합니다.

하지만 우측을 보면 Flex 컴포넌트가 먼저 export되면서 style.css 파일에도 Flex 클래스가 최상단으로 올라온 것을 확인할 수 있습니다.

Step 3. Vite ModuleGraph 분석

import 순서에 따라 style.css가 달라지는 원인을 더 정확히 파악하고자, Vite 내부에서 모듈이 어떤 순서로 로드되는지를 확인해 보았습니다.

Vite는 load → transform → buildEnd 과정을 거치며 빌드를 진행합니다(rollup 공식사이트 build-hooks 참고).

위 이미지에서 Vite 엔트리 지점이었던 public.ts, index.ts 부터 시작해서 각 파일 내부에서 호출하는 다른 파일들을 차례대로 가져오는 것을 알 수 있습니다.

이는 아래 이미지와 같이 ESM import 규칙과 동일하다는 것을 알 수 있습니다.

https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive

load 과정을 차례대로 조합해 보니 ESM import 규칙과 동일하게 호출되고 있다는 것을 알 수 있었습니다. 즉, Vite는 ESM import 규칙 그대로 load하고 있습니다.

Vite가 import를 조작하지 않으므로 load되는 순서(ESM import)대로 style.css가 만들어진다는 가설을 세울 수 있었습니다.

Step 4. Vite Core CSS Plugin 동작 검증

load 되는 순서대로 style.css가 만들어지는지 검증하기 위해 Vite에 내장된 Core CSS Plugin의 동작을 확인해 보겠습니다.

Core CSS 플러그인

Vite v5는 내부적으로 rollup을 가지고 있으며 build 커맨드 호출시 자체 CSS Plugin을 실행합니다(소스코드).
그 후 CSS Plugin은 chunk 단위로 분리된 값을 가져와(load) 붙여주고(+=) 있습니다(소스코드).

즉, 우리는 Vite Core CSS Plugin이 load된 각 청크를 순차적으로 붙이고(+=) 있다는 것을 알게 되었습니다.

이로써 우리는 청크의 순서가 중요하다는 것을 알게 되었습니다. 이제 청크가 어떻게 나뉘는지 확인해 보겠습니다.

Step 5. Vanilla Extract Plugin 동작 확인

앞에서 우아한공방은 VanillaExtract를 사용하고 있다고 했었는데요, 모듈로 나뉜 *.css.ts 파일들은 변환(transform) 과정을 거친 후 각 코드 조각(chunk)이 style.css에 조합되어야 합니다.

따라서 VanillaExtract 플러그인이 어떻게 동작해 chunk를 만든 다음 CSS Plugin에 넘겨주는지 확인할 필요가 있습니다.

// core/vite.config.ts plugins: [ react(), vanillaExtractPlugin() ],

단순한 플러그인 호출 코드

transform 로그

VanillaExtract 플러그인은 transform에서 compiler.processVanillaFile 함수를 호출해 *.css.ts 파일을 *.css.vanilla.css 파일로 컴파일하고 있습니다(소스코드).

즉, Vite에서 파일을 load하면 transfrom 과정에 개입해 *.css.ts 파일을 컴파일하고 해당 chunk를 Core CSS Plugin으로 전달하게 됩니다.

따라서 각 청크는 VanillaExtract Plugin에서 처리되고 Core CSS Plugin에서 조합된다는 것을 알게 되었습니다.

우리는 이 시점에서 1. import 순서에 따라 스타일 우선순위가 바뀌는 이유를 명확히 파악한다는 목표를 이룰 수 있게 되었습니다.

  1. Vite는 ESM import 규칙을 그대로 따릅니다.
  2. 각 *.css.ts 파일이 load된 이후 transform 과정에서 VanillaExtract Plugin이 컴파일한 청크를 만듭니다.
  3. Vite Core CSS Plugin에서 컴파일된 CSS 청크를 style.css에 순차적(+=)으로 붙입니다.

즉, import(호출) 과정에 따라 컴파일 후 순차적으로 조립되므로 import 순서가 중요한 것이었습니다.

문제 해결

Step 6. 해결 방향 탐색

그렇다면 VanillaExtract "청크에 개입"하여 빌드가 끝난 뒤 "재조합"하면 어떠한 경우라도 동일한 우선순위를 보장하지 않을까요?

청크 코드 앞에 주석으로 ID를 남기고 특정 ID를 기준으로 정렬하면 되겠다고 생각했습니다. 그런데 일반 주석은 Vite에서 빌드시 지워지기 때문에 뱅코멘트(/*!)를 활용해 의미 있는 주석이라는 것을 남겨, Vite가 최적화 단계에서 지우지 않도록 합니다.

청크에 개입해 뱅코멘트를 남기는 BangCommentPlugin과 뱅코멘트를 기준으로 정렬하는 ReorderPlugin을 구축해 보겠습니다.

  1. 각 청크 헤더에 뱅코멘트(/*!)를 추가해 ID를 지정합니다.
  2. 뱅코멘트 ID를 기준으로 우선순위 정렬합니다.

원하는 결과는 위 이미지와 같이 청크별로 뱅코멘트 ID를 추가하고 해당 청크 ID 값을 활용해 우선순위 정렬을 하는 것입니다. 이로써 우리는 어떠한 경우라도 동일한 우선순위를 보장할 수 있게 됩니다.

Step 7. Bang Comment Plugin 개발

각 청크 앞에 뱅코멘트를 추가할 플러그인을 개발해 보겠습니다.

Vite 플러그인 개발은 이미 정형화 되어있어 양식을 그대로 따르면 됩니다.

  1. enforce: 'pre' 지정해 Vite Core CSS Plugin보다 먼저 동작하도록 합니다.
  2. 플러그인 순서에 따라 VanillaExtract에서 넘어온 transform 과정에 개입하여 청크 최상단(code 앞)에 뱅코멘트로 이루어진 ID 값을 넣어줍니다.

이로써 load되는 각각의 파일 ID에 맞는 뱅코멘트가 청크의 최상단에 추가되게 됩니다.

Step 8. Reorder Plugin 개발

이제 재정렬 플러그인을 만들어 보겠습니다.

재정렬 플러그인

앞의 BangComment Plugin에서 각 청크 앞에 ID를 지정했으므로 ID를 기준으로 정렬해 줍니다.

  1. enforce: 'post'를 통해 Vite Core CSS Plugin 이후 동작하도록 합니다.
    • 이로써 순차적으로 작성된 style.css 파일 내의 스타일 코드가 재정렬됩니다.
  2. priorityList를 입력받아 최상단으로 가야 할 ID의 순위를 받습니다.
    • priorityList: ['test', 'reset', 'shared']라면 test, reset, shared, …나머지 순서로 청크가 정렬될 것입니다.

위의 과정을 통해 파일의 청크 단위로 개입하고 정렬하기 때문에 import 순서와 무관하게 일관된 순서를 지정할 수 있게 됩니다.

이로써 2. 파악된 이유를 바탕으로, 주석으로 import 순서를 강제하던 임시 코드를 제거한다를 달성할 수 있었습니다.

최상단 코드 해결

강제된 코드 안녕!

이로써 import 순서를 강제해야 하는 기존의 코드를 모두 지울 수 있게 되었습니다.
모든 문제를 해결하였으나, 여기서 마무리하는 것은 조금 아쉬웠습니다.

만약 클래스명이 겹치는 패키지의 스타일을 여러 곳에서 가져올 때 문제는 없을까요?

Step 9. 우아한공방을 넘어서

만약 특정 사용처에서 코어 스타일을 확장한 클레이블루와 클레이민트를 동시에 사용하게 된다면 어떻게 될까요?

블루 → 민트 → global.css 순서로 import 하게 될 경우 블루 → 코어 → 민트 → global 순서로 클래스가 만들어지게 됩니다.

블루 내부의 코어와 민트 내부의 코어가 동일한 클래스 명을 가지고 있기 때문에 중복된 스타일을 뒤에 import 된 민트 영역으로 가져와 코어 스타일이 블루 스타일 이후에 존재하게 됩니다.

블루/민트 스타일은 코어 스타일을 확장하기 때문에, 코어 스타일은 항상 최상단에 있어야 정상적인 CSS 순서라고 볼 수 있습니다.

이 경우도 Step 7, 8을 적용하면 깔끔하게 해결됩니다. 사용처의 vite.config.ts에서 뱅코멘트를 기준으로 정렬하게 되면 결국 CSS가 어떻게 import되어도 정렬되기 때문입니다.

마무리

여기까지 Vite가 CSS 파일을 어떤 흐름으로 번들링하는지 추적하며, 오래된 CSS 정렬 문제를 어떻게 해결했는지 경험을 공유드렸습니다.

스타일이 호출·변환되어 하나의 파일로 합쳐지는 과정에서 원인을 파악했고, 청크 단계에 개입해 동일한 우선순위를 보장하는 방식으로 문제를 정리한 방법도 함께 살펴보았습니다.

정리하면 다음과 같습니다.

  1. Vite는 ESM import 규칙을 그대로 따릅니다.
  2. 각 *.css.ts 파일이 load 된 이후 transform 과정에서 VanillaExtract Plugin이 컴파일한 청크를 만듭니다.
  3. 각 청크 헤더에 뱅코멘트(/*!)를 추가해 ID를 지정합니다.
  4. Vite Core CSS Plugin에서 컴파일된 CSS 청크를 style.css에 순차적(+=)으로 붙입니다.
  5. 뱅코멘트 ID를 기준으로 우선순위 정렬합니다.

이제 우아한공방 개발자들은 코드를 어떻게 리팩터링하더라도 CSS 우선순위 문제는 없다는 확신을 가질 수 있게 되었습니다. 또한 BangComment를 활용해 스타일이 적절한 순서로 적용되었는지, 어떤 컴포넌트의 스타일이 style.css 파일 내 어디에 있는지 더 가시성 있게 분석할 수 있게 되었습니다.

문제의 구조를 파악하고 오픈소스를 분석해 해결책을 조직 환경에 맞게 적용하는 과정은 제게 많은 인사이트를 주었습니다.

이 글이 CSS 우선순위 문제로 고민하시는 분들께도 도움이 되길 바라며 글을 마칩니다.

Read Entire Article