메인 콘텐츠로 이동

웹 접근성이란 무엇이고 어떻게 적용하는가

·-
링크 복사 완료!
Web AccessibilityUXNext.js
접근성 심볼과 키보드가 있는 웹 접근성 일러스트

웹은 모두를 위한 것

팀 버너스리가 웹을 만들었을 때 핵심 원칙이 있었어요. "The power of the Web is in its universality." 어떤 장애가 있든 없든 모든 사람이 접근할 수 있어야 한다는 거예요.

웹 접근성이라고 하면 보통 '장애인을 위한 것'이라고 생각하기 쉬운데 그게 전부는 아니에요. 손에 무언가를 들고 있으면 마우스를 쓸 수 없고 밝은 햇빛 아래에서 화면의 글자가 잘 보이지 않을 수 있어요. 이어폰 없이 공공장소에서 영상을 볼 때 자막이 없으면 내용을 알 수 없고요. 이런 상황을 일시적 장애(situational disability)라고 부르는데 누구에게나 일어날 수 있는 일이에요.

재미있는 건 접근성을 위해 만든 것들이 결국 모든 사용자에게 도움이 된다는 거예요. 보도블록의 경사로는 휠체어 사용자를 위해 만들었지만 유모차를 끌거나 캐리어를 끌 때도 편리하잖아요. 이런 걸 경사로 효과(curb cut effect)라고 해요. 웹에서도 마찬가지예요. 키보드 네비게이션은 파워 유저에게도 빠른 조작 수단이 되고 시맨틱 HTML은 검색 엔진이 콘텐츠를 이해하는 데 도움이 돼요.

접근성은 특별한 배려가 아니라 좋은 설계의 자연스러운 결과예요.

시맨틱 HTML이나 Skip Navigation 같은 기본적인 내용은 이전 글에서 다뤘어요.

SEO 최적화: 검색 결과에 잘 보이는 블로그 만들기
SEO 최적화: 검색 결과에 잘 보이는 블로그 만들기메타태그, 구조화 데이터, 시맨틱 HTML, RSS 피드까지 — 이 블로그에 적용한 SEO 최적화를 정리했어요.

이번 글에서는 좀 더 깊은 내용을 다뤄볼게요. 키보드 네비게이션, 포커스 관리, ARIA 속성, 색상 대비처럼 실제로 컴포넌트를 만들 때 신경 써야 하는 것들이에요.

키보드 네비게이션

마우스를 쓸 수 없는 사용자는 키보드만으로 모든 기능을 사용할 수 있어야 해요. Tab으로 이동하고 Enter로 실행하고 Escape로 닫는 게 기본이에요.

모달의 포커스 트래핑

모달이 열렸을 때 Tab을 누르면 모달 뒤의 요소로 포커스가 빠져나가면 안 돼요. 모달 안에서만 포커스가 순환해야 해요. 이걸 포커스 트래핑이라고 해요.

// ConfirmModal.tsx
useEffect(() => {
  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") onCancel();
    if (e.key === "Tab") {
      if (e.shiftKey && document.activeElement === cancelRef.current) {
        e.preventDefault();
        confirmRef.current?.focus();
      } else if (!e.shiftKey && document.activeElement === confirmRef.current) {
        e.preventDefault();
        cancelRef.current?.focus();
      }
    }
  };
  window.addEventListener("keydown", onKeyDown);
  return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel]);

버튼이 두 개인 간단한 모달이라서 직접 구현했어요. Tab을 누르면 취소 → 확인 → 취소... 순으로 순환하고 Shift+Tab은 반대 방향으로 돌아요. Escape를 누르면 모달이 닫히고요.

포커스 가능한 요소가 더 많은 모달에서는 querySelectorAll로 포커스 가능한 요소를 모아서 처리해요.

// PasswordModal.tsx
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
  "input, button:not([disabled])",
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
 
if (e.shiftKey && document.activeElement === first) {
  e.preventDefault();
  last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
  e.preventDefault();
  first.focus();
}

