최근에 멘토링을 받고 있는데 멘토님과 공부 시간과 관련된 얘기를 하다 스톱워치 얘기가 나왔다. 최근에 내가 사용하던 스톱워치가 망가져 새로 구매해야겠다고 말했는데 멘토님이 직접 만들어보라고 하셨다.. 생각해보니 나 개발자였지! 순간 여러 아이디어가 떠올랐다. 단순하게 구현해서 나만 쓸거면 그냥 로컬로 띄워서 사용해도 되겠지만 크롬 확장프로그램으로 띄워서 사용해도 재밌겠다는 생각이 들었다. 그래서 일단 도전!
간단하게 내가 생각한 기능 리스트를 작성해봤다
- 스톱워치 페이지(기본)
- 재생 버튼 - 누르면 타이머가 재생된다. 타이머가 동작 할 때는 disabled버튼으로 표시한다
- 정지 버튼 - 누르면 타이머가 정지된다. 타이머가 동작 하지 않을 때는 disabled버튼으로 표시한다
- 리셋 버튼 - 누르면 타이머가 리셋된다. 리셋되면 타이머는 동작을 멈춘다
- 기록 버튼
- 누르는 순간 시간을 기록할 수 있는 팝업이 뜬다.
- 해당 팝업에는 어떤 내용인지 텍스트로 작성할 수 있다.
- 작성 후에 버튼을 누르면 리스트에 시간과 내용이 함께 저장된다.
- 타이머가 동작을 멈춘다.
- 리스트 - 시간과 내용이 저장된 리스트
- 삭제 기능
- 캘린더 페이지
- 캘린더
- 날짜를 선택할 수 있다
- 리스트
- 선택된 날짜에 기록한 리스트가 뜬다
- 캘린더
서버를 사용하지 않고 사용자 개인 스토리지에 저장할 수 있는 방법을 찾다가 chorme.storage Api에서 제공하는 기능이 있는 것을 알았다. 문서를 잘 정독해서 기능 구현할 때 사용한다면 될 것 같다.
기능 구현하기
setInterval의 시간 정확성 문제
const StopwatchPage = () => {
const [time, setTime] = useState<number>(0);
const [isRunning, setIsRunning] = useState<boolean>(false);
const handleStart = () => {
setIsRunning(true);
};
const handleStop = () => {
setIsRunning(false);
};
const handleReset = () => {
setTime(0);
};
useEffect(() => {
let interval: NodeJS.Timeout;
if (isRunning) {
interval = setInterval(() => setTime((prev) => prev + 1), 1000);
}
return () => clearInterval(interval);
}, [isRunning]);
...
}
처음에 아무 생각없이 setInterval로 구현하다가 면접 때 들었던 말이 생각났다. setInterval은 원하는 delay 시간을 설정하더라도 항상 그 시간 간격으로 실행되는 것을 보장할 수 없다는 것이다. 이 문제는 공식문서에도 잘 설명되어 있다. setTimeout이나 setInterval과 같은 WebAPI는 비동기 방식으로 실행되기 때문에 콜 스택에 다른 작업이 있을 경우 그 작업이 끝날 때까지 실행되지 않는다. 따라서 사용자가 지정한 delay시간이 정확히 지켜지지 않을 수 있다.
예를 들어 다음 코드를 보자.
function foo() {
console.log("foo 호출");
}
setTimeout(foo, 0);
console.log("setTimeout 완료");
// setTimeout 완료
// foo 호출
이 코드는 setTimeout을 통해 foo함수를 바로 실행하도록 설정했기 때문에 'foo 호출'이 먼저 발생할 것 같지만, 실제로는 'setTimeout 완료'가 먼저 출력된다. 이는 setTimeout이 비동기적으로 처리되기 때문에 다른 작업이 완료된 후에야 실행된다는 것을 보여준다. 즉, 다른작업이 끝날 때까지 대기하는 시간이 포함되어 정확한 delay시간이 보장되지 않는다.
유명한 자바스크립트 런타임 환경 이미지를 보며 동작 원리를 살펴보면 다음과 같다
- JS에서 setInterval 함수가 실행되면, 이 함수는 WebAPI에 전달된다.
- WebAPI는 사용자가 지정한 delay시간을 기다린 후, 해당 작업을 콜백 큐에 전달한다.
- 이후 이벤트루프가 콜백 큐에 있는 작업을 콜스택으로 전달하게 된다.
- 이 때, 콜 스택에 다른 작업이 있으면 setInterval함수는 콜백 큐에서 대기해야 한다.
- 따라서 추가적인 대기 시간이 발생하면서 지정한 delay시간보다 지연되는 것이다.
그럼 타이머를 어떻게 동작하지? Date와 setTimeout으로 해결하자!
setInterval을 사용하지 않고 스톱워치를 만들 수 있는데 바로 Date를 사용하는 방법이다. Date.now()를 활용해 시작 시간과 끝나는 시간을 추출해 이 시간차를 이용하면 꽤 정확한 스톱워치를 구현할 수 있다. 여기서 setTimeout은 시간차를 보정해주는 역할을 한다. 즉, 1초 간격으로 동작하는 타이머가 만약 0.2초 늦게 실행되었다면 다음 타이머는 setTimeout(콜백함수, 1초 - 0.2초) 이런식으로 계산하도록 만드는 것이다.
const INTERVAL = 1000;
const [time, setTime] = useState<number>(0);
const [startTime, setStartTime] = useState<number>(0);
const [isRunning, setIsRunning] = useState<boolean>(false);
useEffect(() => {
// 1. 타이머를 생성한다
let timer: ReturnType<typeof setTimeout>;
// 3. 업데이트 함수 실행
const updateTime = () => {
if (isRunning) {
const now = Date.now();
const diff = now - startTime;
// 4. 현재 시간과 시작 시간의 차이를 화면에 보여줄 시간으로 저장한다.
setTime(diff);
// 5. 중요!! 다음 타이머의 시작을 1초 뒤가 아닌, 오차 범위를 구해서 실행되도록 한다
const nextTick = INTERVAL - (diff % INTERVAL);
timer = setTimeout(updateTime, nextTick);
}
};
// 2. 타이머가 1초 뒤에 updateTime함수를 호출한다
if (isRunning) {
timer = setTimeout(updateTime, INTERVAL);
}
return () => clearTimeout(timer);
}, [startTime, isRunning]);
const handleStart = () => {
if (!isRunning) {
// 0. 시작버튼을 누르면 시작시간이 저장되고, 타이머가 실행된다
setStartTime(Date.now() - time);
setIsRunning(true);
}
};
const handleStop = () => {
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setTime(0);
};
const handleRecord = () => {
setIsOpen(true);
handleStop();
};
이렇게 완성하고 휴대폰에 스톱워치와 동시에 작동시켜보니 5분이 지나도 꽤 정확하게 맞았다. 이전에는 2분만 지나도 화면과 휴대폰의 스톱워치가 조금씩 차이가 났었다.
Chrome extentions에 등록하기
기능을 다 구현하진 않았지만 chrome extentions에 등록이 가능한지 너무 궁금해서 일단 등록부터 해봤다. 어려울줄 알았는데 생각보다 공식 문서를 보고 하나씩 따라해보니 쉬웠다.
먼저 manifest.json 파일이 필요하니 해당 파일을 public폴더에 생성하고 아래와 같이 코드를 작성했다.
{
"manifest_version": 3,
"name": "stopwatch-extensions",
"description": "Base Stopwatch Extension",
"version": "1.0",
"action": {
"default_popup": "index.html",
"default_icon": "stopwatch.png"
}
}
manifest파일에 들어가는 옵션은 문서에서 확인해서 참고해 넣었다. 아이콘도 넣고 싶어서 public폴더에 stopwatch.png를 넣고, default_icon으로 설정했다. 리액트에서 빌드된 파일은 index.html파일로 추출되므로 default_popup은 index.html로 수정하면 된다.
그 다음 chrome://extensions/에 접속해 오른쪽 상단에 개발자 모드를 키고 "압축된 확장 프로그램을 로드합니다" 버튼을 눌러 빌드된 dist파일을 선택하면 된다. 이렇게 하면 전체 확장 프로그램 목록들 사이에 내가 올린 파일이 잘 올라가 있는 것을 확인할 수 있다! 만약 수정이 필요하다면, 수정 후에 다시 빌드 해주고 토글 옆에 있는 새로고침 버튼만 눌러주면 간편하게 업데이트도 된다.
이제 제대로 동작하는지 확인해보자
타이머도 잘 작동되고 메모도 가능하고, 모드도 잘 바뀐다! 다만 아직까지 오류가 많고 원하는 기능들을 아직 다 구현하지 못했다. 업데이트도 build만 새로 하면 되니 얼른 마저 구현하고, 가능하다면 다른 사용자가 사용할 수 있도록 웹 스토어에 게시까지 해볼 계획이다
참고로 크롬 익스텐션 만들 때 프로그램 너비와 높이에 대한 언급이 없어서 배포하면 디폴트 사이즈가 정해져 있는줄 알았다. 그래서 너비와 높이를 따로 지정하지 않고 반응형으로 만들어 배포해보니 아래 이미지처럼 사이즈가 이상하게 나왔다. 검색해보니 body에 사이즈를 지정해줘야하는 것 같아서 App.css에 아래와 같이 body태그에 고정으로 사이즈를 넣은 후에야 정상적으로 나왔다
body {
width: 400px;
height: 600px;
}
#root {
max-width: 1280px;
height: 100%;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
chrome extensions에서 react-router-dom사용시 문제
앱을 크롬 익스텐션으로 넣고 보니 router가 제대로 동작하지 않았다. 하단 내비게이션바에 스톱워치나 캘린더 버튼을 누르면 제대로 이동했지만 처음 뜨는 페이지는 디폴트 페이지(/)인 스톱워치가 아닌, 존재하지 않는 페이지로 지정한 NoMatchPage가 뜨는 것이었다. 검색해보니 크롬 익스텐션의 주소는 일반 브라우저가 아니기 때문에 그들만의 주소 체계가 있었다.
예를 들면 아래와 같이 크롬 익스텐션 아이디를 통해 index.html을 불러오고 있었다. 내가 사용한 BrowserRouter는 웹 history API를 기반으로 동작하기 때문에 웹이 아닌 크롬 익스텐션에서는 제대로 동작하지 않는 것이었다.
chrome-extension://ilifddhdhamdafgcbfbnjllaaogk/index.html
따라서 나는 BrowserRouter가 아닌 MemoryRouter를 사용해 간단하게 해결했다. history API를 사용하지 않는 HashRouter를 사용해도 됐지만, 나는 url이 필요하거나 중요한 페이지들은 아니기 때문에 메모리 상에서 라우터를 관리하는 MemoryRouter를 선택했다. 코드를 아래와 같이 적용해보니 스톱워치 페이지가 바로 로드되었다.
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { MemoryRouter as Router } from "react-router-dom";
createRoot(document.getElementById("root")!).render(
<Router>
<App />
</Router>
);
'개발 > 프론트엔드' 카테고리의 다른 글
[NextJS] NextJS와 TailwindCSS로 폰트 설정하기 (0) | 2024.09.18 |
---|---|
[Typescript] 타입스크립트는 왜 쓰는걸까? (4) | 2024.09.05 |
[NextJS]App Router - 앱 최적화와 캐싱 (0) | 2024.08.01 |
[NextJS]App Router - 라우팅(Routing) 종류 (0) | 2024.07.05 |
[NextJS] 14버전 앱 라우터에 대해 알아보자 (0) | 2024.06.25 |