aria-hidden을 막는 방법: 경고가 맞으며, 찾은 모든 해결책이 틀렸다
요약
모달 다이얼로그를 닫을 때 발생하는 접근성 경고와 그 근본적인 원인을 분석합니다. 기존의 `blur()`나 `aria-hidden` 제거 같은 해결책들은 임시방편일 뿐이며, 가장 좋은 방법은 네이티브 `<dialog>` 요소를 사용하는 것입니다. 핵심은 포커스가 숨겨지거나 비활성화되기 전에 반드시 원래 위치로 돌아가도록 순서를 재배열하는 것입니다.
핵심 포인트
- 모달 닫기 시 발생하는 경고는 접근성 문제이며 무시해서는 안 됩니다.
- 단순히 `blur()`를 호출하거나 속성을 제거하는 것은 근본적인 해결책이 아닙니다.
- 네이티브 `<dialog>` 요소를 사용하고 `.showModal()`로 마이그레이션 하는 것이 가장 권장됩니다.
- 핵심은 포커스가 숨겨지기 전에 원래의 위치(포커스 원점)로 돌아가도록 순서를 재배열하는 것입니다.
다이얼로그를 닫았는데 콘솔이 그 특유의 화난 겨자색으로 변했어요. 메시지를 하이라이트해서 검색창에 붙여넣었더니, 이 정확한 문자열이 Angular, Bootstrap, Ionic 또는 phpMyAdmin에서든 동일하게 나오면서 전방위적인 프론트엔드 인터넷을 쏟아냈습니다.
가장 상위에 노출된 결과들이 숨기는 부분이 여기 있습니다: 그 경고는 올바릅니다. 그 뒤에는 실제 사람이 존재합니다. 스크린 리더를 사용하는 사람으로, 이 사람의 포커스가 여러분 페이지의 구멍 속으로 떨어지기 직전입니다.
그리고 지금 저보다 높은 순위에 있는 모든 해결책들은 이름만 다를 뿐 같은 일을 합니다: blur() 한 줄 코드, 닫는 동작을 감싸는 setTimeout, aria-hidden 속성을 제거하는 트릭 등.
이 모든 방법은 콘솔의 경고음은 잠재우지만, 브라우저가 보호하려던 사람에게 조용히 상처를 입힙니다. 만약 여러분이 이미 이 중 하나를 배포했다면, 엄청나게 많은 동료들과 같은 처지입니다. 여러분은 부주의해서 실패한 것이 아니라, 검색 결과에 의해 실패한 것입니다.
저도 알아요. 저 역시 하나를 배포했으니까요.
다른 단어를 읽기 전에 솔직한 지름길을 알려드릴게요: 만약 네이티브 <dialog> 요소를 사용하고 .showModal()을 호출하는 방식으로 마이그레이션할 수 있다면, 그렇게 하세요. 이 탭을 닫고 오후 시간을 되찾으세요. 왜냐하면 브라우저가 전체 포커스 이동 과정을 알아서 처리해주기 때문에 이런 종류의 버그는 거의 사라지거든요. (물론 요소의 포커스가 돌아가야 할 경우 해당 요소가 DOM에서 제거되는 상황은 여전히 처리해야 합니다. 이는 어떤 브라우저도 여러분을 대신해 추측할 수 없는 일입니다.) 그 이후 내용은 우리 같은 사람들을 위한 것입니다. 우리는 이번 분기에 뜯어낼 수 없는 컴포넌트 라이브러리나 디자인 시스템에 연결되어 있기 때문입니다.
서두르는 경우의 해결책
전체 내용이 한 문장에 담기며, 만약 이 문장 하나만 읽는다면 저보다 위에 노출된 대부분의 정보들보다 앞서 나갈 수 있을 것입니다: 포커스는 해당 영역이 숨겨지거나 비활성화되기 전에 반드시 그 영역을 벗어나야 한다. 그것뿐입니다. 여기에 있는 나머지 모든 내용은 각주, 예외 사례, 그리고 제가 이 지식을 값비싼 방법으로 배우게 된 이야기일 뿐입니다.
실제로는 순서의 문제입니다. 대부분의 모달 코드는 이미 올바른 조각들을 가지고 있지만, 단지 순서가 잘못되었을 뿐입니다. 해결책은 순전히 재배열에 불과하며, 아래 두 버전이 이를 직접 보여주고 있습니다. 주석에는 거의 모든 사람이 건너뛰는 한 단계(닫히는 오버레이 자체를 비활성화하는 것)도 언급되어 있습니다.
// 잘못된 방식: 대부분의 모달 코드가 사용하는 순서
function closeModal() {
// 포커스가 여전히 안에 있는 동안 배경을 숨김 → 고스트 포커스
...
이 잘못된 버전은 누군가 부주의해서 틀린 것이 아닙니다. 이 코드는 위에서 아래로, 마치 모달을 입으로 설명하듯이 읽힙니다.
하지만 브라우저는 그 구문이 실행되는 순간 바로 숨김 처리를 적용합니다. 다음 줄에서 포커스가 이동하기도 전에 말이죠. 이것은 모두 하나의 동기적인 작업입니다. 문제는 시간상의 문자 그대로의 간격에 있는 것이 아니라, 이 두 구문 사이에 존재하는 유효하지 않은 상태(invalid state)와 순서에 있습니다.
순서를 제대로 맞추면 결과는 지루합니다. 그리고 그것이 바로 핵심입니다. 사용자가 Esc 키를 누르면, 포커스가 그 모달을 열 때 사용했던 버튼으로 돌아가고, 계속 진행할 수 있습니다.
만약 순서가 잘못되면, 포커스는 <body> 태그로 이동하고,
침묵이나 단순히 페이지 제목 소리만 들리며, 사용자는 길게 늘어진 페이지의 맨 위부터 자신이 있던 곳까지 Tab 키를 눌러 돌아와야 합니다. 바로 이 두 번째 경험을 경고가 막기 위해 존재하는 것이며, 이것이 blur()
함수가 사용자에게 매번 제공하는 것입니다.
다른 탭에서 이미 열어두었을 법한 함정들, 즉 blur()
원라인 코드, setTimeout
래퍼(shim), 그리고 Radix나 shadcn의 aria-hidden
제거 및 modal={false} 속성에 대해 이야기해 보겠습니다. 각각은 나중에 제대로 분해할 것이기 때문입니다. 왜냐하면 이 모든 것들이 어리석은 실수가 아니라, 그럴듯하게 보이는 실수들이기 때문입니다.
그 경고는 Chrome이 스타일 가이드의 사소한 부분에 대해 잔소리를 하는 것이 아닙니다. 그것은 브라우저가 당신의 아키텍처를 고발하는 것입니다.
Chrome이 경고하는 것이 아닙니다. 당신을 무효화(overruling)하고 있습니다.
당신은
그 단어는 큰 문제를 일으키는데, 왜냐하면 그것이 '권고 사항'임을 알려주기 때문인데, 실제로는 그렇지 않기 때문입니다. 메시지를 보는 시점에는 브라우저가 사용자의 마크업을 검토하고, 사용자가 틀렸다고 판단하여 작성한 것과는 다른 접근성 트리(accessibility tree)를 전송했기 때문입니다.
이것을 유발하는 모달 창을 열고 Elements 패널을 살펴보세요. 배경 래퍼에 aria-hidden="true"가 그대로 남아 있습니다. DOM 인스펙터에서 거짓인 것은 아무것도 없습니다.
이제 Accessibility 패널로 전환하여 Chrome이 실제로 운영체제의 스크린 리더 API에 전달한 트리를 확인하세요. 숨기라고 지시했던 서브트리(subtree)는 여전히 그곳에, 노출되어 있고, 완전히 읽을 수 있는 상태입니다.

#page 래퍼가 aria-hidden="true"를 가지고 있지만 (포커스된 버튼 이름을 명시하는 콘솔 경고 참조), 포커스 가능한 링크를 포함한 전체 서브트리가 여전히 접근성 트리에서 노출되어 있습니다. 그 이유는 포커스된 요소가 그 안에 남아 있기 때문입니다. Blink는 이 속성을 읽었고, 해당 서브트리 내에 존재하는 포커스된 노드를 보고, aria-hidden을 무시하며 포커스된 노드의 조상 체인(ancestor chain)을 따라 위로 올라갔습니다. 포커스가 벗어나는 순간, 가지치기 작업이 되돌아가면서 해당 영역은 요청한 대로 숨겨집니다.
따라서 사용자가 전송했다고 생각하는 상태—즉, 그 영역이 보조 기술(assistive tech)에 보이지 않는 상태—는 어떤 스크린 리더도 받는 상태가 아닙니다. 그것은 오직 Elements 패널과 사용자 본인의 머릿속에만 존재합니다.
두 패널 사이의 이 간극이야말로 전체 버그이며, 이는 aria-hidden에 내재된 역설(paradox)에서 비롯됩니다.
이 속성은 콘텐츠를 접근성 트리에서 제거합니다. 하지만 그 콘텐츠를 키보드 포커스 순서(keyboard focus order)에서도 제거하지는 않습니다. 두 개의 다른 시스템이 존재하며, 이 둘을 동기화하는 것은 아무것도 없습니다.
따라서 하나의 요소가 완전히 포커스 가능하면서도 동시에 완전히 인지할 수 없는 상태일 수 있습니다. Tab 키를 누르는 순간, 당신은 제가 *유령 포커스(ghost focus)*라고 부르게 된 것을 만들어낸 것입니다. 스크린 리더는 존재하지 않는다고 지시받은 노드에 대해 포커스 이벤트를 발생시키고, 이를 찾아보지만 설명할 수 있는 것이 없음을 발견하고 아무 말도 하지 않습니다.
화면 반대편에서 느껴보세요. 누군가 Tab 키를 눌렀습니다. 포커스가 이동하고, 컨트롤이 활성화되며, 스크린 리더는 침묵합니다. “버튼입니다, 대화 상자입니다.” 필드 레이블도 아닙니다. 침묵입니다.
그들은 키를 눌렀지만, 기계는 아무것도 인지하지 못했고, 이제 그들은 앱이 고장 난 것인지, 보조 기술(assistive tech)이 충돌한 것인지, 아니면 자신이 뭔가 잘못한 것인지 알 수 없습니다. 그들은 지도상에는 존재하지 않는 방 한가운데 서 있고, 유일한 탈출구는 눈을 가리고 계속 Tab 키를 누르며 무언가가 결국 말해주기를 바라는 것입니다.
이것이 Chrome이 사용자를 제어할 때 막아주는 것입니다. 혼자 두면, 포커스가 맞춰진 컨트롤 위에서 aria-hidden은 아무것도 친절하게 숨기지 않습니다. 레이블을 숨기고 포커스는 유지하는데, 이것이 최악의 조합입니다.
Chromium은 경고가 나오기 훨씬 전부터 조용히 이 문제를 패치해 왔습니다. 제가 재구성할 수 있는 한, 해당 기능은 두 번에 걸쳐 크게 개선되었으며, 저는 명확하게 지적할 수 있는 변경 로그보다는 버그 보고서들이 모여들었던 시점을 통해 그 타이밍을 맞춰가고 있습니다.
요소에 “방금 포커스를 받았음”이라고 꾸짖는 ‘개방 시간(open-time) 변형’은 2024년 여름 Chrome 127 전반의 트래커에서 나타나기 시작하여, MUI #43106, Ant Design #50170, Flowbite #943 등에서 7월과 8월에 집중되었습니다.
‘포커스 유지(retained focus)’라는 문구와 함께 오는 ‘종료 시간(close-time) 변형’은 몇 달 후인 2024년 말 Chrome 131 전후로 도착했습니다. 11월 5일에 Bootstrap #41005를 제출한 사람이 이를 실시간으로 포착했으며, 131 베타 및 Nightly 빌드에서는 보이지만 아직 안정 버전은 아니라고 언급했고, 12월의 Angular #30187에서도 일치하는 바가 있습니다. 두 번의 물결 속에 하나의 동작 원리가 숨어있습니다.
이러한 동작 방식은 오래된 것입니다. Chromium은 이미 2020년대 초반부터 포커스 가능한 aria-hidden 노출을 하고 있었습니다. 우리는 ARIA WG 이슈 #1185가 Chrome 접근성 엔지니어 Aaron Leventhal이 정확히 이를 제안했던 기록을 통해 알고 있습니다. 즉, 사용자들이 “완전한 침묵 대신 적어도 자신이 어디로 Tab 키를 누르고 있는지 들을 수 있도록” 하기 위함이었습니다.
그가 반대하고 있던 패턴은 더 오래전으로 거슬러 올라가, 팀들이 <body 태그에 aria-hidden을 붙였던 시기로까지 갑니다.
또는 모달(modal)이 열릴 때 거대한 래퍼(wrapper)가 생기는 경우입니다. 포털(portal) 및 마크업(markup) 실수로 인해 이 모달 자체가 가려지면서 화면 리더(screen reader)가 전체 페이지에 접근하지 못하게 막기도 합니다. 사람들은 2019년 Bootstrap #29769부터 이러한 실패에 대해 논쟁하는 것을 지켜볼 수 있습니다.
솔직히 말하자면, 당신의 테어다운 코드(teardown code)는 이 문제가 콘솔에 나타나기 훨씬 전부터 이미 망가져 있었습니다. 그 수정 작업은 내내 조용히 진행되고 있었고, 이것이 바로 아무도 그것을 고치지 못한 이유입니다.
제가 테스트해 본 한도 내에서는 Firefox와 Safari는 이에 필적하는 콘솔 경고를 표시하지 않습니다. 각 엔진이 숨겨진 서브트리(hidden subtree) 내부의 포커스된 콘텐츠(focused content)에 대해 어떤 조치를 취하든, 그것을 알려주지 않고 처리합니다. Chrome은 사용자에게 그 사실을 느끼게 하기로 결정했고, 어조가 어떻든 간에 저는 그 결정이 옳았다고 생각합니다. 조용한 수정은 망가진 코드가 영원히 배포되도록 내버려 둡니다. 왜냐하면 브라우저가 당신의 실수를 가리는 것이 앉아 있는 자리에서, 코드 자체가 올바른 것처럼 구별할 수 없기 때문입니다. 시끄러운 것은 불편하지만, 시끄러운 것은 정직합니다.
만약 Chrome이 당신의 트리를 수정하고 있다면, 질문은 더 이상 “어떻게 하면 이 메시지를 사라지게 할까?”가 아니라 “내 코드가 숨겨진 영역(hidden region) 내부에 포커스된 노드(focused node)를 정확히 어떤 순간에 생성하는가?”로 바뀝니다. 알고 보니 네 가지의 뚜렷한 순간이 있으며, 각각 고유한 형태를 가지고 있습니다.
여기에 도달하는 네 가지 방법
이 모든 경우는 같은 지점에서 끝납니다: 포커스가 방금 숨겨진 영역 내부에 위치하게 되는 것입니다. 하지만 이들은 네 가지 다른 방향에서 도착하며, 어느 쪽을 보고 있는지 모른다면 잘못된 수정을 적용하여 경고를 없애지 못하거나 더 나쁜 것을 망가뜨리면서 없애게 될 것입니다. 제가 가졌으면 좋았을 지도를 여기 정리했습니다. 대략 여러분 중 몇 명이 각 경우에 해당되는지에 따라 정렬했습니다.
심층 분석에 앞서, 자신에게 맞는 유형을 찾을 수 있도록 빠른 분류(triage)를 해드리겠습니다. 만약 모달을 닫을 때 경고가 발생한다면, 닫는 시간의 경쟁(close-time race) 문제입니다. 만약 모달이 열리는 순간에 경고가 발생한다면, 여는 시간의 역전(open-time inversion)입니다. 만약 <select>, popover, 또는 드롭다운이 <dialog> 내부에 중첩되어 있다면...
, 그것은 컴포지션 영역의 전쟁터이며, React 19에서 치명적으로 변합니다. 만약 페이지에 아무것도 변경되지 않았는데도 Alt-Tab을 하거나 오버레이가 열린 상태로 다른 탭으로 전환했을 때 이 문제가 발생한다면, 포커스가 페이지를 벗어납니다. 자신의 증상을 찾고, 그에 맞는 내용을 읽으세요.
사라지는 과정 중 숨겨짐 (닫히는 시간의 경쟁)
사용자가 닫기 버튼을 클릭합니다. 다이얼로그가 페이드 아웃되기 시작합니다. 이 CSS 전환(transition)의 200밀리초 어느 순간, 포커스는 여전히 그 닫기 버튼에 머물러 있고, 이 버튼은 라이브러리가 방금 숨김 처리하여 페이드 아웃을 시작한 오버레이 안에 놓여 있습니다. 전환이 끝나지 않았습니다. 포커스가 어디로든 이동하지 않았습니다. Chrome은 새로 숨겨진 하위 트리(subtree) 안에 포커스된 노드를 발견하고, '포커스 유지(retained focus)' 경고를 기록합니다.
이는 아마 여러분의 70%가 직면했을 상황일 것이며, 가장 깔끔한 구조를 가지고 있습니다.
phpMyAdmin #19793은 이 범죄 현장을 콘솔에 그대로 출력합니다: 포커스를 가진 요소는 <button.btn-close>이고,
그것을 감싸고 있는 조상(ancestor)이 aria-hidden을 가지고 있습니다.
모달(<div.modal>)입니다. 닫기 버튼은 숨겨지는 대상의 후손이며, 여전히 포커스를 소유하고 있습니다.
Bootstrap은 이 부분에서 특별한 위치를 차지하는데, 그 이유는 아키텍처 자체가 실수로가 아니라 설계상 나쁜 창을 열어두기 때문입니다. 역사적으로 hidden.bs.modal 이벤트에서 트리거(trigger)에 포커스를 복원하며, 이 이벤트는 CSS 전환이 완료된 후에 발생합니다.
따라서 페이드 아웃되는 전체 시간 동안, 매 프레임마다, 내부의 버튼에 포커스가 맞춰진 숨겨진 모달이 존재하고, 포커스가 이동할 곳이 없습니다. 왜냐하면 그것을 움직이는 코드가 실행되지 않았기 때문입니다.
Bootstrap #41005에서 보고된 문제는 정확히 이 상황이었고, 유지보수자들의 첫 답변인 PR #41867은 inert를 교체하여 구멍을 막으려고 시도했습니다. 하지만 그 PR은 2026년 6월에 병합되지 않은 채 닫혔습니다: Bootstrap 6은 네이티브한 showModal()로 모달을 열어, 다이얼로그를 최상위 레이어에 배치하고 나머지 문서를 암묵적으로 inert하게 만들어, 수동적인 inert 토글링이 필요하지 않게 되었고 이 버그 유형 전체가 사라졌습니다.
Bootstrap 6을 사용하고 있다면 좋은 소식입니다. 아직 5.x 버전에 머물러 있다면, 실패하는 패턴 자체가 현재 배포하고 있는 방식과 정확히 같습니다.
MUI #43106, Shoelace #2335, Angular #30187에서도 같은 형태를 볼 수 있는데, 이는 모두 동일하게 합리적으로 보이는 결정을 내렸기 때문입니다. 즉, 애니메이션이 시작할 수 있도록 먼저 숨기고, 나중에 포커스를 정리하는 것입니다. 해결책은 전적으로 순서에 관한 것이며, 몇 섹션만 더 가면 있습니다.
남겨진 트리거 (open-time inversion)
이제 이 과정을 역순으로 실행해 보세요. 오버레이가 열립니다. 라이브러리는 전체 배경에 aria-hidden="true"를 표시합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 CSS-Tricks의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기