프로젝트 회고 : Mesic - part 2

정관우·2021년 8월 31일
1
post-thumbnail

Project : Mesic

Mesic 바로가기 🏁
https://realmesic.space/


Github 레포 🏠
https://github.com/codestates/Mesic-client

Intro

약 한달 간의 프로젝트 기간동안, 우리 팀이 정한 Requirements는 거의 달성하였고 내가 얼추 예상했던대로 앱이 완성되었다. 다만, 몇 가지 아쉬운 점들이 있었다.

일단, 첫 번째로 페이지 새로고침 시 로그인 유지가 되지 않아 이용 시 많이 불편했다. 두 번째로, 팔로우의 핀을 보려고 팔로우를 클릭했을 때 지도의 범위가 자동으로 바뀌지 않아 유저 스스로 지도를 축소하거나 확대해야하는 불편함이 있었다. 초반에 미쳐 생각하지 못한 부분이라서 프로젝트 마감이 임박했을 때, 기한을 마출 수 없을 것 같아 포기했었다.

기능 외적으로도, 코드가 너무 지저분했다. 특히, 모달 창 부분은 한 컴포넌트에서 조건에 따라 렌더링되는 HTML 엘리먼트가 너무 다양했다. 삼항 연산자를 활용했는데, 삼항 연산자가 계속 중첩되니까 거의 읽기 불가능한 수준에 이르렀다. 그리고, TypeScript를 속성으로 공부하고 사용해서 일일이 문법을 찾고 적용시키기에 시간이 너무 촉박했다. 그래서, TypeScript를 제대로 활용하지 못하고 에러를 빨리 수정할 수 없는 부분은 타입을 any로 두고 넘어가는 경우가 종종 있었다. 프로젝트가 끝나고 이런 미숙했던 부분들을 고쳐서 기능 추가와 더불어 좀 더 보기 좋은 깔끔한 코드를 만들고 싶었다.

Refactoring

Redux-Persist

먼저 새로고침 후에도 로그인이 유지가 될 수 있도록, Redux-Persist라는 라이브러리를 받아 적용시켰다. Redux-Persist는 Redux의 store가 캐시 기능을 할 수 있도록 만들어 준다. 새로고침으로 Redux의 store가 초기화되면 로컬스토리지나 세션스토리지에 저장된 store 데이터가 있는지 확인한 후, 만약 있다면 다시 불러오는 action을 실행(rehydrate)하여 store를 유지시킬 수 있다.

다음과 같은 방법으로 Redux-Persist를 적용시킬 수 있었다.

Redux-Persist를 적용하기 위해선, store를 만들 때, createStore와 함께 persistStore라는 함수로 지속되는 store (= persistor)를 만들어주어야 한다.

// store.tsx
...
import rootReducer from "../reducers/index";
import { persistStore } from "redux-persist";

const store = createStore(
  rootReducer,
  ... 
);

// * store와 함께 persistor를 만들어 줌
const persistor = persistStore(store);

// 둘 다 export
export { store, persistor };

rootReducer를 만들 때, reducer를 어디에 저장시킬 것인지 설정을 해주어야 한다. 로컬스토리지에 저장시키기 위해서는 storage를, 세션스토리지에 저장시키려면 sessionStorage를 import한다 (패키지 경로는 공식문서에서 확인). rootReducer는 persistReducer라는 함수를 거쳐 지속될 수 있게 만들어줄 수 있다. 그러기 위해선 persist에 옵션을 주는 객체(persistConfig)를 만들어주어야 한다. 여기서 넣어할 키 값 3가지가 있다.

  1. key → 어느 시점부터 reducer를 저장시킬 것인지
  2. storage → 저장할 장소
  3. whitelist → 저장할 reducer들의 목록 (배열)

나의 경우, localStarge에 저장하였고 유저 정보를 저장하기 위해 userReducer를 whitelist에 설정해주었다.

// index.tsx
...
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";

const persistConfig = {
  key: "root", // root에서부터 저장
  storage, // storage = localStorage
  whitelist: ["userReducer"], // 지속시킬 reducer
};

const rootReducer = combineReducers({
  ... ,
  userReducer
});

// rootReducer는 persistConfig에 따라서 지속됨
export default persistReducer(persistConfig, rootReducer);
...

