본문으로 건너뛰기

워크북 읽기 — 객체 모델에서 스트리밍으로

문서 목록으로

엑셀 워크북을 읽는 엔진을 교체하는 설계입니다. 임포터가 만드는 것(RawSheet 격자)과 그 아래 전부는 그대로 두고, 셀을 어디에서 가져오는가만 바꿉니다.

바뀌는 것은 성능이지 동작이 아닙니다. 그래서 이 문서에서 가장 중요한 절은 설계가 아니라 5절 — 값이 같다는 것을 무엇으로 판정하는가입니다.


1. 현재 리더의 비용

XSSFWorkbook은 워크북 전체를 객체 모델로 올립니다. 실측입니다 — 샘플 워크북 29개 (변환 산출물 폴더 12개 + 이전 소규모 코퍼스 17개)를 셀까지 전부 읽는 데 드는 비용입니다.

무엇현재 (NPOI XSSFWorkbook)
29개 합계67.9초
최악 peak6,993 MB

peak을 정하는 것은 가장 큰 워크북 하나입니다(워크북마다 놓고 다음으로 갑니다). 그 하나는 61 MiB · 140시트 · 비어있지 않은 셀 4,903,333개이고, 그것만으로 27.8초 · 6,982 MB입니다.

「61 MiB가 2.2 GB」로 적혀 있던 이전 측정은 정의된 이름이 덮지 않는 시트를 건너뛴 뒤의 숫자였습니다. 리더 자체의 비용은 위의 값입니다.

변환 한 번의 peak — 세 갈래

위의 6,993 MB는 리더만의 값입니다. 변환 실행 전체로 재면 9.4 GB였고, 원인의 비중이 짐작과 달라서 고칠 것의 순서도 달라졌습니다.

무엇얼마
NPOI가 워크북을 DOM으로 올리는 것3.4 GB — 시트를 처음 만질 때 펼치므로, 파일을 연 직후에 재면 790 MB로 보입니다
셀마다 파일 경로 문자열을 새로 만들던 것1.6 GB — 고쳤습니다
우리 격자(RawCell 640만 개)~0.9 GB

가운데 것은 결함이었습니다. Location.Filename의 세터가 \/로 바꾸는데 .NET의 디렉터리 순회가 주는 경로에는 그것이 들어 있어서, 셀마다 같은 경로의 사본이 하나씩 생겼습니다 — 640만 개, 전부 같은 100여 글자. 임포터에서 한 번만 정규화해 인스턴스를 공유하게 했습니다.

첫째 것이 이 문서의 교체로 없어졌습니다. 349시트 전부를 읽는 실행의 peak이 2,757 MB이고, 남은 것은 우리 격자입니다. 산출물은 골든과 실제 프로젝트 샘플 전부 무변경입니다.

다음에 줄일 것이 있다면 격자 쪽이고, 이름이 가리키는 사각형만 격자로 만드는 결정이 그 자리에서 만납니다.

2. 로드맵의 계획이 성립하지 않는 이유

이 교체 전의 계획은 「NPOI 2.8에 이벤트 모델이 이미 있으므로 라이브러리를 바꾸지 않고 읽기 경로만 옮길 수 있다」였습니다 (레이아웃 분석). 실행되지 않습니다.

ReadOnlySharedStringsTable이 압축된 패키지 파트에 Length를 호출해 NotSupportedException을 냅니다. 실제 xlsx는 sharedStrings.xml이 항상 압축되어 있으므로 이 경로는 어떤 워크북에서도 열리지 않고, NPOI 자신의 XSSFEventBasedExcelExtractor.Text도 같은 지점에서 같은 예외로 실패합니다. 2.7.6과 2.8.0 둘 다에서 재현했습니다.

올릴 버전도 없습니다 — 2.8.0(2026-04-06)이 최신이고 이미 그것을 쓰고 있습니다.

부수적으로 두 가지가 더 확인되었습니다. XSSFSheetXMLHandler는 파싱 메서드가 전부 private이라 구동자가 저 추출기 하나뿐이고, 그 핸들러가 주는 값은 DataFormatter가 만든 표시 문자열이라 우리가 읽는 원값과 애초에 다릅니다.

3. 후보와 실측

같은 워크북 29개, 같은 렌더링 규칙입니다. 「값 게이트」는 5절의 해시 대조입니다.

