코드잇 스프린트 중급 프로젝트 회고

2025-02-21

2월 3일부터 진행된 약 2주간의 중급 프로젝트를 마무리했습니다. 두 번째 협업프로젝트라 첫 번째 프로젝트 때보다 더 기여를 많이 해보고 싶은 마음에 욕심이 많았던 프로젝트였던 것 같습니다. 

[github repo] : https://github.com/ToKyun02/Taskify
[taskify service url] : https://taskify-lab.vercel.app/mydashboard


프로젝트 소개

중급 프로젝트도 여러개의 프로젝트 중에 팀원과 회의를 통해서 하나를 골라 진행하는 방식이었습니다. 프로젝트 기간은 2주로 동일하지만 초급 프로젝트 때보다 다뤄야 하는 페이지, 컴포넌트, api 등이 훨씬 많았기에 여러 가지를 고민 후에 프로젝트를 선정하게 되었습니다.

기초 프로젝트에 비해서 다뤄야할 컴포넌트가 독특하거나, 실제 제품으로서 사용자의 생산성을 높여주는 주제의 프로젝트로 의견이 일치하게 되어, 칸반기능으로 일정관리를 하는 프로젝트인 '태스키파이'를 선택하게 되었습니다.


주요 기술스택 선정

초급 프로젝트에서는 기술스택을 선정하는 것에 있어서 크게 생각을 하지 않고 적용을 했습니다. 하지만 이번 두 번째 프로젝트에서는 팀원들과 사용할 라이브러리들에 대해 충분한 토론을 거친 후에 사용하게 될 라이브러리들을 선택했습니다.

  • Next.js: 다양한 렌더링 방식을 선택할 수 있고, 직관적인 폴더 기반 라우팅 시스템, 이미지 최적화, SSR을 통한 초기 JS 로드 사이즈 경량화 등 다양한 최적화를 기본적으로 제공하는 프레임워크라서 선택.
  • TypeScript: 타입 안전성을 제공하고, 코드 오류를 사전에 잡아주어 개발 효율성과 안정성을 높이기 위해 채택.
  • React Query: 데이터 패칭 및 서버 상태 관리가 편리하고, 캐싱 및 최적화된 리패칭을 통해 효율적인 데이터 처리를 위해 선택한 라이브러리.
  • Tailwind CSS: 클래스 네이밍을 고민할 필요가 없고, 빠르고 직관적인 스타일링을 제공하여 선택. CSS 모듈과 비교했을 때 장점이 더 크다고 느껴졌음.
  • React Hook Form / Zod: 폼 검증 및 API 응답 검증을 효율적으로 처리하기 위해 채택한 라이브러리.
  • Motion: React 프로젝트에서 애니메이션을 선언적으로 작성할 수 있는 라이브러리로, 직관적이고 간단하게 애니메이션을 적용할 수 있기 때문에 선택.

내가 한일

랜딩페이지

예전 퍼블리싱을 담당하면서 여러 인터랙션을 시도했으나, 리액트 프로젝트에서 인터랙션을 구현한 경험은 부족했습니다. 이번 기회를 통해 랜딩 페이지 작업을 맡아 GSAP 대신 Motion을 사용해 애니메이션을 구현해 보았습니다.

GSAP은 명령형으로 애니메이션을 다루고 timeline을 제공하는 반면, Motion은 선언적으로 애니메이션을 작성할 수 있어 리액트 프로젝트에서 더 직관적이고 유연한 방식으로 사용할 수 있었습니다. 스크롤 애니메이션의 경우, GSAPScrollTrigger가 더 직관적이었으나, Motion에서는 다소 복잡하게 구현되어 시간이 더 걸렸습니다. 랜딩페이지를 만들면서 느낀 두 애니메이션 라이브러리의 장점을 정리해 보았습니다.

  • GSAP:
    • 장점: timeline이라는 강력한 기능과 명령형 방식으로 애니메이션을 처리할 수 있습니다.
    • 스크롤 애니메이션: ScrollTrigger와 함께 사용하면 스크롤 위치에 따른 애니메이션을 간단하고 직관적으로 구현할 수 있습니다.
  • Motion:
    • 장점: 선언적 애니메이션 방식으로 리액트 친화적입니다. 애니메이션을 선언적으로 작성할 수 있어 코드가 깔끔하고 유지보수가 용이합니다.
    • 스크롤 애니메이션: motion에서는 스크롤 위치를 직접 계산해서 애니메이션의 시작과 끝을 정의하는 키프레임 데이터를 가공해서 animate props로 전달해야 해서 조금 불편하고 복잡한 점이 있었습니다. 그리고 애니메이션을 직접 트리거하는 기능이 없어 때로는 제어가 조금 까다로운 점 있었습니다.

