개인 오픈소스 프로젝트

KExcel — Kotlin DSL 기반 대용량 엑셀 생성 라이브러리

엑셀 보고서를 만드는 개발자가 저수준 객체와 좌표·스타일 수명주기를 반복 관리해야 하는 문제를 개인 오픈소스 라이브러리로 일반화했습니다. Kotlin DSL과 POI·FastExcel 구현을 ExcelDriver로 분리하고 스트리밍·동시 쓰기 오용은 즉시 실패하도록 판단했습니다. 설계·구현·공개를 맡아 OpenJDK 21·512MB heap에서 JMH로 오버헤드와 병합 처리량을 측정했습니다. 안전 검증 우회와 메모리 비용을 한계로 남겨 추상화의 편의와 런타임 비용을 함께 검증한 사례입니다.

30초 요약

맥락
엑셀 보고서를 만드는 개발자가 저수준 객체와 좌표·스타일 수명주기를 반복 관리해야 하는 문제를 개인 오픈소스 라이브러리로 일반화했습니다.
문제
Apache POI의 저수준 API를 직접 사용하면 객체와 좌표, 스타일 생명주기를 반복해서 관리해야 했고, 엔진을 바꾸면 보고서 코드도 함께 변경해야 했습니다.
판단
사용자 DSL과 실제 엑셀 엔진을 ExcelDriver 인터페이스로 분리하고, 스트리밍 제약과 동일 시트 동시 쓰기 오용은 즉시 실패하도록 했습니다.
역할
개인 프로젝트로 API와 Scope 계층 설계, POI·FastExcel 드라이버 구현, 벤치마크와 배포를 수행했습니다.
결과
FastExcel DSL 오버헤드를 16.3%에서 3.1%로 줄이고 512MB heap에서 100만 행 생성을 완료했으며, JitPack 0.1.0으로 공개했습니다.
검증
OpenJDK 21, 512MB heap, G1GC 환경에서 JMH로 DSL 오버헤드와 병합 처리량을 측정했습니다.
한계
병합 중복 검증을 우회한 최적화는 잘못된 병합 요청을 사전에 방지해야 하며, 병합 메타데이터의 메모리 비용은 남습니다.
증명하는 역량
API 추상화의 편의뿐 아니라 오용 정책과 런타임 비용을 벤치마크로 함께 검증하는 역량을 보여줍니다.

검증과 근거

확인 가능한 근거

주장의 적용 범위와 측정 조건을 함께 표시합니다.

  1. 공개 저장소아키텍처

    ExcelDriver 인터페이스 뒤에 PoiDriver와 FastExcelDriver를 분리했다.

    원문 근거 보기
  2. 측정 결과Native FastExcel API 대비 KExcel DSL 처리량 차이

    FastExcel DSL overhead: 3.1 percent

    OpenJDK 21, 512MB heap, G1GC, JMH 1.36, FastExcel 0.18.4, 10,000행 × 10열원문 근거 보기
  3. 측정 결과addMergedRegion 대비 addMergedRegionUnsafe 적용 병합 시나리오

    POI merge throughput: 17.42 ops/s

    JMH MergeBenchmark원문 근거 보기

사용 기술

구현에 직접 연결된 기술

보조 기술 보기
  • Java 21
  • Gradle
  • GitHub Actions

실무에서 경험한 엑셀 생성의 성능·복잡도 문제를 특정 보고서에 한정하지 않고, 엔진을 교체할 수 있는 Kotlin 라이브러리로 일반화했습니다.

1. 문제의 출발점

엑셀 보고서를 생성하는 코드는 데이터 변환보다 라이브러리 사용 절차가 더 많은 비중을 차지하기 쉽습니다.

Workbook 생성
→ Sheet 생성
→ Row와 Cell 좌표 계산
→ Style 객체 생성·적용
→ 파일 쓰기와 리소스 종료

Apache POI와 FastExcel은 같은 XLSX 파일을 생성하지만 API와 지원 기능이 다릅니다. 성능을 이유로 엔진을 바꾸면 보고서 생성 코드도 함께 다시 작성해야 했습니다.

