2차 프로젝트 회고

채림·2022년 8월 14일
0
post-thumbnail

엘리스 2차 프로젝트 끝난 지는 한참 됐는데 이제서야 작성하는 회고... 한달 넘게 너무 달려서 휴식이 필요했다.

프로젝트명 구해줘 댕냥쓰🐶

구해줘댕냥쓰 메인페이지배포된 사이트(현재는 다른 프로젝트를 위해 주소를 사용하고 있습니다)와 Github

개인적으로 너무너무 힘들었던 프로젝트.... 개발이 힘들었던건 아니고 팀장으로써 힘들었다. 별것도 안 해놓고 자꾸 찡찡대는것 같아서 팀원들한테 좀 미안하지만 그 별것도 안 하는게 힘들었다. 학교에서 팀장 할 때는 한 번도 힘들다고 생각한 적 없었는데 이건 내 담당 개발만으로도 힘에 부치는데 팀장 노릇까지 하려니 심적 압박이 어마어마 했음. 내가 제일 어린데 팀장이라니 팀원들이 실망하지 않게 하고 싶었고 직전에 너무 멋진 팀장을 봐서 따라하려다 보니 황새 가랑이 찢어지는 느낌도 있었고. 아무튼 다시는 팀장 안하기로 했음(응 해커톤 다시 팀장~ 사다리는 꼭 돌리는 놈이 걸리더라^^...)

리더의 자질

그래도 팀장 하면서 몇 가지 깨달은게 있다.

  1. 팀원들이 다 나만큼 높은 목표를 가질거라고 생각하고 무작정 밀어붙이면 안된다. 당근과 채찍을 줄 때를 잘 구분해야 한다.
    나는 처음 시작할 때부터 수상에 욕심이 있었고 무조건 "잘" 해야된다는 생각이 있어서 당연히 주말도 반납할 생각을 했다. 그러나 그건 내 생각이었고 4주동안 진행되는 프로젝트에서 주말도 못 쉰다는건 팀원들에게는 너무 큰 리스크였다. 심지어 8시간 5일 근무하는 직장인도 앓는데 우리는 "최소" 오전 10시부터 오후 10시까지 12시간을 달렸으니. 적당히 당근도 주어져야 회복해서 다시 일을 잘 할 수 있다. 사실 저번 프로젝트 때 후반부에 체력 떨어지는거 느껴놓고 첫 주차에 주말 못 쉬게한건 내 잘못.

  2. 팀장이 너무 모든걸 하려고 하면 안된다. 팀장의 역할은 모든걸 짊어지고 떼메는 게 아니라 역할을 분배하는 것
    멋있는 팀장이 되어야 한다는 책임감에 개발을 제외한 모든걸 내가 다 하려고 했다. 회의록 기록, 회의 진행, 이슈나 데드라인 관리, 코치랑 소통, 심지어 api문서까지 거의 모든 문서 작성... 개발 역할 분배는 다른 팀원들이랑 똑같은 양을 받아놓고 거기에 이런 잡다한 일은 더 얹으니 몸이 두개라도 모자른게 당연했다. 이 일들을 적당히 나눠서 다른 팀원들에게 분배할 줄 알았어야 했는데 욕심이 과해서 스스로만 혹사시켰다.
    근데 이건 아직도 잘 모르겠다. 팀장의 역할은 구성원들이 자잘한 일에 신경쓰지 않고 개발에 집중할 수 있는 환경을 만들어주는 거라고 생각하는데, 그렇다고 내가 일정관리, 문서정리, 진도체크 및 독려를 다 하면서 개발을 하나도 안 할수는 없지 않은가? 적당히 조율해야겠지만 아직은 모르겠음..

  3. 남의 코드를 맘대로 뜯어고칠 권한이 있는건 아니다
    사실 이건 좀 예의에 어긋나는 문제였는데 내가 전반적인 코드를 관리하고 돕다 보니 내 눈에 부족한 게 보이면 자꾸 건드렸다. 그 코드 짠 팀원도 나름 그렇게 한 이유가 있을 것이고 내가 맘대로 고치면 마음이 상할 수도 있는 건데 생각을 못 했다. 비개발 팀 프로젝트에서는 피피티같은거 다시 수정해라 어째라 깐깐하게 지시하는 것보다 마음에 안 드는 사람이 고치는 게 맞다고 생각했어서 그 습관이 그대로 남아있었다. 차라리 수정해야 할 부분이 있으면 코드리뷰를 해서 본인이 고치도록 하거나 합당한 이유가 있으면 나뒀어야 했다.

  4. 민주주의를 맹신하지 말자
    팀원들의 의견을 받을 때와 카리스마있게 방향을 제시하고 이끌 때를 잘 구분할 줄 알아야 하는 것 같다. 원래도 주장이 강한 편이라 독단적인 의사결정이 될까봐 너무 모든 것을 다수결로 가려고 했더니 사소한 것 하나하나까지 여섯명의 투표를 받고 망설여졌다. 누군가가 확실한 결정을 내려주어야 다음 방향이 진행될 때에도 우유부단한 상태로 우왕좌왕하는 모습이 스스로 부족하다고 느껴졌던 적이 있다. 민주주의라는건 얼핏 들으면 좋아 보이지만 어떻게 보면 다수라는 이유 만으로 합리적인 근거 없이 좋아보이는 것만 선택하는 경향이 있다. 또, 비등비등한 비율로 나눠지면 다수결로 선택하기에도 적절하지 않은 상황이 된다. 무작정 투표해서 다수결에 따를 것이 아니라 왜 이 쪽이 옳다고 생각하는지 서로에게 설명하고 설득하는 과정이 필요해 보인다.

