What is Hackers' Pub?

Hackers' Pub is a place for software engineers to share their knowledge and experience with each other. It's also an ActivityPub-enabled social network, so you can follow your favorite hackers in the fediverse and get their latest posts in your feed.

I wrote some words for ~ this week in security ~ about how social media giants (aka: ad tech companies!) track you around the web, even if you don't have an account or use their apps. Follows from a brilliant column in the BBC about TikTok's use of website "pixels" to track people's browsing activity.

More: this.weekinsecurity.com/how-te

Sign up (or RSS) for the weekly newsletter, out Sundays. No email open or link click tracking! this.weekinsecurity.com

0
0
0
1

개발곰 shared the below article:

Building a New Excel Library in One Week

Haze @nebuleto@hackers.pub

These days I work primarily in TypeScript on Node.js. I needed to handle bulk uploads of large Excel data and dynamically generate template Excel files to collect that data. Those templates had to include data validation, conditional formatting, dropdowns, and so on.

The existing Node.js Excel libraries each had problems. One split its functionality between a community edition and a paid edition, which meant features I needed were locked away. The other had a gap between its internal implementation and its TypeScript typings, and it was too slow for what I was trying to do. Pull requests had piled up in the repository, but the project was no longer being maintained.

I had known about Excelize, the Go library, for a while. Charts, conditional formatting, formulas, data validation: it covers a lot of the OOXML spec and does it well. I kept thinking I wanted something at that level in TypeScript.

Coding agents have gotten noticeably better in the past year or so, and I wanted to try a specific way of working: I make all the design and architecture decisions, and agents handle the implementation. On Wednesday of last week (February 4th) I started analyzing Excelize and other Excel libraries. By Saturday night (February 7th) I was writing code.

That's SheetKit.

  • Repository
  • Documentation (Getting Started)
  • Benchmark results (environment, methodology, fixtures included):
    • Node.js library comparison
    • Rust comparison
    • Fixture definitions

This is the first of two posts. This one covers what SheetKit is and how the week went, from first release to the v0.5.0 I shipped this evening (February 14th). The second post will be about working with coding agents: what I delegated, how, and where it broke down.


Release Timeline

Dates are crates.io / npm publish timestamps. Approximate, not to-the-minute.

Version When Date What
v0.1.0 Sunday (last week) 2026-02-08 First publish (initial form)
v0.1.2 Monday early morning (last week) 2026-02-09 First snapshot worth calling a public release
v0.2.0 Monday morning (last week) 2026-02-09 Buffer I/O, formula helpers
v0.3.0 Tuesday early morning (last week) 2026-02-10 Raw buffer FFI, batch APIs, benchmark suite
v0.4.0 Tuesday afternoon (last week) 2026-02-10 Feature expansion + documentation site
v0.5.0 Saturday evening (today) 2026-02-14 Lazy loading / streaming, COW save, benchmark rule improvements

What Is SheetKit?

SheetKit is a Rust spreadsheet library for OOXML formats (.xlsx, .xlsm, etc.) with Node.js bindings via napi-rs. Bun and Deno work too, since they support Node-API.

.xlsx files are ZIP archives containing XML parts. SheetKit opens the ZIP, deserializes each XML part into Rust structs, lets you manipulate them, and serializes everything back on save.

Three crates on the Rust side:

  • sheetkit-xml: Low-level XML data structures mapping to OOXML schemas
  • sheetkit-core: All business logic
  • sheetkit: Facade crate for library consumers

Node.js bindings live in packages/sheetkit and expose the Rust API via #[napi] macros.

To get started: sheetkit.dev/getting-started.


Saturday Night to First Release (v0.1.x)

I started coding Saturday night (February 7th) and pushed v0.1.0 the next day. By early Monday morning I had v0.1.2, which was the first version I'd actually call releasable.

I had spent Wednesday analyzing the OOXML spec and how existing libraries implemented features, so by Saturday I had a detailed plan ready. I handed implementation to coding agents (Claude Code and Codex). The setup was: a main orchestrator agent receives the plan, then spawns sub-agents in parallel for each feature area. It burns through tokens fast, but it gets a large plan done quickly. After the agents finish, a separate agent does code review before I look at it.

More on this workflow in the next post.

v0.1.2 was an MVP. It had 44,000+ lines, 1,533 tests, 110 formula functions, charts, images, conditional formatting, data validation, StreamWriter, and builds for 8 platform targets. But it could only read/write via file paths (no Buffer I/O), and I hadn't measured performance at all. It worked, but that was about it.


Monday: Starting to Think About Performance (v0.2.0 – v0.3.0)

Buffer I/O (v0.2.0)

v0.2.0 went up Monday morning, a few hours after v0.1.2.

I added Buffer I/O: read and write .xlsx directly from in-memory buffers, no filesystem needed. In a server you're usually processing binary from an HTTP request or streaming a generated file back in the response, so this had to come early. fill_formula and other formula helpers went in at the same time.

With Buffer I/O in place I could run tests closer to real production workloads. That's where the problems showed up.

Switching to Raw Buffers (v0.3.0)

The initial implementation created a JS object per cell and passed it across the Rust/JS FFI boundary. Pull a 50k×20 sheet as a row array and that's a million-plus JS objects. GC pressure and memory usage went through the roof.

I got the idea from oxc, which transfers Rust AST data to JS as raw buffers instead of object trees. Same principle here:

  • Don't create per-cell objects.
  • Serialize the entire sheet into a compact binary buffer.
  • Cross the FFI boundary once.

The encoder picks dense or sparse layout automatically based on cell occupancy (threshold: 30%). Since the JS side receives a raw buffer, I also wrote a TypeScript parser for the format.

v0.3.0 shipped the first version of this buffer protocol. v0.5.0 later replaced it with a v2 format that supports inline strings and incremental row-by-row decoding.

I also made changes in the Rust XML layer. The goal was fewer heap allocations and simpler hot paths.

Change Why
Cell references ("A1") stored as [u8; 10] inline arrays, not heap Strings Max cell ref is "XFD1048576" (10 bytes). No need for the heap.
Cell type attribute normalized to a 1-byte enum Stops carrying raw XML attribute strings around
Binary search for cells within a row, replacing linear scan
Metric Before After
Memory (RSS) at 100k rows 361 MB 13.5 MB
Node.js read overhead vs. native Rust ~4%
GC pressure 1M+ object creations Single buffer transfer

Benchmarks

This is when I built the benchmark suite, comparing SheetKit against existing Node.js and Rust libraries. The runner outputs Markdown with environment info, iteration counts, and raw numbers.

Setup: Apple M4 Pro, 24 GB / Node v25.3.0 / Rust 1.93.0. Median of 5 runs after 1 warmup. RSS/heapUsed are residual deltas (before vs. after), not peaks. Fixtures are generated deterministically; row counts include the header.

50k rows × 20 columns: SheetKit read 541 ms, write 469 ms. The JS-only libraries: 1.24–1.56s read, 1.09–2.62s write. heapUsed delta: 0 MB, which confirmed that the JS side was no longer accumulating objects.

One odd thing: edit-xlsx, a Rust library, was showing suspiciously fast read times. I didn't understand why at this point. The explanation came during the v0.5.0 work (covered below).


Tuesday: Closing Feature Gaps (v0.4.0)

v0.4.0 shipped Tuesday afternoon. This one was about features, not performance.

I went through what other Excel libraries supported and listed what SheetKit was still missing. Shapes, slicers, form controls, threaded comments, VBA extraction, a CLI. I also added 54 more formula functions (total: 164), mostly financial and engineering.

Same orchestrator/sub-agent setup as before: write a detailed plan for each feature, have the agents implement in parallel, agent review first, then my review.

Memory optimization continued on the side. Reworking the Cell struct and SST memory layout cut RSS from 349 MB to 195 MB for sync reads (44% drop). Async reads: 17 MB.

I also set up a VitePress documentation site around this time.


Today: Rethinking the Architecture (v0.5.0)

v0.5.0 went out this evening. Unlike the previous releases, which added features on top of the same API shape, this one changed the Node.js API structure and parts of the Rust core.

Lazy Loading by Default

Before v0.5.0, open() parsed every XML part upfront. Open a 50k-row file and all sheets load into memory, even the ones you never touch. Now there are three read modes:

  • lazy (default): reads ZIP index and metadata only. Sheets parse on first access.
  • eager: the old behavior. Parse everything immediately.
  • stream: forward-only, bounded memory.

