디자인 시스템을 붙이자 반응형이 깨졌다 — CSS 캐스케이드 충돌을 @source로 푼 이야기
사내 디자인 시스템을 예약 앱에 도입하자 lg 반응형이 무력화돼 운영에서 롤백했다. 빌드 산출 CSS에서 규칙 위치로 원인을 확인하고, 세 가지 선택지를 비교해 앱 Tailwind가 DS 클래스를 직접 생성하는 방식으로 재도입한 기록.
관련 프로젝트: 사내 디자인 시스템 · 멀티테넌트 예약 SaaS
배경 — 도입하자마자 롤백
직접 만든 디자인 시스템(DS)의 첫 소비처가 예약 앱이었다. 위험이 낮은 컴포넌트 4종(Badge·Switch·Checkbox·Toast)부터 바꿔 끼웠는데, 배포하자 화면 곳곳의 반응형이 풀렸다. 데스크톱에서만 보여야 할 사이드바가 모바일에 나오고, lg:flex-row여야 할 영역이 세로로 쌓였다.
운영은 도입 이전 상태로 롤백했다. 개발 브랜치에서는 깨진 자리마다 lg:flex!처럼 !important를 붙여 가며 버텼고, 후속 커밋이 새로 깨지는 곳을 계속 쫓아가는 상태가 됐다.
원인 — 번들을 직접 열었다
!로 이기고 있다는 건 같은 특이도의 규칙이 뒤에서 덮고 있다는 뜻이다. 추측하지 않고 next build가 만든 CSS 파일에서 규칙 위치를 찾았다.
| 규칙 | 번들 내 위치(offset) |
|---|---|
@media (min-width:1281px) { .lg\:flex … } | 58,538 |
최종적으로 남은 .flex { display:flex } | 68,906 |
.flex가 lg:flex 미디어 블록보다 뒤에 있었다. 같은 레이어·같은 특이도면 뒤에 오는 규칙이 이기므로, 화면이 1281px 이상이어도 lg:flex는 항상 졌다.
그러면 .flex는 왜 뒤로 갔을까. DS 패키지의 styles.css를 열어 보니 테마 변수·preflight와 함께, @layer utilities 안에 bare 유틸리티 376개(.flex .hidden .w-full …)가 prefix도 레이어 이름 분리도 없이 들어 있었다. 앱도 같은 이름의 레이어에 같은 유틸리티를 만든다. 빌드 과정에서 중복된 .flex가 하나로 합쳐질 때 DS 쪽 위치가 남았고, 그 위치가 앱의 미디어 블록보다 뒤였다.
"DS가 반응형을 이긴다"는 현상은 레이어 이름 충돌과 중복 규칙 병합 위치가 겹친 결과였다. 덤으로 DS CSS는 lg 브레이크포인트를 1024px로 컴파일하고 있어, 앱(1281px)과 기준 자체도 갈라져 있었다.
선택지 — 세 가지를 비교했다
| 안 | 판단 |
|---|---|
| Cascade Layers 재시도 | 처음 시도가 실패한 이유를 다시 보니 컴파일러 결함이 아니라 레이어 순서(DS 레이어를 base 앞에 둠) 문제였다. 순서를 고치면 동작은 한다. 하지만 중복 CSS·중복 preflight·1024px 브레이크포인트가 그대로 남는다 |
!important 유지 | 이미 30여 개 파일에 퍼져 있었고, 새 화면을 만들 때마다 같은 충돌이 다시 난다. 문제를 소비처에 떠넘기는 방식 |
@source (채택) | DS의 컴파일된 유틸리티를 가져오지 않고, DS가 쓰는 클래스 문자열만 앱 Tailwind가 스캔해 직접 생성한다. 유틸리티가 한 레이어에서 한 번만 만들어지니 순서 문제가 생길 수 없다. DS 패키지를 고칠 필요도 없다 |
적용 — 앱 설정 3줄
@import "tailwindcss";
- @import "@design-system/styles.css";
+ @import "@design-system/tokens.css"; /* :root 토큰 변수 */
+ @import "@design-system/theme.css"; /* @theme 매핑 */
+ @source "../../node_modules/@design-system/dist";
/* 패키지명은 글에서 단순화했다 */토큰과 테마는 그대로 가져오고, 컴포넌트가 쓰는 클래스는 앱이 만든다. DS 컴포넌트는 내부에서 tailwind-merge로 className을 합치기 때문에, 소비처에서 넘기는 오버라이드도 레이어 순서와 무관하게 유지된다.
검증은 빌드 산출 CSS를 before/after로 비교했다.
.flex가lg:flex보다 앞으로 정상화, preflight 1회- CSS 변수 값 전부 동일
- DS 전용 유틸리티(DS 토큰 기반 간격·높이 등)가 앱 번들에서 생성됨
- lg 브레이크포인트가 앱 기준 하나로 통일
- 사라진 앱 셀렉터 9개는 DS 저장소의 스토리·스크립트에서만 쓰이던 것으로, 배포된 DS 코드에서는 참조 0건
그다음 우회용 ! 오버라이드 116건을 걷어냈다. 전부 제거한 상태로 1350px·375px에서 반응형이 정상 동작하는 것을 확인했다.
운영 재도입 — 롤백 커밋을 먼저 되돌린다
운영 브랜치에는 도입을 되돌린 롤백 커밋이 있었다. 개발 브랜치를 그대로 머지하면, 머지 기준점이 롤백 이전이라 스타일은 DS인데 일부 컴포넌트는 롤백 상태로 남는 하이브리드가 된다(merge-tree로 미리 확인). 그래서 롤백 커밋을 먼저 revert하고 머지했다. 배포 후 운영 번들에서 규칙 순서와 반응형(1350/375px)을 다시 확인했다.
배운 점
!important가 필요하다는 건 증상이다. 몇 개를 붙였는지가 아니라 왜 지는지를 번들에서 확인해야 원인이 보인다.- 디자인 시스템의 산출물 형태가 소비처의 CSS 구조를 결정한다. 미리 컴파일한 유틸리티를 배포하면 앱의 유틸리티와 같은 공간에서 부딪친다. 토큰은 배포하고 유틸리티는 소비처가 만들게 하는 편이 경계가 깔끔하다.
- 이전 시도가 실패한 이유를 다시 본다. Cascade Layers는 "안 되는 방법"이 아니라 "순서를 잘못 둔 방법"이었다. 그래도 채택하지 않은 건 동작 여부가 아니라 남는 비용 때문이었다.