Wordpress에서 Next.js로 마이그레이션 여정

개발

Wordpress에서 Next.js로 마이그레이션 여정
최종 수정일:

일전에 공지에 작성했듯, 드디어 숙원 중 하나인 블로그 마이그레이션의 1막을 마쳤습니다.
이번 포스팅에서는 마이그레이션의 과정과 결과에 대해 소개하고자 합니다.

기존 블로그의 문제들

운영 난도

<?php
$post_id = get_the_ID();
 
if (isset($_GET['json'])) : header('Content-Type: application/json');
    echo '{"type":"article","version":"' . get_current_version() . '","bodyClass":"' . join(' ', get_body_class()) . '",';
        while (have_posts()):
            the_post();
        // ...
        echo '}';
 
    endif;
 
else : get_header(); ?>
 
    <main id="main" class="max-860">
        <?php while (have_posts()) : the_post();
        // ...
<?php endif;
    get_footer();
endif;

간단하게 정리한 포스트 페이지(single.php) 코드 일부입니다.
보시다시피, json 쿼리가 있을 땐 JSON 형식으로 응답하고, 없을 땐 HTML을 렌더링하는 작업을 진행합니다.

작업할 당시에는 이런 마개조를 통해 PHP로 서버 사이드 렌더링을 하고, Vanilla JS로 클라이언트 사이드 렌더링을 하는 작업이 성공했다는 것 자체로 감격에 겨웠습니다.
하지만, 아주 당연히 이런 방식은 유지보수가 몹시 어렵고, 실수하기 아주 쉬운 방식입니다.

추가로, 기능을 하나 제작할 때마다 PHP에 JSON 데이터 작성하고, 마크업을 진행한 뒤, TypeScript에서 다시 그대로 DOM을 그리는 코드를 작성하고, 이벤트들을 바인딩해줘야 합니다.
당연히 PHP가 렌더링했을 때 동작할 작업과, JSON 데이터를 통해 클라이언트에서 동작할 작업을 두 번 작성해야하는 스트레스도 있습니다.

물론 이렇게 단점만 있는 것처럼 말하기에는 워드프레스라는 CMS가 제공해주는 편리함 아래에서 이것저것 다양한 시도를 편하게 해볼 수 있었던 건 분명한 사실이긴 합니다만, 제 시간이 갈수록 소중한 자원이 되어가는 시점에서 이런 방식을 지속하기엔 무리가 있었습니다.

기능과 성능의 한계

별도 페이지가 필요한 경우에는 사실 그냥 빈 페이지를 만들어두고 별도로 정적 파일들 배포해두면 그만이지만, 코어 기능을 수정할 땐 한계가 많았습니다.
일례로, 외부 링크를 새 탭으로 여는 기능처럼 포스트의 내용을 수정하는 기능을 추가하려면, 글을 쓸 때마다 그 부분을 신경을 쓰거나, 포스트를 렌더링하는 로직을 수정해야 했습니다.
예시로 든 기능은 단순해서 별 게 없지만, Syntax Highlighter 같은 경우는 PHP로 제작된 마음에 드는 모듈도 없거니와, 해당 작업을 포스트를 렌더링할 때마다 동적으로 진행하는 건 불필요하게 너무 큰 비용이 발생하는 작업입니다 - 그래서 결국 vscode에서 html을 복사하는 꽤 불편한 방식으로 글을 등록 중이었습니다.

실증

이런 경험들이 쌓이다보면 결국 '내다 버리고 싶다'는 감정과 '그래도 이렇게 고생하면서 이런 유일무이한 것들을 만들어왔는데'하는 감정이 뒤섞여서 여러모로 번뇌가 깊어지기 마련입니다.
'새 글 열심히 쓰려면 새 블로그가 있어야지'(물론 아무 상관 없습니다)라는 명목과, '그래도 개발잔데 블로그 정도는 직접 만들어 봐야지'(당연히 이것도 아무 상관 없습니다, 그냥 플랫폼 쓰시는 게 제일 편합니다)라는 명목으로 블로그를 마이그레이션하게 되었습니다.

요구사항 정리

정리하다보니 갈수록 늘어나서 엄청 정리한 게 이만큼입니다.
지금까지 왜 매번 실패했는지가 아주 잘 보이는 대목인데...아무튼 여기 적힌 사항들은 포기도 못 하고, 지키기 어렵지 않은 요구사항들이라고 생각했습니다.

기술 스택 선택

항상 여기서 고민이 정말 많았습니다.

먼저 DB를 쓰느냐, 파일로 관리하느냐부터가 문제입니다.
일단 DB로 관리하는 건 경험해보기도 했고, 굳이 블로그에 쓰는 글을 DB로 관리할 필요는 없다고 생각했습니다.
뭐 일단 Markdown으로 작성하면 나중에 마음이 바뀌어 DB로 전환하는 것도 다시 금방 할 테니, 파일로 관리하기로 했습니다.

이러면 댓글 기능이 문제긴 한데, 일단 slug을 key로 쓰면 글 주소를 잘 바꾸진 않으니, 큰 문제가 없지 않을까 싶었습니다.


이제 마크다운을 어떻게 html로 뿌려줄지를 고민해야 합니다.