Lazy open costs less than 30% of eager, and pre-access memory is under 20% of eager. Auxiliary parts (comments, charts, images, pivot tables) also defer parsing until you actually call a method that needs them.

Streaming Reader

Forward-only reader for large files. One batch in memory at a time.

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) {
    // process
  }
}

Copy-on-Write Save

When you save a lazily-opened workbook, unchanged sheets pass through directly from the original ZIP entry. No parse-serialize round trip. At work I generate files by opening a template, filling in a few cells, and sending it back. That's exactly the workload this helps.

The edit-xlsx Read Anomaly

Back when I built the benchmarks, edit-xlsx was recording very fast read times on some files. Rows/cells count was dropping to zero.

I added comparability rules to the benchmark:

  • Check that rows/cells count matches expectations
  • Value-probe a few cells at known coordinates
  • If either fails, mark the result non-comparable

Then I dug into why. In SpreadsheetML, fileVersion, workbookPr, and bookViews in workbook.xml are optional. edit-xlsx 0.4.x treats them as required. When deserialization fails on a file missing these elements, it falls back to a default struct: rows=0, cells=0, near-zero runtime. It was fast because it wasn't reading anything.

SheetKit now writes default values for fileVersion and workbookPr (matching Excel's own defaults) when they're absent, for compatibility.


Node.js Bindings Faster Than Native Rust?

In some write scenarios, the Node.js bindings beat native Rust.

Scenario Rust Node.js Overhead
Write 50k rows × 20 cols 544 ms 469 ms −14% (Node.js faster)
Write 20k text-heavy rows 108 ms 86 ms −20% (Node.js faster)

This happens because V8 is very good at string interning and memory management when building SST data through the batch API (setSheetData). The napi crossing costs less than what V8 saves. I did not expect to see negative overhead, but here we are.


Dogfooding SheetKit

I replaced our previous library with SheetKit at work. Template generation and bulk upload processing have been running fine.

Where it stands today (February 14th):

  • Streaming read/write in both Node.js and Rust
  • 164 formula functions
  • 43 chart types
  • Multiple image formats

Read overhead (Node.js vs. Rust): ~4%. Some write scenarios are faster from Node.js. Details at sheetkit.dev.

The library is still experimental and APIs may change. I'll keep using it in production, measuring, and fixing things as they come up. Issues and PRs are always welcome.


Next Post

This covered the what and when. The next post is about the how: orchestrator/sub-agent structure, how I used Claude Code and Codex, the agentic code review loop, where I had to step in, and what I'd do differently.

Read more →
4

개발곰 shared the below article:

일주일만에 새로운 엑셀 라이브러리를 만들다

Haze @nebuleto@hackers.pub

저는 회사에서 주로 TypeScript로 Node.js 환경에서 개발을 하고 있습니다. 대량의 엑셀 데이터를 업로드해서 bulk 처리하는 기능이 필요했고, 그 데이터를 받기 위한 템플릿 엑셀 파일도 동적으로 생성해야 했습니다. 템플릿 안에는 데이터 유효성 검사와 조건부 서식, 드롭다운 같은 기능이 들어가야 했고요.

기존에 쓰던 Node.js 엑셀 라이브러리들은 각자 한계가 있었습니다. 하나는 커뮤니티 버전과 유료 버전이 분리되어 있어 기능 제약이 아쉬웠습니다. 다른 하나는 내부 구현과 TypeScript 타이핑 사이에 괴리가 있었고, 성능 문제로 인해 원하는 작업을 빠르게 처리하기 어려웠습니다. 저장소에는 PR이 쌓여 있었지만 더 이상 업데이트되지 않는 상태였습니다.

평소에 알고 있던 Go 생태계의 Excelize 프로젝트를 다시 들여다보았습니다. 차트, 조건부 서식, 수식, 데이터 검증처럼 OOXML 스펙의 큰 기능들을 잘 구현해둔 라이브러리였습니다. Excelize를 보면서 이 정도의 라이브러리를 TypeScript에서도 쓸 수 있으면 좋겠다는 생각이 들었습니다.

코딩 에이전트들의 역량은 계속 좋아지고 있다는 감각을 꾸준히 느꼈고, 제가 설계와 의사결정을 하되 모든 구현을 에이전트에게 위임하는 방식으로 만들어보면 어떨까하는 생각이 들었습니다. 저는 지난주 수요일(2월 4일)에 Excelize와 여러 엑셀 라이브러리들의 기능 목록과 구현 방식을 분석했고, 지난주 토요일(7일) 밤부터 실제 코드 작성에 들어갔습니다.

그렇게 SheetKit을 만들었습니다.

  • 저장소
  • 문서 (Getting Started)
  • 벤치마크 결과 (실행 환경, 실행 방법, 픽스처 포함)
    • Node.js 라이브러리간 비교
    • Rust 비교
    • 픽스처 정의

이 글은 두 파트로 구성됩니다. 지금 읽고 계신 글에서는 SheetKit 소개와 함께, 첫 릴리즈부터 오늘(2월 14일) 배포한 v0.5.0까지 1주일간의 개발 과정을 시간순으로 기록합니다. 다음 글에서는 코딩 에이전트와 어떻게 협업했는지, 어떤 작업에서 사람이 판단해야 했는지 같은 이야기를 더 구체적으로 다룰 예정입니다.


일주일간의 릴리즈 타임라인

표의 날짜는 crates.io와 npm 배포를 기준입니다. (정확한 타임스탬프보다 "무슨 일이 언제 있었는지"를 보여주기 위한 표입니다.)

버전 시점(상대) 날짜 핵심
v0.1.0 일요일(지난주) 2026-02-08 첫 배포(초기 형태)
v0.1.2 월요일 새벽(지난주) 2026-02-09 첫 공개로 부를 만한 스냅샷
v0.2.0 월요일 아침(지난주) 2026-02-09 Buffer I/O, 수식 헬퍼
v0.3.0 화요일 새벽(지난주) 2026-02-10 raw buffer FFI, 배치 API, 벤치마크 구축
v0.4.0 화요일 오후(지난주) 2026-02-10 기능 확장 + 문서 사이트
v0.5.0 토요일 아침(오늘) 2026-02-14 lazy loading / stream, COW save, 벤치마크 룰 개선

SheetKit은 어떤 라이브러리인가요?

SheetKit은 Rust로 작성된 스프레드시트(.xlsx, .xlsm 등의 OOXML 규격) 라이브러리입니다. Rust 코어 위에 napi-rs 기반 Node.js 바인딩이 올라가는 구조이고, Bun과 Deno와 같이 Node-API를 지원하는 다른 런타임에서도 그대로 쓸 수 있습니다.

이런 파일들은 OOXML(Office Open XML) 형식으로 내부적으론 ZIP 아카이브 안에 XML 파트들이 들어 있는 구조입니다. SheetKit은 이 ZIP 파일을 열어서 각 XML 파트를 Rust 구조체로 역직렬화하고, 조작한 뒤 다시 직렬화해서 저장합니다.

Rust 쪽은 세 개의 crate으로 나뉩니다.

  • sheetkit-xml: OOXML 스키마에 대응하는 저수준 XML 데이터 구조
  • sheetkit-core: 모든 비즈니스 로직
  • sheetkit: 라이브러리 사용자를 위한 facade crate

Node.js 바인딩은 packages/sheetkit에 있고, #[napi] 매크로로 Rust API를 JavaScript에 노출합니다.

바로 써보고 싶다면 Getting Started 문서가 가장 빠릅니다.


토요일 밤부터 첫 배포까지 (v0.1.x)

지난주 토요일(2월 7일) 밤에 코드 작성을 시작했고, 다음 날에 첫 배포(v0.1.0)를 찍었습니다. 그리고 9일 월요일 새벽에는 "이 정도면 우선 공개해볼 수 있겠다" 싶은 MVP(v0.1.2)를 만들었습니다.

수요일에 OOXML 스펙과 기존 라이브러리들의 기능 구현 방식을 먼저 분석하고 계획을 자세히 세워 코딩 에이전트에게 작업을 위임해서 짧은 시간 안에 형태를 잡을 수 있었습니다.

작업 방식은 단순했습니다.

  • 설계에 대한 판단과 구조에 대한 결정은 제가 합니다. 그 과정에서 필요한 분석 작업에도 Claude Code, Codex 등과 같은 코딩 에이전트의 도움을 받았습니다.
  • 구현은 코딩 에이전트에게 전적으로 위임합니다. 구현 전에 플랜을 매우 상세하게 세운 뒤, 메인 에이전트는 직접 작업을 하는 것이 아니라 Orchestrator 에이전트로서 각 기능 파트마다 서브 에이전트를 병렬로 돌리고 관리합니다.
  • 구현이 끝나면 별도의 에이전트를 통해 코드 리뷰를 거친 다음, 제가 직접 확인합니다.

다음 글에서는 이 방식이 실제로 어느 지점에서 잘 먹히고, 어느 지점에서 사람이 개입해야 했는지를 더 구체적으로 적을 예정입니다.


월요일: 성능을 생각하기 시작하다 (v0.2.0 ~ v0.3.0)

Buffer I/O (v0.2.0)

첫 릴리즈 이후 얼마 지나지 않은 월요일(9일) 아침에 v0.2.0을 올렸습니다.

핵심은 Buffer I/O였습니다. 파일 경로 없이 메모리 상의 버퍼로 .xlsx를 읽고 쓸 수 있게 하는 기능입니다. 서버 환경에서는 파일 시스템을 거치지 않고 HTTP 요청의 바이너리를 바로 처리하거나, 생성된 엑셀을 바로 응답으로 내려줘야 하는 경우가 많습니다. fill_formula 같은 수식 헬퍼도 이때 함께 넣었습니다.

Buffer I/O를 붙이고 나서부터 "실제 서비스에서 하던 일"과 비슷한 시나리오 테스트를 돌리기 시작했고, 여기서 진짜 병목을 만났습니다.

napi 경계의 오버헤드를 해결하기 위해 Raw Buffer로 전환 (v0.3.0)

초기에는 셀 단위로 자바스크립트 객체를 만들어 Rust와 자바스크립트 경계를 넘기는 형태로 시작했습니다. 50,000행 x 20열 같은 파일을 "행 단위 배열"로 한 번에 꺼내오면, 당연하지만 아주 많은 자바스크립트 객체가 만들어집니다. 이 구조는 GC 압력과 메모리 사용량을 빠르게 올립니다.

oxc 프로젝트에서 효율적으로 Rust로 빠르게 AST 데이터를 자바스크립트 영역으로 전달하는 방식에서 영감을 받아서 다음과 같이 방향을 바꿨습니다. (참고 문서)

  • 셀 단위 객체를 만들지 않습니다.
  • 전체 시트를 compact binary buffer로 직렬화합니다.
  • FFI 경계를 "한 번만" 넘깁니다.

또한 이 방식에는 시트 내 셀이 얼마나 있는지에 따라 dense / sparse 레이아웃을 자동으로 선택하는 방식도 같이 들어갔습니다. Buffer를 그대로 주고받기 때문에 TypeScript로도 한번 더 버퍼 규격에 맞는 파서를 작성하였습니다.

v0.3.0에서 첫 번째 버전의 버퍼 포맷을 구현했고, 이후 v0.5.0에서 지연 로딩과 인라인 string을 지원하는 새로운 포맷으로 개선했습니다.

또한 Rust의 XML을 처리하는 레이어에서도 함께 수정한 것들이 있습니다. "힙 할당을 줄이고, 자주 접근하는 경로를 단순하게 만든다"가 기준이었습니다.

변경 이유
셀 레퍼런스("A1")를 String 대신 고정 길이 인라인 배열로 저장 셀 레퍼런스는 최댓값이 정해져 있어서 힙을 쓰지 않아도 됩니다
타입 문자열을 1바이트 태그로 정규화 XML 속성 문자열을 그대로 들고 다니지 않게 합니다
행 내 셀 검색을 선형 탐색에서 이진 탐색으로 전환 접근 비용을 줄입니다
지표 변경 전 변경 후
100k행 기준 메모리 (RSS) 361MB 13.5MB
Node.js 읽기 오버헤드 (Rust 네이티브 대비) ~4%
GC 압력 100만+ 객체 생성 단일 Buffer 전송

벤치마크를 만들고 숫자를 고정하기

이 시점에 벤치마크 스위트를 만들었습니다. Node.js와 Rust 생태계의 기존 라이브러리들과 성능 지표를 비교하는 벤치마크입니다. 벤치마크 프로그램을 실행하면 결과를 마크다운 문서로 자동으로 출력해주며 여기에는 실행 환경, 반복 횟수 등을 같이 정리해두었습니다.

  • Node.js와 Rust에서의 비교(중앙값, 1 warmup + 5 runs): Apple M4 Pro, 24GB RAM / Node v25.3.0 / Rust 1.93.0
  • 테스트를 위한 스프레드시트 파일의 픽스처는 결정론적으로 생성되고 행 수에는 헤더 행이 포함됩니다
  • RSS/heapUsed는 피크(peak) 값이 아니라 작업 전후의 잔류(residual) 델타 값입니다

50,000행 x 20열 스프레드시트 파일을 기준으로 Node.js 바인딩에서 기본 읽기(getRows())는 541ms, 쓰기는 469ms가 소요되었습니다. 같은 워크로드에서 자바스크립트로 작성된 다른 라이브러리는 읽기에 1.24 ~ 1.56초, 쓰기에 1.09 ~ 2.62초가 소요되었습니다. 그리고 개선을 거치면서 heapUsed 증가분이 0MB로 찍히는 형태를 만들 수 있어서 자바스크립트 객체를 쌓지 않는다는 목표가 결과로 확인할 수 있었습니다.

결국 중요한 건 성능을 측정하고, 수치를 비교하고, 이상한 점이 보이면 원인을 끝까지 추적하는 과정이라고 생각합니다. 벤치마크를 돌렸을 때 Rust 생태계의 한 라이브러리(edit-xlsx)가 읽기에서 이상하게 빠른 결과를 보여주었는데, 이 시점에서는 원인을 알 수 없었습니다. 나중에 v0.5.0 작업 중 정확한 원인을 파악하게 되는데, 이 이야기는 해당 섹션에서 다루겠습니다.


화요일: 빠르게 기능 격차를 줄이다 (v0.4.0)

화요일(2월 10일)에는 v0.4.0을 올렸습니다. 이 릴리즈는 성능보다 부족한 기능을 채우는 것을 목표했습니다.

다른 엑셀 라이브러리들에는 있지만 SheetKit에 없는 기능이 무엇인지 비교하고 OOXML 스펙과 기대 동작을 다시 정리했습니다. 도형, 슬라이서, 양식 컨트롤, 메모, VBA 추출, CLI 같은 기능을 이때 한꺼번에 붙였습니다. 수식 함수도 추가로 늘렸습니다.

메모리 최적화도 계속되었습니다. 셀 구조체와 SST(Shared Strings Table)의 메모리 레이아웃을 개선해서, Node.js에서 동기 API로 읽었을 때 기준 RSS(Resident Set Size)가 349MB에서 195MB로 44% 감소했습니다. 비동기 읽기에서는 RSS가 17MB까지 내려갔습니다.

이 시점에서 문서도 웹 페이지로 관리하고 싶다는 생각이 들어서 VitePress를 활용해서 만들게 되었습니다.


그리고 오늘까지: 구조를 다시 생각하다 (v0.5.0)

이 글을 쓰는 2월 14일 오늘 저녁에 v0.5.0을 릴리즈했습니다.

이전까지는 라이브러리 API에 큰 breaking changes 없이 기능을 추가하고 최적화를 해왔다면, v0.5.0에선 Node.js API 구조를 재설계하고 Rust에서의 코어도 같이 바꾸는 작업이었습니다.

비동기, 그리고 지연 로딩을 기본으로

기존 open()을 통해 시트를 열면 호출 시점에 스프레드시트 파일 내 XML 파트를 한 번에 파싱했습니다. 그만큼 큰 파일을 열면 접근하지 않는 시트의 데이터까지 메모리에 한번에 올라갑니다. 그래서 v0.5.0에서는 읽기 모드를 세 가지로 나누게 되었습니다.

  • lazy(ReadMode::Lazy, 기본값): ZIP 인덱스/메타데이터만 읽고, 시트는 처음 접근할 때 파싱합니다
  • eager(ReadMode::Eager): 모든 시트를 즉시 파싱합니다
  • stream(ReadMode::Lazy): 제한된 메모리 안에서 순방향으로만 읽습니다

스트리밍 리더

대용량 파일에서 전체를 메모리에 올리지 않고 행 단위로 순차 처리할 수 있는 forward-only 리더입니다.

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) {
    // 한 번에 한 배치만 메모리에 존재합니다
  }
}

