[원티드 프리온보딩 챌린지] Next.js로 마크다운 블로그 만들기 - remark, remark html, dangerouslySetInnerHTML

지은·2023년 7월 11일

이전 글) [원티드 프리온보딩 챌린지] Next.js로 마크다운 블로그 만들기 - front matter, gray-matter

이전 포스팅에서 __post 폴더에 있는 마크다운 파일을 읽어와서 게시글 목록 페이지를 렌더링하는 부분까지 만들었고,

이어서
1️⃣ 블로그 게시글을 클릭하면 해당 상세 페이지로 이동하도록 하고(라우팅),
2️⃣ 마크다운 파일의 본문을 읽어와서 상세 페이지를 렌더링하는 부분을 구현하려고 한다.


Next.js의 Link 컴포넌트는 HTML <a> 요소를 확장한 리액트 컴포넌트로, prefetching클라이언트 측 라우팅을 제공한다.(페이지 새로고침 X)

<Link> - Next.js

Prefetching(사전 로드) 이란?

: 사용자가 실제로 해당 페이지로 이동하기 전에 브라우저가 해당 페이지의 리소스를 미리 가져오는 기술

<Link> 컴포넌트를 사용해 Next.js에서 사전 로드(prefetching)를 활용하면, 사용자가 링크에 마우스를 올려놓거나 터치했을 때 해당 페이지의 리소스가 백그라운드에서 미리 가져와진다.
그러면 실제로 페이지로 이동할 때 이미 리소스가 로드되어 있어서 빠른 페이지 전환과 부드러운 사용자 경험을 제공할 수 있다.

ListItem.tsx

export default function ListItem({ post }: Props) {
  const { id, title, date } = post;
  const formattedDate = getFormattedDate(date);

  return (
    <li className='mt-4 text-2xl dark:text-white/90' key={id}>
      <Link href={`/posts/${id}`} className='underline hover:text-black/70 dark:hover:text-white'>
        {title}
      </Link>
      <br />
      <p className='text-sm mt-1'>{formattedDate}</p>
    </li>
  );
}

<Link> 컴포넌트에 href 속성으로 이동하고자 하는 경로를 전달하면 된다.
이때 변수 id를 사용해 동적인 경로를 생성해준다.
만약 게시글의 id가 1이라면, 사용자가 ListItem을 클릭했을 때 /posts/1 경로로 이동될 것이다.


자동 라우팅(Auto Routing) & 동적 라우팅 (Dynamic Routing)

Next.js는 파일 시스템을 기반으로 자동 라우팅을 제공하여 개발자가 라우트 설정을 손쉽게 할 수 있도록 한다.

Linking and Navigating - Next.js
Dynamic routes - Next.js

예를 들어
pages/index.js 파일은 / 경로에 렌더링되고,
pages/about.js 파일은 /about 경로에 렌더링된다.

그리고 파일 이름을 [변수].js로 작성하여 동적 라우팅을 사용하는 페이지 컴포넌트를 만들 수 있다.
pages/blog/[id].js 파일은 /blog/1, /blog/2, /blog/3과 같이 동적인 경로에 대한 페이지를 처리할 수 있다.

posts/[postId]/page.tsx

따라서 위의 <Link> 컴포넌트를 클릭했을 때, 이동할 /posts/${id} 경로에 대한 페이지를 처리하기 위해 posts/[postId] 폴더를 생성하고 안에 page.tsx 파일을 생성해주었다.

이제 이 개별 게시글 컴포넌트 안에서 변수(postId)를 받아오는 법에 대해 알아보자.


동적 라우팅을 사용할 때 변수에 접근하는 방법

동적 라우트 파일에서 변수에 접근하는 방법으로는 params를 이용하는 방법과, useRouter() 훅을 이용하는 방법 2가지가 있다.

1. params 객체 이용

동적 라우트 파일에서 컴포넌트의 props로 params 객체를 받아올 수 있다.
params 객체에는 동적 세그먼트에 대한 값들이 포함된다. (/posts/[postId] 경로에서 postId가 동적 세그먼트이다.)

type Props = {
  params: {
    postId: string;
  };
};

export default function Post({ params }: Props) {
  const { postId } = params;
  // postId를 사용하여 동적인 작업 수행
  // ...
}

2. useRouter 훅 이용

