접근성을 고려한 모달 구현

아더에러·2026년 3월 27일

프론트엔드

목록 보기
11/15

스크린리더가 어떻게 동작하는지 확인하고 싶어서 여러 항공사 사이트에서 스크린리더를 사용해 본 적이 있습니다. 대한항공 홈페이지는 모달이 열리면 포커스가 모달 안에서만 이동하고, 스크린리더가 제목을 읽어줬습니다. 모달이 열렸다는 사실을 소리만으로도 바로 알 수 있었습니다.

그런데 다른 항공사 홈페이지에서는 달랐습니다. 모달이 열렸는데도 포커스가 뒤쪽 페이지 요소로 계속 이어졌고, 지금 모달이 열린 건지 아닌지조차 파악할 수 없었습니다. 같은 모달인데 이렇게 다를 수 있다는 게 인상적이었습니다.

접근성은 "있으면 좋은 것"이 아니라, 없으면 아예 사용이 불가능해질 수 있다는 것을 깨달았습니다. 그때부터 모달을 직접 만들어보고 싶었습니다.

따라서 작년에 진행했던 토스 접근성 모달 폼 챌린지를 다시 진행하며, 시간 제한 없이 직접 구현해보고 접근성을 직접 체감하며 학습해보았습니다.

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

  const handleToggleModal = () => {
    setIsOpen((prev) => !prev);
  };

  return (
    <S.Container>
      <S.Button onClick={handleToggleModal}>신청 폼 작성하기</S.Button>
      {isOpen && <Modal onClose={handleToggleModal}>모달</Modal>}
    </S.Container>
  );
};

처음 모달은 단순하게 시작했습니다. isOpen 상태 하나로 모달을 열고 닫았습니다. Modal 컴포넌트는 배경과 콘텐츠 영역으로 나뉘는데, 접근성 속성은 배경이 아닌 콘텐츠 영역에 달았습니다.

function Modal({ children, onClose, titleId }: ModalProps) {
  return (
    <S.ModalBackdrop onClick={onClose}>
      <S.ModalContent
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        {children}
      </S.ModalContent>
    </S.ModalBackdrop>
  );
}

aria-modal, role="dialog", aria-labelledby를 배경이 아닌 콘텐츠 요소에 단 이유가 있습니다.

배경은 시각적 역할만 하며 의미 있는 콘텐츠가 없습니다. aria-modal="true"는 스크린리더에게 "이 요소 안이 현재 상호작용 범위"임을 알리는데, 이 선언이 배경에 붙으면 배경 자체가 대화상자로 해석되고 그 안의 콘텐츠 구조가 흐트러집니다. aria-labelledby도 마찬가지로, 모달의 제목이 어디에 있는지를 콘텐츠 기준으로 연결해야 스크린리더가 모달에 진입했을 때 제목을 올바르게 읽어줍니다.

ModalContenttabIndex={-1}을 준 것은 별도의 이유입니다. <div>는 기본적으로 포커스를 받을 수 없는데, 모달이 열릴 때 포커스를 모달 안으로 이동시키려면 포커스를 받을 수 있는 요소가 필요합니다. tabIndex={-1}은 Tab 순환에는 포함되지 않으면서 .focus()로는 이동할 수 있게 해주므로, 제목 요소가 없을 때의 fallback 포커스 대상으로 쓸 수 있습니다.

<dialog> 태그를 쓰지 않았는가

처음에는 모달을 좀 더 선언적으로 다루고 싶었습니다.

isOpen 상태를 직접 관리하고 JSX로 렌더링하는 방식에서는, 사용자가 폼을 제출했을 때 그 결과를 호출부에서 바로 받을 수가 없습니다. onSubmit 콜백은 "나중에 호출될 함수"이지 "지금 기다릴 수 있는 값"이 아니기 때문입니다. 원하는 형태는 이런 것이었습니다.

const result = await openModal(...);

if (result) {
  console.log("제출된 데이터:", result);
} else {
  console.log("취소");
}

이 구조를 만들기 위해 자연스럽게 <dialog> 태그를 살펴봤습니다. 브라우저가 포커스 트랩, ESC 키 닫기, aria-modal 같은 접근성 기능을 기본으로 제공해주기 때문에 매력적인 선택지였습니다. 그런데 <dialog>showModal()close() API는 Promise 기반이 아니어서, 제출 결과를 호출부에서 직접 받는 구조를 자연스럽게 만들기가 어려웠습니다.

