이미지09
Coding Story/REACT

[ React ] 리액트 Saga + Toolkit ( 미들웨어 사가, 리덕스 툴킷 )

반응형

 

 

 

지금껏 다룬 포스팅의 project 에 Middleware Saga 와 Redux Toolkit 을 적용시켜보자.

 

 

먼저 해당 포스팅에서 사용할 기능들을 설치하자.

// Redux 와 React-Redux 설치
yarn add redux react-redux

// Redux Toolkit 설치
yarn add @reduxjs/toolkit

// 비동기 통신을 위한 axios 설치
yarn add axios

// 미들웨어 Saga 설치
yarn add redux-saga

// Json Server 설치
yarn global add json-server

 

 

Directory 구조는 다음과 같다.

src
 ┕ api
      ┕ posts.js
 ┕ component
      ┕ BoardList.js
      ┕ BoardNew.js
 ┕ container
      ┕ Container.js
 ┕ module
      ┕ boardReducer.js
      ┕ rootReducer.js
 ┕ App.js
 ┕ index.js
package.json
data.json

 

이전까지의 포스팅과 다른 부분만 살펴 볼 예정이다.

 

먼저 Json Server 로 통신 테스트할 json 파일을 하나 만든다.

 

 

data.json

{
  "data": [
    {
      "id": 1,
      "boardId": 1,
      "boardTitle": "Title 1",
      "boardContent": "Content 1"
    },
    {
      "id": 2,
      "boardId": 2,
      "boardTitle": "Title 2",
      "boardContent": "Content 2"
    }
  ]
}

 

아까 설치한 Json Server 는 아래와 같은 명령어로 터미널에서 실행할 수 있다.

json-server ./data.json --port 4000

 

이제 src/index.js 코드부터 살펴보자.

 

 

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import createSagaMiddleware from 'redux-saga';
import rootReducer, {rootSaga} from './module/rootReducer';
import { configureStore } from '@reduxjs/toolkit';

// Saga Middleware 생성
const sagaMiddleware = createSagaMiddleware();

// Store 만들 때 Saga Middleware 적용
// const store = createStore(rootReducer, applyMiddleware(sagaMiddleware));

// Store 만들 때 Saga Middleware 적용 + redux devtool 적용
// const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(sagaMiddleware)));

const store = configureStore({ reducer: rootReducer, middleware: [sagaMiddleware]})

// Saga 실행
sagaMiddleware.run(rootSaga);

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

 

먼저 redux-saga 의 createSagaMiddleware 를 이용해 Saga Middleware 를 생성한다.

 

이전 포스팅에선 createStore 를 사용해 Store 를 생성했으나 본 포스팅에선 

 

Redux-Toolkit 의 configureStore 를 이용해 Reducer 와 Saga 를 가진 Store 를 생성했다.

 

그 후 sagaMiddleware 의 rootSaga 를 run 했다.

 

다음은 rootSaga 를 가진 src/module/rootReducer.js 컴포넌트를 살펴보자.

 

 

src/module/rootReducer.js

import { combineReducers } from 'redux';
import boardReducer, { boardSaga } from './boardReducer';
import { all } from 'redux-saga/effects';

// boardReducer 를 rootReducer 로 합쳐 내보냄
const rootReducer = combineReducers({
    boardReducer
});

export function* rootSaga() {
    // all 은 여러 사가를 동시에 실행시켜준다. 현재는 boardSaga 하나.
    yield all([boardSaga()]);
}

export default rootReducer;

 

rootSaga 는 ( * ) 가 붙은 제너레이션 함수이다.

 

yield 는 비동기적인 일을 처리할 때 단위를 나누는 기준점 같은 느낌으로 보면 된다.

 

all 은 여러 Saga 를 동시에 실행시켜주는 기능이다. 

 

현재는 주석에 적힌대로 boardSaga 하나 뿐이다.

 

 

src/api/posts.js

import axios from 'axios';

// Search Api
export const getData = async () => {
    const selectUrl = 'http://localhost:4000/data';
    const response = await axios.get(selectUrl);
    return response.data;
}