useRouter() 훅을 사용하면 현재의 라우터 정보에 접근할 수 있다.
useRouter() 훅은 query 객체를 제공하며, 이 객체에 동적 세그먼트 값이 포함되어 있다.

import { useRouter } from 'next/router';

export default function Post() {
  const router = useRouter();
  const { postId } = router.query;
  // postId를 사용하여 동적인 작업 수행
  // ...
}

둘의 차이점?

두 가지 방법 모두 동적 라우팅에서 전달된 변수에 접근할 수 있지만,
params를 이용하면 함수 컴포넌트의 속성으로 전달되기 때문에 정적 타입 검사를 보장할 수 있다.
반면 useRouter() 훅은 런타임 시에 라우터 정보에 접근하기 때문에 타입 검사가 불가능하다.


나는 두 가지 방법 중에 params 객체를 이용하기로 했다.

posts/[postId]/page.tsx

// posts/[postId]/page.tsx

type Props = {
  params: {
    postId: string;
  };
};

export default async function Post({ params }: Props) {
  console.log(params); // { postId: '1' }
  const { postId } = params;
  
  // ... 생략
}

이제 이전에 만들었던 getSortedPostsData() 함수를 이용해 게시글 데이터(title, date, id 메타데이터를 가진)를 불러오고, find()를 이용해 postIdid 값이 일치하는 게시글을 찾아줄 것이다.

만약, 값이 일치하는 게시글이 없다면 존재하지 않는 경로이므로 Not Found 페이지를 반환해줄 것이다.
이때 Next.js의 notFound() 함수를 이용할 수 있다.

notFound()

: 404 상태 코드와 함께 Not Found 페이지를 반환하는 함수

  • 사용자에게 존재하지 않는 페이지를 보여줄 때 사용한다.
  • notFound() 함수를 사용하면 정적 생성(SSG)과 서버 사이드 렌더링(SSR) 시에도 404페이지를 올바르게 처리할 수 있다.

notFound() - Next.js

export default async function Post({ params }: Props) {
  const posts = getSortedPostsData(); // 게시글 데이터
  const { postId } = params;

  if (!posts.find((post) => post.id === postId)) {
    return notFound();
  }
  
  // ... 생략
}

그러면 이제 notFound() 함수가 호출되었을 때 렌더링될 페이지를 만들어야 한다.
같은 폴더 안에 not-found.tsx라는 이름의 파일을 만들면 된다.

또한 폴더 안에 posts/not-found.tsx 페이지를 작성하면, /posts/ㅁㅁㄴㅇㄹ와 같이 /posts/:postId 형식의 경로에서 존재하지 않는 페이지에 대한 커스텀 에러 페이지를 지정할 수 있다.
이를 이용해서 경로별로 에러 페이지를 다르게 처리할 수 있다.

posts/not-found.tsx

export default function NotFound() {
  return <h1>존재하지 않는 게시글입니다.</h1>;
}

마크다운 본문 불러오기

getSortedPostData() 함수는 __posts 폴더에 있는 마크다운 파일들의 메타 데이터만 담고 있는 리스트였다. (게시글 목록 렌더링에 사용하기 위해)
이제 params에서 얻은 postId를 이용해 해당 id를 가진 특정 마크다운 파일의 본문을 불러오는 함수 getPostData()를 작성해보자.

posts/[postId]/page.tsx

export default async function Post({ params }: Props) {
  const posts = getSortedPostsData(); // deduped.
  const { postId } = params;

  if (!posts.find((post) => post.id === postId)) {
    return notFound();
  }

  const { title, date, contentHtml } = await getPostData(postId);
  // ... 생략
}

getPostData()

  1. getPostData() 함수는 매개변수로 게시글의 id를 받는다.

  2. id는 파일명이었기 때문에, postsDirectory 변수와 조합해 파일의 전체 경로를 얻을 수 있다.
    e.g. __posts + 1.md ➡️ __posts/1.md

  3. 그리고 fs.readFileSync() 함수에 파일의 경로를 전달해 파일의 전체 내용을 읽어올 수 있다.
    변수 fileContents를 콘솔에 출력하면 아래와 같다.

  1. fileContentsmatter() 함수에 전달해 front matter와 본문을 분리하여 추출할 수 있다.
    변수 matterResult를 콘솔에 출력하면 아래와 같다.
