교육 웹사이트를 위한 WCAG 2.1 AA 준수: Next.js 체크리스트
2024년 교육 기관을 위한 WCAG 2.1 AA 준수: Next.js 체크리스트
2024년 한 해만 해도 200건 이상의 ADA 관련 소송이 웹사이트 접근성이 부족한 교육 기관을 대상으로 제기되었습니다. 합의금은 25,000달러에서 300,000달러 이상에 달했으며, 이는 복구 비용 전에 계산한 것입니다. WCAG 2.1 AA 표준을 충족하지 않는 대학 또는 학교 구역 웹사이트는 법적 목표물입니다.
이것이 대부분의 기관이 말하지 않는 핵심입니다. 웹사이트를 더 빠르게 만드는 동일한 기술이 접근성을 높입니다. 의미 있는 HTML과 Tailwind CSS로 구축된 Next.js 사이트는 기본적으로 95 이상의 Lighthouse 접근성 점수를 달성합니다. 30개의 플러그인이 있는 일반적인 WordPress 사이트는 40에서 60 사이 어딘가에 착륙합니다. 교육 웹사이트를 충분히 감사해본 결과, 오늘 선택하는 플랫폼이 내년에 복구 티켓을 접수하는지 아니면 편히 주무르는지를 결정합니다.
이것은 이론적인 개요가 아닙니다. 실무 기술 체크리스트입니다. 8가지 범주, 실제 코드 예제, Social Animal에서 접근성 있는 교육 사이트를 구축할 때 사용하는 정확한 구현 패턴입니다. 북마크하세요. 개발 팀과 공유하세요. 인쇄해서 벽에 붙이세요.
목차
- 법적 맥락: 교육 웹사이트가 목표가 되는 이유
- 1. 의미 있는 HTML
- 2. ARIA 레이블 및 라이브 영역
- 3. 키보드 네비게이션
- 4. 색상 대비
- 5. 이미지 대체 텍스트
- 6. 접근성 있는 양식
- 7. 비디오 및 멀티미디어
- 8. CI/CD의 자동화된 테스트
- WordPress와 Drupal이 접근성으로 어려움을 겪는 이유
- 헤드리스 장점: 빌드 시간에 강제되는 접근성
- FAQ

