teklog

부동산 중개 서비스 - 팀 프로젝트 회고록

2022/09/15

n°11

category : Recap

부동산 중개 플랫폼 프로젝트 : 푸망


img


프로젝트 깃헙 리드미

리드미에서 모든 데모 영상을 볼 수 있습니다


Description



  • 팀 프로젝트 푸망은 부동산 중개 플랫폼 다방의 디자인과 기획을 참고하여 개발하였다. 5명의 팀원들과 함께 서버/클라이언트를 개발하고, AWS EC2를 이용해 배포까지 진행했다.


개발 인원 및 기간




Project Goal



  • 오픈 API를 이용한 다양한 이벤트 처리
  • 백엔드 서버에 저장된 데이터의 등록, 수정, 삭제 상태를 지도 UI에 반영하는 것
  • 개인적인 목표로는 팀에 더 많이 기여할 것


Challenging Points



  • 처음으로 오픈 API의 사용법을 익히고, Context API도 동시에 익혀야 했던 점
  • 카카오 지도 API를 DOM에 직접 접근하여 사용해야 했던 점
  • 지도 UI와 연동된 이벤트가 너무 많았던 점
  • 이벤트가 많아서 생긴 지도의 리렌더링 이슈와 효율적인 이벤트 처리를 위한 fetch 함수


Review


2주라는 기간이 너무 촉박하게 느껴지는 프로젝트였다. 일정 산정을 잘못해서 그랬는지, 기한에 맞춰 겨우 아슬아슬하게 끝낼 수 있었다. 지금 돌아봤을 땐 무언가 강렬한 것이 휘몰아치고 지나갔던 느낌이지만, 회고 적으면서 보니 몇 가지 괜찮았던 점과 아쉬웠던 점들이 꽤 분명했던 프로젝트였다.


아쉬웠던 점


1. 욕심

너무 많은 기능을 개발하려고 욕심을 냈던 걸까? 리팩토링 기간에도 우리 팀은 나머지 기능들을 개발했어야 했다. 기획 단계에서 불필요하게 쳐낼 기능은 모두 쳐냈어야 했는데, 그러지 못한 점이 아쉽다. 사실 이건 내가 잘못한 부분이 많았다. (욕심이 많아서..)


가장 기억나는 것은 지도 페이지의 필터링이다. 2개의 필터링 모달 중 하나는 프론트에서 필터링을 구현하려고 하였다. 지도에 이벤트가 너무 많고, 핸들러가 모두 비동기 함수였기에 과도한 요청이 있을 것이라 예상했다. 불필요한 데이터 요청을 줄여 성능을 개선하기 위해 클라이언트에서 필터링을 구현했다. 지도 범위 내의 매물이 context에 저장되고, 필터링 이벤트가 발생했을 때 이미 저장된 매물들을 필터링 하는 방식으로 구현했다. 그러나 그건 잘못된 방식이었다! 데이터가 조금만 커지니 성능이 눈에 띌 정도로 악화되었다. 반대로 또다른 모달은 백엔드에 필터링된 결과를 요청하도록 개발했는데, 그 방식이 훨씬 빨랐다. 프론트에서 필터링을 구현하자고 주장했던 터라 참으로 무안하고, 백엔드 분들에게 특히 미안했다. 내가 괜히 욕심을 부려 팀 전체적으로 비용 소모가 생겼기 때문에 후회가 된다.


2. SDK를 사용하지 않았다

카카오맵 api의 공식 문서가 바닐라 자바스크립트로 되어있고, 정식 리액트용 패키지가 없었던 것 같아서 바닐라 자바스크립트로 맵 api를 활용했다. 그러나 이 방식은 너무 많이 전역 객체에 접근해서 리액트를 온전히 활용하지 못했다. 지도 객체가 재렌더링 되지 않도록 신경을 많이 썼는데, 코드가 장황하고 난해해졌다. 개발 중간에 리액트용 카카오 맵 패키지를 보았는데, 마이그레이션 할 엄두가 나지 않아서 하지 못했다. 조금 시간을 들여서 SDK로 바꾸었더라면 생산성, 코드 퀄리티는 훨씬 좋아졌을 것이다.


