웹 접근성은 왜 기능이 아니라 기본 품질일까: 구조부터 점검까지 실무적으로 이해하기
빠른 답
- 웹 접근성은 일부 사용자를 위한 부가 기능이 아니라, 더 많은 사용자가 같은 기능을 실제로 사용할 수 있게 만드는 기본 품질입니다.
- 출발점은 복잡한 위젯이나
ARIA가 아니라HTML구조, 제목 체계, 버튼과 링크의 의미 같은 기본 마크업입니다. ARIA는 시맨틱 요소를 대체하지 못하고, 이름·역할·상태를 보강해야 할 때만 의미가 있습니다.- 자동 검사 점수보다 중요한 것은 키보드와 스크린 리더로 핵심 사용자 흐름을 끝까지 수행할 수 있는지입니다.
목차
점검 순서
- 제목 구조, 랜드마크, 버튼·링크의 의미를 먼저 확인합니다.
- 마우스를 치우고
Tab,Shift+Tab,Enter, 방향키만으로 이동해 봅니다. - 폼 입력, 오류 메시지, 제출 실패 흐름이 읽히는지 점검합니다.
- 모달·탭·드롭다운 같은 복합 UI에 필요한
ARIA만 보강합니다. - 자동 진단 도구로 누락을 찾고, 마지막에 실제 보조기기 탐색으로 우선순위를 다시 조정합니다.
이 순서가 유용한 이유는 접근성 문제가 대개 화려한 인터랙션보다 기본 구조에서 먼저 드러나기 때문입니다. 처음부터 속성을 많이 덧붙이기보다, 문서 구조와 상호작용 흐름을 바로잡는 편이 실제 사용성 개선으로 이어지는 경우가 많습니다.
흐름으로 보기
화면에 보이는 모양과 보조기기에 전달되는 정보는 완전히 같은 것이 아닙니다. 브라우저는 단순히 픽셀만 그리는 것이 아니라 문서의 의미를 해석하고, 그 결과를 접근성 트리로 정리해 전달합니다. 그래서 같은 디자인이라도 button으로 만든 화면과 클릭 이벤트만 붙인 div로 만든 화면은 사용 경험이 크게 달라질 수 있습니다.
웹 접근성을 기본 품질로 보는 이유
웹 접근성은 흔히 장애가 있는 사용자를 위한 별도 요구사항으로만 이해되지만, 실제로는 서비스 전반의 사용 가능성을 다루는 기준에 가깝습니다. 밝은 야외에서 낮은 대비의 텍스트를 읽어야 하는 상황, 한 손으로 키보드 탐색을 해야 하는 상황, 일시적으로 마우스 사용이 어려운 상황도 모두 접근성과 연결됩니다.
이 관점에서 보면 접근성은 디자인의 마지막 장식이 아니라 정보 구조와 상호작용 설계의 일부입니다. WCAG가 말하는 인식 가능성, 조작 가능성, 이해 가능성, 견고성 역시 결국 같은 방향을 가리킵니다. 내용이 보이는지, 조작할 수 있는지, 이해할 수 있는지, 다양한 환경에서 해석 가능한지가 함께 맞아야 합니다. 기준을 잡을 때는 WCAG, 복합 위젯 패턴은 WAI-ARIA Authoring Practices, 브라우저 기본 요소는 MDN 접근성 문서를 참고하면 현재 기준을 정리하기 좋습니다.
사용자는 어디에서 막히는가
접근성 문제를 이해할 때는 사용자가 막히는 지점을 몇 가지 축으로 나눠 보는 편이 도움이 됩니다.
첫째는 인식입니다. 이미지에 대체 텍스트가 없거나, 제목 구조가 뒤섞여 있거나, 텍스트 대비가 부족하면 콘텐츠 자체를 파악하기 어렵습니다. 이 문제는 스크린 리더 사용자뿐 아니라 작은 화면이나 밝은 환경에서도 그대로 드러납니다.
둘째는 조작입니다. 클릭 가능한 요소가 실제 버튼이 아니어서 키보드로 도달할 수 없거나, 포커스 표시가 사라져 현재 위치를 알 수 없거나, 모달을 열었는데 포커스가 배경으로 빠져버리면 사용자는 다음 행동을 이어가기 어렵습니다. 조작 문제는 특히 마우스를 쓰지 않을 때 빠르게 드러납니다.
셋째는 이해입니다. 레이블 없는 입력창, 모호한 링크 텍스트, 제출에 실패했는데 어디가 잘못됐는지 알려주지 않는 폼은 사용자가 스스로 판단하기 어렵게 만듭니다. 접근성은 정보를 보여주는 일뿐 아니라, 그 정보가 어떤 맥락에서 쓰이는지 전달하는 일과도 가깝습니다.
마지막으로 호환성도 있습니다. 화면에는 정상처럼 보여도 코드 안에서 이름, 역할, 상태가 분명하지 않으면 보조기기에서는 전혀 다른 의미로 읽힐 수 있습니다. 그래서 접근성은 시각 디자인만으로 평가하기 어렵고, 브라우저가 해석할 구조를 함께 살펴봐야 합니다.
시맨틱 구조가 접근성의 출발점인 이유
시맨틱 마크업이 중요한 이유는 브라우저가 이미 많은 기본 동작을 제공하기 때문입니다. button은 버튼이라는 역할과 키보드 동작을 함께 갖고 있고, label은 입력의 이름을 연결하며, nav, main, form 같은 랜드마크는 문서의 큰 구조를 드러냅니다. 이런 기본 의미를 잘 사용하면 접근성의 상당 부분은 별도 속성 없이도 정리됩니다.
반대로 모양만 비슷하게 만든 비시맨틱 구조는 문제를 남기기 쉽습니다. 클릭 가능한 div는 마우스로는 동작할 수 있어도 키보드 포커스를 받지 못할 수 있고, 제목을 전부 굵은 텍스트로만 처리하면 문서 탐색 구조가 사라집니다. 플레이스홀더를 label 대신 쓰는 패턴도 자주 보이는데, 입력 중에는 안내가 사라지고 보조기기에도 안정적으로 연결되지 않기 쉬워서 폼 이해를 어렵게 만듭니다.
접근성을 손볼 때는 새로운 속성을 덧붙이기 전에 먼저 이런 질문을 해볼 만합니다. 이 요소는 원래 버튼이어야 하는가, 링크여야 하는가, 제목이어야 하는가, 입력과 설명이 연결되어 있는가. 이 질문에 대한 답이 정리되면 이후의 ARIA 사용도 훨씬 줄어듭니다.
폼, 버튼, 모달에서 바로 적용하는 코드 예시
폼에서는 입력창 하나만 보지 말고, 입력의 이름과 설명, 오류가 하나의 흐름으로 연결되는지 보는 편이 좋습니다. 아래 예시는 이메일 입력과 완료 모달을 함께 다루는 간단한 예시입니다. 레이블은 label로 연결하고, 도움말과 오류는 aria-describedby로 묶고, 오류가 있을 때만 aria-invalid를 설정합니다. 모달은 열릴 때 내부로 이동하고 닫힐 때 다시 열기 버튼으로 돌아오게 구성했습니다.
import { FormEvent, useId, useRef, useState } from 'react';
export function SignupSection() {
const inputId = useId();
const helpId = `${inputId}-help`;
const errorId = `${inputId}-error`;
const dialogRef = useRef<HTMLDialogElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [email, setEmail] = useState('');
const [error, setError] = useState('');
function submit(event: FormEvent) {
event.preventDefault();
if (!email.includes('@')) {
setError('이메일 형식을 확인해 주세요.');
return;
}
setError('');
dialogRef.current?.showModal();
}
function closeDialog() {
dialogRef.current?.close();
openButtonRef.current?.focus();
}
return (
<>
<form onSubmit={submit} noValidate>
<label htmlFor={inputId}>이메일</label>
<input
id={inputId}
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
aria-describedby={error ? `${helpId} ${errorId}` : helpId}
aria-invalid={error ? 'true' : 'false'}
/>
<p id={helpId}>로그인과 알림 수신에 사용할 주소입니다.</p>
{error ? (
<p id={errorId} role="alert">
{error}
</p>
) : null}
<button ref={openButtonRef} type="submit">
가입하기
</button>
</form>
<dialog ref={dialogRef} aria-labelledby="signup-complete-title">
<h2 id="signup-complete-title">가입이 완료되었습니다.</h2>
<p>확인 메일을 보냈습니다.</p>
<button type="button" onClick={closeDialog}>
닫기
</button>
</dialog>
</>
);
}
이 예시에서 눈여겨볼 부분은 세 가지입니다. 입력의 이름을 시각적으로만 두지 않고 코드에도 연결했다는 점, 오류를 색상만으로 표시하지 않고 실제 문장으로 제공했다는 점, 대화상자를 닫았을 때 사용자가 다시 출발 지점을 잃지 않도록 포커스를 되돌렸다는 점입니다. 브라우저의 기본 요소를 활용하면 적은 코드로도 꽤 많은 문제를 줄일 수 있습니다. dialog 자체의 동작과 제약은 MDN dialog 문서에서 함께 확인해 볼 수 있습니다.
ARIA는 언제 필요하고 언제 멈추는 편이 나은가
ARIA는 접근성을 해결하는 만능 수단이라기보다, 기본 시맨틱으로 부족한 부분을 보강하는 도구에 가깝습니다. 예를 들어 접기·펼치기 버튼의 상태를 알릴 때 aria-expanded를 쓰거나, 비동기 저장이 끝났다는 메시지를 전달하기 위해 aria-live를 두는 것은 도움이 됩니다. 탭, 콤보박스, 메뉴 버튼처럼 상태와 관계를 함께 알려야 하는 복합 위젯에서도 ARIA가 필요할 수 있습니다.
반대로 이미 의미가 있는 요소에 같은 역할을 다시 적는 것은 도움이 되지 않는 경우가 많습니다. button에 role="button"을 다시 적거나, 클릭 가능한 div에 role="button"만 붙이고 키보드 이벤트와 포커스를 구현하지 않으면 오히려 의미와 동작이 어긋납니다. 화면에 보이는 텍스트와 다른 값을 aria-label로 억지로 넣는 패턴도 혼란을 만들기 쉽습니다. 사용자는 화면에서는 "저장"을 보고 있는데 스크린 리더에서는 "등록"이라고 읽을 수 있기 때문입니다.
간단히 정리하면 이렇습니다. 기본 요소만으로 충분하면 ARIA 없이 두고, 상태 변화나 위젯 관계를 전달해야 할 때만 필요한 속성을 더하는 편이 무리가 적습니다. 이름, 역할, 상태가 서로 맞지 않는 ARIA는 정보량을 늘리는 대신 해석 오류를 만들 수 있습니다.
키보드 포커스, 대비, 오류 메시지는 함께 봐야 한다
접근성 점검에서 자주 빠지는 부분이 포커스 표시입니다. 디자인 정리를 하면서 outline을 지워 버리면 키보드 사용자는 현재 위치를 잃기 쉽습니다. 포커스는 보이는 것만으로는 충분하지 않고, 배경과 구분될 정도로 분명해야 합니다. 오류 표시도 비슷합니다. 빨간 테두리만 두는 방식은 색만으로 상태를 전달하기 때문에 상황에 따라 충분하지 않을 수 있습니다.
:focus-visible {
outline: 3px solid #0b57d0;
outline-offset: 2px;
}
input[aria-invalid='true'] {
border-color: #b42318;
}
.error-text {
color: #b42318;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
이 정도의 스타일만 있어도 키보드 이동과 오류 인지가 훨씬 나아집니다. 여기에 오류 문장을 함께 제공하면 색을 구분하기 어려운 환경에서도 의미가 유지됩니다. 자주 놓치는 패턴으로는 outline: none, tabindex="1" 같은 양수 tabindex, 닫힌 모달 뒤에 남아 있는 포커스 가능한 요소, 플레이스홀더를 레이블처럼 사용하는 방식이 있습니다. 이런 패턴은 화면상으로는 사소해 보여도 사용자의 이동 흐름을 쉽게 끊습니다.
개발 과정에 녹이는 설정과 자동 점검
접근성은 배포 직전 한 번 검사해서 끝나는 작업보다, 컴포넌트 작성 단계에서 잘못된 패턴을 줄이는 쪽에 가깝습니다. 프런트엔드 프로젝트에서는 린트 규칙으로 기본 실수를 줄이고, 테스트 단계에서 핵심 화면을 자동 점검해 두면 반복 비용이 많이 낮아집니다.
아래 예시는 eslint-plugin-jsx-a11y를 사용하는 간단한 설정입니다. 대체 텍스트 누락, 유효하지 않은 링크, 레이블 연결 문제 같은 자주 발생하는 실수를 초기에 잡는 데 도움이 됩니다.
// eslint.config.js
import js from '@eslint/js';
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
js.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'jsx-a11y': jsxA11y,
},
rules: {
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-autofocus': 'warn',
'jsx-a11y/click-events-have-key-events': 'error',
},
},
];
자동 검사는 구조적 실수를 줄이는 데는 도움이 되지만, 실제 상호작용 흐름까지 보장하지는 않습니다. 그래서 핵심 화면은 테스트 코드로 한 번 더 잡아 두는 편이 좋습니다. 아래 예시는 Playwright와 axe-core를 함께 써서 접근성 회귀를 확인하는 형태입니다.
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
test('signup page accessibility', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('이메일').fill('wrong-email');
await page.getByRole('button', { name: '가입하기' }).click();
await expect(page.getByRole('alert')).toContainText('이메일 형식');
await expect(page.getByLabel('이메일')).toBeFocused();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
이런 테스트는 접근성을 완성해 주기보다, 이미 개선한 흐름이 다시 깨지지 않도록 붙잡아 주는 역할에 가깝습니다. 특히 로그인, 검색, 결제, 저장처럼 자주 쓰는 흐름은 자동 점검 대상으로 삼아 두는 편이 운영에 도움이 됩니다.
개선 우선순위를 잡을 때 놓치기 쉬운 점
접근성을 한 번에 모두 해결하려고 하면 범위가 너무 넓어 보일 수 있습니다. 그래서 보통은 점수나 항목 개수보다 핵심 사용자 여정을 기준으로 우선순위를 잡는 편이 현실적입니다. 전자상거래라면 상품 탐색과 결제, 사내 도구라면 로그인과 검색과 저장, 콘텐츠 서비스라면 목록 탐색과 본문 읽기와 재생 제어가 먼저일 수 있습니다.
이 과정에서 자주 생기는 오해도 몇 가지 있습니다. 자동 진단 도구 점수가 높으면 충분하다고 보는 경우, ARIA 속성을 많이 넣을수록 더 좋아진다고 생각하는 경우, 플레이스홀더가 있으니 label은 생략해도 된다고 보는 경우, 포커스 표시를 디자인 노이즈처럼 취급하는 경우가 그렇습니다. 이런 판단은 겉보기 품질은 유지할 수 있어도 실제 사용 흐름은 놓치기 쉽습니다.
조금 더 안정적인 접근은 구조와 흐름을 먼저 정리하고, 복합 위젯은 그다음에 다듬고, 자동화는 반복 검증 수단으로 붙이는 방식입니다. 그리고 한 번쯤은 키보드만으로 첫 화면부터 마지막 액션까지 직접 이동해 보는 편이 좋습니다. 코드 리뷰나 자동 검사에서는 놓쳤던 막힘이 그 과정에서 꽤 선명하게 드러나는 경우가 많습니다.
원문 참고
https://www.maeil-mail.kr/question/47
댓글
댓글 쓰기