[React] Redux (2) 리덕스의 이해, react-redux lib

rin·2020년 5월 13일
1

React

목록 보기
5/16
post-thumbnail

ref. 아마 이게 제일 이해하기 쉬울걸요? React + Redux 플로우의 이해

개념

한마디로 정의하면 상태관리 라이브러리이다.
컴포넌트들의 상태 관련 로직들을 다른 파일들로 분리시켜서 효율적으로 관리하며, 여러 컴포넌트를 거치지 않고도 손쉽게 상태 값을 전달/공유 할 수 있다.

리덕스 미들웨어 : 비동기 작업, 로깅 등의 확장적인 작업들을 더욱 쉽게 할 수 있도록 돕는다.

Redux 플로우의 이해

Component가 Store로부터 props를 통해 정보를 받는다. 하지만 props는 immutable(불변적인)하며 상태가 변경될 때마다 새로운 component가 다시 만들어진다.

connect를 실행하고 있는 주변 코드를 보자!
1. Store가 가진 state를 어떻게 props에 엮을지 정한다(이 동작을 정의하는 함수 = mapStateToProps)
2. Reducer에 action을 알리는 함수인 dispatch를 어떻게 props에 엮을지 정한다(이 동작을 정의하는 함수 = mapDispatchToProps)
3. 위에 두가지가 적용된 props를 받을 Component를 정한다.
4. Store와 Reducer를 연결시킬 수 있도록 만들어진 Component가 반환값이 된다.
connect(mapStateToProps, mapDispatchToProps)(Component)

핵심 요소

액션(Action)

상태에 어떠한 변화가 필요할 때, 특정 행위를 발생시키는 객체이다. 액션 객체는 type 필드를 필수적으로 가지고 있어야하고, 그 외의 값들은 개발자 마음대로 넣어줄 수 있다.

//예시 1
{
    type: "ADD_TODO",
    data: {
        id: 0,
        text: "리덕스 배우기"
    }
} 
 
//예시 2
{
    type: "CHANGE_INPUT",
    text: "안녕"
}

액션 생성함수(Action Creator)

(바로 위에서 본)액션을 만드는 함수이다. 단순히 파라미터를 받아와서 액션 객체 형태로 만들어준다. Java로 치면 new 커맨드를 이용해서 객체를 만들어 반환만 해주는 메소드라고 생각하면 쉽다.

function addTodo(data) {
    return {
        type: "ADD_TODO",
        data
    }; //리턴되는 액션 객체
} 
 
//화살표 함수로 만들기
const addTodo = data => ({
    type: "ADD_TODO",
    data
});

리듀서(Reducer)

변화를 "일으키는" 함수. 액션은 어떤 '타입'인지(+a 기타 정보)만 '서술'할 뿐이며 state에 변화를 주는 '메소드'의 역할은 리듀서가 한다(액션 함수는 생성자). 현재 상태와 전달 받은 액션을 참고하여 새로운 상태를 만들어서 반환한다.

function reducer(state, action) {
    //상태 업데이트 로직
    return alteredState;
}

스토어(Store)

일반적으로 한 애플리케이션 당 하나의 스토어를 만들게 된다.
스토어 내부엔 현재 앱 상태와 리듀서가 포함돼있고, 추가적으로 몇가지 내장 함수들이 있다.

디스패치(Dispatch)

스토어의 내장함수 중 하나로써 액션이 일어나게 만들어주는 것이다. dispatch라는 함수에는 액션을 파라미터로 전달한다. e.g. dispatch(action)
1. 디스패치가 호출됨
2. 스토어는 리듀서 함수를 실행
3. 해당 액션(파라미터로 전달해 준 것)을 처리하는 로직이 있다면
4. 액션을 참고하여 새로운 상태를 만들어준다.

구독(Subscribe)

스토어의 내장함수 중 하나로써 함수 형태의 값을 파라미터로 받아온다.
subscribe 함수에 특정 함수를 전달하면, 액션이 디스패치 될 때 마다 subscribe 함수에 전달해준 함수가 호출된다.

