본문 바로가기

개발개발

리셀렉트에 대한 이해

해당 글은 리셀렉터에 대한 이해를 돕고자 reselect 깃헙 페이지의 일부를 번역한 글입니다.

Example

메모된 셀렉터(Memoized Selectors) 대한 필요성

해당 섹션의 예제는 리덕스의 Todos List 예제를 참고합니다

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case 'SHOW_ALL':
      return todos
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed)
  }
}

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state.todos, state.visibilityFilter)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

위 코드에서 mapStateToPropstodos 를 계산하기 위해 getVisibleTodos를 호출합니다. 이 코드는 잘 동작하지만, 상태 트리가 변경될때 마다 매번 todos가 계산된다는 단점이 있습니다. 만약 상태 트리가 크거나 계산 비용이 클 경우, 상태 트리가 변경될때 마다 반복되는 계산은 성능 문제를 일으킬 것입니다. Reselect는 이런 불필요한 재계산을 피하도록 도울 수 있습니다.

메모된 셀렉터 만들기

getVisibleTodosstate.todos의 값이나 state.visibilityFilter의 값이 변경될때만 todos를 재계산하고 그외의 값들의 변경시에는 재계산 하지않는 메모된 셀렉터로 변경해 보겠습니다. Reselect는 메모된 셀렉터를 만들어주는 createSelector 함수를 제공합니다. createSelector 함수는 인자로 input-selector 배열과 transform 함수를 가집니다. 이 셀렉터는 input-selector의 값이 변경되어 리덕스(Redux) 상태 트리가 변경되면 input-selector들의 값을 인자로하고 그 결과를 반환하는 transform 함수를 호출합니다. 만약 input-selector들의 값이 이전과 같은 값이라면 transform 함수를 호출하는 대신 이전에 계산된 값을 반환합니다.

위의 메모되지 않는 셀렉터인 getVisibleTodos를 메모된 셀렉터로 정의 해봅시다.

selectors/index.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state) => state.visibilityFilter
const getTodos = (state) => state.todos

