포커스 트랩 구현기

안녕하세요! 저는 시각 정보 해독이 어려운 사용자를 위한 크롬 확장 프로그램인 Voim의 프론트엔드 개발자 최호입니다.
저희 프로젝트 VOIM은 약시 등 시각 약화 사용자들이 웹 콘텐츠를 더 쉽게 이해할 수 있도록 돕는 다양한 기능을 제공합니다.
VOIM 확장 프로그램은 크롬 웹 스토어에서 다운로드할 수 있으며, 사용자 피드백과 접근성 향상에 큰 힘이 되고 있습니다.
이번 글에서는 VOIM 확장 프로그램의 메뉴바 UI 개발 과정에서 마주친 중요한 접근성 문제, 그리고 이를 해결하기 위해 구현한 포커스 트랩에 대해 자세히 소개하려 합니다.
특히 키보드 사용자와 시각 약화 사용자에게 더 나은 경험을 제공하기 위한 포커스 제어 기술에 관심 있으신 분들에게 유익한 내용이 되길 바랍니다.

1. 도입 – 왜 포커스 트랩이 필요했는가

크롬 익스텐션의 메뉴바 UI를 개발하는 과정에서 사용자 경험(UX) 측면에서 중요한 문제를 발견했습니다.
키보드 Tab 키를 이용해 메뉴 내의 각 요소를 탐색할 때, 포커스가 메뉴 영역 밖으로 빠져나가 버리는 현상이었죠.
이 문제는 특히 약시 등 시각 장애를 가진 사용자에게 심각한 불편을 줍니다.
키보드만으로 UI를 조작하는 환경에서 포커스가 예기치 않게 빠져나가면 사용자가 혼란을 느끼고, 원하는 작업을 원활하게 수행하기 어렵기 때문입니다.
게다가 크롬 익스텐션이라는 특수한 실행 환경은 포커스 제어를 더욱 까다롭게 만들어, 이를 해결하기 위한 별도의 접근법이 필요했습니다.
그래서 저는 이 문제를 해결하고 접근성을 개선하기 위해 ‘포커스 트랩(Focus Trap)’ 구현에 도전하게 되었습니다.
👇 아래 GIF는 포커스 트랩을 적용하기 전과 후의 동작 차이를 보여줍니다.

적용전

포커스가 익스텐션 뒤로 들어가는 모습입니다.
fucos trap 적용 전

적용후

포커스가 익스텐션 내에서만 이동하는 모습입니다.
fucos trap 적용 후

2. 포커스 트랩이란?

포커스 트랩(Focus Trap)은 특정 UI 영역 안에서만 키보드 포커스가 이동하도록 제한하는 기술입니다.
예를 들어 모달, 드롭다운, 사이드바 같은 컴포넌트에서 Tab 키를 눌러 다음 요소로 이동할 때, 포커스가 그 영역을 벗어나지 않고 다시 처음 요소로 돌아오게 만드는 동작입니다.
이렇게 하면 키보드 사용자(특히 시각 장애나 약시 사용자)가 UI 내에서 포커스가 예상치 못하게 빠져나가 길을 잃는 상황을 방지할 수 있습니다.
구현할 때는 다음과 같은 점들이 중요합니다:
  • 포커스 가능한 요소 찾기: button, input, a[href], [tabindex]:not([tabindex="-1"]) 같은 포커스 가능한 요소들을 동적으로 찾아야 합니다.
  • Tab/Shift+Tab 키 제어: 사용자가 Tab 키를 누르면 다음 포커스 가능한 요소로, Shift+Tab은 이전 요소로 이동하게 제어합니다.
  • 포커스 순환 처리: 마지막 요소에서 Tab을 누르면 다시 첫 번째 요소로, 첫 번째 요소에서 Shift+Tab을 누르면 마지막 요소로 포커스를 이동시켜야 합니다.
저는 이 부분을 구현할 때 WAI-ARIA Authoring Practices를 참고했고, 널리 사용되는 오픈소스 라이브러리 focus-trap의 동작 방식을 분석해 방향을 잡았습니다.
하지만 포커스 트랩을 적용할 때는 포커스 트랩에서 탈출할 방법도 반드시 구현해야 합니다.
예를 들어 Esc 키를 눌러 포커스 제한을 해제하거나, 닫기 버튼 클릭 시 포커스를 원래 위치로 돌려주는 방식입니다.
만약 탈출 방법이 없다면 사용자가 UI에 갇혀버리는 불편함을 겪을 수 있어 접근성 측면에서 매우 큰 문제가 됩니다.
따라서 포커스 트랩 구현 시에는 포커스 제한포커스 해제를 모두 고려해야합니다.

