[Javascript] 타이머를 구현하는 방법

배준형·2024년 4월 23일
0
post-thumbnail

서문

안녕하세요. 마이다스인에서 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.

잡다 서비스 내에는 몇몇 타이머가 존재합니다. 특정 이벤트 종료 시점을 알려주는 타이머, 매칭 서비스를 통해 기업으로부터 전달받은 매칭 리포트의 마감 시점을 표시하는 타이머 등 1초 단위로 시간이 줄어들게 표현되는 UI가 있습니다.

해당 부분이 처음엔 setInterval API를 이용하여 1초마다 시간이 줄어들도록 구현이 되어 있었습니다. 그런데, 브라우저에서의 이벤트 루프(Event Loop)에 의해 정확하게 1초마다 줄어드는 것이 보장되지 않기도 하고, 탭이 비활성화 되면 setInterval이 의도한 것과 다르게 이상하게 동작하기도 하는데요. 이를 개선하기 위해 requestAnimationFrame을 사용하여 남은 시간을 표시할 때 오차를 최소화하도록 수정하였습니다.

이에 대해 타이머를 구현하는 방법은 무엇이 있는지 정리하고 공유하고자 합니다.


setTimeout / setInterval

setTimeout과 setInterval은 자바스크립트에서 타이머를 구현할 때 자주 사용되는 메서드입니다. setTimeout은 지정한 시간(ms) 후에 콜백 함수를 한 번 실행하고, setInterval은 지정한 시간 간격마다 콜백 함수를 반복 실행합니다. 1초 단위로 줄어드는 UI를 구현하려면 1초마다 남은 시간을 1초씩 감소시키는 코드를 작성하면 되겠죠.

setTimeout

function timeoutTimer(endTime, timeout) {
  const now = Date.now();
  const end = endTime.getTime();
  const timeLeft = end - now;
  console.log(`남은 시간: ${timeLeft}ms`);

  if (timeLeft <= 0) {
    console.log('타이머 종료');
    return;
  }

  setTimeout(() => {
    timeoutTimer(endTime, timeout);
  }, timeout);
}

// 5초 타이머 시작
timeoutTimer(new Date(Date.now() + 5000), 1000);

setInterval

function intervalTimer(endTime, timeout) {
  const timerId = setInterval(() => {
    const now = Date.now();
    const end = endTime.getTime();
    const timeLeft = end - now;
    console.log(`남은 시간: ${timeLeft}ms`);

    if (timeLeft <= 0) {
      console.log('타이머 종료');
      clearInterval(timerId);
    }
  }, timeout);
}

// 5초 타이머 시작
intervalTimer(new Date(Date.now() + 5000), 1000);

해당 코드를 개발자 도구 console 창에 복사 + 붙여넣기로 시도해보세요.

결과는 어떨까요?

setTimeoutsetInterval

두 경우 모두 정확하게 1000ms 만큼 뒤에 콜백 함수 실행되지 않았습니다. setTimeout 경우 시간이 지날수록 오차가 4ms, 10ms, 13ms 등 늘어났고, setInterval 경우 마찬가지로 16ms 등 격차가 발생하기도 했지만 줄어들었다 늘었다 하는 모습을 확인할 수 있습니다.

기대했던 대로 남은 시간이 5000ms, 4000ms, 3000ms, ..., 0ms 순서로 출력되지 않은 이유는 자바스크립트 이벤트 루프의 동작 방식과 관련이 있습니다.


이벤트 루프(Event Loop)

출처: https://www.lydiahallie.com/blog/event-loop

브라우저에서 자바스크립트 코드는 콜 스택(Call Stack)에 순차적으로 쌓여 실행됩니다. 비동기 로직을 만나면 해당 로직을 별도의 공간(Web API)으로 분리한 후, 실행 가능한 시점이 되면 태스크 큐(Task Queue)로 이동합니다. 이후 콜 스택이 완전히 비어있을 때 이벤트 루프에 의해 태스크 큐의 로직이 콜 스택으로 푸시되어 실행됩니다.

setTimeoutsetInterval 메서드는 지정된 지연 시간 이후에 실행 가능한 상태가 되어 콜 스택에 콜백 함수를 푸시합니다. 하지만 콜백 함수가 실행되기 위해서는 콜 스택이 완전히 비어있어야 합니다.

위 예제 코드에서는 setTimeout / setInterval 외에 다른 코드가 없어 콜 스택이 대부분 비어있었지만, 그래도 약간의 오차가 발생했습니다. 만약 다른 무거운 자바스크립트 코드와 함께 실행된다면 콜백 함수의 실행이 더 지연될 수 있습니다.

