# 대용량 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 { let ctx = build_ctx(doc); let mut out: Vec = Vec::with_capacity(1 << 22); // 키 순서는 `json!`이 내보내는 것과 맞춘다: serde_json은 객체를 BTreeMap으로 // 뒷받침하므로 트리 경로는 최상위 키를 정렬해 쓴다. 여기서 같은 순서를 // 유지하면 두 경로를 바이트 단위로 비교할 수 있다. out.extend_from_slice(b"{\"entities\":["); // 한 번에 엔티티 하나만: `scratch`를 매 회 비우므로 도면에 엔티티가 // 아무리 많아도 라이브 힙이 평평하게 유지된다. let mut scratch: Vec = 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 { 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_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)