안녕하세요, 씨앤텍시스템즈 이나연 연구원입니다.
지난 글에서는 리스트 스타일링까지 진행했습니다. 이번 글에서는 redux에 대해 기본적인 내용을 살펴본 후, Redux를 사용하여 리스트의 데이터를 store에 저장하여 사용해 보겠습니다.
이어지는 글
React 2. Emotion을 이용한 React component 스타일링 (with. CSS-in-JS)
React 3. Redux, Redux Toolkit - Global UI Component 만들기
목차
- Redux 시작하기
- Redux
- React Redux
- Redux Toolkit
- Redux를 사용하여 global component list 만들기
- 수정 버튼 추가
- 모달 컴포넌트 생성
- 슬라이스 작성
- 스토어 연결
- 마무리
1. Redux 시작하기
1-1. Redux
첫 번째로, Redux는 대표적으로 어떤 문제를 해결하기 위해 생겨났는지, Redux를 사용하여 어떠한 것들이 가능한지 한 번 함께 살펴보겠습니다.
먼저, Redux의 생겨나고 우리가 상태 관리 도구를 사용하게 되는 이유를 살펴보겠습니다.
우리가 자바스크립트 애플리케이션을 만드는 과정에서 생각해 봤을 때, state(상태 값)는 특정 시점의 애플리케이션의 상태를 설명하고, 이 상태에 따라 UI가 렌더링 됩니다. 사용자가 클릭 이벤트를 발생시키는 등의 이벤트 발생 시 그에 따른 상태가 업데이트되고 업데이트된 새로운 상태에 따라 UI가 다시 렌더링 됩니다. 아래 이미지와 같이 데이터 흐름을 설명할 수 있습니다.
단순한 기능만을 하는 애플리케이션이라면 하나의 고정된 컴포넌트에서 지정된 이벤트만 따라 데이터가 업데이트되고 UI가 업데이트될 것입니다. 버튼을 클릭하면 count 값이 1씩 증가하는 단순한 카운터를 예로 들 수 있습니다.
하지만 전혀 다른 위치에 있는 다수의 컴포넌트에서 같은 상태를 업데이트할 필요가 있는 등 애플리케이션의 복잡도가 조금만 오른 후 같은 방법으로 상태를 업데이트하고 사용하려고 할 때, 이 단순한 데이터 흐름이 깨지게 됩니다. 상태 관리 도구는 이렇게 발생하는 복잡함을 해결하기 위해 사용한다고 말할 수 있습니다.
공식 홈페이지에서는 자바스크립트 애플리케이션을 위한 예측가능한 상태 저장소라고 요약하고 있습니다. 흔히 말하는 상태관리도구라는 말은, 단어 그대로 어떠한 상태를 관리할 수 있게 한다는 것인데, 이 설명에 보태어 설명하면, 자바스크립트 언어를 사용하여 상태를 예측 가능하도록 관리할 수 있는 도구라고도 할 수 있습니다.
상태를 예측 가능하도록 한다는 것은 변칙적인 예외 상황이 발생하는 것을 줄이고 일관되게 작동하도록 하는 것을 말합니다.
It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
- https://redux.js.org/introduction/getting-started
Redux는 기본적으로 스토어에 상태 정보를 저장하고, action요청을 보내 스토어에 있는 값을 업데이트하여 사용합니다. 예를 들어 React.useState와 비교했을 때, useState는 React 컴포넌트 내에서 사용되어 [state, setState]를 생성하고 props 상속을 통해서만 컴포넌트 간 상태 공유가 가능했다면, Redux의 스토어는 상태 값을 글로벌로 접근할 수 있는 방법을 주며 돕는다고 설명할 수 있습니다.
이외에도 Redux와 같은 상태 관리 도구를 사용하며 해결되는 문제들이 있는데, 아래 주요 개념들과 함께 살펴보겠습니다.
store
store
(스토어)는 아래와 같은 object 형태로 상태 정보를 가지고 있는 상태 값 저장소입니다.
{
todos: [{
text: 'Eat lunch',
completed: true
}, {
text: 'Exercise',
completed: false
}],
visibilityFilter: 'SHOW_COMPLETED'
}
그리고 이 스토어는 createStore
를 이용하여 다음과 같이 생성합니다.
// createStore(reducer, [preloadedState], [enhancer])
const store = createStore(todos, ['Use Redux']);
이렇게 생성된 스토어는 일반적인 자바스크립트 객체와는 달리 직접 수정하거나 상태 값을 변경할 수 없습니다. 상태를 변경하기 위해서는 action
을 전달하는 방법밖에 없습니다. 스토어의 상태 변경 사항을 구독하여 UI 업데이트할 수도 있습니다.
action
action
자체는 어떠한 변화를 줄 것인지를 구별하는 type
과 action에 함께 사용되는 payload 데이터들을 가지고 있는 평범한 객체로, 상태 값을 어떻게 변경할지에 대한 정보를 가지고 있습니다.
// { type: ACTION_TYPE, ...payload }
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }
subscribe
스토어의 내장 함수 중 하나로, 함수 형태의 값을 파라미터로 받아, action
이 전달되었을 때마다 subscribe
함수로 전달된 함수 호출하고 구독을 취소하는 함수를 반환합니다.
// example,
import store from './store'
const unsubscribe = store.subscribe(() =>
console.log('State after dispatch: ', store.getState())
)
unsubscribe()
dispatching actions
action
을 발생시키는 dispatch
함수도 마찬가지로 스토어에 내장되어 있습니다. 아래와 같이 사용합니다.
// example,
import store from './store'
store.dispatch({
type: 'ADD_TODO',
text: 'make todo list with redux'
});
dispatch
함수에 action
을 파라미터로 전달하여 호출하면, 스토어가 리듀서 함수를 실행하고, action
처리 로직에 따라 상태 값을 업데이트합니다.
reducer function
리듀서는 아래 코드와 같이 사용하여, state
와 action
을 파라미터로 받아서 action type
에 따라 변화된 새 상태 값을 계산하여 반환합니다. combineReducers
를 사용하여 여러 개의 리듀서를 합쳐 루트 리듀서를 만들 수도 있습니다.
function visibilityFilter(state = 'SHOW_ALL', action) {
if (action.type === 'SET_VISIBILITY_FILTER') {
return action.filter
} else {
return state
}
}
function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return state.concat([{ text: action.text, completed: false }])
case 'TOGGLE_TODO':
return state.map((todo, index) =>
action.index === index
? { text: todo.text, completed: !todo.completed }
: todo
)
default:
return state
}
}
위와 같이 리듀서는 action type
에 따라 상태 값을 변화시키는데, 반대로 말하면 리듀서에서 정의해놓은 action type
과 payload 데이터가 모두 일치하지 않으면 오류가 나게 됩니다. 이렇게 상태 값 관리는 reducer
에서만 수행하기 때문에 각 컴포넌트에서 상태를 잘못 변경하더라도 에러를 추적하기 용이합니다.
They must be pure functions—functions that return the exact same output for given inputs. They should also be free of side-effects.
위와 같은 설명에 따르면, Redux에서의 리듀서는 순수 함수이어야 하는데, 주어진 input에 대해 동일한 출력을 반환해야 하고 이외의 동작이 실행되어서는 안됩니다.
예시 상황을 가정했을 때, html, css, javascript을 처음 배우고 세 가지만을 이용하여 UI를 구현하다보면, 아래와 같은 코드를 쓰게 될때가 생각보다 많습니다.
// example,
document.querySelector(".call_to_action_btn").addEventListener("click", function(data) {
const user = getUserInfo(data);
...
MODAL.style.display = "block";
MODAL.querySelector(".title").innerText = `${user.name} just call to action!`;
...
});
어떠한 액션을 호출하는 버튼을 클릭했을 때, 임의의 데이터를 가지고 사용자의 정보를 가져오고, 가져온 정보를 모달에 추가하여 모달을 화면에 띄워주는 일들을 하나의 콜백에서 모두 처리하고 있습니다.
하지만 리듀서를 순수 함수로 만들어 상태 변화만 관리하도록 하고, UI 변경 등을 discribe
를 이용하여 담당하게 하면, 상태 변화만을 담당하는 코드와 상태에 따라 UI 등을 변경하게 하는 코드를 따로 분리하여 관리하게 됩니다. 이러한 방식으로 스토어에서 상태 값만을 관리하다 보면, Redux 개발도구나 redux-undo 라이브러리를 사용하여 상태 시간 여행을 보다 쉽게 할 수 있는 바탕이 만들어집니다.
1-2. React Redux
두 번째로, Redux 자체는 React에 종속적이지 않기 때문에 React와 함께 사용할 때 발생하는 어려움을 해결하기 위해 React 프로젝트에서는 Redux와 React를 바인딩 하기 위해 React Redux 패키지를 함께 사용합니다.
<Provider />
React Redux는 <Provider />
컴포넌트를 가지고 있는데, 밑에서 소개할 훅을 사용하여 컴포넌트 내부에서 스토어를 사용할 수 있도록 합니다.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './store'
import App from './views/App'
ReactDOM
.createRoot(document.getElementById('root'))
.render(
<Provider store={store}>
<App />
</Provider>
)
hooks: useSelector, useDispatch
먼저 두 훅을 코드로 설명하자면 다음과 같습니다.
const result: any = useSelector(selector: Function, equalityFn?: Function)
const dispatch: Function = useDispatch()
useSelector
는 selector 함수를 통해 어떤 상태 값을 가져올지 전달하고, 아래와 같이 사용하여 Redux의 store
에서 상태를 가져옵니다.
function TodoList() {
const todos = useSelector((state) => state.todos);
...
}
useDispatch
는 store
에서 디스패치 함수를 반환하므로 다음과 같이 사용합니다.
function TextField() {
const [content, setContent] = useState('');
const dispatch = useDispatch();
return (
<form>
<input value={content} onChange={(event) => setContent(event.target.value)} required />
<button type="submit" onClick={() => dispatch({ type: 'ADD_TODO', text: content })}>
add todo item
</button>
</form>
)
}
1-3. Redux Toolkit
마지막으로 Redux Toolkit은 글을 작성하는 현재(2022.07.20), Redux 로직을 작성하는 표준 방법으로 소개하고 있습니다. 그렇기 때문에 Redux 패키지에서 createStore를 사용하려고 할 때 아래 이미지처럼 createStore
대신 @reduxjs/toolkit
에서 configureStore
를 사용하는 것을 권장하고 있습니다.
공식 문서에서는 1️⃣ 기존의 Redux store는 구성하기 너무 복잡하고, 2️⃣ Redux가 유용한 작업을 수행하기 위해서 많은 패키지가 추가로 필요하고, 3️⃣ Redux를 표준적으로 사용하기 위한 코드가 너무 많은 문제를 해결하기 위해 개발되었다고 함께 소개하고 있습니다.
간단히 말하자면, 앱의 규모가 커짐에 따라 store
와 action
함수 관리에 복잡도를 낮추기 위해 만들어졌다고 할 수 있습니다.
기존의 Redux를 사용할 때에는 중복되는 action type
의 이름에 접두사를 추가하여 action type
을 정의하고 combineReducers
를 이용하여 하나의 스토어를 만들었습니다.
// app/module/todos.js
export const TODO_ADDED = 'todos/Added'
export const TODO_TOGGLED = 'todos/Toggled'
export const STATUS_FILTER_CHANGED = 'filters/statusFilterChanged'
export const TOAST_ADDED = 'toast/Added'
export const TOAST_CLOSED = 'toast/Closed'
export const TOAST_REMOVE = 'toast/Romoved'
...
// app/module/todos.js
export const todoAdded = (text) => ({ type: TODO_ADDED, payload: text })
export const todoToggled = (id) => ({ type: TODO_TOGGLED, payload: id })
export const statusFilterChanged = (id) => ({ type: STATUS_FILTER_CHANGED, payload: id })
export default function todos(state = initialState, action) {
switch (action.type) {
case TODO_ADDED:
return [
...state,
{
id: nextId,
text: action.text
}
];
...
}
}
// app/module/index.js
import { combineReducers } from 'redux'
import todos from './todos'
...
export default combineReducers({
todos,
...
})
// app/index.js
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import reducers from './module'
const store = createStore(reducer)
root.render(
<Provider store={store}>
<App />
</Provider>
)
다음은 위 코드를 Redux Toolkit을 이용하여 수정한 예시입니다.
// app/store/todoSlice.js
import { createSlice } from '@reduxjs/toolkit'
export const todoSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
add: (state, action) => { state.assign({ id: nextId, text: action.payload.text }) },
...
}
})
export default todoSlice.reducer //todoReducer
// app/store/index.js
import { configureStore } from '@reduxjs/toolkit'
import todoReducer from './todoSlice'
...
export const store = configrueStore({
reducer: {
todoReducer,
...
}
})
// app/index.js
import { Provider } from 'react-redux'
import store from './store'
root.render(
<Provider store={store}>
<App />
</Provider>
)
Redux Tollkit을 사용하여 자동으로 각 리듀서에 접두사를 자동으로 만들어 구분하고, reducers
에서 정의한 각 액션 함수 내에서 상태 값에 대한 불변성을 자동으로 유지해 주고 있습니다.
기존에 Redux를 사용할 때에는 상태에 대한 불변성(Immutability)을 위해 리듀서에서 return { ...state, id: newId }
와 같이 새로운 상태를 반환했지만, Redux Toolkit을 사용할 때에는 Immer 라이브러리를 자동으로 사용하게 되므로 state.id = newId
와 같이 새로운 값을 상태에 바로 할당할 수 있습니다.
createSlice
Redux Toolkit은 애플리케이션에서 각 기능별로 나누어진 reducer를 슬라이스(slice)라고 이름 붙이고, createSlice를 이용하여 각 reducer slice를 생성하도록 합니다.
createSlice는 슬라이스 이름, 초기 상태, reducer 함수 객체를 파라미터로 받아 자동으로 action creator와 action type을 생성합니다.
// Parameters
function createSlice({
// action type으로 사용될 이름
name: string,
// reducer의 초기값
initialState: any,
// "case reducers" 객체의 각 key 값을 액션의 이름으로 사용
reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
// "builder callback" 함수는 더 많은 reducers나 추가적인 "case reducers" 객체를 추가하기 위해 사용하며,
// key 값은 다른 action type이어야 함.
extraReducers?:
| Object<string, ReducerFunction>
| ((builder: ActionReducerMapBuilder<State>) => void)
})
이렇게 생성된 슬라이스는 다음과 같은 형태로 슬라이스를 만들어 반환합니다.
// Return Value
{
name : string,
reducer : ReducerFunction,
actions : Record<string, ActionCreator>,
caseReducers: Record<string, CaseReducer>.
getInitialState: () => State
}
configureStore
configureStore
는 필수로 options에서 reducer를 받습니다. 이 리듀서가 단일 함수일 경우 store의 루트 리듀서로 적용되고, 객체 형태의 slice reducers일 경우 자동으로 Redux의 combineReducers 유틸리티에 전달하여 루트 리듀서를 만듭니다.
// Parameters
function configureStore({
reducer: Reducer<any, AnyAction> | ReducerMapObject<any, AnyAction>
...
})
이 외에도 middleware
, devTools
, preloadedState
, enhancers
를 options 안에 가질 수 있습니다.
2. Redux를 사용하여 global component list 만들기
마찬가지로 이전에 스타일링한 List
화면을 활용해 보겠습니다.
이 컴포넌트를 수정하려면 어떻게 해야 할까요? 각 리스트마다 수정 버튼을 추가하거나 더블클릭 등의 이벤트에 따라 수정 컴포넌트로 전환할 수도 있겠지만, 이번 글에서는 Redux Toolkit을 활용하여 리스트 수정 모달을 띄워 상태를 목록 전체의 정보를 수정할 수 있도록 해보겠습니다.
2-1. 수정 버튼 추가
먼저 리스트에 ActionWrapper
를 복사하여 수정 버튼을 추가합니다.
// src/view/ListWrapper.js
export default function ListWrapper() {
...
const handleOpenEditModal = () => {};
return (
<OuterWrapper>
...
<ActionWrapper>
<button className="editBtn" onClick={handleOpenEditModal}>
Edit list content
</button>
</ActionWrapper>
</OuterWrapper>
);
}
2-2. 모달 컴포넌트 생성
수정 버튼을 추가했다면, 버튼을 클릭했을 때 등장할 글로벌 모달 컴포넌트를 생성하여 스타일을 추가하고, 모달에서 버튼을 클릭하면 모달이 사라지도록 하는 핸들러 함수를 임시로 전달합니다.
// src/components/ModalWrapper.js
import React from "react";
import styled from "@emotion/styled";
const Dialog = styled.dialog`
...
`;
export default function ModalWrapper() {
const [open, setOpen] = React.useState(false);
const handleModalHide = () => setOpen(false);
const list = (
<ul>
<li>
<input />
</li>
</ul>
);
return (
<Dialog open={open}>
<form method="dialog">
{list}
<div className="modal-btns">
<button
onClick={handleModalHide}
className="modal-btns__cancel"
>
Cancel
</button>
<button
type="submit"
onClick={handleModalHide}
className="modal-btns__submit"
>
Edit
</button>
</div>
</form>
</Dialog>
);
}
2-3. 슬라이스 작성
먼저 모달의 open
속성을 제어할 modalSlice
를 다음과 같이 작성합니다. 현재는 리스트를 수정할 폼을 출력하기만 할 뿐이기 때문에 상태 값으로 isOpen
속성 하나만 가집니다.
// src/store/modalSlice.js
import { createSlice } from "@reduxjs/toolkit";
const modalSlice = createSlice({
name: "modal",
initialState: {
isOpen: false
},
reducers: {
show: (state) => {
state.isOpen = true;
},
hide: (state) => {
state.isOpen = false;
}
}
});
export default modalSlice.reducer;
export const { show, hide } = modalSlice.actions;
리스트의 값을 전역적으로 사용해야 하기 때문에 listSlice
도 작성합니다.
// src/store/listSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
items: [
{ id: 0, text: "0" },
{ id: 1, text: "1" },
{ id: 2, text: "2" },
{ id: 3, text: "3" }
],
orderedAsc: true
};
const listSlice = createSlice({
name: "list",
initialState,
reducers: {
addOne: (state) => {
state.items.push({
id: state.items.length,
text: String(state.items.length)
});
},
removeOne: (state) => {
const maxIdNum = state.items.reduce(
(max, cur) => Math.max(max, cur.id),
-Infinity
);
state.items = state.items.filter((item) => item.id !== maxIdNum);
},
toggleOrder: (state) => {
if (state.orderedAsc) {
state.items = state.items.sort((a, b) => b.id - a.id);
} else {
state.items = state.items.sort((a, b) => a.id - b.id);
}
state.orderedAsc = !state.orderedAsc;
},
editList: (state, action) => {
state.items = action.payload.items;
}
}
});
export default listSlice.reducer;
export const { addOne, removeOne, toggleOrder, editList } = listSlice.actions;
2-4. 스토어 연결
스토어를 UI 컴포넌트에서 사용하기 위해 가장 먼저 <App />
을 <Provider />
로 감싼 후 위에서 작성한 store
를 전달합니다.
// src/index.js
import { Provider } from 'react-redux'
...
import store from './store'
...
root.render(
<Provider store={store}>
<App />
</Provider>
)
store를 앱 내에서 사용할 수 있게 되었다면, ListWrapper
에서 리스트 액션과 모달 액션을 가져와 각 이벤트에 맞게 액션을 디스패치합니다.
// src/view/ListWrapper.js
import { useDispatch, useSelector } from "react-redux";
import { addOne, removeOne, toggleOrder } from "../store/listSlice";
import { show } from "../store/modalSlice";
export default function ListWrapper() {
const dispatch = useDispatch();
const { items } = useSelector((state) => state.list);
const handleAddOneItem = () => dispatch(addOne());
const handleRemoveOneItem = () => dispatch(removeOne());
const handleToggleOrder = () => dispatch(toggleOrder());
const handleOpenEditModal = () => dispatch(show());
...
}
마지막으로 ModalWrapper
에서도 isOpen
값과 list의 items
값을 가져와서 사용할 수 있도록 다음과 같이 훅을 먼저 작성합니다.
// src/components/useModalWrapper.js
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { hide, show } from "../store/modalSlice";
export const useModal = () => ({
modalOpen: useSelector((state) => state.modal.isOpen),
listItems: useSelector((state) => state.list.items)
});
export const useShowModal = () => {
const dispatch = useDispatch();
return useCallback(() => dispatch(show()), [dispatch]);
};
export const useUnmountModal = () => {
const dispatch = useDispatch();
return {
handleUnmount: useCallback(() => dispatch(hide()), [dispatch])
};
};
그 후 ModalWrapper
에서 방금 작성한 훅을 사용하여 리스트 아이템을 수정할 수 있는 모달을 만듭니다.
// src/components/ModalWrapper.js
import { useDispatch, useSelector } from "react-redux";
import { hide } from "../store/modalSlice";
import { ListItemForm } from "./List";
...
export default function ModalWrapper() {
///
const list = (
<ul>
{items.map((item) => (
<ListItemForm
key={item.id}
id={item.id}
value={item.text}
readOnly={false}
onChange={handleChangeList}
/>
))}
</ul>
);
return (
<Dialog open={modalOpen}>
...
</Dialog>
);
}
ModalWrapper
를 모두 작성했다면 App
내부에 Wrappper를 추가합니다.
// src/App.js
...
import { useModal } from "./components/useModalWrapper";
export default function App() {
const { modalOpen } = useModal();
return (
<div
className={css`
height: 100vh;
`}
>
<ListWrapper />
{modalOpen && <ModalWrapper />}
</div>
);
}
*(참고)전체 코드 https://codesandbox.io/s/component-with-redux-store-e4bx4n
3. 마무리
지금까지 Redux와 React Redux, Redux Toolkit에 대한 기본적인 내용을 훑어보고, React Redux와 Redux Toolkit을 이용하여 React 애플리케이션에서 글로벌 컴포넌트를 만들어보기까지 했습니다. 이 외의 내용들은 공식 문서에 잘 나와있으니 언제나처럼 공식 문서를 제일 먼저 참고하시기 바랍니다.
마지막으로, 모든 상황에 Redux가 필요한 것은 아니므로 Redux를 애플리케이션 개발에 도입하기 이전에 읽어보면 좋을, 공식 문서에서 안내하고 있는 세 개의 글을 공유드립니다.
(참고한 자료)
- Redux
- Core Concepts : https://redux.js.org/introduction/core-concepts
- Glossary : https://redux.js.org/understanding/thinking-in-redux/glossary
- Concepts and Data Flow : https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow
- Immutable Data in Redux : https://redux.js.org/faq/immutable-data
- React Redux
- Getting Started#API Overview : https://react-redux.js.org/introduction/getting-started#api-overview
- Why Use React Redux? : https://react-redux.js.org/introduction/why-use-react-redux
- Redux Toolkit
- Getting Started#API Overview : https://react-redux.js.org/introduction/getting-started#api-overview
- Usage Guide : https://redux-toolkit.js.org/usage/usage-guide
- The new wave of React state management : https://frontendmastery.com/posts/the-new-wave-of-react-state-management/
- Pure function - Wikipedia : https://en.wikipedia.org/wiki/Pure_function
- Build a Global Dialog Modal Infrastructure With React Hooks and Redux Hooks : https://betterprogramming.pub/global-dialog-modal-infrastructure-with-react-hooks-and-redux-hooks-1b6bedd052a6
- Creating a centralized modal in React with Redux : https://andremonteiro.pt/react-redux-modal/
'Web Programming > React' 카테고리의 다른 글
RTK Query를 이용하여 데이터 최신으로 관리하기(& Redux Toolkit) (0) | 2023.05.02 |
---|---|
React의 시작 (0) | 2022.09.30 |
React 2. Emotion를 이용한 React component 스타일링 (with. CSS-in-JS) (0) | 2022.07.12 |
React 1. Component 렌더링 (0) | 2022.07.04 |
React 란? (0) | 2021.12.28 |