[React] Redux + Saga, 상태관리를 끝장내버렸다.

이찬형·2020년 4월 3일
18
post-thumbnail
post-custom-banner

Redux


React에 Redux를 입혀서 상태관리 하는 법을 다뤄봤습니다.

들어가기에 앞서서 살짝 정리해보자면..

  • src/actions/
    - action 객체를 리턴하는 함수들의 집합
  • src/reducers/
    - state 를 관리하는 reducer 생성
  • src/index.js
    - createStore() 로 저장소 생성, Provider로 전달
  • src/App.js
    - useSelector, useDispatch 를 이용해 state 사용

dispatch()의 인자로 액션 생성 함수를 넣었고, 액션 객체가 리듀서 안으로 들어가 상태를 관리하는 형태였어요.

thunk

비동기 로직을 state에 넣기 위해서 우리는 미들웨어인 thunk를 사용헀습니다.

클로저 형태로 되어 있는 이 친구는 액션 생성 함수 내에서 dispatch를 인자로 받는 함수를 리턴하여 비동기 처리를 구현했어요.

const fetchingPopularMovies = () => {
  return dispatch => {
    return axios
      .get(
        `https://api.themoviedb.org/3/movie/popular?api_key=${API_KEY}&language=en-US&page=1`
      )
      .then(response => {
        dispatch(fetchPopularMovies(response.data.results));
      })
      .catch(error => {
        throw error;
      });
  };
};

이게 좀 맘에 안 들었단 말이죠? 액션 생성 함수는 말 그대로 퓨-어한 액션 객체를 만들기만 하면 되는데, 비동기 연산을 여기서 수행한다니..

또 ES6에선 이런 패턴의 콜백 지옥을 개선하기 위해 Promise라는 개념도 생겼잖아요. 저 코드는 조금 옛날 스타일이라는 느낌이 확 왔습니다.

코드를 모던하게 바꿔봅시다. saga를 이용해서요!!

saga

( saga를 이해하기 위해선 ES6 - Generator에 대한 개념이 필요합니다!! )

Unsplash의 API를 사용해서 비동기 로직이 포함된 상태 관리를 해볼게요.

우선 어제 했던 것과 마찬가지로 액션 생성 함수부터 만들겠습니다.

action

src/actions/imageActions.js

// 이 친구는 rootSaga 제너레이터 함수 작성 시 사용됩니다!!
const loadImages = () => {
  return {
    type: "LOAD_IMAGES"
  };
};

const loadImagesSuccess = imgs => {
  return {
    type: "LOAD_IMAGES_SUCCESS",
    images: imgs
  };
};

const loadImagesFail = error => {
  return {
    type: "LOAD_IMAGES_SUCCESS",
    error
  };
};

export default { loadImages, loadImagesSuccess, loadImagesFail };

}

언뜻 보면 필요없어 보이는 loadImages 함수는 추후 rootSaga 제너레이터 함수를 작성할 때 필요합니다.
나중에 다시 돌아올게요.

reducer

리듀서도 빠르게 만들어줍시다.

src/reducers/imageReducer.js

const images = (state = [], action) => {
  switch (action.type) {
    case "LOAD_IMAGES_SUCCESS":
      return [...state, ...action.images];
    case "LOAD_IMAGES_FAIL":
      return [...state, action.error];
    default:
      return state;
  }
};

export default images;

여기까진 일반 Redux를 작성할 때와 똑같습니다.

store, Middleware

이제 미들웨어를 store에 추가해볼게요.

src/index.js

.
.
import createSagaMiddleware from "redux-saga";
import rootSaga from "./saga";

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  compose(
    applyMiddleware(sagaMiddleware),
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
  )
);

sagaMiddleware.run(rootSaga);
.
.

우선 createSagaMiddleware() 함수로 미들웨어를 생성합니다.
그 후 store에 thunk와 똑같이 applyMiddleware로 미들웨어를 넣어줘요.

compose 안에 window.~~ 구문은 리덕스 개발 툴을 사용하기 위해 작성한 것입니다.