랜딩페이지 첫 섹션에는 lottie도 추가하여 좀 더 살아 숨 쉬는듯한 느낌을 주려고 했습니다. 직접 ai 파일에서 레이어를 분리 후, after effect에서 모션작업을 한 후에 bodymovin 플러그인으로 lottie json파일을 추출해 적용했습니다. lottie-react 라이브러리를 react 프로젝트에서 사용할 때는 겪지 못했는데, 이번 nextjs를 사용하면서 생기게 된 문제가 있어 이 부분은 트러블슈팅 섹션에 정리했습니다.

입력 및 버튼 공용 컴포넌트

공용 컴포넌트를 개발할 때, 잘 만들어진 UI 라이브러리를 참고하여 최대한 비슷한 방식으로 구현하는 것을 목표로 삼았습니다. 확장성 있고 사용하기 편한 컴포넌트를 만들기 위해, 기존 UI 라이브러리들의 설계 방식과 인터페이스 구조를 분석하는 것이 중요하다고 판단하였고, 특히 shadcn(radix) 라이브러리를 많이 참고했습니다.

  • 커스텀 컴포넌트 설계 : 기본 속성을 유지하면서도 유연한 커스텀 컴포넌트를 만들기 위해 기본 html 컴포넌트의 인터페이스 확장
  • Variants와 Tailwind CSS 조합 : 다양한 스타일 변형을 쉽게 적용할 수 있도록 Variants 패턴 활용
  • 접근성 고려 : 입력필드의 경우 useId를 활용한 고유 id와 label 연결로 접근성 개선

이러한 원칙을 바탕으로 확장성과 재사용성이 높은 공용 컴포넌트를 제작하는 데 집중했습니다.

공용훅 (useConfirm, useAlert)

window의 기본 함수인 confirm과 alert을 모달형태로 커스텀하게 만드려고 작성한 공용훅입니다. 마침 프로젝트 내에서 전역상태관리를 위해서 미리 세팅해 둔 zustand가 있어서 활용하게 되었습니다. 

  • confirm과 alert이 사용하게 될 전역 modal store를 작성
  • 기본 브라우저 함수처럼 사용가능하도록 작성
  • modal 열릴 때 promise를 리턴하도록 해서, await 키워드로 alert과 confirm을 기다릴 수 있도록 작성

공용 모달 컴포넌트

프로젝트 내에서 모달 컴포넌트가 자주 사용되었기 때문에, 초기 회의부터 어떻게 재사용성을 높일지 고민했습니다. 팀원들이 각자 조사를 해오고 토론한 끝에, 명령형 방식으로 모달을 제어하는 방향을 선택했습니다.

특히 toast, alert, confirm과 같은 모달은 열리고 닫히는 흐름이 직관적으로 명령형 방식과 유사했기 때문에, useImperativeHandle 훅을 사용하여 모달을 ref를 통해 제어할 수 있도록 구현했습니다.

리액트 공식문서에서는 useImperativeHandle을 사용하기 전에, 명령형 방식이 정말 필요한지 고민해 보라고 권장합니다. 하지만, 이번 프로젝트에서는 모달의 사용 패턴을 고려했을 때 명령형 방식이 더 적합하다고 판단하여 적용하게 되었습니다.

왜 명령형적으로 모달을 사용하게 되었는가?

  • 여러 모달들을 컨트롤할 때, 각 모달들마다 상태를 적어야 하는 점이 불편했습니다.
  • 모달의 열림상태를 state로 관리하면, 부모컴포넌트의 불필요한 리랜더링이 일어납니다.
  • 모달의 상태를 부모컴포넌트에서 관리하지 않습니다.
  • 모달컴포넌트에 열림 상태를 전달해주지 않아도 되어서 편리합니다.
  • 때로는 명령형 방식이 실제 사용자 행동과 더 직관적으로 매칭되는 것 같습니다.(예를 들어, 토스트 라이브러리의 사용법)

