I put a whole Laravel app inside php-wasm on a phone. Here is what broke.
Laravel 앱 전체를 php-wasm에 넣고 폰에서 돌려봤습니다 — 무엇이 깨졌는가
php-wasm으로 브라우저에서 코드 조각을 실행하는 데모는 흔하지만, NativeBlade는 vendor/를 포함한 Laravel + Livewire 앱 전체를 서버 없이 WebView 안에서 구동합니다. PHP가 JavaScript의 비동기 작업을 기다릴 수 없다는 근본 제약을 'sentinel 출력 후 exit, 캐시 기반 재실행'이라는 독특한 우회법으로 해결했으며, 이 과정에서 드러나는 결정적 재실행·요청 큐·null origin 등의 실전 문제들을 상세히 공유합니다.
- 주제
본문
php-wasm은 보통 브라우저 탭에서 PHP 스니펫을 실행하는 수준으로 시연되곤 합니다. 저자는 정반대를 시도했습니다. vendor/를 포함한 Laravel + Livewire 앱 전체를 서버 없이 기기 안의 WebView에서 구동하는 것이며, 이것이 NativeBlade가 하는 일입니다. 앱은 하나의 JSON 파일로 배포되는데, 새 Laravel 앱 기준 약 6,200개 파일, JSON으로 40MB, gzip 압축 시 7MB이고 그중 Livewire가 8MB를 차지합니다. 부팅 시 셸이 이를 php-wasm의 인메모리 파일시스템에 쓰고, 그 이후 앱은 CLI 스크립트와 같은 방식으로 '서버' 역할을 합니다. 페이지가 fetch('/livewire/update')를 호출하면 인터셉터가 요청을 셸로 전달하고, 셸은 $_SERVER를 JSON으로 /tmp에 쓴 뒤 이를 로드해 public/index.php를 require하는 부트스트랩으로 php.run()을 실행합니다. 출력이 곧 HTTP 응답이며, 요청 하나당 PHP 프로세스 하나가 처음부터 종료까지 실행됩니다. Livewire는 서버와 통신하지 않는다는 사실을 모른 채 그대로 동작합니다.
동기 PHP와 비동기 JavaScript의 충돌 — sentinel과 재실행이라는 우회법
wasm 샌드박스 안에는 소켓도, 네이티브 SQLite/MySQL 드라이버도, 실제 파일시스템도 없습니다. 이 모든 것은 JavaScript나 Tauri 브리지 뒤의 Rust에 있으며 전부 비동기입니다. 문제는 PHP가 JavaScript의 Promise를 기다릴 수 없다는 점입니다. php.run()은 시작해서 출력을 내고 종료하는 단일 동기 실행이므로, Http::get() 도중에 PHP를 멈추고 fetch를 했다가 재개할 방법이 없습니다. 그래서 '속임수'를 쓰는데 이것이 전체 구조의 핵심입니다. Http::get()이 커스텀 핸들러에 도달하면 요청 내용을 /tmp/__nb_http_pending.json에 쓰고 sentinel 문자열을 출력한 뒤 exit(0)합니다. 셸이 출력에서 sentinel을 발견하면 pending 파일을 읽어 실제 fetch()를 수행하고 응답을 /tmp/__nb_http_cache/<key>.json에 씁니다. 그런 다음 PHP 요청 전체를 처음부터 다시 실행합니다. 이번에는 Http::get()이 캐시에서 답을 찾아 그대로 반환하므로 PHP는 아무 일도 없었다는 듯 계속 진행합니다. 데이터베이스와 파일시스템도 __NB_DB_PENDING__, __NB_FS_PENDING__이라는 동일한 패턴으로 동작합니다.
논리적 요청 1회 = PHP 실행 N회, 그리고 결정적 재실행의 규칙
이 구조의 결과로 HTTP 호출 3개가 순차적으로 있는 페이지는 PHP가 4번 실행됩니다. 프레임워크는 이를 알고 요청당 HTTP 왕복 10회, 쿼리 20회, 파일시스템 작업 20회의 예산을 두고, 초과 시 배치하라는 경고와 함께 요청을 중단합니다. 독립적인 HTTP 호출을 모아 한 번의 왕복으로 처리하는 풀 모드도 이 때문입니다. 누구도 즐겁지 않은 결과는, 부수 효과를 일으킨 뒤 HTTP 호출을 하는 코드가 있으면 그 부수 효과가 재실행마다 반복된다는 점입니다. PHP가 결정적으로 재생되는 것은 입력—$_SERVER, body, 캐시—이 동일하기 때문이며, 브리지 호출 전의 비결정적 코드는 반드시 만나게 될 버그입니다. Eloquent는 같은 브리지 뒤에 있어 쿼리 결과가 캐시되고 재실행 시 SQLite를 다시 치지 않으므로 괜찮습니다. 결국 도달하는 멘탈 모델은 'PHP는 요청 더하기 캐시의 순수 함수'이며, 이를 존중하는 코드를 작성하게 됩니다.
실제로 깨진 것들 — 중단된 요청의 큐 정체와 null origin iframe
사용자가 mount()에서 API를 호출하는 화면을 열고 100ms 만에 다른 탭을 누르면 셸은 진행 중인 fetch를 중단합니다. 그러나 PHP 요청은 논리적으로 그 fetch를 계속 기다리는 상태이고, 임시 파일을 공유하므로 두 브리지 사이클이 한 php-wasm 인스턴스에서 인터리빙될 수 없어 이후 모든 요청이 뒤에 큐잉됩니다. 중단이 대기 중인 요청에 '아무것도 없이 끝났다'고 알려주지 않으면 큐가 영구히 멈춰 앱이 얼어붙는데, 실제로 이 버그를 출시했다가 이번 주에 수정했다고 합니다. 이제 중단은 버려진 요청을 aborted 결과로 resolve하고 네비게이션이 진행됩니다. 렌더링은 파일 서버가 없으므로 srcdoc으로 로드된 iframe 안에서 이뤄지는데, 이 iframe은 null origin이라 <link href="/css/app.css"> 같은 요청은 갈 곳이 없습니다. 그래서 스타일시트, Livewire의 JavaScript, Vite 출력, data URI 이미지까지 모든 로컬 에셋을 매 렌더마다 HTML에 인라인하며, 네비게이션마다 iframe 문서를 교체하고 Livewire와 Alpine을 다시 부팅합니다. 무겁게 들리지만 웜 상태의 릴리스 빌드에서는 충분히 빨라서, 첫 네비게이션이 이미 웜하도록 스플래시 뒤에서 랜딩 라우트를 미리 렌더링합니다.
네이티브 기능과 운영상의 함의
PHP가 카메라를 원하면 네이티브를 직접 호출하지 않고 {"nativeblade": true, "actions": [{"action": "camera", ...}]}를 반환합니다. 셸이 이를 JavaScript 핸들러에 디스패치하면 Tauri 플러그인을 거쳐 Kotlin이나 Swift까지 갔다가, 결과가 nb:camera라는 Livewire 이벤트로 돌아와 #[On('nb:camera')] 메서드가 다음 PHP 실행에서 동작합니다. '묻고, exit하고, 다음 실행에서 답을 받는다'는 브리지와 같은 규칙입니다. 또한 .env가 앱 안에 포함되므로 서버 비밀이란 존재하지 않고 API 키는 앱이 호출하는 백엔드에 있어야 하며, NativeBlade::log()는 stderr에 마커를 붙여 쓰면 셸이 골라내 전달하는 방식이라 tail할 로그 파일이 없습니다. mount()의 느린 작업은 그 페이지를 렌더링할 때마다 브리지 왕복을 의미하므로 첫 HTTP 호출이 어디서 일어나는지 신경 쓰게 되고, 브리지는 셸 안에만 존재하므로 테스트는 일반 PHP로 그대로 돌릴 수 있습니다. 저자는 이 모든 것이 불만이 아니라 모델을 작동하게 만드는 요소라고 말합니다. PHP는 PHP로, Laravel은 Laravel로 유지되고 이상함은 서버인 척하는 몇 개 파일 안에 담겨 있습니다. 코드와 문서는 github.com/NativeBlade와 docs.nativeblade.dev에 공개되어 있습니다.
원문: dev.to / 번역·요약: Trawling