첫 번째 요소에서 Shift+Tab을 누르면 마지막으로 가고 마지막에서 Tab을 누르면 첫 번째로 돌아와요. 이 패턴은 모달에서 거의 공통적으로 쓰이는 패턴이에요.

모달이 열릴 때 body 스크롤을 막는 것도 잊지 말아야 해요. 모달 뒤에서 스크롤이 되면 사용자가 혼란스러워해요.

useEffect(() => {
  document.body.style.overflow = "hidden";
  return () => {
    document.body.style.overflow = "";
  };
}, []);

화살표 키 네비게이션

이미지 뷰어처럼 좌우로 이동하는 컴포넌트에서는 화살표 키를 지원하면 자연스러워요.

// ImageZoomModal.tsx
const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === "Escape") onClose();
  else if (e.key === "ArrowLeft" && hasPrev) go(-1);
  else if (e.key === "ArrowRight" && hasNext) go(1);
};

터치 디바이스에서는 스와이프도 지원하고 있어요.

onTouchStart={(e) => {
  const touch = e.touches[0];
  (e.currentTarget as HTMLElement).dataset.touchX = String(touch.clientX);
}}
onTouchEnd={(e) => {
  const startX = Number((e.currentTarget as HTMLElement).dataset.touchX);
  const endX = e.changedTouches[0].clientX;
  const diff = startX - endX;
  if (Math.abs(diff) > 50) {
    if (diff > 0 && hasNext) go(1);
    else if (diff < 0 && hasPrev) go(-1);
  }
}}

50px 이상 스와이프해야 이동하도록 임계값을 두었어요. 실수로 살짝 터치한 것과 의도적인 스와이프를 구분하기 위해서예요.

검색바의 Escape 처리

검색바에서 Escape를 누르면 입력값을 지우고 포커스를 해제해요. 사소하지만 키보드 사용자를 위해 챙긴 디테일이에요.

// SearchBar.tsx
onKeyDown={(e) => {
  if (e.key === "Escape") {
    setQuery("");
    inputRef.current?.blur();
  }
}}

ARIA 속성

ARIA(Accessible Rich Internet Applications)는 HTML만으로 전달하기 어려운 의미를 보충해주는 속성이에요. 스크린 리더가 요소의 역할과 상태를 이해하는 데 중요해요.

모달에 role과 aria-modal

// ConfirmModal.tsx
<motion.div
  role="alertdialog"
  aria-modal="true"
  aria-label={title}
>

role="dialog"는 일반 대화상자이고 role="alertdialog"는 중요한 확인이 필요한 대화상자예요. 삭제 확인 같은 건 alertdialog가 더 적합해요. aria-modal="true"는 모달 뒤의 콘텐츠가 비활성화되었다는 걸 스크린 리더에게 알려줘요.

토글 버튼의 aria-pressed

// LikeButton.tsx
<motion.button
  type="button"
  aria-label={`좋아요 ${count}`}
  aria-pressed={liked}
>

좋아요 버튼처럼 눌린/안 눌린 상태가 있는 버튼에는 aria-pressed를 써요. 스크린 리더가 "좋아요 3, 토글 버튼, 눌림" 같은 식으로 읽어줘요. 시각적으로는 하트 색상이 바뀌지만 스크린 리더 사용자는 색상 변화를 볼 수 없으니 aria-pressed가 그 역할을 대신하는 거예요.

아이콘 버튼의 aria-label

텍스트 없이 아이콘만 있는 버튼은 스크린 리더가 읽을 수 없어요. aria-label로 버튼의 목적을 알려줘야 해요.

<button type="button" aria-label="검색">
  <Search size={20} />
</button>
 
<button type="button" aria-label="링크 복사">
  <LinkIcon size={16} />
</button>
 
<a href="https://github.com/..." aria-label="GitHub">
  <Github size={20} />
</a>

