인터랙티브 404 페이지 만들기 - 튕기는 프로필과 분열 효과
404 페이지는 사용자가 잘못된 URL에 접근했을 때 보게 되는 화면입니다. 보통은 단순한 에러 메시지만 표시하지만, 이번에 kimtoma.com의 404 페이지를 재미있는 인터랙티브 경험으로 바꿔보았습니다.
완성된 모습

- 프로필 이미지가 화면을 돌아다니며 벽에 튕김 (DVD 스크린세이버 스타일)
- 클릭하면 프로필이 2개로 분열되며 계속 증식
- "홈으로 돌아가기" 버튼에 부딪히면 버튼이 흔들림
직접 확인해보세요: kimtoma.com/asdfasdf
핵심 구현
1. 바운싱 애니메이션
requestAnimationFrame을 사용해 60fps 부드러운 애니메이션을 구현했습니다.
interface Ball {
id: number
x: number; y: number // 위치
dx: number; dy: number // 속도 (방향 포함)
size: number; rot: number // 크기, 회전
}
function tick() {
for (const b of ballsRef.current) {
// 이동
b.x += b.dx
b.y += b.dy
b.rot += b.dx * 0.3 // 회전 효과
// 벽 충돌 감지 및 반사
if (b.x <= 0 || b.x >= W - b.size) {
b.dx = -b.dx // X 방향 반전
}
if (b.y <= 0 || b.y >= H - b.size) {
b.dy = -b.dy // Y 방향 반전
}
// DOM 직접 업데이트 (React 리렌더 없이)
const el = document.getElementById(`ball-${b.id}`)
if (el) {
el.style.transform = `translate3d(${b.x}px,${b.y}px,0) rotate(${b.rot}deg)`
}
}
requestAnimationFrame(tick)
}
React의 useState로 매 프레임 상태를 업데이트하면 성능 문제가 생깁니다. 대신 useRef로 데이터를 관리하고 DOM을 직접 조작하여 부드러운 60fps를 유지했습니다.
2. 클릭 시 분열
클릭하면 해당 프로필이 사라지고 더 작은 2개의 프로필이 생성됩니다.
function split(id: number) {
const b = balls.find(v => v.id === id)
if (!b) return
const sz = Math.max(b.size * 0.78, 18) // 크기 78%로 축소, 최소 18px
const spd = Math.min(2.5 + balls.length * 0.4, 7) // 개수 많아질수록 빨라짐
// 두 개의 새 공 생성
const b1 = { id: nextId++, x: b.x - 5, y: b.y - 5, dx: rand(2, spd), dy: rand(2, spd), size: sz }
const b2 = { id: nextId++, x: b.x + 5, y: b.y + 5, dx: rand(2, spd), dy: rand(2, spd), size: sz }
ballsRef.current = balls.filter(v => v.id !== id).concat(b1, b2)
}
처음에는 프로필 이미지를 clipPath로 반으로 잘라서 쪼개지는 효과를 구현했는데, 실제로 보니 동그란 원이 2개로 늘어나는 게 더 자연스러워서 변경했습니다.
3. 버튼 충돌 및 흔들림
"홈으로 돌아가기" 버튼과의 충돌을 감지하고, 충돌 시 버튼에 흔들림 효과를 줍니다.
// 버튼 위치 가져오기
const btnRect = btnRef.current?.getBoundingClientRect()
// AABB 충돌 검사
const bcx = b.x + b.size / 2 // 공 중심
const bcy = b.y + b.size / 2
const br = b.size / 2
if (bcx + br > btnRect.left && bcx - br < btnRect.right &&
bcy + br > btnRect.top && bcy - br < btnRect.bottom) {
// 충돌! 어느 방향에서 왔는지 계산 후 반사
// ...
// 버튼 흔들림 효과
btn.style.transform = `scale(0.92) rotate(${(Math.random() - 0.5) * 6}deg)`
setTimeout(() => { btn.style.transform = '' }, 200)
}
트러블슈팅: ThemeProvider와 useEffect
가장 어려웠던 부분은 애니메이션이 작동하지 않는 버그였습니다. 원인은 ThemeProvider의 구조에 있었습니다.
문제 상황
기존 ThemeProvider는 마운트 전후로 다른 래퍼를 반환했습니다:
// 마운트 전
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{children}</div>
}
// 마운트 후
return (
<ThemeContext.Provider value={...}>
{children}
</ThemeContext.Provider>
)
이렇게 하면 React가 자식 컴포넌트를 완전히 언마운트했다가 다시 마운트합니다. 그 과정에서 404 페이지의 useEffect가 실행되어 requestAnimationFrame을 시작하지만, 곧바로 언마운트되면서 cleanup 함수가 호출되고 애니메이션이 중단됩니다.
해결 방법
래퍼 구조를 일관되게 유지하도록 수정했습니다:
const value = mounted
? { theme, setTheme, toggleTheme }
: { theme: 'light', setTheme: () => {}, toggleTheme: () => {} }
return (
<ThemeContext.Provider value={value}>
<div style={mounted ? undefined : { visibility: 'hidden' }}>
{children}
</div>
</ThemeContext.Provider>
)
이제 마운트 전후로 동일한 컴포넌트 구조를 유지하므로, 자식 컴포넌트가 리마운트되지 않고 useEffect가 안정적으로 작동합니다.
다크/라이트 모드 대응
테마에 따라 배경색과 텍스트 색상을 변경합니다. ThemeProvider 컨텍스트 대신 localStorage를 직접 읽어서 적용했습니다 (404 페이지는 ThemeProvider 래퍼 내부에서 렌더링되기 전에 테마를 알아야 하므로).
useEffect(() => {
const stored = localStorage.getItem('theme')
const dark = stored === 'dark' ||
(!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)
const el = containerRef.current
if (el) {
el.style.background = dark
? 'linear-gradient(to bottom right, hsl(25,20%,10%), ...)'
: 'linear-gradient(to bottom right, hsl(25,35%,85%), ...)'
}
}, [])
마무리
단순한 에러 페이지도 작은 재미 요소를 추가하면 사용자 경험이 달라집니다. 특히 404 페이지는 사용자가 실망할 수 있는 순간이기 때문에, 이런 작은 인터랙션이 브랜드 인상을 긍정적으로 바꿀 수 있습니다.
구현 코드 전체는 GitHub에서 확인할 수 있습니다.