결국 커스텀 구현을 선택했고, 대신 <dialog>가 제공하던 접근성 기능들을 직접 구현해야 했습니다.

구조 개선

Props Drilling 문제

단순한 구조에 폼을 추가하면 문제가 생깁니다. onCloseModalApplicationForm 둘 다에 내려줘야 하고, 구조가 깊어질수록 같은 prop을 계속 전달해야 합니다. 게다가 더 근본적인 문제가 있었습니다. handleSubmitconsole.log로 끝나고, 부모는 그 결과를 받을 방법이 없었습니다.

모달에서 제출이 이루어진 후 다른 UI를 업데이트하거나, 다른 페이지로 이동 등을 부모가 제어하려면 부모가 결과를 받아야합니다.

ModalFormPage
├── isOpen 상태 관리
├── handleToggleModal
│
├── <Button onClick={handleToggleModal} />
└── <Modal onClose={handleToggleModal}>
      <ApplicationForm onClose={handleToggleModal} />
    </Modal>

Promise로 결과 받기

이 문제는 resolve를 저장해두는 방식으로 해결했습니다.

const openModal = (content: ContentType): Promise<FormData | null> => {
  triggerRef.current = document.activeElement as HTMLElement;
  return new Promise((resolve) => {
    resolveRef.current = resolve;
    contentRef.current = content;
    setIsOpen(true);
  });
};

openModal을 호출하면 Promise가 생성되고, resolve 함수를 resolveRef에 저장한 뒤 모달을 엽니다. 이 시점에서 await은 멈춰 기다리다가, 사용자가 제출하거나 취소하면 저장해둔 resolve를 호출해 await이 풀립니다.

const close = (value: FormData | null) => {
  resolveRef.current?.(value);
  resolveRef.current = null;
  contentRef.current = null;
  setIsOpen(false);
  triggerRef.current?.focus();
  triggerRef.current = null;
};

여기서 resolveuseState가 아닌 useRef에 저장한 데는 이유가 있습니다. resolve는 화면을 다시 그릴 필요가 없는 값입니다. useRef는 값을 저장하되 변경해도 렌더링을 트리거하지 않으므로 이 역할에 더 적합하다고 판단했습니다.

콘텐츠 주입 방식

처음에는 ModalProvider 안에 ApplicationForm을 직접 렌더링했는데, 이렇게 하면 Provider가 특정 폼 컴포넌트에 의존하게 되어 다른 콘텐츠로는 모달을 재사용할 수 없습니다. 이 문제를 해결하기 위해 콘텐츠를 함수로 주입하는 방식을 선택했습니다.

type ContentType = (args: { close: CloseType }) => React.ReactNode;
const result = await openModal(({ close }) => (
  <>
    <FormTitle id="form-title" tabIndex={-1}>신청 폼</FormTitle>
    <ApplicationForm
      onSubmit={(data) => close(data)}
      onClose={() => close(null)}
    />
  </>
));

close 함수를 콘텐츠 렌더 함수의 인자로 넘겨주기 때문에, 콘텐츠 컴포넌트는 close를 통해 모달을 닫으면서 결과값을 함께 반환할 수 있습니다. ModalProvider는 어떤 콘텐츠가 들어오는지 알 필요 없이 모달의 생명주기만 관리하면 됩니다.

Context로 어디서든 호출 가능

openModal을 Context에 담아 제공하면 컴포넌트 트리의 어느 깊이에 있더라도 useContext로 꺼내 쓸 수 있습니다.

export const ModalContext = createContext<ModalContextValue | null>(null);

초기값을 null로 설정한 이유는 Provider 없이 사용하는 실수를 런타임에서 잡기 위해서입니다. if (!context) return null 같은 방어 코드로 Provider 외부에서의 잘못된 사용을 명시적으로 처리할 수 있습니다.

접근성 구현

포커스 트랩

Tab 키로 모달 안을 순환할 때, 마지막 요소에서 Tab을 누르면 포커스가 모달 밖으로 나가는 것을 막아야 합니다.