요즘 Rust에 조금 흥미를 느끼고 있어서 어감이 좀 그렇긴 하지만...zola같은 것도 뒤적여봤는데, 웹사이트에 적힌 것처럼 정말 blazing fast하긴 합니다.
이런 static site builder들은 많고, 사실 이런 걸 쓰는 게 제일 편한 길이라는 건 모르는 사람이 없을 겁니다.

근데 마음 한구석에서 만들고자 하는 욕구를 치울 수가 없었습니다. ROI도 안 나오는 미련한 행동이긴 하지만, 이럴 때 쓰라고 '낭만'이라는 좋은 포장지가 이는 거 아니겠습니까.

Next.js로 작업하는 건 너무 뻔해서 안 하고 싶었는데, App Router랑 친해지기도 할 겸, 이런저런 PoC 간단하게 진행할 환경 만드는 겸, 이것저것 겸해서 Next.js로 결정했습니다.

전환 과정

상술했듯 기존에도 몇 번 PoC까지만 진행하고 접은 전적이 있기에, 과정 자체가 필연적으로 힘들 것으로 예상했습니다.
그래서 일단 두 가지 장치를 마련했습니다.

  1. 우리는 본능적으로 매몰 비용이 발생되는 것을 싫어합니다. 일단 데이터 마이그레이션같은 작업부터 해두면, 이게 아까워서라도 마무리할 것이라 생각했습니다.
  2. 주변에 이런 걸 한다는 걸 떠벌리고 다녔습니다. 이러면 '아 중간에 포기했어요'라는 말을 하기 창피해서라도 마무리하지 않을까 싶었습니다.
    • 고등학생 때 영어 수능특강에서 'Throwing a knapsack over the wall'이라는 표현을 본 기억이 문득 떠오르네요.

포스팅 데이터 이전

사실 Next.js로 정적인 블로그를 생성하는 것은 그리 어렵지 않습니다.

포스트 316 개 댓글 2,137 개

이렇게 달고 가야할 데이터들이 없다는 가정 하에서입니다...

import {
    existsSync,
    mkdirSync,
    writeFileSync,
    createWriteStream,
} from "fs";
import { dirname } from "path";
import { JSDOM } from "jsdom";
import { NodeHtmlMarkdown } from "node-html-markdown";
import axios from "axios";
 
const DANGEROUS_TAGS = [
    "script",
    "iframe",
    "object",
    "embed",
    "form",
    "input",
    "textarea",
    "video",
    "audio",
    "style",
    "link",
    "meta",
    "noscript",
];
 
const hasDangerousTags = (html) => {
    for (const tag of DANGEROUS_TAGS) {
        if (html.includes(`<${tag}`)) {
            console.log(tag);
            return true;
        }
    }
 
    return false;
};
 
const category = "notice";
 
const response = await fetch(`https://marshallku.com/${category}?json=1`);
const {
    paging: { max },
} = await response.json();
 
if (!existsSync(`posts/${category}`)) {
    mkdirSync(`posts/${category}`);
}
 
const nhm = new NodeHtmlMarkdown();
 
const downloadFile = async (url, path) => {
    if (url.includes("lh3.googleusercontent.com")) {
        return;
    }
 
    const downloadPath = `public/images/${dirname(path)}`;
 
    if (!existsSync(downloadPath)) {
        mkdirSync(downloadPath, { recursive: true });
    }
 
    if (existsSync(`public/images/${path}`)) {
        return;
    }
 
    const response = await axios({
        url,
        method: "GET",
        responseType: "stream",
    });
    const writer = createWriteStream(`public/images/${path}`);
 
    response.data.pipe(writer);
 
    return new Promise((resolve, reject) => {
        writer.on("finish", resolve);
        writer.on("error", reject);
    });
};
 
for (let i = 1; i <= max; ++i) {
    const response = await fetch(
        `https://marshallku.com/${category}/page/${i}?json=1`
    );
    const data = await response.json();
 
    for (const post of data.list) {
        const response = await fetch(`${post.uri}?json=1`);
        const slug = decodeURIComponent(post.uri.split("/").pop());
        const data = await response.json();
 
        if (hasDangerousTags(data.body)) {
            console.log(post.uri);
        }
 
        const {
            window: { document },
        } = new JSDOM(data.body);
 
        document.querySelectorAll("code.codeblock").forEach((code) => {
            const parsed = `<pre><code>${code.innerHTML
                .replace(/<div>/g, "<span>")
                .replace(/<\/div>/g, "</span><br>")}</code></pre>`;
 
            code.outerHTML = parsed;
        });
 
        document.querySelectorAll("video").forEach((video) => {
            const src = video.getAttribute("src");
 
            if (!src.includes("marshallku.com")) {
                return;
            }
 
            downloadFile(
                src,
                src.replace("https://marshallku.com/wp-content/uploads/", "")
            );
        });
 
        document.querySelectorAll("img").forEach((img) => {
            const src = (
                img.getAttribute("data-src") || img.getAttribute("src")
            )
                .replace(/(-\d+x\d+)(\.[a-zA-Z]+)$/g, "$2")
                .replace(/=w\d+$/g, "");
 
            downloadFile(
                src,
                src.replace("https://marshallku.com/wp-content/uploads/", "")
            );
 
            img.setAttribute("src", src);
        });
 
        writeFileSync(
            `posts/${category}/${slug}.mdx`,
            `---
title: ${data.title}
description: ${data.desc.replace(/...$/g, "")}
date:
  posted: ${new Date(data.date.posted).toISOString()}${
                data.date.modified
                    ? `\n  modified: ${new Date(
                          data.date.modified
                      ).toISOString()}`
                    : ""
            }${
                data.tags.length
                    ? `\ntags:\n${data.tags
                          .map((tag) => `  - ${tag}`)
                          .join("\n")}`
                    : ""
            }
coverImage: ${data.thumbnail.src}
ogImage: ${data.thumbnail.src}
---
 
${nhm.translate(document.body.innerHTML).replace(/ /g, " ")}`,
            "utf-8"
        );
 
        downloadFile(
            data.thumbnail.src,
            data.thumbnail.src.replace(
                "https://marshallku.com/wp-content/uploads/",
                ""
            )
        );
    }
}

