블로그PLAYBOOK
개발

웹 접근성(WCAG) 완벽 가이드: 법적 의무에서 비즈니스 가치까지

이 글에서 해결할 것

웹 접근성은 법적 의무이자 시장 확장 기회입니다. WCAG 2.1 핵심 원칙부터 실전 구현 패턴까지, 접근 가능한 웹사이트 구축의 모든 것을 다룹니다.

POEMORA · 편집팀2025-12-0115
촉각 표면과 청취 장치, 고대비 물체가 하나의 명확한 경로로 연결된 작업대
서로 다른 감각과 조작 방식을 처음부터 같은 정보 구조 안에 포함해야 한다는 뜻을 담았습니다.
#웹 접근성#WCAG#ARIA#장애인차별금지법#Semantic HTML

읽기 전에 핵심만

  1. 웹 접근성은 별도 장식이 아니라 콘텐츠 인식, 키보드 조작, 이해 가능한 흐름을 설계하는 품질 기준입니다.
  2. 의미에 맞는 HTML을 먼저 사용하고 맞춤형 위젯에만 필요한 ARIA 역할과 상태를 보완하세요.
  3. 자동 검사 결과에 머물지 말고 키보드 탐색과 보조기술을 포함한 사람 중심 검증을 개발 과정에 넣으세요.

웹 접근성(Web Accessibility)은 장애가 있는 사용자를 포함한 모든 사람이 웹사이트를 이용할 수 있도록 보장하는 것입니다. 단순한 법적 의무를 넘어, 더 넓은 시장 접근과 SEO 개선, 사용자 경험 향상으로 이어지는 중요한 비즈니스 전략입니다.

웹 접근성이란 무엇이고 왜 중요한가

전세계 인구의 약 15%(10억 명 이상)가 어떤 형태로든 장애를 겪고 있습니다. 한국에서는 등록 장애인이 약 265만 명(2024년 기준)이며, 이는 전체 인구의 5.1%에 해당합니다.

장애의 범주는 생각보다 넓습니다:

  • 영구적 장애: 시각/청각/신체 장애
  • 일시적 장애: 팔 부상, 눈 수술 후 회복기
  • 상황적 장애: 밝은 햇빛 아래에서 화면 보기, 시끄러운 환경에서 동영상 시청, 한 손으로 스마트폰 조작
접근성 트리 / Accessibility Tree document <html> banner <header> role="banner" main <main> role="main" contentinfo <footer> role="contentinfo" navigation <nav> role="navigation" article <article> role="article" complementary <aside> role="complementary" Screen readers navigate using ARIA roles 스크린 리더는 ARIA 역할을 사용하여 탐색합니다 Semantic HTML → Better Accessibility
How semantic HTML maps to accessibility roles for assistive technology

법적 의무와 비즈니스 가치

한국의 법적 요구사항

장애인차별금지 및 권리구제 등에 관한 법률 (2008년 시행):

  • 공공기관: 2009년부터 의무 (과태료 최대 3천만원)
  • 국가/지방자치단체: 웹 접근성 품질인증 마크 필수
  • 민간기업: 법적 의무는 아니지만, 장애인 차별 소송 가능
  • 의료기관/대학: 정보접근성 의무 강화 추세

비즈니스 가치

  1. 시장 확대: 265만 장애인 + 고령자 시장 접근
  2. SEO 향상: 의미론적 HTML과 명확한 구조는 검색엔진 최적화에 직접 기여
  3. 사용성 개선: 접근 가능한 UI는 모든 사용자에게 더 나은 경험 제공
  4. 법적 리스크 회피: 소송 및 과태료 방지
  5. 브랜드 이미지: 사회적 책임을 다하는 기업 이미지

WCAG 2.1 핵심 원칙 (POUR)

빛과 파동, 촉각 능선, 모듈형 발판으로 된 네 경로가 열린 출입구로 모이는 장면
접근성 원칙은 서로 분리된 체크 항목이 아니라 사용자가 콘텐츠에 도달하도록 함께 작동하는 경로입니다.

