Spring 4 JasperReports JsonDataSource PDF 출력
Spring 4 웹 애플리케이션에서 리포트를 뽑을 때, 데이터를 JDBC 커넥션이 아니라 JSON으로 넘기는 구성입니다. 서비스 계층이 이미 조립해 둔 객체를 그대로 쓰고 싶거나 리포트 전용 쿼리를 jrxml 안에 박아두기 곤란하다면, 객체를 JSON으로 직렬화해 JasperReports에 던지는 쪽이 단순합니다. 리포트는 SQL을 들고 있지 않아도 됩니다. pom.xml 의존성, jrxml 필드와 JSON 노드를 잇는 규칙, JasperReportsViewResolver를 이용한 브라우저 미리보기, JasperFillManager로 PDF 파일을 떨어뜨리는 코드 순서로 정리했습니다.
JsonDataSource가 읽는 JSON
JasperReports는 JRDataSource 구현체를 통해 데이터를 공급받습니다. JsonDataSource는 InputStream으로 들어온 JSON 문서를 읽고, 생성자의 두 번째 인자로 문서 안에서 어디부터 읽을지 지정합니다. 공식 JSON Data Source 샘플은 이 자리에 Northwind.Customers 같은 점 표기 경로를 넣고, 그 경로가 배열이면 "Customers 객체 안의 모든 customer 요소를 담은 목록"을 돌려준다고 설명합니다. 배열 요소 하나가 레코드 하나입니다. 아래 코드처럼 빈 문자열을 넘기면 루트 노드 자체가 레코드 하나로 잡히고, detail 밴드는 딱 한 번 돕니다.
검색해 들어온 사람이 실제로 막히는 곳은 필드와 노드를 잇는 규칙입니다. JasperReports 6.20.6의 JsonDataSource 소스를 보면 필드마다 net.sf.jasperreports.json.field.expression 프로퍼티를 먼저 읽고, 비어 있으면 fieldDescription, 그것도
<field name="title" class="java.lang.String">
<property name="net.sf.jasperreports.json.field.expression" value="title"/>
</field>
<field name="barcode" class="java.lang.String">
<fieldDescription><![CDATA[barcode]]></fieldDescription>
</field>
두 방식 모두 같은 노드를 가리킵니다. 노드 이름과 필드 이름이 같으면 둘 다 생략해도 되지만, company.name처럼 한 단계 아래를 찍어야 하면 표현식을 적어야 합니다. 프로퍼티 쪽이 나중에 들어온 방법이라 오래된 버전은 fieldDescription만 볼 수도 있습니다. 리포트가 통째로 비어 나오면 여기부터 확인하세요. 어느 방식이든 경로가 문자열이라 오타는 컴파일 시점에 걸리지 않고, 값이 없어도 예외 없이 빈칸으로 지나갑니다.
jrxml은 결국 XML 파일입니다. 자바 밖에서 다룬 기록은 Laravel에서 JasperReport를 적용한 정리에 따로 남겨 두었습니다.
pom.xml 의존성 네 가지
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports</artifactId>
<version>${jasperreports.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId>net.sf.barcode4j</groupId>
<artifactId>barcode4j</artifactId>
<version>2.1</version>
</dependency>
<dependency>
<groupId>batik</groupId>
<artifactId>batik-bridge</artifactId>
<version>1.6-1</version>
</dependency>
jasperreports가 엔진 본체입니다. JsonDataSource도 이 아티팩트 안에 있어 데이터 소스용 의존성을 따로 추가하지 않습니다. 버전은 ${jasperreports.version} 프로퍼티로 빼 두었으니 pom의 <properties> 블록에 실제 값을 넣어야 합니다. 전이 의존성이 함께 딸려 오므로 mvn dependency:tree로 한 번 훑어보고 다른 라이브러리와 버전이 겹치지 않는지 확인하세요.
gson은 모델 객체를 JSON 문자열로 바꾸는 데만 씁니다. 리포트 엔진과 직접 관계는 없습니다. Jackson을 이미 쓰고 있다면 ObjectMapper.writeValueAsString으로 대체해도 결과는 같습니다. Gson 자체의 사용법은 Gson 라이브러리 사용 정리에 적어 두었습니다.
barcode4j와 batik-bridge는 리포트에 바코드를 넣을 때만 필요합니다. JasperReports 자신의 pom.xml도 barcode4j 2.1과 batik 아티팩트들을 <optional>true</optional>로 선언해 둡니다. 바코드를 안 쓰는 프로젝트에는 따라오지 않으니, 쓰려면 위처럼 직접 적어야 한다는 뜻입니다. batik이 왜 끼어드는지는 barcode4j 패키지 문서에 나옵니다. 기본 이미지 프로듀서가 바코드를 SVG로 내보낸 뒤 Batik 기반 렌더러를 만들기 때문입니다. 같은 문서가 대안도 알려줍니다. net.sf.jasperreports.components.barcode4j.image.producer 프로퍼티를 image 별칭으로 바꾸면 바코드를 PNG로 래스터화해 Batik 의존성을 피할 수 있습니다.
원문의 batik 좌표는 groupId가 batik인 옛 형식입니다. 요즘 JasperReports가 참조하는 좌표는 org.apache.xmlgraphics:batik-bridge 쪽입니다. 사내 저장소를 프록시로 쓰는 환경이면 옛 좌표가 실제로 내려받아지는지 먼저 봐야 합니다.
JasperReportsViewResolver 등록
브라우저에서 바로 미리보기를 띄우려면 Spring MVC 뷰 리졸버를 하나 더 등록합니다.
@Bean // Jasper preview bean Set.
public JasperReportsViewResolver getJasperReportsViewResolver() {
final JasperReportsViewResolver resolver = new JasperReportsViewResolver();
resolver.setPrefix("/resources/jasper/");
resolver.setSuffix(".jrxml");
resolver.setReportDataKey("datasource");
resolver.setViewNames("report_*"); // .jrxml file name & call name
resolver.setViewClass(JasperReportsMultiFormatView.class);
resolver.setOrder(0);
return resolver;
}
setPrefix와 setSuffix는 컨트롤러가 반환한 뷰 이름 앞뒤에 붙어 최종 템플릿 경로를 만듭니다. report_sample을 반환하면 /resources/jasper/report_sample.jrxml을 찾습니다. 접미사가 .jrxml이라는 건 컴파일 전 설계 파일을 가리킨다는 뜻이고, AbstractJasperReportsView 자바독은 이 경우 리포트를 동적으로 컴파일해 메모리에 올린다고 적어 두었습니다. 미리 컴파일한 .jasper를 배포하고 접미사를 바꾸면 그 단계가 빠집니다.
setReportDataKey는 모델에 담긴 여러 속성 중 어떤 키를 데이터 소스로 볼지 지정합니다. 같은 자바독에 따르면 이 값을 지정하지 않을 때는 모델에서 JRDataSource, java.util.Collection, 객체 배열 순으로 맞는 타입을 찾습니다. 키를 박아 두는 쪽이 예측 가능합니다.
setViewNames("report_*")는 이 리졸버가 처리할 뷰 이름 패턴을 제한합니다. setOrder(0)은 우선순위 조정인데, UrlBasedViewResolver 자바독이 밝힌 기본값이 Ordered.LOWEST_PRECEDENCE이므로 0은 그보다 앞이지만 최고 우선순위는 아닙니다. 음수 order를 가진 리졸버가 있으면 그쪽이 먼저 갑니다. JSP나 Thymeleaf로 가야 할 요청까지 이 리졸버가 집어가지 않도록 순서와 패턴 제한은 짝으로 봐야 합니다.
setViewClass(JasperReportsMultiFormatView.class)는 모델의 포맷 키를 보고 내보내기 형식을 고르는 뷰 구현입니다. 자바독에 기본 포맷 키가 format, 기본 매핑이 csv·html·pdf·xls이고 xlsx는 Spring 4.2부터 들어왔다고 나옵니다. 이 코드가 넣는 값은 pdf 하나입니다.
캐시도 짚고 갑니다. JasperReportsViewResolver는 UrlBasedViewResolver를 거쳐 AbstractCachingViewResolver를 상속합니다. 그 setCache 자바독은 "기본값은 true, 캐싱 활성. 디버깅과 개발 중에만 끄라"고 적고, 기본 캐시 한도는 1024이며 setCache(false)는 한도를 0으로 두는 것과 같다고 설명합니다. 뷰 이름당 첫 요청에서 jrxml을 읽어 컴파일하고 그다음부터는 캐시된 뷰를 재사용한다는 뜻입니다. 템플릿을 고쳐 새로고침만으로 반영하고 싶으면 캐시를 꺼야 하고, 그때부터 매 요청에 컴파일 비용이 붙습니다.
이 방식 자체는 Spring 4에서 끝입니다. SPR-13294에서 org.springframework.web.servlet.view.jasperreports 패키지를 5.0에 맞춰 통째로 걷어냈고, JasperReports가 5.5.2에서 JRExporter API를 폐기한 것이 이유로 적혀 있습니다. 5.0 이상으로 올릴 계획이라면 아래의 직접 호출 방식으로 옮겨야 합니다. @Bean 하나로 인프라 컴포넌트를 등록하는 방식 자체는 Spring 4 Boot 이메일 설정에서 메일 발송기를 붙일 때와 같습니다.
컨트롤러에서 JSON 넘기기
@RequestMapping(value = "test-report", method = RequestMethod.GET)
public String companyList(final Model model) {
try {
Gson gson = new Gson();
testModel tt = new testModel();
tt.setTitle("Hello");
tt.setBarcode("HY12000");
tt.setUsers(sserService.findAllUsers());
InputStream is = new ByteArrayInputStream(gson.toJson(tt).getBytes());
JsonDataSource dataSource = new JsonDataSource(is, "");
model.addAttribute("datasource", dataSource);
model.addAttribute("format", "pdf"); // preview type
return "report_testreoprt"; // return .jrxml file name
} catch (JRException e) {
e.printStackTrace();
return "report_testreoprt"; // return .jrxml file name
}
}
반환값 report_testreoprt는 오타처럼 보입니다. 파일 이름이 그렇게 돼 있다면 문자열도 그대로 맞춰야 합니다. 이 값이 report_* 패턴에 걸리고 /resources/jasper/report_testreoprt.jrxml을 가리키니까요. 글자 하나만 어긋나도 뷰를 못 찾습니다.
나머지는 조립입니다. 모델 객체를 채우고, Gson으로 JSON 문자열을 만들고, 바이트 배열을 ByteArrayInputStream으로 감싸 JsonDataSource에 넘깁니다. 여기서 getBytes()는 손봐야 합니다. String.getBytes() 자바독은 플랫폼 기본 문자셋으로 인코딩하며 인코딩할 수 없는 문자에 대한 동작은 정의되지 않았다고 밝히니, gson.toJson(tt).getBytes(StandardCharsets.UTF_8)로 문자셋을 못 박는 편이 안전합니다. 그렇게 만든 데이터 소스를 리졸버에 등록해 둔 키와 똑같은 datasource로 담고, 출력 포맷은 format에 담습니다. catch 블록은 그대로 두기 어렵습니다. JRException을 잡고 같은 뷰 이름을 반환하면 데이터 소스 없이 리포트 렌더링으로 들어가니, 실패는 별도 에러 페이지로 보내는 편이 원인 파악에 낫습니다.
tt.setUsers(...)처럼 목록을 필드에 담으면 JSON 안에 배열이 생깁니다. 이 배열만 뿌리는 리포트라면 서브데이터셋은 필요 없습니다. 앞에서 본 select expression에 배열 노드 경로를 주면 요소가 레코드로 풀려 메인 detail 밴드에서 그대로 반복됩니다.
JsonDataSource dataSource = new JsonDataSource(is, "users");
조건이 붙는 건 루트의 단일 값과 목록을 한 리포트에서 함께 쓸 때입니다. 커서가 users 배열 요소로 들어가 있으면 루트의 title이나 barcode는 같은 식으로 잡히지 않습니다. 이때는 루트를 빈 select expression으로 물려 단일 값을 쓰고, 목록 영역만 서브데이터셋으로 떼어 subDataSource("users")를 데이터 소스 표현식으로 연결합니다. JsonDataSource 자바독은 이 메서드를 현재 노드를 기준으로 select 조건을 추가 적용한 하위 데이터 소스라고 설명합니다.
<dataSourceExpression><![CDATA[
((net.sf.jasperreports.engine.data.JsonDataSource) $P{REPORT_DATA_SOURCE})
.subDataSource("users")
]]></dataSourceExpression>
REPORT_DATA_SOURCE는 채우는 중인 데이터 소스를 가리키는 내장 파라미터입니다. 7.0 계열 자바독은 같은 클래스를 net.sf.jasperreports.json.data 패키지에서 문서화하니, 버전을 올렸다면 캐스팅할 패키지 이름부터 확인하세요. 루트 데이터 소스 하나만 물린 채 목록 영역을 그대로 두면 제목이나 바코드 같은 단일 값은 나오는데 목록만 비어 보입니다.
PDF 파일로 직접 내보내기
배치 작업이나 메일 첨부처럼 화면이 없는 경로라면 뷰 리졸버를 거치지 않고 엔진 API를 직접 호출합니다.
@Override
public void testPDFDownload(final String jsonData) throws JRException, IOException {
JasperDesign jasperDesign;
JasperPrint jasperPrint;
Resource resource = new ClassPathXmlApplicationContext()
.getResource("classpath:static/jasper/tesy.jrxml");
InputStream is = new ByteArrayInputStream(jsonData.getBytes());
jasperDesign = JRXmlLoader.load(resource.getInputStream());
JasperReport jasperReport = JasperCompileManager.compileReport(jasperDesign);
JsonDataSource dataSource = new JsonDataSource(is, "");
jasperPrint = JasperFillManager.fillReport(jasperReport, new HashMap(),
dataSource);
// exportType
JasperExportManager.exportReportToPdfFile(jasperPrint,
"src/main/resources/static/jasper/report.pdf");
}
JRXmlLoader.load로 jrxml을 읽어 JasperDesign을 만들고, JasperCompileManager.compileReport로 실행 가능한 JasperReport로 컴파일하고, JasperFillManager.fillReport에 파라미터 맵과 데이터 소스를 넘겨 JasperPrint를 채운 다음 JasperExportManager로 내보냅니다. 두 번째 인자인 빈 HashMap은 jrxml에 선언한 <parameter> 값을 전달하는 자리입니다. 제목이나 기준일처럼 데이터 소스와 무관한 값은 여기에 담습니다.
리소스를 읽는 줄은 고쳐 쓰세요. 인자 없는 new ClassPathXmlApplicationContext()는 자바독이 "bean 스타일 설정용"이라 적어 둔 생성자로, setConfigLocation과 afterPropertiesSet을 뒤이어 불러야 빈이 올라옵니다. 이 코드는 둘 다 부르지 않으니 refresh도 없고, 로드되는 빈도 없습니다. 사실상 ResourceLoader 껍데기만 쓰는 셈입니다. 컨텍스트가 기동되지 않는다는 게 면죄부는 아닙니다. 파일 하나 읽자고 ApplicationContext 구현체를 꺼내 오는 것 자체가 오용이고, new ClassPathResource("static/jasper/tesy.jrxml")나 빈에 주입한 ResourceLoader로 같은 결과를 훨씬 가볍게 얻습니다.
출력 경로 src/main/resources/...는 프로젝트 루트를 작업 디렉터리로 삼아 실행할 때만 유효한 상대 경로입니다. jar나 war로 패키징해 배포하면 그 경로가 없으니 파일 생성이 실패합니다. 운영에서는 외부 설정으로 뺀 절대 경로나 임시 디렉터리를 쓰고, 브라우저 다운로드가 목적이라면 파일로 떨구지 말고 응답 스트림에 바로 씁니다. JasperExportManager 자바독의 exportReportToPdfStream(JasperPrint, OutputStream)이 첫 인자를 PDF로 내보내 두 번째 인자의 출력 스트림에 쓰는 메서드입니다. 호출마다 컴파일이 일어나는 것도 부담이니, 컴파일 결과인 JasperReport 객체는 한 번 만들어 캐시해 두고 재사용하기를 권합니다.
자주 걸리는 곳
화면 미리보기나 HTML 출력은 멀쩡한데 PDF에서만 한글이 공백으로 나오는 증상이 있습니다. 폰트 문제입니다. jrxml 텍스트 요소가 어떤 폰트 이름을 가리키는지 보고, 다음으로 PDF 인코딩을 확인하면 대개 거기서 끝납니다. 공식 fonts 샘플은 jrxml 태그의 pdfEncoding·pdfEmbedded 속성이 폐기됐으니 폰트 익스텐션 쪽 설정을 쓰라고 권하고, 한 리포트를 일본어와 중국어로 함께 돌려야 하면 이름은 같고 <locales/>만 다른 폰트 패밀리를 따로 정의하라고 설명합니다. 두 언어를 동시에 지원하는 TTF가 없기 때문입니다. 한글도 사정이 같습니다. 폰트 파일을 익스텐션으로 묶어 배포하면 실행 환경의 JVM에 그 폰트가 없어도 됩니다.
이미 한 번 순회를 끝낸 JsonDataSource 인스턴스를 그대로 다시 채우기에 넘기면 빈 결과가 나옵니다. 커서를 들고 있어서입니다. 이 클래스는 JRRewindableDataSource를 구현하므로 자바독대로 moveFirst()를 불러 커서를 처음으로 되돌리면 됩니다. 원본 JSON 문자열을 들고 있다가 새 데이터 소스를 만드는 방법도 있습니다.
setPrefix가 /resources/jasper/면 정적 리소스 핸들러 매핑 범위와 겹칠 수 있습니다. 겹치면 jrxml 파일이 브라우저에서 그대로 내려받아집니다. 리포트 템플릿에는 내부 필드명이나 쿼리가 담기니, 해당 경로를 정적 매핑에서 제외하거나 WEB-INF 아래로 옮기세요.
정리
- JDBC 대신 JSON: 객체를 직렬화해
ByteArrayInputStream으로 감싼 뒤JsonDataSource(is, selectExpression)에 전달 - select expression: 배열 경로면 요소마다 레코드 하나, 빈 문자열이면 루트 노드 하나
- 필드 매핑 순서:
net.sf.jasperreports.json.field.expression프로퍼티,fieldDescription, 필드 이름 - 서브데이터셋: 루트 단일 값과 목록을 한 리포트에 같이 쓸 때만. 목록만 뿌릴 거면 select expression에 배열 노드를
- 미리보기: 리졸버에 prefix·suffix·
reportDataKey·viewNames지정, 컨트롤러는datasource와format두 키만 - 파일 출력:
JRXmlLoader→JasperCompileManager→JasperFillManager→JasperExportManager직접 호출 - 바코드: barcode4j와 batik은 JasperReports pom에서 optional, 쓰려면 직접 선언
- 뷰 캐시: 기본 켜짐, 한도 1024. jrxml 수정 즉시 반영은 캐시를 끈 뒤부터
- Spring 5.0: jasperreports 뷰 패키지 제거, 엔진 API 직접 호출로 이전