법적 맥락: 교육 웹사이트가 목표가 되는 이유
먼저 법적 내용을 정리하겠습니다. CFO가 주의를 기울이게 만드는 것이 바로 이것이기 때문입니다.
재활법 제508조는 모든 연방 기관과 연방 자금을 받는 모든 기관에 적용됩니다. 이것은 모든 공립 대학입니다. 모든 공립 학교 구역입니다. 귀 기관이 연방 자금의 1달러를 받으면 (Pell Grant, 연구 자금 또는 Title I 자금 포함) 제508조가 적용됩니다.
ADA Title III는 공개 숙박시설을 다룹니다. 법원은 이것이 사립 대학과 그들의 웹사이트를 포함한다고 일관되게 판결했습니다. Harvard, MIT 및 수많은 소규모 사립 기관이 Title III에 따른 소송에 직면했습니다.
WCAG 2.1 AA는 법원이 준수 여부를 평가할 때 참조하는 기술 표준입니다. DOJ는 2024년 최종 규칙을 발행하여 주 및 지방 정부 웹사이트 (공립 대학 및 학교 구역 포함)가 WCAG 2.1 Level AA를 준수해야 한다고 명시적으로 지정했습니다. 이것은 제안이 아닙니다. 이것은 시행 기한이 있는 규칙입니다.
숫자가 이야기합니다. 교육 기관을 대상으로 한 ADA 소송이 2018년과 2025년 사이에 대략 300% 증가했습니다. 민권실(OCR)은 FY2024에 15,000건 이상의 불만을 해결했으며, 디지털 접근성은 가장 빠르게 증가하는 불만 범주 중 하나입니다.
| 법적 프레임워크 | 적용 대상 | 표준 | 주요 기한 |
|---|---|---|---|
| 제508조 | 공립 대학, 학교 구역 (연방 자금 수령자) | WCAG 2.1 AA | 이미 시행 중 |
| ADA Title II (DOJ 2024 규칙) | 주/지방 정부 기관 | WCAG 2.1 AA | 2026년 4월 (대규모 기관), 2027년 4월 (소규모) |
| ADA Title III | 사립 대학, 사립 K-12 학교 | WCAG 2.1 AA (사실상) | 법원이 현재 시행 중 |
| 주(州) 법 (CA, NY 등) | 주(州)에 따라 다름 | 일반적으로 WCAG 2.1 AA | 다양함 |
이제 실제로 통과하는 웹사이트를 구축해봅시다.
1. 의미 있는 HTML
의미 있는 HTML은 모든 것의 기초입니다. 이것을 잘못 이해하면 ARIA 속성을 아무리 많이 사용해도 당신을 구원할 수 없습니다. 스크린 리더는 사용자가 페이지 계층을 이해하고 섹션 간을 탐색하는 데 도움이 되는 문서의 의미 있는 구조에 의존합니다.
제목 계층 구조
모든 페이지는 정확히 하나의 <h1>을 가집니다. 부제목은 순서대로 따릅니다: <h2>, 그 다음 <h3>, 그 다음 <h4>. 절대 수준을 건너뛰지 마세요. 스크린 리더 사용자가 "제목 수준 2" 후에 "제목 수준 4"를 들으면 맥락을 잃게 됩니다.
나는 교육 웹사이트가 이 규칙을 끊임없이 깨뜨리는 것을 봅니다. 특히 CMS에 누군가 글꼴 크기가 올바르게 보였기 때문에 임의의 제목 수준이 있는 내용을 붙여넣은 부서 페이지에서요.
랜드마크 요소
<nav>, <main>, <aside>, <footer>, <header>를 사용하세요. 이들은 스크린 리더 사용자를 위한 탐색 가능한 영역을 만듭니다. JAWS 또는 NVDA 사용자는 단일 키를 눌러 랜드마크 사이를 이동할 수 있습니다.
버튼 vs. 링크
이것은 나를 화나게 합니다. <button>을 작업을 수행하는 대화형 요소에 사용하세요 (메뉴 열기, 양식 제출, 필터 전환). <a>를 사용자를 새 페이지 또는 섹션으로 이동하는 탐색에 사용하세요. 절대 onClick 핸들러가 있는 <div>를 사용하지 마세요.
// ❌ 잘못됨: 버튼인 척하는 div
<div onClick={handleClick} className="cursor-pointer">
메뉴 열기
</div>
// ❌ 잘못됨: 탐색에 사용되는 버튼
<button onClick={() => router.push('/admissions')}>
입학 정보 보기
</button>
// ✅ 올바름: 작업을 위한 의미 있는 버튼
<button
onClick={handleMenuToggle}
aria-expanded={isOpen}
aria-controls="main-nav"
className="focus-visible:ring-2 focus-visible:ring-offset-2"
>
메뉴 열기
</button>
// ✅ 올바름: 탐색을 위한 앵커
import Link from 'next/link';
<Link href="/admissions" className="focus-visible:ring-2">
입학 정보 보기
</Link>
대학 사이트의 완전한 접근성 있는 네비게이션 컴포넌트입니다:
// components/MainNav.tsx
import Link from 'next/link';
export function MainNav() {
return (
<header role="banner">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:text-lg"
>
메인 콘텐츠로 건너뛰기
</a>
<nav aria-label="메인 네비게이션">
<ul role="list">
<li><Link href="/admissions">입학</Link></li>
<li><Link href="/academics">학문</Link></li>
<li><Link href="/campus-life">캠퍼스 생활</Link></li>
<li><Link href="/research">연구</Link></li>
<li><Link href="/about">소개</Link></li>
</ul>
</nav>
</header>
);
}
Next.js는 이 점에서 자연스러운 장점이 있습니다. React/JSX를 작성하고 있기 때문에 의미 있는 요소를 직접 구성합니다. 백그라운드에서 중첩된 <div> 수프를 생성하는 드래그 앤 드롭 페이지 빌더가 없습니다.
2. ARIA 레이블 및 라이브 영역
ARIA (Accessible Rich Internet Applications) 속성은 네이티브 HTML 시맨틱이 충분하지 않은 곳에서 격차를 채웁니다. 여기의 황금 규칙은 좋은 ARIA가 없는 것이 나쁜 ARIA보다 낫다입니다. 먼저 네이티브 HTML 요소를 사용하세요. ARIA가 필요할 때만 사용하세요.
ARIA 사용 시기
aria-label: 보이는 텍스트가 없는 아이콘 전용 버튼의 경우. 돋보기 검색 버튼은aria-label="검색"이 필요합니다.aria-describedby: 입력 필드를 오류 메시지에 연결하므로 스크린 리더가 둘 다 읽습니다.aria-live="polite": 동적 콘텐츠 업데이트 (검색 결과 로드, 필터 변경)를 포커스를 빼앗지 않고 알립니다.role="alert": 즉시 발표가 필요한 긴급 오류 메시지의 경우.aria-expanded: 아코디언, 드롭다운 또는 메뉴의 개방 또는 폐쇄 상태를 전달합니다.
// components/SearchBar.tsx
import { useState, useRef } from 'react';
export function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
setIsSearching(true);
// API에서 결과 가져오기
const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
setResults(data.results);
setIsSearching(false);
}
return (
<div role="search" aria-label="사이트 검색">
<form onSubmit={handleSearch}>
<label htmlFor="site-search" className="sr-only">
대학 웹사이트 검색
</label>
<input
ref={inputRef}
id="site-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="프로그램, 교수진, 뉴스 검색..."
autoComplete="off"
className="focus-visible:ring-2 focus-visible:ring-blue-600"
/>
<button type="submit" aria-label="검색 제출">
<SearchIcon aria-hidden="true" />
</button>
</form>
{/* 라이브 영역은 스크린 리더 사용자에게 결과를 알립니다 */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{isSearching
? '검색 중...'
: `${query}에 대해 ${results.length}개의 결과를 찾았습니다`
}
</div>
{results.length > 0 && (
<ul role="list" aria-label="검색 결과">
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
)}
</div>
);
}
aria-live="polite" 영역에 주목하세요. 검색 결과가 로드되면 스크린 리더 사용자는 "컴퓨터 과학에서 5개 결과를 찾았습니다"를 들으므로 결과 목록으로 탐색할 필요가 없습니다. 그것이 접근성 있는 사이트와 사용 불가능한 사이트의 차이입니다.

