블로그 만들기 02 – 옵시디언 연동하기

2025-08-27

#블로그#티스토리#Nextjs#Vercel#Obsidian#Markdown

Obsidian은 마크다운 기반의 개인 지식 관리 도구이다. 단축키 기반의 글쓰기, 링크 연결, 템플릿 활용 등 유용한 기능을 갖추고 있으며, 마크다운 파일을 그대로 가져다 쓸 수 있다는 점에서 정적 블로그와 궁합이 좋다.

이 포스트에서는 Obsidian에서 작성한 글과 이미지를 Next.js 기반 블로그 프로젝트에 자동 연동하는 과정을 기록한다.


✅ 연동 절차

1. Obsidian Vault 생성

  • obsidian-blog라는 이름으로 Vault를 생성한다.

  • 기본 폴더 구조는 다음과 같다.

.
├── _assets/                        # 이미지 파일 저장
├── _templates/                     # 게시글 템플릿 저장
├── 폴더1/
│   └── 게시글.md
└── 폴더2

2. GitHub 저장소 연동

Vault 디렉토리를 GitHub 저장소와 연결한다.

.obsidian, .git, .github, templates 등은 커밋 및 푸시 대상에서 제외한다.

git init
git remote add origin <your-repo-url>
echo '.obsidian/' >> .gitignore

3. Git Push 시 블로그 프로젝트에 복사

Obsidian Vault 내 작성된 .md 파일들을 블로그 프로젝트 내 /posts 디렉토리로 복사한다. 이를 위해 pre-push 훅에 아래 스크립트를 추가한다.

  • .git/hooks/pre-push
#!/bin/zsh

# 📁 최종 타겟 디렉토리 (md 파일 + 이미지 복사)
TARGET_DIR="/blog/posts"

# 📁 public용 이미지 복사 타겟
PUBLIC_ASSETS_DIR="/blog/public/_assets"

# 디렉토리 존재 확인 및 생성
if [ ! -d "$TARGET_DIR" ]; then
  mkdir -p "$TARGET_DIR"
fi
if [ ! -d "$PUBLIC_ASSETS_DIR" ]; then
  mkdir -p "$PUBLIC_ASSETS_DIR"
fi

# 📦 md, png 파일 + 디렉토리만 posts로 복사 (기존 로직)
rsync -av --delete \
  --exclude='_templates' \
  --exclude='.obsidian' \
  --exclude='.github' \
  --exclude='.git' \
  --exclude='_assets' \
  --include='*/' \
  --include='*.md' \
  --exclude='*' \
  ./ "$TARGET_DIR"

# 🖼️ _assets만 public으로 따로 복사
rsync -av --delete \
  --include="_assets/*" \
  --exclude='*' \
  ./_assets/ "$PUBLIC_ASSETS_DIR"

이 때, next.js의 경우 public 폴더를 이용해서 이미지 처리를 하는게 수월하므로, "_assets" 폴더만 따로 public에 복사해준다.

4. 이미지 경로 처리

Obsidian 내부 링크 형식인 ![[이미지.png]]은 HTML에서 사용할 수 없으므로, 이를 변환하는 전처리 과정을 추가한다.

// 이미지 처리
export const preprocessObsidianLinks = (markdown: string): string => {
  return markdown.replace(
    /!\[\[([^\]]+)\]\]/g,
    (_, filename) =>
      `<img src="/_assets/${filename.trim()}" alt="${filename.trim()}" />`
  );
};

// 게시글 불러오기
export const getAllPosts = (): Post[] => {
  const walk = (dir: string, files: string[] = []): string[] => {
    const entries = fs.readdirSync(dir);
    for (const entry of entries) {
      const fullPath = path.join(dir, entry);
      if (fs.statSync(fullPath).isDirectory()) {
        walk(fullPath, files);
      } else if (entry.endsWith(".md")) {
        files.push(fullPath);
      }
    }
    return files;
  };

  const fullPaths = walk(postsDirectory);

  return fullPaths.map((fullPath) => {
    const relativePath = path.relative(postsDirectory, fullPath);
    const slug = relativePath.replace(/\.md$/, "").replace(/\\/g, "/"); // cross-platform
    const fileContents = fs.readFileSync(fullPath, "utf8");
    const { data, content } = matter(fileContents);

    if (!data.id) {
      throw new Error(`Missing 'id' in frontmatter: ${relativePath}`);
    }

    return {
      id: data.id,
      slug, // 경로용 보조값으로 유지 가능
      title: data.title || slug,
      date: data.date ? new Date(data.date) : new Date(),
      tags: data.tags || [],
      description: data.description || "",
      content,
    };
  });
};

// 게시글 컨텐츠 파싱 후 적용
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import { extractTOCFromHTML } from "app/_lib/toc";
import { getAllPosts } from "app/_lib/post";
import Content from "app/_components/content";
import { preprocessObsidianLinks } from "app/_lib/markdown";

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const allPosts = getAllPosts();
  const { id } = await params;
  const post = allPosts.find((p) => p.id === id);
  if (!post) return <div>404</div>;

  const rawContent = post.content;
  const preprocessed = preprocessObsidianLinks(rawContent);

  const html = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeRaw)
    .use(rehypeSlug)
    .use(rehypeStringify, { allowDangerousHtml: true })
    .process(preprocessed);

  const htmlString = html.toString();
  const toc = extractTOCFromHTML(htmlString);

  console.log("htmlString", htmlString);

  return (
    <div className="relative flex justify-center">
      <Content post={post} postContent={htmlString} />
    </div>
  );
}

5. 결과

스크린샷 2025-08-27 18.52.02.png

🎁 부록: 추천 Obsidian 플러그인

1. Git

  • Git 커밋 및 푸시를 Obsidian 내에서 바로 수행할 수 있다.
  • Vault에 버전 관리와 협업 워크플로우를 자연스럽게 도입할 수 있다.
  • 자동 푸시 시간 설정도 가능하다.

2. Templater

  • 반복적으로 작성하는 서식을 템플릿으로 관리할 수 있다.
  • 날짜, 파일 이름, 사용자 정의 변수 삽입 등 다양한 자동화 기능을 제공한다.
  • 나만의 메타데이터 양식 또는 마크다운 문서 뼈대를 손쉽게 만들 수 있다.

🔍 마무리

🌟 좋았던 점

  • 마크다운 기반 글 관리와 디렉토리 트리 UI의 결합으로 확장성과 유지보수성이 향상되었다.
  • 직접 디렉토리를 파싱하고 구조화하는 과정에서 fs와 Node 환경에 대한 이해가 증진되었다.
  • 사이드바 트리 UI에 간단한 애니메이션을 적용하여 사용자 경험이 개선되었다.

😭 아쉬웠던 점

  • 현재 이미지 사이즈를 조절하지 못한다..