프론트 기술로 꽉 채운 블로그 개발기

개발자들은 대개 블로그를 운영한다. 취업을 위해서이기도 하고 셀프 브랜딩 때문이기도하다. 처음에는 Tistory 플랫폼을 사용했다. 하지만 Tistory는 마크다운 에디터가 잘 동작하지 않고 디자인 커스텀이 완전히 자유롭지 않았다. 그 대안으로 Jekyll로 블로그를 만들어보기도 했다. 만들고 운영하다 보니 디자인이 금세 질렸다. 그리고 Jekyll은 루비 기반 정적 사이트 생성기다 보니 커스텀에 한계가 있었다. ReactVue와 같이 JSX가 없어서 불편하기도 했다. 나중에 시간 있을 때 다시 만들어보기로 다짐하고 우선 Velog 플랫폼에서 블로그를 운영했다.

올해 너무 바쁜 나머지 블로그는 영영 못 만드는 것 아닌가 싶었다. 다행히 추석 연휴에 시간이 남아서 블로그 개발을 시작했다. 그 후로 꾸준히 블로그를 개발하고 있었고 거의 완성됐다. 블로그 배포를 기념하며 블로그를 개발하면서 경험한 것들을 글로 정리해 보았다.

만들고 싶은 블로그

이전부터 맘에 드는 블로그를 모아보면 한가지 공통점이 있었다. 바로 MDX를 사용한다는 것이다. MDXmarkdownjsx를 사용할 수 있게 해준다. 따라서 더 유연하게 글을 작성할 수 있다. 아래 예시처럼 마크다운 문서에 인터렉티브한 요소를 넣을 수 있다. MDX를 사용한 블로그 예시를 살펴보면 더욱 체감할 수 있다.

지금 위 버튼도 MDX에서는 아래와 같이 작성할 수 있다.

<Playground>
 <Example/>
</Playground>

고유의 인터렉티브한 요소가 있는 블로그가 목표였다.

사용하는 기술

블로그도 하나의 프로젝트다. 따라서 프로젝트를 만들기 위해 필요한 기술을 고민해야 한다. 블로그를 만들기 위해 필요했던 기술을 다음과 같다. 많은 부분을 참고했던 블로그에서 참고했다.

  • 정적사이트 생성 : Next.js
  • 모노레포
  • MDX 관련 라이브러리 : contentlayer
  • 스타일링 : tailwindcss
  • 빌드오케스트레이션 : turborepo
  • 호스팅 : vercel

Next.js

보통 블로그는 정적 사이트다. 많이 쓰이는 정적 사이트 생성기는 Jekyll, Gatsby, Astro가 있다. JekyllMDX를 지원하지 않는다. 그렇다면 GatsbyAstro를 사용하면 되지 않을까? 하지만 나는 Next.js를 사용했다. 주된 이유는 익숙함이다. 또 많은 사람들이 Next.js로 블로그를 만들었다. 그만큼 예시가 풍부하다. 스크랩했던 블로그들도 모두 Next.js로 만들었다.

Next.js에서 정적 사이트를 생성하려면 getStaticPropsgetStaticPaths를 사용하면 된다. getStaticPathspath를 미리 만들 수 있고 getStaticProps로 데이터를 미리 가져올 수 있다. 이렇게 하면 빌드시에 데이터를 가져오기 때문에 빌드된 페이지는 정적인 페이지가 된다.

[slug].tsx
export function getStaticPaths() {
  const paths = allPosts.map((blog) => ({
    params: { slug: blog.slug },
  }))
 
  return {
    paths,
    fallback: false,
  }
}
 
export function getStaticProps({ params }: { params: { slug: string } }) {
  const post = allPosts.find((blog) => blog.slug === params?.slug)
  return {
    props: {
      post,
    },
  }
}

Monorepo

블로그를 만드는데 모노레포까지(?)라고 생각할 수 있지만... 연습삼아서 모노레포로 구성해 봤다. 모노레포 중 가장 난이도가 쉬운 쉬운 pnpm workspace를 사용했다. 그리고 빌드 오케스트레이션으로 turborepo를 사용했다. 폴더구조를 다음과 같이 나누었다. 폴더 구조는 참고했던 블로그와 비슷하다.