이제 앱의 최상위 컴포넌트에서, persistGate라는 컴포넌트를 이용하여 React에서 방금 만든 persistReducer를 사용할 수 있게끔 전달해주는 작업이 필요하다. 이 작업을 거치면, 브라우저가 새로고침 되어 앱이 처음부터 렌더링 되더라도 PersistGate에서 persistor를 내려주어 store가 다시 복구(rehydrate)되는 것을 확인할 수 있다.

// index.ts
...
import { store, persistor } from "./store/store";
import { PersistGate } from "redux-persist/es/integration/react";

ReactDOM.render(
  <Provider store={store}>
    <PersistGate persistor={persistor}>
      <App />
    </PersistGate>
  </Provider>,
);


팔로우 체크 시 지도 범위 재설정

리팩토링 전, 지도 범위가 팔로우 핀에 따라서 자동으로 맞춰지지 않아서 모든 핀들을 확인하려면 아예 지도를 작게 축소시키거나, 반대로 자세히 보기 위해선 확대해야 하는 불편함이 있었다.

리팩토링 이후, 생성된 핀들의 위치에 따라서 지도의 범위가 동적으로 바껴 좀 더 사용성이 좋아졌다.

핀의 위치에 따라 지도 범위를 동적으로 변경하려면, 카카오맵 API의 setBounds를 사용해야 했다. setBounds 함수를 실행하기 위해서는 좌표들의 위치정보를 배열에 담아 LatLngBounds라는 객체에 좌표를 모두 추가한 다음 setBounds의 argument로 넘겨주면 지도가 좌표가 모두 보이도록 동적으로 변하는 것을 확인할 수 있다.
카카오맵 API - 지도 범위 재설정 하기

//** 카카오맵 API 공식문서에 나온 setBounds 사용 예시 **
...

// 아래 배열의 좌표들이 모두 보이게 지도 범위를 재설정
var points = [
    ...,
    new kakao.maps.LatLng(33.451744, 126.572441)
];

// 지도를 재설정할 범위정보를 가지고 있을 LatLngBounds 객체를 생성
var bounds = new kakao.maps.LatLngBounds();    

var i, marker;
for (i = 0; i < points.length; i++) {
    // 배열의 좌표들이 잘 보이게 마커를 지도에 추가
    marker =     new kakao.maps.Marker({ position : points[i] });
    marker.setMap(map);
    
    // LatLngBounds 객체에 좌표를 추가
    bounds.extend(points[i]);
}

function setBounds() {
    // LatLngBounds 객체에 추가된 좌표들을 기준으로 지도의 범위를 재설정
    map.setBounds(bounds);
}

예시에서는 좌표들의 위치 (위도, 경도)가 하드코딩 되어있는데, 실제 프로젝트에서는 이렇게 적용할 수 없었다. 처음에 핀을 생성했을 때, 핀 객체 안에 좌표 정보가 들어있을 줄 알았는데... 아무리 찾아봐도 없었다. 그래서, 직접 핀 객체를 생성할 때 위치 정보를 직접 할당하였다.

핀 객체는 내 핀 그리고 팔로우의 핀 (myMarkers, followMarkers) 두 가지 상태(배열)로 관리를 하고 있다. 이 두 가지 상태에서 모든 핀 객체를 가져와 그 안에 있는 위치 정보만 필터링하여 하나의 배열로 합쳐주었다. 이 좌표 정보들로 LatLngBounds 객체를 생성하여 좌표들을 기준으로 지도의 범위를 재설정해주었다.

함수가 호출되는 시점은 useEffect를 이용하여, myMarkers 또는 followMarkers의 상태가 바뀔 때 마다 setBounds 함수가 호출되도록 만들었다.

// MainPage.tsx
...
useEffect(() => {
    if (myMarkers.length > 0 || followMarkers.length > 0) {
      setMapBounds();
    }
  }, [myMarkers, followMarkers]);

