Element UI 기반 엑셀 업로드 컴포넌트와 EasyExcel 연동 구현

프론트엔드 드래그 앤 드롭 컴포넌트

Vue 기반에서 el-upload 컴포넌트를 활용하여 단일 파일 업로드UI를 구성합니다. 확장자 필터링, 용량 제한, 성공/실패 콜백 핸들링을 포함합니다.

<template>
  <el-dialog v-model="isShowDialog" width="55%" :close-on-click-modal="false">
    <el-upload
      class="upload-area"
      drag
      :action="apiEndpoint"
      :headers="requestHeaders"
      :limit="1"
      :file-list="selectedFiles"
      :on-success="onUploadComplete"
      :on-error="onUploadFail"
      accept=".xlsx,.xls"
    >
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">파일을 이곳에 드래그하거나 클릭하여 선택하세요</div>
      <template #tip>
        <div class="el-upload__tip">xlsx/xls 형식만 가능하며, 최대 500KB 내외로 제한됩니다.</div>
      </template>
    </el-upload>
  </el-dialog>
</template>

<script>
export default {
  data() {
    return {
      apiEndpoint: `${baseConfig.SERVER_URL}/api/file/import-data`,
      isShowDialog: false,
      selectedFiles: [],
      requestHeaders: { Authorization: localStorage.getItem('token') }
    };
  },
  methods: {
    openImportPanel() {
      this.selectedFiles = [];
      this.isShowDialog = true;
    },
    onUploadComplete(response, file, fileList) {
      if (response.statusCode === 'ERROR') {
        this.$toast.error(response.errorMessage);
      } else if (response.statusCode === 'SUCCESS') {
        this.$toast.success('데이터가 정상적으로 반영되었습니다.');
        this.isShowDialog = false;
        this.refreshDataTable();
      }
    },
    onUploadFail(err) {
      this.$toast.error('업로드 중 오류가 발생했습니다.');
    }
  }
};
</script>

백엔드 컨트롤러 및 파일 유효성 검증

MultipartFile을 받아 확장자를 검증한 후, EasyExcel의 스트리밍 읽기 기능을 호출합니다. 커스텀 리스너를 주입하여 메모리 효율성을 확보합니다.

@RestController
@RequestMapping("/api/v1/master")
public class MasterDataController {

    @ApiOperation("엑셀 파일을 통한 원단 목록 일괄 저장")
    @ApiImplicitParam(name = "sourceFile", paramType = "formData", dataTypeClass = MultipartFile.class, required = true)
    @PostMapping("/bulk-import")
    public ApiResult<Void> ingestExcel(@RequestPart("sourceFile") MultipartFile sourceFile) throws IOException {
        String extension = FilenameUtils.getExtension(sourceFile.getOriginalFilename());
        if (!Set.of("xlsx", "xls").contains(extension)) {
            throw new ValidationException("파일 형식이 올바르지 않습니다. xlsx 또는 xls만 허용됩니다.");
        }

        EasyExcel.read(
            sourceFile.getInputStream(),
            FabricInspectionDto.class,
            new RawDataValidationListener(fabricRepository)
        ).sheet().doRead();

        return ApiResult.success();
    }
}

데이터 매핑 객체 (DTO/VO)

EasyExcel의 어노테이션을 통해 시트의 컬럼 인덱스와 필드를 매핑합니다. MyBatis-Plus와 연동하기 위해 테이블 명세와 기본키 정의를 포함합니다.

@TableName("T_INSPECTION_RECORD")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FabricInspectionDto implements Serializable {

    @TableId(type = IdType.ASSIGN_UUID)
    private String inspectionUuid;

    @ExcelProperty(value = "원단 유형", index = 0)
    private String fabricType;

    @ExcelProperty(value = "고유 번호", index = 1)
    private String serialNo;

    @ExcelProperty(value = "사번/수", index = 2)
    private String countInfo;

    @ExcelProperty(value = "생산지", index = 3)
    private String productionRegion;

    @ExcelProperty(value = "일괄 식별자", index = 4)
    private String lotId;

    @ExcelProperty(value = "중량(kg)", index = 5)
    private BigDecimal grossWeight;

    @ExcelProperty(value = "비고", index = 6)
    private String memo;
}

스트리밍 리스너 및 헤더 구조 검증

Excel 데이터를 청크 단위로 추출하고, 첫 번째 행에 위치한 헤더가 기대하는 템플릿과 일치하는지 실시간으로 검사합니다. 검증 통과 시 내부 버퍼에 적재하여 최종 커밋 전 단계에서 서비스 레이어로 전달합니다.

public class RawDataValidationListener extends AnalysisEventListener<FabricInspectionDto> {

    private final InspectionRepository repository;
    private final List<FabricInspectionDto> tempBuffer = new ArrayList<>(80);

    public RawDataValidationListener(InspectionRepository repository) {
        this.repository = repository;
    }

