0.TL;DR
문제
- 팬사인회까지 남은 시간을 보여줘야 했기에 setInterval을 사용해 1000ms마다 1초가 줄어들도록 구현하였으나, 탭 비활성화로 인해 1초당 최대 60000ms까지 오차가 발생
- 비대면 팬사인회는 연예인 1명 당 60~120초로 진행되기에, 만약 오차가 누적되어 30초 정도가 지연된다면 사용자는 불쾌한 경험을 하게 될 것
고려한 해결 방안
- 1초마다 서버에서 시간을 가져오기
- 정확한 시간을 출력할 수 있지만, 많은 유저가 접속할 경우 서버에 부담
- 탭이 활성화 될 때, 서버에서 시간을 가져오기
- 서버의 리소스를 1번보다 적게 사용하지만, 변경 이전 시간이 잠깐 보여 사용자 경험에 악영향
- 가짜 audio로 강제로 탭의 비활성화 막기
- 클라이언트 리소스 사용 + 모바일 환경이라면 오디오 점유 이슈 발생 가능성
결론
- UX와 서버를 모두 고려해 2번 방법을 선택, Page Visibility API를 사용해 구현
- 하지만 고려했던 세 가지 방법이 모두 단점이 있었기에, Web Worker 학습 후 추가 개선
1. Why?
프로젝트 스타게이트를 진행하던 중, 타이머가 정상적으로 작동하지 않는 것을 발견했다.
기존 로직 설명
처음 접속 시 useEffect를 이용해 서버에서 데이터를 가져와 DashBoard에 띄워준다. 그리고 클라이언트에서 남은 시간을 setInterval을 사용해서 1초씩 줄이다가, 남은 시간이 30분 이하로 줄어들면 입장 버튼이 활성화되도록 구현했다. 문제는 setInterval이 정확히 시간을 변경하지 못한다는 것이다.
처음에는 그냥 서버에서 시간을 가져오는 방식으로 수정을 고려했으나, 서버의 리소스를 소모하는 방식이기도 하고 클라이언트단에서 해결 가능할 것이라는 생각이 들어 코드를 수정하는 방안으로 결정했다.
2. 근데 왜 setInterval 쓰면 안됨?
setInterval이란?
setInterval은 JavaScript의 내장 함수로, 특정 함수를 일정 시간 간격으로 반복해서 실행하게 해주는 함수다. 예를 들어
setInterval(() => {
console.log("Hello, World!");
}, 1000);
이렇게 하면 1초에 한번씩 console에 Hello, World!가 출력된다.
그래서 뭐가 문젠데?
문제는 얘가 정확한 실행시간을 보장하지 못한다는 것이다. 크게는 두 가지 문제점으로 나눌 수 있는데, 첫 번째는 자바스크립트 자체의 문제고, 두 번째는 실행환경의 문제점이다.
JavaScript의 문제 - 정확하지 않은 시간
우선, setInterval은 JavaScript의 이벤트 루프에 따라 동작한다. 그렇기에 지연 간격이 원하는 시간보다 짧을 수도, 길 수도 있다.
JavaScript의 이벤트 루프와 큐 등은 비동기 처리에서도 중요한 키워드인데, 여기 쓰면 너무 길어질 테니 자세한 설명은 mozilla의 문서를 참조바란다.
- 짧은 경우 : 위의 사진에서 보듯이 func을 실행하는 데 ‘소모되는’ 시간도 지연 간격에 포함시키기에 짧아짐
- 긴 경우 : 시간이 다 지나더라도 큐에서 다른 항목이 모두 처리된 후에 setInterval이 동작하기에 길어짐
좀 더 확실하게 알기 위해 setInterval 시간측정을 해보자. 아래의 코드로 현재 실행시간에서 이전 실행시간을 빼서 한 번 setInterval이 도는데 얼마나 걸렸는지 체크할 것이다.
const from = Date.now();
let i = 0;
setInterval(() => {
i += 1000;
console.log(Date.now() - from - i);
}, 1000);
아무런 동작을 하지 않았는데도 계속해서 다른 값이 출력되어서 찾아보다가 코딩애플의 유튜브 영상에서 그 실마리를 찾을 수 있었는데, setTiimeout은 무조건 실행 시간을 1000(대기 시간) + a(함수 실행 시간)로 한다. 하지만 setInterval은 실행 시간 보정이 들어간다고 한다.
실행환경의 문제 - 브라우저
이 무식하게 큰 숫자들은 놀랍게도 같은 코드에서 출력된 결과물이다.
"엥? 어떻게 저런 숫자가 나와요?"
스타게이트는 React로 개발된 Web 기반 서비스다. 브라우저 환경에서는 탭이 background로 넘어가버리면 CPU와 메모리 등 자원을 아끼기위해 정리를 하게 된다. 그럼 setInterval이 동작을 멈추게 되고, 그 결과 위와 같이 60007이라는 값이 출력되는 것이다.
보통 비대면 팬사인회 시간은 멤버 1명당 60초에서 120초 정도인데, 만약 누적치가 쌓여서 몇 분을 날려먹었다면? 우리는 이익은 커녕 빚만 잔뜩 떠앉게 된다.
3. How?
어떻게 해결할까?
그러면 이제 1번 문제, 값이 미세하게 변경되는 문제는 해결할 필요가 없어졌다. 브라우저의 setInterval 보정효과를 받는다면, 오차는 0.01~2초 정도일 텐데, 티켓팅 서비스도 아니고 이 정도 오차는 아무 문제도 되지 않는다.
그럼 두 번째 문제인 background로 탭이 넘어갔을 때, setInterval의 오차가 커지는 문제를 해결해야한다. 고려한 해결방안은 다음과 같다.
- 1초마다 서버에서 시간을 가져오기
- 정확한 시간을 출력할 수 있지만, 많은 유저가 접속할 경우 서버에 부담이 생길 것이다.
- background에서 다시 탭이 활성화될 때, 서버에서 시간을 가져오기
- 서버의 리소스를 조금은 사용하게 될 것이고, 시간을 가져오는 동안 변경 이전 시간이 잠깐 보여지기에 사용자 경험이 떨어질 수 있음
- 탭이 비활성화 되지 않도록 CPU를 점유할 수 있게 가짜 audio를 실행해주자
- 서버의 리소스를 최소화한다는 측면에서는 이득이지만, 클라이언트의 리소스를 지속적으로 사용할 것이다. 또한 만약 하나의 오디오만 출력되는 모바일이나 태블릿에서 접속한다면....?
위에 적은 것 말고도 각각의 장단점이 있겠지만, 1번 방안은 서버 리소스측면에서 단점이 크고 3번 방안은 너무 위험하다. 이에 2번 방안이 서버 리소스와 UX 모두 약간의 단점이 있더라도 치명적이지 않다고 판단해 해당 방안을 도입했다.
Page Visibility API
Page Visibility API, 즉 페이지 가시성 API를 통해서 코드를 수정할 것이다. 페이지 가시성 API는 두 개의 프로퍼티와 한 개의 이벤트로 구성되어 있다.
// 프로퍼티
document.hidden // 현재 페이지가 유저에게 보일 수 없는 상황이면 true
document.visibilityState // 유저에게 보일 수 있는 상황이면 'visible', 아니면 'hidden'
// 이벤트
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === 'hidden') { // or document.hidden
myVideo.stop();
} else {
myVideo.play();
}
});
상당히 간단한데, 이 중에서도 visibillityState를 사용해서, hidden으로 갔다가 visible로 돌아오면 다시 데이터를 가져오도록 코드를 수정하면 원하는대로 동작이 가능해보인다.
수정 코드
export const useFetchData = (isAdmin: boolean) => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<BoardData>({
ongoing: [],
expected: [],
finished: [],
});
useEffect(() => {
const fetchData = async () => {
const fetchedData = isAdmin
? await fetchAdminBoard()
: await fetchUserBoard();
if (fetchedData) {
setData(fetchedData);
}
setLoading(false);
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchData();
}
};
fetchData();
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return { loading, data, setData };
};
- 처음 mount가 일어날 때 useEffect가 실행되면서 fetchData 함수가 실행
- 데이터 저장 후 로딩 상태 false로 변경
- visibilitychange 이벤트 리스너가 handleVisibilityChange 함수와 함께 등록됨
- visibilitychange가 변경됨
- handleVisibilityChange 함수를 실행
- 만약 현재 상태가 visible이면 fetchData 함수 실행
- isAdmin 여부에 따라 fetchAdminBoard() 혹은 fetchUserBoard() 실행
- 컴포넌트가 unmount될 때 visibilitychange 이벤트 리스너 제거
5. 이후 추가 문제 해결
이렇게 끝인줄 알았으나, Firefox에서는 setInterval 보정이 정상적으로 작동하지 않는 이슈가 있었다. 자세한 내용은 아래 링크에서 볼 수 있다.
또한 고려한 세 가지 방법이 모두 단점이 있는 방안이였기에, Web Worker를 사용하도록 추가적으로 개선했다.
6. 래퍼런스
https://developer.mozilla.org/ko/docs/Web/API/setInterval
https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop
https://ko.javascript.info/settimeout-setinterval
https://youtu.be/oWSNOrBbOIU?si=TVkOa5iQKjfIt6RK
https://developer.mozilla.org/ko/docs/Web/API/Page_Visibility_API
https://blog.shiren.dev/2023-04-24/
'Project > 스타게이트' 카테고리의 다른 글
Web Worker로 백그라운드에서 타이머 작동시키기 (0) | 2024.03.19 |
---|---|
Firefox에서의 setInterval 문제 해결 (0) | 2024.02.07 |
Atomic Design Pattern 도전기 (0) | 2023.07.31 |