몇 달 전, 저는 개인 프로젝트를 시작했습니다: PhotoCut. 이 사진 편집기는 이미지를 어떤 서버에도 업로드하지 않고 AI로 배경을 제거하는 기능입니다. 모든 과정(AI 처리, 자르기, 내보내기)이 브라우저 내에서 이루어집니다.
이 아이디어는 구체적인 불편함에서 출발했습니다. 제가 찾던 무료 배경 제거 도구들은 사용자의 사진을 서버에 업로드한 다음, 고화질 다운로드를 할 때 비용을 청구하는 식이었죠. 저는 그런 일을 전혀 하지 않는 도구를 원했습니다.
제가 이 프로젝트를 어떻게 구성했는지, 그리고 무엇보다 예상치 못했던 문제점들을 말씀드리겠습니다.
내부 작동 방식
- AI 자르기(cropping) 기능은 인터페이스가 멈추지 않도록 Web Worker 내에서 onnxruntime-web을 사용합니다.
- 장비별 두 가지 모델:
- 브라우저가 사용할 수 있는 GPU가 장착된 컴퓨터에서는 BiRefNet-lite를 fp16으로 구동합니다. 이 모델은 약 115MB 크기이며 매우 정교한 자르기를 제공합니다.
- GPU가 없는 휴대폰이나 장비에서는 WebAssembly 위에서 u2netp를 구동합니다. 이 모델은 4.6MB입니다.
- **엣지 다듬기(refinement of edges)**는 제가 Rust로 작성하고 WebAssembly로 컴파일한 GrabCut 엔진을 사용합니다.
- 최초 실행 시 모델이 다운로드되어 캐시됩니다. 첫 자르기가 완료된 후에는 인터넷 연결 없이도 앱을 사용할 수 있습니다.
사진이 브라우저를 벗어나지 않음을 확인하기 위해 전체 세션을 측정해 보았는데, 93개의 네트워크 요청이 발생했지만 이미지 데이터가 전송되는 것은 단 한 건도 없었습니다. 자르기 과정 중 '네트워크(Network)' 탭을 열어보면 누구나 이를 확인할 수 있습니다.
버그: 아이폰 Safari에서 페이지가 새로고침됨
맥에서는 모든 것이 완벽하게 작동했습니다. 그러다가 iPhone 13에서 테스트해보니, 'AI 자르기'를 누르고 모델을 다운로드하자... 페이지가 저절로 새로고침되었습니다. 콘솔에 오류 메시지도 없고, 예외 처리도 없었습니다. 아무것도 없었습니다.
가설
iOS 26은 Safari에서 WebGPU 기능을 활성화하며, 아이폰의 GPU는 제가 확인하던 조건(하드웨어 어댑터, shader-f16, 충분한 저장 버퍼)을 충족했습니다. 따라서 코드는 다음과 같이 판단했던 것입니다: '적절한 GPU가 있다 → BiRefNet을 사용한다.'
문제는 메모리입니다. 115MB 규모의 모델이 여러 번 로드되기 때문입니다. 다운로드된 버퍼, ONNX 런타임 내부의 복사본, 그리고 GPU의 버퍼가 사용되는데, 이는 1024×1024 이미지의 경우 수백 MB에 달합니다. iOS는 탭당 메모리 제한이 있으며, 이 한계를 초과하면 프로세스를 종료시킵니다 (iOS에서 _jetsam_이라고 불립니다). Safari는 단순히 페이지를 다시 로드할 뿐입니다.
그리고 프로세스가 죽기 때문에, 코드가 포착할 수 있는 오류가 전혀 없습니다. 제 백업 논리("BiRefNet이 실패하면 u2netp를 사용한다")는 실행될 기회조차 없었습니다. 더 심각한 것은, 페이지가 다시 로드되면 시도 기록 자체가 남지 않아 매번 재시작된다는 점입니다.
테스트
코드를 건드리기에 앞서, 2분짜리 간단한 테스트를 진행했습니다: iPhone의 '설정 → Safari → 고급 → 기능 플래그(Feature Flags)'에서 WebGPU를 비활성화합니다. WebGPU가 없으면 코드는 u2netp를 선택하게 되고... 크롭은 완벽하게 작동했습니다. 가설이 입증되었습니다.
두 개의 레이어로 된 해결책
- 장치 규칙: 휴대폰과 태블릿에서는 WebGPU가 있더라도 항상 작은 모델을 사용합니다. iPad에 주의해야 하는데, 이는 user agent에서 Mac으로 인식됩니다. 판단 근거는
navigator.maxTouchPoints > 1입니다. 그리고 Web Worker에 주의해야 합니다. Web Worker는 maxTouchPoints나 screen 속성을 가지지 않기 때문에, 메인 스레드에서 "모바일 여부"를 계산하여 워커에게 전달해 주어야 합니다.
- 루프 방지 마커: 큰 모델을 열기 전에 '진행 중인 시도'라는 마커를 IndexedDB에 저장합니다 (IndexedDB는 worker 내부에 존재하며,
localStorage는 그렇지 않습니다). 크롭이 성공적으로 완료되면 이 마커를 삭제합니다. 만약 페이지가 다시 로드되었는데도 마커가 남아 있다면, 다음 시도는 바로 작은 모델로 연결됩니다. 이 마커는 임의로 탭을 닫았을 경우에 대비하여 7일 후에 만료됩니다.
두 번째 레이어는 첫 번째 레이어가 제가 예상하지 못한 장치에서 실패할 수 있기 때문에 존재합니다. 어떤 팀도 영원히 다시 로드되는 상황에 놓여서는 안 됩니다.
교훈: Playwright의 WebKit 테스트에서는 모든 것이 통과했습니다. 왜냐하면 해당 컨텍스트의 Mac에는 WebGPU가 없었기 때문입니다. 실제 iPhone은 2분 만에 어떤 테스트도 발견하지 못한 것을 찾아냈습니다.
보너스: Safari와 사실 PNG인 WebP
특정 프리셋은 WhatsApp 스티커를 내보냅니다: 크기 512×512의 WebP 파일이며 용량은 100KB 미만입니다.
Chrome과 Firefox에서는 canvas.toBlob(cb, 'image/webp', calidad)가 작동합니다. 하지만 Safari에서는 오류를 발생시키지 않고 PNG로 반환됩니다. 만약 요청한 형식에 맞춰 파일 이름을 지정하더라도 결국 .webp라는 이름의 PNG 파일을 전달하게 됩니다.
제가 진행한 작업:
- WebP 네이티브 지원 여부를 한 번 감지합니다: 작은 캔버스를 코딩하고
blob.type을 확인합니다.
- 네이티브가 없는 경우에만, WebAssembly로 컴파일된 libwebp (약 110KB)를 전용 워커(dedicated worker)에서 로드합니다. Chrome 사용자는 이 라이브러리를 다운로드하지 않습니다.
- 파일 이름은 요청한 타입이 아닌 실제 blob의 타입을 따릅니다.
- WebKit을 이용한 테스트에서는 바이트가
RIFF....WEBP로 시작하는지 확인합니다.
제가 배운 것들
- 브라우저에서 AI를 구동하는 것은 이미 실현 가능하지만, 실제 한계는 속도가 아니라 모바일 기기의 메모리입니다.
- 죽는 프로세스는 알리지 않습니다. 만약 백업 로직이 오류 포착에 의존한다면, 이는 _jetsam_으로부터 당신을 지켜주지 못할 것입니다.
- 실제 장치에서 테스트하세요. 에뮬레이터나 테스트용 브라우저는 동일한 한계를 가지고 있지 않습니다.
직접 사용해 보세요
PhotoCut은 계정이나 워터마크 없이 무료이며 코드는 오픈 소스(MIT)입니다. 웹사이트는 호스팅 비용을 지불하기 위해 Google 광고를 포함하고 있으며, 이 스크립트들은 자체 요청을 수행하지만 사진 파일을 전송하는 요청은 없습니다.
👉 앱: [https://www.photocutapp.com/editor/]
👉 코드: [https://github.com/gabiMaglia/PhotoCutOnline]
특히 저가형 기기에서 어떻게 작동하는지 알고 싶습니다. 어떤 비판이라도 환영합니다!