Redux 입문기
리덕스를 처음 잡았다. 컴포넌트가 늘어나면서 상태가 여기저기 흩어지는 게 부담스러웠고, 한곳에 모아둔다는 Redux의 전제가 솔깃했다. 그래서 store부터 reducer, dispatch, subscribe까지 한 바퀴 돌려보며 정리했다.
상태를 객체 하나로 모은다는 전제
Redux는 애플리케이션의 모든 상태를 단 하나의 객체에 욱여넣는다.
state = { /* 앱이 필요한 모든 데이터 */ };처음엔 객체 하나에 다 몰아넣는 게 부담스러워 보였는데, 오히려 그 덕에 "지금 앱의 상태가 뭐냐"는 질문에 한 줄로 답할 수 있다는 게 장점이었다. 데이터가 어디에 있는지 추적할 필요가 없다.
state는 함부로 못 만진다
Redux는 state를 읽기 전용으로 다룬다. 값을 바꾸려면 직접 대입할 수 없고, action을 dispatch해서 reducer가 새 state 객체를 반환하게 해야 한다. 가져올 때도 getState()로만 접근한다.
이 제약이 처음엔 번거로워 보였다. 그런데 의도치 않게 어디선가 state가 변경되는 사고를 막아주는 장치라는 걸 깨달았다. 출입구를 좁혀서 관리 비용을 낮추는 쪽이다.
store가 자산을 보관하는 창구
Redux의 중심은 store다. 자산(데이터)을 보관해두는 은행 같은 곳이라고 비유하면 편했다. 그 자산을 보거나 바꾸려면 정해진 창구를 거쳐야 한다.
getState()— 자산 조회dispatch(action)— 자산 변경 요청subscribe(listener)— 자산이 바뀌었을 가능성이 있을 때 알림 받기
store는 createStore로 만든다. 인자로 reducer를 넘긴다.
const store = Redux.createStore(reducer);createStore에 reducer가 필수 인자라는 점이 흥미로웠다. store 자체는 빈 껍데기에 가깝고, "상태를 어떻게 바꿀지"는 reducer가 결정하는 구조였다.
reducer가 사실상 Redux의 본체
reducer는 현재 state와 action을 받아 새 state를 반환하는 함수다. Redux를 만든다는 건 사실상 reducer를 작성하는 일이라고 해도 과언이 아니었다.
function reducer(oldState, action) {
if (action.type === 'create') {
return Object.assign({}, oldState, {
contents: oldState.contents.concat([/* 새 항목 */]),
maxId: oldState.maxId + 1,
mode: 'read',
selectedId: oldState.maxId + 1,
});
}
}핵심은 Object.assign({}, oldState, { ... })로 새 객체를 반환한다는 점이다. oldState를 직접 건드리지 않는다. 처음엔 "그냥 oldState.contents.push() 하면 안 되나" 싶었는데, 그러면 Redux가 변경을 감지하지 못한다. state는 새 객체로 교체되어야 한다.
render는 Redux 바깥의 내 코드
render는 UI를 그리는 함수다. Redux와 직접적인 관계는 없고, 내가 따로 작성한다.
function render() {
const state = store.getState();
document.querySelector('#app').innerHTML = `<h1>WEB</h1> ...`;
}render는 store의 state에 직접 접근하지 못한다. 반드시 getState()로 받아온다. 결국 render는 "state를 받아 UI로 옮기는 변환 함수"가 된다.
subscribe는 dispatch마다 울린다
subscribe는 store의 변경 가능성을 듣는 리스너 등록 함수다.
store.subscribe(render);처음엔 "state가 바뀔 때마다 render가 호출된다"고 이해했는데, 정확히는 그렇지 않았다. subscribe로 등록한 리스너는 dispatch가 호출될 때마다 실행된다. state가 실제로 바뀌었는지는 보장해주지 않는다. dispatch가 일어났으면 일단 리스너를 호출하고, 진짜 바뀌었는지 비교는 호출된 쪽에서 직접 해야 한다.
이게 헷갈렸다. 같은 값으로 dispatch를 두 번 하면 render도 두 번 호출된다. 최적화가 필요하면 이전 state와 비교해서 실제로 변한 경우에만 갱신하도록 따로 처리해야 한다.
dispatch가 흐름을 시작한다
dispatch는 action 객체를 store에 보내는 함수다. action은 그냥 type을 가진 평범한 객체다.
<form onSubmit="
store.dispatch({ type: 'create', payload: { title: title, desc: desc } });
">dispatch가 호출되면 다음 순서로 일이 벌어진다.
- 현재 state와 action을 인자로 reducer를 호출해 새 state로 교체
- subscribe로 등록된 리스너들을 차례로 호출
- 리스너 안에서 render가 돌면서 화면 갱신
이걸 다이어그램으로 풀면 흐름이 더 명확해진다.
dispatch 하나가 reducer → state 교체 → 리스너 → render → getState → UI까지 줄줄이 끌어낸다. 한 방향으로만 흐른다는 게 인상적이었다. 어디서 끊겼는지 추적이 쉽다.
한 바퀴 돌려보고 남은 것
처음엔 store, reducer, dispatch, subscribe, getState가 다 따로 노는 단어처럼 보였는데, 한 바퀴 흐름을 따라가니 자기 자리가 분명했다. store는 보관소, reducer는 새 state를 만드는 함수, dispatch는 변경 요청, subscribe는 알림 등록, getState는 조회 창구다.
특히 "reducer가 새 객체를 반환한다"와 "subscribe는 dispatch마다 울린다" 두 가지가 처음 잡았을 때 가장 헷갈렸던 지점이다. 이 둘만 손에 익으면 나머지는 자연스럽게 따라온다.