이슈 대응 경험들/FE

.heic 형태의 이미지 처리

calendar2 2025. 3. 2. 18:28

작은 회사의 SM 업무를 담당하게 됐는데 덕분에 다양한 형태의 이슈들을 만나볼 수 있는 경험이 되고있다. 앞으로는 머릿 속에 강렬히 남았던 이슈들을 블로그에 정리해보려고 한다.

원인

아이폰과 같은 Apple 생태계에서는 사진 촬영 시 기본 확장자가 heic로 저장된다고 한다. 필자는 갤럭시 유저라 전혀 모르겠지만 아마 사진 설정을 따로 하지 않을 경우 기본적으로 heic 형식으로 이미지 파일이 저장되는 것 같다.

 

그리고 이 확장자명 때문에 업로드한 이미지의 문제가 발생했다. 업로드 자체는 서버에 해당 이미지를 저장하기에 문제가 없었으나 사이트에서 해당 이미지를 불러올 때 이미지가 보이지 않고 깨져 나오는 현상이 발생했다.

찾아보니 브라우저에서는 아직까지 heic 형태의 이미지 파일을 지원하지는 않는다고 한다.

 

다행히 눈썰미가 좋은 동료의 도움으로 깨진 이미지가 heic 확장자로 되어있다는 것을 금방 발견하였고 대응책도 금방 찾을 수 있었다.

대응 방식

간단히 두 가지 방식을 찾을 수 있었다.

  1. heic2any 라이브러리를 사용하여 이미지 파일 변환
  2. ImageMagick + libheif를 사용하여 이미지 파일 변환

쉽게 말하면, 1번은 FE 서버에서 변환하는 작업이고 2번은 BE 서버에서 변환하는 작업이다.

 

결론부터 말하면 1번 방식을 사용해서 이미지 변환 기능을 만들었다.

프로젝트 환경

지금껏 프로젝트를 하며 많은 블로그들을 참고했지만, 개인적으로는 사실 이 부분이 제일 중요하다고 생각한다.

나랑 이 블로그를 작성한 사람이랑 개발 환경이 얼마나 비슷한가

 

이걸 알아야 글을 참고하는 사람이 본인의 환경에 맞춰서 판단과 선택을 할 수 있다고 생각한다.

우선, 필자의 환경은 JSP 템플릿과 MyBatis로 데이터 입출력을 구현한 구닥다리 방식이다.

 

서버 하나로 프로젝트가 동작하는 방식이라 2번 방식이 가장 정석적인 방법이지만 그럼에도 필자는 1번 방식을 선택했다.

이유는 기존 SI 업체에서 Controller 단에서 데이터 핸들링을 죄다 HashMap으로 진행하여 서버에서 데이터 확인이 어려웠고 View단 또한 스파게티 코드로 알아보기 어려웠지만 이미지를 첨부하고 서버로 데이터를 전달하는 부분까지는 일방향 과정이라 첨부한 이미지 데이터를 바로 알아보기 편하였다.

결과

따라서, 다음과 같이 js 파일로 이미지 변환 메서드를 만들었다. 혹시 결과가 먼저 필요하신 분들을 위해 최종적인 코드부터 보여드린다.

// src/main/webapp/js/common/convertHeicToJpg.js

// heic2any 라이브러리 불러오기
async function loadHeic2Any() {
    // heic2any 라이브러리가 로드된 경우에는 바로 return
    if (window.heic2any) {
        return;
    }

    return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.src = 'https://cdnjs.cloudflare.com/ajax/libs/heic2any/0.0.4/heic2any.min.js';
        script.onload = resolve;
        script.onerror = function () {
            reject('heic2any 라이브러리를 로드하는 데 실패했습니다.');
        };
        document.head.appendChild(script);
    })
}

// heic 형식의 이미지 파일을 jpg 형식으로 변환하기
async function convertHeicToJpg(inputFile) {
    await loadHeic2Any();
    const fileName = inputFile.name;
    const fileExt = fileName.split('.').pop().toLowerCase();

    if (fileExt === 'heic') {
        try {
            const resultBlob = await heic2any({ blob: inputFile, toType: 'image/jpeg' });

            return new File([resultBlob], fileName.replace('.heic', '.jpg'), {
                type: 'image/jpeg',
                lastModified: new Date().getTime()
            });
        } catch (error) {
            console.error('HEIC 변환 오류: ', error);
            return inputFile;
        }
    } else {
        return inputFile;
    }
}

 

