Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[6주차] SNIFF 미션 제출합니다. #12

Open
wants to merge 67 commits into
base: master
Choose a base branch
from

Conversation

oooppq
Copy link

@oooppq oooppq commented Nov 16, 2023

검색과 디테일 페이지 구현

[key Feature]

  • onChange 이벤트를 debouncing한 검색기능
  • 검색 결과 무한 스크롤
  • 검색결과 loading 시 skeleton component 표시
  • 영화 포스터 클릭시 디테일 페이지로 이동

배포

SNIFF-NETFLIX

[Key Question]

정적 라우팅(Static Routing)/동적 라우팅(Dynamic Routing)이란?

정적 라우팅 하나의 페이지가 하나의 url 요청에만 매칭되는 것입니다.
동적 라우팅 하나의 페이지가 여러 개의(하지만 지정된 형식의) url에 매칭되는 것입니다.

app router을 통해 next 어플리케이션을 개발한다면, app 폴더 내부에서 폴더를 만들고, 해당 폴더에 page.ts(x)를 만든다면 폴더 명으로 정적 라우팅이 가능합니다.
폴더명을 괄호([something])에 넣어 지정하면, 다이나믹 라우팅 기능을 사용할 수 있습니다. 저희가 구현한 앱에서 각 영화의 디테일 페이지는 /movies/123342(영화id) 와 같은 주소로 접근할 수 있습니다. 이 때 영화 id에 따라 다른 정보를 보여주도록 하기 위해, 디렉토리를 movies/[id]와 같이 설정하고, page.tsx 파일을 만들어주었습니다.
스크린샷 2023-11-17 오전 1 16 14
해당 파일 안에서는 params라는 prop을 통해서 id에 접근할 수 있고, 이 id를 통해 tmdb의 api를 사용하여 영화 정보를 얻어올 수 있는 것입니다.
스크린샷 2023-11-17 오전 1 16 35

성능 최적화를 위해 사용한 방법

  • 검색 기능 debounce

검색창에 검색어를 입력할 때, 입력에 따라 실시간으로 검색 결과를 보여주어야 합니다. 때문에, 검색어가 바뀔 때마다 이를 인식하여 api 요청을 보내고, 이를 실시간으로 반영해야 합니다.
만약, 단순한 text 데이터를 네트워크로 요청 후 화면에 표시하는 거라면 이렇게 모든 입력에 반응해도 큰 문제 없지만, 우리가 구현하고자 하는 검색결과에는 이미지가 포함됩니다. 때문에, 네트워크 요청 시의 로딩 시간도 적지않게 걸릴 것이고, 화면도 번잡하게 변화할 것입니다.
따라서, debounce를 적용했습니다. 간단하게 말하면, 일정시간동안 동일(유사)한 요청이 지속된다면, 이전의 요청들을 무시하고 가장 마지막 요청만 수행하는 것이 바로 debouncing 입니다. 이를 도와주는 라이브러리가 있는데, 저는 개인적으로 가벼운 기능이라면 라이브러리를 사용하는 것보다 만들어서 사용하는 것을 선호하기 때문에 만들어서 사용하기로 결정했습니다.

export const useDebounce = <T extends (...args: any[]) => any>(
  fn: T,
  delay: number,
) => {
  // timeout 함수가 closure 환경에 저장됨. timeout interval 사이에 fn이 호출된다면
  // 기존의 timeout 스케줄이 삭제되고, 새로운 timeout 스케줄이 할당됨.
  // 덕분에 지정한 delay 시간동안 fn이 한 번만 호출될 수 있음.

  let timeout: ReturnType<typeof setTimeout>;

  return (...args: Parameters<T>): ReturnType<T> => {
    let result: any;
    if (timeout) clearTimeout(timeout); // 기존의 timeout 삭제
    timeout = setTimeout(() => {
      // 새로운 timeout 할당
      result = fn(...args);
    }, delay);
    return result;
  };
};

