브라우저가 감당해야 할 무게: WASM 메모리 천장, 상태 폭발, 타입 구멍의 현실

브라우저가 감당해야 할 무게: WASM 메모리 천장, 상태 폭발, 타입 구멍의 현실

FFmpeg.wasm의 500MB 메모리 벽, boolean 5개가 만드는 32가지 버그, as any 247개의 타입 부채 — 클라이언트 사이드에서 벌어지는 세 가지 전쟁을 해부합니다.

WebAssembly WASM 브라우저 한계 FSM 상태관리 MobX runInAction TypeScript 타입 안전성 클라이언트 사이드 아키텍처 FFmpeg.wasm 하이브리드 아키텍처
광고

클라이언트 사이드라는 전장

프론트엔드 개발자로서 솔직히 말하면, 요즘 브라우저에게 너무 많은 걸 시키고 있습니다. 파일 변환을 서버 없이 돌리겠다고 WASM 바이너리를 20MB씩 올리고, 상태 하나 바꾸려고 useState 다섯 개를 선언하고, 타입 안전성은 as any로 때우면서요. 이 세 가지가 별개의 문제처럼 보이지만, 사용자 입장에서는 전부 같은 증상으로 나타납니다 — 탭이 멈추고, UI가 깨지고, 예측 불가능한 동작이 튀어나오는 것.

1. WASM 파일 변환: 150MB 꿈과 500MB 천장 사이

dev.to에 올라온 한 오디오 프로듀서의 실험기가 이 현실을 정확히 보여줍니다. LibreOffice를 통째로 WASM으로 컴파일하면 브라우저에서 문서 변환이 가능할 거라는 꿈 — 결과는 최소 바이너리 150MB, 초기화만 10~15초, 메모리 점유 200~300MB. Safari의 메모리 한계가 약 500MB인 걸 생각하면, DOCX 하나 PDF로 바꾸다 탭이 죽는 겁니다.

실제로 작동하는 건 훨씬 제한적이었습니다. FFmpeg.wasm은 약 20MB 바이너리로 오디오·비디오 변환이 가능하지만, 네이티브 대비 성능은 10~20% 수준입니다. MP3 변환에 네이티브는 1초, 브라우저는 10초. 사용자 입장에서는 "왜 이렇게 느리지?"라는 생각이 드는 그 지점이에요. 그나마 컨테이너 리먹스(container remux) — 코덱이 호환되는 MP4↔MOV 같은 경우 재인코딩 없이 래퍼만 바꾸는 트릭으로 100배 속도 향상을 얻었다는 점은 인상적이었지만, 이건 특수한 케이스입니다.

더 현실적인 문제는 크로스 브라우저 호환성입니다. Chrome에서는 SharedArrayBuffer로 멀티스레딩이 가능한데, Safari에서는 COOP/COEP 헤더를 요구하면서 CDN 배포가 깨집니다. 결국 Safari에서는 싱글 스레드 폴백으로 돌아가야 하고, 이건 성능 저하를 그대로 사용자에게 전가하는 셈이에요. Figma에서 볼 때는 "파일 변환 중..." 프로그레스 바 하나면 될 것 같았는데, 실제로 구현하면 브라우저별 분기 처리, 메모리 모니터링, Web Worker 초기화 딜레이 100ms 확보까지 — 보이지 않는 코드가 산더미입니다.

결국 이 실험의 결론은 하이브리드 아키텍처였습니다. 이미지·오디오·간단한 비디오(50MB 미만)는 브라우저에서, Office 문서·복잡한 PDF·대용량 파일은 서버로. 전체 변환의 약 90%를 클라이언트에서 처리할 수 있다는 수치는 매력적이지만, 나머지 10%를 위한 서버 인프라를 결국 유지해야 한다는 건 "순수 클라이언트 사이드"라는 꿈이 아직은 미완성이라는 뜻입니다.

2. 상태 폭발: boolean 5개의 수학, async의 배신

클라이언트에 무거운 로직을 올리면 상태 관리 복잡도가 기하급수적으로 올라갑니다. dev.to의 FSM 관련 글이 정확히 지적하는 부분인데, useState 5개를 선언하면 이론적으로 2⁵ = 32가지 상태 조합이 생깁니다. isLoadingisError가 동시에 true인 상태, isEditingisSaving이 동시에 true인 상태 — 코드에서는 가능하지만 UI에서는 말이 안 되는 조합들이 버그의 온상이 됩니다.

