July 6, 2025

🧱 μ»€μŠ€ν…€ μ»΄ν¬λ„ŒνŠΈμ˜ ARIA 속성 λˆ„λ½: μ ‘κ·Όμ„±μ˜ λ²½ μŒ“κΈ°

HTML
UX
μ›Ήν‘œμ€€
SEO/μ ‘κ·Όμ„±

Summary

일반 HTML μš”μ†Œλ‘œ λ§Œλ“  μ»€μŠ€ν…€ μ»΄ν¬λ„ŒνŠΈ(νƒ­, λͺ¨λ‹¬ λ“±)에 WAI-ARIA role, state, propertyλ₯Ό λˆ„λ½ν•˜λ©΄ 보쑰 기술 μ‚¬μš©μžκ°€ λͺ©μ κ³Ό μƒνƒœλ₯Ό μ΄ν•΄ν•˜κΈ° μ–΄λ €μ›Œ 접근성을 μ €ν•΄ν•©λ‹ˆλ‹€. μ μ ˆν•œ ARIA 속성을 μΆ”κ°€ν•˜κ³  ν‚€λ³΄λ“œ 접근성을 ν™•λ³΄ν•˜μ—¬ λͺ¨λ“  μ‚¬μš©μžλ₯Ό μœ„ν•œ 포용적인 UIλ₯Ό ꡬ좕해야 ν•©λ‹ˆλ‹€.

Why Wrong?

일반적인 HTML μš”μ†Œ(div, span λ“±)둜 직접 λ§Œλ“  λ³΅μž‘ν•œ μΈν„°λž™ν‹°λΈŒ μ»΄ν¬λ„ŒνŠΈ(예: νƒ­, μ•„μ½”λ””μ–Έ, λͺ¨λ‹¬, μΊλŸ¬μ…€)λŠ” 본질적으둜 의미둠적인 정보λ₯Ό κ°€μ§€κ³  μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ‹¨μˆœνžˆ μ‹œκ°μ μœΌλ‘œ λ²„νŠΌμ΄λ‚˜ νƒ­μ²˜λŸΌ 보이더라도, 슀크린 리더와 같은 보쑰 κΈ°μˆ μ€ ν•΄λ‹Ή μš”μ†Œμ˜ λͺ©μ , ν˜„μž¬ μƒνƒœ(예: 선택됨, ν™•μž₯됨, 숨겨짐), 그리고 μ‚¬μš©μžμ™€μ˜ μƒν˜Έμž‘μš© 방식을 인지할 수 μ—†μŠ΅λ‹ˆλ‹€. μ΄λŠ” μ‹œκ° μž₯애인, μš΄λ™ μž₯애인 λ“± 보쑰 κΈ°μˆ μ„ μ‚¬μš©ν•˜λŠ” μ‚¬μš©μžλ“€μ΄ μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ μ΄ν•΄ν•˜κ³  효율적으둜 νƒμƒ‰ν•˜λ©° μƒν˜Έμž‘μš©ν•˜λŠ” 것을 λΆˆκ°€λŠ₯ν•˜κ²Œ λ§Œλ“­λ‹ˆλ‹€. 결과적으둜 μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ€ μ ‘κ·Όμ„± μΈ‘λ©΄μ—μ„œ μ‹¬κ°ν•œ 결함을 κ°€μ§€κ²Œ 되며, μ΄λŠ” μ›Ή ν‘œμ€€ μœ„λ°˜μ΄μž μ‚¬μš©μž κ²½ν—˜μ˜ μ €ν•˜λ‘œ μ΄μ–΄μ§‘λ‹ˆλ‹€.

