모달 포커스부터 클릭 큐잉까지, 프론트엔드가 놓치는 UX 사각지대

모달 포커스부터 클릭 큐잉까지, 프론트엔드가 놓치는 UX 사각지대

접근성, 포커스 트랩, 이벤트 루프—세 가지 '보이지 않는 버그'가 사용자 경험을 조용히 무너뜨리는 경로를 해부합니다.

모달 포커스 관리 클릭 큐잉 이벤트 루프 블로킹 웹 접근성 프론트엔드 UX ARIA focus trap 싱글 스레드
광고

사용자가 모달 '확인' 버튼을 눌렀는데 포커스가 모달 뒤쪽 페이지로 빠지는 순간, 스크린리더 사용자는 자기가 어디에 있는지 완전히 잃어버립니다. 동기 루프가 메인 스레드를 10초간 점유하는 동안 쌓인 클릭 이벤트가 한꺼번에 발화하면, 사용자 입장에서는 "버튼이 안 먹히다가 갑자기 세 번 결제됐다"는 공포를 경험합니다. Figma에서 볼 때는 괜찮았는데, 실제로 구현하면 드러나는 이런 사각지대—접근성, 포커스 관리, 이벤트 루프 블로킹—을 하나의 'UX 품질' 프레임으로 묶어 볼 필요가 있습니다.

모달, 열기보다 닫기가 더 어렵다

dev.to에 게시된 Vue 접근성 실무 팁 글(Jacobandrewsky)은 모달 포커스 관리의 세 단계를 명확히 짚습니다. 열릴 때 포커스를 모달 안으로 이동 → 내부에 포커스를 가둠 → 닫힐 때 원래 트리거 요소로 복귀. 단순해 보이지만, 실무에서는 nextTick 타이밍을 놓치거나 tabindex="-1"을 빠뜨리는 것만으로도 포커스 트랩이 깨집니다. SPA에서 라우트가 바뀔 때도 동일한 문제가 발생하는데, 스크린리더는 클라이언트 사이드 네비게이션을 인지하지 못하기 때문에 h1에 포커스를 수동으로 옮겨줘야 합니다. 이거 px 단위로 여백 맞추듯 포커스도 한 틱 단위로 맞춰야 해요.

velog에 올라온 React 명령형 모달 구현 가이드(lywoo00)는 이 문제를 아키텍처 레벨에서 접근합니다. Context API + createPortal + Promise 기반 modal.confirm()이라는 조합인데, createPortal로 DOM 계층을 분리해 z-index 이슈를 원천 차단하고, Promise로 사용자 응답을 await할 수 있게 만드는 구조죠. 기획자가 "확인 누르면 다음 스텝, 취소면 이전 스텝"이라고 설계했을 때, 이 패턴이면 코드 흐름이 기획 의도와 1:1로 대응합니다.

그런데 여기서 한 가지 빠진 게 있습니다. 두 글 모두 모달이 열리는 순간의 포커스 이동은 다루지만, 모달 내부에서 비동기 작업이 메인 스레드를 점유할 때 발생하는 인터랙션 큐잉 문제는 언급하지 않습니다.

클릭이 "쌓인다"는 건, 시스템이 설계된 대로 작동한다는 뜻이다

dev.to의 이벤트 루프 블로킹 글(codewithnuh)이 정확히 이 지점을 찌릅니다. 동기 루프가 콜 스택을 점유하면 이벤트 루프는 매크로태스크 큐를 확인할 수 없고, 그 사이 OS 레벨 인풋 버퍼에 쌓인 클릭 이벤트가 루프 해제 직후 한꺼번에 실행됩니다. JavaScript는 싱글 스레드지만, OS는 아니라는 사실—이게 "버튼을 세 번 눌렀는데 세 번 다 실행됐다"는 사용자 불만의 근본 원인입니다.

사용자 입장에서는 모달 안에서 '결제 확인'을 눌렀고, 반응이 없어서 한 번 더 눌렀을 뿐입니다. 하지만 그 두 번의 클릭은 OS가 성실하게 버퍼링해두었다가, 서버 응답이 돌아오고 콜 스택이 비는 순간 연속 발화합니다. 로딩 스켈레톤 넣으면 어떨까요? 아니요, 그것만으로는 부족합니다. 클릭 디바운싱, 버튼 disabled 상태 즉시 전환, 혹은 AbortController를 통한 중복 요청 취소까지 가야 실제 방어가 됩니다.

세 문제는 하나의 뿌리를 공유한다

포커스 유실, 접근성 누락, 클릭 큐잉—이 셋은 결국 "사용자의 현재 상태와 시스템의 내부 상태가 동기화되지 않는 순간"에 발생합니다. 포커스가 모달 밖으로 빠지는 건 UI 상태와 DOM 포커스 상태의 불일치이고, 클릭 큐잉은 사용자의 인지 상태와 이벤트 루프 상태의 불일치입니다. Lighthouse 점수 100을 받아도, Core Web Vitals가 초록불이어도, 이런 상태 불일치 앞에서는 사용자 경험이 무너집니다.

실무에서 당장 점검할 체크리스트

결국 이건 번들 사이즈나 프레임워크 선택 같은 거창한 문제가 아닙니다. 의도적이고 사소한 변경의 문제입니다.

  • 모달 열림 시 nextTick(혹은 useEffect) 후 포커스를 내부 첫 요소로 이동하고 있는가?
  • 모달 닫힘 시 트리거 요소로 포커스를 복귀시키는가?
  • SPA 라우트 전환 후 h1이나 메인 콘텐츠에 포커스를 설정하는가?
  • 비동기 작업 중 동일 액션 버튼의 중복 클릭을 방어하고 있는가?
  • <div> 대신 <button>을, 커스텀 ARIA 대신 시맨틱 HTML을 우선 사용하는가?

사실 이건 flexbox로 해결되는 류의 문제가 아닙니다. 브라우저 네이티브 <dialog> 요소가 포커스 트랩과 Escape 닫기를 내장하고 있고(이전 배포 글 '브라우저가 이미 해결한 프론트엔드 문제 3가지'에서 다룬 바 있죠), Container Queries나 :has() 같은 새 CSS 기능이 레이아웃 복잡성을 줄여주듯, 플랫폼이 이미 제공하는 접근성 프리미티브를 먼저 활용하고, 부족한 부분만 코드로 메우는 전략이 장기적으로 유지보수 비용을 낮춥니다.

프론트엔드의 품질은 눈에 보이는 UI 완성도가 아니라, 눈에 보이지 않는 상태 동기화의 정밀도에서 갈립니다. 포커스가 1px 어긋나는 건 CSS로 잡으면 되지만, 포커스가 통째로 사라지는 건 아키텍처로 잡아야 합니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요