YH

블로그 이사하기(2) - CMS 와 TUI 만들기

목차들어가며어떻게 할까?파일 시스템과 CMS 만들기

들어가며

이전 포스트에서Next 와 MDX를 통해서 마크다운 문서를 렌더링 할 수 있는 환경을 구성하는 방법을 소개했다.

이번에는 블로그 컨텐츠를 관리하는 CMS 솔루션과 배포 환경을 구성하면서 겪었던 경험과 알게 된 사실들을 적어보려고 한다.

어떻게 할까?

홈페이지의 앞단을 구성할 준비가 끝났다면, 이번에는 뒤편에서 콘텐츠들을 어떻게 관리하는 게 좋을지 에 대한 고민을 해보아야 한다.

콘텐츠의 형식은 무엇인지, 콘텐츠를 저장하는 방법은 무엇인지, 콘텐츠를 관리할 인터페이스는 무엇인지 등에 대한 것들이 포함된다.

이러한 부분들에 대해서는 그다지 경험이 많은 편이 아니었기 때문에 너무 어렵거나 복잡한 방식들을 택하고 싶지는 않았고,

내가 원하는 부분들을 만족시켜줄 수 있는 방법들이 무엇이 있을지 고민하는 것들이 중요했다.

당시에 고려했던 사항들은 다음과 같았다.

  • 터미널 환경에서 콘텐츠를 생성, 작성 및 관리하고 싶다.
  • 다른 특별한 레이어 없이 콘텐츠를 접근하고 싶다.
  • 번거로운 부분정도는 자동화를 하고 싶다.
  • 관리에 많은 노력이 필요하지 않았으면 좋겠다.

환경

현재 개발과 관련된 거의 모든 활동들을 터미널 (Neovim, Tmux) 을 통해서 하고 있기 때문에 블로그 작성이나 콘텐츠 를 위해서 또 다른 환경을 구성하고 싶지 않았다.

또 Neovim 내에서 Markdown 문서에 대한 지원이 굉장히 잘되어있고 vim 모션을 사용할 수 있다는 건 꽤나 장점이라고 생각하기 때문에 직접적으로 얻을 수 있는 이점이 많을 거라고 생각이 들었다.

콘텐츠

작은 규모의 개인 블로그 콘텐츠 데이터였기 때문에, 최대한 번잡스럽지 않도록 만들고 싶었다.

추후에 DB 나 CMS 솔루션 같은 것들을 통해 데이터 레이어를 추가하는 방법을 생각해볼 수 있겠지만 작성하게 될 포스팅의 갯수도 그렇게 많지 않고,

프로젝트 내에 포함시킨 후 빠르게 파일로써 작업하는 게 맞다는 생각이 들었다.

그래서 다음과 같이 구성하기로 결정했다.


파일시스템과 간단한 TUI 를 통해서 CMS 를 만들어보자.


파일 시스템과 CMS 만들기

데이터 결정하기

파일시스템을 통해서 콘텐츠를 관리하기로 결정했으니, Markdown 파일 자체를 어떻게 데이터로 다룰지 생각해보아야 한다.

Markdown 파일은 어떠한 타입의 데이터인지, 어떠한 데이터(콘텐츠, 메타데이터)를 포함시키도록 해야할 지, 어떻게 입출력을 구성해야할지 등에 대해서 생각해보았고 의사결정을 내려보기로 했다.

먼저, 블로그 같은 경우에는 Markdown 파일 자체가 하나의 독립적인 페이지 역할을 하게 된다. 그러나 Markdown 파일 자체만으로 보면 콘텐츠를 제외하고는 특별한 데이터가 존재하지는 않는 것 같다.

그렇다면 어떻게 파일들을 데이터화 시킬 수 있을까?

Front Matter

이전 포스트에서도 이야기 했던 것처럼, Markdown 문서에는 적절한 파서(parser) 와 형식이 존재한다면 언제든지 원하는 방식으로 확장해서 사용할 수 있다.

Front Matter 는 문서자체에 원하는 메타데이터를 추가하여 관리하기 위해 사용하는 전문 형식의 데이터이다.

공통적인 문법은 다음과 같이 --- 블럭안에 key : value 형태로 데이터를 삽입해주면, 파서가 이를 읽고 output 형식(JSON 또는 TOML 등)에 적절하도록 구성해주는 방식이다.

