부동산 중개 서비스 - 팀 프로젝트 회고록
2022/09/15
n°11
category : Recap
☼
2022/09/15
n°11
category : Recap

리드미에서 모든 데모 영상을 볼 수 있습니다
2주라는 기간이 너무 촉박하게 느껴지는 프로젝트였다. 일정 산정을 잘못해서 그랬는지, 기한에 맞춰 겨우 아슬아슬하게 끝낼 수 있었다. 지금 돌아봤을 땐 무언가 강렬한 것이 휘몰아치고 지나갔던 느낌이지만, 회고 적으면서 보니 몇 가지 괜찮았던 점과 아쉬웠던 점들이 꽤 분명했던 프로젝트였다.
아쉬웠던 점
1. 욕심
너무 많은 기능을 개발하려고 욕심을 냈던 걸까? 리팩토링 기간에도 우리 팀은 나머지 기능들을 개발했어야 했다. 기획 단계에서 불필요하게 쳐낼 기능은 모두 쳐냈어야 했는데, 그러지 못한 점이 아쉽다. 사실 이건 내가 잘못한 부분이 많았다. (욕심이 많아서..)
가장 기억나는 것은 지도 페이지의 필터링이다. 2개의 필터링 모달 중 하나는 프론트에서 필터링을 구현하려고 하였다. 지도에 이벤트가 너무 많고, 핸들러가 모두 비동기 함수였기에 과도한 요청이 있을 것이라 예상했다. 불필요한 데이터 요청을 줄여 성능을 개선하기 위해 클라이언트에서 필터링을 구현했다. 지도 범위 내의 매물이 context에 저장되고, 필터링 이벤트가 발생했을 때 이미 저장된 매물들을 필터링 하는 방식으로 구현했다. 그러나 그건 잘못된 방식이었다! 데이터가 조금만 커지니 성능이 눈에 띌 정도로 악화되었다. 반대로 또다른 모달은 백엔드에 필터링된 결과를 요청하도록 개발했는데, 그 방식이 훨씬 빨랐다. 프론트에서 필터링을 구현하자고 주장했던 터라 참으로 무안하고, 백엔드 분들에게 특히 미안했다. 내가 괜히 욕심을 부려 팀 전체적으로 비용 소모가 생겼기 때문에 후회가 된다.
2. SDK를 사용하지 않았다
카카오맵 api의 공식 문서가 바닐라 자바스크립트로 되어있고, 정식 리액트용 패키지가 없었던 것 같아서 바닐라 자바스크립트로 맵 api를 활용했다. 그러나 이 방식은 너무 많이 전역 객체에 접근해서 리액트를 온전히 활용하지 못했다. 지도 객체가 재렌더링 되지 않도록 신경을 많이 썼는데, 코드가 장황하고 난해해졌다. 개발 중간에 리액트용 카카오 맵 패키지를 보았는데, 마이그레이션 할 엄두가 나지 않아서 하지 못했다. 조금 시간을 들여서 SDK로 바꾸었더라면 생산성, 코드 퀄리티는 훨씬 좋아졌을 것이다.
스스로에게 작은 위로를 남기자면 이 문제를 해결하면서 자바스크립트 런타임, 스코프에 대한 이해가 깊어졌다는 점, 코드가 복잡하니 팀원들에게 충분히 설명하고, 주석에 신경 쓴 점은 괜찮았다.
3. 최적화에 신경 쓰지 못했다.
시간에 쫓겨서 성능 최적화에 힘을 많이 쓰지 못한 점이 아쉽다. 지도 페이지는 이벤트가 많아 리렌더링 이슈에 더 민감하게 신경을 썼어야 했는데, 그러지 못했다. 개발을 시작한지 3개월 정도 되던 때라 어떤 기능을 개발했단 사실 자체에 크게 의미를 뒀었다. 시간이 지나고 나서야 그게 제일 중요한 건 아니라는 걸 알았다. 다른 중요한 것들도 많았다. 특히 성능 이슈는 사용자 경험과 밀접하게 관계가 있어 중요하다. 사용자가 체감할 정도의 버벅임은 심각한 성능 저하 이슈인데, 그걸 온전히 해결해보지 못한 점이 아쉽다. 시간 관리가 부족했고, 무엇보다 나와 우리 팀의 역량이 부족했기에 아쉬움이 남는다.
좋았던 점
1. 훌륭한 팀워크
소통이 원활하고 유쾌했던 것 이상으로, 우리 팀에게 맞는 협업 방식을 찾아갔기에 고무적이다. 협업을 위해 간단한 규칙을 정했는데 꽤 도움이 됐었다. 첫째로 블로커가 생기면 바로 팀원과 공유하고, 함께 그 에러를 해결할 것. 둘째로 어렵고 새로운 기능을 개발할 때는 팀원 모두가 참여할 것. 셋째는 5~15분 사이의 간단한 스탠딩 미팅을 매일 할 것.
물론 매번 지켜지지 못할 때도 있었다. 바빠서 회의를 못할 때도 있었고, 시간이 부족해 빠르게 팀원 혼자 개발한 기능도 있었다. 그럼에도 이 세 가지 규칙의 핵심 가치는 충분히 지켰다. 함께 배우고 공유하는 것. 지도 UI를 개발할 때는 프론트 팀원 모두가 둘러 앉아 공식 문서를 뒤져가며 코드를 설계했다. 지도 UI는 내 담당이었기에 코드는 주로 내가 작성했지만, 팀원 분들이 지도 API는 바로 활용할 수 있도록 작성한 코드를 설명하고, 이해시키려 노력했다. 블로커 이슈가 생기면 모두가 함께 해결하는 것도 마찬가지였다. 지도 UI와 관련된 이슈들이 많아서 나중에는 본인이 작성한 코드가 아니더라도 자기 코드처럼 익숙해질 수 있었다. 개발하면서 이처럼 깊고 원활하게 소통했던 건 처음이었던 것 같다. 이 경험을 통해 '내가 맡은 티켓을 어떻게 잘 처리할까'에서 '팀' 중심으로 생각을 옮길 수 있었던 것 같다. 부족한 인력, 버거운 과제, 촉박한 마감 앞에서 우리 팀의 협업이 중요했기에 깨달을 수 있었다.
2. 팀에 대한 기여
처음 팀 프로젝트를 했을 땐 개발 경력이 있는 팀원이 두분이나 있어서 그 분들 도움을 많이 받았다. 팀을 위해 여러 일들을 솔선수범 하는 모습이 귀감이 됐다. 다음 번 팀 프로젝트 땐 나도 그들처럼 나서야겠다고 다짐했는데, 이번 프로젝트에서 그 다짐을 어느 정도 실천했다. 내가 팀에 기여하기 위해 했던 일들은 다음과 같다.
초기 세팅은 번거로울 수 있어도 간단한 작업이어서 바로 나서서 했다. 공용 컴포넌트로는 지도 페이지 전체 레이아웃을 개발했다. 지도 페이지는 컴포넌트를 나눠서 각자 개발하였다. 내가 담당했던 부분은 맵 페이지의 지도 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, trad...