.
├── README.md
├── apps
   └── main # Next.js 코드
├── package.json
├── packages
   ├── @domain
      ├── blog # blog에 필요한 컴포넌트 및 로직 모음
      ├── docs # MDX에서 사용할 수 있는 컴포넌트 모음
      └── posts # 블로그 포스트 모음
   ├── @lib
      └── ui # 공통 ui 모음
   ├── tailwind-config # tailwind config 공통화
   └── tsconfig # tsconfig 공통화
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json

모노레포를 구성하면서 가장 어려웠던 점은 공통으로 1) tsconfig, 2) tailwind.config 3) eslint를 설정하는 일이다.

  1. 공통으로 사용할 tsconfig를 모노레포 내부 패키지로 만들어준다. 그리고 다른 패키지의 tsconfig.json에서 다음과 같이 extends해준다.
   {
     "extends": "tsconfig/library.json",
     "include": ["."],
     "compilerOptions": {
       // ... 추가할 것 작성
     }
   }
  1. tailwind.configtailwindcss를 사용하기 위해 필요한 설정 파일이다. tailwind.config를 공통으로 사용하려면 우선 하나의 패키지로 만들자. 해당 패키지를 사용하는 패키지에서는 tailwind.configextends로 사용하면 된다. 아래와 같이 작성해 주자. 참고로 tailwindcss를 사용하는 모든 패키지에서 tailwind.config 파일을 만드는 것이 좋다. 하나의 tailwind.configcontents 항목으로 해결하려고 하면 dev server가 느려지는 문제가 생긴다.
   import sharedConfig from 'tailwind-config/tailwind.config'
   import type { Config } from 'tailwindcss'
 
   const config: Pick<Config, 'presets'> = {
     presets: [sharedConfig],
   }
 
   export default config
  1. eslint는 기본적으로 타입스크립트를 파싱하지 못한다. 이 문제를 해결하기 위해서 typescript-eslint를 사용해야 한다. typescript-eslint에서는 타입스크립트에 특정한 규칙들을 제공한다. 모노레포가 아닌 구조에서는 .eslintrc.cjsparser 설정을 해주면 된다.
   /* eslint-env node */
   module.exports = {
   extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
   parser: '@typescript-eslint/parser',
   plugins: ['@typescript-eslint'],
   root: true,
   };

주의할 점은 typescript-eslint는 고유한 설정값을 가진다. parserOptions라는 설정은 네이티브 eslint에서도 존재하고 typescript-eslint에서도 존재한다. eslint에서 parserOptions는 지원할 ECMAScript 버전을 명시할 때 사용한다. 해당 버전에서 지원하지 않는 문법이라면 eslint는 파싱하지 못한다. 이와 다르게 typescript-eslint에서 parserOptionstsconfig의 경로를 명시할 때 사용한다. 경로를 명시해주면 tsconfig를 참고해서 타입스크립트를 파싱한다.

모노레포를 사용할 때는 어떻게 tsconfig.json을 사용하는지에 따라서 다르다. 하나의 tsconfig.json을 사용한다면 parserOptionsproject에 경로를 명시해주고 tsconfig.jsoninclude항목에 린트를 적용할 경로를 명시해 준다.

 {
   // ...
   "include": [
   // 린트를 적용할 경로들. glob패턴도 가능하다.
   "src",
   "test",
   "tools"
   ]
 }

모노레포에서 패키지마다 tsconfig를 다르게 구성하는 경우도 있다. 일부 패키지는 라이브러리 용도이고 다른 패키지는 앱 용도일 수 있다. parseOptionsproject 항목에 패키지들의 tsconfig경로를 명시해준다. .eslintrc.jstsconfigRootDirtrue로 명시해 주면 tsconfig를 찾을 때 해당 경로부터 상대경로로 tsconfig를 찾는다.

 {
   // ...
   "parserOptions": {
       project: ['./apps/**/tsconfig.json', './apps/**/tsconfig.script.json', './packages/**/tsconfig.json'],
       tsconfigRootDir: __dirname,}
 }