예λ₯Ό λ“€μ–΄, div둜 λ§Œλ“  탭은 슀크린 λ¦¬λ”μ—κ²Œ λ‹¨μˆœν•œ ν…μŠ€νŠΈ λ©μ–΄λ¦¬λ‘œλ§Œ 인식될 뿐, μ‚¬μš©μžκ°€ νƒ­μž„μ„ μΈμ§€ν•˜κ³  선택할 수 μžˆλŠ” μš”μ†Œλ‘œ μ „λ‹¬λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ˜ν•œ, ν‚€λ³΄λ“œλ§ŒμœΌλ‘œ νƒ­ 간을 μ΄λ™ν•˜κ±°λ‚˜ νƒ­ νŒ¨λ„μ„ ν™œμ„±ν™”ν•˜λŠ” λ“±μ˜ λ™μž‘μ΄ λΆˆκ°€λŠ₯ν•΄μ§‘λ‹ˆλ‹€. μ‹œλ§¨ν‹± HTML μš”μ†Œκ°€ μ œκ³΅ν•˜λŠ” λ‚΄μž₯된 μ ‘κ·Όμ„± 이점을 μžƒκ³ , 보쑰 기술이 UI μš”μ†Œλ₯Ό μ˜¬λ°”λ₯΄κ²Œ ν•΄μ„ν•˜μ§€ λͺ»ν•˜κ²Œ λ§Œλ“œλŠ” μ‹¬κ°ν•œ λ¬Έμ œμž…λ‹ˆλ‹€.

How to Fix?

λͺ¨λ“  μ»€μŠ€ν…€ μΈν„°λž™ν‹°λΈŒ μ»΄ν¬λ„ŒνŠΈμ—λŠ” WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)의 μ μ ˆν•œ role, state, 그리고 propertyλ₯Ό μ μš©ν•˜μ—¬ 의미둠적인 정보λ₯Ό λͺ…μ‹œμ μœΌλ‘œ μ œκ³΅ν•΄μ•Ό ν•©λ‹ˆλ‹€. μ΄λŠ” 보쑰 기술이 μ»΄ν¬λ„ŒνŠΈμ˜ λͺ©μ κ³Ό μƒνƒœλ₯Ό μ •ν™•νžˆ ν•΄μ„ν•˜κ³  μ‚¬μš©μžμ—κ²Œ 전달할 수 μžˆλ„λ‘ λ•μŠ΅λ‹ˆλ‹€.

  1. μ μ ˆν•œ role λΆ€μ—¬: μ»΄ν¬λ„ŒνŠΈμ˜ μœ ν˜•(예: νƒ­ λͺ©λ‘μ€ role="tablist", 각 탭은 role="tab", νƒ­ νŒ¨λ„μ€ role="tabpanel")에 λ§žλŠ” ARIA role을 ν• λ‹Ήν•©λ‹ˆλ‹€.
  2. aria- 속성 ν™œμš©: μ»΄ν¬λ„ŒνŠΈμ˜ 동적인 μƒνƒœ(예: 선택됨 aria-selected, ν™•μž₯됨 aria-expanded, 숨겨짐 aria-hidden)λ‚˜ 관계(예: μ œμ–΄ν•˜λŠ” μš”μ†Œ aria-controls, λ ˆμ΄λΈ” aria-labelledby)λ₯Ό λ‚˜νƒ€λ‚΄λŠ” aria- 속성을 μ‚¬μš©ν•©λ‹ˆλ‹€. μ΄λŸ¬ν•œ 속성듀은 슀크린 리더가 μ‚¬μš©μžμ—κ²Œ μ •ν™•ν•œ 정보λ₯Ό μ „λ‹¬ν•˜λŠ” 데 ν•„μˆ˜μ μž…λ‹ˆλ‹€.
  3. ν‚€λ³΄λ“œ μ ‘κ·Όμ„± 확보: ARIA 속성과 ν•¨κ»˜ ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜(Tab, Enter, Space, Arrow keys λ“±)이 μ˜¬λ°”λ₯΄κ²Œ μž‘λ™ν•˜λ„λ‘ JavaScript λ‘œμ§μ„ κ΅¬ν˜„ν•˜μ—¬, 마우슀 없이도 μ»΄ν¬λ„ŒνŠΈμ˜ λͺ¨λ“  κΈ°λŠ₯을 μ‚¬μš©ν•  수 μžˆλ„λ‘ ν•΄μ•Ό ν•©λ‹ˆλ‹€. λ‹¨μˆœνžˆ ARIAλ§ŒμœΌλ‘œλŠ” ν‚€λ³΄λ“œ λ™μž‘μ„ μ œκ³΅ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ, 이 λ‘˜μ€ 항상 ν•¨κ»˜ κ³ λ €λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€.
  4. tabindex μ‹ μ€‘ν•œ μ‚¬μš©: 포컀슀 κ°€λŠ₯ν•œ μš”μ†Œμ—λ§Œ tabindex="0"을 μ‚¬μš©ν•˜κ³ , 슀크립트둜 포컀슀 이동을 μ œμ–΄ν•˜λŠ” 경우 tabindex="-1"을 적절히 ν™œμš©ν•˜μ—¬ 논리적인 포컀슀 μˆœμ„œλ₯Ό μœ μ§€ν•©λ‹ˆλ‹€.