위와 같이 closure 개념을 사용하여 debounce를 구현했습니다. 이 custom hook을 사용하여, 0.5초 동안 발생된 onChange 이벤트 중 마지막 event 결과만 반영하도록 했습니다. 이벤트 결과에 따라 state로 관리하는 검색 query를 변경시키고, useEffect를 통해 query의 변화를 감지하여 검색어를 적용해 api요청을 보내게 되는 것입니다.

  • component memoization

가장 간편하고 단순한 최적화 방법입니다. 한 컴포넌트의 상태 변화로 인해 자식 컴포넌트가 불필요하게 리렌더링 될 때 비효율성을 야기합니다.
예를 들어, 무한스크롤을 위해 현재 목록의 끝에 도달했다는 flag가 state로 관리되고 있는데, 이 state가 변경될 때마다 검색 결과들이 리렌더링 됐습니다. 이를 막기 위해, React.memo를 사용하여 컴포넌트가 변화하지 않을 때 memoization 되도록 수정했습니다.
이외에도, 데스크탑 환경에서 홈 페이지의 영화 슬라이더를 사용할 때, hover시 좌우 넘김 버튼이 나타나게 되는데, 이것도 state로 구현한 것이므로, 내부의 영화 포스터들이 리렌더링되지 않도록 memo를 적용했습니다.

  • skeleton component

성능적으로 최적화한 것은 아니고 UX적으로 최적화한 부분입니다. 검색 결과(poster, 영화제목)�는 네트워크를 통해 요청되므로, 로딩시간이 상당히 소요되고 규칙적이지도 않습니다. 이 때 유저들은 답답함을 느낄 것입니다. 이를 막기 위해, 로딩중일 때 skeleton 컴포넌트를 display하여 현재 로딩중인 것을 알린다면 flow가 훨씬 자연스러울 것입니다.

2023-11-17.1.38.47.mov

구현한 결과입니다.
저희는 Next Image의 onLoad 속성을 사용하여 이를 구현했습니다. onLoad를 통해 image의 로딩이 완료됐을 때 실행되는 callback 함수를 지정할 수 있습니다. loading 여부를 state로 관리하고, loading중일 때에는 skeleton 컴포넌트를, loading이 끝나면 실제 컴포넌트를 보여주도록 구현했습니다.

