Spring
Jxls에서 반복문과 셀 병합을 동시에 처리하는 커스텀 커맨드
Jxls의 기본 커맨드로는 반복 처리 중 셀 병합이 안 된다. 계층 구조 데이터를 엑셀로 출력하면서 상위 항목 셀을 자동 병합하는 EachMergeCommand를 만들어 해결했다.
jxls excel java custom-command
문제
엑셀 보고서를 만들어야 하는데 계층 구조 데이터였다. 회사 → 부서 → 직원 같은 형태로 상위 항목 셀은 병합되고 그 아래에 하위 항목들이 나열되는 구조다. 엑셀에서 흔히 보는 패턴이다.
| 회사 | 부서 | 직원 |
|--------|--------|--------|
| | 개발팀 | 김XX |
| A사 | | 이XX |
| | 기획팀 | 박XX |
|--------|--------|--------|
| B사 | 개발팀 | 최XX |
Jxls를 쓰고 있었는데 기본 jx:each 커맨드로는 반복 처리와 셀 병합을 동시에 할 수가 없었다. 반복은 되지만 “같은 회사에 속한 행들의 회사 셀을 병합해라” 같은 처리가 불가능하다. Jxls 공식 문서에도 이런 케이스에 대한 가이드가 없어서 커스텀 커맨드를 직접 만들었다.
해결 — EachMergeCommand
EachCommand를 상속해서 반복 처리가 끝날 때마다 셀 병합을 자동으로 수행하는 EachMergeCommand를 만들었다. 핵심은 AreaListener를 활용하는 것이다. 각 셀이 렌더링될 때 리스너가 호출되는데 이 시점에 같은 값을 가진 셀들의 범위를 추적해서 병합한다.
EachMergeCommand
import org.jxls.area.Area;
import org.jxls.command.EachCommand;
import org.jxls.common.CellRef;
import org.jxls.common.Context;
import org.jxls.common.Size;
import java.util.List;
import java.util.stream.Collectors;
public class EachMergeCommand extends EachCommand {
public static final String COMMAND_NAME = "each-merge";
@Override
public Size applyAt(CellRef cellRef, Context context) {
List<Area> childAreas = this.getAreaList().stream()
.flatMap(area -> area.getCommandDataList().stream())
.flatMap(commandData -> commandData.getCommand().getAreaList().stream())
.collect(Collectors.toList());
MergeAreaListener listener = new MergeAreaListener(this.getTransformer(), cellRef);
this.getAreaList().get(0).addAreaListener(listener);
childAreas.forEach(childArea -> childArea.addAreaListener(listener));
return super.applyAt(cellRef, context);
}
}
applyAt을 오버라이드해서 부모 영역과 자식 영역 모두에 MergeAreaListener를 등록한다. 중첩된 each-merge 커맨드가 있어도 병합이 동작하도록 자식 영역까지 탐색한다.
MergeAreaListener
import org.apache.poi.ss.util.CellRangeAddress;
import org.jxls.common.AreaListener;
import org.jxls.common.CellRef;
import org.jxls.common.Context;
import org.jxls.transform.Transformer;
import org.jxls.transform.poi.PoiTransformer;
import org.apache.poi.ss.usermodel.Sheet;
public class MergeAreaListener implements AreaListener {
private final CellRef commandCell;
private final Sheet sheet;
private CellRef lastRowCellRef;
public MergeAreaListener(Transformer transformer, CellRef cellRef) {
this.commandCell = cellRef;
this.sheet = ((PoiTransformer) transformer).getXSSFWorkbook().getSheet(cellRef.getSheetName());
}
@Override
public void afterApplyAtCell(CellRef cellRef, Context context) {
if (commandCell.getCol() != cellRef.getCol()) {
this.setLastRowCellRef(cellRef);
} else {
if (!existMerged(cellRef)) merge(cellRef);
}
}
private void merge(CellRef cellRef) {
if (lastRowCellRef == null) return;
int from = cellRef.getRow();
int to = lastRowCellRef.getRow();
sheet.addMergedRegion(new CellRangeAddress(from, to, cellRef.getCol(), cellRef.getCol()));
}
private void setLastRowCellRef(CellRef cellRef) {
if (lastRowCellRef == null || lastRowCellRef.getRow() < cellRef.getRow()) {
this.lastRowCellRef = cellRef;
}
}
private boolean existMerged(CellRef cell) {
return sheet.getMergedRegions().stream()
.anyMatch(address -> address.isInRange(cell.getRow(), cell.getCol()));
}
}
리스너의 동작 원리는 이렇다.
- 커맨드가 위치한 컬럼과 다른 컬럼의 셀이 렌더링되면 마지막 행 위치를 갱신한다
- 커맨드가 위치한 컬럼과 같은 컬럼의 셀이 렌더링되면 시작행부터 마지막 행까지 병합한다
- 이미 병합된 영역이면 중복 병합을 건너뛴다
사용 방법
커맨드 등록
XlsCommentAreaBuilder.addCommandMapping(EachMergeCommand.COMMAND_NAME, EachMergeCommand.class);
엑셀 템플릿
기존 jx:each 대신 jx:each-merge를 사용한다. 템플릿 주석에 커맨드를 지정하는 것 외에 다른 변경은 없다.
ExcelGenerator
public class ExcelGenerator {
private final String templatePath;
private final Context context;
public ExcelGenerator(String templatePath) {
this.templatePath = templatePath;
this.context = new Context();
XlsCommentAreaBuilder.addCommandMapping(EachMergeCommand.COMMAND_NAME, EachMergeCommand.class);
}
public void addMappingValue(String varName, Object value) {
this.context.putVar(varName, value);
}
public void generate(String outputPath) {
try (InputStream is = this.getClass().getClassLoader().getResourceAsStream(templatePath);
OutputStream os = new FileOutputStream(outputPath)) {
JxlsHelper.getInstance().processTemplate(is, os, this.context);
} catch (IOException e) {
throw new RuntimeException("Template processing error", e);
}
}
}