redux 는 간단한 로직에서 출발했다고 하는데...
리엑트 를 한번 간략하게 구현해보았으니, 이번에는 리덕스를 구현해보는 경험을 가져보고자 하였다. 구현이라고 해봤자 아주 간략하게 이러한 원리와 흐름으로 리덕스가 구현되었구나 정도를 파악하고자 하는것이고, 앞으로 리덕스를 사용함에 있어 리덕스의 flow 를 이해할 수 있지 않을까 하는 기대감이 있다.
실제로 리덕스는 npm trend 에서 알 수 있듯이 아직 많은 사람들이 사용하고있는 대표적인 상태관리 라이브러리이다. 이 외 recoil, mobx 등 많은 상태관리 라이브러리도 그 시작점은 리덕스라 해도 무관할 것 같다.
길어지는 코드가 싫지만 그래도 Redux를 사용했던 이유
처음 리덕스를 사용할 때, 외부에 store 를 생성하고, 거기에 또 dispatch 를 통해서만 action 을 전달해 state 를 변경할 수 있는 등.. 흐름 자체가 복잡하게 느껴져서 상태관리의 편리성을 느끼지 못하였지만, 사용이 익숙해질수록 외부에서 상태를 관리해준다는 점은 여러 컴포넌트를 구성해야하는 입장에서 상당히 편리하다를 넘어 필수적이라 생각이 들었다.
실제로 이러한 이유를 통해서 처음 리덕스의 방향이 잡힌것으로 알고 있다.
리엑트를 통해서 웹 페이지를 다수의 컴포넌트로 구성하기 쉬워졌고, 그 컴포넌트 중 상당부분을 재활용하여 관리하게 됨으로서 생산성 부분에서도 효율을 찾을 수 있었다. 거기에 컴포넌트 내부 상태들의 변화에 따른 view 의 자동적이며 자연스러운 변화로 인해 웹 상에서도 어플리케이션과 같은 자연스러운 변화를 줄 수 있었다.
하지만 애초에 SPA 개념이 활발해진 이유역시, 이미 웹상에서 다루고 있는 데이터의 양과, 다수의 이벤트에 따른 데이터 변화가 너무나도 많아졌기 때문이고, 이를 통해 컴포넌트간의 상태 연결은 더더욱 복잡해지기 시작한다. 기본적으로 리엑트는 부모에서 자식으로의 상태 단방향 흐름을 가지며(이 흐름이 직관적이다) 그렇기에 상태 연결을 하는 과정에서 필시 서로 종속적인 컴포넌트들이 구현될 수 밖에 없다.
누군가가 닉네임을 변경하였다면, 그 닉네임(상태)을 사용하는 모든 컴포넌트는 변경된 닉네임을 받은 뒤, view 에 적용시켜야 한다. 만일 어느 한 부분이라도 상태 업데이트가 제대로 되지 않는다면 한 계정에서 2가지의 닉네임이 보여지는 결과를 초래하게 될 것이다.
문제는 이러한 상태 변화는 하나의 컴포넌트에서 이루어 진다는 점이다. 하나의 상태를 5개의 컴포넌트가 같이 사용한다고 해도, 결국 이 상태를 업데이트하는 이벤트는 하나의 컴포넌트에서 발생하게 된다. 이벤트를 발생시킨 컴포넌트야 바로 상태를 업데이트 할 수 있다고 쳐도, 나머지 컴포넌트들은 어떻게 이 상태가 업데이트 됬는지 알 수 있을까? 이 방향에 대해 리엑트는 props 를 제시하고 있었지만 관계가 복잡해질 수록 이 역시 한계점이 명확해졌다.
거기에 만약 상태를 변경하는과정에서 오류가 발생했다면, 어디 컴포넌트에서 상태값에 오류를 발생시켰는지 파악하기가 쉽지 않다는 단점도 더해졌다.
결과적으로, 공용적으로 사용되는 상태들을 효과적으로 관리해야 하는 상황이 리덕스를 탄생시키게 되었다.
상태 변경을 오직 한군데에서만 진행한다면
앞서서 하나의 컴포넌트에서 상태 변경이 이루어졌을 때, 그러한 이벤트를 다른 컴포넌트가 알고 같이 업데이트를 하는 과정이 쉽지 않다고 설명하였다. 그렇다면 만약 상태를 어떠한 외부에서만 진행을 한다면 가능하지않을까?
여러 컴포넌트들은 이 외부 로직처리부분에 신호를 주고, 외부에서는 이 로직을 받아 상태를 변경시킨다. 그리고 상태를 변경시켜 이 상태를 사용하는 컴포넌트들에게 알려주는 것이다. 이렇게 되면 앞서서 제시한 문제들이 많이 해소될 수 있다. 적어도 상태 변경의 오류는 여기 외부 로직처리부분에서 일어났을 테니 말이다.
이러한 컨셉으로 리덕스는 시작을 하게 된다. 즉 전역에 상태 store 를 놓고 관리하는 부분이 시작점인것이다.
간략하게 리덕스의 핵심인 createStore를 살펴보자면
export function createStore(reducer) {
let state;
function dispatch(action) {
// 근데 여기서 state 는 undefined 이며 reducer 로 넘겨준다.
reducer(state, action);
}
// 외부에서 상태를 얻게 하기 위해서
function getState() {
return state;
}
return {
dispatch,
getState,
};
}
우선 외부 redux.js 에 createStore 라는 함수를 생성해보자.
이 함수는 말그대로 store 를 만드는 함수이다. 위 함수에서는 getState 를 통해서 store에 접근이 가능하다. 초기 상태는 맨 위 state 로 지정되어있다. (state=store 라고 생각하면 된다)
createStore 는 reducer 라는 함수를 인자로 받는다. 그럴수밖에 없는것이, createStore 는 store 를 생성하고 여러 컴포넌트에서 접근할 수 있도록 해주지만, 이 상태를 어떻게 변화시켜야 할지 까지 담당하지는 않는다. 상태를 바꾸는 수많은 로직과 상황이 존재할 것이다. 이를 모두 해결할 수 없는것은 당연한 것이며, 그 해결과정은 역시 개발자가 reducer 라는 함수를 통해 상태를 변경시켜야 한다. 단지 createStore 는 이러한 함수를 전달받아 어느 시점에 이 함수를 실행시킬지를 결정해줄 뿐이다.
중요한 부분은 언제 실행할지를 결정한다 부분이다. createStore 에서는 내부에서 관리하는 state 에 대해서 reducer 함수를 받아 언제든지 신호가 오면 이 state 를 변경할 수 있다. 근데, 그 시점을 createStore 가 알 수 있을까? 자체적으로 그게 가능할 리가 없다. 따라서 외부에서 이벤트가 발생하던지 어떠한 경우라도 createStore 에 알려주어야 한다. 이러한 역할을 할 수 있도록 내부 메서드인 dispatch 를 생성하여 return 한다.
dispatch 는 인자로 action 을 받는다. reducer 는 이 action 에 따라서 state 를 어떤식으로 변경할지에 대한 로직이 이미 짜여져있다. 결론적으로 dispatch 는 상태를 업데이트 해야하는 신호 역할을 하게 된다.
// index.js
// 생략
function reducer(state, action) {
state = action;
}
외부에서 정의한 reducer 를 간략하게 살펴보자.
이 너무나도 간단한 reducer 는 인자로 받은 action 을 그대로 state 로 대입한다. 어떤 이벤트 발생시 dispatch 를 통해서 action 이 전달된다면, reducer 는 실행이 될 것이며, 이 reducer 로 인해 state 가 변경이 되어야 한다.
하지만 실제 실행하면, state는 초기 let state 에서의 값과 같이 undefined 가 나타나게 된다.
이유는 undefined 는 원시값이기 때문이다. 원시값이기에 state = action 처리시 원본 state 를 참조하는 것이 아니라 복사본 state 에 action 객체가 참조되는 것이다. 쉽게 살펴보자면 원본은 state1 이고 reducer 함수 내부 state는 state2 라고 생각하자.
이러한 이유로 지금의 로직에서는 return state 를 생성하여 createStore 에 접근해주면 된다.
function reducer(state, action) {
state = action;
return state;
}
export function createStore(reducer) {
let state;
function dispatch(action) {
// state 에 대입해주자
state = reducer(state, action);
}
// 외부에서 상태를 얻게 하기 위해서
function getState() {
return state;
}
return {
dispatch,
getState,
};
}
근데, 우연하게도 위에서 구현한 reducer 의 경우 순수함수이다. 전달받은 state 가 원시값이며 전달받은 인자를 통해 스코프 내 값만 변화가 이루어진다. 즉 이 함수 외부 변화는 일으키지 않는다. (사이드이펙트가 없음)
그런데 실제로 초기 state 는 원시값이 아닌 객체이다. 원시값이 아니기에 객체는 참조가 이루어지게 되고, reducer 내 스코프의 작업이 외부 state 의 변경을 초래하기에 순수함수가 아니게 된다. 그전에 reducer 가 순수함수여야 하는 이유는 무엇일까
Reducer 가 순수함수여야 하는 이유
앞에서 우리는 reducer 를 통해서만 state 를 변경시키는 리덕스의 흐름을 학습하였다. 그런데 이 상태값을 변경하는 것이라면 원본 상태값을 그대로 가져와서, 그대로 변경시켜주면 되는 것 아닐까.
임시로 우리는 위에서 state 를 원시값인 undefined 로 설정하였기에, return state 를 통해서 외부 원본 state 에 변경된 값을 대입해주었다. 허나 원본 state 가 객체라면 참조일테니 굳이 이렇게 하지 않아도 된다. 허나 리덕스를 사용해봤으면 알겠지만 reducer는 공식문서에서도 순수함수여야 한다고 강조되고 있다. 이유없이 그럴린 없고 결국 효율적인 부분에서 그러한 이유가 있다.
우선 리덕스가 이전 상태값과 현재 상태값을 비교할 수 있어야 하는 이유에 대해서 말하자면, 만약 상태값이 변경되었다면 그 상태값을 사용하는 컴포넌트를 리랜더링 시켜야 하기 때문이다. 앞서서 상태관리를 외부에서 하면서 상태값의 변화를 reducer 에서 처리하겠다고 하였다. 이렇게 처리가 된 상태값에 대해서 컴포넌트들에게 알려주어야 하는데, 이러한 로직을 리덕스는 이전상태값에서 새로운 상태값으로 변화됨을 인지하게 되면서 알려주는게 가능해지게 된다.
만약 리덕스가 상태값의 변화를 인지하지 못한다면, 실제 원본 상태값이 변화되었더라도 다른 컴포넌트에 상태값이 변동되었으니 리랜더링 해라 라고 명령을 내릴 수가 없는것이다.
그렇다면 이전 상태와 현재 상태의 차이를 인지한다는 것은 비교를 해야한다는 것인데, 상태값이 객체라면 결국 두 가지의 객체를 비교하는것과 같다고 생각할 수 있겠다. 여기서 2가지 비교 방식이 있는데, 객체의 주소값을 비교하는것과 객체의 속성값을 비교하는 방식이 그러하다.
두 객체 a,b 의 모든속성을 다 비교하는 방식은 일일히 반복문을 사용하고, 내부 속성의 값이 또 객체라면 재귀함수로서 다시 내부 객체의 속성들을 반복하여 비교하여야 한다. 이전에 리엑트를 구현할 때 가상 돔을 실제 돔으로 요소를 생성하는 과정에서 가상 돔 객체 내부 객체까지 타고타고 재귀적으로 내려가서 차례대로 생성했던 것을 기억할 것이다. 마찬가지로 객체 내부 속성값을 전체 비교하기 위해서 모든속성을 타고 내려가 비교해야 하기에, 구조가 deep 할 수록 효율은 급격하게 떨어지게 된다.
여기서 효율이 떨어진다는 것은 바로 두 객체의 주소값을 비교하는것에 비해서 떨어진다는 의미이다. (실제 효율이 떨어짐을 간략하게 비교해주신 개발자분이 계시기에 링크를 따라 확인해보면 된다.)https://boxfoxs.tistory.com/406
순수함수, Redux Reducer는 왜 순수함수여야 하는가?
Redux를 다룰때 주의해야 할 점은 Reducer는 반드시 순수함수여야 한다는 점 입니다. 하지만 순수함수가 정확히 무엇이고 왜 순수함수여야만 하는지를 잘 모르는 경우가 많습니다. 이번 포스팅에서
boxfoxs.tistory.com
리덕스는 효율성을 증대시키기 위해 이전 상태객체와 현재 상태객체의 차이점을(차이가 발생하게된 시점) 두 상태 객체의 주소값의 변동으로 파악하도록 설계하고자 하였다. 원본 상태와 다른 주소값을 가지게 하는것은 간단하다. 객체를 얕은 복사를 하면 되는것이다. Object.assign을 통해서든 스프레드 연산자를 활용하던간 말이다.
이제 이 부분이 순수함수와 연결이 되는데, reducer 는 원본 상태를 전달받아, 이 상태를 그대로 사용하는것이 아니라 복사하여서 이 복사된 객체에 변경사항(action)을 적용한 뒤, 변경된 객체를 return 한다.
function reducer(state, action) {
if (action.type === "ADD_COUNTER") {
// 필요한 state 를 복사한다.
return {
...state,
count: action.payload.count,
};
}
// 변화가 없으면 기존 상태를 return 한다.
return state;
}
action 의 조건에 따라 기존 state 를 복사한 뒤, 변경부분을 적용시킨 뒤 return 한다. createStore 에서는 이를 받아서 원본 상태값에 대입시켜준다. 이 과정에서 이전상태와 현재상태의 차이점을 인지하게 된다.
변경된 상태를 컴포넌트마다 알려주자
상태의 주소값 비교를 통해서 상태가 변경되었음을 redux 는 알게 된다. 이제 변경된 상태를 각 컴포넌트마다 알려주어야 된다. 이것 역시 신호라 생각하면 된다.
각 컴포넌트 마다 listener 함수를 가지고 있다고 가정하자. 이 listener 함수가 실행이 되면, store 의 getState 를 통해 상태값을 가져올수 있다고 가정해보겠다. 즉, 이 함수가 실행되어야지만 리랜더 한다고 생각해보자
// 변경된 상태값을 그냥 가져와 콘솔에 뛰운다고 가정해보자
// 이 함수는 상태가 변경된 다음 호출되어야 한다.
function listener() {
console.log(store.getState());
}
간단하게 listener 수도함수를 만들어봤고, 실제 이 함수는 컴포넌트마다 차이를 보일 수 있다. 처리하는 작업이 다르다면 말이다. 특정 상태값이 변하게 되면, listener 함수는 그것에 반응하여 실행이 되면 된다. (지금은 그것이 가능하다 가정하겠다)
const store = createStore(reducer);
store.subscribe(listener);
이 함수를 store에 등록시켜주자. subscribe 는 createStore 의 메서드이다.
export function createStore(reducer) {
let state;
const handler = [];
function dispatch(action) {
state = reducer(state, action);
// listener 에 해당하는 함수를 상태를 변경하고 그 다음 호출한다.
handler.forEach((listener) => {
listener();
});
}
// 생략
// listener 함수를 등록해야 한다. 생각해보자
// 만약 상태값이 변화하였고, 이 상태값을 여러 컴포넌트에 사용중이라면, 업데이트 시켜줘야 한다.
// 즉 상태가 변환 뒤 상태를 가져오는 listener 함수가 호출되어야 한다.
function subscribe(listener) {
handler.push(listener);
}
return {
dispatch,
getState,
subscribe,
};
}
컴포넌트마다 listener 함수가 있고, 이를 store 에 등록시켜준다. 이 함수들은 앞에서 설명하였듯이 일정 상태의 변경시 실행되도록 되어있다.
subscribe 에 의해서 내부 listener 함수들이 들어갈 자료구조(배열)에 push 한다. 이후 reducer 에 의해 상태값이 변경된다고 가정해보자
store.dispatch({ type: "ADD_COUNTER", payload: { count: 2 } });
초기 상태값 중 count 값을 2로 변경시킨다고 가정할 때, reducer 는 dispatch 에 의해 action 이 반영되어 count 상태를 변경하게 된다. 동시에 이전상태와 현재상태의 차이점을 인지하여, 각 컴포넌트 중 count 를 사용하는 컴포넌트에게 리랜더링을 명령하여야 한다. 이때 count 가 변경될 시 발동되어야 할 listener 함수들을 반복을 돌면서 실행시킨다. 그게 이 부분이다.
export function createStore(reducer) {
let state;
const handler = [];
function dispatch(action) {
state = reducer(state, action);
// listener 에 해당하는 함수를 상태를 변경하고 그 다음 호출한다.
handler.forEach((listener) => {
listener();
});
}
}
지금까지의 흐름이 가장 초기의 리덕스의 흐름
정리를 해보자면 결국 가장 핵심적인 createStore 를 통해 모든 로직이 시작된다고 할 수 있다.
- createStore 를 생성한다. 이는 외부에서 state 의 변경을 다루기 위해서이다.
- 외부는 바로 reducer. reducer를 createStore 에 등록시키고, 어떤 신호가 오게 되면 reducer 를 통해서 상태를 변경시켜주자
- 중요한 점은 state 는 반드시 reducer 로만 변경할 수 있다는 점이다. 애초에 그러기 위해서 redux 를 사용하는 것이다
- 이 reducer 에게 각 컴포넌트에서 신호를 보내는 역할을 dispatch 가 하게 된다.
- dispatch는 action 에 요구사항을 넣어서 reducer를 호출한다.
- reducer는 이 요구사항을 받아 상태를 변경하게 되는데, 변경된 상태값을 다른 컴포넌트에게 알리기 위해선 상태가 변경되었다는 사실을 인지하여야 한다.
- 그렇기 위해선 이전 상태와 현재 상태의 차이를 알아야 하고 이 부분이 reducer 가 순수함수여야 하는점과 일맥상통한다
- reducer 는 상태를 변경시키고, 상태가 변경됨을 인지하고, 각 컴포넌트에게 받은 listener 함수를 순차적으로 실행시켜 각 컴포넌트가 리랜더링 하도록 명령한다.
이러한 전체 흐름과 이에 대한 위 설명을 참고하면 리덕스가 강조하는 3가지 규칙에 대한 이유를 자연스럽게 이해할 수 있다.
하나의 어플리케이션에 하나의 스토어라는 점은 당연하게도 모든 상태를 한곳에서 조작해야 이전 어느 컴포넌트에서 상태가 업데이트 되거나 에러가 발생했는지 파악하기 힘든 문제를 해결할 수 있기 때문이고, 상태는 읽기 전용이며 dispatch 를 통해서만 상태가 변경되어야 한다는 점도 dispatch 를 통한 신호를 보내서 reducer 가 발동되어야 이전 상태와 현재 상태의 차이점을 인지하고 각 상태를 참조하는 컴포넌트들을 리랜더링 시켜줄 수 있기 때문이다. 마지막 reducer 가 순수함수여야 하는 점은 위에서 설명했으니 패스하겠다.
어찌보면 단순할 수 있는 아이디어 이지만, 실제 리덕스를 사용해본 입장에서 익숙해지니 정말 편하게 상태관리를 할 수 있게 되어서 참 대단하다고 느끼고 있다.. 이런 생각을 어떻게 하는건지...
우선 기본 흐름에 대해 간략하게나마 구현과정을 포스팅해보았다.
사실 이번 포스팅에서 다루지 않은 부분이 있는데, 바로 리덕스의 비동기 처리 과정이다. 위 로직으로는 비동기적인 처리를 할 수 없고, 실제 리덕스 라이브러리 역시 리덕스 혼자만으로는 비동기 처리를 할 수 없다.
이에 우리는 미들웨어를 사용하여 action -> store 에서 상태변경이 이뤄지는 그 중간과정에서 비동기적 요청을 리덕스의 동기적인 흐름으로 변경하여 이전 리덕스의 컨셉과 흐름을 유지하게 된다.
다음 포스팅에서는 간략하게 미들웨어의 작동 원리와 구현을 다뤄보도록 하겠다. (아직 좀 이해가 안가는 부분이있어서 추가학습이 필요..)
'Programing > React' 카테고리의 다른 글
Redux의 비동기 처리 (feat. middleware chaining) (1) | 2023.02.25 |
---|---|
[번역] useState 에 await 을 적용시킨 커스텀훅을 구현하기 (0) | 2023.02.07 |
Hook을 올바르게 사용하기 (0) | 2023.01.26 |
React는 어떻게 작동하는지 간단하게 구현해보자 (0) | 2023.01.26 |
useCallback & useMemo (0) | 2022.12.01 |