ARIA만 붙이면 끝일까: React·Capacitor 앱에 접근성을 도입하며 배운 것

웹 접근성 Android ARIA Capacitor iOS React

세종대학교 학생식당에 새로운 키오스크가 들어왔다. 기존 키오스크와 달리 화면 아래쪽에 휠체려 있었다. 장애인 접근성을 고려한 키오스크였다.

직접 화면을 눌러보고 음성 안내도 확인해 봤다. 이전에는 키오스크 접근성을 개발 업무와 크게 연결해 생각하지 않았다. 그런데 실제 기기를 사용해 보니 접근성이 단순히 글씨를 크게 표시하거나 버튼을 크게 만드는 문제가 아니라는 걸 알 수 있었다. 화면의 정보를 어떻게 전달하는지, 사용자가 어떤 방식으로 조작할 수 있는지까지 함께 설계해야 했다.

비슷한 시기에 웨이티플러스 서비스 운영 측에서도 요청이 들어왔다. 장애인 접근성을 갖춘 키오스크 도입이 확대되고 있으니, 키오스크에서 끝내지 말고 사용자가 주문에 사용하는 앱에도 배리어프리를 적용해 보자는 내용이었다.

이 요청은 당시 국내 정책 흐름과도 맞물려 있었다. 2021년 개정된 장애인차별금지법은 키오스크와 모바일 앱을 장애인이 비장애인과 동등하게 접근하고 이용할 수 있어야 하는 대상으로 명시했고, 관련 내용은 2023년부터 시행됐다. 모바일 앱은 2023년 7월 공공·교육·의료기관 등을 시작으로 단계적으로 확대돼 2024년 7월에는 상시 근로자 100명 미만 사업자 등까지 적용 범위가 넓어졌다. 키오스크 역시 단계적으로 확대된 뒤, 기존에 설치된 기기까지 포함해 2026년 1월 28일부터 관련 의무가 전면 시행됐다. 스가 특정 법률의 모든 요구사항을 충족했다는 내용이 아니다. 이번 작업에서는 WCAG와 WAI-ARIA를 구현 방향을 판단하기 위한 참고 자료로 사용했으며, 전체 기준을 대조해 적합성을 인증받지는 않았다.

한 달 정도 진행한 이번 작업의 목표는 조금 더 현실적인 곳에 있었다.

웨이티플러스의 주요 주문 화면에 스크린 리더가 이해할 수 있는 의미 정보를 추가하고, iOS에서도 시스템 글자 크기를 어느 정도 반영할 수 있는 기반을 만드는 것.

화면이 보인다고 사용할 수 있는 것은 아니었다

웨이티플러스는 React와 Capacitor로 만든 하이브리드 앱이다. 개발자는 React 컴포넌트와 CSS로 화면을 만들지만, 사용자는 App Store나 Google Play에서 설치한 네이티브 앱으로 접한다.

화면을 보는 사용자에게는 CSS로 그린 체크박스와 HTML 기본 체크박스의 차이가 크지 않을 수 있다. 둘 다 체크 표시가 나타나고, 누르면 상태가 바뀐다.

스크린 리더에게는 다르다.

스크린 리더는 화면의 픽셀을 보고 버튼과 체크박스를 구분하지 않는다. HTML 요소와 ARIA 속성으로 구성되는 접근성 정보를 읽는다. WAI-ARIA는 역할과 상태, 속성을 통해 커스텀 UI의 의미를 보조 기술에 전달할 수 있도록 한다. 하지만 ARIA 역할을 붙인다고 브라우저가 그 역할에 필요한 동작까지 자동으로 구현해 주는 것은 아니다. W3C도 이를 두고 “역할은 약속”이라고 설명한다. role="button"을 선언했다면 버튼에 기대되는 조작 방식까지 개발자가 책임져야 한다는 뜻이다. 저 확인한 문제도 커스텀 체크박스였다.

웨이티플러스의 체크박스는 디자인 자유도를 위해 실제 <input type="checkbox">가 아니라 <button>을 기반으로 만들어져 있었다.

<button
  type="button"
  onClick={onClick}
>
  <div data-checked={checked}>
    <CheckIcon />
  </div>
</button>

눈으로 보면 체크박스였지만 VoiceOver와 TalkBack은 이 요소를 버튼으로 인식했다. data-checked 값은 CSS가 체크된 모양을 그리는 데 사용할 뿐, 스크린 리더에는 아무 의미도 전달하지 못했다.

이를 다음과 같이 수정했다.

