MOMSOFT / 기술 블로그
파일을 서버에 올리지 않고 브라우저에서 처리한다는 것
이미지·PDF를 클라이언트에서 처리할 때 무엇을 얻고 무엇을 치르는지, 그리고 어디서부터는 여전히 서버로 보내야 하는지 그 경계를 정리합니다.
프로젝트 문의하기 →결론부터 말하면, 파일을 서버에 올리지 않고 브라우저에서 처리하는 구조는 "기술적으로 가능한가"의 문제가 아니라 "치를 값이 감당 가능한가"의 문제입니다. 이미지 리사이즈, 포맷 변환, PDF 병합·분할·회전 정도는 이미 오래전부터 클라이언트에서 충분히 돌아갑니다. 문제는 그 대가로 사용자가 첫 진입에서 수 MB짜리 WASM 모듈을 내려받아야 하고, 저사양 기기에서는 탭이 통째로 죽을 수 있으며, 무엇이 왜 실패했는지 서버 로그로는 영원히 알 수 없다는 점입니다.
그래서 이 글은 "브라우저에서 다 하자"는 이야기가 아닙니다. 어떤 작업까지 클라이언트로 내리고, 어디서부터는 여전히 서버가 맡아야 하는지 경계를 어디에 긋느냐에 대한 이야기입니다. 판단 기준과 실제 구현 순서, 그리고 하지 말아야 할 경우를 차례로 정리합니다.
왜 지금 이게 가능해졌는가
새로운 마법이 생긴 것이 아닙니다. 원래 따로 있던 조각들이 실무에서 쓸 만한 수준으로 맞물린 것에 가깝습니다. 구조를 이해하려면 아래 네 가지를 분리해서 봐야 합니다. 이 네 층은 각각 다른 종류의 사고를 냅니다. 섞어서 이해하면 문제가 터졌을 때 어느 층을 고쳐야 할지 판단하지 못합니다.
1. 파일을 메모리로 가져오는 층
<input type="file">이나 드래그앤드롭으로 얻은 File 객체는 Blob입니다. 즉 arrayBuffer()로 전체를 읽을 수도 있고, slice()로 일부만 잘라 읽거나 stream()으로 청크 단위로 흘려보낼 수도 있습니다. 이 선택이 나중에 발생하는 메모리 사고의 대부분을 미리 결정합니다. 파일 하나를 통째로 ArrayBuffer로 올리는 순간, 원본 크기만큼의 메모리를 확정적으로 점유합니다.
중요한 점은 File 객체를 손에 쥐고 있는 것 자체는 비싸지 않다는 것입니다. File은 디스크상의 데이터에 대한 참조에 가깝고, 실제 바이트는 arrayBuffer()나 stream()을 호출하는 순간부터 메모리로 올라옵니다. 그래서 "언제 읽을 것인가"를 늦출수록 유리합니다.
2. 실제 연산을 수행하는 층 (WASM)
JPEG·WebP·AVIF 인코더, PDF 렌더러·조작 라이브러리처럼 원래 C/C++로 잘 만들어져 있던 코드를 WebAssembly로 컴파일해 브라우저 안에서 그대로 돌립니다. 여기서 중요한 건 성능보다 이식입니다. 오래 검증된 네이티브 라이브러리를 다시 JS로 짜지 않아도 된다는 것이 본질적인 이득입니다. "JS보다 빠르다"는 부수 효과에 가깝고, 실제로 순수 JS 구현보다 항상 빠른 것도 아닙니다.
배포 단계에서 놓치기 쉬운 것이 있습니다. WebAssembly.instantiateStreaming은 응답의 Content-Type이 application/wasm일 때만 동작합니다. 정적 호스팅에서 이 MIME 타입이 빠져 있으면 조용히 느린 폴백 경로로 떨어집니다. 그리고 .wasm은 그냥 두면 압축이 안 걸려 있는 경우가 있으니, 전송 압축이 실제로 적용되는지 응답 헤더로 확인해야 합니다. 모듈 크기가 문제인 상황에서 이 두 가지는 코드를 한 줄도 고치지 않고 얻는 개선입니다.
3. 메인 스레드를 지키는 층 (Web Worker)
WASM은 빠르지만 그래도 CPU를 씁니다. 메인 스레드에서 돌리면 그 시간 동안 스크롤도, 클릭도, 애니메이션도 멈춥니다. 사용자에게는 "느리다"가 아니라 "고장 났다"로 읽힙니다. 그래서 무거운 처리는 예외 없이 Worker로 내려야 합니다. 이건 선택 사항이 아니라 전제 조건입니다.
4. 데이터를 스레드 사이로 옮기는 층
postMessage는 기본적으로 구조화 복제(structured clone)를 씁니다. 즉 20MB ArrayBuffer를 그냥 넘기면 복사본이 하나 더 생겨 순간적으로 40MB를 씁니다. Transferable을 명시해 소유권을 넘기면 복사 없이 이동합니다.
// 메인 스레드
const worker = new Worker('/workers/image.js', { type: 'module' });
const buf = await file.arrayBuffer(); // 원본 크기만큼 메모리 점유
worker.postMessage({ buf, opts }, [buf]); // 두 번째 인자 = 소유권 이전(zero-copy)
// 이 시점부터 메인 스레드의 buf 는 detached 상태가 되고 byteLength 는 0 입니다
이 한 줄 차이가 저사양 기기에서 성공과 탭 크래시를 가릅니다. 다만 더 나은 방법이 하나 더 있습니다. File과 Blob은 구조화 복제 대상이지만 바이트를 통째로 복사하는 방식이 아니므로, 메인 스레드에서 읽지 말고 File 자체를 Worker로 넘긴 뒤 Worker 안에서 읽는 것이 대체로 가장 깔끔합니다. 그러면 메인 스레드에는 큰 버퍼가 아예 생기지 않습니다.
얻는 것
프라이버시가 "정책"이 아니라 "구조"가 된다
이게 가장 큰 이득입니다. 서버로 파일을 보내지 않으면, 개인정보처리방침에 "업로드된 파일은 처리 후 즉시 삭제합니다"라고 쓰고 사용자에게 믿어달라고 할 필요가 없습니다. 애초에 받지 않았으므로 삭제할 것도, 유출될 것도, 국외로 이전될 것도 없습니다. 신분증 사본, 계약서 PDF, 의료 서류, 급여명세서처럼 사용자가 업로드 버튼 앞에서 한 번 망설이는 종류의 파일이라면 이 차이는 기능 하나가 아니라 제품의 성립 조건입니다.
실무적으로도 무겁던 것들이 사라집니다. 파일 저장소 접근 통제, 보관 주기 관리, 로그에 파일명이 남는 문제, 위탁 처리 고지, 침해 사고 시 영향 범위 산정 — 서버에 원본이 없으면 상당 부분이 애초에 발생하지 않습니다. 규제 산업 대상 시스템에서 이 항목들을 하나씩 방어해 본 사람이라면 이 절감이 무엇을 뜻하는지 알 것입니다.
다만 이 이득은 "네트워크로 아무것도 나가지 않는다"가 실제로 참일 때만 성립합니다. 오류 리포팅 SDK가 예외 메시지에 파일명을 실어 보내거나, 분석 도구가 입력 값을 수집하거나, 미리보기 썸네일을 어딘가로 올리는 코드가 한 줄이라도 섞이면 구조적 보장은 그 순간 무너집니다. 이 주장을 페이지에 적을 생각이라면 네트워크 탭을 열고 처리 전 과정 동안 요청이 정말 하나도 나가지 않는지 직접 확인해야 합니다.
서버 비용이 사용량에 비례하지 않는다
파일 처리는 서버 입장에서 최악의 워크로드에 가깝습니다. CPU를 오래 쓰고, 메모리를 크게 쓰고, 요청 사이 편차가 크고, 트래픽 급증이 곧바로 큐 지연으로 번집니다. 이걸 견디려면 워커 풀, 큐, 임시 스토리지, 타임아웃, 정리 배치가 전부 필요합니다.
클라이언트 처리로 옮기면 이 구조 전체가 정적 파일 배포로 축약됩니다. 사용자가 늘어도 늘어나는 것은 CDN 전송량뿐이고, 계산 비용은 각 사용자의 기기가 부담합니다. 무료로 공개하는 도구라면 이 차이가 서비스를 유지할 수 있느냐 없느냐를 가릅니다.
지연이 사라진다
서버 방식의 체감 시간은 "업로드 + 대기 + 처리 + 다운로드"입니다. 이 중 사용자가 가장 길게 느끼는 건 대개 처리 자체가 아니라 왕복입니다. 가정용·모바일 회선은 대체로 업로드가 다운로드보다 느리므로, 큰 파일을 올리는 시간이 실제 변환 시간을 압도하는 일이 흔합니다. 클라이언트 처리는 이 왕복을 통째로 지웁니다.
덤으로 인터랙션의 성격이 바뀝니다. 품질 슬라이더를 움직일 때마다 서버를 때릴 수는 없지만, 로컬이라면 미리보기를 즉시 갱신할 수 있습니다. 왕복이 사라지면 "제출하고 기다리는 UI"가 "만지면서 조절하는 UI"로 바뀝니다. 이건 성능 개선이 아니라 기능 설계의 선택지가 하나 늘어나는 일입니다.
오프라인과 실패 격리
Service Worker로 자산을 캐시해 두면 네트워크가 끊겨도 동작합니다. 그리고 처리가 실패해도 그 실패는 한 사용자의 탭 안에 갇힙니다. 서버 방식이라면 특정 손상 파일 하나가 워커 프로세스를 물고 늘어져 다른 사용자 요청까지 지연시키는 일이 생기지만, 클라이언트 처리는 그런 전파가 없습니다. 장애 대응 관점에서 보면 "한 사람만 실패한다"는 성질은 꽤 큰 자산입니다.
치르는 값
여기부터가 실제로 판단을 가르는 부분입니다. 앞의 이득은 대부분 홍보 문구에도 등장하지만, 아래 항목들은 대개 배포 후에 발견됩니다.
초기 로딩 — 첫인상을 통째로 내준다
이미지 코덱이나 PDF 엔진을 WASM으로 담으면 모듈 크기가 압축 전 기준 수 MB에 이르는 경우가 흔합니다. 여기에 글리프·폰트·색 프로파일 같은 부속 자산이 붙기도 합니다. 서버 방식이라면 사용자는 아무것도 내려받지 않고 파일만 올리면 되지만, 클라이언트 방식은 아직 쓸지 안 쓸지도 모르는 사용자에게 먼저 비용을 청구합니다.
완화 수단은 있지만 공짜는 아닙니다.
- 지연 로딩 — 사용자가 파일을 실제로 선택한 뒤에 모듈을 받습니다. 초기 진입은 가벼워지지만 첫 처리에 대기가 붙습니다.
- 선인출(prefetch) — 파일 선택 UI에 마우스가 올라오거나 드래그가 시작되는 시점에 미리 받기 시작합니다. 대개 이 절충이 가장 무난합니다.
- 기능별 분할 — "PDF 병합"과 "PDF 텍스트 추출"에 같은 엔진 전체를 실을 필요는 없습니다. 진입 경로별로 필요한 모듈만 나눕니다.
- 캐시 수명 — 어차피 한 번 받으면 오래 씁니다. 해시가 박힌 파일명으로 장기 캐시를 걸어 두 번째 방문의 비용을 사실상 없앱니다.
주의할 점은 이 완화들이 재방문 사용자에게만 잘 듣는다는 것입니다. 검색 결과로 한 번 들어와 한 번 쓰고 떠나는 트래픽이 대부분이라면 캐시는 거의 작동하지 않고, 사실상 모든 방문이 첫 방문입니다. 유입 구조를 먼저 보고 판단해야 합니다.
메모리 — 가장 자주 터지는 곳
서버라면 메모리가 부족할 때 스왑을 쓰거나 인스턴스를 키우면 됩니다. 브라우저에는 그런 선택지가 없습니다. 한도를 넘으면 예외가 아니라 탭 종료로 끝나는 경우가 많고, 그러면 오류 보고조차 남지 않습니다. try/catch로 감싸 두었으니 안전하다고 생각하면 안 됩니다. 잡을 기회 자체가 없습니다.
더 나쁜 건 실제 점유가 순진한 계산보다 훨씬 크다는 점입니다. 압축된 JPEG를 디코드하면 픽셀 버퍼는 파일 크기가 아니라 가로 × 세로 × 4바이트가 됩니다. 여기에 원본 버퍼, WASM 선형 메모리로의 복사본, 결과 버퍼, 미리보기용 사본이 겹치면 원본 파일 크기의 몇 배에서 수십 배가 동시에 살아 있게 됩니다. 그래서 상한은 파일 크기가 아니라 픽셀 수로 걸어야 합니다.
여기에 구조적 제약이 겹칩니다. 32비트 주소 공간을 쓰는 WASM 모듈은 이론상 한계 자체가 4GiB이고, 실제 브라우저·기기별 상한은 그보다 훨씬 낮습니다. 특히 모바일에서는 탭 단위 상한이 보수적으로 잡히는 편이라, 데스크톱에서 잘 돌던 기능이 휴대폰에서만 조용히 죽는 일이 흔합니다. 그리고 WASM 선형 메모리는 늘리기는 쉬워도 줄여서 운영체제에 돌려주기는 어렵다는 점을 기억해야 합니다. 큰 파일을 한 번 처리한 뒤에는 Worker를 재활용하지 말고 terminate()로 버리고 새로 만드는 편이 안전한 경우가 많습니다.
기기 편차 — 테스트 환경이 거짓말을 한다
개발자가 쓰는 기기는 사용자 기기 분포의 상단에 몰려 있습니다. 로컬에서 순식간에 끝나던 변환이 몇 년 지난 보급형 안드로이드에서는 몇 배에서 수십 배 느려질 수 있습니다. 서버 처리에서는 이 편차가 존재하지 않습니다. 누가 요청하든 같은 하드웨어가 처리하니까요. 클라이언트로 내리는 순간 성능은 우리가 통제할 수 없는 변수가 됩니다.
기능 가용성도 균일하지 않습니다. WASM 스레드를 쓰려면 SharedArrayBuffer가 필요하고, 그건 COOP/COEP 헤더로 교차 출처 격리(cross-origin isolation)를 켜야 얻어집니다. 런타임에서는 crossOriginIsolated 값으로 확인할 수 있습니다. 그런데 격리를 켜면 그 헤더 요건을 만족하지 못하는 외부 스크립트·이미지·광고·임베드가 함께 깨집니다. 즉 "스레드를 쓸 것인가"는 성능 결정이 아니라 사이트 전체의 리소스 정책 결정입니다. 이 비용이 아까우면 단일 스레드로 가고, 대신 큰 파일에 대한 기대치를 낮추는 편이 낫습니다.
관측이 사라진다
서버 처리라면 실패한 파일을 보관해 두고 재현할 수 있습니다. 클라이언트 처리는 그럴 수 없고, 그래서도 안 됩니다. 프라이버시를 얻은 대가로 디버깅에 필요한 원본을 영원히 못 보게 됩니다. 남길 수 있는 건 파일 크기 구간, MIME 타입, 실패 단계, 브라우저·기기 정보 같은 내용이 아닌 메타데이터뿐입니다. 이걸 설계 초기에 정해 두지 않으면, 나중에 "일부 사용자에게서만 실패한다"는 제보를 받고 손을 놓게 됩니다. 파일명조차 개인정보가 될 수 있으니 로그 대상에서 빼는 편이 안전합니다.
덧붙여, 로직이 공개된다
클라이언트로 내린 코드는 난독화를 하든 안 하든 사용자 손 안에 있습니다. 처리 알고리즘 자체가 경쟁력의 핵심이거나 라이선스상 배포가 제한된 코드라면 이 방식은 애초에 후보가 아닙니다. 특히 네이티브 라이브러리를 WASM으로 옮길 때는 원 라이브러리의 라이선스가 바이너리 재배포를 어떤 조건으로 허용하는지 먼저 확인해야 합니다. 서버에 두고 쓸 때와 사용자 브라우저로 내려보낼 때의 의무가 다를 수 있습니다.
어떻게 판단하는가
축은 다섯 개면 충분합니다. 이 표에서 오른쪽 칸이 두 개 이상 걸리면 서버 처리 또는 하이브리드를 기본으로 두는 편이 좋습니다.
| 판단 축 | 클라이언트가 유리 | 서버가 유리 |
|---|---|---|
| 파일의 민감도 | 신분증·계약서·의료·재무 등 업로드 자체가 부담 | 어차피 우리 시스템에 저장해야 하는 자산 |
| 결과의 목적지 | 사용자가 받아서 끝(변환·압축·병합) | 서버에 저장·공유·검색·인덱싱해야 함 |
| 작업의 무게 | 단일 파일, 단계가 짧고 예측 가능 | 대용량·다중 파일 일괄, 수십 초 이상 |
| 결과의 결정성 | 브라우저별로 조금 달라도 무방 | 바이트 단위로 동일해야 함(서명·감사·법적 증빙) |
| 사용자 기기 | 사내 표준 기기 등 분포가 좁고 좋음 | 불특정 다수, 저사양 비중이 큼 |
여기에 실무적으로 두 가지를 더 붙입니다.
첫째, "이 기능이 실패했을 때 사용자가 다른 방법을 갖고 있는가." 실패해도 사용자가 다른 도구를 쓰면 그만인 부가 기능이라면 클라이언트 처리의 위험을 감당할 만합니다. 반대로 그 처리가 결제나 신청 같은 핵심 흐름의 통과 조건이라면, 기기 편차 때문에 일부 사용자가 아예 진행하지 못하는 상황을 만들어서는 안 됩니다.
둘째, "프라이버시가 마케팅 문구인가, 요구사항인가." 요구사항이라면 서버 폴백을 기본값으로 두는 설계는 애초에 성립하지 않고, 로컬에서 못 하는 파일은 못 한다고 말하는 편이 맞습니다. 마케팅 문구에 가깝다면 굳이 이 복잡도를 감당할 이유가 약합니다. 이 질문을 건너뛰면 "프라이버시를 지킨다"고 써 놓고 실패 시 조용히 업로드하는, 가장 나쁜 형태가 됩니다.
실제로는 이 순서로 짭니다
1단계 — 브라우저가 이미 할 수 있는 일은 WASM으로 하지 않는다
가장 흔한 과설계가 여기서 나옵니다. 이미지 디코딩과 인코딩, 리사이즈는 브라우저 내장 기능으로 대부분 해결됩니다. createImageBitmap은 Worker 안에서도 동작하고 디코딩을 네이티브로 처리합니다. OffscreenCanvas는 메인 스레드를 건드리지 않고 그리기를 수행합니다. 이 조합이면 WASM 모듈 없이 리사이즈·포맷 변환이 끝납니다.
// Worker 내부 — 추가 모듈 다운로드 없음
const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
const scale = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
const w = Math.round(bmp.width * scale);
const h = Math.round(bmp.height * scale);
const canvas = new OffscreenCanvas(w, h);
canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);
bmp.close(); // 픽셀 버퍼 즉시 해제
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.82 });
지원 포맷, 품질 인자의 해석, imageOrientation 같은 옵션의 기본값은 브라우저와 버전에 따라 차이가 있으므로 대상 브라우저에서 결과를 직접 확인해야 합니다. 요청한 타입을 지원하지 않으면 조용히 PNG로 떨어질 수 있으니, 결과 Blob의 type을 확인하는 습관이 필요합니다. 그럼에도 원칙은 분명합니다. WASM은 브라우저가 못 하는 일에만 씁니다. 고급 코덱 옵션, 특정 인코더의 품질 특성, PDF 구조 조작, 색 관리처럼 내장 기능으로 대체 불가능한 영역이 그 대상입니다.
2단계 — 게이트를 먼저 만든다
처리 코드보다 처리를 할지 말지 결정하는 코드를 먼저 씁니다. 이게 없으면 나중에 붙일 자리를 못 찾습니다.
function canRunLocally(file, env) {
if (!env.hasWasm || !env.hasWorker) return false;
if (file.size > env.maxLocalBytes) return false; // 크기 상한
if (env.deviceMemoryGb && env.deviceMemoryGb < 4) return false;
return true;
}
navigator.deviceMemory는 일부 브라우저에만 있고 값도 대략적인 구간이므로, 있으면 참고하고 없으면 무시하는 보조 신호로만 씁니다. 판단의 중심축은 어디까지나 파일 크기와 이미지의 픽셀 수여야 합니다. 픽셀 수 상한은 파일 크기와 별개로 따로 두어야 합니다. 압축률이 높은 이미지는 파일은 작아도 디코드하면 거대해지고, 이 성질을 악용해 의도적으로 만든 파일도 존재합니다. 헤더만 먼저 읽어 크기를 확인하고 상한을 넘으면 디코드 자체를 시작하지 않는 편이 안전합니다.
확장자와 File.type도 신뢰 대상이 아닙니다. 둘 다 사용자가 바꿀 수 있으므로, 앞부분 몇 바이트를 읽어 시그니처를 확인하고 분기하는 편이 낫습니다. 서버가 없다고 해서 입력 검증이 필요 없어지는 것이 아니라, 검증의 목적이 "서버 보호"에서 "사용자 탭 보호"로 바뀔 뿐입니다.
3단계 — 경계를 좁게, 데이터를 한 방향으로
Worker와 주고받는 메시지 형식을 처음에 확정합니다. 요청은 { id, op, payload, options }, 응답은 { id, type: 'progress' | 'done' | 'error', ... } 정도면 충분합니다. 요청마다 id를 붙이는 것이 핵심입니다. 사용자가 파일을 바꿔 가며 여러 번 실행하면 응답 순서가 뒤섞이는데, id가 없으면 취소된 이전 작업의 결과가 화면에 뜹니다.
오류도 같은 통로로 흘려야 합니다. Worker 안에서 던진 예외는 그냥 두면 메인 스레드에서 원인 문자열이 거의 남지 않는 형태로 보이기 쉬우므로, 처리 함수 전체를 감싸 { type: 'error', stage, message } 형태로 직접 되돌려 보내는 편이 낫습니다. 어느 단계에서 실패했는지가 나중에 유일한 단서가 됩니다.
4단계 — 진행률과 취소를 기능으로 취급한다
서버 처리에서는 스피너 하나로 버틸 수 있지만, 클라이언트 처리는 소요 시간의 편차가 큽니다. 같은 화면이 어떤 기기에서는 눈 깜짝할 사이에 끝나고, 어떤 기기에서는 수십 초를 붙잡습니다. 끝이 언제인지 모르는 상태로 수십 초를 견딜 사용자는 없습니다. 정확한 퍼센트를 못 만들겠다면 최소한 단계 이름이라도 바꿔 가며 보여 줘야 합니다.
그리고 취소는 UI 상태만 되돌리는 것으로 끝나면 안 됩니다. Worker를 실제로 terminate()해서 CPU와 메모리를 회수해야 합니다. WASM 루프 안으로는 취소 신호가 잘 전달되지 않으므로, 협조적 취소를 기대하기보다 종료가 확실합니다. 취소 후에도 배터리가 계속 닳는 페이지는 금방 닫힙니다.
5단계 — 큰 파일은 통째로 읽지 않는다
파일이 커질수록 "전부 메모리로 읽는다"는 전제가 무너집니다. Blob.slice()로 필요한 구간만 읽거나, stream()으로 청크 단위로 처리합니다. 페이지 단위로 분리 가능한 PDF, 프레임 단위로 나뉘는 미디어라면 이 접근이 특히 잘 맞습니다. 중간 산출물을 오래 들고 있어야 한다면 메모리 대신 OPFS(Origin Private File System) 같은 저장소로 내리는 선택지도 있습니다. Worker 안에서 동기 접근 핸들을 쓸 수 있어 임시 파일 용도로 적합합니다. 다만 OPFS에 쓴 중간 파일은 사용자가 페이지를 닫아도 남으므로, 작업이 끝나거나 실패했을 때 지우는 경로를 반드시 같이 만들어야 합니다. 프라이버시를 위해 시작한 구조가 기기에 흔적을 남기면 앞뒤가 맞지 않습니다.
6단계 — 폴백을 처음부터 같이 만든다
"나중에 붙이자"고 미룬 폴백은 대개 붙지 않습니다. 그리고 폴백에는 반드시 사용자 동의가 붙어야 합니다. 로컬 처리에 실패했다고 해서 사용자에게 알리지 않고 파일을 서버로 보내면, 이 구조로 얻은 프라이버시 이득을 조용히 배신하는 것입니다. "이 파일은 이 기기에서 처리하기 어렵습니다. 서버로 보내 처리할까요?" 정도의 명시적 확인이 필요하고, 서버 처리 시 보관 정책을 함께 밝혀야 합니다. 그 정책을 지킬 자신이 없다면 폴백을 만들지 말고 "이 기기에서는 지원하지 않습니다"로 끝내는 편이 정직합니다.
배포 전 체크리스트
- 무거운 처리가 전부 Worker에 있는가. 파일 읽기까지 포함해서.
- 버퍼 전달에 Transferable을 명시했는가. 불필요한 복사본이 남아 있지 않은가.
- 파일 크기 상한과 픽셀 수 상한을 따로 두었는가.
- 확장자·MIME 대신 파일 시그니처로 형식을 확인하는가.
- 상한을 넘겼을 때의 안내 문구가 사용자 언어로 쓰여 있는가. ("메모리 할당 실패" 같은 문구는 안내가 아닙니다.)
- 진행률이 있는가. 취소가 Worker를 실제로 종료시키는가.
- 큰 파일 처리 후 Worker를 폐기하고 새로 만드는가.
- 결과 다운로드에 쓴 오브젝트 URL을
revokeObjectURL로 해제하는가. 여러 파일을 연속 처리할 때 이게 빠지면 그대로 누수입니다. - WASM 응답의 Content-Type이
application/wasm이고 전송 압축이 걸려 있는가. 해시 파일명으로 장기 캐시가 잡혀 있는가. - WASM·Worker·필요한 API가 없는 환경에서 무엇이 보이는가. 빈 화면은 아닌가.
- 실패를 파일 내용 없이 관측할 수 있는가. 단계·크기 구간·기기 정보 정도는 남는가. 반대로 파일명이 로그나 오류 리포팅으로 새 나가지는 않는가.
- 처리 중 네트워크 요청이 정말 하나도 나가지 않는가. 네트워크 탭으로 직접 확인했는가.
- 실제 저사양 모바일 기기에서 테스트했는가. 개발자 도구의 CPU 스로틀링만으로는 메모리 상한이 재현되지 않습니다.
- 이미지 재인코딩 시 EXIF가 어떻게 되는지 확인했는가. 캔버스를 거치면 메타데이터가 사라지는 것이 보통이라 프라이버시 측면에서는 이득이지만, 촬영 방향 정보가 필요한 경우라면 별도로 다뤄야 합니다.
언제는 하지 말아야 하는가
이 구조가 잘 맞는 경우는 생각보다 좁습니다. 아래에 해당하면 클라이언트 처리를 후보에서 빼는 편이 낫습니다.
어차피 서버에 저장할 파일이라면
가장 흔한 오판입니다. 업로드한 이미지를 게시물에 붙일 거라면 파일은 결국 서버로 갑니다. 이때 클라이언트에서 하는 처리는 "업로드를 없애는 것"이 아니라 "업로드 전 축소"일 뿐입니다. 그건 그것대로 가치가 있지만(전송량 감소, 서버 부하 감소), 프라이버시 이득은 하나도 없습니다. 목적을 혼동하면 필요 없는 복잡도만 남습니다. 게다가 서버는 어차피 방어를 위해 자체 검증과 재처리를 해야 하므로, 클라이언트 처리가 서버 파이프라인을 대체하지도 못합니다.
결과가 바이트 단위로 일정해야 한다면
전자서명 대상 문서, 감사 증적, 법적 효력이 있는 산출물처럼 "같은 입력에 항상 같은 출력"이 요구되는 경우입니다. 브라우저와 버전에 따라 인코딩 결과가 미묘하게 달라질 수 있는 환경에서 이 보장을 하는 건 어렵습니다. 서버에서 고정된 라이브러리 버전으로 처리해야 합니다.
사용자 기기 분포를 통제할 수 없고, 그 기능이 핵심 경로에 있다면
불특정 다수를 상대하는 서비스에서 저사양 기기 비중은 늘 예상보다 큽니다. 그 사용자들이 회원가입이나 서류 제출 같은 필수 단계에서 막힌다면, 그건 성능 문제가 아니라 서비스 접근성 문제입니다. 부가 기능이라면 감수할 수 있지만 필수 경로라면 안 됩니다.
처리에 서버만 가진 것이 필요하다면
대용량 참조 데이터, 학습된 모델, 외부 API 키, 라이선스가 걸린 폰트나 코덱, 다른 사용자의 데이터와 대조하는 로직 — 이런 것이 하나라도 끼면 클라이언트 처리는 성립하지 않습니다. 키를 클라이언트에 두는 순간 그건 공개된 키입니다.
결과를 신뢰해야 하는 주체가 서버라면
브라우저에서 만든 산출물을 서버가 그대로 믿고 저장하거나 다음 단계로 넘기는 구조는 위험합니다. 클라이언트 코드는 사용자가 바꿀 수 있으므로, 클라이언트가 수행한 검증이나 마스킹은 편의이지 보증이 아닙니다. 예를 들어 "브라우저에서 개인정보 영역을 가린 뒤 올린다"는 흐름은, 가리지 않은 파일을 올리는 것을 서버가 막지 못한다면 보안 통제가 아닙니다.
단발성 기능인데 모듈이 무겁다면
사용자가 평생 한 번 쓸까 말까 한 기능을 위해 모든 방문자에게 수 MB를 물리는 것은 명백한 손해입니다. 이 경우는 계산이 단순합니다. 기능 사용률 × 절감 비용이 전체 방문자 × 추가 전송량을 이기지 못하면 하지 않는 게 맞습니다. 지연 로딩으로 완화할 수 있지만, 그럴 거면 애초에 서버 왕복 한 번이 더 쌀 때가 많습니다.
먼저 만들어야 할 것이 남아 있다면
WASM과 Worker를 얹은 파이프라인은 유지보수 비용이 있습니다. 모듈 업데이트, 브라우저별 회귀, 재현 안 되는 버그 대응이 계속 따라옵니다. 서버에서 이미지 리사이즈 하나 돌리는 것으로 충분한 단계에서 이 구조를 도입하면, 얻는 것보다 잃는 시간이 큽니다. 이 구조는 프라이버시가 제품의 요구사항이 되었을 때, 또는 처리량이 서버 비용 구조를 위협할 때 꺼내는 카드입니다.
정리
브라우저에서 파일을 처리한다는 것은 계산 비용과 신뢰 비용을 맞바꾸는 결정입니다. 서버가 지던 CPU·메모리·저장소·규제 대응 부담을 사용자 기기로 옮기고, 대신 "우리 파일을 남에게 보내지 않는다"는 구조적 보장을 얻습니다.
그 교환이 남는 장사가 되는 조건은 비교적 분명합니다. 파일이 민감하고, 결과는 사용자가 받아서 끝나며, 작업 단위가 작고, 실패해도 다른 길이 있는 경우입니다. 이 조건에서 벗어날수록 클라이언트 처리는 이득 없는 복잡도가 됩니다.
그리고 실무에서 가장 많이 쓰이는 답은 양자택일이 아니라 경계를 나눈 하이브리드입니다. 기본은 로컬에서 처리하되, 기기가 감당하지 못하거나 결정성이 필요한 작업은 사용자에게 명시적으로 알리고 서버로 넘깁니다. 이 경계를 나중에 급하게 그으려 하면 코드 곳곳에 조건문이 흩어집니다. 처음부터 "이건 어디서 처리하는가"를 하나의 함수로 격리해 두는 것, 그게 이 구조에서 가장 중요한 설계 결정입니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.