HEIF형식 이미지 업로드 대응
어느 날, 사용자로부터 HEIC(HEIF) 이미지 파일이 업로드되지 않는다는 문의가 들어왔습니다.
솔직히 처음 든 생각은 이랬습니다.
"HEIF? 그건 또 뭐지?"
브라우저는 왜 이 파일을 못 읽었나
HEIF는 고효율 이미지 파일 포맷(High Efficiency Image Format)의 약자입니다. 동일한 화질 기준으로 JPEG 대비 절반 수준의 파일 크기를 가지고, Apple이 iOS 11부터 기본 촬영 포맷으로 채택하면서 널리 퍼졌습니다.
문제는 브라우저 지원입니다. HEIF는 내부적으로 HEVC(H.265) 코덱을 써서 이미지를 압축하는데, HEVC는 특허로 묶여 있는 코덱이기 때문입니다. 브라우저 벤더들이 라이선스 비용 문제로 기본 지원을 꺼리는 탓에 2023년 기준으로도 대부분의 브라우저에서 HEIF를 네이티브로 렌더링하지 못합니다.
iOS에서는 이 문제가 겉으로 드러나지 않습니다. 파일 선택 후 업로드 시점에 운영체제 레벨에서 자동으로 JPEG로 변환해주니까요. 반면 Android나 데스크톱 브라우저에서는 그런 처리가 없어서 HEIF 파일이 그대로 서버로 전송됩니다.
타입만 바꾸면 될 줄 알았다
처음엔 Blob 생성 시 타입만 image/jpeg로 지정하면 되지 않을까 싶었습니다.
const blob = new Blob([innerFile[1]], {
type: "image/jpeg",
});
const heifToPngFile = new File(
[blob],
innerFile[1].name
.replaceAll(".heif", ".jpg")
.replaceAll(".heic", ".jpg")
.replaceAll(".HEIC", ".jpg")
.replaceAll(".HEIF", ".jpg"),
{
type: "image/jpeg",
}
);
결과는 실패였습니다. Blob의 type 속성은 MIME 타입을 나타내는 메타데이터일 뿐, 바이너리 데이터 자체를 변환하지는 않습니다. 확장자와 MIME 타입만 바뀌었을 뿐 파일 내부는 여전히 HEIF 인코딩 그대로였기 때문에, 서버나 브라우저에서 JPEG로 인식하지 못한 겁니다.
이름표만 갈아 붙인 셈이었습니다.
heic2any는 파일 안쪽까지 바꿔줬다
CTO님이 추천해주신 heic2any 라이브러리를 적용했습니다. 이 라이브러리는 내부적으로 libheif를 WebAssembly로 컴파일해서 번들링하고, 브라우저 환경에서 HEIF 바이너리를 실제로 디코딩해 JPEG나 PNG로 재인코딩해줍니다. 단순히 타입만 바꾸는 게 아니라 파일 내용 자체를 변환하는 거죠.
const blob = new Blob([files[0]]);
const transBlob = await heic2any({
blob,
quality: 0.1,
toType: "image/jpeg",
});
const trnasFile = new File(
[transBlob],
files[0].name
.replaceAll(".heif", ".jpg")
.replaceAll(".heic", ".jpg")
.replaceAll(".HEIC", ".jpg")
.replaceAll(".HEIF", ".jpg"),
{
type: "image/jpeg",
lastModified: new Date().getTime(),
}
);
로직 자체는 잘 동작했습니다. 그런데 회사 안드로이드폰으로 테스트하다가 뜻밖의 문제가 하나 더 나왔습니다.
안드로이드 8에서는 타입이 빈 문자열로 넘어왔다
Android 8 버전에서 HEIF 파일을 선택하면 업로드가 실패하더라고요. 디버깅해보니 파일 타입이 빈 문자열로 전달되는 상황이었습니다.
원인은 Android의 HEIF 지원 범위에 있었습니다. Android 공식 문서에 따르면 HEIF 형식을 공식 지원하는 건 Android 10부터입니다. 10 미만 버전에서는 운영체제가 HEIF 파일의 MIME 타입을 인식하지 못하니까, 파일 선택 시 File 객체의 type이 빈 값으로 넘어옵니다. 서버에서는 타입 없는 파일을 거부하니 업로드가 막히는 거였죠.
Android 10 이상에서는 운영체제가 HEIF를 인식하니까 heic2any로 변환하면 됩니다. 10 미만에서는 MIME 타입 자체를 모르는 상태라, 변환 없이 Blob으로 감싸면서 타입을 강제로 지정하는 방식으로 처리했습니다. 어차피 10 미만에서는 HEIF 인코딩도 제대로 지원하지 않으니까, 서버에 전달할 파일의 타입 정보만 보정해도 업로드 자체는 통과하기 때문입니다.
const userAgent = navigator.userAgent.toLowerCase();
const androidIndex = userAgent.indexOf("android");
const androidVer = Number(
userAgent
.substring(androidIndex + 8)
.split(";")[0]
.split(".")[0]
);
if (androidVer < 10) {
const blob = new Blob([innerFile[1]], {
type: "image/jpeg",
});
const heifToPngFile = new File(
[blob],
innerFile[1].name
.replaceAll(".heif", ".jpg")
.replaceAll(".heic", ".jpg")
.replaceAll(".HEIC", ".jpg")
.replaceAll(".HEIF", ".jpg"),
{
type: "image/jpeg",
}
);
}
대신 번들이 무거워졌다
heic2any는 동작하지만 한 가지 문제가 있습니다. 내부에서 쓰는 libheif 자체가 무거워서 번들 사이즈가 상당히 커지기 때문입니다. GitHub 이슈에도 이미 등록돼 있는 문제인데, 개발자 답변은 간결합니다. HEIF 디코딩에 쓰이는 libheif 자체의 용량이 크기 때문에 라이브러리 차원에서 해결할 방법이 없다는 거죠.
동적 import로 초기 번들에서 분리하면 첫 로딩 비용을 줄일 수 있습니다.
async function convertHeifToJpeg(file: File): Promise<File> {
// HEIF 파일이 선택됐을 때만 라이브러리 로드
const heic2any = (await import('heic2any')).default;
const blob = await heic2any({
blob: new Blob([file]),
toType: 'image/jpeg',
quality: 0.8,
});
return new File(
[blob as Blob],
file.name.replace(/\.(heic|heif)$/i, '.jpg'),
{ type: 'image/jpeg' }
);
}
이렇게 하면 HEIF 파일이 선택될 때만 라이브러리가 로드됩니다. 파일 업로드를 쓰지 않는 페이지에서는 heic2any가 번들에 포함되지 않고요.
더 근본적인 해결책은 변환 자체를 서버 사이드로 옮기는 겁니다. 클라이언트 부담을 완전히 없앨 수 있지만, 업로드 흐름이 복잡해지고 서버 리소스를 쓰게 됩니다. 사용자 기기 성능이 낮은 경우가 많다면 서버 변환이 맞고, 대부분의 경우라면 동적 import로 클라이언트에서 처리하는 게 간단합니다.
마치며
이 이슈를 처음 접했을 때는 "HEIF 파일을 JPEG로 변환하면 되는 거 아닌가"라고 단순하게 생각했습니다. 실제로는 MIME 타입 변경, 실제 바이너리 변환, Android 버전별 HEIF 지원 범위, 번들 사이즈까지 고려해야 했습니다.
플랫폼별 파일 포맷 처리는 생각보다 많은 예외 케이스가 숨어 있습니다. 특히 iOS와 Android, 그리고 Android 버전 간의 동작 차이는 실제 기기 테스트 없이는 잡아내기 어렵습니다. 이번에 Android 8 기기가 사내에 있었던 게 다행이었습니다. 파일 업로드를 다루고 계시다면, 오래된 기기 한 대쯤 책상에 놔두는 것도 나쁘지 않을 것 같습니다.