← 모든 글
2026-05-11/Kotlin

kotlin-excel-dsl: POI를 모르는 core

core, DSL, render, parser의 책임을 나누며 엑셀 DSL의 경계를 설계한 기록

엑셀 파일을 만드는 코드는 처음에는 단순하다. Workbook을 만들고, Sheet를 만들고, RowCell을 채운다. 헤더 스타일을 조금 넣고, 날짜 포맷을 맞추고, 열 너비를 조정하면 그럭저럭 쓸 만한 파일이 나온다.

요구사항이 늘어나면 분위기가 달라진다. 권한별 컬럼, 헤더 그룹, 업로드 검증, 대용량 스트리밍, 스타일 테마가 붙기 시작하면 엑셀 코드는 더 이상 "파일 하나를 쓰는 코드"로만 남지 않는다. 어떤 컬럼을 보여줄지, 어떤 값을 검증할지, 어떤 방식으로 메모리를 아낄지, 어떤 스타일을 적용할지가 같은 흐름 안에서 서로를 의식하기 시작한다.

kotlin-excel-dsl에서 가장 먼저 세운 기준은 이 의존을 끊는 것이었다.

core는 POI를 몰라야 한다.

여기서 말하는 core는 엑셀 파일을 직접 만드는 코드가 아니다. 엑셀 문서가 어떤 시트와 컬럼을 가져야 하는지 설명하는 내부 모델이다. 이 모델이 Apache POI의 Workbook, Sheet, Row, CellStyle을 알기 시작하면 방향이 뒤집힌다. 엑셀을 설명하는 코드가 아니라 POI를 조립하기 쉬운 코드가 된다.

그래서 설계의 출발점은 "엑셀을 어떻게 만들 것인가"가 아니었다. 먼저 "엑셀을 어떻게 설명할 것인가"를 정해야 했다.

엑셀 유틸은 왜 한 곳에 모이는가

엑셀 처리 코드는 한 파일로 모이기 쉽다. 처음에는 그게 더 빠르다. Workbook을 만들고, 첫 번째 행에 헤더를 쓰고, 반복문으로 데이터를 채우면 된다.

fun writeUsers(users: List<User>, output: OutputStream) {
    val workbook = SXSSFWorkbook(100)
    val sheet = workbook.createSheet("사용자")

    val header = sheet.createRow(0)
    header.createCell(0).setCellValue("이름")
    header.createCell(1).setCellValue("이메일")
    header.createCell(2).setCellValue("가입일")

    users.forEachIndexed { index, user ->
        val row = sheet.createRow(index + 1)
        row.createCell(0).setCellValue(user.name)
        row.createCell(1).setCellValue(user.email)
        row.createCell(2).setCellValue(user.joinedAt.toString())
    }

    workbook.write(output)
    workbook.close()
}

이 정도 코드는 나쁘지 않다. 작은 기능이라면 이쪽이 더 읽기 쉽다. 문제는 기능이 늘어날 때다. 비슷한 엑셀 코드가 몇 군데 생기면 중복을 줄이고 싶어진다. 그러다 보면 이런 코드가 ExcelUtils라는 이름 아래로 옮겨간다.

object ExcelUtils {
    fun writeUsers(
        users: List<User>,
        role: Role,
        output: OutputStream,
    ) {
        val workbook = SXSSFWorkbook(500)
        val sheet = workbook.createSheet("사용자")
        val headerStyle = createHeaderStyle(workbook)
        val warningStyle = createWarningStyle(workbook)

        val columns = mutableListOf(
            Column("이름") { user: User -> user.name },
            Column("이메일") { user -> user.email },
            Column("가입일") { user -> user.joinedAt.format(DateTimeFormatter.ISO_DATE) },
        )

        if (role == Role.ADMIN) {
            columns += Column("최근 로그인") { user -> user.lastLoginAt?.toString() ?: "-" }
        }

        writeHeader(sheet, columns, headerStyle)

        users.forEachIndexed { index, user ->
            val row = sheet.createRow(index + 1)
            columns.forEachIndexed { columnIndex, column ->
                val cell = row.createCell(columnIndex)
                cell.setCellValue(column.value(user).toString())

                if (user.isDormant) {
                    cell.cellStyle = warningStyle
                }
            }
        }

        sheet.createFreezePane(0, 1)
        sheet.setAutoFilter(CellRangeAddress(0, users.size, 0, columns.lastIndex))
        workbook.write(output)
        workbook.close()
    }

