한땀한땀 Next로 프로젝트 마이그레이션 하기

2023. 3. 17. 17:48React/Next.js

 

릴리즈 목적의 프로젝트를 진행하다보면 검색 엔진 등 이점을 받아보기 위해 SSR을 도입하고 싶고, 그 중에서도 Next로 마이그레이션을 하고 싶어할 것 같다. 미루고 미루고 미루다가 이제야 Next로 마이그레이션 하면서 고민했던 프로세스를 공유해보려고 한다.

내 경우에는 CRA로 생성한 프로젝트가 아니라 웹팩으로 한땀한땀 바벨로 폴리필 해줬던 프로젝트여서 더 힘들었던 것 같은데 이 글이 나와 같이 막막한 사람들에게 도움이 되었으면 좋겠다는 마음이다.

 

0. react-dom 프로젝트 구성


먼저 마이그레이션 전 스택과 폴더 구조를 설명해야할 것 같다. 가장 신경써야할 부분이 번들러 관련된 부분일 것 같다.

- 번들러: webpack

- 상태관리: react-redux, reduxjs/toolkit

- 비동기: apollo-client(graphQL)

- 스타일: tailwindcss

 

(혹여나 까보고 싶은 분들을 위해 react-dom 브랜치를 따로 만들어두었다.)

 

GitHub - howdyfrom2019/tweetql

Contribute to howdyfrom2019/tweetql development by creating an account on GitHub.

github.com

📂 public
📂 src
ㄴ📂 assets
ㄴ📂 components
ㄴ📂 hooks
ㄴ📂 routes
ㄴ📂 store
ㄴ📂 type
ㄴ📂 utils
📜App.tsx
📜Index.tsx

 

next와 react-dom의 가장 큰 차이는 react-router-dom으로 하는 네비게이션 관리가 있냐 없냐이다. 가장 신경써야할 부분이 바로 routes를 pages로 변경하는 과정이다. 또 해당 프로젝트에서 지원하는 특정 assets들(svg라던가, mp3라던가 등등)을 url-loader 등으로 읽어오기 위해 next.config.js를 설정하는 과정이 가장 큰 비중을 차지한다.

 

1. package.json


먼저, CRA로 빌드된 프로젝트가 아니기 때문에 아래와 같이 명령어가 설정되어 있었다.

// package.json (react-dom)
"scripts": {
    "start": "npm run dev -- --open",
    "dev": "cross-env NODE_ENV=development webpack serve",
    "build": "cross-env NODE_ENV=production webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

 

dev 명령어가 webpack을 개발환경에서 실행시키는 명령어와 같은데, 환경을 넣어주는 방법으로 직접 NODE_ENV를 푸시해주기 때문에 cross-env를 사용했다. next에서도 똑같이 development 환경에서 설정할 다른 설정을 가져갈 일이 있다면 아래와 같이 바꾸면 된다.

 

// package.json (next)
"scripts": {
    "dev": "cross-env NODE_ENV=development next dev",
    "build": "cross-env NODE_ENV=production next build",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

 

webpack을 next로 바꾸게 되면서 webpack.config.ts파일이 의미가 없어졌다. 이제 next.config.js로 새로 configuration을 넣어줘야한다. (next는 next.config.ts를 지원하지 않기 때문에, next.config.mjs, next.config.js 중 하나로 구성해야한다.)

next에서는 페이지 라우팅을 pages폴더 내부에서 next 서버의 라우팅으로 처리하기 때문에 react-router-dom을 제거해야할 필요도 있다.

 

npm i -D next
npm uninstall -D react-router-dom

 

2. next.config.js 생성


 

next.config.js는 package.json이 있는 계층에 생성해준다.

next는 svg파일 loader가 내장되어있지 않기 때문에 설정에 추가해줘야한다. 내 프로젝트 경우에는 추가로 mp3 파일을 다루기 때문에 해당 파일 loader도 추가해준다.

 

/** @type {import('next').NextConfig} */
module.exports = {
  reactStrictMode: true, // 개발모드에서만
  swcMinify: true, // 빌드 컴파일러 관련 설정

  webpack(config) {
    // svg 파일 loader 관련 설정.
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack']
    });
    // mp3, ogg 파일 loader 관련 설정.
    config.module.rules.push({
      test: /\.(mp3|ogg)$/i,
      loader: 'file-loader'
    })
    return config;
  }
}

 

먼저, 해당 확장자를 읽어오는 로더를 설치되어있지 않는다면 추가로 설치해주자. 음성 파일은 프로젝트에서 위치하는 경로만 읽어와서 audio 태그에 넣어주는 용도이므로 file-loader로 충분히 대응 가능하다.

 

npm i -D @svgr/webpack file-loader

 