Contentlayer

ContentlayerMDX의 설정을 도와주는 라이브러리다. MDX를 구성할 때 겪는 어려움 중 하나는 빌드 설정이다. 특히 프론트엔드 빌드에 익숙하지않은 개발자라면 더욱 힘들다. Contentlayer는 그 과정을 간단하게 추상화했다. next.config.js를 다음과 같이 설정해 주면 된다.

import {withContentlayer} from 'next-contentlayer'
 
export default withContentlayer({})

Contentlayer의 또다른 장점은 콘텐츠를 데이터로 바라보는 것이다. Contentlayer는 MDX를 가공해서 JSON으로 변경시킨다. JSON으로 변경하는 작업은 생각보다 많은 이점이 있다.

  1. 콘텐츠에 타입을 지정할 수 있다. 아래 예시 코드 처럼 콘텐츠의 타입을 지정하면 MDXJSON으로 변경할 때 타입을 체크한다.(런타임에서 체크)
     import { defineDocumentType } from 'contentlayer/source-files'
 
     export const Post = defineDocumentType(() => ({
       name: 'Post',
       filePathPattern: 'posts/*.mdx',
       bodyType: 'mdx',
       fields: {
         title: {
           type: 'string',
           required: true,
         },
         slug: {
           type: 'string',
           required: true,
         },
         publishedAt: {
           type: 'string',
           required: true,
         },
       },
     }))

아래 mdx는 런타임 에러를 발생시킨다. publishedAt이 없기 때문이다.

---
title: 'title'
slug: 'slug'
---
 
# title
  1. 콘텐츠가 데이터화되고 type을 지정했기 때문에 import 하기 쉽다. Contentlayer를 사용하지 않았다면 typescript에서 타입을 작성해야 한다.
contentlayer를 쓰지 않았을 때
import { readFile } from 'fs';
 
// mdx파일을 직접 readFile로 읽어서 type을 구성해야한다. 자세한 코드는 생략한다.
const allPosts = readFile('...')
 
type Post = {
  title: string
  slug: string
  publishedAt: string
}
 
export default function Home() {
  return (
    <div>
      <h1>All posts</h1>
      <ul>
        {(allPosts as Post[]).map((post) => (
          <li key={post.url}>
            <a href={post.url}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}
  1. 콘텐츠를 JSON으로 변환하는 과정중에 콘텐츠를 기반으로 새로운 메타데이터를 만들 수 있다. 아래 예시는 MDX콘텐츠 기반으로 TOC와 최근 수정 일자를 만드는 코드다.
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
  },
  computedFields: {
    headings: {
      // TOC를 만들기 위한 코드
      type: 'json',
      resolve: (doc) => {
          // ... 코드 생략
      },
    },
 
    updated: {
      type: 'string',
      resolve: (doc) => {
        const fullPath = path.resolve('./posts/' + doc._raw.sourceFilePath)
        return readLastModified(fullPath)
      },
    },
  },
}))

TailwindCSS

작업량이 제일 적을 것 같은 스타일링 라이브러리를 선택했다. 현업에서는 Vanilla Extract를 많이 사용하지만 TailwindCSSUtility First 방식이 가장 빠르게 스타일링을 할 수 있다.

TailwindCSS의 단점으로는 지저분해지는 className이 있다. className을 지저분하게 만드는 데에는 두 가지 원인이 있다.

  1. 조건에 따라서 className을 추가하거나 제거해야 하는 경우가 있다. 이를 편하게 하기 위해서 clsx를 사용했다. clsx를 사용하면 코드의 가독성을 높일 수 있다.
import clsx from 'clsx'
 
const Button = ({ primary }) => {
  // clsx 사용 X
  const classes = `btn ${primary ? 'btn-primary' : ''}`
 
  // clsx 사용
  const classes = clsx('btn', {
    'btn-primary': primary,
  })
  return <button className={classes}>Button</button>
}
  1. TailwindCSS에서 다크모드 스타일링을 하려면 dark: prefix를 붙여야한다. 스타일 하나마다 dark:를 붙이기는 귀찮으니 cssVariable을 이용하는 방법을 선택했다. 아무 theme에 따라서 CSS 변수들을 변경시켜주면 된다. tailwind.config.tstheme.extend에 변수들을 추가해주면 된다.
