4️React Router에서 App Router로

실제 마이그레이션의 대상이 되는 앱은 조금 더 복잡한 상황일 가능성이 높습니다. 공식 문서에 나온 마이그레이션 가이드만으론 부족함이 있죠. 이번에는 React Router를 사용하고 있는 앱을 App Router로 마이그레이션 해보며 조금이나마 실제 프로젝트에 도움이 되는 사례를 만들어보겠습니다.

예제

Vite를 이용한 React 예제입니다. React Router를 이용해 상품 목록, 상품 상세 페이지를 구현했습니다. 데이터는 Fake Store API를 이용합니다. UI를 구현하기 위해 TailwindCSS 기반의 daisyUI를 함께 사용합니다.

React Router 공식 예제를 참고해 만들었습니다. 아래와 같은 페이지 구성을 갖습니다.

  1. 홈 페이지 (/)

  2. 상품 목록 페이지 (/products)

  3. 상품 상세 페이지 (/products/:id)

  4. 404 NotFound 페이지 (/not-found)

마이그레이션

Step 0: Vite에서 Next.js로

우선 앞서 배운 Vite에서 Next.js로 마이그레이션하는 가이드에 맞춰 Next.js 앱으로 만듭니다.

Step 1: next.config.js 수정하기

HTML/CSS/JS만 빌드하면 동작하는 앱으로 만들었던 부분을 수정합니다. Next.js 기본 설정과 동일하게 만듭니다. 이제 모든 파일이 .next 폴더에 빌드됩니다.

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
//  output: 'export',
//  distDir: './dist',
}

export default nextConfig

tsconfig.json 파일도 함께 수정합니다. include에 있던 "./dist/types/**/*.ts""./next/types/**/*.ts"로 변경합니다.

tsconfig.json
{
  // "include": ["src", "./dist/types/**/*.ts", "./next-env.d.ts"],
  "include": ["src", "./next/types/**/*.ts", "./next-env.d.ts"],
}

Step 2: React Router에서 App Router로

React Router는 클라이언트에서 라우팅을 모두 처리합니다. 현재는 dynamic 함수를 이용해 App 컴포넌트가 클라이언트에서만 렌더링되도록 만들어 둔 상태입니다. 덕분에 React Router를 이용하는데 무리가 없죠.

하지만 이 방식은 온전한 Next.js의 라우팅 방식과는 거리가 있습니다. 파일 시스템 기반의 라우팅 그리고 서버 사이드 렌더링을 사용하기 위해 App Router로 온전히 이동해보겠습니다.

우선 React Router에서 사용하고 있는 페이지를 App Router의 폴더로 만들어야 합니다. 예제에는 총 4개의 페이지가 존재합니다.

  1. 홈 페이지 (/, <Home>)

  2. 상품 목록 페이지 (/products, <ProductList>)

  3. 상품 상세 페이지 (/products/:id, <ProductDetail>)

  4. 404 NotFound 페이지 (/not-found, <NoMatch>)

하나씩 App Router로 옮겨봅시다.

홈 페이지 (/, <Home>)

루트(/)에 두기 위해선 app 폴더 바로 아래에 page.tsx 파일을 만들면 됩니다.

app/page.tsx
export default function Home() {
  return <h1>Home</h1>
}

React Router의 <Outlet> 컴포넌트를 사용하고 있던 <Layout> 컴포넌트는 layout.tsx로 옮깁니다. <Outlet>children으로 바꿔줍니다.

App.tsx
function Layout() {
  return (
    <div className="...">
      <NavigationBar />
      <Outlet />
    </div>
  )
}
app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <div id="root">
          <div className="...">
            <NavigationBar />
            {children}
          </div>
        </div>
      </body>
    </html>
  )
}

상품 목록 페이지 (/products, <ProductList>)

/products에 두기 위해선 app/products 폴더를 만들고 page.tsx 파일을 만들면 됩니다.

app/products/page.tsx
export default async function ProductPage() {
  const response = await fetch('https://fakestoreapi.com/products')
  const products = await response.json()

  return (
    <div>
      <h2 className="...">상품 목록</h2>
      <ul className="...">
        {products.map((product: Product) => (
          <Link href={`/products/${product.id}`}>
            <ProductCard title={product.title} image={product.image} />
          </Link>
        ))}
      </ul>
    </div>
  )
}

