문서 읽는 데 53분 · E1

E-1: Tailwind CSS v4 — 이름 짓기를 그만두다

목차 44
전체 13강 중 13강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간에 회원가입 폼을 끝냈죠. 규칙은 스키마 한곳에 모였고, 타입도 거기서 나왔습니다.

그런데 마무리하면서 제가 이런 말을 했어요. 폼은 완성됐는데 생긴 걸 한번 보자고요.

signup-form, signup-field, signup-label, signup-input, signup-error, profile-picker-button. 이 이름들 전부 우리가 손으로 지었습니다. 그리고 이름마다 CSS 를 또 손으로 썼죠.

지금 globals.css 를 열어보세요. 387 줄에 클래스 이름이 50 개, 규칙이 56 개 있습니다. 화면은 회원가입 폼 하나와 게시물 카드 두 장이 전부인데요.

오늘은 이름 짓기를 그만두는 방법을 봅니다. Tailwind CSS 입니다.

텍스트
 오늘의 여정

 Step 1     이름 짓기가 왜 힘든가
    │
 Step 2     한 줄로 들이기
    │
 Step 3     Avatar 를 유틸리티로 옮기기
    │
 Step 4     안 쓴 CSS 는 어디로 갔나
    
 클래스 이름을 짓지 않고 스타일이 붙은 Avatar

💡 오늘 수업의 핵심 — "이름을 짓지 않으면, 이름 때문에 생기는 문제도 없다"

우리가 겪은 불편은 대부분 CSS 자체가 아니라 이름에서 왔습니다. 겹치지 않게 짓느라, 어디 있는지 찾느라, 지워도 되는지 몰라서요. 오늘은 그 이름을 없앱니다.

🎯 학습 목표

  • 손으로 이름 짓는 방식의 문제 세 가지를 짚고, 유틸리티 우선 방식이 그 대가로 무엇을 내주는지 말할 수 있다
  • 플러그인 한 줄과 @import "tailwindcss" 한 줄로 v4 를 들이고, 함께 딸려 오는 리셋이 기존 화면에 무엇을 하는지 확인할 수 있다
  • 손으로 쓴 CSS 규칙을 같은 값의 유틸리티로 옮기고, 옮긴 규칙을 지울 수 있다
  • 빌드된 CSS 를 열어 무엇이 들어갔는지 확인하고, 안 쓰는 유틸리티가 왜 딸려 나오는지 설명할 수 있다

Step 1: "이름 짓기가 왜 힘든가"

먼저 지금 방식의 무엇이 문제인지 정확히 짚고 갑시다. "귀찮다" 로 뭉뚱그리면 Tailwind 가 뭘 해결하는지도 흐려지거든요.

선수 과목에서 순수 CSS 를 배울 때는 이 방식이 잘 통했습니다. 화면이 하나였으니까요. B-1 에서 첫 React 컴포넌트를 만들 때도 괜찮았어요. Avatar 하나에 .avatar, .avatar-image, .avatar-name 셋이면 충분했습니다.

문제는 화면이 늘면서 옵니다. 세 가지가 한꺼번에요.

하나 — 이름이 겹칩니다

.post-header.feed-header 를 보세요. 둘 다 머리말인데 이름이 다릅니다. 왜 다를까요. 겹치면 안 되니까요.

그래서 우리는 접두사를 붙였습니다. post- 로 시작하는 클래스가 7 개, signup- 이 6 개, comment-response- 가 각 5 개, feed-profile- 이 각 4 개. 이런 식으로 접두사만 11 종류 입니다.

화면 세 개를 만드는 데 이름 앞에 붙일 말을 11 개나 정해야 했다는 뜻이에요.

이건 CSS 가 전역이라서 생기는 일입니다. 파일을 나눠도 소용없어요. 브라우저에 도착하면 결국 한 덩어리가 되거든요.

둘 — 어떤 스타일이 어디서 오는지 못 찾습니다

카드 안의 "좋아요 1240개" 가 왜 저 위치에 있는지 알고 싶다고 해봅시다.

LikeButton.tsx 를 열면 className="post-likes" 만 보입니다. 값은 없어요. 값을 보려면 globals.css 를 열고 .post-likes 를 찾아야 합니다. 그런데 .post-likes.post-content, .post-comments 와 한 규칙에 묶여 있고, 그 아래에서 한 번 더 덮어써집니다.

찾기도 쉽지 않습니다. 이름만 보면 게시물 본문 어딘가에 있을 것 같은데 실제로는 좋아요 버튼 파일에 있거든요. 이름과 파일이 따로 노는 겁니다.

파일 두 개를 오가면서 규칙 두 개를 합쳐 읽어야 답이 나옵니다. 컴포넌트만 봐서는 절대 알 수 없어요.

셋 — 안 쓰는 CSS 를 지울 엄두가 안 납니다

이게 제일 큽니다. .key-demo-row 를 지워도 될까요?

지워도 되는지 확인하려면 프로젝트 전체에서 key-demo-row 를 검색해야 합니다. 검색해서 안 나와도 확신이 안 서요. 클래스 이름을 문자열로 조립하는 코드가 어딘가 있을 수도 있으니까요.

그래서 아무도 안 지웁니다. CSS 파일은 계속 늘어납니다. 여러분이 실무에서 만날 프로젝트의 CSS 가 몇천 줄인데 아무도 손 못 대는 이유가 이겁니다.

그래서 반대로 갑니다

Tailwind 의 발상은 간단합니다. 이름을 새로 짓지 말고, 이미 이름이 정해진 조각을 골라 쓰자.

p-3 은 padding 을 12px 로 만듭니다. 그게 전부예요. 다른 일은 안 합니다. flexdisplay: flex 만 겁니다. 이렇게 하는 일이 하나씩인 클래스를 유틸리티 클래스 라고 부릅니다.

텍스트
 지금까지                               유틸리티 우선

 이름을 짓는다     .avatar              이미 있는 이름을 고른다   flex
                                            
 CSS 를 쓴다       display: flex        고른 것을 붙인다          items-center
                                            
 두 파일을 오간다                       컴포넌트 한 곳에서 끝난다

이러면 아까 세 문제가 어떻게 되는지 보세요.

이름을 안 지으니 겹칠 이름이 없습니다. 값이 클래스 이름에 그대로 있으니 컴포넌트만 봐도 압니다. 그리고 이게 결정적인데, 안 쓰는 CSS 를 지울 필요가 없습니다. 애초에 안 만들어지거든요. 왜 그런지는 Step 4 에서 직접 확인합니다.

⚠️ 대가도 있습니다

솔직하게 짚고 갈게요. 유틸리티를 쓰면 클래스 문자열이 길어집니다.

className="avatar" 였던 게 className="flex items-center gap-2.5 p-3" 이 됩니다. 처음 보면 지저분해 보여요. "HTML 에 스타일을 다시 집어넣는 거 아닌가" 싶기도 하고요.

이건 취향 문제가 아니라 실제 트레이드오프입니다. 짧은 이름 하나를 읽는 대신, 긴 문자열을 읽게 됩니다. 대신 그 문자열만 읽으면 다른 파일을 안 열어도 됩니다.

어느 쪽이 나은지는 프로젝트에 따라 다릅니다. 오늘 직접 해보고 스스로 판단해 보세요. 마지막에 이 얘기를 다시 꺼낼게요.

💡 한 줄 정리

손으로 이름을 지으면 겹침·추적·삭제 세 가지가 따라옵니다. 유틸리티 우선은 이름 짓기 자체를 없애서 셋을 한꺼번에 피하고, 대신 클래스 문자열이 길어지는 것을 받아들입니다.

🙋 학생 질문 — "그럼 CSS 를 안 배워도 되나요?"

정반대입니다. Tailwind 는 CSS 를 더 잘 알아야 쓸 수 있어요.

items-center 를 쓰려면 align-items: center 가 뭘 하는지 알아야 합니다. object-cover 를 고르려면 object-fit 이 뭔지 알아야 하고요. 유틸리티 이름은 CSS 속성 이름을 줄인 것에 가깝습니다.

