Todo-Todo는 팀원들과 함께 대시보드를 생성하고 할 일을 공유하는 칸반보드 서비스입니다. 평소에도 자주 사용되는 서비스이기에 기능 확장이나 추후 활용 가능성이 높다고 판단하여 프로젝트 주제로 선정했습니다. 저희 팀은 이번 프로젝트를 통해 Next.js의 App Router를 처음으로 사용하며 서버 컴포넌트와 클라이언트 컴포넌트에 대한 이해도를 높이는 것을 주요 목표로 삼았습니다.

기술 스택 및 선택 이유

  • Next.js 14: SSR (Server-Side Rendering)과 SSG (Static Site Generation)를 모두 지원하며 React 기반으로 개발되어 React에 익숙한 저희 팀에게 최적의 선택지였습니다. SSR을 위한 별도의 함수 구현 없이 React 18부터 제공되는 서버 컴포넌트를 활용할 수 있다는 점이 매력적이었습니다. 또한, 페이지 단위가 아닌 컴포넌트 단위로 렌더링 방식을 조절하여 웹사이트 성능 향상을 기대할 수 있었습니다.
  • TypeScript: 런타임 이전 타입 검사를 통해 잠재적인 에러를 사전에 방지하여 프로젝트의 안정성을 확보하는 데 필수적인 요소였습니다.
  • Tailwind CSS: 서버 컴포넌트 스타일링을 위해 채택한 기술입니다. 클래스명 기반 스타일링을 통해 UI와 비즈니스 로직을 명확하게 분리하는 데 효과적이라고 판단했습니다.

User Flow

기본 요구 사항과 제공된 피그마 디자인을 바탕으로, 랜딩 페이지부터 시작하여 사용자의 인터랙션에 따라 이동하는 경로를 시각적으로 정리했습니다.

원활한 협업을 위한 노력

저희 팀은 프로젝트 초기 단계부터 문서화 작업과 역할 분담을 동시에 진행했습니다. 노션 페이지를 구축하여 필요한 모든 문서를 한 곳에 모으고, 코딩 컨벤션 등의 규칙을 기록했습니다. 매 스크럼 회의록 또한 노션에 상세히 기록하여 진행 상황과 추가적인 의견들을 체계적으로 관리했습니다. UX에 대한 다양한 고민을 공유하며 기존 기획에서 개선할 부분을 논의하고 실제 구현에 반영하는 과정을 통해, 서로 다른 의견에 대한 근거를 제시하고 합의점을 찾아나가는 경험을 얻을 수 있었습니다.

프로젝트 구현은 이슈 기반으로 브랜치를 생성하여 진행했으며, 별도의 협업 툴 없이 GitHub에서 제공하는 이슈 및 칸반 보드를 적극적으로 활용했습니다. 팀 프로젝트의 효율성을 높이기 위해 각자 맡은 기능별로 데드라인을 이슈 제목에 명시하여 책임감을 부여하고, 어려움이나 시간 부족 시 팀원들에게 빠르게 공유하여 함께 해결할 수 있도록 장려했습니다.

맡았던 역할: Team Leader

  • 랜딩 페이지 디자인 구현
  • 대시보드 설정 페이지 UI 구현
  • 대시보드 정보 수정 PUT 요청 및 요청 후 데이터 패칭
  • 멤버, 초대 내역, 대시보드 목록 데이터 페이지네이션 구현
  • 초대 내역 검색 기능 구현

Team Leader로서의 경험

좋은 팀원들을 만난 덕분에 팀장으로서 프로젝트 외적으로 관리하고 신경 써야 할 부분들을 많이 분담하여 진행할 수 있었습니다. 문서 관리를 위한 노션 페이지 구축, 프로젝트 기본 설정, 레포지토리 설정 등 여러 작업을 팀원들의 도움으로 수월하게 진행할 수 있었습니다. 팀장으로서 제가 가장 중요하게 생각했던 두 가지는 다음과 같습니다.

  1. 프로젝트 진행 시 의견을 편하게 제안할 수 있는 환경 조성: 팀원들이 부담 없이 자신의 아이디어를 공유하고 논의에 참여할 수 있도록 격려했습니다.
  2. 팀원들의 사기 유지: 팀장으로서 긍정적인 분위기를 조성하고 팀원들의 노고를 인정하며 동기 부여에 힘썼습니다.

