본문 바로가기

개발 여정

눈길을 달리는 JavaScript, TypeScript 스노우체인 장착기

배경

2025년 8월에 있었던 일입니다.

우리 회사는 모든 웹뷰 프론트 개발을 JS로 진행하고 있었습니다.

서비스를 개발하면서 JS의 동적 type 등 자유로운 언어의 특성 상 유지보수의 불편한 점이 있었지만 할 만했습니다.

 

하지만 최근에 하나의 서비스의 개선 작업이 잦아지면서 코드 수정이 빈번해지기 시작했고 그 과정에서 불편함은 커져가 TS 도입의 목마름이 커져가기 시작했습니다.

JS로 작업하면서 불편했던 점은 아래와 같습니다.

 

  1. 작성한 코드의 버그, 에러 여부를 런타임 후 인지
  2. 상태관리 시 enum스럽게 관리하기 힘든 점
  3. API 응답 값 명세 파악의 한계

간단하게 코드로 표현해봤으며 코드 예시는 React입니다.

 

작성한 코드의 버그, 에러 여부를 런타임 후 인지

const Past = ({ curAge }) => {
  return <div>`작년의 내 나이는 ${curAge - 1}살이었습니다.`</div>;
};

const Now = ({ curAge }) => {
  return <div>`올해 내 나이는 ${curAge}살입니다.`</div>;
};

const Future = ({ curAge }) => {
  return <div>`내년의 내 나이는 ${curAge + 1}살이 됩니다.`</div>;
};

const MyAge = ({
  condition1,
  condition2,
  condition3,
}) => {
  if (condition1) {
    return <Past curAge={11} />;
  }
  if (condition2) {
    return <Now curAge={11} />;
  }
  if (condition3) {
    return <Future curAge="11" />;
  }

  return null;
};

export default MyAge;

 

위 코드에서 MyAge 컴포넌트는 조건에 따라 작년, 올해, 내년의 내 나이를 보여주며 노출 조건을 props에 전달하여 해당 조건에 맞는 나이를 보여줍니다.

 

여기서 개발자가 실수로 Future 컴포넌트의 props에 문자열 "11"을 넘겨준다면 Future 컴포넌트는 아래와 같이 반환할 것입니다.

 

"내년의 내 나이는 111살이 됩니다."

 

10에 1을 더한 11이 반환되도록 구현한 로직이지만 아득히 먼 나이를 먹게 되었습니다.

해당 버그는 condition3을 true로 설정하고 코드를 실행한 런타임이 되서야 111살이 된것을 알게 되어 코드를 수정하는 일이 발생할 수 있습니다.

 

상태관리 시 enum스럽게 관리하기 힘든 점

const MyFavoriteFood = ({ food }) => {
  return <div>제가 좋아하는 음식은 {food}입니다.</div>;
};

const MyInfo = () => {
  return (
    <>
      <div>저에 대해 설명드리겠습니다.</div>
      <MyFavoriteFood food="채소" />
    </>
  );
};

export default MyInfo;

 

내가 좋아하는 음식은 "고기", "빵", "면"이며 MyFavoriteFood 컴포넌트의 food라는 props에 해당 값만 들어오기를 기대한다고 가정합니다.

하지만 food에 "채소"를 전달해도 에러가 발생하지 않기에 채소를 좋아하는 청년이 되어도 아무도 그것이 이상하다고 알려주지 않습니다.

 

API 응답 값 명세 파악의 한계

api 연동 시 벡엔드 개발자분이 공유해주신 api 명세를 기반으로 개발을 진행합니다.

만약 api 응답 값의 프로퍼티 값이 nullable하다면 개발자는 옵셔널 체이닝을 통해 값이 undefined 혹은 null이 올 경우에 대한 처리를 해야 합니다.

 

하지만 어디가 nullable인지 코드 작성 단계에서 보장 혹은 안내해 주지 않아 api 명세서와 에디터를 왔다갔다해야 하는 불편함이 생겨 옵셔널 체이닝을 난무하게 될 수 있습니다.

 

