본문 바로가기

react

[리액트를 다루는 기술] 18장 리덕스 미들웨어를 통한 비동기 작업 관리(1)

18.1 작업 환경 준비

 

modules/counter.js

- counter 리덕스 모듈

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

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

//액션 생성
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

const initialState = 0;

//리듀서 함수 
const counter = handleActions(
    {
        [INCREASE]: state => state +1,
        [DECREASE]: state => state -1
    },
    initialState
);

export default counter;

 

 

modules/index.js

- 루트 리듀서 생성

import { combineReducers } from "redux";
import counter from "./counter";

const rootReducer = combineReducers({
    counter
});

export default rootReducer;

 

src/index.js

- 스토어 생성한 후, Provider로 리액트 프로젝트에 리덕스 적용 

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { createStore } from 'redux';
import rootReducer from './modules';
import { Provider } from 'react-redux';


const store = createStore(rootReducer); //스토어 생성

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
      <App/>
    </Provider>

);

 

 

component/Counter.js

- 카운트 컴포넌트 

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

export default Counter;

 

 

containers/CounterContainer.js

import { connect } from "react-redux";
import {increase, decrease} from '../modules/counter';
import Counter from "../components/Counter";

const CounterContainer = ({number,increase, decrease}) => {
    return (
        <Counter number={number} onIncrease={increase} onDecrease={decrease}/>
    );
};

//컴포넌트와 리덕스 연동
export default connect(
    state=>({
        number:state.counter
    }),
    {
        increase,
        decrease
    }
)(CounterContainer);

 

 

18.2 미들웨어란?

 

- 리덕스 미들웨어는 액션을 디스패치했을 때 리듀서에서 이를 처리하기에 앞서 사전에 지정된 작업들을 실행

- 미들웨어는 액션과 리듀서 사이의 중간자라고 볼 수 있음

 

 

18.2.1 미들웨어 만들기 

 

- 액션이 디스패치될 때마다 액션의 정보와 액션이 디스패치되기 전후의 상태를 콘솔에 보여주는 로깅 미들웨어 작성

 

lib/loggerMiddleware.js

const loggerMiddleware = store => next => action => {
    console.group(action && action.type); //액션 타입으로 log를 그룹화
    console.log('이전 상태', store.getState()); 
    console.log('액션', action);
    next(action); //다음 미들웨어 혹은 리듀서에게 전달
    console.log('다음 상태', store.getState()); //업데이트된 상태 
    console.groupEnd(); //그룹 끝

};

export default loggerMiddleware;

 

- 미들웨어는 함수를 반환하는 함수 

- 이전 상태, 액션 정보, 새로워진 상태를 보여줌

 

store

   - 리덕스 스토어 인스턴스 

 

action

  -디스패치된 액션 

 

next

  - store.dispatch와 비슷한 역할 

  - next(action)을 호출하면 그 다음 처리해야 할 미들웨어에게 액션을 넘겨주고, 만약 그 다음 미들웨어가 없다면 리듀서에게 액션을 넘겨줌 

  - 미들웨어에서 next를 사용하지 않으면 액션이 리듀서에 전달되지 않음 

 

index.js

- 미들웨어는 스토어를 생성하는 과정에서 적용

import ReactDOM from 'react-dom/client';
import App from './App';
import { applyMiddleware, createStore } from 'redux';
import rootReducer from './modules';
import { Provider } from 'react-redux';
import loggerMiddleware from './lib/loggerMiddleware';


const store = createStore(rootReducer, applyMiddleware(loggerMiddleware)); //스토어 생성

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
      <App/>
    </Provider>

);

 

 

 

18.2.2 redux-logger 사용하기 

 

npm install redux-logger

 

 

index.js

import ReactDOM from 'react-dom/client';
import App from './App';
import { applyMiddleware, createStore } from 'redux';
import rootReducer from './modules';
import { Provider } from 'react-redux';
import  {createLogger} from 'redux-logger';

const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger)); //스토어 생성

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
      <App/>
    </Provider>

);

 

 

