접근성을 고려하여 폼이 있는 모달 만들기

2025-10-27
  • accessibility

🏃 들어가며

모달은 애플리케이션에서 자주 사용되는 UI 패턴 중 하나다. 그러나, 내가 이전에 구현했던 모달은 접근성 관점에서 부족함이 있었음을 이번 토스 코드 챌린지를 통해 알게 되었다. 따라서 이 글에서는 접근성 있는 모달을 구현하기 위해 고려해야 할 요소들을 정리하고자 한다.

🅰 WAI-ARIA 적용하기

WAI-ARIA란?

ARIA는 Accessible Rich Internet Applications의 약자로, 장애가 있는 사용자도 웹 콘텐츠와 웹 애플리케이션에 쉽게 접근할 수 있도록 하는 방법을 정의한 기술이다.

현대 웹에는 동적인 UI 컴포넌트들이 많이 사용되는데, 이러한 컴포넌트들이 접근성 지원 없이 구현될 경우 시각 장애인이나 스크린 리더 사용자 등이 웹사이트를 제대로 이용할 수 없게 된다. WAI-ARIA는 이러한 디지털 격차를 해소하기 위한 필수적인 웹 표준이다.

역할(Role) 부여하기

<div role="dialog"></div>

role 속성은 HTML 요소에 시맨틱한 의미를 부여하여 스크린 리더 및 다른 보조 기술이 콘텐츠의 목적과 역할을 이해할 수 있도록 한다.

HTML에는 <button>, <nav>, <header>와 같이 기본적으로 의미를 내포하는 시맨틱 태그들이 존재한다. 그러나 <div><span> 같은 비시맨틱 요소로 복잡한 UI를 구현할 때는 role 속성을 통해 명시적으로 역할을 부여해야 한다. 비시맨틱 요소는 단순한 컨테이너일 뿐 의미 정보를 담고 있지 않아, 보조 기술이 해당 요소의 목적과 기능을 이해할 수 없기 때문이다.

모달은 일반적으로 비시맨틱한 <div> 태그로 구현되므로, 보조 기술이 해당 콘텐츠의 역할을 인식할 수 있도록 role="dialog"를 지정해야 한다.

role="dialog"는 웹 페이지의 나머지 부분과 시각적·기능적으로 분리된 대화 상자나 하위 창을 나타낼 때 사용한다. 이를 통해 보조 기술은 모달 콘텐츠를 독립적인 단위로 그룹화하고, 페이지의 나머지 콘텐츠와 구분하여 사용자에게 전달할 수 있다.

중요한 점은, ARIA 속성은 보조 기술에게 의미만 전달할 뿐, 실제 요소의 동작이나 기능에는 영향을 주지 않는다. 따라서 포커스 관리, 스크롤 제어 등의 모달 동작은 개발자가 JavaScript로 직접 구현해야 한다.

맥락 제공하기

label

모든 상호작용 가능한 요소는 이름을 가져야 한다. 모달 역시 스크린 리더 사용자에게 어떤 모달인지 설명하는 label이 필요하다.

const titleId = useId();

<div role="dialog" aria-labelledby={titleId}>
  <h2 id={titleId}>신청 폼</h2>
</div>

aria-labelledby 속성을 사용하면 DOM에 이미 존재하는 요소의 텍스트를 참조하여 레이블로 사용할 수 있다. 위 예시에서는 제목 요소(<h2>)의 id를 aria-labelledby의 값으로 지정하여, 보조 기술이 “신청 폼”이라는 텍스트를 모달의 이름으로 인식하게 한다. 만약 DOM에 참조할 텍스트가 없다면 aria-label 속성을 직접 사용할 수도 있다.

<div role="dialog" aria-label="신청 폼">
  {/* 콘텐츠 */}
</div>

description

aria-describedby 속성을 사용하면 모달에 대한 추가 설명을 제공할 수 있다. 레이블이 간단한 제목을 제공한다면, 설명은 모달의 목적이나 사용자가 수행해야 할 작업에 대한 상세한 맥락을 전달한다. 이 속성도 레이블을 설정했던 방식과 같이 참조하려는 요소(모달을 설명하고 있는 요소)의 id를 적용해줘야 한다.