위와 같은 불편한 점을 실무에서 자주 접하여 이를 개선하기 위해 JS로 개발하면서 불편했던 점(도입 배경)TS 도입 후 이점을 문서로 정리하였습니다.

그리고 웹 프론트 개발자분들에게 문서 공유 및 발표를 진행한 결과 TS 도입이 채택되어 본격적으로 우리 서비스에 TS를 적용하기로 하였습니다.

 

팀 개발 일정상 전면적인 TS 전환이 아닌 점진적인 도입으로 협의하였고 당시 진행 예정이었던 홈 화면 개편 프로젝트와 병렬 진행으로 기존 서비스 중 2개를 TS로 전환, 신 서비스 1개를 TS로 개발하기로 했습니다.

(TS 도입 제안은 제가 하였기에 TS 환경설정 및 마이그레이션 작업은 제가 담당하게 되었습니다.)

 

검토 사항

TS 버전

검토 시점에서 최신 버전은 5.9.2였고 해당 버전으로 건의드렸습니다.

이유는 처음으로 TS 도입하기 때문에 호환성을 고려할 필요가 없고 node.js와 같이 명시적인 LTS(Long Term Support), EOL(End of Life) 정책을 따로 운영하고 있지 않기 때문입니다.

 

한편, 이전 회사에서 1년 넘게 TS를 사용했을 당시 TS의 버전은 4였으나 게시물 작성 시점 버전은 5로 업데이트되었습니다.

버전 5의 공식문서를 검토하였고 버전 4에 비해 좋아진 점은 우선 패키지의 용량이 줄어들었다는 것입니다.

 

버전 4에 비해 패키지 용량이 약 26.4MB 줄어듦으로써 코드를 분석하고 결과물을 만들어내는 시간에서 10~20% 속도 향상을 기대할 있는 것도 버전 5를 도입한 근거 중 하나입니다.

 

TS 적용하기

TS를 설치하고 기존 서비스를 TS로 바꾸는 작업에 들어갔습니다.

그 과정에서 고민한 것들은 아래와 같았습니다.

  1. type 선언 파일 관리 컨벤션
  2. 전역적으로 적용되는 타입 선언하기
  3. 컴포넌트 반환 type
  4. @typescript-eslint 설정

 

type 선언 파일 관리 컨벤션

TS 환경에서 개발 시 type alias와 interface, enum 등 type 선언으로 인해 코드가 길어지며 type 선언 관리에 소홀해지면 코드 가독성에 좋지 않을거라고 생각했습니다.

또한, 동일한 type 선언을 여러 파일에서 하게 되면 중복코드로 인해 유지보수 비용이 증가함으로써 효율적인 type 선언 관리가 필요합니다.

그래서 type 선언 코드를 별도 파일로 생성하여 관리하도록 건의드렸습니다.

type 선언 코드의 관심사를 분리할 수 있고 동일한 type 선언이 필요한 경우 각 파일에서 import하여 사용함으로써 중복 type 선언을 방지할 수 있습니다.

 

그럼 type 선언 코드를 관리할 파일만 생성하면 되겠지 싶었지만 그렇기에는 다소 문제가 발생할 수 있다고 생각했습니다.

개발자분들마다 type 선언 코드를 다르게 작성하면 코드의 통일성이 결여되어 코드를 파악하는데 비용이 들 수 있기 때문입니다.