Tailwind 가 없애주는 건 CSS 지식이 아니라 이름 짓기와 파일 오가기 입니다. 선수 과목에서 배운 Flexbox, 박스 모델, 선택자 우선순위는 오늘도 그대로 씁니다.

거꾸로 말하면, CSS 를 모르는 채로 Tailwind 를 쓰면 유틸리티 이름을 외우는 것밖에 안 됩니다. 그러면 조금만 다른 걸 만들려 해도 막혀요.


Step 2: "한 줄로 들이기 — @import \"tailwindcss\""

이제 실제로 설치해 봅시다. v4 는 설치가 정말 짧아요.

설치

리포지토리 루트에서 실행합니다.

Bash
npm install -D --save-exact tailwindcss@4.3.3 @tailwindcss/vite@4.3.3 --workspace apps/web-spa

두 개를 받았습니다. tailwindcss 가 본체고, @tailwindcss/vite 는 Vite 에 물리는 플러그인이에요.

버전을 정확히 고정한 이유는 A-1 부터 이어온 규칙 그대로입니다. 스타일 도구는 마이너 버전에서도 만들어내는 CSS 가 조금씩 달라져서, 여러분 화면과 제 화면이 어긋나면 원인을 찾기 어렵거든요.

붙이는 곳은 두 군데

먼저 Vite 설정입니다.

TypeScript
// apps/web-spa/vite.config.ts
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    // React Compiler — 빌드할 때 자동으로 메모이제이션을 넣어준다
    await babel({
      presets: [reactCompilerPreset()],
    }),
  ],
});

임포트 한 줄과 tailwindcss() 한 줄이 전부입니다. B-1 에서 이 파일을 만들 때 넣어둔 React Compiler 설정은 그대로 두세요.

그다음 CSS 입니다.

CSS
/* apps/web-spa/src/styles/globals.css */
@import "tailwindcss";

/* 화면에 안 나가는 파일까지 훑지 않도록 미리 일러둔다 */
@source not "../../scratch";
@source not "../**/*.test.ts*";

body {
  margin: 0;
  background: #fafafa;
  color: #262626;
  font-family: system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;
}

맨 위에 @import "tailwindcss"; 한 줄을 넣었습니다. 아래 손으로 쓴 CSS 는 아직 그대로 두세요. 오늘 한 번에 다 걷어내지 않습니다.

이게 v4 설치의 전부입니다. 설정 파일은 만들지 않습니다.

@source not 두 줄은 잠깐 설명이 필요합니다. Tailwind 는 프로젝트 폴더를 훑어서 쓸 클래스를 찾는데, 화면에 안 나가는 파일까지 함께 훑습니다. 그래서 그런 파일이 있으면 미리 제외해 둡니다.

두 줄이 가리키는 곳은 이렇습니다. 아래쪽은 테스트 파일이고, 위쪽 scratch버려도 되는 실험용 코드를 따로 모아두는 폴더예요. 이름은 프로젝트마다 다르지만, 배포되지 않는 코드를 한곳에 모아두는 폴더는 대개 하나쯤 생깁니다.

지금 여러분 프로젝트에는 아직 그런 파일이 없습니다. 그래서 두 줄을 넣어도 당장은 아무것도 안 바뀌어요. 미리 넣어두는 건 F 카테고리에서 테스트 파일을 만들기 시작하는 순간부터 필요해지기 때문입니다.

왜 필요한지는 Step 4 에서 Tailwind 가 파일을 어떻게 훑는지 직접 확인하고 나면 분명해집니다. 지금은 두 줄을 넣어두기만 하세요.

v3 를 검색하면 다른 게 나옵니다

⚠️ 여기서 꼭 짚고 가야 할 게 있어요. 여러분이 Tailwind 를 검색하면 v3 예제가 압도적으로 많이 나옵니다. 블로그도, 스택오버플로 답변도, 강의 영상도요.

v3 는 이렇게 했습니다. CSS 맨 위에 @tailwind 로 시작하는 지시어를 세 줄 넣고, 프로젝트 루트에 tailwind.config.js 라는 설정 파일을 만들어서 거기에 색·간격·폰트를 자바스크립트 객체로 적었습니다. 그리고 어떤 파일을 훑을지 content 배열에 경로를 손으로 적어줬고요.

v4 에서는 셋 다 없어졌습니다. 지시어 세 줄은 @import 한 줄로 합쳐졌고, 설정 파일은 사라졌으며, 훑을 파일은 자동으로 찾습니다.

그러니 검색하다가 @tailwind 로 시작하는 세 줄이나 설정 파일이 보이면 v3 문서구나 하고 넘기세요. 우리 코드에 그대로 넣으면 동작하지 않습니다.

색이나 간격을 우리 브랜드에 맞게 바꾸는 건 v4 에서도 당연히 됩니다. 다만 자바스크립트 설정 파일이 아니라 CSS 안에서 해요. 그건 다음 시간에 정식으로 다룹니다.

설치는 두 경로가 있습니다

우리는 Vite 를 쓰니까 @tailwindcss/vite 플러그인을 썼습니다. 이게 가장 빠른 경로예요.

Vite 를 안 쓰는 프로젝트라면 PostCSS 경로를 씁니다. @tailwindcss/postcss 를 설치하고 PostCSS 설정에 등록하는 방식이에요. 나중에 Next.js 로 넘어가면 그쪽을 쓰게 됩니다.

어느 경로든 CSS 쪽은 똑같습니다. @import "tailwindcss"; 한 줄이에요.

그리고 autoprefixer 를 따로 설치하지 않았다는 점을 눈여겨보세요. v3 까지는 벤더 접두사를 붙여주는 도구를 따로 깔았는데, v4 는 내부 엔진이 그 일까지 합니다. 우리 package.json 에 autoprefixer 가 없는 이유예요.

화면을 확인해 봅시다

npm run dev --workspace apps/web-spa 로 띄우고 브라우저를 열어보세요.

대부분 그대로입니다. 카드도, 폼도, 버튼 색도요. 그런데 맨 위 제목을 보세요.

"인스타그램" 이 가늘어졌습니다.

착시가 아닙니다. 개발자 도구에서 제목을 선택하고 Computed 탭에서 font-weight 를 보면 400 입니다. 설치 전에는 700 이었어요.

왜 가늘어졌나

@import "tailwindcss" 는 유틸리티만 들여오는 게 아닙니다. 리셋(reset) 도 함께 들어옵니다. 브라우저마다 제각각인 기본 스타일을 하나로 맞추는 CSS 예요.

그 리셋에 이런 규칙이 있습니다. 제목 태그의 글자 굵기를 부모에게서 물려받게 하는 거요. 브라우저는 원래 h1 을 굵게 그리는데, 리셋이 그 기본값을 지웁니다.

우리 .feed-title 을 보세요.

CSS
/* apps/web-spa/src/styles/globals.css */
.feed-title {
  font-size: 24px;
  margin-bottom: 16px;
}

크기와 여백은 정했는데 굵기는 안 정했습니다. 지금까지는 정할 필요가 없었어요. 브라우저가 알아서 굵게 그려줬으니까요. 그 기본값이 사라지자 우리가 안 정해둔 게 그대로 드러난 겁니다.

이게 리셋의 양면입니다. 브라우저 차이를 없애주는 대신, 브라우저가 대신 해주던 것도 같이 없앱니다.

첫 유틸리티를 써봅시다

고치는 방법은 굵기를 직접 정해주는 거예요. 손으로 CSS 를 쓸 수도 있지만, 마침 좋은 기회니까 유틸리티로 해봅시다.

tsx
// apps/web-spa/src/App.tsx
<h1 className="feed-title font-bold">인스타그램</h1>

font-bold 하나 붙였습니다. 저장하면 제목이 다시 굵어져요. Computed 탭에서 font-weight700 으로 돌아온 것도 확인해 보세요.

