코린이의 개발 일지

[나들이 갈까?] 프로젝트 회고 본문

웹 (web)/나들이 갈까 프로젝트

[나들이 갈까?] 프로젝트 회고

폴라민 2023. 6. 13. 14:16
반응형

프로젝트 소개

서울시 공공데이터 공모전에 아는 분과 함께 참가하게 되었다. 

공공데이터를 사용해야 하던 상황이라 주제를 어떤걸로 정할까 고민하다가 

'코로나 풀린 시점에 바깥 활동이 많이 늘어났으니, 놀러갈만한 장소 추천해주는 서비스 어때?'

라는 말을 시작으로 기획을 시작했다.

 

서비스에서 제공할 기능 목록을 우선 쭉 나열하고 그걸 바탕으로 필요한 데이터를 찾았다.

우리 서비스에서 제공할 기능은

  • 사용자가 나들이 계획에 참고할 수 있는 약 10일간의 날씨 정보
  • 사용자 위치 기반 주변 나들이 장소 제공
  • 장소 검색
  • 각 장소들의 상세 정보 제공
  • 모바일 & 데스크탑 환경 모두 제공

크게 위 다섯가지였다.

 

사용한 공공데이터는 다음과 같다

 

기획과 설계

처음 고민했던 것은 디자인이었다.

프론트엔드 개발 1명, 백엔드 개발 1명 이렇게 총 두명으로 진행해서 디자인은 내가 맡았다.

아무래도 지도를 사용하는 서비스이니 네이버 지도 구글맵 등등 지도 관련 서비스들 UI를 많이 참고했다.

 

아래는 초기 디자인이다. 현재 서비스는 여기서 조금씩 변형되었다.

https://www.figma.com/file/GPcrDETAEKtHCqjYEn0kPz/%EB%82%98%EB%93%A4%EC%9D%B4-%EA%B0%88%EA%B9%8C%3F?type=design&node-id=1-2

 

 

모바일과 데스크탑 UI를 어떻게 동시에 지원할까 많은 고민을 했다.

각각의 컴포넌트에 media query를 적절히 사용하고 트리 구조를 잘 잡아서 구현하고 싶었으나,

데스크탑 UI와 모바일 UI가 다른부분이 꽤 많아서 쉽지 않았다.

나들이 갈까? 서비스 화면

그래서 처음 설계는 아래처럼 데스크탑 layout과 mobile layout을 따로 만들어서 viewport 너비에 따라 diplay none 속성을 적용하여 구현했다.

<div class="mobile-layout></div>

 

이 부분은 다른 문제 때문에 현재는 다른 방식으로 수정된 상태이다. 

 

 

개발 환경 세팅

프레임워크

사실 서비스 성격 자체만 생각하면 React를 쓰는게 더 옳은 판단이었다.

  • 단일 페이지
  • 사용자 위치 정보 허용 후 api 요청 가능 (SSR 불가능)
  • 지도 api 사용 (마찬가지로 클라이언트에서 렌더링)

이런 이유 때문에 고민했는데, Next.js를 선택했다.

이유는

  • 실제 배포할 서비스이니, 검색엔진최적화에 좋을거 같아서
  • 이미지를 많이 띄워야하는데, 이미지 최적화에 용이할거 같아서

Next.js의 경우 Image 컴포넌트를 사용하면 lazy loading 적용도 되고, 캐싱도 지원해준다.

장소들의 이미지를 띄워야하니 캐싱을 하면 렌더링 성능에 좋겠다고 생각해서 도입했다.

 

문제는 현재 Image 컴포넌트를 사용하고 있지 못한 상황이다.

https://polarmin.tistory.com/80

 

[나들이 갈까?] 배포 트러블 슈팅 (Next/Image 캐싱)

프론트엔드 서버를 AWS에 배포했는데 서버가 자꾸 터지는 상황이 발생했다. EC2 t2.micro 유형으로 배포한 상태였는데, 페이지에 3번정도 접속하면 서버가 자꾸 다운되어서 그 원인을 찾아봤다. 원

polarmin.tistory.com

 

배포 환경에서 문제가 발생해서 못쓰고 있다.

위의 링크에서 자세한 내용을 확인할 수 있다.

 

아무튼 처음에는 이런 생각을 바탕으로 Next.js를 사용했다.

 

 

상태 관리 라이브러리

  • geolocation 사용해서 사용자 위치 정보 
  • 지도 api에서 사용하는 지도 중앙 위치와 지도 경계 좌표
  • 장소 타입 필터 목록 

등등 전역으로 관리할 상태가 많았기 때문에 전역 상태 관리 라이브러리를 사용했다.

전역 상태 관리 라이브러리는 redux-toolkit을 사용했다.