팀원분들이 일정한 규칙으로 특정 위치에 type을 선언하는 파일을 생성하고 파일의 네이밍 규칙을 세운다면 코드 검색과 재사용, 검토 리팩터링이 빨라지고 중복과 실수가 줄어들거라고 생각하여 다음과 같은 컨벤션을 수립하였습니다.

  • type 선언 파일은 types를 포함
    • 예시 : index.types.ts, api.types.ts
  • src 디렉터리 하위에 types 디렉터리 생성하여 해당 디렉터리 하위에서 type 선언 파일 관리
  • 각 서비스별로 사용되는 type 선언 파일은 서비스명으로 디렉터리 생성하여 관리
  • 서비스명 디렉터리 하위에는 파일 두개를 생성
    • api.ts : api 요청 parameter, 응답 값 interface 등 api 관련 type 선언 관리
    • index.ts : 특정 서비스에서 사용하는 상태 값, 비즈니스 로직 함수 파라미터 값 등의 type 선언 관리
  • 서비스에 구애받지 않고 공통으로 사용하는 type은 common 디렉터리 생성하여 하위에서 api.ts, index.ts 생성해서 관리
  • 예시
src
├─types
| └─common
|   └─index.types.ts
|   └─api.types.ts  
| └─event
|   └─index.types.ts
|   └─api.types.ts  
| └─manage
|   └─index.types.ts
|   └─api.types.ts

 

  • 컴포넌트의 props type 선언 시 해당 컴포넌트에서 선언
    • 컴포넌트의 props type 분석 시 가독성 용이
    • props의 경우 interface로 정의하며 네이밍은 컴포넌트명 + Props로 정의
    • 예시
export interface MyApartmentProps {
  name: string;
  address: string;
  // ...
}

const MyApartment = ({ name, address }: MyApartmentProps) => {
  // ...
};

export default MyApartment;

 

전역적으로 적용되는 타입 선언하기

저는 포스트 작성 시점으로 Flutter 앱 내 웹뷰 프론트엔드 개발을 담당하고 있습니다.

웹뷰에서 정의하기 어려운 이벤트(: 안드로이드 뒤로가기 물리 버튼 클릭 ) App 개발자분이 window 객체의 프로퍼티에 커스텀 이벤트로 추가해줍니다.
이때 해당 프로퍼티가 Window 객체의 interface 없으므로 다음과 같은 에러가 발생합니다.

 

Property "onAndroidBack" does not exist on type ‘Window & typeof globalThis’.ts(2339)

 

이 문제를 해결하기 위해 global.d.ts에서 type 선언하였습니다.

global.d.ts 프로젝트 전체에서 Window 객체와 같은 특정 객체의 커스텀 프로퍼티를 type 추가하여 해당 프로퍼티를 TS에게 인식시키기 위해서입니다.

  • 예시
// global.d.ts


interface Window {
  onAndroidBack: () => void;
}

 

컴포넌트 반환 type

컴포넌트의 반환 type은 4가지로 들 수 있습니다.

  • React.ReactNode
  • React.ReactElement
  • JSX.Element
  • React.FC

TS의 컴포넌트를 개발할 때 어떤 컴포넌트 반환 type을 컨벤션으로 할지 고민했습니다.

React.ReactNode의 경우 리액트 컴포넌트가 렌더링하는 모든 type을 갖고 있어서 느슨하다고 생각하며 개발자분들마다 다른 컴포넌트 반환 type을 사용하게 되면 가독성 면에서 좋지 않고 최대한 반환 type을 좁혀두고 싶었기 때문입니다.

그래서 위 4가지의 반환 type에 대해 알아보고 어떤 것이 컴포넌트 개발 시 가독성, 유지보수면에서 유의미한지 정리하였습니다. 

 

type명 설명 type
React.ReactNode
  • 리액트가 랜더링할 수 있는 모든 것의 합집합 type
  • 컴포넌트 props 중 children type 선언 시 자주 쓰임
string |
number |
ReactElement |
ReactPortal |
null |
undefined |
boolean |
ReactNode[]
React.ReactElement
  • 제네릭을 갖는 단일 리액트 엘리먼트 객체 type
  • 제네릭에 props의 type까지 설정하고 싶을 때 사용
interface ReactElement< P = any, T extends string | JSXElementConstructor<any>
= string | JSXElementConstructor<any>, > {
  type: T;
  props: P;
  key: string | null;
}
JSX.Element
  • 단일 리액트 엘리먼트 객체의 type
