2026-05-11/Kotlin

엑셀 파일 하나 만들면 된다면서요

Kotlin으로 타입 안전한 Excel DSL을 만들게 된 이유

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

이름, 전화번호, 신청일, 상태.
컬럼 몇 개를 넣고 다운로드 버튼을 붙이면 끝날 것 같았다.

그리고 제일 먼저 손이 가는 건 당연히 Apache POI다.

POI 자체가 나쁜 라이브러리라는 뜻은 아니다. 오히려 엑셀 파일을 직접 다뤄야 할 때 필요한 API는 거의 다 있다. Workbook에서 createSheet()를 호출하고, Sheet에서 createRow()를 호출하고, Row에서 createCell()을 호출하고, CellsetCellValue()를 하면 된다. 스타일도 Workbook.createCellStyle()로 만들고, 날짜 포맷은 Workbook.createDataFormat().getFormat(...)으로 얻어서 CellStyle.setDataFormat(...)에 넣으면 된다.

문제는 API가 너무 원초적이라는 데 있다.

간단한 목록 하나를 만들려고 해도 코드는 금방 이런 모양이 된다.

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

val headerStyle = workbook.createCellStyle().apply {
    fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
    fillPattern = FillPatternType.SOLID_FOREGROUND
    alignment = HorizontalAlignment.CENTER
    borderBottom = BorderStyle.THIN
}

val dateStyle = workbook.createCellStyle().apply {
    dataFormat = workbook.createDataFormat().getFormat("yyyy-mm-dd")
}

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

users.forEachIndexed { rowIndex, user ->
    val row = sheet.createRow(rowIndex + 1)

    row.createCell(0).setCellValue(user.name)
    row.createCell(1).setCellValue(user.phoneNumber)

    val createdAtCell = row.createCell(2)
    createdAtCell.setCellValue(user.createdAt.toLocalDate())
    createdAtCell.cellStyle = dateStyle

    row.createCell(3).setCellValue(user.status.displayName)
}

for (columnIndex in 0..3) {
    sheet.autoSizeColumn(columnIndex)
}

아직 별일을 하지도 않았다.
헤더 만들고, 행 만들고, 셀 만들고, 값 넣고, 스타일을 입혔을 뿐이다.

그런데 벌써 엑셀의 의도보다 POI의 절차가 더 크게 보인다.
"사용자 목록에 어떤 컬럼이 있는가"보다 createRow, createCell, setCellValue, cellStyle 호출 순서를 먼저 읽어야 한다.

스타일링은 더 괴롭다.
스타일은 셀에 직접 값을 넣는 게 아니라 워크북에 CellStyle을 만들고, 거기에 포맷과 정렬과 테두리와 색을 설정한 다음, 다시 각 셀에 붙여야 한다. 날짜 하나를 예쁘게 보여주려면 데이터 포맷을 만들고, 스타일에 넣고, 그 스타일을 날짜 셀마다 기억해서 적용해야 한다.

이쯤 되면 유틸을 만들고 싶어진다.
사실 만들 수밖에 없다.

처음에는 유틸 함수면 충분했다

처음에는 단순하게 시작했다.

Apache POI를 감싼 유틸 함수를 만들고, 필요한 곳에서 호출했다.

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

이 정도면 괜찮았다.
정말로 괜찮았다.

문제는 두 번째 엑셀부터 시작된다.

첫 번째 엑셀은 이름, 전화번호, 신청일, 상태를 가진다.
두 번째 엑셀은 이름, 생년월일, 소속, 처리 상태를 가진다.
세 번째 엑셀은 헤더가 두 줄이다.
네 번째 엑셀은 특정 컬럼에만 스타일이 들어간다.
다섯 번째 엑셀은 업로드도 해야 한다.

그리고 여섯 번째 엑셀쯤 되면 코드가 이런 모양이 된다.