스스로에게 작은 위로를 남기자면 이 문제를 해결하면서 자바스크립트 런타임, 스코프에 대한 이해가 깊어졌다는 점, 코드가 복잡하니 팀원들에게 충분히 설명하고, 주석에 신경 쓴 점은 괜찮았다.


3. 최적화에 신경 쓰지 못했다.

시간에 쫓겨서 성능 최적화에 힘을 많이 쓰지 못한 점이 아쉽다. 지도 페이지는 이벤트가 많아 리렌더링 이슈에 더 민감하게 신경을 썼어야 했는데, 그러지 못했다. 개발을 시작한지 3개월 정도 되던 때라 어떤 기능을 개발했단 사실 자체에 크게 의미를 뒀었다. 시간이 지나고 나서야 그게 제일 중요한 건 아니라는 걸 알았다. 다른 중요한 것들도 많았다. 특히 성능 이슈는 사용자 경험과 밀접하게 관계가 있어 중요하다. 사용자가 체감할 정도의 버벅임은 심각한 성능 저하 이슈인데, 그걸 온전히 해결해보지 못한 점이 아쉽다. 시간 관리가 부족했고, 무엇보다 나와 우리 팀의 역량이 부족했기에 아쉬움이 남는다.



좋았던 점


1. 훌륭한 팀워크

소통이 원활하고 유쾌했던 것 이상으로, 우리 팀에게 맞는 협업 방식을 찾아갔기에 고무적이다. 협업을 위해 간단한 규칙을 정했는데 꽤 도움이 됐었다. 첫째로 블로커가 생기면 바로 팀원과 공유하고, 함께 그 에러를 해결할 것. 둘째로 어렵고 새로운 기능을 개발할 때는 팀원 모두가 참여할 것. 셋째는 5~15분 사이의 간단한 스탠딩 미팅을 매일 할 것.


물론 매번 지켜지지 못할 때도 있었다. 바빠서 회의를 못할 때도 있었고, 시간이 부족해 빠르게 팀원 혼자 개발한 기능도 있었다. 그럼에도 이 세 가지 규칙의 핵심 가치는 충분히 지켰다. 함께 배우고 공유하는 것. 지도 UI를 개발할 때는 프론트 팀원 모두가 둘러 앉아 공식 문서를 뒤져가며 코드를 설계했다. 지도 UI는 내 담당이었기에 코드는 주로 내가 작성했지만, 팀원 분들이 지도 API는 바로 활용할 수 있도록 작성한 코드를 설명하고, 이해시키려 노력했다. 블로커 이슈가 생기면 모두가 함께 해결하는 것도 마찬가지였다. 지도 UI와 관련된 이슈들이 많아서 나중에는 본인이 작성한 코드가 아니더라도 자기 코드처럼 익숙해질 수 있었다. 개발하면서 이처럼 깊고 원활하게 소통했던 건 처음이었던 것 같다. 이 경험을 통해 '내가 맡은 티켓을 어떻게 잘 처리할까'에서 '팀' 중심으로 생각을 옮길 수 있었던 것 같다. 부족한 인력, 버거운 과제, 촉박한 마감 앞에서 우리 팀의 협업이 중요했기에 깨달을 수 있었다.


2. 팀에 대한 기여

처음 팀 프로젝트를 했을 땐 개발 경력이 있는 팀원이 두분이나 있어서 그 분들 도움을 많이 받았다. 팀을 위해 여러 일들을 솔선수범 하는 모습이 귀감이 됐다. 다음 번 팀 프로젝트 땐 나도 그들처럼 나서야겠다고 다짐했는데, 이번 프로젝트에서 그 다짐을 어느 정도 실천했다. 내가 팀에 기여하기 위해 했던 일들은 다음과 같다.




  • 초기 세팅 및 폴더 구조 세팅
  • 공용 컴포넌트 개발
  • 공용 상태 관리 API 작성
  • 작성한 코드 설명 & 에러 해결 etc.