리액트없이 리덕스 사용하기

우선 액션 타입을 정의한다. 프로젝트에서 상태에 변화를 일으키는 것을 하나의 액션으로 보고, 그 액션의 '이름'을 정해주는 과정. 액션은 메소드가 아니며 state를 변경하지 않는다. state가 변경되면 이를 감지하고 있다가 액션이 일어나는 것이다. 문자열 형태이며 주로 대문자로 작성한다. 이름은 고유해야 한다.

그 다음 액션 생성 함수 정의한다. 액션 객체를 만드는 생성자 함수라고 생각하자. 액션 객체는 type 값을 필수로 들고 있어야 하며, 나머지는 액션에서 '참고'하고 싶은 값을 개발자가 자유롭게 추가해도 된다.

프로젝트에서 사용하는 초깃값을 정의한다. 말 그대로 state로 받아올 값이 명시 되지 않았을 때 초기화 시키는 값이다. state 그 자체라고 생각해도 좋다. (물론 state에 치환되는 "값"일 뿐이다.)

리듀서 함수를 정의해 주었다. 이 함수는 파라미터로 state(null일 경우 바로 위에서 만들어둔 초깃값을 대답한다.)와 action을 받아 action.type에 따라 새로운 state를 생성해 리턴한다.
스토어를 만들 땐, createStore 함수를 사용하며, 파라미터로 리듀서 함수를 전달해준다.

❗️NOTE 불변성 유지
리듀서에서는 변하지 않는 상태를 유지해주면서 데이터에 변화를 일으켜주어야 한다. 이러한 작업을 하기 위해서, ... spread 연산자를 사용한다. 즉 기존의 값은 그대로 두고(변하지 않는 상태 유지), 새로운 값은 덮어쓰는(데이터 변화) 방식이다.

  • immer 같은 불변성 관리 라이브러리 사용 가능
  • spread 연산자를 사용하여 함수의 파라미터를 작성한 형태를 Rest 파라미터라고 부른다.

즉, Rest 파라미터를 사용하면 함수의 파라미터로 오는 값들을 배열로 전달받을 수 있다.
(Java의 가변인자와 유사.. 가장 마지막 파라미터에만 사용가능하다.)
spread 연산자와 spread 호출을 혼동하지 않도록 주의한다.

function foo(param, ...rest){
    console.log(param); //1
    console.log(rest); //[2,3]
}
foo(1,2,3); //rest 호출

function bar(x, y, z){
    console.log(x); //1
    console.log(y); //2
    console.log(z); //3
}
bar(...[1,2,3]); //spread 호출

주황색 박스를 먼저보자. 각 버튼이 클릭 될 때 마다 발생할 이벤트에서는 액션 생성 함수를 이용해 액션을 생성하고, 이를 디스패처로 넘겨주고 있다.

render 함수를 구독함으로써 dispatch에 의해 액션이 발생할 때 마다 재렌더링시킨다. 렌더 함수에서는 현재상태를 가져와 state.light의 값에 따라 DOM 속성을 변경한다.

const listener = () => console.log("update")
const unsubscribe = store.subscribe(listener);

subscribe 함수의 파라미터로는 함수형태의 값을 전달해주고, 이 함수는 (종류에 상관없이) 액션이 디스패치 될 때 마다 호출된다. 위와 예제 코드 같은 경우에는 console에 "update"라는 문자열이 계속 출력 될 것이다. subscribe를 호출한 뒤 반환값으로 구독을 해제하는 unsubscribe 함수를 받게된다.

❗️NOTE react-redux
리액트에서 리덕스를 쉽게 사용하기 위해 react-redux라는걸 사용하게 되는데 해당 라이브러리에서 대신 구독을 수행해주므로, 리액트 프로젝트에서 subscribe를 직접 해야 되는 일은 특별한 경우(e.g. 프로젝트에서 리액트 외의 라이브러리에 리덕스 연동 등)를 제외하고는 거의 없다.

리덕스의 3가지 규칙

