Kotlin DSL로 Excel 쓰기 기능을 선언형으로 구현하기

안녕하세요·2026년 5월 6일
post-thumbnail

1. 개요

보고서용 엑셀 기능 개발 중 마주친 문제를 겪으며 적절한 도구 선택과 Kotlin DSL을 활용해본 경험을 공유합니다. 그리고 이 경험을 통해 Kotlin DSL Excel(KExcel)을 개발하게 된 이유도 담았습니다.


2. 문제 상황

점검결과항목 1항목 2항목 3항목 1항목 2항목 3
이름<--홍길동(병합)--><--김철수(병합)-->
기록 수859278908895

전 회사에서 위 표와 같은 동적 가로 확장 구조의 엑셀 산출 기능이 있었습니다. 구현은 자바진영 대표 라이브러리인 Apache POI를 사용하고 있었고 (spring boot 2.7), 다음과 같이 구현되어 있었습니다.

var startCol = 1
val rowN = 2 // 데이터가 시작되는 기준 행

checkResults.forEachIndexed { index, resultDto ->
    // (1) 헤더 영역: A, B, C 세부 항목 생성
    val subHeaderRow = sheet.getRow(rowN) ?: sheet.createRow(rowN)
    for (i in 0 until itemCellSize) {
        val cell = subHeaderRow.createCell(startCol + i)
        cell.setCellValue("항목 ${i + 1}")
        cell.cellStyle = headerStyle
    }

    // (2) 이름 영역: 사용자 이름을 쓰고 가로로 병합
    val nameRow = sheet.getRow(rowN + 1) ?: sheet.createRow(rowN + 1)
    val nameCell = nameRow.createCell(startCol)
    nameCell.setCellValue(resultDto.clientUserName)
    
    // 가로 병합 (CellRangeAddress 인덱스 계산)
    sheet.addMergedRegion(
        CellRangeAddress(rowN + 1, rowN + 1, startCol, startCol + itemCellSize - 1)
    )

    // (3) 구분 열: 세로 병합
    // 첫 번째 루프에서만 왼쪽 '구분' 레이블을 세로로 합침
    if (index == 0) {
        val labelRow = sheet.getRow(rowN) ?: sheet.createRow(rowN)
        labelRow.createCell(0).setCellValue("점검결과")
        sheet.addMergedRegion(CellRangeAddress(rowN, rowN + 1, 0, 0))
    }

    // (4) 다음 블록을 위한 인덱스 이동
    startCol += itemCellSize + 1 // 데이터 사이 공백 포함
}
...

실제로는 더 복잡했고 저는 이 형식과 유사한 신규 보고서 구현을 맡았습니다. 신규 보고서는 다음과 같은 특징이 있었습니다. (현장 근로자 관련 도메인이었습니다)

  • 시트가 하나가 아닌 여러 개
  • 한 달 치 작업 수 기반 통계 데이터 (최대 50,000개)

그래서 파일이 꽤 커질 수 있고 통계 수식도 복잡했습니다. 그리고 기존 코드도 복잡해서 재사용할 엄두가 나지 않았습니다. 그래서 선임의 허락하에 기존 기능도 리팩토링하면서 신규 보고서 개발을 시작했습니다.


30초가 넘는 파일 다운로드

기존 POI 방식을 활용해 신규 보고서 형식에 맞춰 코드를 작성한 후, 데이터가 잘 나오는지 확인해 보았습니다. 결과는 기존 보고서보다 훨씬 느린 속도로 파일이 생성되었고, 50,000건의 데이터를 처리할 때는 다운로드 완료까지 30초가 넘게 걸렸습니다. 그 당시 원인을 파악하기 위해 여러 자료를 찾아보며 다음과 같은 문제점들을 발견할 수 있었습니다.

  • XSSFWorkbook 구현체의 특징: 기본적인 XSSFWorkbook은 모든 데이터를 메모리에 올린 뒤 한 번에 쓰기 때문에, 데이터가 많아질수록 GC 부하가 급격히 커진다는 것을 알게 되었습니다. (POI의 Workbook 생성 구현체)
  • 임시 파일 병목: 메모리 문제를 피하려고 SXSSFWorkbook을 써도, 결국 디스크에 임시 파일을 쓰고 읽는 과정에서 I/O 병목이 발생해 클라이언트가 무한 대기에 빠질 수 있다는 점도 확인했습니다. (저는 이 임시 파일이 클라이언트를 무한 대기 상태로 만든다고 생각했습니다.)
  • addMergedRegion: 새로운 병합 영역을 추가할 때마다 기존 영역들과 겹치는지 매번 확인하는 로직이 있어, 병합이 많을수록 연산량이 기하급수적으로 늘어나는 구조였습니다. (이 녀석이 시간을 잡아 먹는 주범이었습니다)