이 프로젝트에서는 다음 문제를 분리해 해결하고자 했습니다.

  1. 저수준 엑셀 API가 비즈니스 로직을 가리는 문제
  2. 엔진 교체 시 사용자 코드가 함께 변경되는 문제
  3. 스트리밍 방식의 제약이 호출자에게 늦게 드러나는 문제
  4. 추상화 계층이 네이티브 엔진보다 얼마나 느린지 알 수 없는 문제

2. 설계 목표

목표

비목표


3. 아키텍처

KExcel 엔진 추상화 구조사용자 Kotlin DSL이 Scope 계층과 ExcelDriver를 거쳐 POI 또는 FastExcel 엔진을 선택하고, 엔진 고유 기능은 Native Extension으로 접근하는 구조

엔진 고유 기능

사용자 코드
excel { sheet { row { cell() } } }

WorkbookScope / SheetScope
DataSheetScope / RowScope

ExcelDriver

PoiDriver
SXSSFWorkbook

FastExcelDriver

Native Extension
nativeWorkbook / nativeSheet

사용자 DSL

File("report.xlsx").outputStream().use { output ->
    excel(output) {
        sheet("Summary") {
            row {
                cell(value = "Name")
                cell(value = "Amount")
            }
            row {
                cell(value = "Alice")
                cell(value = 120_000)
            }
        }
    }
}

호출자는 Workbook과 Sheet를 직접 닫거나 다음 셀의 좌표를 계산하지 않습니다. Scope가 현재 위치와 리소스 생명주기를 관리합니다.

ExcelDriver

ExcelDriver는 공통 DSL과 실제 엔진 사이의 경계입니다.

ExcelDriver
- startWorkbook
- startSheet
- startRow
- writeCell
- mergeCells
- nativeWorkbook
- nativeSheet

PoiDriverFastExcelDriver가 같은 인터페이스를 구현하므로, 사용자 코드는 엔진의 저수준 API에 직접 의존하지 않습니다.

엔진 선택

라이브러리는 classpath에서 사용 가능한 엔진을 감지할 수 있으며, 호출자가 명시적으로 Driver를 지정할 수도 있습니다.

excel(output, driver = PoiDriver()) {
    // 동일한 DSL
}

라이브러리는 엔진 의존성을 compileOnly로 두고, 실제 애플리케이션이 필요한 엔진을 선택하도록 했습니다.


4. DSL 계층과 스타일 상속

DSL은 Workbook–Sheet–Row–Cell 계층을 그대로 표현합니다.

Workbook default style
        ↓
Sheet default style
        ↓
Row style
        ↓
Cell style

하위 단계에서 지정한 속성이 상위 기본값을 덮어씁니다. 스타일 객체 생성과 적용 순서를 호출자가 직접 관리하지 않아도 되도록 했습니다.

컬렉션 기반 데이터는 dataSheet로 열 정의와 값을 분리할 수 있습니다.

dataSheet("Inventory", products) {
    column("ID") { it.id }
    column("Name") { it.name }
    column("Price") { it.price }
}

이 API의 목적은 엑셀 API 호출보다 출력할 데이터와 열 정의가 코드에서 먼저 보이게 하는 것입니다.


5. 실용적인 추상화

POI와 FastExcel은 지원 기능이 다릅니다.

모든 기능을 ExcelDriver에 넣으면 인터페이스가 특정 엔진의 기능 집합에 끌려가게 됩니다. 따라서 공통 기능은 DSL로 제공하고, 필요한 경우 nativeSheetnativeWorkbook을 통해 엔진 API에 접근하도록 했습니다.

nativeSheet<SXSSFSheet> { sheet ->
    sheet.createFreezePane(0, 1)
}

이 선택으로 공통 사용 경험은 유지할 수 있지만, Native Extension을 사용한 코드는 해당 엔진에 의존하게 됩니다.


6. Streaming-First와 Fail-Fast

행 단위 스트리밍

Sequence<T>를 입력받아 데이터를 한 행씩 작성합니다.

rows(largeData) { item ->
    cell(item.id)
    cell(item.name)
}

대용량 파일 생성에서는 이미 처리한 행을 메모리에 계속 유지하지 않습니다. 이 때문에 flush가 끝난 이전 행으로 돌아가 수정하는 동작은 지원할 수 없습니다.

역방향 행 접근 차단

row 1 작성 → flush
row 2 작성 → flush
row 1 재접근
        ↓