WCAG (Web Content Accessibility Guidelines) 2.1은 4가지 핵심 원칙을 제시합니다.

1. Perceivable (인식 가능)

정보와 UI 컴포넌트는 사용자가 인식할 수 있어야 합니다.

핵심 성공 기준:

  • 1.1.1 비텍스트 콘텐츠: 모든 이미지에 대체 텍스트 제공
  • 1.3.1 정보와 관계: 명확한 제목 구조, 레이블, 관계
  • 1.4.3 명암 대비: 텍스트 대비 최소 4.5:1 (AA 기준)
  • 1.4.11 비텍스트 명암 대비: UI 컴포넌트 대비 최소 3:1

2. Operable (운용 가능)

UI 컴포넌트와 내비게이션은 운용 가능해야 합니다.

핵심 성공 기준:

  • 2.1.1 키보드 접근: 모든 기능을 키보드로 조작 가능
  • 2.4.1 블록 건너뛰기: Skip navigation 링크 제공
  • 2.4.3 포커스 순서: 논리적인 Tab 순서
  • 2.4.7 포커스 표시: 명확한 포커스 인디케이터

3. Understandable (이해 가능)

정보와 UI의 작동은 이해 가능해야 합니다.

핵심 성공 기준:

  • 3.1.1 페이지 언어: 선언
  • 3.2.1 포커스 시: 포커스만으로 컨텍스트 변경 없음
  • 3.3.1 오류 식별: 입력 오류 명확히 안내
  • 3.3.2 레이블/지시: 입력 필드에 명확한 레이블

4. Robust (견고함)

콘텐츠는 다양한 사용자 에이전트(보조 기술 포함)로 해석 가능해야 합니다.

핵심 성공 기준:

  • 4.1.1 구문 분석: 유효한 HTML 작성
  • 4.1.2 이름, 역할, 값: 모든 UI 컴포넌트에 명확한 역할과 상태 제공
  • 4.1.3 상태 메시지: 스크린 리더가 인식할 수 있는 동적 콘텐츠 업데이트

Semantic HTML의 힘

의미론적 HTML은 접근성의 기초입니다. 올바른 HTML 요소를 사용하면 보조 기술이 콘텐츠를 정확히 해석할 수 있습니다.

Landmark Roles

html
<!-- 나쁜 예: 모든 것이 div -->
<div class="header">
  <div class="nav">...</div>
</div>
<div class="content">...</div>
<div class="footer">...</div>

<!-- 좋은 예: Semantic HTML -->
<header>
  <nav>...</nav>
</header>
<main>
  <article>
    <h1>제목</h1>
    <section>...</section>
  </article>
</main>
<aside>
  <h2>관련 정보</h2>
</aside>
<footer>...</footer>

스크린 리더는 landmark를 인식하여 사용자가 빠르게 원하는 섹션으로 이동할 수 있게 합니다.

Heading Hierarchy

html
<!-- 나쁜 예: 건너뛴 헤딩 레벨 -->
<h1>메인 제목</h1>
<h3>섹션 제목</h3>  <!-- h2를 건너뜀 -->

<!-- 좋은 예: 논리적 구조 -->
<h1>메인 제목</h1>
<h2>섹션 제목</h2>
<h3>하위 섹션</h3>
<h2>다음 섹션</h2>

ARIA 속성 실전 적용

ARIA (Accessible Rich Internet Applications)는 HTML이 표현하지 못하는 의미를 보조 기술에 전달합니다.

기본 원칙: ARIA는 최후의 수단입니다. 먼저 Semantic HTML을 사용하세요.

접근 가능한 모달 구현

tsx
'use client';
import { useEffect, useRef } from 'react';