// Save Api
export async function saveData(saveData) {

    // Update Api
    if(saveData.data.boardId != '' && saveData.data.boardId != null) {
        const updateUrl = 'http://localhost:4000/data/' + saveData.data.id; //data.json 의 id
        const response = await axios.put(updateUrl, {
            boardId: saveData.data.boardId,
            boardTitle: saveData.data.boardTitle,
            boardContent: saveData.data.boardContent
        });    
        return response;
    } 
    
    // Insert Api
    else {
        const insertUrl = 'http://localhost:4000/data';
        const response = await axios.post(insertUrl, {
            id: saveData.lastId+1,    
            boardId: saveData.lastId+1,
            boardTitle: saveData.data.boardTitle,
            boardContent: saveData.data.boardContent
        })
        return response;
    }
}

// Remove Api
export async function removeData(id) {
    const removeUrl = 'http://localhost:4000/data/' + id;
    const response = await axios.delete(removeUrl);
    return response;
}

 

src/api/posts.js 는 reducer 에서 사용 될 통신 api 들을 모아놓은 파일이다.

 

getData(조회), saveData(신규, 수정), removeData(삭제) 에 해당하는 기능들이다.

 

 

src/module/boardReducer.js

import * as API from '../api/posts';
import { call, put, takeEvery } from 'redux-saga/effects';
import { createAction, createReducer } from '@reduxjs/toolkit';

// Action Type
const SEARCH_DATA_ASYNC = 'SEARCH_DATA_ASYNC';
const SEARCH_DATA = 'SEARCH_DATA';
const SAVE_DATA_ASYNC = 'SAVE_DATA_ASYNC';
const REMOVE_DATA_ASYNC = 'REMOVE_DATA_ASYNC';


// Action Creator
export const searchDataAsync = createAction(SEARCH_DATA_ASYNC);
export const searchData = createAction(SEARCH_DATA);
export const saveDataAsync = createAction(SAVE_DATA_ASYNC, (data, lastId) => ({payload: {data, lastId}}));
export const removeDataAsync = createAction(REMOVE_DATA_ASYNC);


// Main Saga
export function* boardSaga() {
    yield takeEvery(SEARCH_DATA_ASYNC, searchDataSaga);
    yield takeEvery(SAVE_DATA_ASYNC, saveDataSaga);
    yield takeEvery(REMOVE_DATA_ASYNC, removeDataSaga);
}

// Search Saga
export function* searchDataSaga() {
    const response = yield call(API.getData);
    yield put(searchData(response));
}

// Save Saga
export function* saveDataSaga({payload}) {
    const response = yield call(API.saveData, payload);
    if(response != null && (response.status == 201 || response.status == 200)) {
        yield put(searchDataAsync());
    }
}

// Remove Saga
export function* removeDataSaga({payload: id}) {
    const response = yield call(API.removeData, id);
    if(response.status == 200) {
        yield put(searchDataAsync());
    }
}


// initState
const initialState = {
    boards: [],
    lastId: 0
}


// Toolkit Reducer
export default createReducer(initialState, {
    [SEARCH_DATA]: (state, {payload: data}) => {
        state.boards.length=0;
        for(let i = 0 ; i < data.length ; i++) {
            state.boards.push({
                id: data[i].id,
                boardId: data[i].id,
                boardTitle: data[i].boardTitle,
                boardContent: data[i].boardContent
            });
            if(i == data.length-1) {
                state.lastId = data[i].id;
            }
        }
    }
})

 

자 이제 현 포스팅의 핵심이 되는 부분이니 이 파일은 좀 더 세밀하게 살펴보겠다.

 

 

// Action Type
const SEARCH_DATA_ASYNC = 'SEARCH_DATA_ASYNC';
const SEARCH_DATA = 'SEARCH_DATA';
const SAVE_DATA_ASYNC = 'SAVE_DATA_ASYNC';
const REMOVE_DATA_ASYNC = 'REMOVE_DATA_ASYNC';


// Action Creator
export const searchDataAsync = createAction(SEARCH_DATA_ASYNC);
export const searchData = createAction(SEARCH_DATA);
export const saveDataAsync = createAction(SAVE_DATA_ASYNC, (data, lastId) => ({payload: {data, lastId}}));
export const removeDataAsync = createAction(REMOVE_DATA_ASYNC);

 