createHeader(row)
applyHeaderStyle(row)
writeBodyRows(sheet, items)
applyDateStyle(sheet)
applyNumberStyle(sheet)
resizeColumns(sheet)

이름만 보면 괜찮다.
실제로 들어가 보면 그렇지 않다.

컬럼 순서는 createHeader()에 있고, 값 매핑은 writeBodyRows()에 있고, 스타일은 applyDateStyle()applyNumberStyle()에 흩어져 있다. 업로드 파싱 규칙은 또 다른 파일에 있다.

이제 컬럼 하나를 추가하려면 네 군데를 봐야 한다.

헤더에 추가했는가?
다운로드 값도 넣었는가?
스타일은 적용했는가?
업로드할 때도 읽히는가?
순서는 맞는가?

엑셀 파일 하나 만들면 된다면서요.

annotation은 편했지만, 금방 답답해졌다

그래서 다음으로 생각한 방식은 annotation이었다.

data class UserExcelRow(
    @ExcelColumn("이름")
    val name: String,

    @ExcelColumn("전화번호")
    val phoneNumber: String,

    @ExcelColumn("신청일")
    val createdAt: LocalDateTime,
)

이 방식은 꽤 편하다.
단순한 엑셀이라면 이보다 더 직관적인 방법도 많지 않다.

컬럼 이름은 필드 옆에 있다.
순서도 지정할 수 있다.
파싱할 때도 같은 모델을 사용할 수 있다.

그런데 annotation은 구조가 단순할 때 가장 빛난다.
반대로 말하면, 구조가 복잡해지면 금방 답답해진다.

예를 들어 이런 요구사항이 들어온다.

  • 특정 권한에서는 일부 컬럼을 숨겨야 한다.
  • 특정 조건에서는 값 대신 -를 내려줘야 한다.
  • 컬럼의 스타일이 런타임 값에 따라 달라진다.
  • 어떤 엑셀은 다운로드만 하고, 어떤 엑셀은 업로드도 해야 한다.
  • 헤더 구조가 단순한 한 줄이 아니다.
  • 같은 필드라도 화면마다 컬럼명이나 표현 방식이 달라진다.

annotation은 정적이다.
런타임 context나 화면별 정책을 자연스럽게 끌고 들어오기 어렵다.

물론 annotation 안에 여러 옵션을 계속 추가할 수도 있다.

@ExcelColumn(
    name = "내부 메모",
    visibleWhen = "...",
    style = "...",
    parser = "..."
)

하지만 이렇게 가기 시작하면 annotation이 점점 설정 파일처럼 변한다.
처음에는 단순한 표시였던 것이 점점 작은 설정 언어가 된다.

이건 내가 원하던 단순함이 아니었다.

"이 권한에서는 이 컬럼 숨겨주세요"

그러다 이런 요청이 들어왔다.

"혹시 이 권한에서 다운로드할 때는 이 컬럼들을 숨겨줄 수 있나요?"

아.
이제 엑셀은 단순한 다운로드 파일이 아니었다.

같은 목록을 내려받더라도 관리자와 일반 사용자가 보는 파일이 달라야 했다. 어떤 사람에게는 내부 메모 컬럼이 보여야 했고, 어떤 사람에게는 보이면 안 됐다.

컬럼 하나의 문제가 아니었다.
엑셀 정의 안에 권한이 들어오기 시작했다.

이때부터 코드는 조금씩 흩어지기 시작했다.

컬럼 정의는 mapper에 있고, 권한 체크는 service에 있고, 스타일은 util에 있었다. 처음에는 역할이 나뉘어 있어서 깔끔해 보였다. 그런데 요구사항이 몇 번 바뀌고 나면 상황이 달라진다.

관리자는 왜 이 컬럼을 보고 있는가.
일반 사용자는 어디서 이 컬럼이 빠졌는가.
값이 -로 바뀌는 조건은 어디에 있는가.

세 번째 질문쯤 오면 코드가 갑자기 추리소설이 된다.

