웨이티플러스에 스크린 리더 접근성 이식하기 — ARIA부터 iOS Dynamic Type까지

웹 접근성 ARIA Capacitor iOS React TypeScript

배리어프리란 무엇인가

배리어프리(Barrier-Free)는 원래 건축 용어다. 휠체어가 다닐 수 있게 경사로를 놓고, 문턱을 없애는 것처럼 물리적 장벽을 없앤다는 뜻이었다. 웹/앱 개발에서는 이게 "장애가 있는 사용자도 서비스를 동등하게 쓸 수 있게 만드는 것"으로 확장됐고, 실무에서는 주로 웹 접근성(Web Accessibility) 이라는 이름으로 다룬다.

핵심은 하나다. 스크린 리더(VoiceOver, TalkBack)는 화면을 보지 않는다. 브라우저가 만드는 접근성 트리(accessibility tree) 라는 별도의 데이터 구조를 읽는다. <div onClick={...}>글자</div>는 사람 눈에는 버튼처럼 보여도, 접근성 트리에서는 그냥 "텍스트가 든 상자"다. 스크린 리더는 그걸 "글자"라고만 읽지, 눌러서 뭔가 할 수 있는 요소라고는 알려주지 않는다.

ARIA(role, aria-* 속성들)는 이 접근성 트리에 정보를 추가로 써넣는 방법이다. 화면에 보이는 것과 완전히 별개로 "이건 체크박스고, 지금 체크되어 있다", "이건 지금 선택된 탭이다" 같은 정보를 스크린 리더에 직접 전달한다. CSS는 눈으로 보는 사람을 위한 것이고, ARIA는 스크린 리더로 듣는 사람을 위한 것 — 이 구분이 이번 작업 전체를 관통하는 원칙이다.

왜 필요한지

웨이티플러스(Waitee+)는 대학교 캠퍼스 내 식당/카페를 조회하고 주문하는 React 19 + Capacitor 하이브리드 앱이다. 이번 작업을 시작하기 전까지, 이 앱은 접근성을 고려하지 않고 만들어진 상태였다. VoiceOver를 켜고 앱을 직접 써보니:

눈을 감고 앱을 쓴다고 생각하면, 화면 대부분이 백지나 다름없었다. 접근성은 "있으면 좋은 기능"이 아니라, 특정 사용자군에게는 서비스 이용 가능 여부를 가르는 문제다. 그리고 실무적으로도 무시할 수 없는 이유가 있다 — WCAG(Web Content Accessibility Guidelines) 준수는 많은 서비스에서 법적/정책적 요구사항이고, 접근성 검사 도구(Lighthouse, axe 등)의 감사 항목이기도 하다.

이번 작업의 목표는 명확했다: 별도의 "접근성 모드" 토글 없이, 항상 켜져 있는 기본값으로 스크린 리더 지원과 iOS 텍스트 확대 대응을 붙이는 것.

어떻게 구현이 가능한지

웹 접근성을 구현하는 방법은 대략 세 갈래로 나뉜다.

1. 시맨틱 HTML을 쓴다. <div> 대신 <button>, <nav>, <main>, <header>를 쓰면 브라우저가 알아서 접근성 트리에 올바른 역할을 부여한다. 가장 저렴하고 확실한 방법이지만, 이미 디자인 시스템이 커스텀 UI로 굳어진 프로젝트에서는 전면 교체가 어렵다.

2. ARIA로 보강한다. 기존 커스텀 UI 구조(<div onClick>, <button> 안에 아이콘+텍스트 조합 등)를 유지한 채, rolearia-* 속성만 얹어서 "이게 뭐고 지금 상태가 어떤지"를 스크린 리더에 별도로 알려준다. 이번 프로젝트가 선택한 방식이다 — 디자인/애니메이션/이벤트 핸들러를 그대로 재사용할 수 있어서 리스크가 낮다.

3. 플랫폼 네이티브 API와 연동한다. 텍스트 크기 확대처럼 OS 레벨 설정(iOS Dynamic Type, Android 시스템 폰트 배율)은 웹 표준만으로는 닿지 않는다. Capacitor 같은 하이브리드 프레임워크에서는 네이티브 플러그인을 직접 만들어 값을 읽어와야 한다.

세 가지를 조합해서, 41개 작업으로 나눠 공용 컴포넌트 → iOS 네이티브 연동 → 핵심 주문/결제 플로우 → CSS 레이아웃 방어 → 나머지 전 화면 순서로 적용했다.

어떻게 구현했는지

커스텀 UI에 시맨틱만 얹기

체크박스와 토글 버튼은 디자인 시스템상 스타일링 자유도 때문에 이미 <button onClick> 구조로 되어 있었다. 여기에 role과 상태 속성만 추가했다.

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

role="checkbox"가 "이건 체크박스 역할을 한다"고 선언하고, aria-checked가 지금 체크 상태를 실시간으로 알려준다. 기존 onClickdata-checked 기반 CSS 분기는 하나도 안 바꿨다.