임시 파일 병목의 진짜 이유
당시 제가 작성했던 코드는 ByteArrayOutputStream에 모든 엑셀 데이터를 먼저 담은 뒤, 이를 ResponseEntity에 실어 반환하는
방식이었습니다.

val outPutStream = ByteArrayOutputStream()
// 데이터를 이 그릇에 다 담을 때까지 '응답'은 시작되지 않습니다.
service.buildExcel(outPutStream) 
return ResponseEntity.ok().body(
	InputStreamResource(
	ByteArrayInputStream(outPutStream.toByteArray())))

이 방식에는 두 가지 치명적인 병목이 있었습니다.

  • 응답 지연(Pending): 서버가 5만 건의 로우를 다 쓰고 파일 작성을 마칠 때까지 클라이언트는 단 1바이트의 데이터도 받지 못합니다. 브라우저는 서버가 작업을 마칠 때까지 'Pending' 상태로 무한 대기하게 됩니다.
  • 메모리 복사 비용: toByteArray()를 호출하는 순간, 이미 메모리에 찬 엑셀 데이터를 또 다른 바이트 배열로 복사합니다. 파일이 크면 클수록 힙(Heap) 메모리에 가해지는 압박과 GC 부하가 성능을 발목 잡습니다.

그때는 이 이유를 몰랐습니다..

문제들을 정리하고 나니 POI로는 신규 보고서 형식을 작성하기 어렵다는 결론에 도달했습니다. 그래서 POI를 대체할 라이브러리를 찾기 시작했습니다.


3. 같은 기능, 다른 구현

그 중 눈에 띈 것은 FastExcel이었습니다. POI보다 나은 성능을 보장하고 엑셀의 부가적인 기능이 거의 안 들어가는 신규 보고서 형식에 딱 맞는 간소화한 엑셀 라이브러리였고 도입한 결과 30초 걸리던 작업은 획기적으로 줄어들었습니다. 하지만 새로운 문제가 발생했습니다.

// Case A. 정기 보고서 (POI 엔진)
val row = sheet.createRow(rowNum)
val cell = row.createCell(0)
cell.setCellValue(user.name)
cell.cellStyle = customStyle // POI 전용 스타일 객체

// Case B. 대용량 통계 (FastExcel 엔진)
worksheet.value(rowNum, 0, user.name)
worksheet.style(rowNum, 0).bold().set() // FastExcel 전용 유동적 API

한 프로젝트에 동일한 기능을 하는 라이브러리를 2가지 사용해서 복잡한 서식이 필요한 곳은 POI를 써야 했고, 대용량 통계는 FastExcel을 써야 했습니다.


추상화와 엑셀의 본질

지금 문제점은

  • 종속적인 코드: 같은 엑셀을 만드는데도 POI와 FastExcel은 API가 완전히 다릅니다.
  • 비즈니스 로직을 가리는 저수준 API: 중요한 '어떤 데이터를 넣을 것인가'라는 비즈니스 로직은 라이브러리를 호출하는 코드 사이에 숨어버리곤 합니다.

그래서 추상화를 도입하기로 했습니다.

interface ExcelDriver {

    fun startWorkbook(outputStream: OutputStream)

    fun finishWorkbook()

    fun startSheet(name: String)

    fun finishSheet()

    ...
}

서로 다른 두 라이브러리는 이 인터페이스를 구현만 하면 됩니다. 하지만 아직 문제가 남아있습니다. ExcelDriver는 단순히 엔진을 갈아끼울 수 있을 뿐, 코드를 작성하는 방식은 여전히 명령형이었습니다. 개발자는 여전히 "어떻게 엑셀을 만들까"에 집중해야 하고, finishSheet() 호출을 깜빡하는 등의 실수도 할 수 있었습니다.

