useRef 및 setInterval 로 타이머 로그아웃 구현하기

hyeok·2023년 3월 12일
0
post-thumbnail

로그인 이후 일정 시간이 지나면 자동으로 로그아웃 하게 하는 기능을 구현해보겠다.
원하는 기능은 다음과 같다.

  1. refresh를 해도 타이머가 유지되야함
  2. 로그인 할 때만 타이머가 시작되어야함
  3. 로그아웃하면 타이머가 종료됨
  4. 타이머가 끝나기 30초 전에 알람 창이 떠서 연장여부를 물어봄, ok 하면 연장. 캔슬하면 아무런 일도 안 일어남.
  5. 타이머가 끝나면 자동으로 로그아웃.
  6. 앱전역에서 타이머는 진행 되어야함

일단 GPT에게 기본적이 타이머 구현을 부탁했다.

import React, { useState, useEffect } from 'react';

function Timer() {
  const [secondsElapsed, setSecondsElapsed] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSecondsElapsed(secondsElapsed => secondsElapsed + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      <p>Seconds Elapsed: {secondsElapsed}</p>
    </div>
  );
}

export default Timer;

일반적 타이머 구조로 setInterval을 이용해서 1초마다 스테이트를 갱신하는 구조이다.
Refresh하면 초기화 되고, 마음대로 연장도 하지 못한다. 그리고 전역도 아니다.
일단 전역에서 진행되게 하기 위해 context api를 활용한다.

PersistantTimerProvider.js 

import { createContext, useMemo, useEffect, useRef, useState } from 'react';

export const TimerContext = createContext();

const initTimer = {
  lastSavedElapsedTime: 0,
  elapsedTime: 0,
  intervalId: 'timer',
  start: 0,
};


const PersistantTimerProvider = ({ children }) => {
	  const timer = useRef(initTimer);
      const cu = timer.current;
      const [elapsedTime, setElapsedTime] = useState(cu.lastSavedElapsedTime);
	
  const start = () => {
    if (cu.intervalId) {
      cu.intervalId = setInterval(() => {
        cu.elapsedTime =
          new Date().getTime() - cu.start + cu.lastSavedElapsedTime;
        const lastTime = options.maxTime - cu.elapsedTime;

        if (lastTime > 0) {
          	타이머가 계속되는 코드
        }

        if (lastTime <= 30 * 1000 ) {
         	30초 이내에 도달하면 모달이 뜨는 코드  
        }

        if (lastTime <= 0) {
       		정지 시키는 코드
        }
      }, 1000);
    }
  };

  useEffect(()=> {
  	start()
    // 시작
  }, [])
  
}


export default PersistantTimerProvider

App.js 

....
return <PersistantTimerProvider>
	<App/>
</PersistantTimerProvider>

여기에서 왜 갑자기 useRef를 사용하게 된걸까?
왜냐하면 참조값을 그대로 유지해야 리셋이나 pause 할때 사용할 수 있기 때문이다.
리엑트 특성상 하위 컴포넌트에 스태잇 변화가 발생하면 다시 실행되서 timer의 intervalId가 다시 정의된다. 그렇게 되면 값은 "timer"로 같지만 참조값이 달라서 이후에 pause나, 리셋을 하지 못한다.
const timer = useTimer(initialTimer)을 이용하면 하위 컴포넌트에서 리랜더링이 일어나든 말든 해당 참조값은 유지된다.

이제 기능 구현을 위한 코드를 더 추가하겠다.

로컬 스토리지에 지속적으로 timer 정보를 지속적으로 최신화해서 Refresh를 하더라도 영향이 오지 않도록 했다.

시간이 30초 남으면 redux를 이용해서 알람 모달을 띄우게 했다.

커스텀 훅인 useAuth에서 가져온 user 정보가 있으면 타이머를 시작, user 정보가 사라지면 pause를 하고 타이머를 멈추도록 했다.

여기에서 타이머와 번외로 까다로웠던 부분이 있었는데 상위에 있는 리덕스의 modal visible 값이 바뀌면 리덕스를 활용한 알람 모달에 영향을 받는 모든 컨텐츠 부분이 타이머와 함께 1초마다 리랜더링 되는 심각한 문제가 있었다.

ModalInCludedContent.js

  ....
  
  const contents = <div>앱 전체의 내용</div>
  
  
  return (
    <>
      {contents}
      <PopupModal visible={visible} {...modalConfigs}>
        {modalConfigs.children}
      </PopupModal>
    </>
  );

위와같이 모달이랑 같이 있는 컨텐츠가 리랜더링 되는데, 이유를 추측해보자면 현재 구조가

<ReduxProvider>
	<PopupModalProvider>
    	<PersistantTimerProvider>
        	<Content/>
        	<Modal/>
    	<PersistantTimerProvider>
    </PopupModalProvider>
</ReduxProvider>

요런 구조인데 persistant Timer provider에서 modal provider에 있는 Modal을 리랜더링 시키니까 Content가 덩달아 리랜더링 되고 그 이후엔 Modal provider 밑에 있는 모든 스테잇 변화(timer 인터벌에 있는 스테잇 변화 포함)에 따라 모두 리랜더링 되는 거 같다. 모달의 visible을 바꾸는 로직은 한번인데 왜 계속 타이머에 따라 모두 리랜더링 되는지는 아직 모르겠다.

해결책을 찾아본 결과 Content를 useMemo로 감싸주는 것이었다. useMemo는 외부 스테잇 변경에따라 자꾸 변하는걸 막아준다. 아마 persistantTimer를 리덕스로 구현했으면 이런일이 없을 거 같다. context api 특성이 props로 내려주는 값이 달라지면 연관된 모든 돔이 리랜더링 되는 건데 이게 문제가 되었다.

