imch.devabout

TinaCMS로 편집 가능한 홈페이지 만들기

최근에 회사에서 홈페이지를 만들어 달라는 업무가 할당됐다. 지금까지는 워드프레스 기반으로 만들고 있었는데, 워드프레스 특유의 (쉬운 사용과는 다른)복잡성과 플러그인끼리 교착 상태가 발생하면서 더 이상 워드프레스로는 유지하기 어렵다는 판단을 내린 것이다.

처음에 워드프레스를 택한 이유는 비개발자도 편집할 수 있다는 장점이 있었는데, Elementor 플러그인이 있기 때문이다. 나는 워드프레스로 홈페이지를 만들어본 적은 없지만, 이 플러그인을 보고 신기하다는 생각을 했다.

그리고 Gatsby로 만든 사이트에서 Elementor 같은 역할을 할 수 있는 TinaCMS가 있다. 이걸 사용하면, 개발 단계에서 Elementor 처럼 좌측에 사이드바가 표시되고, 입력한 내용이 바로 반영된다. TinaCMS는 Git-Backend라서 내용을 변경하고 저장을 누르면 변경 사항이 바로 커밋되고 푸시가 된다.

그래서 내린 나의 가설은 TinaCMS를 사용해서 Gatsby로 만들었지만 마치 워드프레스로 편집하는 것 같은 홈페이지를 만드는 것이다. 그리고 CD를 붙여서 푸시가 될 때 마다 배포가 되면, 꽤 만족스러운 결과물이 나올 것 같다.

이 글에서는 최소한의 Gatsby 프로젝트를 만들고, TinaCMS를 붙이는 삽질을 기록한다. 콘셉트나 작동 원리에 대한 설명은 공식 문서를 참고하자.


0. 프로젝트 준비

Gatsby 프로젝트를 만든다. 타입스크립트와 Emotion을 사용 할 것이기 때문에, 필요한 최소한의 설정을 적용했다. 궁금하시다면 해당 커밋을 참고하시라.

1. TinaCMS 플러그인 설치

TinaCMS 플러그인을 설치한 다음, gatsby-config.js 파일에 플러그인을 추가한다. 이때 styled-components를 설치하지 않으면 오류가 발생한다. TinaCMS가 내부적으로 사용하기 때문이다.

$ yarn add gatsby-plugin-tinacms styled-components
// ...
{
  resolve: 'gatsby-plugin-tinacms',
  options: {
    plugins: [],
  },
},
// ...

이후 프로젝트를 다시 실행하면 아래와 같이 사이드바가 추가된 것을 볼 수 있다.

2. Git-Backend 플러그인 설치

위에서 Git-Backend 라고 소개해서, 마치 TinaCMS만 추가하면 다 되는 것 처럼 이야기 했지만, 관련된 설정은 아직 할게 많다. 아직 폼도 추가를 못했지만, 변경 사항을 저장하면 바로 커밋될 수 있도록 Git-Backend 플러그인을 설치한다.

$ yarn add gatsby-tinacms-git

아까 gatsby-config.js에 추가했던 TinaCMS 플러그인 쪽 plugins 에 추가한다.

// ...
{
  resolve: 'gatsby-plugin-tinacms',
  options: {
    plugins: ['gatsby-tinacms-git'],
  },
},
// ...

3. JSON 플러그인 설치

TinaCMS는 현재 데이터를 관리하는 방법으로 마크다운과 JSON 두가지를 제공한다. 둘 중 하나를 고른다기 보다, 어떤 데이터를 다룰 것인지에 따라 다르겠다. 예를 들어 사이트 전역에 대한 설정이라면 JSON이 맞고, 보도 자료나 블로그를 포함한다면 마크다운이 알맞지 않을까? 일단 JSON 데이터부터 다뤄보자.

$ yarn add gatsby-source-filesystem gatsby-transformer-json gatsby-tinacms-json

그 다음 gatsby-config.js에 내용을 추가한다. 이번에는 TinaCMS 플러그인에 추가하지 않고, Gatsby 플러그인으로 추가하는 것에 주의한다.

[
  // ...
  'gatsby-tinacms-json',
  {
    resolve: `gatsby-source-filesystem`,
    options: {
      name: `data`,
      path: `${__dirname}/src/data`,
    },
  },
  'gatsby-transformer-json',
];

이렇게 하면 /src/data 폴더에 있는 JSON 파일에 해당하는 노드가 생성되어 GraphQL로 조회할 수 있게 된다.

4. JSON 폼 만들기

JSON 폼을 만들 준비가 되었으니 만들어보자. 목표는 간단하게 첫 화면의 Hi People을 마음대로 바꾸는 것이다. Gatsby가 일단 노드를 만들어야 하기 때문에, /src/data에 index.json 파일을 생성한다.

{
  "path": "/",
  "title": "Hi People"
}

