1. 프로젝트 개요
소개 | ChatGPT API 기반 사용자 맞춤형 레시피 추천 서비스 챗팟 (Chatpot) |
기간 | 2023.05.07 ~ 2023.06.27 |
팀원 | 재웅(FE), 현도(BE) |
스택 | (FE) ReactJS (Redux, Router, Styled-components, PWA) (BE) NodeJS, OpenAI API, AWS |
주소 | https://chatpot.site/ |
프로젝트의 코드와 개발 히스토리는 챗팟 깃허브에서 확인할 수 있습니다.
2. 구현
2-1. 메인 (IndexPage.js)
메인 페이지에서는 무한에 가까운 범주의 메뉴를 추천해주는 서비스의 특징을 담고 싶었다. 이를 위해 임의의 메뉴들을 배열에 담아놓고 TypeIt 라이브러리를 활용하여 메뉴가 타이핑되고 지워졌다 다시 다른 메뉴가 출력되는 형태를 구현하였다.
const food = [
"스테이크",
"피자",
"스시",
"파스타",
"햄버거",
"치킨 너겟",
"라면".....]
<StyeldTypeit
getBeforeInit={(instance) => {
for (let i = 0; i <= 50; i++) {
const idx = Math.floor(Math.random() * 100);
instance.type(food[idx]).pause(2000).delete(food[idx].length).pause(1000);
}
return instance;
}}
options={{ loop: true, speed: 130 }}/>
getBeforeInit 속성에서 애니메이션 구현을 위한 초기값, 속도 등을 지정해줄 수 있다. 원래는 Loop 속성을 참으로 설정하여 Idx가 매번 갱신되게 해주었으나, 한 번 저장된 idx가 계속 출력되는 문제가 발생하여 내부에서 자체적으로 반복문을 돌려 랜덤한 메뉴들이 출력되게 해주었다.
2-2. 식재료 선택 (SelectPage.js)
식재료 선택 페이지에서는 식재료 프리셋(select)을 Store.js에 담아놓고 Redux를 통해 값들을 관리하였다. 식재료 클릭 혹은 폼 제출 시 Reducer의 상태함수를 통해 선택된 식재료(selected)로 포함되고, Array.includes()를 통해 해당 식재료가 selected 배열 내 존재하는 경우 (선택된 경우)를 판단하여 clicked 상태를 통해 스타일을 관리해주었다.
{State.select.map(function (item, i) {
return (
<>
<SelectItem
clicked={State.selected.includes(item.type)}
onClick={(e) => {
e.preventDefault();
const selectedValue = e.currentTarget.children[1].innerText;
if (State.selected.includes(selectedValue)) {
dispatch(removeSelected(selectedValue));
} else {
dispatch(pushSelected(selectedValue));
}
}}
>
<ItemDiv fs="330%"> {item.thumbnail}</ItemDiv>
<ItemDiv> {item.type}</ItemDiv>
</SelectItem>
</>
);
식재료 프리셋을 맵핑하여 화면에 출력, 클릭 시 선택된 식재료 배열에 추가해주었다.
let select = createSlice({
name: "select",
initialState: [
{ type: "돼지고기", thumbnail: "🍖" },
{ type: "닭고기", thumbnail: "🍗" },
{ type: "소고기", thumbnail: "🥩" },
{ type: "대파", thumbnail: "🥬" },
{ type: "마늘", thumbnail: "🧄" }....
],
reducers: {},
});
식재료 리스트 프리셋. 아이콘 이미지 대신 OS에서 제공하는 이모지 폰트를 활용하여 부하를 줄였다.
let selected = createSlice({
name: "selected",
// initialState: ["대파", "양파", "저민 돼지고기", "대파"],
initialState: [],
reducers: {
pushSelected(state, item) {
state.push(item.payload);
},
removeSelected(state, item) {
let filtered = state.filter((element) => element !== item.payload);
state = filtered;
return state;
},
initSelected(state) {
state = [];
console.log("Selected initiated");
return state;
},
},
});
선택된 식재료들을 관리하는 상태변수
2-3. 옵션 선택 (OptionPage.js)
옵션 또한 식재료와 동일한 방식으로 관리된다.
2-4. 데이터 전송 및 로딩
옵션 페이지에서 선택을 끝낸 후 제작 버튼을 누르면, handleClick() 콜백이 실행된다.
const handleClick = () => {
setLoading(true);
const ingredients = State.selected;
let option = State.selectedOption;
if (State.selectedOption.length === 0) {
option = ["아무"];
}
const sendData = { ingredients, option };
axios
.post("/selectOption", sendData)
.then((res) => {
const respond = res.data;
dispatch(setReceiveData(respond));
setLoading(false);
Navigate("/recipe", { state: { direction: "right" } });
})
.catch((error) => {
setLoading(false);
setWrongAlert(true);
});
};
서버 요청을 위해 Axios를 이용하였고, 이용자가 선택한 식재료를 sendData에 담아 전송 후 OpenAI API를 통한 답변을 res에 담아왔다. 이를 Redux로 관리하고 있는 레시피 데이터 변수에 담은 후 RecipePage로 이동하였다.
2-5. 레시피 결과 (Recipe.js)
헤더엔 현재 시간에 맞는 멘트와 메뉴명, 간단한 소개를 출력하였다.
var now = new Date();
var hours = now.getHours();
var meal = "";
if (6 <= hours && hours < 11) meal = "아침 식사";
else if (11 <= hours && hours < 14) meal = "점심 식사";
else if (14 <= hours && hours < 17) meal = "늦은 점심";
else if (17 <= hours && hours < 21) meal = "저녁 식사";
else if (21 <= hours && hours < 22) meal = "늦은 저녁";
else if (22 <= hours || hours < 6) meal = "야식";
<Header>오늘 {meal},</Header>;
<Header><b>{State.receiveData.dishName}
</b>어떠세요?</Header>;
페이지 하단에는 레시피를 저정할 수 있는 버튼과 동일한 옵션에서의 다른 레시피 추천, 홈으로 돌아갈 수 있는 버튼이 존재한다.
2-6. 레시피 저장
html2canvas 라이브러리를 이용하여 레시피 저장 기능을 구현하였다. 뷰포트에 출력된 요소를 그대로 캡쳐하는게 아닌, 카드 형태를 가진 별도의 저장용 Div를 만들어 선택자로 감싸주었다.
const captureHTML = async () => {
const element = document.getElementById("captureElement");
const canvas = await html2canvas(element);
const image = canvas.toDataURL("image/png");
const recipeName = State.receiveData.dishName;
const link = document.createElement("a");
link.href = image;
link.download = `${recipeName}.png`;
link.click();
setUnshown(false);
};
2-7. 기타 (가이드, 크레딧)
2-8. 모바일
챗팟은 반응형 웹앱으로 모바일에서도 동일하게 서비스를 이용할 수 있다. 또한 PWA를 이용해 PC나 모바일의 로컬 백그라운드에 앱을 설치하여 더 쾌적하게 사용할 수도 있다. 반응형 구조는 bootstrap의 Grid와 styled-components의 @media를 통해 구현하였다. 기준 사이즈는 Md (width : 768px)이다. 메인 페이지의 모바일 가이드 탭에서 OS별 설치 방법을 확인할 수 있다.
3. 사용한 라이브러리와 그 이유
3-1. PWA
서비스의 주 타겟층을 20-30대 자취생으로 잡았기 때문에, 모바일 환경과의 호환은 필수적이라 생각했다. 단순 반응형 웹에서 끝나는 게 아닌 앱의 느낌을 주고 싶었다. 스토어에 직접 출시하는 건 RN을 아직 배우지도 않았을 뿐더러 절차도 복잡하기에, 개발자와 유저 모두에게 접근성이 용이한 PWA를 채택하게 되었다.
초기 설정을 끝낸 후 정상적으로 작동이 되지 않아 고전했었다. unregister()를 register()로 바꾼다고 끝나는 게 아니었음.. 크기 별 아이콘이나 manifest.json, 상대경로 수정들을 해주고 나서야 비로소 정상적인 이용이 가능했다. 최근 IOS 업데이트 이후 PWA로 제작한 앱도 네이티브 푸쉬 알림을 보낼 수 있게 되었는데, 아직 이는 사용해보지 못했다.
3-2. Styled-components
일단 편했던 점은 스타일 지정을 위해 일일이 클래스리스트나 ID 등의 선택자를 달아줄 필요가 없다는 것이었다. CSS-in-JS를 지원하기 때문에 프롭스로 변수를 내려줘 컴포넌트 간 깔끔한 재사용이 가능했다. 또한 변수 연산식을 값으로 지정하는 등 SASS와 같이 확장성 있게 사용할 수 있었다. 기존의 스크롤바가 서비스의 디자인 컨셉과 이질적이라고 느껴졌었는데, 이 또한 Styled-compoenents의 GlobalStyle을 통해 ShadowDOM의 스타일을 제어해줄 수 있었다.
3-3. Redux
Redux는 리액트 프로젝트 간 거의 필수적으로 사용되는 상태관리 라이브러리이다. 컴포넌트마다 별도의 Props를 설정하지 않아도 되었기에 편리하고 깔끔한 상태관리가 가능했다. 기획 단계에서는 Redux만을 이용하여 상태변수들을 관리할 계획이었다. 그러나 프로젝트가 진행 됨에 따라 제어해야 되는 상태들이 늘어났고, 결과적으로 전역적으로 사용되는 상태값들은 Redux로, 특정 페이지 내에서 지역적으로 사용되는 상태값들은 useState 훅을 이용하였다. 추가적으로 useSelector와 useDispatch 훅을 이용해 상태값과 상태함수들을 관리해주었다.
3-4. Router와 React-Transition-group
싱글 페이지 어플리케이션 (SPA)를 지원하는 리액트 환경에서 페이지를 관리하기 위해 Router를 사용하였다. 또한 페이지 간 부드러운 전환을 위하여 React-Transition-Group (이하 RTG)를 이용하였다. 본 컴포넌트는 페이지 마운트 / 언마운트 시의 상태를 4가지의 클래스 리스트로 관리한다. 추가적으로 페이지 단계에 따른 방향을 지정해주어 왼쪽 혹은 오른쪽으로 전환되게 스타일을 지정하여 구현하였다. 해당 블로그가 구현에 많은 도움이 되었다.
3-5. Typeit
유튜브 알고리즘으로 우연히 접하게 된 라이브러리이다. 영상을 본 순간 번뜩이는 아이디어가 생각나 채택하였다. 메뉴 배열을 랜덤하게 인덱싱하여 타이핑하는 효과를 내고 싶었다. 코드와 이용 방법은 상단의 구현 섹션에서 확인할 수 있다.
3-6. React-lottie
요즘 앱이나 웹 서비스에 자주 보이는 짧은 모션그래픽 소스 라이브러리이다. lottie의 장점은 용량이 큰 기존의 GIF 파일을 JSON 형식으로 변환하여 가볍고 손쉽게 소스를 이용할 수 있다는 점이다. 또한 굉장히 다양하고 퀄리티 좋은 오픈소스들이 많다. 알고보니 디자이너들의 깃허브라고 한다.
3-7. html2canvas
레시피 다운로드 기능을 구현할 때 사용한 라이브러리이다. 선택자로 감싸준 Html 요소들을 캡처한다. 기존 레시피 결과 화면을 그대로 찍어 저장하긴 너무 싫었기에, 별도의 Div를 만들어 레시피 카드 형태로 예쁘게 담아 제공할 계획이었다. 그러나 해당 라이브러리 특성 상 화면에 렌더링(뷰포트를 넘어가더라도)되어야 정상적인 캡처가 가능하다는 것을 발견하였다. 그래서 이걸 어떻게 숨길까 하다가, Position Absloute 속성을 적용하여 뷰포트 밖으로 위치시켰다. 캡처 후 해당 레시피 명으로 스토리지에 저장되게 구현하였다.
4. 문제 해결
4-1. 폼 입력 이슈
옵션 및 식재료를 직접 추가하는 폼 입력을 구현하는 과정에서 target.value를 입력시마다 onChange 속성을 통해 출력하려 했으나, 한 글자 입력할 때마다 포커싱이 풀려 렉이 발생한 것처럼 느껴지는 문제가 존재했다. 잦은 콜백 이벤트와 상태 갱신이 이와 같은 현상을 발생시킨다고 판한하였고, 이를 해결하기 위해 React-hook-form 라이브러리를 이용하였다. register{변수 명}으로 입력 값들을 관리하는데, 폼 제출 시에도 해당 변수만 작성하여 연동이 가능해 상태값으로 변수를 관리해야 하는 리액트 환경에서 굉장히 편리하다고 생각되었다. 추후 로그인이나 챗봇 등을 구현할 계획에 있는데, 이러한 기능 구현 간에도 굉장히 유용하지 않을까 생각한다.
4-2. 재랜더링 시 페이지가 새로고침되는 현상
버튼 클릭, 폼 입력 등을 통해 상태가 변경될 때 페이지가 새로고침되는 현상이다. 사실 해당 문제에 대한 정확한 원인을 파악할 수 없었다. 커밋 기록으로 경우의 수를 고려해보다 App.js에서 상태값 활용을 위해 Props를 보내지 않고, 각 파일 내부에서 useSelector를 통해 Redux의 상태를 불러와 사용하는 것으로 추정하고 있다. (혹시나 해당 문제의 원인을 아시는 분이 있다면, 조언 부탁드립니다)
5. 회고
아쉬운 점
컴포넌트 분리
프로젝트 초반엔 Styled-components나 State 등 사용되는 요소들을 전부 App.js 내에서만 관리하였다. 프로젝트가 진행되면서 페이지가 늘어나게 되자 이를 분리하였고, Components.js 폴더를 경유하여 컴포넌트들을 관리할 수 있게 재구성해주었다. 그 결과 App.js에서는 import { NavBar, RecipePage, OptionPage, SelectPage } from "./Componenets"; 와 같이 한 줄로 컴포넌트들을 불러와 사용할 수 있었다. 그러나 이처럼 페이지 별로 컴포넌트(파일)을 분리한 이후 추가적인 재구성은 없었는데, 한 가지 궁금한 점이 있다면 컴포넌트들을 얼마나 Nested하게 구성해야 되는가이다. 지금은 페이지 당 하나의 컴포넌트 파일이 있고, 그 파일 안에 해당 페이지의 모든 기능, 컴포넌트들이 구현되어 있다. 무분별한 Nested components는 좋지 않다고 생각하지만, 이를 어떻게 분리하여 파일 간 겹치는 컴포넌트를 어느정도 사용할 수 있는지 등을 생각해볼 필요가 있다고 느꼈다.
무분별한 커밋
깃허브를 통한 협업은 거의 처음이라, 작업한 걸 땡겨 오고 내 코드를 푸쉬하여 PR하는 등의 매커니즘을 익히는 데 본 프로젝트가 큰 도움이 되었다. 그러나 그 과정에서 빈번한 커밋이 이루어졌고, 히스토리를 확인해보면 꽤나 조잡하다. 프로젝트가 마무리될 시점 쯔음 개인 브랜치와 Main 말고도 test-merge용 브랜치를 통해 관리하고, 큰 변화에서만 Main으로 보내는게 좋겠다고 동료와 얘기를 나누었다. 그 밖에도 Issues를 통한 의견 교류, 코드 리뷰를 통해 미약하지만 협업에 대한 개념을 이해할 수 있었다.
또 한가지 아쉬운 점이 있다면, 아무래도 FE 1명 + BE 1명의 소규모 프로젝트이다보니 Confilct가 별로 없었다는 것이었다. 주로 각자 폴더 (Project / Server)에서 코드를 작성하고 커밋하다 보니 충돌을 관리하고 처리할 기회가 많지 않았다. 실무에서는 같은 파일에서 다수의 인원이 코드를 작성, 수정하는 경우가 있기 때문에 충돌을 관리, 흐름을 읽으며 최소화하는 능력이 중요하다는 것을 깨닫는 기회가 되었다.
TDD
프로젝트 중간 발생했던 이슈들도 전부 성능관리와 연관되어 발생하였거나, 이를 통해 훨씬 수월하게 해결할 수 있었지 않았을까 생각한다. 지금은 개발자 도구의 Components 수준에서 디버깅하는 정도지만, 추후 제대로 학습하여 다음 프로젝트에 적용시킬 계획이다.
프로젝트를 마치며
항상 언어나 프레임워크 등 새로운 걸 배우고 처음 적용해보는 프로젝트를 진행하다 보니, 구현에만 급급하게 되는 점이 있지 않나 생각한다. 그러나 학습 후 프로젝트를 처음 시작할 때는 리액트 환경에서 JS를 어떻게 적용시킬지도 막막헀지만 하다 보니 삼항 연산자를 통한 상태값 관리나, 콜백 함수 활용 등 감이 잡혀 가는 걸 체감하게 되었던 것 같다.
다음 계획으로는 1. ParamID와 DB를 이용한 회원관리 및 이와 연계된 레시피 북마크 등의 추가기능 구현, 2. 타입스크립트 학습 및 적용, 3. 컴포넌트 의존성과 리팩터링, 4. TDD 성능평가를 공부하고 적용시킬 생각이다.
마지막으로 긴 글을 전부 읽어주신 분이 있다면 정말 감사를 표하고 싶다. 비전공자 특성 상 인프라가 제한적이고, 나아가고 있는 방향이 맞는지 항상 혼자 고민한다. 본 프로젝트에 대한 피드백이나 방향성에 대한 조언 등의 의견을 남겨주신다면 큰 도움이 될 것 같다.