본문 바로가기

[TS] React 컴포넌트를 TypeScript로 포팅하기

[TS] React 컴포넌트를 TypeScript로 포팅하기

본 포스팅은 이미 JavaScript로 작성된 React App(프레임워크는 CRA)을 TypeScript로 포팅해보려고 한다. 그 중, 새로 알게된 점을 위주로 정리 차원의 포스팅으로 모든 코드가 나와있지 않다.

 

1. 라이브러리 설치

 

해당 프로젝트 터미널 내에서 npm을 사용하여 React 프로젝트에 필요한 라이브러리를 설치한다.

  • @types/react와 @types/react-dom: React와 React DOM에 대한 TypeScript 타입 정의 파일
  • @typescript-eslint/eslint-plugin와 @typescript-eslint/parser: TypeScript 프로젝트에서 ESLint를 사용하기 위한 플러그인과 파서 (ESLint는 JavaScript와 TypeScript 코드의 일관성과 품질을 검사하는 도구)
  • eslint-config-prettier와 eslint-plugin-prettier: Prettier와 ESLint의 통합을 제공하는 패키지 (Prettier는 코드 포맷팅 도구로, 코드 스타일을 일관성 있게 유지하는 데 도움) 이 패키지들을 사용하여 ESLint와 Prettier를 함께 사용할 수 있음
  • eslint-plugin-react와 eslint-plugin-react-hooks: React 애플리케이션에 대한 추가적인 ESLint 규칙과 플러그인 (React 컴포넌트와 훅과 관련하여 잠재적인 문제를 검출하고 해결하는 데 도움)
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

 

eslint 에러가 뜨는 모습

 

2. 환경 구성

 

(1) tsconfig.json

{
    "compilerOptions": {
        "jsx": "react-jsx",
        "lib": ["es6", "dom"],
        "rootDir": "src",
        "module": "CommonJS",
        "esModuleInterop": true,
        "target": "es5",   // 6 하면?
        "sourceMap": true,
        "moduleResolution": "node",
        "noImplicitReturns": true,
        "noImplicitThis": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "allowJs": true
    },
    "include": ["./src"],
    "exclude": ["node_modules", "build"]
}

 

(2) .eslintrc.js

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",
  },
};

 

3. TS로 포팅하기

 

(1-1) index.js

멘토님께 들은 꿀팁인데 CRA 공식문서의 <Adding TypeScript> 챕터에서 템플릿을 세팅한 후, 거기서 위의 2번의 내용(tsconfig.json) 등 초기 세팅에 대한 감을 잡으면 좋다고 하셨다. 

 

그렇게 세팅하고 보니, index.js의 코드가 타입스크립트로 이렇게 작성되어 있었다.

 

index.js (확장자만 tsx로 바꾼 상태)
  • 해당 오류는 타입스크립트가 document.getElementById('root')의 반환 타입을 Element | DocumentFragment로 예상하고 있지만, 실제로는 HTMLElement | null이 반환됐기 때문에 발생하는 것이다.
  •  getElementById 메서드는 해당 ID를 가진 요소를 찾으면 해당 요소를 반환하고, 찾지 못하면 null을 반환한다.

 

시도 1) 타입 단언으로 index.js를 포팅한 코드 (웬만하면 타입 단언은 쓰지 않는 습관을 들이자!!)
const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

 

시도 2) null 체크를 명시적으로 수행하기
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");

const root = ReactDOM.createRoot(rootElement);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

 

 

시도 3) 강제 단언 연산자(!)를 사용하여 타입 단언하기 (웬만하면 타입 단언은 쓰지 않는 습관을 들이자!!)
  • 쉽게 말하면 개발자가 컴파일러에게 약을 파는 것이다. "나만 믿어! null 절대절대 안 나와. 진짜야@@"
  • 즉, getElementById의 반환 타입을 Element | DocumentFragment로 강제 단언하여 null이 아님을 단언한다. 
  • 하지만 이 방법은 getElementById가 null을 반환하는 경우 런타임 에러가 발생할 수 있으므로, 해당 요소가 항상 존재함을 확신할 수 있는 경우에만 사용해야 한다. 
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

 

 

(1-2) index.tsx

시도 4) 타입을 명시해주었다.
const rootElement: HTMLElement | null = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element"); // 🟣 설명(1)
const root: ReactDOM.Root = ReactDOM.createRoot(rootElement);  // 🟣 설명(2)

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

 