사용한 이유는 미들웨어를 사용해서 api 응답으로 받아온 데이터를 전역 상태로 관리하기 용이할거 같았기 때문이다.

 

'나들이 갈까' 서비스에서 장소 리스트와 지도에 마크되는 장소들은 같은 목록을 사용한다.

예을 들어 검색을 하면, 검색 결과로 온 목록을 sidebar (혹은 drawer)에 렌더링하고, 지도에 해당 목록들을 마커로 표시한다.

첫 접속시에는 지도 바운더리를 기준으로 지도 내부에 있는 장소 목록을 sidebar (혹은 drawer)에 렌더링하고, 지도에 해당 목록들을 마커로 표시한다.

 

즉 장소 목록을 가져오는 api는 '장소 검색 목록', '지도 바운더리 기준 지도 내부에 있는 장소 목록' 이렇게 두가지 이지만

두개의 응답은 같은 용도로 사용된다.

 

React Query를 사용할까 하다가, redux를 사용한 이유 중 하나인데 

렌더링할 장소 목록을 placeList라는 하나의 전역 상태로 관리하고, 현재 검색 상태인지, 아닌지에 따라 api요청을 통해 데이터 응답을 받아오고 placeList 상태를 변경해주었다.

 

이렇게 구현하게 되면, 데이터 요청 로직만 잘 신경쓰고 map 컴포넌트나, sidebar 혹은 drawer 컴포넌트에서는 그저 placeList 상태를 가져다 렌더링하면되기 때문에, 로직이 더 깔끔해질 거라 생각했다.

 

그리고 recoil은 많이 써봤으니까 이번에는 redux 한번 써보고 싶었다.

 

 

트러블 슈팅

DOM 트리 구조

개발 과정에서 여러 고민들이 있었다.

첫째는 DOM 구조였다.

 

처음 개발할 당시 전체적인 DOM 구조는 아래와 같았다.

<div>
	<div class="desktop-layout">
    	...
    </div>
	<div class="mobile-layout">
    	...
    </div>
</div>

 

각각의 UI가 달라서 선택한 차선책이었다.

아래 그림을 보면 Side bar의 경우 데스크탑에만 있고, Drawer의 경우 모바일에만 있다.

그리고 모바일UI에서는 검색바와 버튼이 지도 위에 있어야 하고, 헤더의 구조도 조금 다르다.

 

 

이러한 부분들이 media query로만 사용해서 해결하기에는 어려운 부분이 있어 고민하다가 위와 같은 구조로 구현한것이었다.

 

문제는 같은 데이터 요청이 두번씩 간다는 것이었다.

렌더링 과정을 보면 

1. 가상돔 생성

2. DOM 트리 구축, CSSOM 트리 생성, 자바스크립트 파싱 후 실행

3. 렌더트리 구축

4. layout 구성, 페인팅

...

 

이런 과정으로 실행된다.

데스크탑 환경에서 실행해서 mobile ui를 display:none으로 숨기면, 렌더 트리에는 반영이 안되지만 가상돔이 생성되고 DOM 트리 구축하는 과정에서는 display: none인 element도 반영이 되기 때문에 데이터 요청이 가게된다.

 

즉 데스크탑 환경에서 실행하면 mobile ui는 display none이라 렌더트리에는 반영되지 않지만 자바스크립트 로직은 실행되어서 데이터 요청을 보내게 된다. 데이터 요청을 보내는 컴포넌트가 2개씩이니 당연히 요청이 두번 가게된다.

 

 

 

이를 해결하기 위해 Atomic design 패턴을 사용했다.

 

Atomic Design 패턴 도입

https://yozm.wishket.com/magazine/detail/1531/

 

Atomic Design Pattern의 Best Practice 여정기 | 요즘IT

좋은 폴더 구조에 관한 이야기는 개발자들 간의 끊임없는 떡밥입니다. 정답이 있지 않고 프로젝트의 특징이나 크기, 주관적인 해석에 따라 정말 여러 가지 방법들이 존재하기 때문입니다. 마치

yozm.wishket.com

 

위의 글을 많이 참고 했는데, 요약하자면 컴포넌트 layer를 아래 그림과 같이 두고 레고를 조립하듯이 컴포넌트를 재사용하고 조립하여 더 큰 컴포넌트, 그다음 컴포넌트, 그렇게 해서 최종 페이지를 구성하자는 게 핵심 아이디어이다.

 

우선 가장 작은 단위인 atom으로 사용할 컴포넌트를 먼저 구현하고, 그 다음 필요한 컴포넌트들을 구현하여 molecules와 organisms으로 구분 지었다.

 

 

도입해보고 느낀점은 molecules와 organisms 경계가 참 모호하다는 것이었다..

