ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,81 @@
+package com.ll.backend.domain.order.service;
+
+import com.ll.backend.domain.order.OrderStatus;
+import com.ll.backend.domain.order.dto.OrderRequestDto;
+import com.ll.backend.domain.order.dto.OrderResponseDto;
+import com.ll.backend.domain.order.entity.Order;
+import com.ll.backend.domain.order.repository.OrderRepository;
+import com.ll.backend.domain.orderdetail.service.OrderDetailService;
+import com.ll.backend.domain.product.repository.ProductRepository;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class OrderService {
+    private final OrderRepository orderRepository;
+    private final ProductRepository productRepository;
+    private final OrderDetailService orderDetailService;
+
+
+    // 주문 생성
+    @Transactional
+    public Order createOrder(OrderRequestDto orderRequestDto) {
+
+        // 주문 정보 생성
+        Order order = new Order(
+                orderRequestDto.getEmail(),
+                orderRequestDto.getAddress(),
+                orderRequestDto.getPostalCode(),
+                setOrderStateBasedOnTime(),
+                orderRequestDto.getTotalPrice()
+        );
+
+        // 주문 저장
+        return orderRepository.save(order);
+    }
+
+    private OrderStatus setOrderStateBasedOnTime() {
+        LocalDateTime now = LocalDateTime.now();
+        LocalTime cutoffTime = LocalTime.of(14, 0); // 오후 2시 기준
+
+        if (now.toLocalTime().isBefore(cutoffTime)) {
+            return OrderStatus.SHIPPED;
+        } else {
+            return OrderStatus.PENDING;
+        }
+    }
+
+    public long count() {
+        return orderRepository.count();
+    }
+
+    public Order initDataOrder(Order order) {
+        return orderRepository.save(order);
+    }
+
+    @Transactional()
+    public List<OrderResponseDto> getOrderList() {
+        List<Order> orders = orderRepository.findAll();
+
+        return orders.stream()
+                .map(order -> new OrderResponseDto(
+                        order.getId(),
+                        order.getEmail(),
+                        order.getAddress(),
+                        order.getPostalCode(),
+                        order.getState().toString(),
+                        order.getTotalPrice(),
+                        order.getOrderDate()
+                ))
+                .collect(Collectors.toList());
+    }
+}
+
+

리뷰: ```java
	//배송 상태 변경
	@Transactional
	public void updateDeliveryStatus(OrderDeliveryRequestDto orderDeliveryRequestDto) {
		List<Integer> orderIds = orderDeliveryRequestDto.getId();
		
		if (CollectionUtils.isEmpty(orderIds)) {
			throw new IllegalArgumentException("ID list cannot be null or empty.");
		}

		for (Integer orderId : orderIds) {
			Order order = orderRepository.findById(orderId)
				.orElseThrow(() -> new IllegalArgumentException("Order not found for ID: " + orderId));

			// 배송 상태를 SHIPPED로 변경
			order.updateToShipped();
		}
	}
```
@@ -0,0 +1,29 @@
+package com.ll.backend.domain.orderdetail.controller;
+
+import com.ll.backend.domain.orderdetail.dto.OrderDetailResponseDto;
+import com.ll.backend.domain.orderdetail.service.OrderDetailService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class OrderDetailController {
+
+    private final OrderDetailService orderDetailService;
+
+    @GetMapping("/admin/order/{id}")
+    public ResponseEntity<List<OrderDetailResponseDto>> getOrderDetail(@PathVariable String id){
+        List<OrderDetailResponseDto> orderDetail = orderDetailService.getOrderDetail(Integer.parseInt(id));
+        if(orderDetail.isEmpty()){
+            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+        }
+        return new ResponseEntity<>(orderDetail, HttpStatus.OK);
+    }
+
+}

