Redux

변승훈·2022년 8월 12일
0

1. Redux의 구조

1. Redux의 구조 및 설치

Redux는 useReducer를 썼을 때와 비슷한 구조를 가지고 있다.

+1, -1 버튼을 가진 카운터를 구현해보자.

먼저 폴더의 구조는 src 내부에 다음과 같이 생성해준다.

그 다음 Redux를 미리 설치해주자

yarn add redux react-redux

[Action Type]

처음으로 어떤 action이 있는지 좋을지를 생각해서 정의를 해 놓는다.

// constants -> counter.js
export const INC_COUNT = "INC_COUNT";
export const DEC_COUNT = "DEC_COUNT";

여기서 type을 변수 형태로 미리 만들어 놓는데, export를 해서 다른 파일에서 편하게 불러와서 사용하는 것과 오타 등을 방지 하기 위함이다.

증가 / 감소를 위한 action type을 명시한 것이다.

[Action 생성 함수] → 항상 액션 객체를 리턴

{ type: INC_COUNT, payload: ~~ } 와 같은 객체를 redux 에서는 action 객체라고 말한다.
이러한 action 객체를 reducer로 보내는 행위를 dispatch라고 표현한다.
useReducer에서 dispatch({type:'~~'})와 같은 방식으로 사용하는 것이라 생각하면 된다.
dispatch에 action객체를 직접 명시해서 전달하기도 하지만, dispatch에 action 객체를 Return하는 함수를 전달해서 사용하는 경우도 있다.

// actions -> counter.js
export function incCount(diff) {
    return {
        type: INC_COUNT,
        payload: {diff},
    }
}

export function decCount(diff) {
    return {
        type: DEC_COUNT,
        payload: {diff},
    }
}

이렇게 action 객체를 return 하는 함수를 action 생성 함수라고 표현한다.
action 객체에 사용해야 할 데이터가 있을 때, action 생성함수를 통해 전달하면서 객체를 생성하는 것이다.
위의 코드를 예시로 버튼을 눌렀을 때 2만큼 수가 증가하도록 하는 action 객체를 생성해서 reducer로 보내고 싶으면 dispatch(incCount(2))를 실행하면 된다.

[Reducer] → state 는 불변성을 유지

[Reducer] → state 는 불변성을 유지해야 한다(push 등 사용 금지, concat 이나 … 을 사용).
동일한 파라미터가 들어왔다면 동일한 결과를 출력해야 합니다 (date 등을 사용할 경우 해당 부분은 액션 생성 시 처리하고, reducer에서 그러한 랜덤 로직을 사용해서는 안됨).

reducer는 state(현재 상태)와 action(dispatch를 통해서 전달해주는 객체)를 파라미터로 받는다.
이를 통해 현재 상태에 action객체를 토대로 변화를 주게 된다.

아래의 코드처럼 reducer를 만들어 예시를 들 수 있겠다.

// reducers -> counter.js
import { INC_COUNT, DEC_COUNT } from "../constants/counter";

const initialState = {number: 0};

export default function counter(state = initialState, action) {
    switch (action.type) {
      case INC_COUNT:
        return {number: state.number + action.payload.diff}
      case DEC_COUNT:
        return {number: state.number - action.payload.diff};
      default:
        return state;
    }
}

초기 상태로 {number: 0} 이라는 객체를 할당하고,
inc_count 가 수행되면 {number: state.number + action.payload.diff} 형태로 해서 action 객체에 명시했던 그 수 (diff) 만큼 더해지도록 하겠다.

[Store] → 하나의 앱에는 하나의 스토어가 있도록 구성하는 것이 규칙

