co-cherry
useMemo, useCallback을 활용한 상세 페이지 구현하기 (w. TMDB) 본문

이전에 구현한 무한 스크롤 영화 페이지에 상세 페이지 모달을 제작해보았다.
이번 시간에는 아래 항목들을 학습해보려고 한다.
- 바운더리 패턴
- useMemo / useCallback
- React Devtools
Suspense / Error Boundary 패턴
Loading / Error UI를 상위 컴포넌트로 올려서 한 곳에서 처리하는 관심사 분리 패턴
- Suspense 로딩 중에 보여 줄 대체 UI로, 자식 컴포넌트들이 로딩을 완료할 때까지 fallback 표시
- Error Boundary Error를 catch 하는 컴포넌트로 에러 발생 시, fallback 표시
*fallback 로딩/에러 중 보여 줄 대체 UI
throw Promise → Suspense가 catch → fallback(로딩UI) 표시
throw Error → ErrorBoundary가 catch → fallback(에러UI) 표시
<ErrorBoundary fallback={<ErrorUI />}> ← 에러 처리 담당
<Suspense fallback={<Spinner />}> ← 로딩 처리 담당
<PostList /> ← 데이터만 신경씀
</Suspense>
</ErrorBoundary>
이 방식을 사용하면, 여러 컴포넌트가 모두 준비될 때까지 하나의 fallback으로 기다렸다가 한 번에 렌더링하게 된다.
상세 페이지 작성을 위해 이전 Movie 인터페이스를 확장해 MovieDetail 타입을 작성한다.
export interface Genre {
id: number;
name: string;
}
export interface MovieDetail extends Movie {
genres: Genre[];
runtime: number;
tagline: string;
status: string;
vote_count: number;
backdrop_path: string | null;
}
상세 데이터는 리스트가 아닌 MovieDetail 객체를 반환받으므로 /movie/{movieId} 경로로 데이터를 fetch 받는다.
export const getMovieDetail = async (movieId: number): Promise<MovieDetail> => {
const res = await fetch(
`${BASE_URL}/movie/${movieId}?language=ko-KR`,
{ headers }
)
if (!res.ok) throw new Error('Failed to fetch movie detail')
return res.json()
}
Suspense를 이용하기 위해 기존에 사용하던 useQuery 대신 useSuspenseQuery를 이용한다.
useSuspenseQuery는 useQuery의 Suspense 버전으로 상태를 직접 반환하지 않고 throw로 위임한다.
- 같은 queryKey면 재요청 없이 저장된 데이터 반환
- 진행 중이면 Promise throw → 가장 가까운 <Suspense> 가 받아 fallback 실행
- 실패하면 Error throw → 가장 가까운 <ErrorBoundary> 가 받아 fallback 실행
export const useGetMovieDetail = (movieId: number) =>
useSuspenseQuery({
queryKey: movieKeys.detail(movieId),
queryFn: () => getMovieDetail(movieId),
staleTime: 1000 * 60 * 5,
})
모달 상세 페이지 구현 후, fallback으로 사용할 skeletonUI를 아래와 같은 형태로 컴포넌트를 생성한다.

function MovieDetailModal({ movieId, onClose }: Props) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden relative"
onClick={(e) => e.stopPropagation()}>
<button
onClick={onClose}
className="absolute top-3 right-3 z-20 w-8 h-8 flex items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60 transition-colors cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<ErrorBoundary fallback={<ModalError />}>
<Suspense fallback={<ModalSkeleton />}>
<MovieDetailContent movieId={movieId} />
</Suspense>
</ErrorBoundary>
</div>
</div>
)
}
Suspense와 Error Boundary는 Content 영역을 대체함으로 모달 창과 닫기 버튼 밑에 구성한다.
- 로딩 중 → ModalSkeleton 렌더링
- 에러 시 → ModalError 렌더링
- 성공 시 → Content 렌더

useMemo / useCallback