<button
  type="button"
  onClick={onClick}
  role="checkbox"
  aria-checked={checked}
  aria-label={ariaLabel}
>
  <div data-checked={checked}>
    <CheckIcon />
  </div>
</button>

현재 구현에서 각 속성의 책임은 분리돼 있다.

  • data-checked는 화면의 스타일을 변경한다.
  • role="checkbox"는 해당 요소가 체크박스 역할을 한다고 알린다.
  • aria-checked는 현재 체크 상태를 전달한다.
  • aria-label은 무엇에 대한 체크박스인지 이름을 제공한다.

CSS를 ARIA로 대체한 것이 아니다. 화면에 보이는 상태와 보조 기술에 전달되는 상태를 각각 관리한 것이다. 현재 웨이티플러스의 CheckBox 컴포넌트도 이 구조를 유지하고 있다.

가능했다면 처음부터 네이티브 <input type="checkbox">를 사용하는 편이 더 안전했을 것이다. 브라우저가 체크박스의 역할과 키보드 조작을 기본으로 제공하기 때문이다. 그러나 이미 많은 화면이 기존 컴포넌트와 CSS 구조에 의존하고 있었다. 체크박스 하나를 교체하는 데서 끝나지 않고 호출부와 스타일, 이벤트 처리를 함께 검증해야 했다.

전면 리팩터링 대신 의미 정보를 보강했다

처음 검토한 방향은 크게 세 가지였다.

첫 번째는 기존 커스텀 컴포넌트를 시맨틱 HTML 중심으로 다시 만드는 방식이었다. 가장 이상적이지만 변경 범위가 너무 컸다. 다른 기능 개발이 동시에 진행되고 있었고, 접근성 작업을 담당하는 개발자는 한 명이었다.

두 번째는 접근성 컴포넌트 라이브러리를 도입하는 방식이었다. 모달의 포커스 관리나 탭의 키보드 이동처럼 직접 구현하기 까다로운 기능을 검증된 형태로 가져올 수 있다. 다만 이미 굳어진 디자인 시스템에 새 컴포넌트 구조를 결합하면 DOM 구조와 상태 관리 방식을 상당 부분 수정해야 할 가능성이 있었다.

세 번째는 기존 컴포넌트 구조를 유지하면서 ARIA와 시맨틱 요소를 보강하는 방식이었다.

이번 작업에서는 세 번째 방식을 선택했다.

접근성 관점에서 가장 완전한 선택이어서가 아니라, 현재 코드와 일정 안에서 실제로 적용할 수 있는 범위였기 때문이다. 한 번에 전체 디자인 시스템을 교체하기보다, 주요 주문 흐름을 스크린 리더로 탐색할 수 있는 첫 단계부터 만들기로 했다.

화면마다 수정하지 않고 공용 컴포넌트부터 바꿨다

PR은 39개 커밋과 99개 파일로 구성됐다. 하지만 처음부터 99개 파일을 하나씩 열어 aria-label을 추가한 것은 아니었다. 먼저 공용 컴포넌트를 수정한 뒤 핵심 주문 흐름과 나머지 화면으로 범위를 넓혔다.

공용 컴포넌트를 먼저 선택한 이유는 코드 반복을 줄이기 위해서였다.

뒤로 가기, 닫기, 검색, 수량 증가처럼 아이콘만 표시되는 버튼은 화면마다 반복해서 사용됐다. 각 호출부에서 접근성 이름을 직접 지정하면 누락 가능성이 커진다. 그래서 공용 버튼에 기본 이름을 두고, 문맥이 필요한 경우에만 호출부에서 덮어쓸 수 있도록 했다.

interface CartButtonProps {
  onClick: () => void;
  ariaLabel?: string;
}

export default function CartButton({
  onClick,
  ariaLabel = '장바구니',
}: CartButtonProps) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={ariaLabel}
    >
      <CartIcon />
    </button>
  );
}

장바구니에 담긴 수량처럼 현재 상태가 중요한 화면에서는 이름을 동적으로 바꿀 수 있었다.

<CartButton
  onClick={handleOpenCart}
  ariaLabel={
    itemCount > 0
      ? `장바구니, 담긴 상품 ${itemCount}개`
      : '장바구니'
  }
/>

이 방식은 기존 호출부를 모두 수정하지 않고 기본적인 이름을 제공할 수 있다는 장점이 있었다. 반대로 기본 이름이 실제 사용 문맥과 맞지 않을 수 있다는 위험도 있다. 예를 들어 공용 닫기 버튼을 검색어 초기화에 재사용한다면 기본값인 “닫기”보다 “검색어 지우기”가 적절하다. 공용 컴포넌트에 기본값을 넣었다고 화면별 검토가 사라지는 것은 아니었다.

