🇺🇸 English version

지난 4편에서 Spring Boot와 TBEG을 연동하여 Excel 다운로드 API를 만들어 봤습니다. 이제 TBEG의 진짜 강력한 기능들을 살펴볼 차례입니다.

수식에 동적 값을 바인딩하고, 이미지를 삽입하고, 같은 값의 셀을 자동으로 병합하고, 독립 영역을 보호하고, 필드를 선택적으로 숨기고, 10만 행을 2.6초에 처리하는 -- 이 모든 것을 하나의 라이브러리로 할 수 있습니다.


수식 활용

Excel 보고서에서 수식은 필수입니다. "합계 행의 SUM 범위를 데이터에 따라 동적으로 바꿔야 하는데..." 이런 고민, 해보셨죠?

TBEG은 수식 안에서도 변수 치환을 지원합니다. ${변수명} 문법을 그대로 수식에 쓸 수 있습니다.

Excel 수식에 변수 바인딩

템플릿 (formula_template.xlsx)

AB
1시작 행${startRow}
2종료 행${endRow}
3
4데이터1100
5데이터2200
6데이터3300
7
8합계=SUM(B${startRow}:B${endRow})

코드

val data = mapOf(
    "startRow" to 4,
    "endRow" to 6
)

ExcelGenerator().use { generator ->
    val template = javaClass.getResourceAsStream("/templates/formula_template.xlsx")
        ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

    val bytes = generator.generate(template, data)
    File("formula_output.xlsx").writeBytes(bytes)
}

결과: B8 셀의 수식이 =SUM(B4:B6)으로 치환되어 600이 계산됩니다.

하이퍼링크

HYPERLINK 수식에도 변수를 바인딩할 수 있습니다.

템플릿: 셀에 수식 설정

=HYPERLINK("${url}", "${text}")

코드

val data = mapOf(
    "text" to "휴넷 홈페이지",
    "url" to "https://www.hunet.co.kr"
)

ExcelGenerator().use { generator ->
    val template = javaClass.getResourceAsStream("/templates/link_template.xlsx")
        ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

    val bytes = generator.generate(template, data)
    File("link_output.xlsx").writeBytes(bytes)
}

💡 SUM, AVERAGE, HYPERLINK 등 어떤 Excel 수식이든 ${변수명} 치환이 가능합니다. 수식 범위뿐 아니라 인자 값도 동적으로 바꿀 수 있습니다.


이미지 삽입

보고서에 로고나 차트 이미지를 넣어야 할 때, POI로 직접 이미지를 삽입하는 코드는 정말 번거롭습니다. TBEG에서는 템플릿에 ${image(이름)} 마커만 넣고, 데이터로 바이트 배열을 전달하면 끝입니다.

로컬 파일에서 이미지 로드

val provider = simpleDataProvider {
    value("title", "보고서")

    // 파일에서 바이트 배열로 읽기
    image("logo", File("logo.png").readBytes())

    // 또는 리소스에서 로드
    image("logo", javaClass.getResourceAsStream("/images/logo.png")!!.readBytes())

    // 지연 로딩도 가능
    image("signature") {
        downloadImage("https://example.com/signatures/user123.png")
    }
}

URL 자동 다운로드

이미지가 외부 서버에 있다면 URL만 전달하면 됩니다. TBEG이 렌더링 시점에 자동으로 다운로드합니다.

val provider = simpleDataProvider {
    value("title", "보고서")
    imageUrl("chart", "https://example.com/chart.png")
}

캐싱 설정: 같은 이미지를 여러 번 생성하는 경우, imageUrlCacheTtlSeconds 설정으로 다운로드 결과를 캐싱할 수 있습니다.

val config = TbegConfig(imageUrlCacheTtlSeconds = 60)  // 60초간 캐싱

ExcelGenerator(config).use { generator ->
    // 같은 URL 이미지는 60초 내 재다운로드 없이 캐시에서 제공
}

다운로드에 실패하면 경고 로그를 출력하고 해당 이미지를 건너뜁니다. Excel 생성 자체는 정상적으로 완료됩니다.


