본문 바로가기
웹 프로젝트/👨‍👨‍👧‍👧소셜 가계부

[React/Node.js/Express/MongoDB] 소셜 가계부 프로젝트 구현 일지: JavaScript → TypeScript 마이그레이션 1(세팅, App, AppRouter)

by 청량리 물냉면 2024. 3. 27.
반응형

마이그레이션 이유

기존에 프로젝트를 개발하면서 타입 때문에 발생한 에러를 잡느라 오랫동안 헤맸던 경험이 있다. (몽고DB의 ObjectId와 string으로 된 id를 비교해서, 게시글을 작성한 유저임에도 불구하고 게시글 수정, 삭제를 하지 못했다...)

이때의 경험을 토대로 요즘 뜨고 있는 타입스크립트를 프로젝트에 적용해 보자는 생각을 하고, 본격적으로 JS -> TS 마이그레이션에 들어갔다.

 

 

필요한 환경설정 설치 (+ tsconfig.json 설정)

npm i -D typescript @types/node @types/react @types/react-dom @babel/preset-typescript
npm install typescript
tsc --init

여기까지 입력하면 tsconfig.json 파일이 생성된다.

 

 

TypeScript 한글 문서

TypeScript 한글 번역 문서입니다

typescript-kr.github.io

위의 문서(리액트 마이그레이션)에서 소개해 주는 대로,  tsconfig.json 내부 설정은 아래와 같이 고쳤다.

{
"compilerOptions": {
"outDir": "./dist/", // path to output directory
"sourceMap": true, // allow sourcemap support
"strictNullChecks": true, // enable strict null checks as a best practice
"module": "es6", // specify module code generation
"jsx": "react", // use typescript to transpile jsx to js
"target": "es5", // specify ECMAScript target version
"allowJs": true // allow a partial TypeScript and JavaScript codebase
},
"include": [
"./src/"
]
}

 

 

본격적인 마이그레이션 시작 (가장 상위 컴포넌트인 App 컴포넌트부터)

기존 App.js를 App.ts로 파일명 변경 후, 구글링한 대로 React:FC로 타입을 지정해 주었다.

React:FC가 무슨 의미인지 모르고 일단 따라쳐 보았다.

 

🚫 오류!

App.ts

AppRouter 컴포넌트를 가져오는 부분이 인식이 안 되는 현상이 발생했다.

어떻게 해결해야 하나 하다가, 다른 분들이 마이그레이션 하는 과정을 적어주신 포스팅이 많기에 참고해 보았다. 위의 현상은 아래 블로그를 통해 해결했다!

 

 

타입스크립트로 마이그레이션 여정기 2

1. 확장자명 js, ts, tsx 파일 파일 확장자명 js에서는 뭐가 js고 jsx인지 모릅니다. 그러나 ts 파일 내에서는 ts와 리액트 컴포넌트들을 구분해서 처리를 하기 때문에 react에 관련 import 즉 tsx를 알 수

kjhg478.tistory.com

ts는 순수한 ts로직만 담겨있는 파일이고, tsx는 jsx요소를 사용한 파일의 파일확장자이다.

ts로 바꾸었던 파일을 tsx 형식으로 고쳤다. 

App.tsx

AppRouter가 잘 인식된다! 

그런데 이제 return에 빨간줄... 이건 왜 이러지?

에러를 확인하니

Type 'Element' is not assignable to type 'FC<{}>'.
Type 'ReactElement<any, any>' provides no match for the signature '(props: {}, context?: any): ReactNode'

대충 이런 에러가 뜬다.

해결방법은 에러를 검색창에 그대로 입력해주는 것으로 간단히 찾을 수 있었다. 

import React from "react";
import AppRouter from "./routes/Router";
function App(): ReturnType<React.FC> {
return <AppRouter />;
}
export default App;

위와 같이 리턴타입 부분을 바꿔주면 된다고 한다!

해결 방법을 따라 하니 에러 없이 코드가 잘 동작한다.