주목할 게 있습니다. 손으로 쓴 feed-title 과 유틸리티 font-bold한 줄에 나란히 있습니다. 둘은 싸우지 않아요. 크기와 여백은 손 CSS 가, 굵기는 유틸리티가 맡습니다.

오늘 전부 갈아엎지 않아도 되는 이유가 이겁니다. 필요한 곳부터 하나씩 옮기면 됩니다.

💡 한 줄 정리

v4 설치는 플러그인 한 줄과 @import 한 줄이고 설정 파일은 없습니다. 다만 유틸리티와 함께 리셋이 딸려 오므로, 브라우저 기본값에 기대고 있던 스타일은 직접 정해줘야 합니다.

🙋 학생 질문 — "리셋이 다른 것도 망가뜨렸을 텐데 왜 제목만 티가 났나요?"

좋은 질문이에요. 실제로 리셋은 여러 곳을 건드렸습니다. 그런데 눈에 띄는 건 제목 하나뿐이었죠.

이유는 간단합니다. 우리가 값을 직접 정해둔 곳은 안 바뀌기 때문입니다.

.post-more 버튼을 보세요. 손 CSS 에서 border: none, background: none, cursor: pointer 를 전부 직접 정했습니다. 리셋이 버튼의 기본 배경색을 지웠지만, 우리가 이미 none 으로 정해뒀으니 화면은 그대로예요.

반대로 .feed-title 은 굵기를 안 정했습니다. 브라우저 기본값에 기대고 있었죠. 기대고 있던 것이 사라지니 바로 드러난 겁니다.

리셋이 실제로 바꾼 것 중에 화면에 안 보이는 것도 있어요. 버튼의 글꼴이 브라우저 기본 글꼴에서 body 의 글꼴로 바뀌었습니다. 이건 오히려 우리가 원하던 모습이라 문제로 안 보였을 뿐이에요.

교훈은 이렇습니다. 리셋을 들이면 브라우저 기본값에 조용히 기대고 있던 곳이 드러납니다. 그 목록은 프로젝트마다 다르니, 설치 직후 화면을 한 번 훑어보는 습관이 필요해요.


Step 3: "Avatar 를 유틸리티로"

이제 컴포넌트 하나를 통째로 옮겨봅시다. Avatar 로 시작할게요. B-1 에서 우리가 만든 첫 컴포넌트고, 규칙이 셋뿐이라 대조하기 좋습니다.

지금 모습

먼저 옮길 대상을 봅시다.

CSS
/* apps/web-spa/src/styles/globals.css */
.avatar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px;
}

.avatar-image {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  object-fit: cover;
}

.avatar-name {
  font-weight: 600;
  font-size: 14px;
}

규칙 셋에 선언이 아홉 개입니다. 이걸 유틸리티로 하나씩 바꿔볼 거예요.

하나씩 짝지어 봅시다

유틸리티 이름은 대부분 CSS 속성 이름에서 짐작이 됩니다.

손으로 쓴 CSS 유틸리티 읽는 법
display: flex flex 그대로
align-items: center items-center items = align-items
gap: 10px gap-2.5 2.5 × 4px = 10px
padding: 12px p-3 3 × 4px = 12px
width: 32px; height: 32px size-8 8 × 4px = 32px, 가로세로 함께
border-radius: 50% rounded-full 완전히 둥글게
object-fit: cover object-cover 그대로
font-size: 14px text-sm small
font-weight: 600 font-semibold semibold = 600

숫자 규칙이 눈에 들어오시나요. p-3, gap-2.5, size-8 의 숫자는 4px 단위입니다. p-3 이면 3 × 4 = 12px 예요.

처음엔 이 환산이 번거롭습니다. 익숙해지면 오히려 편해져요. 디자인이 4px 격자 위에서 움직이게 되니까 간격이 제멋대로 튀지 않습니다. 손으로 쓸 때 11px, 13px 이 섞여 들어가는 걸 막아줘요.

옮긴 결과

tsx
// apps/web-spa/src/components/Avatar.tsx

interface AvatarProps {
  username: string;
  profileImageUrl: string;
}

export function Avatar({ username, profileImageUrl }: AvatarProps) {
  return (
    <div className="flex items-center gap-2.5 p-3">
      <img
        className="size-8 rounded-full object-cover"
        src={profileImageUrl}
        alt={`${username} 프로필 사진`}
      />
      <span className="text-sm font-semibold">{username}</span>
    </div>
  );
}

AvatarProps 는 손대지 않았습니다. src, alt 도 그대로고요. 바뀐 건 className 세 개뿐입니다.

이제 컴포넌트만 봐도 어떻게 생겼는지 압니다. 가로로 늘어놓고, 세로 가운데 정렬하고, 10px 띄우고, 12px 안쪽 여백. globals.css 를 안 열어도 돼요.

옮겼으면 지웁니다

여기가 중요합니다. 옮기고 끝내면 CSS 는 그대로 남아요. globals.css 에서 .avatar 세 규칙을 삭제하세요.

CSS
/* apps/web-spa/src/styles/globals.css */
/* .avatar, .avatar-image, .avatar-name 세 규칙을 지운다 */

지우기 전 392 줄이던 파일이 373 줄이 됩니다. 규칙 세 개에 그 사이 빈 줄까지 19 줄이 빠졌어요.

처음에 387 줄이라고 했는데 392 가 된 건 Step 2 에서 @import@source not 줄들을 넣었기 때문입니다.

지울 때 마음이 편한 것도 느껴보세요. Step 1 에서 .key-demo-row 를 지울지 말지 망설였잖아요. 이번엔 안 망설입니다. 방금 내가 옮긴 것이고, 쓰던 곳은 Avatar.tsx 하나였다는 걸 아니까요.

정말 똑같이 나올까

옮겼으면 확인해야죠. 브라우저에서 프로필 사진 부분을 선택하고 Computed 탭을 열어보세요.

항목 옮기기 전 옮긴 뒤
display flex flex
align-items center center
gap 10px 10px
padding 12px 12px
width / height 32px 32px
object-fit cover cover
font-size 14px 14px
font-weight 600 600

값이 그대로입니다. 화면도 당연히 그대로고요.

⚠️ 딱 하나 다른 게 있습니다. text-sm 은 글자 크기만 정하는 게 아니라 줄 간격(line-height)도 함께 20px 로 정합니다. 우리 손 CSS 는 줄 간격을 안 건드렸으니 원래는 브라우저 기본값이었어요.

한 줄짜리 이름이라 눈에 안 띄지만, "완전히 똑같다" 고 말하면 거짓입니다. 유틸리티 중에는 이렇게 한 번에 두 가지를 정하는 것이 있어요. text-sm, text-lg 같은 글자 크기 계열이 대표적입니다.

rounded-full 도 비슷합니다. 우리는 50% 를 썼는데 rounded-full 은 아주 큰 고정값을 넣습니다. 정사각형 이미지에서는 결과가 같지만, 직사각형이면 달라져요.

💡 한 줄 정리

CSS 규칙을 유틸리티로 옮기면 스타일이 컴포넌트 안으로 들어와 다른 파일을 안 열어도 됩니다. 옮긴 뒤에는 원래 규칙을 지우고, 값이 정말 같은지 Computed 탭으로 확인하세요.

🙋 학생 질문 — "클래스를 여러 컴포넌트가 같이 쓰면요? 유틸리티로 옮기면 중복 아닌가요?"

날카로운 질문입니다. 실제로 그렇게 됩니다.

.post-card 를 유틸리티로 옮긴다고 해봅시다. 카드가 세 군데에서 쓰이면 같은 유틸리티 문자열을 세 번 적게 되죠. CSS 클래스는 한 번만 쓰면 됐는데요.

답은 이미 우리가 가지고 있습니다. B-3 에서 배운 컴포넌트 합성 이에요.

같은 모양이 반복되면 클래스를 공유할 게 아니라 컴포넌트를 공유합니다. 입력 칸 모양이 여러 곳에서 필요하면 그 모양을 컴포넌트 안에 한 번만 적어요.