ReactElement<any, any>
React.FC
  • 함수형 컴포넌트의 Type Alias
  • 제네릭에 컴포넌트 props type 정의 가능
  • 함수형 컴포넌트에서 비권장되는 것을 지원
    (defaultProps, displayName)
FC<P = {}> = FunctionComponent<P>

 

결론부터 말씀드리자면 컴포넌트의 반환 type 사용 시 React.ReactElement를 사용하는 것입니다.

이유는 아래와 같습니다.

  • 컴포넌트는 단일 리액트 엘리먼트 객체임을 명확하게 하기 위해 React.ReactNode는 지양
  • 함수형 컴포넌트에서 비권장되는 defaultProps, displayName을 지원하는 React.FC는 사용 이점이 없기에 채택하지 않음
  • 컴포넌트의 children type 선언 시에 React.ReactNode사용
    • children은 다양한 type이 들어올 수 있기 때문
  • 컴포넌트가 null을 반환할 수 있는 경우를 명시적으로 인지하기 위해서는 React.ReactElement가 효과적
  • React 19에서 JSX 네임스페이스가 global에서 제거되는 변경에 대응해, JSX.Element 대신 React.ReactElement로 통일하여 마이그레이션 비용을 사전에 제거

@typescript-eslint 설정

TS의 경우 변수나 함수의 반환 type을 선언하지 않아도 타입추론(type inference)으로 인해 에러가 발생하지 않습니다.

 

물론 개발자가 함수의 로직을 분석할 때 어떤 값을 반환하는지 유추할 수 있지만 그런 유추 과정도 비용이라고 생각하기 때문에 그 과정을 생략하여 함수 선언 첫줄에 반환 type을 명시적으로 표시하고 싶었습니다.

 

위처럼 함수 반환 type을 명시적으로 적지 않은 경우 lint 에러를 발생시키기 위해 eslintrc.json에 아래와 같이 추가하였습니다.

@typescript-eslint/explicit-function-return-type : 'warn'

 

마무리

이번 포스트를 작성하게 된 이유는 저에게 있어서 일하는 환경을 바꾸는 경험 중 제일 느낀 점이 많은 작업이었기 때문입니다.

 

첫 회사의 경우 기존에 설정된 TS 환경에서 개발을 하였고 그 환경에서 모든 TS 설정 하나하나의 근거를 알고 개발하지 않아서 그런지 TS와 밀접한 관계를 느끼지 못했습니다.

그리고 두번째 회사(포스트 작성 시점 회사)에서 모든 프론트엔드 코드가 JS로 이루어져 있었고 type 선언 코드 관리를 따로 안 해도 된다는 생각에 한편으로 편하다고 생각했습니다.

 

하지만, 신 서비스 오픈 후 고도화 작업이나 A/B 테스트를 하면서 코드를 빈번하게 수정하는 과정에서 자유롭고 느슨한 JS로부터 느끼는 불편함은 커져만 갔고 이러한 불편함이 후에 일하는 환경의 개선점이 되었습니다.

비로소 TS의 탄생 이유와 역할이 저의 불편했던 경험과 융화되어 TS를 이해하는데 한 걸음 가까워질 수 있는 경험이 되었습니다.

 

이러한 경험을 통해 모든 서비스가 TS로 전환된다면 앞으로 프론트엔드 동료분들이 JS의 불편함을 겪지 않게 된다는 생각을 하니 내심 뿌듯한 마음이 들었습니다.

이제 JS라는 바퀴를 굴리며 개발을 할 때 가속이 붙는 순간이 오면 TS라는 스노우 체인을 감싸 가는 길을 일탈하지 않도록 좀 더 안전하게 개발을 할 수 있게 되었습니다. 

 

지금까지 TS 도입 배경, 검토 사항, TS로 전환하면서 경험한 문제와 해결법을 작성하였습니다.

제가 개발하면서 느꼈던 불편함에 대해 공감해주고 TS 도입에 긍정적인 반응을 해주신 팀원분들께 감사 드립니다.

반응형