🟣 설명 (1) 예외 처리를 해줘야 하는 이유?

해주지 않는 경우, getElementById가 해당 id를 가진 요소를 찾지 못한 경우 null을 반환시키기 때문에 아래와 같이 에러가 뜬다.

 

🟣 설명 (2) ReactDOM.Root 타입은 뭐지?

ReactDOM의 타입 중 하나로 React 애플리케이션을 렌더링하기 위한 Root 인스턴스를 나타낸다.

  • 일반적으로 ReactDOM.createRoot() 메서드를 사용하여 Root 인스턴스를 생성한다. 
  • createRoot() 메서드는 ReactDOM.Root 타입의 인스턴스를 반환한다.
    • ReactDOM.Root 인스턴스는 가상 돔의 업데이트를 관리하는 주체이다.
    • 다시 말해, React 애플리케이션의 루트 컴포넌트를 가상 돔에 렌더링하고, 필요한 경우 가상 돔을 업데이트하여 실제 DOM에 반영하는 역할을 담당한다. 
    • ReactDOM.createRoot() 메서드를 사용하여 ReactDOM.Root 인스턴스를 생성하고, 생성된 인스턴스에 대해 render() 메서드를 호출하여 가상 돔의 초기 렌더링 및 업데이트를 수행한다.

 

(2-1) App.js

  • Todos 컴포넌트는 'Todo list'를 렌더링하는 메인 컴포넌트이다.
