JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

nextjs/마이그레이션 예제

Next.js로 마이그레이션하기(영화목록 2)

lamarcK 2025. 4. 13. 00:56

MovieDetailPage 부분

1. 파일 상단에 'use client' 지시문 추가

**제목
'use client'

Next.js 13 이상에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트이다.

React hooks를 사용하는 컴포넌트는 클라이언트 컴포넌트로 지정해야 하므로 'use client' 지시문이 필요

// 기존 React Router
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();

// Next.js
import { useParams, useRouter } from 'next/navigation';
  const params =useParams();
  const id = params?.id as string; // ?가 없을 경우
  const router = useRouter();

2. 라우팅 관련 훅 변경

// 기존 React Router
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();

// Next.js
import { useParams, useRouter } from 'next/navigation';
  const params =useParams();
  const id = params?.id as string; // ?가 없을 경우
  const router = useRouter();
  1. const { id } = useParams<{ id: string }>(); → const params = useParams();
    • useParams()가 반환하는 것이 객체이기 때문에 변수명을 바꿔준다.(이름은 사실 상관없다. id 사용해도 괜찮음)
    • 이 객체는 URL의 모든 동적 파라미터를 포함한다.
  2. 동적 파라미터(Dynamic Parameters)는 URL에서 변할 수 있는 값을 의미한다.
    • 쉽게 말해서, URL의 일부분이 고정되지 않고 상황에 따라 달라질 수 있는 부분이다.
    • http://localhost:3000/MovieDetailPage/1045938 만약에 이렇다면
    • http://localhost:3000/MovieDetailPage/ 부분까지가 고정된 url이고 숫자부분이 동적 파라미터이다.
    • Next.js는 폴더 구조가 url의 경로이기도 하기 때문에 폴더 구조로 동적 파라미터를 설정한다.
        //기본 동적 라우트 [id] 부분
        /pages/MovieDetailPage/[id]/page.tsx
        //[id] : 대괄호 안의 내용이 동적 파라미터

        //여러 동적 파라미터 [category]/[id] 부분
        /pages/[category]/[id]/page.tsx

        URL 예시: /action/1045938
        params: { category: 'action', id: '1045938' }
  1. const id = params?.id as string;
    • Next.js의 useParams()는 제네릭을 지원하지 않기 때문에 타입 단언을 별도로 해줘야한다.
    • params.id에만 타입 단언이 필요한데, 불필요하게 전체 객체에 타입 단언을 하지 않기 위해 params.id에만 적용한다.
    • params의 값이 null 또는 undefined일 수 있기 때문에 옵셔널 체이닝(Optional Chaining) 연산자인 '?'를 사용해서 값이 null이거나 undefined일때 undefined를 반환해서 오류를 없애준다.
  2. const navigate = useNavigate(); → const router = useRouter();
    • useRouter()는 Next.js의 내장 훅으로, 페이지 간 네비게이션을 제어하는데 사용되며 navigate 보다 더 많은 기능을 제공한다.
        // Next.js                     | React Router
        router.push('/home')           | navigate('/home')
        router.replace('/home')        | navigate('/home', { replace: true })
        router.back()                  | navigate(-1)
        router.forward()               | navigate(1)
        router.refresh()               | [대응되는 기능 없음]
        router.prefetch()              | [대응되는 기능 없음]

3. 페이지 파일 위치

  • 기존 : src/pages/MovieDetailPage.tsx
  • Next.js : /app/movie/[id]/page.tsx

4. 네비게이션 로직 변경

// 기존
<BackButton onClick={() => navigate(-1)}>

// Next.js
<BackButton onClick={() => router.back()}>
  • navigate(-1) → router.back() : 네비게이션 방식에서 라우터 방식으로 뒤로가기 구현

5. 타입스크립트 통합

import { Params } from 'next/dist/shared/lib/router/utils/route-matcher'

Next.js는 자체 타입 정의를 제공하므로, 이를 활용하는 것이 권장된다.

6. 이미지 처리

// 기존
<PosterImage 
  src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`} 
  alt={movie.title} 
        />
const PosterImage = styled.img`
  max-width: 300px;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
