Cllaude99Cllaude99

사용자의 접근성을 고려한 개발

·Cllaude99
접근성a11y시맨틱 태그ARIAESLint

사용자의 접근성을 고려한다는 건 무슨 의미일까?

평소 개발할 때 접근성이라고 하면 막연히 "시각 장애인을 위한 것"이라고만 생각하고 자세하게 알지는 못했어요. 그래서 접근성을 고려한다는 건 어떤 의미이고, 어떤 사람들을 위한 것일까? 에 대해 스스로 고민해봤고 아래와 같은 상황이 있을 수 있다는 것을 알게되었어요.

  • 키보드만 사용하는 사람 — 마우스가 고장났거나, 트랙패드 없이 작업하는 경우
  • 일시적으로 한 손만 쓸 수 있는 사람 — 팔을 다쳤거나, 한 손에 커피를 들고 있는 경우
  • 밝은 햇빛 아래에서 화면을 보는 사람 — 낮은 대비로 텍스트가 안 보이는 경우
  • 스크린 리더를 사용하는 사람 — 시각 장애, 저시력, 난독증 등

위의 상황들을 통해 알게된건,마치 엘리베이터가 휠체어 사용자만을 위한 것이 아니듯, 접근성도 특정 누군가만을 위한 것이 아니라는 거였어요. 오늘은 마우스로 편하게 쓰더라도 내일 손목을 다칠 수 있고, 밝은 햇빛 아래에서 화면을 봐야 할 수도 있고, 나이가 들며 시력이 나빠질 수도 있으니까요. "내가 만든 UI를 대체 누가 쓰는 거지?" 라고 스스로에게 물었을 때, 마우스를 능숙하게 다루는 사람만, 화면이 잘 보이는 사람만 쓴다는 보장은 어디에도 없는 것 처럼요.

이러한 생각을 바탕으로 접근성을 고려한 개발에 대해 다시 생각해봤는데, 접근성을 고려한 개발이란 특정 누군가를 배려하는 게 아니라, 미래의 나를 포함한 모든 사람을 위한 거라는 생각이 들더라고요.

최근에 철학책을 읽다가 알게된 실험이 하나 있는데요. 바로, 철학자 존 롤스의 "무지의 베일"이라는 사고실험이에요. 실험의 내용은 내가 사회에서 어떤 위치에 놓일지 전혀 모른다고 상상하면, 자연스럽게 가장 불리한 처지의 사람까지 고려한 시스템을 설계하게 된다는 내용이었어요.

저는 접근성을 고려한 개발도 이와 다르지 않다고 생각해요. 내가 어떤 상태에서 이 서비스를 쓰게 될지 모른다면, 어떤 상태에서든 쓸 수 있도록 만드는 게 맞는거죠.

그래서... 접근성을 개선해보겠다고 마음먹었는데, 막상 어디서부터 손대야 할지 막막했어요. 이것저것 찾아보다가 eslint-plugin-jsx-a11y라는 린트 플러그인을 알게 됐어요. 해당 플러그인은 잘못된 role, 빠진 label, aria-hidden 없는 장식 아이콘 같은 접근성 위반을 자동으로 잡아주는 도구인데, 사용법을 살펴보면서 시맨틱 태그나 ARIA 같은 개념을 자연스럽게 익힐 수 있었어요.

이번 글에서는 이 과정에서 배운 것들을 정리해보려고 해요.

접근성을 고려하지 않으면 어떤 일이 벌어질까?

  • 버튼처럼 보이지만 div로 만들어져 Tab 키로 도달할 수 없는 UI
  • 폼을 작성하려는데 label이 연결되지 않아 어떤 입력 필드인지 알 수 없는 상황
  • 이미지에 alt 텍스트가 없어서 스크린 리더가 "이미지"라고만 읽어주는 경우

이런 문제 대부분은 시맨틱 태그를 올바르게 사용하는 것만으로 해결할 수 있어요.


시맨틱 태그 — 의미를 담은 HTML

divspan으로 모든 UI를 만들 수 있어요. 하지만 브라우저와 보조 기술(스크린 리더 등)은 HTML 태그의 의미를 통해 콘텐츠를 이해해요.

div로 만든 버튼 vs 진짜 button

<!-- 나쁜 예: div로 만든 버튼 -->
<div class="button" onclick="handleClick()">제출하기</div>

<!-- 좋은 예: button 태그 사용 -->
<button type="submit" onclick="handleClick()">제출하기</button>

이 둘은 화면에서는 똑같아 보일 수 있어요. 하지만 동작은 완전히 달라요.

