Chakra UI v2를 버리고 Tailwind v4로 변환.
AI를 사용하기 전까지, UI 프레임워크의 전환은 생각만 해오던 일, 언젠가 해야할 목표 중 하나였다. 회사 내 여러 프로젝트 중 백오피스에 먼저 마이그레이션을 시도하여 성공한 이야기다. (이후 회사 SaaS도 마이그레이션에 문제없이 성공하였다.)
1. 왜 바꾸고 싶었을까
Chakra는 "같은 스타일로 구현하는 방법”이 너무 많다. 같은 패딩 16px을 주는 데도 누구는 <Box p={4}>, 누구는 <Box p="16px">, 누구는 <Box sx={{ p: 4 }}>, 누구는 <Box style={{ padding: '16px' }}>로 썼다. 색상도 마찬가지였다. bg="blue.500", bg="#3182ce", sx={{ bg: 'blue.500' }}, backgroundColor="blue.500".
누가봐도 중구난방인 css 체계였다. 이건 오롯이 Chakra의 문제라기보단, 그 기준을 명확히 구분하지 않고 작성한 “내 문제” 이기도 했다. 추후 여러 인턴과 후임 프론트엔드 개발자가 들어왔을때도 명확하게 어떻게 써라, 하기 애매했다.
어차피 화면에 디자이너가 말한 형태로 나오면 되는게 아닐까?라는 안일한 생각이었다. 그러다 작년 항해 플러스의 디자인 시스템 주차를 듣게 되며, 처음 제시해 준 제멋대로인 오류스러운 코드가 내가 작성하는 회사 코드와 다를 바 없구나를 절절하게 느끼며 혼자 수치심을 느꼈다.
코드의 일관성, 협업, 확장성, 유지보수를 생각하니 정말 이렇게해서는 안될 것 같다 생각이 들었다.
언제나 생각만, 고민만 하던 Tailwind로의 전환은 AI의 발전과 활용이 높아지며 생각을 넘어 실행에 더욱 박차를 가하게 됐다.
1-1. 선택지 정리하기
Panda CSS, vanilla-extract와 같은 다양한 CSS 프레임워크가 있다. 생태계를 무시할 수 없겠더라. 고민할 필요가 많이 없었지만 Chakra의 버전 변경이냐, Tailwind냐의 생각이었는데,
Chakra v3: 익숙한 사고방식, 적은 학습 비용. 하지만 여전히 런타임 CSS-in-JS, 여전히 "같은 걸 다섯 가지 방법으로 쓰는" 문제, 여전히 소수파 생태계.
Tailwind v4: 학습 비용은 들지만 단일 표현 방식, AI 친화적, 압도적 다수파, 런타임 비용 0.
같은 마이그레이션 비용이라면 테일윈드로 가는 게 맞아 보였다. 더욱이, Chakra가 느린 데는 CSS-in-JS라는 구조적 이유가 있었다.
2. 왜 CSS-in-JS는 느린가
브라우저 입장에서 보는 웹페이지
간단히, 웹페이지가 화면에 그려지려면 브라우저는 두 가지만 있으면 된다.
- HTML: 무엇이 있는지
- CSS: 어떻게 생겼는지
JavaScript는 원래 동작을 담당하는 언어지, 스타일과는 상관이 없다. 그런데 어느 시점부터 개발자들이 이런 생각을 하기 시작했다.
"JS 안에 스타일도 같이 쓰면 편하지 않을까? 컴포넌트 단위로 스타일이 캡슐화되고, props로 동적 스타일도 쉽게 줄 수 있고."
그래서 등장한 게 CSS-in-JS 패턴이다. styled-components, Emotion, 그리고 Emotion 위에 만들어진 Chakra UI v2가 모두 이 계열이다.
식당에 비유하자면
Chakra (CSS-in-JS): 주문 받고 그때그때 요리하는 식당
손님이 들어올 때마다 주문을 받고, 재료를 꺼내고, 칼질하고, 볶아서 접시에 담아 내준다. 한 명일 땐 괜찮지만 백 명이 몰리면 주방이 터진다. 메뉴를 매번 처음부터 만드니까.
Tailwind (빌드타임 CSS): 미리 만들어둔 도시락 가게
문 열기 전날 밤에 필요한 메뉴를 다 뽑아서 도시락을 만들어둔다. 손님이 오면 꺼내서 건네주기만 한다. 주문 시점의 일은 "꺼내기"뿐이다.
코드로 보는 차이
같은 버튼을 두 방식으로 짜보면 차이가 분명해진다.
Chakra 방식
<Button bg="blue.500" borderRadius="md" px={4}>저장</Button>이 JSX가 사용자 브라우저에서 실행될 때 실제로 벌어지는 일:
- 버튼이 화면에 나타나려고 함
- Emotion 라이브러리가
bg=blue.500, borderRadius=md…를 읽음 - 해시 함수로 고유 클래스명을 생성 →
css-1a2b3c - CSS 문자열을 만듦 →
.css-1a2b3c { background: #3182ce; ... } <head>의<style>태그에 동적으로 주입- 그제서야 브라우저가 그림
버튼 하나 그리는 데 6단계다. 그리고 이 작업을 가능하게 하는 Emotion 런타임 코드(스타일을 직렬화하고 클래스명을 생성하고 DOM에 주입하는 로직) 자체가 JS 번들에 포함되어 사용자에게 다운로드된다. 정확히는 "CSS 텍스트가 JS 파일에 들어있다"기보다, "런타임에 CSS를 만들어내는 로직과 그 입력이 되는 스타일 객체들"이 JS에 들어있다는 게 맞다.
Tailwind 방식
<button className="bg-blue-500 rounded-md px-4">저장</button>이건 npm run build 시점에 한 번 처리되고 끝이다.
- Tailwind가 소스코드 전체를 스캔
bg-blue-500 rounded-md px-4같은 클래스를 수집- 해당하는 CSS를 계산해서
app.css하나에 저장
사용자 브라우저는 그냥 미리 만들어진 CSS를 받아 그리기만 한다. JS 한 줄도 실행하지 않는다.
Chakra는 사용자 브라우저에서 실시간으로 스타일을 만들어내고, Tailwind는 우리가 빌드할 때 한 번 만들어둔 스타일을 가져다 쓴다.
CSS-in-JS의 생태계가 점차 줄어드는 데에는 여러 이유가 있다고 생각하는데, 런타임 비용 외에도 React Server Components와의 호환성 문제, 주요 라이브러리들의 메인테이너 변경 등이 자주 거론되는 요인이다. 런타임 비용 문제도 그중 하나로 한몫 했지 않을까 싶다.
3. "지금" 변경하기로 한 또 다른 중요한 이유
3-1. Chakra v2의 업데이트 중지
일단, v2는 더 이상 업데이트가 없다. 가장 큰 문제다.
또 Chakra v2 → v3는 사실상 다른 라이브러리 수준의 재작성이다. 컴포넌트 API, 테마 시스템, 토큰 방식이 모두 바뀐다.
즉, "Chakra를 계속 쓸 거면" 어차피 대규모 마이그레이션 비용을 감당해야 한다. 같은 비용이라면 더 나은 곳(많이 쓰고, AI도 잘 이해하는)으로 가는 게 합리적이다.
3-2. 디자인 시스템 - Figma 토큰과의 연결
최근 다시 디자인 시스템을 재정립하며 디자이너와 소통을 자주 하게 됐다.
기존 디자인 시스템은 이전 디자이너와 프론트 개발자가 (매우 힘겹게..) 만들어 둔 게 있다. 설명하기 난해할 정도로 사용하기 어려워 Chakra 기반으로 한 번 수정 했었다.
그러다가 현재 디자이너가 새로이 디자인 시스템을 구축하자고 했다. 이 기회에 새로운 디자인 시스템과 더불어 UI 프레임워크도 바꾸겠다는, 야심찬 계획을 세우게 됐다.
Chakra는 theme 객체와 sx prop 패턴을 쓴다. 말했듯 이게 편하긴 한데, 앞서 보듯 규칙 없이 남발되는 형식을 보다보면 지치게 된다. 이러한 형식은 Figma MCP를 통해 가져오게 되면 어느정도 잘 가져오는 듯 하지만 여전히 제멋대로일때가 많았다.
Tailwind v4는 @theme 디렉티브와 CSS 변수 기반이다. Figma의 디자인 토큰이 그대로 CSS 변수로 export되고, Tailwind 유틸리티가 그 변수를 참조한다. Figma 토큰 ↔ CSS 변수 ↔ Tailwind 유틸리티가 1:1 매핑된다.
잠시 다른 얘기지만 추가적으로 디자인 시스템을 제대로 적용하며 좋은점은, 디자이너와 개발자가 처음으로 같은 단어를 쓰게 됐다는것. --color-primary-500을 디자이너도 그대로 부르고, 개발자도 bg-primary-500으로 쓴다.
4. 순차적으로 변경하기
여기서부터는 실제로 어떻게 했는지의 이야기다. 무작정 "다 바꾸자"가 아니라 4개의 페이즈로 나눴다.
| Phase | 내용 | 핵심 도전 |
|---|---|---|
| Phase 1 | 기반 설정 + Button/Typography 등 핵심 컴포넌트 | Tailwind와 Chakra의 공존 |
| Phase 2 | 나머지 공통 컴포넌트 (Modal, Dropdown, Checkbox 등) | |
| Phase 3 | 페이지별 점진적 전환 (총 ~97개 페이지/컴포넌트) | 순서 정하기와 일관성 유지 |
| Phase 4 | Chakra 완전 제거 | 잔재 청소 |
AI의 활용은 이런 곳에서 극대화된다. 어떤식으로 페이즈를 나누고, 어떤식으로 진행할 것인지에 대해 묻고 답하며, 명확한 계획문서를 통해 Phase 하나씩 진행하게 됐다.
5. Phase 1 - 공존
Phase 1의 목표는 "Tailwind를 도입하되 기존 Chakra 페이지가 깨지지 않게 한다".
5-1. 첫 번째 이슈 - 기존 리셋이 Tailwind를 이긴다
@import "tailwindcss";여느때처럼 설치 후 globals.css에 위 import를 작성하였다.
그런데 갑자기 페이지의 모든 버튼이 투명해졌다. <button> 태그의 배경이 사라진 것이다. <div className="bg-primary-700">은 멀쩡한데 <button className="bg-primary-700">만 안 됐다.
처음에는 Tailwind v4의 preflight 가 문제라고 짐작했다. @import "tailwindcss" 한 줄은 사실 세 가지를 같이 가져온다.
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base); /* ← 의심한 부분 */
@import "tailwindcss/utilities.css" layer(utilities);preflight는 브라우저 기본 스타일을 모두 리셋하는 코드다. button { background-color: transparent } 같은 규칙이 들어있다.
원리상으로는 preflight가 @layer base에 있고 Tailwind 유틸리티가 @layer utilities에 있어서, 동일 specificity일 때 utilities가 base를 이겨야 정상이다. 그런데 그렇지 않았다. 원인을 더 추적해보니 진짜 문제는 preflight가 아니라 우리 globals.css에 이미 깔려있던 기존 리셋 코드였다.
/* unlayered — 어떤 @layer보다도 강함 */
button { background-color: transparent; }이걸 이해하려면 CSS Cascade Layers 라는 개념을 잠깐 짚고가자.
CSS Cascade Layers란
원래 CSS에서 두 규칙이 충돌하면 specificity(명시도) 로 승자를 결정했다.
.button보다#header .button이 강하다는 식. 라이브러리끼리 충돌하면 specificity 전쟁이 벌어지고, 결국!important로 끝나는 일이 흔했다.이걸 정리하려고 도입된 게
@layer다. specificity 이전에 먼저 작동하는 우선순위 그룹이라고 생각하면 된다.@layer base, utilities;이렇게 선언하면, 두 규칙이 충돌할 때
utilities가base를 이긴다. 셀렉터가 얼마나 복잡한지는 보지도 않는다.그리고 한 가지 함정이 있다.
@layer밖에 그냥 쓴 규칙은 어느 레이어보다도 강하다./* 이 규칙은 @layer utilities 안에 있는 어떤 규칙도 이긴다 — ID 셀렉터라도 */ button { background-color: transparent; } @layer utilities { #app .nav .btn { background-color: blue; } /* 진다 */ }"레이어에 속하지 않는다"는 것이 곧 "모든 레이어 위에 있다"는 의미다. CSS 스펙이 이렇게 설계한 이유는
@layer를 모르던 기존 코드와의 호환성 때문이다.
이 함정에 빠진 거였다. globals.css의 리셋은 @layer 없이 그냥 적혀 있었다. 그 결과:
[unlayered 리셋] ← 가장 강함
↑
[@layer utilities] ← Tailwind 유틸리티가 여기 있음
↑
[@layer base] ← preflight가 여기 있음Tailwind 유틸리티(bg-primary-700)가 @layer utilities에 들어 있어도, @layer 밖에 있는 기존 리셋의 button { background-color: transparent } 를 이길 수 없었다. 즉 preflight를 의심했지만 사실 unlayered 리셋이 utilities를 이기고 있었던 게 진짜 원인이었다.
해결: 기존 리셋을 @layer base로 감쌌다. (간단;)
@layer base {
button { background-color: transparent; }
/* ... */
}이렇게 하니 비로소 Tailwind utilities가 리셋을 이기게 됐다.
처음에는 preflight를 빼는 우회로도 시도했었다. 프로젝트가 이미 자체 리셋을 갖고 있었기 때문에 preflight가 반드시 필요한 건 아니어서 그게 동작하긴 했다. 하지만 정확한 처방은 아니었다. preflight를 그대로 두더라도 unlayered 리셋만 @layer base로 감싸주면 같은 결과를 얻을 수 있었다.5-2. 두 번째 이슈 - @theme vs @theme inline
기존의 디자인 토큰 CSS 변수와 Tailwind를 연결하는 단계에서 또 막혔다. 처음엔 이렇게 썼다.
@theme {
--color-primary-700: var(--color-primary-primary700);
}이론적으로는 동작해야 했다. 일반 @theme는 Tailwind가 자체 CSS 변수(--color-primary-700)를 만들고 그 값에 var(--color-primary-primary700)를 넣어주는 방식으로 동작한다. 즉 변수 한 단계만 더 거치면 결과적으로 같은 색이 나와야 한다.
하지만 우리 프로젝트에서는 의도한 색이 나오지 않는 케이스가 발생했다. 추적해보니, 우리 토큰 변수(--color-primary-primary700)가 테마 전환(라이트/다크)에 따라 다른 값으로 재정의되는 구조였다. @theme 방식은 빌드 시점에 Tailwind가 자체 변수의 값을 한 번 결정해서 박아넣는 식이라, 런타임에 원본 변수가 바뀌어도 Tailwind 유틸리티가 그 변화를 따라가지 못했다. (실제로 Tailwind GitHub Discussion에도 같은 이슈 -"테마는 동작하는데 다크 모드가 안 된다" 가 보고되어 있다.)
해결은 inline 키워드 하나였다.
@theme inline {
--color-primary-700: var(--color-primary-primary700);
}inline을 붙이면 Tailwind가 유틸리티에 값을 인라인으로 박아넣지 않고 var() 참조를 그대로 유지한다. 즉 생성되는 CSS가 background-color: var(--color-primary-primary700)가 되어, 런타임에 원본 변수가 바뀌면 즉시 반영된다.
요약하면, 단순 색상 매핑에는 일반 @theme도 잘 동작하고, 런타임에 값이 바뀌는 변수(다크 모드, 테마 전환 등)를 참조할 때는 @theme inline이 필요하다. Tailwind 메인테이너도 "기본적으로는 @theme를 권장하고, 동작하지 않을 때만 inline을 쓰라"고 한다.
5-3. 세 번째 이슈 - 폰트가 사실 로드되지 않고 있었음 (?!)
이건 가장 충격적인 발견이었다. Chakra 환경에서 fontFamily: "Pretendard" 로 설정해뒀고, DevTools에서도 그렇게 보였다. 그런데 Tailwind로 옮기면서 텍스트 두께가 다르게 보이는 게 발견됐다.
Rendered Fonts 를 확인해봤더니 .SF NS (시스템 폰트)였다. Chakra 시절에도 실제로는 Pretendard가 아니라 시스템 폴백 폰트를 쓰고 있었던 것이다. fontFamily 설정만 있고 @font-face로 실제 폰트 파일을 로드하지 않았기 때문에..
- 변명해보자면, 내부 백오피스를 기능 위주로만 사용했다.
이번 기회에 제대로 고쳤다.
@font-face {
font-family: "Pretendard";
src: url("/fonts/PretendardVariable.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
}마이그레이션이 의도치 않은 버그까지 잡아주었다.
실패한 일 - Emotion CacheProvider
여기서 한 번 잘못된 길로 갔다가 돌아온 적이 있다. 처음 문제(unlayered 리셋이 원인이라고 깨닫기 전)를 해결하는 다른 방법으로, Chakra의 Emotion 런타임 스타일을 별도 @layer chakra에 넣어 우선순위를 조정하는 방법을 시도했다.
이론상으로는 깔끔한 해법이었다. Emotion CacheProvider를 설정해서 모든 Chakra 스타일을 @layer chakra로 묶고, 캐스케이드를 chakra → base → utilities 순으로 만들면 Tailwind가 항상 이긴다.
결과는 Chakra 스타일이 전부 깨졌다. @layer chakra가 가장 낮은 우선순위가 되니 @layer base의 리셋에도 밀려버린 것이다. 의도와 정반대로 동작했다. 이렇게 되면 Phase를 나누는 이유가 없어진다.
결국 unlayered 리셋을 @layer base로 감싸는 진짜 처방으로 돌아왔다.
6. Phase 2
핵심 컴포넌트가 자리 잡으니 나머지 공통 컴포넌트로 넘어갔다. Modal, Checkbox, RadioButton, Dropdown, Tag, ToggleSwitch… shadcn/ui의 패턴대로 Radix UI 위에 우리 디자인을 입히는 방식이었다. 아무래도 디자인 시스템 팀이 있는 규모있는 팀이 아니라면, Radix 또는 shadcn/ui가 아직도 대세로 작용하는 듯?
다만, 여기서도 문제가 있었다. 그중 가장 큰 두 가지.
6-1. Radix Dialog가 페이지 스크롤을 리셋한다
Modal 컴포넌트를 Radix Dialog로 짜고 테스트하는데, Dialog를 열 때마다 페이지가 최상단으로 스크롤되는 현상이 발견됐다. 사용자 입장에서 모달 한 번 열면 보고 있던 위치를 잃어버린다.
처음엔 onOpenAutoFocus를 막아봤다. 안 됐다. scroll position을 저장했다가 복원하는 코드를 짰다. 안 됐다. 이상한 일이었다.
원인을 추적해보니 Radix Dialog가 내부적으로 react-remove-scroll 이라는 라이브러리를 써서 scroll lock을 건다는 사실을 알게 됐다. modal={true} (기본값)일 때 동작하는 기능으로, 모달이 열려있는 동안 배경의 스크롤을 막는 게 목적이다. 합리적인 동작이다.
이 라이브러리는 기본적으로 body/html을 스크롤 컨테이너로 가정하고, body에 overflow: hidden과 padding 보정을 적용하는 방식으로 동작한다. 비표준 스크롤 컨테이너(body가 아닌 별도 div)를 쓰는 경우에는 shards 옵션으로 그 컨테이너를 명시적으로 등록해줘야 한다.
우리 레이아웃은 비표준이었다. body가 아니라 별도의 <Box overflow="auto" minH="100vh">가 실제 스크롤 컨테이너였다. shards로 우리 컨테이너를 등록하면 해결될 수도 있었지만, 모든 Radix 기반 컴포넌트마다 이 설정을 반복해야 하고, 그래도 우리 레이아웃 구조와 맞지 않는 부수 효과가 더 있을 것 같아 더 단순한 길을 택했다.
(참고로 Chakra Modal은 자체 scroll lock 구현이라 이 차이가 흡수됐었다. Radix로 옮기고 나서야 우리 레이아웃이 비표준이라는 사실을 알았다.)
해결은 의외로 단순했다.
<Dialog open={isOpen} onOpenChange={...} modal={false}>modal={false} 한 줄.
다만 이 경우 Radix가 Overlay를 렌더하지 않으므로 커스텀 div로 직접 그려줘야 한다.
<div className="fixed inset-0 z-[1400] bg-black/40" onClick={onClose} />이 결정이 의외의 파급 효과를 가져왔다. Dropdown도 같은 문제가 있어서 똑같이 modal={false}를 적용해야 했고, 이후 Phase 2~4의 모든 Radix 컴포넌트에 이 설정이 표준이 됐다.
6-2. Tailwind 동적 클래스의 비밀
Tag 컴포넌트는 7가지 색상을 지원한다 (green, orange, gray, red, blue, purple, white). 자연스럽게 이렇게 짰다.
<span className={`bg-${color}-500 text-${color}-700`}>빌드 후 색이 안 나왔다. Tailwind는 빌드 시 정적으로 클래스를 스캔하기 때문에, 템플릿 리터럴로 조합된 클래스는 감지하지 못한다. bg-green-500이라는 문자열이 코드 어디에도 없으면 그 클래스의 CSS는 생성되지 않는다.
해결책은 두 가지였다.
// 방법 1: 모든 가능한 클래스를 정적으로 (cva 활용)
const tagVariants = cva("...", {
variants: {
color: {
green: "bg-green-500 text-green-700",
orange: "bg-orange-500 text-orange-700",
// ...
}
}
})
// 방법 2: style prop으로 직접
const c = TAG_COLOR_MAP[color];
<span style={{ backgroundColor: c.bg, color: c.text }}>Tag/Chip은 색상이 semantic CSS 변수를 참조하는 구조라 방법 2를 선택했다. 일반 컴포넌트에서는 cva로 정리.
7. Phase 3 — 페이지별 전환의 노하우
기반이 잡혔으니 이제 페이지별 전환이다. 약 97개의 페이지/컴포넌트를 4개의 서브 페이즈로 나눴다.
| 서브페이즈 | 범위 | 규모 |
|---|---|---|
| Phase 3-1 | 레이아웃 + 주요 리스트 페이지 | 17개 |
| Phase 3-2 | 페이지 헤더 + 공유 컴포넌트 | ~15개 |
| Phase 3-3 | 상세/수정 페이지 | ~44개 |
| Phase 3-4 | Login + Site 하위 + 잔여 컴포넌트 | ~21개 |
매핑 표를 만들더라.
처음에는 "useDisclosure는 어떻게 바꿀지" "Chakra Portal은?" 그러다 Phase 3-1이 끝날 즈음 패턴이 보이기 시작했고, 매핑 표를 만들게 된다.
AI를 사용하다면 기본이지만, 명확한 내용으로 끊어서 질문하고 대화하면 그에 맞게 업그레이드된다.
| Before (Chakra) | After (Tailwind) |
|---|---|
useDisclosure() | useState(false) + toggle/close |
Chakra Portal | createPortal (react-dom) |
useMediaQuery(...) | window.matchMedia + addEventListener |
Emotion css prop | Tailwind 클래스 / inline style |
Chakra <Image> | next/image + object-cover |
<Box> | <div> |
<Flex> | <div className="flex"> |
<VStack> | <div className="flex flex-col gap-*"> |
<HStack> | <div className="flex gap-*"> |
isReadOnly | readOnly (네이티브 HTML) |
isDisabled | disabled (네이티브 HTML) |
Chakra Modal | ui-tw Dialog (modal={false}) |
Hex 색상의 정체를 추적하다
전환하면서 코드베이스 곳곳에 박혀있던 hex 색상 코드들을 발견했다. #EAECF1, #979BA9, #212A4A… 이게 디자인 토큰의 어떤 색에 해당하는지 누구도 정확히 몰랐다.
이런걸 Ai가 참 잘 잡는다. Figma 파일과 대조해가며 매핑 표를 만들었다.
| Hex | Tailwind 토큰 | 용도 |
|---|---|---|
#EAECF1 | gray-300 | border, surface-assistive |
#F9FBFC | gray-100 | hover bg, 테이블 헤더 |
#979BA9 | gray-700 | placeholder, 보조 텍스트 |
#212A4A | primary-900 | 사이드바 배경 |
#FF5356 | danger-400 | 알림 뱃지, 에러 |
매핑이 끝나니 코드에서 hex가 사라졌다. 디자이너가 토큰을 바꾸면 자동으로 반영되는 구조가 됐다. 이건 Tailwind 자체의 효과라기보다, 마이그레이션이 강제한 정리의 효과였다.
Tailwind v4의 작은 디테일들
Phase 3 진행 중 알게 된 Tailwind v4의 편의 기능 중 하나.
// Tailwind v3 스타일
<div className="pt-[var(--header-h)]">
// Tailwind v4 단축 문법
<div className="pt-(--header-h)">다만 calc() 내부에서는 단축 문법이 안 통한다.
// ❌ 동작 안 함
className="h-[calc(100vh-(--header-h))]"
// ✅ var() 유지 필수
className="h-[calc(100vh-var(--header-h))]"Ai를 사용한다고 해도 이런 작은 함정들이 종종 있어서, 확인해야 할 부분들이 있다. 이러한 부분을 확인해가며 디테일을 잡아나갔다.
8. Phase 4 - Chakra 잔재 제거
모든 페이지가 Tailwind로 전환됐다. 이제 Chakra를 완전히 들어내는 단계.
# Chakra import 잔재 확인
grep -r "@chakra-ui" src/ --include="*.tsx" --include="*.ts" -l
# 0개가 되면 진짜 제거
npm uninstall @chakra-ui/react @emotion/react @emotion/styled framer-motion같이 끝낸 정리들
큰 마이그레이션의 좋은 점은 그동안 미뤄두던 정리를 같이 할 명분이 생긴다는 것이다..!
이번 PR에는 다음 작업들이 같이 들어갔다.
- API 모듈 통합: 분산되어 있던
fetchData.ts를axiosInstance하나로 통합 - 라우팅 개편: 라우트 경로를
src/config/routes/로 모으고ROUTES상수 도입 - 구조 수정:
src/components/구조 정돈 - 의존성 정리: Babel 잔재, 안 쓰는 i18n/PDF/Excel 라이브러리 등 32개 미사용 의존성 제거
- 아이콘 시스템: 로컬 SVG에서 Remix Icon으로 전면 교체
사실 PR을 분리하는 게 맞지만, 크게 보자면 하나의 묶음으로 봐도 이상하지 않을 부분이라고 생각했다.
참고로 이 부분은 뒤에서 측정 결과를 해석할 때 중요한 맥락이 된다. 번들 감소 수치에는 Chakra/Emotion 제거 외에도 위 정리 작업의 효과가 함께 섞여 있다. 9장에서 가능한 만큼은 분리해서 보겠지만, 모든 수치를 100% Tailwind 전환만의 효과는 아니다.
마이그레이션 PR이 끝났을 때 변경 통계가 이랬다.
93 commits, 514 files changed
+12,998 / -29,894 lines순감소 약 1.7만 라인. 코드를 줄이려고 한 건 아니었는데, 결과적으로 그렇게 됐다.
겸사겸사지만, 많이 덜어내고, 마이그레이션 그 이상으로 개선했다.
9. 실측 - 숫자로 본 결과
말로만 하면 설득력이 약하다. 동일 머신, 동일 Node 버전, next build 프로덕션 빌드 기준으로 직접 측정했다.
9-1. 번들 및 의존성
| 지표 | Chakra (main) | Tailwind (develop) | 변화 |
|---|---|---|---|
node_modules 크기 | 785 MB | 604 MB | −181 MB (−23%) |
| 설치 패키지 수 | 618 | 488 | −130개 (−21%) |
.next 전체 | 641 MB | 430 MB | −211 MB (−33%) |
.next/static/chunks (JS) | 15 MB | 10 MB | −5 MB (−33%) |
.next/static/css | 8 KB | 120 KB | +112 KB (아래 설명) |
9-2. First Load JS — 페이지 진입 시 사용자가 받는 JS
| 페이지 | Chakra | Tailwind | 감소량 |
|---|---|---|---|
| /login | 334 kB | 211 kB | −123 kB (−37%) |
| /client | 393 kB | 210 kB | −183 kB (−47%) |
| /client/[id] | 400 kB | 221 kB | −179 kB (−45%) |
| /account | 393 kB | 216 kB | −177 kB (−45%) |
| /site | 400 kB | 215 kB | −185 kB (−46%) |
| /site/[id] | 400 kB | 227 kB | −173 kB (−43%) |
| /site/[id]/drawing | 392 kB | 210 kB | −182 kB (−46%) |
주요 페이지 전부 40~47% 감소. 평균적으로 매 페이지 약 180 kB씩 덜 내려받는다.
솔직히: 이 감소 폭이 모두 "Chakra → Tailwind" 효과만은 아니다. 8장에서 언급한 32개 미사용 의존성 제거 — 특히 안 쓰는 PDF/Excel/i18n 라이브러리 제거 — 만으로도 페이지당 수십~수백 KB의 감소가 가능하다. 다음 9-3에서 보는 "Chakra/Emotion 청크 약 536 KB가 통째로 사라진 부분"이 Tailwind 전환의 가장 명확한 직접 효과이고, 그 외 부분은 정리 작업의 효과가 섞여 있다고 보는 게 정확하다.
9-3. 사라진 두 청크의 정체
Chakra 빌드의 최상위 두 청크를 보면:
| 파일 | 크기 |
|---|---|
aaea2bcf-*.js | 320 KB ← Chakra UI + Emotion 런타임 본체 |
628-*.js | 216 KB ← 관련 의존 청크 |
이 두 청크의 합계 약 536 KB. 이게 바로 Chakra 컴포넌트 코드 + Emotion 런타임(스타일 직렬화·클래스명 생성·DOM 주입 로직 + 스타일 객체들) 을 사용자가 다운로드하던 비용이다. Tailwind 빌드에서는 이 두 청크가 아예 존재하지 않는다. 최상위 청크가 React 같은 절대적으로 필요한 것들뿐이다.
이 536 KB는 Tailwind 전환의 순수한 직접 효과로 봐도 무방한 부분이다.
9-4. "CSS가 8 KB → 120 KB로 늘었는데 좋은 거예요?"
이게 회사 사람들에게 설명할 때 가장 좋은 포인트였다. 표면적으로는 CSS 파일이 15배 커졌으니 "안 좋은 변화"처럼 보이니까.
Chakra의 정적 CSS 파일이 8 KB밖에 안 됐던 건 스타일이 적었기 때문이 아니다. 스타일이 빌드 산출물 CSS에는 거의 없고, 런타임에 Emotion이<head>의<style>태그로 주입하고 있었기 때문이다. 즉 사용자가 페이지를 열고 JS가 실행되고 나서야 비로소 스타일이 만들어졌다.Tailwind는 그 스타일을 런타임 생성에서 빌드 산출물 CSS로 옮긴 것이다. CSS가 120 KB로 늘어난 건 스타일이 정적 자산으로 돌아왔다는 뜻이다.
같은 양의 정보를 옮기는데 JS 1KB와 CSS 1KB는 처리 비용이 다르다.
- JS: 다운로드 → 파싱 → 컴파일 → 실행 → 메인 스레드 점유
- CSS: 다운로드 → 파싱 → 적용
CSS도 공짜는 아니다. CSS는 기본적으로 렌더 블로킹 리소스라서 <head>에서 로드되면 First Paint를 막고, 큰 CSS는 selector matching과 style recalculation에서 비용이 든다. 다만 같은 1KB라면, JS는 메인 스레드에서 파싱·컴파일·실행을 모두 거쳐야 하는 반면 CSS는 브라우저 기본 파서가 처리하고 메인 스레드를 길게 막지 않는다. 또 CSS는 HTML과 병렬로 다운로드되는 등 처리 경로 자체가 더 가볍다.
CSS +112 KB를 내주고 JS −5 MB를 얻은 거래다. 이 트레이드오프는 거의 모든 경우에 이득이다.
9-5. CPU 스로틀링에서의 차이 (정성적 관찰)
이 부분은 정확한 수치를 깔끔하게 뽑지는 못해서 정성적으로만 적는다. Chrome DevTools의 Performance 탭에서 4× CPU 스로틀링을 걸고 페이지를 로드해봤을 때, Chakra 환경에서는 serializeStyles, insertStyles 같은 Emotion 함수들이 메인 스레드 호출 스택에 반복적으로 등장하는 게 보였다. Tailwind 환경에서는 이 함수들이 아예 호출되지 않으니 그만큼의 메인 스레드 점유가 사라졌다.
이게 사용자 경험 측면에서 의미하는 것:
- 페이지가 보이긴 하는데 클릭이 안 되는 순간(TBT, Total Blocking Time)이 짧아진다
- 저사양 기기에서 입력 응답이 더 빨라진다
- 첫 인터랙션까지의 시간(TTI)이 줄어든다
수치로 단언하지는 못하겠지만, 같은 사양의 기기에서 체감 차이는 분명히 있었다. 이 부분은 Lighthouse 등으로 페이지별 정량 측정이 가능하니, 혹시 이러한 마이그레이션을 검토 중이라면 자체 환경에서 직접 측정해보는 걸 권한다.
10. 정리
Chakra v2는 런타임 CSS-in-JS 구조라서, 사용자가 페이지를 열 때마다 브라우저가 JS로 스타일을 생성합니다. Chakra/Emotion 런타임만으로도 약 536 KB의 JS가 사용자에게 다운로드되고 실행돼요.Tailwind v4는 빌드 타임에 CSS를 뽑아내기 때문에 사용자 쪽 런타임 비용이 거의 0입니다. Next.js App Router · RSC와도 잘 맞고, Figma 디자인 토큰과도 깔끔하게 연결돼요.
Chakra v2 → v3도 어차피 재작성 수준이라 비용은 발생합니다. 그럴 바엔 런타임 0, 생태계 표준인 Tailwind로 옮기는 게 장기 ROI가 훨씬 큽니다.
실측으로도 JS 번들 −33%, 페이지당 First Load JS 평균 −45%, 의존성 -130개가 확인되었습니다. (단, 이 감소 폭에는 마이그레이션과 함께 진행한 미사용 의존성 정리 효과가 일부 포함됩니다. Chakra/Emotion 자체의 순수한 제거 효과는 약 536 KB 청크 부분이 가장 명확합니다.)
11. 마치며
AI가 발전함에 따라, 혼자서는 해결하지 못할 많은 일들을 빠르게 해결할 수 있게 됐다. 백오피스였고 사이즈가 큰 프로젝트는 아니였지만 이전에 생각만 했었던 마이그레이션 작업을 빠르게, 꽤나 손쉽게 해낼 수 있었다.
이 작업을 하며 plan 모드를 기본적으로 사용하고, 더 명확한 계획을 세워 작업하는 것에 초점을 두고 작업했다.
기획서대로 작업하며, phase마다 막히거나 수정된 부분에 대해 다시금 계획서를 업데이트하고, 세션 초기화 후 다시 진행하는 방식을 사용했다.
특히 좋았던건, playwright MCP를 사용하여 직접 눈으로 확인할 부분을 덜어낸 것이다.
이 작업을 성공적으로 마치게 됐고, 이 성공을 발판삼아 실제 고객이 사용할 SaaS 프로젝트 또한 예상보다 훨씬 빠르고 안전하게 마이그레이션을 마칠 수 있었다. 입사하고나서부터 개인적으로 가장 큰 목표중 하나였던 Tailwind로의 마이그레이션을 성공적으로 마쳤음에, 그리고 마이그레이션하며 Tailwind와 CSS의 여러가지 개념들을 한번 더 살펴볼 수 있었던 계기가 됐다.
https://tailwindcss.com/docs/theme
https://github.com/tailwindlabs/tailwindcss/discussions/17826 - tailwindcss theme inline사용에 대해
https://developer.mozilla.org/ko/docs/Web/CSS/Reference/At-rules/@layer