초기 세팅은 번거로울 수 있어도 간단한 작업이어서 바로 나서서 했다. 공용 컴포넌트로는 지도 페이지 전체 레이아웃을 개발했다. 지도 페이지는 컴포넌트를 나눠서 각자 개발하였다. 내가 담당했던 부분은 맵 페이지의 지도 UI와 검색바, 두 개의 필터링 모달이었고, 다른 팀원이 맡은 부분은 지도 범위 내의 매물을 보여주는 리스트 사이드바였다. 지도에서 발생하는 클릭, 드래그, 스크롤 이벤트에 따라 리스트 사이드바가 업데이트 되고, 필터링 모달의 상태에 따라 지도 UI가 업데이트 되는 복잡한 페이지였다. 게다가 모든 이벤트의 핸들러는 비동기 함수였기 때문에, 프로젝트의 가장 까다로운 페이지였다고 할 수 있다.


작업의 혼선을 피하기 위해, 페이지 전체 레이아웃과 컴포넌트를 설계했다. 팀원들이 개발한 컴포넌트를 필요한 위치에 삽입하면 바로 작동할 수 있도록 하기 위해서다. 컴포넌트 관계를 잘못 설정하여 불필요한 상속(prop drilling)을 최대한 피하고 싶었고, 다른 팀원들이 빠르게 지도 페이지 구조를 파악하기를 바랬다.


복잡한 상태 관리가 필요했기 때문에 공용 상태 관리 API 작업이 필요했다. 지도 페이지 안에 지도, 리스트, 필터링 모달이 모두 형제 컴포넌트로 있었고, 그들이 서로를 업데이트 시키는 이벤트가 많이 있었기 때문에 상태 관리가 필수로 느껴졌다. useForwardRef 같은 훅을 사용해 state와 setter 상속으로도 해결할 수도 있었지만, 모든 이벤트를 상속으로 관리했다면 부모 페이지가 너무 방대해질 것이었다. 혼자 개발했다면 상관 없겠지만, 팀원들이 내 코드를 파악해야 하므로 가독성이 중요했기에, 장황하고 난해한 코드는 최대한 줄이고 싶었다. 상태 관리를 위해 Context API를 사용했고, 지도 페이지 내에서 이루어지는 모든 이벤트는 context를 통해 관리하였다. 팀원들도 context를 이용해 개발을 해야했기 때문에 충분히 설명을 하였고, context에 오류가 발생한 부분은 팀원들이 고쳐주기도 하였다.



코드 공유


MapPage 지도 페이지 전체 레이아웃


function MapPage() {
	  return (
	    <Container>
	      <Header />
	      <GlobalContextProvider>
	        <Wrapper>
	          <SearchBar />
	          <MapWrapper>
	            <div className="list">
	              <List />
	            </div>
	            <div className="map">
	              <Map />
	            </div>
	          </MapWrapper>
	        </Wrapper>
	      </GlobalContextProvider>
	    </Container>
	  );
	}
	
...

Context API로 상태 관리를 하면서 지도 페이지 컴포넌트는 이처럼 간결해질 수 있었다.


Map 지도 페이지 내의 지도 컴포넌트


...

