.
2011년, 모바일 메신저 '라인' 의 스티커 캐릭터로 탄생한 라인프렌즈는 오리지널 캐릭터 '브라운앤프렌즈' 에 이어 글로벌 인기 아티스트 방탄소년단과 함께 만든 'BT21', 캐릭터 비즈니스 전문성과 노하우로 재탄생 시킨 슈퍼셀의 인기 모바일 게임 IP '브롤스타즈' 등 다양한 캐릭터와 아티스트와의 콜라보를 활발히 이어가고 있는 기업입니다. 다채로운 캐릭터 라인업으로 전세계 MZ세대의 큰 사랑을 받고 있는 만큼 공식 스토어도 활발히 운영되고 있습니다. 프론트 3명, 백엔드 2명으로 이루어진 저희 팀은 보편적인 커머스 사이트의 구성과 기능을 익히는 데 라인프렌즈 스토어 웹사이트가 가장 적합하다고 판단하여, 이 사이트로 클론 프로젝트를 진행하게 되었습니다.
인생에 협업 도구란 카카오톡과 슬랙밖에 경험해보지 못한 팀원들과 함께 새로운 협업툴에 익숙해져 갔습니다. 트렐로로 서로의 진행 상황을 공유할 수 있었던 것도 매우 좋은 경험이었는데, 깃으로 브랜치를 자유자재로 오가며 각자 작업을 진행한 파일을 깃에 올리고 병합하고, 수많은 컨플릭트를 해결한 경험은 이번 프로젝트에서 단연코 가장 큰 수확이라고 생각합니다. 또 줌으로 멘토님과의 Q&A가 자유롭게 이루어져 위워크에서 함께하지 못하는 대신 더욱 실시간으로 질문을 해결할 수 있었습니다.
검색창에 입력한 값을 포함하는 제품명을 필터링하여, 해당 상품을 검색창 아래에 리스트로 보여주는 기능을 구현했습니다.
constructor() {
super()
this.state = {
categoriesList: [],
searchValue: '',
searchList: [],
isloggedIn: false,
scrollTop: 0,
isNavFixed: false,
}
}
handleSearchValue = (e) => {
this.setState({
searchValue: e.target.value,
})
}
...
render() {
const filteredList = searchList.filter(product => product.name.toLowerCase().includes(searchValue.toLowerCase()) && product)
...
검색어를 입력하고 엔터를 치거나 검색 버튼을 누르면, 상품 리스트 페이지로 검색어를 포함하는 상품명을 가진 제품들만 나타나도록 백엔드에서 엔드포인트를 만들어주었습니다. history.push 메서드로 단 몇 줄로 구현했습니다.
goToSearchResult = (e) => {
e.preventDefault()
this.props.history.push(
`/product/products_info?search='${this.state.searchValue}'`
)
}
큰 카테고리 안에 세부 카테고리가 있는 경우와 없는 경우가 있었는데, 조건문을 걸어 세부 카테고리가 있는 경우 마우스를 올리면 목록이 하단에 뜨도록 하였습니다. 백에서 보내 준 데이터에서는 세부 카테고리의 수가 없는 경우에는 상위 카테고리명이 세부 카테고리로 들어가 있어, 세부 카테고리의 갯수가 1 이상인 경우에만 목록을 띄우도록 했습니다.
<li>
<div className='categoryItem'>{category.menu}</div>
<img
alt='Down arrow'
src='/images/arrow-right-bold.png'
className={category.categories.length > 1 ? 'show' : 'hide'}/>
<div
className={category.categories.length > 1 ? '' : 'preventHover'}>
<ul className='subCategories'>
{category.categories.length > 0 &&
category.categories.map((subCategory, index) => {
return (
<li key={index}>
<Link to='/productlist' className='reset'>
{subCategory}
</Link>
</li>
)
})}
</ul>
</div>
</li>
바닐라JS에서처럼 window에 이벤트리스너를 추가해, 스크롤값을 가져와서 state에 넣은 후 그 값으로 scss에서 속성값을 부여해 fix되도록 했습니다.
handleScroll = (e) => {
const scrollTop = ('scroll', e.srcElement.scrollingElement.scrollTop)
this.setState({
scrollTop,
isNavFixed: scrollTop > 140 ? true : false,
})
}
헤더 컴포넌트와 같이 모든 페이지에서 공유되는 Footer 컴포넌트는 Scss와 jsx로 간단하게 구현했습니다. 코드리뷰를 받으며 새로 알아 간 점은 하단에 있는 작은 메뉴들도 꼭 map 메서드로 반복문을 돌려야 한다는 점이었습니다. 처음에는 그런 부분들은 UX 측면에서 두드러지지 않고 갯수도 적은데 왜 반복문을 돌릴까 하는 생각을 했습니다. 지금 다시 생각해 보면 Footer는 기업의 정보를 알려주는 아주 중요한 섹션이며 메뉴의 수가 늘어나게 된다면 유지보수의 측면에서만 봐도 항목을 한 곳에서 배열로 모아둔 후 map 메서드로 처리하는 것이 좋겠다는 생각을 하게 되었습니다.
const footerMenu = [
'네이버 약관',
'네이버페이 약관',
'전자금융거래 이용약관',
'개인정보처리방침',
'청소년보호정책',
'지식재산권신고센터',
'안전거래 가이드',
'쇼핑&페이',
'고객센터',
]
...
<ul className='footerMenu'>
{footerMenu.map((item) => {
return <li>{item}</li>
})}
</ul>
Slick-slider 라이브러리를 사용하여 콘텐츠를 슬라이더 형식으로 넘겨가면서 볼 수 있도록 하였습니다. 메인 배너의 경우 이미지 한 개와 홍보문구만 넣으면 완성이었는데, 추천상품의 경우 컴포넌트 4개씩 한꺼번에 슬라이드 되도록 구현하고 동시에 좌우 슬라이더 버튼까지 기존에 짜여진 속성의 벽을 허물고 억지로 커스텀하려고 하니 다소 어려움이 있었습니다. 생각보다 오랜 시간이 들었기에, 제가 짠 코드를 팀원들과 common.scss 파일로 공유함으로써 제가 직접 맡지 않은 컴포넌트에 유사한 형태의 슬라이더를 적용하는 데 시간을 단축할 수 있었습니다.
@mixin slick-slider-button {
width: 70px;
height: 70px;
position: relative;
cursor: pointer;
& {
opacity: 0.2;
}
&:hover {
opacity: 0.8;
}
&:first-of-type {
top: 225px;
left: -160px;
}
&:last-of-type {
top: -212px;
left: 1340px;
transform: rotate(180deg);
}
&::before,
&::after {
position: absolute;
left: 32px;
content: ' ';
height: 26px;
width: 2px;
}
&::before {
transform: rotate(45deg);
top: 12px;
background-color: #000;
}
&::after {
transform: rotate(-45deg);
top: 30px;
background-color: #000;
}
&.slick-arrow {
border: 1px solid #000;
&:hover {
border: 1px solid #000;
}
&::before {
font-size: 40px;
opacity: 1;
}
}
}
추천상품과 #우리집 홈카페 섹션에서는 백에서 데이터를 받아와서, 백엔드에서 준 데이터를 가지고 자바스크립트 메서드를 활용해 사용자에게 익숙한, 또는 독특한 방식으로 변환해서 보여주었습니다. 기존 웹사이트에 있던 #우리집 홈카페 섹션에서 상품 버튼을 클릭하면 해당 상품에 대한 이미지가 선택되는 기능을 구현해 보았는데, 클릭을 하는 아이템만 선택이 되고, 나머지는 선택이 되지 않도록 해야 했습니다. 정말 간단하게 구현할 수 있을 거라 생각했지만 백엔드에서 isClicked 라는 boolean 데이터까지 보내주는 건 아니다 보니 프로젝트 마지막 날까지 이 코드 몇 줄을 쓰려고 고민을 많이 했습니다. 결국 fetch받을 때 state를 추가해주는 것으로 하고, 클릭한 버튼의 아이디에 해당하는 상품 사진에 테두리를 적용하는 모습을 구현했습니다.
handleViewClick = (id) => {
const { productsList } = this.state
const filteredList = productsList.map(product => {
product.product_id === id
? product.isClicked = true
: product.isClicked = false
return product
})
this.setState({
productsList: filteredList
})
}
추천상품 섹션에서 상품을 클릭하면 해당 상품에 대한 상세페이지로 연결되도록 하였습니다. Path parameter로 아이디값을 엔드포인트에 작성하고, history.push() 메서드를 이용해 상품 아이디에 해당하는 상세페이지로 연결하였습니다.
goToProductDetail = (id) => {
this.props.history.push(`/productdetail/${id}`)
}
사용자 리뷰의 경우 촉박한 기간으로 인해 백에서 모든 리뷰 데이터를 정성스럽게 작성해주기 어려워, mock data를 활용하였습니다. (상세페이지의 리뷰 데이터는 백에서 fetch하여 가져왔습니다.) 원래 pinterest 형태로 유명한 메이슨리 방식의 그리드로 표현하려고 했는데, createRef와 clientHeight, 그리고 scss에서 그리드 속성을 간단히 조절하는 것으로 만들어지지 못한다는 것을 오랜 시간 끝에 깨닫고 아쉽게도 포기하게 되었습니다.
createdAtString = (createdAt) => {
const splittedDate = createdAt.split('T')[0].split('-')
return `${splittedDate[0]}년 ${splittedDate[1]}월 ${splittedDate[2]}일`
}
componentDidMount = () => {
fetch('/data/reviews.json')
.then(response => response.json())
.then(data => {
this.setState({
reviewsList: data.reviews,
})
}).catch(err => console.log(err))
}
장바구니 기능은 mock data로 프론트에서만 구현했습니다. 데이터 추가/삭제는 비슷한 코드, 다른 함수로 길게 짰다가, 나중에 다른 팀원분의 코드리뷰를 받고 한 개의 함수로 줄였습니다.
// 이전 코드
addItem = (id) => {
const { cartItems } = this.state
const changedStatus = cartItems.map(item => {
if (item.productId === id) {
item.amount++
}
return item
})
this.setState({
cartItems: changedStatus
})
}
subtractItem = (id) => {
const { cartItems } = this.state
const changedStatus = cartItems.map(item => {
if (item.productId === id) {
item.amount--
}
return item
})
this.setState({
cartItems: changedStatus
})
}
// 코드리뷰 후
modifyItemAmount = (id, modify) => {
const { cartItems } = this.state
const changedStatus = cartItems.map(item => {
if (item.productId === id) {
modify === 'plus' ? item.amount++ : item.amount--
}
return item
})
this.setState({cartItems: changedStatus})
}
장바구니 데이터를 선택한 후, 선택한 상품만 삭제하거나 주문하는 기능을 구현하기 위해 isChecked 라는 state가 필요했습니다. Spread 연산자를 활용하여 Fetch하면서 바로 state를 추가하여 해결했습니다.
componentDidMount = () => {
//this.getCartData()
fetch('/data/cartItems.json')
.then(res => res.json())
.then(data => {
const itemsWithState = data.cartItems.map((item) => {
return {...item, isChecked: false}
})
this.setState({
cartItems: itemsWithState
})
})
}