Footer의 소셜 링크도 마찬가지예요. GitHub, Email, Portfolio 같은 아이콘 링크에 모두 aria-label을 넣었어요.

장식용 요소 처리

스크린 리더가 읽을 필요 없는 장식용 요소에는 aria-hidden을 넣어요.

// 로딩 스피너 — 시각적 피드백일 뿐이에요
<Loader2 className="animate-spin" aria-hidden />
 
// 검색 아이콘 — 옆에 input label이 있어요
<Search className="text-muted-foreground" aria-hidden="true" />

로딩 스피너나 아이콘 자체는 장식이에요. 이걸 스크린 리더가 "이미지, 이미지"라고 읽으면 오히려 방해가 돼요.

장식용 이미지도 마찬가지예요. alt를 아예 생략하면 스크린 리더가 파일명을 읽어버리는 경우가 있어요. "1776611160811-search.png" 같은 걸 읽어주면 오히려 혼란스럽겠죠. 장식용 이미지에는 alt=""로 빈 문자열을 명시하면 스크린 리더가 이 이미지를 아예 건너뛰어요.

// 의미가 있는 이미지 — alt에 설명을 넣어요
<Img src={thumbnail} alt="삿포로 눈 축제 현장" />
 
// 장식용 이미지 — alt=""로 건너뛰게 해요
<Img src={divider} alt="" />

정리하면 aria-hidden은 아이콘이나 SVG 같은 인터랙티브 요소에, alt=""<img> 태그에 쓰는 거예요.

sr-only 레이블

시각적으로는 placeholder로 충분하지만 스크린 리더는 placeholder를 안정적으로 읽어주지 않아요. sr-only 클래스로 시각적으로는 숨기되 스크린 리더에는 노출되는 레이블을 붙여요.

// CommentForm.tsx
<label>
  <span className="sr-only">이름</span>
  <input type="text" placeholder="이름" />
</label>
<label>
  <span className="sr-only">비밀번호</span>
  <input type="password" placeholder="비밀번호" />
</label>

<label>로 감싸서 input과 연결하면 스크린 리더가 "이름, 텍스트 편집 필드"라고 읽어줘요. placeholder만으로는 이런 연결이 안 돼요.

검색바와 채팅 입력란에도 같은 패턴을 적용했어요.

// SearchBar.tsx
<span className="sr-only">블로그 검색</span>
 
// ChatInput.tsx
<span className="sr-only">질문 입력</span>

포커스 스타일

포커스 링은 현재 키보드 포커스가 어디에 있는지 보여주는 시각적 표시예요. 없으면 키보드 사용자가 자기가 어디에 있는지 알 수 없어요.

:focus-visible

/* globals.css */
:focus-visible {
  @apply outline-2 outline-offset-2 outline-ring/50;
}

:focus가 아닌 :focus-visible을 쓰는 이유가 있어요. :focus는 마우스 클릭에도 반응해서 버튼을 클릭할 때마다 포커스 링이 보여요. :focus-visible은 키보드로 포커스했을 때만 표시돼요. 마우스 사용자에게는 불필요한 시각 요소를 줄이면서 키보드 사용자에게는 필요한 정보를 제공하는 거예요.

인풋 포커스 스타일

인풋 필드에 포커스가 들어가면 브랜드 컬러 그라데이션 보더가 나타나요.

input:not([data-no-brand]):focus,
textarea:not([data-no-brand]):focus,
[contenteditable]:not([data-no-brand]):focus {
  border-color: transparent !important;
  outline: none;
  background-image:
    linear-gradient(hsl(var(--background)), hsl(var(--background))),
    linear-gradient(135deg, hsl(var(--brand)), hsl(207 50% 75%), hsl(var(--brand)));
  background-origin: border-box;
  background-clip: padding-box, border-box;
}

background-clip을 이용한 트릭이에요. 안쪽은 배경색으로 채우고 보더 영역만 그라데이션이 보이게 하는 방식이에요. data-no-brand 속성을 붙이면 이 스타일을 제외할 수 있어서 예외 처리도 쉬워요.

