Selector: Redux를 좀 더 효율적으로

률루랄라·2020년 5월 24일
4
post-thumbnail

1. Redux의 비효율성과 selector

Redux를 사용하면 상태관리 한 곳에서 관리할 수 있어 효율적인 상태관리와 불필요한 렌더링을 막아준다. 하지만 하나의 작은 비효율성이라면 비효율성인 것이 있다.
다른 reducer가 새로운 객체를 반환하더라도 모든 컴포넌트의 모든 mapStateToProps함수가 매번 불려진다는 것이다.
단순히 상태의 값을 가져와 view로 전달하는 컴포넌트에서는 문제가 없지만
만약 view로 전달하기전에 state의 값을 가공해야한다면 이야기가 달라진다.
단순한 가공이라면 또 상관 없겠지만 만약 가공해야할 데이터의 크기가 크고 계산도 복잡하다면?
해당 컴포넌트에 영향을 주는 state의 변화가 아님에도 불구하고 application은 계속해서 계산을 할 것이고 이는 성능에 영향을 줄 것이다.
그럼 이런 성능저하를 어떻게하면 예방할 수 있을까?
바로 reselect 패키지가 제공하는 selector를 사용하면 된다.


2. reselect와 selector

reselect 패키지는 단순히 redux에서 selector를 사용할 수 있게 해주는 library이다.
간단하게 selector가 무엇인지 살펴보면 다음과 같다.
selector는 사용하고 있는 현재의 값을 caching 하거나 저장하여 사용한다.
Memoization과 관련있는 개념이라고 보면 된다.
그럼 그게 뭘까?


3. Caching과 Memoization

caching은 프로그램을 조금 더 빠르게 해주고 쉬운 접근성을 갖는 조그만한 데이터의 모음집이라고 생각하면 된다. 그리고 Memoization은 caching을 하는 구체적인 형태라고 보면 된다.
짤막한 예로 caching의 좋은점을 살펴보자.



const example = (string, num) => {
  console.log("example func starts")
  return {string:num}
}
console.log(example("one",1))
console.log(example("one",1))
console.log(example("one",1))

// console
'example func starts'
{ string: 1 }
'example func starts'
{ string: 1 }
'example func starts'
{ string: 1 }

example를 작동시켜서 매개변수들로 객체를 데이터로 저장하려고 한다.
3번의 console.log를 찍어서 확인해보니 객체화가 잘 됐지만 프로그램은 3번의 연산을 해야한다. 만약 수억번의 반복이 일어난다면 이는 엄청난 연산이 될 것이다.
그래서 caching을 해보려고 한다.

let cacheExample = {}
const memoizExample = (string, num) => {

  console.log("Function Starts")
if (cacheExample[string]) {
  console.log("data from cache")
  return cacheExample[string]
} else {
    console.log("data to cache")
cacheExample[string] = num
  return cacheExample[string]
}
}
console.log(memoizExample("one", 1))
console.log(memoizExample("one", 1))
console.log(memoizExample("one", 1))

//console
'Function Starts'
'data to cache'
1
'Function Starts'
'data from cache'
1
'Function Starts'
'data from cache'
1

이번에도 비슷하지만 위에 cache라는 변수에 빈 객체를 만들어 그곳에 저장하고 만약 맞는 값이 있다면 연산하는 대신 데이터를 가져와 사용할 것이다.
콘솔로 확인해보면 2번째 3번째 콘솔부터는 첫번쨰 if문의 연산을 하지 않는데 우리가 확인하고자 하는 데이터가 이미 cache변수에 담겨져 있기 때문이다.
이렇게 연산을 더 간단하게 할 수 있다는 장점이 있다.

4. selector 사용하기

그럼 application에 selector를 사용하여 reselect의 memoization 기능을 사용해보자!.
reselect가 제공하는 memoization 기능을 사용하여 data값이 변하지 않으면 새롭게 데이터값을 연산하는 대신에 이전의 값을 사용하여 불필요한 연산을 막아볼 것이다.
다시말해 caching data를 사용하여 불필요한 연산을 줄일 것이고 예제에 사용할 application의 코드는 아래와 같다.

