시작하며…

미니프로젝트가 드디어 끝났다. 주말에 회고를 작성해볼까했지만 금요일부터 밤을 꼴딱 샜더니 토요일은 하루종일 잠만 잤고 일요일은 몸살기가 돌아서 약먹고 침대에만 누워있었다. 정신을 차려보니 11월이 다 가고 12월이 되었다. 날씨도 추워지고 여기저기서 연말 분위기가 풍긴다. 원래 KPT 기간에 엔저 + 유류할증 인하를 노려서 도쿄를 갔다올까 했지만 민이형이 알바를 구하는 바람에 흐지부지되었다 😂 뭐 이렇게 된 김에 리팩토링 + 파이널프로젝트 준비나 하면서 연말을 보내야겠다.

팀빌딩 미팅

거두절미하고 2주전으로 돌아가 팀빌딩 미팅떄로 돌아가 회고를 시작해보도록 하겠다. 이번에는 BE와의 협업이었기때문에 ZEP에서 팀빌딩 미팅을 진행했다. 분명히 사람들이 접속은 하고있는데, 10분째 말이 없길래 먼저 말을 꺼냈다. 늘 하던 것처럼 그룹명부터 정하기 시작했다. 저번 토이프로젝트 2때는 내가 제안한 그룹명으로 정해졌는데, 이번에 제안한 그룹명은 은지가 구리다고 해서 다른 제안에 투표를 했다 ㅋㅋ…


그룹명을 정하고 FE, BE 나눠 각 회의실에서 팀장 선출을 했다. 은지가 나를 추천할게 분명했기에 조용히 있으려고 했는데, 용희님이 먼저 나를 추천하더니 자연스럽게 내가 팀장이 되었다. 난생 처음 팀장을 해보는거라 걱정이 앞섰지만, 뭐 어쩌겠나… 회의록에 팀장 이승현으로 박제되었다 ㅎ…


이후, 간단한 팀 규칙 설정 후 프로젝트 요구사항을 숙지하고 빠르게 기획을 시작하였다.


아이디어 회의에서 나온 것은, 야놀자 클론이었다. RFP의 예시가 야놀자의 서비스이니 제일 만만했다. 이외에도 몇 가지 다른 레퍼런스들이 있었는데, 그 중 투표로 추린 사이트가 에어비앤비와 데일리호텔이었다. 나는 개인적으로 반응형 구현을 좋아하지 않아서 데일리호텔에 한표를 던졌다. 두 사이트는 4:4로 박빙인 상황에서 마지막에 용희님이 데일리호텔에 한표를 던지며 데일리호텔을 레퍼런스로 하는 프로젝트를 구성하였다.


이후, 디자인까지 진행했는데, 피그마를 잘 다룰줄 아는 사람이 없어서 막막해하던 중, 서현님이 이전에 진행했던 프로젝트의 기능명세서 양식을 가져와주셔서 그걸 기반으로 디자인을 해보기로 했다. 다행이었던 점은, 처음부터 디자인을 하는 것이 아닌 대부분을 데일리호텔 UI를 참고해도 된다는 것이었다. 페이지별로 피그마에 정리를 한 후, 개발 요구 기능들을 정리하였다. 이때, 우리 프로젝트에 맞게 조금의 커스텀을 진행하였다.


마지막으로, 공통으로 사용할 컴포넌트와 구현하지 않아도 되는 부분을 명시한 후, BE분들과 공유하였다.


이전에는 유나나 승연님, 정아님이 총대를 매고 디자인을 해주셔서 옆에서 피드백만 드리다가 막상 내가 하려니까 어려웠던 기획, 디자인 작업이었지만 하루만에 어찌저찌 끝낼 수 있었다. 개발자이지만, 디자인적 감각과 툴을 사용하는 방법 등 기초적인 디자인을 할 수 있는 역량을 키워야겠다고 마음먹으며 팀빌딩 미팅이 마무리되었다.

개발 시작

주말이 순식간에 지나간 후, 월요일이 되어 본격적으로 개발을 시작하게 되었다. 저번주에 기획, 디자인을 끝내놓은 덕분에 빠르게 진행을 이어갈 수 있었다. 첫날에는 개발 규칙을 작성하고 개발 스택을 확정지었다. 저번에 nextjs를 사용했는데 아직 완벽히 숙지되었다고 생각하지 않았고, 다른 팀원들도 nextjs사용을 별로 좋아하지 않아서 제외하였고, 마크업 시간을 줄이고 통일성을 주기 위해서 chakraUI를 사용하기로 했다. (사실 css에 자신이 없는 내가 강력히 추천했다 ㅋㅋ). 나영님은 api 호출 시 캐싱과 통일성을 가져가기 위해 react-query를 추천해주셔서 도입해보기로 했다.