자동 셀 병합 (merge)

부서별 매출 보고서에서 같은 부서명이 반복되면 보기 좋지 않습니다. Excel에서 수동으로 셀을 병합하는 것도 번거롭고요. TBEG의 merge 마커를 사용하면 연속된 같은 값의 셀이 자동으로 병합됩니다.

사용법

템플릿 (dept_merge_template.xlsx)

${merge(s.dept)}가 핵심입니다. 일반 필드(${s.dept})와 동일하게 값을 출력하되, 연속된 같은 값의 셀을 자동으로 병합합니다.

코드

data class SalesRecord(val dept: String, val name: String, val amount: Int, val note: String)

fun main() {
    // ⚠️ 병합 기준(dept)으로 반드시 정렬!
    val data = mapOf(
        "sales" to listOf(
            SalesRecord("공통플랫폼", "황용호", 12000, ""),
            SalesRecord("공통플랫폼", "한용호", 9500, ""),
            SalesRecord("공통플랫폼", "홍용호", 8000, "신규"),
            SalesRecord("IT전략기획", "김철수", 15000, ""),
            SalesRecord("IT전략기획", "이영희", 11000, ""),
        )
    )

    ExcelGenerator().use { generator ->
        val template = javaClass.getResourceAsStream("/templates/dept_merge_template.xlsx")
            ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

        val bytes = generator.generate(template, data)
        File("dept_merge_output.xlsx").writeBytes(bytes)
    }
}

결과

A3:A5가 "공통플랫폼"으로, A6:A7이 "IT전략기획"으로 자동 병합되었습니다. 여러 열에 merge를 적용하면 각 열이 독립적으로 병합됩니다.

⚠️ 데이터 정렬 필수: merge 마커는 연속된 같은 값만 병합합니다. 정렬하지 않으면 같은 부서가 떨어져 있어도 병합되지 않습니다. 반드시 병합 기준 필드로 데이터를 정렬하세요.


요소 묶음 (bundle)

한 시트에 두 개 이상의 repeat 영역이 있고 특정 요소들이 두 개 이상의 repeat의 확장에 각각 영향을 받으면 원래 구상했던 템플릿의 구도가 깨질 수 있습니다.

이런 경우 깨지지 말아야 할 요소들을 bundle 마커로 영역을 묶으면 묶인 영역이 마치 하나의 셀인 것처럼 동시에 움직이며 레이아웃을 깨뜨리지 않습니다.

템플릿 (bundle_template.xlsx)

코드

data class Employee(val name: String, val salary: Int)
data class Department(val name: String, val budget: Int)

fun main() {
    val data = mapOf(
        "employees" to listOf(
            Employee("황용호", 8000),
            Employee("한용호", 6500),
            Employee("홍용호", 4500),
            Employee("김철수", 5500),
            Employee("이영희", 7000),
        ),
        "departments" to listOf(
            Department("공통플랫폼", 50000),
            Department("IT전략기획", 30000),
        )
    )

    ExcelGenerator().use { generator ->
        val template = javaClass.getResourceAsStream("/templates/bundle_template.xlsx")
            ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

        val bytes = generator.generate(template, data)
        File("bundle_output.xlsx").writeBytes(bytes)
    }
}

템플릿에 bundle이 있을 때 vs 없을 때

bundle이 없을 때: A-B 열은 employees 확장에 의해 4칸 밀려나고, D-E 열은 departments 확장에 의해 한 칸 밀려납니다. 그리고 C열과 F열은 밀어내는 요소가 없으므로 원래 템플릿의 위치인 8행을 유지합니다, 즉, 애써 구성해 놓은 표의 레이아웃이 깨지게 됩니다:

bundle이 있을 때: Bundle로 지정된 영역이 하나의 셀처럼 가장 많이 밀리는 만큼 모두 같이 밀리게 되어 표의 레이아웃이 깨지지 않습니다.

bundle을 적용하면 범위로 지정된 영역이 마치 하나의 셀인 것처럼 모두 같이 움직입니다.

