Please enable JavaScript to view the comments powered by Disqus.

TypeScript로 작성하는 React-Redux 앱

TypeScript로 React-Redux 앱을 개발하기 위한 설정 가이드

몇 개의 시스템 관리 도구를 개발하면서 느꼈던 점은 서버에서 내려받은 데이터를 다룰 때 데이터의 형태가 타입으로 정의되어 있지 않다 보니 API 문서나 개발 도구의 네트워크 탭, 어떤 경우에는 콘솔 로그를 찍어야 하는 일이 많아서 불편하다는 점이었다. 특히 앱의 규모가 커지고 다뤄야 하는 데이터가 많아질수록 그로 인한 오류는 더욱 자주 발생하곤 했다. 자바스크립트가 동적 타입 언어다 보니 생기는 어쩔 수 없는 문제라고 할 수 있다. 물론 특정 데이터의 기본값을 확장하는 방법과 에디터의 도움을 받으면 어느 정도 극복은 가능하다.

getDataFromServer().then(res => {
  const data = Object.assign({}, DEFAULT_DATA, res.data)
  updateClientStore(data)
})

하지만 데이터 형태를 넘어 함수 객체의 데이터형, 리턴 타입까지 정의할 수 있고 프로그래밍 오류를 줄일 수 있을 것이라는 기대에 Angular 와 함께 인기를 얻기 시작한 TypeScript에 자연스럽게 관심을 두게 되었고, 몇 개의 앱을 TypeScript로 개발하게 되었다.

하지만 써 본 결과, 역시 모든 것에는 장단점이 존재한다는 것을 확인할 수 있었다.

Javascript를 정적 타입 언어 형태로 작성하는 것이 정말로 개발에 도움이 되는가?

도움이 되는 요소는 분명히 있다. 모든 데이터와 함수의 파라미터에 자료형을 붙임으로써 숫자 형 변수에 length같은 속성을 참조하려다가 undefined 값을 돌려받는 일 따위는 확실히 막을 수 있다. 그리고 더 정확한 코드 자동완성이 가능해지므로 한동안 작업을 하지 않다가 다시 시작해도 코드를 파악하는데 시간이 덜 걸리기도 했다.

하지만 개발에 걸리는 시간은 확실히 늘어난다. 타입 선언을 하지 않았다고 자꾸 오류를 발생시키는 TypeScript 컴파일러 때문에 tsconfig.json 파일의 각종 옵션을 false 로 바꿔버리고 any 타입을 남발하면서 개발을 진행하고 싶은 충동이 들 수 있는데, 그렇게 할 거라면 정적 언어를 사용하는 의미가 없어진다. 특히 외부 라이브러리는 TypeScript 는 DefinitelyTyped에 올라온 타입 정의를, flow 는 flow-typed에 추가된 타입 정의를 사용해야 한다. 하지만 타입 정의의 버전은 라이브러리의 최신 버전보다 이전인 경우가 더 많고 수년 전에 안정화되어서 개발이 중단된 라이브러리는 없는 경우도 많다. 그럴 땐 직접 타입을 선언해서 사용해야 한다. 그리고 어떤 라이브러리들은 콜백 함수에 정말 상세하게 타입을 정의해두곤 하는데, 거기에 맞추기 위해 타입 정의를 작성하다 보면 제법 많은 시간이 지나가곤 한다.

그리고 정적 타입 언어가 오류 자체를 줄여주는 것은 아니다. 컴파일 오류가 없다고 하더라도 코드 로직에서는 얼마든지 잘못된 결과를 만들어낼 수 있다. 테스트 코드를 작성하지 않는 이상 오류가 발생하지 않는다는 보장은 가질 수 없는 것에는 변함이 없다. 그리고 데이터 타입 측면에서도 서버의 데이터 모델과 프론트엔드 코드의 타입이 언제나 일치한다는 보장은 없으며 서버 코드에 오류가 있는 경우라면 말할 것도 없다.

