ARCANE BREW 개발기 (7) - 폴리싱과 배포
목차 보기
시리즈 목차 보기7 / 7
ARCANE BREW 개발기
7 / 7
시리즈의 마지막
6편까지의 여정에서 ARCANE BREW는 기능적으로 완성됐다. 가마솥이 끓고, 재료가 합성되고, 물약이 빛난다. 하지만 앱을 열었을 때 "여기 뭔가 특별한 곳이다"라는 첫인상을 주려면 폴리싱이 필요했다. 로딩 화면, 커서, 페이지 전환 — 작은 디테일들이 몰입감을 결정한다.
그리고 그 과정에서 만난 메모리 누수 버그는 이 시리즈에서 가장 중요한 기술적 교훈이 됐다.
🔮 로딩 스플래시 — AppLoader.vue
앱이 로드되는 1~2초 동안 빈 화면 대신 마법진 애니메이션을 보여주기로 했다. 연금술 세계관에 맞게 외곽 원과 내부 삼각형이 서로 반대 방향으로 회전하고, 중앙에 🧪 아이콘이 맥동하는 구성이다.
<div class="relative w-32 h-32 mb-8">
<svg class="absolute inset-0 w-full h-full animate-spin-slow" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="rgba(139,92,246,0.3)" stroke-width="1" />
<circle cx="50" cy="50" r="45" fill="none" stroke="rgba(251,191,36,0.6)" stroke-width="1.5"
stroke-dasharray="20 10" stroke-linecap="round" />
</svg>
<svg class="absolute inset-3 animate-spin-reverse" viewBox="0 0 100 100">
<polygon points="50,15 85,72 15,72" fill="none" stroke="rgba(251,191,36,0.4)" stroke-width="1" />
<polygon points="50,85 15,28 85,28" fill="none" stroke="rgba(139,92,246,0.4)" stroke-width="1" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-4xl animate-pulse-glow">🧪</div>
</div>Teleport로 body에 직접 마운트하고, 1.8초 후 opacity 트랜지션으로 페이드아웃시킨다. 하단에는 프로그레스 바를 두어 로딩 진행률을 시각화했다. 단순한 CSS 애니메이션만으로 구현했지만, 마법진이 천천히 돌아가며 사라지는 연출은 꽤 인상적이었다.
🖱️ 커스텀 마법 커서 — MagicCursor.vue
기본 커서를 마법 세계관에 맞는 커스텀 커서로 교체했다. 외곽 링이 마우스를 부드럽게 따라다니고, 내부 점은 즉시 위치를 추적한다. 클릭 시 스케일이 살짝 커졌다 돌아오는 애니메이션을 넣어 피드백을 줬다.
여기서 중요한 건 데스크톱 전용이라는 점이다. 터치 디바이스에서 커서를 숨기면 아무것도 보이지 않는 재앙이 발생한다. window.matchMedia로 터치 디바이스를 감지하고, CSS에서도 미디어 쿼리로 이중 방어했다.
@media (hover: hover) and (pointer: fine) {
.cursor-none, .cursor-none * { cursor: none !important; }
}(hover: hover) and (pointer: fine) — 이 조합이 "정밀 포인팅 장치가 있고 호버가 가능한 환경", 즉 마우스가 있는 데스크톱을 정확히 타겟한다. 터치 디바이스에서는 기본 커서가 그대로 유지된다.
🌀 페이지 전환 — blur + slide
Nuxt의 <NuxtPage> 트랜지션에 blur와 slide를 조합한 전환 효과를 적용했다. 단순한 fade보다 공간감이 살아난다.
.page-enter-from {
opacity: 0;
transform: translateY(12px) scale(0.99);
filter: blur(4px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.99);
filter: blur(2px);
}진입 시 아래에서 올라오며 초점이 맞춰지고, 이탈 시 위로 빠지며 흐려진다. scale(0.99)는 미묘하지만, 이 1%의 축소가 페이지가 "밀려나는" 느낌을 만든다. cubic-bezier(0.16, 1, 0.3, 1) 이징을 적용해서 초반에 빠르게 진입하고 끝에서 부드럽게 안착하는 동작감을 구현했다.
🐛 메모리 누수 수정 — 가장 중요한 교훈
폴리싱 과정에서 발견한 메모리 누수 버그가 이 시리즈에서 가장 값진 교훈이었다.
문제
onUnmounted를 onMounted 콜백 안에서 등록하고 있었다. 언뜻 보면 자연스러운 패턴이다 — "마운트 시 설정하고, 언마운트 시 정리하자"라는 직관적 사고. 하지만 Vue의 라이프사이클 훅은 setup 레벨의 컴포넌트 인스턴스에 바인딩된다. onMounted 콜백 내부에서 onUnmounted를 호출하면, 중첩된 실행 컨텍스트에서 훅이 등록되어 정리가 불안정해진다. 결과적으로 requestAnimationFrame과 resize 리스너가 컴포넌트 소멸 후에도 계속 실행됐다.
해결 패턴 1 — 변수 참조 (CauldronCanvas)
let animId = 0
let resizeHandler: (() => void) | null = null
// ...
onMounted(start)
onUnmounted(() => {
cancelAnimationFrame(animId)
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
bubbles = []; smokes = []
})onMounted와 onUnmounted를 같은 setup 레벨에 나란히 선언한다. onMounted에서 시작한 애니메이션의 ID와 리스너 참조를 외부 변수에 저장하고, onUnmounted에서 그 참조를 통해 정리한다.
해결 패턴 2 — cleanup 배열 (useParticles)
const cleanups: (() => void)[] = []
// start() 안에서 리스너 등록 시마다 push
onUnmounted(() => {
cancelAnimationFrame(animId)
cleanups.forEach(fn => fn())
cleanups.length = 0
})composable처럼 동적으로 리스너가 추가되는 경우에는 cleanup 배열 패턴이 더 적합했다. 리스너를 등록할 때마다 해제 함수를 배열에 push하고, onUnmounted에서 일괄 실행한다.
교훈: Canvas + requestAnimationFrame을 쓰는 Vue 컴포넌트에서는 반드시 setup 레벨 클린업을 확인하라. onMounted 안에 onUnmounted를 넣는 순간, 시한폭탄이 된다.
🚀 Hash 라우팅과 GitHub Pages 배포
배포 대상은 GitHub Pages였다. GitHub Pages는 SPA의 클라이언트 사이드 라우팅을 지원하지 않는다 — /brew/cauldron 같은 경로로 직접 접근하면 404가 뜬다. 404.html 리다이렉트 해킹도 있지만, 깔끔하게 createWebHashHistory()를 선택했다.
// vite.config.ts
export default defineConfig({
base: '/RealRealFun/',
// ...
})base를 리포지토리 이름으로 설정하고, npx vite build로 dist/를 생성하면 끝이다. Hash 라우팅 덕분에 https://username.github.io/RealRealFun/#/brew 형태로 모든 경로가 정상 동작한다. 배포 과정 자체는 단순했다.
🤖 AI 코드 리뷰
배포 전 마지막으로 vue-code-optimizer 에이전트를 돌려 코드 리뷰를 진행했다. AI 에이전트를 리뷰어로 활용하면 혼자 개발할 때 놓치기 쉬운 잠재적 성능 이슈나 안티패턴을 사전에 탐지할 수 있다. 실제로 위에서 언급한 메모리 누수 패턴도 이 과정에서 힌트를 얻었다. 완벽한 도구는 아니지만, 1인 개발에서 두 번째 눈이 되어준다.
📜 회고 — 연금술사의 노트를 닫으며
7편에 걸쳐 ARCANE BREW의 개발 과정을 기록했다. 돌아보면 단순한 포트폴리오 프로젝트가 아니라, 프론트엔드 기술의 깊은 곳을 탐험하는 여정이었다.
배운 것
- Canvas API와 유체 시뮬레이션 — requestAnimationFrame 루프, 파티클 물리, 버블/연기 시뮬레이션을 직접 구현하며 저수준 렌더링을 이해했다.
- SVG 렌더링 — 마법진, 원소 아이콘, 게이지 바를 SVG로 만들며 벡터 그래픽의 유연함을 체감했다.
- 게임화 패턴 — 업적 시스템, 경험치, 레시피 해금 등 게임 메카닉을 웹 UI에 녹이는 설계를 경험했다.
- Vue 3 고급 패턴 — Composable 설계, provide/inject 아키텍처, 라이프사이클 훅의 미묘한 동작까지 깊이 파고들었다.
아쉬운 것
- 모바일 최적화 — Canvas 파티클을 모바일에서 안정적으로 돌리기 위한 적응형 품질 조절이 부족하다.
- 접근성 — 커스텀 커서, Canvas 기반 인터랙션은 스크린 리더 사용자에게 불친절하다. ARIA 레이블과 키보드 네비게이션을 보강해야 한다.
- 테스트 커버리지 — 게임 로직(레시피 판정, 경험치 계산)은 유닛 테스트가 있어야 했다. 리팩토링 시 안전망이 없는 상태로 작업한 건 도박이었다.
앞으로
- WebGL 파티클 — Canvas 2D의 한계를 넘어 GPU 가속 파티클 시스템으로 전환하고 싶다.
- 사운드 이펙트 — 조제 시 보글보글, 성공 시 팡파르, 희귀 재료 등장 시 신비로운 차임. Web Audio API로 청각적 몰입감을 더하고 싶다.
- 멀티플레이어 — 여러 연금술사가 같은 작업대에서 협업하는 실시간 조제. WebSocket으로 구현하면 또 다른 시리즈가 될 것 같다.
이 프로젝트를 통해 프론트엔드 개발은 기술을 넘어 이야기를 만드는 일이라는 걸 깨달았다.