벨로퍼트와 함께하는 모던 리액트 & React로 NodeBird SNS 만들기 강의를 바탕으로 정리한 내용입니다.
리덕스는 애플리케이션의 상태를 관리하기 위한 오픈소스 자바스크립트 라이브러리이다. 대표적으로 리액트에서 많이 사용되지만 리액트에 종속되는 라이브러리는 아니며 다른 환경에서도 사용 가능하다. 리액트에서 사용하는 상태관리 라이브러리는 여러 개가 있지만 중 가장 많이 사용되는 것이 리덕스다.
React는 React 컴포넌트 자신이 개별적으로 상태관리를 한다.
React+Redux는 상태관리를 하는 전용 장소(store)에서 상태를 관리하고, React 컴포넌트는 그걸 보여주기만 하는 용도로 쓰인다.
우리가 만드는 컴포넌트에는 공통적으로 사용되는 데이터가 존재한다. 하지만 컴포넌트가 분리되어 있기 때문에 이러한 공통 데이터들도 흩어져 있게 되고, 흩어지지 않게 하려면 부모 컴포넌트를 따로 두어 그곳에서 데이터를 받아 자식 컴포넌트로 보내주어야 한다. 이런 과정을 효율적으로 만들기 위해 중앙에서 하나로 관리하여 각각의 컴포넌트로 뿌려주는 중앙 데이터 저장소 역할을 하는 것이 redux다.
규모가 어느 정도 있는 서비스면 데이터 관리 측면에서 중앙 저장소는 필수이다.(Redux, Mobx, Context API, Apollo) react의 context API도 데이터를 중앙저장소에 모아두고 컴포넌트가 필요로 할 때 전체 혹은 일부를 가져가서 쓸 수 있도록 할 수 있다.
redux는 action이 하나하나 기록되어 추적이 가능하기 때문에 에러가 나도 해결하기 쉽다. 단점은 코드가 많아진다.
mobx는 코드량은 줄지만, 실수가 있을 때 트래킹이 어렵다.
context api와 redux의 선택 여부는 비동기 지원의 난이도 측면에서 나뉠 수 있다. 중앙 저장소는 서버에서 데이터를 주로 받아온다. 서버에서 데이터를 받아오는 것은 항상 비동기인데, 비동기를 다룰 때는 항상 실패에 대비해야 한다.
비동기는 보통 데이터 요청, 성공, 실패 3단계로 나뉘는데 context api에서 구현하려면 이 3단계를 직접 구현해야 한다. 따라서 비동기 요청이 많을 경우, redux나 Mobx를 사용하는 것이 좋다.
Store 및 Store에 존재하는 State는 아주 신성한 것이라고 할 수 있기 때문에, React 컴포넌트같은 하등한 것이 직접 접근하려고 하면 안된다. 직접 접근하기 위해서는 Action이라는 의식을 거쳐야 하는데, 이벤트 드리븐과 같은 개념이다.
Action은 상태를 어떻게 업데이트 할 지에 대한 정보를 가지고 있다. 상태에 어떠한 변화가 필요하게 될 때, 액션을 발생시킨다. 액션 객체는 type 필드를 필수적으로 가지고 있어야 하고, 그 외의 값들은 개발자 마음대로 넣어줄 수 있다.
기본적으로 아래와 같은 포맷을 갖고 있는 오브젝트가 된다.
{
type: "액션의 종류를 한번에 식별할 수 있는 문자열 혹은 심볼",
payload: "액션의 실행에 필요한 임의의 데이터",
}
{
type: "@@myapp/ADD_VALUE",
payload: 2,
}
카운터의 값을 2배 늘리는 경우, 아래와 같은 오브젝트가 되고, 머릿부분에 @@myapp/이라고 Prefix을 붙인건 다른 사람이 쓴 코드와의 충돌을 피하기 위함이다.
액션 객체를 만들어주는 함수로, 단순히 파라미터를 받아와서 액션 객체 형태로 만들어준다.
{
type: "CHANGE_INPUT",
text: "안녕하세요"
}
// 액션 생성 함수
export const changeInput = text => ({
type: "CHANGE_INPUT",
text
});
/*
export function changeInput(text) {
return {
type: "CHANGE_INPUT",
text
};
}
*/
그런데 하나하나 액션 객체를 수작업으로 만드는 것과 "@@myapp/ADD_VALUE"같이 매번 Action명을 문자열로 쓰는 것은 괴로운 일이다. 그래서, 이걸 조금 편하게 하기 위해 상수와 함수를 준비하는 게 일반적이다. 외부 파일이 참고할수도 있으니 제대로 export 해놓는 것이 좋다.
export const ADD_VALUE = '@@myapp/ADD_VALUE';
export const addValue = amount => ({type: ADD_VALUE, payload: amount});
export const CHANGE_INPUT = "CHANGE_INPUT";
export const changeInpnut = text => ({type: "CHANGE_INPUT", text})
상태의 변화를 일으키는 함수, state와 action을 파라미터로 가져온다. 가져온 기존 상태와, 전달 받은 액션을 참고하여 새로운 상태를 만들어서 반환한다.
function reducer(state, action) {
// 상태 업데이트 로직
return alteredState;
}
앞에 ‘Store의 문지기’라는 표현을 사용했는데, 그 개념과 비슷한 역할을 하는 것이 Reducer이다. 함수형 프로그래밍에서 Reducer라는 용어는 합성곱을 의미하지만, Redux에 한해서는 아래와 같이 기존 상태와 Action을 합쳐, 새로운 상태를 만드는 조작을 말한다.
import { ADD_VALUE } from './actions';
export default (state = {value: 0}, action) => {
switch (action.type) {
case ADD_VALUE:
return { ...state, value: state.value + action.payload };
default:
return state;
}
}
리덕스를 사용 할 때에는 여러개의 리듀서를 만들고 이를 합쳐서 루트 리듀서 (Root Reducer)를 만들 수 있다. (루트 리듀서 안의 작은 리듀서들은 서브 리듀서라고 부른다.)
import { combineReducers } from 'redux';
const sessionReducer = (state = {loggedIn: false, user: null}, payload) => {
/* 생략 */
};
const timelineReducer = (state = {type: "home", statuses: []}, payload) => {
/* 생략 */
};
const notificationReducer = (state = [], payload) => {
/* 생략 */
};
export default rootReducer = combineReducers({
session: sessionReducer,
timeline: timelineReducer,
notification: notificationReducer,
})
이렇게 하면, Reducer분할에 쓰인 Key가 그대로 State분할에도 쓰인다. 또한 실제로 각각의 서브 리듀서를 다른 파일로 나누는 것이 일반적이다.
리덕스에서는 한 애플리케이션당 하나의 스토어를 만들게 된다. 스토어 안에는 현재의 앱 상태와, 리듀서가 들어가 있고, 추가적으로 몇가지 내장 함수들이 있다.
상태는 기본적으로 전부 여기서 집중관리 된다. 커다란 JSON의 결정체 같은 이미지다. 규모가 클 경우에는 상태를 카테고리별로 분류하는 경우가 일반적이다.
디스패치는 스토어의 내장함수 중 하나이다. 디스패치는 액션을 발생 시키는 것 이라고 이해한다. dispatch
라는 함수에는 dispatch(action) 과 같이 액션을 파라미터로 전달한다.
그렇게 호출을 하면, 스토어는 리듀서 함수를 실행시켜서 해당 액션을 처리하는 로직이 있다면 액션을 참고하여 새로운 상태를 만들어준다.
구독 또한 스토어의 내장함수 중 하나이다. subscribe 함수는, 함수 형태의 값을 파라미터로 받아온다. subscribe 함수에 특정 함수를 전달해주면, 액션이 디스패치 되었을 때 마다 전달해준 함수가 호출된다.
리액트에서 리덕스를 사용하게 될 때 보통 이 함수를 직접 사용하는 일은 별로 없다. 그 대신에 react-redux 라는 라이브러리에서 제공하는 connect 함수 또는 useSelector Hook 을 사용하여 리덕스 스토어의 상태에 구독한다.
여러개의 스토어를 사용하는것은 사실 가능하기는 하나, 권장되지 않는다. 특정 업데이트가 너무 빈번하게 일어나거나, 애플리케이션의 특정 부분을 완전히 분리시키게 될 때 여러개의 스토어를 만들 수도 있지만 그렇게 하면, 개발 도구를 활용하지 못하게 된다.
리액트에서 state 를 업데이트 해야 할 때, setState 를 사용하고, 배열을 업데이트 해야 할 때는 배열 자체에 push 를 직접 하지 않고, concat 같은 함수를 사용하여 기존의 배열은 수정하지 않고 새로운 배열을 만들어서 교체하는 방식으로 업데이트를 한다. 엄청 깊은 구조로 되어있는 객체를 업데이트를 할 때도 마찬가지로, 기존의 객체는 건드리지 않고 Object.assign 을 사용하거나 spread 연산자 (...) 를 사용하여 업데이트 한다.
리덕스에서도 마찬가지로, 기존의 상태는 건들이지 않고 새로운 상태를 생성하여 업데이트 해주는 방식을 사용한다.
const prev = { name: 'zerocho'}
const next = { name: 'boogicho'}
=> 이전 기록과 다음 기록이 둘다 남아 있다.
const next = prev;
next.name = 'boogicho';
=> 리덕스를 쓰는 주요 목적 중 하나가 history를 관리하는 것인데 참조 관계로 해버리면 기록이 사라지게 된다.
그래서 항상 새로운 객체를 만들어 기록이 남도록 만든다.
내부적으로 데이터가 변경 되는 것을 감지하기 위하여 얕은 비교 검사를 하기 때문이다. 이를 통하여 객체의 변화를 감지 할 때 객체의 깊숙한 안쪽까지 비교를 하는 것이 아니라 겉핥기 식으로 비교를 하여 좋은 성능을 유지할 수 있는 것이다.(바뀌지 않는 데이터는 참조 관계를 유지하여 메모리를 아낄 수 있음)
동일한 인풋이라면 언제나 동일한 아웃풋이 있어야 한다. 그런데 new Date(), 랜덤 숫자 생성, 네트워크에 요청 등과 같이 일부 로직들은 실행 할 때마다 다른 결과값이 나타날 수도 있다. 그러한 작업은 결코 순수하지 않은 작업이므로, 리듀서 함수의 바깥에서 처리해줘야 한다. 그런 것을 하기 위해 리덕스 미들웨어를 사용하곤 한다.