어제 오늘 Redux에 대해 학습, 실습하며 정리한 내용.
React 앱을 만들 때, 여러 컴포넌트에서 공유되는 상태는 그들의 공통된 상위 컴포넌트에서 관리하며 뿌려주게 되는데, 만약 이 상위 컴포넌트가 App 컴포넌트처럼 아주 꼭대기에 있는 컴포넌트라면, 위에서부터 props를 계속해서 전달해주며 쭈욱쭈욱 내려가게 될 것이다. 중간중간, 사이사이에 껴있는, 해당 상태가 필요하지 않은 컴포넌트도 말단에 있는 컴포넌트에게 상태를 전달해주기 위해 계속해서 넘겨주고 넘겨받는 Props drilling(프로퍼티 내려꽂기) 이슈가 발생하는 것이다...
음? 그럼 말단 컴포넌트에서 각자 불러와서 동기화만 잘 시켜주면 되지 않나?
안될건 없겠지만 동기화가 항상 완벽하게 되리란 보장도 없고 굉장히 복잡해질 것이다.
데이터의 정확성을 보장하기 위해 데이터의 변경이나 수정 시 제한을 두어 안정성을 저해하는 요소를 막고 데이터 상태들을 항상 옳게 유지하는 데이터 무결성을 위한 방법론인 Single source of truth(신뢰할 수 있는 단일 출처)원칙을 위한 것이기도 한 것이다.
그래서 이런 상황(여러 컴포넌트에서 하나의 상태를 공유한다거나)을 상태를 한 곳에 저장하고 접근하는 방식으로 해결하기 위해 상태 관리 라이브러리를 사용하는 것이다.
전역 변수를 선언하고 여러 함수에서 가져다 쓰는 것처럼 전역 상태를 두고 사용한다.
React Context, Redux, MobX 같은 상태 관리 툴이 있는데 그 중에서 Redux를 다루게 되었다.
어찌되었든 상태 관리 라이브러리들은 공통적으로 전역 상태 저장소를 제공하며 Props drilling 이슈를 해결해준다. 하지만 위에서도 말했던 것처럼 상태 관리 툴이 반드시 필요한 것은 아니고, 없어도 충분히 문제를 해결할 수 있기 때문에 장단점을 인자하고 적절히 사용하는 것이 중요하다.
어떤 액션을 취할 것인지 정의해 놓은 객체
{type: "ACTION_NAME", payload: request}
보통 위 형태로 쓰이고 type은 필수로 지정해줘야 한다. 취할 행동의 이름이라 생각하면 된다. 앱에서 일어나는 일을 직관적으로 알기 쉽게 하는 역할.
Action을 전달하는 메소드. dispatch의 전달인자로 Action 객체가 전달됨. 그리고 Reducer를 호출해 state의 값을 바꾸는 역할을 함.
state가 관리되는 저장소. Redux 앱의 state가 저장되어 있는 공간. createStore를 사용해 생성하며 리듀서의 조합을 인자로 넣을 수 있음. 미들웨어와 Redux devtools 지원을 위해 두번째 인자에 추가적인 내용을 넣을 수 있음.
현재의 state와 Action을 이용해서 새로운 state를 만들어 내는 pure function.
const reducer = (state = initialState, action) => {
switch (action.type) {
case ACTION_NAME:
// 상태 변경
return newState;
...
default:
return state;
}
}
보통 위와 같은 모양으로 구성. switch문 또는 if문으로 작성.
Reducer 함수를 작성할 때 주의할 점. Reducer의 Immutability(불변성)
Redux의 state 업데이트는 immutable한 방식으로 변경해야 한다. Redux의 장점 중 하나인 변경된 state를 로그로 남기기 위해서 꼭 필요한 작업이기도 하다.
즉, 기존에 React에서 state를 직접 변경하지 않고 setState를 통해 변경했던 것처럼 state를 직접 변경하는 것이 아닌 새로운 값을 만들어서 리턴을 한다.
이제 위 4가지 개념을 연결시켜줘야한다. connect parameter를 통해 mapStateToProps, mapDispatchProps 등의 메소드를 이용하는 방법과 Redux hooks를 이용하는 방법이 있다. 이 중에서 Redux hooks가 보다 최근에 나온 방법이고 비교적 사용하기 쉽다.
컴포넌트와 state를 연결하는 역할. 컴포넌트에서 useSelector 메소드를 통해 store의 state에 접근할 수 있음. 전달인자로 콜백 함수를 받으며 콜백 함수의 전달인자로 state 값이 들어감.
Action 객체를 Reducer로 전달해주는 메소드.
위 내용을 바탕으로 간단한 예시와 함께 정리해보았다.
먼저 action들이 있다. 컴포넌트에서 전역 상태를 변경하거나 하는 어떤 작업을 하게 될텐데, 그런 행위들을 정의해놓은 것이다. 다만, action은 위에 적은대로 실제로 데이터를 가공하거나 하지는 않는다. 예를들어 온라인 쇼핑을 하며 어떤 상품을 장바구니에 담는다고 할 때, 장바구니라는 상태에 새 상품이 추가가 될 것이다. 이때 장바구니에 상품을 담는 행위 그 자체를 기술해 놓는 것이라 보면 된다.
const actionName1 = (data) => {
return {
type: ACTION1,
payload: {
data
}
}
};
위와 같은 형태로 구성이 되며 객체를 반환한다. 'ACTION1'이라는 행위를 할 것이며 이때 'data'라는 값을 넘겨줄 것이다. 그럼 이 객체를 dispatch를 통해 넘겨줄 것이고 이 객체를 받은 reducer는 type에 맞는 행위를 실제로 수행하는 것이다.
const dispatch = useDispatch();
dispatch(actionName1(data));
말했던대로, dispatch는 reducer에게 actionName1이 반환해준 객체를 넘길 것이다.
const reducer1 = (state = globalState.something, action) => {
switch (action.type) {
case ACTION1:
// state와 action.payload.data를 가지고
// 무언가 처리를 할 것이다.
// 그리고 새롭게 생성된 newState를 반환한다.
// 그렇게 되면 globalState.something 에는 원래 state에 해당하는 값이 있었겠지만
// return 이후에는 newState에 해당하는 값이 있게 될 것이다.
// globalState.something은 임의로 아무렇게나 지은 이름이다.
// 전역 상태에서 어떤 상태를 가져온다는 것을 표현하고 싶었다.
return newState;
case ACTION2:
...
default:
return state;
// default는 필수는 아니다.
reducer가 전역 상태에 영향을 준다는 것까지는 알았다. 그렇다면 dispatch는 reducer가 뭐가 있는지 알고 전달해줬을까.
const rootReducer = combineReducers({reducer1, reducer2, ...});
const store = createStore(rootReducer);
위 내용처럼 저장소를 생성하면서 어떤 reducer를 사용할 것인지 지정을 해준다.
저장소를 생성한 다음에는 기존에 React 앱을 만들었을 때
<App />
위와 같이 index.js 에 만들어져 있었다면
<Provider store={store}>
<App />
</Provider>
이렇게 App을 redux에 내장되어있는 Provider를 사용해 감싸준다.
이렇게 해주면, 이제는 어떤 컴포넌트에서든 useSelector를 통해 전역 상태에 접근해 필요한 상태를 가져올 수 있게 된다.
const state = useSelector((state)=>state.something);
다시 한번 정리해보면,
1. 이렇게 가져온 상태를 어떻게 변경할 것인지
2. 그에 맞는 action을 골라
3. dispatch를 통해 reducer에게 넘겨주면
4. reducer가 이를 변경해 다시 렌더링하게 되는 것이다.
import { compose, createStore, applyMiddleware } from "redux";
import rootReducer from '../reducers/index';
import thunk from "redux-thunk";
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));
export default store;
우선 redux 디버깅용 툴과 thunk를 사용하는 코드를 한꺼번에 적었다.
제목대로 thunk는 비동기처리를 위한 미들웨어이다. 다른건 다 똑같지만 컴포넌트에 있는 dispatch를 통해 전달되는 action에는 return문이 아닌 또 하나의 dispatch가 있다. 예시를 통해 보면 간단하다.
dispatch(action(x));
const action = (x) => (dispatch) => {
// fetch 등 무언가 비동기 처리를 위한 코드가 있을 것이다.
// 이를 통해 만들어진 y라는 데이터를 또다른 action에 전달한다.
// anotherAction은 y를 받아 reducer에 전달해줄 객체를 반환할 것이다.
// 보통은 async / await 을 사용해 비동기처리를 한 뒤에 다음 dispatch를 호출하게 될 것이다.
dispatch(anotherAction(y));
}
const anotherAction = (y) => {
return {
type: AWESOMEACTION,
payload: {
y,
}
}
}
전체적인 흐름이나 사용방법 등은 이해한대로 최대한 정리하였다. 실제로 사용해보면서 연습해보면 정말 좋을 것 같다.