HTML 웹표준 + ARIA 사용 기준
원칙: “HTML 먼저, ARIA는 보완” (First Rule of ARIA Use)
참고: MDN Web Docs, WHATWG HTML Standard, WAI-ARIA 1.2
적용 범위:@vortex/ui-foundation,@vortex/ui-icignal,@vortex/ui-cals컴포넌트
1. 시맨틱 HTML 우선 원칙
1.1 규칙
ARIA role을 추가하기 전에 동일한 의미를 가진 HTML 네이티브 요소로 대체 가능한지 먼저 확인한다.
| 목적 | 잘못된 패턴 | 올바른 패턴 |
|---|---|---|
| 버튼 | <div role="button" onClick> | <button type="button"> |
| 링크 | <div role="link" onClick> | <a href="..."> |
| 현재 페이지 표시 | <span role="link" aria-disabled> | <span aria-current="page"> |
| 입력 그룹 | <div role="group"> (label 없음) | <fieldset><legend>...</legend></fieldset> |
| 체크박스 | <div role="checkbox"> | <input type="checkbox"> |
| 표 | <div role="table"> | <table> |
| 탐색 영역 | <div role="navigation"> | <nav> |
| 주 콘텐츠 | <div role="main"> | <main> |
| 머리글 | <div role="heading" aria-level="2"> | <h2> |
1.2 div 대체 가능 시맨틱 태그
구조/레이아웃:
<header> 페이지/섹션 헤더
<main> 주 콘텐츠 (페이지당 1개)
<nav> 내비게이션 영역
<footer> 페이지/섹션 푸터
<aside> 부가 콘텐츠 (사이드바)
<section> 주제별 섹션 (heading 포함 시)
<article> 독립 콘텐츠 단위
<figure> 이미지/차트 + <figcaption>
인터랙티브:
<button> 클릭 가능한 모든 인터랙티브 요소
<a href> 다른 URL로 이동
<details> 펼침/접힘 (Accordion 대안)
<summary> <details>의 제목
<dialog> 모달/팝업 (dialog element)
폼:
<form> 폼 컨테이너
<fieldset> 관련 폼 그룹
<legend> <fieldset> 제목
<label> 입력 레이블
<input> 텍스트/체크박스/라디오 등
<select> 선택 목록
<textarea> 여러 줄 텍스트
<datalist> 자동완성 후보 목록
<output> 계산 결과 출력
텍스트:
<h1>~<h6> 제목 계층 (페이지당 h1 1개 권장)
<p> 단락
<strong> 강한 강조 (의미론적)
<em> 약한 강조 (의미론적)
<time> 날짜/시간
<abbr> 약어 + title
<mark> 하이라이트
<code> 인라인 코드
<pre> 서식 있는 텍스트
<kbd> 키보드 입력
<samp> 샘플 출력
목록:
<ul>/<li> 순서 없는 목록
<ol>/<li> 순서 있는 목록
<dl>/<dt>/<dd> 설명 목록 (용어-정의)
표:
<table> 데이터 표
<caption> 표 제목
<thead>/<tbody>/<tfoot> 표 영역 구분
<th scope> 헤더 셀
<td> 데이터 셀2. ARIA 사용 기준
2.1 ARIA를 사용해야 하는 경우
- 네이티브 HTML 요소로 표현할 수 없는 커스텀 위젯 (combobox, tree, grid, spinbutton 등)
- 동적으로 업데이트되는 영역의 live region 선언 (
aria-live,role="status",role="alert") - 네이티브 요소의 기본 role을 명확히 재정의해야 할 때
- 시각적으로만 연결된 레이블과 컨트롤의 프로그래밍 방식 연결 (
aria-labelledby,aria-describedby)
2.2 ARIA를 사용하지 말아야 하는 경우
-
네이티브 HTML 요소로 대체 가능한 경우 (위 표 참조)
-
이미 올바른 의미론적 요소를 사용하고 있는 경우에 중복 role 선언
// 잘못됨 — nav에 role="navigation"은 중복 <nav role="navigation">...</nav> // 올바름 <nav>...</nav> -
<button>에role="button"중복 -
<a href>에role="link"중복 -
<h2>에role="heading" aria-level="2"중복
2.3 주요 ARIA 속성 사용 기준
aria-label vs aria-labelledby
// aria-label: 레이블 텍스트를 직접 제공
<button aria-label="검색">
<SearchIcon aria-hidden="true" />
</button>
// aria-labelledby: 다른 요소의 텍스트를 레이블로 참조
<section aria-labelledby="section-title">
<h2 id="section-title">공지사항</h2>
...
</section>
// 원칙: 보이는 텍스트가 있으면 aria-labelledby 우선,
// 보이는 텍스트가 없으면 aria-label 사용aria-describedby
// 추가 설명 연결 (레이블 외 보충 정보)
<input
id="email"
aria-describedby="email-hint email-error"
/>
<p id="email-hint">예: user@example.com</p>
<div id="email-error" role="alert">
유효한 이메일 형식이 아닙니다.
</div>aria-hidden
// 사용해야 하는 경우:
// 1. 장식 아이콘 (의미 없는 SVG)
<SearchIcon aria-hidden="true" />
// 2. 측정용 숨김 DOM
<div aria-hidden="true" style={{ visibility: 'hidden' }}>...</div>
// 3. 포커스 불가 장식 요소
// 절대 사용하면 안 되는 경우:
// - 포커서블 요소(button, a, input)를 포함하는 컨테이너
// - sr-only 텍스트를 포함하는 컨테이너
// - aria-hidden 요소에 tabIndex가 있는 경우aria-live regions
// 동적 콘텐츠 업데이트 선언
// 즉시 읽어야 하는 중요 알림 (에러, 경고)
<div role="alert">에러 메시지</div> // assertive
// 또는
<div aria-live="assertive" aria-atomic="true">...</div>
// 비방해적 업데이트 (상태, 진행 상황)
<div role="status">저장 완료</div> // polite
// 또는
<div aria-live="polite">...</div>
// 주의: 페이지 로드 시 이미 존재하는 요소에 role="alert"를 선언해도
// AT가 읽지 않음. 동적으로 DOM에 추가될 때만 효과 있음.3. 컴포넌트별 ARIA 패턴
3.1 버튼
// 텍스트 버튼 — ARIA 불필요
<button type="button">저장</button>
// 아이콘 전용 버튼 — aria-label 필수
<button type="button" aria-label="닫기">
<XIcon aria-hidden="true" />
</button>
// 로딩 상태
<button type="button" aria-busy="true" disabled>
<Spinner aria-hidden="true" />
<span>처리 중...</span>
</button>
// 토글 버튼
<button type="button" aria-pressed={isActive}>
{isActive ? '활성' : '비활성'}
</button>3.2 폼 필드
// 기본 연결
<label htmlFor="name">이름</label>
<input id="name" type="text" />
// 필수 입력
<label htmlFor="email">
이메일
<span aria-hidden="true"> *</span> {/* 시각적 표시 */}
</label>
<input id="email" type="email" required aria-required="true" />
// 에러 상태
<input
id="password"
type="password"
aria-invalid="true"
aria-describedby="password-error"
/>
<div id="password-error" role="alert">
비밀번호는 8자 이상이어야 합니다.
</div>
// 그룹 (fieldset/legend 우선)
<fieldset>
<legend>알림 수신 방법</legend>
<label><input type="radio" name="notify" value="email" /> 이메일</label>
<label><input type="radio" name="notify" value="sms" /> SMS</label>
</fieldset>
// div role="group" 사용 시 반드시 aria-labelledby
<div role="group" aria-labelledby="notify-label">
<span id="notify-label">알림 수신 방법</span>
...
</div>3.3 모달/Dialog
// Base UI Dialog — 자동 처리:
// - role="dialog" + aria-modal="true"
// - focus trap (포커스 갇힘)
// - Escape 키 닫기
// - 초기 포커스 설정
// 반드시 제공해야 하는 것:
<DialogTitle>모달 제목</DialogTitle> {/* aria-labelledby 자동 연결 */}
<DialogDescription>설명</DialogDescription> {/* aria-describedby 자동 연결 */}
// 닫기 버튼
<button aria-label="대화상자 닫기">
<XIcon aria-hidden="true" />
<span className="sr-only">닫기</span> {/* aria-label이 있으면 이건 불필요 */}
</button>3.4 표 (Table)
// 기본 데이터 표
<table>
<caption>월별 매출 현황</caption> {/* 또는 aria-label */}
<thead>
<tr>
<th scope="col">월</th>
<th scope="col">매출</th>
<th scope="col">전월 대비</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1월</th> {/* 행 헤더 */}
<td>₩1,200만</td>
<td>+5%</td>
</tr>
</tbody>
</table>
// 현재 ui-foundation TableHead 권장 패턴:
<TableHead scope="col">월</TableHead> {/* scope prop 전달 */}3.5 Carousel / Slider
// ARIA carousel 패턴
<div
role="region"
aria-label="최신 공지사항" {/* aria-label 필수 */}
aria-roledescription="carousel"
>
<div role="group" aria-roledescription="slide" aria-label="1 / 3">
...
</div>
{/* 자동재생 정지 버튼 — WCAG 2.2.2 */}
<button aria-label="자동재생 정지" onClick={pause}>
<PauseIcon aria-hidden="true" />
</button>
<button aria-label="이전 슬라이드" onClick={prev}>
<ChevronLeftIcon aria-hidden="true" />
</button>
<button aria-label="다음 슬라이드" onClick={next}>
<ChevronRightIcon aria-hidden="true" />
</button>
</div>3.6 Backdrop / 외부 클릭 닫기 오버레이
팝업·모달 외부 클릭 감지용 전체 화면 오버레이는 인터랙티브 요소가 아니므로 AT 접근성 트리에서 숨긴다.
// 올바른 패턴 — aria-hidden으로 AT에서 숨김
<div
className="fixed inset-0 z-[9998]"
onClick={() => setPopup(null)}
aria-hidden="true" // AT에서 완전 숨김 (필수)
/>
// 잘못된 패턴 — AT가 빈 div를 인식하려 시도
<div
className="fixed inset-0"
onClick={() => setPopup(null)}
/>키보드 닫기 대안 필수 — backdrop은 마우스 전용이므로 반드시 하나 이상의 키보드 대안을 함께 제공해야 한다.
| 대안 | 방법 |
|---|---|
| 닫기 버튼 | 팝업 내부에 <button aria-label="닫기"> 배치 |
| ESC 키 | 팝업 컨테이너에 onKeyDown 또는 라이브러리 내장 처리 |
// 팝업 구조 예시 — backdrop + 닫기 버튼 함께 제공
<>
{/* 외부 클릭 닫기 — 마우스 전용, AT 숨김 */}
<div
className="fixed inset-0 z-[9998]"
onClick={() => setPopup(null)}
aria-hidden="true"
/>
{/* 팝업 본체 */}
<div className="absolute z-[9999] ..." role="dialog" aria-modal="true">
<button type="button" aria-label="닫기" onClick={() => setPopup(null)}>
<XIcon aria-hidden="true" />
</button>
{/* 팝업 콘텐츠 */}
</div>
</>
aria-hidden="true"가 있으면 A016(비인터랙티브 onClick) 규칙에서 자동 제외됨.
3.7 로딩/상태 표시
// Spinner — SVG에 직접 role 적용 대신 span 래퍼 사용
<span role="status" aria-label="로딩 중">
<Loader2Icon aria-hidden="true" className="animate-spin" />
</span>
// Skeleton — AT에서 읽히지 않도록 숨김
<div aria-hidden="true" className="skeleton" />
// 또는 부모 컨테이너에서 처리
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? (
<div aria-hidden="true">...</div>
) : (
<ActualContent />
)}
</div>4. 포커스 관리
4.1 포커스 스타일 기준
/* 금지 패턴 — outline 완전 제거 */
:focus {
outline: none;
}
.element {
outline: 0;
}
/* 권장 패턴 — focus-visible 사용 */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Tailwind CSS 4 패턴 (현재 프로젝트) */
/* focus-visible:ring-[3px] + focus-visible:border-ring
→ outline-none 대신 ring 방식으로 보상
→ :focus-visible 미지원 환경을 위한 fallback 검토 필요 */4.2 tabIndex 사용 기준
// tabIndex={0} — 포커스 순서에 추가 (키보드 접근 필요한 커스텀 요소)
// tabIndex={-1} — 포커스는 받을 수 있지만 Tab 순서에서 제외
// (측정용 숨김 요소, 프로그래밍 방식 포커스 이동 대상)
// tabIndex={양수} — 사용 금지 (DOM 순서 기반 포커스 순서 원칙 위반)
// 올바른 사용
<div tabIndex={-1} ref={dialogRef}> {/* dialog 초기 포커스 target */}
<div aria-hidden="true" tabIndex={-1}> {/* 숨김 컨테이너 — 단 포커서블 자식 없어야 함 */}5. 안티패턴 목록
| 안티패턴 | 문제 | 올바른 패턴 |
|---|---|---|
<div onClick> (인터랙티브) | 키보드 접근 불가, role 없음 | <button> 또는 <a> |
<div onClick> (backdrop 오버레이) | AT가 빈 오버레이를 인식 | aria-hidden="true" 추가 + 키보드 닫기 대안 제공 |
<span role="link" aria-disabled> | 현재 페이지에 링크 role 오용 | <span aria-current="page"> |
<img> (alt 없음) | 스크린리더 파일명 읽음 | alt="설명" 또는 alt="" |
role="region" (label 없음) | landmark 무효 | aria-label="영역명" 추가 |
role="group" (label 없음) | AT에서 group 명칭 알 수 없음 | aria-labelledby 또는 <fieldset> |
aria-hidden 내부 sr-only | sr-only 텍스트 무효화 | aria-hidden을 아이콘에만 적용 |
aria-label + sr-only 동시 사용 | 이중 레이블, 하나 무시됨 | 하나만 사용 |
중첩 role="group" (3개 이상) | AT 탐색 혼란 | 불필요한 group 제거 |
SVG에 role="status" | 브라우저 AT 조합 불일치 | <span role="status"> 래퍼 |
outline: none 단독 | WCAG 2.4.7 위반 | focus-visible:ring 보상 |
tabIndex={1} 이상 | 예측 불가 Tab 순서 | tabIndex={0} 또는 DOM 순서 정리 |
placeholder = 레이블 | 입력 시 사라져 혼란 | <label> + placeholder 함께 사용 |
<table> 레이아웃 목적 사용 | 표 데이터로 오인 | CSS flexbox/grid 사용 |
참고: WAI-ARIA 1.2 — https://www.w3.org/TR/wai-aria-1.2/
MDN ARIA — https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA
ARIA in HTML — https://www.w3.org/TR/html-aria/