//app.js
import React from 'react';
import './App.css';

import AppleButton from './components/apple-button.component';
import BananaButton from './components/banana-button.component';

const App = () => {
  return (
      <div className="foodlist-container">
        <AppleButton title="apple" />
        <BananaButton title="banana" />
      </div>
  );
};

export default App;

//appleButton.js
import React from 'react';
import { connect } from 'react-redux';
import { AddAppleCount } from '../redux/food/food.actions';
import { selectCurrentAppleCount } from '../redux/food/food.selectors';

const AppleButton = ({ AddAppleCount, appleCount, title }) => {
  return (
    <div>
      <button type="button" onClick={() => AddAppleCount(title)}>
        {title}
      </button>
      <div>{title === 'apple' && appleCount}</div>
    </div>
  );
};

const mapStateToProps = state => ({
  appleCount: console.log(state.foodList.apple.length),
});

const mapDispatchToProps = dispatch => ({
  AddAppleCount: title => dispatch(AddAppleCount(title)),
});
export default connect(mapStateToProps, mapDispatchToProps)(AppleButton);

// bananaButton.js

import React from 'react';
import { connect } from 'react-redux';
import { AddBananaCount } from '../redux/food/food.actions';

const BananaButton = ({ AddBananaCount, bananaCount }) => {
  console.log(bananaCount);
  return (
    <div>
      <button type="button" onClick={() => AddBananaCount('바나나')}>
        바나나
      </button>
      <div>{bananaCount}</div>
    </div>
  );
};

const mapStateToProps = state => ({
  bananaCount: console.log(state.foodList.banana.length),
});

const mapDispatchToProps = dispatch => ({
  AddBananaCount: banana => dispatch(AddBananaCount(banana)),
});
export default connect(mapStateToProps, mapDispatchToProps)(BananaButton);

//food.actions.js
import FoodActionTypes from './food.types';

export const AddAppleCount = title => ({
  type: FoodActionTypes.ADD_APPLE,
  payload: title,
});

export const AddBananaCount = banana => ({
  type: FoodActionTypes.ADD_BANANA,
  payload: banana,
});

// food.reducer.js
import FoodActionTypes from './food.types';

const INITIAL_STATE = {
  apple: [],
  banana: [],
};

const foodReducer = (state = INITIAL_STATE, action) => {
  switch (action.type) {
    case FoodActionTypes.ADD_APPLE:
      return {
        ...state,
        [action.payload]: state[action.payload].concat(action.payload),
      };
    case FoodActionTypes.ADD_BANANA:
      return {
        ...state,
        banana: state.banana.concat(action.payload),
      };
    default:
      return state;
  }
};

export default foodReducer;

먼저 apple버튼과 바나나 버튼을 만들어서 클릭하면 각각의 이름이 state 속 apple과 banna에 배열로 담기게 된다.
그리고 각각 그 배열의 길이를 콘솔로그에 담아 props로 넘어가도록 mapStateToProps 함수로 작성하여 view로 보여주도록 작성하였다.


4.1. 불필요한 호출

콘솔창을 열어서 apple 버튼을 클릭해보자.

먼저 apple-button 컴포넌트에 작성한 콘솔 그리고 banana-button 컴포넌트에 작성한 콘솔이 찍히고 마지막으로 state의 변화에 대한 로그가 나올 것이다.
근데 이상하다. 분명 나는 apple버튼을 눌렀고 state도 살펴보면 apple의 state만 변했는데도 banana-button 컴포넌트속 작성한 콘솔로그가 찍혔다.
banana-button 컴포넌트 파일에서 console.log위치를 찾아가보자.

//banana-button.js

//...
const mapStateToProps = state => ({
  bananaCount: console.log(state.foodList.banana.length),
});
//...

확인해보면 console.log의 위치가 mapStateToProps함수 안에 있는 것을 볼 수 있다.
banana의 값이 변한 것도 아닌데 왜 banana-button.jsmapStateToProps함수가 실행된 것일까?
앞서 말한 것처럼
다른 reducer가 새로운 객체를 반환하더라도 모든 컴포넌트의 모든 mapStateToProps함수가 매번 불려진다는 것이다.