copy-on-write 저장

지연 로딩 모드로 열린 워크북을 저장할 때, 변경되지 않은 시트는 원본 ZIP 엔트리에서 직접 전달합니다. 파싱과 직렬화 왕복을 거치지 않기 때문에, 큰 워크북에서 일부 시트 / 일부 셀만 수정하는 워크로드에서 저장 시간이 줄어듭니다.

제가 실제로 겪는 템플릿 생성 시나리오("대부분은 그대로 두고 일부 셀만 채워서 내려주기")가 딱 이 케이스였고, 이게 v0.5.0에서 개선한 방향이었습니다.

edit-xlsx 라이브러리의 읽기 이상치와 벤치마크 비교 규칙

v0.3.0 이후로 벤치마크를 만들고 관리하면서 실행해보면 이상치가 나옵니다. Rust 비교 벤치마크에서 edit-xlsx가 읽기에서 비정상적으로 짧은 시간을 찍는 경우가 있었고, 자세히 들여다보니 rows/cells 카운트가 0으로 떨어지는 케이스가 섞여 있었습니다.

그래서 “비교 가능성 규칙(comparability rules)”을 도입했습니다.

  • rows / cells 카운트가 기대치와 맞는지 확인
  • 동일 좌표의 값 검증(value probe)이 맞는지 확인
  • 하나라도 어긋나면 결과를 비교할 수 없다고 표시