    fun parseUsers(input: InputStream): List<UserUploadRow> {
        val workbook = WorkbookFactory.create(input)
        val sheet = workbook.getSheetAt(0)
        val header = readHeader(sheet.getRow(0))

        require("이메일" in header) { "이메일 컬럼이 필요합니다." }
        require("가입일" in header) { "가입일 컬럼이 필요합니다." }

        return sheet.drop(1).map { row ->
            UserUploadRow(
                email = row.cell("이메일", header).stringCellValue,
                joinedAt = LocalDate.parse(row.cell("가입일", header).stringCellValue),
            )
        }
    }
}

한 파일에 있다는 사실만으로 문제가 되는 것은 아니다. 작은 기능이라면 오히려 한 파일이 낫다. 문제는 이 파일을 바꾸는 이유가 하나가 아니라는 데 있다. 관리자 컬럼을 추가할 때도, 휴면 사용자 스타일을 바꿀 때도, 대용량 다운로드 성능을 조정할 때도, 업로드 헤더 검증을 바꿀 때도 같은 ExcelUtils를 열게 된다.

이렇게 되면 코드를 고칠 때마다 엑셀의 어느 부분을 건드리는지 다시 확인해야 한다. 다운로드 컬럼을 바꿨을 뿐인데 업로드 파싱과 공통 헤더 처리에 영향이 없는지 봐야 한다. 스트리밍 설정을 바꿨을 뿐인데 스타일 생성 순서가 깨지지 않는지도 봐야 한다. 파일 하나가 길어서 불편한 것이 아니라, 서로 다른 변경 이유가 같은 문맥을 공유하면서 매번 같이 읽히는 것이 부담이 된다.

각 요구사항은 따로 보면 작다. 하지만 모두 같은 유틸 안으로 들어가면 다운로드 표현, 업로드 해석, 스타일 정책, 성능 전략이 한 자리에 섞인다. ExcelUtils는 공통 코드처럼 보이지만, 실제로는 서로 다른 책임이 임시로 모여 있는 장소가 된다.

이 프로젝트에서 피하고 싶었던 건 엑셀 코드 자체가 아니었다. 피하고 싶었던 것은 엑셀의 구조를 설명하는 코드와 엑셀을 실제 파일로 쓰는 구현 세부사항이 한 문맥에서 계속 서로를 의식하게 되는 상황이었다.

기준: core는 POI를 몰라야 한다

이 기준은 몇 가지 결정을 따라오게 만든다.

  • DSL은 POI를 직접 호출하지 않는다.
  • core 모델은 Workbook, Sheet, Row, CellStyle을 참조하지 않는다.
  • 스타일은 POI 객체가 아니라 값으로 표현한다.
  • 업로드 parser는 DSL builder를 몰라도 된다.
  • POI 호출 순서와 성능 전략은 render 안에 둔다.

그 기준 위에서 모듈을 나누었다.

kotlin-excel-dsl
├── core
├── excel-dsl
├── render
├── theme
├── annotation
├── parser
└── benchmarks

각 모듈의 역할은 이렇게 잡았다.

core       : 엑셀을 설명하는 내부 모델
excel-dsl  : 내부 모델을 Kotlin DSL로 조립하는 API
render     : 내부 모델을 XLSX 파일로 렌더링
theme      : 스타일과 테마 정의
annotation : 업로드 타입의 정적 컬럼 선언
parser     : 업로드된 엑셀을 타입으로 파싱
benchmarks : 대용량 처리 성능 측정

모듈 개수 자체가 중요한 것은 아니다. 중요한 건 서로 다른 이유로 바뀌는 코드를 다른 자리에 두는 것이다.

core: 엑셀을 설명하는 모델

가장 먼저 자리를 잡아야 했던 건 core였다. DSL부터 만들고 싶어도 DSL이 곧 엑셀이 되면 안 된다. DSL은 사용자가 엑셀을 표현하는 입구 중 하나일 뿐이다. annotation도 다른 입구가 될 수 있고, render와 parser도 같은 컬럼 개념을 서로 다른 방향에서 바라본다.

그러려면 중간에 공통 언어가 필요하다. core가 할 일은 파일 생성이 아니라 엑셀이 무엇이어야 하는지를 적어두는 것이다.

