지금껏 다룬 포스팅의 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"
}
]
}
실행화면
조회를 눌려 데이터를 조회해보고,
신규 데이터도 입력해보고,
행을 클릭해 데이터도 수정해보고,
삭제도 해보장 ㅎ.
'Coding Story > REACT' 카테고리의 다른 글
[ React ] 리액트 Material UI 사용해보기 (13) | 2020.10.27 |
---|---|
[ React ] 리액트 React-Hook-Form (2) | 2020.10.27 |
[ React ] 리액트 리덕스 툴킷 redux-toolkit (3) | 2020.10.22 |
[ React ] 리액트 Redux 로 게시판 만들어보기 (8) | 2020.10.22 |
[ React ] 리액트 리덕스(Redux) 사용해보기 (0) | 2020.10.22 |