HEIF형식 이미지 업로드 대응
2023-10-10어느 날, 사용자로부터 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(),
}
);
로직 자체는 잘 동작했어요. 그런데 회사 안드로이드폰으로 테스트하다가 뜻밖의 문제가 하나 더 나왔어요.
Android 버전별 동작 차이
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 기기가 사내에 있었던 게 다행이었어요.