본문으로 건너뛰기

© 2026 Molayo

Lobste.rs헤드라인2026. 05. 22. 10:42

OxCaml js_of_ocaml 번들 크기 축소: 285 MB에서 4 MB로

요약

OCaml 코드를 브라우저에서 실행하기 위한 js_of_ocaml 번들 크기를 285MB에서 4MB로 획기적으로 줄인 과정을 다룹니다. 의존성 문제를 해결하여 설치 없이 클라이언트 측에서 즉시 실행 가능한 대화형 학습 환경을 구축하는 것이 핵심입니다.

핵심 포인트

  • Jane Street 런타임의 전이적 의존성으로 인한 번들 크기 폭증 문제 해결
  • 285MB에서 4MB로 번들 크기를 축소하여 브라우저 배포 가능성 확보
  • 설치 과정 없는 클라이언트 측 대화형 학습 환경(Interactive Books) 구현
  • js_of_ocaml 및 x-ocaml 프로젝트를 통한 최적화 작업 수행

OxCaml js_of_ocaml 번들 크기 축소: 285 MB에서 4 MB로

2026년 5월 10일
이전의 capsules(캡슐)에 관한 포스트에서 저는 속임수를 썼습니다. 제가 각색하던 강의(병렬성을 위한 언어 추상화에 관한 저의 CS6868 코스에서 가져온 것)는 캡슐 뮤텍스(capsule mutex)를 획득하기 위한 권장되는 비권장(non-deprecated) 방식인 Await_capsule.Mutex.with_lock을 사용했습니다. 하지만 포스트에는 권장되지 않는다는 경고(deprecation alert)를 숨긴 채 Capsule_blocking_sync.Mutex를 대신 실었습니다. 그 이유는 번들 크기 때문이었습니다. await 라이브러리를 base, sexplib0, base_quickcheck 및 Jane Street의 나머지 런타임(runtime)을 통해 전이적 의존성(transitive dependencies)을 추적해 나가다 보면, 브라우저 내의 탑레벨(toplevel) 크기가 약 285 MB까지 불어났을 것입니다. 올바른 API를 사용했다면 GitHub의 파일당 100 MB 푸시(push) 제한조차 통과하지 못했을 것이며, 독자의 브라우저로 전송하는 것은 말할 것도 없이 불가능했을 것입니다.

이 포스트는 우리가 어떻게 285 MB에서 4 MB로 크기를 줄였는지, 그리고 결과물인 번들이 브라우저 내 탑레벨과 깔끔하게 결합되도록 만들어, 강의의 Await_capsule 형태가 이 포스트 하단의 셀(cell)에서 엔드 투 엔드(end-to-end)로 작동하게 만든 과정에 대한 이야기입니다. 대부분의 작업은 ocsigen/js_of_ocaml의 브랜치에서 이루어졌으며, 셀을 구동하는 WebComponent인 art-w/x-ocaml에서 작은 부분의 작업이 진행되었습니다.

번들 크기가 중요한 이유

저는 IIT Madras에서 OCaml 비중이 높은 두 개의 코스를 가르칩니다. 하나는 학부 과정인 CS3100 기능형 프로그래밍(functional programming) 코스이고, 다른 하나는 병렬성을 위한 언어 추상화에 관한 더 최근의 대학원 과정인 CS6868입니다. 두 코스 모두 강의 노트, 예제 및 숙제가 학생이 로컬 설치 없이 완전히 클라이언트 측(client-side)에서 읽고, 편집하고, 실행할 수 있는 대화형 도서(interactive books) 형태라면 훨씬 더 유용할 것입니다. 동일한 형태는 우리가 직접 해보는 OCaml 및 OxCaml 워크숍을 운영할 때도 도움이 될 것입니다. 워크숍의 첫 세션은 보통 설치 장벽(installation hump) 때문에 시간을 다 허비하곤 합니다. 즉, 본격적인 강의가 시작되기 전에 불안정한 컨퍼런스 WiFi 환경에서 모든 참석자의 기기에 opam, 컴파일러 및 필요한 라이브러리들을 작동하도록 설정하는 데 시간을 다 쓰는 것입니다.