다음은 항상 어려웠던 역할 분담을 진행했다. 하지만, 이번 프로젝트같은 경우는 페이지별로 구분이 명확했기에 먼저 희망자를 조사했다. 다행히 아무도 희망하는 페이지가 겹치지 않아서 나는 마지막에 남은 검색 결과 페이지와 관련 컴포넌트들의 구현을 맡게 되었다.


이후, 용희님의 도움을 받아 initial commit을 완료하고 바로 마크업에 들어갔다. chakraUI를 적극 활용하여 검색하기 컴포넌트, 검색 결과 컴포넌트 및 모달 마크업을 빠르게 진행했다.


멘토링

어느덧 첫번째 멘토링 시간이 되었다. 나는 기획 단계에서 구상하였던 검색 컴포넌트에 관해서 질문드렸다. 검색 컴포넌트에는 숙소명, 시/도, 군/구, 날짜, 카테고리의 총 5개의 필터가 존재하는데, BE 분들이 해당 querying이 복잡할것이라 걱정해주셔서 이대로 필터들을 유지해도 되는지 여쭤보았다.


멘토님은 이정도의 쿼링은 문제되지는 않고, 오히려 강남/역삼/삼성에 대한 코드를 어떻게 정의할지를 생각해보라고 하였다. 일반적으로 문자열의 형태로 db에 저장하지 않고 A000과 같은 코드화된 방식으로 저장할텐데, 강남, 역삼, 삼성에 대한 코드를 따로 관리한다면, api fetching을 어떻게 할지, 메뉴 관리는 어떻게 할지 고민해보라고 하셨다.


멘토님꼐서 우문현답을 해주셨다😂 실제로, db에 저장되는 구조를 파악하고 api end point를 파악한 후, 메뉴 관리에 대한 질문을 했어야하는데 쿼링에 대한 걱정부터 하고있었다니… 부끄러움이 밀려왔다.


이외 다른 질문들의 답변을 정리해보자면, react-query의 도입은 api fetching 과정에서 통일성을 주기 때문에 도입해도 괜찮을 것 같다는 내용과 이번 프로젝트를 단순히 react로만 구현하게 된다면, 다른 프로젝트와 차별성이 없다는 내용의 답변으로는

  • data fetching에 대한 에러 핸들링과 같이 내실을 다지는 기회로 활용해보기
  • cicd 경험 쌓아보기
  • 안전한 어플리케이션을 어떻게하면 만들까에 대한 고민 해보기
  • 사용자가 이탈한 시점 파악해서 ux적 최적화 해보기
  • storybook 사용해보기

가 있었다. 미리 말하자면, 프로젝트가 끝난 시점에서 봤을 떄 어느것도 시도해보지 못했다. 그렇기에 오늘부터 3주동안 진행되는 KPT 기간을 활용해서 꼭 진행해보고싶다. (특히 마지막 항목!!)

첫번째 고비

시간은 빠르게 흘러 목요일이 되었다. 이제 슬슬 마크업 단계가 끝나서 앞으로 API가 나오면 사용해야 할 react-query를 나영님이 가르쳐주시기로 했다. ㅎㅎ

image

귀여운 브로콜리 아이콘과 함께 react-query강의를 준비해주셨다. 처음 접하는 스택이라 겁이 나기도 했지만 나영님이 워낙 쉽게쉽게 잘 설명해주셔서 금새 이해가 되었고, 초기 hook을 아래와 같이 구성하였다.

import { useQuery } from '@tanstack/react-query';
import { getSearchList } from '@api/getSearchList';

export const useSearchList = () => {
  return useQuery({
    queryKey: ['searchList'],
    queryFn: () => getSearchList(),
  });
};

하지만, 순탄하기만 했던 우리 프로젝트에도 첫번째 고비가 찾아왔다. 바로 오픈 API가 제공해주는 데이터와 우리가 필요한 데이터가 너무 달랐던 것이다. 그래서 우리는 급히 페이지별로 필요한 데이터를 추려 최종적으로, 아래와 같이 제공되는 API의 데이터와 렌더링시킬 데이터를 정리하는 회의를 하였다.

