[원티드 프리온보딩 프론트엔드 과정] 2차 과제, 상품 등록 관리 페이지
♦️ 2차 과제 : 상품 등록 어드민 페이지
프로젝트 소개
커머스에 노출되는 상품의 신규 등록/수정을 할 수 있는 상품 등록 어드민 페이지를 구현하는 것을 목표로 하는 프로젝트입니다.
링크
구현사항
상품 정보 고시
☑️ 정보고시 form 이 추가/삭제 정보고시 form 생성 순서 숫자 차례대로 count로 관리(default 값은 1)
☑️ 정보고시 form 추가 기능, 추가 될 때마다 생성 순서 숫자 count +1
☑️ 정보고시 form 삭제 기능, 삭제 될 때마다 생성 순서 숫자 count -1
☑️ form input의 title, placeholder 를 mock data로 관리 및 실시간 업데이트 기능 구현
☑️ 정보고시 form 삭제 기능 고유한 id 값으로 컨트롤하여 form count 순서대로 배치할 수 있도록 구현
☑️ [항목 추가 버튼]을 누르면 [항목 옵션]이 추가되고 'title' 및 '내용' 입력 가능
☑️ [항복 삭제 버튼]을 누르면 해당 [항목 옵션] 만 삭제 가능
☑️ 정보고시 form 내 입력된 모든 값(항목추가옵션 포함)은 해당하는 form 데이터 형태로 실시간 업데이트 가능
공통 UI 컴포넌트
☑️ Radio : 사용하고자 하는 컴포넌트에 content, select, onChange를 받아 onChange로 event가 실행되면 select가 되도록 기능, '제한 없음'을 default값으로 지정하여 자동으로 select 값 유지
☑️ Toggle : boolean state 값을 받아 true, false로 state를 관리되도록 기능
기능별 영상
상품 정보 고시 (전체 form 추가/삭제, 정보고시 생성 순서 숫자 표기)
상품 정보 고시 (input 입력값 받기)
상품 정보 고시 (항목 옵션 추가/삭제)
[원티드 프리온보딩 코스] 두번째 과제는 커머스에 노출되는 상품의 신규 등록/수정을 할 수 있는 상품 등록 어드민 페이지를 구현하는 것이었다. 팀원들은 각자 맡은 섹션을 구현하고, 마지막에 최종적으로 한 페이지 안에서 섹션들을 모두 합쳐야 했기에 공통으로 합의된 테스크 마감 기한을 지켜야만 했고, 무엇보다도 이를 위해서 각자의 진행 상황을 실시간으로 공유하며 소통하는 게 무엇보다도 중요한 프로젝트였다고 생각한다.
새로 배웠거나 고민했던 지점
🔹 공통 컴포넌트의 사용
이번 과제는 비슷한 UI 컴포넌트를 반복적으로 사용해야 했기에, 기초 세팅 전에 팀원들과 함께 구현해야 하는 프로젝트 페이지를 보면서 공통 UI 컴포넌트를 선별해내는 작업부터 진행했다. 개인적으로 Radio와 Toggle은 이전에 작업해봤기에 비교적 빠르게 작업할 수 있으리라 생각하고 맡게 되었는데, 팀원 모두가 사용할 수 있어야 한다는 조건을 만족하는 것이 생각보다 쉽지 않았다.
Radio Component
- 사용하고자 하는 컴포넌트에 content, select, onChange 함수를 받아 onChange로 event가 실행되면 select가 되도록 기능
- '제한 없음'을 default값으로 지정하여 자동으로 select 값 유지
const Radio = ({ content, select, onChangeValue, ...props }) => {
return (
<Wrapper>
<Item>
<RadioButton
type="radio"
value={content}
checked={content === select}
onChange={(event) => onChangeValue(event)}
{...props}
/>
<RadioButtonLabel />
<div>{content}</div>
</Item>
</Wrapper>
);
};
Radio 컴포넌트의 경우, 사용하고자 하는 컴포넌트를 미리 살펴보았을 때 기본적으로 필요한 값들이 있었다. content
와 checked의 상태를 지정할 select
그리고 상태를 변경해줄 함수인 onChange
였다. 그리고 '제한 없음'을 default값으로 지정하여 자동으로 select 값을 유지할 수 있도록 했다.
const [select, setSelect] = useState('제한 없음');
const handleSelect = (e) => {
const value = e.target.value;
setSelect(value);
};
return (
...
<Radio
content={'제한 없음'}
id="exposePeriod"
select={exposureOption}
onChangeValue={handleSelect}
/>
<Radio
content={'미노출'}
id="exposePeriod"
select={exposureOption}
onChangeValue={handleSelect}
/>
<Radio
content={'노출 기간 설정'}
id="exposePeriod"
select={exposureOption}
onChangeValue={handleSelect}
/>
...
)
Radio 값 받아오기
공통 컴포넌트를 생성 후 공유하더라도 이 컴포넌트를 사용하는 방법에 대해서 팀원들 간의 충분한 합의가 있어야 했기 때문에 이 공통 컴포넌트를 어떻게 공유할 것인지에 대한 고민이 필연적으로 따라왔다. 그래서 나는 공통 컴포넌트를 생성할 때마다 사용하는 방법에 대한 예시에 대한 코드를 미리 작성하고, 이를 토대로 팀원들이 컴포넌트를 활용할 수 있도록 했고 직관적인 변수명을 사용하여 다른 팀원들이 예시와 컴포넌트를 보면서 보다 빠르게 이해할 수 있도록 코드를 작성했다.
🔹 form 데이터 관리
내가 맡은 [상품 정보 고시]는 input에 입력된 값들이 하나의 form 으로 데이터를 내보내야 하는 방식이었기 때문에 form 데이터를 함께 관리해주는 게 관건이었다. form 하나에 추가된 각각의 데이터 뿐만 아니라, 추가 option의 데이터도 함께 관리해주어야 했고 form 자체적으로 count가 필요했기 때문에 이 또한 form 이 추가/삭제 되는 갯수에 따라 관리해주어야 하는.. 설명하기에도 꽤나 복잡한 문제가 있었다. 😇
InfoNotice.js
const InfoNotice = () => {
const [count, setCount] = useState(2);
const [form, setForm] = useState([{ count: 1, id: 1 }]);
const addNotice = (newNotice) => {
setCount((prev) => prev + 1);
setForm((prev) => [...prev, { ...newNotice, count, id: Math.random() }]);
};
...
return (
<InfoContainer>
{form.map((notice) => (
<InfoNoticeForm
key={notice.id}
count={notice.count}
notice={notice}
delNotice={() => delNotice(notice)}
setNotice={(transform) => {
setForm((oldForm) =>
oldForm.map((oldNotice) => {
if (oldNotice.count !== notice.count) return oldNotice;
return transform(oldNotice);
})
);
}}
/>
))}
...
</InfoContainer>
);
}
먼저 form 자체를 컨트롤(추가/삭제/데이터 합치기)해주기 위한 메인 컴포넌트인 InfoNotice
컴포넌트를 생성하고, form 안의 input 값을 관리해주는 InfoNoticeForm
컴포넌트를 차례대로 생성해주었다.
최종 form 데이터와 count는 InfoNotice
에서 관리해주고, InfoNoticeForm
에서 각각의 input 데이터와 추가 option 데이터를 같이 관리해줄 수 있도록 했다. 그러기 위해서는 InfoNoticeForm
컴포넌트에서 합쳐진 input 데이터와 추가 option 데이터를 다시 상위 컴포넌트(InfoNotice
)로 보내주는 작업도 필요했다.
infoDic
const infoDic = [
{
id: 1,
name: "name",
title: "제품명 / 중량",
placeholder: "제품명/중량을 입력해 주세요.",
},
,
{
id: 2,
name: "origin",
title: "원산지 / 원재료 함량",
placeholder: "원산지/원재 함량을 입력해 주세요.",
}
...
];
form 내부 input의 title이나 placeholder를 매번 하드코딩하긴 어려울 것 같아서 InfoNoticeForm
파일에 미리 데이터(infoDic
)를 생성하고 매핑한 뒤 이후에 이벤트 함수를 통해서 변경해주는 방식을 취했는데, 하드코딩을 하지 않으려다가 오히려 로직이 더 복잡해지면서 꽤나 괴로운 시간(삽질)을 보냈다.. 😂
InfoNoticeForm.js
const InfoNoticeForm = ({ count, notice, setNotice, delNotice }) => {
const [optionList, setOptionList] = useState([]);
function changeTitle(oldTitle, newTitle) {
if (!Object.keys(notice).includes(newTitle)) {
setOptionList((prev) =>
prev.map((option) => {
if (option.id === oldTitle || option.name === oldTitle) {
return { ...option, name: newTitle };
}
return option;
})
);
setNotice((prev) => {
const value = prev[oldTitle];
const result = {
...prev,
[newTitle]: value,
};
delete result[oldTitle];
return result;
});
}
}
return (
<Container>
...
{infoDic.map((item) => (
<InfoBox key={item.id}>
...
<Input
id={item.name + "-input"}
width="600px"
placeholder={item.placeholder}
value={notice[item.name]}
onChange={(e) => {
setNotice((prev) => ({
...prev,
[item.name]: e.target.value,
}));
}}
/>
</InfoBox>
))}
{optionList.map((item) => (
<InfoBox key={item.id}>
<Input
...
onChange={(e) => {
const newTitle = e.target.value;
changeTitle(item.name || item.id, newTitle);
}}
/>
<Input
...
onChange={(e) => {
setNotice((prev) => ({
...prev,
[item.name || item.id]: e.target.value,
}));
}}
/>
...
</InfoBox>
))}
...
<Button
onClick={() => {
const newOption = { id: crypto.randomUUID(), name: "" };
setOptionList((prev) => [...prev, newOption]);
}}
...
/>
</Container>
);
};
한 눈에 보기에도 복잡해보인다.. 조금 더 간결하게 코드를 작성할 수 있는 방법을 고민해보고 추후에 리팩토링을 진행할 예정이다. 🥲
form 에서 각각 입력한 데이터를 하나의 form 데이터로 합쳐주게 되면서, form 을 여러개 추가하더라도 각각의 form 데이터가 하나의 객체 형식으로 들어가게 된다.
console.log("InfoNotice Data Result", JSON.stringify(form));
최종적으로는 이런 데이터 형태로 출력되는 걸 확인할 수 있다.
[
{
"count":1,
"name":"제품명 내용",
"origin":"원산지 내용",
"ranking":"등급 내용",
"keep":"보관 내용",
"type":"식품 유형 내용",
"추가항목제목1":"추가항목내용1",
"추가항목제목2":"추가항목내용2"
}
]
🔹 추가 리팩토링 : id 값으로 form 삭제/관리하기 (update : 3/21)
☑️ 이슈 확인
정보고시 폼을 추가/삭제하는 기능을 구현하면서, 정보고시 폼 리스트에 count
값을 추가하여 폼의 갯수를 count
로 관리하도록 해왔다. 그런데 정보고시 폼 삭제 기능에 약간의 문제가 있었다는 걸 깨달았다. 삭제 버튼을 클릭하여 삭제되는 폼이 해당 폼이 아니라 정보고시 리스트의 마지막 순서의 폼이 삭제되었기 때문이다.
InfoNotice.jsx
const [count, setCount] = useState(2);
const [form, setForm] = useState([{ count: 1 }]);
const addNotice = (newNotice) => {
setCount((prev) => prev + 1);
setForm((prev) => [...prev, { ...newNotice, count }]);
};
const delNotice = () => {
if (count <= 1) return;
setCount((prev) => prev - 1);
setForm((prev) => prev.slice(0, prev.length - 1));
};
return (
<InfoContainer>
{form.map((notice) => (
<InfoNoticeForm
key={notice.count}
count={notice.count}
notice={notice}
delNotice={delNotice}
...
count
로 갯수를 관리하면서 정보고시 폼 리스트의 배열을 slice(0, prev.length - 1)
로 가장 마지막 순서만 삭제하다보니 내가 선택해서 삭제하려고 했던 form을 삭제할 수 없었다.
☑️ 어떻게 해결했을까?
특정한 폼을 삭제하기 위해서는 정보고시 폼 마다 고유한 id
값이 필요했다. 현재는 count
로 관리해주고 있었지만, 고유한 id
값을 추가해서 id
를 기반으로 폼 리스트를 필터링해주려고 했다. 이제 form
의 상태(state) 초기값에 id
값을 지정하고, 정보고시 폼이 추가될 때에도 마찬가지로 Math.random()
을 이용하여id
값이 자동으로 생성될 수 있도록 했다.
InfoNotice.jsx
const [form, setForm] = useState([{ count: 1, id: 1 }]);
const addNotice = (newNotice) => {
setCount((prev) => prev + 1);
setForm((prev) => [...prev, { ...newNotice, count, id: Math.random() }]);
};
먼저 삭제 버튼의 onClick
이벤트로 특정 form
의 데이터를 받아와야 했기 때문에 form
을 매핑할 때 InfoNoticeForm
컴포넌트에 prop으로 전달했던 delNotice()
함수에 해당 form
을 인자로 전달해주었다.
const delNotice = (targetItem) => {
if (count <= 1) return;
setCount((prev) => prev - 1);
setForm((prev) => prev.slice(0, prev.length - 1));
}
return (
<InfoContainer>
{form.map((notice) => (
<InfoNoticeForm
key={notice.count}
count={notice.count}
notice={notice}
delNotice={() => delNotice(notice)}
...
InfoNoticeForm.jsx
const InfoNoticeForm = ({ count, notice, setNotice, delNotice }) => {
...
return (
<Button
...
onClick={() => {
delNotice();
}}
/>
};
delNotice()
의 인자로 받아온 targetItem
(form) 의 id
값과 기존의 폼 내부의 id
값과 비교하여 targetItem
의 id
값이 form 의id
값과 다른 form(삭제 버튼으로 선택하지 않은 폼)만 남길 수 있도록 filter()
메소드로 필터링해주었다.
const delNotice = (targetItem) => {
if (count <= 1) return;
setCount((prev) => prev - 1);
setForm((prev) => {
const filteredList = prev.filter(
(formItem) => formItem.id !== targetItem.id
);
return filteredList.map((item, index) => {
return { ...item, count: index + 1 };
});
});
}
그리고 필터링 된 배열을 매핑하여 스프레드 연산자로 기존의 form 객체들을 그대로 받아오고, count
를 index 값에 1을 더하는 방식으로 count
를 새롭게 카운팅 해주었다. 여기까지 로직 상에는 크게 문제는 없어보였는데 라이브 서버로 확인해보았을 때 원하는 폼을 삭제하려고 해도 다른 폼이 삭제되는 이슈가 지속해서 발생했다. console로 남은 form을 출력해보면서 분명 삭제를 누른 폼의 데이터가 삭제 되었음을 확인했지만, UI상에서는 해당 폼이 삭제가 되는 것처럼 보여지지 않았던 것이다.
return (
<InfoContainer>
{form.map((notice) => (
<InfoNoticeForm
key={notice.count}
count={notice.count}
notice={notice}
delNotice={() => delNotice(notice)}
...
다시 코드를 천천히 살펴보니 아주 간단한 부분에서 문제가 있었음을 깨달았다. 바로 form
을 매핑하여 InfoNoticeForm
컴포넌트에 prop으로 데이터를 넘길 때 key
를 notice.count
로 넘겨주고 있었던 것이다.
return (
<InfoContainer>
{form.map((notice) => (
<InfoNoticeForm
key={notice.id}
count={notice.count}
notice={notice}
delNotice={() => delNotice(notice)}
...
key={notice.count}
을 key={notice.id}
로 변경하고 나서야 원하는 form이 데이터 뿐만 아니라, UI 상으로도 정상적으로 삭제가 되는 것을 확인할 수 있었다. 또한 중간에 위치한 form을 삭제할 때마다 count
도 삭제된 폼을 제외하고 재 카운팅되었다.
React는
key
를 통해서 현재 배열의 길이를 비롯하여 이미 렌더링한 아이템의 갯수를 확인할 수 있고 동시에 아이템의 '위치'까지 고려하게 된다. 그런데 React가 개별 아이템을 식별할 수 있도록 해주는 바로 이key
값을count
로 설정을 해주었기 때문에, 해당 form을 삭제 해도key
값은 교체가 되지 않아 이러한 문제가 발생했던 것이다. 초반에key
의 문제라고 생각하지 못하고 여러번 로직을 수정했는데.. 꽤나 허탈한 감이 있다. 앞으로는 아무리 간단한 로직이라도, 기본을 지키고 있는지 체크하면서 코드를 좀더 면밀하게 살펴보며 코드를 작성해야 겠다는 생각이 든다.
🙋🏻♀️ 아쉬운 점
내가 맡은 form
에서 추출한 데이터 뿐만 아니라, 전체 페이지에서 입력한 모든 데이터들을 전역 상태로 관리해주지 못했다. 처음부터 각자 맡은 데이터를 콘솔에 출력하는 것까지만 고려했기 때문에 팀원 모두의 데이터를 하나로 통합하는 것까지는 미처 생각하지 못했고 이 부분을 제대로 해결하지 못한 채로 끝내게 되어서 적잖은 아쉬움이 있다. 처음부터 모든 데이터를 전역 상태로 관리할 수 있도록 했다면, 마지막에 데이터를 통합하는 시도에서 조금 더 쉽게 작업할 수 있었을 것 같다.
✍🏻 회고
이번 과제는 지난 과제보다 (상대적으로) 볼륨이 크다고 느껴졌기 때문에 이전보다 프로젝트 일정 관리에 특히나 신경을 더 써야겠다고 생각했다. 그러기 위해서는 팀원들과 충분한 협의와 소통을 통해 테스크를 나누고 제출 기한에 밀리지 않도록 데드라인을 정하는 게 무엇보다 중요했다. 짧은 시간동안 프로젝트를 완성해야 한다는 부담감이 있었지만, 첫번째 프로젝트를 진행해보니 프로젝트를 시작할 때나 프로젝트가 진행되는 사이에도 팀원들 간에 충분한 소통과 협의가 중요하다는 걸 느꼈기 때문이다. 그래서 기능 구현을 완료하고, 제출 기한을 맞추는 것에 초점을 두는 동시에 팀원들이 원활하게 커뮤니케이션을 하면서 자유롭게 의견을 제시할 수 있도록 했다. 그래서 대체 내가 어떤 '노력'을 했는지 한 번 돌아보자면! 😎
- 모두가 사용하고 공유할 수 있는 노션 페이지를 만들었다!
- 회의나 스크럼에서 팀원들과 논의한 내용을 팀 노션 페이지에 모두 기록했다!
- 프로젝트 일정을 공유하고, 각자의 진행 상황을 기록하며 팀 내에서 공유할 수 있도록 했다!
- 디스코드 채널에 상주하면서 팀원들의 진행사항과 이슈 사항을 실시간으로 체크하고 도움이 필요하면 언제든 나서서 도와주려고 했다!
사실 첫번째 과제보다 구현해내야할 사항들이 많았기 때문에, 하루 반나절 이라는 빡빡한 일정에 맞추는 게 생각보다 쉽지 않았다. 하지만 이틀에 가까운 시간 동안 팀원 모두가 밤을 새며 최선을 다해서 구현해냈다. 그 과정 속에서 아무도 포기하지 않았고, 모두가 서로를 응원하며 끝까지 노력한 결과 배포까지 할 수 있었다고 생각한다. 최선을 다한 팀원 모두에게 감사를 전하고 싶다.
🗂 Reference
JavaScript 내장 메소드를 사용하여 숫자 천단위마다 콤마 찍기
Understanding "Keys"
Author And Source
이 문제에 관하여([원티드 프리온보딩 프론트엔드 과정] 2차 과제, 상품 등록 관리 페이지), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@ichbinmin2/원티드-프리온보딩-프론트엔드-과정-2차-과제-상품-등록-관리-페이지저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)