Skip to content

웹 게임을 위한 분석과 텔레메트리

분석은 플레이어가 무엇을 하는지, 어디에서 막히는지, 무엇이 계속 플레이하게 만드는지 이해하는 데 도움이 됩니다. 이 튜토리얼에서는 무엇을 추적하고 어떻게 추적하는지 보여줍니다.

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' } })

관련 글

외부 자료

  • Plausible Analytics — 프라이버시 친화적이고 가벼운 분석
  • PostHog — 이벤트 추적이 가능한 오픈소스 제품 분석
  • Sentry — 웹 앱을 위한 오류 모니터링과 크래시 리포팅