본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 05. 28. 17:18

No.1 Markdown Editor의 GitHub Image Hosting 해설: 로컬 이미지를 Qiita에 그대로 붙여넣을 수 있는

요약

Markdown 에디터에서 로컬 이미지를 GitHub 리포지토리에 업로드하고 공개 URL로 변환하는 메커니즘을 설명합니다. 이를 통해 Qiita와 같은 외부 플랫폼에서도 별도의 업로드 과정 없이 이미지를 바로 사용할 수 있는 구현 방법을 다룹니다.

핵심 포인트

  • 로컬 이미지 경로를 GitHub 호스팅 URL로 자동 변환하는 메커니즘
  • 상대 경로를 절대 경로로 해결하고 충돌 없는 파일명 생성 방법
  • Tauri 및 Rust를 이용한 GitHub Contents API 업로드 구현
  • PAT 및 공개 리포지토리 사용 시의 보안 처리 방식

No.1 Markdown Editor

의 GitHub Image Hosting은 Markdown 내의 로컬 이미지 참조 (local image reference)를 찾아, 이미지 파일 (image file)을 GitHub 리포지토리 (GitHub repository)에 업로드 (upload)하고, 해당 Markdown을 공개 URL (public URL)로 다시 쓰는 메커니즘입니다.

이 부분이 중요합니다.

Qiita에 붙이고 싶은 Markdown을, Qiita 측의 이미지 업로드 (image upload) 작업에 의존하지 않고, 에디터 (editor) 측에서 GitHub 호스팅 이미지 URL (GitHub hosted image URL)로 변환합니다.

예를 들어, 기사를 작성 중인 Markdown에 다음과 같은 로컬 이미지 (local image)가 있다고 가정해 봅시다.

# GitHub Image Hosting
![screen](./images/screen.png)
![diagram](../assets/flow.JPG "upload flow")
...

Image Hosting을 실행하면, 로컬 이미지 (local image)만 업로드 (upload)되며, Markdown은 다음과 같은 형태가 됩니다.