📌 bundle 범위는 다른 범위형 요소(병합된 셀, repeat 마커, 다른 bundle 마커 등등)의 범위와 경계에 걸치면 안 됩니다. bundle 안에 전체 범위형 요소의 범위가 다 들어가 있어야 합니다.


선택적 필드 노출 (hideable)

"같은 보고서인데, 경영진용에는 급여를 보여주고 팀장용에는 숨겨야 합니다."

이런 요구사항, 템플릿 2개를 만들어야 할까요? TBEG의 hideable 마커를 사용하면 하나의 템플릿으로 전체 보고서와 축소 보고서를 모두 생성할 수 있습니다.

기본 사용법

템플릿

ABCD
1${repeat(employees, A3:D3, emp)}
2이름부서급여입사일
3${emp.name}${emp.dept}${hideable(value=emp.salary, bundle=C2:C3)}${emp.hireDate}

hideable 마커는 일반 필드처럼 값을 출력하지만, hideFields()를 호출하면 해당 열을 숨길 수 있습니다. bundle=C2:C3으로 헤더(C2)와 데이터(C3)를 함께 묶어서 처리합니다.

코드

// 전체 보고서 (모든 필드 표시)
val fullProvider = simpleDataProvider {
    items("employees", listOf(
        mapOf("name" to "김철수", "dept" to "개발팀", "salary" to 5000, "hireDate" to "2020-01-15"),
        mapOf("name" to "이영희", "dept" to "기획팀", "salary" to 4500, "hireDate" to "2021-03-20")
    ))
    // hideFields를 호출하지 않으면 모든 필드 표시
}

// 축소 보고서 (급여 컬럼 숨김)
val compactProvider = simpleDataProvider {
    items("employees", listOf(
        mapOf("name" to "김철수", "dept" to "개발팀", "salary" to 5000, "hireDate" to "2020-01-15"),
        mapOf("name" to "이영희", "dept" to "기획팀", "salary" to 4500, "hireDate" to "2021-03-20")
    ))
    hideFields("employees", "salary")  // 급여 컬럼 숨김
}

전체 보고서 결과:

ABCD
2이름부서급여입사일
3김철수개발팀5,0002020-01-15
4이영희기획팀4,5002021-03-20

축소 보고서 결과 (급여 열이 삭제됨):

ABC
2이름부서입사일
3김철수개발팀2020-01-15
4이영희기획팀2021-03-20

DELETE vs DIM 모드

모드동작열 구조적합한 경우
DELETE (기본)열을 물리적으로 제거변경됨깔끔한 출력
DIM데이터를 비우고 비활성화 스타일(회색 배경, 연한 글자색) 적용유지됨수식 참조가 깨지면 안 될 때

DIM 모드를 사용하려면 마커에 mode=dim을 추가합니다:

${hideable(value=emp.salary, bundle=C2:C3, mode=dim)}

여러 필드 동시 숨기기

val provider = simpleDataProvider {
    items("employees", employeeList)
    hideFields("employees", "dept", "salary")  // 부서와 급여 동시 숨김
}

💡 핵심 포인트: hideFields()를 호출하지 않으면 hideable 마커는 일반 필드와 동일하게 동작합니다. 따라서 하나의 템플릿으로 전체/축소 보고서를 모두 생성할 수 있습니다.


다중 시트

하나의 데이터를 여러 시트에 바인딩할 수 있습니다. Summary 시트에서 요약 수치를 보여주고, Detail 시트에서 상세 데이터를 나열하는 패턴에 유용합니다.

Summary + Detail 시트 패턴

Summary 시트:

AB
1제목${title}
2총 직원 수${size(employees)}

Employees 시트:

ABC
1${repeat(employees, A3:C3, emp)}
2이름직급연봉
3${emp.name}${emp.position}${emp.salary}

${size(employees)}는 해당 컬렉션의 전체 개수를 출력합니다. 별도의 변수 없이 데이터 건수를 표시할 수 있어 요약 시트에 유용합니다.