엑셀 작성 코드는 화면을 그리는 것과 비슷합니다. 화면을 그리기 위한 코드는 코드가 무엇을 그리는 지, 시각적으로 화면과 일치하는 지에 집중하는 선언형 코드가 어울립니다.
이 고민의 끝에서 모든 정황은 Kotlin DSL이라는 하나의 해답을 지목하고 있었습니다.


4. Kotlin DSL로 그리는 엑셀

Workbook → Sheet → Row → Cell로 이어지는 엑셀의 계층 구조를 Kotlin의 Type-safe builders 패턴으로 그대로 옮겨왔습니다.

// 1. DSL의 시작점: 전/후처리를 자동화합니다.
fun excel(output: OutputStream, driver: ExcelDriver, init: WorkbookScope.() -> Unit) {
    driver.startWorkbook(output)
    WorkbookScope(driver).apply(init)
    driver.finishWorkbook()
}
// 2. 계층을 담당하는 Scope 클래스: 내부적으로 엔진(Driver) 명령을 감쌉니다.
class WorkbookScope(val driver: ExcelDriver) {
    fun sheet(name: String, init: SheetScope.() -> Unit) {
        driver.startSheet(name)        // 전처리: 시트 생성 알림
        SheetScope(driver).apply(init) // 본문 실행: 사용자가 작성한 DSL 블록
        driver.finishSheet()           // 후처리: 시트 작성 완료 및 리소스 정리
    }
}
... // 나머지 row, cell등 구현

이 간단한 구조 덕분에 사용자는 더 이상 finishSheet()를 호출해야 한다는 사실을 기억할 필요가 없습니다. 그저 중괄호{ } 안에 '무엇을 그릴지'만 적으면, 라이브러리가 그 생명주기를 책임지고 관리해줍니다.

이를 활용해 기존 POI 코드를 DSL로 옮기면 다음과 같습니다.

excel(output) {
    sheet("점검결과") {
        val rowN = 2
        val itemCellSize = 3

        // (1) 구분 열 세로 병합 (A열의 두 행을 합침)
        mergeCells(firstRow = rowN, lastRow = rowN + 1, firstCol = 0, lastCol = 0)

        // (2) 헤더 영역 (Row 2)
        row(rowN) {
            cell(0, value = "점검결과")
            
            checkResults.forEach {
                repeat(itemCellSize) { i -> 
                    cell(value = "항목 ${i + 1}", style = headerStyle) 
                }
                skip() // 데이터 사이 공백 열 건너뛰기
            }
        }

        // (3) 이름 영역 (Row 3) 자동 인덱싱
        row() {
            skip() // 이미 병합된 0번 열(구분 레이블)은 가볍게 건너뜁니다
            
            checkResults.forEach { resultDto ->
                val startCol = nextColNum
                cell(value = resultDto.clientUserName)
                
                // 가로 병합 선언
                mergeCells(
                    firstRow = rowN + 1, lastRow = rowN + 1, 
                    firstCol = startCol, lastCol = startCol + itemCellSize - 1
                )
                
                // 병합된 나머지 칸들과 공백 열을 한 번에 건너뜁니다
                skip(itemCellSize) 
            }
        }
    }
}

무엇이 바뀌었을까요?

  1. 선언적 구조: POI 코드에서는 index == 0일 때만 세로 병합을 하는 등의 분기 처리가 필요했습니다. 하지만 DSL에서는 행 단위로 사고하게 되면서, "이 행에는 어떤 데이터가 들어가는가?"에만 집중하게 됩니다.
  2. 좌표 계산의 자동화: 굳이 startCol + i 같은 덧셈 연산을 노출하지 않아도 됩니다. 내부에서 인덱스 계산을 자동화합니다.
  3. 가독성: 엑셀 시트에서 행(Row)은 가장 기본적인 단위입니다. 나중에 스타일을 수정하거나 레이아웃을 변경할 때 해당 row { ... } 블록만 확인하면 됩니다.

DSL을 도입하자 비즈니스 로직이 명확히 보이기 시작했습니다. 하지만 한 가지 걱정이 생겼습니다. "이 추상화가 혹시 성능을 갉아먹지는 않을까?" 하는 의구심이었습니다.