처음 필요한 모델은 작았다.

data class ExcelDocument(
    val sheets: List<Sheet> = emptyList(),
)

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

data class ColumnDefinition<T>(
    val header: String,
    val valueExtractor: (T) -> Any?,
)

이 정도만 있어도 "사용자 목록 시트에 이름 컬럼을 둔다"는 말은 표현할 수 있다. 하지만 실제 엑셀은 여기서 끝나지 않는다. 날짜 포맷, 헤더 그룹, 첫 행 고정, 필터, 조건부 스타일도 모델에 남아야 렌더러가 나중에 처리할 수 있다.

그래서 모델은 조금 더 커졌다.

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

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

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

이 모델에는 POI 타입이 등장하지 않는다. Workbook도 없고, Sheet.createRow()도 없고, POI CellStyle을 만드는 코드도 없다. 대신 이 문서에 어떤 시트가 있는지, 어떤 컬럼이 있는지, 값은 어디서 오는지, 어떤 표현 정책이 붙는지만 있다.

이 차이가 작아 보여도 뒤에서 계속 영향을 준다. core가 POI를 알기 시작하면 DSL도 POI를 따라간다. core가 DSL 문법을 알기 시작하면 annotation이나 parser가 DSL에 끌려간다. core가 순수할수록 다른 모듈은 자기 역할에 집중할 수 있다.

excel-dsl: 파일이 아니라 모델을 만든다

사용자가 쓰고 싶은 코드는 대개 이런 모습이다.

val document = excel {
    sheet<User>("사용자") {
        freezePane(row = 1)
        autoFilter()

        column("이름") { it.name }
        column("이메일") { it.email }
        column("가입일", format = "yyyy-MM-dd") { it.createdAt }

        rows(users)
    }
}

이 코드는 XLSX 파일을 바로 만들지 않는다. ExcelDocument를 만든다.

Kotlin DSL -> core model -> renderer -> XLSX

DSL의 책임은 사용자가 Kotlin답게 엑셀 정의를 조립하게 하는 것이다. 그래서 excel { ... }의 반환값도 실제 파일이 아니라 ExcelDocument다.

fun excel(block: ExcelBuilder.() -> Unit): ExcelDocument {
    return ExcelBuilder().apply(block).build()
}

반환 타입이 파일이 아니라 모델이라는 점이 중요하다. DSL이 곧바로 POI를 호출했다면 column(...)을 구현할 때마다 렌더링 세부사항을 같이 고민해야 한다. 헤더 그룹을 추가할 때도, 조건부 스타일을 추가할 때도, freeze pane을 추가할 때도 POI의 호출 순서를 떠올려야 한다.

그러면 DSL은 사용자에게만 예쁜 문법이고, 내부에서는 다시 절차 코드가 된다.

예를 들어 SheetBuilderfreezePane이나 autoFilter 같은 표현을 받는다.

fun freezePane(
    row: Int = 0,
    col: Int = 0,
) {
    require(row >= 0) { "freezePane row must be non-negative, but was $row" }
    require(col >= 0) { "freezePane col must be non-negative, but was $col" }
    freezePaneConfig = FreezePane(row, col)
}

fun autoFilter() {
    autoFilterEnabled = true
}

여기에는 POI 호출이 없다. freezePane(row = 1)이 실제로 sheet.createFreezePane(...)을 호출하는 시점은 나중이다. DSL은 "이 시트는 첫 행이 고정되어야 한다"는 사실만 기록한다.

render: POI를 가두는 자리

POI는 좋은 도구다. 다만 좋은 공용어는 아니었다.

서비스 코드, DSL 코드, annotation 코드, parser 코드 여기저기에서 POI 타입이 새어 나오면 모든 코드가 POI의 사정을 알아야 한다. Workbook이 언제 만들어지는지, CellStyle이 어느 workbook에 묶이는지, SXSSF에서는 어떤 제약이 있는지, 스타일을 얼마나 많이 만들 수 있는지 같은 것들이다.

그건 호출부가 신경 쓸 정보가 아니다. POI 의존은 render 안쪽으로 몰아넣었다. render의 책임은 ExcelDocument를 읽고 XLSX 파일로 쓰는 것이다.

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)
                val sheetRenderer = SheetRenderer(
                    sheet = sheet,
                    sheetModel = sheetModel,
                    styleResolver = styleResolver,
                    styleCache = styleCache,
                )
                sheetRenderer.render()
            }

            workbook.write(output)
        }
    }
}

