Lighthouse 접근성 점수가 움직이지 않은 이유

커버 이미지

웹 접근성은 시각, 청각, 운동 능력 등에 제약이 있는 사용자도 다른 사용자와 동일하게 서비스를 이용할 수 있도록 페이지를 설계하는 영역입니다. 키보드만으로 모든 기능에 도달할 수 있는지, 스크린리더로 읽었을 때 의미가 통하는지, 색 대비와 글자 크기 같은 시각적 기준이 충족되는지가 핵심 항목입니다. Lighthouse의 접근성 점수는 이 중에서도 정적 마크업으로 자동 검사 가능한 일부를 0~100점으로 환산한 수치이고, 저희는 배포 단계에서 회귀를 감지하는 객관적 지표로 활용하고 있습니다. 다만 이 점수가 실제 사용성과 항상 같은 방향을 가리키지는 않는다는 것이 이번 글의 출발점이기도 합니다.

저희가 운영하는 마켓 서비스에서 음성 안내(스크린리더)를 켠 채 페이지를 열었을 때, 닫혀 있는 사이드바 메뉴 항목이 그대로 읽혀 사용자가 자기 위치를 파악하지 못한다는 현상이 발견됐습니다. 키보드 Tab으로 카드나 드롭다운에 도달조차 못 하는 영역도 함께 드러났고, 이 발견에서 시작해 홈 화면 전반의 접근성을 정리했습니다. 카드와 드롭다운의 포커스 처리를 정리하고, 사이드바와 모달이 열렸을 때 본문이 포커스를 가져가지 않도록 inert 처리를 도입했으며, 헤더와 사이드바의 포커스 트랩을 풀어 키보드 사용자가 빠져나올 수 있게 했습니다. 그러나 이러한 편의성 개선에도 불구하고 Lighthouse의 접근성 점수는 84점 그대로였습니다.

작업 자체가 효과가 없었던 것은 아니었습니다. 키보드만으로 홈을 한 바퀴 돌아보는 시나리오는 작업 전과 후가 분명히 달랐고, 사이드바를 연 상태에서 Tab 키로 본문 요소가 포커스를 받는 문제도 사라졌습니다. 그런데도 Lighthouse 점수가 그대로였던 이유는 Lighthouse의 자동 검사가 측정하는 영역이 따로 있기 때문이었습니다. 이 글에서는 그 경계가 어디인지 살펴보고, 자동 검사 항목 4건을 추가로 개선하여 접근성 점수를 84점에서 95점까지 올린 과정을 함께 정리합니다.

Lighthouse가 측정하는 접근성과 측정하지 않는 접근성

Lighthouse의 접근성 카테고리는 내부적으로 axe-core 룰을 실행해 점수를 매깁니다. axe-core는 정적 마크업을 검사하는 자동 도구이기 때문에, "이 요소가 포커스를 받을 수 있는가", "이 ARIA 속성이 의미적으로 올바르게 쓰였는가", "이 색 대비가 4.5:1 이상인가"처럼 DOM에서 즉시 확인 가능한 항목만 검사할 수 있습니다.

반면 다음 영역은 자동 검사 범위 밖에 있습니다.

  • 키보드 인터랙션의 자연스러움: Tab 순서가 시각적 흐름과 맞는지, 드롭다운에서 Esc로 닫히는지, 사이드바를 열었을 때 포커스가 적절한 지점으로 이동하는지
  • 오버레이 상태의 포커스 격리: 모달이나 사이드바가 열린 상태에서 본문 요소로 포커스가 새어나가지 않는지
  • 스크린리더가 읽었을 때의 흐름: 카드의 제목과 메타 정보가 올바른 순서로 읽히는지, 의미가 비어 있는 영역을 건너뛸 수 있는지
  • focus-visible 시각 표시의 일관성: 키보드로 진입했을 때 outline이 가려지지 않고 보이는지
  • 인터랙티브 요소의 시맨틱 정확도: <div onClick>이나 <li onClick>처럼 보이지만 키보드로 활성화되지 않는 패턴이 있는지. axe가 잡는 룰이 일부 있지만 "Tab으로 도달 가능한가, Enter로 활성화되는가" 같은 핵심 검증은 결국 사람이 확인해야 하는 영역입니다.
  • landmark 활용도: <main>, <nav>, <header> 같은 영역이 있어야 스크린리더 사용자가 단축키로 페이지 영역을 이동할 수 있습니다. 이 항목 자체는 Lighthouse의 manual use-landmarks 영역에 포함되어 있습니다.

