워드프레스를 휴고로 변환하기

마크다운을 사용하고 싶다

워드프레스에 마크다운 플러그인을 설치한 다음에, 글을 쓰니 마음에 든다. 그런데, 기존 쿠텐베르크 블록들로 만들어진 게시글에서는 이미지가 안보이거나 다른 이미지가 보이는 등의 문제가 발생하는 것이 아닌가! 결국 구텐베르크 스타일로 변경할 수 밖에 없었다. 고민하다가, 마크다운 기반의 블로그 플랫폼으로 옮겨야겠다고 마음 먹고, 그 과정을 기록으로 남겨본다.

휴고(hugo) 설치 with 옵시디안

다른 블로그 플랫폼으로는 휴고를 선택했다. 정적 페이지 빌드가 빠르다고 다른 웹사이트들에서 소개하고 있었는데, 그건 내겐 중요하지 않았고, 마지막에 테스트 해본 것이 휴고라서 선택했다. (Quartz, Jeklly?? 는 어떻게 하는지 잊어버렸다.)

우선 휴고를 설치하자. 나는 맥북 환경이므로 아주 쉽게 설치를 했다. 설치 위치는 옵시디안 볼트 페이지내에서 설치를 했다. 왜냐하면 옵시디안이 시놀로지와 동기화되는 폴더이기 때문이다. 아래와 같은 효과를 원했다.

  • 맥북에서 옵시디안으로 블로그 글을 작성하면,
  • 시놀로지 나스에 작성된 글이 동기화된다.
  • 맥북에서 hugo로 페이지를 빌드하면,
  • 시놀로지에도 빌드된 페이지들이 동기화된다.
  • 빌드된 페이지를 시놀로지 웹 서비스에 자동으로 옮기도록 하면 새 글이 퍼블리싱 된다.

그럼 이제 시작해보자.

brew install hugo
hugo new site blog

진짜 너무 쉽게 설치가 완료됐다. 그러면 이제 워드프레스를 직접 옮기는 작업을 해보자.

wp2hugo 다운로드

워드프레스 사이트를 hugo로 변환하기 위해서는 워드프레스의 게시글과 이미지들을 내려받아야 한다. 아래 링크는 그 중에서 업데이트가 가장 활발한 wp2hugo라는 변환툴이다. 깃합 사이트에 들어가서 릴리즈에서 운영체제에 맞는 파일을 다운로드 받는다.

https://github.com/ashishb/wp2hugo/releases

나는 macOS에서 변환을 진행하기 때문에 darwin, arm 붙은 파일을 다운로드 받았다.

워드프레스 글 내려받기

워드프레스에서 도구 > 내보내기 > 모든 콘텐츠를 선택해서 .xml 파일을 다운로드 받는다.

wp2hugo로 변환하기

다운로드 받은 wp2hugo와 워드프레스 .xml 파일을 변환하면 된다. 그 전에 wp2hugo의 실행권한을 줘야 한다.

chmod +x ./wp2hugo
xattr -d com.apple.quarantine ./wp2hugo

아래와 같이 변환 명령을 치면, /my_hugo 라는 폴더에 xml에 게시글을 .md 파일로 변환하고, 게시글에 있는 이미지들을 다운로드 받기 시작한다. 게시글의 갯수에 따라, 서버 및 네트워크 속도에 따라 시간이 조금 걸린다.

./wp2hugo --source <xml 파일명> --download-media --output ./my_hugo

자 그럼 .md 파일로의 변환이 끝났다면, 아래 이미지처럼 폴더 구조를 가진 my_hugo가 보인다.

Pasted_image_20251023134911.png

다운로드 받은 게시글의 이미지들은 static > wordpresss > wp-content > uploads 에 저장되고, 게시글은 content > posts 에 저장된다. static/wordpress 폴더와 content/posts 폴더를 hugo 세팅 폴더로 옮겨준다.

파일명 변환

그런데, content/posts에 있는 .md 파일들의 파일명이 알아볼 수 없게 되어 있다. 한글은 파일명이 알아볼 수 없도록 되어 있어서 변환해야 한다. 변환 코드를 하나 짜서 일괄로 다 바꿔보자.

PATH=/usr/bin:/bin:/usr/sbin:/sbin
set -euo pipefail