위까지 구현했더니 모달을 열고 닫을 때마다 TanstackQuery가 전체 리렌더 되는 문제가 발생한다.
이를 해결하기 위해 useMemo와 useCallback에 대해 배워보자.
useMemo
함수의 결과 값을 메모이제이션하여 비용이 많이 드는 계산을 최적화할 때 사용
const value = useMemo(() => {
return calculate();
}, [item]);
- 의존성 배열 안의 값이 업데이트 될 때만 콜백 함수를 다시 호출해 메모리에 저장된 값을 업데이트
- 의존성이 변경되지 않으면 이전에 계산한 값을 그대로 재사용
useMemo는 외부 상태에 의존하지 않는 순수한 계산에만 사용하는 것이 원칙
useCallback
함수를 메모이제이션하여 필요할 때만 생성하도록 하는 훅
useMemo가 값의 계산 결과를 캐싱하는 것과 달리, useCallback은 함수 자체를 캐싱한다.
- 함수는 객체이므로 리렌더링 될 때마다 새로 만들어지면 내용이 같아도 참조값이 달라져 항상 새 props로 인식하게 됨
const handleIncreaseCount = useCallback((number: number) => {
setCount(count + number);
}, [count]);
- 의존성 배열의 값이 변경되지 않으면 이전에 생성한 함수의 참조 값 그대로 사용
의존성 배열이 빈 배열이면 useCallback은 항상 같은 함수 참조를 반환해 함수 내부에서 참조하는 상태값이 초기값으로 고정되어 아무리 클릭해도 값이 변하지 않는 문제가 발생할 수 있다.
따라서, 함수 내부에서 참조하는 상태나 props는 반드시 의존성 배열에 포함시켜야 한다.
const handleIncreaseCount = useCallback((number: number) => {
setCount(count + number);
// 빈 배열이면 count는 항상 0으로 고정
// 클릭해도 0 + 10 = 10에서 변하지 않음
}, []); // ❌
useCallback 단독으로는 하위 컴포넌트의 리렌더링을 막을 수 없다.
하위 컴포넌트가 props를 비교하는 최적화가 되어 있지 않으면 함수 참조가 유지되어도 리렌더링이 발생하기 때문이다.
React.memo
함수형 컴포넌트를 메모이징하는 고차 컴포넌트
props가 동일하다면 이전 렌더 결과를 재사용하고 변경되었다면 리렌더링되도록 한다.
export default memo(CountButton);
useCallback + React.memo
- useCallback 함수 참조를 안정적으로 유지 (고정)
- React.memo props 참조값을 비교해 바뀐 게 없으면 리렌더링 스킵
먼저, TanstackQuery 페이지에 있는 movies(영화 목록) 계산에 useMemo를 적용해보겠다.
현재는 selectedMovieId가 바뀔 때마다 매번 재계산 하도록 되어 있는데,
이를 useMemo를 적용해 영화 목록 값을 캐싱해두고 data가 바뀔 때만 재계산하도록 수정했다.
// 현재 — selectedMovieId 바뀔 때마다 매번 재계산
const movies = data?.pages.map((page) => page.results).flat() ?? []
// 적용 후 — data가 바뀔 때만 재계산
const movies = useMemo(
() => data?.pages.map((page) => page.results).flat() ?? [],
[data]
)
이를 통해 리렌더가 발생해 모달을 열고 닫을 때마다 movies가 재계산되는 문제를 해결했다.
이번에는 모달을 열고 닫을 때 각각의 카드들이 불필요하게 리렌더되는 문제를 해결해보겠다.
먼저, memo를 적용하려면 컴포넌트 형태여야 하므로 MovieCard를 컴포넌트로 분리하고 memo로 감쌌다.
import { memo } from 'react'
import type { Movie } from '../types/movie'
const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/w300'
interface Props {
movie: Movie
onClick: (id: number) => void
}
const MovieCard = memo(({ movie, onClick }: Props) => {
return (
<div className="flex flex-col cursor-pointer" onClick={() => onClick(movie.id)}>
<div className="relative bg-gray-200 rounded overflow-hidden aspect-2/3 w-full">
{movie.poster_path ? (
<img
src={`${IMAGE_BASE_URL}${movie.poster_path}`}
alt={movie.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
No Image
</div>
)}
{movie.adult && (
<span className="absolute top-1 left-1 w-5 h-5 bg-red-600 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
19
</span>
)}
</div>
<p className="mt-1 text-gray-800 text-xs truncate font-bold">{movie.title}</p>
<button className="mt-1 border border-gray-300 py-1 rounded text-gray-700 text-xs hover:bg-gray-100 transition-colors cursor-pointer">
예매하기
</button>
</div>
)
})
export default MovieCard
인라인 화살표 함수로 작성된 모달 열기/닫기 함수를 useCallback으로 감싸 함수 참조를 유지한다.
useCallback으로 감싸면 의존성이 바뀌지 않는 한 같은 함수 참조를 유지하므로 memo가 "함수가 그대로다"라고 판단해 불필요한 리렌더를 건너뛸 수 있다.
// 전: 매 렌더마다 새 함수 생성
onClick={() => setSelectedMovieId(movie.id)}
onClose={() => setSelectedMovieId(null)}
// 후: 최초 한 번만 생성, 참조 유지
const handleCardClick = useCallback((id: number) => setSelectedMovieId(id), [])
const handleClose = useCallback(() => setSelectedMovieId(null), [])
그 후, 위에서 작성한 MovieCard 컴포넌트와 onClick 함수를 교체했다.
<div className="grid grid-cols-5 gap-3">
{isLoading
? Array.from({ length: 10 }).map((_, i) => <MovieCardSkeleton key={i} />)
: movies.map((movie, index) => (
<MovieCard
key={`${index}-${movie.id}`}
movie={movie}
onClick={handleCardClick}
/>
))}
{isFetchingNextPage &&
Array.from({ length: 5 }).map((_, i) => <MovieCardSkeleton key={`next-${i}`} />)}
</div>
<div ref={observerRef} className="h-4" />
</section>
</main>
</div>
{selectedMovieId && (
<MovieDetailModal
movieId={selectedMovieId}
onClose={handleClose}
/>
)}
이로써 Suspense + Error Boundary 패턴과 memo, useCallback, useMemo를 활용한 렌더링 최적화까지 적용했다.
마지막으로, React Devtools를 이용해 최적화가 제대로 적용되었는지 확인해보자.
React DevTools
React로 만든 앱을 디버깅하고 분석할 수 있는 크롬 확장 프로그램
- Components 현재 컴포넌트 트리와 props/state 확인
- Profiler 렌더링 성능 측정 및 분석
Profiler를 이용해 모달을 열고 닫았을 때 MovieCard의 리렌더가 일어나는지 확인해보자.

모달을 열었을 때 렌더링 결과다.
TanstackQuery가 리렌더되며 Suspense, ModalSkeleton, MovieDetailModal이 함께 렌더된 것을 볼 수 있다.
목록에 MovieCard가 보이지 않는데, memo가 적용되어 모달 열기/닫기 시 MovieCard의 리렌더가 발생하지 않았음을 의미한다.
useCallback으로 onClick 함수 참조를 유지했기 때문에 memo가 props가 동일하다고 판단해 리렌더를 건너뛴 것이다.
'React' 카테고리의 다른 글
| 폴더 구조와 아키텍처: 프로젝트 구조 개선 (0) | 2026.05.03 |
|---|---|
| 에러 핸들링과 안전성 (0) | 2026.04.26 |
| Tanstack query 활용한 무한 스크롤 페이지 (w.TMDB) (0) | 2026.03.29 |
| TanStack Query (0) | 2026.03.27 |
| 상태 관리 전략 비교 (0) | 2026.03.22 |
