Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결
스크롤 프로그레스 바
블로그 글이 길어지면 "지금 어디쯤 읽고 있는 거지?"라는 생각이 든다. 브라우저 기본 스크롤바로도 알 수 있지만, 화면 상단에 얇은 프로그레스 바가 있으면 직관적이다.
구현
생각보다 간단하다. 전체 문서 높이 대비 현재 스크롤 위치의 비율을 계산하면 된다.
'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 바로 아래에 배치한다:
<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를 재계산해야 한다.
결과적으로:
- Canvas가
clearRect로 지워짐 → backdrop-blur 재계산 - Canvas에 원이 그려짐 → backdrop-blur 또 재계산
- 이게 초당 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-0→top-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를 사용하지만, 상호작용 방식에 따라 성능 문제가 발생할 수 있다.
사소해 보이는 깜빡임이지만, 사용자 경험에 직접적인 영향을 주는 문제였다. 레이어 구조를 정리하는 것만으로 깔끔하게 해결된 케이스.
관심 있을 만한 포스트
Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기
IntersectionObserver 기반 TOC(Table of Contents)와 Canvas 커서 트레일 효과 구현기. 스크롤 하이라이팅, fixed 레이아웃 처리까지.
CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스
CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.
CSS Stacking Context — z-index: 99999를 줬는데 왜 안 올라올까
CSS 쌓임 맥락의 생성 조건, z-index의 실제 작동 원리, 그리고 레이아웃 버그를 디버깅하는 실전 전략을 정리한다.
Next.js 블로그 만들기 — 정적 블로그에 맞춤 추천 포스트 기능 추가
localStorage에 조회 이력을 저장하고, 태그 가중치 스코어링으로 정적 블로그에서도 개인화 추천을 구현하는 방법.
Next.js 블로그 만들기 — 정적 블로그에 검색 기능 추가
빌드 타임 검색 인덱스 생성과 클라이언트 사이드 필터링으로 정적 블로그에 검색 기능을 구현하기. Cmd+K 단축키, 오버레이 UI까지.
AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다
PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.
Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건
1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.
V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법
V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.