🇺🇸 English version

이 글에서 다루는 내용

TBEG을 실제 프로젝트에 도입하면 다양한 상황을 마주하게 됩니다. 이 글에서는 실전에서 자주 겪는 문제와 해결법, 그리고 더 나은 결과를 위한 팁을 정리합니다.

  1. 템플릿 설계 팁 -- 유지보수하기 좋은 템플릿 만들기
  2. 성능 최적화 -- 데이터 크기별 3단계 가이드
  3. 자주 발생하는 오류와 해결법 -- 증상별 원인 진단
  4. hideable 관련 문제 -- 선택적 필드 노출 트러블슈팅
  5. 운영 환경 가이드 -- 메모리 산정, 동시 요청 처리

1. 템플릿 설계 팁

repeat 마커는 반복 범위 밖에 배치하세요

${repeat(...)} 마커는 워크북 내 어디든 배치할 수 있습니다. 데이터 영역 위의 행에 배치하면 가독성이 좋아집니다.

ABC
1${repeat(employees, A2:C2, emp)}
2${emp.name}${emp.position}${emp.salary}

수식은 데이터 영역 아래에 배치하세요

수식의 범위는 자동으로 확장되므로 위치는 자유입니다. 다만 데이터 → 집계 순서가 자연스러워 Excel 편집 시 가독성이 좋습니다.

ABC
1${repeat(items, A2:C2, item)}
2${item.name}${item.value}${item.qty}
3합계=SUM(B2:B2)=AVERAGE(C2:C2)

SUM, AVERAGE, COUNT, MAX, MIN 등 범위를 참조하는 모든 수식이 자동 조정됩니다.

1행에 1개 데이터를 원칙으로

1행 1데이터가 가장 직관적입니다. 복잡한 레이아웃이 필요하면 다중 행 반복(A2:B3)을 사용하세요.

숨길 가능성이 있는 필드는 hideable 마커를 사용하세요

${hideable(emp.salary, C1:C3, dim)}
  • bundle 범위로 타이틀, 합계까지 함께 관리
  • DIM 모드: 레이아웃 유지 + 비활성화 처리
  • DELETE 모드(기본): 열을 물리적으로 삭제

반복 영역이 겹치지 않도록 하세요

같은 시트에 여러 repeat 영역을 배치할 때, 열 범위가 겹치면 오류가 발생합니다. 열을 분리하거나 행을 분리하여 배치하세요.

상세 가이드: 모범 사례 전문


2. 성능 최적화 -- 3단계 가이드

데이터 크기에 따라 단계적으로 적용하세요.

1단계: count 제공

컬렉션의 전체 건수를 함께 제공하면 데이터 이중 순회를 방지합니다.

val count = employeeRepository.count().toInt()

val provider = simpleDataProvider {
    items("employees", count) {
        employeeRepository.findAll().iterator()
    }
}

2단계: 지연 로딩

모든 데이터를 미리 메모리에 로드하지 말고, Lambda로 필요한 시점에 로드합니다.

// 비권장: 모든 데이터를 미리 로드
val allEmployees = employeeRepository.findAll()
items("employees", allEmployees)

// 권장: 지연 로딩
items("employees", count) {
    employeeRepository.findAll().iterator()
}

3단계: DB 스트리밍

10만 행 이상의 대용량 데이터는 JPA Stream 또는 MyBatis Cursor를 사용합니다.

@Transactional(readOnly = true)
fun generateLargeReport(): Path {
    val count = employeeRepository.count().toInt()

    val provider = simpleDataProvider {
        items("employees", count) {
            employeeRepository.streamAll().iterator()
        }
    }

    return excelGenerator.generateToFile(
        template = template,
        dataProvider = provider,
        outputDir = outputDir,
        baseFileName = "large_report"
    )
}

데이터 크기별 권장 설정

데이터 크기예상 생성 시간권장 방식
~1,000행~20msMap 방식으로 충분
~10,000행~110mssimpleDataProvider + count
~50,000행~500ms+ DB Stream
~100,000행~1초+ DB Stream
~500,000행~5초커스텀 DataProvider + DB Stream + generateToFile()
~1,000,000행~9초커스텀 DataProvider + DB Stream + generateToFile()

3개 컬럼 repeat + SUM 수식 기준(DataProvider + generateToFile). 컬럼 수, 수식 복잡도, 서버 사양에 따라 달라질 수 있습니다.


3. 자주 발생하는 오류와 해결법

마커가 결과 파일에 그대로 남습니다

${title}, ${emp.name} 등이 치환되지 않고 출력되는 경우:

원인해결
데이터에 해당 키가 없음data 맵 또는 DataProvider에 변수를 추가
변수명 오타템플릿의 마커명과 데이터의 키를 정확히 일치
repeat 변수가 범위 밖에서 사용됨${emp.name} 등은 repeat 범위 안에서만 유효

