본문으로 건너뛰기
2022. 5. 15·약 5분

HEIC 이미지를 JPEG로 변환하기

분홍 꽃잎 여덟 장이 겹친 사진 앱 꽃 카드에서 점선 화살표를 따라 파란 산 그림 카드로 넘어가는 그림

배경​

텍스트 에디터가 지원하지 않는 HEIC 포맷 이미지​

회사에서 운영하는 커뮤니티 서비스에는 여러 기기에서 사진이 올라옵니다. 글과 댓글에 첨부한 이미지를 어느 환경에서나 보여주려면 파일 형식의 호환성을 확인해야 합니다.

아이폰에서 찍은 사진은 설정에 따라 HEIC 형식으로 저장됩니다. 당시 사용하던 WYSIWYG* 에디터는 HEIC 업로드와 편집을 지원하지 않았습니다. 따라서 업로드한 파일을 JPEG로 변환해 저장한 뒤, 그 주소를 에디터에 전달해야 했습니다.

이 글은 게시글에 첨부된 HEIC 파일을 JPEG로 변환하는 과정을 실험한 기록입니다.

* WYSIWYG: 화면에 보이는 형태 그대로 콘텐츠를 편집하는 방식입니다. 출처


💡 HEIC 포맷은 무엇인가요?​

참고: Heic 확장자는 무엇일까?

HEIF(High Efficiency Image File Format)는 고효율 이미지 컨테이너 포맷이고, HEIC는 그중 HEVC 코덱으로 인코딩된 이미지를 담은 파일을 가리킵니다. Apple의 아이폰이나 아이패드로 사진을 찍은 경우 해당 사진 파일은 HEIC 확장자로 저장됩니다.

⚠️ 브라우저 호환성 문제​

HEIC는 비슷한 화질의 JPEG보다 파일이 작을 수 있지만, 표시하려면 HEVC 이미지 디코딩을 지원하는 환경이 필요합니다. 파일 형식을 바꾼다고 사진의 해상도가 높아지는 것은 아닙니다. Safari 17 이상은 HEIC을 브라우저에서 표시할 수 있지만 Chrome·Firefox·Edge는 지원하지 않으므로, 여러 브라우저를 지원하는 서비스라면 JPEG 같은 형식으로 변환해야 합니다.


heic-convert 라이브러리​

npm 의 heic-convert 패키지 페이지. 주간 다운로드가 13,177 이다

heic-convert는 HEIC/HEIF 이미지를 JPEG 또는 PNG로 변환하는 JavaScript 라이브러리입니다. 이 글을 쓸 당시에는 Node.js에서 사용했지만, 현재는 heic-convert/browser 엔트리도 제공합니다.

HEIC 이미지의 Buffer* 객체와 출력 형식, 0에서 1 사이의 품질 값을 넘기면 변환된 이미지 Buffer를 Promise로 돌려받습니다.

* Buffer: Node.js에서 원시 바이너리 데이터를 다루는 객체입니다. 출처: Node.js의 버퍼 이해하기

const { promisify } = require("util");
const fs = require("fs");
const convert = require("heic-convert");

(async () => {
const inputBuffer = await promisify(fs.readFile)("/path/to/my/image.heic");
const outputBuffer = await convert({
buffer: inputBuffer, // the HEIC file buffer
format: "JPEG", // output format
quality: 1, // the jpeg compression quality, between 0 and 1
});

await promisify(fs.writeFile)("./result.jpg", outputBuffer);
})();

제가 처음 생각했던 이미지 변환 및 첨부 파이프라인은 아래와 같습니다.

클라이언트의 이미지 업로드 훅과 AWS 변환, 저장 서비스를 잇는 파이프라인 그림

기존 로직은 에디터가 이미지 업로드를 감지하면 파일을 AWS S3의 임시 버킷에 올리고, 반환된 원격 주소를 본문에서 참조하는 구조였습니다.

HEIC 파일만 변환용 Lambda로 보내 JPEG로 바꾼 뒤, 기존 파이프라인이 변환된 파일의 주소를 반환하도록 구성할 수 있습니다.

실제 서비스에서는 애플리케이션 서버가 파일 본문을 직접 받기보다 S3의 서명된 URL과 이벤트를 이용하는 편이 낫다고 판단했습니다. 구상한 흐름은 다음과 같습니다.

  1. AWS S3의 pre-signed URL과 파일 식별자를 발급받아 임시 버킷에 업로드합니다.
  2. S3의 PutObject 이벤트로 Lambda를 호출하고, 파일이 HEIC이면 JPEG로 변환합니다.
  3. 변환한 JPEG를 실제 자산 버킷에 저장하고 CDN을 연결합니다. 클라이언트는 처음 받은 식별자로 최종 경로를 참조합니다.

여기서는 전체 파이프라인을 구현하지 않고, 라이브러리로 파일 형식을 변환하는 과정만 확인합니다.


heic-convert 직접 사용해 보기​

목업 화면​

HEIC 파일을 고르는 입력 하나만 둔 목업 화면

변환 과정만 확인할 수 있도록 파일 입력 하나를 둔 화면을 만들었습니다.

당시 Vue 코드에서 multipart/form-data 헤더를 직접 지정하고, 옆의 로컬 서버에서 HEIC 변환 라우트를 구현한 편집기

Lambda 대신 동작할 서버를 로컬에 띄우고 Vue로 화면을 만들었습니다. 프론트엔드는 8080번, 서버는 9000번 포트를 사용합니다.

