Files
dwg-dxf-viewer-sample/docs/large-dwg-wasm-heap-quadratic.md
T
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

15 KiB

대용량 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는 그대로 두고(네이티브 덤프·테스트에서 계속 사용), 공통 조각을 뽑아낸 뒤 스트리밍 버전을 추가했다.

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:

#[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_jsonpreserve_order 없이 객체를 BTreeMap으로 뒷받침하므로 json! 매크로가 만든 최상위 키는 알파벳 순(entities, stats, tables, vars, version)으로 직렬화된다. 수동 출력도 같은 순서를 지켜야 바이트 단위 비교가 성립한다. 처음 구현에서 선언 순서(version, vars, …)로 썼다가 길이는 같은데 내용이 달라 실패했다.

4.2 JS — JSON.parse 경유로 전환

src/viewer2d/acadrustParser.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 빌드

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 — 구간별 시간 측정 + 두 경로 출력 동등성 비교

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


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 참고.

라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다.


9. 한 줄 요약

파서 알고리즘 문제가 아니라, 결과 전체를 serde_json::Value 트리로 메모리에 쌓는 구조 때문에 라이브 힙이 2.9GB까지 부풀고, 힙이 커질수록 wasm 할당자의 할당 비용이 증가해 전체가 O(n²)가 된 것이다. 엔티티를 하나씩 JSON 텍스트로 흘려보내 라이브 힙을 평평하게 유지하면 선형으로 돌아오며, 19MB 도면 기준 354초 → 2.5초(143배)로 개선된다. 출력 JSON은 바이트 단위로 동일하므로 뷰어 쪽 변경은 JSON.parse 한 줄뿐이다.


10. 참고 파일