따라서 setTimeoutsetInterval 메서드는 지정된 지연 시간 이후에 콜백 함수를 호출하되, 실제 실행 시점은 의도한 것보다 늦어질 수 있다는 점을 이해해야 합니다. 정확한 시간 간격을 보장하지 않으므로, 장시간 타이머를 사용하면 오차가 누적되어 실제 시간과 차이가 발생할 수 있습니다.


setTimeout vs setInterval

타이머 구현 시 setTimeoutsetInterval 모두 사용 가능하지만, 오차 누적 측면에서 setInterval이 더 나은 선택일 수 있습니다.

setTimeout 메서드를 사용할 때는 콜백 함수 내부에서 재귀적으로 setTimeout을 호출하는 방식으로 구현해야 합니다. 이 경우 매 호출마다 지연 시간 + 오차만큼의 시간이 지난 후에 콜백이 실행됩니다. 다음 setTimeout(지연 시간 + 오차1) + 지연 시간 + 오차2만큼 지연되어 호출되므로, n번 호출하면 n번의 오차가 누적되어 시간이 점점 뒤로 밀리게 됩니다.


setTimeout

const start = Date.now();
let count = 0;

function timeoutTimer(endTime, timeout) {
  const now = Date.now();
  const end = endTime.getTime();
  const timeLeft = end - now;

  const delay = now - start - count * timeout;
  count++;
  console.log(`${count}회 실행 | 지연 시간: ${delay}ms`);

  if (timeLeft <= 0) {
    console.log('타이머 종료');
    return;
  }

  setTimeout(() => {
    timeoutTimer(endTime, timeout);
  }, timeout);
}

timeoutTimer(new Date(Date.now() + 5000), 100);

위 코드에서 5초 동안 100ms 간격으로 setTimeout을 호출한 결과, 각 호출마다 1~4ms의 오차가 누적되어 50번째 호출에서는 146ms의 지연이 발생했습니다. 카운트다운 타이머에 사용된다면 5146ms 후에 종료되는 것이죠.



반면에 setInterval은 콜백 함수의 실행 시간이 지정한 간격보다 길어지더라도, 다음 타이머는 이전 타이머의 실행 시간과 무관하게 일정한 간격으로 실행 대기열에 추가됩니다. 따라서 오차 범위가 일정하게 유지되는 것처럼 보일 수 있습니다.

setInterval

const start = Date.now();
let count = 0;

function intervalTimer(endTime, timeout) {
  const timerId = setInterval(() => {
    const now = Date.now();
    const end = endTime.getTime();
    const timeLeft = end - now;

    const delay = now - start - (count + 1) * timeout;
    count++;
    console.log(`${count}회 실행 | 지연 시간: ${delay}ms`);

    if (timeLeft <= 0) {
      console.log('타이머 종료');
      clearInterval(timerId);
    }
  }, timeout);
}

intervalTimer(new Date(Date.now() + 5000), 100);

마찬가지로 5초 동안 100ms 간격으로 오차 시간을 측정한 결과, 오차가 0~2ms로 유지되었습니다. 이전 타이머에 2ms의 오차가 있어도 다음 타이머는 일정한 간격으로 실행 대기열에 추가되기 때문이죠.

따라서 setTimeoutsetInterval 중에서는 오차 누적을 최소화하기 위해 setInterval을 사용하는 것이 더 나은 선택일 수 있습니다.


requestAnimationFrame

requestAnimationFrame은 브라우저에서 애니메이션을 효율적으로 구현하기 위해 사용되는 메서드로, 브라우저의 렌더링 주기에 맞춰 호출됩니다. 일반적으로 브라우저는 디스플레이 주사율에 맞춰 초당 60회(60fps) 렌더링을 수행하며, 다음 리페인트 직전에 브라우저가 지정된 애니메이션 업데이트 함수를 호출하도록 요청합니다.

콜백 함수의 호출 빈도는 보통 1초에 60회이지만, 대부분의 웹 브라우저에서는 W3C 권장사항에 따라 디스플레이 주사율과 일치하도록 동작합니다. 60Hz 주사율의 모니터에서는 1초당 60회의 콜백이 실행되는 것이죠. 이는 1000ms / 60회 = 16.6ms/회로 계산됩니다. 만약 120Hz 주사율의 모니터를 사용한다면 1000ms / 120회 = 8.33ms/회 단위로 실행됩니다.


requestAnimationFrame의 사용 방법은 setTimeout과 유사하며, 두 번째 인자인 timeout 값을 제거하면 됩니다.

requestAnimationFrame

