토이프로젝트 - Rolic

SeungMin·2023년 1월 30일
1

용량을 낮춰서 움짤을 만들다 보니 잔상이 많이 남는다...

> 배포 사이트 보러가기 <

시작하게 된 계기

평소에 라멘을 많이 즐겨먹고, 친구들과 저녁약속을 잡을 때는 대부분 라멘을 먹는다.
오늘은 어떤 라멘을 먹어야 할지 늘 고민이 있었다.

지금 근처에 라멘 맛집이 있는지 찾아보기 위해서는 지도 어플을 켜고 라멘을 검색한 뒤
리뷰를 확인, 주차 가능 여부를 파악 등등 확인 할 일이 너무 많았다.

마침 취업 준비를 하며 이것 저것 하느라 바빴던 시기가 지나고 잠깐 짬이 났기에
나만의 라멘 맛집 정리 지도를 만들어보자는 생각이 들었다.

이전에 진행한 프로젝트들은 모두 팀 프로젝트로 진행되었기 때문에
이번에는 혼자서 해보자는 생각이 들었다.

프로젝트 소개

라멘 맛집을 지도상으로 확인 할 수 있게 구현했습니다.
지도는 카카오맵 API를 이용해서 구현했습니다.

구글 로그인을 한다면 가게 별 찜 기능, 찜 한 가게 몰아보기, 가게 추가 요청등
여러가지 기능을 추가로 이용할 수 있습니다.

관리자 권한을 추가하여 Json-Server에 등록된 관리자 계정으로 구글 로그인을 한다면
가게 추가, 가게 추가요청 관리 등의 기능으로 대체하여 표시됩니다.

프로젝트 기간

2023-01-03 ~ 2023-01-26

설날 앞뒤로 잠깐 프로젝트를 멈춰서 실제로는 약 2주정도 소요됐다.

사용된 기술 스택

Next.js , TypeScript , styled-component , Json-Server , Axios ,
GitHub, kakao.maps , google-login

초기 기획

Json-Server 활용한 API 구축

  • 백엔드 개발자 없이 개인으로 서버를 구축할 때 가장 러닝커브가 낮은 Json-Server를 이용했습니다.
  • 서버를 배포할 때 AWSheroku 둘 중 후자를 선택하여 배포했습니다.
    - herokuAWS를 기반으로 동작합니다,
    다만 AWS와는 다르게 세부적인 설정을 신경쓰지 않고 push한번에 배포까지 손쉽게 완료할 수 있기 때문에 다른곳에 신경쓰지 않고 정말 간단하게 배포하기에는 최적이라고 생각하여 heroku를 이용했습니다.

DB.json

{
  "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
    },
    .
    .
    .
  ]   

초기 UI 시안

Figma 이용해서 초기 UI를 설계했습니다.

디자인 관련한 지식이 없기 때문에 이곳 저곳 만져가며 괜찮아 보이는 UI를 도출해야 했고
그 말인 즉슨 초기부터 UI의 수치를 정하고 디자인 한 것이 아니기 때문에
이후에 실제로 구현할 때 시안의 수치를 알 수 있어야 수월하게 UI구현이 가능하다는 것이었습니다.

따라서 Figma를 이용해서 초기 UI 시안을 디자인 했습니다.


페이지 별 로직

메인 페이지

  • CLICK 태그가 붙은 로고를 클릭하면 로그인 없이 비로그인 상태로 /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');
  };

지도 페이지

카카오맵 API

  • 카카오맵 API의 InfoWindow 기능을 이용하여 MouseOver, MouseLeave이벤트를 추가하고
    지도 상에서 해당 마커가 어떤 가게인지 알 수 있도록 했습니다.

  • 지도 페이지에서 Props로 내려받은 SetState함수를 마커의 OnClick으로 넘겨주어 페이지 우측에 클릭한 마커의 가게 정보를 표시할 수 있게 했습니다.
		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);
        });

SSR, SSG

지도페이지는 비로그인시/Map , 로그인시 /LoggedInMap 으로 다른 페이지로 접속됩니다.

비로그인시는 가게 별 찜하기 기능과 마이페이지로 이동할 수 있는 UserMenu를 제공하지 않기 때문에
이외의 모든 항목은 언제나 같은 결과만 화면에 그려집니다.

따라서 비로그인시 접속되는 /Map 페이지는 getStaticProps를 사용하여 정적 페이지로 빌드했고,
로그인시 접속되는 /LoggedInMap 페이지는 getServerSideProps를 사용하여 빌드했습니다.

스크립트 관련 hooks

카카오맵과 구글로그인은 동일하게 Script를 이용해서 해당 기능을 제공받습니다.
따라서 스크립트를 추가하는 로직을 Hooks를 통해 관심사를 분리했습니다.

  • useScriptScript를 통해 기능을 제공받을 링크와
    해당 기능을 로드하는 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>
  );
}

찜하기 로직

  • 특정 가게를 찜하면 해당 유저의 DB에 찜 한 가게의 idlike_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));
    }
  };

