July 4, 2025

🎯 ν‚€λ³΄λ“œ 포컀슀 관리 미흑: μ ‘κ·Όμ„± μ‚¬κ°μ§€λŒ€ 생성

UX
SEO/μ ‘κ·Όμ„±
JavaScript
React
μ»΄ν¬λ„ŒνŠΈ

Summary

ν‚€λ³΄λ“œ 포컀슀 관리λ₯Ό μ†Œν™€νžˆ ν•˜λ©΄ ν‚€λ³΄λ“œ 및 보쑰 기술 μ‚¬μš©μžμ˜ 접근성을 μ‹¬κ°ν•˜κ²Œ μ €ν•΄ν•˜κ³ , μ‚¬μš©μž κ²½ν—˜μ„ μ•…ν™”μ‹œν‚΅λ‹ˆλ‹€. μ‹œλ§¨ν‹± HTML μš”μ†Œ, tabindex의 μ μ ˆν•œ μ‚¬μš©, 동적 UIμ—μ„œμ˜ 포컀슀 νŠΈλž˜ν•‘ 및 볡원, SPA 라우트 λ³€κ²½ μ‹œ 포컀슀 이동, 그리고 λͺ…ν™•ν•œ μ‹œκ°μ  포컀슀 ν‘œμ‹œλ₯Ό 톡해 λͺ¨λ“  μ‚¬μš©μžκ°€ μ›ν™œν•˜κ²Œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ 탐색할 수 μžˆλ„λ‘ ν•΄μ•Ό ν•©λ‹ˆλ‹€.

Why Wrong?

μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ—μ„œ ν‚€λ³΄λ“œ 포컀슀 관리가 μ œλŒ€λ‘œ 이루어지지 μ•ŠμœΌλ©΄, ν‚€λ³΄λ“œλ§Œμ„ μ‚¬μš©ν•˜λŠ” μ‚¬μš©μž(μ‹œκ° μž₯애인, μš΄λ™ λŠ₯λ ₯ μ €ν•˜ μ‚¬μš©μž)λ‚˜ 슀크린 리더 μ‚¬μš©μžμ—κ²Œ μ‹¬κ°ν•œ μ ‘κ·Όμ„± 문제λ₯Ό μ•ΌκΈ°ν•©λ‹ˆλ‹€. 예λ₯Ό λ“€μ–΄, λͺ¨λ‹¬ 창이 열렸을 λ•Œ ν¬μ»€μŠ€κ°€ λͺ¨λ‹¬ 내뢀에 'κ°‡νžˆμ§€' μ•Šκ³  λ’· λ°°κ²½ μš”μ†Œλ‘œ μ΄λ™ν•˜κ±°λ‚˜, λͺ¨λ‹¬μ΄ λ‹«ν˜”μ„ λ•Œ ν¬μ»€μŠ€κ°€ μ›λž˜ μœ„μΉ˜λ‘œ λŒμ•„μ˜€μ§€ μ•ŠμœΌλ©΄ μ‚¬μš©μžλŠ” ν˜„μž¬ μœ„μΉ˜λ₯Ό μžƒκ³  μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ 효과적으둜 탐색할 수 μ—†κ²Œ λ©λ‹ˆλ‹€. λ˜ν•œ, SPA(Single Page Application)μ—μ„œ λΌμš°νŠΈκ°€ 변경될 λ•Œ 메인 μ½˜ν…μΈ  μ˜μ—­μœΌλ‘œ 포컀슀λ₯Ό μ΄λ™μ‹œν‚€μ§€ μ•ŠμœΌλ©΄, 슀크린 리더 μ‚¬μš©μžλŠ” νŽ˜μ΄μ§€κ°€ λ³€κ²½λ˜μ—ˆμŒμ„ μΈμ§€ν•˜κΈ° μ–΄λ ΅κ³  λ‹€μ‹œ μ²˜μŒλΆ€ν„° 탐색해야 ν•˜λŠ” λΆˆνŽΈν•¨μ„ κ²ͺ게 λ©λ‹ˆλ‹€. μ΄λŠ” WCAG(μ›Ή μ½˜ν…μΈ  μ ‘κ·Όμ„± μ§€μΉ¨) μœ„λ°˜μœΌλ‘œ 이어지며, 잠재적인 법적 λ¬Έμ œμ™€ ν•¨κ»˜ μ‚¬μš©μž κ²½ν—˜μ„ 크게 μ €ν•΄ν•©λ‹ˆλ‹€.

How to Fix?