image

하지만, 위와 같이 필요한 데이터를 추렸음에도 정제되지 않은 데이터들이 너무 많아 BE중 한분이 정제하는데에만 일주일 이상이 걸렸고, 이는 우리 프로젝트가 전반적으로 지연되는 결과를 낳게 되었다.

무한스크롤

두번째 고비는 무한스크롤이었다. 그동안은 pagnation으로만 구현해서 무한스크롤을 한번도 구현해보지 않았지만, 크게 문제되리라 생각은 하지 않았다.

const { data, error, isLoading, refetch } = useSearchList(
  ...
)

useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  document.documentElement.scrollTop = 0;

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

useEffect(() => {
  if (data) {
    setSearchList((prevSearchList) => [
      ...prevSearchList,
      ...data.accommodations,
    ]);
    setTotalPage(data.total_pages);
    setIsLoadingMore(false);
  }
}, [data]);

const handleScroll = () => {
  const { scrollTop, clientHeight, scrollHeight } = document.documentElement;

  if (scrollTop + clientHeight >= scrollHeight - 50) {
    if (page < totalPage) {
      setPage((prevPage) => prevPage + 1);
      setIsLoadingMore(true);

      refetch();
    }
  }
};

위는 무한스크롤을 구현한 초기 버전의 코드이다. 간단하게 설명하자면, useEffect를 통해 eventListener를 등록해주고, handleScroll 함수 내에서 scroll이 특정 조건을 만족하면, 다음 page를 react-query를 이용하여 refetch해오는 간단한 로직이다. 또한, refetchdata에 변화가 있으면 두번째 useEffect hook이 실행되어서 렌더링되는 searchList를 갱신시켜주는 구조이다.


예상대로, 실제로 구현 자체는 어렵지 않았지만 크게 두 가지 에러가 존재하였다.

  1. 아래로 스크롤 시 한 페이지가 아닌 여러 페이지가 넘어감
  2. totalPage값이 갱신되지 않음


첫번째 에러는 debounce를 적용하여 해결하였다. debounce는 유저가 입력할 때마다 코드를 오직 한 번씩만 실행되도록 해주는 기술이다. 주로 사용자 입력과 같이 빈번한 이벤트에 대한 성능 개선이나 불필요한 호출을 방지하기 위해 활용된다.

이 경우에도 스크롤 이벤트 발생 시 불필요한 호출이 일어나기 때문에 아래와 같이 200ms동안은 동일한 함수 호출을 막아주었다.

import { debounce } from "lodash";

...

const handleScroll = debounce(() => {
  const { scrollTop, clientHeight, scrollHeight } = document.documentElement;

  if (scrollTop + clientHeight >= scrollHeight - 50) {
      if (page < totalPage) {
        setPage((prevPage) => prevPage + 1);
        setIsLoadingMore(true);

        refetch();
      }
    }
  }
}, 200);

두번째 에러는 eventListener등록 시, useEffect의 의존성 배열을 추가하여 해결하였다.

useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  document.documentElement.scrollTop = 0;

  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, [totalPage]);

data가 불러와진 후 totalPage가 set되면 eventListener등록되게 만들어서, handleScroll 함수 동작 시점을 totalPage 설정 이후로 변경해주었다.

useSuspenseQuery

추가로, 데이터를 가져오는 동안 로딩 상태, 에러 상태 등을 보다 쉽게 관리하기 위하여 useSuspenseQuery를 도입하였다. React의 Suspense와 함께 사용하여 fallback을 이용해 로딩 상태를 표시해주었다.

먼저, 아래와 같이 최상위 component를 Suspense 태그로 감싸주었다.

<Suspense
  fallback={
    <Box
      width="100vw"
      height="100vh"
      display="flex"
      justifyContent="center"
      alignItems="center"
    >
      <Spinner thickness="4px" speed="0.65s" emptyColor="gray.200" size="xl" />
    </Box>
  }
>
  <RecoilRoot>
    <GlobalStyles />
    <App />
  </RecoilRoot>
</Suspense>

이렇게 페이지 이동 중일 때는 fallback으로 등록된 spinner가 동작하게 된다.

추가로, 자식 component 역시

<Suspense
  fallback={
    <styles.SpinnerWrapper>
      <Spinner thickness="2px" size="md" />
    </styles.SpinnerWrapper>
  }