나만의 주관적인 기준을 가지고 나누긴 했으나 이것이 옳은지는 모르겠다. 다른 레퍼런스들을 찾아보니 다른 분들도 많이 고민하시는 부분인거 같았다.

 

나만의 주관적인 기준은 페이지 내에서 특정한 한 부분을 크게 담당하고 있으면 organisms으로 넣었다. header, sidebar, drawer등등..

 

 

루트 컴포넌트에서 훅을 통해 api요청을 보내 전역 상태로 저장하고, 각각의 컴포넌트에서는 전역 상태를 가져다 사용하기 때문에 두번씩 요청이 보내지지 않는다. 각 컴포넌트 UI와 렌더링 여부는 미디어 쿼리를 통해 조절해주었다.

 

구현해둔 컴포넌트 들을 아래와 같이 page에서 조립했다.

 

<Wrapper>
  <SideBar isLoading={isLoading || isGetLocation} />
  <TobBar />
  <Section>
  {!search && !id && <Filter />}
  {isGetLocation ? (
    <LoadingContainer>
      <Loading />
    </LoadingContainer>
  ) : (
    <Map />
  )}
  </Section>
  <Drawer isDetail={!!id}>
    {id ? <Detail /> : <PlaceList isLoading={isLoading || isGetLocation} mobile />}
  </Drawer>
</Wrapper>

 

 

이런식으로 컴포넌트 중복 사용을 막아서 api 요청을 한번씩만 가도록 수정하였고, 컴포넌트 구조도 한눈에 들어오게 수정하였다. SideBar는 데스크탑 UI에서만 렌더링되고, TobBar와 Drawer는 모바일 UI에서만 렌더링된다.

 

 

모바일 UI 깨짐 

같이 프로젝트를 진행한 팀원분이 핸드폰에서 서비스 사용하면 UI가 깨진다는 얘기를 해주셔서 확인해봤다.

 

모바일 환경에서 일정 횟수 이상 api요청을 보냈을 때 UI가 깨지는 현상이 있었다.

Android os에서는 개발자 도구와 마찬가지로 UI가 깨졌고 IOS에서는 아래와 같이 문구가 뜨고 새로고침이 되었다.

 

 

 

 

 

보통 이런 경우는 JavaScript 실행 시간이 너무 오래 걸려서 브라우저가 응답이 없다고 판단하여 발생한다.

Performence 탭을 통해 memory를 체크해봤다.

 

 

 

보면 JS Heap 메모리와 Listener가 계속 증가하고 있다.

어떤 특정 이유 때문에 메모리가 가비지 컬렉팅 되지 않고 계속 쌓이고 있는 것이다.

 

이를 해결하기 위해 불필요한 리렌더링 최적화와 지도를 다시 그리는 로직을 수정했다.

 

기존에는 여러 코드의 관심사가 여기저기 산재되어 있어서 트러블 슈팅하기에도 불편하고 수정할때 이곳저곳 건드리다 보니 전역 상태 관리도 잘 안되고 불필요한 리렌더링도 많이 생긴 상황이었다.

 

때문에 코드 전체적인 리팩토링에 들어갔다.

 

export default function Home() {
  const { id, search } = useQueryString();
  const { isGetLocation } = useUserLocation();
  const { isLoading } = usePlaceList();

  return (
    <Wrapper>
      {width > BREAK_POINT.mobile && <SideBar {...{ isLoading, isGetLocation }} />}
      {width <= BREAK_POINT.mobile && <Header mobile />}
      <Section>
        {width <= BREAK_POINT.mobile && <SearchContainer />}
        {!search && !id && <Filter />}
        {isGetLocation ? (
          <LoadingContainer>
            <Loading />
          </LoadingContainer>
        ) : (
          <Map />
        )}
      </Section>
      {width <= BREAK_POINT.mobile && (
        <Drawer isDetail={!!id}>
          {id ? <Detail /> : <PlaceList isLoading={isLoading || isGetLocation} mobile />}
        </Drawer>
      )}
    </Wrapper>
  );
}

 

처음 렌더링을 시작하면 id, search 쿼리 스트링 값을 가져오는 훅과

사용자 위치 정보를 가져오는 useUserLocation()훅

그리고 현재 렌더링할 장소목록을 가져오는 usePlaceList() 이렇게 세개의 훅으로 관심사 분리했다.

 

핵심은 usePlaceList 훅이다.

서비스를 보면, 처음 접속시 사용자 주변 장소 목록을 sidebar(모바일은 drawer)와 지도에 띄우고

검색시, 검색 관련 목록을 sidebar와 지도에 띄운다.

 

두개 기능의 응답 결과는 다르고 검색 api와 주변 장소 api는 분리되어 있으나, 같은 컴포넌트에서 렌더링된다.

