회사에서 어드민 페이지 프론트 개발을 담당하게 된 후, 기존 개발에 이슈가 상당히 많고 리팩토링 할 것도 많다고 항상 느끼고 있었습니다... 

이상하게 동작하는 것들도 많고, 왜 코드가 이렇게 짜져 있을까...? 하는 당황,곤혹,의문의 연속...

네...? 이 상태에서 또 신규 기능 개발을 하라고요..?

 

에라 모르겠다, 다 뒤집어 엎어보자! 라고 하고 싶지만, 제가 짠 코드가 아니기에 섣불리 손도 댈 수 없는 상황.

그렇다고 손놓고 있을 수는 없으니 하나하나씩 차근히 수정해보기로 다짐합니다...

 

그 중 로그인 만료까지 남은 시간을 렌더링 해주는 타이머 기능을 이번에 수정했습니다.

이에 대해서 글로 끄적끄적 기록을 남겨봅니다.

 

 

발견한 문제점

어드민 페이지에서 유저가 로그인을 하면 로그인 유효까지 남은 시간을 헤더에서 렌더링해서 보여줍니다.

은행 사이트에서 흔히들 많이 보이는 기능이죠.

요론 느낌입니다.

제가 개발하고 있는 어드민 페이지에도 로그아웃까지 남은 시간이 렌더링 되고, 연장버튼 클릭 시 유효 시간이 60분으로 다시 set 됩니다.

타이머를 렌더링 하는 코드는 아래와 같이 구현되어 있었습니다.

//Timer.js

const Timer = ({ mm, ss, setIsFinish, isRestart, setIsRestart, isPause }) => {
  const [minutes, setMinutes] = useState(parseInt(mm));
  const [seconds, setSeconds] = useState(parseInt(ss));

  useEffect(() => {
    if (isRestart) {
      setMinutes(parseInt(mm));
      setSeconds(parseInt(ss));
      setIsRestart(false);
      setIsFinish(false);
    }
  }, [setIsFinish, isRestart, setIsRestart, mm, ss]);

  useEffect(() => {
    if (isPause) {
      return;
    }
    const countdown = setInterval(() => {
      if (parseInt(seconds) > 0) {
        setSeconds(parseInt(seconds) - 1);
      }
      if (parseInt(seconds) === 0) {
        if (parseInt(minutes) === 0) {
          clearInterval(countdown);
          setIsFinish(true);
        } else {
          setMinutes(parseInt(minutes) - 1);
          setSeconds(59);
        }
      }
    }, 1000);
    return () => clearInterval(countdown);
  }, [minutes, seconds, setIsFinish, isPause]);

  return (
    <TimerText id={'timer-text'}>
      {t('leftTime')} {minutes} : {seconds < 10 ? `0${seconds}` : seconds}
    </TimerText>
  );
};

 

Timer.js 컴포넌트가 'mm' props를 상위 컴포넌트(네비게이션 헤더바)에서 '60' 고정 값을 받고, setInterval로 1초씩 감소시키면서 렌더링 되는 구조입니다.

여기서 두가지 문제가 생깁니다.

 

새로고침하면, 'mm' props가 다시 '60'으로 초기화되죠. 그런데 실제 서버의 데이터에서는 사용자의 로그인 유효 시간이 초기화되지 않습니다.

사용자가 '연장' 버튼을 누른게 아니니까요. 

그래서 이걸 맞추기 위해 기존 코드에서는 '연장' 버튼을 클릭했을 때 호출하는 api를 새로고침할 때 호출하더라고요...? 🤔

서버와의 싱크를 맞추기 위해서 새로고침 때 api를 호출한다는 것은, 해당 기능의 본질적인 기획과 다르다고 생각해 수정이 필요하다고 생각했습니다.

 

두번째 문제는 setInterval이 호출되고, 브라우저 내에서 다른 탭으로 이동 후 얼마간의 시간이 지난 후, 다시 어드민 페이지 탭으로 돌아왔을 때 타이머가 실제 경과되었어야 할 시간보다 뒤쳐진다는 것이였습니다. (콘솔에 찍어보면 정상작동 하는데 말이죠)