설치를 고통스럽지 않게 만들려는 더 광범위한 노력은 OCaml Platform 로드맵이며, 저희 Tarides는 이를 "한 번의 클릭으로 zero에서 OCaml까지" 도달하는 경험으로 만들기 위해 작업해 왔습니다. 해당 로드맵은 완전한 에디터, 디버거, 프로젝트 관리 기능을 갖춘 실제 로컬 툴체인 (toolchain)을 원하며, 한 번의 설정 이후에는 넉넉한 지연 시간 (latency) 예산을 허용하는 개발자를 대상으로 합니다. 반면 워크숍 참가자의 목표는 훨씬 더 좁습니다. 그저 눈앞의 연습 문제를 완료하기에 충분한 OCaml만 있으면 됩니다. 클라이언트 측 x-ocaml toplevel은 모든 것이 정적 자산 (static assets)으로 제공되고 설치 단계가 없기 때문에 자연스럽게 그 목표에 부합합니다. 이러한 환경에서 번들 (bundle) 크기는 곧 지연 시간 예산입니다. 285 MB는 브라우저 내 경로를 배포 불가능하게 만들지만, 4 MB는 90분 세션 동안 로컬 툴체인을 대체할 수 있는 현실적인 대안이 됩니다.

왜 285 MB인가?

x-ocaml이 "실행 중인 브라우저 내 toplevel에 추가 라이브러리를 로드하기" 위해 이미 사용하고 있는 레시피는 다음과 같습니다. 배포하려는 각 cma에 대해 다음을 실행합니다.

$ js_of_ocaml --toplevel <library>.cma -o lib.js

그 다음 각 cma별 출력물을 하나의 번들로 연결(concatenate)하고 <script src-load=...>를 통해 로드합니다. 각 cma별 출력물은 kind=cma입니다. 이는 기존의 toplevel에 아무것도 덮어쓰지 않고 cma의 모듈을 등록하며, 모듈이 활성화되면 셀 (cell)에서 open할 수 있습니다. 이 방식은 작동하며, 이전 두 개의 OxCaml 포스트에서도 이미 사용 중인 방식입니다.

문제는 데드 코드 제거 (dead code elimination, DCE)가 한 번에 하나의 cma에 대해서만 실행된다는 점입니다. 만약 base를 배포한다면, base전체를 배포하게 됩니다. 왜냐하면 cma별 DCE 패스 (pass)가 링크된 프로그램을 전체적으로 살펴보며, 실제로 원하는 await 라이브러리가 sexplib0의 아주 작은 부분만 사용한다는 사실을 인지하지 못하기 때문입니다. 따라서 await + capsule + basement 클로저 (closure)를 위한 번들은 전이적 의존성 (transitive dependency)에 있는 모든 cma의 합집합이 되며, 모두 링크된 상태가 됩니다. 이는 OxCaml 컴파일러의 일반적인 최적화가 수행된 후, 그리고 JavaScript 측의 어떠한 영리한 기법이 적용되기 전 단계에서 대략 285 MB에 달하게 됩니다.

다시 말해, await

-기반의 blessed API는 번들링 도구가 고장 나서가 아니라, CMA(Compilation Unit) 단위의 DCE (Dead Code Elimination, 데드 코드 제거)가 이 문제에 있어 적절한 입도(granularity)가 아니기 때문에 배포할 수 없는 것입니다.

또 다른 방법, 그리고 그것이 결합되지 않는 이유

알고 보니 js_of_ocaml에는 이미 교차 CMA DCE (cross-cma DCE)를 수행하는 두 번째 방법이 있었습니다. 이는 Ricky Vetter가 X(구 트위터)에서 --export 경로를 알려주었을 때 알게 되었습니다. 이 방법은 두 단계로 이루어집니다:

  • -linkall을 사용하여 원하는 모든 라이브러리를 연결하는 단일 바이트코드(bytecode)를 빌드합니다. 이렇게 하면 바이트코드 수준에서 아무것도 제거되지 않습니다.
  • 이 단일 바이트코드를 js_of_ocaml --toplevel --export units.txt에 전달합니다. export 목록은 toplevel에 계속 노출되어야 하는 컴파일 단위(compilation units)의 이름을 지정하며, 그 외의 모든 것은 통합된 중간 표현(unified intermediate representation) 상에서 DCE의 대상이 됩니다.

