Skip to content

[6주차 과제] Tanstack Query 연습 - #8

Open
soyyyyy wants to merge 48 commits into
mainfrom
week6/homework
Open

[6주차 과제] Tanstack Query 연습#8
soyyyyy wants to merge 48 commits into
mainfrom
week6/homework

Conversation

@soyyyyy

@soyyyyy soyyyyy commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

🏡 과제 명세

💡 과제

  • React + TypeScript
  • TanStack Query 사용
  • HTTP 클라이언트 라이브러리 사용 (axios, ky, ...)
  • ThemeProvider, GlobalStyle 사용 (CSS 라이브러리 사용)
  • any 사용 금지
  1. 영화 목록 페이지(메인 페이지)
  • 리스트 형태로 영화 데이터 불러오기
  • 별점을 통한 필터링 기능 구현
  • 각 영화 카드에는 포스터, 영화 제목, 개봉일, 줄거리 표시
  • 영화 카드 hover시 효과(확대 or 그림자 or 색상 변경 등 자유)
  • 영화 카드 클릭 시, 영화 상세 페이지로 이동
  • 무한 스크롤 구현
  1. 영화 상세 페이지
  • movieId 로 라우팅
  • 영화 포스터, 배경 이미지, 제목, 개봉일, 장르, 평점, 상영 시간, 투표 수, 상태, 줄거리 표시
  • 기본 정보(원제, 원어, 제작 국가, 사용 언어, 예산, 수익) 표시
  • ‘<- 목록으로 돌아가기’ 클릭 시 영화 목록 페이지로 이동
  1. 별점 남기기/삭제하기
  • 영화 상세 페이지에 존재
  • 이전에 매긴 별점이 없을 경우 비어 있으며, 있을 경우 해당 값으로 채워짐
  • 범위 밖 숫자 입력 시 저장 x
  • 성공시, 성공 메시지 하단에 출력
  • ‘별점 삭제하기’ 클릭 시, 별점 삭제

✍️ 구현 요약 및 새로 배운 점

폴더 구조

영화 목록 / 상세 페이지 기준으로 apis, components, types, constants, utils 등를 나눠서 관리했습니다.

src
├─ assets
├─ pages
│  ├─ movieDetail
│  │  ├─ apis
│  │  │  ├─ mutaions
│  │  │  └─ queries
│  │  ├─ components
│  │  │  ├─ button
│  │  │  ├─ card
│  │  │  └─ section
│  │  ├─ constants
│  │  ├─ hooks
│  │  ├─ types
│  │  └─ utils
│  └─ movieList
│     ├─ apis
│     ├─ components
│     │  ├─ card
│     │  └─ ratingFilter
│     ├─ constants
│     ├─ hooks
│     ├─ types
│     └─ utils
├─ routes
├─ shared
│  ├─ apis
│  ├─ components
│  ├─ constants
│  ├─ hooks
│  ├─ types
│  └─ utils
│  └─ styles

절대 경로 설정

상대 경로가 길어지는 걸 줄이기 위해 shared/, pages 폴더 대상으로 path alias를 설정했습니다.
가장 짧은 경로를 사용할 수 있도록 했습니다. (예: shared/constants → @constants)

resolve: {
  alias: {
    "@pages": path.resolve(__dirname, "./src/pages"),
    "@apis": path.resolve(__dirname, "./src/shared/apis"),
    "@constants": path.resolve(__dirname, "./src/shared/constants"),
    생략
  },
},

라우터 설정

path를 상수화했고, 각 페이지에 이미지가 많으므로 로딩 속도를 개선하기 위해 lazy loading 적용했습니다.

const router = createBrowserRouter([
  {
    path: ROUTES.MOVIE_LIST,
    children: [
      { path: ROUTES.MOVIE_LIST, lazy: async () => ({ Component: (await import("@pages/movieList/MovieListPage")).default }) },
      { path: ROUTES.MOVIE_DETAIL, lazy: async () => ({ Component: (await import("@pages/movieDetail/MovieDetailPage")).default }) },
    ],
  },
]);

API 공통 구조

endpoint 상수화, 공통 HTTP 구조, axios 공통 설정을 분리해 관리했습니다.

endpoint 상수화

export const API_ENDPOINT = {
  LIST: "/discover/movie",
  DETAIL: (movieId: number) => `/movie/${movieId}`,
  ...
} as const;

공통 HTTP wrapper 구조

각 api 함수에서 response.data를 매번 꺼내지 않아도 되게 설정했고, 이번 과제에서 필요한 get, post, delete만 정의했습니다.