TARGET="blog/content/posts"  # 필요하면 경로 수정
RECURSIVE=0                  # 하위 폴더까지 처리하려면 1로 변경
DRY_RUN=1                    # 미리보기; 실제 변경하려면 0으로 변경

find_args=(-type f -name '*.md')
if [[ $RECURSIVE -eq 0 ]]; then
  find_args=("-maxdepth" "1" "${find_args[@]}")
fi

declare -a renames=()
declare -a warnings=()

while IFS= read -r -d '' src; do
  basename=${src##*/}
  decoded=$(python3 - "$basename" <<'PY'
import sys
from urllib.parse import unquote
print(unquote(sys.argv[1]), end="")
PY
)

  [[ "$decoded" == "$basename" ]] && continue

  dest_dir=${src%/*}
  dest="$dest_dir/$decoded"

  if [[ -e "$dest" && "$dest" != "$src" ]]; then
    warnings+=("skip (already exists): $src -> $dest")
    continue
  fi

  duplicate=0
  for ((i=0; i<${#renames[@]}; i+=2)); do
    if [[ "${renames[i+1]}" == "$dest" && "${renames[i]}" != "$src" ]]; then
      duplicate=1
      break
    fi
  done
  (( duplicate )) && {
    warnings+=("skip (duplicate target): $src -> $dest")
    continue
  }

  renames+=("$src" "$dest")
done < <(find "$TARGET" "${find_args[@]}" -print0)

if (( ${#renames[@]} == 0 )); then
  echo "No percent-encoded Markdown filenames found."
  exit 0
fi

if (( ${#warnings[@]} > 0 )); then
  echo "Warnings:"
  for warning in "${warnings[@]}"; do
    echo "  $warning"
  done
  echo
fi

echo "Planned renames:"
for ((i=0; i<${#renames[@]}; i+=2)); do
  src=${renames[i]}
  dest=${renames[i+1]}
  rel_src=${src#"$TARGET"/}
  rel_dest=${dest#"$TARGET"/}
  echo "  $rel_src -> $rel_dest"
done

if (( DRY_RUN )); then
  echo
  echo "Dry-run complete. DRY_RUN=0 으로 다시 실행하면 실제로 이름이 바뀝니다."
  exit 0
fi

echo
for ((i=0; i<${#renames[@]}; i+=2)); do
  mv "${renames[i]}" "${renames[i+1]}"
done

echo "Renaming complete."

위 쉘 스크립트를 터미널에서 실행시키면, “%ea%b0%95%ec%95%84%ec%a7%80%ec%97%90%ea%b2%8c-%ea%b0%90%ec%8b%9c-%eb%8b%b9%ed%95%98%ea%b3%a0-%ec%9e%88%eb%8b%a4.md” 이랬던 파일명이 “강아지에게-감시-당하고-있다.md” 로 바뀐다. posts 디렉토리에 있는 모든 파일이 변경된다.

그런데, 변경하고 보니, .md 파일명 빈칸에 해당하는 부분이 하이픈으로 대체되어 있어서 보기 좋지 않다. 어차피 hugo의 .md 포맷에는 프론트메터 부분에서 slug를 표시하도록 되어 있으니, 하이픈을 빼보자.

PATH=/usr/bin:/bin:/usr/sbin:/sbin

/usr/bin/find blog/content/posts -type f -name '*.md' -print0 |
while IFS= read -r -d '' path; do
  dir=$(/usr/bin/dirname "$path")
  base=$(/usr/bin/basename "$path")
  new_base="${base//-/ }"
  [[ "$base" == "$new_base" ]] && continue
  new_path="$dir/$new_base"
  [[ -e "$new_path" ]] && { echo "이미 존재해서 건너뜀: $new_path"; continue; }
  /bin/mv "$path" "$new_path"
done

위 쉘 스크립트를 터미널에서 실행시키면, 하이픈이 빈칸으로 대체된 .md 파일명을 얻을 수 있다. 변환되지 않는 몇몇 파일들도 있는데, 그건 수동으로 수정하자. 그리고, .md 파일명에 gallery 태그들은 나누던지 아니면 수작업으로 삭제하고 다른 태그를 사용해도 된다.

게시글의 이미지 파일 이동

그런데 게시글(.md)과 게시글에 들어있던 이미지들이 서로 다른 폴더에 저장되어 있는 것이 마음에 들지 않는다. hugo에서 지원하는 페이지 번들(page bundle) 형식으로 바꿔보자. 페이지 번들은 게시글 제목에 해당하는 폴더를 하나 만들고, 게시글은 index.md 파일에 기록하고, 이미지도 같은 폴더에 집어 넣는 방식을 말한다. 그럼 기존 .md 파일들은 폴더명으로 변경하고, 내용은 index.md로 저장한 후에, 저기 다른 폴더(static/wordpress)에 저장되어 있던 이미지들을 폴더로 옮겨오는 작업을 해보자. 게시글에서 이미지 부분을 체크하고, 해당 이미지들을 하나씩 손수 옮기는 작업이다. 순수 쉘 스크립트로는 못짤 것 같아서 파이썬 코드도 넣었다.

set -euo pipefail

POSTS_DIR="blog/content/posts"
STATIC_DIR="blog/static"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --posts)
      POSTS_DIR=$2
      shift 2
      ;;

    --static)
      STATIC_DIR=$2
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "Unknown option: $1" >&2
      usage
      exit 1
      ;;
    esac
  done

if [[ ! -d "$POSTS_DIR" ]]; then
  echo "posts 디렉터리를 찾을 수 없습니다: $POSTS_DIR" >&2
  exit 1
fi

if [[ ! -d "$STATIC_DIR" ]]; then
  echo "static 디렉터리를 찾을 수 없습니다: $STATIC_DIR" >&2
  exit 1
fi

find "$POSTS_DIR" -maxdepth 1 -type f -name '*.md' -print0 |
while IFS= read -r -d '' md_file; do
  bundle_dir="${md_file%.md}"
  bundle_index="$bundle_dir/index.md"

  if [[ -d "$bundle_dir" ]]; then
    echo "이미 폴더가 존재하여 건너뜁니다: $bundle_dir" >&2
    continue
  fi

  echo "▶︎ 변환 시작: $md_file"
  mkdir -p "$bundle_dir"
  mv "$md_file" "$bundle_index"

  python3 - "$bundle_index" "$STATIC_DIR" <<'PYTHON'
import shutil
import sys
import re

from pathlib import Path
index_path = Path(sys.argv[1])
static_root = Path(sys.argv[2])
bundle_dir = index_path.parent

try:
	text = index_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
	text = index_path.read_text(encoding="utf-8-sig")

pattern = re.compile(r"(/wordpress/[^\s\"'\)\]]+)")
paths = []
seen = set()
for match in pattern.finditer(text):
	url = match.group(1)
	if url not in seen:
		seen.add(url)
		paths.append(url)
	if not paths:
		sys.exit(0)

replacements = {}

for url in paths:
	src = static_root / url.lstrip("/")
	if not src.exists():
		print(f"경고: 이미지 파일이 없음 -> {src}", file=sys.stderr)
		continue
	dest = bundle_dir / src.name
	if dest.exists():
		stem = dest.stem
		suffix = dest.suffix
		counter = 1
		while dest.exists():
			counter += 1
			dest = bundle_dir / f"{stem}-{counter}{suffix}"
	
	print(f" 이미지 이동: {src} -> {dest}")
	shutil.move(str(src), dest)
	replacements[url] = dest.name

for old, new in replacements.items():
	text = text.replace(old, new)

if replacements:
	index_path.write_text(text, encoding="utf-8")

PYTHON

  echo " 완료: $bundle_dir"
done

echo "모든 파일 처리가 끝났습니다."

이제 쉘 스크립트를 터미널에서 실행하면, 페이지 번들 형식으로 폴더가 만들어지고, 폴더안에 게시글과 이미지가 모두 들어가게 된다. 물론, 제대로 되지 않는 글들도 있으니, 하나씩 수작업으로 처리하자.

테마 적용

hugo server

이렇게 입력하면, 못생긴 웹페이지를 확인할 수 있다. 그래서 이제는 테마를 입혀보자.

brew install go
hugo mode init vividian.net
hugo mod get github.com/hugo-sid/hugo-blog-awesome

Golang을 설치하고, 모드로 설치할 수 있다. 나는 hugo-blog-awesome 테마가 맘에 들어서 이걸로 결정했다. 하지만, 테마가 제대로 나오지 않는다. 테마를 좀 손 볼라고 했더니, Golang mod라서 ~/go/pkg/mod/github.com/hugo-sid/hugo-blog-awesome@버전/ 이곳에 테마가 저장이 된다. 이거 이러면 손보기 쉽지 않다.

그래서 hugo 홈페이지에서 설명하는 git submoule 형식으로 테마를 설치했다. 테마는 themes 폴더 아래 저장이 된다.

마크다운 빌드하기

자 그러면 이게 거의 다 끝났다. 맥북에서 마크다운을 정적페이지로 빌드한 다음에 public 폴더를 나스에 옮겨주기만 하면 된다. 매번 수작업으로 옮겨줘도 되긴 하지만, 귀찮으니깐 쉘 스크립트를 하나 짠다. 아래와 같이 쉘 스크립트를 하나 짜 놓으면, 글 써놓고 스크립트 실행하면 자동으로 배포까지 이뤄진다.

#!/usr/bin/env bash
set -e

echo "🚧 Hugo 빌드 시작..."
hugo
echo "✅ Hugo 빌드 완료"

echo "⏳ 5초 대기 중..."
sleep 5

echo "🚀 원격 서버로 동기화 중..."
ssh -p [포트번호]] [아이디]@[아이피주소 or 도메인주소] 'rsync -av "[맥북에 있는 public 절대경로]" [시놀로지 나스 웹 서비스 절대경로]'

echo "🎉 배포 완료!"

나는 deploy.sh 라고 만들었고. 아래와 같이 실행한다. (실행 권한 주는 것 잊지 말자)

./deploy.sh

프론트 메터(front matter) 정리

.md 파일을 보면, 워드프레스에서 사용하던 메타데이터들을 변환한 거라서 실제 hugo에서 사용하지 않는 메타데이터들이 많다. 하나씩 지우는 것은 시간이 너무 너무 많이 걸리니, 쉘 스크립트로 한방에 변환하자.

author, categories, date, tags, title, url 이외에는 휴고에서 사용하지 않는 프론트 메터이므로 과감히 삭제하고, title, date, author, slug, aliases, description, tags, categories, keywords, summary 순으로 프론트 메터를 만들었다.

#!/usr/bin/env bash
# Hugo 프론트매터를 정리하는 쉘 스크립트.
# blog/content 아래의 모든 Markdown 파일을 순회하며 워드프레스에서 넘어온 프론트매터를 지정한 형식으로 재구성한다.
# PyYAML(pip install pyyaml)이 설치되어 있어야 한다.
set -euo pipefail

ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
CONTENT_DIR="${ROOT_DIR}/content"

if [[ ! -d "${CONTENT_DIR}" ]]; then
  echo "content 디렉터리를 찾을 수 없습니다: ${CONTENT_DIR}" >&2
  exit 1
fi

python3 - <<'PYTHON' "${CONTENT_DIR}"
from __future__ import annotations
import json
import re
import sys
from collections.abc import Iterable
from pathlib import Path

try:
	import yaml
except ModuleNotFoundError:
	print("PyYAML이 필요합니다. pip install pyyaml 후 다시 실행하세요.", file=sys.stderr)
	raise SystemExit(1)

CONTENT_DIR = Path(sys.argv[1])

def read_front_matter(lines: list[str]) -> tuple[dict, int]:
	if not lines or lines[0].strip() != "---":
		return {}, -1
	for idx in range(1, len(lines)):
		if lines[idx].strip() == "---":
			closing = idx
			break
		else:
			return {}, -1

	block = "\n".join(lines[1:closing])
	if not block.strip():
		return {}, closing

	try:
		data = yaml.safe_load(block) or {}
	except yaml.YAMLError as exc:
		raise ValueError(f"YAML 파싱 실패: {exc}") from exc
	if not isinstance(data, dict):
		data = {}
	return data, closing

def to_str(value) -> str:
	if value is None:
		return ""
	return str(value)

def normalize_sequence(value) -> list[str]:
	if value is None:
		return []
	if isinstance(value, str):
		stripped = value.strip()
		if not stripped:
			return []
		if stripped.startswith("[") and stripped.endswith("]"):
			try:
				parsed = yaml.safe_load(stripped)
				return normalize_sequence(parsed)
			except yaml.YAMLError:
				pass

		items = [part.strip() for part in re.split(r"[,\n]+", value)]
		return [itm for itm in items if itm]
	if isinstance(value, Iterable):
		cleaned: list[str] = []
		for itm in value:
			if itm is None:
				continue
			text = str(itm).strip()
			if text:
				cleaned.append(text)
		return cleaned
	return [str(value).strip()]

def make_slug(title: str, fallback: str) -> str:
	base = title.strip() or fallback
	base = re.sub(r"\s+", "-", base)
	base = re.sub(r"-+", "-", base)
	return base.strip("-")

def quote(value: str) -> str:
	escaped = value.replace("\\", "\\\\").replace('"', '\\"')
	return f'"{escaped}"'

def format_array(items: list[str]) -> str:
	return json.dumps(items, ensure_ascii=False)

def rebuild_front_matter(data: dict, md_path: Path) -> list[str]:
	title = to_str(data.get("title")).strip()
	date = to_str(data.get("date")).strip()
	author = to_str(data.get("author")).strip()
	url = to_str(data.get("url")).strip()
	guid = to_str(data.get("guid")).strip()
	tags = normalize_sequence(data.get("tags") or data.get("tag"))
	categories = normalize_sequence(data.get("categories") or data.get("category"))
	aliases: list[str] = []
	
	if url:
		aliases.append(url)
		aliases.extend(normalize_sequence(data.get("aliases") or data.get("alias")))
	if guid:
		aliases.append(url)

	seen = set()
	deduped = []
	for entry in aliases:
		if not entry or entry in seen:
			continue
		deduped.append(entry)
		seen.add(entry)

	fallback_slug = md_path.stem
	if md_path.name == "index.md":
		fallback_slug = md_path.parent.name or fallback_slug
	slug = make_slug(title, fallback_slug)

	front = [
		"---",
		f"title: {quote(title)}",
		f"date: {quote(date)}",
		f"author: {quote(author)}",
		f"slug: {quote(slug)}",
		f"url: {quote(url)}",
		'aliases: ""',
		'description: ""',
		f"tags: {format_array(tags)}",
		f"categories: {format_array(categories)}",
		f"keywords: {format_array(tags)}",
		'summary: ""',
		"---",
	]
	return front

def process_file(md_path: Path) -> bool:
	raw = md_path.read_text(encoding="utf-8")
	if raw.startswith("\ufeff"):
		raw = raw.lstrip("\ufeff")
	lines = raw.split("\n")
	try:
		data, closing = read_front_matter(lines)
	except ValueError as exc:
		print(f"[경고] {md_path}: {exc}", file=sys.stderr)
		return False
	if closing == -1:
		print(f"[건너뜀] 프론트매터 없음: {md_path}", file=sys.stderr)
		return False
	body = lines[closing + 1 :]
	new_front = rebuild_front_matter(data, md_path)
	new_lines = new_front + body
	new_content = "\n".join(new_lines)
	if not new_content.endswith("\n"):
		new_content += "\n"
	md_path.write_text(new_content, encoding="utf-8")
	return True

md_files = sorted(CONTENT_DIR.rglob("*.md"))
if not md_files:
	print("Markdown 파일을 찾지 못했습니다.", file=sys.stderr)
	raise SystemExit(1)

count = 0
for md_path in md_files:
	if process_file(md_path):
		count += 1

print(f"완료: {count}개 파일 업데이트")
PYTHON

자 이제 프론트 메터까지 hugo 스타일로 변경했다. 거의 다 끝났다. 물론 쉘 스크립트 돌려서 안되는 게시물도 있다. 그런 것은 손수 수작업으로 변경해주자.

길고 긴 마이그레이션 작업

길고 긴 마이그레이션 작업이었다. 약 6시간 정도 걸린 것 같다. 워드프레스를 걷어내고 완전히 hugo로 변경했다. 군더더기 없어 보이는 사이트를 보고 있자니, 왠지 마음에 든다. 하지만, 앞으로 수정해야 할 것도 좀 된다. 댓글, 광고, 인기글, SEO 등등 앞으로 시간 날 때마다 천천히 수정을 해야겠다.