본문으로 건너뛰기

19 · JXLS에서 xl3로 — 자바스크립트 대안

시나리오​

팀이 JVM에서 JXLS로 Excel 보고서를 렌더링하고 있고, 이제 같은 걸 Node.js나 브라우저에서 해야 하는 상황 — 또는 "JXLS for JavaScript"를 검색했는데 node-java를 감싼 8년 된 래퍼뿐이었던 상황입니다. xl3가 유지되고 있는 답입니다. 스프레드시트 자체가 템플릿인 Excel-to-Excel 템플릿 엔진입니다.

기능이 우연히 겹친 게 아닙니다. xl3의 명세는 JXLS가 약 10년간 겪은 엣지 케이스를 항목 단위로 흡수했습니다 — 병합된 데이터 행 셀, 이름 있는 범위, 인쇄 영역, 윤곽 수준, 여러 줄 텍스트 모두 전용 ADR과 적합성 픽스처를 가지고 있습니다. 운영 원칙은 (ADR-0034) JXLS의 경험은 빌려오고, 문법은 빌려오지 않는다입니다.

모델 차이를 표 하나로​

JXLSxl3
지시자가 사는 곳셀 메모 (jx:each(items="rows" lastCell="D4")) — 그리드에서 보이지 않음셀 값 ({{ @filter [Status] = "Open" }}) — 보이고, 리뷰 가능하고, diff 가능
표현식 언어JEXL (${employee.payment * 1.1}) — 새로 배워야 하는 두 번째 언어Excel 문법 ({{ [Payment] * 1.1 }}, IF, XLOOKUP, SUM) — 템플릿 작성자가 이미 아는 것
데이터가 오는 곳코드에서 바인딩한 자바 객체 (context.putVar("employees", list))두 번째 .xlsx, 또는 stdin으로 넘기는 JSON — render(template, data)가 순수 함수입니다. 같은 입력, 같은 바이트
블록 경계명시적 lastCell="D4" 좌표{{ ... }} 마커로부터 추론 (원하면 명시적 {{ @block A:D }})
탈출구커스텀 자바 커맨드 — 튜링 완전하고 이식 불가설계상 없음 — 템플릿은 어떤 구현체든 렌더링할 수 있는 인계 산출물로 남습니다 (ADR-0048)

결과적으로 JXLS 템플릿은 셀 메모와 자바 바인딩을 편집할 수 있는 사람, 즉 개발자가 소유합니다. xl3 템플릿은 스프레드시트를 편집할 수 있는 사람이 소유합니다.

지시자 대응표​

JXLSxl3 대응비고
jx:each(items="rows" var="r" lastCell=…)데이터 블록 — {{ [Column] }} 마커가 들어 있는 템플릿 행루프 선언이 아예 없습니다. 블록이 소스 행 하나당 출력 행 하나로 확장됩니다. 시작하기 참고
${r.name}{{ [Name] }}소스 행의 열 참조
${r.amount * 1.1}{{ [Amount] * 1.1 }}JEXL이 아니라 Excel 연산자
셀에 붙은 jx:if(condition=…){{ IF([Renewal] > 10000, "Priority", "Standard") }}조건부 셀
행을 걸러내는 데 쓴 jx:if{{ @filter [Status] = "Open" }}@filter 여러 개는 AND로 결합됩니다
orderBy가 붙은 jx:each{{ @sort [Total] desc }}
groupBy가 붙은 jx:each{{ @group [Region] }} + {{ @subtotal SUM([Renewal]) }}소계 행을 사이사이 끼워 넣고 N단 중첩까지 — 그룹과 소계
jx:each(direction="RIGHT"){{ @repeat right 3 }}
여러 컬렉션블록마다 {{ @source Renewals }}, 그리고 {{ @join Customers on Customers[Account] = Renewals[Account] }}멀티 소스 + @join
jx:multisheet패턴을 시트 이름에 넣습니다: Region-{{ [Region] }}그룹별 시트. 그룹별 파일은 output_file_pattern으로 — 그룹별 파일
jx:link{{ HYPERLINK(url, label) }}ADR-0039
jx:params(formulas=…)선언할 것이 없습니다. 템플릿의 native Excel 수식은 그대로 보존됩니다ADR-0046
확장된 블록에 대한 SUM{{ SUM([Renewal]) }} 집계, 또는 평범한 Excel =SUM(...) 수식집계

의도적으로 넘어오지 않는 것​

xl3는 JXLS 기능 세 가지를 근거를 기록하고 거절했습니다. 그래서 경계가 빈틈이 아니라 결정으로 남아 있습니다.

  • jx:image (데이터 기반 이미지 삽입) — 거절, ADR-0037. 템플릿에 배치된 이미지는 렌더링을 통과해 살아남습니다. 데이터로부터 이미지를 삽입하는 것은 브라우저에서 안전하고 결정론적인 파이프라인에 맞지 않습니다.
  • jx:updateCell (런타임 셀 변경) — 거절, ADR-0042. {{ ... }} 치환이 이미 그 용도를 커버하며, 평가 순서를 관측 가능하게 만들지 않습니다.
  • 커스텀 커맨드 (호스트 언어 탈출구) — 거절, ADR-0034. 직접 만든 자바/JS 헬퍼를 요구하는 템플릿은 다른 팀이나 다른 구현체에 넘길 수 없습니다.