: TbegConfig(missingDataBehavior = MissingDataBehavior.THROW)로 설정하면 누락된 데이터에 대해 예외가 발생하여 원인 파악이 쉬워집니다.

TemplateProcessingException

템플릿 파싱 중 문법 오류가 발견되면 발생합니다.

ErrorType원인해결
INVALID_MARKER_SYNTAX마커 문법 오류괄호, 파라미터 형식 확인
MISSING_REQUIRED_PARAMETER필수 파라미터 누락repeat의 collection, range 등 확인
INVALID_RANGE_FORMAT잘못된 셀 범위A2:C2 같은 올바른 범위 사용
RANGE_CONFLICT범위 충돌 (중첩, 경계 걸침)겹치는 범위를 분리하거나 완전 포함으로 조정

OutOfMemoryError

대용량 데이터 처리 시 JVM 메모리가 부족하면 발생합니다.

  1. DataProvider에서 지연 로딩 사용
  2. JVM 힙 메모리 증가: -Xmx2g
  3. 데이터를 여러 파일로 분할

피벗 테이블이 포함된 템플릿은 재생성 과정에서 결과 파일 전체를 메모리에 로드하므로 약 30만 행이 현실적 상한입니다.

merge 병합 결과가 기대와 다릅니다

merge는 연속된 같은 값만 병합합니다. 같은 값이 떨어져 있으면 별도의 병합 그룹이 됩니다.

// 정렬 전: [영업1팀, 영업2팀, 영업1팀] -> 영업1팀이 2개 그룹으로 분리됨
// 정렬 후: [영업1팀, 영업1팀, 영업2팀] -> 영업1팀이 하나로 병합됨
val employees = employeeRepository.findAll().sortedBy { it.department }

4. hideable 관련 문제

hideFields를 지정했지만 필드가 숨겨지지 않습니다

확인할 포인트 3가지:

  1. 필드명 불일치: hideFields("employees", "salary")의 필드명이 템플릿 마커(${emp.salary})와 일치해야 합니다.
  2. 컬렉션명 불일치: 첫 번째 인자가 repeat의 컬렉션명과 일치해야 합니다.
  3. repeat 밖 필드: hideFields는 repeat의 반복 항목 필드에만 적용됩니다.

데이터 영역만 숨겨지고 타이틀/합계는 남습니다

hideable 마커에 bundle 파라미터가 지정되지 않은 경우입니다.

${hideable(emp.salary, C1:C4)}

C1:C4 범위에 필드 타이틀(C1), 데이터(C2~C3), 합계(C4)를 모두 포함하면 함께 처리됩니다.

bundle 범위가 셀과 일치하지 않는 오류

  • DOWN repeat: bundle의 범위가 hideable 셀의 열과 일치해야 합니다.
  • RIGHT repeat: bundle의 범위가 hideable 셀의 행과 일치해야 합니다.

5. 운영 환경 가이드

메모리 산정

데이터 규모권장 -Xmx비고
1만 행 이하512MB대부분의 일반 보고서
10만 행1~2GB
50만 행4GB
100만 행8GB

동시에 여러 보고서를 생성하는 경우, 위 값에 동시 요청 수를 곱하여 산정하세요.

동시 요청 처리

ExcelGenerator는 스레드 안전하므로 싱글톤 빈으로 사용하세요.

@Configuration
class TbegConfig {
    @Bean
    fun excelGenerator() = ExcelGenerator()
}

개발/운영 환경 분리

# application-dev.yml
tbeg:
  missing-data-behavior: throw

# application-prod.yml
tbeg:
  missing-data-behavior: warn

개발 환경에서는 THROW로 누락 데이터를 즉시 감지하고, 운영 환경에서는 WARN으로 경고만 출력합니다.

JPA Stream 사용 시 주의

JPA Stream은 트랜잭션이 종료되면 닫힙니다. Excel 생성이 완료될 때까지 트랜잭션이 유지되어야 합니다.

@Transactional(readOnly = true)  // 필수!
fun generateReport(): ByteArray {
    val provider = simpleDataProvider {
        items("employees", count) {
            employeeRepository.streamAll().iterator()
        }
    }
    return excelGenerator.generate(template, provider)
}

@Transactional이 없으면 LazyInitializationException이 발생할 수 있습니다.


마무리

이 글에서 다룬 내용을 요약하면:

  • 템플릿 설계: repeat 마커는 범위 밖에, 수식은 아래에, 1행 1데이터 원칙
  • 성능: count 제공 → 지연 로딩 → DB 스트리밍 3단계
  • 오류: MissingDataBehavior.THROW로 개발 중 즉시 감지
  • hideable: bundle 범위와 필드명 일치 확인
  • 운영: 스레드 안전 싱글톤 + 환경별 설정 분리

상세한 내용은 GitHub 문서를 참조하세요:

GitHub: jogakdal/data-processors-with-excel
Maven Central: io.github.jogakdal:tbeg:1.2.3

0개의 댓글