convertHeicToJpg 메서드가 실질적인 이미지 변환 메서드이며, loadHeic2Any 메서드가 이미지 변환 전에 heic2any 라이브러리가 cdn 형태로 불러오는 메서드이다.

npm 환경이 아니기 때문에 cdn으로 불러와야 하는 불편함이 있다.

문제점

최초의 이미지 변환 로직은 다음과 같이 이미지를 리스트로 받아 순회하며 변환하는 형태였다.

// heic 형식의 이미지 파일을 jpg 형식으로 변환하기
async function convertHeicFilesToJpg(inputFiles) {
    await loadHeic2Any();
    
    const conversionPromises = inputFiles.map(async (file) => {
        const fileName = file.name;
        const fileExt = fileName.split('.').pop().toLowerCase();

        if (fileExt === 'heic') {
            try {
                const resultBlob = await heic2any({ blob: file, toType: "image/jpeg" });
                return new File([resultBlob], fileName.replace(".heic", ".jpg"), {
                    type: "image/jpeg",
                    lastModified: new Date().getTime(),
                });
            } catch (error) {
                console.error("HEIC 변환 오류: ", error);
                return file; // 변환 실패 시 원본 파일 반환
            }
        } else {
            return file; // HEIC가 아닌 파일은 그대로 반환
        }
    });

    return Promise.all(conversionPromises);
}

 

그러나 이 방식으로 작업한 뒤 이미지 변환 처리까지 시간이 비상식적으로 길어지는 결과를 얻었다.

  • "신청" 버튼 클릭부터 페이지 렌더링까지 걸린 시간 : 7.126초
  • 이미지 파일 리스트를 순회하며 이미지 변환이 완료되는데까지 걸리는 시간 : 5.482초

이는 실제 운영되는 서비스에서 허용할 수 없는 속도였고 어떻게든 이를 개선해야 했다.

 

실행 속도가 저렇게까지 느린 이유는 한 가지밖에 없었다

  1. 이미지 변환 전에 heic2any 라이브러리를 불러와야 하고
  2. 각 이미지 리스트를 순회하며 이미지를 변환하고
  3. 최종적인 결과를 받아와야 페이지 렌더링 기능이 진행된다

이 모든 작업이 비동기 처리가 아닌 동기처리가 되며 선행작업이 끝날 때까지 기다렸기에 저렇게 느린 속도가 나왔다고 생각했다.

아이러니하게 가장 마지막에 Promise.all() 메서드를 이용해 비동기 함수의 병렬 처리를 진행했지만 필자가 온전히 이해하지 못 하고 사용하여 병렬처리가 되지 못 한 것으로 보였다.

 

따라서, 이미지 변환 메서드는 리스트를 순회하지 않고 파일 하나씩 매개변수로 받는 비동기 메서드로 만들고 이를 호출하는 jsp 파일에서 다음과 같이 Promise.all() 메서드로 병렬 처리를 진행하였다.

// src/main/webapp/WEB-INF/views/pages/shop/mypage/claimWrite.jsp

const uploadFileList = await Promise.all(fileList.map((file) => convertHeicToJpg(file)));
for (let i = 0; i < uploadFileList.length; i++) {
    formData.append('files', uploadFileList[i]);
}

 

첫 방식과 다른 점이라고 한다면, 처음에 Promise.all() 메서드로 호출한 conversionPromises 메서드는 inputFiles를 순회하며 map 함수 내에서 구현된 콜백함수가 비동기 함수였다.

애초에 비동기 함수가 아닌 conversionPromises 메서드를 병렬처리 했으니 이미지 리스트를 순회하며 각 이미지를 변환할 때까지 기다려야 했고 그 시간들이 샇여 무려 5초나 필요하게 된 것이었다.(라고 생각하고 있다)

// heic 형식의 이미지 파일을 jpg 형식으로 변환하기
async function convertHeicFilesToJpg(inputFiles) {
    await loadHeic2Any();
    
    // conversionPromises는 async 메서드가 아님!!
    // 그러므로 inputFiles를 map으로 순회하는 결과를 전부 받을 때까지 기다려야 하고
    // map 함수 내에서 구성된 콜백 함수는 async 메서드이지만 병렬 처리가 되어있지 않음!!
    const conversionPromises = inputFiles.map(async (file) => {
        const fileName = file.name;
        const fileExt = fileName.split('.').pop().toLowerCase();

        if (fileExt === 'heic') {
            try {
                const resultBlob = await heic2any({ blob: file, toType: "image/jpeg" });
                return new File([resultBlob], fileName.replace(".heic", ".jpg"), {
                    type: "image/jpeg",
                    lastModified: new Date().getTime(),
                });
            } catch (error) {
                console.error("HEIC 변환 오류: ", error);
                return file; // 변환 실패 시 원본 파일 반환
            }
        } else {
            return file; // HEIC가 아닌 파일은 그대로 반환
        }
    });

    return Promise.all(conversionPromises);
}

 