이 다음, index.tsx 페이지에서 만들어진 JSON 파일 데이터를 가져오고, gatsby-tinacms-json에서 제공하는 useLocalJsonForm 훅을 사용한다. 이 훅을 사용하기 위해서는, dataJson 노드에 있는 fileRelativePath와 rawJson 항목이 필수로 있어야 한다.

const IndexPage = () => {
  const data = useStaticQuery(graphql`
    query($slug: String) {
      dataJson(path: { eq: $slug }) {
        title
        fileRelativePath
        rawJson
      }
    }
  `);

  const [formData] = useLocalJsonForm(data.dataJson);

  return (
    <Layout>
      <SEO title="Home" />
      <h1>{formData.title}</h1>
      <p>Welcome to your new Gatsby site.</p>
      <p>Now go build something great.</p>
    </Layout>
  );
};

이렇게 한 다음 프로젝트를 다시 실행하면 사이드바에 사진과 같이 편집할 수 있는 상태가 되었음을 확인할 수 있다.

useLocalJsonForm의 두번째 인자는 옵션이다. 이 옵션을 사용하지 않아도 사진처럼 사용할 수 있지만,

  • 다양한 필드를 사용할 수 없다. 오로지 text로 고정된다.
  • 필드가 표시되는 순서를 보장할 수 없다.
  • rawJson.path 처럼 노출되지 말아야 할 필드도 표시된다. 이걸 변경 해 버리면 작동하지 않는다.
  • rawJson.title 처럼 불친절한, 개발자 친화적인 문구가 표시된다.

따라서 옵션을 사용하는 것이 바람직하다. 두번째 인자로 옵션을 추가했다.

// ...
const [formData] = useLocalJsonForm(data.dataJson, {
  fields: [
    {
      label: '타이틀',
      description: '기존 Hi People 위치에 표시됩니다.',
      name: 'rawJson.title',
      component: 'text',
    },
  ],
});
// ...

그러면 아래와 같이 나타난다. 아주 보기 좋다.

5. 마크다운 플러그인 설치

JSON 폼을 만들어 봤으니, 그 안에서 사용할 수 있는 마크다운 또한 사용 해 보자. Gatsby 내에서 마크다운을 읽기 위한 플러그인과 TinaCMS 마크다운 플러그인을 설치하고,

$ yarn add gatsby-tinacms-remark gatsby-transformer-remark

gatsby-config.js에 추가한다.

// ...
{
  resolve: 'gatsby-plugin-tinacms',
  options: {
    plugins: ['gatsby-tinacms-git', 'gatsby-tinacms-remark'],
  },
},
// ...
'gatsby-transformer-remark'
// ...

6. JSON 데이터 내의 마크다운 처리하기

마크다운 플러그인을 설치했으니 description 이라는 필드에 마크다운을 입력할 수 있도록 해보자. 아까 만들었던 폼에 필드를 하나 추가한다.

const [formData] = useLocalJsonForm(data.dataJson, {
  fields: [
    {
      label: '타이틀',
      description: '기존 Hi People 위치에 표시됩니다.',
      name: 'rawJson.title',
      component: 'text',
    },
    {
      label: '설명',
      description: '본문에 표시됩니다.',
      name: 'rawJson.description',
      component: 'markdown',
    },
  ],
});

그러면 사진과 같이 마크다운을 입력할 수 있는 폼이 나온다.

그런데 문제는 HTML로 변환된 내용이 아닌 원본 마크다운 내용이 표시된다. 그러니 dangerouslySetInnerHTML를 사용해서 표시할 수 있는게 없는 상황이다. TinaCMS 공식 문서 내에도 이걸 처리하는 내용은 없다. 다만 Tina Grande 라는 샘플(스타터)를 제공하고 있고, 여기에는 내가 원하는 대로 마크다운이 내용으로 처리되는 것을 볼 수 있다. 지금부터 설명하는 내용은 위 프로젝트의 코드를 읽고 메모에 가깝게 설명했다.

수정 해야 하는 것은 Gatsby API 중 onCreateNode 를 수정해야 한다. Tina Grande를 보면, 특정 타입의 쿼리인 경우:

// Check for the correct type to only affect this
if (node.internal.type === `PagesJson`) {
  // ...
}

해당 노드의 content를 확인하고, 존재할 경우 해당 노드를 기준으로 새로운 마크다운 노드를 생성한 뒤, 기존 노드의 하위 필드로 markdownContent 를 연결하는 것을 볼 수 있다. (라고 적긴 했는데 무슨 말인지 나도 모르겠다.)