하나의 어플리케이션 안에는 하나의 스토어가 있다.

여러개의 스토어 사용이 가능하지만 권장하지 않는다. 예를 들어 특정 업데이트가 빈번하거나 애플리케이션의 특정 부분을 완전히 분리하는 경우(모듈화) 여러개의 스토어를 만들 수 있다. 하지만 개발 도구를 활용 할 수 없게 된다.

상태는 읽기 전용

리액트에서 state 업데이트에는 setState을 사용하고, 배열 업데이트에는 concat 같은 함수를 사용하여 기존 배열 수정 없이 새로운 배열을 만들어 교체한다. 객체를 업데이트 하는 경우에도 기존 객체 수정를 수정하지 않고 Object.assign or spread 연산자를 사용해 새로운 객체를 만들어 반환한다. 리덕스 또한, 기존의 상태 건들이지 않고 새로운 상태를 생성하여 업데이트 해주는 방식이다.

변화를 일으키는 함수인 리듀서는 순수함수여야한다.

리듀서 함수는 이전 상태와 액션 객체를 파라미터로 받는다. 이전 상태는 절대로 건들이지 않고, 변화를 일으킨 새로운 상태 객체를 만들어서 반환(위의 상태는 읽기 전용과 일맥 상통)한다.
똑같은 파라미터로 호출된 리듀서 함수는 언제나 똑같은 결과값을 반환해야한다. (순수함수) 즉, new Date(), random(), network요청 등 실행 할 때마다 다른 결과값이 나타날 경우는 순수 함수가 성립되지 않는다. 따라서 이런 작업들은 리듀서 함수의 바깥에서 리덕스 미들웨어를 사용하는 등으로 대체하여 처리해줘야한다.

리액트와 함께 리덕스 사용하기

구조

components : 하위 파일들은 렌더링될 컴포넌트 객체이다. 이들은 함수형 객체로 만들어져 있으며, state와 action을 주입받는다.
containers : connect 함수를 이용하여 스토어의 state와 action을 props로 전달하고 components에 상태를 주입한다. 특정 액션을 dispatch로 등록하여 사용자가 특정한 이벤트(e.g. onClick)를 수행하여 리듀서에 의해 state가 변경되고 재렌더링되게 한다.

index.js

import React from 'react';
import ReactDOM from 'react-dom';
// 1-1. createStore와 루트 리듀서 불러오기
import { createStore } from 'redux';
import rootReducer from './store/modules';
// 1-2. Provider 불러오기
import { Provider } from 'react-redux';
 
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
 
// 2. 스토어를 만들고 현재 값 확인해보기
const store = createStore(rootReducer);
console.log(store.getState());
 
// 3. Provider 렌더링해서 기존의 App 감싸주기
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
registerServiceWorker();

Provider는 react-redux 라이브러리 안에 들어있는 컴포넌트로써 Provider의 props로 store를 주입함으로써 리액트 프로젝트에 스토어를 연동한다.
const store = createStore(rootReducer);로 만든 store<Provider store={store}>의 파라미터로 사용된다.

App.js

import React, { Component } from 'react';
 
import './App.css';
import Counter from './components/Counter';
import Palette from './components/Palette';
import WaitingList from './components/WaitingList';
import PaletteContainer from './containers/PaletteContainer';
import CounterContainer from './containers/CounterContainer';
 
class App extends Component {
  render() {
    return (
      <div className="App">
        <PaletteContainer/>
        <CounterContainer/>
        <WaitingList />
      </div>
    );
  }
}
 
export default App;

container를 모아서 컴포넌트로 엮는다.

store/modules/counter.js

//액션 타입 정의
const CHANGE_COLOR = 'counter/CHANGE_COLOR';
const INCREMENT = 'counter/INCREMENT';
const DECREMENT = 'counter/DECREMENT';
 
 
// 액션 생성함수 정의
export const changeColor = color => ({type: CHANGE_COLOR, color}); //{type: CHANGE_COLOR, color} 요만큼이 액션 객체
export const increment = () => ({type: INCREMENT});
export const decrement = () => ({type: DECREMENT});
 
