본문 바로가기
개발 기록

좌충우돌 블로그 Next.js로 마이그레이션하기

by 유세지 2023. 7. 28.

 

 

저는 지금 이 글이 올라온 티스토리 블로그 외에도, 바닥부터 만드는 블로그 개발기에서 만들었던 블로그를 함께 사용하고 있습니다. 개인적인 이야기들을 많이 적는 용도로 사용해서 크게 신경쓰고 있지는 않았지만, 블로그에게는 치명적인 문제점이 하나 있었습니다. 바로 SEO가 제대로 적용되지 않는다는 점이었는데요.

 

 

포스트에 대한 설명이 없습니다.

 

실제로 블로그 글을 트위터나 카카오톡과 같은 메신저에 공유하게되면 기본적으로 설정된 메타 데이터만 표시될뿐, 포스트의 정확한 정보나 설명이 전혀 보이지 않았습니다. SSR을 적용하면 쉽게 해결할 수 있는 문제이지만 아직까지 사용해본적이 없어서 미루고만 있었던 문제였습니다.

 

그러던중 이번에 Next.js의 13 버전이 어느정도 안정화가 되었다는 소식을 듣고, 내친김에 블로그에 SSR 방식을 적용해보기로 하였습니다.

 

 

문제점

블로그는 기본적으로 React 기반 프로젝트이다 보니 CSR 방식으로 동작합니다. 따라서 특정 도메인으로 먼저 접근하는 경우, 결과로 받는 html 파일에는 적절한 메타 데이터가 포함되어있지 않습니다. 문제를 해결하는 가장 쉬운 방법은, 어느정도 세팅된 구성을 제공해주는 프레임워크인 Next.js를 사용하는 것입니다.

 

한 가지 걱정되는 점이 있다면 이전까지 Next.js를 사용한 적이 없어서 마이그레이션을 한다고 해도 어떻게 해야할지 전혀 몰랐다는 점입니다. 심지어 넥스트는 현재 페이지 라우터(page router)앱 라우터(app router), 두 가지 방식이 있어서 모르는 방식에 대한 자료들이 섞여있었다는 것도 문제라면 문제였습니다.

 

이럴 때일수록 의지할 곳은 공식 문서 뿐이었습니다. 어떻게든 배우면서 해보자는 마음으로 부딪혀보기로 하였습니다.

 

 

마이그레이션 준비

 

프로젝트 구조

 

 

현재 프로젝트 구조는 이렇습니다.

패키지 매니저로 yarn pnp를 사용하였고,

posts/ 디렉토리에 있는 md 파일들을 불러와서 띄워주고 있는 모습입니다.

 

이 프로젝트를 곧바로 Next.js로 옮겨버리면 좋겠지만, 그 전에 먼저 어떤 버전의 Next.js로 옮길지 정해야 합니다.

현재 이 글을 쓰는 시점에서 최신 메이저 버전은 13입니다. Next.js 12까지는 페이지 기반의 라우팅 방식을 사용하였는데, 이번 버전에서부턴 앱 라우터라고 하는 방식을 차용하여 현재 어느정도 안정화 작업이 끝난 상태입니다.

 

저의 경우 공식 문서의 추천에 따라 App router 방식을 사용하기로 하였습니다. 기존의 페이지 라우터에서 커뮤니티의 요구에 따라 더 직관적이고 사용하기 좋은 형태로 발전하였으니, 새로 시작하는 프로젝트에는 앱 라우터 방식을 사용하길 바란다는 글이 있었습니다. 리액트 사용자라서 그런지, 마치 클래스 컴포넌트에서 함수 컴포넌트로 넘어가는 과도기를 보는 기분이 드네요.

 

앞으로 버전이 오르고 기능이 추가될수록 앱 라우터의 사용률 또한 점점 많아질테니,

미리 공부해두는 것이 좋겠다는 느낌이 들었기에 망설임 없이 선택했습니다.

 

 

공식이 추천하는 App Router

 

 

 

친절하게도, Next.js에서는 필요한 대부분의 세팅이 완료된 기본 프로젝트를 구성해주는 create-next-app 명령어를 제공해주고 있습니다. 다만 제 경우 프로젝트를 새로 시작하는건 아니고 기존 프로젝트를 마이그레이션 해야하니 cna 명령어 대신 공식 문서의 Manual Installation의 과정을 그대로 따라가보겠습니다.

 

먼저 필요한 핵심 라이브러리들을 설치해줍니다. next를 설치하고, react와 react-dom은 최신버전으로 올려주었습니다.

 

