ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,32 @@
+import * as S from './Header.styled';
+
+interface HeaderProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  searchKeyword: string;
+  onSearch: (keyword: string) => void;
+}
+
+function LargeHeader({ searchKeyword, onSearch, ...rest }: HeaderProps) {
+  return (
+    <S.Layout>
+      <a href="https://chosim-dvlpr.github.io/react-movie-review/">
+        <S.LogoButton>
+          <S.LogoImage src="./logo.png" />
+        </S.LogoButton>
+      </a>
+      <S.InputBox $isExpanded={true}>
+        <S.Input
+          {...rest}
+          value={searchKeyword}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
+            onSearch(event.target.value)
+          }
+          $isDisabled={false}
+          disabled={false}
+        />
+        <S.SearchIcon src="./search_button.png" />
+      </S.InputBox>
+    </S.Layout>
+  );
+}
+
+export default LargeHeader;

리뷰: 라우팅이 따로 필요하지 않아서 a 태그로 대체합니다!
@@ -0,0 +1,20 @@
+import { DISPLAY_SIZE } from '../../../constants/displaySize';
+import LargeHeader from './LargeHeader';
+import MobileHeader from './MobileHeader';
+
+interface HeaderProps {
+  searchKeyword: string;
+  onSearch: (keyword: string) => void;
+}
+
+function Header({ searchKeyword, onSearch }: HeaderProps) {
+  const isMobile = document.documentElement.clientWidth <= DISPLAY_SIZE.mobile;
+
+  return isMobile ? (
+    <MobileHeader searchKeyword={searchKeyword} onSearch={onSearch} />
+  ) : (
+    <LargeHeader searchKeyword={searchKeyword} onSearch={onSearch} />
+  );
+}
+
+export default Header;

리뷰: HTMLInputElement를 상속받으면서 rest props를 넘겨주고 있지만 현재로서는 이 기능들을 활용하고 있는 것 같지 않아요. 그리고 보통 이런 패턴은 공통 컴포넌트에서 많이 사용하는데, 요 헤더는 common에 들어있기는 하지만 특정 디자인과 구현에 특화된 것 같아서 이렇게 확장성을 열어둔 이유가 궁금합니다!

+ common 폴더에만 _가 붙어있는데 처음 보는 컨벤션이라 어떤 의미인지도 궁금해요 ㅎㅎ
@@ -0,0 +1,58 @@
+import React, { useReducer } from 'react';
+import * as S from './Header.styled';
+
+interface HeaderProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  searchKeyword: string;
+  onSearch: (keyword: string) => void;
+}
+
+type State = boolean;
+type Action = { type: 'TOGGLE' } | { type: 'OPEN' } | { type: 'CLOSE' };
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'TOGGLE':
+      return !state;
+    case 'OPEN':
+      return true;
+    case 'CLOSE':
+      return false;
+    default:
+      return state;
+  }
+}
+
+function MobileHeader({ searchKeyword, onSearch, ...rest }: HeaderProps) {
+  const [isExpanded, updateState] = useReducer(reducer, false);
+
+  const handleSearchIconClick = () => {
+    updateState({ type: 'TOGGLE' });
+  };
+
+  return (
+    <S.Layout>
+      <a href="https://chosim-dvlpr.github.io/react-movie-review/">
+        <S.LogoButton>
+          <S.LogoImage src="./logo.png" />
+        </S.LogoButton>
+      </a>
+      <S.InputBox $isExpanded={isExpanded}>
+        <S.Input
+          {...rest}
+          value={searchKeyword}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
+            onSearch(event.target.value)
+          }
+          $isDisabled={!isExpanded}
+          disabled={!isExpanded}
+        />
+        <S.SearchIcon
+          src="./search_button.png"
+          onClick={handleSearchIconClick}
+        />
+      </S.InputBox>
+    </S.Layout>
+  );
+}
+
+export default MobileHeader;

