문서 읽는 데 55분 · E3

E-3: Tailwind 변형 — 유틸리티 앞에 조건을 붙인다

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

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

안녕하세요, 홍순구입니다. 지난 시간에 우리 색 아홉 개에 이름을 주고 피드와 댓글과 폼을 그 이름으로 전부 다시 그렸는데, 딱 두 군데는 아직 마무리를 못 지어서 globals.css를 열면 손으로 쓴 규칙이 딱 두 줄 남아 있어요.

CSS
/* apps/web-spa/src/styles/globals.css */
.comment-submit:disabled {
  color: #b2dffc;
  cursor: default;
}

.comment-remove:hover {
  color: #262626;
}

이 둘만 못 옮긴 이유가 있었는데, 유틸리티는 "이 요소는 파란 글자다"라고 말할 수는 있어도 "잠겼을 때만 흐린 파랑이다" 라고는 말할 방법이 없었거든요. 조건을 붙일 문법을 아직 안 배웠으니까요.

오늘 그 문법을 배우는데, 조건은 상태만이 아니라 화면 크기도 조건이 됩니다.

텍스트
 오늘의 여정

 Step 1     "잠겼을 때만" 을 어떻게 말할까
    │
 Step 2     마우스를 올렸을 때, 그리고 마우스가 없는 기기
    │
 Step 3     키보드로 쓰는 사람에게만 보이는 것
    │
 Step 4     화면 크기도 조건이 된다
    │
 Step 5     피드를 두 열로
    
 피드와 댓글에서 손 이름이 사라지고, 화면 크기에 따라 배치가 달라진다

💡 오늘 수업의 핵심 — "유틸리티 앞에 조건을 붙이면, 그 조건일 때만 걸리는 규칙이 생긴다"

text-brand는 항상 걸리지만 disabled:text-brand는 잠겼을 때만 걸려요. 유틸리티 이름 앞에 조건:을 붙이면 Tailwind가 그 조건이 담긴 CSS 규칙을 대신 만들어 줍니다.

🎯 학습 목표

  • 상태 변형(disabled:·hover:·focus-visible:)으로 의사클래스 규칙을 유틸리티로 옮길 수 있다
  • 변형이 만드는 CSS 규칙의 모양을 빌드된 파일에서 찾아, 왜 그 규칙이 이기고 지는지 설명할 수 있다
  • 반응형 변형이 왜 "그 크기 이상"을 뜻하는지 알고, 좁은 화면을 기준으로 먼저 쓸 수 있다
  • 자바스크립트 없이 화면 크기에 따라 배치를 바꿀 수 있다

Step 1: "잠겼을 때만"을 어떻게 말할까

남은 두 줄 중 첫 줄부터 봅시다. 댓글 입력칸이 비어 있으면 게시 버튼이 잠기는데, 그때 글자색이 흐려집니다.

지금은 이렇게 되어 있습니다

버튼은 손으로 지은 이름을 아직 달고 있어요.

tsx
// apps/web-spa/src/components/CommentForm.tsx
<Button
  className="comment-submit cursor-pointer text-sm font-semibold text-brand"
  type="submit"
  disabled={isEmpty}
>
  게시
</Button>

comment-submit이 유틸리티 사이에 끼어 있죠. 지난 시간에 다른 이름은 전부 걷어냈는데 이것만 남겼습니다. .comment-submit:disabled 규칙이 붙을 데가 필요했거든요.

유틸리티 앞에 조건을 붙입니다

문법은 아주 짧은데, 콜론 하나예요.

텍스트
disabled:cursor-default
└──┬───┘ └─────┬──────┘
  조건       유틸리티

"잠겼을 때 커서를 기본값으로"라는 뜻입니다. cursor-default는 원래 있던 유틸리티고, 앞에 disabled:를 붙인 것뿐이에요.

색도 같은 방식으로 갑니다. 그런데 여기서 잠깐 멈춰야 해요.

#b2dffc를 무엇으로 옮길까

손 규칙에 적힌 색은 #b2dffc이지만 우리 토큰 아홉 개 중에는 이런 색이 없어요.

