문서 읽는 데 55분 · G1

G-1: 웹 접근성 — 눈으로 못 보고, 마우스를 못 써도 우리 인스타그램을 쓸 수 있게

목차 40
전체 34강 중 30강 · HTML·CSS·JS
난이도 · 입문

안녕하세요, 홍순구 튜터입니다. 지난 시간(F-2)에 우리는 사진을 가볍게 만들면서 모든 <img>alt(대체 텍스트)를 적었어요. "노을이 지는 협재 해변" 처럼요. 그때 제가 마지막에 질문 하나를 던졌죠. 이 글자, 대체 누가 읽는 걸까요?

오늘 그 답을 만나요. 눈으로 사진을 못 보는 분은 화면을 소리로 읽어 주는 프로그램(스크린 리더)으로 우리 사이트를 써요. 그 프로그램이 사진 자리에서 alt를 대신 읽어 줘요. 마우스를 못 쓰는 분은 키보드만으로 좋아요와 댓글을 눌러요. 오늘은 이런 분들도 우리 인스타그램을 쓸 수 있는지 점검하고, 부족한 곳을 메워요.

텍스트
   같은 피드 화면, 다른 방식으로 쓰는 사람들

   👁  눈으로 본다          화면을 그냥 본다
   🔊  소리로 듣는다        스크린 리더가 alt·버튼 이름을 읽어 준다
   ⌨   키보드로 누른다       Tab 으로 이동, Enter 로 누른다
   🎨  색 구분이 약하다      대비가 충분해야 글씨가 보인다

    네 사람 모두에게 똑같이 동작해야 "접근성 있는" 페이지예요

좋은 소식이 하나 있어요. 우리는 그동안 <button>을 고집하고, 아이콘마다 이름을 달고, alt를 꼬박꼬박 적어 왔어요. 그 습관 덕분에 우리 코드는 이미 절반 이상 접근성을 갖췄어요. 오늘은 "이미 잘 한 것을 확인" 하고, "진짜 빈 구멍만 메우는" 수업이에요.

💡 오늘 수업의 핵심 — "스크린 리더에게는 이름(alt·aria-label)을, 키보드에게는 길(Tab·건너뛰기·포커스)을, 눈이 약한 분에게는 대비(4.5:1)를 — 세 무리 모두가 쓸 수 있는 페이지로 다듬는다." 🎯

🎯 학습 목표

  • 웹 접근성이 누구를 위한 것인지 알고, WCAG의 네 가지 원칙(인식·조작·이해·견고)으로 페이지를 진단합니다.
  • 의미 있는 이미지와 장식 이미지를 구분해 alt제대로 적습니다.
  • 키보드만으로 모든 기능을 쓸 수 있게 만들고, 건너뛰기 링크와 또렷한 포커스 표시를 더합니다.
  • 아이콘 버튼에 aria-label로 이름을 주고, 좋아요 상태(aria-pressed)와 변화(aria-live)를 스크린 리더에게 전합니다.
  • 글자와 배경의 색상 대비를 측정해 WCAG AA(4.5:1) 기준을 맞춥니다.

Step 1: 접근성은 누구를 위한 걸까 — 네 가지 원칙(인식·조작·이해·견고)

"접근성(accessibility)"이라는 말이 처음엔 좀 멀게 들려요. 쉽게 말하면 "몸이나 환경이 달라도 똑같이 쓸 수 있게" 만드는 거예요. 우리가 흔히 떠올리는 건 시각 장애인이지만, 대상은 훨씬 넓어요.

생각보다 많은 사람이 "다르게" 쓴다

텍스트
   ┌─ 영구적            ┌─ 일시적            ┌─ 상황적
   │  시각 장애          │  눈 수술 회복 중     │  밝은 햇빛 아래 화면
   │  손 마비            │  팔 깁스            │  한 손에 아기를 안고
   │  난청              │  중이염             │  시끄러운 지하철
   └────────────────────┴────────────────────┴────────────────────
          접근성은 "특별한 소수"가 아니라 "언젠가의 나"를 위한 거예요

깁스를 하면 누구나 잠깐 마우스를 못 써요. 지하철에선 소리를 못 들어 자막이 필요하고요. 접근성을 챙기면 장애인뿐 아니라 모든 사람의 어떤 순간이 편해져요. 그래서 잘 만든 접근성은 결국 모두를 위한 품질이에요.

네 글자로 외우는 원칙: 인식 · 조작 · 이해 · 견고

웹 접근성의 국제 기준을 WCAG(Web Content Accessibility Guidelines, 웹 콘텐츠 접근성 지침)라고 불러요. 지금 가장 널리 쓰이는 판은 WCAG 2.2예요. 내용이 방대하지만, 큰 줄기는 딱 네 가지예요.

텍스트
   인식 가능 (Perceivable)  — 콘텐츠를 어떤 감각으로든 받아들일 수 있는가
                            예) 사진엔 alt, 영상엔 자막
   조작 가능 (Operable)     — 마우스 없이 키보드로도 다 쓸 수 있는가
                            예) Tab 이동, Enter 실행
   이해 가능 (Understandable) — 글과 동작이 헷갈리지 않고 예측되는가
                            예) 버튼 이름이 분명, 에러 안내가 친절
   견고함   (Robust)        — 다양한 기기·보조기술이 잘 읽어 들이는가
                            예) 표준 HTML, 올바른 ARIA

오늘 수업은 이 네 원칙을 코드로 하나씩 짚어요. alt인식, 키보드 탐색은 조작, 버튼 이름은 이해, 표준 마크업은 견고예요. 외우려 하지 말고, "지금 고치는 게 네 원칙 중 어디에 닿나" 정도만 떠올리면 돼요.

💡 접근성은 "다 됐다"가 없는 작업이에요. 100점을 향한 체크리스트가 아니라, "한 사람이라도 더 쓸 수 있게" 계속 다듬는 습관이에요.


Step 2: alt 다시 보기 — 의미 있는 사진과 장식 사진을 가른다

지난 시간 우리는 모든 <img>alt를 적었어요. 오늘은 그게 잘 적힌 alt인지 점검해요. 스크린 리더는 사진 자리에서 alt를 그대로 읽으니까, alt의 품질이 곧 그분들이 보는 화면의 품질이에요.

우리 코드의 alt부터 열어 보자

게시물 카드를 만드는 코드에 이미 alt가 들어 있어요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<img class="post-avatar" src="${post.avatar}" alt="${post.username} 프로필 사진" width="32" height="32">
...
<img src="${post.image}.jpg" alt="${post.alt}" width="600" height="600"
     ${this.priority ? 'fetchpriority="high"' : 'loading="lazy"'}>

아바타는 "jiwoo_log 프로필 사진" 처럼 누구의 사진인지 읽혀요. 게시물 사진은 데이터에 담긴 post.alt("노을이 지는 협재 해변")를 읽고요. 둘 다 그 사진이 무엇인지를 말해 줘요. 잘 적힌 alt예요.

좋은 alt를 가르는 기준 — "이 사진을 못 본다면 무슨 말을 듣고 싶을까"

텍스트
   이 <img> 는 무엇인가?
        │
        ├─ 내용을 전달한다 (사진·차트·로고)
        │      alt 에 "무엇인지" 적는다
        │       예) alt="노을이 지는 협재 해변"
        │
        └─ 순수 장식이다 (구분선·배경 무늬·아이콘 옆 글자가 이미 있음)
               alt="" (빈 값) 으로 둬서 스크린 리더가 건너뛰게 한다