$ yarn add next@latest react@latest react-dom@latest

 

설치한 next를 실행할 수 있도록 package.json의 script 부분을 수정해줍니다.

 

/* 변경된 script */
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}

/* 이전 script */
"script": {
  "start": "webpack-dev-server --config ./webpack.dev.js --open",
  "start:prod": "webpack-dev-server --config ./webpack.prod.js --open",
  "build": "webpack --config ./webpack.dev.js",
  "build:prod": "webpack --config ./webpack.prod.js",
}

 

앞으로는 기존의 스크립트 대신 next가 실행과 빌드 과정을 책임지게 됩니다.

아래의 webpack 기반 스크립트를 위의 next 기반 스크립트로 변경해주었습니다.

 

 

이제 next가 무엇을 라우팅 할지 찾을 수 있도록 구조에 맞게 파일을 만들어줍니다.

src 아래에 app 디렉토리를 만들고, layout.tsx과 page.tsx라는 이름의 파일을 만들었습니다.

 

파일 구성은 공식 문서에 쓰여있는 코드를 그대로 가져왔습니다.

 

/* app/layout.tsx */

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
/* app/page.tsx */

export default function Page() {
  return <h1>Hello, Next.js!</h1>;
}

 

 

 

공식 문서의 설명을 보니, layout 컴포넌트가 각종 바운더리들과 폴백 컴포넌트(not-found), 컨텐츠 역할을 하는 page 파일을 모두 감싸고 있는 계층 구조를 확인할 수 있었습니다. 순서에 유의해서 사용하면 될 것 같아 보입니다.

 

이 시점에서 dev로 실행하면 어떤 화면이 나오는지 궁금했습니다. 바로 실행시켜봤습니다.

 

 

$ yarn run dev

 

처음하는 실행이라 그런지 next가 이런저런 조언을 해줍니다.

특히 tsconfig.json에 관해서 추가하면 좋은, 혹은 자동으로 추가했으니 건드리면 안될 옵션들을 소개해주네요.

 

  • resolveJsonModule
  • isolatedModules
  • jsx

 

위 세 가지 옵션은 next가 자동으로 설정하고 사용하는 옵션이니 이를 제외하고 필요에 맞게 조정하면 될 것 같습니다.

일단 실행은 잘 된 것 같으니 결과를 확인해보면,

 

 

받아오는 파일에 내용(Hello, Next.js)이 있는 모습입니다.

 

 

서버로부터 받아오는 파일인데도, 기존의 CSR과는 달리 완성된 html을 받아오고 있습니다. 배포중이었던 기존 블로그를 보면 body에 div 태그 하나 있는것을 제외하면 텅 비어있는 모습입니다.

 

 

배포된 리액트 프로젝트에는 없습니다.

 

 

막상 비교하고 보니 헤더 부분이 비어있는게 느껴집니다.

public/index.html 에 있던 헤더 부분을 src/app/layout.tsx에 옮겨주었습니다.

 

헤더 부분을 여기에 두는 것이 적절한 위치선정인지는 모르겠지만,

속성들을 관리하기에는 일단 눈에 잘 보이는 곳에 두는게 유리하다는 생각이 들었습니다.

나중에 불편하거나 때가 되면 바로 옮겨주기로 하고, 잠시 죄책감을 덮어두었습니다.

 

 

이제 헤더도 생겼네요.

 

 

이렇게 1차적인 Next.js 구성은 완료된 것 같습니다.

다음부터가 진짜 마이그레이션 시작입니다.

 

 

기능 마이그레이션

 

이제 본격적인 기능들을 옮겨줄 시간입니다.

src/page/Home 하위에 있던 코드들을 모두 src/app/page.tsx로 옮겨줍시다.

 

오류가 발생했습니다.

 

당연하겠지만 곧바로 오류가 발생했습니다.

처음보는 타입의 오류라서 에러 메시지를 유심히 읽어봤는데 결론은 의외로 간단했습니다.

 

 

당신은 useRef가 필요한 코드를 불러왔습니다.
그 코드는 "use client"로 표시된 클라이언트 컴포넌트에서만 동작할 것입니다.

 

 

이 내용을 이해하기 위해서는 먼저 서버 컴포넌트가 무엇인지부터 알아야합니다. 자세하게 쓰려면 글 하나를 통째로 할애해야 할 것 같으니 여기서는 간단하게만 짚어보겠습니다.

 