// transform markdown in node.content
if (node.content) {
  const textNode = {
    id: createNodeId(`${node.id} markdown field`),
    children: [],
    parent: node.id,
    internal: {
      content: node.content,
      mediaType: `text/markdown`, // Important!
      contentDigest: createContentDigest(node.content),
      type: `${node.internal.type}Markdown`,
    },
  };

  createNode(textNode);

  // Add link to the new node
  createNodeField({
    node,
    name: `markdownContent___NODE`, // Before the ___NODE: Name of the new fields
    value: textNode.id, // Connects both nodes
  });
}

일단 따라서 나도 해 보기로 했다. gatsby-node.js에 onCreateNode API를 아래와 같이 작성했다.

exports.onCreateNode = ({
  node,
  actions,
  createNodeId,
  createContentDigest,
}) => {
  const { createNode, createNodeField, createParentChildLink } = actions;

  if (node.internal.type === '?') {
  }
};

그런데 Tina Grande는 어떻게 PagesJson 이라는 타입으로 체크를 할 수 있었을까? 코드를 아무리 샅샅히 뒤져도 따로 PagesJson 을 선언하는 곳이 없었다. 해답은 폴더 구조에 있었다. Tina Grande는 내가 했던 것 처럼 /src/data에 두지 않고, /data에 모아뒀는데, 중요한 것은 여기에 또 폴더별로 나뉘어져 있다.

그 중에 pages 라는 폴더에 페이지별 JSON이 모여있다. 그리고 이 폴더의 JSON 파일들은 PagesJson 이라는 노드로 분류가 된다. 이는 gatsby-transformer-json 플러그인의 기능이다.

그래서 그렇게 폴더 구조를 변경했다. 기존 /src/data 폴더에 있던 index.json을 /data/pages/index.json으로 이동했다. 그리고 아래와 같이 onCreateNode API를 완성했다. 완성이라기 보다 Tina Grande 의 내용을 가져왔다.

exports.onCreateNode = ({
  node,
  actions,
  createNodeId,
  createContentDigest,
}) => {
  const { createNode, createNodeField } = actions;

  if (node.internal.type === 'PagesJson') {
    node.description = node.description || '';

    const textNode = {
      id: createNodeId(`${node.id} markdown field`),
      children: [],
      parent: node.id,
      internal: {
        content: node.description,
        mediaType: `text/markdown`, // Important!
        contentDigest: createContentDigest(node.description),
        type: `${node.internal.type}Markdown`,
      },
    };

    createNode(textNode);

    // Add link to the new node
    createNodeField({
      node,
      name: `markdownContent___NODE`, // Before the ___NODE: Name of the new fields
      value: textNode.id, // Connects both nodes
    });
  }
};

이제 변환된 description 내용을 가져와서 표시하자.

const IndexPage = () => {
  const data = useStaticQuery(graphql`
    query($slug: String) {
      pagesJson(path: { eq: $slug }) {
        title
        fileRelativePath
        rawJson
        fields {
          markdownContent {
            childMarkdownRemark {
              html
            }
          }
        }
      }
    }
  `);

  // ...

  return (
    <Layout>
      <SEO title="Home" />
      <h1>{formData.title}</h1>
      <div
        dangerouslySetInnerHTML={{
          __html: formData.fields.markdownContent.childMarkdownRemark.html,
        }}
      />
      <p>Welcome to your new Gatsby site.</p>
      <p>Now go build something great.</p>
    </Layout>
  );
};

그러면 아래와 같이 표시되는 것을 볼 수 있다.


마무리

TinaCMS 삽질기는 대충 이렇게 마무리 됐다. 글로 소개한 기능이 끝이 아니다. 전역 데이터 값을 변경하는 useGlobalJsonFrom도 안 써봤지만 크게 다르지 않다고 생각되어 스킵했다. 여기에 Gatsby의 강력한 동적 페이지 생성 기능과 TinaCMS의 기능을 접목하면 한층 더 강력한 사이트를 만들 수 있다. 특히, 블럭의 개념이 있는데, 페이지 내용의 순서를 변경하거나 하는 등의 작업이 가능하다. 블럭은 따로 글을 써야할 정도의 분량이 나올 것 같다.

다만 아쉬운 점도 있다. 나온지 얼마 되지 않아서 TinaCMS 자체가 성숙하지 않은 점. 공식 문서가 완전하지 못한 점. (실제로 이 글을 쓰면서 공식 문서와 Tina Grande를 같이 읽어야 했다.) 그리고 폼 내용을 변경하면 fatal: Path ‘data/pages/index.json’ exists on disk, but not in ‘HEAD’. 오류가 발생한다.

프로덕션에 바로 반영하기는 어려울 듯 하나, 모험심만 있다면 충분히 괜찮을 것 같다. TinaCMS의 컨셉 자체는 매우 만족스럽다.

이찬희

프론트엔드 엔지니어 이찬희 입니다. 리액트와 타입스크립트를 주로 사용합니다. 현재 당근에서 재직중입니다.

© 2023 iamchanii