실제 남은 시간은 '27:54'여야 하는데 '28:01'로 렌더링 되고 있습니다.

 

문제 원인

위 두가지 문제의 원인을 파악해보면 다음과 같습니다.

  • 새로고침 시, 로그인 연장 API를 호출할 수 밖에 없는 첫번째 문제
    → 로그인 만료 시간을 '60'분 고정 값을 프론트에서 설정하여 새로고침하면 초기화 되는 것이 원인!

  • 페이지를 보여주는 브라우저 탭이 비활성화 되고, 다시 활성화 되었을 때 타이머가 뒤쳐지는 문제
    → setInterval API가 타이머 정확성을 보장하지 못하는 것이 원인!

첫 번째 문제의 원인은 파악하기 쉬웠지만, 두 번째 문제의 원인은 setInterval에 대한 이해가 필요했습니다.

setInterval 동작에 대체 무슨 문제가 있을까요?

 

setInterval은 Web API로 전달한 콜백 함수를 일정 간격(밀리초)마다 반복 실행하는 기능입니다.

그런데 이 setInterval을 사용할 때, 주의해야할 점이 몇몇 있습니다.

 

1. delay 시간(간격)에 정확하게 맞춰 작동하지 않는 문제

 

setInterval과 같은 비동기 Web API는 브라우저의 이벤트 루프에 의해 관리됩니다.

이벤트 루프의 동작 과정

프론트엔드 개발자라면, 회사 면접 질문 대비로 한번쯤은 이벤트 루프에 대해 들여다 봤을텐데요.

이벤트 루프에 대해 간단하게 설명하자면, 브라우저 환경에서 우리가 작성한 코드가 Heap에 메모리로 할당이 되고, Call Stack에 쌓이며 실행이 됩니다.

이 때, 이벤트 루프가 setTimeout이나 setInterval과 같은 비동기 동작 함수들은 Web API에게 처리해줘~ 하고 할당합니다.

Call Stack에 쌓인 코드들이 실행되는 동안, Web API는 백그라운드에서 처리된 비동기 함수의 결과를 콜백 함수 형태로 Callback Queue에 전달합니다. 

그리고 이벤트 루프는 Call stack과 Callback Queue를 계속 주시하면서, Call stack에 처리할 함수들이 남아 있지 않다면, Call stack에 비동기 함수들을 집어넣고, Call stack에서 비동기 콜백 함수를 실행시킵니다.

 

그런데, 이벤트 루프가 너무나 바쁘다면...?

여기서 setInteval의 반복 간격의 정확성이 무너집니다.

 

Web API가 특정 조건에 따른 (3초 후 실행)을 결과를 Callback Queue에 전달하고, Call stack이 비어 있을 때 이 콜백함수가 Call stack으로 옮겨져서 실행된다고 했었죠.

그런데 Call stack이 비어지지 않아서 setInterval의 콜백 함수가 Callback Queue에 머물러 있는 시간이 길어지면 어떻게 될까요?

나는 3초후에 실행하고 싶은데 지연이 되어서 3.5초후 또는 10초후에 실행될 수도 있겠죠.

 

마치 아래 코드처럼요.

console.log('Script start');

// 3초 후에 실행
setInterval(() => {
    console.log('setInterval callback');
}, 3000);

// 콜 스택을 오래 차지하는 작업
for (let i = 1; i <= 300000; i++) {
    // 뭔가 시간이 엄청 걸리는 무거운 작업...
}

console.log('Script end');

 

 

위 코드는 실행 순서는 다음과 같습니다.

  1. 콘솔에 'Script start' 출력
  2.  setInterval이 이벤트루프에 끌려감..
  3. 뭔가 엄청 헤비한 for문 작업 실행
  4. for문 작업이 끝난 후 'Script end' 출력
  5. setInterval의 콜백 'setInterval callback' 출력

 

for문 작업이 10초이상 걸린다면, 사실상 setInterval의 콜백 함수는 스크립트가 불러와지고 나서 3초 후에 실행되는게 아니라 10초가 훨씬 지난 후에야 실행 되겠죠.

 

