MOMSOFT / 기술 블로그
웹 접근성, 자동 검사로는 절대 안 되는 항목들
자동 검사는 확실히 틀린 것만 잡도록 설계돼 있습니다. 사용자가 실제로 막히는 지점과 인증 심사에서 반복 지적되는 항목은 대부분 그 바깥에 있습니다.
프로젝트 문의하기 →결론부터 말하겠습니다. Lighthouse 접근성 점수 100점은 "접근성 문제가 없다"는 뜻이 아니라 "기계가 확신을 가지고 판정할 수 있는 규칙을 위반하지 않았다"는 뜻입니다. 이 둘이 겹치는 영역은 생각보다 좁습니다. 실제로 스크린리더 사용자나 키보드만 쓰는 사용자가 막히는 지점, 그리고 국내 웹 접근성 품질인증 심사에서 반복해서 지적되는 항목은 대부분 자동 검사의 사각지대에 몰려 있습니다.
그렇다고 자동 검사가 쓸모없다는 말은 아닙니다. 자동 검사는 바닥을 깔아 주는 도구입니다. 라벨 없는 입력 필드나 중복 id 같은 것을 사람이 눈으로 찾고 있으면 정작 중요한 것을 볼 시간이 없습니다. 문제는 많은 팀이 자동 검사 통과를 종착지로 착각하고 거기서 점검을 멈춘다는 데 있습니다. 이 글은 그 경계선이 정확히 어디인지, 그리고 경계 바깥을 사람이 어떤 순서로 점검해야 하는지에 대한 이야기입니다.
자동 검사 도구가 실제로 하는 일
axe-core, WAVE, pa11y, Lighthouse 같은 도구는 원리가 대동소이합니다. 특정 시점의 DOM과 계산된 스타일을 읽어 규칙 집합에 대입하고, 명백한 위반을 보고합니다. 여기서 세 가지 구조적 한계가 나옵니다.
첫째, 스냅샷 하나만 봅니다
도구가 검사하는 것은 대체로 페이지가 로드된 직후의 정적인 트리입니다. 모달이 열린 상태, 폼 검증이 실패한 상태, 데이터를 불러오는 중인 상태, 드롭다운이 펼쳐진 상태는 검사 대상에 들어가지 않습니다. 그런데 접근성 결함은 압도적으로 상태 전이 순간에 발생합니다. 모달을 닫았을 때 포커스가 어디로 가는가, 검색 결과가 갱신됐을 때 그 사실이 전달되는가 같은 것들이죠. 상태별로 검사를 돌리려면 E2E 테스트 안에서 각 상태를 만든 뒤 검사기를 호출하는 방식으로 직접 짜야 합니다. 그렇게 해도 아래 두 번째, 세 번째 한계는 그대로 남습니다.
둘째, 오탐을 피하도록 보수적으로 튜닝돼 있습니다
도구 입장에서 가장 나쁜 결과는 멀쩡한 코드를 위반으로 찍는 것입니다. 개발자가 도구를 불신하게 되니까요. 그래서 규칙은 반박하기 어려운 경우에만 발동하도록 좁게 설계됩니다. axe-core가 위반(violations)과 별개로 "수동 확인 필요(incomplete)" 결과를 따로 내보내는 이유가 이것인데, 대부분의 CI 설정은 이 목록을 무시합니다. 실질적으로 팀이 가장 봐야 할 목록이 파이프라인에서 버려지는 셈입니다. Lighthouse도 마찬가지로 규칙 점수와 별개로 "직접 확인이 필요한 항목" 목록을 따로 보여 주는데, 점수만 보고 넘어가면 그 목록은 아무도 읽지 않습니다.
셋째, 의미를 판정할 수 없습니다
alt 속성이 있는지는 기계가 압니다. 그 alt 값이 이미지가 전달하는 정보와 일치하는지는 기계가 모릅니다. 제목 태그가 h2인지는 압니다. 그 문장이 실제로 아래 문단들의 제목 역할을 하는지는 모릅니다. 접근성 기준의 상당수는 "적절한", "동등한", "이해할 수 있는" 같은 판단어를 포함하는데, 이 판단어가 붙은 항목은 예외 없이 사람의 몫입니다.
| 점검 대상 | 자동 검사 | 사람이 봐야 하는 부분 |
|---|---|---|
| 이미지 대체 텍스트 | 속성 유무만 | 내용의 등가성, 장식 이미지의 빈 alt 처리 |
| 폼 라벨 | 연결 여부 | 라벨 문구가 실제 요구를 설명하는지, 오류 안내가 붙는지 |
| 제목 구조 | 레벨 건너뜀 정도 | 제목만으로 문서 개요가 파악되는지 |
| 명도 대비 | 단색 배경 위의 텍스트 | 이미지·그라디언트·반투명 위의 텍스트, 포커스 표시, 다크 모드 |
| 키보드 조작 | 거의 못 잡음 | 전체 흐름을 Tab만으로 완주 가능한지 |
| 포커스 관리 | 못 잡음 | 열기·닫기·페이지 전환 시 포커스 이동과 복귀 |
| 동적 알림 | 속성 존재만 | 실제로 읽히는지, 적절한 시점에 한 번만 읽히는지 |
| 읽히는 순서 | 못 잡음 | 시각 순서와 접근성 트리 순서의 일치 |
키보드 순서와 포커스 관리
키보드 접근성은 자동 검사가 사실상 손을 대지 못하는 영역입니다. 도구는 요소가 포커스를 받을 수 있는지 정도만 알 뿐, 사용자가 Tab을 눌렀을 때 어떤 여정을 겪는지 시뮬레이션하지 않습니다.
시각 순서와 DOM 순서의 분리
가장 흔한 결함이자 자동 검사가 절대 못 잡는 것이 이것입니다. 접근성 트리와 Tab 순서는 기본적으로 DOM 순서를 따르지만, 화면상의 배치는 CSS가 결정합니다. 둘이 어긋나면 사용자는 포커스 링이 화면을 아무 규칙 없이 튀어 다니는 경험을 하게 됩니다.
/* 시각 순서는 뒤집히지만 Tab 순서는 DOM 순서 그대로입니다 */
.toolbar { display: flex; flex-direction: row-reverse; }
/* 이것도 같은 문제를 만듭니다 */
.card__actions { display: flex; }
.card__actions .btn-primary { order: -1; }
반응형 레이아웃에서 특히 잘 터집니다. 데스크톱에서는 순서가 맞는데 모바일 브레이크포인트에서 그리드 배치가 바뀌면서 어긋나는 식이죠. 점검할 때는 반드시 뷰포트 폭을 바꿔 가며 Tab을 눌러 봐야 합니다. 판단 기준은 간단합니다. 시각적 순서를 바꾸는 CSS는 읽는 순서까지 바꾸어도 되는 경우에만 쓴다는 것입니다. 순서가 의미를 갖는 곳이라면 CSS로 뒤집지 말고 마크업 순서를 고치는 편이 맞습니다.
양의 tabindex 값도 같은 부류입니다. tabindex="1" 같은 값을 하나라도 쓰는 순간 그 요소들이 문서 전체보다 먼저 순회되면서 순서 규칙이 깨지므로, 값은 0과 -1만 쓴다고 생각하는 편이 안전합니다.
포커스를 잃어버리는 순간들
포커스가 body로 떨어지면 스크린리더 사용자는 문서 맨 앞으로 되돌아간 것과 다름없습니다. 다음 상황들이 반복적으로 문제가 됩니다.
- 모달을 닫았는데 포커스가 원래 열었던 버튼으로 돌아오지 않는 경우
- 모달이 열려 있는데 Tab이 배경 콘텐츠까지 순회하는 경우
- 목록에서 항목을 삭제했는데 삭제 버튼과 함께 포커스가 사라지는 경우
- SPA에서 라우트가 바뀌었는데 스크롤만 맨 위로 가고 포커스는 그대로인 경우
- 비동기 로딩이 끝나고 콘텐츠가 교체되면서 포커스 대상이 DOM에서 제거되는 경우
// 마크업 전제: #app 과 #dialog 는 형제입니다.
// dialog 가 #app 안에 있으면 app.inert 가 dialog 까지 비활성화합니다.
let lastFocused = null;
function openDialog(trigger) {
lastFocused = trigger;
dialog.hidden = false; // 먼저 표시해야 포커스가 들어갑니다
appRoot.inert = true; // 배경 전체를 조작·낭독 대상에서 제외
dialog.querySelector('h2').focus(); // tabindex="-1" 을 부여해 둔 제목
}
function closeDialog() {
appRoot.inert = false;
dialog.hidden = true;
lastFocused?.focus(); // 반드시 원래 자리로 복귀
}
배경 차단은 직접 만든 포커스 트랩보다 inert 속성을 쓰는 편이 안정적입니다. 위 코드에서 보듯 inert는 자기 자신과 그 하위 전체에 적용되므로, 대화상자를 비활성화 대상 안에 두면 대화상자까지 같이 죽습니다. 이 실수가 의외로 흔합니다. 네이티브 <dialog>의 showModal()을 쓰면 배경 비활성화와 Esc 닫기를 브라우저가 처리해 주므로 직접 구현할 일이 줄어듭니다. 다만 닫힌 뒤 포커스가 어디로 가는지는 실제로 확인해 보고, 어긋나면 위처럼 명시적으로 되돌려 놓으십시오.
SPA 라우트 전환에서는 새 화면의 h1에 tabindex="-1"을 두고 포커스를 옮기는 방식이 가장 예측 가능합니다. 이 처리를 넣을 때는 뒤로 가기로 되돌아온 경우까지 같이 확인해야 합니다.
포커스 표시를 지우는 문제
outline: none은 디자인 검토 과정에서 거의 항상 등장합니다. 대체 표시를 함께 넣었는지가 관건인데, 자동 검사는 outline이 제거됐다는 사실 자체를 위반으로 잡지 않습니다. 포커스 표시가 "충분히 보이는가"는 실제 픽셀을 봐야 알 수 있는 판단이기 때문입니다. 대체 표시를 넣더라도 그것이 배경과 충분히 구분되는지, 다른 요소에 가려지지 않는지까지 봐야 합니다. 상단 고정 헤더 아래로 포커스된 요소가 숨는 현상은 스크롤 컨테이너를 쓰는 화면에서 흔한데, 화면을 눈으로 보지 않으면 발견되지 않습니다. WCAG 2.2에 포커스 표시가 다른 콘텐츠에 가려지지 않아야 한다는 기준이 별도로 들어간 것도 이 문제가 그만큼 흔하기 때문입니다.
마우스 사용자에게 포커스 링을 보이지 않게 하고 싶다면 :focus-visible을 쓰면 됩니다. :focus를 통째로 지우는 것과는 결과가 전혀 다릅니다.
복합 위젯의 조작 모델
탭, 트리, 그리드, 콤보박스 같은 위젯은 구성 요소 전부가 Tab 정지점이 되면 안 됩니다. 위젯 전체가 하나의 정지점이고, 내부는 방향키로 이동하는 것이 표준 동작입니다. 이른바 로빙 tabindex 패턴이죠. 자동 검사는 role="tablist"가 붙어 있는지, 필수 자식 role이 맞는지 정도만 확인할 뿐, 방향키가 실제로 동작하는지는 검사하지 않습니다. 직접 만들 이유가 없다면 네이티브 요소를 쓰거나 검증된 컴포넌트를 쓰는 편이 낫습니다.
포인터 전용 조작
드래그로만 순서를 바꿀 수 있는 목록, 마우스를 올려야만 열리는 메뉴, 길게 눌러야 나오는 컨텍스트 메뉴는 전부 키보드 사용자를 배제합니다. WCAG 2.2는 드래그로 하는 조작에 단일 포인터 대안을 요구하는 기준을 별도로 두고 있습니다. 실무적으로는 "이 기능을 마우스 없이 수행할 다른 경로가 화면 안에 있는가"만 물어보면 됩니다. 정렬을 드래그로 제공한다면 위·아래 이동 버튼을 같이 두는 식입니다.
스크린리더에서 실제로 읽히는 순서
스크린리더 사용자는 문서를 위에서 아래로 순서대로 읽지 않습니다. 제목 목록으로 건너뛰고, 랜드마크로 이동하고, 링크만 뽑아서 훑습니다. 그러니까 "읽히는 순서"는 두 가지를 뜻합니다. 하나는 선형으로 읽었을 때의 순서이고, 다른 하나는 구조를 통해 탐색했을 때 드러나는 지도입니다. 후자가 무너져 있으면 콘텐츠가 아무리 정확해도 사용자는 길을 잃습니다.
제목과 랜드마크가 곧 목차입니다
제목 태그를 글자 크기 조절용으로 쓰는 습관은 아직도 흔합니다. 반대로 시각적으로만 제목처럼 보이게 div에 큰 글씨를 준 경우도 많고요. 둘 다 자동 검사에서 조용히 넘어갑니다. 레벨 건너뜀은 잡히지만, 레벨이 가지런한 엉터리 개요는 잡히지 않기 때문입니다. 점검 방법은 단순합니다. 스크린리더의 제목 목록 기능으로 페이지 개요를 뽑아 보고, 그 목록만으로 이 페이지가 무슨 내용인지 파악되는지 확인하면 됩니다. 파악이 안 되면 구조가 잘못된 것입니다.
랜드마크도 같습니다. nav가 여러 개일 때 각각에 이름을 주지 않으면 사용자에게는 "탐색, 탐색, 탐색"으로만 들립니다. main이 없거나 두 개인 경우도 자주 봅니다. 반복 영역 건너뛰기 링크는 존재 여부보다 눌렀을 때 실제로 본문으로 포커스가 옮겨지는지가 중요합니다. 앵커만 걸어 두면 스크롤은 이동하지만 포커스는 그대로 남는 구현이 흔합니다. 대상 요소에 tabindex="-1"을 붙여야 포커스까지 따라갑니다.
이름 계산 규칙이 원문을 덮어씁니다
접근 가능한 이름은 대체로 aria-labelledby, aria-label, 요소 내부 콘텐츠, title 순으로 계산됩니다. 앞의 것이 있으면 뒤의 것은 무시됩니다. 아이콘 버튼에 이름을 붙이는 정당한 용도도 있지만, 이미 텍스트가 있는 링크에 다른 문구의 aria-label을 얹으면 화면에 보이는 글자와 읽히는 글자가 달라집니다. 음성 명령으로 조작하는 사용자는 보이는 대로 말했는데 반응이 없는 상황을 겪습니다. WCAG에 "이름 속의 레이블" 기준이 따로 있는 이유이고, 자동 검사는 여기서 "접근 가능한 이름이 있다"고 판정하고 통과시킵니다. 규칙은 하나입니다. 보이는 텍스트가 접근 가능한 이름에 포함되어야 합니다.
aria-label은 아무 요소에나 붙는다고 동작하지도 않습니다. 역할이 없는 div나 span에 붙인 aria-label은 무시되는 경우가 많습니다. 이름을 붙이고 싶으면 먼저 그 요소에 적절한 역할이 있어야 합니다.
aria-hidden="true"도 위험합니다. 포커스 가능한 요소를 품은 컨테이너에 이 속성을 걸면 키보드로는 도달하는데 스크린리더에는 존재하지 않는 유령 요소가 만들어집니다. 캐러셀의 비활성 슬라이드, 접힌 메뉴, 애니메이션 중인 패널에서 자주 발생합니다. 숨기려면 aria-hidden이 아니라 hidden이나 display: none, 또는 inert를 써서 포커스 가능성까지 함께 없애야 합니다.
표와 목록의 의미
레이아웃 목적으로 table을 쓰는 경우는 많이 줄었지만, 반대로 데이터 표를 div 그리드로 만드는 경우가 늘었습니다. 이러면 셀과 헤더의 관계가 사라져서 스크린리더가 어떤 값이 어느 항목의 값인지 읽어 줄 수 없습니다. 실제 표라면 th와 scope를 제대로 넣고, 복잡한 표는 구조 자체를 단순한 표 여러 개로 쪼개는 편이 훨씬 낫습니다. 헤더가 두 방향으로 겹치는 표를 headers와 id로 정교하게 묶는 것보다, 표를 나누는 쪽이 만드는 사람에게도 읽는 사람에게도 낫습니다.
대체 텍스트는 규칙이 아니라 판단입니다
대체 텍스트에서 가장 중요한 질문은 "이 이미지가 없으면 사용자가 무엇을 잃는가"입니다. 이미지의 생김새를 묘사하는 것이 목적이 아닙니다. 이 관점으로 나누면 판단이 단순해집니다.
- 장식용: 잃는 것이 없습니다.
alt=""를 주어 접근성 트리에서 빼야 합니다. 여기에 "배경 이미지" 같은 설명을 넣는 것이 오히려 소음입니다. - 기능용: 링크나 버튼 안의 이미지입니다. 그림 설명이 아니라 동작이나 목적지를 써야 합니다. 장바구니 아이콘의 대체 텍스트는 "장바구니 아이콘"이 아니라 "장바구니"입니다.
- 정보용: 본문이 전달하지 않는 정보를 담고 있습니다. 그 정보를 문장으로 쓰면 됩니다.
- 텍스트 이미지: 이미지 안의 글자를 그대로 옮깁니다.
- 복합 이미지: 차트나 다이어그램입니다. 짧은 대체 텍스트로 무엇에 관한 그림인지 알리고, 실제 데이터는 본문이나 표로 별도 제공해야 합니다. 대체 텍스트 안에 수백 자를 밀어 넣는 것은 해결책이 아닙니다.
- 중복: 바로 옆 캡션이 같은 내용을 이미 말하고 있다면
alt=""가 맞습니다. 같은 문장을 두 번 듣는 것은 도움이 아니라 방해입니다.
자동 검사가 잡아 주는 것은 속성이 아예 없는 경우, 그리고 파일명처럼 보이는 값 정도가 전부입니다. "이미지" 한 단어만 있는 경우, 마케팅 문구가 들어간 경우, 그럴듯하지만 그림과 관계없는 문장이 들어간 경우는 전부 통과합니다. 심사에서 가장 많이 되돌아오는 지점이 정확히 여기입니다.
CSS 배경 이미지로 정보를 표현한 경우도 자동 검사 대상 밖입니다. 배지나 상태 아이콘을 배경 이미지로 그려 놓으면 화면을 보지 않는 사용자에게는 그 정보가 아예 없습니다. 아이콘 폰트를 ::before 콘텐츠로 넣는 방식도 위험합니다. 사용자가 지정한 대체 폰트가 적용되면 엉뚱한 글리프가 보이고, 보조기술이 의미 없는 기호를 읽어 버리는 경우도 있습니다. 아이콘은 인라인 SVG로 넣고, 장식이면 aria-hidden="true", 의미가 있으면 부모 요소에 텍스트 이름을 주는 편이 안전합니다.
색 대비는 계산이 아니라 실측입니다
일반 텍스트 4.5:1, 큰 텍스트 3:1, 그리고 UI 구성 요소와 의미 있는 그래픽 3:1이라는 기준 자체는 널리 알려져 있습니다. 문제는 도구가 이 계산을 선언된 색상값 두 개로만 한다는 점입니다. 실제 화면에 찍히는 픽셀과 다른 경우가 부지기수입니다.
- 배경이 이미지나 영상인 경우. 도구는 배경색을 알 수 없어 판정을 포기하거나 조상 요소의 색을 씁니다. 스크림 레이어를 깔았어도 이미지 밝기에 따라 실제 대비는 위치마다 달라집니다.
- 그라디언트 위의 텍스트. 한쪽 끝은 통과하고 반대쪽 끝은 탈락하는 상황이 그대로 넘어갑니다.
opacity나 반투명 오버레이가 걸린 경우. 합성 결과를 계산에 반영하지 못하는 도구가 많습니다.- 포커스 표시와 상태 표시. 포커스 링은 요소 자체와도, 바깥 배경과도 구분돼야 합니다. 밝은 배경에서만 맞춰 놓고 어두운 카드 위에서는 보이지 않는 링이 흔합니다.
- 플레이스홀더와 보조 텍스트. 회색으로 흐릿하게 처리하는 관행이 거의 항상 기준을 넘깁니다. 비활성 요소는 기준 적용에서 빠지지만, 플레이스홀더는 비활성이 아닙니다.
- 다크 모드. 라이트 모드에서 통과한 팔레트는 아무것도 보장하지 않습니다. 별도로 전부 다시 재야 합니다.
실무적으로는 화면을 캡처해서 픽셀 색을 직접 찍어 재는 방법이 가장 확실합니다. 텍스트 가장자리가 아니라 획 안쪽의 픽셀을 찍어야 안티에일리어싱에 속지 않습니다. 그리고 기준 통과가 목표가 아니라는 점도 기억해야 합니다. 얇은 폰트에 작은 크기가 겹치면 수치상 통과해도 실제로는 읽기 어렵습니다. 수치가 아슬아슬하게 걸린다면 색을 조정하기 전에 굵기부터 올려 보는 편이 대개 결과가 좋습니다.
색만으로 상태를 구분하는 것도 대비와는 별개의 문제입니다. 오류 필드를 빨간 테두리로만 표시하면 색을 구분하지 못하는 사용자에게는 아무 신호가 아닙니다. 아이콘이나 문구를 함께 넣어야 합니다. 그래프의 계열을 색으로만 구분하는 것, 링크를 색으로만 구분하는 것도 같은 부류입니다. 점검은 간단합니다. 화면을 흑백으로 바꿔 보고 여전히 구분되면 통과입니다.
동적 콘텐츠 알림
현대 웹에서 가장 자주 무너지는 영역입니다. 화면이 바뀌었다는 사실이 시각 정보로만 전달되면, 화면을 보지 않는 사용자에게는 아무 일도 일어나지 않은 것과 같습니다.
<!-- 나쁨: 알림이 생길 때 영역 자체를 삽입하면 읽히지 않는 경우가 많습니다 -->
<div aria-live="polite">저장되었습니다</div>
<!-- 좋음: 빈 라이브 영역을 미리 두고 텍스트만 교체합니다 -->
<div id="status" role="status"></div>
핵심 원칙은 라이브 영역이 먼저 존재해야 한다는 것입니다. 보조기술은 이미 관찰 중인 영역의 변화를 감지하는 방식이라, 영역과 내용이 동시에 나타나면 놓칠 수 있습니다. 같은 이유로 라이브 영역을 display: none으로 토글하는 구현도 실패합니다. 참고로 role="status"는 aria-live="polite"를 함축하므로 둘을 같이 쓸 필요는 없습니다.
그 외에 반복되는 실수는 다음과 같습니다.
aria-live="assertive"남용. 이것은 진행 중인 읽기를 끊습니다. 실제로 긴급한 경우가 아니면polite를 쓰십시오. 자동 저장 알림 같은 것을 assertive로 두면 사용자는 문서를 읽을 수가 없습니다.- 토스트 자동 소멸. 잠깐 떠 있다가 사라지는 알림은 읽기가 끝나기 전에 없어질 수 있고, 사라진 뒤에는 사용자가 다시 확인할 방법이 남지 않습니다. 중요한 알림은 자동으로 사라지지 않게 하거나 문서 흐름 안에 남기는 편이 낫습니다.
- 이중 알림. 라이브 영역으로 읽어 주면서 동시에 포커스를 그리로 옮기면 같은 문구가 두 번 읽힙니다. 둘 중 하나만 하십시오.
- 과다 갱신. 입력할 때마다 결과 건수를 갱신하면 라이브 영역이 쉬지 않고 떠듭니다. 디바운스를 걸고, 최종 상태만 한 번 알리는 편이 낫습니다.
- 필터·검색 결과 갱신. 목록이 바뀌었으면 결과 건수 같은 상태 문구를 라이브 영역에 넣어야 합니다. 이것이 없는 화면이 대단히 많습니다.
- 비동기 폼 검증. 오류 메시지는 입력 필드와 프로그래밍적으로 연결돼야 하고, 제출 실패 시에는 첫 오류 필드로 포커스를 옮기는 것이 가장 확실합니다.
<label for="email">이메일</label>
<input id="email" type="email"
aria-invalid="true" aria-describedby="email-err">
<p id="email-err">이메일 형식이 올바르지 않습니다.</p>
자동 검사는 aria-live 속성값이 유효한지, aria-describedby가 가리키는 id가 존재하는지 정도만 봅니다. 그 알림이 적절한 시점에, 적절한 횟수로, 실제로 읽히는지는 스크린리더를 켜고 조작해 보는 것 외에 확인할 방법이 없습니다.
인증 심사에서 반복되는 지적
국내 웹 접근성 품질인증은 한국형 웹 콘텐츠 접근성 지침을 기준으로 하고, 심사 과정에 장애인 심사원의 실사용 평가가 포함됩니다. 그래서 자동 검사만 돌리고 신청한 경우 대개 보완 요구를 받습니다. 반복되는 지적 지점은 아래 범주에 몰려 있습니다.
- 대체 텍스트의 등가성: 속성은 있지만 내용이 이미지와 맞지 않는 경우. 파일명, 중복 설명, 장식 이미지에 붙은 불필요한 설명.
- 키보드 접근: 마우스 호버로만 열리는 메뉴, 드래그로만 조작되는 슬라이더, 키보드로 닫을 수 없는 레이어.
- 초점 이동: 시각 순서와 다른 Tab 순서, 초점 표시 제거, 레이어 밖으로 초점이 새는 문제.
- 반복 영역 건너뛰기: 링크는 있는데 실제로 눌러도 본문으로 초점이 이동하지 않는 경우가 의외로 많습니다.
- 제목 제공: 페이지마다 구분되는 제목, 그리고
iframe의 제목. - 자동 재생 콘텐츠 제어: 자동으로 넘어가는 배너나 캐러셀에 정지 수단이 없는 경우. 자동 검사가 거의 못 잡는 대표 항목입니다.
- 표의 구성: 헤더와 셀의 연관, 레이아웃 목적의 표 사용.
- 레이블과 오류 안내: 시각적으로만 라벨처럼 보이는 텍스트, 오류 발생 위치와 정정 방법의 부재.
- 명도 대비: 특히 보조 텍스트, 그리고 비활성처럼 보이지만 비활성이 아닌 요소.
- 응답 시간 조절: 세션 만료 전 연장 수단.
- 확대 시 콘텐츠 손실: 브라우저 확대나 좁은 뷰포트에서 콘텐츠가 잘리거나 겹치는 경우.
목록을 보면 알겠지만, 절반 이상이 "기능이 실제로 동작하는가"에 관한 것입니다. 마크업이 올바른가의 문제가 아닙니다. 그래서 개발이 끝난 뒤 마크업만 손봐서 통과하려 하면 범위가 계속 늘어납니다.
사람이 하는 점검, 이 순서로
전문 감리를 받기 전에 팀이 자체적으로 돌릴 수 있는 순서입니다. 익숙해지면 화면 하나에 오래 걸리지 않고, 이것만 해도 심사 보완 요구의 상당 부분이 미리 걸러집니다.
- 자동 검사를 먼저 돌립니다. 위반 항목을 정리하고, 반드시 "수동 확인 필요" 목록도 함께 뽑아 둡니다. 이 목록이 이후 단계의 출발점입니다.
- 마우스를 치우고 Tab만으로 완주합니다. 주요 과업 하나를 처음부터 끝까지 수행합니다. 포커스가 지금 어디 있는지 항상 보이는가, 순서가 시각 배치와 일치하는가, 갇히거나 사라지는 곳이 없는가를 봅니다. Shift+Tab으로 되돌아오는 경로도 확인합니다.
- 모든 상태를 열어 봅니다. 모달, 드롭다운, 툴팁, 아코디언, 오류가 발생한 폼, 로딩 중 화면. 각 상태에서 다시 2번을 반복합니다. Esc로 닫히는지, 닫은 뒤 포커스가 어디로 가는지가 핵심입니다.
- 브라우저 확대를 올리고 창을 좁힙니다. 200%에서 콘텐츠가 잘리는지 보고, 더 크게 확대해 좁은 뷰포트 상태를 만들었을 때 가로 스크롤이 생기거나 요소가 겹치는지 봅니다. 텍스트 크기만 키우는 설정도 별도로 확인합니다.
- 스크린리더를 켭니다. Windows는 NVDA, macOS는 VoiceOver면 시작으로 충분합니다. 브라우저 조합에 따라 동작이 달라지므로 실제 사용자 비중이 큰 조합을 하나 정해 두십시오. 전부 읽으려 하지 말고 제목 목록과 랜드마크 목록부터 뽑아 봅니다. 그다음 폼 필드 하나하나가 무엇을 요구하는지 들리는지, 상태 변화가 알려지는지 확인합니다.
- 화면 캡처로 대비를 실측합니다. 계산된 CSS 값이 아니라 실제 픽셀을 찍습니다. 다크 모드가 있으면 별도로 다시 합니다.
- 색을 뺀 채로 봅니다. 흑백 필터를 걸고, 색으로만 구분되는 정보가 있는지 확인합니다.
이 과정을 지속 가능하게 만들려면 화면마다 반복하지 말고 디자인 시스템 레벨에서 해결해야 합니다. 버튼, 입력 필드, 모달, 토스트, 탭 같은 공통 컴포넌트를 한 번 제대로 만들어 두면 개별 화면에서 남는 일은 대체 텍스트와 제목 구조, 그리고 화면 고유의 포커스 흐름 정도입니다. 반대로 공통 컴포넌트가 잘못돼 있으면 같은 지적이 화면 수만큼 반복됩니다. 수정 우선순위를 정할 때 공통 컴포넌트를 맨 앞에 두어야 하는 이유입니다.
언제는 하지 말아야 하는가
모든 팀이 모든 항목을 지금 해야 하는 것은 아닙니다. 다음 경우에는 멈추거나 다른 선택을 하는 편이 낫습니다.
ARIA를 먼저 붙이는 것
가장 흔한 자충수입니다. div에 role="button"과 tabindex와 키 핸들러를 붙이느니 button을 쓰는 편이 언제나 낫습니다. ARIA는 네이티브 요소로 표현할 수 없는 의미가 있을 때만 쓰는 보완재입니다. 잘못 붙인 ARIA는 아무것도 안 붙인 것보다 나쁩니다. 요소의 원래 의미를 덮어써서 사용자를 적극적으로 속이기 때문입니다.
접근성 오버레이 위젯 도입
한 줄 스크립트를 넣으면 사이트가 접근 가능해진다고 광고하는 제품군이 있습니다. 실제 보조기술 사용자 커뮤니티에서 지속적으로 비판을 받아 왔고, 기존 보조기술과 충돌해 오히려 사용을 방해한다는 지적이 반복됩니다. 근본 문제를 덮으면서 비용만 지출하는 선택입니다.
텍스트 전용 대체 페이지 제작
"장애인용 페이지"를 따로 만드는 접근은 거의 항상 실패합니다. 유지보수가 본 서비스를 따라가지 못해 금세 낡은 정보가 남고, 결과적으로 차별적인 경험을 만듭니다. 하나의 페이지가 모두에게 동작하도록 만드는 것이 목표여야 합니다.
인증 마크 획득 자체를 목표로 삼는 것
심사를 통과하기 위한 임시 수정은 심사가 끝나면 되돌아갑니다. 인증은 결과 확인 수단이지 목표가 아닙니다. 인증을 받을 계획이 없더라도 키보드 접근과 포커스 관리는 해야 하고, 반대로 인증을 받았다고 해서 점검을 멈춰도 되는 것은 아닙니다.
자동 검사 점수를 CI 게이트로 과하게 조이는 것
규칙 위반 0을 무조건 요구하면, 팀은 문제를 해결하는 대신 규칙을 회피하는 코드를 씁니다. 검사기가 판정을 포기하도록 마크업을 비틀거나, 무의미한 aria-label을 채워 넣는 식입니다. 게이트는 명백한 회귀를 막는 정도로 두고, 판단이 필요한 항목은 리뷰 체크리스트로 옮기는 편이 낫습니다.
전면 리팩터링을 접근성 명분으로 시작하는 것
레거시 화면 전부를 한 번에 고치겠다는 계획은 대개 중간에 멈춥니다. 사용자 유입이 많은 진입 경로, 회원가입과 결제 같은 핵심 과업, 그리고 공통 컴포넌트 순으로 좁혀서 처리하는 편이 실제로 개선을 만듭니다. 내부 인원만 쓰는 관리 도구라면 인증 수준을 목표로 잡을 것이 아니라 키보드로 조작 가능한 정도만 확보하고 넘어가는 것도 합리적인 판단입니다.
검증할 사람이 없는데 복잡한 ARIA 패턴을 쓰는 것
라이브 영역이나 복합 위젯은 실제로 스크린리더로 확인하지 않으면 맞게 만들었는지 알 수 없습니다. 검증 수단이 없다면 화려한 커스텀 위젯 대신 네이티브 요소와 단순한 구조를 택하는 편이 결과적으로 더 접근 가능합니다. 만들 수 있는 것과 검증할 수 있는 것 중 작은 쪽이 그 팀의 실제 역량입니다.
정리
자동 검사는 명백한 실수를 빠르게 걸러 줍니다. 거기까지가 그 도구의 설계 목적이고, 그 이상을 기대하는 것은 도구의 잘못이 아니라 사용하는 쪽의 오해입니다. 접근성에서 실제로 중요한 항목들은 판단이 필요하거나, 상태 전이 중에 나타나거나, 화면에 찍히는 픽셀을 봐야 알 수 있습니다. 전부 사람이 직접 조작해 봐야 하는 것들입니다.
당장 시작할 것을 하나만 고르라면 마우스를 치우고 Tab만으로 서비스의 핵심 과업을 완주해 보는 것입니다. 검사 점수가 만점인 서비스에서도 여기서 대개 막힙니다. 그 지점이 자동 검사가 끝나고 실제 접근성이 시작되는 자리입니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.