const setMapBounds = () => {
    let myMarkersPos: markerPos[] = [];
    let followMarkersPos: markerPos[] = [];
  	
    // 내 핀들의 위치정보
    if (myMarkers.length > 0) {
      myMarkersPos = myMarkers.map((each: Marker) => each.pos); // pos = 직접 할당한 위치정보
    }

    // 팔로우 핀들의 위치정보
    if (followMarkers.length > 0) {
      for (let i = 0; i < followMarkers.length; i += 1) {
        followMarkersPos = [...followMarkersPos, ...followMarkers[i].map((each: Marker) => each.pos)];
      }
    }
  
    // 위치 정보를 하나의 배열로 합침
    const position = [...myMarkersPos, ...followMarkersPos];
  	
    // 배열의 좌표들이 모두 보이게 지도 범위를 재설정
    const points = position.map(
      (each) => new window.kakao.maps.LatLng(each.Ma, each.La)
    );
  
    // 아래 과정은 위와 동일
    ... 
  };


DetailModal 컴포넌트 코드 수정

음악, 사진, 메모 게시물의 CRUD는 모두 모달 창에서 이루어진다. 그만큼 DetailModal ( PostModal / ReadModal )라는 컴포넌트는 여러 기능을 가지고 있고 내부가 복잡하다. 때문에 DetailModal은 프로젝트가 진행되는 동안 가장 많이 바뀐 컴포넌트였는데, 대략 이런 과정을 거치면서 지금의 모습을 갖추게 되었다.


[ version1 ]
이렇게 복잡한 컴포넌트를 만들어본 적이 없어서 처음에는 단순하게 DetailModal 아래 세 가지 컴포넌트 (Music, Photo, Memo)로 나눴다. 하지만, 하나의 컴포넌트 안에서, CRUD를 모두 구현하려고 하니 코드가 걷잡을 수 없이 길어지기 시작했다. 조건에 따라 다른 기능을 수행하기 위해, 다른 UI를 렌더링시켜야 했는데 그 조건들이 아래와 같이 너무 많았다.

1. 게시물을 조회
A. 나의 게시물을 조회
ㅤa. 음악
ㅤㅤi. 내용이 있을  때, 조회
ㅤㅤii. 내용이 없을 때, 조회
ㅤㅤii. 내용을 수정
ㅤb. 사진
ㅤㅤi. 내용이 있을  때, 조회
ㅤㅤii. 내용이 없을 때, 조회
ㅤㅤii. 내용을 수정
ㅤ... ( 메모 생략 )
ㅤ
B. 팔로우의 게시물을 조회
ㅤa. 음악
ㅤㅤi. 내용이 있을  때, 조회
ㅤㅤii. 내용이 없을 때, 조회
ㅤ... ( 생략 )

2. 게시물을 작성

[ version 2 ]
DetailModal을 더 세부적으로 나누어 줄 필요성을 느끼게 되었다. DetailModal의 하위 컴포넌트를 용도에 맞게 ReadOOO, PostOOO... 이런식으로 나누어 주었다. 확실히, 코드가 짧아지면서 읽기가 좀 더 수월해졌다. 렌더링 되는 HTML 코드는 짧아졌지만 DetailModal에서 Read와 Post에 필요한 state와 메서드가 모두 모여있어서 좀 더 세분화 시킬 필요가 있었다.

[ version 3 ]
DetailModal 자체를 ReadModal과 PostModal로 나누어 주었다. 핀 내용을 조회하기 위해 핀을 클릭했을 때는 ReadModal을, 핀을 생성하기 위해 지도를 클릭했을 때는 PostModal이 열리도록 코드를 수정했다. 조회와 생성에 관련된 코드들이 완전 분리되니 코드의 가독성이 훨씬 좋아진 것을 느낄 수 있었다.

먼저 이렇게 대략적인 와이어 프레임만 잡아놓고 기능을 모두 구현해놓았다. 그런 다음, 레이아웃을 수정하고 CSS 작업을 하는 방식으로 프로젝트를 진행하였데 이 과정에서 코드가 또 엄청나게 길어졌다. 위에서 언급한 듯이, 컴포넌트 내부에서 여러 조건에 따라 다른 UI를 렌더링 시키기 위해 삼항연산자를 중첩해서 사용했다. 처음에는 기능을 구현할 수 있는 최소한의 뼈대만 만든 상황이라 코드가 그렇게까지 복잡해 보이진 않았는데, 여기에 살을 붙이기 시작하니 코드가 도저히 읽을 수 없는 수준에 이르렀다...

// 수정 전 ReadModal/ReadMusic.tsx

