웹 프로그램을 테스트하다 이미지 파일을 업로드하면서 이미지 크기 이슈에 대해 생각해 보았습니다.
ⓐ 클라이언트(프론트엔드 코드)에서 크기를 줄인 후 업로드한다.
ⓑ 서버(백엔드 코드)에서 크기를 줄인 후 저장한다.
솔직히 저는 별 생각 없이 ⓑ, '서버(백엔드 코드)에서 크기를 줄인 후 저장한다'로 코딩했었는데 당연히 비효율적이라는 걸 서비스를 테스트하면서 새삼 깨닫게 되었습니다.
웹에도 이미지 사이즈를 줄여(scaling down) 업로드하는 방법, 즉 이미지 크기를 줄이고 파일로 가공하는 방법에 대해서는 정보가 많지 않아 정리해 놓으려합니다.
일단 제가 참고한 코드는 아래와 같습니다.
https://gist.github.com/dcollien/312bce1270a5f511bf4a
Resize Images in the Browser
Resize Images in the Browser. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
참고로 사용한 위 코드에서 이미지 크기를 줄이고 파일로 가공하는 방법에 대해 상세히 밝히고 있습니다. 하지만 코드가 콜백(callback) 함수 기반으로 되어 있어 사용하기 불편했습니다. 더불어 이미지 크기를 조정하면서 정사각형으로 크롭핑(cropping)하는 부분에 대해서도 생각해 보았습니다. 프로필 사진 등으로 사용하기 위해서 크롭핑이 필요한 경우가 있거든요.
위 참고 코드에 아래 내용을 반영해서 수정한 전체 코드입니다.
- 결과를 콜백으로 전달 → 결과를 프로미스를 이용해서 전달
- 이미지 사이즈 조정 후 크롭핑 기능 추가
export class ImageResizer {
static resize(file: File, targetWidth: number, targetHeight: number): Promise<Blob> {
return new Promise((resolve, reject) => {
let image = document.createElement('img');
ImageResizer.loadImage(image, file).then((result) => {
if(result === true){
let width = image.width;
let height = image.height;
// console.log('ImageResizer.targetImage.width: %d, height: %d', width, height);
let getWidthHeight = (img: HTMLImageElement, targetSize: number): Array<number> => {
const { width, height } = img;
let positions = [0, 0, 0, 0];
if(width === height){
positions = [0, 0, targetSize, targetSize];
}
else{
if(width < height){
let ratio = height/width;
let top = ((targetSize*ratio) - targetSize)/2;
positions = [0, -1*top, targetSize, targetSize*ratio];
}
else{
let ratio = width/height;
let left = ((targetSize*ratio) - targetSize)/2;
positions = [-1*left, 0, targetSize*ratio, targetSize];
}
}
return positions;
};
let canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
let context = canvas.getContext('2d');
if(context){
const coordinates = getWidthHeight(image, 235);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
// context.drawImage(image, ...getWidthHeight(image, 235));
context.drawImage(image, coordinates[0], coordinates[1], coordinates[2], coordinates[3]);
}
let hasToBlobSupport = (typeof HTMLCanvasElement !== 'undefined' ? HTMLCanvasElement.prototype.toBlob : false);
let hasBlobSupport = hasToBlobSupport || (typeof Uint8Array !== 'undefined' && typeof ArrayBuffer !== 'undefined' && typeof atob !== 'undefined');
if(hasToBlobSupport){
canvas.toBlob((blob) => {
if(blob) {
resolve(blob);
}
}, file.type)
}
else{
let blob = ImageResizer.toBlob(canvas, file.type);
resolve(blob);
}
}
else{
reject(new Error('Loading image failed.'));
}
}).catch((error) => {console.log('[Error]ImageResizer:', error)});
});
} // static resize(file: File, targetWidth: number, targetHeight: number): Promise<Blob> {
static toBlob(canvas: HTMLCanvasElement, type: string) {
let dataURI = canvas.toDataURL(type);
let dataURIParts = dataURI.split(',');
let byteString;
if(dataURIParts[0].indexOf('base64') >= 0){
// Convert base64 to raw binary data held in a string
byteString = atob(dataURIParts[1]);
}
else{
// Convert base64/URLEncoded data component to raw binary data
byteString = decodeURIComponent(dataURIParts[1]);
}
let arrayBuffer = new ArrayBuffer(byteString.length);
let intArray = new Uint8Array(arrayBuffer);
for(let i = 0; i < byteString.length; i = i + 1){
intArray[i] = byteString.charCodeAt(i);
}
let mimeString = dataURIParts[0].split(':')[1].split(';')[0];
let blob = null;
let hasBlobConstructor = typeof Blob !== 'undefined' && (function () {
try{
return Boolean(new Blob());
} catch(e){
return false;
}
}());
let hasArrayBufferViewSupport = hasBlobConstructor && typeof Uint8Array !== 'undefined' && (function (){
try{
return new Blob([new Uint8Array(100)]).size === 100;
} catch(e){
return false;
}
}());
if(hasBlobConstructor){
blob = new Blob(
[hasArrayBufferViewSupport ? intArray : arrayBuffer], {type: mimeString}
);
}
else{
blob = new Blob([arrayBuffer]);
}
return blob;
}
static loadImage(image: HTMLImageElement, file: File) {
return new Promise((resolve, reject) => {
image.onload = (event) => {
// console.log('Signup.ImageResizer.loadImage.event:', event);
// console.log('Signup.ImageResizer.loadImage.image.(width: %d, height: %d):', image.width, image.height);
resolve(true);
};
if(typeof URL === 'undefined'){
let reader = new FileReader();
reader.onload = (event) => {
if(event.target && typeof event.target.result === 'string'){
image.src = event.target.result;
}
};
reader.readAsDataURL(file);
}
else{
image.src = URL.createObjectURL(file);
}
});
}
}
코드가 약간 길어보이지만 3개의 함수(메서드)로 구성되어 있어 각각의 함수를 따로 살펴보면 그렇게 복잡하지만도 않습니다.
단순한 함수들인 toBlob( ) 함수와 loadImage( ) 함수부터 살펴보겠습니다. 먼저 loadImage( ) 함수는 사용자가 업로드한 파일을 HTML image 엘리먼트로 로딩하는 역할을 합니다.
static loadImage(image: HTMLImageElement, file: File) {
return new Promise((resolve, reject) => {
image.onload = (event) => {
// console.log('Signup.ImageResizer.loadImage.event:', event);
// console.log('Signup.ImageResizer.loadImage.image.(width: %d, height: %d):', image.width, image.height);
resolve(true);
};
if(typeof URL === 'undefined'){
let reader = new FileReader();
reader.onload = (event) => {
if(event.target && typeof event.target.result === 'string'){
image.src = event.target.result;
}
};
reader.readAsDataURL(file);
}
else{
image.src = URL.createObjectURL(file);
}
});
}
loadImage( ) 함수를 이미지 로딩이 완료되면 true 값을 반환하는 프로미스로 구성하였습니다. HTML image 엘리먼트의 src 속성에 읽어들인 이미지를 전달하면 이미지 로딩이 완료된 시점에 image.onload( ) 함수가 실행됩니다. 그리고 이 이미지 onload( ) 함수는 프로미스의 resolve( ) 함수 즉, 처리가 정상적으로 완료되었음을 반환하게 됩니다.
loadImage( ) 함수에 전달된 첫 번째 인자인 image는 HTML image 엘리먼트로서 클래스 변수로 선언되어 있어 이미지 크기를 조절하는 다른 함수들도 이 image 변수를 참조할 수 있습니다.
다음으로 사이즈가 조정된 이미지를 파일 형태로 저장하는 toBlob( ) 함수입니다.
static toBlob(canvas: HTMLCanvasElement, type: string) {
let dataURI = canvas.toDataURL(type);
let dataURIParts = dataURI.split(',');
let byteString;
if(dataURIParts[0].indexOf('base64') >= 0){
// Convert base64 to raw binary data held in a string
byteString = atob(dataURIParts[1]);
}
else{
// Convert base64/URLEncoded data component to raw binary data
byteString = decodeURIComponent(dataURIParts[1]);
}
let arrayBuffer = new ArrayBuffer(byteString.length);
let intArray = new Uint8Array(arrayBuffer);
for(let i = 0; i < byteString.length; i = i + 1){
intArray[i] = byteString.charCodeAt(i);
}
let mimeString = dataURIParts[0].split(':')[1].split(';')[0];
let blob = null;
let hasBlobConstructor = typeof Blob !== 'undefined' && (function () {
try{
return Boolean(new Blob());
} catch(e){
return false;
}
}());
let hasArrayBufferViewSupport = hasBlobConstructor && typeof Uint8Array !== 'undefined' && (function (){
try{
return new Blob([new Uint8Array(100)]).size === 100;
} catch(e){
return false;
}
}());
if(hasBlobConstructor){
blob = new Blob(
[hasArrayBufferViewSupport ? intArray : arrayBuffer], {type: mimeString}
);
}
else{
// ? new Blob([intArray]). arrayBuffer seems empty.
blob = new Blob([arrayBuffer]);
}
return blob;
}
약간은 허탈하지만 중요한 사실은 이 toBlob( ) 함수는 브라우저가 canvas 엘리먼트의 toBlob( ) 함수를 지원하지 않을 때 사용하는 예비 수단이라는 점입니다. 하지만 2017년 이후 배포된 대부분의 브라우저에서는 canvas.toBlob( ) 함수를 지원합니다.
그래도 약간의 설명을 남기겠습니다.
toBlob( ) 함수는 먼저 HTML canvas 요소를 입력으로 받습니다. 두 번째 인자인 type은 저장할 이미지 형식인데 보통 'image/jpeg', 'image/png' 둘 중 하나가 됩니다.
toBlob( ) 함수가 이미지 데이터를 canvas로 받는데는 이유가 있습니다. 자바스크립트 코드는 사용자가 업로드한 사이즈 조정 대상 이미지를 HTML <img> 엘리먼트로 읽어들인 후 <canvas> 엘리먼트를 생성해서 크기 조정과 크롭핑된 이미지를 다시 그려주기 때문입니다. 따라서 수정된 이미지(다시 그려준 이미지)가 <canvas> 엘리먼트에 들어있게 됩니다.
toBlob( ) 함수는 canvas 엘리먼트에 들어있는 수정된 이미지를 canvas.toDataURL( ) 함수를 이용해서 읽습니다. 그 내용은 이미지 바이너리 데이터를 문자열로 인코딩한 형태로 되어 있습니다. 만약 인코딩 방식이 base64 인코딩(바이너리 데이터를 64개의 ASCII 문자, 즉 A~Z 등을 이용해서 표현한 방식)이라면 atob( ) 함수를 이용해서 바이트 단위의 데이터로 정렬해줍니다.
만약 canvas로 부터 읽어들인 문자열의 인코딩 방식이 base64가 아니라면 decodeURIComponent( ) 함수를 이용해서 바이트 단위로 정렬해줍니다.
바이트 단위로 정렬된 byte string을 얻은 후에는 이를 다시 배열 형태인 arrayBuffer로 가공하고 최종적으로 byte 배열을 blob 즉, 2진 데이터인 파일 형태로 만들어 줍니다.
toBlob( ) 함수 중간에 위치한 mimeString은 이미지의 형식을 지정하는 값이고, hasBlobConstructor, hasArrayBufferViewSupport 등의 변수는 이 스크립트를 실행하는 브라우저가 바이트 배열을 파일로 만들기 위한 몇 가지 방법들을 지원하는지 여부를 나타내는 플래그들입니다.
이미지 사이즈를 조절하는 ImageResizer 클래스의 메인 함수인 resize( ) 함수입니다. 전체적인 처리 과정을 아래와 같이 요약할 수 있습니다.
① HTML image 엘리먼트 생성
② HTML image 엘리먼트 ← image 파일 로딩
③ 사이즈 조절 목표 크기로 HTML canvas 엘리먼트 생성
④ ②에서 읽어들인 image를 ③의 canvas에 drawImage( ) 함수를 이용해서 표시
⑤ canvas를 blob(binary large object, blob은 파일의 내용에 해당합니다)으로 저장
static resize(file: File, targetWidth: number, targetHeight: number): Promise<Blob> {
return new Promise((resolve, reject) => {
let image = document.createElement('img');
ImageResizer.loadImage(image, file).then((result) => {
if(result === true){
let width = image.width;
let height = image.height;
// console.log('ImageResizer.targetImage.width: %d, height: %d', width, height);
let getWidthHeight = (img: HTMLImageElement, targetSize: number): Array<number> => {
const { width, height } = img;
let positions = [0, 0, 0, 0];
if(width === height){
positions = [0, 0, targetSize, targetSize];
}
else{
if(width < height){
let ratio = height/width;
let top = ((targetSize*ratio) - targetSize)/2;
positions = [0, -1*top, targetSize, targetSize*ratio];
}
else{
let ratio = width/height;
let left = ((targetSize*ratio) - targetSize)/2;
positions = [-1*left, 0, targetSize*ratio, targetSize];
}
}
return positions;
};
let canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
let context = canvas.getContext('2d');
if(context){
const coordinates = getWidthHeight(image, 235);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, coordinates[0], coordinates[1], coordinates[2], coordinates[3]);
}
let hasToBlobSupport = (typeof HTMLCanvasElement !== 'undefined' ? HTMLCanvasElement.prototype.toBlob : false);
let hasBlobSupport = hasToBlobSupport || (typeof Uint8Array !== 'undefined' && typeof ArrayBuffer !== 'undefined' && typeof atob !== 'undefined');
if(hasToBlobSupport){
canvas.toBlob((blob) => {
if(blob) {
resolve(blob);
}
}, file.type)
}
else{
let blob = ImageResizer.toBlob(canvas, file.type);
resolve(blob);
}
}
else{
reject(new Error('Loading image failed.'));
}
}).catch((error) => {console.log('[Error]ImageResizer:', error)});
});
} // static resize(file: File, targetWidth: number, targetHeight: number): Promise<Blob> {
추가로 정리를 필요로하는 부분은 resize( ) 함수가 내부적으로 참조하는 getWidthHeight( ) 함수인데, 아래와 같이 정리할 수 있습니다.
이미지의 가로, 세로 크기를 구하고 이 중 짧은 면을 기준으로 다른 면을 잘라냅니다. 위 코드의 getWidthHeight( ) 함수의 결과값인 positions 배열을 보면 짧은 면을 기준이 되는 targetSize로 하고 긴 면을 마이너스 좌표로 해서 캔버스의 보이지 않는 영역으로 넣어줍니다.
canvas의 drawImage( ) 함수의 목표 좌표 인자들을 이와 같이 잡아주면 원래 이미지가 이 좌표에 맞게 축소되어 그려지고 canvas 영역 밖의 이미지 부분은 크롭핑됩니다.
마지막으로 ImageResize 클래스의 사용 방법을 예로 기술하고 포스팅을 마무리하겠습니다.
let result = await ImageResizer.resize(uploadedFile, targetWidth, targetHeight);
let fileType = 'image/png'; // or 'image/jpeg'
let resizedFile = new File([result], fileName, {type: fileType});
참고로 제가 작성한 ImageResizer.resize( ) 함수는 내부적으로 getWidthHegith( ) 함수를 이용해서 축소될 이미지의 크기와 각 모서리 좌표를 구할 때 getWidthHegith(image, 235)와 같이 목표 크기를 235 픽셀로 고정하도록 코딩되어 있습니다. 이 부분의 235 대신 ImageResizer.resize( ) 함수의 targetWidth, targetHeight 인자 중 작은 길이를 받도록 하면 원하는 크기로 축소되고 정사각형으로 크롭핑된 이미지를 얻을 수 있습니다.
■
'웹 프로그래밍 고급 주제들' 카테고리의 다른 글
1. 프롤로그 - 크롬 익스텐션 개발, 할 만 한가? (2) | 2024.01.22 |
---|---|
[HTML + JavaScript] Passive Event Listener (2) | 2022.09.30 |
[React+Flask] 플라스크Flask 서버에서 리액트React 배포하기 (0) | 2021.10.16 |
[React+Flask] 리액트React 프론트엔드 + 플라스크Flask 백엔드 (0) | 2021.10.16 |
3. AJAX와 플라스크 (0) | 2020.12.16 |
댓글