-
[NextJs] Markdown 포스팅을 정적 페이지(SSG)로 배포하기프레임워크/React(NextJs) 2022. 10. 8. 20:36
원티드 프리온보딩 챌린지 10월 2일차 과제
이번 과제는 마크다운으로 작성한 파일을 Nextjs를 통해 정적 페이지로 배포하는 것이다.
과제 주요 내용
✏️ 사용자는 root 경로의 `/__posts` 폴더에 작성된 마크다운 파일(.md)를 작성할 수 있어야 한다.
✏️ 해당 파일은 마크다운 본문과 게시물에 대한 meta data를 담을 수 있어야 한다.
✏️ 블로그에 작성된 게시물을 렌더링하는 `목록 페이지`와 개별 게시물을 렌더링하는 `상세 페이지`로 나누어 작성한다.: `/` - 목록 페이지
: `/[id]` - 상세 페이지진행 과정
이번에는 과제를 하는 과정을 적어보려고 한다. 해결해 나가는 과정의 프로세스가 중요하다고 하심
우선 과제를 받았을 때 가장 먼저 떠올랐던 것은 root 경로에 있는 __posts폴더의 파일을 어떻게 읽어올 것인가? 였다.
api를 요청하는 서버도 없고, 사용자가 작성하는 static 파일이기 때문에 Nodejs의 fs API를 사용하기로 했다.
이후, 기능을 구현하기에 앞서 getStaticPaths 와 getStaticProps 함수에 대해서 공식문서를 읽어보았다.
getStaticPath와 getStaticProps는 Next를 빌드할 때를 기준으로 정적 페이지를 생성한다.
getStaticPath
When exporting a function called getStaticPaths from a page that uses Dynamic Routes, Next.js will statically pre-render all the paths specified by getStaticPaths.
> 동적 경로(Dynamic Routes)를 사용하는 페이지에서 getStaticPaths라는 함수를 내보낼 때
Next.js는 getStaticPaths에 의해 지정된 모든 경로를 정적으로 사전 렌더링합니다.
동적 경로란?
말 그대로 변하는 URL 이다.
동적 경로를 만들고 싶다면 pages 경로 안에 아래와 같이 대괄호를 사용하여 둘 중 원하는 방법으로 만들어주면 된다.
NextJs의 Dynamic Routes 만드는 방법 이렇게 만들어 놓는다면 웹 페이지에서 아래와 같은 url로 접근 했을 때 id = markdownpost가 된다.
그리고 저 id는 getStaticProps의 context.params를 통해 사용할 수 있다.
추가로 getStaticPaths의 리턴에 { fallback : false }를 하면 사전 렌더링 된 경로 외에는 404 page를 던져준다.
💡 __posts 경로의 파일의 이름들을 모두 읽어와서 paths를 리턴해 정적으로 경로를 사전 렌더링 하기
getStaticProps
Exporting a function called `getStaticProps` will pre-render a page at build time using the props returned from the function
> 'getStaticProps'라는 함수를 내보내면 빌드 시 해당 함수의 props 명령을 사용하여 페이지를 사전 렌더링합니다.
💡 getStaticPaths에서 리턴된 params로 데이터를 패칭하여 사전 렌더링 시켜주기
그런데 getStatic~을 사용하면 빌드시 정적 파일이 만들어 진다고 하는데,
markdown 파일이 블로그 글이라고 했을 때, 파일 내용이 바뀔 때마다 빌드를 해줘야되네...? 라는 생각이 들었다....
root(/) 페이지에 목록 페이지를 만들기
export const getStaticProps: GetStaticProps = async () => { const dir = path.resolve('./__posts'); const fileNames = fs.readdirSync(dir); const pathNames = fileNames.map((file) => { const fileName = file.split('.'); const mimetype = fileName.pop(); if (fileName && mimetype === 'md') return fileName.join(''); }); return { props: { postLists: pathNames } }; };
위에서 언급한대로 StaticProps를 통해 파일의 목록을 읽어와 목록을 props로 넘겨주어 View에서 보여지도록 했다.
/[id] 페이지에 상세 페이지 만들기
// 각 포스트를 그려줄 상세 페이지 경로를 생성 export const getStaticPaths: GetStaticPaths = () => { const dir = path.resolve('./__posts'); const fileNames = fs.readdirSync(dir); const pathNames = fileNames.map((file) => { const fileName = file.split('.'); const mimetype = fileName.pop(); if (fileName && mimetype === 'md') return fileName.join(''); }); const params = pathNames.map((name) => { return { params: { id: name } }; }); return { paths: params, fallback: false // 지정된 경로가 없을 때 404 페이지를 리턴함 }; }; // 정적 페이지를 생성할 때 필요한 데이터 생성 export const getStaticProps: GetStaticProps = async (context) => { const fileName = context.params?.id; const contents = fs.readFileSync(`./__posts/${fileName}.md`, 'utf8'); const { data, content } = matter(contents); console.log(data); console.log(content); // markdown 파일 파싱 로직 return { props: { data: data, content: content } // will be passed to the page component as props }; };
.md 파일 파싱하기 (feat. gray-matter, frontmatter)
이제 파일 내용을 불러왔으니, markdown 파일 파싱 로직을 추가하고, props로 넘겨서 페이지를 구성하면 된다!
우선 markdown에 meta data를 사용할 수 있어야 한다는 요구사항과
주신 힌트에 따라 gray-matter와 frontmatter를 보기로 한다.
예시를 찾아볼 겸 frontmatter의 코드(링크)를 살펴보았다.
/pages/docs/index.tsx
import { getAllPosts } from '../../lib/api'; export const getStaticProps = async () => { const pages = getAllPosts('docs', [ 'title', 'slug', 'description', 'date', 'lastmod', 'weight', 'content', 'fileName' ]); return { props: { pages }, } }
/ilb/api.ts
import fs from 'fs'; import glob from 'glob'; import { join } from 'path'; import matter from 'gray-matter'; type ContentType = "docs" | "changelog"; const postsDirectory = join(process.cwd(), 'content'); export function getPostSlugs(type: ContentType) { return glob.sync(join(postsDirectory, type, '**/*.md')).map(file => file.replace(join(postsDirectory, type, '/'), '')); } export function getPostByFilename(type: ContentType, crntFile: string, fields: string[] = []) { const realSlug = crntFile.replace(/\.md$/, ''); const fullPath = join(postsDirectory, type, `${realSlug}.md`) const fileContents = fs.readFileSync(fullPath, 'utf8') const { data, content } = matter(fileContents) const items: any = {} // Ensure only the minimal needed data is exposed fields.forEach((field) => { if (field === 'content') { items[field] = content } if (field === 'fileName') { items[field] = realSlug } if (field === 'slug') { items[field] = data['slug'] || realSlug } if (data[field]) { if (data[field] instanceof Date) { items[field] = (data[field] as Date).toISOString() } else { items[field] = data[field] } } }) return items } export function getAllPosts(type: ContentType, fields: string[] = []) { const fileNames = getPostSlugs(type); const posts = fileNames .map((fileName) => getPostByFilename(type, fileName, fields)) // sort posts by date in descending order .sort((post1, post2) => { return (post1 as any)?.date > (post2 as any)?.date ? -1 : 1 }); return posts }
필요한 옵션을 배열로 넘겨서 meta data를 가져와 사용한 것을 알 수 있었고,
gray-matter를 사용해서 파싱하는 것을 확인할 수 있었다.
여기서부터 약간 감이 안잡혀서 gray-matter와 react-markdown을 install 해서 써봤는데...
바로 과제가 끝난 수준이 되었다.
(이게 개발인가?)하지만 결국에는 파싱을 직접 하는 것이 목표... 위 라이브러리들을 uninstall 해주었다.
마음을 다잡고 차근차근 해보자..
에디터를 만드는 것이 목적도 아니고, md 파일을 정적으로 페이지에 보여주는 것이 목표이기 때문에
우선 meta data를 파싱부터 해보기로 했다.
front-matter 양식에서 사용된 문법은 yaml이라는 것이고, json과 같은 데이터 직렬화 양식이다.
gray-matter(링크)에서 어떻게 파싱하는지 제대로 다시 살펴보자
/index.js
function matter(input, options) { if (input === '') { return { data: {}, content: input, excerpt: '', orig: input }; } let file = toFile(input); const cached = matter.cache[file.content]; if (!options) { if (cached) { file = Object.assign({}, cached); file.orig = cached.orig; return file; } // only cache if there are no options passed. if we cache when options // are passed, we would need to also cache options values, which would // negate any performance benefits of caching matter.cache[file.content] = file; } return parseMatter(file, options); } /** * Parse front matter */ function parseMatter(file, options) { const opts = defaults(options); const open = opts.delimiters[0]; const close = '\n' + opts.delimiters[1]; let str = file.content; // ... return file; }
여기서 주목할 것은 parseMatter 함수 내부에 선언된 opts, open, close 변수이다.
opts는 사용할 meta data의 key 이름, 그리고 open과 close 변수를 통해 meta data의 시작과 끝을 찾는다.
이 변수들과 함께 front-matter을 파싱하는 로직이 이어진다.
이를 참고하여 meta data를 뽑아내서 파싱하고, 페이지에 내용을 띄워주는 작업을 진행했다.
사용 할 meta는 categories, date, description, slug, tags, title 가 있다.
이 중 slug가 무엇인지 몰라서 찾아보았고,
'일부 시스템은 슬러그를 사람이 읽을 수 있는 키워드의 페이지를 식별하는 URL의 일부로 정의한다.' 라고 한다. (https://en.wikipedia.org/wiki/Clean_URL)
💡 slug를 :id(param)로 사용하기
이 meta data 작업 과정에서 파일을 읽어오고, 파싱하는 부분을 함수로 분리했다.
그 다음은 컴포넌트화 및 CSS 작업 진행
예쁜.... 디자인이 좋긴한데 HTML 구조랑 CSS 연습 중이라서
블로그 디자인 조금 배껴서 간단하게 만듬. 반응형은 아직 어렵..
이제 markdown 파싱만 남았다.
하기전에 remark, remark-html 라이브러리 참고해보라고 힌트를 주셔서 찾아봤다.
그런데... 그 안에도 여러 라이브러리들이 쓰여있다...
라이브러리를 참고해서 만드는데 라이브러리를 써야하는 ㄴㅇㅁㅇㄱ
어디서부터 라이브러리를 써야하고, 어디서부터 라이브러리를 쓰지 않고 해봐야할까..
라이브러리들을 타고 다니다보니
remark, remark-html, react-remark 에서 unified, vfile 그리고 mdast-util-from-markdown까지 넘어왔다.
아무래도 내가 사용해야할 라이브러리는 mdast-util-from-markdown으로 생각된다.
왜냐... 이 라이브러리 코드가 yaml 파싱해서 객체 만들려고 했던거랑 비슷하게 생겼다.. 파싱 라이브러리라...
이 라이브러리를 사용하니 markdown으로 작성된 내용이 아래와 같이 객체로 리턴된다.
{ type: 'root', children: [ { type: 'heading', depth: 2, children: [ { type: 'text', value: '예시입니다' } //... ] // ... }, { type: 'list', ordered: false, start: null, spread: false, children: [ { type: 'listItem', spread: false, checked: null, children: [ { type: 'paragraph', children: [ { type: 'text', value: '예시입니다', //... } ], ] //... }
이 객체를 가지고 HTML 로 만들어서 컴포넌트에 주입하면 된다.
이후, 프로젝트를 build 해보니 static page 들이 빌드 된것을 확인할 수 있었다! 완성!
완성된 프로젝트 보러가기
https://github.com/gaeundev/wanted-pre-onboarding-challenge-3-day2
GitHub - gaeundev/wanted-pre-onboarding-challenge-3-day2
Contribute to gaeundev/wanted-pre-onboarding-challenge-3-day2 development by creating an account on GitHub.
github.com
회고
긴 시간 작업하면서 이런 저런 들었던 생각들을 다 적어봤습니다....
1)
이 전 회사 프로젝트에서 NextJs를 사용해서 홈페이지를 만들었었는데.
이 때는 위 두개의 함수가 잘 이해되지 않았고, SEO를 위해 getServerSideProps를 통해 데이터 패칭만 해주었었는데..
이번 강의에서 SSG에 대한 내용을 듣고 나서 공식문서의 내용을 다시 읽으니 조금씩 머리속에 들어오는게 신기했다.
그러면서 전 프로젝트에서 이 기능들을 사용해서 만들 수 있던 페이지가 없었을까? 라는 고민을 할 수 있었고,
결론은 있었다고 생각한다.
홈페이지 내의 이벤트 페이지였는데, 페이지 내부의 데이터가 유동적으로 바뀔 일도 없었고,
이벤트 페이지의 경로도 동적라우팅이지만 리턴할 페이지들이 지정되어 있었기 때문에
해당 페이지들을 SSG 기능을 사용해서 했다면 좀 더 성능을 개선할 수 있었겠구나. 라는 생각이 들었다.
나도 공식문서를 읽고, 그 내용을 빠르게 이해해서 적재적소에 필요한 기능을 잘 사용하는 개발자가 되고 싶다ㅠㅠ
2)
확실히 라이브러리가 js로 되어있으면 타고 들어가서 까보는게 조금은 수월하다.
ts로 되어있는 라이브러리는 쉽지 않았는데..
그 이유를 생각 해보면, 필요한 함수에 접근하기 위해서 우선은 type을 재끼고? 찾아 들어가야하는데,
아직 구분이 잘 되지 않아 어렵게 느껴지는 것 같다.
3)
중간에 md파일 파싱하는 내용에서 front-matter의 yaml 문법을 직접 파싱하려고 시도하다가 그만두었다.
생각보다 어렵고, 시간이 오래걸려서 이게 맞나? 싶었음...
😵💫 정신이 혼미해갈 때 쯤... 뭔가 과제의 의도와 점점 벗어나고 있다는 생각이 들었다. (+ 내 정신건강을 위해..)
그래서 바로 js-yaml 설치해서 진행했다.
4)
처음에는 파일명을 param으로 사용했는데, 파싱하면서 slug로 param을 사용하는 걸로 바꾸는 과정에서 고민이 생겼다.
상세 페이지에서 param에서 값을 읽어와 해당 파일을 찾는 부분에서 수정이 필요해졌고,
그러면서 이걸 찾기 위해서 모든 파일을 읽어와서 검색해야한다고? 라는...
물론 static한 페이지를 배포하기 때문에 사용자들은 딱히 성능의 차이를 못 느끼겠지만, 빌드가 느려질 것이다..
포스트가 많아지고, 내용이 늘어날수록 더 느껴지겠지... 이 부분은 추후에 리팩토링 해보자
5)
파일 불러오는 부분을 함수화하는 과정에서 변수명이 중간중간 수정되고 할 때,
다른 페이지들에서 오류가 있다고 알려주었다. 이것이 ts의 맛인가..? 🤩
js 였다면 실행하면서 오류가 터지고, 오류 터진 파일을 찾아가야 했을 텐데..
이런 기능은 확실히 개발하기에 유용해서 좋다.
6)
이 프로젝트는 작업하는데에 2일 정도 걸렸다.
처음에는 하루면 되겠지 했는데, 하루는 무슨... 하루에 10시간 넘게해서 겨우 2일내에 완성했다.
가장 많이 쓴 시간은 다른 라이브러리들 참고하려고 뜯어보는 부분이었다.
추가로 컴포넌트 나누는거나 로직 함수화를 고민하면서 했는데도 아직 아쉽다.
그리고 약간 지쳤을지도...
재밌게 했는데 왜 지쳤지..?'프레임워크 > React(NextJs)' 카테고리의 다른 글
HTML form 태그 안에 submit 버튼을 여러 개 사용하여 동작을 다르게 하기 (with. React Hook Form) (0) 2024.07.24 [NextJs] Vercel로 SSG 프로젝트 배포하기 (0) 2022.10.14 CSR/SSR (with Next.js) (0) 2022.10.08 [React] typescript + webpack 에서 tsconfig.json - paths 속성 사용하기 (별칭 경로) (0) 2022.09.10 [React] react-hook-form(feat. typescript)으로 Input 컴포넌트 만들어보기 (0) 2022.09.06