DB를 덤프해도 되긴 할텐데, 어차피 이러나저러나 마크다운으로 변환해야 해서 그냥 기존에 만들어둔 JSON 데이터를 활용했습니다.

Next.js로 전환하는 데 2주가량이 소요되었는데, 그 중 1주 이상을 이 스크립트에 있는 버그 수정하고 데이터 다시 생성하고 수동으로 봐야하는 영역들 작업하는 데 소비했습니다.
심지어 codeblock은 상술했듯 vsc가 렌더링하는 형식 그대로 html로 복사해서 붙여둬서 여러모로 불편한 작업이었습니다.
막연하게 innerText를 사용하면 될 줄 알았는데, Node.js에서 구현된 DOM Parser 중 innerText를 구현한 게 없어서 innerHTML을 읽고 정규식으로 치환하고 깨지는 줄바꿈은 다시 전체 검색으로 치환했습니다. 또, 당연히 언어들이 하나도 지정이 안 되어 있어서, 하나하나 다 지정해줘야 했습니다.

수동으로 오래전에 쓴 글들 하나하나 읽어보는데 우주 쓰레기 같은 글이 참 많아서 얘네를 그냥 지워버릴까 싶기도 했지만, 그래도 그동안의 기록이라는 것이 아까워서 그냥 다 옮겼습니다.

역시 예상대로 여기까지 오니까, 이건 이제 실패해서는 안 되는 작업이 되었습니다.

블로그 제작

글을 보여줄 페이지, 글 목록을 보여줄 페이지 정도만 있으면 기본적인 블로그를 만들 수 있습니다.

파일 탐색

// 하위 폴더까지 탐색하는 함수 - 폴더 하위 폴더들이 없다면 필요 없는 함수입니다.
export function walk(dir: string, cb: (path: string) => void) {
    const files = readdirSync(dir);
 
    for (let i = 0, max = files.length; i < max; ++i) {
        const file = join(dir, files[i]);
        const stat = lstatSync(file);
 
        if (stat.isDirectory()) {
            walk(file, cb);
        } else {
            cb(file);
        }
    }
}
 
// mdx 파일 경로들을 반환하는 함수
export function getPostSlugs(subDirectory?: string) {
    const files: string[] = [];
    const fullPath = subDirectory ? join(POSTS_DIRECTORY, subDirectory) : POSTS_DIRECTORY;
 
    if (!existsSync(fullPath)) {
        return [];
    }
 
    walk(fullPath, (path) => {
        if (path.endsWith(".mdx")) {
            files.push(path);
        }
    });
 
    return files.map((file) => file.replace(POSTS_DIRECTORY, "").replace(/\.mdx$/, ""));
}
 
// mdx 파일을 파싱해서 반환하는 함수
export function getPostBySlug(slug: string): Post | undefined {
    const fullPath = join(POSTS_DIRECTORY, `${slug}.mdx`);
 
    if (!existsSync(fullPath)) {
        return;
    }
 
    const fileContents = readFileSync(fullPath, "utf8");
    const { data, content } = matter(fileContents);
 
    return {
        data: {
            title: data.title.replace(/\\/g, ""),
            // 워드프레스 쓰면서 excerpt란 표현이 항상 안 와닿았는데, 드디어 description을 쓸 수 있게 되었습니다.
            description: data.description,
            date: {
                posted: new Date(data.date.posted),
                modified: data.date.modified ? new Date(data.date.modified) : undefined,
            },
            tags: data.tags,
            coverImage: data.coverImage,
            ogImage: data.ogImage,
        },
        content,
        slug,
        category: parse(slug).dir,
    };
}
 
// 전체 글을 조회하는 함수
export function getPosts(category?: string) {
    return getPostSlugs(category)
        .map((slug) => getPostBySlug(slug)!)
        .sort((a, b) => {
            if (a.data.date.posted > b.data.date.posted) {
                return -1;
            }
 
            if (a.data.date.posted < b.data.date.posted) {
                return 1;
            }
 
            return 0;
        });
}

이정도면 글 목록과 글 내용을 조회할 수 있습니다.

getPosts()를 통해 목록 페이지를 제작하고, getPostBySlug()을 통해 상세 페이지를 제작하면 됩니다.

