3️CRA에서 Next.js로

create-react-app(이하 CRA)은 한동안 React를 시작하는 표준이었습니다. React 공식 문서에서도 해당 도구를 사용하길 권했죠. 현재는 React 공식 문서에서도 Next.js, Remix 등으로 시작하길 권하고 있습니다.

그럼에도 여전히 많은 분들이 CRA로 React를 시작하고 있습니다. 그만큼 편리하고 쉽기 때문이겠죠. 하지만 이 역시 Next.js로 마이그레이션이 필요한 경우가 있습니다. 이번에는 CRA를 Next.js 앱으로 마이그레이션해보겠습니다.

Vite와 달리 CRA는 Next.js 공식 문서에서 마이그레이션 가이드를 제공하지 않고 있습니다. 앞서 배운 Vite 마이그레이션과 Next.js의 설정을 토대로 CRA를 React 앱으로 바꿔봅시다.

Step 1: Next.js 의존성 설치하기

아래의 명령어를 실행해 Next.js 최신 패키지를 설치합니다.

npm install next@latest

Step 2: Next.js 설정 파일 만들기

next.config.js 파일을 만듭니다. 관련 설정에 대해서는 앞서 언급한 <Next.js 설정 읽어보기 - next.config.js>에서 확인하실 수 있습니다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export', // HTML/CSS/JS만 빌드해 Single-Page Application으로 만듭니다. 
  distDir: './build', // 빌드된 결과물이 ./build 폴더에 담깁니다.
}

module.exports = nextConfig

Step 3: TypeScript 설정 수정하기

CRA로 시작한 앱의 TypeScript 설정 중 일부를 제거하거나 변경합니다. 특별한 변경이 없었다면 아래와 같은 기본 설정을 가지고 있습니다.

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}

이 파일에서 6가지를 변경합니다. 한 단계씩 수행해봅시다.

  1. include 배열에 문자열 "./build/types/**/*.ts""./next-env.d.ts"를 추가합니다.

  2. exclude 필드를 만들고 ["./node_modules"]를 추가합니다.

  3. moduleResolution 설정을 "bundler"로 변경합니다.

  4. jsx 설정을 "preserve"로 변경합니다.

  5. compilerOptionsincremental을 추가하고 true로 활성화합니다.

  6. compilerOptionsplugins를 추가하고 값으로 [{ "name": "next" }]를 추가합니다.

결과적으로 아래와 같은 설정이 완성됩니다. 각 설정에 대한 설명은 <Next.js 설정 읽어보기 - tsconfig.json>에서 확인하실 수 있습니다.

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ]
  },
  "include": [
    "src",
    "./build/types/**/*.ts",
    "./next-env.d.ts",
  ],
  "exclude": [
    "./node_modules"
  ]
}

Step 4: Root Layout 만들기

Next.js의 App Router에서는 루트 라우트인 app 폴더에 layout 파일이 반드시 필요합니다.

CRA 앱에서는 index.html 파일이 유사한 역할을 합니다. index.html 파일에는 <html>, <head> 그리고 <body> 태그가 포함되어 있습니다.

총 6단계를 거쳐 index.html 파일을 layout 파일로 변환해봅니다.

  1. src 폴더에 app 폴더를 만듭니다.

  2. app 폴에 layout.tsx 파일을 만듭니다. RootLayout 컴포넌트를 아래와 같이 정의합니다.

src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return null
}
  1. index.html에 있는 태그를 앞서 만든 RootLayout 컴포넌트로 붙여넣습니다. 그리고 <body> 태그 안에 있는 값을 <div id="root">{children}</div>로 대체합니다.

src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="theme-color" content="#000000" />
        <meta
          name="description"
          content="Web site created using create-react-app"
        />
        <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
        <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
        <title>React App</title>
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}
  1. Next.js에서는 meta charset과 meta viewport 태그는 이미 포함되어 있습니다. <head> 태그에 있는 값을 지워도 괜찮습니다.

src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        // <meta charset="utf-8" />
        <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
        // <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="theme-color" content="#000000" />
        <meta
          name="description"
          content="Web site created using create-react-app"
        />
        <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
        <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
        <title>React App</title>
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}
  1. favicon.ico, icon.png, robots.txt와 같은 메타데이터 파일을 app 폴더 바로 아래에 두기만하면 <head> 태그에 자동으로 추가됩니다. 지원하는 모든 파일app 폴더로 옮겼다면 <link> 태그 역시 지워도 괜찮습니다. (PWA(Progressive web apps)을 위해 사용하는 manifest.json 파일은 주제에서 벗어나 우선 제외합니다.)

src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        // <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
        <meta name="theme-color" content="#000000" />
        <meta
          name="description"
          content="Web site created using create-react-app"
        />
        // <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
        // <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
        <title>React App</title>
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}
src/app/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'React App',
  description: 'Web site created using create-react-app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        <meta name="theme-color" content="#000000" />
        // <meta
        //   name="description"
        //   content="Web site created using create-react-app"
        // />
        // <title>React App</title>
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}
  1. 남아있는 theme-colorviewport 객체를 만들어 옮겨줍니다.

src/app/layout.tsx
import { Metadata, Viewport } from 'next'