3. 메뉴 안에서 포커스가 순환되도록 구현하기

저희 메뉴바 UI에서는 다음과 같은 포커스 흐름을 목표로 구현했습니다:
[MenubarButton] → [PanelContent 내부의 포커스 가능한 요소들] → 다시 [MenubarButton]
즉, 사용자가 메뉴 버튼을 눌러 메뉴 패널을 열면, 패널 내부에 있는 여러 포커스 가능한 요소들을 순차적으로 Tab 키로 탐색할 수 있습니다.
그리고 마지막 요소에서 다시 Tab을 누르면 포커스가 처음의 메뉴 버튼으로 돌아오도록 만들어, 포커스가 메뉴 영역을 벗어나지 않고 계속 순환되도록 했습니다.
이 순환 구조 덕분에 사용자는 메뉴 안에서 자유롭게 키보드로 조작할 수 있으며, 실수로 포커스가 다른 UI 영역으로 빠져나가 길을 잃는 불편함을 방지할 수 있습니다.

4. 실제 코드와 설명

포커스 트랩 구현의 핵심 로직은 크게 두 가지입니다.
먼저, 메뉴 패널 내에 있는 모든 포커스 가능한 요소(focusable elements) 를 찾아야 합니다.
포커스 가능한 요소란 기본적으로 button, a[href], input, select, textarea와 같이 키보드로 포커스가 가능한 태그들이며, [tabindex] 속성이 설정된 요소도 포함합니다.
특히 tabindex="-1"인 요소는 키보드 포커스 대상에서 제외되기 때문에 이를 필터링합니다.
const focusableElements = panelRef.current?.querySelectorAll(
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
이 배열에서 첫 번째와 마지막 요소를 기준으로 포커스 순환을 제어합니다.
다음으로, keydown 이벤트 핸들러에서 Tab 키 입력을 감지합니다.
Tab 키가 눌렸을 때, 현재 포커스가 첫 번째 요소(역방향 이동 시) 혹은 마지막 요소(순방향 이동 시)에 있을 경우 기본 동작을 막고 순환하도록 포커스를 강제로 이동시킵니다.
const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key !== "Tab") return;

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

  if (e.shiftKey && document.activeElement === first) {
    // Shift + Tab: 첫 번째 요소에서 이전으로 가려 할 때 마지막 요소로 이동
    e.preventDefault();
    last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    // Tab: 마지막 요소에서 다음으로 가려 할 때 첫 번째 요소로 이동
    e.preventDefault();
    first.focus();
  }
};
이렇게 하면 Shift + Tab을 눌러 첫 번째 요소에서 역방향 순환, 일반 Tab을 눌러 마지막 요소에서 순방향 순환이 가능해집니다.

tabindex란?

tabindex 속성은 HTML 요소의 키보드 포커스 순서를 제어하는 데 사용됩니다.
  • 기본적으로는 button, a[href] 등 포커스 가능한 요소가 자연스러운 순서대로 포커스됩니다.
  • `tabindex="0"은 해당 요소를 일반 포커스 순서에 포함시킵니다.
  • `tabindex=-1"은 포커스 순서에서 제외하지만, 스크립트로 포커스를 줄 수는 있습니다.
  • 양의 정수(tabindex="1" 등)는 권장하지 않으며, 순서를 직접 지정하는 대신 자연스러운 DOM 순서와 tabindex="0"을 사용하는 것이 좋습니다.
포커스 트랩을 구현할 때는 tabindex="-1"인 요소는 제외해 포커스가 순환하지 않도록 하는 게 중요합니다.

5. 크롬 익스텐션 환경에서의 포커스 트랩 트러블슈팅

