본문 바로가기

React

React에서 접근성을 고려한 UI 컴포넌트 설계

접근성은 "특별한 기능"이 아니라 모든 사용자가 제품을 사용 가능하게 만드는 기본 품질입니다. 실무에서는 작은 결정 하나가 전체 경험을 좌우합니다. 아래는 React에서 바로 적용 가능한 접근성 중심 UI 컴포넌트 설계 방법과 코드 예시입니다.

1. 기본 원칙: 시맨틱, 키보드, 포커스

시맨틱 요소 우선 사용합니다. 버튼은 button, 링크는 a를 사용합니다. 모든 인터랙션은 마우스 없이도 키보드로 수행 가능해야 합니다. 포커스는 보이고 예측 가능해야 하며, 포커스 이동을 강제할 때는 들어온 곳과 나간 곳을 관리합니다.

// 나쁜 예: div는 버튼이 아닙니다
const Bad = () => <div onClick={() => {}}>제출</div>;

// 좋은 예: 네이티브 button + type 명시
function PrimaryButton({ children, type = 'button', ...props }) {
  return (
    <button type={type} {...props}>{children}</button>
  );
}

2. 버튼과 아이콘 버튼의 접근 가능한 이름

아이콘만 있는 버튼은 스크린 리더를 위한 접근 가능한 이름이 필요합니다. aria-label 또는 화면에 보이지 않는 텍스트를 제공합니다. title은 보조적일 뿐 대체가 되지 않습니다.

function IconButton({ label, children, ...props }) {
  return (
    <button type="button" aria-label={label} title={label} {...props}>
      {children}
    </button>
  );
}

// 사용 예: 닫기 버튼
<IconButton label="닫기" onClick={onClose}>
  <CloseIcon />
</IconButton>

3. 폼 필드: label, description, error 연결

폼은 레이블과 설명, 오류 메시지를 명확히 연결해야 합니다. React 18의 useId로 안정적인 id를 생성하고 aria-describedby로 힌트와 오류를 연결합니다. 오류는 role="alert"로 즉시 고지합니다.

import { useId } from 'react';

