Three.js와 Framer Motion으로 인터랙티브 포트폴리오 업그레이드
풀스택 개발자 포트폴리오에 3D와 애니메이션을 넣기까지
포트폴리오 사이트를 새로 만들었습니다.
이번에는 정적인 이력서가 아니라, 인터랙션이 있는 포트폴리오를 목표로 했습니다.
3D 파티클 구체가 마우스에 반응하고, 스킬 아이콘이 궤도를 따라 돌고, 별이 반짝이는 배경 위에서 경력과 프로젝트를 보여주는 사이트입니다.
솔직히 과하다는 생각도 했는데, 만들고 나니까 만족스럽습니다.
근데 과정이 순탄하지만은 않았습니다.
전체 구조
포트폴리오는 싱글 페이지에 여러 섹션이 쌓인 구조입니다.
Hero (3D 파티클 구체 + 타이틀)
↓
Skills (궤도 애니메이션)
↓
Works (캐러셀 + 상세 패널)
↓
Blog Posts (마퀴 스크롤)
↓
Contact (폼 + 소셜 링크)
데이터는 서버 컴포넌트에서 fetch해서 클라이언트 컴포넌트에 props로 내려줍니다.
export default async function Page() {
const [profile, works, skills, recentPosts] = await Promise.all([
getProfile(),
getWorks(),
getSkills(),
getRecentPosts(),
])
return (
<PortfolioClient
profile={profile}
works={works}
skills={skills}
recentPosts={recentPosts}
/>
)
}Next.js의 서버 컴포넌트에서 API를 병렬 호출하고, 결과를 한 번에 클라이언트에 넘기는 방식입니다.
Hero: 3D 파티클 구체
React Three Fiber
Three.js를 직접 쓰는 대신 React Three Fiber (R3F)를 사용했습니다.
Three.js의 명령형 API를 React의 선언적 방식으로 쓸 수 있게 해주는 라이브러리입니다.
<Canvas
camera={{ position: [0, 0, 5], fov: 45 }}
dpr={[1, 1.5]}
>
<Suspense fallback={null}>
<ParticleField />
</Suspense>
</Canvas>dpr={[1, 1.5]}는 디바이스 픽셀 비율 범위입니다.
레티나 디스플레이에서 전체 해상도로 렌더링하면 성능이 떨어지니까, 1.5배로 제한했습니다.
커스텀 셰이더
파티클 구체의 핵심은 GLSL 셰이더입니다.
Perlin noise로 구체 표면을 변형시키고, 마우스 위치에 반응하게 만들었습니다.
// 다중 레이어 노이즈로 유기적인 변형
float n1 = snoise(pos * 1.5 + uTime * 0.3) * 0.3;
float n2 = snoise(pos * 3.0 + uTime * 0.5) * 0.15;
float n3 = snoise(pos * 5.0 - uTime * 0.2) * 0.08;
// 마우스 근접도에 따른 반응
float proximity = 1.0 - smoothstep(0.0, 1.5, dist);
float hover = uHover * proximity * 0.4;
vec3 newPos = pos + normal * (n1 + n2 + n3 + hover);3개의 노이즈 레이어를 다른 스케일과 속도로 겹쳐서 자연스러운 움직임을 만들었습니다.
uHover는 마우스가 구체에 가까워질 때 증가하는 유니폼 변수입니다.
Fragment Shader
색상도 셰이더에서 처리합니다.
// 림 라이팅 (외곽 발광)
float rim = 1.0 - dot(vNormal, viewDir);
rim = pow(rim, 2.5);
// 기본 색상 + 노이즈 기반 색상 변형
vec3 color = baseColor;
color += rim * vec3(0.3, 0.15, 0.5); // 보라색 림
color += noise * vec3(0.1, 0.05, 0.15);외곽이 보라색으로 빛나는 효과는 림 라이팅입니다.
시선 방향과 표면 노멀의 내적으로 외곽을 감지하고, 색상을 더합니다.
마우스 인터랙션
마우스 위치를 3D 공간 좌표로 변환해서 셰이더에 전달합니다.
const handlePointerMove = (e: ThreeEvent<PointerEvent>) => {
uniforms.uMouse.value.set(e.point.x, e.point.y, e.point.z)
uniforms.uHover.value = 1.0
}클릭하면 uClick 값이 1이 되고, 매 프레임 0.93을 곱해서 자연스럽게 감쇠합니다.
회전 속도도 클릭 값에 연동시켜서, 클릭 시 구체가 빠르게 회전합니다.
Three.js에서 dispose를 빼먹으면 메모리 릭이 생깁니다.
geometry, material은 useEffect의 cleanup에서 반드시 dispose해야 합니다.
Hero Content: 마그네틱 레터
"Portfolio" 타이틀의 각 글자가 마우스에 반응해서 움직입니다.
function MagneticLetter({ char, index }: Props) {
const ref = useRef<HTMLSpanElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const springX = useSpring(x, { stiffness: 40, damping: 15 })
const springY = useSpring(y, { stiffness: 40, damping: 15 })
// 마우스와의 거리에 따라 글자가 밀려남
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const dx = e.clientX - (rect.left + rect.width / 2)
const dy = e.clientY - (rect.top + rect.height / 2)
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 150) {
const force = (150 - dist) / 150
x.set(-dx * force * 0.3)
y.set(-dy * force * 0.3)
} else {
x.set(0)
y.set(0)
}
}
// ...
}, [])
return (
<motion.span ref={ref} style={{ x: springX, y: springY }}>
{char}
</motion.span>
)
}Framer Motion의 useSpring으로 스프링 물리를 적용했습니다.
stiffness: 40, damping: 15로 설정하면 글자가 천천히 밀렸다가 부드럽게 돌아옵니다.
Skills: 궤도 애니메이션
스킬 아이콘들이 타원 궤도를 따라 돌아가는 섹션입니다.
궤도 계산
// 동심 타원 궤도
const radiusX = 200 + orbitIndex * 120
const radiusY = 150 + orbitIndex * 95
// 각 스킬의 각도 (시간에 따라 변화)
const angle = baseAngle + time * speed
const x = centerX + radiusX * Math.cos(angle)
const y = centerY + radiusY * Math.sin(angle)바깥 궤도일수록 타원이 커지고, 속도도 다르게 설정해서 자연스러운 리듬이 나옵니다.
숙련도 시각화
스킬의 숙련도에 따라 아이콘 크기와 색상이 달라집니다.
| 숙련도 | 크기 | 색상 |
|---|---|---|
| 90% 이상 | 큰 아이콘 | 초록 |
| 75% 이상 | 중간 | 파랑 |
| 60% 이상 | 작은 | 보라 |
| 60% 미만 | 최소 | 회색 |
호버하면 궤도 회전이 멈추고, 해당 스킬의 상세 정보가 표시됩니다.
궤도 데이터는 useMemo로 메모이제이션해야 합니다.
리렌더 시마다 새 배열이 생성되면 requestAnimationFrame이 초기화되어 애니메이션이 끊깁니다.
Works: 캐러셀 + 상세 패널
경력과 프로젝트를 캐러셀로 보여주고, 선택하면 상세 패널이 슬라이드인됩니다.
원형 캐러셀
// 원호 위에 카드 배치
const arcRadius = compact ? 700 : 900
const angleStep = 0.15
const angle = (i - currentIndex) * angleStep
const x = Math.sin(angle) * arcRadius
const z = Math.cos(angle) * arcRadius - arcRadius
const rotateY = angle * (180 / Math.PI)카드들이 원호 위에 배치되어, 양쪽으로 스와이프하면 3D 회전 효과가 납니다.
다국어 MDX 콘텐츠
경력 상세 내용은 한국어, 영어, 일본어로 MDX를 지원합니다.
백엔드에서 locale별 콘텐츠를 받아서, 사용자가 언어를 전환할 수 있습니다.
const locales = [
{ code: 'ko', label: '한국어' },
{ code: 'en', label: 'English' },
{ code: 'jp', label: '日本語' },
]별 배경
전체 페이지 배경에 반짝이는 별이 있습니다.
Canvas API로 직접 구현했습니다.
// 120개의 별 생성 (랜덤 위치, 크기, 깜빡임 속도)
const stars = Array.from({ length: 120 }, () => ({
x: Math.random() * width,
y: Math.random() * height,
radius: Math.random() * 1.5 + 0.5,
twinkleSpeed: Math.random() * 0.02 + 0.01,
phase: Math.random() * Math.PI * 2,
}))스크롤 위치에 따라 별의 투명도가 줄어들어서, 아래 섹션에서는 자연스럽게 사라집니다.
aria-hidden="true"와 tabIndex={-1}을 추가해서 스크린 리더와 키보드 네비게이션에 방해되지 않게 했습니다.
로딩 스크린
페이지 진입 시 Three.js와 대량의 에셋이 로드되니까, 로딩 스크린을 넣었습니다.
// 최소 1800ms 표시 + 프로그레스 바 애니메이션
const MINIMUM_DISPLAY_TIME = 1800
useEffect(() => {
let frame: number
const animate = () => {
setProgress(prev => {
const increment = (100 - prev) * 0.03
return Math.min(prev + increment, 99.5)
})
frame = requestAnimationFrame(animate)
}
frame = requestAnimationFrame(animate)
return () => cancelAnimationFrame(frame)
}, [])프로그레스 바는 점점 느려지는 곡선으로 올라갑니다.
99.5%까지 가고, 실제 로딩 완료 시 100%로 점프합니다.
SSR 이슈
포트폴리오는 Next.js SSR인데, Three.js는 클라이언트에서만 돌아갑니다.
window 참조 에러가 곳곳에서 터졌습니다.
window is not defined
프리렌더링 시 window.innerWidth 같은 코드가 실행되면 에러입니다.
// before (에러)
const height = window.innerHeight
// after
const height = typeof window !== 'undefined' ? window.innerHeight : 0Three.js Canvas 자체도 'use client'가 없으면 서버에서 렌더링을 시도합니다.
모든 인터랙티브 컴포넌트에 'use client'를 붙이고, 서버 컴포넌트에서는 데이터만 내려주는 구조로 분리했습니다.
성능 최적화
인터랙션이 많으면 성능이 금방 나빠집니다.
신경 쓴 부분들입니다.
requestAnimationFrame cleanup
rAF를 시작했으면 cleanup에서 반드시 취소해야 합니다.
useEffect(() => {
let frame: number
const animate = () => {
// 애니메이션 로직
frame = requestAnimationFrame(animate)
}
frame = requestAnimationFrame(animate)
return () => cancelAnimationFrame(frame) // 필수
}, [])이걸 빠뜨리면 컴포넌트가 언마운트되어도 애니메이션이 계속 돌아갑니다.
Three.js dispose
return () => {
geometry.dispose()
material.dispose()
if (material.uniforms.uMouse) {
material.uniforms.uMouse.value = null
}
}Geometry, Material은 GPU 메모리를 차지합니다.
cleanup에서 dispose하지 않으면 메모리 릭이 생깁니다.
mousemove 최적화
// before: setState로 매 프레임 리렌더
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
// after: ref로 리렌더 없이 값만 저장
const mousePosRef = useRef({ x: 0, y: 0 })mousemove 이벤트는 초당 60번 이상 발생합니다.
setState로 받으면 매번 리렌더가 발생하는데, useRef로 바꾸면 리렌더 없이 값만 업데이트됩니다.
PDF 이력서 생성
포트폴리오에서 이력서를 PDF로 다운로드할 수 있습니다.
@react-pdf/renderer를 사용해서 브라우저에서 직접 PDF를 생성합니다.
const handleDownload = async (locale: string) => {
const { generateResumePDF } = await import('./resume-pdf')
const blob = await generateResumePDF(locale)
// blob을 URL로 변환하여 다운로드
}동적 import로 PDF 생성 코드를 lazy load합니다.
@react-pdf/renderer가 꽤 무거운 라이브러리라서, 초기 번들에 포함시키면 안 됩니다.
정리
인터랙티브 포트폴리오를 만들면서 느낀 점입니다.
좋은 점
- Three.js + R3F 조합이 React 개발자에게 접근하기 좋습니다
- Framer Motion의 스프링 물리가 자연스러운 애니메이션을 만들어줍니다
- 셰이더를 직접 작성하면 원하는 비주얼을 정밀하게 구현할 수 있습니다
- SSR + 클라이언트 인터랙션 분리 패턴이 확실해졌습니다
어려웠던 점
- GLSL 셰이더 디버깅이 어렵습니다 (console.log가 없음)
- SSR 환경에서 window 참조 에러를 하나씩 잡아야 합니다
- 성능 최적화에 생각보다 시간이 많이 들었습니다
- Canvas 기반 UI의 접근성 보장이 까다롭습니다
팁
- Three.js를 React에서 쓸 때는 React Three Fiber를 강력 추천합니다
- rAF, setTimeout, eventListener는 반드시 cleanup 함수에서 정리하세요
- mousemove처럼 빈번한 이벤트는
useRef로 리렌더를 피하세요 aria-hidden,tabIndex로 장식 요소의 접근성을 처리하세요- Heavy 라이브러리는 동적 import로 분리하세요
참고 자료
연관 게시글