Next.js
퍼블리셔로 일할 때부터 지금 프론트엔드 개발자로 전환하기까지, 꾸준히 개발 블로그를 운영해왔다.
개발 블로그를 어떻게 활용하느냐는 사람마다 다르겠지만, 나의 경우엔 실무에서 겪은 에러 해결이나 트러블슈팅 기록, 새로 알게 된 것들의 정리, 그리고 단순히 무언가라도 올리고 싶어서 같은 이유들이 뒤섞여 있다.
기존에는 티스토리로 운영하고 있었고, 솔직히 티스토리로 계속해도 아무 문제가 없었다. 그런데 평소에 다른 사람들의 기술 블로그를 즐겨 찾아보는데, 티스토리든 velog든 플랫폼은 달라도 직접 만든 블로그를 운영하는 사람들의 글에 유독 눈이 갔다. 뭔가 그 블로그 자체에서도 그 사람의 색깔이 느껴지는 것 같았다.
사실 티스토리로 운영할 때도 아무 문제가 없었지만, 블로그를 개발하기로 마음 먹은 이유는 첫 번째로 내 포트폴리오 사이트에 블로그 API를 연동해서 내 사이트에 내 블로그 글들을 보여주고 싶었다. 그래서 티스토리 API 제공하겠지? + 그걸로 내 블로그 글 뿌려주면 되겠지? 라고 생각했지만….