function Map() { 
  const RealEstate = useContext(RealEstateContext);
  	  const RealEstateDispatch = useContext(RealEstateContextDispatch);
  	  const { mapBounds, 
  	          roomTypeFilter,
  	          tradeTypeFilter, 
  	          clustererStyle, 
  	          realEstate}  = RealEstate;
  	
  
  	  const { kakao } = window;
  	  const mapContainer = useRef('');
  	
  
  	  // 지도 객체, 클러스터러 데이터를 담을 ref
  	  const mapDOM = useRef('');
  	  const clustererDOM = useRef('');
  	  const markerDOM = useRef('');
  	  const kakaoMap = mapDOM.current;
  	  const kakaoClusterer = clustererDOM.current;
  	  let tradeTypeQuery;
  	
  
  	  // 지도 생성 함수
  	  const mapscript = () => {
  	    let container = mapContainer.current;
  	    let options = {
  	      center: new kakao.maps.LatLng(37.507454314288054, 127.03402073986199),
  	      level: 4,
  	      maxLevel: 7,
  	    };
  	
  
  	    const map = new kakao.maps.Map(container, options);
  	    const zoomControl = new kakao.maps.ZoomControl();
  	    map.addControl(zoomControl, kakao.maps.ControlPosition.BOTTOMRIGHT);
  	
  
  	    RealEstateDispatch({ type: 'UPDATE_MAP', map: map });
  	    RealEstateDispatch({ type: 'GET_BOUNDS', getBounds: map.getBounds() });
  	
  
  	    kakao.maps.event.addListener(map, 'zoom_changed', () => {
  	      RealEstateDispatch({ type: 'GET_BOUNDS', getBounds: map.getBounds() });
  	      RealEstateDispatch({ type: 'GET_SELECTED_ESTATE', selected: [] });
  	    });
  	    kakao.maps.event.addListener(map, 'dragend', () => {
  	      RealEstateDispatch({ type: 'GET_BOUNDS', getBounds: map.getBounds() });
  	      RealEstateDispatch({ type: 'GET_SELECTED_ESTATE', selected: [] });
  	    });
  	    kakao.maps.event.addListener(map, 'click', () =>
  	      RealEstateDispatch({ type: 'GET_SELECTED_ESTATE', selected: [] })
  	    );
  	    // 지도 객체를 반환하여 ref에 저장
  	    return map;
  	  };
  	
  
  	  // 지도의 좌표 범위를 보내고, 범위 내의 매물을 Context에 저장하는 fetch 함수
  	  const sendBoundGetItem = () => {
  	    // 거래 종류(전세, 월세) 필터를 query에 담는 조건문 
  	    if (
  	      Object.entries(tradeTypeFilter).filter(el => el[1] === true).length !== 0
  	    )  {
  	      tradeTypeQuery = Object.entries(tradeTypeFilter)
  	        .filter(el => el[1] === true)
  	        .map(el => el[0])
  	        .toString();
  	    }
  	    fetch(`${BASE_URL}/estates?tradeType=${tradeTypeQuery}`, {
  	      method: 'GET',
  	      headers: {
  	        'Content-type': 'application/json',
  	        LatLng: `${RealEstate.mapBounds.ha},${RealEstate.mapBounds.oa},${RealEstate.mapBounds.qa},${RealEstate.mapBounds.pa}`,
  	      },
  	    })
  	      .then(res => {
  	        if (!res.ok) {
  	          throw new Error(res.statusText);
  	        }
  	        return res.json();
  	      })
  	      .catch(err => {
  	        // 응답 에러 시 context의 매물 정보를 빈 배열로 전환
  	        console.log(err.message)
  	        RealEstateDispatch({ type: 'GET_REAL_ESTATE', realEstate: [] });
  	      })
  	      .then(data => {
  	        // 해당 범위 내의 존재하는 매물이 없다면 context에 빈 배열로 저장
  	        // fetch로 받은 매물에서 방종류(원룸, 빌라, 오피스텔, 아파트)를 필터링하는 로직
  	        if (
  	          Object.values(RealEstate.roomTypeFilter).filter(
  	            filter => filter.isOn === true
  	          ).length < 4
  	        ) {
  	          const filteredData = data.clusters.filter(estate =>
  	            Object.values(RealEstate.roomTypeFilter).find(
  	              filter => filter.roomType === estate.category_type && filter.isOn
  	            )
  	          );
  	          RealEstateDispatch({
  	            type: 'GET_REAL_ESTATE',
  	            realEstate: filteredData,
  	          });
  	          return;
  	        } else {
  	          RealEstateDispatch({
  	            type: 'GET_REAL_ESTATE',
  	            realEstate: data.clusters,
  	          });
  	          return;
  	        }
  	      });
  	  };
  	
  
  	  // 첫 마운트시 1번만 지도를 렌더링하고, useRef에 지도 객체를 저장.
  	  useEffect(() => {
  	    mapDOM.current = mapscript();
  	  }, []);
  	
  
  	  // 지도의 범위가 바뀔 때마다 fetch함수가 실행, Context에 범위 내 매물 저장
  	  useEffect(() => {
  	    sendBoundGetItem();
  	  }, [mapBounds, roomTypeFilter, tradeTypeFilter]);
  	
  
  	  // 현재 좌표 범위 내의 매물들이 로드 되고 난 후, 클러스터만 다시 렌더링
  	  useEffect(() => {
  	    // ref에 저장된 기존의 클러스터를 삭제
  	    if (kakaoClusterer) {
  	      kakaoClusterer.clear();
  	    }
  	
  
  	    if (kakaoMap) {
  	      const marker = realEstate.map(el => {
  	        return new kakao.maps.Marker({
  	          map: kakaoMap,
  	          position: new kakao.maps.LatLng(el.lat, el.lng),
  	        });
  	      });
  	
  
  	      const clusterer = new kakao.maps.MarkerClusterer({
  	        map: kakaoMap,
  	        averageCenter: true,
  	        minLevel: 1,
  	        minClusterSize: 1,
  	        disableClickZoom: true,
  	        gridSize: 200,
  	        styles: clusterStyle,
  	      });
  	      clusterer.addMarkers(marker);
  	
  
  	      kakao.maps.event.addListener(clusterer, 'clusterover', cluster => {
	        const overlay = cluster.getClusterMarker().getContent();
	        overlay.style.background = '#fff';
	        overlay.style.color = 'rgb(50, 106, 249)';
	      });
	      kakao.maps.event.addListener(clusterer, 'clusterout', cluster => {
	        const overlay = cluster.getClusterMarker().getContent();
	        overlay.style.background = 'rgba(50, 106, 249, 0.8)';
	        overlay.style.color = '#fff';
	      });
	      kakao.maps.event.addListener(clusterer, 'clusterclick', cluster => {
	        RealEstateDispatch({
	          type: 'GET_SELECTED_ESTATE',
	          selected: RealEstate.realEstate.filter(estate => {
	            return cluster
	              .getMarkers()
	              .map(x => x.getPosition())
	              .find(
	                qa =>
	                  estate.lat.toFixed(12) === qa.Ma.toFixed(12) &&
	                  estate.lng.toFixed(12) === qa.La.toFixed(12)
	              );
	          }),
	        });
	      });
	

	      clustererDOM.current = clusterer;
	      markerDOM.current = marker;
	

	      RealEstateDispatch({ type: 'UPDATE_CLUSTERER', clusterer: clusterer });
	      RealEstateDispatch({ type: 'UPDATE_MARKER', marker: marker });
	    }
	  }, [RealEstate.realEstate]);
	

	  return (
	    <div>
	      <div
	        id="map"
	        ref={mapContainer}
	        style={{ width: '100%', height: '90vh' }}
	      />
	    </div>
	  );
	}