μ΄λ ‡κ²Œ WAI-ARIAλ₯Ό μ μš©ν•˜λ©΄, 보쑰 기술 μ‚¬μš©μžλŠ” μ»΄ν¬λ„ŒνŠΈμ˜ ꡬ쑰와 κΈ°λŠ₯을 λͺ…ν™•νžˆ μ΄ν•΄ν•˜κ³  효과적으둜 μƒν˜Έμž‘μš©ν•  수 있게 λ©λ‹ˆλ‹€. μ΄λŠ” λ‹¨μˆœνžˆ 기술적인 μš”κ΅¬μ‚¬ν•­μ„ λ„˜μ–΄, λͺ¨λ“  μ‚¬μš©μžλ₯Ό μœ„ν•œ 포용적인 웹을 λ§Œλ“œλŠ” 핡심적인 μ‹€μ²œ λ°©λ²•μž…λ‹ˆλ‹€.

Before Code (Bad)

// Before: μ»€μŠ€ν…€ νƒ­ μ»΄ν¬λ„ŒνŠΈ (ARIA 속성 μ—†μŒ)
import React, { useState } from 'react';

function CustomTabs() {
  const [activeTab, setActiveTab] = useState('tab1');

  return (
    <div className="tab-container">
      <div className="tab-list">
        <div
          className={`tab-item ${activeTab === 'tab1' ? 'active' : ''}`}
          onClick={() => setActiveTab('tab1')}
          // ν‚€λ³΄λ“œ 이벀트 핸듀링 및 ARIA 속성 λΆ€μž¬λ‘œ μ ‘κ·Όμ„± 문제 λ°œμƒ
        >
          νƒ­ 1
        </div>
        <div
          className={`tab-item ${activeTab === 'tab2' ? 'active' : ''}`}
          onClick={() => setActiveTab('tab2')}
        >
          νƒ­ 2
        </div>
      </div>
      <div className="tab-content">
        {activeTab === 'tab1' && (
          <div className="panel">
            <p>νƒ­ 1의 λ‚΄μš©μž…λ‹ˆλ‹€.</p>
          </div>
        )}
        {activeTab === 'tab2' && (
          <div className="panel">
            <p>νƒ­ 2의 λ‚΄μš©μž…λ‹ˆλ‹€.</p>
          </div>
        )}
      </div>
    </div>
  );
}

export default CustomTabs;

After Code (Good)

// After: ARIA 속성과 ν‚€λ³΄λ“œ 접근성이 적용된 μ»€μŠ€ν…€ νƒ­ μ»΄ν¬λ„ŒνŠΈ
import React, { useState, useRef, useEffect } from 'react';