우리 코드베이스에 이미 두 가지 방식이 다 있습니다. B-5 에서 만든 TextField 는 라벨·입력·오류 메시지의 클래스를 자기 안에 직접 들고 있어요. 반대로 B-3 의 CardButtonclassName 을 밖에서 받습니다. 구조만 정해주고 생김새는 쓰는 쪽이 정하게 한 거죠.

전자는 "이 모양은 늘 같다" 일 때, 후자는 "쓰는 곳마다 다르다" 일 때 씁니다. 유틸리티로 옮길 때도 이 구분이 그대로 유효해요.

이게 Tailwind 를 React 와 같이 쓸 때의 핵심입니다. 재사용 단위가 CSS 클래스에서 컴포넌트로 옮겨갑니다. 그리고 컴포넌트는 클래스와 달리 props 도 받고 타입도 붙일 수 있죠. A-6 에서 ComponentProps<typeof Button> 으로 IconButton 을 만든 것을 떠올려 보세요.

그래도 컴포넌트로 묶기 애매한 반복은 남습니다. 그럴 때 값 자체를 한곳에서 정해두면 반복이 훨씬 견딜 만해져요. 그 방법이 다음 시간의 주제입니다.


Step 4: "안 쓴 CSS 는 어디로 갔나"

Step 1 에서 제가 이렇게 말했죠. 유틸리티는 안 쓰는 CSS 를 지울 필요가 없다고, 애초에 안 만들어진다고요. 이제 그걸 직접 확인합니다.

빌드해서 열어봅시다

Bash
npm run build --workspace apps/web-spa

dist/assets/ 에 CSS 파일이 하나 생깁니다. 이게 실제로 사용자에게 전달되는 CSS 예요.

먼저 크기부터 봅시다. 지금까지 세 시점을 재보면 이렇습니다.

시점 CSS 크기
Tailwind 설치 전 4,256 B
Step 2 를 끝낸 뒤 (유틸리티 0 개) 8,453 B
Step 3 을 끝낸 뒤 9,221 B

두 번째 줄이 이상하지 않나요. 유틸리티를 하나도 안 썼는데 4,197 B 가 늘었습니다.

그게 Step 2 에서 만난 리셋입니다. 유틸리티가 아니라 리셋이 저 4 KB 의 대부분이에요. Tailwind 를 들이는 순간 내야 하는 고정 비용이라고 보면 됩니다.

이때 CSS 안에 Tailwind 가 만든 변수는 네 개뿐입니다. 글꼴 관련된 것들이고, 그마저도 리셋이 쓰기 때문에 들어간 거예요. 색·간격 같은 기본값이 통째로 쏟아지지 않는다는 뜻입니다.

세 번째 줄은 768 B 늘었습니다. 유틸리티 아홉 개와 제목의 font-bold 가 들어오고, 대신 .avatar 세 규칙이 빠진 결과예요. 더한 것과 뺀 것을 합친 순증입니다.

쓴 것만 들어있는지 확인

이제 진짜 확인입니다. CSS 안에 뭐가 있는지 직접 찾아봅시다.

Bash
grep -o "\.p-3[{,]" dist/assets/*.css | wc -l
grep -o "\.p-4[{,]" dist/assets/*.css | wc -l

p-3Avatar 가 씁니다. p-4 는 아무도 안 써요.

결과는 1 과 0 입니다. 우리가 쓴 것만 들어있어요.

몇 개 더 해보세요. size-8, rounded-full, text-sm 은 전부 1 입니다. size-12, flex-col, items-start, bg-red-500 은 전부 0 이에요.

⚠️ gap-2.5 를 찾을 때는 주의하세요. CSS 에서는 클래스 이름의 점을 이스케이프해서 gap-2\.5 로 적습니다. 점을 그대로 넣고 검색하면 못 찾고, "없구나" 하고 잘못된 결론을 내리게 돼요.

설정한 적 없는데 어떻게 알았을까

우리는 어떤 파일을 훑으라고 적어준 적이 없습니다. 설정 파일 자체를 안 만들었으니까요.

v4 는 프로젝트를 자동으로 훑습니다. 소스 파일을 찾아 읽고, 거기서 발견한 클래스 이름만 CSS 로 만들어요. 이걸 자동 content 감지라고 합니다.

덕분에 Step 1 의 셋째 문제가 사라집니다. 안 쓰는 유틸리티를 지울 필요가 없어요. 안 쓰면 안 만들어지니까요. 그리고 Avatar 에서 p-3 을 지우면 다음 빌드에서 .p-3 규칙도 같이 사라집니다.

어떻게 찾는지 직접 확인해 봅시다

"쓴 것만 만든다" 는 말이 정확히 무슨 뜻인지 실험 하나로 확인할 수 있습니다.

Toast.tsx 를 열고 맨 아래에 주석 한 줄을 넣어보세요. 코드가 아니라 주석입니다.

tsx
// apps/web-spa/src/components/Toast.tsx
// p-4

그리고 다시 빌드해서 .p-4 를 세어 보세요.

Bash
grep -o "\.p-4[{,]" dist/assets/*.css | wc -l

방금까지 0 이던 것이 1 이 됩니다.

주석이잖아요. 자바스크립트는 저 줄을 아예 실행하지 않습니다. 그런데도 CSS 가 만들어졌어요.

자동 감지는 마법이 아닙니다

이유를 알면 허무합니다. Tailwind 는 파일을 그냥 글자로 훑습니다.

코드를 해석해서 "이건 클래스고 저건 주석이야" 를 가려내지 않아요. 유틸리티 이름과 똑같은 단어가 파일 어딘가에 있으면 그걸 클래스 후보로 봅니다.

텍스트
 우리가 기대한 것                   실제로 하는 일

 컴포넌트를 해석해서                파일을 글자로 훑어서
 className 만 골라낸다              같은 단어를 전부 줍는다
                                        
 쓴 것만 나온다                     쓴 것 + 우연히 겹친 단어

주석만 그런 게 아닙니다. 변수 이름이 container 이거나, 이벤트 이름 문자열이 'resize' 이거나, 객체 속성 이름이 ring 이어도 똑같이 걸립니다. 전부 실제 유틸리티 이름이거든요.

확인이 끝났으면 방금 넣은 주석은 지우세요.

그래서 Step 2 의 두 줄이 있었습니다

이제 설치할 때 넣어둔 @source not 두 줄이 왜 필요했는지 보입니다.

Tailwind 는 앱에 포함되는지를 따지지 않습니다. main.tsx 에서 불러오지 않는 파일이라도 폴더 안에 있으면 훑어요. "앱에 들어가는가" 와 "Tailwind 가 훑는가" 는 서로 다른 기준입니다.

방금 주석 한 줄로 확인했죠. 그렇다면 화면에 안 나가는 파일 안의 단어들도 똑같이 걸립니다.

실무 프로젝트에는 그런 파일이 늘 함께 있습니다. 테스트 파일, 목업 데이터, 문서, 실험용 코드 조각 같은 것들이요. 전부 사용자에게 전달되지 않는데도 Tailwind 는 훑습니다.

특히 테스트 파일이 그렇습니다. F 카테고리에서 우리도 만들게 될 텐데, 화면을 확인하는 코드라 container 처럼 유틸리티와 겹치는 이름을 자연스럽게 쓰게 되거든요. 방금 말한 변수 이름 사례가 바로 그것입니다.

CSS
/* apps/web-spa/src/styles/globals.css */
@source not "../../scratch";
@source not "../**/*.test.ts*";

경로는 이 CSS 파일 위치 기준 상대 경로입니다. Step 2 에서 넣어둔 두 줄이 이것이었어요.

⚠️ 지금은 제외할 파일이 없어서 두 줄을 지웠다 넣어도 CSS 크기가 그대로입니다. 효과를 눈으로 보는 건 F 카테고리에서 테스트 파일이 생긴 뒤예요. 오늘은 왜 그 장치가 필요한지까지만 이해하면 됩니다.

💡 한 줄 정리