const titleId = useId();
const descriptionId = useId();

<div 
  role="dialog" 
  aria-labelledby={titleId} 
  aria-describedby={descriptionId}
>
  <h2 id={titleId}>등록 폼 모달</h2>
  <p id={descriptionId}>이름, 나이, 성별 등 정보를 입력해주세요.</p>
</div>

외부 컨텐츠와의 상호작용 제한

const titleId = useId();
const descriptionId = useId();

<div 
  role="dialog"
  aria-labelledby={titleId}
  aria-describedby={descriptionId}
  aria-modal="true"
>
  <h2 id={titleId}>등록 폼 모달</h2>
  <p id={descriptionId}>이름, 나이, 성별 등 정보를 입력해주세요.</p>
</div>

모달이 활성화되면 사용자의 탐색 영역은 모달 내부로 제한되어야 하며, 배경의 콘텐츠는 상호작용할 수 없는 상태가 되어야 한다. 시각적으로는 오버레이(반투명 배경)를 통해 이를 표현하지만, 스크린 리더 사용자는 이러한 시각적 단서를 인지할 수 없다.

따라서 aria-modal="true"를 설정함으로써 보조 기술에게 다음과 같은 사실을 알린다.

  • 현재 모달 외부의 콘텐츠는 비활성(inert) 상태임
  • 모달을 닫거나 포커스를 잃기 전까지는 외부 콘텐츠와 상호작용할 수 없음

그러나, 앞서 말한 것처럼 aria-modal="true"는 보조 기술에게 의미만 전달할 뿐, 실제로 포커스를 모달 내부로 가두는 기능(포커스 트랩)을 제공하지는 않는다. 포커스 트랩은 개발자가 JavaScript로 직접 구현해야 한다.

모달에 aria-modal="true"를 명시적으로 설정할 경우 보조 기술에게 대화 상자 외부의 콘텐츠가 비활성(inert) 상태임을 알린다. 그러나, 오버레이 자체가 의미 없는 장식 요소인 경우 aria-hidden="true"를 추가하는 것이 안전할 수 있다.

내가 구현한 오버레이는 클릭을 통해 모달을 닫을 수 있어 단순 시각적 요소는 아니다. 그러나 시각 장애인은 오버레이를 인지하거나 클릭할 수 없으므로, Escape 키로 모달을 닫는 기능을 추가했고 aria-hidden="true"를 설정하여 보조 기술에서는 오버레이 요소를 숨겼다. 이를 통해 스크린 리더 사용자가 불필요한 요소에 포커스하지 않도록 했다.

<div
  aria-hidden="true"
  onClick={closeModal}
/>

⚙️ 모달 동작 구현하기

보조 기술에게 ARIA 속성으로 모달의 역할과 상태를 잘 설명했지만, 실제 모달 동작(포커스 관리, 키보드 컨트롤, 스크롤 제어 등)은 별도로 구현해야 한다. 아래에서는 어떤 기능을 구현해야 하는지 구현 로직과 함께 설명하려 한다.

배경 스크롤 방지

useEffect(() => {
  const originalStyle = window.getComputedStyle(document.body).overflow;

  if (isOpenModal) {
    document.body.style.overflow = "hidden";
  }

  return () => {
    document.body.style.overflow = originalStyle;
  };
}, [isOpenModal]);

모달이 활성화되면 배경 페이지의 스크롤을 비활성화해야 한다. 이를 구현하지 않으면 모달이 열린 상태에서도 배경 콘텐츠를 스크롤할 수 있어 사용자에게 혼란을 줄 수 있다. 위 코드는 모달이 열릴 때 body의 overflow 속성을 hidden으로 설정하고, 모달이 닫힐 때(cleanup 함수) 원래 값으로 복원한다.

포커스 관리

모달 열림 시 포커스 관리