---
title: 블로그 이사하기(2) - CMS 와 배포하기
excerpt: "Next 와 TUI 를 이용해 블로그를 만들어보자"
publish: true
tags: [프로젝트, Next, 블로그]
slug: 블로그-이사하기-2
---

자바스크립트 라이브러리 중 Gray Matter 가 유명하며 일반적으로 사용되고 있다. 사용법도 직관적이고 인터페이스도 간단하니 살펴보면 금방 사용할 수 있을 것이다.

대부분의 블로그 포스팅과 같은 경우에는 title, excerpt, tag, date 등과 같은 기본적인 메타정보를 포함시키면 충분하지만, 목적에 따라서 reading time 과 같은 추가 정보나 런타임에서 활용할 정보들을 포함시키면 많은 방식으로 사용할 수 있다.

그런데 여기서 가장 중요한 메타데이터가 하나 있는데 바로 slug 이다.

Slug

블로그 같은 경우, 하나의 콘텐츠 데이터가 하나의 페이지에 1:1 대응 되는 형태라고 봐야 한다. DB 를 통해서 관리하는 데이터라면, 아마도 각각의 레코드에 대해서 Id 필드를 생성하고 값을 부여해 관리하도록 했을 것이다.

그러나 그렇게 할 경우 한 가지 문제가 되는 것은, Id 값이 곧바로 URL 에 매핑된다는 사실이다.

개인화된 데이터와 그에 관련된 페이지라면 크게 문제될 것이 없지만 검색과 공유를 목적으로 하는 블로그 와 같은 웹 페이지의 경우, URL 또한 검색엔진의 정보 수집에 영향을 미친다.

사용자의 입장에서도 의미 없는 문자나 숫자의 나열 보다는 정보를 확인할 수 있는 값이 URL 에 구성되어있는 편이 알아보기 쉽고 훨씬 신뢰가 갈 것이다.

이렇게 레코드의 Id 필드 역할을 하면서 콘텐츠를 알아보기 쉬운 값으로 URL 바인딩하는 역할을 하는 값을 Slug 라고 부른다.

보통 이러한 슬러그를 생성하는 방법에는 몇 가지가 있는데 대체적으로 다음과 같은 규칙을 따른다.

  • 특수문자는 제거한다.
  • 모든 영문자는 소문자로 치환한다.
  • 띄어쓰기는 -(dash) 로 치환한다.

이와 같은 규칙을 적용하면 우리가 익히 보아왔던 URL 패러미터를 확인할 수 있다.


slug example

슬러그를 생성하는 기준이 되는 값은 타이틀, 특정문자열 등 다양하게 결정할 수 있고, 해당 포스트의 특징을 잘 보여줄 수 있는 값이라면 무엇이든 상관 없다.

그러나 유의해야 될 사항이 있다. 슬러그 자체가 콘텐츠의 Id 역할을 하기 때문에 이를 자주 변경될 수 있는 값을 기준으로 생성하게 되면 의도하지 않은 혼란을 만들어낼 수 있다는 사실이다.

만약 값이 변경될 때 마다 슬러그가 변경된다면, 바뀌기 이전의 슬러그를 기준으로 포스트를 찾고자 하는 사람은 빈 페이지를 만나게 될 것이고 만약 슬러그가 변경된 사항과 아무 상관이 없는 값으로 정해진다면 콘텐츠와의 연관성이 깨질 것이다.

나의 경우는 프로젝트 파일명을 기준으로 슬러그 값을 생성하기로 결정했다. 타이틀의 경우 언제든지 바뀔 가능성이 있지만, 파일명의 경우 페이지 콘텐츠 자체에서 보여주는 부분이 없어 영향이 미미하기 때문이었다.

또한 파일시스템을 통해 데이터를 관리하기 때문에, 코드에서 이를 참조할 때 슬러그가 어떤 Markdown 파일을 가리키는 지 정확하게 매핑할 필요성이 있었는데, 이 관계를 통해서 한 번에 목표한 바를 처리할 수 있었다.

그리고 다음과 같이 slug 들을 모아놓은 index 파일을 생성해서, slug <-> index <-> file.md 간의 참조가 빠르게 이루어질 수 있도록 구성했다.