그런데 빌드 스크립트를 작성할 때, development와 production 모드를 나눴었다. next.config.js에서 이 설정에 따라 다른 속성을 넣어줄 수 있는데, 그 중 가장 대표적인 react strictmode를 다루는 속성을 개발모드에서만 넣는다고 가정해보자.

 

const { PHASE_DEVELOPMENT_SERVER } = require('next/constants')

const defaultConfig = {
  webpack(config) {
    // svg 파일 loader 관련 설정.
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack']
    });
    // mp3, ogg 파일 loader 관련 설정.
    config.module.rules.push({
      test: /\.(mp3|ogg)$/i,
      loader: 'file-loader'
    })
    return config;
  }
}

module.exports = (phase, { defaultConfig }) => {
  if (phase === PHASE_DEVELOPMENT_SERVER) {
    return {
      reactStrictMode: true,
      ...defaultConfig
    }
  }

  return defaultConfig
}

 

phase 속성을 바탕으로, development 모드를 구분하여 다른 속성을 넣어주는 것이 가능하다. 대응할 수 있는 정말 많은 속성이 있다. BundleAnalyzer를 최소화한다던가.. 프로덕션모드에서 최적화 관련된 속성들이 많으니 찾아보면 좋을 것 같다.

next의 공식문서에는 대응할 수 있는 속성을 따로 공유해주고 있으니 찾아보고 적용해보면 좋을 것 같다.

https://github.com/vercel/next.js/blob/canary/packages/next/src/server/config-shared.ts

 

GitHub - vercel/next.js: The React Framework

The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.

github.com

 

※ webpack.config.ts에 비해서 보일러 플레이트 코드가 적다.


 

CRA 없이 react 프로젝트를 세팅할 때 가장 난해했던 부분이 webpack에서 모든 확장자에 대한 모듈 처리를 해주는 부분이었는데, next 컴파일러는 기본 세팅이 잘 되어있다. 타입 체크 에러 관련 에러라던가, 증분 컴파일 기법들에 대한 내용 역시 공식 문서에 나와있으니 한 번 살펴보면 좋을 것 같다.

https://nextjs.org/docs/basic-features/typescript#type-checking-nextconfigjs

 

Basic Features: TypeScript | Next.js

Next.js supports TypeScript by default and has built-in types for pages and the API. You can get started with TypeScript in Next.js here.

nextjs.org

 

3. 엔트리포인트 파일(보일러플레이트 코드) 생성


react-dom에선 public 폴더의 index.html을 바탕으로 spa가 구동되었었는데 next에서는 _document.tsx에서 구동된다.

App.tsx도 _app.tsx로 작성해주고, Index.tsx로 무조건 엔트리포인트가 설정되는 특징이 있는데 각각 대응을 해주는게 좋다.

 

_app.tsx

import type { AppProps } from 'next/app';
import '@/index.css';

function _app({ Component, pageProps }: AppProps) {
  return (
    <>
      <Component {...pageProps} />
    </>
  );
}

export default _app;

 

만약 head 태그에 관련된 기본 설정을 넣고 싶다면 여기에서 넣어주면 된다. document.tsx에 넣으면 경고 문구가 나오기 때문에 app.tsx에서 처리한다.

 

_document.tsx

import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';

class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
        <Main />
        <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

 

추후 SEO 관련된 메타태그나 문서의 title 설정 등은 컴포넌트 단위에서 동적으로 대응이 가능하다. _app.tsx에는 공통적으로 들어갈 코드를 처리하는 것이 좋다.

 

4. pages 폴더 구성


기존 routes 폴더를 pages로 바꾸고, 그 안에서 중첩된 라우팅 구조에 따라 폴더 구조를 구성한다.

