React로 만든 SPA를 잘 써오고 있었는데, 어느 날 접근성 검사를 했더니 이런 피드백이 왔습니다.
"페이지가 이동했는데 스크린 리더가 아무 말도 안 해요."
페이지 전환 애니메이션은 부드럽게 동작하고, URL도 바뀌고, 내용도 바뀌었는데… 스크린 리더 사용자 입장에서는 아무 일도 안 일어난 거였어요.
이게 SPA의 숨겨진 함정입니다.
전통적인 웹사이트 vs SPA#
전통적인 멀티페이지 사이트에서 링크를 클릭하면 무슨 일이 일어날까요?
- 브라우저가 새 페이지를 불러옵니다
- 페이지 전체가 다시 렌더링됩니다
- 브라우저가 자동으로 포커스를 문서 최상단으로 이동합니다
- 스크린 리더가 새 페이지 제목을 읽어줍니다
이 모든 게 브라우저가 알아서 해줬어요. 공짜로요.
SPA에서는 어떨까요? URL이 바뀌어도 페이지가 다시 로드되지 않습니다. JavaScript가 DOM을 업데이트할 뿐이에요. 브라우저 입장에서는 "페이지 이동"이 일어나지 않았으니, 포커스를 움직여야 할 이유가 없습니다.
결과: 사용자가 내비게이션 메뉴의 링크를 클릭했을 때, 포커스는 그 링크에 그대로 남아 있습니다. 내용은 완전히 바뀌었는데 포커스는 헤더 영역에 있는 거예요.
마우스 사용자는 모르지만, 키보드 사용자와 스크린 리더 사용자는 완전히 길을 잃습니다.
왜 이게 문제인가요?#
스크린 리더 사용자의 입장에서 생각해봅시다. 내비게이션에서 "제품 소개" 링크를 눌렀어요. 소리로는 아무 변화가 없습니다. 다시 Tab을 눌러 탐색을 시작하면… 내비게이션 링크들이 또 나옵니다. 페이지가 바뀐 건지, 그냥 포커스가 이동한 건지 알 수가 없어요.
키보드 사용자도 마찬가지입니다. 새 페이지의 본문 내용에 도달하려면 헤더의 모든 내비게이션을 Tab으로 건너뛰어야 합니다. Tab 순서나 스킵 링크 같은 키보드 조작의 기본기는 키보드 접근성 A to Z에서 따로 다뤘으니, 이 글은 라우팅이라는 SPA 고유의 상황에 집중하겠습니다. 스킵 링크가 있다면 그나마 낫지만, 포커스가 링크에 남아 있으면 스킵 링크도 다시 활성화되지 않아요.
이건 기능의 문제가 아닙니다. 동등한 경험의 문제예요.