벤치마크는 숫자를 뽑는 도구이기도 하지만, 이상치를 잡아내는 도구이기도 합니다. 이 규칙을 넣고 나서부터는 “빠른데 뭔가 이상한 결과”를 자동으로 걸러낼 수 있게 됐습니다.

이 이후에 왜 이런 결과가 나왔을까 궁금해서 edit-xlsx 라이브러리를 분석하게 되었습니다. SpreadsheetML 규격에서 workbook.xmlfileVersion, workbookPr, bookViews는 선택 요소입니다. 하지만 이 라이브러리에서는 파싱 과정에서 이 요소들을 필수로 요구하고 있었습니다. 라이브러리에서 파싱과 역직렬화에 실패하면 기본 구조체로 대체되는데, 이 과정에서 rows와 cells의 수가 0이 나오고 매우 짧은 실행 시간을 기록하게 됩니다. 즉, 데이터를 실제로 읽지 않았기 때문에 빠른 것이었습니다.

그래서 SheetKit에서도 호환을 위해 파일을 저장할 때 workbook.xml에서 fileVersion, workbookPr 값이 아예 없을 경우에는 해당 값들에 대해 Microsoft Excel를 참고해 유사한 기본 값을 넣어주게 되었습니다.


바인딩을 거쳤는데 오히려 더 빠르다고?

Rust 라이브러리와 Node 바인딩을 같이 돌려보면 흥미로운 결과가 나오는 케이스가 있습니다. 일부 쓰기 시나리오에서 Node.js 바인딩이 Rust 네이티브보다 오히려 빠르다는 점입니다.

시나리오 Rust Node.js 오버헤드
50k행 x 20열 쓰기 544ms 469ms -14% (Node.js가 빠름)
20k행 텍스트 쓰기 108ms 86ms -20% (Node.js가 빠름)

왜 이런 결과가 나올 수 있었을까요? 내부적으로 SST 데이터를 구성하는 과정에서 V8의 문자열 인터닝과 메모리 관리가 효율적으로 작동한 결과입니다. napi 경계를 넘는 오버헤드보다 V8 엔진 자체의 최적화가 더 큰 이득을 준 셈입니다. Rust 위에 바인딩을 올리는 작업을 하면서, JavaScript 엔진의 최적화가 얼마나 정교한지 다시 한 번 느끼게 되었습니다.


SheetKit, 열심히 개밥먹기 중

저는 회사에서 SheetKit을 개밥먹기(dogfooding)하고 있습니다. 기존 라이브러리를 걷어내고 교체한 뒤에도, 템플릿 생성과 업로드 처리 플로우에서 필요한 기능들을 무리 없이 소화하고 있습니다.

SheetKit 프로젝트는 글을 쓰고 있는 2월 14일 오늘 기준, 다음과 같이 지원합니다.

  • Node.js와 Rust에서 스트리밍 읽기 / 쓰기
  • 164개의 다양한 수식 함수 지원
  • 43개의 다양한 차트 타입 지원
  • 다양한 이미지 포맷을 지원

Node.js - Rust간 오버헤드는 읽기 항목에서 ~ 4% 정도이며, 쓰기 시나리오에서는 케이스에 따라 오히려 Node.js가 빠른 결과를 가져온 케이스도 있었습니다. 자세한 내용은 문서 사이트에서 확인하실 수 있습니다.

SheetKit은 아직 개선할 점들이 있고 API도 변경될 수 있습니다. 하지만 실제로 적용해서 쓰면서 고치고, 성능을 측정하고 분석해서 고치는 방식은 계속 유지할 생각입니다. 궁금한 점이 있으면 편하게 물어봐주시고, 이슈와 PR 모두 환영합니다.


다음 글에서는...

이번 글에서는 일주일동안 어떻게 무엇을 만들었는지를 자세히 적어보았습니다. 다음 글에서는 Claude Code와 Codex 등 코딩 에이전트와 협업한 방식(작업의 워크플로우, 제약사항, 서브 에이전트 구조, 사람의 리뷰 전 별도 에이전트로 리뷰 후 피드백 루프를 만든 점, 사람이 어떻게 개입했는지)과 그 과정에서 느낀 점, 배운 점들을 더 구체적으로 적어보려고 합니다.

Read more →
6
1
1
0
1
0

@lifi리피 :ydg: :verified: 사고지역 근처를 가는 건... 어그로죠 어그로 :blobcatthink:

근데 사고 지역 근처면 몰라도 산맥 너머에 있고
행정구역상 후쿠시마현에 있는 사람들은
걱정할 수준은 아니라고 하더라구요.

하기야 후쿠시마도 신칸센이 지나는데
신칸센 타고 북쪽 가는 사람들이
다 영향권 안에 있는 것도 아니고(......)

0
1
2
1
1
0
0
0
0

저는 처음으로 일본을 간 게 2012년이었고
그 때 도쿄를 가고 싶었지만
도쿄는 아무래도
동일본대지진이 있은지 얼마 안되다보니
혼슈 쪽은 일단 불안해서

규슈로 갔었습니다...

0

87% of video games released in the U.S. before 2010 are technically unavailable for legal purchase.

As of 2026, libraries and archives can digitally preserve, but not digitally *share* games, and can provide on-premises access only. Libraries *are* allowed to share books, films, and music both onsite and remotely.

This is all very messed up.

0
0
0

Misskeyは個人開発です​:blob_bongo_cat_keyboard:
今後も開発を続けられるよう、よろしければMisskey Projectへのご寄付をお願いします
🙏🙏🙏
支援特典もございます
:ai_blink_nod:
https://misskey-hub.net/ja/docs/donate/

2

Jiwon shared the below article:

일주일만에 새로운 엑셀 라이브러리를 만들다

Haze @nebuleto@hackers.pub

저는 회사에서 주로 TypeScript로 Node.js 환경에서 개발을 하고 있습니다. 대량의 엑셀 데이터를 업로드해서 bulk 처리하는 기능이 필요했고, 그 데이터를 받기 위한 템플릿 엑셀 파일도 동적으로 생성해야 했습니다. 템플릿 안에는 데이터 유효성 검사와 조건부 서식, 드롭다운 같은 기능이 들어가야 했고요.

기존에 쓰던 Node.js 엑셀 라이브러리들은 각자 한계가 있었습니다. 하나는 커뮤니티 버전과 유료 버전이 분리되어 있어 기능 제약이 아쉬웠습니다. 다른 하나는 내부 구현과 TypeScript 타이핑 사이에 괴리가 있었고, 성능 문제로 인해 원하는 작업을 빠르게 처리하기 어려웠습니다. 저장소에는 PR이 쌓여 있었지만 더 이상 업데이트되지 않는 상태였습니다.

