프로젝트 소개

Reaction Time Test는 사용자의 반응 속도를 측정하는 웹 게임입니다. 화면이 변화하는 순간부터 사용자가 클릭할 때까지의 시간을 측정하여 밀리초 단위로 결과를 보여줍니다.

5개 언어(한국어, 영어, 일본어, 중국어, 스페인어)를 지원하며, 전 세계 사용자가 이용할 수 있습니다.

초기 구현의 문제점

1. 부정확한 시간 측정

처음에는 Date.now()를 사용하여 시간을 측정했습니다:

const startTime = Date.now();
// ... 사용자가 클릭 ...
const reactionTime = Date.now() - startTime;

하지만 Date.now()는 밀리초 단위로만 측정할 수 있어, 더 정밀한 측정이 필요했습니다.

2. 복잡한 상태 관리

게임의 상태(대기, 준비, 측정 중, 결과)를 여러 변수로 관리하다 보니 코드가 복잡해졌습니다:

let isWaiting = false;
let isReady = false;
let isMeasuring = false;
// ... 복잡한 if-else 체인 ...

해결 방법 1: performance.now() API

performance.now()는 마이크로초 단위(소수점 5자리까지)로 시간을 측정할 수 있습니다.

const startTime = performance.now();
// ... 사용자가 클릭 ...
const reactionTime = performance.now() - startTime;

성능 비교

메서드 정밀도 용도
Date.now() 밀리초 날짜/시간 표시
performance.now() 마이크로초 성능 측정

구현 예시

class ReactionTimer {
  constructor() {
    this.startTime = 0;
    this.endTime = 0;
  }

  start() {
    this.startTime = performance.now();
  }

  stop() {
    this.endTime = performance.now();
    return this.endTime - this.startTime;
  }

  reset() {
    this.startTime = 0;
    this.endTime = 0;
  }
}

해결 방법 2: State Machine 패턴

State Machine 패턴을 적용하여 게임 상태를 명확하게 관리합니다.

상태 정의

const GameState = {
  IDLE: 'idle',
  WAITING: 'waiting',
  READY: 'ready',
  MEASURING: 'measuring',
  RESULT: 'result'
};

State Machine 구현

class GameStateMachine {
  constructor() {
    this.state = GameState.IDLE;
    this.timer = new ReactionTimer();
    this.listeners = {};
  }

  // 상태 변경
  setState(newState) {
    const oldState = this.state;
    this.state = newState;
    this.emit('stateChange', { oldState, newState });
  }

  // 이벤트 리스너 등록
  on(event, callback) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  // 이벤트 발생
  emit(event, data) {
    if (this.listeners[event]) {
      this.listeners[event].forEach(callback => callback(data));
    }
  }

  // 게임 시작
  startGame() {
    this.setState(GameState.WAITING);
    const delay = this.getRandomDelay(2000, 5000);

    setTimeout(() => {
      if (this.state === GameState.WAITING) {
        this.setState(GameState.READY);
      }
    }, delay);
  }

  // 화면 클릭
  handleClick() {
    switch (this.state) {
      case GameState.WAITING:
        this.handleEarlyClick();
        break;
      case GameState.READY:
        this.startMeasurement();
        break;
      case GameState.MEASURING:
        this.finishMeasurement();
        break;
      case GameState.RESULT:
        this.startGame();
        break;
    }
  }

  // 조기 클릭 처리
  handleEarlyClick() {
    this.setState(GameState.IDLE);
    this.emit('earlyClick');
  }

  // 측정 시작
  startMeasurement() {
    this.setState(GameState.MEASURING);
    this.timer.start();
  }

  // 측정 완료
  finishMeasurement() {
    const reactionTime = this.timer.stop();
    this.setState(GameState.RESULT);
    this.emit('measurementComplete', { reactionTime });
  }

  // 랜덤 지연 시간 생성
  getRandomDelay(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
}

UI와 통합

const game = new GameStateMachine();

// 상태 변경에 따른 UI 업데이트
game.on('stateChange', ({ oldState, newState }) => {
  updateUI(newState);
});

// 측정 완료 이벤트 처리
game.on('measurementComplete', ({ reactionTime }) => {
  displayResult(reactionTime);
});

// 조기 클릭 이벤트 처리
game.on('earlyClick', () => {
  showEarlyClickMessage();
});

// 클릭 이벤트 연결
document.getElementById('game-area').addEventListener('click', () => {
  game.handleClick();
});

function updateUI(state) {
  const gameArea = document.getElementById('game-area');
  const message = document.getElementById('message');

  switch (state) {
    case GameState.IDLE:
      gameArea.style.backgroundColor = '#333';
      message.textContent = '클릭하여 시작';
      break;
    case GameState.WAITING:
      gameArea.style.backgroundColor = '#333';
      message.textContent = '초록색이 되면 클릭하세요';
      break;
    case GameState.READY:
      gameArea.style.backgroundColor = '#4CAF50';
      message.textContent = '지금 클릭!';
      break;
    case GameState.MEASURING:
      message.textContent = '';
      break;
    case GameState.RESULT:
      gameArea.style.backgroundColor = '#333';
      message.textContent = '다시 하려면 클릭하세요';
      break;
  }
}

최적화 결과

항목 개선 전 개선 후
시간 정밀도 1ms 0.01ms
상태 관리 복잡한 if-else 명확한 State Machine
코드 가독성 낮음 높음
유지보수 어려움 쉬움

추가 최적화

1. RequestAnimationFrame 사용

애니메이션과 타이머를 requestAnimationFrame을 사용하여 최적화합니다:

function updateTimer() {
  if (game.state === GameState.MEASURING) {
    const currentTime = performance.now();
    const elapsed = currentTime - game.timer.startTime;
    displayTime(elapsed);
    requestAnimationFrame(updateTimer);
  }
}

2. 이벤트 위임

여러 요소에 이벤트 리스너를 추가하는 대신 이벤트 위임을 사용합니다:

document.getElementById('game-container').addEventListener('click', (e) => {
  if (e.target.matches('#game-area, #start-button')) {
    game.handleClick();
  }
});

결론

performance.now() API와 State Machine 패턴을 적용하여 Reaction Time Test의 정확도와 코드 품질을 크게 개선했습니다.

프로젝트는 https://reactiontest.dreamurl.biz/에서 체험해볼 수 있습니다.