18.3 비동기 작업을 처리하는 미들웨어 사용 

 

18.3.1 redux-thunk

 

- 비동기 작업을 처리할 때 가장 많이 사용하는 미들웨어 

- 객체가 아닌 함수 형태의 액션을 디스패치 가능

 

18.3.1.1 Thunk란?

Thunk

- 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것

 

ex)

- addOne()을 호출했을 때 바로 1+1이 연산되는데 이를 미룸

const addOne = x => x+1;
cost addOneThunk = x => () => addOne(x);

const fn = addOneThunk(1);
setTimeout(() => {
    const value = fn(); //fn이 실행되는 시점에 연산
    console.log(value);
},1000);

 

 

18.3.1.2 미들웨어 적용하기 

 

redux-thunk 미들웨어 설치 

npm install redux-thunk

 

index.js

- 스토어를 만들 때 redux-thunk 적용 

...
import { Provider } from 'react-redux';
import  {createLogger} from 'redux-logger';
import thunk from 'redux-thunk';

const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger, thunk)); //스토어 생성

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Provider store={store}>
      <App/>
    </Provider>

);

 

 

18.3.1.3 Thunk 생성 함수 만들기 

 

- redux-thunk는 액션 생성 함수에서 일반 액션 객체를 반환하는 대신 함수를 반환 

- increaseAsync와 decreaseAsync 함수를 만들어 카운터 값을 비동기적으로 한번 변경

 

modules/counter.js

...
//1초 뒤에 incrase 혹은 decrease 함수를 디스패치함 
export const increaseAsync = () => dispatch => {
    setTimeout(() => {
        dispatch(increase());
    },1000);
};

export const decreaseAsync = () => dispatch => {
    setTimeout(() => {
        dispatch(decrease());
    },1000);
};
...

 

 

container/CounterContainer.js

import { connect } from "react-redux";
import {increaseAsync, decreaseAsync} from '../modules/counter';
import Counter from "../components/Counter";

const CounterContainer = ({number,increaseAsync, decreaseAsync}) => {
    return (
        <Counter number={number} onIncrease={increaseAsync} onDecrease={decreaseAsync}/>
    );
};

//컴포넌트와 리덕스 연동
export default connect(
    state=>({
        number:state.counter
    }),
    {
        increaseAsync,
        decreaseAsync
    }
)(CounterContainer);

 

 

- 처음 디스패치되는 액션은 함수형태이고, 두 번째 액션은 객체 형태 

 

 

18.3.1.4 웹 요청 비동기 작업 처리하기 

 

- thunk의 속성을 활용하여 웹 요청 비동기 작업 처리 

 

lib/api.js

- API 함수화 

import axios from "axios";

//포스트 읽기
export const getPost = id => 
    axios.get(`http://jsonplaceholder.typicode.com/posts/${id}`);

//모든 사용자 정보 불러오기 
export const getUsers = id => 
    axios.get(`http://jsonplaceholder.typicode.com/users`);

 

 

modules/sample.js

- API를 사용하여 데이터를 받아와 상태를 관리할 sample 리듀서 생성

import { handleActions } from "redux-actions";
import * as api from '../lib/api';

//액션 타입을 선언 

const GET_POST = 'sample/GET_POST'; //시작
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS'; //성공
const GET_POST_FAILURE = 'sample/GET_POST_FAILURE'; //실패 

const GET_USERS = 'sample/GET_USERS'; //시작
const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS'; //성공
const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE'; //실패 

//thunk 함수 생성
//thunk 함수 내부에서는 시작할 떄, 성공했을 떄, 실패했을 때 다른 액션을 디스패치 
export const getPost = id => async dispatch => {
    dispatch({type:GET_POST}); //요청을 시작한 것을 알림
    try {
        const response = await api.getPost(id);
        dispatch({
            type:GET_POST_SUCCESS,
            payload:response.data
        }); //요청 성공
    } catch(e) {
        dispatch({
            type: GET_POST_FAILURE,
            payload:e,
            error:true
        }); //에러 발생
        throw e; //나중에 컴포넌트단에서 에러를 조회할 수 있게 해줌
    }
};