예외처리

  • 지도 페이지에 연결된 직후는 아직 지도의 마커를 클릭하지 않은 상태이기 때문에
    보여줄 선택한 상점 정보가 없다. 이 때 Aside에 이미지를 표시하도록 처리했습니다.

  • 사실 토이프로젝트 수준에서 맛집 사이트에서 제공하는 정보를 전부 대체하기엔 한계가 있었습니다.
    때문에 가게 정보 하단에 대표적인 맛집사이트 망고플레이트와 다이닝코드의 링크를 추가했습니다
    몇몇 가게는 해당 링크가 존재하지 않는 경우가 있었기 때문에
    이런 경우에 링크 버튼의 색상을 회색으로 바꾸고, 링크가 존재하지 않는다는 얼럿을 띄웠습니다.


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값을
    filter 메소드를 통해 비교하여 현재 찜 한 가게의 배열만을 남겨서 찜목록을 구현했습니다.
  • 만약 찜목록이 없을 경우 예외처리 이미지를 표시했습니다.

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>
  );

가게 추가 요청

  • 가게 추가 요청은 간단하게 글 내용과 글 제목으로 구성했습니다.
  • 해당 글을 관리자가 읽고 가게를 추가하는데 참고하는 용도로만 구현했습니다.
  • 정보가 너무 부족한 요청을글 방지하기 위해서 글 내용의 길이를 기준으로 작성하기 버튼의
    disabled를 적용했습니다.

관리자 메뉴

관리자 메뉴는 기본적으로 별도의 큰 페이지가 있는 형태로 구현하지 않았습니다.
관리자는 관리를 하고, 유저는 이용을 한다는 개념을 따라서 마이페이지의 좌측 Aside메뉴를
관리자 계정, 일반 유저 계정을 판단하여 다르게 노출하는 방식으로 구현했습니다.

가게 추가 요청 확인

  • 일반 유저의 마이페이지의 좌측 Aside 메뉴인 추가 문의 를 통해서 작성된 문의 글을
    해당 메뉴에서 확인할 수 있습니다.
  • 문의는 요청 된 순서대로 Json-Server에 저장됩니다
    이후 요청 날짜 순(요청 된 순서)에 따라서 글목록이 구성됩니다.
  • 작성된 추가 문의는 클릭하면 모달을 띄워서 확인할 수 있게 구현했습니다.
  • 해당 문의를 요청한 날짜를 확인 할 수 있게 구현했습니다.
  • 확인을 완료한 요청은 삭제할 수 있도록 우측에 삭제버튼을 구현했습니다.

삭제버튼을 누르면 Json-ServerDelete 요청을 보내며, 이전에 서버에서 받아온 문의 요청 배열을
수정하여 다시 저장합니다.

  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
    }
  };
}

진행하며 겪은 오류

Vercel 배포 오류?

1차적으로 프로젝트를 완료한 뒤 맛집사이트 링크가 없는 경우 버튼을 회색으로 처리하는 로직을
보다 간단하게 수정하는 작업을 github repo에서 직접 적용했던게 원인이었다.

줄바꿈을 Eslint rule에 기반하여 자동으로 적용해줄 수 있는 VS Code에서 작업하지 않았기 때문에
코드 자체에 문제가 없더라도 Eslint rule을 위반하여 빌드과정에서 오류가 발생한 것이다.

아무리 간단한 작업이라도 원칙을 지켜서 작업해야 한다는 교훈을 얻게됐다..

프로젝트 후기

글을 시작할 때도 언급했던 부분이지만 나는 평소에 친구들과 맛있는 라멘을 먹는 것을 즐긴다.
라멘 맛집을 검색하고 정보를 찾고 확인하는 과정도 꽤나 불편했다.

그런데 나는 개발자가 아닌가?
일상생활의 불편함을 코드로 해결하고 싶어하고 실제로 구현하는 직업이다!
평소에 불편했던 나의 경험을 기반으로 라멘 맛집을 모아서 나만의 페이지를 만들자는 생각이 들었다.

사실 이전에도 여러번 프로젝트를 진행해봤지만
이번 프로젝트만큼 내 생각대로, 내 흥미대로 모두 결정하고 구현했던 경험은 처음이다.

프로젝트를 진행하면서 사용자 입장에서 구상하고 실제로 구현이 가능할지 검토하며
내 수준에서 적용 가능한 기술인지 파악하고 추가로 공부하는 모든 과정이 매우 즐겁게 느껴졌다.
가장 재미있던 점은 역시 완성하고 나서 페이지를 배포하고 실제로 내 생각대로 동작하는 페이지를
마주했을 때가 아닐까 싶다.

하루에 3~4시간씩 하다보니 생각보다 많이 늦게 완성했지만 전부 완성하니 엄청 뿌듯하다..
이후에 지인들에게 배포링크를 공유해서 불편한점, 버그 등등 피드백을 받아서 업데이트 해볼 생각이다.

사용자가 유의미한 수치만큼 많아지면 가게별 찜 통계 페이지 같은것도 구현해 볼 생각이다.

처음으로 A to Z 내 힘으로 완성해서 그런지 너무 기분이 좋다

이번 프로젝트 글은 여기까지 하고 줄이겠다

profile
공부기록

0개의 댓글