범인은 대체 어디서 컬럼을 숨겼는가.

컬럼을 한곳에서 보고 싶었다

엑셀 코드를 작성하면서 가장 싫었던 건 "한 컬럼에 대한 정보가 흩어지는 것"이었다.

실무에서 엑셀 컬럼은 작은 업무 규칙에 가까웠다.

누구에게 보여야 하는가.
값이 없으면 어떻게 표현할 것인가.
날짜는 어떤 포맷으로 보여줄 것인가.
다시 업로드될 때는 어떤 타입으로 읽을 것인가.

그런데 기존 코드는 이 질문들을 한곳에 두지 못했다. 컬럼 이름은 헤더 생성 코드에 있고, 값 매핑은 row 생성 코드에 있고, 포맷은 스타일 유틸에 있고, 업로드 parser는 또 다른 파일에 있었다.

결국 컬럼 하나를 바꾸려면 여러 파일을 동시에 믿어야 했다.

그리고 나는 그런 믿음이 별로 없었다.

그래서 Kotlin DSL로 표현했다

그래서 해결책은 자연스럽게 한 방향으로 모였다.

컬럼을 정의하는 곳에서 컬럼의 이름, 값, 조건, 표현 방식을 같이 보고 싶었다. annotation처럼 선언적으로 읽히되, 필요할 때는 일반 Kotlin 코드처럼 조건문도 쓸 수 있어야 했다. context를 넘기고, 분기하고, 확장 함수를 쓰고, 도메인 객체의 값을 자연스럽게 꺼내는 쪽이 맞다고 봤다.

처음 모양은 아주 단순했다.

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

        rows(users)
    }
}.writeTo(output)

이 코드에서 가장 먼저 보고 싶었던 건 "엑셀을 어떻게 만드는가"가 아니라 "이 엑셀이 무엇을 담는가"였다.

Workbook을 만들고, Sheet를 만들고, Row를 만들고, Cell을 만드는 절차는 뒤로 빠진다. 대신 사용자 시트에는 이름, 전화번호, 신청일, 상태 컬럼이 있고, 각 컬럼의 값은 어디서 오는지가 바로 보인다.

여기까지만 해도 유틸 함수보다 낫다.
컬럼 이름과 값 매핑이 같은 줄에 있기 때문이다.

그 다음에 필요한 건 표현 방식이었다.

excel(theme = Theme.Modern) {
    styles {
        header {
            bold()
            align(Alignment.CENTER)
            backgroundColor(Color.GRAY)
            fontColor(Color.WHITE)
        }
        column("신청일") {
            body {
                align(Alignment.CENTER)
                numberFormat("yyyy-mm-dd")
            }
        }
    }

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

        rows(users)
    }
}.writeTo(output)

엑셀은 사람이 열어보는 파일이다. 스타일은 빠질 수 없다.

대신 스타일이 엑셀 정의 밖으로 도망가지 않게 해야 했다.
헤더 스타일은 헤더 근처에 있고, 신청일 컬럼의 날짜 포맷은 신청일이라는 이름으로 다시 찾을 수 있다.

조금 더 작은 경우에는 컬럼 옆에 바로 붙일 수도 있다.

sheet<Product>("상품") {
    column("상품명") { it.name }
    column(
        "가격",
        bodyStyle = { align(Alignment.RIGHT); numberFormat("#,##0") }
    ) { it.price }

    rows(products)
}

이 정도의 차이가 실무에서는 꽤 크다.
나중에 "가격 컬럼 오른쪽 정렬이 왜 들어갔지?"라고 물어볼 때, 별도의 applyPriceStyle()을 찾아다니지 않아도 된다.

그리고 DSL을 고른 진짜 이유는 여기서부터 나온다.
엑셀 정의 안에 Kotlin 코드를 그대로 쓸 수 있다.

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

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

        rows(users)
    }
}.writeTo(output)