메뉴 버튼과 패널 내부 요소 간의 포커스 이동 순서를 제어하는 과정에서 몇 가지 중요한 고민과 도전이 있었습니다.
  • 포커스 이동 시점 관리 메뉴 패널이 열리거나 닫힐 때, 포커스가 적절한 요소로 정확히 이동하도록 타이밍을 맞추는 것이 매우 중요했습니다. 예를 들어 패널이 열리자마자 첫 번째 포커스 가능한 요소로 포커스를 이동시켜야 하는데, DOM이 완전히 렌더링되기 전에는 포커스 이동이 실패할 수 있어 useEffectsetTimeout 등으로 렌더링 완료를 보장하는 처리가 필요했습니다.
  • 크롬 익스텐션 콘텐츠 스크립트 환경 특성 크롬 익스텐션에서는 일반 웹 페이지와 달리 콘텐츠 스크립트가 삽입된 환경이라, 포커스 이벤트가 예상치 못한 방식으로 동작하거나 제한될 수 있습니다. 이에 따라 DOM 접근 시점과 이벤트 등록 타이밍을 세밀하게 조절해야 했고, 이벤트 위임 방식도 고민하게 되었습니다.
  • preventDefault 호출 위치 주의 키보드 이벤트에서 preventDefault()를 호출하는 시점이 포커스 제어에 큰 영향을 미칩니다. 너무 일찍 호출하면 기본 동작이 막혀 예상치 못한 포커스 이동이 발생할 수 있고, 너무 늦으면 순환 로직이 작동하지 않습니다. 그래서 Tab 키 여부 및 현재 포커스 위치를 먼저 판단한 후에 적절히 호출하도록 신경 썼습니다.
이러한 고민들을 해결하면서 포커스 트랩 구현에 필요한 미묘한 타이밍과 환경 차이를 깊이 이해할 수 있었습니다.

6. 개선 아이디어 또는 보완할 점

이번 프로젝트에서는 직접 포커스 트랩 로직을 구현하며 많은 인사이트를 얻었지만, 유지보수성과 안정성을 위해 검증된 라이브러리 도입을 적극 고려하고 있습니다.
  • react-focus-lock React 환경에 최적화된 포커스 락 라이브러리로, 다양한 접근성 시나리오를 지원하고 복잡한 포커스 제어를 손쉽게 처리할 수 있습니다.
  • focus-trap-react 저수준 focus-trap 라이브러리를 React 컴포넌트로 감싼 형태로, 포커스 순환과 포커스 아웃 방지를 강력하게 지원합니다.
이와 함께 단순히 포커스 이동만 처리하는 것을 넘어서, WAI-ARIA 권고사항에 따른 다음 속성들을 적극 활용해 접근성을 더욱 강화할 예정입니다.
  • role="dialog" 또는 role="menu" 스크린 리더에게 현재 영역의 의미를 명확히 알립니다.
  • aria-modal="true" 모달 다이얼로그임을 명시해 배경 콘텐츠와의 상호작용 제한을 알립니다.
  • aria-labelledbyaria-describedby 사용자에게 현재 UI 영역에 대한 추가 설명과 컨텍스트를 제공합니다.
앞으로도 사용자 경험과 접근성 모두를 만족시키는 UI 개발을 위해 지속해서 개선해 나가겠습니다.

7. 마무리

이번 포커스 트랩 구현을 통해 UI의 접근성과 사용자 경험이 크게 향상됨을 직접 체감할 수 있었습니다.
키보드 사용자뿐 아니라 스크린 리더 사용자에게도 한층 친화적인 인터페이스를 제공하는 데 중요한 첫걸음이었죠.
실제로 시각장애인 지원센터를 방문해 VOIM 확장 프로그램을 소개했을 때, 현장에서 “실제로 있으면 정말 유용할 것 같다”는 긍정적인 피드백도 받았습니다.
이런 실사용자의 반응은 저에게 큰 동기부여가 되었습니다.
저는 이번 메뉴바에 적용한 경험을 바탕으로, 앞으로 모달, 드롭다운 등 다양한 UI 컴포넌트에도 포커스 트랩을 확대 적용할 계획입니다.
VOIM은 동아리에서 시작된 작은 프로젝트이지만, 시각 정보 접근에 어려움을 겪는 분들에게 실질적인 도움을 주고, 정보 양극화를 조금이나마 해소하는 데 기여하고자 하는 마음으로 개발하고 있습니다.
앞으로도 꾸준히 개선하며 더 많은 사람에게 편리하고 포용적인 디지털 환경을 제공하기 위해 노력하겠습니다.