수상 소감에서 팀원들 너무 몰아붙인 것 같아서 미안하다고 하긴 했지만 나도 힘들고 팀원들도 힘든 시간들이었다.. 그래도 빡세게 해서 우수상 받았으니 약간 보상받은 기분이다.




기술적 어려움

이번엔 로직이 복잡할 것도 없고 리액트를 써서 그런지 javascript와 다르게 성능에 대해 고민할 게 크게 없어서 기술적으로도 크게 어렵진 않았다. 다만 리액트를 처음 써봤더니 렌더링이 마음대로 돼서 리렌더링 조건 파악하는게 조금 걸렸다..

1. 리액트에서 이미지 로딩은 <img> 태그로!

getPhoto 함수

RescueList 컴포넌트에서 getPhoto함수로 이미지 url을 fetch하려고 했더니 CORS 에러가 났다.

localhost/:1 Access to fetch at 'http://www.animal.go.kr/files/shelter/2022/04/202207080907808_s.jpg' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

분명 1차 프로젝트때도 봤던 에러라 CORS 정책 공부를 다시 하고 백엔드 팀원에게 Access-Control-Allow-Origin 설정을 요청했는데 다른 팀원에게 이미지를 왜 fetch로 가져오냐는 질문을 받았다. 리액트에서 데이터를 가져오는 방법은 fetch밖에 생각하지 못해서 그런거였는데 img태그에 src로 받아오면 되는거였다.

이제 선택지가 두개가 되었으니 어떤걸로 받아야 하나 고민하다가 코치님께 질문해서 다음과 같은 답변을 받았다.

1) fetch는 이미지 스트림을 받는 거라서 비효율적이고 cors도 신경써야 하니까 굳이?라는 생각이 든다.
2) img태그는 이미지를 받아오는 게 아니라 url를 그대로 박아넣는 역할이라 fetch보다 빠른건 맞다.
3) 일반적으로는 img로 처리하니까 img로 하는게 맞을 듯 하다.

최종적으로는 img태그로 잘 작동하게 되었다. 역시 머리를 모으면 문제가 해결된다.


2. 문제의 무한 리렌더링

리렌더링 일으킨 함수