원본은 아래와 같습니다. 서버 컴포넌트를 사용하면 컴포넌트가 마운트되면 useEffect에서 데이터를 페칭하던 코드를 바로 페칭하도록 변경할 수 있습니다.

App.tsx
function ProductList() {
  const [products, setProducts] = useState([])

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://fakestoreapi.com/products')
      const data = await response.json()
      setProducts(data)
    }

    fetchData()
  }, [])

  // ...
}

<Link> 컴포넌트 역시 react-router-dom이 아닌 next/link로부터 가져오도록 변경합니다.

// Before
import { Link } from 'react-router-dom'
<Link to={`...`}>

// After
import Link from 'next/link'
<Link href={`...`}>

상품 상세 페이지 (/products/:id, <ProductDetail>)

동적 라우트를 사용하는 페이지입니다. app/products/[id] 폴더를 만들고 page.tsx 파일을 만들면 됩니다.

/products/[id]/page.tsx
export default async function ProductDetailPage({
  params,
}: {
  params: { id: string }
}) {
  const { id } = params
  const response = await fetch(`https://fakestoreapi.com/products/${id}`)
  const product = await response.json()

  return (
    <div>
      <h2 className="...">상품 상세</h2>
      {product && (
        <ProductCard
          title={product.title}
          image={product.image}
          description={product.description}
        />
      )}
      {product && <ProductCounter />}      
    </div>
  )
}

<ProductCounter>의 경우 useState를 포함하고 있습니다. 서버 컴포넌트로 렌더링할 수 없어 'use client'를 명시해 클라이언트 컴포넌트로 바꿔줍니다.

404 NotFound 페이지 (/not-found, <NoMatch>)

일치하는 경로가 없는 경우 이동하는 페이지입니다. Next.js에서는 일치하는 경로가 없을 때 이동하는 라우트로 not-found 페이지를 정의할 수 있습니다. 해당 페이지는 에러 페이지와 마찬가지로 클라이언트 컴포넌트로 정의합니다.

app/not-found.tsx
'use client'
import Link from 'next/link'

export default function NotFoundPage() {
  return (
    <div>
      <h2 className="text-center">404 Not Found</h2>
      <p>
        <Link href="/">홈으로 이동</Link>
      </p>
    </div>
  )
}

Step 3: Image 컴포넌트 사용하기

상품 정보를 보여주는 <ProductCard> 컴포넌트에는 이미지가 존재합니다. Next.js는 이미지 최적화를 돕는 Image 컴포넌트를 제공합니다. img 태그를 확장한 기능이죠.

우선 외부에 있는 이미지를 가져오기 위해 next.config.js 설정을 수정해야 합니다.

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'fakestoreapi.com',
        pathname: '/img/*',
      },
    ],
  },
}

export default nextConfig

이제 img 태그를 Image 컴포넌트로 교체해주면 됩니다.

import Image from 'next/image'

type Props = {
  title: string
  image: string
  description?: string
}

export default function ProductCard(props: Props) {
  return (
    <li className="card card-compact w-90 bg-base-100 shadow-xl">
      <figure className="relative h-60">
        <Image
          src={props.image}
          alt={props.title}
          fill
          style={{ objectFit: 'cover', objectPosition: 'top' }}
        />
      </figure>
      <div className="card-body">
        <h2 className="card-title">{props.title}</h2>
        {props.description && <p>{props.description}</p>}
      </div>
    </li>
  )
}

이슈 핸들링

PostCSS의 설정과 Next.js가 충돌하는 이슈가 있습니다. 이를 해결하기 위해 아래와 같이 postcss.config.js 파일에 있는 설정을 package.json으로 옮겨줄 수 있습니다.

package.json
"postcss": {
  "plugins": {
    "tailwindcss": {},
    "autoprefixer": {}
  }
}

이슈 PR은 여기서 확인해보세요. https://github.com/vercel/next.js/issues/34448

Last updated