웹 접근성 완전 가이드: ARIA 패턴과 키보드 인터랙션

프론트엔드

웹 접근성ARIA키보드 인터랙션스크린리더WCAG

이 글은 누구를 위한 것인가

  • 스크린리더 사용자를 포함한 모든 사용자를 위한 UI를 만들려는 팀
  • WCAG 2.2 AA 기준을 충족해야 하는 프로젝트 개발자
  • 모달, 탭, 드롭다운 같은 복잡한 컴포넌트의 접근성을 개선하려는 팀

들어가며

접근성은 장애인만을 위한 것이 아니다. 키보드 전용 사용자, 임시 장애(손 부상), 열악한 환경(밝은 햇빛, 소음)의 사용자 모두에게 영향을 미친다. ARIA는 HTML 시맨틱만으로 표현할 수 없는 UI 패턴을 스크린리더에 전달하는 표준이다.

이 글은 bluefoxdev.kr의 웹 접근성 ARIA 가이드 를 참고하여 작성했습니다.


1. ARIA 핵심 개념

[ARIA 3가지 요소]

1. Role (역할): 요소의 의미
   role="button"    → 클릭 가능한 버튼
   role="dialog"    → 모달 다이얼로그
   role="tablist"   → 탭 그룹
   role="alert"     → 긴급 알림 (즉시 읽음)
   role="status"    → 비긴급 알림 (여유롭게 읽음)

2. Properties (속성): 불변 특성
   aria-label       → 시각적 레이블 없을 때 대체 텍스트
   aria-labelledby  → 레이블 역할 요소 ID 참조
   aria-describedby → 추가 설명 요소 ID 참조
   aria-required    → 필수 입력 필드

3. States (상태): 동적으로 변하는 값
   aria-expanded    → 열림/닫힘
   aria-selected    → 선택됨
   aria-disabled    → 비활성화
   aria-invalid     → 유효성 오류
   aria-live        → 동적 콘텐츠 알림

[ARIA 사용 원칙]
  1. 시맨틱 HTML 우선 (<button> > <div role="button">)
  2. 네이티브 시맨틱 변경 금지 (<h2 role="button"> X)
  3. 인터랙티브 ARIA는 키보드 접근 가능해야 함
  4. aria-hidden="true" 요소는 포커스 받으면 안 됨

[WCAG 2.2 AA 핵심]
  색상 대비: 일반 텍스트 4.5:1 이상
  터치 타겟: 최소 24×24px (권장 44×44px)
  포커스 표시: 2px 이상 명확한 시각적 표시
  레이블: 모든 폼 컨트롤에 연결된 레이블

2. 접근성 UI 패턴 구현

// 1. 접근성 모달 - 포커스 트랩 + Esc 닫기
import { useEffect, useRef } from 'react';

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

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
      const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    } else {
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;

    function trapFocus(e: KeyboardEvent) {
      if (e.key === 'Escape') { onClose(); return; }
      if (e.key !== 'Tab') return;

      const focusable = Array.from(
        dialogRef.current?.querySelectorAll<HTMLElement>(
          'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])'
        ) ?? []
      );
      if (!focusable.length) return;

      const first = focusable[0];
      const last  = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault(); last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault(); first.focus();
      }
    }

    document.addEventListener('keydown', trapFocus);
    return () => document.removeEventListener('keydown', trapFocus);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose} aria-hidden="true">
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal"
        onClick={e => e.stopPropagation()}
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="닫기">×</button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  );
}
// 2. 접근성 탭 - Roving Tabindex + Arrow Key 내비게이션
function Tabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());

  function handleKeyDown(e: React.KeyboardEvent, currentId: string) {
    const ids = tabs.map(t => t.id);
    const idx = ids.indexOf(currentId);
    let next: number | null = null;

    if (e.key === 'ArrowRight') next = (idx + 1) % ids.length;
    else if (e.key === 'ArrowLeft') next = (idx - 1 + ids.length) % ids.length;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = ids.length - 1;

    if (next !== null) {
      e.preventDefault();
      const nextId = ids[next];
      setActiveTab(nextId);
      tabRefs.current.get(nextId)?.focus();
    }
  }

  return (
    <div>
      <div role="tablist" aria-label="콘텐츠 탭">
        {tabs.map(tab => (
          <button
            key={tab.id}
            ref={el => el && tabRefs.current.set(tab.id, el)}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === tab.id ? 0 : -1}
            onClick={() => setActiveTab(tab.id)}
            onKeyDown={e => handleKeyDown(e, tab.id)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map(tab => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== tab.id}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}
// 3. aria-live 동적 알림 + 접근성 폼
function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  return (
    <form noValidate onSubmit={e => { e.preventDefault(); /* 검증 로직 */ }}>
      {/* 오류 요약: 제출 실패 시 상단에 표시 */}
      {Object.keys(errors).length > 0 && (
        <div role="alert" aria-label="입력 오류 목록">
          <h2>다음 항목을 수정해주세요</h2>
          <ul>
            {Object.entries(errors).map(([field, msg]) => (
              <li key={field}><a href={`#${field}`}>{msg}</a></li>
            ))}
          </ul>
        </div>
      )}

      <div>
        <label htmlFor="email">
          이메일
          <span aria-hidden="true" style={{ color: 'red' }}> *</span>
          <span className="sr-only">(필수)</span>
        </label>
        <input
          id="email"
          type="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : 'email-hint'}
        />
        <span id="email-hint" className="hint">예: user@example.com</span>
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>
    </form>
  );
}

// 스킵 내비게이션 (키보드 사용자 핵심)
function SkipLink() {
  return (
    <a href="#main-content" className="skip-link">
      본문으로 건너뛰기
    </a>
  );
}
/* visually-hidden: 시각적으로 숨기되 스크린리더는 읽음 */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* 스킵 링크: 평소 숨김, 포커스 시 표시 */
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  z-index: 9999;
  padding: 8px 16px;
  background: #000;
  color: #fff;
  text-decoration: none;
  font-weight: bold;
}
.skip-link:focus { top: 0; }

/* 포커스 스타일 (절대 제거 금지) */
:focus-visible {
  outline: 2px solid #6366f1;
  outline-offset: 2px;
  border-radius: 4px;
}
/* 마우스 클릭 시에는 포커스 링 숨김 */
:focus:not(:focus-visible) {
  outline: none;
}

/* 고대비 모드 대응 */
@media (forced-colors: active) {
  :focus-visible {
    outline: 3px solid ButtonText;
  }
}

/* 모션 감소 선호 */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

마무리

접근성의 첫 번째 원칙은 시맨틱 HTML 우선이다. <button>을 쓰면 role, keyboard 접근, focus가 자동 처리된다. ARIA는 HTML 시맨틱으로 부족한 경우에만 보완한다. 모달의 포커스 트랩, 탭의 Roving Tabindex, aria-live의 동적 알림, aria-invalid + aria-describedby의 오류 연결은 스크린리더 경험의 핵심 네 가지다. :focus-visible로 마우스와 키보드 사용자의 포커스 스타일을 분리하면 시각적 노이즈 없이 접근성을 확보할 수 있다.