리뷰: 오 input이 어차피 돋보기 클릭 전에는 열리지 않아서 놓치기 쉬웠을텐데 꼼꼼하게 disabled까지 챙겨주셨네요! 👍
이렇게 UI에서 input이 사용 불가능할 때 disabled를 명시해준 이유가 궁금해요. 저는 UI와 코드의 일치 + 불필요한 DOM 조작 방지 정도로 생각했는데 맞나용?
@@ -0,0 +1,58 @@
+import React, { useReducer } from 'react';
+import * as S from './Header.styled';
+
+interface HeaderProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  searchKeyword: string;
+  onSearch: (keyword: string) => void;
+}
+
+type State = boolean;
+type Action = { type: 'TOGGLE' } | { type: 'OPEN' } | { type: 'CLOSE' };
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'TOGGLE':
+      return !state;
+    case 'OPEN':
+      return true;
+    case 'CLOSE':
+      return false;
+    default:
+      return state;
+  }
+}
+
+function MobileHeader({ searchKeyword, onSearch, ...rest }: HeaderProps) {
+  const [isExpanded, updateState] = useReducer(reducer, false);
+
+  const handleSearchIconClick = () => {
+    updateState({ type: 'TOGGLE' });
+  };
+
+  return (
+    <S.Layout>
+      <a href="https://chosim-dvlpr.github.io/react-movie-review/">
+        <S.LogoButton>
+          <S.LogoImage src="./logo.png" />
+        </S.LogoButton>
+      </a>
+      <S.InputBox $isExpanded={isExpanded}>
+        <S.Input
+          {...rest}
+          value={searchKeyword}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
+            onSearch(event.target.value)
+          }
+          $isDisabled={!isExpanded}
+          disabled={!isExpanded}
+        />
+        <S.SearchIcon
+          src="./search_button.png"
+          onClick={handleSearchIconClick}
+        />
+      </S.InputBox>
+    </S.Layout>
+  );
+}
+
+export default MobileHeader;

리뷰: Action을 객체로 만든 이유는 확장성 때문일까요? 저라면 간단한 프로그램에서는 단순 문자열 union 타입으로 만들었을 것 같네유ㅎㅎㅎ
@@ -0,0 +1,58 @@
+import React, { useReducer } from 'react';
+import * as S from './Header.styled';
+
+interface HeaderProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  searchKeyword: string;
+  onSearch: (keyword: string) => void;
+}
+
+type State = boolean;
+type Action = { type: 'TOGGLE' } | { type: 'OPEN' } | { type: 'CLOSE' };
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'TOGGLE':
+      return !state;
+    case 'OPEN':
+      return true;
+    case 'CLOSE':
+      return false;
+    default:
+      return state;
+  }
+}
+
+function MobileHeader({ searchKeyword, onSearch, ...rest }: HeaderProps) {
+  const [isExpanded, updateState] = useReducer(reducer, false);
+
+  const handleSearchIconClick = () => {
+    updateState({ type: 'TOGGLE' });
+  };
+
+  return (
+    <S.Layout>
+      <a href="https://chosim-dvlpr.github.io/react-movie-review/">
+        <S.LogoButton>
+          <S.LogoImage src="./logo.png" />
+        </S.LogoButton>
+      </a>
+      <S.InputBox $isExpanded={isExpanded}>
+        <S.Input
+          {...rest}
+          value={searchKeyword}
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
+            onSearch(event.target.value)
+          }
+          $isDisabled={!isExpanded}
+          disabled={!isExpanded}
+        />
+        <S.SearchIcon
+          src="./search_button.png"
+          onClick={handleSearchIconClick}
+        />
+      </S.InputBox>
+    </S.Layout>
+  );
+}
+
+export default MobileHeader;

