블로그를 마이그레이션하면서 몇 가지 문제를 겪었는데 대부분 Gatsby 플러그인이 자동으로 해주던 기능이 사라져서 생긴 것들이었습니다.
이 글에서는 Next.js에서 MDX를 사용할 때 생기는 이미지 절대 경로 문제를 어떤 식으로 해결했는지 남겨보겠습니다.
포스트 디렉토리 구조
제 블로그의 포스트 디렉토리 구조는 아래와 같이 MDX와 MDX에서 사용하는 이미지를 한곳에 배치했습니다.
.└── contents└── handle-nextjs-mdx-image-path├── 0.png├── 1.png├── 2.png└── index.mdx
이런 구조를 사용했던 이유는 MDX에서 이미지를 불러올 때 ![](0.png)
와 같이 상대 경로로 손쉽게 사용할 수 있기 때문입니다. 기존에는 문제가 없었는데 마이그레이션 이후에 경로를 찾지 못하는 문제가 발생했습니다.
Next.js의 Image
를 사용하면 해결되는 걸로 알고 있지만 편의성이 떨어진다는 생각이 들어서 마크다운 문법을 유지하고 싶었습니다.
이미지를 부르지 못하는 이유
요청 URL을 자세히 보니 (root)/0.png
와 같이 절대 경로로 이미지를 요청하고 있었습니다. 상대 경로로 처리되길 바랐던 것과 달리 Next.js는 항상 절대 경로로 public
디렉토리에서 이미지를 찾고 있었습니다.
그럼 단순하게 아래와 같이 public
디렉토리에서 이미지를 관리하면 됐겠지만 그러고 싶지 않았습니다. 이미지를 삽입 할 때마다 디렉토리를 옮겨다니는 피곤한 과정을 거쳐야했고, ![](/handle-nextjs-mdx-image-path/0.png)
처럼 긴 이미지 주소를 작성해야했기 때문입니다.
.├── contents│ └── handle-nextjs-mdx-image-path│ └── index.mdx└── public└── handle-nextjs-mdx-image-path├── 0.png├── 1.png└── 2.png
그래서 방법을 찾던 중 비슷한 문제를 고민한 블로그를 발견했습니다. Next.js를 실행할 때 이미지들을 public
디렉토리에 복사한다는 아이디어가 적혀있었습니다.
좋은 아이디어를 얻어서 제 블로그에 맞게 구현해보기로 했습니다.
이미지 복사하기
이 부분은 스크립트를 이용하기로 했습니다.
이미지들을 public
디렉토리에 복사하는 단순한 스크립트인데 단순히 public
안에 복사하면 모든 이미지 이름을 다르게 지어야하기 때문에 public/images/[포스트 URL]
안에 복사되도록 했습니다.
import { access, constants, copyFile, mkdir, readdir, rm } from 'fs/promises';import path from 'path';const SOURCE_DIR = path.join(process.cwd(), 'contents');const TARGET_DIR = path.join(process.cwd(), 'public/images');const exists = async (path) => {try {await access(path, constants.F_OK);return true;} catch {return false;}};const copyImages = async () => {const entries = await readdir(SOURCE_DIR, { withFileTypes: true });const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);if (await exists(TARGET_DIR)) await rm(TARGET_DIR, { recursive: true });for await (const dir of dirs) {const sourceDirPath = path.resolve(path.join(SOURCE_DIR, dir));const targetDirPath = path.resolve(path.join(TARGET_DIR, dir));const entries = await readdir(sourceDirPath, { withFileTypes: true });const files = entries.filter((entry) => !entry.isDirectory()).map((entry) => entry.name);const imageFiles = files.filter((file) => /.(png|gif)$/.test(file));if (imageFiles.length === 0) continue;await mkdir(targetDirPath, { recursive: true });await Promise.all(imageFiles.map((imageFile) => {const sourceFilePath = path.resolve(path.join(sourceDirPath, imageFile));const targetFilePath = path.resolve(path.join(targetDirPath, imageFile));copyFile(sourceFilePath, targetFilePath);}));}};await copyImages();
작성한 스크립트를 실행해보면 public
디렉토리 안에 이미지들이 잘 복사된 걸 확인할 수 있습니다.
스크립트를 매번 실행하기 귀찮기 때문에 package.json
을 수정해줍니다.
"scripts": {"prebuild": "node ./scripts/pre-script.mjs","build": "next build","predev": "node ./scripts/pre-script.mjs","dev": "next dev",}
경로 개선하기
그런데 public/images/[포스트 URL]
안에 복사했기 때문에 MDX에서 ![](images/[포스트 URL]/0.png)
와 같이 작성해야되는 불편함은 여전히 있었습니다. ![](0.png)
과 같이 같은 디렉토리에 있는 이미지 이름만 입력해도 알아서 잘 찾아주면 얼마나 좋을까요.
간단한 remark
플러그인을 만들어서 빌드 단계에서 img
의 src
를 수정해보겠습니다. unist-util-visit을 이용하면 AST 트리에서 특정 타입의 노드를 탐색하고 수정할 수 있습니다.
여기서는 이미지를 수정해야하기 때문에 image
노드를 탐색해서 수정했습니다.
import { visit } from 'unist-util-visit';const remarkPublicImage = () => {return (tree, file) => {// file.history => [(root)/[포스트 URL]/index.mdx]const path = file.history.at(0).split('/').at(-2);visit(tree, 'image', (node) => {node.url = `/images/${path}/${node.url}`;});};};
만든 플러그인은 next.config.mjs
에 추가해줍니다.
import remarkPublicImage from './scripts/remark-public-image.mjs';const withMDX = createMDX({options: {...remarkPlugins: [remarkPublicImage],},});export default withMDX(nextConfig);
MDX 문법으로 작성만 했는데 어떻게 브라우저에 렌더링 되는걸까요?
제 블로그의 경우 @mdx-js/mdx
가 중간에서 변환해주고 있습니다.
간략하게 MDX -> MDAST(Markdown Abstract Syntax Tree) -> HAST(HTML Abstract Syntax Tree) -> JSX 순으로 변환됩니다.
플러그인들은 이런 변환 과정에 개입해서 추상 구문 트리를 조작 할 수 있습니다. 제가 플러그인을 만들면서 unist-util-visit
을 통해 src
를 수정 할 수 있었던 이유입니다.
이제 마무리되었습니다. 그런데 실행 시 public
디렉토리에 이미지들이 자동으로 생성되기 때문에 원격 저장소에 올릴 필요가 없습니다. .gitignore
에도 추가해줍니다.
# asset/public/images/*
이제 MDX와 이미지를 한 곳에서 관리하고, 경로도 간단하게 적을 수 있습니다.