먼저 Action Type 이야 지금껏 다룬 것과 똑같으니 넘어가고,

 

이번 포스팅에선 Action Creator 를 Redux-Toolkit 의 createAction 을 이용해 만들었다.

 

createAction 에 대해 아직 감이 안잡혔다면 여기를 참조하길 바란다.

 

 

// Main Saga
export function* boardSaga() {
    yield takeEvery(SEARCH_DATA_ASYNC, searchDataSaga);
    yield takeEvery(SAVE_DATA_ASYNC, saveDataSaga);
    yield takeEvery(REMOVE_DATA_ASYNC, removeDataSaga);
}

 

아까 위에서 다룬 src/module/rootReducer.js 의 boardSaga 를 실행시켰는데,

 

그 boardSaga 가 이 Main Saga 역할을 하는 boardSaga 이다.

 

takeEvery 는 내부에서 선언한 Saga 를 항상 실행시켜 놓는데,

 

이는 액션 객체를 수시로 참조하고 있다가 해당 객체가 생성되면 행위를 취할 수 있게 해준다.

 

이 코드들 예로 들면 SEARCH_DATA_ASYNC 액션 객체가 생성되면 본래는 Reducer 로 바로 갔지만,

 

그러지 않고 searchDataSaga 를 수행한다.

 

SAVE_DATA_ASYNC 역시 액션 객체가 생성되면 saveDataSaga 를 실행한다.

 

 

// Search Saga
export function* searchDataSaga() {
    const response = yield call(API.getData);
    yield put(searchData(response)); // put 은 dispatch 이다
}

// Save Saga
export function* saveDataSaga({payload}) {
    const response = yield call(API.saveData, payload);
    if(response != null && (response.status == 201 || response.status == 200)) {
        yield put(searchDataAsync());
    }
}

// Remove Saga
export function* removeDataSaga({payload: id}) {
    const response = yield call(API.removeData, id);
    if(response.status == 200) {
        yield put(searchDataAsync());
    }
}

 

만약 위에서 SEARCH_DATA_ASYNC 액션 객체가 생성됐다면 searchDataSaga 를 수행하는데,

 

src/api/posts.js 에 선언해 놓은 getData 를 호출한다.

 

위에서 import * as API from '../api/posts'; 로 선언했기에 posts.js 를 API 별칭으로 사용 가능.

 

그로하여 결국 posts.js 의 getData() 를 수행해 데이터를 읽어오고 이를 response 에 담는다.

 

그 후 response 를 searchData(Action Creator) 로 put(dispatch) 한다.

 

이 때, SEARCH_DATA 라는 액션 객체가 생성되는데 다시 boardSaga() 를 봤더니 

 

SEARCH_DATA 액션 객체에 대한 행위가 없음을 확인하고 곧장 Reducer 로 이동하는 것이다.

 

 

// Toolkit Reducer
export default createReducer(initialState, {
    [SEARCH_DATA]: (state, {payload: data}) => {
        state.boards.length=0;
        for(let i = 0 ; i < data.length ; i++) {
            state.boards.push({
                id: data[i].id,
                boardId: data[i].id,
                boardTitle: data[i].boardTitle,
                boardContent: data[i].boardContent
            });
            if(i == data.length-1) {
                state.lastId = data[i].id;
            }
        }
    }
})

 

Reducer 또한 toolkit 을 이용해 createReducer 로 만들었는데,

 

Redux-Toolkit 은 불변성을 보존해주기에 위처럼 push 를 이용할 수 있다.

 

또한 분명 아까 Action Creator 에서 export const searchDataAsync = createAction(SEARCH_DATA_ASYNC);

 

선언해 인자값 설정을 안해놨음에도 payload 에 자동으로 할당 돼 위처럼 {payload} 로 사용할 수 있다.

 

 

전체 소스는 아래에.

 

 

src/App.js

import React from 'react';
import Container from './container/Container';

function App() {
  return (
    <div>
      <Container />
    </div>
  );
}