function AccessibleTabs() {
  const [activeTab, setActiveTab] = useState('tab1');
  const tabRefs = useRef({});

  // ν‚€λ³΄λ“œ λ„€λΉ„κ²Œμ΄μ…˜ (쒌우 ν™”μ‚΄ν‘œ ν‚€λ‘œ νƒ­ 이동, Enter/Space둜 νƒ­ 선택)
  const handleKeyDown = (e, tabId) => {
    const tabIds = ['tab1', 'tab2'];
    const currentIndex = tabIds.indexOf(tabId);
    let newIndex = currentIndex;

    if (e.key === 'ArrowRight') {
      newIndex = (currentIndex + 1) % tabIds.length;
    } else if (e.key === 'ArrowLeft') {
      newIndex = (currentIndex - 1 + tabIds.length) % tabIds.length;
    }

    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault(); // μŠ€νŽ˜μ΄μŠ€λ°”μ˜ κΈ°λ³Έ λ™μž‘ λ°©μ§€ (슀크둀 λ“±)
      setActiveTab(tabId);
    } else if (newIndex !== currentIndex) {
      setActiveTab(tabIds[newIndex]);
      // μƒˆ ν™œμ„± 탭에 포컀슀 μ΄λ™ν•˜μ—¬ ν‚€λ³΄λ“œ μ‚¬μš©μžκ°€ μΈμ§€ν•˜λ„λ‘ 함
      tabRefs.current[tabIds[newIndex]].focus();
    }
  };

  useEffect(() => {
    // ν™œμ„± 탭이 변경될 λ•Œ ν•΄λ‹Ή 탭에 포컀슀λ₯Ό 보μž₯ (선택 μ‚¬ν•­μ΄λ‚˜ UX κ°œμ„ μ— 도움)
    if (tabRefs.current[activeTab]) {
      tabRefs.current[activeTab].focus();
    }
  }, [activeTab]);

  return (
    <div className="tab-container">
      {/* role="tablist": νƒ­ κ·Έλ£Ήμž„μ„ λͺ…μ‹œ, aria-label: 그룹의 λͺ©μ μ„ 슀크린 리더에 μ•Œλ¦Ό */}
      <div role="tablist" aria-label="Example Tabs" className="tab-list">
        <div
          id="tab1-header" // νƒ­ νŒ¨λ„κ³Όμ˜ 관계λ₯Ό μœ„ν•œ 고유 ID
          role="tab" // μš”μ†Œκ°€ νƒ­μž„μ„ λͺ…μ‹œ
          aria-controls="tab1-panel" // 이 탭이 μ œμ–΄ν•˜λŠ” νŒ¨λ„μ˜ IDλ₯Ό λͺ…μ‹œ
          aria-selected={activeTab === 'tab1'} // νƒ­μ˜ 선택 μƒνƒœλ₯Ό λͺ…μ‹œ
          tabIndex={activeTab === 'tab1' ? 0 : -1} // ν™œμ„± νƒ­λ§Œ 포컀슀 κ°€λŠ₯ν•˜λ„λ‘ μ„€μ •
          className={`tab-item ${activeTab === 'tab1' ? 'active' : ''}`}
          onClick={() => setActiveTab('tab1')}
          onKeyDown={(e) => handleKeyDown(e, 'tab1')}
          ref={(el) => (tabRefs.current['tab1'] = el)}
        >
          νƒ­ 1
        </div>
        <div
          id="tab2-header"
          role="tab"
          aria-controls="tab2-panel"
          aria-selected={activeTab === 'tab2'}
          tabIndex={activeTab === 'tab2' ? 0 : -1}
          className={`tab-item ${activeTab === 'tab2' ? 'active' : ''}`}
          onClick={() => setActiveTab('tab2')}
          onKeyDown={(e) => handleKeyDown(e, 'tab2')}
          ref={(el) => (tabRefs.current['tab2'] = el)}
        >
          νƒ­ 2
        </div>
      </div>
      {activeTab === 'tab1' && (
        <div
          id="tab1-panel" // νƒ­ ν—€λ”μ™€μ˜ 관계λ₯Ό μœ„ν•œ 고유 ID
          role="tabpanel" // μš”μ†Œκ°€ νƒ­ νŒ¨λ„μž„μ„ λͺ…μ‹œ
          aria-labelledby="tab1-header" // 이 νŒ¨λ„μ΄ μ–΄λ–€ νƒ­ 헀더에 μ˜ν•΄ λ ˆμ΄λΈ”λ§λ˜λŠ”μ§€ λͺ…μ‹œ
          tabIndex={0} // νŒ¨λ„ λ‚΄μš©μ— 직접 포컀슀 μ§„μž… κ°€λŠ₯ν•˜λ„λ‘ (선택 사항)
          className="panel"
        >
          <p>νƒ­ 1의 λ‚΄μš©μž…λ‹ˆλ‹€.</p>
        </div>
      )}
      {activeTab === 'tab2' && (
        <div
          id="tab2-panel"
          role="tabpanel"
          aria-labelledby="tab2-header"
          tabIndex={0}
          className="panel"
        >
          <p>νƒ­ 2의 λ‚΄μš©μž…λ‹ˆλ‹€.</p>
        </div>
      )}
    </div>
  );
}

export default AccessibleTabs;