export async function getPostData(id: string) { // 1️⃣
  const fullPath = path.join(postsDirectory, `${id}.md`); // 2️⃣
  const fileContents = fs.readFileSync(fullPath, 'utf8'); // 3️⃣
  const matterResult = matter(fileContents); // 4️⃣
 
  // ...  
}

remark & remark-html

matterResult에 담긴 마크다운을 파싱하고 처리하기 위해 remark 라이브러리를 사용할 수 있다.
그리고 이렇게 처리한 마크다운을 remark-html를 이용해 HTML로 변환할 수 있다.
remark - npm
remark-html - npm

문법

import remark from 'remark';
import html from 'remark-html';

const markdown = `# Hello, *world*!`;

const markdownToHtml = async (markdown) => {
  const result = await remark().use(html).process(markdown);
  return result.toString();
};

markdownToHtml(markdown)
  .then((html) => {
    console.log(html);
  })
  .catch((error) => {
    console.error(error);
  });

getPostData()

export async function getPostData(id: string) {
  const fullPath = path.join(postsDirectory, `${id}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const matterResult = matter(fileContents);

  const processedContent = await remark().use(html).process(matterResult.content); // 1. 마크다운을 HTML로 변환한다.
  const contentHtml = processedContent.toString(); // 2. HTML을 문자열로 변환한다.

  const blogPostWithHTML: BlogPost & { contentHtml: string } = {
    id,
    title: matterResult.data.title,
    date: matterResult.data.date,
    contentHtml,
  };

  return blogPostWithHTML;
}

contentHtml을 콘솔에 출력하면 아래와 같다.


이제 다시 상세 페이지 컴포넌트로 이동해서 JSX 부분을 마저 작성하면 완성이다.

posts/[postId]/page.tsx

export default async function Post({ params }: Props) {
  const posts = getSortedPostsData(); // deduped.
  const { postId } = params;

  if (!posts.find((post) => post.id === postId)) {
    return notFound();
  }

  const { title, date, contentHtml } = await getPostData(postId);

  const formattedDate = getFormattedDate(date);

  return (
    <main className='px-6 prose prose-xl prose-slate dark:prose-invert mx-auto'>
      <h1 className='text-3xl mt-4 mb-0'>{title}</h1>
      <p className='mt-0'>{formattedDate}</p>
      <article>
        <section>{contentHtml}</section> // ❌
        <p>
          <Link href='/'>홈으로 가기</Link>
        </p>
      </article>
    </main>
  );
}

하지만 contentHtml에 직렬화된 HTML(문자열)가 담겨있다고 해서 그냥 변수를 그대로 태그 안에 넣으면 안된다. 이렇게 하면 아래처럼 렌더링된다.

dangerouslySetInnerHTML

React에서는 dangerouslySetInnerHTML 속성을 사용해 HTML을 직접 설정할 수 있다.

<section dangerouslySetInnerHTML={{ __html: contentHtml }} /> ⭕️

하지만 이 속성을 사용할 때는 주의가 필요하다. 신뢰할 수 없는 소스에서 가져온 HTML 문자열은 보안상 위험이 있을 수 있기 때문이다.
따라서 이 속성을 사용할 때에는 신뢰할 수 있는 소스로부터의 HTML 문자열만 사용해야 하며, 공격을 방지하기 위해 적절한 XSS 방어 메커니즘을 구현하는 것이 좋다.

XSS(Cross-Site Scripting) 공격

: 악성 스크립트를 삽입하여 다른 사용자의 웹 브라우저에서 실행되게 하는 공격
XSS는 웹 애플리케이션에서 발생하는 보안 취약점 중 하나로, 사용자의 개인 정보를 탈취하는 등 웹 애플리케이션의 안전성을 침해할 수 있다.

profile
블로그 이전 -> https://janechun.tistory.com

4개의 댓글

comment-user-thumbnail
2023년 7월 12일

고생하셨습니다! prefetching개념 정리해줘서 좋았습니다.

답글 달기
comment-user-thumbnail
2023년 7월 16일

내용이 꼼꼼해서 공부하기 좋습니다 !

답글 달기
comment-user-thumbnail
2023년 7월 16일

저도 이제 공부해보려고 하는데 예습하는 느낌이라 좋네요 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 7월 17일

고생하셨습니당~

답글 달기