동일한 await + capsule + basement 세트에 대해, 이 방법은 per-cma 결합 방식보다 거의 두 자릿수(orders of magnitude)나 작은 4 MB 크기의 번들을 생성합니다. 링크 단계의 DCE는 전체 링크된 프로그램에 대해 함수 단위(function granularity)로 작동하므로, sexplib, base, base_quickcheck 및 나머지 의존성 폐쇄(dependency closure)의 사용되지 않는 부분들이 깔끔하게 제거됩니다.

그렇다면 왜 우리는 이미 이 방법을 사용하고 있지 않을까요?

그 결과물로 나오는 아티팩트(artifact)가 kind=exe이기 때문입니다. 즉, 라이브러리가 아니라 독립 실행형 실행 파일(self-contained executable)이라는 뜻입니다. 이러한 번들을 이미 실행 중인 toplevel이 있는 Web Worker에 로드하면, 초기화가 caml_main 스타일로 실행되며 호스트의 전역 변수(globals)를 덮어쓰는 것으로 시작합니다:

caml_global_data.symbols = <bundle's symbol table>
caml_global_data.sections = <bundle's bytecode sections>
caml_global_data.prim_count = <bundle's primitive count>
...

이 네 가지 할당은 호스트 toplevel의 테이블을 덮어씁니다(overwrite). 번들이 로드된 후, caml_get_global_data().symbols는 워커의 것이 아니라 번들의 심볼이 되며, 이름 기반 심볼 해석(name-based symbol resolution)을 수행하는 호스트 toplevel의 모든 것(Toploop, hover-types 조회, open Stdlib 등)

)은 이제 워커(worker)가 이미 링크했던 모듈들에 대해 알지 못하는 테이블을 참조하게 됩니다. toplevel은 살아남지만, 그 타이핑 환경(typing environment)이 잘못되어 셀(cells)이 더 이상 무엇인가를 open 할 수 없게 됩니다. 우리는 캡슐(capsules) 포스트에서 이 막다른 길에 부딪혔습니다. 번들은 올바르고 크기도 훌륭했지만, exe 형태의 번들을 기존 toplevel에 연결하는 통합 단계가 빠져 있었습니다.

패치 (The patch)

해결책은 제가 ocsigen/js_of_ocaml의 한 브랜치에 추가한 --toplevel-extend 플래그입니다. 이 플래그가 설정되면, js_of_ocaml --toplevel --export …는 이전과 정확히 동일한 DCE(Dead Code Elimination) 출력을 생성하며, 등록된 모듈에 대한 바이트와 /static/cmis/ 아래에 임베드된 동일한 .cmi 파일들을 유지하지만, 세 가지 작은 변경 사항이 적용됩니다:

  • ~standalone:false로 패킹되어, 출력물을 감싸는 globalThis 폴리필(polyfill) IIFE가 없습니다.
  • 위에서 언급한 caml_global_data에 대한 네 개의 caml_js_set 쓰기 작업이 생략됩니다.
  • buildInfo 헤더에 kind=cma로 태그가 지정됩니다.

번들의 모듈들은 여전히 로드 시 일반적인 caml_register_global(n, v, name)을 통해 스스로를 등록하며, 런타임(runtime)은 symidx를 통해 이를 호스트의 기존 테이블로 올바르게 병합합니다. 그 결과는 *파괴적이지 않고 가산적(additive, not destructive)*입니다. 즉, 호스트 toplevel의 심볼 테이블(symbol table), 섹션(sections), 프리미티브(primitives) 및 에일리어스(aliases)가 모두 온전하게 유지되며, 타이핑 환경은 이미 링크된 Stdlib, Toploop 및 그 외 모든 것들을 계속해서 해석(resolve)할 수 있습니다. 번들로부터 온 새로운 모듈들은 단순히 그 위에 새로운 심볼로 나타날 뿐입니다.