리뷰: Integer로 받읍시다.
@@ -0,0 +1,29 @@
+package com.ll.backend.domain.orderdetail.controller;
+
+import com.ll.backend.domain.orderdetail.dto.OrderDetailResponseDto;
+import com.ll.backend.domain.orderdetail.service.OrderDetailService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class OrderDetailController {
+
+    private final OrderDetailService orderDetailService;
+
+    @GetMapping("/admin/order/{id}")
+    public ResponseEntity<List<OrderDetailResponseDto>> getOrderDetail(@PathVariable String id){
+        List<OrderDetailResponseDto> orderDetail = orderDetailService.getOrderDetail(Integer.parseInt(id));
+        if(orderDetail.isEmpty()){
+            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+        }
+        return new ResponseEntity<>(orderDetail, HttpStatus.OK);
+    }
+
+}

리뷰: (나중에 시간 나면 확인해 보세요~~~~~)
- `@ControllerAdvice`, `@ExceptionHandler` 를 같이 사용하면, 저희가 굳이 저렇게 응답 코드를 분기하지 않아도 예외의 종류만 보고 HttpStatusCode를 지정할 수 있습니다.
@@ -0,0 +1,15 @@
+package com.ll.backend.domain.orderdetail.repository;
+
+import com.ll.backend.domain.order.entity.Order;
+import com.ll.backend.domain.orderdetail.entity.OrderDetail;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface OrderDetailRepository extends JpaRepository<OrderDetail,Long> {
+    List<OrderDetail> findByOrder(Order order);
+
+    Long id(Integer id);
+
+    List<OrderDetail> findByOrder_Id(Integer orderId);
+}

리뷰: Google Java Convention/Naver Java Convention 모두에서, 메서드 이름에 언더스코어를 쓰지 말라고 적혀있을거에요. (일반적으로 테스트가 아니라면 메서드 이름에 언더스코어를 쓰지 않습니다.)
@@ -25,6 +25,7 @@ public class Board {
     @Column(updatable = false)
     private LocalDateTime createdAt = LocalDateTime.now();
 
+
     @ManyToOne(fetch = FetchType.LAZY)
     @JoinColumn(name = "userId")
     private User user;

리뷰: N+1 문제를 고려해야해서 수정해야함
@@ -1,5 +1,22 @@
+import MovieListContainer from './components/MovieListContainer';
+import Header from './components/Header';
+import QueryErrorBoundary from './components/common/QueryResetErrorboundary';
+
 function App() {
-  return <div>hi</div>;
+  return (
+    <div style={AppStyle}>
+      <QueryErrorBoundary>
+        <Header />
+        <main>
+          <MovieListContainer />
+        </main>
+      </QueryErrorBoundary>
+    </div>
+  );
 }
 
+const AppStyle = {
+  paddingBottom: '48px',
+};
+
 export default App;

리뷰: 와우 에러바운더리까지...!👍🏻
@@ -0,0 +1,56 @@
+import * as S from './styles';
+import Portal from '../common/Portal';
+import ModalBackground from '../common/ModalBackground';
+import useGetMovieDetail from '../../hooks/useGetMovieDetail';
+import {IMAGE_BASE_URL} from '../../constants/movie';
+import {toOneDecimalPlace} from '../../utils/number';
+import filledStar from '../../assets/star_filled.png';
+import MyRating from '../MyRating';
+
+interface DetailModalProps {
+  selectedMovieId: number;
+  closeModal: () => void;
+}
+
+const DetailModal = ({selectedMovieId, closeModal}: DetailModalProps) => {
+  const {data} = useGetMovieDetail(selectedMovieId);
+
+  const {poster_path, vote_average, title, overview, genres} = data;
+
+  return (
+    <Portal>
+      <ModalBackground closeModal={closeModal}>
+        <S.ModalContainer>
+          <S.Header>
+            <h2>{title}</h2>
+            <button type="button" onClick={closeModal}>
+              닫기
+            </button>
+          </S.Header>
+
+          <S.Content>
+            <S.Poster src={`${IMAGE_BASE_URL}${poster_path}`} alt={`${title} 포스터`} />
+
+            <S.RightContent>
+              <S.ShortInfo>
+                <S.Genres>{genres.map(genre => genre.name).join(', ')}</S.Genres>
+                <S.Rate>
+                  <span>{toOneDecimalPlace(vote_average)}</span>
+                  <img src={filledStar} alt="" />
+                </S.Rate>
+              </S.ShortInfo>
+
+              <S.Overview>{overview}</S.Overview>
+
+              <S.MyRatingWrapper>
+                <MyRating movieId={selectedMovieId} />
+              </S.MyRatingWrapper>
+            </S.RightContent>
+          </S.Content>
+        </S.ModalContainer>
+      </ModalBackground>
+    </Portal>
+  );
+};
+
+export default DetailModal;

리뷰: 모달 데이터를 불러오고 있는 중에는 모달이 안띄워지더라구요. 그래서 Movie를 클릭했을 때 약간의 지연시간 후에 모달이 띄워지고 있구요! 이 부분에서 사용성을 위해 스켈레톤이나 Suspense같은 UI를 넣는 것은 어떨까요?
@@ -0,0 +1,81 @@
+import {BASE_URL, POPULAR_MOVIE_URL, SEARCH_URL} from '../constants/movie';
+import {MovieDetail, MovieList} from '../types/movie';
+import {createApiErrorMessage} from '../utils/error';
+
+export const getMovieList = async ({pageParam = 1}: {pageParam?: number}) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+  const endpoint = `${POPULAR_MOVIE_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+interface GetSearchedMovieListParam {
+  pageParam?: number;
+  title: string;
+}
+
+export const getSearchedMovieList = async ({pageParam = 1, title}: GetSearchedMovieListParam) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    query: title, // 검색어
+    language: 'ko-KR',
+    page: String(pageParam),
+  };
+
+  const endpoint = `${SEARCH_URL}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieList;
+};
+
+export const getMovieDetail = async (movieId: number) => {
+  const params = {
+    api_key: import.meta.env.VITE_TMDB_TOKEN,
+    language: 'ko-KR',
+  };
+  const endpoint = `${BASE_URL}/movie/${movieId}?${new URLSearchParams(params).toString()}`;
+
+  const response = await fetch(endpoint, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
+      accept: 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(createApiErrorMessage(response.status));
+  }
+
+  const data = await response.json();
+  return data as MovieDetail;
+};