이후 미들웨어에서 run 함수를 실행합니다.
이것은 마치 이벤트 리스너를 열어서 rootSaga에 해당하는 액션이 올 때를 기다리는 거예요.

일반 리덕스를 사용할 때와는 다르게 두 가지가 추가됐죠?

  • createSagaMiddleware()로 미들웨어 생성
  • 미들웨어에 run 함수 실행

saga

이제 계속해서 언급되었던 rootSaga를 작성해봅시다.

src/saga/index.js

import { takeEvery } from "redux-saga/effects";

function* rootSaga {
  yield takeEvery("LOAD_IMAGES", workerSaga);
}

export default rootSaga;

제너레이터 함수로 rootSaga를 만들었어요.

takeEvery는 redux-saga의 이펙트 중 하나로,
dispatch에 의해 action.type이 "LOAD_IMAGES"인 객체가 올 때 workerSaga를 실행시켜줘!! 란 의미입니다.

이어서 작성해보죠.

import { takeEvery, put, call } from "redux-saga/effects";
import allActions from "../actions";
import api from "../api";

function* workerSaga() {
  console.log("Hello, worker!");
  try {
    const { data } = yield call(api.getImages);
    console.log(data);
    yield put(allActions.imageActions.loadImagesSuccess(data));
  } catch (error) {
    yield put(allActions.imageActions.loadImagesFail(error));
  }
}

// rootSaga

workerSaga에선 call로 API를 호출하고 put으로 dispatch해요.

먼저 call부터 볼까요? 이 함수의 인자는 Promise를 반환해야 해요.
때문에 axios.get()을 리턴하면 잘 처리가 됩니다.

이렇게 비동기로 받은 데이터를 put, 즉 dispatch 하는 거예요.

이제 App.js에서 뿌려주면 끝입니다.

src/App.js

const images = useSelector(state => state.images);
const dispatch = useDispatch();

useEffect(() => {
  dispatch(allActions.imageActions.loadImages());
}, []);

액션 생성 함수를 만들 때 loadImages()의 역할이 없어 보였죠?

여기서, useEffect는 컴포넌트가 마운트 됐을 때 아래 코드를 실행해요.

dispatch({ type: "LOAD_IMAGES" });

saga 미들웨어가 존재하기 때문에 dispatch가 rootSaga로 넘어갑니다.
그 안에서 takeEvery()를 만나 type이 일치하는 것이 확인되고 workerSaga로 넘어가죠.

여기선 비동기로 로직을 실행한 다음에 put으로 loadImagesSuccess()를 dispatch합니다.
마지막으로 리듀서로 넘어가 state가 업데이트 되는거예요.


04/10 추가++

검색 기능 구현하던 중, workerSaga에 파라미터를 넘겨줘야 하는데 방법을 모르겠더라구요.

엄청 고민하다가 구글링 해서 찾아냈습니다ㅠㅠ

우선 Search API를 볼게요.

src/api/index.js

const searchMoviesApi = (term) => {
  return axios.get(
    `${BASE_URL}search/movie?api_key=${API_KEY}&language=ko&query=${encodeURIComponent(
      term
    )}`
  );
};

const searchTVApi = (term) => {
  return axios.get(
    `${BASE_URL}search/tv?api_key=${API_KEY}&language=ko&query=${encodeURIComponent(
      term
    )}`
  );
};

term엔 사용자의 입력이 들어갈거예요.
그걸 쿼리에 보내서 원하는 데이터를 긁어오는 API입니다.

액션 생성 함수부터 빠르게 만들어볼게요. 영화 검색 결과만 구현해보겠습니다.

src/actions/searchActions.js

const searchMovies = (term) => {
  return {
    type: "SEARCH_MOVIES",
    term,
  };
};

const successSearchMovies = (movies) => {
  return {
    type: "SUCCESS_SEARCH_MOVIES",
    movies,
  };
};

const failSearchMovies = (error) => {
  return {
    type: "FAIL_SEARCH_MOVIES",
    error,
  };
};

export default {
  searchMovies,
  successSearchMovies,
  failSearchMovies,
};