(참고: https://stackoverflow.com/questions/67213681/typescript-returns-error-when-a-function-type-is-functionalcomponent-but-not-fo)

 

Typescript returns error when a function type is FunctionalComponent, but not for arrow function

Below is the example: function RoundButton(): React.FC { return <div>"hello world"</div>; } gives an error: Type 'Element' is not assignable to type 'FC<{}>'. Type '

stackoverflow.com

 

❓❓ React.FC 사용을 지양하라

그런데 문제를 해결하고 난 뒤 React.FC에 대해 조금 더 자세히 알아봐야겠다는 생각에 검색해 보니, React.FC 사용을 지양하라는 말이 있었다. 관련한 포스팅을 가져와 보았다.

 

[React] React.FC 사용 지양하기

Function Component의 줄임말로 React + TypeScript 조합으로 개발할 때 사용하는 타입함수형 컴포넌트 사용 시 타입 선언에 쓸 수 있도록 React에서 제공하는 타입React.FC를 사용하는 경우 위와 같이 props의

velog.io

 

React.FC 사용 지양하기

FunctionComponent 타입의 줄임말React + Typescript 조합으로 개발 시 사용하는 타입 중 하나함수형 컴포넌트 사용 시 타입 선언에 쓸 수 있도록 React에서 제공하는 타입클래스형 컴포넌트에서 함수형 컴

velog.io

코드 길이도 길어지고, TS의 제네릭 문법도 지원하지 않는다고 한다...

 

 

💁‍♀️그렇다면 내 프로젝트는 어떻게 고쳐야 할까? 

App.tsx의 경우 최상위 코드이므로 props를 받지 않는다. 그러므로 간단하게 매개변수 부분과 React.FC 부분도 싹 지워주었다.

import React from "react";
import AppRouter from "./routes/Router";
function App() {
return <AppRouter />;
}
export default App;

오류가 모두 사라졌다.

 

 

마이그레이션2 (AppRouter 컴포넌트)

App.tsx 파일이 정상적으로 동작하니, 이제 App.tsx에서 불러오고 있는 AppRouter.jsx를 고쳐보기로 했다.

아래와 같이 하나씩 차근차근 타입을 지정해 주었다.

...
interface StoreData {
transactionAnalytics: Object;
transactions: Object;
user: UserData;
}
interface UserData {
isLoggedIn: boolean;
token: string;
tokenExpiration: string;
userInfo: Object;
}
function AppRouter() {
const [isLoading, setIsLoading] = useState(true);
const userData: UserData = useSelector((state: StoreData) => state.user);
const dispatch = useDispatch();
const logoutHandler = useCallback(() => {
dispatch(logout());
localStorage.removeItem("userData");
}, [dispatch]);
useEffect(() => {
const storedData = JSON.parse(localStorage.getItem("userData") || "{}");
if (storedData && storedData.token && new Date(storedData.tokenExpiration) > new Date()) {
dispatch(loginSuccess());
dispatch(setUserInfo(storedData.userInfo));
dispatch(setToken(storedData.token));
dispatch(setTokenExpiration(storedData.tokenExpiration));
}
setIsLoading(false);
}, [logoutHandler, dispatch]);
useEffect(() => {
let logoutTimer: ReturnType<typeof setTimeout>;
//토큰과 만료기간 둘 다 있으면 타이머 설정
if (userData.token && userData.tokenExpiration) {
const remainingTime = new Date(userData.tokenExpiration).getTime() - new Date().getTime(); //남은 만료기간
logoutTimer = setTimeout(logoutHandler, remainingTime);
}
return () => {
if (logoutTimer) {
clearTimeout(logoutTimer); //진행 중인 타이머 모두 제거
}
};
}, [logoutHandler, userData.token, userData.tokenExpiration]);
...

 

수정한 내용을 정리해 보면 다음과 같다.

 

1. StoreData 인터페이스를 이용해 전역에서 사용되는 리덕스 데이터 타입을 정의했다.

2. UserData 인터페이스는 StoreData 내의 user 속성에 대한 타입을 명시하고 있다.

3. JSON.parse(localStorage.getItem("userData") || "{}")에서는, localStorage에서 데이터를 가져올 때 null일 경우를 처리하기 위한 fallback 값을 추가했다.

4. logoutTimer 변수의 리턴 타입을 ReturnType<typeof setTimeout>으로 명시했다.

 

🚫 오류! ( clearTimeout(logoutTimer); Variable 'logoutTimer' is used before being assigned. )

useEffect(() => {
let logoutTimer: ReturnType<typeof setTimeout>;
//토큰과 만료기간 둘 다 있으면 타이머 설정
if (userData.token && userData.tokenExpiration) {
const remainingTime = new Date(userData.tokenExpiration).getTime() - new Date().getTime(); //남은 만료기간
logoutTimer = setTimeout(logoutHandler, remainingTime);
} else {
clearTimeout(logoutTimer); //진행 중인 타이머 모두 제거
}
}, [logoutHandler, userData.token, userData.tokenExpiration]);

 

원인

타입스크립트는 변수가 할당되기 전에 사용되는 것을 엄격하게 검사한다.

위 코드에서 logoutTimer 변수는 조건문 밖에서 선언되었다.
userData.token과 userData.tokenExpiration가 존재한다면 상관이 없지만, 그 반대의 상황이라면 logoutTimer 변수가 초기화되지 않은 상태로 clearTimeout(logoutTimer)를 호출하게 된다. 이런 상황에 대비해 TypeScript는 경고를 발생시키고 있는 것이다.

 

해결방안

따라서 이러한 상황에서는 TypeScript 컴파일러가 logoutTimer 변수의 할당 여부를 인식하지 못하고, 변수가 할당되기 전에 clearTimeout 함수가 호출되는 것을 방지하기 위해서는 logoutTimer 변수가 초기화된 상태인지 추가적인 확인이 필요하다.

 

해결

이를 해결하기 위해 useEffect 내부에 return 함수를 사용하여 clearTimeout 함수를 호출하기 전에 변수가 할당되었는지 확인하는 방법을 사용했다.

useEffect(() => {
let logoutTimer: ReturnType<typeof setTimeout>;
//토큰과 만료기간 둘 다 있으면 타이머 설정
if (userData.token && userData.tokenExpiration) {
const remainingTime = new Date(userData.tokenExpiration).getTime() - new Date().getTime(); //남은 만료기간
logoutTimer = setTimeout(logoutHandler, remainingTime);
}
return () => {
if (logoutTimer) {
clearTimeout(logoutTimer); //진행 중인 타이머 모두 제거
}
};
}, [logoutHandler, userData.token, userData.tokenExpiration]);

useEffect에서의 return은 해당 effect가 더 이상 실행할 필요가 없을 때 clean-up하기 위한 용도로 쓰인다.

return 함수가 클린업을 진행하는 경우는 아래와 같다.
1. dependancy(두 번째 인자인 의존 배열 내부 요소)가 바뀌어서 effect가 달라져야 할 때
2. 해당 컴포넌트가 unmount 될 때

즉, 컴포넌트의 unmount이전 / update직전에 어떠한 작업을 수행하고 싶다면, clean-up 함수를 반환해 주어야 한다.
clean-up 함수의 작동순서
re-render → 이전 effect의 clean-up → effect 실행

 

return 함수는 useEffect가 clean-up을 수행할 때 호출되는데, 여기서는 logoutTimer가 할당되었는지 확인하고 타이머를 제거하는 역할을 하고 있다. return 구문은 useEffect가 종료될 때 실행되며, 만약에 logoutTimer가 설정되지 않았다면 clearTimeout 함수를 호출하지 않는다.
return 함수에서는 logoutTimer가 설정되었는지 확인한 후에 clearTimeout 함수를 호출하므로, 변수가 초기화되기 전에 clearTimeout 함수가 호출되는 문제가 발생하지 않아 오류가 사라진다.

 

 

 

 

➕ 추가로 vercel에서 커밋한 코드가 배포되지 않는 오류가 발생해서 ts 버전을 다운그레이드 시켜 주었다.

npm i typescript@^4 -S

참고: https://stackoverflow.com/questions/77621320/im-trying-to-deploy-my-react-app-with-vercel-i-did-every-step-in-my-terminal-b

반응형