👥

접근성 트리: 브라우저는 DOM 옆에 하나를 더 만든다

태그
React
UX
접근성
최종 수정일
Last updated September 20, 2026
Slug
accessibility-tree-aria-multiline
Date
September 10, 2026
Description
DOM에 선언한 ARIA 속성이 실제 접근성 트리에 어떻게 반영되는지, combobox와 aria-multiline 충돌을 수정한 오픈소스 기여 사례로 살펴봅니다.
Published
Published

notion image
개요
지난 글에서는 Astryx 기여 중 만난 이슈를 계기로 WCAG를 살펴봤습니다.
그렇다면, 접근성을 지켰다는 사실은 어디에서, 어떻게 확인할 수 있을까요?
화면이 달라 보이지 않는 수정도 브라우저와 보조 기술이 사용하는 의미에는 영향을 줄 수 있습니다. 제가 직접 제출한 PR을 따라가며, DOM에 선언한 ARIA가 실제 접근성 트리에 어떻게 반영되는지 확인해 보겠습니다.

매주 critical이라고 신고되던 것
Astryx에는 매주 돌아가는 접근성 검사가 있습니다. 검사의 결과는 issue 하나에 댓글로 계속 쌓입니다.
notion image
이번 주 결과는 36건이었고, 그중 4건이 critical으로 분류되고 있었습니다. 그 4건 중 하나가 ChatComposerInput이라는 컴포넌트였습니다. ChatComposerInput은 채팅 메시지를 입력하는 칸입니다. 평범한 입력칸이 아니라 @를 치면 사람 이름 목록이 떠서 고를 수 있는 종류입니다.
notion image
설정에 따라 @를 치면 다음과 같이 특정 대상을 검색할 수 있습니다. Cursor나 ChatGPT를 써보셨다면 익숙하실 겁니다.
notion image
이 컴포넌트에서 발생한 이슈의 내용은 이것이었습니다.
aria-allowed-attr (critical) "Ensure an element's role supports its ARIA attributes"
"Ensure an element's role supports its ARIA attributes" 라고 하여, ARIA attributes를 지원하는 element role을 넣으라고 되어 있습니다.
잠시 ARIA 라는 용어에 집중해봅니다. 여기서 ARIA는 Accessible Rich Internet Applications의 약자입니다. 앞의 두 글자가 핵심이죠. Accessible은 "접근 가능하게"이고, Rich는 "복잡한"이라는 뜻입니다. 평범한 HTML만으로 설명하기 어려운 UI의 역할, 상태, 이름을 보조 기술에 전달하는 속성 모음입니다.
실제로 문제가 된 요소는 이렇게 생겼습니다.
<div aria-multiline="true" aria-label="Message input" contenteditable="true" role="combobox" aria-expanded="false" aria-haspopup="listbox">
복잡해 보이지만 나눠 보면 단순한 div에 접근성을 위한 코드가 붙어 있다고 볼 수 있습니다.
aria-multiline="true" <--- 집중 aria-label="Message input" contenteditable="true" role="combobox" <--------- 집중 aria-expanded="false" aria-haspopup="listbox"
여기서 role="combobox"aria-multiline="true"가 같이 있는 것이 위반이었습니다. aria-multiline은 "여기는 여러 줄을 쓸 수 있다"는 표시입니다. MDN에서 확인해 보니 이 속성이 허용되는 역할은 textbox 하나뿐이었습니다. <textarea>처럼 원래 여러 줄을 받는 요소는 브라우저가 알아서 textbox 역할과 여러 줄이라는 상태를 부여합니다. 하지만 이 컴포넌트는 divcontenteditable을 붙여 만든 입력칸이라 브라우저가 그 의미를 알 수 없고, 그래서 rolearia-multiline을 직접 선언해야 했던 것입니다. 단 헷갈리면 안되는 점은, aria-multiline="true"를 붙여도 실제로 여러 줄을 입력할 수 있게 되지는 않습니다. Enter가 줄바꿈이 되는지는 contenteditable 동작과 JS 핸들러가 정하고, ARIA는 보조 기술에 전달되는 의미만 바꿉니다.
그런데 앞서 본 컴포넌트는 상황에 따라 자기 역할을 바꿔야 합니다. @를 통해 특정 파일들을 참조하는 기능이 꺼져 있으면 평범한 textbox이고, 기능이 켜져 있으면 목록이 연결되므로 combobox가 됩니다. 즉, 위의 케이스는 Accessible Rich 한 케이스라고 볼 수 있겠죠?
그래서 찾아본 결과, @ 기능을 쓰는 곳에서만 위반이 발생했습니다. 위반 사항 14개 중 8가 이것 때문이였고, 그 8개가 @ 기능을 설정한 스토리북의 컴포넌트와 정확히 일치했습니다. 여기까지가 왜 접근성을 해하게 되었는가에 대한 이야기입니다.

