E-7: 인스타 클론 비주얼 완성 패스 — 눈으로 보지 말고 재보는 날
목차 70
안녕하세요, 홍순구입니다. 지난 시간에 남이 짠 파일을 열어서 읽었죠. 두 군데를 고쳤고, 모달까지 띄웠습니다.
이제 우리 인스타그램은 기능이 다 있어요. 좋아요도 되고 댓글도 달리고 모달도 뜨고 다크모드도 되는데, 아직 진짜 인스타그램처럼 보이지는 않아요.
오늘은 마감을 하는 날이라, 지난 시간 마지막에 약속한 세 가지를 전부 합니다. 카드가 자기 폭을 보고 스스로 배치를 바꾸고, 딸려온 아이콘 꾸러미를 꺼내 쓰고, 누를 때 살짝 반응하게요.
한 가지를 더 하려고 하는데, 오늘 하루 종일 반복될 이야기라 미리 말해둘게요. 화면으로는 멀쩡한데 재보면 틀린 것들이 계속 나옵니다. 카드가 470px 인 줄 알았는데 948px 이었고, 버튼 두 개가 겹쳐 있었고, 빨간 글씨가 기준에 못 미쳤어요. 전부 눈으로는 못 잡고 재서 잡은 것들입니다.
오늘의 여정
Step 1 통은 이미 세워져 있었다
│
Step 2 피드를 창이 아니라 통에게 묻게
│
Step 3 같은 글자가 두 통에서 다르게 읽힌다
│
Step 4 문자 자리에 아이콘을 놓는다
│
Step 5 좋아요 줄을 인스타그램처럼
│
Step 6 누르면 반응하게
│
Step 7 어두운 화면에 남은 어긋남
│
Step 8 화면 전체를 훑는다
▼
기능이 다 된 화면을 "다 됐다" 고 말할 수 있는 상태로
💡 오늘 수업의 핵심 — "마감은 보는 일이 아니라 재는 일이다"
눈으로 훑어서 괜찮아 보이는 것과, 재봐서 괜찮은 것은 다릅니다. 오늘 그 차이를 여덟 번 만납니다.
🎯 학습 목표
- 창이 아니라 담긴 통의 폭을 보고 배치를 바꾸는 방법을 쓸 수 있고, 그 조건이 미디어 쿼리로는 왜 불가능한지 설명할 수 있다
- 아이콘을 이름·크기·색·낭독기까지 챙겨서 쓸 수 있고, 움직임을 원하지 않는 사람을 위한 처리를 할 수 있다
- 이름·대비·누를 자리를 직접 재서 확인하고, 기준에 못 미치는 곳을 찾아 고칠 수 있다
Step 1: "통은 이미 세워져 있었다"
오늘 첫 Step은 코드를 한 줄도 안 씁니다. 대신 지난 시간에 우리가 못 보고 지나친 줄 하나를 찾습니다.
복사돼 온 파일 안에 이미 있던 것
지난 시간에 card.tsx를 열어서 읽었죠. 그때 CardHeader의 클래스 문자열이 길어서 대충 넘긴 부분이 있습니다. 다시 봅시다.
// apps/web-spa/src/components/ui/card.tsx (30번째 줄 부근)
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 ..."
두 번째 글자, @container/card-header요. 이게 뭘까요.
빌드된 CSS에서 찾아보면 이렇게 나옵니다.
.\@container\/card-header {
container: card-header / inline-size;
}
container라는 CSS 속성이에요. "이 요소를 기준 상자로 삼겠다"는 선언입니다. inline-size는 "가로 폭만 잰다"는 뜻이고요.
그런데 아무도 묻지 않고 있었습니다
기준 상자를 세웠으면, 누군가는 그 상자에게 물어야 하죠. "너 지금 몇 픽셀이야?" 하고요. 그 묻는 문법이 @container (width >= ...)입니다.
빌드된 CSS 전체에서 그 조건절을 세어봤어요.
grep -o "@container[^{]*" dist/assets/*.css | sort | uniq -c
1 @container\/card-header ← 통을 세우는 줄
← 묻는 줄은 하나도 없음
세워는 놨는데 아무도 안 묻는 상태였습니다. 지난 시간에 딸려온 것 중에 우리가 못 쓰고 있던 게 하나 더 있었던 거예요.
우리 피드는 그동안 창에게 물었습니다
지금 우리 피드가 두 열로 갈리는 조건은 이렇게 생겼습니다.
@media (width >= 62.25rem) { ... }
E-4에서 만든 --breakpoint-2col이 만든 줄이죠. 창이 996px 보다 넓으면 두 열, 좁으면 한 열입니다.
여기서 질문 하나 드릴게요. 이게 왜 아쉬울까요?
창 1400px
┌──────────────────────────────────────────────┐
│ ┌──────────┐ ┌────────────────────────┐ │
│ │ 피드 │ │ 다른 무언가 │ │
│ │ 카드 │ │ (사이드바·추천 목록…) │ │
│ │ 카드 │ │ │ │
│ └──────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────┘
└ 이 자리는 400px 인데 창은 1400px 이라
"두 열로 가라" 는 조건이 켜진다
같은 카드가 넓은 자리에도 놓이고 좁은 자리에도 놓입니다. 그런데 창은 양쪽 다 똑같아요. 창만 보고 정하면, 좁은 자리에 놓인 카드도 "지금 창이 넓으니까 두 열"이라고 착각합니다.
컴포넌트는 자기가 어디에 놓일지 모릅니다. 그게 컴포넌트의 장점이기도 한데, 스타일 조건이 창을 보면 그 장점이 깨집니다.
창을 묻는 것과 통을 묻는 것
정리하면 이렇습니다.
미디어 쿼리 컨테이너 쿼리
@media @container
묻는 대상: 창(뷰포트) 묻는 대상: 조상 중 통으로 선언된 상자
답은 하나 답은 놓인 자리마다 다르다
전역 조건 국소 조건
문법도 거의 똑같이 생겼어요. @media 자리에 @container를 넣은 정도입니다. Tailwind에서는 조건 접두사 앞에 @를 붙이면 됩니다. lg:는 창, @lg:는 통이에요.
💡 한 줄 정리
지난 시간 복사돼 온 파일에 통을 세우는 줄이 이미 들어와 있었지만, 그 통에게 묻는 줄은 하나도 없었습니다. 창은 답이 하나뿐이라 같은 컴포넌트가 어디에 놓였는지를 구분하지 못합니다.
🙋 학생 질문 — "튜터님, 그럼 미디어 쿼리는 이제 안 쓰나요?"
아니요, 둘 다 씁니다. 판단 기준이 있어요.
화면 전체의 성격이 바뀌는 것은 창을 봅니다. 모바일이냐 데스크톱이냐, 전체 레이아웃을 한 칸으로 하냐 세 칸으로 하냐 같은 것들이요. 사용자가 지금 어떤 기기를 들고 있는지는 창이 알려주는 정보니까요.
컴포넌트 안쪽의 배치는 통을 봅니다. 카드 안에서 프로필이 얼마나 큰가, 사진과 글이 위아래냐 좌우냐 같은 것들이요. 이건 그 카드가 놓인 자리의 문제지 기기의 문제가 아니거든요.
오늘 우리도 둘 다 남겨둡니다. 모달의 최대 폭(sm:max-w-4xl)은 창을 보고, 모달 안쪽 배치는 통을 봅니다. 상자가 얼마나 커질 수 있는지는 화면 크기의 문제고, 그 안을 어떻게 나눌지는 상자 크기의 문제니까요.
Step 2: "피드를 창이 아니라 통에게 묻게"
이제 조건을 옮깁니다. 창을 보던 곳을 통을 보게요.
토큰 이름부터 옮깁니다
E-4에서 만든 중단점 토큰이 있었죠.
/* apps/web-spa/src/styles/globals.css */
@theme {
/* 카드 470 두 장 + 사이 24 = 964px 부터 두 열이 들어간다.
E-4 때는 창을 재느라 좌우 여백 32 를 더한 996 이었다 — 이제 통 자신을 재므로 빠진다. */
--container-2col: 60.25rem;
}
원래는 --breakpoint-2col: 62.25rem 이었습니다. 두 가지가 바뀌었어요.
이름의 앞부분이 --breakpoint-에서 --container-로 바뀌었습니다. Tailwind는 이 앞부분을 보고 "창 조건에 쓸 이름"과 "통 조건에 쓸 이름"을 가릅니다.
값도 62.25rem(996px)에서 60.25rem(964px)으로 줄었어요. 창을 잴 때는 창 좌우 여백 32px 이 포함된 값이 필요했는데, 이제 재는 것이 통 자신이라 그 여백이 빠집니다.
통을 세우고, 조건을 통으로 바꿉니다
// apps/web-spa/src/App.tsx
<main className="@container mx-auto max-w-[996px] py-4 sm:px-4">
// apps/web-spa/src/components/Feed.tsx
<List
items={posts}
className="@2col:grid @2col:grid-cols-2 @2col:gap-6"
aria-label="피드 목록"
renderItem={(post) => <PostCard {...post} onToggleLike={onToggleLike} />}
/>
2col: 앞에 @가 하나 붙은 게 전부인데, 이 작은 차이가 다음 함정을 만듭니다.
⚠️ 이름을 옮기면 다른 곳의 2col:이 조용히 죽습니다
--breakpoint-2col이 사라지는 순간, @가 없는 2col:은 존재하지 않는 조건이 됩니다. 그런 클래스가 두 곳에 더 있었어요.
PostCard mb-6 2col:mb-0 두 열일 때 카드 아래 여백을 지우는 줄
SignUpForm p-4 2col:max-w-[470px] 통이 넓어져도 폼은 안 늘어나게 하는 줄
여기서 무서운 건 아무도 안 알려준다는 겁니다. 직접 확인해 봤어요.
빌드 성공
경고 0건 (npm run build 2>&1 | grep -i warn)
JSX 의 클래스 그대로
DOM 의 클래스 그대로
빌드된 CSS 2col\:mb-0 → 0건
2col\:max-w-\[470px\] → 0건
클래스 이름은 화면에 그대로 남아 있는데, 그 이름에 해당하는 CSS 규칙만 사라진 거예요. 개발자 도구에서 요소를 클릭하면 클래스 목록에 2col:mb-0이 버젓이 보이는데, 아무 일도 안 해요.
Tailwind는 "이런 클래스는 모르는데요"라고 말해주지 않습니다. 못 만드는 클래스는 그냥 안 만들 뿐이에요. 이건 Tailwind의 성격이라, E-1에서 소스 문자열을 훑어서 클래스를 만든다고 했던 그 방식 그대로 훑다가 모르는 걸 만나면 건너뛰기 때문에, 이런 이름 옮기기를 할 때는 바꾼 곳이 아니라 안 바꾼 곳을 찾아야 합니다. 우리는 화면 전체를 훑어서 @ 없는 2col:이 하나도 안 남았는지 확인하는 검사를 코드베이스에 만들어 뒀어요. 되돌려서 실제로 걸리는 것까지 확인했습니다.
⚠️ 재는 상자가 동시에 폭 상한일 수는 없습니다
여기서 더 재밌는 게 나왔어요. 원래 main은 이랬습니다.
mx-auto max-w-[470px] py-4 sm:px-4 2col:max-w-[996px]
한 열일 때 470px, 두 열일 때 996px. 창을 볼 때는 이게 잘 돌아갔는데, main을 통으로 만들면 문제가 생깁니다. 통의 폭이 964px 을 넘어야 두 열이 되는데, main 자신이 470px 로 묶여 있으면 영영 964px 에 못 닿아요.
창 1200px
┌─────────────────────────────────────────┐
│ ┌──────────┐ │
│ │ main │ ← 470px 로 묶임 │
│ │ (통) │ │
│ └──────────┘ │
└─────────────────────────────────────────┘
"통이 964px 넘으면 두 열로 가라"
→ 통이 470px 라서 영영 안 넘는다
→ 자기가 자기 조건을 막는다
재는 상자와 폭 상한을 같은 요소가 겸할 수 없다는 거죠. 그래서 상한을 걷고 max-w-[996px] 하나만 남겼습니다.
⚠️ 그랬더니 카드가 늘어났습니다
상한을 걷자 한 열일 때 카드가 통 폭까지 늘어났어요. 창 980px 에서 카드가 948px이 됐습니다. 원래 470px 이어야 하는데, 여기가 진짜 무서운 부분입니다. 코드베이스의 확인은 전부 초록이었어요. 클래스 문자열만 보는 검사는 카드가 몇 픽셀로 그려지는지 모르거든요. 브라우저를 열어야만 보이는 종류의 어긋남입니다.
고치는 방법은 간단합니다. 상한을 필요한 쪽이 각자 가지면 돼요.
// apps/web-spa/src/components/PostCard.tsx
<Card className="@container mx-auto mb-6 max-w-[470px] @2col:mb-0">
폼 쪽은 오히려 조건이 하나 없어졌습니다. 통이 넓어져도 늘어날 일이 없어졌으니까요.
// apps/web-spa/src/components/SignUpForm.tsx
className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 max-w-[470px]"
창을 한 번도 안 건드리고 재봤습니다
이제 확인할 차례예요. 컨테이너 쿼리는 창이 아니라 상자를 보니까, 창은 그대로 두고 main의 폭만 바꿔가며 재는 게 정확합니다.
통 폭 피드 칸 카드 프로필
------- ----------- ------------- ------ ------
963px 한 열 — 470px 32px
964px 두 열 470px 470px 470px 32px
1200px 두 열 588px 588px 470px 32px
창은 이 세 번 내내 784px 로 고정이었습니다. 창을 한 번도 안 건드렸는데 피드가 두 열이 됐어요. 미디어 쿼리로는 절대 만들 수 없는 상태입니다. 이게 오늘 배운 것의 증거예요.
964px 에서 정확히 갈리는 것도 확인했습니다. 963에서는 한 열, 964에서 두 열. 우리가 정한 60.25rem 그대로죠.
⚠️ E-4에서 배운 것을 반만 기억하면 정확히 틀립니다
E-4에서 이런 걸 확인했죠. --breakpoint-* 토큰은 빌드된 CSS의 :root에 하나도 안 나온다고요. 미디어 쿼리 조건절은 var()를 읽을 수 없어서, 값이 그 자리에 그대로 들어가기 때문이었습니다.
통 쪽도 똑같습니다.
/* 우리가 쓴 것 */
--container-2col: 60.25rem;
/* 빌드된 CSS 에 나오는 것 */
@container (width >= 60.25rem) { ... } /* 값이 박혔다 */
/* :root 에서 --container-2col 을 찾으면 0건 */
여기서부터는 갈리는데, 같은 --container-로 시작하는 이름인데도 :root에 남아 있는 것들이 있어요.
:root {
--container-sm: 24rem;
--container-4xl: 56rem;
}
@media (width >= 40rem) {
.sm\:max-w-sm { max-width: var(--container-sm); }
.sm\:max-w-4xl { max-width: var(--container-4xl); }
}
왜 이건 남았을까요. max-w-4xl은 이 토큰을 조건이 아니라 폭 값으로 쓰기 때문입니다. 값으로 쓰이면 var()로 읽을 수 있고, var()로 읽으려면 :root에 있어야 하죠.
--container-* 라는 한 이름이 두 가지 일을 한다
조건으로 쓰일 때 @2col: · @3xl: → 값이 그대로 들어가고 :root 에 안 남는다
값으로 쓰일 때 max-w-sm · max-w-4xl → var() 로 읽으므로 :root 에 남는다
참고로 저 두 줄은 우리 화면에 실제로 있습니다. sm:max-w-sm은 들여온 모달의 기본 폭이고, sm:max-w-4xl은 다음 Step에서 우리가 모달을 넓힐 때 줄 값이에요.
E-4를 "토큰은 조건에 못 쓰니까 :root에 안 나온다"로 외운 분은 :root에서 --container-sm을 보고 정확히 틀린 결론을 냅니다. 남아 있는 이유가 조건이 아니라 값이라서예요.
💡 한 줄 정리
조건을 창에서 통으로 옮기면 같은 카드가 놓인 자리에 따라 다르게 배치되지만, 토큰 이름을 옮기면 그 이름을 쓰던 다른 클래스가 경고 없이 죽고, 재는 상자는 동시에 폭 상한이 될 수 없습니다.
🙋 학생 질문 — "튜터님, 클래스가 죽었는데 경고가 없으면 어떻게 알아채나요?"
세 가지 방법을 같이 씁니다.
첫째, 이름을 바꿀 때 그 이름을 쓰는 곳을 전부 찾습니다. 편집기 전체 검색으로 2col:을 검색하면 세 곳이 나와요. 하나만 고치고 끝내지 않는 습관이 제일 확실합니다.
둘째, 빌드된 CSS를 뒤집니다. 클래스 이름이 규칙으로 만들어졌는지 직접 확인하는 거죠. 이때 조심할 게 있는데, 셸에서 특수문자를 이스케이프하다 실수하면 있는 걸 없다고 읽습니다. 저도 이번에 @2col\:max-w-\[470px\]를 잘못 써서 "0건"을 보고 죽은 줄 알았어요. 실제로는 다섯 개가 멀쩡히 있었습니다. 결과가 0으로 나오면 한 번 더 다른 방법으로 확인하세요.
셋째, 화면 전체에서 그 패턴이 안 남았는지 검사를 만들어 둡니다. 우리는 @가 앞에 없는 2col:이 화면 어디에도 없어야 한다는 검사를 코드베이스에 만들어 뒀어요. 앞으로 누가 실수로 다시 넣으면 그때 걸립니다.
Step 3: "같은 글자가 두 통에서 다르게 읽힌다"
Step 2에서는 피드 전체가 통을 봤습니다. 이번엔 카드 안쪽이 통을 보게 합니다. 여기서 컨테이너 쿼리의 진짜 성격이 드러나요.
카드도 자기가 통이라고 선언합니다
// apps/web-spa/src/components/PostCard.tsx
<Card className="@container mx-auto mb-6 max-w-[470px] @2col:mb-0">
@container가 붙어 있죠. 이게 없으면 카드 안쪽 요소들이 조상을 거슬러 올라가다 main을 만나서, 바깥 통을 보고 갈려버립니다.
컨테이너 쿼리는 "가장 가까운 통"을 봅니다. 통이 없으면 더 위로 올라가기 때문에, 컴포넌트가 자기 안쪽의 기준이 되고 싶으면 스스로 통이라고 말해야 합니다.
머리 구역에 조건을 답니다
// apps/web-spa/src/components/PostHeader.tsx
<div className="flex items-center gap-2.5 @lg:gap-4">
<Avatar className="@lg:size-11">
<AvatarImage src={profileImageUrl} alt={`${username} 프로필 사진`} />
<AvatarFallback>{username.slice(0, 1).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm font-semibold @lg:text-base">{username}</span>
</div>
@lg:는 "담긴 통이 lg보다 넓으면"이라는 뜻입니다. 프로필 사진이 커지고, 사이가 벌어지고, 이름 글자가 커져요.
모달도 통으로 만들고 넓힙니다
// apps/web-spa/src/components/PostModal.tsx
<DialogContent className="@container max-w-[calc(100%-2rem)] gap-3 sm:max-w-4xl">
{/* 낭독기는 이 이름으로 상자를 부른다.
화면에는 아래 머리 구역이 대신 보이므로 이름만 남기고 감춘다. */}
<DialogTitle className="sr-only">{username} 의 게시물</DialogTitle>
<div className="grid gap-3 @3xl:grid-cols-2 @3xl:items-start @3xl:gap-5">
<img className="w-full rounded-lg" src={imageUrl} alt={`${username} 의 게시물`} />
<div className="flex flex-col gap-2">
<PostHeader username={username} profileImageUrl={profileImageUrl} />
<DialogDescription>{content}</DialogDescription>
<p className="text-sm text-faint">
좋아요 {likeCount}개 · 댓글 {commentCount}개
</p>
</div>
</div>
</DialogContent>
지난 시간에는 모달이 sm:max-w-lg 였습니다. 좁아서 사진 하나 겨우 들어갔죠. sm:max-w-4xl로 넓혔고, 안쪽은 통이 넉넉하면 사진 왼쪽·글 오른쪽으로 갈리게 했습니다.
여기서 sm:은 창을 봅니다. 상자가 얼마나 커질 수 있는지는 화면 크기의 문제니까요. 그 안쪽 @3xl:은 통을 봅니다. 갈릴지 말지는 상자 크기의 문제고요.
같은 글자, 다른 결과
여기서부터 재밌는 지점인데, 모달의 머리 구역도 카드와 똑같은 PostHeader를 씁니다. 글자가 하나도 안 다른데, 결과는 이렇게 갈려요.
카드 안 (통 470px) 모달 안 (통 864px)
프로필 32px 프로필 44px
이름 14px 이름 16px
사이 10px 사이 16px
@lg:size-11이라는 같은 글자가 한쪽에서는 아무 일도 안 하고 다른 쪽에서는 44px 을 만듭니다. 갈리는 것은 담긴 통의 폭뿐이에요.
미디어 쿼리로는 이걸 만들 방법이 없습니다. 카드와 모달은 같은 창 안에 동시에 떠 있으니까요. 창은 둘에게 같은 답을 줍니다.
⚠️ 창의 lg와 통의 @lg는 이름만 같고 숫자가 다릅니다
여기서 한 번 걸려 넘어지기 딱 좋은 게 있어요. Tailwind 기본값을 직접 열어보면 이렇습니다.
--breakpoint-lg: 64rem (1024px) ← 창의 lg
--container-lg: 32rem ( 512px) ← 통의 @lg
절반입니다. 이름이 같아서 같은 크기라고 착각하기 쉬운데, 창의 중단점은 화면 크기 눈금이고 통의 눈금은 상자 크기 눈금이라 애초에 다른 자를 씁니다.
우리 모달 안쪽이 @3xl:(48rem = 768px)에서 갈리는 것도 이 눈금이에요. 창 기준으로 3xl 을 찾으면 없습니다. 애초에 창 눈금에는 3xl 이 없거든요.
E-6에서 못 채운 것 하나를 여기서 채웁니다
위 코드에 DialogTitle이 sr-only로 들어가 있죠. 지난 시간에 제목을 안 넣으면 이름 없는 대화 상자가 조용히 만들어진다고 했는데, 이번엔 화면에 제목을 보여주고 싶지 않아요. 바로 아래에 프로필과 이름이 이미 나오니까요.
sr-only는 화면에서는 안 보이지만 낭독기는 읽는 클래스입니다. 이름은 남기고 그림만 빼는 거죠.
💡 한 줄 정리
컴포넌트가 스스로 통이 되면 안쪽 조건이 그 컴포넌트 폭을 기준으로 갈립니다. 그래서 똑같이 적힌 한 줄이 카드에서는 32px, 모달에서는 44px 이 됩니다.
🙋 학생 질문 — "튜터님, 통이 여러 겹이면 어느 통을 보나요?"
가장 가까운 통을 봅니다. 우리 화면이 딱 그 구조예요.
main (@container)
└ 카드 (@container)
└ 머리 구역의 @lg:size-11 ← 카드를 본다. main 은 안 본다.
카드가 통 선언을 안 했다면 @lg:size-11은 main을 봤을 겁니다. main은 훨씬 넓으니까 좁은 카드 안에서도 프로필이 커졌겠죠. 이게 Step 2에서 "카드도 통이 돼야 한다" 고 한 이유예요.
특정 통을 콕 집어 묻고 싶으면 이름을 줄 수도 있습니다. 지난 시간에 복사돼 온 card.tsx가 @container/card-header라고 이름을 붙인 것이 그거예요. 그러면 @lg/card-header:처럼 이름을 지목해서 물을 수 있습니다. 우리는 아직 그렇게까지 안 해도 돼서 이름 없는 통만 씁니다.
Step 4: "문자 자리에 아이콘을 놓는다"
지금 우리 화면에서 아이콘이어야 할 자리에 문자가 들어가 있습니다. 더보기는 ⋯, 댓글 삭제는 ×요. 폰트에 따라 모양이 달라지고, 크기를 글자 크기로 맞춰야 하고, 무엇보다 인스타그램처럼 안 보입니다.
아이콘 꾸러미는 이미 들어와 있었습니다
새로 깔 게 없어요. E-5에서 라이브러리를 설치할 때 lucide-react가 함께 깔렸고, E-6에서 복사돼 온 dialog.tsx가 이미 쓰고 있었습니다.
// apps/web-spa/src/components/ui/dialog.tsx (8번째 줄)
import { XIcon } from "lucide-react"
모달 오른쪽 위의 닫기 X가 이겁니다. 우리가 처음 만나는 게 아니라, 이미 화면에 떠 있었던 거예요.
⚠️ 이름이 여섯 개인데 화면에 남는 건 하나입니다
여기서 오늘 제일 조심할 것이 나옵니다. 더보기 아이콘을 찾으려고 검색하면 대개 MoreHorizontal이라는 이름이 나와요. 그대로 쓰면 잘 돌아갑니다. 그런데 그건 정식 이름이 아닙니다.
꾸러미가 이름을 내보내는 줄을 직접 열어보면 이렇게 생겼어요.
export { default as Ellipsis,
default as EllipsisIcon,
default as LucideEllipsis,
default as LucideMoreHorizontal,
default as MoreHorizontal,
default as MoreHorizontalIcon }
from './icons/ellipsis.mjs';
한 줄에 이름이 여섯 개인데, 전부 default 하나를 가리켜서 뭐로 부르든 완전히 같은 것이 옵니다.
숫자로 보면 이렇습니다.
아이콘 파일 1,769개
내보내는 이름 수 6,065개
→ 아이콘 하나당 이름이 평균 3.4개
이름이 이렇게 많은 이유는 옛 이름을 안 버리기 때문입니다. 예전엔 MoreHorizontal 이었고 지금은 Ellipsis인데, 옛 이름을 지우면 기존 코드가 다 깨지니까 별칭으로 남겨둔 거죠. MoreVertical도 EllipsisVertical의 별칭이라서, 검색해서 나온 오래된 코드가 그대로 컴파일됩니다. 틀린 줄 모르고 지나가요. 편집기도 안 알려줍니다. 진짜 있는 이름이니까요.
그럼 뭐가 정식 이름인지 어떻게 알까요. 화면이 알려줍니다.
<svg class="lucide lucide-ellipsis size-5 @lg:size-6" ...>
MoreHorizontal로 불러도 클래스에는 lucide-ellipsis가 찍힙니다. 클래스 이름은 아이콘 파일이 자기 정식 이름으로 만들거든요. 개발자 도구에서 보이는 그 이름이 진짜입니다.
문자를 아이콘으로 바꿉니다
// apps/web-spa/src/components/PostHeader.tsx
import { Ellipsis } from 'lucide-react';
<IconButton
className="cursor-pointer p-3 text-lg leading-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="게시물 메뉴"
>
{/* 그림은 자기 크기를 24px 로 갖고 온다. 글자 크기로는 안 바뀌므로 따로 준다.
넓은 통에서 커지는 것은 옆의 프로필과 같은 조건을 쓴다. */}
<Ellipsis className="size-5 @lg:size-6" />
</IconButton>
댓글 삭제도 같은 방식입니다.
// apps/web-spa/src/components/CommentList.tsx
import { X } from 'lucide-react';
<IconButton
className="cursor-pointer px-1 text-sm leading-none text-faint hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="댓글 삭제"
onClick={() => onRemove(comment.id)}
>
<X className="size-4" />
</IconButton>
버튼의 클래스는 한 글자도 안 건드렸습니다. 바뀐 건 안에 든 것뿐이에요. A-6에서 만든 IconButton이 aria-label을 필수로 받게 해뒀기 때문에, 이름을 빠뜨릴 수가 없습니다. 그때 만든 타입 한 줄이 여기서 일합니다.
우리가 안 적었는데 낭독기에서 빠집니다
그려진 결과를 보면 우리가 안 적은 속성이 하나 붙어 있어요.
<svg ... class="lucide lucide-ellipsis size-5 @lg:size-6" aria-hidden="true">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="19" cy="12" r="1"></circle>
<circle cx="5" cy="12" r="1"></circle>
</svg>
aria-hidden="true"요. 이 꾸러미가 알아서 붙입니다. 조건이 있어요. 안에 든 것도 없고 aria-로 시작하는 속성이나 role·title도 안 넘겼을 때만 붙입니다.
왜 감출까요. 그림은 그 자체로 읽을 게 없기 때문입니다. 읽어줄 이름은 바깥 버튼이 aria-label로 들고 있으니, 안쪽 그림까지 읽으면 두 번 읽는 셈이 돼요.
여기서 함정 하나. 아이콘에 aria-label을 붙이면 이 조건이 깨져서 감춤이 사라집니다. 친절하게 이름을 하나 더 달아준다는 게 오히려 낭독기에 군더더기를 만드는 거죠. 이름은 버튼에, 그림은 조용히. 이 역할 분담을 기억해 주세요.
⚠️ 글자 크기로는 아이콘 크기가 안 바뀝니다
버튼에 text-lg가 그대로 남아 있는 게 보이시죠. 지난 모듈에서 ⋯ 글자를 18px 로 맞추려고 붙인 것인데, 이제 그 자리에 글자가 없어요.
그럼 아이콘은 몇 픽셀일까요. 재봤습니다.
버튼의 font-size 18px (text-lg 가 준 값)
아이콘의 width 속성 24 (꾸러미가 정해서 보낸 값)
아이콘의 실제 크기 20px (size-5 가 CSS 로 이긴 값)
세 숫자가 전부 다릅니다. 정리하면 이렇습니다.
글자 크기(text-lg) → 아이콘과 아무 상관 없다
width/height 속성 → 기본 24px 로 정해져서 온다
size-* 클래스 → CSS 라서 속성을 이긴다
CSS가 HTML 속성을 이기는 건 SVG에서 자주 만나는 규칙이에요. 그래서 아이콘 크기는 반드시 클래스로 줍니다.
우리는 size-5 @lg:size-6을 줬습니다. Step 3의 프로필과 같은 조건이에요. 재보면 이렇게 나옵니다.
카드 안 (통 470px) 20px
모달 안 (통 864px) 24px
색은 반대로 글자색을 따라갑니다. 아이콘의 stroke가 currentColor로 정해져서 오거든요. 그래서 더보기는 본문색, 댓글 삭제는 흐린색으로 저절로 그려집니다. 크기는 안 따라오고 색은 따라오는 거예요.
⚠️ 1,769개가 다 딸려오는 건 아닙니다. 단, 부르는 방법에 따라 다릅니다
아이콘이 1,769개인 꾸러미에서 두 개를 꺼내 썼습니다. 번들에 몇 개가 들어갔을까요. 빌드해서 재봤습니다.
아이콘 쓰기 전 408,176 B
두 개 쓴 뒤 408,394 B ← 218 B 늘었다
두 개 값만 늘었어요. 번들러가 "안 쓰는 건 안 넣는다"를 해준 겁니다. 이름으로 딱 집어서 불렀기 때문에 가능한 일인데, 이름을 변수로 골라야 하는 코드를 짜면 이야기가 달라집니다.
import { Ellipsis, X } from 'lucide-react' → 408,394 B
import { icons } 후 icons[변수이름] → 915,478 B
2.24배, 507KB 가 더 들어갑니다. 이유는 단순해요. 이름이 변수면 번들러가 무엇을 버려도 되는지 판단할 수 없어서, 전부 넣습니다.
아이콘 이름을 서버에서 받아와 그리는 화면 같은 걸 만들 때 이 함정에 빠집니다. 그럴 땐 쓸 아이콘만 미리 골라 지도를 만들어 두는 식으로 피해요.
💡 한 줄 정리
아이콘은 이름이 여러 개라 옛 이름으로도 컴파일되고, 크기는 글자 크기가 아니라 size-*로 주며, 낭독기에서 감추는 것은 꾸러미가 알아서 해주지만, 이름을 변수로 고르는 순간 번들이 두 배가 됩니다.
🙋 학생 질문 — "튜터님, 그럼 어떤 이름으로 쓰는 게 맞나요?"
공식 사이트에서 검색해서 나오는 이름을 쓰세요. 그게 정식 이름입니다.
이미 짜인 코드에서 옛 이름을 발견하면, 굳이 급하게 바꿀 필요는 없습니다. 같은 것을 가리키니까 동작이 달라질 일은 없어요. 다만 새로 짤 때는 정식 이름을 쓰는 게 좋습니다. 옛 이름이 언젠가 사라질 수도 있고, 무엇보다 팀 안에서 같은 아이콘을 두 이름으로 부르는 상황이 생기면 검색이 안 되거든요.
접미사가 붙은 EllipsisIcon 같은 이름도 정식입니다. 우리 코드에 이미 두 방식이 섞여 있어요. 복사돼 온 dialog.tsx는 XIcon을 쓰고, 우리가 쓴 CommentList는 X를 씁니다. 남의 파일은 그쪽 방식을 존중하고 우리 파일은 우리 방식으로 통일했습니다.
Step 5: "좋아요 줄을 인스타그램처럼"
지금 좋아요 버튼은 테두리가 있는 상자 안에 ♡ 좋아요라고 적혀 있습니다. 인스타그램은 테두리 없는 하트 하나죠. 바꿔봅시다.
바꾸고 나면 이름이 안 바뀝니다
여기서 접근성 문제가 하나 생깁니다. 지금까지는 버튼 이름 자체가 상태였어요.
안 눌림 "♡ 좋아요"
눌림 "♥ 좋아요 취소"
낭독기 사용자는 이름만 듣고도 지금 상태를 알았습니다. 그런데 아이콘만 남기면 이름은 늘 "좋아요"예요. 하트가 찼는지 비었는지는 눈으로 보는 사람만 알게 되기 때문에, 상태를 따로 알려야 합니다. E-5에서 밝기 버튼을 만들 때 썼던 그 속성이에요.
// apps/web-spa/src/components/LikeButton.tsx
import { Heart } from 'lucide-react';
import { IconButton } from './IconButton';
<IconButton
className="cursor-pointer p-2 ... focus-visible:outline-brand"
aria-label="좋아요"
aria-pressed={liked}
onClick={onToggle}
>
{/* 이름이 안 바뀌니 눌렸다는 것은 aria-pressed 가 알린다.
눈으로 보는 사람에게는 속을 채우고 색을 바꿔서 알린다. */}
<Heart
className={`size-6 ${
liked ? 'fill-current text-danger' : ''
}`}
/>
</IconButton>
aria-pressed는 "이 버튼은 눌렸다 안 눌렸다 하는 버튼이고, 지금은 이 상태다"를 알려줍니다. 밝기 버튼에서는 있으면 좋은 정도였지만, 여기서는 없으면 상태를 알 길이 아예 없어집니다.
글자 버튼이던 때 아이콘 버튼이 된 뒤
이름 "♡ 좋아요" 이름 "좋아요" (안 바뀐다)
"♥ 좋아요 취소" 눌림 false/true (여기가 상태를 든다)
두 방식 다 맞는 방식입니다. 다만 둘을 섞으면 안 됩니다. 이름도 바뀌고 aria-pressed도 있으면 낭독기가 상태를 두 번 말해요.
우리 Button 컴포넌트는 이 속성을 받을 줄 몰랐어서, 받을 수 있게 한 줄 더했습니다.
// apps/web-spa/src/components/Button.tsx
// 글자 대신 기호만 보이는 버튼은 읽어줄 이름을 따로 준다
'aria-label'?: string;
// 눌린 상태를 색으로만 알리면 눈으로 보는 사람만 안다
'aria-pressed'?: boolean;
눈으로 보는 사람에게는 속을 채웁니다
fill-current는 채우기 색을 글자색으로 바꾸는 클래스입니다. 아이콘은 원래 fill="none"으로 그려져 오는데, 이걸 CSS가 이겨서 속이 찹니다. Step 4에서 size-5가 width="24"를 이긴 것과 같은 구조예요.
재보면 이렇습니다.
안 눌림 fill: none stroke: 본문색
눌림 fill: 빨강 stroke: 같은 빨강
text-danger 하나로 선과 속이 함께 빨개집니다. currentColor를 타고 두 곳에 같이 흐르니까요.
동작이 없는 아이콘은 놓지 않습니다
인스타그램의 그 줄에는 아이콘이 넷입니다. 하트·말풍선·비행기·북마크요. 우리도 넷을 놓고 싶어질 텐데, 여기서 멈춰야 합니다.
우리 앱에 실제로 있는 동작은 좋아요 하나뿐이에요. 나머지 셋을 모양만 놓으면 어떻게 될까요.
낭독기 사용자에게는 "버튼이 넷 있습니다"라고 들립니다. 하나씩 눌러보면 셋은 아무 일도 안 해요. 눈으로 보는 사람은 "아직 안 되나 보다" 하고 넘어가지만, 화면을 못 보는 사람은 자기가 뭘 잘못했는지 한참 찾습니다.
모양을 위해 놓은 버튼은 모양만 거짓말하는 게 아니라서, 하나만 놓았습니다.
왼쪽 끝을 맞추는 데 8px 이 걸렸습니다
바꾸고 나서 화면을 봤는데 뭔가 어긋나 보였어요. 재봤습니다. 카드 왼쪽 끝에서 각 줄이 어디서부터 시작하는지 봤습니다.
하트 36px
좋아요 1240개 36px
캡션 28px
댓글 32개 모두 보기 28px
좋아요 줄만 8px 오른쪽으로 밀려 있습니다. 원인은 아이콘 버튼이 누를 자리를 넓히려고 안쪽 여백을 갖기 때문이에요. 글자 버튼이던 때는 없던 여백이라, 바깥 여백에서 그만큼 뺐습니다.
// apps/web-spa/src/components/LikeButton.tsx
// 아이콘 버튼은 누를 자리를 넓히려고 안쪽 여백을 갖는다.
// 그만큼 바깥 여백에서 빼야 하트가 아래 캡션과 같은 자리에서 시작한다.
return (
<div className="px-1 pt-2">
px-3(12px)에서 px-1(4px)로요. 다시 재니 넷 다 28px 입니다.
하트 28px ✅
좋아요 1240개 28px ✅
캡션 28px ✅
댓글 32개 모두 보기 28px ✅
(버튼의 누를 수 있는 상자는 20px 에서 시작한다 —
8px 이 글줄 왼쪽으로 삐져나온다. 이게 맞는 모습이다)
누를 자리는 넓게, 그림은 줄에 맞게. 인스타그램도 이렇게 돼 있어요.
💡 한 줄 정리
아이콘만 남으면 버튼 이름이 안 바뀌므로 눌린 상태는 aria-pressed가 들어야 하고, 아이콘 버튼의 안쪽 여백만큼 바깥 여백을 빼야 글줄과 왼쪽 끝이 맞습니다.
🙋 학생 질문 — "튜터님, 이름을 '좋아요 취소'로 바꾸는 게 더 친절하지 않나요?"
친절해 보이는데 실제로는 헷갈립니다. 이유가 두 가지예요.
첫째, 이름이 바뀌면 무엇을 가리키는지 흔들립니다. 낭독기는 "좋아요 취소, 버튼"이라고 읽는데, 이게 지금 상태가 좋아요라는 뜻인지 누르면 취소된다는 뜻인지 애매해요. 실제로 이 애매함 때문에 사용자가 헷갈린다는 보고가 많습니다.
둘째, 찾기가 어려워집니다. 이름이 상태에 따라 바뀌면 "좋아요 버튼"을 이름으로 찾는 모든 코드가 두 이름을 다 알아야 해요. 우리도 이번에 그 대가를 치렀습니다. 이름으로 버튼을 찾던 코드가 코드베이스 아홉 군데에서 걸렸어요.
aria-pressed는 이 둘을 다 풉니다. 이름은 늘 "좋아요"로 고정이고, 상태는 눌림 표시가 따로 전합니다. 낭독기는 "좋아요, 토글 버튼, 눌림"처럼 읽어요.
Step 6: "누르면 반응하게"
지금 좋아요를 눌러도 하트가 그냥 툭 바뀝니다. 인스타그램은 살짝 튀죠. 그 차이를 만듭니다.
움직임 꾸러미도 이미 들어와 있습니다
E-5에서 라이브러리를 깔 때 tw-animate-css라는 것이 함께 왔고, 우리 CSS 첫머리에 한 줄로 들어와 있습니다.
/* apps/web-spa/src/styles/globals.css */
@import "tailwindcss";
@import "tw-animate-css";
지난 시간 모달이 뜰 때 살짝 커지면서 나타났던 것 기억하시죠. data-open:animate-in data-open:zoom-in-95가 그 일을 했습니다. 그게 이 꾸러미예요.
이 꾸러미는 CSS 파일 하나가 전부입니다. JavaScript가 없어요. 구조가 재밌는데, 애니메이션은 딱 두 개뿐입니다.
@keyframes enter {
from {
opacity: var(--tw-enter-opacity, 1);
transform: ... scale3d(var(--tw-enter-scale, 1), ...) ...;
}
}
fade-in-0 → --tw-enter-opacity 를 0 으로 세팅
zoom-in-95 → --tw-enter-scale 을 0.95 로 세팅
zoom-in-50 → --tw-enter-scale 을 0.5 로 세팅
fade-in-0과 zoom-in-95를 같이 써도 되는 이유가 여기 있습니다. 애니메이션 두 개가 겹치는 게 아니라, 한 애니메이션이 읽는 변수 두 개를 채우는 거예요.
누르는 느낌과 튀는 느낌
두 가지를 넣습니다.
// apps/web-spa/src/components/LikeButton.tsx
<IconButton
className="cursor-pointer p-2 transition duration-150 hover:opacity-60 active:scale-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="좋아요"
aria-pressed={liked}
onClick={onToggle}
>
<Heart
className={`size-6 ${
liked ? 'fill-current text-danger animate-in zoom-in-50 duration-200' : ''
}`}
/>
</IconButton>
active:scale-90은 누르고 있는 동안 90% 로 줄어듭니다. 손가락으로 눌렀을 때 눌리는 느낌이 나요. transition duration-150이 그 변화를 0.15초에 걸쳐 부드럽게 만들고요.
animate-in zoom-in-50은 좋아요를 켤 때만 붙습니다. 하트가 절반 크기에서 원래 크기로 0.2초에 걸쳐 커져요.
여기서 왜 켤 때만일까요. 끌 때도 튀면 취소가 축하처럼 보이기 때문입니다. 움직임은 의미를 전달해요. 아무 데나 넣으면 잘못된 의미를 전달합니다.
클래스를 붙이는 것만으로 애니메이션이 다시 돕니다
이 코드에는 좀 신기한 구석이 있어요. liked가 true가 되면 클래스가 생기는데, 그때 애니메이션이 처음부터 돕니다.
CSS 애니메이션은 요소에 animation-name이 새로 생기는 순간 시작합니다. 클래스를 뗐다 붙이면 다시 시작하고요. 그래서 좋아요를 껐다 켰다 하면 켤 때마다 매번 튑니다. React가 클래스만 갈아 끼우면 나머지는 브라우저가 알아서 해요.
재보면 이렇게 확인됩니다.
안 눌림 animation-name: none · 버튼 transition 0.15s
눌림 animation-name: enter · animation 0.2s
enter라는 이름이 보이시죠. 아까 그 공용 애니메이션입니다. duration-200이 그 안의 시간 변수를 채운 것이고요.
⚠️ 이 꾸러미는 "움직임 줄이기"를 전혀 안 봅니다
여기서 오늘 두 번째로 중요한 이야기를 하겠습니다.
운영체제에는 "동작 줄이기" 설정이 있습니다. 화면이 움직이면 어지럽거나 속이 울렁이는 분들이 켜는 설정이에요. 전정기관 질환이 있거나 편두통이 있는 분들에게는 화면의 움직임이 실제로 증상을 일으킵니다.
웹은 이 설정을 읽을 수 있어요. E-4에서 다크모드를 만들 때 운영체제 설정을 읽었던 것과 똑같은 방식인데, 우리가 방금 쓴 꾸러미에 그 처리가 있는지 세어봤어요.
tw-animate-css prefers-reduced-motion 처리 0건
shadcn 스타일 prefers-reduced-motion 처리 1건 (우리가 안 쓰는 클래스용)
없습니다. 지난 시간 모달이 커지면서 나타나던 그 움직임은, 동작 줄이기를 켠 사람에게도 그대로 돌고 있었어요. 우리가 넣은 게 아니라 딸려온 것이라 눈치채기도 어려웠습니다.
고치는 방법이 두 가지 있습니다.
방법 1 클래스로 한 곳씩 motion-reduce: 접두사를 우리 클래스마다 붙인다
→ 우리가 안 쓴 클래스(모달의 딸려온 움직임)는 못 덮는다
방법 2 CSS 한 덩어리로 전부 미디어 쿼리로 화면 전체의 움직임을 줄인다
→ 남의 코드까지 덮인다
우리 상황에서는 두 번째가 맞습니다. 문제가 우리가 안 쓴 클래스에서 나고 있으니까요.
/* apps/web-spa/src/styles/globals.css */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
0.01ms로 만드는 이유가 있어요. none으로 꺼버리면 애니메이션이 끝났을 때 도는 코드가 영영 안 돌아서 화면이 멈추는 경우가 있습니다. 아주 짧게 만들면 끝나긴 끝나되 눈에는 안 보입니다.
!important는 평소 같으면 피할 것이지만, 여기서는 목적이 "무엇이든 이긴다"라서 씁니다. 남이 짠 코드까지 덮어야 하니까요.
이 한 덩어리가 CSS 1,418바이트를 더 씁니다. 그 값에 무엇을 사는지는 분명해요.
💡 한 줄 정리
움직임은 의미를 전달하므로 켤 때만 튀게 하고, 딸려온 움직임 꾸러미가 동작 줄이기를 안 보고 있었으므로 우리가 화면 전체에 한 번에 걸었습니다.
🙋 학생 질문 — "튜터님, 움직임을 다 꺼버리면 사용자가 뭐가 바뀌었는지 모르지 않나요?"
날카로운 질문이라, 움직임을 없애는 게 아니라 줄이는 겁니다.
동작 줄이기를 켠 사람도 "무언가 바뀌었다"는 건 알아야 하지만, 그걸 움직임 말고 다른 것으로 알려줘야 합니다. 우리 하트를 보면 이미 그렇게 돼 있어요.
튀는 움직임 동작 줄이기를 켜면 사라진다
속이 차는 것 그대로 남는다
색이 빨개지는 것 그대로 남는다
aria-pressed 그대로 남는다
움직임 하나가 사라져도 나머지 셋이 상태를 전합니다. 이게 중요한 원칙이에요. 움직임을 유일한 신호로 쓰지 않는 것이요.
반대로 "이 목록에 새 항목이 추가됐다"를 오직 슬라이드 애니메이션으로만 알리고 있었다면, 동작을 줄인 사람은 아무것도 못 봅니다. 그럴 땐 움직임을 줄이는 대신 색이나 표시를 남기는 식으로 다르게 설계해야 해요.
Step 7: "어두운 화면에 남은 어긋남"
E-5에서 라이브러리를 들여올 때 우리 색과 저쪽 색을 화해시켰죠. --card·--border 같은 이름을 우리 토큰을 가리키게 이어뒀습니다. 그런데 두 개가 남아 있었어요.
화면 전체에 걸려 있던 회색
들여온 파일이 이런 규칙을 CSS 맨 아래에 깔아 뒀습니다.
/* apps/web-spa/src/styles/globals.css — 들여올 때 함께 온 부분 */
@layer base {
* {
@apply border-border outline-ring/50;
}
}
*는 모든 요소입니다. 빌드된 CSS를 보면 이렇게 나와요.
* { border-color: var(--border); outline-color: var(--ring); }
* { outline-color: color-mix(in oklab, var(--ring) 50%, transparent); }
--border는 E-6에서 우리 --color-line을 가리키게 이어뒀습니다. 그런데 --ring은 안 이어져 있었어요. 값을 보면 이렇습니다.
밝은 화면 어두운 화면
--ring oklch(0.708 0 0) 회색 oklch(0.556 0 0) 회색
우리 brand oklch(65.46% 0.177 248.2) 파랑
우리 화면의 포커스 테두리는 파랑입니다. E-3에서 조작 가능한 요소마다 focus-visible:outline-brand를 붙였지만, 우리가 안 붙인 곳은 전부 저 회색을 씁니다. 지난 시간에 만든 모달의 닫기 버튼이 그래요.
같은 화면에서 포커스 표시가 두 색이었던 겁니다.
⚠️ 이 이름들은 :root에서 못 찾습니다
빌드된 CSS에서 --color-ring을 찾아보면 0건입니다. --ring은 5곳에서 쓰이는데 --color-ring은 하나도 없어요. 왜일까요.
E-4에서 비슷한 걸 봤죠. 중단점 토큰이 :root에 안 나온다고요. 그때 이유는 "미디어 쿼리 조건은 var()를 못 읽어서" 였습니다.
이번엔 이유가 다릅니다. 들여온 파일의 이 블록을 보세요.
@theme inline {
--color-ring: var(--ring);
--color-destructive: var(--destructive);
}
@theme 뒤에 inline이 붙어 있습니다. 이건 "이 이름을 :root에 내보내지 말고 쓰는 곳에 값을 그대로 넣어라"는 뜻이라, outline-ring이 만든 규칙에는 var(--color-ring)이 아니라 var(--ring)이 바로 들어갑니다.
결과는 같다: :root 에서 못 찾는다
이유는 다르다
E-4 의 --breakpoint-* 조건절이 var() 를 못 읽어서 값이 박힌다
E-7 의 --color-ring @theme inline 이라 원래 안 내보낸다
같은 증상을 보고 같은 원인이라고 결론 내리면 틀립니다. 개발자 도구에서 --color-ring을 읽어보면 빈 문자열이 나와요. "없어졌나?"가 아니라 "여기 있을 이름이 아니다"인 겁니다.
두 개를 잇습니다
E-6에서 --card를 이었던 것과 같은 방식입니다. className으로 매번 덮는 대신 값을 잇습니다.
/* apps/web-spa/src/styles/globals.css */
:root {
/* 우리 빨강과 들여온 빨강이 따로 놀지 않게 잇는다 (E-6 에서 --card 를 이은 것과 같은 방식) */
--destructive: var(--color-danger);
--border: var(--color-line);
/* 포커스 테두리 색 — 우리 화면의 포커스는 파랑 하나여야 한다 */
--ring: var(--color-brand);
}
.dark {
/* --destructive · --ring 은 :root 에서 우리 토큰을 가리키므로 여기서 지웠다 */
}
어두운 화면 쪽 두 줄은 지웠습니다. 우리 --color-brand와 --color-danger는 양쪽 화면에서 같은 값이라, :root의 참조가 그대로 따라오면 되거든요. 남겨두면 오히려 그게 이겨서 회색으로 돌아갑니다.
확인해 보면 빌드된 CSS 전체에서 --ring 선언이 하나, --destructive 선언이 하나입니다. 둘 다 우리 토큰을 가리켜요.
이건 취향 문제가 아니었습니다
색을 통일하는 건 보기 좋으라고 하는 일 같지만, 재보면 다릅니다.
포커스 표시에는 기준이 있어요. 배경과 3:1 이상 차이가 나야 합니다. 안 그러면 지금 어디에 포커스가 있는지 안 보이니까요. 각 색을 배경에 대고 재봤습니다.
밝은 화면 어두운 화면
옛 회색 링 2.58 ❌ 3.95 ✅
우리 브랜드 파랑 3.17 ✅ 5.91 ✅
밝은 화면에서 회색 링은 2.58로 기준에 못 미쳤습니다. 이으니까 3.17로 넘어갔어요. 어긋난 색을 맞춘 김에 기준도 통과한 게 아니라, 안 맞추면 통과 못 하는 상태였던 겁니다.
한 가지 덧붙이자면, 들여온 버튼은 반투명 3px 링과 불투명 테두리를 함께 씁니다. 반투명 쪽만 재면 밝은 화면 1.79로 여전히 낮아요. 기준을 넘기는 건 불투명한 테두리 쪽입니다. "링을 이었으니 대비도 통과"라고 뭉뚱그리면 틀립니다.
💡 한 줄 정리
들여온 토큰 중 --ring과 --destructive가 우리 색과 따로 놀고 있었고, :root에서 우리 토큰을 가리키게 이으면서 어두운 화면 쪽 값은 지웠습니다. 포커스 색을 맞추는 일은 취향이 아니라 대비 기준을 넘기는 일이었습니다.
🙋 학생 질문 — "튜터님, 이렇게 이어두면 나중에 다시 add 할 때 또 날아가지 않나요?"
날아갑니다. 정확히는, 다시 add 하면 CLI 가 덮어쓸지 물어봅니다.
지난 시간에 배운 그 흐름이에요. --dry-run으로 미리 보면 "1 file will be overwritten"이 뜨고, --diff로 보면 우리가 고친 줄까지 다 보여주기 때문에, 우리 규약이 두 가지입니다. 첫째, 고친 자리에는 왜 고쳤는지 주석을 남깁니다. 위 코드에도 남아 있죠. 둘째, 다시 받기 전에 반드시 --diff를 먼저 봅니다.
globals.css는 그나마 나은 편이에요. 컴포넌트 파일과 달리 이건 우리가 계속 쓰는 파일이라 add로 통째로 갈아치울 일이 거의 없습니다. 대신 다음에 새 컴포넌트를 받을 때 :root에 이름이 몇 개 더 추가될 수는 있어요. 그때 새로 온 이름 중에 우리 색과 겹치는 게 있는지 확인하는 게 우리 몫입니다.
Step 8: "화면 전체를 훑는다"
마지막 Step입니다. 지금까지는 고칠 곳을 알고 고쳤어요. 이번엔 모르는 것을 찾으러 갑니다. 세 가지를 훑습니다. 이름, 대비, 누를 자리요.
이름 훑기 — 0건이었습니다
먼저 조작할 수 있는 것을 전부 걷어서, 낭독기가 읽어줄 이름이 있는지 봅니다. 이름을 정하는 규칙은 순서가 있어요.
aria-label 이 있으면 → 그것
aria-labelledby 가 있으면 → 그 id 요소의 글자
label[for] 이 가리키면 → 그 라벨의 글자
이미지면 → alt
그 외 → 안에 든 글자
개발자 도구 콘솔에서 이 규칙대로 한 번 훑어봅니다.
조작 요소 26개
이름 없는 것 0개
한 건도 없었습니다. 이건 오늘 갑자기 잘한 게 아니라, 지난 시간에 닫기 버튼이 영어로 읽히고 이름 없는 대화 상자가 만들어지는 걸 겪은 뒤로 계속 챙겨온 결과예요. A-6에서 IconButton이 aria-label을 필수로 받게 만든 것도 여기서 일했습니다.
고칠 게 없다고 그냥 넘어가지는 않았어요. 이 훑기를 코드베이스에 검사로 남겨 뒀습니다. 앞으로 이름 없는 버튼이 하나라도 들어오면 그때 걸립니다.
대비 훑기 — 한 값으로 두 화면을 만족시킬 수 없습니다
색은 눈으로 판단하면 안 됩니다. 특히 자기가 만든 색은요. 우리 빨강을 배경에 대고 재봤습니다.
우리 빨강 #ed4956 밝은 화면 3.69 어두운 화면 5.07
기준이 두 가지예요. 글자는 4.5, 그림은 3.0입니다. 그림에 기준이 낮은 이유는 글자보다 알아보기 쉬워서인데, 우리 빨강이 쓰이는 곳을 나눠 보면 이렇습니다.
좋아요 하트 그림 기준 3.0 → 3.69 ✅
회원가입 오류 글자 글자 기준 4.5 → 3.69 ❌
같은 색인데 한쪽은 통과하고 한쪽은 못 넘습니다. 오류 메시지는 읽어야 하는 글자니까요.
그럼 빨강을 어둡게 하면 될까요. 후보를 몇 개 만들어 재봤습니다.
oklch(64.1% .2 20.5) #ed4956 밝은 3.69 ❌ 어두운 5.07 ✅
oklch(58% .2 20.5) #d73244 밝은 4.74 ✅ 어두운 3.95 ❌
oklch(55% .2 20.5) #cc253c 밝은 5.39 ✅ 어두운 3.48 ❌
oklch(52% .2 20.5) #c11433 밝은 6.15 ✅ 어두운 3.05 ❌
양쪽을 다 넘기는 값이 하나도 없습니다. 밝은 화면을 위해 어둡게 하면 어두운 화면이 무너져요. 당연합니다. 밝은 배경에서 잘 보이려면 어두워야 하고, 어두운 배경에서 잘 보이려면 밝아야 하니까요.
이게 토큰이 화면마다 다른 값을 담는 이유 그 자체입니다. E-2에서 이름 하나에 값을 담는 법을 배웠고, E-4에서 그 이름에 화면마다 다른 값을 담는 법을 배웠죠. 그게 왜 필요한지가 여기서 숫자로 나온 겁니다.
그래서 글자용 빨강을 따로 뒀습니다.
/* apps/web-spa/src/styles/globals.css */
@theme {
--color-danger: oklch(64.1% 0.2 20.5);
/* 위 빨강은 하트 같은 그림용이다(밝은 화면 대비 3.69). 글자에는 4.5 가 필요해서
글자용을 따로 둔다 — 한 값으로는 두 화면을 다 만족시킬 수 없다. */
--color-danger-strong: oklch(58% 0.2 20.5);
}
.dark {
/* brand·danger 는 안 바꾼다 — 브랜드색은 양쪽 화면에서 같아야 한다.
글자용 빨강만 되돌린다. 어두운 판에서는 원래 값이 5.07 로 이미 넉넉하고,
밝은 판용으로 어둡게 만든 값을 그대로 쓰면 3.95 로 오히려 모자란다. */
--color-danger-strong: oklch(64.1% 0.2 20.5);
}
// apps/web-spa/src/components/TextField.tsx
{error && <p className="text-xs text-danger-strong">{error}</p>}
하트는 그대로 --color-danger를 씁니다. 인스타그램의 그 빨강이거든요. 브랜드 색을 대비 때문에 바꿔버리면 브랜드가 아니게 되니까, 글자만 다른 값을 쓰게 갈랐습니다.
여기서 Step 2의 이야기가 한 번 더 나옵니다. 이 새 토큰은 빌드된 CSS의 :root에 남아 있어요. 값으로 쓰이기 때문입니다. 조건으로 쓰이는 --container-2col은 안 남았죠. 같은 @theme 안에 적었는데 결과가 다릅니다.
누를 자리 훑기
마지막으로 각 버튼이 몇 픽셀인지 잽니다. 기준은 24 x 24예요. 손가락으로 누를 수 있는 최소 크기입니다.
잰 값 판정
좋아요 40 x 40 ✅
게시물 메뉴 44 x 44 ✅
밝게 / 어둡게 / 시스템 38·50 x 24 ✅ (딱 걸침)
댓글 삭제 24 x 16 ❌
게시(댓글 제출) 24 x 20 ❌
댓글 입력칸 170 x 20 ❌
더 보기(캡션 안) 44 x 20 ⚠️
셋이 걸렸습니다. 전부 세로가 모자라요. 글자만 있고 위아래 여백이 없어서 글자 높이가 곧 버튼 높이였던 겁니다.
여백을 더해서 고쳤습니다.
// apps/web-spa/src/components/CommentList.tsx (px-1 → p-1)
className="cursor-pointer p-1 text-sm leading-none text-faint hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
// apps/web-spa/src/components/CommentInput.tsx (py-1 추가)
className="flex-1 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
// apps/web-spa/src/components/CommentForm.tsx (py-1 추가)
className="cursor-pointer py-1 text-sm font-semibold text-brand disabled:cursor-default disabled:text-brand/30 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
다시 재면 24 x 24, 170 x 28, 24 x 28입니다.
⚠️ 작다고 전부 위반은 아닙니다
마지막 줄의 "더 보기"는 44 x 20 인데 안 고쳤습니다. 이 버튼은 문단 안에 글자로 섞여 있거든요.
jaehoon 오늘 한강 노을이... 더 보기
~~~~~~~
문단 안에 섞인 글자 버튼
기준에는 예외가 있습니다. 문장 안에 섞여 있는 것은 빠져요. 이유가 분명합니다. 여기에 위아래 여백을 주면 그 줄만 키가 커져서 글줄 간격이 들쭉날쭉해집니다. 읽기가 더 나빠져요.
기준을 외워서 기계적으로 적용하면 이런 걸 놓칩니다. 왜 그 기준이 있는지를 알아야 예외를 알아봅니다.
⚠️ 겹쳐 있던 두 버튼
마지막 하나. 모달이 두 칸으로 갈릴 때 오른쪽 칸 위쪽의 더보기(⋯)와 상자 오른쪽 위의 닫기(X)가 가까웠어요. 화면으로는 그냥 좀 붙어 보이는 정도였습니다. 상자를 재보니 이랬습니다.
닫기 X 좌 828 상 119 우 856 하 147
더보기 ⋯ 좌 800 상 127 우 848 하 175
가로로 828~848 이 겹치고
세로로 127~147 이 겹친다
→ 20 x 20 만큼 두 버튼이 같은 자리를 다툰다
그 안을 누르면 위에 있는 닫기가 먹습니다. 더보기를 누르려던 사람이 창을 닫게 되는 거예요.
이건 Step 4에서 아이콘을 넣으며 재보다가 발견했습니다. 눌러봤으면 몰랐을 겁니다. 그 20px 을 정확히 노려서 누를 일이 잘 없거든요.
고침은 한 줄입니다. 두 칸일 때만 오른쪽 칸의 오른쪽을 비웁니다.
// apps/web-spa/src/components/PostModal.tsx
{/* 두 칸일 때 이 칸의 오른쪽 끝은 상자 오른쪽 위 닫기 버튼과 자리를 다툰다 */}
<div className="flex flex-col gap-2 @3xl:pr-8">
다시 재니 가로 겹침이 0이고 사이가 11px 벌어졌습니다.
💡 한 줄 정리
이름은 지난 시간부터 챙긴 덕에 0건이었고, 대비는 한 값으로 두 화면을 못 만족시킨다는 것이 숫자로 나왔으며, 누를 자리는 셋이 기준에 못 미쳐 넓혔고, 겹쳐 있던 두 버튼은 눌러봐서가 아니라 재봐서 찾았습니다.
🙋 학생 질문 — "튜터님, 이걸 매번 손으로 재야 하나요?"
전부는 아닙니다. 자동으로 잡히는 것과 사람이 봐야 하는 것이 갈려요.
자동으로 잘 잡히는 것은 규칙이 분명한 것들입니다. 이름이 있는지 없는지, 대비가 몇인지, 상자가 몇 픽셀인지요. 이런 건 도구가 훨씬 잘합니다. 브라우저 개발자 도구에 이미 들어 있는 검사 기능도 있고, 자동 검사 도구를 붙일 수도 있어요.
사람이 봐야 하는 것은 판단이 필요한 것들입니다. "더 보기"가 문장 안에 섞인 글자인지 아닌지는 도구가 몰라요. 이름이 있긴 한데 그 이름이 적절한지도 모르고요. 오늘 우리가 만난 겹친 두 버튼도, 도구는 "둘 다 이름 있고 크기도 충분하다" 고 통과시켰을 것이기 때문에, 순서를 이렇게 잡습니다. 자동 검사로 훑어서 명백한 것을 걷어내고, 남은 시간에 사람이 판단이 필요한 곳을 봅니다. 우리도 오늘 그렇게 했어요. 훑는 건 콘솔에서 한 번에 했고, 판단은 하나씩 했습니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 기능이 다 된 화면을 "다 됐다" 고 말할 수 있는 상태로 만들었습니다.
기능은 지난 시간에 이미 다 있었죠. 오늘 바뀐 건 그 위에 올라간 것들입니다. 배치가 놓인 자리를 보고, 아이콘이 문자를 대신하고, 누르면 반응하고, 색과 크기가 기준을 넘습니다.
오늘 배운 핵심 세 가지
💡 하나 — 조건은 창이 아니라 통을 볼 수 있습니다. 같은 한 줄이 카드에서는 32px, 모달에서는 44px 이 됐어요. 창은 답이 하나라서 이걸 못 만듭니다. 컴포넌트가 어디에 놓일지 모른다는 것이 장점이라면, 스타일 조건도 그 장점을 지켜야 합니다.
💡 둘 — 딸려온 것에는 빠진 것도 함께 딸려옵니다. 아이콘 꾸러미는 낭독기에서 감추는 것까지 챙겨줬지만, 움직임 꾸러미는 동작 줄이기를 아예 안 봤습니다. 포커스 색은 우리 것과 이어져 있지도 않았고요. 가져다 쓴다는 건 무엇이 빠졌는지도 안다는 뜻입니다.
💡 셋 — 눈으로 보는 것과 재보는 것은 다릅니다. 카드가 948px 이던 것도, 두 버튼이 20px 겹쳐 있던 것도, 빨간 글씨가 3.69 이던 것도 화면으로는 다 멀쩡해 보였어요. 오늘 고친 것의 절반은 재봐서 찾은 것입니다.
시작할 때 남아 있던 것
지난 시간에 남겨둔 약속들이 어떻게 닫혔는지 봅시다.
| 약속했던 것 | 닫힌 곳 |
|---|---|
| 카드가 자기 폭을 보고 배치를 바꾸는 법 | Step 2 · 3 — 창은 784px 고정인데 피드가 두 열이 됐다 |
| 함께 딸려온 아이콘 꾸러미 | Step 4 · 5 — 문자 셋을 아이콘으로 |
| 누를 때 살짝 반응하는 움직임 | Step 6 — 누르면 줄고, 켤 때 튄다 |
| 눈에 안 보이는 것을 개발자 도구로 훑는 일 | Step 8 — 이름 26개, 대비, 누를 자리 |
| 통을 세우는 줄이 이미 와 있던 것 | Step 1 — 세워졌는데 아무도 안 묻고 있었다 |
다음 시간 예고
여기까지가 화면 이야기입니다. E 카테고리가 오늘로 끝났어요. Tailwind를 처음 만난 날부터 오늘까지, 손으로 지은 클래스가 유틸리티가 되고, 토큰이 생기고, 조건이 붙고, 라이브러리가 들어오고, 마감까지 왔습니다.
다음 시간부터는 다시 React로 돌아가는데, 우리 앱에는 아직 화면이 하나뿐이에요. 피드와 회원가입 폼이 같은 화면에 세로로 쌓여 있죠. 진짜 인스타그램이라면 주소가 갈려야 합니다. 피드는 /, 프로필은 /jaehoon, 게시물은 /p/123처럼요.
주소가 갈리면 생각할 게 많아집니다. 뒤로 가기를 누르면 어디로 가야 할까요. 주소를 복사해서 붙여넣으면 그 화면이 바로 떠야 할 텐데, 그때 필요한 데이터는 누가 언제 불러올까요. 다음 시간에는 화면을 주소로 나누는 일을 시작합니다.
오늘 만든 모달도 그때 다시 봅니다. 지금 우리 모달은 주소가 안 바뀌어요. 열어두고 새로고침하면 사라집니다. 그게 맞는 걸까요, 아닐까요. 다음 시간에 같이 정합니다.
과제
[구현] 아이콘 하나를 더 놓고 이름과 크기를 챙기기
Step 5에서 동작 없는 아이콘은 안 놓는다고 했는데, 동작을 먼저 만들면 놓을 수 있습니다.
- 캡션 아래에 저장(북마크) 아이콘 버튼을 하나 만들어 보세요. 아이콘은
Bookmark입니다. 저장 여부는 컴포넌트 안에서 상태로 들고 있으면 됩니다. - 눌렀을 때와 안 눌렸을 때가 낭독기에서 구분되게 만들어 주세요. Step 5에서 하트에 쓴 방식 그대로면 됩니다.
- 눈으로 보는 구분도 넣어 주세요. 하트가 속을 채운 것처럼요.
- 크기와 왼쪽 끝을 하트와 맞춰 주세요. 개발자 도구에서 두 아이콘의 위치를 재서 확인하고, 숫자를 적어 주세요.
- 마지막으로 이 버튼이 24 x 24를 넘는지 재 주세요.
[구현] 통을 하나 더 세워 모달 안을 갈라보기
지금 모달 안에서 프로필이 44px 로 커지죠. 그 조건이 무엇을 보고 있는지 직접 확인해 보세요.
- 먼저 모달을 넓은 화면에서 열고, 오른쪽 칸이 몇 px 인지와 프로필이 몇 px인지 재 주세요. 두 숫자를 적습니다.
- 그다음 오른쪽 칸을 감싸는
div에@container를 붙여 보세요. 프로필 크기가 어떻게 되나요. - 왜 그렇게 됐는지 한 줄로 적어 주세요. 힌트는 Step 3의 "가장 가까운 통"입니다.
- 이 상태가 우리가 원하는 모습일까요? 원래대로 되돌릴지 그대로 둘지 정하고 이유를 적어 주세요.
[탐구] 재보고 숫자를 적기
각 항목의 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
PostHeader의 더보기 아이콘에aria-label="더보기"를 붙여 보세요. 개발자 도구에서 그svg의 속성이 어떻게 달라지는지 적고, 왜 그런지 말해 주세요.- 아이콘의
size-5 @lg:size-6을 지우고 대신 버튼의text-lg를text-3xl로 바꿔 보세요. 아이콘 크기가 어떻게 되나요. 몇 px인지 재서 적어 주세요. - 운영체제 설정에서 동작 줄이기를 켜고 좋아요를 눌러 보세요. 무엇이 사라지고 무엇이 남는지 적어 주세요. (설정을 못 바꾸는 환경이면 개발자 도구의 렌더링 탭에서 흉내 낼 수 있습니다.)
globals.css에서--ring: var(--color-brand)를 원래 회색 값으로 되돌리고, 모달의 닫기 버튼에 Tab으로 포커스를 줘 보세요. 어떤 색이 보이는지, 우리 다른 버튼과 어떻게 다른지 적어 주세요.
생각해볼 주제
1. 재는 것을 언제까지 사람이 해야 할까요
오늘 우리가 찾은 것들을 떠올려 보세요. 카드가 948px 이던 것, 두 버튼이 20px 겹쳐 있던 것, 빨간 글씨가 3.69 이던 것이요. 전부 사람이 브라우저를 열고 콘솔에 코드를 쳐서 찾았습니다.
이런 확인을 매번 사람이 하면 언젠가는 빠뜨립니다. 반대로 전부 자동으로 만들려고 하면, 만드는 데 드는 시간이 찾는 이득보다 커지는 지점이 옵니다.
어디까지를 자동으로 만들고 어디부터를 사람의 눈에 맡길지, 그 선을 무엇을 기준으로 그을지 생각해 보세요. 팀 규모나 화면 수에 따라 답이 달라질까요?
2. 접근성은 누구를 위한 것일까요
오늘 우리가 한 일 중 상당수는 "화면을 못 보거나 잘 안 보이는 사람"을 위한 것이었습니다. aria-pressed, 대비 4.5, 24px 표적, 동작 줄이기인데, 이 중 몇 가지는 다시 보면 모두를 위한 것이기도 합니다. 24px 표적은 지하철에서 흔들리며 한 손으로 쓰는 사람에게도 필요하고, 대비 4.5는 햇빛 아래에서 화면을 보는 사람에게도 필요해요.
접근성을 "특정 사용자를 위한 추가 작업"으로 볼 때와 "품질의 일부"로 볼 때 팀의 일하는 방식은 어떻게 달라질지, 그리고 그 차이가 실제 결과물에 어떻게 드러날지 생각해 보세요.
3. 남의 코드에 없는 것을 어떻게 알아챌까요
오늘 움직임 꾸러미에 동작 줄이기 처리가 없다는 걸 알았는데, 이건 우리가 "혹시 없나?" 하고 찾아봤기 때문에 안 거예요. 안 찾아봤으면 몰랐을 겁니다.
라이브러리를 쓸 때 우리는 대개 있는 것만 봅니다. 문서에 적힌 기능, 예제 코드, API 목록이요. 없는 것은 문서에 안 적혀 있으니까요.
무엇이 없는지를 알아채려면 무엇이 필요한지, 그리고 매번 모든 라이브러리를 이렇게 뒤질 수는 없다면 어디에 힘을 줘야 할지 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 먼저 스스로 해보신 뒤에 펼쳐서 견줘 보세요.
과제 예시답안
🎯 [과제 1 예시답안] 아이콘 하나를 더 놓고 이름과 크기를 챙기기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 정식 이름 | Bookmark로 불렀는가 (별칭이 아닌 정식 이름을 골랐는지) |
| 읽어줄 이름 | 버튼에 aria-label을 줬는가 (아이콘이 아니라 버튼에) |
| 상태 전달 | aria-pressed로 눌림을 알렸는가 |
| 눈으로 보는 구분 | fill-current로 속을 채웠는가 |
| 크기·정렬 | 하트와 같은 size-6, 같은 p-2로 왼쪽 끝이 맞는가 |
| 누를 자리 | 재봐서 24 x 24를 넘는지 확인했는가 |
| ⚠️ 상태 위치 | 저장 여부를 어디에 뒀는가 (부모가 아니라 이 컴포넌트 안이면 충분) |
풀이 예시
하트 옆에 놓으려면 둘을 한 줄로 세워야 합니다. 지금은 하트 하나뿐이라 감싸는 요소가 없었죠.
// apps/web-spa/src/components/LikeButton.tsx
import { useState } from 'react';
import { Heart, Bookmark } from 'lucide-react';
import { IconButton } from './IconButton';
export function LikeButton({ liked, likeCount, onToggle }: LikeButtonProps) {
// 저장 여부는 아직 서버에 보낼 곳이 없다. 이 컴포넌트 안에서만 든다.
const [saved, setSaved] = useState(false);
return (
<div className="px-1 pt-2">
<div className="flex items-center gap-1">
<IconButton
className="cursor-pointer p-2 transition duration-150 hover:opacity-60 active:scale-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="좋아요"
aria-pressed={liked}
onClick={onToggle}
>
<Heart
className={`size-6 ${
liked ? 'fill-current text-danger animate-in zoom-in-50 duration-200' : ''
}`}
/>
</IconButton>
<IconButton
className="cursor-pointer p-2 transition duration-150 hover:opacity-60 active:scale-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="저장"
aria-pressed={saved}
onClick={() => setSaved((prev) => !prev)}
>
<Bookmark className={`size-6 ${saved ? 'fill-current' : ''}`} />
</IconButton>
</div>
{likeCount > 0 && <p className="px-2 pt-1 pb-1 text-sm font-semibold">좋아요 {likeCount}개</p>}
</div>
);
}
하트가 40 x 40 이었으니 같은 p-2·size-6을 준 북마크도 40 x 40입니다. p-2(8px 씩) 두 겹에 아이콘 24px 을 더한 값이죠. 직접 재서 24 x 24 기준을 넘는지 확인해 주세요.
왼쪽 끝도 확인합니다. 하트가 28px 에서 시작하는데 북마크도 같은 줄에 있으니 자동으로 맞아요. gap-1이 둘 사이를 4px 벌립니다.
자주 나오는 실수
아이콘에 aria-label을 붙이는 것. 친절해 보이지만 반대로 갑니다. 아이콘에 aria-로 시작하는 속성이 붙으면 꾸러미가 자동으로 붙여주던 aria-hidden="true"가 사라지고, 낭독기가 버튼 이름과 아이콘을 둘 다 읽으려 합니다. 이름은 버튼에, 아이콘은 조용히.
fill-current 없이 색만 바꾸는 것. text-brand만 주면 선 색만 바뀌고 속은 비어 있습니다. 하트를 보면 알 수 있듯이 인스타그램의 저장 표시는 속이 찬 모양이에요. fill-current가 채우기 색을 글자색으로 데려옵니다.
저장 여부를 App까지 올리는 것. 올려도 틀리진 않지만 지금은 필요 없습니다. 이 값을 쓰는 곳이 이 버튼 하나뿐이거든요. B-2에서 배운 기준 그대로입니다. 두 곳 이상이 같은 값을 봐야 할 때 올립니다.
💡 튜터의 한마디
이번 과제의 핵심은 "동작을 먼저 만들고 아이콘을 놓는다"는 순서입니다. Step 5에서 비행기와 말풍선을 안 놓은 이유가 그거였죠.
useState 한 줄이 붙는 순간 이 버튼은 진짜 버튼이 됩니다. 서버에 저장되지 않아도, 누르면 상태가 바뀌고 그 상태가 낭독기에도 전달되니까요. 나중에 서버가 생기면 useState를 서버 상태로 바꾸기만 하면 되고, 나머지는 그대로예요.
🎯 [과제 2 예시답안] 통을 하나 더 세워 모달 안을 갈라보기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 재기 | 오른쪽 칸 폭과 프로필 크기를 숫자로 적었는가 |
| 결과 | @container를 붙이자 프로필이 44px 에서 32px 로 줄어드는 것을 확인 |
| 이유 | "가장 가까운 통" 이라는 규칙으로 설명했는가 |
| 판단 | 되돌릴지 둘지 정하고 이유를 댔는가 (정답은 되돌리기) |
| ⚠️ 착각 | 창 크기가 아니라 상자 크기가 기준이라는 것을 헷갈리지 않았는가 |
풀이 예시
먼저 재봅니다. 넓은 화면에서 모달을 열고 개발자 도구 콘솔에서요.
(모달 상자가 864px 이었을 때 잰 값 — 창 크기에 따라 달라집니다)
모달 상자 864px
오른쪽 칸 406px
프로필 44px
칸 폭은 이렇게 나옵니다. 상자 864에서 좌우 여백 32(p-4)를 빼면 832, 거기서 두 칸 사이 20(@3xl:gap-5)을 빼고 반으로 나누면 406이라서, 창이 더 넓어 상자가 896이면 칸은 422가 됩니다. 숫자보다 이 관계를 확인하는 게 핵심입니다.
여기서 이상한 점이 하나 보입니다. 프로필의 조건은 @lg:size-11이고 @lg는 512px 인데, 오른쪽 칸은 그보다 좁습니다. 그런데도 44px 이에요.
이유는 지금 오른쪽 칸이 통이 아니기 때문입니다. 조건이 조상을 거슬러 올라가다 만나는 첫 번째 통은 모달 상자(864px)인데, 864는 512를 넘으니 조건이 켜집니다. 이제 오른쪽 칸을 통으로 만들어 봅니다.
// apps/web-spa/src/components/PostModal.tsx
<div className="@container flex flex-col gap-2 @3xl:pr-8">
다시 재면 프로필이 32px입니다. 이제 조건이 오른쪽 칸(406px)을 보는데, 그 값은 512에 못 미치니 안 켜지는 거죠. 칸이 512를 넘을 만큼 창을 넓히면 다시 44px 로 돌아옵니다.
통 선언 전 가장 가까운 통 = 모달 상자 864px → 512 넘음 → 44px
통 선언 후 가장 가까운 통 = 오른쪽 칸 406px → 512 못 넘음 → 32px
되돌릴까요, 둘까요
되돌립니다. 이유는 "모달에서 프로필이 커 보이는 것"이 우리가 Step 3에서 의도한 모습이기 때문이에요.
모달은 게시물 하나를 크게 보는 화면이라, 카드 목록에서는 작게, 크게 볼 때는 크게가 자연스러운데, 오른쪽 칸을 통으로 만들면 칸이 좁다는 이유로 프로필이 작아집니다. 상자는 큰데 프로필만 작아지는 어정쩡한 모습이 돼요.
다만 이건 판단이지 규칙이 아닙니다. 만약 오른쪽 칸에 들어갈 내용이 훨씬 많아져서 그 칸 자체가 하나의 독립된 영역처럼 동작해야 한다면, 통으로 만드는 게 맞을 수도 있어요.
자주 나오는 실수
창 크기를 바꿔가며 확인하는 것. 창을 넓히면 모달도 커지고 오른쪽 칸도 커져서 어디가 기준인지 구분이 안 됩니다. 확인할 때는 창을 고정하고 요소의 폭만 바꾸세요. 개발자 도구에서 해당 요소에 width를 직접 주면 됩니다.
@container를 잘못된 요소에 붙이는 것. 조건이 붙은 요소 자신이 아니라 그 조상에 붙여야 합니다. 프로필에 직접 붙이면 자기 자신을 재게 돼서 이상한 값이 나와요.
💡 튜터의 한마디
이 실험이 알려주는 건 컨테이너 쿼리는 "누구를 보느냐"를 통 선언으로 정한다는 겁니다. 조건을 적는 쪽은 자기가 무엇을 보게 될지 모릅니다.
이건 강력하면서 위험하기도 해요. 나중에 누가 중간에 @container를 하나 끼워 넣으면 그 아래 모든 조건의 기준이 아무 경고도 없이 조용히 바뀌기 때문에, 통을 세울 때는 "이 상자를 기준으로 삼겠다"는 의도를 주석으로 남겨두는 게 좋습니다.
🎯 [과제 3 예시답안] 재보고 숫자를 적기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 실험 1 | aria-hidden="true"가 사라지는 것을 확인하고 이유를 설명 |
| 실험 2 | 아이콘이 24px 로 고정되고 글자 크기와 무관하다는 것을 숫자로 확인 |
| 실험 3 | 움직임만 사라지고 색·속·눌림 표시는 남는 것을 확인 |
| 실험 4 | 포커스 색이 회색으로 돌아가고 우리 파랑과 다른 것을 확인 |
| ⚠️ 방법 | 개발자 도구에서 클래스를 더할 때의 함정을 알아챘는가 |
실험 1 — 아이콘에 이름을 붙이면
PostHeader의 아이콘에 aria-label="더보기"를 붙이고 개발자 도구에서 그 svg를 보면 이렇게 달라집니다.
붙이기 전 <svg class="lucide lucide-ellipsis size-5 @lg:size-6" aria-hidden="true">
붙인 뒤 <svg class="lucide lucide-ellipsis size-5 @lg:size-6" aria-label="더보기">
↑ aria-hidden 이 사라졌다
이유는 꾸러미가 aria-hidden을 붙이는 조건에 있습니다. 안에 든 것도 없고, aria-로 시작하는 속성이나 role·title도 없을 때만 붙여요. 우리가 aria-label을 준 순간 그 조건이 깨집니다.
꾸러미 입장에서는 합리적입니다. 이름을 준 사람은 "이 그림을 읽어달라"는 뜻이었을 테니 감추면 안 되겠죠. 문제는 우리 의도가 그게 아니었다는 거예요. 이름은 이미 버튼이 들고 있으니까요.
결과적으로 낭독기는 "게시물 메뉴 버튼" 안에서 "더보기"라는 그림을 또 만납니다. 친절하게 하나 더 붙인 게 군더더기가 됐어요.
실험 2 — 크기 클래스를 지우면
size-5 @lg:size-6을 지우고 글자 크기를 바꿔가며 재봤습니다.
버튼 글자 크기 아이콘 크기
14px 24px
30px 24px
60px 24px
전혀 안 바뀝니다. 아이콘은 width="24" height="24"를 자기 속성으로 갖고 오거든요. 글자 크기는 그 속성과 아무 관계가 없습니다.
⚠️ 여기서 함정 하나. 개발자 도구에서 버튼에 text-3xl 클래스를 더해도 글자 크기가 안 바뀝니다. 우리 화면 어디에도 text-3xl을 안 써서 Tailwind가 그 규칙을 아예 안 만들었기 때문이에요. E-1에서 배운 그대로입니다. 소스에 없는 클래스는 CSS에도 없습니다.
이 실험은 소스 파일을 고쳐서 하거나, 개발자 도구에서 클래스 대신 font-size를 직접 주고 해야 합니다. 클래스를 더했는데 아무 일도 안 일어나면 "효과가 없구나"가 아니라 "그 클래스가 만들어졌나?"를 먼저 의심하세요.
실험 3 — 동작 줄이기를 켜면
운영체제 설정을 켜고(또는 개발자 도구의 렌더링 탭에서 prefers-reduced-motion: reduce를 흉내 내고) 좋아요를 눌러 봅니다.
사라지는 것 하트가 튀는 움직임
누를 때 줄어드는 움직임
모달이 커지면서 나타나는 움직임
남는 것 하트 속이 차는 것
하트가 빨개지는 것
aria-pressed 가 true 로 바뀌는 것
좋아요 개수가 올라가는 것
상태를 알려주는 것은 하나도 안 사라지고 움직임만 없어지는데, 이게 우리가 원한 모습입니다.
만약 "좋아요가 됐다"를 오직 튀는 움직임으로만 알리고 있었다면, 이 설정을 켠 사람은 아무것도 못 봤을 겁니다. 움직임을 유일한 신호로 쓰지 않는 것이 중요한 이유예요.
실험 4 — 포커스 색을 되돌리면
--ring: var(--color-brand)를 원래 값 oklch(0.708 0 0)으로 되돌리고, 모달을 열어 Tab으로 닫기 버튼에 포커스를 줍니다.
우리 버튼들(좋아요·더보기·게시…) 파랑 테두리 우리가 붙인 focus-visible:outline-brand
모달 닫기 버튼 회색 테두리·링 들여온 파일이 쓰는 --ring
같은 화면에서 포커스 표시가 두 색이 됩니다. Tab을 계속 눌러 한 바퀴 돌면 색이 중간에 한 번 바뀌는 게 보여요.
보기에 어색한 것도 문제지만 더 중요한 건 대비입니다. 밝은 화면에서 그 회색은 배경과 2.58밖에 차이가 안 나서 기준(3.0)에 못 미쳐요. 포커스가 어디 있는지 잘 안 보인다는 뜻입니다.
💡 튜터의 한마디
네 실험 모두 공통점이 있습니다. 친절하게 하나 더 해주는 것이 오히려 나쁜 경우들이에요.
아이콘에 이름을 더 붙이면 낭독기에 군더더기가 생기고, 없는 클래스를 더 붙이면 아무 일도 안 일어나고, 움직임을 더 넣으면 어떤 사람에게는 증상을 일으킵니다.
확인하는 습관이 그래서 중요합니다. "붙였으니 됐겠지"가 아니라 붙인 뒤에 실제로 무엇이 달라졌는지 개발자 도구에서 보는 거요. 오늘 우리가 여덟 번 한 일이 그겁니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 재는 것을 언제까지 사람이 해야 할까요
문제 상황 요약
오늘 찾은 어긋남 세 가지는 전부 사람이 브라우저를 열고 콘솔에 코드를 쳐서 찾았습니다. 카드가 948px 이던 것, 두 버튼이 20px 겹쳐 있던 것, 빨간 글씨가 3.69 이던 것이요.
매번 사람이 하면 언젠가 빠뜨립니다. 그렇다고 전부 자동으로 만들면, 만드는 비용이 찾는 이득보다 커지는 지점이 옵니다. 어디에 선을 그을지가 문제입니다.
튜터의 가이드 및 해설
먼저 두 종류로 나눕니다. 기준이 숫자로 딱 떨어지는 것과, 사람의 판단이 필요한 것이요.
숫자로 떨어지는 것 판단이 필요한 것
------------------------ ------------------------
이름이 있는가 없는가 그 이름이 적절한가
대비가 몇 대 몇인가 이 색이 브랜드에 맞는가
상자가 몇 px 인가 이 버튼이 문장 안에 섞였는가
클래스 규칙이 만들어졌는가 이 배치가 보기 좋은가
왼쪽은 기계가 사람보다 훨씬 잘합니다. 지치지도 않고 빠뜨리지도 않아요. 오른쪽은 아직 사람 몫입니다.
그다음 비용을 봅니다. 자동 검사를 만드는 비용은 한 번이지만, 그걸 유지하는 비용은 계속 듭니다. 화면이 바뀔 때마다 검사도 따라 바뀌어야 하니까요. 그래서 이런 기준을 씁니다.
- 자주 깨지는가. 사람이 자주 실수하는 곳부터 자동화합니다. 이름 빠뜨리기는 자주 일어나요.
- 깨졌을 때 늦게 발견되는가. 눈에 잘 안 보이는 것일수록 자동화 가치가 큽니다. 색 대비나 이름이 그렇죠.
- 깨졌을 때 비용이 큰가. 결제 버튼이 안 보이는 것과 프로필 사진이 2px 작은 것은 무게가 다릅니다.
현실적인 순서는 이렇습니다. 처음에는 사람이 훑습니다. 훑다가 같은 것을 세 번째 발견하면 그때 자동화합니다. 세 번 나왔다는 건 앞으로도 나온다는 뜻이거든요.
오늘 우리도 그렇게 했습니다. 이름 훑기는 검사로 남겼어요. 앞으로도 계속 확인할 것이고, 확인 방법이 명확하니까요. 반면 "두 버튼이 겹쳤는가"는 검사로 안 만들었습니다. 이번 한 번 나온 것이고, 어떤 버튼 쌍을 검사해야 하는지 정하는 것부터가 판단이거든요.
한 가지 더. 자동 검사는 통과했다고 괜찮다는 뜻이 아닙니다. 오늘 겹친 두 버튼은 어떤 자동 검사로도 안 잡혔을 거예요. 둘 다 이름이 있고 크기도 충분했으니까요. 자동 검사는 바닥을 지키는 것이지 천장을 보장하지 않습니다.
🎯 면접관을 홀리는 핵심 멘트
"자동화는 '이걸 자동으로 만들 수 있나'가 아니라 '같은 실수를 몇 번 봤나'로 정합니다. 세 번째 반복되면 그때 검사로 만들었어요. 다만 자동 검사는 바닥을 지키는 장치라, 통과했다고 품질이 보장되는 건 아니라고 봅니다. 실제로 두 버튼의 클릭 영역이 겹쳐 있던 건 어떤 자동 검사도 못 잡았고, 상자를 직접 재보고 찾았습니다."
🤔 [생각해볼 주제 2] 접근성은 누구를 위한 것일까요
문제 상황 요약
오늘 한 일의 상당수는 화면을 못 보거나 잘 안 보이는 사람을 위한 것이었습니다. aria-pressed, 대비 4.5, 24px 표적, 동작 줄이기요.
이 중 몇 가지는 다시 보면 모두를 위한 것이기도 한데, 이걸 "추가 작업"으로 보느냐 "품질의 일부"로 보느냐에 따라 팀의 일하는 방식이 달라집니다.
튜터의 가이드 및 해설
먼저 사실 확인부터. 오늘 한 것들이 실제로 누구에게 닿는지 보겠습니다.
24px 표적 손 떨림이 있는 사람
지하철에서 흔들리며 한 손으로 쓰는 사람
화면이 작은 기기를 쓰는 사람
손가락이 굵은 사람
대비 4.5 저시력인 사람
햇빛 아래에서 화면을 보는 사람
오래된 저품질 모니터를 쓰는 사람
밤에 밝기를 낮춰놓은 사람
동작 줄이기 전정기관 질환이 있는 사람
편두통이 있는 사람
멀미에 민감한 사람
"특정 사용자를 위한 배려" 라기엔 대상이 너무 넓은 데다, 이 목록의 상당수는 영구적인 상태가 아니라 일시적인 상황이에요. 팔을 다쳐 한 손만 쓰는 두 달, 햇빛 아래 있는 5분처럼요. 누구나 살면서 이 목록에 몇 번씩 들어갑니다.
추가 작업으로 볼 때 vs 품질로 볼 때의 차이는 일정에서 가장 크게 드러납니다.
추가 작업으로 볼 때 품질의 일부로 볼 때
-------------------------- --------------------------
기능을 다 만든 뒤 따로 붙인다 만들면서 같이 챙긴다
일정이 빠듯하면 먼저 잘린다 일정이 빠듯해도 안 잘린다
(버그를 안 고치고 출시하지 않듯이)
담당자가 따로 있다 만든 사람이 챙긴다
"했나요?" 라고 물어봐야 안다 안 하면 검사에서 걸린다
오늘 우리 경험이 좋은 예입니다. 좋아요 버튼을 아이콘으로 바꾸면서 aria-pressed를 함께 넣었죠. 이건 "나중에 접근성 작업할 때 붙이자"가 아니었어요. 버튼 이름이 안 바뀌게 만든 그 결정 자체가 상태 전달 방법을 바꾸라고 요구한 것입니다. 붙일 수밖에 없었어요.
반대로 나중에 붙이려 했다면 어땠을까요. 화면은 이미 잘 돌아가고 있으니 급하지 않아 보이고, 다음 기능이 밀려오고, 그렇게 계속 밀립니다. 그러다 사용자 문의가 오면 그때 부랴부랴 고치는데, 그때는 화면이 열 배 커져 있어요.
한 가지 균형도 말씀드립니다. 품질의 일부로 본다고 해서 모든 항목을 100% 채워야 한다는 뜻은 아닙니다. 오늘 우리도 "더 보기" 버튼은 안 고쳤어요. 기준에 예외가 있었고, 고치면 오히려 읽기가 나빠지니까요. 기준을 아는 것과 기계적으로 적용하는 것은 다릅니다.
🎯 면접관을 홀리는 핵심 멘트
"접근성을 나중에 붙이는 작업으로 두면 일정이 빠듯할 때 제일 먼저 잘립니다. 그래서 만들 때 같이 챙기는 쪽으로 잡았어요. 실제로 좋아요 버튼을 아이콘으로 바꾸니까 이름이 상태를 못 전하게 됐고,
aria-pressed를 넣는 건 선택이 아니라 그 설계가 요구한 결과였습니다. 다만 기준을 기계적으로 적용하진 않습니다. 문장 안에 섞인 버튼은 표적 크기 예외라 안 키웠어요."
🤔 [생각해볼 주제 3] 남의 코드에 없는 것을 어떻게 알아챌까요
문제 상황 요약
오늘 움직임 꾸러미에 동작 줄이기 처리가 없다는 걸 알았습니다. 이건 "혹시 없나?" 하고 찾아봤기 때문에 안 거예요.
라이브러리를 쓸 때 우리는 대개 있는 것만 봅니다. 문서에 적힌 기능, 예제 코드, API 목록이요. 없는 것은 문서에 안 적혀 있으니까요.
튜터의 가이드 및 해설
왜 어려운지부터 짚습니다. 문서는 파는 사람이 씁니다. 잘하는 것을 앞에 놓고 못하는 것은 안 적어서, 거짓말은 아니지만 균형이 맞지도 않습니다. "문서를 꼼꼼히 읽자"로는 그래서 안 풀립니다. 안 적힌 것을 읽을 수는 없으니까요.
그럼 무엇으로 알아챌까요. 세 가지가 있습니다.
첫째, 목록을 들고 갑니다. 무엇을 확인할지 미리 정해두는 거죠. 우리 팀의 목록은 이런 식일 수 있습니다.
이 라이브러리는
- 키보드만으로 조작되는가
- 낭독기가 읽을 이름을 챙기는가
- 동작 줄이기·다크모드 같은 운영체제 설정을 보는가
- 한국어 같은 비영어 환경을 고려하는가
- 우리 디자인 토큰과 이어붙일 구멍이 있는가
목록이 있으면 "없는 것"을 찾는 일이 "있는 것을 확인하는 일"로 바뀝니다. 훨씬 쉬워져요. 오늘 우리가 동작 줄이기를 찾아본 것도, E-4에서 다크모드로 운영체제 설정을 다뤄본 경험이 목록에 한 줄을 남겼기 때문입니다.
둘째, 소스를 검색합니다. 문서보다 소스가 정직해요. node_modules 안의 파일을 열어서 찾는 말이 있는지 세어보면 됩니다. 오늘 한 게 정확히 그거였는데, 두 파일에서 한 단어를 세는 데 1분도 안 걸렸습니다.
셋째, 우선순위를 둡니다. 모든 라이브러리를 이렇게 뒤질 수는 없습니다. 그래서 이런 것부터 봅니다.
- 사용자에게 직접 닿는 것. 모달·드롭다운·달력처럼 사용자가 직접 조작하는 것이요. 날짜 계산 도구 같은 건 접근성 이슈가 없습니다.
- 바꾸기 어려운 것. 화면 전체에 퍼지는 것일수록 나중에 걷어내기 어렵습니다.
- 우리가 대신 책임지게 되는 것. 지난 시간에 이야기했죠. 모달의 접근성은 이제 우리가 이해하지 못한 코드가 책임집니다. 그런 자리는 미리 봐야 해요.
마지막으로. 없는 걸 발견했다고 그 라이브러리를 버릴 필요는 없습니다. 오늘 우리도 안 버렸고, 없다는 걸 알고 우리가 채운 것뿐입니다. 다섯 줄이면 됐죠. 무서운 건 없다는 걸 모르는 상태로 쓰는 겁니다.
🎯 면접관을 홀리는 핵심 멘트
"문서는 파는 쪽이 쓰기 때문에 없는 기능은 안 적혀 있습니다. 그래서 문서를 읽는 대신 확인 목록을 들고 소스를 검색합니다. 실제로 애니메이션 라이브러리에서
prefers-reduced-motion을 세어보니 0건이었어요. 라이브러리를 버리진 않았고, 없다는 걸 알았으니 우리 CSS 다섯 줄로 채웠습니다. 위험한 건 기능이 없는 게 아니라 없는 줄 모르고 쓰는 겁니다."