이전 포스팅에서 아쉬운 부분들을 보완하여 NewIMS를 만들어봤습니다.
그 과정에서 겪은 어려움들을 정리하고 새롭게 알게된 내용이 무엇인지 정리한 포스팅입니다.
1. 기능 설명 & 기능 예시
우선 NewIMS에는 어떠한 기능들이 존재하는지 설명하는 동시에 어떻게 동작하는지 간단하게 보여드리려고 합니다.
1-1. 재고 관리 페이지
ItemManage.jsx는 페이지를 나타내는 컴포넌트입니다.
현재 NewIMS에 등록된 재고의 총 수량 그리고 재고의 종류가 몇종류 있는지를 보여주는 화면입니다.
1-2. 재고 추가 페이지
Add.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 NewIMS에 직접적으로 재고를 추가하는 기능을 제공합니다.
여기서부터는 기능을 조금 상세하게 기술하도록 하겠습니다.
1. '상품명', '바코드', '로케이션', 그리고 '수량'을 입력받고 재고 추가 버튼을 누르면 추가 목록에 재고가 등록된다.
2. 만약 하나라도 입력되지 않는다면 focus를 제공해주고 재고 추가가 진행되지 않는다.
3. 추가 목록에는 내가 방금 추가한 재고들이 리스트로 보여진다.
4. 만약 잘못 추가한 재고가 있다면 리스트에서 체크박스를 통해 해당 재고들을 선택한 이후, 재고 삭제 버튼을 누르면 목록에서 제외된다.
5. 재고 추가 페이지를 벗어나면 추가 목록에 존재하는 아이템들이 NewIMS에 등록된다.
6. 만약 수량에 숫자가 아닌 값이 들어온다면 처리되지 않는다.
1) 재고 추가 예시
2) 추가된 재고 삭제 예시
1-3. 재고 이동 페이지
Update.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 NewIMS에 등록된 재고들의 로케이션과 수량을 변경하는 기능을 제공합니다.
1. 해당 페이지에는 현재 NewIMS에 등록된 재고 리스트를 화면에 보여줍니다.
2. 리스트에서 이동시키고자 하는 재고가 있다면 새로운 로케이션, 그리고 이동시킬 재고의 수량을 입력합니다.
3. 여러개의 재고가 존재하기때문에 하나씩 이동시키는 방식이 아니라, 여러 재고의 인풋을 넣어주고 재고 이동 버튼을 눌러서 한번에 이동시킬 수 있습니다.
4. 동일한 바코드와 로케이션을 가진 재고는 수량이 합쳐집니다.
5. 반면, 동일한 바코드이나 로케이션이 다르다면 재고는 다른위치에 존재하기 때문에 수량이 별도로 분리됩니다.
6. 만약 수량에 숫자가 아닌 값이 들어온다면 처리되지 않는다.
7. 또한, 수량이 현재 존재하는 재고를 넘을경우 사용자에게 Alert를 제공하고 방금 입력한 Input은 사라진다.
재고 이동 예시
1-4. 재고 삭제 페이지
Delete.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 NewIMS에 등록된 재고를 삭제하는 기능을 제공합니다.
1. 현재 NewIMS에 등록된 재고 리스트를 화면에 보여줍니다.
2. 체크 박스를 클릭하여 삭제하고 싶은 재고들을 선택합니다.
3. 이후 재고 삭제 버튼을 클릭하면 NewIMS에서 해당 재고들은 완전히 사라집니다.
재고 삭제 예시
1-5. 입고 현황 페이지
OrderStatus.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 이후에 설명드릴 발주와 입고 페이지의 데이터와 상호작용합니다.
1. 해당 페이지에서는 오직 사용자에게 화면을 보여주는 기능만 존재합니다.
2. 총 입고량은 발주한 총 수량을 의미하며, 총 진행률은 발주 수량 중에서 NewIMS에 등록된 재고 수량을 의미합니다.
3. 발주 이력은 발주 페이지를 통해서 발주 요청한 이력을 나타냅니다.
4. 입고 이력은 입고 페이지를 통해서 입고된 재고의 변동 이력을 나타냅니다.
1-6. 발주 페이지
Order.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 NewIMS에 등록하고자하는 재고를 요청하는 페이지라고 생각하면 됩니다.
재고 추가 페이지와 다른점은 발주로 들어온 상품은 입고를 통해야만 NewIMS에 등록될 수 있으나 재고 추가는 직접적으로 NewIMS에 재고를 등록할 수 있다는 차이점이 존재합니다.
1. 사용자로부터 상품명, 바코드, 그리고 수량을 입력받습니다. 이후 발주 버튼을 누르면 하단 리스트에 추가됩니다.
2. 재고 추가 페이지와 마찬가지로 리스트에서 불필요한 재고는 삭제할 수 있습니다.
3. 발주는 저장 버튼을 눌러야만 요청이 됩니다. 저장 버튼을 클릭하면 입고 현황 페이지에서 요청한 내용을 확인할 수 있습니다.
발주 예시
1-7. 입고 페이지
OrderProcess.jsx는 페이지를 나타내는 컴포넌트입니다.
해당 페이지에서는 이전에 발주로 요청받은 상품의 바코드를 입력하여 등록 예정 수량과 상품의 정보를 조회합니다.
이후 처리 프로세스를 진행하여 NewIMS에 재고를 등록합니다.
1. 사용자로부터 바코드를 입력받고 조회 버튼을 클릭합니다. 이때 바코드는 입고 현황 - 발주 이력에 존재하는 상품이어야만 합니다.
2. 만약 매칭되는 바코드가 존재하지 않는다면 alert를 제공합니다.
3. 바코드를 통해 상품의 발주 이력을 가져옵니다.
4. 사용자로부터 저장할 재고의 수량과 로케이션을 입력받고 재고 저장 버튼을 클릭하면 NewIMS에 등록됩니다.
5. 마찬가지로 저장 수량은 숫자가 들어오지 않는다면 아무런 동작이 없습니다.
입고 예시
2. 컴포넌트 다이어그램
이전 재고 시스템을 만들면서 겪었던 문제중 하나가 변수를 어느 컴포넌트에서 관리할지 명확한 규격이 없었다는 것입니다.
그래서 이번에는 NewIMS를 만들기 전에 어디서 변수를 관리할지 결정하고 시작했습니다.
위 다이어그램에서 살펴볼 수 있다시피 몇가지 규칙을 지정하고 시작하고 싶었습니다. 규칙은 아래와 같습니다.
1. 최상위 컴포넌트 App.jsx에서 NewIMS의 등록된 재고를 관리한다.
2. 크게 NewIMS를 ItemManage와 OrderStatus로 나누어서 관리한다.
3. OrderStatus에서 orderItems라는 변수를 통해 Order 하위 컴포넌트들을 관리한다. 이는 불필요한 참조를 막기 위함이다.
4. 각 페이지 컴포넌트에서 필요한 변수를 별도로 지정하여 로직을 처리하고 App 컴포넌트에서 정의된 함수를 전달받아 마지막으로 재고를 관리한다.
2-1. 첫번째 고민
하지만 OrderStatus를 개발할 때 즈음 어떤 고민을 하게되었습니다.
우선 제가 작성하고 있는 구조가 위 다이어그램을 따르지 않는 것 같다는 것이 첫 번째 고민이었습니다.
ItemManage.jsx 하위에 Add, Delete, Update가 존재하도록 다이어그램을 작성했으나 ItemManage.jsx도 페이지 컴포넌트로 사용자에게 화면을 보여줘야 합니다.
우선 ItemManage.jsx의 일부 코드를 살펴보도록 하겠습니다.
ItemManage.jsx
ItemManage 컴포넌트 관련 코드...
return (
<Routes>
<Route path="/ItemManage/Add" element={<Add />} />
<Route path="/ItemManage/Delete" element={<Delete />} />
<Route path="/ItemManage/Update" element={<Update />} />
</Routes>
)
만약 코드를 위처럼 작성했다고 가정해봅시다. 저는 이런 생각을 했습니다. (지금와서는 이렇게 구조를 짜는 것이 더 적절할 수 있다고 생각합니다..!)
‘ItemManage가 페이지를 나타내는 컴포넌트이므로 하위 컴포넌트로 다른 페이지 컴포넌트 갖는 것은 바람직하지 않다.’, ‘Props를 어떻게 넘겨줄 것인가?’
지금 회고록을 작성하면서 생각해보니 Context API를 사용하거나 props로 충분히 넘겨줄 수 있는 것 같습니다.
예를들면 아래처럼 코드를 작성하면 되지 않을까 합니다.
ItemManage.jsx
ItemManage 컴포넌트 관련 코드...
const [addItem, setAddItem] = useState([]);
const [deleteItem, setDeleteItem] = useState([]);
const [updateItem, setUpdateItem] = useState([]);
return (
<Routes>
<Route path="/ItemManage/Add" element={<Add addItem={addItem}/>} />
<Route path="/ItemManage/Delete" element={<Delete deleteItem={deleteItem}/>} />
<Route path="/ItemManage/Update" element={<Update updateItem={updateItem}/>} />
</Routes>
<div className="ItemManage">
<div>
<div className="ItemManage-Summary">
<ViewCurrentUnits items={items} />
<ViewCurrentKinds items={items} />
</div>
</div>
</div>
)
(최상위 컴포넌트인 App.jsx에서도 이미 이렇게 사용하고 있으면서 왜 그 당시에는 이걸 생각하지 작성하지 못했나하는 생각이 듭니다..)
이렇게 코드를 작성했다면 아마 기존 계획대로 모듈화를 시켜서 유지보수가 조금 더 유용하지 않았을까 생각합니다.
하지만! 그 당시에는 페이지 컴포넌트에서 다른 페이지 컴포넌트를 Route하면 props를 전달할 수 없다고 생각했기 때문에 구조를 조금 개편해야 했습니다.
OrderStatus도 마찬가지로 생각을 했고 여기서는 다른 해결책을 제시해보고자 했습니다.
OrderStatus, Order, OrderProcess를 관리하는 다른 컴포넌트 OrderApp을 만들어서 해당 컴포넌트에서 변수를 관리해보자라는 마인드였습니다.
그래서 수정한 다이어그램은 아래와 같습니다.
다이어그램을 설명하자면 Add, ItemManage, Delete, Update는 App에서 선언된 변수인 items를 Context API로 전달받아 화면에 표시하고 있습니다.
반면, Order, OrderProcess, OrderStatus에서는 OrderApp이라는 상위 컴포넌트에서 관리하는 orderItems를 전달받아서 로직을 처리하고 있습니다.
2-2. 두번째 고민
여기서 두 번째 문제가 발생했습니다. 문제가 발생하는 케이스는 아래와 같습니다.
1. Order를 통해 상품 발주 요청을 넣는다.
2. Update 페이지로 이동한다.
3. 다시 Order 페이지로 돌아온다.
4. 기존에 작성한 발주 요청이 사라져있다 ?!
문제를 찾아보니 결국 렌더링과 관련되어있었습니다.
OrderApp 컴포넌트에서 관리하는 orderItems 변수는 Add 페이지로 이동하게되면 unmount됩니다.
즉, Order 페이지에서 로직을 처리하여 orderItems 변수에 값을 저장했더라도 Add 페이지로 넘어가면 OrderApp 컴포넌트가 unmount 되기 때문에 저장하고 있던 변수가 모두 사라집니다.
다시 Order 페이지로 돌아와서 해당 컴포넌트들을 mount시키더라도 이미 사라진 이후이므로 문제가 발생하는 것이었습니다.
이를 해결하는 방법은 아주 간단합니다. App.jsx는 서버를 종료시키는게 아니라면 항상 mount되어있기 때문에 App.jsx에서 orderItems 변수를 관리하면 됩니다.
그래서 최종적으로 수정한 컴포넌트 다이어그램은 아래와 같습니다.
결국 App 컴포넌트 하위에 페이지 컴포넌트들을 모두 라우트시키고 주요 변수도 App에서 관리하고 있습니다.
하지만, ‘2-1’에서 생각한 바와 같이 ItemManage에서 Add, Delete, Update를 route시키고 OrderStatus에서 Order, OrderProcess를 route를 시키는 방식으로 모듈화를 진행한다면,
그리고 거기서 관리되는 변수들을 모두 App에서 선언된 변수들에 저장한다면 모듈화를 진행하여 조금 더 효과적으로 관리할 수 있지 않을까 생각합니다.
3. Problems
다음으로는 NewIMS를 만들면서 발생한 문제들을 다뤄보도록 하겠습니다.
3-1. 네비게이션 Active 처리
우선 제가 Navigation Bar를 어떻게 만들었는지부터 소개하겠습니다.
App.jsx
const navigationURL = [
{
id: 1,
title: "메인 화면",
url: "/",
icon: <i class="ri-home-7-line"></i>,
},
{
id: 2,
title: "재고 관리",
url: "/ItemManage",
icon: <i class="ri-archive-stack-line"></i>,
drop: [
{
id: 3,
title: "재고 추가",
url: "/ItemManage/Add",
icon: <i class="ri-add-box-line"></i>,
},
{
id: 4,
title: "재고 이동",
url: "/ItemManage/Update",
icon: <i class="ri-input-method-line"></i>,
},
...
],
}, ...
]
function App() {
...
<Navigation />
}
Navigation.jsx;
const Navigation = () => {
const dropId = useRef(10);
const { navigationURL } = useContext(ItemStateContext);
return (
<div className="Navigation">
<div className="Navigation-Title">
<img className="logo" src="/src/assets/logo.png" />
<section>IMS</section>
</div>
{navigationURL.map((navMenu) => {
return (
<div
className="Navigation-Main"
key={`${dropId.current++}-${navMenu.title}`}
>
<Link to={navMenu.url}>
<span className="icon">{navMenu.icon}</span>
<span className="title">{navMenu.title}</span>
</Link>
<div className="Navigation-Drop">
{navMenu.drop
? navMenu.drop.map((navDrop) => (
<Link key={navDrop.key} to={navDrop.url}>
<span className="icon">{navDrop.icon}</span>
<span className="title">{navDrop.title}</span>
</Link>
))
: null}
</div>
</div>
);
})}
</div>
);
};
export default Navigation;
이처럼 Navigation 컴포넌트에서 App.jsx에서 정의된 NavigationURL 변수를 Context API를 통해 전달받아서 사용합니다.
NavigationURL에는 id, title, url, icon 그리고 필요한 경우 drop 객체를 프로퍼티로 정의하였습니다.
Navigation 컴포넌트에서는 전달받은 NavigationURL 변수를 이용하여 필요에 맞춰서 화면에 출력하도록 작성하였습니다.
그런데 저는 여기서 현재 활성화된 Navigation Bar에는 추가적인 이펙트를 주고 싶었습니다.
Git Blog를 만들 때 경험을 떠올리면 active가 됐을 경우 class명을 조작하여 css로 이펙트를 줬던 기억이 있습니다.
그래서 찾아보니 Route 라이브러리에 Link가 아니라 NavLink가 존재했고 NavLinkRenderProps의 isActive props, NavLinkProps의 className을 사용하면 쉽게 구현할 수 있었습니다.
(NavLink 컴포넌트를 선언할 때 props에서 정의하면 됩니다.)
수정한 코드는 아래와 같습니다.
Navigation.jsx
...
const Navigation = () => {
const dropId = useRef(10);
const { navigationURL } = useContext(ItemStateContext);
return (
<div className="Navigation">
<div className="Navigation-Title">
<img className="logo" src="/src/assets/logo.png" />
<section>IMS</section>
</div>
{navigationURL.map((navMenu) => {
return (
<div
className="Navigation-Main"
key={`${dropId.current++}-${navMenu.title}`}
>
<NavLink
to={navMenu.url}
className={({ isActive }) => {
return isActive ? "active" : "";
}}
>
<span className="icon">{navMenu.icon}</span>
<span className="title">{navMenu.title}</span>
</NavLink>
<div className="Navigation-Drop">
{navMenu.drop
? navMenu.drop.map((navDrop) => (
<NavLink
key={navDrop.key}
to={navDrop.url}
className={({ isActive }) => {
return isActive ? "active" : "";
}}
>
<span className="icon">{navDrop.icon}</span>
<span className="title">{navDrop.title}</span>
</NavLink>
))
: null}
</div>
</div>
);
})}
</div>
);
};
export default Navigation;
이후 css에서 .active에 원하는 효과를 추가하면 쉽게 구현할 수 있었습니다.
관련 내용을 조금 더 정리하자면 NavLink는 Link의 특별한 버전이라고 설명합니다.
이전에 구현하면서 사용한 activeClassName과 더불어 activeStyle 속성이 존재하는데 리액트 웹의 현재 URL과 to가 가리키는 링크가 일치하다면 boolean 값이 true가 되는 방식입니다.
이를 활용하여 스타일을 추가할 수 있었습니다.
3-2. 리렌더링
Add 페이지를 만들면서 인지했던 문제입니다. 우선 Add 페이지의 구성을 살펴보도록 하겠습니다.
위 사진과 같이 Add 컴포넌트에는 Input을 받아 재고 추가를 하는 영역이 있고 하위 컴포넌트로 AddItemList가 존재합니다.
여기서 재고추가를 하면 AddItemList 아래에 데이터가 하나씩 추가됩니다. (AddItemListTableData.jsx라는 포맷을 가지고 있습니다.)
마주쳤던 문제는 추가되는 재고의 index를 처리하는 과정에 있었습니다.
우선 AddItemList.jsx의 코드는 아래와 같습니다.
const AddItemList = ({ addItemList }) => {
const indexRef = useRef(1);
useEffect(() => {
indexRef.current = 1;
}, [addItemList]);
return (
<div className="AddItemList">
<table>
<caption>추가 목록</caption>
<thead>
<tr>
<th></th>
<th>번호</th>
<th>상품명</th>
<th>바코드</th>
<th>로케이션</th>
<th>수량</th>
</tr>
</thead>
<tbody>
{addItemList.map((addItem) => {
return (
<AddTableData
key={addItem.barcode + addItem.location}
index={indexRef.current++}
addItem={addItem}
/>
);
})}
</tbody>
</table>
</div>
);
};
export default React.memo(AddItemList);
위는 제가 작성한 최종 코드인데 이를 얻어낸 과정은 다음과 같습니다.
처음에는 AddItemList에서 useState로 index를 정의하고, 이를 AddTableData 컴포넌트의 props로 전달하는 방식을 사용했습니다.
하지만, 이렇게되면 <input> 태그에서 값을 입력할 때마다 index가 증가하는 모습을 확인할 수 있었습니다.
이러한 문제의 발생 이유는 데이터의 인풋을 받는 공간이 Add 컴포넌트에 정의가 되어있었고, AddItemList의 부모 컴포넌트인 Add 컴포넌트가 리랜더링이 될때마다 index가 증가하기 때문이었습니다.
그에 대한 해결책으로 React의 memo 라이브러리를 사용하여 AddItemList로 전달되는 props가 변경될때만 인덱스를 변경하고자 하였습니다.
하지만, 생각해보면 index는 실시간으로 변할 필요가 없습니다.
따라서, useRef를 사용하여 AddTableData로 전달될 때만 값을 증가시키고 반영되도록 하고자 하였습니다.
(위 코드에서 useEffect가 없다고 생각하면 됩니다.)
이렇게 작성하면 발생하는 문제는 AddItemList가 리렌더링이 될때마다 (버튼을 누를때마다) useRef에 저장된 index값은 그대로 유지되고 있으므로 인덱싱이 1부터 시작하지 않는 문제가 있었습니다.
이를 해결하기 위해 useEffect를 사용해서 AddItemList가 리렌더링이 될때마다 useRef를 1로 설정하도록 코드를 작성했습니다.
즉, 전달받는 AddItemList가 변화할 때마다 인덱싱을 다시 진행하도록 코드를 작성했습니다.
리렌더링과 관련된 또다른 문제도 있었습니다. 이를 설명하기 위해 Add 컴포넌트의 동작을 추가적으로 소개해보겠습니다.
Add 페이지에는 addItemList라는 변수가 선언되어있고 해당 변수는 useState로 정의된 배열입니다.
재고 추가 버튼이 클릭되면 재고의 정보가 addItemList에 추가되고, 해당 변수는 AddItemList 컴포넌트의 props로 전달됩니다.
여기서 마주한 문제는 재고 추가 버튼의 동작을 정의하는 함수에서 발생했습니다.
Add 페이지에서는 AddClickAddItem이라는 click 이벤트 함수를 정의합니다. (재고 추가 버튼이 눌렸을 때 동작을 정의)
수정하기 이전 코드는 아래와 같습니다.
const handleClickAddItem = () => {
if (!validateFields()) return;
setAddItemList((prevList) => {
const index = prevList.findIndex(
(item) =>
`${item.barcode}+${item.location}` ===
`${addItem.barcode}+${addItem.location}`
);
if (index !== -1) {
const updateAddItem = {
...prevList[index],
quantity:
Number(prevList[index]["quantity"]) + Number(addItem.quantity),
};
prevList[index] = updateAddItem;
return prevList;
}
return [...prevList, addItem];
});
};
위 코드에서 보시면 prevList를 직접 조작하여 값을 변경하고 있습니다.
이렇게 코드를 작성하면 참조 동일성 문제가 발생합니다.
여기서 참조 동일성이란 prevList의 값은 실제로 변경되었으나, props로 전달받은 AddItemList가 변경된지를 모르는 것을 의미합니다.
즉, 위 코드로 설명하면 index !== -1일때 prevList를 그대로 반환하고 있습니다.
실제로 prevList의 값은 변경되었으나 반환하는 값이 이전과 동일한 prevList이기 때문에 변화를 감지하지 못합니다.
(얕은 비교를 통해 참조 값을 비교하기 때문에 발생하는 현상입니다.)
따라서 참조 동일성 문제를 해결하기 위해서는 (하위 컴포넌트가 객체의 프로퍼티의 value가 변화함을 감지하도록 하기 위해서는) prevList를 깊은 복사를 이용 & 새로운 배열로 반환하는 방법을 사용할 수 있습니다.
다음은 수정된 코드입니다.
const handleClickAddItem = () => {
if (!validateFields()) return;
setAddItemList((prevList) => {
const index = prevList.findIndex(
(item) =>
`${item.barcode}+${item.location}` ===
`${addItem.barcode}+${addItem.location}`
);
if (index !== -1) {
const updateList = [...prevList];
updateList[index] = {
...updateList[index],
quantity: Number(updateList[index].quantity) + Number(addItem.quantity),
};
return updateList;
}
return [...prevList, addItem];
});
};
이렇게 작성하면 클릭 버튼을 눌렀을 때 addItemList의 값이 변하게 되고, addItemList라는 변수는 AddItemList 컴포넌트의 props로 전달되기 때문에 리렌더링을 발생시킵니다.
이를 통해서 원하는 동작을 얻을 수 있었습니다.
위 문제들을 해결하는 방법이 어려운 것은 아니지만, 저에게는 리렌더링이 언제 발생하는지를 다시 생각해볼 수 있는 부분이었습니다. (저는 시간이 꽤 걸렸습니다…)
3-3. useEffect에 대해서..
NewIMS의 Add 페이지를 살펴보면 한 가지 특징이 존재하는데 이것은 바로 별도의 “저장”버튼이 존재하지 않는다는 것입니다.
제가 구현하고자 했던 동작 순서는 아래와 같습니다.
1. Add 페이지에서 등록하고자 하는 상품의 정보를 입력하고 재고 추가 버튼을 누른다.
2. 하단의 리스트에서 추가한 목록을 다시 확인할 수 있도록 한다.
3. Add 페이지(재고 추가 페이지)를 벗어나면 목록에 존재하는 상품들이 모두 NewIMS에 등록된다. <-- 여기가 포인트입니다!
사실 위 방법이 좋은 UX는 아니라고 생각하지만, NewIMS를 만들 때는 여러 방법을 시도해보고 싶어서 위처럼 동작시키고 싶었습니다.
목표는 useEffect훅을 이용해서 Add 페이지가 unmount되면 NewIMS에 자동으로 등록되도록 하는 것입니다.
저는 먼저 아래처럼 생각하고 코드를 작성했습니다.
1. Add.jsx에서 useEffect 코드 추가
2. deps로 빈배열 제공
3. Add.jsx가 언마운트될 때 useEffect 내부 코드 실행
4. useEffect 내부에 클린업 함수로 addItemList를 NewIMS에 등록 -> 동작 완료!
코드는 아래와 같습니다.
Add.jsx
const Add = () => {
const [addItemList, setAddItemList] = useState([]);
const [addItem, setAddItem] = useState({
name: "",
barcode: "",
location: "",
quantity: "",
isCheckedAdd: false,
});
const { onCreateItem } = useContext(ItemDispatchContext);
useEffect(() => {
return () => {
addItemList.forEach((addItem) => onCreateItem(addItem));
};
}, []);
...
return (...);
};
export default Add;
아래는 위 코드의 간단한 설명입니다. (설명에 필요한 부분만 가져왔습니다.)
1. addItem에는 사용자로부터 입력받고 있는 상품의 정보가 저장됩니다.
2. 저장 버튼을 클릭하면 addItem에 저장된 데이터가 addItemList에 추가됩니다.
3. onCreateItem은 App.jsx에 정의되어있는 함수입니다. NewIMS에 바로 재고를 추가하는 역할을 합니다.
위에서 작성한 useEffect 코드를 보면 클린업 함수로 addItemList를 루프를 돌면서 NewIMS에 재고를 등록하고 있습니다.
하지만, 위처럼 작성하면 제가 원하는대로 코드가 동작하지 않습니다.
useEffect 외부에서 콘솔을 찍어서 확인해보면 addItemList에는 정상적으로 재고 데이터가 저장되어있습니다.
하지만, useEffect 내부에서 콘솔을 찍어서 확인해보면 addItemList가 빈배열로 출력됩니다.
이 부분을 해결하기 이전에 왜 이렇게 되는지 궁금했고 찾아본 결과는 아래와 같습니다.
[클로저 트랩]
React의 Fiber 노드는 내부적으로 각 훅의 상태를 연결 리스트로 관리한다.
이를 통해 훅이 호출될 때마다 새로운 상태 노드가 추가되며, 각각의 상태 노드는 특정 시점의 상태를 memoizedState 프로퍼티에서 기억한다.
훅의 상태 변화를 요청하면 memoziedState에서 자신의 노드에 접근하여 요청을 처리한다.
즉, useEffect는 자신이 호출된 시점을 클로저 기능을 통해 기억하고 있다.
(코드를 통해 memoziedState에 접근해서 참조하고 있는 노드를 확인하고자 하였으나, 모두 동일한 상태를 출력하는 문제가 발생해서 간접적으로 확인해봤습니다.)
아래는 이를 확인하기 위한 테스트 코드입니다.
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import "./Home.scss";
const Home = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("setup (with []) :", count); // 현재 count 값을 로그로 출력
return () => {
console.log("cleanup (with []) :", count); // cleanup 시점의 count 값을 로그로 출력
};
}, []);
useEffect(() => {
console.log("updated count (with count):", count);
}, [count]);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default Home;
코드 실행 결과는 아래와 같습니다.
즉, 위 테스트를 통해서도 확인할 수 있다시피 []을 의존성 배열로 가지고 있는 useEffect는 최초 mount되는 시점의 Add.jsx 상태를 기억하고 있기 때문에 (다른 상태의 변화를 감지하지 않기 때문에) addItemList에 데이터를 아무리 저장하더라도 해당 훅에서는 반영되지 않는 것이었습니다.
이를 해결하기 위해서는 리렌더링이 발생하지 않더라도 addItemList의 값을 저장하고 있도록 하면 됩니다.
따라서 수정한 코드는 아래와 같습니다.
Add.jsx
const Add = () => {
const [addItemList, setAddItemList] = useState([]);
const [addItem, setAddItem] = useState({
name: "",
barcode: "",
location: "",
quantity: "",
isCheckedAdd: false,
});
const { onCreateItem } = useContext(ItemDispatchContext);
const currentList = useRef(addItemList);
useEffect(() => {
currentList.current = addItemList;
}, [addItemList]);
useEffect(() => {
return () => {
currentList.current.forEach((addItem) => onCreateItem(addItem));
};
}, []);
return (...);
};
export default Add;
위처럼 useRef와 useEffect 훅을 추가하여 addItemList의 현재 상태를 currentList에 저장합니다.
이후, unmount 시점에 currentList에 접근하여 조작하여 NewIMS에 재고를 등록하도록 코드를 수정했습니다.
(리액트 공식 문서에서는 useEffect를 사용할 때, setUp 함수가 없다면 code Smell이라고 하긴합니다 ㅎㅎ..)
이를 통해서 useRef는 실시간 상태를 저장하는 특징과 리액트에서 제공하는 훅의 특징을 경험적으로 살펴볼 수 있었습니다.
4. 마무리
NewIMS 회고록을 작성하면서 겪은 문제점에 대해서, 그리고 구조에 대해서 다시 한번 생각해보며 정리해보는 시간을 가졌습니다.
다음에는 강의를 수강하면서 다시 한번 개념들을 복습해보는 시간을 갖도록 하겠습니다.
NewIMS Github : NewIMS
참고 레퍼런스
1.React Hook과 Closure의 관계 (feat. useState)
2.useEffect
3.(번역) 리액트 훅(React Hooks)의 클로저 트랩(Closure Trap) 이해하기
4.[React] Link와 NavLink
5.React Fiber Data Structure — Demystified