Next.js로 나만의 블로그 만들기 [1] 기술 스택과 포스팅 구현

May 11, 2023

바닥부터 시작하는 기술 블로그 생성기

Next.js로 나만의 블로그 만들기 [1] 기술 스택과 포스팅 구현

개요

엘리스 1차 팀 프로젝트가 끝나고 다음 프로젝트까지 약 4주 가량의 시간이 생겼다. 그동안 커리큘럼 외에 개인적으로 어떤걸 공부할지 고민하던 중에, 기존에 배우다 멈춘 Next.js를 좀 더 공부하고 그걸 활용해서 뭔가 만들고 싶다는 생각이 들었다. 그렇게 어떤걸 만들지 고민하다가 결국 SSR과 SSG를 구현해보기에 가장 적합한 개인 블로그를 만들기로 결심하였다.

기술스택 고르기

"모든 프레임워크에는 기술 부채가 따른다." -프레임워크 없는 프론트엔드 개발- 중 발췌
블로그를 만들기로 결심한 이후에 가장 먼저 한 일은 바로 기술스택을 결정하는 일이었다.

Typescript

현재 프론트엔드 씬에서 바닐라 JS보다 많이 쓰이는 Typescript를 사용하기로 결정했다. 사실 TS를 제대로 써본적도 없고 정말 기초적인 이론만을 알고 있지만 그냥 만들어보면서 부딪혀보기로 했다.

Next.js

SSG 기반 블로그를 만들때 가장 많이 사용되는 스택 2가지를 뽑으라면 아마 Next.jsGatsby 두 가지일 것이다. 그 외에 방법으로는 jekyll을 사용해서 자동화로 만드는 방식도 존재한다. 사실 단순한 블로그를 만드는데에는 SSR을 배제하고 Gatsby만 사용하여 만드는 방법 또한 추천할만한 방법이다. 하지만 현재 많은 기업에서 쓰이고 개인적으로도 평소에도 관심이 있었던 Next.js를 사용해보고 싶은 마음이 컸기에 Next.js를 사용하기로 결정했다.

styled-components

기존에 내가 사용해본 컴포넌트 스타일링 라이브러리는 CSS Modulestyled-components가 있었다. 개인적으로는 CSS-in-CSS보다는 CSS-in-JS 스타일을 선호하여서 styled-components를 좀 더 자주 사용한다. 그렇기에 이번에도 styled-components를 사용하여 스타일링을 하기로 결정하였다. Emotion을 배워서 적용시켜볼까 하는 생각도 있었지만 styled-components와 큰 차이가 없다는 얘기를 들어서 좀 더 나중에 사용해보기로 했다.

Recoil

전역 상태관리 라이브러리는 Recoil을 사용할 생각이다. 전역 상태관리 라이브러리 같은 경우에는 기존에 Redux와 Redux Toolkit을 써본 적이 있다. Redux는 몰라도 RTK를 쓰면서 불편함을 느꼈던 적은 없었기 때문에 RTK를 쓸까라는 생각을 했지만 요새 많이 쓰이는 Recoil이 궁금하기도 하고 예전부터 한번 사용해보고 싶은 마음이 있었기에 Recoil을 사용할 예정이다. 하지만 이번 주제가 블로그다 보니 전역상태를 관리할 경우는 다크모드 외에는 크게 없을것 같아서 이후에 필요한 시점이 올 때 적용할 예정이다.

포스팅 구현

Readme 파일 가져오기