index.json
{
  ...
  "slugs": [
    "react-와-react-렌더링-이해하기-1",
    "react-와-react-렌더링-이해하기-2",
    ...
  ]
}

INK 로 CMS 만들기

그 다음 구성해야할 부분은, TUI 와 API 를 만들어주는 부분이었다. 이 부분이 가장 흥미로웠던 부분이자, 고민이 되는 부분이었다. Node 를 활용해서 시스템레벨의 많은 작업을 해본 적이 없었기 때문이다.

그래도 복잡한 부분은 특별하게 없는 간단한 요구사항이니 여러가지를 공부해본 다는 마음으로 접근했다.

달성해야할 굵직한 목표사항은 다음과 같은 두 가지였다.

  1. 파일시스템을 활용해 CRUD 가 가능한 API 작성하기
  2. API 를 연동할 TUI 만들기

정의하기

우선 파일 시스템을 사용하기 위한 API 의 기본 재료로 node 의 path, fs 모듈을 사용했다. path 모듈은 node 환경에서 파일 및 디렉토리의 위치를 사용할 수 있는 다양한 메소드들을 제공하며, fs 모듈은 파일 시스템을 활용해 읽기 / 쓰기 / 삭제 등의 기본 연산을 실행할 수 있도록 하는 메소드를 제공한다.

그 다음 API 의 기본 기능 정의를 구상한다.

프로젝트의 Root 에 contents/ 디렉토리를 생성한후, posts/ 하위에 관련 콘텐츠 파일을 저장한다. contnets/posts/ 로 구성해 추후 기능이 확장되어 다른 분류가 필요할 때를 대비했다.

그러면 이제 API를 작성해야 하는데 디자인과 코드 베이스를 어떻게 짜야할 지 생각해보아야 한다.

API 만들기

크게 API 들을 이루게 될 몇 가지 작업들을 생각해보았다.

  • 데이터를 가공 및 작성 하게 되는 작업
  • 파일시스템을 건드려 직접 부수효과를 일으키게 되는 작업
  • 각각의 작업들에서 공통으로 쓰이게 될 작업

이들을 기준으로 다음과 같은 구조를 구성하기로 했다.

  • io.ts : 해당 작업들은 실제로 부수효과를 일으키는 파일시스템 관련 함수들
  • builder.ts : 데이터를 불변하도록 다뤄, 데이터의 생성 및 변경을 다루는 함수들
  • actions.ts : iobuilder 의 함수들을 엮어 실제 API 가 되는 함수들

예로 들어 새로운 포스트를 생성하는 API는 다음과 같이 구성된다.

actions.ts
import { writeOrCreateFile } from "./io";
 
import {
  genNewIndex,
  genNewPost,
  genNewPostMeta,
  genNewSlugFromFilename,
  getPostPath,
  validateSlug,
} from "./builder";
 
const createPost = (title: string, date: Date): Result<PostData> => {
  try {
    const slug = genNewSlugFromFilename(title);
    const { slugs } = getIndex();
    validateSlug(slug, slugs);
    const newPostMeta = genNewPostMeta(title, slug, date);
    const newPostData = genNewPost(newPostMeta);
    const path = getPostPath(getContentPath(), newPostData.fileName);
    const content = matter.stringify(newPostData.post.content, newPostData.post.data);
    writeOrCreateFile(path, content);
    return { data: newPostData, success: true };
  } catch (error) {
    console.error("Post creation failed:", error);
    return { data: error, success: false };
  }
};

복잡할 것 없이 간단한 API 이지만, 이렇게 작업들을 분리 하고 모듈화 해서 얻을 수 있는 이점은 테스트를 진행하기 용이하다는 점, 어떤 함수를 호출할 때 좀 더 조심해야하는지 예상할 수 있다는 점이었다.

추가적으로 API를 실행하면서 Result 객체를 사용해 API 를 다룰 때 에러를 분명하게 다룰 수 있도록 하고 싶었다. 해당 API를 호출하는 TUI 의 부분으로 찾아가면 다음과 같다.