서버 컴포넌트(RSC, React Server Component)란 리액트 18에서 추가된 개념으로, 서버 측 작업이 가능한 컴포넌트를 말합니다. 위에서 이야기 했던 SSR과는 다른 개념이지만 이를 통해 얻을 수 있는 결과는 유사합니다. 이전까지의 리액트 컴포넌트들은 모두 클라이언트 컴포넌트입니다. 사용자와 인터렉션을 하고, 상태를 관리하고, 브라우저와 통신하면서 다양한 작업들을 처리해왔습니다.

 

그러나 클라이언트 컴포넌트들은 명확한 한계가 있습니다. 특히 서버와 통신을 주고 받을때 가장 두드러지게 느끼셨을텐데요. 기존의 백엔드가 넘겨준 엔드 포인트들이 아닌 파일 시스템에 직접 접근하거나, 데이터베이스에 직접 접근해서 값을 받아오는 등 서버 측에서 할 수 있는 작업은 따로 있었습니다.

 

다시 돌아와서, 결국 현재의 리액트 컴포넌트는 컴포넌트들이 작업을 수행하는 위치가 어디인지에 따라 클라이언트 컴포넌트서버 컴포넌트로 나뉘어집니다. 구조적으로 서로가 서로의 영역을 침범할 수 없습니다.

 

 

자. 정리를 하고 나니 지금 오류가 발생한 부분이 왜 문제가 되는지 그 이유도 알 수 있을 것 같습니다.

 

현재 오류가 발생한 부분은 useRef를 사용한 코드, 즉 emotion 라이브러리가 사용된 부분입니다. src/page/Home 내부에는 emotion을 이용해 스타일링을 한 컴포넌트들이 다수 포진해 있는 상태입니다.

 

useRef의 경우 DOM 요소를 선택하여 담는 역할을 하기 때문에, 먼저 화면이 그려지는 작업이 필요합니다. 서버가 아닌 클라이언트에서 이루어져야 하는 작업입니다. 대부분의 hooks도 마찬가지 입니다. createElement, useContext, useState 등등 상태를 사용하는 코드들도 클라이언트에서만 사용할 수 있습니다. 따라서 오류 메시지에서도 파일의 최상단에 'use client' 를 기입하여 이 컴포넌트를 클라이언트 컴포넌트로 만든 뒤 사용하라고 안내하는 것입니다. (Next.js 는 기본값이 server component입니다.)

 

좋습니다. 오류 메시지가 붙이라고 하는 곳에 해당 키워드를 모두 붙여봤습니다.

 

 

 

 

...이제 또 다시 고민이 들기 시작했습니다. 

 

하나를 잡으면 다른 하나가 나타나고 있습니다. 오류가 사라지질 않네요. 사실 이렇게 모든 컴포넌트를 클라이언트 컴포넌트로 바꾸는 것에도 문제가 있습니다. 그렇다고 최상단에 "use client" 를 달아주면 당장 문제야 해결되겠지만, 그럼 결국 하위의 모든 컴포넌트들이 클라이언트에서 동작하게 될테고, 애써 Next.js로 넘어온 이유가 사라집니다.

 

그러나 다행히도, 약간의 패턴을 이용하면 둘은 함께 사용할 수 있습니다.

 

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Layout>{children}</Layout>
  );
}

 

 

위 코드에서 Layout 컴포넌트는 각종 스타일이 담겨있는 클라이언트 컴포넌트입니다. RootLayoutLayout 모두 {children} 에 무엇이 들어올지는 알지 못합니다. 오직 개발자만 알고있는 정보입니다.

 

이렇게 하면 Layout은 클라이언트에서 동작하되, 그 안에 들어가는 children은 서버에서 동작하는 컴포넌트로 채워 사용 할 수도 있습니다. (물론 Layout에서 서버 컴포넌트를 직접적으로 import 하여 사용 할 수는 없습니다.)

 

이렇게 하면 서버 컴포넌트를 클라이언트 컴포넌트로 감쌀 수 있게 됩니다.

이제 정상적으로 사용할 수 있을 것 같습니다.

 

 

오류

 

 

하지만 현실은 냉혹했습니다.

닭똥같은 눈물이 흘렀지만 이 문제에 대한 해결은 일단 뒤로 미루고, 로직부터 마저 옮기도록 하겠습니다.

 

기존에는 useList 라는 커스텀 훅을 통해 글 목록 데이터를 불러오고,

불러온 데이터를 컴포넌트에서 가져다가 화면에 띄워주는 형태로 사용하였습니다.

그러나 이 방식은 데이터를 클라이언트 상태에 저장하게 된다는 치명적인 문제가 있었습니다.

 