브라우저에게 따지면 이렇게 말할 것 같습니다. "저는 페이지 이동이 없었는데요?" 기술적으로는 맞는 말입니다. 그래서 우리가 직접 해야 하는 거고요.
React Router에서 포커스 관리하기#
React Router를 쓴다면, 라우트가 바뀔 때 포커스를 이동시켜야 합니다.
가장 간단한 방법은 페이지 컴포넌트의 최상단 요소에 포커스를 주는 것입니다.
// 각 페이지 컴포넌트에서
import { useEffect, useRef } from 'react';
function ProductPage() {
const headingRef = useRef(null);
useEffect(() => {
// 컴포넌트가 마운트될 때 제목으로 포커스 이동
headingRef.current?.focus();
}, []);
return (
<main>
{/* tabIndex={-1}: 키보드 탭으로는 접근 안 되지만 프로그래밍으로 포커스 가능 */}
<h1 ref={headingRef} tabIndex={-1}>제품 소개</h1>
<p>내용...</p>
</main>
);
}tabIndex={-1}이 핵심입니다. 일반적으로 <h1>은 포커스를 받을 수 없어요. -1을 주면 Tab 키로는 접근할 수 없지만, JavaScript로는 포커스를 줄 수 있습니다.
라우트 변경을 감지해서 처리하기#
페이지마다 같은 코드를 반복하는 건 번거롭습니다. 라우트 변경을 한 곳에서 처리하는 게 더 낫습니다. 라우트 콘텐츠를 감싸는 컴포넌트 하나를 만들어 두면 돼요.
이걸 쓰기로 했다면 앞의 페이지별 방식은 걷어내세요. 각 페이지 컴포넌트의 <main>과 포커스용 useEffect를 그대로 둔 채 이 컴포넌트로 감싸면 <main>이 둘로 겹칩니다. 그러면 페이지가 h1에 준 포커스를 바깥 main이 곧바로 덮어써요. 둘 중 하나만 씁니다.
// RouteChangeAnnouncer.jsx
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export default function RouteChangeAnnouncer({ children }) {
const location = useLocation();
const mainRef = useRef(null);
useEffect(() => {
// 라우트가 바뀌면 메인 콘텐츠 영역으로 포커스 이동
mainRef.current?.focus();
}, [location.pathname]);
return (
<main ref={mainRef} tabIndex={-1}>
{children}
</main>
);
}라우터 안에서 라우트 전체를 감싸 씁니다.
<BrowserRouter>
<Header />
<RouteChangeAnnouncer>
<Routes>{/* ... */}</Routes>
</RouteChangeAnnouncer>
<RouteAnnouncer /> {/* 뒤에서 만듭니다. 포커스와 알림은 따로예요 */}
</BrowserRouter><main>에 style={{ outline: 'none' }}을 붙이고 싶은 충동이 들 수 있습니다. 라우팅할 때마다 굵은 테두리가 뜨는 게 거슬리거든요. 그런데 인라인으로 지우면 마우스로 온 사람과 키보드로 온 사람이 같이 잃습니다. 감춰야 할 쪽은 앞사람뿐인데요.
이 갈래는 CSS가 대신 쳐줍니다.
/* 키보드로 왔을 때만 아웃라인 */
main:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
/* 마우스·터치로 상호작용한 직후라면 감춤 */
main:focus:not(:focus-visible) {
outline: none;
}:focus-visible이 스크립트 포커스를 무조건 걸러낸다고 오해하기 쉬운데 그렇지 않습니다. 명세가 브라우저에 권하는 판단 기준 중에 직전 포커스가 표시되고 있었다면 스크립트가 옮긴 포커스도 표시한다가 있어요(규범이 아니라 제안입니다). 크롬 152에서 직접 재보니 그대로 동작합니다 — 키보드로 링크를 눌러 라우팅하면 :focus-visible이 매치되고 아웃라인이 그려지지만, 마우스로 누르면 매치되지 않습니다. 위 인라인 스타일을 같이 두면 이 판단이 통째로 무력해지고요(인라인이 이깁니다).
Next.js App Router에서는 — 알림은 있지만 포커스 이동은 없습니다#
"Next.js 쓰면 이거 알아서 해주는 거 아니야?" 반은 맞습니다. 나머지 반이 문제고요.
Next.js에는 라우트 어나운서(Route Announcer)라는 게 내장돼 있습니다. 화면에 보이지 않는 자리에 글자를 넣어두면 스크린 리더가 그걸 읽어주는 영역이 있는데(aria-live 영역이라고 합니다 — 뒤에서 직접 만들어 봅니다), 페이지가 바뀔 때마다 거기에 document.title을, 없으면 <h1>을 넣어주는 장치예요. 그래서 스크린 리더 사용자는 "주문 내역"처럼 바뀐 페이지 이름을 듣게 됩니다.
다만 포커스는 결국 옮겨지지 않습니다. 15까지는 라우팅 후 새 세그먼트의 최상위 요소에 focus()를 부르긴 했는데, 그 요소에 tabindex가 없어 아무 일도 일어나지 않았어요. 16에서는 새 스크롤 핸들러가 기본이 되면서 그 호출마저 빠졌고, 코드에 "포커스는 일부러 건드리지 않는다"는 주석이 붙어 있습니다. 어느 쪽이든 결론은 같아요. 제목만 읽어주고, 커서는 여전히 방금 누른 링크 자리에 남아 있어요. 링크가 페이지 하단 푸터에 있었다면 사용자는 안내를 듣고 나서도 푸터에서 Tab을 눌러야 합니다. 알림과 포커스는 별개의 일이고, Next.js가 대신해주는 건 앞쪽 하나뿐입니다.
그래서 포커스 이동은 여전히 직접 붙여야 합니다.
루트 layout에서 metadata를 export하고 있다면 이 파일을 'use client'로 만들 수 없습니다. 그럴 땐 포커스 이동만 하는 작은 클라이언트 컴포넌트를 따로 빼고, layout은 서버 컴포넌트로 두세요.
// app/layout.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
mainRef.current?.focus();
}, [pathname]);
return (
<html lang="ko">
<body>
<header>...</header>
<main ref={mainRef} tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}aria-live로 페이지 변경 알리기#
포커스를 이동시키면 스크린 리더가 포커스된 요소를 읽어줍니다. 하지만 "어떤 페이지로 이동했는지"를 명시적으로 알려주는 것도 좋은 방법입니다.
aria-live 영역을 활용하면 DOM이 바뀔 때 스크린 리더가 자동으로 그 내용을 읽어줍니다. polite와 assertive의 차이, 빈 요소를 미리 놔둬야 하는 이유 같은 라이브 리전의 동작 원리는 aria-live 라이브 리전 제대로 쓰기에 따로 정리해뒀어요.

