📚 목차
[React] 시맨틱 태그, Aria, 포커스 트래핑으로 접근성(VoiceOver)과 SEO까지 개선하기
프론트엔드 개발을 하다 보면, 기능이 잘 동작하고 UI가 예쁘게 렌더링되면 끝이라고 느끼기 쉽다.
그런데 어느 순간부터 이런 질문이 계속 남았다.
- 이 UI는 스크린 리더(VoiceOver)가 이해할 수 있을까?
- 키보드(Tab)만으로도 흐름이 끊기지 않을까?
- 검색 엔진/크롤러가 이 페이지의 구조를 “문서”로 파악할 수 있을까?
특히 로그인/모달/내비게이션처럼 “앱의 뼈대”가 되는 UI는 시각 사용자에게만 잘 동작하면 충분하지 않다.
접근성은 옵션이 아니라 기본 품질이고, 시맨틱 태그와 ARIA는 그 품질을 문서 구조 수준에서 끌어올리는 도구다.
이번 글에서는 내가 실제 프로젝트에서 적용한 내용을 다음 순서로 정리한다.
- 시맨틱 태그가 왜 중요한가 (스크린리더/키보드/크롤러/SEO)
- ARIA는 언제, 어떻게 써야 하는가 (과하게 쓰지 않기)
- 폼에서 “에러가 보인다”를 “에러가 읽힌다”로
- 카드/링크/아이콘 UI에 의미 부여하기
- 모달에서 Tab 키 기반 키보드 네비게이션 + 포커스 트래핑(useModalFocus) 구현
- VoiceOver로 검증하는 체크리스트
1. 시맨틱 태그가 왜 중요한가?
1-1. div는 “박스”고, 시맨틱 태그는 “문장”이다
div는 레이아웃에는 편하지만 의미가 없다. 반면 시맨틱 태그는 그 자체로 역할을 가진다.
<form>: 입력을 제출하는 폼<label>: 입력 필드의 이름<button>: 클릭 가능한 인터랙션<nav>: 내비게이션 영역<main>: 페이지의 메인 콘텐츠<header>,<footer>: 상단/하단 영역<h1>~<h6>: 섹션의 계층 구조
시맨틱 태그는 “이 요소가 무슨 역할을 하나요?”를 명확히 한다.
같은 UI라도 시맨틱 태그를 쓰면 브라우저/스크린리더/크롤러가 이미 의미를 알고 있는 구조가 된다.
1-2. 스크린 리더는 “스크롤”보다 “탐색”을 한다
시각 사용자는 스크롤로 훑는다.
하지만 스크린 리더 사용자는 이런 방식으로 빠르게 이동한다.
- 랜드마크(메인/내비/헤더 등) 점프
- 헤딩 목록으로 섹션 이동
- 링크/버튼 목록으로 탐색
이때 시맨틱 구조가 없으면 페이지는 “의미 없는 박스들의 나열”이 되고, 사용자는 맥락을 잃는다.
반대로 구조가 명확하면 **페이지가 “탐색 가능한 문서”**가 된다.
1-3. SEO/크롤링 관점에서도 “해석 비용”이 줄어든다
시맨틱 태그 하나로 검색 순위가 갑자기 오르진 않는다.
하지만 다음 효과는 확실히 체감된다.
- 내비게이션/본문/제목/섹션이 분리되면서 콘텐츠 중심이 명확해짐
- 중복 텍스트(장식 요소/아이콘)가 정리되면서 본문 신호가 선명해짐
- 구조가 안정되면서 페이지 템플릿 품질이 일정해짐(크롤러 입장에서도 예측 가능)
즉, 시맨틱 태그는 “SEO 트릭”이 아니라 문서를 문서답게 만드는 기본기다.