// 초기상태 정의
const initialState = {
  color: 'red',
  number: 0,
}
 
// 리듀서 작성
export default function counter(state = initialState, action){
  switch(action.type){
    case CHANGE_COLOR:
      return {
        ...state,
        color: action.color,
      };
    case INCREMENT:
      return {
        ...state,
        number: state.number + 1,
      };
    case DECREMENT:
      return {
        ...state,
        number: state.number - 1,
      }
      default:
        return state;
  }
 
}

❗️NOTE
Ducks패턴 : 액션을 위한 파일과 리듀서를 위한 파일을 분리하지 않고, 하나의 파일로 작성한다. 즉 위 코드에서 볼 수 있듯이 액션, 액션 생성함수, 초깃값, 리듀서 함수를 하나의 모듈에 작성한다.
다른 모듈에서 작성하게 될 수도 있는 (동일한 이름의) 액션들과 충돌되지 않도록 Ducks 패턴에서 액션 이름을 지을 때는 문자열 앞부분에 모듈 이름을 넣는다. (위의 counter/CHANGE_COLOR 등)

액션 생성 함수를 정의할 땐 반드시 export const를 붙여줌으로써 컴포넌트에 리덕스를 연동해 불러와서 사용할 수 있도록 한다.
스토어를 만들 때 리듀서 함수를 필요로 하므로 리듀서 함수에는 반드시 export default를 붙여준다.

store/modules/index.js

import { combineReducers } from 'redux';
import counter from './counter';
 
export default combineReducers({
  counter,
  // 다른 리듀서를 만들게되면 여기에 추가
});
 
-------------
 //이렇게 서브 리듀서를 합치게 되면, 루트 리듀서의 초깃값은 다음과 같은 구조로 된다.
{
    counter: {
        color: 'red',
        number: 0,
    },
    // ... 다른 리듀서에서 사용하는 초깃값들
}

리듀서가 여러개일 때는 redux의 내장함수인 combineReducers를 사용하여 리듀서를 하나로 합치는 작업을 한다. 서브 리듀서는 여러개로 나뉘어진 각각의 리듀서들을 의미하며 루트 리듀서는 서브 리듀서를 하나로 합친 리듀서를 뜻한다. 스토어를 만들 때는 루트 리듀서를 주입한다. (e.g const store = createStore(rootReducer))

components

함수형 컴포넌트의 arguments로 받는 것는 state, action. 해당 컴포넌트에 이벤트가 발생하면 연결된 리듀서에 의해 새로운 상태를 만들고 반환하는 일을 수행한다.

Counter.js

import React from 'react';
import './Counter.css';
 
const Counter = ({ value, color, onIncrement, onDecrement }) => {
  return (
    <div className="Counter">
      <h1 style={{ color }}>{value}</h1>
      <button onClick={onIncrement}>+</button>
      <button onClick={onDecrement}>-</button>
    </div>
  );
};
 
export default Counter;

Palette.js

import React from 'react';
import './Palette.css';
 
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
 
const PaletteItem = ({ color, active, onClick }) => {
  return (
    <div
      className={`PaletteItem ${active ? 'active' : ''}`}
      style={{ backgroundColor: color }}
      onClick={onClick}
    />
  );
};
 
const Palette = ({ selected, onSelect }) => {
  return (
    <div className="Palette">
      <h2>색깔을 골라골라</h2>
      <div className="colors">
        {colors.map(color => (
          <PaletteItem
          color={color}
          key={color}
          active={selected === color}
          onClick={()=>onSelect(color)}/>
        ))}
      </div>
    </div>
  );
};
 
export default Palette;

containers

각각의 서브 컨테이너를 보도록 하자.

CounterContainer.js

Counter 컴포넌트의 이벤트 객체로 handleIncrement()handleDecrement()를 주입한다.

import React, { Component } from 'react';
import { connect } from 'react-redux';
import Counter from '../components/Counter';
import { increment, decrement } from '../store/modules/counter';
import { bindActionCreators } from 'redux';
  