W3C의 ARIA Authoring Practices Guide에 따르면, 모달이 화면에 표시될 때 포커스가 모달 내부의 적절한 요소로 이동해야 한다고 나와있다. (SHOULD).

나는 챌린지 요구사항에 맞춰 모달이 활성화 되면 제목 요소로 포커스를 이동하도록 구현했다. 그렇지만, 개인적으로 생각했을 때 제목 요소가 아닌 컨트롤 요소에 포커스를 가장 먼저 주는 것이 편리하지 않을까 생각해서 관련 자료를 찾아보았다.

W3C 문서에 따르면 단일 문자열로 콘텐츠를 안내하기 어려운 경우 tabindex="-1"를 컨텐츠 시작 부분에 있는 요소(제목이나, 설명 요소)에 추가하고 해당 요소에 포커스를 가장 먼저 주는 것을 권장한다고 나와있다. 생각해보니, 컨트롤 요소로 바로 이동할 경우 보조 기술 사용자가 바로 모달의 목적과 기능을 이해하기 어렵기 때문에 해당 방식이 더 좋겠다는 생각이 들었다.

포커스가 어디로 이동할지는 모달의 목적에 따라 달라지는데 간단한 confirm의 경우 액션 버튼으로 이동하고, 단일 입력 폼 같이 간단한 모달이라면 첫 번째 입력 필드로 바로 이동해도 좋을 것 같다. 문서에서도 모달의 특성과 크기에 따라 포커스를 다르게 관리하도록 권장한다.

const titleRef = useRef<HTMLHeadingElement | null>(null);
const titleId = useId();
const descriptionId = useId();

useEffect(() => {
  if (!isOpenModal) return;
  
  // 제목으로 포커스 이동
  setTimeout(() => {
    titleRef.current?.focus();
  }, 0);
}, [isOpenModal]);

<div 
  role="dialog"
  aria-labelledby={titleId}
  aria-describedby={descriptionId}
  aria-modal="true"
>
  <h2 ref={titleRef} id={titleId} tabIndex={-1}>
    등록 폼 모달
  </h2>
  <p id={descriptionId}>이름, 나이, 성별 등 정보를 입력해주세요.</p>
</div>

모달이 DOM에 마운트되고 실제로 렌더링이 완료되는 시점과 React의 상태 업데이트 시점 사이에는 미묘한 타이밍 차이가 있을 수 있다. setTimeout을 사용하면 현재 실행 중인 스택이 완료된 후 다음 이벤트 루프에서 포커스를 이동하게 되어, 요소가 완전히 렌더링되고 포커스 가능한 상태가 된 후에 포커스를 이동할 수 있어 안전하다.

모달 닫힘 시 포커스 복원

모달이 닫힌 후에는 키보드 포커스를 모달을 열었던 요소(트리거 요소)로 되돌려야 한다. 이 또한 문서에서 권장하고 있는 내용이다. 그렇지 않으면 포커스가 페이지 시작 부분으로 이동하거나 손실되어, 키보드 사용자가 자신의 위치를 잃게 된다.

const previousFocusElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  if (!isOpenModal) return;

  // 모달 열림: 현재 포커스된 요소 저장
  if (document.activeElement instanceof HTMLElement) {
    previousFocusElementRef.current = document.activeElement;
  }
  
  // 모달 닫힘: 이전 포커스 복원
  return () => {
    previousFocusElementRef.current?.focus();
  };
}, [isOpenModal]);

document.activeElement를 사용하여 모달이 열리기 직전에 포커스되어 있던 요소를 저장하고, cleanup 함수에서 해당 요소로 포커스를 복원하도록 구현했다.

그러나, 이 또한 애플리케이션의 흐름에 따라 다른 요소로 포커스를 이동하는 것이 더 나을 수 있다. 예를 들어 항목 삭제 모달을 닫은 후 삭제된 항목의 다음 항목으로 포커스를 이동하는 것이 사용자 경험상 자연스럽다.

포커스 트랩