function ReadMusic({ readMusic, setReadMusic, markerId, setPinUpdate }: ReadMusicProps) {
 ...
  return (
    <>
      ... 
      // 삼항연산자 지옥의 시작...
      <div className="music">
        {updateMode ? (
          <>
            <div className="update-mode-post-icon">
              <i className="fa fa-headphones fa-lg" aria-hidden="true"></i>
            </div>
            <div className="widget-outsider">
              <img className="thumbnail-cd" src={updateMusic.thumbnail}></img>
              <div className="title-cd-hidden">
                <div className="title-cd">{updateMusic.title}</div>
              </div>
              <iframe
                src={
                  updateMusic.video_Id
                    ? `https://www.youtube.com/embed/${updateMusic.video_Id}?modestbranding=1&enablejsapi=1&autoplay=0&loop=1&playlist=${updateMusic.video_Id}
                      `
                    : "https://www.youtube.com/embed/"
                }
                id="ytplayer"
                frameBorder="0"
                allow="autoplay"
              ></iframe>
              <div
                onClick={() => {
                  if (!isPlay) {
                    setIsPlay(true);
                  } else {
                    setIsPlay(false);
                  }
                }}
              >
                {isPlay ? (
                  <img className="play-pause" src={pauseImg} />
                ) : (
                  <img className="play-pause" src={playImg} />
                )}
              </div>
            </div>
            <div className="save-cancel-btn">
              <button onClick={updateReadMusic}>저장</button>
              <button
                onClick={() => {
                  setUpdateMode(false);
                  setUpdateMusic({
                    video_Id: "",
                    title: "",
                    thumbnail: "",
                  });
                }}
              >
                취소
              </button>
            </div>
          </>
        ) : (
          <div>
            {isLogin && mode !== "WATCH" ? (
              readMusic.video_Id.length > 0 ? (
                <>
                  <div className="edit-del-btn">
                    <i
                      className="fa fa-headphones fa-lg"
                      aria-hidden="true"
                    ></i>
                    <div>
                      <i
                        className="fas fa-pencil-alt"
                        aria-hidden="true"
                        onClick={() => setOpenEditMusic(true)}
                      ></i>
                      <i
                        className="fa fa-trash"
                        aria-hidden="true"
                        onClick={() => setOpenConfirm(true)}
                      ></i>
                    </div>
                  </div>
                  <div className="widget-outsider">
                    <img
                      className="thumbnail-cd"
                      src={readMusic.thumbnail}
                    ></img>
                    <div className="title-cd-hidden">
                      <div className="title-cd">{readMusic.title}</div>
                    </div>
                    <iframe
                      src={
                        readMusic.video_Id
                          ? `https://www.youtube.com/embed/${readMusic.video_Id}?modestbranding=1&enablejsapi=1&autoplay=1&loop=1&playlist=${readMusic.video_Id}
                          `
                          : "https://www.youtube.com/embed/"
                      }
                      id="ytplayer"
                      frameBorder="0"
                      allow="autoplay"
                    ></iframe>
                    <div>
                      {isPlay ? (
                        <img
                          className="play-pause"
                          src={pauseImg}
                          onClick={() => setIsPlay(false)}
                        />
                      ) : (
                        <img
                          className="play-pause"
                          src={playImg}
                          onClick={() => setIsPlay(true)}
                        />
                      )}
                    </div>
                  </div>
                </>
              ) : (
                <>
                  <div className="post-icon">
                    <i
                      className="fa fa-headphones fa-lg"
                      aria-hidden="true"
                    ></i>
                  </div>
                  <div className="add-btn-container">
                    <button
                      className="add-btn-music"
                      onClick={() => setOpenEditMusic(true)}
                    >
                      +
                    </button>
                  </div>
                </>
              )
            ) : readMusic.video_Id.length > 0 ? (
              <>
                <div className="post-icon">
                  <i className="fa fa-headphones fa-lg" aria-hidden="true"></i>
                </div>
                <div className="widget-outsider">
                  <img className="thumbnail-cd" src={readMusic.thumbnail}></img>
                  <div className="title-cd-hidden">
                    <div className="title-cd">{readMusic.title}</div>
                  </div>
                  <iframe
                    src={
                      readMusic.video_Id
                        ? `https://www.youtube.com/embed/${readMusic.video_Id}?modestbranding=1&enablejsapi=1&autoplay=1&loop=1&playlist=${readMusic.video_Id}
                        `
                        : "https://www.youtube.com/embed/"
                    }
                    id="ytplayer"
                    frameBorder="0"
                    allow="autoplay"
                  ></iframe>
                  <div
                    onClick={() => {
                      if (!isPlay) {
                        setIsPlay(true);
                      } else {
                        setIsPlay(false);
                      }
                    }}
                  >
                    {isPlay ? (
                      <img className="play-pause" src={pauseImg} />
                    ) : (
                      <img className="play-pause" src={playImg} />
                    )}
                  </div>
                </div>
              </>
            ) : (
              <>
                <div className="edit-del-btn">
                  <i className="fa fa-headphones fa-lg" aria-hidden="true"></i>
                </div>
                <div className="follow-widget-outsider">
                  <div className="no-music">음악이 없습니다.</div>
                </div>
              </>
            )}
          </div>
        )}
      </div>
    </>
  );
}