리뷰: 여기서는 as로 리턴하고 있군요! 혹시 타입 단언을 하는 경우도 `getMovieDetail` 함수의 리턴값 타입 추론이 되나요??(잘 몰라서...) 사실 서버로부터 오는 데이터 타입을 Typescript가 추론할 수는 없어서 `as`도 상관없을 것 같지만 권장되는 형식은 명시적인 반환 타입 선언을 하는 방법 같아서요! 올리는 as와 반환 타입 선언 중 어떤 방법이 더 낫다고 생각하는지 궁금해요!
@@ -0,0 +1,56 @@
+import * as S from './styles';
+import Portal from '../common/Portal';
+import ModalBackground from '../common/ModalBackground';
+import useGetMovieDetail from '../../hooks/useGetMovieDetail';
+import {IMAGE_BASE_URL} from '../../constants/movie';
+import {toOneDecimalPlace} from '../../utils/number';
+import filledStar from '../../assets/star_filled.png';
+import MyRating from '../MyRating';
+
+interface DetailModalProps {
+  selectedMovieId: number;
+  closeModal: () => void;
+}
+
+const DetailModal = ({selectedMovieId, closeModal}: DetailModalProps) => {
+  const {data} = useGetMovieDetail(selectedMovieId);
+
+  const {poster_path, vote_average, title, overview, genres} = data;
+
+  return (
+    <Portal>
+      <ModalBackground closeModal={closeModal}>
+        <S.ModalContainer>
+          <S.Header>
+            <h2>{title}</h2>
+            <button type="button" onClick={closeModal}>
+              닫기
+            </button>
+          </S.Header>
+
+          <S.Content>
+            <S.Poster src={`${IMAGE_BASE_URL}${poster_path}`} alt={`${title} 포스터`} />
+
+            <S.RightContent>
+              <S.ShortInfo>
+                <S.Genres>{genres.map(genre => genre.name).join(', ')}</S.Genres>
+                <S.Rate>
+                  <span>{toOneDecimalPlace(vote_average)}</span>
+                  <img src={filledStar} alt="" />
+                </S.Rate>
+              </S.ShortInfo>
+
+              <S.Overview>{overview}</S.Overview>
+
+              <S.MyRatingWrapper>
+                <MyRating movieId={selectedMovieId} />
+              </S.MyRatingWrapper>
+            </S.RightContent>
+          </S.Content>
+        </S.ModalContainer>
+      </ModalBackground>
+    </Portal>
+  );
+};
+
+export default DetailModal;