function Field({ label, hint, error, id, children }) {
  const rid = useId();
  const inputId = id ?? `field-${rid}`;
  const hintId = hint ? `${inputId}-hint` : undefined;
  const errorId = error ? `${inputId}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      {children({
        id: inputId,
        'aria-describedby': describedBy,
        'aria-invalid': Boolean(error)
      })}
      {hint && <p id={hintId}>{hint}</p>}
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}

// 사용 예
<Field label="이메일" hint="업무 이메일을 입력하세요" error={errors.email}>
  {(inputProps) => <input type="email" {...inputProps} />}
</Field>

4. 모달 다이얼로그: 포커스 트랩과 복귀

모달은 열릴 때 내부 첫 포커스 가능한 요소로 이동하고, Tab 순환을 강제하며, 닫힐 때 트리거로 포커스를 되돌립니다. role="dialog", aria-modal, aria-labelledby를 설정합니다.

import { useEffect, useRef } from 'react';

function Modal({ open, onClose, children, labelledById }) {
  const scopeRef = useRef(null);
  const lastFocusedRef = useRef(null);

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

    lastFocusedRef.current = document.activeElement;
    const getFocusable = () => scopeRef.current?.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])'
    );

    const focusables = getFocusable();
    focusables?.[0]?.focus();

    const onKeyDown = (e) => {
      if (e.key === 'Escape') onClose?.();
      if (e.key === 'Tab') {
        const list = Array.from(getFocusable() || []);
        if (list.length === 0) return;
        const first = list[0];
        const last = list[list.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', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      lastFocusedRef.current?.focus?.();
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div role="dialog" aria-modal="true" aria-labelledby={labelledById}>
      <div ref={scopeRef}>{children}</div>
    </div>
  );
}

5. 메뉴/리스트의 키보드 내비게이션(로빙 탭인덱스)

메뉴는 화살표 키, Home/End, Enter/Space를 지원해야 합니다. 탭은 메뉴 밖으로 이동시키고, 내부 항목 간 이동은 로빙 탭인덱스로 처리합니다.

import { useEffect, useRef, useState } from 'react';

function Menu({ items, onSelect }) {
  const [index, setIndex] = useState(0);
  const refs = useRef([]);

  useEffect(() => {
    refs.current[index]?.focus?.();
  }, [index]);

  return (
    <ul role="menu" aria-activedescendant={`item-${index}`}
      onKeyDown={(e) => {
        if (e.key === 'ArrowDown') { e.preventDefault(); setIndex((i) => (i + 1) % items.length); }
        if (e.key === 'ArrowUp') { e.preventDefault(); setIndex((i) => (i - 1 + items.length) % items.length); }
        if (e.key === 'Home') { e.preventDefault(); setIndex(0); }
        if (e.key === 'End') { e.preventDefault(); setIndex(items.length - 1); }
        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect?.(items[index]); }
      }}
    >
      {items.map((label, i) => (
        <li id={`item-${i}`} key={label}>
          <button
            role="menuitem"
            ref={(el) => (refs.current[i] = el)}
            tabIndex={i === index ? 0 : -1}
            onMouseEnter={() => setIndex(i)}
            onClick={() => onSelect?.(label)}
          >
            {label}
          </button>
        </li>
      ))}
    </ul>
  );
}

6. 상태 전달: 라이브 영역과 로딩

동적 상태는 스크린 리더에 즉시 전달되어야 합니다. 메시지는 aria-live로, 로딩은 aria-busy/disabled를 함께 사용합니다.

function Toast({ message, type = 'info' }) {
  const politeness = type === 'error' ? 'assertive' : 'polite';
  return (
    <div role="status" aria-live={politeness}>{message}</div>
  );
}

function SaveButton({ loading, onClick }) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={loading}
      aria-busy={loading}
      aria-disabled={loading}
    >
      {loading ? '저장 중…' : '저장'}
    </button>
  );
}

7. 모션, 포커스 표시, 스킵 링크

과도한 애니메이션은 멀미를 유발할 수 있습니다. 사용자 환경설정(prefers-reduced-motion)을 존중하여 애니메이션을 줄입니다. 포커스 링은 제거하지 말고 디자인에 맞춰 명확히 보이게 합니다. 긴 페이지에는 스킵 링크를 제공해 메인 콘텐츠로 빠르게 이동하게 합니다.

// 모션 축소 환경 감지
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function animateSafely(runAnimation) {
  if (prefersReducedMotion) return; // 애니메이션 생략
  runAnimation();
}

8. 테스트: 키보드, 이름, 위반 탐지

테스트는 실제 사용 시나리오를 다룹니다. user-event로 키보드 경로를 검증하고, jest-axe로 접근성 위반을 탐지합니다. 또한 eslint-plugin-jsx-a11y와 Storybook a11y 애드온을 CI에 연결해 회귀를 막습니다.

// @testing-library로 키보드 포커스 검증
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

it('모달 내부에서 Tab 순환이 유지됩니다', async () => {
  render(<Modal open labelledById="title">
    <h2 id="title">설정</h2>
    <button>확인</button>
    <button>취소</button>
  </Modal>);
  await userEvent.tab();
  expect(screen.getByRole('button', { name: '확인' })).toHaveFocus();
});

// jest-axe로 정적 위반 탐지
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('접근성 위반이 없어야 합니다', async () => {
  const { container } = render(<PrimaryButton>제출</PrimaryButton>);
  expect(await axe(container)).toHaveNoViolations();
});

9. 실무 체크리스트

컴포넌트는 네이티브 가능 여부를 먼저 검토합니다. 접근 가능한 이름(텍스트, aria-label, aria-labelledby)이 존재하는지 확인합니다. 키보드로 동일한 작업이 가능한지 테스트합니다. 상태 변화를 포커스, ARIA 속성, 라이브 영역으로 전달합니다. 포커스 이동, 트랩, 복귀를 명시적으로 처리합니다. 색 대비는 디자인 시스템에서 최소 대비를 보장합니다. 도입된 유틸(필드, 모달, 메뉴)은 팀 공용 컴포넌트로 추출해 재사용합니다.

결론적으로, 접근성은 초기 설계와 컴포넌트 아키텍처에서 결정됩니다. 위 패턴들을 기본 템플릿으로 채택하면 제품 전반의 사용성을 꾸준히 향상할 수 있습니다.