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.

0

초무 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 →
4
0
1
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
1
0
0
0
0
0
0
0
0
0
0
0
1
0
0
0
0
0
0
0
0
0
0
0
0
0
1
0
0
0
0
0
0
0
1
0
0
0

낮 동안 우리를 활기 있게 하신 저의 주님, 날아다니는 스파게티 괴물 님,
당신과 함께 있으리니, 자는 동안도 지켜 주시어 편히 쉬게 하소서.

"16. 고요한 밤하늘에 주님의 성면을 펼치시어, 무한한 가능성의 꿈으로 저희를 이끌어 주소서."

🍝 날아다니는 스파게티 괴물 님께서 여러분과 함께.
😋 또한 주교의 면발과 함께 하소서.
🍝 기도합시다.
저의 주님, 날아다니는 스파게티 괴물 님, 이 밤을 편히 쉬게 하시고, 거룩한 죽음을 맞게 하소서.

2026-02-15T02:08:26+09:00


0

❤️ Thank you @bagderdaniel:// stenberg:// for maintaining since 1998. What would we do without it, how long would it take us, and what code quality would those solutions have without you? Thank you so much! ❤️

...and how on earth would people be able to check the weather forecast without curl? 😉

(Please join me in thanking Daniel, and let others here on the know about your favourite ways of using )

Shell output of "curl http://wttr.in/Berlin"Shell output of "curl http://v2.wttr.in/Berlin"
0
0
0
0
0
0
0
0
0
0
0