리뷰: useReducer를 실제로 쓰는 코드는 처음 보네요! 매일 useState만 생각했는데 리프레시가 되는군요ㅋㅋㅋ
다만 지금 action 중에서 실질적으로 사용하는 동작은 TOGGLE뿐인 것 같은데 OPEN과 CLOSE가 명시된 이유, 리듀서를 사용한 이유가 궁금합니다~~
@@ -0,0 +1,81 @@
+import styled from 'styled-components';
+import media from '../../../styles/mediaQueries';
+
+export const Layout = styled.header`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+
+  position: relative;
+
+  width: 100%;
+  height: 6rem;
+  padding: 0.8rem 3.2rem;
+
+  border-bottom: 1px solid rgba(255, 255, 255, 0.5);
+  box-shadow: 0px 4px 8px 0px rgba(255, 255, 255, 0.2);
+
+  color: ${({ theme }) => theme.colors.white};
+  background-color: rgba(17, 17, 17, 1);
+`;
+
+export const LogoImage = styled.img`
+  width: 12.3rem;
+  height: 2rem;
+`;
+
+export const LogoButton = styled.button`
+  cursor: pointer;
+`;
+
+export const InputBox = styled.div<{ $isExpanded: boolean }>`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.8rem;
+
+  right: 3.2rem;
+
+  width: 100%;
+  max-width: 32rem;
+  height: 4.4rem;
+  padding: 1rem 1.4rem;
+
+  border: 1px solid rgba(208, 213, 221, 1);
+  border-radius: 0.8rem;
+
+  overflow: hidden;
+  background-color: ${({ theme }) => theme.colors.white};
+  transition: width 0.2s ease;
+
+  ${({ $isExpanded }) => media.mobile`
+      position: absolute;
+      width: ${$isExpanded ? 'calc(100% - 6.4rem)' : '4.8rem'};
+      max-width: 100%;
+      height: 4.4rem;
+    `};
+`;
+
+export const Input = styled.input<{ $isDisabled: boolean }>`
+  width: 100%;
+  height: 100%;
+
+  pointer-events: ${({ $isDisabled }) => ($isDisabled ? 'none' : 'auto')};
+  transition: width 0.2s ease;
+
+  ${({ $isDisabled }) => media.mobile`
+      display: ${$isDisabled ? 'none' : 'block'};
+
+      width: ${$isDisabled ? 0 : '100%'};
+      height: ${$isDisabled ? 0 : '100%'};
+    `}
+`;
+
+export const SearchIcon = styled.img`
+  width: 1.8rem;
+  height: 1.8rem;
+
+  cursor: pointer;
+`;

리뷰: 태그가 header면 더 좋을 것 같아요~!
@@ -0,0 +1,52 @@
+import React, { Suspense, useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import * as S from './Modal.styled';
+import useEscapeKey from '../../../hooks/useEscapeKey';
+import ModalBackdrop from './ModalBackdrop';
+import SkeletonModal from '../Skeleton/SkeletonModal';
+
+interface ModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  children: React.ReactNode;
+}
+
+const ANIMATION_DURATION_TIME = 200;
+
+const Modal = ({ children, isOpen, onClose }: ModalProps) => {
+  const portalElement = document.getElementById('modal') as HTMLElement;
+  const [isRender, setIsRender] = useState(isOpen);
+
+  useEscapeKey(isOpen, onClose);
+
+  useEffect(() => {
+    if (isOpen) {
+      setIsRender(true);
+    } else {
+      const timer = setTimeout(() => {
+        setIsRender(false);
+      }, ANIMATION_DURATION_TIME);
+      return () => clearTimeout(timer);
+    }
+  }, [isOpen]);
+
+  if (!portalElement || !isRender) {
+    return null;
+  }
+
+  const modalLayout = (
+    <S.Layout>
+      <ModalBackdrop onClick={onClose} />
+      <S.ContentLayout
+        $isRender={isRender && isOpen}
+        $animationTime={ANIMATION_DURATION_TIME}
+      >
+        <Suspense fallback={<SkeletonModal />}>{children}</Suspense>
+      </S.ContentLayout>
+    </S.Layout>
+  );
+
+  return isRender && createPortal(modalLayout, portalElement);
+};
+
+export default Modal;

리뷰: 어떤 효과였을까요?!
@@ -0,0 +1,23 @@
+import Title from '../../_common/Title/Title';
+import MovieList from '../MovieList/MovieList';
+import * as S from '../Movie.styled';
+
+interface ContentProps {
+  searchKeyword: string;
+}
+
+function Content({ searchKeyword }: ContentProps) {
+  const titleContent =
+    searchKeyword.length > 0
+      ? `${searchKeyword} 검색 결과`
+      : '지금 인기있는 영화';
+
+  return (
+    <S.ContentLayout>
+      <Title content={titleContent} />
+      <MovieList keyword={searchKeyword} />
+    </S.ContentLayout>
+  );
+}
+
+export default Content;

리뷰: 오 위에서 searchKeyword 값을 받아오되 디바운스를 걸어서 새 값을 받아오는군용
그런데 디바운스 처리를 searchKeyword를 소유한 부모 컴포넌트가 아니라 Content에서 해 주는 이유가 있나요? 
디바운스 로직이 Content를 거쳐야 나오는 상황이 아니라서 부모 컴포넌트가 담당하는 게 조금 더 낫다는 생각입니다. (Single Source of Truth 면에서)

