Redux 시작하기
Redux는 자바스크립트 앱을 위한 예측 가능한 상태 컨테이너이다. 즉, 일관적으로 동작하고, 서로 다른 환경에서 작동하고, 테스트하기 쉬운 앱을 작성하도록 도와준다.
본격적으로 들어가기 전에 리덕스의 역사를 알아보자
MVC 아키텍처의 한계
애플리케이션을 작성해 본 개발자라면 한 번쯤은 사용 혹은 들어봤을 MVC 패턴에 대해 알아보자.
MVC 패턴에서 Controller는 Model에 정의된 데이터를 조회하거나 업데이트 하는 역할을 하며, 변결된 Model의 데이터를 View에 반영한다. 사용자는 View를 통해 데이터를 입력하고 Model에 반영되며, View와 Model은 데이터를 양방향으로 주고받는 형태이다.
하지만 프로젝트 규모가 커질수록 수많은 View와 Modele들이 생겨나게 되므로 데이터가 어디로 흐르는지 파악하기 어려워진다. 또한 새 기능을 추가할 때마다 크고 작은 문제 혹은 Side Effect를 불러 일으킨다.
Flux의 탄생
이러한 MVC 패턴의 문제를 해결하기 위해 페이스북 개발팀이 다른 아키텍처인 Flux 패턴을 적용하기로 했다
Flux 패턴은 어떤 Action이 발생하면 dispatcher에 의해 store에 반경된 사항이 저장되고 그 저장된 데이터들에 의해 view가 변경되는 단방향 패턴이다. 이러한 패턴은 양방향으로 흐르는 MVC와 달리 단방향으로 흐르기 때문에 흐름을 훨씬 파악하기 쉽고 예측 가능한 장점이 있다.
그 중 널리 사용되는 Flux 구현체 중 하나가 바로 Redux이다. Redux는 Dan Abramov가 작성 했으며 Flux보다 조금 더 단순화되어 사용이 간편해 졌다.
Flux와 Redux는 무엇이 다를까
- Hot Reloading
Hot Reloading이란 코드가 변경되어도 기존의 상태를 유지할 수 있게 만들어 주는 것이다. Flux의 첫 번째 문제점으로 상태 업데이트에 관련된 코드를 리로딩하게 되면 애플리케이션 상태도 같이 리로딩되어 저장된 상태 정보를 잃어버리게 되는 것이다. 왜냐? store가 두 가지 역할을 맡고 있기 때문이다.
- 상태 즉, State 변환을 위한 로직
- 현재 애플리케이션의 상태 (State)
Redux는 이 두 가지 역할을 분리함으로써 상태 변환 로직을 핫 리로딩할 수 있다. Redux에서 store가 애플리케이션 상태를 가지며, 상태 변환 로직은 reducer가 관리를 한다. 즉, 리듀서를 리로딩하는 것으로 애플리케이션 상태를 잃어버리지 않고 관련 로직만을 핫 리로딩 할 수 있다.
2. Time Travel Debugging
번역을 해보면 시간 여행 디버깅이다. 이름처럼 이전의 특정 상태로 돌아갈 수 있게 해 주는 것이다. 그러기 위해서는 상태가 새로 바뀔 때마다 상태 객체의 모든 버전을 기록해 두어야 한다.
Redux는 이러한 문제를 해결하기 위해 기존의 애플리케이션 상태를 직접 수정하는 대신 그 상태를 복사하여 복사본을 수정하는 식으로 불변성을 유지한다.
Flux는 여러개의 스토어가 사용되지만 리덕스는 하나의 스토어만 가지며, reducer가 존재하고, 불변성을 유지해 상태를 변경할 수 없다.
리덕스 왜 필요할까?
프로젝트 규모가 커질수록 컴포넌트 개수도 많아질 것이고, 그에 따라 관리해야 하는 state들도 많아져 복잡해질 수 밖에 없다. 그래서 상태 관리 라이브러리를 사용하게 되고, 그 주으이 하나가 Redux이다.
React에서의 데이터가 공유되는 방식은 부모 컴포넌트가 중간자 역할을 한다. 즉, 부모에서 자식으로만 데이터가 흐르는 단방향 패턴이며 자식 컴포넌트들 간의 다이렉트 데이터 전달은 불가능하다. 물론 ref를 사용해서 컴포넌트 끼리 직접 소통 하는 방법은 있긴 하지만, 이렇게 되면 코드가 굉장히 많이 꼬여버리기 때문에 권장되지 않는 방식이다.
예를 들어보자
다음과 같은 구조의 프로젝트에서 루트 컴포넌트에서 G 컴포넌트에게 값을 전달해 줘야 하는 상황에서는 루트에서 A ⇒ E ⇒ G 순서로 데이터가 props로 전달해 줘야 한다.
하지만 자식이 너무 많아진다면 어떻게 될까?
이러한 경우 데이터를 사용하지 않는 A ⇒ E 컴포넌트에서도 데이터를 전달받아야 되기 때문에 Props drilling 이슈가 생긴다. 즉, 프로젝트의 규모가 커질수록, 데이터를 전달하기 위해 필요없는 데이터의 흐름이 생기게 되며 코드가 지저분해 지면서 유지보수도 어려워 지게 된다.
이때 리덕스 등장!
이때 하나의 store라는 매체를 두면 A ⇒ E ⇒ G 순서가 아니라 Root ⇒ store ⇒ G 순서로 접근하기 때문에 효율적이며 불필요한 컴포넌트 간의 데이터 전달이 없어진다.
스토어 설정
스토어 안에는 상태에 관한 데이터들이 담겨있다.
컴포넌트의 스토어 구독
G 컴포넌트는 스토어에 구독을 한다. 이런 과정에서 특정 함수가 스토어한테 전달이 되고 나중에 스토어의 상태값에 변동이 생긴다면 전달 받았던 함수를 호출하게 된다.
스토어에 상태 변경하라고 알려주기
이제 B 컴포넌트에서 어떤 이벤트가 생겨서, 상태를 변화 할 일이 생기면 dispatch 라는 함수를 통하여 액션을 스토어한테 던져준다. 액션은 상태에 변화를 일으킬 때 참조 할 수 있는 객체이다. 액션 객체는 필수적으로 type 라는 값을 가지고 있어야 한다.
리듀서를 통하여 상태를 변화시키기
액션 객체를 받으면 전달받은 액션의 타입에 따라 어떻게 상태를 업데이트 해야 할지 정의를 해줘야한다. 이러한 업데이트 로직을 정의하는 함수를 리듀서라고 부른다.
리듀서 함수는 두가지의 파라미터를 받습니다.
- state: 현재 상태
- action: 액션 객체
그리고, 이 두가지 파라미터를 참조하여, 새로운 상태 객체를 만들어서 이를 반환한다.
상태가 변화가 생기면, 구독하고 있던 컴포넌트에게 알림
상태에 변화가 생기면, 이전에 컴포넌트가 스토어한테 전달했던 함수 listener가 호출된다. 이를 통해 컴포넌트는 새로운 상태를 받게 되고, 리렌더링을 하게 된다.
정리
기존에는 한 부모에서 자식, 그 자식의 자식까지 상태가 흘렀었는데, 리덕스를 사용하면 스토어를 통해 상태를 컴포넌트 구조의 바깥에 두고, 업데이트 하거나 새로운 상태를 전달받았다. 따라서 props drilling이 발생하지 않고 깊숙한 컴포넌트에 부모에게서 받아오는 것 처럼 props를 편리하게 받아올 수 있다.
Redux의 기본 개념 : 세 가지 원칙 🔥
- Single source of truth : 하나의 애플리케이션은 하나의 store만 가진다
동일한 데이터는 store라는 항상 같은 곳에서 관리되고 가지고 온다. 이러한 장점은 디버깅이 쉬워지고 서버와의 직렬화가 가능하며 클라이언트 단에서 데이터를 쉽게 받을 수 있다.
- State is read-only : 상태는 읽기 전용이다
리액트에서는 state를 직접 변경하지 않고 setState 메소드를 활용해 상태 변경을 한다. 리덕스에서도 state의 변경은 reducer에서만 할 수 있다. 그 이외의 공간은 읽기 전용인 것이다. 즉, 액션이라는 객체를 통해서만 상태를 변경할 수 있으므로 데이터의 단방향 흐름의 이점으로 상태 변경에 대한 추적이 용이해진다.
- Changes are made with pure functions : 리듀서는 순수 함수여야 한다
변경은 순수 함수로만 가능하다. 즉, reducer는 순수 함수여야만 한다. 기존의 state를 직접 변경하지 않고, 새로운 state object를 작성해서 return 해야 한다. 순수 함수이기 때문에 언제나 같은 결과값만 반환한다.
Store(스토어) – Action(액션) – Reducer(리듀서)
Store, Action, Reducer의 의미와 특징
Store (스토어) : 상태가 관리하는 오직 하나의 공간
스토어는 앱에서 필요한 상태들과 리듀서가 저장되어 있으며 컴포넌트와는 별개로 스토어라는 공간이 있어서 그 스토어 안에 앱에서 필요한 상태를 담는다. 컴포넌트에서 상태 정보가 필요할 때 스토어에 접근한다.
Action (액션) : 앱에서 스토어에 운반할 데이터
액션은 상태를 변화시키려는 의도를 표현한 객체이다. 상태를 변경해야 할 때, 어떠한 변화를 줄지 해당하는 액션을 발생시킬 수 있다.
{
type: 'ACTION_CHANGE_USER', // 필수
payload: { // 옵션
name: 'Voyage',
age: 100
}
}
액션은 사실 그냥 객체 (object) 이며 두 가지 형태의 액션이 있다.
- { type: 'TEST' } // payload 없는 액션
- { type: 'TEST', params: 'hello' } // payload 있는 액션
type 만이 필수 프로퍼티이며, type 은 문자열이다.
리덕스의 액션은 어떤 일을 하나?
- 액션 생성자를 통해 액션을 만들어 낸다.
- 만들어낸 액션 객체를 리덕스 스토어에 보낸다
- 리덕스 스토어가 액션 객체를 받으면 스토어의 상태 값이 변경된다.
- 변경된 상태 값에 의해 상태를 이용하고 있는 컴포넌트가 변경된다.
- 액션은 스토어에 보내는 일종의 인풋이라 생각할 수 있다.
액션을 준비하기 위해서는 ?
- 액션의 타입을 정의하여 변수로 빼는 단계
- 강제는 아니다.
- 그냥 타입을 문자열로 넣기에는 실수를 유발할 가능성이 크다.
- 미리 정의한 변수를 사용하면, 스펠링에 주의를 덜 기울여도 된다.
- 액션 객체를 만들어 내는 함수를 만드는 단계
- 하나의 액션 객체를 만들기 위해 하나의 함수를 만들어낸다.
- 액션의 타입은 미리 정의한 타입 변수로 부터 가져와서 사용한다.
Action Creator (액션 생성 함수)
액션이 동작에 대해 선언된 객체라면, Action Creator는 액션을 생성해 실제 객체로 만들어 주는 함수이다.
함수를 통해 액션을 생성해서, 액션 객체를 리턴해 준다.
createTest('hello');
//{ type: 'TEST', params: 'hello' } 리턴
Reducer (리듀서) : State에 변화를 일으키는 함수
리듀서는 현재의 state와 action을 인자로 받아 store에 접근해 action에 맞춰 state를 변경한다. 순수 함수이기 때문에 이전의 상태는 건드리지 않고 변화된 새로운 상태 객체를 만들어 반환한다. 왜? 리듀서를 통해 state가 달라졌음을 리덕스가 인지하는 방식이다.
function 리듀서(previousState, action) {
return newState;
}
- 액션을 받아서 스테이트를 리턴하는 구조
- 인자로 들어오는 previousState 와 리턴되는 newState 는다른 참조를 가지도록 해야한다.
- 그렇게 했을때만 리덕스가 상태가 변경됬음을 인지하고 제대로 처리한다.
정리
- Action(액션)을 Store(스토어)에 바로 전달하는 것이 아니다.
- Action(액션)을 Reducer(리듀서)에 전달해야한다.
- Reducer(리듀서)가 주문을 보고 Store(스토어)의 상태를 업데이트하는 것이다.
- Action(액션)을 Reducer(리듀서)에 전달하기 위해서는 dispatch() 메소드를 사용해야한다.
React Redux 간단한 Todo 앱 만들기
npx create-react-app [프로젝트명] --typescript
# NPM
npm install redux
# Yarn
yarn add redux
필요한 라이브러리 설치
yarn add redux react-redux @types/react-redux
yarn add typesafe-actions
typesafe-actions : 액션 생성 함수와 리듀서를 훨씬 쉽고 깔끔하게 작성 할 수 있게 해주는 라이브러리이다.
module 작성하기
// src/modules/todos.ts
import { ActionType, createAction, createReducer } from 'typesafe-actions';
// 이 리덕스 모듈에서 관리 할 Todo의 타입을 선언
export type Todo = {
id: number;
text: string;
done: boolean;
};
export type TodosState = Todo[];
// action 타입 선언
const ADDTODO = 'todo/ADDTODO';
const DELETETODO = 'todo/DELETETODO';
let nextId = 1;
// action creator
export const addTodo = createAction(ADDTODO, (text: string) => ({
id: nextId++,
text: text,
}))<Todo>();
export const deleteTodo = createAction(DELETETODO)<number>();
const actions = { addTodo, deleteTodo };
type TodosAction = ActionType<typeof actions>;
// 초기상태 선언
const initialState: TodosState = [];
// reducer
const todos = createReducer<TodosState, TodosAction>(initialState, {
[ADDTODO]: (state, action) =>
state.concat({
...action.payload,
done: false,
}),
[DELETETODO]: (state, { payload: id }) => state.filter((todo) => todo.id !== id),
});
export default todos;
루트 리듀서 적용
// src/modules/index.ts
import { combineReducers } from 'redux';
import todos from './todo';
const rootReducer = combineReducers({
todos,
// 그외 사용할 다른 module 추가
});
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;
스토어 생성
// src/store.ts
import { configureStore } from '@reduxjs/toolkit';
import reducer from './modules';
const store = configureStore({
reducer, // rootReducer
});
export type AppDispatch = typeof store.dispatch;
export default store;
스토어 적용
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { Provider } from 'react-redux';
import store from './store';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<Provider store={store}>
<App />
</Provider>
);
Provider : react-redux에 내장된 컴포넌트로 store를 전역에서 손쉽게 연동할 수 있게 해준다.
Todo 추가
const dispatch = useDispatch();
const onSubmit = (e: FormEvent) => {
e.preventDefault();
dispatch(addTodo(todo));
setTodo('');
};
새로운 입력값을 받으면 dispatch를 통해 새로운 todo를 추가하는 addTodo action이 동작되도록 리듀서를 호출한다.
Todo 조회
const todos = useSelector((state: RootState) => state.todos);
useSelector : Provider로 감싼 스토어에서 todos 데이터를 가져올 수 있게 해 준다. React-redux는 자동적으로 해당 컴포넌트가 스토어를 subscription 하도록 도와준다.
Redux의 Flow
- UI에서 컴포넌트 내에 존재하는 이벤트가 호출
- 이벤트와 연결된 액션 생성자가 호출
- 액션 생성자에서 생성된 액션이 호출
- 액션이 리듀서로 전달. 이 과정을 디스패치에서 담당
- 리듀서에서 디스패치된 액션에 따라 상태값을 변경
- 변경사항이 렌더링되어 UI에 나타난다
Redux의 장점
- 상태를 예측 가능하게 만든다. (순수함수를 사용하기 때문)
- 유지보수 (복잡한 상태 관리와 비교) 용이하다.
- 디버깅에 유리 (action과 state log 기록 시) → redux dev tool (크롬 확장)
- 비동기를 지원하는 Redux Saga, Redux Thunk 등 다양한 미들웨어 존재.
- 테스트를 붙이기 용의 (순수함수를 사용하기 때문)
마무리 정리
프로젝트의 규모가 커지면 커질수록 단순히 부모에서 props로만 전달하면 안 된다. 반드시 상태관리 라이브러리 중 하나를 골라서 써야한다. 프로젝트에 따라 Redux, Recoil, Zustand, ContexAPI 등등 어떤 생태 관리 방법이 자신한테 맞는지를 파악해서 도입하고 연습하면 자신의 실력을 한 단계 높일 수 있다.
Recoil과 Zustand만 사용해봤던 본인으로서 Redux의 작동 흐름과 사용방법이 생소하고 어렵다. Redux의 이러한 초기 세팅과 복잡한 사용방법으로 인해 요즘 비교적 가볍고 사용하기 쉬운 상태관리 라이브러리로 넘어가는 추세지만
그래프를 보면 아직까지도 Redux가 압도적인걸 볼 수 있다. 시간을 들여서 좀 더 익숙해 지는 시간을 가져야겠다!
출처 : 아래의 사이트들을 보면서 큰 공부 하였습니다
https://ko.redux.js.org/introduction/getting-started/