[프로젝트] 게시판 front 리펙토링 (4) middleware(redux-thunk)를 이용하여 api 요청하기

rin·2020년 6월 7일
1

React

목록 보기
12/16
post-thumbnail

목표
1. middleware를 이용해 데이터를 요청한다.
2. 요청 받은 데이터의 구조에 맞춰 출력 데이터를 조정한다.

middleware

why?

ref. https://velopert.com/3401
https://www.npmjs.com/package/redux-thunk

현재 만들어 둔 구조를 최대한 변경하지 않고 비동기 요청으로 데이터를 받아올 방법을 강구해보았다. 🤔
물론 이전에 MainContainer에서 사용한 것처럼 특정 함수가 호출되면 내부에서 api 요청을 시도하고, 얻은 값을 액션 생성 함수에 payload로 넘겨줘도 된다.

하지만 좀 더 깔끔하게 이런 순차적인 로직을 작성하고 싶었고 결과적으로 미들웨어를 추가하기로 하였다. (미들웨어에 대한 자세한 내용은 따로 정리해서 올리도록 하겠다.)

라이브러리 추가

redux-thunk & redux-logger

두가지 라이브러리를 추가할 것인데, redux-thunkredux-logger 이다.

🔎 redux-thunk
Redux Thunk 미들웨어는 액션 대신 함수를 반환하는 액션 생성자를 사용할 수 있게 해준다. Thunk는 액션의 디스패치를 늦추거나, 특정 조건이 충족되는 경우에만 디스패치하도록 할 수 있다. 내부 함수에서는 디스패치나 getState를 매개 변수로 받는다.
redux-logger는 위 이미지처럼 콘솔창에 미들웨어를 거치는 특정 액션이 수행되었을 때 전후의 상태를 출력해준다.

아래 명령어를 통해 두 라이브러리를 추가해주자.
yarn add redux-thunk
yarn add redux-logger

index.js

store를 생성할 때 미들웨어를 추가해주도록 한다. 이렇게만 하면 디스패치를 반환하는 액션 생성 함수를 사용할 수 있고, 로직은 일반적인 액션 생성 함수를 만드는 것과 동일하기 때문에 어려움이 없을 것이다.

🙅🏻 사실 문제는 이 "미들웨어"라는 것이 어떻게 돌아가는 것인지 이해하는 것이라고 생각한다.

액션 생성 함수

board 스토어의 action.js파일을 수정해 줄 것이다.

그전에, 아래와 같은 코드를 작성해보자.

export const changePageAsync = (pageNumber, selectedData) => dispatch => {
    setTimeout(
        () => {dispatch(changePage(pageNumber, selectedData))},
        1000
    );
}

BoardContainer 컴포넌트의 mapDispatchToProps 함수에 위 액션 생성 함수를 추가하고 페이지를 변경했을 때 changePage 대신 작동하도록 코드를 수정한다.

실행 후 페이지를 넘기면 1초 뒤에 데이터가 랜더링되는 것을 확인할 수 있을 것이다.

위의 redux-thunk에 대한 간략한 소개말에서 말했듯이 디스패치하는데 일정 시간을 지연한 것이다. 결과적으로 실행되는 액션 생성함수는 changePage이다.

그럼 이 코드를 살짝 바꿔서 Api 요청 후 받아온 데이터를 selectedData로 넘겨주도록 하자.
이 때 리턴값으로 promise 객체를 전달해야 하기때문에 boardApiget 메소드는 다음처럼 바꿔주었다.

🔎 boardApi.js

import axios from 'axios';
import { DOMAIN } from '../../static/constant';

export function get(pageNumber, pageSize) {
    return axios.get(DOMAIN+'/api/boards?page='+pageNumber+'&size='+pageSize);
}

🔎 action.js

import {get} from "../../api/boardApi";

// ( .. 생략 )

export const changePageNumber = createAction(
    type.CHANGE_PAGE,
    (pageNumber, selectedData) => ({
        pageNumber, selectedData
    })
)