class CounterContainer extends Component {
  handleIncrement = () => {
    this.props.increment();
  };
  handleDecrement = () => {
    this.props.decrement();
  }
  render() {
    const { color, number } = this.props;
    return (
      <Counter
        color={color}
        value={number}
        onIncrement={this.handleIncrement}
        onDecrement={this.handleDecrement}
      />
    );
  }
}
 
const mapStateToProps = ({counter}) => ({
  color: counter.color,
  number: counter.number,
});
 
// 아래 3가지는 같은 효과를 가지는 액션 생성 함수를 명시하는 함수이다.
/* (1)
const mapDispatchToProps = dispatch => ({
  increment: () => dispatch(increment()),
  decrement: () => dispatch(decrement()),
});
*/
 
/* (2) 액션 생성함수가 파라미터를 필요로 하는 것이라더라도, 정상적으로 작동한다.
const mapDispatchToProps = dispatch =>
  bindActionCreators({increment, decrement}, dispatch);
*/
 
// (3) 함수형태가 아닌 아예 액션 생성 함수로 이뤄진 객체{}를 전달해주면
//      bindActionCreators를 자동으로 해준다.
const mapDispatchToProps = {increment, decrement}
 
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(CounterContainer);

컨테이너 컴포넌트를 만들땐, react-redux의 connect라는 함수를 사용한다. 이 함수의 파라미터에 전달해주는 mapStateToProps스토어 안에 들어있는 state를 props로 전달해주고, mapDispatchToProps액션 생성함수들을 props로 전달하고 디스패치에 등록한다. props로 전달된 액션 생성함수가 호출 돼 액션 객체가 생성되면 해당 액션 객체를 스토어에 전달해주고 상태변화가 발생한다.

connect( ... )(CounterContainer)
connect 함수가 호출되면 반환되는 값 = 특정 컴포넌트에 설정된 props(=mapStateToProps, mapDispatchToProps)를 전달해주는 함수
connect()를 호출해서 반환받은 함수에, CounterContainer를 파라미터로 넣어서 호출한 것

bindActionCreators

PaletteContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import Palette from '../components/Palette';
import { changeColor } from '../store/modules/counter';
 
class PaletteContainer extends Component {
  handleSelect = color => {
    const { changeColor } = this.props;
    console.log('color : '+color);
    changeColor(color);
  };
 
  render() {
    const { color } = this.props;
    return <Palette onSelect={this.handleSelect} selected={color} />;
  }
}
 
// props로 넣어줄 스토어 상태값
const mapStateToProps = state => ({
  color: state.counter.color,
})
 
// props로 넣어줄 액션 생성함수
const mapDispatchToProps = dispatch => ({
  changeColor: color => dispatch(changeColor(color)),
})
 
// 컴포넌트에 리덕스 스토어를 연동해줄 때에는 connect 함수 사용
export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(PaletteContainer);

위 예제의 mapDispatchToProps에서는, color를 파라미터로 받아와서 그 값을 가지고 CHANGE_COLOR 액션 객체를 생성한 다음에 스토어에 컴포넌트의 props로 디스패치 하는 함수를 전달해주는 것이다.

Redux-actions

redux-action 라이브러리를 활용해 리덕스 모듈 작성을 손쉽게 하도록 하자!

❗️NOTE
FSA 규칙 : 읽기 쉽고, 유용하고, 간단한 액션 객체를 만들기 위해 만들어졌다.
필수조건으로 1. 순수 js 객채여야하고 2. type property를 가지고 있어야한다. 선택조건으로는 error, payload, meta property가 있다.

  • error : 에러가 발생할 시 넣어 줄 수 있는 값
  • payload : FSA 규칙을 따르는 액션 객체는 액션에서 사용할 파라미터 필드명을 payload로 통일한다.
  • meta : 상태 변화에 있어서 완전히 핵심적이지는 않지만, 참조할만한 값을 넣어준다.