후보만든 곳라이선스값 게이트29개 합계최악 peak
NPOI XSSFWorkbookApache POI 포팅Apache 2.0기준67.9초6,993 MB
Sylvan.Data.Excel 0.5.8Mark Pflug (개인)MIT29/2910.9초126 MB
ExcelDataReader 3.9커뮤니티MIT29/2918.4초~70 MB
Open XML SDK 3.5.1 + 자체 리더MicrosoftMIT27/2929.3초164 MB
EPPlus 8.6.3EPPlus Software ABPolyform NC (유료)미측정2,059 MB

EPPlus는 절충안이 아닙니다. 상업 사용이 유료이고, .xlsb를 열지 못하며(직접 확인: XmlException), 무엇보다 여전히 객체 모델입니다 — 위 peak은 61 MiB 워크북 하나에 대한 것이라 195 MiB 규모에서는 다시 GB 대가 됩니다. 벽을 옮기는 것이지 없애는 것이 아닙니다.

Open XML SDK는 진짜 마이크로소프트 제품이고 공급망이 가장 안전합니다. 다만 스프레드시트 리더가 아니라 형식 라이브러리라, 공유 문자열 해석·스타일에서의 날짜 판정·수식의 캐시된 결과·오류 셀을 전부 우리가 씁니다. 약 160줄을 써서 29개 중 27개를 통과하였고, 실패한 둘의 원인은 하나였습니다 — _x000D_. OOXML은 공유 문자열 안의 캐리지 리턴을 그 escape로 적는데, 그것을 문자 그대로 통과시켜 대사 셀 235개와 아이템 설명 6개에 _x000D_가 들어갔습니다.

그 실패의 형태가 이 선택의 근거입니다. 예외도 없고 셀 개수도 맞는데 텍스트가 틀립니다. 그리고 _x000D__xHHHH_ 계열의 하나일 뿐입니다.

4. 고른 것 — Sylvan, 그리고 그 이유

Sylvan이 가장 빠르고 메모리가 가장 적은 것은 결과일 뿐이고, 근거는 셋입니다.

근거
우리 데이터에서 이미 옳습니다. 워크북 29개 · 약 1,000만 셀에서 현재 렌더링과 값이 한 글자도 다르지 않습니다. 우리가 새로 쓰는 코드에는 이 보증이 없습니다 — 3절이 그 증거입니다
.xlsb를 읽는 유일한 후보입니다. 이번 범위에서 쓰지 않지만, 지금 다른 것을 고르면 그 문을 닫습니다
교체 지점이 한 파일입니다. src/에서 NPOI를 참조하는 파일은 XlsxImporter.cs 하나뿐입니다

약점은 공급망입니다 — 0.5.8로 1.0 이전이고, 관리자가 개인이며, 누적 다운로드가 120만입니다 (비교: Open XML SDK 4억 1,000만, ExcelDataReader 1억 900만). 그 약점을 감수하는 근거가 위의 세 번째 줄입니다. 리더가 한 파일 뒤에 있고 5절의 게이트가 있으므로, 갈아타는 것이 다시 한 파일입니다. 그리고 갈아탈 곳이 이미 검증되어 있습니다 — ExcelDataReader가 29/29입니다.

5. 판정 기준 — 값 해시 게이트

이 교체에서 「되었다」의 정의입니다. 골든과 ModelFingerprint는 그 위에 얹히는 것이고, 리더 layer에서는 이것이 판정합니다.

워크북 하나를 두 리더로 읽고 다음 넷을 대조합니다.

재는 것
비어있지 않은 셀 개수덜 읽고 빨라진 것을 배제합니다
값의 문자 총량같은 개수를 다르게 렌더한 것을 드러냅니다
값의 FNV-1a 해시 (행 우선 순서)위 둘이 같아도 값이 다른 경우를 검출합니다
시트 개수시트를 건너뛴 것을 배제합니다

해시는 string.GetHashCode가 아니라 FNV-1a입니다 — 전자는 프로세스마다 값이 달라 두 실행을 비교할 수 없습니다.

이 게이트는 이미 한 번 일했습니다. 3절의 _x000D_를 검출한 것이 이것입니다.

6. 값을 같게 만드는 규칙 하나 — 날짜

교체에서 조정이 필요한 자리는 정확히 하나였습니다.

