♿ 07. 웹 접근성 기초: 모든 사람을 위한 웹 만들기

2026년 3월 5일 수정됨

📋 개요

ARIA 역할, 키보드 접근성, focus ring, skip link, 스크린리더 — 접근성 없는 서비스가 놓치는 것들을 5년 차의 언어로 파헤칩니다.

📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 18분 / 핵심 파트만: 10분

🗺️ 이 문서의 흐름

[접근성이란 무엇인가] → [키보드 접근성] → [ARIA 기초] → [스크린리더 체크리스트] → [React에서의 접근성]

🎯 이 문서를 다 읽으면 할 수 있는 것

  • 키보드만으로 영수네 커뮤니티를 탐색할 수 있도록 구현할 수 있다.
  • aria-label, aria-hidden, aria-live 를 상황에 맞게 사용할 수 있다.
  • Skip Navigation 링크를 구현할 수 있다.
  • React 컴포넌트에서 포커스 관리를 할 수 있다.

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 🐣 영철 ( 신입 ): "영호 님, 접근성은 장애인을 위한 거잖아요. 우리 서비스 사용자 중에 시각장애인이 있을 거라고는 생각 안 하는데, 꼭 해야 하나요? 솔직히 일정도 빠듯한데..."
  • 🦁 영호 ( 리드 ): "영철 님, 사용자 중 10~20%가 어떤 형태로든 장애가 있어요. 그리고 접근성은 장애인만을 위한 게 아니에요. 키보드로 폼 탭해가는 파워 유저, 밝은 햇볕 아래서 화면 보기 어려운 상황, 팔이 부러져서 마우스 못 쓰는 사람들... 접근성 좋은 서비스가 모두에게 더 편한 서비스예요."

🤔 왜 알아야 하는가

영수네 커뮤니티에 VoiceOver(macOS 스크린리더) 켜고 접속해본 영호 리드의 반응:

🦁 영호 리드: "영철 님, 스크린리더 켜면 우리 메인 네비가 그냥 '링크 링크 링크 링크'만 읽혀요. 어떤 링크인지 알 수가 없어요. 아이콘 버튼들도 '버튼 버튼 버튼'이고요. 그리고 Tab 키로 탐색하면 포커스가 어디 있는지 아예 안 보여요."

접근성 문제는 사용자 경험과 법적 의무(정보통신접근성 가이드라인, WCAG) 모두에 영향을 준다.


⌨️ 1. 키보드 접근성 — Tab 키로만 모든 기능을

모든 마우스로 할 수 있는 작업은 키보드로도 할 수 있어야 해.

<!-- ❌ 키보드 접근 불가: div로 만든 버튼 -->
<div onclick="handleClick()">클릭하세요</div>
<!-- Tab 포커스 안 됨, Enter 키 작동 안 됨 -->
 
<!-- ✅ 키보드 접근 가능: 올바른 버튼 요소 -->
<button type="button" onclick="handleClick()">클릭하세요</button>
<!-- Tab 포커스, Enter + Space 키 자동 지원 -->

tabindex 속성:

<!-- tabindex="0": Tab 순서에 포함 (자연스러운 DOM 순서로) -->
<div role="button" tabindex="0" onclick="..." onkeypress="...">커스텀 버튼</div>
 
<!-- tabindex="-1": Tab 순서에서 제외, JS로만 포커스 이동 가능 -->
<div id="modal-content" tabindex="-1">모달 내용</div>
<!-- JS: document.getElementById('modal-content').focus() -->

Skip Navigation 링크:

<!-- body 최상단에 위치: 스크린리더 사용자가 내비게이션을 건너뛸 수 있게 -->
<body>
  <a href="#main-content" class="skip-link">본문 바로가기</a>
  <nav>...긴 내비게이션...</nav>
  <main id="main-content" tabindex="-1">
    <!-- 여기부터 핵심 콘텐츠 -->
  </main>
</body>
/* Skip link: 평소엔 화면 밖에, 포커스 받으면 화면에 표시 */
.skip-link {
  position: absolute;
  top: -3rem;
  left: 1rem;
  background: #000;
  color: #fff;
  padding: 0.5rem 1rem;
  z-index: 9999;
}
.skip-link:focus {
  top: 1rem;
}

🏷️ 2. ARIA 기초 — 시맨틱 태그로 안 되는 것들을

ARIA(Accessible Rich Internet Applications) 는 HTML 요소에 추가적인 의미를 부여해 스크린리더가 이해할 수 있게 해줘.

<!-- aria-label: 텍스트 없는 아이콘 버튼에 이름 지정 -->
<button aria-label="게시글 삭제">
  <svg><!-- 쓰레기통 아이콘 --></svg>
</button>
 
<!-- aria-hidden: 스크린리더에서 완전히 숨기기 (장식 요소) -->
<span aria-hidden="true">👍</span>  <!-- 이모지는 스크린리더가 "손을 들어 엄지를 올린 기호"라고 읽음 -->
<span class="like-count">24</span>
 
<!-- aria-live: 동적으로 업데이트되는 내용 알림 -->
<div aria-live="polite" aria-atomic="true">
  <!-- 메시지 전송 후 상태 업데이트 → 스크린리더에 자동 알림 -->
  {status && <p>{status}</p>}
