용량을 낮춰서 움짤을 만들다 보니 잔상이 많이 남는다...
> 배포 사이트 보러가기 <
평소에 라멘을 많이 즐겨먹고, 친구들과 저녁약속을 잡을 때는 대부분 라멘을 먹는다.
오늘은 어떤 라멘을 먹어야 할지 늘 고민이 있었다.
지금 근처에 라멘 맛집이 있는지 찾아보기 위해서는 지도 어플을 켜고 라멘을 검색한 뒤
리뷰를 확인, 주차 가능 여부를 파악 등등 확인 할 일이 너무 많았다.
마침 취업 준비를 하며 이것 저것 하느라 바빴던 시기가 지나고 잠깐 짬이 났기에
나만의 라멘 맛집 정리 지도를 만들어보자는 생각이 들었다.
이전에 진행한 프로젝트들은 모두 팀 프로젝트로 진행되었기 때문에
이번에는 혼자서 해보자는 생각이 들었다.
라멘 맛집을 지도상으로 확인 할 수 있게 구현했습니다.
지도는 카카오맵 API를 이용해서 구현했습니다.
구글 로그인을 한다면 가게 별 찜 기능, 찜 한 가게 몰아보기, 가게 추가 요청등
여러가지 기능을 추가로 이용할 수 있습니다.
관리자 권한을 추가하여 Json-Server에 등록된 관리자 계정으로 구글 로그인을 한다면
가게 추가, 가게 추가요청 관리 등의 기능으로 대체하여 표시됩니다.
2023-01-03 ~ 2023-01-26
설날 앞뒤로 잠깐 프로젝트를 멈춰서 실제로는 약 2주정도 소요됐다.
Next.js , TypeScript , styled-component , Json-Server , Axios ,
GitHub, kakao.maps , google-login
{ "admin": [ "pmb087@gmail.com" ], "store": [ { "id": 1, "thumbnail": "https://mp-seoul-image-production-s3.mangoplate.com/488914/1948795_1669780015868_95539", "store_name": "멘츠루", "parking_info": "근처 공영주차장 또는 바로 앞 이마트 이용", "main_menu": [ "소유라멘", "매운소유" ], "address": "경기 군포시 산본로 323번길 10-18 백운빌딩 2층", "click_link": { "mango": "https://www.mangoplate.com/restaurants/cHfvfQTjXA3B", "dining": "https://www.diningcode.com/profile.php?rid=KT5nPtqCaBOe" }, "position": { "lat": 33.45161231, "lng": 126.12312412 } }, . . . ], "users": [ { "id": "pmb087@gmail.com", "name": "박승민", "picture_uri": "https://lh3.googleusercontent.com/a/AEdFTp7YK7CpbF6nUnkj65nddjIc01mrnD7VbOhk6P-g=s96-c", "like_store": [ 0, 7, 8, 25, 16, 19 ] }, . . . ], "requests": [ { "storeName": "우리동네 라멘맛집", "requestReason": "너무 맛있어서 추천드립니다 제발 추가해주세요~", "postTime": "2023-01-17", "id": 1 }, . . . ]
Figma 이용해서 초기 UI를 설계했습니다.
디자인 관련한 지식이 없기 때문에 이곳 저곳 만져가며 괜찮아 보이는 UI를 도출해야 했고
그 말인 즉슨 초기부터 UI의 수치를 정하고 디자인 한 것이 아니기 때문에
이후에 실제로 구현할 때 시안의 수치를 알 수 있어야 수월하게 UI구현이 가능하다는 것이었습니다.
따라서 Figma를 이용해서 초기 UI 시안을 디자인 했습니다.
/Map
경로로 이동합니다./LoggedInMap
으로 이동합니다.// 구글로그인 콜백함수 function GoogleLogin({ option }: Props) { const route = useRouter(); const googleSignInButton = useRef<HTMLDivElement>(null); const useCredential = (response: GApiResponse) => { const { email, name, picture }: DecodedResponse = jwt_decode( response.credential ); LocalStorageService.set('user', email); UserService.signUp(email, name, picture).catch((error) => { if (error.message === 'Insert failed, duplicate id') { return; } }); route.push('/LoggedInMap'); };
kakao.maps.event.addListener(marker, 'mouseover', function () { infowindow.open(map, marker); }); kakao.maps.event.addListener(marker, 'mouseout', function () { infowindow.close(); }); kakao.maps.event.addListener(marker, 'click', function () { setSelectedId(id); });
지도페이지는 비로그인시/Map
, 로그인시 /LoggedInMap
으로 다른 페이지로 접속됩니다.
비로그인시는 가게 별 찜하기 기능과 마이페이지로 이동할 수 있는 UserMenu를 제공하지 않기 때문에
이외의 모든 항목은 언제나 같은 결과만 화면에 그려집니다.
따라서 비로그인시 접속되는 /Map
페이지는 getStaticProps를 사용하여 정적 페이지로 빌드했고,
로그인시 접속되는 /LoggedInMap
페이지는 getServerSideProps를 사용하여 빌드했습니다.
카카오맵과 구글로그인은 동일하게 Script를 이용해서 해당 기능을 제공받습니다.
따라서 스크립트를 추가하는 로직을 Hooks를 통해 관심사를 분리했습니다.
useScript는 Script를 통해 기능을 제공받을 링크와
해당 기능을 로드하는 script.onload
에 할당 될 함수를 인자로 받아서 동작합니다.
로직 내부에 typeof document !== 'undefined'
라는 조건을 추가한 이유는
Next.js는 서버쪽과 클라이언트 측에서 모두 움직이는 프레임워크이기 때문에
document, window와 같은 클라이언트 측에서만 정의된 전역 변수는 찾을 수 없습니다.
즉, 클라이언트 쪽 전역변수를 사용하려면 랜더링이 된 후에 사용해야 하는 것입니다.
useScript
const useScript = (url: string, onload: () => void) => { if (typeof document !== 'undefined') { const script = document.createElement('script'); script.src = url; script.async = true; script.defer = true; script.onload = onload; document.head.appendChild(script); } }; export default useScript;
지도 페이지 우측에 가게 정보를 표시하는 Aside 메뉴를 자세히 보면
비슷한 스타일의 3가지 문단 [주소, 메뉴, 주차정보]
가 있습니다.
이중 메뉴 단락은 여러가지 메뉴를 받아오기 때문에 변수의 타입이 string[]이었습니다.
타입스크립트의 특성 상 넘겨받은 Props의 타입을 정확하게 기입해야 했기 때문에
문자열을 Props로 받을 때와 문자열로 이루어진 배열을 Props로 받을 경우 두가지를 모두 고려하여
로직을 구현했습니다.
Info.tsx
interface Props { title: string; content: string | string[]; } function Info({ title, content }: Props) { return ( <InfoWrap> <InfoTitle>{title}</InfoTitle> {!Array.isArray(content) ? ( <InfoContent>{content}</InfoContent> ) : ( content.map((item: string) => ( <InfoContent key={item}>{item}</InfoContent> )) )} </InfoWrap> ); }
id
를 like_stores
배열에 추가하여like_stores.includes(id)
를 이용하여 현재 클릭한 가게의 id
값이 내가 찜 한 가게LoggedInStoreInfo.tsx
const handleLike = async () => { if (!storeLike) { const { data } = await UserService.likeStore( userData.id, id, userData.like_store ); setUserData(data); setStoreLike(data.like_store.includes(id)); } else { const { data } = await UserService.unLikeStore( userData.id, id, userData.like_store ); setUserData(data); setStoreLike(data.like_store.includes(id)); } };
ClickLink.tsx
if (isNoData) { return ( <NoLinkWrap onClick={noDataClick}> <ClickLinkWrap> <ImageWrap themeColor={theme} isNoData={isNoData}> <Image src={type === 'mango' ? '/noDataMangoplate.svg' : '/noDataDiningcode.svg'} alt='linkImage' width={150} height={50} /> </ImageWrap> </ClickLinkWrap> </NoLinkWrap> ); } return ( <Link href={link} target='_blank' rel='noopener noreferrer'> <ClickLinkWrap> <ImageWrap themeColor={theme} isNoData={isNoData}> <Image src={type === 'mango' ? '/mangoLink.svg' : '/diningLink.svg'} alt='linkImage' width={150} height={50} /> </ImageWrap> </ClickLinkWrap> </Link> ); }
store_like
배열과 모든 가게의 id
값을WishList.tsx
const filterdStore = storeResponse.filter((el) => currentUserInfo?.like_store.includes(el.id) ); return ( <WishListContainer> <WishListHeader> <WishListHeaderText>찜 목록</WishListHeaderText> </WishListHeader> <WishStoreContainer> {filterdStore.length < 1 ? ( <Image src='/noWishList.svg' alt='찜한 가게가 없음' width={800} height={800} /> ) : ( filterdStore.map((el) => { return <WishStore key={el.id} storeInfo={el} />; }) )} </WishStoreContainer> </WishListContainer> );
관리자 메뉴는 기본적으로 별도의 큰 페이지가 있는 형태로 구현하지 않았습니다.
관리자는 관리를 하고, 유저는 이용을 한다는 개념을 따라서 마이페이지의 좌측 Aside메뉴를
관리자 계정, 일반 유저 계정을 판단하여 다르게 노출하는 방식으로 구현했습니다.
삭제버튼을 누르면 Json-Server에 Delete
요청을 보내며, 이전에 서버에서 받아온 문의 요청 배열을
수정하여 다시 저장합니다.
const deleteRequestData = () => { StoreService.deleteRequest(id); setRequest(allRequest.filter((el) => el.id !== id)); };
string[]
타입으로 구성되었기 때문에 별도의 State로 관리하여Post
함수에 넣어주었습니다.// 메뉴, 메뉴배열 상태 선언 const [storeMenu, setStoreMenu] = useState<string[]>([]); const [menuString, setMenuString] = useState(''); // 메뉴 추가 함수 const addStoreMenu = () => { setStoreMenu([...storeMenu, menuString]); setMenuString(''); }; // 메뉴 삭제 함수 const deleteMenu = (id: number) => { setStoreMenu([...storeMenu.filter((_, index) => index !== id)]); }; // 가게 추가 함수 const addStoreToMap = () => { const content: AddStoreBody = { thumbnail: addStoreData.thumbnail, store_name: addStoreData.storeName, parking_info: addStoreData.parkingInfo, main_menu: storeMenu, address: addStoreData.address, click_link: { mango: addStoreData.mango, dining: addStoreData.dining }, position: { lat: Number(addStoreData.lat), lng: Number(addStoreData.lng) } }; StoreService.addStore(content); setAddStoreData({ thumbnail: '', storeName: '', parkingInfo: '', address: '', mango: '', dining: '', lat: '', lng: '' }); setMenuString(''); setStoreMenu([]); alert('지도에 가게가 추가되었습니다.'); };
ID
값을 기준으로params
값을 이용하여 SSR 방식으로 구현했습니다export async function getServerSideProps({ params }: SSRProps) { const { id } = params; const storeResponse = await StoreService.getStore(id); const storeData = storeResponse.data; return { props: { storeData } }; }
1차적으로 프로젝트를 완료한 뒤 맛집사이트 링크가 없는 경우 버튼을 회색으로 처리하는 로직을
보다 간단하게 수정하는 작업을 github repo에서 직접 적용했던게 원인이었다.
줄바꿈을 Eslint rule에 기반하여 자동으로 적용해줄 수 있는 VS Code에서 작업하지 않았기 때문에
코드 자체에 문제가 없더라도 Eslint rule을 위반하여 빌드과정에서 오류가 발생한 것이다.
아무리 간단한 작업이라도 원칙을 지켜서 작업해야 한다는 교훈을 얻게됐다..
글을 시작할 때도 언급했던 부분이지만 나는 평소에 친구들과 맛있는 라멘을 먹는 것을 즐긴다.
라멘 맛집을 검색하고 정보를 찾고 확인하는 과정도 꽤나 불편했다.
그런데 나는 개발자가 아닌가?
일상생활의 불편함을 코드로 해결하고 싶어하고 실제로 구현하는 직업이다!
평소에 불편했던 나의 경험을 기반으로 라멘 맛집을 모아서 나만의 페이지를 만들자는 생각이 들었다.
사실 이전에도 여러번 프로젝트를 진행해봤지만
이번 프로젝트만큼 내 생각대로, 내 흥미대로 모두 결정하고 구현했던 경험은 처음이다.
프로젝트를 진행하면서 사용자 입장에서 구상하고 실제로 구현이 가능할지 검토하며
내 수준에서 적용 가능한 기술인지 파악하고 추가로 공부하는 모든 과정이 매우 즐겁게 느껴졌다.
가장 재미있던 점은 역시 완성하고 나서 페이지를 배포하고 실제로 내 생각대로 동작하는 페이지를
마주했을 때가 아닐까 싶다.
하루에 3~4시간씩 하다보니 생각보다 많이 늦게 완성했지만 전부 완성하니 엄청 뿌듯하다..
이후에 지인들에게 배포링크를 공유해서 불편한점, 버그 등등 피드백을 받아서 업데이트 해볼 생각이다.
사용자가 유의미한 수치만큼 많아지면 가게별 찜 통계 페이지 같은것도 구현해 볼 생각이다.
처음으로 A to Z 내 힘으로 완성해서 그런지 너무 기분이 좋다
이번 프로젝트 글은 여기까지 하고 줄이겠다