Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

co-cherry

Tanstack query 활용한 무한 스크롤 페이지 (w.TMDB) 본문

React

Tanstack query 활용한 무한 스크롤 페이지 (w.TMDB)

co-cherry 2026. 3. 29. 19:07

4주차 과제로 영화 사이트(CGV)처럼 TMDB를 통해 영화 데이터를 불러와 무한 스크롤 페이지를 구현해보았다. 

4주차 과제

 

1. 타입 설정

먼저, 영화 리스트와 영화 타입을 먼저 지정해주었다. 

TMDB API 응답 구조를 그대로 정의하되, 필요하지 않을 것 같은 부분은 빼주었다.

MovieListResponse가 API 호출 시 반환되는 최상위 타입이고, 그 안에 Movie 배열이 담기는 구조이다. 

export interface Movie {
    id: number;
    title: string;
    overview: string;
    poster_path: string | null; // 포스터가 없을 수 있으므로 NULL 허용 
    release_date: string;
    vote_average: number;
    popularity: number;
    adult: boolean;
    original_language: string;
    original_title: string;
}

export interface MovieListResponse {
    page: number; // 현재 페이지 번호 
    results: Movie[]; // 영화 목록
    total_pages: number; // 전체 페이지 수
    total_results: number; // 전체 결과 수 
}

 

 

2. API 호출

fetchMovies 하나로 실제 fetch 로직을 공통화하고, endpoint 만 다르게 넘겨서 4개의 함수로 export 했다.

page 파라미터를 받아 페이지네이션을 지원한다. 

import type { MovieListResponse } from "../types/movie"

const BASE_URL = 'https://api.themoviedb.org/3'
const ACCESS_TOKEN = import.meta.env.VITE_TMDB_ACCESS_TOKEN

const fetchMovies = async (endPoint: string, page = 1): Promise<MovieListResponse> => {
    const res = await fetch(
        `${BASE_URL}/movie/${endPoint}?language=ko-KR&page=${page}`,
        {
            headers: {
                Authorization: `Bearer ${ACCESS_TOKEN}`,
                'Content-Type': 'application/json',
            },
        }
    ); 
    if (!res.ok) throw new Error('Failed to fetch movies')
    return res.json(); 
}

export const getPopularMovies = (page?: number) => fetchMovies('popular', page);
export const getNowPlayingMovies = (page?: number) => fetchMovies('now_playing', page);
export const getTopRatedMovies = (page?: number) => fetchMovies('top_rated', page);
export const getUpcomingMovies = (page?: number) => fetchMovies('upcoming', page);

 

3. Query Key Factory

앞 글에서 다룬 Query Key Factory를 처음으로 구현해보았다. 

일반 쿼리는 page 번호를 포함해 페이지 별로 독립 캐시, 무한 쿼리는 infinite 문자열로 구분해 전체를 하나의 캐시로 관리했다.

  • 키를 문자열로 직접 쓰면 오타 위험이 있고 변경 시 여러 곳을 수정해야 함
  • → Factory 패턴으로 한 곳에서 관리하면 일관성 유지 및 자동완성 지원
export const movieKeys = {
  all: ['movies'] as const,
  lists: () => [...movieKeys.all, 'list'] as const,

  // 일반 쿼리용 (페이지 번호 포함)
  popular: (page: number) => [...movieKeys.lists(), 'popular', page] as const,
  nowPlaying: (page: number) => [...movieKeys.lists(), 'nowPlaying', page] as const,
  topRated: (page: number) => [...movieKeys.lists(), 'topRated', page] as const,
  upcoming: (page: number) => [...movieKeys.lists(), 'upcoming', page] as const,

  // 무한 쿼리용 (페이지 번호 없이 'infinite'로 구분)
  popularInfinite: () => [...movieKeys.lists(), 'popular', 'infinite'] as const,
  nowPlayingInfinite: () => [...movieKeys.lists(), 'nowPlaying', 'infinite'] as const,
  topRatedInfinite: () => [...movieKeys.lists(), 'topRated', 'infinite'] as const,
  upcomingInfinite: () => [...movieKeys.lists(), 'upcoming', 'infinite'] as const,
};

 

4. 일반 Query 훅 

useQuery를 사용하는 기본 버전

한번에 1 page 분량의 데이터만 가져오게 했으며, 반환 타입은 이전에 명시한 MovieListResponse 이다. 

data.results 로 바로 영화 목록에 접근할 수 있다. 

import { useQuery } from '@tanstack/react-query';
import { movieKeys } from './movieKeys';
import { getNowPlayingMovies, getPopularMovies, getTopRatedMovies, getUpcomingMovies } from '../../api/movies';

export const usePopularMovies = (page = 1) => {
  return useQuery({
    queryKey: movieKeys.popular(page),
    queryFn: () => getPopularMovies(page),
    staleTime: 1000 * 60 * 5, 
  });
};

export const useNowPlayingMovies = (page = 1) => {
  return useQuery({
    queryKey: movieKeys.nowPlaying(page),
    queryFn: () => getNowPlayingMovies(page),
    staleTime: 1000 * 60 * 5,
  });
};

export const useTopRatedMovies = (page = 1) => {
    return useQuery({
        queryKey: movieKeys.topRated(page),
        queryFn: () => getTopRatedMovies(page),
        staleTime: 1000 * 60 * 5,
    });
};

export const useUpcomingMovies = (page = 1) => {
    return useQuery({
        queryKey: movieKeys.upcoming(page),
        queryFn: () => getUpcomingMovies(page),
        staleTime: 1000 * 60 * 5,
    });
};

 

5. Infinite Query 훅

훅 공통 옵션은 중복을 줄이기 위해 하나의 객체로 분리했다. 

