2021. 1. 6. 21:10ㆍFrontend
Overview
웹 레이아웃을 짜다 보면 남은 시간을 표시하는 타이머를 표시해야 할 때가 종종 있습니다. 로그인 시 이메일 인증이나 SMS 인증을 할 때, 세션 만료까지 남은 시간을 표시해주어야 할 때도 있고, 브라우저에서 동작하는 간단한 게임을 만들 때 게임 종료 시까지 남은 시간을 표시해주어야 할 때도 있습니다.
자바스크립트에는 특정한 시간마다, 혹은 특정 시간이 지난 후에 callback함수를 호출할수 있는 setInterval, setTimout이라는 내장 함수를 제공하기 때문에, 이를 이용해서 타이머를 구현할 수 있습니다. 하지만, 싱글 스레드로 동작하는 자바스크립트의 특성상 setTimeout, setInterval이 항상 정확한 시간에 호출되지는 않기 때문에 브라우저에서 처리하는 일들이 많은 경우 실제 시간의 흐름과는 전혀 다른 시각을 표시하게 됩니다. 이를테면 실제 시간은 5초가 지났는데 타이머에서는 2초밖에 경과하지 않은 것으로 표시하게 되는 것입니다.
이번 포스팅에서는 이런 문제가 발생하는 원인을 자바스크립트의 특성에 비추어 간단히 살펴보고, 정확하게 동작하는 타이머를 구현해보도록 하겠습니다.
ProblemShooting
우선 문제의 원인을 분석하기 위해 자바스크립트의 '이벤트 루프'의 개념을 알아야 합니다. 자바스크립트는 기본적으로 '싱글 스레드' 언어입니다. webWorker등을 통해 멀티스레드로 동작하게 할 수 있지만, 함수 호출에 따라 실행 콘텍스트가 쌓이는 콜 스택(Call Stack)이 하나라는 점에서 '싱글 스레드'라고 합니다.
싱글 스레드 언어이기 때문에 여러 함수를 순서대로 실행했을 때, 하나의 함수가 실행되고 나서야 그 다음 함수가 실행되게 됩니다. 따라서 다음과 같은 함수 호출(동기식으로 작성되었다고 가정했을 때)은 항상 동일한 순서로 함수가 실행됨을 보장할 수 있습니다.
function a() {
console.log('a');
}
function b() {
console.log('b');
}
function c() {
console.log('c');
}
a();
b();
c();
// a
// b
// c
하지만 실행되어야 하는 코드가 비동기일때는 이야기가 달라집니다. 하나의 함수를 처리하는 동안에는 다른 일을 할 수가 없기 때문에 해당 함수가 리턴되고 난 후에야 다음 함수가 실행될 수 있는데, 서버의 응답을 기다리거나, 사용자의 이벤트(마우스 클릭, 스크롤 등)를 기다리는 비동기 코드를 하나의 콜 스택에서 실행하게 되면 응답을 기다리는 동안 브라우저가 아무것도 할 수 없게 됩니다. 따라서 자바스크립트에서는 이러한 상황을 방지하기 위해서 "이벤트 루프"라는 개념을 사용합니다.
서버의 응답을 기다리거나, setTimeout등 비동기 이벤트가 발생하면 자바스크립트는 이를 콜 스택에 넣는 것이 아니라 '이벤트 루프'에 넣게 됩니다. 그리고, 이벤트 루프는 '콜 스택이 비었을 때' 이벤트 루프에 들어온 순서대로 코드를 실행하게 됩니다. 다시 말해서 브라우저의 효율성을 높이기 위해 비동기 코드의 경우 이벤트 루프에 집어놓고 "콜 스택이 비면" 순서대로 실행하는 것입니다.
따라서 '콜 스택이 비지 않았다면' 이벤트 루프에서 대기하고 있는 코드들은 실행되지 않고 기다리게 되며, 이벤트 루프로 들어가는 setTimeout, setInterval 등의 로직은 자바스크립트로부터 제때 실행되는 것을 보장받을 수 없게 되는 것입니다. 이 때문에 setTimeout으로 타이머를 만들게 되면 콜 스택 상황에 따라서 타이머가 제각각 표시되게 됩니다.
Implementation
위에서 언급한 문제를 해결하기 위해서, 타이머를 제작할때 싱크(Sync)를 맞춰야 합니다. setTimeout 로직을 호출하여 타이머를 돌리되, 이 부분의 타이밍이 정확하지 않을 수 있으므로 타이머가 표시할 시간을 현재 시간과, 끝나는 시간 기준으로 정하는 것입니다.
먼저 Timer컴포넌트를 마운트 할 때, 타이머의 종료시간을 계산합니다. 예를 들어 10초 뒤에 끝나는 타이머를 생성하려고 한다면, 컴포넌트가 마운트 될 때의 시각에 10초를 더한 시간을 종료시간으로 설정하는 것입니다. 그리고 setTimeout을 1초뒤에 호출하도록 합니다. 1초 뒤에 setTimeout이 호출될 때도 있고, 로직에 따라 1초보다 긴 시간이 지난 후에 setTimeout이 호출될 수도 있지만, setTimeout이 호출되었을 때의 시간과 마운트 될 때 설정해두었던 종료시간의 차이를 구해서 타이머에 표시합니다.
예를 들어 1초뒤에 실행되어야 하는 setTimeout이 브라우저의 복잡한 로직들로 인해 2초 뒤에 실행되었다고 하더라도, 실행된 시간을 기준으로 남은 시간을 계산하기 때문에 9초가 아닌 8초가 남았다고 표시되므로, 타이머는 항상 정확한 시간을 표시하게 되는 것입니다.
Source Code
Timer.tsx
P.S. 아래 로직은 타이머가 종료되면 다시 처음부터 시작합니다.
import React, { useEffect, useState, FC } from "react";
import dayjs from "dayjs";
interface IProps {
duration: number;
}
const Timer: FC<IProps> = ({ duration }) => {
const [endTime, setEndTime] = useState<number>(
new Date().getTime() + duration * 1000
);
const [timeLeft, setTimeLeft] = useState<number>(duration);
useEffect(() => {
let timerId = setTimeout(() => {
setTimeLeft(calculateTimeLeft());
}, 1000);
return () => {
clearTimeout(timerId);
};
});
useEffect(() => {
if (timeLeft <= 0) {
setTimeLeft(duration);
setEndTime(new Date().getTime() + duration * 1000);
}
}, [timeLeft]);
const calculateTimeLeft = () => {
const currTime = new Date().getTime();
return endTime - currTime;
};
return <>{dayjs(timeLeft).format("mm:ss")}</>;
};
export default Timer;
Reference
developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout
developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval
'Frontend' 카테고리의 다른 글
[Webpack] Introduction & Setup (0) | 2021.06.09 |
---|---|
[Optimization] using JSX props (0) | 2021.04.04 |
[Safari] 내 iPhone 브라우저 Inspect하기 (0) | 2021.01.04 |
[React] 클로저와 useState Hooks (2) (3) | 2020.11.03 |
[Browser] 웹 페이지 로드 과정 (0) | 2020.10.27 |