또 역할을 직관적으로 나눠볼 때도 debounce 책임은 검색창 쪽에서 담당하고 Content는 그 값을 받아서 쓰기만 하는 게 자연스러워 보인다고 생각해요~!
@@ -0,0 +1,59 @@
+import { useState } from 'react';
+import { useModalContext } from '../../../contexts/ModalContext';
+import Modal from '../../_common/Modal/Modal';
+import * as S from '../Movie.styled';
+import MovieDetail from '../MovieDetail/MovieDetail';
+import MovieItem from '../MovieItem/MovieItem';
+import FetchTargetBox from '../../_common/FetchTargetBox/FetchTargetBox';
+import SkeletonColumn from '../../_common/Skeleton/SkeletonColumn';
+import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
+import useMovieQuery from '../../../hooks/useMovieQuery';
+
+interface MovieListProps {
+  keyword: string;
+}
+
+function MovieList({ keyword }: MovieListProps) {
+  const [selectedMovie, setSelectedMovie] = useState<number>(0);
+
+  const { isOpenModal, openModal, closeModal } = useModalContext();
+
+  const handleModalOpen = (movieId: number) => {
+    setSelectedMovie(movieId);
+    openModal('movie');
+  };
+
+  const {
+    movieList,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+    isLoading,
+  } = useMovieQuery({ keyword });
+
+  const { nextFetchTargetRef } = useInfiniteScroll({
+    fetchNextPage,
+    hasNextPage,
+    movieList,
+  });
+
+  return (
+    <S.MovieListLayout>
+      <S.ItemList>
+        {isLoading && <SkeletonColumn count={20} />}
+        {movieList.map(movie => (
+          <MovieItem movie={movie} key={movie.id} onClick={handleModalOpen} />
+        ))}
+      </S.ItemList>
+      {!isFetchingNextPage && hasNextPage && (
+        <FetchTargetBox ref={nextFetchTargetRef} />
+      )}
+
+      <Modal onClose={() => closeModal('movie')} isOpen={isOpenModal}>
+        <MovieDetail movieId={selectedMovie} />
+      </Modal>
+    </S.MovieListLayout>
+  );
+}
+
+export default MovieList;

리뷰: id 값을 관리하니까 변수명에서도 명확하게 Id임이 드러나면 좋겠어요!
@@ -0,0 +1,131 @@
+import axios from 'axios';
+import { API_URL, TMDB_URL } from './constant';
+import { Movie, MovieDetail } from '../types/movie.type';
+
+interface GetPopularMoviesResponse {
+  page: number;
+  results: Movie[];
+  total_pages: number;
+  total_results: number;
+}
+
+export const getPopularMovies = async ({
+  page,
+}: {
+  page: number;
+}): Promise<GetPopularMoviesResponse> => {
+  try {
+    const response = await axios.get(
+      `${TMDB_URL}/movie/popular?${new URLSearchParams({
+        api_key: import.meta.env.VITE_API_KEY,
+        language: 'ko-KR',
+        page: `${page}`,
+      })}`,
+    );
+
+    return response.data;
+  } catch (error) {
+    throw new Error('Failed to fetch popular movies');
+  }
+};
+
+interface GetSearchedMoviesResponse {
+  page: number;
+  results: Movie[];
+  total_pages: number;
+  total_results: number;
+}
+
+export const getSearchedMovies = async ({
+  page,
+  keyword,
+}: {
+  page: number;
+  keyword: string;
+}): Promise<GetSearchedMoviesResponse> => {
+  try {
+    const response = await axios({
+      method: 'GET',
+      url: `${TMDB_URL}/search/movie`,
+      params: {
+        api_key: import.meta.env.VITE_API_KEY,
+        include_adult: 'false',
+        language: 'ko-KR',
+        page,
+        query: keyword,
+      },
+      headers: {
+        accept: 'application/json',
+        Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
+      },
+    });
+
+    return response.data;
+  } catch (error) {
+    throw new Error('Failed to fetch popular movies');
+  }
+};
+
+export const getMovie = async ({
+  movieId,
+}: {
+  movieId: number;
+}): Promise<MovieDetail> => {
+  try {
+    const response = await axios({
+      method: 'GET',
+      url: `${TMDB_URL}/movie/${movieId}`,
+      params: {
+        api_key: import.meta.env.VITE_API_KEY,
+        language: 'ko-KR',
+      },
+      headers: {
+        accept: 'application/json',
+        Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
+      },
+    });
+
+    return response.data;
+  } catch (error) {
+    throw new Error('Failed to fetch popular movies');
+  }
+};
+
+export const getRating = async ({
+  movieId,
+}: {
+  movieId: number;
+}): Promise<number> => {
+  try {
+    const response = await axios({
+      method: 'GET',
+      url: `${API_URL}/rating/${movieId}`,
+    });
+
+    return response.data;
+  } catch (error) {
+    throw new Error('Failed to fetch popular movies');
+  }
+};
+
+export const updateRating = async ({
+  movieId,
+  rating,
+}: {
+  movieId: number;
+  rating: number;
+}): Promise<{ rating: number }> => {
+  try {
+    const response = await axios({
+      method: 'POST',
+      url: `${API_URL}/rating/${movieId}`,
+      data: {
+        rating,
+      },
+    });
+
+    return { rating: response.data };
+  } catch (error) {
+    throw new Error('Failed to fetch popular movies');
+  }
+};