export const changePage = (pageNumber, pageSize) => dispatch => {
    var testData = [{name: 'Mehmet', surname: '안녕하세요 ^^ 5-1', birthYear: 1987, birthCity: 63},
        {name: 'Zerya Betül', surname: '글 제목입니다.', birthYear: 2017, birthCity: 34},
        {name: 'Bread', surname: '게시판 테스트', birthYear: 2011, birthCity: 34},
        {name: 'Jonny', surname: 'Material-table 이용하기', birthYear: 2012, birthCity: 17},
        {name: 'Sera', surname: '페이징 처리 이용하기', birthYear: 2007, birthCity: 17}];

    return get(pageNumber+1, pageSize)
        .then(response => {
            const selectedData = testData.concat(response.data.contents).concat(testData);
            dispatch(changePageNumber(pageNumber, selectedData));
        }).catch(error => {
            /* error control code */
        })
}

헷갈릴 수도 있겠지만, 함수명을 다음처럼 바꿔주었다.

  • changePage → changePageNumber
  • changePageAsync → changePage

새로운 changePage 함수가 호출되면 get api 호출을 수행하고, 그에 따른 결과값이 포함된(then or catch) 프로미스 객체를 반환해준다.

테스트를 위해서 api 요청으로 얻어낸 데이터(response.data.contents)의 앞, 뒤로 가짜 데이터를 5개씩 붙여넣어 selectedData로 사용한다.

❗️
changePage 내 get 함수를 호출할 때, pageNumber에 +1을 해주고 있다. 이는 이 컴포넌트가 제로베이스의 페이징 처리를 하고 있기 때문이다.
이게 문제가 되는 이유는 서버측에서는 페이지 번호가 0이면 1로 인식하기 때문에

client에서 pageNumber=0 전달(화면상에선 1페이지) → 서버에서 1페이지 데이터 추출
client에서 pageNumber=1 전달(화면상에선 2페이지) → 서버에서 1페이지 데이터 추출
client에서 pageNumber=2 전달(화면상에선 3페이지) → 서버에서 2페이지 데이터 추출
...

위와 같이 사용자가 인식하는 1페이지와 2페이지가 동일한 데이터로 출력되게된다.
따라서 서버에 보내기 전에 +1해줌으로써 문제를 해결하는 것이다.

client에서 pageNumber=0+1 전달(화면상에선 1페이지) → 서버에서 1페이지 데이터 추출
client에서 pageNumber=1+1 전달(화면상에선 2페이지) → 서버에서 2페이지 데이터 추출
client에서 pageNumber=2+1 전달(화면상에선 3페이지) → 서버에서 3페이지 데이터 추출
...

🔎 BoardContainer

//... 생략
import {changePage} from "../store/modules/board/action";

const BoardContainer = ({pageNumber, pageSize, selectedData, changePage}) => {
    //... 생략
  
    const handleChangePage = (pageNumber) => {
        changePage(pageNumber, pageSize);
    };

    const handleChangeRowPerPage = (pageSize) => {
        changePage(pageNumber, pageSize);
    }
    
    //... 생략
}

const mapDispatchToProps = dispatch => ({
    changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
})

//... 생략

이제 BoardContainer에서는 미들웨어로 사용될 changePage만 스토에 연동할 것이다.

어플리케이션을 실행시켜보자.

데이터가 잘 출력되는 1페이지와 달리 2페이지로 넘어가면 데이터가 출력되지 않을 것이다. 콘솔을 켜서 로그가 찍어낸 값을 확인하자. BOARD.CHANGE_PAGE 액션이 수행된 뒤 변경된 데이터를 보면 selectedData에 실제로 서버에서 받아온 데이터가 포함되어 있는 것을 볼 수 있다. 이 데이터가 출력이 되지 않는 이유는 단지 key 이름이 다르기 때문이다.

액션 변경하기

보다시피 페이지가 변경됐을 때와 페이지당 보여주는 게시글 개수가 변경됐을 때의 호출 함수가 동일하다. 따라서 이 함수는 통합해준다. 굳이 pageNumber state와 pageSize state를 바꾸고/바꾸지 않는 두개의 액션으로 나눌 필요가 없어보인다. (크게 비용이 들지 않는 작업이기 때문에)