화면이 바뀌었다는 사실도 알려야 했다

버튼과 체크박스는 사용자가 직접 탐색하는 요소다. 토스트나 로딩 상태는 사용자가 포커스를 옮기지 않아도 화면에서 갑자기 나타난다.

화면을 보는 사용자는 결제 오류 토스트가 나타나면 바로 알 수 있다. 스크린 리더 사용자에게는 별도로 상태 변화를 전달해야 했다.

웨이티플러스의 토스트는 오류와 일반 안내를 구분했다.

const ariaRole = variant === 'error' ? 'alert' : 'status';
const ariaLive = variant === 'error' ? 'assertive' : 'polite';

<div
  role={ariaRole}
  aria-live={ariaLive}
  aria-atomic="true"
>
  <div data-visible={visible}>
    {message}
  </div>
</div>

일반적인 성공과 안내 메시지는 polite로 설정해 현재 읽고 있는 내용을 무조건 중단하지 않도록 했다. 즉시 확인해야 하는 오류는 alertassertive를 사용했다. 현재 토스트 컴포넌트에도 이 구분이 적용돼 있다.

이 과정에서 ARIA는 “화면을 음성으로 그대로 읽어 주는 기술”이 아니라는 점을 다시 확인했다. 개발자가 어떤 변화가 중요한지 판단하고, 그 변화가 언제 어떤 우선순위로 전달돼야 하는지 지정해야 했다.

Android에서 먼저 글자 크기 문제를 만났다

시스템 글자 크기에 대한 문제는 이번 작업에서 처음 발견한 것이 아니었다.

Android 베타 테스트 중 일부 사용자 기기에서 공용 버튼과 고정 영역의 UI가 깨지는 문제가 발생한 적이 있었다. 같은 기종에서도 모든 사용자에게 발생하지 않아 원인을 찾는 데 시간이 걸렸다. 확인해 보니 사용자마다 Android 시스템 글자 크기 설정이 달랐다.

글자 크기가 커지면서 버튼 텍스트가 두 줄로 내려갔지만, 컨테이너는 고정 높이를 유지했다. 텍스트가 잘리거나 아이콘과 겹치는 문제가 생겼다.

이후 공용 컴포넌트의 고정 높이를 줄이고, 콘텐츠에 따라 확장될 수 있도록 수정했다.

/* 변경 전 */
.container {
  height: var(--bottom-navigation-height);
}
/* 변경 후 */
.container {
  min-height: var(--bottom-navigation-height);
  height: auto;
}

Android 14부터 네이티브 앱은 시스템 글자 크기를 최대 200%까지 지원하며, Android 공식 문서도 최대 글자 크기에서 UI가 내용을 수용할 수 있는지 테스트하도록 안내한다. 다만 웨이티플러스는 네이티브 TextView가 아니라 WebView 내부의 HTML을 사용하므로 네이티브 sp 단위에 대한 설명을 그대로 적용할 수는 없다. Android WebView에는 별도의 텍스트 확대 설정인 setTextZoom()도 존재한다. 이번 프로젝트에서는 문서상의 일반론보다 실제 Android 15 기기에서 나타난 동작을 기준으로 UI를 확인했다. 글자 크기가 반영되지 않았다

Android에서 글자 크기에 따른 UI 변화를 확인한 뒤 iOS에서도 같은 테스트를 진행했다.

웨이티플러스의 iOS 앱에서는 시스템의 텍스트 크기 설정을 변경해도 WebView 내부 글자 크기가 달라지지 않았다. Capacitor가 제공하는 WebView 안에서 React 앱이 실행되기 때문에, iOS 네이티브 UI가 사용하는 Dynamic Type 값이 CSS에 자동으로 전달되지 않고 있었다.

이를 해결하기 위해 iOS의 preferredContentSizeCategory를 읽는 Capacitor 플러그인을 추가했다.

@objc(DynamicType)
public class DynamicTypePlugin: CAPPlugin, CAPBridgedPlugin {
  public let identifier = "DynamicTypePlugin"
  public let jsName = "DynamicType"

  public let pluginMethods: [CAPPluginMethod] = [
    CAPPluginMethod(
      name: "getFontScale",
      returnType: CAPPluginReturnPromise
    )
  ]
}

