저번
Wepleshop
1차 프로젝트를 수행 후 두번째 팀 프로젝트입니다. 1차 프로젝트에서의 부족했던 점과 더 배우고 싶었던 점을 깨달았고, 이를 보완하는 방향으로 프로젝트를 진행해보자 하는 생각으로 이번 프로젝트를 진행하였습니다.
이번 클론 코딩 프로젝트의 주제는Nike
웹사이트 입니다.
나이키는 스포츠웨어 브랜드 중에서 가장 유명한 브랜드라고 해도 과언이 아닐 것입니다. 평소 나이키를 좋아하는 저와 희윤님에 의해Nike
가 채택되었고, 1차 프로젝트인 마플샵 클론코딩과 같은 웹 쇼핑몰이기에 진행 방향이 비슷했습니다. 차이점은 리스트 페이지 내에서의 다양한필터링
SNKRS 페이지에서의 응모 및 추첨
기능이었으며, 백엔드를 구현할 저와 준혁님은 각자 이러한 메인 기능(필터링, SNKRS)을 한 가지씩 분담하여 프로젝트를 진행하였습니다.
백엔드 : 이준혁 김영욱
프론트 엔드 : 황희윤, 이진웅
- Front-End : React.js, Sass
- Back-End : Node.js, Express, Prisma, nodemon, JWT, Bcrypt, My SQL, CORS
- Common : RESTful API
- Community Tools : Slack, Zoom, Notion
저번 프로젝트는 프론트엔드
와 백엔드
를 개인이 모두 한가지 이상을 구현하며 Fullstack
으로 프로젝트를 진행하였습니다.
1차 프로젝트를 통해 프론트 엔드
와 백엔드
중 어떤 것이 각자 자신에게 적합한지를 파악 가능했으며, 이번 2차 프로젝트는 프론트 엔드
와 백엔드
의 역할을 명확히 나누어, 프로젝트를 진행하였습니다.
백엔드 외의 역할이 있었다면, 프론트 엔드단에서 사용할 이미지(제품 이미지)는 순조로운 작업을 위해 백엔드인 저와 준혁님이 자료 수집을 진행하였습니다.
그 외엔 프론트 엔드와 백엔드의 작업들이 모두 역할 분담을 통해 이루어졌습니다.
나이키 공식 홈페이지입니다. 해당 페이지의 큰 범주인 메인 페이지, 리스트 페이지 및 필터 기능, 디테일 페이지 입니다.
(나이키 디테일 페이지 / 출처 : 나이키 공식 홈페이지)
(나이키 리스트 페이지 / 출처 : 나이키 공식 홈페이지)
(나이키 리스트 페이지 - 필터링 (1) / 출처 : 나이키 공식 홈페이지)
(나이키 리스트 페이지 - 필터링 (2) / 출처 : 나이키 공식 홈페이지)
(나이키 디테일 페이지 / 출처 : 나이키 공식 홈페이지)
준혁님과 호흡을 맞췄던 백엔드 작업입니다. 초기 모델링, 프로젝트 초기 세팅, 데이터 생성 및 활용, 미들웨어 및 API 생성 등의 작업들을 진행하였습니다. 협업을 하면서 서로 부족한 점에 있어서 서로 도울 수 있었던 점이 매우 좋았으며, 처음 같이 작업을 진행하였지만, 합이 꽤나 잘 맞아서 재밌고 즐겁게 프로젝트를 진행할 수 있었습니다.
모델링 작업과 데이터 입력을 모두 마무리 후, 각자 구현할 API에 대한 역할 분담을 진행하였습니다.
(MVC Pattern 적용 : Model, Services, Controller 계층화)
routes/index.js
routes/productRouter.js
routes/userRouter.js
routes/snkrsRouter.js
(REST API 적용 : GET, POST, PUT , DELETE 활용)
프로젝트의 모든 진행은
MVC Pattern
을 적용한 계층적 구조로 진행하였으며,REST API
를 통한API
구현을 진행하였습니다.
List Page API
FILTER API
REVIEW API
KAKAO Login API
인가(장바구니 권한) 및 미들웨어 API
Detail Page API
CART API
SNKRS API
검색 API
저번 프로젝트에서는 로그인 회원가입 및 장바구니와 좋아요 관련된 API를 구현했다. 이번에도 그러한 기능들을 구현한다면 작업은 순조롭겠지만 내 자신의 발전이 조금도 되지 않을거라고 판단하였다. 그렇기에 이번에는 나이키의 메인 기능 중 하나라고 생각하는 Filter API
를 포함하여, REVIEW API
, LIST PAGE API
, KAKAO LOGIN 관련 API
를 구축하기로 마음 먹었다. (작업을 하면서 FILTER API
는 리스트 페이지 내에서 자체적으로 수행하는게 좋을 것같아 두 API를 병합하여 작업하였습니다.)
가장 어려워 보이는 API는 FILTER API
와 SNKRS API
였다. 리스트 페이지 내의 좌측 필터는 내부에서는 같은 항목 내에서는 OR 기능, 다른 항목 끼리는 AND 기능을 통해 필터 기능을 구축하고 있었기 때문에 직관적으로 구현 방향을 찾기가 힘들었다. (예시를 들자면 색상에서 PINK, GRAY를 선택하면 해당 2컬러의 제품이 모두 나오고, 이후 브랜드에서 ACG를 선택한다면 이전 필터링에서 브랜드가 ACG인 제품만이 나오도록 필터가 구축되있다.)
SNKRS 기능은 유저가 정해진 시간 동안 제품의 응모가 가능하고, 응모가 끝날 경우 당첨 결과가 나오고 응모 페이지 신청이 마감되는 구조이기 때문에 처음 접해보는 기능을 구현하는 것이고, 새로운 기능인만큼 난이도가 있었다. 그렇기에 이 두 기능은 각자 한 가지씩 나눠서 구현하기로 진행하였다.
<productController.js>
const productList = async (req, res) => {
try {
const {
genderId,
categoryId,
colorId,
sizeName,
subBrandName,
subIconName,
subClothesName,
subAccessoriesName,
sortMethod,
search,
} = req.query; // request의 query에서 요청한 정보를 받아옴
const list = await productServices.productList(
genderId,
categoryId,
colorId,
sizeName,
subBrandName,
subIconName,
subClothesName,
subAccessoriesName,
+sortMethod, // 제품의 정렬 기준을 위한 sortMethod(숫자형)
search
);
return res.status(200).json({ message: 'ProductFilterList', list });
} catch (err) {
console.log(err);
return res.status(500).json({ message: 'Load Fail' });
}
};
<productServices.js>
const productList = async (
genderId,
categoryId,
colorId,
sizeName,
subBrandName,
subIconName,
subClothesName,
subAccessoriesName,
sortMethod,
search
) => {
const arrayChange = filterValue => {
if (filterValue) {
if (typeof filterValue === 'string') { // 받아온 정보가 단일이거나 복수일 경우에 따라 조건문을 통해 적합한 문자열로 변환)
return `(${filterValue})`;
} else return '(' + filterValue.join() + ')';
} else return filterValue;
};
const arrayColorIdChange = filterValue => {
if (filterValue) {
if (typeof filterValue === 'string') { // 받아온 정보가 단일이거나 복수일 경우에 따라 조건문을 통해 적합한 문자열로 변환)
return `(${Number(filterValue)})`; // 변수를 문자형이 아닌 숫자로 변환후 반환
} else return '(' + filterValue.join() + ')';
} else return filterValue;
};
let isSearch = false;
if (search) isSearch = true;
let list = await productDao.getProductList(
genderId,
categoryId,
arrayColorIdChange(colorId),
arrayChange(subBrandName),
arrayChange(subIconName),
arrayChange(subClothesName),
arrayChange(subAccessoriesName),
sortMethod,
search,
isSearch
);
// 제품마다의 재고 사이즈를 할당 & 사이즈 필터링 기능 구현
if (list) {
for (let i = 0; i < list.length; i++) {
let sizeObject = await productDao.getSizes(list[i].styleCode);
let sizeArr = sizeObject.map(e => e.size);
list[i].sizes = sizeArr;
}
if (sizeName) {
let sizeSyntax = ``;
if (typeof sizeName === 'string') {
sizeSyntax = `x.sizes.indexOf(${sizeName})!==-1`;
} else {
for (let i = 0; i < sizeName.length; i++) {
if (i === 0) {
sizeSyntax += `x.sizes.indexOf(${sizeName[i]})!==-1`;
} else {
sizeSyntax += `||x.sizes.indexOf(${sizeName[i]})!==-1`;
}
}
}
list = list.filter(x => eval(sizeSyntax));
}
}
return list;
};
<productDao.js>
const getProductList = async (
genderId,
categoryId,
colorId,
subBrandName,
subIconName,
subClothesName,
subAccessoriesName,
sortMethod,
search,
isSearch
) => {
// 필터링할 기능에 따라 filterQuery를 구현 (prisma.raw)
let filterQuery = ``;
if (colorId) {
filterQuery += ` AND products.color_id in ${colorId}`;
}
if (subBrandName) {
filterQuery += ` AND sub_brand.name in ${subBrandName}`;
}
if (subIconName) {
filterQuery += ` AND sub_icon.name in ${subIconName}`;
}
if (subClothesName) {
filterQuery += ` AND sub_clothes.name in ${subClothesName}`;
}
if (subAccessoriesName) {
filterQuery += ` AND sub_accessories.name in ${subAccessoriesName}`;
}
const list = await prisma.$queryRaw`
SELECT
product_genders.name as genderName,
categories.name as categoryName,
products.style_code as styleCode,
products.name as productName,
products.normal_price,
products.sale_rate,
products.sale_price,
products.is_member,
product_img_urls.name as imgUrl,
product_colors.name as colorName,
products.color_id,
sub_brand.name as subBrandName,
sub_icon.name as subIconName,
sub_clothes.name as subClothesName,
sub_accessories.name as subAccessoriesName
FROM
products
JOIN
product_genders ON products.gender_id=product_genders.id
JOIN
categories ON products.category_id=categories.id
JOIN
product_img_urls ON products.style_code=product_img_urls.style_code
LEFT JOIN
product_colors ON products.color_id=product_colors.id
LEFT JOIN
sub_icon ON products.sub_icon_id=sub_icon.id
LEFT JOIN
sub_brand ON products.sub_brand_id=sub_brand.id
LEFT JOIN
sub_clothes ON products.sub_clothes_id=sub_clothes.id
LEFT JOIN
sub_accessories ON products.sub_accessories_id=sub_accessories.id
WHERE
If(${isSearch},products.name like '%${raw(search)}%',TRUE)
AND
product_img_urls.is_main=1
AND
CASE
WHEN ${genderId} and ${categoryId} THEN products.gender_id = ${genderId} and products.category_id = ${categoryId}
WHEN ${genderId} THEN products.gender_id = ${genderId}
WHEN ${categoryId} THEN products.category_id = ${categoryId}
ELSE TRUE
END
${raw(filterQuery)}
ORDER BY
case WHEN ${sortMethod} = null then products.create_at end ASC,
case WHEN ${sortMethod} = 1 then products.create_at end ASC,
case WHEN ${sortMethod} = 2 then products.review_counts end DESC,
case WHEN ${sortMethod} = 3 then products.name end DESC,
case WHEN ${sortMethod} = 4 then products.sale_rate end DESC,
case WHEN ${sortMethod} = 5 then products.normal_price end ASC,
case WHEN ${sortMethod} = 6 then products.normal_price end DESC;
`;
return list;
};
// 각 제품에 따른 재고 사이즈 파악을 위한 Query
const getSizes = async styleCode => {
const sizes = await prisma.$queryRaw`
SELECT
product_sizes.name as size
FROM
product_with_sizes
JOIN
product_sizes ON product_with_sizes.product_size_id=product_sizes.id
WHERE
product_with_sizes.style_code=${styleCode};
`;
return sizes;
};
가장 많은 시간과 공을 들인 LIST & FILTER API 입니다. 우선 리스트 페이지에서 필요한 정보들을 받아오는 API인 LIST API를 먼저 구현하였고, 이후 불러온 리스트 페이지의 데이터를 필터링할 방법에 대한 긴 고민이 이어졌습니다. 계속 모니터 앞에 앉아 있어도 좋은 생각이 떠오르지 않아, 산책을 하던 중, 나이키 홈페이지에서 자체적으로 필터 기능을 어떻게 처리할지에 대해 생각하던 중 '리스트 API로 받아온 정보들을 filter 메소드를 통해 원하는 정보만 추출하면 되지 않을까?' 라는 생각이 들었고, 시간을 들여 코드를 적용해본 결과 필터링이 적용된 원하는 제품들만 뽑아낼 수 있도록 구현을 성공했습니다.!
허나, 기능이 잘 작동되어 기뻤던 것도 잠시, 멘토님의 코드 리뷰를 통해 Services 단의 많은 함수들로 구현이 아닌 Dao 단에서 SQL Query로 통해 구현하는 것이 더 바람직하다는 피드백을 받았고, SQL Query 문법중 하나인 WHERE IN
기능을 통해 OR 조건을 구현 가능하다는 것을 깨달았습니다. (진작 알았더라면.. ㅠㅠ)
이후 Services 단에서의 사이즈 필터링 과정을 제외하고 모든 필터 기능을 Dao단의 Query를 통해 구축하기로 하였습니다. 단, WHERE IN
조건문은 예를 들면 WHERE product_colors.id in (1,2,3) 처럼 배열이 아닌 tuple
을 통해 조건을 받아낼 수 있기 때문에 조건을 받아올 대상을 튜플로 변환하는 과정을 Services 단에서 진행하였습니다.
이렇게 모든 필터링 기능 구현에 성공할 수 있었습니다.
Team Project - Nike Clone Coding (3) 에서 이 포스팅에서 다루지 않은 API에 대한 포스팅이 이어집니다.
긴 글 읽어주셔서 감사합니다 🥰