>
  ...
</Suspense>

와 같이 Suspense 태그로 감싸주어 무한스크롤 동작시, 로딩이 발생하면 fallback이 실행되게 하였다.

styled-component에서의 $ 접두사

...

  color: ${(props) => (props.selected ? '#db074a' : '#888')};
  background-color: ${(props) => (props.selected ? 'white' : '#f4f4f6')};

...

이전에 styled-component에 props로 selected 변수를 내려줘서 선택 여부에 따라 color값과 background값을 바꾸는 로직을 위와 같이 작성하였다.

이 부분에서

styled component error "it looks like an unknown prop "responsive" is sent through to the DOM, which will likely trigger a React console error."

위와 같은 warning이 발생하였는데, 대수롭지 않게 생각하다가 지속해서 warning이 발생하여 나영님이 지적을 해주셨다.

이 경고는 props로 스타일드컴포넌트에 내려줄 때 props가 DOM의 attribute처럼 받아들일 수 있다는 경고이며, $ 접두사를 사용해서 스타일드컴포넌트에서만 사용하겠다고 명시해서 DOM에 직접 전달되지 않는 방식으로 해결해야한다고 comment를 달아주셨다.

...

color: ${($selected) => ($selected ? "#db074a" : "#888")};
  background-color: ${($selected) => ($selected ? "white" : "#f4f4f6")};

  &:hover {
    background-color: ${($selected) => ($selected ? "white" : "#e0e0e0")};
  }

...

따라서 위와 같이 수정했고, 앞으로 styled-component의 props를 사용할 때 주의하도록 해야겠다 ㅎㅎ…

오프라인 1차 미팅

사실, 2주차 들어서 진도가 안나가는것 같아서 오프라인으로 미팅을 가지려고 했지만, 월요일과 화요일은 개인 일정도 있었고, 다른 분들이 필요성을 그리 느끼지 못하는 것 같아서 오프라인 미팅을 진행하지 못했는데,

image

위와 같이 제출 이틀이 남은 시점에서 API 구현도 제대로 되어있지 않고, 온라인으로 BE분들이랑 소통이 제한적일것이라 판단하여 수요일에 오프라인 미팅을 진행했다. 고맙게도 BE분들을 포함해서 많은 분들이 나와주셔서 숨통이 좀 틔였다… 😂


오전에는 나영님이 먼저 오셔서 나영님이랑 post 요청시 사용되는 react-querymutation hook들을 모듈화하기로 했다.

const postMutate = useMutation<ResponseType, Error, number>({
  mutationFn: (accommodationId) => postWish(accommodationId, headers),
  onSuccess: (res) => {
    console.log(res.statusCode, res.message);
    Swal.fire({
      icon: 'warning',
      text: '위시리스트에 추가되었습니다.',
    });
  },
}).mutate;

위 로직은 1차적으로 모듈화를 하지 않은 코드이다. 이 로직을 hook으로 모듈화하는 것이 간단해 보였지만, 나영님도 나도 처음 써보는 스택이라 고생을 조금 했다.

interface LikeProps {
  accommodationId: number | null;
  headers: { [key: string]: string };
}

...

export const usePostWish = () => {
  return useMutation<ResponseType, Error, LikeProps>({
    mutationFn: ({ accommodationId, headers }: LikeProps) =>
      postWish(accommodationId, headers),
    onSuccess: () => {
      toast.success("위시리스트에 추가되었습니다.");
    }
  });
};

먼저, 크게 수정된 부분은 없지만, post요청 시, header에 token값을 담아서 보내주어야하기때문에, mutationFn의 parameter type을 수정해주었다.

export const postWish = async (
  accommodationId: number | null,
  headers: { [key: string]: string }
): Promise<ResponseType> => {
  const POST_LIKE_URL = `${API_BASE_URL}/accommodations/${accommodationId}/wish`;

  return await axios.post(POST_LIKE_URL, {}, { headers });
};

이후, postWish 함수도 수정해주었다. 이때 많이 헤맸던 부분은 axios post 요청 시, request body가 없어도 빈 객체를 전달해주어야한다는 것이었다. 이 사실을 모른 상태에서 500 에러가 나길래 BE분들이 쓸데없는 고생을 하셨다는 …😢

const { mutate: postWish } = usePostWish();

...

await postWish({ accommodationId, headers });

