블로그로 돌아가기

마크다운을 Word로 내보낼 때 이미지가 사라진다면? 크로스 오리진과 WebP 호환성 문제를 해결한 방법

MD-TO Team •

md-to.com의 Markdown to Word 내보내기 기능을 개발하던 중, 골치 아픈 버그를 만났습니다. 사용자의 Markdown에 있던 이미지가 Word로 내보내면 사라져 버리는 문제였습니다.

원인을 파고들어 보니 두 겹으로 얽혀 있었습니다. 하나는 브라우저 보안 정책(CORS), 다른 하나는 이미지 형식 호환성(WebP)입니다. 이 글에서는 디버깅 과정과 최종 해결 방법을 정리합니다.


증상

사용자가 편집기에 다음과 같은 Markdown을 붙여넣었다고 해 보겠습니다.

![편집기 화면](https://example.com/screenshot.webp)
![사이트 아이콘](https://example.com/icon.png)

미리보기에서는 두 이미지가 모두 문제없이 표시됩니다. 하지만 내보낸 Word 파일에서는 두 이미지가 모두 사라지고, [편집기 화면], [사이트 아이콘] 같은 대체 텍스트만 남습니다.


문제 1: Word는 WebP를 지원하지 않는다

원인

저희는 docx 라이브러리로 Word 문서를 생성합니다. 이 라이브러리의 ImageRun 컴포넌트가 지원하는 이미지 형식은 네 가지뿐입니다.

type DocxImageType = 'jpg' | 'png' | 'gif' | 'bmp';

요즘 웹사이트는 WebP를 많이 사용합니다(PNG보다 25~35% 작습니다). 저희 코드는 지원하지 않는 형식을 만나면 Unsupported image type 오류를 던졌고, 이미지는 아무 경고 없이 버려졌습니다.

해결: Canvas로 자동 변환

브라우저는 WebP를 기본적으로 렌더링할 수 있으므로, Canvas API를 이용해 PNG로 변환할 수 있습니다.

function loadImageViaCanvas(
  source: Blob | string,
): Promise<{ blob: Blob; width: number; height: number }> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const isBlobSource = source instanceof Blob;
    if (!isBlobSource) {
      img.crossOrigin = 'anonymous';
    }
    const blobUrl = isBlobSource ? URL.createObjectURL(source) : null;

    img.onload = () => {
      if (blobUrl) URL.revokeObjectURL(blobUrl);
      const { naturalWidth: width, naturalHeight: height } = img;
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0);
      canvas.toBlob(
        (pngBlob) => {
          if (pngBlob) resolve({ blob: pngBlob, width, height });
          else reject(new Error('Canvas toBlob failed'));
        },
        'image/png',
      );
    };
    img.onerror = () => reject(new Error('Failed to load image'));
    img.src = blobUrl ?? (source as string);
  });
}

이 함수는 두 종류의 입력을 받습니다.

  • Blob: 이미 가져온 이미지의 형식만 변환할 때 사용합니다.
  • URL 문자열: CORS 폴백에 사용합니다(아래에서 자세히 설명합니다).

입력이 WebP, AVIF 등 어떤 형식이든 출력은 항상 PNG이며, Word는 PNG를 문제없이 처리합니다.


문제 2: CORS가 크로스 오리진 이미지 가져오기를 막는다

WebP 문제를 고치고 나니 더 근본적인 문제가 드러났습니다. 크로스 오리진 이미지는 아예 가져올 수 없다는 것이었습니다.

원인

저희 코드는 fetch()로 이미지 데이터를 가져옵니다.

const response = await fetch(imageUrl);
const blob = await response.blob();

이미지 URL이 웹사이트와 다른 도메인에 있으면(예: 사이트는 md-to.com, 이미지는 example.com), 브라우저의 CORS(Cross-Origin Resource Sharing) 정책이 요청을 차단합니다.

Access to fetch at 'https://example.com/image.png'
from origin 'https://md-to.com' has been blocked by CORS policy

미리보기는 되는데 왜 내보내기는 안 될까?

브라우저는 작업 종류에 따라 서로 다른 수준의 보안 규칙을 적용하기 때문입니다.

작업크로스 오리진 허용 여부이유
<img src="external"> 렌더링✅ 허용브라우저는 CORS 없이도 이미지를 렌더링함
fetch()로 데이터 접근❌ 차단JS가 바이너리 데이터를 읽으려면 CORS가 필요함
<img crossOrigin> + Canvas 내보내기❌ 차단오염된(tainted) Canvas는 데이터를 내보낼 수 없음
fetch({mode: 'no-cors'})❌ 읽을 수 없음빈 본문이 반환됨

