
웹 접근성(a11y): 키보드만으로 내 서비스를 쓸 수 있을까?
내 서비스를 키보드만으로 써보려다 탭이 엉뚱한 곳으로 날아갔다. 웹 접근성을 실제로 개선하면서 배운 것들을 정리했다.

내 서비스를 키보드만으로 써보려다 탭이 엉뚱한 곳으로 날아갔다. 웹 접근성을 실제로 개선하면서 배운 것들을 정리했다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

마우스를 잠시 치워두고 키보드만 써보기로 했다. Tab, Enter, Space, 화살표 키. 간단할 줄 알았다.
첫 번째 Tab을 눌렀다. 포커스가 어딘가로 갔다. 화면을 아무리 봐도 어디에 포커스가 있는지 안 보였다. 두 번째 Tab. 포커스 아웃라인이 헤더 로고에 잠깐 보이다가 사라졌다. 세 번째 Tab. 갑자기 페이지 아래쪽 Footer에 있는 링크가 선택됐다. 네비게이션 메뉴는 건너뛰었다.
모달을 열었다. 닫기 버튼이 어디 있는지 찾다가 Tab을 계속 눌렀더니 포커스가 모달 뒤에 깔린 페이지로 빠져나가 버렸다. 모달이 열린 상태에서 배경 콘텐츠를 탐색하고 있었다. Escape를 눌렀다. 아무 일도 안 일어났다.
폼 필드에는 레이블이 없었다. placeholder만 있었다. 입력 중에 placeholder가 사라지면 "이 칸이 뭘 입력하는 칸이었지?" 싶어진다. 실제로 그 상황이 됐다.
10분도 안 돼서 포기했다. 마우스 없이는 내가 만든 서비스를 거의 쓸 수 없었다.
스크린 리더 사용자, 손 떨림이 있는 사용자, 마우스 없이 키보드로만 컴퓨터를 쓰는 사용자. 나는 그들을 서비스에서 사실상 배제하고 있었다.
접근성(accessibility, a11y)이 중요하다는 건 알고 있었다. 개념도 알고 있었다. 근데 실제로 내 서비스에 적용이 안 돼 있었다.
이유를 생각해봤다.
HTML을 div로만 쌓는 습관이 있었다. 클릭 가능한 버튼을 만들 때 <button> 대신 <div onClick={...}>으로 만들었다. 스타일 주기가 더 편하다는 이유였다. 이 div는 마우스로 클릭은 되지만 Tab으로 포커스가 안 가고, Enter나 Space로 활성화도 안 된다. 키보드 사용자는 존재하지 않는 버튼이나 마찬가지다.
<img> 태그에 alt 속성을 생략하거나 alt=""로 비워뒀다. 스크린 리더는 이미지를 "image" 또는 파일명으로 읽는다. "button-icon-32.png"라는 안내를 받는 사용자 입장을 생각해본 적이 없었다.
색상 대비(color contrast)도 마찬가지였다. 디자인 감각으로 "예쁘다"고 생각한 연한 회색 텍스트가, 시각 장애가 있는 사람에게는 아예 안 보이는 색일 수 있다는 걸 몰랐다.
이걸 비유하면 건물에 계단만 있는 상황이다. 설계자는 계단을 쓰니까 아무 문제 없다. 하지만 휠체어를 탄 사람, 유모차를 미는 부모, 무거운 짐을 끄는 사람은 건물을 쓸 수 없다. 경사로를 만들면 오히려 모든 사람이 편해진다. 접근성도 똑같다. 장애가 있는 사람만을 위한 게 아니라 모두를 위한 설계다.
접근성 개선의 80%는 시맨틱 HTML 하나로 해결된다고 해도 과언이 아니다.
div는 의미 없는 박스다. 브라우저와 스크린 리더는 div를 보면 "이게 뭔지 모르겠다"고 한다. 반면 <button>, <nav>, <main>, <header>, <article>은 의미가 내장된 태그들이다. 브라우저가 이들을 보면 "아, 이건 탐색 영역이구나", "이건 누를 수 있는 버튼이구나"하고 이해한다.
직접 비교해보면 차이가 명확하다.
나쁜 예 (div 수프) | 좋은 예 (시맨틱 HTML) |
|---|---|
<div class="nav"> | <nav aria-label="주 내비게이션"> |
<div class="btn" onClick={...}> | <button type="button"> |
<div class="heading">제목</div> | <h2>제목</h2> |
<div class="list"> + <div class="item"> | <ul> + <li> |
<div class="main-content"> | <main> |
<div class="footer"> | <footer> |
시맨틱 태그를 쓰면 따라오는 것들이 있다.
<button>을 쓰면 키보드 포커스가 자동으로 된다. Enter와 Space로 클릭이 된다. 스크린 리더가 "버튼"이라고 읽어준다. 이 세 가지를 <div>로 구현하려면 tabindex="0", role="button", onKeyDown 핸들러를 일일이 붙여야 한다. 그냥 <button> 쓰면 공짜로 얻는다.
<h1> ~ <h6> 계층 구조를 지키면 스크린 리더 사용자가 "다음 제목으로 이동" 단축키로 페이지를 빠르게 탐색할 수 있다. 목차처럼 쓸 수 있게 되는 것이다. 디자인 때문에 폰트 크기를 조절하고 싶다면 태그 레벨은 유지하면서 CSS로 조절하면 된다.
이걸 비유하면 브라우저의 언어로 말하는 것이다. 외국인과 대화할 때 그들의 언어로 말하면 훨씬 잘 통한다. 시맨틱 HTML이 그렇다. 브라우저와 보조 기술이 이해하는 언어로 마크업하면, 별도의 ARIA 설정 없이도 많은 게 자동으로 동작한다.
ARIA(Accessible Rich Internet Applications)에는 첫 번째 규칙이 있다.
ARIA를 사용하지 말 것. 가능하다면 네이티브 HTML 요소를 써라.
처음 이걸 읽었을 때 의아했다. ARIA가 접근성을 위한 거 아닌가? 왜 쓰지 말라고 하나.
이유가 있었다. ARIA는 브라우저에게 "이건 이런 역할이에요"라고 선언하는 것일 뿐, 실제 동작을 만들어주지는 않는다. role="button"을 div에 붙이면 스크린 리더가 "버튼"이라고 읽어준다. 하지만 Enter/Space로 클릭되는 동작은 직접 구현해야 한다. 포커스 관리도 직접 해야 한다. 반면 그냥 <button> 태그를 쓰면 이 모든 게 기본으로 된다. ARIA는 네이티브 HTML만으로 표현할 수 없는 커스텀 위젯에 쓰는 마지막 수단이다.
ARIA가 실제로 필요한 경우는 이런 상황들이다.
role="combobox", aria-expanded, aria-autocompleterole="tablist", role="tab", role="tabpanel", aria-selectedaria-live="polite" 또는 aria-live="assertive"aria-busy="true"aria-current="page"반대로 ARIA를 잘못 쓰는 경우가 흔하다.
// 이렇게 하면 안 된다
<div role="button" onClick={handleClick}>
클릭하세요
</div>
// 이렇게 해야 한다
<button type="button" onClick={handleClick}>
클릭하세요
</button>
// 이것도 불필요한 ARIA
<nav role="navigation">
<ul>...</ul>
</nav>
// nav 자체가 이미 navigation landmark다
<nav aria-label="주 내비게이션">
<ul>...</ul>
</nav>
ARIA를 과도하게 쓰면 오히려 접근성이 나빠진다. 잘못된 role이 붙으면 스크린 리더가 엉뚱하게 읽고, 사용자는 더 혼란스럽다. 네이티브 HTML을 먼저 쓰고, 정말로 표현이 안 될 때만 ARIA를 추가하는 게 맞다.
말로만 하면 추상적이니, 실제로 고친 패턴들을 정리한다.
스크린 리더 사용자가 페이지를 방문할 때마다 헤더와 네비게이션을 처음부터 읽어야 한다면 불편하다. 키보드 사용자도 마찬가지다. 매번 Tab을 20번 눌러서 헤더를 지나야 한다면 쓸 수가 없다.
"본문 바로가기" 링크가 해결책이다. 페이지 최상단에 두되, 평소에는 안 보이다가 Tab을 누르면 나타나는 패턴이다.
// SkipNav.tsx
export function SkipNav() {
return (
<a
href="#main-content"
className="
sr-only
focus:not-sr-only
focus:fixed
focus:top-4
focus:left-4
focus:z-50
focus:px-4
focus:py-2
focus:bg-blue-600
focus:text-white
focus:rounded
"
>
본문 바로가기
</a>
);
}
// Layout에서
<SkipNav />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
sr-only는 Tailwind CSS 유틸리티로, 시각적으로는 숨기지만 스크린 리더는 읽을 수 있다. focus:not-sr-only로 포커스를 받으면 다시 보이게 된다. main에 tabIndex={-1}을 붙이면 링크 클릭 시 포커스가 main으로 이동한다.
모달이 열렸을 때 Tab을 누르면 포커스가 모달 안에서만 순환해야 한다. 모달 뒤 배경으로 포커스가 빠져나가면 안 된다. 이걸 "포커스 트랩(focus trap)"이라고 한다.
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 모달 열릴 때 현재 포커스 저장
previousFocusRef.current = document.activeElement as HTMLElement;
// 모달 안의 첫 번째 포커스 가능 요소로 이동
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusableElements?.[0]?.focus();
// Escape 키로 닫기
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
// Tab 키 순환 제한
if (e.key === 'Tab' && focusableElements) {
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
// 모달 닫힐 때 원래 포커스로 복원
if (!isOpen && previousFocusRef.current) {
previousFocusRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
>
<h2 id="modal-title">모달 제목</h2>
{children}
<button type="button" onClick={onClose}>닫기</button>
</div>
);
}
포커스 트랩의 핵심은 세 가지다. 모달이 열릴 때 안으로 포커스 이동, Tab/Shift+Tab이 모달 범위를 벗어나지 못하도록 막기, 닫힐 때 원래 포커스 위치 복원. 이 패턴을 매번 직접 구현하기보다는 Radix UI나 shadcn/ui가 이미 이걸 내장하고 있다.
// 이렇게 하면 안 된다
<input
type="email"
placeholder="이메일을 입력하세요"
className="..."
/>
// 이렇게 해야 한다
<div>
<label htmlFor="email" className="block text-sm font-medium">
이메일
</label>
<input
id="email"
type="email"
placeholder="example@email.com"
aria-describedby="email-hint"
className="..."
/>
<p id="email-hint" className="text-sm text-gray-500">
가입 시 사용한 이메일 주소를 입력하세요
</p>
</div>
htmlFor와 id로 레이블과 입력 필드를 연결하면, 레이블을 클릭했을 때 필드에 포커스가 간다. 스크린 리더는 필드에 포커스가 오면 레이블을 자동으로 읽는다. aria-describedby로 부가 설명도 연결하면 더 좋다. placeholder는 레이블을 대체할 수 없다. 입력 시작과 동시에 사라지고, 스크린 리더가 항상 읽어주지도 않는다.
구현했다고 끝이 아니다. 실제로 접근성이 작동하는지 확인해야 한다.
axe-core / axe DevTools가 가장 쉬운 시작점이다. Chrome/Firefox 확장 프로그램으로 설치하면, 현재 페이지의 접근성 문제를 자동으로 스캔해서 보여준다. 색상 대비 부족, 레이블 없는 입력 필드, role 오용 같은 것들을 잡아준다. 100% 다 잡아주지는 않지만, 기계적으로 확인 가능한 문제들은 대부분 찾아낸다.
Lighthouse 접근성 감사도 유용하다. Chrome DevTools에서 Lighthouse 탭을 열고 접근성 항목을 체크하면 점수와 구체적인 이슈 목록을 준다. CI에 통합할 수도 있다.
# CI에서 Lighthouse 접근성 점수 확인
npx @lhci/cli autorun --config=lighthouserc.json
키보드 직접 테스트는 여전히 중요하다. 마우스를 옆에 두고 키보드만으로 서비스를 처음부터 끝까지 써보는 것이다. 포커스가 보이는지, 논리적인 순서로 이동하는지, 모달과 드롭다운이 제대로 닫히는지, 폼 제출이 되는지.
스크린 리더 테스트는 macOS라면 VoiceOver(Command + F5), Windows라면 NVDA(무료)로 할 수 있다. 처음에는 낯설어서 어렵지만, 기본 단축키 몇 개만 익히면 핵심 경험을 확인할 수 있다.
이 테스트들을 쭉 해보면 느끼는 게 있다. 접근성 문제는 대부분 "한 번도 키보드로 안 써봤기 때문"에 발생한다. 개발할 때 마우스를 잠깐 치워두는 습관이 가장 강력한 접근성 테스트다.
전부 다 한 번에 고치려면 엄두가 안 난다. 아래 항목들만 챙겨도 대부분의 접근성 문제가 해결된다.
1. 포커스 아웃라인 제거하지 않기/* 절대 이렇게 하면 안 된다 */
* {
outline: none;
}
/* 이렇게 해야 한다: 마우스 사용자에게는 숨기되, 키보드 포커스는 유지 */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
outline: none을 전역으로 제거하는 건 키보드 사용자에게 눈을 가리는 것과 같다. :focus-visible을 쓰면 마우스로 클릭할 때는 아웃라인이 안 보이고, 키보드 포커스일 때만 보인다.
alt 속성alt="" (스크린 리더가 건너뜀)aria-label로 버튼 목적을 설명일반 텍스트는 배경 대비 4.5:1, 큰 텍스트(18pt+ 또는 굵은 14pt+)는 3:1 이상이어야 한다. WebAIM Contrast Checker나 Figma 플러그인으로 확인할 수 있다.
4. Radix UI / shadcn 쓰기이 부분이 실질적으로 가장 강력한 빠른 개선책이다. Radix UI는 접근성을 기본값으로 설계된 헤드리스 컴포넌트 라이브러리다. Dialog, DropdownMenu, Select, Tabs, Tooltip 같은 복잡한 위젯들이 모두 ARIA 표준을 따르고 포커스 관리가 내장되어 있다. shadcn/ui는 Radix를 기반으로 스타일까지 입혀놨다. 모달, 드롭다운, 폼 컴포넌트를 직접 구현하는 것보다 Radix/shadcn을 쓰는 게 접근성 측면에서 훨씬 낫다.
접근성을 경사로에 비유했는데, 경사로는 비용이 드는 별도 시설이 아니라 처음부터 설계에 포함되면 오히려 더 사용성이 좋은 구조물이다. Radix/shadcn을 쓰면 접근성을 "추가"하는 게 아니라 처음부터 접근성이 있는 컴포넌트를 쓰는 것이다.
키보드로 직접 써봐야 안다. 탭 이동, 모달, 폼, 드롭다운. 마우스 없이 5분 써보면 문제가 바로 보인다.
시맨틱 HTML이 기반이다. <button>, <nav>, <main>, <label> 같은 올바른 태그를 쓰면 ARIA 없이도 많은 게 해결된다.
ARIA의 첫 번째 규칙은 쓰지 말라는 것이다. 네이티브 HTML로 안 될 때 마지막으로 쓰는 도구다.
모달에는 포커스 트랩이 필수다. 열릴 때 포커스 이동, Tab 순환 제한, 닫힐 때 포커스 복원. Radix Dialog가 이걸 다 해준다.
axe DevTools + 키보드 직접 테스트로 대부분 잡힌다. 100% 완벽한 접근성은 어렵지만, 이 두 가지로 80%는 커버된다.
Radix/shadcn을 쓰면 공짜로 얻는 게 많다. 접근성을 나중에 고치는 비용보다 처음부터 접근성이 있는 컴포넌트를 쓰는 게 낫다.
내가 만든 서비스를 키보드만으로 써보려다 탭이 엉뚱한 곳으로 날아갔을 때, 그게 단순한 UX 버그가 아니라는 걸 깨달았다. 누군가에게는 그게 서비스를 아예 쓸 수 없다는 뜻이었다.