앱 실행 중 사용자가 텍스트 크기를 변경하는 경우도 감지하도록 UIContentSizeCategory.didChangeNotification을 구독했다. 현재 플러그인은 변경 이벤트를 fontScaleChanged라는 이름으로 React 영역에 전달한다.

TypeScript에서는 전달받은 배율을 <html>font-size에 적용했다.

function applyFontScale(scale: number): void {
  document.documentElement.style.fontSize = `${scale * 100}%`;
}

웨이티플러스는 대부분의 글자와 레이아웃 크기를 rem으로 관리한다. 루트 글자 크기를 조정하면 각 화면의 글자 크기를 하나씩 변경하지 않고 전체 앱에 배율을 적용할 수 있었다. 해당 로직은 iOS에서만 실행되도록 분기했다.

하지만 rem은 글자에만 사용하는 단위가 아니었다.

버튼 높이, 내부 여백, 간격까지 rem으로 작성된 영역은 글자와 함께 커졌다. 시스템 글자 크기를 React에 전달하는 것보다, 커진 크기를 기존 레이아웃이 받아들이도록 수정하는 작업이 더 오래 걸렸다.

1.353배는 접근성 기준이 아니라 임시 상한이었다

현재 iOS 플러그인에는 다음과 같은 배율 표가 들어 있다.

let scaleMap: [UIContentSizeCategory: Double] = [
  .extraSmall: 0.823,
  .small: 0.882,
  .medium: 0.941,
  .large: 1.0,
  .extraLarge: 1.118,
  .extraExtraLarge: 1.235,
  .extraExtraExtraLarge: 1.353,

  .accessibilityMedium: 1.353,
  .accessibilityLarge: 1.353,
  .accessibilityExtraLarge: 1.353,
  .accessibilityExtraExtraLarge: 1.353,
  .accessibilityExtraExtraExtraLarge: 1.353,
]

이 값은 Claude Code가 초기 구현 과정에서 제안했다. 당시 설명으로는 iOS 텍스트 크기 단계에 대응하는 값이었지만, 작업자가 Apple 공식 자료에서 각 숫자의 근거를 직접 검증하지는 못했다.

Apple은 일반 크기와 별도로 접근성 관련 콘텐츠 크기 범주를 제공하지만, 공식 문서에서 이 프로젝트에 사용한 숫자 표 자체를 확인하지는 못했다. CAG나 Apple이 요구하는 공식 기준이라고 표현할 수 없다.

접근성 전용 크기까지 그대로 확대했을 때 기존 UI가 크게 무너질 가능성이 있었고, 다른 기능 개발과 병행하는 상황에서 전 화면을 한 번에 다시 설계하기도 어려웠다. 이번 작업에서는 1.353배를 임시 상한으로 정했다.

이는 접근성 준수를 보장하는 숫자가 아니었다.

우선 시스템 설정을 전혀 반영하지 않던 상태에서 일정 범위까지 반영하고, 이후 테스트를 통해 확대 범위를 넓히기 위한 임시 안전선이었다.

접근성 사용자가 더 큰 크기를 선택해도 앱에서는 1.353배까지만 적용된다는 명확한 한계가 남았다.

Claude Code Agent를 도입한 이유

웨이티플러스는 이전까지 AI 코딩 도구를 적극적으로 사용하지 않았다. 최근 개발 리소스가 부족해지면서 Claude Code Agent를 도입하기 시작했다.

배리어프리는 처음 다루는 분야였다. 개념을 익히고 적용 범위를 정해야 했으며, 결정한 패턴을 99개 파일에 반복해서 적용해야 했다. 이런 작업은 에이전트의 도움을 받을 가치가 있다고 판단했다.

역할은 다음과 같이 나눴다.

작업 진행 방식
접근성 개념 학습 직접
적용 범위와 작업 순서 공동
공용 컴포넌트 ARIA 구현 Claude Code 중심
반복적인 화면 수정 Claude Code 중심
Swift Dynamic Type 플러그인 Claude Code 중심
CSS 레이아웃 보정 공동
코드 리뷰와 결함 탐색 공동
실기기 테스트 직접
최종 스펙과 적용 여부 판단 직접

Claude Code는 반복 작업 속도를 크게 줄였다. 공용 컴포넌트에서 정한 패턴을 여러 화면에 적용하고, Swift와 TypeScript 사이의 브리지 코드를 작성하는 데도 도움을 줬다.

하지만 결과를 그대로 병합할 수는 없었다.