초기 차이점(diff)은 작습니다. parse_bytecode.ml에서 새로운 플래그에 따라 caml_js_set 블록을 제어하고, cmd_arg.ml/compile.ml을 통해 이를 전달합니다. 이를 통해 번들을 조합 가능(composable)하게 만들 수 있습니다. 실제 디버깅은 그 이후에 이루어졌습니다. 조합된 번들이 호스트가 기대하는 방식으로 동작하도록 만들려고 시도할 때 발생했으며, 이것이 이 포스트의 나머지 내용입니다.

x-ocaml을 통한 연결 (Wiring it through x-ocaml)

x-ocaml 측면에서는, --dce 플래그가 단일 바이트코드(single-bytecode) + --export를 구동합니다.

우리가 배포하는 번들(bundles)을 위해 js_of_ocaml --toplevel-extend --export units.txt를 호출하여 빌드합니다. 제 x-ocaml 포크(fork)의 oxcaml 브랜치에 해당 변경 사항이 반영되어 있습니다. 빌드가 실행될 때 패치된 js_of_ocamlPATH에 있어야 합니다.

수치 (Numbers)

강의의 gensym 예제에서 사용하는 라이브러리들의 전체 클로저(closure)에 대해, 두 경로는 매우 다르게 나타납니다:

| Per-cma | --dce --toplevel-extend |
|---|---|---|

Per-cma--dce --toplevel-extend비율
basement + capsule0만 포함1.0 MB1.0 MB
+ capsule + await + portable 포함285 MB4.0 MB

basement + capsule0 행은 사실상 차이가 거의 없는데, 그 규모에서의 번들 크기는 .cmi 파일들이 지배적이며 DCE (Dead Code Elimination, 데드 코드 제거)가 처리할 자바스크립트(JavaScript) 코드가 거의 없기 때문입니다. 일단 await와 엄선된 capsule API가 포함되면 per-cma 경로는 급격히 팽창합니다. 왜냐하면 base, sexplib0, bin_prot, base_quickcheckppx_* 런타임(runtime) 라이브러리의 이행 클로저(transitive closure)에 있는 모든 cma를 포함해야 하기 때문입니다. 반면 --dce는 내보내기 목록(export list)에서 실제로 도달 가능한 함수들과, 사용자가 셀(cell)에 입력하는 시그니처(signatures)를 최상위 수준(toplevel)에서 상세화하는 데 필요한 .cmi 파일들만 유지합니다.

몇 가지 추가적인 문제들 (A few more snags)

번들을 kind=cma로 만드는 것은 쉬운 부분이었습니다. 이를 이미 실행 중인 최상위 수준(toplevel)과 결합하는 과정에서 일련의 후속 문제들이 나타났으며, 각 문제는 원인을 파악한 후 짧은 수정 과정을 거쳤습니다. 이 모든 수정 사항은 동일한 kc-toplevel-extend 브랜치에 있으며, 커밋 메시지에 자세한 내용이 기록되어 있습니다.

  • 사전 정의된 예외(Predefined-exception)의 정체성이 번들 간에 어긋남. 번들 내에서 재할당된 Not_found, Sys_error 등은 호스트(host)의 복사본과 물리적으로 구별됩니다. 따라서 stdlib 코드 내의 try ... with Not_found -> (이 문제를 처음 발견한 곳은 OCAMLRUNPARAM을 읽는 Hashtbl.randomized_default였습니다)는 런타임에 의해 발생한 호스트의 Not_found를 포착하지 못합니다. 해결책은 번들 내의 각 사전 정의된 예외(predef-exn) 변수를 런타임의 caml_get_global_data에 바인딩하는 것입니다.