v4 는 설정 없이 프로젝트를 훑어 쓴 유틸리티만 만들어냅니다. 다만 코드를 해석하는 게 아니라 글자로 훑기 때문에 이름이 겹치는 단어가 딸려 올 수 있고, 그럴 땐 @source not 으로 훑지 않을 곳을 알려줍니다.

🙋 학생 질문 — "글자로 훑는다면, 클래스 이름을 코드로 조립하면 못 찾겠네요?"

정확합니다. 그리고 이건 실무에서 자주 밟는 함정이에요.

이렇게 쓰면 안 됩니다.

tsx
// 이렇게 조립하면 Tailwind 가 못 찾는다
const color = liked ? 'red' : 'gray';
<button className={`text-${color}-500`}>♡</button>

파일 어디에도 text-red-500 이라는 완성된 글자가 없습니다. text-, color, -500 조각만 있죠. Tailwind 는 글자로 훑으니까 이걸 못 찾고, CSS 를 안 만듭니다. 개발 중에는 멀쩡해 보이다가 배포하면 색이 안 먹는 사고로 이어져요.

고치는 방법은 완성된 이름을 그대로 적어두는 것 입니다.

tsx
// 완성된 이름이 파일에 있으니 찾을 수 있다
<button className={liked ? 'text-red-500' : 'text-gray-500'}>♡</button>

Step 1 에서 얘기한 "지워도 되는지 몰라서 못 지운다" 문제 기억하시나요. 그때도 이유가 같았어요. 클래스 이름을 문자열로 조립하는 코드가 있을 수 있어서 확신이 안 선다고요.

Tailwind 는 그 불확실성을 없애는 대신 규칙을 하나 겁니다. 클래스 이름은 조립하지 말고 통째로 적어라. 이 규칙만 지키면 훑기가 정확해지고, 안 쓰는 CSS 가 안 생긴다는 약속도 지켜집니다.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 이름을 짓는 대신 골라 쓰기 시작했다.

Avatar 하나를 옮겼을 뿐인데 확인한 게 많았죠. 값이 그대로 나온다는 것, 옮긴 CSS 를 지워도 마음이 편하다는 것, 그리고 안 쓴 유틸리티는 애초에 만들어지지 않는다는 것까지요.

오늘 배운 핵심 세 가지

💡 하나 — 유틸리티 우선은 CSS 를 줄이는 기술이 아니라 이름을 없애는 기술입니다. 겹치지 않게 짓기, 어디 있는지 찾기, 지워도 되는지 판단하기. 이 셋은 전부 이름 때문에 생긴 일이었어요. 이름이 없으면 셋 다 없습니다.

💡 둘 — 리셋은 브라우저 차이를 없애는 대신 브라우저가 해주던 것도 없앱니다. 제목이 가늘어진 게 그 증거였죠. 값을 직접 정해둔 곳은 멀쩡했고, 기본값에 기대던 곳만 드러났습니다.

💡 셋 — 자동 감지는 코드를 해석하지 않고 글자로 훑습니다. 그래서 설정 없이도 동작하지만, 이름이 겹치는 단어가 딸려 오고 조립한 클래스 이름은 못 찾습니다. 편리함과 한계가 같은 뿌리에서 나와요.

시작할 때 내걸었던 것들

Step 1 에서 문제를 셋 꺼냈죠. 어디서 닫혔는지 봅시다.

내걸었던 것 닫힌 곳
이름이 겹쳐서 접두사가 열한 종류 Step 3 — Avatar 에 지은 이름 0 개
어떤 스타일이 어디서 오는지 못 찾는다 Step 3 — 컴포넌트만 보면 됨
안 쓰는 CSS 를 지울 엄두가 안 난다 Step 4 — 안 쓰면 안 만들어짐

다음 시간 예고

그런데 오늘 옮긴 Avatar 를 다시 보세요.

tsx
<span className="text-sm font-semibold">{username}</span>

text-smfont-semibold 는 Tailwind 가 미리 정해둔 값입니다. 우리가 고른 게 아니라 주어진 걸 쓴 거예요.

그런데 우리 앱에는 우리만의 값이 있습니다. globals.css 를 보면 #0095f6 이라는 파란색이 버튼에 쓰이고, #ed4956 이라는 빨간색이 좋아요와 오류 메시지에 쓰입니다. #dbdbdb 테두리 색은 여러 규칙에 반복해서 적혀 있고요.

이 색들은 Tailwind 가 모릅니다. 그래서 지금은 유틸리티로 못 씁니다.

특히 #dbdbdb11 번 반복해서 적혀 있어요. 테두리 색을 한 번 바꾸려면 11 곳을 고쳐야 한다는 뜻입니다.

다음 시간에는 우리 색과 간격을 Tailwind 에게 알려주는 방법을 봅니다. 알려주고 나면 bg-brand 같은 유틸리티가 저절로 생겨요. 우리가 만들지 않았는데도요.

그리고 오늘 못 한 게 하나 더 있습니다. 화면 크기에 따라 다르게 보이게 하는 것, 마우스를 올렸을 때 색이 바뀌는 것 말이에요. B-4 과제에서 창 너비를 재는 훅을 만들면서 "CSS 로 되는 일을 굳이 자바스크립트로 하지 말라" 고 했던 것 기억하시나요. 그 CSS 쪽 얘기를 다음 시간에 합니다.

오늘 옮긴 건 Avatar 하나뿐이니, 나머지 컴포넌트도 다음 시간에 마저 옮깁니다.


과제

[구현] 게시물 카드의 머리말을 유틸리티로

PostHeader 와 그 안의 더보기 버튼을 유틸리티로 옮기세요.

  • globals.css.post-header.post-more 두 규칙이 대상입니다.
  • Step 3 에서 한 순서를 그대로 따르세요. 값을 표로 짝지어 보고, 옮기고, 원래 규칙을 지우고, Computed 탭으로 확인합니다.
  • .post-more 에는 line-height: 1 이 있습니다. 이 값에 맞는 유틸리티를 찾아보세요. Tailwind 공식 문서에서 line-height 를 검색하면 나옵니다.
  • 옮긴 뒤 globals.css 가 몇 줄이 됐는지 적어 주세요.

다 하고 나면 npm run typecheck -w web-spanpm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.

[탐구] 훑기의 경계를 직접 건드려 보기

각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요. 확인이 끝나면 전부 원래대로 되돌립니다.

  • Avatar 에서 p-3 을 지우고 빌드한 뒤, CSS 에서 .p-3 을 찾아보세요. 몇 개가 나오나요? Step 1 의 셋째 문제와 이어서 설명해 보세요.
  • Step 4 의 주석 실험을 변수 이름으로 바꿔서 해보세요. 아무 컴포넌트에 const container = 1; 을 넣고 빌드한 뒤 .container 를 세어 보세요. 몇 개가 나오나요? 주석 실험 때와 개수가 다르다면 왜 그럴지 생각해 보세요.
  • 🙋 토글에서 본 조립 방식(`text-${color}-500`)을 LikeButton 에 실제로 넣고 빌드해 보세요. CSS 에서 .text-red-500 이 몇 개인가요? 그리고 빌드된 JS 에서 like-button text- 를 찾아보세요. 클래스는 붙는데 색은 안 먹는 상태를 두 곳에서 각각 확인하는 겁니다. 이런 실패가 왜 발견하기 어려운지 적어 주세요.
  • 위 조립 방식을 완성된 이름으로 고친 뒤 다시 빌드해서 .text-red-500 을 세어 보세요. 이번엔 몇 개인가요?

생각해볼 주제

1. 긴 클래스 문자열은 정말 읽기 나쁜가

Step 1 에서 대가를 이야기했죠. className="avatar"className="flex items-center gap-2.5 p-3" 이 됐습니다.

Tailwind 를 처음 본 사람들이 가장 많이 하는 반응이 이겁니다. "HTML 이 더러워진다", "이럴 거면 인라인 스타일이랑 뭐가 다르냐" 요.