기능div 버튼button 태그
Tab으로 포커스 이동불가능가능
Enter/Space로 클릭불가능가능
스크린 리더가 "버튼"으로 인식불가능가능
기본 포커스 스타일없음있음

div로 버튼을 만들면 tabindex, role, onKeyDown 등을 모두 수동으로 추가해야 해요. 하지만 button 태그를 쓰면 이 모든 게 기본으로 제공되죠.

실제로 확인하면 다음과 같아요.

div 버튼은 Tab으로 포커스가 건너뛰어지고, button 태그에만 파란 외곽선이 잡히는 모습

위와 같이 Tab 키로 이동하면 div 버튼은 포커스가 건너뛰어지지만, button 태그는 포커스가 잡히고 Enter/Space로 클릭할 수 있어요.

자세한 건, 키보드 내비게이션 체험 페이지에서 직접 차이를 느껴볼 수 있어요.

시맨틱 구조 태그 활용

페이지 레이아웃에도 같은 원리가 적용돼요. 실제 프로젝트의 시맨틱 태그 체험 페이지에서 사용한 코드를 비교해볼게요.

{
  /* 나쁜 예: div만 사용한 내비게이션 */
}
<div className="nav-list">
  <div className="nav-item"></div>
  <div className="nav-item">소개</div>
  <div className="nav-item">연락처</div>
</div>;

{
  /* 좋은 예: 시맨틱 태그 사용 */
}
<nav aria-label="예시 내비게이션">
  <ul className="nav-list">
    <li>
      <a href="#home"></a>
    </li>
    <li>
      <a href="#about">소개</a>
    </li>
    <li>
      <a href="#contact">연락처</a>
    </li>
  </ul>
</nav>;

div로 만든 내비게이션은 접근성 트리에서 generic role로 표시되지만, nav + ul + li + a 태그를 사용하면 navigation, list, link 등 의미 있는 role이 부여돼요. 또, 스크린 리더가 "내비게이션 랜드마크"로 페이지 구조를 알려주고, 사용자가 원하는 영역으로 바로 이동할 수도 있고요.

왼쪽은 generic 반복, 오른쪽은 navigation, link 등 의미 있는 스크린 리더 출력이 표시된 모습

시맨틱 태그 체험 페이지에서 직접 비교해볼 수 있어요.

form과 label 연결

폼에서도 labelinput의 연결이 중요해요.

<!-- 나쁜 예: label 없이 placeholder만 사용 -->
<input type="email" placeholder="이메일을 입력하세요" />

<!-- 좋은 예: label과 input 연결 -->
<label for="email">이메일</label>
<input id="email" type="email" placeholder="example@email.com" />

label이 없으면 스크린 리더가 입력 필드의 용도를 알려줄 수 없어요. 또, placeholder는 입력을 시작하면 사라지기 때문에 대안이 될 수 없고요. 게다가 스크린 리더에 따라 placeholder 텍스트를 읽어주는 방식이 제각각이라, 접근성 측면에서 일관된 경험을 보장하지 못해요.


ARIA — 시맨틱 태그만으로 부족할 때

위의 HTML 시맨틱 태그만으로 모든 접근성 정보를 전달할 수 없는 경우가 있는데요, 이러한 경우에는 ARIA(Accessible Rich Internet Applications) 속성을 사용할 수 있어요.

자주 쓰이는 ARIA 속성

role 은 요소의 역할을 명시하는 속성이에요.

<!-- 탭 인터페이스 -->
<div role="tablist">
  <button role="tab" aria-selected="true">탭 1</button>
  <button role="tab" aria-selected="false">탭 2</button>
</div>
<div role="tabpanel">탭 1의 내용</div>

aria-label 은 시각적 텍스트가 없는 요소에 이름을 부여하는 속성이에요.

<!-- 아이콘만 있는 버튼 -->
<button aria-label="메뉴 닫기">
  <svg><!-- X 아이콘 --></svg>
</button>

aria-hidden 는 장식용 요소를 보조 기술에서 숨기는 역할을 해요.

<!-- 장식용 아이콘은 스크린 리더가 읽지 않도록 -->
<button>
  <svg aria-hidden="true"><!-- 아이콘 --></svg>
  공유하기
</button>

실제 프로젝트에서 수정한 사례

저는 프로젝트에 eslint-plugin-jsx-a11y를 적용하면서 몇 가지 문제를 발견하고 수정했어요.

1. Tab 컴포넌트의 잘못된 role

// 수정 전: 잘못된 role
<button role="tab_button">탭 1</button>

