Skip to Content
WebReactReact 실무18. 접근성 감사와 보정

이번 편의 결과물: 로그인 폼·책 등록 폼·책 테이블·모달을 마우스 없이 키보드만으로 조작할 수 있게 됩니다. Lighthouse 접근성 감사 점수를 개선 전후로 기록합니다. · 다루는 개념: Lighthouse 접근성 감사, 키보드만으로 폼·테이블·모달 조작, ARIA 라벨·포커스 이동 보정

이 편에서 만드는 파일

bookshelf/src/ ├── components/ │ ~ Modal.jsx ← role, aria-modal, 포커스 트랩, Escape 닫기 │ ~ BookTable.jsx ← <caption>, scope, aria-sort 추가 ├── pages/ │ ~ LoginPage.jsx ← label htmlFor/id 연결, aria-live 에러 │ ~ NewBookPage.jsx ← aria-invalid, aria-describedby로 zod 에러 연결 └── index.css ← ~ .sr-only, :focus-visible 스타일 추가

개념 정리

Lighthouse 접근성 감사가 확인하는 것

Lighthouse의 Accessibility 카테고리는 자동화 가능한 접근성 규칙만 검사합니다. 대비 비율, alt 속성 존재, 폼 라벨 연결, ARIA 속성 오용 여부 등입니다. 점수가 100이어도 키보드로 실제 조작 가능한지는 자동 검사로 완전히 잡히지 않습니다. 그래서 이 편은 Lighthouse 점수와 수동 키보드 점검을 함께 합니다.

접근 가능한 이름과 라벨 연결

스크린 리더는 폼 요소를 읽을 때 접근 가능한 이름(accessible name)을 사용합니다. <label><input>htmlFor/id로 연결하지 않으면 스크린 리더 사용자는 입력칸이 무엇을 위한 것인지 알 수 없습니다.

방법예시비고
htmlFor + id 연결<label htmlFor="email">이메일</label><input id="email" />가장 명시적, 권장
<label>로 감싸기<label>이메일<input /></label>id 없이도 연결됨
aria-label<input aria-label="이메일" />화면에 보이는 라벨이 없을 때만

포커스 이동과 트랩

모달처럼 화면 일부를 가리는 요소가 열리면, 포커스가 그 안에 갇혀야 합니다(포커스 트랩, focus trap). 그렇지 않으면 Tab 키를 눌렀을 때 포커스가 모달 뒤의 화면 요소로 빠져나가 사용자가 모달이 열려 있다는 사실조차 모른 채 다른 곳을 조작하게 됩니다. 모달이 닫힐 때는 포커스를 모달을 연 버튼으로 되돌려야 합니다.

aria-live로 동적 에러 알리기

로그인 실패 메시지처럼 사용자 조작 없이 화면에 나타나는 텍스트는 스크린 리더가 자동으로 읽어주지 않습니다. aria-live="polite"를 준 영역에 메시지를 넣으면, 스크린 리더가 현재 읽던 것을 마치고 나서 새 내용을 읽어 줍니다.

실습

1. Lighthouse로 현재 상태 감사하기

npm run build npm run preview npx lighthouse http://localhost:4173 --view --only-categories=accessibility

리포트에서 실패한 항목을 확인합니다. bookshelf에서 자주 나오는 항목은 “Form elements do not have associated labels”, “Elements do not have sufficient color contrast”, “[aria-*] attributes do not match their roles” 세 가지입니다.

2. 로그인 폼 라벨과 에러 알림 고치기

// 수정 전 (07편 산출물, 발췌) function LoginPage() { const [email, setEmail] = useState('') const [error, setError] = useState(null) return ( <form onSubmit={handleSubmit}> <input placeholder="이메일" value={email} onChange={(e) => setEmail(e.target.value)} /> {error && <p className="error">{error}</p>} <button type="submit">로그인</button> </form> ) }
// 수정 후 function LoginPage() { const [email, setEmail] = useState('') const [error, setError] = useState(null) return ( <form onSubmit={handleSubmit}> <label htmlFor="login-email">이메일</label> <input id="login-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={Boolean(error)} /> <p role="alert" aria-live="polite"> {error} </p> <button type="submit">로그인</button> </form> ) }

placeholder는 라벨을 대신하지 못합니다. 입력을 시작하면 사라져서 어떤 값인지 잊게 만들기 때문입니다. role="alert"를 준 문단은 내용이 빈 문자열일 때는 아무것도 읽지 않다가, 에러 텍스트가 들어오는 순간 스크린 리더가 즉시 읽어 줍니다.

3. 책 등록 폼 에러를 aria-describedby로 연결하기

10편에서 만든 zod 검증 에러를 시각적으로만 보여주고 있었다면, 스크린 리더 사용자는 어떤 입력칸이 왜 잘못됐는지 알 수 없습니다.

// src/pages/NewBookPage.jsx (발췌, 제목 필드) <label htmlFor="book-title">제목</label> <input id="book-title" {...register('title')} aria-invalid={Boolean(errors.title)} aria-describedby={errors.title ? 'book-title-error' : undefined} /> {errors.title && ( <p id="book-title-error" role="alert"> {errors.title.message} </p> )}

aria-describedby는 입력칸과 에러 문단을 id로 연결해, 스크린 리더가 입력칸에 포커스될 때 “제목, 필수 입력입니다” 처럼 라벨과 에러를 함께 읽게 합니다. 에러가 없을 때는 undefined를 넣어 속성 자체를 없애야 빈 연결이 남지 않습니다.

4. 테이블에 caption과 aria-sort 추가하기

