해당 포스트는
해당 포스트는 나만의 명언집 프로젝트에 대한 트러블 슈팅을 정리한 포스트 입니다. 전체 1,2,3 버전이 있고 현재 포스트는 3에 해당합니다.
로그인 액세스 토큰 재발급 시 과도한 HTTP GET 요청이 발생하는 문제
※ 해당 문제는 AcessToken을 자동 재발급하는 로직에서 한번에 최소 10번 이상의 GET 요청이 발생하여, 서버측에서도 과도한 요청 처리가 발생하여 심각성을 느꼈던 문제였습니다. AWS EC2 의 모니터링 도구에서 네트워크 요청이 짧은 시간 안에 그래프가 큰 폭으로 상승하는 것이 규칙적으로 발생하는 것을 확인하여 이상함을 느꼈고, 이를 네트워크 탭에서 확인해보니 해당 문제가 발생하고 있음을 인식하였던 문제입니다.
문제상황
액세스 토큰 재발급을 위해 자동화 로직을 구현하는 로직에서 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>
}
결과 및 나가는 말
개선 이후, 토큰 재발급 기능을 다시 테스트 해보았습니다.
다행히 더 이상 과도한 GET 요청이 발생하여 네트워크 리소스를 낭비하는 문제를 해결할 수 있었습니다.
이번 트러블 슈팅의 주요 포인트는 잘못 작성된 코드로 인해 메모리 누수를 유발하고, 네트워크 리소스가 크게 낭비될 수 있다는 점 인것 같습니다. 다시는 같은 실수를 범하지 않도록 코드를 작성할 때 내가 작성한 코드가 미칠 수 있는 부수효과를 생각하며 좋은 코드를 작성하기 위해 노력해야 겠다는 성찰을 해보는 시간이 되었습니다.
EC2 인스턴스 재부팅 시 사이트 접속 불가 문제
※ EC2 인스턴스가 장기간 실행됨에 따라 t2. 마이크로 환경에서 네트워크 트래픽을 처리하는 속도가 매우 느려졌던 문제를 경험했습니다. 해당 문제를 개선하기 위해 재부팅을 시도하였는데, 어찌 보면 당연한 이유로 인해 발생한 접속 불가 문제를 해결하기 위한 과정을 그렸습니다.
배포 후 이메일 서비스가 안 되었던 문제와 개선과정
기존에 노드 메일러를 사용하여 비밀번호 찾기 시 임시 토큰을 재발급하기 위해 활용했었습니다. 그런데 EC2 에 배포하고나니 노드 메일러가 동작하지 않아서 당혹스러운 상황에 직면했고, 그 대안으로 AWS SES 를 찾으면서 개선하는 과정을 다루고 있습니다.
트러블 슈팅 3 나가는 말(전체 후기)
나름 포스트를 세 버전으로 나눠 작성해 보았습니다. 전반적으로 트러블슈팅이라는 명목으로 많은 글을 작성하였는데, 좀 더 도전적이고, 진취적인 프로젝트와 이슈를 다루지 못한 것 같아서 아쉬움이 약간 들기도 합니다. 작성을 하면서 이게 맞나 라며 스스로 의구심도 약간 첨가해봅니다. 프로젝트를 하면서 사소한 문제도 있었고, 프론트엔드 측면의 문제가 아닌 서버 측에서 일어난 문제도 다루게 되면서, 분명 당혹스럽고 힘들었음에도 여러모로 방향성이 너무 없었던게 아닌가 돌아보는 시간이 되기도 했습니다.
트러블 슈팅은 총 16 가지로 정리가 되었습니다. 물론 이 이후에도 추가될 수 있구요. 다만, 글로 표현하지 못한 실질적으로 경험한 문제는 당연하게도 이 보다 더 많았습니다. 학원을 수료하고, 개인프로젝트를 하나씩 만들면서 매번 가벼운 주제로 가볍게 끝내는 경우가 많았는데, 해당 프로젝트를 통해 프론트엔드와 백엔드, 인프라 구축 전 과정에 대해 다루다 보니 기록을 남기기 보다 빨리 해결해야지 라는 생각이 많이 들었던 것 같습니다.
정리하지 못한 이슈 중에는 상태관리의 복잡성과 이로 인한 프롭드릴링 이슈, 대량의 명언 목록을 무한 스크롤로 렌더링 시 점점 스크롤 속도가 느려지고 무거워지는 문제, 배포 등을 포함해서 길면 며칠을 소요했던 문제도 있었지만, 이를 글로서 녹이지 못한 것이 아쉽게 다가옵니다.
그럼에도, 해당 프로젝트는 저에게 있어서 동기부여이자 힘이 되었던 프로젝트 였기에 최대한 글로서 녹여보려고 노력했다는 사실은 변하지 않다고 봅니다. 다음의 도전은 더 값지고, 배움이 더 많기를 바라고, 또 그렇게 될 수 있도록 노력해야 겠다는 다짐을 마지막으로 글을 줄여봅니다.
감사합니다.
[링크] 이전 트러블 슈팅
'프로젝트 > 나만의명언집' 카테고리의 다른 글
[나만의 명언집] GithubActions 을 통한 CI 와 AWS CodePipeline (CodeDeploy)을 통한 CD 구축 | NextJS 프로젝트를 EC2 인스턴스로 배포하는 과정 (0) | 2024.06.21 |
---|---|
WIndow 환경에서 Open SSH 로 접속 및 AWS EC2 인스턴스와 연결하기(+ 번외: SCP 사용해서 EC2 인스턴스로 파일 전송하기) (0) | 2024.06.18 |
크롤링이 되긴 되었는데.. (0) | 2024.04.14 |
[나만의 명언집] 이메일 본인인증 기능 구현(With Redis 클라우드 & NextJS 서버리스) -> 핵심만 (0) | 2024.04.14 |
[나만의명언집] 드디어 ,, AWS SES 샌드박스에서 벗어났다 (0) | 2024.04.14 |