아이콘 전용 버튼(뒤로가기, 검색, 닫기 등) 21종에는 ariaLabel선택적 prop으로 두고 한국어 기본값을 채워 넣었다. 기존 호출부 코드를 한 줄도 안 고쳐도 즉시 접근성이 생기고, 문맥이 필요한 곳(장바구니 담긴 개수 등)만 오버라이드하면 된다.

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

실시간 상태를 소리로 전달하기

수량 조절, 토스트 알림, 로딩 상태처럼 화면이 계속 바뀌는 UI는 aria-live로 처리했다.

// Toast.tsx
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(사용자가 하던 걸 마저 끝내고 여유 있을 때 읽음), 에러는 role="alert" + assertive(즉시 끊고 읽음)로 나눴다. aria-live가 붙은 컨테이너는 토스트가 떠 있든 아니든 항상 DOM에 존재한다는 게 중요하다 — 조건부로 마운트/언마운트되는 요소는 라이브 리전으로 등록되는 타이밍을 놓쳐서 안 읽히는 경우가 있다.

iOS 텍스트 크기 설정 반영하기

Android WebView는 시스템 폰트 배율을 자동으로 반영하지만, iOS Capacitor WebView는 그렇지 않다. Swift로 네이티브 플러그인을 만들어 값을 읽어왔다.

@objc(DynamicType)
public class DynamicTypePlugin: CAPPlugin, CAPBridgedPlugin {
  public let jsName = "DynamicType"
  public let pluginMethods: [CAPPluginMethod] = [
    CAPPluginMethod(name: "getFontScale", returnType: CAPPluginReturnPromise)
  ]

  public override func load() {
    observer = NotificationCenter.default.addObserver(
      forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main
    ) { [weak self] _ in
      self?.notifyListeners("fontScaleChanged", data: ["scale": self?.currentFontScale() ?? 1.0])
    }
  }
}
// dynamicType.ts
export async function initDynamicType(): Promise<void> {
  if (Capacitor.getPlatform() !== DeviceType.IOS) return; // Android는 시스템이 자동 반영

  const { scale } = await DynamicType.getFontScale();
  document.documentElement.style.fontSize = `${scale * 100}%`; // rem 기반 전체 텍스트 스케일

  await DynamicType.addListener('fontScaleChanged', ({ scale }) => {
    document.documentElement.style.fontSize = `${scale * 100}%`;
  });
}

<html>font-size를 조정하는 이유는 프로젝트 전체가 rem 단위를 쓰기 때문이다. 이 트릭 하나로 텍스트 크기가 앱 전체에 비례해서 커진다. 배율은 1.353배로 상한을 뒀다 — iOS 접근성 전용 크기까지 다 반영하면 기존 레이아웃이 감당을 못 해서, 표준 크기 범위(0.823x~1.353x)만 그대로 반영하고 그 이상은 1.353x로 고정했다.

텍스트가 커지는 걸 전제로, 고정 높이 컨테이너들도 CSS로 방어했다.

/* 수정 전 */
.container { height: var(--bottom-safe-navigation-height); }

/* 수정 후 */
.container {
  min-height: var(--bottom-safe-navigation-height);
  height: auto;
}

min-height + height: auto로 바꾸면 평소(1x 배율)에는 기존과 동일하게 렌더링되고, 텍스트가 커지는 상황에서만 최소값을 넘어 자연스럽게 늘어난다. 디자인 토큰(CSS 변수) 이름은 그대로 두고 적용 속성만 바꿔서, 디자인 시스템 쪽 결정과 접근성 대응 코드를 분리했다.

트러블슈팅

여기서부터는 코드 리뷰 단계에서 실제로 걸린 문제들이다. 전부 "겉보기엔 표준 패턴대로 짠 것 같은데 실제로는 반쪽짜리였던" 사례라, 어떻게 발견했는지까지 같이 적는다.

문제 1: 그룹 역할만 있고 자식 역할이 없던 라디오그룹

결제 수단 선택 화면은 이런 구조였다.

<div role="radiogroup" aria-label="결제 수단 선택">
  <RegistrationCardSelector ... />  {/* 그냥 <button>, role="radio" 없음 */}
  <PaycoSelector ... />              {/* 그냥 <button>, role="radio" 없음 */}
</div>

role="radiogroup"은 붙어 있는데, 안쪽 컴포넌트들은 평범한 <button>이었다. 스크린 리더는 "라디오 그룹, 결제 수단 선택"이라고 그룹은 읽어주는데, 그 안에 정작 라디오 버튼이 하나도 없다고 인식했다. 그룹만 있고 내용물이 텅 빈 구조. 카테고리 탭에도 동일한 패턴의 버그가 있었다 — role="tablist"는 있는데 자식에 role="tab"이 없어서 aria-selected가 무의미하게 붙어 있었다.

문제 2: 접근성 기능이 사용자를 에러 페이지로 보내던 skip-link

키보드 사용자가 헤더/네비게이션을 건너뛰고 본문으로 바로 이동할 수 있게 skip-link를 추가했다.

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

