canvas.toBlob이 IOS에서 null을 반환 받는 경우
회사에서 react-easy-crop 라이브러리와 Canvas API를 쓰다가 이상한 현상을 만났습니다. iOS에서만, 그것도 zoom이 1 이하인 경우에만 canvas.toBlob이 null을 반환하는 겁니다.
안드로이드와 데스크톱에서는 아무 문제가 없었습니다. iOS에서만 재현되는 버그라 처음엔 원인을 찾기가 쉽지 않았습니다.
iOS는 조용히 실패한다
결론부터 말하면, iOS WebKit은 Canvas 픽셀 수에 상한선을 두고 있습니다. 제한 수치는 디바이스마다 다른데, 일반적으로 약 16,777,216픽셀(4096×4096)을 초과하면 toBlob()이 null을 반환합니다. 예외를 던지거나 오류 메시지를 주는 게 아니라 그냥 콜백에 null을 넘겨주기 때문입니다. 처음 보면 왜 null이 오는지 전혀 감이 잡히지 않습니다.
이 제한은 iOS가 GPU 메모리를 엄격하게 관리하기 때문에 생기는 겁니다. 데스크톱 브라우저나 Android는 OS 수준에서 메모리 사용에 상대적으로 너그럽지만, iOS는 앱 하나가 쓸 수 있는 GPU 메모리를 강하게 제한합니다. Canvas 내용은 CPU 메모리가 아니라 GPU 텍스처로 올라가니까, 이 한도를 넘어서면 브라우저가 그냥 조용히 실패하는 거죠.
축소해서 봤는데 캔버스는 커진다
zoom이 1 이하라는 건 이미지를 축소해서 보고 있다는 뜻입니다. 즉 화면에 보이는 영역보다 더 넓은 원본 이미지 영역을 canvas에 담아야 한다는 거죠. react-easy-crop은 crop 영역을 픽셀 기준으로 계산하는데, zoom이 작아질수록 같은 화면 크기 안에 더 많은 원본 픽셀이 들어가야 하니까 canvas 크기가 반비례하여 커집니다. 예를 들어 zoom이 0.5이면 원본 이미지의 두 배 면적을 canvas에 그려야 하는 상황이 되는 겁니다. 이 계산이 그대로 canvas.width, canvas.height에 반영되면 픽셀 수가 순식간에 한계를 넘어섭니다.
해결: zoom만큼 캔버스 크기를 줄이기
해상도를 무조건 낮추는 건 서비스 품질에 영향을 주니까, zoom이 1 이하일 때만 그 비율만큼 캔버스 크기를 줄이는 방식을 선택했습니다.
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
newImage.src = imagePath;
newImage.onload = () => {
// zoom >= 1이면 원본 크기 유지, zoom < 1이면 그만큼 축소
const scalefactor = Math.min(zoom, 1);
canvas.width = width * scalefactor;
canvas.height = height * scalefactor;
// 캔버스 크기를 줄인 만큼 그리기 컨텍스트도 같은 비율로 스케일 조정
ctx.scale(scalefactor, scalefactor);
ctx.drawImage(newImage, 0, 0);
canvas.toBlob((blob) => {
// ...로직
}, 'image/png');
};
Math.min(zoom, 1)로 zoom이 1 이상인 경우는 그대로 두고, 1 미만인 경우에만 스케일을 적용합니다. 캔버스 크기를 줄인 뒤 ctx.scale()로 그리기 좌표계도 같은 비율로 맞춰주면, 이미지는 축소된 캔버스에 올바르게 그려집니다. 결과적으로 zoom이 0.5일 때 절반 크기의 캔버스가 생성돼서 픽셀 수 제한을 피할 수 있고, 해상도 저하도 실질적으로는 zoom에 비례하는 수준으로 제한되는 거죠.
크로스 브라우징 이슈는 대부분 겉으로는 단순해 보이지만, 파고들면 각 플랫폼의 렌더링 정책 차이에서 비롯되는 것 같습니다. iOS의 Canvas 메모리 제한처럼 스펙 문서보다 실제 기기에서 먼저 만나게 되는 경우가 많으니, 한 번 겪어두면 비슷한 상황에서 훨씬 빨리 원인을 좁힐 수 있지 않을까 싶습니다.