핵심은 두 가지예요. 첫째, 의미 있는 사진엔 그 사진을 못 봐도 알 내용을 적어요. "이미지", "사진 1" 같은 말은 알맹이가 없으니 빼요. 둘째, 순수 장식 이미지엔 alt="" 를 줘요. 빈 값이 곧 "이건 읽지 않아도 돼"라는 신호라서, 스크린 리더가 조용히 넘어가요. alt를 아예 안 쓰면 안 돼요 — 그러면 스크린 리더가 파일 이름을 어색하게 읽어 버리거든요.

우리 아이콘들을 보면 SVG에 aria-hidden="true"가 붙어 있어요. 이게 이미지의 alt=""와 같은 역할이에요. "이 그림은 장식이니 읽지 마"라는 뜻이죠. 버튼의 진짜 이름은 따로 줄 건데, 그건 Step 6에서 다뤄요.

🙋 학생 질문 — "튜터님, alt에 '~사진'이라고 꼭 써야 하나요?"

안 써도 돼요. 오히려 스크린 리더는 이미 "이미지"라고 종류를 먼저 읽어 줘요. 그래서 alt="노을이 지는 협재 해변 사진"이면 "이미지, 노을이 지는 협재 해변 사진"처럼 "사진"이 두 번 겹쳐 들려요.

다만 아바타처럼 누구인지가 중요한 경우엔 "jiwoo_log 프로필 사진"이 더 또렷해요. 규칙은 하나예요 — 군더더기 없이, 그 사진을 못 보는 사람에게 가장 도움이 되는 말을 적는 거예요.


Step 3: 키보드만으로 — Tab 으로 이동하고, 건너뛰기 링크로 질러간다

이제 마우스를 치워요. 손을 키보드에만 올리고 우리 피드를 써 봐요. 이게 키보드 사용자, 그리고 스크린 리더 사용자가 매일 하는 방식이에요.

Tab 은 "다음 요소로", Enter 는 "누르기"

키보드 탐색의 규칙은 단순해요.

텍스트
   Tab         다음 요소(링크·버튼·입력칸)로 포커스 이동
   Shift+Tab   이전 요소로
   Enter       링크 따라가기 / 버튼 누르기
   Space       버튼 누르기 / 체크박스 켜고 끄기
   Esc         열린 모달·메뉴 닫기

feed.html을 열고 Tab을 눌러 보세요. 포커스(지금 선택된 요소를 감싸는 표시)가 로고 → 메뉴 → 게시물 버튼 순서로 옮겨 다녀요. 우리가 링크와 버튼을 표준 태그(<a>, <button>)로 만든 덕분에, 브라우저가 자동으로 이 순서를 만들어 줘요. 따로 코드를 안 짜도 키보드 이동이 되는 거예요.

함정 ① 포커스가 안 보이면 길을 잃는다

키보드 사용자는 "지금 내가 어디 있는지"를 포커스 표시로만 알아요. 그런데 우리 댓글 입력칸에 이런 코드가 있었어요.

CSS
/* instagram-clone-frontend/css/components.css */
.comment-form textarea:focus {
  outline: none;   /* ← 포커스 표시를 지워 버렸어요. 대체할 표시도 없이! */
}

outline: none은 브라우저가 그려 주는 포커스 테두리를 지워요. 마우스 쓸 땐 거슬릴 수 있지만, 키보드 사용자에겐 지금 댓글칸에 와 있다는 유일한 신호를 없앤 거예요. 그래서 이렇게 고쳤어요.

CSS
/* 키보드로 댓글칸에 들어오면 또렷한 포커스 링 — outline 을 없애지 말고 보이게 둬요 */
.comment-form textarea:focus-visible {
  outline: 2px solid var(--ig-blue);
  outline-offset: -2px;
  border-radius: var(--radius-sm);
}

:focus-visible키보드로 이동했을 때만 또렷한 테두리를 보여 줘요(마우스 클릭엔 안 떠서 거슬리지 않아요). 사실 우리는 이걸 이미 버튼들에 쓰고 있었어요.

CSS
.icon-btn:focus-visible,
.suggest-follow:focus-visible,
.btn-login:focus-visible,
.btn-edit:focus-visible {
  outline: 2px solid var(--ig-blue);
  outline-offset: 2px;
}

원칙 하나만 기억하세요. outline: none만 적고 끝내지 마세요. 지우려면 반드시 더 또렷한 표시로 바꿔 줘야 해요.

함정 ② 매번 긴 메뉴를 지나야 본문에 닿는다

키보드 사용자가 Tab으로 본문 게시물에 닿으려면, 매번 사이드바 메뉴 열 몇 개를 다 지나야 해요. 페이지를 옮길 때마다요. 그래서 건너뛰기 링크(skip link)를 둬요. 평소엔 숨어 있다가, 첫 Tab에 맨 위로 튀어나와 "본문으로 바로 가기"를 내밀어요.

HTML
<!-- instagram-clone-frontend/feed.html -->
<body class="app">
  <a class="skip-link" href="#main-content">본문으로 건너뛰기</a>
  ...
<main id="main-content" tabindex="-1">

링크는 #main-content를 가리키고, <main>엔 같은 id를 줬어요. tabindex="-1"은 "평소엔 Tab 차례에 안 끼지만, 링크로 보내면 포커스를 받을 수 있게" 해 줘요. 평소엔 안 보이게, 포커스되면 나타나게 하는 건 CSS가 맡아요.

CSS
/* instagram-clone-frontend/css/components.css */
.skip-link {
  position: fixed;
  top: -3rem;          /* 평소엔 화면 위로 숨겨 둬요 */
  left: 0.5rem;
  z-index: 200;
  padding: 0.5rem 1rem;
  border-radius: var(--radius-md);
  background-color: var(--gray-900);  /* 흰 글자와 14.5:1 — 건너뛰기 링크부터 대비를 지켜요 */
  color: #ffffff;
  font-weight: 600;
  transition: top 0.2s ease;
}

.skip-link:focus-visible {
  top: 0.5rem;         /* Tab 으로 포커스되면 끌어내려 보여줘요 */
  outline: 2px solid var(--color-text);
  outline-offset: 2px;
}
텍스트
   페이지를 열고 Tab 한 번

   건너뛰기 링크 없음              건너뛰기 링크 있음
   ┌────────────────┐          ┌──────────────┐
   │ 로고           │          │ [본문으로 건너뛰기]  첫 Tab 에 등장
   │ 홈             │  Tab×10  │   Enter 누르면
   │ 검색           │  ─────  │   바로 게시물로!
   │ … (메뉴 8개)   │          │              │
   │ 겨우 본문 도착 │          │              │
   └────────────────┘          └──────────────┘

feed.html을 열고 Tab을 딱 한 번 눌러 보세요. 검은 "본문으로 건너뛰기" 띠가 왼쪽 위에 나타나요. Enter를 누르면 메뉴를 모두 건너뛰고 본문으로 쏙 들어가요.


Step 4: Enter 와 Space — 왜 우리는 버튼이 그냥 눌릴까

키보드로 버튼에 도착했어요. 이제 EnterSpace로 눌러야 해요. 그런데 우리 좋아요·댓글·공유 버튼은 아무 코드를 더 안 짰는데도 키보드로 눌려요. 왜 그럴까요?

<button>은 키보드를 공짜로 준다

