What is Redux?
Redux는 Cross-Component State 또는 App-Wide State를 위한 state 관리 시스템이라고 한다. 즉, 애플리케이션 상호작용으로 변경된 정보를 화면에 표시하는 데이터(state)를 다수의 component나 더 나아가 App 전체에서 관리할 수 있도록 도와준다.
자세한 사항은 아래 링크를 참고할 수 있다.
Categories of State
이미 앞서 useState
나 useReducer
를 사용하여 그러한 데이터들을 관리했는데, 그 기능에 따라 아래 세가지로 구분지을 수 있다.
Local State
- 데이터가 변경되어서 하나의 component에 속하는 UI에 영향을 미치는 state
- 예를 들어, 사용자의 입력을 듣고 state 변수에 저장하는 것, 키고 끌 수 있는 토클 버튼, 버튼 클릭 시 세부정보가 표시되는 등...
- 보통
useState
를 통해 state를 관리하며, 약간 복잡하다면useReducer
를 사용하기도 한다.
Cross-Component State
- 하나의 component가 아니라 다수의 component에 영향을 미치는 state를 의미한다.
- 예를 들어, Modal overlay를 열거나 닫는 버튼이 있다면, Modal component는 다수의 component에 영향을 미칠 것이다.
- 또한 Modal을 여는 트리거는 modal의 밖에 위치하기 때문에, 다수의 component가 협력하여 표시와 숨김을 처리한다.
App-Wide State
- 다수의 component가 아니라 애플리케이션의 거의 모든 component에 영향을 미치는 state를 의미한다.
- 예를 들자면, 사용자 인증(user authentication)이 있을 것이다. 로그인의 state에 따라 새로운 옵션을 표시해주어야 한다.
Cross-Component State나 App-Wide State를 구현할 때에는 이제껏 배운 방식으로는 2가지가 있었다.
먼저, useState
나 useReducer
를 이용하여 구현할 수 있는데, Local State와 한 가지 다른 점이 있다면, prop가 있다는 것이다.
각 component에 parameter로 prop를 넣어 prop chains(prop drilling)을 구축해야 한다.
하지만 이 방법 또한, 매번 이렇게 props 함수 전체를 업데이트 하는 것이 번거로울 수 있다.
이럴 경우, useContext
를 활용하여 필요한 component에서만 호출하여 state를 단순화 할 수 있다.
Why need Redux?
그럼 여기서 생기는 궁금증이 있을 것이다.
이미 다수의 component에 영향을 미치는 state를 useContext
로 관리가 가능한데, redux는 왜 필요한가?
그 이유로, React Context에는 잠재적인 단점이 존재하기 때문이다.
React Context를 사용하면 설정과 state 관리가 복잡해질 수 있다.
그 복잡성은 구축되는 애플리케이션의 종류에 따라 다르겠지만, 소형이나 중형의 애플리케이션에서는 대부분 문제되지 않는다.
하지만, 많은 component와 context가 있는 대형 프로젝트의 경우,
당연하게도 아주 다양한 state의 관리가 필요하기 때문에, React
Context를 사용한다면 오른쪽 코드와 같은 구조를 띌 수 밖에 없다.
결론적으로, 오른쪽과 비슷한 구조로 ContextProvider가 아주 심하게 중접된 형태를 띌 것이다.
물론, ContextProvider를 꼭 위의 방식으로만 구축할 필요는 없다. 전체와 다양한 크고 작은 state를 관리하기 위해 하나의 큰 Context로 구현할 수도 있을 것이다.
하지만 이렇게 하면 ContextProvider component 하나가 굉장히 많은 것을 관리하기 때문에 캡슐화가 되지 않는다는 문제점이 있을 것이다.
(캡슐화에 대한 설명은 추후에 설명하겠다. 간단히 말해 component가 단순화되어야 다른 component에 영향을 끼치지 않고 관리가 가능한데, 왼쪽의 코드와 같은 방식으로 서로 얽히고 설켜있다면 유지보수 측면에서 좋지 못한 영향을 끼치게 된다.)
따라서, 다시 이전 구조로 돌아와서 해당 문제에 대해서 생각해볼 필요가 있다. 잠재적으로 발생할 수 있는 단점이라니..
여기에 덧붙여서 React 팀원이 올린 공식 언급에 따르면, Context를 사용할 때, 테마나 사용자 인증과 같은 저빈도 업데이트에는 아주 좋지만, 데이터가 자주 변경되는 경우 좋지 않다고 언급되어 있다. 다시 말해 빈번히 일어나는 고빈도 변경에는 적합하지 않다.
2018년 새롭게 도입된 React Context는 유동적인 state 확산을 대체할 수 없다고 언급한다.
Potential Disadvantages of React Context
정리하자면, React Context의 단점으로는 다음과 같다:)
- 복잡한 Setup과 관리
- 아주 심하게 중접된 JSX 코드와 다양하고 많은 ContextProvider, 또는 유지하기 어려운 거대한 하나의 ContextProvider
- 잠재적인 성능 이슈
- 고빈도 state 변경에서는 React Context를 사용해서는 안됨.
How does Redux Work?
Redux는 애플리케이션에 있는 하나의 중앙 데이터(state) 저장소로, 해당 저장소에서 전체 애플리케이션의 모든 state를 저장한다.
그래서 그 저장소에 사용자 인증, 테마, 입력 상태 등등.. 무엇이든 저장할 수 있다.
Cross-Component State든 App-Wide State든 모두 하나의 저장소에 저장된다.
그러면 관리가 React Context처럼 어려울 것 같다는 생각이 들지만, 다행히도 저장소 전체를 항상 직접 관리할 필요는 없다.
What is possible with the Central Data Store?
그렇다면, 중앙 데이터 저장소(Central Data Store)로 무엇을 할 수 있을까?
궁극적으로 그 저장소에 데이터를 저장해서 필요한 component 안에서 사용할 수 있다.
- 예를 들어, 사용자의 인증 상태가 변경되면 component가 인지하여 그에 맞춰 다른 UI로 대응하길 원한다고 하자.
- 이 component를 위해 중앙 데이터 저장소에 대한 구독(Subscription)을 설정한다.
- 이로써, 데이터가 변경될 때마다 저장소가 component에 알려주게 된다.
- 그러면 component는 변경되었다는 것을 확인하고 필요한 데이터를 습득하게 된다.
Reducer Function
그렇다면, 이 데이터를 변경하는 방법에 대해서 알아야 한다. 변경하는 것에 대한 아주 중요한 규칙이 하나 존재한다.
component는 절대로 저장된 데이터를 직접 조작하지 않고 데이터 또한 반대 방향으로 흐르지 않는다.
그 규칙을 해치지 않기 위해 데이터 변경을 담당하는 reducer 함수를 도입하게 된다.
reducer라는 용어가 useReducer
에서 봐왔던 것이지만, reducer 함수는 아주 일반적인 개념으로 사용된다.
이전 상태의 입력[state, action
]을 받아서 입력에 따른 새로운 state를 return을 해주는 함수를 일컫는다.
그렇다면 입력을 변환해서 새로운 출력, 결과물을 뱉어내어 그 과정들을 줄여준다.
이것이 reducer 함수의 일반적인 프로그래밍 개념이고, useReducer에도 이러한 개념으로 사용하는 것일 뿐이다.
How Component and Reducer Functions are Linked
그렇다면, component와 reducer 함수를 어떻게 연결할 수 있을까? 여기에는 트리거라는 개념이 필요하다.
component가 action을 발송할 때, component가 어떤 action(주로 Object 형태로 사용)을 트리거한다고 말할 수 있다.
이 action이 reducer 함수가 수행해야할 작업을 설명하게 된다.
// 아래는 useReducer에서 action을 dispatch하는 모습을 볼 수 있다.
// ex.. cart reducer in useReducer()
const cartReducer = (state, action) => {
if (action.type === "ADD_ITEM") {
...
}
}
...
// event handler in component
const addItemToCartHandler = (item) => {
dispatchCartAction({ type: "ADD_ITEM", item: item });
};
그래서 redux는 그 action을 reducer로 전달하고 원하는 작업에 대한 설명을 읽게 된다.
그 작업을 reducer에서 이어서 수행하며, 새로운 state 값을 뱉어내게 되는데 그 값이 중앙 데이터 저장소에 기존 값을 대처하게 된다.
중앙 데이터 저장소의 state가 업데이트되면 구독중인 component가 알림을 받고 데이터를 새롭게 요청해 UI를 업데이트하게 된다.
이것이 전반적인 redux의 구동 방식이다.
Practice
Initial Settings
조금 복잡해 보일 수도 있기에 연습을 통해 살펴보자. 우선 비어 있는 폴더와 그 안에 새로운 JavaScript 파일을 만들자.
물론 React 앱으로 추가할 수 있지만, 당장은 필요하지 않기에 빈 폴더만으로 세팅해보자.
이제 이 .js
파일을 nodejs 로 실행할 것이다. nodejs를 사용하면 브라우저 밖에서 JavaScript를 실행할 수 있게 된다.
이미 React 앱을 만들기 위해서, npm
명령어를 사용하기 위해서 nodejs가 설치되어 있을 것이다.
이제 아래의 명령어를 입력해서 초기 세팅을 해준다. npm init -y
는 모든 기본 질문에 yes로 답하게 된다.
npm init
npm init -y
그 결과 package.json
파일이 생성되는데, 이제 third party library를 설치할 수 있게 되었다.
이후 다음 명령어로 redux를 설치하자.
npm install redux
그러면, node_modules
디렉토리가 생성되며 그 안에 redux와 관련된 종속 파일들이 추가된다.
이제부터 redux를 사용할 준비는 완료되었고, .js
파일에서 사용가능하다.
Import Redux
그러기 위해서는 redux를 먼저 import
해야 사용할 수 있다.
여기서는 nodejs로 이 파일을 실행할 것이기 때문에 이전과는 약간 다른 방식으로 import
한다.
const redux = require('redux') ;
이제 몇 가지 작업들을 수행해야한다. 앞서서 다뤘던 내용들을 살펴보자면, 중앙 데이터 저장소를 만들어야 한다.
또한, 저장소를 변경하기 위해서 reducer 함수도 만들어야한다. 그리고 그 함수를 처리할 component와 action도 필요하다.
하지만, 아직 React 앱을 사용하지 않기 때문에 저장소를 구독하기 위한 설정 코드가 별도로 필요하다.
Create Store
저장소는 redux의 핵심개념이기 때문에 우선, 저장소(store)부터 만들어보자.
redux 객체에 createStore()로 호출할 수 있다. 이는 저장소를 생성한다.
const store = redux.createStore() ;
위의 선언방식으로 사용하면서 @deprecated 즉, 아직까지는 사용되는 기능이지만 새로운 기능이 나왔기 때문에 조만간 사라질 수 있는 상태를 표시하는 경고와 함께 경고 메세지가 출력되었다.
해석하자면, createStore()를 대체하는 @reduxjs/toolkit 패키지의 configureStore 메서드를 사용하는 것이 좋다. 이 방식은 저장소 설정, 축소, 데이터 가져오기 등을 포함하여 오늘날 Redux 로직을 작성하는 데 권장되는 접근 방식이다.
자세한 내용은 Redux docs 페이지를 참조바란다.
redux Toolkit에서 configureStore()는 설치를 단순화하고 일반적인 버그를 방지하는 createStore()의 개선된 버전으로, 학습 목적을 제외하고는 redux 코어 패키지를 단독으로 사용해서는 안된다. createStore() 메서드는 제거되지 않겠지만 모든 사용자가 모든 Redux 코드에 대해 Redux Toolkit을 사용하는 것을 권장한다. 이 시각적 사용 중지 경고 없이 createStore()를 사용하려면 대신 legacy_createStore() 가져오기를 사용하십시오:
const store = redux.legacy_createStore() ;
위의 경고대로 이런 방식으로 경고를 무시할 수 있다.
Reducer Function
이 다음에는 reducer 함수를 만들어야 한다. reducer 함수는 표준 JavaScript 함수이지만 redux 라이브러리에 의해 호출될 것이다.
때문에, 항상 2개의 입력(parameter)으로 기존의 state와 발송된 action을 갖게 되며, 항상 새로운 state를 반환한다.
- 여기서 말하는 state는 이론적으로 어떠한 값의 유형이 올 수 있지만 대부분의 경우, object를 return한다.
- 또한 object의 형태도 어떠한 구조든 될 수 있다. 그것은 전적으로 개발자에게 달려있기 때문이다.
그래서 reducer 함수는 동일한 입력을 넣으면 정확히 같은 출력이 산출되는 pure 함수가 되어야한다.
그리고 그 함수 안에는 어떠한 부수적인 효과가 없어야한다.
예를 들어, side effect와 같이 HTTP 요청을 전송하거나, loacl 저장소에 기록하거나 가져오는 과정을 수행해서는 안된다.
const counterReducer = (state = { counter: 0 }, action) => {
return { counter: state.counter + 1 };
};
const store = redux.legacy_createStore(counterReducer);
기본적으로 기존의 state.counter에 1씩 증가시키는 reducer 함수를 만들었다.
그리고 이 함수를 store 생성시 사용하는 createStore() 함수의 parameter로 넣어주면 store가 생성된다.
store와 작업하는 것은 reducer이기 때문에 store는 데이터를 조작하는 reducer 함수가 어떤 함수인지 알고 있어야 한다.
Subscribe
이제 store를 구독한 무언가가 필요하다. 그렇기에 먼저 구독(subscribe)를 살펴보자.
const counterSubscriber = () => {
const latestState = store.getState();
console.log(latestState);
};
앞서서 store가 변경되면 변경되었다는 것을 알려주기 위해서 subscribe 한다고 했었다.
따라서 위의 코드는 변경사항이 발생하면 트리거되는 함수라고 해석할 수 있다.
이 함수는 어떠한 parameter도 받지 않으며, store에 접근해서 getState()를 호출할 수도 있다.
이는 업데이트된 후의 최신 state의 snapshot을 제공할 것이다.
그리고 이 함수를 store.subscribe() 함수의 parameter로 넣어주면 redux는 데이터와 저장소가 변결될 때마다 실행해 줄 것이다.
store.subscribe(counterSubscriber);
중요한 것은 직접적으로 subscriber(counterSubscriber) 함수를 실행하지 않는다는 것이다. 단지 그것을 가르킬 뿐이다.
Dispatch Action
이제 남은 것은 action이다.
store object는 getState()와 subscribe() 외에도 dispatch()를 호출할 수 있다.
그리고 dispatch는 action을 발송(dispatch)하는 함수이며, action은 식별자 역할을 하는 type 속성을 가진 JavaScript object이다.
useReduce와 마찬가지로 Reducer 함수에서 action의 type 값을 보고 그에 맞는 처리를 할 것이다.
store.dispatch({ type: "increment" });
store.dispatch({ type: "decrement" });
Add Condition on Reducer Function
일반적으로 redux를 사용할 때, reducer 함수 내부에서 다른 action에 따른 다른 일을 처리하는 것이 목표이다.
따라서 reducer 함수에서도 두번째 parameter로 action을 받고 있는 것이다.
현재 state와 dispatch된 action을 받은 것을 바탕으로 reducer 함수가 가동될 것이다.
이제 if 문을 사용하여 다른 조건에 따라 다른 동작을 할 수 있게끔 만들어보자.
dispatch에서 action에 type을 다르게 지정해주었다. increment, decrement라는 두가지 type을 dispatch 해주었다.
const counterReducer = (state = { counter: 0 }, action) => {
if (action.type === "increment") {
return { counter: state.counter + 1 };
}
if (action.type === "decrement") {
return { counter: state.counter - 1 };
}
return { counter: state.counter };
};
때문에 위와 같이 업데이트해 줄 수 있을 것이다.
File Execution
이제 nodejs로 파일을 실행해보자. nodejs로 파일을 실행하는 방법은 아래와 같다.
node [filename].js
그 결과로 다음과 같은 output을 얻을 수 있다.
{ counter: 1 } // dispatch on type increment
{ counter: 0 } // dispatch on type decrement
dispatch되어 reducer 함수의 결과로 내부 저장소에 변화가 생겼다.
변화가 생김을 감지하면 구독(subscribe)한 counterSubscriber 함수의 console.log가 실행된다.
Conclusion
이번 시간에는 전반적인 redux의 작업을 살펴보았다.
물론, 더욱 복잡한 구조의 reducer 함수를 구상할 수 있다. 이것이 redux의 핵심이자 작동하는 방식이다.
이 과정을 살펴보면서 React 앱은 사용하지 않았지만 redux가 작동하는 데에는 문제가 없다.
redux library는 React에만 국한되지 않고 어떠한 JavaScript 프로젝트에서든 사용할 수 있다.
심지어는 다른 프로그래밍 언어에서도 실행할 수 있다.
최종 코드는 다음과 같다
const redux = require("redux");
const counterReducer = (state = { counter: 0 }, action) => {
if (action.type === "increment") {
return { counter: state.counter + 1 };
}
if (action.type === "decrement") {
return { counter: state.counter - 1 };
}
return { counter: state.counter };
};
const store = redux.legacy_createStore(counterReducer);
const counterSubscriber = () => {
const latestState = store.getState();
console.log(latestState);
};
store.subscribe(counterSubscriber);
store.dispatch({ type: "increment" });
store.dispatch({ type: "decrement" });