React에 Redux를 입혀서 상태관리 하는 법을 다뤄봤습니다.
들어가기에 앞서서 살짝 정리해보자면..
src/actions/
action
객체를 리턴하는 함수들의 집합src/reducers/
state
를 관리하는 reducer
생성src/index.js
createStore()
로 저장소 생성, Provider
로 전달src/App.js
useSelector
, useDispatch
를 이용해 state
사용dispatch()
의 인자로 액션 생성 함수를 넣었고, 액션 객체가 리듀서 안으로 들어가 상태를 관리하는 형태였어요.
비동기 로직을 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를 이해하기 위해선 ES6 - Generator에 대한 개념이 필요합니다!! )
Unsplash의 API를 사용해서 비동기 로직이 포함된 상태 관리를 해볼게요.
우선 어제 했던 것과 마찬가지로 액션 생성 함수부터 만들겠습니다.
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
제너레이터 함수를 작성할 때 필요합니다.
나중에 다시 돌아올게요.
리듀서도 빠르게 만들어줍시다.
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에 추가해볼게요.
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
함수 실행이제 계속해서 언급되었던 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가 업데이트 되는거예요.
검색 기능 구현하던 중, 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
로 향합니다.
rootSaga
는 action.type
을 보고 일치하는 것을 찾아서 workerSearchMovieSaga
로 이 객체를 넘겨주는 거예요.
따라서 action.term
을 받아올 수 있습니다!! 해결!!
이렇게 멋진 코드들로 상태를 받아왔는데, 디자인을 안 하고 넘어갈 수가 없더라구요.
또 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"
rootSaga
의 takeEvery
와 action.type
일치 => workerSaga
로 넘어감workerSaga
에서 비동기 처리, put
으로 dispatch
=> action.type
은 "LOAD_IMAGE_SUCCESS"
reducer
에서 state
갱신끝!! 감사합니다 :D