정말 무엇하나 쉬운게 없습니다... 사실상 제가 해결 하려고 하는 문제의 근본 원인은 따로 있습니다.

 

 

2. 브라우저 탭 비활성 시, 설정된 delay(지연시간)보다 지연되어서 실행

 

현재 setInterval이 실행 중인 브라우저 탭에서 벗어나 다른 탭으로 이동하면, setInterval이 실행중인 페이지의 탭은 비활성화 됩니다.

그리고 브라우저는 CPU, 배터리, 메모리와 같은 리소스 관리를 최적화 하고 부하를 줄이기 위해 setInterval 타이머의 실행 빈도를 줄여 동작을 제한합니다.

 

브라우저마다 제한 방식은 조금씩 상이하지만 FireFox와 Chrome은 최소 지연을 1초 이상으로 강제합니다.

즉, setInterval의 콜백이 0.5초 마다 반복 실행 되어야 할 때, 탭이 비활성화 되어 있으면 지연시간이 최소 1초 이상이 걸릴 수 있다는 말이죠.

 

이에 대한 설명은 MDN 문서에서도 확인할 수 있습니다.

https://developer.mozilla.org/ko/docs/Web/API/setTimeout

 

setTimeout() 전역 함수 - Web API | MDN

전역 setTimeout() 메서드는 만료된 후 함수나 지정한 코드 조각을 한 번 실행하는 타이머를 설정합니다.

developer.mozilla.org

 

이러한 몇몇 이슈들 때문에, setTimeout이나 setInterval을 사용 할 때, 우리가 설정한 지연 시간만큼 정확히 동작하리란 기대는 하지 않는게 좋습니다. 😑

 

 

 

문제 해결 

근본적인 문제 원인을 찾았으니, 해결 방법만 도출하면 됩니다.

 

첫번째 문제 해결은 간단했습니다.

프론트단에서 로그인 유효 시간을 '60'분으로 설정하지 않고, 서버에서 받아온 만료 시간을 받아와서 사용하면 되는겁니다.

저는 서버에서 받은 JWT를 디코드하여 로그인 만료 시점 값을 가져왔습니다. (왜 굳이 jwt를 디코드했냐면... 서버 개발자가 그렇게 쓰래요.)

이 값은 10분, 60분, 240분처럼 로그인 만료 '기간'이 아닌 만료되는 '시점'의 값입니다.

제가 로그인을 2024년 5월 1일 오후 1시에 했다면, 이 값은 2024년 5월 1일 오후 2시가 되는거죠.

이 값을 어느 곳에 저장해놓고 사용한다면, 새로고침 할때마다 '60'분으로 초기화 되는 문제를 해결 할 수 있습니다.

 

두번째 문제는 setInterval의 이슈로 이와 관련하여 대표적으로 알려진 해결법인 webWorker나 requestAnimationframe과 같은 Web API를 써야하나 고민했습니다. 

 

그런데 생각해보니, 그럴 필요가 없었습니다.

왜냐하면 60분이라는 '기간'을 가져와서 1초씩 빼는 타이머가 아니라, 1초마다 만료 시점에서 현재 시점의 시간을 뺀 값을 보여주는 타이머로 구현하면 해결되기 때문입니다.

 

무슨 말이냐고요...?

 

위의 코드를 다시 가져와서 예를 들어보겠습니다.

 

<60분에서 1초씩 빼는 setInterval>

console.log('Script start');


// 3초 후에 실행
setInterval(() => {
    const 만료까지_남은시간 = 3600 - 1
}, 1000);

// 콜 스택을 오래 차지하는 작업
for (let i = 1; i <= 300000; i++) {
    // 뭔가 시간이 엄청 걸리는 무거운 작업...
}

console.log('Script end');

 

이렇게 되면, setInterval의 콜백함수는 항상 1초마다 실행되는 것이 보장되지 않습니다. 현재 브라우저 탭이 비활성화 되었을 때도 1초마다 실행되는 것이 보장되질 않으니 타이머가 정확하지 않겠죠.

 