권한에 따라 컬럼이 달라지는 요구사항을 annotation 옵션으로 밀어 넣고 싶지 않았다.

visibleWhen = "..." 같은 문자열을 만들고, 그 문자열을 해석하는 작은 설정 언어를 다시 만들 필요도 없었다. 이미 Kotlin에는 if가 있고, 함수가 있고, 타입이 있고, IDE의 도움도 있다.

상태에 따라 값 표현이 달라지는 것도 마찬가지다.

sheet<User>("사용자") {
    column("이름") { it.name }
    column("승인일") { user ->
        if (user.approvedAt == null) "-" else user.approvedAt.toLocalDate()
    }
    column("상태", conditionalStyle = { value: String? ->
        when (value) {
            "반려" -> fontColor(Color.RED)
            "승인" -> fontColor(Color.GREEN)
            else -> null
        }
    }) { it.status.displayName }

    rows(users)
}

값을 어떻게 보여줄지, 그 값에 어떤 스타일을 줄지, 둘 다 같은 컬럼 옆에 붙어 있다.
이게 내가 원했던 모양에 가까웠다.

엑셀이 조금 더 문서처럼 변하면 헤더도 한 줄로 끝나지 않는다.

excel {
    sheet<OrderRow>("주문 정산") {
        headerGroup("주문 정보") {
            column("주문번호") { it.orderNo }
            column("고객명") { it.customerName }
        }
        headerGroup("정산") {
            column("금액", bodyStyle = { align(Alignment.RIGHT); numberFormat("#,##0") }) { it.amount }
            column("수수료", bodyStyle = { align(Alignment.RIGHT); numberFormat("#,##0") }) { it.fee }
            column("합계") { formula("SUM(C2:D2)") }
        }

        rows(orders)
    }
}.writeTo(output)

이런 구조를 POI로 직접 만들면 병합 셀, 헤더 행, 컬럼 인덱스가 금방 코드의 주인공이 된다. 그런데 업무적으로 중요한 건 "C열과 D열을 병합했는가"보다 "주문 정보와 정산 정보가 나뉘어 있는가"에 가깝다.

그래서 headerGroup은 헤더를 그리는 기능이라기보다, 엑셀의 의미 단위를 코드에 남기는 장치에 가까웠다.

사용자가 실제로 파일을 열어볼 때 필요한 기능도 같은 문맥에 둘 수 있다.

sheet<OrderRow>("주문 정산") {
    freezePane(row = 2)
    autoFilter()
    alternateRowStyle {
        backgroundColor(Color.LIGHT_GRAY)
    }

    // columns...
    rows(orders)
}

틀 고정, 자동 필터, 줄무늬 행은 핵심 업무 규칙은 아닐 수 있다.
하지만 사용자가 파일을 열자마자 체감하는 건 이런 것들이다.

헤더가 고정되어 있는가.
필터를 바로 걸 수 있는가.
행을 따라 읽다가 눈이 미아가 되지 않는가.

사소해 보인다.
그리고 이런 사소한 것들이 빠지면, 엑셀은 바로 귀찮은 파일이 된다.

그러니 이것들도 후처리 함수로 밀어내기보다 시트를 설명하는 코드 안에 있어야 했다. 스타일, 그룹 헤더, 조건부 스타일, 수식, 필터가 각각 다른 유틸 함수와 설정 파일로 흩어지는 순간, 다시 처음 문제로 돌아간다.

다운로드와 업로드도 같은 생각의 연장선에 있었다.

다만 업로드에서는 annotation이 다시 등장한다.

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

    @Column("이메일", aliases = ["Email", "E-mail"])
    val email: String,
)

val result = parseExcel<UserUploadRow>(inputStream) {
    headerMatching = HeaderMatching.FLEXIBLE
    skipEmptyRows = true
    trimWhitespace = true

    validateRow { row ->
        require(row.email.contains("@")) { "이메일 형식 오류" }
    }
}