특히 접근성은 코드에 aria-* 속성이 존재하는지만 확인해서는 부족했다. 역할 사이의 관계가 맞는지, 실제 스크린 리더에서 어떻게 읽히는지, 기존 라우터와 UI 구조에서 동작하는지를 다시 봐야 했다.

그룹 역할은 있었지만 자식 역할이 없었다

결제 수단 선택에는 다음과 같은 구조가 만들어졌다.

<div
  role="radiogroup"
  aria-label="결제 수단 선택"
>
  <RegistrationCardSelector />
  <PaycoSelector />
</div>

부모에는 radiogroup이 있었지만 내부 선택지는 일반 <button>이었다. 스크린 리더 입장에서는 라디오 그룹이라고 선언된 영역 안에 실제 라디오 항목이 없는 구조였다.

리뷰 과정에서 자식 버튼에 역할과 상태를 추가했다.

<button
  type="button"
  role="radio"
  aria-checked={isSelected}
  onClick={onSelect}
>

카테고리 영역에서도 부모에 tablist를 지정했지만 자식에 tab 역할이 빠진 비슷한 문제가 발견됐다.

이 문제는 ARIA를 많이 추가하는 것과 올바른 접근성 구조를 만드는 것이 다르다는 사실을 보여줬다.

W3C는 잘못된 ARIA보다 ARIA가 없는 편이 낫다고 경고한다. 보이는 UI와 다른 역할을 전달하면 스크린 리더 사용자는 화면을 보지 못한 상태에서 잘못된 조작 방법을 안내받기 때문이다. 만들었지만 Xcode는 알지 못했다

Claude Code가 DynamicTypePlugin.swift를 만들고 MainViewController에서 등록했지만, 처음에는 Xcode 프로젝트 설정에 파일이 포함되지 않은 상태였다.

웨이티플러스의 Xcode 프로젝트는 폴더에 Swift 파일을 추가하는 것만으로 컴파일 대상에 자동 포함되는 구조가 아니었다. project.pbxproj의 파일 참조와 빌드 단계에도 직접 등록해야 했다.

최종적으로 다음 영역에 플러그인을 추가했다.

  • PBXFileReference
  • PBXBuildFile
  • App 프로젝트 그룹
  • PBXSourcesBuildPhase

TypeScript 검사와 Vite 빌드가 성공하더라도 iOS 네이티브 프로젝트가 정상적으로 빌드된다는 뜻은 아니었다. WebView와 네이티브 코드가 함께 존재하는 Capacitor 앱에서는 웹 빌드와 Xcode 빌드를 각각 확인해야 했다.

메인 스레드라는 가정도 다시 확인했다

초기 Swift 구현은 UIApplication.shared.preferredContentSizeCategory에 바로 접근했다.

private func currentFontScale() -> Double {
  let category =
    UIApplication.shared.preferredContentSizeCategory

  return scaleMap[category] ?? 1.0
}

리뷰 과정에서 Capacitor 플러그인 메서드가 항상 메인 스레드에서 실행된다고 가정할 수 없다는 점을 다시 확인했다. UIKit 관련 값은 메인 스레드에서 읽도록 수정했다.

private func currentFontScale() -> Double {
  if Thread.isMainThread {
    return contentSizeCategoryScale()
  }

  return DispatchQueue.main.sync {
    contentSizeCategoryScale()
  }
}

Capacitor 브리지의 소스 확인은 Claude Code에 위임했다. 따라서 이 경험을 “직접 프레임워크 소스를 분석했다”고 표현하는 것은 맞지 않는다.

정확한 설명은 다음에 가까웠다.

AI가 생성한 코드가 암묵적으로 가정한 실행 환경을 리뷰 과정에서 다시 검증했고, 실제 프레임워크 동작에 맞게 수정했다.

현재 플러그인에도 메인 스레드 여부를 확인하는 코드가 남아 있다.

정석대로 만든 skip-link를 제거했다

skip-link는 키보드 사용자가 반복되는 헤더와 내비게이션을 건너뛰고 본문으로 이동할 수 있게 하는 링크다.

처음에는 일반적인 예제와 비슷하게 구현했다.

<a href="#main-content">
  본문 바로가기
</a>

<main
  id="main-content"
  tabIndex={-1}
>
  {children}
</main>

하지만 웨이티플러스에 적용해 실기기와 개발 환경에서 테스트하자 헤더가 정상적으로 동작하지 않거나 UI가 깨지는 문제가 발생했다.