그렇다고 redux에서 이 작업을 하자니 redux에선 커스텀 훅을 불러올 수 없다. 커스텀 훅에서 위의 모든 과정을 진행한 후 리덕스를 통해 내려보내는 건 가능할 거 같긴하다. 즉 프로바이더를 모두 통합해서 redux에서만 관리하는 것이다. 이 구조는 구현 해본적이 없어서 진행해봐야할 거 같다.

전체적인 위의 요구사항 특징은 모두 구현이 되었고, 완성되었다. setInterval의 특성 때문에 고생했고, context api의 리랜더링을 막는것이 약간 시간이 걸리게 했다.
useRef, setInterval, context api 각종 커스텀 훅을 활용해서 완성했다.

위의 코드

import { Typography } from '@mui/material';
import { Stack } from '@mui/system';
import { createContext, useMemo, useEffect, useRef, useState } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { useModal } from '../../hooks/useModal';
const initTimer = {
  //initial value of ref
  lastSavedElapsedTime: 0,
  elapsedTime: 0,
  intervalId: 'timer',
  start: 0,
};

function pad(n) {
  return n < 10 ? '0' + n : n;
}

const options = {
  updateFrequency: 1,
  maxTime: 15 * 60 * 1000,
  localStorageItemName: 'Persistant_timer',
};

export const TimerContext = createContext();

const PersistantTimerProvider = ({ children }) => {
  const { visible, setVisible, setModalConfigs } = useModal();
  const timer = useRef(initTimer);
  const { user, logout } = useAuth();
  const cu = timer.current;
  const [loading, setLoading] = useState(true);
  const [elapsedTime, setElapsedTime] = useState(cu.lastSavedElapsedTime);

  const getValueFromLocalStorage = () => {
    let v = parseInt(localStorage.getItem(options.localStorageItemName) || '0');
    if (isNaN(v) || v < 0) v = 0;
    cu.lastSavedElapsedTime = v;
    cu.elapsedTime = 0;
    cu.start = new Date().getTime();
  };

  const start = () => {
    if (cu.intervalId) {
      getValueFromLocalStorage();
      cu.intervalId = setInterval(() => {
        cu.elapsedTime =
          new Date().getTime() - cu.start + cu.lastSavedElapsedTime;
        const lastTime = options.maxTime - cu.elapsedTime;

        if (lastTime > 0) {
          keepTimerTiking(
            setElapsedTime,
            cu.elapsedTime,
            options.localStorageItemName,
            setLoading,
          );
        }
        const isAlarmed = localStorage.getItem('isAlarmed');
        if (lastTime <= 30 * 1000 && !isAlarmed) {
          alarmModalStart(
            lastTime,
            setModalConfigs,
            resetTimer,
            setVisible,
            visible,
            t,
          );
        }

        if (lastTime <= 0) {
          localStorage.removeItem('isAlarmed');
          pause();
          logout();
        }
      }, 1000);
    }
  };

  const pause = () => {
    setLoading(true);
    setVisible(false);
    cu.elapsedTime = 0;
    cu.lastSavedElapsedTime = cu.elapsedTime;
    localStorage.removeItem('isAlarmed');
    localStorage.removeItem(options.localStorageItemName);
    if (cu.intervalId) {
      clearInterval(cu.intervalId);
      cu.intervalId = null;
    }
  };

  const resetTimer = () => {
    cu.lastSavedElapsedTime = 0;
    cu.elapsedTime = 0;
    localStorage.setItem(options.localStorageItemName, '0');
    cu.start = new Date().getTime();
    setElapsedTime(0);
  };

  const lastTime = new Date(options.maxTime - elapsedTime);
  const lastTimeString =
    lastTime.getMinutes() + ':' + pad(lastTime.getSeconds());

  useEffect(() => {
     if (!user) {
       pause();
     } else {
       cu.intervalId = 'timer';
       start();
     }
  }, [user]);

  const value = useMemo(
    () => ({
      elapsedTime,
      start,
      resetTimer,
      lastTimeString,
      loading,
      pause,
    }),
    [start, resetTimer, elapsedTime, lastTimeString, loading, pause],
  );

  return (
    <TimerContext.Provider value={value}>{children}</TimerContext.Provider>
  );
};

const alarmModalStart = (
  lastTime,
  setModalConfigs,
  resetTimer,
  setVisible,
  visible,
  t,
) => {
  const lastDateTime = new Date(lastTime);
  const lastTimeString =
    lastDateTime.getMinutes() + ':' + pad(lastDateTime.getSeconds());
	// 리덕스를 이용해서 알람 모달뜨게 하는 파트
  setModalConfigs({
    width: 600,
    onClose: () => {
      localStorage.setItem('isAlarmed', true);
      setVisible(false);
    },
    children: (
		<div> {lastTimeString + "남았음 연장 or Not?" </div>
    ),
    modalButtonsConfigs: [
      {
        text: t('page:executeExtend'),
        onClick: () => {
          resetTimer();
          setVisible(false);
        },
      },
      {
        text: t('page:close'),
        type: 'outlined',
        onClick: () => {
          localStorage.setItem('isAlarmed', true);
          setVisible(false);
        },
      },
    ],
  });
  if (!visible) {
    setVisible(true);
  }
  return;
};

const keepTimerTiking = (
  setElapsedTime,
  elapsedTime,
  localStorageItemName,
  setLoading,
) => {
  setElapsedTime(elapsedTime);
  setLoading(false);
  localStorage.setItem(localStorageItemName, elapsedTime.toString());
};

참조 : https://stackoverflow.com/questions/73738160/how-to-prevent-the-timer-on-reload-the-page-in-react-js

profile
내가 만든 소프트웨어가 사람들을 즐겁게 할 수 있기 바라는 개발자

0개의 댓글