앞에서 annotation이 답답하다고 말해놓고 다시 annotation을 쓰는 게 이상해 보일 수 있다.
맞다. 나도 안다. 방금까지 annotation 욕했다.

그런데 업로드는 다운로드와 문제가 조금 다르다.

다운로드는 내부 데이터를 어떤 화면, 어떤 권한, 어떤 표현 방식으로 내보낼지의 문제다. 그래서 런타임 context와 조건 분기가 중요하다.

반대로 업로드는 외부에서 들어온 파일을 내부에서 다룰 수 있는 객체로 바꾸는 일이다. 이때는 오히려 정적 선언이 안전하다. 헤더 이름이 무엇인지, 어떤 타입으로 읽을지, 내부에서는 어떤 프로퍼티로 다룰지가 먼저 고정되어야 한다. 외부 데이터를 내부 객체로 다루려면 타입 정의가 필수이기 때문이다.

대신 헤더 매칭 방식, 공백 처리, 행 단위 검증처럼 실행 시점에 필요한 정책은 parseExcel { ... } 블록 안에서 Kotlin 코드로 다룬다. annotation은 구조를 잡고, DSL 블록은 처리 정책을 잡는다.

성능도 호출부에서 신경 쓸 문제가 아니어야 했다. 내부에서는 SXSSF 기반 스트리밍을 쓰고, 벤치마크 기준 1M 행도 약 3.5초, 평균 136MB, 최대 약 193MB 안에서 처리된다.

좋다.
엑셀 백만 줄.
메모리 200MB 아래.

이 정도면 적어도 "다운로드 버튼 눌렀더니 서버가 죽었다" 같은 장르는 피할 수 있다.

여기까지 오고 나서야 처음 원하던 모양이 보였다.

컬럼 이름은 값 옆에 있다.
스타일은 컬럼 근처에 있다.
권한 분기는 Kotlin 코드로 읽힌다.
업로드 타입은 명시되어 있다.
대용량 처리는 호출부 바깥으로 숨었다.

각각은 작은 기능이다.
하지만 흩어지면 다시 무서워진다.

엑셀을 설명하는 코드를 만들고 싶었다

처음에는 엑셀 파일을 만드는 코드라고 생각했다.

그런데 계속 만들다 보니, 진짜 문제는 파일을 찍어내는 일이 아니었다.
이 엑셀이 무엇인지 한곳에서 말할 수 있느냐가 문제였다.

어떤 컬럼이 있는가.
값은 어디서 오는가.
누구에게 보여야 하는가.
어떤 모양으로 나가야 하는가.
다시 들어올 때는 어떤 타입이 되어야 하는가.

같은 이유로 바뀌는 것들은 같은 자리에 있어야 한다.
말로 하면 응집도지만, 실무에서는 보통 이런 장면이다.

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

그리고 나는 헤더 생성 코드, 값 매핑 코드, 스타일 유틸, 권한 분기, 업로드 parser를 차례로 연다.
좋다. 응집도 사망.

내가 원한 건 대단한 추상화가 아니었다.
같이 바뀌는 것들이 같이 보이는 것. 그게 전부였다.

컬럼 이름은 값 옆에. 스타일은 컬럼 근처에. 권한 조건은 컬럼이 사라지는 바로 그 자리에. 업로드 타입과 검증 규칙도 엑셀 정의에서 멀리 떨어지지 않게.

그렇게 모이고 나면, 엑셀 코드는 파일을 찍어내는 절차가 아니라 이 엑셀이 무엇인지 설명하는 코드가 된다.

이 프로젝트는 엑셀을 잘 만들기 위한 라이브러리로 시작했다. 그런데 만들고 보니, kotlin-excel-dsl은 이 엑셀 파일이 도대체 어디서 이렇게 만들어졌는지 알 수 없게 되는 순간을 한 발짝 미루기 위한 라이브러리에 가까웠다.

엑셀 코드가 슬슬 추리소설처럼 읽히기 시작했다면, 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