JXLS 템플릿이 커스텀 커맨드에 의존하고 있다면, 그 로직은 템플릿이 아니라 데이터 파일로 옮겨갑니다 — 데이터를 만들어내는 쪽에서 해당 열을 미리 계산하면 됩니다.

렌더 호출 비교​

JXLS (자바):

List<Employee> employees = loadEmployees();
Context context = new Context();
context.putVar("employees", employees);
JxlsHelper.getInstance().processTemplate(templateStream, outStream, context);

xl3 (Node.js 또는 브라우저):

import { convert } from '@xl3-lang/xl3';

const outputs = await convert(templateBuffer, dataBuffer);
// outputs: [{ filename: 'renewal-report.xlsx', buffer }, ...]

바인딩할 컨텍스트 객체가 없습니다. 렌더에 필요한 모든 것이 입력 안에 있습니다. 그래서 출력이 재현 가능하고, 호스트 프로그램 없이도 템플릿을 테스트할 수 있습니다.

JVM에 그대로 남는 방법​

렌더 루프를 Node로 옮기는 건 선택지 하나일 뿐입니다. 서비스를 JVM에 그대로 둔다면 CLI를 호출하면 됩니다. 데이터는 이미 있는 자리 그대로, 중간 워크북 없이 JSON으로 넘깁니다.

// xl3-source-json/0.1 을 프로세스에 써넣고, 프로세스가 쓴 파일을 읽습니다.
Process p = new ProcessBuilder(
"xl3", "render", "template.xlsx", "--data=-", "--out=./out/", "--json")
.start();
p.getOutputStream().write(sourceJson.getBytes(StandardCharsets.UTF_8));
p.getOutputStream().close();

int exit = p.waitFor(); // 0 성공 · 1 변환 실패 · 2 사용법 오류

JSON은 컬럼 지향입니다 — headers와 배열 rows. 그래서 List<Employee>를 옮기는 일이 워크북 생성이 아니라 반복문 하나로 끝납니다.

{
"version": "xl3-source-json/0.1",
"sources": {
"default": {
"headers": ["직원", "급여"],
"rows": [["김", 4200], ["이", 3900]]
}
}
}

0이 아닌 종료 코드로 끝나면 --json이 stderr에 실패 내용을 내보내고, 거기 담긴 error.code로 분기하면 됩니다 — 코드 목록은 Cookbook 13에 있습니다. 요청마다 npx를 부르면 매번 레지스트리를 조회하니, npm i -g @xl3-lang/xl3로 한 번 설치해 두는 편이 낫습니다.

JXLS를 배포하던 방식과 가장 가까운 형태입니다. 서버에 산출물 하나를 설치해 두고 애플리케이션 코드에서 호출하는 것. 넘어오지 않는 것은 SPI 확장점이며, 위의 절을 참고하세요.

이관 체크리스트​

  1. 데이터를 코드 밖으로 옮깁니다. putVar로 넣던 것이 소스가 됩니다. 시트로 내보내거나(컬렉션 하나당 표 하나), xl3-source-json/0.1의 sources 항목 하나로 만드세요. 이미 들고 있는 컬렉션을 직렬화하는 것이 보통 유일한 실질 작업이고, JSON 쪽은 다시 읽히기만 할 워크북을 만드는 단계를 건너뜁니다.
  2. 메모를 지우고 셀에 씁니다. 각 jx:each 영역은 {{ [Column] }} 마커로 된 한 줄 데이터 블록이 됩니다. lastCell 경계는 사라집니다.
  3. JEXL을 Excel 표현식으로 다시 씁니다. ${...}의 산술과 조건은 IF/연산자와 함께 {{ ... }}로 1:1 대응됩니다.
  4. 그룹핑을 선언적으로 재구성합니다. groupBy/orderBy는 블록 안의 @group/@sort/@subtotal 셀이 됩니다.
  5. 돌려보고 diff를 뜹니다. convert()는 결정론적입니다 — zip 엔트리 타임스탬프까지 포함해 같은 입력이면 같은 바이트가 나오므로, 골든 파일 테스트가 눈으로 확인하는 작업을 대체합니다. 바이트 재현성은 0.12.0에 들어갔습니다. 0.11.0 이하에서는 정규화한 파트를 비교하세요 (npx xl3-conformance canonicalize out.xlsx).

설치 없이 브라우저에서 템플릿 하나로 이관을 시험해보세요 — xl3.io/try.

같이 보기: ADR-0048 (최종 JXLS 경계), spec/language.md "Directives".