oooppq and others added 30 commits November 6, 2023 17:12
구현 도중, client component로 선언한 LandingLogo에서 계속 document가 undefined 이라는 에러가 발생했다.
아마, server side에서 렌더링 하면서 dom을 구성하는 도중에 발생하는 오류인 것 같다.
따라서, next의 dynamic loading 기능을 사용하여 해당 컴포넌트를 server side에서 아예 렌더링하지 않도록
설정해주었더니 에러가 해결됐다.
feat: netflix landing 구현
navbar가 랜딩 시에는 표시되지 않아야 하므로, pathname에 따라
navbar가 조건부 렌더링되도록 설정했다. 이를 위해 middleware.ts에서
routing시 pathname을 cookie에 저장하도록 설정했고, root layout에서 저장된
pathname에 따라 조건부로 렌더링되도록 하였다.
svgr을 통해 svg 파일을 다루고, 이를 index.ts에 모아 쉽게 컴포넌트 형태로 사용할 수 있도록 구현했다.
기존에는 cookie로 pathname을 기록하여 layout에서 렌더링 조건을 달아
렌더링 여부를 결정했는데, 어차피 navbar 컴포넌트를 client component로 정의하므로,
해당 컴포넌트 내에서 usePathname을 통해 pathname을 확인하여 랜딩 페이지라면 렌더링하지
않도록 하는 방법을 사용했다.
아직 구현되지 않은 페이지의 라우팅을 막고, common 폴더를 만들어
디렉토리를 이동했다.
Link(a) 로 변경하여 클릭시 어딘가로 이동하도록 설정했다. 추후,
홈화면 윗쪽의 포스터 표시를 클릭한 content의 포스터로 보여주도록 할 예정이다.
그 과정에서 tailwind의 plugin을 사용하여 scroll-hide라는 custom style을 정의했다.
기존에는 횡이동 스크롤로만 움직일 수 있었으나, 기능상 마우스로 드래그하는 것이 꼭 필요하기 때문에
useState와 useRef를 사용하여 마우스 drag를 구현했다. 그 과정에서 해당 컴포넌트를 client component로
변경했다.
데스크탑 환경에서 마우스 hover시 넘기기 버튼이 보여지도록 구현했다.
drag를 통해 슬라이더를 이동시킬 수 있도록 했으나, 데스크탑 환경에서의 사용성이 좋은 것 같지 않아,
버튼을 통해 움직이도록 수정했다.
[feat] 홈화면 슬라이더 컴포넌트 구현
oooppq and others added 25 commits November 14, 2023 23:34
검색어가 없을 떄의 default 데이터를 설정해야 하므로, popular category의 영화들을
불러올 수 있도록 구현했다.
검색 페이지 하단 padding을 추가했고, 제목이 2줄 이상이 될 때 ...으로 표시하도록 구현했다.
구현하면서 input 컴포넌트 내부에서 query를 저장하는 state를 없애고,
page의 root 컴포넌트에서 선언한 keyword state를 통해 검색어가 비었는지 판단하기로 결정했다.
api 로딩 중에 어색한 flow를 방지하기 위해 init이라는 state를 선언했다. 추후에 loading이나
스켈레톤 컴포넌트를 활용할 수 있을 것 같다.
Image의 onLoad 속성을 사용하여 로딩이 완료되기 전에는 skeleton component를 display 하도록 구현했다.
skeleton component는 pulse animation을 사용하여 실제로 로딩이 진행중인 느낌을 전달한다.
[feat] search 페이지 구현 완료
�[feat] detail페이지 구현완료
[chore] minor 에러 수정
[chore] minor 한 변경사항 적용
[chore] minor한 버그 수정
추가한줄알았는데, 까먹었었음.
Copy link

@wokbjso wokbjso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 수준높은 코드 잘 봤습니다. 코드리뷰를 하면서 많은 것들을 배울 수 있었습니다. 시간상 양질의 코드리뷰를 못해드린 것 같아 죄송하고 정말 수고 많으셨습니다!!

@@ -0,0 +1,85 @@
/* eslint-disable react-hooks/exhaustive-deps */
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 페이지는 최상단에 use client 를 선언하셨는데 시간이 없어 리팩토링을 하지 못한것인지 특별한 이유가 있는지 개인적으로 궁금하네요 ㅎㅎ

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지 내에서 리엑트 훅을 사용하려면 csr로 페이지를 구성해야 하는 것 같드라구요!

Comment on lines +19 to +47

useEffect(() => {
if (!keyword) {
(async () => {
// next의 fetch는 같은 요청에 대해 caching을 진행하므로,
// 첫 요청 이후에는 caching된 데이터를 가져올 것(?)
const defaultMovies = await getMovies('popular');
setMovies(defaultMovies);
setPageNum(1);
setTotalPageNum(1);
setInit(false);
})();
}
}, [keyword]);

useEffect(() => {
if (pageNum > 1) {
(async () => {
const data = await getSearchedMovies(keyword, pageNum);
setMovies([...movies, ...data.results]);
})();
}
}, [pageNum]);

