데브코스 팀 프로젝트 회고 - Angola

김영준·2023년 10월 4일
3
post-thumbnail

9월 1일 ~ 9월 27일
약 한 달간 진행했던 팀 프로젝트에 대해서 회고를 작성하려고 한다!

📢 프로젝트 소개

앙골라 서비스는 밸런스 게임 플랫폼으로 한 주제에 대해 사용자들이 A와 B 중 하나를 선택하여 각자의 의견을 공유하는 SNS 기반 커뮤니티 사이트다.

앙골라 바로가기


🧐 기획 배경

둘 중 하나를 고르기 어렵거나 참신하고 재미있는 상황들을 다루는 밸런스 게임이 MZ 세대를 중심으로 유행하고 있다. 따라서 한 주제에 대해 다양한 의견을 나누며 즐거움을 느끼고, 각자의 선택에 도움을 줄 수 있는 밸런스 게임 기반 플랫폼이 있으면 좋겠다고 생각하였고, 이를 통해서 앙골라 서비스를 기획하게 되었다.


🇦🇴 왜 이름이 앙골라??

밸런스 게임이라는 주제가 정해지고 각자 떠오르는 네이밍 아이디어를 노션에 기록해 두기로 했었는데 밸런스 게임이면 둘 중 하나를 골라야겠지..? 고른다.. 골라.. 앙골라...?! 해서 탄생하게 되었다.

이미 존재하는 단어인 앙골라라는 국가 이름을 서비스명에 활용하여 사용자들에게 친근한 느낌을 주는 동시에 앙~골라 라는 귀여운 어감으로 누구나 쉽게 떠올릴 수 있다고 생각했다!

이외에 다양한 아이디어를 제시했지만 팀원분들께서 앙골라를 너무 좋아하시고 재밌어해주신 덕분에 암묵적으로 프로젝트 이름은 앙골라로 확정되었다.


⚙️ 기술 스택

React + TypeScript + Vite를 사용하여 프로젝트를 세팅하였다.

상태 관리 라이브러리는 Recoil, Style은 emotion을 사용하였다.
이전에 styled-component를 사용해 본 경험이 있는데 emotion과 크게 다르지 않아서 쉽게 사용할 수 있었다.

HTTP 통신은 axios를 사용하였고 일정한 코드 형식을 맞추기 위해 코드 포매터인 PrettierESLint를 사용하였다.

협업 툴로는 버전 관리를 위해 Github, 문서화를 위해 Notion, 정기적인 회의를 위해 Discord, 커뮤니케이션을 위해 Slack을 활용하였다.


💻 내가 구현한 기능

  • 검색, 좋아요, 팔로우 관련 API 훅 구현
  • 홈(메인) 페이지 및 무한 스크롤 구현
  • 검색 페이지 및 정렬 구현
  • 헤더 컴포넌트 및 Title 컴포넌트 구현
  • 404 페이지 구현
  • UserListItem 컴포넌트, PostListItem 컴포넌트 구현

📘 API 설계 및 활용

로그인 유무에 따라서 서버에 header 값을 전달하는 것이 결정되었기 때문에
instance.ts 파일에 공통으로 쓰이는 두 개의 axios instance를 작성한 후 각각의 api 파일에서 import 하여 사용하는 방식으로 구현했다.

또한 사용자가 로그인에 성공하면 전달받는 토큰 값을 로컬 스토리지에 저장하고, recoil을 사용하여 로그인 유무를 판단했다.

// src/apis/instance.ts

import axios from 'axios';
import { useRecoilValue } from 'recoil';
import { authInfoState } from '@store/auth';

const useAxiosInstance = () => {
  const auth = useRecoilValue(authInfoState);
  const baseInstance = axios.create({
    baseURL: '',
  });

  const authInstance = axios.create({
    baseURL: '',
    headers: {
      Authorization: `${auth?.token}`,
    },
  });

  return { baseInstance, authInstance };
};

export default useAxiosInstance;

api 함수 같은 경우에는 카테고리 별로 파일을 분류하고 훅 내부에 react-query를 사용함으로써 실제로 fetch 할 때는 훅만 호출하여 필요한 값(data, loading, error 등)을 전달받을 수 있도록 구현하였다.

// src/apis/search.ts