색상과 시각적 표현

색상만으로 정보를 전달하지 않기

색맹이나 색약인 사용자는 생각보다 많아요. 남성의 약 8%가 색각 이상을 가지고 있다고 해요. 빨간색과 초록색으로만 성공/실패를 구분하면 이 사용자들은 차이를 구별할 수 없어요.

그래서 색상과 함께 아이콘이나 텍스트를 병행해야 해요.

// ❌ 색상만으로 에러 표시
<span className="text-red-500">이미 존재하는 이름이에요</span>
 
// ✅ 색상 + 아이콘으로 에러 표시
<span className="text-destructive">⚠ 이미 존재하는 이름이에요</span>

좋아요 버튼도 빨간 하트 색상만으로 상태를 구분하지 않고 aria-pressed로 상태를 전달하고 있어요. 앞에서 다룬 내용이지만 이것도 같은 맥락이에요.

WCAG 색상 대비 기준

WCAG 2.1에서는 텍스트의 색상 대비를 최소 4.5:1 이상 유지하도록 권고해요. 큰 텍스트(18px bold 이상)는 3:1이면 돼요.

이 블로그에서는 CSS 커스텀 프로퍼티로 라이트/다크모드 색상을 관리하고 있어요.

:root {
  --foreground: 0 0% 9%;        /* 거의 검정 */
  --background: 0 0% 100%;      /* 흰색 */
  --muted-foreground: 0 0% 45.1%; /* 보조 텍스트 */
}
 
.dark {
  --foreground: 0 0% 98%;       /* 거의 흰색 */
  --background: 0 0% 3.9%;      /* 거의 검정 */
  --muted-foreground: 0 0% 63.9%; /* 보조 텍스트 (밝게 조정) */
}

다크모드에서 보조 텍스트 색상(muted-foreground)을 45.1%에서 63.9%로 올린 이유가 있어요. 어두운 배경에서는 같은 명도 차이라도 대비가 낮아 보이거든요.

순수 검정 배경을 피한 이유

다크모드 배경을 0 0% 0%(순수 검정)이 아닌 0 0% 3.9%로 한 건 눈의 피로도 때문이에요. 순수 검정 위의 흰 텍스트는 대비가 너무 강해서 오래 읽으면 눈이 피로해요. 약간의 밝기를 주면 대비를 충분히 유지하면서도 더 편안하게 읽을 수 있어요.

브랜드 컬러 조정

:root {
  --brand: 207 38% 59%;   /* 라이트모드 */
}
.dark {
  --brand: 207 45% 68%;   /* 다크모드에서 밝게 */
}

라이트모드에서 쓰던 브랜드 컬러를 다크모드에서 그대로 쓰면 어두운 배경에 묻혀서 잘 안 보여요. 채도와 명도를 올려서 다크모드에서도 브랜드 컬러가 돋보이도록 했어요.

contentEditable과 접근성

댓글 시스템에서 답글 에디터는 <textarea> 대신 contentEditable을 사용했어요. 멘션 텍스트를 볼드로 표시하려면 HTML 렌더링이 필요하거든요. 이 경우 접근성 속성을 직접 챙겨야 해요.

// CommentForm.tsx
<div
  contentEditable
  role="textbox"
  aria-label="답글"
  className="min-h-[4.5rem] ..."
/>

contentEditable만으로는 스크린 리더가 이 요소의 역할을 모르기 때문에 role="textbox"로 역할을 명시하고 aria-label로 이름을 붙여요.

댓글 수정 모드에서도 같은 패턴을 적용했어요.

// CommentItem.tsx
<div
  contentEditable
  role="textbox"
  aria-label="댓글 수정"
/>

네비게이션 컴포넌트

// SeriesNavigation.tsx
<nav aria-label="시리즈 네비게이션">
  ...
</nav>