리뷰: selectedMovieId도 props가 꽤 깊이 있어서 전역으로 관리해도 좋을 것 같은데 어떨까요??
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: 데이터 형식 처리를 MovieList에서 하고 있군요! 제 생각에는 역할에 따라 useGetList에서 담당할 수도 있을 것 같아요!
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: 오 이렇게 무한 스크롤 로직을 분리하니 정말 깔끔하네요👍🏻
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: closest를 사용한 이유가 있을까요?!
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: 오호 이렇게 마지막 요소에 ref를 넣는 방법도 있군요..! 이렇게 하면 확실히 마지막 요소까지 도달했을 때 새로운 API를 호출할 수 있을 것 같아요
@@ -0,0 +1,66 @@
+import {useAtomValue} from 'jotai';
+import useGetList from '../../hooks/useGetList';
+import useInfiniteScroll from '../../hooks/useInfiniteScroll';
+import * as S from './styles';
+import MovieItem from '../MovieItem';
+import SkeletonMovieList from '../skeleton/MovieList';
+import {searchTextAtom} from '../../jotai/atoms';
+
+interface MovieListProps {
+  openModal: (movieId: number) => void;
+}
+
+const MovieList = ({openModal}: MovieListProps) => {
+  const {movies, hasNextPage, fetchNextPage, isLoading, isFetchingNextPage} = useGetList();
+  const searchText = useAtomValue(searchTextAtom);
+
+  const lastElementRef = useInfiniteScroll({
+    fetchNextPage,
+    isLoading,
+    isLastPage: !hasNextPage,
+  });
+
+  const handleItemClick = (event: React.MouseEvent<HTMLUListElement>) => {
+    event.preventDefault();
+
+    const listItem = (event.target as HTMLElement).closest('li');
+    if (!listItem) return;
+
+    const id = Number(listItem.id);
+    const selectedItem = movies.find(item => item.id === id);
+
+    if (selectedItem) openModal(id);
+  };
+
+  return (
+    <S.MovieList onClick={handleItemClick}>
+      {isLoading ? (
+        <SkeletonMovieList />
+      ) : (
+        movies.map((movie, index) => {
+          const isLastMovie = index === movies.length - 1;
+
+          return (
+            <div
+              key={movie.id}
+              ref={isLastMovie ? lastElementRef : null} // 마지막 요소에 ref 연결
+            >
+              <MovieItem
+                key={movie.id}
+                id={movie.id}
+                title={movie.title}
+                poster_path={movie.poster_path}
+                vote_average={movie.vote_average}
+              />
+            </div>
+          );
+        })
+      )}
+      {isFetchingNextPage && <SkeletonMovieList />}
+
+      {searchText && movies.length === 0 && <S.Message>검색 결과가 없습니다.</S.Message>}
+    </S.MovieList>
+  );
+};
+
+export default MovieList;

리뷰: 이 부분을 새로운 컴포넌트로 분리해보는 건 어떨까요? ref를 MovieItem에 직접 넘겨주는 방법으로도 해볼 수 있을 것 같아요