Post

웹 접근성으로 모두를 품은 컴포넌트 설계

웹 접근성으로 모두를 품은 컴포넌트 설계

디자인 시안에서 정해진 커스텀 스타일이 있었지만, 기본 input 요소(checkbox, radio)로는 이를 구현할 수 없었습니다. 네이티브 요소 대신 커스텀 디자인을 적용하면서도 웹 접근성을 지키기 위해 WAI-ARIA 패턴을 도입한 공통 컴포넌트를 설계했습니다.

목표는 다음과 같았습니다.

  • 스크린 리더와 키보드 사용자도 동등하게 사용할 수 있게 하기
  • 커스텀 디자인과 접근성을 동시에 충족
  • 재사용 가능한 공통 컴포넌트로 개발 효율성 높이기

개발 과정

1. 문제 인식과 접근법 결정

프로젝트에서 디자이너가 제시한 시안은 기본 input 요소의 스타일로는 구현이 불가능했습니다.

체크박스, 라디오 디자인 시안

스크린샷 2025-02-27 오전 12 12 01

기본 input을 사용하면 CSS 커스텀이 제한적이어서, 네이티브 <input>을 숨기고 <span>으로 커스텀 UI를 설계하기로 했습니다. 하지만 이 과정에서 접근성이 깨질 수 있다는 우려가 생겼고, 이를 해결하기 위해 WAI-ARIA 패턴을 적용하는 방향을 택했습니다.

  • <span>인가?: 네이티브 <input>을 숨기고 <span>role="checkbox"role="radio"를 부여해 커스텀 디자인을 자유롭게 구현하면서도 스크린 리더가 인식할 수 있게 했습니다.
  • WAI-ARIA 선택 이유: <input>을 대체하면서도 키보드와 스크린 리더 지원을 보장하려면 ARIA 속성이 필수였습니다.

2. WAI-ARIA 패턴 적용

WAI-ARIA의 체크박스와 라디오 패턴을 참고해 다음과 같이 구현했습니다.

  • 고유 ID 연결: useId로 고유 ID를 생성해 <input><label>을 연결. 스크린 리더가 라벨과 요소를 명확히 연관 짓게 함
  • ARIA 속성: aria-checked로 상태 전달, aria-disabled로 비활성 상태 알림, aria-labellabel prop이 없을 때 기본 설명 제공
  • 키보드 이벤트: SpaceEnter 키로 토글 가능하도록 onKeyDown 핸들러 직접 작성. e.preventDefault()로 기본 동작 방지하며 키보드 사용성 강화

3. 커스텀 디자인과 접근성 조화

  • 숨김 처리: 네이티브 <input>className="hidden" 적용해 시각적 중복 제거
  • 포커스 스타일: focus:ring으로 키보드 사용자가 포커스 상태를 인지하도록 시각적 피드백 추가
  • 상태 반영: checked 상태에 따라 아이콘 변경하며 스크린 리더와 동기화(aria-checked)

4. 코드 최적화와 재사용성

CheckboxInputRadioInput을 공통 컴포넌트로 설계하며 재사용성을 높였습니다.

  • props 확장: React.InputHTMLAttributes 상속으로 다양한 속성 지원
  • variant 옵션: CheckboxInputcheckboxcheck 스타일 변형 추가로 유연성 확보
  • 모듈화: 반복 로직(handleToggle)을 함수로 분리해 유지보수성 개선

체크박스 및 라디오 인풋 WAI-ARIA 적용 코드