[모달 컴포넌트 작성코드]

https://github.com/ToKyun02/Taskify/blob/main/src/components/ui/Modal/index.tsx


시도해 본 것

태그 및 아바타의 색상 지정 방식 (랜덤처럼 보이지만 랜덤이 아닌 색상코드)

Taskify 프로젝트에서 제공하는 API의 한계로 인해 태그 색상과 기본 아바타의 배경색을 저장할 수 없었습니다. 랜덤 한 색상을 적용하면 매번 다른 색상이 보이게 되어 일관성이 부족할 것 같아, 랜덤처럼 보이지만 실제로는 일정한 색상을 유지할 방법을 고민했습니다. 해결책은 간단했습니다. 각 글자의 첫 글자를 추출한 후, 이를 유니코드 값으로 변환해 고유한 숫자로 사용하고, 프로젝트 내에서 설정한 색상 배열의 길이로 나눈 나머지를 색상 인덱스로 활용하는 방식이었습니다. 이렇게 하면 동일한 글자는 항상 같은 색상을 가지게 되고, 서로 다른 글자는 다른 색상을 가지도록 유지할 수 있어, 마치 랜덤처럼 보이지만 예측 가능한 방식으로 동작하게 되었습니다.

// color type 지정
export type HexColor = `#${string}`;
export const DEFAULT_COLORS = ['#7AC555', '#760DDE', '#FFA500', '#76A5EA', '#E876EA'] as const satisfies HexColor[];
export type DEFAULT_COLOR = (typeof DEFAULT_COLORS)[number];

// 첫문자열로 주어진 color array에서 color 추출
export function getColorByString(value: string, colorArray: readonly string[]) {
  const charCode = value.toLowerCase().charCodeAt(0);
  const index = charCode % colorArray.length;
  return colorArray[index];
}

// 실제 사용
const colorCode = getColorByString(label, DEFAULT_COLORS);

소소한 Server side data fetching시도

프로젝트 전반적으로 데이터 패칭을 클라이언트 사이드에서 진행하도록 작업되었는데, 마이페이지로 접근할 때는 서버사이드에서 데이터를 패칭 해서 form의 기본값으로 넘겨주는 방식으로 작업해 보았습니다.

클라이언트 사이드에서 내 정보를 불러오면, 잠시동안 기본 데이터가 비어있는 타이밍이 존재하는 이유로 해당 데이터 패칭 방식을 사용했습니다. 이렇게 되면 form은 서버에서 미리 가져온 데이터를 initialData로 쓰게 되었고, 프로필 업데이트 이후 클라이언트에서는 router.refresh()를 통해서 지금 경로의 서버컴포넌트의 데이터 재패칭 및  재랜더링 을 하게 하여 업데이트해주었습니다.

트러블슈팅

Link의 prefetch로 생긴 클라이언트 측 route cache로 인한 문제점

Next.js의 Link 태그는 뷰포트에 보이거나 마우스를 hover 할 때 해당 페이지의 데이터를 prefetch 하여 클라이언트의 route cache에 저장합니다. 이렇게 하면 사용자가 해당 링크를 클릭할 때 서버 요청 없이 빠르게 화면을 전환할 수 있습니다.

하지만 middleware가 개입하는 경우 문제가 발생할 수 있습니다. 예를 들어, middleware에서 사용자의 로그인 상태를 확인한 후 다른 페이지로 리다이렉트 하는 로직이 있다면, prefetch 과정에서 해당 middleware가 실행됩니다. 그러나 문제는 이미 prefetch 된 페이지로 이동할 때는 middleware가 다시 실행되지 않는다는 점입니다.

nextjs에서는 서버사이드에서 클라이언트 사이드에서 각각 이 route cache를 무효화하는 방법에 대해서 설명을 하고 있습니다.

그중에 오해를 했던 것이 router.refresh()만으로도 해결이 될 줄 알았지만 원문을 자세히 읽어보니, router refresh는 지금 현재 경로에 대한 route cache만 무효화하는 것이었습니다. 클라이언트 사이드에서 어떻게든 해결하고 싶어서 저희는 window.location.reload로 강제 새로고침을 통해 route cache를 초기화는 것으로 결정했습니다.