5. 성능과 설계, 두 마리 토끼 잡기

우리가 앞서 POI에서 FastExcel로 넘어온 결정적인 이유는 '성능' 때문이었습니다. 그런데 DSL이라는 레이어를 한 겹 더 얹으면서 다시 성능이 느려진다면, 그것은 본말전도가 되는 셈입니다.

1. Streaming-First

대용량 엑셀 처리의 핵심은 '메모리를 얼마나 적게 쓰는가'입니다.

[Streaming-based vs Memory-based 비교]

방식특징메모리 사용량적합한 데이터
Memory-based모든 데이터를 힙에 적재 후 한 번에 쓰기데이터 양에 비례해서 증가 (위험)1,000건 이하의 작은 데이터
Streaming행 단위로 즉시 쓰기 및 메모리 비우기데이터 양과 관계없이 일정 (안전)5만 건 이상의 대용량 데이터

스트리밍 방식의 대원칙은 "한 번 지나간 행(Row)은 다시 돌아갈 수 없다"는 것입니다. DSL은 이러한 순차적 흐름을 자연스럽게 유도합니다.

// SheetScope.kt 내부 로직
private fun validateRowNum(target: Int) {
    if (target < nextRowNum) {
        throw ExcelStreamingException(
            "Cannot write to row $target because row $nextRowNum has already been processed and flushed."
        )
    }
}
fun row(rowNum: Int? = null, ...) {
    val targetRow = rowNum ?: nextRowNum
    validateRowNum(targetRow) // 스트리밍 원칙 검증: 뒤로 돌아가기 시도 시 에러!
    driver.startRow(targetRow, height)
    // ... 데이터 작성 ...
    nextRowNum = targetRow + 1 // 다음 행 번호 갱신

    flush() // 실제 스트리밍은 여기서 일어남. 버퍼 비우기(1000개 단위 or OS 버퍼 가득차면)
}

이 간단한 체크 로직 하나가 수십만 건의 데이터를 안전하게 스트리밍할 수 있게 해줍니다.


2. Thread Safety

엑셀 생성은 본질적으로 순차적입니다. 만약 여러 스레드가 동시에 같은 시트에 접근한다면 어떻게 될까요? 이를 방지하기 위해 Fail-Fast 전략을 사용할 수 있습니다.

상황결과
일반적데이터가 뒤섞이거나, 원인 모를 엑셀 깨짐 발생
Fail-Fast동시 접근 감지 즉시 에러 발생
private val writeLock = ReentrantLock()

fun <T> writeSafely(block: () -> T): T {
    // 락 획득 시도 (이미 다른 스레드가 점유 중이면 즉시 에러!)
    if (!writeLock.tryLock()) {
        throw ExcelConcurrentWriteException("Concurrent write detected!")
    }
    return try {
        block() // 안전한 영역에서 작업 수행
    } finally {
        writeLock.unlock() // 작업 완료 후 반드시 락 해제
    }
}

// 적용 예시: 모든 DSL 블록은 이 안전망 안에서 실행됩니다.
fun row(...) = writeSafely {
    // 이 안의 모든 작업은 동시 접근으로부터 안전합니다.
    driver.startRow(...)
}

// Thread A
row { cell(value = "A") }
// Thread B (동시에 접근 시)
row { cell(value = "B") } // 즉시 에러 발생! 데이터 오염을 원천 차단

단순히 synchronized를 쓰는 대신 tryLock()을 사용한 이유는, 스레드를 기다리게(Block) 하는 대신 "동시 접근 자체가 잘못된 사용법"임을 사용자에게 즉시 알려주기 위함(Fail-Fast)입니다.


3. 스타일 위임

기존 POI 방식의 고충 중 하나는 스타일 지옥입니다. 모든 셀마다 배경색, 폰트, 테두리를 일일이 설정해야 하고, 스타일 객체를 재사용하지 않으면 파일이 무거워지거나 에러가 발생하곤 합니다.
이를 해결하기 위해 스타일 상속 계층을 도입했습니다.

[스타일 상속 계층 다이어그램]

이 구조 덕분에 우리는 중복된 스타일 설정을 최소화하고, 선언적으로 디자인을 관리할 수 있습니다.

