Post

모바일에서 이미지를 업로드 해보자.

모바일 웹에서 이미지를 업로드를 해보자.

먼저 흔히 사용하는 모바일 웹을 떠올려 보면 이미지를 업로드 하는 방식에는 두 가지가 있다. 첫 번째는 사진 찍어서 올리기, 두 번째는 갤러리에서 선택하기다.

PC에서는 파일 선택 (DnD)으로 이루어지기 때문에 업로드 트리거도 하나였지만, 모바일에서는 두 가지의 트리거를 위해

1
버튼 클릭 > 갤러리 OR 사진 선택 > 업로드

와 같은 방식으로 구성했다.

이때 input의 props로 아래와 같은 내용을 추가로 구성해주어야 한다.

1
2
3
4
5
6
7
8
9
<input type="file" hidden ref="{fileInputRef}" multiple accept="image/*" />
<input
  type="file"
  hidden
  ref="{cameraInputRef}"
  multiple
  accept="image/*"
  capture="environment"
/>

여기서 봐야할 3가지 속성은 다음과 같다.

속성역할
accept="image/*"이미지 파일만 선택 가능
multiple여러 장 업로드 허용
capture="environment"모바일에서 후면 카메라 바로 실행

마지막 capture 속성 때문에 input을 둘로 나누었다고 이해하자.

나는 현재 안드로이드 핸드폰을 사용하는데, 이 후 큰 문제 없이 잘 작동하였으나 새로운 난관에 봉착했다!

바로 IOS 핸드폰에서는 이미지가 전송되지 않는 것이다.😱

어떤 것이 문제일까 하니, IOS에서는 기본적으로 JPEG로 사진이 촬영되지 않도 HEIC이라는 애플의 확장자로 사진이 촬영된다는 것이었다.

이를 위해 IOS 사용자인지 감지 한 뒤, 이 사용자의 이미지는 JPEG로 따로 변경할 필요가 있었다.

✨USER 감지하기

user 감지는 의외로 간단하다.

1
console.log(navigator.userAgent)

위와 같이 콘솔을 확인하면 아이폰의 경우 iPhone으로 명시되어 있기 때문에 확인에 큰 어려움은 없는 편이다.

1
const isIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent)

🎁이미지 압축하기

또 다른 문제가 발생했다. 바로 핸드폰으로 이미지를 두 장 이상 촬영 후 보내려고 하자 보내지지 않는것이다…

분명 PC에서는 보내졌는데, 뭐가 문제일까 하고 원래 보내려고 했던 이미지를 PC로 옮겨와 테스트했고 413 Request Entity Too Large 에러를 발견했다.

413에러는 클라이언트가 서버의 허용 용량을 초과하는 큰 파일을 업로드할 때 발생할 수 있는 오류이기 때문에 사진 크기 문제이지 않을까 추측했고, 역시 사진 용량을 압축하자 여러장의 사진이 문제 없이 전송되었다.

하지만, 핸드폰 사진의 퀄리티가 올라가면서 사진 용량도 커지는건 당연한 이치라 처음부터 사용자의 이미지 사이즈를 제한할 필요성을 느꼈다.

라이브러리를 사용해도 간편한데, canvas를 사용해도 어렵지 않게 구현할 수 있어서 나는 canvas를 사용하는 방식을 택했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const compressImage = (
  file: File,
  maxWidth = 1000,
  maxHeight = 1000,
  quality = 0.85,
): Promise<File> => {
  return new Promise((resolve, reject) => {
    const img = new window.Image()
    const url = URL.createObjectURL(file)

    img.onload = () => {
      let { width, height } = img

      // 비율을 유지하면서 축소한다.
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height)
        width = Math.floor(width * ratio)
        height = Math.floor(height * ratio)
      }

      const canvas = document.createElement("canvas")
      canvas.width = width
      canvas.height = height
      const ctx = canvas.getContext("2d")
      ctx?.drawImage(img, 0, 0, width, height)

      canvas.toBlob(
        (blob) => {
          if (!blob) {
            reject(new Error("이미지 압축에 실패했습니다."))
            return
          }
          const compressed = new File([blob], file.name, {
            type: "image/jpeg",
            lastModified: Date.now(),
          })
          URL.revokeObjectURL(url)
          resolve(compressed)
        },
        "image/jpeg",
        quality,
      )
    }

    img.onerror = (err) => {
      URL.revokeObjectURL(url)
      reject(err)
    }

    img.src = url
  })
}

이미지 압축 과정에는 아래와 같은 비동기 단계가 존재한다.

  1. 파일 → 브라우저가 읽어서 이미지로 디코딩
  2. 이미지가 메모리에 로드될 때까지 대기
  3. Canvas에 그리기
  4. Canvas → Blob으로 변환

즉 Promise를 사용하게 된다!

1
2
3
 return new Promise((resolve, reject) => {
  ///
 }

이미지는 업로드 하자마자 width와 height를 알 수 없고 메모리에 완전히 올라와야 알 수 있으므로 img.onload를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
img.onload = () => {
      let { width, height } = img;

      // 최대 크기 제한
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width = Math.floor(width * ratio);
        height = Math.floor(height * ratio);
      }

      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d');
      ctx?.drawImage(img, 0, 0, width, height);

      canvas.toBlob(
        blob => {
          if (!blob) {
            reject(new Error('Image compression failed'));
            return;
          }
          const compressed = new File([blob], file.name, {
            type: 'image/jpeg',
            lastModified: Date.now(),
          });
          URL.revokeObjectURL(url);
          resolve(compressed);
        },
        'image/jpeg',
        quality
      );
    };

    img.onerror = err => {
      URL.revokeObjectURL(url);
      reject(err);
    };

    img.src = url;
  });
This post is licensed under CC BY 4.0 by the author.