Files
dwg-dxf-viewer-sample/docs/large-dwg-wasm-heap-quadratic.md
minsungandClaude Opus 5 b047dc44e2 fix(viewer2d): sync dwg-wasm fix for large-DWG load and hatch arc artifacts
Mirrors hmwebviewer's rust/dwg-wasm change into the sample: the wasm
artifact plus the parser wrapper, which now goes through parse_dwg_json
and JSON.parse instead of the JsValue-returning parse_dwg.

Building the whole result as a serde_json::Value tree pushed the live
wasm heap near 2.9GB for a 19MB drawing, and the allocator's cost grows
with that heap, so parsing went quadratic — 354s, with the tab frozen
throughout. Streaming one entity at a time keeps it linear: 2.5s.

The same build also fixes three bugs that drew stray 4km grey shapes
where two CF-PATT SOLID hatches should be. Max hatch span 4655 -> 110.

Adds two docs recording both investigations, in the format of the
existing ones: measurements, the hypotheses that were ruled out, the
fixes, regression checks, and the MPL-2.0 position (acadrust is
unmodified, so no new source-disclosure obligation arises).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:55:34 +09:00

322 lines
15 KiB
Markdown

# 대용량 DWG 로딩 멈춤 — 원인 분석 및 해결 가이드
| 항목 | 내용 |
|------|------|
| 작성 목적 | 19MB DWG 로딩이 사실상 멈추던 문제의 원인 규명과 수정 내역 기록 |
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
| 재현 도면 | `dwg_18.3mb.dwg` (19,243,300 bytes · AC1032 · 82,393 entities) |
| 대조 도면 | `BasicSample.dwg` (459KB), `civil.dwg` (5.6MB) |
| 수정일 | 2026-08-03 |
| 상태 | 원본(`hmwebviewer`) 수정 완료 / 샘플 동기화 완료 / 배포 완료 |
---
## 1. 현상
쿼리 파라미터로 대용량 DWG를 여는 링크가 "안 열린다"는 제보.
```
https://dwg-dxf-viewer-sample.pages.dev/?model=https://minio.gsimtech.com/api/viewer-test/dwg_18.3mb.dwg
```
증상은 다음과 같다.
- 상태바가 `loading dwg_18.3mb.dwg…` 에서 멈춘 채 진행이 없음
- 탭 전체가 무반응 (스크롤·클릭·패널 조작 불가)
- 크롬이 "페이지가 응답하지 않습니다" 배너를 띄우거나 탭을 종료
에러는 발생하지 않는다. 끝까지 기다리면 **약 6분(354초) 뒤 정상적으로 렌더링된다.** 즉 실패가 아니라 극단적으로 느린 것이다.
### 자주 하는 오진
아래는 모두 **원인이 아니었다.** 실측으로 하나씩 배제한 기록이다.
| 오진 | 검증 방법 | 결과 |
|------|-----------|------|
| CORS 차단 | `curl -H "Origin: https://dwg-dxf-viewer-sample.pages.dev"` | `access-control-allow-origin` 정상 응답, 200 OK |
| 쿼리 파라미터 미지원 | 배포된 번들에서 `URLSearchParams(location.search).get("model")` 확인 | 정상 동작 |
| DWG 버전 미지원 | 헤더 시그니처 `AC1032` 확인 | `BasicSample.dwg`와 동일 버전, 지원됨 |
| 파서 알고리즘 O(n²) | 네이티브 빌드로 동일 함수 실행 | **선형** (JSON 1MB당 약 37ms 일정) |
| `serde_wasm_bindgen` 경계 비용 | JSON 문자열 반환 함수를 따로 만들어 측정 | 여전히 310초 — 무관 |
| wasm 메모리 `max` 미선언으로 인한 grow 복사 | `--max-memory` / `--initial-memory` 선언 후 재측정 | 330초 — **개선 없음** |
| `dlmalloc` 자체 결함 | 전역 할당자를 `talc`로 교체 후 재측정 | 316초 — **개선 없음** |
| `opt-level = "s"` (크기 최적화) | 네이티브도 동일 프로파일을 쓴다는 점 확인 | 네이티브/wasm 공통이므로 차이 요인 아님 |
---
## 2. 계측
### 2.1 네이티브 vs wasm
`rust/dwg-wasm`에는 네이티브 바이너리를 만들 수 있으므로, 같은 코드를 두 타깃에서 돌려 비교했다.
| 파일 | JSON 출력 | 네이티브 | wasm | 배율 |
|------|-----------|---------|------|------|
| `BasicSample.dwg` | 2.7MB | 122ms | 266ms | 2.2x |
| `civil.dwg` | 16.5MB | 581ms | 11,554ms | 20x |
| `dwg_18.3mb.dwg` | 96.9MB | 3,644ms | 310,572ms | **85x** |
네이티브는 완전히 선형인데 wasm만 초선형이다. **배율 자체가 파일 크기에 비례해 커진다** — 전형적인 O(n²) 신호다.
일반적인 wasm은 네이티브 대비 1.5~3배 느린 수준이므로, 85배는 정상 범위가 아니다.
### 2.2 구간 분리
wasm 내부에 `js_sys::Date::now()` 기반 구간 타이머를 넣어 세 단계를 나눴다.
| 파일 | `read_document` (DWG 디코딩) | `document_to_parse_result` | `serde_json::to_string` |
|------|------------------------------|----------------------------|-------------------------|
| `BasicSample.dwg` | 71ms | 160ms | 18ms |
| `civil.dwg` | 77ms | **11,235ms** | 87ms |
| `dwg_18.3mb.dwg` | 413ms | **351,777ms** | 509ms |
DWG 디코딩과 직렬화는 선형이고 정상이다. **`document_to_parse_result` 하나가 전체의 99.7%를 차지하며 이 함수만 2차 곡선을 그린다.**
그런데 같은 함수의 네이티브 실행은 71 → 425 → 2,488ms로 완전히 선형이다. **로직이 원인이 아니라는 뜻이다.**
### 2.3 판별 실험 — 할당 횟수인가, 힙 크기인가
원인 후보가 "할당 횟수가 많아서"인지 "살아있는 힙이 커서"인지 가르기 위해, 같은 루프를 두 방식으로 돌렸다.
- **drop 모드** — 엔티티마다 `Value`를 만들고 즉시 버림 (할당 횟수 동일, 라이브 힙 ≈ 0)
- **keep 모드** — 만든 `Value`를 전부 보관 (할당 횟수 동일, 라이브 힙 최대)
두 모드의 **할당 횟수와 수행 작업은 완전히 같고, 오직 객체가 살아있는지만 다르다.**
| 파일 | drop 모드 | keep 모드 | 배율 |
|------|-----------|-----------|------|
| `civil.dwg` (17,758 ents) | 147ms | 3,545ms | 24x |
| `dwg_18.3mb.dwg` (82,403 ents) | **739ms** | **92,848ms** | **126x** |
drop 모드는 선형이고 빠르다 (엔티티 4.6배 증가에 시간 5배). keep 모드만 2차다.
### 2.4 메모리
네이티브 실행의 피크 워킹셋을 측정했다.
```
PEAK WORKING SET: 2,920 MB
```
**19MB DWG 하나를 파싱하는 데 2.9GB.** 원본 대비 154배다.
---
## 3. 원인
세 가지 사실이 하나로 모인다.
1. `document_to_parse_result`는 결과 전체를 `serde_json::Value` 트리로 **메모리에 쌓아 올린다.**
2. 그 트리는 원본 DWG의 약 150배 크기다 (19MB → 약 2.9GB). 좌표 하나하나가 `json!({"x":.., "y":..})`, 즉 힙 문자열 키 2개를 가진 맵으로 만들어지기 때문이다.
3. **wasm 할당자는 살아있는 힙이 커질수록 할당 1건당 비용이 증가한다.** 힙이 GB 단위로 부풀면서 뒤쪽 엔티티일수록 할당이 느려지고, 전체가 O(n²)가 된다.
네이티브에서 같은 코드가 선형인 이유는 Windows 힙이 이 패턴을 잘 견디기 때문이고, 문제는 wasm 타깃에서만 드러난다.
> 할당자를 `talc`로 바꿔도 개선되지 않았다는 점이 중요하다. 특정 할당자의 결함이 아니라 **"거대한 라이브 힙 + 수천만 건의 미세 할당"이라는 조합 자체가 wasm에서 감당되지 않는 것**이다. 따라서 해결책은 할당자 교체가 아니라 **라이브 힙을 없애는 것**이다.
메인 스레드가 이 6분을 통째로 점유하기 때문에 탭이 얼어붙고, 사용자에게는 "안 열린다"로 보인다.
---
## 4. 해결
**결과 전체를 메모리에 들고 있지 않는다.** 엔티티를 하나씩 JSON 텍스트로 변환해 출력 버퍼에 붙이고 즉시 버린다. 라이브 힙이 평평하게 유지되므로 할당 비용이 커지지 않는다.
### 4.1 Rust — 스트리밍 직렬화 경로 추가
`rust/dwg-wasm/src/lib.rs`
기존 `document_to_parse_result`는 그대로 두고(네이티브 덤프·테스트에서 계속 사용), 공통 조각을 뽑아낸 뒤 스트리밍 버전을 추가했다.
```rust
pub fn document_to_parse_result_json(
doc: &acadrust::CadDocument,
) -> Result<String, serde_json::Error> {
let ctx = build_ctx(doc);
let mut out: Vec<u8> = Vec::with_capacity(1 << 22);
// 키 순서는 `json!`이 내보내는 것과 맞춘다: serde_json은 객체를 BTreeMap으로
// 뒷받침하므로 트리 경로는 최상위 키를 정렬해 쓴다. 여기서 같은 순서를
// 유지하면 두 경로를 바이트 단위로 비교할 수 있다.
out.extend_from_slice(b"{\"entities\":[");
// 한 번에 엔티티 하나만: `scratch`를 매 회 비우므로 도면에 엔티티가
// 아무리 많아도 라이브 힙이 평평하게 유지된다.
let mut scratch: Vec<Value> = Vec::new();
let mut first = true;
for e in doc.entities() {
scratch.clear();
entity_to_json(e, &ctx, &mut scratch);
for v in &scratch {
if !first { out.push(b','); }
first = false;
serde_json::to_writer(&mut out, v)?;
}
}
out.extend_from_slice(b"]");
out.extend_from_slice(b",\"stats\":");
serde_json::to_writer(&mut out, &stats_value(doc))?;
out.extend_from_slice(b",\"tables\":");
serde_json::to_writer(&mut out, &tables_value(doc))?;
out.extend_from_slice(b",\"vars\":");
serde_json::to_writer(&mut out, &vars_value(doc))?;
out.extend_from_slice(b",\"version\":");
serde_json::to_writer(&mut out, &version_value(doc))?;
out.push(b'}');
Ok(String::from_utf8(out).expect("serde_json emits UTF-8"))
}
```
새 wasm export:
```rust
#[wasm_bindgen]
pub fn parse_dwg_json(bytes: &[u8]) -> Result<String, JsError> {
let doc = read_document(bytes).map_err(|e| JsError::new(&e))?;
document_to_parse_result_json(&doc).map_err(|e| JsError::new(&e.to_string()))
}
```
기존 `parse_dwg`(JsValue 반환)는 호환을 위해 남겨두었다.
> **키 순서 주의** — `serde_json`은 `preserve_order` 없이 객체를 `BTreeMap`으로 뒷받침하므로 `json!` 매크로가 만든 최상위 키는 알파벳 순(`entities`, `stats`, `tables`, `vars`, `version`)으로 직렬화된다. 수동 출력도 같은 순서를 지켜야 바이트 단위 비교가 성립한다. 처음 구현에서 선언 순서(`version`, `vars`, …)로 썼다가 길이는 같은데 내용이 달라 실패했다.
### 4.2 JS — `JSON.parse` 경유로 전환
`src/viewer2d/acadrustParser.ts`
```ts
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
}
```
97MB 문자열을 `JSON.parse`로 넘기는 비용은 19MB 도면 기준 약 0.6초다. 엔진의 네이티브 파서를 쓰므로 wasm에서 객체를 하나씩 만드는 것보다 훨씬 싸다.
### 4.3 빌드
```bash
cd hmwebviewer
npm run build:dwg-wasm # rust/dwg-wasm → src/viewer2d/acadrust-dwg
```
이후 `src/viewer2d/acadrust-dwg/` 4개 파일과 `acadrustParser.ts`를 샘플 저장소로 복사한다.
---
## 5. 결과
| 파일 | 이전 | 이후 (wasm + `JSON.parse`) | 개선 |
|------|------|---------------------------|------|
| `BasicSample.dwg` (459KB) | 345ms | **138ms** | 2.5x |
| `civil.dwg` (5.6MB) | 14,064ms | **446ms** | 32x |
| `dwg_18.3mb.dwg` (19MB) | **353,735ms** | **2,481ms** | **143x** |
곡선이 2차에서 선형으로 바뀌었다. 엔티티 7,309 → 17,810 → 82,393에 대해 138 → 446 → 2,481ms.
### 검증
동작이 바뀌지 않았음을 두 층위에서 확인했다.
1. **Rust 층**`document_to_parse_result` 트리 경로와 스트리밍 경로의 출력을 5개 도면에서 비교, **전부 바이트 단위 동일**
(`BasicSample.dwg`, `civil.dwg`, `bb.dwg`, `C0060203-001-표준단면도….dwg`, `dwg_18.3mb.dwg`)
2. **JS 층** — 기존 `parse_dwg` 결과와 `JSON.parse(parse_dwg_json(...))` 결과를 `assert.deepStrictEqual`로 비교, 3개 도면 통과
(u64 핸들이 BigInt로 바뀌는 등의 타입 변화가 없음을 확인)
3. `tsc --noEmit` 통과, `npm run build` 성공
---
## 6. 진단 하네스
원인 규명에 쓴 네이티브 바이너리를 회귀 테스트용으로 남겨두었다.
`rust/dwg-wasm/src/bin/prof.rs` — 구간별 시간 측정 + 두 경로 출력 동등성 비교
```bash
cd hmwebviewer/rust/dwg-wasm
cargo build --release --bin prof
./target/release/prof.exe <file.dwg>
```
출력 예:
```
file dwg_18.3mb.dwg bytes 19243300
read_document 619 ms
Value tree + to_string 2875 ms (96897608 bytes)
streaming json 1188 ms (96897608 bytes)
BYTE-IDENTICAL: true
```
`document_to_parse_result`를 수정할 일이 생기면 이걸로 두 경로가 계속 일치하는지 확인하면 된다.
---
## 7. 남은 과제
1. **렌더링 비용 미측정**
파싱은 2.5초로 해결됐지만, 그 뒤 Viewer2D가 82,393개 엔티티를 실제로 그리는 시간은 별도로 측정하지 않았다. 대용량 도면이 여전히 느리다면 여기부터 봐야 한다.
2. **메인 스레드 블로킹**
파싱 2.5초 동안 여전히 탭이 멈춘다. 6분에 비하면 수용 가능하지만, Web Worker로 옮기면 진행률 표시와 취소가 가능해진다.
3. **`parse_dwg` 정리**
기존 JsValue 반환 export는 현재 아무도 쓰지 않는다. 외부 의존이 없다고 확인되면 제거해 wasm 크기를 줄일 수 있다.
4. **메모리 상한**
스트리밍으로 라이브 힙은 없앴지만 출력 문자열 자체는 여전히 97MB이고, wasm32는 주소 공간이 4GB로 제한된다. 이보다 몇 배 큰 도면은 다시 한계에 부딪힐 수 있다. 필요해지면 청크 단위로 JS에 넘기는 방식을 검토한다.
5. **회색 원 아티팩트 (별건)**
`dwg_18.3mb.dwg` 렌더 결과에 도면과 무관한 거대한 회색 원 2개가 나타난다. `CF-PATT` 레이어의 SOLID HATCH 2개(span 4655 / 4092)이며 본 성능 수정과는 **무관하다** — 출력 JSON이 수정 전후 바이트 단위로 동일하므로 원래부터 있던 문제이고, 지금까지 6분이 걸려 아무도 확인하지 못했을 뿐이다.
별도 조사·수정 완료 (최대 해치 span 4655 → 110): [`hatch-boundary-arc-artifact.md`](./hatch-boundary-arc-artifact.md)
---
## 8. 오픈소스 라이선스 (MPL-2.0)
DWG 파싱은 두 계층으로 나뉘며, 라이선스가 다르다.
| 구성요소 | 위치 | 라이선스 | 본 작업에서 |
|----------|------|---------|------------|
| `acadrust` v0.4.1 | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **수정하지 않음** |
| `dwg-wasm` (래퍼) | `hmwebviewer/rust/dwg-wasm/` | MIT (자체 코드) | 수정함 |
| 뷰어 | `src/viewer2d/` | 자체 코드 | 수정함 |
MPL-2.0은 **파일 단위 카피레프트**로, MPL 파일 자체를 수정했을 때 그 파일들을 MPL-2.0으로 공개할 의무가 생긴다. 라이브러리로 링크하기만 한 자체 코드는 대상이 아니다.
본 성능 수정은 전부 래퍼(`rust/dwg-wasm/src/lib.rs`)와 뷰어(`src/viewer2d/acadrustParser.ts`)에서 이루어졌고 `rust/acadrust/` 는 변경하지 않았다. **따라서 새로 발생하는 소스 공개 의무는 없다.**
```
$ git status --short rust/acadrust/
(변경 없음)
```
수정 여부와 무관하게 `rust/acadrust/LICENSE` (MPL-2.0 전문)와 저작권 고지는 계속 유지해야 하며, 현재 유지되고 있다.
향후 acadrust 내부를 직접 고쳐야 하는 상황이 오면 의무가 발생한다. 상세 지침은 [`hatch-boundary-arc-artifact.md` §7](./hatch-boundary-arc-artifact.md#7-오픈소스-라이선스-mpl-20) 참고.
> 라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다.
---
## 9. 한 줄 요약
> **파서 알고리즘 문제가 아니라, 결과 전체를 `serde_json::Value` 트리로 메모리에 쌓는 구조 때문에 라이브 힙이 2.9GB까지 부풀고, 힙이 커질수록 wasm 할당자의 할당 비용이 증가해 전체가 O(n²)가 된 것이다.
> 엔티티를 하나씩 JSON 텍스트로 흘려보내 라이브 힙을 평평하게 유지하면 선형으로 돌아오며, 19MB 도면 기준 354초 → 2.5초(143배)로 개선된다.
> 출력 JSON은 바이트 단위로 동일하므로 뷰어 쪽 변경은 `JSON.parse` 한 줄뿐이다.**
---
## 10. 참고 파일
- 스트리밍 구현: [`../../hmwebviewer/rust/dwg-wasm/src/lib.rs`](../../hmwebviewer/rust/dwg-wasm/src/lib.rs) (`document_to_parse_result_json`, `parse_dwg_json`)
- 뷰어 연결부: [`../src/viewer2d/acadrustParser.ts`](../src/viewer2d/acadrustParser.ts)
- 진단 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs)
- 쿼리 파라미터 로딩부: [`../src/main.ts`](../src/main.ts) (`loadUrl`, `?model=`)
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)