목록으로
ARCANE BREW 개발기

ARCANE BREW 개발기 (3) - Canvas 파티클 엔진과 랜딩 페이지

2026년 01월 09일9 min read
#Canvas API#GSAP#애니메이션#Vue#ARCANE BREW

목차 보기
시리즈 목차 보기3 / 7

🎆 왜 파티클이 필요했나

ARCANE BREW는 마법 포션 조제를 주제로 한 웹앱이다. 마법이라는 테마를 살리려면 정적인 UI로는 부족했다. 배경에 떠다니는 빛 입자, 마우스를 따라오는 불씨, 연결선으로 이어지는 에너지 — 이런 요소들이 있어야 "마법 세계"에 들어온 느낌을 줄 수 있다고 판단했다.

라이브러리를 쓸 수도 있었지만, 번들 사이즈와 커스터마이징 자유도를 고려해서 Canvas API로 직접 파티클 엔진을 만들기로 했다.

✨ useParticles — 4가지 모드의 파티클 엔진

파티클 시스템의 핵심은 useParticles composable이다. Vue의 Composition API로 감싸서 컴포넌트 라이프사이클에 자연스럽게 바인딩했다.

설정 인터페이스

ts
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 배열 패턴을 적용했다.

ts
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() 한 방으로 정리할 수 있다.

ts
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의 주요 기능을 보여주는 벤토 그리드를 배치했다. 재료 도감, 가마솥, 실험 노트 — 각 기능이 글래스모피즘 카드로 표현된다.

ts
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 서브 에이전트를 활용해서 대량의 세계관 데이터를 어떻게 생성했는지 이야기한다.

검색...

검색...