export const getVisibleTodos = createSelector(
  [ getVisibilityFilter, getTodos ],
  (visibilityFilter, todos) => {
    switch (visibilityFilter) {
      case 'SHOW_ALL':
        return todos
      case 'SHOW_COMPLETED':
        return todos.filter(t => t.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(t => !t.completed)
    }
  }
)

위 코드에서 getVisibilityFiltergetTodos는 iuput-selector입니다. 이 셀렉터들은 셀렉트 하는 데이터를 변환(가공)하지 않기 때문에 메모되지않은 셀렉터 함수로 만들어졌습니다. 그에 반해 getVisibleTodos는 메모된 셀렉터입니다. 이 셀렉터는 getVisibilityFiltergetTodos를 input-selector로 가지고 todos 리스트를 필터링하여 재계산하는 변환 함수 입니다.

셀렉터 조합하기

메모된 셀렉터는 또다른 메모된 셀렉터의 input-selector이 될 수 있습니다. 다음 코드에서 getVisibleTodos는 키워드로 todos를 필터링하는 셀렉터의 input-selector로써 사용되었습니다.

const getKeyword = (state) => state.keyword

const getVisibleTodosFilteredByKeyword = createSelector(
  [ getVisibleTodos, getKeyword ],
  (visibleTodos, keyword) => visibleTodos.filter(
    todo => todo.text.includes(keyword)
  )
)

리덕스 스토어와 셀렉터 연결하기

React Redux를 사용한다면 mapStateToProps() 내부에서 셀렉터를 일반함수로써 호출할 수 있습니다.

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { getVisibleTodos } from '../selectors'

const mapStateToProps = (state) => {
  return {
    todos: getVisibleTodos(state)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

셀렉터에서 리액트 프롭스(Props) 접근하기

지금까지 셀렉터만 리덕스 스토어 상태를 인자로 받는 것만 보았지만, 셀렉터도 프롭스를 받을 수 있습니다.
다음 App컴포넌트는 각각의 인스턴스가 listId 프롭스를 가진는 3개의 VisibleTodoList컴포넌트를 렌더링합니다.

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'

const App = () => (
  <div>
    <VisibleTodoList listId="1" />
    <VisibleTodoList listId="2" />
    <VisibleTodoList listId="3" />
  </div>
)

VisibleTodoList컨테이너는 listId프롭스에 따라 다른 상태값을 가집니다. getVisibilityFiltergetTodos가 프롭스를 인자로 받아들일수 있도록 수정해봅시다.

selectors/todoSelectors.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state, props) =>
  state.todoLists[props.listId].visibilityFilter

const getTodos = (state, props) =>
  state.todoLists[props.listId].todos

const getVisibleTodos = createSelector(
  [ getVisibilityFilter, getTodos ],
  (visibilityFilter, todos) => {
    switch (visibilityFilter) {
      case 'SHOW_COMPLETED':
        return todos.filter(todo => todo.completed)
      case 'SHOW_ACTIVE':
        return todos.filter(todo => !todo.completed)
      default:
        return todos
    }
  }
)

export default getVisibleTodos

propsmapStateToProps로부터 getVisibleTodos에 전달됩니다.

const mapStateToProps = (state, props) => {
  return {
    todos: getVisibleTodos(state, props)
  }
}

이제 getVisibleTodosprops에 접근하게되었고 모든것이 잘동작 할 것 같습니다.

그러나 문제가 있습니다.

VisibleTodoList 컨테이너의 여러 인스턴스에서 getVisibleTodos 셀렉터를 사용하면 옳바르게 메모되지 않습니다.

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { getVisibleTodos } from '../selectors'

const mapStateToProps = (state, props) => {
  return {
    // WARNING: THE FOLLOWING SELECTOR DOES NOT CORRECTLY MEMOIZE
    todos: getVisibleTodos(state, props)
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

createSelector로 생성된 셀렉터는 캐쉬 크기가 1이고 셀렉터의 인자들이 이전과 같을 때만 캐쉬된 값을 반환합니다. <VisibleTodoList listId="1" /><VisibleTodoList listId="2" />이 번갈아 렌더링되면 공유된 셀렉터가 props 인자로 {listId: 1}{listId: 2}를 번갈아 받게됩니다. 이러면 각 호출마다 인자가 달라지게 됩니다. 그래서 셀렉터는 캐쉬괸 값을 반환하는것 대신 항상 재계산하게됩니다. 다음 섹션에서 이문제를 해결하는 방법을 보겠습니다.

여러 컴포넌트 인스턴스에서 props와 셀렉터 공유하기

다음 코드는 리엑트 리덕스 v4.3.0 이상을 필요로 합니다.
re-reselect에서 다른 해결방법을 볼 수 있습니다.

여러 VisibleTodoList 인스턴스에서 셀렉터를 공유하기 위해서는 props를 전달하고 각 컴포넌트의 인스턴스가 전용 셀렉터를 가져서 메모가 유지되어야합니다. getVisibleTodos 셀렉터가 호출될때 마다 새로운 셀렉터를 반환하는 makeGetVisibleTodos 함수를 만들어 봅시다.

selectors/todoSelectors.js

import { createSelector } from 'reselect'

const getVisibilityFilter = (state, props) =>
  state.todoLists[props.listId].visibilityFilter

const getTodos = (state, props) =>
  state.todoLists[props.listId].todos

const makeGetVisibleTodos = () => {
  return createSelector(
    [ getVisibilityFilter, getTodos ],
    (visibilityFilter, todos) => {
      switch (visibilityFilter) {
        case 'SHOW_COMPLETED':
          return todos.filter(todo => todo.completed)
        case 'SHOW_ACTIVE':
          return todos.filter(todo => !todo.completed)
        default:
          return todos
      }
    }
  )
}

export default makeGetVisibleTodos

또한 컨테이너의 각 인스턴스가 가지는 전용 셀렉터에 접근할 수 있도록 해야합니다. connectmapStateToProps인자를 통해 가능합니다.
connect에 제공되는 mapStateToProps 인자가 객체 대신 함수일 경우 mapStateToProps 함수는 각 셀렉터에 대해 베타적인 접근을 허용하게됩니다.(참고)

다음 코드의 makeMapStateToProps는 새 getVisibleTodos 셀렉터를 만들고 해당 셀렉터에 유일하게 접근할 수 있는 mapStateToProps 함수를 반환 합니다.

const makeMapStateToProps = () => {
  const getVisibleTodos = makeGetVisibleTodos()
  const mapStateToProps = (state, props) => {
    return {
      todos: getVisibleTodos(state, props)
    }
  }
  return mapStateToProps
}

connectmakeMapStateToProps를 전달하면 각 VisibleTodoList 컨터이너는 전용 getVisibleTodos셀렉터를 가지는 mapStateToProps함수를 가지게 됩니다. 이제 VisibleTodoList 컨테이너의 순서에 관계없이 메모기능이 옳바르게 동작합니다.

containers/VisibleTodoList.js

import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { makeGetVisibleTodos } from '../selectors'

const makeMapStateToProps = () => {
  const getVisibleTodos = makeGetVisibleTodos()
  const mapStateToProps = (state, props) => {
    return {
      todos: getVisibleTodos(state, props)
    }
  }
  return mapStateToProps
}

const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id))
    }
  }
}

const VisibleTodoList = connect(
  makeMapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

'개발개발' 카테고리의 다른 글

타입 스크립트에서 배열과 튜플  (0) 2020.03.11
Redux Style Guide  (0) 2020.01.30
Expected linebreaks to be 'LF' but found 'CRLF'  (0) 2019.01.30
babel v7에서 babel-jest 적용기  (0) 2018.11.22
Mattermost 개발환경 구축기  (0) 2018.11.12