excel(output) {
    // 1. 워크북 전체 기본 폰트 설정
    defaultStyle = ExcelStyle(font = ExcelFont(size = 11))

    sheet("매출보고서") {
        // 2. 이 시트의 모든 행은 파란색 헤더를 가짐
        row(style = ExcelStyle(background = "#4F81BD")) {
            cell(value = "No")
            cell(value = "상품명")
            // 3. 특정 셀만 글씨를 굵게 변경 (나머지 속성은 상속됨)
            cell(value = "금액", style = ExcelStyle(font = ExcelFont(bold = true)))
        }
    }
}

내부적으로는 parentStyle.merge(childStyle) 로직을 통해, 하위 레벨에서 정의하지 않은 스타일은 상위 레벨의 설정을 그대로 따라가도록 구현되어 있습니다. 이를 통해 "스타일 코드는 줄이고, 가독성은 높이는" 결과를 얻었습니다.


4. Native 지원

세상에 완벽한 추상화는 없습니다. 엔진마다 제공하는 특수한 기능(틀 고정, 오토 필터, 복잡한 조건부 서식 등)을 DSL이 모두 담으려고 하면, 오히려 DSL 자체가 너무 복잡해지는 문제가 발생합니다.

이를 해결하기 위해 Native Hook을 구현합니다. DSL을 쓰다가도 엔진 고유의 기능이 필요하면 언제든 직접 접근할 수 있습니다.

excel(output) {
    sheet("현황판") {
        // Apache POI 전용 기능: 틀 고정(Freeze Pane)
        nativeSheet<SXSSFSheet> { sheet ->
            sheet.createFreezePane(0, 1) // 첫 번째 행 고정
        }

        row { cell("ID"); cell("이름"); cell("상태") }
        
        // FastExcel 전용 기능이 필요하다면?
        nativeSheet<Worksheet> { ws ->
            // FastExcel만의 특화 기능을 여기서 호출
        }
    }
}

이 설계의 핵심은 타입 안정성입니다. nativeSheet<SXSSFSheet> 블록은 현재 엔진이 POI일 때만 실행되고, FastExcel일 때는 조용히 무시됩니다. 덕분에 엔진을 교체해도 코드가 깨지지 않으며, 필요할 때는 엔진의 모든 힘을 100% 끌어다 쓸 수 있습니다. "추상화의 편리함을 누리되, 한계에 갇히지 않는 것"이 철학입니다.


5. Inline 딜레마 (성능과 설계 사이의 줄타기)

대용량 엑셀을 만들다 보면 cell() 함수는 수백만 번 호출됩니다. 이때마다 중괄호{ } 람다 객체가 생성된다면?

// thread safety를 위한 람다를 받는 함수
fun writeSafely(block: () -> Unit) = ... 

// 기존 방식: 매 호출마다 람다 객체가 생성됨
fun cell(value: Any?) = writeSafely { 
    // 이 중괄호 { } 블록이 셀 하나당 하나의 객체로 할당됩니다.
    val targetCol = nextColNum
    driver.writeCell(targetCol, value)
    nextColNum = targetCol + 1
}

벤치마크 결과, 람다 생성으로 인한 오버헤드는 생각보다 컸고 메모리 할당량은 치솟았습니다.

성능을 생각하면 코틀린의 inline 함수를 써서 람다를 제거해야 합니다. 하지만 여기서 치명적인 설계적 딜레마에 빠지게 됩니다.

딜레마: 성능(Inline) vs 설계(Private)

  • 성능을 택하면 (Inline): 내부 변수(nextColNum, writeLock)를 @PublishedApi로 외부에 노출해야 합니다. 라이브러리 사용자가 내부 상태를 직접 수정할 수 있는 위험이 생깁니다. 이 경우, 누군가 의도치 않게 nextColNum을 변경하면 엑셀의 셀 위치가 뒤섞여 데이터가 엉망이 될 수 있습니다.
  • 설계를 택하면 (Private): 내부 변수를 private으로 꽁꽁 숨길 수 있지만, 매 셀 호출마다 람다 객체가 생성되어 성능이 떨어집니다.

이 문제를 해결하기 위해 '람다 없는 수동 락(Manual Lock) 제어'라는 제3의 길을 택했습니다.

