마이그레이션 이유
기존에 프로젝트를 개발하면서 타입 때문에 발생한 에러를 잡느라 오랫동안 헤맸던 경험이 있다. (몽고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` 파일이 생성된다.
위의 문서(리액트 마이그레이션)에서 소개해 주는 대로, 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가 무슨 의미인지 모르고 일단 따라쳐 보았다.
🚫 오류!
`AppRouter` 컴포넌트를 가져오는 부분이 인식이 안 되는 현상이 발생했다.
어떻게 해결해야 하나 하다가, 다른 분들이 마이그레이션 하는 과정을 적어주신 포스팅이 많기에 참고해 보았다. 위의 현상은 아래 블로그를 통해 해결했다!
ts는 순수한 ts로직만 담겨있는 파일이고, tsx는 jsx요소를 사용한 파일의 파일확장자이다.
ts로 바꾸었던 파일을 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;
위와 같이 리턴타입 부분을 바꿔주면 된다고 한다!
해결 방법을 따라 하니 에러 없이 코드가 잘 동작한다.
❓❓ React.FC 사용을 지양하라
그런데 문제를 해결하고 난 뒤 `React.FC`에 대해 조금 더 자세히 알아봐야겠다는 생각에 검색해 보니, `React.FC` 사용을 지양하라는 말이 있었다. 관련한 포스팅을 가져와 보았다.
코드 길이도 길어지고, 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