2024년 새해를 맞이하기 위해 카운트다운 웹페이지 Last Pang을 만들었다.
나는 그 중 서버와 시간을 동기화하는 파트를 맡았다.
시간 정확도가 매우 중요한 페이지였기 때문에 굉장히 힘든 과정이 있었는데 이것을 구현하기 위해 노력한 것들을 나열해놓은 글이다.
우리 서비스에서 서버로부터 어떻게 시간을 받아오는지 알아보자.
우선 선택 기준으로 두가지 주요 요소가 있다.
첫번째로는 단방향 통신과 두번째로는 네트워크 리소스 및 서버 부하 최소화이다.
보통 서버에서 이벤트를 전송하기 위해서는 (Long) Polling, SSE, WebSocket 중 하나를 선택한다.
서버 시간을 받아오기 위해서는 서버
에서 클라이언트
로 텍스트 스트림을 단방향으로만 전송하면 되기 때문에 WebSocket은 탈락.
그리고 (Long) Polling 방식은 클라이언트
가 서버
에 주기적인 간격으로 요청을 보내 데이터를 확인하는 방식이다.
이로 인해, 사용자가 몰릴 경우 많은 네트워크 트래픽과 서버 부하를 발생시킬 수 있기 때문에 (Long) Polling도 탈락.
결론적으로 모든 조건에 부합한 SSE 방식으로 서버로부터 시간을 받아오게 되었다.
SSE란?
- Server-Sent Event
- 실시간 데이터 전송 방법 중 하나이다.
- 주요한 특징 중 하나는 단방향 통신이기 때문에
서버
에서클라이언트
로만 데이터 전송이 가능하다는 것.- 웹소켓과는 다르게 독립적인 프로토콜을 사용하는 것이 아니라 HTTP 프로토콜 위에서 작동한다.
우선 한국 시간 기준으로 카운트다운을 하기 때문에 전세계 어디서든 한국 시간으로 보이게 설정을 해줘야한다.
// 한국시간으로 krCurr 변수를 설정 (UTC+9)
const krCurr = moment().tz('Asia/Seoul');
// krCurr 변수의 값을 currentTime 상태의 초기값으로 설정
const [currentTime, setCurrentTime] = useState(krCurr);
moment.js 공식문서
-> moment.js는 JavaScript
에서 날짜와 시간을 다루기 위한 라이브러리이다.
UTC 시간이란?
- UTC 시간은 협정 세계시로, 전 세계의 시간 측정의 기준이 된다.
- 한국의 서울 시간대는 UTC+9
SSE(Server-Sent Events)를 구현하기 위해서는 서버
에서는 SSE 프로토콜을 생성하고 클라이언트
에서는 EventSource 객체를 생성해야 한다.
EventSource 인스턴스를 생성할 때, SSE 데이터 스트림을 제공하는 서버
의 URL(엔드포인트)을 인자로 제공한다.
인스턴스를 생성하고 한번 연결을 맺은 후엔 서버
는 설정한 시간 간격으로 또는 특정 이벤트 발생 시 계속해서 클라이언트
에게 데이터를 전송한다.
이 과정은 클라이언트가 명시적으로 연결을 종료하거나 네트워크 문제 등으로 연결이 끊어질 때까지 계속된다.
// EventSource 객체를 위한 전역 변수
let eventSource = null;
eventSource = new EventSource('엔드포인트');
eventSource.onmessage = (e) => {
// 서버로부터 받은 데이터를 처리
};
Time Gap을 구하는 이유는 클라이언트 시간(브라우저에서 실행중인 사용자의 로컬 시간)과 서버 시간의 차이를 계산하기 위해서이다.
이 차이를 구하면 클라이언트 측에서 시간을 보정하여 서버 시간과 동기화할 수 있다.
서버에서 시간을 가져오는 이유는 사용자의 시간대가 다르거나 각자의 로컬 시간 설정이 다르기 때문이다.
Time Gap을 계산해서 얻은 값으로 클라이언트
측에서 현재 시간을 조정해서, 모든 사용자가 서버
기준 시간을 기반으로 동일한 시간 값을 얻을 수 있도록함이다.
eventSource.onmessage = (e) => {
// 서버로 부터 온 JSON 타입의 unixTime을 moment 객체로 변환
const serverTime = moment(JSON.parse(e.data).unixTime);
// 사용자의 로컬 시간(clientTime)을 Date 객체의 인스턴스로 생성
const clientTime = new Date();
// 서버로 부터 온 유닉스 시간에서 클라이언트의 유닉스 시간을 빼서 서버와의 시간차를 구함
const timeGap = serverTime - clientTime.getTime();
// serverTime에 timeGap을 더해 한국 시간대로 보정한 moment객체로 currentTime을 설정
setCurrentTime(moment(serverTime + timeGap).tz('Asia/Seoul'));
}
UNIX 시간이란?
- UTC 기준 1970년 1월 1일 자정에서부터 현재까지 몇 초가 지났는지를 정수 형태로 표시한 값
지금까지의 로직은 SSE로 서버 시간을 받아올때만 시간이 업데이트되기 때문에 서버에서 설정한 주기에만 시간이 새로 업데이트된다.
그래서 시간이 업데이트되는 주기 사이에 시간을 1초마다 업데이트해주는 로직을 만들어야했다.
Javascript에서 시간 지연 관련 작업을 수행할 때 쓰는 함수는 대표적으로 setInterval과 setTimeout이 있다.
setInterval 함수는 지정된 시간 간격마다 함수나 지정된 코드를 반복해서 실행해준다.
setTimeout 함수는 지정된 시간이 지난 후에 함수나 지정된 코드를 단 한 번 실행해준다.
정리하자면, 알림을 표시한 후 자동으로 사라지게 하는 것과 같은 단발성 작업에는 setTimeout 함수가 유용하고 실시간으로 시계를 업데이트하거나, 주기적으로 서버에서 데이터를 가져오는 반복 작업같은 경우엔 setInterval 함수가 유용하다.
결론적으로, 서버에서 시간을 받아오는 주기 사이에 1초마다 시간을 업데이트 해줘야하는 서비스 특성 상 setInterval 함수가 적합했다.
eventSource.onmessage = (e) => {
// 기존에 남아있던 intervalTime이 있다면 삭제
if (intervalTime) {
clearInterval(intervalTime);
}
intervalTime = setInterval(() => {
setCurrentTime((prevTime) => {
// prevTime을 밀리초 단위로 변환
const prevTimeMillis = prevTime.valueOf();
// prevTimeMillis에 1초(1000 밀리초) 추가
const newTimeMillis = prevTimeMillis + 1000;
// 1초를 더한 newTimeMillis를 한국 시간대로 보정 후 moment 객체로 변환
return moment(newTimeMillis).tz('Asia/Seoul');
});
}, 1000); // 1초마다 실행
};
이렇게 되면 SSE로 서버에서 정한 주기마다 서버시간으로 보정이 되고 다음 SSE 서버 시간을 받아오기 전까진 1초마다 setInterval 함수로 클라이언트 측에서 한국 시간대로 시간이 흐르게 만들 수 있다.
우선 로직의 순서를 알아보자.
const now = currentTime;
const hours = now.hours();
const minutes = now.minutes();
const seconds = now.seconds();
// 숫자가 한자리 수일때 앞에 0을 붙여줌
const padWithZero = (number) => {
return number < 10 ? `0${number}` : number;
};
setTimeDifference(
`${padWithZero(hours)} : ${padWithZero(minutes)} : ${padWithZero(
seconds,
)}`,
);
const newYear = moment('2024-1-1 00:00:00').tz('Asia/Seoul');
const diff = newYear - now;
// D-DAY 시간
const countDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const countHours = Math.floor(
(diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const countMinutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const countSeconds = Math.floor((diff % (1000 * 60)) / 1000);
if (
countDays == 0 &&
countHours === 0 &&
countMinutes === 0 &&
countSeconds <= 10
) {
setStartCountDown(true);
setViewMessageModal(true);
}
// 0초가 되면 '/' 페이지로 이동
if (
countDays == 0 &&
(countHours == 0) & (countMinutes == 0) & (countSeconds == 0)
) {
navigate('/fire');
}
이제 위의 모든 로직을 합치면 아래와 같은 코드가 된다.
// 숫자가 한자리 수일때 앞에 0을 붙여줌
const padWithZero = (number) => {
return number < 10 ? `0${number}` : number;
};
const calculateTimeDifference = () => {
const now = currentTime;
const newYear = moment('2024-1-1 00:00:00').tz('Asia/Seoul');
const diff = newYear - now;
// D-DAY 시간
const countDays = Math.floor(diff / (1000 * 60 * 60 * 24));
const countHours = Math.floor(
(diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const countMinutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const countSeconds = Math.floor((diff % (1000 * 60)) / 1000);
// 현재 시간
const hours = now.hours();
const minutes = now.minutes();
const seconds = now.seconds();
setTimeDifference(
`${padWithZero(hours)} : ${padWithZero(minutes)} : ${padWithZero(
seconds,
)}`,
);
if (
countDays == 0 &&
countHours === 0 &&
countMinutes === 0 &&
countSeconds <= 10
) {
setStartCountDown(true);
setViewMessageModal(true);
}
// 0초가 되면 '/' 페이지로 이동
if (
countDays == 0 &&
(countHours == 0) & (countMinutes == 0) & (countSeconds == 0)
) {
navigate('/fire');
}
};
useEffect(() => {
if (currentTime) {
console.log(`현재 시간: ${currentTime}`);
calculateTimeDifference();
}
}, [currentTime]);
Chrome
과 같은 최근 웹 브라우저들은 페이지가 Inactive 상태일 때(최소화, 탭 비활성화 등) 불필요한 리소스 소모 방지나 전력 절약 등을 위해 특정 기능들을 비활성화하거나 동작의 우선순위를 낮추도록 되어있다.
그래서 기존에 setInterval 함수의 주기가 1초로 되어있지만 점점 느려지는 문제가 발생하였다.
이를 해결하기 위해 다음과 같은 코드를 작성하였다.
useEffect(() => {
fetchServerTime();
// 매초 시간 업데이트
intervalTime = setInterval(() => {
setCurrentTime((prevTime) => {
// prevTime을 밀리초 단위로 변환
const prevTimeMillis = prevTime.valueOf();
// 1초 (1000 밀리초)와 timeGap을 더함
const newTimeMillis = prevTimeMillis + 1000;
// moment를 사용하여 한국 시간대의 Date 객체로 변환
return moment(newTimeMillis).tz('Asia/Seoul');
});
}, 1000);
// 탭 활성화 감지
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 탭이 활성화되면 서버 시간을 다시 가져옴
fetchServerTime();
} else if (document.visibilityState === 'hidden') {
// 탭이 비활성화되면 EventSource 연결을 닫음
if (eventSource) {
eventSource.close();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// 정리
return () => {
if (intervalTime) {
clearInterval(intervalTime);
}
if (eventSource) {
eventSource.close(); // 컴포넌트 정리 시 EventSource 인스턴스 닫기
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
document.visibilityState
속성을 사용하여 탭의 현재 상태를 확인한다.
이 속성은 탭이 활성화(visible) 상태인지, 비활성화(hidden) 상태인지를 나타낸다.
탭이 활성화 document.visibilityState === 'hidden'
되면, 열려있는 EventSource 연결이 있는지 확인한다.
만약 연결이 있다면, eventSource.close()
메소드를 호출해 해당 연결을 닫는다.
document.addEventListener('visibilitychange', handleVisibilityChange)
호출을 통해 탭의 활성화 상태 변화를 감지하기 위한 이벤트 리스너를 등록한다.
handleVisibilityChange
함수가 이 이벤트에 대한 콜백 함수로 사용된다.
컴포넌트가 언마운트될 때, document.removeEventListener('visibilitychange', handleVisibilityChange)
호출을 통해 이전에 등록했던 이벤트 리스너를 해제한다.
이는 불필요한 메모리 사용을 방지하고, 컴포넌트가 사라진 후에도 이벤트 리스너가 활성화 상태로 남아있는 것을 방지한다.