웹 게임을 위한 분석과 텔레메트리
분석은 플레이어가 무엇을 하는지, 어디에서 막히는지, 무엇이 계속 플레이하게 만드는지 이해하는 데 도움이 됩니다. 이 튜토리얼에서는 무엇을 추적하고 어떻게 추적하는지 보여줍니다.
1) 무엇을 추적할까
참여도 지표:
- 세션 시작 수와 지속 시간
- 시작한 레벨과 완료한 레벨
- 사용한 기능
- 리텐션(재방문)
성능 지표:
- 로드 시간
- 프레임 레이트
- 메모리 사용량
- 오류와 크래시
전환 지표:
- 튜토리얼 완료
- 첫 구매
- 소셜 공유
2) 간단한 이벤트 추적
js
class Analytics {
constructor(endpoint) {
this.endpoint = endpoint
this.sessionId = crypto.randomUUID()
this.queue = []
this.flushInterval = 30000 // 30초
setInterval(() => this.flush(), this.flushInterval)
// beforeunload가 아니라 페이지가 숨겨질 때 flush한다.
// beforeunload/unload는 신뢰할 수 없고(모바일에서 자주 발생하지 않고,
// 뒤로/앞으로 가기 캐시를 막는다), visibilitychange + pagehide가
// 세션 종료 시 전송에 권장되는 이벤트다.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush()
})
window.addEventListener('pagehide', () => this.flush())
}
track(event, data = {}) {
this.queue.push({
event,
data,
sessionId: this.sessionId,
timestamp: Date.now(),
url: location.href,
})
// 중요한 이벤트는 즉시 flush
if (event === 'error' || event === 'purchase') {
this.flush()
}
}
async flush() {
if (this.queue.length === 0) return
const events = [...this.queue]
this.queue = []
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events }),
keepalive: true, // beforeunload에 중요
})
} catch {
// 이벤트를 큐에 다시 넣는다
this.queue.unshift(...events)
}
}
}
const analytics = new Analytics('/api/analytics')3) 세션 추적
js
// 세션 시작 추적
analytics.track('session_start', {
referrer: document.referrer,
screen: `${screen.width}x${screen.height}`,
devicePixelRatio: window.devicePixelRatio,
userAgent: navigator.userAgent,
})
// 세션 종료 추적
let sessionStart = Date.now()
// beforeunload가 아니라 페이지가 숨겨질 때 session_end를 보낸다.
// beforeunload는 모바일에서 신뢰할 수 없고 bfcache를 깨뜨린다.
function recordSessionEnd() {
analytics.track('session_end', {
duration: Date.now() - sessionStart,
})
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') recordSessionEnd()
})
window.addEventListener('pagehide', recordSessionEnd)
// 가시성 변화 추적
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
analytics.track('tab_hidden')
} else {
analytics.track('tab_visible')
}
})4) 게임 전용 이벤트
js
// 레벨 추적
function onLevelStart(levelId) {
analytics.track('level_start', { levelId })
}
function onLevelComplete(levelId, score, time) {
analytics.track('level_complete', {
levelId,
score,
timeSeconds: time,
})
}
function onLevelFail(levelId, reason) {
analytics.track('level_fail', {
levelId,
reason, // 'death', 'timeout', 'quit'
})
}
// 업적 추적
function onAchievementUnlocked(achievementId) {
analytics.track('achievement', { achievementId })
}
// 튜토리얼 추적
function onTutorialStep(step, skipped = false) {
analytics.track('tutorial', { step, skipped })
}5) 성능 모니터링
js
class PerformanceMonitor {
constructor(analytics) {
this.analytics = analytics
this.frameTimes = []
this.lastFrame = performance.now()
}
recordFrame() {
const now = performance.now()
this.frameTimes.push(now - this.lastFrame)
this.lastFrame = now
// 최근 60프레임 유지
if (this.frameTimes.length > 60) {
this.frameTimes.shift()
}
}
getAverageFPS() {
if (this.frameTimes.length === 0) return 0
const avgFrameTime = this.frameTimes.reduce((a, b) => a + b) / this.frameTimes.length
return 1000 / avgFrameTime
}
reportPerformance() {
const fps = this.getAverageFPS()
const memory = performance.memory?.usedJSHeapSize
this.analytics.track('performance', {
avgFPS: Math.round(fps),
memoryMB: memory ? Math.round(memory / 1024 / 1024) : null,
})
}
}
// 1분마다 보고
const perfMonitor = new PerformanceMonitor(analytics)
setInterval(() => perfMonitor.reportPerformance(), 60000)Long Animation Frames로 끊김 원인 짚기
위의 FPS 카운터는 프레임이 언제 떨어지는지는 알려주지만 왜 그런지는 알려주지 않습니다. Chrome과 Edge 123에 도입된 Long Animation Frames API(LoAF)가 그 공백을 메웁니다. 50ms보다 오래 걸리는 프레임을 표시하고 어떤 스크립트가 지연을 일으켰는지 분석해 주기 때문에, 추측하지 않고 끊김을 특정 코드에 귀속시킬 수 있습니다.
js
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
analytics.track('long_frame', {
durationMs: Math.round(entry.duration),
blockingMs: Math.round(entry.blockingDuration),
// scripts[]는 프레임을 잡아먹은 소스 URL을 알려준다
scripts: entry.scripts?.map((s) => s.sourceURL),
})
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
}LoAF는 현재 Chromium 전용이라(Firefox나 Safari에는 없음) 사용하기 전에 기능을 감지하고, 크로스 브라우저 기준선으로는 FPS 카운터를 유지하세요.
6) 오류 추적
js
window.addEventListener('error', (event) => {
analytics.track('error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
})
})
window.addEventListener('unhandledrejection', (event) => {
analytics.track('error', {
message: event.reason?.message || String(event.reason),
type: 'unhandledrejection',
stack: event.reason?.stack,
})
})
// 커스텀 오류 추적
function trackGameError(context, error) {
analytics.track('game_error', {
context,
message: error.message,
stack: error.stack,
})
}7) 로드 시간 추적
js
// 초기 로드 추적
window.addEventListener('load', () => {
const timing = performance.timing
const loadTime = timing.loadEventEnd - timing.navigationStart
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart
analytics.track('page_load', {
totalMs: loadTime,
domReadyMs: domReady,
})
})
// 게임 전용 로드 단계 추적
async function loadGame() {
const start = performance.now()
await loadCriticalAssets()
const criticalTime = performance.now() - start
analytics.track('load_critical', { ms: Math.round(criticalTime) })
await loadGameAssets()
const totalTime = performance.now() - start
analytics.track('load_complete', { ms: Math.round(totalTime) })
}8) 퍼널 추적
플레이어가 핵심 흐름을 거쳐 가는 과정을 추적합니다:
js
class FunnelTracker {
constructor(analytics, funnelName) {
this.analytics = analytics
this.funnelName = funnelName
this.startTime = Date.now()
}
step(stepName) {
this.analytics.track('funnel_step', {
funnel: this.funnelName,
step: stepName,
elapsedMs: Date.now() - this.startTime,
})
}
complete() {
this.analytics.track('funnel_complete', {
funnel: this.funnelName,
totalMs: Date.now() - this.startTime,
})
}
abandon(reason) {
this.analytics.track('funnel_abandon', {
funnel: this.funnelName,
reason,
elapsedMs: Date.now() - this.startTime,
})
}
}
// 사용 예
const onboarding = new FunnelTracker(analytics, 'onboarding')
onboarding.step('welcome_shown')
// ... 플레이어가 계속하기를 클릭
onboarding.step('name_entered')
// ... 플레이어가 튜토리얼을 완료
onboarding.complete()9) A/B 테스트 지원
js
class ABTest {
constructor(testName, variants) {
this.testName = testName
this.variants = variants
// 그룹을 가져오거나 할당
const stored = localStorage.getItem(`ab_${testName}`)
if (stored && variants.includes(stored)) {
this.variant = stored
} else {
this.variant = variants[Math.floor(Math.random() * variants.length)]
localStorage.setItem(`ab_${testName}`, this.variant)
}
// 할당 추적
analytics.track('ab_assignment', {
test: testName,
variant: this.variant,
})
}
getVariant() {
return this.variant
}
trackConversion(metric) {
analytics.track('ab_conversion', {
test: this.testName,
variant: this.variant,
metric,
})
}
}
// 사용 예
const difficultyTest = new ABTest('difficulty', ['easy', 'normal', 'hard'])
game.difficulty = difficultyTest.getVariant()
// 플레이어가 레벨을 완료할 때
difficultyTest.trackConversion('level_complete')10) 프라이버시 고려 사항
js
class PrivacyAwareAnalytics extends Analytics {
constructor(endpoint) {
super(endpoint)
this.enabled = this.checkConsent()
}
checkConsent() {
return localStorage.getItem('analytics_consent') === 'true'
}
setConsent(enabled) {
localStorage.setItem('analytics_consent', enabled ? 'true' : 'false')
this.enabled = enabled
if (enabled) {
this.track('consent_granted')
}
}
track(event, data = {}) {
if (!this.enabled) return
// PII 제거
const sanitized = { ...data }
delete sanitized.email
delete sanitized.name
delete sanitized.ip
super.track(event, sanitized)
}
}
// 동의 대화상자 표시
function showConsentDialog() {
const dialog = document.createElement('div')
dialog.innerHTML = `
<p>게임을 개선하기 위해 분석을 사용합니다. 괜찮으신가요?</p>
<button id="accept">동의</button>
<button id="decline">거부</button>
`
document.body.appendChild(dialog)
dialog.querySelector('#accept').onclick = () => {
analytics.setConsent(true)
dialog.remove()
}
dialog.querySelector('#decline').onclick = () => {
analytics.setConsent(false)
dialog.remove()
}
}서드파티 대안
직접 만들고 싶지 않다면:
- Plausible — 프라이버시 중심, 간단함
- Amplitude — 제품 분석, 퍼널
- Mixpanel — 이벤트 추적, 사용자 여정
- Sentry — 오류 추적 전문
js
// 예시: Plausible
const script = document.createElement('script')
script.defer = true
script.dataset.domain = 'yourgame.com'
script.src = 'https://plausible.io/js/plausible.js'
document.head.appendChild(script)
// 커스텀 이벤트 추적
window.plausible('level_complete', { props: { level: '1' } })관련 글
- 빠르게 로드되는 웹 게임 출시하기
- IndexedDB 게임 세이브
- 창작자를 위해
- itch.io에서 게임 출시하는 방법 — itch.io 분석과 다운로드 추적
- Steam Next Fest 전략 — 위시리스트와 데모 전환율 측정
외부 자료
- Plausible Analytics — 프라이버시 친화적이고 가벼운 분석
- PostHog — 이벤트 추적이 가능한 오픈소스 제품 분석
- Sentry — 웹 앱을 위한 오류 모니터링과 크래시 리포팅