문제
발견
Sentry와 연동된 디스코드로 에러 메시지가 전송되어 에러 발생을 인지했습니다.
undefined is not an object (evaluating 'r.toString')
JavaScript
복사
해당 에러 메시지를 보면, undefined로 toString 메서드를 호출하려 할 때 에러가 발생했다는 걸 알 수 있습니다.
좀 더 자세한 내용을 파악하기 위해 Sentry - Issues - 해당 이슈 페이지로 이동 ∙∙∙
Stack Trace를 보면
MainPage-bb*.js 파일의 하이라이트 된 부분(7행)에서 발생한다는 걸 알 수 있습니다.
Vite는 프로젝트를 빌드 시 기본적으로 파일 이름에 해시를 추가합니다.
[파일명]-[hash].js
Plain Text
복사
따라서 빌드 전 파일은 MainPage.tsx임을 유추할 수 있습니다.
MainPage.tsx로 이동해서 toString 검색
코드끼리 대조해 봤을 때 r이 currentFloor인 것을 알 수 있습니다.
정의
/main에서 undefined가 된 currentFloor로 toString 메서드 호출하면 에러(TypeError) 발생
원인
currentFloor가 undefined 타입일 수 있는지 확인해 보니, 같은 파일(MainPage.tsx)에서 다음과 같이 선언되어 있습니다 :
const currentFloor = useRecoilValue<number>(currentFloorNumberState); // 현재 층 수
TypeScript
복사
currentFloor는 number 타입으로 지정되어 있습니다. 따라서 toString 호출하는 코드 작성 당시 에러 메시지가 안 띄워져 오류가 발생하지 않을 것이라고 생각했을 것입니다.
그리고 Recoil 전역변수여서 atom으로는 정의된 부분을 살펴보니,
export const currentFloorNumberState = atom<number>({
key: "CurrentFloor",
default: undefined, //
effects_UNSTABLE: [persistAtom],
});
TypeScript
복사
초깃값이 undefined 로 돼 있었습니다.
atom은 number 타입으로 지정돼 있지만,,(타입 불일치)
파악 후,
궁금증
: 내비게이션 UI를 통해 /main 경로에 접속하면 바로 현재 층수에 대한 값이 전역변수에 저장되는데, 이 전역 변수의 값은 언제 undefined가 되는 거지?
궁금증을 해결하기 위해 Replays 섹션으로 가서 See Full Replay 버튼 누르면
하나의 리플레이에 대한 더 구체적인 디테일 알 수 있는데,
Breadcrumbs에서 이벤트 발생 과정을 살펴봅시다.
•
00:05에 새로고침 버튼 클릭 후
•
00:50에 에러와 Reload 이벤트(새로고침)가 발생
00:05~00:50 사이 어떤 일이 일어났는지 안 나와 있어서 앱 외에서 조작한 가능성이 있다고 판단했습니다. 아무것도 안 했는데 currentFloor가 사라질 일은 없으니까…
Sentry의 Session Replay 기록은 사용자가 웹 애플리케이션의 UI를 통해 수행하는 행동은 기록되지만, 그 외는 기록이 안 됨
추정
1.
로컬 스토리지에서(앱 외에서) currentFloor값 삭제 후 새로고침(
)
삭제 후 브라우저 페이지를 새로고침하면,
예상대로 에러가 발생합니다.
2.
코드 내에서 명시적으로 currentFloor를 undefined로 변경
코드에서 currentFloor 값이 변경되는 경우
•
setCurrentFloor 호출 :
◦
LeftMainNav.container.tsx - 왼쪽 내비게이션에서 층 버튼 클릭 시
◦
MapItem.tsx - 지도 아이템 클릭 시
◦
HomePage.tsx - 시작하기 버튼 클릭 시
•
resetCurrentFloor 호출 :
◦
ProfileCard.container.tsx, LeftMainNav.container.tsx - 로그아웃 버튼 클릭 시
확인 결과, 코드상 undefined로 설정하는 경우는 없습니다.
그럼,
결론
currentFloor값을 의도적으로 지워서 값이 없는 채로 웹 페이지를 새로고침하게되면 Recoil의 상태도 함께 초기화됨. 따라서 currentFloor는 초깃값인 undefined가 됨.
이후 currentFloor.toString() 실행할 때 에러가 발생하는 것.
Recoil은 상태를 메모리에서 관리합니다. 따라서 페이지를 새로 고침하면 메모리가 초기화되고, Recoil의 상태도 함께 초기화됩니다.
해결
초깃값 변경 고민
초깃값을 기존 undefined로 유지할 지 0으로 변경할지 고민했습니다.
•
undefined로 유지
◦
변수가 초기화되었음을 명확히 나타낼 수 있음
◦
그러나 number 타입 변수에 undefined를 할당하면 나중에 숫자 연산을 할 때 이 에러처럼 예상치 못한 오류 발생 가능
◦
타입을 <number | undefined>로 지정하면 → 이 상태 사용하는 모든 코드에서 타입 체크 필요
•
0으로 변경(
)
◦
코드 상 state가 undefined 되는 경우 방지
◦
기존 코드 변경 최소화
◦
0층은 존재하지 않으므로 초기값으로 사용 가능
적용
초깃값 0으로 변경 후 로컬스토리지에서 값 삭제하고 새로고침하면
/home으로 이동 (정상 작동)
리팩토링
currentFloorNumberState 변수명 통일
현재 currentFloorNumberState를 여러 파일에서 currentFloorNumber, currentFloor, floor 같이 각기 다르게 선언해서 사용 중입니다.
이렇게 같은 Recoil state 임에도 변수명이 일관되지 않으니까,
•
상태 파악의 어려움 : 같은 state인지 즉시 파악하기 어려워 불필요하게 시간을 소모했습니다.
•
코드 검색의 비효율성 : 코드 검색 시 원하는 결과를 한 번에 찾기 어려웠고, 일부 코드가 누락될 위험이 있었습니다.
이런 불편을 해소하기 위해 변수명을 일관성있게 조정하기로 하였습니다.
const currentFloor = useRecoilValue<number>(currentFloorNumberState);
TypeScript
복사
현재 코드에서 가장 많이 사용하고, 직관적인 의미를 가지고 있는 currentFloor로 통일하였고, 이를 통해 코드의 가독성과 유지보수성을 향상할 수 있었습니다.
useRecoilState vs useRecoilValue
const [currentFloor] = useRecoilState<number>(currentFloorNumberState);
const currentFloor = useRecoilValue<number>(currentFloorNumberState);
TypeScript
복사
여러 파일에서 Recoil 상태를 읽기만 하는 경우에도 useRecoilState를 사용하고 있었습니다.
이런 경우 useRecoilState 대신 useRecoilValue를 사용하는 것은 미미하지만 유효한 차이가 있습니다.
•
useRecoilValue
◦
상태 값만 반환
•
useRecoilState
◦
상태 값을 반환할 뿐만 아니라 상태를 변경할 수 있는 setter 업데이트 함수가 생성되고 반환
◦
쓰기 작업을 하지 않을 경우에도 불필요하게 setter 함수가 생성돼서 메모리에 로드되므로 메모리 낭비가 발생
◦
컴포넌트가 리렌더링될 때마다 setter 함수가 함께 생성
◦
코드에서 setter 함수를 직접적으로 변수에 할당하지 않아도, 컴포넌트가 언마운트될 때까지 메모리에 남아서 계속 사용될 가능성 있음
따라서 currentFloor를 선언할 때 값만 사용하는 경우 useRecoilState 대신 useRecoilValue로 변경했습니다.