접근성의 진짜 난이도: 대비 말고 포커스 관리

접근성의 진짜 난이도: 대비 말고 포커스 관리

접근성 점검을 자동화 도구에 맡기면 리포트가 깔끔하게 나온다. 대비가 4.5:1을 넘겼는지, 이미지에 alt가 붙었는지, 페이지에 랜드마크(landmark)와 헤딩 구조가 있는지. 이건 축하할 일이 아니라 당연한 일이다. 이런 항목은 규칙이 명확해서 기계가 셀 수 있고, 그래서 CI에서 빨간 불로 잡힌다. 명도 대비 최소 기준은 WCAG 1.4.3(Contrast Minimum, AA)에 숫자로 박혀 있고, 린터는 그 숫자를 읽는다.

문제는 리포트가 초록불이어도 키보드 사용자가 화면 한가운데서 길을 잃을 수 있다는 것이다. 자동화가 잡는 건 접근성의 정적인 절반이다. 나머지 절반은 사람이 화면을 실제로 통과하는 흐름, 곧 포커스가 어디에 있고 어디로 가느냐다. 대비는 정지 화면 한 장으로 판정되지만, 포커스는 시간 축 위에서 움직인다. 모달이 열리고, 드롭다운이 펼쳐지고, 탭이 전환되고, 라우팅으로 화면이 통째로 바뀌는 그 동적인 순간마다 포커스를 누군가 손으로 옮겨줘야 한다. 이 글은 그 손이 하는 일을 정리한다.

하나 짚고 시작하자. 이 글에서 포커스는 키보드 사용자의 손가락이 지금 짚고 있는 자리라고 생각하면 된다. 마우스 사용자는 화면 아무 데나 커서를 찍지만, 키보드만 쓰는 사람은 Tab 키로 짚을 수 있는 자리를 하나씩 옮겨 다닌다. 지금 그 손가락이 어느 버튼, 어느 입력칸에 얹혀 있는지가 포커스다. 앞으로 나오는 코드는 전부 이 손가락을 어디로 옮기고, 어디에 가두고, 어떻게 되돌리느냐에 대한 이야기다. 그리고 미리 말해두면, 디자이너가 이 코드를 직접 짤 일은 없다. 코드의 모양만 눈으로 훑어서 이 손가락이 어떻게 움직이는지 그림이 그려지면 충분하다.

스크린리더는 화면을 선형으로 읽는다

포커스 이야기를 하기 전에 스크린리더 사용자의 멘탈 모델부터 맞춰야 한다. 이걸 모르면 뒤에 나오는 모든 기법이 그냥 외워야 할 규칙으로만 남는다.

시각 사용자는 화면을 훑는다. 눈이 제목으로 튀었다가 오른쪽 사이드바로 갔다가 다시 본문 가운데로 돌아온다. 레이아웃의 시각적 위계가 이 점프를 안내한다. 스크린리더 사용자는 다르다. 기본 이동은 위에서 아래로 한 줄씩 읽는 선형 순차다. 여기에 랜드마크 점프와 헤딩 점프가 얹힌다. 예를 들어 헤딩만 훑어 목차처럼 건너뛰거나, main과 nav 같은 랜드마크 사이를 오간다. 하지만 이 점프의 뼈대는 결국 DOM 순서다.

핵심은 한 문장으로 요약된다. DOM 순서가 읽는 순서다. 그리고 이게 디자이너에게 함정이 되는 지점이 있다. CSS로 시각적 위치를 옮기는 건 DOM 순서를 바꾸지 않는다. 예를 들어 플렉스 컨테이너에서 order 값을 줘서 사이드바를 시각적으로 본문 왼쪽에 놓아도, DOM에서 사이드바가 본문 뒤에 있으면 스크린리더는 본문을 다 읽고 나서야 사이드바를 읽는다. position: absolute로 CTA 버튼을 카드 상단에 띄워도, DOM에서 그게 카드 맨 아래에 있으면 읽히는 건 맨 나중이다. 시각 순서와 읽는 순서가 어긋나는 것이다. 이건 WCAG 2.4.3(Focus Order, A)이 말하는 문제이기도 하다. 포커스가 이동하는 순서가 의미와 조작에 맞는 순서를 지켜야 한다는 조항이다.