반면, 수정한 방식은 정확하게 비동기 메서드를 Promise.all() 메서드로 병렬처리를 진행하고 있다.

// src/main/webapp/js/common/convertHeicToJpg.js

// heic 형식의 이미지 파일을 jpg 형식으로 변환하기
async function convertHeicToJpg(inputFile) {
    await loadHeic2Any();
    const fileName = inputFile.name;
    const fileExt = fileName.split('.').pop().toLowerCase();

    if (fileExt === 'heic') {
        try {
            const resultBlob = await heic2any({ blob: inputFile, toType: 'image/jpeg' });

            return new File([resultBlob], fileName.replace('.heic', '.jpg'), {
                type: 'image/jpeg',
                lastModified: new Date().getTime()
            });
        } catch (error) {
            console.error('HEIC 변환 오류: ', error);
            return inputFile;
        }
    } else {
        return inputFile;
    }
}


// src/main/webapp/WEB-INF/views/pages/shop/mypage/claimWrite.jsp

// fileList를 순회하며 map 함수 내에서 호출한 convertHeicToJpg 메서드는 async 메서드이다!!
const uploadFileList = await Promise.all(fileList.map((file) => convertHeicToJpg(file)));
for (let i = 0; i < uploadFileList.length; i++) {
    formData.append('files', uploadFileList[i]);
}

 

물론 결과도 대폭 개선되었다.

  • cdn 스크립트 jsp 파일에 직접 명시
    • "신청" 버튼 클릭부터 페이지 렌더링까지 걸린 시간 : 1.532초
    • 이미지 파일을 순회하며 변환하는데 걸린 시간 : 2.071 밀리초
  • cdn 스크립트 js 메서드로 불러오는 방식
    • "신청" 버튼 클릭부터 페이지 렌더링까지 걸린 시간 : 1.627초
    • 이미지 파일을 순회하며 변환하는데 걸린 시간 : 0.196초

확실히 cdn 스크립트도 jsp 파일에 직접 명시하는 것이 추가적인 비동기 메서드를 실행할 필요없어 약 0.1초 속도가 더 빨랐다.

 

하지만, 최종적으로는 이미지 변환 메서드에서 비동기 형식으로 heic2any 라이브러리를 불러오는 방식을 선택했다.

이유는 유지보수를 고려해서이다.

 

이미지를 업로드하는 기능은 해당 페이지 뿐만 아니라 다른 곳에도 많이 존재한다.

당장 거대한 프로젝트의 모든 이미지 변환 로직을 변경할 수 없고, 이후에 비슷한 문제가 발견될 때 수정을 해줘야 하는데 그때마다 js 파일과 cdn 스크립트를 모두 작성해주는 방식은 수정하는 입장에서 꽤 불편할 것으로 생각했다.

 

또한, 내가 아닌 다른 사람이 이 유지보수를 담당할 때 그 부분을 놓칠 가능성이 존재하기에 수정사항은 파일 하나로 가져가는 것이 편하다.

로직 성능이 압도적인 차이가 나지 않고 겨우 0.1초 정도 차이기에 성능을 다소 느리게 가져가더라도 유지보수가 쉬운 방식을 선택했다.

 

++) View의 헤더 영역에 cdn을 넣으면 된다는 생각이 있을 수 있다. 물론 필자도 처음에는 그걸 생각했다.

하지만 실제 현업에서 사용되는 코드를 보면 취준생들이나 학생들이 작업하는 규모와 비교를 할 수 없게 거대하게 구성되어 있다.

게다가 필자가 담당한 프로젝트는 jsp 파일 하나의 2 ~ 3천줄이 기본으로 들어갈정도로 가독성을 좋지 못 하게 가져간 프로젝트였다.(실행이 되는 게 신기할 정도다...)

결국 View 단에서 공통으로 적용되는 head 부분을 찾지 못해 집약적인 수정으로 해결책을 찾는 방향으로 우회할 수 밖에어 없었다.(이건 아직 업무 경험이 많이 부족한 필자의 역량 한계라고 생각한다. 계속 성장해서 더 좋은 해결책을 찾도록 노력하겠다)