<만료시점에서 현재시점 값을 빼는 setInterval>

 

console.log('Script start');

// 3초 후에 실행
setInterval(() => {
   const 만료까지_남은시간 = 만료시점 - 현재시점; 
}, 1000);

// 콜 스택을 오래 차지하는 작업
for (let i = 1; i <= 300000; i++) {
    // 뭔가 시간이 엄청 걸리는 무거운 작업...
}

console.log('Script end');

 

위 코드도 현재 브라우저 탭이 비활성화 되어 있을 때, setInterval의 콜백이 정확히 1초마다 호출되리란 보장은 없습니다.

대신 언제 호출되어도 현재시점을 반영해서 만료시점까지 남은 시간을 계산해주기 때문에 콜백함수의 호출시점은 1초 후 또는 10초 후여도 그 당시의 남은 시간을 보여주니, 타이머는 정상으로 보일겁니다.

 

그래서 위에서 보았던 기존의 리액트 코드를 아래와 같이 수정했습니다.

 

const AuthTimer = ({ updateTime }) => {
  const endTimeRef = useRef(null);
  const [minute, setMinute] = useState(null);
  const [second, setSecond] = useState(null);

  useEffect(() => {
    const token = sessionStorage.getItem('accessToken');
    const decode = jwtDecode(token);
    const expireTime = decode.exp;
    endTimeRef.current = expireTime;
  }, [updateTime]);

  useEffect(() => {
    const authExpireInterval = setInterval(() => {
      const currentTime = Math.floor(Date.now() / 1000);
      const timeRemain = endTimeRef.current - currentTime;
      const minuteRemaining = Math.floor(timeRemain / 60);
      const secondRemaining = timeRemain % 60;
      setMinute(minuteRemaining);
      setSecond(secondRemaining);

      if (timeRemain <= 0) {
        clearInterval(authExpireInterval);
      }
    }, 1000);

    return () => clearInterval(authExpireInterval);
  }, []);

 

AuthTimer.js 컴포넌트를 생성하고, updateTime을 props로 부모컴포넌트로부터 전달받습니다.

이 때, updateTime은 사용자가 로그인하였거나, 로그인 시간 연장 버튼을 눌렀을 때 api 호출 응답이 성공일 때 갱신됩니다.

그리고 사용자의 jwt를 디코딩하여 안에 포함된 '로그인 만료 시점 값'을 가져와 endTimeRef에 할당합니다.

왜 useState가 아니라 useRef에 할당해야 하냐면.. setInterval 콜백함수 내에서 클로저가 생성되기 때문에 값을 유지할 수 있는 useRef 훅을 사용했습니다. 

 

이에 대한 내용을 리액트 공식 도큐먼트 페이지에서 찾아볼 수 있습니다.

https://ko.legacy.reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

 

Hook 자주 묻는 질문 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

 

그리고 만료시점에서 현재시점을 빼서 남은 시간을 분과 초로 바꾸고 렌더링 될 수 있도록 setMinute, setSecond를 해줍니다.

 

그리고 한가지 더 중요한 점!

남은시간(timeRemain)이 0이 되면 타이머가 멈추도록 clearInterval을 호출해야 하잖아요?

만약 코드를 아래와 같이 작성하면.. 시간이 음수로 변하는 것을 보실 수 있습니다..

   if (timeRemain === 0) {
        clearInterval(authExpireInterval);
     }

 

계속 언급했듯이, setInterval이 정확히 1초마다 호출되리란 보장은 없습니다.

그래서 setInterval이 호출되는 순간 timeRemain이 -1이나 -2가 되어버리면 clearInterval이 호출되지 않겠죠.

그래서 timeRemain이 '0이거나 0보다 작다'의 조건을 걸어주어야 합니다.

 

 

이번 이슈 해결을 통해서 setInterval이 믿을만한 녀석은 아니라는걸 배웠네요.

하긴 세상에 100% 믿을만한 건 없지요. 항상 모든것에 의문을 품고 생각해보는 개발자가 됩시다... 🤓