2025-06-11
Maskit 프로젝트를 진행하면서, 사용자가 마스킹한 이미지를 클립보드에 복사하는 기능을 구현할 일이 있었습니다. 마스킹된 결과는 canvas
로 렌더링되고, canvas.toBlob()
으로 만든 이미지를 ClipboardItem에 담아 클립보드에 복사하는 방식이었습니다.
Chrome에서는 문제없이 복사되었지만, iOS에서는 이미지가 클립보드에 복사되지 않았습니다. 같은 코드인데도 iOS에서만 동작하지 않아, iOS 환경의 특성을 좀 더 정확히 이해할 필요가 있었습니다.
처음에는 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에서는 동일한 코드가 전혀 작동하지 않았고, 명확한 에러 메시지도 없어서 처음엔 단순한 제한 사항 정도로 생각했습니다. 그러다 **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")
),
}),
]);
}