리뷰: axios도 거의 반년만에 보네요...ㅋㅋㅋㅋㅋㅋ 확실히 fetch 코드가 더 간결해지는군요
page를 보낼 때 String()이 아니라 템플릿 리터럴을 사용한 게 눈에 띄네요!
@@ -0,0 +1,136 @@
+import styled from 'styled-components';
+import media from '../../../styles/mediaQueries';
+
+export const Layout = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+// Text
+
+export const TitleText = styled.p`
+  ${({ theme }) => theme.font.body};
+  text-align: center;
+`;
+
+export const CaptionText = styled.p`
+  ${({ theme }) => theme.font.caption};
+`;
+
+export const CaptionBoldText = styled.p`
+  ${({ theme }) => theme.font.caption};
+  font-weight: 700;
+  white-space: nowrap;
+`;
+
+export const OverviewText = styled.p`
+  ${({ theme }) => theme.font.caption};
+  text-align: start;
+`;
+
+export const RatingDescriptionText = styled.p`
+  ${({ theme }) => theme.font.caption};
+
+  ${media.tablet`
+      display: none;
+    `}
+`;
+
+// Box Layout
+
+export const TitleBox = styled.div`
+  position: relative;
+  width: 100%;
+
+  padding: 1.8rem 0;
+  border-bottom: 1px solid rgba(241, 241, 241, 0.25);
+`;
+
+export const ContentBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  gap: 3.2rem;
+
+  width: 100%;
+  height: calc(100% - 6.4rem);
+
+  padding: 3.6rem 3.2rem 4.8rem;
+`;
+
+export const DetailBox = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: flex-start;
+`;
+
+export const DetailHeaderBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  gap: 1.6rem;
+`;
+
+export const DetailContentBox = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 1.6rem;
+
+  -webkit-line-clamp: 10;
+  -webkit-box-orient: vertical;
+
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: normal;
+`;
+
+export const DetailVoteBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 1.2rem;
+
+  width: 100%;
+  padding: 1.6rem;
+  border-radius: 1.6rem;
+
+  background: ${({ theme }) => theme.colors.greyScale2};
+
+  ${media.tablet`
+      justify-content: center;
+    `}
+`;
+
+export const StarImageVoteBox = styled.div`
+  display: flex;
+  flex-direction: row;
+`;
+
+export const ThumbnailImageBox = styled.div`
+  width: 29.2rem;
+  height: 43.3rem;
+`;
+
+// Image
+
+export const ThumbnailImage = styled.img`
+  ${media.desktop`
+      width: 26rem;
+      height: 40rem;
+    `}
+
+  ${media.tablet`
+      display: none;
+    `}
+`;
+
+export const StarImage = styled.img`
+  width: 2.6rem;
+  height: 2.6rem;
+
+  cursor: pointer;
+
+  ${media.tablet`
+      width: 3.2rem;
+      height: 3.2rem;
+    `}
+`;

리뷰: 저는 항상 스타일링 대상이 되는 요소의 기능만 생각해서 styled component의 이름을 지었는데 적용할 스타일로 이름을 붙이니까 더 직관적이고 편하네요 😊 왜 이 생각을 못 했지?!
@@ -0,0 +1,136 @@
+import styled from 'styled-components';
+import media from '../../../styles/mediaQueries';
+
+export const Layout = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+// Text
+
+export const TitleText = styled.p`
+  ${({ theme }) => theme.font.body};
+  text-align: center;
+`;
+
+export const CaptionText = styled.p`
+  ${({ theme }) => theme.font.caption};
+`;
+
+export const CaptionBoldText = styled.p`
+  ${({ theme }) => theme.font.caption};
+  font-weight: 700;
+  white-space: nowrap;
+`;
+
+export const OverviewText = styled.p`
+  ${({ theme }) => theme.font.caption};
+  text-align: start;
+`;
+
+export const RatingDescriptionText = styled.p`
+  ${({ theme }) => theme.font.caption};
+
+  ${media.tablet`
+      display: none;
+    `}
+`;
+
+// Box Layout
+
+export const TitleBox = styled.div`
+  position: relative;
+  width: 100%;
+
+  padding: 1.8rem 0;
+  border-bottom: 1px solid rgba(241, 241, 241, 0.25);
+`;
+
+export const ContentBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  gap: 3.2rem;
+
+  width: 100%;
+  height: calc(100% - 6.4rem);
+
+  padding: 3.6rem 3.2rem 4.8rem;
+`;
+
+export const DetailBox = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: flex-start;
+`;
+
+export const DetailHeaderBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  gap: 1.6rem;
+`;
+
+export const DetailContentBox = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 1.6rem;
+
+  -webkit-line-clamp: 10;
+  -webkit-box-orient: vertical;
+
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: normal;
+`;
+
+export const DetailVoteBox = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 1.2rem;
+
+  width: 100%;
+  padding: 1.6rem;
+  border-radius: 1.6rem;
+
+  background: ${({ theme }) => theme.colors.greyScale2};
+
+  ${media.tablet`
+      justify-content: center;
+    `}
+`;
+
+export const StarImageVoteBox = styled.div`
+  display: flex;
+  flex-direction: row;
+`;
+
+export const ThumbnailImageBox = styled.div`
+  width: 29.2rem;
+  height: 43.3rem;
+`;
+
+// Image
+
+export const ThumbnailImage = styled.img`
+  ${media.desktop`
+      width: 26rem;
+      height: 40rem;
+    `}
+
+  ${media.tablet`
+      display: none;
+    `}
+`;
+
+export const StarImage = styled.img`
+  width: 2.6rem;
+  height: 2.6rem;
+
+  cursor: pointer;
+
+  ${media.tablet`
+      width: 3.2rem;
+      height: 3.2rem;
+    `}
+`;