2. ARIA는 “보완재”다: Semantic First, ARIA Last
ARIA는 강력하지만, 잘못 쓰면 오히려 접근성을 망친다.
내가 지킨 원칙은 단순했다.
- 먼저 네이티브 요소로 해결하기 (
button,a,input,label…) - 네이티브로 의미 전달이 부족할 때만 ARIA로 보완하기
- ARIA로 “역할”을 흉내내기보다 진짜 역할을 가진 태그로 바꾸기
예를 들어 div + role="button"은 가능하지만,
가능하면 애초에 <button>을 쓰는 것이 더 안전하고 유지보수도 쉽다.
2-1. form: “에러가 보인다”를 “에러가 읽힌다”로
form은 접근성의 기본이 드러나는 UI다.
- 라벨이 연결되어야 한다
- 에러가 발생하면 사용자가 즉시 인지해야 한다
- 스크린 리더가 에러 메시지와 입력 필드를 관계로 이해해야 한다
form으로 자주 쓰이는 로그인 폼으로 어떻게 개선했는지 보자. (style은 생략)
내 코드의 핵심은 aria-describedby + aria-invalid + 라이브 영역(aria-live)이다.
<fieldset>
<legend className='sr-only'>로그인 정보 입력</legend>
<div>
<label htmlFor='email'>이메일</label>
<input
id='email'
type='email'
placeholder='이메일을 입력해주세요'
value={formData.email}
onChange={handleChange('email')}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
<span id='email-error' role={errors.email ? 'alert' : undefined} aria-live='polite'>
{errors.email || ' '}
</span>
</div>
<div>
<label htmlFor='password'>비밀번호</label>
<input
id='password'
type='password'
placeholder='비밀번호를 입력해주세요'
value={formData.password}
onChange={handleChange('password')}
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
/>
<span
id='password-error'
role={errors.password ? 'alert' : undefined}
aria-live='polite'
>
{errors.password || ' '}
</span>
</div>
</fieldSet>aria-describedby: 에러 메시지와 입력 필드를 연결해 스크린 리더가 “이 필드에 이런 에러가 있어요”라고 읽게 한다.aria-invalid: 입력 필드가 에러 상태임을 명시해 스크린 리더가 “이 필드는 잘못된 값이에요”라고 알린다.role="alert"+aria-live="polite": 에러 메시지에 라이브 영역을 지정해 에러가 생기면 스크린 리더가 즉시 읽도록 한다.
즉, 화면에서 빨간 글씨가 보이는 것에 그치지 않고
“스크린 리더가 에러를 인지하고 안내하는 흐름”을 만들 수 있다.
2-2. 카드/내비게이션/아이콘: “의미”를 채우는 디테일
시맨틱 태그를 잘 써도, 실제 제품 UI는 Card, Chip, IconButton, Tabs 같은 디자인 시스템 컴포넌트로 쌓이면서 의미가 흐려지기 쉽다.
특히 접근성에서 자주 터지는 지점은 “겉으로는 클릭 가능해 보이지만, 기계(스크린리더/키보드)는 그걸 모른다”는 문제다.
“클릭 가능한 영역”은 가능한 한 네이티브 요소로 만든다
클릭하면 동작하는 UI는 div가 아니라 <button> 또는 <a>가 1순위다.
“버튼처럼 보이는 div”는 만들 수 있지만, 그 순간부터 키보드/스크린리더 동작을 전부 수동으로 재구현하게 된다.
그럼 언제 button와 <a>를 써야 할까?
버튼 같은 경우는 같은 페이지에서 상태를 바꾸거나 모달을 여는 등 “행동(action)”이면 <button type="button"> 을 쓴다.
링크 같은 경우는 다른 페이지로 이동하거나 외부 URL을 여는 등 “이동(navigation)”이면 <a href="...">를 쓴다.
불가피하게 div를 써야 한다면(최후의 수단)
role="button", tabIndex={0}를 추가하고,
Enter/Space 키 이벤트 처리까지 구현해 키보드 접근성을 제공해야 한다.
결론: 의미를 ARIA로 “붙이는 것”보다, 태그 자체로 “가지게 하는 것”이 더 안전하다.
현재 위치/선택 상태는 시각적 표시만으로 끝내지 않는다
탭/사이드바/헤더 링크처럼 “현재 위치”가 중요한 UI는, 색상/밑줄만으로 표시하면 스크린리더·키보드 사용자는 맥락을 잃는다.
-
현재 페이지 링크:
aria-current="page" -
탭 UI
- 탭 버튼:
role="tab" - 탭 리스트:
role="tablist" - 선택 상태:
aria-selected - 연결:
aria-controls/id
- 탭 버튼:
이렇게 하면 “선택됨/현재” 같은 상태 정보가 음성으로도 전달된다.
아이콘은 ‘정보’인지 ‘장식’인지 먼저 결정한다
아이콘은 접근성에서 흔한 함정이다.
화면에는 예쁘지만, 스크린리더 입장에서는 의미 없는 요소가 반복해서 읽히는 노이즈가 되기 쉽다.
-
장식 아이콘(대부분의 경우)
-
정보 전달이 목적이 아니라면: aria-hidden="true"
-
예: 단순한 꾸밈, 레이아웃 분리, “분위기용” 아이콘
-
정보 아이콘(아이콘이 의미가 있는 경우)
- 버튼이면 aria-label="..."로 의도를 설명
- 상태 아이콘이면 주변 텍스트로 의미를 제공하거나 aria-label로 보완
여기서 자주 하는 실수는 아이콘과 텍스트가 함께 있는데 둘 다 읽혀서 중복 낭독 발생할 수 있다.
그래서 아이콘을 aria-hidden="true" 처리하고, 텍스트만 읽히게 정리하는 게 깔끔하다.
2-3. 동적 텍스트(알림, 에러, 배지)는 “변화”를 읽히게 만든다
UI에서 숫자 배지(알림 수), 폼 에러, 토스트처럼 텍스트가 실시간으로 바뀌는 요소는 시각 사용자에게는 즉시 보이지만, 스크린리더에게는 “변화가 있었는지”가 전달되지 않을 수 있다.
에러/중요 알림: role="alert" , aria-live="assertive"
role="alert"를 사용하면 자동으로 aria-live="assertive"가 적용되어 즉시 낭독된다.
<div id="alert-box" role="alert">
<!-- 중요한 알림이 여기에 표시됨 -->
</div>
<button onclick="showAlert()">알림</button>
<script>
function showAlert() {
document.getElementById("alert-box").innerText = "긴급 공지: 서비스 점검이 예정되었습니다.";
}
</script>덜 긴급한 변화: aria-live="polite"
사용자의 현재 작업을 방해하지 않고, 콘텐츠가 변경되었을 때만 읽어준다.
주로 알림 메시지, 검색 결과 갱신 등에 사용된다
<div id="search-result" aria-live="polite">
검색 결과가 여기에 표시됩니다.
</div>
<button onclick="updateSearchResults()">검색</button>
<script>
function updateSearchResults() {
document.getElementById("search-result").innerText = "총 10개의 검색 결과가 있습니다.";
}
</script>위 코드를 예시로 보면, 검색 버튼을 누르면 결과가 업데이트되며, 스크린 리더가 새로운 내용만 읽어준다.
하지만 사용자의 현재 작업을 방해하지 않는다.
입력과 에러 연결: aria-describedby + aria-invalid
<form>
<label for='username'>사용자 이름:</label>
<input id='username' type='text' aria-describedby='username-error' aria-invalid='true' />
<span id='username-error' role='alert' aria-live='assertive'>
사용자 이름은 필수 입력 항목입니다.
</span>
</form>aria-describedby는 입력 필드와 에러 메시지를 연결해 스크린 리더가 “이 필드에 이런 에러가 있어요”라고 읽게 한다.
aria-invalid는 입력 필드가 에러 상태임을 명시해 스크린 리더가 “이 필드는 잘못된 값이에요”라고 알린다.
3. 모달 접근성의 진짜 핵심: Tab 네비게이션 + 포커스 트래핑
시맨틱/ARIA를 잘 적용해도, 모달에서 이 문제가 남으면 접근성은 깨진다.
모달이 열렸는데 Tab을 누르면 배경으로 포커스가 빠져나간다.
키보드 사용자에게 이건 “길을 잃는 경험”이다.
- 모달을 조작 중인데 포커스가 배경 버튼으로 이동
- 현재 위치를 잃고 다시 모달로 돌아오기 어렵다
- 스크린 리더는 배경을 계속 읽어버려 “모달 맥락”이 무너진다
그래서 나는 useModalFocus 훅으로 **포커스 트래핑(Focus Trap)**을 구현했다.
useModalFocus 코드
import { useEffect, useRef } from 'react';
const FOCUSABLE_ELEMENTS =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export const useModalFocus = (isOpen: boolean) => {
const modalRef = useRef<HTMLDivElement>(null);
const focusableElementsRef = useRef<NodeListOf<Element> | null>(null);
useEffect(() => {
if (isOpen && modalRef.current) {
focusableElementsRef.current = modalRef.current.querySelectorAll(FOCUSABLE_ELEMENTS);
const focusableElements = focusableElementsRef.current;
if (focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
} else {
modalRef.current.setAttribute('tabindex', '-1');
modalRef.current.focus();
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
if (!focusableElementsRef.current || focusableElementsRef.current.length === 0) return;
const focusableElements = focusableElementsRef.current;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
} else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen]);
return modalRef;
};위 코드를 천천히 분석해보면,
FOCUSABLE_ELEMENTS의 경우 모달 내에서 포커스 가능한 모든 요소를 선택한다.
이후에 아래 코드를 통하여 모달이 열릴 때 첫 번째 포커스 가능한 요소에 포커스를 맞추거나, 포커스 가능한 요소가 없을 경우 모달 컨테이너에 포커스를 맞춘다.
if (focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
} else {
modalRef.current.setAttribute('tabindex', '-1');
modalRef.current.focus();
}그 다음 코드가 핵심인데, Tab/Shift+Tab 키 이벤트를 감지하여 포커스가 모달 내부에서 순환하도록 하는 역할을 하고 있다.
if (event.key === 'Tab') {
...
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
// Shift+Tab으로 첫 요소에서 "이전"으로 나가려는 순간 → 마지막으로 보내기
if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
// Tab으로 마지막 요소에서 "다음"으로 나가려는 순간 → 첫 번째로 보내기
else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}정리하면, 브라우저에서 Tab 키를 누를 때 기본적으로 다음으로 넘어가는 기능을 제공해주지만, 자신이 구현한 모달(컴포넌트) 밖으로 포커스가 나가는 것을 방지하기 위해, useModalFocus 훅에서는 Tab 키 이벤트를 감지하여 포커스가 모달 내부에서 순환하도록 한다.
VoiceOver으로 검증
VoiceOver는 macOS/iOS에 내장된 스크린 리더다.
해당 스크린리더를 통하여 시맨틱 태그와 ARIA가 제대로 동작하는지 검증할 수 있다.
내가 실제로 VoiceOver(맥)에서 확인한 체크리스트는 아래다.
키보드(Tab) 관점
- Tab 순서가 시각적 흐름과 자연스러운가?
- 포커스가 빠져나가거나(배경/주소창) 끊기지 않는가?
- 모달 오픈 시 첫 포커스가 적절한 위치로 이동하는가?
- 모달 내에서 Tab/Shift+Tab이 정상 순환하는가?
- Escape로 닫히는가?
스크린 리더(VoiceOver) 관점
- 로그인 에러가 발생했을 때 즉시 읽히는가?
- 버튼/링크가 역할에 맞게 읽히는가?
- 아이콘/장식 요소가 불필요하게 읽히지 않는가?
- 모달이 “dialog”로 인지되고 제목이 함께 읽히는가?
- 모달이 열린 동안 배경을 읽으려 하지 않는가?