기술 스택 및 로직 결정
포트폴리오 겸 블로그 개발에 Next.js를 사용했다. 이 과정에서 노션에 작성된 포트폴리오와 블로그 데이터를 일종의 CMS처럼 사용했는데, 포트폴리오 메인 페이지에 렌더링이 필요한 데이터는 노션에서 서버리스 함수와
getStaticProps
를 사용해 로드했고, ISR을 사용하여 60초마다 데이터를 재생성하도록 설정했다. 프로젝트와 블로그의 본문 데이터는 /posts/[category]/[tag]/[slug]/[id]
로 이동 시 확인할 수 있도록 설계했다.export async function getStaticProps() { const databaseList = ['works', 'posts']; try { const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const props = { works: data[0], posts: data[1], }; return { props, revalidate: 60 }; } catch (e) { console.log('getStaticProps error >>', e); return { props: { works: [], posts: [], }, revalidate: 60 }; } }
SEO 최적화
이 과정에서 SEO를 위해 두 가지 로직을 추가로 적용했다.
getStaticPath
를 사용해[category]
,[tag]
,[slug]
,[id]
에 해당하는 데이터를 노션에서 미리 로드하고,getStaticProps
로 전달하여[id]
에 해당하는 값을 사용해 프로젝트와 블로그의 본문 데이터를 미리 로드했다.
export async function getStaticPaths() { const databaseList = ['works', 'posts']; const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const paths = data.reduce((acc, cur) => { return [ ...acc, ...cur.results.map((post) => { return { params: { category: post.properties?.category?.select.name, tag: post.properties?.tag?.multi_select[0].name, slug: post.properties?.slug?.rich_text[0]?.plain_text, id: post.id, }, }; }), ]; }, []); return { paths, fallback: 'blocking', }; } export async function getStaticProps({ params }) { const { id } = params; const notion = new NotionAPI({ activeUser: process.env.NOTION_ACTIVE_USER, authToken: process.env.NOTION_TOKEN_V2, }); const recordMap = await notion.getPage(id); return { props: { recordMap, }, revalidate: 60, }; }
- postbuild 시
/[category]/[tag]/[slug]/[id]
의 값을 모두 불러오는 스크립트를 생성했다. 그리고next-sitemap
을 사용하여 게시된 모든 프로젝트와 블로그의 라우터에 해당하는 정보를 포함한 sitemap을 생성하여 구글 서치콘솔과 연동했다.
/** @type {import('next-sitemap').IConfig} */ const fs = require('fs'); module.exports = { siteUrl: 'https://hyeonjong.com', generateRobotsTxt: true, sitemapSize: 7000, changefreq: 'daily', priority: 1, additionalPaths: async (config) => { const dynamicRoutes = JSON.parse(fs.readFileSync('dynamicRoutesForSitemap.json', 'utf8')); return dynamicRoutes.map(({ category, tag, slug, id }) => { return { loc: `/posts/${category}/${tag}/${slug}/${id}` }; }); }, robotsTxtOptions: { policies: [ { userAgent: '*', allow: '/', disallow: [], }, ], }, };
// scripts/generateDynamicRoutesForSitemap.js const fs = require('fs'); const fetch = require('node-fetch'); const getDynamicPaths = async () => { const databaseList = ['works', 'posts']; const responses = await Promise.all( databaseList.map((database) => fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/notion?database=${database}`) ) ); const data = await Promise.all(responses.map((res) => res.json())); const paths = data.reduce((acc, cur) => { return [ ...acc, ...cur.results.map((post) => { return { category: post.properties?.category?.select.name, tag: post.properties?.tag?.multi_select[0].name, slug: post.properties?.slug?.rich_text[0]?.plain_text, id: post.id, }; }), ]; }, []); // 경로 정보를 파일에 저장 fs.writeFileSync('dynamicRoutesForSitemap.json', JSON.stringify(paths), 'utf8'); }; getDynamicPaths();