글쓰기 프리뷰
    블로그 목차(TOC) 스크롤 동기화 구현하기

    블로그 목차(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 스크롤: 헤더 높이 고려한 위치 보정
    • 반응형 레이아웃: 화면 크기에 따른 사이드바/햄버거 전환

    추가로 고려할 사항:

    • 스크롤 위치 저장 및 복원
    • 목차 자체가 길 때 스크롤 처리
    • 접기/펼치기 기능 추가
    Dunde's Portfolio

    © 2026 Dunde. All rights reserved.

    Built with React, TypeScript, and Vite. Deployed on GitHub Pages.