모든 사용자를 위한 접근 가능한 웹 컴포넌트 만들기
James Reed
Infrastructure Engineer · Leapcell

소개
오늘날 상호 연결된 디지털 환경에서 웹은 정보, 통신 및 상거래를 위한 필수 도구입니다. 그러나 장애가 있는 상당수 인구에게는 웹 탐색이 좌절스럽거나 불가능한 경험이 될 수 있습니다. 이것이 바로 웹 접근성의 역할로, 모든 사용자가 능력에 관계없이 디지털 콘텐츠를 사용할 수 있도록 하는 것입니다. 최신 프론트엔드 프레임워크의 부상과 재사용 가능한 UI 요소를 구축하기 위한 웹 컴포넌트의 채택 증가로 인해 이러한 컴포넌트가 본질적으로 접근 가능하도록 보장하는 것은 규제 요구 사항일 뿐만 아니라 도덕적 의무이기도 합니다. 웹 컴포넌트 개발 워크플로에 접근성 모범 사례를 선제적으로 내장함으로써, 우리는 장벽을 허물고 모든 사용자를 지원하는 보다 포괄적인 웹에 기여합니다. 이 기사는 WCAG 표준을 충족하는 웹 컴포넌트를 구축하기 위한 핵심 원칙과 실질적인 단계를 탐구하고, 잠재적으로 배타적인 디지털 공간을 모두를 위한 접근 가능한 공간으로 변화시킵니다.
핵심 개념
모범 사례를 자세히 살펴보기 전에 이 논의의 기초가 되는 주요 용어를 공통적으로 이해해 봅시다.
- 웹 컴포넌트: 개발자가 사용자 정의되고 재사용 가능하며 캡슐화된 HTML 태그를 만들 수 있도록 하는 일련의 W3C 표준입니다. 사용자 정의 요소, Shadow DOM, HTML 템플릿 및 ES 모듈로 구성됩니다.
- WCAG (웹 콘텐츠 접근성 가이드라인): 월드 와이드 웹 컨소시엄(W3C)에서 개발한 WCAG는 웹 접근성에 대한 널리 인정받는 국제 표준입니다. 웹 콘텐츠를 장애인이 더 쉽게 사용할 수 있도록 만들기 위한 포괄적인 권장 사항 세트를 제공하며, 웹 콘텐츠를 더 쉽게 인식하고, 작동하고, 이해하고, 강력하게 만들 수 있도록 다양한 권장 사항을 포함합니다.
- ARIA (접근 가능한 풍부한 인터넷 애플리케이션): UI 요소 및 보조 기술과의 상호 작용에 대한 추가 의미론적 정보를 제공하기 위해 HTML 요소에 추가할 수 있는 속성 집합입니다. ARIA는 기본 HTML 요소가 풍부한 UI 의미론을 전달하는 데 부족한 격차를 해소하는 데 도움이 됩니다.
- 보조 기술 (AT): 장애인이 컴퓨터를 사용하는 데 도움이 되는 소프트웨어 및 하드웨어입니다. 예로는 화면 읽기 프로그램, 점자 디스플레이, 음성 인식 소프트웨어 및 접근성 스위치가 있습니다.
접근 가능한 웹 컴포넌트를 위한 모범 사례
접근 가능한 웹 컴포넌트를 구축하려면 마크업부터 상호 작용까지 모든 측면을 고려하는 전체적인 접근 방식이 필요합니다.
1. Shadow DOM 내의 의미론적 HTML
Shadow DOM은 캡슐화를 제공하지만, 이를 통해 의미론적 HTML을 사용할 책임이 면제되는 것은 아닙니다. 화면 읽기 프로그램과 기타 보조 기술은 콘텐츠의 구조와 의미를 이해하기 위해 의미론적 마크업에 크게 의존합니다.
원칙: 항상 올바른 HTML 요소를 사용하십시오.
예: 버튼에 대한 일반 div
대신 button
요소를 사용하십시오. 목록의 경우 ul
또는 ol
을 사용하십시오.
<!-- 나쁜 예: 의미론적이지 않은 버튼 --> <div class="my-button" tabindex="0" role="button">Click Me</div> <!-- 좋은 예: 의미론적 버튼 --> <button class="my-button">Click Me</button>
탭 인터페이스와 같은 사용자 정의 컴포넌트를 구축할 때 탭과 패널에 대한 기본 구조가 의미론적 요소를 사용하도록 하십시오.
<!-- my-tabs.js --> class MyTabs extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style>/* styles here */</style> <div role="tablist"> <slot name="tab"></slot> </div> <div class="tab-panels"> <slot name="panel"></slot> </div> `; } } customElements.define('my-tabs', MyTabs); <!-- 사용법 --> <my-tabs> <button slot="tab" role="tab" aria-selected="true" tabindex="0">Tab 1</button> <button slot="tab" role="tab" aria-selected="false" tabindex="-1">Tab 2</button> <div slot="panel" role="tabpanel">Content for Tab 1</div> <div slot="panel" role="tabpanel" hidden>Content for Tab 2</div> </my-tabs>
2. ARIA 역할, 상태 및 속성 활용
의미론적 HTML이 기본이기는 하지만, 웹 컴포넌트는 종종 기본 HTML의 기능을 뛰어넘는 복잡한 UI 패턴을 구현합니다. ARIA는 이러한 복잡한 상호 작용 및 상태를 보조 기술에 전달하는 데 필요한 어휘를 제공합니다.
원칙: ARIA는 의미론적 HTML을 대체하는 것이 아니라 보완하기 위해 사용합니다. 기본 HTML이 컴포넌트의 역할이나 상태를 적절하게 설명할 수 없을 때만 ARIA를 사용합니다.
웹 컴포넌트에 대한 일반적인 ARIA 속성:
role
: 요소의 목적을 설명합니다(예:role="button"
,role="alert"
,role="navigation"
).aria-label
: 시각적 레이블이 없거나 불충분할 때 요소에 대한 텍스트 레이블을 제공합니다.aria-labelledby
: 현재 요소의 레이블 역할을 하는 요소의 ID를 참조합니다.aria-describedby
: 현재 요소를 설명하는 요소를 참조합니다.aria-expanded
: 접기/펼치기 가능한 요소가 현재 확장되었는지 또는 축소되었는지를 나타냅니다.aria-hidden
: 요소가 보조 기술에 보이는지 아니면 숨겨져 있는지를 나타냅니다.aria-current
: 관련 항목 집합 내의 현재 항목을 나타냅니다(예: 페이징의 경우).aria-live
: 동적으로 업데이트될 것으로 예상되는 영역을 나타내어 화면 읽기 프로그램에 변경 사항을 알립니다.
예: ARIA가 있는 사용자 정의 토글 버튼
<!-- my-toggle-button.js --> class MyToggleButton extends HTMLElement { static get observedAttributes() { return ['aria-pressed']; } constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> :host { display: inline-block; border: 1px solid #ccc; padding: 8px 12px; cursor: pointer; user-select: none; } :host([aria-pressed="true"]) { background-color: #e0f2f1; } </style> <slot></slot> `; this.addEventListener('click', this._handleClick); if (!this.hasAttribute('role')) { this.setAttribute('role', 'button'); } if (!this.hasAttribute('aria-pressed')) { this.setAttribute('aria-pressed', 'false'); } if (!this.hasAttribute('tabindex')) { this.setAttribute('tabindex', '0'); } } attributeChangedCallback(name, oldValue, newValue) { if (name === 'aria-pressed' && this.shadowRoot) { // 여기서 상태에 따라 내부 스타일이나 내용을 업데이트할 수 있습니다. } } _handleClick() { const isPressed = this.getAttribute('aria-pressed') === 'true'; this.setAttribute('aria-pressed', !isPressed); this.dispatchEvent(new CustomEvent('toggle', { detail: { pressed: !isPressed }, bubbles: true, composed: true })); } } customElements.define('my-toggle-button', MyToggleButton);
사용법:
<my-toggle-button aria-label="Mute Audio"> <span aria-hidden="true">🔊</span> Toggle Audio </my-toggle-button>
여기서 role="button"
은 버튼처럼 작동하게 하고, aria-pressed
는 토글 상태를 보조 기술에 전달합니다. aria-label
은 화면 읽기 프로그램 사용자에게 의미 있는 설명을 제공하고, 아이콘의 aria-hidden="true"
는 아이콘이 두 번 읽히는 것을 방지합니다.
3. 키보드 탐색
모든 대화형 컴포넌트는 키보드로 작동해야 합니다. 여기에는 요소 사이를 탭하고 상호 작용을 위해 화살표 키, Enter 및 Space를 사용하는 것이 포함됩니다.
원칙: 모든 대화형 요소가 포커스 가능하고 표준 키보드 입력에 응답하도록 하십시오.
tabindex
속성:tabindex="0"
: 요소는 순차 키보드 탐색에서 포커스 가능하며 JavaScript로 포커스할 수 있습니다.tabindex="-1"
: 요소는 JavaScript로 포커스 가능하지만 순차 키보드 탐색에는 포커스할 수 없습니다. 프로그래밍 방식으로만 포커스해야 하는 컴포넌트에 유용합니다.- 0보다 큰
tabindex
값은 자연스러운 탭 순서를 방해하므로 피하십시오.
- 일반적인 키보드 이벤트(
keydown
,keyup
) 처리: 캐러셀, 메뉴 또는 탭 패널과 같은 컴포넌트의 경우 ARIA 저작 관행 가이드(APG) 권장 사항에 따라 화살표 키, Home, End, Esc 등을 구현합니다.
예: 사용자 정의 체크박스에 대한 키보드 탐색
<!-- my-checkbox.js --> class MyCheckbox extends HTMLElement { static get observedAttributes() { return ['checked']; } constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> :host { display: inline-flex; align-items: center; cursor: pointer; } .checkbox-box { width: 16px; height: 16px; border: 1px solid #333; display: inline-block; margin-right: 8px; position: relative; background-color: white; transition: background-color 0.1s ease; } :host([checked]) .checkbox-box { background-color: #007bff; border-color: #007bff; } :host([checked]) .checkbox-box::after { content: '✔'; color: white; position: absolute; top: -2px; left: 2px; } /* 키보드 사용자를 위한 포커스 스타일 */ :host(:focus) .checkbox-box { border-color: #007bff; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } </style> <span class="checkbox-box"></span> <span class="label"> <slot></slot> </span> `; if (!this.hasAttribute('role')) this.setAttribute('role', 'checkbox'); if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0'); if (!this.hasAttribute('aria-checked')) this.setAttribute('aria-checked', 'false'); this.addEventListener('click', this._handleClick); this.addEventListener('keydown', this._handleKeydown); } get checked() { return this.hasAttribute('checked'); } set checked(val) { if (val) { this.setAttribute('checked', ''); this.setAttribute('aria-checked', 'true'); } else { this.removeAttribute('checked'); this.setAttribute('aria-checked', 'false'); } } _handleClick() { this.checked = !this.checked; this.dispatchEvent(new CustomEvent('change', { detail: { checked: this.checked }, bubbles: true, composed: true })); } _handleKeydown(event) { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); // 기본 공백/입력 동작 방지 this._handleClick(); } } attributeChangedCallback(name, oldValue, newValue) { if (name === 'checked' && this.isConnected) { // 필요한 경우 더 복잡한 논리를 여기에 추가할 수 있습니다. } } } customElements.define('my-checkbox', MyCheckbox);
사용법:
<my-checkbox>Agree to terms</my-checkbox>
여기서 tabindex="0"
은 컴포넌트를 포커스 가능하게 합니다. keydown
리스너는 Space와 Enter 키 모두 checked
상태를 토글하도록 보장하여 표준 체크박스 동작을 준수합니다. aria-checked
속성은 상태를 AT에 전달합니다.
4. 충분한 색상 대비 제공
시각적 콘텐츠 및 UI 컴포넌트는 저시력 또는 색맹 사용자가 인식할 수 있도록 최소 대비 비율을 가져야 합니다.
원칙: WCAG 2.x AA 대비 비율(일반 텍스트의 경우 4.5:1, 큰 텍스트 및 그래픽 개체의 경우 3:1)을 준수합니다.
- 컴포넌트의 기본값 및 다양한 상태(호버, 포커스, 활성)를 테스트하여 대비를 보장하십시오.
- 온라인 대비 검사기(예: WebAIM Contrast Checker) 또는 브라우저 개발자 도구를 사용합니다.
- 컴포넌트가 복잡한 스타일링을 포함하는 경우 고대비 모드에 대한 옵션을 제공하는 것을 고려하십시오.
5. 복합 컴포넌트 내에서 포커스 관리
모달 대화 상자, 드롭다운 또는 자동 완성 필드와 같은 복잡한 컴포넌트의 경우 적절한 포커스 관리가 중요합니다. 모달이 열려 있을 때 포커스는 모달 내에 갇히고 닫힐 때 트리거 요소로 복원되어야 합니다.
원칙: 복합 컴포넌트 내에서 사용자 포커스를 논리적이고 예측 가능하게 제어합니다.
예: 기본 모달 대화 상자 포커스 관리
// modal-dialog.js class ModalDialog extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> :host { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); justify-content: center; align-items: center; z-index: 1000; } :host([open]) { display: flex; } .modal-content { background-color: white; padding: 20px; border-radius: 8px; max-width: 500px; width: 90%; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); position: relative; } .close-button { position: absolute; top: 10px; right: 10px; border: none; background: none; font-size: 1.5em; cursor: pointer; } </style> <div role="dialog" aria-modal="true" aria-labelledby="dialog-title" class="modal-content"> <h2 id="dialog-title"><slot name="title">Modal Title</slot></h2> <slot></slot> <button class="close-button" aria-label="Close dialog">×</button> </div> `; this._closeButton = this.shadowRoot.querySelector('.close-button'); this._modalContent = this.shadowRoot.querySelector('.modal-content'); this._closeButton.addEventListener('click', this.close.bind(this)); this.addEventListener('keydown', this._handleKeydown.bind(this)); } static get observedAttributes() { return ['open']; } get open() { return this.hasAttribute('open'); } set open(val) { if (val) { this.setAttribute('open', ''); this._trapFocus(); } else { this.removeAttribute('open'); this._releaseFocus(); } } attributeChangedCallback(name, oldValue, newValue) { if (name === 'open' && oldValue !== newValue) { if (this.open) { this._previousActiveElement = document.activeElement; this.focus(); // 팝업 자체에 포커스 } else { this._previousActiveElement?.focus(); } } } connectedCallback() { // 열려 있으면 초기에 포커스할 수 있는 요소를 팝업에 포함해야 합니다. if (this.open) { this.focus(); } } close() { this.open = false; this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true })); } _handleKeydown(event) { if (event.key === 'Escape' && this.open) { this.close(); } } _trapFocus() { const focusableElements = this._modalContent.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; if (!firstFocusable) { // 내부에 포커스 가능한 요소가 없으면 팝업 내용 자체에 포커스 this._modalContent.setAttribute('tabindex', '0'); this._modalContent.focus(); return; } else { this._modalContent.removeAttribute('tabindex'); } // 초기 포커스 설정 firstFocusable.focus(); this.shadowRoot.addEventListener('keydown', (e) => { if (e.key === 'Tab') { if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstFocusable) { lastFocusable.focus(); e.preventDefault(); } } else { // Tab if (document.activeElement === lastFocusable) { firstFocusable.focus(); e.preventDefault(); } } } }); } _releaseFocus() { // 선택적으로 키다운 이벤트 리스너를 제거합니다 (한 번에 하나의 팝업만 열릴 수 있거나 문제가 발생하는 경우). // 단순화를 위해 여기서는 현재 리스너가 처리하도록 둡니다. } } customElements.define('modal-dialog', ModalDialog);
사용법:
<button id="open-modal-button">Open Modal</button> <modal-dialog id="my-modal"> <span slot="title">Important Notification</span> <p>This is the content of the modal dialog.</p> <button>Action</button> </modal-dialog> <script> const openButton = document.getElementById('open-modal-button'); const modal = document.getElementById('my-modal'); openButton.addEventListener('click', () => { modal.open = true; }); modal.addEventListener('close', () => { console.log('Modal closed'); }); </script>
여기서 aria-modal="true"
는 AT에 대화 상자 외부의 페이지 콘텐츠가 비활성 상태임을 알립니다. _trapFocus()
로 포커스를 관리하고 Esc 키로 모달을 닫도록 합니다. aria-labelledby
속성은 모달의 제목을 참조합니다.
6. 비텍스트 콘텐츠에 대한 텍스트 대안 제공
이미지, 아이콘 및 기타 비텍스트 콘텐츠에는 설명 텍스트 대안이 있어야 합니다.
원칙: 의미 있는 모든 비텍스트 콘텐츠에는 동등한 텍스트 설명이 필요합니다.
<img>
태그에 대한alt
속성(Shadow DOM 내에서도).- 내장 텍스트가 없는 SVG 아이콘 또는 사용자 정의 그래픽 요소의 경우
aria-label
또는aria-labelledby
. - 이미지가 단순히 장식용인 경우
alt=""
(빈 alt 텍스트) 또는aria-hidden="true"
를 사용합니다.
예: aria-label
이 있는 아이콘
<!-- 사용자 정의 컴포넌트의 Shadow DOM 내 --> <div class="icon-wrapper" aria-label="Search"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="11" cy="11" r="8"></circle> <line x1="21" y1="21" x2="16.65" y2="16.65"></line> </svg> </div>
7. 확대/축소 및 배율 지원
저시력 사용자는 브라우저 확대/축소 또는 화면 배율기에 의존할 수 있습니다. 확대될 때 컴포넌트가 중단되거나 사용할 수 없게 되면 안 됩니다.
원칙: 반응형 단위(예: rem
, em
, 백분율)로 설계하고 유연성이 필요한 곳에 고정 픽셀 크기를 피하십시오. 레이아웃이 자연스럽게 조정되도록 하십시오.
8. CustomEvent
를 사용하여 통신
웹 컴포넌트가 이벤트를 발생시킬 때, 이러한 이벤트가 Shadow DOM 경계를 넘어서 document
또는 다른 상위 요소의 표준 이벤트 리스너에 의해 감지될 수 있도록 bubbles: true
및 composed: true
와 함께 CustomEvent
를 사용합니다. 이렇게 하면 보조 기술 및 애플리케이션의 다른 부분에서 컴포넌트 상태 변경에 표준 방식으로 반응할 수 있습니다.
예:
// 사용자 정의 이벤트를 발생시키는 컴포넌트 내부 this.dispatchEvent(new CustomEvent('item-selected', { detail: { itemId: '123', selected: true }, bubbles: true, composed: true }));
9. 보조 기술 및 실제 사용자와 함께 테스트
자동화된 접근성 검사기는 좋은 시작점이지만, 전체 접근성 문제의 일부만 포착합니다. 화면 읽기 프로그램(NVDA, JAWS, VoiceOver) 및 장애가 있는 실제 사용자와의 수동 테스트는 필수적입니다.
원칙: 개발 워크플로에 접근성 테스트를 통합합니다.
- 자동화 도구: Lighthouse, AXE DevTools, tota11y.
- 수동 확인: WCAG 체크리스트를 사용합니다.
- 화면 읽기 프로그램 테스트: 화면 읽기 프로그램을 사용하여 컴포넌트를 탐색합니다. 무엇을 발표하는지 들어봅니다. 말이 됩니까? 모든 것을 작동시킬 수 있습니까?
- 키보드 전용 테스트: 마우스 없이 전체 컴포넌트를 사용할 수 있습니까?
결론
접근 가능한 웹 컴포넌트를 구축하는 것은 사용자 포함, 대상 고객 확대 및 모든 사람을 위한 향상된 사용 편의성 측면에서 이익을 가져다주는 투자입니다. 의미론적 HTML, 신중한 ARIA 적용, 꼼꼼한 키보드 포커스 관리, 적절한 대비 보장, 보조 기술을 통한 엄격한 테스트를 수용함으로써 개발자는 강력하고 효율적일 뿐만 아니라 본질적으로 포괄적인 재사용 가능한 UI 빌딩 블록을 만들 수 있습니다. 접근성은 기능이 아니라 웹 개발의 기본적인 측면이며, 웹이 진정으로 모두를 위한 것임을 보장합니다.