[category]/[...slug]/page.tsx

이제 여러 depth를 갖는 카테고리가 하나밖에 안 남았지만, /web/tips/my-awesome-title같은 주소 구조를 사용해왔기에, slug은 배열로 받아서 처리했습니다.

export async function generateStaticParams() {
    return getPostSlugs().map((slug) => ({
        category: slug.slice(1).split("/")[0],
        slug: slug.slice(1).split("/").slice(1),
    }));
}

getPostSlugs()가 반환한 mdx 파일 경로들을 적절히 가공해주면 build 시 생성해야할 페이지들을 정의할 수 있습니다.

import { MDXRemote } from "next-mdx-remote/rsc";
 
// ...
 
export default async function PostPage({ params: { category, slug } }: PostProps) {
    // ...
    return (
        <MDXRemote
            source={post.content}
            options={{
                mdxOptions: {
                    remarkPlugins: [remarkToc, remarkGfm, remarkSlug, remarkUnwrapImages],
                    rehypePlugins: [
                        rehypeAutolinkHeadings,
                        [
                            // @ts-expect-error
                            rehypePrettyCode,
                            {
                                theme: {
                                    dark: "one-dark-pro",
                                    light: "solarized-light",
                                },
                                keepBackground: true,
                            },
                        ],
                        setImageMetaData,
                        makeIframeResponsive,
                    ],
                },
            }}
            components={MDXComponents}
        />
    );
}

마크다운을 적당히 가공해서 렌더링해주면 됩니다.
MDXRemote에서 react server component도 제공해주기에, 신경 쓸 게 setImageMetaData같은 rehype plugin을 제작해 이미지 너비 / 높이 값 가져오는 것 정도를 제외하면 없었습니다.

import { imageSize } from "image-size";
 
// ...
 
async function addImageMetaData(node: ImageNode) {
    const {
        properties: { src },
    } = node;
 
    if (isExternalImage(src)) {
        return;
    }
 
    try {
        const dimensions = imageSize(`public${decodeURIComponent(src)}`);
 
        if (dimensions) {
            node.properties.width = dimensions.width;
            node.properties.height = dimensions.height;
        }
    } catch (err) {
        console.error(`Failed to get image dimensions for ${src}`);
    }
}
 
// ...
 
export function setImageMetaData(this: Processor) {
    return async function transformer(tree: Node, _: VFile) {
        const imageNodes: ImageNode[] = [];
 
        visit(tree, "element", (node: ImageNode) => {
            if (node.type === "element" && node.tagName === "img") {
                imageNodes.push(node);
            }
        });
 
        for (const node of imageNodes) {
            await addImageMetaData(node);
        }
 
        return tree;
    };
}

이렇게 각 element들을 순회하면서, 이미지 노드들의 너비와 높이를 가져와서 attribute을 추가해주면 됩니다.
PHP 쓰던 시절에는 - 물론 이미지 까지도 전부 CMS가 신경써주긴 했지만 - 이렇게 포스트를 수정하려면 preg_match_all로 정규식을 사용해 처리했어야 하는데, 훨씬 직관적이고 좀 더 신뢰할 수 있는 방식으로 작업할 수 있게 되었습니다.

그리고 드디어...드디어...!! 서버에 syntax highlighter를 내장할 수 있게 되었고, 무려 theme toggle도 가능하게 되었습니다.


막간을 이용해 멸종 위기종인 (해 뜬 시간에만)light theme 유저로서 solarized-light theme 추천해봅니다.

[category]/page/[index]/page.tsx

export function getGroupedPostByCategory() {
    const posts = getPosts();
    const groupedPosts: Record<string, typeof posts> = {};
 
    for (let i = 0, max = posts.length; i < max; ++i) {
        const post = posts[i];
        const category = post.category.split("/")[1];
 
        if (!groupedPosts[category]) {
            groupedPosts[category] = [];
        }
 
        groupedPosts[category].push(post);
    }
 
    return groupedPosts;
}
 
export function generateStaticParams() {
    const groupedPosts = getGroupedPostByCategory();
 
    return Object.entries(groupedPosts).flatMap(([category, posts]) =>
        Array.from({ length: Math.ceil(posts.length / PAGE_SIZE) }, (_, i) => ({
            params: {
                category,
                index: `${i + 1}`,
            },
        })),
    );
}

전체 post를 가져와서, 카테고리별로 그룹핑만 하면 build시 생성해야 할 페이지들을 정의할 수 있습니다.

페이지 계산할 때마다 1씩 더하고 빼는는 게 귀찮기도 하거니와, 기술 블로그인데 첫 페이지는 0페이지가 맞는 거 아닐까 하는 불건전한 생각을 잠깐 했는데, 아무래도 너무 파격적 행보 같아 일단은 참았습니다.

기존 시스템 호환성 유지

  1. 포스트 안에 js / css 등 작성한 코드가 있는 경우 동작 유지하기
  2. 워드프레스에서 처리하거나, 소스에서 직접 처리하는 301 리다이렉트들 유지하기
  3. js / css 따로 업로드해서 사용하는 페이지들 유지하기
  4. 외부에서 사용하는 api 유지하기

정도 작업을 진행해야 전환할 수 있지 않을까 싶었는데, 3번은 둘러보니 크게 급한 건은 없어 보여서 일단 필수 작업 목록에 포함시키진 않았습니다.

포스트 안에 js / css 등 작성한 코드가 있는 경우 동작 유지하기

<style dangerouslySetInnerHTML={{ __html: ".form{display:flex;max-width:700px;margin:0 auto}.form__input{width:100%;border:1px solid #aaa;border-radius:30px;outline:0;padding:.2rem .5rem}.form__submit{display:inline-block;margin-left:10px;padding:.2rem .5rem;border:none;border-radius:4px;box-shadow:#aaa 0 0 0 2px inset;flex-shrink:0;text-decoration:none;text-align:center;cursor:pointer}@media (any-hover: hover){.form__submit{transition:color .2s cubic-bezier(.39,.5,.15,1.36),box-shadow .2s cubic-bezier(.39,.5,.15,1.36)}.form__submit:hover{color:#fff;box-shadow:#aaa 0 0 0 40px inset}}.result{position:relative;display:inline-block;width:clamp(0px,100%,calc(1280px + 2rem));margin:1rem auto 0;padding:1rem;border:1px solid rgba(0,0,0,.35);text-align:center}.result__title{position:absolute;top:0;left:50%;padding:0 10px;background-color:var(--bg-color);transform:translate3d(-50%,-50%,0)}.result__thumbnail{display:flex;flex-direction:column;gap:1rem}.result__thumbnail img{max-width:100%}" }} />
<form className="form">
    <input type="text" className="form__input" name="uri" placeholder="URI of Youtube video" />
    <button type="submit" className="form__submit">Export</button>
</form>
<div className="result" style={{ display: "none" }}>
    <div className="result__title">Thumbnails</div>
    <div className="result__thumbnail"></div>
</div>
<script src="/youtube-thumbnail-extractor/v2.js" defer />

이건 사실 mdx를 쓸 수 있으니, 적당히 잘 syntax만 변환해서 가져오기만 하면 됩니다.

워드프레스에서 처리하거나, 소스에서 직접 처리하는 301 리다이렉트들 유지하기

/** @type {import('next').NextConfig} */
const nextConfig = {
    async redirects() {
        return [
            {
                source: "/web/tips/:path*",
                destination: "/dev/:path*",
                permanent: true,
            },
            {
                source: "/web/log/:path*",
                destination: "/dev/:path*",
                permanent: true,
            },
            {
                source: "/web/:path*",
                destination: "/dev/:path*",
                permanent: true,
            },
            {
                source: "/rss",
                destination: "/feed.xml",
                permanent: true,
            },
        ];
    },
};

next config 파일에서 오래된 카테고리 주소들을 새 주소로 리다이렉트시키는 작업을 진행했습니다.

server {
    # ...
 
    if ($request_uri ~* "/minecraft/(.*)") {
        return 301 https://mc.marshallku.com/$1;
    }
}

나머지 작업은 nginx에서 처리하도록 했습니다.
사실 nginx config에서 관리하면 관리하기가 좀 귀찮아지긴 합니다만, 저런 영역 코드들은 한 번 작성되고 수정된 적이 없어서 크게 상관없지 싶습니다.

첫 번째 커밋

무려 git으로 소스코드 관리 시작하는 시점부터 지금까지 단 한 번도 건드리지 않은 역사와 전통이 살아 숨 쉬는 코드입니다.

RSS 피드 생성

new Response(
    `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
>
<channel>
<title>${SITE_NAME}</title>
<description>${SITE_DESCRIPTION}</description>
<language>ko-KR</language>
<atom:link href="${host}/feed" rel="self" type="application/rss+xml" />
<link>${host}</link>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
${formattedPosts
.map(
    ({ title, link, pubDate, category, tags, description, content }) => `    <item>
    <title>${title}</title>
    <link>${link}</link>
    <dc:creator><![CDATA[Marshall K]]></dc:creator>
    <pubDate>${pubDate}</pubDate>
    <category><![CDATA[${category}]]></category>${
        tags ? `\n${tags.map((tag) => `        <category><![CDATA[${tag}]]></category>`).join("\n")}` : ""
    }
    <guid isPermaLink="false">${link}</guid>
    <description><![CDATA[${description}]]></description>
    <content:encoded><![CDATA[${content}]]></content:encoded>
</item>`,
)
.join("\n")}
</channel>
</rss>
`,
    {
        headers: {
            "Content-Type": "application/rss+xml; charset=UTF-8",
        },
    },
);

버릇 남 못 주고 또 이러고 있습니다.
워드프레스에서 만들어주는 피드 보고 그대로 옮겨왔습니다.

const renderer = new marked.Renderer();
 
renderer.link = (href, title, text) => {
    return `<a href="${href}" title="${title}" target="_blank" rel="noopener noreferrer">${text}</a>`;
};
 
marked.setOptions({
    gfm: true,
    breaks: true,
    renderer,
});

개인적으론 MDXRemote로 본문을 렌더링한 결과를 그대로 보여주고 싶은데, Server component가 반환한 element를 string으로 변환할 수가 없어서 별도의 렌더러를 사용했습니다.
저도 RSS 피드는 구독만 해두고 보통 알림 오면 주소로 이동해서 보는 편이라 크게 신경을 안 썼지만, 개선이 필요하지 않을까 싶긴 합니다.

새 서비스로 트래픽 이전

이제 배포만 진행하면 됩니다.

server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name marshallku.com www.marshallku.com;
 
        # ...
 
        location / {
            #try_files $uri $uri/ /index.php?$args;
            #index index.php;
 
            proxy_http_version 1.1;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_pass http://0.0.0.0:4200;
        }
 
        # ...
 
        location ~* \.(png|jpg|jpeg|gif|ico|svg|ttf|woff|woff2|webm|mp4|mp3|eot|otf|json)$ {
            expires 1y;
            etag off;
            add_header Cache-Control "public, no-transform";
        }
}

고독한 주말, 떨리는 마음으로 운영 서버에 ssh 접속을 진행합니다.

docker compose로 서비스를 띄우고, 서버가 정상적으로 뜬 것을 확인한 뒤, sudo vim /etc/nginx/sites-available/marshallku.com을 입력합니다.
참 별거 없는데, 괜시리 떨리는 마음을 진정시키려 물 한 잔 마시고, 손도 씻은 다음, index.php로 보내는 부분을 주석 처리하고 로컬 4200번 포트로 리버스 프록시를 걸어줍니다.

깨진 이미지

nginx 설정 확인하고 재시작한 뒤 접속해보니, 화면이 저 모양입니다.
황급히 nginx 설정 다시 열어서 정적 파일들 처리하는 영역을 전부 제거했습니다.

CDN 서버 구축

next/images는 예전부터 메모리관련 문제를 일으키기도 하고, 정적 파일은 별도로 관리하는 케이스가 대부분에었기에 아직 한 번도 프로덕션에서 제대로 사용해보지 않았습니다.
이번에 작업하며 처음으로 포스트를 출력하는 화면에서 사용했는데, 여전히 잡다한 문제가 많았습니다.

배포를 좀 자주 하면서 이미지가 깨지기도 하다 보니, 급하게 작업을 진행했습니다.

정도의 요구사항을 가지고 시작했습니다.

from flask import Flask, request, send_from_directory, abort
import requests
from PIL import Image
import os
import re
import magic
 
app = Flask(__name__)
 
CDN_ROOT = "cdn_root"
ORIGINAL_SERVER = "https://marshallku.com"
 
 
@app.route('/files/<path:filename>')
def serve_static_file(filename):
    # Validate file extension
    if not filename.endswith(('css', 'js', 'ico', 'json', 'woff2', 'woff', 'svg', 'ico')):
        abort(404)
 
    file_path = os.path.join(CDN_ROOT, 'files', filename)
    if not os.path.exists(file_path):
        # Fetch file from original server
        response = requests.get(f"{ORIGINAL_SERVER}/{filename}")
        if response.status_code == 200:
            os.makedirs(os.path.dirname(file_path), exist_ok=True)
            with open(file_path, 'wb') as f:
                f.write(response.content)
        else:
            abort(404)
 
    return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path))
 
 
@app.route('/images/<path:filename>')
def serve_image(filename):
    if os.path.exists(os.path.join(CDN_ROOT, 'images', filename)):
        print("CACHE HIT!")
        return send_from_directory(os.path.join(CDN_ROOT, 'images'), filename)
 
    original_filename = filename
    resize_width = re.search(r'=w(\d+)$', filename)
    width = 0
 
    if resize_width:
        original_filename = original_filename.replace(resize_width.group(), '')
        width = resize_width.group(1)
 
    convert_to_webp = original_filename.endswith('.webp')
 
    if convert_to_webp:
        original_filename = original_filename.replace('.webp', '')
 
    file_path = os.path.join(CDN_ROOT, 'images', filename)
    original_file_path = os.path.join(CDN_ROOT, 'images', original_filename)
    if not os.path.exists(original_file_path):
        # Fetch image from original server
        response = requests.get(f"{ORIGINAL_SERVER}/{original_filename}")
        if response.status_code == 200:
            os.makedirs(os.path.dirname(original_file_path), exist_ok=True)
            with open(original_file_path, 'wb') as f:
                f.write(response.content)
        else:
            abort(404)
 
    if width or convert_to_webp:
        img = Image.open(original_file_path)
        if width:
            mime_magic = magic.Magic(mime=True)
            mimetype = mime_magic.from_file(original_file_path)
 
            w_percent = (int(width)/float(img.size[0]))
            h_size = int((float(img.size[1])*float(w_percent)))
            img = img.resize((int(width), h_size), Image.ANTIALIAS)
            img.save(file_path, mimetype.split('/')[1], quality=90)
            return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), mimetype=mimetype)
        if convert_to_webp:
            img.save(file_path, 'WEBP', quality=90)
            return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), mimetype='image/webp')
 
    return send_from_directory(os.path.dirname(original_file_path), os.path.basename(original_file_path))
 
 
if __name__ == '__main__':
    app.run(port=41890)

일전에 캐시 서버를 구축할 땐 php를 사용했는데, 리사이징같은 기능이 필요하기도 하거니와, 이미지를 다루는 데는 python이 더 편리하다고 생각해서 python으로 작업했습니다.