따라서 서버 컴포넌트의 의도에 맞게, 컴포넌트에서 미리 데이터를 불러오도록 수정하였습니다.

 

 

const getData = async () => {
  let postNumber = 1;
  let list: Post[] = [];
  let error = false;

  while (!error) {
    await import(`posts/${postNumber}.md`)
      .then(data => {
        list = [parseDocument(postNumber, data.default), ...list];
        postNumber += 1;
      })
      .catch(err => {
        error = true;
      });
  }

  return list;
};

export default async function Page() {
  const list = await getData();
  // ..
}

 

 

이렇게 되면 Page는 사용자에게 보내지기 전에 데이터를 먼저 불러오게 될 것입니다.

dev 모드로 실행해서 제대로 불러오고 있는지 확인해봅시다.

 

 

글 목록을 제대로 불러오고 있습니다.

 

 

성공입니다. 비록 스타일이 적용되지 않아서 보기엔 안좋지만, 원하는 데이터를 정확히 가져오고 있는건 확인할 수 있었습니다. 이제 특정 글을 클릭하면 포스팅을 보여줄 수 있도록 만들 차례입니다.

 

 

글 읽기

 

 

 

기존의 포스트 주소는 /post/:id 입니다. 앱 라우터는 디렉토리 구조에 따라 라우팅을 해주니, 이에 맞게 폴더들을 구성해주면 자동으로 라우팅이 됩니다.

 

 

app > post > [id] > page

 

 

위의 [id] 같이 param 이름을 대괄호로 감싼 형태로 구성해 줄 수 있습니다. 이제 /post/3 으로 접근하게 되면, page.tsx에서는 id = 3 을 사용할 수 있습니다.

 

 

param으로&nbsp;id를 받아서 사용할 수 있습니다.

 

 

화면에서 확인을 해보면, 원하는 데이터가 잘 표시되고 있는걸 확인할 수 있습니다.

 

 

제대로 동작하고 있네요.

 

 

 

이제 마지막입니다. 문제가 됐던 emotion을 어떻게든 해야 합니다.

 

children을 통해 넘겨주는 방식으로 서버 컴포넌트를 클라이언트 컴포넌트 아래에 위치하도록 해주었음에도 불구하고, 어디선가 이모션 리소스가 겹쳐서 들어오는 것인지 에러가 발생하고 있습니다.

 

이모션을 import 해서 사용하는 파일들은 모두 *.styled.ts 에 몰아주었고, 이 파일들에는 모두 'use client' 를 표기해주었습니다. 그럼에도 불구하고 어째서인지.. 오류가 발생하는 이유를 알 수 없었습니다.

 

 

 

 

혹시라도 이모션 버전이 오래돼서 현재 Next.js 와 호환이 잘 안됐을수도 있다는 생각이 들어 최신 버전으로 교체해주었지만 결과는 동일했습니다. 사실 마이너 버전 하나 차이라 그리 큰 차이도 아니었습니다.

 

 

변경 전 (11.10.4) => 변경 후 (11.11.1)

 

 

이리저리 찾아보다가 emotion은 공식에서도 아직 지원 준비 중이고, 많은 분들이 시도하셨지만 포기했다는 후기들이 많아 결국 styled-components로 교체하는 쪽으로 결론지었습니다.

 

 

 

 

@emotion/react, @emotion/styled, emotion-reset을 삭제하고,

styled-components, styled-reset을 사용하였습니다.

 

여기에 스타일드 컴포넌트를 사용하려면 한 가지 세팅이 더 필요하다고 해서, 적용해주었습니다.

 

/* registry.tsx */
'use client'
 
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
 
export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
 
  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement()
    styledComponentsStyleSheet.instance.clearTag()
    return <>{styles}</>
  })
 
  if (typeof window !== 'undefined') return <>{children}</>
 
  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  )
}

 

 

간단히 설명하면 하위의 스타일들을 수집하는 레지스트리를 만들고,

이를 적용시켜주는 컴포넌트를 만들어 적용시켜주는 과정입니다.

 

공식 문서에서 저 코드를 제공해줘서 그대로 사용하기만 하면 됩니다.

 

import GlobalStyle from 'styles/GlobalStyle';

import Layout from 'components/Layout';
import StyledComponentsRegistry from 'components/Registry';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <head>
        <title>유세지의 식물원</title>
      </head>
      <body>
        <StyledComponentsRegistry>
          <GlobalStyle />
          <Layout>{children}</Layout>
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

 

 

이제 다시 실행해보면...

 

 

emotion이 아직 덜 지워졌나봅니다.

 

 