// 최종 해결책: 람다를 받지 않는 방식으로 락 로직 분리
fun cell(...) {
    enterWrite() // 1. 락 획득 (람다 생성 없음!)
    try {
        val targetCol = col ?: nextColNum // 2. private 변수에 안전하게 접근
        driver.writeCell(targetCol, value, mergedStyle)
        nextColNum = targetCol + 1
    } finally {
        exitWrite() // 3. 반드시 락 해제
    }
}

결과는 놀라웠습니다. 수동 락 제어(Manual Lock)를 도입한 것만으로도 다음과 같은 성능 향상을 얻을 수 있었습니다.

항목최적화 전 (Lambda)최적화 후 (Manual Lock)개선 결과
실행당 메모리 할당량약 200 KB 추가약 300 Bytes 추가99.8% 감소
FastExcel DSL 오버헤드16.3%8.6%약 47% 감소

람다를 원천 차단함으로써 메모리 할당량을 놀랍게도(...) 99.8% 절감했고, 동시에 nextColNum 같은 핵심 변수들을 private으로 보호할 수 있었습니다.


6. 그래서 나아졌는가

모든 설계와 최적화를 마친 후, 실제 벤치마크(JMH)를 통해 기존 방식과 fastExcel의 성능을 정밀하게 측정해 보았습니다.

지표Apache POI (기존 방식)개선 후 (fastExcel 사용)개선 결과
대용량 처리 속도 (1,000,000행)4.249 s3.396 s약 25% 향상
셀 병합 처리량 (Throughput)3.36 ops/s(addMergedRegion)17.42 ops/s약 518% 향상
추가 메모리 할당 (Alloc/Op)~200 KB~300 Bytes99.8% 절감

엔진네이티브 호출DSL 사용오버헤드
FastExcel7.925 ops/s7.243 ops/s8.6%
Apache POI4.485 ops/s5.086 ops/s~0% (오차 범위 내)

📌 Note
FastExcel의 오버헤드가 높게 나타나는 이유는 엔진 자체가 극도로 빨라 락(Lock) 체크나 안전 검증 로직의 상대적 비용이 더 잘 보이기 때문입니다. POI의 경우 엔진 자체 로직이 무거워 DSL 비용이 상쇄됩니다.
(100m를 10초에 뛰는 선수(FastExcel)에게 1kg 조끼를 입히면 기록 저하가 눈에 띄지만, 100kg 배낭을 메고 걷는 사람(POI)에게 1kg 조끼를 더 입히는 건 큰 차이가 없는 것과 같습니다.)

가장 고무적인 성과는 복잡한 셀 병합이 포함된 대용량 리포트에서의 체감 성능이었습니다.
Apache POI의 기본 addMergedRegion 메서드는 새로운 병합 영역을 추가할 때마다 기존 영역들과 겹치는지 검사하는 O(N2)O(N^2) 알고리즘을 사용하고 있었습니다. 병합이 많아질수록 속도가 느려지는 주범이었죠.
다행히 POI 내부에는 이 검증 과정을 생략하는 addMergedRegionUnsafe라는 메서드가 있었고, Driver 레벨에서 이를 활용하도록 설정을 변경해 어느 드라이버를 사용하든 셀 병합에 병목이 적도록 했습니다. 이 발견으로 수만 건의 병합이 포함된 리포트 생성 시간이 30초 대에서 3초 대로 단축되었습니다.


7. 결론

이 글에서 다룬 문제들은 이전 직장에서 통계 보고서 다운로드를 구현하며 겪었던 트러블슈팅의 기록을 다시 재현해 본 것입니다. 당시에는 당장의 문제를 해결하고 관련 자료도 부족해서 왜 느린지 깊게 고민할 여유가 없었습니다. 그때 답답했던 내용을 지금 LLM의 활용해 보다 쉽게 깨닫고 공유할 수 있어서 좋습니다.

이 인사이트를 바탕으로 오픈소스인 KExcel을 만들어 보았습니다.
Gemini와 함께 문제들을 해결해 보았고, 그 과정에서 더 나은 설계를 고민했습니다.
엑셀 처리가 골칫거리라면, KExcel이 그 해답이 될 수 있기를 바랍니다.

profile
게으르지 않으려고 노력하는 사람

0개의 댓글