여기서야 비로소 SXSSFWorkbook이 등장한다. 이 위치가 중요하다. StyleResolver는 문서와 컬럼에 걸린 스타일 정책을 해석하고, StyleCache는 그 결과를 workbook에 맞는 POI 스타일로 재사용한다.

사용자는 데이터가 100줄이든 100만 줄이든 같은 정의를 쓴다.

excel {
    sheet<User>("사용자") {
        column("이름") { it.name }
        rows(users)
    }
}

데이터가 많으면 어떻게 되느냐는 질문은 당연히 나온다. 엑셀 다운로드 코드는 작은 데이터에서만 예쁘면 별 의미가 없다. 그래서 renderer 내부에서는 SXSSF 기반 스트리밍을 사용하고, style cache를 둔다.

벤치마크에서는 백만 행 기준으로 약 3.5초, 평균 136MB, 최대 약 193MB 정도가 나왔다. 이 수치는 빠르다는 자랑보다 경계가 실제로 버티는지 확인하는 쪽에 가깝다. 호출부가 성능 전략을 매번 들고 다니지 않아도 된다는 확인이다.

theme: 스타일을 값으로 다룬다

엑셀에서 스타일은 겉으로 보기에는 장식이다. 그런데 구현으로 들어가면 상태다. POI의 CellStyle은 workbook에 묶인다. 아무 데서나 만들어서 아무 workbook에나 붙일 수 있는 값이 아니다. 스타일을 너무 많이 만들면 workbook의 스타일 제한에도 걸린다.

그래서 core에는 POI 스타일이 아니라 라이브러리의 CellStyle을 둔다.

data class CellStyle(
    val backgroundColor: Color? = null,
    val fontColor: Color? = null,
    val bold: Boolean = false,
    val alignment: Alignment? = null,
    val numberFormat: String? = null,
)

이 값은 workbook에 묶이지 않는다. 헤더를 굵게 할지, 본문을 오른쪽 정렬할지, 숫자 포맷을 어떻게 둘지만 표현한다. workbook과 결합되는 시점은 렌더링 단계다.

DSL에서는 이렇게 쓴다.

val document = excel(Theme.Modern) {
    sheet<User>("사용자") {
        column("이름") { it.name }
    }
}

렌더링 시점에는 StyleCache가 이 값을 POI CellStyle로 바꾼다.

internal class StyleCache(private val workbook: Workbook) {
    private val cache = mutableMapOf<CellStyle, PoiCellStyle>()

    fun getOrCreate(style: CellStyle): PoiCellStyle {
        return cache.getOrPut(style) { createPoiStyle(style) }
    }
}

이렇게 두면 스타일 정책과 POI 변환 규칙이 분리된다. theme는 스타일 조합을 제공하고, render는 그 값을 workbook에 맞는 POI 객체로 바꾼다.

테마를 바꾸는 일과 POI 스타일 캐시를 손보는 일은 같은 자리에 있을 필요가 없다.

반대 방향의 문제: 업로드

다운로드 쪽부터 보면 DSL이 자연스럽다. 다운로드는 내부 데이터를 외부 파일로 내보내는 일이다. 권한에 따라 컬럼을 숨기거나, 조건에 따라 값을 다르게 보여주거나, 화면마다 다른 정책을 적용해야 한다.

이럴 때는 Kotlin 코드가 편하다.

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

그런데 업로드는 방향이 반대다. 업로드는 외부 파일을 내부 타입으로 바꾸는 일이다. 런타임 context를 조립한다기보다, 외부 데이터를 어떤 내부 모양으로 받아들일지 고정하는 문제에 가깝다.

그래서 annotation은 버린 설계가 아니었다. 다른 입구였다.

@Excel
data class UserUploadRow(
    @Column("이름", aliases = ["성명", "사용자명"])
    val name: String,

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

    @Column("가입일", format = "yyyy-MM-dd")
    val joinedAt: LocalDate,
)

