서론
WebAssembly(Wasm) 바이너리를 작게 만들기 위해 최적화를 적용했더니, 오히려 gzip 압축 후의 크기가 증가하는 문제가 발생했습니다.
이는 제가 개발 중인 OSS 프로젝트 SIGIL에서 실제로 겪은 일입니다.
SIGIL은 로컬 AI 실행 환경을 보안 관점에서 조사하기 위한 OSS 도구입니다. 예를 들어, Ollama으로 관리되는 AI 모델의 무결성이나 라이선스, 런타임을 구성하는 실행 파일, 바이너리에 포함된 기능 호출 흔적, 네트워크 대기 상태 등을 조사하고 증거와 함께 보고서를 생성할 수 있습니다. 특히 조사 대상 파일을 실행하지 않고 읽기 전용의 정적 분석을 중심으로 한다는 점이 특징입니다.
또한, SIGIL에는 조사 결과를 브라우저에서 확인할 수 있는 Viewer도 존재합니다. 이 Viewer에서는 CLI(Command Line Interface)와 동일한 Rust 기반의 검증 및 보고서 생성 처리를 WebAssembly로 구동하고 있습니다.
이번에 최적화한 것은 바로 이 Viewer에서 사용되는 Wasm입니다.
최종적으로 141가지 설정을 시도하여, gzip 압축 후 크기를 494,725바이트에서 256,433바이트로 48.17% 절감하는 데 성공했습니다.
다만 이번에 흥미로웠던 점은 단순히 절감률만이 아니었습니다.
- 압축되지 않은 Wasm이 작아져도 gzip 후에는 작아지지 않을 수 있다.
- 컴파일러 설정뿐 아니라 표준 라이브러리의 재빌드나 함수 배치 역시 결과에 영향을 미친다.
- 최적화된 이후의 동작 호환성과 빌드의 재현성은 별도로 검증할 필요가 있다.
이번 글에서는 PR #110에서 실제로 진행한 최적화 과정과 그 실험 결과를 정리했습니다.
500 KiB 제한까지, 이제 17 KB밖에 남지 않았다
SIGIL은 로컬 AI 환경을 조사하기 위한 Rust 기반의 CLI입니다. Ollama 모델 스토어나 실행 파일을 조사하여 모델의 무결성, 라이선스, 런타임 구성, 네이티브 코드 내 기능 호출 등을 증거와 함께 기록합니다.
또한, 조사 결과를 브라우저에서 표시할 수 있는 Viewer도 제공합니다.
Viewer는 Rust 측의 모델 검증 및 보고서 생성 처리를 Wasm으로 컴파일하여 재사용하고 있습니다. 세션이나 AI-BOM JSON을 읽어 검증한 후 Markdown 또는 HTML로 변환합니다.
별도의 렌더러를 브라우저에 구현하는 대신, CLI와 동일한 Rust 처리를 이용하도록 설계되었습니다.
따라서 모델이나 검증 처리 기능이 추가됨에 따라 Wasm의 크기도 커지게 됩니다.
2026년 10월 27일 조사 당시, Wasm의 크기는 다음과 같은 상태였습니다.
| 지표 | 크기 |
|---|
| 압축되지 않은 Wasm | 2,124,516 bytes |
| ... | |
CI(Continuous Integration) 상한선은 500 KiB (512,000바이트)입니다. 이미 예산의 96.6%를 사용하고 있었습니다.
실제로 직전 PR #106 이전에는 481,112바이트였기 때문에, 이 정도 수준의 증가가 다시 발생하면 상한선을 초과할 가능성이 있었습니다.
여기서 상한선을 높이는 것은 쉽습니다. 하지만 그렇게 되면 향후 바이너리 비대화를 감지하는 메커니즘이 약화됩니다.
그래서 Issue #107에서는 기능 축소나 번들 분할 없이 Wasm 자체를 작게 만드는 방침을 세웠습니다.
중요한 것은 기존의 검증 처리나 렌더링 결과가 변하지 않아야 하며, 재빌드해도 동일한 바이너리를 얻을 수 있어야 한다는 점입니다.
wasm-opt -Oz를 적용했더니 오히려 커졌다
처음에 주목한 것은 WebAssembly 최적화 도구인 Binaryen의 wasm-opt였습니다.
일반적으로 -Oz는 코드 크기를 줄이기 위한 최적화 옵션입니다.
그런데 원본 Wasm에 wasm-opt -Oz를 적용하자 다음과 같은 결과가 나왔습니다.
| 원본 Wasm | wasm-opt -Oz | |
|---|
| 압축되지 않은 | 2,124,516 | 1,609,552 |
| gzip 적용 후 | 494,725 | 502,185 |
압축되지 않은 바이너리는 약 24% 작아졌습니다.
하지만, gzip 압축 후의 크기는 7,460바이트 증가했습니다.
왜일까요?
이는 Wasm 자체의 크기와 gzip에 의한 압축 효율이 서로 다른 지표이기 때문입니다.
gzip은 반복적으로 나타나는 바이트열이나 그 배열에 영향을 받습니다. Wasm 명령어 수를 줄이거나 코드를 재작성하더라도, 압축하기 쉬운 구조가 사라지면 gzip 후의 크기가 증가할 수 있습니다.
이번 실험은 그러한 트레이드오프가 실제로 존재한다는 것을 보여줍니다. 다만, 이 수치만으로 증가의 원인을 특정할 수는 없습니다.
같은 현상은 Rust 컴파일 설정에서도 발생했습니다.
예를 들어 codegen-units=1만 설정했을 경우, 비압축 크기는 1,736,635바이트까지 줄어들었지만, gzip을 거치자 507,386바이트로 증가했습니다.
즉, 이번 목적에 있어서는 비압축 크기(uncompressed size)를 최소화하는 것만으로는 불충분했습니다.
최종적으로 사용되는 gzip 후 바이트 수를 측정하고, 그 값을 최적화 판단 기준으로 삼을 필요가 있었습니다.
측정에는 CI와 동일한 명령어를 사용했습니다.
gzip -c site/viewer/pkg/sigil_wasm_bg.wasm | wc -c
압축 레벨이나 헤더를 포함하는 측정 조건을 통일하여, 설정별 결과를 비교하고 있습니다.
Rust 컴파일 설정을 통한 최적화
우선 Rust 측의 빌드 설정을 비교했습니다.
대표적인 결과는 다음과 같습니다.
| 빌드 설정 | gzip 크기 |
|---|
| 기본 설정 | 494,725 |
opt-level=z만 적용 | 414,124 |
opt-level=s만 적용 | 439,319 |
| fat LTO만 | 494,817 |
codegen-units=1만 적용 | 507,386 |
panic=abort만 적용 | 494,756 |
z + fat LTO + 1 codegen unit + abort | 395,246 |
위 항목 + wasm-opt -Oz | 344,838 |
가장 효과적이었던 것은 opt-level=z였습니다.
반면, LTO나 codegen unit, panic 설정은 단독으로는 반드시 개선되지 않았습니다. 이들을 조합하고, 나아가 Wasm 측의 최적화까지 수행함으로써 크게 줄일 수 있었습니다.
다만 344 KB에서도 당초 목표 대비로는 아직 개선할 여지가 있습니다.
표준 라이브러리 재빌드
여기서 더 최적화하기 위해 Rust의 nightly toolchain을 사용했습니다.
선택한 버전은 nightly-2026-01-20입니다.
일반적인 Wasm 빌드에서는 Rust의 표준 라이브러리가 사전에 컴파일되어 있습니다. 하지만 애플리케이션 측만 최적화한다고 해서, 표준 라이브러리의 코드에 동일한 설정이 적용된다는 보장이 없습니다.
그래서 -Zbuild-std=std,panic_abort를 사용하여 표준 라이브러리도 재빌드했습니다.
최종적으로 채택된 설정은 다음과 같습니다.
export CARGO_PROFILE_RELEASE_OPT_LEVEL=z
export CARGO_PROFILE_RELEASE_LTO=fat
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1
...
여기에 더해 RUSTFLAGS에는 -Zlocation-detail=none을 지정하여, panic 발생 시의 소스 위치 정보도 생략했습니다.
여기서 일반적인 panic=abort와 panic=immediate-abort는 구별할 필요가 있습니다.
panic=abort는 panic 발생 시 unwinding(언와인딩)을 수행하지 않는 설정이지만, panic과 관련된 처리나 진단 코드가 모두 사라지는 것은 아닙니다.
반면, immediate-abort는 예상치 못한 Rust의 panic이 발생했을 때 즉시 abort하여, panic 메시지 포매팅이나 훅 호출에 필요한 코드를 생략합니다.
물론 트레이드오프가 있습니다. 내부 panic에 대한 상세한 진단 정보는 손실됩니다. 반면, Result로 반환되는 일반적인 입력 검증 에러는 유지됩니다. 경계 체크(boundary check)나 trap 자체를 무효화하는 것은 아닙니다.
이번 Viewer에서는 이 트레이드오프를 허용하는 설계로 했습니다.
참고로, 이러한 설정들은 Viewer 전용의 빌드 스크립트에서 적용되었습니다. 네이티브 CLI의 일반적인 빌드 프로파일은 변경하지 않았습니다.
Binaryen에서는 최적화 경로 순서까지 조정한다
Rust 측 설정을 조정한 후, 다음으로 Binaryen을 이용한 Wasm 최적화를 시도했습니다.
여기서 특히 중요했던 것은 함수의 배열 순서와 인라이닝(inlining)이었습니다.
최종적으로 채택된 명령어는 다음과 같습니다.
각각의 역할을 간단히 설명하겠습니다.
-Oz
Wasm의 크기 축소(size reduction)를 우선하는 최적화(optimization)를 실행합니다. 이번에는 한 번뿐만 아니라, 제어 흐름 변환(control flow transformation) 전후에 실행했습니다.
--one-caller-inline-max-function-size=25
호출원(caller)이 1곳밖에 없는 함수의 인라인화(inlining)에 대해 크기 상한선을 설정합니다.
인라인화는 함수 호출을 줄일 수 있지만, gzip 크기 측면에서는 무제한으로 수행해도 된다는 의미가 아닙니다.
--flatten 및 --rereloop
제어 흐름의 구조를 변환하고 재구성하는 패스(pass)입니다.
다른 최적화 패스와 조합했을 때 효과가 달라지기 때문에, 순서까지 포함하여 실험했습니다.
--reorder-functions-by-name
함수를 이름(name)을 기반으로 정렬합니다.
함수 배치에 따라 gzip 압축 효율이 달라질 수 있기 때문에, 이번 최적화에서는 중요한 조정 대상이었습니다.
또한, 함수 이름을 이용해 재배치한 후에는 불필요한 디버그 정보나 사용자 지정 메타데이터를 삭제했습니다.
원본 크기가 작다는 이유만으로 선택하지 않은 설정
흥미로운 점은 최종 단계에서 인라인화 임계값(threshold)을 비교한 결과입니다.
| 설정 | 비압축 | gzip 후 |
|---|
| 임계값 25 | 1,103,475 | 256,546 |
| ... | | |
| 임계값 30 쪽이 비압축 크기는 더 작습니다. | | |
하지만, gzip 후에는 임계값 25 쪽이 더 작았습니다.
게다가, 최적화 초반에 함수를 재배치하는 것만으로도 gzip 후 크기가 113바이트 감소했습니다.
차이는 작지만, 최적화 패스의 순서가 실제로 측정 결과에 영향을 미친다는 것을 알 수 있습니다.
이 결과를 바탕으로 최종 설정으로는 임계값 25와 전후 2회의 함수 재배치를 채택했습니다.
중요한 것은 이 설정이 어떤 Wasm에도 최적이라는 것이 아니라는 점입니다. 이번 코드와 툴체인(toolchain)에서 실제로 시도해 본 설정 중에서 선택된 것입니다.
141가지 설정을 재현성과 동작 양쪽 모두로 검증하기
이번 개선에서는 총 141가지의 설정 조합을 측정했습니다.
실험 결과는 모두 ci/viewer-wasm-measurements.csv에 저장되어 있습니다.
CSV에는 다음 정보가 기록되어 있습니다:
- 최적화 설정과 패스의 조합
- 비압축 Wasm 크기
- gzip 후 크기
- 독립적인 클린 빌드 일치 결과
- 동작 체크 결과
실험에서는 Rust의 각 설정에 대해 빈 Cargo target 디렉토리에서 2회 빌드를 수행했습니다. Binaryen을 이용한 설정도 각각 독립적으로 생성된 입력에 적용했습니다.
그리고 생성된 패키지 전체가 바이트 단위로 일치하는지 확인했습니다.
141가지 모두에서 일치 검증과 동작 체크가 성공했습니다.
크기가 작아져도 처리 방식이 바뀌어서는 안 된다
이번 Wasm은 보안 조사 결과를 표시하기 위한 것입니다. 입력된 JSON의 검증에 실패했을 때, 실수로 정상적인 리포트로 보여주는 변경은 허용할 수 없습니다.
따라서, 크기 비교뿐만 아니라 기존 테스트 외에도 구(舊) 바이너리와 신(新) 바이너리의 차이점 테스트도 구현했습니다.
추가된 것은 ci/compare-viewer-wasm.mjs입니다.
이 스크립트에서는 변경 전과 변경 후의 Wasm을 각각 로드하여 동일한 입력을 주어 결과를 비교합니다.
비교 대상에는 정상적인 세션이나 AI-BOM뿐만 아니라, 필드의 누락(field missing), null 또는 배열로의 대체, 불완전한 JSON, 기본형 값(primitive value) 등도 포함됩니다.
또한, 공개 API의 export 이름이나 버전 정보가 변경되지 않았는지도 검증합니다.
에러를 반환하는 경우에는 그 메시지까지 비교합니다.
최종적으로 5,771건의 입력 문서에 대해 구 Wasm과 신 Wasm의 결과가 일치했습니다.
Markdown에서 HTML로의 변환에 대해서도, <script>를 포함하는 문자열, HTML 특수 문자, 일본어 등을 사용하여 출력을 비교했습니다.
게다가 다음 검증도 실시했습니다:
| 검증 | 결과 |
|---|
| 정상적인 피クス처(fixture) | 41건 성공 |
| ... | |
| 구 바이너리와 최종 바이너리의 차이점 비교 후, Wasm 메모리 크기 역시 둘 다 1,441,792바이트였습니다. | |
물론, 이것이 모든 입력에 대한 의미론적 동등성이 수학적으로 증명된 것은 아닙니다. 하지만 컴파일러나 최적화기를 변경할 때의 회귀 검증(regression verification)으로서 상당히 넓은 범위까지 확인할 수 있었습니다.
작게 만드는 것뿐만 아니라 동일한 바이트열을 재생성 가능하게 만들기
또 하나 중요했던 점은 재현 가능한 빌드(Reproducible Build)였습니다.
SIGIL의 Viewer에서는 생성된 Wasm 패키지를 site/viewer/pkg/에 커밋하고 있습니다.
CI는 해당 패키지를 새로 빌드하여, 이미 커밋된 파일과 바이트 단위로 일치하는지 요구합니다.
이번처럼 컴파일러나 최적화기를 늘릴 경우, 도구의 버전이나 실행 환경이 불확실하면 이 메커니즘이 깨집니다.
그래서 이번 PR에서는 다음 사항들을 고정했습니다:
- Rust:
nightly-2026-01-20
- wasm-pack:
0.15.0
- wasm-bindgen:
Cargo.lock으로 고정 - Binaryen:
133
- 표준 라이브러리: 핀된 nightly의
rust-src 이용 - Cargo 의존성: --locked
게다가 Binaryen 공식 릴리스 아카이브를 다운로드할 때는 SHA-256 다이제스트를 검증합니다.
빌드 시 파일 경로도 재현성에 영향을 미치기 때문에, --remap-path-prefix로 Cargo 홈이나 표준 라이브러리 소스 경로를 정규화했습니다.
또한, 외부에서 전달된 CARGO_ENCODED_RUSTFLAGS가 지정된 빌드 설정을 덮어쓰지 않도록 했습니다.
CI에서는 최종적으로 다음과 같은 비교를 실행합니다.
ci/build-viewer-wasm.sh /tmp/rebuilt
diff -r site/viewer/pkg /tmp/rebuilt
Wasm 파일뿐만 아니라 생성된 JavaScript 등도 포함하여 비교했습니다.
실험에서 선택한 설정을 프로덕션 스크립트에 반영한 후에도, 독립적인 클린 빌드를 2회 실시하여 실험 결과와 커밋된 패키지의 일치 여부를 확인했습니다.
참고로, 엄격한 바이트 일치를 검증한 릴리스 환경은 Linux x86-64입니다. 다른 OS에서도 무조건 같은 바이트열이 된다고 주장하는 것이 아니라, CI 환경에서의 일치를 기준으로 삼았습니다.
결과: gzip 크기는 48.17% 절감
최종적인 개선 결과는 다음과 같습니다.
| 지표 | 개선 전 | 개선 후 |
|---|
| 비압축 Wasm | 2,124,516 B | 1,103,506 B |
| ... | | |
<text weight="medium">gzip 후 크기 비교</text>
<box gap={2}>
<box gap={1}>
...
gzip 후 크기는 약 238 KB 감소했습니다.
또한, CI의 상한은 높이지 않고 계속 512,000바이트로 유지했습니다.
대신, 상한의 90%에 해당하는 460,800바이트를 초과할 경우에는 경고를 발생시키도록 했습니다.
PR별 GitHub Actions 실행 결과에는 비압축 크기, gzip 후 크기, 경고 임계값(warning threshold), 하드 리밋(hard limit), 남은 여유가 표시됩니다.
이를 통해 상한을 초과한 후에야 알게 되는 것이 아니라, 크기가 커지기 시작하는 단계에서 파악할 수 있게 되었습니다.
이번 개선을 통해 배운 점
이번 PR을 통해 얻은 교훈은 크게 3가지가 있습니다.
첫째, 최적화할 지표를 잘못 잡지 않는 것입니다.
이번 CI가 제한했던 것은 Wasm의 비압축 크기가 아니라 gzip 후 크기였습니다.
따라서 비압축 크기가 줄어드는 것을 성공 조건으로 삼으면 오히려 좋지 않은 설정을 선택하게 될 가능성이 있습니다.
실제로, 비압축 상태에서는 더 작지만, gzip 후에는 커지는 후보가 여러 번 나타났습니다.
둘째, 컴파일 설정과 최적화 경로의 조합을 실험하는 것입니다.
opt-level=z, LTO(Link Time Optimization), 표준 라이브러리의 재빌드, panic 처리 방식, Binaryen의 최적화 경로는 각각 단독으로 평가하기에는 불충분했습니다.
조합이나 순서에 따라 결과가 달라집니다.
이번 141가지 조합이라는 실험은 단순한 총当たり(brute-force)라기보다는, 어떤 설정이 실제로 크기에 영향을 미치는지 확인하며 탐색해 나간 기록입니다.
셋째, 최적화 결과뿐만 아니라, 그 제작 방식과 검증 방법도 남기는 것입니다.
설령 256 KB의 Wasm이 생성되더라도, 다음에 코드를 변경했을 때 같은 방법으로 재생성할 수 없다면 지속적인 개발 과정에서 다루기 어려운 결과가 됩니다.
이번에는 최종 바이너리뿐만 아니라 툴체인(toolchain), 빌드 스크립트, 141가지 측정 로그, 동작 비교 스크립트, 심지어 CI의 크기 제한까지 함께 업데이트했습니다.
이번에 측정된 것은 주로 바이너리 크기와 동작 호환성이며, 실제 브라우저에서의 시작 시간이나 실행 속도가 48% 개선되었다는 의미는 아닙니다.
또한, 내부 패닉(panic)에 대한 상세 진단을 잃는다는 트레이드오프도 존재합니다.
이러한 제약 사항들을 명확히 한 상태에서, 기존 기능을 유지하면서도 향후 기능 추가에 사용할 수 있는 크기 예산(size budget)을 크게 회복할 수 있었다는 것이 이번 성과라고 생각합니다.
Wasm의 바이너리 크기에 고민이 있다면, 처음부터 복잡한 최적화에 착수할 필요는 없습니다.
먼저 실제로 배포되거나 제한되는 형식의 크기를 측정하고, 단일 설정 변경이 어떻게 영향을 미치는지 확인합니다. 그 후에 최적화 경로(optimization path) 조합을 시도하고 동작 호환성 및 재현성을 검증하는 것입니다.
이번 SIGIL 개선은 그러한 진행 방식의 하나의 실제 사례가 되었습니다.
관련 링크
토론 (Discussion)