Create.tsx
//...
// success 는 API 가 성공했을 경우 true 아닐 경우 false 가 된다.
const { success, data } = createPost(title, new Date());
if (success) {
  setPostInfo(data);
  setStageIdx(3);
} else {
  process.exit(1);
}

Go 와 같은 언어에서는 에러를 값으로 처리함으로써, 프로그램의 안정성을 높이고 개발자로 하여금 예상치 못한 부분을 처리하도록 강제하는 것으로 알고 있다.

JS 생태계에서도 never-throw 패키지나 effect 라는 패키지들을 통해서 이를 비슷하게 사용할 수 있는 것 같지만, 오버헤드를 만들고 싶지도 않았고 제대로 적용해보기 전에 테스트를 해보자는 느낌으로 적용해봤다.

아직 많이 어설프고 부족한 부분이 많지만 해보면서 느는게 아니겠어?

INK 로 TUI 만들기

이제 기능을 담당해줄 뒷단이 완성 되었으니, 사용을 담당해줄 앞단을 만들어야 한다. TUI 는 사용하기만 해봤지 사실 만들어본 적이 없으니 어떻게 시작해야할 지 감이 안왔다.

다행히도 node 에는 다양한 라이브러리가 이미 존재하고, 이를 취사선택해서 사용하면 됐다. chalkinquirer 등을 혼합해 빌딩블록들을 직접 쌓아가면서 만들 수도 있었지만 더 좋은 방법을 우연히 발견했다.

openAI의 codex 가 출시 되었을 때, 궁금해서 소스코드를 찾아보다가 Ink 라는 패키지로 TUI를 만들었다는 걸 확인하게 됐고, 이거다 싶었다.


Ink 는 TUI 를 node 환경에서 보다 쉽게 작성할 수 있게 만들어주는 라이브러리이다. 단순 유틸함수들을 제공해주는 것이 아니라, React를 차용해 이를 TUI로 바꿔주는 방식으로 말이다.

살펴보면 React DOM 에 가깝다기 보다는 React Native 의 방식과 비슷한 것 같다.

HTML 요소를 사용하는 것이 아닌 TUI 의 UI(Text, Box, useInput 등) 요소들을 하나하나 import 해서 사용할 수 있고, 로직 자체는 React 스럽게 사용하는게 가능하도록 만들어준다.

나의 경우 우선 다음과 같이 접근하기로 결정했다.

  1. 셀렉터 기반의 UI 를 만든다.
  2. 각각의 셀렉터 옵션들은 각각의 일련의 프로세스들을 실행한다.
  3. 프로세스들은 각각의 요소들을 통해 입력을 받거나 할 수 있다.
  4. 최종적으로 결과를 실행한다.

이를 구현하기 위해 우선은 행하게 될 액션들을 API 에 맞게 작성했다.

cliOptions.ts
// ...
export const CLI_OPTIONS = [
  { prompt: "(c) 📝새 포스트 작성", step: "create", key: "c" },
  { prompt: "(u) 📦 포스트 업데이트", step: "update", key: "u" },
  { prompt: "(d) ❌ 포스트 삭제", step: "delete", key: "d" },
  { prompt: "(q) ↩️ 종료", step: "exit", key: "q" },
  { prompt: "(l) 📋 리스트", step: "list", key: "l" },
] as const;
 
export type CLIStep = (typeof CLI_OPTIONS)[number]["step"];

이를 기반으로 이렇게 프로세스들을 통해 UI 들을 전개해 나가기로 했다.

index.tsx
 
import React from "react";
import { render, useInput, Text } from "ink";
import OptionSelector from "./components/OptionSelector";
import { CLI_OPTIONS } from "./components/cliOptions";
import Processors from "./components/Processors";
import { Update } from "./components/processes/Update";
 
const args = process.argv.slice(2);
 