웨이티플러스는 createHashRouter를 사용한다. 일반 웹사이트에서 본문 위치를 나타내는 #main-content와 라우팅에 사용하는 URL 해시가 같은 영역을 공유한다. 현재 프로젝트의 라우터가 실제로 createHashRouter를 사용하는 것은 코드에서도 확인할 수 있다.

이벤트의 기본 동작을 막고 직접 포커스를 옮기는 방식도 테스트했지만, 현재 프로젝트 구조에 억지로 넣을 만큼 효과가 크지 않다고 판단했다. 웨이티플러스의 헤더와 하단 내비게이션은 아직 길지 않았고, skip-link 때문에 추가되는 예외 처리가 더 많았다.

결국 실제 화면에서는 제거했다. 현재 PageView에는 <main> 랜드마크만 남아 있다.

관련 CSS는 향후 다시 검토할 수 있도록 남겨뒀다.

이 경험에서 얻은 교훈은 “skip-link가 Capacitor 앱에 필요 없다”가 아니었다.

접근성 문서에서 권장하는 패턴도 프로젝트의 라우팅 구조와 입력 환경에 따라 그대로 적용되지 않을 수 있었다.

접근성 라이브러리를 사용했다면 달랐을까

이번 작업에서는 접근성 라이브러리를 도입하지 않았다. 그렇다고 Capacitor 앱에서 웹 접근성 라이브러리가 의미 없다는 뜻은 아니다.

웨이티플러스는 사용자에게는 네이티브 앱이지만, 대부분의 UI는 WebView 안에서 React와 HTML로 렌더링된다. Capacitor 역시 자신을 웹 기술로 iOS와 Android 네이티브 앱을 만드는 런타임으로 설명한다. 웹 표준에 가까운 UI를 유지하면서 필요한 경우 Swift와 Android 네이티브 API에 접근하는 구조다. 앱의 접근성을 볼 때는 “웹이냐 앱이냐”로 단순하게 나누기보다 세 계층으로 구분하는 편이 맞았다.

계층 웨이티플러스에서 다루는 영역 해결 방식
WebView UI 버튼, 체크박스, 탭, 모달, 입력 필드 시맨틱 HTML, ARIA, React 접근성 라이브러리
Capacitor 브리지 네이티브 값 조회와 이벤트 전달 Capacitor 플러그인
운영체제 설정 VoiceOver, TalkBack, Dynamic Type iOS·Android 네이티브 API와 실기기 테스트

React Aria, Radix UI, Headless UI 같은 라이브러리는 첫 번째 계층에 도움을 줄 수 있다.

React Aria

React Aria는 ARIA 역할과 상태뿐 아니라 키보드·포인터 이벤트, 포커스 관리, 스크린 리더 안내까지 함께 제공한다. 공식 문서에는 iOS VoiceOver와 Android TalkBack 테스트도 명시돼 있다. 자인을 유지하면서 상호작용 로직만 가져오려는 경우 검토할 가치가 있다. Hooks나 headless 형태로 사용할 수 있어 CSS를 완전히 교체하지 않아도 된다.

반면 추상화 수준이 낮은 기능까지 세밀하게 제공하기 때문에 학습해야 할 API가 많다. 이미 자체 상태 관리와 이벤트 패턴이 있는 컴포넌트에 적용하면 기존 코드와 React Aria의 상태 책임을 어떻게 나눌지 먼저 정해야 한다.

또한 공식 테스트 환경에 iOS VoiceOver와 Android TalkBack이 포함돼 있더라도, 웨이티플러스가 실행되는 환경은 Safari나 Android Chrome 자체가 아니라 Capacitor의 WKWebView와 Android WebView다. 브라우저 테스트 결과만으로 앱에서의 동작을 보장할 수는 없으므로 실기기 검증은 여전히 필요하다.

Radix UI

Radix Primitives는 Dialog, Tabs, Radio Group처럼 복잡한 UI 패턴을 컴포넌트 단위로 제공한다. 공식 문서에 따르면 ARIA 역할과 상태, 포커스 관리, 키보드 탐색 같은 구현을 내부에서 처리한다. 프로젝트라면 직접 role, 포커스 트랩, 방향키 이동을 각각 구현하는 것보다 안전할 수 있다.

웨이티플러스에서는 이미 GlobalModal, BottomSheet, CategoryTab 같은 공용 컴포넌트가 만들어져 있었다. Radix 컴포넌트를 도입하면 접근성 로직만 추가하는 것이 아니라 DOM 구조와 Portal, 애니메이션, 열림 상태 관리까지 맞춰야 할 가능성이 크다.

