회사에서 운영하는 서비스 중 커뮤니티 도메인은 큰 비중을 차지하고 있습니다. 많은 유저가 다양한 디바이스로 사진 등 미디어를 첨부하여 글과 댓글을 업로드하기 때문에 호환성은 늘 신경쓰이는 이슈입니다.
언제부터인지 아이폰에서 찍은 사진 파일의 포맷이 HEIC로 저장이 되고, 현재 사용하고 있는 WYSIWYG* 에디터는 HEIC 미디어 업로드를 지원하지 않습니다. 이미지 편집 기능 등 관련된 기능을 사용할 수 없으니 업로드 하자마자 JPEG로 변환하여 리소스 인프라에 저장하고, 원격 주소를 받아와야 합니다.
어떻게하면 HEIC을 JPEG로 변환하고, 이를 자동화 시킬 수 있을까요?
이 글은 게시글에 첨부된 HEIC을 JPEG로 변환하는 과정을 개인적으로 공부했던 기록입니다.
* WYSIWYG: What You See Is What You Get의 약자로, 문서 및 문서 작성 방법을 GUI로 구현한 것을 이릅니다. 출처
출처: Heic 확장자는 무엇일까?
HEIC, High Efficiency Image File Format의 약자로 고효율의 파일 형식이라는 의미입니다. Apple의 아이폰이나 아이패드로 사진을 찍은 경우 해당 사진 파일은 HEIC 확장자로 저장되게 됩니다.
HEIF/HEIC 확장자는 용량도 줄어들고 파일의 해상도 부분이 개선되어 성능과 효율이 좋은 확장자이지만, 해당 파일을 리딩하려면 별도의 뷰어가 필요합니다. HEIF 확장자를 지원하지 않는 환경일 경우 JPG로 변경하여 사용하게 됩니다.
HEIC/HEIF 이미지를 JPEG 또는 PNG로 변환시켜주는 JS 라이브러리로 heic-convert가 많이 쓰입니다. 위클리 다운로드도 많고, 이슈도 신경써주는 것으로 보아 사용하기에 비교적 안정적인 라이브러리로 보입니다. 하지만 내용을 살펴보니 서버사이드에서 사용이 가능한 라이브러리입니다.
사용 방법은 아래와 같이 간단합니다.
heic-convert 모듈을 호출할때 HEIC 이미지 파일의 buffer* 객체와 출력 포맷, 퀄리티(0~1 사이)를 인자로 전달하면 Promise 객체를 통해 변환된 이미지 buffer를 받을 수 있습니다.
* buffer: Nodejs에서 buffer는 raw 바이너리 데이터를 저장할 수 있는 특수한 유형의 객체입니다. 출처: nodejs의 버퍼 이해하기
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 S3 버킷에 임시로 업로드 하고, 이미지 원격 주소를 받아와 파일이 아닌 주소로 참조하도록 되어있습니다.
이미지 업로드 훅에서 HEIC 포맷의 이미지를 예외 처리하여 HEIC을 JPEG로 변환하는 람다로 컨버팅을 한 후 파이프라인을 거쳐 JPEG 원격 주소를 받아오도록 해야 합니다.
제대로 한다면 별도의 엔드포인트로 람다를 분리하여 서버에 직접 파일을 업로드하는 것을 지양해야 합니다. 제가 생각하는 이상적인 업로드 및 변환 방법은 아래와 같습니다.
이번 포스팅에서 제가 실험해본 것은 간단히 라이브러리를 사용하여 파일 포맷을 변환하는 것이기 때문에 다음 포스팅에선 위 방법대로 S3 버킷과 람다를 활용하여 이미지 컨버팅을 하는 것을 생각하고 있습니다.
간단한 목업 프로젝트이기 때문에 UI는 그리 신경쓰지 않았습니다.
우선 람다의 역할을 대신해줄 서버를 로컬에 하나 띄우고, 간단하게 Vue로 화면단을 구성했습니다.
프론트엔드 로컬 호스트는 포트 넘버 8080으로, 서버는 9000번으로 설정하였습니다.
API Call (Vue Options API)
methods: {
apiCall: async function (formData) {
const res = await axios.post(URL,
formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
})
const { data } = res
const img = new Image()
img.src = 'data:image/jpg;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
}
}
우선 프론트에서 파일 업로드가 감지되면 이미지 객체 타입을 보고 image/heic
이 아닌 이미지는 걸러냅니다.
HEIC 포맷이라면 이미지 파일 객체를 formData 객체에 담고, multipart/form-data
로 컨텐츠 타입을 명시하여 API Call을 날립니다.
그 후 base64로 변환되어 API로부터 내려온 JPEG 이미지를 받아 화면에 렌더링하고, 다운받을 수 있습니다.
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}`)
})
화면단에서 form-data
로 파일을 올린것 까진 순조로웠는데, 생각해보니 한번도 서버 단에서 파일이 담긴 formData 객체를 열어본 경험이 없었습니다. req의 body 객체나, 람다라면 event 안에 들어있을 것이라 생각했었습니다.
multer
라는 미들웨어를 사용하면 multipart/form-data
형식의 요청을 다룰 수 있습니다.
* 파일이나 이미지를 서버로 전송할때 주로 사용하는 컨텐츠 속성값 multipart/form-data
에 대해 더 알아보고 싶다면 링크를 참조하세요.
const multer = require('multer')
multer를 사용하면 파일 데이터를 처리할 수 있습니다. 일반적으로 전송받은 파일을 임시 폴더에 파일로 저장 후 계속 처리합니다. 하지만 저는 파일 정보를 임시 폴더에 저장하지 않고 메모리에 저장해서 처리하고 싶습니다.
const storage = multer.memoryStorage()
const upload = multer({ storage: storage })
메모리 스토리지 인스턴스를 변수에 할당하고 multer 모듈의 storage로 전달하면 메모리에 파일을 저장하고 처리할 수 있게 됩니다.
app.post('/image', upload.single('img'), async function (req, res) {
// ...some logics
})
/image
엔드포인트의 라우트 핸들러에 multer로 만든 미들웨어를 끼워넣습니다. 이때 upload.single('img')
이라 명시한 이유는, 폼 데이터의 속성명이 img이거나 폼 태그 인풋 이름이 img인 파일 하나를 받겠다는 뜻입니다. 나머지 데이터는 그대로 req.body에 들어옵니다. 참조
이제 HEIC 파일을 업로드해서 속을 들여다 보겠습니다.
app.post('/image', upload.single('img'), async function (req, res) {
console.log('req.file >>>>>>>>', req.file)
console.log('req.file.buffer >>>>>>>>', req.file.buffer)
화면단에서 이미지를 업로드하면 아래와 같이 콘솔에 찍힙니다.
위와 같이 fieldname
이 img
이기 때문에 미들웨어에서 multer 스토리지에 업로드 처리 되었고, mimetype에 image/heic
이 명시된 것을 볼 수 있습니다. 메타 데이터와 함께 파일은 buffer 속 바이너리 데이터로 들어옵니다.
이 buffer 필드의 heic 파일을 heic-convert
라이브러리에 input buffer로 넣어줍니다. 그리고 response 객체에 변환된 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('base64Ouptut >>>>>', base64Img)
res.json(base64Img)
})
base64로 변환된 문자열이 콘솔에 쏟아집니다.
apiCall: async function (formData) {
const res = await axios.post(URL,
formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
})
const { data } = res
const img = new Image()
img.src = 'data:image/jpg;base64,' + data
img.width = 600
this.isLoading = false
this.image = img
}
변환된 JPEG 이미지 데이터를 base64를 src에 data:image/jpg;base64,
문자열을 붙여 참조시키고, 이미지 객체를 만들어 화면에 렌더링합니다.
이렇게 변환된 이미지를 비교해보면 확실히 heic이 월등한 효율성을 보이는 것을 확인할 수 있습니다.
heic을 jpg로 컨버팅하는 것을 클라이언트 단에서 처리하는 것이 가능한지, 가능하다면 어떤 프로세스에서 사용할지는 더 공부해야 할 것 같습니다.
이상 간단한 heic-convert 라이브러리 사용기였습니다.
Wonkook Lee
Frontend Developer
LinkedIn