조회하여 번들이 호스트의 인스턴스를 재사용하도록 합니다. -
의사 파일 시스템(pseudo-filesystem)은 중복 등록 시 예외를 발생시킵니다. 번들이 .cmi 파일을 다시 방출하려고 시도할 때, 호스트가 이미 부팅 시점에 /static/cmis/stdlib.cmi를 등록했다면 MlFakeDevice.register는 덮어쓰기를 거부합니다. register를 멱등(idempotent)하게 만들면 아무것도 잃지 않고 충돌을 제거할 수 있습니다. 두 개의 stdlib.cmi 복사본은 동일한 opam 스위치에서 왔으므로 서로 일치하기 때문입니다. -
표준 라이브러리(Stdlib) 재등록이 호스트 모듈을 덮어씁니다. 보호 장치가 없으면 caml_register_global은 호스트의 caml_global_data["Format"](및 번들의 바이트코드가 링크하는 다른 모든 표준 라이브러리 모듈)을 번들의 새로 초기화된 복사본으로 아무렇지 않게 교체해 버립니다. 이름이 이미 알려져 있을 때 조기 반환(early return)을 추가하면, 호스트가 아직 가지고 있지 않은 이름에 대한 동작은 변경하지 않으면서 이 문제를 해결할 수 있습니다. -
번들은 로드될 때 표준 라이브러리의 모듈 초기화를 다시 실행하며, 이 과정에서 Domain.DLS 슬롯 충돌이 호버 타입(hover types)을 조용히 망가뜨렸습니다. Stdlib__Domain.DLSlet key_counter = Atomic.make 0은 카운터를 재할당하고 0부터 다시 시작합니다. 그러면 번들의 Format.stdbuf_key는 호스트가 이미 자신의 Format.stdbuf_key에 할당했던 낮은 DLS 인덱스를 차지하게 되고, DLS.set은 공유된 caml_domain_dls 배열 내의 호스트 엔트리를 덮어씁니다. 결과적으로 호스트의 Format.flush_str_formatter는 번들의 빈 버퍼를 읽게 되고, Merlin의 타입 포괄 프린터(Format.str_formatter를 통해 플러시함)는 모든 쿼리에 대해 ""를 반환하며, 식별자 위 호버 툴팁이 빈 칸으로 나타납니다. 해결책은 build_portable_js_extend.sh의 번들 로드 경계에 있습니다. 번들의 IIFE가 실행되기 전에 caml_domain_dls_get ()을 스냅샷하고, 이후에 호스트 소유의 슬롯을 복구하는 것입니다. 번들의 새로운 높은 인덱스 슬롯은 그대로 두고, 이전에 채워져 있던 호스트의 슬롯만 복구합니다. OCaml 5+의 Domain.DLS 초기화 경로를 추적하기 전까지는 한동안 이것이 Format DCE 버그라고 확신하며 시간을 허비했습니다. -
번들 빌드: 큐레이션된 capsuleawait.capsule API는 모두 open! Base

파일 상단에 open! Base를 사용하므로, 해당 인터페이스들은 Stdlib.unit 대신 Base.unit을 언급합니다. 이러한 시그니처 (signatures)를 상세히 설명하기 위해, 호스트 최상위 레벨 (toplevel)은 /static/cmis/ 경로에 있는 basesexplib0 .cmi 파일들의 작은 체인이 필요합니다. 우리는 js_of_ocaml--file=<src>:/static/cmis/ 플래그를 통해 세 개의 base .cmi 파일 (base.cmi, base__.cmi, base__Unit.cmi)과 두 개의 sexplib0 .cmi 파일을 전달합니다. 이 플래그는 base를 바이트코드 링크 라인 (bytecode-link line)에 추가하지 않고 (그렇게 하면 base 전체가 다시 끌려 들어오게 됩니다) 파일을 직접 임베드 (embed)합니다.

The cell

아래의 셀 (cell)은 강의의 gensym_capsule.ml 형태를 직접 사용합니다: Await_capsule.Mutex.with_lockAwait_kernel.Await.t 증거 (witness)를 인자로 받으며, Capsule_expert.Data.createCapsule_expert.Data.unwrap을 사용합니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0