iOS에서 ClipboardItem으로 canvas 이미지 복사가 실패하는 이유와 해결 방법

2025-06-11

Maskit 프로젝트를 진행하면서, 사용자가 마스킹한 이미지를 클립보드에 복사하는 기능을 구현할 일이 있었습니다. 마스킹된 결과는 canvas로 렌더링되고, canvas.toBlob()으로 만든 이미지를 ClipboardItem에 담아 클립보드에 복사하는 방식이었습니다.

Chrome에서는 문제없이 복사되었지만, iOS에서는 이미지가 클립보드에 복사되지 않았습니다. 같은 코드인데도 iOS에서만 동작하지 않아, iOS 환경의 특성을 좀 더 정확히 이해할 필요가 있었습니다.

초기 구현 (Chrome 기준)

처음에는 Chrome을 기준으로 다음과 같이 구현했습니다.

export async function copyClipboard(canvas: HTMLCanvasElement) {
  const blob = await new Promise<Blob>((resolve) =>
    canvas.toBlob((blob) => resolve(blob!), "image/png")
  );

  await navigator.clipboard.write([
    new ClipboardItem({
      "image/png": blob,
    }),
  ]);
}

이 코드는 데스크톱 Chrome 등에서는 정상적으로 동작하지만, iOS의 Safari, Chrome 등 WebKit 기반 브라우저에서는 클립보드 복사에 실패했습니다.

iOS에서 실패하는 이유

iOS에서는 동일한 코드가 전혀 작동하지 않았고, 명확한 에러 메시지도 없어서 처음엔 단순한 제한 사항 정도로 생각했습니다. 그러다 **WebKit 이슈 페이지**를 통해, iOS(WebKit)에서는 ClipboardItem에 Blob을 직접 넘기는 방식이 지원되지 않는다는 사실을 확인했습니다. (보안때문에 사용자의 직접적인 동작과 연결되지 않은 작업이라고 판단되는 것 같습니다.)

추가로 **web.dev의 문서**를 살펴보면서, WebKit 기반 브라우저에서는 클립보드에 데이터를 쓰려면 ClipboardItem의 값이 반드시 Promise 형태여야 하고, 그 작업이 사용자 인터랙션 안에서 실행돼야 한다는 제약이 있다는 것도 알게 됐습니다.

https://bugs.webkit.org/show_bug.cgi?id=222262

Warning: Safari (WebKit) treats user activation differently than Chromium (Blink) (see WebKit bug #222262). For Safari, run all asynchronous operations in a promise whose result you assign to the ClipboardItem

https://web.dev/articles/async-clipboard

해결 방법

다행히도 Promise을 사용하는 방식은 iOS뿐 아니라 Chrome, Edge 등 주요 브라우저에서도 모두 정상적으로 동작했습니다. 따라서 브라우저별 분기나 예외 처리 없이 다음과 같이 공통 코드로 정리할 수 있습니다.

// 개선전 (blob을 직접 전달하는 방식)
export async function copyClipboard(canvas: HTMLCanvasElement) {
  const blob = await new Promise<Blob>((resolve) =>
    canvas.toBlob((blob) => resolve(blob!), "image/png")
  );

  await navigator.clipboard.write([
    new ClipboardItem({
      "image/png": blob,
    }),
  ]);
}

// 개선후 (Promise를 사용하는 방식)
export async function copyClipboard(canvas: HTMLCanvasElement) {
  await navigator.clipboard.write([
    new ClipboardItem({
      "image/png": new Promise((resolve) =>
        canvas.toBlob((blob) => resolve(blob!), "image/png")
      ),
    }),
  ]);
}