:root[color-theme='light'] {
  --bg-primary: #fff;
  --bg-secondary: #000;
}
 
:root[color-theme='dark'] {
  --bg-primary: #000;
  --bg-secondary: #fff;
}
import type { Config } from 'tailwindcss'
 
const config: Partial<Config> = {
  content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
  theme: {
    extend: {
      backgroundColor: {
        primary: 'var(--bg-primary)',
        secondary: 'var(--bg-secondary)',
      },
    },
  },
  plugins: [],
}
export default config

호스팅 : Vercel

아무래도 Next.js로 만들었다 보니 Next.js의 개발사 Vercel을 사용했다. vercelgithub과 연동되어 있어서 github에 코드를 푸시하면 자동으로 배포된다. CI/CD가 없어도 된다는 것은 정말 편리했다. 또한 vercelpreview 기능을 제공한다. 각 커밋마다 빌드가 돼서 preview를 볼 수 있다.

블로그를 배포하면서 vercel에서 도메인도 구입하고 설정했는데 이 UX도 굉장히 만족스러웠다. 입력 폼에 사용하고 싶은 도메인을 입력하고 추가를 누르면 도메인이 추가되고 나머지 설정까지 자동으로 해준다. 대부분의 사이드 프로젝트에서도 vercel을 사용하고 있다. Vercel이 없었다면 AWS에서 삽질하고 있을 나 자신이 그려진다. 아직 한 번도 Vercel 서비스를 이용하지 않았다면 한번 사용해 보길 추천한다.

Turborepo

앞서 언급했듯이 이 블로그는 여러 개의 패키지로 이루어져 있다. 그리고 각각의 의존성이 엮여있다. 모노레포를 구성하다 보면 필연적으로 빌드 오케스트레이션1을 고려하게 된다. pnpm workspace 통해서도 빌드 오케스트레이션을 흉내 낼 수 있지만 Turborepo를 사용했다. Turborepo의 가장 큰 장점은 캐싱이다. 한번 작업(빌드,테스트)을 실행했다면 파일이 변경되지 않는 한 다시 한번 실행하지 않는다. 패키지 별로 캐시가 존재하게 된다. 또한 작업들은 의존되어있지 않다면 병렬로 실행된다. remote-cache를 이용하면 원격으로 캐시가 공유된다. 똑같은 코드에 대해서 이미 다른 사람이 빌드를 수행했다면 여러 작업자 그리고 CI에서도 빌드를 수행하지 않고 캐시를 가져온다. 많은 예제이 있어서 이미 모노레포에 익숙하다면 쉽게 적용할 수 있다.

마치며

블로그를 구성하면서 프론트의 전반적인 기술을 살펴봤다. 1년 전에는 잘 몰랐던 개념들인데 지금은 익숙해진 걸 보니 성장했다는 것을 느낀다. 특히 모노레포는 특히 더 모르는 분야였는데 블로그를 개발하면서 개념을 제대로 이해했다. 하지만 아직 나와 이 블로그는 부족한 점이 많다. 채워 넣어야 할 것들을 나열하면서 글을 마친다.

  • Sandpack을 사용한 live-editing 코드 에디터
  • SEO 최적화
  • github action을 통한 배포
  • 다크모드/라이트모드
  • 구글 애널리스틱 연동

Footnotes

  1. 오케스트레이션은 여러 부분이 조화롭게 동작하도록 조정하거나 통제하는 것을 의미한다. 오케스트라에서 악기를 효과적으로 연주하도록 지휘자가 지휘하는 것을 생각하면 쉽다. 소프트웨어 관련해서 이 용어가 많이 쓰이는데, 쿠버네티스는 컨테이너 오케스트레이션 Turborepo는 빌드 오케스트레이션이라고 불린다.