즉 reducer함수가 실행되면 컴포넌트속 모든 mapStateToProps함수가 호출된다는 것이다. 그리고 위 예제 코드처럼 mapStateToProps함수속에서 연산을 하게 된다면 apple버튼을 누를 떄 마다 프로그램은 이 연산을 실행할 것이다.
이 불필요한 호출을 어떻게 예방할 수 있을까?

4.2. reselect

바로 reselect 라이브러리가 제공하는 selector를 통해서 막을 수 있다.
먼저 reselect를 설치해보자

npm install reselect
이렇게 설치 한 후
redux폴더 안에 각 데이터 성격에 맞는 폴더에 selector 파일을 만들어 주자.
이 경우에는 food폴더 안에 food.selectors.js라는 파일을 만들고 아래처럼 작성하였다.

//food.selectors.js
//1️⃣2️⃣3️⃣4️⃣ 순서로 작성

1️⃣import { createSelector } from 'reselect';

2️⃣const selectFood = state => state.foodList;

3️⃣export const selectCurrentAppleCount = createSelector(
  [selectFood],
  foodList => 
  console.log('apple selector', foodList.apple.length)
);
);
4️⃣export const selectCurrentBananaCount = createSelector(
  [selectFood],
  foodList => 
console.log('banana selector', foodList.banana.length)
);

1️⃣. 먼저 reselect 라이브러리에서 createSelector를 import 해주었다.
2️⃣. input selector라고도 불리는 selector형식이다. 단순히 state를 받아서 한단계 narrow down 해준다. 즉 select 해주는 거라고 생각하면 될 것같다.
3️⃣. 이제 output selector를 작성한다. createSelector함수의 매개변수로 두 가지를 넣어준다.
하나는 먼저 reference할 input selector를 배열안에 넣어준다. 배열 형태인만큼 여러개의 input selector를 지정해줄 수 있다.
그 다음 두번째로 input selector가 참조하는 state의 값을 가지고 연산을 하거나 기타 작업들을 해줄 수 있다. 이 예제 같은 경우는 selector의 기능을 참고하기 위해 console.log를 해주었다.
해석해보면 state.foodList를 참조하는 selectFood input selector를 참조해서 foodList의 state의 속의 apple 값에 접근하였고
그 길이를 콘솔에 로그하라는 코드다. 이렇게 내가 원하는 값을 select해주는 것이 selector이다.
4️⃣. 똑같이 이번에는 banana의 값에 접근하면 select 해주었다.

그 후 apple-button.jsbanana-button.js파일로 가서 적용해주자.

// apple-button.js
//...
1️⃣import { selectCurrentAppleCount } from '../redux/food/food.selectors';
//...
2️⃣const mapStateToProps = state => ({
  appleCount: selectCurrentAppleCount(state),
});
//...

1️⃣. 작성한 selector를 새롭게 import 해준다.
2️⃣. 이전에 있던 mapStateToProps함수를 위 예제처럼 수정해준다.
그 후 로컬에 화면을 띄워보고 apple버튼과 바나나 버튼을 순서대로 한번씩 눌러보자.

오잉? 무슨일인지 모든 apple과 바나나 두 컴포넌트의 mapStateToProps가 모두 찍힌다. reselect에서 제공하는 memoization 기능이 제대로 동작하지 않았다.
왜인지 알아보자

4.3. 선택자 함수의 결과값 재사용 시키기

다시 우리의 현재 state형태와 food.selector.js파일을 같이 살펴보자

다시말해

state ={
  foodList: {
    apple:[],
    banana:[]
  }
}

이렇게 우리의 state는 구성되어있다.
그렇다면 food.selector.js파일속 코드를 다시 살펴보자.

import { createSelector } from 'reselect';

const selectFood = state => state.foodList;

export const selectCurrentAppleCount = createSelector(
  [selectFood],
  foodList => 
  console.log('apple selector', foodList.apple.length)
);
);
export const selectCurrentBananaCount = createSelector(
  [selectFood],
  foodList => 
console.log('banana selector', foodList.banana.length)
);