Next.js 공식 문서에서는 아래의 방법으로 캐시를 무효화를 하도록 제안하고 있습니다.

  • 서버에서 revalidatePath()를 사용하여 특정 경로의 캐시를 무효화하는 방법
  • 클라이언트에서 router.refresh()를 사용하여 현재 페이지의 데이터를 다시 불러오는 방법

하지만 router.refresh()는 현재 경로에 대한 캐시만 무효화할 뿐, 전체 route cache를 초기화하지는 않습니다.
즉, 로그인/로그아웃과 같이 모든 라우트의 상태가 변하는 경우에는 해결되지 않습니다. 결국 저희 팀은 window.location.reload()를 사용하여 전체 페이지를 새 로고침하는 방식을 선택했습니다. 강제적으로 클라이언트의 route cache를 초기화하여, middleware가 올바르게 실행되도록 했습니다.

There are two ways you can invalidate the Router Cache:

In a Server Action: Revalidating data on-demand by path with (revalidatePath) or by cache tag with (revalidateTag) Using cookies.set or cookies.delete invalidates the Router Cache to prevent routes that use cookies from becoming stale (e.g. authentication).

Calling router.refresh will invalidate the Router Cache and make a new request to the server for the current route.

https://nextjs.org/docs/app/building-your-application/caching#client-side-router-cache

배포 시 lottie-react의 document 참조 문제

Vercel에서 프리뷰로 배포된 브랜치에서 document is not defined 에러가 발생하여 알게 된 문제입니다. 이 문제는 lottie-react 라이브러리에서 발생하는 것으로, lottie-web이 내부적으로 document를 직접 참조하면서 발생하는 오류였습니다. lottie-web의 플레이어가 초기화될 때 document를 사용하기 때문에, 서버 사이드 렌더링(SSR) 환경에서는 document가 정의되지 않아 오류가 발생했습니다.

이 문제를 해결하기 위해, dynamic import를 사용하여 SSR을 비활성화하는 방법을 적용하여, 해당 컴포넌트가 클라이언트에서만 렌더링 되도록 할 수 있습니다. 이를 통해, lottie-web이 클라이언트 환경에서만 document를 참조할 수 있게 되어, 서버 렌더링 중에는 해당 문제가 발생하지 않게 할 수 있습니다.

https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading


전체적인 회고

좋았던 점

  • 깃 활용 능력 향상 : 이번 프로젝트에서는 깃을 적극적으로 활용하며, 리베이스, 체리픽, 스태시 등 다양한 기능을 능숙하게 사용해 볼 수 있었습니다. 이를 통해 팀원들과 협업할 때 버전 관리가 훨씬 원활해졌고, 다양한 상황에서 깃을 자유롭게 다루는 자신감을 얻었습니다. 이전보다 깃 사용에 대한 두려움이 사라졌고, 협업 시 발생하는 충돌을 효과적으로 해결할 수 있게 되었습니다.
  • 적극적인 코드 리뷰 참여 : 기초 프로젝트 때보다 더 자신감 있게 코드 리뷰에 임할 수 있었습니다. 이제는 개선점을 주고받는 과정에서 더 적극적이고 건설적인 피드백을 제공할 수 있게 되었으며, 동료들과 함께 성장하는 느낌을 받았습니다.
  • Next.js 캐싱 이슈 체험 : Next.js에서 발생할 수 있는 캐싱 문제를 실제로 경험하면서, 캐싱의 동작 원리를 깊이 이해하게 되었습니다. 이런 경험은 향후 프로젝트에서 캐싱 문제를 해결하는 데 큰 도움이 될 것입니다.

아쉬웠던 점

  • 배포 과정에 대한 자세한 체험 부족 : 프로젝트에서 Vercel을 사용하여 배포를 진행하다 보니 배포 과정에 대한 세부적인 부분을 직접 체험할 기회가 부족했습니다. 향후 배포 과정에 대한 이해를 더욱 깊게 쌓기 위해 다른 방식의 배포나 인프라 관리를 공부할 필요성을 느꼈습니다.