From 01df0239662a15cf1b64b6872c7105233e7e9777 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 13:40:00 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20Dead?= =?UTF-8?q?=20Code=20=EC=A0=95=EB=A6=AC=20(8=ED=8C=8C=EC=9D=BC,=20~1,200?= =?UTF-8?q?=20LOC=20=EC=82=AD=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/web 미사용 프레임워크 6개 삭제: BaseController, BaseService, BaseServiceImpl, BaseProxyService, BaseHybridService, BaseDto (구현체 0개, ApiResponse만 유지) - common/util 미사용 유틸리티 2개 삭제: SafeGetDataUtil (private 메서드 결함), JsonChangeDetector (미호출) Co-Authored-By: Claude Opus 4.6 --- .../batch/common/util/JsonChangeDetector.java | 233 -------------- .../batch/common/util/SafeGetDataUtil.java | 33 -- .../common/web/controller/BaseController.java | 300 ------------------ .../com/snp/batch/common/web/dto/BaseDto.java | 33 -- .../common/web/service/BaseHybridService.java | 202 ------------ .../common/web/service/BaseProxyService.java | 176 ---------- .../batch/common/web/service/BaseService.java | 94 ------ .../common/web/service/BaseServiceImpl.java | 131 -------- 8 files changed, 1202 deletions(-) delete mode 100644 src/main/java/com/snp/batch/common/util/JsonChangeDetector.java delete mode 100644 src/main/java/com/snp/batch/common/util/SafeGetDataUtil.java delete mode 100644 src/main/java/com/snp/batch/common/web/controller/BaseController.java delete mode 100644 src/main/java/com/snp/batch/common/web/dto/BaseDto.java delete mode 100644 src/main/java/com/snp/batch/common/web/service/BaseHybridService.java delete mode 100644 src/main/java/com/snp/batch/common/web/service/BaseProxyService.java delete mode 100644 src/main/java/com/snp/batch/common/web/service/BaseService.java delete mode 100644 src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java diff --git a/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java b/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java deleted file mode 100644 index 41eaf65..0000000 --- a/src/main/java/com/snp/batch/common/util/JsonChangeDetector.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.snp.batch.common.util; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.security.MessageDigest; -import java.util.*; - -public class JsonChangeDetector { - - // Map으로 변환 시 사용할 ObjectMapper (표준 Mapper 사용) - private static final ObjectMapper MAPPER = new ObjectMapper(); - - // 해시 비교에서 제외할 필드 목록 (DataSetVersion 등) - // 이 목록은 모든 JSON 계층에 걸쳐 적용됩니다. - private static final java.util.Set EXCLUDE_KEYS = - java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime"); - - // ========================================================================= - // ✅ LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의 - // ========================================================================= - private static final Map LIST_SORT_KEYS; - static { - // TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다. - Map map = new HashMap<>(); - // List 필드명 // 정렬 기준 복합 키 (JSON 필드명, 쉼표로 구분) - map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence"); - map.put("CrewList", "LRNO,Shipname,Nationality"); - map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode"); - map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence"); - map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence"); - map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence"); - map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode"); - map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode"); - map.put("NameHistory", "Sequence,EffectiveDate"); - map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence"); - map.put("PandIHistory", "PandIClubCode,EffectiveDate"); - map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo"); - map.put("IceClass", "IceClassCode"); - map.put("SafetyManagementCertificateHistory", "Sequence"); - map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence"); - map.put("SurveyDatesHistory", "ClassSocietyCode"); - map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType"); - map.put("SisterShipLinks", "LinkedLRNO"); - map.put("StatusHistory", "Sequence,StatusCode,StatusDate"); - map.put("SpecialFeature", "Sequence,SpecialFeatureCode"); - map.put("Thrusters", "Sequence"); - map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status"); - map.put("CompanyComplianceDetails", "OwCode"); - map.put("CompanyVesselRelationships", "LRNO"); - map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate"); - - LIST_SORT_KEYS = Collections.unmodifiableMap(map); - } - - // ========================================================================= - // 1. JSON 문자열을 정렬 및 필터링된 Map으로 변환하는 핵심 로직 - // ========================================================================= - /** - * JSON 문자열을 Map으로 변환하고, 특정 키를 제거하며, 키 순서가 정렬된 상태로 만듭니다. - * @param jsonString API 응답 또는 DB에서 읽은 JSON 문자열 - * @return 필터링되고 정렬된 Map 객체 - */ - public static Map jsonToSortedFilteredMap(String jsonString) { - if (jsonString == null || jsonString.trim().isEmpty()) { - return Collections.emptyMap(); - } - - try { - // 1. Map으로 1차 변환합니다. (순서 보장 안됨) - Map rawMap = MAPPER.readValue(jsonString, - new com.fasterxml.jackson.core.type.TypeReference>() {}); - - // 2. 재귀 함수를 호출하여 키를 제거하고 TreeMap(키 순서 정렬)으로 깊은 복사합니다. - return deepFilterAndSort(rawMap); - - } catch (Exception e) { - System.err.println("Error converting JSON to filtered Map: " + e.getMessage()); - // 예외 발생 시 빈 Map 반환 - return Collections.emptyMap(); - } - } - - /** - * Map을 재귀적으로 탐색하며 제외 키를 제거하고 TreeMap(알파벳 순서)으로 변환합니다. - */ - private static Map deepFilterAndSort(Map rawMap) { - // Map을 TreeMap으로 생성하여 키 순서를 알파벳 순으로 강제 정렬합니다. - Map sortedMap = new TreeMap<>(); - - for (Map.Entry entry : rawMap.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - - // 🔑 1. 제외할 키 값인지 확인 - if (EXCLUDE_KEYS.contains(key)) { - continue; // 제외 - } - - // 2. 값의 타입에 따라 재귀 처리 - if (value instanceof Map) { - // 재귀 호출: 하위 Map을 필터링하고 정렬 - @SuppressWarnings("unchecked") - Map subMap = (Map) value; - sortedMap.put(key, deepFilterAndSort(subMap)); - } else if (value instanceof List) { - // List 처리: List 내부의 Map 요소만 재귀 호출 - @SuppressWarnings("unchecked") - List rawList = (List) value; - List filteredList = new ArrayList<>(); - - // 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가 - for (Object item : rawList) { - if (item instanceof Map) { - @SuppressWarnings("unchecked") - Map itemMap = (Map) item; - // List의 요소인 Map도 필터링하고 정렬 (Map의 필드 순서 정렬) - filteredList.add(deepFilterAndSort(itemMap)); - } else { - filteredList.add(item); - } - } - - // 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직) - String listFieldName = entry.getKey(); - String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 키 문자열 - - if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) { - // 복합 키 문자열을 개별 키 배열로 분리 - final String[] sortKeys = sortKeysString.split(","); - - // Map 요소를 가진 리스트인 경우에만 정렬 실행 - try { - Collections.sort(filteredList, new Comparator() { - @Override - @SuppressWarnings("unchecked") - public int compare(Object o1, Object o2) { - Map map1 = (Map) o1; - Map map2 = (Map) o2; - - // 복합 키(sortKeys)를 순서대로 순회하며 비교 - for (String rawSortKey : sortKeys) { - // 키의 공백 제거 - String sortKey = rawSortKey.trim(); - - Object key1 = map1.get(sortKey); - Object key2 = map2.get(sortKey); - - // null 값 처리 로직 - if (key1 == null && key2 == null) { - continue; // 두 값이 동일하므로 다음 키로 이동 - } - if (key1 == null) { - // key1이 null이고 key2는 null이 아니면, key2가 더 크다고 (뒤 순서) 간주하고 1 반환 - return 1; - } - if (key2 == null) { - // key2가 null이고 key1은 null이 아니면, key1이 더 크다고 (뒤 순서) 간주하고 -1 반환 - return -1; - } - - // 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능) - int comparisonResult = key1.toString().compareTo(key2.toString()); - - // 현재 키에서 순서가 결정되면 즉시 반환 - if (comparisonResult != 0) { - return comparisonResult; - } - // comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함 - } - - // 모든 키를 비교해도 동일한 경우 - // 이 경우 두 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다. - return 0; - } - }); - } catch (Exception e) { - System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage()); - // 정렬 실패 시 원래 순서 유지 (filteredList 상태 유지) - } - } - sortedMap.put(key, filteredList); - } else { - // String, Number 등 기본 타입은 그대로 추가 - sortedMap.put(key, value); - } - } - return sortedMap; - } - - // ========================================================================= - // 2. 해시 생성 로직 - // ========================================================================= - - /** - * 필터링되고 정렬된 Map의 문자열 표현을 기반으로 SHA-256 해시를 생성합니다. - */ - public static String getSha256HashFromMap(Map sortedMap) { - // 1. Map을 String으로 변환: TreeMap 덕분에 toString() 결과가 항상 동일한 순서를 가집니다. - String mapString = sortedMap.toString(); - - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(mapString.getBytes("UTF-8")); - - // 바이트 배열을 16진수 문자열로 변환 - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) hexString.append('0'); - hexString.append(hex); - } - return hexString.toString(); - } catch (Exception e) { - System.err.println("Error generating hash: " + e.getMessage()); - return "HASH_ERROR"; - } - } - - - // ========================================================================= - // 3. 해시값 비교 로직 - // ========================================================================= - public static boolean isChanged(String previousHash, String currentHash) { - // DB 해시가 null인 경우 (첫 Insert)는 변경된 것으로 간주 - if (previousHash == null || previousHash.isEmpty()) { - return true; - } - - // 해시값이 다르면 변경된 것으로 간주 - return !Objects.equals(previousHash, currentHash); - } - -} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/common/util/SafeGetDataUtil.java b/src/main/java/com/snp/batch/common/util/SafeGetDataUtil.java deleted file mode 100644 index ce80e53..0000000 --- a/src/main/java/com/snp/batch/common/util/SafeGetDataUtil.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.snp.batch.common.util; - -public class SafeGetDataUtil { - private String safeGetString(String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - return value.trim(); - } - - private Double safeGetDouble(String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - try { - return Double.parseDouble(value); - } catch (NumberFormatException e) { - return null; - } - } - - private Long safeGetLong(String value) { - if (value == null || value.trim().isEmpty()) { - return null; - } - try { - return Long.parseLong(value.trim()); - } catch (NumberFormatException e) { - return null; - } - } - -} diff --git a/src/main/java/com/snp/batch/common/web/controller/BaseController.java b/src/main/java/com/snp/batch/common/web/controller/BaseController.java deleted file mode 100644 index 445a0c6..0000000 --- a/src/main/java/com/snp/batch/common/web/controller/BaseController.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.snp.batch.common.web.controller; - -import com.snp.batch.common.web.ApiResponse; -import com.snp.batch.common.web.service.BaseService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -/** - * 모든 REST Controller의 공통 베이스 클래스 - * CRUD API의 일관된 구조 제공 - * - * 이 클래스는 추상 클래스이므로 @Tag를 붙이지 않습니다. - * 하위 클래스에서 @Tag를 정의하면 모든 엔드포인트가 해당 태그로 그룹화됩니다. - * - * @param DTO 타입 - * @param ID 타입 - */ -@Slf4j -public abstract class BaseController { - - /** - * Service 반환 (하위 클래스에서 구현) - */ - protected abstract BaseService getService(); - - /** - * 리소스 이름 반환 (로깅용) - */ - protected abstract String getResourceName(); - - /** - * 단건 생성 - */ - @Operation( - summary = "리소스 생성", - description = "새로운 리소스를 생성합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "생성 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @PostMapping - public ResponseEntity> create( - @Parameter(description = "생성할 리소스 데이터", required = true) - @RequestBody D dto) { - log.info("{} 생성 요청", getResourceName()); - try { - D created = getService().create(dto); - return ResponseEntity.ok( - ApiResponse.success(getResourceName() + " created successfully", created) - ); - } catch (Exception e) { - log.error("{} 생성 실패", getResourceName(), e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 단건 조회 - */ - @Operation( - summary = "리소스 조회", - description = "ID로 특정 리소스를 조회합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "리소스 없음" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping("/{id}") - public ResponseEntity> getById( - @Parameter(description = "리소스 ID", required = true) - @PathVariable ID id) { - log.info("{} 조회 요청: ID={}", getResourceName(), id); - try { - return getService().findById(id) - .map(dto -> ResponseEntity.ok(ApiResponse.success(dto))) - .orElse(ResponseEntity.notFound().build()); - } catch (Exception e) { - log.error("{} 조회 실패: ID={}", getResourceName(), id, e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 전체 조회 - */ - @Operation( - summary = "전체 리소스 조회", - description = "모든 리소스를 조회합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping - public ResponseEntity>> getAll() { - log.info("{} 전체 조회 요청", getResourceName()); - try { - List list = getService().findAll(); - return ResponseEntity.ok(ApiResponse.success(list)); - } catch (Exception e) { - log.error("{} 전체 조회 실패", getResourceName(), e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 페이징 조회 (JDBC 기반) - * - * @param offset 시작 위치 (기본값: 0) - * @param limit 조회 개수 (기본값: 20) - */ - @Operation( - summary = "페이징 조회", - description = "페이지 단위로 리소스를 조회합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping("/page") - public ResponseEntity>> getPage( - @Parameter(description = "시작 위치 (0부터 시작)", example = "0") - @RequestParam(defaultValue = "0") int offset, - @Parameter(description = "조회 개수", example = "20") - @RequestParam(defaultValue = "20") int limit) { - log.info("{} 페이징 조회 요청: offset={}, limit={}", - getResourceName(), offset, limit); - try { - List list = getService().findAll(offset, limit); - long total = getService().count(); - - // 페이징 정보를 포함한 응답 - return ResponseEntity.ok( - ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list) - ); - } catch (Exception e) { - log.error("{} 페이징 조회 실패", getResourceName(), e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 단건 수정 - */ - @Operation( - summary = "리소스 수정", - description = "기존 리소스를 수정합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "수정 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "리소스 없음" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @PutMapping("/{id}") - public ResponseEntity> update( - @Parameter(description = "리소스 ID", required = true) - @PathVariable ID id, - @Parameter(description = "수정할 리소스 데이터", required = true) - @RequestBody D dto) { - log.info("{} 수정 요청: ID={}", getResourceName(), id); - try { - D updated = getService().update(id, dto); - return ResponseEntity.ok( - ApiResponse.success(getResourceName() + " updated successfully", updated) - ); - } catch (IllegalArgumentException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("{} 수정 실패: ID={}", getResourceName(), id, e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 단건 삭제 - */ - @Operation( - summary = "리소스 삭제", - description = "기존 리소스를 삭제합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "삭제 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "리소스 없음" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @DeleteMapping("/{id}") - public ResponseEntity> delete( - @Parameter(description = "리소스 ID", required = true) - @PathVariable ID id) { - log.info("{} 삭제 요청: ID={}", getResourceName(), id); - try { - getService().deleteById(id); - return ResponseEntity.ok( - ApiResponse.success(getResourceName() + " deleted successfully", null) - ); - } catch (IllegalArgumentException e) { - return ResponseEntity.notFound().build(); - } catch (Exception e) { - log.error("{} 삭제 실패: ID={}", getResourceName(), id, e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage()) - ); - } - } - - /** - * 존재 여부 확인 - */ - @Operation( - summary = "리소스 존재 확인", - description = "특정 ID의 리소스가 존재하는지 확인합니다", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "확인 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "서버 오류" - ) - } - ) - @GetMapping("/{id}/exists") - public ResponseEntity> exists( - @Parameter(description = "리소스 ID", required = true) - @PathVariable ID id) { - log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id); - try { - boolean exists = getService().existsById(id); - return ResponseEntity.ok(ApiResponse.success(exists)); - } catch (Exception e) { - log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e); - return ResponseEntity.internalServerError().body( - ApiResponse.error("Failed to check existence: " + e.getMessage()) - ); - } - } -} diff --git a/src/main/java/com/snp/batch/common/web/dto/BaseDto.java b/src/main/java/com/snp/batch/common/web/dto/BaseDto.java deleted file mode 100644 index 46230bf..0000000 --- a/src/main/java/com/snp/batch/common/web/dto/BaseDto.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.snp.batch.common.web.dto; - -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 모든 DTO의 공통 베이스 클래스 - * 생성/수정 정보 등 공통 필드 - */ -@Data -public abstract class BaseDto { - - /** - * 생성 일시 - */ - private LocalDateTime createdAt; - - /** - * 수정 일시 - */ - private LocalDateTime updatedAt; - - /** - * 생성자 - */ - private String createdBy; - - /** - * 수정자 - */ - private String updatedBy; -} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java b/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java deleted file mode 100644 index 7499dcb..0000000 --- a/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.snp.batch.common.web.service; - -import com.snp.batch.common.batch.repository.BaseJdbcRepository; -import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.reactive.function.client.WebClient; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Map; -import java.util.Optional; - -/** - * 하이브리드 서비스 Base 클래스 (DB 캐시 + 외부 API 프록시) - * - * 사용 시나리오: - * 1. 클라이언트 요청 → DB 조회 (캐시 Hit) - * - 캐시 데이터 유효 시 즉시 반환 - * 2. 캐시 Miss 또는 만료 시 - * - 외부 서비스 API 호출 - * - DB에 저장 (캐시 갱신) - * - 클라이언트에게 반환 - * - * 장점: - * - 빠른 응답 (DB 캐시) - * - 외부 서비스 장애 시에도 캐시 데이터 제공 가능 - * - 외부 API 호출 횟수 감소 (비용 절감) - * - * @param Entity 타입 - * @param DTO 타입 - * @param ID 타입 - */ -@Slf4j -public abstract class BaseHybridService extends BaseServiceImpl { - - /** - * WebClient 반환 (하위 클래스에서 구현) - */ - protected abstract WebClient getWebClient(); - - /** - * 외부 서비스 이름 반환 - */ - protected abstract String getExternalServiceName(); - - /** - * 캐시 유효 시간 (초) - * 기본값: 300초 (5분) - */ - protected long getCacheTtlSeconds() { - return 300; - } - - /** - * 요청 타임아웃 - */ - protected Duration getTimeout() { - return Duration.ofSeconds(30); - } - - /** - * 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출 - * - * @param id 조회 키 - * @return DTO - */ - @Transactional - public D findByIdHybrid(ID id) { - log.info("[하이브리드] ID로 조회: {}", id); - - // 1. DB 캐시 조회 - Optional cached = findById(id); - - if (cached.isPresent()) { - // 캐시 유효성 검증 - if (isCacheValid(cached.get())) { - log.info("[하이브리드] 캐시 Hit - DB에서 반환"); - return cached.get(); - } else { - log.info("[하이브리드] 캐시 만료 - 외부 API 호출"); - } - } else { - log.info("[하이브리드] 캐시 Miss - 외부 API 호출"); - } - - // 2. 외부 API 호출 - try { - D externalData = fetchFromExternalApi(id); - - // 3. DB 저장 (캐시 갱신) - T entity = toEntity(externalData); - T saved = getRepository().save(entity); - - log.info("[하이브리드] 외부 데이터 DB 저장 완료"); - return toDto(saved); - - } catch (Exception e) { - log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage()); - - // 4. 외부 API 실패 시 만료된 캐시라도 반환 (Fallback) - if (cached.isPresent()) { - log.warn("[하이브리드] Fallback - 만료된 캐시 반환"); - return cached.get(); - } - - throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e); - } - } - - /** - * 외부 API에서 데이터 조회 (하위 클래스에서 구현) - * - * @param id 조회 키 - * @return DTO - */ - protected abstract D fetchFromExternalApi(ID id) throws Exception; - - /** - * 캐시 유효성 검증 - * 기본 구현: updated_at 기준으로 TTL 체크 - * - * @param dto 캐시 데이터 - * @return 유효 여부 - */ - protected boolean isCacheValid(D dto) { - // BaseDto를 상속한 경우 updatedAt 체크 - try { - LocalDateTime updatedAt = extractUpdatedAt(dto); - if (updatedAt == null) { - return false; - } - - LocalDateTime now = LocalDateTime.now(); - long elapsedSeconds = Duration.between(updatedAt, now).getSeconds(); - - return elapsedSeconds < getCacheTtlSeconds(); - - } catch (Exception e) { - log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage()); - return false; - } - } - - /** - * DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능) - */ - protected LocalDateTime extractUpdatedAt(D dto) { - // 기본 구현: 항상 캐시 무효 (외부 API 호출) - return null; - } - - /** - * 강제 캐시 갱신 (외부 API 호출 강제) - */ - @Transactional - public D refreshCache(ID id) throws Exception { - log.info("[하이브리드] 캐시 강제 갱신: {}", id); - - D externalData = fetchFromExternalApi(id); - T entity = toEntity(externalData); - T saved = getRepository().save(entity); - - return toDto(saved); - } - - /** - * 외부 API GET 요청 - */ - protected RES callExternalGet(String endpoint, Map params, Class responseType) { - log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint); - - return getWebClient() - .get() - .uri(uriBuilder -> { - uriBuilder.path(endpoint); - if (params != null) { - params.forEach(uriBuilder::queryParam); - } - return uriBuilder.build(); - }) - .retrieve() - .bodyToMono(responseType) - .timeout(getTimeout()) - .block(); - } - - /** - * 외부 API POST 요청 - */ - protected RES callExternalPost(String endpoint, REQ requestBody, Class responseType) { - log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint); - - return getWebClient() - .post() - .uri(endpoint) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(responseType) - .timeout(getTimeout()) - .block(); - } -} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java b/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java deleted file mode 100644 index 05c0032..0000000 --- a/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.snp.batch.common.web.service; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.time.Duration; -import java.util.Map; - -/** - * 외부 API 프록시 서비스 Base 클래스 - * - * 목적: 해외 외부 서비스를 국내에서 우회 접근할 수 있도록 프록시 역할 수행 - * - * 사용 시나리오: - * - 외부 서비스가 해외에 있고 국내 IP에서만 접근 가능 - * - 클라이언트 A → 우리 서버 (국내) → 외부 서비스 (해외) → 응답 전달 - * - * 장점: - * - 실시간 데이터 제공 (DB 캐시 없이) - * - 외부 서비스의 최신 데이터 보장 - * - DB 저장 부담 없음 - * - * @param 요청 DTO 타입 - * @param 응답 DTO 타입 - */ -@Slf4j -public abstract class BaseProxyService { - - /** - * WebClient 반환 (하위 클래스에서 구현) - * 외부 서비스별로 인증, Base URL 등 설정 - */ - protected abstract WebClient getWebClient(); - - /** - * 외부 서비스 이름 반환 (로깅용) - */ - protected abstract String getServiceName(); - - /** - * 요청 타임아웃 (밀리초) - * 기본값: 30초 - */ - protected Duration getTimeout() { - return Duration.ofSeconds(30); - } - - /** - * GET 요청 프록시 - * - * @param endpoint 엔드포인트 경로 (예: "/api/ships") - * @param params 쿼리 파라미터 - * @param responseType 응답 클래스 타입 - * @return 외부 서비스 응답 - */ - public RES proxyGet(String endpoint, Map params, Class responseType) { - log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params); - - try { - WebClient.RequestHeadersSpec spec = getWebClient() - .get() - .uri(uriBuilder -> { - uriBuilder.path(endpoint); - if (params != null) { - params.forEach(uriBuilder::queryParam); - } - return uriBuilder.build(); - }); - - RES response = spec.retrieve() - .bodyToMono(responseType) - .timeout(getTimeout()) - .block(); - - log.info("[{}] 응답 성공", getServiceName()); - return response; - - } catch (Exception e) { - log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e); - throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e); - } - } - - /** - * POST 요청 프록시 - * - * @param endpoint 엔드포인트 경로 - * @param requestBody 요청 본문 - * @param responseType 응답 클래스 타입 - * @return 외부 서비스 응답 - */ - public RES proxyPost(String endpoint, REQ requestBody, Class responseType) { - log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint); - - try { - RES response = getWebClient() - .post() - .uri(endpoint) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(responseType) - .timeout(getTimeout()) - .block(); - - log.info("[{}] 응답 성공", getServiceName()); - return response; - - } catch (Exception e) { - log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e); - throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e); - } - } - - /** - * PUT 요청 프록시 - */ - public RES proxyPut(String endpoint, REQ requestBody, Class responseType) { - log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint); - - try { - RES response = getWebClient() - .put() - .uri(endpoint) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(responseType) - .timeout(getTimeout()) - .block(); - - log.info("[{}] 응답 성공", getServiceName()); - return response; - - } catch (Exception e) { - log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e); - throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e); - } - } - - /** - * DELETE 요청 프록시 - */ - public void proxyDelete(String endpoint, Map params) { - log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params); - - try { - getWebClient() - .delete() - .uri(uriBuilder -> { - uriBuilder.path(endpoint); - if (params != null) { - params.forEach(uriBuilder::queryParam); - } - return uriBuilder.build(); - }) - .retrieve() - .bodyToMono(Void.class) - .timeout(getTimeout()) - .block(); - - log.info("[{}] DELETE 성공", getServiceName()); - - } catch (Exception e) { - log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e); - throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e); - } - } - - /** - * 커스텀 요청 처리 (하위 클래스에서 오버라이드) - * 복잡한 로직이 필요한 경우 사용 - */ - protected RES customRequest(REQ request) { - throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다"); - } -} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseService.java b/src/main/java/com/snp/batch/common/web/service/BaseService.java deleted file mode 100644 index 663870b..0000000 --- a/src/main/java/com/snp/batch/common/web/service/BaseService.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.snp.batch.common.web.service; - -import java.util.List; -import java.util.Optional; - -/** - * 모든 서비스의 공통 인터페이스 (JDBC 기반) - * CRUD 기본 메서드 정의 - * - * @param Entity 타입 - * @param DTO 타입 - * @param ID 타입 - */ -public interface BaseService { - - /** - * 단건 생성 - * - * @param dto 생성할 데이터 DTO - * @return 생성된 데이터 DTO - */ - D create(D dto); - - /** - * 단건 조회 - * - * @param id 조회할 ID - * @return 조회된 데이터 DTO (Optional) - */ - Optional findById(ID id); - - /** - * 전체 조회 - * - * @return 전체 데이터 DTO 리스트 - */ - List findAll(); - - /** - * 페이징 조회 - * - * @param offset 시작 위치 (0부터 시작) - * @param limit 조회 개수 - * @return 페이징된 데이터 리스트 - */ - List findAll(int offset, int limit); - - /** - * 전체 개수 조회 - * - * @return 전체 데이터 개수 - */ - long count(); - - /** - * 단건 수정 - * - * @param id 수정할 ID - * @param dto 수정할 데이터 DTO - * @return 수정된 데이터 DTO - */ - D update(ID id, D dto); - - /** - * 단건 삭제 - * - * @param id 삭제할 ID - */ - void deleteById(ID id); - - /** - * 존재 여부 확인 - * - * @param id 확인할 ID - * @return 존재 여부 - */ - boolean existsById(ID id); - - /** - * Entity를 DTO로 변환 - * - * @param entity 엔티티 - * @return DTO - */ - D toDto(T entity); - - /** - * DTO를 Entity로 변환 - * - * @param dto DTO - * @return 엔티티 - */ - T toEntity(D dto); -} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java b/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java deleted file mode 100644 index 3308a8f..0000000 --- a/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.snp.batch.common.web.service; - -import com.snp.batch.common.batch.repository.BaseJdbcRepository; -import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * BaseService의 기본 구현 (JDBC 기반) - * 공통 CRUD 로직 구현 - * - * @param Entity 타입 - * @param DTO 타입 - * @param ID 타입 - */ -@Slf4j -@Transactional(readOnly = true) -public abstract class BaseServiceImpl implements BaseService { - - /** - * Repository 반환 (하위 클래스에서 구현) - */ - protected abstract BaseJdbcRepository getRepository(); - - /** - * 엔티티 이름 반환 (로깅용) - */ - protected abstract String getEntityName(); - - @Override - @Transactional - public D create(D dto) { - log.info("{} 생성 시작", getEntityName()); - T entity = toEntity(dto); - T saved = getRepository().save(entity); - log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved)); - return toDto(saved); - } - - @Override - public Optional findById(ID id) { - log.debug("{} 조회: ID={}", getEntityName(), id); - return getRepository().findById(id).map(this::toDto); - } - - @Override - public List findAll() { - log.debug("{} 전체 조회", getEntityName()); - return getRepository().findAll().stream() - .map(this::toDto) - .collect(Collectors.toList()); - } - - @Override - public List findAll(int offset, int limit) { - log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit); - - // 하위 클래스에서 제공하는 페이징 쿼리 실행 - List entities = executePagingQuery(offset, limit); - - return entities.stream() - .map(this::toDto) - .collect(Collectors.toList()); - } - - /** - * 페이징 쿼리 실행 (하위 클래스에서 구현) - * - * @param offset 시작 위치 - * @param limit 조회 개수 - * @return Entity 리스트 - */ - protected abstract List executePagingQuery(int offset, int limit); - - @Override - public long count() { - log.debug("{} 개수 조회", getEntityName()); - return getRepository().count(); - } - - @Override - @Transactional - public D update(ID id, D dto) { - log.info("{} 수정 시작: ID={}", getEntityName(), id); - - T entity = getRepository().findById(id) - .orElseThrow(() -> new IllegalArgumentException( - getEntityName() + " not found with id: " + id)); - - updateEntity(entity, dto); - T updated = getRepository().save(entity); - - log.info("{} 수정 완료: ID={}", getEntityName(), id); - return toDto(updated); - } - - @Override - @Transactional - public void deleteById(ID id) { - log.info("{} 삭제: ID={}", getEntityName(), id); - - if (!getRepository().existsById(id)) { - throw new IllegalArgumentException( - getEntityName() + " not found with id: " + id); - } - - getRepository().deleteById(id); - log.info("{} 삭제 완료: ID={}", getEntityName(), id); - } - - @Override - public boolean existsById(ID id) { - return getRepository().existsById(id); - } - - /** - * Entity 업데이트 (하위 클래스에서 구현) - * - * @param entity 업데이트할 엔티티 - * @param dto 업데이트 데이터 - */ - protected abstract void updateEntity(T entity, D dto); - - /** - * Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현) - */ - protected abstract ID extractId(T entity); -}