비밀은 우리가 진짜 <button> 태그를 썼다는 데 있어요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<button type="button" class="icon-btn icon-btn-like" aria-label="좋아요" aria-pressed="false">
  <svg class="ico" aria-hidden="true"><use href="assets/icons.svg#ico-heart"></use></svg></button>

<button>은 브라우저가 만든 "버튼 부품"이에요. 그래서 세 가지를 태어날 때부터 갖고 있어요. ① Tab 차례에 자동으로 낀다. ② Enter·Space로 눌린다. ③ 스크린 리더가 "버튼"이라고 읽어 준다. 우리가 클릭만 연결하면, 키보드 동작은 브라우저가 알아서 따라와요.

실제로 좋아요 처리는 클릭 한 곳에서만 받고 있어요.

JavaScript
// instagram-clone-frontend/js/feed.js
feed.addEventListener("click", async (event) => {
  // 1) 좋아요 하트
  const likeBtn = event.target.closest(".icon-btn-like");
  if (likeBtn) {
    const article = likeBtn.closest("article");
    const index = [...document.querySelectorAll("article")].indexOf(article);
    toggleLike(index);
    return;
  }
  // ...
});

click만 들었는데도 키보드 Enter·Space가 동작해요. <button>을 누르면 키보드든 마우스든 똑같이 click 이벤트가 일어나거든요. 이게 시맨틱 태그(semantic tag, 의미를 가진 태그)를 쓰는 큰 보상이에요.

함정: <div>로 만든 가짜 버튼

만약 버튼을 이렇게 만들었다면 어땠을까요?

텍스트
   <button> 로 만든 진짜 버튼        <div onclick> 으로 만든 가짜 버튼
   ┌──────────────────────────┐      ┌──────────────────────────┐
   │ ✅ Tab 으로 도착         │      │ ❌ Tab 이 그냥 지나침    │
   │ ✅ Enter·Space 로 눌림   │      │ ❌ 마우스로만 눌림       │
   │ ✅ "버튼" 이라고 읽힘    │      │ ❌ 그냥 글자로 읽힘      │
   └──────────────────────────┘      └──────────────────────────┘

겉모습은 CSS로 똑같이 꾸밀 수 있어요. 하지만 <div>는 키보드도, 스크린 리더도 "버튼"으로 대접하지 않아요. 그걸 흉내 내려면 tabindex, 키 이벤트, role="button"을 일일이 손으로 붙여야 해요. 우리가 처음부터 <button>을 쓴 게 이 모든 수고를 없앤 거예요. "버튼처럼 보이는 것"이 아니라 "버튼인 것"을 쓰세요.


Step 5: 포커스 가두기 — 모달이 열리면 포커스도 그 안에 머물러야 한다

공유 버튼을 누르면 가운데에 다이얼로그(dialog, 대화상자)가 떠요. 이때 키보드 사용자에게 중요한 게 하나 있어요. 포커스가 다이얼로그 안에만 머물러야 해요.

왜 포커스를 가둬야 할까

텍스트
   포커스 가둠 없음                  포커스 가둠 있음 (포커스 트랩)
   ┌─────────────────────┐            ┌─────────────────────┐
   │  [공유 다이얼로그]  │            │  [공유 다이얼로그]  │
   │  받는사람 [____]    │            │  받는사람 [____]    │
   │  [보내기] [닫기]    │            │  [보내기] [닫기]    │
   └─────────────────────┘            └─────────────────────┘
     Tab  뒤 배경 메뉴로            Tab  다시 첫 칸으로 (안에서 순환)
     새어 나감 😵                    갇혀서 안전 ✅

포커스가 다이얼로그 밖 배경으로 새어 나가면, 키보드 사용자는 "보이지도 않는 뒤쪽"을 더듬게 돼요. 그래서 다이얼로그가 열려 있는 동안엔 Tab이 그 안에서만 돌고, Esc로 닫혀야 해요. 이걸 포커스 트랩(focus trap)이라고 불러요.

네이티브 <dialog>는 트랩을 공짜로 준다

여기서 또 한 번 보상이 와요. 우리는 공유창을 표준 <dialog> 태그로 만들었어요.

HTML
<!-- instagram-clone-frontend/feed.html -->
<dialog id="shareDialog">
  <h2>공유하기</h2>
  <p>이 게시물을 친구에게 보내보세요.</p>
  <form action="/share" method="post">
    <label for="share-to">받는 사람</label>
    <input type="text" id="share-to" name="shareTo" placeholder="친구 아이디" required>
    <button type="submit">보내기</button>
  </form>
  <form method="dialog">
    <button type="submit">닫기</button>
  </form>
</dialog>

이 창을 여는 버튼은 command="show-modal"로 다이얼로그를 모달(modal) 방식으로 띄워요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<button type="button" class="icon-btn icon-btn-share" command="show-modal" commandfor="shareDialog" aria-label="공유">

모달로 열린 <dialog>는 브라우저가 ① 포커스를 다이얼로그 안으로 옮기고, ② Tab을 그 안에 가두고, ③ Esc로 닫는 동작까지 전부 알아서 해 줘요. 우리가 포커스 트랩 코드를 한 줄도 안 짰는데 말이에요. 만약 <div>로 모달을 만들었다면 이 셋을 전부 손으로 구현해야 했어요.