export const api = {
  get: async <T>(url: string, params?: Record<string, unknown>) => {
    const response = await axiosInstance.get<T>(url, { params });
    return response.data;
  },
  ...

axios 공통 설정

  • axiosInstance: baseURL, 공통 헤더를 설정했습니다.
  • interceptor: api_key, language를 params에 자동적으로 붙일 수 있도록 처리했습니다.
axiosInstance.interceptors.request.use((config) => {
  config.params = {
    ...config.params,
    api_key: config.params?.api_key ?? apiKey,
    language: config.params?.language ?? "ko-KR",
  };
  return config;
});

TanStack Query 도입

목록 조회, 상세 조회, 별점 관련 요청을 쿼리/뮤테이션으로 나누어 구현했습니다. 이 과정에서 둘 다 사용되는 상세페이지에서는 apis 폴더 밑에 queries, mutations 폴더를 별도로 두었습니다.

export const useMovieDetailQuery = (id: number) => {
  return useQuery({
    queryKey: QUERY_KEY.MOVIE_DETAIL(id),
    queryFn: () => getMovieDetail(id),
    enabled: Number.isFinite(id),
  });
};

상세페이지의 데이터를 UI 용으로 가공

상세페이지에서 사용되는 label이 많아서 이를 상수화 하고, API 응답을 각 label에 맞게 가공했습니다.

export const DETAIL_STAT_LABEL = {
  VOTE_AVERAGE: "평점",
  VOTE_COUNT: "투표 수",
  RUNTIME: "상영 시간",
  STATUS: "상태",
} as const;

export const BASIC_INFO_LABEL = {
  ORIGINAL_TITLE: "원제",
  ORIGINAL_LANGUAGE: "원어",
  PRODUCTION_COUNTRY: "제작 국가",
  SPOKEN_LANGUAGE: "사용 언어",
  BUDGET: "예산",
  REVENUE: "수익",
} as const;
export const getMovieDetailViewData = (movieDetail: MovieDetailResponse) => {
  return {
    detailStats: [
      { label: DETAIL_STAT_LABEL.VOTE_AVERAGE, value: `${movieDetail.vote_average.toFixed(1)} / 10` },
      { label: DETAIL_STAT_LABEL.RUNTIME, value: `${Math.floor(movieDetail.runtime / 60)}시간 ${movieDetail.runtime % 60}분` },
   ...
    ],
    basicInfo: [
      { label: BASIC_INFO_LABEL.ORIGINAL_TITLE, value: movieDetail.original_title },
      { label: BASIC_INFO_LABEL.PRODUCTION_COUNTRY, value: movieDetail.production_countries.map((country) => country.name).join(", ") || "-" },
    ...
    ],
  };
};

게스트 세션 발급

게스트 세션 id가 없으면 새로 발급받고, 있으면 재사용하도록 했습니다.
처음에 이 부분도 axiosInstance에서 공통 처리하려고 했는데, 서버로 보내는 방식이 params, path로 각각 달라서 제외했습니다.

export const useGuestSessionQuery = () => {
  return useQuery({
    queryKey: QUERY_KEY.GUEST_SESSION,
    queryFn: async () => {
      const storedGuestSessionId = localStorage.getItem("guest_session_id");
      if (storedGuestSessionId) return storedGuestSessionId;

      const response = await getGuestSession();
      localStorage.setItem("guest_session_id", response.guest_session_id);
      return response.guest_session_id;
    },
  });
};

💦 구현 과정에서 어려웠던 & 고민했던 부분

TMDB 이미지 변환

TMDB 응답의 poster_path, backdrop_path는 완성 URL(https:// ~)이 아니라 상대 경로라서, UI에서 바로 매핑하니 이미지가 깨지는 문제가 있었습니다.

이 문제를 해결하기 위해 아래처럼 util을 분리해 사용했습니다. 영화 목록/상세 페이지에서 각각 필요한 크기(size)만 다르게 넣을 수 있습니다. (참고자료)

export const getImageUrl = (path: string | null, size = 400) => {
  if (!path) return null;
  return `https://image.tmdb.org/t/p/w${size}${path}`;
};

무한 스크롤 단계별 구현

useInfiniteQuery만 붙인다고 끝나는 게 아니라, pageParam, getNextPageParam, IntersectionObserver, hasNextPage, isFetchingNextPage 를 같이 이해해야 해서 흐름을 잡기 어려웠습니다.
특히 단계별로 스크롤이 진행되지 않고, 계속 요청이 나가는 문제가 있었습니다.(UI도 안 그려짐.)

원인

fetchNextPage()가 컴포넌트 최상단에서 렌더링마다 호출되어, 스크롤과 무관하게 모든 페이지를 한 번에 불러왔습니다.

if (hasNextPage && !isFetchingNextPage) {
  fetchNextPage();
}

해결방법

Intersection Observer로 리스트 하단 요소가 뷰포트에 진입할 때만 fetchNextPage()를 호출하도록 변경했습니다.

useEffect(() => {

  const observer = new IntersectionObserver( // 1. Observer 생성
    (entries) => {
      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { // sentinel이 화면에 보이는지, 다음 페이지가 존재하는지, 로딩 중이 아닌지 검사
        fetchNextPage();
      }
    },
    { rootMargin: "200px", threshold: 0 }
  );

  if (observerRef.current) observer.observe(observerRef.current); // 2. sentinel 요소 감시 시작

  return () => observer.disconnect(); // 클린업
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);

추가적으로 스크롤 튕김 현상이 심했는데, grid auto-fit 은 레이아웃을 재계산 하기 때문에 auto-fill 변경하면 이를 방지할 수 있다는 점을 알게 되었습니다.

input 초기값 관리

저장된 별점이 있으면 input에 넣거나 삭제할 경우 input의 값을 없애는 작업에서 의도한대로 잘 동작이 되지 않았습니다.
최종적으로는 초기 rating이 바뀔 때 key를 바꿔 컴포넌트를 다시 마운트하는 방식으로 수정해 해결했습니다.

<RatingSection
  key={`${movieId}-${initialRating ?? "empty"}`}
  initialRating={initialRating}
/>

🙋‍♀️ 리뷰 요청 포인트 & 질문

  • input 값을 초기화 할 때 쿼리키를 invalidate 해야할 것 같은데, 현재 작동은 잘 되어서 추가하지 않았습니다. 어떻게 하면 좋을지 궁금해요.
  • 테일윈드 사용이 익숙치 않은데, 어떻게 하면 깔끔하게 잘 활용할 수 있을지 궁금합니다.
  • 전체적으로 컴포넌트/파일 등을 분리 정도나 개선할 점이 있는지 봐주시면 좋을 것 같습니다.

📷 결과물

2026-06-02.10.07.35.1.1.1.mov

@soyyyyy soyyyyy self-assigned this Jun 2, 2026
@soyyyyy soyyyyy added the 6주차 label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant