방구석에 놔둔 개발 노트

1년차 웹 / 앱 프론트엔드 엔지니어의 좌충우돌 얼렁뚱땅 앞뒤짱구 생존기

2023-01-12: TypeScript 학습 (3)

TypeScript를 이용해 React에서 Todo를 구현해보자.

강의 내용에 따라 TypeScript가 포함된 CRA를 설치해보고, 컴포넌트도 만들어보고 이제 props에 타입을 설정해보는 것도 배웠다.

이번 시간에는 이전에 배운 내용들을 기반으로 강의 내용에 따라 TodoList를 계속해서 만들어보도록 하겠다.

목차

1️⃣ li 요소를 하위 컴포넌트로 분리하기

2️⃣ 이벤트 객체의 타입 정의

3️⃣ HTML 요소의 타입 지정 및 !? 연산자

4️⃣ 함수 타입을 정의하기

5️⃣ TypeScript에서 useState의 사용

1️⃣ li 요소를 하위 컴포넌트로 분리하기

이전에 작성한 코드를 보면 아래와 같이 li 요소를 통해 Todo의 내용들을 출력하고 있다.

import React from "react";
import Todo from "../models/todo";

const Todos: React.FC<{ items: ***Todo[]*** }> = (props) => {
  return (
    <ul>
      {props.items.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
};

export default Todos;

이제 이 li 요소를 하위 컴포넌트로 만들어 받아온 Todo의 내용을 출력해보고자 한다. 출력해볼 수 있는 방법은 Todo의 속성들을 배치해서 넘기는 방법Todo 자체를 넘겨서 컴포넌트 내부에서 속성들을 배치하는 방법이 있다.

우선은 Todo의 속성들을 배치해서 넘기는 방법이다. 이 내용은 위와 크게 다르지 않다. 먼저 li를 담고 있는 하위 컴포넌트인 TodoItem부터 살펴보자.

import React from "react";
import Todo from "./todo";

const TodoItem: React.FC<{ **text: string** }> = (props) => {
  return <li>{**props.text**}</li>;
};

export default TodoItem;

위와 같이 Todo의 속성들을 받아온다면, 받아올 속성들의 타입을 지정해주도록 한다. 이렇게 만든 TodoItem 컴포넌트를 아래의 Todos 컴포넌트에 배치해 보내줄 prop들을 추가해주자.

import React from "react";
import Todo from "../models/todo";
import TodoItem from "../models/todoitem";

const Todos: React.FC<{ items: Todo[] }> = (props) => {
  return (
    <ul>
      {props.items.map((item) => (
          <TodoItem key={item.id} text={item.text} />
      ))}
    </ul>
  );
};

export default Todos;

이제 다른 방법으로 Todo 자체를 넘겨서 컴포넌트 내부에서 속성들을 배치하는 방법이다. 다른 점은 위의 TodoItem에서 제네릭 타입의 정의와 li에 배치되는 속성값들에 차이가 있다.

import React from "react";
import Todo from "./todo";

const TodoItem: React.FC<{ todo: Todo }> = (props) => {
  return <li>{props.todo.text}</li>;
};

export default TodoItem;

위처럼 제네릭에 todo라는 propTodo를 타입으로 지정해주고, li 내부의 텍스트를 Todo가 담고있는 text로 받아오게 하면 된다.

Todos 컴포넌트의 코드는 아래와 같이 바꿔주자.

import React from "react";
import Todo from "../models/todo";
import TodoItem from "../models/todoitem";

const Todos: React.FC<{ items: Todo[] }> = (props) => {
  return (
    <ul>
      {props.items.map((item) => (
          <TodoItem key={item.id} todo={item} />
      ))}
    </ul>
  );
};

export default Todos;

전달해줘야하는 건 Todo 그 자체이므로 item을 보내주되, key 값은 생성해줄 필요가 있으므로 해당 값만 따로 이쪽에 배치해주도록 하면 된다.

2️⃣ 이벤트 객체의 타입 정의

이제 유저가 입력할 입력 폼을 만들고 타입을 지정해보려고 한다. 아래와 같이 NewTodo 컴포넌트 만들고 입력 폼을 생성해봤다.

const NewTodo = () => {
  const submitHandler = (e) => {
        // 에러: 'e' 매개 변수에는 암시적으로 'any' 형식이 포함됩니다.
    e.preventDefault();
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">Todo text</label>
      <input type="text" id="text" />
      <button>Add Todo</button>
    </form>
  );
};

export default NewTodo;

위와 같이 코드를 입력할 경우, 매개변수인 e의 타입이 암시적으로 any 타입으로 지정된다는 경고 메시지가 출력된다.

JavaScript였다면 크게 신경쓸 일은 아니지만, TypeScript는 이런 점에 민감하기 때문에 타입을 반드시 지정해줘야 한다.

React를 공부하면서 저 e라는 매개변수에는 이벤트 객체가 들어오게 되고, 그 이벤트 객체를 조사해보면 다양한 속성과 메소드들이 존재하고 있다는 것을 알 수 있다.

이 역시 일일이 다 타입을 지정할 수는 없는 노릇인데, 어떻게하면 저 이벤트 객체 타입을 손쉽게 정의내릴 수 있을까?

방법은 React에서 지원해주는 타입을 이용해주면 된다. 아래의 코드를 보자.

const NewTodo:React.FC = () => {
  const submitHandler = (e: React.FormEvent) => {
    e.preventDefault();
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">Todo text</label>
      <input type="text" id="text" />
      <button>Add Todo</button>
    </form>
  );
};

export default NewTodo;

앞서 컴포넌트에 타입을 선언할 때, 함수형 컴포넌트라면 우리는 타입을 React.FC로 정의를 내렸다. 이는 React에서 미리 정의해둔 타입이 있기 때문에 가능한 일인데, 이벤트 객체 또한 React가 미리 정의해둔 여러가지 타입이 존재한다.

여기서는 form 태그에 있는 submit 이벤트를 적용하기 위해 React.FormEvent를 이벤트 객체의 타입으로 정의했다.

React에서는 다양한 종류의 이벤트 객체 타입을 지원해주고 있다. FormEvent 뿐만 아니라 드래그와 관련한 DragEvent, 클릭 등의 마우스와 관련한 MouseEvent, 그 밖에도 TouchEvent, FocusEvent 등 다양한 이벤트 타입이 존재한다.

향후 이렇게 요소에 이벤트를 배치할 때, 어떤 이벤트를 사용하느냐에 따라서 이벤트 객체에 내릴 타입 정의가 달라질 수 있으니 자신이 사용하는 이벤트를 잘 알아두고 타입을 정의하도록 하자.

3️⃣ HTML 요소의 타입 지정 및 !? 연산자

이제 input 요소에 입력한 값을 submit 이벤트가 받아오도록 하는 내용을 작성해보려고 한다. 보통 이 경우에 useState와 onChange 이벤트를 이용한 방법도 있지만 여기서는 useRef를 이용해서 코드를 작성하겠다.

useRef를 사용할 경우, useRef의 제네릭에 어느 요소를 우리가 참고하고 싶은지 타입을 정의해줘야 한다.

따라서 아래와 같이 코드를 작성하자.

import { useRef } from "react";

const NewTodo: React.FC = () => {
  const todoTextInputRef = **useRef<HTMLInputElement>(null);**
  const submitHandler = (e: React.FormEvent) => {
    e.preventDefault();
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">Todo text</label>
      <input type="text" id="text" ref={todoTextInputRef} />
      <button>Add Todo</button>
    </form>
  );
};

export default NewTodo;

inputHTMLInputElement, h1HTMLHeadElement, pHTMLParagraphElement, buttonHTMLButtonElement 등 TypeScript에서 모든 DOM 요소들은 미리 정의된 타입을 가지고 있다.

우리가 useRef에서 참고하고자 하는 것은 input이므로, 여기서는 HTMLInputElement를 타입으로 정의해줬다.

또한 useRef 선언 시에는 당장 참고할 값을 지정하지 않았으므로, 초기값을 null로 지정해줬다.

자, 이제 inputref prop에 useRef를 지정한 변수 todoTextInputRef를 배치하면 useRef가 정상적으로 적용될 것이다.

다음으로 유저가 값을 입력하고 엔터 키를 누르든 Add Todo 버튼을 누르든 하면 값을 검증하는 로직을 아래 코드와 같이 추가해봤다.

import { useRef } from "react";

const NewTodo: React.FC = () => {
  const todoTextInputRef = useRef<HTMLInputElement>(null);
  const submitHandler = (e: React.FormEvent) => {
    e.preventDefault();

    const enteredText = todoTextInputRef.current?.value;
        // useRef를 통해 input 요소의 현재값으로 초기화한 변수, enteredText
    if (enteredText?.trim().length === 0) {
            // 해당 변수의 길이가 0일 때, 즉 빈값이면 에러를 출력하도록 한다.
      return;
    }
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">Todo text</label>
      <input type="text" id="text" ref={todoTextInputRef} />
      <button>Add Todo</button>
    </form>
  );
};

export default NewTodo;

변수 enteredText를 통해 현재 input 요소의 current value를 참고하도록 초기화해줬다. 이 때 해당 변수의 코드를 작성하면 자동으로 current 뒤에 ?가 붙는 것을 볼 수 있다.

이는 TypeScript가 todoTextInputRef가 참조하는 inputcurrent value에 값이 없을 경우를 대비해서 자동으로 ?를 붙게 해줬다.

TypeScript에서의 이 ?는 두 가지 용도로 쓰인다.

  • 선택적 프로퍼티: 물음표가 붙은 값은 포함해도 되고, 포함되지 않아도 된다.

      import React from "react";
      import Todo from "./todo";
    
      const TodoItem: React.FC<{ todo: Todo, name?: string }> = (props) => {
      // 여기서의 name은 string 타입으로 지정은 되어 있으나, 꼭 필요한 값이 아님을 가리킨다.
        return <li>{props.todo.text}</li>;
      };
    
      export default TodoItem;
    
  • 옵셔널 체이닝: JavaScript의 옵셔널 체이닝을 가리키며 프로퍼티의 타입이 null이나 undefined가 올 수 있는 경우가 있음을 알려준다.

    • 변수 enteredText에 붙은 ?가 이 옵셔널 체이닝이다.

그럼 ?가 아닌 !는 TypeScript에서 어떤 의미로 쓰일까? 우선 JavaScript의 논리 연산자로서 false의 의미를 담고 있는 것은 물론 있지만 위의 옵셔널 체이닝과 관련해 반대적인 의미를 가진 연산자로서도 쓰인다.

  • null 아님을 주장하는 연산자: Not Null 어선셜(assertion) 연산자라고도 말한다. 받아오는 값은 null이 아니며 할당된 값이 반드시 존재한다고 주장한다.

      type Person {
          firstName: string | null,
          lastName: string;
      }
    
      let human: Person;
    
      human = {
          firstName: null,
          lastName: "Mac"
      }
    
      console.log(human.firstName);
      // 이 경우, firstName의 값은 null로 받아와지므로 해당 값이 null일 수 있다 경고를 출력한다.
    
      human = {
          firstName: "Donald",
          lastName: "Mac"
      }
    
      console.log(human!.firstName);
      // 이 경우, firstName은 반드시 null이 아닌 할당된 값이 존재한다는 컴파일러에게 전달해준다.
    
  • 확정 할당 주장: !를 붙인 변수나 객체는 반드시 값을 가지고 있음을 주장한다.

      let x: number
      console.log( x + x );
      // 이 경우, x를 사용하기 전에 x에 값을 할당하라는 에러를 띄워준다.
    
      let x!: number
      // 이 경우, x는 반드시 값을 가지고 있음을 컴파일러에게 전달한다.
      console.log( x + x );
      // 컴파일러는 x에 값이 반드시 있다고 생각하고 위의 코드에
      // x에 값을 할당하라는 에러를 띄우지 않는다.
    

이처럼 TypeScript에서는 ?!를 통해 좀 더 해당 타입이 받아오는 값들을 명확하게 해줄 수 있다.

4️⃣ 함수 타입을 정의하기

이제, 입력한 Todo 값을 받아와 리스트로 보내줘 화면 상에 출력시키는 내용을 구현해보고자 한다.

우선은 이전의 NewTodo에서 정상적으로 값을 받아왔다면, 상위 컴포넌트인 App으로 보내주기 위한 코드를 아래와 같이 작성했다.

import { useRef } from "react";

const NewTodo: React.FC<{ **onAddTodo: (text: string) => void** }> = (props) => {
  const todoTextInputRef = useRef<HTMLInputElement>(null);
  const submitHandler = (e: React.FormEvent) => {
    e.preventDefault();

    const enteredText = todoTextInputRef.current?.value;
    if (enteredText?.trim().length === 0) {
      return;
    }

    **props.onAddTodo(enteredText!);**
        // 위에서 말한 대로 enteredText에는 값이 반드시 있다는 것을 명확하기 위해 !를 붙였다.
  };

  return (
    <form onSubmit={submitHandler}>
      <label htmlFor="text">Todo text</label>
      <input type="text" id="text" ref={todoTextInputRef} />
      <button>Add Todo</button>
    </form>
  );
};

export default NewTodo;

상위 컴포넌트로 값을 보내주기 위한 함수로서 onAddTodo를 생성했다. 보내줄 값은 유저가 입력한 내용이므로 enteredText가 들어간다.

하지만, onAddTodoprop에 없는 메소드이기 때문에 우리는 제네릭을 통해서 onAddTodo의 타입을 지정해줄 필요가 있다.

앞서 함수의 타입은 매개변수와 반환값의 타입을 지정한다고 했다.

여기서 유저가 입력한 값인 enteredText는 문자열이므로 onAddTodo의 매개변수인 text는 문자열로 정의했으며, 단지 enteredText를 가져다 줄 뿐인 내용이므로 별도의 반환값은 필요 없기에 return이 없다는 의미에서 void를 반환값의 타입으로 정의했다.

5️⃣ TypeScript에서 useState의 사용

자, 그러면 App 컴포넌트로 돌아가서 해당 컴포넌트가 onAddTodo를 받아올 수 있도록 수정하자.

import NewTodo from "./components/NewTodo";
import Todos from "./components/Todos";
import Todo from "./models/todo";

function App() {
  const todos = [new Todo("Learn React"), new Todo("Learn TypeScript")];

  const addTodoHandler = (todoText: string) => {};

  return (
    <div className="App">
      <NewTodo onAddTodo={addTodoHandler} />
      <Todos items={todos} />
    </div>
  );
}

export default App;

NewTodo에서 onAddTodo의 타입을 정의했기 때문에 App 컴포넌트에서는 NewTodoonAddTodo prop을 요구할 것이다.

앞서 onAddTodoprop의 메소드, 즉 함수로 정의했으므로 onAddTodo가 쓰여질 함수인 addTodoHandler를 생성하고, onAddTodo의 매개변수 타입을 string를 받았으니 여기서도 매개변수의 타입을 동일하게 string으로 받게 했다.

이제, useState를 통해서 NewTodo를 통해 입력한 값을 배열 속에 넣어 리스트로 출력하는 내용을 작성해볼 것이다.

**import { useState } from "react";**
import NewTodo from "./components/NewTodo";
import Todos from "./components/Todos";
import Todo from "./models/todo";

function App() {
  **const [todos, setTodos] = useState([]);**
  const addTodoHandler = (todoText: string) => {};

  return (
    <div className="App">
      <NewTodo onAddTodo={addTodoHandler} />
      <Todos items={todos} />
    </div>
  );
}

export default App;

useState를 사용하는 것 자체는 기존 React에서 쓰는 것과 동일하다. 다만, 다른 점이 있다면 initialState, 즉 초기값에 관한 부분인데 유저가 입력한 Todo 내용이 추가될 때마다 배열에 넣기 위해 현재 초기값으로 빈 배열을 적용했다.

하지만, 여기서 todos를 확인하면 todos의 타입이 array가 아닌 never라는 타입으로 정의되는 걸 볼 수 있다.

never 타입은 값이 없음을 나타내는데, 여기서 initialState는 빈 배열로 넣어지면서 값이 없다고 판단해 타입을 never로 정의해버리면서 todos에는 어떠한 값도 넣을 수 없게 만든다.

이로 인해 setState 함수를 사용해 새로운 값을 넣고 싶어도 타입 에러가 발생하므로 never 타입이 적용되지 않도록 수를 써야 한다.

다행히 React에서는 이러한 점을 고려하여 useState에 제네릭을 쓸 수 있도록 허용해 타입이 유연하게 설정될 수 있도록 했다.

이 제네릭을 이용해 우리가 받아올 배열이 class로 정의한 Todo 타입이 들어오게 정의해주자.

const [todos, setTodos] = useState**<Todo[]>**([]);
// 이제 todos는 Todo[] 타입으로 정의된다.

이제, 작성한 Todo를 배열에 넣어 리스트로 나오게 만들어주자.

함수 addTodoHandler를 통해 NewTodo에서 입력한 값을 class Todo의 형식에 따른 객체로 만들어주고 이를 setState 함수인 setTodos를 통해 배열에 추가했다.

import { useState } from "react";
import NewTodo from "./components/NewTodo";
import Todos from "./components/Todos";
import Todo from "./models/todo";

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const addTodoHandler = (todoText: string) => {
    **const newTodo = new Todo(todoText);
    setTodos([...todos, newTodo]);**
  };

  return (
    <div className="App">
      <NewTodo onAddTodo={addTodoHandler} />
      <Todos items={todos} />
    </div>
  );
}

export default App;

setTodos에 기존 배열인 todos를 전개 구문을 활용해 새로운 값인 newTodo가 추가되도록 했다.

이제 만들어진 내용을 테스트하면, 아래와 같이 잘 작동된다.

⚠️ 전개 구문이 아닌 .concat()을 통해 배열을 만드는 방법

위의 방법은 이전에 전개 구문을 학습한 적이 있고, 프로젝트를 진행할 때 자주 사용했기 때문에 해당방법으로 작성할 수 있었다.

그러나, 강의에서는 전개 구문 방법이 아닌 .concat()이라는 배열의 메소드를 활용하여 배열을 새로 만드는 형태로 코드를 만들었으므로 해당 내용을 여기에도 기록하고자 한다.

우선 .concat() 메소드에 대해 설명하자.

.concat() 는 배열의 메소드이며, 인자로 받은 배열이나 값을 기존 배열에 합쳐 새로운 배열로 만들어주는 메소드이다.

const alpha = ['a', 'b', 'c'];
const numeric = [1, 2, 3];

alpha.concat(numeric);
// 결과: ['a', 'b', 'c', 1, 2, 3]

이처럼 기존 배열에 .concat()의 인자값을 받아와 기존 배열과 합쳐 새로운 배열을 만들어낸다.

이 메소드를 이용하여 기존 todos 배열에 newTodo를 추가하는 방법은 아래와 같이 작성된다.

setTodos((prevTodos) => {
  return prevTodos.concat(newTodo);
});

setTodos의 매개변수로 기존 배열을 받아오는 prevTodos.concat() 메소드르 사용하여 새로운 값인 newTodo를 추가하게 해줬다.

이렇게 작성하면 앞에서 구조 전개 문법으로 만든 새로운 배열과 동일한 배열을 만들 수 있다.

📁 참고 자료

【한글자막】 React 완벽 가이드 2024 with React Router & Redux

타입스크립트에서 이벤트 객체 타입 지정하기

[TypeScript]타입스크립트 물음표(?), 선택적 프로퍼티, 옵셔널 체이닝

[TypeScript]타입스크립트 느낌표(!) 사용

Array.prototype.concat() - JavaScript | MDN