이 색이 어디서 왔는지 생각해 봅시다. 잠긴 버튼은 원래 파랑(#0095f6)을 흐리게 만든 것이니까, 누군가 파랑을 흰 배경 위에 얇게 얹은 결과를 눈으로 골라 hex로 적어둔 거고, 우리도 흐리게 만들면 됩니다. Tailwind는 색 유틸리티 뒤에 슬래시로 진하기를 적을 수 있어요.

텍스트
text-brand/30
         └┬┘
        30% 만 얹는다

정말 같은 색이 되는지 재봤습니다. 흰 배경 위에 칠하고 화면에 찍힌 픽셀을 읽어봤어요.

흰 배경 위 픽셀
손으로 적은 #b2dffc 178, 223, 252
text-brand/30 179, 223, 252

⚠️ 빨강이 1 다릅니다. 눈으로는 절대 구분 못 하지만, "완전히 같다" 고 말하면 거짓입니다. 파랑을 30% 얹으면 빨강 값이 178.5가 나오는데 브라우저가 올림하기 때문이에요.

1 픽셀보다 중요한 차이

진짜 차이는 다른 데 있는데, 두 색을 어두운 배경 위에 얹어 보면 이렇게 갈려요.

배경 #b2dffc text-brand/30
흰색 #ffffff 178, 223, 252 179, 223, 252
어두운 회색 #121212 178, 223, 252 13, 57, 86

#b2dffc는 흰 배경을 전제로 고른 값이라 배경이 어두워져도 그대로 밝게 뜨지만, 알파로 적으면 뒤에 무엇이 오든 그것에 섞여요.

지금은 배경이 항상 흰색이라 둘 다 맞아 보입니다. 언제 갈리는지는 다음 시간에 확인합니다.

옮기고 손 규칙을 지웁니다

tsx
// apps/web-spa/src/components/CommentForm.tsx
<Button
  className="cursor-pointer text-sm font-semibold text-brand disabled:cursor-default disabled:text-brand/30"
  type="submit"
  disabled={isEmpty}
>
  게시
</Button>

comment-submit이 사라졌습니다. 이제 globals.css에서 .comment-submit:disabled 블록을 지워도 됩니다.

무엇이 만들어졌나

빌드된 CSS를 열어 보면 이렇게 들어가 있어요.

CSS
.disabled\:cursor-default:disabled { cursor: default }

클래스 이름 안에 콜론이 그대로 들어가고(백슬래시는 CSS가 콜론을 글자로 읽게 하는 표시입니다), 뒤에 :disabled가 붙었습니다. 우리가 손으로 쓰던 것과 똑같이 생겼는데, 다만 우리가 이름을 짓지 않았을 뿐이에요.

잠기면 정말 커서가 바뀌나

여기서 한 가지 걸리는 게 있는데, 버튼에는 cursor-pointer도 붙어 있어요. 잠기면 disabled:cursor-default가 붙지만, cursor-pointer도 여전히 붙어 있습니다. 둘 중 뭐가 이길까요.

실제로 재보니 잠긴 상태에서 커서는 default였는데, 변형 쪽이 이겼어요.

이유는 두 규칙의 무게가 다르기 때문입니다. 나란히 놓고 보면 분명해요.

텍스트
 .cursor-pointer                          클래스 하나
 .disabled\:cursor-default:disabled       클래스 하나 + 조건 하나

CSS는 조건을 하나 더 단 쪽을 더 구체적인 규칙으로 보는데, 더 구체적인 쪽이 이겨요. 파일 안에서 누가 먼저 나오는지는 상관이 없습니다.

일부러 순서를 뒤집어 확인해 볼 수도 있는데, 조건이 붙은 규칙을 먼저 쓰고 조건 없는 규칙을 나중에 써도 여전히 조건이 붙은 쪽이 이깁니다.

⚠️ 이 이야기는 상태 변형에만 해당합니다. 화면 크기 조건은 무게를 더하지 않아서 사정이 다른데, 그건 Step 4에서 봅니다.

지난 시간에도 비슷한 걸 봤죠. 그때는 손으로 쓴 .comment-submit:disabledtext-brand 유틸리티를 이겼는데, 이긴 이유는 또 달라요. 그때는 손 CSS가 유틸리티 묶음 바깥에 있어서였어요. 묶음 밖에 있는 스타일은 무게를 따지기도 전에 이깁니다.

💡 한 줄 정리

유틸리티 앞에 disabled:를 붙이면 Tailwind가 :disabled가 달린 규칙을 대신 만들어 줍니다. 조건이 하나 더 달린 만큼 규칙이 더 구체적이라, 같은 속성을 다투면 순서와 관계없이 변형 쪽이 이깁니다.

🙋 학생 질문 — "text-brand/30 말고 --color-brand-disabled 토큰을 새로 만들면 안 되나요?"

만들 수 있고, 실제로 그렇게 하는 팀도 많습니다. 다만 기준을 세워두는 게 좋아요.

지난 시간에 세운 기준을 떠올려 보세요. 이름을 줄지는 몇 번 반복되는가로 판단했습니다. 잠긴 버튼 색은 지금 앱 전체에서 한 번 쓰입니다. 한 번 쓰는 값에 이름을 주면 팔레트만 길어져요.

text-brand/30에는 이름으로는 못 얻는 성질도 하나 있는데, 앞에서 본 것처럼 배경을 타고 섞이거든요. 새 토큰으로 hex를 하나 더 만들면 그 값도 흰 배경 전용이 됩니다. 잠긴 버튼이 여러 배경 위에 놓일 예정이라면 알파 쪽이 덜 고생합니다.

반대로 잠긴 색이 앱 곳곳에 열 번쯤 나오고 디자이너가 정확한 값을 지정했다면, 그때는 토큰이 맞습니다.


Step 2: 마우스를 올렸을 때, 그리고 마우스가 없는 기기

남은 한 줄을 마저 옮깁시다. 댓글 옆 × 버튼에 마우스를 올리면 글자가 진해지는 규칙이에요.

이번엔 색이 정확히 맞습니다

CSS
.comment-remove:hover {
  color: #262626;
}

#262626은 지난 시간에 --color-ink라는 이름을 준 바로 그 값이라서, 옮기기가 간단해요.

tsx
// apps/web-spa/src/components/CommentList.tsx
<IconButton
  className="cursor-pointer px-1 text-sm leading-none text-muted hover:text-ink"
  aria-label="댓글 삭제"
  onClick={() => onRemove(comment.id)}
>
  ×
</IconButton>

실제로 마우스를 올려서 재봤습니다.

픽셀
평소 (text-muted) 142, 142, 142
마우스 올렸을 때 (hover:text-ink) 38, 38, 38
옛 손 규칙이 쓰던 #262626 38, 38, 38

Step 1과 달리 여기는 어긋남이 없는데, 토큰이 그 값을 정확히 옮겨둔 덕분이에요.

피드와 댓글에서 손 이름이 사라졌습니다

comment-removeclassName에서 사라져서 이제 globals.css에서 두 번째 블록도 지웁니다.

지난 시간 끝과 견줘 보면 이렇습니다.

E-2 끝 지금
globals.css 줄 수 167 158
손으로 쓴 규칙 22 20

지지난 시간을 떠올려 보세요. Avatar 하나를 옮기면서 시작했죠. 게시물 카드와 댓글 구역에서는 그 일이 여기서 닫힙니다.

⚠️ 다만 20개가 남았다는 걸 흘려보내지 마세요. 대부분은 화면에 안 나오는 교습용 데모지만, 네 개는 지금 화면에 있습니다. 회원가입 폼 맨 위 "프로필 사진 고르기" 버튼이 아직 손 클래스를 달고 있는데, 지난 시간 과제로 넘긴 것이라 오늘도 그대로 둡니다. 이 버튼은 Step 3에서 다시 만납니다.

⚠️ 그런데 hover:는 그냥 :hover가 아닙니다

빌드된 CSS에서 방금 만든 규칙을 찾아보면 예상과 다르게 생겼습니다.

CSS
@media (hover:hover) {
  .hover\:text-ink:hover { color: var(--color-ink) }
}

:hover만 붙은 게 아니라 미디어 쿼리로 한 겹 감싸져 있어요. Step 1의 disabled:에는 이런 게 없었습니다.

(hover: hover)는 "이 기기에 마우스처럼 올려둘 수 있는 입력 장치가 있는가"를 묻는 조건입니다. 데스크톱은 참이고, 손가락만 쓰는 기기는 거짓이에요.

왜 감쌌을까

터치 화면에서 손가락으로 버튼을 누르면 브라우저가 :hover를 잠깐 켰다가 손을 떼도 그대로 남겨두는 일이 있는데, 다른 데를 눌러야 풀려요. 눌렀던 버튼이 계속 진하게 남아 있는 화면, 한 번쯤 보셨을 겁니다.

Tailwind는 hover:를 아예 마우스가 있는 기기에서만 걸리게 해서 이 문제를 피합니다.

조건이 어긋난 미디어 규칙이 정말 죽는지 직접 확인해 볼 수 있어요. 개발자 도구 콘솔에서 규칙 두 개를 심어 보면 됩니다.

CSS
#probe { color: rgb(1, 2, 3) }
@media (hover: hover) { #probe { color: rgb(11, 22, 33) } }
@media (hover: none)  { #probe { color: rgb(99, 88, 77) } }

마우스가 있는 컴퓨터에서 재보면 rgb(11, 22, 33)이 나옵니다. (hover: none) 쪽은 조건이 어긋나서 아예 안 걸리고, 기본값 rgb(1, 2, 3)이 그대로 남아요.

여기서 중요한 결론이 하나 나옵니다. 마우스를 올려야만 알 수 있는 정보는 터치 기기에서 영영 안 보입니다. 색이 조금 진해지는 정도는 괜찮지만, "여기를 누를 수 있어요"를 hover로만 알려주면 휴대폰 사용자는 그걸 알 방법이 없습니다.

💡 한 줄 정리

hover::hover 규칙을 만들면서 @media (hover: hover)로 감쌉니다. 마우스가 없는 기기에서는 통째로 걸리지 않으니, hover로만 전달되는 정보를 만들면 안 됩니다.

🙋 학생 질문 — "휴대폰에서도 hover가 걸리게 하려면요?"

v4는 설정 파일이 없으니 옵션 하나로 끄는 방법은 없습니다. 대신 hover:라는 변형 자체를 우리가 다시 정의해서 감싸기를 없앨 수는 있어요. 변형을 손수 만드는 문법이 있는데, 그건 다음 시간에 다른 용도로 처음 만나고요.

그 전에 한 번 여쭤보고 싶은 게 있습니다. 휴대폰에서 hover가 걸리길 원하는 이유가 무엇인가요. 대부분은 "눌렀을 때 반응이 보였으면 좋겠다"입니다.

그거라면 hover가 아니라 active:가 맞습니다. active:는 누르고 있는 동안을 뜻하고, 터치에서도 마우스에서도 걸리는데, "눌린 느낌"은 active:, "가리키고 있는 느낌"은 hover:로 갈라 쓰는 게 두 기기 모두에서 자연스럽습니다.


Step 3: 키보드로 쓰는 사람에게만 보이는 것

상태 변형을 하나 더 봅시다. 이번엔 우리가 아직 안 쓰고 있던 것이에요.

먼저 지금 상태를 확인합시다

키보드 Tab 키를 눌러 화면 요소 사이를 옮겨 다녀 보세요. 지금 어디에 있는지 파란 테두리로 표시됩니다.

이건 우리가 만든 게 아닙니다. 브라우저가 원래 그려주는 거예요. 재보면 이렇게 나옵니다.

텍스트
outline: rgb(0, 95, 204) auto

⚠️ 한 가지 짚고 갑시다. Tailwind의 기본 정리(Preflight)는 버튼의 테두리와 배경을 지우지만 이 테두리는 건드리지 않습니다. Preflight가 버튼의 배경과 테두리를 지웠던 것과는 다른 이야기예요. 우리가 뺏은 게 아니라 원래 있는 겁니다.

그런데 많은 사람이 이걸 지웁니다

파란 테두리가 디자인과 안 어울린다는 말이 나오면, 개발자가 흔히 이렇게 씁니다.

CSS
button { outline: none }

이 한 줄로 마우스 쓰는 사람의 불만은 사라지지만, 키보드만 쓰는 사람은 자기가 어디 있는지 영영 알 수 없게 됩니다. 화면을 못 보는 분만 키보드를 쓰는 게 아니에요. 손을 다쳐서, 마우스가 고장 나서, 그냥 빠르니까 쓰는 사람도 많습니다.

focus:는 왜 답이 아닐까

"그럼 지우지 말고 우리 색으로 바꾸면 되겠네요"가 자연스러운 다음 생각이지만, 조건을 focus:로 쓰면 문제가 그대로 남아요.

같은 버튼에 조건만 바꿔가며 세 번 재봤습니다.

조건 어떻게 조작했나 테두리
focus: 마우스로 클릭 떴다
focus-visible: 마우스로 클릭 안 떴다
focus-visible: Tab 키로 이동 떴다

focus:는 마우스로 눌러도 테두리를 띄웁니다. 버튼을 누를 때마다 테두리가 남으니 결국 또 지우고 싶어져요. 이게 outline: none이 그렇게 흔한 이유입니다.

focus-visible:은 브라우저가 조작 방식을 보고 갈라줍니다. 마우스로 눌렀으면 안 띄우고, 키보드로 왔으면 띄웁니다.

⚠️ 다만 "키보드일 때만"이라고 외우면 틀립니다

댓글 입력칸을 마우스로 클릭하고 재보면 테두리가 뜹니다. 버튼과 반대예요.

요소 마우스로 클릭했을 때 focus-visible
버튼 안 켜진다
텍스트 입력칸 켜진다

이유를 생각해 보면 당연합니다. 글자를 입력받는 칸은 커서를 어디에 뒀는지 보여야 하니까요. 마우스로 눌렀다고 표시를 감추면 어디에 타이핑되는지 알 수 없습니다.

그래서 focus-visible:의 뜻은 "키보드일 때"가 아니라 "이 사용자에게 지금 표시가 필요한가를 브라우저가 판단했을 때"입니다.

우리 색으로 바꿔 답니다

세 개를 함께 씁니다. 두께, 요소에서 띄울 간격, 색이에요.

tsx
// apps/web-spa/src/components/CommentInput.tsx
<input
  className="flex-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
  ref={ref}
  value={value}
  onChange={onChange}
  placeholder="댓글 달기..."
  aria-label="댓글 입력"
/>

같은 문자열을 여덟 곳에 똑같이 붙였습니다.

컴포넌트 요소
LikeButton 좋아요
PostBody 더 보기
PostHeader 게시물 메뉴
CommentInput 댓글 입력칸
CommentForm 게시
CommentList 댓글 삭제
TextField 회원가입 입력칸
SignUpForm 가입하기

재보면 테두리가 2px 굵기에 2px 간격, 색은 브랜드 파랑(0, 149, 246)으로 정확히 나옵니다.

⚠️ 한 곳을 빠뜨리면 어떻게 되는지 지금 볼 수 있습니다

여덟 곳이라고 했는데, 이 화면에서 Tab은 아홉 번 멈춥니다. Step 2에서 말한 그 버튼이 빠졌어요.

페이지 맨 위에서 Tab을 한 번 눌러 보세요. 제일 먼저 걸리는 "프로필 사진 고르기" 버튼만 브라우저 기본 파란 테두리로 뜹니다. 나머지 여덟 곳은 우리 브랜드 색이고요.

표시가 사라진 게 아니라 혼자 다른 모양인 겁니다. 지난 시간 과제로 남겨둔 컴포넌트라 오늘 손대지 않지만, 한 곳을 빠뜨리면 화면이 어떻게 되는지 직접 보여주는 셈이 됐어요.

⚠️ 한 곳은 아슬아슬합니다

가입하기 버튼만 사정이 다릅니다. 배경이 브랜드 파랑인데 테두리도 브랜드 파랑이라 둘이 같은 색이거든요.

그래도 화면에서 보이기는 합니다. outline-offset-2가 버튼과 테두리 사이에 2px 흰 틈을 만들어 주거든요. 테두리는 그 틈 바깥에 그려지니 양옆이 모두 흰색이고, 그래서 눈에는 들어옵니다.

문제는 틈 하나에 통째로 기대고 있다는 겁니다. 간격을 0으로 되돌리면 테두리가 버튼과 붙어 같은 색끼리 만나고, 그 순간 사라져요. 이 버튼을 다른 배경 위로 옮겨도 마찬가지입니다.

색을 잘 골랐으면 틈이 있든 없든 보입니다. 이건 과제로 남겨둘게요.

💡 한 줄 정리

브라우저는 원래 포커스 테두리를 그려줍니다. 그걸 지우지 말고 focus-visible:로 바꿔 다세요. focus:로 쓰면 마우스 클릭에도 떠서 결국 지우고 싶어집니다.

🙋 학생 질문 — "테두리 대신 그림자로 표시하면 안 되나요?"

됩니다. focus-visible:ring-2 같은 걸 쓰면 그림자로 테두리 비슷한 걸 그려요. 실무에서도 많이 씁니다.

차이가 하나 있습니다. outline은 요소의 크기 계산에 안 들어가서 테두리가 생겨도 옆 요소가 안 밀립니다. 그림자도 마찬가지라 이 둘은 안전한데, focus-visible:border-2처럼 테두리 속성으로 하면 위험합니다. 포커스가 갈 때마다 요소가 2px 커져서 주변이 들썩거려요.

또 하나. 운영체제에 "색 대비 높이기" 설정을 켠 사용자에게는 그림자가 안 보이는 경우가 있습니다. outline은 그 설정에서도 살아남고요. 둘 다 쓰는 팀도 있습니다.


Step 4: 화면 크기도 조건이 된다

여기까지가 상태였습니다. 이제 조건의 다른 종류를 봅시다.

미뤄뒀던 숙제

지지난 시간 과제에서 이런 실험을 하셨죠. 아무 컴포넌트에 const container = 1;이라고 변수를 하나 만들고 빌드했더니, 쓰지도 않은 .container 클래스가 CSS에 생겼습니다.

그때 개수가 이상했어요. .p-4는 하나 나왔는데 .container여섯 개 였습니다. 왜 여섯인지 오늘 답이 나옵니다. 빌드된 CSS에서 전부 뽑아 보면 이렇습니다.

CSS
.container { width: 100% }
@media (width>=40rem) { .container { max-width: 40rem } }
@media (width>=48rem) { .container { max-width: 48rem } }
@media (width>=64rem) { .container { max-width: 64rem } }
@media (width>=80rem) { .container { max-width: 80rem } }
@media (width>=96rem) { .container { max-width: 96rem } }

기본 규칙 하나에 화면 크기별 규칙 다섯 개. 합쳐서 여섯입니다. 유틸리티 하나가 규칙 하나라는 보장이 없다는 게 이런 뜻이었어요.

다섯 개의 기준 크기

여기 나온 다섯 개가 Tailwind가 미리 정해둔 기준 크기입니다. 각각 이름이 있어요.

이름 크기 픽셀로 대략
sm 40rem 640px 휴대폰 세로를 벗어난 크기
md 48rem 768px 태블릿 세로
lg 64rem 1024px 태블릿 가로·작은 노트북
xl 80rem 1280px 노트북
2xl 96rem 1536px 큰 모니터

쓰는 법은 상태 변형과 똑같습니다. 앞에 붙이면 돼요.

텍스트
sm:px-4
└┬┘ └─┬─┘
조건  유틸리티

⚠️ sm:은 "작은 화면"이 아닙니다

이름 때문에 가장 많이 헷갈리는 부분입니다. sm:px-4는 "작은 화면에서 좌우 여백"이 아니라 "640px 이상에서 좌우 여백"입니다.

위 CSS를 다시 보세요. @media (width>=40rem) 이죠. 크거나 같을 때예요.

그래서 순서가 이렇게 됩니다.

텍스트
 조건 없는 유틸리티        모든 화면에 걸린다  (= 제일 좁은 화면 기준)
        │
        ├── sm:  640px 이상에서 덮어쓴다
        │
        ├── md:  768px 이상에서 덮어쓴다
        │
        └── lg: 1024px 이상에서 덮어쓴다

좁은 화면을 기본으로 쓰고, 넓어질수록 조건을 붙여 덮어쓰는 겁니다. 이 순서를 좁은 쪽부터 쓴다고 해서 mobile-first라고 불러요.

반대 방향도 있습니다. max-sm:은 "640px 미만에서만"을 뜻해요. 다만 기본으로는 mobile-first를 쓰고, 좁은 화면에서만 예외를 둘 때 max-를 꺼내는 편이 규칙 수가 적습니다.

⚠️ 쓴 순서는 아무 상관이 없습니다

여기서 학생분들이 자주 하는 착각이 하나 있어요. className에 적은 순서가 우선순위를 정한다고 생각하는 겁니다.

확인해 봤습니다. 일부러 뒤집어서 넓은 조건을 먼저 적고 좁은 조건을 나중에 적어도, 빌드된 CSS에서는 항상 같은 순서로 나옵니다.

텍스트
 CSS 파일에 나오는 순서 (앞에서부터)

 조건 없는 유틸리티
    │
 상태 변형 (hover: focus-visible: disabled: 등)
    │
 max-sm: 처럼 위쪽을 자르는 조건
    │
 sm:  md:  lg:  xl:  2xl:   (좁은 것부터 넓은 것 순)
    │
 (하나 더 있는데 다음 시간에 만납니다)

여기서 Step 1과 갈리는데, 상태 변형은 조건이 규칙을 더 구체적으로 만들어서 무게로 이겼지만 화면 크기 조건은 무게를 더하지 않습니다. sm:p-2p-2는 무게가 똑같아요.

무게가 같으면 반응형끼리는 순서가 유일한 기준이 되어, 뒤에 나온 것이 이깁니다. lg:는 항상 sm:을 이기고, className을 어떻게 늘어놓든 결과가 같습니다.

⚠️ 다만 대괄호로 직접 적은 기준 크기는 이 줄에 안 섭니다. min-[900px]:은 900px 이라 sm:(640px)보다 넓은데도 CSS에서는 sm:보다 앞에 나와서, 둘이 부딪히면 sm:이 이깁니다. 크기 순서대로일 거라 믿으면 틀리는 부분입니다.

💡 한 줄 정리

반응형 변형은 "그 크기 이상"을 뜻합니다. 조건 없는 유틸리티가 가장 좁은 화면을 맡고, 넓어질 때마다 조건을 붙여 덮어씁니다. 화면 크기 조건은 무게를 안 더하므로, 우선순위는 className에 쓴 순서가 아니라 Tailwind가 정한 출력 순서로 갈립니다.

🙋 학생 질문 — "왜 하필 640px 인가요? 요즘 휴대폰은 더 큰데요."

휴대폰 화면이 커진 건 맞지만, 브라우저가 보는 폭은 기기 폭과 다릅니다.

요즘 휴대폰은 화면 한 점을 여러 개의 물리 점으로 그려서, 기기 사양에 1170px 이라고 적혀 있어도 CSS가 보는 폭은 390px 인 식이에요. 아주 큰 휴대폰도 대체로 430px 언저리입니다.

즉 640px 은 "세로로 든 휴대폰은 전부 아래"를 가르는 선입니다. 지금도 유효해요. 가로로 눕히면 그보다 넓어져서 위로 올라갑니다.

기준 크기는 바꿀 수도 있어서, 우리 화면에 맞는 선이 따로 있다면 우리가 정하면 돼요. 그 방법은 다음 시간에 다룹니다.


Step 5: 피드를 두 열로

배운 걸 실제 화면에 적용해 봅시다. 지금 우리 앱은 화면이 아무리 넓어도 470px 짜리 한 줄입니다.

지금 무엇이 아쉬운가

두 가지가 걸립니다.

휴대폰에서는 카드 양옆에 16px 여백이 있어서 사진이 화면을 꽉 못 채웁니다. 인스타그램 앱을 열어 보면 사진이 가장자리까지 닿죠.

큰 모니터에서는 반대입니다. 화면이 1900px 인데 470px 만 쓰고 양옆이 텅 빕니다.

두 문제의 방향이 반대예요. 좁을 때는 더 넓게, 넓을 때는 더 많이. 조건 없이는 둘 다 만족시킬 수 없습니다.

좁은 화면부터 씁니다

mobile-first 니까 조건 없는 쪽이 가장 좁은 화면을 맡습니다.

tsx
// apps/web-spa/src/App.tsx
<main className="mx-auto max-w-[470px] py-4 sm:px-4 lg:max-w-[964px]">

세 덩어리로 읽으면 됩니다.

  • py-4 — 위아래 여백은 항상. 좌우는 안 줍니다
  • sm:px-4 — 640px 이상에서만 좌우 여백을 줍니다
  • lg:max-w-[964px] — 1024px 이상에서 폭 상한을 470px 에서 964px 로 올립니다

원래 p-4 였던 걸 py-4 sm:px-4로 갈랐습니다. 좁은 화면에서 좌우 여백이 사라지니 사진이 가장자리까지 닿아요.

넓어지면 두 열로

폭만 넓히면 카드 하나가 964px 로 커집니다. 그건 원하는 게 아니에요. 목록을 두 열로 만들어야 합니다.

tsx
// apps/web-spa/src/components/Feed.tsx
<List
  items={posts}
  className="lg:grid lg:grid-cols-2 lg:gap-6"
  aria-label="피드 목록"
  renderItem={(post) => <PostCard {...post} onToggleLike={onToggleLike} />}
/>

조건 없는 상태에서는 그냥 목록이라 카드가 세로로 쌓입니다. 1024px 이상에서만 격자가 되고 두 열로 갈라져요.

여백의 책임이 옮겨갑니다

여기서 손볼 게 하나 생깁니다. 카드에는 원래 mb-6이 붙어 있어요. 세로로 쌓일 때 카드 사이를 벌리는 여백이지만, 격자가 되면 lg:gap-6이 그 일을 합니다. 둘 다 두면 아래쪽만 두 배로 벌어져요.

tsx
// apps/web-spa/src/components/PostCard.tsx
<Card
  className="mb-6 overflow-hidden rounded-lg border border-line bg-surface lg:mb-0"

lg:mb-0으로 넘깁니다. 좁을 때는 카드가 자기 여백을 챙기고, 넓어지면 격자가 대신 챙기는 겁니다.

⚠️ 넓히니 엉뚱한 게 딸려왔습니다

여기까지 하고 큰 화면에서 열어봤더니 문제가 하나 보였습니다. 피드는 두 열로 잘 갈라졌는데 회원가입 폼이 964px 로 늘어나 버렸어요. 입력칸이 화면을 가로지릅니다.

당연한 일입니다. 우리가 넓힌 건 main이고, 회원가입 폼은 그 안에 있는 형제 요소니까요. 컨테이너를 넓히면 그 안의 모든 것이 함께 넓어집니다.

폼에 따로 상한을 걸어 잡았습니다.

tsx
// apps/web-spa/src/components/SignUpForm.tsx
<form
  className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 lg:max-w-[470px]"

세 폭에서 재봅시다

브라우저 창을 줄였다 늘렸다 하면서 확인해 보세요. 재보면 이렇게 나옵니다.

창 너비 main 좌우 여백 목록 카드 폭 한 줄에 카드 아래 여백
390 390 0 세로 쌓기 390 1장 24px
640 470 16 세로 쌓기 438 1장 24px
1023 470 16 세로 쌓기 438 1장 24px
1024 964 16 격자 454 2장 0px
1200 964 16 격자 454 2장 0px

경계가 640과 1024에서 정확히 갈립니다. 그 값 자체는 이미 조건에 들어가요. 크거나 같을 때니까요.

숫자도 맞춰볼 수 있습니다. 438은 470에서 좌우 16씩 뺀 값이고, 454는 964에서 좌우 여백 32와 열 사이 간격 24를 빼고 둘로 나눈 값입니다.

자바스크립트가 한 줄도 없습니다

B-4 과제에서 창 너비를 재는 useWindowWidth 훅을 만드셨죠. 그때 제가 CSS로 되는 일을 굳이 자바스크립트로 하고 있지 않은지 먼저 따져보자고 했습니다. 오늘이 그 이야기를 회수하는 시간이에요.

같은 화면을 그 훅으로 만들 수도 있었습니다. 너비를 상태로 들고, 리사이즈를 구독하고, 숫자를 비교해서 열 수를 정하는 식인데, 그러면 두 가지가 따라와요.

창을 끌어서 늘리는 동안 리렌더가 계속 도는데, 나중에 서버가 HTML을 먼저 그려 보내는 방식을 배우게 되면 그때는 서버가 창 크기를 알 수 없어서 열 수를 정할 방법이 없습니다.

CSS로 하면 둘 다 없습니다. 브라우저가 화면을 그리기 전에 이미 어떤 규칙이 걸릴지 정해지거든요.

💡 한 줄 정리

좁은 화면을 조건 없이 쓰고, 넓어질 때 sm:·lg:로 덮어씁니다. 배치를 바꾸면 여백의 책임도 함께 옮겨가고, 컨테이너를 넓히면 그 안의 형제 요소까지 따라 넓어진다는 걸 잊으면 안 됩니다.

🙋 학생 질문 — "lg:grid-cols-2 말고 화면이 넓어질수록 열을 계속 늘리면 안 되나요?"

xl:grid-cols-3를 더 붙이면 됩니다. 화면이 1280px 을 넘으면 세 열이 되고요.

다만 피드에서는 한 번 생각해 볼 만합니다. 열이 늘면 카드가 좁아지고, 카드가 좁아지면 사진도 작아집니다. 사진을 보는 화면인데 사진이 작아지는 셈이에요.

인스타그램이 실제로 어떻게 하는지 보면 힌트가 됩니다. 웹 인스타그램은 화면이 아무리 넓어도 피드를 한 줄로 두고, 대신 옆에 추천 사용자 목록을 붙입니다. 남는 폭을 피드를 넓히는 데 안 쓰고 다른 정보를 놓는 데 쓰는 거죠.

무엇이 정답이라기보다, 남는 공간에 무엇을 넣을지가 설계 결정이라는 게 핵심입니다. 우리는 지금 두 열을 골랐지만 이유를 댈 수 있어야 합니다.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 유틸리티에 조건을 붙였습니다.

지난 시간까지는 유틸리티가 항상 걸리는 것만 표현할 수 있었어요. 오늘 앞에 콜론 하나를 붙이면서, 언제 걸릴지를 함께 말할 수 있게 됐습니다.

오늘 배운 핵심 세 가지

💡 하나 — 같은 속성을 다툴 때 누가 이기는지는 조건의 종류마다 다릅니다. 상태 조건은 규칙을 더 구체적으로 만들어 순서와 관계없이 이기고, 화면 크기 조건은 무게를 안 더해서 순서로만 갈립니다. 둘을 하나로 외우면 언젠가 틀립니다.

💡 둘 — 조건은 화면 안쪽에만 있는 게 아닙니다. disabled:는 요소의 상태를 보고, hover:는 기기에 마우스가 있는지까지 봅니다. sm:은 창 크기를 보고요. 같은 문법인데 보는 대상이 다릅니다.

💡 셋 — 반응형은 좁은 화면부터 씁니다. 조건 없는 유틸리티가 가장 좁은 화면을 맡고, 넓어질 때마다 덮어써요. sm:이 "작은 화면"이 아니라 "그 이상"이라는 것만 잡으면 나머지는 따라옵니다.

시작할 때 남아 있던 두 줄

오프닝에서 손으로 쓴 규칙 두 줄을 보여드렸죠. 어떻게 닫혔는지 봅시다.

남아 있던 것 닫힌 곳
.comment-submit:disabled Step 1 — disabled:text-brand/30 disabled:cursor-default
.comment-remove:hover Step 2 — hover:text-ink

두 컴포넌트의 className에서 손으로 지은 이름도 함께 사라졌습니다. 지지난 시간에 Avatar 하나로 시작한 일이 여기서 끝났어요.

다음 시간 예고

Step 4에서 기준 크기 다섯 개를 봤죠. 그런데 그건 Tailwind가 정해준 값입니다. 우리 화면에 맞는 선을 우리가 정하려면 어떻게 할까요.

지난 시간에 색에 이름을 줬던 방법을 그대로 쓰면 되는데, 기준 크기도 토큰이거든요. 다만 이 토큰은 지난 시간에 배운 것과 달라서 아무리 써도 :root에 안 나타나요. 왜 그런지가 다음 시간의 첫 이야기입니다.

그리고 Step 4의 순서 그림에서 제가 하나를 비워뒀습니다. 모든 조건보다 뒤에 오는 게 하나 더 있어요. 다크모드입니다.

Step 1에서 잠긴 버튼 색을 text-brand/30으로 옮기면서, 어두운 배경 위에서는 값이 갈린다고 했죠. 그때 미뤄둔 확인도 다음 시간에 합니다. 지난 시간에 이름만 지어두고 안 갈라놓은 canvassurface도 마침내 갈라집니다.

마지막으로 오늘 우리는 창 크기를 보고 배치를 바꿨는데, 컴포넌트가 어디에 놓였느냐에 따라 다르게 보이고 싶을 때도 있어요. 같은 카드가 넓은 칸에 놓이면 가로로, 좁은 칸에 놓이면 세로로 그려지는 식으로요. 창이 아니라 담긴 칸을 보는 방법도 Tailwind에 들어 있습니다. 이름은 컨테이너 쿼리이고, 실제로 써보는 건 조금 더 뒤예요.


과제

[구현] 틈 하나에 기대고 있는 링 고치기

Step 3에서 가입하기 버튼만 테두리 색과 배경색이 같다고 했죠. 지금은 2px 틈 덕분에 보이지만, 그 틈이 사라지면 함께 사라집니다. 이걸 고쳐 주세요.

  • 먼저 문제를 눈으로 확인하세요. 그 버튼의 focus-visible:outline-offset-2를 잠깐 빼고 Tab으로 밟아 보면 테두리가 어떻게 되나요?
  • 간격을 되돌린 뒤, 그 버튼에만 다른 색 테두리를 주세요. 우리 토큰 중에서 고르면 됩니다.
  • 고른 색으로 다시 간격을 빼봤을 때도 테두리가 보이는지 확인하세요. 보인다면 제대로 고른 겁니다.
  • 개발자 도구에서 그 버튼의 outline-colorbackground-color를 나란히 확인하고, 두 값을 적어 주세요.
  • 왜 그 색을 골랐는지 한 줄로 설명해 주세요. 버튼 위에서도 보이고 페이지 배경 위에서도 보여야 합니다.

[구현] 좁은 화면에서 덜 보여주기

지금 게시물 카드에는 "댓글 32개 모두 보기" 줄이 있습니다. 좁은 화면에서는 이 줄을 감춰 보세요.

  • PostBody의 그 문단이 대상입니다.
  • 640px 미만에서만 감기게 하세요. Step 4에서 이야기한 max- 방향을 쓰면 됩니다.
  • 같은 결과를 mobile-first 방향으로도 만들어 보세요. 조건 없는 쪽에 감추기를 두고 sm:으로 되살리는 식입니다.
  • 두 방법 모두 화면 결과는 같습니다. 어느 쪽이 읽기 좋았는지 한 줄로 적어 주세요.

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

[탐구] 변형의 경계를 건드려 보기

각 실험 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.

  • 아무 버튼에 sm:text-danger lg:text-brand를 함께 붙이고, 창을 좁혔다 넓히며 글자색을 보세요. 그다음 className에서 두 개의 순서를 바꿔 다시 확인해 보세요. 결과가 달라지나요? Step 4의 순서 이야기와 맞는지 견줘 보세요.
  • 같은 버튼에 min-[900px]:text-danger sm:text-brand를 붙이고 창을 1000px 쯤으로 맞춰 보세요. 두 조건이 모두 참인데 어느 쪽이 이기나요? 크기 순서대로 나오나요?
  • 댓글 삭제 버튼의 hover:text-inkactive:text-ink로 바꾸고, 마우스를 올릴 때와 누르고 있을 때를 각각 확인해 보세요. 개발자 도구의 기기 모드로 터치 기기를 흉내 내면 무엇이 달라지나요?
  • 아무 요소에 disabled:text-danger를 붙여 보세요. 그 요소가 <div> 라면 어떻게 되나요? 빌드된 CSS에 규칙이 생기는지, 화면이 바뀌는지 나눠서 확인해 보세요.

생각해볼 주제

1. 여덟 곳에 같은 문자열을 적는 것이 맞을까

Step 3에서 우리는 세 개짜리 문자열을 여덟 곳에 똑같이 붙였습니다. 새 버튼을 만들 때마다 아홉 번째, 열 번째로 또 붙여야 하고요.

이미 한 곳을 빠뜨린 상태이기도 한데, 프로필 사진 고르기 버튼만 혼자 브라우저 기본 테두리로 뜹니다. 표시가 사라지는 건 아니지만 화면마다 다른 테두리가 섞이고, 나중에 누가 outline: none을 한 줄 쓰는 순간 그 버튼만 아무 표시가 없어져요. 그리고 빠뜨렸다는 걸 알아차리기가 어렵습니다. 마우스로 쓰면 아무 문제가 없으니까요.

방법이 없지는 않습니다. 포커스 표시를 CSS 파일에 한 번만 써두고 모든 버튼에 걸리게 할 수도 있는데, 그러면 우리가 세 시간에 걸쳐 걷어낸 손 CSS가 다시 생겨요.

Button 컴포넌트 안에 넣는 방법도 있습니다. 다만 B-3에서 우리는 "생김새는 쓰는 쪽이 정한다"는 규칙으로 그 컴포넌트를 만들었어요. 그 규칙을 깨는 셈입니다.

여러분이라면 어떻게 하시겠어요. 이 판단을 할 때는 포커스 표시가 "생김새"인지, 아니면 버튼이라면 당연히 갖춰야 할 기능인지도 함께 생각해 보세요.

2. 조건을 화면 크기로 나누는 것의 한계

오늘 우리는 1024px 을 기준으로 한 열과 두 열을 갈랐습니다. 창이 1024px 보다 넓으면 두 열이지만, 창 크기가 정말 우리가 알고 싶은 것일까요.

사용자가 브라우저 옆에 다른 창을 띄워두고 우리 화면을 절반만 보고 있다면, 창은 좁지만 화면은 넓습니다. 태블릿을 세로로 들면 창은 좁은데 손가락으로 조작하고요.

반대 경우도 있습니다. 화면은 좁은데 마우스를 쓰는 사람, 화면은 넓은데 터치로만 쓰는 사람도 있어서, 화면 크기 하나로 "이 사람이 어떤 환경인가"를 다 알 수는 없습니다. 오늘 hover:가 크기가 아니라 입력 장치를 봤던 것도 같은 이유예요.

우리가 배치를 바꾸고 싶을 때 진짜로 알아야 하는 게 무엇인지 정리해 보고, 그중 화면 크기로 알 수 있는 게 얼마나 되는지도 생각해 보세요.

3. 아무도 안 보는 상태를 어떻게 관리할까

오늘 우리가 만든 규칙 중에는 평소에 화면에 안 보이는 게 많습니다. 잠긴 버튼 색, 마우스를 올렸을 때 색, 키보드로 왔을 때 테두리 모두 특정 조건에서만 나타나요.

문제는 이런 상태가 평소에 확인되지 않는다는 겁니다. 개발자가 화면을 열어봐도 안 보이고, 디자인 시안에도 대개 기본 상태만 그려져 있습니다. 잠긴 버튼 색이 잘못돼도 몇 달 동안 아무도 모를 수 있어요.

오늘 우리도 가입하기 버튼의 테두리가 배경과 같은 색인 걸 뒤늦게 발견했죠. 그것도 일부러 Tab 키를 눌러가며 확인했기 때문에 찾은 겁니다.

조건이 붙은 스타일이 늘어날수록 이런 사각지대도 늘어납니다. 팀이 이걸 놓치지 않으려면 무엇을 정해두어야 할까요. 그리고 그 방법이 새 팀원이 들어와도 지켜질 만한 것인지도 생각해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 틈 하나에 기대고 있는 링 고치기

채점 포인트

항목 확인할 것
진단 간격을 빼면 테두리가 사라지는 것을 눈으로 확인했는가
값 확인 링 색과 배경색이 같다는 것을 개발자 도구에서 두 값으로 확인했는가
토큰 사용 색을 직접 적지 않고 우리 토큰 이름으로 골랐는가
색 선택 버튼 배경과 페이지 배경 양쪽에서 보이는 색을 골랐는가
밝기 판단 색상만 다른 색(danger 등)을 고르지 않았는가
조작 확인 Tab으로 밟아 링이 뜨고, 마우스 클릭에는 안 뜨는 것을 확인했는가
범위 그 버튼 하나만 바꿨는가 (여덟 곳을 전부 바꾸지 않았는가)
설명 왜 그 색인지 한 줄로 적었는가

풀이 예시

먼저 진단부터 정확히 합시다. 개발자 도구에서 그 버튼의 두 값을 나란히 보면 이렇습니다.

속성
background-color 브랜드 파랑 (0, 149, 246)
outline-color 브랜드 파랑 (0, 149, 246)

같은 색입니다. 그런데도 화면에서 링이 보였는데, outline-offset-2가 버튼과 링 사이에 2px 흰 틈을 만들어 주기 때문이라 "안 보인다"가 아니라 "틈 하나에 기대고 있다"가 정확한 진단이에요. outline-offset-2를 빼고 다시 보면 링이 통째로 사라집니다.

고치는 건 한 줄입니다.

tsx
// 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-ink"
  type="submit"
  disabled={isSubmitting}
>

outline-brandoutline-ink로 바꿨습니다.

ink인가. 지금은 링 양옆이 모두 흰색이라 흰색과의 대비 하나로 버팁니다. 그런데 링이 무엇과 맞닿을지는 상황에 따라 바뀝니다. 간격이 0이 되면 안쪽이 버튼 파랑으로 바뀌고, 버튼이 다른 배경 위로 옮겨가면 바깥쪽이 달라져요.

그러니 어느 쪽과 만나도 보이는 색을 골라야 합니다. ink는 거의 검정이라 파랑 위에서도 흰색 위에서도 진합니다.

링 색 버튼 파랑과 만나면 흰 배경과 만나면
brand (고치기 전) 1.00 3.17
ink (고친 뒤) 4.78 15.13

포커스 표시처럼 글자가 아닌 요소는 대비가 3 이상이면 됩니다.

⚠️ 표를 읽을 때 주의하세요. 1.00은 지금 화면의 값이 아닙니다. 지금은 2px 틈이 링과 버튼을 떼어놓고 있어서 실제로 만나는 건 흰색이고, 그래서 3.17로 기준을 겨우 넘깁니다. 1.00은 그 틈이 사라졌을 때의 값이라서, 고치기 전 상태는 "미달"이 아니라 "틈 하나가 빠지면 곧바로 미달"입니다.

자주 나오는 실수

danger를 고릅니다. 빨강은 파랑과 색상이 확실히 다르니 잘 보일 것 같지만, 재보면 파랑과 만났을 때 대비가 1.17입니다. 고치기 전보다 나은 게 거의 없어요.

이유는 두 색의 밝기가 비슷하기 때문입니다. 대비를 만드는 건 색상이 아니라 밝기예요. 지난 시간에 OKLCH를 쓰면서 첫 숫자가 밝기라고 했죠. 우리 brand는 65.46%, danger는 64.1% 로 거의 같습니다.

⚠️ 색약이 있는 분에게는 이 조합이 아예 한 덩어리로 보입니다. 색상만으로 구분하게 만든 화면이 위험한 이유가 이겁니다.

여덟 곳을 전부 ink로 바꿉니다. 문제가 있던 건 배경색이 깔린 버튼 하나뿐입니다. 나머지 일곱은 링 안쪽에도 흰색이 이어져서 brand 링이 틈에 기대지 않아요. 게다가 전부 바꾸면 포커스 표시가 브랜드 색이라는 성격을 잃습니다.

outline-offset을 늘려서 해결합니다. 틈을 4px 로 벌리면 링이 조금 더 잘 보이긴 하지만, 링 자체는 여전히 배경과 같은 색이에요. 버튼이 다른 배경 위로 옮겨가는 순간 다시 문제가 됩니다.

💡 튜터의 한마디

이 과제의 진짜 교훈은 "보이긴 하는데 왜 보이는지 모르는 상태"가 위험하다는 겁니다.

고치기 전에도 화면에서 링은 보였어요. 눈으로만 확인했다면 통과시켰을 겁니다. 두 값을 나란히 놓고 재봤기 때문에 우연히 보이고 있었다는 걸 안 거죠.

실무에서 접근성 문제가 오래 살아남는 이유가 대체로 이겁니다. 완전히 망가진 게 아니라 아슬아슬하게 동작하고 있어서, 접근성은 눈으로 보는 게 아니라 재는 겁니다.


🎯 [과제 2 예시답안] 좁은 화면에서 덜 보여주기

채점 포인트

항목 확인할 것
max- 방향 max-sm:hidden으로 640px 미만에서만 감췄는가
mobile-first 방향 hidden sm:block 두 개로 같은 결과를 만들었는가
경계 확인 640px 에서 보이는 것을 확인했는가 (640 이하가 아니라 640 미만만 감춤)
유틸리티 수 두 방법의 유틸리티 개수 차이를 알아챘는가
비교 어느 쪽이 읽기 좋았는지 이유와 함께 적었는가
마무리 확인 typechecklint가 통과하는가

풀이 예시

두 방향 모두 화면 결과는 같습니다.

(가) max- 방향 — 한 개

tsx
// apps/web-spa/src/components/PostBody.tsx
<p className="px-3 pt-1 pb-3 text-sm text-muted max-sm:hidden">댓글 {commentCount}개 모두 보기</p>

(나) mobile-first 방향 — 두 개

tsx
<p className="px-3 pt-1 pb-3 text-sm text-muted hidden sm:block">댓글 {commentCount}개 모두 보기</p>

(가)로 만들어 놓고 창 너비를 바꿔가며 재보면 이렇습니다.

창 너비 display 보이나
390 none 안 보임
640 block 보임
1200 block 보임

⚠️ 640에서 이미 보입니다. sm은 "640 이상"이라 640 자체가 조건에 들어가요. max-sm:은 그 반대인 "640 미만" 이고요. 두 조건이 640을 서로 겹치거나 비우지 않고 정확히 나눠 갖습니다.

두 방법의 차이

(가)는 유틸리티가 하나입니다. "평소엔 보이는데 좁을 때만 감춘다"를 그대로 옮긴 문장이에요.

(나)는 두 개가 듭니다. "기본은 감춤, 넓어지면 보임"이라 조건 없는 쪽이 좁은 화면을 맡는 mobile-first 흐름과 같은 방향으로 읽혀요.

정답은 없지만 기준은 세울 수 있습니다.

  • 앱 전체를 mobile-first로 통일해 읽고 싶다 → (나)
  • 이 한 곳만 예외를 두는 거다 → (가)

자주 나오는 실수

sm:hidden을 씁니다. 정반대가 되는데, 640 이상에서 감춰지니 넓은 화면에서만 사라져요. sm:이 "작은 화면"이라는 착각이 그대로 드러나는 실수입니다.

hidden만 붙이고 끝냅니다. 모든 화면에서 사라지는데, 조건 없는 유틸리티는 항상 걸린다는 걸 잊은 경우예요.

(나)를 hidden sm:hidden으로 씁니다. 두 번째가 첫 번째를 덮으라고 쓴 건데 같은 값이라 아무 일도 안 일어나서, 되살리려면 hidden의 반대인 block을 줘야 해요.

💡 튜터의 한마디

같은 화면을 두 가지 방법으로 만들어 보라고 한 이유가 있습니다.

반응형을 배우면 대부분 max-를 즐겨 쓰게 됩니다. 사람이 생각하는 순서와 맞거든요. 화면을 넓은 데스크톱 기준으로 먼저 만들고, "휴대폰에서는 이걸 좀 감추자"로 가니까요.

그런데 그 순서로 가면 좁은 화면이 항상 나중에 생각한 예외가 됩니다. 실제로 그렇게 만든 화면은 휴대폰에서 어딘가 어색해요. 감춰야 할 걸 하나 빠뜨리는 식으로요.

기본을 좁은 화면에 두면 순서가 뒤집힙니다. 휴대폰에서 되는 걸 먼저 만들고 넓어지면 더 얹는 거죠. 유틸리티가 하나 더 들어도 이 방향을 권하는 이유입니다.


🎯 [과제 3 예시답안] 변형의 경계를 건드려 보기

채점 포인트

항목 확인할 것
순서 실험 className 순서를 바꿔도 결과가 같다는 것을 확인했는가
순서의 이유 반응형끼리는 무게가 같아 CSS 출력 순서로 정해진다는 것을 설명했는가
대괄호 함정 min-[900px]:sm:에게 진다는 것을 확인했는가
active: 미디어 감싸기 여부의 차이를 찾았는가
<div> 실험 규칙은 생기지만 화면은 안 바뀐다를 나눠서 확인했는가
되돌리기 실험을 전부 되돌렸는가

풀이 예시

실험 1 — sm:text-danger lg:text-brand의 순서를 바꾸면

결과가 같습니다. 창을 넓히면 640에서 빨강, 1024에서 파랑이 되고, className 순서를 뒤집어도 똑같아요.

빌드된 CSS를 견줘 보면 더 분명합니다. 순서를 바꾼 빌드와 원래 빌드가 파일 이름의 해시까지 같습니다. 글자 하나 안 달라졌다는 뜻이에요.

CSS 안에서 두 규칙의 위치는 항상 이렇습니다.

텍스트
 ... sm:text-danger ... lg:text-brand ...
      (앞)                 (뒤)

화면 크기 조건은 규칙의 무게를 안 더하므로 둘은 무게가 같습니다. 같으면 뒤에 나온 것이 이겨요. 1024 이상에서는 둘 다 조건이 참인데 lg:가 뒤라서 파랑이 됩니다.

실험 2 — min-[900px]:text-danger sm:text-brand를 1000px 에서

sm이 이깁니다. 글자색은 파랑이에요.

900이 640보다 큰데도 그렇습니다. CSS 안의 위치를 보면 이유가 나와요.

텍스트
 ... min-[900px]:text-danger ... sm:text-brand ...
        (앞)                        (뒤)

대괄호로 직접 적은 크기는 이름 붙은 중단점보다 앞쪽에 몰려서 나옵니다. 그래서 숫자가 더 커도 뒤에 나오는 sm:에게 집니다.

⚠️ "숫자가 큰 쪽이 이긴다"는 이름 붙은 중단점끼리만 성립합니다. 대괄호를 섞으면 깨져요.

실험 3 — hover:active:

만들어지는 CSS가 다릅니다.

CSS
/* hover: 는 미디어로 감싼다 */
@media (hover:hover) { .hover\:text-ink:hover { color: var(--color-ink) } }

/* active: 는 안 감싼다 */
.active\:text-ink:active { color: var(--color-ink) }

감싸지 않으니 터치 기기에서도 걸리는데, 다만 보이는 시점이 달라요. hover:는 올려두는 동안, active:는 누르고 있는 동안입니다. 마우스에서 active:로 바꾸면 색 변화가 훨씬 짧게 스칩니다.

실험 4 — disabled:text-danger<div>

둘을 나눠서 봐야 합니다.

확인할 것 결과
CSS에 규칙이 생기나 생긴다
화면이 바뀌나 안 바뀐다

빌드된 CSS를 찾아보면 이렇게 들어가 있어요.

CSS
.disabled\:text-danger:disabled { color: var(--color-danger) }

<div>:disabled가 될 수 없는데, 그 상태는 버튼이나 입력칸 같은 요소만 가질 수 있어서 규칙은 영원히 안 걸려요.

지지난 시간에 배운 게 여기서 다시 나옵니다. Tailwind는 글자만 훑습니다. disabled:text-danger라는 글자를 봤으니 규칙을 만든 거예요. 그 클래스가 어떤 요소에 붙는지, 그 요소가 그 상태가 될 수 있는지는 안 따집니다.

자주 나오는 실수

실험 4에서 "화면이 안 바뀌니 규칙도 없겠지"라고 넘어갑니다. 두 질문이 따로라는 게 이 실험의 핵심입니다. 규칙은 있는데 안 걸리는 것과, 규칙이 아예 없는 것은 다릅니다. 개발자 도구에서 "이 스타일이 왜 안 먹지"를 찾을 때 이 구분이 필요해요.

실험 2를 800px 에서 확인합니다. 800은 min-[900px] 조건에 안 들어가서 sm만 걸립니다. 둘이 다투는 상황이 아니에요. 두 조건이 모두 참인 폭에서 봐야 누가 이기는지 알 수 있습니다.

💡 튜터의 한마디

네 실험이 각각 다른 얘기 같지만 하나로 모입니다. Tailwind가 만든 CSS를 직접 열어보면 전부 답이 나온다는 거예요.

순서가 왜 그런지, 왜 저게 이기는지, 규칙이 생겼는지 안 생겼는지 전부 파일 안에 적혀 있습니다. 추측하거나 외울 필요가 없어요.

앞으로 스타일이 예상과 다르게 나오면 순서가 이렇습니다. 개발자 도구의 Elements 탭에서 어떤 규칙이 걸렸고 어떤 게 그어져 있는지 보고, 그래도 모르겠으면 빌드된 CSS에서 그 클래스를 검색합니다. 이 두 단계로 대부분 끝납니다.


🤔 [생각해볼 주제 1] 여덟 곳에 같은 문자열을 적는 것이 맞을까

문제 상황 요약

포커스 표시 세 개짜리 문자열을 여덟 곳에 똑같이 붙였습니다. 새 버튼을 만들 때마다 또 붙여야 하고, 한 곳만 빠뜨리면 그 버튼만 브라우저 기본 테두리로 뜹니다. 실제로 프로필 사진 고르기 버튼이 지금 그 상태인데, 마우스로 쓰면 아무 문제가 없어서 빠뜨린 걸 알아차리기도 어렵습니다.

CSS 파일에 한 번만 쓰는 방법, Button 컴포넌트 안에 넣는 방법이 있는데 각각 우리가 지켜온 것과 부딪힙니다.

튜터의 가이드 및 해설

먼저 세 선택지를 늘어놓고 각각 무엇을 포기하는지 봅시다.

호출하는 곳마다 붙이기 (지금 방식)는 걷어낸 손 CSS를 되살리지 않고 Button의 계약도 안 건드립니다. 대신 반복이 생기고 빠뜨릴 수 있어요.

CSS 파일에 한 번 쓰기는 반복이 사라지고 빠뜨릴 수가 없습니다. 모든 버튼에 자동으로 걸리니까요. 대신 세 시간에 걸쳐 걷어낸 손 CSS가 다시 생깁니다.

Button 안에 넣기도 반복이 사라집니다. 대신 B-3에서 세운 "생김새는 쓰는 쪽이 정한다"를 깨요.

여기서 질문을 하나 바꿔볼 필요가 있습니다. 포커스 표시가 정말 "생김새" 인가요?

색이나 여백은 화면마다 다르게 하고 싶은 게 맞습니다. 그런데 포커스 표시는 다릅니다. 버튼이라면 무조건 있어야 하는 것이고, 없으면 그 버튼은 일부 사용자에게 쓸 수 없는 물건이 되니, 이건 취향이 아니라 버튼이라는 물건의 최소 조건에 가깝습니다. 그렇게 보면 Button 안에 넣는 게 계약을 깨는 게 아니라, 계약을 정확히 다시 쓰는 것일 수 있어요. "생김새는 쓰는 쪽이 정하고, 버튼으로서 갖춰야 할 것은 이 컴포넌트가 챙긴다" 로요.

실무에서 자주 보는 정리는 이렇습니다.

성격 어디에 두나
화면마다 달라지는 것 (색·여백·글자 크기) 쓰는 쪽
그 컴포넌트라면 항상 필요한 것 (포커스 표시·역할·키보드 동작) 컴포넌트 안

지금 우리 ButtonclassName을 통째로 받아 그대로 넘기는 아주 얇은 컴포넌트라서, 안에서 기본값을 더하려면 문자열을 합치는 일을 해야 하고 쓰는 쪽이 그걸 덮어쓰고 싶을 때 어떻게 할지도 정해야 합니다. 이런 문제를 다루는 도구가 따로 있는데, 그건 컴포넌트 라이브러리를 만나는 시간에 봅니다.

지금 단계에서 중요한 건 결론보다 판단 기준입니다. "이걸 빠뜨렸을 때 누가 곤란해지는가"를 물어보세요. 색을 빠뜨리면 화면이 밋밋해지고 금방 눈에 띕니다. 포커스 표시를 빠뜨리면 아무도 모르는 채로 누군가가 못 쓰게 됩니다. 후자는 사람이 기억하는 것에 맡기면 안 됩니다.

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

"반복을 줄이는 문제로 보면 컴포넌트에 넣을지 말지의 취향 문제가 되는데, 저는 빠뜨렸을 때 누가 곤란해지는지로 나눕니다. 색은 빠뜨리면 바로 눈에 띄지만 포커스 표시는 마우스로 쓰는 사람에겐 아무 문제가 없어서 몇 달을 갑니다. 이렇게 조용히 실패하는 것은 사람의 기억이 아니라 컴포넌트나 린트 규칙처럼 자동으로 걸리는 데 둬야 한다고 봅니다."


🤔 [생각해볼 주제 2] 조건을 화면 크기로 나누는 것의 한계

문제 상황 요약

우리는 1024px 을 기준으로 한 열과 두 열을 갈랐는데, 창 크기가 정말 우리가 알고 싶은 것일까요.

브라우저를 반만 띄운 사람은 창은 좁지만 화면은 넓습니다. 태블릿을 세로로 들면 창은 좁은데 손가락으로 조작하고요. 화면은 좁은데 마우스를 쓰는 사람도 있습니다.

튜터의 가이드 및 해설

먼저 우리가 배치를 바꿀 때 진짜로 알고 싶은 게 무엇인지 적어 봅시다. 대체로 셋입니다.

얼마나 넣을 수 있나. 폭이 넓으면 두 열이 들어갑니다. 이건 창 크기로 알 수 있어요.

어떻게 조작하나. 손가락이면 누르는 곳이 커야 하고, hover로 뭔가를 알려주면 안 됩니다. 이건 창 크기로 못 압니다. 그래서 hover:가 크기가 아니라 입력 장치를 보는 거예요.

얼마나 집중해서 보나. 휴대폰은 이동 중일 때가 많고 큰 모니터는 앉아서 봅니다. 이건 어느 쪽으로도 알 수 없어요.

정리하면 창 크기로 알 수 있는 건 셋 중 하나뿐입니다. 나머지는 다른 방법이 있거나, 아예 없습니다.

입력 방식hover:처럼 물어볼 수 있습니다. 오늘 본 (hover: hover) 외에도 포인터가 정밀한지 묻는 조건이 있어요. 손가락은 정밀하지 않고 마우스는 정밀합니다.

의도나 상황은 물어볼 방법이 없습니다. 그래서 이건 추측하지 말고 사용자가 고르게 하는 게 맞습니다.

여기서 실무의 함정이 하나 나옵니다. 창 크기로 알 수 없는 걸 창 크기로 추측하는 코드가 아주 흔해요. "폭이 768 미만이면 휴대폰이니까 터치겠지" 같은 겁니다. 이 추측은 태블릿 가로, 브라우저를 반만 띄운 데스크톱, 접는 휴대폰에서 전부 틀립니다.

그리고 오늘 배운 조건들은 각각 다른 걸 보고 있었습니다. 이게 중요한 정리예요.

조건 무엇을 보나
disabled: 그 요소의 상태
hover: 기기의 입력 장치
focus-visible: 사용자의 조작 방식에 대한 브라우저의 판단
sm:·lg: 창 크기

같은 문법인데 보는 대상이 다릅니다. 그래서 "반응형"을 크기 하나로만 생각하면 나머지 셋을 놓쳐요.

마지막으로 창 크기조차 우리가 원하는 답이 아닐 때가 있습니다. 컴포넌트를 좁은 칸에 넣었다면 창이 아무리 넓어도 그 안은 좁습니다. 그 컴포넌트가 알아야 하는 건 창이 아니라 자기가 담긴 칸이에요. 그걸 묻는 방법도 Tailwind에 있습니다. 이름은 오늘 마무리에서 이미 만났고, 실제로 써보는 건 조금 더 뒤예요.

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

"반응형을 창 너비 하나로 푸는 코드가 많은데, 실제로 알고 싶은 건 대개 셋입니다. 얼마나 넣을 수 있나, 어떻게 조작하나, 어디에 담겼나. 이 중 창 너비로 알 수 있는 건 첫 번째뿐이라, 나머지를 너비로 추측하면 태블릿 가로나 창을 반만 띄운 데스크톱에서 바로 틀립니다. 저는 입력 방식은 미디어 쿼리로 직접 묻고, 담긴 칸은 컨테이너 쿼리로 묻는 쪽으로 나눕니다."


🤔 [생각해볼 주제 3] 아무도 안 보는 상태를 어떻게 관리할까

문제 상황 요약

오늘 만든 규칙 중에는 평소에 화면에 안 보이는 게 많습니다. 잠긴 버튼 색, 마우스를 올렸을 때 색, 키보드로 왔을 때 테두리 모두 특정 조건에서만 나타나요.

개발자가 화면을 열어봐도 안 보이고, 디자인 시안에도 대개 기본 상태만 그려져 있습니다. 잘못돼도 몇 달 동안 아무도 모를 수 있습니다.

튜터의 가이드 및 해설

먼저 왜 어려운지 정확히 짚읍시다. 상태가 늘어나면 확인할 조합이 곱셈으로 늘어납니다.

버튼 하나에 기본·마우스·포커스·잠김 네 상태가 있고 화면 크기가 셋이면 벌써 열둘이지만, 다크모드가 들어오면 스물넷이 되고요. 사람이 매번 다 눌러볼 수는 없어서, 실무에서 쓰는 방법은 대체로 세 갈래입니다.

한곳에 모아 보이게 만들기. 모든 상태를 한 화면에 늘어놓은 페이지를 따로 두는 겁니다. 버튼의 네 상태를 나란히 그려두면 눈으로 한 번에 훑을 수 있어요. 상태가 화면 어딘가에 항상 떠 있으니 잘못되면 금방 보입니다. 이걸 도구로 만들어주는 게 컴포넌트 카탈로그이고, 나중에 테스팅을 배우는 시간에 만나게 됩니다.

규칙으로 강제하기. 사람이 기억하지 않아도 되게 만드는 방법입니다. 포커스 표시를 지우는 코드가 들어오면 린트가 막게 하거나, 접근성 검사 도구를 빌드에 붙이는 식이에요. 놓쳤을 때 바로 알려주니 가장 확실합니다. 다만 규칙으로 잡을 수 있는 건 정해진 몇 가지뿐이에요.

확인 절차에 넣기. 화면 작업을 리뷰할 때 "Tab 키로 한 바퀴 돌아봤는가"를 항목으로 두는 겁니다. 도구가 필요 없어 오늘 당장 할 수 있어요. 대신 사람이 지켜야 해서 바쁘면 제일 먼저 빠집니다.

셋 중 하나를 고르라면 저는 두 번째부터 권합니다. 새 팀원이 와도 저절로 지켜지는 게 그것뿐이거든요. 첫 번째는 만들어두고 아무도 안 열어보는 일이 잦고, 세 번째는 사람에 따라 달라지는데, 오늘 우리 경험을 다시 보세요. 가입하기 버튼의 링이 배경과 같은 색인 걸 어떻게 찾았죠. 일부러 Tab 키를 눌러가며 확인했기 때문입니다. 그렇게 안 했으면 못 찾았을 텐데, 여기서 한 걸음 더 나가야 합니다. 찾았다는 것보다, 찾을 마음을 먹어야만 찾을 수 있었다는 게 문제입니다. 다음 사람은 그 마음을 안 먹을 수 있어서, "잘 보자"는 해결책이 아닙니다. 안 봐도 걸리게 만드는 게 해결책입니다.

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

"상태가 붙은 스타일은 조합이 곱셈으로 늘어나서 사람이 매번 확인할 수가 없습니다. 그래서 저는 확인을 잘하자는 방향보다, 안 보고도 걸리게 만드는 방향을 먼저 봅니다. 포커스 표시를 지우는 코드를 린트로 막는 식으로요. 컴포넌트 카탈로그도 유용하지만 그건 열어봐야 보이는 거라, 놓쳤을 때 알려주는 장치를 먼저 깔고 그다음에 보는 도구를 얹는 순서가 맞다고 생각합니다."

전체 목록 리액트