let count = 0;
function rAFTimer(endTime) {
  const now = Date.now();
  const end = endTime.getTime();
  const timeLeft = end - now;
  count += 1;
  console.log(`${count}회 실행 | 남은 시간: ${timeLeft}`);
  if (timeLeft <= 0) {
    console.log('타이머 종료');
    return;
  }

  requestAnimationFrame(() => {
    rAFTimer(endTime);
  });
}

rAFTimer(new Date(Date.now() + 5000));

실행 결과를 보면 5초 후에 타이머가 종료된 것처럼 보이며, 5초 동안 약 1202회 실행되었습니다.


실험한 화면의 주사율은 240Hz였습니다. 주사율이 240Hz인 경우, 4.17ms/회 단위로 실행되어야 하고, 1초당 240번 호출되어야 합니다. 5초 동안 1200회 실행되어야 하는데 1202회 실행되어 2회의 오차가 발생했지만, 1초당 240번 호출 조건을 만족했다고 볼 수 있습니다. 만약 60Hz 주사율을 가진 모니터에서 실행하면 어떻게 될까요??

주사율을 60Hz로 낮추고 실험해 보았습니다. 5000ms 동안 총 301회 실행되었고 16.6ms/회 단위로 실행 되었네요. 1초당 60회 실행되어야 하므로 300회 실행되어야 하는데, 1회의 오차가 발생했지만 1초당 약 60회 실행된 것을 볼 수 있습니다.


requestAnimationFrame의 장단점

장점

requestAnimationFrame은 브라우저의 렌더링 주기에 맞춰 실행되므로 효율적인 애니메이션을 구현할 수 있습니다. 브라우저 탭이 활성화되어 있지 않거나 애니메이션이 보이지 않는 경우 프레임 생성을 자동으로 중지하여 불필요한 리소스 소모를 방지할 수 있습니다.

또한 브라우저의 화면 갱신 주기와 동기화되어 실행되므로 프레임 간 간격이 일정하게 유지됩니다. 이를 통해 타이머 UI를 구현할 때 setTimeout이나 setInterval을 사용하는 것보다 더 정확한 타이머를 구현할 수 있습니다.


단점

하지만 requestAnimationFrame은 브라우저의 렌더링 주기에 의존하므로 정확한 타이밍 제어가 어려울 수 있습니다. 만약 requestAnimationFrame 외에 다른 무거운 작업이 진행 중이라면 프레임 속도(frame rate)가 일정하지 않을 수 있습니다. 이로 인해 콜백 함수의 실행 시간이 렌더링 주기를 초과하면 프레임 드롭(frame drop)이 발생하여 애니메이션이 부드럽지 않게 될 수 있습니다.

또한 requestAnimationFrame을 사용하여 일정 시간 후에 기능이 동작하도록 구현할 수는 있지만, 여전히 오차가 발생할 수 있습니다. 이를 해결하기 위해서는 종료 시간과 현재 시간을 계산하여 보여주는 추가 작업이 필요합니다. 만약 이러한 연산이 오래 걸린다면 다른 방법을 사용하는 것이 더 나은 선택이 될 수 있습니다.


Web Worker

Web Worker는 웹 브라우저에서 백그라운드에서 실행되는 자바스크립트 스크립트로, 메인 스레드와 별개로 독립적으로 작동합니다. Web Worker를 사용하여 타이머를 구현하면 메인 스레드와 분리된 백그라운드 스레드에서 타이머를 실행할 수 있어 타이머 로직이 메인 스레드의 성능에 영향을 주지 않고 정확한 타이밍을 유지할 수 있습니다.

단, Web Worker는 DOM에 직접 접근할 수 없으므로 메시지 전달을 통해 데이터를 주고받아 UI를 처리해야 합니다.


사용 방법은 onmessage 또는 onmessageerror 이벤트 핸들러로 이벤트를 바인딩하고, worker를 생성하여 postMessage 메서드를 활용해 데이터를 주고받으면 됩니다.

worker.js

  • 루트 디렉토리에 생성합니다.
  • 저는 Next.js를 사용하고 있어서 /public/worker.js에 생성했습니다.
function intervalTimer(endTime, timeout) {
  let timerId;
  let count = 0;
  const start = Date.now();

  timerId = setInterval(() => {
    const now = Date.now();
    const timeLeft = endTime - now;
    const delay = now - start - (count + 1) * timeout;
    count += 1;
    postMessage({ type: 'timer', count, delay });

    if (timeLeft <= 0) {
      postMessage({ type: 'timer-end' });
      timerId && clearInterval(timerId);
    }
  }, timeout);
}

onmessage = function (e) {
  const { type } = e.data;
  switch (type) {
    case 'start-timer':
      intervalTimer(Date.now() + 5000, 1000);
      break;
    case 'timer-end':
      postMessage({ type: 'timer-end' });
      break;
  }
};