export const useFetchSearchPosts = ({ query }: SearchRequestQuery) => {
  const { baseInstance } = useAxiosInstance();
  const { data, isSuccess, isError, isLoading, refetch } = useQuery<
    AxiosResponse<(User | Post)[]>,
    AxiosError,
    Post[]
  >('searchPosts', () => baseInstance.get(`/search/all/${query}`), {
    select: ({ data }) => {
      return data.filter((resData) => {
        return 'title' in resData;
      }) as Post[];
    },
  });

  return {
    searchPostsData: data,
    isSearchPostsSuccess: isSuccess,
    isSearchPostsError: isError,
    isSearchPostsLoading: isLoading,
    searchPostsDataRefetch: refetch,
  };
};

✅ 컴포넌트 구조

Main 컴포넌트에 NavBarHeader가 존재하고 URL을 감지하여 해당하는 페이지를 렌더링 하는 방식으로 구현했다.

Main 컴포넌트에서 각각의 페이지에서 필요한 데이터들(검색 키워드, 타이틀, 정렬 기준 등...)을 내려주어 쿼리 스트링과 같은 값들을 받아오기 위해 중복해서 라우터를 감지하는 불필요한 코드를 작성할 필요가 없었다.


♾️ 무한 스크롤 구현하기

앙골라 프로젝트의 Home 페이지는 모든 post의 목록을 보여준다.
하지만 Home으로 갈 때마다 수많은 post들을 불러오는 것은 너무 많은 리소스가 소모된다고 판단되어 무한 스크롤을 구현하기로 했다.
스크롤이 화면 끝에 닿는 것을 감지하기 위해 Intersection Observer를 사용하였다.

처음에는 Home 페이지와 검색 페이지에서 전체 post를 불러오는 api를 useFetchAllPosts라는 훅으로 공통되게 사용했다. 하지만 같은 리액트 쿼리 key를 갖고 있기 때문에 검색 페이지를 갔다가 Home으로 돌아오면 검색 페이지의 데이터를 기준으로 post가 변화되는 문제가 발생했다.

따라서 offset과 limit을 params로 받는 useFetchPartPosts라는 훅을 추가로 구현했다.
오로지 스크롤로 인해 추가적인 refetch가 적용될 수 있도록 모든 refetch 옵션을 false로 설정하였고, 다른 페이지로 이동했다가 다시 돌아오면 처음부터 5개의 post만 렌더링 될 수 있도록 cacheTime을 0으로 설정했다.

export const useFetchPartPosts = (offset: number, limit: number) => {
  const { baseInstance } = useAxiosInstance();
  const path = `/posts/channel/${CHANNEL_ID}`;

  const { data, isError, isLoading, isSuccess, refetch } = useQuery<
    AxiosResponse<Post[]>,
    AxiosError
  >(
    'partPosts',
    () =>
      baseInstance.get(path, {
        params: {
          offset,
          limit,
        },
      }),
    {
      refetchOnWindowFocus: false,
      refetchOnMount: false,
      refetchOnReconnect: false,
      cacheTime: 0,
    },
  );

  return {
    partPostsData: data?.data,
    isPartPostsSuccess: isSuccess,
    isPartPostsError: isError,
    isPartPostsLoading: isLoading,
    partPostsRefetch: refetch,
  };
};

또한 react-queryisLoading 값에 따라서 로딩 컴포넌트를 렌더링 하였는데 처음 5개의 post를 가져올 때만 로딩 컴포넌트가 보이고 추가적인 post를 불러올 때는 로딩 컴포넌트가 불러와지지 않았다.

이는 refetch를 실행할 때마다 isLoading의 값도 함께 변한다고 착각했기 때문이다. 따라서 비동기로 동작하는 refetch 함수에 await 키워드를 붙여 동기적으로 동작하게 하고 별도의 loading state를 생성한 후 loadingtrue 일 때도 로딩 컴포넌트가 렌더링 되게 구현하였다.

 const handleIntersection = async (
      [entry]: IntersectionObserverEntry[],
      io: IntersectionObserver,
    ) => {
      if (isLoading) return;
      if (entry.isIntersecting) {
        setIsLoading(true);
        setOffset((prev) => prev + LIMIT);
        await partPostsRefetch();
        io.unobserve(entry.target);
        setIsLoading(false);
      }
    };

// ...

return {
  // ...
	{isPartPostsLoading || isLoading ? <Spinner /> : null}
}


🔍 검색 기능 구현하기

