2026-05-11/Kotlin

POI는 파일을 만들고, DSL은 문서를 설명한다

POI 유틸과 annotation의 한계를 지나, 컬럼 정의를 한곳에 모으고 문서 모델과 renderer의 경계를 나눈 과정

처음에는 정말 엑셀 파일 하나만 만들면 됐다.

이름, 전화번호, 신청일, 상태를 담은 목록이었다. Apache POI로 workbook과 sheet를 만들고, 행마다 셀을 채운 뒤 다운로드 버튼을 붙였다. 요구사항도 단순했고 코드도 한 파일에 들어갔다.

문제는 그다음 엑셀부터 시작됐다.

어떤 파일은 관리자에게만 내부 메모 컬럼을 보여줘야 했다. 날짜와 금액에는 서로 다른 포맷이 필요했고, 헤더가 두 줄인 파일도 생겼다. 다운로드한 양식을 다시 업로드하는 기능이 붙었고, 데이터가 많아지자 스트리밍 방식도 고려해야 했다.

그중에서도 방향을 바꾸게 만든 요청은 이 문장이었다.

이 권한으로 다운로드할 때는 내부 메모 컬럼을 빼주세요.

컬럼 하나를 숨기려면 헤더 생성 코드, 값 매핑 코드, 스타일 유틸, 권한 분기를 차례로 찾아야 했다. 업로드에도 같은 컬럼이 있었다면 parser까지 확인해야 했다.

엑셀을 만드는 일보다 엑셀이 어디에서 정의됐는지 찾는 일이 더 어려워졌다.

POI 코드는 파일을 만드는 순서를 잘 보여준다

Apache POI로 목록을 만들면 대략 다음과 같은 코드가 나온다.

val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("사용자")

val header = sheet.createRow(0)
listOf("이름", "전화번호", "신청일", "상태").forEachIndexed { index, title ->
    header.createCell(index).setCellValue(title)
}

users.forEachIndexed { rowIndex, user ->
    val row = sheet.createRow(rowIndex + 1)
    row.createCell(0).setCellValue(user.name)
    row.createCell(1).setCellValue(user.phoneNumber)
    row.createCell(2).setCellValue(user.createdAt.toLocalDate())
    row.createCell(3).setCellValue(user.status.displayName)
}

workbook.write(output)

이 코드는 workbook을 만들고, sheet를 만들고, row와 cell을 채우는 순서를 정확히 보여준다. XLSX 파일을 직접 다뤄야 한다면 이 수준의 API가 필요하다.

하지만 호출부에서 알고 싶은 것은 다른 쪽에 가깝다.

  • 사용자 시트에는 어떤 컬럼이 있는가?
  • 각 값은 어디에서 오는가?
  • 누가 어떤 컬럼을 볼 수 있는가?
  • 날짜와 금액은 어떤 형식으로 보이는가?

POI 코드는 파일 생성 절차에는 충실하지만, 이 문서가 무엇을 뜻하는지는 여러 호출 사이에 흩어 놓는다.

작은 다운로드 하나라면 문제가 아니다. 함수 하나가 가장 읽기 쉽다. 추상화를 추가하기 전에 이 사실을 인정해야 한다.

유틸 함수는 반복을 줄였지만 정의를 모으지는 못했다

다음 단계에서는 POI 호출을 유틸 함수로 감쌌다.

sheet.addHeader("이름", "전화번호", "신청일", "상태")
sheet.addRow(
    user.name,
    user.phoneNumber,
    user.createdAt,
    user.status.displayName,
)

중복은 줄었다. 그런데 엑셀 종류가 늘자 컬럼 순서는 header 함수에, 값은 row 함수에, 스타일은 별도 함수에 남았다.

createHeader(row)
writeBodyRows(sheet, users)
applyDateStyle(sheet)
resizeColumns(sheet)

이름은 깔끔하다. 문제는 변경할 때 나타난다.

상태 컬럼을 하나 추가하려면 header와 body의 인덱스를 맞춰야 한다. 날짜 포맷을 바꾸려면 스타일 함수가 어느 열을 가리키는지 확인해야 한다. 권한에 따라 컬럼을 빼면 그 뒤의 모든 인덱스도 달라진다.

유틸 함수는 POI 호출의 반복을 줄여줬다. 하지만 같은 컬럼에 관한 결정을 한곳에 모아주지는 못했다.

annotation은 정적인 표에 잘 맞았다

컬럼 이름과 프로퍼티를 붙여 놓으려면 annotation이 자연스럽다.

@Excel
data class UserExcelRow(
    @Column("이름", order = 1)
    val name: String,

    @Column("전화번호", order = 2)
    val phoneNumber: String,

    @Column("신청일", order = 3)
    val createdAt: LocalDate,
)

