본문 바로가기

프로젝트/나만의명언집

[나만의 명언집] 트러블 슈팅 모음집 ③ | 14

반응형

[링크] 이전 트러블 슈팅

 

[나만의명언집 프로젝트] 트리블 슈팅 모음집 ① | 1 ~ 6

오늘의 명언  참고) 정리 방식은 1) 문제상황 → 2) 해결과정 → 3) 성과/소감/결과시연 흐름으로 정리합니다.   들어가기 전아주 사소한 문제일지도 모르나 개인적으로 해결하기에 고민을 많

duklook.tistory.com

 

 

[나만의 명언집 프로젝트] 트러블 슈팅 모음집 ② | 7 ~ 13

들어가기 전해당 포스트는 프로젝트를 진행하는 중 경험하는 트러블 요소들을 어떻게 해결해 나갔는지 정리하는 용도로 작성됩니다. 해당 포스트는 트러블 슈팅①(https://duklook.tistory.com/417) 에

duklook.tistory.com

 

 


해당 포스트는

이 포스트는 NextJS 14 버전 이상의 리액트 메타 프레임워크를 사용하여 개발한 프로젝트인 나만의 명언집 사이트 개발 중 발생한 문제와 그 문제를 개선한 과정과 결과를 정리하는 포스트 입니다.

 


 

[2024.06.18] 로그인 액세스 토큰 재발급 시 과도한 HTTP GET 요청이 발생하는 문제

문제상황

액세스 토큰 재발급을 위해 자동화 로직을 구현하는 로직에서 HTTP GET 요청시 비정상적으로 이루어지는 문제가 발생했습니다.

 

아래와 같이 동시에 GET 요청이 대량으로 발생했습니다.

 

 

서버 측에서 해당 요청에 대해 어떻게 처리하는지 확인해보니 한 번에 32개의 요청을 받은 것을 확인할 수 있었습니다.  만일 실제 많은 유저를 받아서 운영하는 사이트였다면,  불필요한 리소스 낭비로 인해 서버가 마비되거나 유지보수 비용이 크게 나갔을지도 모를 심각한 문제라고 생각 됩니다.

 

개선과정

코드 분석 |  checkTokenExp : 토큰 만료 시간을 측정하는 함수

우선, 갱신을 수행하는 코드를 살펴보았습니다.

  // 토큰 만료 시간 측정
  const checkTokenExp = useCallback(async () => {
    const exp = getLoginExp()

    if (typeof exp !== 'number') return

    const currentTime = Math.floor(Date.now() / 1000)
    const expired60SecondsAgo = exp - (currentTime - MINUTE_TO_SEC) //  현재(sec) - 60(sec) = 1분 전 토큰 만료
    setTimeScale(Number(expired60SecondsAgo))

    if (exp <= currentTime - MINUTE_TO_SEC) {
      const isSuccess = await requestNewAccessToken()
      if (isSuccess) checkTokenExp()
    }
  }, [])
  
  // setInterval 을 통해 
    useEffect(() => {
    const timeId = setInterval(checkTokenExp, 1000)

    return () => {
      clearTimeout(timeId)
    }
  }, [checkTokenExp])

 

해당 코드에서는 if(exp <= currentTime - MINUTE_TO_SEC ) 내에 조건식이 true 가 되는 경우 requestNewAccessToken() 함수로 호출되어 새로운 토큰을 발급하는 요청을 서버로 보내도록 하고 있습니다.

 

요청이 성공하면, true 가 isSuccess 변수에 할당되고, 그 즉시 checkTokenExp 함수를 재귀호출 합니다. 현재 로직에서 해당 함수는 토큰의 만료 시간 측정 뿐만 아니라 새로운 accessToken을 요청하는 역할도 같이 수행하고 있어서 두 역할을 기능 별로 분리해줄 필요가 있어 보입니다.

 

또한 setInterval 을 통해서 매번 1초 마다 리렌더링을 일으키고 있는 상황에서,  checkTokenExp() 함수를 다시 재귀호출을 하고 있는데, 한 번의 호출에 과도한 GET 요청을 발생시키는 원인으로 의심이 되는 부분입니다. 

 

코드분석 | debounce 를 적용한 focus 이벤트

해당 로직은 사용자가 브라우저 바깥으로 포커스를 옮긴 후 다시 브라우저 내부로 포커스를 맞추는 경우 자동으로 로그인 연장을 위한 토큰을 재발급 받도록 해둔 부분입니다. 잦은 포커스 인/ 아웃 문제를 개선하기 위해 디바운스를 걸어둔 것으로 보이는데, 현재 보는 시점에서는 굳이 불필요한 로직이 아닌가 생각이 듭니다.

  function debounce() {
    let timeId: NodeJS.Timeout

    return function (func: Function, delay: number) {
      clearTimeout(timeId)
      timeId = setTimeout(() => {
        func()
      }, delay)
    }
  }

  const debounceCloser = debounce()
  
  const windowBlur = useCallback(() => {
    
    window.addEventListener('focus', () => {
      debounceCloser(requestNewAccessToken, 1000 * 40)
    })
  }, [])

// 과거 작성한 로직인데, 이 부분의 이벤트 리스너가 제대로 해제되고 있지 않네요.
// remove 하기 위해서는 add 를 한 부분과 동일한 형태를 지녀야 하며, windowBlur 내부에서 
//이벤트 리스너를 등록하고 있는 점 등 잘못 작성된 로직이 눈에 보입니다.
  useEffect(() => { 
    windowBlur()
    return () => removeEventListener('focus', windowBlur)
  }, [windowBlur])

 

 

 

현 로직에서 문제가 되는 부분 중에서 엉뚱하게도 메모리 누수가 발생하는 로직이 보입니다.  이벤트 리스너의 등록과 해제 방식을 잘못 적용함에 따라 이벤트 리스너가 디마운트 시에 제거되지 않고 계속해서 등록되고 있는 상황입니다.

// 이벤트 리스너를 내부에서 등록 및 호출하는 함수를 제거하고 있습니다. 다름과 차이가 아닌 그냥 잘못된 코드입니다.
  useEffect(() => { 
    windowBlur()
    return () => removeEventListener('focus', windowBlur)
  }, [windowBlur])

 

 

또한, windowBlor 함수의 경우 별도의 조건없이 requestNewAccess 함수를 포커스 시 실행하고 있습니다. useEffect의 의존성 배열에  windowBlur 함수가 들어있고, useCallback 으로 감싼 것을 보면 앞서 토큰 만료시간 함수의 경우와 동일하게 잦은 호출 문제가 발생하고 있는 것이 보입니다.

  const windowBlur = useCallback(() => {
    
    window.addEventListener('focus', () => {
      debounceCloser(requestNewAccessToken, 1000 * 40)
    })
  }, [])

 

이렇게 작성한 과거를 돌아보면, 중복된 요청을 캐싱해두기 위해 useCallback를 적용한 것으로 보이는데, 현재 상황에서 굳이 포커스 마다 디바운스를 적용하고, useCallback 까지 적용하며 매번 토큰 재발급을 시도 할 필요성이 없다는게 현재 생각입니다.

 

따라서, 개선과정에서는 해당 로직 전체를 제거하고 사용자가 필요에 따라 사용할 수 있는 별도의 대체 기능으로 대신할 생각 입니다.

 

로직 변경 |  checkTokenExp 함수 개선

여기서 주요 개선 점을 요약하면, 기존 checkTokenExp 가 수행하는 토큰 만료 시간 측정과 재갱신 로직을 분리하고, 과도한 GET 요청의 원인으로 보이는 재귀호출 로직을 제거하며, 전체적으로 기능은 유지하되 크기는 간소화하는 작업을 해주었습니다.

 

isExpire 상태추가

해당 상태는 기존 checkTokenExp 함수 내부에서 새로운 토큰 요청을 수행하는 함수와 기능적으로 분리하기 위해 추가해주었습니다.

  const [isExpire, setIsExpire] = useState(false)

 

checkTokenExp 와 토큰 재갱신 함수 분리(관심사 분리)

이전 로직에서는 expire60SecondAgo 변수를 setTimeScale 이라는 업데이트 함수에 전달하여, 사용자에게 재갱신 시간을 보여주는 용도로만 활용하였습니다. 이를  if (expired60SecondsAgo < 1) setIsExpire(true) 형태로 재사용하여 토큰 만료 시간 60초 전에 상태(isExpire)를 true로 업데이트하도록 바꿔 주었습니다.

  /** 토큰 만료 시간 측정 */
  const checkTokenExp = useCallback(async (exp: number | false | undefined) => {
    if (typeof exp !== 'number') return

    const currentTime = Math.floor(Date.now() / 1000) // 현재 시간
    const expired60SecondsAgo = exp - (currentTime - MINUTE_TO_SEC) //  현재(sec) - 60(sec) = 1분 전 토큰 만료

    setTimeScale(Number(expired60SecondsAgo)) // 만료 시간 추적
    if (expired60SecondsAgo < 1) setIsExpire(true)

  }, [])

 

또한 앞서 개선전 코드에서는 requestNewAccessToken 함수를 호출하고, 성공 시 반환되는 boolean 값을 통해 재귀적으로 checkTokenExp() 함수를 호출하도록 했었습니다.

    if (exp <= currentTime - MINUTE_TO_SEC) {
      const isSuccess = await requestNewAccessToken()
      if (isSuccess) checkTokenExp() // -> setInterval 이 동작하므로 굳이 재귀호출이 필요하지 않습니다.
    }

 

이를 외부로 분리하여 아래와 같이 별도의 부수효과로 관리하도록 해주었습니다. 이렇게 함으로서 해당 함수는 isExpire 가 true 인 경우에만 호출 됩니다.

  /** 토큰 갱신 */
  async function updateToken( isExpire: boolean) {
    if (isExpire) {
      const {isSuccess} = await requestNewAccessToken()

      // 토큰 갱신 성공 시
      if (isSuccess) setIsExpire(false); 
    }
  }

  useEffect(() => {
    const update = async () => await updateToken( isExpire)
    update()
  }, [isExpire])

 

로직 변경 | 기존 포커스 이벤트 제거 및 갱신 기능 추가

(요약) 여기서 주요 개선점은 불필요한 포커스 이벤트를 제거하고, 사용자가 직접적으로 사용할 수 있는 기능을 추가하는 것입니다. 굳이 디바운스와 useCallback 를 사용하여 메모리 낭비와 오히려 성능에 저하되는 문제를 안고 가는 것은 좋지 못하다고 판단하여 기능 대체를 변경 하였습니다.

 

포커스 이벤트 관련 로직을 제거하기로 한 이유

더보기

① useCallback 이 성능 최적화가 아닌 반대로 부하를 유발

추가적인 캐싱처리를 위한 단계를 거치면서 발생하는 성능상의 의존성 배열에 함수를 넣어두게 되면, 함수가 과도하게 호출되어서 호출 스택이 최대치로 쌓여 멈추는 경험을 한 번씩 했었습니다. 함수가 한 번 호출 될 때 마다 매번 새롭게 등록된 함수가 호출이 되니 함수 컨텍스트가 계속해서 쌓이는 것이 주요 원인이었습니다. 이를 방지하기 위해 useCallback 이라는 캐싱 훅을 사용했으나, 애초에 이것을 사용하는 것부터가 추가적인 캐싱 처리를 위한 단계를 포함하는 것이기 때문에 매번 포커스 시 CPU에 많은 부하를 주고 있음을 느꼈습니다.

 

② 불필요한 디바운스 기능이 불필요한 메모리 공간을 차지

추가로, 해당 이벤트 처리 로직에서 메모리 누수를 발생시킬 여지가 있었던 것은 디바운스 기능이었습니다. 디바운스 기능은 내부적으로 클로저를 사용하기 때문에, 외부함수는 종료되어도 내부함수가 참조하는 timerId 는 메모리 공간 상에 존재하기 때문에 가비지 컬렉터가 제거하지 못하고 남겨두게 됩니다. 

 

이러한 로직들을 사용한 토큰 재발급 기능이 굳이 현 프로젝트에 필요한가? 라고 자문했을 때, 성능상 부담 크고, 불필요하다는 결론을 내렸습니다. 해당 방법이 아니라도 대체할 방식은 많으니까요.

 

토큰을 재발급하는 유저 편의성을 높이는 방식은 대체할 방식이 많다고 생각합니다.  과거 당시에는 모르겠으나, 현재 상황에서 포커스 이벤트를 통해 토큰을 재갱신할 필요성이 도저히 보이지 않습니다.

 

따라서, 사용자가 로그인 상태에 따른 권한 문제가 발생한다면 명시적  갱신할 수 있도록  안전장치를 마련해주는 방식으로 개선 하였고, 사용자가 버튼을 클릭하면 handleSetIsExpore 함수를 호출하여 강제로 현재 토큰을 만료시키고, 새로운 토큰을 서버로 부터 받아올 수 있도록 개선하였습니다. 확실히 앞서 코드 보다는 더 가볍고 효율적으로 보입니다.

  // handleSetIsExpire 함수가 호출되면 isExpire 상태가 변경되면서 토큰을 갱신
  useEffect(() => {
    const update = async () => await updateToken( isExpire)
    update()
  }, [isExpire])


  /** 토큰 강제 갱신을 위한 만료 상태 설정 */
  function handleSetIsExpire() {
    setIsExpire(true)
  }
  
  return {
       // ... 생략
       <button onClick={handleSetIsExpire} >
        <HiRefresh className='mr-[1px]' />갱신
      </button>
      }

 

 

결과 및 나가는 말

개선 이후, 토큰 재발급 기능을 다시 테스트 해보았습니다. 

첫 번째 access 요청은 정해진 토큰 만료시간이 모두 만료되었을 때 이고, 두 번째는 사용자가 직접 토큰 갱신을 클릭 했을 때 입니다.

 

다행히 더 이상 과도한 GET 요청이 발생하여 네트워크 리소스를 낭비하는 문제를 해결할 수 있었습니다.

 

이번 트러블 슈팅의 주요 포인트는 잘못 작성된 코드로 인해 메모리 누수를 유발하고, 네트워크 리소스가 크게 낭비될 수 있다는 점 인것 같습니다. 다시는 같은 실수를 범하지 않도록 코드를 작성할 때 내가 작성한 코드가 미칠 수 있는 부수효과를 생각하며 좋은 코드를 작성하기 위해 노력해야 겠다는 성찰을 해보는 시간이 되었습니다.

 

 

 

 

 

반응형