그래서 디자이너가 와이어프레임에서 요소를 이리저리 옮길 때, 그 순서는 시각 배치인 동시에 낭독 대본이다. 반응형에서 데스크톱과 모바일의 시각 순서가 다르다면, 둘 중 하나는 DOM 순서와 어긋날 수밖에 없다. 어느 쪽 흐름을 낭독 기준으로 삼을지가 설계 결정이 된다. 이 감각을 깔아두면 나머지는 전부 같은 원리의 응용이다. 포커스 관리란 결국 시각 사용자에게 자연스러운 흐름을, 선형으로 읽고 키보드로 이동하는 사용자에게도 똑같이 자연스럽게 재현해 주는 일이다.

포커스 트랩: 모달이 열리면 포커스를 가둔다

가장 자주 깨지는 곳이 모달이다. 시각 사용자에게 모달은 명확하다. 화면 가운데 카드가 뜨고 뒤가 어두워진다. 뒤 배경은 눌리지 않는다는 걸 눈으로 안다. 키보드 사용자에게는 이 어두워진 배경이 아무 의미가 없다. Tab을 누르면 포커스는 모달을 지나 뒤 배경의 링크와 버튼으로 태연히 넘어간다. 화면에는 모달이 떠 있는데 포커스는 보이지 않는 배경 어딘가를 헤맨다. 자기가 지금 어디를 조작하는지 알 수 없는 상태에 빠진다.

그래서 두 가지를 해줘야 한다. 첫째, 모달이 열리면 포커스를 모달 안으로 가둔다. Tab이 마지막 요소를 지나면 다시 첫 요소로 돌아오고, Shift+Tab이 첫 요소에서 뒤로 가면 마지막 요소로 순환한다. 밖으로 새지 않는다. 둘째, 모달이 닫히면 그 모달을 열었던 트리거 버튼으로 포커스를 되돌린다. 이걸 빼먹으면 모달을 닫는 순간 포커스가 문서 맨 위로 리셋되거나 허공으로 사라져서, 사용자는 방금 어디 있었는지부터 다시 찾아야 한다.

뒤 배경은 포커스뿐 아니라 클릭과 스크린리더 접근까지 통째로 끊어야 한다. 예전에는 배경의 모든 요소에 tabindex="-1"을 걸고 aria-hidden="true"를 발라야 했다. 지금은 inert 속성 하나로 해결된다. 요소에 inert를 걸면 그 안의 모든 것이 포커스도, 클릭도, 보조기술 낭독도 받지 않는다. inert는 크로스 브라우저 상호운용이 2023년 4월에 이루어졌고(Baseline 신규 지원), 30개월 뒤인 2025년 10월경 Baseline 광범위 지원으로 넘어갔다. Chrome/Edge 102, Firefox 112, Safari 15.5에서 각각 들어왔으니, 지금 새로 짜는 코드라면 폴리필 없이 써도 되는 범위다.