<nav> 태그만으로도 네비게이션이라는 건 알 수 있지만 페이지에 여러 <nav>가 있을 수 있어요. 헤더 네비게이션, 시리즈 네비게이션, 푸터 네비게이션 등이요. aria-label로 각 네비게이션의 용도를 구분하면 스크린 리더 사용자가 "시리즈 네비게이션"으로 바로 점프할 수 있어요.

모션과 접근성

prefers-reduced-motion

전정기관 장애가 있는 사용자는 화면의 움직임 때문에 어지러움이나 메스꺼움을 느낄 수 있어요. 운영체제에는 "동작 줄이기" 설정이 있고 CSS에서 이를 감지할 수 있어요.

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Tailwind CSS를 쓴다면 motion-reduce: 프리픽스로 개별 요소에 적용할 수도 있어요.

<div className="animate-bounce motion-reduce:animate-none">
  ...
</div>

framer-motion을 쓰고 있다면 useReducedMotion 훅으로 감지해서 애니메이션을 조건부로 적용할 수 있어요.

import { useReducedMotion } from "framer-motion";
 
function Component() {
  const shouldReduce = useReducedMotion();
 
  return (
    <motion.div
      animate={shouldReduce ? {} : { scale: [1, 1.4, 1] }}
    />
  );
}

모든 애니메이션을 없앨 필요는 없어요. 페이드 인 같은 단순한 전환은 보통 괜찮고 대신 슬라이드, 바운스, 회전처럼 요소가 크게 움직이는 애니메이션을 줄이거나 제거하면 돼요.

터치 타겟 크기

모바일에서 버튼이 너무 작으면 손가락으로 정확하게 누르기 어려워요. WCAG 2.2에서는 터치 타겟의 최소 크기를 24 x 24px 이상으로 권고하고 있어요. Apple의 Human Interface Guidelines는 44 x 44pt를 권장하고요.

// ❌ 터치 타겟이 너무 작아요
<button type="button" className="p-1">
  <X size={14} />
</button>
 
// ✅ 충분한 터치 영역을 확보해요
<button type="button" className="p-2.5">
  <X size={14} />
</button>

아이콘이 작더라도 padding으로 터치 영역을 넓힐 수 있어요. 시각적 크기는 작지만 실제 터치 가능한 영역은 충분히 확보하는 거예요.

비활성 상태 표시

<button
  type="button"
  disabled={!password.trim()}
  className="... disabled:opacity-40"
>
  확인
</button>

disabled 속성을 쓰면 스크린 리더가 "비활성화됨"이라고 알려줘요. 시각적으로도 opacity-40으로 흐리게 표시해서 클릭할 수 없다는 걸 보여주고 있어요. 두 가지를 함께 챙기는 게 중요해요.

마무리

접근성은 처음부터 신경 쓰면 추가 작업이 거의 없어요. 반대로 나중에 고치려고 하면 컴포넌트 구조부터 바꿔야 하는 경우가 많아서 훨씬 어려워져요.

새 컴포넌트를 만들 때에 체크해보면 좋은 것들이에요.

  • 키보드만으로 모든 기능을 사용할 수 있는가?
  • 스크린 리더가 요소의 역할과 상태를 이해할 수 있는가?
  • 색상 외의 수단으로도 정보를 전달하고 있는가?
  • 움직임에 민감한 사용자를 고려했는가?
  • 터치 타겟이 충분히 큰가?

도입부에서 얘기한 것처럼 이것들을 챙기면 장애가 있는 사용자뿐 아니라 모든 사용자의 경험이 좋아져요. 크롤러가 콘텐츠에 접근하는 방식도 '장애가 있는 사용자'가 접근하는 방식과 많이 닮아 있어서 SEO에서도 이점이 있어요. 웹 접근성은 하나하나 보면 사소해 보이지만 쌓이면 사용자 경험을 좌우하는 요소가 돼요. 결국 접근성을 챙기는 건 더 좋은 웹을 만드는 일이에요.