`;

// Next.js
import Image from 'next/image'
<MoviePoster 
          src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
          alt={movie.title}
          width={0}
          height={0}
          sizes="400px"
          style={{
            width: 'auto',
            height: '375px',
          }}
        />
  • import Image from 'next/image' : Next.js의 내장 이미지 최적화 컴포넌트이다.
  • Image 컴포넌트로 감싸고 설정을 해준다.
  • 기존 styled-components 스타일을 그대로 사용하면 Next.js의 이미지 최적화 기능을 사용할 수 없게된다. 때문에 최적화가 필요없고 편의성을 위한다면 기존 스타일을 사용하고 그렇지 않다면 Image 컴포넌트를 사용하면 된다.
    1. 자동 이미지 최적화
      • img 태그는 원본 이미지를 그대로 불러와서 큰 이미지 파일을 그대로 로드하게 된다.
      • Image 컴포넌트는 원본 이미지를 실제 표시할 크기(300px)에 맞게 서버에서 자동으로 리사이징하면서 이미지 용량도 최적화한다.(예: 200KB로 압축)
    2. WebP와 같은 최신 이미지 포맷 지원
    3. 지연 로딩 기능 제공 : 기존의 태그는 지연 로딩을 수동으로 구현해야 한다.
    4. 레이아웃 시프트 방지 : 레이아웃 시프트(Layout Shift) : 이미지가 로드되기 전과 후에 페이지 레이아웃이 흔들리는 현상

또한 기본적으로 Image 컴포넌트는 width와 height가 명시적으로 존재해야한다.

<MoviePoster 
          src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
          alt={movie.title}
          width={375px}
          height={400px}
          sizes="400px"
        />

이렇게 명시적으로 width={375px}, height={400px} 크기가 정해져야한다.

<MoviePoster 
          src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
          alt={movie.title}
          width={0}
          height={0}
          sizes="400px"
          style={{
            width: 'auto',
            height: '375px',
          }}
        />
  • 혹은 width={0}, height={0}으로 Next.js Image의 기본 레이아웃 계산을 비활성화한 다음
  • style 속성에 따라 크기가 결정되도록 할 수 있다.
  • 이 경우엔 이미지의 원본 비율을 유지하면서 이미지의 크기가 결정된다.
  • Next.js Image 컴포넌트의 필수 속성
    • src (이미지 경로)
    • alt (대체 텍스트)
    • width
    • height
  • 추가 속성
    • sizes ={크기} : 이 경우 불러오는 이미지의 대략적인 크기를 정한다. 만약에 없다면 100vw를 기본으로 잡아서 너무 큰 이미지를 로드하게 되어 로딩이 느려질 수 있다.
    • priority={true} : 페이지 로드 시 즉시 로딩
    • quality={75} : 1-100 사이의 이미지 품질
    • placeholder="blur" : 이미지 로딩 중 표시할 블러 효과
    • loading="lazy" : 지연 로딩 (기본값)
'use client'  // 추가됨

import { useState, useEffect } from 'react';
import { getMovieDetails, MovieDetail } from '@/services/movieApi';
import styled from 'styled-components'; // 스타일링을 위해 사용
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image'

const MovieDetailPage = () => {
  const params =useParams();
  const router = useRouter();
  const id = params?.id as string;

  const [movie, setMovie] = useState<MovieDetail | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchMovieDetail = async () => {
      try {
        if (!id) throw new Error('Movie ID is required');
        const data = await getMovieDetails(id);
        setMovie(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred');
      } finally {
        setLoading(false);
      }
    };

    fetchMovieDetail();
  }, [id]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!movie) return <div>Movie not found</div>;

  return (
    <Container>
      <BackButton onClick={() => router.back()}>← 이전</BackButton>

      <MovieHeader>
      <Image 
      src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
      alt={movie.title}
      width={300}
      height={450}
      />
        <MovieInfo>
          <h1>{movie.title}</h1>
          <Tagline>{movie.tagline}</Tagline>

          <InfoSection>
            <h3>개요</h3>
            <p>{movie.overview}</p>
          </InfoSection>

          <InfoGrid>
            <InfoItem>
              <Label>출시일</Label>
              <Value>{new Date(movie.release_date).toLocaleDateString()}</Value>
            </InfoItem>

            <InfoItem>
              <Label>상영 시간</Label>
              <Value>{movie.runtime} 분</Value>
            </InfoItem>

            <InfoItem>
              <Label>평점</Label>
              <Value>{movie.vote_average.toFixed(1)} / 10</Value>
            </InfoItem>
          </InfoGrid>

          <GenreList>
            {movie.genres.map(genre => (
              <GenreTag key={genre.id}>{genre.name}</GenreTag>
            ))}
          </GenreList>
        </MovieInfo>
      </MovieHeader>

      <AdditionalInfo>
        <h2>제작사</h2>
        <CompanyList>
          {movie.production_companies.map(company => (
            <CompanyItem key={company.id}>
              {company.name}
            </CompanyItem>
          ))}
        </CompanyList>
      </AdditionalInfo>
    </Container>
  );
};

// Styled Components
const Container = styled.div`
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  border: 5px solid;
  border-image: linear-gradient(120deg, rgb(0, 42, 105) 0%, rgb(89, 167, 255) 100%) 1;