useEffect(() => {
if (inview && pageNum < totalPageNum) {
setPageNum(pageNum + 1);
}
}, [inview]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필요한 부분을 세부 클라이언트 컴포넌트에 선언한다면 최상단에 'use client'를 없앨수도 있겠네요 ㅎㅎ 저희도 시간 때문에 서치페이지 'use client'로 선언해버렸는데 이제 리팩토링 해봐야됩니다 ㅋㅋㅋㅋㅋㅋ

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

약간 귀찮아서 걍 페이지 자체를 csr로 구성했는데 ssr로 구성하고 내부에 csr 컴포넌트를 넣는 것이 성능적으로 뛰어나다면 앞으로 그렇게 해봐야겠네요ㅎㅎ

Comment on lines +68 to +70
setInit={setInit}
handleOnChangeQuery={useDebounce(handleOnChangeQuery, 500)}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

debounce 를 통해 과도한 데이터 fetching 방지하신 디테일 좋네요 ㅎㅎ

Comment on lines +35 to +42
>
<Image
src={url + content.poster_path}
alt={content.title}
className={imageClasses}
width={width}
height={isPreview ? width : height}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

next/image 를 통한 최적화 좋은 것 같습니다 ㅎㅎ

Comment on lines +1 to +3
export const useDebounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제네릭 함수 사용 좋네요 ㅎㅎ

Comment on lines +7 to +9
<button className="px-[2px]">TV Shows</button>
<button className="px-[2px]">Movies</button>
<button className="px-[2px]">My List</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일이 같은 태그가 3번 중복되므로 텍스트를 리스트 변수로 묶어서 map 함수로 렌더링하는 것도 좋을 것 같네요 ㅎㅎ

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 귀찮아서 그냥 수기로 썼는데, 확실히 그렇게 하는게코드 중복에 도움될 것 같아요~~

Comment on lines +38 to +46
<ContentsSlider
title="Preview"
isRanking={false}
isPreview={true}
contents={Nowplaying}
/>
<ContentsSlider title="Nigeria Today" isRanking={true} contents={Top} />
<ContentsSlider title="Popular" isRanking={false} contents={Popular} />
<ContentsSlider title="Upcoming" isRanking={false} contents={Upcoming} />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정보를 리스트 선언 후 map 함수로 렌더링 한다면 가독성과 추후 슬라이더 추가시에도 편리할 것 같다는 개인적인 의견 드립니다 ㅎㅎ

Suggested change
<ContentsSlider
title="Preview"
isRanking={false}
isPreview={true}
contents={Nowplaying}
/>
<ContentsSlider title="Nigeria Today" isRanking={true} contents={Top} />
<ContentsSlider title="Popular" isRanking={false} contents={Popular} />
<ContentsSlider title="Upcoming" isRanking={false} contents={Upcoming} />
const sliderData=[
{
title: "Preview",
movieData: await getMovies('now_playing'),
isRanking: false,
isPreview:true
},
{
title: "Nigeria Today",
movieData: await getMovies('top_rated'),
isRanking: true,
isPreview:false
},
{
title: "Popular",
movieData: await getMovies('popular'),
isRanking: false,
isPreview:false
},
{
title: "Upcoming",
movieData: await getMovies('upcoming'),
isRanking: false,
isPreview:false
}
]
....
{sliderData.map((data,index)=>(
<ContentsSlider
key={data.title}
title={data.title}
isRanking={data.isRanking}
isPreview={data.isPreview}
contents={data.movieData}
/>
))}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 배워갑니다~!

Comment on lines +36 to +41
- 팀 repo 그대로 vercel에 배포하려면 요금을 지불해야 함
- 따라서, 팀 repo를 오대균 github에 fork 하여, 오대균 repo에서 배포 함
- 수동으로 배포하면 번거로우니 다음과 같은 flow로 배포되도록 설정함
> 1. team repo master branch에 push 발생
> 2. 오대균 repo의 master branch에 변경사항 자동으로 적용
> 3. vercel이 이를 인식하고 자동으로 배포 반영
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희는 vercel 을 사용한적이 없어 아직 팀 레포 그대로 배포하도 공짜로 사용 가능하여 그냥 했는데, github action 을 이용하여 이를 해결하신점 매우 멋지네요!!!^^

Copy link

@geeoneee geeoneee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 보면서 많이 배워갑니다:) 과제 수고 많으셨어요:)