블로그 포스팅의 경우 Readme를 파싱해서 렌더링하는 방식으로 구현하려 했다. 그러기 위해선 우선 Readme를 어떻게 불러오는가에 대한 의문이 있었는데 Next.js의 공식 레포지토리에 blog-starter가 존재하여서 여기서 많은 참고를 했다. 공식 레포지포리에서 Readme를 읽어오는 방식은 다음과 같다.
1import fs from 'fs'; 2import { join } from 'path'; 3import matter from 'gray-matter'; 4 5const postsDirectory = join(process.cwd(), '_posts'); 6 7export function getPostSlugs() { 8 return fs.readdirSync(postsDirectory); 9} 10 11export function getPostBySlug(slug: string, fields: string[] = []) { 12 const realSlug = slug.replace(/\.md$/, ''); 13 const fullPath = join(postsDirectory, `${realSlug}.md`); 14 const fileContents = fs.readFileSync(fullPath, 'utf8'); 15 const { data, content } = matter(fileContents); 16 17 type Items = { 18 [key: string]: string; 19 }; 20 21 const items: Items = {}; 22 23 // Ensure only the minimal needed data is exposed 24 fields.forEach((field) => { 25 if (field === 'slug') { 26 items[field] = realSlug; 27 } 28 if (field === 'content') { 29 items[field] = content; 30 } 31 32 if (typeof data[field] !== 'undefined') { 33 items[field] = data[field]; 34 } 35 }); 36 37 return items; 38} 39 40export function getAllPosts(fields: string[] = []) { 41 const slugs = getPostSlugs(); 42 const posts = slugs 43 .map((slug) => getPostBySlug(slug, fields)) 44 // sort posts by date in descending order 45 .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); 46 return posts; 47} 48
간단하게 요약해서 설명하자면 루트 디렉토리에 존재하는 _post 폴더 내에 모든 파일을 읽어 온 후 해당 파일을 gray-matter 라이브러리를 통해 데이터와 콘텐트를 분리시킨 후 객체로 묶어서 배열화하는 방식이다. 이 방식 또한 충분히 훌륭하지만 나는 개인적으로 카테고리와 태그를 추가로 구현하고 싶어서 코드를 살짝 수정하였다.
1// .../lib/api.ts 2 3import fs from 'fs'; 4import { join } from 'path'; 5import matter from 'gray-matter'; 6 7const postsDirectory = join(process.cwd(), '_posts'); 8 9interface Category { 10 categoryName: string; 11 quantity: number; 12} 13 14export function getAllCategories(): Category[] { 15 const categories = fs.readdirSync(postsDirectory); 16 const categoriesWithQuantity = categories.map((category) => { 17 const filesRoot = join(postsDirectory, category); 18 const posts = fs.readdirSync(filesRoot).length; 19 return { categoryName: category, quantity: posts }; 20 }); 21 return categoriesWithQuantity; 22} 23 24export function getSlugsByCategory(category: string) { 25 const filesRoot = join(postsDirectory, category); 26 const slugs = fs.readdirSync(filesRoot, 'utf-8').map((slug) => { 27 return { 28 slug, 29 category, 30 }; 31 }); 32 return slugs; 33} 34 35export function getPostBySlug(slug: string, category: string, fields: string[] = []) { 36 const postMdFileRoot = join(postsDirectory, category, slug); 37 const postMdFile = fs.readFileSync(`${postMdFileRoot}`, 'utf8'); 38 const { data, content } = matter(postMdFile); 39 40 type Items = { 41 [key: string]: string; 42 }; 43 44 const items: Items = {}; 45 46 fields.forEach((field) => { 47 if (field === 'slug') { 48 items[field] = slug.split('.md')[0]; 49 } 50 if (field === 'content') { 51 items[field] = content; 52 } 53 if (field === 'category') { 54 items[field] = category; 55 } 56 if (typeof data[field] !== 'undefined') { 57 items[field] = data[field]; 58 } 59 }); 60 61 return items; 62} 63 64export function getAllPosts(fields: string[] = []) { 65 const categories = getAllCategories(); 66 const categoriesWithoutquantity = categories.map((item) => item.categoryName); 67 const posts = categoriesWithoutquantity 68 .map((category) => getSlugsByCategory(category)) 69 .flat() 70 .map(({ slug, category }) => getPostBySlug(slug, category, fields)) 71 .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); 72 return posts; 73} 74 75export function getAllPostsByCategory(category: string, fields: string[] = []) { 76 const posts = getSlugsByCategory(category) 77 .map(({ slug, category }) => getPostBySlug(slug, category, fields)) 78 .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); 79 return posts; 80} 81 82export function getAllPostsByTag(tag: string, fields: string[] = []) { 83 const posts = getAllPosts(fields) 84 .filter(({ tags }) => tags.includes(tag)) 85 .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); 86 return posts; 87} 88 89export function getAllTags(category?: string) { 90 const allTags = category ? getAllPostsByCategory(category, ['tags']) : getAllPosts(['tags']); 91 const tagsObj: { [key: string]: number } = {}; 92 allTags 93 .map((item) => item.tags) 94 .flat() 95 .forEach((item) => { 96 if (!tagsObj[item]) tagsObj[item] = 1; 97 else tagsObj[item] += 1; 98 }); 99 const tagKeys = Object.keys(tagsObj); 100 const tags = tagKeys.map((tag) => { 101 return { 102 tagName: tag, 103 quantity: tagsObj[tag], 104 }; 105 }); 106 107 return tags; 108} 109
카테고리 같은 경우에는 gray-matter 라이브러리를 사용하기에 사실 front-matter에 categoy를 추가해서 구현하면 되긴 한다. 하지만 그럴경우 카테고리에 오타가 존재할 가능성이 있고 md 파일 하나하나 모두 카테고리를 직접 입력해야 했기에 좀 더 자동화 시키는 방식으로 구현했다. 카테고리의 경우에는 폴더 뎁스를 하나 더 추가해서 해당 디렉토리명을 카테고리로 묶어서 관리하기로 했다.

Readme 파일 파싱하기

이제 불러온 파일을 포스트 페이지에서 파싱하여 렌더링 해야한다. 이전에 설치한 gray-mattercontent영역을 분리하는건 완료하였으니 이제 이 데이터를 HTML문서로 변환만 해주면 된다. 이러한 string 타입의 마크다운 파일을 변환해주는 React-Markdown 라이브러리를 활용하여 포스트를 구현하였다.
1const customComponent = { 2 p({ ...props }) { 3 if (typeof props.children[0] === "object") { 4 const element: any = props.children[0]; 5 return ( 6 { ...element } 7 ) 8 } 9 return ( 10 <PostContentP> 11 {props.children} 12 </PostContentP> 13 ) 14 }, 15 a({ ...props }) { 16 return ( 17 <Link href={props.href}> 18 {props.children} 19 </Link> 20 ) 21 }, 22 h1({ ...props }) { 23 return ( 24 <PostContentH1 id={props.children}> 25 {props.children} 26 </PostContentH1> 27 ) 28 }, 29 h2({ ...props }) { 30 return ( 31 <PostContentH2 id={props.children}> 32 {props.children} 33 </PostContentH2> 34 ) 35 }, 36 h3({ ...props }) { 37 return ( 38 <PostContentH3 id={props.children}> 39 {props.children} 40 </PostContentH3> 41 ) 42 }, 43 img({...props}) { 44 return ( 45 <PostContentImg 46 src={props.src} 47 alt={props.alt} 48 /> 49 ) 50 }, 51 code({ ...props }) { 52 const match = /language-(\w+)/.exec(props.className) as RegExpExecArray; 53 if (!match) { 54 return ( 55 <code className='small-code'> 56 {String(props.children).replace(/\n$/, '')} 57 </code> 58 ) 59 } 60 return ( 61 <SyntaxHighlighter style={vscDarkPlus} language={match[1]} PreTag="div" {...props}> 62 {String(props.children).replace(/\n$/, '')} 63 </SyntaxHighlighter> 64 ) 65 } 66} 67 68const PostBody = ({ children, post } : Props) => { 69 return ( 70 <> 71 <PostBodyBox> 72 <img className="thumbnail" src={post.thumbnail} /> 73 <ReactMarkdown components={customComponent}> 74 {children} 75 </ReactMarkdown> 76 </PostBodyBox> 77 </> 78 ) 79} 80
임포트한 ReactMarkdown 컴포넌트에 children으로 content를 삽입하면 알아서 마크다운을 파싱해준다. 하지만 추가적으로 HTML 컴포넌트를 커스텀하고 싶다면 components에 컴포넌트 집합 객체를 props로 부여하여 해결 할 수 있다. 코드블럭같은 경우는 SyntaxHighlighter 라이브러리를 활용하였다.