export default App;

 

 

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import createSagaMiddleware from 'redux-saga';
import rootReducer, {rootSaga} from './module/rootReducer';
import { configureStore } from '@reduxjs/toolkit';

// saga middleware 생성
const sagaMiddleware = createSagaMiddleware();

const store = configureStore({ reducer: rootReducer, middleware: [sagaMiddleware]})

// saga 실행
sagaMiddleware.run(rootSaga);

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

 

 

src/api/posts.js

import axios from 'axios';

// Search Api
export const getData = async () => {
    const selectUrl = 'http://localhost:4000/data';
    const response = await axios.get(selectUrl);
    return response.data;
}

// Save Api
export async function saveData(saveData) {

    // Update Api
    if(saveData.data.boardId != '' && saveData.data.boardId != null) {
        const updateUrl = 'http://localhost:4000/data/' + saveData.data.id; //data.json 의 id
        const response = await axios.put(updateUrl, {
            boardId: saveData.data.boardId,
            boardTitle: saveData.data.boardTitle,
            boardContent: saveData.data.boardContent
        });    
        return response;
    } 
    
    // Insert Api
    else {
        const insertUrl = 'http://localhost:4000/data';
        const response = await axios.post(insertUrl, {
            id: saveData.lastId+1,    
            boardId: saveData.lastId+1,
            boardTitle: saveData.data.boardTitle,
            boardContent: saveData.data.boardContent
        })
        return response;
    }
}

// Remove Api
export async function removeData(id) {
    const removeUrl = 'http://localhost:4000/data/' + id;
    const response = await axios.delete(removeUrl);
    return response;
}

 

 

src/component/BoardList.js

import React from 'react';

function BoardList({ id, boardId, boardTitle, boardContent, onRowClick, onRemoveButtonClick }) {

    return (
        <tr>
            <td onClick={() => onRowClick(id, boardId, boardTitle, boardContent)}>{boardId}</td>
            <td onClick={() => onRowClick(id, boardId, boardTitle, boardContent)}>{boardTitle}</td>
            <td onClick={() => onRowClick(id, boardId, boardTitle, boardContent)}>{boardContent}</td>
            <td><button onClick={() => onRemoveButtonClick(id)}>삭제</button></td>
        </tr>
    )
}

export default BoardList;

 

 

src/component/BoardNew.js

import React from 'react';

function BoardNew({ changeInput, inputData, onSaveButtonClick, resetForm }) {

    const saveBtnClick = (e) => {
        e.preventDefault();
        onSaveButtonClick(inputData);
        resetForm();
    }

    return (
        <div>
            <form onSubmit={saveBtnClick}>
                <div>
                    제목 : <input type="text" name="boardTitle" onChange={changeInput} value={inputData.boardTitle} />
                </div>
                <div>
                    내용 : <input type="text" name="boardContent"  onChange={changeInput} value={inputData.boardContent} />
                </div>
                <input type="hidden" name="boardId" onChange={changeInput} value={inputData.boardId} />
                <button type="submit" >저장</button>
            </form>
        </div>
    )
};

export default BoardNew;

 

 

src/container/Container.js

import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import BoardList from '../component/BoardList';
import BoardNew from '../component/BoardNew';
import { saveDataAsync, searchDataAsync, removeDataAsync } from '../module/boardReducer';

