← 홈으로 돌아가기
Dev & Software

Next.js 블로그 반응형 목차(TOC) 개발과 4단계 실전 트러블슈팅 가이드

🤖AI Key Summary & Global Takeaways

이 아티클의 핵심 내용 요약 및 글로벌 검색/AI 엔진(Perplexity, ChatGPT, Claude) 색인을 위한 핵심 테이크어웨이입니다.

  • 🇰🇷 주제: Next.js 블로그 반응형 목차(TOC) 개발과 4단계 실전 트러블슈팅 가이드 (Dev & Software)
  • 🇰🇷 핵심 요약: Next.js 환경에서 데스크톱 사이드바와 모바일 플로팅 바텀시트 목차(TOC)를 구축하며 겪은 CSS 부모 트랩, Hydration 유령 노드(20/20 버그), 2초 스마트 자동 숨김 UX 최적화 과정을 상세히 기록합니다.
  • 🌐 Global Takeaway: In-depth first-principles analysis, technical architecture, and actionable guide authored by NaRD on Next.js 블로그 반응형 목차(TOC) 개발과 4단계 실전 트러블슈팅 가이드 for global creators and developers.

📑 1. 모던 블로그에서 반응형 목차(TOC)가 필수적인 이유

긴 기술 아티클 (Article)을 읽을 때 독자가 가장 답답함을 느끼는 순간은 "내가 지금 전체 내용 중 어디를 읽고 있는지, 다음 주제는 무엇인지"를 놓쳤을 때입니다.

웹사이트의 목차(Table of Contents, TOC)는 독자에게 글의 전체 지도를 제공하는 핵심 내비게이션 도구입니다:

  • PC 데스크톱 환경: 우측 여백에 본문과 나란히 고정되어 현재 스크롤 위치의 소제목을 하이라이트 표시하는 '스티키 사이드바(Sticky Sidebar)'가 가장 이상적입니다.
  • 모바일 환경: 좁은 스마트폰 화면에서 사이드바를 둘 수 없으므로, '우측 하단 플로팅 캡슐 배너 + 클릭 시 화면 아래에서 올라오는 바텀시트(Drawer Modal)' 형태가 글로벌 표준 UX입니다.

겉보기에는 단순해 보이는 UI이지만, Next.js 정적 사이트 생성(SSG) 환경에서 이를 완벽하게 동작하게 만들기까지는 4가지 치명적인 기술적 복병이 숨어 있었습니다. 본 글에서는 이 문제들을 해결해 나간 전 과정을 공유합니다.


🏗️ 2. 반응형 목차 아키텍처 및 요구사항 정의

구현하고자 한 반응형 목차의 핵심 요구사항은 다음과 같습니다:

[ 반응형 목차(TOC) 핵심 스펙 ]

1. 본문 자동 파싱 : 마크다운에서 렌더링된 H2, H3 태그를 수집하여 목차 트리 자동 생성
2. PC 데스크톱 (1080px 이상) : 우측 sticky 사이드바에 상시 노출 및 현재 읽는 챕터 하이라이트
3. 모바일 (1080px 이하) :
   • 우측 하단에 엄지손가락 높이로 [📑 목차 1/N] 플로팅 캡슐 상시 유지
   • 터치 시 화면 전체 덮는 암전 오버레이와 함께 하단에서 바텀시트 서랍 드로어 등장
   • 원하는 소제목 터치 시 해당 위치로 부드러운 스크롤(Smooth Scroll) 이동 후 서랍 자동 닫힘
4. 성능 & UX 최적화 :
   • 60Hz/120Hz 고주사율 스크롤 쓰로틀링 (requestAnimationFrame)
   • 글을 빠르게 읽는 동안 시야를 가리지 않는 '2초 스마트 자동 숨김' 모드

⚠️ 3. [트러블슈팅 1] 모바일 플로팅 버튼이 댓글창 밑에 갇히는 현상 (CSS 부모 트랩)

1) 증상 (Symptom)

  • 모바일 화면에서 목차 버튼이 브라우저 화면(Viewport) 우측 하단에 항상 떠서 따라다녀야 하는데, 글을 읽는 내내 보이지 않다가 글 맨 끝 댓글창(Giscus) 영역까지 다 내려가서야 댓글창 위에 덩그러니 놓여 있는 현상이 발생했습니다.

2) 근본 원인 (Root Cause)

  • CSS 표준 명세에 따르면, position: fixed 요소는 기본적으로 브라우저 창(Viewport)을 기준으로 위치가 결정됩니다.
  • 하지만 부모 컨테이너(예: .layout, .postWrapper)에 overflow-x: clip, transform, filter, perspective, 또는 contain 속성이 지정되어 있으면, position: fixed의 기준점이 브라우저 창이 아니라 '해당 부모 컨테이너'로 격리(Containment)됩니다.
  • 그 결과, 버튼이 화면에 고정되지 못하고 부모 태그의 높이 끝자락에 갇혀버렸던 것입니다.
[ CSS 부모 트랩 원리 (12세 청소년 비유) ]