const FOCUSABLE_SELECTORS =
  'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])';

const useFocusTrap = (ref: React.RefObject<HTMLElement | null>) => {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;

      const elements = Array.from(
        ref.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS) ?? []
      );

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

      const first = elements[0];
      const last = elements[elements.length - 1];

      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    };

    const el = ref.current;
    el?.addEventListener("keydown", handler);
    return () => el?.removeEventListener("keydown", handler);
  }, [ref]);
};

포커스 트랩의 핵심은 "모달 안에서 Tab을 눌렀을 때 이동 가능한 요소가 어디까지인지"를 직접 정의하는 것입니다. 브라우저는 Tab을 누르면 문서 전체를 기준으로 다음 포커스 가능한 요소로 이동하기 때문에, 모달이 열려 있어도 그냥 밖으로 나가버립니다. 이를 막으려면 모달 안의 포커스 가능한 요소 목록을 직접 수집하고, 순환의 경계에서만 개입해야 합니다.

FOCUSABLE_SELECTORS가 필요한 이유가 여기에 있습니다. 첫 번째 요소와 마지막 요소를 기준으로 순환을 만들려면, Tab으로 실제 이동 가능한 요소가 어떤 것들인지 명시적으로 정의해야 합니다.

a[href]로 한정한 이유는 href가 없는 <a> 태그는 Tab 이동 대상이 아니기 때문이고, [tabindex]:not([tabindex="-1"])tabIndex={-1}처럼 프로그래밍 방식으로만 포커스를 받는 요소를 순환에서 제외하기 위해서입니다.

이벤트는 windowdocument가 아닌 ref.current, 즉 모달 컨테이너에 등록했습니다. document에 달면 페이지 어디서 Tab을 눌러도 반응하게 되므로, 모달 내부에서 발생한 키 입력만 처리하도록 범위를 좁혔습니다.

실제로 개입하는 시점은 순환의 경계뿐입니다. 마지막 요소에서 Tab을 누르면 첫 번째 요소로, 첫 번째 요소에서 Shift+Tab을 누르면 마지막 요소로 강제 이동합니다. 그 사이의 이동은 브라우저가 알아서 처리하므로 중간 요소들에는 개입하지 않습니다.

모달이 닫힐 때 포커스 복귀

모달이 닫힌 뒤 포커스가 원래 있던 버튼으로 돌아오지 않으면, 키보드 사용자는 처음부터 다시 Tab을 눌러야 합니다.

const openModal = (content: ContentType): Promise<FormData | null> => {
  triggerRef.current = document.activeElement as HTMLElement;
  // ...
};

const close = (value: FormData | null) => {
  // ...
  setIsOpen(false);
  triggerRef.current?.focus();
  triggerRef.current = null;
};

openModal이 호출되는 시점에 document.activeElement는 항상 모달을 연 버튼입니다. 사용자가 버튼을 클릭했을 때 onClick이 실행되고 그 안에서 openModal이 호출되기 때문입니다. 이 시점을 포착해 저장해두면 모달이 닫힐 때 정확히 그 버튼으로 돌아갈 수 있습니다.

ESC 키와 배경 클릭

const useKeyDown = ({ key, onKeyDown }: useKeyDownProps) => {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === key) onKeyDown(e);
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [key, onKeyDown]);
};

ESC 키 처리를 window에 등록한 이유는, 포커스 트랩과 달리 ESC는 포커스 위치에 관계없이 어디서 눌러도 동작해야 하기 때문입니다. 모달이 열려 있는 동안 사용자가 어떤 요소에 포커스를 두고 있든 ESC를 누르면 닫혀야 합니다.

배경 클릭으로 닫는 동작은 ModalBackdroponClick으로 처리하고, 콘텐츠 영역의 클릭은 e.stopPropagation()으로 막았습니다. 클릭 이벤트가 배경까지 전파되지 않아야 콘텐츠를 클릭해도 모달이 닫히지 않습니다.

배경 스크롤 잠금

const useBodyScrollLock = () => {
  useEffect(() => {
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = prevOverflow;
    };
  }, []);
};