문서에 나오는 정석 그대로였다. 그런데 이 프로젝트는 Capacitor WebView 호환을 위해 createHashRouter를 쓴다 — URL이 .../#/home처럼 # 뒤 전체가 라우트 경로다. <a href="#main-content">를 누르면 브라우저가 URL 해시를 #main-content로 바꾸려 하고, react-router는 이걸 새 경로 요청으로 해석한다. 실제로 react-router 소스를 열어서 확인했다.

// react-router 해시 히스토리 파싱 로직
let { pathname } = parsePath(window.location.hash.substring(1));
if (!pathname.startsWith("/")) pathname = "/" + pathname;
// "main-content" → "/main-content" 로 정규화됨

/main-content라는 라우트는 당연히 존재하지 않으니 매칭 실패 → 에러 페이지가 렌더링됐다. 접근성 기능을 쓰려던 사람이, 그 기능 때문에 에러 화면으로 튕겨나가는 상황이었다. 이 버그는 세 번의 리뷰 라운드를 통과한 뒤, 네 번째 리뷰에서야 발견됐다.

문제 3: 파일은 있는데 빌드에 안 잡히던 iOS 플러그인

DynamicTypePlugin.swift 파일을 만들고 MainViewController.swift에서 참조하게 했는데, Xcode 프로젝트 파일(project.pbxproj)에 이 파일이 전혀 등록되지 않은 상태였다. 이 프로젝트는 명시적 파일 참조 방식이라(자동 폴더 동기화 없음), 등록이 빠지면 컴파일 대상에서 제외돼 "Cannot find 'DynamicTypePlugin' in scope" 빌드 실패가 확정이었다.

문제 4: 메인 스레드가 아닌 곳에서 UIApplication에 접근하던 코드

private func currentFontScale() -> Double {
  let category = UIApplication.shared.preferredContentSizeCategory // 메인 스레드 전용 API
  ...
}

코드 주석에는 "Capacitor 플러그인 메서드는 일반적으로 메인 스레드에서 호출됨"이라고 적혀 있었다. Capacitor iOS 브릿지 소스를 직접 열어보니 사실이 아니었다 — 플러그인 메서드는 전용 백그라운드 큐(DispatchQueue(label: "bridge"))에서 실행되고 있었다. UIApplication.shared는 메인 스레드에서만 접근해야 하는 API라, Main Thread Checker 위반(정의되지 않은 동작) 소지가 실재했다.

해결책

문제 1 (라디오그룹/탭): 자식 컴포넌트에 대응하는 role을 추가했다.

// RegistrationCardSelector.tsx / PaycoSelector.tsx
<button role="radio" aria-checked={isSelected} onClick={onSelect}>

// CategoryTab.tsx
<button role="tab" aria-selected={active} tabIndex={active ? -1 : 0}>

부모에 그룹 역할(radiogroup, tablist, list)을 선언했다면, 자식에도 반드시 대응하는 역할(radio, tab, listitem)을 세트로 챙겨야 한다는 게 이 버그의 교훈이다.

문제 2 (skip-link × 해시 라우터): 기본 앵커 이동을 막고 포커스만 프로그램적으로 옮겼다.

const handleSkipLinkClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
  event.preventDefault();
  document.getElementById('main-content')?.focus();
};

<a href="#main-content" onClick={handleSkipLinkClick}>본문 바로가기</a>
<main id="main-content" tabIndex={-1}>{children}</main>

preventDefault()로 브라우저가 URL 해시를 건드리는 걸 아예 막고, 코드에서 직접 .focus()로 포커스만 이동시킨다. tabIndex={-1}은 Tab 순서에는 안 끼지만 프로그램적 focus는 가능하게 만드는 표준 패턴이다.

문제 3 (Xcode 미등록): project.pbxproj에 기존 플러그인과 동일한 방식으로 PBXBuildFile/PBXFileReference/그룹/Sources 빌드 단계 4곳을 등록했다. plutil -lint로 구문 유효성을 확인했다.

문제 4 (스레딩): 호출 스레드를 확인해서 메인이 아니면 동기적으로 메인 큐에 전달하도록 바꿨다.

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

네 문제 모두 "표준 패턴대로 짰다"는 것이 "이 프로젝트에서 실제로 동작한다"는 보장이 아니라는 걸 보여준다. 특히 skip-link 버그와 스레딩 버그는 라이브러리/프레임워크의 실제 소스 코드를 직접 열어서 검증한 뒤에야 확신할 수 있었다.

개선사항

이번 범위에서 의도적으로 미룬 것들이 있다.

접근성은 한 번에 완벽하게 끝내는 종류의 작업이 아니라는 걸 이번에 다시 느꼈다. 중요한 건 "지금 무엇을 타협했고 왜 타협했는지"를 남겨두는 것 — 그래야 다음에 보완할 때 처음부터 다시 조사하지 않아도 된다.

13 ❤️ 0

댓글

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

첫 댓글을 남겨보세요.