게시물 메뉴(#postMenu)도 표준 팝오버(popover) 기능으로 만들어서, Esc로 닫히고 바깥을 누르면 사라져요. 다시 한번, 표준 기능을 쓰면 접근성이 따라온다는 패턴이에요.

⚠️ 직접 만든 <div> 모달은 포커스 트랩을 스스로 구현해야 해요. 그래서 가능하면 <dialog>·팝오버 같은 표준 기능을 먼저 쓰는 게 안전하고 편해요.


Step 6: 이름 붙이기 — aria-label 로 아이콘 버튼에 목소리를 준다

스크린 리더는 버튼을 읽을 때 그 버튼의 이름(accessible name, 접근 가능한 이름)을 읽어요. 그런데 우리 좋아요 버튼 안엔 하트 그림(SVG)만 있고 글자가 없어요. 그럼 뭐라고 읽힐까요? 아무 이름이 없으면 "버튼"이라고만 읽혀서, 무슨 버튼인지 알 수가 없어요.

우리는 이미 이름을 달아 뒀다

다행히 우리 버튼들엔 aria-label이 붙어 있어요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<button type="button" class="icon-btn icon-btn-like" aria-label="좋아요" aria-pressed="false">
  <svg class="ico" aria-hidden="true"><use href="assets/icons.svg#ico-heart"></use></svg></button>
<button type="button" class="icon-btn" aria-label="댓글">
  <svg class="ico" aria-hidden="true"><use href="assets/icons.svg#ico-comment"></use></svg></button>

aria-label="좋아요"가 그림뿐인 버튼에 "좋아요"라는 이름을 달아 줘요. 안쪽 SVG엔 aria-hidden="true"가 있어서 그림 자체는 안 읽고, 버튼의 이름만 "좋아요 버튼"으로 깔끔하게 읽혀요. 이름과 장식을 분리한 거예요.

텍스트
   <button aria-label="좋아요">           버튼의 이름
       <svg aria-hidden="true"> ♡ </svg>   장식(읽지 않음)
   </button>
        │
        └─ 스크린 리더: "좋아요, 버튼"   그림 대신 이름이 들려요

랜드마크(landmark)에도 같은 원리를 썼어요. 사이드바 메뉴엔 aria-label="주요 메뉴"를, 지금 보는 페이지 링크엔 aria-current="page"를 줘서, 스크린 리더가 "주요 메뉴, 홈, 현재 페이지"처럼 위치를 짚어 줘요.

HTML
<!-- instagram-clone-frontend/feed.html -->
<nav class="sidebar" aria-label="주요 메뉴">
  ...
  <li><a href="feed.html" aria-current="page"> ... 홈 ... </a></li>

aria-label vs aria-labelledby — 화면에 글자가 있다면

이름을 주는 방법은 두 가지예요. 화면에 보이는 글자가 없으면 aria-label로 직접 적고, 화면에 이미 보이는 글자가 있으면 그걸 가리키는 aria-labelledby를 써요.

텍스트
   aria-label="좋아요"           이름을 직접 글자로 적는다 (보이는 글자 없을 때)
   aria-labelledby="제목id"      화면의 그 글자를 이름으로 빌려 쓴다 (보이는 글자 있을 때)

왜 둘을 구분할까요? 한 가지 실무 이유가 있어요. 브라우저 번역 기능이 페이지를 다른 언어로 바꿀 때, aria-label 값은 번역에서 빠질 수 있어요. 화면 글자는 번역되는데 aria-label만 원래 언어로 남는 거죠. 그래서 화면에 이미 글자가 있다면, 그 글자를 가리키는 aria-labelledby가 번역까지 따라와 더 안전해요. 우리 아이콘 버튼처럼 글자가 아예 없는 곳은 aria-label이 맞고요.


Step 7: 상태와 변화 알리기 — aria-pressed 와 aria-live

이름은 줬어요. 그런데 좋아요엔 한 가지가 더 있어요. 눌렸는지 안 눌렸는지예요. 우리는 그걸 빨간 하트로 보여 주지만, 그건 눈으로만 보이는 정보예요. 스크린 리더 사용자에겐 안 들려요.

aria-pressed — 토글 버튼의 켜짐/꺼짐을 귀에도

좋아요는 누르면 켜지고 다시 누르면 꺼지는 토글(toggle) 버튼이에요. 토글 상태는 aria-pressed로 알려요. 처음 만들 때 false로 시작하게 적어 뒀어요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<button type="button" class="icon-btn icon-btn-like" aria-label="좋아요" aria-pressed="false">

그리고 좋아요를 토글할 때 이 값을 같이 바꿔 줘요.

JavaScript
// instagram-clone-frontend/js/like.js
const likeBtn = article.querySelector(".icon-btn-like");
const liked = likeBtn.classList.toggle("is-active");

// 토글 버튼의 '눌림' 상태를 스크린 리더에게 알려줘요. aria-pressed 가 true 면
// '좋아요, 눌림' 처럼 읽혀요. 색(빨간 하트)은 눈으로만 보이니 이 한 줄로 귀에도 전해요.
likeBtn.setAttribute("aria-pressed", String(liked));

classList.toggle이 돌려준 liked(켜졌으면 true)를 그대로 aria-pressed에 넣어요. 이제 스크린 리더는 "좋아요, 눌림" / "좋아요, 안 눌림"처럼 상태까지 읽어 줘요. 색으로만 전하던 정보를 소리로도 전한 거예요.

텍스트
   눈으로 보는 사람          스크린 리더로 듣는 사람
   ──────────────          ──────────────────────
   🤍 빈 하트      안 눌림   "좋아요, 버튼, 안 눌림"
   ❤️ 빨간 하트    눌림     "좋아요, 버튼, 눌림"    aria-pressed 덕분

여기서 이름(aria-label="좋아요")은 일부러 안 바꿔요. 상태는 aria-pressed가 맡으니까요. 이름은 고정, 상태는 따로 — 역할을 나눠 두는 게 깔끔하고 번역에도 안전해요.

aria-live — 화면이 바뀐 걸 소리로 알린다

좋아요를 누르면 숫자가 "1,240개" → "1,241개"로 바뀌어요. 댓글을 달면 목록에 한 줄이 늘고요. 눈으로 보면 바로 알지만, 스크린 리더는 사용자가 그 자리에 다시 가지 않으면 변화를 몰라요. 그래서 "여기 바뀌면 소리 내어 알려 줘" 표시를 달아요. 그게 aria-live예요.

JavaScript
// instagram-clone-frontend/js/post-card.js
<p class="post-likes" aria-live="polite" aria-atomic="true">좋아요 <strong>${post.likes.toLocaleString()}</strong>개</p>
<p class="post-caption"><strong>${post.username}</strong> ${post.caption}</p>
<p class="post-comments"><a href="#comments">댓글 ${post.commentCount}개 모두 보기</a></p>
<ul class="comment-list" aria-live="polite"></ul>

aria-live="polite"는 "지금 하던 읽기를 끊지 말고, 끝나면 알려 줘"라는 공손한 알림이에요. 좋아요 수에 붙인 aria-atomic="true"는 "바뀐 숫자만 말고 '좋아요 1241개' 문장 전체를 읽어 줘"라는 뜻이고요. 댓글 목록(.comment-list)에도 aria-live를 줘서, 새 댓글이 붙으면 그 댓글이 읽혀요.

삭제 버튼에도 작은 손질을 했어요. 화면엔 "삭제" 두 글자뿐이라 무엇을 지우는지 흐릿하거든요.

JavaScript
// instagram-clone-frontend/js/comment.js
delBtn.textContent = "삭제";
// 화면엔 "삭제" 두 글자뿐이라, 스크린 리더로 들으면 무엇을 지우는지 흐릿해요.
// aria-label 로 "댓글 삭제" 라고 또렷한 이름을 달아줘요(보이는 글자는 그대로).
delBtn.setAttribute("aria-label", "댓글 삭제");

보이는 글자 "삭제"는 그대로 두고, aria-label로 "댓글 삭제"라는 또렷한 이름을 얹었어요. 눈으로 보는 사람은 그대로, 듣는 사람은 더 분명하게 — 둘 다 챙긴 거예요.

🙋 학생 질문 — "aria-live='polite' 말고 'assertive'도 있던데 뭐가 달라요?"

assertive는 "하던 읽기를 끊고 당장 알려"예요. 급한 경고(결제 실패, 세션 만료 같은)에만 써요. 좋아요 수나 댓글처럼 "알면 좋지만 급하진 않은" 변화엔 polite가 맞아요.

assertive를 남발하면 스크린 리더가 계속 말을 끊고 끼어들어서, 오히려 쓰기 불편해져요. "정말 지금 끊어야 하나?"를 기준으로 고르면 돼요.


Step 8: 색이 흐리면 안 보인다 — 색상 대비를 숫자로 맞춘다

마지막 무리는 눈으로 보지만 색 구분이 약한 분들이에요. 저시력, 색약, 혹은 햇빛 아래에서 화면을 보는 누구나요. 이분들에겐 글자와 배경의 대비(contrast)가 충분해야 글씨가 읽혀요.

대비는 느낌이 아니라 숫자다

대비는 "밝기 차이"를 비율로 잰 숫자예요. WCAG는 일반 글자에 4.5:1 이상을 요구해요(큰 글자나 굵은 글자는 3:1까지 허용). 흰 바탕에 검은 글자는 21:1로 최고, 흰 바탕에 연한 회색 글자는 비율이 낮아 읽기 힘들어요.

우리 디자인 토큰을 열어 보니, 보조 텍스트 색이 문제였어요.

CSS
/* instagram-clone-frontend/css/variables.css (고치기 전) */
--gray-500: #8e8e8e;
--color-text-soft: var(--gray-500);   /* 시간·캡션 등 보조 글자에 쓰는 회색 */

#8e8e8e 회색을 우리 배경(#fafafa) 위에서 재 보면 약 3.1:1이에요. 4.5:1에 한참 못 미쳐요. 인스타그램 특유의 연한 회색인데, 측정해 보니 기준 미달이었던 거예요. 그래서 한 단계 진한 회색으로 바꿨어요.

CSS
/* instagram-clone-frontend/css/variables.css (고친 뒤) */
--gray-500: #8e8e8e;
--gray-600: #6e6e6e; /* 본문 위 보조 텍스트가 WCAG AA(4.5:1)를 넘게 한 단계 진한 회색 */
...
--color-text-soft: var(--gray-600); /* #8e8e8e(3.1:1)는 AA 미달 → #6e6e6e(4.9:1)로 */
텍스트
   배경 #fafafa 위 보조 글자 대비 (WCAG AA 기준선 = 4.5:1)

   #8e8e8e  ███████░░░░░░░░░░  3.1 : 1   ✗ 미달 (흐릿)
   #6e6e6e  ███████████░░░░░   4.9 : 1   ✓ 통과 (또렷)
                          4.5 기준선

의미 토큰(--color-text-soft) 한 줄만 진한 회색을 가리키게 바꾸니, 그 색을 쓰던 시간·캡션·추천 글자가 전부 한 번에 또렷해졌어요. 색을 한곳에서 관리한 보람이에요.

못 바꾸는 색도 있다 — 트레이드오프 읽기

그럼 모든 색을 다 4.5:1로 올리면 될까요? 꼭 그렇진 않아요. 우리 브랜드 색인 인스타 파랑(#0095f6) 버튼에 흰 글자를 올리면 대비가 약 3.2:1이에요. 일반 글자 기준(4.5:1)엔 못 미쳐요. 하지만 버튼 글자는 굵게 쓰니 큰·굵은 글자 3:1 예외에 걸쳐 있어요. 여기서 판단이 갈려요.

텍스트
   인스타 파랑 #0095f6 + 흰 글자 = 약 3.2 : 1
        │
        ├─ 브랜드색을 지킨다   굵은 글자라 3:1 예외에 걸침, 그대로 둠
        └─ 대비를 더 올린다    더 진한 파랑으로 (브랜드 인상은 옅어짐)

정답은 하나가 아니에요. 브랜드 정체성과 가독성 사이의 트레이드오프예요. 우리는 굵은 글자라는 예외에 기대 브랜드 파랑을 지키되, "이 색은 일반 글자로 쓰면 위험하다"는 걸 알고 쓰는 거예요. 접근성은 무조건 다 바꾸는 게 아니라, 재고 나서 의식적으로 정하는 일이에요.

💡 대비는 Chrome 개발자 도구로 바로 잴 수 있어요. 글자 요소를 검사하면 색상 옆에 대비 비율과 AA·AAA 통과 여부가 떠요. "느낌"이 아니라 그 숫자를 보고 정하세요.


Step 9: 종합 점검 — 키보드·소리·대비 세 갈래로 훑는다

오늘 손본 것들을 한 번에 점검해요. 접근성은 한 번 고치고 끝이 아니라, 새 기능을 더할 때마다 이 점검표를 다시 돌리는 습관이에요.

세 갈래 점검표

텍스트
   ⌨  키보드만으로
      □ Tab 으로 모든 버튼·링크에 닿는가
      □ 지금 포커스가 어디인지 항상 보이는가 (:focus-visible)
      □ 건너뛰기 링크가 첫 Tab 에 나오는가
      □ 모달이 Esc 로 닫히고 포커스가 안에 갇히는가

   🔊  소리로 들으며 (스크린 리더)
      □ 모든 이미지가 뜻 있는 alt 를 가졌는가 (장식은 빈 alt)
      □ 아이콘 버튼이 이름(aria-label)을 가졌는가
      □ 좋아요 상태(aria-pressed)가 읽히는가
      □ 좋아요·댓글 변화(aria-live)가 들리는가

   🎨  색 대비로
      □ 본문·보조 글자가 4.5:1 이상인가
      □ 큰·굵은 글자가 3:1 이상인가

직접 들어 보는 게 최고의 점검

표만 보지 말고, 한 번은 직접 체험해 보세요. 윈도우엔 NVDA(무료), 맥엔 VoiceOver(기본 내장)가 있어요. 스크린 리더를 켜고 눈을 감은 채 우리 피드에서 좋아요를 눌러 보면, 우리가 단 aria-labelaria-pressed가 실제로 어떻게 들리는지 단번에 와닿아요. 키보드만으로도 한 바퀴 돌아 보고요. 한 번 체험하면 "왜 이렇게까지 하나"가 몸으로 이해돼요.

Chrome 개발자 도구의 Lighthouse에도 접근성 점수가 있어요. 돌려 보면 우리가 놓친 항목을 짚어 줘요. 다만 점수는 시작일 뿐이에요. 자동 검사가 100점이어도 "버튼 이름이 적절한가", "alt가 사진을 잘 설명하나" 같은 의미는 사람이 직접 봐야 알아요. 숫자와 손점검을 함께 써야 진짜 접근성이에요.


마무리

오늘 우리는 같은 피드를 눈·소리·키보드·색 네 갈래로 다시 봤어요. 그동안 쌓아 온 좋은 습관 덕에 절반은 이미 갖춰져 있었고, 빈 구멍만 메우면 됐어요.

  • alt 점검: 의미 있는 사진엔 뜻을, 장식엔 빈 alt를. 그림뿐인 아이콘엔 aria-hidden.
  • 키보드 길: :focus-visible로 포커스를 또렷하게, 건너뛰기 링크로 메뉴를 질러가게. outline: none만 적고 끝내지 않기.
  • 시맨틱의 보상: <button>은 키보드를, 네이티브 <dialog>는 포커스 트랩을 공짜로 줬어요.
  • 이름과 상태·변화: aria-label로 이름을, aria-pressed로 토글 상태를, aria-live로 좋아요·댓글 변화를 소리로 전했어요.
  • 색 대비: #8e8e8e(3.1:1)를 #6e6e6e(4.9:1)로 올려 보조 글자를 또렷하게. 브랜드 파랑은 트레이드오프를 알고 남겼어요.

다음 시간 예고

오늘 우리는 접근성을 위해 <button>을 고집하고, <article>·<nav> 같은 뜻을 가진 태그를 썼어요. 그리고 아이콘마다 aria-label로 이름을 달았죠. 그런데 이렇게 구조가 뚜렷한 페이지를 좋아하는 게 한 무리 더 있어요. 바로 검색 엔진이에요. 구글 크롤러는 우리 페이지의 어디가 제목이고 어디가 메뉴인지, 뜻을 가진 태그를 보고 파악해요. 다음 시간(G-2)엔 이 구조 위에 메타 정보를 얹어요. 검색 결과에 어떻게 비칠지, SNS에 공유했을 때 미리보기가 어떻게 뜰지(Open Graph)를 다루는 SEO 기초예요. 접근성으로 다진 구조가 검색에도 그대로 힘이 된다는 걸 보게 될 거예요.


과제

오늘 배운 걸 직접 손으로 확인하고, 한 걸음 더 실험해 보는 과제예요. npm run mock과 Live Server를 켜 둔 채로 진행하세요.

[구현] 키보드만으로 한 바퀴 돌아보기

마우스에서 손을 떼고 feed.html을 키보드만으로 써 보세요.

  • 페이지를 새로 열고 Tab을 한 번 눌러, "본문으로 건너뛰기" 링크가 왼쪽 위에 나타나는지 확인하세요. Enter를 누르면 본문으로 가나요?
  • 계속 Tab을 눌러 좋아요 버튼까지 가서 Enter(또는 Space)로 눌러 보세요. 하트가 켜지나요? 지금 포커스가 어디 있는지 테두리로 보이나요?
  • 공유 버튼을 눌러 다이얼로그를 열고, Tab을 여러 번 눌러 보세요. 포커스가 다이얼로그 밖으로 새어 나가나요, 안에서 도나요? Esc로 닫히나요?
  • 한 곳이라도 포커스가 안 보이거나 닿지 않는 데가 있으면 적어 두세요.

[구현] 다른 페이지에도 건너뛰기 링크 달기

우리는 feed.html에만 건너뛰기 링크를 달았어요. index.html이나 profile.html에도 똑같이 달아 보세요.

  • <body> 첫 줄에 <a class="skip-link" href="#main-content">본문으로 건너뛰기</a>를 넣으세요.
  • 그 페이지의 <main>(또는 본문을 감싸는 요소)에 id="main-content"tabindex="-1"을 주세요.
  • Tab을 눌러 링크가 나타나고, Enter로 본문에 닿는지 확인하세요. skip-link CSS는 이미 components.css에 있으니 그대로 동작해요.

[탐구] 보조 텍스트 색을 더 진하게 바꿔 보기

css/variables.css에서 --gray-600 값을 더 진한 회색(예: #5a5a5a)으로 바꿔 저장해 보세요.

  • 화면에서 시간·캡션·추천 글자가 어떻게 달라지나요? 더 또렷해졌나요, 아니면 너무 진해서 인스타 느낌이 사라졌나요?
  • Chrome 개발자 도구로 그 글자의 대비 비율을 재 보고, 4.5:1을 얼마나 넘는지 확인하세요.
  • "읽기 좋음"과 "브랜드다움" 사이에서 여러분이라면 어떤 값을 고를지, 그 이유와 함께 적어 보세요. 확인했으면 원래 값(#6e6e6e)으로 되돌리세요.

생각해볼 주제

정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어 보는 질문들이에요. 스스로 답을 만들어 본 뒤, 예시답안과 비교해 보세요.

1. 색만으로 정보를 전하면 왜 위험할까?

우리는 좋아요가 눌리면 하트를 빨갛게 바꿔요. 보기엔 분명하죠. 그런데 색약인 분이나 흑백 화면으로 보는 분에게는 빨강과 회색이 비슷하게 보일 수 있어요. 만약 "빨간 하트 = 좋아요 됨"이라는 정보가 색에만 담겨 있다면 무슨 일이 생길까요? 우리가 aria-pressed를 더한 것, 그리고 하트 모양 자체가 빈 하트↔꽉 찬 하트로 바뀌는 것이 이 문제와 어떻게 연결되는지 생각해 보세요. "색은 거들 뿐, 색 말고도 알 방법이 있어야 한다"가 무슨 뜻일지 정리해 보세요.

2. <div>로 버튼을 만들면 정확히 무엇을 잃을까?

겉모습은 CSS로 <button>과 똑같이 꾸밀 수 있어요. 그런데 우리는 굳이 <button>을 고집했죠. 만약 좋아요를 <div onclick="...">로 만들었다면, 키보드 사용자와 스크린 리더 사용자는 각각 무엇을 못 하게 될까요? 그걸 다시 되살리려면 어떤 속성과 코드를 손으로 더 붙여야 할지 따라가 보세요. "버튼처럼 보이는 것"과 "버튼인 것"의 차이가 접근성에서 왜 그렇게 큰지 생각해 보세요.

3. 자동 접근성 점수가 100점이면 다 된 걸까?

Lighthouse 접근성 점수가 100점이 나왔어요. 그럼 우리 페이지는 모두에게 완벽할까요? 자동 검사가 잡아내는 것(이름이 없는 버튼, 너무 낮은 대비)과 잡아내지 못하는 것(alt가 사진을 엉뚱하게 설명함, 버튼 이름이 "버튼1"처럼 무의미함, Tab 순서가 뒤죽박죽임)을 떠올려 보세요. 점수가 왜 "끝"이 아니라 "시작"인지, 그리고 사람이 직접 키보드·스크린 리더로 확인해야 하는 부분은 무엇인지 정리해 보세요.

✅ 예시 답안정답 보기

오늘 과제는 "키보드와 색을 직접 만져 보며, 접근성이 실제로 동작하는지 두 눈으로(그리고 손끝으로) 확인하는" 거예요. 정답을 외우기보다, 마우스를 치우고 직접 써 보고 자기 말로 정리하는 게 핵심이에요. npm run mock과 Live Server를 켜 둔 채로 진행하세요.


과제 예시답안

🎯 [과제 1 예시답안] 키보드만으로 한 바퀴 돌아보기

핵심 접근

마우스에서 손을 떼는 순간, 우리가 만든 접근성이 진짜인지 가짜인지 바로 드러나요. "보이니까 되겠지"가 아니라 "Tab으로 닿고, Enter로 눌리고, 포커스가 보이는지"를 손끝으로 확인하는 게 목표예요.

확인 절차

  • feed.html을 새로 열고 Tab을 한 번 누르면, 왼쪽 위에 검은 "본문으로 건너뛰기" 띠가 나타나요. Enter를 누르면 메뉴를 건너뛰고 본문으로 들어가요.
  • 계속 Tab을 눌러 좋아요 버튼까지 가서 Enter 또는 Space로 눌러요. 하트가 켜지고, 지금 포커스 위치가 파란 테두리로 보여야 해요.
  • 공유 버튼을 눌러 다이얼로그를 연 뒤 Tab을 여러 번 눌러, 포커스가 다이얼로그 안에서만 도는지 봐요. Esc로 닫히는지도요.
텍스트
   키보드로 한 바퀴 (기대 동작)

   첫 Tab      [본문으로 건너뛰기] 등장
   Tab×N       로고·메뉴·게시물 버튼으로 포커스 이동 (테두리 보임)
   Enter       좋아요 켜짐 ❤️
   공유  Tab  다이얼로그 안에서만 순환 (밖으로 안 샘)
   Esc         다이얼로그 닫힘, 포커스 복귀

예상 결과와 해석

대부분 매끄럽게 돌아가요. <button>·<a>·<dialog> 같은 표준 태그를 쓴 덕분에 키보드 이동·실행·포커스 트랩이 공짜로 따라온 거예요. 포커스가 잘 보이는 건 :focus-visible 덕분이고, 댓글칸도 이제 포커스 링이 떠요(예전엔 outline: none으로 지워져 있었죠).

채점 포인트

포인트 설명 배점 가중
건너뛰기 링크 첫 Tab에 링크가 나오고 Enter로 본문에 닿는 걸 확인했는가
포커스 가시성 이동 중 포커스 위치가 항상 테두리로 보이는지 확인했는가
키보드 실행 좋아요를 Enter/Space로 눌러 켜지는 걸 확인했는가
포커스 트랩 다이얼로그에서 Tab이 안에서 돌고 Esc로 닫히는지 봤는가

흔한 실수

  • 포커스 테두리를 못 찾음 — 마우스로 클릭하면 :focus-visible라 테두리가 안 떠요. 마우스를 아예 쓰지 말고 Tab으로만 이동해야 보여요.
  • Space로 스크롤됨 — 버튼이 아닌 빈 곳에 포커스가 있으면 Space가 페이지를 스크롤해요. 포커스가 버튼 위에 있는지 먼저 확인하세요.
  • 다이얼로그 밖으로 포커스가 샘 — 만약 샌다면 command="show-modal"이 아니라 평범하게 열린 거예요. 모달로 열려야 트랩이 생겨요.

실무 개선 포인트 (심화)

  • 실무에선 "키보드 순서(Tab order)"가 화면에 보이는 순서와 어긋날 때가 많아요. CSS로 요소 위치를 옮기면 보이는 순서와 Tab 순서가 따로 놀 수 있어요. 그래서 마크업 순서 자체를 읽는 흐름대로 두는 게 첫 번째 원칙이에요.
  • 큰 서비스는 키보드 점검을 사람 손으로만 하지 않고, 자동화 테스트로 "모든 인터랙티브 요소에 Tab으로 닿는가"를 반복 검사해요. 다만 "포커스 순서가 자연스러운가"는 결국 사람이 직접 눌러 봐야 알아요.

🎯 [과제 2 예시답안] 다른 페이지에도 건너뛰기 링크 달기

핵심 접근

feed.html에서 만든 건너뛰기 링크 패턴을 다른 페이지에 그대로 옮겨 보는 과제예요. 새 CSS는 필요 없어요 — components.css.skip-link가 모든 페이지에서 공유되니까, 마크업 두 곳만 손대면 돼요.

답안

profile.html(또는 index.html)에 두 군데를 고쳐요. 먼저 <body> 첫 줄에 링크를 넣어요.

HTML
<!-- instagram-clone-frontend/profile.html -->
<body class="app">
  <a class="skip-link" href="#main-content">본문으로 건너뛰기</a>
  ...

그리고 본문을 감싸는 <main>에 링크가 가리킬 id와, 포커스를 받을 수 있게 하는 tabindex를 줘요.

HTML
<main id="main-content" tabindex="-1">

이게 전부예요. feed.html과 똑같은 구조죠. Tab을 눌러 검은 띠가 나타나고, Enter로 본문에 닿으면 성공이에요.

텍스트
   건너뛰기 링크 한 벌 = 마크업 두 줄

   <a class="skip-link" href="#main-content">   body 첫 줄에 링크
   <main id="main-content" tabindex="-1">        본문에 같은 id + 포커스 허용

   CSS(.skip-link)는 이미 components.css 에 있어 그대로 동작

채점 포인트

포인트 설명 배점 가중
링크-타깃 짝 href="#main-content"id="main-content"를 정확히 맞췄는가
tabindex <main>tabindex="-1"을 줘 포커스가 가게 했는가
동작 확인 Tab으로 링크가 뜨고 Enter로 본문에 닿는 걸 확인했는가
CSS 재사용 새 CSS 없이 기존 .skip-link가 동작함을 이해했는가

흔한 실수

  • idhref가 안 맞음href="#main"인데 id="main-content"면 아무 데도 안 가요. 글자 하나까지 똑같아야 해요.
  • tabindex 빠뜨림<main>은 원래 포커스를 못 받아요. tabindex="-1"이 없으면 링크를 눌러도 포커스가 본문으로 안 옮겨가요(다음 Tab 위치가 어정쩡해져요).
  • 링크를 <body> 한참 아래에 둠 — 건너뛰기 링크는 맨 첫 요소여야 해요. 중간에 두면 그 앞 요소들을 못 건너뛰어요.

실무 개선 포인트 (심화)

  • 페이지에 본문 말고도 건너뛸 만한 큰 묶음(검색, 사이드바)이 여럿이면 건너뛰기 링크를 여러 개 두기도 해요. "본문으로", "메뉴로"처럼요. 다만 너무 많으면 그것대로 번거로워, 보통 본문 하나로 충분해요.
  • 공통 레이아웃을 한 파일에서 관리하는 큰 프로젝트에선 건너뛰기 링크를 그 공통 틀에 한 번만 넣어 모든 페이지가 자동으로 갖게 해요. 우리처럼 페이지가 따로면 각 페이지에 넣어야 하고요.

🎯 [과제 3 예시답안] 보조 텍스트 색을 더 진하게 바꿔 보기

핵심 접근

색 하나를 바꿔 보며 "읽기 좋음"과 "브랜드다움"이 어떻게 부딪히는지 몸으로 느끼는 탐구 과제예요. 의미 토큰 한 줄을 바꾸면 화면 전체가 같이 바뀌는 것도 확인해요.

답안

css/variables.css에서 --gray-600 값을 더 진하게 바꿔요.

CSS
/* instagram-clone-frontend/css/variables.css */
--gray-600: #5a5a5a;   /* 6e6e6e → 5a5a5a 로 더 진하게 (실험) */

저장하면 시간·캡션·"회원님을 위한 추천" 같은 보조 글자가 일제히 진해져요. --color-text-soft가 이 토큰 하나를 가리키니까, 그 색을 쓰던 모든 곳이 한 번에 바뀌는 거예요. 대비를 재 보면 #5a5a5a는 흰 배경에서 약 7:1로, AA(4.5:1)는 물론 더 엄격한 AAA(7:1)에도 닿아요.

텍스트
   같은 보조 글자, 색만 바꿨을 때 (배경 #fafafa)

   #8e8e8e  3.1 : 1   ✗ 원래 (흐릿)
   #6e6e6e  4.9 : 1   ✓ 우리 선택 (또렷 + 회색 느낌 유지)
   #5a5a5a  7.0 : 1   ✓ 실험 (가장 또렷, 다만 인스타 특유의 연함은 사라짐)

읽기엔 #5a5a5a가 제일 좋아요. 하지만 인스타그램 특유의 "연한 회색 보조 글자" 느낌은 옅어져요. 정답은 없어요 — 가독성을 최우선으로 본다면 진하게, 브랜드 인상을 지키되 기준은 넘기려면 #6e6e6e처럼 둘 사이에서 골라요. 핵심은 숫자를 보고 의식적으로 정한다는 거예요. 확인했으면 #6e6e6e로 되돌리세요.

채점 포인트

포인트 설명 배점 가중
한곳 전파 토큰 한 줄을 바꾸니 여러 글자가 함께 바뀐 걸 확인했는가
대비 측정 개발자 도구로 바뀐 색의 대비 비율을 숫자로 확인했는가
트레이드오프 가독성과 브랜드다움 사이의 판단을 자기 말로 적었는가
되돌리기 실험 후 #6e6e6e로 원복했는가

흔한 실수

  • --gray-500을 바꿈 — 보조 글자는 --color-text-soft--gray-600을 가리켜요. --gray-500을 바꾸면 그 색을 쓰는 다른 데(테두리 등)가 엉뚱하게 바뀌어요.
  • 대비를 눈대중으로 판단 — "충분히 진해 보인다"는 느낌이지 숫자가 아니에요. 개발자 도구가 보여 주는 비율로 확인해야 해요.
  • 다크 모드를 안 봄 — 다크 모드의 보조 글자는 --color-text-soft를 다른 어두운 값으로 따로 정의해 둬서, 이 실험과 별개로 움직여요. 라이트 모드에서 확인하세요.

실무 개선 포인트 (심화)

  • 디자인 토큰을 원시 토큰(--gray-600)과 의미 토큰(--color-text-soft)으로 나눈 게 오늘 큰 도움이 됐어요. 색을 바꿀 때 "회색 6번"이 아니라 "보조 텍스트"라는 쓰임새 단위로 한 번에 바꿀 수 있으니까요.
  • 실무에선 대비 검사를 디자인 단계에서부터 자동으로 돌려, 기준 미달 색 조합이 화면에 들어오기 전에 걸러요. 색을 다 칠한 뒤 고치는 것보다, 토큰을 정할 때 한 번 통과시켜 두는 게 훨씬 싸요.

생각해볼 주제 예시답안

1. 색만으로 정보를 전하면 왜 위험할까?

[문제 상황 요약]

좋아요가 눌리면 하트를 빨갛게 바꿔요. 그런데 색약인 분이나 흑백 화면으로 보는 분에게는 빨강과 회색이 비슷하게 보일 수 있어요. "좋아요 됨"이라는 정보가 색에만 담겨 있다면 무슨 일이 생길까요?

[튜터의 가이드 및 해설]

그분들에겐 "눌렸는지 안 눌렸는지"가 안 보여요. 정보가 색 하나에만 실려 있으면, 그 색을 구별 못 하는 순간 정보 자체가 사라지는 거예요. 그래서 접근성의 오래된 원칙이 있어요 — "색은 거들 뿐, 색 말고도 알 방법이 있어야 한다."

우리 좋아요는 다행히 색 말고도 두 갈래로 더 알려 줘요. 첫째, 하트 모양이 빈 하트(🤍)에서 꽉 찬 하트(❤️)로 바뀌어요. 색을 못 봐도 모양이 다르니 구별돼요. 둘째, aria-pressed가 "눌림/안 눌림"을 스크린 리더에게 말로 전해요. 색·모양·소리 세 갈래라, 한 감각이 막혀도 나머지가 메워 줘요.

이게 핵심이에요. 빨간 하트 하나에 모든 걸 걸지 않고, 모양과 상태값으로 정보를 여러 통로에 나눠 실은 거예요. 에러 메시지를 빨간색으로만 표시하지 않고 ⚠️ 아이콘과 글자를 함께 두는 것도, 필수 입력칸을 빨간 별 하나가 아니라 "필수"라는 글자로도 알리는 것도 같은 이치예요.

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

"색은 정보를 전하는 보조 수단이지 유일한 수단이면 안 돼요. 색약 사용자나 흑백 환경에선 색이 통째로 사라지니까요. 그래서 저는 상태를 색·모양·텍스트 여러 통로에 나눠 실어요. 좋아요는 빨강뿐 아니라 하트 모양 변화와 aria-pressed로도 상태를 전하고, 에러는 색뿐 아니라 아이콘과 문구로도 알려요. 'WCAG의 색에만 의존하지 않기' 원칙을 코드로 지키는 거죠."


2. <div>로 버튼을 만들면 정확히 무엇을 잃을까?

[문제 상황 요약]

겉모습은 CSS로 <button>과 똑같이 꾸밀 수 있어요. 그런데 우리는 굳이 <button>을 고집했죠. 좋아요를 <div onclick="...">로 만들었다면 키보드·스크린 리더 사용자는 각각 무엇을 못 하게 될까요?

[튜터의 가이드 및 해설]

<div>는 그냥 "상자"예요. 의미가 없어요. 그래서 <button>이 공짜로 주던 세 가지를 한꺼번에 잃어요.

첫째, 키보드로 닿지 못해요. <button>은 자동으로 Tab 순서에 끼지만, <div>는 안 껴요. 키보드 사용자는 그 좋아요에 영영 도달을 못 해요. 둘째, 키보드로 누르지 못해요. Tab으로 어찌어찌 포커스를 줘도, Enter·Space로는 안 눌려요. <div>는 마우스 클릭만 알거든요. 셋째, 스크린 리더가 "버튼"으로 안 읽어요. 그냥 글자 덩어리로 읽혀서, 누를 수 있는 거라는 것조차 몰라요.

이걸 되살리려면 손이 많이 가요. tabindex="0"으로 Tab 순서에 넣고, keydown을 들어 Enter·Space를 직접 클릭으로 연결하고, role="button"으로 "이건 버튼이야"라고 알려야 해요. <button> 하나가 공짜로 주던 걸 세 가지 코드로 겨우 흉내 내는 거예요. 게다가 빠뜨리기도 쉬워서 사고가 잦아요.

그래서 결론은 단순해요. "버튼처럼 보이는 것"이 아니라 "버튼인 것"을 쓰세요. 시맨틱 태그를 쓰면 접근성이 기본으로 따라오고, <div>로 흉내 내면 그 기본을 일일이 다시 만들어야 해요.

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

"<div onclick>은 키보드 접근(Tab 도달·Enter 실행)과 스크린 리더의 역할 인식을 한꺼번에 잃어요. 되살리려면 tabindex·키 이벤트 핸들러·role을 손으로 다 붙여야 하는데, 그건 <button>이 이미 공짜로 주는 것들이에요. 그래서 저는 '버튼처럼 보이게 꾸미기'보다 '버튼 태그를 쓰고 꾸미기'를 택해요. 시맨틱 HTML이 곧 접근성의 가장 싼 보험입니다."


3. 자동 접근성 점수가 100점이면 다 된 걸까?

[문제 상황 요약]

Lighthouse 접근성 점수가 100점이 나왔어요. 그럼 우리 페이지는 모두에게 완벽할까요?

[튜터의 가이드 및 해설]

아니에요. 100점은 "기계가 검사할 수 있는 것"이 다 통과했다는 뜻이지, "사람이 쓰기 좋다"는 뜻이 아니에요. 자동 검사는 형식은 잘 잡지만 의미는 못 봐요.

자동 검사가 잘 잡는 것들이 있어요. 이름 없는 버튼, 너무 낮은 색 대비, alt 속성이 통째로 빠진 이미지처럼 "있나 없나"로 판별되는 것들이요. 반대로 못 잡는 게 더 많아요. alt="이미지123"처럼 속성은 있는데 내용이 엉터리인 경우, 버튼 이름이 "버튼1"처럼 달려는 있지만 무의미한 경우, Tab 순서가 화면 흐름과 뒤죽박죽인 경우요. 기계는 "alt가 있다"까지만 보지, "그 alt가 사진을 제대로 설명하나"는 판단을 못 해요.

그래서 점수는 시작이에요. 자동 검사로 빤한 빈틈을 먼저 메우고, 그다음엔 사람이 직접 키보드로 한 바퀴 돌고 스크린 리더로 들어 봐야 해요. 눈을 감고 좋아요를 눌러 봤을 때 "좋아요, 눌림"이 자연스럽게 들리는지, Tab 순서가 읽는 흐름과 맞는지는 사람만 알 수 있어요. 자동화는 빠르고 일관되지만 의미를 모르고, 사람은 느리지만 의미를 알아요. 둘을 함께 써야 진짜 접근성이에요.

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

"자동 접근성 점수 100점은 '기계가 검사 가능한 항목'이 통과했다는 뜻이지 '모두가 쓰기 좋다'는 보장이 아니에요. 자동 도구는 속성의 유무·대비 수치는 잡지만, alt 내용이 적절한지·버튼 이름이 의미 있는지·Tab 순서가 자연스러운지 같은 의미는 못 봐요. 그래서 저는 Lighthouse·axe로 빠른 1차 점검을 하고, 반드시 키보드 전수 탐색과 스크린 리더 실청취로 마무리해요. 자동화는 시작점, 수동 검증이 마침표입니다."

전체 목록 HTML·CSS·JS