그래도 자동완성 기능은 역시 매력적이다

TypeScript, flow 같은 자바스크립트 정적 언어를 통해 자동완성, intelliSense 라고도 불리는 기능을 제대로 활용할 있다는 것은 확실히 좋은 점이다. 특히 Redux 를 사용할 때 reducer 내부에서 특정 케이스 아래에서 action 객체의 속성을 참조하기 위해 점(.)을 찍으면 에디터(TypeScript 라면 vscode)가 어떤 타입의 액션인지 알아서 파악한 후 정확한 속성을 표시해 주는 모습을 보면 막혀 있던 가슴이 시원해지는 느낌이 들기도 한다. 이처럼 타이핑은 수많은 액션 타입을 사용해야 하는 Redux 에서 무척 큰 도움이 된다. 사실 내가 TypeScript를 이용해서 해결하려고 했던 것도 Redux 에서 action, reducer, model 의 속성을 확인하기 위해 이 파일 저 파일을 옮겨다니며 확인하는 일을 해결하고 싶어서였다.

TypeScript React-Redux 프로젝트 설정

TypeScript React 앱은 create-react-app을 사용하면 간단히 만들 수 있다. 다만 TypeScript로 사용하기 위해서는 옵션을 추가해야 한다.

npm install -g create-react-app

create-react-app my-app --scripts-version=react-scripts-ts

이렇게 해서 프로젝트를 생성하면 앱 실행에 react-scripts 대신 react-scripts-ts를 사용하기 때문에 소스를 TypeScript로 작성할 수 있다.

React 컴포넌트 타입 선언

컴포넌트로는 함수형의 stateless component, 클래스 형태의 statefull component, Redux 가 연결된 container component 가 있다. higher-order 컴포넌트도 있지만, 여기에서는 다루지 않도록 하겠다. 타입 선언을 위해서는 React의 타입 선언 패키지를 설치 후 진행해야 한다.

npm i -D @types/react @types/react-dom @types/react-redux

1. stateless component

React는 Props의 데이터형을 설정하기 위해 컴포넌트 클래스는 static 타입의 PropTypes 변수 안에 타입을 선언하는 방식을 사용한다. 하지만 이제는 TypeScript를 사용하므로 interfacetype을 사용해서 Props의 형태를 선언하면 된다.

import * as React from 'react'

type Props = {} // Props 타입 선언

// 컴포넌트의 리턴 타입을 Props 타입과 함께 설정
const TSSFC: React.SFC<Props> = props => {
  const { children, ...restProps } = props

  return <div {...restProps}>{children}</div>
}

export default TSSFC

React의 Props와 State의 타입 선언에는 generic 타입을 사용한다. generic 타입은 함수처럼 인자를 받아서 타입 선언을 유동적으로 바꿀 수 있으므로 컴포넌트마다 서로 다른 Props와 State를 선언하는데 활용된다. 컴포넌트 TSSFC의 리턴 타입이 React.SFC<Props>으로 되어 있는데, 이는 Props라는 타입을 전달받아서 내부적으로 타입 선언에 사용한다는 의미이다.

2. statefull component

import * as React from 'react'

type Props = {}
type State = {}

class TSComponent extends React.Component<Props, State> {
  static defaultProps = {}

  state: State = {}

  constructor(props: Props) {
    super(props)
  }

  render() {
    return <div>TSComponent</div>
  }
}

export default TSComponent

클래스 컴포넌트는 React.Component 대신 React.Component<Props, State>를 상속받는 방식을 사용해서 타입을 추가한다. 그리고 클래스 내부에서 state 변수를 선언할 때 타입을 붙여주고 constructor 함수의 인자에도 타입을 붙여준다. 이 외에는 Javascript의 React 컴포넌트와 차이가 없다. 오히려 PropTypes를 이용한 타입 선언보다 TypeScript의 타입 선언이 더 간단하기 때문에 더 간결하게 느껴질 수 있다.