모달이 열려 있는 동안 키보드 포커스는 모달 내부에만 머물러야 한다. Tab 키로 포커스를 이동할 때, 마지막 포커스 가능 요소에서 Tab을 누르면 첫 번째 요소로, 첫 번째 요소에서 Shift+Tab을 누르면 마지막 요소로 순환해야 한다. 이를 구현하지 않으면 Tab 키로 모달 밖으로 포커스가 빠져나가게 된다.

이를 위해서는 useFocusTrap이라는 hook을 구현했다.

export const useFocusTrap = (isActive: boolean) => {
  const containerRef = useRef<HTMLDivElement | null>(null);

  const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
    const focusableSelectors = [
      "button:not(:disabled)",
      "input:not(:disabled)",
      "textarea:not(:disabled)",
      "select:not(:disabled)",
      "a[href]",
      '[tabindex]:not([tabindex="-1"])',
    ].join(", ");

    return Array.from(container.querySelectorAll(focusableSelectors));
  };

  useEffect(() => {
    const container = containerRef.current;
    if (!isActive || !container) return;

    const handleTabKey = (event: KeyboardEvent) => {
      if (event.key !== "Tab") return;

      const focusableElements = getFocusableElements(container);

      if (focusableElements.length === 0) return;

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (event.shiftKey) {
        // Shift + Tab: 역방향 이동
        // 첫 번째 요소에서 Shift+Tab을 누르면 마지막 요소로 이동
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab: 정방향 이동
        // 마지막 요소에서 Tab을 누르면 첫 번째 요소로 이동
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    };

    container.addEventListener("keydown", handleTabKey);
    return () => container.removeEventListener("keydown", handleTabKey);
  }, [isActive]);

  return containerRef;
};

흐름은 다음과 같다.

  1. keydown 이벤트에서 Tab 키 감지
  2. 모달 컨테이너 내부의 모든 포커스 가능한 요소를 가져옴 (버튼, 입력 필드, 링크 등)
  3. Shift+Tab (역방향) 키일 경우 현재 포커스가 첫 번째 요소라면 마지막 요소로 이동
  4. Tab (정방향) 키일 경우 현재 포커스가 마지막 요소라면 첫 번째 요소로 이동

이를 통해 모달 내부에서만 포커스가 이동 가능하도록 구현할 수 있다. 이 훅은 containerRef를 반환하여 모달의 루트 요소에 연결하면 된다. isActive 상태 값을 받아 모달이 열려 있을 때만 작동하도록 구현했다.

W3C 가이드에서는 Escape 키로 모달을 닫을 수 있도록 구현할 것을 권장한다. 따라서 모달이 열려 있을 경우 Escape 키를 감지하여 모달을 닫도록 다음과 같이 구현했다.

useEffect(() => {
  const handleEscapeKey = (event: KeyboardEvent) => {
    if (event.key === "Escape" && isOpenModal) {
      closeModal();
    }
  };

  if (isOpenModal) {
    document.addEventListener("keydown", handleEscapeKey);
  }

  return () => document.removeEventListener("keydown", handleEscapeKey);
}, [isOpenModal, closeModal]);

오버레이 클릭으로 모달을 닫는 기능은 W3C 가이드에서 명시적으로 권장하지 않는다. 하지만 이번 챌린지의 요구사항이기도 했고, 마우스 사용자에게 편의성을 제공할 수 있는 기능이라고 판단하여 모달을 만들 때마다 항상 구현해왔다. 개인적으로 구현이 되어 있지 않아서 불편했던 경험이 종종 있었다. 코드는 이렇게 간단하게 작성해줄 수 있다.

const [isOpenModal, setIsOpenModal] = useState<boolean>(false);

const handleCloseModal = () => {
  setIsOpenModal(false);
};

<div
  onClick={handleCloseModal}
 />

📋 폼의 접근성

이제 Form의 접근성을 고려해보자. 모달 내부의 폼 요소도 접근성을 고려해야 한다. 아래의 코드는 Form에 사용되는 input 형식의 Field이다.