스프린트 과정에서 파트 시작 후 프로젝트 돌입 전까지 주어지는 준비 기간을 활용하여 프로젝트 기반을 다지는 데 집중했습니다. 노션 문서를 일찍이 만들어 팀원들이 자유롭게 의견을 개진할 수 있도록 했고, 매일 진행되는 팀 미팅에서는 아이스 브레이킹 시간을 통해 팀원 간 유대감을 강화하고자 노력했습니다. 부산에 거주하여 위워크 참여가 어려운 점을 고려하여 온라인 소통에 더욱 신경 썼습니다. 이러한 노력 덕분에 프로젝트 진행 과정에서 팀원들 모두 사소한 것이라도 편안하게 의견을 제시하고, 반대 또는 찬성 의견을 자유롭게 나눌 수 있는 협력적인 분위기를 조성할 수 있었습니다.

팀장으로서 특별히 어려운 업무를 맡았던 것은 아니었습니다. 오히려 팀원들의 도움 덕분에 문서 관리나 초기 설정 등 여러 부분을 지원받을 수 있었습니다. 이에 보답하고자 팀원들이 기피하는 업무를 제가 맡아서 처리하려고 노력했습니다. 스프린트에서 제공되는 프로젝트 기획의 상당 부분을 차지하는 랜딩 페이지는 기능 구현보다는 디자인 작업 위주라 팀원들이 선호하지 않는 경향이 있었습니다. 특히 저를 제외한 모든 팀원이 이전 프로젝트에서 랜딩 페이지 개발을 경험했기에, 퍼블리싱 경험이 있는 제가 랜딩 페이지 구현을 담당하기로 했습니다. 또한, 프로젝트 발표 역시 팀원들이 부담스러워하는 부분이었기에, 막바지에 개인적인 일정으로 시간이 부족했음에도 불구하고 발표 자료와 스크립트를 밤샘 작업으로 준비했습니다. 팀원들 모두 만족해하고 발표 자료를 흥미롭게 봐주어서 보람을 느꼈습니다.

랜딩 페이지, 대시보드 설정 페이지 UI 구현

Tailwind CSS를 처음 사용해 보았기 때문에 CSS 구현 과정이 처음에는 쉽지 않았습니다. Tailwind는 일반적인 CSS 속성명과는 다른 방식으로 클래스명을 정의하며, 하나의 클래스명에 속성과 값을 함축적으로 표현해야 합니다. 예를 들어 width: 100% 속성을 사용하려면 w-full이라고 작성해야 합니다. 공식 문서를 참고하여 필요한 속성을 하나씩 찾아 적용하는 데 다소 시간이 소요되었지만, 프로젝트 진행될수록 익숙해져 나중에는 검색 없이도 스타일링이 가능하게 되었습니다.

Next.js App Router는 이전 Page Router와 달리 페이지 단위로 레이아웃 설정을 용이하게 합니다. 덕분에 대시보드 페이지 내에서만 사용될 헤더 컴포넌트를 더욱 쉽게 구성할 수 있었습니다. 공통적으로 사용되는 페이지네이션 기능과 버튼 등은 재사용 가능한 컴포넌트로 분리하여 효율성을 높였습니다.

초대 내역 검색 기능 구현

검색 기능은 input 창에 사용자가 입력한 값에 따라 API 요청을 보내고, 서버로부터 받은 응답 데이터를 화면에 실시간으로 매핑해야 했습니다. 이미 팀원이 구현해 놓은 무한 스크롤 기능을 검색 결과에도 동일하게 적용해야 했습니다. 저는 검색어 유무에 따른 데이터 패칭 로직 분기를 처리하기 위해 별도의 Container 컴포넌트를 생성하여 데이터 요청 및 상태 관리를 담당하도록 구현했습니다.

