5월부터 참여하고 있는 프로젝트는 S 보험사에 납품되는 C/S Application의 커스터마이징 작업을 수행하는 프로젝트입니다. 그러나 어제 대상포진 발생으로 인해 긴급 휴가를 내야 했습니다. 오늘은 휴가를 내고 병원에 가서 치료를 받으며, 일보의 후퇴의 마음으로 이번 기회에 '계약서 관리 시스템' 프로젝트 코드를 회고하는 시간을 가지려고 합니다.
저는 지금까지의 저의 블로그를 통해 '계약서 관리 시스템' 의 프론트엔드 개발자로서 사용성 관점에서 UI 개선 작업에 대한 회고를 작성했습니다. 이번에는 코드적인 리뷰를 통해 개선해본 사항들을 정리하고자 합니다. 특히, 이번 주제에서는 useReducer를 활용한 깔끔한 상태 관리 방법에 대해 다루어 보려고 합니다. 이는 계속해서 하게 될 프로젝트의 효율성을 높이고 유지보수성을 개선할 수 있도록 노력하기 위해서 입니다.
지금까지 많은 구글링을 통해 접한 다른 분들의 지식에 저의 경험이 녹아든 지식을 공유할 수 있는 시간을 가질 수 있어 의미있게 생각합니다. 다소의 주절거림이 있을 수 있지만 정리와 회고, 공유를 통한 제 자신과 타인의 성장을 믿습니다.
React에서 useState를 활용한 컴포넌트의 상태 관리는 매우 유용한 기능입니다. 개념이 단순하고 사용법이 간단하기 때문에 많은 개발자들이 쉽게 접근하고 사용하고 있습니다. useState를 사용하면 컴포넌트에서 동적인 데이터를 쉽게 다룰 수 있으며, 이를 통해 컴포넌트의 사용성을 개선할 수 있습니다.
그러나 프로덕트 개선으로 기능이 추가되거나 프로젝트의 규모가 커지면서 프로젝트와 컴포넌트 내에서 관리해야 할 상태값이 많아지면 컴포넌트 하나에만 수십개의 상태값이 선언되게 됩니다. 상태값이 너무 많으면 코드의 가독성도 떨어지고 나중에 유지보수성에도 어려워지게 됩니다.
제가 useState을 사용하면서 자주 사용하게 되면서 마주한 문제들은 크게 2가지로 구분됩니다. 첫 번째는 관리해야 할 상태값이 많아지는 경우입니다. 상태 관리 로직이 컴포넌트 내부에서 복잡해지면 코드의 가독성과 유지보수성이 저하되는 문제가 발생합니다. 또한 같은 컴포넌트를 여러 명의 개발자가 작업해야 할 경우, 해당 컴포넌트의 내부 로직을 파악하는데 시간이 많이 소요되어 개발 프로세스가 더디게 진행될 수 있습니다.
따라서 대규모 애플리케이션에서는 상태 관리를 위해 useState 대신 useReducer나 Redux와 같은 상태 관리 라이브러리를 사용하는 것이 좋습니다. 이를 통해 상태 관리 로직을 모듈화하여 코드의 재사용성을 높일 수 있고, 다른 개발자들과의 협업이 수월해지며, 유지보수성을 높일 수 있습니다. useReducer를 사용하면 컴포넌트 내부에서 복잡한 상태 관리 로직을 분리하여 깔끔하게 관리할 수 있습니다.
두 번째는 컴포넌트 간의 상태 공유가 필요한 경우입니다. 이 경우 useState를 사용하면 각각의 컴포넌트에서 상태를 관리해야 하기 때문에 상태 관리가 복잡해집니다. 이럴 경우에는 useContext를 사용하여 전역 상태를 관리하거나 Redux와 같은 상태 관리 라이브러리를 사용하는 것이 좋습니다.
useState는 매우 유용한 기능이지만 useReducer나 다른 상태 관리 기능을 함께 사용하여 코드의 깔끔함과 유지보수성을 높일 수 있습니다. 우리 팀에서는 이러한 이유로 useReducer와 같은 기능을 도입하기 전에 이를 도입하는 이유와 효과 등을 고민하고 나누는 시간을 가졌습니다.
제가 작업한 'OCR 보정 페이지' 컴포넌트를 useState에서 useReducer로 전환한 사례를 구체적으로 살펴보겠습니다. 이 페이지는 OCR 솔루션으로 추출하여 DB에 저장된 텍스트를 화면에서 직접 조회하고 수정하여 DB에 업데이트할 수 있는 기능을 제공합니다.
OcrMain.js 페이지는 컨테이너 컴포넌트로 문서가 속해 있는 그룹, 페이지, 수정에 관한 정보들을 상태값으로 관리합니다. 여기서는 상태의 세세한 정의 보다는 useReducer 사용 전후를 비교하여 간단하게 사용법에 대한 이해를 돕도록 하겠습니다.
- useReducer 사용 전
OcrMain.js
export default function OcrMain() {
const [groupList, setGroupList] = usState([]);
const [groupCode, setGroupCode] = useState("");
const [fileList, setFileList] = useState([]);
const [docCode, setDocCode] = useState("");
const [pageCode, setPageCode] = useState("");
const [pageIndex, setPageIndex] = useState(0);
const [file_info, setFileInfo] = useState([]);
const [page_info, setPageInfo] = useState({ CONTEXT: ' '});
const [amendTimeList, setAmendTimeList] = useState([]);
- useReducer 사용 후
- 1차 적용: 한 줄로 모든 상태값을 선언
OcrMain.js
export default function OcrMain() {
const [state, dispatch] = useReducer(reducer, initialState);
const { gropuList, groupCode, fileList, fileInfo, pageInfo,
pageCode, pageIndex, docCode, amendTimeList } = state;
- 2차 적용: 상태값을 정보 구조에 맞춰서 state.group, state.file, state.page 로 구분하여 선언
// OcrMain.js
import { useReducer } from "react";
import { initialState, reducer } from "./OcrReducer";
export default function OcrMain() {
const [state, dispatch] = useReducer(reducer, initialState);
const { gropuList, groupCode } = state.group;
const { fileList, fileInfo } = state.file;
const { pageInfo, pageCode, pageIndex } = state.page;
const { docCode, amendTimeList } = state;
useReducer 사용 후 OcrMain.js의 상태값의 코드 줄이 줄어 들었습니다. 또한 2차 적용에서 보듯이 상태값을 연관이 있는 항목끼리 구조분해 할당으로 구분하여 표시할 수 있습니다. 다른 동료 분이 작성한 코드를 볼 때마다 컴포넌트의 최상단에 숨막히게 나열된 여러 개의 useState는 각각의 값이 어떤 상태를 나타내는지 파악하기 어렵고 상태값 아래 선언된 함수들과 함께 컴포넌트의 복잡성을 증가시켜 다른 개발자가 다루기 어렵게 합니다.
useReducer는 컴포넌트의 상태를 하나의 객체로 관리하여 여러 개의 useState를 하나의 useReducer로 대체할 수 있습니다. 솔직히 useReducer 사용만으로 컴포넌트의 상태값을 파악하기 쉽지 않습니다. useReducer를 사용하기 위해서 reducer 파일을 작성해야 합니다.
OcrReducer.js
const initialState = {
group: {
groupList: [],
groupCode: "
},
file: {
fileList: [],
fileInfo: []
},
page: {
pageInfo: { CONTEXT: ''},
pageCode: "",
pageIndex: 0
},
docCode: "",
amendTimeList: []
};
function reducer(state, action) {
const { type, payload } = action;
switch (type) {
case "SET_GROUP_LIST":
return { ...state, group: { ...state.group, groupList: payload } };
case "SET_GROUP_CODE":
return { ...state, group: { ...state.group, groupCode: payload } };
case "SET_FILE_LIST":
return { ...state, file: { ...state.file, fileList: payload } };
case "SET_FILE_INFO":
return { ...state, file: { ...state.file, fileInfo: payload } };
case "SET_PAGE_INFO":
return { ...state, page: { ...state.page, pageInfo: payload } };
case "SET_PAGE_CODE":
return { ...state, page: { ...state.page, pageCode: payload } };
case "SET_PAGE_INDEX":
return { ...state, page: { ...state.page, pageIndex: payload } };
case "SET_DOC_CODE":
return { ...state, docCode: payload };
case "SET_AMENDTIME_LIST":
return { ...state, amendTimeList: payload };
default:
throw new Error(`Unhandled action type: ${type}`)
}
};
export { initialState, reducer };
reduce는 "줄이다", "축소하다"라는 의미를 가지고 있는 reducer는 "감속기"라는 의미를 가지고 있는 함수로, 값을 계산하거나 변형하는 기능을 구현하여 적용할 수 있습니다. OcrReducer.js 파일에서 reducer 함수는 이전 상태값과 액션을 받아서 새로운 상태값을 반환합니다. reducer 함수가 반환한 새로운 상태값은 컴포넌트의 상태값으로 업데이트가 됩니다.
제가 reducer 함수를 사용하여 좋았던 점은 action type 별로 상태값을 업데이트 해주는 코드를 컨테이너 컴포넌트 OcrMain.js 파일과 분리하여 case 별로 한 눈에 볼수 있다는 것입니다. 이를 통해 코드의 가독성이 향상되었고, 유지보수성도 높아졌습니다. 또한, reducer 함수는 순수 함수로 작성되어 있어서 같은 인자를 받으면 항상 같은 값을 반환하므로 예측 가능한 동작을 보장합니다. 이러한 특징들은 코드를 디버깅하고 오류를 찾는 데에도 매우 유용합니다.
사용법
위의 코드 블럭에서 처럼 OcrMain.js와 OcrReducer.js 에서 useReducer로 상태값을 선언하고 action type 별로 상태값을 업데이트 해주는 코드를 작성하였다면 dispatch를 통해서 적용을 할 수 있습니다.
OcrMain.js 파일의 dispatch 함수를 사용할 것입니다. useReducer는 reducer 함수를 인자로 받습니다. 이 함수는 '[state, dispatch]' 반환합니다. 'state' 는 현재 상태값을 나타내는 변수이고, 'dispatch'는 액션을 발생시키는 함수입니다.
const [state, dispatch] = useReducer(reducer, initialState);
계약서 관리 시스템은 버튼을 클릭하면서 API 호출을 통해 DB 데이터를 가져오고 해당 응답값을 별도의 처리없이 사용하면 되기에 API 호출에 따른 응답값을 dispatch 함수의 payload의 값으로 전달해주는 방식이 주를 이루고 있습니다.
const getAllGroupList = async() => {
try {
const res = await API request code;
if() {
dispatch({ type: "SET_GROUP_LIST", payload: res})
}
} catch(err) {
}
}
dispatch가 연이어서 호출될 수도 있습니다.
useEffect(() => {
getFileData();
dispatch({ type: "SET_PAGE_INFO", payload: { CONTEXT: ' '} });
dispatch({ type: "SET_AMENDTIME_LIST", payload: []});
}, [docCode])
Tip 1. 초기 상태 & 액션 타입 & 리듀서 함수 정의
Redux는 JavaScript 애플리케이션 상태를 관리하기 위한 상태 관리 라이브러리입니다. Redux를 사용하려면 초기 상태, 액션 타입, 리듀서 함수를 정의해야 합니다. 이러한 정의는 일반적으로 파일을 구분하여 보일러 플레이트(상용구 코드)를 만드는 것이 관례입니다.
수정사항
1) actionTypes로 묶어서 구분
2) export actionTypes 추가
OcrReducer.js
// 초기 상태 정의
const initialState = {
// 생략
};
// 액션 타입 정의
const actionTypes = {
SET_GROUP_LIST: 'SET_GROUP_LIST',
SET_GROUP_CODE: 'SET_GROUP_CODE',
.
.
.
}
// 리듀서 함수 정의
function reducer(state, action) {
const { type, payload } = action;
switch (type) {
case actionTypes.SET_GROUP_LIST:
return { ...state, group: { ...state.group, groupList: payload } };
case actionTypes.SET_GROUP_CODE:
return { ...state, group: { ...state.group, groupCode: payload } };
case actionTypes.SET_FILE_LIST:
return { ...state, file: { ...state.file, fileList: payload } };
.
.
.
}
}
export { initialState, actionTypes, reducer }
OcrMain.js
import { initialState, actionTypes, reducer } from "./OcrReducer";
export default function OcrMain() {
const [state, dispatch] = useReducer(reducer, initialState);
.
.
.
if (res?.rs_value ?? false) {
dispatch({ type: actionTypes.SET_GROUP_LIST, payload: [...res.rs_value] });
}
}
Tip 2. 객체 불변성 유지 문제
Redux에서 객체의 불변성을 유지하는 것은 중요한 개념입니다. 객체의 불변성을 유지하는 이유는 Redux의 작동 원리와 예측 가능성을 보장하기 위함입니다.
Redux에서는 상태를 직접 수정하지 않고 새로운 객체를 생성합니다. 이를 위해 경우 전개 연산자 '. . .' 또는 'Object.assign()' 메서드를 사용해야 합니다. 중첩된 객체를 변경하는 경우에는 해당 객체의 상위 객체를 새로운 객체로 복사하고, 변경이 필요한 속성을 새로운 객체로 업데이트합니다
1) 전개 연산자 사용
switch (type) {
case actionTypes.SET_GROUP_LIST:
return { ...state, group: { ...state.group, groupList: payload } };
2) Object.assign() 사용
switch (type) {
case actionTypes.SET_GROUP_LIST:
return Object.assign({}, state, {
group: Object.assign({}, state.group, {
groupList: payload,
}),
});
Tip 3. @reduxjs/toolkit의 'createReducer'로 switch 문 없애기
Redux toolkit은 Redux 쉬운 사용을 위해 Redux 팀에서 공식적으로 제공하는 라이브러리입니다. Redux toolkit의 createReducer로 switch 문을 없애고, 상태의 불변성을 신경쓰지 않아도 됩니다.
아래의 코드에서는 createReducer를 사용하여 리듀서 함수를 정의하고 있습니다. 중첩된 객체의 경우, 해당 객체의 속성을 업데이트할 때도 새로운 객체를 생성합니다. 리듀서 함수에서는 builder 객체를 사용하여 각 액션 타입에 대한 상태 업데이트 로직을 추가하고 있습니다. 깊은 복사를 사용하지 않고 createReducer 함수를 사용할 때 immer 라이브러리가 내부적으로 불변성을 유지하면서 상태를 업데이트 한다고 합니다.
수정사항
1) builder의 addCase 메서드로 액션 타입을 추가
OcrReducer.js
import { createReducer } from '@reduxjs/toolkit';
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(actionTypes.SET_GROUP_LIST, (state, action) => {
state.group.groupList = action.payload;
})
.addCase(actionTypes.SET_GROUP_CODE, (state, action) => {
state.group.groupCode = action.payload;
})
.addCase(actionTypes.SET_FILE_LIST, (state, action) => {
state.file.fileList = action.payload;
})
.addCase(actionTypes.SET_FILE_INFO, (state, action) => {
state.file.fileInfo = action.payload;
})
.addCase(actionTypes.SET_PAGE_INFO, (state, action) => {
state.page.pageInfo = action.payload;
})
.addCase(actionTypes.SET_PAGE_CODE, (state, action) => {
state.page.pageCode = action.payload;
})
.addCase(actionTypes.SET_PAGE_INDEX, (state, action) => {
state.page.pageIndex = action.payload;
})
.addCase(actionTypes.SET_DOC_CODE, (state, action) => {
state.docCode = action.payload;
})
.addCase(actionTypes.SET_AMENDTIME_LIST, (state, action) => {
state.amendTimeList = action.payload;
});
});
Tip 4. 'createAction' 으로 액션 생성자 함수 생성하기
Redux Toolkit에서 createAction을 사용하여 액션 생성자 함수를 생성하고, 이를 통해 액션 타입을 정의합니다. 액션 생성자 함수는 액션 객체를 생성하는 함수로, useState의 setState 함수와 비슷한 역할을 합니다.
OcrMain.js
import { createReducer, createAction } from '@reduxjs/toolkit';
//액션 타입 정의
// const actionTypes = {
// SET_GROUP_LIST: 'SET_GROUP_LIST',
// SET_GROUP_CODE: 'SET_GROUP_CODE',
// SET_FILE_LIST: 'SET_FILE_LIST',
.
.
.
// }
const setGroupList = createAction(actionTypes.SET_GROUP_LIST);
const setGroupCode = createAction(actionTypes.SET_GROUP_CODE);
const setFileList = createAction(actionTypes.SET_FILE_LIST);
.
.
.
// 리듀서 함수 정의
// const reducer = createReducer(initialState, (builder) => {
// builder
// .addCase(actionTypes.SET_GROUP_LIST, (state, action) => {
// state.group.groupList = action.payload;
// })
// .addCase(actionTypes.SET_GROUP_CODE, (state, action) => {
// state.group.groupCode = action.payload;
// })
// .addCase(actionTypes.SET_FILE_LIST, (state, action) => {
// state.file.fileList = action.payload;
// })
.
.
.
// });
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(setGroupList, (state, action) => {
state.group.groupList = action.payload;
})
.addCase(setGroupCode, (state, action) => {
state.group.groupCode = action.payload;
})
.addCase(setFileList, (state, action) => {
state.file.fileList = action.payload;
})
.
.
.
});
export {
initialState,
setGroupList,
setGroupCode,
setFileList,
setFileInfo,
setPageInfo,
setPageCode,
setPageIndex,
setDocCode,
setAmendTimeList,
reducer
}
결론
- 최종 코드
OcrReducer.js
import { createReducer, createAction } from '@reduxjs/toolkit';
// 초기 상태 정의
const initialState = {
group: {
groupList: [], groupCode: ""
},
file: {
fileList: [], fileInfo: []
},
page: {
pageInfo: { CONTEXT: '' },
pageCode: "",
pageIndex: 0
},
docCode: "",
amendTimeList: []
};
//액션 타입 정의
const setGroupList = createAction(actionTypes.SET_GROUP_LIST);
const setGroupCode = createAction(actionTypes.SET_GROUP_CODE);
const setFileList = createAction(actionTypes.SET_FILE_LIST);
const setFileInfo = createAction(actionTypes.SET_FILE_INFO);
const setPageInfo = createAction(actionTypes.SET_PAGE_INFO);
const setPageCode = createAction(actionTypes.SET_PAGE_CODE);
const setPageIndex = createAction(actionTypes.SET_PAGE_INDEX);
const setDocCode = createAction(actionTypes.SET_DOC_CODE);
const setAmendTimeList = createAction(actionTypes.SET_AMENDTIME_LIST);
// 리듀서 함수 정의
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(setGroupList, (state, action) => {
state.group.groupList = action.payload;
})
.addCase(setGroupCode, (state, action) => {
state.group.groupCode = action.payload;
})
.addCase(setFileList, (state, action) => {
state.file.fileList = action.payload;
})
.addCase(setFileInfo, (state, action) => {
state.file.fileInfo = action.payload;
})
.addCase(setPageInfo, (state, action) => {
state.page.pageInfo = action.payload;
})
.addCase(setPageCode, (state, action) => {
state.page.pageCode = action.payload;
})
.addCase(setPageIndex, (state, action) => {
state.page.pageIndex = action.payload;
})
.addCase(setDocCode, (state, action) => {
state.docCode = action.payload;
})
.addCase(setAmendTimeList, (state, action) => {
state.amendTimeList = action.payload;
});
});
export {
initialState,
setGroupList,
setGroupCode,
setFileList,
setFileInfo,
setPageInfo,
setPageCode,
setPageIndex,
setDocCode,
setAmendTimeList,
reducer
}
OcrMain.js
import React, { useReducer } from 'react';
import {
initialState,
setGroupList,
setGroupCode,
setFileList,
setFileInfo,
setPageInfo,
setPageCode,
setPageIndex,
setDocCode,
setAmendTimeList,
reducer
} from './OcrReducer';
function OcrMain() {
const [state, dispatch] = useReducer(reducer, initialState);
.
.
.
if (res?.rs_value ?? false) {
const groupList = [...res.rs_value]
dispatch(setGropuList(groupList));
}
return (
.
.
.
);
}
이번 상황에서 useReducer를 사용하여 상태값을 관리하는 것이 useState보다 더 나은 방법임을 경험하게 되었습니다. reducer 함수를 사용하면 코드의 가독성과 유지보수성이 향상되고, 순수 함수로 작성되어 있어서 예측 가능한 동작을 보장할 수 있습니다. 앞으로도 이러한 좋은 패턴을 계속해서 습득하고, 좀 더 효율적이고 안정적인 코드를 작성하는 데에 노력할 것입니다.
이상 '회사 프로젝트 후기 - (5) UseReducer로 상태관리 최적화' 였습니다. 긴 글 읽어 주셔서 감사합니다.
"도움이 되셨다면 공감과 댓글로 지지해주세요!!"