fix: ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 #3

병합
htlee feature/bugfix-cache-schedule 에서 develop 로 10 commits 를 머지했습니다 2026-02-19 09:50:34 +09:00
8개의 변경된 파일0개의 추가작업 그리고 1202개의 파일을 삭제
Showing only changes of commit 50badbe2bb - Show all commits

파일 보기

@ -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<String> EXCLUDE_KEYS =
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
// =========================================================================
// LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
// =========================================================================
private static final Map<String, String> LIST_SORT_KEYS;
static {
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
Map<String, String> 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<String, Object> jsonToSortedFilteredMap(String jsonString) {
if (jsonString == null || jsonString.trim().isEmpty()) {
return Collections.emptyMap();
}
try {
// 1. Map<String, Object>으로 1차 변환합니다. (순서 보장 안됨)
Map<String, Object> rawMap = MAPPER.readValue(jsonString,
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
// 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<String, Object> deepFilterAndSort(Map<String, Object> rawMap) {
// Map을 TreeMap으로 생성하여 순서를 알파벳 순으로 강제 정렬합니다.
Map<String, Object> sortedMap = new TreeMap<>();
for (Map.Entry<String, Object> 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<String, Object> subMap = (Map<String, Object>) value;
sortedMap.put(key, deepFilterAndSort(subMap));
} else if (value instanceof List) {
// List 처리: List 내부의 Map 요소만 재귀 호출
@SuppressWarnings("unchecked")
List<Object> rawList = (List<Object>) value;
List<Object> filteredList = new ArrayList<>();
// 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가
for (Object item : rawList) {
if (item instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> itemMap = (Map<String, Object>) 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<Object>() {
@Override
@SuppressWarnings("unchecked")
public int compare(Object o1, Object o2) {
Map<String, Object> map1 = (Map<String, Object>) o1;
Map<String, Object> map2 = (Map<String, Object>) 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<String, Object> 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);
}
}

파일 보기

@ -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;
}
}
}

파일 보기

@ -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 <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
public abstract class BaseController<D, ID> {
/**
* Service 반환 (하위 클래스에서 구현)
*/
protected abstract BaseService<?, D, ID> 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<ApiResponse<D>> 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<ApiResponse<D>> 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<ApiResponse<List<D>>> getAll() {
log.info("{} 전체 조회 요청", getResourceName());
try {
List<D> 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<ApiResponse<List<D>>> 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<D> 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<ApiResponse<D>> 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<ApiResponse<Void>> 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<ApiResponse<Boolean>> 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())
);
}
}
}

파일 보기

@ -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;
}

파일 보기

@ -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 <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
/**
* 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<D> 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> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> 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 <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
return getWebClient()
.post()
.uri(endpoint)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
}
}

파일 보기

@ -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 <REQ> 요청 DTO 타입
* @param <RES> 응답 DTO 타입
*/
@Slf4j
public abstract class BaseProxyService<REQ, RES> {
/**
* 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<String, String> params, Class<RES> 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<RES> 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<RES> 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<String, String> 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("커스텀 요청이 구현되지 않았습니다");
}
}

파일 보기

@ -1,94 +0,0 @@
package com.snp.batch.common.web.service;
import java.util.List;
import java.util.Optional;
/**
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
* CRUD 기본 메서드 정의
*
* @param <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
public interface BaseService<T, D, ID> {
/**
* 단건 생성
*
* @param dto 생성할 데이터 DTO
* @return 생성된 데이터 DTO
*/
D create(D dto);
/**
* 단건 조회
*
* @param id 조회할 ID
* @return 조회된 데이터 DTO (Optional)
*/
Optional<D> findById(ID id);
/**
* 전체 조회
*
* @return 전체 데이터 DTO 리스트
*/
List<D> findAll();
/**
* 페이징 조회
*
* @param offset 시작 위치 (0부터 시작)
* @param limit 조회 개수
* @return 페이징된 데이터 리스트
*/
List<D> 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);
}

파일 보기

@ -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 <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
@Transactional(readOnly = true)
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
/**
* Repository 반환 (하위 클래스에서 구현)
*/
protected abstract BaseJdbcRepository<T, ID> 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<D> findById(ID id) {
log.debug("{} 조회: ID={}", getEntityName(), id);
return getRepository().findById(id).map(this::toDto);
}
@Override
public List<D> findAll() {
log.debug("{} 전체 조회", getEntityName());
return getRepository().findAll().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
@Override
public List<D> findAll(int offset, int limit) {
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
// 하위 클래스에서 제공하는 페이징 쿼리 실행
List<T> entities = executePagingQuery(offset, limit);
return entities.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
/**
* 페이징 쿼리 실행 (하위 클래스에서 구현)
*
* @param offset 시작 위치
* @param limit 조회 개수
* @return Entity 리스트
*/
protected abstract List<T> 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);
}