[6주차] CONX 김홍엽 & 오유진 과제 제출합니다.#16
Conversation
chore: 프로젝트 초기 세팅
chore: 이슈 및 PR 템플릿 추가
chore: Husky 설정
chore: 개인 레포 미러링 배포 워크플로우 추가
chore: coderabbit 설정
feature: tmdb api 연동 및 환경변수 설정
Feature/08 common layout
Feature/09 movie service
Feature/12 featured content
…nd-splash-hydration refactor: no-scrollbar 유틸리티 이동 및 SplashScreen 하이드레이션 수정
chore: vercel 배포 SPA 설정
chore: SVGR 설정 및 SVG 아이콘 마이그레이션
style: 폰트 사이즈 단위 px에서 rem으로 변환
release: v3 배포
refactor: 홈 페이지 렌더링 CSR -> SSR + ISR 개선
release: v4 배포
feat: detail page 제작 및 링크 연결
Feature/seo metadata 설정
release: v5 배포
fix: vercel rewrites 설정 제거
release: v6 배포
| { | ||
| "rewrites": [{ "source": "/(.*)", "destination": "/" }] | ||
| } |
There was a problem hiding this comment.
현재 이 rewrites 설정이 모든 경로(/(.*))를 루트(/)로 강제 매핑하고 있는 것 같아요!
이로 인해 url은 바뀌지만, 정작 화면을 갈아끼울 데이터는 계속 '홈' 것만 오기 때문에 화면 전환이 일어나지 않고 멈춰있는 현상이 보이는 것 같습니다
| const [movies, setMovies] = useState<Movie[]>([]); | ||
| const [page, setPage] = useState(1); | ||
| const [totalPages, setTotalPages] = useState(0); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [isInitialLoad, setIsInitialLoad] = useState(true); | ||
|
|
||
| const sentinelRef = useRef<HTMLDivElement | null>(null); | ||
| const [prevQuery, setPrevQuery] = useState(query); | ||
|
|
||
| // query 변경 시 리셋 | ||
| if (prevQuery !== query) { | ||
| setPrevQuery(query); | ||
| setMovies([]); | ||
| setPage(1); | ||
| setTotalPages(0); | ||
| setIsInitialLoad(true); | ||
| } | ||
|
|
||
| // 데이터 fetch | ||
| useEffect(() => { | ||
| let stale = false; | ||
|
|
||
| const fetchResults = async () => { | ||
| setIsLoading(true); | ||
| const data = await movieService.searchMulti(query, page); | ||
|
|
||
| if (stale) return; | ||
|
|
||
| if (data) { | ||
| setMovies((prev) => { | ||
| if (page === 1) return data.results; | ||
| const existingIds = new Set(prev.map((m) => m.id)); | ||
| return [...prev, ...data.results.filter((m) => !existingIds.has(m.id))]; | ||
| }); | ||
| setTotalPages(data.total_pages); | ||
| } | ||
| setIsLoading(false); | ||
| setIsInitialLoad(false); | ||
| }; | ||
|
|
||
| if (query) fetchResults(); | ||
|
|
||
| return () => { | ||
| stale = true; | ||
| }; | ||
| }, [query, page]); |
There was a problem hiding this comment.
TanStack Query의 useInfiniteQuery를 도입하면 이 모든 영역을 단 하나의 훅으로 대체할 수 있을 것 같습니다!
특히 이미 검색했던 결과를 다시 보여줄 때 별도의 로딩 없이 즉시 보여주는 캐싱 기능까지 챙길 수 있어서 UX 면에서도 훨씬 유리할 것 같습니다.
|
|
||
| export function useDebounce<T>(value: T, delay: number = 300): T { | ||
| const [debouncedValue, setDebouncedValue] = useState(value); | ||
|
|
||
| useEffect(() => { | ||
| const timer = setTimeout(() => setDebouncedValue(value), delay); | ||
| return () => clearTimeout(timer); | ||
| }, [value, delay]); | ||
|
|
||
| return debouncedValue; | ||
| } |
There was a problem hiding this comment.
검색 입력마다 API를 호출하지 않고 useDebounce를 적용해 주셔서 네트워크 비용을 줄일 수 있을 것 같습니다!
| // 시간 변환 유틸리티 (nn분 -> n시간 n분) | ||
| const formatRuntime = (minutes: number) => { | ||
| const hours = Math.floor(minutes / 60); | ||
| const mins = minutes % 60; | ||
| return hours > 0 ? `${hours}시간 ${mins}분` : `${mins}분`; | ||
| }; | ||
|
|
| <Link | ||
| key={movie.id} | ||
| href={`/movie/${movie.id}${title === 'Netflix Originals' ? '?type=tv' : ''}`} | ||
| className="relative shrink-0 overflow-hidden rounded-xs" | ||
| style={{ width: cardSize.width, height: cardSize.height }} | ||
| > |
There was a problem hiding this comment.
현재 영화와 TV 상세 페이지를 구분하기 위해 title 텍스트를 비교해서 쿼리 스트링(?type=tv)을 붙여주고 계신데,
TV 시리즈인데 /movie/... 경로를 사용하는 것이 구조적으로 어색한 것 같습니다.
그래서 상세 페이지 폴더 구조를 app/[mediaType]/[id]/page.tsx로 변경하시는 것도 괜찮을 것 같습니다!
이렇게 바꾸면 이 Link 태그에서도 하드코딩된 문자열 비교 없이 href={/${movie.media_type}/${movie.id} 처럼 데이터 기반으로 훨씬 안전하게 경로를 생성할 수 있습니다.
상세 페이지 내에서도 searchParams를 기다릴 필요 없이 params에서 바로 타입을 읽어올 수 있어 코드가 훨씬 깔끔해질 것 같습니다!
yiyoonseo
left a comment
There was a problem hiding this comment.
과제 수고많으셨습니다! 코드가 깔끔하게 잘 짜여 있어서 보기 좋았어요😍
There was a problem hiding this comment.
이슈 템플릿을 엄청 세세하게 나눠주셨네요~ 저는 늘 하나로 합치기만 했었는데 이렇게 나누는 방법도 고려해보아야겠어용😊
| <button className="flex flex-col items-center gap-1 text-white"> | ||
| <AddIcon className="h-6 w-6" /> | ||
| <span className="text-caption1">My List</span> | ||
| </button> |
There was a problem hiding this comment.
a 태그 내에 button 태그가 있으면 웹 접근성이 저하되는 문제가 있습니다! 현재는 상호작용이 없어 괜찮을 것 같지만 한 번 참고해보시면 좋을 것 같아용
| observer.observe(el); | ||
| return () => observer.disconnect(); | ||
| }, [observerCallback]); | ||
|
|
There was a problem hiding this comment.
useEffect 내에서 observerCallback을 의존성으로 가지고 있는데, isLoading이나 page가 바뀔 때마다 옵저버가 해제되고 다시 생성되는 걸로 보입니다.
isLoading과 page 정보를 useRef에 담아 관리하면 콜백 안에서 최신 값을 참조할 수 있습니다!
| params, | ||
| searchParams, | ||
| }: { | ||
| params: { id: string }; |
There was a problem hiding this comment.
여기도 Promise로 타입 일관성을 지키는 편이 좋아 보입니다
params: Promis<{ id: string }>; 이렇게요!
| </button> | ||
| </Link> | ||
| ); | ||
| } |
There was a problem hiding this comment.
name 존재 여부만으로 tv/movie를 판단하면 person 타입이 잘못 라우팅될 수 있을 것 같습니다. media_type 기준으로 분기하면 더 안전할 것 같아용!
| </button> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
삭제 아이콘 클릭 시 onChange('')로 검색어를 초기화하도록 구현한 점이 직관적이네요! value와 onChange를 props로 받는 controlled component 형태라 구조도 깔끔한 것 같아용!
배포 링크 : https://ceos-week5-next-netflix-23rd.vercel.app/
오유진
기존 Movie 인터페이스는 목록 노출을 위한 최소한의 데이터만 포함하고 있어 상세 페이지에서 필요한 정보를 모두 담기엔 부족했습니다. 중복 코드를 방지하고 기존 타입을 재사용하기 위해 extends 키워드를 활용해 MovieDetail 인터페이스를 설계했습니다. 이를 통해 타입 안정성을 확보하고, 상세 페이지 전용 데이터를 효율적으로 관리했습니다.
영화와 TV 시리즈의 데이터 구조가 다르다는 점(runtime vs seasons 등)을 파악했습니다. 이에 실제 넷플릭스 서비스와 최대한 동일한 경험을 주기 위해 세 가지 케이스로 로직을 분기했고 이용자가 어떤 콘텐츠를 클릭하더라도 어색함 없이 정확한 메타데이터를 확인할 수 있는 UI를 구현했습니다.
Case 1 (영화): 분 단위 시간을 "n시간 n분"으로 변환하여 표시
Case 2 (단일 시즌 TV쇼): "에피소드 n개"로 분량 표시
Case 3 (다중 시즌 TV쇼): "시즌 n개"로 전체 규모 표시
generateMetadata를 활용해 상세페이지 진입 시 영화 제목과 줄거리가 브라우저 탭 이름과 미리보기 카드에 실시간으로 반영되도록 동적 SEO를 구축했습니다.
김홍엽
검색 기능 구현
검색 API와 페이지네이션 API를 추가하면서, 검색 페이지 UI를 SearchBar, SearchItem, SearchResults, TopSearches, SearchItemSkeleton 등 작은 컴포넌트 단위로 분리해서 진행했습니다. 각 컴포넌트의 책임을 분리함으로써 수정과 디버깅이 훨씬 수월해지는 것을 느꼈습니다.
useDebounce커스텀 훅으로 디바운스를 적용해서 불필요한 API 호출을 줄이고,Intersection Observer로 무한 스크롤을 붙이면서 UX와 성능에 대해 고민하는 경험을 할 수 있었습니다.CSR → SSR/ISR 전환
홈 페이지를 CSR에서 SSR/ISR로 전환하며, 서버 컴포넌트와 클라이언트 컴포넌트의 역할이 왜 분리되어야 하는지 코드를 직접 짜보면서 이해할 수 있었습니다. 서버 전용 API Service Layer
movieServiceServer.ts를 따로 두고, 데이터를 서버에서 fetch해 클라이언트 컴포넌트에 props로 넘기는 구조로 바꿨더니 책임 경계가 보다 선명해졌습니다.다만 응답이 지연되면 페이지 자체가 느려질 수 있고 사용자가 계속 대기해야 한다는 단점이 있기 때문에, 타임아웃을 설정하여 보다 방어적으로 구현했습니다.
SVGR 설정
SVGR을 설정해서 SVG 파일을 React 컴포넌트로 바로 import할 수 있게 만들고, 기존 아이콘을 전부 마이그레이션했습니다. 아이콘 관리가 깔끔해진 것도 좋았고,
currentColor를 활용해 아이콘 클릭 시 색이 바뀌지 않던 문제를 해결할 수 있었습니다.배포
Vercel 배포를 위해
vercel.json에 SPA용 rewrites 설정을 추가했다가, Next.js에서는 이 설정이 오히려 방해가 된다는 점을 알게 되었습니다.React Router와 달리, Next.js는 파일 시스템 기반 라우팅을 서버가 직접 처리하고, SSR/ISR이 동작하면서 각 경로마다 서버가 응답을 만들어 내려주는 구조이기 때문에, 여기에 모든 경로를 index.html로 보내버리면 SSR이 동작할 기회 자체를 빼앗는 셈이었습니다.
그래서 rewrites 설정을 걷어내고, Vercel이 Next.js 프로젝트를 자동 감지해 라우팅을 잡도록 두는 방식으로 문제를 해결했습니다.
총평
이번 과제를 통해 렌더링 전략 선택, 커스텀 훅을 통한 로직 분리, 빌드 도구 설정과 배포 환경 구성까지 프론트엔드 개발의 여러 영역을 두루 경험하며 많은 것을 배울 수 있었습니다.