Sylvan은 날짜 서식이 붙은 숫자 셀을 ISO 8601로 렌더합니다 — 2021-12-29, 2021-12-29T23:59:59. 시각도 보존합니다. 우리는 yyyy-MM-dd HH:mm:ss로 적어 왔으므로 그것만 맞춰야 합니다. GetExcelDataType은 이런 셀에도 Numeric을 답하므로(저장 타입이 숫자이기 때문입니다) 날짜 여부를 직접 확인하는 수단이 없습니다.

판별은 「숫자로 저장되어 있는데 ISO로 읽힌다」입니다.

Numeric 이고 GetString()이 ISO 날짜 형태이면 → GetDateTime()을 우리 형식으로
그 밖의 Numeric → GetDouble().ToString("R")

두 방향 모두 안전합니다. 평범한 수는 ISO처럼 보이지 않고, 텍스트로 2021-12-29가 적힌 셀은 타입이 String으로 오므로 이 분기에 들어오지 않습니다.

스키마를 주는 방법을 쓰지 않았습니다. Sylvan은 컬럼 타입을 미리 알려줄 수 있지만, 임포터는 타입을 모르는 layer입니다 — 타입 행을 해석하는 것은 그 아래의 쿠커입니다. 임포터가 타입을 알아야 하게 만드는 것은 이 교체가 건드리지 말아야 할 경계입니다.

이 규칙을 넣은 뒤 29개 전부가 게이트를 통과했습니다. 규칙이 필요하였던 셀은 두 워크북에 19,685개였고 전부 날짜였습니다.

7. Sylvan이 내지 않는 것 셋

필요한 것어떻게
셀 노트 (doc 주석이 됩니다)지금은 읽지 않습니다 — 아래
워크북 정의된 이름 (이름을 테이블 경계로 쓰는 레이아웃)xl/workbook.xml을 직접 읽습니다. 25 KB이고 2 ms입니다
수식 오류 (OnFormulaError)Sylvan이 냅니다 — GetFormulaErrorExcelErrorCode가 7종 전부입니다. GetErrorAsNull을 켜지 않아야 오류가 오류로 옵니다

정의된 이름을 NPOI가 보는 것과 대조했습니다. 29개 워크북 전부 차이 0건입니다 — 이름·참조까지 같습니다(61 MiB 워크북에서 워크북 스코프 이름 142개).

셀 노트는 이제 읽지 않습니다. 이 문서를 쓸 때는 「범위 축소의 대상이 아니다」로 적었습니다 — 노트가 doc 주석이 되니 빼면 산출물이 달라진다는 것이었습니다. 그 전제가 틀렸습니다: 어떤 레이아웃도 노트를 설명으로 읽지 않았고, 실제로 쓰는 사람도 없었습니다. 이유는 어느 도구의 문제가 아니라 스프레드시트 쪽입니다 — 노트는 쓰기 번거롭고, 올려놓지 않으면 보이지 않고, 한 컬럼을 한눈에 볼 방법이 없습니다. 시트가 컬럼을 설명할 때 쓰는 것은 셀이고, Field.Comment는 거기서 옵니다.

그래서 워크북마다 xl/comments*.xml을 파싱하던 것과 .xlsb 패키지의 항목을 훑던 것이 없어졌고, 셀마다 있던 사전 조회와 문자열 참조도 없어졌습니다. 결정과 그 근거는 RawCell에 적혀 있습니다.

8. 설계 — 무엇이 어디로

XlsxImporter의 형태는 유지합니다. 워크북 순회·워크북/시트 필터·「이름이 덮지 않는 시트 건너뛰기」·RawSheet.Optimize() 호출·AttachNamedRanges의 좌표 변환은 그대로입니다. 바뀌는 것은 그 안에서 셀과 메타데이터를 어디에서 가져오는가입니다.

파일역할
src/Importers/Xlsx/SheetGridReader.csSylvan을 감싸 시트·행·셀을 돌며 임포터의 규칙으로 텍스트를 만듭니다. 6절의 날짜 규칙과 오류 셀 정책이 여기 있습니다
src/Importers/Xlsx/WorkbookPackage.cs패키지에서 정의된 이름을 읽습니다. System.IO.Compression + XmlReader만 씁니다
src/Importers/XlsxImporter.cs위 둘을 부릅니다. NPOI 참조가 사라집니다

