E-6: Shadcn 컴포넌트로 화면 만들기 — 남이 짠 코드를 열어서 읽는 날
목차 64
안녕하세요, 홍순구입니다. 지난 시간에 우리는 라이브러리를 들여놓고 이름이 부딪히는 걸 정리하고 다크모드 스위치까지 우리 손으로 가져왔는데, 정작 컴포넌트는 하나도 안 썼어요. components/ui/ 폴더는 아직 비어 있는데, 설치만 하고 끝난 셈이죠.
오늘 드디어 가져다 씁니다. 명령 하나로 파일이 복사돼 오는 걸 보고, 그 파일을 열어서 읽습니다. 남이 짠 코드지만 이제 내 파일이니까요. 지난 시간 첫머리에서 꺼냈던 목록도 다시 꺼내는데, 모달 하나 제대로 만들려면 챙길 게 여섯 가지였죠. 그중 몇 개가 공짜로 딸려오는지 오늘 직접 세어봅니다.
오늘의 여정
Step 1 명령 하나에 파일 넷이 왔다
│
Step 2 large 와 small 을 어떻게 구분하나
│
Step 3 cn 이 무슨 일을 하는지 이제 말합니다
│
Step 4 붙어 있는 표시와 없는 함수
│
Step 5 피드를 갈아끼운다
│
Step 6 사진을 누르면 크게
│
Step 7 여섯 줄을 세어본다
│
Step 8 값을 매긴다
▼
남이 짠 코드를 읽고, 고치고, 우리 것과 나란히 세운다
💡 오늘 수업의 핵심 — "가져다 쓴다는 것은 읽을 줄 안다는 뜻이다"
복사해 온 파일은 블랙박스가 아니라서, 열면 읽을 수 있고 읽으면 고칠 수 있어요. 읽을 줄 알아야 소유한 값을 합니다.
🎯 학습 목표
- 복사돼 온 컴포넌트를 열어
cva로 변형을 정의하는 방식과cn이 클래스 충돌을 정리하는 방식을 설명할 수 있다 data-slot표시가 왜 붙어 있는지 설명하고, 우리 토큰과 들여온 토큰을 한 화면에서 함께 쓸 수 있다- Dialog로 모달을 만들고, 접근성 동작 중 무엇이 딸려오고 무엇이 우리 몫인지 가려낼 수 있다
Step 1: "명령 하나에 파일 넷이 왔다"
바로 가져와 보기 전에, 한 번 물어볼 수 있어요.
npx shadcn@latest add dialog --dry-run
--dry-run은 "실제로 하지 말고 뭘 할 건지만 보여줘"라는 뜻입니다.
┌ shadcn add dialog (dry run)
│
├ Files (2) +2 new
│ + src/components/ui/button.tsx create
│ + src/components/ui/dialog.tsx create
│
│ 2 files
└ Run without --dry-run to apply.
dialog 하나만 불렀는데 button.tsx가 따라옵니다. 대화 상자 안에 닫기 버튼이 들어가거든요. 이렇게 딸려오는 것들이 있어서 미리 보는 습관이 도움이 됩니다.
이제 필요한 것들을 한 번에 가져올게요.
npx shadcn@latest add dialog avatar card
✔ Created 4 files:
- src/components/ui/avatar.tsx
- src/components/ui/card.tsx
- src/components/ui/button.tsx
- src/components/ui/dialog.tsx
설치했는데 package.json이 안 바뀌었습니다
여기서 이상한 걸 발견하게 됩니다. 보통 뭔가를 설치하면 package.json이 늘어나기 마련인데, 열어보면 한 줄도 안 바뀌어 있습니다.
지난 시간을 떠올려 보세요. init을 돌렸을 때 이미 여덟 개가 깔렸고 @base-ui/react도 그때 들어왔으니, 오늘 온 것은 컴포넌트 소스 파일뿐이에요.
지난 시간 init → package.json 에 8개 추가 (동작 알맹이·cn 재료·아이콘…)
오늘 add → 파일 4개 복사. package.json 변화 없음
지난 시간에 말한 "생김새는 내가 소유하고, 동작은 빌려 쓴다"가 이렇게 눈에 보입니다.
이름이 전부 겹칩니다
새로 온 파일 이름을 우리 폴더와 나란히 놓아 보세요.
src/components/
Avatar.tsx ← 우리가 B-3 에서 짠 것
Button.tsx ← 우리가 짠 것
Card.tsx ← 우리가 짠 것
PostCard.tsx
...
ui/
avatar.tsx ← 방금 온 것
button.tsx ← 방금 온 것
card.tsx ← 방금 온 것
dialog.tsx ← 방금 온 것
셋이나 겹치죠. 지난 시간 질문 토글에서 components/ui/ 폴더 규약을 이야기했는데, 그게 지금 진짜로 일을 합니다. 폴더가 다르니 파일은 안 부딪히고, 폴더 이름만 보고도 "이건 가져온 것"을 알 수 있어요.
열어보면 우리 스타일이 아닙니다
파일을 하나 열어보면 바로 눈에 띄는 게 있습니다.
// apps/web-spa/src/components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
쌍따옴표를 쓰고 줄 끝에 세미콜론이 없는데, 우리는 작은따옴표에 세미콜론을 붙여 왔죠. 남의 집 규칙이 그대로 들어온 건데, 린트를 돌려보면 그래도 통과합니다. 우리 설정이 따옴표나 세미콜론까지는 강제하지 않기 때문이에요. 그대로 두셔도 되고, 팀 규칙이 엄격하면 포매터를 한 번 돌려 맞춰도 됩니다.
dialog.tsx는 첫 줄이 더 낯설어요.
// apps/web-spa/src/components/ui/dialog.tsx
"use client"
이건 Next.js를 위한 표시입니다. 우리는 지금 Vite로 만든 SPA라 아무 일도 하지 않아요. 나중에 D 카테고리에서 Next.js를 다룰 때 이 줄이 무슨 뜻인지 제대로 봅니다. 지금은 "무해한 줄이 하나 있구나" 정도로 넘어가세요.
💡 한 줄 정리
add는 컴포넌트 소스를 components/ui/로 복사해 옵니다. 딸린 것이 함께 오고, 이미 깔린 의존성은 다시 안 깔며, 우리가 짠 컴포넌트와 이름이 겹쳐도 폴더가 달라 부딪히지 않습니다.
🙋 학생 질문 — "튜터님, 한 파일에서 우리 Card랑 가져온 Card를 둘 다 쓰려면 어떡하죠?"
좋은 질문이에요. 파일은 안 부딪혀도 한 파일 안에서 이름을 두 번 부를 수는 없죠.
이럴 땐 부를 때 이름을 바꿔 줍니다.
import { Card } from './ui/card';
import { Card as LegacyCard } from './Card';
as를 붙이면 그 파일 안에서만 다른 이름으로 쓸 수 있지만, 실무에서는 이렇게 오래 두지는 않습니다. 둘 다 쓰고 있다는 건 갈아타는 중이라는 뜻이거든요. 옮기는 동안만 잠깐 이렇게 두고, 다 옮기면 옛것을 지웁니다.
우리도 오늘 옮기지만 옛 Card.tsx는 안 지웁니다. B-3에서 직접 만들어 본 그 파일은 남겨 둘 만한 가치가 있으니까요.
Step 2: "large와 small을 어떻게 구분하나"
이제 읽어봅시다. button.tsx부터요. 버튼은 생김새가 제일 여러 갈래라 읽을 게 많습니다.
// apps/web-spa/src/components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg …",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted …",
ghost: "hover:bg-muted hover:text-foreground …",
destructive: "bg-destructive/10 text-destructive …",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 …",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 …",
lg: "h-9 gap-1.5 px-2.5 …",
icon: "size-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
구조가 보이시나요. 세 덩어리입니다.
cva(
"언제나 붙는 클래스 한 뭉치", ← 어떤 버튼이든 공통
{
variants: {
variant: { default: "...", outline: "...", ghost: "..." }, ← 성격
size: { default: "...", sm: "...", lg: "...", icon: "..." } ← 크기
},
defaultVariants: { ... } ← 아무것도 안 고르면 이것
}
)
쓰는 쪽은 클래스를 몰라도 됩니다. 이름만 고르면 돼요.
<Button variant="outline" size="sm">취소</Button>
buttonVariants({ variant: 'outline', size: 'sm' })를 부르면 공통 뭉치 + outline 줄 + sm 줄이 이어 붙은 긴 문자열이 나옵니다. 그 문자열이 className으로 들어가는 거죠.
타입이 저절로 생깁니다
컴포넌트 선언부를 보면 재미있는 게 있어요.
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
VariantProps<typeof buttonVariants> — A-6에서 배운 typeof로 값에서 타입을 뽑아내는 그 방식입니다. 위에서 정의한 variants 객체를 읽어서 variant는 여섯 개 중 하나, size는 여덟 개 중 하나라는 타입이 자동으로 만들어지니까, 오타를 내면 바로 잡힙니다. variant="outlien"이라고 쓰면 편집기가 그 자리에서 빨간 줄을 그어요. 변형 목록을 한 곳에 적었더니 타입까지 따라온 겁니다.
⚠️ 그런데 넷 중 셋은 cva를 안 씁니다
여기서 많이들 오해하는 지점이 있는데, "shadcn은 cva로 변형을 만든다"고 외우면 card.tsx를 열었을 때 당황해요. 거기엔 cva가 없거든요.
넷을 나란히 놓고 세어보면 이렇습니다.
button.tsx cva 씀 variant 6종 · size 8종
card.tsx cva 없음 data-size 속성으로 갈림
avatar.tsx cva 없음 data-size 속성으로 갈림
dialog.tsx cva 없음 변형 자체가 없음
card.tsx는 이렇게 되어 있어요.
// apps/web-spa/src/components/ui/card.tsx
<div
data-slot="card"
data-size={size}
className={cn(
"… py-(--card-spacing) [--card-spacing:--spacing(4)] " +
"data-[size=sm]:[--card-spacing:--spacing(3)] …",
className
)}
/>
size를 클래스 묶음으로 바꾸는 대신 속성에 그대로 적어 두고, data-[size=sm]:이라는 조건부 클래스로 갈라요. E-3에서 배운 변형 문법이 여기 쓰인 겁니다.
두 방식이 갈리는 기준은 이렇게 이해하면 편합니다.
| 상황 | 어떻게 |
|---|---|
| 성격이 여러 갈래고 조합이 많다 | cva — 이름으로 고르고 타입까지 얻는다 |
| 크기 하나만 갈리고 자식들도 함께 반응해야 한다 | data-* 속성 — 자식이 부모 속성을 보고 따라간다 |
card.tsx가 두 번째인 이유는 카드 안의 제목도 크기를 따라가야 하기 때문이에요. 속성을 부모에 걸어두면 자식이 group-data-[size=sm]/card:로 그걸 읽습니다. 클래스 묶음으로는 그 연결이 안 되죠.
💡 한 줄 정리
cva는 변형을 이름으로 고르게 만들고 그 이름의 타입까지 함께 만들어 주지만, 모든 컴포넌트가 쓰는 건 아니에요. 크기 하나가 자식들에게까지 퍼져야 하는 곳은 data-* 속성으로 갑니다.
🙋 학생 질문 — "튜터님, 같은 라이브러리인데 왜 방식이 섞여 있나요? 통일하는 게 낫지 않나요?"
통일이 항상 좋은 건 아니라서 그렇습니다.
cva는 클래스 문자열을 만들어 주는 도구예요. 만들어진 문자열은 그 요소 하나에만 붙어서 바깥으로 정보가 안 나가는데, 카드는 사정이 다릅니다. 카드가 작아지면 그 안의 제목도, 여백도 함께 작아져야 하죠. 부모가 만든 클래스 문자열은 자식이 읽을 방법이 없습니다.
속성은 됩니다. data-size="sm"을 부모에 걸어 두면 자식이 group-data-[size=sm]/card:text-sm처럼 부모 속성을 조건으로 삼을 수 있어요. CSS 선택자는 원래 조상을 볼 수 있으니까요.
정리하면 혼자 결정되는 것은 cva, 아래로 퍼져야 하는 것은 속성입니다. 목적이 다르니 도구도 다른 거예요.
Step 3: "cn이 무슨 일을 하는지 이제 말합니다"
지난 시간에 lib/utils.ts를 보면서 미뤄둔 게 있죠. 이 여섯 줄이요.
// apps/web-spa/src/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
방금 읽은 button.tsx의 마지막 줄에서 이게 쓰였습니다.
className={cn(buttonVariants({ variant, size, className }))}
무슨 일을 하는지 알려면, 이게 없을 때 무슨 일이 생기는지 보는 게 빠릅니다. 그리고 그 예가 우리 코드에 이미 있어요.
우리 Button이 갖고 있던 문제
B-3에서 만든 우리 Button은 이랬습니다.
// apps/web-spa/src/components/Button.tsx
export function Button({ children, className, ... }: ButtonProps) {
return (
<button className={className} ...>
{children}
</button>
);
}
받은 className을 그대로 붙이다 보니, 안쪽에 기본 생김새가 없었어요. 쓰는 쪽이 전부 적어야 했습니다.
만약 우리 Button에도 기본 여백을 넣고 싶어서 이렇게 했다고 해봅시다.
<button className={"px-2.5 " + className} />
그리고 쓰는 쪽에서 className="px-6"을 넘기면요? 결과는 이렇게 됩니다.
"px-2.5 px-6"
둘 다 살아남는데, 어느 쪽이 이길까요. E-3에서 배웠죠 — 클래스를 적은 순서는 아무 상관이 없고, CSS 파일에 나중에 나온 규칙이 이깁니다. 그런데 그 순서는 우리가 정하는 게 아니라 Tailwind가 정해요.
즉 px-6을 넘겼는데 px-2.5가 이길 수도 있어요. 우연에 기대는 코드가 되는 셈이죠.
cn은 겹치는 것을 지웁니다
같은 상황을 cn에 넣으면 이렇게 됩니다.
| 넣은 것 | 나온 것 |
|---|---|
'px-2.5' + ' ' + 'px-6' (그냥 이어붙이기) |
px-2.5 px-6 |
cn('px-2.5', 'px-6') |
px-6 |
cn('px-2.5 py-1 text-sm', 'px-6') |
py-1 text-sm px-6 |
겹치는 것만 지우고 안 겹치는 건 그대로 두다 보니, 나중에 넘긴 것이 확실히 이겨요. 우연이 사라집니다.
이름 두 개가 각각 하는 일은 이렇게 나뉩니다.
clsx 조건·배열·객체를 문자열 하나로 모은다
cn('base', 조건 && 'on', { off: false }, ['arr']) → "base on arr"
(겹치는 건 안 건드린다 — 모으기만 한다)
tailwind-merge 그 문자열에서 서로 겹치는 클래스를 정리한다
"px-2.5 px-6" → "px-6"
cn 위 둘을 이어 붙인 것. 모으고 나서 정리한다.
⚠️ "뒤엣것이 이긴다"가 아닙니다
여기서 한 걸음 더 들어가 볼게요. 이 두 줄의 결과가 다릅니다.
cn('px-6', 'p-4') → "p-4" px-6 이 사라졌다
cn('p-4', 'px-6') → "p-4 px-6" 둘 다 남았다
첫 줄은 p-4가 좌우 여백까지 전부 정하니까 앞의 px-6은 어차피 덮여요. 그래서 지웁니다.
둘째 줄은 px-6이 p-4의 좌우만 덮습니다. 위아래는 p-4가 계속 필요해서, 둘 다 남깁니다.
즉 규칙은 "뒤엣것이 이긴다"가 아니라 "뒤엣것이 앞엣것을 완전히 덮을 수 있을 때만 앞엣것을 지운다"입니다.
⚠️ 그리고 갈래가 다르면 아예 못 밉니다
이건 나중에 우리가 실제로 부딪히는 함정이라 미리 봐 둘게요.
cn('ring-1 ring-foreground/10', 'border border-line')
→ "ring-1 ring-foreground/10 border border-line"
하나도 안 지워졌죠. ring은 테두리처럼 보이지만 실은 그림자로 그리는 것이고, border는 진짜 테두리입니다. 서로 다른 갈래라 겹친다고 판단하지 않다 보니, 테두리가 두 겹으로 그려집니다.
이럴 땐 끄고 싶은 쪽을 직접 꺼야 해요.
cn('ring-1 ring-foreground/10', 'border border-line ring-0')
→ "ring-foreground/10 border border-line ring-0"
cn은 만능이 아니라서, 같은 종류끼리만 정리해 줘요.
💡 한 줄 정리
cn은 클래스를 모으고(clsx) 겹치는 것을 정리합니다(tailwind-merge). 덕분에 바깥에서 넘긴 클래스가 확실히 이겨요. 단 완전히 덮을 수 있을 때만 지우고, 갈래가 다르면 손대지 않습니다.
🙋 학생 질문 — "튜터님, 그럼 우리가 만든 컴포넌트에도 전부 cn을 써야 하나요?"
기준은 하나예요. 컴포넌트 안쪽에 기본 클래스가 있느냐입니다.
우리 Button처럼 받은 것을 그대로 붙이기만 하면 겹칠 일이 없어요. 붙는 클래스가 한 줄뿐이라서, 이럴 땐 cn이 있으나 없으나 같습니다.
반대로 안쪽에 기본값을 두는 순간 필요해집니다. "기본은 이렇게, 필요하면 바꿔 쓰세요"라고 열어주는 컴포넌트는 전부 그래요.
그래서 실무에서는 이렇게 갈립니다. 화면 한 곳에서만 쓰는 컴포넌트는 클래스를 안에 적어두고 className을 아예 안 받아요. 여러 곳에서 조금씩 다르게 쓰일 컴포넌트만 className을 받고 cn으로 합칩니다.
받을지 말지부터 정하고, 받기로 했으면 cn을 쓰는 순서로 생각하시면 됩니다.
Step 4: "붙어 있는 표시와 없는 함수"
파일을 읽다 보면 눈에 걸리는 게 하나 더 있습니다. 모든 요소에 이게 붙어 있어요.
// apps/web-spa/src/components/ui/card.tsx
<div data-slot="card" ... />
<div data-slot="card-header" ... />
<div data-slot="card-content" ... />
<div data-slot="card-footer" ... />
우리가 가져온 파일 넷을 다 뒤지면 이런 이름표가 스물네 종류 나옵니다. avatar, avatar-image, avatar-fallback, dialog-trigger, dialog-content… 조각마다 하나씩 붙어 있어요.
이게 뭘까요. 클래스도 아니고 아이디도 아닌, 그냥 data-로 시작하는 우리끼리의 표시예요.
부모가 자식을 보고 자기를 바꿉니다
답은 card.tsx의 클래스 문자열 안에 있습니다.
has-data-[slot=card-footer]:pb-0
풀어 읽으면 이렇습니다. "내 안에 data-slot="card-footer"를 단 자식이 있으면, 내 아래쪽 여백을 0으로 한다."
왜 이런 게 필요할까요. 카드에 꼬리 구역이 있을 때와 없을 때 여백이 달라야 하기 때문입니다.
꼬리가 없는 카드 꼬리가 있는 카드
┌──────────────┐ ┌──────────────┐
│ (위 여백) │ │ (위 여백) │
│ 내용 │ │ 내용 │
│ (아래 여백) │ ├──────────────┤
└──────────────┘ │ 꼬리 │ ← 여백 없이 딱 붙는다
└──────────────┘
꼬리는 배경색이 따로 있어서 카드 바닥까지 꽉 차야 예뻐요. 그런데 카드는 자기 자식이 뭘 넣을지 미리 알 수 없죠.
옛날에는 이걸 쓰는 쪽이 챙겼습니다. "꼬리를 넣을 거면 카드에 pb-0도 같이 붙이세요" 하는 식이었는데, 까먹으면 어긋나고요.
지금은 카드가 스스로 봅니다. 자식에 이름표가 붙어 있으니 그걸 조건으로 삼을 수 있어요.
이름표는 바깥에서 겨냥할 때도 씁니다. 여러 아바타를 겹쳐 놓는 곳은 이렇게 되어 있어요.
*:data-[slot=avatar]:ring-2
"내 직계 자식 중 아바타인 것들에게 테두리를 둘러라." 자식 컴포넌트를 고치지 않고 부모가 조정하는 겁니다.
없는 것도 읽을 줄 알아야 합니다
이번엔 반대로, 있어야 할 것 같은데 없는 것을 찾아봅시다.
가져온 파일 넷을 통틀어 forwardRef라는 단어가 한 번도 안 나옵니다.
이 이름이 낯설다면 A-4를 떠올려 보세요. 부모가 자식 안쪽의 DOM 요소를 직접 만지고 싶을 때, 예전 React에서는 컴포넌트를 이 함수로 한 번 감싸야 했습니다. 감싸지 않으면 ref가 자식에게 전달되지 않았거든요.
React 19부터는 그냥 props로 받습니다.
// A-4 에서 배운 방식 — 감싸는 것 없이 받는다
function TextField({ ref, ...props }: TextFieldProps) {
return <input ref={ref} {...props} />;
}
이 방식 덕분에 오늘 온 파일들에도 감싸는 코드가 없어요. 인터넷에서 shadcn 예제를 찾아보면 감싸는 코드가 있는 글이 아직 많습니다. 그건 오래된 글입니다. 지금 여러분 파일에는 없는 게 정상이에요.
이렇게 "없는 것"을 알아보는 것도 읽기의 일부입니다.
💡 한 줄 정리
data-slot은 조각마다 붙은 이름표입니다. 부모가 자식의 존재를 보고 자기 스타일을 정하거나, 바깥에서 특정 조각만 겨냥할 때 쓰이는데, React 19부터는 ref를 전달하려고 컴포넌트를 감쌀 필요가 없어서, 그 코드가 파일에 아예 없습니다.
🙋 학생 질문 — "튜터님, 이름표를 클래스로 붙이면 안 되나요? 클래스도 선택할 수 있잖아요."
됩니다. 실제로 예전 라이브러리들은 그렇게 했어요.
문제는 클래스가 생김새를 담는 자리라는 데 있습니다. 지금 우리 카드에는 클래스가 스무 개 넘게 붙어 있죠. 거기에 card-footer 같은 구조용 이름을 섞으면 두 가지가 한 통에 담깁니다.
그러면 이런 일이 생겨요. 디자인을 바꾸려고 클래스를 손보다가 구조용 이름을 지워버립니다. 지워도 화면은 멀쩡해 보이는데, 어느 날 부모의 여백만 이상해지다 보니 원인 찾기가 아주 어렵습니다.
data-slot으로 나눠두면 이 사고가 안 납니다. 클래스는 생김새, data-slot은 신분으로 자리가 갈리니까요. 클래스를 통째로 갈아엎어도 이름표는 남습니다.
덤으로 하나 더. 개발자 도구에서 요소를 클릭했을 때 data-slot="dialog-content"가 보이면 "아 이게 대화 상자 본체구나" 하고 바로 압니다. 클래스 스무 개를 읽는 것보다 훨씬 빠르죠.
Step 5: "피드를 갈아끼운다"
읽었으니 이제 씁니다. 피드 카드부터 갈아끼워 볼게요.
우리가 B-3에서 만든 Card는 이런 모양이었죠.
// apps/web-spa/src/components/Card.tsx
export function Card({ header, footer, children, className }: CardProps) {
return (
<article className={className}>
{header}
{children}
{footer}
</article>
);
}
머리와 꼬리를 props 로 받습니다. header={<PostHeader ... />}처럼요.
들여온 Card는 다르게 생겼습니다.
// apps/web-spa/src/components/ui/card.tsx
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
머리와 꼬리가 각각 별도 컴포넌트예요. props가 아니라 자식으로 넣습니다.
우리 방식 (자리를 props 로) 들여온 방식 (자리를 자식으로)
<Card <Card>
header={<PostHeader />} <CardHeader><PostHeader /></CardHeader>
footer={<CommentForm />} <CardContent>...</CardContent>
> <CardFooter><CommentForm /></CardFooter>
... </Card>
</Card>
둘 다 B-3에서 배운 합성입니다. 어느 쪽이 낫다기보다 성격이 달라요. props 방식은 자리가 고정돼서 실수할 여지가 적고, 자식 방식은 자리를 마음대로 늘리거나 뺄 수 있습니다.
들여온 쪽이 자식 방식을 고른 이유는 카드가 쓰이는 곳이 워낙 다양해서예요. 어떤 카드는 제목만, 어떤 카드는 설명과 버튼까지 필요하니까요.
카드를 바꿔 봅니다
// apps/web-spa/src/components/PostCard.tsx
import { Card, CardContent, CardFooter, CardHeader } from './ui/card';
export function PostCard({ id, username, profileImageUrl, imageUrl, ... }: PostCardViewProps) {
const [comments, dispatch] = useReducer(commentReducer, initialCommentState);
return (
<Card className="mb-6 2col:mb-0">
<CardHeader>
<PostHeader username={username} profileImageUrl={profileImageUrl} />
</CardHeader>
<PostImage imageUrl={imageUrl} username={username} onLike={() => onToggleLike(id)} />
<CardContent>
<PostBody ... />
<CommentList ... />
</CardContent>
<CardFooter>
<CommentForm onSubmit={(text) => dispatch({ type: 'add', content: text })} />
</CardFooter>
</Card>
);
}
className에 남긴 건 mb-6 2col:mb-0 둘뿐입니다. 카드 아래 여백은 두 열이 되는 순간 gap에게 넘긴다 — E-4에서 정한 그 규칙이죠. 나머지 생김새는 들여온 것에 맡깁니다.
사진을 CardContent 안에 안 넣고 밖에 둔 게 보이시나요. 인스타그램 사진은 카드 좌우에 딱 붙어야 하는데, CardContent는 좌우 여백을 갖고 있거든요. 형제로 두면 여백 없이 꽉 찹니다.
프로필도 바꿉니다
// apps/web-spa/src/components/PostHeader.tsx
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
export function PostHeader({ username, profileImageUrl }: PostHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Avatar>
<AvatarImage src={profileImageUrl} alt={`${username} 프로필 사진`} />
{/* 사진이 아직 안 왔거나 실패했을 때 이 자리에 남는 것 */}
<AvatarFallback>{username.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm font-semibold">{username}</span>
</div>
<IconButton ...>⋯</IconButton>
</div>
);
}
우리 Avatar에는 없던 게 하나 생겼습니다. AvatarFallback이요.
우리 것은 <img> 하나였어요. 사진 주소가 깨져 있으면 브라우저가 그리는 회색 깨진 아이콘이 그대로 노출됐습니다. 들여온 것은 사진이 실제로 도착했는지 확인하고, 도착하기 전이나 실패했을 때는 대체 글자를 보여줘요. 사용자 이름 첫 글자를 넣어두면 그게 뜹니다.
작은 차이 같지만 목록에 카드가 수십 장 뜨는 화면에서는 크게 다릅니다. 깨진 아이콘 수십 개와 동그란 이니셜 수십 개는 인상이 완전히 달라요.
⚠️ 화면은 멀쩡한데 구조가 바뀌었습니다
바꾸고 나서 화면을 보면 별문제가 없어 보이는데, 카드가 잘 그려지고 사진도 뜨죠.
그런데 개발자 도구로 카드 요소를 눌러 보면 이렇게 나옵니다.
우리가 짰을 때 <article class="...">
들여온 것으로 바꾼 뒤 <div data-slot="card" data-size="default" class="...">
article이 div로 바뀌었어요.
이게 왜 문제냐면, article은 그냥 상자가 아니라 "여기부터 여기까지가 하나의 완결된 글"이라는 뜻이기 때문입니다. 화면 낭독기를 쓰는 사람은 이 표시 덕분에 게시물 단위로 건너뛸 수 있는데, div는 아무 뜻도 없는 상자라 그게 안 됩니다.
색이나 모서리가 바뀐 건 눈에 보이니 금방 알아채요. 뜻이 사라진 건 눈에 안 보입니다.
여기서 소유의 값을 합니다
Step 1에서 복사 방식은 파일이 내 것이라 마음에 안 들면 열어서 고칠 수 있다고 이야기했는데, 그 말을 지금 씁니다.
// apps/web-spa/src/components/ui/card.tsx
// 우리가 고친 곳: div → article.
// 이 프로젝트에서 카드는 전부 "게시물 한 장" 이라 문서 구조상 하나의 글이다.
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"article"> & { size?: "default" | "sm" }) {
return (
<article
data-slot="card"
...
두 줄 고쳤습니다. 태그 하나와 타입 하나요.
만약 패키지 방식이었다면 이 두 줄을 못 고칩니다. node_modules 안의 파일이니까요. 라이브러리가 as 같은 구멍을 열어줬기를 바라거나, 열어주지 않았으면 포기해야 해요.
⚠️ 대신 이 수정은 날아갈 수 있습니다
값을 치렀으니 비용도 봐야죠. 나중에 카드를 다시 가져오면 우리가 고친 게 덮입니다.
다행히 조용히 사라지지는 않고, 먼저 물어봅니다.
npx shadcn@latest add card --dry-run
├ Files (1) ~1 overwrite
│ ~ src/components/ui/card.tsx overwrite
│
⚠ 1 file will be overwritten.
무엇이 덮이는지 궁금하면 차이까지 보여줍니다.
npx shadcn@latest add card --diff
│ │ -// 우리가 고친 곳: div → article.
│ │ function Card({
│ │ -}: React.ComponentProps<"article"> & { size?: "default" | "sm" }) {
│ │ +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
│ │ - <article
│ │ + <div
우리가 고친 자리가 그대로 나옵니다. 이걸 보고 다시 덮을지 말지 정하면 돼요.
그래서 실무에서는 고친 자리에 주석을 남깁니다. 위 코드에서 // 우리가 고친 곳이라고 적어둔 게 그것이에요. 몇 달 뒤에 이 파일을 다시 가져올 사람이 그 주석 덕분에 멈출 수 있습니다.
💡 한 줄 정리
들여온 카드는 자리를 자식으로 받고, 아바타는 사진이 실패했을 때 대체 글자를 보여줍니다. 다만 카드가 div로 바뀌면서 문서 구조상의 뜻이 사라졌고, 파일이 우리 것이라 두 줄을 고쳐 되돌렸습니다.
🙋 학생 질문 — "튜터님, 고쳐 놓으면 라이브러리가 버그를 고쳐도 못 받는 것 아닌가요?"
맞습니다. 그게 이 방식의 진짜 비용이에요.
다만 생각보다 덜 아픈 이유가 몇 가지 있습니다.
첫째, 이 파일들은 자주 안 바뀝니다. 카드나 아바타 같은 건 완성도가 높아서 몇 달에 한 번 손이 갈까 말까예요.
둘째, 진짜 어려운 동작은 파일 안에 없습니다. 포커스를 가두고 키보드를 처리하는 코드는 @base-ui/react 안에 있고, 그건 npm update로 따라옵니다. 우리 파일에는 생김새와 조립만 있어요.
셋째, 방금 본 것처럼 덮기 전에 차이를 보여줍니다. 우리 수정을 새 버전 위에 다시 얹으면 돼요.
그래도 원칙은 하나 지키세요. 고칠 때는 최소한만 고치고, 고친 자리마다 주석을 남깁니다. 파일을 통째로 자기 스타일로 다시 쓰면 다음 갱신 때 정말로 힘들어집니다.
Step 6: "사진을 누르면 크게"
이제 오늘의 주인공입니다. 모달을 만들어요.
들여온 Dialog는 조각이 여러 개인데, 우리가 쓸 건 넷입니다.
Dialog 전체를 감싸며 열림·닫힘 상태를 갖는다
DialogTrigger 누르면 열리는 것
DialogContent 실제로 뜨는 상자 (뒤 배경까지 함께 그린다)
DialogTitle 상자의 제목
DialogDescription 상자의 설명
우리가 상태를 만들 필요가 없어요. useState로 open을 들고 있지 않아도 Dialog가 알아서 합니다.
// apps/web-spa/src/components/PostModal.tsx
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from './ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
export function PostModal({ username, profileImageUrl, imageUrl, content, likeCount, commentCount }: PostModalProps) {
return (
<Dialog>
<DialogTrigger className="cursor-pointer px-3 pt-1 pb-3 text-left text-sm text-faint ...">
댓글 {commentCount}개 모두 보기
</DialogTrigger>
<DialogContent className="max-w-[calc(100%-2rem)] gap-3 sm:max-w-lg">
<DialogTitle className="flex items-center gap-2.5">
<Avatar size="sm">
<AvatarImage src={profileImageUrl} alt={`${username} 프로필 사진`} />
<AvatarFallback>{username.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
{username}
</DialogTitle>
<img className="w-full rounded-lg" src={imageUrl} alt={`${username} 의 게시물`} />
<DialogDescription>{content}</DialogDescription>
<p className="text-sm text-faint">
좋아요 {likeCount}개 · 댓글 {commentCount}개
</p>
</DialogContent>
</Dialog>
);
}
Avatar에 size="sm"을 줬죠. Step 2에서 본 그 속성입니다. 이름만 골랐고 클래스는 한 글자도 안 적었어요.
여는 자리를 옮겼습니다
지난 시간 첫머리에서 저는 "사진을 누르면 크게 뜨는 그거"라고 말했는데, 막상 만들려니 문제가 있었어요.
우리 사진에는 이미 두 번 누르면 좋아요가 붙어 있습니다. A-4에서 붙였는데, 브라우저에서 두 번 누르기는 한 번 누르기가 두 번 일어난 다음에 일어나서 순서가 이렇게 됩니다.
사용자가 사진을 빠르게 두 번 누름
│
├─ 한 번 누르기 발생 → 모달이 열린다
├─ 한 번 누르기 발생 → (이미 열려 있음)
└─ 두 번 누르기 발생 → 좋아요를 누르려는데 화면이 이미 덮여 있다
좋아요를 누르려던 사람 앞에 모달이 뜹니다. 둘을 같은 자리에 놓으면 서로를 방해하다 보니, 여는 자리를 "댓글 N개 모두 보기"로 옮겼는데, 실제 인스타그램도 이렇게 동작해요. 피드에서 사진을 누르면 아무 일도 안 일어나고, 댓글 줄을 눌러야 게시물이 열립니다.
만들다 보면 이런 일을 자주 겪는데, 계획이 코드와 부딪히면 계획을 고치는 게 맞아요.
화면에 붙입니다
파일만 만들어 두면 아무 데도 안 나옵니다. 여는 버튼이 PostModal 안에 이미 들어 있으니, 댓글 줄을 이걸로 바꾸기만 하면 돼요.
댓글 줄을 그리던 PostBody에는 지금까지 이렇게 적혀 있었어요.
// apps/web-spa/src/components/PostBody.tsx
<p className="px-3 pt-1 pb-3 text-sm text-faint">댓글 {commentCount}개 모두 보기</p>
글자만 있고 아무 일도 안 하던 줄이라, 지우고 PostModal을 놓습니다.
// apps/web-spa/src/components/PostBody.tsx
import { PostModal } from './PostModal';
<PostModal
username={username}
profileImageUrl={profileImageUrl}
imageUrl={imageUrl}
content={content}
likeCount={likeCount}
commentCount={commentCount}
/>
여기서 PostBody가 지금 안 갖고 있는 게 둘 있습니다. 모달은 사진과 프로필 사진을 크게 보여줘야 하는데, 본문 구역은 그동안 그 둘이 필요 없었거든요. props에 더해 줍니다.
interface PostBodyProps {
username: string;
profileImageUrl: string;
imageUrl: string;
content: string;
liked: boolean;
likeCount: number;
commentCount: number;
onToggle: () => void;
}
그다음은 PostCard에서 그 둘을 PostBody로 내려주기만 하면 됩니다. PostCard는 이미 갖고 있으니까요.
닫기 버튼은 안 만들었는데 있습니다
화면을 열어보면 오른쪽 위에 X 버튼이 있는데, 우리는 안 만들었어요. DialogContent가 기본으로 넣어줍니다.
배경도 마찬가지예요. 뒤쪽이 어둡게 깔리고 살짝 흐려지는데, 그것도 DialogContent 안에 들어 있습니다.
이런 게 몇 개나 딸려왔는지는 다음 Step에서 제대로 세어볼게요.
💡 한 줄 정리
Dialog는 여는 버튼과 뜨는 상자를 감싸기만 하면 열림 상태를 알아서 관리하지만, 여는 자리를 정할 때는 그 요소에 이미 붙어 있는 동작과 부딪히지 않는지 봐야 해요.
🙋 학생 질문 — "튜터님, DialogTrigger는 왜 버튼을 대신 만들어 주나요? 제가 만든 버튼을 쓰고 싶으면요?"
기본으로는 <button>을 만들어 줍니다. 대부분 그게 맞으니까요.
내가 만든 버튼을 쓰고 싶으면 넘겨줄 수 있어요. dialog.tsx 안에 그 방식이 이미 쓰이고 있습니다.
<DialogPrimitive.Close
render={<Button variant="ghost" size="icon-sm" />}
>
render에 넘긴 요소가 대신 그려지고, 열고 닫는 동작은 그 위에 얹힙니다. 겉모습은 내 버튼, 동작은 라이브러리 것이 되는 거죠.
이 방식에는 이름이 있는데 라이브러리마다 다릅니다. 우리가 쓰는 Base UI는 render라고 부르고, 예전부터 널리 쓰이던 Radix는 asChild라는 이름을 씁니다. 인터넷 예제에서 asChild를 보면 "아 그쪽 라이브러리 예제구나" 하고 알아보시면 돼요. 하는 일은 같습니다.
Step 7: "여섯 줄을 세어본다"
지난 시간 첫머리의 그 목록입니다. 모달 하나 제대로 만들려면 챙길 것들이요.
모달 하나를 제대로 만들려면
포커스를 상자 안에 가둔다
Esc 를 누르면 닫는다
닫으면 원래 있던 곳으로 포커스를 되돌린다
뒤쪽 배경은 스크롤을 잠근다
화면 낭독기에 "지금 대화 상자가 열렸다" 고 알린다
상자 바깥은 낭독기에서 숨긴다
우리는 이 중 한 줄도 안 짰습니다. 몇 개가 되고 있는지 하나씩 확인해 봅시다. 개발자 도구를 열어두고 모달을 띄운 채로 보면 됩니다.
하나 — 포커스가 상자 안에 갇히나
모달을 연 뒤에 Tab을 계속 눌러 보세요. 뒤쪽 회원가입 폼이나 테마 버튼으로 넘어가지 않고 상자 안에서만 돕니다. 지금 우리 상자 안에 누를 수 있는 건 닫기 버튼 하나뿐이라, 몇 번을 눌러도 거기 머물러요.
딸려옵니다.
둘 — Esc로 닫히나
눌러보면 닫힙니다. 딸려옵니다.
셋 — 닫으면 포커스가 돌아오나
이건 눈으로 보기 어려우니 개발자 도구 콘솔에서 확인해 보세요. 모달을 닫은 직후에 document.activeElement를 찍으면 우리가 눌렀던 "댓글 N개 모두 보기" 버튼이 나옵니다.
키보드로 쓰는 사람에게 이건 아주 중요해요. 안 돌아오면 페이지 맨 처음부터 다시 Tab을 눌러야 하거든요.
딸려옵니다.
넷 — 뒤쪽 스크롤이 잠기나
모달을 띄운 채로 마우스 휠을 굴려 보세요. 뒤쪽 피드가 안 움직입니다. body의 계산된 스타일을 보면 overflow가 hidden으로 바뀌어 있고, 닫으면 원래대로 돌아옵니다.
딸려옵니다.
다섯 — 낭독기에 대화 상자라고 알리나
상자 요소를 개발자 도구에서 보면 이렇게 되어 있습니다.
role="dialog"
aria-labelledby="…" ← DialogTitle 이 연결돼 있다
aria-describedby="…" ← DialogDescription 이 연결돼 있다
여는 버튼 쪽에도 붙어 있어요.
aria-haspopup="dialog" ← 누르면 대화 상자가 열린다고 미리 알린다
aria-expanded="false" ← 열리면 true 로 바뀐다
딸려옵니다.
⚠️ 다만 제가 지난 시간에 적은 이름은 안 나옵니다
지난 시간 목록에 저는 aria-modal이라고 적어뒀습니다. 그런데 상자를 아무리 뒤져도 그 속성이 없어요.
빠뜨린 게 아니라 다른 방법을 쓰고 있는 건데, 그게 여섯 번째 줄이에요.
여섯 — 상자 바깥이 낭독기에서 숨겨지나
모달이 열린 상태에서 우리 앱 전체를 감싸는 요소를 보세요.
<div id="root" aria-hidden="true">
앱 전체에 "낭독기는 여기 보지 마세요" 표시가 붙습니다. 닫으면 사라지고요.
aria-modal="true"는 "이 상자가 모달이니 바깥은 알아서 무시해줘"라고 부탁하는 방식이고, 지금 이 방식은 바깥을 직접 숨기는 방식입니다. 목표는 같은데 부탁 대신 직접 하는 쪽이에요. 낭독기마다 해석이 조금씩 달라서, 직접 숨기는 쪽이 더 확실하게 동작합니다.
딸려옵니다.
세어보면
여섯 줄 중 딸려온 것 ── 여섯 개
우리가 짠 줄 ── 없음
전부입니다. 우리가 쓴 코드는 <Dialog>로 감싸고 <DialogTrigger>와 <DialogContent>를 넣은 게 전부였어요.
⚠️ 그런데 공짜가 아닌 게 하나 있습니다
DialogTitle을 빼고 열어보면 어떻게 될까요. 한번 지워 보세요.
화면은 멀쩡하고, 에러도 안 나고 경고도 없어요. 그런데 상자 요소를 보면 aria-labelledby가 아예 없습니다.
이름 없는 대화 상자가 됩니다. 낭독기가 "대화 상자" 라고만 읽고 무슨 상자인지 말해주지 못해요. 눈으로 보는 사람은 사진과 이름이 다 보이니까 아무 문제를 못 느낍니다.
제목은 딸려오지 않습니다. 우리가 줘야 해요. 그리고 안 줘도 아무도 안 알려줍니다.
⚠️ 그리고 닫기 버튼이 영어였습니다
딸려온 닫기 버튼을 낭독기로 들어보면 "Close"라고 읽습니다. dialog.tsx 안에 그렇게 적혀 있거든요.
우리 화면은 전부 한국어인데 이 버튼만 영어로 읽히는 겁니다. 눈으로 보면 X 아이콘이라 아무 이상이 없어요.
파일이 우리 것이니 고칩니다.
// apps/web-spa/src/components/ui/dialog.tsx
<XIcon />
{/* 우리가 고친 곳: 낭독기가 읽는 이름을 우리말로 */}
<span className="sr-only">닫기</span>
sr-only는 E-1에서 봤던 그 클래스로, 화면에는 안 보이고 낭독기만 읽어요.
이런 건 라이브러리를 탓할 일이 아닙니다. 세계 여러 나라에서 쓰는 라이브러리가 한국어를 미리 넣어둘 수는 없으니까요. 가져다 쓰는 쪽이 자기 언어로 바꾸는 것까지가 도입입니다.
직접 만들었다면
여섯 줄을 우리가 다 짰다면 어땠을지 잠깐 그려볼게요.
| 항목 | 직접 만들면 챙길 것 |
|---|---|
| 포커스 가두기 | 상자 안에서 누를 수 있는 요소를 전부 찾고, 마지막에서 Tab을 누르면 처음으로 돌려보내고, 처음에서 Shift+Tab이면 마지막으로 |
| Esc | 키 이벤트를 어디에 붙일지, 상자가 여러 개면 맨 위 것만 닫히게 |
| 포커스 복귀 | 열기 직전에 어디에 있었는지 기억해 두고, 닫힐 때 그 요소가 아직 화면에 있는지 확인한 뒤 되돌리기 |
| 스크롤 잠금 | overflow: hidden을 걸되 스크롤바 폭만큼 화면이 튀지 않게, 닫을 때 원래 값 복원 |
| 낭독기 알림 | 역할과 이름·설명 연결 |
| 바깥 숨김 | 형제 요소들에 표시를 걸고 닫을 때 정확히 되돌리기 |
하나하나는 어렵지 않습니다. 다만 전부 합치면 하루가 가고, 놓친 자리는 대개 키보드나 낭독기로 써보기 전까지 아무도 모릅니다. 그래서 이 영역은 가져다 쓰는 게 거의 항상 이득이에요.
💡 한 줄 정리
포커스 가두기·Esc·포커스 복귀·스크롤 잠금·역할 알림·바깥 숨김 여섯 가지가 전부 딸려옵니다. 다만 제목과 우리말 표기는 우리 몫이고, 빠뜨려도 아무도 알려주지 않아요.
🙋 학생 질문 — "튜터님, 그럼 접근성은 라이브러리에 맡기면 끝인가요?"
아니요. 오늘만 봐도 제목과 우리말 표기, 두 군데가 우리 몫이었죠.
라이브러리가 해주는 건 동작입니다. 포커스를 어떻게 옮기고 어떤 역할 이름을 붙일지 같은 것들이요. 이건 화면 내용과 무관하게 정해지니까 대신 해줄 수 있어요.
반대로 내용은 대신 못 하는데, 이 상자가 무슨 상자인지, 이 버튼이 무엇을 하는지, 이 사진이 무엇을 담고 있는지는 우리만 알아요. alt를 우리가 써야 하는 것과 같은 이유입니다.
그래서 실무에서는 두 가지를 함께 합니다. 동작은 검증된 라이브러리에 맡기고, 내용은 우리가 채우고 키보드만으로 한 번 써봅니다. 마우스를 치우고 Tab과 Enter와 Esc 만으로 화면을 끝까지 돌아보는 거예요. 5분이면 하는데 놓친 걸 꽤 잡습니다.
Step 8: "값을 매긴다"
다 만들었으니 이제 비용과 여파를 봅니다. 오늘 무엇이 늘었고 무엇이 어긋났는지요.
놀고 있던 줄이 일을 시작했습니다
지난 시간에 이 줄을 넣어두고 "지금은 아무 일도 안 한다" 고 했죠.
/* apps/web-spa/src/styles/globals.css */
@custom-variant dark (&:is(.dark *));
우리 화면에 dark:로 시작하는 클래스가 하나도 없었기 때문입니다. 우리는 값만 갈아끼우는 방식을 썼으니까요.
오늘 들여온 파일을 열어보면 이런 게 보입니다.
button.tsx dark:hover:bg-muted/50 · dark:border-input · dark:bg-input/30 … 9곳
avatar.tsx dark:after:mix-blend-lighten 1곳
남의 코드가 그 문법을 쓰고 들어왔어요. 우리는 여전히 한 곳도 안 쓰는데, 우리 화면에는 이제 그 클래스가 있습니다. 지난 시간에 깔아둔 줄이 없었다면 저 클래스들이 아무 일도 못 했을 거예요.
미리 깔아둔 길로 남이 지나간 셈입니다.
⚠️ 밝은 화면에선 같고 어두운 화면에서만 갈립니다
이제 오늘의 진짜 함정입니다.
들여온 카드는 자기 토큰인 bg-card를 쓰고, 우리 회원가입 판은 우리 토큰인 bg-surface를 씁니다. 두 판이 같은 화면에 나란히 있어요.
밝은 화면에서 두 배경색을 재보면 이렇습니다.
회원가입 판 oklch(1 0 0)
게시물 카드 oklch(1 0 0) ← 완전히 같다
둘 다 흰색이라 아무 문제가 없습니다.
어두운 화면으로 바꾸고 다시 재보세요.
회원가입 판 oklch(0.182 …) ← 우리가 E-4 에서 고른 값
게시물 카드 oklch(0.205 …) ← 들여온 쪽이 고른 값
갈립니다. 같은 화면에 놓인 두 판이 미묘하게 다른 회색이 돼요.
E-5에서 겪은 것과 같은 종류의 함정입니다. 그때는 자동 확인이 통과하는데 화면이 깨져 있었고, 오늘은 밝은 화면에서 멀쩡한데 어두운 화면에서만 어긋납니다. 한 조건에서만 확인하면 못 봅니다.
두 갈래 길
고치는 방법이 두 가지 있어요.
첫째, 카드마다 우리 토큰을 얹는 방법입니다. Step 3에서 배운 cn이 겹치는 걸 정리해주니 잘 됩니다.
<Card className="bg-surface"> {/* bg-card 가 지워지고 bg-surface 가 남는다 */}
되긴 하는데 문제가 있어요. 카드를 쓸 때마다 적어야 하고, 앞으로 들여올 상자·팝업마다 또 적어야 하는 데다가, Step 3에서 본 것처럼 ring과 border는 서로 못 미니까 테두리까지 맞추려면 ring-0도 따라붙어야 해요. 매번 적는 목록이 점점 길어집니다.
둘째, 값을 잇는 방법입니다. 들여온 쪽 토큰이 우리 토큰을 가리키게 하는 거예요.
/* apps/web-spa/src/styles/globals.css */
:root {
/* 우리가 고친 곳 — 값을 박지 않고 우리 이름을 가리키게 했다.
"판" 을 뜻하는 셋이 우리 surface 한 곳에서 갈린다. */
--card: var(--color-surface);
--card-foreground: var(--color-ink);
--popover: var(--color-surface);
--popover-foreground: var(--color-ink);
--border: var(--color-line);
}
원래 거기에는 oklch(1 0 0) 같은 값이 그대로 적혀 있었는데, 그걸 우리 이름을 가리키는 참조로 바꾸면 재미있는 일이 생깁니다. 들여온 쪽에도 어두운 화면용 블록이 따로 있었는데, 그 다섯 줄이 필요 없어집니다.
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* --card · --card-foreground · --popover · --popover-foreground · --border 는
여기 없다. 위에서 우리 이름을 가리키게 해뒀고 그 이름이 여기서 이미 갈린다. */
...
}
우리 --color-surface가 어두운 화면에서 이미 값을 바꾸니까, 그걸 가리키는 --card도 저절로 따라옵니다. 지워도 되는 거예요.
E-2에서 배운 그 이야기가 여기서 한 번 더 나옵니다. 값과 조건을 갈라 두면 조건은 한 곳에서만 관리하면 됩니다.
고치고 다시 재보면 이렇습니다.
회원가입 판 oklch(0.182 …)
게시물 카드 oklch(0.182 …) ← 같아졌다
카드 아래선 oklch(0.333 …) ← 우리 --color-line 을 쓴다
크기는 이렇게 늘었습니다
마지막으로 비용을 보려고, 지난 시간 끝과 오늘 끝을 나란히 놓아 볼게요.
| 시점 | CSS | JS |
|---|---|---|
지난 시간 끝 (ui/가 비어 있을 때) |
18,038 B | 306,516 B |
add 직후 (아직 아무 데서도 안 씀) |
39,919 B | 306,516 B |
| 오늘 끝 (다 쓰고 토큰까지 이은 뒤) | 39,870 B | 408,093 B |
가운데 줄이 이상하죠. 아무 데서도 안 쓰는데 CSS가 두 배 넘게 늘었지만, JS는 한 바이트도 안 늘었어요.
실제로 쓰기 시작한 마지막 줄에서는 반대가 되는데, CSS는 그대로인데 JS가 십만 바이트 늘었어요.
이 엇갈림의 이유는 E-1에서 배운 데 있습니다. Tailwind는 소스 파일의 글자를 훑어서 필요한 CSS를 만들죠. 파일이 src/ 안에 놓이는 순간, 아무도 부르지 않아도 그 안의 클래스 이름이 전부 후보가 됩니다.
JS는 반대로 부르는 관계를 따라갑니다. 아무도 import 하지 않은 파일은 빌드 결과에 안 들어가서, 실제로 써야 늘어납니다.
CSS 파일이 놓이는 순간 는다 (글자를 훑으니까)
JS 실제로 불러 써야 는다 (부르는 관계를 따라가니까)
CSS가 마지막에 49 바이트 줄어든 것은 방금 들여온 쪽 어두운 화면 블록에서 다섯 줄을 지웠기 때문이에요.
💡 한 줄 정리
들여온 컴포넌트는 자기 토큰과 자기 다크모드 문법을 갖고 들어옵니다. 우리 토큰과 어긋나는 자리는 매번 덮기보다 값을 이어 한 곳에서 끝내는 게 낫고, 비용은 CSS와 JS가 서로 다른 시점에 늘어납니다.
🙋 학생 질문 — "튜터님, 안 쓰는 파일 때문에 CSS가 느는 건 손해 아닌가요? 지울 방법이 있나요?"
있습니다. 지난 시간에 쓴 그 방법이에요.
@source not "../**/*.test.ts*";
Tailwind에게 "이 경로는 훑지 마라"라고 일러두는 겁니다. 우리도 화면에 안 나가는 파일을 이미 이렇게 빼뒀지만, components/ui/에는 이걸 쓰면 안 됩니다. 지금은 안 쓰지만 쓰려고 가져온 파일이니까요. 빼두면 실제로 화면에 넣는 순간 스타일이 통째로 사라지다 보니, 현실적인 답은 이렇습니다. 안 쓸 컴포넌트는 애초에 가져오지 않는 것이에요. add는 한 번에 전부 가져오는 명령도 있지만 그걸 쓰면 안 쓰는 컴포넌트 수십 개가 CSS를 부풀립니다. 필요할 때 하나씩 가져오세요.
그리고 숫자 자체는 너무 겁내지 않아도 됩니다. 39 KB라도 압축하면 8 KB 남짓이고, CSS는 한 번 받으면 캐시에 남거든요. JS가 십만 바이트 는 쪽이 훨씬 무겁습니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 남이 짠 파일을 열어서 읽고, 두 군데를 고치고, 우리 것과 나란히 세웠습니다.
지난 시간까지 components/ui/는 비어 있었죠. 오늘 그 폴더가 처음으로 일을 했습니다.
오늘 배운 핵심 세 가지
💡 하나 — 복사해 온 파일은 읽으라고 온 것입니다. cva가 변형을 이름으로 고르게 해주고, cn이 겹친 클래스를 정리하고, data-slot이 조각마다 신분을 달아 둡니다. 읽을 줄 알면 고칠 수 있고, 고칠 수 있어야 소유한 값을 해요.
💡 둘 — 눈에 보이는 것만 확인하면 놓칩니다. 카드가 div로 바뀐 것도, 닫기 버튼이 영어로 읽히는 것도, 제목이 빠져 이름 없는 상자가 된 것도 화면으로는 멀쩡했습니다. 색과 여백은 눈으로, 구조와 이름은 개발자 도구로 봐야 해요.
💡 셋 — 어려운 동작은 가져다 쓰는 게 거의 항상 이득입니다. 오늘 여섯 줄이 전부 딸려왔고 우리가 짠 줄은 없었습니다. 대신 내용은 우리 몫이에요. 라이브러리는 우리 화면이 무슨 화면인지 모릅니다.
시작할 때 남아 있던 것
지난 시간에 남겨둔 약속들이 어떻게 닫혔는지 봅시다.
| 약속했던 것 | 닫힌 곳 |
|---|---|
cn이 무슨 일을 하는지 |
Step 3 — 겹친 것을 지우되 덮을 수 있을 때만 |
처음 보게 될 것들 (변형 정의·클래스 충돌·data-slot) |
Step 2 · 3 · 4 |
| Step 1의 모달 목록 여섯 줄 | Step 7 — 여섯 개 전부, 다만 제목은 우리 몫 |
놀고 있던 @custom-variant |
Step 8 — 남의 코드가 그 문법을 쓰고 들어왔다 |
components/ui/ 폴더 규약 |
Step 1 — 이름이 셋이나 겹쳤고 폴더가 갈라줬다 |
다음 시간 예고
우리 인스타그램은 이제 기능이 다 있어서 좋아요도 되고 댓글도 달리고 모달도 뜨고 다크모드도 되지만, 아직 진짜 인스타그램처럼 보이지는 않습니다. 여백이 조금씩 어색하고, 아이콘 자리에 문자를 쓰고 있고, 좋아요를 눌러도 아무 반응이 없어요.
다음 시간에는 마감을 합니다. 카드가 자기 폭을 보고 스스로 배치를 바꾸는 방법을 배우고, 오늘 함께 딸려온 아이콘 꾸러미를 꺼내 쓰고, 누를 때 살짝 반응하는 움직임을 넣으면서, 오늘 두 군데서 배운 걸 화면 전체에 적용해요. 눈으로 안 보이는 것을 개발자 도구로 훑는 일을요.
과제
[구현] 버튼도 갈아끼우고 충돌을 직접 만들어보기
오늘 button.tsx를 읽기만 하고 안 썼습니다. 실제로 써보면서 Step 2·3에서 본 것을 확인해 보세요.
- 회원가입 폼의 가입 버튼을 들여온
Button으로 바꿔 보세요. 지금 붙어 있는 클래스를 지우고variant와size를 골라서요. - 그 상태에서
className으로 좌우 여백을 넘겨 보세요. 개발자 도구에서 최종 클래스 문자열을 보고, 안쪽 기본값이 사라졌는지 확인해 주세요. - 이번엔
cn을 거치지 않으면 어떻게 되는지 보고 싶습니다.button.tsx에서cn(...)을 잠깐 벗겨내고 클래스 문자열이 어떻게 달라지는지, 화면의 여백은 어느 쪽을 따르는지 재 보세요. 확인한 뒤에는 되돌립니다. variant에 없는 이름을 적어보고 편집기가 언제 알려주는지도 확인해 주세요.
[구현] 이름 없는 대화 상자 만들어보기
Step 7에서 제목이 딸려오지 않는다고 했죠. 직접 겪어보면 오래 남습니다.
- 게시물 머리의
⋯버튼에 대화 상자를 하나 새로 붙여 보세요. 안에는 "이 게시물 신고하기" 같은 항목 두어 개면 됩니다. - 먼저
DialogTitle없이 만들고 열어 보세요. 개발자 도구에서 상자 요소의 속성을 확인하고, 무엇이 없는지 적어 주세요. - 그다음 제목을 넣고 다시 확인해 주세요. 무엇이 생겼나요.
- 제목을 화면에 보이고 싶지 않다면 어떻게 해야 할까요. 오늘 닫기 버튼을 고칠 때 쓴 클래스가 힌트입니다.
- 마지막으로 마우스를 치우고 키보드만으로 열고 닫아 보세요. Tab · Enter · Esc만 씁니다. 이상한 자리가 있었다면 적어 주세요.
[탐구] 덮어쓰기와 값 잇기
각 실험 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
npx shadcn@latest add avatar --diff를 실행해 보세요. 우리가 안 고친 파일입니다. 결과가 어떻게 나오는지 적고, CLI가 무엇을 기준으로 판단하는지 말해 보세요.card.tsx에 아무 주석이나 한 줄 넣고 다시--diff를 보세요. 우리 수정이 어떻게 보이는지 확인해 주세요.- Step 8에서 이어둔
--card를 원래 값oklch(1 0 0)으로 되돌리고, 들여온 쪽 어두운 화면 블록에도--card를 다시 적어 보세요. 두 판의 색을 다시 재서 어긋나는 걸 확인하고 되돌립니다. --input은 아직 안 이었습니다. 이 토큰이 우리 화면 어디에 쓰이고 있는지 찾아보고, 이어야 할지 그대로 둬도 될지 판단해 주세요.
생각해볼 주제
1. 남의 파일을 어디까지 고쳐도 될까요
오늘 우리는 들여온 파일 두 곳을 고쳤습니다. 태그 하나와 글자 하나요. 둘 다 이유가 분명했지만, 고치기 시작하면 끝이 잘 안 보입니다. 여백이 마음에 안 들고, 변수 이름이 우리 규칙과 다르고, 안 쓰는 변형이 여섯 개나 있고요. 하나씩 손대다 보면 어느새 원본과 완전히 다른 파일이 됩니다.
어디까지가 "고치는 것"이고 어디부터가 "다시 쓰는 것"인지, 그 선을 팀에서 어떻게 합의하고 남겨둘 수 있을지도 함께 생각해 보세요.
2. 눈에 안 보이는 것을 어떻게 지킬까요
오늘 카드가 div로 바뀐 것을 화면으로는 알 수 없었습니다. 닫기 버튼이 영어로 읽히는 것도, 제목이 없어 이름 없는 상자가 된 것도 그랬어요.
이런 것들은 대개 시간이 한참 지난 뒤에 발견됩니다. 발견되는 계기도 대개 누군가 불편을 겪은 다음이고요.
미리 알아채려면 무엇을 해야 할까요. 사람이 주기적으로 훑는 방법도 있고, 자동으로 검사하는 도구를 붙이는 방법도 있습니다. 각각 무엇을 잡을 수 있고 무엇을 못 잡을지, 그리고 우리 규모에서는 어디까지가 적당할지 생각해 보세요.
3. 딸려오는 것에 기대는 것은 어디까지 안전할까요
오늘 우리는 여섯 가지를 공짜로 얻었습니다. 우리가 짠 줄은 하나도 없었어요.
편했지만 한 가지 사실도 함께 생겼는데, 이제 우리 모달의 접근성은 우리가 이해하지 못한 코드가 책임지고 있어요. 그 라이브러리가 방향을 바꾸거나 관리가 멈추면 우리도 함께 멈춥니다.
이런 의존을 판단하는 기준은 무엇일지, 그리고 만약 그 라이브러리를 걷어내야 하는 날이 온다면 오늘의 선택 중 무엇이 그때 우리를 도와주고 무엇이 발목을 잡을지 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 먼저 스스로 해보신 뒤에 펼쳐서 견줘 보세요.
과제 예시답안
🎯 [과제 1 예시답안] 버튼도 갈아끼우고 충돌을 직접 만들어보기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 갈아끼우기 | 손으로 적던 클래스를 지우고 variant·size 이름으로 고름 |
| 제출 동작 유지 | type="submit"을 넘겼는가 (안 넘기면 폼이 제출되지 않는다) |
| 충돌 확인 | 최종 클래스 문자열에서 안쪽 기본값이 사라진 것을 눈으로 확인 |
| ⚠️ hover 함정 | 배경색만 바꾸면 마우스를 올렸을 때 원래 색으로 돌아가는 것을 발견 |
cn 벗기기 |
벗긴 뒤 클래스 개수가 늘고 어느 쪽이 이길지 알 수 없어지는 것을 확인 |
| 타입 확인 | 없는 variant 이름을 적었을 때 편집기가 그 자리에서 잡아주는 것을 확인 |
풀이 예시
먼저 갈아끼웁니다. 원래 이랬죠.
// apps/web-spa/src/components/SignUpForm.tsx (바꾸기 전)
<Button
className="cursor-pointer rounded-lg bg-brand p-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
type="submit"
disabled={isSubmitting}
>
들여온 Button으로 바꾸면 이렇게 짧아집니다.
// apps/web-spa/src/components/SignUpForm.tsx (바꾼 뒤)
import { Button } from './ui/button';
<Button
size="lg"
className="w-full cursor-pointer bg-brand text-white hover:bg-brand/80"
type="submit"
disabled={isSubmitting}
>
포커스 링·모서리·글자 크기·비활성 처리가 전부 안쪽에 이미 있어서 지웠고, 남긴 건 폭과 브랜드색뿐이에요.
type="submit"은 반드시 넘겨야 합니다. 안 넘기면 type="button"이 붙어서 폼이 제출되지 않아요. 이건 실수하기 쉬운데 화면으로는 티가 안 나고, 가입 버튼을 눌렀는데 아무 일도 안 일어나는 걸로 나타납니다.
충돌은 이렇게 확인합니다
개발자 도구에서 버튼 요소의 class를 보면 이렇게 되어 있습니다.
… disabled:opacity-50 … hover:bg-primary/80 h-9 gap-1.5 px-2.5 … w-full cursor-pointer bg-brand text-white
찾아보면 이렇습니다.
| 클래스 | 남았나 | 왜 |
|---|---|---|
bg-primary |
사라짐 | 우리가 넘긴 bg-brand와 같은 갈래 |
text-primary-foreground |
사라짐 | 우리가 넘긴 text-white와 같은 갈래 |
h-8 (기본 크기) |
사라짐 | size="lg"가 h-9로 바꿈 |
hover:bg-primary/80 |
남음 | ⚠️ |
⚠️ 자주 나오는 실수 — hover만 원래 색으로 돌아갑니다
배경색만 바꾸고 끝내면 버튼이 이상하게 동작합니다. 평소엔 파란 브랜드색인데 마우스를 올리면 어두운 회색으로 바뀌어요.
cn이 못 지운 게 아니라 지울 이유가 없어서 안 지운 겁니다. bg-brand는 평소 상태고 hover:bg-primary/80은 마우스를 올렸을 때라, 서로 다른 조건이라 겹치지 않습니다.
E-3에서 배운 그 이야기예요. hover:가 붙은 것과 안 붙은 것은 애초에 다른 규칙이다 보니, hover도 함께 넘겨야 합니다.
className="w-full cursor-pointer bg-brand text-white hover:bg-brand/80"
이건 hover: 끼리 같은 갈래라서, cn이 정리해 줍니다.
cn('hover:bg-primary/80', 'hover:bg-brand/80') → "hover:bg-brand/80"
상태별로 색을 정한 컴포넌트를 덮을 때는 상태마다 덮어야 한다 — 이게 이 과제의 핵심입니다.
cn을 벗겨보면
button.tsx에서 잠깐 이렇게 바꿔 봅니다.
// 확인용 — 확인이 끝나면 되돌립니다
className={buttonVariants({ variant, size, className })}
클래스 개수가 39 개에서 42 개로 늘어나는데, 늘어난 셋은 bg-primary · text-primary-foreground · hover:bg-primary/80이고 우리가 넘긴 것과 나란히 살아남습니다.
이 상태에서 어느 색이 나올지는 우리가 정할 수 없습니다. 두 규칙 중 CSS 파일에 나중에 나온 것이 이기는데, 그 순서는 Tailwind가 정하니까요. 확인이 끝나면 꼭 되돌리세요.
타입은 이렇게 잡힙니다
<Button variant="outlien"> {/* 오타 */}
편집기가 그 자리에서 빨간 줄을 긋습니다. variant에 올 수 있는 값이 여섯 개로 정해져 있고, 그 목록이 cva에 적은 variants에서 자동으로 만들어졌기 때문이에요.
💡 튜터의 한마디
이 과제에서 제일 값진 건 hover 함정입니다. 배경색을 바꿨는데 마우스를 올리면 원래대로 돌아가는 이 현상은 실무에서 정말 자주 만나요. 그리고 대부분은 배포한 다음에야 발견되는데, 개발할 때는 마우스를 계속 움직이지 않으니까요.
컴포넌트를 덮어쓸 때는 상태를 한 바퀴 돌려보는 습관을 들이세요. 평소·마우스 올림·눌림·포커스·비활성 다섯 가지요. 5초면 합니다.
🎯 [과제 2 예시답안] 이름 없는 대화 상자 만들어보기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 상자 붙이기 | ⋯ 버튼을 DialogTrigger로 감싸거나 render로 넘김 |
| 제목 없을 때 | aria-labelledby가 아예 없는 것을 개발자 도구로 확인 |
| 경고가 없다 | 콘솔에 아무 경고도 안 뜨는 것을 확인 |
| 제목 넣은 뒤 | aria-labelledby가 생기고 제목 요소의 id를 가리키는 것을 확인 |
| 안 보이는 제목 | sr-only로 화면에서만 감추는 방법을 찾음 |
| 키보드 확인 | Tab·Enter·Esc 만으로 열고 닫아 봄 |
풀이 예시
⋯ 버튼에 상자를 붙이는데, DialogTrigger가 버튼을 만들어 주니 원래 붙어 있던 클래스와 aria-label을 그대로 옮기면 생김새가 유지돼요.
// apps/web-spa/src/components/PostHeader.tsx
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from './ui/dialog';
<Dialog>
<DialogTrigger
className="cursor-pointer p-3 text-lg leading-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="게시물 메뉴"
>
⋯
</DialogTrigger>
<DialogContent className="sm:max-w-xs">
<DialogTitle className="sr-only">게시물 메뉴</DialogTitle>
<button className="cursor-pointer py-2 text-left text-sm text-danger">신고하기</button>
<button className="cursor-pointer py-2 text-left text-sm">링크 복사</button>
</DialogContent>
</Dialog>
제목 없이 열어보면
먼저 DialogTitle 줄을 지우고 열어 보세요. 개발자 도구에서 상자 요소를 보면 이렇습니다.
제목이 있을 때 role="dialog" aria-labelledby="…" aria-describedby="…"
제목이 없을 때 role="dialog"
aria-labelledby가 아예 안 생기고, 콘솔에는 경고 한 줄도 안 뜹니다.
낭독기로 들으면 "대화 상자" 라고만 읽고 무슨 상자인지 말해주지 못해요. 눈으로 보는 사람은 "신고하기"와 "링크 복사"가 다 보이니까 아무 문제를 못 느끼다 보니, 이 실수는 잘 안 잡힙니다.
aria-describedby도 마찬가지라서, DialogDescription을 안 넣으면 안 생겨요. 이 상자에는 설명할 게 딱히 없으니 안 넣어도 됩니다. 제목은 다르죠 — 이름 없는 상자는 곤란합니다.
제목을 화면에 안 보이게 하려면
이 메뉴 상자에 "게시물 메뉴"라는 제목을 큼직하게 띄우면 어색한데, 그렇다고 빼면 이름이 없어지고요.
교안에서 닫기 버튼을 고칠 때 쓴 그 클래스를 씁니다.
<DialogTitle className="sr-only">게시물 메뉴</DialogTitle>
sr-only는 화면에서만 감추고 낭독기에는 남깁니다. display: none과는 완전히 다른데, 그걸 쓰면 낭독기도 못 읽어요.
감추는 법 화면 낭독기
display: none 안 보임 못 읽음 ← 이름이 사라진다
sr-only 안 보임 읽음 ← 이럴 때 쓴다
키보드로 돌려보면
마우스를 치우고 Tab으로 ⋯까지 간 다음 Enter를 눌러 보세요.
- 상자가 열리고 포커스가 상자 안으로 들어갑니다
- Tab을 계속 눌러도 상자 안의 버튼들 사이에서만 돕니다
- Esc를 누르면 닫히고, 포커스가 다시
⋯버튼으로 돌아옵니다
돌아오는 게 중요한데, 안 돌아오면 그 게시물로 다시 가려고 Tab을 수십 번 눌러야 해요.
💡 튜터의 한마디
sr-only를 처음 만나면 "안 보이는데 왜 쓰지" 싶은데, 화면을 보는 사람과 듣는 사람이 필요로 하는 정보의 양이 다르다고 생각하면 이해가 쉬워요.
보는 사람은 상자가 어디 붙어 있는지, 위에 뭐가 있는지 한눈에 알지만, 듣는 사람은 그 맥락이 없어요. 그래서 보는 사람에게는 군더더기인 이름이 듣는 사람에게는 유일한 단서가 됩니다.
아이콘만 있는 버튼에 aria-label을 붙이는 것도 정확히 같은 이유예요. 우리 ⋯ 버튼에 이미 붙어 있죠.
🎯 [과제 3 예시답안] 덮어쓰기와 값 잇기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 안 고친 파일 | No changes.가 나오고, 파일 내용을 견주는 것임을 설명 |
| 고친 파일 | 주석 한 줄만 넣어도 차이로 잡히는 것을 확인 |
| 토큰 되돌리기 | 두 판의 색이 다시 어긋나는 것을 어두운 화면에서 확인 |
--input 판단 |
이름과 달리 우리 입력칸과 무관하다는 것을 근거를 들어 설명 |
풀이 예시
첫째 — 안 고친 파일의 차이
npx shadcn@latest add avatar --diff
├ src/components/ui/avatar.tsx (skip)
│ No changes.
차이가 없다고 나오는데, CLI가 지금 우리 파일의 내용과 받아올 내용을 견주기 때문이고 우리가 avatar는 안 건드렸으니 같습니다.
즉 이 명령은 "언제 가져왔는지"를 보는 게 아니라 "지금 무엇이 다른지"를 보다 보니, 우리가 고친 파일만 걸립니다.
둘째 — 주석 한 줄만 넣어도
card.tsx 아무 데나 주석을 한 줄 넣고 다시 돌리면 그 줄이 차이로 잡힙니다. 교안에서 본 것처럼 우리가 고친 태그와 타입도 함께 나오죠.
여기서 알 수 있는 게 하나 있는데, CLI는 우리 의도를 모른다는 것입니다. 의미 있는 수정이든 오타든 똑같이 "다르다"고만 알려주다 보니, 고친 자리에 주석으로 이유를 남기는 게 중요합니다. 몇 달 뒤에 이 차이를 보는 사람이 판단할 근거가 그것뿐이거든요.
셋째 — 토큰을 되돌리면
:root {
--card: oklch(1 0 0); /* 되돌림 */
}
.dark {
--card: oklch(0.205 0 0); /* 다시 적음 */
}
밝은 화면에서는 회원가입 판도 카드도 흰색이라 아무 차이가 없습니다.
어두운 화면으로 바꾸면 갈립니다.
회원가입 판 oklch(0.182 …)
게시물 카드 oklch(0.205 …)
한쪽 조건에서만 보면 못 잡는다는 걸 몸으로 확인하게 됩니다.
넷째 — --input은 이어야 할까
이름만 보면 입력칸 같지만 아닌데, 우리 화면에서 이 토큰을 쓰는 곳을 찾아보면 button.tsx 한 군데예요.
dark:border-input outline 변형 버튼의 테두리
dark:bg-input/30 outline 변형 버튼의 배경
dark:hover:bg-input/50 그 버튼에 마우스를 올렸을 때
셋 다 dark:가 붙어 있고, 셋 다 outline 변형 버튼에서만 쓰입니다.
우리 입력칸인 TextField는 우리 토큰을 쓰고 있어요.
// apps/web-spa/src/components/TextField.tsx
className="rounded-md border border-line p-2 text-sm …"
우리는 지금 outline 변형 버튼을 한 곳도 안 쓰다 보니, 지금은 이을 필요가 없습니다.
다만 조건을 붙여 답하는 게 정확합니다. 나중에 variant="outline" 버튼을 쓰기 시작하면 어두운 화면에서 그 테두리만 우리 --color-line과 다른 회색이 됩니다. 그때 이으면 돼요.
💡 튜터의 한마디
--input 문제의 핵심은 이름을 믿지 않는 것입니다. 이름이 input이라고 입력칸에 쓰이는 게 아니에요. 어디에 실제로 쓰이는지 찾아본 사람만 정확히 답할 수 있는데, "지금은 필요 없다"로 끝내지 않고 "언제부터 필요해진다"까지 적는 게 좋은 답입니다. 실무에서 기술 판단을 적을 때도 그래요. 결론만 적으면 나중에 상황이 바뀌었을 때 그 결론이 왜 나왔는지 아무도 모릅니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 남의 파일을 어디까지 고쳐도 될까요
문제 상황 요약
복사해 온 파일은 내 것이라 자유롭게 고칠 수 있지만, 자유롭기 때문에 선이 없어요. 두 줄 고치는 것과 통째로 다시 쓰는 것 사이 어디쯤에서 멈춰야 할지 정해두지 않으면, 몇 달 뒤에 원본과 아무 관계없는 파일이 남습니다. 그러면 갱신도 못 받고 남들이 쓴 예제도 안 맞게 되죠.
튜터의 가이드 및 해설
기준을 하나 두면 판단이 쉬워집니다. "이 수정이 없으면 우리 앱이 틀리는가"를 물어보세요.
오늘 고친 두 곳은 이 질문에 그렇다고 답합니다. 카드가 div 면 문서 구조가 틀리고, 닫기 버튼이 영어면 우리 화면이 틀려요. 취향이 아니라 결함입니다.
반대로 이런 것들은 아니라고 답합니다. 따옴표 스타일이 다르다, 안 쓰는 변형이 여섯 개 있다, 변수 이름이 우리 규칙과 다르다. 불편하지만 틀린 건 아니라서, 참는 게 낫습니다.
그래도 애매한 자리는 남는데, 그럴 때는 고칠 자리를 옮길 수 있는지 먼저 봅니다.
우선순위
1. 쓰는 쪽에서 해결한다 className 을 넘겨서 · 감싸는 컴포넌트를 하나 만들어서
2. 토큰 값을 바꾼다 오늘 Step 8 처럼 — 파일을 안 건드리고 색을 통째로 옮겼다
3. 그래도 안 되면 파일을 고친다 태그가 틀렸을 때처럼 안쪽을 바꿔야만 되는 것
오늘 Step 8이 2번의 좋은 예입니다. 카드 색을 우리 것으로 맞추려고 card.tsx를 열 수도 있었지만, 토큰 값을 이어서 파일을 안 건드리고 끝내다 보니, 그 파일은 지금도 원본과 두 줄만 다릅니다.
팀에서 합의하고 남기는 방법은 이렇게 하면 실효가 있습니다.
- 고친 자리마다 주석을 답니다. 오늘처럼
// 우리가 고친 곳: 이유형식으로요. 형식이 통일되면 나중에 한 번에 찾을 수 있습니다. - 갱신 전 확인을 습관으로 둡니다.
--diff로 먼저 보고, 우리 수정이 걸리면 새 버전 위에 다시 얹습니다. - 되돌리기 어려운 수정은 리뷰에서 한 번 더 봅니다. 두 줄짜리는 가볍게, 파일 절반을 바꾸는 건 무겁게요.
🎯 면접관을 홀리는 핵심 멘트
"복사해 소유하는 방식에서 진짜 비용은 코드가 아니라 갱신 경로입니다. 그래서 저희는 고칠 자리를 세 단계로 나눴습니다. 쓰는 쪽에서 되면 거기서, 토큰으로 되면 토큰에서, 그래도 안 되면 그때 파일을 엽니다. 실제로 저희가 연 파일은 두 줄만 다르고, 그 두 줄에는 이유가 주석으로 남아 있습니다. 덕분에 갱신할 때 무엇을 지켜야 하는지 논쟁할 필요가 없었어요."
🤔 [생각해볼 주제 2] 눈에 안 보이는 것을 어떻게 지킬까요
문제 상황 요약
오늘 만난 문제들은 화면으로는 전부 멀쩡했다는 공통점이 있습니다. 카드가 div로 바뀐 것, 닫기 버튼이 영어로 읽히는 것, 제목이 없어 이름 없는 상자가 된 것 모두요. 눈으로 하는 확인은 이런 걸 원리적으로 못 잡습니다. 그렇다고 매번 낭독기를 켜고 전수 확인을 할 수도 없고요.
튜터의 가이드 및 해설
방법을 세 갈래로 나눠 보면 각자 잡는 게 다릅니다.
| 방법 | 잡는 것 | 못 잡는 것 | 비용 |
|---|---|---|---|
| 자동 검사 도구 | 규칙으로 표현되는 것 — 이름 없는 버튼, alt 없는 이미지, 낮은 대비 |
이름이 틀린 것("Close" 처럼 있긴 있는데 우리말이 아닌 것) | 낮음 — 한 번 붙이면 계속 돈다 |
| 키보드로 직접 써보기 | 포커스가 새는 곳, 못 닿는 버튼, 안 돌아오는 포커스 | 낭독기가 어떻게 읽는지 | 5분 — 사람이 해야 한다 |
| 낭독기로 들어보기 | 읽히는 내용이 말이 되는지 | 시각적 대비·레이아웃 | 높음 — 익숙해지는 데 시간이 든다 |
자동 검사가 가장 싸니 먼저 붙이는 게 맞지만, 한계를 알고 붙여야 해요. 오늘 닫기 버튼은 이름이 "Close"로 있었습니다. 자동 검사는 "이름이 있다"까지만 보니까 통과시킵니다. 그게 우리 화면 언어와 다르다는 건 사람만 압니다.
그래서 현실적인 조합은 이렇습니다.
- 자동 검사를 기본선으로 깔아 두고 (없는 것·비어 있는 것을 잡는다)
- 새 컴포넌트를 도입한 날에는 키보드로 한 바퀴 돌고 (오늘 같은 날)
- 화면 단위로 낭독기 확인은 가끔 합니다 (분기에 한 번이라도)
그리고 규모에 따라 답이 달라집니다. 두세 명이 만드는 앱에서 낭독기 확인을 매 배포마다 하겠다고 하면 지켜지지 않아요. 지켜지지 않는 규칙은 없느니만 못합니다. 지킬 수 있는 선에서 가장 싼 것부터 붙이는 게 낫습니다.
한 가지 더. 오늘 우리가 실제로 문제를 발견한 방법은 개발자 도구로 요소를 눌러본 것이었습니다. 도구를 새로 붙이기 전에, 화면을 만들고 나서 요소 몇 개를 눌러보는 습관만으로도 꽤 잡힙니다.
🎯 면접관을 홀리는 핵심 멘트
"자동 검사는 '없는 것'을 잘 잡고 '틀린 것'은 못 잡습니다. 저희가 겪은 사례가 정확히 그랬어요. 라이브러리가 준 닫기 버튼에 이름이 영어로 붙어 있었는데, 이름이 있긴 하니까 검사는 통과했습니다. 그래서 저희는 자동 검사를 기본선으로 두되, 새 컴포넌트를 들이는 날에는 키보드만으로 한 바퀴 도는 걸 규칙으로 정했습니다. 5분이면 되고, 검사 도구가 구조적으로 못 잡는 걸 잡습니다."
🤔 [생각해볼 주제 3] 딸려오는 것에 기대는 것은 어디까지 안전할까요
문제 상황 요약
오늘 여섯 가지 접근성 동작을 공짜로 얻었습니다. 우리가 짠 줄은 하나도 없었어요. 편했지만 그만큼 우리가 이해하지 못한 코드에 중요한 동작을 맡긴 셈입니다. 그 라이브러리가 방향을 바꾸거나 관리가 멈추면 우리도 함께 멈추고요.
튜터의 가이드 및 해설
먼저 구분해야 할 게 있습니다. 모든 의존이 같은 무게가 아닙니다.
갈아끼우기 쉬운 의존 날짜 다루는 라이브러리 · 아이콘 꾸러미
화면 여러 곳에 흩어져 있어도 바꾸는 일이 기계적이다
갈아끼우기 어려운 의존 오늘의 Dialog 같은 것
동작·접근성·상태가 얽혀 있어 바꾸면 전부 다시 확인해야 한다
어려운 쪽일수록 고를 때 오래 봐야 합니다. 볼 만한 것들은 이렇습니다. 관리가 활발한가, 쓰는 사람이 많은가, 문서가 있는가, 그리고 비슷한 것으로 갈아탈 길이 있는가입니다.
마지막 항목이 특히 중요합니다. 오늘 우리가 쓴 것은 이 점에서 나쁘지 않아요. 지난 시간에 봤듯 같은 자리에 끼울 수 있는 다른 부품이 있고, 컴포넌트 소스가 우리 저장소에 있어서 안쪽만 갈아끼우면 되니까요. 우리 화면 코드는 <Dialog>와 <DialogContent>를 부르는 것뿐이라 그대로 둘 수 있습니다.
소스를 소유한 것이 여기서 값을 합니다. 패키지 방식이었다면 우리 화면 전체가 그쪽 컴포넌트 이름에 묶여 있었을 거예요. 걷어내야 하는 날이 온다면 무엇이 도와주고 무엇이 발목을 잡을까요.
| 도와주는 것 | 발목을 잡는 것 |
|---|---|
| 화면 코드가 얇다 — 우리 컴포넌트는 조립만 한다 | 그쪽 토큰 이름을 화면에 직접 썼다면(bg-card 같은 것) 전부 찾아야 한다 |
| 소스가 우리 저장소에 있어 안쪽만 바꾸면 된다 | 그쪽만의 문법(render 같은 것)을 화면 코드에 퍼뜨렸다면 전부 고쳐야 한다 |
| 오늘 토큰을 이어둬서 색은 우리 이름 한 곳에서 갈린다 | 컴포넌트를 우리 스타일로 통째로 다시 썼다면 새 버전과 견줄 기준이 없다 |
오른쪽을 줄이는 방법은 하나로 요약됩니다. 경계를 얇게 유지하는 것이에요. 라이브러리 고유의 것들이 우리 화면 코드로 새어 나오지 않게, 감싸는 컴포넌트 한 겹에서 멈추게 하는 겁니다. 오늘 만든 PostModal이 그 역할을 하고 있어요. 우리 피드는 PostModal만 알지 그 안에 무엇이 들었는지 모릅니다.
🎯 면접관을 홀리는 핵심 멘트
"접근성처럼 놓치면 조용히 틀리는 영역은 검증된 구현에 맡기는 게 거의 항상 낫다고 봅니다. 다만 맡기는 것과 묶이는 것은 다릅니다. 저희는 라이브러리 고유 문법이 화면 코드로 새어 나오지 않게 감싸는 컴포넌트 한 겹에서 끊었고, 색은 저희 토큰을 가리키게 이어뒀습니다. 그래서 지금 그 라이브러리를 걷어내야 한다면 고칠 곳은 그 한 겹뿐입니다. 이 얇은 경계가 의존을 감당 가능한 크기로 만들어 준다고 생각합니다."