
블로그 목차(TOC) 스크롤 동기화 구현하기
(수정: 2025년 12월 24일 오후 02:36)
블로그 목차(TOC) 스크롤 동기화 구현하기
긴 블로그 포스트에서 현재 읽고 있는 위치를 표시하는 목차(Table of Contents)를 구현했습니다. IntersectionObserver API를 활용한 스크롤 동기화 방식을 정리합니다.
요구사항
- 마크다운 헤딩(h1~h3)을 자동 추출
- 현재 보이는 섹션 하이라이트
- 클릭 시 해당 섹션으로 스무스 스크롤
- 반응형: 데스크톱은 우측 고정, 모바일은 햄버거 메뉴
헤딩 추출
마크다운에서 헤딩 파싱
const extractHeadings = (content: string): TocItem[] => { // 코드 블록 내용 제거 (```...``` 패턴) const contentWithoutCodeBlocks = content.replace(/```[\s\S]*?```/g, ""); const headingRegex = /^(#{1,3})\s+(.+)$/gm; const items: TocItem[] = []; let match; while ((match = headingRegex.exec(contentWithoutCodeBlocks)) !== null) { const level = match[1].length; // #의 개수 const text = match[2].replace(/[*_`]/g, "").trim(); // 마크다운 서식 제거 // ID 생성: 소문자, 특수문자 제거, 공백을 -로 변환 const id = text .toLowerCase() .replace(/[^a-z0-9가-힣\s]/g, "") .replace(/\s+/g, "-"); items.push({ id, text, level }); } return items; };
코드 블록 제외 이유
마크다운 코드 블록 내부에도 #으로 시작하는 주석이 있을 수 있습니다. 이를 제외해야 정확한 헤딩만 추출할 수 있습니다.
# 실제 헤딩 \`\`\`python # 이것은 파이썬 주석 (추출 제외해야 함) print("hello") \`\`\`
IntersectionObserver로 스크롤 동기화
기본 개념
IntersectionObserver는 요소가 뷰포트에 들어오거나 나갈 때 콜백을 실행합니다. 폴링 방식보다 성능이 좋습니다.
const [activeId, setActiveId] = useState<string>(""); useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { setActiveId(entry.target.id); } }); }, { rootMargin: "-80px 0px -80% 0px" } ); // 모든 헤딩 요소 관찰 headings.forEach(({ id }) => { const element = document.getElementById(id); if (element) observer.observe(element); }); return () => observer.disconnect(); }, [headings]);
rootMargin 설정
{ rootMargin: "-80px 0px -80% 0px" }
| 값 | 의미 |
|---|---|
| -80px | 상단 80px 여백 (헤더 높이 고려) |
| 0px | 좌우 여백 없음 |
| -80% | 하단 80% 축소 (상단 20% 영역에서만 감지) |
이 설정으로 헤딩이 화면 상단 20% 영역에 들어올 때만 활성화됩니다. 아래로 스크롤할 때 자연스럽게 다음 헤딩으로 전환됩니다.
부드러운 스크롤 이동
offset 적용
헤더 높이만큼 오프셋을 주어 헤딩이 가려지지 않게 합니다.
const handleClick = (id: string) => { const element = document.getElementById(id); if (!element) return; const offset = 100; // 헤더 높이 + 여유 공간 const top = element.getBoundingClientRect().top + window.scrollY - offset; window.scrollTo({ top, behavior: "smooth" }); };
CSS scroll-margin 대안
CSS로도 같은 효과를 얻을 수 있습니다.
/* 모든 헤딩에 스크롤 마진 적용 */ h1, h2, h3 { scroll-margin-top: 100px; }
그러면 JavaScript는 간단해집니다.
const handleClick = (id: string) => { document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); };
반응형 레이아웃
브레이크포인트 전략
| 화면 크기 | 동작 |
|---|---|
| 1900px 이상 | 우측 고정 사이드바 |
| 1900px 미만 | 플로팅 햄버거 버튼 |
return ( <> {/* 데스크톱: 우측 고정 */} <nav className="hidden min-[1900px]:flex fixed right-8 top-1/2 -translate-y-1/2 w-64"> <TocList /> </nav> {/* 모바일: 햄버거 버튼 */} <div className="min-[1900px]:hidden fixed right-6 top-24 z-40"> <button onClick={() => setIsOpen(!isOpen)}> {isOpen ? <X /> : <List />} </button> {isOpen && ( <> <div className="fixed inset-0 bg-background/80" onClick={() => setIsOpen(false)} /> <nav className="absolute right-0 top-14 w-72 bg-card border rounded-xl p-5"> <TocList /> </nav> </> )} </div> </> );
화면 크기 변경 처리
데스크톱에서 모바일 메뉴가 열린 상태로 화면을 키우면 메뉴가 닫히도록 처리합니다.
useEffect(() => { const handleResize = () => { if (window.innerWidth >= 1900) { setIsOpen(false); } }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []);
레벨별 들여쓰기
헤딩 레벨에 따라 시각적 계층을 표현합니다.
<ul> {headings.map(({ id, text, level }) => ( <li key={id} style={{ paddingLeft: `${(level - 1) * 12}px` }}> <button onClick={() => handleClick(id)} className={activeId === id ? "text-primary font-medium" : "text-muted-foreground"} > {text} </button> </li> ))} </ul>
| 레벨 | paddingLeft | 예시 |
|---|---|---|
| h1 (level=1) | 0px | 대제목 |
| h2 (level=2) | 12px | 중제목 |
| h3 (level=3) | 24px | 소제목 |
시리즈 네비게이션 통합
목차와 함께 시리즈 이전/다음 포스트 링크도 표시합니다.
interface SeriesNavigation { seriesName: string; totalCount: number; currentIndex: number; prev: BlogPost | null; next: BlogPost | null; } const TableOfContents = ({ content, seriesNavigation }: TableOfContentsProps) => { // ... return ( <nav> {/* 목차 */} {headings.length > 0 && <TocList />} {/* 시리즈 네비게이션 */} {seriesNavigation && ( <div className="mt-6 pt-4 border-t"> <p className="text-sm font-semibold">{seriesNavigation.seriesName}</p> <p className="text-xs">{seriesNavigation.currentIndex} / {seriesNavigation.totalCount}</p> {seriesNavigation.prev && ( <Link to={`/blog/${seriesNavigation.prev.slug}`}> <ChevronLeft /> {seriesNavigation.prev.title} </Link> )} {seriesNavigation.next && ( <Link to={`/blog/${seriesNavigation.next.slug}`}> <ChevronRight /> {seriesNavigation.next.title} </Link> )} </div> )} </nav> ); };
마무리
IntersectionObserver를 활용한 목차 동기화 구현의 핵심 포인트:
- 코드 블록 제외: 마크다운 파싱 시 코드 블록 내 주석 무시
- rootMargin 조정: 상단 영역에서만 헤딩 감지로 자연스러운 전환
- offset 스크롤: 헤더 높이 고려한 위치 보정
- 반응형 레이아웃: 화면 크기에 따른 사이드바/햄버거 전환
추가로 고려할 사항:
- 스크롤 위치 저장 및 복원
- 목차 자체가 길 때 스크롤 처리
- 접기/펼치기 기능 추가