- 주제 : Subway 홈페이지 클론 프로젝트
- 인원 : FrontEnd 3명, BackEnd 2명. 총 5명
- 프로젝트 기간 : 5월 25일 - 6월 5일(12일)
- 기술 스택(FrontEnd기준) : React/CRA/scss
위코드 커리큘럼 중엔 프론트앤드와 백앤드가 협업하여 웹을 클론하는 프로젝트가 있다.
우리 팀의 주제는 subway 웹으로, FronEnd 입장에 생각했을 때 컴포넌트의 제작과 재사용,
컴포넌트와 map 메서드의 조합 등을 통해 React의 기초를 다질 수 있는 기회였다.
BackEnd의 경우, 단순 웹크롤링이 위주라 프로젝트 기간 동안 BackEnd 담당자들은 크롤링만 하다 끝날 가능성이 있어 프로젝트가 시작하고 처음 회의에서 기능을 추가하기로 결정하였다.
기존 subway 페이지에 있던 가맹점, 이벤트, 뉴스 등의 페이지를 생략하고 그 대신 로그인, 샌드위치 커스터마이징, 장바구니 페이지를 추가하기로 하였다.
전체적인 페이지의 흐름은 다음과 같다.
회원가입 => 로그인 => 메인페이지 => 메뉴페이지
=> 상세메뉴페이지 => 커스터마이징 => 장바구니
- menu 페이지 제작 : 컴포넌트와 map 활용, 필터, hover시 애니메이션
- 페이지간 라우팅 : url-parameter 활용
- cutom 페이지 서포트 : state 변화에 따른 render 페이지 변화, local storage 활용
프로젝트는 2주 동안 진행되는데 1주씩 2번 sprint 기간을 나누어 진행하였다.
1주차에는 이번 프로젝트의 뼈대라고 할 수 있는 메인 / 메뉴 / 상세 메뉴 페이지를 FrontEnd 3명이 하나씩 선택하여 제작, 2주차는 각 페이지의 기능 추가 및 커스터마이징, 장바구니 페이지를 제작하기로 결정하였다.
프로젝트를 시작하기 전, 위코드 멘토님들께서 결과물도 중요하지만 프로젝트 기간 동안 개인이
배우고 싶은 것, 이루고 싶은 것을 목표로 두고 프로젝트에 참여하면 성과가 좋다고 말씀해주셨다.
개인적으로 컴포넌트와 map 활용의 이해는 리액트의 꽃이라는 이야기를 어디서 들은 적이 있어 이 부분을 중점적으로 다루고 싶었고 이 부분이 가장 많이 사용될 것이라고 판단되는 menu 페이지 제작을 맡았다.
Sprint 2회차 때는 페이지의 흐름에 대해 생각해봐야 했다.
모든 페이지가 부모, 자식 Component로 이루어져있지 않고 라우팅을 통해 페이지간 이동하였는데 이런 경우에는 하나의 페이지에서 사용되는 state 값을 props를 통해 전달할 수 없다는 것을 알게 되었다.
페이지간 이동에서 데이터(정보)가 지속적으로 유지될 수 있는 방법에 대해 고민했고
URL-parameter 개념을 사용하여 해결하였다. 페이지가 이동할 때 지속되어야 할, 이어져야 할 데이터(정보)를 라우팅의 key 와 value로 설정하여 전달했고 전달 받은 데이터는 해당 페이지에서 state 값으로 저장하였다.
프로젝트 마지막 기간에 cutom 페이지가 아직 해결이 되지 않아 FrontEnd 3명이서 고민하였다.
custom 페이지의 문제는 커스텀 페이지와 토핑선택 페이지가 라우팅으로 연결되는데,
커스텀 페이지 안에 빵선택 페이지가 자식 컴포넌트로 제작되어 페이지간 라우팅시 새로 mount가 되어 state값이 초기화 된다는 점이 문제였다.(커스텀한 정보를 state 값에 저장하여 render시, 페이지의 모습을 변화시켰는데 라우팅이 되면 이 모습을 유지하기가 힘들다.)
[(커스텀 페이지 <> 빵선택 컴포넌트) <> (토핑 페이지)]
이 부분은 빵선택과 토핑 선택을 local storage에 저장하는 방법으로 해결하였다.
(이런식으로 해결하는 것은 정답이 아니다. 그러나 프로젝트 발표 때 우리 팀이 충분히 고민을 했다는 것을 보여주고 싶어 이 방법을 선택하였다.)
- 잘한 점 :프론트앤드 팀원간 커뮤니케이션
- 아쉬운 점 : 백앤드 팀원간 커뮤니케이션, 스케줄 관리
- 해결/개선 방법 : 연습
이번 프로젝트는 처음으로 백앤드와의 협업, 1:1 이 아닌 다수의 협업, 0부터 제작을 방향을 결정, 실행해보는 경험이었다. 프로젝트를 끝내고 생각해보니 가장 중요한 것은 커뮤니케이션이었다고 생각한다. 잘한 점도 커뮤니케이션, 아쉬운 점도 커뮤니케이션이다.
이번 프로젝트를 진행하며 git 을 활용했는데 많은 Conflict를 경험했다.
또 함께 모여 문제 해결을 위한 코드를 생각해보거나 서로 모르는 부분은 도움을 요청하는 경우가 많았다.
이러다보니 자연스럽게 다른 프론트앤드가 작성한 코드를 보게 되는데
다른 사람의 코드를 보고 이해하는게 얼마나 어려운지 알게 되었다.
아마 다른 사람이 내 코드를 보고도 그랬을거라 생각한다.
코드를 깔끔하게 써야하는 이유, 보기 좋게 정리해야하는 필요성을 느꼈다.
다행히 이런 문제에선 FrontEnd 팀원간 커뮤니케이션이 잘 진행되어 이해가 되지 않는 부분은 설명을 통해 이해하였다. 코드뿐만 아니라 작업 진행 방향과 속도, 목표에 대해 매일 회의를 통해 각자 맡은 일을 다르지만 같은 방향으로 진행될 수 있었다.
잘한 점이 커뮤니케이션이라면 아쉬운 점 역시 커뮤니케이션이다.
어쩌면 front 간의 커뮤니케이션보다 더 중요하다고 볼 수 있는 BackEnd와의 커뮤니케이션이 부족하여 프로젝트 막바지에 문제를 발견하였다.
매일 standup meeting 하며 어떠한 목적을 위해 데이터가 필요하다 라는 식의 얘기는 하였지만
그 데이터의 형태는 어떤 모습이면 좋고, 정확하게 특정 부분에 해당하는 특정 데이터가 필요하다는 이야기를 전달하지 못 하였다. 그 결과, 장바구니 페이지에서 필요한 데이터를 받았지만 막상 사용할 수 있는 데이터는 받지 못 하였다.
개인의 성향의 문제도 있지만 여전히 백앤드에 특정한 데이터가 필요하다는 요구를 하기가 힘들다. 내가 어느 수준까지 요구를 해도 되는건지 감이 오지 않는다.
하지만 아마 내가 이러한 요구를 하지 않는 것이 백앤드 팀원들을 2번 3번 일하게 만드는건지도 모르겠다. 이 부분은 개선해야 한다.
menu 페이지를 완성시켰을 때, 당시에는 너무 늦게 제작을 완료했다고 생각했다. 지금 생각해보면 그리 늦지는 않았던 것 같다. 아무튼 당시에 menu 페이지를 만들고 나니 너무 초조했다.
빨리 페이지 하나라도 더 만들어 프로젝트에 기여를 해야겠다는 생각뿐이었다.
심지어 메인과 메뉴상세페이지를 만든 팀원들은 커스터마이징 부분을 맡아 제작이 진행되는데
나 스스로는 무엇을 해야할지 몰랐다. 지도, 장바구니를 맡았지만 지도는 카카오맵 api를 받아와
진행해야 했고 장바구니는 커스텀이 끝난 정보를 서버에 저장하고 그 저장된 정보를 받아와 진행시켜야 했다.
카카오맵 api의 활용에 대해선 하루종일 보아도 이해가 되지 않았다. 앉아서 숨만 쉬다가 집에 가는 것 같았다. 장바구니는 형태는 만들었지만 데이터를 받아들일 준비가 제대로 되지 않았다.
결국 두 페이지를 완성시키지 못했다. 백앤드에서 이 페이지를 위한 정보를 준비를 해주었는데도 말이다. 정말 죄송했다.
몸도 피곤하고 멘탈도 나가니 시야가 좁아지는 기분이다. 지금 생각해보면 menu 페이지가 끝났을 때 조금 더 침착하게 지도든 장바구니든 뭐라도 하나에 집중하여 끝내는게 좋지 않았을까 싶다.
결국엔 연습뿐이다.
getData = () => {
fetch(`${URL}/product/sandwich`)
.then((res) => res.json())
.then((res) =>
this.setState({
sandwich: res.sandwiches,
filtered_sandwich: res.sandwiches
})
)
}
코드는 혼자 보는게 아니고 다같이 본다.
따라서 코드는 깔끔하게 쓰는 것이 좋다.
페이지 상황에 따라 fetch(GET)는 여러번 쓰일 수 있다.
fetch() 는 길다... 따라서 함수로 따로 만들어 필요할때 호출하는 것이
보기에도 좋고 정신건강에도 좋다.
const {
sandwich,
filtered_sandwich,
isActive,
} = this.state;
4-1 과 마찬가지이유이다. 깔끔하게 쓰기 위해서.
this.state.sandwich
this.props.snadwich
....
내가 보기엔 이해해도 처음 보는 사람이 이 코드를 계속 보면 정신이 나갈 수 있다.
이고잉씨가 말한 것처럼 이 코드가 1억번 반복이 된다면 어떻게 될까요.
난 이 노트북을 창밖으로 집어던질 것이다.
4-1과 4-2 부분은 멘토님들이 항상 강조하는 부분인데 내가 항상 못했던 부분이다.
그래서 이 블로그에 남긴다. 자매품으로 confing.js에 API 주소를 변수로 저장하여 꺼내쓰는 방법도 있다.
// 부모컴포넌트(조부모 컴포넌트에서 sandwich라는 데이터를 받아온 상태)
<div className="menuList">
{sandwich.map((sandwich) => {
return (
<MenuBox
id={sandwich.id}
image={sandwich.image_url}
name={sandwich.name}
ename={sandwich.name_en}
kcal={sandwich.kcal}
summary={sandwich.description}
/>
);
})}
</div>
// 자식 컴포넌트(map을 통해 실제로 render 되는 컴포넌트)
<div className="menuBox">
<div className="menuImg">
<img alt="We are sorry" src={this.props.image} />
</div>
<p className="name">{this.props.name}</p>
<p className="ename">{this.props.ename}</p>
<p className={this.props.kcal > 0 ? "kcal" : "nokcal"}>
{this.props.kcal} kcal
</p>
</div>
리액트의 꽃. 컴포넌트와 map의 활용.
map 메서드는 배열 메서드다. arr.map()
map 메서드는 기존 배열의 변화시키지 않고 새로운 배열을 return한다.
데이터를 받고 state 값으로 저장(배열 형태로), 이를 props로 전달.
손에 손잡고
// Routes.js
<Router>
<Switch>
<Route exact path="/" component={Main} />
<Route exact path="/menu" component={Menu} />
<Route exact path="/login" component={Login} />
<Route exact path="/signup" component={SignUp} />
<Route exact path="/menu_details/:key" component={Menu_Details} />
<Route exact path="/toppings/:key" component={Toppings} />
<Route exact path="/custom/:key" component={Custom} />
<Route exact path="/bread:key" component={Bread} />
<Route exact path="/shop" component={Shop} />
<Route exact path="/modal" component={Modal} />
</Switch>
</Router>
//라우팅 출발지
<Link to={`/menu_details/${this.state.id}`} className="menuTo">
//라우팅 도착지
getData = () => {
const num = this.props.match.params.key;
fetch(`${URL}/product/sandwich/?product_id=${num}`)
.then((res) => res.json())
.then((res) =>
this.setState({
sandwich: res.product,
nutrition: res.nutrition,
prev: res.all_subcategory_products[0],
next: res.all_subcategory_products[1],
id: res.product.id,
})
);
};
페이지 흐름상 menu에서 선택한 샌드위치를 상세페이지에서 보여야 했고
또 커스텀으로 넘어가야했다. 따라서 데이터의 흐름이 끊어지면 안 되었는데
Route URL-parameter로 해결했다.
route에서 키를 설정하고. 출발지에서 값을 보냈다.
도착지에서 그 값을 받아 state 값으로 저장했고 다시 이 값이 필요한 값으로 보냈다.
//커스텀페이지(부모 컴포넌트)
// componetDidMount 부분
componentDidMount() {
const topping_LS = JSON.parse(localStorage.getItem("testObject"));
const bread_LS = JSON.parse(localStorage.getItem("newBread"));
fetch(
`${URL}/product/sandwich/customization/?product_id=${this.props.match.params.key}`
)
.then((res) => res.json())
.then((res) => {
this.setState({
added_bread: bread_LS,
bread: res.all_bread.filter(
(bread) => bread.name.includes("top") || bread.name.includes("플랫")
),
default_ingredients: res.default_ingredients,
default_topping: res.default_ingredients.slice(
1,
res.default_ingredients.length - 1
),
product_name: res.product_name,
});
//로컬스토리지에 저장된 토핑이 있을때
if (topping_LS !== null && bread_LS === null) {
this.setState({
added_topping: topping_LS,
product_name: res.product_name,
customized: res.default_ingredients.concat(topping_LS),
page_key: this.props.match.params.key,
bread: res.all_bread.filter(
(bread) =>
bread.name.includes("top") || bread.name.includes("플랫")
),
}); //로컬스토리지에 저장된 토핑이 없을때
} else if (topping_LS === null) {
this.setState({
customized: res.default_ingredients,
page_key: this.props.match.params.key,
}); // 로컬스토리지에 저장된 빵이 있을때
} else if (bread_LS !== null && topping_LS !== null) {
this.setState({
added_bread: bread_LS,
customized: bread_LS.concat(
this.state.default_topping.concat(topping_LS)
),
page_key: this.props.match.params.key,
});
}
});
}
//빵선택했을때 변경사항 반영되어 커스텀 화면으로 돌아오기
looksgood = (newBread) => {
this.setState({
isShown: "noshow_bread",
added_bread: newBread,
customized: newBread.concat(
this.state.default_ingredients.slice(
1,
this.state.default_ingredients.length - 1
)
),
});
// 토핑 페이지(라우팅 도착지)
getData = () => {
fetch(`${URL}/product/sandwich/customization/topping/`)
.then((res) => res.json())
.then((res) =>
this.setState({
toppings: res.all_toppings,
page_key: this.props.match.params.key,
selectedToppings: res.all_toppings.filter(
(topping) => topping.ingredient_category_id === 2
),
})
);
};
// 다시 커스텀 페이지로 돌아가기
goToCustom = () => {
this.props.history.push("/custom");
};
sendTopping = () => {
//토핑추가하기를 누르면 실행될 함수
//custom 페이지로 이동하면서 고른 토핑을 펼쳐서 보여준다.
localStorage.setItem(
"testObject",
JSON.stringify(this.state.addedToppings)
);
this.goToCustom.call(this);
};
리액트에 대해 아직 알아야할 부분이 많이 남아있을 때.
현재 알고있는 지식으로만 문제를 해결해야 했다.
리액트 생명주기에 대해선 이해가 부족했다.
우리가 활용할 수 있는 지식은 다음과 같았다.
componentDidMount는 단 한번 된다는 것.
state값이 변할때 새로 render 된다는 것.
페이지간 이동을 하면 기존의 state 값을 저장되지 않는다는 것.
빵선택을 하면 커스텀의 자식 컴포넌트라 state 값을 조작하여, 변한 모습을 보여줄 수 있지만
토핑 선택을 위해 토핑 페이지를 다녀오면 state 값이 초기화 되어 변한 빵 모습을 볼 수 없었다.
이를 해결하기 위해 localstorage를 사용했다.
처음 빵을 선택할 때 localstorage에 저장하였다.
그리고 토핑 페이지로 갈때. 다시 커스텀 페이지로 돌아올 수 있도록 url-parameter를 사용했고 선택한 토핑을 localstorage에 저장했다.
다시 돌아왔을 때 mount가 다시 시작된다는 것을 사용하기 위해서
componentDidMount() 의 setState 부분을 조건문으로 조작하였다.
빵에 대한 정보가 local에 있는가. 토핑에 대한 정보가 local 있는가.
그리고 그 경우에 따라 mount 실행시 render의 모습을 결정하였다.
토핑을 선택하든 빵을 선택하든 일단 모습은 변할것이다.
state와 componentdidmount를 이용하니 꽃놀이패라고 생각한다.
근데 이 방법은 아마 정답은 아닐것이다. 프론트 앤드에서 이 문제를 해결하다 보니
어렵고 복잡해졌다.
아무튼.. 1차가 끝났다.