리액트에서 리덕스의 사용은 상태 관리를 효율적으로 할 수 있다는 점이다. 리덕스는 상태를 중앙 집중적으로 관리하고 변경 사항을 예측 가능한 방식으로 다룬다.
여러 컴포넌트에서 공유해야 하는 데이터를 중앙에서 관리하면, 컴포넌트 간의 데이터 흐름이 단순화된다. 데이터를 컴포넌트 트리를 따라 전달하는 대신, 컴포넌트는 필요할 때 데이터를 구독하고 업데이트를 수신한다. 이를 통해 애플리케이션의 전반적인 복잡도를 줄이고 디버깅을 용이하게 만들 수 있다.
또한, 리덕스는 애플리케이션의 상태를 일관된 방식으로 업데이트한다. 상태 변경을 위해 액션(action)을 사용하고, 상태를 변경하는 로직을 순수한 함수인 리듀서(reducer)로 분리한다. 이러한 방식은 애플리케이션에서 발생하는 상태 변화를 예측 가능하게 하고, 디버깅과 테스팅을 쉽게 만들어준다.
마지막으로, 리덕스는 미들웨어(middleware)를 사용하여 애플리케이션의 비즈니스 로직과 UI 로직을 분리할 수 있다. 미들웨어를 사용하면 비동기 작업을 처리하거나, 로깅, 유효성 검사 등의 작업을 수행할 수 있으며, 이를 통해 애플리케이션의 유연성과 재사용성을 높일 수 있다.
리액트로 리덕스 상태를 관리하는 프로젝트를 만들어보자.
작업 환경 설정
npx create-react-app react-redux-tutorial --template typescript
cd react-redux-tutorial
npm install @types/redux @types/react-redux react-redux
UI 준비
리액트에서 리덕스를 사용할 때 가장 많이 사용하는 패턴은 Flux 아키텍처 패턴이다. Flux는 단방향 데이터 흐름 아키텍처로, 데이터가 단방향으로 흐르기 때문에 애플리케이션의 상태 변화를 예측 가능하게 만들어준다.
Flux 아키텍처는 다음과 같은 구성 요소로 이루어져 있다.
- 액션(Action): 애플리케이션에서 발생하는 모든 이벤트를 의미. 액션은 하나 이상의 키-값 쌍으로 이루어진 객체
- 디스패처(Dispatcher): 액션을 받아서 스토어(Store)에 전달하는 중앙 집중형 역할 수행
- 스토어(Store): 애플리케이션의 상태를 저장하는 장소. 스토어는 상태를 변경하는 유일한 방법은 디스패처를 통해 액션을 전달하는 것으로 제한
- 뷰(View): 스토어의 상태를 기반으로 화면 렌더링
이러한 구성 요소를 바탕으로 Flux 패턴을 구현하는 방법은 다음과 같다.
- 액션(Action)을 정의한다. 각 액션은 액션 타입과 필요한 데이터를 포함하는 객체이다.
- 액션(Action) 생성자 함수를 정의한다. 액션 생성자 함수는 액션 객체를 생성하여 디스패처에 전달한다.
- 스토어(Store)를 정의한다. 스토어는 상태(state)와 상태를 변경하는 리듀서(reducer)를 포함한다.
- 디스패처(Dispatcher)를 정의한다. 디스패처는 액션(Action)을 받아서 스토어(Store)에 전달한다.
- 뷰(View)를 정의한다. 뷰는 스토어의 상태(state)를 구독하고, 상태가 변경될 때마다 화면을 업데이트한다.
이러한 Flux 패턴을 리덕스에서 구현할 때는, 리듀서(reducer)를 사용하여 스토어(Store)를 정의하고, 액션(Action) 생성자 함수를 사용하여 액션(Action)을 생성하고 디스패처(Dispatcher)에 전달한다. 또한, 컴포넌트는 connect() 함수를 사용하여 스토어(Store)의 상태를 구독하고, 상태가 변경될 때마다 화면을 업데이트한다.
이번 프로젝트에는 추가적으로 프레젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리해서 작성해볼 예정이다. 프레젠테이셔널 컴포넌트란 주로 상태 관리가 이루어지지 않는, 그저 props를 받아 화면에 UI를 보여주기만 하는 컴포넌트를 말한다. 컨테이너 컴포넌트는 리덕스와 연동되어 있는 컴포넌트로, 리덕스로부터 상태를 받아오기도 하고 리덕스 스토어에 액션을 디스패치하기도 한다.
따라서, UI 관련 프레젠테이셔널 컴포넌트는 components 디렉토리에 저장하고, 리덕스와 연동된 컨테이너 컴포넌트는 containers 컴포넌트에 작성할 것이다.
카운터 컴포넌트 만들기
숫자를 더하고 뺄 수 있는 카운터 컴포넌트를 만들어보자.
// src/components/Counter.tsx
export default function Counter({ number: number, onIncrease, onDecrease }) {
return (
<div>
<h1>{number}</h1>
<div>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
</div>
);
}
// src/App.tsx
import Counter from "./components/Counter";
function App() {
return (
<div>
<Counter number={0} />
</div>
);
}
export default App;
코드 작성 후 렌더링해보면, 타입 미설정으로 인한 오류는 발생하지만 화면은 보여지는것을 확인할 수 있다.
할 일 목록 컴포넌트 만들기
이번에는 할 일을 추가하고, 체크하고, 삭제할 수 있는 할 일 목록 컴포넌트를 만들어보자.
// src/components/Todos.tsx
function TodoItem({ todo, onToggle, onRemove }) {
return (
<div>
<input type="checkbox" />
<span>예제 텍스트</span>
<button>삭제</button>
</div>
);
}
export default function Todos({
input,
todos,
onChangeInput,
onInsert,
onToggle,
onRemove,
}) {
function onSubmit(e) {
e.preventDefault();
}
return (
<div>
<form onSubmit={onSubmit}>
<input />
<button type="submit">등록</button>
</form>
<div>
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
<TodoItem />
</div>
</div>
);
}
이렇게 작성하고 렌더링해보면 props를 사용하지 않아 오류는 발생하겠지만, 화면은 표시될 것이다.
리덕스 관련 코드 작성하기
리덕스 코드 디렉토리 구조로 여러 구조가 있고, 정해진 방법이 없지만, 주로 사용되는 방법 중 하나인 Ducks 패턴을 사용해 코드를 작성해보고자 한다.
Ducks 패턴은 리덕스의 액션, 액션 생성 함수, 리듀서를 하나의 모듈로 묶는 방법론으로, 이 패턴에서 모듈이라고 부르는 단일 파일에서 액션 타입, 액션 생성 함수, 리듀서 함수를 함께 정의하고 내보냄으로써 코드의 유지보수성과 재사용성을 높이는 장점이 있다.
기존의 리덕스 코드는 액션 타입, 액션 생성 함수, 리듀서 함수를 서로 다른 파일에 작성하고 내보내는 방식을 사용했다. 이 경우, 코드가 증가하면 파일 수가 많아지고 파일 간의 의존성이 높아져서 코드의 복잡도가 증가할 수 있다.
Ducks 패턴은 이러한 문제를 해결하기 위해 하나의 모듈에 액션 타입, 액션 생성 함수, 리듀서 함수를 함께 작성하고 내보내는 방식을 사용한다. 모듈의 이름은 해당 기능이나 리소스의 이름으로 정하면 된다. 이렇게 작성된 모듈은 다른 모듈에서 쉽게 재사용할 수 있다.
Ducks 패턴을 사용하면 액션 타입, 액션 생성 함수, 리듀서 함수가 모두 같은 파일에 존재하므로, 이들 간의 관계를 쉽게 파악할 수 있고 코드의 가독성이 향상된다. 또한 모듈 단위로 코드를 작성하므로, 코드의 유지보수성이 향상되고 의존성 문제도 해결할 수 있다.
counter 모듈 작성
modules 디렉토리를 생성하고 그 안에 counter.tsx 파일을 작성해보자
- 액션 타입 정의하기
// modules/counter.tsx
const INCREASE: string = "counter/INCREASE" as const;
const DECREASE: string = "counter/DECREASE" as const;
type IncreaseAction = {
type: typeof INCREASE;
}
type DecreaseAction = {
type: typeof DECREASE;
}
type ActionType = IncreaseAction | DecreaseAction;
가장 먼저 할 것은 액션 타입을 정의하는 것이다. 액션 타입은 대문자로 정의하고, 문자열 내용은 '모듈 이름/액션 이름' 과 같은 형태로 작성한다. 문자열 안에 모듈 이름을 넣음으로써, 나중에 프로젝트가 커졌을 때 액션의 이름이 충돌되지 않도록 해 준다.
먼저, INCREASE
와 DECREASE
라는 두 개의 상수를 정의했다. 이 상수는 Redux 액션 객체의 type
속성에 사용될 것이다.
다음으로, IncreaseAction
과 DecreaseAction
이라는 두 개의 타입을 정의한다. 이 두 타입은 Redux 액션 객체에 대한 인터페이스 역할을 한다. 각 타입은 type 속성을 가지고 있으며, INCREASE와 DECREASE 상수 값 중 하나로 설정된다.
그리고 ActionType
이라는 타입을 정의한다. 이는 IncreaseAction과 DecreaseAction을 유니온 타입으로 묶어준 것이다. 이렇게 하면 나중에 리듀서 함수에서 ActionType으로 받아온 액션 객체의 type 속성을 검사하여, 해당하는 액션을 처리할 수 있다.
- 액션 생성 함수 만들기
액션 타입을 정의한 뒤에는 액션 생성 함수를 만들어 주어야 한다. counter 모듈 아래에 계속 작성하자.
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });
이제 increase
와 decrease
라는 두 개의 함수를 정의한다. 이 함수들은 Redux 액션 객체를 생성하며, increase 함수는 { type: INCREASE }
형태의 액션 객체를, decrease 함수는 { type: DECREASE }
형태의 액션 객체를 생성한다.
- 초기 상태 및 리듀서 함수 만들기
counter 모듈의 초기 상태와 리듀서 함수를 정의해주자. counter모듈 아래에 계속 작성해준다.
interface InitialStateType {
number: number;
}
const initialState: InitialStateType = {
number: 0,
};
export default function counter(
state: InitialStateType = initialState,
action: ActionType
) {
switch (action.type) {
case INCREASE:
return {
number: state.number + 1,
};
case DECREASE:
return {
number: state.number - 1,
};
default:
return state;
}
}
여기서는 InitialStateType
이라는 인터페이스를 정의했다. 이 인터페이스는 리덕스 스토어의 초기 상태 값의 타입을 정의한다. 이 경우에는 number 속성이 하나 있으며, number 속성의 초기값은 0으로 정의된다.
그리고, counter
라는 리듀서 함수를 정의했다. 이 함수는 state
와 action
두 개의 매개변수를 받는다. state는 스토어에 저장된 상태 값이고, action은 디스패치된 액션 객체이다.
리듀서 함수 내부에서는 switch 문을 사용하여 action.type
값에 따라 다른 처리를 수행한다. 만약 action.type이 INCREASE
이면, state 객체를 복제하여 number 속성을 1 증가시킨 후, 해당 객체를 반환한다. 만약 action.type이 DECREASE
이면, state 객체를 복제하여 Number 속성을 1 감소시킨 후, 해당 객체를 반환한다. action.type이 INCREASE나 DECREASE가 아닌 경우에는 state 객체를 그대로 반환한다.
todos 모듈 만들기
이번에는 todos 모듈을 만들어보자. modules 디렉토리에 todos.tsx 파일을 생성해주었다.
- 액션 타입 정의하기
// src/modules/todos.tsx
type ChangeInputAction = {
type: typeof CHANGE_INPUT;
payload: {
input: string;
};
};
type InsertAction = {
type: typeof INSERT;
payload: {
todo: {
id: number;
text: string;
done: boolean;
};
};
};
type ToggleAction = {
type: typeof TOGGLE;
payload: {
id: number;
};
};
type RemoveAction = {
type: typeof REMOVE;
payload: {
id: number;
};
};
type ActionType =
| ChangeInputAction
| InsertAction
| ToggleAction
| RemoveAction;
- 액션 생성 함수 만들기
counter 모듈과는 달리 todos 모듈의 액션 생성 함수는 파라미터가 필요하다. 전달받은 파라미터는 액션 객체 안에 추가 필드로 들어가게 된다.
export const changeInput = (input: string): ChangeInputAction => ({
type: CHANGE_INPUT,
payload: {
input,
},
});
let id: number = 3;
export const insert = (text: string): InsertAction => ({
type: INSERT,
payload: {
todo: {
id: id++,
text,
done: false,
},
},
});
export const toggle = (id: number): ToggleAction => ({
type: TOGGLE,
payload: {
id,
},
});
export const remove = (id: number): RemoveAction => ({
type: REMOVE,
payload: {
id,
},
});
- 초기 상태 및 리듀서 함수 만들기
이번에는 업데이트 방식이 조금 까다로워진다. 객체에 한 개 이상의 갓이 들어가므로 불변성을 유지해주어야 하기 때문이다. spread 연산자를 활용해서 작성한다.
interface InitialStateType {
input: string;
todos: {
id: number;
text: string;
done: boolean;
}[];
}
const initialState: InitialStateType = {
input: "",
todos: [
{
id: 1,
text: "리덕스 기초",
done: true,
},
{
id: 2,
text: "리액트 타입스크립트",
done: false,
},
],
};
export default function todos(
state: InitialStateType = initialState,
action: ActionType
) {
switch (action.type) {
case CHANGE_INPUT:
return {
...state,
input: action.payload.input,
};
case INSERT:
return {
...state,
todos: state.todos.concat(action.payload.todo),
};
case TOGGLE:
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id ? { ...todo, done: !todo.done } : todo
),
};
case REMOVE:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
return state;
}
}
- 코드 정리
우선, 액션 타입(action type) 상수들이 선언되었다. 액션 타입은 액션 객체의 type 프로퍼티 값으로 사용되며, 리듀서(reducer)에서 해당 액션에 따라 상태를 업데이트할 때 사용된다.
그 다음으로는 액션 객체 타입들이 정의된다. 각각의 타입은 액션 객체의 타입, 그리고 필요한 값들을 가지고 있다.
ChangeInputAction은 input 값을 변경하는 액션을 정의하고, InsertAction은 새로운 할 일을 추가하는 액션을 정의하며, ToggleAction은 할 일의 완료 여부를 토글하는 액션을 정의하며, RemoveAction은 할 일을 제거하는 액션을 정의한다.
그 다음으로는 ActionType 타입이 정의된다. 이는 ChangeInputAction, InsertAction, ToggleAction, RemoveAction 타입을 모두 포함하는 유니온 타입(union type)이며, 이 유니온 타입은 리듀서에서 사용될 수 있는 모든 액션 객체들을 정의한다.
그 다음으로는 각각의 액션 생성자 함수들이 정의된다. changeInput 함수는 input 값을 받아와서, ChangeInputAction 객체를 생성하여 반환한다. insert 함수는 todo의 text 값을 받아와서, InsertAction 객체를 생성하여 반환한다. toggle 함수는 할 일의 id 값을 받아와서, ToggleAction 객체를 생성하여 반환한다. 마지막으로, remove 함수는 할 일의 id 값을 받아와서, RemoveAction 객체를 생성하여 반환한다.
이후, initialState 객체가 선언되고, 이 객체는 어플리케이션의 초기 상태를 나타냅니다. 위 코드에서는 input 값이 빈 문자열("")이며, 두 개의 todo가 초기 상태로 포함되어 있다.
마지막으로, todos 리듀서가 정의된다. 이 리듀서는 initialState 객체를 초기 상태로 가지며, ActionType 타입의 액션 객체를 받아서, switch문을 통해 액션에 따라 상태를 업데이트한다. CHANGE_INPUT 액션은 input 값을 변경하며, INSERT 액션은 새로운 할 일을 추가하며, TOGGLE 액션은 할 일의 완료 여부를 토글하며, REMOVE 액션은 할 일을 제거한다. 각각의 액션에 대해 새로운 상태를 반환한다.
루트 리듀서 생성
이 프로젝트에서는 두 개 이상의 리듀서를 만들었다. 향후 createStore 함수를 사용해 스토어를 만들 때에는 리듀서를 하나만 사용해야 한다. 그렇기 때문에 기존에 만들었던 리듀서를 합쳐주어야 하는데, 이 작업은 리덕스에서 제공하는 combineReducers라는 함수를 사용하여 처리할 수 있다.
modules 디렉토리에 index.tsx 파일을 만들고, 다음 코드를 작성하자.
import { combineReducers } from "redux";
import counter from "./counter";
import todos from "./todos";
export const rootReducer = combineReducers({
counter,
todos,
});
파일 이름을 index로 설정한다면 나중에 불러올 때 다음과 같이 디렉토리 이름만 경로 설정한다면 불러올 수 있다.
import rootReducer from './modules'
리액트에 리덕스 적용하기
이제 리액트에 리덕스를 적용해보자.
스토어 만들기
// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { legacy_createStore as createStore } from "redux";
import { rootReducer } from "./modules";
const store = createStore(rootReducer);
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
reportWebVitals();
Provider 컴포넌트를 사용한 리덕스 적용
리액트 컴포넌트에서 스토어를 사용할 수 있도록 App 컴포넌트를 리덕스에서 제공하는 Provider 컴포넌트로 감싸준다. 이 컴포넌트를 사용할 때 store를 props로 전달해주어야 한다.
// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { legacy_createStore as createStore } from "redux";
import { rootReducer } from "./modules";
import { Provider } from "react-redux";
const store = createStore(rootReducer);
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
Redux DevTools 설치 및 적용
Redux DevTools는 크롬 확장 프로그램으로 설치해 사용할 수 있다.
확장 프로그램 설치 후, 개발환경에서 패키지를 하나 설치해주자.
npm install redux-devtools-extension
// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { legacy_createStore as createStore } from "redux";
import { rootReducer } from "./modules";
import { Provider } from "react-redux";
import { composeWithDevTools } from "redux-devtools-extension";
const store = createStore(rootReducer, composeWithDevTools());
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
reportWebVitals();
적용 후, 렌더링 한 뒤, 크롬 개발자 도구 실행 후 Redux 탭을 열어보자.
현재 리덕스 스토어 내부의 상태를 확인할 수 있다.
길어져서 다음에 ..
'WEB > React' 카테고리의 다른 글
리액트 리덕스 상태관리 - 2 [react/redux/typescript] (0) | 2023.03.24 |
---|---|
리액트 Context API [react/typescript] (0) | 2023.03.21 |
리액트 외부 API 연동 실습 [react/API/typescript] (0) | 2023.03.20 |
리액트 라우터 써보기 [react/router/typescript] (0) | 2023.03.19 |
리액트 불변성 유지하기 [react/immer/typescript] (0) | 2023.03.18 |