2025-02-21
2월 3일부터 진행된 약 2주간의 중급 프로젝트를 마무리했습니다. 두 번째 협업프로젝트라 첫 번째 프로젝트 때보다 더 기여를 많이 해보고 싶은 마음에 욕심이 많았던 프로젝트였던 것 같습니다.
[github repo] : https://github.com/ToKyun02/Taskify
[taskify service url] : https://taskify-lab.vercel.app/mydashboard
중급 프로젝트도 여러개의 프로젝트 중에 팀원과 회의를 통해서 하나를 골라 진행하는 방식이었습니다. 프로젝트 기간은 2주로 동일하지만 초급 프로젝트 때보다 다뤄야 하는 페이지, 컴포넌트, api 등이 훨씬 많았기에 여러 가지를 고민 후에 프로젝트를 선정하게 되었습니다.
기초 프로젝트에 비해서 다뤄야할 컴포넌트가 독특하거나, 실제 제품으로서 사용자의 생산성을 높여주는 주제의 프로젝트로 의견이 일치하게 되어, 칸반기능으로 일정관리를 하는 프로젝트인 '태스키파이'를 선택하게 되었습니다.
초급 프로젝트에서는 기술스택을 선정하는 것에 있어서 크게 생각을 하지 않고 적용을 했습니다. 하지만 이번 두 번째 프로젝트에서는 팀원들과 사용할 라이브러리들에 대해 충분한 토론을 거친 후에 사용하게 될 라이브러리들을 선택했습니다.
예전 퍼블리싱을 담당하면서 여러 인터랙션을 시도했으나, 리액트 프로젝트에서 인터랙션을 구현한 경험은 부족했습니다. 이번 기회를 통해 랜딩 페이지 작업을 맡아 GSAP 대신 Motion을 사용해 애니메이션을 구현해 보았습니다.
GSAP은 명령형으로 애니메이션을 다루고 timeline을 제공하는 반면, Motion은 선언적으로 애니메이션을 작성할 수 있어 리액트 프로젝트에서 더 직관적이고 유연한 방식으로 사용할 수 있었습니다. 스크롤 애니메이션의 경우, GSAP의 ScrollTrigger가 더 직관적이었으나, Motion에서는 다소 복잡하게 구현되어 시간이 더 걸렸습니다. 랜딩페이지를 만들면서 느낀 두 애니메이션 라이브러리의 장점을 정리해 보았습니다.
랜딩페이지 첫 섹션에는 lottie도 추가하여 좀 더 살아 숨 쉬는듯한 느낌을 주려고 했습니다. 직접 ai 파일에서 레이어를 분리 후, after effect에서 모션작업을 한 후에 bodymovin 플러그인으로 lottie json파일을 추출해 적용했습니다. lottie-react 라이브러리를 react 프로젝트에서 사용할 때는 겪지 못했는데, 이번 nextjs를 사용하면서 생기게 된 문제가 있어 이 부분은 트러블슈팅 섹션에 정리했습니다.
공용 컴포넌트를 개발할 때, 잘 만들어진 UI 라이브러리를 참고하여 최대한 비슷한 방식으로 구현하는 것을 목표로 삼았습니다. 확장성 있고 사용하기 편한 컴포넌트를 만들기 위해, 기존 UI 라이브러리들의 설계 방식과 인터페이스 구조를 분석하는 것이 중요하다고 판단하였고, 특히 shadcn(radix) 라이브러리를 많이 참고했습니다.
이러한 원칙을 바탕으로 확장성과 재사용성이 높은 공용 컴포넌트를 제작하는 데 집중했습니다.
window의 기본 함수인 confirm과 alert을 모달형태로 커스텀하게 만드려고 작성한 공용훅입니다. 마침 프로젝트 내에서 전역상태관리를 위해서 미리 세팅해 둔 zustand가 있어서 활용하게 되었습니다.
프로젝트 내에서 모달 컴포넌트가 자주 사용되었기 때문에, 초기 회의부터 어떻게 재사용성을 높일지 고민했습니다. 팀원들이 각자 조사를 해오고 토론한 끝에, 명령형 방식으로 모달을 제어하는 방향을 선택했습니다.
특히 toast, alert, confirm과 같은 모달은 열리고 닫히는 흐름이 직관적으로 명령형 방식과 유사했기 때문에, useImperativeHandle 훅을 사용하여 모달을 ref를 통해 제어할 수 있도록 구현했습니다.
리액트 공식문서에서는 useImperativeHandle을 사용하기 전에, 명령형 방식이 정말 필요한지 고민해 보라고 권장합니다. 하지만, 이번 프로젝트에서는 모달의 사용 패턴을 고려했을 때 명령형 방식이 더 적합하다고 판단하여 적용하게 되었습니다.
왜 명령형적으로 모달을 사용하게 되었는가?
[모달 컴포넌트 작성코드]
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);
프로젝트 전반적으로 데이터 패칭을 클라이언트 사이드에서 진행하도록 작업되었는데, 마이페이지로 접근할 때는 서버사이드에서 데이터를 패칭 해서 form의 기본값으로 넘겨주는 방식으로 작업해 보았습니다.
클라이언트 사이드에서 내 정보를 불러오면, 잠시동안 기본 데이터가 비어있는 타이밍이 존재하는 이유로 해당 데이터 패칭 방식을 사용했습니다. 이렇게 되면 form은 서버에서 미리 가져온 데이터를 initialData로 쓰게 되었고, 프로필 업데이트 이후 클라이언트에서는 router.refresh()를 통해서 지금 경로의 서버컴포넌트의 데이터 재패칭 및 재랜더링 을 하게 하여 업데이트해주었습니다.
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 공식 문서에서는 아래의 방법으로 캐시를 무효화를 하도록 제안하고 있습니다.
하지만 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
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