import { useInfiniteQuery } from "@tanstack/react-query";
import { movieKeys } from "./movieKeys";
import { getNowPlayingMovies, getPopularMovies, getTopRatedMovies, getUpcomingMovies } from "../../api/movies";

const infiniteOptions = {
  initialPageParam: 1,
  getNextPageParam: (lastPage: { page: number; total_pages: number }) =>
    lastPage.page < lastPage.total_pages ? lastPage.page + 1 : undefined,
  staleTime: 1000 * 60 * 5,
} as const


export const useInfiniteNowPlayingMovies = () =>
  useInfiniteQuery({
    queryKey: movieKeys.nowPlayingInfinite(),
    queryFn: ({ pageParam }) => getNowPlayingMovies(pageParam),
    ...infiniteOptions,
  })

export const useInfinitePopularMovies = () =>
  useInfiniteQuery({
    queryKey: movieKeys.popularInfinite(),
    queryFn: ({ pageParam }) => getPopularMovies(pageParam),
    ...infiniteOptions,
  })

export const useInfiniteTopRatedMovies = () =>
  useInfiniteQuery({
    queryKey: movieKeys.topRatedInfinite(),
    queryFn: ({ pageParam }) => getTopRatedMovies(pageParam),
    ...infiniteOptions,
  })

export const useInfiniteUpcomingMovies = () =>
  useInfiniteQuery({
    queryKey: movieKeys.upcomingInfinite(),
    queryFn: ({ pageParam }) => getUpcomingMovies(pageParam),
    ...infiniteOptions,
  })

 

일반 Query와의 차이점

  useQuery useInfiniteQuery
queryFn 인자 없음 { pageParam } 자동 주입
반환 data 구조 MovieListResponse { pages: MovieListResponse[], pageParams: number[] }
추가 반환값 없음 fetchNextPage, hasNextPage, isFetchingNextPage
캐시 단위 페이지별 독립 캐시 전체를 하나의 캐시로 관리

 

6. 무한 스크롤 적용

useInview를 사용해 무한 스크롤을 구현했다. 

브라우저 내장 IntersectionObserver를 직접 사용하면 보일러 플레이트가 많이 발생하는데, 

useInview를 이용하면 ref로 특정 HTML 요소 감시 / inView로 감시 중인 요소가 화면에 보이는지 check 해 간단히 구현 가능하다.

 

① pages 데이터 펼치기

const movies = data?.pages.map((page) => page.results).flat() ?? []

 

useInfiniteQuery는 페이지를 아래와 같이 배열로 쌓기 때문에 results만 뽑으면 2중 배열이 반환된다.

따라서, .flat()을 통해 1차원으로 펼치는 것이 필요하다. 

data.pages = [
  { results: [영화1, 영화2, ...] },  // 1페이지
  { results: [영화21, 영화22, ...] }, // 2페이지
]

 

② useInView 설정

const { ref: observerRef, inView } = useInView({ threshold: 0.5 })
  • ref 감시할 DOM 요소에 붙일 ref 
  • inView 해당 요소가 화면에 50% 이상 보이면 true 반환
  • threshold 해당 요소가 threshold 값만큼 보일 때 감지, 0이면 1px만 보여도 감지하며 1이면 100%가 다 보여야 감지한다. 

③ 다음 페이지 요청

  • 감시하는 <div> 태그가 화면에 보인다.
  • 불러올 페이지가 남아 있다.
  • 로딩 중이 아니다. (중복 요청 감지)

위 세 가지 조건을 만족하면 fetchNextPage()를 통해 다음 페이지를 요청한다. 

useEffect(() => {
  if (inView && hasNextPage && !isFetchingNextPage) {
    fetchNextPage()
  }
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])

 

감시 대상 div는 페이지 가장 아래 지정해줬다. 

스크롤이 끝에 닿아 div가 화면에 보이는 순간 inView가 true로 바뀌고 ③이 실행된다.

 

<div ref={observerRef} className="h-4" />

 

⑤ 로딩 상태 구분

로딩 상태일 때마다 skeleton UI를 호출하도록 설정했는데,

  • isLoading은 첫 요청일 때만 true 이고, 
  • isFetchingNextPage는 추가 페이지 요청 중일 때만 true 이므로 

두 상태를 각각 분리해서 skeleton UI를 설정해줬다. 

// 최초 로딩 — 아직 아무 데이터도 없을 때
{isLoading
  ? Array.from({ length: 10 }).map((_, i) => <MovieCardSkeleton key={i} />)
  : movies.map((movie, index) => ( ... ))
}

// 다음 페이지 로딩 — 이미 데이터가 있고 추가 요청 중일 때
{isFetchingNextPage &&
  Array.from({ length: 5 }).map((_, i) => <MovieCardSkeleton key={`next-${i}`} />)
}

 


 

무한 스크롤 동작 흐름

  1. 첫 진입 시, 1 페이지 자동 fetch 하여  Skeleton UI 10개 표시
  2. 스크롤을 내리면 observerRef div가 화면에 등장
  3. inView = true 이면 useEffect 가 실행되어 fetchNextPage() 호출
  4. 다음 페이지 로딩 중이면 isFetchingNextPage = true 가 되므로 Skeleton UI 5개 추가
  5. 로딩 완료 시, pages 배열에 새 페이지 추가되므로 movies 자동 갱신
  6. 마지막 페이지라면 hasNextPage 가 false 이므로 더 이상 fetch 하지 않음 

'React' 카테고리의 다른 글

에러 핸들링과 안전성  (0) 2026.04.26
useMemo, useCallback을 활용한 상세 페이지 구현하기 (w. TMDB)  (0) 2026.04.12
TanStack Query  (0) 2026.03.27
상태 관리 전략 비교  (0) 2026.03.22
커스텀 훅과 책임 분리  (0) 2026.03.15