검색한 결과를 화면에 출력하기 위해 sort 함수를 적극 활용했다.
정렬을 구현하면서 당황했던 적이 있는데, 주어진 API가 전체 post의 데이터는 최근순으로, 특정 키워드의 post는 옛순으로 받아와지는 상황이었다.

keyword prop을 정렬 함수에 추가로 전달하여 prop의 유무에 따라 최근순 정렬을 적용하였는데 리액트 쿼리의 캐싱으로 인해 기존의 데이터 순서가 남아있어 정렬 기준을 바꿔도 최근순 정렬이 원활히 구현되지 못하였다.

따라서 어떠한 API든지 최근순으로 한번 정렬을 해야겠다고 생각하였고 post의 createdAt 속성을 활용하여 Date형식으로 변환한 뒤 getTime 함수를 통해 밀리 초로 변환하였다. 이렇게 되면 '2023-10-04T12:34:56.789Z' 형식이었던 값이 '1664940896789'와 같이 정수 형태로 변환되어 sort 함수에 콜백 인자로 전달될 수 있다.

export const getSortPostList = (
  searchData: Post[] | undefined,
  sort?: string,
): Post[] | undefined => {
  if (sort === 'recent') {
    return searchData?.sort(
      (a, b) =>
        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
    );
  } else if (sort === 'like') {
    return searchData?.sort((a, b) => b.likes.length - a.likes.length);
  }
  return searchData;
};

유저 정렬은 팔로워 순, 레벨 순으로 구현하였는데 레벨 순 정렬 같은 경우에는 작성한 post의 개수와 댓글 개수를 합친 값으로 레벨을 반환하는 calculateLevel 함수를 사용하였다.

export const getSortUserList = (
  searchData: User[] | undefined,
  sort: string,
): User[] | undefined => {
  if (sort === 'follower') {
    return searchData?.sort((a, b) => b.followers.length - a.followers.length);
  } else if (sort === 'level') {
    return searchData?.sort((a, b) => calculateLevel(b) - calculateLevel(a));
  }
  return searchData;
};

또한 NavBar 컴포넌트 내부에 존재하는 searchBar 컴포넌트에서 검색을 하면 url에 쿼리 스트링으로 keyword를 등록하고 Main 컴포넌트에서 url의 쿼리 스트링을 감지하여 searchPage에 내려주는 형식인데 한글로 검색할 시 인코딩 된 값이 전달되는 문제가 발생했다.

문제를 해결하기 위해 검색해 본 결과 이러한 형태의 문자열이 출력되는 것은 URL 인코딩 때문이었다. URL은 ASCII 문자만을 허용하기 때문에, 한글과 같은 비 ASCII 문자는 URL에서 안전하게 전송하기 위해 인코딩되어야 한다고 한다.

따라서 decodeUri라는 함수를 만들어 keyword디코딩 해줌과 동시에 공백이 인코딩 된 값 '+'로 변환되었기 때문에 replace 함수를 통해 '%20'으로 치환해 주었다.

keyword가 존재할 때만 decodeUri 함수를 실행시키도록 하여 또 다른 에러가 발생하지 않도록 했다.

export const decodeUri = ({ keyword }: DecodeUriProps) => {
  return decodeURIComponent(keyword!.replace(/\+/g, '%20'));
};
{target === PARAM_VALUES.TARGET.USER ? (
        <UserList
          keyword={keyword && decodeUri({ keyword: keyword! })}
          sort={sort || SEARCH_VALUES.SORT.FOLLOWER}
        />
      ) : (
        <PostList
          keyword={keyword && decodeUri({ keyword: keyword! })}
          sort={sort || SEARCH_VALUES.SORT.RECENT}
        />
      )}

이제 한글이 정상적으로 검색되는 것을 볼 수 있다.


🚩 KPT 회고

앙골라 프로젝트를 진행하고 나서 느낀 점을 KPT 회고로 작성해 보고자 한다.

K: Keep

  • 프로젝트 시작 전 명확한 컨벤션 정리
    적절한 개발 컨벤션을 정한 것이 서로가 무엇을 개발하고 있는지 쉽게 파악할 수 있게 되어 중복되는 코드를 작성하지 않는 데에 도움이 된 것 같다.

  • 적극적인 의사소통과 이슈 공유
    이슈 공유에 대한 규칙을 만들고 일정 시간이 지체되면 팀원들에게 즉시 이슈를 공유했던 점이 개발 시간을 빠르게 단축할 수 있었다.

    또한 자신이 담당한 역할이 아닌 부분에서 발생하는 이슈도 함께 고민하고 해결하는 과정에서 좀 더 성장하는 계기가 되었던 것 같다.