즉시 예외

호출자가 파일 생성이 끝난 뒤 깨진 결과물을 확인하는 대신, 허용되지 않는 접근 시점에 오류를 확인하도록 했습니다.

동일 시트 동시 쓰기

이 라이브러리는 하나의 시트를 여러 스레드가 동시에 안전하게 작성하도록 만드는 것이 목적이 아닙니다.

두 실행 흐름이 현재 행과 열 상태를 동시에 변경하면 결과 위치를 보장할 수 없습니다. 따라서 쓰기 작업이 진행 중일 때 다른 쓰기가 들어오면 대기시키지 않고 즉시 실패시킵니다.

안전한 설명은 다음과 같습니다.

멀티스레드 쓰기를 지원하는 것이 아니라, 동일 시트의 잘못된 동시 사용을 감지해 데이터 손상 전에 실패시킨다.


7. 벤치마크 설계

측정 환경

항목환경
JVMOpenJDK 21 (Adoptium)
Heap512MB
GCG1GC
JMH1.36
FastExcel0.18.4
Apache POI5.2.5 (SXSSF)

저장소의 현재 라이브러리 버전과 벤치마크 당시 버전은 다를 수 있습니다.

확인하려던 질문

  1. 네이티브 엔진 API 대비 DSL이 추가하는 비용은 얼마인가?
  2. 행 수가 증가했을 때 두 Driver는 제한된 heap에서 완료되는가?
  3. 스타일 상속 방식은 개별 셀 스타일보다 어느 정도 비용을 가지는가?
  4. 병합 영역이 늘어날 때 POI의 처리량이 왜 급격히 감소하는가?

8. DSL 핫패스 최적화

초기 현상

셀 쓰기는 대용량 파일에서 수십만 번 이상 반복됩니다. 초기 구현에는 안전 검증을 위한 람다와 runCatching이 핫루프에 포함됐고, 작은 할당이 반복 실행마다 누적됐습니다.

변경

결과

지표변경 전변경 후
FastExcel DSL 오버헤드16.3%3.1%
DSL 실행당 추가 할당량약 200KB약 364B

99.8% 감소는 전체 JVM 메모리 사용량이 아니라 DSL 실행 과정에서 추가로 발생한 할당량에 대한 수치입니다.


9. 대용량 행 생성

행 수FastExcel DriverPOI SXSSF Driver
100,0000.238초0.440초
1,000,0003.396초4.249초

두 Driver 모두 512MB heap의 해당 벤치마크에서 100만 행 생성을 완료했습니다. 이 결과는 측정 환경의 처리 특성을 보여주며, 모든 서버 환경의 절대 성능을 보장하지 않습니다.


10. POI 병합 성능 분석

현상

병합 영역이 증가할수록 POI의 처리량이 급격히 감소하고 GC 할당 속도가 증가했습니다.

원인 가설과 확인

POI의 표준 addMergedRegion은 새로운 병합 영역을 추가할 때 기존 영역과 겹치는지 검사합니다. 병합 개수가 증가하면 이 검사가 반복되며 비용이 커집니다.

벤치마크에서는 이 동작이 병합이 많은 시나리오의 주요 병목으로 나타났습니다.

변경

PoiDriver에서 addMergedRegionUnsafe()를 사용해 중복 영역 검사를 우회했습니다.

결과

지표변경 전변경 후
병합 처리량3.36 ops/s17.42 ops/s
GC 할당 속도1580.8 MB/s328.6 MB/s

트레이드오프

Unsafe API는 중복 병합 검증을 생략합니다. 따라서 Driver 또는 호출 계층이 잘못된 병합 영역을 만들지 않는다는 전제가 필요합니다.

또한 CPU 검증 비용을 줄여도 병합 메타데이터 자체는 파일 작성 완료 시점까지 메모리에 남습니다. 수십만 개의 병합 영역을 사용하는 경우 JVM heap의 영향을 계속 받습니다.


11. 결과

구조적 결과

측정 결과

공개 결과


12. 남은 한계


13. 배운 점

추상화는 저수준 API를 감추는 것만으로 완성되지 않았습니다.

를 함께 정의해야 라이브러리의 책임 범위를 설명할 수 있었습니다.


14. 근거