3편에서는 TBEG의 템플릿 문법을 깊이 살펴봤습니다. 이제 실무에서 가장 많이 쓰이는 조합인 Spring Boot + TBEG으로 Excel 다운로드 API를 만들어 보겠습니다.
의존성 하나 추가하면 ExcelGenerator Bean이 자동 등록되고, Controller에서 바로 Excel 파일을 응답할 수 있습니다.
// build.gradle.kts
dependencies {
implementation("io.github.jogakdal:tbeg:1.2.3")
}
Groovy DSL, Maven 등 다른 빌드 도구는 GitHub 문서를 참고하세요.
tbeg:
file-naming-mode: timestamp # none, timestamp
timestamp-format: yyyyMMdd_HHmmss
file-conflict-policy: sequence # error, sequence
preserve-template-layout: true
missing-data-behavior: warn # warn, throw
unmarked-hide-policy: warn-and-hide # warn-and-hide, error
주요 옵션만 추렸습니다. 전체 설정 옵션은 Configuration Reference에서 확인하세요.
tbeg 의존성을 추가하면 TbegAutoConfiguration이 자동으로 활성화됩니다.
ExcelGenerator Bean 자동 등록application.yml의 tbeg.* 프로퍼티를 TbegProperties로 바인딩별도의 @Bean 정의나 @EnableXxx 어노테이션이 필요 없습니다.
@Service
class ReportService(
private val excelGenerator: ExcelGenerator,
private val resourceLoader: ResourceLoader,
private val employeeRepository: EmployeeRepository
) {
/** Map으로 간단한 보고서 생성 */
fun generateSimpleReport(): ByteArray {
val template = resourceLoader.getResource("classpath:templates/simple.xlsx")
val data = mapOf(
"title" to "간단 보고서",
"date" to LocalDate.now().toString(),
"author" to "황용호"
)
return excelGenerator.generate(template.inputStream, data)
}
/** DataProvider로 직원 보고서 생성 */
fun generateEmployeeReport(): Path {
val template = resourceLoader.getResource("classpath:templates/employees.xlsx")
val employeeCount = employeeRepository.count().toInt()
val provider = simpleDataProvider {
value("title", "직원 현황 보고서")
value("date", LocalDate.now().toString())
// count를 미리 제공하면 수식 범위를 즉시 계산할 수 있고,
// 이중 순회를 방지하여 성능이 향상됩니다
items("employees", employeeCount) {
employeeRepository.findAll().iterator()
}
metadata {
title = "직원 현황 보고서"
author = "인사 시스템"
company = "(주)휴넷"
}
}
return excelGenerator.generateToFile(
template = template.inputStream,
dataProvider = provider,
outputDir = Path.of("/var/reports"),
baseFileName = "employee_report"
)
}
}
@Service
public class ReportService {
private final ExcelGenerator excelGenerator;
private final ResourceLoader resourceLoader;
private final EmployeeRepository employeeRepository;
public ReportService(
ExcelGenerator excelGenerator,
ResourceLoader resourceLoader,
EmployeeRepository employeeRepository) {
this.excelGenerator = excelGenerator;
this.resourceLoader = resourceLoader;
this.employeeRepository = employeeRepository;
}
public byte[] generateSimpleReport() throws IOException {
var template = resourceLoader.getResource("classpath:templates/simple.xlsx");
Map<String, Object> data = new HashMap<>();
data.put("title", "간단 보고서");
data.put("date", LocalDate.now().toString());
data.put("author", "황용호");
return excelGenerator.generate(template.getInputStream(), data);
}
public Path generateEmployeeReport() throws IOException {
var template = resourceLoader.getResource("classpath:templates/employees.xlsx");
int employeeCount = (int) employeeRepository.count();
var provider = SimpleDataProvider.builder()
.value("title", "직원 현황 보고서")
.value("date", LocalDate.now().toString())
.itemsFromSupplier("employees", employeeCount,
() -> employeeRepository.findAll().iterator())
.metadata(meta -> meta
.title("직원 현황 보고서")
.author("인사 시스템")
.company("(주)휴넷"))
.build();
return excelGenerator.generateToFile(
template.getInputStream(),
provider,
Path.of("/var/reports"),
"employee_report"
);
}
}
핵심 포인트:
ExcelGenerator는 생성자 주입으로 바로 사용generate() → ByteArray 반환 (Controller 응답에 적합)generateToFile() → 파일 Path 반환 (서버에 저장)simpleDataProvider { } DSL로 데이터를 선언적으로 구성실무에서 가장 흔한 패턴 — GET 요청 → Excel 파일 다운로드입니다.
@RestController
@RequestMapping("/api/reports")
class ReportController(
private val excelGenerator: ExcelGenerator,
private val resourceLoader: ResourceLoader,
private val employeeRepository: EmployeeRepository
) {
@GetMapping("/employees/download")
fun downloadEmployeeReport(): ResponseEntity<Resource> {
val template = resourceLoader.getResource("classpath:templates/employees.xlsx")
val data = mapOf(
"title" to "직원 현황",
"date" to LocalDate.now().toString(),
"employees" to employeeRepository.findAll()
)
val bytes = excelGenerator.generate(template.inputStream, data)
val filename = "employee_status_${LocalDate.now()}.xlsx"
val encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''$encodedFilename"
)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
))
.contentLength(bytes.size.toLong())
.body(ByteArrayResource(bytes))
}
}
@RestController
@RequestMapping("/api/reports")
public class ReportController {
private final ExcelGenerator excelGenerator;
private final ResourceLoader resourceLoader;
private final EmployeeRepository employeeRepository;
// Constructor 생략...
@GetMapping("/employees/download")
public ResponseEntity<Resource> downloadEmployeeReport() throws IOException {
var template = resourceLoader.getResource("classpath:templates/employees.xlsx");
Map<String, Object> data = new HashMap<>();
data.put("title", "직원 현황");
data.put("date", LocalDate.now().toString());
data.put("employees", employeeRepository.findAll());
byte[] bytes = excelGenerator.generate(template.getInputStream(), data);
String filename = "employee_status_" + LocalDate.now() + ".xlsx";
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedFilename
)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
))
.contentLength(bytes.length)
.body(new ByteArrayResource(bytes));
}
}
핵심 포인트:
Content-Disposition: filename*=UTF-8''로 한글 파일명 인코딩Content-Type: .xlsx 전용 MIME 타입 지정ContentLength: 브라우저가 다운로드 진행률을 표시하도록 설정위의 Controller 예제에서는 generate()로 ByteArray를 먼저 생성한 후 응답에 담았습니다. 대용량 파일의 경우 generateToStream()으로 HTTP 응답 스트림에 직접 쓰면 메모리 사용을 줄일 수 있습니다.
@GetMapping("/employees/download/stream")
fun downloadEmployeeReportStream(): ResponseEntity<StreamingResponseBody> {
val template = resourceLoader.getResource("classpath:templates/employees.xlsx")
val data = mapOf(
"title" to "Employee Status",
"date" to LocalDate.now().toString(),
"employees" to employeeRepository.findAll()
)
val filename = "employee_status_${LocalDate.now()}.xlsx"
val encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8)
val body = StreamingResponseBody { outputStream ->
excelGenerator.generateToStream(template.inputStream, data, outputStream)
}
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''$encodedFilename"
)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
))
.body(body)
}
@GetMapping("/employees/download/stream")
public ResponseEntity<StreamingResponseBody> downloadEmployeeReportStream()
throws IOException {
var template = resourceLoader.getResource("classpath:templates/employees.xlsx");
Map<String, Object> data = new HashMap<>();
data.put("title", "Employee Status");
data.put("date", LocalDate.now().toString());
data.put("employees", employeeRepository.findAll());
String filename = "employee_status_" + LocalDate.now() + ".xlsx";
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
StreamingResponseBody body = outputStream ->
excelGenerator.generateToStream(template.getInputStream(), data, outputStream);
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedFilename
)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
))
.body(body);
}
핵심 포인트:
StreamingResponseBody는 별도 스레드에서 실행되어 서블릿 스레드를 블로킹하지 않음generate()가 ByteArray를 반환하는 것과 달리, generateToStream()은 OutputStream에 직접 써서 응답 코드가 간결해짐ContentLength를 설정할 수 없음 (크기를 미리 알 수 없으므로) — 브라우저에서 다운로드 진행률 대신 용량만 표시됨대용량 보고서는 생성에 시간이 걸릴 수 있습니다. submitToFile()을 사용하면 요청 즉시 202 Accepted를 반환하고, 백그라운드에서 생성이 완료되면 이벤트로 알려줍니다.
@PostMapping("/employees/async")
fun generateReportAsync(
@RequestBody request: ReportRequest
): ResponseEntity<JobResponse> {
val template = resourceLoader.getResource("classpath:templates/employees.xlsx")
val provider = simpleDataProvider {
value("title", request.title)
items("employees") {
employeeRepository.findByHireDateBetween(
request.startDate, request.endDate
).iterator()
}
}
val job = excelGenerator.submitToFile(
template = template.inputStream,
dataProvider = provider,
outputDir = Path.of("/var/reports"),
baseFileName = "employee_report",
listener = object : ExcelGenerationListener {
override fun onCompleted(jobId: String, result: GenerationResult) {
eventPublisher.publishEvent(
ReportReadyEvent(jobId, result.filePath!!, result.rowsProcessed)
)
}
override fun onFailed(jobId: String, error: Exception) {
eventPublisher.publishEvent(
ReportFailedEvent(jobId, error.message)
)
}
}
)
return ResponseEntity.accepted().body(JobResponse(job.jobId))
}
@PostMapping("/employees/async")
public ResponseEntity<JobResponse> generateReportAsync(
@RequestBody ReportRequest request
) throws IOException {
var template = resourceLoader.getResource("classpath:templates/employees.xlsx");
var provider = SimpleDataProvider.builder()
.value("title", request.getTitle())
.itemsFromSupplier("employees",
() -> employeeRepository.findByHireDateBetween(
request.getStartDate(), request.getEndDate()
).iterator())
.build();
var job = excelGenerator.submitToFile(
template.getInputStream(),
provider,
Path.of("/var/reports"),
"employee_report",
new ExcelGenerationListener() {
@Override
public void onCompleted(String jobId, GenerationResult result) {
eventPublisher.publishEvent(
new ReportReadyEvent(jobId, result.getFilePath(), result.getRowsProcessed())
);
}
@Override
public void onFailed(String jobId, Exception error) {
eventPublisher.publishEvent(
new ReportFailedEvent(jobId, error.getMessage())
);
}
}
);
return ResponseEntity.accepted().body(new JobResponse(job.getJobId()));
}
ExcelGenerationListener의 콜백:
| 콜백 | 시점 |
|---|---|
onStarted(jobId) | 생성 시작 |
onProgress(jobId, progress) | 설정된 행 간격마다 |
onCompleted(jobId, result) | 생성 완료 |
onFailed(jobId, error) | 오류 발생 |
Spring의 @EventListener와 조합하면 이메일 알림, WebSocket 푸시 등 다양한 후속 처리가 가능합니다.
비동기 API + WebSocket 실시간 진행률 통합 예제는 GitHub 문서를 참고하세요.
수십만 건 이상의 데이터를 처리할 때는 JPA Stream으로 메모리를 효율적으로 사용할 수 있습니다.
@Service
class LargeReportService(
private val excelGenerator: ExcelGenerator,
private val resourceLoader: ResourceLoader,
private val employeeRepository: EmployeeRepository
) {
@Transactional(readOnly = true) // Stream 유지를 위해 필수
fun generateLargeEmployeeReport(): Path {
val template = resourceLoader.getResource("classpath:templates/employees.xlsx")
val employeeCount = employeeRepository.count().toInt()
val provider = simpleDataProvider {
value("title", "전 직원 현황")
items("employees", employeeCount) {
employeeRepository.streamAll().iterator()
}
}
return excelGenerator.generateToFile(
template = template.inputStream,
dataProvider = provider,
outputDir = Path.of("/var/reports"),
baseFileName = "all_employees"
)
}
}
@Service
public class LargeReportService {
private final ExcelGenerator excelGenerator;
private final ResourceLoader resourceLoader;
private final EmployeeRepository employeeRepository;
// Constructor 생략...
@Transactional(readOnly = true)
public Path generateLargeEmployeeReport() throws IOException {
var template = resourceLoader.getResource("classpath:templates/employees.xlsx");
int employeeCount = (int) employeeRepository.count();
var provider = SimpleDataProvider.builder()
.value("title", "전 직원 현황")
.itemsFromSupplier("employees", employeeCount,
() -> employeeRepository.streamAll().iterator())
.build();
return excelGenerator.generateToFile(
template.getInputStream(),
provider,
Path.of("/var/reports"),
"all_employees"
);
}
}
주의: JPA Stream을 사용할 때는 반드시
@Transactional어노테이션을 붙여야 합니다. 트랜잭션이 끝나면 Stream이 닫히므로, Excel 생성이 완료될 때까지 트랜잭션이 유지되어야 합니다.
페이징 Iterator, MyBatis 연동 등 더 상세한 내용은 Advanced Examples를 참고하세요.
class ReportServiceTest {
private val excelGenerator = ExcelGenerator()
private val employeeRepository = mockk<EmployeeRepository>()
@Test
fun `직원 보고서 생성 테스트`() {
// Given
every { employeeRepository.findAll() } returns listOf(
Employee(1, "황용호", "공통플랫폼팀", 5000),
Employee(2, "홍용호", "IT전략팀", 4500)
)
val template = ClassPathResource("templates/employees.xlsx")
// When
val bytes = excelGenerator.generate(
template.inputStream,
mapOf(
"title" to "테스트 보고서",
"employees" to employeeRepository.findAll()
)
)
// Then
XSSFWorkbook(ByteArrayInputStream(bytes)).use { workbook ->
val sheet = workbook.getSheetAt(0)
assertEquals("테스트 보고서", sheet.getRow(0).getCell(0).stringCellValue)
assertEquals("황용호", sheet.getRow(2).getCell(0).stringCellValue)
assertEquals("홍용호", sheet.getRow(3).getCell(0).stringCellValue)
}
}
}
class ReportServiceTest {
private final ExcelGenerator excelGenerator = new ExcelGenerator();
@Test
void employeeReportGenerationTest() throws Exception {
// Given
var employees = List.of(
new Employee(1, "황용호", "공통플랫폼팀", 5000),
new Employee(2, "홍용호", "IT전략팀", 4500)
);
var template = new ClassPathResource("templates/employees.xlsx");
// When
byte[] bytes = excelGenerator.generate(
template.getInputStream(),
Map.of(
"title", "테스트 보고서",
"employees", employees
)
);
// Then
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
var sheet = workbook.getSheetAt(0);
assertEquals("테스트 보고서", sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("황용호", sheet.getRow(2).getCell(0).getStringCellValue());
assertEquals("홍용호", sheet.getRow(3).getCell(0).getStringCellValue());
}
}
}
class ReportServiceTest {
private final ExcelGenerator excelGenerator = new ExcelGenerator();
@Test
void employeeReportGenerationTest() throws Exception {
// Given
var employees = List.of(
new Employee(1, "황용호", "공통플랫폼팀", 5000),
new Employee(2, "홍용호", "IT전략팀", 4500)
);
var template = new ClassPathResource("templates/employees.xlsx");
// When
byte[] bytes = excelGenerator.generate(
template.getInputStream(),
Map.of(
"title", "테스트 보고서",
"employees", employees
)
);
// Then
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
var sheet = workbook.getSheetAt(0);
assertEquals("테스트 보고서", sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("황용호", sheet.getRow(2).getCell(0).getStringCellValue());
assertEquals("홍용호", sheet.getRow(3).getCell(0).getStringCellValue());
}
}
}
@SpringBootTest
@AutoConfigureMockMvc
class ReportControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `직원 보고서 다운로드 테스트`() {
mockMvc.get("/api/reports/employees/download")
.andExpect {
status { isOk() }
header {
string("Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
}
header { exists("Content-Disposition") }
}
}
@Test
fun `비동기 보고서 생성 요청 테스트`() {
mockMvc.post("/api/reports/employees/async") {
contentType = MediaType.APPLICATION_JSON
content = """
{
"title": "테스트 보고서",
"startDate": "2026-01-01",
"endDate": "2026-01-31"
}
""".trimIndent()
}.andExpect {
status { isAccepted() }
jsonPath("$.jobId") { exists() }
}
}
}
@SpringBootTest
@AutoConfigureMockMvc
class ReportControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void downloadEmployeeReport() throws Exception {
mockMvc.perform(get("/api/reports/employees/download"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(header().exists("Content-Disposition"));
}
@Test
void asyncReportGeneration() throws Exception {
mockMvc.perform(post("/api/reports/employees/async")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"테스트 보고서\",\"startDate\":\"2026-01-01\",\"endDate\":\"2026-01-31\"}"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.jobId").exists());
}
}
Spring Boot에서 TBEG를 사용하는 핵심 패턴을 정리하면:
| 패턴 | 설명 |
|---|---|
| 의존성 추가 | tbeg 하나만 추가하면 Auto-Configuration 완료 |
| Service | ExcelGenerator 주입 → generate() / generateToFile() |
| Controller | ByteArray → ResponseEntity<Resource>로 다운로드 응답 |
| 비동기 | submitToFile() + ExcelGenerationListener |
| Streaming | generateToStream() + StreamingResponseBody |
| JPA Stream | @Transactional + streamAll().iterator() |
| 테스트 | MockMvc로 Controller 통합 테스트 |
전체 소스 코드와 더 많은 예제는 GitHub 문서에서 확인하세요.
다음 편: 5편 — TBEG 고급 기능: 차트, 이미지, 대용량 처리. 차트 자동 조정, 이미지 삽입, 대용량 데이터 스트리밍 처리를 다룹니다.