기존 코드
export default function Todos() {
  const [todos, setTodos] = useState([]);

  const addTodo = (todo) => {
    if (!todo.text || /^\s*$/.test(todo.text)) {
      return;
    }
    const newTodos = [todo, ...todos];
    setTodos(newTodos);
  };
  .
  .
  .
    return (
    <div>
      <div className="todo-app">
        <h1>To Do List</h1>
        <h2>오늘은 무슨 일을 계획하나요?</h2>
        <TodoForm onSubmit={addTodo} />
        <Todo
          todos={todos}
          completeTodo={completeTodo}
          removeTodo={removeTodo}
        />
      </div>
    </div>
  );
  .
  .

 

(2-2) App.tsx

포팅한 코드
  • 우선 srx/types 폴더를 만들어서, 다른 컴포넌트에서도 중복 사용이 되는 TodoItem의 인터페이스를 만들어주고, export 해주었다.
export interface TodoItem {
  id: number;
  isComplete: boolean;
  text: string;
}

 

  • 그 후, TodoItem을 import 하여 사용해주었다.
import { TodoItem } from "./types/TodoItemType";
.
.
.
export default function Todos() {
 // useState의 경우 초기 상태가 명확하지 않을 때나 타입이 복잡한 구조인 경우에는 명시해준다.
  const [todos, setTodos] = useState<TodoItem[]>([]); 

  const addTodo = (todo: TodoItem) => {
    if (!todo.text || /^\s*$/.test(todo.text)) {  // 🟣 설명(1)
      return;
    }
    const newTodos = [todo, ...todos];
    setTodos(newTodos);
  };
  .
  .
  .

 

🟣 설명 (1)  /^\s*$/.test

/^\s*$/.test는 정규 표현식을 사용하여 문자열이 공백 문자로만 이루어져 있는지를 확인하는 코드이다. (예: " ")

  •  / : 정규 표현식 시작 
  • ^ : 문자열의 처음부터
  • $ : 문자열의 끝까지  
  • \s : 공백 문자가   
  • * : 0회 이상 반복해서(빈문자열 포함)   
  • .test : 나타나고 있니?   
  • / : 정규 표현식 끝  
따라서 !todo.text는 text의 값이 undefined, null, '' 인 경우를 걸러주고, 위의 정규 표현식은 공백 문자로 이루어진 '  ' 의 경우를 걸러준다고 할 수 있겠다.

 

(3) Todo.tsx

.
.
interface TodosProps {
  todos: TodoItem[];
  removeTodo: (id: number) => void;  // 🟣 설명(1)
  completeTodo: (id: number) => void;
}

export default function Todo({ todos, completeTodo, removeTodo }: TodosProps) {
  // : FC<TodoProps> ? 🟣 설명(2)
  .
  .

 

🟣 설명 (1)  Todo 컴포넌트의 속성의 타입 정의

부모로부터 전달받은 prop의 데이터 type도 선언해준다.

  • 자식 컴포넌트에서 명시해 놓은 데이터 타입과 부모로부터 넘겨받은 데이터 타입이 일치하지 않으면 콘솔에 에러 경고문이 띄워진다.
  • 부모 컴포넌트에서 이미 todos에 대한 타입 선언을 해줬는데 자식 컴포넌트에서 또 해주는 이유가 뭔지 궁금하다.
// 함수 넘기는 법

  func(): void // func: () => void 와 같다. 파라미터와 함수의 return이 없을 경우 
  func2(id: number): void // 파라미터가 있고 함수의 return이 없을 경우
  func3(id: string): TodoItem // 파라미터와 return값이 있을 경우

 

🟣 설명 (2)  React.FC를 써야 하나? 

React.FC는 React 함수형 컴포넌트의 타입을 정의하기 위한 방식 중 하나이다. React.FC를 쓰지 않는 것을 추천한다는 블로그 글이 있던데, 이유는 아래와 같다.

  • React.FC는 기본적으로 컴포넌트가 children prop을 자동으로 포함하도록 정의되어 있다. 
  • 따라서 children prop을 명시적으로 사용하고 싶지 않은 경우에도 강제로 포함되기 때문에 일부 혼동을 야기할 수 있다. 
  • 또한 React.FC는 기본적으로 모든 prop이 선택적으로 처리되기 때문에 필수 prop을 명시적으로 지정하기 어렵다.

최신의 React 버전에서는 React.FC보다는 위와 같이 props 옆에 타입을 정의해주는 방식을 사용하는 것이 권장된다고 한다.

 // FC의 구조는 아래와 같다.
 
 type FC<P = {}> = FunctionComponent<P>;

    interface FunctionComponent<P = {}> {
        (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
        propTypes?: WeakValidationMap<P> | undefined;
        contextTypes?: ValidationMap<any> | undefined;
        defaultProps?: Partial<P> | undefined;
        displayName?: string | undefined;
    }

 

  • 만~~~약 필요에 의해 React.FC를 쓰는 경우 아래와 같이 제네릭 타입으로 TodosProps 타입을 지정해줄 수 있겠다.
const Todo: React.FC<TodosProps> = ({ todos, completeTodo, removeTodo }) => {
.
.
export default Todo;

 

최근에는 컴포넌트의 타입을 지정하지 않고 화살표 함수나 함수 선언문으로 컴포넌트를 정의하는 방식이 일반적이다. 코드의 가독성을 향상시키고 불필요한 제약을 피할 수 있으며, TypeScript의 타입 추론 기능을 활용할 수 있기 때문이다. 타입을 명시적으로 지정하고 싶거나, 속성(props)의 타입이 복잡한 경우에는 인터페이스를 사용하여 타입을 정의할 수 있다. 

 

(4) TodoForm.tsx

interface TodosProps {
  onSubmit: (todo: TodoItem) => void;
}

export default function TodoForm({ onSubmit }: TodosProps) {
.
.
  const inputRef = useRef<HTMLInputElement | null>(null); // 🟣 설명(1) 

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

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { // 🟣 설명(2) 
    setInput(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent) => { // 🟣 설명(2) 
    
    e.preventDefault();
.
.
    onSubmit({
      id: number,
      text: input,
      isComplete: false,
    });
 .
 .

 

🟣 설명 (1)  const inputRef = useRef<HTMLInputElement | null>(null);

useRef 훅은 제네릭을 사용하여 타입 정보를 전달할 수 있다.

  •  inputRef의 타입을 지정하려면 useRef의 제네릭 매개변수로 해당 요소의 타입을 전달해주어야 한다.
  • const inputRef = useRef(null);</htmlinputelement | null> inputRef가 HTMLInputElement 또는 null을 참조할 수 있다는 것을 나타낸다.
    • 즉, useRef의 제네릭 매개변수로 HTMLInputElement | null을 전달함으로써 타입스크립트에게 inputRef가 어떤 타입을 참조할 수 있는지 알려줄 수 있다.
  • 이렇게 inputRef의 타입을 명시적으로 지정하면 해당 요소에 대한 타입 안정성을 확보할 수 있다.

 

useState의 타입을 지정하는 경우는 보통 아래와 같다.

  • 상태가 null일 수도 있고 아닐수도 있을때
    • 유저의 로그인 여부에 따라 userInfo 의 값이 null이 될 수도 있는 경우이다.
type Information = { name: string; description: string };
const [info, setInformation] = useState<Information | null>(null);
.
.
export interface UserInfo {
  userid: string | undefined;
  username: string | undefined;
}
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);

 

  • 상태의 타입이 까다로운 구조를 가진 객체이거나 배열일 때
    • 배열인 경우에는 해당 배열이 어떤 타입으로 이루어진 배열인지 추론할 수 있도록 Generics을 명시하는 것이 좋다.
type Todo = { id: number; text: string; done: boolean };
const [todos, setTodos] = useState<Todo[]>([]);

 

🟣 설명 (2)  event 타입 지정하기

event에도 타입을 지정해주어야 한다. 각 이벤트 타입은 이벤트 객체의 속성과 메서드를 정의하여 특정 이벤트에 대한 정보를 제공한다. 

 

이벤트 타입은 보통 아래와 같은 것들이 자주 쓰인다. 

  • React.ChangeEvent<T>:
    • 주로 <input>, <select>, <textarea> 등의 값이 변경되었을 때 발생하는 이벤트이다.
    • <T>에는 이벤트 대상 엘리먼트 타입을 지정해준다.
    • 예시: React.ChangeEvent<HTMLInputElement>
  • React.MouseEvent<T>: 
    • 주로 마우스 이벤트 (클릭, 마우스 오버, 드래그 등)에 사용된다.
    • 예시: React.MouseEvent<HTMLButtonElement>
  • React.KeyboardEvent<T>:
    • 주로 키보드 이벤트 (키 누름, 키 뗌 등)에 사용된다.
    • 예시: React.KeyboardEvent<HTMLInputElement>
  • React.FormEvent<T>:
    • <form> 엘리먼트에서 발생하는 이벤트에 사용된다.
    • 예시: React.FormEvent<HTMLFormElement>
  • React.FocusEvent<T>:
    • 주로 포커스 이벤트 (포커스 인, 포커스 아웃 등)에 사용된다.
    • 예시: React.FocusEvent<HTMLInputElement>
  • React.SyntheticEvent
    • 함수 내 이벤트가 여러개 있을 때 아울러서 작성할 수 있는 이벤트 타입이다. 

 

일단은 이벤트 타입만 지정해주었고, 아래와 같이 handleChange 함수에서만 오류가 났다.

  • 오류가 발생하는 이유는 e.target의 타입이 EventTarget로 추론되어서이다.
    • EventTarget 인터페이스는 이벤트를 수신하는 모든 객체를 대표하는 타입이기 때문에, 타입스크립트에서는 e.target이 EventTarget으로 추론된다. 
    • EventTarget 인터페이스에는 이벤트를 등록하고 삭제하는 메서드인 addEventListener와 removeEventListener가 포함되어 있다.
  • 즉, EventTarget 타입은 value 속성을 가지고 있지 않기 때문에 오류가 발생한다.
  • 해결하기 위해서는 e.target의 타입을 적절한 요소 타입으로 명시적으로 지정해면 된다.

 

React.ChangeEvent<HTMLInputElement>input 요소에서 발생하는 변경 이벤트를 나타내는 타입이다.

  • React.ChangeEvent는 input, textarea, select와 같은 입력 요소에서 발생하는 이벤트를 다루기 위한 제네릭 타입이다. 
  • 입력 요소의 값에 접근하기 위해 e.target.value와 같은 속성을 사용해야 하므로, React.ChangeEvent의 제네릭 타입에는 해당 입력 요소의 타입을 명시해주어야 한다. 
  • HTMLInputElement 타입을 사용하여 e.target을 형변환할 수 있고, 이를 통해 value 속성에 접근할 수 있다.
  • 아래와 같이 타입을 명시적으로 지정하면 타입 체크가 정상적으로 이루어지고, 오류가 해결된다.

 

React.FormEvent는 form 요소에서 발생하는 이벤트를 다루기 위한 제네릭 타입이다.

  • 따라서 React.FormEvent를 사용할 때에는 <T>를 지정하지 않아도 문제가 없다.
  • React.FormEvent는 e.preventDefault()와 같은 공통적인 이벤트 핸들링 기능을 제공한다.
  • React.FormEvent를 사용하는 것은 form 요소에서 발생한 이벤트를 다루기 위해서이며, 값을 직접 접근하는 것이 아니라 다른 로직을 수행하기 위한 용도이다.

728x90
⬆︎