코드

data class Employee(val name: String, val position: String, val salary: Int)

fun main() {
    val employees = listOf(
        Employee("황용호", "Manager", 8000),
        Employee("한용호", "Senior", 6500),
        Employee("홍용호", "Junior", 4500)
    )

    val data = mapOf(
        "title" to "직원 현황",
        "employees" to employees
    )

    ExcelGenerator().use { generator ->
        val template = javaClass.getResourceAsStream("/templates/multi_sheet_template.xlsx")
            ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

        val bytes = generator.generate(template, data)
        File("multi_sheet_output.xlsx").writeBytes(bytes)
    }
}

데이터는 한 번만 전달하면 됩니다. TBEG이 모든 시트를 순회하면서 동일한 데이터를 자동으로 바인딩합니다.


비동기 처리

대용량 보고서 생성은 시간이 오래 걸릴 수 있습니다. API 서버에서 사용자가 30초씩 기다리게 할 수는 없죠. TBEG은 세 가지 비동기 처리 방식을 지원합니다.

Kotlin Coroutines

import kotlinx.coroutines.runBlocking
import java.nio.file.Path

fun main() = runBlocking {
    val provider = simpleDataProvider {
        value("title", "비동기 보고서")
        items("data") { generateData().iterator() }
    }

    ExcelGenerator().use { generator ->
        val template = javaClass.getResourceAsStream("/templates/template.xlsx")
            ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

        val path = generator.generateToFileAsync(
            template = template,
            dataProvider = provider,
            outputDir = Path.of("./output"),
            baseFileName = "async_report"
        )

        println("파일 생성됨: $path")
    }
}

Java CompletableFuture

SimpleDataProvider provider = SimpleDataProvider.builder()
    .value("title", "비동기 보고서")
    .items("data", generateData())
    .build();

try (ExcelGenerator generator = new ExcelGenerator();
     InputStream template = getClass().getResourceAsStream("/templates/template.xlsx")) {

    CompletableFuture<Path> future = generator.generateToFileFuture(
        template, provider, Path.of("./output"), "async_report"
    );

    future.thenAccept(path -> {
        System.out.println("파일 생성됨: " + path);
    });

    Path result = future.get();
}

백그라운드 + 리스너 (HTTP 202 패턴)

API 서버에서 가장 실용적인 패턴입니다. 요청을 받으면 즉시 202를 반환하고, 생성 완료 시 리스너로 알림을 받습니다.

val provider = simpleDataProvider {
    value("title", "백그라운드 보고서")
    items("data") { (1..5000).map { mapOf("id" to it) }.iterator() }
}

ExcelGenerator().use { generator ->
    val template = javaClass.getResourceAsStream("/templates/template.xlsx")
        ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

    val job = generator.submitToFile(
        template = template,
        dataProvider = provider,
        outputDir = Path.of("./output"),
        baseFileName = "background_report",
        listener = object : ExcelGenerationListener {
            override fun onStarted(jobId: String) {
                println("[시작] Job ID: $jobId")
            }

            override fun onCompleted(jobId: String, result: GenerationResult) {
                println("[완료] 파일: ${result.filePath}")
                println("[완료] 처리 행: ${result.rowsProcessed}")
                println("[완료] 소요 시간: ${result.durationMs}ms")
            }

            override fun onFailed(jobId: String, error: Exception) {
                println("[실패] ${error.message}")
            }

            override fun onCancelled(jobId: String) {
                println("[취소됨]")
            }
        }
    )

    println("작업 제출됨: ${job.jobId}")
    // API 서버에서는 여기서 HTTP 202 반환
    // job.cancel()로 취소도 가능
}

대용량 데이터 처리

"10만 행을 Excel로 내보내야 하는데 메모리가 터지지 않을까?"

걱정할 필요 없습니다. TBEG은 Apache POI의 SXSSF(스트리밍 모드)를 활용하여 100행 버퍼만 메모리에 유지하면서 순차 기록합니다.

스트리밍 모드 사용법