3. container component

import * as React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators, compose, Dispatch } from 'redux'
import { RootState } from '../../store/rootReducer'
import todoActions from '../../store/todo/actions'
import { returntypeof } from 'react-redux-typescript'

const mapStateToProps = (state: RootState) => ({
  todoList: state.todo.list,
})

const mapDispatchToProps = (dispatch: Dispatch<{}>) =>
  bindActionCreators(
    {
      addTodo: todoActions.addTodo,
    },
    dispatch
  )

// 두 함수에 의해 생성될 Props를 객체 형태로 가져온다.
const statePropTypes = returntypeof(mapStateToProps)
const actionPropTypes = returntypeof(mapDispatchToProps)

type Props = typeof statePropTypes & typeof actionPropTypes & {}
type State = {}

class Container extends React.Component<Props, State> {
  static defaultProps = {}
  state: State = {}

  constructor(props: Props) {
    super(props)
  }

  render() {
    return null
  }
}

export default compose(connect(mapStateToProps, mapDispatchToProps))(Container)

컨테이너 컴포넌트는 외부 라이브러리를 활용한다. mapStateToProps 함수와 mapDispatchToProps 함수의 리턴값을 typeof 명령어로 타입 형태로 바꿀 수 있도록 객체 형태로 바꿔주는 returntypeof라는 헬퍼 함수가 그것이다. 두 함수의 리턴 타입을 직접 작성하는 것보다 자동으로 생성해주는 라이브러리를 사용함으로서 컴포넌트의 Props 타입 선언을 무척 간편하게 할 수 있다.

위의 예제 코드에서 returntypeof(mapStateToProps)의 결과는 { todoList: state.todo.list }가 되고 이를 typeof 연산자로 타입으로 변경하면 아래와 같은 형태가 된다.

type StatePropTypes = {
  todoList: Array<Todo>, // 예시를 위해 임의로 todoList가 Todo 타입의 배열이라고 표현
}

그리고 type 키워드로 선언한 타입은 & 연산자를 통해 intersection 타입 형태로 합치는 것이 가능하므로 사용자가 직접 정의한 Props와 함께 사용할 수 있다.

type Props = typeof statePropTypes & typeof actionPropTypes & {}

React-Redux의 타입 선언

action 객체

export const ADD_TODO = 'ADD_TODO'

export type Actions = {
  ADD_TODO: {
    type: typeof ADD_TODO // string
    content: string
  }
}

export const todoActions = {
  addTodo: (content: string): Actions[typeof ADD_TODO] => ({
    type: ADD_TODO,
    content,
  }),
}

Javascript로 액션을 설정할 때는 타입 상수만 선언해주면 되었지만 여기서는 Actions 타입도 함께 선언해줘야 한다. 그 이유는 액션 타입을 reducer에서 사용하기 위함이다. 액션의 타입이 정의되어 있어야 reducer에서 어떤 액션인지 파악할 수 있다.

액션의 타입을 직접 선언해줘야 해서 코드의 양이 이 부분에서 많이 늘어난다. 하지만 이 작업을 제대로 해 줘야 reducer의 case문 내부에서 어떤 액션인지 제대로 찾을 수 있다. 액션의 타입 형태는 거의 비슷하므로 복사 & 붙여넣기를 사용하면 작업 시간을 줄일 수 있지만, 작업 시간이 늘어나는 건 어쩔 수 없다.

root action

import { Actions as TodoActions } from './todo/todoActions'
import { Actions as AnotherAction } from './todo/anotherAction'

type RootAction = TodoActions[keyof TodoActions] | AnotherAction[keyof AnotherAction]

export default RootAction

