[TS] migrating to typescript

hyeondoonge·2024년 6월 10일
0

ts를 모르던 시절에 만들기 시작해서 전부 js로 작성된 Alog 서비스를, ts가 주는 편의성을 몸소 느낀 이후로 마이그레이션 해보고싶다는 생각을 했었다.

앞으로 기능을 야금야금 추가, 수정하거나 또는 성능을 개선하는 등 유지보수를 이어나갈 계획이기때문에 규모를 키움에 따라 더 TS의 필요성이 커지게 될 것이라고 생각했다.

뿐만 아니라 실제 프로덕트를 개발하는 팀에서는 이처럼 기술 스택을 전환해야하는 상황이 일어날 수 있을텐데

이러한 상황에서 어떤 점들을 고려해야하는지 궁금하기도 했다.

CRA(create-react-app) + styled-components 기반으로 작업을 진행해서 해당 환경을 바탕으로 마이그레이션 중간 여정을 얘기해보고자한다.

전체적인 개발 흐름은 microsoft/TypeScript-React-Conversion-Guide에 따라 작업했다.

개발 환경 구성하기

CRA 공식문서의 가이드를 참고하여 필요한 기본 의존성을 설치해주었다.

npm install --save typescript @types/node @types/react @types/react-dom @types/jest

만약 CRA를 사용하지않고 직접 번들링 환경을 구성한다면 ts-loader 등의 라이브러리도 추가로 필요로 할 것이다.

위의 styled-components, react-router-dom 등과 같이 추가 의존성을 사용하고 있는 상황이고 해당 라이브러리에 타입 선언 모듈이 포함되어있지않다면, 따로 의존성을 추가해주어야한다.

그리고 프로젝트 루트에 tsconfig.json, .eslintrc.json(선택)를 추가해준다.