const Index = () => {
  const [selectedOption, setSelectedOption] = React.useState<(typeof CLI_OPTIONS)[number] | null>(
    null,
  );
 
  useInput(
    (input) => {
      if (input === "q") {
        setSelectedOption(CLI_OPTIONS.find((option) => option.step === "exit") || null);
      }
      if (input === "c") {
        setSelectedOption(CLI_OPTIONS.find((option) => option.step === "create") || null);
      }
 
      if (input === "d") {
        setSelectedOption(CLI_OPTIONS.find((option) => option.step === "delete") || null);
      }
 
      if (input === "l") {
        setSelectedOption(CLI_OPTIONS.find((option) => option.step === "list") || null);
      }
 
      if (input === "u") {
        setSelectedOption(CLI_OPTIONS.find((option) => option.step === "update") || null);
      }
    },
    { isActive: !!!selectedOption },
  );
 
  // update 작업은 옵션이 전달되면 빠르게 추가로 실행되도록 구성
  if (args.includes("-u")) return <Update />;
 
  return (
    <>
      <Text color="whiteBright">블로그 관련 작업을 실행합니다.</Text>
      <Text color="blue">옵션을 선택하세요.</Text>
      <OptionSelector
        key="cli-options"
        options={CLI_OPTIONS}
        onConfirm={(option) => setSelectedOption(option)}
        isActive={!!!selectedOption}
      />
      {selectedOption && <Processors step={selectedOption.step} />}
    </>
  );
};
 
render(<Index />);

이게 정확히 올바르고 효율적인 구성인지는 모르겠지만, 우선 이를 기반으로 진행하기로 했다. TUI 가 키 입력을 기반으로 하기 때문에, 키 액션들을 처리하다보면 if, switch 문이 길어지는 문제가 있어 한 번 래핑해서 사용할까 하다가 결국 오히려 일이 커질 것 같아서 그대로 사용하기로 결정했다.

적절한 인풋을 받고나면, <Processors/> 컴포넌트에서 연결되어있는 프로세스를 렌더링하는 형태이다.

한 가지 문제 아닌 문제점이 있었는데, 원하는 대로 동작하길 원하는 컴포넌트를 만들고 싶다면 직접 작성해야한다는 점이었다. 여러 셀렉터 (option-selector, multi-selector) 등을 사용해야 하는 상황에서 내가 미리 만들어놓은 상태값이나 컨트롤과 매칭되는 컴포넌트가 없어서 직접 구성해서 사용해야했다.

공식 레포지토리에서 여러 컴포넌트들을 모아두긴 했지만, 이참에 한 번 직접 만들어보고자 했다. 우선은 동작하게만 만들자 하고 조잡하게 만들긴 했지만 의도한대로 된다는 것에 의의를 둔다.

MultiSelector.tsx
 
import React, { useState, useEffect } from "react";
import { Box, type Key, useInput, Text } from "ink";
 
export type OptionDefault = {
  prompt: string;
};
 
type SelecterExtendOption = {
  hoverColor?: string;
  selectedColor?: string;
};
 
type SelectorContainerProps = React.ComponentProps<typeof Box>;
 
type MultiSelectorProps<T extends OptionDefault> = {
  items: readonly T[] | T[];
  selctorOption?: SelectorContainerProps & SelecterExtendOption;
  onConfirm: (option: T[]) => void;
  isActive?: boolean;
  modifier?: (item: T) => string;
};
 
const defaultSelectorOption: SelectorContainerProps = {
  width: "50%",
  borderStyle: "classic",
  flexDirection: "column",
  overflowY: "visible",
  padding: 1,
};
 