마지막으로, 호출은 이렇게 해주었다. 구조분해할당을 이용한 간단한 로직같지만, postWish함수 호출 시 parameter를 어떻게 전달해야하는지 몰라

const { mutate: postWish } = usePostWish({ accommodationId, headers });

이런식으로 나영님과 한참을 헤맸다. 결론은 postWish함수 호출 시의 parameter값은 자동으로 usePostWish hook의 mutationFn의 parameter값으로 전달되고, 이 값이 다시 api 호출 함수로 전달되는 로직으로 동작한다.

사실 이때 몇시간동안 해결이 안돼서 react-query사용과 모듈화를 포기할까 했지만, 나영님이 끝까지 포기하지 않고 옆에서 에러핸들링을 해주셔서 더 나은 프로젝트 결과가 있었지 않나 싶다. 다시 한 번 고맙습니다 나영님😊

오프라인 2차 미팅

사실 오프라인 1차 미팅에서 어느 정도 테스트가 끝날 줄 알았지만 테스트는 무슨 API 연동도 제대로 이루어지지 않아 다음날인 목요일도 오프라인 미팅을 가졌다.


이날은 주로 테스트 -> 에러핸들링 과정을 반복했다. 그 중 기억에 남는 에러핸들링 내용은 검색 결과 페이지 로딩 시 스크롤바 위치에 관한 에러 핸들링이다.

const handleSearchClick = () => {
  refetch();
};

원래 로직은 위와 같이 search component에서 검색 버튼을 클릭하면 refetch가 일어나고,

useEffect(() => {
  setSearchList((prevSearchList) => [
    ...prevSearchList,
    ...data.accommodations,
  ]);
  setTotalPage(data.total_pages);
  setIsLoadingMore(false);
}, [data]);

이후, 위와 같이 searchList component에서 data의 변화가 생겨 useEffect hook이 실행되고, 렌더링되는 searchList의 갱신을 기대했다. 하지만, 무슨 이유에서인지 초기 로딩 시 ` document.documentElement.scrollTop = 0` 속성을 적용하였음에도 스크롤바가 중간에 위치해있었다. 또한, 무한스크롤 로딩 시 맨 위로 스크롤바가 이동하는 에러가 발생하였다.

const handleSearchClick = () => {
  let newRefetchState = isRefetched;
  newRefetchState = !newRefetchState;

  refetch();
  setIsRefetched(newRefetchState);
};

그래서 나는, 초기 페이지 로딩 시와 refetch가 일어나서 searchList배열에 data가 추가될 경우의 두 가지 로직으로 구분하기 위해 isRefetched라는 state를 search 컴포넌트에서 선언하여 검색하기 버튼이 눌릴때마다 위와 같이 상태값을 toggle 시켜주었다.

// 검색하기 버튼 클릭 시
useEffect(() => {
  setSearchList(data.accommodations);
  setTotalPage(data.total_pages);
  setIsLoadingMore(false);
}, [isRefetched]);

// 무한스크롤시
useEffect(() => {
  setSearchList((prevSearchList) => [
    ...prevSearchList,
    ...data.accommodations,
  ]);
  setTotalPage(data.total_pages);
  setIsLoadingMore(false);
}, [page]);

이후, searchList 페이지에서는 검색하기 버튼이 클릭되었을 때는 첫번째 hook을, 무한스크롤시에는 두번째 hook이 실행되도록 하여 초기 로딩 시와 무한스크롤시의 두 가지 로직으로 구분지어서 1차적으로 해결했다.

image

위에서 소개한 에러핸들링 내용뿐만 아니라 테스트때마다 발생하는 수많은 에러들을 해결하느라 팀원 전체가 발표 당일까지 밤새서 작업했다 ㅠㅠ 😢

마무리

급한 불을 끄는 느낌으로 프로젝트를 어찌저찌 제출하긴 했지만, 아직 검색하기 버튼을 2번 눌러야 검색이 되는 문제나, wishList 페이지 로직 등 리팩토링 및 에러핸들링 할 내용들이 산더미같이 많다. 어쨌든 2주동안 고생해준 우리 9조 팀원들 너무너무 고생 많았고, KPT기간에는 조금 여유 가지고 프로젝트 리팩토링을 진행해서 완성도 있는 마무리를 하고 싶다. 화이팅!! 💪💪 그럼 KPT가 끝나고 이어서 회고 작성하도록 하겠다… ㅎㅎ

카테고리:

업데이트: