React 접근성 — aria 속성과 키보드 내비게이션 구현
마우스 없이 웹사이트를 사용해 본 적이 있나요? 키보드만으로 모든 기능을 사용할 수 있어야 하는 이유는 무엇일까요?
접근성(Accessibility, a11y)은 장애를 가진 사용자뿐만 아니라 모든 사용자의 경험을 개선합니다. 시각 장애인은 스크린 리더를, 운동 장애인은 키보드를, 인지 장애인은 명확한 구조를 필요로 합니다. React에서 접근성을 구현하는 핵심 방법을 정리합니다.
시맨틱 HTML — 접근성의 기초
가장 중요한 접근성 전략은 올바른 HTML 태그를 사용하는 것입니다.
// 나쁜 예 — div로 모든 것을 만듦
<div onClick={handleClick} className="button">클릭</div>
<div className="nav">내비게이션</div>
<div className="header">헤더</div>
// 좋은 예 — 시맨틱 태그 사용
<button onClick={handleClick}>클릭</button>
<nav>내비게이션</nav>
<header>헤더</header>
시맨틱 태그가 자동으로 제공하는 것
<button>: 포커스 가능, Enter/Space로 클릭,role="button"자동 설정<a href>: 포커스 가능, Enter로 이동,role="link"자동 설정<nav>: 스크린 리더에 "네비게이션 랜드마크"로 인식<main>: 메인 콘텐츠 영역으로 인식, 스킵 내비게이션 대상
div에 role="button"을 쓰면
// button 태그의 기능을 div로 재현하려면 이 모든 것이 필요
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
style={{ cursor: 'pointer' }}
>
클릭
</div>
// 그냥 button을 쓰면 됩니다
<button onClick={handleClick}>클릭</button>
aria 속성
시맨틱 HTML만으로 부족한 정보를 보충할 때 aria 속성을 사용합니다.
aria-label과 aria-labelledby
// aria-label — 텍스트가 없는 버튼에 레이블 제공
<button aria-label="메뉴 닫기" onClick={closeMenu}>
<XIcon /> {/* 아이콘만 있는 버튼 */}
</button>
// aria-labelledby — 다른 요소를 레이블로 참조
<h2 id="section-title">검색 결과</h2>
<ul aria-labelledby="section-title">
{results.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
aria-describedby
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">8자 이상, 대문자와 숫자를 포함해야 합니다</p>
aria-live — 동적 콘텐츠 알림
function SearchResults({ results, loading }) {
return (
<div>
{/* 스크린 리더에 동적 변경을 알림 */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{loading
? '검색 중...'
: `${results.length}개의 결과를 찾았습니다`}
</div>
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
aria-live="polite": 현재 작업이 끝난 후 변경 사항을 알립니다aria-live="assertive": 즉시 알립니다 (에러 메시지 등)aria-atomic="true": 영역 전체를 다시 읽습니다
시각적으로 숨기되 스크린 리더에는 보이게
/* sr-only 클래스 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
display: none이나 visibility: hidden은 스크린 리더에서도 숨겨집니다. sr-only는 시각적으로만 숨기고 보조 기술에서는 접근 가능합니다.
포커스 관리
모달 포커스 트래핑
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// 열기 전 포커스 저장
previousFocusRef.current = document.activeElement;
// 모달 내 첫 번째 포커스 가능 요소로 이동
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
return () => {
// 닫힐 때 이전 포커스로 복원
previousFocusRef.current?.focus();
};
}, [isOpen]);
// 포커스 트래핑 — Tab 키가 모달 안에서만 순환
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="대화상자"
>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{children}
</div>
</div>
);
}
스킵 내비게이션
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
본문으로 바로가기
</a>
);
}
function App() {
return (
<>
<SkipLink />
<Header />
<nav>...</nav>
<main id="main-content" tabIndex={-1}>
{/* 메인 콘텐츠 */}
</main>
</>
);
}
.skip-link {
position: absolute;
top: -40px;
left: 0;
z-index: 100;
}
.skip-link:focus {
top: 0;
/* 포커스 시에만 보임 */
}
키보드 내비게이션
커스텀 드롭다운 메뉴
function Dropdown({ options, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef(null);
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) => Math.min(prev + 1, options.length - 1));
}
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen && activeIndex >= 0) {
onSelect(options[activeIndex]);
setIsOpen(false);
} else {
setIsOpen(true);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
선택하세요
</button>
{isOpen && (
<ul role="listbox" ref={listRef}>
{options.map((option, index) => (
<li
key={option.id}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? 'active' : ''}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
폼 접근성
function AccessibleForm() {
const [errors, setErrors] = useState({});
return (
<form>
<div>
<label htmlFor="name">이름 (필수)</label>
<input
id="name"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name}
</span>
)}
</div>
<fieldset>
<legend>알림 방법 선택</legend>
<label>
<input type="radio" name="notify" value="email" /> 이메일
</label>
<label>
<input type="radio" name="notify" value="sms" /> SMS
</label>
</fieldset>
<button type="submit">제출</button>
</form>
);
}
핵심 규칙:
label과input을htmlFor/id로 연결합니다- 에러 메시지에
role="alert"를 추가하면 스크린 리더가 즉시 읽습니다 - 관련 라디오/체크박스는
fieldset과legend로 그룹화합니다
eslint-plugin-jsx-a11y
npm install -D eslint-plugin-jsx-a11y
자동으로 감지하는 접근성 문제:
img에alt누락- 클릭 가능한 비인터랙티브 요소에 키보드 이벤트 누락
aria-*속성의 잘못된 사용label과input의 미연결tabIndex의 양수 값 사용
정리
React 접근성의 핵심은 "기본에 충실하기"입니다.
- **시맨틱 HTML을 우선 사용합니다 **:
button,nav,main등 올바른 태그가 접근성의 기초입니다 - **aria 속성은 보충용입니다 **: 시맨틱 태그만으로 부족할 때만 aria를 추가합니다
- ** 포커스 관리가 핵심입니다 **: 모달, 라우트 전환, 동적 콘텐츠에서 포커스를 적절히 이동합니다
- ** 키보드로 모든 기능을 사용할 수 있어야 합니다 **: 마우스 전용 인터랙션은 키보드 대안이 필요합니다
- eslint-plugin-jsx-a11y 로 코드 작성 시점에 접근성 문제를 감지합니다
주의할 점
div에 onClick만 붙이고 키보드 접근성을 무시
<div onClick={...}>은 마우스로만 동작합니다. 키보드 사용자를 위해 tabIndex, onKeyDown, role="button" 등을 추가해야 합니다. 가장 좋은 해결책은 <button>을 사용하는 것입니다.
aria 속성을 시맨틱 태그 대신 사용
<div role="navigation">보다 <nav>가 더 간결하고 신뢰성이 높습니다. aria는 시맨틱 태그만으로 부족할 때 보충용 으로 사용해야 합니다.
접근성은 "추가 기능"이 아니라 "기본 기능"입니다. 처음부터 고려하면 나중에 수정하는 것보다 훨씬 적은 비용이 듭니다.
댓글 로딩 중...