액션 타입 중 CHANGE_PAGE만 남겨두고 리듀서도 그에 맞춰 하나로 통합해주었다.

변경 전변경 후

changePage 함수에서 dispatch할 다른 액션 함수를 호출하는 부분을 다음처럼 객체 형태로 바꿔주자. 처음에 작성한 것처럼 액션 생성 함수를 호출해줘도 되지만 굳이..? 스러운 작업이기 때문에 이렇게 변경해주겠다.

결과적으로 action.js는 다음과 같다.

import type from './type'
import {get} from "../../api/boardApi";

export const changePage = (pageNumber, pageSize) => dispatch => {
    var testData = [{name: 'Mehmet', surname: '안녕하세요 ^^ 5-1', birthYear: 1987, birthCity: 63},
        {name: 'Zerya Betül', surname: '글 제목입니다.', birthYear: 2017, birthCity: 34},
        {name: 'Bread', surname: '게시판 테스트', birthYear: 2011, birthCity: 34},
        {name: 'Jonny', surname: 'Material-table 이용하기', birthYear: 2012, birthCity: 17},
        {name: 'Sera', surname: '페이징 처리 이용하기', birthYear: 2007, birthCity: 17}];

    return get(pageNumber+1, pageSize)
        .then(response => {
            const selectedData = testData.concat(response.data.contents).concat(testData);
            dispatch({
                type: type.CHANGE_PAGE,
                payload: {
                    pageNumber: pageNumber,
                    pageSize: pageSize,
                    selectedData: selectedData
                }
            })
        }).catch(error => {
            /* error control */
        })
}

여기까지 수정됐으면 잘 작동하는지 한 번 확인해본다. 🙋🏻

출력 가능한 데이터로 가공하기

helper 생성

이전에 데이터를 가공하기 위해 handleData라는 함수 오브젝트를 BoardContainer에 만들어두었는데 데이터를 가져오는 작업이 Action으로 넘어가면서 이를 사용하지 못하게 되었다.
이를 helper로 뺀 뒤 Action에서 해당 함수를 호출해 사용할 수 있도록 만들것이다.

src 하위에 helper 폴더를 만든 뒤 boardHelper.js 파일을 생성해주었다. 이전에 사용하던 코드를 가져와서 약간의 변형만 가해줬다. 헬퍼는 action.jschangePage에서 사용될 것이다.

이전에는 하드코딩 되어있던 데이터 리스트에서 split을 이용해 realData를 뽑아냈지만, 현재부터는 서버로부터 받아온 response에서 realData를 추출하여 사용한다.

변경 전(BoardContainer)변경 후(boardHelper)

또한 MaterialTable이 요구하는 형식에 맞추기위해 오브젝트로 전달받아지는 createdAt (글 작성 일시)에 약간의 가공을 가할것이다.

전체 코드는 아래와 같다.

export const getData = (pageNumber, pageSize, response) => {
    let prevFakeData = createPrevFakeData(pageSize * pageNumber);
    let nextFakeData = createNextFakeData(pageSize);
    const realData = setRealData(response.data.contents);

    if (isFirstPage(pageNumber)) {
        return realData.concat(nextFakeData);
    } else if (isLastPage(pageNumber, response)) {
        return prevFakeData.concat(realData);
    } else {
        return prevFakeData.concat(realData).concat(nextFakeData);
    }
}

const setRealData = contents => {
    contents.forEach(function (element, idx, data) {
        var month = element.createdAt.month;
        var dayOfMonth = element.createdAt.dayOfMonth;
        var hour = element.createdAt.hour;
        var minute = element.createdAt.minute;
        data[idx].createdAt = month + '-' + dayOfMonth + ', ' + hour + ':' + minute;
    })
    return contents;
}

const createPrevFakeData = size => {
    let fakeData = Array.apply(null, new Array(size)).map(Object.prototype.valueOf, new Object());
    return fakeData.map((currentValue, index) => setFakeData(index));
}


const createNextFakeData = size => {
    let fakeData = Array.apply(null, new Array(size)).map(Object.prototype.valueOf, new Object());
    return fakeData.map((currentValue, index) => setFakeData(index));
}