    @Override
    public void invoke(FabricInspectionDto dto, AnalysisContext ctx) {
        tempBuffer.add(dto);
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext ctx) {
        if (tempBuffer.isEmpty()) {
            throw new BizException("업로드된 파일에 유효한 데이터 행이 존재하지 않습니다.");
        }
        repository.processAndPersist(tempBuffer);
    }

    @Override
    public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext ctx) {
        if (ctx.readRowHolder().getRowIndex() == 0) {
            String[] expectedSchema = {"원단 유형", "고유 번호", "사번/수", "생산지", "일괄 식별자", "중량(kg)", "비고"};
            for (int idx = 0; idx < expectedSchema.length; idx++) {
                if (!expectedSchema[idx].equalsIgnoreCase(headMap.get(idx))) {
                    throw new BizException("서식을 지원하지 않습니다. 표준 다운로드 템플릿을 재사용하세요.");
                }
            }
        }
    }
}

서비스 레이어: 빈 행 제거 및 중복 판단 로직

전송된 목록에서 주요 필드가 모두 Null인 비어있는 행을 필터링합니다. 이후 핵심 식별값 조합을 기준으로 중복이 있는지 검사한 후, 변환 작업을 거쳐 배시 저장 프로시저로 전달합니다.

@Service
@Slf4j
public class InspectionManagementImpl implements InspectionManagementService {

    @Override
    public void processAndPersist(List<FabricInspectionDto>> rawData) {
        List<FabricInspectionDto> cleanedList = filterEmptyRows(rawData);
        
        String duplicateMsg = detectDuplicateEntries(cleanedList);
        if (duplicateMsg != null) {
            throw new BizException(duplicateMsg);
        }

        List<FabricInspectionDto> sanitized = normalizeNullValues(cleanedList);
        if (sanitized.size() > 1000) {
            throw new BizException("한 번에 처리할 수 있는 한도는 1000건입니다. 분할하여 업로드해 주세요.");
        }

        inspectionDao.batchInsert(sanitized);
    }

    private List<FabricInspectionDto> filterEmptyRows(List<FabricInspectionDto> source) {
        return source.stream()
            .filter(row -> !isEmptyKeyFields(row))
            .collect(Collectors.toList());
    }

    private boolean isEmptyKeyFields(FabricInspectionDto row) {
        return Strings.isNullOrEmpty(row.getFabricType()) &&
               Strings.isNullOrEmpty(row.getSerialNo()) &&
               Strings.isNullOrEmpty(row.getCountInfo()) &&
               row.getGrossWeight() == null;
    }

    private String detectDuplicateEntries(List<FabricInspectionDto> items) {
        for (int i = 0; i < items.size(); i++) {
            for (int j = i + 1; j < items.size(); j++) {
                FabricInspectionDto current = items.get(i);
                FabricInspectionDto target = items.get(j);

                boolean match = Strings.nullToEmpty(current.getSerialNo()).equals(Strings.nullToEmpty(target.getSerialNo())) &&
                                Strings.nullToEmpty(current.getCountInfo()).equals(Strings.nullToEmpty(target.getCountInfo())) &&
                                Optional.ofNullable(current.getGrossWeight()).map(BigDecimal::compareTo).orElse(-1) == Optional.ofNullable(target.getGrossWeight()).map(BigDecimal::compareTo).orElse(-1);

                if (match) {
                    return String.format("파일 %d행과 %d행의 핵심 데이터가 동일하게 확인됩니다.", i + 1, j + 1);
                }
            }
        }
        return null;
    }

    private List<FabricInspectionDto> normalizeNullValues(List<FabricInspectionDto> list) {
        return list.stream()
            .map(this::convertNullToStringFields)
            .collect(Collectors.toList());
    }
}

오라클 기반 다건 삽입(XML 매퍼)

Oracle 데이터베이스 특유의 INSERT ALL ... SELECT * FROM DUAL 구문을 사용하여 성능 최적화된 일괄 저장 기능을 구현합니다. 조건부 컬럼은 동적 SQL 태그로 제어하여 불필요한 NULL 매칭을 방지합니다.

<insert id="batchInsertInspectionRecords">
    INSERT ALL
    <foreach collection="list" item="dto">
        INTO T_INSPECTION_RECORD (
            INSPECTION_UUID,
            FABRIC_TYPE,
            SERIAL_NO,
            COUNT_INFO,
            PRODUCTION_REGION,
            LOT_ID,
            GROSS_WEIGHT,
            MEMO
        ) VALUES (
            #{dto.inspectionUuid},
            #{dto.fabricType},
            #{dto.serialNo},
            #{dto.countInfo},
            #{dto.productionRegion},
            #{dto.lotId},
            #{dto.grossWeight},
            #{dto.memo}
        )
    </foreach>
    SELECT * FROM dual
</insert>

태그: Element-UI EasyExcel spring-boot vue-js MyBatis-Plus

9월 26일 01:57에 게시됨