모든 액션 타입을 합친 하나의 루트 타입을 만든다. RootAction| 연산자를 사용해서 만드는 union 타입 형태로서 or 연산자와 유사한 의미다. 앞서 생성한 Actions 타입을 가져온 후 keyof 키워드를 사용해서 union 타입 형태로 변환한 후 RootAction 타입에 할당한다. 다른 액션 생성자 타입도 같은 방식으로 계속 추가하면 된다.

추가로 설명하자면 TodoActions[keyof TodoActions] 코드의 결과는 아래와 같은 형태가 된다.

type todoActionUnion =
  | {
      type: 'ADD_TODO',
      content: string,
    }
  | {
      type: 'TOGGLE_TODO',
      id: string,
    }

reducer 함수와 state

import { Todo } from './todoModel'
import { ADD_TODO } from './todoActions'
import RootAction from '../rootAction'

export type TodoState = Readonly<{
  list: Array<Todo>,
}>

const initialState: TodoState = {
  list: [],
}

const todo = (state: TodoState = initialState, action: RootAction) => {
  switch (action.type) {
    case ADD_TODO:
    // 에디터는 action이 어떤 타입인지 case와 RootAction 타입을 사용해서 파악해준다

    default:
      return state
  }
}

export default todo

todo state의 형태를 표현한 TodoState 타입 선언에는 ReadOnly 키워드를 사용했다. 이는 컴파일시점에서 이 타입이 할당된 객체가 수정되었는지 검사하는데, redux는 immutable한 객체 상태를 지향하기 때문에 사용하는 것이 좋다.

그리고 reducer에서는 RootAction 타입과 추후 기술할 RootReducer 타입을 사용한다. reducer 함수의 파라미터인 state 에는 현재 reducer에서만 사용하는 데이터를 표현하는 TodoState 타입을 사용하고, action 에는 RootAction을 사용했다. RootAction 대신 todoActions 파일에서 선언해둔 타입을 가져와서 RootAction을 만들 때 사용했던 union 타입을 만드는 방식을 사용해도 된다.

const todo = (
  state: TodoState = initialState,
  action: TodoAction[keyof TodoActionType]
) => {
  ...
}

이렇게 state 함수의 action 파라미터에 타입을 추가하면 case 문 내부에서 action이 어떤 타입인지 자동으로 파악해준다. 단, 타입을 정확하게 선언해둬야 한다. 수많은 액션 객체를 생성하고 타입을 선언하다 보면(=복사 & 붙여넣기를 반복하다 보면) 잘못 선언한 타입이 생길 가능성은 얼마든지 있다.

root reducer

import { combineReducers } from 'redux'
import todo, { TodoState } from './todo/todoReducer'

export interface RootState {
  todo: TodoState;
}

const rootState = combineReducers<RootState>({
  router,
  todo,
});

export default rootState

root reducer 는 rootAction보다 단순하다. reducer들의 state 구조를 표현한 타입을 가져와서 합치기만 하면 된다. 그리고 combineReducers 함수가 리턴해야 하는 객체의 형태가 가변적이므로 generic type으로 RootState를 전달하고 있다.

이렇듯 장황한 타이핑이 필요한 TypeScript

React, Redux에서 TypeScript를 사용하는 방법을 정리해보았다. 개인적으로 TypeScript, Flow를 모두 시도해 본 상황에서 다음 프로젝트에 TypeScript를 사용할지 말지를 고민하고 있다. 동적 타입의 언어는 유연하다는 장점도 있고 개발 진행 속도도 빠르기 때문이다. 인기 있는 언어인 php, python, ruby도 동적 타입 언어이기도 하다. 많은 팀원이 참여하고 있는 프로젝트라면 TypeScript를 도입해볼 만 하지만 타입 선언만 강제할 수 있을 뿐 서로의 코딩 스타일이 달라서 결국 표준 스타일의 정립과 많은 대화의 필요성은 마찬가지일 것 같다.


참고 자료

React, Redux 타입 설정

본문에서 사용한 전체 예제 코드