신규 컴포넌트에는 유리하지만, 기존 디자인 시스템을 부분적으로 교체할 때는 마이그레이션 비용을 먼저 계산해야 한다.

Headless UI

Headless UI는 스타일이 없는 접근성 컴포넌트를 제공하며 React와 Vue를 지원한다. 공식적으로 Dialog, Popover, Tabs, Checkbox, Radio Group 같은 컴포넌트를 제공한다. 중심으로 화면을 구성하고 있거나, 제공되는 컴포넌트 구조가 현재 UI와 잘 맞는다면 적용이 비교적 단순할 수 있다.

반대로 제공되는 컴포넌트의 구조와 웨이티플러스의 기존 CSS Modules 기반 구조가 맞지 않는다면 래퍼 컴포넌트와 스타일을 다시 작성해야 한다. 원하는 접근성 패턴이 라이브러리의 컴포넌트 범위에 포함되는지도 확인해야 한다.

Capacitor Screen Reader 플러그인

Capacitor에는 공식 Screen Reader 플러그인도 있다. 이 플러그인은 VoiceOver나 TalkBack의 활성화 상태를 확인하거나 특정 문장을 읽도록 요청할 수 있다. 튼에 이름을 붙이거나 체크박스 역할을 자동으로 만들어 주는 것은 아니다.

const { value } = await ScreenReader.isEnabled();
await ScreenReader.speak({ value: '주문이 완료되었습니다.' });

스크린 리더가 켜져 있는지 감지해 별도 UI를 제공하는 데 사용할 수는 있지만, 모든 요소의 의미와 상태를 직접 읽어 주는 방식으로 접근하면 운영체제의 탐색 흐름과 충돌할 수 있다.

웨이티플러스가 이번에 필요로 했던 것은 별도 음성 출력 기능보다, 기존 React UI가 VoiceOver와 TalkBack에 올바른 의미로 노출되는 것이었다. 그래서 Screen Reader 플러그인을 중심으로 구현하지 않았다.

네이티브 앱 접근성 라이브러리는 왜 사용하지 않았나

iOS의 UIKit이나 Android의 Jetpack Compose에는 각 플랫폼에 맞는 접근성 API와 컴포넌트가 있다. 하지만 웨이티플러스 UI는 UIKit이나 Compose로 만들어진 것이 아니다.

네이티브 접근성 컴포넌트를 사용하려면 특정 화면을 Swift나 Kotlin으로 다시 만들거나, 웹 UI와 네이티브 UI를 혼합해야 한다. 단순한 라이브러리 추가가 아니라 애플리케이션 구조 변경에 가까워진다.

이번 프로젝트에서 현실적인 선택지는 다음 두 가지였다.

  1. WebView UI에는 시맨틱 HTML, ARIA, React 기반 라이브러리를 사용한다.
  2. Dynamic Type처럼 WebView만으로 해결되지 않는 항목은 Capacitor 플러그인으로 연결한다.

향후 공용 컴포넌트를 대규모로 정리할 기회가 생긴다면, 직접 구현을 계속하기보다 React Aria나 Radix UI를 기반으로 다시 설계하는 방안을 검토할 수 있다. 특히 다음 기능은 라이브러리가 제공하는 검증된 패턴을 사용하는 편이 유리하다.

  • 모달의 초기 포커스와 포커스 복원
  • 모달 내부 포커스 제한
  • 탭과 라디오 그룹의 키보드 이동
  • 중첩 오버레이의 포커스 순서
  • 스크린 리더 상태 안내

다만 라이브러리를 사용해도 iOS Dynamic Type 연동, WebView별 차이, 앱 생명주기, 네이티브 플러그인 접근성까지 해결되는 것은 아니다.

1.6.0에 배포했지만 아직 완료라고 부르지 않는다

접근성 작업은 2026년 7월 웨이티플러스 1.6.0에 포함돼 iOS와 Android에 정식 배포됐다.

배포했다고 작업이 끝난 것은 아니다.

현재도 iOS 26과 Android 15 기기에서 내부 테스트를 진행하고 있다. 시스템 글자 크기가 다른 환경에서 일부 공용 컴포넌트의 레이아웃을 계속 보정하고 있으며, VoiceOver와 TalkBack을 이용한 화면별 탐색도 확인하고 있다.