// 수정 후: 올바른 ARIA role
<button role="tab" aria-selected={isActive}>탭 1</button>

기존에 작성한 tab_button은 ARIA 명세에 정의된 role이 아니었어요. 정의된 role이 아니기에, 브라우저의 접근성 트리에서 이 요소는 역할 없는 상태로 표시되고, 스크린 리더가 탭이라는 것을 인식하지 못하게 돼요. 따라서 정의되어 있는 올바른 role인 tab을 사용하고, aria-selected로 현재 선택 상태를 표현하도록 했어요.

실제로 DevTools 접근성 트리에서 확인하면 tab_button은 인식되지 않는 role이라 역할 없이 표시되고, tab은 올바른 탭 역할로 인식돼요.

아래의 화면 예시를 통해 확인할 수 있어요.

왼쪽은 "프로필, 버튼"만 표시, 오른쪽은 "프로필, 탭, 3개 중 1번째, 선택됨"이 표시된 모습

자세한건, ARIA role 체험 페이지에서 직접 비교해볼 수 있어요.

2. 아이콘 컴포넌트의 접근성 개선

// 수정 전: 접근성 정보 없음
const ShareIcon = () => (
  <svg viewBox="0 0 24 24">
    <path d="..." />
  </svg>
);

// 수정 후: 장식용 아이콘에 aria-hidden 추가
const ShareIcon = ({ ariaHidden = true, ...props }) => (
  <svg viewBox="0 0 24 24" aria-hidden={ariaHidden} {...props}>
    <path d="..." />
  </svg>
);

텍스트와 함께 사용되는 장식용 아이콘은 aria-hidden="true"로 숨겨야 스크린 리더가 중복으로 읽지 않아요.

aria-hidden이 없으면 스크린 리더가 "이미지, 즐겨찾기에 추가됨"처럼 아이콘을 불필요하게 읽지만, aria-hidden을 적용하면 "즐겨찾기에 추가됨"만 깔끔하게 읽어줘요.

아래의 화면 예시를 통해 확인할 수 있어요.

왼쪽은 말풍선에 "이미지, 주문 완료" 등 아이콘 정보가 포함되고, 오른쪽은 "주문 완료"만 깔끔하게 표시된 모습

자세한건, aria-hidden 체험 페이지에서 직접 비교해볼 수 있어요.

"No ARIA is better than bad ARIA"

ARIA를 사용할 때 주의할 점이 있어요.

ARIA를 잘못 사용하면 접근성을 오히려 해칠 수 있어요.

<!-- 나쁜 예: 불필요하거나 잘못된 ARIA -->
<button role="button">클릭</button>
<!-- button에 role="button"은 중복 -->
<div role="button" aria-label="클릭">클릭</div>
<!-- button 태그를 쓰는 게 맞음 -->

<!-- 좋은 예: 필요한 경우에만 ARIA 사용 -->
<button>클릭</button>
<button aria-label="검색">
  <svg aria-hidden="true"><!-- 돋보기 아이콘 --></svg>
</button>

정리하자면, 원칙은 간단해요.

  1. 시맨틱 HTML로 해결할 수 있으면 ARIA를 쓰지 말아야 해요.
  2. ARIA를 쓴다면 올바른 값을 사용해야 해요.
  3. 불확실하면 쓰지 않는 게 나아요.

자동으로 접근성 검사하기 — eslint-plugin-jsx-a11y

하지만 위와 같은 접근성 규칙 이외에도 많은 규칙들이 있고, 이러한 모든 접근성 규칙을 일일이 외우기는 어려워요. 그래서 코드를 작성할 때 자동으로 검사해주는 도구가 필요한데, eslint-plugin-jsx-a11y가 바로 그 역할을 해줘요.

프로젝트에 적용하기

pnpm add -D eslint-plugin-jsx-a11y

ESLint 설정에 플러그인을 추가하면 돼요.

// .eslintrc.js
module.exports = {
  extends: ['plugin:jsx-a11y/recommended'],
  plugins: ['jsx-a11y'],
  settings: {
    'jsx-a11y': {
      components: {
        Button: 'button',
        InputField: 'input',
        InputLabel: 'label',
      },
    },
  },
  rules: {
    // recommended 기본값(error)을 warn으로 완화 (점진적 도입)
    'jsx-a11y/click-events-have-key-events': 'warn',
    'jsx-a11y/no-static-element-interactions': 'warn',
    'jsx-a11y/no-noninteractive-element-interactions': 'warn',
    // recommended에 미포함, 수동 활성화
    'jsx-a11y/control-has-associated-label': 'warn',
  },
};

