Skip to content

성능

SheetKit은 Rust와 TypeScript 애플리케이션 모두에 네이티브 Rust 성능을 제공합니다. 이 페이지에서는 SheetKit이 얼마나 빠른지, 그리고 이를 가능하게 하는 최적화 기법들을 설명합니다.

SheetKit은 얼마나 빠른가요?

ExcelJS, SheetJS와의 비교 (Node.js)

기존 Node.js 벤치마크 스위트(benchmarks/node/RESULTS.md) 기준으로, SheetKit은 대표적인 읽기/쓰기 시나리오에서 ExcelJS와 SheetJS보다 일관되게 빠르게 동작합니다.

시나리오SheetKitExcelJSSheetJS
대용량 읽기 (50k 행 x 20 열)541ms1.24s1.56s
대용량 쓰기 (50k 행 x 20 열)469ms2.62s1.09s
버퍼 왕복 (10k 행)123ms319ms163ms
랜덤 접근 읽기 (50k 행 파일에서 1k 셀)453ms1.27s1.34s

Rust Excel 라이브러리와의 비교

Rust 라이브러리 중 SheetKit은 가장 빠른 writer입니다. 읽기의 경우 비교 가능한 전체 읽기 작업 기준에서 calamine(읽기 전용)이 더 빠르며, edit-xlsx의 읽기 결과는 비교 가능성 검증이 필요합니다.

시나리오SheetKitcalaminerust_xlsxwriteredit-xlsx
대용량 읽기 (50k 행)494ms324msN/A372ms*
대용량 쓰기 (50k 행 x 20 열)475msN/A886ms939ms
스트리밍 쓰기 (50k 행)191msN/A885msN/A
수정 (50k 행 파일에서 1k 셀)668msN/AN/A537ms

* edit-xlsx에서 *가 표시된 값은 작업량 카운트 또는 값 프로브가 일치하지 않아 Winner 계산에서 제외된 결과입니다.

edit-xlsx 읽기 이상치 원인

SpreadsheetML에서 workbook.xmlfileVersion, workbookPr, bookViews는 선택 요소(옵션)입니다.
하지만 edit-xlsx 0.4.x는 일부 파일에서 이 요소들을 역직렬화 시 필수처럼 처리할 수 있습니다. 이때 파싱이 실패하면 기본 워크북/워크시트 구조로 fallback되어 런타임은 매우 짧게 측정되지만 rows=0, cells=0이 되는 경우가 발생합니다.

공정한 비교를 위해 Rust 비교 벤치마크는 다음 조건을 만족한 결과만 비교합니다.

  • 라이브러리 간 행/셀 작업량 카운트 일치
  • 동일 좌표 샘플에 대한 값 프로브 일치

이 조건을 만족하지 못한 결과는 non-comparable로 표시하고 Winner 계산에서 제외합니다.

Rust vs Node.js 오버헤드

SheetKit의 Node.js 바인딩은 네이티브 Rust와 매우 가까운 성능을 유지합니다:

작업오버헤드
읽기 작업 (sync)약 1.04배 (일반적으로 약 4% 느림)
읽기 작업 (async)약 1.04배 (일반적으로 약 4% 느림)
쓰기 작업 (batch)약 0.86배 (V8 문자열 처리로 더 빠름)
스트리밍 쓰기1.51배 (51% 느림)
버퍼 왕복약 1.0배 (거의 동일)

대부분의 실제 워크로드에서 Node.js 성능은 네이티브 Rust와 매우 유사합니다.

읽기 성능 비교

시나리오RustNode.js오버헤드
대용량 데이터 (50k 행 x 20 열)518ms541ms+4%
많은 스타일 (5k 행, 서식 적용)27ms27ms0%
다중 시트 (10개 시트 x 5k 행)301ms290ms-4% (더 빠름)
수식 (10k 행)33ms34ms+3%
문자열 (20k 행 텍스트 중심)109ms117ms+7%

쓰기 성능 비교

시나리오RustNode.js오버헤드
50k 행 x 20 열544ms469ms-14% (더 빠름)
5k 스타일 적용 행28ms32ms+14%
10k 행 (수식 포함)24ms27ms+13%
20k 텍스트 중심 행108ms86ms-20% (더 빠름)