그런데 오늘 실제로 옮겨보고 나서 생각이 좀 달라지셨을 수도 있습니다. 짧은 이름 하나를 읽고 다른 파일로 이동하는 것과, 긴 문자열 하나를 그 자리에서 읽는 것 중에 무엇이 더 빠를까요.

여러분의 판단은 어느 쪽인가요. 그리고 그 판단이 프로젝트 크기나 팀 인원에 따라 달라질지도 생각해 보세요.

2. 어디까지를 컴포넌트로 묶을 것인가

🙋 토글에서 같은 모양이 반복되면 클래스가 아니라 컴포넌트를 공유한다고 했습니다. B-3 에서 배운 방식이죠.

그런데 이 기준이 늘 선명하지는 않습니다. 카드 세 개가 비슷하지만 완전히 같지는 않다면요? 여백만 다르다면 컴포넌트를 나눠야 할까요, props 로 받아야 할까요, 아니면 그냥 유틸리티를 두 번 적는 게 나을까요.

B-3 에서 우리는 CommentItem 을 만들지 않기로 했습니다. 한 줄짜리라 자기 상태도 이벤트도 없었으니까요. 그때의 기준을 스타일에도 그대로 적용할 수 있을까요.

같은 유틸리티 문자열이 몇 번 반복되면 컴포넌트로 묶을지, 여러분만의 기준을 세워 보세요.

3. 편리한 자동화가 규칙을 만들 때

Step 4 에서 자동 감지의 두 얼굴을 봤습니다. 설정을 안 해도 되는 대신, 클래스 이름을 조립하면 안 된다는 규칙이 생겼어요.

이런 거래는 도구를 쓸 때 자주 만납니다. 뭔가를 알아서 해주는 도구는 대개 "이렇게 써야 알아볼 수 있다" 는 전제를 깔고 있거든요. 그 전제를 어기면 조용히 동작하지 않습니다. 오류도 안 나고요.

여러분이 이미 만난 것 중에도 비슷한 게 있습니다. A-6 에서 확인했듯 번들러는 타입을 검사하지 않아서 타입 오류가 있어도 빌드가 성공했죠. 그래서 우리는 타입 검사를 따로 돌리는 습관을 들였습니다.

이런 "조용한 실패" 를 팀이 놓치지 않으려면 무엇이 필요할까요. 문서에 적어두는 것으로 충분할까요, 아니면 다른 방법이 있을까요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 게시물 카드의 머리말을 유틸리티로

채점 포인트

항목 확인할 것
값 짝짓기 .post-header 세 선언과 .post-more 일곱 선언을 각각 어떤 유틸리티로 옮겼는지 표로 정리했는가
line-height: 1 leading-none 을 찾아냈는가
눈금 확인 18px 에 맞는 이름(text-lg)을 눈금에서 찾아냈는가
규칙 삭제 옮긴 뒤 globals.css 에서 두 규칙을 실제로 지웠는가
값 확인 Computed 탭으로 옮기기 전후를 대조했는가
줄 수 보고 지운 뒤 globals.css 줄 수를 적었는가
마무리 확인 typechecklint 가 통과하는가

풀이 예시

먼저 옮길 대상을 봅니다.

CSS
/* apps/web-spa/src/styles/globals.css */
.post-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.post-more {
  border: none;
  background: none;
  color: #262626;
  font-size: 18px;
  line-height: 1;
  padding: 12px;
  cursor: pointer;
}

Step 3 에서 한 대로 짝을 지어봅니다.

손으로 쓴 CSS 유틸리티 메모
display: flex flex
align-items: center items-center
justify-content: space-between justify-between
padding: 12px p-3 3 × 4px
cursor: pointer cursor-pointer
line-height: 1 leading-none leading = line-height
font-size: 18px text-lg 눈금에 이미 있는 값

font-size: 18px 에서 잠깐 멈추셨을 수 있습니다. Tailwind 의 글자 크기는 text-sm, text-base, text-lg 처럼 이름으로 되어 있어서 18 이라는 숫자가 안 보이니까요.

찾아보면 text-lg 가 정확히 18px 입니다. 이름만 봐서는 몇 px 인지 모르니, 공식 문서의 글자 크기 표를 한 번 훑어두면 좋아요.

여기에 한 가지 더 있습니다. text-lg 는 글자 크기만 정하지 않고 줄 높이도 28px 로 함께 정합니다. 원래 .post-more 규칙은 line-height: 1 이었죠. 그래서 leading-none 을 같이 붙여 줄 높이를 되돌려야 합니다. text-lg leading-none 을 Computed 탭에서 재보면 18px / 18px 로 원래 값과 같습니다.

글자 크기 유틸리티가 줄 높이를 끼워 넣는다는 건 옮길 때마다 확인해야 하는 부분이에요. 다음 시간에 우리 값을 Tailwind 에게 알려주면서 이 짝을 다시 만납니다.

옮긴 결과입니다.

tsx
// apps/web-spa/src/components/PostHeader.tsx
export function PostHeader({ username, profileImageUrl }: PostHeaderProps) {
  return (
    <div className="flex items-center justify-between">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <IconButton className="cursor-pointer p-3 text-lg leading-none" aria-label="게시물 메뉴">
        ⋯
      </IconButton>
    </div>
  );
}

그리고 globals.css 에서 .post-header.post-more 두 규칙을 지웁니다. 373 줄이던 파일이 357 줄이 됩니다. 규칙 사이 빈 줄까지 16 줄이 빠졌어요.

자주 나오는 실수

하나 — 손 CSS 의 선언을 하나도 빠짐없이 옮기려 합니다.

.post-more 에는 선언이 일곱 개인데 위 답안에는 유틸리티가 넷뿐입니다. 셋은 왜 없을까요.

color: #262626 부터 보죠. text-[#262626] 을 붙여도 되지만 없어도 색이 똑같습니다. body 가 이미 color: #262626 이고 버튼이 그걸 물려받거든요. 직접 확인해 보세요. 유틸리티를 빼고 Computed 탭에서 color 를 보면 그대로 rgb(38, 38, 38) 입니다.

border: nonebackground: none 은 Step 2 에서 만난 리셋이 이미 처리했습니다. 리셋이 버튼의 기본 테두리와 배경을 지워두거든요.

기계적으로 옮기면 필요 없는 유틸리티가 따라붙습니다. 하나씩 빼보면서 화면이 바뀌는지 확인하는 게 좋아요.

둘 — border-style 이 미묘하게 다릅니다.

Computed 탭을 꼼꼼히 본 분은 한 곳이 어긋난 걸 찾았을 겁니다.

옮기기 전 옮긴 뒤
border-style none solid
border-width 0px 0px

손 CSS 의 border: none 은 스타일을 none 으로 만들고, 리셋은 solid 에 두께 0 을 남깁니다. 두께가 0 이라 화면은 같아요. 이걸 찾아냈다면 아주 잘 보신 겁니다.

셋 — justify-content: space-between 의 유틸리티 이름을 못 찾습니다.

justify-space-between 이 아니라 justify-between 입니다. Tailwind 는 align-itemsitems-, justify-contentjustify- 로 줄이고 뒤에 값을 붙여요. align-items: centeritems-center 인 것과 같은 규칙입니다.

💡 튜터의 한마디

옮기는 작업의 핵심은 "몇 개를 옮겼나" 가 아니라 "안 옮겨도 되는 걸 찾았나" 입니다.

오늘 답안에서 선언 일곱 개 중 셋이 사라졌죠. 하나는 부모가 주고 있었고, 둘은 리셋이 처리하고 있었습니다. 손으로 CSS 를 쓸 때는 이런 중복이 보이지 않아요. 각 규칙이 자기 파일에서 자기 말만 하니까요.

유틸리티로 옮기면 그게 드러납니다. 컴포넌트 한 줄에 다 모여 있으니 "이거 정말 필요한가" 를 묻게 되거든요. 실무에서 오래된 CSS 를 Tailwind 로 옮기는 작업을 하면, 옮기는 것보다 버리는 것이 훨씬 많습니다.