export default React.memo(Map)


지도 컴포넌트엔 렌더링과 관련된 로직 에러가 있었다. 이벤트 핸들러의 데이터 요청 함수가 실행되고, 리턴으로 받은 fetching 데이터를 바탕으로 지도 위의 마커들을 다시 렌더링 해주어야 했다. 그러나 마커들이 지도 위에 아예 그려지지 않거나, 필터링 결과로 지워져야 할 마커들이 지워지지 않은 채로 남아있었다. 추측컨데 전역 객체를 통해 접근할 수 있는 Kakao Map Api의 지도 객체와 context에 저장된 상태가 업데이트 될 때의 시차가 발생하기 때문으로 이해했다. context가 업데이트 될 때마다 컴포넌트는 계속해서 지도를 다시 렌더링하려고 했기 때문에 이를 방지하면서 동시에 필요한 부분(마커)에서만 리렌더링이 일어나도록 해야했다. 이를 해결하기 위해 useRef와 useEffect를 활용했다. 내가 해결한 로직의 과정은 다음과 같았다.


1 ) Map API에 접근해 설정들이 담긴 지도 객체를 반환하는 함수를 선언한다.

2 ) useEffect를 통해 지도 컴포넌트가 처음 마운트 될 때만 이 함수가 실행되도록 한다.