const setFakeData = id => {
    let fakeData = new Object();
    fakeData.tableData = {id: id};
    return fakeData;
}

const isFirstPage  = pageNumber => {
    return pageNumber == 0;
}

const isLastPage = (pageNumber, response) => {
    return pageNumber == response.data.totalPages - 1
}

헬퍼를 추가한뒤 board/action.js는 다음과 같이 변경하였다.

import type from './type'
import {get} from "../../api/boardApi";
import {getData} from "../../../helper/boardHelper";

export const changePage = (pageNumber, pageSize) => dispatch => {

    return get(pageNumber+1, pageSize)
        .then(response => {
            const selectedData = getData(pageNumber, pageSize, response);
            dispatch({
                type: type.CHANGE_PAGE,
                payload: {
                    pageNumber: pageNumber,
                    pageSize: pageSize,
                    selectedData: selectedData
                }
            })
        }).catch(error => {
            /* error control */
        })
}

BoardContainer

import React, {useEffect} from "react";
import {connect} from 'react-redux';
import {changePage} from "../store/modules/board/action";
import Board from "../components/Board";

const BoardContainer = ({pageNumber, pageSize, selectedData, changePage}) => {
    useEffect(() => {
        changePage(pageNumber, pageSize);
    }, [])

    const columns = [
        {title: '제목', field: 'title'},
        {title: '작성자', field: 'writer.accountId'},
        {title: '작성일시', field: 'createdAt'},
    ]

    const handleChangePageNumber = (pageNumber) => {
        changePage(pageNumber, pageSize);
    };

    const handleChangePageSize = (pageSize) => {
        changePage(pageNumber, pageSize);
    };

    return (
        <Board
            pageNumber={pageNumber}
            pageSize={pageSize}
            selectedData={selectedData}
            handleChangePageNumber={handleChangePageNumber}
            handleChangePageSize={handleChangePageSize}
            columns={columns}
            data={selectedData}
        />
    );
}

const mapStateToProps = state => ({
    pageNumber : state.board.pageNumber,
    pageSize : state.board.pageSize,
    selectedData : state.board.selectedData
})

const mapDispatchToProps = dispatch => ({
    changePage : (pageNumber, pageSize) => dispatch(changePage(pageNumber, pageSize)),
})

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(BoardContainer)

data 전체를 지워줬으며, useEffect를 추가하고 columns를 변경해주었다.

첫 페이지 데이터로 초기화하기 위해 useEffect의 두번째 인자를 빈 배열 []로 설정하여 처음 로드할 때와 요소가 제거될 때만 호출되도록 하였다.

board/reducer.js의 초기화 상수도 데이터 대신 빈 배열로 변경해주자.

import {handleActions} from 'redux-actions'
import type from './type'
import {BOARD_PAGE_SIZE} from '../../../static/constant';

const initialState = {
    pageNumber: 0,
    pageSize: BOARD_PAGE_SIZE,
    selectedData: []
}

export default handleActions({
        [type.CHANGE_PAGE]: (state, action) => ({
            ...state,
            pageNumber: action.payload.pageNumber,
            pageSize: action.payload.pageSize,
            selectedData: action.payload.selectedData
        }),
    }, initialState
)

🙋🏻 서버에서 보낸 데이터로 잘 출력되는지 확인해보자!!

❗️
MaterialTable에서 페이지 당 게시글 수를 변경하면

onChangeRowsPerPage 뿐만 아니라, onChangePage 또한 호출된다.
이게 거의 동시에 호출되면서 서버로 데이터를 요청하고 받아오는 시간의 차이때문에 일관성이 무너지는 것을 발견하였다. 데이터를 받아오는건 둘째치고 양쪽에서 갱신하는 pageSize가 상이해지면서 화면에 제대로 출력되지 않는다. 🤔

이 문제를 어떻게 해결할지 고민해보도록 하겠다. (지금 떠오르는 것은 이전처럼 pageSize와 pageNumber를 각기 다른 액션에서 갱신하는 것..)

전체 코드는 github에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글