토스터 부스에서는 사용자가 촬영한 네컷 사진을 PNG로 다운로드할 수 있도록 dom-to-image 를 사용하고 있었습니다.
(( 그 전에는 html2Canvas 도 고려해봤으나 해당 라이브러리는 filter css 를 호환하지 않고, 우리 서비스는 보정 필터가 핵심 기능이라 dom-to-image 로 변경한 이력이 있음. ))
그러다 최근 디버깅 중 dom-to-image 는 사파리 브라우저를 호환하지 않는다는 걸 발견한 후 html-to-image 로 변경하였습니다.
첫 MVP에서는 노트북 환경을 우선적으로 작업하다보니 사파리에서 작동 안하는 이슈를 캐치하지 못하는 불찰이 있었습니다..
이후 html-to-image의 toBlob() 함수를 사용하여 작업을 진행했습니다.
그런데 테스트 도중 Safari 브라우저에서만 이미지가 비어 있는 상태로 저장되는 현상을 발견했습니다.
처음엔 네트워크 문제인가? 에러인가? 했는데 디버깅을 해보니 꽤 깊은 이유를 발견할 수 있었습니다.
🐛 문제 현상
- Chrome, Edge, Firefox: 정상적으로 이미지 포함된 PNG 다운로드
- Safari: 이미지가 일부 누락되거나 완전히 빠진 채로 저장됨
🧪 원인 분석: Safari가 또.. 남다른 길을 간다.
html-to-image는 내부적으로 DOM을 캔버스로 렌더링하고 이미지를 PNG로 변환합니다.
그런데 Safari는 리소스 최적화를 위해 백그라운드 탭에서의 이미지 로딩을 지연(Lazy-load)시키거나, 크로스-오리진 이미지에서 CORS 정책을 더 엄격하게 적용하기도 합니다.
이러한 차이로 인해 렌더링 시점에 이미지가 준비되지 않아 빈 캔버스가 생성되는 경우가 발생했습니다.
그리고 html-to-image는 DOM 요소를 SVG foreignObject로 변환한 뒤, 이를 <canvas>에 그려서 최종적으로 Blob 객체를 생성합니다. 이 과정에서 리소스 로딩 상태가 완벽하지 않으면 일부 요소가 누락된 상태로 캡처됩니다.
결국 결과물에는 이미지가 들어가지 않거나, 중간에 짤려버리는 현상이 발생하게 되었습니다...
☢️ 무조건 해결해야 한다! 유저들이 이미 사용 중이다!
처음 Safari에서만 발생하는 문제를 발견했을 땐,
"혹시 사용하는 사람이 많지 않으면 일단 다른 것부터 하고 해결할까?" 생각했지만,
Amplitude 로 사용자 데이터를 확인해본 결과,
전체 사용자 중 30% 이상이 Safari 브라우저를 사용하고 있었습니다.
당시에는 아직 모바일 기능을 오픈하지 않은 상태였지만, 곧 오픈될 예정이었기 때문에 무조건 해결해야하는 숙제라고 판단되었습니다.
그래서 이 문제를 가장 높은 우선순위로 두고 해결하기로 했습니다.
(🔥 게다가 사진 다운로드는 토스터 부스에서 가장 중요한 기능이기도 하니깐요!!)
🙌 문제를 해결해보자!
역시나 이 문제에 대한 이슈가 이미 html-to-image 레포지토리에도 열려 있었고, 덕분에 다양한 실험을 해볼 수 있었습니다.
참고한 이슈: html-to-image #367
Image is not showing in some cases iOS, Safari · Issue #361 · bubkoo/html-to-image
The html is converted to png without the images included in the html block. It shows white background replaced instead of the images It happens sometimes not everytime, specially on iOS, Safari dev...
github.com
🔧 초기 시도: 이미지 로딩 기다리기
이슈의 코멘트를 읽어본 후 가장 먼저 떠올린 건 모든 <img> 요소가 로딩된 뒤에 변환을 시도하는 방법이었습니다.
const images = element.querySelectorAll('img');
const loadPromises = Array.from(images).map(img => {
return new Promise(resolve => {
if (img.complete) return resolve(true);
img.onload = () => resolve(true);
img.onerror = () => resolve(true);
});
});
await Promise.all(loadPromises);
Safari에서는 효과가 있을 때도 있지만 없을 때가 더 많았습니다.
🌀 무작정 여러번 호출하기?
일부 커뮤니티에서는 정말 극단적이지만 변환 메서드를 여러번 호출하면 된다라는 말도 있었습니다.
실제 사용해보니 처음에 작동도 해서 아니.. 이게 된다고? 하기도 했지만 이 방법도 불안정했습니다.
await toPng(...);
await toPng(...);
await toPng(...);
const result = await toPng(...); // This should have the images properly loaded
사진을 한장 랜더링하는 건 버틸 수 있어보이지만 우리의 네컷 사진은 사용자의 사진 4개 + 프레임 사진까지 총 5개의 사진을 랜더링 해야하보니 메서드를 극단적으로 10번 이상 호출하는 거 아닌 이상 크게 도움이 되지 않았습니다…
(그리고 사실 코드도 자체도 좀... )
일부 커뮤니티에선 "Safari에선 좀 더 기다리면 된다"는 팁도 있어서 적용해봤습니다.
if (isSafari()) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
일정 시간 기다리면 성공할 때도 있었지만,
우리 서비스처럼 리소스가 많거나 느린 환경에선 여전히 실패 확률이 존재했습니다.
✅ 최종 해결: 반복 시도 + Blob 크기 검사
결국 안정적인 방법은 다음과 같았습니다:
📌 변환을 여러 번 시도하면서,
생성된 Blob의 크기가 일정 이상일 때만 결과로 인정하기!
const buildBlobWithRetry = async (
element: HTMLElement,
minBlobSize = 500_000,
maxAttempts = 10,
) => {
let blob: Blob | null = null;
let attempt = 0;
while (attempt < maxAttempts) {
blob = await toBlob(element, {
cacheBust: true,
pixelRatio: 3,
});
if (blob && blob.size > minBlobSize) {
break;
}
attempt += 1;
await new Promise(resolve => setTimeout(resolve, 500));
}
return blob;
};
이 방식은 사파리에서만 2~6번 정도 시도하게 되고,
다른 브라우저에서는 대부분 첫 시도에 성공해서 바로 리턴되기 때문에 성능상 큰 부담도 없었습니다!
💡 최종 적용 코드
const blob = await buildBlobWithRetry(downloadDivRef.current);
if (blob) {
saveAs(blob, `toaster-booth-${getFormatDate(new Date())}.png`);
}
🧠 회고
- 브라우저 호환성 문제는 지금도 여전히 존재한다.
- Safari는 성능 최적화 관점에서 이미지 로딩을 기다려주지 않기 때문에 더 방어적인 접근이 필요했다.
- "완성된 이미지의 상태"를 기준으로 안정성을 보장하는 방식이 가장 효과적이었다.
Safari에서의 예외 처리 덕분에 사용자는 더 안정적인 다운로드 경험을 누릴 수 있게 되었습니다!
브라우저 호환성은 여전히 프론트엔드 개발자에게 숙제지만, 그 과정을 통해 더 깊이 배우는 것 같아서 뿌듯합니당. 😊
이 부분을 조금 더 파서 라이브러리 기여도 도전해보고 싶습니다! 일단 토스터 부스 운영이 안정이 되면 도전해보려고 합니당 !
'개발자의 성장 도파민 기록' 카테고리의 다른 글
🍞 Toaster Booth — 토스터기로 네 컷을 찍어보세요! (5) | 2025.04.14 |
---|---|
📸 toaster: 네컷 사진이 사라졌다!? 새로고침을 견디는 상태 저장 여정 (1) | 2025.04.12 |
🥐 toaster: Rive를 사용해서 귀엽고 인터랙티브한 서비스 만들기 (5) | 2025.04.08 |
🍞 toaster: 헤일리의 웹캠네컷의 시작 ! ; 브라우저에서 카메라 사용부터 컴포넌트를 이미지로 변환 후 저장까지! (7) | 2025.04.02 |
🏆 마일리지 장학금 신청 서비스 개발기: 한달간의 성장 일지 (11) | 2025.03.20 |