main.js

const worker = new Worker('worker.js');

worker.onmessage = function (e) {
  const { type, count, delay } = e.data;
  switch (type) {
    case 'timer':
      console.log(`${count}회 실행 | 지연 시간: ${delay}ms`);
      break;
    case 'timer-end':
      console.log('타이머 종료');
      break;
  }
};

worker.postMessage({ type: 'start-timer' });

worker.js 파일을 루트 디렉토리에 생성하여 onmessage / onmessageerror 등의 이벤트를 작성합니다. 여기서 받는 event 매개변수는 worker.postMessage 메서드를 호출할 때 인자로 넘겨준 값을 전달해줄 수 있습니다.

실제 타이머를 사용할 페이지, 컴포넌트, 파일 등에서 worker를 정의하고, worker.postMessage를 사용하여 특정 스크립트를 실행시킬 수 있습니다.


위의 예시에선 web workersetInterval을 같이 사용했습니다. 그냥 setInterval을 사용했을 때 보다 코드도 늘어났고, 코드를 이해해야 하는 과정도 필요했는데요. 이렇게 작성하면 일반 setInterval을 하는 것과 무엇이 다를까요?


setInterval vs Web Worker + setInterval

setInterval만 사용했을 때는 브라우저의 탭이 비활성화 됐을 때 interval이 이상하게 작용합니다.

브라우저 비활성화비활성화 시 동작

100ms 단위로 callback이 반복되게 만들었는데, 처음엔 잘 동작하다가 해당 콘솔이 띄워져 있는 브라우저 탭을 비활성화하고 다른 탭을 활성화했을 때 보시는 바와 같이 약 800ms 정도씩 오차가 발생하게 됐습니다. 이후 탭을 다시 활성화했을 땐 지연된 시간은 유지된 채로 다시 정상 동작하는 모습을 확인할 수 있습니다.


그러면 Web Worker + setInterval을 사용하면 어떨까요?

Web Worker를 사용하면 메인 스레드와 별개로 독립적으로 작동하므로 탭이 비활성화 되더라도 setInterval이 계속 활성화 되어있던 것처럼 정상 동작하게 됩니다.

requestAnimation을 사용했을 때 너무 많은 연산이 들어가게 되어서 정확히 1초마다 연산이 수행되는 타이머를 만들고 싶다면 Web Worker + setInterval 조합이 나은 선택이 될 것입니다.


Web Worker + requestAnimationFrame은 안될까?

Web Worker 내부에서 requestAnimationFrame을 사용하는 것은 일반적이지 않습니다. requestAnimationFrame은 브라우저의 렌더링 주기에 맞추어 콜백 함수를 실행한다고 했었죠. requestAnimationFrame은 주로 메인 스레드에서 사용되며, 브라우저의 렌더링 주기와 동기화되어 애니메이션을 구현하는 데 사용됩니다.

Web Worker는 UI 렌더링과 직접적인 관련이 없으므로 Web Worker와 requestAnimationFrame을 같이 사용하는 것은 적절하지 않을 수 있습니다.


정리

타이머를 구현할 때 setTimeout / setInterval / requestAnimationFrame / Web Worker 키워드를 사용하여 구현할 수 있습니다.

setTimeout을 사용하면 시간이 흐를수록 오차의 시간이 중첩되어 밀리므로 가장 안좋은 선택지가 될 것 같습니다. 이에 비해 setInterval은 이전 타이머의 오차 시간과 상관 없이 일정한 시간에 따라 실행 대기열에 추가되므로 오차 시간이 늘어나지 않고 일정 수준을 유지하게 됩니다.

requestAnimationFrame은 사용자의 모니터 주사율(Hz)에 따라 초당 콜백 호출수가 달라지게 됩니다. 일반적으로 60Hz 모니터를 사용하니 콜백은 약 16.6ms에 1회 실행하게 되고, 사용자마다 달라질 수 있습니다.

Web Worker는 웹 브라우저에서 백그라운드에서 실행되는 자바스크립트 스크립트로 메인 스레드와 독립적으로 수행됩니다. 이번 글에서는 타이머에서만 사용했지만, 타이머 외에도 다양하게 사용할 수 있을 것으로 보입니다.

잡다 웹에서 사용되는 타이머 UI는 requestAnimationFrame으로 구현이 되어 있습니다. Web Worker는 비교적 최근에 알게 되었는데, 적절하게 사용하면 유용할 것 같아서 다음에 타이머를 구현해야 한다면 Web Worker를 이용해보는 것도 괜찮을 것 같습니다.

참조

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글