포커스 이동은 앞의 RouteChangeAnnouncer가, 말로 알리는 건 아래 RouteAnnouncer가 맡습니다. 둘 다 라우터 안에 두고 함께 씁니다 — 하나가 다른 하나를 대신하지 않아요.
// RouteAnnouncer.jsx
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer({ pageTitle }) {
const location = useLocation();
const announceRef = useRef(null);
useEffect(() => {
if (announceRef.current) {
// 잠깐 비웠다가 다시 채워야 스크린 리더가 인식
announceRef.current.textContent = '';
const id = setTimeout(() => {
// 100ms 사이에 언마운트될 수 있으니 한 번 더 확인
if (announceRef.current) {
announceRef.current.textContent = `${pageTitle} 페이지로 이동했습니다`;
}
}, 100);
return () => clearTimeout(id); // 라우트가 연달아 바뀌면 타이머가 겹친다
}
}, [location.pathname, pageTitle]);
return (
<div
ref={announceRef}
role="status"
aria-live="polite"
aria-atomic="true"
// 시각적으로는 숨기되 스크린 리더는 읽음
style={{
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
}}
/>
);
}aria-live="polite"는 현재 읽고 있는 내용을 끊지 않고, 스크린 리더가 한가해지면 알려줍니다. 긴급한 알림이라면 aria-live="assertive"를 쓰지만, 페이지 이동 알림은 polite로 충분해요.
그런데, 약간의 꼼수가 보이시죠. 접근성 구현에 setTimeout이 등장하면 뭔가 잘못됐다는 느낌이 드실 텐데 — 맞습니다, 저도 그렇습니다. textContent를 직접 바꾸면 스크린 리더가 "변경"으로 인식하지 못하는 경우가 있어서, 한 번 비웠다가 다시 채우는 것이 현재로선 가장 안정적인 방법이에요. 우아하진 않지만 확실합니다.
직접 눌러보기 — 포커스 관리 없음/있음 비교 데모#
여기까지가 라우트 변경에서 해야 할 일입니다. 그런데 "포커스가 링크에 남는다"는 말은 글로 읽으면 좀처럼 와닿지 않아요. Tab을 직접 눌러봐야 압니다.
SPA 포커스 관리 비교 데모를 열어 위쪽 토글로 관리 없음/있음을 바꿔가며 내비게이션을 눌러보세요. 포커스가 지금 어디에 있는지 화면 아래에 실시간으로 표시됩니다.
함께 있는 로그 패널은 미리 말씀드릴 게 있습니다. 실제 스크린 리더가 뱉은 출력이 아니라, 같은 상황에서 무엇이 읽히고 무엇이 안 읽히는지를 제가 손으로 적어둔 시뮬레이션이에요. 진짜 판정은 VoiceOver나 NVDA를 켜고 직접 해보셔야 합니다.
실제로 재보면 — 포커스는 정말 링크에 남아 있습니다#
말로만 하면 못 믿을 것 같아서, 데모에서 링크 하나를 키보드로 누르고 그 직후 document.activeElement를 그대로 찍어봤습니다. 2026년 9월 15일, 크롬 152 기준입니다.
| 구분 | 이동 전 포커스 | 이동 후 document.activeElement | aria-live 영역 |
|---|---|---|---|
| 포커스 관리 없음 | a "소개" (내비게이션) | a "소개" (내비게이션) | 비어 있음 |
| 포커스 관리 있음 | a "연락처" (내비게이션) | h1 "연락처" (본문) | "연락처 페이지로 이동했습니다" |
두 경우 모두 본문은 제대로 바뀌었습니다. h1도 각각 "소개"와 "연락처"로 갈렸고요. 갈린 건 포커스와 알림 딱 두 가지입니다.
관리하지 않은 쪽은 포커스가 방금 누른 링크 위에 그대로 앉아 있습니다. 화면을 보는 사람에게는 페이지가 바뀐 게 명백한데, 커서 위치로 화면을 읽는 사람에게는 아무 일도 안 일어난 거예요. aria-live 영역이 비어 있는 것도 같은 사고입니다. 넣어준 말이 없으니 읽을 것도 없습니다.
크롬 152에서 한 번 잰 값입니다. 사파리·파이어폭스, 그리고 실제 스크린 리더에서는 다를 수 있어요. 판독은 콘솔에서
document.activeElement로 했습니다. 데모 화면의 포커스 표시기는 포커스를 잃는 순간 갱신되지 않아 직전 값을 그대로 붙들고 있거든요. aria-live 영역은 포커스와 달리 한 박자 뒤(여기선 100ms 남짓)에 채워지므로, 직접 재보실 땐 잠깐 기다렸다 읽어야 합니다.그리고 "관리 없음" 쪽은 제가 흔한 실수대로 일부러 만든 대조군입니다. 거기서 포커스가 안 움직인 건 당연해요. 이 표는 뭘 발견했다기보다, 관리하지 않으면 정확히 무엇이 어긋나는지를 눈에 보이게 만든 것에 가깝습니다.
여기까지가 "페이지가 바뀔 때"의 이야기입니다. 그런데 같은 문제가 페이지 안에서도 일어나요. 모달이나 드롭다운을 열고 닫을 때요.
포커스 복원 패턴#
모달이나 드롭다운 같은 UI 요소에서 중요한 패턴이 있습니다. 바로 포커스 복원입니다. 모달 안에서 Tab을 가두는 포커스 트랩까지 포함한 이야기는 키보드 접근성 A to Z에 정리돼 있어요.
예를 들어, "삭제" 버튼을 클릭해서 확인 모달이 열렸다고 해봅시다. 모달에서 "취소"를 눌러 닫으면… 포커스가 어디로 가야 할까요?
그 "삭제" 버튼으로 돌아가야 합니다.

