모바일 전체메뉴 하나에서 결함 9건 — 가장 많이 쓰이는 화면이 가장 많이 어긋나 있었어요
KRDS 원본 보완 시리즈
(3편)- 1.KRDS를 그대로 가져다 쓰면 검수에 걸려요 — 원본은 안 고치고 보완한 기록
- 2.Modal JS를 다시 쓴 이유 — Esc가 안 먹는 리스너와 350ms의 정체
- 3.모바일 전체메뉴 하나에서 결함 9건 — 가장 많이 쓰이는 화면이 가장 많이 어긋나 있었어요
2편에서 Modal JS 결함 5건을 봤어요. 오늘은 9건입니다. 한 컴포넌트에서요.
재현 조건: 화면 폭 1023px 이하. KRDS는 1024px 이상에서 모바일 전체메뉴를
display: none으로 감추고 PC 메뉴를 씁니다(_main_menu.scss의size-large-more). 넓은 창에서는 이 글의 어떤 항목도 재현되지 않아요. 태블릿 폭(768~1023px)도 포함입니다.
모바일 전체메뉴예요. 공공 사이트에서 가장 많이 눌리는 버튼이고, 모바일 방문자에게는 사실상 유일한 탐색 수단입니다. 그런데 결함이 제일 많았어요.
왜 그런지는 짐작이 가요. 이 컴포넌트는 대화상자 + 탭 + 4단 깊이 메뉴가 한 덩어리로 들어 있습니다. 세 패턴이 겹치면 각각의 접근성 요구도 겹쳐요.
9건
먼저 목록부터 보여드릴게요. KRDS v1.1.0 ui-script.js의 krds_mainMenuMobile을 대조한 결과입니다 (2026-09-30 확인).
| # | 결함 | 성격 |
|---|---|---|
| 1 | 여는 버튼의 aria-expanded 갱신이 주석 처리돼 있음 | 상태 전달 |
| 2 | Esc로 닫히지 않음 | 키보드 |
| 3 | 배경 inert를 고정 id로 걸어 KRDS 푸터가 빠짐 | 초점 가두기 |
| 4 | 열 때마다 초점 가두기 리스너가 누적되고 대상이 고정됨 | 초점 가두기 |
| 5 | tablist/tab 역할인데 방향키 동작이 없음 | 키보드 |
| 6 | 펼침 요소가 a[href="#"] | 의미 |
| 7 | 4depth 본문이 ul > h4 (HTML 위반) | 마크업 |
| 8 | 4depth의 "전체메뉴 닫기"가 4depth만 닫음 | 동작 |
| 9 | 메뉴 영역에 대화상자 역할이 없음 | 역할 |
하나씩 보면 작아 보이는데, 합치면 키보드로는 쓸 수 없는 메뉴가 됩니다.
1. aria-expanded가 주석 처리돼 있어요
원본 코드에 상태를 갱신하는 줄이 있는데 주석으로 막혀 있습니다. 누군가 잠깐 끄고 되돌리지 않은 것처럼 보였어요.
결과는 이거예요. 메뉴를 열어도 버튼은 계속 "접힘"이라고 말합니다. 화면낭독기 사용자는 방금 자기가 연 메뉴가 열렸는지 알 수 없어요. 눈으로 보는 사람에게는 메뉴가 화면을 덮고 있으니 명백한데, 듣는 사람에게는 아무 변화가 없습니다.
KWCAG 4.1.2(웹 애플리케이션 접근성)에서 바로 걸리는 항목이에요.
2·9. Esc로 닫히지 않고, 대화상자도 아니에요
전체메뉴는 열리면 화면을 덮습니다. 동작으로 보면 모달이에요. 그런데 원본은 역할을 선언하지 않고, Esc도 받지 않습니다.
그러니까 키보드 사용자가 메뉴를 열면 나가는 방법이 "닫기 버튼까지 Tab으로 가기" 하나뿐이에요. 4depth까지 펼쳐둔 상태면 그 거리가 꽤 멉니다.
role="dialog"와 aria-modal="true"를 붙이고, Esc를 받게 했어요. 다만 순서를 정해야 했습니다 — 4depth 패널이 열려 있으면 그것부터 닫습니다. 안쪽부터 닫는 게 사용자가 기대하는 동작이에요. Esc 한 번에 전부 닫히면 "어디까지 보고 있었는지"를 잃어버립니다.
3. inert가 KRDS 푸터를 빼먹어요
2편의 Modal과 같은 부류인데, 이쪽이 더 분명한 실수예요.
원본은 배경을 못 만지게 하려고 #container와 #footer에 inert를 겁니다.
그런데 KRDS 푸터의 실제 id는 krds-footer예요. krds.go.kr 페이지에서 확인했습니다 — id="wrap", id="container", id="krds-footer"가 있고 id="footer"는 없어요.
그래서 getElementById('footer')는 null을 돌려주고, ?. 때문에 아무 일 없이 조용히 넘어갑니다. 에러도 안 나요. 그래서 이 결함은 콘솔을 봐도 안 보입니다.
그래서 메뉴를 열어둔 채 Tab을 계속 누르면, 초점이 화면에 안 보이는 푸터 링크들로 들어갑니다. 주소, 연락처, 정책 링크, SNS 링크까지 전부요. 사용자는 메뉴를 보고 있는데 초점은 그 뒤 푸터를 돌아다녀요.
고친 방식은 Modal과 같아요. 고정 id에 기대지 않고, 메뉴에서 body까지 올라가며 형제 요소를 inert로 만듭니다.
4. 열 때마다 리스너가 쌓여요
원본은 메뉴를 열 때 초점 가두기 리스너를 답니다. 닫을 때 떼지 않아요. 열고 닫기를 다섯 번 하면 리스너가 다섯 개 붙어 있습니다.
게다가 가둘 대상을 열 때 한 번 계산해요. 3depth를 펼쳐서 링크가 늘어나면 그 링크들은 목록에 없습니다.
Tab마다 다시 계산하는 쪽으로 바꿨어요.
5. 탭이라고 선언했는데 방향키가 없어요
이게 가장 까다로운 항목이었습니다.
원본 마크업은 1depth 목록에 tablist/tab 역할을 씁니다. 그런데 방향키로 탭을 옮기는 동작이 없어요. 클릭만 됩니다.
이게 왜 문제냐면, 역할을 선언하는 건 사용자에게 조작법을 약속하는 것이기 때문이에요. 화면낭독기는 "탭, 2개 중 1번째"라고 읽어주고, 사용자는 방향키를 누릅니다. 그게 탭의 규칙이니까요. 아무 일도 안 일어나면 사용자는 고장났다고 생각해요.
차라리 역할이 없으면 "링크 목록"으로 읽히고, 사용자는 Tab으로 넘어갑니다. 잘못된 역할은 역할이 없는 것보다 나빠요.
여기서 선택이 갈렸어요. 역할을 빼서 목록으로 둘지, 역할을 지키고 동작을 채울지. 마크업·클래스는 KRDS 그대로 두는 게 원칙이라 동작을 채우는 쪽으로 갔습니다. 방향키·Home·End와 roving tabindex를 넣었어요.
roving tabindex는 탭 묶음 전체에서 Tab 정지점을 하나만 두는 방식이에요. 현재 탭만 tabindex="0"이고 나머지는 -1입니다.
이렇게 하면 Tab 한 번으로 탭 묶음을 지나가고, 탭 사이 이동은 방향키로 합니다. 탭이 10개일 때 Tab을 10번 누르지 않아도 돼요.
참고로 4편에서 다룰 SideNavigation에서는 반대 결정을 했어요. 거기는 menubar/menu 역할을 아예 뺐습니다. 같은 "역할과 동작 불일치" 문제인데 결론이 다른 이유는, 전체메뉴의 탭은 실제로 탭처럼 생겼고 사이드 내비게이션은 그냥 링크 목록이기 때문이에요.
6·7. a[href="#"]와 ul > h4
6번 — 3depth를 펼치는 요소가 <a href="#">입니다. 페이지 안 어디로도 가지 않는데 링크로 돼 있어요. 화면낭독기는 "링크"라고 읽고, 사용자는 이동할 거라고 기대합니다. 실제로는 펼치기죠. <button>으로 바꿨습니다.
바꾸면서 생긴 문제가 하나 있었어요. KRDS CSS가 a를 기준으로 폭을 잡아서 button으로 바꾸니 모양이 달라졌습니다. 새 CSS를 쓰지 않는다는 원칙이 있어서, 인라인으로 width:100%; text-align:left만 추가했어요. 딱 원래 모양으로 돌아가는 만큼만.
7번 — 4depth 본문이 <ul> 안에 <h4>로 들어 있어요. ul의 자식은 li만 허용되니 HTML 규칙 위반입니다. 브라우저가 알아서 고쳐주긴 하는데, 그 결과가 브라우저마다 다를 수 있고 보조기술이 구조를 잘못 읽을 수 있어요. div로 바꿨습니다.
8. "전체메뉴 닫기"가 전체메뉴를 안 닫아요
4depth 패널 안에 "전체메뉴 닫기" 버튼이 있는데, 4depth 패널만 닫습니다. 전체메뉴는 그대로 열려 있어요.
버튼 이름이 하는 일과 다른 거예요. KWCAG 2.5.3(레이블과 네임) 관점에서도 문제고, 그냥 사용자를 속이는 동작입니다. 실제로 전체메뉴를 닫게 했어요.
왜 이렇게 많았을까
9건을 다 보고 나서 생각한 건 이거예요. 이 컴포넌트는 세 패턴이 겹쳐 있습니다.
- 대화상자 → 초점 가두기, Esc, 역할, 배경 차단
- 탭 → 방향키, roving tabindex, 선택 상태
- 다단 메뉴 → 펼침 상태, 깊이별 닫기 순서
각 패턴의 요구가 곱해져요. 그래서 하나씩 보면 작은 누락인데 합치면 쓸 수 없는 메뉴가 됩니다.
그리고 이 결함들은 눈으로는 전혀 안 보여요. 마우스로 쓰면 9건 전부 정상처럼 동작합니다. 디자인 QA로는 하나도 안 걸려요.
오늘 정리
- 잘못된 역할은 역할이 없는 것보다 나빠요.
tablist를 선언하면 사용자는 방향키를 누릅니다. 동작을 못 채우면 역할을 빼는 게 맞아요. - 고정 id에 기댄
inert는 깨집니다. 원본이#footer를 찾는데 실제 푸터가#krds-footer면 배경이 안 막혀요. - 패턴이 겹치는 컴포넌트는 결함도 곱해져요. 대화상자 + 탭 + 다단 메뉴가 한 덩어리면 세 패턴의 요구를 다 봐야 합니다.
본인 사이트에서 확인하실 거라면 이 순서로 해보세요. 창 폭을 1023px 아래로 줄이고 → 전체메뉴를 열고 → Tab을 끝까지 눌러보세요. 메뉴를 벗어나 푸터로 들어가면 3번과 같은 문제예요.
관련 링크
- ARIA APG — Tabs Pattern — roving tabindex와 방향키 규칙
inert— MDN- KRDS-uiux/krds-uiux — 원본 저장소 (v1.1.0 기준, 2026-09-30 확인)
- @hanui/vue
- claude-settings
시리즈: KRDS 원본 보완
- 1편: KRDS를 그대로 가져다 쓰면 검수에 걸려요 — 원본은 안 고치고 보완한 기록
- 2편: Modal JS를 다시 쓴 이유 — Esc가 안 먹는 리스너와 350ms의 정체
- 3편: 모바일 전체메뉴 하나에서 결함 9건 ← 현재 글
- 4편:
menubar역할을 버렸어요 — SideNavigation을 APG 권고로 되돌린 판단 (예정)
HANUI
KRDS 기반 React 컴포넌트 라이브러리. 공공 웹 개발을 더 쉽게.