가장 난해했던 라우팅이 아래 예시인데 하나만 보면 감이 금방 올 것이다.

 

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<Home />} />
        <Route path={"/champions"} element={<Champion />} />
        // params를 2개 받는 경우
        <Route path={"/champion/:name/version/:version"} element={<ChampionDetail />} />
        <Route path={"/user"} element={<User />}>
          <Route path={"/user/:id"} element={<User />} />
        </Route>
        <Route path={"/draft"} element={<Draft />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App;

 

params를 대응할 때는 [PARAMS].tsx로 파일을 만들면 알아서 대응이 되는데, 2개를 받는 저 라우팅에 대한 폴더 구조를 보면 모든 구조에 대응할 수 있을 것이다.

📂 src
ㄴ📂 pages
ㄴㄴ📂 champion
ㄴㄴㄴ📂 [name]
ㄴㄴㄴㄴ📂 version
ㄴㄴㄴㄴㄴ📜[version].tsx // (pages/:name/version/:version)

 

또, 변경과 동시에 기존 레거시코드인 react-router-dom의 Link, NavLink, useNavigate, useParams 코드를 바꿔줘야한다.

혹여나 이때 이제 import 구문에서 따로 설정 없이 상대경로를 썼다면 경로에 맞게 ../../ 등으로 수정해주자. (이 기회에 절대 경로로 바꿔보는 걸 추천한다.)

(next/link에는 NavLink가 없기 때문에, NavLink는 Link로 변경해야한다.)

// 1. useNavigator => useRouter 변경
// react-dom
const navigator = useNavigator();
navigator({ to: '/page' });

// next
const router = useRouter();
router({
  pathname: '/page',
  query: {} //?
})

// 3. useParams 대체
// react-dom
const { id, version } = useParams<{ id: string, version: string }>();

// next
const router = useRouter();
const { id, version } = router.query;

// 2. Link의 차이
// react-dom
<Link to={'/page'} />

// next
<Link href={'/page'} />

 

5. 빌드 해보기 (+.eslintrc 수정)


이제 거의 모든 설정이 완료되었다. dev 환경에서 실행해보기 위해서는 일단 빌드를 한 번 해야되는데, 빌드 명령어를 실행시켰을 때 import 구문을 해석할 수 없다는 에러메시지나 eslint 공백문자 관련 에러가 나온다면 eslintrc를 수정해야한다.

next가 친절하다고 느낀게 빌드 상황에서 에러발생 시 참고 링크가 진짜 유용하다. 두려워하지 말고 링크를 눌러보면 대부분 해결이 가능하다.

 

{
  // import 구문 파싱
  "parser": "@babel/eslint-parser",
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "extends": ["next", "plugin:prettier/recommended"],
  // eslint 규칙 에러 무시하기
  "rules": {
    "react/no-unescaped-entities": "off",
    "@next/next/no-page-custom-font": "off"
  }
}

 

빌드가 성공했다면 아래처럼 별 다른 에러메시지 없이 빌드가 종료된다고 나온다.

 

6. (추가) tailwindcss, apollo-client 관련 세팅들


tailwindcss나 apollo-client 세팅 관련한 부분이 어려운 건 아니지만, 서버에서 보내지는 props나 엔트리포인트가 달라지는 점에서 약간 신경 쓸 필요가 있다

 

tailwindcss

_app.tsx에 css 파일을 import 해서 넣어주면 된다. index.css에 @tailwind @layer와 같은 tailwind 설정이 들어간다면 해당 파일도 같이 import한다.

 

// _app.tsx

import 'tailwindcss/tailwind.css';
import '../../index.css';

//..

 

apollo-client

실행된 위치가 dom 영역인지 아닌지 판단하여 중복 선언되는 걸 막고 cache를 사용하는게 성능 상 좋다.

마찬가지로 ApolloProvider를 _app.tsx에서 감싸줘야한다.

 

// src > client.ts

import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { mergeDeep } from '@apollo/client/utilities';
import { useMemo } from 'react';

let apolloClient: ApolloClient<NormalizedCacheObject> | null = null;

const client = () => {
  return new ApolloClient({
    ssrMode: typeof window === 'undefined',
    uri: 'YOUR_URLS',
    cache: new InMemoryCache(),
  });
}

export const initializeApolloClient = (initialState: any) => {
  const _apolloClient = apolloClient ?? client();

  if (initialState) {
    // 초기값이 있다면 client 인스턴스 생성을 막고 merge를 통해 복구한다.
    const cntCache = _apolloClient.extract();
    const data = mergeDeep(initialState, cntCache);
    _apolloClient.cache.restore(data);
  }

  if (typeof window === 'undefined') return _apolloClient;
  if (!apolloClient) apolloClient = _apolloClient;

  return _apolloClient;
}

export function useApollo(initialState: any) {
  return useMemo(() => initializeApolloClient(initialState), [initialState]);
}
import type { AppProps } from 'next/app';
import { useApollo } from '../../client';
import { ApolloProvider } from '@apollo/client';
import 'tailwindcss/tailwind.css';
import '../../index.css';

function _app({ Component, pageProps }: AppProps) {
  const apolloClient = useApollo(pageProps.initialApolloState);
  return (
    <>
      <ApolloProvider client={apolloClient}>
        <Component {...pageProps} />
      </ApolloProvider>
    </>
  );
}

export default _app;

꼬박 하루가 걸린 migration이었습니다.

공식문서와 PR 문서를 찾아보면서 썼기 때문에 다소 부족한 부분이나 고려 못한 부분이 있을 수 있습니다. 이런 부분은 언제나 댓글 남겨주시면 반영하겠습니다. 긴 글 읽어주셔서 감사합니다 :)