이번 작업의 거의 전부가 위 영역에 속했습니다. 자동 검사가 점수에 반영하지 않는 것이 당연한 결과였습니다. Lighthouse 자체도 접근성 카테고리 하단에 "Additional items to manually check"라는 별도 영역을 두어, 점수에는 포함되지 않지만 사람이 직접 확인해야 한다고 명시하고 있습니다.

1단계: 자동 검사 범위 밖의 작업

작업 내용을 모두 옮기지는 않고, 글의 흐름에 필요한 것 위주로 정리합니다.

인터랙티브 요소의 시맨틱 정리

작업의 가장 큰 부분은 평범한 <div><li>onClick만 달려 있던 영역을 시맨틱한 요소로 바꾸는 일이었습니다. 카드, 카테고리 칩, 탭, "전체보기" 버튼, 헤더 아이콘 등 곳곳에 다음과 같은 패턴이 흩어져 있었습니다.

<li
  className={style.card}
  onClick={() => router.push(`/emoticon/${stickerId}`)}
>
  <img src={thumbnail} alt="" />
  <span>{title}</span>
</li>

이 패턴은 마우스나 터치 사용자에게는 정상적으로 동작하지만, 키보드 사용자는 Tab으로 도달하지 못하고 Enter도 무시됩니다. 의도에 따라 두 갈래로 변환했습니다.

  • navigation (다른 페이지로 이동): <Link>. 휠클릭 새 탭, 우클릭 메뉴 같은 link 기본 UX가 자동으로 따라옵니다. prefetch 부담을 피해 prefetch={false}를 함께 적용했습니다.
  • action (모달 토글, 필터 선택, 상태 변경 등): <button>. 토글성 항목은 aria-pressed로 선택 상태도 함께 노출했습니다.

카드, 리스트 아이템, 공통 탭 등 재사용 컴포넌트 단위로 처리해 사용처 수십 곳에 한 번에 적용되도록 했습니다.

페이지 콘텐츠를 inert로 비활성화

사이드바와 모달, 바텀시트가 열려 있을 때 본문이 포커스를 가져가지 않도록 본문 콘텐츠 전체를 inert 처리했습니다. 세 종류의 오버레이 상태를 하나의 boolean으로 통합하는 훅을 두고, 본문 래퍼에서 그 값을 받아 inertaria-hidden을 토글합니다.

// 오버레이 상태 통합 훅
export const useIsPageInert = () => {
  const isDrawerOpen = useDrawerStore((s) => s.isOpen);
  const hasOpenOverlayModal = useModalStore((s) =>
    Object.values(s.modals).some((m) => m.isOpen),
  );
  return isDrawerOpen || hasOpenOverlayModal;
};
// 본문 래퍼 컴포넌트
export default function PageContent({
  children,
}: {
  children: React.ReactNode;
}) {
  const isInert = useIsPageInert();
  return (
    <div className={styles.root} inert={isInert} aria-hidden={isInert}>
      {children}
    </div>
  );
}

inert만으로 키보드 포커스와 클릭이 모두 차단되며 최신 환경의 스크린리더(VoiceOver, TalkBack)에서는 단독으로도 영역이 숨겨집니다. 다만 inert를 인식하지 못하는 일부 환경이 남아 있어, 동일 영역을 명시적으로 가리는 aria-hidden도 함께 두기로 정리했습니다. 한 가지 주의할 점은 래퍼에 display: contents를 사용하면 일부 환경에서 inert가 자식에게 전파되지 않는 사례가 있다는 점입니다. 저희는 평범한 <div> 컨테이너로 두는 쪽을 선택했습니다.

같은 맥락에서 사이드바도 처리했습니다. 닫혀 있는 동안에도 Tab으로 메뉴 항목에 도달할 수 있는 상태였는데, 컨테이너 자체에 inert를 걸어 닫힌 동안에는 Tab 순서에서 빠지도록 했습니다. 닫기 버튼은 사이드바 헤더의 가장 첫 포커스 가능한 요소로 두어, 열린 상태에서 빠져나오는 흐름도 명확하게 정리했습니다.

검색 결과 헤더의 "총 N개의 이모티콘 검색결과" 영역은 외형상 한 덩어리지만 마크업이 다음 형태로 감싸져 있었습니다.