μ˜¬λ°”λ₯Έ ν‚€λ³΄λ“œ 포컀슀 관리λ₯Ό μœ„ν•΄μ„œλŠ” λ‹€μŒ 사항듀을 μ€€μˆ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€:

  1. μ‹œλ§¨ν‹± HTML μš”μ†Œ μ‚¬μš©: 기본적으둜 button, a, input λ“±κ³Ό 같이 ν¬μ»€μŠ€κ°€ κ°€λŠ₯ν•œ μ‹œλ§¨ν‹± HTML μš”μ†Œλ₯Ό μ‚¬μš©ν•˜μ—¬ λΈŒλΌμš°μ €μ˜ κΈ°λ³Έ 포컀슀 λ™μž‘μ„ ν™œμš©ν•©λ‹ˆλ‹€.
  2. tabindex의 μ˜¬λ°”λ₯Έ ν™œμš©: λΉ„ν™œμ„± μš”μ†Œμ— 포컀슀λ₯Ό μ£Όκ±°λ‚˜ 포컀슀 μˆœμ„œλ₯Ό λ³€κ²½ν•΄μ•Ό ν•  λ•Œλ§Œ tabindex 속성을 μ‹ μ€‘ν•˜κ²Œ μ‚¬μš©ν•©λ‹ˆλ‹€. tabindex="0"은 μš”μ†Œλ₯Ό μžμ—°μŠ€λŸ¬μš΄ νƒ­ μˆœμ„œμ— ν¬ν•¨μ‹œν‚€κ³ , tabindex="-1"은 μš”μ†Œλ₯Ό ν”„λ‘œκ·Έλž˜λ° λ°©μ‹μœΌλ‘œ 포컀슀 κ°€λŠ₯ν•˜κ²Œ ν•˜μ§€λ§Œ νƒ­ μˆœμ„œμ—μ„œλŠ” μ œμ™Έν•©λ‹ˆλ‹€.
  3. 동적 UI (λͺ¨λ‹¬, λ“œλ‘­λ‹€μš΄ λ“±) 포컀슀 트랩 및 볡원: λͺ¨λ‹¬μ΄λ‚˜ νŒμ—…μ΄ 열릴 λ•Œ 포컀슀λ₯Ό λͺ¨λ‹¬ 내뢀에 가두고(focus trap), λͺ¨λ‹¬μ΄ λ‹«νž λ•ŒλŠ” λͺ¨λ‹¬μ„ μ—΄κΈ° μ „μ˜ μš”μ†Œλ‘œ 포컀슀λ₯Ό λ³΅μ›ν•˜λŠ” λ‘œμ§μ„ κ΅¬ν˜„ν•΄μ•Ό ν•©λ‹ˆλ‹€. μ΄λŠ” useEffect와 useRefλ₯Ό μ‚¬μš©ν•˜μ—¬ ν”„λ‘œκ·Έλž˜λ°μ μœΌλ‘œ element.focus()λ₯Ό ν˜ΈμΆœν•¨μœΌλ‘œμ¨ κ°€λŠ₯ν•©λ‹ˆλ‹€. aria-modal 속성을 μ‚¬μš©ν•˜μ—¬ 슀크린 리더에 λͺ¨λ‹¬μ˜ 의미λ₯Ό μ „λ‹¬ν•˜λŠ” 것도 μ€‘μš”ν•©λ‹ˆλ‹€.
  4. SPA 라우트 λ³€κ²½ μ‹œ 포컀슀 이동: 라우트 λ³€κ²½ μ‹œ <main> νƒœκ·Έλ‚˜ μƒˆλ‘œμš΄ νŽ˜μ΄μ§€μ˜ 첫 번째 μ€‘μš” μ½˜ν…μΈ  μš”μ†Œλ‘œ 포컀슀λ₯Ό μ΄λ™μ‹œμΌœ 슀크린 리더 μ‚¬μš©μžκ°€ μ½˜ν…μΈ  변경을 μΈμ§€ν•˜κ³  λ°”λ‘œ 탐색할 수 μžˆλ„λ‘ ν•©λ‹ˆλ‹€.
  5. μ‹œκ°μ μΈ 포컀슀 ν‘œμ‹œ μœ μ§€: :focus μ˜μ‚¬ 클래슀λ₯Ό μ‚¬μš©ν•˜μ—¬ 포컀슀된 μš”μ†Œμ— λŒ€ν•œ λͺ…ν™•ν•œ μ‹œκ°μ  μŠ€νƒ€μΌ(예: outline μŠ€νƒ€μΌ λ³€κ²½)을 μ œκ³΅ν•˜μ—¬ μ‹œκ°μ μΈ μ‚¬μš©μžλ„ ν˜„μž¬ 포컀슀 μœ„μΉ˜λ₯Ό μ‰½κ²Œ μ•Œ 수 μžˆλ„λ‘ ν•΄μ•Ό ν•©λ‹ˆλ‹€. λΈŒλΌμš°μ €μ˜ κΈ°λ³Έ outline을 outline: none으둜 μ œκ±°ν•˜λŠ” 것은 μ§€μ–‘ν•΄μ•Ό ν•©λ‹ˆλ‹€.

