From dde9bf4c02751479b35544d7befdfda7bca29dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=98=81?= Date: Thu, 11 Dec 2025 13:44:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=BC=ED=83=9C=EB=A3=8C=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EB=B9=84=EA=B5=90=20=EB=A1=9C=EC=A7=81=20=EB=B3=91?= =?UTF-8?q?=EB=A0=AC=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **병렬 처리 도입** - 대상 데이터 처리 작업을 병렬화하여 성능 최적화 - CPU 코어 수의 2배 스레드 풀 생성 및 `CompletableFuture` 활용 - **트랜잭션 단위 변경** - 기존: 전체 트랜잭션으로 관리, 하나의 실패 시 전체 롤백 - 변경: 개별 대상별 트랜잭션 적용, 실패 데이터만 롤백 - **통계 및 로그 추가** - 성공/실패, 유형별 처리 건수(상품용, 이첩, 정상 등) 통계 데이터 제공 - 병렬 처리 상태 및 작업 내역 로그 추가 - **기타** - `TransactionTemplate` 도입 및 가독성을 위한 코드 리팩토링 - 문서 업데이트 (`자동차과태료_비교로직_정리-[미필].md`, `자동차과태료_비교로직_정리-[지연].md`) --- docs/자동차과태료_비교로직_정리-[미필].md | 6 +- docs/자동차과태료_비교로직_정리-[지연].md | 14 +- .../impl/CarFfnlgTrgtIncmpServiceImpl.java | 254 ++++++++++++------ 3 files changed, 178 insertions(+), 96 deletions(-) diff --git a/docs/자동차과태료_비교로직_정리-[미필].md b/docs/자동차과태료_비교로직_정리-[미필].md index efa94fb..603cb52 100644 --- a/docs/자동차과태료_비교로직_정리-[미필].md +++ b/docs/자동차과태료_비교로직_정리-[미필].md @@ -6,9 +6,9 @@ ### 구현 위치 - **Checker 클래스**: `src/main/java/go/kr/project/carInspectionPenalty/registrationOm/service/impl/om_checker/` - - `ProductUseOmChecker.java` - 1. 상품용 - - `OwnerTransferOmChecker.java` - 2. 명의이전 소유자 확인 - - `TransferOmChecker.java` - 3. 이첩 + - `ProductUseOmChecker.java` - 1. 상품용 : 상품용 + - `OwnerTransferOmChecker.java` - 2. 명의이전 소유자 확인 : 명의이전(25.9.5.) 이전소유자 상품용, 경상남도 창원시/ 현대캐피탈 주식회사, 미수검명의이전(25.5.19.)(37하1553) + - `TransferOmChecker.java` - 3. 이첩 : 경기도 과천시/ 이정호, 115일 도래지 ### 기본 설정 - 비교로직에 사용되는 API: `ExternalVehicleApiServiceImpl.getBasicInfo`, `getLedgerInfo` 호출 diff --git a/docs/자동차과태료_비교로직_정리-[지연].md b/docs/자동차과태료_비교로직_정리-[지연].md index 72124ec..f4abe3d 100644 --- a/docs/자동차과태료_비교로직_정리-[지연].md +++ b/docs/자동차과태료_비교로직_정리-[지연].md @@ -6,13 +6,13 @@ ### 구현 위치 - **Checker 클래스**: `src/main/java/go/kr/project/carInspectionPenalty/registration/service/impl/delay_checker/` - - `ProductUseChecker.java` - 1. 상품용(명의이전) - - `ProductUseChnageChecker.java` - 1-1. 상품용(변경등록) - - `ProductCloseWithin31Checker.java` - 2. 내사종결(명의이전 이전소유자 상품용, 31일 이내) - - `OwnerCloseWithin31Checker.java` - 3. 내사종결(순수 명의이전, 31일 이내) - - `ProductLevyOver31Checker.java` - 4. 날짜수정후부과(명의이전 이전소유자 상품용, 31일 초과) - - `OwnerLevyOver31Checker.java` - 5. 날짜수정후부과(순수 명의이전, 31일 초과) - - `TransferCase115DayChecker.java` - 6. 이첩 + - `ProductUseChecker.java` - 1. 상품용(명의이전) : 상품용 + - `ProductUseChnageChecker.java` - 1-1. 상품용(변경등록) : 상품용 + - `ProductCloseWithin31Checker.java` - 2. 내사종결(명의이전 이전소유자 상품용, 31일 이내) : 명의이전(25.9.11.) 이전소유자 상품용 + - `OwnerCloseWithin31Checker.java` - 3. 내사종결(순수 명의이전, 31일 이내) : 명의이전(25.8.28.) + - `ProductLevyOver31Checker.java` - 4. 날짜수정후부과(명의이전 이전소유자 상품용, 31일 초과) : 경기도 고양시/ 장준혁, 미수검명의이전(25.8.19.) + - `OwnerLevyOver31Checker.java` - 5. 날짜수정후부과(순수 명의이전, 31일 초과) : 대구광역시 달서구/ 하나캐피탈(주), 미수검명의이전(25.9.3.) + - `TransferCase115DayChecker.java` - 6. 이첩 : 경상남도 창원시/ 현대캐피탈 주식회사, 115일 도래지, 인천광역시 부평구/ (주)우리카드, 검사일사용본거지 ### 기본 설정 - 비교로직에 사용되는 API: `ExternalVehicleApiServiceImpl.getBasicInfo`, `getLedgerInfo` 호출 diff --git a/src/main/java/go/kr/project/carInspectionPenalty/registrationOm/service/impl/CarFfnlgTrgtIncmpServiceImpl.java b/src/main/java/go/kr/project/carInspectionPenalty/registrationOm/service/impl/CarFfnlgTrgtIncmpServiceImpl.java index 3321c1e..167ee5a 100644 --- a/src/main/java/go/kr/project/carInspectionPenalty/registrationOm/service/impl/CarFfnlgTrgtIncmpServiceImpl.java +++ b/src/main/java/go/kr/project/carInspectionPenalty/registrationOm/service/impl/CarFfnlgTrgtIncmpServiceImpl.java @@ -15,6 +15,7 @@ import lombok.extern.slf4j.Slf4j; import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; import java.io.BufferedReader; @@ -26,6 +27,11 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * 자동차 과태료 대상 미필 Service 구현체 @@ -38,6 +44,7 @@ public class CarFfnlgTrgtIncmpServiceImpl extends EgovAbstractServiceImpl implem private final CarFfnlgTrgtIncmpMapper mapper; private final CarFfnlgPrnParseConfig parseConfig; private final ComparisonOmService comparisonOmService; + private final TransactionTemplate transactionTemplate; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @@ -396,127 +403,202 @@ public class CarFfnlgTrgtIncmpServiceImpl extends EgovAbstractServiceImpl implem } /** - * 선택된 목록에 대해 API 호출 및 기본정보/등록원부 비교 + * 선택된 목록에 대해 API 호출 및 기본정보/등록원부 비교 (병렬처리) * 미필의 경우 부과일자 = 검사유효기간 종료일 + OM_DAY_CD의 D 코드값(146일) + * 변경사항: 각 건을 병렬로 처리하며, 개별 트랜잭션으로 관리됩니다. + * 실패한 건만 실패 처리되고, 성공한 건은 정상 처리됩니다. + * + * @param targetList 선택된 과태료 대상 목록 + * @return 비교 결과 */ @Override - @Transactional public Map compareWithApi(List> targetList) { - log.info("========== 미필 API 호출 및 비교 시작 =========="); + log.info("========== 미필 API 호출 및 비교 시작 (병렬처리) =========="); log.info("선택된 데이터 건수: {}", targetList != null ? targetList.size() : 0); if (targetList == null || targetList.isEmpty()) { throw new IllegalArgumentException("선택된 데이터가 없습니다."); } - List> compareResults = new ArrayList<>(); - int successCount = 0; - int productUseCount = 0; - int transferCount = 0; - int normalCount = 0; - - // 가산일 조회 (OM_DAY_CD 코드의 D값) + // 가산일 조회 (OM_DAY_CD 코드의 D값) - 병렬처리 전에 미리 조회 String plusDayStr = mapper.selectOmDayPlusDay(); - int plusDay = 146; // 기본값 + final int plusDay; if (plusDayStr != null && !plusDayStr.isEmpty()) { + int parsed = 146; try { - plusDay = Integer.parseInt(plusDayStr); + parsed = Integer.parseInt(plusDayStr); } catch (NumberFormatException e) { log.warn("가산일 파싱 실패, 기본값 146 사용: {}", plusDayStr); } + plusDay = parsed; + } else { + plusDay = 146; // 기본값 } log.info("부과일자 가산일: {}일", plusDay); - for (Map target : targetList) { - String carFfnlgTrgtIncmpId = target.get("carFfnlgTrgtIncmpId"); - String vhclno = target.get("vhclno"); - String inspVldPrd = target.get("inspVldPrd"); - - log.info("처리 중 - 차량번호: {}, 검사유효기간: {}", vhclno, inspVldPrd); - - Map compareResult = new HashMap<>(); - compareResult.put("carFfnlgTrgtIncmpId", carFfnlgTrgtIncmpId); - compareResult.put("vhclno", vhclno); - - try { - // 1. 기존 데이터 조회 - CarFfnlgTrgtIncmpVO existingData = new CarFfnlgTrgtIncmpVO(); - existingData.setCarFfnlgTrgtIncmpId(carFfnlgTrgtIncmpId); - existingData = mapper.selectOne(existingData); - - if (existingData == null) { - String errorMsg = String.format("기존 데이터를 찾을 수 없습니다. 차량번호: %s", vhclno); - log.error(errorMsg); - throw new MessageException(errorMsg); - } - - // 2. 처리상태 검증 - 접수상태(01)가 아닌 경우 API 호출 불가 - if (!TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_01_RCPT.equals(existingData.getTaskPrcsSttsCd())) { - String errorMsg = String.format("접수 상태(01)인 데이터만 API 호출이 가능합니다. 차량번호: %s, 현재 상태: %s", - vhclno, existingData.getTaskPrcsSttsCd()); - log.error(errorMsg); - throw new MessageException(errorMsg); - } - - // 3. 검사유효기간에서 부과일자 계산 (종료일 + 가산일) - String levyCrtrYmd = calculateLevyCrtrYmdFromInspVldPrd(inspVldPrd, plusDay); - existingData.setLevyCrtrYmd(levyCrtrYmd); - log.info("부과일자 계산 완료 - 검사유효기간: {}, 부과일자: {}", inspVldPrd, levyCrtrYmd); + // I/O 작업이므로 CPU 코어 수의 2배로 스레드 풀 생성 + int threadPoolSize = Runtime.getRuntime().availableProcessors() * 2; + ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize); - // 4. 비교 로직 실행 - String statusCode = comparisonOmService.executeComparison(existingData); + log.info("병렬처리 스레드 풀 크기: {}", threadPoolSize); - // 결과 처리 - if (statusCode != null) { - if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_02_PRODUCT_USE.equals(statusCode)) { + try { + // 병렬로 각 건 처리 + List>> futures = targetList.stream() + .map(target -> CompletableFuture.supplyAsync(() -> processOneTarget(target, plusDay), executor)) + .collect(Collectors.toList()); + + // 모든 작업 완료 대기 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + // 결과 수집 + List> compareResults = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + // 통계 집계 + int successCount = 0; + int failCount = 0; + int productUseCount = 0; + int transferCount = 0; + int normalCount = 0; + + for (Map result : compareResults) { + Boolean success = (Boolean) result.get("success"); + if (Boolean.TRUE.equals(success)) { + successCount++; + String processStatus = (String) result.get("processStatus"); + if ("상품용".equals(processStatus)) { productUseCount++; - compareResult.put("processStatus", "상품용"); - compareResult.put("message", "상품용으로 처리되었습니다."); - } else if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_03_TRANSFER.equals(statusCode)) { + } else if ("이첩".equals(processStatus)) { transferCount++; - compareResult.put("processStatus", "이첩"); - compareResult.put("message", "이첩으로 처리되었습니다."); - } else if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_04_INVESTIGATION_CLOSED.equals(statusCode)) { + } else if ("내사종결".equals(processStatus) || "정상".equals(processStatus)) { normalCount++; - compareResult.put("processStatus", "내사종결"); - compareResult.put("message", "내사종결로 처리되었습니다."); } else { normalCount++; - compareResult.put("processStatus", "기타"); - compareResult.put("message", "기타 상태로 처리되었습니다."); } - compareResult.put("success", true); - successCount++; } else { - normalCount++; - compareResult.put("success", true); - compareResult.put("message", "정상 처리되었습니다."); - compareResult.put("processStatus", "정상"); - successCount++; + failCount++; } - - } catch (Exception e) { - log.error("데이터 비교 중 오류 발생 - 차량번호: {}, 전체 롤백 처리", vhclno, e); - throw new MessageException(e.getMessage(), e); } - compareResults.add(compareResult); + Map resultData = new HashMap<>(); + resultData.put("compareResults", compareResults); + resultData.put("totalCount", targetList.size()); + resultData.put("successCount", successCount); + resultData.put("failCount", failCount); + resultData.put("productUseCount", productUseCount); + resultData.put("transferCount", transferCount); + resultData.put("normalCount", normalCount); + + log.info("========== 미필 API 호출 및 비교 완료 (병렬처리) =========="); + log.info("전체: {}건, 성공: {}건, 실패: {}건, 상품용: {}건, 이첩: {}건, 정상: {}건", + targetList.size(), successCount, failCount, productUseCount, transferCount, normalCount); + + return resultData; + + } finally { + // ExecutorService 종료 + executor.shutdown(); + try { + if (!executor.awaitTermination(120, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } } + } + + /** + * 개별 건을 처리 (개별 트랜잭션) + * + * @param target 처리할 대상 데이터 + * @param plusDay 부과일자 가산일 + * @return 처리 결과 + */ + private Map processOneTarget(Map target, int plusDay) { + String carFfnlgTrgtIncmpId = target.get("carFfnlgTrgtIncmpId"); + String vhclno = target.get("vhclno"); + String inspVldPrd = target.get("inspVldPrd"); + + log.info("처리 중 - 차량번호: {}, 검사유효기간: {}", vhclno, inspVldPrd); + + Map compareResult = new HashMap<>(); + compareResult.put("carFfnlgTrgtIncmpId", carFfnlgTrgtIncmpId); + compareResult.put("vhclno", vhclno); + + try { + // 개별 트랜잭션으로 실행 + String statusCode = transactionTemplate.execute(status -> { + try { + // 1. 기존 데이터 조회 + CarFfnlgTrgtIncmpVO existingData = new CarFfnlgTrgtIncmpVO(); + existingData.setCarFfnlgTrgtIncmpId(carFfnlgTrgtIncmpId); + existingData = mapper.selectOne(existingData); + + if (existingData == null) { + String errorMsg = String.format("기존 데이터를 찾을 수 없습니다. 차량번호: %s", vhclno); + log.error(errorMsg); + throw new MessageException(errorMsg); + } - Map resultData = new HashMap<>(); - resultData.put("compareResults", compareResults); - resultData.put("totalCount", targetList.size()); - resultData.put("successCount", successCount); - resultData.put("failCount", 0); - resultData.put("productUseCount", productUseCount); - resultData.put("transferCount", transferCount); - resultData.put("normalCount", normalCount); + // 2. 처리상태 검증 - 접수상태(01)가 아닌 경우 API 호출 불가 + if (!TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_01_RCPT.equals(existingData.getTaskPrcsSttsCd())) { + String errorMsg = String.format("접수 상태(01)인 데이터만 API 호출이 가능합니다. 차량번호: %s, 현재 상태: %s", + vhclno, existingData.getTaskPrcsSttsCd()); + log.error(errorMsg); + throw new MessageException(errorMsg); + } + + // 3. 검사유효기간에서 부과일자 계산 (종료일 + 가산일) + String levyCrtrYmd = calculateLevyCrtrYmdFromInspVldPrd(inspVldPrd, plusDay); + existingData.setLevyCrtrYmd(levyCrtrYmd); + log.info("부과일자 계산 완료 - 검사유효기간: {}, 부과일자: {}", inspVldPrd, levyCrtrYmd); + + // 4. 비교 로직 실행 + return comparisonOmService.executeComparison(existingData); - log.info("========== 미필 API 호출 및 비교 완료 =========="); - log.info("성공: {}건, 상품용: {}건, 이첩: {}건, 정상: {}건", - successCount, productUseCount, transferCount, normalCount); + } catch (Exception e) { + // 트랜잭션 롤백 + status.setRollbackOnly(); + throw e; + } + }); + + // 결과 처리 + if (statusCode != null) { + // 비교 규칙이 적용됨 + if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_02_PRODUCT_USE.equals(statusCode)) { + compareResult.put("processStatus", "상품용"); + compareResult.put("message", "상품용으로 처리되었습니다."); + } else if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_03_TRANSFER.equals(statusCode)) { + compareResult.put("processStatus", "이첩"); + compareResult.put("message", "이첩으로 처리되었습니다."); + } else if (TaskPrcsSttsConstants.TASK_PRCS_STTS_CD_04_INVESTIGATION_CLOSED.equals(statusCode)) { + compareResult.put("processStatus", "내사종결"); + compareResult.put("message", "내사종결로 처리되었습니다."); + } else { + compareResult.put("processStatus", "기타"); + compareResult.put("message", "기타 상태로 처리되었습니다."); + } + compareResult.put("success", true); + } else { + // 정상 처리 (비교 로직에 해당 안됨) + compareResult.put("success", true); + compareResult.put("message", "정상 처리되었습니다."); + compareResult.put("processStatus", "정상"); + } + + } catch (Exception e) { + log.error("데이터 비교 중 오류 발생 - 차량번호: {}", vhclno, e); + compareResult.put("success", false); + compareResult.put("message", "처리 실패: " + e.getMessage()); + compareResult.put("processStatus", "실패"); + } - return resultData; + return compareResult; } /**