[ version 4 ]
이 장황한 코드를 다시 보니, 중복된 코드가 굉장히 많이 존재하는 것을 발견했다. 완전히 똑같지는 않지만, 매우 비슷한 형태를 띄고 있었다. 이 비슷한 형태의 UI를 작은 컴포넌트 단위로 분리하여 하나의 부품처럼 사용하면 훨씬 더 깔끔한 코드가 완성될 것이라고 생각했다. 그래서, 이 컴포넌트들을 Modules라는 폴더 안에 만들고 전달 받는 props에 따라 약간 변화를 주어 컴포넌트를 적극적으로 재활용하였다.

i. 내용이 있을 때, 조회하는 UI → Music, Photo, Memo
ii. 내용이 없을 때, 조회하는 UI → NoMusic, NoPhoto, NoMemo
iii. 내용을 수정하는 UI → UpdateMusic, UpdatePhoto, UpdateMemo


그 결과 아래와 같이, 코드가 매우 간결해지고 그 덕분에 유지보수가 훨씬 수월해졌다.
function ReadMusic({
  readMusic,
  setReadMusic,
  markerId,
  setPinUpdate,
}: ReadMusicProps) {
  ...
  return (
    <>
      ...
      <div className="music">
        {updateMode && (
          <UpdateMusic
            updateMusic={updateMusic}
            isPlay={isPlay}
            setIsPlay={setIsPlay}
            setUpdateMode={setUpdateMode}
            setUpdateMusic={setUpdateMusic}
            updateReadMusic={updateReadMusic}
          />
        )}
        {updateMode || (
          <div>
            {readMusic.video_Id.length > 0 ? (
              <Music
                setOpenEditMusic={setOpenEditMusic}
                setOpenConfirm={setOpenConfirm}
                musicData={readMusic}
                isPlay={isPlay}
                setIsPlay={setIsPlay}
              />
            ) : (
              <NoMusic setOpenEditMusic={setOpenEditMusic} />
            )}
          </div>
        )}
      </div>
    </>
  );
}

TypeScript 수정

TypeScript가 컴파일 전에 미리 Type 에러를 발생시켜 에러를 사전에 방지하는 데는 효과적이었다. 하지만, 매우 문법을 철저하게 검사해서 혹시라도 에러가 날 여지가 조금이라도 있다면 에러를 발생시켰다. 때문에, TypeScript에 미숙한 내가 시간이 촉박한 프로젝트에서 Type을 일일이 찾고 사용법을 익혀가며 사용하기에는 많이 벅찼다. 그래서, 일단 빠르게 해결되지 않는 에러가 있다면 Type을 any로 두고 넘어가곤 했다.

프로젝트 기간이 끝나고 나서, 이 부분이 매우 찝찝하게 느껴졌다. TypeScript를 분명 사용했는데 절반도 활용하지 못한 기분이었다. 그래서, TypeScript를 다시 공부해가며 이 부분들을 다시 고쳐보기로 했다.

서버나 외부 API에서 받은 응답은 객체에 많은 데이터 타입을 내포하기 때문에 컴포넌트 안에서 타입을 지정해주면 코드가 너무 장황해졌다. 그래서, 타입만 따로 지정해주는 파일 ( OOO.d.ts )들을 새로 만들어 state와 props들의 타입을 선언해주고 컴포넌트 파일에서 import한 후 타입을 지정해주었다.

