🔥 학습목표
- 페어 프로그래밍을 통해 JavaScript로 작성 된 React App을 TypeScript로 포팅할 수 있다.
파일 구조는 다음과 같다.
React로 개발 된 간단한 Todo 애플리케이션이다. 포팅을 위해 만들어진 거라 엄청 단순하다 ㅎㅎ
Todo 목록을 보여주는 Todo
컴포넌트와 Todo 입력란인 TodoForm
컴포넌트로 구성된다.
애플리케이션을 실행하면 아래와 같은 화면이 나타난다.
입력란에 문자열을 넣고 enter
혹은 addTodo
버튼을 누르면 그 아래 Todo 목록이 차례대로 추가된다.
가장 먼저 프로젝트에 필요한 라이브러리를 설치한다.
npm install -D typescript @types/react @types/react-dom @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks prettier
typescript
- TypeScript 코드 작성을 위한 타입스크립트 패키지.
--init
옵션을 붙이면 tsconfig.json
파일이 자동 생성된다. @types/react
- 리액트에서 사용할 타입이 타입이 정의 된 패키지.
@types/react-dom
- 리액트 돔 패키지에서 사용할 타입이 정의된 패키지.
└▷ 자바스크립트에서는 react와 react-dom 패키지만 있으면 됐지만, 타입 스크립트에서는 이들에 대한 타입 정보가 필요하다!
@typescript-eslint/eslint-plugin
- TypeScript 코드에 대한 lint 규칙을 제공하는 ESLint 플러그인.
@typescript-eslint/parser
- ESLint가 TypeScript 코드를 린트할 수 있도록 TypeScript ESTree를 활용하는 ESLint 파서.
eslint
- EsLint 설치. ECMAScript/JavaScript 코드에서 발견된 패턴을 식별하고 보고하는 도구.
eslint-config-prettier
- 불필요하거나 Prettier와 충돌할 수 있는 모든 규칙을 해제.
eslint-plugin-prettier
- Pretty를 ESLint 규칙으로 실행.
eslint-plugin-react
- ESLint 규칙에 따라 린트되는 리액트
eslint-plugin-react-hooks
- Hook 규칙을 시행하는 ESLint 플러그인. 리액트에서 typescript로 Hooks API를 사용하려면 필요하다.
prettier
- 프리티어 패키지. 코드를 규칙에 따라 깔끔하고 예쁘게 정리해준다.
.ts
파일을.js
파일로 변환할 때 어떻게 변환할 것인지 세부 설정하는 파일
tsconfig.json 에 영향을 받는 요인들
VScode는 기본적으로 타입스크립트에 대한 문법 자동완성을 지원하는데, 이 intellisense가 .ts
파일을 인식하는 방법을 제어한다.
TypeScript 컴파일러 tsc
또한 컴파일 과정에서 tsconfing.json
을 사용하여 컴파일을 제어한다.
tsc
를 통해 실제 결과물이 어떻게 변환되는지 고민하며 작성할 것인지, 혹은 단순히 VScode
에서 가이드라인을 제시하는 방법을 제어하는 용도로 작성할 것인지 잘 파악하고 옵션을 정해야 한다.
일단 프로젝트 root 경로에 tsconfig.json
파일을 생성한다.
{
"compilerOptions": {
"jsx": "react-jsx", // jsx 구문을 사용
"lib": ["es6", "dom"], // 현재 프로젝트에서 사용하는 라이브러리
"rootDir": "src", // 시작 루트 폴더
"module": "CommonJS", // 컴파일 된 결과물이 사용할 모듈 방식
"esModuleInterop": true, // `true`로 설정할 경우, `ES6` 모듈 사양을 준수하여 `CommonJS` 모듈을 import 할 수 있다.
"target": "es5", // 최종적으로 컴파일하는 결과물의 문법 형태
"sourceMap": true, // `true` 인 경우 출력물에 `.js.map` 혹은 `.jsx.map` 파일을 포함한다.
"moduleResolution": "node", // 컴파일러가 각 import마다 어떤 모듈을 가리키는지 해석하는 과정.
"noImplicitReturns": true, // 리턴이 제대로 다 안 될 경우 에러를 알려준다. (오류를 강력하게 체크하는 용도)
"noImplicitThis": true, // this 표현식에 `any` 가 추론되면 에러를 알려준다.(any 사용을 막는 용도)
"noImplicitAny": // true, 암시적으로 선언된 타입이 `any`로 추론되면 에러를 알려준다.
"strictNullChecks": true, // null이나 undefined를 서브 타입으로 사용하지 못 하게 한다.
"allowJs": true // `.js` 파일도 컴파일 대상으로 포함한다
},
"include": ["./src"], // 컴파일 할 파일 경로(src 폴더 하위의 모든 파일)
"exclude": ["node_modules", "build"] // 컴파일 대상에서 제외할 파일
}
lib
- 정의하지 않을 경우 target
에 지정한 ECMAScript 버전에 따라 기본값이 정의된다."dom"
: DOM 관련 API를 호출해야 하므로 추가한다.
"es6"
: ES6 문법을 사용한다.
└▷ lib
은 typescript가 해당 문법과 가능이 있다는 걸 알게 해주는 것이지, 런타임에 해당 기능을 추가하는 게 아니다.
└▷ 따라서 target
이 ES5
인데 ES6 문법을 사용하면 에러가 발생한다. lib
에 ES6
를 추가하면 에러가 나지 않는다.
└▷ 단, 런타임이 ES5
만 지원한다면 런타임 에러가 발생한다.
module
- 컴파일 된 결과물이 사용할 모듈 방식esModuleUnterop
- 🎁 자세한 내용 참고
target
- tsc
가 최종적으로 컴파일하는 결과물의 문법 형태
ES5 : 화살표 함수가 function
표기법으로 변환된다.
tsc
로 결과물을 출력하는 게 아니라면 현재 코드에서 사용하는 문법을 기준으로 선택하면 된다.
sourceMap
- 🎁 .js.map 파일이란?
moduleResolution
- 🎁 자세한 내용 참고
node
: require()
함수를 이용하여 import를 해성하는 과정을 모방한 전략많고도 많다... 하나하나 뜯어보려니까 머리가 복잡하다. 자주 사용되는 옵션에 대해서만 일단 잘 알아두면 될 것 같다.
필수는 아니지만 마찬가지로 .eslintrc.js
파일을 만들어 eslint 설정도 추가한다.
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
extends: [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
rules: {
"prettier/prettier": [
"error",
{
doubleQuote: true,
semi: true,
useTabs: false,
tabWidth: 4,
printWidth: 80,
bracketSpacing: true,
arrowParens: "avoid",
},
],
},
parserOptions: {
parser: "@typescript-eslint/parser",
},
};
.js
컴포넌트 파일 확장자명을 .tsx
로 변경해준다.
index.js
→ index.tsx
App.js
→ App.tsx
Todo.js
→ Todo.tsx
TodoForm.js
→ TodoForm.tsx
난리가 났다. 이제 이것들을 해결해주는 게 이번 과제의 목표다!
const root = ReactDOM.createRoot(document.getElementById('root'));
React 18버전부터 루트 엘리먼트를 생성할 때 위와 같은 코드를 작성한다.
ReactDOM
- 애플리케이션의 최상위에서 사용 가능한 DOM 관련 메서드를 제공하는 패키지.
ReactDOM.createRoot
- 브라우저 DOM 노드 내부에 리액트 컴포넌트를 표시할 루트를 생성한다.
└▷ (document
의 id=root
엘리먼트를 가져와 React 엘리먼트의 root 노드로 쓴다)
root.render(렌더링 할 React 엘리먼트)
- 렌더링 할 리액트 엘리먼트를 인자로 보낸다..tsx
파일로 변환하면 바로 저 부분에서 첫 번째 에러를 마주하는데, 당연한 말이지만 타입을 명시하지 않았기 때문이다.
변수 root
에 들어가는 값이 어떤 타입인지 명시해야 한다.
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
해결책은 뒤에 as HTMLElement
를 작성하는 것이다.
여기서 잠깐,
typescript에서 변수에 대한 타입 명시는
const example: string = "문자열";
이라고 들었는데 as
는 뭐지? 그래서 알아본 결과
:type
(타입 선언) - 변수명 옆에 붙여 그 값이 선언된 타입임을 명시한다.
as type
(타입 단언) - 변수의 값 끝에 붙인다. 타입스크립트가 추론한 타입이 있더라도 단언한 타입으로 간주한다.
두 가지는 명확히 다른 기능을 가진 타입 명시 방법이었다.
지금은 root
변수에 들어가는 값이 HTMLElement
라는 게 당연하기 때문에 as
를 사용하지만,
웬만해서는 타입 단언보다는 타입 선언을 사용하라고 한다.
가장 먼저 직면한 문제는 바로 상태 관리에 관한 것이었다.
const [todos, setTodos] = useState([]);
지금까지 이론 및 (아주)간단한 실습으로 파악한 타입스크립트는 순수한 JavaScript 코드에 단순히 타입 명시를 해준 것 뿐이었는데,
리액트 앱을 타입스크립트 코드로 바꾸려면 상태 관리 라이브러리 같은 건 어떡하지..? 라는 생각이 들었다.
거기다가 고개를 조금 더 내려보니 아래 함수가 보였다.
const addTodo = todo => {
if (!todo.text || /^\s*$/.test(todo.text)) {
return;
}
const newTodos = [todo, ...todos];
setTodos(newTodos);
};
todo
를 인자로 받는 함수...? todo
가 뭔데... 당연히 객체겠지.
todo
라는 건 사용자가 입력하는 "할 일" 인 게 분명하고, 그 객체가 담고있는 속성은 무엇인지 보기 위해 addTodo
함수를 호출하는 TodoForm
로 이동했다.
보아하니 id
, text
, isComplete
세 가지 값을 가진 객체다.
이 객체를 타입으로 명시해야 한다.
export type Item = {
id:number;
text:string;
isComplete:boolean;
}
나는 일단 이렇게 Item
이라는 타입을 만들었다.
다른 컴포넌트에서도 사용될 것 같아 export
도 해줬다.
그럼 이제 상태관리나 매개변수에 대한 타입 명시는 보다 간단해졌다.
const [todos, setTodos] = useState<Item[]>([]);
todos
는 "할 일들 배열" 이 분명하고, useState
메서드는 react
에 TypeScript 형식으로 선언되어 있을 것이다. (typescript 어쩌고 react 모듈을 설치했기 때문에...)
확인해보니 이렇게 제네릭 타입으로 잘 명시되어 있는 게 보인다.
이제 나는 저 제네릭 타입의 메서드에 원하는 타입을 적으면 된다.
useState<Item[]>([])
이렇게! todos
는 Item
배열 타입인 거다.
addTodo 함수도 마찬가지로 타입을 명시해준다.
const addTodo = (todo: Item):void => {
if (!todo.text || /^\s*$/.test(todo.text)) {
return;
}
const newTodos: Item[] = [todo, ...todos];
setTodos(newTodos);
};
인자로 받는 todo
의 타입은 Item
이고, 함수의 반환값은 따로 없으므로 void
라 해준다.
변수 newTodos
도 todo
배열이므로 Item[]
타입을 명시한다.
Todo
컴포넌트로 들어오자 또 눈앞이 깜깜해졌다.
Todo
컴포넌트는 부모 컴포넌트 App
으로부터 무려 3가지 파라미터를 받는다.
function Todo({ todos, completeTodo, removeTodo })
todos
- 할 일 목록
completeTodo
- 할 일 완료 이벤트 함수
removeTodo
- 할 일 삭제 이벤트 함수
바로 이 세 가지다.
이것들에 대한 타입 명시를 어떻게 해야할지 막막했다.
나는 생각했다. 이걸 하나하나 다 적으면 겁나 복잡해 보이겠구나...
그래서 App
컴포넌트 때와 마찬가지로 별도의 type
을 정의했다.
type TodoProps<T extends Item> = {
todos: T[];
completeTodo: (todoId:number)=>void;
removeTodo: (todoId:number)=>void;
}
솔직히 함수에 대한 타입 선언을 저렇게 하는 건 코치님 Q&A 시간 때 알게 되었다. ㅎ
(일단 any
로 한 다음 오류만 벗어났었다)
게다가 TodoProps
에 제네릭 타입을 사용하여 <T extends Item>
이라 명시하다니!!! 너무 천재 같다고 느꼈다. 물론 내가 바보라서지만
나는 그냥
type TodoProps = {
todos: Item[];
...
}
이라고 했었기 때문이다!
코치님이 알려주신대로 하면 만약 Item
이라는 타입이 아주 복잡할 때 보수가 쉬워진다고 했다.
어쨌든 이렇게 TodoProps
타입을 만들고 나서
function Todo<T extends Item>({todos, completeTodo, removeTodo}:TodoProps<T>)
함수형 컴포넌트의 매개변수 타입을 위와 같이 명시 해주면 된다~~~
이제 거의 마지막에 다다랐다.
마지막 난관으로 useRef
를 발견했다.
useRef
역시 react
패키지에 타입스크립트 제네릭 형식으로 선언 된 메서드다. 이제 사용할 타입 값만 제네릭 타입으로 넘겨주면 되는데...
@types/react 의 index.d.ts
를 보면 useRef
에 대한 정의가 3개나 오버로딩 되어있다고 한다.
그 중에서 나는 초기값이 null
이면서 Input
태그에 접근하는 useRef 훅이라 할 수 있겠다.
그래서 이렇게 해줬다.
const inputRef = useRef<HTMLInputElement>(null);
근데 또 에러가 발생했다.
useEffect(() => {
inputRef.current.focus();
})
위 부분에서 inputRef.current
가 null
이기 때문에 접근할 수 없다는 것이다!
초기값을 null
로 주었기 때문이라고 생각
마운트 전/후 DOM 접근에 관한 문제
두 가지가 떠올라 일단 아래와 같이 조건문을 추가하였다.
useEffect(():void => {
if(inputRef.current)
inputRef.current.focus();
})
그런데 TypeScript로 포팅하기 전에는 잘만 실행되었는데, 왜 포팅하고 부터 저런 조건문을 달아야 하는 걸까?
여기에 대해서는 길종님에게 너무 멋진 답변을 받았다.
길종님은... 천재? 유료 결제 해야 들을 수 있을 것 같은 가르침을 받고 기립박수를 쳤다.
"타입스크립트가 컴파일 되는 시점"이라는 걸 생각치 않고, 코드가 실행되는 일련의 과정을 뭉뚱그려 생각한 내 자신을 반성하게 되었다.
또한 .js
파일이더라도 브라우저 환경이 아닌 경우엔 에러가 난다는 것.
(useEffect
가 실행되는 시점도 까먹고 있었다. jsx
가 들어가있는 return 문이 끝난 뒤 실행.)
나는 개발에 있어서 너무 당연한 배경을 까먹은 채로 접근하고 있던 것 같다.
useRef 훅에서 끝난 줄 알았는데 고쳐야 하는 부분이 하나 더 있었다.
바로 이벤트 리스너의 전달인자로 들어오는 이벤트 객체 e
에 대한 타입 명시다.
e: Event
? 이런거 끄적이고 있다가 검색해봤다.
결론적으로 타입 명시는 아래와 같다.
const handleChange = (e:React.ChangeEvent<HTMLInputElement>):void => {
setInput(e.target.value);
}
살짝... 헛웃음이 나왔다. 이벤트 종류별로, 요소 종류별로 기입해줘야 한다고...?
어차피 자주 쓰는 태그, 자주 부르는 이벤트만 사용하다가 익숙해지겠거니... 하고 그러려니 한다.
🎁 참고 블로그
이제 TypeScript로 작성한 리액트 앱을 JS로 컴파일 해서 제대로 작동하는지 알아봐야 한다!
npm run build
위 명령어로 배포 파일을 만든 다음에
npx serve -s build
해당 명령어로 배포 파일을 실행하면 ts 코드가 js로 잘 컴파일 되어 정상작동 하는 걸 볼 수 있다!
이번 과제에는 참고한 부분이 없지만 자료 찾다보니 자세히 다시 읽어보고 싶은 자료들