유튜브 사이트에서도 무한 스크롤을 통해 데이터를 로드하고 있어, 저도 무한 스크롤을 적용해보기로 했습니다.
이를 위해 useInfiniteQuery와 IntersectionObserver를 사용하여 무한 스크롤 기능을 구현했습니다.
1. YoutubeApi 변경
YouTube Data API는 많은 데이터를 페이지 단위로 나눠서 제공하는데, 이 때 사용되는 것이 pageToken과 nextPageToken입니다.
이 매개변수들을 사용하면, API 요청 시 특정 페이지의 데이터를 가져올 수 있습니다.
import axios from "axios";
const YoutubeApi = {
httpClient: axios.create({
baseURL: "https://www.googleapis.com/youtube/v3",
params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
}),
search(keyword: string | undefined, pageToken: string = "") {
return keyword ? this.getVideosByKeyword(keyword, pageToken) : this.getPopularVideos(pageToken);
},
async getVideosByKeyword(keyword: string, pageToken: string) {
const response = await this.httpClient.get("search", {
params: {
part: "snippet",
maxResults: 25,
q: keyword,
pageToken: pageToken,
},
});
const videoIds = response.data.items.map((item: any) => item.id.videoId).join(',');
const videosResponse = await this.httpClient.get("videos", {
params: {
part: "snippet,statistics",
id: videoIds,
},
});
return {
items: videosResponse.data.items.map((item: any) => ({
...item,
id: item.id,
})),
nextPageToken: response.data.nextPageToken,
}
},
async getPopularVideos(pageToken: string) {
const response = await this.httpClient.get("videos", {
params: {
part: "snippet, statistics",
maxResults: 25,
chart: "mostPopular",
regionCode: "KR",
pageToken: pageToken,
},
});
return {
items: response.data.items,
nextPageToken: response.data.nextPageToken,
}
},
};
export default YoutubeApi;
pageToken과 nextPageToken의 역할
- pageToken: pageToken은 현재 요청하는 페이지를 지정하는 토큰입니다. API 요청 시 이 값을 포함하면, 해당 토큰에 맞는 페이지 데이터를 가져옵니다.
- nextPageToken: nextPageToken은 API 응답에서 제공되며, 다음 페이지의 데이터를 요청할 때 사용할 수 있는 토큰입니다. 응답받은 데이터의 마지막에 위치한 토큰으로, 이를 통해 이어지는 페이지의 데이터를 가져올 수 있습니다.
2. Videos 컴포넌트에서 무한 스크롤 구현
이 컴포넌트에서는 useInfiniteQuery와 IntersectionObserver를 사용하여 무한 스크롤 기능을 구현했습니다.
현재는 데이터를 로드하는 과정에서 단순히 Loading 텍스트를 표시하고 있지만, 추후에는 로딩 스피너나 다른 스타일을 적용할 계획입니다.
import { useParams } from "react-router-dom";
import styles from "./Videos.module.css";
import { useInfiniteQuery } from "@tanstack/react-query";
import VideoCard from "../../components/VideoCard/VideoCard";
import { Video } from "../../../public/types";
import YoutubeApi from "../../api/youtubeApi";
import { useCallback, useRef } from "react";
export default function Videos() {
const { keyword } = useParams();
const {
data: videos,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["videos", keyword],
queryFn: async ({ pageParam = "" }) => {
return YoutubeApi.search(keyword, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
staleTime: 60000,
gcTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
initialPageParam: "",
});
const observer = useRef<IntersectionObserver | null>(null);
const lastVideoElementRef = useCallback(
(node: HTMLElement | null) => {
if (isFetchingNextPage) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (node) observer.current.observe(node);
},
[isFetchingNextPage, fetchNextPage, hasNextPage]
);
return (
<>
<ul className={styles.grid}>
{videos?.pages.map((page, pageIndex) =>
page.items.map((video: Video, index: number) => {
if (pageIndex === videos.pages.length - 1 && index === page.items.length - 1) {
return (
<VideoCard ref={lastVideoElementRef} key={video.id} video={video}/>
);
} else {
return <VideoCard key={video.id} video={video}/>
}
})
)}
{isFetchingNextPage && <p>Loading...</p>}
</ul>
</>
);
}
useInfiniteQuery의 사용
- queryKey: 쿼리의 고유 키로, 검색어와 함께 설정되어 동일한 키워드로 검색된 데이터가 캐시될 수 있습니다.
- queryFn: 데이터를 가져오는 함수로, YoutubeApi.search를 사용하여 keyword와 pageToken을 기반으로 결과를 가져옵니다.
- getNextPageParam: nextPageToken을 추출하여 다음 페이지 데이터를 가져올 수 있게 합니다. lastPage는 가장 최근에 가져온 데이터의 페이지를 나타냅니다. nextPageToken이 존재하지 않으면 undefined를 반환하여 더 이상 가져올 페이지가 없다는 것을 나타냅니다.
- initialPageParam: 초기 페이지의 요청을 위한 파라미터로, 빈 문자열로 설정하여 첫 페이지를 가져옵니다.
IntersectionObserver를 이용한 무한 스크롤
1. useRef를 통한 observer 저장
observer는 useRef를 통해 저장됩니다. useRef를 사용하면 컴포넌트가 리렌더링되더라도 동일한 observer 인스턴스를 유지할 수 있습니다. useRef는 .current 프로퍼티를 통해 값에 접근할 수 있습니다.
2. observe.disconnect()의 사용 이유
스크롤을 내리거나 데이터를 추가로 불러올 때, 기존에 관찰하던 요소들이 계속 관찰 상태로 남아 있으면, 스크롤을 올렸을 때 이 요소들이 다시 화면에 나타나면서 불필요한 데이터 요청이 발생합니다.
즉, 한 번 관찰된 요소가 화면에 다시 나타날 때마다 데이터가 중복으로 로드될 수 있습니다.
이를 방지하기 위해 disconnect()를 사용하여 기존에 관찰하던 요소들의 관찰을 중지시키고, 새로운 마지막 요소만을 관찰하게 합니다.
3. useCallback의 사용 이유
disconnect()가 제대로 작동하려면, lastVideoElementRef 함수가 컴포넌트가 리렌더링될 때마다 새로 생성되지 않도록 해야 합니다. 만약 useCallback을 사용하지 않으면, 컴포넌트가 리렌더링될 때마다 lastVideoElementRef 함수가 새로 생성되고, 이로 인해 ref가 변경되면서 IntersectionObserver가 새로운 함수에 연결된 요소를 관찰하게 됩니다. 이 과정에서 disconnect()가 제대로 작동하지 않게 되어, 무한 스크롤이 정상적으로 동작하지 않습니다.
4. 마지막 요소에만 ref를 전달하는 이유
무한 스크롤에서 중요한 점은 마지막 요소가 화면에 나타났을 때 다음 페이지의 데이터를 불러오는 것입니다.
이 때문에 페이지의 마지막 인덱스에 해당하는 요소에만 ref를 전달하여, 그 요소가 화면에 나타날 때만 IntersectionObserver가 동작하도록 합니다. 이를 통해 효율적으로 데이터를 불러올 수 있습니다.
3. VideoCard 컴포넌트에서 forwardRef 사용
VideoCard 컴포넌트는 각 동영상의 정보를 표시합니다. 여기서 중요한 점은 forwardRef를 사용하여 부모 컴포넌트에서 ref를 전달받아 DOM 요소에 접근할 수 있도록 하는 것입니다.
import React, { forwardRef } from "react";
import { Video } from "../../../public/types";
import styles from "./VideoCard.module.css";
import { formatDateTime, formatViewCount } from "../../util";
const VideoCard = forwardRef<HTMLLIElement, { video: Video }>(({ video }, ref) => {
const { title, channelTitle, thumbnails, publishedAt } = video.snippet;
const viewCount = parseInt(video.statistics?.viewCount || "0", 10);
return (
<li className={styles.card} ref={ref}>
<div className={styles['img-container']}>
<img
className={styles.img}
src={thumbnails.medium.url}
alt={title}
/>
</div>
<h3 className={styles.title}>{title}</h3>
<p className={styles.text}>{channelTitle}</p>
<div className={styles.flex}>
<p className={styles.text}>
조회수 {formatViewCount(viewCount)}
</p>
<p className={styles.text}>{formatDateTime(publishedAt)}</p>
</div>
</li>
);
});
export default VideoCard;
forwardRef : 부모 컴포넌트에서 ref를 전달받아 자식 컴포넌트에서 DOM 요소에 접근하려면 forwardRef가 필요합니다. 만약 forwardRef를 사용하지 않고 일반 함수형 컴포넌트로 작성할 경우, ref를 직접 받을 수 없기 때문에 오류가 발생합니다.
HTMLLIElement 타입 : <li> 요소의 타입을 명확히 지정하기 위해 HTMLLIElement를 사용합니다. HTMLElement로 설정하면 <li> 요소와 관련된 특정 속성을 활용할 수 없기 때문에 오류가 발생할 수 있습니다.
'React > Youtube' 카테고리의 다른 글
YouTube Data API를 이용한 Youtube Clone - 6 (0) | 2024.09.04 |
---|---|
YouTube Data API를 이용한 Youtube Clone - 5 (1) | 2024.09.02 |
YouTube Data API를 이용한 Youtube Clone - 3 (0) | 2024.08.29 |
YouTube Data API를 이용한 Youtube Clone - 2 (0) | 2024.08.28 |
YouTube Data API를 이용한 Youtube Clone - 1 (0) | 2024.08.27 |