function Container() {

    let [inputData, setInputData] = useState({
        id: '',
        boardId: '',
        boardTitle: '',
        boardContent: ''
    });

    const dispatch = useDispatch();

    const {boards, lastId} = useSelector(state => state.boardReducer);

    const onSearchButtonClick = () => {
        resetForm();
        dispatch(searchDataAsync());
    }

    const onSaveButtonClick = (data) => {
        dispatch(saveDataAsync(data, lastId));
    }

    const onRemoveButtonClick = (id) => {
        dispatch(removeDataAsync(id));
    }

    const onRowClick = (id, boardId, boardTitle, boardContent) => {
        setInputData({
            id: id,
            boardId: boardId,
            boardTitle: boardTitle,
            boardContent: boardContent
        })
    }

    const changeInput = (e) => {
        setInputData({
            ...inputData,
            [e.target.name]: e.target.value
        })
    }

    const resetForm = () => {
        setInputData({
            boardId: '',
            boardTitle: '',
            boardContent: ''
        })
    }

    return (
        <div>
            <button onClick={onSearchButtonClick}>조회</button>
            <div>
                <table border="1">
                    <tbody>
                        <tr align="center">
                            <td width="50">번호</td>
                            <td width="100">제목</td>
                            <td width="200">내용</td>
                        </tr>
                        {boards.length > 0 && boards.map(row =>
                            <BoardList
                                key={row.boardId}
                                id={row.id}
                                boardId={row.boardId}
                                boardTitle={row.boardTitle}
                                boardContent={row.boardContent}
                                onRowClick={onRowClick}
                                onRemoveButtonClick={onRemoveButtonClick}
                            />
                        )}
                    </tbody>
                </table>
            </div>
            <div>
                <BoardNew 
                    changeInput={changeInput}
                    inputData={inputData}
                    onSaveButtonClick={onSaveButtonClick}
                    resetForm={resetForm}
                />
            </div>
        </div>
    );
}

export default Container;

 

 

src/module/boardReducer.js

import * as API from '../api/posts';
import { call, put, takeEvery } from 'redux-saga/effects';
import { createAction, createReducer } from '@reduxjs/toolkit';


// Action Type
const SEARCH_DATA_ASYNC = 'SEARCH_DATA_ASYNC';
const SEARCH_DATA = 'SEARCH_DATA';
const SAVE_DATA_ASYNC = 'SAVE_DATA_ASYNC';
const REMOVE_DATA_ASYNC = 'REMOVE_DATA_ASYNC';


// Action Creator
export const searchDataAsync = createAction(SEARCH_DATA_ASYNC);
export const searchData = createAction(SEARCH_DATA);
export const saveDataAsync = createAction(SAVE_DATA_ASYNC, (data, lastId) => ({payload: {data, lastId}}));
export const removeDataAsync = createAction(REMOVE_DATA_ASYNC)

export function* boardSaga() {
    yield takeEvery(SEARCH_DATA_ASYNC, searchDataSaga);
    yield takeEvery(SAVE_DATA_ASYNC, saveDataSaga);
    yield takeEvery(REMOVE_DATA_ASYNC, removeDataSaga);
}

export function* searchDataSaga() {
    const response = yield call(API.getData);
    yield put(searchData(response));
}

export function* saveDataSaga({payload}) {
    const response = yield call(API.saveData, payload);
    if(response != null && (response.status == 201 || response.status == 200)) {
        yield put(searchDataAsync());
    }
}

export function* removeDataSaga({payload: id}) {
    const response = yield call(API.removeData, id);
    if(response.status == 200) {
        yield put(searchDataAsync());
    }
}


// initState
const initialState = {
    boards: [],
    lastId: 0
}


// Toolkit Reducer
export default createReducer(initialState, {
    [SEARCH_DATA]: (state, {payload: data}) => {
        state.boards.length=0;
        for(let i = 0 ; i < data.length ; i++) {
            state.boards.push({
                id: data[i].id,
                boardId: data[i].id,
                boardTitle: data[i].boardTitle,
                boardContent: data[i].boardContent
            });
            if(i == data.length-1) {
                state.lastId = data[i].id;
            }
        }
    }
})

 

 

src/module/rootReducer.js

import { combineReducers } from 'redux';
import boardReducer, { boardSaga } from './boardReducer';
import { all } from 'redux-saga/effects';

const rootReducer = combineReducers({
    boardReducer
});

export function* rootSaga() {
    yield all([boardSaga()]);
}

export default rootReducer;

 

 

data.json

{
  "data": [
    {
      "id": 1,
      "boardId": 1,
      "boardTitle": "Title 1",
      "boardContent": "Content 1"
    },
    {
      "id": 2,
      "boardId": 2,
      "boardTitle": "Title 2",
      "boardContent": "Content 2"
    }
  ]
}

 

 

실행화면

포스팅 이미지 01

 

조회를 눌려 데이터를 조회해보고,

 

신규 데이터도 입력해보고,

 

행을 클릭해 데이터도 수정해보고,

 

삭제도 해보장 ㅎ.

 

 

 

반응형