"새(목차 버튼)에게 '하늘 높이 날아다녀라(position: fixed)'라고 명령했는데, 
알고 보니 새장이 닫힌 유리 상자(부모 컨테이너의 overflow: clip) 안에 갇혀 있던 것입니다. 
새는 날고 싶어도 유리 상자 바닥을 벗어날 수 없었습니다."

3) 해결책: React createPortal 최상위 마운트

React의 createPortal을 사용하여 모바일 플로팅 버튼과 바텀시트 모달을 부모 DOM 트리에서 완전히 탈출시켜 document.body 최상단에 직접 렌더링했습니다:

import { createPortal } from 'react-dom';

// 부모 컨테이너의 CSS 제약을 100% 탈출하여 document.body에 직접 장착
const mobilePortalUI = mounted && createPortal(
  <>
    <button 
      type="button" 
      className={styles.mobileFloatingPill}
      onClick={() => setIsMobileOpen(true)}
    >
      <span className={styles.pillIcon}>📑</span>
      <span className={styles.pillText}>목차</span>
      <span className={styles.pillProgressBadge}>{progressText}</span>
    </button>

    {isMobileOpen && (
      <div className={styles.mobileTocOverlay}>
        <div className={styles.mobileTocDrawer}>
          {/* 바텀시트 목차 목록 */}
        </div>
      </div>
    )}
  </>,
  document.body
);

⚠️ 4. [트러블슈팅 2] 첫 화면부터 20/20으로 점프하는 버그 (Hydration 유령 노드)

1) 증상 (Symptom)

  • 글 맨 위에서 읽기 시작하는데도 목차 뱃지에 1/20이 아니라 처음부터 마지막 번호인 20/20이 노출되었습니다.
  • 1번 목차를 클릭해서 이동해도, 스크롤이 조금만 움직이면 다시 20/20으로 덮어씌워졌습니다.

2) 근본 원인 (Root Cause)

  • Next.js는 마크다운 HTML을 dangerouslySetInnerHTML을 통해 본문에 주입합니다.
  • 초기 구현에서는 컴포넌트가 마운트될 때 container.querySelectorAll('h2, h3')로 DOM 요소들을 찾아 headingElements 배열에 저장해 두었습니다.
  • 하지만 React가 클라이언트 사이드 Hydration(동기화)을 수행하면서 본문의 DOM 노드들을 새 인스턴스로 교체했습니다!
  • 기존 배열에 저장되어 있던 노드들은 메모리에서만 붕 떠 있는 '유령 노드(Detached Node)'가 되었고, detachedNode.getBoundingClientRect()를 호출하면 무조건 { top: 0, bottom: 0 }을 반환했습니다.
  • 스크롤 판정 로직인 el.getBoundingClientRect().top <= 120에서 모든 유령 노드가 0 <= 120 (True)로 평가되어, 루프가 1번부터 20번까지 브레이크 없이 전부 통과하여 마지막 20번 목차로 덮어씌워졌던 것입니다.
[ 유령 노드 버그 원리 (12세 청소년 비유) ]

"교실에 20명의 학생(목차)이 앉아있는데, 선생님이 '어제 찍은 옛날 단체 사진(이전 참조 배열)'만 보면서 
학생 자리를 체크하고 있었습니다. 사진 속 학생들은 모두 0번 자리에 있는 것으로 계산되어, 
선생님이 맨 마지막 20번 학생만 계속 부르고 있었던 것입니다."

3) 해결책: 실시간 document.getElementById 동적 조회

이전 참조 배열 대신, 매 스크롤마다 현재 화면에 실제로 살아있는 DOM 노드를 ID로 직접 조회하도록 수정했습니다:

const activationOffset = 120;

const updateActiveHeading = () => {
  let active = headingList[0].id;

  for (const heading of headingList) {
    // 실시간으로 살아있는 현재 화면의 DOM 노드를 직접 조회하여 유령 노드 방지
    const el = document.getElementById(heading.id);
    if (!el) continue;

    if (el.getBoundingClientRect().top <= activationOffset) {
      active = heading.id;
    } else {
      break; // 현재 뷰포트 기준선 아래에 있는 제목을 만나면 즉시 루프 중단
    }
  }

  setActiveId((current) => (current === active ? current : active));
};

⚡ 5. [트러블슈팅 3] 스크롤 부하 방지 (requestAnimationFrame 쓰로틀링)

스크롤 이벤트는 사용자가 화면을 조금만 움직여도 1초에 수백 번씩 호출됩니다. 매 이벤트마다 getBoundingClientRect()를 호출하면 브라우저가 화면 레이아웃을 다시 계산(Reflow)하느라 모바일 기기에서 심한 버벅임(Jank)이 발생합니다.

이를 해결하기 위해 브라우저의 화면 주사율(60Hz/120Hz)에 맞추어 1프레임당 최대 1번만 연산하도록 window.requestAnimationFrame으로 래핑했습니다:

let animationFrameId = null;

const scheduleActiveHeadingUpdate = () => {
  if (animationFrameId === null) {
    animationFrameId = window.requestAnimationFrame(() => {
      animationFrameId = null;
      updateActiveHeading();
    });
  }
};