<label htmlFor={inputId}>{label}</label>
<Input
  id={inputId}
  aria-describedby={error ? errorId : undefined}
  aria-invalid={error ? "true" : "false"}
  aria-required={required}
  />
{error && (
  <p
    id={errorId}
    role="alert"
    >
    {error}
  </p>
)}

유효하지 않은 필드

alert role

alert role은 사용자에게 즉각적으로 주의를 주어야 하는 정보에 사용된다. 예를 들어 필드에 유효하지 않은 값이 입력되거나, 사용자의 로그인 세션이 만료된 경우가 있다. 이 속성을 통해 브라우저는 보조 기술에게 alert 이벤트를 전송하여 사용자에게 즉각적으로 주의 사항을 전달한다. 이 때 전달되는 경고문은 요소 안에 입력된 텍스트로 전달한다.

// 보조기술은 이메일 필드에 유효하지 않은 값이 입력되면 '이메일 형식이 올바르지 않습니다'라는 텍스트를 읽는다.
<p role="alert">이메일 형식이 올바르지 않습니다.</p>

aria-describedby

aria-describedby는 오류 메시지 요소의 id를 참조한다. role="alert"의 경우 에러가 발생하는 순간만 즉시 에러 텍스트를 읽어주지만, aria-describedby 필드에 포커스가 돌아올 때마다 에러 메시지를 읽어주기 때문에 사용자에게 지속적으로 에러가 있음을 알릴 수 있다.

aria-invalid

aria-invalid 속성은 필드에 입력된 값이 유효하지 않음을 보조 기술에 알린다. 사용자가 해당 필드로 포커스를 이동하면 스크린 리더는 “유효하지 않은 데이터”라고 알려준다.

맥락 제공

<label>의 htmlFor 속성과 <input>의 id를 연결하는 것은 접근성의 기본이다. 레이블을 클릭했을 때 해당하는 입력 필드로 포커스를 이동할 수도 있지만, 스크린 리더가 필드에 포커스할 때 레이블 텍스트를 읽어준다. 추가적으로 앞서 코드에 적용한 aria-required는 보조 기술 사용자에게 포커스된 필드가 필수 필드임을 알린다.

이를 통해 Voice Over는 포커스된 필드의 레이블과 필드가 필수 입력 필드임을 알려준다. 이렇게 보조 기술 사용자는 시각 정보 없이 안내 음성만으로도 폼의 구조와 요구사항을 정확히 파악할 수 있다.

✅ 체크리스트 요약

  • 다이얼로그 컨테이너
    • role='dialog' 속성을 가져야 한다.
    • aria-modal=true 속성을 가져야 한다.
  • 설명
    • aria-label 혹은 aria-labelledby 속성으로 다이얼로그의 타이틀을 설정해야 한다.
    • (선택) aria-describedby 속성을 통해 다이얼로그의 주요 목적이나 메시지를 설명해야 한다.
      • 만약 초기 키보드 포커스가 모달을 설명하는 요소가 아닐 경우 aria-describedby 속성은 필수적이다.
  • 포커스
    • 다이얼로그 내부에 포커스 가능한 하위 요소가 하나 이상 존재해야 한다.
    • 다이얼로그가 열리면 포커스 가능한 첫 번째 요소로 포커스가 이동한다. (적절한 포커스 위치는 콘텐츠의 성격과 크기에 달라질 수 있음)
    • 다이얼로그가 닫히면 트리거 요소로 포커스가 이동해야 한다. (플로우에 따라 적절한 포커스 위치는 달라질 수 있음)
  • 키보드 상호 작용
    • 다이얼로그가 닫힐 때까지 키보드 포커스는 모달 대화 상자 내의 포커스 가능한 요소로 제한한다.
    • 다이얼로그 Tab 순서에 다이얼로그를 닫는 버튼을 가진 요소가 포함되어야 한다.
    • 마지막 요소에서 Tab → 첫 번째 요소로 순환
    • 첫 번째 요소에서 Shift+Tab → 마지막 요소로 순환
    • Escape 키로 다이얼로그를 닫을 수 있음




참고 문서

W3C 가이드

mdn 문서

Profile picture

박세리

Frontend Developer