FSM(Finite State Machine)으로 전환하면 이 문제가 구조적으로 해결됩니다. idle → loading → editing → saving → error 같이 명시적으로 5개 상태와 9개 전이를 정의하면, 불가능한 조합 자체가 존재하지 않습니다. TypeScript의 discriminated union으로 type FormState = { status: 'editing' } | { status: 'submitting' } | { status: 'error'; message: string }처럼 선언하면 XState 같은 라이브러리 없이도 컴파일 타임에 잘못된 상태 접근을 잡아줍니다.

여기에 비동기가 끼면 문제가 한 층 더 깊어집니다. velog의 MobX 분석 글이 잘 짚어주는데, async/await에서 await 이후의 코드는 microtask로 밀려나면서 MobX의 action 트랜잭션 경계 바깥으로 빠져나갑니다. enforceActions: 'always' 설정에서는 이게 에러로 터지고, runInAction으로 새 트랜잭션 경계를 명시적으로 감싸야 합니다. WASM 파일 변환처럼 비동기 작업이 긴 경우, 로딩·데이터·에러 상태를 각각 바꿀 때마다 불필요한 리렌더가 발생할 수 있는데, runInAction 안에서 한 번에 묶으면 트랜잭션 종료 시점에 딱 한 번만 reaction이 실행됩니다. 이건 WASM 초기화 중 UI가 멈추는 문제와 직결되는 최적화 포인트예요.

3. 타입 구멍: as any 247개가 말하는 것

상태가 복잡해지고 WASM 인터페이스가 늘어나면, 타입 시스템에 구멍이 뚫리기 시작합니다. dev.to의 TypeScript 패턴 글에서 소개된 사례 — 코드베이스에 as any247개 있었고, 10가지 패턴을 적용한 뒤 3개로 줄였다는 이야기는 과장이 아닙니다. 남은 3개는 진짜로 타입이 없는 서드파티 라이브러리 인터페이스였고요.

특히 클라이언트 사이드 아키텍처에서 치명적인 패턴 몇 가지가 있습니다. Branded TypesUserIdOrderId가 둘 다 string일 때 컴파일 타임에 혼용을 잡아주고, Zod 스키마 추론은 API 응답의 런타임 검증과 타입 정의를 단일 소스로 통합합니다. satisfies 연산자는 타입 검증을 하면서도 리터럴 타입을 보존해서, config.portnumber가 아니라 3000으로 추론되게 해줍니다. WASM 모듈의 반환값처럼 런타임에서야 형태가 결정되는 데이터를 다룰 때, 이런 패턴 없이 as any로 때우는 순간 타입 시스템이 제공하는 안전망이 통째로 무너집니다.

시사점: 복잡성은 사라지지 않고, 클라이언트로 이동했을 뿐

이 세 가지 전선을 종합하면 하나의 결론이 나옵니다. "서버에서 브라우저로" 로직을 옮기는 건 복잡성을 제거하는 게 아니라 이전하는 것입니다. WASM의 메모리 천장은 인프라 비용을 사용자 디바이스의 물리적 한계로 바꿨고, 비동기 상태 관리의 트랜잭션 경계 문제는 서버 사이드의 DB 트랜잭션만큼이나 까다로워졌고, 타입 안전성 없는 클라이언트 코드는 서버리스 이전의 타입 없는 PHP만큼 취약합니다.

사용자 입장에서는 "파일이 내 디바이스를 안 떠난다"는 프라이버시 메시지는 매력적입니다. 하지만 그 대가로 탭이 1GB 메모리를 먹고, Safari에서만 변환이 실패하고, 상태 버그로 프로그레스 바가 100%에서 멈춰 있다면 — 그건 좋은 UX가 아닙니다. Lighthouse 점수가 아무리 높아도, 500MB 비디오 변환 중 CLS가 튀고 INP가 10초를 넘기면 Core Web Vitals가 의미 없어지는 거예요.

2025년 클라이언트 사이드 아키텍처의 현실적 답은 결국 정직한 하이브리드입니다. 브라우저에서 할 수 있는 건 브라우저에서 하되, FSM으로 상태를 명시적으로 관리하고, 타입 시스템으로 인터페이스 경계를 봉인하고, 브라우저가 감당 못하는 10%는 서버로 솔직하게 위임하는 것. "순수 클라이언트 사이드"라는 이상에 집착해서 사용자 경험을 희생시키는 건, 사실 1px 어긋난 UI를 방치하는 것과 다르지 않습니다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요