마이그레이션 이유
기존에 프로젝트를 개발하면서 타입 때문에 발생한 에러를 잡느라 오랫동안 헤맸던 경험이 있다. (몽고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가 무슨 의미인지 모르고 일단 따라쳐 보았다.
🚫 오류!
`AppRouter` 컴포넌트를 가져오는 부분이 인식이 안 되는 현상이 발생했다.
어떻게 해결해야 하나 하다가, 다른 분들이 마이그레이션 하는 과정을 적어주신 포스팅이 많기에 참고해 보았다. 위의 현상은 아래 블로그를 통해 해결했다!
타입스크립트로 마이그레이션 여정기 2
1. 확장자명 js, ts, tsx 파일 파일 확장자명 js에서는 뭐가 js고 jsx인지 모릅니다. 그러나 ts 파일 내에서는 ts와 리액트 컴포넌트들을 구분해서 처리를 하기 때문에 react에 관련 import 즉 tsx를 알 수
kjhg478.tistory.com
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;
위와 같이 리턴타입 부분을 바꿔주면 된다고 한다!
해결 방법을 따라 하니 에러 없이 코드가 잘 동작한다.
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