store는 앞서 만든 reducer와 상태를 담아놓은 것을 말하며, 이 store를 Provider라는 것을 통해 컴포넌트에 공유함으로써, 어떤 컴포넌트에서든 reducer와 상태를 가져와서 사용할 수 있도록 코드를 작성한다.
`

// store -> index.js
import { legacy_createStore as createStore } from "redux";
import counter from "./reducers/counter";

const store = createStore(counter);

export default store;

※ legacy_createStore 가 아닌 그냥 createStore 의 경우 deprecate 되었다. redux 개발자들이 redux toolkit 사용을 권장하기 위해서 해당 함수를 deprecate 시켰으나, 사용 빈도를 보면 createStore 를 쓰는 경우도 아직 많은 것으로 보인다.

App.js에서 아래처럼 Provider로 컴포넌트를 감싸준다.

// App.js
import { Provider } from "react-redux";
import store from './redux';

function App() {
  return (
    <Provider store={store}>
			<Counter />
    </Provider>
  );
}

export default App;

2. useSelector, useDispatch

useSelector

useSelector는 redux로 관리 중인 상태를 가져오기 위한 Hook이다.
state안에 number라는 데이터가 있으면 아래처럼 가져올 수 있다.

import { useSelector } from 'react-redux'

const { number } = useSelector(state => state)

dispatch 를 통해서 state 가 변하게 되면, 자동으로 useSelector 를 통해서 받아온 state 도 변하게 된다.

useDispatch

useDispatch 는 dispatch 함수를 실행할 수 있도록 해주는 Hook이다.
즉, 만들어진 액션 객체를 Reducer 로 전달하는 과정을 수행해주는 Hook 이라고 생각하면 된다.

mport { useDispatch } from 'react-redux'

const dispatch = useDispatch()

// 사용 예시
dispatch(incCount(1))

3. Container Presenter 패턴

Container Presenter 패턴이란 react 디자인 패턴 중 하나로, 기능과 UI를 컴포넌트 사응로 분리하는 것을 말한다.

  • Container 컴포넌트: 데이터를 처리, 받아오는 기능을 담당
  • Presenter 컴포넌트: 데이터를 보여주는 부분을(UI) 담당

아래는 위에서 만든 코드들 Container Presenter 패턴으로 바꾸는 예시이다.

먼저 Container 컴포넌트이다. 여기서는 처리한 데이터와 함수를 Presenter 컴포넌트에 props형태로 넘겨주게 된다.

// containers -> CounterContainer.jsx
import { useDispatch, useSelector } from 'react-redux';
import Counter from '../components/Counter';
import { incCount, decCount } from '../redux/actions/counter';

function CounterContainer() {
    const dispatch = useDispatch();
    const number = useSelector((state) => state.number);

    const onIncrease = () => {
        dispatch(incCount(1));
    }

    const onDecrease = () => {
        dispatch(decCount(1));
    }

    return <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
}

export default CounterContainer

다음은 Presenter 컴포넌트이다. 여기서는 Container 컴포넌트에서 넘겨준 것을 받아 UI를 만들어 준다.

// components -> Counter.jsx
import React from 'react'

function Counter({number, onIncrease, onDecrease}) {
  return (
    <div>
      <p>{number}</p>
      <button onClick={onIncrease}>+1</button>
      <button onClick={onDecrease}>-1</button>
    </div>
  )
}

export default Counter

마지막으로 CounterContainer를 App.js에서 표시하도록 변경해주면 된다.

import { Provider } from 'react-redux';
import store from './redux/store/store';
import CounterContainer from "./containers/CounterContainer";

function App() {
  return (
    <Provider store={store}>
      <CounterContainer/>
    </Provider>
  );
}

export default App;

4. ducks 패턴

ducks 패턴이란 위 패턴처럼 파일을 분리하지 않고, 하나의 파일에다가 모든 코드를 작성하는 것을 말한다.

코드 작성이 조금 더 용이해지고, 폴더 구조 상의 가독성이 더 좋아진다는 장점이 있다.

[규칙]

  1. 반드시 reducer 함수를 default export 해야 한다.
  2. 반드시 action 생성 함수를 export 해야 한다.
  3. 반드시 접두사를 붙인 형태로 action type을 정의해야 한다.
  4. (선택) 액션 타입은 UPPER_SNAKE_CASE 형태로 이름을 짓고 export 할 수 있다.

먼저 modules 폴더 안에 아래와 같이 2가지 파일만 생성해준다.

counter.js에서는 흩어졌던 코드를 하나로 모아준다.

// 액션 타입
const INC_COUNT = "counter/INC_COUNT";
const DEC_COUNT = "counter/DEC_COUNt";

// 액션 생성 함수
export function incCount(diff) {
    return {
        type: INC_COUNT,
        payload: {diff},
    }
}

export function decCount(diff) {
    return {
        type: DEC_COUNT,
        payload: {diff},
    }
}

// 초기값
const initialState = {number: 0};


// 리듀서 선언
export default function counter(state = initialState, action) {
    switch (action.type) {
      case INC_COUNT:
        return {
          ...state,
          number: state.number + action.payload.diff
        }
      case DEC_COUNT:
        return {
          ...state,
          number: state.number - action.payload.diff
        };
      default:
        return state;
    }
  }

index.js에서 store를 만들어주자.

import { legacy_createStore as createStore } from "redux";
import counter from "./counter";

const store = createStore(counter);

export default store;

이제 app.js에서 modules폴더에서 store를 가져오도록 수정하자.

import { Provider } from 'react-redux';
import store from './modules';
import CounterContainer from "./containers/CounterContainer";

function App() {
  return (
    <Provider store={store}>
      <CounterContainer/>
    </Provider>
  );
}

export default App;

마지막으로 containers의 CounterContainer.jsx에서 action 생성 함수를 불러오는 경로만 바꿔주면 완성된다.

import { useDispatch, useSelector } from 'react-redux';
import Counter from '../components/Counter';
import { incCount, decCount } from '../modules/counter';

function CounterContainer() {
    const dispatch = useDispatch();
    const number = useSelector((state) => state.number);

    const onIncrease = () => {
        dispatch(incCount(1));
    }

    const onDecrease = () => {
        dispatch(decCount(1));
    }

    return <Counter number={number} onIncrease={onIncrease} onDecrease={onDecrease} />
}

export default CounterContainer

2. Redux Logger 및 Dev-tools

1. redux logger

redux logger 는 redux 로 실행되는 로직에 대해서 logging, 즉 콘솔창에 기록을 남겨주는 역할을 담당하는 리덕스 미들웨어 (리덕스가 작동하는 과정에서 store, action 객체를 다루면서 특정한 기능을 수행하는 함수)이다.

아래 코드를 통해서 설치해보자!

yarn add redux-logger --dev

applyMiddleware 를 통해서 해당 미들웨어를 적용할 수 있다.

import { applyMiddleware, legacy_createStore as createStore } from "redux";
import logger from 'redux-logger';

const store = createStore(rootReducer, applyMiddleware(logger));

만약 todo라는 앱이 있다면 아래와 같이 적용해볼 수 있다.

import { applyMiddleware, legacy_createStore as createStore } from "redux";
import { combineReducers } from "redux";
import logger from "redux-logger";
import todo from "./todo";

const rootReducer = combineReducers({
    todo,
})

const store = createStore(rootReducer, applyMiddleware(logger));

export default store;

어떤 action 이 실행되었고, state 가 어떻게 변화했는지를 개발자 도구 콘솔에서 확인할 수 있다!

2. redux devtools

redux-devtools 는 크롬에서 redux 전용 개발자 도구를 활용할 수 있도록 해준다.

우선 아래 링크에서 확장 프로그램 추가를 해준다.

https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd

확장 프로그램만 설치하면 된다.

store 를 관리하는 부분에서 아래처럼 코드를 수정해보자.

*아래 코드는 redux logger 와 동시에 사용하기 위한 코드이다

import { applyMiddleware, compose, legacy_createStore as createStore } from "redux";

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(logger)));

이후 실행해보면, 아래처럼 실행된 action 과 관련한 세부사항을 확인할 수 있고, 되돌리기까지도 가능하다.

3. 비동기 요청을 보내는 미들웨어 (Axios)

1. redux-thunk의 개념

redux-thunk 는 리덕스에서 비동기 작업을 처리할 수 있도록 도와주는 미들웨어다. 정확히 말하자면, 해당 미들웨어를 사용하면 action 객체가 아니라 함수 그 자체를 dispatch할 수 있도록 해준다.

기존에는 아래와 같이 action 생성 함수를 작성해주었었습니다. 그러면 이를 통해서 {type: ~~, payload: ~~} 와 같은 액션 객체가 만들어지는 것이였다.

export function addTodo(todoItem) {
    return {
        type: ADD_TODO,
        payload: {...todoItem, id: todoId++},
    }
}

만약 우리가 axios 요청을 보내는 액션을 만들고자 한다면,

export const fetchData = async () => {
  const response = await axios.get('url')
  return {
    type: 'FETCH_DATA',
    payload: response.data
  }
}

이런식으로 작성을 하게 될 텐데, 그러면

action must be plain objects. Use custom middleware for async actions

위와 같은 오류가 발생하게 된다.

즉, 액션 생성 함수를 작성할 때에는 async, await 을 쓸 수 없는 것이다!

이러한 문제를 해결하기 위해, redux thunk 는 액션이 액션 객체를 반환하는 것이 아니라, 아래처럼 함수를 반환할 수 있도록 해준다.

export const fetchData = () => async () => {
  const response = await axios.get('url')
  return {
    type: 'FETCH_DATA',
    payload: response.data
  }
}

위 코드를 풀어서 작성하면 아래와 같은 형식인건데, 즉, async, await 을 통해서 요청을 보내는 함수를 호출하는 함수이다.

export const fetchData = () => {
  return async function(dispatch, getState) {
	  const response = await axios.get('url')
	  return {
	    type: 'FETCH_DATA',
	    payload: response.data
	  }
  }
}

이러한 redux-thunk 가 작동하는 방식에 대해서 풀어서 설명하자면,

  1. 우선 dispatch(액션) 을 하게 되면,
  2. 해당 액션이 반환하는 함수를 우선 실행하고,
  3. 함수 실행을 통해서 요청이 다 이루어져서 전달할 데이터가 만들어지고 나면,
  4. 그제서야 reducer 로 해당 데이터가 포함된 객체가 넘어가게 된다.

2. redux-thunk 설치 및 세팅

redux-thunk 는 아래와 같이 설치해준다.

yarn add redux-thunk

적용하는 예시 코드는 아래와 같다.

import ReduxThunk from 'redux-thunk';

const store = createStore(rootReducer, applyMiddleware(ReduxThunk));

이걸 logger, devtools 랑 같이 쓰고 싶다면 아래와 같이 쓸 수 있다.

import { applyMiddleware, compose, legacy_createStore as createStore } from "redux";
import ReduxThunk from 'redux-thunk';
import logger from 'redux-logger';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(ReduxThunk, logger)));

3. 요청 보내고, 데이터 받아서 관리하기: redux 잦ㄱ성

아래 주소로 GET 요청을 보내고 데이터를 받아서 관리하는 과정을 redux를 통해서 진행해보자.

https://jsonplaceholder.typicode.com/posts

구조는 다음과 같다.

먼저 modules-> posts.js이다.

import axios from "axios";

// 액션 타입
//GET_POSTS → 로딩을 True 로 설정하는 액션
//GET_POSTS_SUCCESS → 요청이 성공하면, 로딩을 False 로, data 부분을 응답으로 설정하는 액션
//GET_POSTS_ERROR → 요청이 실패하면, 에러를 저장하는 액션
const GET_POSTS = "posts/GET_POSTS";
const GET_POSTS_SUCCESS = "posts/GET_POSTS_SUCCESS";
const GET_POSTS_ERROR = "posts/GET_POSTS_ERROR";

// 바로 액션 객체 넣기
export const getPosts = () => async (dispatch, getState) => {
  dispatch({ type: GET_POSTS }); // 요청이 시작됨
  try {
    const response = await axios.get('https://jsonplaceholder.typicode.com/posts'); // API 호출
    dispatch({ type: GET_POSTS_SUCCESS, payload: {posts: response.data} }); // 성공
  } catch (e) {
    dispatch({ type: GET_POSTS_ERROR, error: e }); // 실패
  }
}

// 초기값
const initialState = {
  loading: false,
  data: null,
  error: null
};

// 추가 / 제거
export default function posts(state = initialState, action) {
  switch (action.type) {
    case GET_POSTS:
      return {
        loading: true,
        data: null,
        error: null
      };
    case GET_POSTS_SUCCESS:
      return {
        loading: false,
        data: action.payload.posts,
        error: null
      };
    case GET_POSTS_ERROR:
      return {
        loading: false,
        data: null,
        error: action.error
      };
    default:
      return state
  }
}

다음은 modeuls안의 index.js이다.
redux thunk, logger, devtools 모두 적용해서 store를 만들어주자.

import { applyMiddleware, compose, legacy_createStore as createStore } from "redux";
import { combineReducers } from "redux";
import posts from "./posts";
import ReduxThunk from 'redux-thunk';
import logger from 'redux-logger';

const rootReducer = combineReducers({
    posts,
})

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(ReduxThunk, logger)));

export default store;

4. 요청 보내고, 데이터 받아서 관리하기: container-presenter 작성

pages -> posts -> PostsContainer.jsx는 다음과 같이 작성한다.

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getPosts } from '../../modules/posts';
import PostsPresenter from './PostsPresenter';

function PostsContainer() {
    const { data, loading, error } = useSelector(state => state.posts);
    const dispatch = useDispatch();
  
    // 글 가져오는 과정 수행! (아래 코드가 수행되고 나면 이제 data 에 글이 담겨 있음)
    useEffect(() => {
      dispatch(getPosts());
    }, [dispatch]);

    if (loading) return <div>로딩중...</div>;
    if (error) return <div>에러 발생</div>;
    if (!data) return null;
    return <PostsPresenter posts={data} />
}

export default PostsContainer

useEffect를 통해서, postspresenter를 표시하기 이전에 우선 데이터를 가져오는 액션을 진행하도록 했다. 그러면 해당 액션에 포함된 dispatch 함수들이 실행되면서, 데이터를 가져오고 state에 저장하는 과정까지 진행해줄 것이다.

이제 data 라는 변수에 글들이 담겼으니, 해당 글들을 postspresenter 로 props 형태로 넘겨주자.

pages -> posts -> PostsPresenter.jsx

import React from 'react'

function PostsPresenter({ posts }) {

  return (
    <div>
        {posts.map((post) => (
           <p>{post.title}</p> 
        ))}
    </div>
  )
}

export default PostsPresenter

마지막으로 수월한 import를 위해 index.js를 아래처럼 작성하면 된다.

import PostsContainer from "./PostsContainer"

export default PostsContainer;

5. 이미 받은 데이터가 있다면 요철 보내지 않기

router 를 쓰는 상황이므로 일단 모듈을 설치해준다.

yarn add react-router-dom

우선 app.js 에서 아래처럼 Link 태그를 통해서 컴포넌트 간 이동을 구현하자.

import { Provider } from "react-redux";
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom';
import store from './modules';
import PostsPage from './pages/Posts'

function App() {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <Link to="/">Posts</Link> |
        <Link to="/temp">Temp</Link> {/* 해당 링크와 페이지를 연결한 route 가 없으니 Not Found */}
        <Routes>
          <Route path="/" element={<PostsPage/>}/>
          <Route path="/*" element={<p>Not Found</p>}/>
        </Routes>
      </BrowserRouter>
    </Provider>
  );
}

export default App;

Temp 버튼을 눌렀다가 Posts 버튼을 누르면 '로딩중'이 표시된다.
이미 Posts 데이터를 불러왔는데 또 로딩중이 나온다면 사용자가 불편함을 느낄 수 있다.

그래서 이미 가져온 데이터가 있으면 다시 요청을 보내지 않도록 수정해주자.
PostsContainer를 아래와 같이 수정해주면 된다.

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getPosts } from '../../modules/posts';
import PostsPresenter from './PostsPresenter';

function PostsContainer() {
    const { data, loading, error } = useSelector(state => state.posts);
    const dispatch = useDispatch();
  
    // data 가 없을 때만 요청을 보냄!
    useEffect(() => {
      !data && dispatch(getPosts());
    }, [data, dispatch]);

    if (loading) return <div>로딩중...</div>;
    if (error) return <div>에러 발생!</div>;
    if (!data) return null;
    return <PostsPresenter posts={data} />
}

export default PostsContainer
profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글