setInit(false);
})();
}
}, [keyword]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서치 페이지에서 검색 전 popular 데이터 불러올 때 몇개의 데이터에 문제가 있는 것 같아요..!
스크린샷 2023-11-19 오후 4 14 57

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 보니까 vercel 뭐 가격 정책때문에 이미지 요청이 막히는 것 같네요ㅋㅋㅋ.. 뭐 무슨 limit이 있다고 했는데 오늘 딱 그 limit에 도달했나봐요 함 해결해볼게요ㅜㅜ

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

next 이미지 optimization 설정을 꺼주니까 해결되었네요!

</div>
</Link>
);
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 화면에서만 그런건지 모르겠는데, 검색을 했을 때 영화가 다 뜨지 않고, 개발자 도구 콘솔창에는 402 오류가 뜨네요🥲

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위와 같은 오류 같아요ㅋㅜㅜ

) : (
<div className="absolute w-2/5 h-full flex items-center justify-center bg-[#545454]">
<Image
src={defaultImageUrl}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스니프팀도 포스터가 없을 때에는 defaultImageUrl 뜨게 해놓았군요!! 좋은 것 같습니다:)

}, delay);
return result;
};
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onChange 이벤트를 debouncing하도록 검색 기능하신 점 너무 좋은 것 같습니다! 저도 다음에 사용해봐야겠어요:)

vercel 가격 정챙때문에 이미지 요청이 막히는 것 같아 시도해본다.
Copy link

@dhshin98 dhshin98 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번주 과제 수고하셨습니다~ 성능 최적화를 위해 고민하신게 느껴지는 코드였습니다 :) 두분 다 너무 수고하셨어요~~ㅎㅎ

);
};

export default SearchResultElement;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

 저도 검색을 했을때 보니까 payment required 가 뜨는 것 같네요.. 구글링 해봤는데 정확한 이유는 찾지 못했어요 ㅜ 아래 링크는 찾아본건데 참고해보셔도 될 것 같아요..!!
 
https://jha-memo.tistory.com/199

를 쓰고 리뷰를 마무리하는 와중에 다시 새로고침해보니까 헤결이 되었네요..!! 빠르게 찾아서 해결하셨군요ㅋㅋㅋ👍

return (
<Link href={clickUrl}>
<div className="relative">
{isLoading ? <SkeletonElement /> : null}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loading 중일때 skeletonElement뜨게 하는 아이디어 베워갑니다~!

Comment on lines +49 to +61
const handleOnChangeQuery = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const query = e.target.value.trim();
if (query) {
const data = await getSearchedMovies(query);
setMovies(data.results);
setPageNum(data.page);
setTotalPageNum(data.total_pages);
}
setKeyword(query);
if (!query) setInit(true);
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleChangeQuery를 통해 검색어 변화가 있을때 이렇게 처리를 할수도 있겠네요..! 저희는 불러온 리스트에서 필터링을 했었는데, 이런 방법도 있군요!

Copy link
Member

@leejin-rho leejin-rho left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오늘도 구현하신 코드가 깔끔하고 배워갈 내용이 많은 것 같습니다! 수고하셨습니다~

import { getMovies } from '@/utils/Api';

interface BrowseProps {
searchParams: { [key: string]: string | undefined };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 useSearchParams를 사용했는데 직접 구현하신 거 좋은 것 같습니다

Comment on lines +38 to +46
<ContentsSlider
title="Preview"
isRanking={false}
isPreview={true}
contents={Nowplaying}
/>
<ContentsSlider title="Nigeria Today" isRanking={true} contents={Top} />
<ContentsSlider title="Popular" isRanking={false} contents={Popular} />
<ContentsSlider title="Upcoming" isRanking={false} contents={Upcoming} />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 배워갑니다~!

import dynamic from 'next/dynamic';

const page = () => {
const Logo = dynamic(() => import('@/components/LandingLogo'), {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 다음에는 dynamic을 이용해서 구현해봐야겠어요~!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants