이 블로그는 Obsidian에서 글을 쓰고 Next.js로 발행하는 구조예요. Obsidian에서 스크린샷을 붙여넣으면 ![[스크린샷.png]] 같은 위키링크 문법이 자동으로 삽입되는데, Obsidian 안에서는 이 문법으로 이미지가 잘 보입니다.
그런데 블로그에 올리고 나서 확인해보니, 이미지가 있어야 할 자리에 ![[스크린샷.png]]라는 텍스트만 덩그러니 남아 있었어요. 이미지가 4개 들어간 글 두 개를 발행하면서 이 문제를 본격적으로 마주했습니다.
처음에는 단순히 문법 차이 때문이라고 생각했는데, 파고 들어가 보니 문제가 두 겹으로 겹쳐 있었습니다.
- 문법 변환만으로는 부족하고, 이미지 경로도 함께 바꿔야 했음
- 해결 방식도 “원본 MDX를 건드릴 것인가, 렌더링 파이프라인에서 처리할 것인가”를 선택해야 했음
왜 Obsidian 이미지가 블로그에서 깨지는 걸까
MDX 렌더러(next-mdx-remote)는 표준 마크다운 이미지 문법()만 인식합니다. Obsidian의 ![[...]]는 Obsidian 전용 위키링크 문법이라, 표준 마크다운 파서(remark)가 이 패턴을 만나면 이미지로 인식하지 못하고 일반 텍스트 노드로 처리합니다.
Obsidian에서: ![[스크린샷.png]] → 이미지가 보임 ✅
블로그에서: ![[스크린샷.png]] → 텍스트로 출력 ❌
매번 ![[파일명.png]]를 으로 수동 변환할 수도 있겠지만, 이미지가 많아지면 실수가 생기고, 무엇보다 Obsidian에서 다시 편집할 때 이미지 미리보기가 깨져요. 글쓰기 도구의 편의성을 포기하고 싶지는 않았습니다.
그런데 문법을 자동 변환하면 끝나는 문제인가 싶었는데, 한 가지 더 걸리는 게 있었습니다.
문법만의 문제가 아니었다
문법을 바꿔도 이미지가 보이지 않을 수 있어요. Obsidian이 이미지를 저장하는 위치(content/ 디렉토리)는 Next.js에서 정적 파일로 서빙되지 않기 때문입니다. 웹에서 접근하려면 public/ 디렉토리에 이미지가 있어야 하거든요.
정리하면 두 가지 문제가 겹쳐 있었습니다.
| 문제 | 설명 |
|---|---|
| 문법 불일치 | ![[파일명.png]]는 remark가 인식하지 못하는 비표준 문법 |
| 이미지 저장 위치 | content/에 저장된 이미지는 Next.js에서 서빙 불가 |
문법을 변환하면서 이미지 URL도 함께 바꿔야 한다는 뜻이었습니다. 이 두 가지를 한 번에 처리할 수 있는 방법이 뭘까 고민하다가, 마크다운 렌더링 파이프라인의 어느 단계에서 개입할지를 먼저 결정해야 한다는 걸 알게 되었습니다.
어느 단계에서 개입할 것인가
접근 방식을 두 가지로 검토했어요.
| MDX 파일 전처리 | remark 플러그인 (AST 변환) | |
|---|---|---|
| 방식 | 빌드 전 스크립트로 ![[...]]를  텍스트 치환 | 렌더링 파이프라인에서 AST를 조작 |
| 원본 MDX | 수정됨 | 그대로 유지 |
| Obsidian 편집 | 이미지 미리보기 깨짐 | 정상 동작 |
| 구현 난이도 | 단순 | 플러그인 작성 필요 |
remark 플러그인을 선택했습니다. Obsidian에서의 편집 경험을 그대로 유지할 수 있고, 변환 로직이 렌더링 단계에만 존재해서 관심사가 분리되기 때문입니다.
📌 **AST(Abstract Syntax Tree)**는 텍스트를 구조화된 트리로 표현한 것입니다. 마크다운에서
# 제목뒤에 본문이 오면, 파서는 이걸 “heading 노드 아래에 text 노드, 그 다음 paragraph 노드” 같은 트리로 바꿉니다. 이 트리를 조작하면 원본 텍스트를 직접 건드리지 않고도 렌더링 결과를 바꿀 수 있습니다.remark는 unified 생태계의 마크다운 처리 라이브러리로, 마크다운을 MDAST(Markdown AST)라는 트리로 파싱합니다. 플러그인으로 이 트리를 조작한 뒤, 다시 HTML이나 JSX로 변환하는 구조입니다. 플러그인 작성 가이드에 전체 흐름이 잘 설명되어 있습니다.
방향이 정해졌으니, 실제로 AST에서 ![[...]]가 어떻게 표현되는지 확인해봤습니다.
AST에서 위키링크 이미지는 어떻게 보이는가
remark가 ![[image.png]]를 만나면, 파서가 인식하지 못하므로 text 타입 노드의 value 안에 문자열 그대로 남아요. 이미지가 아니라 그냥 텍스트인 것이죠.
플러그인이 해야 할 일은 이 텍스트 노드를 찾아서 image 타입 노드로 교체하는 것이었습니다.
- AST를 재귀 순회하면서
text노드를 검사 - 정규식으로
![[파일명.확장자]]패턴을 찾음 - 매칭된 텍스트를
image노드로 교체하고, URL에/images/blog/경로를 붙임 - 이미지 전후에 다른 텍스트가 있으면 별도 노드로 분리
Before AST:
paragraph
└─ text: "앞 텍스트 ![[스크린샷.png]] 뒤 텍스트"
After AST:
paragraph
├─ text: "앞 텍스트 "
├─ image: { url: "/images/blog/스크린샷.png", alt: "스크린샷.png" }
└─ text: " 뒤 텍스트"
이미지 URL에 /images/blog/ 경로를 붙여서, 앞서 정리한 두 가지 문제(문법 변환과 이미지 URL 변환)를 동시에 처리하는 구조입니다.
이 변환을 구현하면서 예상보다 신경 쓸 부분이 몇 가지 있었습니다.
구현과 삽질
외부 의존성 없이 직접 순회
처음에는 AST 순회에 unist-util-visit를 사용하려 했어요. remark 플러그인 예제에서 자주 보이는 유틸리티입니다.
그런데 pnpm 환경에서 문제가 생겼습니다. unist-util-visit는 remark의 transitive dependency이긴 하지만, pnpm의 엄격한 의존성 관리 정책 때문에 직접 설치하지 않으면 import할 수 없거든요. 물론 pnpm add unist-util-visit로 설치하면 되지만, 50줄도 안 되는 단순한 순회 로직을 위해 의존성을 하나 더 추가하는 건 과하다고 느꼈습니다.
그래서 직접 재귀 순회를 구현했어요.
function visitNode(node: Node) {
if (!('children' in node)) return;
const parent = node as Parent;
let i = 0;
while (i < parent.children.length) {
const child = parent.children[i];
// text 노드에서 위키링크 패턴을 찾아 image 노드로 교체
// ...
visitNode(child);
i++;
}
}
for가 아니라 while을 쓴 이유는 뒤에서 나옵니다.
정규식 lastIndex 함정
첫 번째 이미지는 잘 변환되는데, 같은 문단에 두 번째 이미지가 있으면 매칭이 건너뛰어지더라고요.
원인은 전역 플래그(g)가 있는 정규식의 lastIndex 동작이었습니다. IMAGE_PATTERN.test(child.value)로 매칭 여부를 먼저 확인하면, test() 호출이 lastIndex를 이동시켜요. 이후 exec() 루프가 시작될 때 lastIndex가 0이 아니라 이전 매칭 직후 위치를 가리키고 있어서, 앞쪽 매칭이 빠지는 것이었습니다.
if (isTextNode(child) && IMAGE_PATTERN.test(child.value)) {
IMAGE_PATTERN.lastIndex = 0;
// 이 한 줄이 없으면 두 번째 이미지부터 누락
}
원인을 모른 채 정규식 패턴을 이리저리 바꿔보다가, MDN의 RegExp.prototype.exec() 문서를 읽고 나서야 lastIndex의 동작을 제대로 이해했습니다.
한글 파일명과 노드 교체
나머지 두 가지도 사소하지만 빠뜨리면 바로 버그가 됩니다.
한글 파일명 인코딩. Obsidian이 생성하는 스크린샷 파일명에는 한글과 공백이 포함됩니다. 스크린샷 2026-03-12 오후 5.29.34.png 같은 이름이죠. 이걸 그대로 URL에 넣으면 브라우저에 따라 깨질 수 있어서, encodeURIComponent로 인코딩했습니다.
newNodes.push({
type: 'image',
url: `${IMAGE_BASE_PATH}${encodeURIComponent(fileName)}`,
alt: fileName,
} as ImageNode);
AST splice 후 인덱스 관리. parent.children.splice()로 노드를 교체하면 배열 길이가 변합니다. 하나의 text 노드가 3개의 노드로 바뀌면, 인덱스를 1만 증가시키면 방금 삽입한 노드를 다시 방문하게 됩니다. 앞서 while을 쓴 이유가 이것입니다. 교체된 노드 수만큼 건너뛰어야 하거든요.
parent.children.splice(i, 1, ...(newNodes as Node[]));
i += newNodes.length;
전체 코드
이 네 가지 포인트가 모두 반영된 전체 플러그인 코드입니다. 앞서 조각으로 본 부분이 어디에 위치하는지 하이라이트로 표시했어요.
전체 플러그인 코드 보기
import type { Plugin } from 'unified';
import type { Node, Parent } from 'unist';
interface TextNode extends Node {
type: 'text';
value: string;
}
interface ImageNode extends Node {
type: 'image';
url: string;
alt: string;
}
const IMAGE_PATTERN = /!\[\[([^\]]+\.(png|jpe?g|gif|svg|webp))\]\]/gi;
const IMAGE_BASE_PATH = '/images/blog/';
function isTextNode(node: Node): node is TextNode {
return node.type === 'text';
}
function visitNode(node: Node) {
if (!('children' in node)) return;
const parent = node as Parent;
let i = 0;
while (i < parent.children.length) {
const child = parent.children[i];
if (isTextNode(child) && IMAGE_PATTERN.test(child.value)) {
IMAGE_PATTERN.lastIndex = 0;
const newNodes: (TextNode | ImageNode)[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMAGE_PATTERN.exec(child.value)) !== null) {
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: child.value.slice(lastIndex, match.index),
} as TextNode);
}
const fileName = match[1];
newNodes.push({
type: 'image',
url: `${IMAGE_BASE_PATH}${encodeURIComponent(fileName)}`,
alt: fileName,
} as ImageNode);
lastIndex = match.index + match[0].length;
}
if (lastIndex < child.value.length) {
newNodes.push({
type: 'text',
value: child.value.slice(lastIndex),
} as TextNode);
}
parent.children.splice(i, 1, ...(newNodes as Node[]));
i += newNodes.length;
} else {
visitNode(child);
i++;
}
}
}
const remarkObsidianImages: Plugin = () => {
return (tree: Node) => {
visitNode(tree);
};
};
export default remarkObsidianImages;MDX 렌더링 파이프라인에 등록하면 적용이 끝납니다.
// mdx.tsx: remarkPlugins에 등록
const components = await compileMDX({
source: content,
options: {
remarkPlugins: [remarkObsidianImages],
},
});
검증
pnpm build가 성공한 뒤, 두 가지를 확인했어요.
- 블로그 쪽. 이미지가 포함된 포스트 2개(
/blog/frontend-sha256-login-hashing,/blog/http-error-i18n-fallback)에서 스크린샷 4장이 모두 정상 렌더링되었고, 이미지 전후 텍스트가 잘리거나 중복되는 문제도 없었습니다. - Obsidian 쪽. 빌드 후에도 Obsidian에서 해당 글을 열었을 때
![[...]]문법과 이미지 미리보기가 정상 동작했어요. 앞서 “원본을 건드리지 않는다”는 판단 덕분에, 빌드와 편집이 서로 간섭하지 않는 구조가 유지되었습니다.
운영하면서 알게 된 것
빌드와 렌더링은 해결했지만, 실제로 글을 발행하면서 한 가지 더 신경 쓸 부분이 생겼어요. 플러그인이 문법 변환과 이미지 URL 변환을 처리하지만, 이미지 파일 자체는 public/images/blog/에 있어야 하거든요.
이 부분은 두 가지 운영 방식을 검토했습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 수동 이동 (현재) | vault와 프로젝트 구조가 분리됨 | 발행할 때마다 이미지를 옮겨야 함 |
| Obsidian 첨부 폴더 설정 변경 | 이동 단계 생략 가능 | vault가 프로젝트 구조에 결합됨 |
현재는 수동 이동을 유지하고 있어요. 이미지가 더 많아지면 이동을 자동화하는 스크립트를 만들 생각입니다.
마무리
블로그에 올린 글에서 이미지가 텍스트로 보이는 문제를 remark AST 변환 플러그인으로 해결했습니다. 정리하면 이렇습니다.
- 문제: Obsidian
![[이미지.png]]가 MDX에서 텍스트로 출력. 문법 불일치 + 이미지 저장 위치 불일치가 겹쳐 있었음 - 판단: 원본 MDX를 건드리지 않기 위해 remark 플러그인(AST 변환) 선택
- 해결:
text→image노드 교체 + URL에/images/blog/경로 부여 - 결과: Obsidian 편집 경험 유지, 빌드와 편집이 서로 간섭하지 않는 구조
돌아보면, “어느 단계에서 개입할 것인가”가 가장 중요한 판단이었어요. MDX 원본을 전처리하는 방법이 더 빨랐겠지만, Obsidian의 편집 경험을 포기해야 했습니다. 렌더링 파이프라인에서 처리하기로 한 덕분에, 글을 쓸 때는 Obsidian 문법을 그대로 쓰고, 발행할 때는 자동으로 변환되는 워크플로우가 만들어졌어요.
한계도 있습니다. 현재는 이미지 파일만 처리해요. Obsidian의 다른 위키링크 [[노트 링크]]나 ![[파일.pdf]] 같은 임베드는 대상이 아닙니다. 블로그 안에서 노트 간 링크가 필요해지면 플러그인을 확장할 수 있을 거예요. 정규식 패턴과 변환 대상 노드 타입만 추가하면 되니까요.