그 속성은 누가 읽을까?
그런데 위 접근성 관련 태그를 하나씩 떼어도 제 눈에는 전혀 바뀐 것이 없어 보였습니다. 화면은 그대로인데 이 태그들은 무슨 의미가 있을까, 효과가 없는 변경이라면 왜 CI 단계에서 왜, 그리고 어떻게 검사할까 하는 질문이 생겼습니다. 이 질문에 답하려면 브라우저가 UI의 의미를 어떻게 전달하는지부터 봐야 합니다. 브라우저는 DOM과 CSS, ARIA를 해석해 역할·이름·상태를 계산하고, 이를 접근성 API를 통해 스크린 리더 같은 보조 기술에 노출합니다. 화면을 보는 방식이 서로 다른 사용자도 같은 UI의 의미에 접근할 수 있도록 하기 위해서입니다.
DOM + CSS + ARIA ↓ 브라우저가 의미를 계산 접근성 트리 ↓ OS 접근성 API 스크린 리더 등 보조 기술
위 그림은 흐름을 단순화한 도식화입니다.
일반적인 개발자라면 항상 봐오고, 다루고, 최적화를 하는 쪽은 DOM입니다. Document Object Model의 약자이고, 글자 그대로 "문서를 객체로 본 모형"입니다. div가 몇 개 있고 어느 것이 어느 것 안에 있는지를 담습니다. 개발자 도구의 Elements 패널에서 보는 그것이죠.
스크린 리더 같은 프로그램은 DOM을 그대로 읽지 않습니다. 브라우저가 DOM을 해석해 따로 계산한 결과를 접근성 트리로 만들고, 이를 OS 접근성 API로 전달합니다.
둘의 차이는 담는 내용에 있습니다. DOM은 "이건 div다"를 담고, 접근성 트리는 "이건 글 쓰는 칸이고, 이름은 Message input이며, 여러 줄을 받는다"처럼 사용 가능한 의미와 상태를 담습니다. 따라서 div라는 태그 이름이 아니라 그 요소의 역할과 상태가 전달됩니다.
처음에는 DOM 을 그냥 쓰면 되는 거 아닌가? div 안에 div 안에 div가 있으면 그걸 접근성 트리로 만들면 되지 않을까 생각했습니다. 하지만 시각적 배치를 위한 중첩까지 모두 전달하면 오히려 불필요한 정보가 됩니다. 그래서 구조를 위해서만 쓰인 div는 접근성 트리에 올리지 않는 경우가 많습니다. 반대로 화면에는 보이지 않아도 접근성 트리에는 이름이나 상태가 있을 수 있습니다. 둘은 목적에 따라 분리됩니다.
속성을 떼는 것이 의미가 있느냐보다, 그 속성이 애초에 접근성 트리까지 도달하고 있었느냐를 확인해야 했습니다.
접근성 트리는 어떻게 열어 보나
다행히 이건 특별한 도구 없이 볼 수 있습니다. Chrome이라면 개발자 도구의 Elements 패널 오른쪽에 Accessibility 탭이 있습니다. 요소를 하나 선택하면 계산된 역할과 이름이 나오고, 그 이름이 어떤 규칙을 거쳐 정해졌는지까지 순서대로 보여 줍니다.
notion image
위쪽의 Show accessibility tree를 클릭하면 DOM이 아니라 접근성 트리를 중심으로 볼 수 있습니다. 조금 다르죠?
접근성 트리를 처음 익힌다면 Firefox가 제일 좋은 선택입니다. 개발자 도구에 독립된 Accessibility 패널이 있고, 트리에서 항목을 고르면 페이지의 해당 위치가 하이라이트됩니다. 하단 이미지처럼 탭 이동 순서를 켜면 탭 키가 이동하는 순서를 페이지 위에 번호로 그려 주기까지 합니다. (와우, 약간 감동 포인트였습니다.) 접근성으로 페이지를 탐색하는 사용자의 경로를 생각해 보면, 이 시각화가 구조를 먼저 이해하는 데 도움이 됩니다.
notion image
Chrome에서는 더 깊이 들어가고 싶을 때 주소창에 chrome://accessibility를 입력할 수 있습니다. 탭별 내부 트리를 보여 주므로 브라우저가 의미를 계산한 결과를 확인할 때 유용합니다.
notion image
탭 이동 순서를 알려 준 Firefox에 감동받아 같은 주소를 입력해 봤지만, Firefox에서는 열리지 않더군요..
notion image
그렇다면 Astryx의 메인테이너나 CI는 매번 직접 눈으로 보지 않고 어떻게 확인했을까요? 자동화된 테스트에서도 접근성 의미를 바탕으로 검증할 수 있습니다. Playwright와 Testing Library의 getByRole('button', { name: '저장' }) 같은 쿼리는 role, accessible name, 관련 접근성 의미를 바탕으로 요소를 찾습니다. 브라우저의 실제 접근성 트리를 그대로 덤프하거나 쿼리하는 것은 아니며, role locator만으로 접근성 감사를 대신할 수도 없습니다. 다만 이 쿼리로 요소를 찾지 못하면 UI의 역할이나 이름이 기대와 다를 수 있다는 신호가 되어 CI의 성공/실패를 판단하는 데 도움이 됩니다.

