시각장애인 고객도 전자동의를 할 수 있을까 — 체크해도 「선택 안 됨」으로 읽히던 화면의 스크린리더 접근성 점검
전자동의는 화면에서 체크하고 서명하면 끝날까요. 스크린리더로 듣는 시각장애인 고객에게는 체크한 항목이 「선택 안 됨」으로 읽힐 수 있습니다. 이미 한 번 손본 모바일 전자계약 화면을 접근성 트리로 다시 점검해 찾은 문제 4가지와, 서버 코드를 건드리지 않고 고친 방법을 정리합니다.

전자동의는 고객이 화면을 보고 체크하고 서명하면 끝난다고 생각하기 쉽습니다. 그런데 화면을 볼 수 없는 고객도 같은 문자 링크를 받습니다. 시각장애인 고객은 TalkBack 이나 VoiceOver 같은 스크린리더로 화면을 들으며 동의하는데, 이때 무엇에 동의했는지가 귀로 확인되지 않으면 그 동의는 화면 속에서만 끝난 셈입니다. 장애인차별금지법도 회사가 만들어 배포하는 전자정보에 장애인이 동등하게 접근할 수 있게 하라고 정하고 있습니다. 그래서 전자동의 화면은 스크린리더 접근성까지 점검해야 합니다.
필자가 맡은 모바일 전자계약 화면은 2024년에 시각장애인 고객의 민원으로 접근성을 한 번 손본 화면이었습니다. 이번에 스크린리더가 실제로 받는 접근성 트리를 직접 뽑아 보니, 체크해도 「선택 안 됨」으로 읽힐 동의 체크박스를 비롯해 문제가 4가지 남아 있었습니다. 서버 코드와 동의 문구는 건드리지 않고 공통 스크립트 한 파일로 보완했습니다.
이 글은 그 과정을 순서대로 적습니다. 대상은 오래된 웹 화면을 유지보수하는 프론트엔드 개발자입니다. 왜 점검이 필요한지, 접근성 트리를 어떻게 뽑았는지, 찾은 문제 4가지와 각각의 해결, 검증 결과, 배운 점 순서입니다. 먼저 밝혀 둘 것이 있습니다. 검증은 접근성 트리 기준으로 마쳤고, 실기기에서 TalkBack 과 VoiceOver 로 실제 음성을 들어 보는 확인은 다음 단계로 남아 있습니다.
전자동의 화면에 스크린리더 접근성 점검이 필요한 이유
장애인차별금지법이 요구하는 것
장애인차별금지 및 권리구제 등에 관한 법률(장차법) 제21조는 정보통신과 의사소통에서 정당한 편의를 제공할 의무를 정합니다. 행위자가 생산하고 배포하는 전자정보에 장애인이 장애인 아닌 사람과 동등하게 접근하고 이용할 수 있도록 필요한 수단을 제공하라는 내용입니다. 시행령 제14조는 그 편의의 하나로 웹사이트 접근성을 듭니다.
적용 대상은 2009년 4월 공공기관부터 시작해 시행령 별표로 단계적으로 넓혀 왔습니다. 지키지 않으면 국가인권위원회 진정과 법무부 시정명령의 대상이 될 수 있고, 법에는 손해배상(제46조)과 법원의 구제조치(제48조) 조항도 있습니다. 내 서비스가 어느 단계에 해당하는지는 시행령 별표 3에서 확인하면 됩니다. 다만 법 적용 여부를 따지기 전에, 고객 중에 스크린리더 사용자가 있다면 이미 풀어야 할 문제입니다.
동의는 무엇에 동의하는지 알고 하는 것이다
동의 기록을 남기는 이유는 고객이 무엇을 확인하고 동의했는지를 증명하기 위해서입니다. 스크린리더 사용자에게 확인한다는 것은 듣는다는 뜻입니다. 체크한 항목이 「선택 안 됨」으로 들리면 고객은 자신이 체크를 했는지 확인할 수 없습니다. 입력칸의 이름이 비어 있으면 무엇을 적는 칸인지도 모릅니다. 화면에서는 체크와 서명이 끝났어도, 그 고객에게는 확인할 수 없는 동의가 됩니다.
전자동의 화면의 접근성은 그래서 편의 기능이 아니라 동의 절차의 일부라고 봅니다. 눈으로 보는 고객과 귀로 듣는 고객이 같은 내용을 확인하고 동의할 수 있어야 같은 동의입니다.
어떤 화면이었나: 오래된 모바일 전자계약 화면
문자 링크로 들어와 서명까지 하는 흐름
고객은 문자로 받은 링크를 눌러 휴대폰에서 들어옵니다. 신청서를 쓰고, 계약 주요 내용을 확인하고, 전자동의 화면에서 항목마다 체크하고 서명한 뒤, 마지막에 계약서를 확인합니다.
화면은 ASP.NET WebForms 와 jQuery Mobile 로 만들어져 있습니다. 서버가 페이지를 그리고, UpdatePanel 이 일부분만 다시 그리고, 동의 항목은 비동기로 늦게 그려지는 구조입니다. 이 구조는 뒤에서 해결 방법을 고를 때 중요한 조건이 됩니다. 전자계약에서 본인인증과 전자서명이 각각 어떤 역할을 하는지는 본인인증과 전자서명 차이를 정리한 글에서 다뤘습니다.
2024년에 이미 한 번 손본 화면
2024년에 VoiceOver 를 쓰는 시각장애인 고객의 민원이 있었습니다. 앞에서 말한 문제가 실제 고객에게서 나온 것입니다. 그때 접근성을 한 차례 보완했고, 들어간 것은 이렇습니다.
- 동의 체크 요소에 role="checkbox" 와 이름(label)
- 약관 「보기」 링크에 「새 창」 안내
- 안내 팝업을 열 때 뒤 화면에 aria-hidden 처리
이번에 화면 전체를 정리하면서 접근성 트리를 직접 떠 봤더니, 그때 넣은 것만으로는 제대로 읽히지 않는 부분이 남아 있었습니다.
스크린리더 접근성은 접근성 트리로 점검한다
접근성 트리란
브라우저는 DOM 을 그대로 스크린리더에 넘기지 않습니다. DOM 과 ARIA 속성을 바탕으로 요소마다 역할(role), 이름(name), 상태(checked, expanded 같은 값)를 계산한 접근성 트리를 따로 만들고, TalkBack 이나 VoiceOver 는 이 트리를 읽습니다.
그래서 코드에 role 과 label 이 있어도, 트리에서 이름이 비어 있거나 상태가 바뀌지 않으면 스크린리더도 그대로 읽습니다. 코드가 아니라 트리를 봐야 하는 이유입니다.
실기기에 원격 디버깅으로 붙기
데스크톱 크롬의 모바일 에뮬레이션으로도 볼 수는 있지만, 고객이 실제로 쓰는 환경에 가깝게 보려고 안드로이드 실기기의 크롬을 USB 로 연결했습니다. 순서는 다음과 같습니다.
- 휴대폰에서 개발자 옵션과 USB 디버깅을 켜고 PC 와 연결합니다.
adb forward tcp:9222 localabstract:chrome_devtools_remote로 휴대폰 크롬의 디버깅 포트를 PC 로 넘깁니다.http://localhost:9222/json에서 열린 탭 목록과 웹소켓 주소를 얻습니다.- 그 주소로 Chrome DevTools Protocol(CDP)에 붙어
Accessibility.getFullAXTree를 호출합니다.
마지막 호출이 돌려주는 것이 스크린리더가 받는 이름, 역할, 상태입니다. 크롬 DevTools 의 Accessibility 패널에서 요소를 하나씩 볼 수도 있지만, 화면 전체를 한 번에 세려면 프로토콜로 뽑는 편이 빠릅니다.
이름과 상태를 세는 작은 점검 스크립트
뽑은 트리에서 버튼, 링크, 체크박스, 입력칸만 골라 이름이 비었는지, 체크 상태 값이 무엇인지를 표로 남기는 스크립트를 만들었습니다. 아래는 그 생각을 짧게 다시 쓴 예시이고, 실제 코드 그대로는 아닙니다.
// nodes: Accessibility.getFullAXTree 가 돌려준 nodes 배열
const ROLES = ['button', 'link', 'checkbox', 'textbox', 'combobox', 'radio'];
function audit(nodes) {
const rows = [];
for (const n of nodes) {
const role = n.role && n.role.value;
if (n.ignored || !ROLES.includes(role)) continue;
const name = ((n.name && n.name.value) || '').trim();
const checked = (n.properties || []).find(p => p.name === 'checked');
rows.push({ role, name, checked: checked ? checked.value.value : '-' });
}
console.table(rows);
console.log('이름 없음:', rows.filter(r => !r.name).length);
}
이 표를 동의 항목을 체크하기 전과 후에 한 번씩 뽑아 나란히 놓으면, 상태가 바뀌어야 할 곳이 그대로인지 바로 보입니다. 이름 열을 훑으면 같은 말이 두 번 붙은 이름도 눈에 띕니다.
접근성 트리로 찾은 문제 4가지와 해결
먼저 정한 원칙: 서버와 동의 문구는 건드리지 않는다
고치기 전에 원칙부터 정했습니다. 전자계약에서 동의 문구와 처리 흐름은 법적 기록입니다. 고객이 어떤 문구를 보고 어떻게 동의했는지가 그대로 남아야 하므로, 화면을 보완하겠다고 이것을 바꾸면 안 됩니다. 동의 기록을 어떻게 남기는지는 전자동의 기록 보관 방법을 다룬 글에 정리했습니다.
- 서버 코드, 동의 문구, 동의 스크립트는 수정하지 않는다
- 마스터 페이지가 모든 고객 화면에 싣는 공통 스크립트 한 파일에만 덧붙인다
- 이미 이름이 있는 요소(aria-label, label for)는 건드리지 않는다
1. 체크해도 「선택 안 됨」으로 읽히는 동의 체크박스
동의 체크박스 16개는 role="checkbox" 와 이름이 정상이었습니다. 그런데 체크해도 상태가 바뀌지 않았습니다. 체크 표시는 CSS 클래스 on 을 붙였다 뗐다 하는 것으로만 그려지고, aria-checked 를 바꾸는 코드는 어디에도 없었습니다. 눈으로 보면 체크가 되는데, 스크린리더에는 늘 「선택 안 됨」으로 읽힐 상태였습니다.
2024년 보완 때 role 을 붙인 것은 맞는 방향이었습니다. 다만 role="checkbox" 는 상태도 직접 관리하겠다는 약속입니다. 네이티브 체크박스(input type="checkbox")는 브라우저가 상태를 알아서 알려 주지만, div 나 span 에 role 을 붙이면 aria-checked 는 개발자가 바꿔 줘야 합니다.
동의 스크립트를 고치지 않으려면, 클래스 변화를 지켜보다가 aria-checked 를 따라 바꾸는 방법이 가장 덜 건드리는 방법이었습니다. MutationObserver 로 role="checkbox" 요소의 class 변화를 보고, on 이 있으면 true, 없으면 false 로 맞춥니다. 키보드 사용자를 위해 tabindex="0" 과 스페이스, 엔터 처리도 붙였습니다. 키를 누르면 기존 클릭 처리를 그대로 태우므로 동의 흐름은 달라지지 않습니다.
까다로운 점은 동의 항목이 비동기로 늦게 그려지고, UpdatePanel 이 부분 갱신으로 요소를 통째로 갈아 끼운다는 것이었습니다. 처음 한 번 훑는 것으로는 부족합니다. 그래서 자식 노드가 바뀌면 50ms 를 기다렸다가 화면을 다시 훑게 했습니다. 갱신이 연달아 와도 마지막 한 번만 훑습니다.
function syncCheckbox(el) {
el.setAttribute('aria-checked', el.classList.contains('on') ? 'true' : 'false');
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
}
function scanAll() {
document.querySelectorAll('[role="checkbox"]').forEach(syncCheckbox);
}
var timer;
new MutationObserver(function (records) {
records.forEach(function (r) {
if (r.type === 'attributes' && r.target.getAttribute('role') === 'checkbox') {
syncCheckbox(r.target); // class 가 바뀐 그 요소만 바로 맞춘다
} else if (r.type === 'childList') {
clearTimeout(timer); // 늦게 그려지거나 갈아 끼운 요소는
timer = setTimeout(scanAll, 50); // 50ms 뒤 한 번에 다시 훑는다
}
});
}).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] });
document.addEventListener('keydown', function (e) {
var el = e.target;
if (el.getAttribute && el.getAttribute('role') === 'checkbox' && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
el.click(); // 기존 동의 처리를 그대로 태운다
}
});
scanAll();
2. 「동의완료 동의완료」처럼 두 번 읽히는 버튼
「동의완료」와 「다시쓰기」 버튼은 이름이 두 번 읽힐 상태였습니다. 보이는 글자 옆에, 화면에서만 감춘 시각장애인용 글자(.blind)가 같은 내용으로 들어 있었기 때문입니다. 스크린리더는 둘 다 읽으니 「동의완료 동의완료」가 됩니다.
그렇다고 .blind 를 전부 감추면 안 됩니다. 「보기」 링크 옆의 숨김 글자 「새 창」은 보이는 글자에 없는 정보를 더해 줍니다. 그래서 조건을 하나 두었습니다. .blind 글자가 부모의 나머지 글자와 같을 때만 aria-hidden="true" 를 붙이고, 다를 때는 그대로 둡니다.
document.querySelectorAll('.blind').forEach(function (b) {
var hidden = b.textContent.trim();
var rest = b.parentElement.textContent.replace(b.textContent, '').trim();
if (hidden && hidden === rest) b.setAttribute('aria-hidden', 'true');
});
이 처리도 실제로는 위의 다시 훑기에 같이 넣어, 늦게 그려진 버튼에도 적용되게 했습니다.
3. 예시 글자로 읽히거나 이름이 없는 입력칸
신청 화면의 항목명은 span 으로 되어 있어 입력칸과 연결되어 있지 않았습니다. 그래서 이름 칸은 예시 글자(placeholder)인 「신청자명」으로, 생년월일 칸은 「ex.20221031」로 읽혔습니다. 통신사와 성별 칸처럼 예시 글자도 없는 곳은 이름이 아예 비어 있었습니다. 신청 화면에서 이름 없는 입력칸은 6개였습니다.
placeholder 는 브라우저가 이름을 찾다가 마지막에 쓰는 대체 수단일 뿐이고, 글자를 입력하면 화면에서 사라집니다. 예시 형식이 이름처럼 읽히면 무엇을 쓰는 칸인지 알기 어렵습니다.
보완은 이렇게 했습니다.
- 카드형 화면의 항목명(.cf-label)에 id 를 주고, 같은 항목 안에 이름 없는 입력칸이 있으면 aria-labelledby 로 연결합니다.
- 한 항목에 칸이 여럿이면 칸마다 고유한 이름을 줍니다. 「생년월일 8자리」, 「주민등록번호 뒷자리 첫 번째 숫자」, 「통신사」 같은 식입니다.
- 카드번호 4칸처럼 같은 칸이 반복되면 「카드번호 1번째 칸」처럼 순서를 붙입니다.
- 성별 같은 라디오 묶음은 감싼 요소에 role="radiogroup" 을 주고 항목명과 연결합니다.
- 이미 aria-label 이나 label for 로 이름이 있는 칸은 건너뜁니다.
4. 떴는지 알 수 없는 안내 창
안내 창은 두 종류였습니다. 화면에서 직접 만든 모달과 jQuery Mobile 팝업입니다. 둘 다 대화상자라는 표시가 없었고, 열려도 포커스가 옮겨 가지 않았습니다. 스크린리더 사용자는 창이 떴는지 알기 어려운 상태였습니다.
열릴 때와 닫힐 때 할 일을 정했습니다.
- 열릴 때 role="alertdialog", aria-modal="true", 본문을 가리키는 aria-describedby 를 붙이고 [확인] 버튼으로 포커스를 옮깁니다.
- 닫힐 때는 창을 열기 직전에 포커스가 있던 자리로 돌려놓습니다.
- 단, 「채울 칸으로 데려가기」 기능이 닫히면서 포커스를 다른 입력칸으로 옮겼다면 그쪽을 우선합니다. 빈칸을 알려 주는 안내라면 그 칸으로 가는 것이 맞기 때문입니다.
jQuery Mobile 팝업은 popupafteropen 과 popupafterclose 이벤트에 걸었고, 직접 만든 모달은 기존 표시 함수를 감싸서 앞뒤에 같은 처리를 넣었습니다. 기존 함수의 동작은 그대로 두고 앞뒤만 덧붙이는 방식입니다.
검증: 같은 점검을 보완 전과 후로 비교
검증에는 처음에 문제를 찾은 점검 스크립트를 그대로 썼습니다. 공통 스크립트를 넣기 전과 넣은 뒤에 같은 화면, 같은 조작으로 트리를 뽑아 비교했습니다.
| 항목 | 보완 전 | 보완 후 (트리 기준) |
|---|---|---|
| 동의 체크박스 상태 | 체크해도 변화 없음 | 체크하면 checked=true |
| 「동의완료」「다시쓰기」 이름 | 두 번 | 한 번 |
| 「보기」 링크의 「새 창」 안내 | 있음 | 그대로 유지 |
| 신청 화면의 이름 없는 입력칸 | 6개 | 0개 |
다만 이것은 트리 기준의 확인입니다. 트리에 맞는 값이 들어갔다는 것과 TalkBack, VoiceOver 가 의도대로 읽어 준다는 것은 같은 말이 아닙니다. 읽는 순서, 포커스가 옮겨 갈 때 실제로 무엇을 말하는지, 안내 창이 열리면 바로 읽어 주는지는 실기기에서 들어 봐야 압니다. 이 확인은 배포 후 운영 테스트에서 하기로 했고, 이 글을 쓰는 시점에는 아직 하지 않았습니다.
배운 점: role 과 label 은 시작일 뿐
붙였다는 것과 제대로 읽힌다는 것은 다르다
2024년 보완은 방향이 맞았습니다. 그래도 상태가 바뀌는지, 이름이 두 번 읽히지 않는지는 속성을 붙인 것만으로는 알 수 없었고, 트리를 떠 보고 나서야 보였습니다. 특히 role 을 직접 붙인 요소는 상태 관리까지 개발자 몫이라는 점을 놓치기 쉽습니다.
트리 점검은 싸고 빠른 1차 거름망이다
실기기 음성 테스트는 정확하지만 시간이 듭니다. 화면을 하나씩 손으로 넘기며 들어야 하니까요. 접근성 트리 점검은 스크립트 한 번이면 화면 전체의 이름과 상태가 나옵니다. 이름 빈칸, 중복 이름, 바뀌지 않는 상태 같은 문제는 여기서 대부분 걸러지고, 실기기 테스트는 읽는 순서와 흐름처럼 사람이 들어야 아는 부분에 집중할 수 있습니다.
오래된 화면일수록 덧붙이는 방식이 안전하다
서버 코드나 마크업을 고치면 영향 범위가 넓어지고, 동의 문구처럼 법적 기록이 걸린 화면은 바꾸는 것 자체가 부담입니다. 공통 스크립트 한 파일에 덧붙이는 방식은 되돌리기도 쉽습니다. 문제가 생기면 그 파일에서 덧붙인 부분만 빼면 됩니다.
한계도 있습니다. 스크립트가 돌기 전의 짧은 순간은 예전 상태 그대로이고, 근본적으로는 네이티브 요소(input, label)로 다시 만드는 편이 낫습니다. 덧붙이기는 화면을 새로 만들 수 없을 때의 선택입니다.
숨김 글자는 정보를 더할 때만 쓴다
.blind 는 보이는 글자에 없는 정보를 줄 때 의미가 있습니다. 「새 창」처럼요. 보이는 글자와 같은 내용을 숨김 글자로 또 넣으면 도움이 아니라 소음이 됩니다.
레거시 화면 스크린리더 접근성 점검 순서
오래된 화면의 스크린리더 접근성을 손볼 때 바로 따라 해 볼 수 있는 순서입니다.
- 실기기(또는 데스크톱 크롬)에 CDP 로 붙어 Accessibility.getFullAXTree 로 트리를 뽑는다
- 버튼, 링크, 체크박스, 입력칸 가운데 이름이 빈 것을 센다
- role="checkbox" 같은 직접 만든 요소는 조작 전후로 상태 값이 바뀌는지 본다
- 이름에 같은 말이 두 번 들어간 곳을 찾는다
- placeholder 가 이름 대신 읽히는 입력칸을 찾는다
- 팝업과 모달이 열릴 때 대화상자 표시와 포커스 이동이 있는지 본다
- 고칠 때는 서버와 문구를 건드리지 않는 덧붙이기부터 검토한다
- 같은 점검으로 전후를 비교하고, 실기기 음성 확인은 마지막에 따로 잡는다
맺음말
전자동의는 화면에서 체크하고 서명하는 것으로 끝나지 않습니다. 화면을 듣는 고객도 무엇에 동의하는지 확인할 수 있어야 같은 동의입니다. 이번 작업에서 가장 크게 남은 것은 그것을 확인하는 순서입니다. 속성을 붙이고 끝내지 않고, 접근성 트리를 뽑아 실제로 무엇이 들어갔는지 확인하는 단계를 하나 더 두는 것입니다. 그 단계 하나로 이미 한 번 손본 화면에서도 체크 상태, 중복 이름, 빈 이름, 안내 창 문제 4가지가 나왔습니다. 스크린리더 접근성은 트리에서 한 번, 실기기 음성으로 한 번 확인해야 끝납니다. 이 화면은 지금 앞의 확인까지 마친 상태이고, 실기기 음성 확인이 다음 단계입니다.