Canvas drawImage
웹에서 이미지를 보여주는 건 <img> 태그면 충분하다. 그런데 이미지를 자르거나, 합성하거나, 비디오 프레임을 캡처하거나, 실시간으로 조작해야 하는 순간이 온다. 포토 에디터, 비디오 합성기, 게임 렌더링, 워터마크 삽입 같은 상황이다. 이때 <img> 태그로는 할 수 있는 게 없다. 이미지를 "보여주는" 게 아니라 "그리는" 영역이 필요하고, 그게 바로 Canvas다.
drawImage()는 Canvas 2D 렌더링 컨텍스트에서 가장 핵심적인 메서드다. 이름 그대로 이미지를 캔버스 위에 "그린다". 하지만 단순히 이미지만 그리는 게 아니다. HTMLImageElement, HTMLVideoElement, HTMLCanvasElement, ImageBitmap, OffscreenCanvas, VideoFrame 등 다양한 소스를 받아들이고, 소스의 일부분만 잘라서 크기를 변환하며 캔버스에 배치할 수 있다.
세 가지 호출 형태
drawImage()는 인자 개수에 따라 세 가지 형태로 동작한다. 단순 배치, 크기 조절, 부분 크롭+배치. 세 형태가 하나의 메서드에 오버로딩되어 있다는 게 핵심이다.
1. 3-인자: 단순 배치
ctx.drawImage(image, dx, dy);
소스 이미지를 원본 크기 그대로 캔버스의 (dx, dy) 좌표에 그린다. 가장 단순한 형태다.
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = '/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0); // 캔버스 좌상단에 원본 크기로
};
여기서 (dx, dy)는 destination의 x, y 좌표다. 캔버스에서 이미지가 시작되는 왼쪽 상단 모서리의 위치를 의미한다. 이미지가 캔버스보다 크면? 넘치는 부분은 그냥 잘린다. 에러가 나지는 않는다.
2. 5-인자: 크기 조절
ctx.drawImage(image, dx, dy, dWidth, dHeight);
소스 이미지를 dWidth × dHeight 크기로 스케일링해서 그린다.
// 원본이 1920x1080이라도 300x200으로 축소해서 그린다
ctx.drawImage(img, 10, 10, 300, 200);
비율을 유지하려면 직접 계산해야 한다. drawImage()는 비율 따위 신경 쓰지 않고, 주어진 크기에 맞춰 늘리거나 줄인다.
// 가로 기준으로 비율 유지
const ratio = img.naturalHeight / img.naturalWidth;
const targetWidth = 400;
ctx.drawImage(img, 0, 0, targetWidth, targetWidth * ratio);
dWidth나 dHeight에 음수를 넣으면 이미지가 뒤집힌다. 다만 실무에서 뒤집기는 ctx.scale(-1, 1) 같은 변환을 쓰는 게 더 명확하다.
3. 9-인자: 소스 크롭 + 배치
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
이게 drawImage()의 진짜 힘이다. 소스 이미지에서 특정 영역만 잘라내서, 캔버스의 원하는 위치에 원하는 크기로 그린다.
┌─────────────────────┐
│ 소스 이미지 │
│ ┌──────┐ │
│ │(sx,sy)│ │
│ │ crop │ sHeight │
│ │ area │ │
│ └──────┘ │
│ sWidth │
└─────────────────────┘
↓ drawImage ↓
┌──────────────────┐
│ 캔버스 │
│ ┌────────┐ │
│ │(dx,dy) │ │
│ │ drawn │dHeight│
│ │ result │ │
│ └────────┘ │
│ dWidth │
└──────────────────┘
인자 순서가 중요하다. 앞 4개가 소스(source) 영역, 뒤 4개가 대상(destination) 영역이다. 소스에서 어디를 자를지 → 캔버스에 어디에 얼마나 크게 그릴지. 이 순서를 기억하면 헷갈리지 않는다.
// 원본 이미지에서 (100, 50) 위치부터 200x200 영역을 잘라서
// 캔버스의 (0, 0)에 400x400 크기로 확대해서 그린다
ctx.drawImage(img, 100, 50, 200, 200, 0, 0, 400, 400);
스프라이트 시트에서 특정 프레임을 뽑아내거나, 이미지의 특정 부분만 확대해서 보여줄 때 이 형태를 쓴다.
사용 가능한 소스 타입
drawImage()의 첫 번째 인자는 CanvasImageSource 타입이다. 이건 여러 타입의 유니온이다.
HTMLImageElement
가장 기본적인 소스. new Image()로 생성하거나 DOM에서 가져온 <img> 요소.
const img = new Image();
img.src = 'photo.jpg';
img.onload = () => ctx.drawImage(img, 0, 0);
반드시 onload 이후에 그려야 한다. 이미지 로딩이 완료되기 전에 drawImage()를 호출하면 아무것도 그려지지 않는다. 에러도 나지 않아서 디버깅이 어렵다. 이게 가장 흔한 실수다.
HTMLVideoElement
비디오의 현재 프레임을 정지 이미지처럼 캔버스에 그린다. 이게 비디오 편집, 필터 적용, 프레임 캡처의 기반이 된다.
const video = document.getElementById('myVideo');
// 비디오의 현재 프레임을 그린다
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
한 번 호출하면 그 시점의 프레임 하나만 그려진다. 연속적으로 비디오를 캔버스에 렌더링하려면 requestAnimationFrame과 조합해야 한다.
function renderFrame() {
if (video.paused || video.ended) return;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
requestAnimationFrame(renderFrame);
}
video.addEventListener('play', renderFrame);
이 패턴이 캔버스 기반 비디오 필터, 크로마키, 워터마크 삽입의 출발점이다. 매 프레임마다 drawImage()로 비디오를 캔버스에 올리고, getImageData()로 픽셀을 조작한 뒤, 다시 putImageData()로 그린다.
HTMLCanvasElement / OffscreenCanvas
다른 캔버스를 소스로 쓸 수 있다. 이건 다중 레이어 합성의 기본이다.
// 배경 레이어
const bgCanvas = document.createElement('canvas');
const bgCtx = bgCanvas.getContext('2d');
bgCtx.drawImage(backgroundImg, 0, 0);
// 전경 레이어
const fgCanvas = document.createElement('canvas');
const fgCtx = fgCanvas.getContext('2d');
fgCtx.drawImage(characterImg, 0, 0);
// 최종 합성
const mainCtx = mainCanvas.getContext('2d');
mainCtx.drawImage(bgCanvas, 0, 0); // 배경 먼저
mainCtx.drawImage(fgCanvas, 0, 0); // 전경 위에
OffscreenCanvas는 메인 스레드 바깥(Web Worker)에서도 사용할 수 있어서 무거운 이미지 처리를 백그라운드에서 수행할 때 유용하다.
ImageBitmap
createImageBitmap()으로 생성하는 최적화된 비트맵 객체다. 이미지 디코딩을 비동기로 처리하고, 디코딩된 결과를 재사용할 수 있다.
const response = await fetch('photo.jpg');
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0); // 디코딩 완료 상태라 빠르다
bitmap.close(); // 메모리 해제
Image 객체는 drawImage() 호출 시 매번 내부적으로 디코딩이 필요할 수 있지만, ImageBitmap은 이미 디코딩된 픽셀 데이터를 들고 있어서 반복 렌더링 시 성능 이점이 있다. 게임이나 애니메이션처럼 같은 이미지를 반복해서 그리는 상황에서 체감된다.
VideoFrame
WebCodecs API의 VideoFrame 객체. 비디오 프레임 단위의 정밀한 제어가 필요할 때 사용한다. 일반적인 웹 앱에서는 거의 쓸 일이 없고, 미디어 처리 파이프라인을 직접 구축할 때 등장한다.
실전 활용 패턴
스프라이트 시트 렌더링
게임이나 애니메이션에서 하나의 큰 이미지에 여러 프레임을 배치해놓고, 9-인자 형태로 특정 프레임만 꺼내 그린다.
const FRAME_WIDTH = 64;
const FRAME_HEIGHT = 64;
const COLS = 8; // 한 줄에 8프레임
function drawSprite(spriteSheet, frameIndex, x, y) {
const col = frameIndex % COLS;
const row = Math.floor(frameIndex / COLS);
ctx.drawImage(
spriteSheet,
col * FRAME_WIDTH, // sx: 소스에서 잘라낼 x
row * FRAME_HEIGHT, // sy: 소스에서 잘라낼 y
FRAME_WIDTH, // sWidth: 잘라낼 너비
FRAME_HEIGHT, // sHeight: 잘라낼 높이
x, y, // dx, dy: 캔버스에 그릴 위치
FRAME_WIDTH, // dWidth: 그릴 너비
FRAME_HEIGHT // dHeight: 그릴 높이
);
}
// 애니메이션 루프
let frame = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawSprite(sheet, frame, 100, 100);
frame = (frame + 1) % totalFrames;
requestAnimationFrame(animate);
}
스프라이트 시트의 장점은 HTTP 요청 횟수 감소와 텍스처 전환 비용 절감이다. 프레임마다 별도 이미지를 로드하면 네트워크 오버헤드도 크고, 브라우저가 이미지 소스를 전환하는 비용도 발생한다.
이미지 크롭 (사용자 영역 선택)
프로필 사진 편집기 같은 UI에서 사용자가 드래그로 선택한 영역을 잘라내는 패턴.
function cropImage(img, selection) {
const { x, y, width, height } = selection;
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const outputCtx = outputCanvas.getContext('2d');
outputCtx.drawImage(
img,
x, y, width, height, // 소스에서 선택 영역
0, 0, width, height // 출력 캔버스 전체
);
return outputCanvas.toDataURL('image/jpeg', 0.9);
}
워터마크 합성
이미지 위에 반투명 워터마크를 겹쳐 그리는 패턴.
function addWatermark(baseImg, watermarkImg) {
const canvas = document.createElement('canvas');
canvas.width = baseImg.naturalWidth;
canvas.height = baseImg.naturalHeight;
const ctx = canvas.getContext('2d');
// 원본 이미지
ctx.drawImage(baseImg, 0, 0);
// 워터마크 (우하단, 반투명)
ctx.globalAlpha = 0.3;
const wmWidth = canvas.width * 0.2;
const wmHeight = wmWidth * (watermarkImg.naturalHeight / watermarkImg.naturalWidth);
ctx.drawImage(
watermarkImg,
canvas.width - wmWidth - 20,
canvas.height - wmHeight - 20,
wmWidth,
wmHeight
);
ctx.globalAlpha = 1.0; // 반드시 복원
return canvas.toDataURL('image/png');
}
globalAlpha를 설정하면 이후 모든 그리기 작업에 적용되므로, 작업이 끝나면 반드시 1.0으로 복원해야 한다. 안 그러면 이후에 그리는 모든 것이 반투명으로 나온다.
비디오 프레임에서 썸네일 추출
비디오의 특정 시간대 프레임을 이미지로 캡처하는 패턴.
function captureFrame(videoUrl, timeInSeconds) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.src = videoUrl;
video.addEventListener('loadeddata', () => {
video.currentTime = timeInSeconds;
});
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
resolve(canvas.toDataURL('image/jpeg', 0.8));
});
video.addEventListener('error', reject);
});
}
// 사용
const thumbnail = await captureFrame('/video.mp4', 5.0);
loadeddata 이벤트 후 currentTime을 설정하면 해당 시점으로 이동하고, seeked 이벤트가 발생하면 그 프레임이 준비된 상태다. 이때 drawImage()로 캡처한다.
성능 고려사항
큰 이미지의 반복 렌더링
매 프레임마다 4000×3000 원본을 drawImage()로 그리면 느리다. 실제 표시할 크기가 400×300이라면 미리 작은 캔버스에 축소해놓고 그 캔버스를 소스로 사용하는 게 좋다.
// 미리 축소된 캐시 캔버스 생성
const cacheCanvas = document.createElement('canvas');
cacheCanvas.width = 400;
cacheCanvas.height = 300;
cacheCanvas.getContext('2d').drawImage(largeImage, 0, 0, 400, 300);
// 렌더 루프에서는 캐시된 작은 이미지를 그린다
function render() {
ctx.drawImage(cacheCanvas, x, y); // 400x300 소스 → 훨씬 빠름
requestAnimationFrame(render);
}
ImageBitmap으로 디코딩 분리
new Image()의 경우, drawImage() 호출 시 브라우저가 내부적으로 이미지를 디코딩해야 할 수 있다. 이 디코딩이 메인 스레드에서 일어나면 프레임 드롭이 발생한다.
// 안 좋은 패턴: 여러 이미지를 한 프레임에서 처음으로 그릴 때
images.forEach(img => ctx.drawImage(img, ...)); // 각각 디코딩 발생 가능
// 좋은 패턴: 미리 ImageBitmap으로 변환
const bitmaps = await Promise.all(
urls.map(async url => {
const res = await fetch(url);
const blob = await res.blob();
return createImageBitmap(blob);
})
);
// 이후 drawImage는 이미 디코딩된 비트맵을 사용
bitmaps.forEach(bm => ctx.drawImage(bm, ...));
clearRect와 조합
애니메이션에서 drawImage() 전에 이전 프레임을 지우지 않으면 잔상이 남는다.
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 이전 프레임 클리어
ctx.drawImage(sprite, x, y);
requestAnimationFrame(animate);
}
배경이 불투명하게 꽉 차는 경우라면 clearRect 없이 바로 위에 그려도 된다. 하지만 투명 영역이 있는 PNG라면 반드시 클리어해야 한다.
자주 만나는 문제
이미지 로드 전 그리기
// ❌ 이미지가 아직 로드 안 됐을 수 있다
const img = new Image();
img.src = 'photo.jpg';
ctx.drawImage(img, 0, 0); // 빈 화면
// ✅ onload 또는 decode() 사용
const img = new Image();
img.src = 'photo.jpg';
await img.decode();
ctx.drawImage(img, 0, 0);
img.decode()는 이미지 로딩과 디코딩이 모두 완료되면 resolve되는 Promise를 반환한다. onload보다 더 정확하다 — onload는 로드만 완료된 시점이고 디코딩은 아직 안 끝났을 수 있다.
CORS와 Canvas taint
다른 도메인의 이미지를 캔버스에 그린 뒤 toDataURL()이나 getImageData()를 호출하면 SecurityError가 발생한다. 캔버스가 "오염(tainted)" 상태가 되기 때문이다.
// ❌ SecurityError
const img = new Image();
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0);
ctx.toDataURL(); // SecurityError!
};
// ✅ crossOrigin 설정 (서버도 CORS 헤더 필요)
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0);
ctx.toDataURL(); // 정상 동작
};
중요한 건 crossOrigin 속성을 src보다 먼저 설정해야 한다는 것이다. 순서가 바뀌면 이미 요청이 CORS 없이 시작되어 제대로 동작하지 않을 수 있다.
캔버스 크기와 CSS 크기 혼동
캔버스에는 두 가지 크기가 있다. 내부 해상도(.width, .height 속성)와 표시 크기(CSS). 이 둘이 다르면 drawImage() 결과가 흐릿하게 보인다.
// CSS로 400x300으로 표시하지만, 내부 해상도는 기본값 300x150
// → 이미지가 늘어나고 흐릿해짐
// 올바른 설정
canvas.width = 400; // 내부 해상도
canvas.height = 300;
canvas.style.width = '400px'; // CSS 표시 크기
canvas.style.height = '300px';
고해상도 디스플레이(Retina)에서는 devicePixelRatio를 곱해서 내부 해상도를 높여야 선명하게 보인다.
const dpr = window.devicePixelRatio || 1;
canvas.width = 400 * dpr;
canvas.height = 300 * dpr;
canvas.style.width = '400px';
canvas.style.height = '300px';
ctx.scale(dpr, dpr);
비디오 프레임 타이밍
drawImage(video, ...)는 비디오의 "현재 표시 중인 프레임"을 가져온다. 하지만 브라우저의 비디오 디코더와 Canvas 렌더링의 타이밍이 정확히 일치하지 않을 수 있다. requestVideoFrameCallback()을 사용하면 비디오 프레임이 실제로 준비된 시점에 콜백을 받을 수 있어서 더 정밀한 동기화가 가능하다.
function onFrame(now, metadata) {
ctx.drawImage(video, 0, 0);
// metadata.mediaTime으로 정확한 프레임 시간 확인 가능
video.requestVideoFrameCallback(onFrame);
}
video.requestVideoFrameCallback(onFrame);
이 API는 requestAnimationFrame과 달리 비디오 프레임이 실제로 갱신될 때만 콜백이 호출된다. 30fps 비디오를 60fps 모니터에서 재생할 때, requestAnimationFrame은 60번 호출되지만 requestVideoFrameCallback은 30번만 호출된다. 불필요한 중복 렌더링을 방지할 수 있다.
drawImage와 변환(Transform) 조합
drawImage() 자체에는 회전이나 뒤집기 기능이 없다. 대신 Canvas의 변환 행렬을 설정한 뒤 drawImage()를 호출하면 된다.
이미지 회전
function drawRotated(img, x, y, angle) {
ctx.save();
ctx.translate(x + img.width / 2, y + img.height / 2); // 중심점으로 이동
ctx.rotate(angle); // 라디안 단위
ctx.drawImage(img, -img.width / 2, -img.height / 2); // 중심 기준으로 그리기
ctx.restore();
}
// 45도 회전
drawRotated(img, 100, 100, Math.PI / 4);
핵심은 translate로 회전 중심을 먼저 설정하고, drawImage에서는 중심 기준 좌표(-width/2, -height/2)를 사용하는 것이다. save()와 restore()로 변환 상태를 감싸서 다른 그리기 작업에 영향을 주지 않도록 한다.
좌우 반전
ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(img, -img.width, 0); // x 좌표를 음수로
ctx.restore();
scale(-1, 1)은 x축을 뒤집는다. 이렇게 하면 좌표계도 뒤집히므로 그리는 위치의 x를 음수로 보정해야 한다.
정리
drawImage()는 Canvas에서 이미지를 다루는 모든 작업의 시작점이다.
- 3-인자: 원본 크기 그대로 배치
- 5-인자: 크기 조절해서 배치
- 9-인자: 소스의 일부를 잘라서 크기 조절 후 배치
소스로 <img>, <video>, <canvas>, ImageBitmap 등 다양한 타입을 받을 수 있고, Canvas의 변환 행렬(translate, rotate, scale)과 조합하면 회전, 반전, 합성까지 처리할 수 있다. 반복 렌더링 시에는 ImageBitmap이나 캐시 캔버스로 성능을 최적화하고, 크로스 도메인 이미지를 다룰 때는 crossOrigin 설정을 잊지 말아야 한다.