// 모달을 열 때: 열었던 트리거를 기억하고, 포커스를 모달 안 첫 요소로 옮긴다.
function openDialog(dialog, trigger) {
  const focusable = dialog.querySelectorAll(
    'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  // 배경을 통째로 비활성. inert가 포커스·클릭·낭독을 한 번에 끊는다.
  document.getElementById('app-root').inert = true;
  dialog.hidden = false;
  first?.focus();

  function onKeydown(e) {
    if (e.key === 'Escape') { close(); return; }
    if (e.key !== 'Tab') return;
    // Tab이 모달 밖으로 새지 않게 양 끝에서 순환시킨다.
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  function close() {
    dialog.hidden = true;
    document.getElementById('app-root').inert = false;
    dialog.removeEventListener('keydown', onKeydown);
    trigger.focus(); // 핵심: 열었던 그 버튼으로 포커스를 되돌린다.
  }

  dialog.addEventListener('keydown', onKeydown);
}

코드가 길어 보여도 하는 일은 방 하나를 잠그는 것뿐이다. 모달이라는 방이 열리면 손가락이 그 방 안에서만 돌게 문을 잠갔다가, 방을 나갈 때 원래 있던 자리로 손가락을 되돌려 놓는다. 줄별로 방 이야기에 얹어 읽어보자.

맨 위의 dialog.querySelectorAll(...)은 이 방 안에서 손가락이 짚을 수 있는 자리를 전부 찾아 목록으로 만든다. 괄호 안의 긴 문자열은 링크(a[href]), 꺼져 있지 않은 버튼(button:not([disabled])), 입력칸 같은 짚을 수 있는 요소들의 이름표를 죽 나열한 것이다. focusable[0]은 그 목록의 첫 자리, focusable[focusable.length - 1]은 맨 끝 자리다. 방의 입구와 출구라고 보면 된다.

app-root.inert = true는 방 바깥, 곧 모달 뒤에 깔린 화면 전체를 통째로 잠그는 줄이다. 앞에서 말한 inert가 여기서 쓰인다. 이 한 줄로 배경은 손가락도, 클릭도, 낭독도 못 받는 상태가 된다. 바로 아래 first?.focus()는 문을 잠근 직후 손가락을 방 안 첫 자리에 올려놓는다.

onKeydown은 방 안에서 Tab 키를 눌렀을 때 손가락이 문 밖으로 새지 않게 지키는 문지기다. 눌린 키가 Escape면 방을 닫고, Tab이 아니면 신경 쓰지 않는다. 핵심은 그다음 두 줄이다. Shift+Tab으로 첫 자리에서 더 뒤로 가려 하면(document.activeElement === first) 손가락을 맨 끝 자리로 보내고, 그냥 Tab으로 끝 자리에서 더 앞으로 가려 하면 첫 자리로 보낸다. 손가락이 방 끝에 닿으면 반대쪽 끝으로 순환시켜서, 아무리 Tab을 눌러도 방을 벗어나지 못하게 한다.

close가 방을 나가는 절차다. 모달을 감추고(dialog.hidden = true), 아까 잠갔던 배경을 다시 풀고(inert = false), 문지기를 내리고, 마지막 trigger.focus()로 이 모달을 열었던 그 버튼에 손가락을 되돌려 놓는다. 이 마지막 줄이 빠지면 방을 나온 손가락이 허공에 뜬다. 주석에 핵심이라고 적힌 이유다.

사실 이 손품 대부분은 네이티브 <dialog> 요소가 대신해 준다. dialog.showModal()로 열면 포커스 트랩과 배경 비활성(inert), Escape 닫기가 브라우저 기본 동작으로 딸려 온다. 그래도 위 코드를 굳이 펼쳐 본 이유는, 트랩이 안에서 무슨 일을 하는지 알아야 트리거로 되돌리는 것 같은 세부를 안 빠뜨리기 때문이다. 라이브러리를 쓰든 네이티브를 쓰든, 열었던 자리로 포커스를 되돌리는 책임은 여전히 우리 몫으로 남는 경우가 많다.

라우팅: 화면이 바뀌어도 포커스는 옛 자리에 남는다

모달은 그래도 브라우저가 절반은 거들어 준다. 아무도 안 거들어 주는 이동이 하나 있다. 싱글 페이지 앱(SPA)의 라우팅이다.

전통적인 페이지 이동은 링크를 누르면 브라우저가 문서를 통째로 새로 불러오고, 포커스도 새 문서 맨 위로 리셋된다. SPA는 다르다. 링크를 눌러도 브라우저 관점에서는 페이지가 바뀐 게 아니라 자바스크립트가 DOM 일부만 갈아 끼운 것이다. 그래서 포커스는 방금 누른 그 링크 자리에 그대로 남는다. 시각 사용자는 새 화면이 그려진 걸 눈으로 보지만, 스크린리더 사용자는 아무 통보도 못 받는다. 화면은 상품 목록에서 상세 페이지로 넘어갔는데, 포커스와 낭독 위치는 사라진 옛 화면의 링크 언저리에 멈춰 있다.

해법은 라우팅이 끝난 뒤 포커스를 새 화면의 시작점으로 직접 옮기는 것이다. 보통 새 페이지의 제목(h1)이 그 시작점이다. 제목 요소는 원래 포커스를 받지 않으니 tabindex="-1"을 걸어 프로그램적 포커스만 받게 열고, 거기로 focus()를 준다. 그러면 스크린리더가 새 페이지 제목부터 다시 읽기 시작해서, 시각 사용자가 새 화면을 보는 것과 같은 출발선이 생긴다.

// SPA 라우팅이 끝나면: 새 화면의 제목으로 포커스를 옮겨 낭독을 거기서 다시 시작시킨다.
function onRouteChange() {
  const heading = document.querySelector('main h1');
  if (!heading) return;
  heading.setAttribute('tabindex', '-1'); // 제목은 원래 포커스를 안 받으니 프로그램적 포커스만 열어 둔다.
  heading.focus();
}

이 짧은 코드도 손가락으로 읽으면 단순하다. document.querySelector('main h1')은 새로 그려진 화면에서 제목(h1)을 찾는다. 없으면(if (!heading) return) 아무것도 안 한다. setAttribute('tabindex', '-1')은 원래 손가락이 짚을 수 없는 제목에, 프로그램이 직접 올려놓을 때만 짚히는 임시 손잡이를 다는 것이다. Tab 순서에는 안 끼지만 코드로는 포커스를 줄 수 있게 된다. 마지막 heading.focus()가 그 손잡이에 손가락을 올린다. 화면이 통째로 바뀐 직후 손가락을 새 화면 맨 위 제목으로 옮겨 주는 셈이다.

여기에도 함정이 있다. 우선 이건 브라우저 기본 동작이 아니라서 라우터마다 손으로 붙여야 한다. 그리고 제목으로 포커스를 옮기면 뒤에서 다룰 포커스 링이 그 제목에 뜰 수 있다. 프로그램적 포커스라 안 뜨는 브라우저도 있고 뜨는 경우도 있어서, 큰 제목에 굵은 링이 걸리는 게 어색하면 그 요소의 포커스 표시를 따로 손봐야 한다. 어느 쪽이든, 화면을 통째로 갈아 끼웠으면 포커스도 같이 옮겨 주는 건 우리가 붙여야 하는 코드다.

라이브 리전: 화면 변화를 소리로 알린다

포커스 트랩과 라우팅이 포커스를 옮기는 문제였다면, 라이브 리전(live region)은 옮기지 않고 알리는 문제다. 시각 사용자는 화면 어딘가가 바뀌면 눈으로 알아챈다. 검색창 아래 결과 개수가 바뀌고, 폼 필드 밑에 빨간 에러가 뜨고, 화면 구석에 토스트가 올라온다. 스크린리더 사용자는 지금 포커스가 있는 곳만 듣기 때문에, 포커스 밖에서 조용히 바뀐 내용은 존재하지 않는 것과 같다.

aria-live가 이 간극을 메운다. 어떤 요소에 aria-live를 걸어두면, 그 안의 텍스트가 바뀔 때 스크린리더가 읽어 준다. 값은 두 가지다. polite는 사용자가 하던 낭독이 끝날 때까지 기다렸다가 조용히 끼워 읽는다. assertive는 지금 읽던 걸 끊고 즉시 알린다. 검색 결과 개수 같은 건 급하지 않으니 polite, 방금 제출한 폼이 실패했다는 에러처럼 즉시 알아야 하는 건 assertive다.

여기서 하나 정확히 구분할 게 있다. role="alert"aria-live="assertive"aria-atomic="true"를 더한 것과 같다. 즉 폼 에러에 role="alert"를 쓰면 그게 곧 assertive 라이브 리전이다. 그리고 이런 상태 메시지가 포커스를 받지 않고도 프로그램적으로 전달되어야 한다는 요구는 WCAG 4.1.3(Status Messages, AA)의 조항이다. 참고로 커스텀 위젯의 이름·역할·값이 보조기술에 노출되어야 한다는 4.1.2(Name, Role, Value)와는 다른 조항이니, 리포트에서 둘을 섞지 않는 게 좋다.

<!-- 검색 결과 개수: 조용히 알린다. 하던 입력을 끊지 않는다. -->
<div aria-live="polite" class="visually-hidden" id="search-status"></div>

<!-- 폼 제출 에러: 즉시 끊고 알린다. role="alert" = assertive 라이브 리전. -->
<div role="alert" id="form-error"></div>
// 결과가 갱신되면 텍스트만 갈아 끼운다.
document.getElementById('search-status').textContent = `검색 결과 ${count}건`;

이 코드는 손가락을 옮기지 않는다. 손가락은 제자리에 두고 귀에만 소식을 넣는다. 공항 안내방송을 떠올리면 딱 맞는다. 승객은 자기 자리에 앉아 있는데 스피커에서 안내가 흘러나온다.

첫 HTML 블록에서 aria-live="polite"가 붙은 빈 div가 그 스피커다. 지금은 비어 있지만, 나중에 이 안의 글자가 바뀌면 스크린리더가 그 내용을 읽어 준다. polite는 하던 낭독이 끝나는 다음 틈에 조용히 끼워 읽으라는 뜻이고, class="visually-hidden"은 이 스피커를 화면에는 안 보이게 숨긴다는 뜻이다. 소리로만 존재하는 안내판인 셈이다. 두 번째 블록의 role="alert"는 지금 당장 하던 말을 끊고 읽으라는 급한 스피커다. 폼 제출이 실패했다 같은 소식이 여기로 들어간다.

JS 한 줄은 그 스피커에 실제로 방송 문구를 넣는 동작이다. textContent = ...는 스피커 안의 글자를 검색 결과 몇 건이라는 문장으로 갈아 끼운다. 방송 내용만 바꾸는 것이지 스피커 자체를 새로 다는 게 아니다. 바로 이 점이 다음 함정과 이어진다.

라이브 리전에는 두 가지 실무 함정이 있다. 첫째, 리전은 콘텐츠를 넣기 전에 DOM에 미리 비어 있는 채로 존재해야 한다. 스크린리더는 페이지가 로드될 때 라이브 리전을 등록해 두는데, 빈 컨테이너와 내용을 동시에 새로 꽂아 넣으면 등록 시점을 놓쳐서 낭독이 안 된다. 컨테이너는 미리 깔고, 안의 텍스트만 나중에 바꾼다.

둘째, 남용하면 오히려 방해가 된다. assertive를 여기저기 뿌리면 스크린리더가 계속 하던 말을 끊는다. 예를 들어 실시간 검색에서 글자를 칠 때마다 결과 개수를 assertive로 읽어 주면, 사용자는 자기가 방금 입력한 글자 소리도 다 못 듣고 개수 낭독에 매번 잘린다. 시각 UI로 치면, 아무 변화나 다 화면 한가운데 모달 토스트로 띄워서 계속 클릭을 가로채는 것과 같다. 대부분의 상태 갱신은 polite로 충분하고, assertive는 정말로 하던 일을 멈추게 만들어야 하는 알림에만 아낀다.

로빙 탭인덱스: 항목이 많은 위젯은 정지점을 하나로

지금까지는 포커스를 옮기고 알리는 이야기였다. 마지막은 포커스가 멈추는 지점의 개수를 조절하는 문제다.

툴바를 생각해 보자. 버튼이 열두 개 달린 서식 툴바가 있다. 각 버튼을 전부 Tab 정지점으로 두면, 키보드 사용자는 툴바를 지나 본문으로 가려고 Tab을 열두 번 눌러야 한다. 툴바가 화면에 서너 개 있으면 정지점이 수십 개로 불어난다. 시각 사용자가 마우스로 한 번에 원하는 버튼을 찍는 것과 형평이 안 맞는다.

WAI-ARIA Authoring Practices Guide(APG)가 제시하는 표준 패턴이 로빙 탭인덱스(roving tabindex)다. 원리는 이렇다. 위젯 안에서 딱 한 항목만 tabindex="0"으로 두고 나머지는 전부 tabindex="-1"로 둔다. 그러면 Tab으로는 위젯 전체가 정지점 하나로 취급된다. Tab 한 번에 위젯에 들어오고, 위젯 안에서는 화살표 키로 항목 사이를 이동한다. 포커스를 옮길 때마다 tabindex="0"도 함께 그 항목으로 옮겨 다닌다. 이게 로빙, 곧 값이 항목 사이를 굴러다닌다는 이름의 뜻이다. Home과 End로 처음과 끝으로 점프하는 것까지 얹으면 패턴이 완성된다.

이 패턴은 툴바 말고도 라디오 그룹, 메뉴, 탭 목록, 트리처럼 형제 항목이 죽 늘어선 위젯 전반에 적용된다. 라디오 그룹을 떠올리면 이해가 쉽다. 라디오 버튼 다섯 개짜리 그룹에서 Tab은 그룹 하나에만 멈추고, 그 안에서는 화살표로 선택을 옮기는 게 데스크톱 폼의 기본 동작이다. 로빙 탭인덱스는 이 익숙한 동작을 커스텀 위젯에서 손으로 재현하는 것이다.

// 툴바: 항목 하나만 Tab 정지점(tabindex=0), 나머지는 -1. 이동은 화살표로.
const toolbar = document.querySelector('[role="toolbar"]');
const items = Array.from(toolbar.querySelectorAll('[role="button"]'));

let current = 0;
items.forEach((el, i) => { el.tabIndex = i === 0 ? 0 : -1; });

toolbar.addEventListener('keydown', (e) => {
  const delta = { ArrowRight: 1, ArrowLeft: -1 };
  if (e.key === 'Home') { move(0); return; }
  if (e.key === 'End') { move(items.length - 1); return; }
  if (!(e.key in delta)) return;
  e.preventDefault();
  move((current + delta[e.key] + items.length) % items.length);
});

function move(next) {
  items[current].tabIndex = -1;
  items[next].tabIndex = 0;
  items[next].focus(); // 포커스와 tabindex=0을 항상 같이 옮긴다.
  current = next;
}

이 코드는 TV 리모컨을 떠올리면 통째로 읽힌다. 리모컨은 버튼이 수십 개지만 TV로 들어가는 입구는 하나다. 일단 화면에 들어간 다음엔 방향키로 항목 사이를 오간다. 툴바도 똑같이 만드는 코드다.

toolbar.querySelectorAll('[role="button"]')은 툴바 안 버튼을 전부 모아 목록으로 만든다. 바로 아래 forEach가 핵심이다. el.tabIndex = i === 0 ? 0 : -1은 첫 버튼(i === 0)에만 0을 주고 나머지는 전부 -1을 준다는 뜻이다. 손가락 관점으로 옮기면, Tab으로 짚을 수 있는 자리를 툴바 전체에서 딱 하나만 남긴다는 것이다. 그래서 Tab 한 번이면 툴바가 버튼 열두 개가 아니라 툴바 하나로 취급돼 안으로 쑥 들어온다. 리모컨의 입구가 하나인 것과 같다.

keydown 안은 툴바에 들어온 뒤 방향키를 처리한다. ArrowRight는 오른쪽으로 한 칸(1), ArrowLeft는 왼쪽으로 한 칸(-1), Home은 맨 앞, End는 맨 끝으로 손가락을 옮긴다. (current + delta[e.key] + items.length) % items.length라는 계산은 끝에서 한 칸 더 가면 처음으로 도는 순환을 만드는 나눗셈 나머지(%) 트릭인데, 모양만 봐도 끝과 처음이 이어진다는 뜻으로 읽으면 된다.

맨 아래 move 함수가 실제로 손가락을 옮기는 동작이다. 지금 자리의 tabIndex-1로 끄고, 갈 자리의 tabIndex0으로 켜고, 그 자리에 focus()로 손가락을 올린다. Tab으로 짚히는 그 하나뿐인 자리가 손가락을 따라 항목 사이를 굴러다니는 것이다. 이게 로빙, 곧 굴러다닌다는 이름의 뜻이다.

주의할 점은 이 패턴이 공짜가 아니라는 것이다. 화살표 키를 위젯이 가로채기 때문에, 위젯 안에서 화살표가 원래 하던 일(예: 텍스트 캐럿 이동, 페이지 스크롤)과 충돌하지 않는지 봐야 한다. 그리고 APG 패턴을 어설프게 흉내 내다가 정지점을 아예 0개로 만드는 실수가 흔하다. 모든 항목을 tabindex="-1"로 두고 tabindex="0" 항목을 빠뜨리면, 위젯 전체가 Tab으로 도달 불가능한 섬이 된다. 로빙의 전제는 언제나 정확히 하나가 0이라는 것이다.

포커스 링은 지우는 게 아니라 디자인하는 것이다

여기까지는 개발이 짊어지는 것처럼 보이지만, 시작점은 디자인에 있다. 포커스가 지금 어디 있는지 눈에 보이지 않으면, 앞의 모든 관리가 사용자에게 전달되지 않는다.

가장 흔한 죄가 outline: none이다. 기본 포커스 링이 디자인과 안 어울린다는 이유로 지우고, 대체 스타일을 안 주는 것이다. 이러면 마우스 사용자에게는 아무 표시 없이 깔끔해 보이지만, 키보드 사용자는 포커스가 지금 어느 버튼에 있는지 전혀 알 수 없다. 화면을 보면서도 시각 사용자가 스크린리더 사용자처럼 길을 잃는다. 이건 WCAG 2.4.7(Focus Visible, AA)이 정면으로 금지하는 상황이다.

해법은 지우고 방치하는 게 아니라 대체 스타일을 주는 것이다. 그리고 :focus가 아니라 :focus-visible을 쓰는 게 요령이다. :focus는 마우스로 클릭했을 때도 링을 그려서 시각 사용자에게 거슬리고, 그게 애초에 사람들이 outline: none을 지르는 원인이었다. :focus-visible은 브라우저가 키보드 조작 맥락이라고 판단했을 때만 링을 그린다. 마우스 클릭에는 안 뜨고 Tab 이동에는 뜬다. 지우고 싶었던 상황과 필요한 상황을 브라우저가 갈라 준다.

/* 기본 아웃라인은 정리하되, 키보드 포커스에는 명확한 링을 준다. */
button:focus-visible {
  outline: 2px solid var(--accent-blue);
  outline-offset: 2px;
}

이 몇 줄은 손가락 위치를 눈에 그려 주는 코드다. button:focus-visible는 버튼에 키보드 손가락이 얹혔을 때만이라는 조건이다. outline: 2px solid var(--accent-blue)는 그 자리에 파란 테두리를 두르고, outline-offset: 2px는 그 테두리를 버튼에서 2픽셀 띄운다. 손가락이 지금 짚고 있는 자리에 스포트라이트를 씌우는 것이다.

포커스 링의 색도 디자인 대상이다. 이 블로그는 상호작용을 나타내는 강조색을 파랑(--accent-blue)으로 통일한다. 호버, 선택 상태, 스크롤 인디케이터가 다 파랑이다. 포커스 링도 같은 상호작용 계열이니 파랑으로 맞추는 게 일관된다. 빨강은 에러 같은 의미색 전용이라 포커스 링에 쓰지 않는다. 포커스 표시를 컴포넌트 스펙의 한 상태로 처음부터 그려 두면, 개발 단계에서 급하게 outline: none으로 지우고 잊는 사고가 애초에 안 난다.

정리하면, 접근성에서 자동화가 잡아 주는 대비와 alt는 출발선이지 결승선이 아니다. 진짜 난이도는 동적인 화면에서 포커스를 사람 손으로 옮기고, 가두고, 되돌리고, 알리고, 정지점을 정돈하고, 그 포커스를 눈에 보이게 그리는 흐름 관리에 있다. 이건 린터가 초록불을 켜 줘도 확인되지 않는 부분이라, 결국 키보드만으로 화면을 한 바퀴 통과해 보는 것 말고는 검증할 길이 없다. 대비만 맞추면 접근성이 끝난다는 말은, 정지 화면 한 장만 보고 영화를 다 봤다고 하는 것과 같다.

이 글, 어떠셨어요?