다운로드에서는 annotation이 답답할 수 있다. 화면마다 컬럼이 달라지고, 권한과 조건이 런타임에 결정되기 때문이다. 하지만 업로드에서는 같은 고정성이 오히려 장점이 된다. 어떤 헤더가 어떤 필드로 들어와야 하는지, 어떤 타입으로 변환되어야 하는지, 필수 컬럼인지 아닌지를 타입 옆에 둘 수 있다.

parser는 이 정적 선언을 읽는다.

inline fun <reified T : Any> parseExcel(
    input: InputStream,
    noinline configure: ParseConfig.Builder<T>.() -> Unit = {},
): ParseResult<T> = parseExcel(T::class, input, configure)

parser 내부에서는 annotation에서 컬럼 메타데이터를 추출하고, 헤더를 매칭하고, 셀 값을 변환하고, 검증 오류를 모은다. 여기서 중요한 건 parser가 DSL builder를 몰라도 된다는 점이다.

다운로드와 업로드를 나란히 보면 방향 차이가 더 분명하다.

Rendering diagram...

다운로드는 DSL에서 출발해서 core 모델을 만들고, renderer가 그 모델을 XLSX로 쓴다.

Rendering diagram...

업로드는 외부 XLSX에서 출발한다. annotation으로 선언된 타입 정보를 보고, parser가 헤더를 맞추고 셀 값을 변환한 뒤 내부 객체로 바꾼다. 같은 엑셀 문제를 보고 있지만 출발점이 다르다. 그래서 입구도 다르게 두었다.

경계가 버티는 모습

구조가 의미 있으려면 변경할 때 차이가 나야 한다. 예를 들어 이런 요청이 들어온다.

> 상태 컬럼 하나 바꿔주세요.

이 문장만으로는 아직 어느 코드를 열어야 할지 모른다.

상태 값을 진행중에서 진행 중으로 바꾸는 일이라면 값 표현의 문제다. 특정 권한에서만 상태 컬럼을 숨겨야 한다면 컬럼 노출 정책의 문제다. 업로드할 때 같은 값을 읽지 못한다면 입력 값을 해석하는 규칙의 문제다. 상태에 따라 배경색을 바꾸는 일이라면 스타일 정책의 문제고, 백만 행 다운로드에서 느려졌다면 렌더링 전략의 문제다.

경계가 없으면 이 변경들은 모두 같은 유틸 함수로 모인다. 경계가 있으면 이유에 맞는 자리를 열 수 있다.

값 표현과 컬럼 노출은 엑셀 정의 쪽에서 드러난다. 업로드 해석은 annotation과 parser가 맡는다. 스타일의 의도는 themecore의 값에 남고, 그 값을 POI 객체로 바꾸는 일은 render 안에 있다. SXSSF window size나 스타일 캐시 전략도 renderer의 사정이다.

이런 구분이 있으면 작은 변경을 할 때도 무관한 POI 호출부를 따라가며 다시 읽지 않아도 된다. 엑셀 정의는 선언적으로 모아두고, 그 선언을 해석하는 구현은 각자의 경계 안에 둘 수 있다.

결국 같은 이유로 바뀌는 것끼리 묶는 일

kotlin-excel-dsl의 겉모습은 DSL이다. 하지만 구조의 중심은 DSL 문법이 아니다.

core는 엑셀을 설명한다. excel-dsl은 그 설명을 Kotlin답게 조립한다. render는 설명을 파일로 쓴다. theme는 스타일을 값으로 표현한다. annotation은 업로드 타입을 설명하고, parser는 외부 파일을 내부 객체로 바꾼다.

같은 이유로 바뀌는 것들은 같은 자리에 둔다. 다른 이유로 바뀌는 것들은 떨어뜨린다. 이름을 붙이면 SRP다. 다만 이름은 나중에 붙어도 된다. 출발점은 더 단순한 질문이었다.

엑셀의 구조를 설명하는 모델과 엑셀 파일을 만드는 과정을 어디에서 나눌 것인가.

이 질문은 엑셀에만 갇히지 않는다. CSV, PDF, 외부 API 응답처럼 특정 포맷이나 라이브러리를 다루는 코드에서도 비슷한 경계가 필요하다. kotlin-excel-dsl에서는 그 경계를 corerender, parser 사이에 그었다.

내가 만들고 싶었던 것은 POI를 조금 편하게 감싼 API가 아니었다. 엑셀 파일이 어디에서 설명되고, 어디에서 실제 파일이 되는지 추적할 수 있는 구조였다.