React + TypeScript

최근에 진행하고 있는 프로젝트를 React + TS의 조합으로 운영해봤다. React의 생태계가 워낙 괜찮다보니 사용하면서 내가 직접 타입을 작성한 라이브러리는 react-image-annotation, react-redux-firebase 두 개 정도. 단점은 다들 알다시피 러닝커브가 있다는 것. 하지만, 타입 없이 프로그래밍 하는 것은 오른손잡이가 굳이 왼손으로 밥 먹는 바보같은 일이라고 생각한다. 가장 명확하고 직관적으로 손쉽게 잡을 수 있는 타입 에러를 굳이 런타임까지 끌고가야할 이유가 있을까. 장점을 조금 더 세부적으로 설명해보자면,

  1. 타입은 코드와 가장 가까운 문서다. 생전 처음보는 함수가 몇 개의 인자를 어떤 형식으로 받고 어떤 결과값을 반환하는지 /** ... */ 와 같은 주석 없이도 명확하게 드러낼 수 있다. 잘 정의된 타입이 있으면 첫 번째 파라미터가 number인지, 아니면 object인지. object라면 어떤 키에 어떤 타입의 값이 들어오는 지, 그 값은 undefinednull일 수 있는지 등을 코드 속에서 발견할 수 있다. 심지어 이 문서는 컴파일러가 이해할 수 있는 수준이라 실행하지 않아도 컴파일 타임에 에러를 검출할 수 있다.

  2. 문서를 보는 시간이 줄어든다. 처음 써보는 외부 라이브러리일 수록 타입의 장점은 극대화된다. 프로젝트에 처음 투입된 신입에게도 비슷할 것이다. 타입정보가 있으면 어떤 함수를 어떻게 사용할지 빠르게 알게 되고, 그 함수의 반환값에 어떤 데이터가 어떤 형식으로 들어있는지를 API 문서를 보지 않고도 대략적으로 유추할 수 있게 된다.

    “자주 사용하는 명령어를 최적화하라”. 컴퓨터 아키텍쳐에서 질리도록 들었던 문장이다. 프로그래머가 일을 하면서 커서 포커스가 IDE를 벗어나는 순간은 언제일까? 프로그래머의 컨텍스트 스위칭을 야기하는 인터럽트는 무엇일까? 자바스크립트 프로그래밍에서는 단연컨대 문서 읽기다. 이 함수를 도대체 어떻게 써야하는지 모르겠거든. 이 함수의 반환값에 어떤 데이터가 어떤 키에 들어있는지 모르겠거든.

  3. 프로젝트 초반, 테스트에 대한 강박을 덜어낸다. 테스트 무용론을 주장하는 것은 아니다. 다만, 프로젝트와 아이템이 미성숙할 수록 스펙은 자주 바뀌기 마련이고, 이를 구현하느라 힘들어본 경험이 있는 프론트엔드 개발자들이라면 섣부른 테스트의 작성은 오히려 생산성을 까먹는다는 것에 쉽게 동의할 수 있을 것이다. 이런 점에서 일정 수준까지는 “유닛, E2E 테스트 없이” 빠르게 코딩을 하는 기간이 필요하고, 이 때 테스트하지 않은 코드에 대한 개발자의 불안감과 죄악감(?)을 덜어낼 수 있는 가장 간단한 방법이 타입이라고 생각한다.


실제로 본인의 진행한 프로젝트에서 어떤 형식으로 가져다 쓰고 있는지를 간단히 예제를 통해 알아보자.

  • deep-copy: 빠르고 타입있는 객체 복사 라이브러리
  • deox: 타입있는 redux-actions
  • react, redux, react-redux, react-router, typescript: 설명 생략