excelOf(rows).writeTo(output)

고정된 컬럼을 가진 표라면 이 방식이 충분하다. 헤더와 값의 관계가 타입 선언 옆에 있고, 순서도 정적으로 확인할 수 있다.

업로드에서도 같은 장점이 있다.

@Excel
data class UserUploadRow(
    @Column("이름")
    val name: String,

    @Column("이메일")
    val email: String,
)

val result = parseExcel<UserUploadRow>(input)

외부 파일의 헤더를 어떤 내부 프로퍼티로 받을지 고정하는 일에는 annotation이 잘 맞는다.

하지만 다운로드 컬럼이 실행 시점의 권한이나 화면에 따라 달라지면 이야기가 달라진다. annotation 옵션에 조건을 계속 추가하면 결국 문자열로 된 작은 설정 언어를 만들게 된다.

@Column(
    name = "내부 메모",
    visibleWhen = "currentUser.isAdmin",
)

이 조건을 누가 해석해야 할까? 타입은 어떻게 검사할까? 이름이 바뀌면 IDE가 함께 고쳐줄까?

이미 Kotlin에는 조건문과 함수와 타입 검사가 있다. 동적인 문서까지 annotation 안에 넣을 이유가 없었다.

컬럼에 관한 결정을 컬럼 옆에 모았다

DSL에서 먼저 해결하고 싶었던 문제는 문법의 모양이 아니었다. 같은 이유로 바뀌는 코드를 같은 자리에 두고 싶었다.

excel {
    sheet<User>("사용자") {
        column("이름") { it.name }
        column("전화번호") { it.phoneNumber }
        column("신청일") { it.createdAt.toLocalDate() }
        column("상태") { it.status.displayName }

        if (currentUser.isAdmin) {
            column("내부 메모") { it.internalMemo ?: "-" }
        }

        rows(users)
    }
}.writeTo(output)

이 코드에서는 헤더 이름과 값 추출 방식이 같은 줄에 있다. 내부 메모 컬럼이 사라지는 조건도 컬럼이 정의되는 바로 그 자리에 있다.

스타일도 같은 문맥에서 지정할 수 있다.

excel(theme = Theme.Modern) {
    sheet<Transaction>("거래 내역") {
        column("내역") { it.description }
        column(
            "금액",
            conditionalStyle = { value: Int? ->
                when {
                    value == null -> null
                    value < 0 -> fontColor(Color.RED)
                    value > 1_000_000 -> fontColor(Color.GREEN)
                    else -> null
                }
            },
        ) { it.amount }

        rows(transactions)
    }
}.writeTo(output)

DSL을 쓰면 모든 문제가 사라지는 것은 아니다. 호출부가 선언적으로 보이더라도 builder 안에서 POI 객체를 바로 만들기 시작하면, 생성 순서와 workbook 생명주기가 DSL 구현 전체에 퍼진다.

겉모양만 DSL인 유틸 함수로 돌아가지 않으려면 내부 경계가 하나 더 필요했다.

DSL은 파일을 만들지 않고 문서를 설명한다

kotlin-excel-dsl의 DSL은 XLSX 파일을 바로 쓰지 않는다. 먼저 ExcelDocument를 만든다.

Kotlin DSL → ExcelDocument → POI renderer → XLSX

내부 모델은 대략 다음 정보를 가진다.

data class ExcelDocument(
    val sheets: List<Sheet> = emptyList(),
    val headerStyle: CellStyle? = null,
    val bodyStyle: CellStyle? = null,
)

data class Sheet(
    val name: String,
    val columns: List<ColumnDefinition<*>> = emptyList(),
    val dataSource: Iterable<*>? = null,
    val freezePane: FreezePane? = null,
    val autoFilter: Boolean = false,
)

data class ColumnDefinition<T>(
    val header: String,
    val width: ColumnWidth = ColumnWidth.Auto,
    val bodyStyle: CellStyle? = null,
    val valueExtractor: (T) -> Any?,
)

여기에는 POI의 Workbook, Row, CellStyle이 없다. 어떤 시트와 컬럼이 있고, 값을 어디에서 꺼내며, 어떤 표현 정책을 적용할지만 남는다.

이 구분 덕분에 DSL의 freezePane(row = 1)은 “첫 행을 고정한다”는 의도를 모델에 기록하는 데서 끝난다. 실제 sheet.createFreezePane(...) 호출은 renderer가 맡는다.

문서의 의미와 파일 생성 절차를 분리한 셈이다.

POI를 알아야 하는 코드는 renderer 안에 남겼다

POI에는 호출부까지 퍼뜨리고 싶지 않은 제약이 있다. CellStyle은 특정 workbook에 속하고, SXSSF는 메모리에 유지할 행의 범위를 관리해야 하며, 같은 스타일 객체도 가능한 한 재사용해야 한다.