대용량 처리의 핵심은 count + lazy iterator 조합입니다.

// 대용량 데이터용 설정
val config = TbegConfig(
    progressReportInterval = 1000  // 1000행마다 진행률 보고
)

// 데이터 개수 (DB COUNT 쿼리로 조회)
val dataCount = 1_000_000

val provider = simpleDataProvider {
    value("title", "대용량 보고서")

    // count와 함께 지연 로딩 제공 (최적 성능)
    items("data", dataCount) {
        // 100만 건 데이터를 Sequence로 생성 (메모리에 전부 올리지 않음)
        (1..dataCount).asSequence().map {
            mapOf("id" to it, "value" to it * 10)
        }.iterator()
    }
}

ExcelGenerator(config).use { generator ->
    val template = javaClass.getResourceAsStream("/templates/template.xlsx")
        ?: throw IllegalStateException("템플릿을 찾을 수 없습니다")

    val path = generator.generateToFile(
        template = template,
        dataProvider = provider,
        outputDir = Path.of("./output"),
        baseFileName = "large_report"
    )

    println("파일 생성됨: $path")
}

💡 count를 먼저 제공하는 이유: TBEG이 전체 행 수를 미리 알면 수식 범위를 즉시 계산할 수 있습니다. DB의 SELECT COUNT(*) 쿼리는 인덱스만 사용하므로 매우 빠릅니다.

성능 벤치마크

테스트 환경: Java 21, macOS, 3개 컬럼 repeat + SUM 수식

데이터 크기소요 시간
1,000행146ms
10,000행519ms
30,000행1.1초
50,000행1.3초
100,000행2.6초

10만 행을 2.6초에 처리합니다. 스트리밍 모드 덕분에 메모리 사용량은 데이터 크기와 무관하게 일정합니다.

타 라이브러리 비교 (30,000행)

라이브러리소요 시간
TBEG1.1초
JXLS5.2초

같은 조건에서 TBEG이 JXLS 대비 약 5배 빠릅니다. (JXLS 벤치마크 출처)


종합 예제: 분기 매출 실적 보고서

지금까지 살펴본 기능들을 하나의 보고서에서 모두 활용하는 예제입니다.

템플릿

템플릿 다운로드 (rich_sample_template.xlsx)

템플릿

이 템플릿에는 다음 기능이 모두 사용되었습니다:

  • 변수 마커: ${reportTitle}, ${period}, ${author}, ${reportDate}, ${subtitle_emp}
  • 이미지 마커: ${image(logo,,-1:0)}, ${image(ci)}
  • 반복 마커: 부서별 실적, 제품 카테고리, 직원별 실적 (3개 repeat 영역)
  • 자동 병합 마커: ${merge(emp.dept)}, ${merge(emp.team)}
  • 요소 묶음 마커: ${bundle(B30:K33)} (직원 실적 영역 보호)
  • 수식: SUM, AVERAGE, 셀 간 계산 (Profit = Revenue - Cost)
  • 조건부 서식: 달성률/점유율에 따른 색상 자동 적용
  • 차트: 부서별 매출 막대 차트, 제품 카테고리 파이 차트

코드

import io.github.jogakdal.tbeg.ExcelGenerator
import io.github.jogakdal.tbeg.simpleDataProvider
import java.io.File
import java.nio.file.Path
import java.time.LocalDate

data class DeptResult(val deptName: String, val revenue: Long, val cost: Long, val target: Long)
data class ProductCategory(val category: String, val revenue: Long)
data class Employee(
    val dept: String, val team: String, val name: String, val rank: String,
    val revenue: Long, val cost: Long, val target: Long
)