Before Code (Bad)

// 잘λͺ»λœ λͺ¨λ‹¬ κ΅¬ν˜„ (포컀슀 관리 μ—†μŒ)
import React, { useState } from 'react';

const BadModal = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>λͺ¨λ‹¬ μ—΄κΈ°</button>

      {isOpen && (
        <div style={{
          position: 'fixed',
          top: '50%',
          left: '50%',
          transform: 'translate(-50%, -50%)',
          backgroundColor: 'white',
          padding: '20px',
          border: '1px solid black',
          zIndex: 1000,
        }}>
          <h2>μ•ˆλ…•ν•˜μ„Έμš”, λͺ¨λ‹¬μž…λ‹ˆλ‹€!</h2>
          <p>여기에 μ€‘μš”ν•œ λ‚΄μš©μ΄ μžˆμŠ΅λ‹ˆλ‹€.</p>
          <button onClick={() => setIsOpen(false)}>λ‹«κΈ°</button>
          <input type="text" placeholder="μž…λ ₯" />
        </div>
      )}
      {isOpen && <div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }} />}
    </div>
  );
};

export default BadModal;

After Code (Good)

// μ˜¬λ°”λ₯Έ λͺ¨λ‹¬ κ΅¬ν˜„ (접근성을 κ³ λ €ν•œ 포컀슀 관리 포함)
import React, { useState, useRef, useEffect } from 'react';

const AccessibleModal = () => {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef(null);
  const triggerRef = useRef(null); // λͺ¨λ‹¬μ„ μ—° λ²„νŠΌ μ°Έμ‘°

  useEffect(() => {
    if (isOpen) {
      // λͺ¨λ‹¬μ΄ 열리면 λͺ¨λ‹¬ μžμ²΄μ— 포컀슀 (tabindex="-1" ν•„μš”)
      // λ˜λŠ” λͺ¨λ‹¬ λ‚΄ 첫 번째 포컀슀 κ°€λŠ₯ν•œ μš”μ†Œμ— 포컀슀
      modalRef.current.focus();

      const handleKeyDown = (event) => {
        if (event.key === 'Escape') {
          setIsOpen(false);
        }
        // TODO: λͺ¨λ‹¬ λ‚΄λΆ€μ—μ„œλ§Œ 포컀슀 μˆœν™˜ (tab ν‚€)
        // λ³΅μž‘ν•˜λ―€λ‘œ ARIA APG λ¬Έμ„œ μ°Έκ³ ν•˜μ—¬ 라이브러리 μ‚¬μš© ꢌμž₯
      };

      document.addEventListener('keydown', handleKeyDown);
      return () => {
        document.removeEventListener('keydown', handleKeyDown);
      };
    } else {
      // λͺ¨λ‹¬μ΄ λ‹«νžˆλ©΄ μ›λž˜ λͺ¨λ‹¬μ„ μ—΄μ—ˆλ˜ λ²„νŠΌμœΌλ‘œ 포컀슀 볡원
      triggerRef.current.focus();
    }
  }, [isOpen]);

  const openModal = () => {
    triggerRef.current = document.activeElement; // ν˜„μž¬ 포컀슀된 μš”μ†Œ μ €μž₯
    setIsOpen(true);
  };

  const closeModal = () => {
    setIsOpen(false);
  };

  return (
    <div>
      <button onClick={openModal}>λͺ¨λ‹¬ μ—΄κΈ°</button>

      {isOpen && (
        <div
          ref={modalRef}
          role="dialog"
          aria-modal="true" // 슀크린 리더에 λͺ¨λ‹¬μž„을 μ•Œλ¦Ό
          tabIndex="-1" // ν”„λ‘œκ·Έλž˜λ° λ°©μ‹μœΌλ‘œ 포컀슀 κ°€λŠ₯ν•˜κ²Œ 함
          style={{
            position: 'fixed',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
            backgroundColor: 'white',
            padding: '20px',
            border: '1px solid black',
            zIndex: 1000,
          }}
        >
          <h2>μ•ˆλ…•ν•˜μ„Έμš”, λͺ¨λ‹¬μž…λ‹ˆλ‹€!</h2>
          <p>여기에 μ€‘μš”ν•œ λ‚΄μš©μ΄ μžˆμŠ΅λ‹ˆλ‹€.</p>
          <button onClick={closeModal}>λ‹«κΈ°</button>
          <input type="text" placeholder="μž…λ ₯" />
        </div>
      )}
      {isOpen && <div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }} onClick={closeModal} />}
    </div>
  );
};

export default AccessibleModal;