tsconfig.json

  • typescript 작성된 코드를 어떻게 컴파일할 것인지에 대한 커스텀 설정이다.
  • default 설정보다 정교한 검사를 통해 타입 안정성을 극대화하고자 아래와 같이 설정했고, 각자의 프로젝트 환경에 맞게 값을 조금씩 바꾸면 되겠다.
{
  "compilerOptions": {
    "baseUrl": "./src",
    "target": "es5",
    "lib": ["dom", "esnext"],
    "skipLibCheck": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

.eslintrc.json (선택)

  • typescirpt 기반의 코드를 위한 eslint 도구가 존재한다. 이 도구를 통해 안티패턴 및 버그 가능성이 있는 코드를 검출하고 사전에 차단할 수 있다.
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": ["plugin:@typescript-eslint/recommended"],
}

모듈 변환 방향 정하기

이게 무슨 말이냐... 하면 전체 프론트엔드 코드에 다양한 모듈이 의존하고 있을텐데,
그 중 상위 계층 모듈과 하위 계층 모듈 중에 어떤 모듈부터 변경을 할 것인지에 대한 것이다.

(여기서 상위, 하위 계층은 전체 컴포넌트 트리를 기준으로 봤을 때 최상위가 App 그리고 최하위가 Button, Input과 같은 다른 것에 의존하지 않는 것을 의미한다)

나는 의존하는 게 없는 하위 계층의 것들을 우선 변환하는 방향으로 했다. 빠르게 ts 변환을 위해서 그리고 직관적으로 type을 정의하기 위해서였다.

작성된 하위 계층의 ts 기반 코드를 상위 계층에 점진적으로 적용하며 타입 시스템의 이점을 누릴 수 있다는 점과 타입을 정의할 때 타입만 보고도 어떤 스펙을 갖고 어떤 역할을 하는지 파악하기 쉽게 함으로써 유지보수성도 잡고 싶었기 때문이다.

반대의 방식은 복잡해보였고, 빠르게 작업하기 어려울 것 같았다.

JS → TS 변환하기

대략적인 변환 패턴은 동일하다.

ts 필요로하는 모든 모듈에 대해 js 확장자를 ts 또는 tsx로 변경해준다. 그리고 컴포넌트를 포함한 함수, 상수 등을 위한 타입을 정의하고 사용한다. 이때 위에서 설정해준 tsconfig, eslint에 따라 발생하는 컴파일 오류를을 전부 해결하면 ts기반으로 작업할 수 있게된다.

이 과정에서 컴포넌트, 훅, API 변환 작업을 진행한 것들 중 각각 핵심적인 작업들만 조금씩 보자.

컴포넌트 변환

아래는 js 기반으로 작성된 ClickableTag 컴포넌트인데 ts를 가능한 잘 활용할 수 있는 방향으로 변환을 진행했다.

다음과 같이 언어를 선택할 수 있는 컴포넌트다.

StyledClickableTag와 같이 styled-component로 생성될 객체에 타입을 줄 수 있다.

아래와 같이 제네릭 인자로 스타일링에 사용할 속성들과 그 타입들을 객체형태로 전달해준다.

만약 color로 hex 포맷의 값만 받도록 제한하고 싶다면 color의 타입을 #${string} 이러한 형태를 사용할 수도 있다. 나는 transparent, initial 등 좀 유연하게 사용하고자 string으로 지정했다.

const StyledClickbaleTag = styled.label<{ size?: number; backgroundColor: string; main: string }>`
  color: white;
  background-color: ${(props) => props.backgroundColor};
  font-size: ${(props) => (props.size ? `${props.size}rem` : 'inherit')};
  padding: 1rem 1.5rem;
  border-radius: 3rem;
  cursor: pointer;
  transition: background-color 0.4s;

  input {
    appearance: none;
    margin: 0;
  }

  &:hover {
    box-shadow: 0 0 2px 1px #eee;
  }

  &:has(:focus-visible) {
    outline: -webkit-focus-ring-color auto 5px;
  }

  &:has(:checked) {
    color: black;
    background-color: ${(props) => props.main};
  }
`;

그다음! ClickableTag 기능을 위한 컴포넌트가 주입받을 props의 타입을 지정한다.

이벤트 핸들러에 대한 타입을 지정하는 방식은 React에 내장된 EventHandler 관련 타입을 사용하거나 아래와 같이 간단하게 () ⇒ void 형태로 작성할 수도 있다.

interface ClickableTagProps {
  label: string;
  selected: boolean;
  handleChange: React.ChangeEventHandler<HTMLInputElement>;
  size?: number;
}
interface ClickableTagProps {
  label: string;
  selected: boolean;
  handleChange: () => void;
  size?: number;
}

전달할 핸들러가 event 객체에 대한 참조를 필요로 하지 않는다면 후자로 해도 충분하다. 하지만 이러한 경우에도 EventHandler 로 정의하는 방법은 가능하기 때문에, 이를 사용하는게 일관성있고 명시적으로 작성하는 걸 개인적으로 선호한다.

export default function ClickbaleTag({ size, label, selected, handleChange }: ClickableTagProps) {
  const theme = useContext(ThemeContext);

  return (
    <StyledClickbaleTag size={size} backgroundColor={theme.background} main={theme.main}>
      {label}
      <input type="checkbox" name="categories" onChange={handleChange} checked={selected} />
    </StyledClickbaleTag>
  );
}

커스텀 훅 변환

useIntersectionObserver는 WebAPI 중 IntersectionObserver API의 기능을 한 번 더 래핑하여 편하게 사용할 수 있게 만든 커스텀 훅이다.

observerRef는 생성된 observer 객체를 가리킨다. 이를 위해 useRef 훅을 사용하고 있고 여기에도 타입을 지정할 수 있다. IntersectionObserver로 지정해준다.

import { useEffect, useRef, useState } from 'react';

export default function useIntersectionObserver() {
  const observerRef = useRef<IntersectionObserver | null>(null);
  const targets = useRef<HTMLElement[]>([]);

  const unobserve = () => {
    targets.current.forEach((target) => {
      if (!observerRef.current) {
        return;
      }
      observerRef.current.unobserve(target);
    });
  };

  const createObserver = (callback: () => void) => {
    if (observerRef.current) {
      unobserve();
      targets.current = [];
    }
    observerRef.current = new IntersectionObserver(
      (entries, observer) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            callback();
            observer.unobserve(entry.target);
          }
        });
      },
      {
        threshold: 1.0
      }
    );
  };

  const registerTargets = (targetElements: HTMLElement[]) => {
    if (observerRef.current) {
      targets.current = targetElements;
      targetElements.forEach((target) => {
        if (!observerRef.current) {
          return;
        }
        observerRef.current.observe(target);
      });
    }
  };

  useEffect(() => {
    return () => {
      if (observerRef.current) unobserve();
    };
  }, []);

  return [createObserver, registerTargets];
}

이렇게 변환한 커스텀 훅의 타입은 아래와 같이 추론된다.

이를 해결하기위해 반환타입을 각 객체에 정확히 대응되도록 지정해주거나, 반환값을 배열이 아닌 dictionary로 변환해준다.

전자의 방식은 변수명을 직접 정의하는 특징이 있다. 반면 후자의 방식은 타입을 추가로 관리할 필요가 없고 또 필요한 프로퍼티만 꺼내서 사용하면 되어서 코드가 지저분해질 가능성도 줄어들 수 있는 장점이 있는 것 같다.

아래와 같이 dictionary로 전달해주면 다음과 같이 추론된다.

유의할 점