참고: 일부 쓰기 시나리오에서는 데이터 생성 시 V8의 효율적인 문자열 처리와 batch setSheetData() API 덕분에 Node.js가 Rust보다 약간 더 빠른 성능을 보입니다.

확장성 성능

읽기 성능은 다양한 파일 크기에서 일관성을 유지합니다:

행 수RustNode.js오버헤드
1k5ms5ms0%
10k51ms50ms-2% (더 빠름)
100k539ms535ms-1% (더 빠름)

쓰기 성능은 선형적으로 확장됩니다:

행 수RustNode.js오버헤드
1k5ms4ms-20% (더 빠름)
10k48ms46ms-4% (더 빠름)
50k258ms231ms-10% (더 빠름)
100k531ms485ms-9% (더 빠름)

Raw Buffer 전송과 메모리 동작

SheetKit은 셀별 JavaScript 객체 대신 Raw Buffer로 시트 데이터를 전달하여 Node.js-Rust 경계 비용을 줄입니다. 이 전송 모델은 FFI 경계를 큰 단위로 유지하여 객체 마샬링 오버헤드를 낮추고, 읽기 중심 경로에서 GC 부담을 줄입니다.

주요 최적화 기법

버퍼 기반 FFI 전송

각 셀에 대해 개별 JavaScript 객체를 생성하는 대신, SheetKit은 전체 시트를 컴팩트한 바이너리 버퍼로 직렬화하여 단일 작업으로 FFI 경계를 넘습니다.

최적화 전: 셀 단위 객체를 FFI 경계로 개별 전송 최적화 후: 시트 단위 페이로드를 Raw Buffer로 단일 전송

이 최적화는:

  • 읽기 경로의 FFI 오버헤드를 줄입니다
  • 셀 단위 객체 생성으로 인한 할당 및 GC 부담을 줄입니다
  • 완전한 타입 안전성을 유지합니다

내부 데이터 구조 최적화

SheetKit의 내부 표현은 할당을 최소화합니다:

  • CompactCellRef: 셀 참조를 heap String 대신 inline [u8;10] 배열로 저장합니다
  • CellTypeTag: 셀 타입을 Option<String> 대신 1바이트 enum으로 저장합니다
  • Sparse-to-dense 변환: 최적화된 행 반복으로 중간 할당을 방지합니다

이러한 최적화는 Rust와 Node.js 모두의 성능에 도움이 됩니다.

밀도 기반 인코딩

버퍼 인코더는 셀 밀도에 따라 자동으로 dense와 sparse 레이아웃 중 하나를 선택합니다:

  • 셀 점유율이 ≥30%인 파일은 dense 인코딩
  • 셀 점유율이 <30%인 파일은 sparse 인코딩

이를 통해 모든 파일 타입에 대해 최적의 메모리 사용을 보장합니다.

벤치마크 환경

모든 벤치마크는 다음 환경에서 수행되었습니다:

구성 요소버전
CPUApple M4 Pro
RAM24 GB
OSmacOS arm64 (Apple Silicon)
Node.jsv25.3.0
Rustrustc 1.93.0

결과는 시나리오당 1회 워밍업 실행 후 5회 실행의 중앙값입니다.

벤치마크 범위와 데이터

이 페이지의 수치는 리포지토리 내부의 SheetKit Rust/Node.js 벤치마크 스위트에서 측정한 결과입니다. 실제 결과는 데이터 형태, 사용 기능, 런타임 환경에 따라 달라집니다.

벤치마크 방법론과 원시 데이터는 리포지토리의 benchmarks/COMPARISON.md를 참조하세요.

성능 팁

읽기 모드 선택

SheetKit은 open() 시 파싱 범위를 제어하는 세 가지 읽기 모드를 지원합니다:

