이 글은 누구를 위한 것인가
- 가이드·매뉴얼처럼 긴 문서를 모바일에 싣는 팀
- 앵커 링크를 눌렀는데 제목이 고정 헤더에 가려지는 문제를 겪는 경우
- 뒤로 가기로 돌아왔을 때 읽던 위치를 잃는 문제
들어가며
데스크톱에서는 긴 문서를 옆에 목차를 띄워 해결한다. 모바일에는 그 자리가 없다.
[데스크톱] [모바일]
┌────┬──────────┐ ┌──────────┐
│목차│ 본문 │ │ 본문 │ ← 목차 둘 자리가 없음
│고정│ │ │ │
└────┴──────────┘ └──────────┘
그래서 모바일에서는 문서가 길어질수록 **"지금 어디쯤인지"와 "원하는 곳으로 어떻게 가는지"**를 둘 다 잃는다. 이 글은 그 두 가지를 되찾는 구현을 다룬다.
1. 목차를 어디에 둘 것인가
세 가지 방식이 있고, 문서 길이에 따라 답이 다르다.
[A] 상단 접힌 목차
문서 맨 위에 <details> 로 접어 둠
· 구현 단순, 접근성 무료
· 스크롤 내려간 뒤에는 쓸 수 없음
→ 섹션 5~8개 문서에 적합
[B] 하단 고정 버튼 → 시트
우하단 플로팅 버튼, 누르면 목차 시트
· 어디서나 접근 가능
· 화면을 가림, 구현 부담
→ 섹션 10개 이상 긴 문서
[C] 상단 고정 진행 바 + 현재 섹션명
스크롤에 따라 현재 섹션 표시
· 위치 인지에는 최적
· 이동 기능은 별도로 필요
→ A 또는 B와 함께
대부분의 문서는 A + C 조합으로 충분하다. B는 만들기도 유지하기도 비싼데, 정작 사용자가 문서 중간에서 목차를 다시 여는 빈도는 생각보다 낮다.
판단 기준은 결국 섹션 수다. 풀림의 야근·만성 피로 가이드 정도의 분량 — 도입부와 유형별 설명, 주의사항으로 구성된 문서 — 이라면 상단 접힌 목차만으로 충분하고, 여기에 플로팅 버튼까지 올리면 화면만 좁아진다. 목차 방식은 취향이 아니라 문서 길이에 따라 결정하는 편이 낫다.
<!-- A: 별도 JS 없이 동작하고 접근성도 기본 제공 -->
<details class="toc">
<summary>목차</summary>
<nav aria-label="문서 목차">
<ol>
<li><a href="#section-1">이런 경우에 해당합니다</a></li>
<li><a href="#section-2">먼저 확인할 것</a></li>
</ol>
</nav>
</details>
<details>를 쓰면 JS 없이 접기·펴기가 동작하고, 키보드 조작과 스크린 리더 대응도 브라우저가 처리한다. 직접 만든 아코디언은 이 세 가지를 전부 구현해야 한다.
2. 앵커 오프셋 — 고정 헤더에 제목이 가려지는 문제
앵커 링크를 눌렀을 때 목표 제목이 고정 헤더 뒤로 숨는 건 가장 흔한 버그다.
┌──────────────┐
│ 고정 헤더 56px │ ← 여기에 제목이 가려짐
├──────────────┤
│ 본문 … │
JS로 스크롤 위치를 계산하는 해법을 많이 쓰는데, CSS 한 줄로 끝난다.
:target,
h2[id], h3[id] {
scroll-margin-top: calc(var(--header-height) + 12px);
}
scroll-margin-top은 스크롤로 대상을 맞출 때만 적용되는 여백이라 레이아웃에 영향을 주지 않는다. JS 계산 방식은 헤더 높이가 바뀌면 깨지고, 부드러운 스크롤과 충돌하며, 브라우저의 기본 앵커 동작(새로고침 시 복원)도 놓친다.
[함께 확인할 것]
□ 헤더 높이가 스크롤에 따라 변하면 --header-height 도 갱신
□ scroll-behavior: smooth 는 prefers-reduced-motion 존중
□ 앵커 대상에 id 가 실제로 있는지 (자동 생성 슬러그 확인)
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
모션 민감 사용자에게 부드러운 스크롤은 불편을 넘어 어지럼증을 유발할 수 있다. 이 미디어 쿼리는 선택이 아니다.
3. 스크롤 스파이 — 현재 섹션 표시
스크롤 이벤트에서 각 제목의 좌표를 매번 계산하는 방식은 모바일에서 프레임을 떨어뜨린다. IntersectionObserver를 쓰면 브라우저가 처리한다.
/** 화면 상단 근처를 지나는 섹션을 '현재'로 표시 */
function useScrollSpy(ids: string[], headerHeight: number) {
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
const visible = new Map<string, number>();
const io = new IntersectionObserver(
entries => {
for (const e of entries) {
if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top);
else visible.delete(e.target.id);
}
// 화면에 걸친 것 중 가장 위에 있는 섹션
const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0];
if (top) setActive(top[0]);
},
{
// 상단 헤더 아래 ~ 화면 하단 60% 지점을 관측 밴드로
rootMargin: `-${headerHeight}px 0px -60% 0px`,
threshold: 0,
}
);
for (const id of ids) {
const el = document.getElementById(id);
if (el) io.observe(el);
}
return () => io.disconnect();
}, [ids, headerHeight]);
return active;
}
rootMargin의 하단 -60%가 핵심이다. 이게 없으면 화면에 여러 섹션이 동시에 보일 때 어느 것이 '현재'인지 계속 흔들린다. 관측 밴드를 화면 상단 40% 구간으로 좁히면 표시가 안정된다.
[흔한 문제와 대응]
섹션이 화면보다 짧다 → 밴드를 벗어나 아무것도 활성화 안 됨
→ 마지막 활성값을 유지 (위 코드처럼 null 갱신 안 함)
문서 맨 아래에서 갱신 안 됨 → 마지막 섹션 뒤에 여백 추가
앵커 클릭 직후 값이 튐 → 클릭 후 300ms 동안 관측 결과 무시
"앵커 클릭 직후 값이 튀는" 문제는 실제로 자주 보고된다. 스크롤 이동 중에 중간 섹션들을 지나며 활성 표시가 빠르게 바뀌기 때문이다. 클릭 시점에 목표 섹션을 즉시 활성화하고 짧은 시간 관측을 무시하면 해결된다.
4. 스크롤 위치 복원
목록 → 문서 → 뒤로가기 흐름에서 읽던 위치를 잃으면 긴 문서일수록 치명적이다.
// 브라우저 기본 복원을 끄고 직접 관리할 때만
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
대부분의 경우 기본값(auto)이 낫다. history.scrollRestoration을 manual로 바꾸는 건 직접 복원 로직을 완전히 구현할 때만 의미가 있고, 어설프게 끄면 상황이 더 나빠진다.
직접 관리해야 하는 경우는 대체로 하나다 — 콘텐츠가 비동기로 채워져서 복원 시점에 문서 높이가 아직 작을 때다.
// 콘텐츠 로드 완료 후 복원
useEffect(() => {
if (!contentReady) return;
const saved = sessionStorage.getItem(`scroll:${pathname}`);
if (saved) window.scrollTo(0, Number(saved));
}, [contentReady, pathname]);
useEffect(() => {
const save = () => sessionStorage.setItem(`scroll:${pathname}`, String(window.scrollY));
window.addEventListener('pagehide', save);
return () => { save(); window.removeEventListener('pagehide', save); };
}, [pathname]);
pagehide를 쓰는 이유는 모바일 브라우저에서 beforeunload가 신뢰할 수 없기 때문이다. 탭 전환이나 앱 전환으로 페이지가 백그라운드로 가는 경우를 beforeunload는 잡지 못한다.
[복원이 어긋나는 원인]
· 이미지 크기 미지정 → 로드되며 높이가 밀림
· 폰트 로딩으로 줄바꿈 변경
· 광고·임베드가 나중에 삽입
이미지에 width/height를 지정하는 것만으로 대부분 해결된다. 복원 로직을 정교하게 만들기 전에 레이아웃이 흔들리는 원인부터 없애는 편이 빠르다.
5. 접근성
[체크리스트]
□ 목차를 <nav aria-label="문서 목차"> 로 감싸기
□ 현재 섹션 링크에 aria-current="location"
□ 본문 시작으로 건너뛰는 링크 제공
□ 진행 바는 장식 → aria-hidden="true"
□ 앵커 이동 후 포커스도 함께 이동
마지막 항목이 자주 빠진다. 앵커 링크를 눌러 화면은 이동했는데 키보드 포커스는 그대로면, 이후 Tab 키가 문서 처음부터 다시 순회한다.
function goToSection(id: string) {
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth' });
el.setAttribute('tabindex', '-1'); // 원래 포커스 불가한 요소에 필요
el.focus({ preventScroll: true }); // 스크롤은 위에서 이미 처리
}
긴 문서에서 반복 영역을 건너뛰는 수단은 WCAG의 블록 건너뛰기 기준이 요구하는 사항이기도 하다. 목차 자체가 이 역할을 겸할 수 있다.
[현재 섹션 표시의 aria]
✓ <a href="#s3" aria-current="location"> 현재 위치
✗ aria-current="page" 페이지 단위일 때만
6. 성능 — 긴 문서 특유의 문제
[모바일에서 실제로 느려지는 지점]
· 이미지가 많은 문서의 초기 로딩
· 코드 블록 문법 강조를 클라이언트에서 수행
· 스크롤 중 리렌더 (스파이 상태가 상위 컴포넌트에 있을 때)
세 번째가 놓치기 쉽다. 활성 섹션 상태를 문서 전체를 감싸는 컴포넌트에 두면, 스크롤할 때마다 본문 전체가 리렌더된다. 상태를 목차 컴포넌트 안으로 내리거나, 본문을 메모이제이션해야 한다.
// 목차만 리렌더되도록 상태를 안쪽에 배치
const Article = memo(function Article({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
});
로딩 지표는 Core Web Vitals의 LCP·INP로 관리하되, 긴 문서는 INP가 병목이 되기 쉽다. 스크롤 중 리렌더가 입력 응답을 지연시키기 때문이다.
7. 정리
1. 섹션 5~8개면 <details> 목차 + 현재 위치 표시로 충분
2. 앵커 오프셋은 scroll-margin-top — JS 계산 불필요
3. scroll-behavior: smooth 는 prefers-reduced-motion 과 함께
4. 스크롤 스파이는 IntersectionObserver + rootMargin 하단 -60%
5. 앵커 클릭 직후 짧게 관측을 무시해 표시 흔들림 제거
6. scrollRestoration 은 기본값이 대체로 낫다
7. 복원이 어긋나면 로직보다 레이아웃 안정성부터 확인
8. 앵커 이동 시 포커스도 함께 이동
가장 비용 대비 효과가 큰 건 2번이다. scroll-margin-top 한 줄이 흔히 수십 줄로 구현되는 JS 오프셋 계산을 대체하고, 헤더 높이가 바뀌어도 깨지지 않는다.