Canvas 이미지 저장과 Web Share API
TL;DR
- Canvas에 이미지를 그리는 것과 Canvas 픽셀을 다시 읽는 것은 다른 권한 문제다.
- 외부 이미지를 CORS 없이 그리면 Canvas가 tainted 상태가 되고
toDataURL()·toBlob()이 실패한다. <a download>는 저장 API가 아니라 다운로드 의도를 전달하는 힌트다.- 모바일 브라우저에서 앨범 저장을 직접 보장하는 웹 API는 없다. Web Share API는 파일을 OS 공유 시트로 넘길 뿐이다.
요구사항은 단순했다. 사용자가 입력한 이름을 배경 이미지 위에 얹어 카드 이미지를 만들고, 그 이미지를 저장하거나 공유하게 한다. 구현은 Canvas로 시작한다.
const link = document.createElement("a");
link.href = canvas.toDataURL("image/png");
link.download = "card.png";
link.click();
이 코드는 데스크톱 브라우저에서는 그럴듯하게 동작한다. 하지만 모바일까지 포함하면 저장은 세 개의 경계를 지난다.
- Canvas 픽셀을 읽을 수 있는가
- 브라우저가 다운로드로 처리할 것인가
- 사용자가 파일을 앨범에 저장했는가
세 질문은 서로 다른 주체가 결정한다. 서버, 브라우저, OS가 각각 한 구간씩 잡고 있다.
Canvas의 origin-clean 플래그
Canvas는 화면에 그려진 픽셀을 다시 읽을 수 있는지 판단하기 위해 origin-clean 플래그를 가진다. 생성 직후에는 참이다. CORS 허용 없이 다른 출처의 이미지를 drawImage()로 그리면 이 플래그가 거짓이 된다. 그 뒤부터는 다음 API가 실패한다.
canvas.toDataURL("image/png");
canvas.toBlob(callback, "image/png");
중요한 구분은 “보이는가”와 “읽을 수 있는가”다. 외부 이미지는 화면에 그릴 수 있다. 하지만 그 픽셀을 다시 읽어서 파일로 만들려면 이미지를 제공한 서버가 CORS를 허용해야 한다. 클라이언트에서는 이미지 로드 전에 CORS 모드를 지정한다.
const image = new Image();
image.crossOrigin = "anonymous";
image.src = imageUrl;
서버는 해당 요청에 CORS 응답 헤더를 내려야 한다.
Access-Control-Allow-Origin: *
또는 서비스 도메인만 명시한다.
Access-Control-Allow-Origin: https://example.com
crossOrigin만 붙이면 해결되는 문제가 아니다. 클라이언트가 CORS 요청을 보내고, 서버가 그 요청을 허용해야 Canvas가 origin-clean 상태를 유지한다.
download 속성의 한계
Canvas에서 이미지 데이터를 얻었다면 다음 선택지는 <a download>다.
function downloadDataURL(canvas: HTMLCanvasElement, fileName: string) {
const link = document.createElement("a");
link.href = canvas.toDataURL("image/png");
link.download = `${fileName}.png`;
link.click();
}
download는 파일 저장 명령이 아니다. 브라우저에 “이 링크를 다운로드로 처리해 달라”는 의도를 전달하는 속성이다. 파일명도 강제값이 아니라 제안값이다. 그래서 이 경로가 보장하는 것은 제한적이다.
| 항목 | 보장 범위 |
|---|---|
href |
내려받을 리소스 위치 |
download |
다운로드 의도와 제안 파일명 |
| 실제 저장 | 브라우저와 사용자 에이전트 정책에 따름 |
| 앨범 저장 | 보장하지 않음 |
모바일 환경에서는 이 차이가 크게 보인다. 같은 코드라도 다운로드로 처리될 수 있고, 새 탭에서 이미지가 열릴 수 있고, 공유 시트나 파일 앱 흐름으로 이어질 수 있다. 웹 페이지는 최종 저장 위치를 알 수 없다.
Web Share API의 역할
모바일에서 더 자연스러운 선택지는 Web Share API다. Canvas를 Blob으로 만들고, 그 Blob을 File로 감싼 뒤 OS 공유 시트에 넘긴다.
canvas.toBlob(async (blob) => {
if (!blob) return;
const file = new File([blob], `${fileName}.png`, { type: "image/png", }); if (!navigator.canShare?.({ files: [file] })) { return; }
await navigator.share({ files: [file] });
}, "image/png");
이 경로도 저장 API는 아니다. 웹이 만든 파일을 OS 공유 시트에 전달한다. 사용자는 공유 시트에서 사진 저장, 메신저 전송, 파일 앱 저장 같은 대상 중 하나를 고른다. 따라서 성공의 의미도 다르다.
| API | 성공 의미 |
|---|---|
toBlob() |
Canvas 픽셀을 파일 데이터로 읽었다 |
canShare({ files }) |
현재 브라우저가 이 파일 공유 payload를 처리할 수 있다 |
share({ files }) |
공유 흐름이 시작되어 OS에 전달됐다 |
navigator.share()의 성공은 “앨범에 저장됐다”가 아니다. 사용자가 어떤 대상을 선택했는지는 웹이 관측할 수 없다.
Promise 기반 실패 처리
navigator.share()는 Promise를 반환한다. 실패와 취소는 동기 예외가 아니라 rejected Promise로 전달된다.
try {
navigator.share({ files: [file] });
} catch {
showFallback();
}
위 코드는 실패를 잡지 못한다. await가 없기 때문이다.
try {
await navigator.share({ files: [file] });
} catch {
showFallback();
}
또는 Promise 체인에 직접 붙인다.
navigator.share({ files: [file] }).catch(() => {
showFallback();
});
사용자가 공유 시트를 닫는 동작도 실패 경로로 온다. 그러므로 catch는 “예상하지 못한 예외”만 처리하는 자리가 아니라 사용자의 취소까지 포함한 정상적인 분기다.
payload 구성
Web Share API는 files, text, title, url을 함께 받을 수 있다.
await navigator.share({
files: [file],
title: "card.png",
text: "공유 문구",
});
공유 대상 앱은 이 값을 각자의 모델로 해석한다. 파일만 받을 때와 텍스트가 같이 있을 때 공유 시트의 후보나 대상 앱의 처리 방식이 달라질 수 있다. 이미지 저장이 목적이라면 payload를 작게 유지하는 편이 낫다.
await navigator.share({ files: [file] });
이렇게 하면 공유의 주 객체가 이미지 파일이라는 점이 가장 분명해진다. 텍스트나 URL을 같이 보내야 하는 SNS 공유 요구는 별도 버튼으로 분리하는 편이 예측 가능하다.
구현 기준
Canvas 이미지 저장 기능은 “저장 버튼 하나”로 보이지만 내부 기준은 세 개다.
| 기준 | 확인 방법 | 실패 시 처리 |
|---|---|---|
| 픽셀 읽기 | toBlob() 성공 여부 |
CORS 설정 확인 |
| 파일 공유 가능성 | navigator.canShare({ files }) |
다운로드 링크 또는 안내 |
| 사용자 전달 | navigator.share({ files }) Promise |
취소·실패 분기 처리 |
데스크톱에서는 <a download>가 간단한 경로가 될 수 있다. 모바일에서는 Web Share API가 더 자연스럽다. 하지만 둘 다 최종 저장을 보장하지 않는다.
정리
브라우저는 Canvas로 이미지를 만들 수 있고, 그 이미지를 파일처럼 사용자에게 넘길 수 있다. 그러나 모바일 브라우저에서 “앨범에 저장”을 직접 보장하는 API는 없다. 그래서 구현 기준은 이렇게 잡는 편이 안전하다.
- Canvas 픽셀을 읽어야 하면 CORS를 먼저 맞춘다.
- 다운로드는 저장 명령이 아니라 브라우저 힌트로 본다.
- 모바일 저장 UX는 Web Share API로 파일을 넘기는 방식으로 설계한다.
- 저장 완료 여부가 서버 로직에 필요하다면 클라이언트 저장이 아니라 서버 업로드 URL을 기준으로 삼는다.