코어에 프로젝트 이름이 들어가지 않습니다. 두 새 파일은 「워크북을 읽는 법」만 알고, 어느 레이아웃이 정의된 이름을 쓰는지는 지금처럼 LayoutRegistry.UsesNamedRanges가 나타냅니다.

세부 둘을 기록해 둡니다. 실제 요구사항이고 확인했습니다.

  • 파일은 우리가 엽니다. FileShare.ReadWrite로 연 스트림을 Sylvan에 넘깁니다 (OwnsStream = false). 디자이너가 Excel에 열어둔 워크북도 읽어야 하기 때문이고, 지금 임포터가 그렇게 하고 있습니다. 스트림 오버로드로 값이 같은 것을 확인했습니다.
  • 확장자로 형식을 지정합니다. .xlsx·.xlsmExcelXml. 세트에 .xlsm이 있습니다.

NPOI를 걷으면 csproj에서 두 가지가 함께 사라집니다 — 빌드마다 경고를 막던 AcceptNPOIOSMFLicense와, NPOI를 통해 들어오는 SkiaSharp의 83 MB 디버그 파일을 막던 설정입니다.

9. 이번 범위 밖

의도적으로 남깁니다. 각각 따로 판단할 것들입니다.

나중왜 지금이 아닌가
.xlsb 직독됐습니다.xlsb의 정의된 이름. 선행 조건으로 적어둔 둘 중 BrtName 파서는 그 문서의 3~4절이 처리했고, 셀 개수 차이는 선행 조건이 아니었습니다 — 880만 셀 중 차이 131개가 전부 이름 사각형 입니다
원시 격자 캐시같은 워크북을 다시 읽지 않게 하는 것. 반복 실행을 줄이지만 peak은 줄이지 않으므로, 이 교체 뒤에 남는 비용을 보고 판단합니다
문서 정정끝났습니다. 「NPOI는 .xlsb를 열지 못한다」가 근거였던 서술들인데, 그 근거가 두 번 낡았습니다 — XSSFBReader로 열리고, 그 뒤 리더가 교체되어 NPOI가 읽기 경로에 없습니다. convert-xlsb.ps1파일째로 없어졌고, 남은 서술은 당시 판단의 기록이라 그대로 둡니다
이름 기반 레이아웃의 Optimize 생략·이름 접두어따로 결정된 것들이고, 스트리밍 리더와 맞물리면 「이름이 가리키는 사각형만 읽기」까지 갈 수 있습니다. 이 교체를 먼저 착지시키고 그 위에서 봅니다

10. 저장소에 남는 게이트

5절의 대조는 이번 한 번으로 끝나면 안 됩니다. 다음에 리더를 갈아탈 사람이 이번과 같은 근거를 가질 수 있어야 하므로, 두 가지가 저장소에 남습니다.

무엇어디
픽스처 워크북 33개가 읽히는 값의 기록 — 시트·행·셀 개수, 문자 총량, 값 해시, 이름 개수XlsxReaderGateTests + test/fixtures/golden/xlsx-reader.tsv. 재기록은 골든과 같은 TABBIT_UPDATE_GOLDEN=1
우리가 손으로 쓴 것의 단위 테스트 — 참조 파서, _xHHHH_ 해제, 노트의 작성자 접두어WorkbookPackageTests

두 번째가 필요한 이유가 첫 번째의 한계입니다. 픽스처 워크북에는 정의된 이름도 셀 노트도 없습니다 — 기록이 33개 전부에 names=0 notes=0으로 적히는 것이 그 증거이고, 레이아웃 테스트는 이름의 사각형을 메모리에서 만들어 씁니다. 그래서 그 두 경로는 픽스처가 아니라 단위 테스트와 샘플 워크북이 지킵니다.

남은 빈칸을 적어둡니다. 정의된 이름과 노트를 워크북에서 읽는 경로는 샘플 29개에서 객체 모델과 대조해 확인하였지만, 픽스처에는 그런 워크북이 없어 스위트가 매번 확인하지는 않습니다. 이름 하나와 노트 하나를 가진 픽스처를 FixtureGen에 추가하는 것이 그 빈칸을 닫는 방법입니다.

되돌리는 것 자체는 한 파일입니다. 리더가 그 뒤에 있고, 갈아탈 곳이 이미 이 게이트를 통과해 있습니다.