리덕스란?
대표적인 상태관리 Javascript 라이브러리로 컴포넌트 내부에서 사용하는 데이터인 상태를 관리하기 위해 사용한다. 만약 하나의 부모 컴포넌트와 자식 컴포넌트들이 여럿이라는 상황을 가정해보자.
만약 자식 1의 state를 자식 6에서 사용하고 싶다면 자식 1 -> 부모 -> 자식 3 -> 자식 6으로 state를 넘겨줘야하는데 하위 컴포넌트에서 상위컴포넌트로 데이터를 넘겨주는 것은 적절한 방법이 아니다. 그렇기 때문에 전역상태관리를 위한 라이브러리인 리덕스를 이용하여 하나의 스토어에 저장해둔 상태를 원하는 컴포넌트에 불러다 사용하면 되는 것이다.
이렇게 리덕스를 이용하면 컴포넌트끼리 같은 상태를 공유해야할 때도 쉽게 적용할 수 있으며 상태 업데이트 로직을 분리시켜 더욱 효율적인 관리가 가능해진다.
리덕스 관련 용어
대표적인 리덕스 흐름 구조를 살펴보면 UI -> ACTION -> REDUCER -> STORE -> STATE -> UI 이런 식을 진행되는데 하나하나 어떤 것인지 정리해보려고 한다.
1. Action(액션)
- 상태에 변화를 일으키기 위해 액션을 생성한다.
- 액션은 하나의 객체로 표현되며 TYPE 필드의 값을 반드시 가지고 있어야한다.
- TYPE 필드의 값이 액션의 이름이라고 생각하면 편하다.
const DELETE_AUDIOBOOK = "DELETE_AUDIOBOOK";
2. Action Creator(액션 생성 함수)
- 액션 생성함수는 액션 객체를 만들어주는 함수로 화살표함수로 표현이 가능하다.
const deleteAudioBook = createAction(DELETE_AUDIOBOOK, (audioBookId) => ({ audioBookId }));
3. Reducer(리듀서) => immer사용
- action을 통해서 어떠한 행동을 실행시켰다면 현재 상태와 액션 객체를 받아 새로운 상태를 리턴하는 함수이다.
- 즉, 액션을 실행시켜 리듀서에 전달하면 리듀서가 주문된 것을 보고 Store의 상태를 업데이트해준다.
// 초기값
const initialState = {
category_novel: [],
category_poem: [],
category_economy: [],
category_kids: [],
category_self: [],
paging: { page: 1, size: 20 },
is_loading: false,
};
// immer를 사용한 리듀서
export default handleActions(
{
[DELETE_AUDIOBOOK]: (state, action) =>
produce(state, (draft) => {
draft.detail_book.audio = draft.detail_book.audio.filter((p) => p.audioBookId !== action.payload.audioBookId);
}),
},
initialState
);
4. Store(스토어)
- 상태가 들어있는 저장공간을 말하며 하나의 프로젝트는 하나의 스토어만 가질 수 있다는 특징이 있다.
- 즉, State의 변화를 감지해 View로 변경된 사항을 알려주는 역할을 하는 것이다.
import { createStore, combineReducers, applyMiddleware, compose } from "redux";
import thunk from "redux-thunk";
import { createBrowserHistory } from "history";
import { connectRouter } from "connected-react-router";
// 모듈
import User from "./modules/user";
import Book from "./modules/book";
import Audio from "./modules/audio";
import Search from "./modules/search";
import Fund from "./modules/fund";
import Mypage from "./modules/mypage";
import Chat from "./modules/chat";
import Creator from "./modules/creator"
// history를 페이지에서 편하게 사용할 수 있도록 준비
export const history = createBrowserHistory();
const rootReducer = combineReducers({
user: User,
book: Book,
audio : Audio,
search : Search,
fund : Fund,
mypage : Mypage,
chat : Chat,
creator : Creator,
router: connectRouter(history),
});
const middlewares = [thunk.withExtraArgument({ history: history })];
// 지금이 어느 환경인 지 알려주는 것
const env = process.env.NODE_ENV;
// 콘솔에서 로거 확인하기
if (env === "development") {
const { logger } = require("redux-logger");
middlewares.push(logger);
}
// 리덕스 데브툴 설정하기
const composeEnhancers =
typeof window === "object" && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
: compose;
// 미들웨어 묶기
const enhancer = composeEnhancers(applyMiddleware(...middlewares));
// 스토어 만들기
let store = (initialStore) => createStore(rootReducer, enhancer);
export default store();
5. Dispatch(디스패치)
- 스토어의 내장함수 중 하나로 리듀서에게 Action을 실행하라고 명령하는 함수라고 생각하면 된다.
- 보통 dispatch(action)으로 작성하며 action을 인자로 넘긴다.
// 미들웨어
const deleteAudioBookAC = (audioBookId) => {
let Token = getToken("Authorization");
return function (dispatch, getState, { history }) {
axios.delete(process.env.REACT_APP_BASE_URL + `/audio/detail/remove/${audioBookId}`,
{ headers: { 'Authorization': `${Token}` } },
)
.then((res) => {
// 디스패치로 액션을 실행시키는 부분
dispatch(deleteAudioBook(audioBookId))
})
.catch(error => {
// console.log("error", error)
})
}
}
6. Subscribe(구독)
- 스토어의 내장함수 중 하나로 리스너 함수를 파라미터로 넣어 호출하면 상태가 업데이트될 때마다 호출된다.
- 일종의 이벤트 리스너
리덕스의 3가지 원칙
1. Single source of truth(단일 스토어)
- 하나의 애플리케이션 안에는 하나의 스토어를 가져야한다는 원칙이다.
- 이렇게 하게 될 경우, 동일한 데이터는 항상 같은 곳에서 가져올 수 있게 된다.
2. State is read-only(읽기 전용 상태)
- 리덕스의 상태는 읽기 전용이다.
- 리덕스 고유의 불변성을 지키기 위해 리덕스의 상태를 업데이트 할 때는 기존의 객체를 건드리지 않고 새로운 객체를 생성해야한다.
3. Changes are made with pure functions(리듀서는 순수한 함수)
- 리듀서는 이전의 상태와 액션 객체를 파라미터로 받아야하며 이전의 상태를 건드리지 않고 변화된 새로운 상태 객체를 만들어 반환해야한다.
- 동일한 파라미터로 호출된 리듀서는 언제나 똑같은 결과를 반환해야한다.
리덕스 미들웨어 Redux-thunk
redux-thunk는 리덕스에서 비동기 작업을 처리할 때 사용하는 미들웨어로 액션 객체가 아닌 함수를 디스패치할 수 있게 된다. 이 라이브러리를 이용하면 thunk 함수를 만들어 디스패치할 수 있게 되는데 미들웨어가 그 함수를 전달받아 store의 dispatch와 getState를 파라미터로 넣어서 호출해준다.
// redux-thunk
// 오디오북 챕터 삭제(관리자)
const deleteAudioBookAC = (audioBookId) => {
let Token = getToken("Authorization");
return function (dispatch, getState, { history }) {
axios.delete(process.env.REACT_APP_BASE_URL + `/audio/detail/remove/${audioBookId}`,
{ headers: { 'Authorization': `${Token}` } },
)
.then((res) => {
dispatch(deleteAudioBook(audioBookId))
})
.catch(error => {
// console.log("error", error)
})
}
}
Immer로 불변성 유지하기
블변성을 지킨다는 것은 기존의 값을 직접적으로 수정하지 않고 새로운 값을 만드는 것을 의미하는데 구조가 복잡해지게 되면 불변성을 유지하며 이를 업데이트 하는 것이 힘들어질 수밖에 없다. 그렇기 때문에 이러한 상황에서 간결한 코드로 불변성을 유지하며 data를 업데이트할 수 있는 immer를 사용하게 된다. 편의를 위한 것이기 때문에 필수는 아니지만 적절한 곳에 사용하게 되면 생산성을 크게 높일 수 있다는 장점이 있다.
즉, immer는 보다 편리한 방법으로 불변성을 유지시켜주는 패키지이며 현재 상태를 가져와서 변경 사항을 기록하기 위해 draft를 만들고 작업이 끝나면 draft를 토대로 nextState를 만들어 반환한다.
여기서 produce 함수는 두개의 파라미터를 받는데 첫번째 파라미터는 수정하고 싶은 상태이며, 두번째 파라미터는 기존에 실행시킨 액션 객체가 된다. 액션을 통해 받아온 data를 파라미터로 넘기면서 draft 내부에서 값을 변경하게 되고 이 때, produce 함수가 불변성을 유지하면서 상태를 업데이트하게 해준다.
import { produce } from "immer";
export default handleActions(
{
[DELETE_AUDIOBOOK]: (state, action) =>
produce(state, (draft) => {
draft.detail_book.audio = draft.detail_book.audio.filter((p) => p.audioBookId !== action.payload.audioBookId);
}),
},
initialState
);
이렇게 불변성 유지를 위해 리덕스 내에서 immer를 사용했고 immer를 사용하지 않았을 때에 비해 코드가 훨씬 간결해져서 어렵지 않게 적용해볼 수 있었던 것 같다. 앞서 언급했던대로 immer는 편의를 위한 것이니 필수는 아니기 때문에 적절한 곳에서 잘 활용하면 보다 더 나은 코드를 짤 수 있을 것 같다는 생각이 들었다.
스스로 공부하면서 작성한 내용입니다. 혹시라도 잘못된 부분이 있다면 피드백 남겨주세요!!