CheckboxInput

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const CheckboxInput = ({ label, checked, disabled, onChange }) => {
  const id = useId();
  return (
    <div>
      <input
        id={checkboxId}
        type="checkbox"
        checked={checked}
        onChange={onChange}
        disabled={disabled}
        {...props}
        className="hidden"
      />
      <span
        role="checkbox"
        tabIndex={0}
        aria-checked={checked}
        aria-labelledby={checkboxId}
        aria-disabled={disabled}
        onKeyDown={(e) => handleKeyDown(e, handleToggle, disabled)}
        onClick={!disabled ? handleToggle : undefined}
        className="focus:outline-none focus:ring-1 focus:ring-primary-normal cursor-pointer"
      >
        {getIconForState(variant, checked)}
      </span>
      <label htmlFor={checkboxId}>
        {label}
      </label>
    </div>
  );
};

RadioInput

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const RadioInput = ({ label, checked, disabled, onChange }) => {
  const id = useId();
  return (
    <label htmlFor={`radio-${id}`}>
      <input
        type='radio'
        checked={checked}
        id={`radio-${id}`}
        onChange={onChange}
        disabled={disabled}
        className="hidden"
      />
      <span
        role="radio"
        tabIndex={0}
        aria-checked={checked}
        aria-disabled={disabled}
        onKeyDown={(e) => {
          if ((e.key === ' ' || e.key === 'Enter') && !disabled && !checked) {
            e.preventDefault();
            onChange?.({ target: { checked: true } });
          }
        }}
        onClick={!disabled && !checked ? () => onChange?.({ target: { checked: true } }) : undefined}
      >
        {/* 커스텀 라디오 인풋 UI */}
      </span>
      {label && <span>{label}</span>}
    </label>
  );
};

스크린 리더 테스트 영상

웹 접근성을 보장하기 위해 스크린 리더 테스트를 진행하였습니다.
아래 영상에서는 체크박스가 스크린 리더에서 올바르게 인식되고 조작될 수 있는지를 확인합니다.

스크린 리더 테스트를 위한 회원가입 폼 코드 (약관 동의 체크박스 그룹)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<fieldset role="group" aria-labelledby="terms-group">
  <legend id="terms-group" className="sr-only">
    약관 동의
  </legend>
  {/* 전체 동의 체크박스 */}
  <Form.Checkbox
    name="agreeToAll"
    label={
      <Text.Title>
        전체동의
      </Text.Title>
    }
  />

  <div>
    <Divider isVertical={false} />

    {/* 개별 동의 체크박스 */}
    <Form.Checkbox
      name="age"
      aria-describedby="desc-age"
      label={
        <Text.Body id="desc-age">
          만 14세 미만입니다 <Highlight>(필수)</Highlight>
        </Text.Body>
      }
    />
    <Form.Checkbox
      name="termsAgreement"
      aria-describedby="desc-terms"
      label={
        <Text.Body id="desc-terms">
          서비스 이용약관 동의 <Highlight>(필수)</Highlight>
        </Text.Body>
      }
    />
    <Form.Checkbox
      name="userInfo"
      aria-describedby="desc-info"
      label={
        <Text.Body id="desc-info">
          개인정보 수집 및 이용 동의 <Highlight>(필수)</Highlight>
        </Text.Body>
      }
    />
    <Form.Checkbox
      name="marketingConsent"
      aria-describedby="desc-marketing"
      label={
        <Text.Body id="desc-marketing">
          이벤트 등 마케팅 정보 수신 동의 (선택)
        </Text.Body>
      }
    />
  </div>
</fieldset>

성과

  • 접근성 향상: 스크린 리더와 키보드로 완벽히 동작하며 모든 사용자에게 동등한 경험 제공
  • 디자인 충족: 시안에 맞춘 커스텀 UI를 구현하면서도 기능 손실 없음
  • 개발 효율성: 공통 컴포넌트로 재사용성 높아 팀원 간 협업과 생산성 개선

이 프로젝트를 통해 디자인과 접근성을 동시에 고려한 컴포넌트 개발의 중요성을 깨달았고, 단순한 UI 구현을 넘어, 모든 사용자가 동등하게 접근할 수 있는 인터페이스를 만드는 것이 중요하다는 점을 실감했습니다.

This post is licensed under CC BY 4.0 by the author.
-->