setTimeout으로 스톱워치를 만들었는데 시간이 왜 안맞는거지

김나연·2022년 12월 4일
0

타이머 함수

setTimeout과 setInterval 함수를 사용하면 타이머를 생성할 수 있다. 생성한 타이머가 만료되면 콜백 함수가 호출된다. setTimeout 함수는 콜백 함수를 한 번, setInterval 함수는 콜백 함수를 반복 호출한다는 차이점이 있다.

setTimeout

const timeoutId = setTimeout(function, delay, param1, param2, ...);
setTimeout(() => console.log("안녕"), 1000); // 안녕

setTimeout에 첫 번째 인자로 실행 시키고 싶은 콜백 함수, 두 번째 인자로 시간(ms), 세 번째 인자 부터는 콜백 함수에 전달해야 할 인수가 존재하는 경우 사용할 수 있다. setTimeout은 고유한 타이머 id(timeoutId)를 반환하며 후에 타이머를 취소할 때 사용할 수 있다.

setTimeout 함수 사용 예제를 살펴보면 위 코드는 1초(1000ms) 뒤에 안녕이 출력 된다. setInterval 함수도 콜백 함수를 반복 호출한다는 차이점을 제외하고 같다. (다만, setInterval 함수는 콜백 함수를 실행하는 데 소모되는 시간도 지연 간격에 포함되어, 지연 간격이 실제 명시한 간격보다 짧아진다고 한다.)

스톱워치 만들기

const ConcentrateTimeComponent = () => {
  const [pause, setPause] = useRecoilState(pauseClicked);
  const [hour, setHour] = useState(0);
  const [minute, setMinute] = useState(0);
  const [second, setSecond] = useState(0);

  useEffect(() => {
    if (pause === false) setTimeout(() => setSecond(second + 1), 1000);

    if (second > 59) {
      setSecond(0);
      setMinute(minute + 1);
    }
    if (minute > 59) {
      setMinute(0);
      setHour(hour + 1);
    }
  }, [pause, second]);
};

위 코드는 내가 처음 React로 만든 스톱워치의 일부분이다.

  useEffect(() => {
    if (pause === false) setTimeout(() => setSecond(second + 1), 1000);
  }, [pause, second]);

간단히 설명해 보자면, 1초 뒤에 second를 second + 1로 바꿔주어 화면에 표시하는 코드이다. 1초 뒤에 1, 그 다음 1초 뒤에 2, 또 그 다음 1초 뒤에 3이 표시되는 코드이다. 우리가 아는 그 스톱워치이다.

처음에는 아주 잘 실행되는 듯 싶었으나 테스트를 위해 5분, 10분 그리고 30분을 실행 시켜보았더니 시간이 더디게 가는 것이었다. 계속 테스트를 반복해도 실제 시간 보다 미묘하게 느리게 흘렀다. 그래서 분명 어느 컴포넌트 부분에서 rendering이 느리거나 브라우저 관련 문제일 것이라 생각했다.

setTimeout의 delay

setTimeout을 잘못 쓰고 있는 거 같아 이에 대해 알아보기 위해 모던 자바스크립트 Deep Dive 책에서 타이머 부분을 살펴보았다. 인터넷 검색보다 Javascript를 이해하는데 도움이 되어서 왠지 힌트를 얻을 수 있을 것 같았다.

delay 시간이 설정된 타이머가 만료되면 콜백 함수가 즉시 호출되는 것이 보장되지 않는다. delay 시간은 태스크 큐에 콜백 함수를 등록하는 시간을 지연할 뿐이다. (42장 참고)

이렇게 써있어서 42장인 비동기 프로그래밍을 읽어보고 이유를 알 수 있었다.

동기 처리와 비동기 처리 방식

const 함수1 = () => {};
const 함수2 = () => {};

함수1();
함수2();

자바스크립트 엔진은 하나의 실행 컨텍스트 스택을 갖고 있기 때문에 두 가지 이상의 일을 동시에 진행할 수 없다. 스택이라는 단어에서 알 수 있듯이 나중에 들어온 실행 컨텍스트가 먼저 실행된다. 이 처럼 현재 실행 중인 일이 끝날 때까지 다음 실행할 일이 대기하는 방식을 동기 처리라고 한다. 따라서 타이머 함수인 setTimeout 함수는 현재 실행 중인 일이 종료 되지 않았어도 바로 실행해 버리는 비동기 처리 방식으로 작동하고 실행 컨텍스트가 아닌 다른 곳을 통해 작동된다.

setTimeout 작동 방식

function foo() {}
function bar() {}

setTimeout(foo, 3000);
bar();

위 그림 설명을 위한 자바스크립트 런타임 환경을 간단히 정리해 보자면 다음과 같다.

  • 자바스크립트 엔진
    • 콜 스택: 실행 컨텍스트 스택
    • 힙: 객체가 저장되는 메모리 공간 (사실 자바스크립트의 모든 값은 객체로 힙에 저장됨)
  • 브라우저 환경
    • 태스크 큐: 비동기 함수의 콜백 또는 이벤트 핸들러가 일시적으로 보관되는 영역
    • 이벤트 루프: 콜 스택과 태스크 큐를 반복해서 확인하며 콜 스택이 비어 있고 태스크 큐에 대기 중인 함수가 있으면 이벤트 루프는 태스크 큐에 대기 중인 함수를 콜 스택으로 이동시킴