티스토리 Open API는 무려 23년 말에 종료되어 있었다.
물론 지금의 내 포트폴리오 사이트에는 블로그 글을 보여주는 기능을 넣지 않았다. 지금 생각해보니 너무 복잡하고 지저분해 보일 것 같아서 현재는 그 생각을 잠시 접어 뒀지만, 나중에라도 블로그 글을 활용한 무언가를 만들려면 티스토리는 힘들 것 같다는 판단이 들었다.
두 번째는 프론트엔드 개발자로서 블로그 하나 못 만들면 좀 이상하지 않나(?)라는 막연한 마음도 있었다. 그리고 티스토리나 velog 같이 플랫폼을 사용하면 글 작성과 운영은 편하지만, 코드 스니펫이나 블로그 전체 레이아웃 등이 정해져있어서 내가 원하는 디자인으로 완전히 커스텀이 되지 않는다는 것 + 물론 티스토리에서는 커스텀할 수 있도록 지원하고 있지만 그럴거면 내가 처음부터 만들지…. 라는 생각이 들었다.
일단 당연하면서도 막연하게 Next.js로 개발한다고 생각하고 있었다. 블로그는 글이 중심이 되고, SEO가 중요하다보니 리액트를 기반으로 하되 서버 사이드 렌더링이 되는 Next.js로 생각이 이어졌다.
그리고 회사에서 가장 최근에 진행했던 프로젝트가 Next.js였고, App Router 방식에 익숙해지기 위해 + Next.js 자체에 좀 더 익숙해지기 위해 혼자 학습 겸으로도 선택하게 되었다.
근데 그럼 글은 어떻게 작성하고 DB는 뭘 사용하지? 라는 생각이 들어서 서칭을 해보니, Notion으로 개발 블로그를 만드는 사람들이 꽤 많았다. 노션을 사용하면 일단 글 작성이 편할 것 같다는 생각이 들었고 평소에도 노션으로 개인적인 정리나 업무 일지 작성 등 다양하게 잘 활용을 하고 있었기에 노션으로 결정하게 되었다.
나는 실무에서 Styled-components 사용 경험이 가장 많지만, 이번에는 빠르게 완성하고 싶었기에 Tailwind CSS를 사용했다. 그리고 UI 컴포넌트는 빠르게 완성도 있는 UI를 만들 수 있는 shadcn을 사용했다.
노션 API는 공식문서에서 사용법을 모두 확인할 수 있다.
https://developers.notion.com/reference/intro
import { Client } from '@notionhq/client';
import { NotionToMarkdown } from 'notion-to-md';
export const notion = new Client({
auth: process.env.NOTION_TOKEN,
});
const n2m = new NotionToMarkdown({
notionClient: notion,
config: {
parseChildPages: false,
convertImagesToBase64: false,
},
});노션 API를 사용하기 위해서는 인증키가 필요한데, 인증키는 환경변수로 관리해주었다. 그리고 notion-to-md는 Notion 페이지 블록을 마크다운 문자열로 변환해주는 라이브러리다. 노션 API는 페이지 본문을 블록(Block) 단위로 반환하는데, 이걸 화면에 렌더링하려면 블록 타입마다(paragraph, heading_1, code, bulleted_list_item, ...) 일일이 직접 처리 로직을 짜야하는데, notion-to-md가 이 과정을 대신해준다.
export const getPublishedPosts = async ({
tag = '전체',
sort = 'latest',
pageSize = 8,
startCursor,
}: getPublishedPostParams = {}) => {
const response = await notion.dataSources.query({
data_source_id: process.env.NOTION_DATASOURCE_ID!,
filter: {
and: [
{ property: 'Status', select: { equals: 'Published' } },
...(tag !== '전체'
? [{ property: 'Tags', multi_select: { contains: tag } }]
: []),
],
},
sorts: [
{
property: 'Date',
direction: sort === 'latest' ? 'descending' : 'ascending',
},
],
page_size: pageSize,
start_cursor: startCursor,
});
return {
posts: response.results.map(getPostMetadata),
hasMore: response.has_more,
nextCursor: response.next_cursor,
};
};노션에서 작성 중인 글은 보이지 않고, 작성 완료한 글만 보여지도록 하기 위해 Status라는 Select 타입을 통해 Published인 페이지만 블로그에서 보이도록 했다.
const response = await notion.dataSources.query({
data_source_id: process.env.NOTION_DATASOURCE_ID!,
filter: {
and: [
{ property: 'Slug', rich_text: { equals: slug } },
{ property: 'Status', select: { equals: 'Published' } },
],
},
});
const mdBlocks = await n2m.pageToMarkdown(response.results[0].id);
const { parent } = n2m.toMarkdownString(mdBlocks);
return { markdown: parent, post: getPostMetadata(response.results[0]) };notion-to-md은 노션으로 작성한 본문을 변환해서 마크다운 문자열로 만들어준다. 그리고 getPostMetadata는 노션 응답 타입을 내가 만든 Post라는 타입에 맞게 변환해주는 함수이다.
<MDXRemote
source={markdown}
components={{
p: ({ children }) => {
return <p className="not-prose relative leading-relaxed">{children}</p>;
},
br: () => <br className="my-2" />,
......
/>next-mdx-remote 를 사용해서 마크다운 텍스트를 리액트 컴포넌트로 렌더링 해주는데, 필요한 부분은 components 속성 안에서 커스텀해서 사용했다.
블로그를 만들었으면 검색에 노출이 잘 돼야 의미가 있으니, SEO 작업도 필요했다.
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | Jihyeon Kim Blog',
default: 'Jihyeon Kim Blog',
},
description: '........',
keywords: [
'프론트엔드',
'웹개발',
......
],
formatDetection: {
email: false,
telephone: false,
address: false,
},
metadataBase: new URL(`${process.env.NEXT_PUBLIC_SITE_URL}`),
alternates: {
canonical: '/',
},
openGraph: {
title: 'Jihyeon Kim Blog',
description: 'Jihyeon Kim Blog',
url: 'https://jhkim-work.com',
siteName: 'Jihyeon Kim Blog',
images: [
{
url: '/assets/images/og-image.png',
width: 1200,
height: 630,
alt: 'Blog OpenGraph Image',
},
],
locale: 'ko_KR',
type: 'website',
},
};metadata 객체는 Next.js가 자동으로 head 태그에 삽입해주는 SEO 관련 설정이다.
template 는 하위 페이지에서 title을 지정하면 "글 제목 | Jihyeon Kim Blog" 형태로 자동 조합해준다.
모바일 브라우저가 텍스트를 자동으로 감지해서 링크로 변환하는 기능을 끄는 설정이다. 예를 들면 모바일 브라우저는 010-XXXX-XXXX 형태의 텍스트가 있으면 자동으로 전화 걸기 링크로 변환한다. 블로그 본문에 코드 예시나 일반 텍스트로 적은 번호 등이 의도치 않게 링크처럼 보이는 걸 방지하기 위해 사용한다.
같은 콘텐츠가 여러 URL로 접근 가능할 때(예: ?page=1 쿼리스트링 등), 검색엔진에 대표 URL이 어디인지 알려주는 설정이다. 중복 콘텐츠로 인한 SEO 감점을 방지한다.
카카오톡, 슬랙, 트위터 등에 링크를 공유했을 때 나오는 미리보기 카드에 사용되는 정보이다. images에 지정한 이미지가 썸네일로 표시된다.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const { post } = await getPost(slug);
if (!post) {
return {
title: '포스트를 찾을 수 없습니다',
description: '요청하신 블로그 포스트를 찾을 수 없습니다.',
};
}
return {
title: post.title,
description: post.description || `${post.title} - Jihyeon Kim Blog`,
keywords: post.tags,
alternates: {
canonical: `/posts/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.description,
url: `/posts/${post.slug}`,
type: 'article',
publishedTime: post.date,
modifiedTime: post.modifiedDate,
authors: post.author || 'JH',
tags: post.tags,
},
};
}그리고 각 포스트 페이지에서 해당 글의 정보로 메타데이터를 동적으로 생성하기 위해 generateMetadata를 사용했다.
검색 엔진이 내 블로그를 잘 크롤링할 수 있도록 sitemap.xml과 robots.txt도 추가했다. Next.js에서는 sitemap.ts를 작성하면 sitemap.xml로 자동 생성된다.
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/'], // API 경로와 Next.js 내부 경로는 차단
},
sitemap: `${process.env.NEXT_PUBLIC_SITE_URL}/sitemap.xml`,
};
}robots.txt는 검색엔진 크롤러에게 어떤 경로를 크롤링해도 되는지 알려주는 파일이다. robots.ts를 작성하면 robots.txt로 자동 생성된다.
그리고 sitemap을 만든 뒤 Google Search Console에 등록해서 색인이 제대로 되는지 확인하고 있다. 색인이 생성되지 않는 글들은 메일로 알려주기 때문에 주기적으로 메일을 확인하면서 수정하고 있다.

Lighthouse에서 검사해봤을 때 SEO는 100점이 나왔다.
배포는 Vercel로 배포했고 커스텀 도메인도 추가해주었다. Vercel에서 커스텀 도메인 추가하는 방법은 여기로
아직 개선하고 싶은 부분도 있다. 디자인도 너무 평범한 디자인이라 개선하고 싶고, 코드 리팩토링이나 LCP 등의 성능 개선도 점진적으로 진행해 나가면서 운영할 생각이다.