아까 삭제했던 이모션의 캐시가 남아서 오류가 나고 있는 것 같습니다.

생성되었던 .next 디렉토리를 삭제하고 다시 실행해줍니다.

 

 

서버에서 잘 보내주고 있습니다.

 

 

스타일까지 적용이 잘 되어있는 모습입니다.

너무 감동적이네요 😂

 

이제 이번 마이그레이션의 본 목적이었던 메타 태그를 설정해주겠습니다.

 

export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const id = Number(params.id);

  const content = await import(`posts/${id}.md`)
    .then(data => {
      return parseDocument(id, data.default);
    })
    .catch(() => {
      return {
        id,
        title: '',
        subTitle: '',
        date: '',
        content: '',
      };
    });
  
  return {
    title: content.title,
    description: content.subTitle,
    openGraph: {
      images: [
        'https://user-images.githubusercontent.com/28296575/198838771-84438140-d95a-4899-b5bc-35cbaa92184a.png',
      ],
    },
    twitter: {
      site: '@dev_usage',
      creator: '@dev_usage',
      title: content.title,
      description: content.subTitle,
      images: [
        'https://user-images.githubusercontent.com/28296575/198838771-84438140-d95a-4899-b5bc-35cbaa92184a.png',
      ],
    },
  };
}

 

메타 태그의 경우 공식 문서와 Next.js에 내장되어 있던 d.ts 파일을 참고해서 작성했습니다.

이렇게 하면 URL을 공유했을때 페이지를 나타낼 수 있는 정보들이 메타 데이터에 추가되어

해당 내용을 미리보기로 확인할 수 있게 됩니다.

 

 

배포하기

 

메타 데이터는 로컬에서 확인하긴 어렵고, 배포를 해야 확인이 가능하니 바로 배포를 진행하도록 하겠습니다.

 

일반적으로 정적 호스팅만 지원하는 깃헙 페이지와 같은 곳에서 배포를 했다면 적용이 어렵겠지만, 저는 다행히도 원래 사용하던 배포처가 Next.js를 만드는 vercel이라 SSR 지원이 정말 잘됩니다. (심지어 무료입니다!)

 

레포지토리는 이미 연결되어 있으니, 가서 빌드 설정만 변경해주겠습니다.

 

 

Next.js 의 설정에 맞게 변경합니다.

 

 

Framework Preset을 기존 CRA에서 Next.js로 변경했고, (이전에도 CRA는 사용하지 않았지만 빌드 설정때문에 CRA로 두었습니다.) Output Directory도 Next.js가 자동으로 잡아줄테니 Override 설정을 off로 변경했습니다.

 

중간에 노드 버전도 올라서 16.x 에서 18.x 로 변경해주었네요.

 

 

가동중인 서버 함수들도 따로 보여줍니다.

 

 

빌드가 완료되니 현재 실행중인 서버 함수까지 보여주는 모습입니다. 역시 Next.js와 궁합이 좋은 vercel이네요.

이제 이곳저곳에 링크를 공유해보며 메타 데이터가 제대로 적용되고 있는지 확인해 볼 차례입니다.

 

 

포스트의 내용을 잘 보여주고 있는 모습입니다.

 

 

메신저, SNS 등에 링크를 뿌려가며 확인해보니 모두 잘 받아오고 있는 모습이네요.

드디어 길었던 마이그레이션 과정도 끝입니다.

 

 

 

마치며

어쩌다보니 Next.js로 넘어오게 되었는데, 사실 이렇게만 놓고 쓴다면 Next.js를 제대로 쓰는게 아니라고 생각합니다. Next.js라는 프레임워크가 제공하는 기능들에는 아직 제가 경험해보지 못한 부분들이 너무나도 많이 남아있기 때문에 한동안은 이것저것 만져보며 지낼 것 같네요. 장난감이 하나 더 생긴 기분이라 은근히 신이 납니다.

 

더불어 완전히 백지에서 시작했는데도 어찌어찌 삽질을 하다보니 원하는 결과물을 얻을 수 있었습니다. 오류 메시지부터 공식 문서까지 입문자에게 참 친절한 프레임워크라는 생각이 들었고, 개인적으로도 많이 배우게 된 시간이어서 기분이 매우 좋네요. 별개로 공부해야할건 정말 끝이 없는 것 같습니다 😅

 

앞으로 Next.js를 더 가지고 놀면서 알게된 내용들은 포스팅으로 따로 정리해보도록 하겠습니다.

이상 좌충우돌 블로그 Next.js 마이그레이션

 

반응형

댓글