# GitHub Image Hosting
![screen](https://cdn.jsdelivr.net/gh/engchina/markdown-images@main/images/2026/05/screen-github-image-hosting-1716894000-1.png)
![diagram](https://cdn.jsdelivr.net/gh/engchina/markdown-images@main/images/2026/05/flow-github-image-hosting-1716894000-2.jpg "upload flow")
...

이 상태라면, 그대로 Qiita의 에디터 (editor)에 붙여넣을 수 있습니다.

Qiita는 Markdown 표기법을 사용할 수 있으므로, ![alt](url) 형태라면 이미지로 취급할 수 있습니다. Qiita Markdown은 GitHub Flavored Markdown을 기본으로 하며, 링크 (link) / 이미지 (image)는 중간에 줄바꿈을 하지 않는 것이 중요합니다.

이 기사에서는 이 로컬 이미지 (local image) -> GitHub 호스팅 URL (GitHub hosted URL) 변환을 구현 코드 (implementation code)로 분해하여 설명합니다.

이 기사의 코드는 v0.21.0 / 커밋 (commit) 70ad6de의 구현을 바탕으로 하고 있습니다.

  • Markdown 내의 로컬 이미지 (local image)만을 검출하는 방법

  • 상대 경로 (relative path)를 현재 문서 경로 (document path)로부터 절대 경로 (absolute path)로 해결하는 방법

  • GitHub에 둘 원격 파일명 (remote filename)을 충돌하기 어려운 형태로 만드는 방법

  • Tauri / Rust 측에서 GitHub Contents API에 업로드 (upload)하는 방법

  • 업로드 (upload) 후 Markdown을 Qiita에서 사용할 수 있는 URL로 다시 쓰는 방법

  • 실패한 이미지만 원래대로 남겨두는 이유

  • PAT와 공개 리포지토리 (public repository) 처리를 어떻게 안전한 방향으로 기울이고 있는지

  • 이 기능을 테스트 (test)에서 어떻게 보호하고 있는지

  • Qiita 기사용 스크린샷 (screenshot) 관리를 편하게 하고 싶은 분

  • Markdown 에디터 (Markdown editor)에 이미지 호스팅 (image hosting)을 통합하고 싶은 분

  • Tauri 앱 (Tauri app)에서 GitHub API를 호출하고 싶은 분

  • 로컬 파일 경로 (local file path)와 Markdown 이미지 구문 (Markdown image syntax) 처리에 고민 중인 분

  • "작성한 Markdown을 그대로 Qiita에 붙여넣는" 워크플로우 (workflow)를 만들고 싶은 분

사용자 입장에서 보면 조작은 매우 짧습니다.

  • Markdown 파일을 저장해 둔다
  • 로컬 이미지 (local image)를 붙인다
  • GitHub Image Hosting 설정을 한다
  • 업로드 명령 (upload command)을 실행한다
  • Markdown 내의 로컬 이미지 (local image)가 공개 URL (public URL)로 교체된다
  • Markdown을 Qiita에 복사 및 붙여넣기 (copy & paste) 한다

설정에서 가지는 정보는 대략 다음과 같습니다.

owner: engchina
repo: markdown-images
branch: main
...

실행 전:

![hero](./images/hero.png)

실행 후:

![hero](https://cdn.jsdelivr.net/gh/engchina/markdown-images@main/images/2026/05/hero-post-1716894000-1.png)

GitHub 리포지토리 (GitHub repository)에 이미지 파일 (image file)이 들어가고, Markdown에는 공개 URL (public URL)만 남습니다.

이 Markdown은 Qiita에 그대로 붙여넣을 수 있습니다.

처리는 한 곳에서 완결되지 않습니다.

Toolbar / Command Palette
-> triggerImageHostingUploadForActiveDocument()
-> runImageHostingUploadForDocument()
...

중심이 되는 파일 (file)은 다음과 같습니다.

src/lib/imageHosting/urlBuilder.ts

가장 먼저 필요한 것은, Markdown의 이미지 목적지(destination)가 로컬인지 아닌지를 판단하는 것입니다.

const IMAGE_EXTENSION_PATTERN = /\\(png|jpe?g|gif|webp|svg|bmp|avif)$/i
const REMOTE_URL_PATTERN = /^(?:https?:|data:|file:|\/\/)/i
export function isLocalImageReference(destination: string): boolean {
...

이 단계에서는 다음 항목들은 업로드 대상에 포함하지 않습니다.

https://example.com/a.png

data:image/png;base64,...

file:///C:/...

//cdn.example.com/a.png

./not-image.txt

즉, 이미 원격 URL(remote URL)로 되어 있는 이미지나, 이미지가 아닌 첨부 파일은 그대로 남겨둡니다.

이러한 판단 기준이 중요합니다.

Image Hosting은 '모든 것을 업로드'하는 기능이 아니라, Qiita에서 그대로 표시할 수 없는 로컬 Markdown 이미지만을 공개 URL로 변환하는 기능입니다.

GitHub repository에 이미지를 올릴 때, 원래 파일 이름(original file name)을 그대로 사용하면 충돌하기 쉽습니다.

따라서 구현에서는 다음 요소들을 조합하여 원격 파일명(remote filename)을 만듭니다.

  • UTC 기준의 yyyy/mm
  • 원본 이미지의 basename
  • 문서 이름 (document name)
  • 배치 ID (batch id)
  • 이미지 인덱스 (image index)
  • 확장자 (extension)
export function buildRemoteFilename(input: BuildRemoteFilenameInput): string {
const now = input.now ?? new Date()
const year = now.getUTCFullYear().toString().padStart(4, '0')
...

예를 들어 다음 입력이 있다면,

sourcePath: /Users/me/notes/image/Screenshot 2026.png
documentName: My Article
batchId: 1716894000
...

출력은 다음과 같습니다.

2026/05/screenshot-2026-my-article-1716894000-3.png

GitHub repository 측에서는 월별 디렉터리(directory)에 자연스럽게 정리됩니다.

images/
2026/
05/
...

나중에 봤을 때, 어떤 문서에서 업로드된 이미지인지 추적하기 쉽습니다.

핵심 처리는 replaceLocalImagesWithRemoteUrls()입니다.

const MARKDOWN_IMAGE_PATTERN =
/!\s*\[(?<alt>(?:\\.|[^\r\n\])*)\]\(\s*(?:<(?<destinationBracketed>[^>\r\n]+)>|(?<destinationBare>[^\s)]+))(?<title>\s+(?:

이 함수가 수행하는 작업은 상당히 실무적입니다.

- Markdown image syntax (Markdown 이미지 문법)만 추출
- remote / unsupported / unresolved (원격 / 지원되지 않음 / 해결되지 않음)를 건너뜀 (skip)
- local path (로컬 경로)를 해결
- remote filename (원격 파일 이름)을 생성
- 업로드 (upload) 수행
- 업로드에 성공한 부분만 Markdown을 치환
- 실패한 부분은 원래의 Markdown 상태로 유지

실패한 이미지까지 삭제하거나, 깨진 URL로 교체하지 않습니다.

Qiita에 게시하는 글은 이미지 하나라도 깨지면 가독성이 떨어집니다. 따라서 업로드(upload)에 성공한 것만 치환하고, 실패한 것은 사용자가 재시도할 수 있도록 남겨둡니다.

Markdown에서는 대부분의 경우 local image (로컬 이미지)가 relative path (상대 경로)입니다.

screen


업로드(upload)를 하려면 실제 file system path (파일 시스템 경로)가 필요합니다.

export function resolveAbsoluteLocalPath(
rawDestination: string,
documentPath: string | null
...


이 처리를 통해 다음과 같이 해결할 수 있습니다.

document: /workspace/notes/article.md
image: ./image/1.png
result: /workspace/notes/image/1.png


`?foo=bar#frag`와 같은 query (쿼리) / hash (해시)는 file system path (파일 시스템 경로)가 아니므로 제거합니다.

Windows path (Windows 경로)도 `/`로 통일합니다.

C:\workspace\img.png
-> C:/workspace/img.png


Markdown editor (Markdown 에디터)는 Windows / macOS / Linux를 모두 다루기 때문에, path normalization (경로 정규화)은 사소해 보이지만 중요합니다.

업로드(upload)는 active document (활성 문서)에 대해 실행합니다.

export async function runImageHostingUploadForDocument(
input: RunImageHostingUploadInput
): Promise<RunImageHostingUploadOutcome> {
...


여기서 저장되지 않은 document (문서)를 차단하는 이유는, relative image path (상대 이미지 경로)를 정확하게 해결할 수 없기 때문입니다.

`./images/a.png`는 document path (문서 경로)를 알지 못하면 실제 file path (파일 경로)로 변환할 수 없습니다.

따라서 먼저 저장을 하도록 유도합니다.

이 제약 사항은 다소 엄격해 보일 수 있지만, 이미지 업로드(upload)와 같이 file system (파일 시스템)을 다루는 기능에서는 올바른 제약입니다.

UI 진입점에서는 active tab (활성 탭)의 snapshot (스냅샷)을 찍고, 업로드(upload) 결과에 따라 notice (알림)를 표시합니다.

export async function triggerImageHostingUploadForActiveDocument(): Promise<void> {
const state = useEditorStore.getState()
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
...


실제 코드에서는 `not-configured` (설정되지 않음), `unsaved-document` (저장되지 않은 문서), `no-local-images` (로컬 이미지 없음), `uploadPartial` (부분 업로드) 등의 notice (알림)를 구분하고 있습니다.

여기서 중요한 점은 Frontend (프론트엔드)가 업로드(upload)의 세부 사항을 알지 못한다는 것입니다.

Frontend (프론트엔드)의 책임은 다음과 같습니다.

- active document (활성 문서)를 선택한다
- upload (업로드)를 시작한다
- 반환된 Markdown으로 tab content (탭 콘텐츠)를 업데이트한다
- 사용자에게 결과를 보여준다

이것뿐입니다.

GitHub API, PAT, base64 encoding (base64 인코딩), repository permission (저장소 권한)은 Rust 측에 격리해 두었습니다.

Frontend (프론트엔드)에서 Rust command (Rust 명령)를 호출하는 layer (계층)는 매우 얇습니다.

export async function uploadImageToHosting(
localPath: string,
remoteFilename: string
...


이 `assertDesktopAvailable()`

에 의해, GitHub Image Hosting은 desktop app (데스크톱 앱) 전용이 됩니다.

Browser fallback (브라우저 폴백)으로 PAT (Personal Access Token)를 다루는 것보다, Tauri desktop (Tauri 데스크톱)의 Rust 측에 맡기는 것이 더 안전합니다.

PAT는 JavaScript의 localStorage (로컬 스토리지)에 두지 않고, Rust 측에서 keyring (키링)에 저장합니다.

본체는 `src-tauri/src/image_hosting.rs`입니다.

설정은 app local data (앱 로컬 데이터)에 저장하고, PAT는 keyring (키링)에 저장합니다.

const IMAGE_HOSTING_CONFIG_FILE: &str = "image-hosting.json";
const IMAGE_HOSTING_KEYRING_SERVICE: &str = "com.no1.markdown-editor.image-hosting";
const IMAGE_HOSTING_PAT_ACCOUNT: &str = "github-pat";
...


upload (업로드) 시에는 local file (로컬 파일)을 읽어 base64로 변환한 뒤, GitHub Contents API의 `PUT /repos/{owner}/{repo}/contents/{path}`로 전송합니다.

#[tauri::command]
pub async fn image_hosting_upload<R: Runtime>(
app: AppHandle<R>,
...


GitHub의 Contents API는 file content (파일 콘텐츠)를 base64로 전달합니다.

이렇게 설계하면 Frontend (프론트엔드)에서 binary (이진 데이터)를 GitHub로 직접 보내지 않아도 됩니다.

또한, commit message template (커밋 메시지 템플릿)을 갖추고 있어 GitHub repository (리포지토리) 측에도 upload (업로드) 이력이 남습니다.

Upload image: 2026/05/screen-post-1716894000-1.png


upload (업로드)가 성공하면, Rust 측에서는 두 종류의 URL을 생성합니다.

let cdn_url = build_jsdelivr_url(&config.owner, &config.repo, &config.branch, &remote_path);
let raw_url = build_raw_github_url(&config.owner, &config.repo, &config.branch, &remote_path);
Ok(ImageHostingUploadResult {
...


URL builder (URL 빌더)는 다음과 같습니다.

fn build_jsdelivr_url(owner: &str, repo: &str, branch: &str, path: &str) -> String {
format!("https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path}")
}
...


`raw_url`은 GitHub의 raw URL입니다.


실제로 Markdown (마크다운)에 넣는 `url`은 jsDelivr CDN URL입니다.


둘 다 GitHub repository (리포지토리) 상의 file (파일)을 공개하는 URL이지만, 기사에 붙여넣을 Markdown (마크다운)으로서는 CDN URL을 사용하도록 설계되어 있습니다.

Qiita에서 사용하는 Markdown (마크다운)은 최종적으로 다음과 같이 됩니다.

screen


이미지 URL은 Qiita의 독자가 접근할 수 있어야 합니다.

따라서 verify (검증) 단계에서는 repository (리포지토리)가 private (프라이빗)이 아닌지, PAT가 push (푸시) 가능한지를 확인합니다.

let private = body
.get("private")
.and_then(serde_json::Value::as_bool)
...


이 부분은 타협하지 않는 것이 좋습니다.

private repository (비공개 저장소)에 업로드하더라도, Qiita의 독자들에게는 보이지 않습니다.

PAT (Personal Access Token)가 read-only (읽기 전용)라면 업로드할 수 없습니다.

따라서 설정 화면에서 owner / repo / branch / directory / PAT를 입력한 후에 verify (검증)합니다.

업로드 대상 path (경로)는 외부 API에 전달되므로, sanitize (정화)가 필요합니다.

fn sanitize_remote_filename(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
...


`../etc/passwd`와 같은 parent traversal (부모 디렉토리 탐색), absolute path (절대 경로), backslash (역슬래시), null byte (널 바이트)는 거부합니다.

GitHub repository (저장소) 내의 path (경로)라고 할지라도, 이 부분을 느슨하게 처리하면 나중에 관리하기 어려워집니다.

Markdown (마크다운)을 여러 곳에서 치환할 때, 앞에서부터 치환하면 index (인덱스)가 어긋납니다.

그렇기 때문에 치환은 뒤에서부터 적용합니다.

function applyReplacements(markdown: string, replacements: Replacement[]): string {
let output = markdown
for (const replacement of [...replacements].reverse()) {
...


이것은 작은 code (코드)이지만, Markdown rewriting (마크다운 재작성)에서는 상당히 중요합니다.

AST (추상 구문 트리)를 사용하는 선택지도 있지만, 이 기능에서는 Markdown image syntax (마크다운 이미지 문법)만을 대상으로 하며, alt / title / destination (대체 텍스트 / 제목 / 목적지)의 범위도 regex (정규 표현식)로 명확하게 가져옵니다.

따라서 치환 범위를 가지고 reverse apply (역순 적용)하는 구현만으로도 충분히 다룰 수 있습니다.

`urlBuilder`의 test (테스트)에서는 remote (원격) 판정, local (로컬) 판정, extension (확장자), filename (파일명)의 format (형식)을 확인합니다.

test('isLocalImageReference rejects remote schemes', () => {
assert.equal(isLocalImageReference('https://example.com/x.png'), false)
assert.equal(isLocalImageReference('http://example.com/x.png'), false)
...


`replaceLocalImages`의 test (테스트)에서는 local image (로컬 이미지)만 업로드되고, remote image (원격 이미지)와 non-image (이미지가 아닌 것)는 skip (건너뛰기)되는 것을 확인합니다.

const markdown = [
'# Title',
'',
...


실패 시의 contract (규약)도 test (테스트)하고 있습니다.

assert.match(report.rewrittenMarkdown, /![ok](https://cdn.example.com/ok.png)/)
assert.match(report.rewrittenMarkdown, /![bad](./b.png)/, 'failed image stays untouched')


즉, 업로드에 실패한 이미지는 망가뜨리지 않습니다.

이 동작은 기사 작성 workflow (워크플로)에서 상당히 중요합니다.

Qiita에도 이미지 upload (업로드) 기능은 있습니다.

다만, Markdown editor (마크다운 에디터) 측에서 글을 쓰다 보면 다음과 같은 작업이 발생하기 쉽습니다.

이미지를 붙여넣는다
Qiita에 upload (업로드)한다
URL을 copy (복사)한다
...


이 반복은 기사가 길어질수록 번거롭습니다.

GitHub Image Hosting (깃허브 이미지 호스팅)으로 넘기면, Markdown source (마크다운 소스) 측에서 다음과 같이 완결할 수 있습니다.

이미지를 붙여넣는다
upload command (업로드 명령)를 실행한다
Markdown이 public URL (공개 URL)로 바뀐다
...


게다가 이미지 repository (저장소)에 이력이 남으므로, 기사용 asset (에셋)을 GitHub 측에서 관리할 수 있습니다.

이 기능의 핵심은 단순히 GitHub에 upload (업로드) 하는 것이 아닙니다.

이미 remote URL (원격 URL)이 있는 이미지는 그대로 유지합니다.

Qiita에서 사용할 수 있는 URL을 다시 한번 upload (업로드) 할 필요가 없습니다.

upload (업로드)에 성공한 부분만 Markdown (마크다운)을 치환합니다.

실패한 image reference (이미지 참조)는 원래 상태 그대로 남겨둡니다.

relative image path (상대 이미지 경로)는 document path (문서 경로)가 없으면 해결할 수 없습니다.

따라서 unsaved document (저장되지 않은 문서)는 upload (업로드) 전에 중단합니다.

GitHub PAT (개인 액세스 토큰)는 browser storage (브라우저 저장소)에 두지 않고, desktop app (데스크톱 앱)의 keyring (키링)에 저장합니다.

Qiita에 붙여넣는 이미지 URL은 독자에게 보이지 않는다면 의미가 없습니다.

따라서 private repository (프라이빗 저장소)는 verify (검증) 단계에서 거부합니다.

- GitHub Image Hosting은 Markdown (마크다운) 내의 local image (로컬 이미지)를 탐지하여, GitHub repository (GitHub 저장소)에 upload (업로드)한 뒤, Qiita에 붙여넣을 수 있는 public URL (퍼블릭 URL)로 다시 쓰는 기능입니다.
- 구현은 `replaceLocalImagesWithRemoteUrls()`, `buildRemoteFilename()`, `image_hosting_upload`의 3개 계층으로 나뉘어 있으며, Markdown rewriting (마크다운 재작성)과 GitHub API upload (GitHub API 업로드)를 명확하게 분리하고 있습니다.
- PAT는 keyring (키링)에 저장, repository (저장소)는 public (퍼블릭), upload (업로드) 성공한 부분만 치환, 실패한 부분은 원래대로 유지한다는 제약 조건을 통해, 기사 작성 workflow (워크플로)를 해치지 않으면서 자동화를 구현했습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0