읽기 모드Open 비용Open 시 메모리적합한 용도
lazy (기본값)낮음 -- ZIP 인덱스 + 메타데이터만최소대부분의 워크로드. 시트는 첫 접근 시 파싱됩니다.
eager높음 -- 모든 시트 파싱전체 워크북open 직후 모든 시트가 필요한 경우.
stream현재 lazy와 동일현재 lazy와 동일향후 호환성 모드이며 Rust 코어에서는 현재 lazy와 동일하게 동작합니다.
typescript
// Lazy open (기본값): 가장 빠른 open, 시트를 필요시 파싱
const wb = await Workbook.open("huge.xlsx");
const rows = wb.getRows("Sheet1"); // Sheet1이 여기서 파싱됨

// Eager open: open 시 모든 시트 파싱
const wb2 = await Workbook.open("huge.xlsx", { readMode: "eager" });

// Stream 모드: 메모리 제한 순방향 읽기
const wb3 = await Workbook.open("huge.xlsx", { readMode: "stream" });
const reader = await wb3.openSheetReader("Sheet1", { batchSize: 500 });
for await (const batch of reader) {
  for (const row of batch) {
    process(row);
  }
}

보조 파트 지연 로드

기본적으로 보조 파트(comments, charts, images, pivot table)는 open 시 파싱되지 않습니다. 해당 파트가 필요한 메서드를 처음 호출할 때 로드됩니다. 모든 파트를 즉시 사용해야 하는 경우 auxParts: 'eager'를 설정합니다:

typescript
const wb = await Workbook.open("report.xlsx", { auxParts: "eager" });

읽기 위주 워크로드

OpenOptions를 사용하여 필요한 부분만 로드합니다:

typescript
const wb = await Workbook.open("huge.xlsx", {
  readMode: "lazy",
  sheetRows: 1000,      // 시트당 첫 1000행만 읽기
  sheets: ["Sheet1"],   // Sheet1만 파싱
  maxUnzipSize: 100_000_000  // 압축 해제 크기 제한
});

SheetStreamReader를 사용한 스트리밍 읽기

랜덤 셀 접근이 필요 없는 대용량 파일의 경우, openSheetReader()를 사용하여 메모리 제한 순방향 반복을 수행합니다:

typescript
const wb = await Workbook.open("huge.xlsx", { readMode: "stream" });
const reader = await wb.openSheetReader("Sheet1", { batchSize: 1000 });

for await (const batch of reader) {
  for (const row of batch) {
    // 각 행 처리 -- 한 번에 하나의 배치만 메모리에 존재
  }
}

Raw Buffer V2 전송

getRowsBufferV2()는 글로벌 문자열 테이블을 즉시 생성하지 않고 행별로 점진적으로 디코딩할 수 있는 inline 문자열 포함 v2 바이너리 buffer를 생성합니다:

typescript
const bufV2 = wb.getRowsBufferV2("Sheet1");

Copy-on-Write 저장

lazy 모드로 열린 워크북을 저장할 때, 변경되지 않은 시트는 파싱-직렬화 왕복 없이 원본 ZIP 엔트리에서 직접 기록됩니다. 이는 대용량 워크북에서 일부 시트만 수정하는 워크로드의 저장 지연 시간을 크게 줄여줍니다.

쓰기 위주 워크로드

순차적 행 쓰기에는 StreamWriter를 사용합니다. 각 write_row() 호출은 디스크의 임시 파일에 직접 기록하므로, 행 수에 관계없이 메모리 사용량이 일정하게 유지됩니다:

typescript
const wb = new Workbook();
const sw = wb.newStreamWriter("LargeSheet");

for (let i = 1; i <= 100_000; i++) {
  sw.writeRow(i, [`Item_${i}`, i * 1.5]);
}

wb.applyStreamWriter(sw);
await wb.save("output.xlsx");

대용량 파일

Lazy open과 StreamWriter를 결합합니다:

typescript
// Lazy open -- 메타데이터만 파싱됨
const wb = await Workbook.open("input.xlsx");

// 스트리밍으로 처리
const sw = wb.newStreamWriter("ProcessedData");
// ... 데이터 처리 ...
wb.applyStreamWriter(sw);

참고: 스트리밍된 시트의 셀 값은 applyStreamWriter 이후 직접 읽을 수 없습니다. 데이터를 읽으려면 워크북을 저장한 후 다시 열어야 합니다.

다음 단계

MIT / Apache-2.0 라이선스로 배포됩니다.