이 지식은 render 모듈에 둔다.

class PoiRenderer(
    private val rowAccessWindowSize: Int = 100,
) : ExcelRenderer {
    override fun render(
        document: ExcelDocument,
        output: OutputStream,
    ) {
        SXSSFWorkbook(rowAccessWindowSize).use { workbook ->
            val styleCache = StyleCache(workbook)
            val styleResolver = StyleResolver.from(document)

            document.sheets.forEach { sheetModel ->
                val sheet = workbook.createSheet(sheetModel.name)
                SheetRenderer(
                    sheet = sheet,
                    sheetModel = sheetModel,
                    styleResolver = styleResolver,
                    styleCache = styleCache,
                ).render()
            }

            workbook.write(output)
        }
    }
}

사용자는 같은 DSL로 100행과 100만 행을 정의한다. SXSSF의 row window와 스타일 캐시는 renderer가 처리한다.

현재 저장소의 JMH 문서에는 M3 Pro와 JDK 21 환경에서 100만 행을 약 3.5초에 생성했고, 평균 메모리 136MB와 최대 메모리 약 193MB를 기록한 결과가 남아 있다. 이 수치는 모든 환경의 성능을 보장하지 않는다. 호출부와 renderer를 분리한 구조가 대용량 처리 전략을 한곳에서 검증할 수 있게 했다는 근거로만 사용한다.

현재 모듈 경계는 변경 이유를 따라간다

현재 프로젝트는 다음 모듈로 나뉜다.

core       : 문서 모델과 Kotlin DSL
annotation : 정적인 행 타입과 스타일 선언
render     : ExcelDocument를 XLSX로 변환하는 POI 구현
parser     : 업로드된 XLSX를 타입으로 변환
theme      : 재사용할 테마
excel-dsl  : 위 기능을 한 번에 사용하는 통합 모듈
benchmarks : 대용량 생성 성능 측정

다운로드와 업로드는 같은 엑셀 파일을 다루지만 시작점이 다르다.

Rendering diagram...

동적으로 문서를 구성할 때는 DSL이 맞다. 고정된 행 타입을 빠르게 내보내거나 외부 파일을 내부 타입으로 읽을 때는 annotation이 유리하다. POI 객체의 생성과 스트리밍은 renderer의 일이다.

모듈을 나눈 덕분에 파일 수는 늘었다. 대신 변경 요청을 받았을 때 처음 열어볼 곳이 달라졌다.

컬럼 값과 노출 조건     → 사용자 DSL
문서 구조와 공통 스타일 → core / theme
XLSX 생성과 성능        → render / benchmarks
정적인 행 선언          → annotation
업로드 변환과 검증      → parser

함수 하나로 충분한 경우도 많다

모든 엑셀 다운로드에 DSL과 여러 모듈이 필요한 것은 아니다.

컬럼이 고정되어 있고 파일이 하나뿐이며, 스타일과 업로드 요구도 없다면 POI 함수 하나가 더 낫다. 정적인 표가 여러 개 있을 뿐이라면 annotation만으로 충분할 수 있다.

DSL의 비용은 요구사항이 다음처럼 변할 때부터 회수된다.

  • 권한과 화면에 따라 컬럼 구성이 달라진다.
  • 값, 포맷, 스타일이 함께 자주 바뀐다.
  • 다운로드와 업로드가 같은 업무 개념을 공유한다.
  • 대용량 처리 전략을 호출부와 분리하고 싶다.
  • 여러 제품에서 같은 엑셀 기능을 반복해서 사용한다.

처음 문제는 “엑셀을 어떻게 생성할까?”였다. 요구사항이 쌓인 뒤에는 질문이 달라졌다.

“이 엑셀을 어디에서 설명하고 있는가?”

지금은 컬럼을 바꾸면 먼저 그 컬럼이 선언된 곳을 연다. 파일 생성 순서를 바꾸는 일이 아니라면 POI 코드를 읽지 않는다. 업로드 규칙이 아니라면 parser도 열지 않는다.

원했던 결과는 화려한 문법이 아니었다. 같이 바뀌는 코드가 같이 보이고, 서로 다른 이유로 바뀌는 코드는 따로 읽을 수 있는 구조였다.

현재 API와 모듈 구성은 kotlin-excel-dsl 저장소에서 확인할 수 있다.

GitHubGitHub - clroot/kotlin-excel-dsl: Type-safe Kotlin DSL for creating Excel files with elegant syntax, annotation support, and customizable themesType-safe Kotlin DSL for creating Excel files with elegant syntax, annotation support, and customizable themes - clroot/kotlin-excel-dslhttps://github.com/clroot/kotlin-excel-dsl