fun main() {
    val data = simpleDataProvider {
        value("reportTitle", "Q1 2026 매출 실적 보고서")
        value("period", "2026년 1월 ~ 3월")
        value("author", "황용호")
        value("reportDate", LocalDate.now().toString())
        value("subtitle_emp", "Employee Performance Details")
        image("logo", File("logo.png").readBytes())
        image("ci", File("ci.png").readBytes())

        items("depts") {
            listOf(
                DeptResult("공통플랫폼", 52000, 31000, 50000),
                DeptResult("IT전략기획", 38000, 22000, 40000),
                DeptResult("인재경영",   28000, 19000, 30000),
                DeptResult("교육사업",   95000, 61000, 90000),
                DeptResult("L&D연구원", 42000, 28000, 45000),
            ).iterator()
        }

        items("products") {
            listOf(
                ProductCategory("온라인 코스", 128000),
                ProductCategory("컨설팅", 67000),
                ProductCategory("마이크로러닝", 45000),
                ProductCategory("사이트구축", 15000),
            ).iterator()
        }

        items("employees") {
            listOf(
                Employee("공통플랫폼", "전략", "황용호", "Manager", 18000, 11000, 17000),
                Employee("공통플랫폼", "전략", "박성준", "Senior", 15000,  9000, 14000),
                Employee("공통플랫폼", "백엔드", "최창민", "Senior", 12000,  7000, 13000),
                Employee("공통플랫폼", "백엔드", "김현경", "Junior",  7000,  4000,  6000),
                Employee("IT전략기획", "기획", "변재명", "Manager", 20000, 12000, 20000),
                Employee("IT전략기획", "기획", "김민철", "Senior", 11000,  6000, 12000),
                Employee("IT전략기획", "분석", "김민희", "Senior",  7000,  4000,  8000),
                Employee("교육사업",   "영업", "윤서진", "Manager", 35000, 22000, 30000),
                Employee("교육사업",   "영업", "강민우", "Senior", 28000, 18000, 25000),
                Employee("교육사업",   "영업", "임소연", "Junior", 15000, 10000, 15000),
                Employee("교육사업",   "지원", "오준혁", "Senior", 17000, 11000, 20000),
            ).iterator()
        }
    }

    ExcelGenerator().use { generator ->
        val template = File("rich_sample_template.xlsx").inputStream()
        generator.generateToFile(template, data, Path.of("output"), "quarterly_report")
    }
}

결과

결과

TBEG이 자동으로 처리한 항목:

  • 변수 치환 -- 제목, 기간, 작성자, 날짜, 소제목
  • 이미지 삽입 -- 로고, CI
  • 반복 데이터 확장 -- 부서 5행, 제품 4행, 직원 11행으로 확장
  • 자동 셀 병합 -- 같은 부서명/팀명이 연속된 셀을 자동 병합
  • 요소 묶음 -- 직원 실적 영역이 부서 실적 확장에 영향받지 않도록 보호
  • 수식 범위 자동 조정 -- SUM, AVERAGE 범위가 확장된 데이터에 맞게 갱신
  • 조건부 서식 복제 -- 달성률/점유율 색상이 모든 행에 적용
  • 차트 데이터 범위 반영 -- 차트가 확장된 데이터 범위를 자동 참조

마무리

이번 편에서 다룬 고급 기능을 정리하면 다음과 같습니다.

기능마커/API핵심 포인트
수식 변수 바인딩=SUM(B${startRow}:B${endRow})어떤 Excel 수식이든 변수 치환 가능
이미지 삽입image(), imageUrl()바이트 배열, URL, 지연 로딩 지원
자동 셀 병합${merge(item.field)}데이터 정렬 필수
요소 묶음${bundle(A1:E10)}독립 영역 보호
선택적 필드 노출${hideable(...)} + hideFields()DELETE/DIM 모드, 하나의 템플릿으로 다중 보고서
다중 시트템플릿에 시트 추가동일 데이터 자동 바인딩, ${size()}
비동기 처리generateToFileAsync(), submitToFile()Coroutines, CompletableFuture, 리스너
대용량 처리count + lazy iterator10만 행 2.6초, JXLS 대비 5배

다음 편에서는 실전 팁 & 트러블슈팅을 다룹니다. 템플릿 설계 모범 사례, 자주 발생하는 문제와 해결법, 성능 최적화 단계별 가이드 등을 정리합니다.


참고 링크

상세 문서

0개의 댓글