export default function MultiSelector<T extends OptionDefault>({
  items,
  modifier,
  selctorOption = { selectedColor: "greenBright", hoverColor: "yellowBright" },
  onConfirm = () => {},
  isActive = true,
}: MultiSelectorProps<T>) {
  const [curIdx, setCurIdx] = useState(0);
  const [selected, setSelected] = useState<number[]>([]);
  const [confirmed, setConfirmed] = useState<T[] | null>(null);
 
  const { selectedColor, hoverColor } = selctorOption;
  const boxProps = { ...defaultSelectorOption, ...selctorOption };
 
  const selectHandler = () => {
    setSelected((prevSelected) => {
      const hasAlreadySelected = prevSelected.some((item) => {
        return item === curIdx;
      });
      if (hasAlreadySelected) {
        return prevSelected.filter((item) => item !== curIdx);
      } else {
        return [...prevSelected, curIdx];
      }
    });
  };
 
  const confirmHandler = () => {
    setConfirmed(selected.map((index) => items[index]));
  };
 
  useEffect(() => {
    if (confirmed) {
      onConfirm(confirmed);
    }
  }, [confirmed]);
 
  const inputHandler = (input: string, key: Key) => {
    if (key.leftArrow) {
      setCurIdx((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : prevIndex));
    }
 
    if (key.rightArrow) {
      setCurIdx((prevIndex) => (prevIndex < items.length - 1 ? prevIndex + 1 : prevIndex));
    }
 
    if (key.upArrow) {
      setCurIdx((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : prevIndex));
    }
 
    if (key.downArrow) {
      setCurIdx((prevIndex) => (prevIndex < items.length - 1 ? prevIndex + 1 : prevIndex));
    }
 
    if (key.tab) {
      selectHandler();
    }
 
    if (key.return) {
      confirmHandler();
    }
  };
 
  useInput(inputHandler, { isActive });
 
  return (
    <Box {...boxProps}>
      {items.map((item: T, index: number) => {
        const isSelected = selected.some((item) => item === index);
        const isHovered = curIdx === index;
        const backgroundColor = isSelected
          ? selectedColor
          : isHovered && isActive
            ? hoverColor
            : "";
        return (
          <Text backgroundColor={backgroundColor} key={index}>
            {modifier ? modifier(item) : item.prompt}
          </Text>
        );
      })}
    </Box>
  );
}
 

여하튼 이렇게 TUI를 구현하고, 사용해보니 나름 귀엽게 잘 동작하는 것 같아 마음에 든다.

tui example


삽질하기

각각의 패키지들을 어떻게 관리할 것이냐?

콘텐츠

사실 가장 원했던 것은 메인 패키지와 별개로 추적되는 레포지토리이길 원했다. 콘텐츠가 업데이트 된다고 웹과 CLI 등의 소스코드가 이에 종속되길 바라지 않았기 때문이다.

그래서 처음으로 생각해 본 방법은, git-submodule 로 관리하면 어떨까였다.

git-submodule 이란 하나의 Git 저장소 안에 다른 Git 저장소를 포함시켜, 부모 저장소와 독립적으로 버전과 히스토리를 관리하고 싶을 때 사용한다.

이는 주로 해당 하위 레포가 외부로 배포되지 않거나 별도 패키징이 불필요할 때, 부모 레포가 여러 개의 독립 프로젝트(도구, 모듈, 구성요소)를 포함하는 모노레포처럼 동작할 때 유용하다.

이 블로그 같은 경우, 블로그 콘텐츠를 따로 관리하고 싶었기 때문에 해당 내용을 git-submodule 로써 블로그 레포지토리에 삽입하고 싶었지만 이 경우 git submodule 이 효율적이 되는 상황과 달라졌기 때문에 포기하는 방향으로 결정했다. blog contents 의 업데이트가 본 프로젝트보다 잦기 때문에 오히려 의존성이 역전되는 상황이 발생했기 때문이다.

TUI

TUI 의 경우 상황이 조금 더 복잡했다.

  1. 유틸리티 패키지를 만든 것이 아니라 프로젝트 자체에 종속되어있는 어플리케이션
  2. Next 와 다른 실행환경을 가지고 있음

이었기 때문이었다.

그래서 이 경우, 간단하게 이상한 생각들을 접고 전체 블로그 앱에 모노레포로 구성해 TUI를 포함하도록 만들었다.

디렉토리를 따로 둔 뒤, pnpm 을 통해서 모노레포 환경을 따로 구성하고 해당 어플리케이션의 의존하는 node-modulestsconfig 을 따로 생성하도록 했다.

다만, 명령어 자체는 패키지 어디에서든지 실행시키고 싶었기 때문에 tsx 패키지 자체는 메인 프로젝트에 포함시켰다.

프로젝트를 운영하고 있는 지금에 와서는 모노레포 구성에 대해서 조금 더 공부한 뒤에 각각의 디렉토리들을 구성했으면 더 좋은 방법들이 생각이 날까 싶기도 하다.


마무리

CMS 와 TUI 작성 부분은 이정도로 하는 게 좋을 것 같다. 아쉬운 부분이 많기도 했지만 여러 가지를 경험해볼 수 있었던 것에 의의를 둔다.

다음은 배포 후 최적화와 테스팅 등을 구성하면서 겪었던 내용들을 다뤄보려고 한다.