3 ) useEffect를 이용해 지도의 좌표가 바뀔 때, 해당 지도의 마커(의 묶음인 클러스터)만 모두 지워지고 새롭게 그려지도록 한다.


이를 해결하기 위해 useRef에 카카오맵 API의 지도 객체를 저장하고, 형제 컴포넌트에 지도의 정보를 전달해주기 위해 dispatch로 context state를 저장하도록 하였다. 코드가 이토록 복잡해진 이유는 가상이 아닌 DOM에 직접 접근하여 지도와 마커들을 조작해야 했기 때문이다. 오로지 전역 객체 window를 통해서만 접근할 수 있도록 지도 api가 설계되어 있었기에, 함수로 생성한 지도의 DOM을 useRef에 저장해주어야 했다. 이 지도 객체는 이벤트 리스너를 추가해줄 때, 클러스터가 그려질 지도를 선택할 때 등 핵심적인 기능을 위해 꼭 필요했다.




 RealEstateDispatch({
	          type: 'GET_SELECTED_ESTATE',
	          selected: RealEstate.realEstate.filter(estate => {
	            return cluster
	              .getMarkers()
	              .map(x => x.getPosition())
	              .find(
	                qa =>
	                  estate.lat.toFixed(12) === qa.Ma.toFixed(12) &&
	                  estate.lng.toFixed(12) === qa.La.toFixed(12)
	              );
	          }),

이 부분은 클러스터 클릭 이벤트 핸들러 안에 있는 context dispatch이다. api가 제공하는 메소드 getMarkers를 통해 클러스터에 속한 모든 마커들의 정보를 가져온 후, 각각의 마커들의 좌표 정보들만 map 함수를 통해 객체 배열로 반환한다. 그 후 context에 저장된 경도 위도 좌표 (현재 렌더링된 지도의 범위 좌표) 내에 속하는 매물들을 다시 필터링한다. 지도 위의 클러스터를 클릭했을 때 매물 리스트를 업데이트하기 위해서 필요했다. filter 메소드의 콜백함수 내부에서 api와 find를 사용해 필터링을 구현했다. 당시엔 이 코드가 약간은 복잡하게 느껴졌으나, 두 개의 객체 배열에서 동일한 속성을 갖는 객체를 찾을 때 사용하는데 유용했기에 지금도 기억에 남는다. (특히 매물의 좌표 값이 소수점 19자리까지 있었는데, 백엔드에 저장된 매물들의 좌표와 지도 상의 마커들의 좌표가 소수점 13~7자리에서부터 차이가 있는 매물들이 있었다. 지도 api의 문제였던 것 같은 데, 찾기 어려운 에러여서 상당히 고생했다.)



 if (
  	      Object.entries(tradeTypeFilter).filter(el => el[1] === true).length !== 0
  	    )  {
  	      tradeTypeQuery = Object.entries(tradeTypeFilter)
  	        .filter(el => el[1] === true)
  	        .map(el => el[0])
  	        .toString();
  	    }
  	    fetch(`${BASE_URL}/estates?tradeType=${tradeTypeQuery}`, {
  	      method: 'GET',
  	      headers: {
  	        'Content-type': 'application/json',
  	        LatLng: `${RealEstate.mapBounds.ha},${RealEstate.mapBounds.oa},${RealEstate.mapBounds.qa},${RealEstate.mapBounds.pa}`,
  	      },
  	    })

거래 종류 필터가 하나라도 활성화되어 있을 시, 쿼리로 거래 타입을 요청하는 분기 처리이다. 이 프로젝트에서 복잡한 필터링은 충분히 경험하면서 성장할 수 있었다.



2부로 이어집니다.