Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결

6 min read
CanvasUX디버깅

스크롤 프로그레스 바

블로그 글이 길어지면 "지금 어디쯤 읽고 있는 거지?"라는 생각이 든다. 브라우저 기본 스크롤바로도 알 수 있지만, 화면 상단에 얇은 프로그레스 바가 있으면 직관적이다.

구현

생각보다 간단하다. 전체 문서 높이 대비 현재 스크롤 위치의 비율을 계산하면 된다.

src/components/common/ScrollProgressBar.tsx
'use client'
 
import { useEffect, useState } from 'react'
 
export default function ScrollProgressBar() {
  const [progress, setProgress] = useState(0)
 
  useEffect(() => {
    const handleScroll = () => {
      const scrollTop = window.scrollY
      const docHeight =
        document.documentElement.scrollHeight - window.innerHeight
 
      if (docHeight <= 0) {
        setProgress(0)
        return
      }
 
      setProgress(Math.min((scrollTop / docHeight) * 100, 100))
    }
 
    window.addEventListener('scroll', handleScroll, { passive: true })
    return () => window.removeEventListener('scroll', handleScroll)
  }, [])
 
  return (
    <div className="fixed top-14 right-0 left-0 z-50
      h-0.5 bg-transparent">
      <div
        className="h-full bg-text transition-[width]
          duration-75 ease-out"
        style={{ width: `${progress}%` }}
      />
    </div>
  )
}

핵심 포인트:

  • document.documentElement.scrollHeight - window.innerHeight — 실제 스크롤 가능한 거리
  • { passive: true } — 스크롤 이벤트 성능 최적화. 브라우저에게 preventDefault()를 호출하지 않겠다고 알려줘서 스크롤을 차단하지 않음
  • top-14 — navbar(h-14) 바로 아래에 위치
  • bg-text — 라이트 모드에서는 검은색, 다크 모드에서는 밝은 색으로 테마에 자동 대응
  • transition-[width] duration-75 — 너무 부드러우면 느려 보이고, 없으면 뚝뚝 끊겨 보인다. 75ms가 적당

레이아웃에 적용

루트 레이아웃의 Navbar 바로 아래에 배치한다:

src/app/layout.tsx
<Navbar />
<ScrollProgressBar />
<main className="mt-14 flex-1">{children}</main>

Canvas 커서 효과와 GNB 깜빡임

프로그레스 바를 추가하고 나서 테스트하는데, 마우스를 움직일 때마다 GNB가 깜빡거리는 현상을 발견했다. 프로그레스 바와는 무관하고, 이전에 추가한 커서 트레일 효과가 원인이었다.

원인 분석

문제의 구조를 정리하면 이렇다:

z-index 레이어 구조 (수정 전)
─────────────────────────
z-9999  Canvas (커서 효과)     ← 매 프레임 clearRect + 다시 그리기
z-50    Navbar (backdrop-blur) ← blur 대상이 계속 바뀜
z-40    ScrollProgressBar
        본문 콘텐츠

Canvas가 fixed inset-0 z-[9999]로 화면 전체를 덮고 있었다. requestAnimationFrame으로 매 프레임(약 16ms마다) clearRect → 원 그리기를 반복한다.

여기서 문제가 생긴다. Navbar의 backdrop-blur-md뒤에 있는 레이어의 픽셀을 블러 처리하는 CSS 필터다. Canvas가 navbar 위에서 매 프레임 다시 그려지면, 브라우저는 그때마다 backdrop-blur를 재계산해야 한다.

결과적으로:

  1. Canvas가 clearRect로 지워짐 → backdrop-blur 재계산
  2. Canvas에 원이 그려짐 → backdrop-blur 또 재계산
  3. 이게 초당 60번 반복 → GNB가 깜빡거림

해결

두 가지를 수정했다:

1. Canvas를 navbar 아래로 이동

// Before
<canvas className="pointer-events-none fixed inset-0 z-[9999]" />
 
// After
<canvas className="pointer-events-none fixed top-14 right-0 bottom-0 left-0 z-40" />
  • inset-0top-14 : navbar 영역(56px)을 제외
  • z-[9999]z-40 : navbar(z-50)보다 아래로

이렇게 하면 Canvas가 navbar와 겹치지 않으므로 backdrop-blur 재계산이 발생하지 않는다.

2. 마우스 좌표 보정

Canvas의 시작점이 바뀌었으니, 마우스 Y좌표도 navbar 높이만큼 빼줘야 한다:

const NAV_HEIGHT = 56
 
const handleResize = () => {
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight - NAV_HEIGHT
}
 
const handleMouseMove = (e: MouseEvent) => {
  mouseX = e.clientX
  mouseY = e.clientY - NAV_HEIGHT  // navbar 높이만큼 보정
}

수정 후 레이어 구조

z-index 레이어 구조 (수정 후)
─────────────────────────
z-50    Navbar (backdrop-blur) ← Canvas와 겹치지 않음
z-50    ScrollProgressBar
z-40    Canvas (커서 효과)     ← navbar 아래 영역만 커버
        본문 콘텐츠

Canvas가 navbar 영역을 침범하지 않으므로, backdrop-blur가 매 프레임 재계산될 이유가 없다. 깜빡임 해결.

교훈

이번 이슈에서 배운 점:

  • backdrop-blur는 비싼 연산이다 — 뒤에 있는 레이어가 자주 바뀌면 매번 재계산된다. 가능하면 blur 뒤에서 애니메이션을 돌리지 않는 게 좋다.
  • z-index는 신중하게 — "일단 9999"로 올려놓으면 의도치 않은 레이어 충돌이 생긴다. 필요한 만큼만 사용하자.
  • Canvas와 CSS 필터의 조합은 주의 — 둘 다 GPU를 사용하지만, 상호작용 방식에 따라 성능 문제가 발생할 수 있다.

사소해 보이는 깜빡임이지만, 사용자 경험에 직접적인 영향을 주는 문제였다. 레이어 구조를 정리하는 것만으로 깔끔하게 해결된 케이스.

관심 있을 만한 포스트