export const metadata: Metadata = {
  title: 'React App',
  description: 'Web site created using create-react-app',
}

export const viewport: Viewport = {
  themeColor: '#000000',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      // <head>
      //   <meta name="theme-color" content="#000000" />
      // </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  )
}

<link rel="stylesheet"/> 태그의 경우 Next.js의 Metadata API에서 지원하지 않습니다. 현재는 기존처럼 <head> 태그에 둬도 무방합니다. 참고1, 참고2

Step 5: 엔트리포인트 페이지 만들기

CRA 앱에서는 index.tsx 파일이 엔트리포인트(진입점) 역할을 합니다. Next.js에서는 page.tsx 파일이 동일한 역할을 합니다. 이번에는 엔트리포인트 페이지를 만들어봅니다.

지금은 CRA 앱을 Next.js 앱으로 점진적으로 마이그레이션하고 있습니다. 한 번에 모든 걸 바꿨을 때 발생하는 리스크를 줄이기 위함이죠. 때문에 당장은 Next.js의 파일 시스템 기반 라우팅 방식으로 완전히 전환하지 않습니다.

app 폴더에 page.tsx 파일을 만들고 아래와 같은 내용을 정의합니다.

app/page.tsx
'use client'
 
import dynamic from 'next/dynamic'
import '../index.css'
 
const App = dynamic(() => import('../App'), { ssr: false })
 
export default function Page() {
  return <App />
}

Next.js에서 클라이언트 컴포넌트는 브라우저로 보내기 전에 서버에서 HTML로 미리 렌더링됩니다. 점진적인 마이그레이션을 위해 <App> 컴포넌트가 서버에서 미리 렌더링되는 것을 막아둬야 합니다. 서버에서 HTML로 미리 렌더링할 수 없는 컴포넌트가 존재할 수 있기 때문입니다.

아래와 같이 dynamic 함수의 ssr 옵션을 false로 정의해 <App> 컴포넌트가 서버에서 렌더링되지 않도록 합니다.

const App = dynamic(() => import('../App'), { ssr: false })

Step 6: 정적 이미지 가져오기 방식 수정하기

CRA에서는 정적 이미지를 가져오면 이미지의 URL이 문자열로 반환됩니다.

App.tsx
import image from './img.png' // `image`는 '/assets/img.2d8efhg.png'와 같은 문자열
 
export default function App() {
  return <img src={image} />
}

반면, Next.js에서는 정적 이미지를 가져오면 객체가 반환됩니다. 이 객체는 Next.js의 <Image> 컴포넌트와 함께 사용하거나 <img> 태그의 src 프로퍼티에 사용할 수도 있습니다.

<Image> 컴포넌트로 변경하면 최적화 등에 이점이 있지만 이 역시 점진적인 마이그레이션을 위해 나중으로 미루겠습니다. <img> 태그를 이용해 이미지 가져오기 방식을 수정하겠습니다.

  1. /public 폴더로부터 가져오는 절대 경로로 지정된 이미지 경로를 상대 경로로 변경합니다.

// Before (CRA)
import logo from '/logo.png'
 
// After (Next.js)
import logo from '../public/logo.png'
  1. src 프로퍼티에 객체를 전달하는 대신 src 값을 전달합니다.

// Before (CRA)
<img src={logo} />
 
// After (Next.js)
<img src={logo.src} />

혹시 타입 에러를 마주해도 당황하지 마세요. 이후에 next-env.d.ts 파일이 정의되면 에러가 해결됩니다.

Step 7: 환경 변수 마이그레이션하기

Next.js와 CRA는 모두 .env 파일로 환경 변수를 정의합니다. 다른 점은 클라이언트 측에 노출되는 환경 변수의 접두사입니다. 마이그레이션을 위해 REACT_APP_ 접두사가 붙은 모든 환경 변수를 NEXT_PUBLIC_로 변경합니다.

Step 8: package.json 스크립트 수정하기

이제 Next.js 앱을 실행시켜볼 수 있습니다. 그 전에 package.json 파일의 scripts 필드를 수정해볼까요.

package.json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
  }
}

package.json 파일의 eslintConfig 영역을 지우고 npm run lint 명령어를 실행하면 Next.js의 기본 ESLint 설정이 적용된 .eslintrc 파일이 생성됩니다.

다음으로 Next.js와 연관된 몇가지 폴더(.next)와 파일(next-env.d.ts)을 .gitignore에 추가합니다.

.gitignore
# ...
.next
next-env.d.ts

이제 npm run dev 명령어를 실행하고 http://localhost:3000 페이지를 열어보시면 Next.js로 돌아가는 앱을 확인하실 수 있습니다.

next.config.js 파일을 읽을 때 "ReferenceError: module is not defined in ES module scope" 와 같은 에러가 발생했다면 next.config.js 파일의 마지막 줄을 export default nextConfig로 변경해주세요.

Step 9: 정리하기

이제 CRA와 관련된 파일과 패키지를 제거하면 끝입니다.

  • index.tsx 삭제하기

  • index.html 삭제하기

  • react-app-env.d.ts 삭제하기

  • react-scripts 패키지 제거하기

Last updated