HTML은 이미지에 사용할 수 있는 crossorigin 속성을 제공하는데요. 이 속성을 적절한 CORS 헤더와 함께 사용하면, 다른 도메인(foreign origins)에서 불러온 <img> 요소의 이미지를 마치 내 사이트(현재 출처)에서 불러온 것처럼 <canvas> 내에서 자유롭게 사용할 수 있습니다.
이 crossorigin 속성이 구체적으로 어떻게 동작하고 설정되는지 궁금하시다면 CORS 설정 속성(CORS settings attributes) 문서를 참고해 보세요.
💡 강사님의 꿀팁:
"교차 출처(Cross-Origin)"란 도메인, 프로토콜, 포트 중 하나라도 다른 경우를 말해요. 프론트엔드 개발을 하다 보면 외부 API나 이미지 서버(S3 등)에서 리소스를 가져올 일이 정말 많은데, 이때 발생하는 CORS 에러는 실무는 물론이고 프론트엔드 기술 면접에서도 단골로 등장하는 핵심 주제랍니다! 이 문서는 그중에서도 캔버스에 외부 이미지를 그릴 때 발생하는 보안 문제를 어떻게 해결하는지 아주 잘 설명하고 있어요.
캔버스의 비트맵에 그려지는 픽셀들은 다른 호스트에서 가져온 이미지나 비디오 등 아주 다양한 출처에서 올 수 있기 때문에, 필연적으로 보안 문제가 발생할 수밖에 없어요.
그래서 CORS 승인을 받지 않은 다른 출처의 데이터를 캔버스에 그리는 순간, 그 캔버스는 오염됩니다(tainted). 오염된 캔버스는 브라우저 입장에서 더 이상 안전하지 않은 것으로 간주되어서, 캔버스에서 다시 이미지 데이터를 추출하려고 시도하면 예외(exception) 에러를 뱉어내게 됩니다.
만약 그 외부 콘텐츠의 출처가 HTML <img>나 SVG <svg> 요소라면, 캔버스의 내용을 가져오려는 시도 자체가 아예 허용되지 않아요.
또한, 외부 콘텐츠가 HTMLCanvasElement나 ImageBitMap에서 얻은 이미지인데, 그 이미지의 최초 출처가 동일 출처 정책(same-origin rules)을 충족하지 못하는 경우에도 캔버스의 내용을 읽으려는 시도는 차단됩니다.
오염된 캔버스에서 다음 메서드 중 하나라도 호출하면 곧바로 에러가 발생한답니다.
getImageData() 호출하기<canvas> 요소 자체에서 toBlob(), toDataURL(), 또는 captureStream() 호출하기캔버스가 오염되었을 때 위와 같은 작업을 시도하면 SecurityError가 발생해요. 이는 악의적인 웹사이트가 보이지 않는 이미지를 사용해서 사용자의 허락 없이 다른 원격 웹사이트의 개인정보를 빼내는 것을 막기 위한 브라우저의 강력한 보호 조치입니다.
💡 강사님의 꿀팁:
콘솔창에서SecurityError: The operation is insecure라는 에러를 보셨다면 바로 이 문제입니다! 사용자가 업로드한 이미지가 아닌 외부 URL의 이미지를 캔버스에 렌더링하고 나서 썸네일로 추출하려고 할 때 굉장히 자주 만나는 에러죠. 이럴 땐 '아, 내가 CORS 설정이 안 된 외부 이미지를 캔버스에 그리고 데이터를 빼내려고 했구나!'라고 바로 캐치하셔야 해요.
이 예제에서는 다른 출처(foreign origin)에서 이미지를 가져와서 브라우저의 로컬 스토리지(local storage)에 저장하는 것을 허용해 보려고 합니다. 이걸 제대로 구현하려면 프론트엔드 코드만 수정해서는 안 되고, 서버 측의 설정도 함께 필요해요.
가장 먼저 필요한 건, 이미지 파일에 대한 교차 출처 접근을 허용하도록 Access-Control-Allow-Origin 헤더가 올바르게 설정된 채로 이미지를 호스팅하는 서버입니다.
우리가 Apache를 사용해서 사이트를 서비스하고 있다고 가정해 볼게요. 아래에 보여드리는 HTML5 Boilerplate의 CORS 이미지를 위한 Apache 서버 설정 파일을 한번 살펴보세요.
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(avifs?|bmp|cur|gif|ico|jpe?g|jxl|a?png|svgz?|webp)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>
</IfModule>
</IfModule>
간단히 설명해 드리자면, 이 설정은 그래픽 파일(확장자가 ".bmp", ".cur", ".gif", ".ico", ".jpg", ".jpeg", ".png", ".svg", ".svgz", ".webp"인 파일들)이 인터넷 어디에서든(*) 교차 출처로 접근될 수 있도록 웹 서버를 구성하는 명령어들입니다.
💡 강사님의 꿀팁:
프론트엔드 개발자라고 해서 자바스크립트 코드만 알면 안 되는 이유가 바로 이거예요. 나중에 실무에서 CORS 에러를 마주했을 때 끙끙 앓기만 하는 게 아니라, 백엔드 개발자나 인프라 담당자에게 "이미지 서버의 응답 헤더에Access-Control-Allow-Origin을 추가해 주셔야 캔버스에서 처리할 수 있습니다!"라고 정확히 원인을 짚어 소통할 줄 알아야 진짜 실력 있는 프론트엔드 개발자랍니다.
이제 서버가 교차 출처로 이미지를 가져올 수 있도록 구성되었으니, 사용자가 이 이미지들을 마치 내 사이트와 동일한 도메인에서 제공된 것처럼 로컬 스토리지(local storage)에 저장할 수 있게 해주는 프론트엔드 코드를 작성해 볼 수 있습니다.
핵심은 이미지가 로드될 HTMLImageElement 객체에 crossOrigin 속성을 설정하여 crossorigin 특성을 활성화하는 거예요. 이렇게 하면 브라우저에게 이미지 데이터를 다운로드할 때 교차 출처 접근(CORS 요청)을 하라고 명시적으로 알려주게 됩니다.
사용자가 "다운로드" 버튼을 클릭했을 때와 같이 다운로드를 시작하는 코드는 다음과 같습니다.
function startDownload() {
let imageURL = "https://mdn.github.io/shared-assets/images/examples/mdn.svg";
let imageDescription = "Logo of a dinosaur in front of a map";
downloadedImg = new Image();
downloadedImg.crossOrigin = "anonymous";
downloadedImg.addEventListener("load", imageReceived);
downloadedImg.alt = imageDescription;
downloadedImg.src = imageURL;
}
코드 설명을 좀 더 보충해 드릴게요. 여기서는 하드코딩된 URL(imageURL)과 그에 대한 설명 텍스트(imageDescription)를 사용하고 있지만, 실제 프로젝트에서는 API 응답 등 어디에서든 동적으로 가져올 수 있는 값들입니다.
이미지 다운로드를 시작하기 위해 우리는 Image() 생성자를 사용해서 새로운 HTMLImageElement 객체를 만듭니다. 그런 다음 이 이미지가 교차 출처 다운로드를 허용하도록 구성하기 위해 crossOrigin 속성을 "anonymous"로 설정합니다 (이는 자격 증명, 즉 인증 정보 없이 교차 출처 이미지를 다운로드하도록 허용한다는 의미입니다).
이후 이미지 요소에 load 이벤트 리스너를 추가하는데, 이 이벤트가 발생했다는 것은 이미지 데이터를 성공적으로 다 받아왔다는 것을 의미해요. 이미지에 대체 텍스트(alt)도 추가해 줍니다. 캔버스 자체는 alt 속성을 직접 지원하지 않지만, 이 값을 사용해서 나중에 aria-label이나 캔버스 내부의 보조 텍스트 콘텐츠로 활용할 수 있거든요.
마지막으로, 이미지의 src 속성을 다운로드할 이미지의 URL로 설정합니다. 이 src에 값을 할당하는 순간, 브라우저가 네트워크 요청을 보내어 다운로드를 트리거하게 됩니다.
💡 강사님의 꿀팁:
코드를 유심히 보시면crossOrigin설정과load이벤트 리스너 등록을src할당보다 먼저 하고 있죠? 이건 정말 중요한 포인트예요!src를 먼저 할당해 버리면 브라우저가 즉시 다운로드를 시작해 버려서, 속성을 설정하거나 이벤트를 달기도 전에 이미지가 캐시에서 로드되어 버려 이벤트가 씹히거나 CORS 에러가 날 수 있습니다. 속성 설정 -> 이벤트 리스너 등록 -> 마지막에src할당 순서를 꼭 기억하세요!
새로 다운로드된 이미지를 실제로 처리하고 저장하는 로직은 imageReceived() 메서드에 들어 있습니다.
function imageReceived() {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = downloadedImg.width;
canvas.height = downloadedImg.height;
canvas.innerText = downloadedImg.alt;
context.drawImage(downloadedImg, 0, 0);
imageBox.appendChild(canvas);
try {
localStorage.setItem("saved-image-example", canvas.toDataURL("image/png"));
} catch (err) {
console.error(`Error: ${err}`);
}
}
imageReceived() 함수는 다운로드된 이미지를 받는 HTMLImageElement에서 "load" 이벤트가 발생할 때 호출됩니다. 이 이벤트는 필요한 이미지 데이터를 모두 메모리에서 사용할 수 있게 되었을 때 비로소 트리거되죠.
함수가 시작되면 먼저 우리가 이미지를 데이터 URL 형태로 변환하는 데 사용할 새로운 <canvas> 요소를 자바스크립트로 생성합니다. 그리고 캔버스의 2D 그래픽을 그릴 수 있는 도구 상자인 2D 드로잉 컨텍스트(CanvasRenderingContext2D)에 접근해서 context 변수에 할당합니다.
캔버스의 크기를 원본 이미지의 크기(width, height)와 정확히 일치하도록 조정하고, 캔버스의 내부 텍스트를 아까 설정했던 이미지 설명으로 넣어줍니다. 그런 다음 drawImage() 메서드를 사용해서 메모리에 있는 이미지를 캔버스 위에 그려 넣습니다. 이렇게 그려진 캔버스를 문서의 특정 요소(imageBox)에 추가하면 화면에도 이미지가 보이게 되죠.
이제 대망의 로컬 저장 단계입니다! 이를 위해 브라우저의 Web Storage API가 제공하는 로컬 스토리지 메커니즘을 사용하는데, 이는 전역 객체인 localStorage를 통해 접근할 수 있습니다. 캔버스의 toDataURL() 메서드를 사용하면 캔버스에 그려진 이미지를 PNG 이미지를 나타내는 data:// 형태의 긴 문자열(Base64) URL로 변환할 수 있습니다. 그리고 이 문자열을 setItem() 메서드를 사용해서 로컬 스토리지에 텍스트 형태로 저장하는 것입니다.