import deepCopy from 'deep-copy';
import { createActionCreator, createReducer } from 'deox';
export type MyState = {
num: number;
};
export const initialState: MyState = {
num: 0,
};
export const myActions = {
updateNum: createActionCreator(
'my/UPDATE_NUM',
resolve => (newNum: number) => resolve(newNum),
),
};
const myReducer = createReducer(initialState, handleActions => [
handleAction(
actions.updateCounter,
(state, { payload }) => {
const newState = deepCopy(state);
newState.num = payload;
return newState;
},
),
]);
export default myReducer;
view raw my.ts hosted with ❤ by GitHub
  • 위 gist는 deep-copy, deox를 사용하여 “my” redux action과 reducer를 만든 코드다.
  • 내가 정의한 updateNum의 타입으로부터 deox가 reducer의 payload의 타입을 자동으로 추론한다.
  • myActions.updateNum의 타입도 deox가 자동으로 추론한다.
  • deepCopy를 이용해서 redux store 불변성(immutability)을 달성한다.

위 action을 내 React Component에 react-redux의 connect 함수를 사용하여 연결하는 코드는 다음과 같다.

import React, { useCallback } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { myActions } from './my'
type CounterStateProps = {
num: number;
};
type CounterDispatchProps = {
updateNum: typeof myActions.updateNum;
};
type CounterOwnProps = {
title: string;
};
type CounterProps = CounterStateProps &
CounterDispatchProps &
CounterOwnProps & {
location: Location; // NOTE: injected by withRouter HoC
};
const Counter: React.FC<CounterProps> = props => {
const {
title,
num,
updateNum,
location: {
href,
},
} = props;
const handleIncreaseClick = useCallback(
() => {
updateNum(num + 1);
},
[num],
);
return (
<>
<h1>This is {title} Counter</h1>
<p>href: {href}</p>
<p>current count: {num}</p>
<button onClick={handleIncreaseClick}>increase</button>
</>
);
};
const enhance = compose<React.FC<CounterOwnProps>>(
connect<
CounterStateProps,
CounterDispatchProps,
CounterOwnProps,
any // 또는 state의 타입
>(
state => ({
num: state.my.num,
}),
myActions,
),
withRouter,
);
export default enhance(Counter);
view raw Counter.tsx hosted with ❤ by GitHub

하나씩 순서대로 뜯어보자.

  1. line22에서 선언된 Counter는 React Functional Component다.

  2. line49의 enhance는 Counter를 위한 HoC들을 하나의 함수로 통합하는 역할과 더불어, compose의 타입으로 들어간 React.FC<CounterOwnProps> 를 반환값으로 타이핑한다.

    • compose의 타입으로 아무것도 넣지 않은 경우, 이 Counter는 타입 정보를 잃게 된다. (any로 취급됨)
    • compose의 타입으로 CounterProps를 넣는 경우, 이 컴포넌트를 가져다 쓸 때 HoC가 주입하는 prop들을 유저(= 프로그래머)가 넣지 않았다며 타입 오류를 내뿜는다. 즉, <Counter title="my counter" /> 로 가져다 쓰고 싶은데, location prop의 전달을 까먹었다며 컴파일이 되지 않는다.
    • 따라서, Counter가 부모 컴포넌트로부터 전달받는 prop만을 정리한 것이 CounterOwnProps이다.
  3. line50의 connect()의 첫 번째, 두 번째 인자인 CounterStatePropsCounterDispatchPropsconnect()의 첫 번째, 두 번째 파라미터로 전달되는 함수의 타입을 결정한다. 다시 말해 line56~58와 line59의 함수의 타입이 line8과 line11과 다른 경우 컴파일 에러.

  4. line11에서 typeof 연산자를 통해 타입의 중복 선언을 막을 수 있다.

  5. line19에서 HoC에 의해 전달되는 prop의 타입을 선언한다.

뱀발로,

위 구현은 <Counter title="counter1" /> <Counter title="counter2" /> 와 같이 사용할 때 두 카운터의 값이 동기화되어 같이 바뀌는 불완전한 구현이다. 개념을 보이는 데에 가장 간단한 예제인 카운터를 사용했음을 이해해주시길. 독립적인 값으로 돌아가는 경우에는 redux 필요없이 useState 훅을 사용하는 것이 옳다.

One thought on “React + TypeScript”

Leave a Reply