♿ 07. 웹 접근성 기초: 모든 사람을 위한 웹 만들기
📋 개요
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-visibleCSS 가상 클래스를 사용해요. - 📌 핵심 기억법: "
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 세팅해봐야겠다.
🔗 더 알아보기