이와 같이 단순히 TS로 변환되는 걸 너머 기능의 구조가 약간 변경될 수도 있다. useIntersectionObserver()는 여러 컴포넌트에서 사용되는 기능이다. 변경에 따라 영향을 받는 코드가 무엇인지 파악하고 일괄적인 변경을 통해 잠재적인 버그에 방어적으로 코드를 작성해야한다.

API 변환

API server와의 통신을 위해 사용되는 로직을 타입으로 변경해보자. 아래는 API server로부터 풀이 목록을 받아온다. 타입 시스템의 장점을 극대화하기 위해 반환타입을 명시해준다. 명시해주지 않은 경우에는 Promise<any> 로 추론이된다.

const fetchPosts_GET: (option: Option) => Promise<
  | {
      posts: IPost[];
      totalCount: number;
      leftCount: number;
    }
  | undefined
> = async (option) => {
  try {
    const query = createQuery(option);
    const response = await fetch(`/api/posts/search?${query}`);
    const json = await response.json();
    return json;
  } catch (err) {
    console.log(err);
    return;
  }
};

런타임에 발생할 수 있는 타입 오류 가능성 낮추기

위와 같이 정적인 타임에 버그없어 타입 안정성을 확보해도 런타임에서는 오류를 마주할 수 있다. 서버 응답이 내가 명시해준 타입으로 올 것이라고 100% 보장하지 못하기 때문이다.

런타임에서도 안전하게 동작하게 하려면 런타임에서 타입 검사를 하는 코드를 추가해줄 수 있다.

https://medium.com/@wujido20/runtime-types-in-typescript-5f74fc9dc6c4

위의 글을 참고했는데, 여러 방식 중 assertion function를 적용했다.

export function safelyCheckPosts(
  param: any
): asserts param is { posts: IPost[]; totalCount: number; leftCount: number } {
  if (typeof param !== 'object' || param === null) {
    throw new Error('failed to checking type of param');
  }
  if (typeof param.totalCount !== 'number') {
    throw new Error('failed to checking type of profile_fileName');
  }
  if (typeof param.leftCount !== 'number') {
    throw new Error('failed to checking type of api_accessToken');
  }

  // posts 검사
  if (!Array.isArray(param.posts)) {
    throw new Error('failed to checking type of posts');
  }
  if (1 <= param.posts.length) {
    const post = param.posts[0];
    if (typeof post._id !== 'string') {
      throw new Error('failed to checking type of _id');
    }
    if (typeof post.title !== 'string') {
      throw new Error('failed to checking type of title');
    }
    if (typeof post.subtitle !== 'undefined' && typeof post.subtitle !== 'string') {
      throw new Error('failed to checking type of subtitle');
    }
    // ...
  }
}

이때 typeof 연산자의 반환값은 문자열이기 때문에, 문자열 타입명과 비교해주는 것을 기억하자.

이제 아래와 같이 safelyCheckPosts() 호출함으로써, 런타임에 json 객체의 타입을 검사함으로써 안정성을 향상할 수 있다.

const fetchPosts_GET: (option: Option) => Promise<
  | {
      posts: IPost[];
      totalCount: number;
      leftCount: number;
    }
  | undefined
> = async (option) => {
  try {
    const query = createQuery(option);
    const response = await fetch(`/api/posts/search?${query}`);
    const json = await response.json();
    safelyCheckPosts(json);
    return json;
  } catch (err) {
    console.log(err);
    return;
  }
};

이와 같은 API 응답 뿐만 아니라 타입을 100% 예측하지 못하는 유저 입력, 웹 스토리지 등의 데이터를 읽어올 때 런타임 안정성 향상의 목적으로 사용될 수 있을 것 같다.

느낀점

  • typescript를 적용하며 잠재적인 버그들을 찾아낼 수 있었던 경험이었고, 왜 ts가 대부분 서비스의 필수 기술로 자리잡았는지 이해할 수 있었다.
  • 유저 입력, 웹 스토리지 접근, API 통신 등 런타임에 어떤 값을 받게될지 확신할 수 없다. 따라서 타입 안정성 뿐만 아니라 런타임에도 안전한 코드인지 생각해봐야한다.
  • 코어한 부분을 수정하다보면 기능을 파라미터 형태나 타입을 약간 변형시키는 게 나을 때가 있다. 이때 이에 의존하는 코드는 무엇이있는지 전부 파악하고 버그가 일어나지 않도록 유의해야한다. 이처럼 위험비용이 드는 작업이기 때문에 되도록이면 타입 시스템만 적용하는 방향으로 개발하는 것이 좋겠다.

참고

microsoft/TypeScript-React-Conversion-Guide

How to correctly use TypeScript types for your API response | by Vaclav Hrouda | Medium

0개의 댓글