export function AccessibleModal({
  isOpen,
  onClose,
  title,
  children
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 현재 포커스 요소 저장
      previousFocusRef.current = document.activeElement as HTMLElement;

      // 모달로 포커스 이동
      modalRef.current?.focus();

      // 바깥 스크롤 방지
      document.body.style.overflow = 'hidden';
    } else {
      // 모달 닫힐 때 이전 포커스 복원
      previousFocusRef.current?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <div
        ref={modalRef}
        className="bg-white rounded-lg p-6 max-w-md w-full"
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <div className="flex justify-between items-start mb-4">
          <h2 id="modal-title" className="text-xl font-bold">
            {title}
          </h2>
          <button
            onClick={onClose}
            aria-label="모달 닫기"
            className="text-gray-500 hover:text-gray-700"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>
        <div>{children}</div>
      </div>
    </div>
  );
}

주요 접근성 기능:

  • role="dialog", aria-modal="true": 스크린 리더에 모달임을 알림
  • aria-labelledby: 모달 제목과 연결
  • 포커스 트랩: 모달 내부에서만 Tab 이동
  • 포커스 복원: 모달 닫힐 때 이전 포커스 위치로 복귀
  • ESC 키로 닫기 (추가 구현 권장)

ARIA Live Regions

동적으로 업데이트되는 콘텐츠를 스크린 리더에 알립니다.

tsx
export function FormStatus({ message, type }: { message: string; type: 'success' | 'error' }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className={`p-4 rounded-md ${
        type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
      }`}
    >
      {message}
    </div>
  );
}
  • aria-live="polite": 사용자 작업 후 알림 (긴급하지 않음)
  • aria-live="assertive": 즉시 알림 (오류, 경고)
  • aria-atomic="true": 전체 내용을 읽음 (일부만 변경되어도)

색상 대비와 시각적 접근성

WCAG 대비 기준

레벨일반 텍스트큰 텍스트 (18px+)
AA4.5:13:1
AAA7:14.5:1

큰 텍스트 기준: 18px 이상 또는 14px bold 이상

대비 검사 도구

typescript
// 간단한 대비비 계산 함수
function getContrastRatio(color1: string, color2: string): number {
  const l1 = getLuminance(color1);
  const l2 = getLuminance(color2);

  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);

  return (lighter + 0.05) / (darker + 0.05);
}

// Chrome DevTools > Inspect > Accessibility에서도 확인 가능

Focus Indicator

css
/* 기본 outline 제거하지 말 것! */
button:focus {
  outline: 2px solid #4a90d9;
  outline-offset: 2px;
}

/* 더 나은 커스텀 포커스 스타일 */
button:focus-visible {
  outline: 2px solid #4a90d9;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(74, 144, 217, 0.2);
}

:focus-visible는 키보드 포커스에만 스타일을 적용합니다 (마우스 클릭 시에는 적용 안됨).


키보드 네비게이션

모든 인터랙티브 요소는 키보드로 접근하고 조작할 수 있어야 합니다.

Skip Navigation

tsx
// components/layout/SkipNav.tsx
export function SkipNav() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
                 bg-accent text-white px-4 py-2 rounded-md z-50"
    >
      본문으로 건너뛰기
    </a>
  );
}

// app/[locale]/layout.tsx
<body>
  <SkipNav />
  <Header />
  <main id="main-content">
    {children}
  </main>
</body>

sr-only 클래스는 시각적으로 숨기지만 스크린 리더는 읽을 수 있습니다.

Focus Trap (모달, 드롭다운)

tsx
// 간단한 포커스 트랩 구현
useEffect(() => {
  if (!isOpen) return;

  const modal = modalRef.current;
  if (!modal) return;

  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );

  const firstElement = focusableElements[0] as HTMLElement;
  const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

  const handleTab = (e: KeyboardEvent) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  };

  modal.addEventListener('keydown', handleTab);
  return () => modal.removeEventListener('keydown', handleTab);
}, [isOpen]);

접근 가능한 폼 구현

tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('유효한 이메일을 입력해주세요'),
  message: z.string().min(10, '메시지는 최소 10자 이상이어야 합니다'),
});