컴포넌트에서 searchMovies를 dispatch할 때 term을 넘겨줘요.

리듀서는 똑같으니 생략하겠습니다.

saga를 작성할게요.

src/saga/index.js

function* workerSearchMoviesSaga(action) {
  console.log("Hello, I am workerSaga. I got " + action.term);
  try {
    const {
      data: { results },
    } = yield call(api.searchMoviesApi, action.term);
    yield put(allActions.searchActions.successSearchMovies(results));
  } catch (error) {
    yield put(allActions.searchActions.failSearchMovies(error));
  }
}

function* rootSaga() {
  // .. code
  yield takeEvery("SEARCH_MOVIES", workerSearchMoviesSaga);
}

해결방법이 바로 이겁니다!!
takeEvery 부분에서 action.type이 일치하면 해당 제너레이터 함수로 넘겨주잖아요.

dispatch로 받은 객체를 전부 넘겨주는겁니다!! 따라서 workerSearchMovieSaga에서 action 인자로 이 객체에 접근할 수 있어요.

때문에 컴포넌트에서 아래와 같이 불러주면 action.term으로 접근이 가능합니다.

src/routers/Search/index.js

  const [term, setTerm] = useState("");
  const search = useSelector((state) => state.search);
  const dispatch = useDispatch();

  const handleChange = (event) => {
    const term = event.target.value;
    setTerm(term);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    dispatch(allActions.searchActions.searchMovies(term));
  };

submit이 발생했을 때,
dispatch({type: "SEARCH_MOVIES", term: term}) 가 미들웨어인 rootSaga 로 향합니다.

rootSagaaction.type을 보고 일치하는 것을 찾아서 workerSearchMovieSaga로 이 객체를 넘겨주는 거예요.

따라서 action.term을 받아올 수 있습니다!! 해결!!

번외) CSS grid


이렇게 멋진 코드들로 상태를 받아왔는데, 디자인을 안 하고 넘어갈 수가 없더라구요.
또 Unsplash 사진들이 너무 좋아서.. grid 공부할 겸 css를 작성했습니다.

.content {
  max-width: 100vw;
  margin: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.grid {
  /* grid 선언 */
  display: grid;
  /* columns을 자동으로 나눔, 최소 200px, 최대 1fr */
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  padding: 25px;
  grid-gap: 25px;
  /* 정렬되지 않은 요소들을 row에 자동배치 */
  grid-auto-flow: dense;
  align-items: stretch;
  width: 100%;
}

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 5px;
}

.item {
  position: relative;
}

/* item-${Math.ceil(image.height / image.width)} */
/* 비율을 구해서 row span 적용 */
.item-1 {
  grid-row: span 1;
}
.item-2 {
  grid-row: span 2;
}
.item-3 {
  grid-row: span 3;
}
.item-4 {
  grid-row: span 4;
}
.item-5 {
  grid-row: span 5;
}

.btn {
  all: unset;
  cursor: pointer;
  width: 200px;
  height: 70px;
  border: 1px solid #727272;
  border-radius: 10px;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 24px;
  color: #f2f2f2;
  background-color: #323232;
  font-weight: 600;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
  box-shadow: 7px 7px 15px 0px rgba(148, 146, 148, 1);
}

결과는..

grid 최고..!!

마무리


리액트랑 약간 권태기가 올 뻔 했는데, 상태 관리 공부하면서 더 재미있어졌네요.
실무형 코드를 작성해 나가는 것 같아서 뿌듯합니다 ㅎㅎ

이틀동안 리액트에 리덕스를 잘 입혀보려고 엄청 공부했는데, 마무리가 잘 된것 같아서 기쁘네요.

흐름을 정리하고 끝내겠습니다!

  • App.js에서 dispatch => action.type"LOAD_IMAGES"
  • rootSagatakeEveryaction.type 일치 => workerSaga로 넘어감
  • workerSaga에서 비동기 처리, put으로 dispatch => action.type"LOAD_IMAGE_SUCCESS"
  • reducer에서 state 갱신

끝!! 감사합니다 :D

profile
WEB / Security
post-custom-banner

0개의 댓글