JavaScript을 TypeScript로 포팅하기

Kim-DaHam·2023년 6월 1일
0

JavaScript

목록 보기
18/18
post-thumbnail

🔥 학습목표

  • 페어 프로그래밍을 통해 JavaScript로 작성 된 React App을 TypeScript로 포팅할 수 있다.



🟩 Todo App

🟣 프로젝트 구조

⬜ 파일 구조

파일 구조는 다음과 같다.

React로 개발 된 간단한 Todo 애플리케이션이다. 포팅을 위해 만들어진 거라 엄청 단순하다 ㅎㅎ

Todo 목록을 보여주는 Todo 컴포넌트와 Todo 입력란인 TodoForm 컴포넌트로 구성된다.


⬜ 실행 화면

애플리케이션을 실행하면 아래와 같은 화면이 나타난다.

입력란에 문자열을 넣고 enter 혹은 addTodo 버튼을 누르면 그 아래 Todo 목록이 차례대로 추가된다.



🟣 TypeScript 환경 구성

⬜ 필요한 라이브러리 설치

가장 먼저 프로젝트에 필요한 라이브러리를 설치한다.

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 - 프리티어 패키지. 코드를 규칙에 따라 깔끔하고 예쁘게 정리해준다.



⬜ tsconfig.json

🎁 tsconfig.json 제대로 알고 사용하기

.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 문법을 사용한다.

      └▷ libtypescript가 해당 문법과 가능이 있다는 걸 알게 해주는 것이지, 런타임에 해당 기능을 추가하는 게 아니다.

      └▷ 따라서 targetES5인데 ES6 문법을 사용하면 에러가 발생한다. libES6를 추가하면 에러가 나지 않는다.

      └▷ 단, 런타임이 ES5만 지원한다면 런타임 에러가 발생한다.



  • esModuleUnterop - 🎁 자세한 내용 참고

  • target - tsc 가 최종적으로 컴파일하는 결과물의 문법 형태

    • ES5 : 화살표 함수가 function 표기법으로 변환된다.

    • tsc 로 결과물을 출력하는 게 아니라면 현재 코드에서 사용하는 문법을 기준으로 선택하면 된다.



🎁 공식문서-옵션 종류

많고도 많다... 하나하나 뜯어보려니까 머리가 복잡하다. 자주 사용되는 옵션에 대해서만 일단 잘 알아두면 될 것 같다.


⬜ .eslintrc.js

필수는 아니지만 마찬가지로 .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.jsindex.tsx

  • App.jsApp.tsx

  • Todo.jsTodo.tsx

  • TodoForm.jsTodoForm.tsx

난리가 났다. 이제 이것들을 해결해주는 게 이번 과제의 목표다!



🟣 index.tsx

⬜ root Element 생성

const root = ReactDOM.createRoot(document.getElementById('root'));

React 18버전부터 루트 엘리먼트를 생성할 때 위와 같은 코드를 작성한다.

  • ReactDOM - 애플리케이션의 최상위에서 사용 가능한 DOM 관련 메서드를 제공하는 패키지.

  • ReactDOM.createRoot - 브라우저 DOM 노드 내부에 리액트 컴포넌트를 표시할 루트를 생성한다.

└▷ (document의 id=root 엘리먼트를 가져와 React 엘리먼트의 root 노드로 쓴다)

  • root.render(렌더링 할 React 엘리먼트) - 렌더링 할 리액트 엘리먼트를 인자로 보낸다.

⬜ as (타입 단언)

.tsx 파일로 변환하면 바로 저 부분에서 첫 번째 에러를 마주하는데, 당연한 말이지만 타입을 명시하지 않았기 때문이다.

변수 root 에 들어가는 값이 어떤 타입인지 명시해야 한다.

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

해결책은 뒤에 as HTMLElement 를 작성하는 것이다.

여기서 잠깐,

typescript에서 변수에 대한 타입 명시는

const example: string = "문자열";

이라고 들었는데 as 는 뭐지? 그래서 알아본 결과

  • :type (타입 선언) - 변수명 옆에 붙여 그 값이 선언된 타입임을 명시한다.

  • as type (타입 단언) - 변수의 값 끝에 붙인다. 타입스크립트가 추론한 타입이 있더라도 단언한 타입으로 간주한다.

두 가지는 명확히 다른 기능을 가진 타입 명시 방법이었다.

지금은 root 변수에 들어가는 값이 HTMLElement 라는 게 당연하기 때문에 as 를 사용하지만,

웬만해서는 타입 단언보다는 타입 선언을 사용하라고 한다.



🟣 App.tsx

⬜ type 분류하기

가장 먼저 직면한 문제는 바로 상태 관리에 관한 것이었다.

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도 해줬다.


⬜ TypeScript로 상태 관리

그럼 이제 상태관리나 매개변수에 대한 타입 명시는 보다 간단해졌다.

const [todos, setTodos] = useState<Item[]>([]);

todos 는 "할 일들 배열" 이 분명하고, useState 메서드는 react 에 TypeScript 형식으로 선언되어 있을 것이다. (typescript 어쩌고 react 모듈을 설치했기 때문에...)

확인해보니 이렇게 제네릭 타입으로 잘 명시되어 있는 게 보인다.

이제 나는 저 제네릭 타입의 메서드에 원하는 타입을 적으면 된다.

useState<Item[]>([])

이렇게! todosItem 배열 타입인 거다.


⬜ addTodo() 함수

addTodo 함수도 마찬가지로 타입을 명시해준다.

const addTodo = (todo: Item):void => {
    if (!todo.text || /^\s*$/.test(todo.text)) {
      return;
    }

    const newTodos: Item[] = [todo, ...todos];

    setTodos(newTodos);
  };

인자로 받는 todo의 타입은 Item 이고, 함수의 반환값은 따로 없으므로 void 라 해준다.

변수 newTodostodo 배열이므로 Item[] 타입을 명시한다.



🟣 Todo.tsx

⬜ 매개변수 타입 명시

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>)

함수형 컴포넌트의 매개변수 타입을 위와 같이 명시 해주면 된다~~~



🟣 TodoForm.tsx

⬜ useRef 타입 명시

이제 거의 마지막에 다다랐다.

마지막 난관으로 useRef 를 발견했다.

useRef 역시 react 패키지에 타입스크립트 제네릭 형식으로 선언 된 메서드다. 이제 사용할 타입 값만 제네릭 타입으로 넘겨주면 되는데...

@types/react 의 index.d.ts를 보면 useRef 에 대한 정의가 3개나 오버로딩 되어있다고 한다.

그 중에서 나는 초기값이 null 이면서 Input 태그에 접근하는 useRef 훅이라 할 수 있겠다.

그래서 이렇게 해줬다.

const inputRef = useRef<HTMLInputElement>(null);

근데 또 에러가 발생했다.

useEffect(() => {
    inputRef.current.focus();
})

위 부분에서 inputRef.currentnull 이기 때문에 접근할 수 없다는 것이다!

  • 초기값을 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로 잘 컴파일 되어 정상작동 하는 걸 볼 수 있다!



이번 과제에는 참고한 부분이 없지만 자료 찾다보니 자세히 다시 읽어보고 싶은 자료들

🎁 TypeScript-함수를 인자로 받을 때 인자의 개수가 더 적어도 되는 이유

🎁 타입스크립트 함수 문법 다루기

profile
다 하자

0개의 댓글