<Text as="button" className={styles["result-title"]}>
  <Link href={url}>총 {count}개의 이모티콘 검색결과</Link>
  <Image src="..." />
</Text>

interactive 요소(<button>)가 다른 interactive 요소(<a>)를 자식으로 갖는 것은 HTML 스펙상 invalid일 뿐 아니라, 키보드로 Tab했을 때 button과 a가 각각 별도 stop으로 잡혀 사용자가 같은 시각적 영역에서 두 번 멈추게 됩니다. 외부 <button><Link>로 합치고 안의 텍스트는 비-interactive한 <span>으로 감싸 단일 stop이 되도록 정리했습니다.

<Link href={url} className={styles["result-title"]}>
  <Text as="span">총 {count}개의 이모티콘 검색결과</Text>
  <Image src="..." alt="" />
</Link>

같은 패턴이 사이드바의 "최근 본 이모티콘 > 전체보기", 섹션 헤더의 "전체보기" 등 여러 곳에서 발견되어 일괄 정리했습니다. axe-core가 직접 잡지 않는 케이스이지만 사용자 영향은 컸습니다.

<main> landmark 추가

스크린리더 사용자는 페이지를 좌→우→아래 순서로 읽지 않고, "랜드마크"라는 단축키로 페이지 영역 사이를 이동합니다. VoiceOver라면 VO+U의 Rotor 메뉴를, TalkBack이라면 영역 단축키를 사용합니다. 이 기능이 동작하려면 페이지에 <main>, <nav>, <header> 같은 시맨틱 landmark가 있어야 합니다.

저희는 헤더와 사이드바는 이미 <header>/<nav>를 쓰고 있었지만 페이지 본문은 <div>로 감싸진 곳이 다수였습니다. FAQ, 검색, 이모티콘/크리에이터 상세, 마이페이지, 회원가입, 결제 플로우 등 24개 페이지의 본문 wrapper <div><main>으로 바꿨습니다.

<main>의 user-agent default style은 <div>와 동일(둘 다 display: block)하기 때문에 시각적 변화 없이 시맨틱만 정확해집니다. Lighthouse가 자동 점수에 반영하지 않는 manual 항목(use-landmarks)이지만, 1단계 영역의 전형적인 예시로 함께 정리했습니다.

이 단계까지 끝낸 뒤 측정한 점수는 84점이었습니다. Lighthouse 카테고리별로 보면 이전과 동일한 수치였습니다.

카테고리BeforeAfter (1단계)
Accessibility0.840.84

자동 검사 항목 살펴보기

점수에 영향을 주려면 axe-core가 검사하는 룰 중 실제로 실패하고 있는 항목을 손봐야 했습니다. Lighthouse 결과를 JSON으로 export한 뒤 categories.accessibility.auditRefsaudits 객체를 함께 살펴보면, 각 룰의 통과 여부와 가중치를 한꺼번에 확인할 수 있습니다.

저희 페이지에서 점수를 깎고 있던 항목은 다음 네 개였습니다.

Audit IDWeight원인 위치
meta-viewport10<meta name="viewport">user-scalable=no, maximum-scale=1.0
list7사이드바의 <ul> 직계에 <button>이 들어감
listitem7인기차트 그룹의 <li><ul> 밖에 있음
label-content-name-mismatch0검색 트리거의 보이는 텍스트와 aria-label 불일치
Lighthouse 접근성 카테고리에서 보고된 실패 항목

가중치가 가장 높은 meta-viewport(10)와 <ul>/<li> 구조 관련 두 항목(7씩)이 점수의 대부분을 결정합니다. 마지막 label-content-name-mismatch는 가중치 0이지만 정성적으로는 의미 있는 항목이라 함께 정리했습니다.

2단계: 마크업 4건 정리

meta-viewport: user-scalable=no 제거

가장 가중치가 큰 항목이었지만, 단순히 점수만 보고 빼기에는 신중해야 하는 설정이었습니다. 핀치줌을 막는 옵션은 보통 의도된 결정이고, 디자인이 특정 줌 레벨을 전제로 만들어진 경우도 있기 때문입니다. 저희는 저시력 사용자가 본문을 확대해 읽을 수 있어야 한다는 WCAG 1.4.4 (Resize Text) 기준을 근거로 디자인·기획과 합의한 뒤 옵션을 제거했습니다.

// app/layout.tsx
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

listitem: 인기차트 그룹의 <div><ul>로 변경

