🇺🇸 English version

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 문서를 참고하세요.

application.yml

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에서 확인하세요.

Auto-Configuration

tbeg 의존성을 추가하면 TbegAutoConfiguration이 자동으로 활성화됩니다.

  • ExcelGenerator Bean 자동 등록
  • application.ymltbeg.* 프로퍼티를 TbegProperties로 바인딩
  • 애플리케이션 종료 시 자동 리소스 정리

별도의 @Bean 정의나 @EnableXxx 어노테이션이 필요 없습니다.


Service 패턴

Kotlin

@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"
        )
    }
}

Java

@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로 데이터를 선언적으로 구성

Controller에서 Excel 다운로드

실무에서 가장 흔한 패턴 — GET 요청 → Excel 파일 다운로드입니다.

Kotlin

@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))
    }
}

Java

@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 응답 스트림에 직접 쓰면 메모리 사용을 줄일 수 있습니다.

Kotlin

@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)
}

Java

@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를 반환하고, 백그라운드에서 생성이 완료되면 이벤트로 알려줍니다.

Kotlin

@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))
}

Java

@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 연동

수십만 건 이상의 데이터를 처리할 때는 JPA Stream으로 메모리를 효율적으로 사용할 수 있습니다.

Kotlin

@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"
        )
    }
}

Java

@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를 참고하세요.


테스트 작성

Service 단위 테스트 (Kotlin)

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)
        }
    }
}

Service 단위 테스트 (Java)

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());
        }
    }
}

Service 단위 테스트 (Java)

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());
        }
    }
}

Controller 통합 테스트 (Kotlin)

@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() }
        }
    }
}

Controller 통합 테스트 (Java)

@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 완료
ServiceExcelGenerator 주입 → generate() / generateToFile()
ControllerByteArrayResponseEntity<Resource>로 다운로드 응답
비동기submitToFile() + ExcelGenerationListener
StreaminggenerateToStream() + StreamingResponseBody
JPA Stream@Transactional + streamAll().iterator()
테스트MockMvc로 Controller 통합 테스트

전체 소스 코드와 더 많은 예제는 GitHub 문서에서 확인하세요.

다음 편: 5편 — TBEG 고급 기능: 차트, 이미지, 대용량 처리. 차트 자동 조정, 이미지 삽입, 대용량 데이터 스트리밍 처리를 다룹니다.

0개의 댓글