redux-actions의 createAction이라는 함수를 사용하면 FSA 규칙을 따르는 액션 객체를 만들어준다

createAction

const MY_ACTION = 'module_name/MY_ACTION';
  
//createAction으로 액션 생성 객체 정의하기
export const myAction = createAction(MY_ACTION, text=>text);
//이는 아래의 코드와 동일하다.
export const myAction = text => ({type:MY_ACTION, payload: text});
 
//const text = "hello, world"
//myAction(text);

createAction()메소드를 사용하면 첫 argument는 type으로, 두번째 argument는 payload로 대치된다. 위와 같은 예시에서 myAction(text)가 수행되면 {type: MY_ACTION, payload: "hello, world"}인 액션 객체가 생성된다.

데이터를 새로 생성 할 때마다 고유 id값을 주어야할 때는 자동증가를 위해 액션 생성함수를 아래처럼 수정할 수 있다.

let id = 1;
export const myAction = createAction(MY_ACTION, text=>({text, id: id++}));
//const text = "hello, world"
//myAction(text); 수행 시 {type: MY_ACTION, payload: {text:"hello, world", id: 1}} 액션 객체 생성
//const text = "goodBye, Vue"
//myAction(text); 수행 시 {type: MY_ACTION, payload: {text:"goodBye, Vue", id: 2}} 액션 객체 생성

handleActions

리듀서 작성에 용이하다.
switch .. case .. 대신 대괄호에 액션 타입을 명시하는 방식으로 작성한다.
위에 작성한 리듀서를 수정해보도록하겠다.

export default handleActions(
 {
    [CHANGE_COLOR]: (state, action) => ({
        ...state,
        color: action.payload,
    }),
    [INCREMENT]: (state, action) => ({
        ...state,
        number: state.number+1,
    }),
    [DECREMENT]: (state, action) => ({
        ...state,
        number: state.number-1,
    }),
 },
 initialState
);

-------------------------
export default function counter(state = initialState, action){
  switch(action.type){
    case CHANGE_COLOR:
      return {
        ...state,
        color: action.color,
      };
    case INCREMENT:
      return {
        ...state,
        number: state.number + 1,
      };
    case DECREMENT:
      return {
        ...state,
        number: state.number - 1,
      }
      default:
        return state;
  }
 
}

-----------------------

const CHANGE_INPUT = 'waiting/CHANGE_INPUT'; // 인풋 값 변경
const CREATE = 'waiting/CREATE'; // 명단에 이름 추가
const ENTER = 'waiting/ENTER'; // 입장
const LEAVE = 'waiting/LEAVE'; // 나감
 
let id = 3;
// createAction 으로 액션 생성함수 정의
export const changeInput = createAction(CHANGE_INPUT, text => text);
export const create = createAction(CREATE, text => ({ text, id: id++ }));
export const enter = createAction(ENTER, id => id);
export const leave = createAction(LEAVE, id => id);
 
 
// **** 초기 상태 정의
const initialState = {
  input: '',
  list: [
    {
      id: 0,
      name: '홍길동',
      entered: true,
    },
    {
      id: 1,
      name: '콩쥐',
      entered: false,
    },
    {
      id: 2,
      name: '팥쥐',
      entered: false,
    },
  ],
};
 
 
// **** handleActions 로 리듀서 함수 작성
export default handleActions(
  {
    [CHANGE_INPUT]: (state, action) => ({
      ...state,
      input: action.payload,
    }),
    [CREATE]: (state, action) => ({
      ...state,
      list: state.list.concat({
        id: action.payload.id,
        name: action.payload.text,
        entered: false,
      }),
    }),
    [ENTER]: (state, action) => ({
      ...state,
      list: state.list.map(
        item =>
          item.id === action.payload
            ? { ...item, entered: !item.entered }
            : item
      ),
    }),
    [LEAVE]: (state, action) => ({
      ...state,
      list: state.list.filter(item => item.id !== action.payload),
    }),
  },
  initialState
);
profile
🌱 😈💻 🌱

0개의 댓글