이런 복잡한 객체들의 타입을 지정해주니, 이 객체 내부가 어떻게 생겼는지 바로 알 수 있었다. 프로젝트를 진행하면서 객체에 어떤 키값이 있는지, 또 어떤 타입인지 몰라서 일일이 console을 찍어본 기억이 있는데...처음부터 타입을 지정해주었더라면 훨씬 더 수월하게 프로젝트를 진행할 수 있지 않았을까라는 생각을 했다.

// state-types.d.ts
...
export type markerData = {
  location: {
    latitude: string;
    longitude: string;
  };
  memo: string;
  music: {
    thumbnail: string;
    title: string;
    video_Id: string;
  };
  photo: string;
  user_id: string;
  _id: string;
  __v?: number;
} | null;

export type followerData = {
  email: string;
  follow: string[];
  marker: string;
  name: string;
  nickname: string;
  password: string;
  profile: string;
  refreshToken: string;
  __v?: number;
  _id: string;
};
...

Conclusion

한 달 간의 기간이 어떻게 흘러간지도 모를 정도로 시간이 너무 빨리 지나갔다. 사실, 6월 쯤에 끝난 프로젝트인데 프로젝트가 끝난 후에 공부하고 작은 프로젝트도 하고 리팩토링하느라 이제서야 회고록을 쓰게 되었다. 이렇게 큰 프로젝트는 처음이라 프로젝트 기간이 끝나고 아쉬운 점이 많았다. 항상 리팩토링을 생각만 하고 있었는데, 부족한 기능을 추가하고 지저분한 코드를 정리하니 마음이 뿌듯하고 내 실력도 한층 성장한 계기가 된 것 같다.

이번 프로젝트를 마치면서, 내가 겪었던 일들을 글로서 정리하는 것이 얼마나 좋은 습관인지 깨닫게 되었다. 이번 프로젝트를 진행하면서, 최대한 매일 한 일을 정리하려고 애썼다. (프로젝트 기간동안 썼던 Dev-Log ) 매일 기록으로 남기기 전에는 무엇을 어떻게 했는지 잘 기억에 남지 않고 그냥 어렴풋이 어떤 일을 했던 느낌만 남았었다.

그런데, 내가 한 일들을 논리적으로 글로서 정리하니까 도움이 많이 됐다. 내가 어떤 사고를 했는지, 왜 이런 코드를 썼는지를 알 수 있었고 이를 통해서 내가 부족한 점과 배운 점 그리고 앞으로 내가 노력해야 할 방향을 알 수 있었다. 하지만, 코드를 치는 것보다 글을 논리적으로 정리하는 것이 더 힘들게 느껴진다... 그래도, 힘든 만큼 도움이 많이 될 것은 확실한 것 같다.

또, 느낀 점은 설계 단계가 얼마나 중요한지 뼈저리게 느꼈다. 지금 생각하면, 그 때 당시에는 '대충 이렇게 하면 되겠지'라는 생각으로 만들기 시작했던 것 같다. 왜냐하면, 이미 최종 앱 디자인을 구체적으로 그려놓았고 어떻게 작동해야 하는지 알고 있으니까 그냥 만들면 되는 줄 알았던 것 같다. 하지만, 준비가 안 된 상태로 만들기 시작하니 DetailModal 구조를 3번이나 갈아엎었던 것처럼 엄청난 시간과 체력이 허비되는 것을 경험했다.

시간이 오래 걸리더라도 최대한 완벽하게 설계를 마친 다음 코드를 써야겠다고 생각했다. 사실, 설계를 다 해놓은 다음 코드를 치는 시간은 그렇게 오래 걸리진 않았다. 그렇게 하지 않는 것이 더 비효율적이다. 코드를 치면서 어떻게 만들어야 되는지 생각하면 시간이 훨씬 더 오래 걸릴 뿐더러 결과도 좋지 못해서 또 여러 번 수정을 거쳐야하기 때문이다. '처음부터 설계를 잘하자'가 이번 프로젝트에서 얻은 가장 큰 교훈인 것 같다.

profile
작지만 꾸준하게 성장하는 개발자🌳

0개의 댓글