🎯 [과제 2 예시답안] 훑기의 경계를 직접 건드려 보기

채점 포인트

항목 확인할 것
삭제 실험 유틸리티를 지우면 CSS 규칙도 사라진다는 것을 확인했는가
변수 이름 실험 변수 이름만으로도 CSS 가 생긴다는 것을 확인했는가
개수 차이 주석 실험(1)과 변수 실험(6)의 개수가 다른 이유를 짚었는가
조립 실험 CSS 는 0 인데 JS 에는 코드가 들어간다는 것을 두 곳에서 확인했는가
조용한 실패 왜 발견하기 어려운지 자기 말로 설명했는가
고친 뒤 확인 완성된 이름으로 고치면 CSS 가 생긴다는 것까지 확인했는가

풀이 예시

p-3 을 지우면

Avatar 에서 p-3 을 빼고 빌드하면 .p-30 이 됩니다.

이게 Step 1 의 셋째 문제가 사라졌다는 증거입니다. 손 CSS 였다면 .avatar 규칙에서 padding 줄을 지워야 했고, 안 지우면 아무도 안 쓰는 선언이 파일에 남았겠죠. 유틸리티는 컴포넌트에서 지우는 순간 CSS 에서도 사라집니다.

변수 이름을 container 로 지으면

아무 컴포넌트에 const container = 1; 을 넣고 빌드하면 .container6 개 나옵니다.

변수 이름이잖아요. className 근처에도 안 갔습니다. 그런데도 생겼어요. Step 4 의 주석 실험과 같은 이유입니다. 글자로 훑으니까요.

개수가 왜 다른가

주석 실험에서 .p-4 는 1 개였는데 .container 는 6 개입니다.

p-4 는 padding 하나만 정하니 규칙도 하나면 됩니다.

container 는 다릅니다. 기본 규칙이 하나 있고, 그 위에 화면 너비별로 다른 값을 주는 규칙이 다섯 개 더 붙어요. 그래서 1 + 5 = 6 입니다. 유틸리티 하나가 규칙 하나라는 보장이 없다는 뜻이에요.

그래서 CSS 를 검색할 때는 개수를 세는 것보다 0 이냐 아니냐를 보는 게 안전합니다. 정확한 이유는 다음 시간에 화면 크기별 스타일을 다루면서 확인합니다.

조립한 클래스 이름

LikeButton 을 이렇게 바꿔서 빌드해 봅니다.

tsx
// apps/web-spa/src/components/LikeButton.tsx
<Button className={`like-button text-${liked ? 'red' : 'gray'}-500`} onClick={onToggle}>

두 곳을 확인합니다.