모달이 열려 있을 때 배경이 스크롤되면 사용자가 현재 어떤 컨텍스트에 있는지 혼란스러워집니다. 이전 값을 저장했다가 복원하는 이유는, 모달이 열리기 전에 이미 overflow가 다른 값으로 설정되어 있을 수 있기 때문입니다. 단순히 ""로 초기화하면 기존 스타일을 잃을 수 있습니다.

폼 필드의 접근성 속성

각 필드에는 aria-required, aria-invalid, aria-describedby를 함께 적용했습니다.

<input
  id="email"
  type="email"
  value={formData.email}
  onChange={handleChange}
  required
  aria-required="true"
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
<S.ErrorMessageSlot>
  {errors.email && (
    <S.ErrorMessage id="email-error" role="alert">
      {errors.email}
    </S.ErrorMessage>
  )}
</S.ErrorMessageSlot>

requiredaria-required="true"를 함께 쓴 이유는 브라우저 기본 유효성 검사와 스크린리더 지원을 모두 챙기기 위해서인데, 이 폼에서는 noValidate로 브라우저 기본 검사를 비활성화했으므로 실질적인 유효성 검사는 직접 구현한 함수가 담당합니다.

aria-invalid는 오류가 없을 때 아예 제거하는 쪽을 선택했습니다. 항상 false를 명시하는 것보다, 오류가 생겼을 때의 상태 변화를 스크린리더가 더 명확하게 감지할 수 있습니다.

aria-describedby는 오류 메시지가 존재할 때만 연결합니다. 오류가 없을 때 빈 요소를 가리키게 하면 스크린리더가 불필요한 정보를 읽게 됩니다.

오류 메시지에 role="alert"를 적용한 이유는 콘텐츠가 동적으로 나타날 때 스크린리더가 즉시 읽어주게 하기 위해서입니다. role="alert"는 라이브 리전으로, 콘텐츠가 추가되면 스크린리더가 현재 읽던 것을 멈추고 해당 내용을 읽어줍니다. 오류 메시지는 사용자가 즉시 인지해야 하므로 이 동작이 적합합니다.

개발자 경험과 접근성의 균형

이 설계에서 접근성 속성의 상당 부분은 Modal 컴포넌트 안에 고정되어 있습니다. aria-modal, role="dialog", 포커스 이동과 포커스 트랩도 Modal 내부에서 처리되기 때문에, openModal을 사용할 때 이 속성들을 따로 신경 쓸 필요가 없습니다.

const result = await openModal(({ close }) => (
  <>
    <FormTitle>신청 폼</FormTitle>
    <ApplicationForm ... />
  </>
));

마무리

<dialog> 태그를 쓰지 않기로 결정하고 나니, 브라우저가 당연하게 처리해주던 것들을 하나씩 직접 구현해야 했습니다. 처음에는 단순히 "모달을 띄우는 UI"라고 생각했지만, 실제로는 훨씬 더 많은 책임을 가지고 있었습니다.

포커스는 어디로 이동해야 하는지, Tab을 눌렀을 때 어디까지 이동할 수 있는지, ESC 키는 어떤 범위에서 동작해야 하는지, 모달이 닫힌 뒤 사용자는 어디로 돌아가야 하는지까지 모두 직접 정의해야 했습니다.

특히 인상 깊었던 점은, 접근성이 "추가 기능"이 아니라는 점이었습니다. 포커스 트랩이 없으면 키보드 사용자는 모달을 벗어나 버리고, aria 속성이 없으면 스크린리더 사용자는 현재 무엇이 열려 있는지조차 알 수 없습니다. 즉, 접근성은 있으면 더 좋은 것이 아니라, 없으면 아예 사용할 수 없는 상태가 됩니다.

결과적으로 이 과정을 통해 얻은 것은 단순한 모달 컴포넌트 하나가 아니라, 평소에는 의식하지 않던 포커스 흐름과 상호작용 모델을 직접 다뤄보면서, UI를 "보이는 것"이 아니라 "사용되는 것"의 관점에서 바라보게 되었습니다.

앞으로는 단순히 기능을 구현하는 것을 넘어서, 키보드 사용자와 스크린리더 사용자까지 포함한 전체 사용자 경험을 기준으로 컴포넌트를 설계하려고 합니다.

작성한 코드는 https://github.com/mlnwns/accessibility-friendly-modal-form/tree/study 에서 확인 가능합니다.

0개의 댓글