위 코드를 살펴보면 selectCurrentAppleCountselectCurrentBananaCountcreateSelector로 만든 selector는
const selectFood = state => state.foodList;
이렇게 생긴 selectFood input selector를 참조하고있는 것을 알 수 있다.
그런데reducer함수가 새로운 객체를 반환한다고 했다.
이 예제 속 food reducer는 foodList라는 새로운 객체를 반환한다. 그렇게 때문에 apple버튼을 눌렀어도, banana의 state가 여전히 빈배열일지라도, 바나나 selector는 새로운 state가 인식되었다고 느껴 다시한번 연산하는 것이고 재사용하지 못하는 것이다.

그럼 선택자 함수가 이전의 결과값을 재사용하게 만들어주자
재사용 여부를 확인해보기 위해 apple-button 코드만 수정해 줄것이다.

// food.selectors.js
//...
const selectFood = state => state.foodList;

export const selectCurrentApple = createSelector(
  [selectFood],
  foodList => foodList.apple
);
// state를 한 depth 더 들어가서 선택해주자.
// 이 output selector 역시 다른 선택자 함수의 input selector로 사용 가능하다.

export const selectCurrentAppleCount = createSelector(
  [selectCurrentBanana],
  apple => console.log('apple selector', apple.length)
);
// mapStateToProps에 사용할 선택자 함수의 참조를 위에 작성한 selectCurrentApple selector로 지정해주었다. 

그 다음 local에 화면을 띄우고 apple 버튼과 바나나 버튼을 순서대로 한번씩 누르고 console을 확인해 보자.

잘 살펴보면 apple버튼을 눌렀을 때 두 버튼의 mapStateToProps함수가 모두 호출된 것에 비해 바나나 버튼을 클릭했을 때는 바나나 버튼의 mapStateToProps함수만 호출 된 것을 볼 수 있다.
아래쪽의 logger를 보면 알 수 있듯이 banana관련 action이 실행되고 banana의 배열에 바나나가 추가되었고 apple의 배열에는 아무 변화가 없다.
그렇기 떄문에 apple의 선택자 함수는 이전의 결과값을 재사용 하여 apple-button 컴포넌트는 데이터를 재연산하지 않은것을 알 수 있다. 즉 reselect에서 제공하는 memoization 기능이 제대로 작동한 것을 알 수 있다.

지금에야 간단한 연산이기에 큰 문제는 없지만 복잡한 연산으로 가공해서 data를 사용할 때는 큰 차이를 보인다.
설치나 사용에 있어서 너무 간단하기 때문에 많은 사람들이 추천하는 library이다.

5. createStructuredSelector

reselect는 createStructuredSelector 라는 함수도 제공하는데 application의 규모가 커질수록, mapStateToProps에 작성해야할 선택자 함수가 많아질수록 유용하다.
밑에 작성할 예제 코드에서 두개의 mapStateToProps함수는 동일한 것이다. 이렇게 사용하면 간편해진다는 것만 알고 정리를 마무리하겠다.

// someFile.js
const mapStateToProps = state => {
  cartItems: selectCartItems(state),
  total: selectCartToal(state),
  currentUser: selectCurrentUser(state),
  itemCount: selectCartItemsCount(state),
  collections: selectCollectionsForPreview(state),
  isLoading: selectIsCollectionFetching(state),
  sections: selectDirectorySections(state),
  hidden: selectCartHidden(state),
}
  //위 코드와 아래 코드는 동일하다.
//...
const mapStateToProps = createStructuredSelector({
  cartItems: selectCartItems,
  total: selectCartTotal,
  currentUser: selectCurrentUser,
  itemCount: selectCartItemsCount,
  collections: selectCollectionsForPreview,
  isLoading: selectIsCollectionFetching,
  sections: selectDirectorySections,
  hidden: selectCartHidden,
});
//...
profile
💻 소프트웨어 엔지니어를 꿈꾸는 개발 신생아👶

1개의 댓글

comment-user-thumbnail
2020년 5월 24일

👍

답글 달기