Bash
grep -o "\.text-red-500[{,]" dist/assets/*.css | wc -l
grep -o "like-button text-" dist/assets/*.js | wc -l

CSS 는 0 이고 JS 는 1 입니다.

이 조합이 핵심이에요. 자바스크립트는 멀쩡히 동작합니다. 버튼에 text-red-500 이라는 클래스가 실제로 붙어요. 개발자 도구로 요소를 보면 클래스가 보입니다.

그런데 그 클래스를 뒷받침할 CSS 가 없습니다. 그래서 색만 안 먹어요.

왜 발견하기 어려운가

세 가지가 겹칩니다.

하나 — 아무도 오류를 안 냅니다. 타입 검사도 통과하고, 린트도 조용하고, 빌드도 성공합니다. className 은 그냥 문자열이니까 TypeScript 가 내용을 볼 이유가 없어요. A-6 에서 번들러가 타입을 검사하지 않는다는 걸 확인했는데, 여기서는 타입 검사조차 볼 수 없는 영역입니다.

둘 — 클래스는 붙어 있습니다. 개발자 도구에서 요소를 보면 text-red-500 이 멀쩡히 있어요. 그래서 "클래스가 안 붙었나" 를 의심하는 방향으로는 원인을 못 찾습니다. Styles 탭에서 그 클래스에 딸린 규칙이 없다는 걸 알아채야 하는데, 없는 것을 알아채기는 어렵죠.

셋 — 조건에 따라 갈립니다. 위 예제에서 liked 가 false 인 상태로만 테스트하면 회색도 안 먹으니 둘 다 안 보입니다. 반대로 원래 색과 비슷하면 눈치채지 못하고 넘어가요.

그래서 규칙을 지키는 쪽이 훨씬 쌉니다. 클래스 이름은 조립하지 말고 통째로 적습니다.

tsx
// apps/web-spa/src/components/LikeButton.tsx
<Button className={liked ? 'like-button text-red-500' : 'like-button text-gray-500'} onClick={onToggle}>

이렇게 고치고 다시 빌드하면 .text-red-500.text-gray-500각각 1 개씩 생깁니다. 이제 색이 먹어요.

같은 화면을 만드는 두 코드인데 하나는 되고 하나는 안 됩니다. 차이는 완성된 이름이 파일에 글자 그대로 있느냐 하나뿐이에요.

💡 튜터의 한마디

오늘 확인한 것 중에 제일 중요한 건 마지막 실험입니다.

"클래스는 붙는데 CSS 가 없다" 는 상태는 아무 도구도 안 알려줍니다. 컴파일러도, 린트도, 빌드도요. 여러분이 직접 화면을 보고 "어, 색이 왜 이러지" 하고 알아채야 해요.

이런 실패를 만날 때마다 기억하세요. 도구가 편해질수록 도구가 전제하는 사용법이 생깁니다. Tailwind 는 설정을 없애준 대신 "이름을 통째로 적어라" 를 전제해요. 그 전제를 어기면 조용히 안 됩니다.

새 도구를 쓸 때 "이건 뭘 자동으로 해주지" 만 묻지 말고, "그 자동화가 뭘 전제하지" 를 같이 물어보세요. 실무에서 하루를 날리느냐 마느냐가 여기서 갈립니다.


🤔 [생각해볼 주제 1] 긴 클래스 문자열은 정말 읽기 나쁜가

문제 상황 요약

className="avatar"className="flex items-center gap-2.5 p-3" 이 됐습니다. 글자 수로는 네 배가 넘어요. Tailwind 를 처음 보는 사람이 가장 먼저 하는 말이 "HTML 이 지저분해진다" 인 이유입니다.

튜터의 가이드 및 해설

이 논쟁을 제대로 하려면 무엇과 무엇을 비교하는지 부터 맞춰야 합니다.

흔히 인라인 스타일과 비교합니다. style={{ display: 'flex' }} 랑 뭐가 다르냐고요. 이건 틀린 비교예요. 인라인 스타일로는 마우스를 올렸을 때나 화면 크기에 따라 다르게 하는 걸 못 합니다. 유틸리티는 결국 CSS 클래스라 다 됩니다.

제대로 된 비교는 읽는 데 걸리는 시간 입니다.

className="avatar" 를 읽으면 글자는 짧지만 아직 아무것도 모릅니다. 알려면 globals.css 를 열고, .avatar 를 찾고, 아래에 덮어쓰는 규칙이 또 있는지 확인해야 해요. Step 1 에서 .post-likes 로 실제로 해봤죠. 파일 두 개에 규칙 두 개였습니다.

className="flex items-center gap-2.5 p-3" 은 길지만 그걸로 끝입니다. 파일을 안 옮겨요.

그래서 판단 기준은 이렇습니다. 코드를 쓰는 사람보다 읽는 사람이 훨씬 많고, 읽는 사람은 대개 그 코드를 처음 봅니다. 처음 보는 사람에게는 "짧지만 이동해야 하는 것" 보다 "길지만 그 자리에서 끝나는 것" 이 대체로 빠릅니다.

다만 상황에 따라 달라집니다.

팀이 클수록 유틸리티가 유리합니다. 남이 쓴 CSS 를 읽을 일이 많아지니까요. 그리고 남의 CSS 를 건드릴 때 다른 화면이 깨질 걱정도 없어집니다.

디자인이 복잡하고 반복이 많으면 손 CSS 도 경쟁력이 있습니다. 다만 그때도 답은 CSS 클래스가 아니라 컴포넌트일 가능성이 높아요. 주제 2 에서 이어갑니다.

프로젝트가 아주 작으면 어느 쪽이든 상관없습니다. 화면 두세 개짜리에서는 Step 1 의 세 문제가 아예 안 생기거든요. 그럴 땐 익숙한 쪽이 정답입니다.

한 가지 덧붙이면, 이 논쟁은 실무에서 생각보다 빨리 끝납니다. 며칠 써보면 대부분 익숙해지거든요. 오히려 오래 남는 불만은 길이가 아니라 "우리 디자인 값을 Tailwind 가 모른다" 쪽입니다. #0095f6 같은 우리 색을 유틸리티로 못 쓴다는 것 말이에요.

🎯 면접관을 홀리는 핵심 멘트

"유틸리티 클래스가 길다는 지적은 맞습니다. 다만 저는 그걸 문자열 길이가 아니라 읽는 비용으로 봅니다. 짧은 클래스 이름은 스타일 파일로 한 번 이동해야 알 수 있고, 그 파일에서 덮어쓰는 규칙이 또 있는지까지 확인해야 합니다. 유틸리티는 길지만 그 줄에서 끝납니다. 코드는 쓰는 횟수보다 읽는 횟수가 많고 대개 처음 보는 사람이 읽으니, 저는 이동이 없는 쪽을 택합니다."

🤔 [생각해볼 주제 2] 어디까지를 컴포넌트로 묶을 것인가

문제 상황 요약

같은 유틸리티 문자열이 반복되면 컴포넌트로 묶는다고 했습니다. 그런데 세 곳이 비슷하지만 똑같지는 않을 때, 컴포넌트를 나눌지 props 를 받을지 그냥 두 번 적을지 기준이 필요합니다.

튜터의 가이드 및 해설

먼저 B-3 에서 세운 기준을 다시 꺼내 봅시다. 그때 우리는 댓글 한 줄을 따로 컴포넌트로 빼지 않기로 했어요. 쪼갤 이유 세 가지를 대 봤는데 셋 다 아니었거든요. 자기 상태도 없고, 자기 이벤트도 없고, 다른 데서 재사용하지도 않았습니다.

그 기준은 동작에 관한 것이었습니다. 스타일에는 그대로 적용되지 않아요. 자기 상태가 없어도 모양이 똑같이 반복되면 묶을 이유가 생기거든요.

그래서 기준을 하나 더 둡니다. 바뀔 때 같이 바뀌어야 하는가.

카드 세 개의 여백이 지금 우연히 같은 거라면 묶으면 안 됩니다. 나중에 하나만 바꾸고 싶을 때 묶어둔 게 방해가 돼요. 반대로 "우리 서비스의 카드는 이렇게 생겼다" 라서 같은 거라면 묶어야 합니다. 디자인이 바뀌면 셋 다 같이 바뀌어야 하니까요.

A-4 에서 AvatarProps 를 직접 선언할지 Post 에서 파생할지 고민할 때 썼던 기준과 같습니다. 운명을 같이하는가를 물어보는 거예요.

중간 단계도 있습니다. 구조는 같고 생김새만 다르면 className 을 밖에서 받으면 됩니다. B-3 의 CardButton 이 그렇게 되어 있죠. 무엇을 어떤 순서로 그릴지는 컴포넌트가 정하고, 어떻게 보일지는 쓰는 쪽이 정합니다.

반대로 B-5 의 TextField 는 클래스를 자기 안에 들고 있습니다. 입력 칸 모양은 어디서 쓰든 같아야 하니까요.

반복 횟수로 기계적으로 정하는 건 권하지 않습니다. "세 번 나오면 묶는다" 같은 규칙은 편하지만, 우연히 세 번 같은 것까지 묶어버려요. 두 번이라도 의미가 같으면 묶고, 다섯 번이라도 우연이면 두는 게 맞습니다.

실무에서는 디자인 시스템이 있으면 훨씬 쉬워집니다. 디자이너가 "카드", "버튼" 을 이미 정의해 뒀다면 그 단위가 곧 컴포넌트 단위거든요. 우리 코드베이스의 Card, Button, TextField 도 그렇게 나뉘어 있습니다.

🎯 면접관을 홀리는 핵심 멘트

"저는 반복 횟수로 컴포넌트를 나누지 않습니다. 같은 모양이 세 번 나와도 우연히 같은 거면 두고, 두 번이어도 함께 바뀌어야 하는 거면 묶습니다. 기준은 '지금 같은가' 가 아니라 '앞으로도 같이 바뀌어야 하는가' 입니다. 우연히 같은 것을 묶으면 나중에 하나만 바꾸려 할 때 그 묶음이 방해가 되거든요."

🤔 [생각해볼 주제 3] 편리한 자동화가 규칙을 만들 때

문제 상황 요약

Tailwind 는 설정 파일을 없애줬습니다. 대신 "클래스 이름을 조립하지 말라" 는 규칙이 생겼어요. 어기면 오류 없이 조용히 안 됩니다.

튜터의 가이드 및 해설

이 거래는 도구를 쓸 때 계속 만납니다. 뭔가를 알아서 해주는 도구는 대개 "이렇게 써야 알아볼 수 있다" 를 전제로 깔거든요.

여러분이 이미 만난 것만 꼽아도 여럿입니다.

React Compiler 는 메모이제이션을 자동으로 넣어주는 대신 컴포넌트가 규칙을 지키기를 전제합니다. 렌더 중에 바깥 값을 건드리면 최적화를 포기해요. B-2 와 B-4 에서 린트가 그걸 알려줬죠.

번들러는 안 쓰는 코드를 걷어내는 대신 정적으로 분석 가능한 형태를 전제합니다. 그리고 A-6 에서 봤듯 타입은 아예 안 봅니다.

문서에 적어두는 것만으로는 부족합니다. 사람은 문서를 안 읽고, 읽어도 잊어버려요. 특히 이런 규칙은 어겼을 때 바로 안 아프니까 더 그렇습니다.

그래서 실무에서는 세 겹으로 막습니다.

첫째, 자동으로 잡히게 만듭니다. 린트 규칙이 최선이에요. 우리가 겪은 것 중에도 그런 게 많았죠. 훅을 조건문 안에서 부르면 린트가 막고, 렌더 중에 참조를 읽어도 막습니다. 조립한 클래스 이름을 잡아주는 린트 플러그인도 실제로 있습니다.

둘째, 안 잡히는 건 눈에 보이게 만듭니다. 조립한 클래스 이름은 완벽하게 잡기 어려워요. 문자열은 무한히 조립될 수 있으니까요. 이럴 땐 화면을 사람이 보는 절차를 넣습니다. F 카테고리에서 다룰 테스트나, 화면 변화를 이미지로 비교하는 방식이요.

셋째, 규칙을 어기기 어려운 구조를 만듭니다. 제일 강력합니다. 색이 필요하면 조립하지 않고도 되도록 미리 컴포넌트를 만들어두면, 조립할 이유 자체가 없어져요.

정리하면 이렇습니다. 조용한 실패는 문서가 아니라 구조로 막습니다. 문서는 마지막 안전망이지 첫 방어선이 아니에요.

🎯 면접관을 홀리는 핵심 멘트

"자동화 도구를 볼 때 저는 '무엇을 대신 해주는가' 와 함께 '무엇을 전제하는가' 를 봅니다. 전제를 어기면 대개 오류가 아니라 조용한 실패로 나타나거든요. Tailwind 는 설정을 없앤 대신 클래스 이름을 통째로 적을 것을 전제하고, 어기면 클래스는 붙는데 CSS 만 없는 상태가 됩니다. 그래서 저는 이런 규칙을 문서가 아니라 린트나 구조로 막으려 합니다. 문서는 사람이 잊어버리지만 구조는 안 잊어버리니까요."

전체 목록 리액트
이 토픽을 끝까지 봤어요 🎉 다른 트랙도 둘러보기 →