내가 지금으로부터 약 2년 전, 그러니까 제로초님의 NodeBird 강의를 들을 때 부터 리덕스는 높은 벽과 같은 느낌이었다. 왜 쓰는지도 알고, 어떤 형태인지도 알았지만 실제로 사용할때마다 뇌정지가 씨게 와버렸고, 결국 'Redux를 사용할 줄 안다'라고 말할 수는 없는 상태가 되어버렸다. 이번에는 기필코..! 리덕스를 정복해보자는 취지 아래 내 좌우명인 '기록하는 지식만이 죽은 지식이 되지 않는다'를 토대로 리덕스를 포스팅해서 정리해보고 내 머릿속에 넣도록 하자.
공식문서에서 리덕스는 자바스크립트 앱을 위한 예측 가능한 상태 컨테이너
라고 명시해두고 있다. 난 여기서부터 벌써 이해가 되지 않았다. 상태 컨테이너는 뭐 상태 관리를 위한 라이브러리니까 끄덕끄덕하고 넘어갔지만, '예측 가능한'은 대체 어떤 의미일까? 구글링을 통해 알아보던 중, Stack Overflow에 나랑 같은 궁금증을 가진 사람을 발견했다.
리덕스 공식문서에서 리덕스는 '예측 가능한' 상태 컨테이너라고 하는데 '예측 가능한'이 먼지 잘 몰?루겠어용
리덕스가 어떻게 동작되는지 먼저 알아야 합니다
1. state는 불변의 객체이다.
2. state는 직접 변경되지 않고 새로운, 수정된 state를 반환한다.
3. 모든 state 변경은 action을 통해 일어난다.
4. 리듀서는 현재 state와 action을 통해 새 state를 반환한다.
이 모든 것들은 state -> action -> reducer -> state -> action -> reducer -> state... 와 같은 단방향으로 이루어지고, 순수 함수(side effect가 없고 같은 input에서 나온 output이 항상 같음)를 장려하며 개발자의 명시 아래에서 state 변경이 이루어지기 때문에 '예측이 가능'해 집니다.
고로, 리덕스를 사용하면 애플리케이션의 모든 작업이 어떻게 수행되고 상태가 어떻게 변경되는지 알 수 있기 때문에 예측 가능한 상태 컨테이너라고 명시해 둔 겁니다!
아하! 리덕스에서의 상태 변경은 단방향으로 이루어지고, 개발자의 명시 아래에서만 이루어지기 때문에 상태 변경이 예측 가능한 것이구나! 고마워요 외쿡 개발자맨!
추가적으로 Flux와 MVC 아키텍쳐의 차이에 대해 알아두면 위 질문의 추가적인 해답이 된다.
Flux가 적용되기 이전에는, 대부분의 애플리케이션은 MVC 아키텍쳐를 채택하였었다. MVC 아키텍쳐에서 Controller
는 Model
에 정의된 데이터를 조회하거나 업데이트 하는 역할을 하며, 변경된 모델을 View
에 반영해주었다. 또한 사용자는 View
를 통해 데이터를 입력하고 Model
에 반영되며, View
와 Model
은 데이터를 양방향으로 주고받는 형태의 아키텍쳐였다. 위와 같이 심플한 구조일 경우에는 아주 알잘딱하고 이쁜 아키텍쳐이다.
킹치만 이렇게 프로젝트 구조가 커지게 되면? 으악! 거의 스파이더맨 웹스윙이 가능할 것 같은 복잡한 거미줄 구조가 되어버린다. 이러면 데이터의 흐름을 파악하기가 어려워 질 뿐더러, 새 기능을 추가할 때마다 크고 작은 문제가 생기고 사이드 이펙트가 생기게 된다.
우리의 킹-갓 페이스북 개발자들은 위와 같은 문제들을 해결하기 위해 기존의 MVC 아키텍쳐를 갖다 버려버리고, Flux라는 새로운 아키텍쳐를 개발하게 된다. Flux 패턴은 Action
이 발생하면 dispatcher
에 의해 store
에 변경된 사항이 저장되고 그 저장된 데이터들에 의해 View
가 변경되는 단방향 패턴이다. MVC 아키텍쳐와 다르게 데이터가 단방향으로 흐르기 때문에, 흐름을 훨씬 파악하기 쉽고 예측이 가능하게 된다.
이렇게 Flux 패턴이 등장하게 되자, 많은 개발자들이 이 Flux 패턴을 적용한 구현체들을 개발하기 시작했는데, Dan Abramov라는 개발자 형님이 이 패턴을 적용하여 리덕스를 개발하게 된다. 이는 Flux를 더 단순화시켜 사용을 간편하게 했기 때문에 Flux를 개발한 페북 개발자들한테도 기립박수를 받았다는 카더라가 있다.
멋진 남자 Dan Abramov (욱일기 아님)
무튼 이렇게 리덕스는 상태 관리를 위해 만들어진 라이브러리이다. 그렇다면 왜 상태관리가 필요한 것일까? 근본적인 질문에서부터 들어가보자.
서울에 사는 김행갬씨는 어느 날 출근을 하던 중, 버스에 치여 이세카이에 떨어지게 되었다. 이세카이에서는 김행갬이 마왕성에 납치된 공주님을 구할 수 있는 유일한 용사님이었지만 마왕을 잡기 위해서는 고대로부터 내려오는 상태검을 통해서만 잡을 수 있었다.
" 검은 어떻게 구할 수 있죠? "
" 아, 검은 고대의 용사님들을 통해서 내려오는 거기 때문에 직접 전달을 받아야 하는데 1대 용사님이 2대 용사님에게, 2대 용사님이 3대 용사님에게 ... 결국 지금의 용사님인 4만 8천번째 용사님에게 오려면 100년이 걸립니다."
결국 행갬은 127세가 되어서야 검을 받을 수 있었고, 행갬은 검을 들자마자 그 무게때문에 심장에 무리가 와서 마왕의 얼굴을 보지도 못한 채 다시 이세카이에서 현생으로 돌아와 이세카이는 멸망하고 말았다. 끗
만약 상태검이 용사에서 용사로 직접 전달되는 방식이 아니라, 마을 무기 창고에 보관되어 있었다면 행갬은 마왕의 목을 따버리고 무사히 공주님을 구출할 수 있었을 것이다. 그리고 만약 중간 기수의 용사들이 용사가 되기를 포기하고 농부나 광부의 삶을 살기로 선택했어도 그들은 검을 가지고 있을 필요가 없음에도 후대 용사들에게 상태검을 전해주기 위해 검을 가지고 있었어야 했을 것이다.
이렇듯, 위에서부터 아래로 상태를 내려주는 방식은 자식 컴포넌트의 수가 많아질수록 더 복잡해지고 비효율적이며 시간이 길어지게 된다. 이를 'Props Drilling' 이슈라고 한다.
이러한 이슈는 전역 상태 저장소가 있고, 어디서든 해당 저장소에 접근하여 상태를 변경할 수 있으면 해결되는 이슈인데 바로 이 상태 관리를 도와주는 라이브러리가 Redux인 것이다.
상태 관리 라이브러리에는 여러 가지가 있는데, 리덕스는 이들 중에서 당당히 원탑을 차지하고 있다. 근데 중간에 다운로드가 떡락한 기점이 한번 있는데, 그건 바로 작년 크리스마스이다. 개붕이들이 모두 아싸 코스프레를 하지만 결국 그들도 크리스마스에는 나가서 여친과 노는 인싸들이었던 것이다.
리덕스에는 3가지 원칙이 있다. 한번 알아보자.
동일한 데이터는 store라는 하나뿐인 데이터 공간에서 관리된다. 이렇게 하면 애플리케이션의 디버깅이 쉬워지고 서버와의 직렬화가 가능하며 클라이언트에서 데이터를 쉽게 받아 들여올 수 있다.
state를 직접 변경해서는 안되며 state의 변경은 reducer에서만 할 수 있다. reducer 이외 공간에서의 state는 읽기 전용인 것이다. 이것이 바로 데이터의 단방향 흐름의 이점으로 상태를 변화시키는 의도를 정확히 표현할 수 있으며 상태 변경에 대한 추적이 용이해진다.
reducer는 순수 함수여야만 한다. reducer 함수는 기존의 state를 직접 변경하지 않고, 새로운 state object를 작성해서 return 해야 한다. 동일한 파라미터로 호출 된 reducer는 순수함수이기 때문에 언제나 같은 결과값만 반환한다.
1번부터 천천히 살펴보자면, 하나의 어플리케이션에서는 state를 관리하는 공간인 store가 하나밖에 없어야 한다는 뜻이다. 아까 Flux 패턴에서 MVC 아키텍쳐와 다르게 단방향으로 데이터의 흐름이 일어난다고 했는데, 만일 store가 여러개이면 단방향이 아니라 양방향이나 여러 갈래로 데이터의 흐름이 꼬이게 되고 이는 곧 사이드 이펙트를 유발하게 된다. 그러므로 하나의 store에서 state를 관리하여 직관적인 데이터 흐름이 일어나도록 해야 한다.
또한, state는 읽기 전용으로 설정되어 state의 직접 변경을 막아야 한다. 즉, state의 변경은 reducer에서 직접 state를 변경하는 것이 아닌 새로운 state object 작성을 통해 return 해야 하며, reducer 이외의 공간에서는 읽기 전용이기 때문에 변경 접근이 불가해야 하는 것이다. 2, 3번과 상통하는 내용이다. 이를 통해 상태 변화의 의도와 상태 변경 추적이 용이해져 예측 가능한 상태가 되고, reducer는 순수함수이므로 언제나 같은 결과값만 반환하여 예측 불가능한 결과 값이 나오지 않게 된다.
아까부터 계속 나오던 store라는 개념은, 앱의 전체 상태 트리를 가지고 있는 저장소이다. 상태 트리란, Redux API에서 저장소에 의해 관리되고 getState()
에 의해 반환되는 하나의 상태값을 지칭한다. 컴포넌트에서는 상태 정보가 필요할 때 바로 이 store에 접근하게 된다.
액션은 state를 변화시키려는 의도를 표현하는 객체이다. action은 store에 데이터를 넣는 유일한 방법이며, 모든 데이터는 action으로써 보내지게 된다.
type Action = Object
액션은 어떤 형태의 액션이 행해질지 표시하는 type
필드를 가져야 하며, 다른 모듈에서 import할 수 있다. 그 외의 값들은 개발자 마음대로 넣어줄 수 있다.
{
type: "ADD_TODO",
data: {
id: 0,
text: "리덕스 배우기"
}
}
{
type: "CHANGE_INPUT",
text: "안녕하세요"
}
액션은 단지 동작에 대해 선언된 객체이기 때문에, 컴포넌트에서 이를 더욱 쉽게 발생시키게 하려면 파라미터를 받아와서 액션 객체 형태로 만들어주는 액션 생성 함수를 사용해야 하는 편이 좋다.
export function addTodo(data) {
return {
type: "ADD_TODO",
data
};
}
// 화살표 함수로도 만들 수 있습니다.
export const changeInput = text => ({
type: "CHANGE_INPUT",
text
});
리덕스 사용시에 액션 생성함수 사용은 필수적인 것은 아니다. 액션을 발생 시킬때마다 직접 액션 객체를 작성할 수도 있지만, 귀찮으니까 액션 생성 함수를 애용하자!
리듀서는 현재 state와 action을 파라미터로 받아 store에 접근하여 action에 맞는 state 변경을 발생시킨다. 앞서 언급했다시피, reducer가 변경하는 state는 직접 변경된 state가 아닌 새로 생성되어 반환된 새로운 state이다. 즉, reducer는 순수 함수임을 지켜야 한다.
function reducer(state, action) {
// 상태 업데이트 로직
return newModifiedState;
}
switch 문을 사용하여 action type에 맞는 case에 따른 state 변경도 가능하다.
function counter(state, action) {
switch (action.type) {
case 'INCREASE':
return state + 1;
case 'DECREASE':
return state - 1;
default:
return state;
}
}
API 호출은 리듀서 안에 들어가면 안된다. (리듀서는 순수 함수여야 하기 때문에 사이드 이펙트 발생 x)
디스패치 함수는 액션이나 비동기 액션을 받는 함수로, 액션을 파라미터로 전달하여 호출한 뒤, 스토어가 리듀서 함수를 실행시켜 해당 액션을 처리하게 된다. 즉, 액션을 발생시키는 함수라고 이해하면 된다.
<button onClick={()=>{ props.dispatch({ type: 'INCREASE'}) }}>+</button>
Subscribe 함수는, 함수 형태의 값을 파라미터로 받아와 액션이 디스패치 되었을 때 마다 받은 함수가 호출되게 된다.
이제 구성요소들을 모두 한번씩 훑어봤으니 이들을 조립해서 Redux Flow가 어떤 방식으로 진행되는지 한번 살펴보자!
초기 상태
store에서 reducer를 호출하고 return 값을 초기 state에 저장한다.
UI 최초 렌더링 시, store의 state를 렌더링하고, 그 state가 업데이트 되는 것을 subscribe 한다.
Flow
사용자가 Deposit $10 버튼을 클릭한다.
onClick 이벤트에 있는 dispatch 함수를 실행시켜 action을 발생시킨다.
store에서 현재 state와 action을 reducer에 parameter로 전달하고, reducer는 이들을 통해 리턴된 값을 새로운 state로 반환한다. (state 변경)
store에서 subscribe된 UI 컴포넌트들의 업데이트 여부를 확인한다.
store의 데이터가 필요한 컴포넌트들은 state 변경을 확인하고 데이터가 변경된 요소들이 강제 리렌더링되어 화면에 업데이트 된다.
이렇게 보니 한눈에 어떻게 Redux가 동작하는지 알기 쉽군!
백문이 불여일견, 이제 구성요소도, Flow도 모두 알았으니 직접 간단한 예제를 만들어 보며 머릿속에 완전히 넣어보자구~~
우선 Redux 역시 라이브러리이기 때문에 별도의 설치를 통해 프로젝트에 적용해 주어야 한다.
# NPM
npm install redux
npm install react-redux
# Yarn
yarn add redux
yarn add react-redux
기존의 Redux 구조들은 action 파일들을 따로 action 폴더에, reducer는 또 reducers 폴더에, saga는 saga 폴더에 이렇게 구조 중심적으로 분리해서 작업하고는 했다. 하지만 이렇게 하면, 하나의 기능을 수정하려고 할 때 여러개의 파일들을 수정해야 되기 때문에 번거롭다. 이러한 불편함을 개선하고자 나온 것이 바로 Ducks 패턴이다.
Ducks 패턴에서는 기능중심으로 파일을 나누기 때문에, action type, action 생성자, saga, reducer를 같은 기능을 하는 하나의 파일에서 관리한다. 이는 대개 modules라는 폴더에 저장해 놓는다.
요로코롬! 그러나 Ducks 패턴이 무적권 옳고 정답이다라는 뜻은 아니다. 폴더 구조나 코딩 방식은 항상 개인마다 다르고 회사 컨벤션마다 다르니 이런게 있다더라~ 정도만 알아두고 넘어가도록 하자.
나는 이번 프로젝트에서 나이가 먹으면 철이 들어 얌전해지는 잼민이 친구를 만들어보고자 한다. 우선 액션을 만들어 주도록 하자.
각각의 액션과 액션 생성자 함수를 만들어주자. 이때 action 함수는 나중에 컴포넌트에서 사용되기 때문에 export로 내보내야 한다.
이제 각 액션들을 실행시킬 리듀서를 작성한다. 우선 initialState라는 변수에 초깃값을 설정해 두고, 리듀서의 parameter에 초기 state와 액션 객체를 넣어준다. 내가 생각하는 잼민이로써 불릴 수 있는 나이의 시작은 7살이니 초깃값을 7로 설정해 주었다.
그리고 switch 문으로 받은 액션 parameter에 따른 값을 리턴하도록 작성하고 어떠한 액션도 발생하지 않았을 때의 기본 default return 값도 반환한다.
여러 리듀서들을 하나 하나 연결하는 것은 몹시 비효율적이므로, 이들을 하나로 합쳐주는 리덕스의 내장 함수인 combineReducer
를 생성해준다. 지금은 리듀서가 하나 뿐이지만, 나중에 많아질 경우에는 아주 효율적이다.
이제 잼민이를 우리의 화면에 나타나게 하기 위해서 컴포넌트를 생성해 주자.
useDispatch는 react-redux에서 제공하는 hook으로, dispatch를 함수에서 사용할 수 있게 해주며, useSelector 역시 리덕스 스토어의 state를 조회하는 hook이다.
createStore를 생성하여 앞서 만든 리듀서를 parameter에 넣어준다. 이때 파라미터는 만들어둔 combineReducer를 넣어주면 되는데.. 오잉? createStore에 줄이 그어져 있다. 자세히 보니 리덕스 툴킷에서 제공하는 configureStore를 쓰는것을 권장한다고 나와있다. 왜 그런지는 아직 툴킷 공부를 안해서 모르겠다만.. 까라면 까야지
configureStore로 바꿔주었다. Provider는 리액트 프로젝트에 store 연동을 쉽게 할 수 있도록 도와주는 컴포넌트이므로, props에 store를 넣어준 뒤 App 컴포넌트를 감싸주었다.
Ta-da! 정상적으로 애플리케이션이 작동하는 모오습이다.
이렇게 기초와 이론, 그리고 실습을 통해서 리덕스를 한번 훑어보았다. 이제 조금씩 리덕스의 사용 방법에 대해서 알것만 같은 기분이 솔솔 든다. 하지만 아직 Redux-saga, Redux-thunk, 그리고 이것들을 쉽게 사용할 수 있게 해주는 Redux-toolkit 과 같은 것들이 더 남아있다. 얘네들은 다음 글에서 알아보도록 하면서 이만 마치도록 하겠다.
리덕스 공식 문서
https://react.vlpt.us/redux/01-keywords.html
https://tech.osci.kr/2022/06/29/%EB%B3%B5%EC%9E%A1%ED%95%98%EA%B3%A0-%EC%96%B4%EB%A0%A4%EC%9A%B4-redux-%EC%A0%81%EC%9D%91%EA%B8%B0/
https://ivorycode.tistory.com/entry/Redux%EC%9D%98-%ED%9D%90%EB%A6%84%EA%B3%BC-%EC%98%88%EC%A0%9C