import { useRef, useState } from 'react';
function DeleteButton({ itemId }) {
const buttonRef = useRef(null);
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
}
function closeModal() {
setIsModalOpen(false);
// 모달이 닫히면 버튼으로 포커스 복원
setTimeout(() => {
buttonRef.current?.focus();
}, 0);
}
return (
<>
<button ref={buttonRef} onClick={openModal}>
삭제
</button>
{/* ConfirmModal은 열릴 때 모달 안 첫 요소로 포커스를 옮긴다고 가정합니다 */}
{isModalOpen && (
<ConfirmModal
onCancel={closeModal}
onConfirm={() => {
deleteItem(itemId);
closeModal(); // 삭제 후에도 포커스는 원래 자리로
}}
/>
)}
</>
);
}setTimeout을 0으로 설정하는 이유는 모달이 DOM에서 제거되는 타이밍 때문입니다. 동기적으로 포커스를 주려 하면 아직 DOM이 업데이트되지 않은 상태일 수 있어요.
복원을 빠뜨리면 어떻게 될까요? 모달이 사라질 때 포커스도 사라진 요소와 함께 증발합니다. 브라우저는 포커스를 body로 리셋해요. 요즘 브라우저는 다음 Tab을 사라진 요소가 있던 자리에서 이어주긴 합니다(sequential focus navigation starting point). 그래도 그 사이 스크린 리더 사용자는 "지금 어디인지"를 잃고, Shift+Tab은 엉뚱한 곳으로 갑니다. 열 때 어디서 왔는지 기억해뒀다가, 닫힐 때 그 자리로 돌려보내는 것 — 왕복 티켓이라고 생각하면 쉽습니다.
포커스만 옮기면 끝이 아닙니다 — 탭 제목과 로딩 상태#
document.title 업데이트#
포커스 관리와 함께 페이지 제목도 바꿔줘야 합니다. SPA에서 <title> 태그는 자동으로 바뀌지 않거든요.
// React Helmet 또는 Next.js Metadata API 사용
import { Helmet } from 'react-helmet-async';
function ProductPage() {
return (
<>
<Helmet>
<title>제품 소개 | 내 서비스</title>
</Helmet>
<main>...</main>
</>
);
}브라우저 탭 제목도 바뀌고, 스크린 리더 사용자도 어떤 페이지인지 파악할 수 있습니다.
로딩 상태 처리#
데이터를 불러오는 동안 스켈레톤 UI를 보여줄 때도 주의가 필요합니다. 스켈레톤이 표시되는 동안 포커스를 주면, 스크린 리더가 스켈레톤 요소를 읽어버립니다.
function ProductPage() {
const { data, isLoading } = useFetchProduct();
const headingRef = useRef(null);
useEffect(() => {
// 로딩이 완료된 후 포커스 이동
if (!isLoading && headingRef.current) {
headingRef.current.focus();
}
}, [isLoading]);
// aria-label은 role 없는 요소에서 무시됩니다. 로딩은 '이름'이 아니라 '상태'예요.
if (isLoading) {
return (
{/* .sr-only: 화면에서만 감추고 스크린 리더에는 남기는 그 흔한 유틸리티 클래스입니다 */}
<div role="status">
<span className="sr-only">콘텐츠를 불러오는 중입니다</span>
<SkeletonLoader aria-hidden="true" />
</div>
);
}
return (
<main>
<h1 ref={headingRef} tabIndex={-1}>{data.title}</h1>
</main>
);
}정리#
SPA에서 포커스 관리는 선택이 아닌 기본입니다. 마우스 없이 웹을 탐색하는 사람들, 스크린 리더를 쓰는 사람들에게 SPA의 "빠른 페이지 전환"이 오히려 족쇄가 되어서는 안 됩니다.
핵심 패턴을 정리하면:
- 라우트 변경 시 포커스 이동:
tabIndex={-1}을 가진 요소로 프로그래밍 포커스 - aria-live로 변경 알림:
role="status"영역에 페이지 이동 메시지 삽입 - document.title 업데이트: 페이지 제목을 동기화
- 포커스 복원: 모달, 드롭다운을 닫을 때 원래 요소로 포커스 반환
- 로딩 완료 후 포커스: 데이터가 준비된 뒤에 포커스 이동
SPA가 빠르고 매끄럽다는 건 모든 사용자에게 그래야 합니다. 포커스 관리 하나가 그 "모든"을 완성시켜 줍니다.
그 접근성 피드백에는 이렇게 회신할 수 있었습니다 — "이제 페이지가 이동하면, 스크린 리더가 가장 먼저 압니다."
질문으로 다시 보기#
SPA에서 페이지를 이동해도 스크린 리더가 조용한 이유는 무엇인가요?
tabIndex=-1은 언제 사용하나요?
이 시리즈의 다른 글#
- 폼 실시간 검증 제대로 하기: 타이밍부터 aria-invalid까지 — 언제 알려줄지가 전부인 폼 검증 타이밍
- 다크모드 제대로 구현하기 — 색만 뒤집으면 되는 줄 알았던 이야기
프론트엔드 × 접근성 시리즈 전체 보기 — 일반 프론트엔드 주제에 접근성 시각을 연결하는 연재입니다.