미리보기는 <img> 태그를 사용하므로 크로스 오리진 렌더링이 허용됩니다. 반면 내보내기는 JavaScript로 이미지의 바이너리 데이터를 읽어야 하는데, 이것이 CORS에 막힙니다. “보이기는 하지만 내보낼 수는 없는” 이유가 바로 이것입니다.

Base64로도 해결되지 않는 이유

“이미지를 Base64로 바꾸면 되지 않나요?”라고 생각할 수 있습니다.

하지만 Base64는 인코딩 형식일 뿐 데이터를 얻는 방법이 아닙니다. 문제는 “어떤 형식으로 저장하느냐”가 아니라 “브라우저가 원본 바이트에 접근하지 못하게 한다”는 점입니다. Base64로 인코딩하든 ArrayBuffer로 바꾸든, 첫 단계는 데이터를 가져오는 것이고, CORS는 바로 이 단계를 완전히 차단합니다.

해결: 서버 측 이미지 프록시

브라우저 쪽에서는 우회할 방법이 없으므로, 이미지를 서버가 대신 가져오도록 했습니다. 서버에는 CORS 제약이 없어서 어떤 URL이든 자유롭게 요청할 수 있습니다.

이미지 프록시 역할을 하는 가벼운 Edge Function을 만들었습니다.

브라우저 → /api/image-proxy?url=https://example.com/image.png → 우리 서버 → example.com
                                                                ↑ CORS 제약 없음

프록시 함수(엣지에 배포):

export async function onRequest({ request }) {
  const { searchParams } = new URL(request.url);
  const target = searchParams.get('url');

  // 보안: http(s)만 허용, 이미지 타입만 허용, 크기 제한
  const upstream = await fetch(target);
  const contentType = upstream.headers.get('content-type');

  return new Response(await upstream.arrayBuffer(), {
    headers: {
      'Content-Type': contentType,
      'Cache-Control': 'public, max-age=86400',
      'Access-Control-Allow-Origin': '*',
    },
  });
}

클라이언트 측 자동 라우팅:

function resolveImageSrc(src: string): string {
  const resolved = new URL(src, window.location.href).toString();

  // 크로스 오리진 이미지는 프록시를 거치도록 함
  if (new URL(resolved).origin !== window.location.origin) {
    return `/api/image-proxy?url=${encodeURIComponent(resolved)}`;
  }

  return resolved;
}

이제 모든 크로스 오리진 이미지 요청이 동일 오리진 요청으로 바뀌므로 CORS 문제가 사라집니다.


최종 아키텍처

Markdown의 이미지 URL
        │
        ▼
  resolveImageSrc()
        │
        ├── 동일 오리진 → 직접 fetch
        │
        └── 크로스 오리진 → /api/image-proxy?url=xxx
                              │
                              ▼
                        Edge Function이 요청 전달
                              │
                              ▼
                          Blob 획득
                              │
                              ▼
                    getDocxImageType()으로 형식 확인
                              │
                    ┌─────────┴──────────┐
                    ▼                    ▼
              jpg/png/gif/bmp      webp/avif/기타
              그대로 삽입          Canvas → PNG → 삽입

개발 환경

Vite 개발 서버에도 동일한 프록시 미들웨어를 추가해, 개발 환경과 프로덕션 환경의 동작이 일치하도록 했습니다.

보안 조치

이미지 프록시는 아무 제한 없는 오픈 프록시가 아닙니다.

  • http://와 https:// 프로토콜만 허용합니다.
  • 이미지 Content-Type(png/jpeg/gif/bmp/webp/avif/svg)만 통과시킵니다.
  • 이미지 한 장당 10 MB 크기 제한이 있습니다.
  • 응답을 24시간 동안 캐시합니다.

요약

문제원인해결 방법
WebP 이미지가 Word에 표시되지 않음docx 라이브러리가 WebP를 지원하지 않음Canvas API로 PNG로 변환
크로스 오리진 이미지를 가져올 수 없음브라우저의 CORS 보안 정책서버 측 Edge Function 프록시
개발 환경에서도 이미지가 실패함localhost에서 외부 도메인으로의 요청은 크로스 오리진Vite 개발 서버 프록시 미들웨어

핵심은 이것입니다. 브라우저의 보안 모델은 “렌더링”과 “데이터 접근”을 구분합니다. <img>가 크로스 오리진 이미지를 표시할 수 있다고 해서 JavaScript가 그 데이터를 읽을 수 있는 것은 아닙니다. 크로스 오리진 리소스를 클라이언트에서 다뤄야 한다면, 서버 측 프록시가 사실상 유일하게 믿을 만한 방법입니다.


직접 사용해 보고 싶으신가요? md-to.com 마크다운 → Word에 접속해 외부 이미지가 포함된 Markdown을 붙여넣고 내보내 보세요.