새로운 버전의 티처캔에서 타이머를 구현하게 되었다. 이전 버전에서도 타이머를 구현했기에 큰 어려움은 없었으나 이전 버전에서 미처 발견하지 못한 문제와 타이머 폰트 크기에 대한 문제가 생겨 이에 대해 기록을 남기고자 한다.
1. 백그라운드에서의 타이머
첫 번재 문제는 '백그라운드에서의 타이머'라고 할 수 있다. 타이머가 실행 중인 탭이 아닌 다른 탭으로 이동되었을 때(탭이 비활성화되었을 때), 타이머를 구현하기 위해 사용한 'setInterval'함수는 백그라운드의 탭의 자원 사용을 최소화하기 위해 'setInterval'의 콜백 함수의 호출 시간이 지정된 시간보다 늦어질 수 있다. 즉 예를 들어, 1000ms 마다 콜백 함수를 호출하는 것을 원했으나 그보다 더 긴 시간인 2000ms마다 호출되는 것이다. 때문에 타이머를 시작하고 다른 탭을 다녀오면 시간이 덜 흐른 것을 확인할 수 있다.
티처캔에서의 타이머는 학생들에게 쉬는 시간 안내, 학습 활동 안내와 같이 시간을 큰 화면에 보여주는 것을 예상 사용 목적으로 생각하고 있기에 타이머 페이지에서 사용자가 이탈할 확률은 적다고 생각한다. 하지만 예외의 상황이 존재할 수 있으므로 이를 해결하고자 한다.
Web Worker API를 사용하여 해결하기
앞서 발생한 문제를 Web Worker API를 사용하여 해결할 수 있다. Web Woker란 무엇인지 간단히 살펴보고 어떻게 타이머에 구현을 했는지 설명한다.
Web Worker란?
MDN 문서에 따르면 Web Worker는 다음과 같다.
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술입니다. 웹 워커를 통해 무거운 작업을 분리된 스레드에서 처리하면 주 스레드(보통 UI 스레드)가 멈추거나 느려지지 않고 동작할 수 있습니다.
https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API
웹 워커에서 사용할 수 있는 함수, Web API는 아래의 MDN문서를 참고하면 좋을 듯하다.
Web Worker에 대한 좋은 자료는 MDN에 친절하고 자세히 있으니 Web Worder에 대한 소개는 빠르게 마무리한다. 이어서 Web Wokrder를 사용하여 어떻게 타이머를 구현하였는지 설명한다.
Step1. 백그라운드에 일 시키기
Woker를 생성하기 전에 우선 백그라운드에서 어떤 일을 해야 하는지 코드로 작성을 한다. 아래의 코드는 MDN문서에서의 소개하는 worker.js의 예제 코드이다.
onmessage = function(e) {
console.log('Worker: Message received from main script');
const result = e.data[0] * e.data[1];
if (isNaN(result)) {
postMessage('Please write two numbers');
} else {
const workerResult = 'Result: ' + result;
console.log('Worker: Posting message back to main script');
postMessage(workerResult);
}
}
'onmessage'함수를 만들고 해당 함수 내에서 'postMessage'를 통해 어딘가로 메시지를 보내고 있다'. 좀 더 자세히 살펴보면 다음과 같다.
1. 매개변수 e의 data 속성을 통해 특정 값에 접근을 한다. 이후 새로운 값을 만든다.(result)
2. result가 숫자가 아니라면 'Please write two numbers'라는 메세지를 보낸다.
3. result가 숫자라면 해당 값을 메시지로 보낸다.
결국 '매개변수에서 특정 값을 가져온다.'라는 것과 'postMessage로 새로운 값을 보낸다.'라는 것을 토대로 타이머와 관련된 로직을 작성하면 된다. 아래는 타이머 관련된 로직이다.
onmessage = (event: MessageEvent<number>) => {
let time = event.data;
setInterval(() => {
time--;
postMessage(time);
}, 1000);
};
1. event.data를 통해 time를 전달받는다.
2. setInterval 함수를 통해 1초마다 time이 1씩 줄어들고 이를 postMessage를 통해 전달한다.
Step2. Woker 생성하기(제거하기)
다음으로 할 작업은 Woker를 생성하는 것이다. 더불어 더 이상 Worker가 필요 없을 때, 제거하는 것도 다룬다. 먼저 아래의 코드는 MDN문서에서의 예제 코드이다.
const first = document.querySelector('#number1');
const second = document.querySelector('#number2');
const result = document.querySelector('.result');
if (window.Worker) {
const myWorker = new Worker("worker.js"); // 워커 생성
first.onchange = function() {
myWorker.postMessage([first.value, second.value]); // 메세지 보내기
console.log('Message posted to worker');
}
second.onchange = function() {
myWorker.postMessage([first.value, second.value]);
console.log('Message posted to worker');
}
// 메세지 받기
myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log('Message received from worker');
}
} else {
console.log('Your browser doesn\'t support web workers.');
}
다른 코드 보다 우선 myWoker 변수를 살펴보자. 해당 변수는 new Woker("worker.js")가 할당된다. 즉 Woker를 생성하는 것은 다음과 같다.
const myWoker = new Woker(앞서 작성한 로직이 있는 경로)
이를 토대로 타이머 구현을 위한 Woker를 생성해 보자.
const useTimer = () => {
// ...
useEffect(() => {
const worker = new Worker('../../../src/workers/timerWorker.ts');
if (isProceeding) playTimer(worker);
return () => {
worker.terminate();
};
}, [playTimer, isProceeding]);
// ...
}
프로젝트에서 Woker는 src/wokers 폴더에서 관리한다. 때문에 경로를 위와 같이 작성하였다. 이렇게 되면 백그라운드에서도 열심히 일을 할 수 있는 Woker가 생겼다.
playTimer는 Woker를 매개변수로 가지는 함수로 Woker와 메시지를 주고받으면서 time 상태를 업데이트한다. 아래에서 다룬다.
isProceeding은 현재 타이머가 작동 중인지를 나타내는 변수이다.
만약 컴포넌트가 unmount 되었을 때, 더 이상 Woker를 필요 없으므로 이를 제거하기 위해 cleanup 함수에서 Woker의 'terminate'메서드를 호출한다. 해당 메서드는 메서드 명 그대로 Woker를 제거하는 역할을 한다.
Step3. 메시지 주고받기
마지막 단계이다. 해당 단계에서는 Woker와 메시지를 주고 받으면서 타이머의 시간을 업데이트를 한다. Step2의 MDN 예제 코드를 살펴보면 다음과 같은 메서드를 확인할 수 있다.
1. postMessage: Woker에 메세지를 보낸다.
2. onmessage: Woker에서 메세지를 받는다. 이는 콜백함수로 매개변수의 data 속성을 통해 메시지에 접근할 수 있다.
앞서 Step2에서 타어머가 작동 중일 때 playTimer함수를 실행한다고 했다. 해당 함수에서 postMessage, onmessage를 사용하고 있다. playTimer의 코드는 다음과 같다.
const useTimer = () => {
// ...
const playTimer = useCallback(
(worker: Worker) => {
worker.postMessage(time);
worker.onmessage = (event: MessageEvent<number>) => {
const leftTime = event.data;
setTime(leftTime);
if (leftTime === 0) setIsProceeding(false);
};
},
[time],
);
// ...
}
postMessage의 인자로 time를 전달한다. 이때 time은 timerWoker로 전달되어 해당 숫자부터 1씩 줄어든 값을 다시 보낸다.
보낸 숫자를 onmessage로 받을 수 있다. 이를 leftTime이라는 변수로 할당하고 setTime(leftTime)를 통해 상태를 업데이트를 한다.
만약 leftTime이 0이라면 타이머 동작을 중단한다.
마무리
지금까지 Web Woker API를 통해 백그라운드에서 시간이 천천히 흐르는 문제를 해결했다. Web Woker API는 한 번도 다루지 않았던 기술이었기에 아직 학습할 내용이 많다. 추후에 Web Woker API가 필요한 상황이 찾아오면 더 깊이 학습을 해봐야겠다. 또한 타이머를 구현하기 위해 setInterval함수를 사용했는데 requestAnimationFrame함수를 사용하여 구현할 수 있다고 한다. 아직 해보진 않았는데 성능적으로 더 뛰어나다고 하니, 이후에 시간이 되면 리팩터링을 통해 개선해 봐야겠다.
2. 타이머 시간 폰트 사이즈
두 번째 문제는 타이머 시간의 폰트 사이즈에 대한 내용이다. 화면 viewport에 따라 폰트 사이즈의 크기를 다르게 하고 싶었다. 이는 이전 버전의 티처캔에서도 고민했던 내용이다. 그때의 해결책은 간단했다. 바로 vw를 사용하면 충분히 문제를 해결할 수 있었다. 하지만 새로운 버전의 티처캔에서 vw를 적용하기에는 까다로운 요소가 존재했다.
새롭게 생긴 왼쪽 사이드 메뉴바
새로운 버전에는 왼쪽에 사이드 메뉴바가 생겼다. 이는 고정된 크기를 가지고 있다.
때문에 vw로 폰트 사이즈를 조절한다면 사이드 메뉴바의 width를 고려해야 한다.
아직 다양한 viewport에서 살펴보지 않았지만 기본적으로 mac 16인치와 27인치 모니터에서 확인했을 때, vw로 할 경우 먼가 마음에 들지 않아 조금 더 세세하게 폰트 사이즈를 설정하고 싶은 욕심이 생겼다.
이를 해결하기 위해 타이머 시간의 폰트 사이즈를 계산하는 새로운 기준이 필요했다.
viewport가 아닌 새로운 기준
왼쪽 사이드 메뉴바가 고정되어 있기 때문에 실제로 viewport가 변경될 때마다 너비가 바뀌는 것은 왼쪽 사이드 메뉴바를 제외한 나머지 공간이다.
해당 공간의 너비를 관찰하여 너비가 바뀐다면 타이머 시간의 폰트 사이즈도 변경하면 된다.
기준을 찾았으니 해결한 과정을 단계별로 살펴보자.
Step1. 컴포넌트 너비 관찰하기
먼저 해야 할 과정은 컴포넌트의 너비가 변경될 때마다 바뀐 너비 값을 관찰하는 것이다. 계속 관찰이라는 단어를 사용하고 있는데, 그 이유는 이를 위해 사용하는 것이 바로 ResizeObserver API이기 때문이다.
다음은 ResizeObserver API를 사용하여 만든 useLength훅이다. 해당 훅은 특정 컴포넌트의 너비 혹은 높이를 관찰하여 값을 반환하는 역할을 한다.
import { useLayoutEffect, useRef, useState } from 'react';
type UseLength = {
standard: 'width' | 'height';
resized?: boolean;
};
const useLength = <T extends HTMLElement>({
standard,
resized = true,
}: UseLength): [React.RefObject<T>, number] => {
const ref = useRef<T>(null);
const [length, setLength] = useState(0);
useLayoutEffect(() => {
if (!ref.current) return;
if (resized) {
const resizeObserver = new ResizeObserver(([element]) => {
setLength(element.contentRect[standard]);
});
resizeObserver.observe(ref.current);
}
if (!resized) {
const lengthStandard =
standard === 'width' ? 'clientWidth' : 'clientHeight';
setLength(ref.current[lengthStandard]);
}
}, [resized, standard]);
return [ref, length];
};
export default useLength;
useLength훅은 다음과 같은 옵션을 받는다.
1. standard: 컴포넌트의 너비 또는 높이 중 관찰을 하고자 하는 기준을 정한다.
2. resize: 컴포넌트의 사이즈(너비 또는 높이)가 변경됨에 변경된 새로운 값을 반환할지 하지 않을지에 대한 값이다.
타이머 폰트 사이즈는 부모 컴포넌트의 너비에 영향을 받기기 때문에 standard를 width로 하였고 부모 컴포넌트의 너비가 변경될 때 마다 새로운 값이 필요하기 때문에 resize를 true(기본값)로 하였다. 아래는 useLength훅을 사용하는 Timer컴포넌트이다.
function Timer() {
const [ref, width] = useLength<HTMLDivElement>({
standard: 'width',
});
// ...
return (
<S.Layout ref={ref}>
<S.Time>{displayTime}</S.Time>
{/* */}
</S.Layout>
);
}
Step2. 타이머 시간 폰트 사이즈 계산하기
그다음으로 할 작업은 부모 컴포넌트의 너비에 대해 얼마만큼 타이머 시간의 폰트 사이즈를 설정하는지 계산하는 것이다. 단순히 부모 컴포넌트의 너비에 원하는 값만큼 나누면 된다.
function Timer() {
const [ref, width] = useLength<HTMLDivElement>({
standard: 'width',
});
const timerFontSize = `${width / 4}px`;
// ...
return (
<S.Layout ref={ref}>
<S.Time $fontSize={timerFontSize}>{displayTime}</S.Time>
{/* */}
</S.Layout>
);
}
이후, timerFontSize를 Time 스타일드 컴포넌트에 전달하여 폰트 사이즈를 설정하면 끝이다.
마무리
타이머 시간 폰트 사이즈에 대한 문제 해결은 생각보다 간단했다. 하지만 어떤 식으로 해결해야 하는지에 대한 고민은 짧지 않은 시간을 투자했다. 타이머 시간이 고정된 사이즈를 가지고 있는 것과 현재 구현한 것처럼 사이즈가 너비에 따라 변경되는 것 중 어느 것이 사용자에게 더욱 좋은지는 모르겠다. 이는 실제 사용자에게 직접 피드백을 받아봐야 되지 않을까 싶다.
'⚛️ 리액트' 카테고리의 다른 글
State를 구조화하는 다섯 가지 원칙 (0) | 2023.06.05 |
---|---|
React Router v6.4 이상에서 Router 다루기(RouterProvider, createBrowserRouter, Route) (0) | 2023.06.03 |
🧭 Paths Alias 사용하기(CRA, Storybook, Jest 설정도 함께) (0) | 2023.04.25 |