HEIC 사진을 올렸는데 서버에는 JPEG가 도착한다


HEIC 사진을 올렸는데 서버에는 JPEG가 도착한다

이미지 업로드 코드를 살펴보다가 이상한 걸 발견했다. HEIC 파일을 JPEG로 변환하는 로직이 있는데, 그 변환을 수행하는 라이브러리가 특정 화면에서는 로드되지 않고 있었다.

당연히 그 화면에서는 아이폰 사진 업로드가 깨질 거라고 생각했다. 그런데 실제로 아이폰으로 올려보니 아무 문제 없이 잘 됐다.

문제 상황

업로드 파이프라인은 대략 이런 모양이었다. 파일명에 heicheif가 포함되어 있으면 heic2any로 JPEG로 변환하고, 아니면 원본을 그대로 쓴다.

const convertHeic = async (file, index) => {
  const name = file.name.toLowerCase();

  if (name.includes("heic") || name.includes("heif")) {
    const href = URL.createObjectURL(file);

    await fetch(href)
      .then(res => res.blob())
      .then(blob => heic2any({ blob, toType: "image/jpeg", quality: 0.5 }))
      .then(result => {
        converted[index] = new File(
          [result],
          file.name.split(".")[0] + ".jpg",
          { type: "image/jpeg" }
        );
      })
      .catch(e => {
        console.log(e);
      });
  } else {
    converted[index] = file;
  }
};

문제는 이 코드가 실행되는 화면 중 하나에 heic2any 스크립트 태그가 없었다는 점이다. 라이브러리가 없으면 heic2any(...) 호출에서 ReferenceError가 나야 한다.

그런데 아이폰에서, 그리고 맥북 크롬에서도 업로드가 정상 동작했다. 서버 응답도 성공이었다.

누가 변환했는지 파일명으로 알 수 있다

서버가 받은 원본 파일명을 확인해보니 실마리가 잡혔다.

{
  "fileNameOrigin": "IMG_2796.jpeg",
  "contentType": "image/jpeg"
}

확장자가 .jpeg였다. 이게 핵심이다. 위 코드에서 heic2any가 변환에 성공하면 파일명은 항상 .jpg가 된다. file.name.split(".")[0] + ".jpg"로 만들기 때문이다.

즉 변환 주체를 확장자로 구분할 수 있다.

변환 주체최종 파일명
OS / 브라우저IMG_2796.jpeg
heic2anyIMG_2796.jpg
변환 없음IMG_2796.heic

.jpeg가 왔다는 건 브라우저의 JavaScript가 파일을 만지기 전에 이미 JPEG였다는 뜻이다. convertHeic의 HEIC 분기는 애초에 실행되지 않았고, 그래서 라이브러리가 없다는 사실도 드러나지 않았다.

OS가 대신 변환해준다

애플은 사진 라이브러리에서 이미지를 내보낼 때 HEIC를 JPEG로 변환해 전달한다. 버그가 아니라 웹 호환성을 위한 의도된 동작이다. HEIC를 인식하지 못하는 서버가 아직 많기 때문이다.

직접 확인한 결과는 이렇다.

환경선택 방식결과
iOS사진 앱에서 선택JPEG로 변환됨
macOS + Chrome파일 선택창에서 사진 라이브러리 경유JPEG로 변환됨
macOS + ChromiumDesktop의 .heic 파일 직접 선택변환되지 않음

여기서 하나 짚어둘 게 있다. 이 동작을 Safari 전용으로 설명하는 글이 많지만, 실제로는 크롬에서도 사진 라이브러리를 경유하면 변환된다. 브라우저보다 파일을 어디서 가져오는지가 갈림길이었다.

반대로 AirDrop으로 받아 파일시스템에 저장된 .heic를 직접 고르면 원본이 그대로 전달된다. 이때 비로소 변환 코드가 실행되고, 라이브러리가 없으면 그대로 터진다.

크롬은 HEIC를 디코딩하지 못한다

그럼 라이브러리 없이 canvas로 우회하면 될까 싶어 확인해봤다. 결론은 안 된다.

await ImageDecoder.isTypeSupported("image/heic"); // false
await ImageDecoder.isTypeSupported("image/heif"); // false
await ImageDecoder.isTypeSupported("image/avif"); // true
await ImageDecoder.isTypeSupported("image/webp"); // true

AVIF와 WebP는 지원하지만 HEIC와 HEIF는 지원하지 않는다. 그래서 HEIC 파일을 <img>나 canvas로 읽으려 하면 디코딩 단계에서 실패한다.

한 가지 헷갈리기 쉬운 부분은 HEVC 비디오 코덱은 지원된다는 점이다.

MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"'); // true

HEIC는 HEVC로 인코딩된 이미지 컨테이너지만, 비디오 코덱 지원 여부와 이미지 디코더 지원 여부는 별개다. 비디오가 된다고 이미지도 될 거라 기대하면 안 된다.

canvas로 내보낼 때도 마찬가지다. 지원하지 않는 MIME 타입을 toBlob에 넘기면 조용히 PNG로 폴백한다.

const canvas = document.createElement("canvas");
canvas.toBlob(blob => {
  console.log(blob.type); // "image/png"
}, "image/heic");

요청한 타입이 무시되고 PNG가 나오는데 에러는 없다. 파일명은 .heic인 채로 내용만 PNG인 파일이 만들어질 수 있으니, 출력 타입을 신뢰하지 말고 blob.type을 확인하는 게 안전하다.

배운 것

파일명으로 포맷을 판별하는 코드는 생각보다 자주 건너뛰어진다. 확장자 기반 분기는 OS가 미리 변환해버리면 실행되지 않는다. 그래서 그 분기 안에 있는 버그는 오래 살아남는다. 위 사례에서 라이브러리 누락은 2년 넘게 발견되지 않았다.

재현 조건을 좁게 특정해야 한다. "아이폰 사진 업로드"로는 재현되지 않고, "파일시스템의 .heic를 직접 선택"에서만 재현된다. 이 차이를 모르면 버그를 못 찾거나, 반대로 심각도를 과대평가하게 된다. 나는 후자였다.

변환 실패를 console.log로 삼키면 안 된다. 위 코드의 catch는 에러를 로그로만 남긴다. 그러면 변환 실패 시 결과 배열이 비어 있는 상태로 다음 단계로 넘어가고, 검증도 업로드도 빈 배열을 통과해 아무 일도 일어나지 않는다. 사용자에게는 토스트조차 뜨지 않고, console.error가 아니니 에러 수집 도구에도 잡히지 않는다. 이런 침묵하는 실패가 가장 찾기 어렵다.

용량도 같이 늘어난다. HEIC의 압축률이 JPEG보다 좋기 때문에 변환되면 파일이 커진다. 실제로 2MB짜리 HEIC가 3.4MB JPEG로 도착하는 걸 봤다. 업로드 용량 제한을 원본 기준으로 잡아두면 예상보다 빨리 걸린다.

  • HEIC
  • 이미지 업로드
  • Safari
  • Chrome
  • macOS
  • iOS
  • 웹 개발

profile
heum2
Frontend Developer