본문으로 건너뛰기

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.1sources 항목 하나로 만드세요. 이미 들고 있는 컬렉션을 직렬화하는 것이 보통 유일한 실질 작업이고, 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".