Easy ExcelでWrite操作を行う際のセルの結合方法は以下の通りです。

`@ExcelMerge`（自分で追加した）：
```java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ExcelMerge {


}
```

`MergeHandler`:
```java
public class CustomAbstractMergeStrategy<T, R> implements WorkbookWriteHandler {

    /**
     * 結合する行数
     */
    private List<Integer> mergeRowCountList;

    /**
     * 結合が必要な列のインデックス
     */
    private List<Integer> mergeColumnIndexList;

    /**
     * オブジェクト
     */
    private Class<T> clazz;

    /**
     * セルの結合ルール:
     * 1. データにのみ適用、ヘッダーには適用しない
     * 2. 結合が必要なフィールドは @ExcelMerge で注釈が必要
     * 3. 結合条件は: 入力パラメータ Function<T, R> function が必要で、List<T> list 内の結合するセルが連続している必要あり(ソートが必要な場合もある)
     *
     * @param clazz
     * @param list
     * @param function
     */ 
    private CustomAbstractMergeStrategy(Class<T> clazz, List<T> list, Function<T, R> function) {
        if (CollUtil.isNotEmpty(list)) {
            Map<R, Long> collect = list.stream().collect(Collectors.groupingBy(function, LinkedHashMap::new, Collectors.counting()));
            this.mergeRowCountList = collect.values().stream().map(Long::intValue).collect(Collectors.toList());
            this.clazz = clazz;
            this.mergeColumnIndexList = new ArrayList<>();
            markMergeIndex();
        }
    }

    public static <T, R> CustomAbstractMergeStrategy<T, R> of(Class<T> clazz, List<T> list, Function<T, R> function) {
        return new CustomAbstractMergeStrategy<>(clazz, list, function);
    }

    /**
     * 結合する列のインデックスをマーク
     */
    private void markMergeIndex() {
        Field[] fields = clazz.getDeclaredFields();
        int columnIndex = 0;
        for (Field field : fields) {
            if (field.isAnnotationPresent(ExcelProperty.class)) {
                if (field.isAnnotationPresent(ExcelMerge.class)) {
                    mergeColumnIndexList.add(columnIndex);
                }
                columnIndex++;
            }
        }
    }

    /**
     * ワークブック作成前に呼び出される
     */
    @Override
    public void beforeWorkbookCreate() {

    }

    /**
     * ワークブック作成後に呼び出される
     *
     * @param writeWorkbookHolder
     */
     @Override
     public void afterWorkbookCreate(WriteWorkbookHolder writeWorkbookHolder) {

     }

    /**
     * ワークブックの全操作後に呼び出される
     *
     * @param writeWorkbookHolder
     */
     @Override
     public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) {
         if (CollUtil.isEmpty(mergeColumnIndexList)) {
             return;
         }
         if (CollUtil.isEmpty(mergeRowCountList)) {
             return;
         }
         Sheet sheet = writeWorkbookHolder.getWorkbook().getSheetAt(0);
         // 0行目がヘッダー、1行目からデータ
         int rowIndex = 1;
         for (Integer mergeRowCount : mergeRowCountList) {
             if (mergeRowCount > 1) {
                 for (Integer columnIndex : mergeColumnIndexList) {
                     CellRangeAddress cellRangeAddress = new CellRangeAddress(rowIndex, rowIndex + mergeRowCount - 1, columnIndex, columnIndex);
                     sheet.addMergedRegionUnsafe(cellRangeAddress);
                 }
                 rowIndex += mergeRowCount;
             } else {
                 rowIndex += 1;
             }
         }
     }


}
```

Data：
```java
@Data
public class Data implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 計画ID
     */ 
    @ExcelProperty(value = "計画ID")
    @ExcelMerge
    private Integer id;

    /**
     * 試験計画
     */
    @ExcelProperty(value = "試験計画") 
    @ExcelMerge
    private String examPlanName;

    /**
     * 種類
     */
    @ExcelProperty(value = "種類")
    private String subjectTypeName;

    /**
     * 科目名
     */
    @ExcelProperty(value = "科目名")
    private String examSubjectName;

    /**
     * 問題数
     */
    @ExcelProperty(value = "問題数")
    private Integer questionCount;

    /**
     * 受験人数
     */
    @ExcelProperty(value = "受験人数")
    private Integer examCount;

}
```
Util:
```java
 public class Util{

        public static <T> void exportExcelWithMerge(List<T> data, Class<? extends T> cls, String fileName, Integer sheetNo, String sheetName, WriteHandler writeHandler) {
            HttpServletResponse response = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();
            try (OutputStream out = response.getOutputStream()) {
                response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
                response.setCharacterEncoding("utf-8");
                fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
                response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
                EasyExcel.write(out, cls).registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()).registerWriteHandler(writeHandler).sheet(sheetNo, sheetName).doWrite(data);
            } catch (IOException e) {
                log.error("error", e);
                throw new BusinessException(BizErrorCodeEnum.FILE_DOWNLOAD_FAILED);
            }
        }

        public static <T> void exportExcelWithMerge(List<T> data, Class<? extends T> cls, String fileName, WriteHandler writeHandler) {
            exportExcelWithMerge(data, cls, fileName, 0, "Sheet1", writeHandler);
        }

    }
```

Test:
```java
    @PostMapping("/export")
    @ApiOperation("export")
    public void export(@RequestBody @Validated ExamPlanExportReq req) {
         List<ExportExamPlanResp> data = getDataFromDb(req);
        FileUtil.exportExcelWithMerge(data, ExportExamPlanResp.class, "list", CustomAbstractMergeStrategy.of(ExportExamPlanResp.class, data, ExportExamPlanResp::getId));
    }
```

WorkbookWriteHandlerを使用する理由は、すべてのデータの解析が完了した後にafterWorkbookDisposeメソッドが呼び出されるためです。

セルの結合は、RowWriteHandlerやCellWriteHandlerでも実装可能ですが、CellWriteHandlerで行うのは非効率です。

CellWriteHandlerでは各行の各セルを反復処理する必要があるため、
M行 x N列のデータがある場合、結合処理にM x N回の反復が必要になります。

RowWriteHandlerの方が良いのですが、それでもM回の反復処理が必要です。

そのため、WorkbookWriteHandlerを使うのが最も効率的です。
すべてのデータの解析が完了した後、一度だけ結合処理を行えば良いので、とても高速に動作します。

以上の理由から、セルの結合にはWorkbookWriteHandlerを使うのがベストであると判断し、
この実装を選択しました。効率的な処理を行うための設計です。