본문 바로가기
웹 프로그래밍/👨‍👨‍👧‍👧소셜 가계부

[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

반응형