프론트엔드 드래그 앤 드롭 컴포넌트
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>