일반 사용자가 기존 기능을 이용하는 데 문제가 있는 상태는 아니다. 하지만 다음 항목은 아직 완료되지 않았다.

  • VoiceOver와 TalkBack을 이용한 전체 화면 검증
  • 접근성 전용 텍스트 크기 전체 지원
  • 장애인 사용자가 참여하는 사용성 테스트
  • 자동화된 접근성 회귀 테스트
  • WCAG 전체 기준에 대한 준수 여부 검증
  • 접근성 라이브러리 도입 여부 결정

실제 장애인 사용자를 통한 테스트는 현재 기능과 UI가 더 안정된 뒤 진행할 예정이다.

접근성 작업의 효과를 보여주는 주문 완료율이나 이탈률 같은 정량 지표도 아직 없다. 사용자 피드백 역시 수집 전이다. 따라서 이번 작업을 성과 수치로 설명할 수는 없다.

현재 확인할 수 있는 결과는 조금 더 제한적이다.

웨이티플러스의 주요 주문 흐름에 이름·역할·상태 정보를 추가했고, iOS 시스템 글자 크기를 React UI에 전달하는 기반을 만들었다.

AI가 구현량을 줄여도 검증 책임은 남았다

이번 작업 전에는 ARIA 속성을 추가하면 스크린 리더 지원이 어느 정도 끝날 것이라고 생각하기 쉬웠다.

실제로는 역할 하나를 추가할 때마다 다른 질문이 따라왔다.

  • 부모와 자식의 역할 관계가 맞는가?
  • 해당 역할에 기대되는 키보드 동작이 구현됐는가?
  • 상태 변화가 적절한 시점에 안내되는가?
  • 네이티브 설정이 WebView까지 전달되는가?
  • 글자가 커졌을 때 레이아웃이 이를 수용하는가?
  • 웹에서 알려진 패턴이 HashRouter 기반 앱에서도 동작하는가?

Claude Code Agent는 처음 접하는 분야의 조사와 반복 구현 속도를 높여줬다. 개발자가 한 달 동안 99개 파일을 모두 수작업으로 수정하는 것보다 효율적이었다.

하지만 에이전트는 프로젝트의 실제 기기를 사용할 수 없었다. 기존 컴포넌트가 왜 그 구조로 만들어졌는지, 특정 UI가 어떤 경로에서 사용되는지, 앱에서 어떤 문제가 발생하는지를 완전히 알 수도 없었다.

skip-link는 문서에서 권장되는 패턴이었지만 현재 앱 구조에서는 제거해야 했다. Dynamic Type 배율은 제안받았지만 공식 근거를 직접 확인하지 못했다. Swift 코드는 작성됐지만 Xcode 빌드 설정과 실행 스레드를 다시 검토해야 했다.

결국 AI가 대신한 것은 구현량의 일부였다.

어떤 요구사항을 받아들일지 정하고, 결과를 리뷰하고, 실제 기기에서 검증하는 책임은 개발자에게 남았다.

아직 첫 번째 단계다

웨이티플러스에 접근성을 적용한 이번 작업을 “배리어프리 시스템 도입 완료”라고 부르지는 않으려 한다.

현재 상태는 다음 표현에 더 가깝다.

주요 화면의 1차 접근성 보강과, ARIA 의미 정보 및 iOS 글자 확대 기반 마련.

앞으로는 장애인 사용자가 실제 주문 흐름을 사용해 보는 테스트가 필요하다. 자동 검사 도구도 도입해야 하고, 새 컴포넌트를 만들 때 확인할 접근성 체크리스트도 정리해야 한다.

접근성 라이브러리를 사용할지, 기존 컴포넌트를 계속 개선할지도 결정해야 한다. 전체 UI를 한 번에 교체하기는 어렵지만, 모달이나 탭처럼 직접 구현하기 까다로운 컴포넌트부터 점진적으로 전환하는 방법은 검토할 수 있다.

이번 작업에서 가장 크게 바뀐 것은 코드보다 개발 과정이었다.

이전에는 화면이 디자인대로 표시되고 터치가 동작하면 기능이 완성됐다고 판단했다. 이제는 같은 화면을 VoiceOver와 TalkBack이 어떻게 이해하는지, 사용자가 글자를 키웠을 때도 주문을 이어갈 수 있는지까지 확인해야 한다.

접근성은 별도 모드를 추가하는 작업이 아니었다.

기존 기능이 누구에게는 처음부터 존재하지 않았다는 사실을 확인하고, 그 기능을 사용할 수 있도록 기본 동작을 다시 정의하는 작업이었다.

65 ❤️ 0

댓글

댓글을 작성하려면 GitHub 로그인이 필요합니다.

첫 댓글을 남겨보세요.

AI 챗봇
...