3. 키보드 네비게이션
시력이 있는 사용자가 클릭할 수 있는 모든 것은 키보드 사용자가 Tab과 Enter 또는 Space로 활성화할 수 있어야 합니다. 예외는 없습니다.
절대적 필요사항
- Tab을 통해 모든 대화형 요소에 도달 가능. 이것은 의미 있는
<button>및<a>요소를 사용하면 자동으로 발생합니다. - 논리적 탭 순서. 탭 순서는 시각적 콘텐츠 흐름을 따라야 합니다.
tabindex값이 0보다 크도록 사용하지 마세요. 혼란을 초래합니다. - 눈에 띄는 포커스 표시기.
:focus에outline: none또는outline: 0을 절대 작성하지 마세요. 절대로요. 대신 Tailwind의focus-visible:ring-2를 사용하세요. 키보드 사용자에게는 링을 표시하지만 마우스 클릭에는 표시되지 않습니다. - 스킵 링크. 페이지의 첫 번째 포커스 가능한 요소는 "메인 콘텐츠로 건너뛰기" 링크여야 합니다. 포커스될 때까지 숨겨집니다.
- 모달에서 포커스 트래핑. 모달이 열리면 Tab은 모달 내에서 순환해야 합니다. Escape는 이를 닫습니다. 모달이 닫히면 포커스가 트리거 버튼으로 돌아갑니다.
// components/AccessibleModal.tsx
import { useEffect, useRef, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
triggerRef: React.RefObject<HTMLButtonElement>;
}
export function AccessibleModal({ isOpen, onClose, title, children, triggerRef }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
if (!modalRef.current) return;
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableElements[0] as HTMLElement;
const lastEl = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
closeButtonRef.current?.focus();
document.addEventListener('keydown', trapFocus);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', trapFocus);
document.body.style.overflow = '';
// 트리거로 포커스 반환
if (!isOpen) triggerRef.current?.focus();
};
}, [isOpen, trapFocus, triggerRef]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
>
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h2 id="modal-title" className="text-xl font-bold">{title}</h2>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="대화상자 닫기"
className="focus-visible:ring-2 focus-visible:ring-blue-600 rounded p-1"
>
✕
</button>
</div>
{children}
</div>
</div>
);
}
이 패턴은 포커스 트래핑, Escape로 닫기, 포커스 복원을 처리합니다. 나는 최소한 십여 개의 교육 사이트에서 이 패턴의 변형을 배포했습니다.
4. 색상 대비
대비 비율은 가장 자주 실패한 WCAG 기준 중 하나이며, 처음부터 디자인 토큰을 올바르게 설정하면 쉽게 수정할 수 있는 것 중 하나입니다.
| 텍스트 유형 | 최소 대비 비율 (AA) | 예 |
|---|---|---|
| 일반 텍스트 (< 18px) | 4.5:1 | 본문, 캡션, 양식 레이블 |
| 큰 텍스트 (18px 이상 일반, 14px 이상 굵음) | 3:1 | 제목, 큰 버튼 |
| 대화형 요소 | 인접한 색상에 대해 3:1 | 버튼 테두리, 링크 밑줄 |
| 비텍스트 요소 (아이콘, 포커스 링) | 3:1 | 양식 필드 테두리, 차트 요소 |
비율을 넘어선 규칙
정보를 전달하기 위해 색상만 사용하지 마세요. 오류 상태가 "필드가 빨간색으로 변함"일 수 없습니다. 빨간 테두리 + 오류 아이콘 + 텍스트 레이블이 필요합니다. 데이터 시각화는 카테고리를 구분하기 위해 색상에만 의존할 수 없습니다. 패턴, 레이블 또는 구별되는 모양을 사용하세요.
테스트 도구
- Chrome DevTools 접근성 패널: 모든 요소를 검사하고, 대비 비율을 즉시 확인합니다.
- WebAIM 대비 검사기: 16진수 값을 입력하고, AA 및 AAA에 대한 합격/불합격을 얻습니다.
- Figma 플러그인 (Stark, A11y): 코드에 도달하기 전에 대비 문제를 포착합니다.
참고로: 검은색에 가까운 배경 (#0a0a0b)에 금색 악센트 (#c8a96e)는 4.7:1의 대비 비율을 생성합니다. 일반 텍스트는 통과합니다. 디자인 토큰이 중요합니다.
5. 이미지 대체 텍스트
모든 <img> 요소는 alt 속성이 필요합니다. 여기에 들어가는 것은 이미지의 목적에 따라 다릅니다.
결정 트리
- 정보 제공 이미지 (사진, 콘텐츠를 전달하는 삽화): 설명적인 대체 텍스트를 작성하세요. "image123.jpg"가 아니라 "캠퍼스 도서관에서 공부하는 학생들".
- 장식 이미지 (배경 텍스처, 시각적 분할선, 순전히 미학적):
alt=""를 사용하세요 (빈 문자열). 이것은 스크린 리더에 이를 건너뛰라고 알립니다. - 차트 및 그래프: 데이터를 요약하는 자세한 대체 텍스트를 작성하거나,
aria-describedby를 사용하여 차트 아래의 데이터 표를 가리킵니다. - 교수 사진:
alt="Sarah Chen 박사, 컴퓨터과학과 부교수" - 프로그램 헤로 이미지: 장면 맥락을 설명합니다.
alt="메이커 랩에서 로보틱스 프로젝트를 협력하는 공학 학생들"
// ✅ 정보 제공 이미지
import Image from 'next/image';
<Image
src="/campus/library-study-area.jpg"
alt="설립자 도서관 중앙홀의 테이블에서 공부하는 학생들"
width={1200}
height={600}
/>
// ✅ 장식 이미지
<Image
src="/patterns/wave-divider.svg"
alt=""
role="presentation"
width={1200}
height={40}
/>
// ✅ 교수 사진
<Image
src="/faculty/sarah-chen.jpg"
alt="Sarah Chen 박사, 컴퓨터과학과 부교수"
width={300}
height={400}
/>
Next.js Image 컴포넌트는 실제로 alt 속성을 잊으면 경고합니다. 이와 같은 작은 것들이 모여집니다.
6. 접근성 있는 양식
양식은 교육 웹사이트가 접근성에 대해 성공하거나 실패하는 곳입니다. 지원 양식, 연락처 양식, 과정 등록, 재정 지원. 이것들이 접근성이 없으면 당신의 서비스가 가장 필요한 학생들을 제외하고 있습니다.
// components/ContactForm.tsx
import { useState } from 'react';
export function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitted, setSubmitted] = useState(false);
function validate(formData: FormData) {
const newErrors: Record<string, string> = {};
if (!formData.get('name')) newErrors.name = '전체 이름이 필수입니다.';
if (!formData.get('email')) newErrors.email = '이메일 주소가 필수입니다.';
const email = formData.get('email') as string;
if (email && !email.includes('@')) newErrors.email = '올바른 이메일 주소를 입력하세요.';
if (!formData.get('message')) newErrors.message = '메시지는 필수입니다.';
return newErrors;
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newErrors = validate(formData);
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) setSubmitted(true);
}
if (submitted) {
return <div role="alert"><p>감사합니다! 2영업일 내에 연락하겠습니다.</p></div>;
}
return (
<form onSubmit={handleSubmit} noValidate>
{/* 오류 요약 */}
{Object.keys(errors).length > 0 && (
<div role="alert" className="bg-red-50 border-red-500 border p-4 mb-6 rounded">
<h2 className="text-red-800 font-bold mb-2">
{Object.keys(errors).length}개의 오류를 수정하세요:
</h2>
<ul>
{Object.entries(errors).map(([field, msg]) => (
<li key={field}>
<a href={`#field-${field}`} className="text-red-700 underline">{msg}</a>
</li>
))}
</ul>
</div>
)}
<div className="mb-4">
<label htmlFor="field-name" className="block font-medium mb-1">
전체 이름 <span aria-hidden="true">*</span>
</label>
<input
id="field-name"
name="name"
type="text"
autoComplete="name"
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'error-name' : undefined}
className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
/>
{errors.name && (
<p id="error-name" className="text-red-600 text-sm mt-1" role="alert">
{errors.name}
</p>
)}
</div>
<div className="mb-4">
<label htmlFor="field-email" className="block font-medium mb-1">
이메일 주소 <span aria-hidden="true">*</span>
</label>
<input
id="field-email"
name="email"
type="email"
autoComplete="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'error-email' : undefined}
className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
/>
{errors.email && (
<p id="error-email" className="text-red-600 text-sm mt-1" role="alert">
{errors.email}
</p>
)}
</div>
<div className="mb-4">
<label htmlFor="field-message" className="block font-medium mb-1">
메시지 <span aria-hidden="true">*</span>
</label>
<textarea
id="field-message"
name="message"
rows={5}
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'error-message' : undefined}
className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-600"
/>
{errors.message && (
<p id="error-message" className="text-red-600 text-sm mt-1" role="alert">
{errors.message}
</p>
)}
</div>
<button
type="submit"
className="bg-blue-700 text-white px-6 py-3 rounded font-medium hover:bg-blue-800 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-600"
>
메시지 보내기
</button>
</form>
);
}
상단에 있는 오류 요약과 각 문제 있는 필드로의 앵커 링크를 주목하세요. aria-describedby 연결. autoComplete 속성. aria-required 및 aria-invalid 상태. 이것이 접근성 있는 양식이 실제로 어떤 모습인지입니다.
7. 비디오 및 멀티미디어
대학 웹사이트는 비디오로 가득 차 있습니다. 가상 캠퍼스 투어, 강의 녹음, 총장 인사, 학생 증언. 이 모든 것들은 접근성 있는 대안이 필요합니다.
요구 사항
- 모든 비디오 콘텐츠에 대한 자막. 자동 생성 자막 (YouTube, Rev.ai)은 출발점이지만 인간이 검토해야 합니다. 자동 자막의 오류율은 10-15%입니다. 학술 콘텐츠에는 허용되지 않습니다.
- 시각 전용 콘텐츠에 대한 오디오 설명. 아름다운 건물을 보여주는 가상 캠퍼스 투어 비디오입니까? 시각 장애인 사용자는 화면의 내용을 설명하지 않으면 침묵을 듣습니다.
- 모든 멀티미디어에 대한 성적표 이용 가능. 다운로드 가능하거나 페이지 내 텍스트 버전.
- 일시 중지/중지 제어. 자동 재생 콘텐츠의 경우.
- 오디오 자동 재생 없음. 사용자가 즉시 중지할 수 없는.
// 접근성 있는 비디오 임베드 패턴
<figure>
<div className="relative aspect-video">
<iframe
src="https://www.youtube.com/embed/VIDEO_ID?cc_load_policy=1"
title="자동, 교실실, 학생 공간을 포함한 공학 건물의 가상 투어"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
<figcaption className="mt-2 text-sm text-gray-600">
공학 건물의 가상 투어.
<a href="/transcripts/engineering-tour" className="underline ml-1">
완전한 성적표 읽기
</a>
</figcaption>
</figure>
iframe의 title 속성은 중요합니다. 사용자가 임베드에 도달할 때 스크린 리더가 이를 알립니다. cc_load_policy=1 매개 변수는 YouTube 임베드에서 기본적으로 자막을 강제합니다.
8. CI/CD의 자동화된 테스트
수동 접근성 테스트는 필요하지만 충분하지 않습니다. 회귀가 프로덕션에 도달하는 것을 방지하는 자동화된 검사가 필요합니다.
파이프라인
- GitHub Actions 또는 Vercel 빌드에서 Lighthouse CI: 임계값을 설정하고 접근성 점수가 90 이하로 떨어지면 빌드를 실패합니다.
- axe-core 통합: 단위/통합 테스트 중 모든 컴포넌트에서 자동화된 WCAG 2.1 AA 스캔을 실행합니다.
- 수동 키보드 테스트: 모든 주요 릴리스 전에 Tab, Enter, Space 및 화살표 키만 사용하여 전체 사이트를 탐색합니다.
- 스크린 리더 테스트: VoiceOver (Mac), NVDA (Windows), TalkBack (Android)으로 분기별 테스트.
# .github/workflows/accessibility.yml
name: 접근성 감사
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Lighthouse CI 실행
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
http://localhost:3000/admissions
http://localhost:3000/academics
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
// lighthouse-budget.json
[{
"path": "/*",
"options": {
"assertions": {
"categories:accessibility": ["error", { "minScore": 0.9 }]
}
}
}]
axe-core을 사용한 컴포넌트 수준 테스트의 경우:
// __tests__/ContactForm.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ContactForm } from '../components/ContactForm';
expect.extend(toHaveNoViolations);
test('ContactForm에는 접근성 위반이 없습니다', async () => {
const { container } = render(<ContactForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
절대 접근성 없는 페이지를 배포하지 마세요. 파이프라인에 보호 장치를 구축하면 내년에 복구할 필요가 없을 것입니다.
WordPress와 Drupal이 접근성으로 어려움을 겪는 이유
나는 WordPress가 접근성일 수 없다고 말하는 것이 아닙니다. 나는 실제로는 거의 절대 그렇지 않다고 말하는 것입니다. 특히 복잡한 요구사항이 있는 교육 사이트의 경우.
이것이 이유입니다:
- 접근성은 설치된 모든 플러그인이 접근성이 있는지에 달려 있습니다. 연락처 양식 플러그인, 이벤트 캘린더 플러그인, 메가 메뉴 플러그인, 슬라이더 플러그인. 모든 플러그인이 유효하고 접근성 있는 마크업을 생성해야 합니다. 대부분은 그렇지 않습니다.
- 플러그인 업데이트가 문제를 깹니다. WooCommerce 업데이트 또는 Elementor 업데이트가 조용히 접근성 회귀를 도입할 수 있습니다. 누군가가 불만을 제기하거나 고소할 때까지 모를 것입니다.
- 배포 파이프라인에 자동화된 접근성 검사가 없습니다. 표준 WordPress 배포에는 Lighthouse 게이트 또는 axe-core 스캔이 포함되지 않습니다. 변경 사항은 접근성 검증 없이 라이브로 갑니다.
- 콘텐츠 작성자가 접근성 없는 콘텐츠를 작성합니다. WYSIWYG 편집기를 통해 사용자가 제목 수준을 건너뛸 수 있고, 대체 텍스트 없이 이미지를 삽입할 수 있으며, "여기 클릭"이라고 하는 링크를 만들 수 있습니다. 강제 메커니즘이 없습니다.
나는 테마 업데이트 후 Lighthouse 42를 얻은 WordPress 교육 사이트를 감사했습니다. 42. 학교는 우리가 말할 때까지 알지 못했습니다.
헤드리스 장점: 빌드 시간에 강제되는 접근성
우리가 Next.js 개발과 헤드리스 CMS 아키텍처로 취하는 접근 방식은 모델을 뒤집습니다. 접근성은 사후 패치가 아니라 빌드 시간에 강제됩니다.
| 접근방식 | 접근성 강제 | 일반적인 Lighthouse 점수 | 회귀 위험 |
|---|---|---|---|
| WordPress + 플러그인 | 수동 감사, 오버레이 도구 | 40-65 | 높음 (모든 플러그인 업데이트) |
| Drupal + contrib 모듈 | WP보다 나음, 여전히 수동 | 55-75 | 중간 |
| Next.js + 헤드리스 CMS | CI/CD 자동화, 빌드 시간 | 90-100 | 낮음 (자동화된 게이트) |
의미 있는 HTML은 React 기본값입니다. Tailwind의 focus-visible 유틸리티는 단일 클래스입니다. CI/CD Lighthouse 검사는 회귀를 방지합니다. 하나의 코드베이스는 모든 학교, 부서 및 프로그램 페이지에서 일관된 준수를 의미합니다. 47개의 다른 플러그인 구성이 있는 47개의 다른 WordPress 설치가 아닙니다.
재구축이나 마이그레이션을 고려 중이라면, 세부 사항을 통해 기꺼이 이야기하고 싶습니다. 기능 또는 연락처를 확인하세요. 헤드리스 교육 사이트 프로젝트이 예산 관점에서 어떻게 보이는지 궁금하다면 우리의 가격 책정 페이지에 투명한 숫자가 있습니다.
FAQ
WCAG 2.1 AA 준수는 K-12를 포함한 모든 학교 웹사이트에 적용됩니까?
네. 공립 학교 구역은 연방 자금을 받으므로 제508조 요구 사항을 트리거합니다. DOJ의 ADA Title II 2024 최종 규칙은 공립 학교 구역을 포함하는 주 및 지방 정부 기관을 다룹니다. 사립 K-12 학교도 ADA Title III에 따라 포함될 수 있습니다. 안전한 가정: 학교 웹사이트를 운영하면 WCAG 2.1 AA가 적용됩니다.
WCAG 2.1 AA와 WCAG 2.2 AA의 차이는 무엇입니까?
2023년 10월에 발행된 WCAG 2.2는 2.1 위에 9개의 새로운 성공 기준을 추가합니다. DOJ의 2024 규칙은 현재 준수 표준으로 WCAG 2.1 AA를 구체적으로 참조합니다. 그러나 2.2 AA를 목표로 하는 것은 전향적인 계획입니다. 새 기준은 포커스 모양, 드래그 움직임, 일관된 도움과 같은 것에 중점을 둡니다. 모두 복잡한 양식과 네비게이션이 있는 교육 사이트와 관련이 있습니다.
accessiBe 또는 UserWay와 같은 접근성 오버레이 도구가 사이트를 준수하게 만들 수 있습니까?
아니오. 맹인국가연맹과 여러 법원 판결에서 오버레이 도구가 WCAG 준수를 제공하지 않는다고 명시했습니다. 사실, 일부 피고인은 오버레이 도구의 존재를 구체적으로 피고인이 자신의 사이트가 접근성이 없다는 것을 알고 있었지만 실제 수정 대신 화장 수정을 선택했다는 증거로 인용했습니다. 소스 코드를 수정하세요.
기존 교육 웹사이트를 WCAG 2.1 AA에 대해 복구하는 데 비용이 얼마나 듭니까?
복구 비용은 사이트의 현재 상태에 따라 엄청나게 다릅니다. 일반적인 대학 WordPress 사이트의 경우 철저한 복구에 $50K-$150K 이상을 예상합니다. 많은 기관에서 전체 WCAG 준수를 처음부터 포함하고 극적으로 낮은 지속적인 유지보수 비용을 포함하는 Next.js와 같은 현대적이고 기본적으로 접근성 있는 스택에서 재구축하는 것이 더 비용 효율적이라고 생각합니다 ($75K-$200K).
Lighthouse 접근성 점수를 목표로 해야 하는 것은 무엇입니까?
최소 90, 목표 95 이상. 하지만 Lighthouse는 WCAG 2.1 AA 문제의 약 30-40%만 포착할 수 있다는 것을 이해하세요. 대비 비율, 대체 텍스트 존재 및 ARIA 속성 유효성을 확인할 수 있지만 탭 순서가 논리적인지, 스킵 링크가 작동하는지, 또는 스크린 리더 사용자에게 콘텐츠가 의미가 있는지는 테스트할 수 없습니다. 자동화된 테스트와 수동 테스트만이 실제 답변입니다.
교육 웹사이트를 접근성에 대해 얼마나 자주 테스트해야 합니까?
자동화된 테스트는 모든 풀 요청에서 실행되어야 합니다. 그것이 CI/CD 파이프라인의 목적입니다. 수동 키보드 탐색 테스트는 모든 주요 릴리스 전에 수행되어야 합니다. 스크린 리더 테스트 (VoiceOver, NVDA)는 최소한 분기별로 수행되어야 합니다. 전체 전문 WCAG 감사는 연간 또는 중요한 기능이 추가될 때마다 수행되어야 합니다.
Next.js가 웹사이트를 자동으로 접근성 있게 만듭니까?
어떤 프레임워크도 자동으로 접근성이 있지 않습니다. 여전히 좋은 코드를 작성해야 합니다. 그러나 Next.js는 상당한 이점을 제공합니다. Image 컴포넌트는 누락된 대체 텍스트에 대해 경고합니다, Link 컴포넌트는 올바른 href 처리를 사용하여 적절한 <a> 태그를 생성합니다, React의 JSX는 의미 있는 요소를 권장합니다, 그리고 빌드 파이프라인은 자동화된 접근성 테스트를 지원합니다. 프레임워크가 당신을 위해 일을 수행하지 않지만, 올바른 일을 하는 것을 최소 저항의 경로로 만듭니다.
접근성 없는 대학 웹사이트에 대한 페널티는 무엇입니까?
교육 웹사이트에 대한 ADA 합의금은 $25,000에서 $300,000 이상에 달하며, 변호사비, 복구 비용 (자체가 합의금을 초과할 수 있음) 외에도 추가로 청구됩니다. 금전적 페널티를 초과하여, OCR은 준수 협약을 요구할 수 있으며, 이는 수년 동안 지속적인 모니터링 및 보고를 의무화합니다. 그리고 평판 손상이 있습니다. 예비 학생과 교수진이 당신의 기관에 대해 두 번 생각하게 만드는 종류입니다.