따라서 placeList라는 전역 상태를 만들었고, 현재 검색 상태인지 유무에 따라 placeList를 업데이트하는 로직을 구현해주었다.

 

export const usePlaceList = () => {
  const fetchPlaceList = useCallback(async () => {
    const data = await httpGet(주변 장소 목록 api);
    dispatch(addPlaceList(data.data));
  }, [min, max, typeFilter]);

  const fetchSearchList = useCallback(async () => {
    const data = await httpGet(검색 장소 목록 api);
    dispatch(addPlaceList(data.data));
  }, [search, latitude, longitude]);

  useEffect(() => {
    if (!latitude || !longitude) return;
    if (search) return;
    if (id && placeList.length !== 0) return;
    fetchPlaceList();
  }, [min, max, typeFilter, search]);

  useEffect(() => {
    if (!latitude || !longitude) return;
    if (!search) return;
    fetchSearchList();
  }, [search, latitude, longitude]);
  return { isLoading };
};

 

따라서 검색 이벤트 핸들러는 router.push로 쿼리스트링만 붙여줘도, search 값이 변경되면서 useEffect 콜백 함수가 트리거 되기 때문에 검색 목록을 새로 불러와 placeList에 업데이트한다.

 

const searchHandler = () => {
    router.push({
      pathname: '/',
      query: { search: value },
    });
  };

 

이런식으로 필요없는 로직을 최대한 제거하고 side effect로 인한 리렌더링을 쉽게 확인 가능하도록 관심사를 분리했다.

이를 바탕으로 리렌더링 최적활를 진행했다.

 

그 결과 메모리가 가비지 컬렉팅되는 것을 확인할 수 있었고, 모바일 환경에서의 오류도 해결되었다.

 

 

배포 서버 문제

프론트엔드 서버를 AWS에 배포했는데 서버가 자꾸 터지는 상황이 발생했다.

아래 링크에 가면 자세한 내용을 살펴볼 수 있다.

https://polarmin.tistory.com/80

 

[나들이 갈까?] 배포 트러블 슈팅 (Next/Image 캐싱)

프론트엔드 서버를 AWS에 배포했는데 서버가 자꾸 터지는 상황이 발생했다. EC2 t2.micro 유형으로 배포한 상태였는데, 페이지에 3번정도 접속하면 서버가 자꾸 다운되어서 그 원인을 찾아봤다. 원

polarmin.tistory.com

 

결론부터 얘기하자면, 이미지 파일들이 문제였다.

Next.js는 이미지 최적화를 위해 Next/Image 컴포넌트를 제공한다. Next/Image 컴포넌트는 이미지를 반복해서 요청하는 횟수를 줄이기 위해 프론트엔드 서버에 이미지를 캐싱한다.

 

즉 사용자가 접속하면 동적으로 불러오는 모든 이미지들을 캐싱하는데 그게 서버에 쌓이다가 메모리 부족으로 서버가 터진것이다.

 

캐싱이 안되도록 unoptimized 옵션을 추가하여 해결했다.

 

하지만 이렇게 되면 문제가 하나 있는데 아래와 같이 HTTPS로 로드된 페이지에서 HTTP로 요청하는 요소가 있는 경우 발생하는 문제이다.

 

공공데이터에서 제공하는 이미지가 HTTP로된 이미지들이 몇개 있어서 사실 서버에서 HTTPS로 제공되는 API를 사용하도록 변경하는 것이 보안상 더 좋다.

만약 Image 컴포넌트를 사용해서 서버에서 캐시되도록 구현했다면

브라우저는 프론트엔드 서버에 이미지 요청을 보내고 (https) 서버에서 다시 공공데이터 이미지를 가져와서(http) 응답으로 브라우저에 보내주기 때문에, 보안상 더 좋다.

 

아쉽지만 서버 메모리가 부족하니 어쩔 수 없지..

 

 

 

>> 서비스 링크 : https://picnic-map.polarmin.net/

 

나들이 갈까?

나들이 장소 추천 서비스⭐ 나들이 장소를 추천받아 보세요!

picnic-map.polarmin.net

>> 깃허브 링크: https://github.com/green-pinetree/picnic-map

 

GitHub - green-pinetree/picnic-map: '나들이 갈까?' 나들이 장소 추천 서비스 ⭐

'나들이 갈까?' 나들이 장소 추천 서비스 ⭐. Contribute to green-pinetree/picnic-map development by creating an account on GitHub.

github.com

 

 

Refs.

https://ui.toast.com/weekly-pick/ko_20210611

 

당신이 모르는 자바스크립트의 메모리 누수의 비밀

크롬 개발자도구로 하는 디버깅과 해결책을 찾아서!

ui.toast.com

 

반응형
Comments