ARCANE BREW 개발기 (3) - Canvas 파티클 엔진과 랜딩 페이지
목차 보기
시리즈 목차 보기3 / 7
ARCANE BREW 개발기
3 / 7
🎆 왜 파티클이 필요했나
ARCANE BREW는 마법 포션 조제를 주제로 한 웹앱이다. 마법이라는 테마를 살리려면 정적인 UI로는 부족했다. 배경에 떠다니는 빛 입자, 마우스를 따라오는 불씨, 연결선으로 이어지는 에너지 — 이런 요소들이 있어야 "마법 세계"에 들어온 느낌을 줄 수 있다고 판단했다.
라이브러리를 쓸 수도 있었지만, 번들 사이즈와 커스터마이징 자유도를 고려해서 Canvas API로 직접 파티클 엔진을 만들기로 했다.
✨ useParticles — 4가지 모드의 파티클 엔진
파티클 시스템의 핵심은 useParticles composable이다. Vue의 Composition API로 감싸서 컴포넌트 라이프사이클에 자연스럽게 바인딩했다.
설정 인터페이스
export interface ParticleConfig {
count: number
colors: string[]
speed: { min: number; max: number }
size: { min: number; max: number }
mode: 'float' | 'attract' | 'ember' | 'connect'
interactive: boolean
fadeIn: boolean
opacity: { min: number; max: number }
}하나의 인터페이스로 4가지 모드를 모두 제어한다. mode에 따라 update 함수 내부의 물리 연산이 달라지는 구조다.
모드별 동작 원리
각 모드는 파티클의 update 단계에서 위치를 계산하는 방식이 다르다.
- float — 기본 모드. 미세한 상승(
vy -= 0.01)과 랜덤 횡이동을 조합한다. 수면 위로 떠오르는 기포처럼 보이도록 사인 함수로 좌우 흔들림을 추가했다. - attract — 마우스 위치를 중력원으로 설정한다. 파티클과 마우스 사이 거리에 반비례하는 인력을 적용해서, 가까울수록 강하게 빨려들되 일정 거리 이하에서는 반발력으로 전환한다.
- ember — 불씨 모드. 지속적으로 상승하면서
Math.sin(time * frequency)기반 흔들림이 더해진다. 수명이 짧고 opacity가 빠르게 감소해서 사라지는 느낌을 준다. - connect — 파티클 간 거리를 매 프레임 계산해서, 임계값 이하인 쌍에 연결선을 그린다. O(n²) 연산이라
count를 50 이하로 제한했다.
메모리 관리 — cleanup 배열 패턴
Canvas 애니메이션에서 메모리 누수는 치명적이다. requestAnimationFrame이 계속 돌고 있으면 컴포넌트가 사라져도 루프가 멈추지 않는다. 이걸 해결하기 위해 cleanup 배열 패턴을 적용했다.
const cleanups: (() => void)[] = []
// start() 내에서:
cleanups.push(() => window.removeEventListener('resize', resize))
// onUnmounted에서:
onUnmounted(() => {
cancelAnimationFrame(animId)
cleanups.forEach(fn => fn())
cleanups.length = 0
particles = []
})이벤트 리스너, 타이머, RAF — 뭐든 등록할 때 해제 함수를 cleanups에 push한다. 언마운트 시 한 번에 정리하면 빠뜨릴 일이 없다. 단순하지만 효과적인 패턴이다.
🎬 useGsapContext — GSAP 라이프사이클 관리
GSAP을 Vue 컴포넌트에서 쓸 때 가장 골치 아픈 문제가 정리(cleanup)다. ScrollTrigger, timeline, 개별 tween — 이것들을 하나씩 추적하다 보면 코드가 지저분해진다.
GSAP v3.11+의 gsap.context()가 이 문제를 깔끔하게 해결해준다. context 안에서 생성된 모든 애니메이션을 revert() 한 방으로 정리할 수 있다.
export function useGsapContext(
scopeRef: Ref<HTMLElement | null>,
setupFn: (ctx: gsap.Context) => void,
) {
let ctx: gsap.Context | null = null
onMounted(() => {
if (!scopeRef.value) return
ctx = gsap.context(() => { setupFn(ctx!) }, scopeRef.value)
})
onUnmounted(() => { ctx?.revert() })
}scopeRef로 범위를 잡으면 내부 셀렉터(.hero-title 등)가 해당 컴포넌트 DOM 안에서만 동작한다. 전역 오염 걱정 없이 GSAP을 쓸 수 있게 된 것이다.
🏠 히어로 섹션 — 키네틱 타이포그래피 + 파티클
랜딩 페이지의 첫 화면이 승부처였다. "ARCANE BREW"라는 타이틀이 글자 단위로 등장하면서, 배경에는 ember 모드 파티클이 올라가는 구성을 설계했다.
타이틀 애니메이션은 SplitText로 글자를 분리한 뒤 GSAP timeline에 스태거를 걸었다. 각 글자가 아래에서 위로 올라오면서 opacity가 0에서 1로 변하는 단순한 구성인데, 뒤의 파티클과 합쳐지니 마법서의 첫 페이지를 펼치는 느낌이 났다.
서브타이틀과 CTA 버튼은 타이틀 애니메이션 완료 후 순차적으로 페이드인된다. timeline.add()로 시퀀스를 정밀 제어했다.
📦 벤토 그리드 — ScrollTrigger 스태거 등장
히어로 아래에는 ARCANE BREW의 주요 기능을 보여주는 벤토 그리드를 배치했다. 재료 도감, 가마솥, 실험 노트 — 각 기능이 글래스모피즘 카드로 표현된다.
gsap.from('.bento-card', {
y: 60,
opacity: 0,
duration: 0.8,
stagger: 0.15,
ease: 'power3.out',
scrollTrigger: {
trigger: '.bento-grid',
start: 'top 80%',
},
})스크롤이 그리드 영역에 도달하면 카드들이 0.15초 간격으로 아래에서 올라온다. 각 카드는 RouterLink로 해당 기능 페이지에 연결했고, 호버 시 글로우 이펙트가 반응한다. backdrop-filter: blur()와 반투명 배경으로 글래스모피즘을 구현했는데, Tailwind의 backdrop-blur-md와 커스텀 box-shadow를 조합하면 깔끔하게 나온다.
🔮 나머지 랜딩 섹션
히어로와 벤토 그리드 외에도 몇 가지 섹션을 더 추가했다.
- 원소 쇼케이스 — 5원소(불, 물, 흙, 바람, 정령)를 각각의 테마 컬러와 파티클 모드로 시연하는 인터랙티브 섹션. 탭 전환 시 파티클 모드가 바뀐다.
- 스탯 카운터 — 33가지 재료, 5원소, 비밀 레시피 수 등을 ScrollTrigger로 카운트업 애니메이션 처리했다.
gsap.to()로 숫자 객체의 값을 트위닝하면서onUpdate에서 DOM에 반영하는 고전적인 패턴이다. - CTA 섹션 — 페이지 하단에 ember 모드 파티클 배경 위로 "조제 시작하기" 버튼을 배치했다.
모든 섹션에 useGsapContext를 적용해서 라이프사이클 관리를 통일했다. 덕분에 페이지 전환 시 애니메이션 잔여물이 남는 문제가 완전히 사라졌다.
📝 정리
Canvas 파티클 엔진을 직접 만드는 건 생각보다 재미있었다. requestAnimationFrame 루프 안에서 수백 개 입자의 위치를 매 프레임 갱신하는 과정은 게임 개발과 비슷한 쾌감이 있다. Vue의 Composition API와 GSAP의 context 시스템 덕분에 복잡한 애니메이션도 컴포넌트 단위로 깔끔하게 관리할 수 있었다.
다음 편에서는 ARCANE BREW의 핵심 데이터인 33개 재료 시스템과 데이터 아키텍처를 다룬다. 5원소 체계의 33개 재료를 어떻게 설계했는지, 그리고 AI 서브 에이전트를 활용해서 대량의 세계관 데이터를 어떻게 생성했는지 이야기한다.