비동기 처리에서 소스코드의 평가와 실행을 제외한 모든 처리는 자바스크립트 엔진을 구동하는 환경인 브라우저나 Node.js가 담당한다. setTimeout의 콜백 함수의 평가와 실행은 자바스크립트 엔진이 담당하지만 호출 스케줄링을 위한 타이머 설정과 콜백 함수의 등록은 브라우저나 Node.js가 담당한다.

setTimeout 함수가 호출되면 setTimeout 실행 컨텍스트가 생성되고 콜스택에 푸시되어 현재 실행 중인 실행 컨텍스트가 된다. setTimeout 함수가 실행되면 콜백 함수를 호출 스케줄링하고 종료되어 콜 스택에서 팝된다. 브라우저는 타이머를 설정하고 타이머의 만료를 기다린다. 타이머가 만료되면 콜백 함수 foo가 태스크 큐에 푸시되어 대기하게 된다. bar 함수가 호출되어 bar 함수가 실행컨텍스트가 되고 종료되면 콜 스택에서 팝한다. 이때 브라우저가 수행하는 역할과 자바스크립트 엔진이 수행하는 역할이 병행 처리된다. 그 다음 전역 코드 실행 컨텍스트가 콜 스택에서 팝되고 콜 스택이 완전히 비었다면 태스트 큐에서 대기 중인 콜백 함수 foo가 이벤트 루프에 의해 푸시된다. 이때 이벤트 루프는 콜 스택이 비었는지 태스크 큐에 대기 중인 태스크가 있는지 반복 확인한다.

지정 시간보다 delay가 생기는 이유

만약 setTimeout 함수가 동기 처리 된다면 setTimeout 함수의 호출 스케줄링을 위한 타이머 설정 대기 시간동안 어떠한 일도 실행할 수 없다. 그렇게 때문에 setTimeout 함수의 타이머 설정과 타이머가 만료되면 콜백 함수를 태스크 큐에 등록하는 처리를 자바스크립트 엔진이 아닌 브라우저가 실행하게 된다.

결론적으로, setTimeout 함수로 호출 스케줄링한 콜백 함수는 정확히 지연 시간 후에 호출된다는 보장이 없다. 지연 시간 이후에 콜백 함수가 태스크 큐에 푸시되어 대기하게 되지만 콜 스택이 비어야 호출되므로 약간의 시간차가 발생할 수 있기 때문이다.

다시 만드는 스톱워치

const ConcentrateTimeComponent = () => {
	const [currentStartTime, setCurrentStartTime] = useRecoilState(startTime);

	const startTotalTime = () => {
    const now = new Date(Date.now() - (currentStartTime));

    setSecond(now.getUTCSeconds());
    setMinute(now.getUTCMinutes());
    setHour(now.getUTCHours());
  };

	useEffect(() => {
    if (!pause === true) {
      startTotalTime();
      let timerId = setTimeout(() => {
        startTotalTime();
      }, 1000);

      return () => clearTimeout(timerId);
    }
  }, [pass, second]);
  }, []);
};

타이머 함수는 브라우저 동작 시간에 영향을 받으니 이와 상관 없으려면 어떻게 해야할까를 생각해 보았다. 스톱워치 다른 기능 구현에서 사용하고 있던 현재 까지 경과한 시간을 반환하는 Date.now 메서드를 활용해 보기로 하였다.

스톱워치 시작 버튼을 누르면 setCurrentStartTime(Date.now())로 currentStartTime을 현재 시각으로 바꿔준다. 그 다음 1초 마다 현재 시각에서 스톱워치 시작 버튼를 눌렀던 시각인 currentStartTime을 빼주면 시간이 얼마나 흘렀는 지 알 수 있다. 스톱워치를 일시 정지할 때는 일시정지한 시각인 currentPauseTime을 저장한 후 재 시작할 때 스톱워치를 시작했던 시각과 지금 시각을 더한 값에서 정지했던 시각을 빼주면 된다.

useStopWatch

const useStopWatch = () => {
  const [hour, setHour] = useState(0);
  const [minute, setMinute] = useState(0);
  const [second, setSecond] = useState(0);

  const startTime = () => {
    const now = new Date(Date.now() - currentStartTime);

    setSecond(now.getUTCSeconds());
    setMinute(now.getUTCMinutes());
    setHour(now.getUTCHours());
  };

  useEffect(() => {
    if (condition === true) {
      startTime();
      let timerId = setTimeout(() => {
        startTime();
      }, 1000);

      return () => clearTimeout(timerId);
    }
  }, [condition, second]);

  return [hour, minute, second];
};

다시 만든 스톱워치를 내가 구현하고 싶은 기능에 맞게 react custom hook으로 제작해 보았다.

참고

  1. 자바스크립트 Deep Dive - 30장 Date 41장 타이머, 42장 비동기 프로그래밍

  2. 딜레이가 지정한 값보다 더 긴 이유

  3. 자바스크립트 Date 메소드 총정리

0개의 댓글