P: Problem

🚨 Fact
기능 구현에 급급해서 팀원들의 코드 리뷰를 꼼꼼히 하지 못했다.

Problem
추후 발생하는 문제에 대해 예상하지 못했고 최종 발표 전 날에 급히 코드를 수정하는 일이 발생했다.

Solution(Action Item)
코드 리뷰를 보다 꼼꼼하게 진행하고 다른 사람의 코드를 많이 접해보는 경험을 자주 가져야겠다.

🚨 Fact
반복되는 이슈들에 대해서 매끄럽게 대처하지 못했다.

Problem
동일한 이슈임에도 불구하고 이슈를 해결하는 데 시간을 많이 소요했다.

Solution(Action Item)
팀원들과 공유할 수 있는 문서화 툴에 트러블 슈팅 문서를 만들어두고 이슈가 발생했을 때의 과정과 해결 방법을 문서화해두어 동일한 트러블을 직면했을 때 보다 빠르게 대응할 수 있도록 해야겠다.

🚨 Fact
중간 회고 때 멘토님의 피드백을 받았지만 모든 피드백을 반영하진 못했다.

Problem
앙골라 프로젝트에서 React-Query를 도입하여 사용하는 중이었고, 해당 라이브러리에서 제공하는 useInfiniteQuery를 활용하여 무한 스크롤을 구현하여 보다 여러 인터페이스를 추가적으로 따로 구현할 필요가 없음에도 일단 기능을 구현하는 데 초점을 두어 기존의 Intersection Observer를 사용한 무한 스크롤 코드를 수정하지 않았다. 또한 React 18에서 도입된 Suspense를 로딩 처리에 적용해 보라는 멘토님의 피드백도 실제 코드에 반영하지 못했다.

Solution(Action Item)
반영하지 못한 멘토님의 피드백을 기준으로 지속적으로 리팩토링하여 새로운 지식들을 학습하고 프로젝트의 품질을 향상시키는 데 노력해야겠다.

T: Try

  • 코드 리뷰, 이슈 대처, 그리고 피드백 미반영과 같은 문제들은 결국 기능 구현에 너무 집중하여 시간에 대한 관리가 미흡했던 탓에 발생했던 것 같다. 3차 팀에서는 기능 구현 이외의 작업에 대해 충분한 시간을 할애하기 위해 일정을 잘 분리하는 데에 신경을 써서 프로젝트의 완성도를 높일 수 있도록 임해야겠다.

  • 프로젝트를 진행하면서 팀원들로부터 좋은 영향을 많이 받았다. 이러한 긍정적인 영향을 3차 팀에 그대로 반영하여, 팀 내에서 원활한 협력과 소통이 이뤄질 수 있도록 의견을 적극적으로 제시하고, 팀의 이슈에 대해 적극적으로 해결하는 모습으로 임하여 프로젝트에 많은 도움을 주는 팀원이 되도록 노력해야겠다.

다음 프로젝트 때 실행할 것

  • 팀 스프린트를 나눈 후 개인적인 업무를 한 번 더 체계적으로 나누기
  • 문서화를 적극적으로 활용하여 팀원에게 공유하기 (트러블 이슈, 코드 개선 등)
  • 의견을 적극적으로 제시하기

🎉 마무리 하며

항상 즐거운 분위기 속에서 협업을 진행했다는 점이 놀라웠다.
물론 훌륭한 팀원분들을 만난 덕분이겠지만, 적극적인 소통, 타인에 대한 배려, 그리고 긍정적인 분위기를 유지하는 것이 큰 역할을 한 것 같다.
이러한 성향을 갖춘 사람에 가까워질 수 있도록 지속적으로 노력해야겠다.

또한 프로젝트를 함께 진행한 앙골라 팀원분들! 항상 감사하게 생각하고 있으며 프로젝트를 지속적으로 리팩토링하여 코드의 품질을 높여볼 생각이다.

앙골라 포에버!!!!

profile
꾸준히 성장하는 개발자 블로그

2개의 댓글

comment-user-thumbnail
2023년 10월 6일

아이디어 뱅크 영준님! 팀 프로젝트 너무 수고하셨습니다👏
서로 더욱 성장해서 꼭 다시 협업해보고 싶습니다 ㅎㅎ

1개의 답글