오, 리덕스!

권세원·2023년 5월 27일
0
post-thumbnail

리덕스!

최근 동아리에서 스터디를 진행하게 되었다.
그 첫 번째 주제로 리덕스에 대해 공부하기로 하였다.
지금부터 리덕스에 대해 알아보자.


Before - 상태관리 라이브러리

리덕스에 대해 알아보기 전에 상태관리 라이브러에 대해 알아보았다.
아래를 통해 상태관리 라이브러리에 대해 알아보자.

상태관리 라이브러리?

What - 리덕스란 무엇인가

JS의 상태관리 라이브러리

Recoil이 React만을 위한 상태관리 라이브러리라면,
Redux는 vanila js 에서도 사용이 가능하다.

  • 프로젝트의 규모가 커지면 상태관리 라이브러리의 사용은 필수이다.
  • 시장에는 각각의 특성과 장단점을 가진 다양한 상태관리 라이브러리들이 존재한다.
  • 그중에서도 리덕스의 사용률은 단연 1등이다.

Why - 왜 리덕스인가

흐름

리덕스는 단방향으로 데이터가 흐른다.
즉 상태와 버그 예측이 쉽다.
고로 디버깅하기 좋다.

기록

액션과 상태 변화가 모두 로그에 남는다

위 주된 두가지 특징이 유지보수와 디버깅을 용이하게 해준다.

범용성

Recoil이 더 사용하기 쉽고 단순하지만 React에만 국한되어 있다면,
리덕스는 React뿐만 아니라, 바닐라, Ts, Vue 등 여러 환경에서 사용할 수 있다.

미들웨어

리덕스는 여러가지 유용한 미들웨어를 제공한다.
비동기 처리 작업 및 추가적인 작업에 있어서 용이하다.

통제

사전에 정의한 동작만 처리한다.
즉, 상태 변화를 예측, 통제할 수 있다.
(의도하지 않은 상태 변화를 방지한다.)

How - 어떻게 사용하는가

리덕스의 구성과 동작 방식은 생각보다 단순하다.

리덕스의 구성 요소

  • 액션(Action)
  • 액션 생성함수(Action Creator)
  • 리듀서(Reducer)
  • 스토어(Store)
  • 디스패치(dispatch)
  • 구독(subscribe)

액션(Action)

정확한 정의를 찾기도 힘들 뿐더러 그또한 추상적이어서 나만의 해석으로 정의하자면,

이름처럼 어떠한 상태의 업데이트를 위해 어떤 액션을 취해주어야 하는지 명시해주는 객체.
즉, 실직적으로 상태를 변경시키는 리듀서가 어떤 동작을 취해야 하는지 참조하는 객체라고 할 수 있다.

액션의 구성 요소를 크게 두가지로 분류할 수 있는데

  • type(필수) : 어떤 형태로 실행될지 명시(실행될 함수의 이름 정도로 여기면 된다.)
  • 참조 값(선택) : 상태 업데이트를 위해 참조할 값(필요에 따라 존재하지 않을 수 있다.)
{
  type: 'ADD_TODO',
  text: '밥먹기'
}

{
  type: 'INCREASE',
  num
}

액션 생성함수(Acction Creator)

매번 액션 객체를 직접 장성하기 번거로울 뿐만 아니라, 만드는 과정에서 실수를 할 수 있기에 이를 함수로 만들어 관리하는 것이 좋다.

아래와 같은 구조로 액션 객체를 생성하는 함수를 만들 수 있다.

const addTodo = text => ({
  type: 'ADD_TODO',
  text
});

리듀서(Reducer)

변화를 일으키는 함수.
즉, 실질적인 로직을 가지고 state와 액션 객체를 참조하여 상태를 업데이트 시킨다.
현재 상태(state)와 액션 객체를 파라미터로 받아 그 값들을 참조한다.

const initialState = { // 기본값 설정을 위한 객체
  counter: 1
};
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT:
      return {
        counter: state.counter + 1
      };
    default:
      return state; // 지정되지 않은 요청시 값 반환
  }
}

스토어(Store)

프로젝트에 리덕스를 적용하기 위해 스토어를 만들어야한다.
하나의 프로젝트는 하나의 스토어만 가질 수 있으며, 스토어는 현재 애플리케이션 상태와 리듀서, 그외 내장함수들을 포함하고 있다.

const store = createStore(reducer);

console.log(store.getState()); // getState() 함수를 통해 현재 스토어에 들어있는 상태를 조회한다.

위와 같이 createStore함수를 통해 스토어를 생성할 수 있으며, 파라미터로 리듀서를 전달한다.

디스패치(dispatch)

디스패치는 액션을 발생시키는 역할을 한다.
트리거 같은 존재이다.

const button = element.addEventListener("click", increaes);
const increase = () => {
  store.dispatch(increase(1));
};
const decrease = () => {
  store.dispatch(decrease());
};

위와 같이 dispatch()함수에 액션 객체를 파라미터로 전달하는 방식으로 사용할 수 있다.

구독(subscribe)

스토어의 내장함수로 아래와 같이 subscribe()함수에 리스너 함수를 파라미터로 넣어 호출하여주면, 스토어가 업데이트될 때마다 이 리스너 함수를 호출시켜줍니다.

const listener = () => {
  console.log("상태가 업데이트됨");
}
const unsubscribe = store.subscribe(listener);

unsubscribe(); // 추후 구독을 비활성화할 때 함수를 호출

바닐라 Js 에서는 subscribe 함수를 직접 사용하지만,
React 환경에서는 react-redux라는 라이브러리가 대신 처리해줄 수 있다.

Rule - 리덕스 규칙

단일 스토어

하나의 프로젝트 안에는 하나의 스토어만 존재한다.
스토어를 복수 사용하는 것이 아예 불가능한 것은 아니지만 권장하지 않는다.

읽기 전용 상태

리덕스는 읽기 전용 상태이다.
상태를 업데이트할 때 기존의 객체는 건드리지 않고 새로운 객체를 생성, 반환해주어 불변성을 유지해야 한다.

리듀서 === 순수 함수

리듀서 함수는 순수한 함수여야 한다.
순수한 함수는 다음 조건을 충족하여야 한다.

  • 리듀서 함수는 이전 상태와 액션 객체를 파라미터로 받는다.
  • 파라미터 외의 값에 의존여선 안된다.
  • 이전 상태는 절대 건드리지 않고, 변화를 준 새로운 상태 객체를 만들어서 반환한다.
  • 똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과 값을 반환해야 한다.

위 조건들을 정리해보면,
리듀서는 파라미터 이외의 값에 의존하지 않아야 하며, 멱등성과 불변성을 지켜야한다.

Extend - 리액트로 리덕스를 써보자!

React 환경에서 리덕스를 사용해 카운터를 만들어보자!

필요 라이브러리

리액트 프로젝트에서 리덕스를 사용하려면 다음 라이브러리들이 필요하다.

  • redux: 리덕스 모듈
  • react-redux: 리액트 컴포넌트에서 리덕스를 사용하기위한 유용한 도구들 포함
  • redux-action: 액션 생성에 용이함(필수는 아니지만 유용함)

액션 타입 정의하기

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

매직 리터럴은 사용하지 않기 위해 변수로 선언하였으며,
중복을 방지하기 위해 모듈이름/액션이름의 형태로 작성하였다.

액션 생성 함수 만들기

export const increase = () => ({ type: INCREASE});
export const decrease = () => ({ type: DECREASE});

생성 함수를 export 하여 추후에 다른 파일에서 사용할 수 있도록 하였다.

초기 상태 세팅 및 리듀서 함수 만들기

const initialState = {
  number: 0
};

const counter = (state = initialState, action) => {
  switch (action.type) {
    case INCREASE:
      return {
        number: state.number + 1
      };
    case DECREASE:
      return {
        number: state.number - 1
      };
    default:
      return state; // 의도되지 않은 요청은 그대로 반환시켜준다.
  }
}

스토어 생성과 적용

스토어 생성 방식은 동일하다.
하지만 구조상 적용 방식은 차이가 있다.
아래 코드를 참고하자

// index.js
...
import { createStore } from 'redux';
import { Provider } from 'react-redux';

const store = createStore(reducer);

root.render(
	<Provider store={store}>
  		<App />
  	</Provider>
);

react-redux에서 제공하는 Provider 컴포넌트에 스토어를 파라미터로 전달하고, App 컴포넌트를 감싸는 방식으로 프로젝트 전반에 리덕스 스토어를 적용시켜줄 수 있다.

루트 리듀서

프로젝트에 따라 리듀서는 늘어날 수 있다.
이러한 여러개의 리듀서는 하나로 합쳐서 사용해야 한다.
그 이유는 스토어를 생성할 때 파라미터로 리듀서를 주어야 하는데, 이때 하나의 리듀서만을 사용해야 한다.

고로 기존에 만들었던 리듀서들을 하나로 합쳐야 하는데, 이때 combineReducers라는 리덕스 유틸 함수를 사용한다.

방법을 아래와 같다.

// modules/index.js
import { combineReducers } from 'redux';
import counter from './counter'; 
import todos from './todos'; // 투두 리듀서가 있다고 가정한다.

const rootReducer = combineReducer({
  counter,
  todos,
})

export default rootReducer;

위 방식을 스토어에 적용시키면

// index.js
...
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules'

const store = createStore(rootReducer);

root.render(
	<Provider store={store}>
  		<App />
  	</Provider>
);

컨테이너 컴포넌트 만들기

import Counter from '아무튼 카운터 컴포넌트 주소'; // 카운터 ui 컴포넌트

const CounterContainer = () => {
  return <Counter />;
};

export default CounterContainer;

위 컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다.
connect 함수에는 리덕스 스토어 안에 있는 상태를 넘겨주는 mapStateToProps와
액션 생성 함수를 넘겨주는 mapDispatchToProps라는 함수를 사용한다.
위 두 함수는 connect 함수 안에서 선언하지 않고 익명 함수로 사용할 수 있다.

바로 적용하면

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} /> // 액션 생성 함수 전달
  );
};

export default connect(
  state => ({
    number: state.counter.number,
  }),
  dispatch => ({
    increase: () => dispatch(increase()),
    decrease: () => dispatch(decrease()),
  }),
)(CounterContainer);

생략은 우리의 코드를 깔끔하게 만든다

redux에서 제공하는 bindActionCreators 유틸 함수를 사용하면 이마저도 줄일 수 있다.

하지만 여기서 또!

이것 조차도 생략할 수 있다.

그냥 increase, decrease를 두 번째 파라미터에 객체 형태로 넣어 주면 이마저도 생략이 가능하다.
(connect함수가 내부적으로 bindActionCreators의 작업도 처리해준다.)

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} /> // 액션 생성 함수 전달
  );
};

export default connect(
  state => ({
    number: state.counter.number,
  }),
  {
    increase,
    decrease,
  },
)(CounterContainer);

이 얼마나 깔끔한가

More - 리덕스를 더 편하게 사용해보자

redux-actions

redux-actions 라이브러리는 액션과 관련된 작업을 더 간결하게 만들어준다.

createAction

createAction함수를 이용하여 액션 생성 함수를 더 간단하게 액션 생성 함수를 선언할 수 있다.

import { createAction } from 'redux-actions';

const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';

export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

매번 직접 객체를 선언하지 않아도 된다.

handleActions

redux-actions의 handleActions라는 함수를 통해 switch문과 같은 조건문을 더 직관적이게 만들 수 있따.

import { createAction, handleActions } from 'redux-actions';

... // 대충 액션 생성 함수, 기본값

const counter = handleActions(
  {
    [INCREASE]: (state, action) => ({ number: state.number + 1}),
    [DECREASE]: (state, action) => ({ number: state.number - 1}),
  },
  initialState,
);

export default counter;

코드가 상당히 짧아지고 가독성 또한 좋아졌다(직관적이다).

파라미터가 필요하다면?

위 코드에서는 파라미터가 필요하지 않았다.
하지만 파라미터가 필요하다면?

playload라는 이름으로 파라미터를 관리해주어야 한다.

const INSERT = 'todos/INSERT';
export const insert = createAction(INSERT);
const action = insert('밥먹기');

/*
	결과: { type: INSERT, playload: '밥먹기'}
*/

값에 변형을 주고 싶다면 아래와 같이 선언해주면 된다.

const INSERT = 'todos/INSERT';
export const insert = createAction(INSERT, text => `${text}!!!!!!`);
const action = insert('밥먹기');

/*
	결과: { type: INSERT, playload: '밥먹기!!!!!!'}
*/

리듀서 함수 또한!

const todos = handleActions(
  {
    [INSERT]: (state, { playload: todo }) => ({ 
      ...state, 
      todos: state.todos.concat(todo),
    }),
  },
  initialState,
);

...

Hooks를 사용하여 컨테이너 컴포넌트를 생성하자

useSelector로 상태 조회하기

useSelector 훅을 사용하면 connect 함수를 사용하지 않고도 리덕스의 상태를 조회할 수 있다.

...
import { useSelector } from 'react-redux';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  return <Counter number={number} />;
};

...

connect를 따로 선언하지 않으니 훨 간결하다.

useDispatch로 액션 디스패치하기

useDispatch 훅은 컴포넌트 내부에서 dispatch를 사용할 수 있게 해준다.

...
import { useSelector, useDispatch } from 'react-redux';

const CounterContainer = () => {
  const number = useSelector(state => state.counter.number);
  const dispatch = useDispatch();
  const onIncrease = useCallback(() => dispatch(increase()), [dispatch]);
  const onDecrease = useCallback(() => dispatch(decrease()), [dispatch]);
  return <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />;
};

...

useDispatch를 사용할 때는 이렇게 useCallback과 함께 사용하는 습관을 들여야 한다.
컴포넌트 성능 최적화 해야하니까...

useStore를 사용하여 리덕스 스토어 사용하기

useStore 훅을 사용하면 컴포넌트 내부에서 리덕스 스토어 객체를 직접 사용할 수 있다.

const store = useStore();
store.dispatch({ type: 'SAMPLE_ACTION' });
store.getState();

이건 알아만 두자.
쓸일 거의 없을거다.

useActions 사용하기!

useActions는 정식으로 릴리즈 되진 않았지만 사용은 가능하다.
리덕스 공식 웹에 코드가 있으니 참고하자!
react-redux공식 웹

이 훅은 여러개의 액션을 사용해야 하는 경우 코드를 훨씬 깔끔하게 정리할 수 있게 해준다.

lib 디렉토리에서 사용해보자!

코드는 아래와 같다.

import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';

const useActions = (actions, deps) => {
	const dispatch = useDispatch();
  	return useMemo(
    	() => {
         if(Arrays.isArray(actions)){
          	return actions.map(a => bindActionCreators(a, dispatch))
         }
         return bindActionCreators(actions, dispatch);
        },
      deps ? [dispatch, ...deps] : deps
    );
}

export default useActions;

위 코드를 아래와 같은 방식들로 사용할수 있다.

import useActions from 'lib/경로'; // lib 아니라도 상관없다.

const boundAC = useActions(actionCreator : Function, deps : any[])
const boundACsObject = useActions(actionCreators : Object<string, Function>, deps : any[])
const boundACsArray = useActions(actionCreators : Function[], deps : any[])

deps는 생략하여도 상관 없으나 최적화를 생각한다면 빈 배열이라도 넣는게 좋다.

예를 들면

import useActions from 'lib/경로'; 

const [onChangeINPUT, onInesrt, onToggle, onRemove] = 
      useActions([changeInput, insert, toggle, remove], []);

WOW
간결하다.


흠..

리덕스..
많은 글에서 리덕스는 찍먹조차 힘들다고 하였다.
그런데 막상 공부해보니 또 못할건 아니었다.
사람들이 리덕스를 어려워하는 이유는 크게 두가지 정도로 느껴진다.

  • 리덕스는 설명 자체가 쉽지 않다. 실제로도 쉽진 않다.
  • 고려할게 너무 많다. 그냥 하다가 지쳐버리는 경우가 많을거다.

이번에 리덕스를 공부하면서 어느정도 알게 되었긴 하지만
역시나 완전히 내것으로 만든 느낌은 아니다.

입에 머금고만 있는 느낌이다.

미들 웨어 더 공부하면서 이제는 몇번 씹어봐야겠다.

실제로 더 사용하면 언젠가 삼킬 수 있지 않을까 싶다.

profile
rnsjtpdnjs

0개의 댓글