`;

const BackButton = styled.button`
  padding: 8px 16px;
  margin-bottom: 20px;
  border: none;
  background-color:rgb(200, 200, 240);
  border-radius: 25px;
  cursor: pointer;

  &:hover {
  background-color:rgb(161, 161, 255);  }
`;

const MovieHeader = styled.div`
  display: flex;
  gap: 40px;
  margin-bottom: 40px;

  @media (max-width: 768px) {
    flex-direction: column;
  }
`;

const MovieInfo = styled.div`
  flex: 1;
`;

const Tagline = styled.p`
  font-style: italic;
  color: #666;
  margin-bottom: 20px;
`;

const InfoSection = styled.div`
  margin-bottom: 20px;
`;

const InfoGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
  margin-bottom: 20px;
`;

const InfoItem = styled.div`
  background-color: #f8f8f8;
  padding: 15px;
  border-radius: 8px;
`;

const Label = styled.div`
  font-size: 0.9em;
  color: #666;
  margin-bottom: 5px;
`;

const Value = styled.div`
  font-weight: bold;
`;

const GenreList = styled.div`
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
`;

const GenreTag = styled.span`
background:rgb(222, 221, 255);
  padding: 5px 10px;
  border-radius: 15px;
  font-size: 0.9em;
`;

const AdditionalInfo = styled.div`
  margin-top: 40px;
`;

const CompanyList = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
  margin-top: 20px;
`;

const CompanyItem = styled.div`
  background-color: #f8f8f8;
  padding: 15px;
  border-radius: 8px;
  text-align: center;
`;

export default MovieDetailPage

2. MovieCard tsx부분

import 부분 변경

// MovieCard.tsx
(제거) import { Link } from 'react-router-dom';
(추가) import Link from 'next/link';
(추가) import Image from 'next/image';

Link 컴포넌트 속성 변경

<Card>
(삭제)<Link to={`/movie/${movie.id}`}>
(변경)<Link href={`/movie/${movie.id}`}>

스타일링 수정

(삭제) const MoviePoster = styled.img`
(변경) const MoviePoster = styled(Image)`

//Next.js
const MoviePoster = styled(Image)`
object-fit: cover;
`;

//width와 height는 Image 컴포넌트에 작성 되야 하기 때문에 삭제

이미지 처리 방식 변경

//기존
<MoviePoster 
    src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`} 
    alt={movie.title} 
/>

//Next.js
<MoviePoster 
    src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
    alt={movie.title}
    width={500}
    height={375}
    priority
/>

next.config.js 파일에 이미지 도메인 설정

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    domains: ['image.tmdb.org'],
  },
};

export default nextConfig;
  • Next.js의 Image 컴포넌트는 보안상의 이유로 외부 이미지 도메인을 명시적으로 설정해야 한다.
  • 때문에 도메인 주소인 image.tmdb.org부분을 config 파일에 입력해줘야한다.
  • 다수의 도메인의 경우 쉼표로 구분해서 넣어주면 된다.

movieApi.ts 부분

환경 변수 설정

(제거) const API_KEY = import.meta.env.VITE_APP_TMDB_API_KEY;
(추가) const API_KEY = process.env.NEXT_PUBLIC_TMDB_API_KEY;

.env.local 파일 생성

NEXT_PUBLIC_TMDB_API_KEY=api키 번호
  • Vite의 .env 파일에서 Next.js의 .env.local 파일로 변경

파일 경로

moviedetail의 경우 id를 전달하기 때문에

app/MovieDetailPage/[id]/page.tsx

구조여야한다.