</div>
 
<!-- aria-expanded: 접기/펼치기 상태 전달 -->
<button
  aria-expanded="false"
  aria-controls="dropdown-menu"
  onclick="toggleMenu()"
>
  메뉴 펼치기
</button>
<ul id="dropdown-menu" hidden>
  <li>설정</li>
  <li>로그아웃</li>
</ul>

⚠️ ARIA 사용 1순위 원칙
No ARIA is better than bad ARIA. 시맨틱 HTML 태그로 해결되면 ARIA를 쓰지 않는 게 낫기도 해요. <button> 대신 <div role="button" tabindex="0"> 을 쓰면, Enter/Space 키 핸들러를 직접 달아야 하는 등 부가 작업이 생겨요.


⚛️ 3. React에서의 접근성

// 모달: 열릴 때 포커스 이동, 닫힐 때 트리거 버튼으로 복원
function Modal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      // 모달 열리기 전 포커스된 요소 저장
      previousFocusRef.current = document.activeElement as HTMLElement;
      // 모달로 포커스 이동
      modalRef.current?.focus();
    } else {
      // 모달 닫히면 이전 포커스 복원
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);
 
  if (!isOpen) return null;
 
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      tabIndex={-1}  // JS로만 포커스, Tab 순서에서 제외
    >
      <h2 id="modal-title">회원가입 완료</h2>
      <button onClick={onClose}>닫기</button>
    </div>
  );
}

접근성 자동 체크 도구:

# eslint-plugin-jsx-a11y: JSX 접근성 lint 규칙
npm install -D eslint-plugin-jsx-a11y
 
# .eslintrc에 추가
# "extends": ["plugin:jsx-a11y/recommended"]


📝 마무리 퀴즈

Q1. 아이콘만 있는 '게시글 삭제' 버튼을 스크린리더에서 올바르게 읽히게 하는 방법은?

정답: <button aria-label="게시글 삭제"> + <svg aria-hidden="true"> 조합

💡 상세 해설:

  • 원리 설명: 스크린리더는 버튼의 텍스트 또는 aria-label 을 읽어요. 아이콘 SVG에 aria-hidden="true" 를 두면 스크린리더가 SVG를 건너뛰고, 버튼의 aria-label 을 읽해요.
  • 📌 핵심 기억법: "아이콘 버튼 = aria-label 필수 + SVG엔 aria-hidden='true'"

Q2. 포커스 링(Focus Ring)을 CSS로 outline: none 제거하는 것이 왜 문제인가?

정답: 키보드 사용자가 현재 포커스된 요소를 시각적으로 확인할 수 없어 서비스를 탐색할 수 없게 됨

💡 상세 해설:

  • 원리 설명: 포커스 링은 키보드 사용자의 '마우스 커서' 역할을 해요. outline: none 으로 제거하면 키보드 사용자는 '현재 어디 있는지' 알 수 없어요. 마우스 사용 시에는 포커스 링 없애고 키보드 사용 시에만 표시하고 싶다면 :focus-visible CSS 가상 클래스를 사용해요.
  • 📌 핵심 기억법: "outline: none 전체 제거 금지, 대신 :focus-visible 로 키보드 포커스만 스타일링"

Q3. 영철이의 테스트 타임

영수네 커뮤니티에 알림 토스트가 새로 생겼다. 게시글 '좋아요' 를 누르면 화면 우측 하단에 "좋아요를 눌렀습니다!" 가 팝업된다. 스크린리더 사용자도 이 알림을 들을 수 있게 하려면?

정답: 토스트 컨테이너에 role="status" 또는 aria-live="polite" 적용

💡 상세 해설:

  • 원리 설명: 동적으로 추가되는 콘텐츠는 스크린리더가 자동으로 읽지 않아요. aria-live="polite" 는 현재 읽고 있는 내용이 끝나면 업데이트된 내용을 읽어주고, aria-live="assertive" 는 즉시 끊고 읽어줘요. 토스트처럼 비중요 알림은 polite 가 적합해요.
  • 📌 핵심 기억법: "동적 업데이트 알림 = aria-live='polite', 긴급 알림 = aria-live='assertive'"

🐣 영철이의 퇴근 일기

오늘 영호 리드 님이 직접 VoiceOver 켜고 우리 서비스 시연해주셨는데... 진짜 말문이 막혔다. "버튼 버튼 버튼" 이거 들을 때 내가 만든 게 얼마나 불친절한지 새삼 느꼈다.

💡 "접근성은 장애인을 위한 특별한 기능이 아니라, 모든 사용자를 위한 기본 품질이다. 시맨틱 HTML만 잘 써도 접근성의 80%는 해결된다."

Skip link 추가하고, 아이콘 버튼에 aria-label 붙이고 나니까 VoiceOver에서 낭독이 확 달라졌다. 생각보다 엄청 간단한 작업이었는데 왜 이걸 미뤘나 싶다. 퇴근하고 eslint-plugin-jsx-a11y 세팅해봐야겠다.


🔗 더 알아보기