그럼에도 불구하고, 안 올라가는 경우가 있다
이제 원래 질문으로 돌아갑니다. role="combobox"aria-multiline을 붙여 두면, 그게 접근성 트리까지 가는가? 측정은 Chromium을 띄워 접근성 트리를 직접 뽑는 방식으로 했습니다. 세 가지 상태를 나란히 놓고 비교했습니다. 역할이 textbox인 상태, 속성을 뗀 지금 상태, 그리고 속성을 다시 붙여 수정 전을 재현한 상태입니다.
상태
DOM의 aria-multiline
접근성 트리의 multiline
textbox
"true"
true
combobox, 속성 뗀 뒤
없음
없음
combobox, 속성 다시 붙임
"true"
없음
표에서 볼 것은 두 가지입니다.
첫째, textbox 행이 측정을 검증하는 대조군입니다. 역할이 textbox일 때는 속성이 접근성 트리까지 올라가므로 측정 방법이 제대로 동작하는지 확인할 수 있습니다.
둘째, 두 combobox 행의 접근성 트리 결과는 서로 같습니다. 이 Chromium 측정에서는 DOM에 aria-multiline이 남아 있어도 role=combobox에 대해서는 접근성 트리에 노출되지 않았습니다.
처음에는, 단순히 속성을 써 두면 그게 어딘가에는 반영된다고 막연히 생각하고 있었습니다.
이 측정에서 combobox 역할이 허용하지 않는 aria-multiline은 접근성 트리에 노출되지 않았습니다. 그렇다고 모든 브라우저가 언제나 지원되지 않는 ARIA 속성을 전부 버린다고 일반화할 수는 없습니다. 이 PR은 실제로 전달되지 않던 선언을 역할에 맞게 정리한 작업입니다. 화면도 그대로, 입력 동작도 그대로, 기계가 받는 값도 그대로입니다.
그러면 왜 고치느냐는 질문이 남죠. 두 가지입니다.
매주 critical로 올라오던 신고가 사라지니 진짜 문제가 가려지지 않습니다. 그리고 같은 실수가 다시 나지 않게 구조를 바꿀 수 있습니다.
그래서 어떻게 해결했는가?
위반을 없애는 가장 빠른 방법은 aria-multiline="true" 한 줄을 지우는 것입니다. 그런데 그러면 반대쪽이 깨집니다. 트리거를 안 쓰는 입력칸에서는 textbox 역할에 aria-multiline이 적절한 표시이기 때문입니다.
그래서 이 속성이 어쩌다 combobox에까지 따라붙었는지를 먼저 봐야 했습니다. 화면을 그리는 파일에는 이렇게 박혀 있었습니다.
// ChatComposerInput.tsx : 속성은 여기서 붙이고, 역할은 맨 아래 spread로 들어옵니다. <div ref={editableRef} aria-multiline="true" aria-label={label} contentEditable={!isDisabled} {...triggerMenu.ariaProps} />
역할을 정하는 쪽은 다른 파일이었습니다. useTriggerMenu라는 훅이 트리거 설정을 보고 textboxcombobox 중 하나를 골라 내려보내고 있었습니다.
// useTriggerMenu.tsx (수정 전) : 역할만 정하고, aria-multiline은 모릅니다. const ariaProps = !hasTriggers ? {role: 'textbox'} : {role: 'combobox', 'aria-expanded': ..., 'aria-haspopup': 'listbox'};
한 요소의 역할과, 그 역할에 종속된 속성을 서로 다른 파일이 나눠 정하고 있었던 것이 원인이었습니다. 각 파일만 따로 열어 보면 둘 다 멀쩡한 코드입니다.
그래서 속성을 지우는 대신, 역할이 정해지는 자리로 옮겼습니다.
// useTriggerMenu.tsx (수정 후) : 역할과 속성을 같은 분기에서 정합니다. const ariaProps = !hasTriggers ? {role: 'textbox', 'aria-multiline': 'true'} : {role: 'combobox', 'aria-expanded': ..., 'aria-haspopup': 'listbox'};
역할과 그 역할에 종속된 ARIA 속성은 같은 분기에서 결정한다.
이렇게 바꾸니까 textbox 갈래에만 속성이 붙고, combobox 갈래에는 애초에 속성이 포함되지 않습니다. 화면을 그리는 파일에서는 그 한 줄이 사라졌습니다.
테스트도 갈래대로 두 개를 넣었습니다.
// trigger가 없으면 textbox이므로 속성이 있어야 합니다. it('marks the textbox as multiline', () => { render(<ChatComposerInput />); expect(screen.getByRole('textbox')).toHaveAttribute('aria-multiline', 'true'); }); // trigger를 걸면 combobox가 되므로 속성이 없어야 합니다. it('drops aria-multiline once triggers make it a combobox', () => { render(<ChatComposerInput triggers={[createMentionTrigger()]} />); expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-multiline'); });
두 번째 테스트는 수정 전 코드에 되돌려 놓고 실패하는 것을 확인했습니다. 이 확인을 건너뛰면 통과하는 테스트를 한 줄 늘린 것과 구분이 안 됩니다.
결과는 스토리 단위로 10건이 사라졌습니다. ChatComposerInput의 스토리 8개와, 그 입력칸을 그대로 쓰는 ChatLayout의 스토리 2개입니다.
앞에서 본 "critical 4건"과 숫자가 안 맞아 보이는데, 세는 단위가 다릅니다. 주간 검사는 컴포넌트별로 묶어서 세고, 저장소가 관리하는 위반 목록은 스토리 하나하나를 셉니다. ChatComposerInput 한 건이 스토리 8건인 셈이죠.
그래서 이 PR 하나로 critical 4건 중 2건이 없어졌습니다. 저장소 전체 위반은 108건에서 98건이 되었고, 새로 생긴 위반은 없었습니다.
notion image
사용자 경험을 개선한 게 아니라, 복잡한(Rich)한 컴포넌트를 구현하면서도, 동시에 접근성(Accessibility)을 챙기는 작업의 일환이었습니다. 정말 재미있었습니다..ㅎㅎ
참고: PR 본문

참고 자료