보호소 데이터를 가져오는 getRescue함수를 만들어 useEffect에서 호출했는데 무한렌더링이 발생했다. 도대체 바뀌는 것도 없는데 왜 렌더링을 다시 시도하는건지???
사실 전에 수업 실습할 때에도 비슷한 문제가 있었던 적이 있다. 그 때는 setStateuseEffect내에서 사용하지 않았더니 계속 리렌더링 됐던 문제였다. 그 때 이 글을 코치님이 전달해주셨었는데 이해가 되지 않아 넘어갔었다. useState가 비동기적으로 동작하기 때문에 컴포넌트 렌더링이 완료되기전에 상태가 변해서 다시 렌더링을 시도하는게 반복되는거라고만 이해했다. 이 글을 시간이 지나고 다시 보이 이해가 됐다. 여기에서는 함수형 컴포넌트 내에 setState 코드를 작성하면 무한 렌더링 현상이 발생하기 때문이라고 나오는데 이건 사실 아직도 잘 모르겠다. 나중에 다시 보면 또 깨닫게 되겠지!


라우팅 처리를 하다가 여러 방식의 차이가 궁금해졌다. 내가 아는 것만 4개인데 이 중에 무엇을 써야 하는가?

  1. window.location.replace는 바닐라js이다. 물론 리액트에서도 쓸 수야 있지만 굳이 window객체를 불러내는게 좋은 방법은 아니다. window객체의 location 속성값을 바꿔주면 브라우저가 어? 내 위치가 여기네? 하고 페이지를 이동시켜 주는 방식.
  2. <a>는 html의 방식이다. <Link>와 다른 점은 <Link>는 해당 사이트 내에서 이동할 때, <a>는 외부 사이트로 이동할 때 사용한다는 것.
  3. window.location<a>를 제외한 3개는 리액트 라우터에서 사용할 수 있는 방법들이다.
    <Link>는 클릭 하면 <a>태그로 바뀌어 바로 이동시켜 준다.
    useNavigate는 hook을 실행할 때 페이지 이동 함수를 실행하는 것이라서 조건을 걸어 이동하게 해준다. 로그아웃이 되면 메인페이지로 이동하게 한다던가 하는 것들.

역시 나만 헷갈리는건 아니었는지 잘 정리된 글이 많았다.


4. 버튼을 두 번 눌러야 state가 반영되는 현상

메인페이지의 구조 목록 컴포넌트하고 지도 컴포넌트가 토글되도록 버튼을 만들었는데, 버튼을 두 번 눌러야만 페이지가 전환되는 문제가 있었다.
토글 버튼

구조 목록에서 지도로 이동하는 버튼을 눌렀을 때 페이지 전환은 되지만 버튼의 텍스트는 ‘지도 보기’를 유지하다가 다시 한 번 버튼을 눌러야 ‘리스트 보기’로 전환됐다. 그리고 한 번 더 버튼을 누르면 리스트 화면으로 전환되는 동시에 ‘지도 보기’로 버튼이 바뀌었다. 콘솔을 찍어 봐도 두 번 만에 state가 바뀌어 찍혔다.

Main 컴포넌트와 MapView 컴포넌트에 이 코드가 각각 들어가 있었는데, 이게 원인이었다.

  const [toggleList, setToggleList] = useState(true);
  const navigate = useNavigate();

  return (
      <button
        type="button"
        style={{ height: '40px' }}
        onClick={() => {
          if (toggleList) {
            navigate('/lostMap');
          } else {
            navigate('/');
          }
          setToggleList((toggle) => !toggle);
        }}
      >
        {toggleList ? '지도 보기' : '리스트 보기'}
      </button>
  );
}

같은 내용의 코드가 반복되는 것도 문제지만, 토글 상태를 나타내는 toggleList state가 두 번 선언돼서 서로 다른 변수로 동작하는게 버튼이 오작동하는 원인이었다.
1) 각각의 toggleList는 이름만 같은 별개의 변수로 동작해서 구조 목록에서 버튼을 누르면 Main이 unmount되고 MapView가 mount되면서 새로운 toggleList 변수가 선언된다.
2) MapView에서 다시 버튼을 눌렀을 때는 기존의 컴포넌트 그대로 unmount되지 않은 상태로 버튼이 동작한다. 페이지 전환 시 MapView가 mount되면서 toggleListtrue로 정의되었기 때문에, navigate(’/lostMap’)는 화면 전환을 일으키지 않은 채 setToggleList에 의해 버튼 내용만 ‘리스트 보기’가 되었던 것이다.