급하게 작업하는 건 끝났으니 Rust로 다시 작성하고 있는데, 간단하게 개발 서버에서만 테스트해봐도 정적 파일 서빙하는 속도가 Rust가 많이 억울하게 측정해도 3배 이상 빠릅니다.

server {
    # ...
    location /files/ {
        try_files $uri /cdn_root/files/$uri @backend;
    }
 
    location /images/ {
        try_files $uri /cdn_root/images/$uri @backend;
    }
 
    location @backend {
        proxy_pass http://localhost:4200;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

그러고 nginx에선 파일이 존재하지 않을 때 리버스 프록시 서버로 보내도록 설정했습니다.

FROM python:3.10-slim as base
 
WORKDIR /app
 
RUN apt-get update && apt-get install -y magic libmagic-dev
 
COPY requirements.txt /app/requirements.txt
 
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gunicorn
 
FROM base as runner
 
COPY ./app.py /app/app.py
 
EXPOSE 41890
 
CMD ["gunicorn", "-b", "0.0.0.0:41890", "app:app"]

gunicorn으로 서버를 띄우고, nginx에서 리버스 프록시를 걸어주었습니다.

이러고 next config에서 assetPrefix를 추가해 정적 파일들이 해당 서버로 올라가도록 하고

interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
    forceSize?: number;
}
 
const IMAGE_SIZE = [480, 600, 860, 1180];
 
function Image({ src, alt, width, height, forceSize, ...rest }: ImageProps) {
    return (
        <img
            src={`${process.env.NEXT_PUBLIC_CDN_URL}${src}${
                forceSize && process.env.NEXT_PUBLIC_CDN_URL !== "" ? `=w${forceSize}` : ""
            }`}
            srcSet={
                width && height && process.env.NEXT_PUBLIC_CDN_URL !== ""
                    ? IMAGE_SIZE.filter((size) => size < Number(width))
                          .map((size) => `${process.env.NEXT_PUBLIC_CDN_URL}${src}=w${size} ${size}w`)
                          .join(", ")
                    : undefined
            }
            alt={alt || ""}
            width={width}
            height={height}
            loading="lazy"
            {...rest}
        />
    );
}

이렇게 이미지 컴포넌트를 만들어 next/images를 대체했습니다.

Performance 95, FCP 2.0s, LCP 2.7s

이러고 글 목록에서 이미지 크기를 조정해주는 작업 정도만 진행해도, LCP를 2.7초로 5초 이상 단축시킬 수 있었습니다.

깨진 이미지

파일들을 클라이언트단에서 fetch로 가져오기도 하니, 이 꼴을 다시 한 번 보고 싶은 게 아니라면 CORS 헤더도 설정해줘야 합니다.

전환 결과 및 우려 사항

성능 개선

서버 응답 시간 비교, 현재 18.40ms, 기존 107.85ms

글 목록을 조회하는 데 걸리는 시간이 100ms가량에서 20ms가량으로 단축되었습니다. 솔직히 2분 정도 걸려서 파일 전부 빌드하고, 그 파일 꺼내서 서빙하는 건데 20ms면 태평양까지 건너다닐 수 있을 정도로 너무 느린 거 아닌가 싶긴 하지만...줄어든 수치만 보자면 눈물을 흘리며 기립박수를 치지 않을 수 없습니다.

더욱 간단한 커스터마이징

React Server Component가 주는 제약이 있긴 하지만, 본문에서 소개했듯 rehype을 통해 간단하게 포스트 내용을 수정하는 등 훨씬 더 세밀하게 커스터마이징을 할 수 있게 되었습니다.

function simplifyAspectRatio(width: number, height: number): { width: number; height: number } {
    const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
    const factor = gcd(width, height);
    return {
        width: width / factor,
        height: height / factor,
    };
}
 
export function makeIframeResponsive(this: Processor) {
    return async function transformer(tree: Node, _: VFile) {
        visit(tree, "mdxJsxFlowElement", (node: IframeNode, index, parent: Parent) => {
            if (node.name !== "iframe") {
                return;
            }
 
            const width = Number(node.attributes.find((attr) => attr.name === "width")?.value || 0);
            const height = Number(node.attributes.find((attr) => attr.name === "height")?.value || 0);
            let className = "iframe-container";
 
            if (width && height) {
                const ratio = simplifyAspectRatio(width, height);
                className += ` iframe-container--ratio-${ratio.width}-${ratio.height}`;
            }
 
            const wrapperNode = {
                type: "mdxJsxFlowElement",
                name: "div",
                attributes: [{ type: "mdxJsxAttribute", name: "className", value: className }],
                children: [] as IframeNode[],
            };
 
            if (parent && Array.isArray(parent.children)) {
                parent.children.splice(index, 1, wrapperNode);
                wrapperNode.children.push(node);
            }
        });
    };
}

다른 예시로 이렇게 iframe을 반응형으로 만드는 것도, 기존엔 이 글을 읽고 계신 여러분의 기기가 노력해서 진행해야 했는데, 이제는 제가 합니다.

{
    "index": 2,
    "hidden": false,
    "name": "잡담",
    "icon": "chat-bubble",
    "color": "#ffdb4d"
}

카테고리에 색상이나 아이콘을 추가하는 작업이나 특정 카테고리를 숨기는 작업도, 기존엔 이런 거 하자고 DB 테이블을 만들자니 과한 투자라 여기저기 하드 코딩하는 방식으로 진행했었는데 이제는 간단하게 할 수 있게 되었습니다.

추가로 Frontend application을 Turborepo로 구성해서, 아이콘이나 공통 유틸리티도 간단하게 작업할 수 있게 되었습니다.

더욱 쉬운 플랫폼 전환

글을 마크다운 형태로 전부 긁어오면서, html 마구잡이로 작성해둔 영역이나, codeblock 등을 훨씬 기계가 알아보기 쉽게 변환했기에 변덕을 부리기가 훨씬 쉬워졌습니다.

더해서 이런 삽질들이 한 번이 어렵지, 두 번부터는 쉽습니다.

포스팅 작성 경험 개선

이런 변방에서 운영하는 블로그도 일 평균 2,000 ~ 3,000회 가량 봇들이 공격을 시도합니다.거이걸 신경쓰기 싫어서 white list(allow list)로 IP들을 관리해 한정된 환경에서만 로그인할이수 있도록 해뒀습니다.
근데 이제 파일로 관리하니, SSH등을 통해 신뢰할 수 있는 기기는 외부에서 접속해 글을 바로바로 수정할 수 있게 할 수 있게 되었습니다.

그리고 wysiwyg 에디터를 쓰는 것보다, 마크다운만으로 작성하는 게 훨씬 편하기도 합니다.

사실 아예 개선만 있다고 하기엔, 이미지 첨부가 조금 불편해지긴 했습니다.


그리고 여기까진 생각을 못 했었는데, Copilot이 계속 글 같이 작성하자고 이것저것 추천해줍니다.

Copilot이 추천해준 포스트

뭔가 뭔가한 게 이 친구도 고민이 많나봅니다.

운영 난도 증가

지금까지 편하다고 해놓고 이게 무슨 소린가 싶지만...CMS 없이 혼자 관리해야 하는 영역이 늘어났다는 점, 정적 파일을 관리하기 위한 서버를 따로 관리해야 한다는 점 등 난도를 증가시키는 요소들이 있습니다.
지금이야 신 난다고 이것저것 벌려뒀는데, 경험상 이게 운영 업무들로 말미암은 스트레스로 변환되는 데에는 그리 오랜 시간이 걸리지 않습니다.

최대한 containerization 잘 해두고, 자동화 잘 해둬서 제가 신경 쓸 영역을 최대한 잘 줄여둬야 하지 않을까 싶습니다.

예정 작업

파이프라인 개선

Multi stage 빌드같은 아주 기본적인 작업들은 되어있지만, Caching 등 작업이 빠져있습니다.
보다 빠른 빌드를 위해 이런저런 작업들을 진행할 예정입니다.

추가로, Lighthouse CI나, Sonarqube도 도입해볼 예정입니다.

무중단 배포

아주 짧긴 하지만, docker compose로 새 컨테이너를 띄우는 동안 1초 가량 순단 현상이 발생합니다.

댓글 기능 추가

운영 난도 증가에서 언급했던 것처럼 이런 걸 별도로 또 만들게 되면 그만큼 인생이 힘들어지긴 하겠지만...
제가 댓글 기능이 없는 블로그들을 별로 안 좋아하기도 하고, 앞서 요구사항 정리에서 정리했듯 제 운명은 최대한 제 손에만 맡기고 싶기에 또 만들지 않을까 싶습니다.

기존이랑 다르게 패스워드도 달아서 수정이나 삭제도 할 수 있도록 해볼 생각인데, 개인정보는 일절 다룰 생각이 없어서 정말 말 그대로 비밀번호 정도만 추가되는 아주 취약한 형태로 제작해볼 생각입니다.

모니터링 강화

아직 L7에서밖에 모니터링을 진행하지 않고 있습니다.
기존에 Wordpress에 붙였던 Newrelic을 다시 붙일 예정입니다.

사실 사건사고가 제일 많이 터지는 게 전환하고 얼마 안 된 시점인데...빨리 진행하고자하는 욕심에 조금 앞서간듯 합니다.
이 시점엔 제가 자주 보기도 하고, 모든 포스트는 전수 검사 했고, 클라이언트에서 도는 코드가 많이 줄기도 해서 조금 신경을 덜 쓰기도 했습니다.

글 작성

글 주제 목록

물론 무엇보다 중요한 건, 저기 대충 구상만 해두고 안 적은 글들을 작성하는 게 아닐까 싶긴 합니다.

마무리

이 작업 진행하느라 2주 정도 출퇴근길 버스 안에서, 아침/점심 시간에 잠깐씩, 퇴근하고 나서 밤잠 줄여가며, 주말까지 정말 이런저런 시간들 다 끌어모아 진행했습니다.
힘들긴 했지만, 꽤 빠르게 결과를 낸 것 같아 만족스럽습니다.
막상 해보면 별 거 아닌데, 이게 뭐라고 4년 넘게 방치해뒀나 싶기도 하네요.

앞으로도 이런저런 작업들 진행하면서, 글 작성할 거리들 열심히 만들어보겠습니다.

Report an issue