Hexo에서 Gatsby로 블로그 마이그레이션 야크쉐이빙 후기

    Hexo에서 Gatsby로 블로그 마이그레이션 야크쉐이빙 후기


    필자는 지난 황금 연휴 동안 기존에 Hexo를 사용하여 만들었던 블로그를 Gatsby로 마이그레이션했다. 이번 포스팅에서는 1주일 조금 넘게 마이그레이션을 진행하면서 새롭게 학습했던 것들에 대한 공유와 더불어 의외로 예상하지 못했던 이슈들에 대한 공유를 진행해볼까 한다.

    야크 쉐이빙의 시작

    사실 처음부터 Hexo를 버릴 생각은 아니었다. 물론 ejs 엔진 기반의 Hexo보다 리액트 기반의 Gatsby가 편한 건 사실이지만, 마이그레이션에 리소스를 쏟는 동안 포스팅을 작성하지 못할 뿐더러 굳이 그 정도 리소스를 소비할 만큼의 불편함도 딱히 느끼지 못하고 있었기 때문이다.

    게다가 블로그라는 매체는 글 자체가 주요 컨텐츠이기 때문에 여러가지 다양한 기능을 굳이 블로그에 붙힐 필요도 없다고 생각했다. 만약 필요한 기능이 있더라도 대부분 다른 사람들이 플러그인으로 먼저 구현해놓았기 때문에 필자는 그냥 그걸 가져다가 쓰기만 하면서 포스팅을 집필하는 것 자체에만 집중하고 싶었다.

    Gatsby가 편한건 알겠는데...굳이 마이그레이션까지 해야하나...?

    그러나 문제는 의외의 곳에서 발생했다. 사실 필자는 지금까지 작성한 포스팅을 영어로 번역해서 다시 작성하려는 계획을 가지고 있었는데, 원래 연휴가 시작하기 전에 블로그에 i18n 세팅을 해놓은 뒤 연휴 동안 여유있게 한 두개의 포스팅을 먼저 번역하려고 했었다.

    물론 Hexo는 자체적으로도 i18n 설정을 제공하고 있고, 필자가 사용하고 있던 테마인 Icarus 테마에서도 i18n 기능을 제공해주는 것으로 알고 있었기 때문에 몇 가지 설정만으로 간단하게 이 기능을 구현할 수 있을 것이라 생각했다.

    그리고 Hexo에서 제공하는 i18n 설정 가이드 문서에도 설정 파일에 언어 종류를 추가함으로써 여러 개의 언어를 사용할 수 있는 환경을 구축할 수 있다고 이야기하고 있기 때문에 그리 큰 작업이 아닐 것이라고 예상했었다.

    # _config.yml
    
    language:
      - ko
      - en # 새로운 언어 추가!
    
    # ...

    사실 Hexo의 가이드 문서대로라면 i18n 설정을 추가하는 것은 몇 분이면 끝날 수도 있는 굉장히 간단한 일이었지만…


    .
    .
    .
    .

    ERROR Render HTML failed: index.html
    TypeError: /Users/evan/dev/evan-blog/themes/icarus/layout/layout.ejs:2
        1| <!DOCTYPE html>
     >> 2| <html <%- has_config('language') ? ' lang="' + get_config('language').substring(0, 2) + '"' : '' %>>
        3| <head>
        4|     <%- partial('common/head') %>
        5| </head>
    chit 이건 또 뭐임...?

    마치 _config.yml 파일에 코드 한 줄 추가하면 해피엔딩일 것처럼 설명해놓았던 Hexo의 문서와 다르게 역시 현실은 녹록치 않았다.

    사실 이 에러가 발생하는 이유 자체는 간단명료하다. Hexo의 Config 파서는 한 프로퍼티에 여러 개의 값이 설정되는 경우 이 값들을 배열로 파싱해서 반환하는데, Icarus 테마는 language 프로퍼티가 반드시 String 타입으로 들어올 것이라고 가정하고 substring 메소드를 사용하고 있기 때문에 이런 에러가 발생하는 것이다.

    이 상황 속에서 필자가 선택할 수 있는 옵션은 대략 4가지 정도였다.


    1. Icarus 테마를 직접 고친다.
    2. i18n을 잘 지원하는 다른 Hexo 테마를 사용한다.
    3. 직접 Hexo 테마를 만든다.
    4. 그냥 Hexo를 버리고 자유로운 Gatsby로 갈아탄다.

    Hexo의 테마는 npm이나 yarn과 같은 패키지 관리자를 통해 설치하는 것이 아니라 직접 테마를 다운로드 받아 소스에 포함시키는 방식으로 적용되므로 1번 옵션과 같이 사용자가 직접 테마를 수정하는 일도 가능하다. 그리고 이전에도 비슷한 문제가 발생했을 때 몇 번 테마를 직접 수정했던 적도 있어서 이번에도 조금 수정해주면 해결될 것이라고 생각했다.

    그러나 language 프로퍼티가 배열로 변경되면서 발생하는 사이드 이펙트는 layout.ejs 파일 내에서만 발생하는 것이 아니라서 꽤 많은 부분을 손 대야 할 것 같았다.

    그렇다고 2번 옵션을 선택하자니 그것도 뭔가 찜찜하다. 이 상황의 근본적인 문제는 단지 Icarus 테마가 Multiple Language를 지원하지 않는다는 것이 아니라, 필자의 블로그가 다른 사람이 만든 테마의 제한적인 기능에 너무 의존하고 있다는 것이기 때문이다. 결국 해당 기능을 지원하는 다른 테마로 변경한다고 해도 비슷한 상황이 반복될 가능성이 높다.

    자, 그럼 직접 Hexo 테마를 만든다는 3번 옵션은 어떨까? 테마를 만든 김에 오픈소스로 배포도 하면 좋지 않을까?

    하지만 Hexo의 테마를 만드려면 ejs와 jQuery와 같은 올드한 기술을 적극 사용해야한다. 게다가 어차피 테마를 만드는 것이나 마이그레이션을 하는 것이나 필요한 리소스는 크게 다르지 않다. 오히려 ejs로 UI를 그리는 것보다 필자에게 익숙한 리액트 기반으로 되어있는 Gatsby를 사용하는 것이 더 이득이다.

    결국 이러한 이유들로 Hexo에서 Gatsby로 환승을 하기로 마음을 먹게 되었다.

    사실 Hexo에서 Gatsby로 건너가는 마이그레이션 자체는 크게 어렵지 않다. Gatsby의 공식 블로그에서도 Migrating My Blog From Hexo To Gatsby라는 문서를 제공하고 있는데다가 그 과정이 그렇게 어려운 것도 아니기 때문이다.

    그러나 필자의 블로그는 이미 운영 중인 서비스나 마찬가지이기 때문에, 단지 정적 페이지 생성기를 변경하는 것이 아니라 기존에 있는 기능을 전부 다시 구현해야하는 작업이 추가로 필요하다.

    만약 Hexo의 플러그인을 사용하여 편하게 사용하고 있던 기능이 Gatsby에서는 지원되지 않는다면 일일히 다 만들어줘야 하며, 테마를 사용하면서 신경쓰고 있지 않았던 CSS를 사용한 스타일도 반응형 디자인까지 전부 고려해서 직접 작성해야하는 일은 그렇게 가벼운 작업이 아니다.

    그리고 이 마이그레이션 작업을 진행하는 동안 블로그 포스팅을 작성하거나 다른 토이프로젝트를 진행하는 일을 병행하기는 어렵기 때문에, 최대한 빠른 시간 안에 집중해서 호다닥 끝내야한다고 생각했다.

    마침 4월 30일 석가탄신일부터 장장 6일 간에 걸친 황금 연휴가 시작되었고, 이 정도의 황금 연휴는 올해 안에 다시 오지 않기 때문에 이런 대작업을 진행하기에는 올해 마지막 기회라고 봐도 좋았다. 여기까지 생각이 닿은 필자는 바로 브랜치를 따고 Hexo에서 Gatsby로 환승하는 작업을 시작했다.

    yak 그렇게 야크 쉐이빙이 시작됐다...

    Gatsby에 대한 간단한 설명

    gatsby

    Gatsby is a free and open source framework based on React that helps developers build blazing fast websites and apps

    Gatsby Offical Site

    Gatsby는 리액트 기반의 정적 사이트 생성기이다.

    개인적으로 Gatsby의 최대 장점은 아마 리액트 기반이라는 것이 아닐까라는 생각을 하는데, 리액트는 이미 전 세계의 프론트엔드 개발자들에게 사랑받는 UI 라이브러리이기 때문에, Ruby를 사용하는 Jekyll이나 ejs를 학습해야하는 Hexo에 비해 학습 비용이 상대적으로 낮기 때문이다.

    그리고 다른 개발자 분들이 만들어 놓은 프로젝트 스타터를 사용하면 직접 보일러플레이트를 작성할 필요가 없기 때문에 빠르게 프로젝트를 세팅할 수도 있다. 필자는 gatsby-starter-blog라는 스타터를 사용해서 프로젝트를 세팅했다.

    $ gatsby new evan-blog https://github.com/gatsbyjs/gatsby-starter-blog

    필자가 사용한 스타터 외에도 국내외의 개발자 분들이 블로그용 스타터를 만들어서 깃허브에 올려놓은 경우가 많으므로 한번 쭉 흝어보고 마음에 드는 스타터를 사용하도록 하자.

    또 한 가지 특이한 점이 있다면 리액트 컴포넌트 내에서 데이터를 Fetch 해오기 위한 수단으로 GraphQL을 채택했다는 점인데, 아무래도 REST API에 익숙한 개발자들이 많기 때문에 처음에는 조금 어색할 수 있지만, 개념 자체가 그렇게 어려운 편은 아니기 때문에 막상 몇 번 쓰다보면 금방 적응할 수 있는 것 같다.

    GraphQL

    GraphQL(Graph Query Language)은 이름 그대로 새로운 개념의 질의 언어이다. 우리가 평소에 자주 사용하는 SQL(Structured Query Language)와 같이 어떤 시스템으로부터 데이터를 가져오기 위해 질의를 날리는 수단으로 이용된다는 것이다.

    SQL이 데이터베이스 시스템에 저장되어 있는 데이터를 가져오기 위해 데이터베이스에게 질의를 날리기 위한 용도로 사용된다면, GraphQL은 클라이언트가 서버에게 데이터를 보내달라고 질의하는 목적으로 사용된다.

    SELECT frontmatters, title, thumbnail FROM post WHERE post_id = 1;
    {
      post(fields: {
        id: { eq: 1 }
      }) {
        frontmatters
        title
        thumbnail
      }
    }

    기존의 REST API를 사용할 때의 클라이언트는 단지 서버가 제공해주는 엔드포인트로 요청을 보내고 미리 정해진 구조의 응답을 받아서 데이터를 사용했지만, GraphQL을 사용하는 클라이언트는 능동적으로 자신이 필요한 데이터를 선택하여 서버에게 질의할 수 있다는 장점을 가진다.

    그러나 클라이언트가 원하는 스키마의 쿼리를 맘대로 날릴 수 있다는 장점은 반대로 사용자에게 제어권을 넘겨준다는 클라이언트 프로그램의 특성을 생각하면 보안 측면에서의 단점도 될 수 있고, 백엔드 개발자에 비해 상대적으로 쿼리에 미숙한 클라이언트 개발자가 슬로우 쿼리를 발생시킬 수도 있다.

    그렇기 때문에 GraphQL의 백엔드 시스템은 반드시 이러한 상황에 대한 조치를 적절하게 취해줘야하고, GraphQL의 이러한 단점들은 REST API에 비해 안정성이 떨어진다고 판단될 수 있기 때문에 아직 대규모 프로젝트에서 사용되는 사례는 드문 것 같다. (실제로 이런 방식에 대한 개발자들의 호불호도 꽤나 갈린다)

    그러나 Gatsby는 정적 사이트 생성기이기 때문에 GraphQL을 사용하여 쿼리를 날리는 상황 자체가 런타임이 아닌 컴파일타임만으로 제한되어 있고, 슬로우 쿼리가 발생된다해도 사용자에게 실제로 피해가 가는 것이 아닌 빌드가 조금 더 느려진다 뿐이므로 이러한 단점들이 상대적으로 상쇄되는 환경이라고 볼 수 있다.

    GraphQL을 직접 사용해보자

    Gatsby는 로컬 환경에서 자유롭게 쿼리를 날려볼 수 있는 페이지를 별도로 제공해주고 있다. 이 페이지가 Swagger같이 일종의 API 문서 역할을 하는 것이다. gatsby develop 명령어를 이용하여 로컬 서버를 실행하고 localhost:8000/__graphql로 접속하면 Gatsby가 제공하는 쿼리 테스트 페이지를 만나볼 수 있다.

    query test page

    필자 역시 GraphQL을 사용해본 경험이 별로 없었기 때문에 처음에는 감을 잘 못 잡고 있었는데 이 페이지에서 30분 정도 이것저것 만져보다 보니까 대충 어떤 식으로 쿼리를 날려야하는지 감을 잡을 수 있었다.

    필자가 선택한 스타터인 gatsby-starter-blog는 기본적으로 마크다운을 사용한 블로그 작성에 필요한 Gatsby 플러그인들을 포함하고 있기 때문에 마크다운 파일을 파싱하는 markdownRemark나 이미지 리소스를 파싱하는 imageSharp와 같은 쿼리 타입들이 추가되어있지만, 만약 이 친구들이 보이지 않는다면 gatsby-transformer-remark와 같은 플러그인을 직접 설치해서 추가해줘야한다.

    이렇게 __graphql 페이지에서 이것저것 만져보면서 대충 Gatsby의 GraphQL 인터페이스에 대한 감을 잡았다면, 리액트 컴포넌트 내에서 useStaticQuery 훅을 사용하여 쿼리를 직접 날리면 된다.

    import { useStaticQuery, graphql } from 'gatsby';
    
    const data = useStaticQuery(graphql`
      query MyQuery {
        markdownRemark(frontmatter: {title: {eq: "테스트 포스트"}}) {
          frontmatter {
            title
            date
          }
        }
      }
    `);
    
    console.log(data);
    {
      markdownRemark: {
        frontmatter: {
          title: "테스트 포스트",
          date: "2020-05-09T12:57:08.000Z"
        }
      }
    }

    방금 필자가 보인 예시의 useStaticQuery 훅은 이름 그대로 정적인 쿼리만을 제공하기 때문에 위 쿼리는 단지 frontmatter.title이 정확히 테스트 포스트인 데이터를 가져올 뿐이다. 만약 억지로 graphql 함수의 인자로 변수가 포함된 템플릿 스트링을 넘기려고 한다면 다음과 같은 에러를 만나게 된다.

    Error: It appears like Gatsby is misconfigured.
    Gatsby related `graphql` calls are supposed to only be evaluated at compile time, and then compiled away.
    Unfortunately, something went wrong and the query was left in the compiled code.

    Gatsby에서 사용하는 graphql 호출은 오직 컴파일 타임에만 평가됨.
    님이 쿼리를 잘못써서 쿼리가 평가가 안됐음.

    즉, 사용자가 쿼리로 사용하는 템플릿 스트링에 변수를 포함시킨다고 해도 이 변수는 “런타임” 때 평가되기 때문에 Gatsby가 코드를 컴파일할 때는 이 변수에 어떤 값이 들어올 지 알 수가 없다는 것이다.

    하지만 블로그 포스팅 페이지를 만드려면 반드시 /posts/2 같이 URL을 통해 동적인 파라미터를 받고, 이 파라미터를 사용하여 “2번 포스트를 가져와!”와 같은 쿼리를 날릴 수 있어야한다. 이럴 때 사용하는 것이 바로 pageQuery이다.

    동적 파라미터를 받는 페이지 만들기

    블로그의 가장 기본적인 기능은 동일한 UI 템플릿에 다른 내용이 표현되어야 한다는 것이다.

    일반적으로 이러한 기능을 구현해야하는 경우, 런타임 때 클라이언트가 서버의 HTTP API를 사용하여 데이터를 받아온 후 렌더하거나, SEO가 중요한 페이지일 경우 SSR(Server Side Rendering) 서버가 데이터를 받아온 뒤 렌더된 페이지를 클라이언트에게 응답으로 보내주는 방식으로 구현한다.

    하지만 Gatsby는 정적 사이트 생성기이기 때문에 어플리케이션이 작동하고 있는 런타임에 데이터를 동적으로 받아와서 가공하는 방식이 아니라, 컴파일 타임에 GraphQL을 사용하여 데이터를 받아온 뒤 실제 페이지 파일을 생성해야한다.

    이게 어떤 말인지 잘 이해가 가지 않는다면 public 디렉토리 내부의 파일을 한번 살펴보도록 하자.

    $ cd evan-blog/public/page-data && ll
    
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 2017
    drwxr-xr-x   5 evan  1525943656   160B  5 10 14:34 2018
    drwxr-xr-x  10 evan  1525943656   320B  5 10 14:34 2019
    drwxr-xr-x   7 evan  1525943656   224B  5 10 14:34 2020
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 404
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 404.html
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 about
    drwxr-xr-x  19 evan  1525943656   608B  5 10 14:34 categories
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 dev-404-page
    drwxr-xr-x   3 evan  1525943656    96B  5 10 14:34 index
    drwxr-xr-x   6 evan  1525943656   192B  5 10 14:34 page

    필자의 블로그 포스트 경로는 /2020/05/09/blog-title/과 같은 포맷을 가지고 있기 때문에 Gatsby는 필자의 블로그를 컴파일할 때 실제로 public/page-data 디렉토리 내부에 2020, 05, 09, blog-title이라는 디렉토리를 생성하고 그 안에 page-data.json이라는 파일을 생성하며, 이 안에는 렌더할 HTML을 문자열로 가지고 있다.

    즉, 런타임에 동적으로 데이터를 받아와서 페이지의 내용을 렌더하는 것이 아니라, 컴파일을 할 때 하나의 포스팅 당 하나의 페이지 파일을 실제로 생성해야한다는 것이다. 이 차이점을 잘 파악한다면 정적 사이트 생성기를 조금 더 편하게 다룰 수 있다.

    createPages API

    Gatsby는 컴파일 타임 때 사용자가 입맛에 맞는 페이지를 생성할 수 있도록 createPages라는 API를 제공한다. 이 API는 gatsby-node.js 파일에서 모듈로 export하는 방식으로 사용할 수 있다.

    // gatsby-node.js
    const path = require('path');
    
    exports.createPages = async ({ graphql, actions }) => {
      // ...
    }

    첫 번째 인자인 graphql은 리액트 컴포넌트 내부에서 GraphQL을 사용할 때 호출하는 함수와 동일한 녀석이고, 두 번째 인자인 actions는 우리가 Flux 아키텍처에서 이야기하는 그 액션과 동일한 녀석이다.

    Gatsby는 내부적으로 리덕스(Redux)를 사용하여 상태 관리를 하고 있고, Gatsby의 모든 API는 인자로 이러한 액션 목록을 받고 있기 때문에 우리는 자유롭게 이 액션들을 사용하여 Gatsby에게 명령을 내릴 수 있다. 그 중 createPage라는 액션이 바로 페이지를 생성하라는 명령을 내릴 수 있는 액션이다.

    자, 그럼 대략 이런 플로우를 생각해볼 수 있겠다.

    1. GraphQL로 작성된 포스팅을 싹 다 가져온다
    2. 포스팅 데이터를 순회하면서 createPage 액션을 디스패치해서 페이지를 하나씩 생성한다.
    3. Profit!

    글로만 봐도 이미 심플해보이지만, 막상 해보면 실제로는 별로 고민할 필요도 없을 정도로 더 심플하다. 그럼 포스팅 페이지를 한번 생성해보도록 하자.

    // gatsby-node.js
    const path = require('path');
    
    exports.createPages = async ({ graphql, actions }) => {
      const { createPage } = actions;
      const template = path.resolve('./src/templates/Post.tsx');
    
      // 1. 모든 포스팅 데이터를 긁어온다
      const allPostsQuery = await graphql(`
        allMarkdownRemark {
            edges {
              fields {
                path
              }
              node {
                id
              }
            }
          }
      `);
    
      // 2. 포스팅 데이터를 순회하며 페이지 파일을 생성한다.
      const { edges: posts } = allPostsQuery.data.allMarkdownRemark;
      posts.forEach(({ node }, index) => {
        createPage({
          path: node.fields.path,
          component: template,
          context: {
            postId: node.id,
          },
        });
      })
    }

    이렇게 createPages API를 사용하여 페이지 파일 생성 로직을 작성해주게 되면 나머지 귀찮은 작업은 Gatsby가 알아서 해주기 때문에 굉장히 편리하다.

    하지만 createPage 함수는 단지 페이지 파일을 생성할 뿐이고, Page.tsx 컴포넌트에게 포스팅 데이터를 넘겨주는 것은 아니다. 대신 우리는 createPage 함수의 context 옵션을 사용하여 원하는 데이터를 템플릿 컴포넌트에게 넘겨줄 수 있고, 템플릿 컴포넌트 내에서 이 컨텍스트 데이터를 이용하여 다시 포스팅 데이터를 가져오는 과정이 필요하다.

    // Post.tsx
    
    export const pageQuery = graphql`
      query PostQuery($postId: String!) {
        markdownRemark(id: {eq: $postId}) {
          fields {
            path
          }
          frontmatter {
            title
            date
          }
        }
      }
    `;
    
    interface Props {
      data: any;
      pageContext: {
        postId: string;
      }
    }
    const PostTemplate = ({ data, pageContext }: Props) => {
      const { frontmatter } = data.markdownRemark;
      // ...
    }

    우리가 createPagecontext 옵션을 통해 컴포넌트로 넘긴 변수는 컴포넌트 파일 내부의 pageQuery 모듈에게 주입된다.

    필자가 작성한 쿼리를 보면 query PostQuery($postId: String!) {}과 같이 쿼리의 인자로 postId라는 변수를 받은 후에 markdownRemark 쿼리 타입에 사용하고 있는 모습을 볼 수 있다. 이런 과정을 통해 우리는 동적인 값을 사용하여 데이터를 가져올 수 있는 페이지를 생성할 수 있는 것이다.

    이 외에도 createRedirect, createNode 등 다양한 액션들을 지원하고 있고, 이런 API와 액션들을 조합하여 재미있는 기능을 만들 수도 있지만, 블로그 같은 어플리케이션에 필요한 페이지네이션이나 카테고리, 태그 페이지와 같은 간단한 기능은 대부분 createPage 액션만으로도 충분히 구현 가능하다.

    의외로 귀찮았던 부분

    이렇게 Gatsby에 대한 기본적인 학습을 하고난 후 기존에 Hexo로 구현되어있던 기능들과 Icarus 테마에서 제공하던 스타일을 전부 직접 구현해야하는 작업을 하게 되었는데, 이 마이그레이션 작업을 하는 동안 대부분의 시간을 여기에 쏟았다고 해도 과언이 아닐 정도로 지루한 작업의 연속이었다.

    물론 모든 기능을 다 구현하면 시간이 너무 오래 걸릴 수도 있으니 기존 기능들 중에서 사람들이 자주 사용하거나, SEO에 영향을 줄 것 같은 기능을 우선 구현하는 것을 목표로 잡기는 했지만 그래도 코드의 양이 많은 것은 변하지 않는 사실이라 인내심을 가지고 하루에 조금씩이라도 꾸준히 작업하는 것을 목표로 했다.

    기존 링크와의 연결을 신경써줘야한다

    어떤 웹 서비스를 마이그레이션할 때 주의해야 할 것 중 하나는 바로 기존 페이지로 연결되는 링크가 죽으면 안된다는 것이다. 예를 들어 마이그레이션 전의 /posts/1이라는 URL이 1번 포스팅을 볼 수 있는 페이지로 연결되는 링크였다면, 이 규칙은 반드시 마이그레이션 후에도 동일해야한다.

    만약 이 규칙이 깨지게 되는 경우, 해당 링크로 접속하는 사용자는 404 상태를 보게 될 것이기 때문이다. 필자의 블로그 포스팅의 경우 이미 다양한 채널들을 통해 공유가 되었고, 또 다른 블로그에 참조 포스팅으로 링킹되어있는 경우도 많기 때문에, 필자는 이 부분에 대해서 신경을 쓸 수 밖에 없었다.

    whoami 기존 링크를 제대로 처리해주지 않는다면 유저는 404 페이지를 보게 된다.

    또한 SEO 측면에서도 기존 포스팅 링크에서 404가 떨어진다면 검색엔진 봇은 해당 페이지가 삭제된 것으로 인식하기 때문에 검색 결과에서 해당 포스팅을 내리게 될 것이다. 혹여나 불가피하게 기존의 링크를 다른 링크로 변경해야하는 상황이라고 한다면 반드시 명시적으로 301 상태코드를 응답으로 반환하여 /posts/1이라는 링크가 어떤 링크로 변경되었는지를 검색엔진 봇에게 알려야한다.

    필자가 기존에 사용하던 Hexo는 yyyymmdd-{postTitle} 형식의 제목을 가진 포스트를 자동으로 /yyyy/mm/dd/{postTitle}/ 형식의 URL로 매핑해주는 기능을 가지고 있었지만, Gatsby에는 딱히 이런 기능이 없다.

    물론 gatsby-starter-blog 스타터는 기본적으로 각 포스트 마크다운 파일의 경로를 가져와서 그대로 URL로 사용하는 코드를 보일러플레이트로 제공하지만, 이 방식은 기존 Hexo 블로그의 URL 생성 방식과 다른 방식이기 때문에 포스트들이 이전과 동일한 URL을 가질 수 있도록 별도의 작업이 필요했다.

    이를 위해 페이지 파일을 컴파일할 때 각 포스트의 파일 경로를 토대로 정규식을 사용하여 URL을 만들어 낸 후 포스트 노드 필드에 path라는 키로 삽입하는 방식을 사용했다.

    // gatsby-node.js
    
    exports.onCreateNode = ({ node, actions, getNode }) => {
      const { createNodeField } = actions;
    
      if (node.internal.type === `MarkdownRemark`) {
        const filePath = createFilePath({ node, getNode });
        const path = filePath.replace(
          /(?<=\/)(\d{4})(\d{2})(\d{2})(-)(?=.+)/,
          '$1/$2/$3/'
        );
        createNodeField({
          name: `slug`,
          node,
          value: filePath,
        });
        createNodeField({
          name: `path`,
          node,
          value: path,
        });
      }
    };

    이렇게 createNodeField 액션을 통해 생성한 노드의 필드는 fields라는 프로퍼티에 삽입되며, 쿼리를 사용할 때 요런 느낌으로 접근할 수 있다.

    const data = graphql`
      query PostQuery {
        markdownRemark {
          fields {
            path
          }
        }
      }
    `;

    이렇게 기존 블로그와 동일한 링크를 가지도록 매핑하는 일은 크게 어렵거나 복잡한 일은 아니었지만, 단 한 개의 링크라도 망가지지 않도록 신경써서 작업해야하는 일이었고, 제대로 매핑되었는지 일일히 테스트도 해야했기 때문에 귀찮음 1순위였던 작업이었다.

    내가 직접 다 만들어야한다

    기존에 Hexo를 사용할 때는 테마에서 제공해주는 기능을 가져다 사용하거나, 플러그인을 적극적으로 활용했었기 때문에 필자가 직접 개발을 해야하는 경우가 거의 없었다. 그러나 애초에 이 마이그레이션을 진행하게 된 계기가 테마나 플러그인에 너무 의존했기 때문이었으니, 이왕 Gatsby를 사용할 거라면 되도록 기능을 직접 구현하는 방향을 선택했다.

    예를 들면 관련 포스팅 섹션 같은 경우에는 기존에 hexo-related-popular-posts 플러그인을 사용하여 렌더하고 있었지만, Gatsby 진영에는 딱히 쓸만한 녀석이 없는 것 같아서 요런 느낌으로 직접 구현했다.

    import React, { useMemo } from 'react';
    import intersection from 'lodash/intersection';
    import { useAllPosts } from 'src/hooks/useAllPosts';
    
    const RelatedPosts = ({ tags, currentPost }) => {
      const posts = useAllPosts();
      const relatedPosts = useMemo(() => {
        return posts
          // 현재 포스트는 걸러낸다
          .filter(post => post.frontmatter.title !== currentPost)
          // 현재 포스트의 태그와 다른 포스트의 태그 중 겹치는 부분을 찾는다
          .map(post => ({
            post,
            tags: intersection(post.frontmatter.tags ?? [], tags),
          }))
          // 겹치는 태그가 없는 포스트는 걸러낸다
          .filter(({ tags }) => tags.length > 0)
          // 겹치는 태그가 많은 순으로 내림차순 정렬한다
          .sort((a, b) => b.tags.length - a.tags.length)
          .map(v => v.post)
          // 가장 겹치는 부분이 많은 최대 5개 포스트만 걸러낸다
          .slice(0, MAX_COUNT);
      }, [currentPost, posts, tags]);
    
      return (
        // ...
      )
    }

    사실 관련 포스팅이라고 해봤자 단순히 현재 포스팅과 똑같은 태그를 가장 많이 가지고 있는 포스팅들을 솎아내는 것이기 때문에 별로 복잡할 것도 없었다.

    물론 일일히 이런 걸 다 구현해야한다는 것이 번거롭기는 하지만, 반대로 생각하면 필자가 원하는 기능을 구현할 수 있는 자유도도 높아졌다는 이야기므로 이 정도 번거로움은 감수하는 것이 맞는 듯 하다.

    CLI에 포스팅 생성 기능이 없다

    애초에 Gatsby는 마크다운 블로그 전용이 아니라 범용적인 정적 사이트 생성기이기 때문에 CLI를 통한 포스팅 생성 기능 같은 것은 제공해주지 않는다.

    그렇다고 매번 디렉토리 생성하고 디렉토리 이름 바꿔주고 포스팅 파일 만들고 하는 작업을 손으로 할 수는 없으니 아주 간단한 스크립트를 하나 작성해서 사용하기로 했다. 포스팅 파일을 생성할 때마다 일일히 문자열로 기본 템플릿을 만들어 줄 수도 있지만, 조금 더 간단하게 처리할 수 있는 방법은 바로 예제 파일을 사용하는 것이다.

    <!-- example.md -->
    
    ---
    
    title: $title
    date: $date
    tags:
    categories:
    thumbnail:
    
    ---

    이렇게 예제 파일을 하나 만들어 놓고 새로운 포스팅 파일을 생성할 때 이 예제 파일을 불러와서 $title$date만 적당한 문자열로 변경해주면 간단하게 새로운 포스팅 파일을 생성할 수 있다.

    이후 readline API를 사용하여 런타임 중에 시스템 입력을 통해 포스팅의 제목을 입력받고, 포스팅 생성 시간을 이 마크다운 파일에 기록해주면 된다.

    $ yarn new-post
    
    yarn run v1.22.0
    $ node ./scripts/newPost.js
    포스팅의 제목을 입력해주세요. 이 제목이 포스팅의 URL이 되기 때문에 공백없는 영어로 작성해야합니다.
     =>

    물론 카테고리나 태그도 포스팅 생성 시에 입력받게 만들 수도 있지만, 필자는 포스팅을 작성하는 중에 카테고리나 태그를 바꾸는 일이 워낙 빈번한지라 딱히 효용성이 없다고 느껴져서 딱히 관련 기능을 추가하지는 않았다. (사실 귀찮은 게 더 크다…)

    필자 뿐만 아니라 다른 분들도 직접 스크립트를 작성해서 이런 작업을 수행하고 있지만, 필자의 심플한 스크립트와 다르게 다른 분들이 만든 스크립트는 번쩍번쩍 화려하고 기능도 좋은 것들이 많으니 한번 찾아보도록 하자.

    마치며

    처음 블로그 마이그레이션 작업을 시작할 때는 한 달 정도 걸릴 것이라고 생각했는데, 황금 연휴의 힘인지 의외로 작업이 빨리 끝났다.

    물론 이전에 비해서 디자인도 약간 투박한 느낌이고 버그도 존재하는 구멍이 숭숭 뚫려있는 어플리케이션으로 퇴보하기는 했지만, 그래도 처음 목표였던 “맘대로 기능을 붙힐 수 있는 어플리케이션”으로 진화했기에 일단은 만족스럽다.

    그리고 이번 마이그레이션을 진행하면서 원래 붙어있던 구글 애드센스도 그냥 없애버렸다. 광고로 들어오는 수입이 워낙 작고 귀여운 수준이기도 하지만, 그것보다는 광고로 인해 필자의 블로그를 찾아주시는 독자 분들에게 피로감을 줄 수도 있다는 판단이 들었기 때문이다. (돈은 주식과 부동산으로…읍읍)

    어쨌든 시작은 i18n 기능을 블로그에 붙히는 것이었는데 갑자기 마이그레이션을 하게 되버려서 뭔가 야크 쉐이빙 냄새가 조금 나기는 하지만 결과적으로 Hexo를 버림으로써 이런저런 기능적인 제약에서 벗어나게 되었으니, 앞으로는 포스팅 집필을 하면서도 심심할 때마다 조금씩 블로그에 재미있는 기능도 붙혀볼 예정이다.

    이상으로 Hexo에서 Gatsby로 블로그 마이그레이션 야크쉐이빙 후기 포스팅을 마친다.

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.