평소에 알고 있던 Go 생태계의 Excelize 프로젝트를 다시 들여다보았습니다. 차트, 조건부 서식, 수식, 데이터 검증처럼 OOXML 스펙의 큰 기능들을 잘 구현해둔 라이브러리였습니다. Excelize를 보면서 이 정도의 라이브러리를 TypeScript에서도 쓸 수 있으면 좋겠다는 생각이 들었습니다.

코딩 에이전트들의 역량은 계속 좋아지고 있다는 감각을 꾸준히 느꼈고, 제가 설계와 의사결정을 하되 모든 구현을 에이전트에게 위임하는 방식으로 만들어보면 어떨까하는 생각이 들었습니다. 저는 지난주 수요일(2월 4일)에 Excelize와 여러 엑셀 라이브러리들의 기능 목록과 구현 방식을 분석했고, 지난주 토요일(7일) 밤부터 실제 코드 작성에 들어갔습니다.

그렇게 SheetKit을 만들었습니다.

  • 저장소
  • 문서 (Getting Started)
  • 벤치마크 결과 (실행 환경, 실행 방법, 픽스처 포함)
    • Node.js 라이브러리간 비교
    • Rust 비교
    • 픽스처 정의

이 글은 두 파트로 구성됩니다. 지금 읽고 계신 글에서는 SheetKit 소개와 함께, 첫 릴리즈부터 오늘(2월 14일) 배포한 v0.5.0까지 1주일간의 개발 과정을 시간순으로 기록합니다. 다음 글에서는 코딩 에이전트와 어떻게 협업했는지, 어떤 작업에서 사람이 판단해야 했는지 같은 이야기를 더 구체적으로 다룰 예정입니다.


일주일간의 릴리즈 타임라인

표의 날짜는 crates.io와 npm 배포를 기준입니다. (정확한 타임스탬프보다 "무슨 일이 언제 있었는지"를 보여주기 위한 표입니다.)

버전 시점(상대) 날짜 핵심
v0.1.0 일요일(지난주) 2026-02-08 첫 배포(초기 형태)
v0.1.2 월요일 새벽(지난주) 2026-02-09 첫 공개로 부를 만한 스냅샷
v0.2.0 월요일 아침(지난주) 2026-02-09 Buffer I/O, 수식 헬퍼
v0.3.0 화요일 새벽(지난주) 2026-02-10 raw buffer FFI, 배치 API, 벤치마크 구축
v0.4.0 화요일 오후(지난주) 2026-02-10 기능 확장 + 문서 사이트
v0.5.0 토요일 아침(오늘) 2026-02-14 lazy loading / stream, COW save, 벤치마크 룰 개선

SheetKit은 어떤 라이브러리인가요?

SheetKit은 Rust로 작성된 스프레드시트(.xlsx, .xlsm 등의 OOXML 규격) 라이브러리입니다. Rust 코어 위에 napi-rs 기반 Node.js 바인딩이 올라가는 구조이고, Bun과 Deno와 같이 Node-API를 지원하는 다른 런타임에서도 그대로 쓸 수 있습니다.

이런 파일들은 OOXML(Office Open XML) 형식으로 내부적으론 ZIP 아카이브 안에 XML 파트들이 들어 있는 구조입니다. SheetKit은 이 ZIP 파일을 열어서 각 XML 파트를 Rust 구조체로 역직렬화하고, 조작한 뒤 다시 직렬화해서 저장합니다.

Rust 쪽은 세 개의 crate으로 나뉩니다.

  • sheetkit-xml: OOXML 스키마에 대응하는 저수준 XML 데이터 구조
  • sheetkit-core: 모든 비즈니스 로직
  • sheetkit: 라이브러리 사용자를 위한 facade crate

Node.js 바인딩은 packages/sheetkit에 있고, #[napi] 매크로로 Rust API를 JavaScript에 노출합니다.

바로 써보고 싶다면 Getting Started 문서가 가장 빠릅니다.


토요일 밤부터 첫 배포까지 (v0.1.x)

지난주 토요일(2월 7일) 밤에 코드 작성을 시작했고, 다음 날에 첫 배포(v0.1.0)를 찍었습니다. 그리고 9일 월요일 새벽에는 "이 정도면 우선 공개해볼 수 있겠다" 싶은 MVP(v0.1.2)를 만들었습니다.

수요일에 OOXML 스펙과 기존 라이브러리들의 기능 구현 방식을 먼저 분석하고 계획을 자세히 세워 코딩 에이전트에게 작업을 위임해서 짧은 시간 안에 형태를 잡을 수 있었습니다.

작업 방식은 단순했습니다.

  • 설계에 대한 판단과 구조에 대한 결정은 제가 합니다. 그 과정에서 필요한 분석 작업에도 Claude Code, Codex 등과 같은 코딩 에이전트의 도움을 받았습니다.
  • 구현은 코딩 에이전트에게 전적으로 위임합니다. 구현 전에 플랜을 매우 상세하게 세운 뒤, 메인 에이전트는 직접 작업을 하는 것이 아니라 Orchestrator 에이전트로서 각 기능 파트마다 서브 에이전트를 병렬로 돌리고 관리합니다.
  • 구현이 끝나면 별도의 에이전트를 통해 코드 리뷰를 거친 다음, 제가 직접 확인합니다.

다음 글에서는 이 방식이 실제로 어느 지점에서 잘 먹히고, 어느 지점에서 사람이 개입해야 했는지를 더 구체적으로 적을 예정입니다.


월요일: 성능을 생각하기 시작하다 (v0.2.0 ~ v0.3.0)

Buffer I/O (v0.2.0)

첫 릴리즈 이후 얼마 지나지 않은 월요일(9일) 아침에 v0.2.0을 올렸습니다.

핵심은 Buffer I/O였습니다. 파일 경로 없이 메모리 상의 버퍼로 .xlsx를 읽고 쓸 수 있게 하는 기능입니다. 서버 환경에서는 파일 시스템을 거치지 않고 HTTP 요청의 바이너리를 바로 처리하거나, 생성된 엑셀을 바로 응답으로 내려줘야 하는 경우가 많습니다. fill_formula 같은 수식 헬퍼도 이때 함께 넣었습니다.

Buffer I/O를 붙이고 나서부터 "실제 서비스에서 하던 일"과 비슷한 시나리오 테스트를 돌리기 시작했고, 여기서 진짜 병목을 만났습니다.

napi 경계의 오버헤드를 해결하기 위해 Raw Buffer로 전환 (v0.3.0)

초기에는 셀 단위로 자바스크립트 객체를 만들어 Rust와 자바스크립트 경계를 넘기는 형태로 시작했습니다. 50,000행 x 20열 같은 파일을 "행 단위 배열"로 한 번에 꺼내오면, 당연하지만 아주 많은 자바스크립트 객체가 만들어집니다. 이 구조는 GC 압력과 메모리 사용량을 빠르게 올립니다.

oxc 프로젝트에서 효율적으로 Rust로 빠르게 AST 데이터를 자바스크립트 영역으로 전달하는 방식에서 영감을 받아서 다음과 같이 방향을 바꿨습니다. (참고 문서)

  • 셀 단위 객체를 만들지 않습니다.
  • 전체 시트를 compact binary buffer로 직렬화합니다.
  • FFI 경계를 "한 번만" 넘깁니다.

또한 이 방식에는 시트 내 셀이 얼마나 있는지에 따라 dense / sparse 레이아웃을 자동으로 선택하는 방식도 같이 들어갔습니다. Buffer를 그대로 주고받기 때문에 TypeScript로도 한번 더 버퍼 규격에 맞는 파서를 작성하였습니다.

v0.3.0에서 첫 번째 버전의 버퍼 포맷을 구현했고, 이후 v0.5.0에서 지연 로딩과 인라인 string을 지원하는 새로운 포맷으로 개선했습니다.

또한 Rust의 XML을 처리하는 레이어에서도 함께 수정한 것들이 있습니다. "힙 할당을 줄이고, 자주 접근하는 경로를 단순하게 만든다"가 기준이었습니다.