캡처에는 당시 Content-Type: multipart/form-data를 직접 넣은 코드가 보입니다. 이렇게 하면 boundary가 빠질 수 있으므로 아래 예제에서는 헤더를 제거하고 브라우저가 FormData에 맞는 값과 boundary를 만들게 했습니다.

API Call (Vue Options API)

export default {
watch: {
formData(newValue) {
this.apiCall(newValue);
},
},
methods: {
apiCall: async function (formData) {
// 브라우저가 FormData의 Content-Type과 boundary를 설정한다.
const res = await axios.post(URL, formData);
const { data } = res;
const img = new Image();
img.src = "data:image/jpeg;base64," + data;
img.width = 600;
this.isLoading = false;
this.image = img;
},
detectFileUpload: async function (e) {
const [image] = e.target.files;
if (image.type.indexOf("image/heic") === -1) {
this.error = "Uploaded file is not a HEIC image. Please try again.";
return;
}
if (this.error) this.error = null;
this.isLoading = true;

const formData = new FormData();
formData.append("img", image);
this.formData = formData;
},
},
};

파일을 고르면 MIME 타입이 image/heic인지 확인해 FormData에 담습니다. 전체 예제에서는 formData watcher가 apiCall()을 호출해 전송합니다. 브라우저가 multipart boundary를 포함한 Content-Type을 만들도록 헤더는 직접 지정하지 않습니다. 이 예제는 브라우저가 알려준 MIME 타입만 확인하므로, 실서비스에서는 서버에서 파일 시그니처 등을 따로 검사해야 합니다. API가 JPEG를 Base64 문자열로 돌려주면 이를 화면에 표시합니다.

Server.js

const express = require("express");
const app = express();
const cors = require("cors");
const multer = require("multer");
const convert = require("heic-convert");

const PORT = 9000;

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

app.post("/image", upload.single("img"), async function (req, res) {
console.log(req.file.buffer);
const outputBuffer = await convert({
buffer: req.file.buffer,
format: "JPEG",
quality: 1,
});
console.log(outputBuffer);
const base64Img = outputBuffer.toString("base64");
res.json(base64Img);
});

app.listen(PORT, () => {
console.log(`Listening on ${PORT}`);
});

multipart/form-data 요청의 파일은 일반 JSON 본문처럼 바로 읽을 수 없습니다. Express에서는 multer 미들웨어로 파일과 나머지 필드를 나눠 받을 수 있습니다.

* 파일을 전송할 때 사용하는 콘텐츠 형식 multipart/form-data에 관한 설명은 링크를 참고하세요.

Multer.js로 FormData 처리하기​

const multer = require("multer");

Multer는 받은 파일을 디스크나 메모리에 저장할 수 있습니다. 여기서는 별도 임시 파일을 만들지 않고 메모리에서 바로 변환합니다.

const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

memoryStorage()를 storage 옵션으로 넘기면 업로드된 파일을 메모리에서 처리할 수 있습니다.

app.post("/image", upload.single("img"), async function (req, res) {
// ...변환 로직
});

/image 라우트에 Multer 미들웨어를 연결합니다. upload.single('img')는 img 필드의 파일 하나를 받겠다는 뜻입니다. 파일은 req.file, 나머지 필드는 req.body에 들어갑니다. 참조

이제 HEIC 파일을 올려 req.file의 내용을 확인합니다.

app.post('/image', upload.single('img'), async function (req, res) {
console.log('req.file >>>>>>>>', req.file)
console.log('req.file.buffer >>>>>>>>', req.file.buffer)
});

목업 화면에 HEIC 파일을 올려 JPG 로 변환된 사진이 나타나는 과정

화면에서 이미지를 업로드하면 아래와 같이 콘솔에 찍힙니다.

업로드된 파일 정보가 콘솔에 찍힌 모습. mimetype 이 image/heic 이고 크기는 293608 이다

fieldname은 img, mimetype은 image/heic으로 표시됩니다. 파일의 바이너리 데이터는 메타데이터와 함께 buffer 속성에 들어 있습니다.

이 Buffer를 heic-convert에 넘긴 뒤, 변환된 JPEG Buffer를 Base64 문자열로 인코딩해 응답합니다.

app.post("/image", upload.single("img"), async function (req, res) {
console.log("inputBuffer >>>>>", req.file.buffer);
const outputBuffer = await convert({
buffer: req.file.buffer,
format: "JPEG",
quality: 1,
});
console.log("outputBuffer >>>>>", outputBuffer);
const base64Img = outputBuffer.toString("base64");
console.log("base64Output >>>>>", base64Img);
res.json(base64Img);
});

Base64 변환 결과를 출력한 터미널

Base64 문자열이 출력되는지 확인합니다.

const methods = {
apiCall: async function (formData) {
// 브라우저가 FormData의 Content-Type과 boundary를 설정한다.
const res = await axios.post(URL, formData);
const { data } = res;
const img = new Image();
img.src = "data:image/jpeg;base64," + data;
img.width = 600;
this.isLoading = false;
this.image = img;
},
};

응답 앞에 data:image/jpeg;base64,를 붙여 이미지의 src로 지정하면 변환 결과를 화면에 표시할 수 있습니다.

변환된 JPG 2.2MB 와 원본 HEIC 294KB 의 파일 정보 창 비교

이 예제에서는 294KB였던 HEIC 원본이 JPEG로 변환된 뒤 2.2MB가 되었습니다. 품질 설정과 원본에 따라 결과는 달라질 수 있으므로, 실제 서비스에서는 변환 위치뿐 아니라 출력 품질과 용량 제한도 함께 정해야 합니다. 현재는 heic-convert/browser를 이용해 브라우저에서 변환하는 방법도 선택할 수 있습니다.

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자