안녕하세요! 캔버스 튜토리얼의 새로운 장에 오신 것을 환영합니다. 지금까지 우리는 직접 캔버스 위에 도형을 그리고 색상이나 스타일을 적용하는 방법을 배웠죠?
하지만 <canvas>의 가장 신나고 흥미로운 기능 중 하나는 바로 이미지(Images)를 직접 가져와서 도화지 위에 렌더링할 수 있다는 점입니다! 이 기능을 활용하면 사진들을 동적으로 합성하거나, 복잡한 그래프의 멋진 배경을 깔아두거나, 게임에서 캐릭터(스프라이트)를 움직이게 만드는 등 무궁무진한 일들을 할 수 있습니다.
캔버스에서는 브라우저가 지원하는 포맷(PNG, GIF, JPEG 등)이라면 어떤 외부 이미지라도 사용할 수 있습니다. 심지어 같은 페이지 내에 있는 다른 캔버스 요소가 그려낸 화면을 가져와서 소스 이미지로 써먹을 수도 있답니다!
캔버스로 이미지를 불러와 그리는 과정은 기본적으로 딱 두 단계로 요약됩니다.
HTMLImageElement 객체나 다른 캔버스 요소 등)의 참조(Reference)를 자바스크립트로 낚아챕니다. (URL을 제공해서 이미지를 동적으로 생성할 수도 있습니다.)drawImage() 함수를 호출해서 그 이미지를 도화지 위에 시원하게 촥! 그려냅니다.자, 그럼 이 과정을 어떻게 코드로 구현하는지 자세히 알아보겠습니다.
캔버스 API는 아주 똑똑해서, 이미지 소스로 활용할 수 있는 데이터 타입이 꽤나 다양합니다. 다음과 같은 타입들을 마음껏 던져주셔도 됩니다.
HTMLImageElement
가장 흔한 타입이죠! HTML 문서 안에 있는 일반적인 <img> 태그 요소나, 자바스크립트에서 new Image() 생성자로 만들어낸 이미지 객체들을 말합니다.
SVGImageElement
SVG 그래픽 문서 안에 <image> 요소로 삽입된 이미지들입니다.
HTMLVideoElement
재미있게도 HTML <video> 요소도 소스로 쓸 수 있습니다! 비디오를 넘겨주면, 브라우저가 현재 재생 중인 비디오의 해당 프레임(정지 화면)을 찰칵 캡처해서 마치 이미지처럼 사용하게 해줍니다.
HTMLCanvasElement
또 다른 <canvas> 요소도 훌륭한 이미지 소스가 됩니다. (마치 복사기처럼 쓸 수 있죠!)
ImageBitmap
고성능 비트맵 이미지 객체입니다. 커다란 원본 이미지에서 우리가 필요한 아주 작은 특정 부분(예: 게임의 스프라이트 시트 중 하나)만 효율적으로 오려내서(crop) 사용할 때 아주 유용합니다.
OffscreenCanvas
화면에 보이지 않는(offscreen) 백그라운드 캔버스입니다. 사용자의 눈에 보이지 않는 곳에서 그림을 다 완성한 다음, 진짜 캔버스로 화면 깜빡임 없이 한 번에 덮어씌울 때(더블 버퍼링 기법 등) 사용합니다.
VideoFrame
비디오 스트림에서 뽑아낸 단일 프레임을 나타내는 이미지 객체입니다.
이 다양한 소스들을 우리의 캔버스로 가져오기 위해 여러 가지 방법을 쓸 수 있습니다.
가장 간단한 방법입니다. 캔버스와 같은 HTML 문서 안에 얌전히 들어있는 이미지 요소들을 낚아채려면 다음 방법들을 씁니다.
document.images 컬렉션을 써서 페이지의 모든 이미지 목록을 가져오기document.getElementsByTagName() 메서드를 써서 특정 태그(img)들만 싹 다 긁어모으기document.getElementById()를 써서 딱 그 녀석만 집어오기!만약 여러분이 캔버스에 아주 많은 이미지를 한 번에 쏟아붓고 싶거나, 이미지들이 지연 로딩(lazy-loading) 방식으로 로드되고 있다면, 캔버스에 붓질을 시작하기 전에 반드시 모든 이미지 파일이 완전히 다운로드되어 준비될 때까지 기다려야(wait) 합니다.
아래 예제 코드는 여러 개의 이미지를 다룰 때, async 함수와 Promise.all을 사용하여 모든 이미지가 로드(load) 이벤트를 발생시킬 때까지 우아하게 기다린 후, drawImage()를 호출하는 모범적인 패턴을 보여줍니다.
async function draw() {
// 모든 이미지가 로드될 때까지 기다립니다 (비동기 처리의 꽃!):
await Promise.all(
Array.from(document.images).map(
(image) =>
new Promise((resolve) => image.addEventListener("load", resolve)),
),
);
const ctx = document.getElementById("canvas").getContext("2d");
// 모든 이미지의 로딩이 끝났으니, 이제 안심하고 평소처럼 drawImage()를 팍팍 호출하세요!
}
draw();
💡 강사의 팁: 실무에서 캔버스로 게임이나 화려한 인터랙티브 페이지를 만들 때, 이미지가 다 안 불려왔는데
drawImage를 호출해버리면 화면에 아무것도 안 나오는 하얀 화면(Blank screen) 버그가 정말 자주 발생합니다. 위 코드처럼 이미지 애셋(Assets)들을Promise.all로 묶어서 완벽하게 "Pre-load(사전 로딩)" 해두는 로직은 프론트엔드 개발자라면 꼭 눈여겨봐야 할 꿀팁입니다!
HTML 페이지에 아예 <img> 태그가 없더라도, 우리는 스크립트(JS) 안에서 마법처럼 새로운 HTMLImageElement 객체를 무에서 유로 창조해 낼 수 있습니다. 편안하게 Image() 생성자라는 녀석을 쓰면 되죠!
const img = new Image(); // 새 이미지 요소를 메모리상에 만듭니다. (화면엔 안 보임)
img.src = "myImage.png"; // 소스 경로를 할당하는 순간, 브라우저가 몰래 다운로드를 시작합니다!
이 스크립트가 실행되고 src에 경로가 꽂히는 순간, 이미지는 부리나케 다운로드를 시작합니다. 하지만 조심하세요! 이미지가 다운로드를 마치기도 전에 성급하게 drawImage()를 호출해 버리면 캔버스는 아무 일도 하지 않고 무시해 버릴 겁니다. (아주 오래된 구형 브라우저에서는 아예 스크립트 에러를 뿜어내며 뻗어버릴 수도 있어요 😱).
따라서 여러분은 캔버스에 붓을 대기 전에 그림이 다 준비되었는지 확인하기 위해, 반드시 이미지의 load 이벤트 리스너를 활용해야 합니다.
const ctx = document.getElementById("canvas").getContext("2d");
const img = new Image();
// "이미지야, 다운로드 다 끝나면 이 함수 실행해 줘!"
img.addEventListener("load", () => {
ctx.drawImage(img, 0, 0); // 이제 안전하게 그려!
});
img.src = "myImage.png"; // 리스너를 먼저 달아두고, 마지막에 소스를 할당하는 것이 가장 안전합니다.
여러분이 HTML 마크업에 <img> 태그를 적어뒀든, 자바스크립트로 프로그래밍해서 생성했든 상관없이, 외부 서버(다른 도메인)에서 가져온 이미지들은 항상 CORS (교차 출처 리소스 공유) 정책의 깐깐한 감시를 받게 됩니다.
기본적으로 다른 도메인에서 몰래 가져온 이미지를 내 캔버스에 그리면, 브라우저는 그 캔버스를 "오염된(tainted) 상태"로 낙인찍어 버립니다. 오염된 캔버스에서는 getImageData() 같은 메서드를 통해 캔버스의 픽셀 데이터를 뽑아내는(read) 행위가 보안상 철저하게 차단됩니다. (해커들이 다른 사이트의 이미지를 캔버스에 그린 다음 데이터를 훔쳐가는 걸 막기 위해서죠.)
이럴 때는 <img> 요소의 crossorigin 속성(자바스크립트에서는 HTMLImageElement.crossOrigin 속성)을 사용해서 "CORS 권한을 허락해 주세요!"라고 당당하게 요청해야 합니다. 만약 상대방(이미지 호스팅 서버)이 여러분의 도메인에서 이미지를 사용하는 것을 허용해 준다면(적절한 CORS 응답 헤더 제공), 비로소 오염 걱정 없이 깨끗하게 캔버스에서 이미지를 지지고 볶을 수 있습니다.
이미지를 포함시키는 또 다른 재미있는 방법은 바로 data: URL 스킴을 사용하는 것입니다. Data URL은 이미지 파일 자체를 통째로 Base64라는 문자열 덩어리로 인코딩해서, 여러분의 코드(CSS나 JS) 안에 글자 형태로 직접 박아버리는(embed) 기술입니다.
const img = new Image(); // 새 이미지 요소 생성
// 엄청 길고 복잡해 보이는 문자열 덩어리가 바로 이미지 데이터 그 자체입니다!
img.src =
"data:image/gif;base64,R0lGODlhCwALAIAAAAAA3pn/ZiH5BAEAAAEALAAAAAALAAsAAAIUhA+hkcuO4lmNVindo7qyrIXiGBYAOw==";
이 Data URL 방식의 가장 큰 장점은, 이미지가 코드 자체에 텍스트로 포함되어 있기 때문에 서버에 별도의 이미지 다운로드 요청(HTTP request)을 보낼 필요 없이 즉시! 즉각적으로! 사용 가능하다는 점입니다. 또 다른 장점은 CSS, 자바스크립트, HTML, 심지어 이미지들까지 전부 텍스트로 변환해서 단 하나의 단일 파일(single file)로 돌돌 말아 패키징할 수 있어서, 어디든 손쉽게 이동(portable) 시킬 수 있다는 것이죠.
물론 단점도 뚜렷합니다. 브라우저가 이 이미지를 따로 캐싱(caching)하지 못하며, 이미지가 조금만 커져도 인코딩된 URL 문자열의 길이가 수만, 수십만 자로 끝없이 길어져 코드가 매우 뚱뚱해진다는 점입니다. (작은 아이콘이나 로딩 스피너 같은 것에만 쓰는 걸 추천해요!)
일반적인 이미지를 쓸 때와 똑같이, document.getElementsByTagName() 이나 document.getElementById() 같은 메서드들로 페이지 안의 다른 캔버스 요소를 찾아 참조로 가져오면 됩니다. 단, 소스로 써먹을 캔버스 요소가 대상 캔버스에 그려지기 전에 뭔가 내용이 칠해져 있어야 한다는 점만 주의하세요 (빈 캔버스를 복사해 봤자 투명하니까요!).
이 기능이 가장 요긴하게 쓰이는 실무 패턴 중 하나는, 커다란 메인 캔버스가 있을 때 그 내용을 복사해서 옆에 작게 '미니맵'이나 '썸네일 뷰(thumbnail view)'용 두 번째 캔버스를 만들어 보여주는 것입니다. 아주 똑똑한 활용법이죠!
화면에 보이든, display: none으로 숨겨져 있든 상관없이 HTML <video> 요소가 재생하고 있는 비디오에서 원하는 프레임을 쏙 뽑아서 캔버스에 그릴 수도 있습니다.
예를 들어, "myVideo"라는 ID를 가진 <video> 요소가 있다면 이렇게 할 수 있습니다:
const video = document.getElementById("myVideo");
video.currentTime = 10; // 비디오의 재생 위치를 정확히 10초 지점으로 탐색(Seek)합니다.
video.pause(); // 화면을 멈춰서(freeze) 프레임을 고정시킵니다.
이제 이 HTMLVideoElement는 10초 지점에 멈춰 섰습니다. 이제 여러분은 이 현재 프레임을 캔버스에 촥 그릴 수 있습니다. 참고로, drawImage()를 호출할 때 비디오 프레임이 렌더링 할 준비가 완전히 끝난 상태인지 완벽하게 보장하고 싶다면, requestVideoFrameCallback() 이라는 최신 메서드 안에서 drawImage()를 호출하는 것이 좋습니다.
자, 이제 소스 이미지 객체에 대한 참조를 손에 넣었으니, 대망의 drawImage() 메서드를 사용해서 캔버스에 이미지를 렌더링해 봅시다. 나중에 보시겠지만 이 drawImage() 메서드는 매개변수를 몇 개 넣느냐에 따라 3가지 다른 방식으로 동작하도록 오버로딩(overloaded)되어 있습니다. 우선 가장 기본적이고 단순한 형태부터 보겠습니다.
drawImage(image, x, y)
첫 번째 image 인자로 전달된 이미지를 가져와서, 캔버스의 (x, y) 좌표 위치(이미지의 왼쪽 위 꼭짓점이 이 좌표에 맞물림)에 원본 크기 그대로 그립니다.
참고: 소스로 SVG 이미지를 사용할 때는 반드시 SVG 파일 내부의 최상위
<svg>요소에width와height속성을 픽셀 단위로 명시해 주어야 합니다. 안 그러면 캔버스가 이 SVG가 얼마나 큰지 몰라서 당황하게 됩니다.
다음 예제에서는 캔버스 배경에 예쁜 외부 이미지를 먼저 쫙 깔아두고, 그 위에 작은 선 그래프를 그려보겠습니다. 배경 이미지를 사용하면 격자무늬나 배경 효과를 일일이 자바스크립트 코드로 그릴 필요가 없어지므로 스크립트 용량을 극적으로 줄일 수 있는 꿀팁입니다.
이 예제에서는 외부 이미지를 딱 하나만 쓰기 때문에, 이미지 객체의 load 이벤트 핸들러 안에 모든 그리기 코드를 몰아넣었습니다. drawImage() 메서드는 캔버스의 가장 왼쪽 위 원점인 좌표 (0, 0)에 배경 이미지를 턱 하니 내려놓을 겁니다.
<canvas id="canvas" width="180" height="150"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
const img = new Image(); // 이미지 객체 준비
// 이미지가 다운로드 완료되면 실행될 함수
img.onload = () => {
// 1. 배경 이미지를 (0,0) 위치에 먼저 깔아줍니다.
ctx.drawImage(img, 0, 0);
// 2. 그 위에 선 그래프를 슥슥 그립니다.
ctx.beginPath();
ctx.moveTo(30, 96);
ctx.lineTo(70, 66);
ctx.lineTo(103, 76);
ctx.lineTo(170, 15);
ctx.stroke();
};
// 소스 할당 (다운로드 시작!)
img.src = "backdrop.png";
}
draw();
완성된 그래프는 아래와 같이 멋지게 나타날 겁니다!
[이미지를 로드하여 캔버스 배경으로 깔고, 그 위에 꺾은선 그래프가 그려진 모습]
drawImage() 메서드의 두 번째 변형(variant)은 매개변수를 2개 더 받아서, 이미지를 캔버스에 그릴 때 원하는 크기로 마음대로 늘리거나 줄일 수(스케일링) 있게 해 줍니다.
drawImage(image, x, y, width, height)
기존에 있던 x, y 뒤에 width와 height라는 두 개의 매개변수가 추가되었습니다. 이 값들은 이미지를 캔버스에 그릴 때, 해당 이미지를 가로로 얼마나, 세로로 얼마나 늘리거나 줄여서 그릴지 **목적지 크기**를 지시합니다.
이번 예제에서는 코뿔소 이미지를 하나 가져와서 캔버스 배경 벽지처럼 여러 번 반복해서 타일 형태로 깔아보겠습니다. 반복문(loop)을 돌리면서 캔버스의 좌표(x, y)를 계속 바꿔가며 축소된 이미지를 도장 찍듯 찍어내는 방식이죠.
아래 코드를 보면 첫 번째 for 루프는 행(row, 세로 방향)을 반복하고, 두 번째 중첩된 for 루프는 열(column, 가로 방향)을 돌면서 도장을 찍습니다. 이때 원래 큰 코뿔소 이미지를 50x38 픽셀 크기로 꽉 눌러서 축소시켰습니다. (대략 원본 크기의 3분의 1 정도네요!)
참고: 이미지를 억지로 너무 크게 스케일링(Scale-up)하면 화면이 흐릿하게 뭉개지고, 너무 작게 스케일링(Scale-down)하면 이미지가 자글자글해지는 픽셀 깨짐(grainy) 현상이 생길 수 있습니다. 특히 이미지 안에 뚜렷하게 읽어야 하는 '글자(Text)'가 들어있다면 웬만하면 스케일링은 피하시는 게 좋습니다.
<canvas id="canvas" width="150" height="150"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
const img = new Image();
img.onload = () => {
// 4행 3열로 루프를 돕니다.
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 3; j++) {
// x좌표는 j * 50, y좌표는 i * 38씩 띄워서 도장을 찍습니다.
// 이미지의 크기는 50x38로 강제로 축소시켰습니다!
ctx.drawImage(img, j * 50, i * 38, 50, 38);
}
}
};
img.src = "[https://mdn.github.io/shared-assets/images/examples/rhino.jpg](https://mdn.github.io/shared-assets/images/examples/rhino.jpg)";
}
draw();
그 결과, 캔버스에는 작은 코뿔소들이 벽지처럼 예쁘게 깔리게 됩니다!
[코뿔소 이미지가 가로 3개, 세로 4개로 바둑판처럼 나열되어 캔버스를 꽉 채우고 있는 모습]
대망의 drawImage() 메서드 마지막 세 번째 변형입니다! 이 녀석은 소스 이미지 외에도 무려 8개의 매개변수를 주렁주렁 달고 다닙니다. 굉장히 복잡해 보이지만, 이 기능 하나면 소스 이미지의 아주 일부분만 가위로 싹둑 잘라내서(Slice), 크기를 마음대로 조절한 뒤, 캔버스의 원하는 위치에 턱 하니 붙여 넣을 수 있는 엄청난 권한을 얻게 됩니다.
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
이 함수는 image를 받아옵니다. 그런 다음, 원본 소스 이미지 안에서 (sx, sy) 좌표를 시작점(왼쪽 위)으로 하고 너비가 sWidth, 높이가 sHeight인 네모난 영역을 가위로 싹둑 오려냅니다(Source 영역).
오려낸 이 조각을 들고 와서, 캔버스 도화지 위 (dx, dy) 좌표에 내려놓습니다. 이때 오려낸 조각의 크기를 dWidth와 dHeight 크기로 강제로 쫙 늘리거나 줄여서(스케일링) 그려냅니다 (Destination 영역). (원본의 종횡비(가로세로 비율)가 유지되게 하려면 이 숫자 계산을 잘하셔야 합니다!)
이 8개의 파라미터가 정확히 뭘 하는지 직관적으로 이해하시려면 아래 이미지를 보시는 게 가장 빠릅니다!
[원본 이미지에서 (sx, sy, sWidth, sHeight) 영역을 잘라내어, 대상 캔버스의 (dx, dy, dWidth, dHeight) 영역에 그려 넣는 원리를 보여주는 구조도]
가운데 4개의 매개변수(s로 시작하는 놈들)는 '원본 이미지(Source)'에서 어느 부분을 잘라낼 것인지 그 위치와 크기를 정의합니다. 마지막 4개의 매개변수(d로 시작하는 놈들)는 잘라낸 이미지를 '목적지 캔버스(Destination)'의 어느 위치에, 어떤 크기로 그릴 것인지를 정의하는 사각형입니다.
이 슬라이싱(자르기) 기능은 여러 이미지를 조합(compositing)할 때 아주 막강한 도구가 됩니다.
예를 들어, 게임을 만들 때 캐릭터의 뛰는 모습, 점프하는 모습, 멈춘 모습 등 수십 개의 동작을 각기 다른 이미지 파일로 관리하는 대신, 거대한 이미지 파일 하나(스프라이트 시트)에 모두 몰아넣고 이 drawImage의 자르기 기능을 이용해 필요한 순간에 필요한 동작 부분만 오려내어 애니메이션을 만들 수 있습니다.
또는, 차트를 그릴 때 필요한 모든 숫자나 텍스트 이미지들을 한 파일에 모아두고 오려 쓰면, 데이터에 따라 차트의 스케일을 변경할 때 아주 유연하게 대처할 수 있죠. 이 방식의 가장 큰 장점은, 브라우저가 수많은 자잘한 이미지 파일들을 하나하나 일일이 다운로드하는 대신 큰 파일 하나만 다운로드하면 되므로 페이지 로딩 성능(네트워크 성능)이 극도로 향상된다는 점입니다!
이 예제에서는 이전 예제에 썼던 코뿔소 사진을 다시 활용할 건데요. 이번에는 코뿔소의 '얼굴 부분'만 동그랗게 잘라내서(Slice), 아주 우아한 그림 액자 안에 예쁘게 합성해 보겠습니다!
여기 쓰이는 그림 액자 이미지는 자체 그림자 효과(Drop shadow)를 포함하고 있는 24-bit PNG 파일입니다. GIF나 8-bit PNG와는 달리, 24-bit PNG 이미지는 완벽한 투명도를 지원하는 8-bit 알파 채널(Alpha channel)을 포함하고 있기 때문에, 배경색이나 매트(Matte) 컬러를 걱정할 필요 없이 어떤 배경 위에든 투명도를 살려서 아주 예쁘게 얹을 수 있답니다.
<canvas id="canvas" width="150" height="150"></canvas>
<div class="hidden">
<img
id="source"
src="[https://mdn.github.io/shared-assets/images/examples/rhino.jpg](https://mdn.github.io/shared-assets/images/examples/rhino.jpg)"
width="300"
height="227" />
<img id="frame" src="canvas_picture_frame.png" width="132" height="150" />
</div>
.hidden {
display: none;
}
async function draw() {
// 모든 숨겨둔 이미지들이 완벽하게 다운로드될 때까지 대기합니다.
await Promise.all(
Array.from(document.images).map(
(image) =>
new Promise((resolve) => image.addEventListener("load", resolve)),
),
);
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// 1. 코뿔소 이미지 슬라이싱해서 그리기!
ctx.drawImage(
document.getElementById("source"), // 원본 코뿔소 사진
33, // 원본 이미지의 x좌표 33 지점에서 (코뿔소 얼굴 시작)
71, // 원본 이미지의 y좌표 71 지점에서
104, // 너비 104픽셀만큼
124, // 높이 124픽셀만큼을 가위로 싹둑 오려냅니다! (Source 영역)
21, // 캔버스(도화지)의 x좌표 21 위치에 내려놓고
20, // 캔버스의 y좌표 20 위치에 내려놓습니다.
87, // 캔버스에 그릴 땐 너비를 87픽셀로 줄이고
104, // 높이를 104픽셀로 약간 줄여서 그립니다! (Destination 영역)
);
// 2. 그 위에 속이 뻥 뚫린 투명한 액자 이미지를 덮어씌웁니다.
ctx.drawImage(document.getElementById("frame"), 0, 0);
}
draw();
이번에는 이미지를 불러오는 방식에 살짝 변화를 주었습니다. 자바스크립트에서 new Image()로 객체를 만들어 로딩하는 대신, HTML 마크업 안에 <img id="source">, <img id="frame">처럼 태그를 직접 박아두고 이미지를 다운받게 했습니다. 그리고 이 이미지들이 페이지에 흉하게 보이지 않도록 CSS의 display: none을 먹여서 사용자 몰래 숨겨버렸죠. 그러고 나서 자바스크립트는 getElementById()를 이용해 이 숨겨진 이미지들을 쏙쏙 골라와 캔버스에 그리기만 한 것입니다.
그 결과물은 바로 이렇습니다!
[코뿔소의 얼굴만 정교하게 잘라져서 우아한 그림 액자 이미지 안쪽에 감쪽같이 합성되어 들어간 모습]
이 챕터의 대미를 장식할 마지막 예제로, 아주 귀여운 작은 아트 갤러리(미술관)를 구축해 보겠습니다! 이 갤러리는 표(Table)로 만들어져 있고, 그 안에 8개의 썸네일 사진들이 들어있습니다. 페이지가 짠! 하고 열리면, 자바스크립트가 부지런히 돌아가면서 각 사진마다 새로운 <canvas> 요소를 하나씩 만들어 끼워 넣고, 그 주변에 그림 액자 이미지를 예쁘게 덮어 씌워 줄 것입니다.
이 예제에서는 모든 사진들의 가로세로 크기가 완전히 동일하다고 가정했고, 그 사진을 덮어씌울 액자(frame) 이미지의 크기 역시 고정되어 있습니다. 만약 여러분이 조금 더 똑똑한 스크립트를 짜고 싶다면, 사진들마다 각각 다른 원래의 너비(width)와 높이(height) 속성을 읽어와서 거기에 액자의 크기가 유동적으로 딱 맞아떨어지도록 로직을 업그레이드해 볼 수도 있겠죠!
아래 코드에서도 역시 모든 이미지 로딩이 완료될 때까지 안전하게 기다리기 위해 Promise.all을 듬직하게 사용했습니다.
우리는 document.images 컬렉션을 쭉 순회(loop)하면서, 각 사진마다 document.createElement("canvas")를 사용해 새로운 캔버스 요소를 찍어냅니다.
여기서 눈여겨볼 만한 핵심 포인트는 바로 Node.insertBefore 메서드를 활용했다는 점입니다. insertBefore()는 특정 부모 노드(여기서는 <td> 테이블 셀)에게 "내가 방금 만든 새 노드(캔버스)를, 저 기존에 있던 노드(사진) 바로 앞(before)에 끼워 넣어줘!"라고 지시하는 DOM 조작 메서드입니다.
<table>
<tbody>
<tr>
<td><img src="gallery_1.jpg" /></td>
<td><img src="gallery_2.jpg" /></td>
<td><img src="gallery_3.jpg" /></td>
<td><img src="gallery_4.jpg" /></td>
</tr>
<tr>
<td><img src="gallery_5.jpg" /></td>
<td><img src="gallery_6.jpg" /></td>
<td><img src="gallery_7.jpg" /></td>
<td><img src="gallery_8.jpg" /></td>
</tr>
</tbody>
</table>
<img id="frame" src="canvas_picture_frame.png" width="132" height="150" />
body {
background: 0 -100px repeat-x url("bg_gallery.png") #4f191a; /* 미술관 벽지 배경 */
margin: 10px;
}
img {
display: none; /* 원래의 img 태그들은 화면에서 모두 숨깁니다! */
}
table {
margin: 0 auto; /* 테이블을 화면 중앙에 정렬 */
}
td {
padding: 15px; /* 액자들 사이의 간격 */
}
이제 모든 걸 하나로 묶어주는 마법의 자바스크립트 코드입니다.
async function draw() {
// 모든 이미지(사진 8장 + 액자 1장)가 로드 완료될 때까지 대기합니다.
await Promise.all(
Array.from(document.images).map(
(image) =>
new Promise((resolve) => image.addEventListener("load", resolve)),
),
);
// 문서 안의 모든 이미지 태그를 하나씩 꺼내어 반복문을 돕니다.
for (const image of document.images) {
// 단, 액자(frame) 이미지 자체에는 캔버스를 추가하면 안 되니까 건너뜁니다!
if (image.getAttribute("id") !== "frame") {
// 1. 캔버스 요소를 새로 창조합니다.
const canvas = document.createElement("canvas");
canvas.setAttribute("width", 132); // 액자 크기에 맞춰 너비 설정
canvas.setAttribute("height", 150); // 액자 크기에 맞춰 높이 설정
// 2. 캔버스를 기존 사진(img) 요소 바로 앞에 DOM 트리 상에 삽입합니다.
image.parentNode.insertBefore(canvas, image);
// (위에서 숨겼기 때문에 기존 img는 보이지 않고, 이 새 캔버스만 보이게 됩니다.)
ctx = canvas.getContext("2d");
// 3. 캔버스 위에 원본 사진을 그립니다. (액자 틀 안에 쏙 들어가게 여백(15, 20)을 줌)
ctx.drawImage(image, 15, 20);
// 4. 마지막으로 그 사진 위에 투명한 구멍이 뚫린 액자 이미지를 스윽 덮어씌웁니다.
ctx.drawImage(document.getElementById("frame"), 0, 0);
}
}
}
draw();
이렇게 하면 밋밋했던 사각형 사진들이 순식간에 고급스러운 그림 액자에 담긴 갤러리로 변신합니다!
[테이블 안의 8개 셀 각각에 캔버스가 삽입되고, 그 안쪽에는 사진이, 바깥쪽에는 투명한 테두리를 가진 액자 이미지가 입체적으로 덮어씌워진 화려한 갤러리 결과물]
자, 마지막으로 하나만 더 짚고 넘어가겠습니다. 앞서 배운 대로 이미지를 강제로 크게 확대하거나(Scale-up) 너무 작게 축소하다(Scale-down) 보면, 스케일링 과정에서 이미지 외곽선이 부연 안개처럼 흐릿해지거나(fuzzy), 픽셀이 모자이크처럼 뭉개지는(blocky) 불쾌한 현상(artifacts)이 발생할 수 있다고 했죠.
이때 여러분은 캔버스 드로잉 컨텍스트의 imageSmoothingEnabled라는 마법의 스위치(속성)를 꺼내들 수 있습니다! 이 속성을 제어하면 브라우저가 이미지를 확대/축소할 때 픽셀 사이사이를 부드럽게 문질러주는 '이미지 스무딩(안티앨리어싱) 알고리즘'을 쓸지 말지 여러분이 직접 지시할 수 있게 됩니다.
이 속성의 기본값은 true입니다. 즉, 별도의 설정을 하지 않으면 브라우저는 항상 이미지를 크기에 맞게 쭈욱 늘리면서 픽셀들을 열심히 문질러(smoothed) 부드럽게 보이도록 최선을 다합니다. 만약 레트로한 느낌의 도트 그래픽 게임(Pixel Art)을 만들어서 네모난 픽셀의 각진 맛을 그대로 살리고 싶다면? 당장 저 속성을 false로 꺼버리면 되는 겁니다!
자, 정말 수고 많으셨습니다. 캔버스에서 이미지를 자유자재로 요리하는 법을 마스터하셨네요! 이 지식을 무기 삼아 멋진 시각 효과들을 만들어보시길 바라며, 궁금한 점은 언제든 물어보세요!