export default function InvitationContainer({
  initialInvitations,
  initialCursorId,
}: InvitationListProps) {
  const [invitationList, setInvitationList] =
    useState<Invitation[]>(initialInvitations);
  const [apiCursorId, setApiCursorId] = useState(initialCursorId);
  const [searchWord, setSearchWord] = useState<string>('');
  const { ref, inView } = useInView();

  async function loadMore() {
    // NOTE - 더 이상 로드할 데이터가 없는 경우
    if (apiCursorId === null) return;
    const data: InvitationResponse = await getInvitations(
      INITIAL_NUMBER_OF_USERS,
      apiCursorId ?? undefined,
      searchWord ?? undefined
    );
    const { invitations, cursorId } = data;

    // NOTE - 새 데이터를 기존 데이터에 추가하고 cursorId를 업데이트
    setInvitationList((prevInvitations) => [
      ...prevInvitations,
      ...invitations,
    ]);
    setApiCursorId(cursorId);
  }
  async function searchInvitation() {
    const data: InvitationResponse = await getInvitations(
      INITIAL_NUMBER_OF_USERS,
      undefined,
      searchWord ?? undefined
    );
    const { invitations, cursorId } = data;

    setInvitationList(invitations);
    setApiCursorId(cursorId);
  }

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchWord(e.target.value);
    searchInvitation();
  };

  const debouncedOnChange = debounce<typeof onChange>(onChange, 500);

  useEffect(() => {
    searchInvitation();
  }, [searchWord]);

  useEffect(() => {
    if (inView) {
      loadMore();
    }
  }, [inView]);
  return (
    <>
      <input
        placeholder="검색"
        onChange={debouncedOnChange}
      />
      <ul>
        <InvitationList
          invitationList={invitationList}
          setInvitationList={setInvitationList}
          setApiCursorId={setApiCursorId}
        />
        <div ref={ref} />
      </ul>
    </>
  );
}

또한, 사용자가 검색어를 입력하는 동안 발생하는 불필요한 API 요청을 줄여 네트워크 비용을 최적화하기 위해 debounce 함수를 구현하여 검색어 입력 완료 후 일정 시간 동안 추가 입력이 없을 경우에만 API 요청이 발생하도록 처리했습니다.

export const debounce = <T extends (...args: any[]) => any>(
  fn: T,
  delay: number
) => {
  let timeout: ReturnType<typeof setTimeout>;

  return (...args: Parameters<T>): ReturnType<T> => {
    let result: any;
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => {
      result = fn(...args);
    }, delay);
    return result;
  };
};

프로젝트를 끝내며…

기본 요구 사항 구현은 비교적 빠르게 완료되었지만, 명확한 테스트 기간을 설정하지 않아 프로젝트 막바지에 급하게 버그를 수정하고 리팩토링하는 데 많은 시간을 할애하게 되었습니다. 여유로울 것이라고 예상했던 프로젝트는 결국 마지막까지 밤샘 작업을 필요로 했습니다. 또한, 시간 제약으로 인해 fetch 로직에 대한 에러 처리나 중복 코드 개선 등 코드 완성도 및 아키텍처 개선에 충분한 시간을 투자하지 못한 점이 아쉬움으로 남습니다. 이는 코드의 완성도에 대한 욕심 때문에 기본적인 기능 구현이 늦어지는 저의 개발 습관 때문이라고 생각합니다. 앞으로 프로젝트를 진행할 때는 기능 구현을 최우선으로 고려하여 우선적으로 동작하는 코드를 만드는 데 집중해야 할 것 같습니다.

추가적으로, 프로젝트 중간 점검일을 지정하여 미리 리팩토링 및 버그 수정 시간을 확보하는 것이 막바지 작업 부담을 줄이는 효과적인 방법이 될 수 있을 것 같습니다. 또한, 적절한 주기를 가지고 적용했던 기술 스택에 대한 정리나 프로젝트 회고를 진행하여 미래에 동일한 실수를 반복하지 않도록 꾸준히 기록해야 할 것입니다.