export const getUsers = () => async dispatch => {
    dispatch({type:GET_USERS}); //요청을 시작한 것을 알림
    try {
        const response = await api.getUsers();
        dispatch({
            type:GET_USERS_SUCCESS,
            payload:response.data
        }); //요청 성공
    } catch(e) {
        dispatch({
            type: GET_USERS_FAILURE,
            payload:e,
            error:true
        }); //에러 발생
        throw e; //나중에 컴포넌트단에서 에러를 조회할 수 있게 해줌
    }
};


//초기 상태 선언
//요청이 로딩 중 상태는 loading이라는 객체에서 관리 

const initialState = {
    loading: {
        GET_POST:false,
        GET_USERS:false
    },
    post:null,
    users:null
};

const sample = handleActions(
    {
        [GET_POST] : state => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST:true //요청 시작
            }
        }),
        [GET_POST_SUCCESS]: (state,action) => ({
            ...state,
            loading:{
                ...state.loading,
                GET_POST:false //요청 완료
            },
            post: action.payload
        }),
        [GET_POST_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST:false //요청 완료 
            }
        }),
        [GET_USERS] : state => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS:true //요청 시작
            }
        }),
        [GET_USERS_SUCCESS]: (state,action) => ({
            ...state,
            loading:{
                ...state.loading,
                GET_USERS:false //요청 완료
            },
            users: action.payload
        }),
        [GET_USERS_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS:false //요청 완료 
            }
        })
    },
    initialState
);

export default sample;

 

 

modules/index.js

- sample 리듀서 루트 리듀서에 포함 

...
const rootReducer = combineReducers({
    counter,
    sample
});
...

 

 

components/Sample.js

const Sample = ({loadingPost, loadingUsers, post, users}) => {
    return (
        <div>
            <section>
                <h1>포스트</h1>
                {loadingPost&& '로딩중...'}
                {!loadingPost && post && (
                    <div>
                        <h3>{post.title}</h3>
                        <h3>{post.body}</h3>
                    </div>
                )}
            </section>
            <hr/>
            <section>
                <h1>사용자 목록</h1>
                {loadingUsers && '로딩중...'}
                {!loadingUsers && users && (
                    <ul>
                        {users.map(user => (
                            <li key={user.id}>
                                {user.username} ({user.email})
                            </li>
                        ))}
                    </ul>
                )}
            </section>
        </div>
    );
};

export default Sample;

 

- 데이터를 불러와서 렌더링해 줄때는 유효성 검사를 해주는 것이 중요!!

  -> post&&를 사용하면 post 객체가 유효할 때만 그 내부의 post.tilte 혹은 post.body 값을 보여줌 

  -> 만약 데이터가 없는 상태라면 post.title을 조회하려고 할 때 자바스크립트 오류가 발생

 

 

containers/SampleContainer.js

import { useEffect } from "react";
import { connect } from "react-redux";
import Sample from "../components/Sample";
import {getPost,getUsers} from '../modules/sample';

const SampleContainer = ({
    getPost,
    getUsers,
    post,
    users,
    loadingPost,
    loadingUsers
}) => {
    useEffect(() => {
        getPost(1);
        getUsers(1);
    },[getPost, getUsers]);

    return (
        <Sample
            post={post}
            users={users}
            loadingPost={loadingPost}
            loadingUsers={loadingUsers}
        />
    );
};

export default connect (
    ({sample}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: sample.loading.GET_POST,
        loadingUsers: sample.loading.GET_USERS
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

 

 

18.3.1.5 리팩토링

 

- 반복되는 로직을 따로 분리

 

lib/createRequestThunk.js 

- API 요청을 해주는 thunk 함수를 한 줄로 생성할 수 있게 해줌 

- 액션 타입과 API를 요청하는 함수를 파라미터로 넣어주면 나머지 작업 대신 처리

import { startLoading, finishLoading } from "../modules/loading";

export default function createRequestThunk(type, request) {
    //성공 및 실패 액션 타입을 정의 
    const SUCCESS = `${type}_SUCCESS`;
    const FAILURE = `${type}_FAILURE`;
    return params => async dispatch => {
        dispatch({type}); //시작됨
        dispatch(startLoading(type));
        try {
            const response = await request(params);
            dispatch({
                type:SUCCESS,
                payload:response.data
            }); //성공
            dispatch(finishLoading(type));
        } catch(e) {
            dispatch({
                type:FAILURE,
                payload:e,
                error:true
            }); //에러 발생
            dispatch(startLoading(type));
            throw e;
        }
    };
}

//사용법 : createRequestThunk('GET_USERS",api.getUSers);

 

 

moudles/sample.js

import { handleActions } from "redux-actions";
import * as api from '../lib/api';
import createRequestThunk from "../lib/createRequestThunk";

//액션 타입을 선언 

const GET_POST = 'sample/GET_POST'; //시작
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS'; //성공
const GET_POST_FAILURE = 'sample/GET_POST_FAILURE'; //실패 

const GET_USERS = 'sample/GET_USERS'; //시작
const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS'; //성공
const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE'; //실패 

//thunk 함수 생성
//thunk 함수 내부에서는 시작할 떄, 성공했을 떄, 실패했을 때 다른 액션을 디스패치 

export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_USERS, api.getUsers);

...

 

 

modules/loading.js

- 요청의 로딩 상태 관리하는 작업 개선

- 로딩 상태만 관리하는 리덕스 모듈을 따로 생성하여 처리 

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

const START_LOADING = 'loading/START_LOADING';
const FINISH_LOADING = 'loading/FINISH_LOADING';

/*
요청을 위한 액션 타입을 payload로 설정
*/

export const startLoading = createAction(
    START_LOADING,
    requestType => requestType
);

export const finishLoading = createAction(
    FINISH_LOADING,
    requestType => requestType
);

const initialState={};

const loading = handleActions(
    {
        [START_LOADING] : (state,action) => ({
            ...state,
            [action.payload]:true
        }),
        [FINISH_LOADING]: (state,action) => ({
            ...state,
            [action.payload]:false
        })
    },
    initialState
);

export default loading;

 

 

요청이 시작될 때 디스패치할 액션

 - 이 액션이 디스패치되면 loading 리듀서가 관리하고 있는 상태에서 sample/GET_POST 값을 true로 설정 

{
    type:'loading/START_LOADING',
    payload:'sample/GET_POST'
}

 

 

요청이 끝날 때 디스패치할 액션

- 기존에 true로 설정했던 값을 다시 false로 전환 

{
    type:'loading/FINISH_LOADING',
    payload:'sample/GET_POST'
}

 

modules/index.js

- loading 루트 리듀서에 포함

...
const rootReducer = combineReducers({
    counter,
    sample,
    loading
});
...

 

 

containers/SampleContainer.js

...
export default connect (
    ({sample,loading}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: loading['sample/GET_POST'],
        loadingUsers: loading['sample/GET_USERS']
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

 

modules/sample.js

import { handleActions } from "redux-actions";
import * as api from '../lib/api';
import createRequestThunk from "../lib/createRequestThunk";

//액션 타입을 선언 

const GET_POST = 'sample/GET_POST'; //시작
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS'; //성공

const GET_USERS = 'sample/GET_USERS'; //시작
const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS'; //성공


//thunk 함수 생성
//thunk 함수 내부에서는 시작할 떄, 성공했을 떄, 실패했을 때 다른 액션을 디스패치 

export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_USERS, api.getUsers);


//초기 상태 선언
//요청이 로딩 중 상태는 loading이라는 객체에서 관리 

const initialState = {
    post:null,
    users:null
};

const sample = handleActions(
    {
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            post:action.payload
        }),
        [GET_USERS_SUCCESS]: (state,action) => ({
            ...state,
            users:action.payload
        })
    },
    initialState
);

export default sample;