리뷰: 요기 overflow-y 속성이 있어야 할 것 같아요!
@@ -0,0 +1,59 @@
+import { useState } from 'react';
+import { useModalContext } from '../../../contexts/ModalContext';
+import Modal from '../../_common/Modal/Modal';
+import * as S from '../Movie.styled';
+import MovieDetail from '../MovieDetail/MovieDetail';
+import MovieItem from '../MovieItem/MovieItem';
+import FetchTargetBox from '../../_common/FetchTargetBox/FetchTargetBox';
+import SkeletonColumn from '../../_common/Skeleton/SkeletonColumn';
+import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
+import useMovieQuery from '../../../hooks/useMovieQuery';
+
+interface MovieListProps {
+  keyword: string;
+}
+
+function MovieList({ keyword }: MovieListProps) {
+  const [selectedMovie, setSelectedMovie] = useState<number>(0);
+
+  const { isOpenModal, openModal, closeModal } = useModalContext();
+
+  const handleModalOpen = (movieId: number) => {
+    setSelectedMovie(movieId);
+    openModal('movie');
+  };
+
+  const {
+    movieList,
+    fetchNextPage,
+    isFetchingNextPage,
+    hasNextPage,
+    isLoading,
+  } = useMovieQuery({ keyword });
+
+  const { nextFetchTargetRef } = useInfiniteScroll({
+    fetchNextPage,
+    hasNextPage,
+    movieList,
+  });
+
+  return (
+    <S.MovieListLayout>
+      <S.ItemList>
+        {isLoading && <SkeletonColumn count={20} />}
+        {movieList.map(movie => (
+          <MovieItem movie={movie} key={movie.id} onClick={handleModalOpen} />
+        ))}
+      </S.ItemList>
+      {!isFetchingNextPage && hasNextPage && (
+        <FetchTargetBox ref={nextFetchTargetRef} />
+      )}
+
+      <Modal onClose={() => closeModal('movie')} isOpen={isOpenModal}>
+        <MovieDetail movieId={selectedMovie} />
+      </Modal>
+    </S.MovieListLayout>
+  );
+}
+
+export default MovieList;

리뷰: queries를 객체로 만들고 key를 name으로 하면 어떤 리소스를 위한 쿼리인지 더 잘 드러날 것 같아요~ 근데 객체라서 코드가 좀 더 복잡해지겠네요 😗