변경 이유
셀 레퍼런스("A1")를 String 대신 고정 길이 인라인 배열로 저장 셀 레퍼런스는 최댓값이 정해져 있어서 힙을 쓰지 않아도 됩니다
타입 문자열을 1바이트 태그로 정규화 XML 속성 문자열을 그대로 들고 다니지 않게 합니다
행 내 셀 검색을 선형 탐색에서 이진 탐색으로 전환 접근 비용을 줄입니다
지표 변경 전 변경 후
100k행 기준 메모리 (RSS) 361MB 13.5MB
Node.js 읽기 오버헤드 (Rust 네이티브 대비) ~4%
GC 압력 100만+ 객체 생성 단일 Buffer 전송

벤치마크를 만들고 숫자를 고정하기

이 시점에 벤치마크 스위트를 만들었습니다. Node.js와 Rust 생태계의 기존 라이브러리들과 성능 지표를 비교하는 벤치마크입니다. 벤치마크 프로그램을 실행하면 결과를 마크다운 문서로 자동으로 출력해주며 여기에는 실행 환경, 반복 횟수 등을 같이 정리해두었습니다.

  • Node.js와 Rust에서의 비교(중앙값, 1 warmup + 5 runs): Apple M4 Pro, 24GB RAM / Node v25.3.0 / Rust 1.93.0
  • 테스트를 위한 스프레드시트 파일의 픽스처는 결정론적으로 생성되고 행 수에는 헤더 행이 포함됩니다
  • RSS/heapUsed는 피크(peak) 값이 아니라 작업 전후의 잔류(residual) 델타 값입니다

50,000행 x 20열 스프레드시트 파일을 기준으로 Node.js 바인딩에서 기본 읽기(getRows())는 541ms, 쓰기는 469ms가 소요되었습니다. 같은 워크로드에서 자바스크립트로 작성된 다른 라이브러리는 읽기에 1.24 ~ 1.56초, 쓰기에 1.09 ~ 2.62초가 소요되었습니다. 그리고 개선을 거치면서 heapUsed 증가분이 0MB로 찍히는 형태를 만들 수 있어서 자바스크립트 객체를 쌓지 않는다는 목표가 결과로 확인할 수 있었습니다.

결국 중요한 건 성능을 측정하고, 수치를 비교하고, 이상한 점이 보이면 원인을 끝까지 추적하는 과정이라고 생각합니다. 벤치마크를 돌렸을 때 Rust 생태계의 한 라이브러리(edit-xlsx)가 읽기에서 이상하게 빠른 결과를 보여주었는데, 이 시점에서는 원인을 알 수 없었습니다. 나중에 v0.5.0 작업 중 정확한 원인을 파악하게 되는데, 이 이야기는 해당 섹션에서 다루겠습니다.


화요일: 빠르게 기능 격차를 줄이다 (v0.4.0)

화요일(2월 10일)에는 v0.4.0을 올렸습니다. 이 릴리즈는 성능보다 부족한 기능을 채우는 것을 목표했습니다.

다른 엑셀 라이브러리들에는 있지만 SheetKit에 없는 기능이 무엇인지 비교하고 OOXML 스펙과 기대 동작을 다시 정리했습니다. 도형, 슬라이서, 양식 컨트롤, 메모, VBA 추출, CLI 같은 기능을 이때 한꺼번에 붙였습니다. 수식 함수도 추가로 늘렸습니다.

메모리 최적화도 계속되었습니다. 셀 구조체와 SST(Shared Strings Table)의 메모리 레이아웃을 개선해서, Node.js에서 동기 API로 읽었을 때 기준 RSS(Resident Set Size)가 349MB에서 195MB로 44% 감소했습니다. 비동기 읽기에서는 RSS가 17MB까지 내려갔습니다.

이 시점에서 문서도 웹 페이지로 관리하고 싶다는 생각이 들어서 VitePress를 활용해서 만들게 되었습니다.


그리고 오늘까지: 구조를 다시 생각하다 (v0.5.0)

이 글을 쓰는 2월 14일 오늘 저녁에 v0.5.0을 릴리즈했습니다.

이전까지는 라이브러리 API에 큰 breaking changes 없이 기능을 추가하고 최적화를 해왔다면, v0.5.0에선 Node.js API 구조를 재설계하고 Rust에서의 코어도 같이 바꾸는 작업이었습니다.

비동기, 그리고 지연 로딩을 기본으로

기존 open()을 통해 시트를 열면 호출 시점에 스프레드시트 파일 내 XML 파트를 한 번에 파싱했습니다. 그만큼 큰 파일을 열면 접근하지 않는 시트의 데이터까지 메모리에 한번에 올라갑니다. 그래서 v0.5.0에서는 읽기 모드를 세 가지로 나누게 되었습니다.

  • lazy(ReadMode::Lazy, 기본값): ZIP 인덱스/메타데이터만 읽고, 시트는 처음 접근할 때 파싱합니다
  • eager(ReadMode::Eager): 모든 시트를 즉시 파싱합니다
  • stream(ReadMode::Lazy): 제한된 메모리 안에서 순방향으로만 읽습니다

스트리밍 리더

대용량 파일에서 전체를 메모리에 올리지 않고 행 단위로 순차 처리할 수 있는 forward-only 리더입니다.

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) {
    // 한 번에 한 배치만 메모리에 존재합니다
  }
}

copy-on-write 저장

지연 로딩 모드로 열린 워크북을 저장할 때, 변경되지 않은 시트는 원본 ZIP 엔트리에서 직접 전달합니다. 파싱과 직렬화 왕복을 거치지 않기 때문에, 큰 워크북에서 일부 시트 / 일부 셀만 수정하는 워크로드에서 저장 시간이 줄어듭니다.

제가 실제로 겪는 템플릿 생성 시나리오("대부분은 그대로 두고 일부 셀만 채워서 내려주기")가 딱 이 케이스였고, 이게 v0.5.0에서 개선한 방향이었습니다.

edit-xlsx 라이브러리의 읽기 이상치와 벤치마크 비교 규칙

v0.3.0 이후로 벤치마크를 만들고 관리하면서 실행해보면 이상치가 나옵니다. Rust 비교 벤치마크에서 edit-xlsx가 읽기에서 비정상적으로 짧은 시간을 찍는 경우가 있었고, 자세히 들여다보니 rows/cells 카운트가 0으로 떨어지는 케이스가 섞여 있었습니다.

그래서 “비교 가능성 규칙(comparability rules)”을 도입했습니다.

  • rows / cells 카운트가 기대치와 맞는지 확인
  • 동일 좌표의 값 검증(value probe)이 맞는지 확인
  • 하나라도 어긋나면 결과를 비교할 수 없다고 표시

벤치마크는 숫자를 뽑는 도구이기도 하지만, 이상치를 잡아내는 도구이기도 합니다. 이 규칙을 넣고 나서부터는 “빠른데 뭔가 이상한 결과”를 자동으로 걸러낼 수 있게 됐습니다.

이 이후에 왜 이런 결과가 나왔을까 궁금해서 edit-xlsx 라이브러리를 분석하게 되었습니다. SpreadsheetML 규격에서 workbook.xmlfileVersion, workbookPr, bookViews는 선택 요소입니다. 하지만 이 라이브러리에서는 파싱 과정에서 이 요소들을 필수로 요구하고 있었습니다. 라이브러리에서 파싱과 역직렬화에 실패하면 기본 구조체로 대체되는데, 이 과정에서 rows와 cells의 수가 0이 나오고 매우 짧은 실행 시간을 기록하게 됩니다. 즉, 데이터를 실제로 읽지 않았기 때문에 빠른 것이었습니다.

그래서 SheetKit에서도 호환을 위해 파일을 저장할 때 workbook.xml에서 fileVersion, workbookPr 값이 아예 없을 경우에는 해당 값들에 대해 Microsoft Excel를 참고해 유사한 기본 값을 넣어주게 되었습니다.


바인딩을 거쳤는데 오히려 더 빠르다고?

Rust 라이브러리와 Node 바인딩을 같이 돌려보면 흥미로운 결과가 나오는 케이스가 있습니다. 일부 쓰기 시나리오에서 Node.js 바인딩이 Rust 네이티브보다 오히려 빠르다는 점입니다.

시나리오 Rust Node.js 오버헤드
50k행 x 20열 쓰기 544ms 469ms -14% (Node.js가 빠름)
20k행 텍스트 쓰기 108ms 86ms -20% (Node.js가 빠름)

왜 이런 결과가 나올 수 있었을까요? 내부적으로 SST 데이터를 구성하는 과정에서 V8의 문자열 인터닝과 메모리 관리가 효율적으로 작동한 결과입니다. napi 경계를 넘는 오버헤드보다 V8 엔진 자체의 최적화가 더 큰 이득을 준 셈입니다. Rust 위에 바인딩을 올리는 작업을 하면서, JavaScript 엔진의 최적화가 얼마나 정교한지 다시 한 번 느끼게 되었습니다.


SheetKit, 열심히 개밥먹기 중

저는 회사에서 SheetKit을 개밥먹기(dogfooding)하고 있습니다. 기존 라이브러리를 걷어내고 교체한 뒤에도, 템플릿 생성과 업로드 처리 플로우에서 필요한 기능들을 무리 없이 소화하고 있습니다.

SheetKit 프로젝트는 글을 쓰고 있는 2월 14일 오늘 기준, 다음과 같이 지원합니다.

  • Node.js와 Rust에서 스트리밍 읽기 / 쓰기
  • 164개의 다양한 수식 함수 지원
  • 43개의 다양한 차트 타입 지원
  • 다양한 이미지 포맷을 지원

Node.js - Rust간 오버헤드는 읽기 항목에서 ~ 4% 정도이며, 쓰기 시나리오에서는 케이스에 따라 오히려 Node.js가 빠른 결과를 가져온 케이스도 있었습니다. 자세한 내용은 문서 사이트에서 확인하실 수 있습니다.

SheetKit은 아직 개선할 점들이 있고 API도 변경될 수 있습니다. 하지만 실제로 적용해서 쓰면서 고치고, 성능을 측정하고 분석해서 고치는 방식은 계속 유지할 생각입니다. 궁금한 점이 있으면 편하게 물어봐주시고, 이슈와 PR 모두 환영합니다.


다음 글에서는...

이번 글에서는 일주일동안 어떻게 무엇을 만들었는지를 자세히 적어보았습니다. 다음 글에서는 Claude Code와 Codex 등 코딩 에이전트와 협업한 방식(작업의 워크플로우, 제약사항, 서브 에이전트 구조, 사람의 리뷰 전 별도 에이전트로 리뷰 후 피드백 루프를 만든 점, 사람이 어떻게 개입했는지)과 그 과정에서 느낀 점, 배운 점들을 더 구체적으로 적어보려고 합니다.

Read more →
6
1
0
0
0

Jiwon shared the below article:

Building a New Excel Library in One Week

Haze @nebuleto@hackers.pub

These days I work primarily in TypeScript on Node.js. I needed to handle bulk uploads of large Excel data and dynamically generate template Excel files to collect that data. Those templates had to include data validation, conditional formatting, dropdowns, and so on.

The existing Node.js Excel libraries each had problems. One split its functionality between a community edition and a paid edition, which meant features I needed were locked away. The other had a gap between its internal implementation and its TypeScript typings, and it was too slow for what I was trying to do. Pull requests had piled up in the repository, but the project was no longer being maintained.

I had known about Excelize, the Go library, for a while. Charts, conditional formatting, formulas, data validation: it covers a lot of the OOXML spec and does it well. I kept thinking I wanted something at that level in TypeScript.

Coding agents have gotten noticeably better in the past year or so, and I wanted to try a specific way of working: I make all the design and architecture decisions, and agents handle the implementation. On Wednesday of last week (February 4th) I started analyzing Excelize and other Excel libraries. By Saturday night (February 7th) I was writing code.

That's SheetKit.

  • Repository
  • Documentation (Getting Started)
  • Benchmark results (environment, methodology, fixtures included):
    • Node.js library comparison
    • Rust comparison
    • Fixture definitions

This is the first of two posts. This one covers what SheetKit is and how the week went, from first release to the v0.5.0 I shipped this evening (February 14th). The second post will be about working with coding agents: what I delegated, how, and where it broke down.


Release Timeline

Dates are crates.io / npm publish timestamps. Approximate, not to-the-minute.

Version When Date What
v0.1.0 Sunday (last week) 2026-02-08 First publish (initial form)
v0.1.2 Monday early morning (last week) 2026-02-09 First snapshot worth calling a public release
v0.2.0 Monday morning (last week) 2026-02-09 Buffer I/O, formula helpers
v0.3.0 Tuesday early morning (last week) 2026-02-10 Raw buffer FFI, batch APIs, benchmark suite
v0.4.0 Tuesday afternoon (last week) 2026-02-10 Feature expansion + documentation site
v0.5.0 Saturday evening (today) 2026-02-14 Lazy loading / streaming, COW save, benchmark rule improvements

What Is SheetKit?

SheetKit is a Rust spreadsheet library for OOXML formats (.xlsx, .xlsm, etc.) with Node.js bindings via napi-rs. Bun and Deno work too, since they support Node-API.

.xlsx files are ZIP archives containing XML parts. SheetKit opens the ZIP, deserializes each XML part into Rust structs, lets you manipulate them, and serializes everything back on save.

Three crates on the Rust side:

  • sheetkit-xml: Low-level XML data structures mapping to OOXML schemas
  • sheetkit-core: All business logic
  • sheetkit: Facade crate for library consumers

Node.js bindings live in packages/sheetkit and expose the Rust API via #[napi] macros.

To get started: sheetkit.dev/getting-started.


Saturday Night to First Release (v0.1.x)

I started coding Saturday night (February 7th) and pushed v0.1.0 the next day. By early Monday morning I had v0.1.2, which was the first version I'd actually call releasable.

I had spent Wednesday analyzing the OOXML spec and how existing libraries implemented features, so by Saturday I had a detailed plan ready. I handed implementation to coding agents (Claude Code and Codex). The setup was: a main orchestrator agent receives the plan, then spawns sub-agents in parallel for each feature area. It burns through tokens fast, but it gets a large plan done quickly. After the agents finish, a separate agent does code review before I look at it.

More on this workflow in the next post.

v0.1.2 was an MVP. It had 44,000+ lines, 1,533 tests, 110 formula functions, charts, images, conditional formatting, data validation, StreamWriter, and builds for 8 platform targets. But it could only read/write via file paths (no Buffer I/O), and I hadn't measured performance at all. It worked, but that was about it.


Monday: Starting to Think About Performance (v0.2.0 – v0.3.0)

Buffer I/O (v0.2.0)

v0.2.0 went up Monday morning, a few hours after v0.1.2.

I added Buffer I/O: read and write .xlsx directly from in-memory buffers, no filesystem needed. In a server you're usually processing binary from an HTTP request or streaming a generated file back in the response, so this had to come early. fill_formula and other formula helpers went in at the same time.

With Buffer I/O in place I could run tests closer to real production workloads. That's where the problems showed up.

Switching to Raw Buffers (v0.3.0)

The initial implementation created a JS object per cell and passed it across the Rust/JS FFI boundary. Pull a 50k×20 sheet as a row array and that's a million-plus JS objects. GC pressure and memory usage went through the roof.

I got the idea from oxc, which transfers Rust AST data to JS as raw buffers instead of object trees. Same principle here:

  • Don't create per-cell objects.
  • Serialize the entire sheet into a compact binary buffer.
  • Cross the FFI boundary once.

The encoder picks dense or sparse layout automatically based on cell occupancy (threshold: 30%). Since the JS side receives a raw buffer, I also wrote a TypeScript parser for the format.

v0.3.0 shipped the first version of this buffer protocol. v0.5.0 later replaced it with a v2 format that supports inline strings and incremental row-by-row decoding.

I also made changes in the Rust XML layer. The goal was fewer heap allocations and simpler hot paths.

Change Why
Cell references ("A1") stored as [u8; 10] inline arrays, not heap Strings Max cell ref is "XFD1048576" (10 bytes). No need for the heap.
Cell type attribute normalized to a 1-byte enum Stops carrying raw XML attribute strings around
Binary search for cells within a row, replacing linear scan
Metric Before After
Memory (RSS) at 100k rows 361 MB 13.5 MB
Node.js read overhead vs. native Rust ~4%
GC pressure 1M+ object creations Single buffer transfer

Benchmarks

This is when I built the benchmark suite, comparing SheetKit against existing Node.js and Rust libraries. The runner outputs Markdown with environment info, iteration counts, and raw numbers.

Setup: Apple M4 Pro, 24 GB / Node v25.3.0 / Rust 1.93.0. Median of 5 runs after 1 warmup. RSS/heapUsed are residual deltas (before vs. after), not peaks. Fixtures are generated deterministically; row counts include the header.

50k rows × 20 columns: SheetKit read 541 ms, write 469 ms. The JS-only libraries: 1.24–1.56s read, 1.09–2.62s write. heapUsed delta: 0 MB, which confirmed that the JS side was no longer accumulating objects.

One odd thing: edit-xlsx, a Rust library, was showing suspiciously fast read times. I didn't understand why at this point. The explanation came during the v0.5.0 work (covered below).


Tuesday: Closing Feature Gaps (v0.4.0)

v0.4.0 shipped Tuesday afternoon. This one was about features, not performance.

I went through what other Excel libraries supported and listed what SheetKit was still missing. Shapes, slicers, form controls, threaded comments, VBA extraction, a CLI. I also added 54 more formula functions (total: 164), mostly financial and engineering.

Same orchestrator/sub-agent setup as before: write a detailed plan for each feature, have the agents implement in parallel, agent review first, then my review.

Memory optimization continued on the side. Reworking the Cell struct and SST memory layout cut RSS from 349 MB to 195 MB for sync reads (44% drop). Async reads: 17 MB.

I also set up a VitePress documentation site around this time.


Today: Rethinking the Architecture (v0.5.0)

v0.5.0 went out this evening. Unlike the previous releases, which added features on top of the same API shape, this one changed the Node.js API structure and parts of the Rust core.

Lazy Loading by Default

Before v0.5.0, open() parsed every XML part upfront. Open a 50k-row file and all sheets load into memory, even the ones you never touch. Now there are three read modes:

  • lazy (default): reads ZIP index and metadata only. Sheets parse on first access.
  • eager: the old behavior. Parse everything immediately.
  • stream: forward-only, bounded memory.

Lazy open costs less than 30% of eager, and pre-access memory is under 20% of eager. Auxiliary parts (comments, charts, images, pivot tables) also defer parsing until you actually call a method that needs them.

Streaming Reader

Forward-only reader for large files. One batch in memory at a time.

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) {
    // process
  }
}

Copy-on-Write Save

When you save a lazily-opened workbook, unchanged sheets pass through directly from the original ZIP entry. No parse-serialize round trip. At work I generate files by opening a template, filling in a few cells, and sending it back. That's exactly the workload this helps.

The edit-xlsx Read Anomaly

Back when I built the benchmarks, edit-xlsx was recording very fast read times on some files. Rows/cells count was dropping to zero.

I added comparability rules to the benchmark:

  • Check that rows/cells count matches expectations
  • Value-probe a few cells at known coordinates
  • If either fails, mark the result non-comparable

Then I dug into why. In SpreadsheetML, fileVersion, workbookPr, and bookViews in workbook.xml are optional. edit-xlsx 0.4.x treats them as required. When deserialization fails on a file missing these elements, it falls back to a default struct: rows=0, cells=0, near-zero runtime. It was fast because it wasn't reading anything.

SheetKit now writes default values for fileVersion and workbookPr (matching Excel's own defaults) when they're absent, for compatibility.


Node.js Bindings Faster Than Native Rust?

In some write scenarios, the Node.js bindings beat native Rust.

Scenario Rust Node.js Overhead
Write 50k rows × 20 cols 544 ms 469 ms −14% (Node.js faster)
Write 20k text-heavy rows 108 ms 86 ms −20% (Node.js faster)

This happens because V8 is very good at string interning and memory management when building SST data through the batch API (setSheetData). The napi crossing costs less than what V8 saves. I did not expect to see negative overhead, but here we are.


Dogfooding SheetKit

I replaced our previous library with SheetKit at work. Template generation and bulk upload processing have been running fine.

Where it stands today (February 14th):

  • Streaming read/write in both Node.js and Rust
  • 164 formula functions
  • 43 chart types
  • Multiple image formats

Read overhead (Node.js vs. Rust): ~4%. Some write scenarios are faster from Node.js. Details at sheetkit.dev.

The library is still experimental and APIs may change. I'll keep using it in production, measuring, and fixing things as they come up. Issues and PRs are always welcome.


Next Post

This covered the what and when. The next post is about the how: orchestrator/sub-agent structure, how I used Claude Code and Codex, the agentic code review loop, where I had to step in, and what I'd do differently.

Read more →
4
0
0
0

"アメリカでは昨年、2人の子供を含む3人が麻疹(はしか)で死亡した。本来であれば避けられたはずの死だ。アメリカの昨年のはしか感染者数は2276人に上り、2024年の285人に比べ8倍近くに膨れ上がった。この感染急増も防ぎ得たはずだ。ではなぜ回避できなかったのか。"
https://www.newsweekjapan.jp/stories/world/2026/02/587369.php

0

여러분, 블스 등의 다크 모드의 역사가 생각보다 오래되었다는 걸 아시나요? 검은 기도서라고 수백년 전에도 사람들은 검은 것은 글씨요, 흰 것은 종이에 질려서 검은 바탕에 흰 글씨 기도서를 만들었다!ㅋㅋㅋㅋ 물론 그 당시에 지금의 우리처럼 밤에 더 잘보려고는 아니었겠죠.....역시 플렉스였겠죠? en.wikipedia.org/wiki/Black_b...

Black books of hours - Wikiped...

0
0
0
0

살아가면서 정말 자주 느끼게 되는데, 회색의 영역을 인정하는 감각이 사람들에게 반드시 필요하다고 생각한다. 내 적의 친구가 자동으로 내 적인 것은 아니고 내 친구의 친구 또한 자동으로 내 좋은 친구가 되는 것은 아니다. 흑과 백이 서로 뭉친다 한들 그 경계가 흐려지는 구간이 있음을 인지해야한다. 그걸 흑백 중 하나로 억지로 분별하려고 하다가 이 세상에 온갖 실패와 분쟁과 반목이 태어나곤 했다.

1
1
1
1
1
2
0

I'm not saying that the US is a racist country. But I am saying that the highest paid government employee in most states is a college sports coach🤡, and that college sports is a racist system that exploits poor Black kids in a way that would be wildly illegal in most countries.

🤔OK, I am saying that.

Map showing that the highest paid government employee in most states is a college football or college basketball coach.
0
0
0
1
0
1
0
0
0

Anyone in land able to get working with integrated graphics in ?

I can run vkcube fine and it shows the spinny cube and:

Selected GPU 0: Intel(R) Graphics (ADL GT2), type: Integrated GPU

But doing 'winetricks dxvk' and enabling Vulkan with export WINE_D3D_CONFIG="renderer=vulkan" when I run it, it complains it doesn't recognize the card or Vulkan setup.

Anyone have any ideas I can try?
(boosts welcome)

wine-devel 11.0,1
vulkan-loader-1.4.336
vulkan-headers-1.3.336
drm-66-kmod-6.6.25.1500068_8

In the .i386-wine-pkg it's got mesa and vulkan-loader in there.

0
0
1
헉 무지개 서버에 커모지 없다고 앵앵거린지 한참 됐는데
드디어 추가해주셨구나 감사합니다

:ko_stamp_goodjob:​​:ohayoo:​​:ohayo:​​:kr_goodmorning:​​:ko_nyanpuppu:​​:ko_leavework:​​:ko_gwiyeoweoyo:​​:ko_goodmorning:​​:ko_beepboop:​​:ko_cute:​​:blobcatresonyance:

근데 이게 끝이야(?)
1
0
0

병리적 자기애는 ‘타인의 시선을 통해 내가 존재한다’라면, 건강한 자기애는 ‘나는 나로서, 타인은 타인으로서 구별되어 존재한다’고 할 수 있다. 내가 나에게 필요한 말을 스스로 해줄 수 있다면, 그것만으로도 나는 나에게 꽤 든든한 내 편이 될 수 있다. 쉽지 않더라도, 내가 경험했던 좋은 ‘자기대상’을 내 안으로 옮겨오는 일은 의미있는 시도가 될 것이다.

“나 괜찮은 사람이죠?” 칭찬 없이는 못 사는 사람들

0