window.addEventListener('scroll', scheduleActiveHeadingUpdate, { passive: true });

📱 6. [트러블슈팅 4] 독서 몰입을 위한 '2초 스마트 자동 숨김' UX 구현

1) 문제점

  • 모바일 화면은 가로 폭이 360~400px 수준으로 매우 좁습니다.
  • 목차 버튼이 화면 우측 하단에 계속 떠 있으면, 본문의 핵심 텍스트나 코드 블록, 이미지 캡션을 가려 독서 몰입을 방해합니다.

2) 해결책: 스크롤 중 즉시 숨김 & 멈춘 후 2초 뒤 노출

독자가 글을 읽으며 스크롤을 내리는 동안에는 버튼을 숨겨 시야를 100% 확보해주고, 스크롤이 멈추고 2초가 지나면 부드럽게 다시 띄워주는 타이머 인터랙션을 적용했습니다:

let scrollHideTimer = null;

const handleScroll = () => {
  scheduleActiveHeadingUpdate();

  // 1. 스크롤이 움직이는 즉시 우측으로 숨김
  setIsPillVisible(false);

  // 2. 기존 타이머를 취소하고, 스크롤이 멈춘 시점부터 2초 뒤에 다시 노출
  if (scrollHideTimer) clearTimeout(scrollHideTimer);
  scrollHideTimer = setTimeout(() => {
    setIsPillVisible(true);
  }, 2000);
};
/* 부드러운 글래스 슬라이드 인/아웃 트랜지션 */
.mobileFloatingPill {
  opacity: 1;
  transform: translateX(0) scale(1);
  transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), 
              transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}

.mobileFloatingPillHidden {
  opacity: 0 !important;
  transform: translateX(36px) scale(0.85) !important;
  pointer-events: none !important;
}

🎯 7. 요약 및 프론트엔드 개발 체크리스트

점검 항목 문제 원인 적용한 해결책
화면 고정 실패 부모 레이아웃의 overflow-x: clip 트랩 createPortal(..., document.body)로 최상위 탈출
20/20 점프 현상 Hydration 후 이전 DOM 참조가 무효화(유령 노드) 매 프레임 document.getElementById 실시간 조회
스크롤 버벅임 고빈도 스크롤 이벤트 내 레이아웃 리플로우 window.requestAnimationFrame 쓰로틀링
모바일 가독성 저하 플로팅 버튼이 본문 텍스트/코드 블록 가림 스크롤 중 즉시 숨김 & 멈춘 후 2초 뒤 재등장 UX

반응형 목차(TOC)는 작은 UI 컴포넌트처럼 보이지만, CSS 컨테인먼트, React 렌더링 라이프사이클(Hydration), 브라우저 렌더링 파이프라인(Reflow), 사용자 경험(UX)이 모두 집약된 고난도 엔지니어링 과제입니다.

위의 패턴들을 적용하면 어떤 복잡한 레이아웃과 긴 본문에서도 100% 안정적으로 동작하는 프리미엄 목차 시스템을 구축할 수 있습니다.

이 아티클 공유하기
XThreads
← 이전 아티클온톨로지(Ontology)란 무엇인가? 이커머스 검색과 AI 추천 혁신의 핵심다음 아티클 →GitHub 스타(Star) 랭킹 TOP 10 완벽 분석: AI 시대, 비전공자도 알아야 할 전 세계 오픈소스 보물창고

💡 카테고리 관련 글 추천

서버·DB 없이 매일 자정 순위가 갱신되는 비결: GitHub Actions 크론과 월간 앵커 순위표 구축기
Dev & Software2026년 9월 11일

서버·DB 없이 매일 자정 순위가 갱신되는 비결: GitHub Actions 크론과 월간 앵커 순위표 구축기

GitHub Pages나 Vercel을 이용해 정적 블로그(SSG, Static Site Generation)를 운영해 본 개발자라면 누구나 한 번쯤 마주치는 본질적인 벽이 있습니...

GitHub 스타(Star) 랭킹 TOP 10 완벽 분석: AI 시대, 비전공자도 알아야 할 전 세계 오픈소스 보물창고
Dev & Software2026년 9월 1일

GitHub 스타(Star) 랭킹 TOP 10 완벽 분석: AI 시대, 비전공자도 알아야 할 전 세계 오픈소스 보물창고

과거에 GitHub은 프로그래머들만의 전유물이었습니다. 하지만 생성형 AI와 AI 에이전트(Agent)가 일상화된 지금, GitHub은 전 세계 인류가 만들어낸 가장 방대한 '무료...

GitHub Pages 블로그 URL 슬러그 개편과 SEO 리디렉션 완벽 가이드
Dev & Software2026년 8월 22일

GitHub Pages 블로그 URL 슬러그 개편과 SEO 리디렉션 완벽 가이드

블로그를 운영하다 보면 누구나 한 번쯤 "URL 주소를 어떻게 설계해야 검색엔진 최적화(SEO)와 방문자 유입에 가장 유리할까?" 라는 고민에 부딪히게 됩니다. 처음에는 단순히 날...

💬 댓글 나누기