export function AccessibleContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <div className="mb-4">
        <label htmlFor="email" className="block mb-2 font-medium">
          이메일 <span aria-label="필수 입력" className="text-red-500">*</span>
        </label>
        <input
          id="email"
          type="email"
          {...register('email')}
          aria-required="true"
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
          className="w-full px-4 py-2 border rounded-md"
        />
        {errors.email && (
          <p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.email.message}
          </p>
        )}
      </div>

      <div className="mb-4">
        <label htmlFor="message" className="block mb-2 font-medium">
          메시지 <span aria-label="필수 입력" className="text-red-500">*</span>
        </label>
        <textarea
          id="message"
          rows={5}
          {...register('message')}
          aria-required="true"
          aria-invalid={errors.message ? 'true' : 'false'}
          aria-describedby={errors.message ? 'message-error' : undefined}
          className="w-full px-4 py-2 border rounded-md"
        />
        {errors.message && (
          <p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.message.message}
          </p>
        )}
      </div>

      <button
        type="submit"
        className="px-6 py-3 bg-accent text-white rounded-md hover:bg-accent/90"
      >
        제출하기
      </button>
    </form>
  );
}

접근성 포인트:

  • id 연결
  • aria-required: 필수 입력 표시
  • aria-invalid: 오류 상태 알림
  • aria-describedby: 오류 메시지 연결
  • role="alert": 오류 메시지를 스크린 리더가 즉시 읽음

접근성 테스트 도구와 워크플로우

자동 테스트 도구

  1. axe DevTools (Chrome/Firefox 확장 프로그램)

- 가장 정확한 자동 테스트 도구

- WCAG 2.1 Level A/AA 검사

  1. Lighthouse (Chrome DevTools)

- Performance, SEO와 함께 Accessibility 점수 제공

- 자동화 가능

  1. WAVE (WebAIM)

- 시각적으로 문제를 표시

- 교육용으로 유용

수동 테스트

자동 도구는 전체 접근성 문제의 30-40%만 감지합니다. 수동 테스트가 필수입니다.

스크린 리더 테스트

Windows: NVDA (무료)

Mac: VoiceOver (내장)

모바일: TalkBack (Android), VoiceOver (iOS)

기본 단축키 (NVDA):

  • Ctrl: 읽기 중지
  • Insert + Down: 연속 읽기
  • Tab: 다음 인터랙티브 요소
  • H: 다음 헤딩

마무리

웹 접근성은 단순히 법을 준수하는 것을 넘어, 더 나은 사용자 경험과 비즈니스 가치를 창출합니다. POEMA는 모든 프로젝트에서 WCAG 2.1 AA 기준을 목표로 개발하며, 접근성 감사 및 개선 컨설팅도 제공합니다.

실천 가능한 첫 단계:

  1. Semantic HTML로 마크업 개선
  2. 모든 이미지에 의미있는 alt 텍스트 추가
  3. 키보드 네비게이션 테스트
  4. 색상 대비 검사
  5. axe DevTools로 자동 테스트 실행

접근 가능한 웹은 모두를 위한 웹입니다. 오늘부터 시작하세요.

접근성 점검과 개선을 웹사이트 설계에 반영하려면 POEMORA 웹 개발 서비스에서 적용 범위를 살펴보세요.

FAQ

자주 묻는 질문

자동 검사만 통과하면 웹 접근성을 충족한 것인가요?

아닙니다. 자동 도구는 일부 오류를 빠르게 찾는 데 유용하지만, 실제 키보드 사용 흐름과 보조기술 경험처럼 사람의 판단이 필요한 항목은 수동 평가를 병행해야 합니다.

이미지에는 언제 대체 텍스트가 필요한가요?

정보를 전달하거나 기능을 수행하는 이미지에는 같은 목적을 전달하는 대체 텍스트가 필요합니다. 의미 없는 장식 이미지는 보조기술이 건너뛸 수 있도록 처리하는 편이 적절합니다.

ARIA를 많이 추가하면 접근성이 더 좋아지나요?

항상 그렇지는 않습니다. 먼저 의미에 맞는 기본 HTML 요소를 사용하고, 기본 요소만으로 표현하기 어려운 맞춤형 위젯의 역할·상태·속성을 보완할 때 ARIA를 신중히 적용해야 합니다.

REFERENCES

확인한 자료

관련 글