도입 스토리
"스킵 내비게이션 추가했어요!" 김개발이 자랑스럽게 말했습니다.
박멘토가 Tab 키를 눌렀습니다. 아무것도 나타나지 않았습니다. 다시 Tab. 내비게이션 첫 번째 링크로 이동했습니다.
"스킵 링크를 추가했는데, 실제로 나타나지 않아요?"
"그냥 포커스가 올 때 보이게 해뒀는데, 포커스가 이미 다음 요소로 넘어가는 것 같아요."
박멘토가 소스를 확인했습니다. 스킵 링크가 내비게이션 메뉴 <ul> 아래 있었습니다.
"스킵 링크는 페이지의 첫 번째 요소여야 해요. 내비게이션보다 먼저 나와야 Skip Navigation이죠."
핵심 개념 설명
건너뛰기 링크 — WCAG 2.4.1
건너뛰기 링크(Skip Navigation)는 반복되는 메뉴를 건너뛰고 본문으로 바로 이동하는 링크입니다. 페이지의 가장 첫 번째 요소여야 합니다.
<!DOCTYPE html><html lang="ko"><head>...</head><body> <!-- 반드시 <body> 직후 첫 번째 요소 --> <a href="#main-content" class="skip-link"> 본문으로 건너뛰기 </a> <header> <nav aria-label="주요 메뉴"> <!-- 30개의 링크 --> </nav> </header> <main id="main-content" tabindex="-1"> <!-- tabindex="-1": 프로그래밍 포커스는 가능, Tab 순서에는 없음 --> <h1>서비스 소개</h1> ... </main></body></html>
/* 기본은 숨기고, 포커스 시 보이게 */.skip-link { position: absolute; top: -100%; left: 1rem; padding: 0.75rem 1.5rem; background: #005FCC; color: white; font-weight: bold; border-radius: 0 0 4px 4px; text-decoration: none; z-index: 9999; transition: top 0.2s;}.skip-link:focus { top: 0;}
동적 콘텐츠의 포커스 관리
SPA(Single Page Application)에서 페이지 이동 시, 새 콘텐츠로 포커스를 이동시켜야 합니다. 그렇지 않으면 포커스가 이전 위치에 남아 혼란이 생깁니다.
// React Router 예시: 페이지 이동 후 포커스 관리function PageWrapper({ children }) { const location = useLocation(); const mainRef = useRef(null); useEffect(() => { // 라우트 변경 시 main으로 포커스 이동 if (mainRef.current) { mainRef.current.focus(); } }, [location.pathname]); return ( <main ref={mainRef} tabIndex={-1} id="main-content" > {children} </main> );}
모달 포커스 관리
모달을 열 때와 닫을 때 포커스 이동이 중요합니다.
class Modal { constructor(element) { this.modal = element; this.opener = null; // 모달을 연 버튼 기억 } open(openerButton) { this.opener = openerButton; // 모달 표시 this.modal.removeAttribute('hidden'); this.modal.setAttribute('aria-modal', 'true'); // 배경 스크롤 방지 document.body.style.overflow = 'hidden'; // 포커스를 모달 안으로 이동 const firstFocusable = this.modal.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); firstFocusable?.focus(); // 포커스 트랩 활성화 this.modal.addEventListener('keydown', this.handleKeydown.bind(this)); } close() { // 모달 숨기기 this.modal.setAttribute('hidden', ''); document.body.style.overflow = ''; this.modal.removeEventListener('keydown', this.handleKeydown.bind(this)); // 포커스를 모달을 열었던 버튼으로 복원 (핵심!) this.opener?.focus(); } handleKeydown(e) { if (e.key === 'Escape') { this.close(); } }}
포커스 이동 원칙 정리
| 상황 | 포커스 이동 위치 |
|---|---|
| 페이지 로드 | <main> 또는 <h1> |
| 모달 열기 | 모달 첫 번째 포커스 가능 요소 |
| 모달 닫기 | 모달을 열었던 버튼 |
| 드롭다운 열기 | 첫 번째 메뉴 항목 |
| 드롭다운 닫기 | 드롭다운 버튼 |
| 알림/토스트 닫기 | 알림을 유발한 버튼 또는 이전 포커스 |
| SPA 라우트 변경 | 새 페이지의 <main> 또는 <h1> |
포커스 이동 시 사용자 안내
포커스가 갑자기 이동하면 스크린리더 사용자가 혼란스러울 수 있습니다. aria-live로 이동 이유를 알려줍니다.
<!-- 스크린리더 전용 공지 영역 --><div id="page-announcement" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
function navigateTo(page) { // 페이지 이동 loadPage(page); // 스크린리더에 이동 알림 document.getElementById('page-announcement').textContent = `${page.title} 페이지로 이동했습니다.`;}
단계별 실습
따라하기: 건너뛰기 링크 추가하기
<body>직후 첫 번째 자식으로 스킵 링크를 추가합니다.<main>에id="main-content"와tabindex="-1"을 추가합니다.- Tab 키를 눌러 스킵 링크가 나타나는지 확인합니다.
- Enter를 눌러
<main>으로 포커스가 이동하는지 확인합니다.
변형하기: 스킵 링크 스타일링
/* 접근성 + 디자인 모두 만족하는 스킵 링크 */.skip-link { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%) translateY(-200%); padding: 1rem 2rem; background: #1a1a1a; color: #ffffff; font-size: 1rem; font-weight: 600; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); text-decoration: none; z-index: 99999; transition: transform 0.2s ease;}.skip-link:focus { transform: translateX(-50%) translateY(0);}
응용하기: SPA 포커스 관리 감사
React, Vue, Angular 앱에서 라우트 변경 후 포커스 위치를 확인합니다.
// 현재 포커스 위치 추적document.addEventListener('focusin', (e) => { console.log('포커스:', e.target.tagName, e.target.id, e.target.textContent?.substring(0, 30));});
정리와 확인
핵심 내용 요약
- 스킵 링크(WCAG 2.4.1):
<body>직후 첫 번째 요소 —href="#main-content" <main tabindex="-1">: 스킵 링크의 목적지에 필요- 모달 포커스: 열 때 → 모달 내부 첫 요소 / 닫을 때 → 열었던 버튼
- SPA 라우트 변경:
useEffect로<main>포커스 이동 - 포커스 복원: 사용자 맥락을 잃지 않게 원래 위치로 돌아와야 함
확인 문제
문제 1. 스킵 링크가 작동하려면 <main>에 어떤 속성이 필요한가?
<main id="main-content" tabindex="-1"><!-- id: href="#main-content"와 연결, tabindex="-1": 프로그래밍 포커스 허용 -->
문제 2. 모달을 닫은 후 포커스가 페이지 상단으로 이동하면 왜 문제인가?
사용자가 모달을 열기 전에 어디에 있었는지 맥락을 잃게 됩니다.
페이지 상단에서 다시 Tab을 처음부터 눌러야 이전 위치로 돌아올 수 있습니다.
PART 03을 마쳤습니다. 다음 PART에서는 "이해할 수 있는(Understandable)" 원칙을 다룹니다. 예측 가능한 UI, 명확한 오류 메시지, 언어 설정까지 — 콘텐츠가 이해되어야 진짜 접근성입니다.