extendsplugin:jsx-a11y/recommended를 추가하면 기본 접근성 규칙이 활성화돼요. 저는 settings에서 커스텀 컴포넌트를 HTML 태그에 매핑하고, rules에서 점진적 도입을 위해 일부 규칙을 warn으로 완화했어요.

점진적 도입 전략

처음부터 모든 규칙을 error로 설정하면 기존 코드에서 대량의 에러가 발생할 수 있어요. 그래서 저는 점진적으로 도입했어요.

  1. 1단계: recommended 규칙을 warn으로 시작
  2. 2단계: 주요 위반 사항 수정 (잘못된 role, 누락된 alt 텍스트 등)
  3. 3단계: 안정화된 후 error로 격상

어떤 위반을 잡아주는지

실제로 프로젝트에서 잡힌 대표적인 위반 사항들이에요.

img-redundant-alt — 이미지 alt에 "이미지", "사진" 등 중복 단어 사용

// 위반
<img alt="로고 이미지" src="/logo.png" />
// 수정
<img alt="Cllaude99 Labs 로고" src="/logo.png" />

스크린 리더는 이미 <img> 태그를 "이미지"로 안내하기 때문에, alt에 "이미지"를 넣으면 "이미지, 로고 이미지"처럼 중복으로 읽혀요.

no-noninteractive-element-to-interactive-role — 비대화형 요소에 대화형 role 부여

// 위반
<div role="button" onClick={handleClick}>클릭</div>
// 수정
<button onClick={handleClick}>클릭</button>

divrole="button"을 추가해도 키보드 이벤트나 포커스는 자동으로 생기지 않아요. 따라서 시맨틱 태그를 쓰는 게 근본적인 해결 방법이에요.

label-has-associated-control — label이 입력 요소와 연결되지 않음

// 위반
<label>이메일</label>
<input type="email" />
// 수정
<label htmlFor="email">이메일</label>
<input id="email" type="email" />

labelinput이 연결되지 않으면 label을 클릭해도 input에 포커스가 안 가고, 스크린 리더가 어떤 필드인지 안내하지 못해요.

커스텀 컴포넌트 매핑

UI 라이브러리의 커스텀 컴포넌트를 사용할 때는 ESLint가 해당 컴포넌트의 역할을 모를 수 있어요. 따라서 위 설정에서 settings['jsx-a11y'].components 부분처럼 적용을 해야해요.

// .eslintrc.js의 settings 안에 추가
settings: {
  'jsx-a11y': {
    components: {
      Button: 'button',      // <Button>을 <button>으로 인식
      InputField: 'input',   // <InputField>를 <input>으로 인식
      InputLabel: 'label',   // <InputLabel>을 <label>로 인식
    },
  },
},

이렇게 매핑하면 <Button> 컴포넌트에도 button 태그와 동일한 접근성 규칙이 적용돼요. 예를 들어 <InputLabel><InputField>와 연결되지 않으면 label-has-associated-control 경고가 발생해요.


직접 확인해보기

접근성은 직접 체험해봐야 더 와닿아요. 위에서 다룬 내용들을 체험해볼 수 있는 페이지도 만들어뒀으니, 함께 활용해보세요.

이외에도 브라우저 도구로 직접 확인하는 방법들이 있어요.

브라우저 개발자 도구

Chrome DevTools > Elements 패널에서 요소를 선택한 뒤, 하단 탭 영역(Styles, Computed 등)의 오른쪽 끝 >> 버튼을 클릭하면 숨겨진 탭 중 Accessibility를 찾을 수 있어요.

  • Name: 스크린 리더가 읽어주는 이름
  • Role: 요소의 역할 (button, link, tab 등)
  • State: 현재 상태 (selected, expanded 등)

Chrome Lighthouse

DevTools > Lighthouse 탭에서 Accessibility 카테고리를 선택하면 자동으로 접근성 점수를 측정해줘요. 점수와 함께 구체적인 개선 사항도 알려주기 때문에 빠르게 문제를 파악할 수 있어요.


마무리

이번 글에서는 접근성을 고려한 개발에 대해 다루어봤어요. 이 과정에서 접근성을 고려한 개발은 특정 누군가를 위한 배려가 아니라, 미래의 나를 포함한 모든 사람을 위한 거라는 생각도 가지게 됐어요. 시맨틱 태그를 올바르게 쓰면 SEO에도 긍정적인 영향을 주고, 결국 더 많은 사용자가 불편 없이 쓸 수 있는 UI로 이어지게 될 수 있는 것도 알게되었어요.

참고 자료