상품의 상세 정보를 출력하는 기능은.. 전역 변수를 관리하는 라이브러리가 달라졌다는 것 이외에는 이전 프로젝트에서 크게 변화한 점은 없다. 이전에는 DB 문서의 ID값을 URL Param으로 보내서 데이터를 조회하는데 사용하였다면, 이번에는 제품 이름을 기준으로 사용한다 정도..
페이지 하단에 상품의 상세한 정보를 알려주는 제품정보 컴포넌트와 상품의 리뷰를 작성하는 기능도 마찬가지이다.
필요한 컴포넌트를 출력하는 방법은 회원가입 기능의 UI를 구현할 때 상황에 따라 다른 컴포넌트가 출력되도록 하는 방식을 그대로 사용했고, 리뷰를 작성하는 기능과 DB 구조, 해당 리뷰가 어느 제품의 리뷰인지 구분하는 방식은 이전 프로젝트의 댓글 기능과 동일하다.
네이버 등지에서 물건을 살때 보면, 제품마다 여러가지 옵션이 존재하고 구매자는 원하는 옵션을 선택하여 원하는 수량을 조정한 다음에 제품을 구매할 수 있다. 따라서 나도 이 기능을 구현해보기로 하였다.
우선 이 기능이 어떤 원리로 동작할 것인지를 생각해보았다. 무언가 감이 잡히는 듯, 잡히지 않는 듯 머리 속이 복잡해졌으나 어느 순간 갑작스럽게 구현 방식이 떠올랐다.
선택한 옵션들은 자유롭게 추가되고 또 삭제될 수 있어야 한다. 또 선택된 옵션들은 화면에 실시간으로 출력되어야 하는데, 이를 위해서는 map 함수를 이용한 배열 렌더링이 가장 적합한 방법이다. 그렇다면 우선 필요한 것은 옵션 데이터를 저장할 배열이 하나 필요하다는 것!
const [purchaseList, setPurchaseList] = useState([]);
다음은 UI의 구현이다. 내가 구현한 상품 등록 방식은 하나의 상품에 최대 5개 옵션만을 등록할 수 있도록 하였다. 또한 옵션이 존재하지 않을 때는 필드에 '옵션없음' 값이 들어가도록 하였는데, 이 점을 이용해서 필드의 값이 옵션없음이 아닌 경우에만 option 태그에 출력되도록 조건부 렌더링 방식을 사용하였다.
<PurchaseSelect onChange={onSelectPurchaseOption}>
<option value=''>제품옵션선택</option>
{productData[0]?.productOption.option1 !== '옵션없음' && <option value={productData[0]?.option1}>{productData[0]?.productOption.option1}</option>}
{productData[0]?.productOption.option2 !== '옵션없음' && <option value={productData[0]?.option2}>{productData[0]?.productOption.option2}</option>}
{productData[0]?.productOption.option3 !== '옵션없음' && <option value={productData[0]?.option3}>{productData[0]?.productOption.option3}</option>}
{productData[0]?.productOption.option4 !== '옵션없음' && <option value={productData[0]?.option4}>{productData[0]?.productOption.option4}</option>}
{productData[0]?.productOption.option5 !== '옵션없음' && <option value={productData[0]?.option5}>{productData[0]?.productOption.option5}</option>
</PurchaseSelect>
이에 따라서 제품의 옵션이 하나만 존재할 경우, 아래와 같이 화면상에 출력된다.
onChange 속성을 이용하여 option이 선택되었을 때 데이터가 배열에 저장되도록 하면 된다.
우선 가장 먼저 해야할 일은 데이터의 유효성 검사. option 태그의 값이 지정되어 있기 때문에 무슨 유효성을 검사할까 싶지만, 리액트에서 select 태그를 사용할 때 selected를 사용할 수 없는 문제로 인해서 select 태그를 구현할 때 해당 태그에 대한 설명을 작성하는 부분도 option 태그를 이용해 구현하였다. 따라서 이 태그의 값이 옵션 배열에 들어가버릴 수 있으니 이를 방지해야한다. 또한 한번 선택된 옵션이 또다시 선택되어서는 안된다.
이를 위해 함수 내부에 아래와 같은 분기를 추가해준다.
// 옵션선택문구는 옵션선택으로 간주되지 않도록 한번 걸러준다.
if (event.target.value === '') {
return;
};
// 한번 선택된 옵션이 다시 선택되지 않도록 걸러준다.
for (let value of purchaseList) {
if (event.target.value === value.optionName) {
return;
};
};
상단에 있는 옵션 선택 스크린샷에서 '제품옵션선택'은 해당 옵션 선택 태그가 어떤 것인지 설명해주는 역할일 뿐 제품의 옵션에 포함되지 않는다. 따라서 value의 값을 빈 문자열로 지정하여 '제품옵션선택'이 선택되었을 때 옵션 배열에 추가되지 않도록 조건문과 return를 사용하였다.
또한 옵션을 선택하였을 때, 옵션 배열을 한번 순회하여 선택된 옵션명이 이미 배열 내부에 존재할 경우에 또 배열에 추가되지 못하도록 구현하였다.
해당 페이지에서는 결제 페이지에 필요한 정보만을 취합하여 넘겨주어야 한다. 따라서 다음 순서는 데이터 형식에 따라 알맞은 형태로 가공하는 것.
const data = {
optionNumber: '',
optionName: '',
optionPrice: '',
purchaseQuantity: 1,
totalAmount: 0,
};
옵션 배열에는 해당 옵션이 몇번 옵션인지, 옵션명은 무엇인지, 옵션의 가격은 무엇이며 구매 수량과 총 금액은 얼마인지의 정보가 필요하다. 이 구조에 맞춰 데이터를 가공해주어야 한다.
시리즈 마지막에 설명하겠지만, 기능의 동작 구조와 DB의 구조 등을 설계하는 경험과 능력이 부족해서 결과적으로 상품의 정보에서 옵션의 정보들은 각기 다른 필드에 저장되도록 만들어졌다. 따라서 필요한 정보가 나누어 저장되어 있으므로 for 문을 2번 이용하여 각 필드에서 필요한 데이터를 가지고오도록 하였다.
이런 이유로 옵션을 선택하게 되면 아래와 같은 데이터 가공 과정을 거치게 된다.
for (let [key, value] of Object.entries(productData[0]?.productOption)) {
if (event.target.value === value) {
data.optionNumber = key;
data.optionName = value;
break;
};
};
for (let [key, value] of Object.entries(productData[0]?.productOptionSurchargePrice)) {
if (data.optionNumber === key) {
data.optionPrice = value - (value * productData[0]?.discountRate / 100);
data.totalAmount = value - (value * productData[0]?.discountRate / 100);
break;
};
};
첫 번째 for문.
프론트에서 가지고 있는 값은 옵션의 이름. 따라서 DB의 상품 옵션 이름을 저장하고 있는 필드에 저장된 데이터를 순회하여 일치하는 필드의 옵션명과, 필드의 key값을 가지고 온다. 옵션 정보는 동일한 key값을 사용하고 있으므로 이것이 다른 데이터를 조회하는데 필요하기 때문이다.
두 번째 for문.
가지고 온 옵션 필드의 key을 이용해서, 해당 옵션의 다른 정보들을 가지고 온다. 옵션 배열은 상품 옵션의 가격 데이터를 필요로 하는데, 구매 수량을 조절하는 기능을 구현하기 위해서는 개당 가격과 수량에 따라 변화하는 총 가격을 계산해야한다. 이 함수는 수량을 조절하지 않고 단지 옵션 배열에 초기값을 선언하기만 하는 역할이므로 개당 가격과 총 가격은 동일하게 가져오면 된다. 다만 제품에 적용된 할인률이 존재하므로 기본 가격에 할인률에 따라 가격을 뺀 값을 저장하도록 하였다.
setTotalQuantity(totalQuantity + data.purchaseQuantity);
setPurchaseList([...purchaseList, data]);
마지막은 구매하려는 제품들의 총 수량을 계산하고, 옵션 배열에 데이터를 저장한다.
옵션을 선택하면, 배열에 가공된 데이터가 저장되고 map 함수를 이용한 배열 렌더링을 통해 위와 같이 화면에 출력된다.
이제 구현해야하는 남은 기능은 2가지. 제품 수량을 조정하는 기능과 옵션을 삭제하는 기능이다.
{purchaseList.map((item) => (
(...)
<div>
<button onClick={() => onPurchaseQuantity('-', item)}>-</button>
<p>{item.purchaseQuantity}</p>
<button onClick={() => onPurchaseQuantity('+', item)}>+</button>
</div>
(...)
)
제품의 수량을 조절한다는 것은 이미 배열에 저장되어 있는 데이터의 '일부'를 변경해야한다는 것이다. 이걸 어떻게 구현할까 생각하다가.. 새 데이터를 생성하여 기존 데이터를 대체하는 방식으로 구현하였다.
const onPurchaseQuantity = (type, item) => {
// 깊은 복사로 item 객체를 복사.
const data = JSON.parse(JSON.stringify(item));
// 변경된 갯수값을 반영.
if (type === '-') {
data.purchaseQuantity -= 1;
if (data.purchaseQuantity < 1) {
alert('상품의 갯수는 1개 이상이어야합니다.');
data.purchaseQuantity = 1;
return;
};
data.totalAmount = data.optionPrice * data.purchaseQuantity;
setTotalQuantity(totalQuantity - 1);
}
else if (type === '+') {
// 각 옵션의 구매갯수 제한보다 더 많은 수량을 선택할 수 없도록 한다.
for (let [key, value] of Object.entries(productData[0]?.productOptionPurchaseQuantityLimit)) {
if (data.optionNumber === key) {
if (data.purchaseQuantity > value) {
alert('구매 갯수 제한보다 더 많이 구매할 수 없습니다.');
break;
}
else {
data.purchaseQuantity += 1;
data.totalAmount = data.optionPrice * data.purchaseQuantity;
setTotalQuantity(totalQuantity + 1);
};
};
};
};
// 기존 배열의 optionNumber와 현재 제어중인 optionNumber를 비교하여..
// 동일한 경우에는 새롭게 만든 data가 기존의 요소를 대체하도록 한다.
const itemNumber = item.optionNumber;
setPurchaseList(
purchaseList.map((item) =>
item.optionNumber === itemNumber ? data : item
)
);
};
const data = JSON.parse(JSON.stringify(item));
제품의 수량을 변경하는 함수는 map 함수 내부에 선언되어 옵션 배열의 데이터를 그대로 가지고 올 수 있다. 매개변수를 통해 데이터를 가져온 다음에는 우선 데이터 불변성 유지를 위해서 깊은 복사로 데이터 복사본을 생성한다.
다음은 수량을 빼는 작업인지, 더하는 작업인지를 조건문으로 구분하여 분기를 나누었다. 수량을 빼는 경우에는..
data.purchaseQuantity -= 1;
우선 구매수량에서 1을 뺀다.
if (data.purchaseQuantity < 1) { alert('상품의 갯수는 1개 이상이어야합니다.'); data.purchaseQuantity = 1; return; };
제품의 구매 수량은 0 이하가 될 수 없다. 따라서 수량에서 1을 뺀 결과값이 1보다 작을 경우에는 다음과 같은 경고를 출력하고 상품 갯수를 1로 지정하고 함수를 종료하게 하였다.
제품의 구매 수량에서 1을 뺀 결과값이 1보다 작지 않을 경우에는, 옵션 배열에 저장된 총 구매금액을 다시 계산해준 다음, 제품의 총 구매 수량에서 1을 빼도록 구현하였다.
data.totalAmount = data.optionPrice * data.purchaseQuantity;
setTotalQuantity(totalQuantity - 1);
제품의 구매 수량을 더하는 기능도 기본 구조는 비슷하지만, 여기는 다른 제약조건이 하나 존재한다. 상품의 옵션마다 구매제한수량을 지정할 수 있는데, for 문을 이용해서 DB 옵션 필드의 구매제한수량이 몇 개인지 가지고 온 다음. 구매하려는 수량이 제한수량을 초과할 경우 아래와 같은 경고창이 출력되도록 하였다. 문제가 없을 경우에는 위에서 설명했던 것 처럼 필요한 값을 수정하게 된다.
이렇게해서 깊은 복사로 만든 복제본에서 수정해야할 부분을 모두 수정하였다. 남은 것은 이 복제본이 옵션 배열에 있는 기존값을 대체하도록 하는 것.
const itemNumber = item.optionNumber; setPurchaseList( purchaseList.map((item) => item.optionNumber === itemNumber ? data : item ) );
optionNumber을 기준으로 map 함수를 사용하여 옵션 배열 내부를 순회, 바뀌어야 하는 요소를 식별하여 새로운 데이터로 대체되도록 구현하였다.
{purchaseList.map((item) => (
(...)
<button onClick={() => onOptionDelete(item)}>X</button>
(...)
)
수량 조절에 비하면 너무 간단해서 구현에서 행복을 느끼기까지 한 기능..
const onOptionDelete = (item) => {
const itemNumber = item.optionNumber;
setPurchaseList(
purchaseList.filter((item) =>
item.optionNumber !== itemNumber
)
);
setTotalQuantity(totalQuantity - item.purchaseQuantity);
};
이번에는 따로 데이터를 수정하고 가공할 필요가 없다. 수량 조절 기능과 동일한 방식으로 optionNumber을 가지고 오고, 이번에는 map 함수가 아닌 filter 함수를 이용해서 optionNumber와 일치하는 요소를 삭제하고 나머지를 남기도록 하면 된다.
이 프로젝트를 진행하면서 계속 문제가 된 점은 Redux Store에서 어떤 구조로 state를 저장하고 가지고와야하는지 모범답안을 모르겠고, DB의 구조는 어떻게 되어야하는 지도 알 수가 없다는 것이었다. 일단 어찌어찌 만들어봤는데 기능을 구현할 때마다 뭐가 부족하고 뭐가 없어서 구조를 계속 수정해야 했고, 이미 구현한 기능들도 변경된 구조에 맞춰서 죄다 갈아엎어야 했다. 이걸 반복하다보니 프로젝트의 전체적인 코드는 상당히 꼬여있고, 가독성이나 유지보수의 편리함은 죄다 뒷전으로 밀려났고, 컴포넌트 최적화는 아예 염두되지 않았다.
이 기능도 처음부터 끝까지 아무 검색 없이 내가 혼자 구현한 것인데, 내 실력도 경험도 부족한데 위와 같은 이유까지 겹치면서 이게 제대로 된 코드인지 의심스러울 뿐이다. 동작은 잘 되니까 문제가 없지않냐고 하지만.. 언젠가 리팩토링이 필요하다는 것은 확실하다.
좋은 정보 얻어갑니다, 감사합니다.