// src/components/BookTable.jsx (발췌 — books·onSelectBook props는 17편 코드 유지) function BookTable({ books, onSelectBook }) { const sortBy = useUiStore((state) => state.sortBy) const sortOrder = useUiStore((state) => state.sortOrder) const setSort = useUiStore((state) => state.setSort) function ariaSortFor(key) { if (sortBy !== key) return 'none' return sortOrder === 'asc' ? 'ascending' : 'descending' } return ( <table> <caption className="sr-only">책 목록, 열 제목을 눌러 정렬합니다</caption> <thead> <tr> <th scope="col" aria-sort={ariaSortFor('title')}> <button type="button" onClick={() => setSort('title')}>제목</button> </th> <th scope="col" aria-sort={ariaSortFor('author')}> <button type="button" onClick={() => setSort('author')}>저자</button> </th> <th scope="col">쪽수</th> <th scope="col">상태</th> </tr> </thead> {/* ...tbody는 17편 코드(BookRow 매핑) 그대로... */} </table> ) }

<caption>은 화면에는 안 보여도(.sr-only) 스크린 리더에게 표의 목적을 알려줍니다. scope="col"은 각 셀이 어느 열에 속하는지 명시해, 행을 옮겨 다녀도 열 제목을 다시 읽어 줍니다. 정렬 버튼은 <th> 안에 <div onClick>이 아니라 실제 <button>이어야 Tab으로 접근하고 Enter로 실행할 수 있습니다. aria-sort는 현재 정렬 방향을 스크린 리더에 알립니다.

.sr-only 클래스가 아직 없다면 전역 스타일에 추가합니다.

/* src/index.css (추가) */ .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; } :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }

.sr-only는 요소를 화면에서만 안 보이게 숨기고 스크린 리더에는 남겨두는 표준 패턴입니다. :focus-visible은 키보드로 포커스했을 때만 테두리를 보여줘, 마우스 클릭 시 생기던 불필요한 테두리 없이 키보드 사용자에게 현재 위치를 알려줍니다.

5. 모달에 포커스 트랩과 Escape 닫기 추가하기

// src/components/Modal.jsx import { useEffect, useRef } from 'react' export function Modal({ isOpen, onClose, title, children }) { const dialogRef = useRef(null) const previouslyFocused = useRef(null) useEffect(() => { if (!isOpen) return previouslyFocused.current = document.activeElement dialogRef.current?.focus() function handleKeyDown(event) { if (event.key === 'Escape') { onClose() } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) previouslyFocused.current?.focus() } }, [isOpen, onClose]) if (!isOpen) return null return ( <div className="modal-backdrop" onClick={onClose}> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onClick={(event) => event.stopPropagation()} > <h2 id="modal-title">{title}</h2> {children} <button type="button" onClick={onClose} aria-label="닫기"> 닫기 </button> </div> </div> ) }

role="dialog"aria-modal="true"는 스크린 리더에게 이 영역이 화면의 나머지를 가리는 대화 상자임을 알립니다. tabIndex={-1}을 준 다음 dialogRef.current?.focus()로 모달이 열리자마자 포커스를 안으로 옮기고, previouslyFocused에 저장해둔 요소로 닫힐 때 되돌립니다. Escape 키 처리는 마우스 없이도 닫을 수 있게 합니다. 완전한 포커스 트랩(Tab 순환)은 “직접 해보기”에서 확장합니다.

6. 키보드만으로 전체 흐름 점검하기

마우스를 치우고 Tab, Shift+Tab, Enter, Escape만으로 다음을 확인합니다.

  • 로그인 폼: 이메일 → 비밀번호 → 로그인 버튼 순서로 포커스가 이동하는가.
  • 책 테이블: 정렬 버튼에 Tab으로 도달하고 Enter로 정렬이 바뀌는가.
  • 책 등록 모달: 열리면 포커스가 모달 안으로 들어가고, Escape로 닫으면 원래 버튼으로 돌아오는가.

Lighthouse를 다시 실행해 점수를 기록합니다.

측정 시점Accessibility 점수
개선 전기록한 값
개선 후기록한 값

직접 해보기

Modal 안에서 Tab 키가 모달 밖 요소로 빠져나가지 않도록, 마지막 포커스 가능 요소에서 Tab을 누르면 첫 요소로 돌아가는 로직을 추가해 보세요.

dialogRef.current.querySelectorAll('button, input, a[href]')로 포커스 가능한 요소 목록을 구하고, keydown 핸들러에서 event.key === 'Tab'일 때 현재 포커스가 마지막 요소면 event.preventDefault() 후 첫 요소로, 첫 요소에서 Shift+Tab이면 마지막 요소로 포커스를 옮깁니다.

자주 하는 실수

증상원인고치는 법
스크린 리더가 입력칸 용도를 안 읽는다placeholder만 쓰고 <label> 연결이 없음htmlFor/id로 라벨 연결
에러 메시지가 스크린 리더에 안 읽힌다에러 문단에 aria-live·role="alert" 없음role="alert" 추가
모달이 열려도 Tab이 뒤 화면 요소로 넘어간다포커스 트랩 미구현dialogRef.focus()와 Tab 순환 처리
정렬 헤더를 키보드로 조작 못한다<div onClick>으로 구현실제 <button> 요소 사용

확인 문제

문제 14지선다
입력칸의 placeholder만 쓰고 label을 연결하지 않으면 생기는 접근성 문제는 무엇입니까
문제 24지선다
모달이 열렸을 때 포커스 트랩이 필요한 이유는 무엇입니까
문제 34지선다
role=alert가 붙은 요소의 스크린 리더 동작으로 옳은 것은 무엇입니까

참고 자료

Last updated on