인기차트 그룹은 두 개의 컬럼에 카드를 나눠 배치하는 구조인데, 컬럼 컨테이너가 <div>였고 그 안에 <li> 카드가 직접 들어가 있었습니다. <li>의 부모가 <ul>이 아니어서 axe가 잡아내는 케이스였습니다.

Before:

<div className={style['chart-column']}>
  {leftColumn.map((item) => (
    <RankCard ... />
  ))}
</div>

After:

<ul className={style['chart-column']}>
  {leftColumn.map((item) => (
    <RankCard ... />
  ))}
</ul>

list: 사이드바 <ul> 직계 자식 정리

반대 방향의 문제도 있었습니다. 사이드바 헤더의 <ul>이 직계 자식으로 <button>을 가지고 있어, "리스트 안에는 <li>만 들어와야 한다"는 룰을 위반하고 있었습니다. 닫기 버튼을 <li>로 감싸 구조를 정리했습니다.

Before:

<ul className={style.sidebarHeader}>
  <button onClick={() => close()}>
    <img src="/icons/icon-arrow-left.svg" alt="뒤로 가기" />
  </button>
</ul>

After:

<ul className={style.sidebarHeader}>
  <li>
    <button onClick={() => close()}>
      <img src="/icons/icon-arrow-left.svg" alt="뒤로 가기" />
    </button>
  </li>
</ul>

label-content-name-mismatch: 검색 링크의 aria-label 제거

검색 트리거 링크에 보이는 텍스트("검색")가 있는데 aria-label="검색 페이지로 이동"을 함께 두고 있었습니다. axe-core는 보이는 텍스트와 접근 가능한 이름(accessible name)이 일치하지 않으면 음성 명령(예: "Click 검색")이 동작하지 않을 수 있다는 이유로 이 케이스를 잡아냅니다. 보이는 텍스트가 충분히 명확하므로 aria-label을 제거하는 쪽으로 정리했습니다.

결과

4건을 정리한 뒤 다시 측정한 결과는 다음과 같습니다.

카테고리BeforeAfter (1단계)After (2단계)
Accessibility0.840.840.95

이미지에 함께 보이는 Performance 점수는 이 접근성 작업에 앞서 진행한 이미지 최적화의 결과입니다. 그 과정은 이전 글에 정리해 두었습니다.

측정 전 작업 전 Lighthouse 점수 측정 후 작업 후 Lighthouse 점수

접근성은 84점에서 95점으로 올랐습니다. 남은 5점은 색 대비(color-contrast) 두 건으로, 디자인 토큰 자체에 묶인 색이라 추후 디자인 작업 때 별도로 다루기로 했습니다.

정리

1단계와 2단계의 작업량은 비슷했지만, 점수에 미친 영향은 0점과 11점으로 갈렸습니다. 두 단계가 측정하는 대상이 달랐기 때문입니다. 정리하면 다음과 같습니다.

  • Lighthouse 점수가 보장하는 것: 정적 마크업 수준에서 axe-core 룰을 통과합니다. 즉 명백한 구조적 문제가 없다는 점입니다.
  • Lighthouse 점수가 보장하지 않는 것: 키보드만으로 페이지를 사용할 수 있는지, 스크린리더가 의미 있는 흐름으로 콘텐츠를 읽는지, 오버레이 상태에서 포커스가 새어나가지 않는지입니다.

자동 검사 점수를 올리는 작업이 의미 없는 것은 아닙니다. 가중치 10인 meta-viewport처럼 점수를 통해 드러난 항목은 실제로 저시력 사용자에게 영향을 주는 결정이었고, <ul>/<li> 구조 문제도 스크린리더의 그룹 탐색 경험과 직접 연결됩니다. 다만 점수만 따라가면 실제 사용자가 마주치는 문제 대부분은 그대로 남는다는 점도 함께 인지하고 있어야 합니다.

이번 작업에서는 두 단계를 모두 거친 뒤에야 95점이라는 숫자와 실제 키보드/스크린리더 사용성이 같은 방향을 가리키게 되었습니다. 한쪽만 진행했다면 점수는 좋아 보이지만 실제 사용성은 그대로 남거나, 사용성은 개선됐지만 점수는 그대로여서 외부에 설명하기 어려운 상태가 됐을 것입니다. 두 영역을 함께 보는 것이 결국 가장 실용적인 접근이라는 결론입니다.