이 로직을 별도의 컴포넌트로 분리하고 useLocation hook을 이용해서 현재 위치에 따라 페이지를 이동하도록 했더니 해결되었다.

export default function Map2ListToggle() {
  const navigate = useNavigate();
  const location = useLocation();
  const toggleState = location.pathname;

  return (
    <button
      type="button"
      className="btn-orange w-32"
      onClick={() => {
        if (toggleState === '/') {
          navigate('/lostMap');
        } else {
          navigate('/');
        }
      }}
    >
      {toggleState === '/' ? '지도 보기' : '리스트 보기'}
    </button>
  );
}



5. 컴포넌트 간에 state 공유하기

구조 목록 페이지를 구현할 때 컴포넌트끼리 state를 공유해야 했다.

메인페이지 구조

이렇게 Main 컴포넌트 내부에 FilterBarRescueList가 있고 FilterBar 내부에 Filter 컴포넌트가 있는 상황이었다. 체크박스 버튼으로 필터링 기능을 구현하려고 했더니 FilterRescueList가 구조 목록 데이터인 rescueList를 공유해야 했다.

첫 번째로 찾은 해결법은 state 끌어올리기였다. FilterRescueList의 가까운 공통 조상인 Main으로 rescueList state를 끌어올리려고 했는데 실패했다. 문제는 두 가지였다.

  1. 단순 렌더를 위한 Main 컴포넌트에 데이터를 fetch하는 코드가 들어가게 됐다. Main은 메인 페이지에 들어가는 여러 컴포넌트들을 모아서 한 번에 렌더하기 위한 페이지 컴포넌트라고 생각했는데 Main에서 백엔드와 통신하는 코드를 넣자니 찜찜했다.
  2. prop drilling이 발생했다. 물론 심하진 않았지만, FilterBar 컴포넌트는 처음 fetch된 데이터 rescueList랑 필터링 된 데이터 showList를 사용하지 않음에도 단순히 전달만을 위해 prop으로 받아야 했다.

두 번째 방법은 custom Hook 제작이었습니다. fetch하는 함수를 컴포넌트 내부 함수로 사용하지 않고 useFetch custom hook을 만들어서 필요한 컴포넌트에서 사용하려고 했다.

useFetch hook

근데 여기에도 문제가 있었다.
첫 호출 때 빈 객체 한 번, 처음 setLoading 호출될 때 한 번, 그리고 fetch 후 setData 때 한 번 해서 총 세 번의 return이 일어났다. 빈 값을 받으니 그 뒤의 코드에서 data.image같이 속성을 호출할 때 undefined 에러가 나서 다음 단계로 넘어가지지가 않았다.

세 번째 방법은 context API 사용이었다.
redux 같은 상태 관리 라이브러리를 사용할 수도 있지만 익숙하지도 않을 뿐더러 보일러플레이트 코드가 어마어마하기 때문에 context API를 사용하는게 낫다고 생각했다. (나중에 알았지만 recoil 같이 보일러플레이트 코드가 거의 없다시피 한 라이브러리도 있었다..)
여기에서도 useFetch랑 동일하게 첫 번째 return의 결과가 rescueList에 할당돼서 공유되는 문제가 있었다. 그리고 context API는 FilterBar, Filter, RescueList 중 하나의 컴포넌트 내부에서만 state가 바뀌어도 세 컴포넌트 모두 리렌더링되는 결과가 있어서 마음에 들지 않았다.

따라서 최종적으로는 하위 컴포넌트의 props로 전달하게 됐다. 역시 구관이 명관이라고, 기본을 무시하면 안 된다.
여기서도 무한 렌더링 문제가 있어서 찾다가 useCallback hook을 쓰게 됐다. 리스트 화면을 그리는 RescueList(코드에서는 RenderList라고 나와있다)를 호출하며 데이터를 props로 전달받아 렌더링하는 방식을 이용해서 해결했다.


profile
나는 말하는 감자... 감자 나부랭이....

0개의 댓글