Merge pull request 'feat/infer_dev_260211' (#114) from feat/infer_dev_260211 into develop

Reviewed-on: #114
This commit was merged in pull request #114.
This commit is contained in:
2026-02-27 10:09:04 +09:00
6 changed files with 75 additions and 41 deletions

View File

@@ -78,7 +78,7 @@ public class InferenceCommonService {
// 4) 추론 실행 API 호출 // 4) 추론 실행 API 호출
ExternalCallResult<String> result = ExternalCallResult<String> result =
externalHttpClient.call(inferenceUrl, HttpMethod.POST, dto, headers, String.class); externalHttpClient.callLong(inferenceUrl, HttpMethod.POST, dto, headers, String.class);
if (result.statusCode() < 200 || result.statusCode() >= 300) { if (result.statusCode() < 200 || result.statusCode() >= 300) {
log.error("Inference API failed. status={}, body={}", result.statusCode(), result.body()); log.error("Inference API failed. status={}, body={}", result.statusCode(), result.body());

View File

@@ -3,8 +3,8 @@ package com.kamco.cd.kamcoback.config.resttemplate;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -14,54 +14,86 @@ import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@RequiredArgsConstructor
@Component @Component
@Log4j2 @Log4j2
public class ExternalHttpClient { public class ExternalHttpClient {
private final RestTemplate restTemplate; private final RestTemplate restTemplate; // short (@Primary)
private final RestTemplate restTemplateLong; // long
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ExternalHttpClient(
RestTemplate restTemplate,
@Qualifier("restTemplateLong") RestTemplate restTemplateLong,
ObjectMapper objectMapper) {
this.restTemplate = restTemplate;
this.restTemplateLong = restTemplateLong;
this.objectMapper = objectMapper;
}
/** 기본(짧은 timeout) 호출 */
public <T> ExternalCallResult<T> call( public <T> ExternalCallResult<T> call(
String url, HttpMethod method, Object body, HttpHeaders headers, Class<T> responseType) { String url, HttpMethod method, Object body, HttpHeaders headers, Class<T> responseType) {
// responseType 기반으로 Accept 동적 세팅 return doCall(restTemplate, url, method, body, headers, responseType);
}
/** 추론/대용량 전용 (긴 timeout) */
public <T> ExternalCallResult<T> callLong(
String url, HttpMethod method, Object body, HttpHeaders headers, Class<T> responseType) {
return doCall(restTemplateLong, url, method, body, headers, responseType);
}
private <T> ExternalCallResult<T> doCall(
RestTemplate rt,
String url,
HttpMethod method,
Object body,
HttpHeaders headers,
Class<T> responseType) {
HttpHeaders resolvedHeaders = resolveHeaders(headers, responseType); HttpHeaders resolvedHeaders = resolveHeaders(headers, responseType);
logRequestBody(body); logRequestBody(body);
HttpEntity<Object> entity = new HttpEntity<>(body, resolvedHeaders); HttpEntity<Object> entity = new HttpEntity<>(body, resolvedHeaders);
try { try {
// String: raw bytes -> UTF-8 string
// String 응답은 raw byte로 받아 UTF-8 변환
if (responseType == String.class) { if (responseType == String.class) {
ResponseEntity<byte[]> res = restTemplate.exchange(url, method, entity, byte[].class); ResponseEntity<byte[]> res = rt.exchange(url, method, entity, byte[].class);
String raw = String raw =
(res.getBody() == null) ? null : new String(res.getBody(), StandardCharsets.UTF_8); (res.getBody() == null) ? null : new String(res.getBody(), StandardCharsets.UTF_8);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
T casted = (T) raw; T casted = (T) raw;
return new ExternalCallResult<>(res.getStatusCodeValue(), true, casted, null); return new ExternalCallResult<>(res.getStatusCodeValue(), true, casted, null);
} }
// byte[]: raw bytes로 받고, JSON이면 에러로 처리 // byte[] 응답 처리
if (responseType == byte[].class) { if (responseType == byte[].class) {
ResponseEntity<byte[]> res = restTemplate.exchange(url, method, entity, byte[].class); ResponseEntity<byte[]> res = rt.exchange(url, method, entity, byte[].class);
MediaType ct = res.getHeaders().getContentType(); MediaType ct = res.getHeaders().getContentType();
byte[] bytes = res.getBody(); byte[] bytes = res.getBody();
// JSON이면 에러로 간주
if (isJsonLike(ct)) { if (isJsonLike(ct)) {
String err = (bytes == null) ? null : new String(bytes, StandardCharsets.UTF_8); String err = (bytes == null) ? null : new String(bytes, StandardCharsets.UTF_8);
return new ExternalCallResult<>(res.getStatusCodeValue(), false, null, err); return new ExternalCallResult<>(res.getStatusCodeValue(), false, null, err);
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
T casted = (T) bytes; T casted = (T) bytes;
return new ExternalCallResult<>(res.getStatusCodeValue(), true, casted, null); return new ExternalCallResult<>(res.getStatusCodeValue(), true, casted, null);
} }
// DTO 등: 일반 역직렬화 // DTO 응답
ResponseEntity<T> res = restTemplate.exchange(url, method, entity, responseType); ResponseEntity<T> res = rt.exchange(url, method, entity, responseType);
return new ExternalCallResult<>(res.getStatusCodeValue(), true, res.getBody(), null); return new ExternalCallResult<>(res.getStatusCodeValue(), true, res.getBody(), null);
} catch (HttpStatusCodeException e) { } catch (HttpStatusCodeException e) {
@@ -70,29 +102,28 @@ public class ExternalHttpClient {
} }
} }
// 기존 resolveJsonHeaders를 "동적"으로 교체 /** Accept / Content-Type 자동 처리 */
private HttpHeaders resolveHeaders(HttpHeaders headers, Class<?> responseType) { private HttpHeaders resolveHeaders(HttpHeaders headers, Class<?> responseType) {
// 원본 headers를 그대로 쓰면 외부에서 재사용할 때 사이드이펙트 날 수 있어서 복사 권장
HttpHeaders h = (headers == null) ? new HttpHeaders() : new HttpHeaders(headers); HttpHeaders h = (headers == null) ? new HttpHeaders() : new HttpHeaders(headers);
// 요청 바디 기본은 JSON이라고 가정 (필요하면 호출부에서 덮어쓰기) // 기본 Content-Type
if (h.getContentType() == null) { if (h.getContentType() == null) {
h.setContentType(MediaType.APPLICATION_JSON); h.setContentType(MediaType.APPLICATION_JSON);
} }
// 호출부에서 Accept를 명시했으면 존중 // Accept 이미 있으면 존중
if (h.getAccept() != null && !h.getAccept().isEmpty()) { if (h.getAccept() != null && !h.getAccept().isEmpty()) {
return h; return h;
} }
// responseType Accept 자동 지정 // 응답 타입 Accept 자동 지정
if (responseType == byte[].class) { if (responseType == byte[].class) {
h.setAccept( h.setAccept(
List.of( List.of(
MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_OCTET_STREAM,
MediaType.valueOf("application/zip"), MediaType.valueOf("application/zip"),
MediaType.APPLICATION_JSON // 실패(JSON 에러 바디) 대비 MediaType.APPLICATION_JSON));
));
} else { } else {
h.setAccept(List.of(MediaType.APPLICATION_JSON)); h.setAccept(List.of(MediaType.APPLICATION_JSON));
} }
@@ -100,12 +131,15 @@ public class ExternalHttpClient {
return h; return h;
} }
/** JSON 응답 여부 체크 */
private boolean isJsonLike(MediaType ct) { private boolean isJsonLike(MediaType ct) {
if (ct == null) return false; if (ct == null) return false;
return ct.includes(MediaType.APPLICATION_JSON) return ct.includes(MediaType.APPLICATION_JSON)
|| "application/problem+json".equalsIgnoreCase(ct.toString()); || "application/problem+json".equalsIgnoreCase(ct.toString());
} }
/** 요청 바디 로그 */
private void logRequestBody(Object body) { private void logRequestBody(Object body) {
try { try {
if (body != null) { if (body != null) {

View File

@@ -4,6 +4,7 @@ import lombok.extern.log4j.Log4j2;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.client.BufferingClientHttpRequestFactory; import org.springframework.http.client.BufferingClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@@ -13,10 +14,20 @@ import org.springframework.web.client.RestTemplate;
public class RestTemplateConfig { public class RestTemplateConfig {
@Bean @Bean
@Primary
public RestTemplate restTemplate(RestTemplateBuilder builder) { public RestTemplate restTemplate(RestTemplateBuilder builder) {
return build(builder, 2000, 3000);
}
@Bean("restTemplateLong")
public RestTemplate restTemplateLong(RestTemplateBuilder builder) {
return build(builder, 2000, 60000);
}
private RestTemplate build(RestTemplateBuilder builder, int connectTimeoutMs, int readTimeoutMs) {
SimpleClientHttpRequestFactory baseFactory = new SimpleClientHttpRequestFactory(); SimpleClientHttpRequestFactory baseFactory = new SimpleClientHttpRequestFactory();
baseFactory.setConnectTimeout(2000); baseFactory.setConnectTimeout(connectTimeoutMs);
baseFactory.setReadTimeout(60000); baseFactory.setReadTimeout(readTimeoutMs);
RestTemplate rt = RestTemplate rt =
builder builder
@@ -24,27 +35,8 @@ public class RestTemplateConfig {
.additionalInterceptors(new RetryInterceptor()) .additionalInterceptors(new RetryInterceptor())
.build(); .build();
// byte[] 응답은 무조건 raw로 읽게 강제 (Jackson이 끼어들 여지 제거)
rt.getMessageConverters() rt.getMessageConverters()
.add(0, new org.springframework.http.converter.ByteArrayHttpMessageConverter()); .add(0, new org.springframework.http.converter.ByteArrayHttpMessageConverter());
return rt; return rt;
} }
// @Bean(name = "restTemplateLong")
// public RestTemplate restTemplateLong(RestTemplateBuilder builder) {
// SimpleClientHttpRequestFactory baseFactory = new SimpleClientHttpRequestFactory();
// baseFactory.setConnectTimeout(2000);
// baseFactory.setReadTimeout(60000); // 길게(예: 60초)
//
// RestTemplate rt =
// builder
// .requestFactory(() -> new BufferingClientHttpRequestFactory(baseFactory))
// .additionalInterceptors(new RetryInterceptor())
// .build();
//
// rt.getMessageConverters()
// .add(0, new org.springframework.http.converter.ByteArrayHttpMessageConverter());
// return rt;
// }
} }

View File

@@ -144,6 +144,7 @@ public class InferenceResultService {
* @return * @return
*/ */
public UUID runExcl(InferenceResultDto.RegReq req) { public UUID runExcl(InferenceResultDto.RegReq req) {
// TODO 쿼리로 한번에 할수 있게 수정해야하나..
// 기준연도 실행가능 도엽 조회 // 기준연도 실행가능 도엽 조회
List<MngListDto> targetMngList = List<MngListDto> targetMngList =
mapSheetMngCoreService.getMapSheetMngHst( mapSheetMngCoreService.getMapSheetMngHst(
@@ -267,7 +268,7 @@ public class InferenceResultService {
*/ */
@Transactional @Transactional
public UUID runPrev(InferenceResultDto.RegReq req) { public UUID runPrev(InferenceResultDto.RegReq req) {
// TODO 쿼리로 한번에 할수 있게 수정해야하나..
// 기준연도 실행가능 도엽 조회 // 기준연도 실행가능 도엽 조회
List<MngListDto> targetMngList = List<MngListDto> targetMngList =
mapSheetMngCoreService.getMapSheetMngHst( mapSheetMngCoreService.getMapSheetMngHst(

View File

@@ -351,7 +351,7 @@ public class MapSheetMngCoreService {
} }
/** /**
* 이전 년도 도엽 조회 * 이전 년도 도엽 조회 조건이 많을 수 있으므로 chunk 줘서 끊어서 조회
* *
* @param year * @param year
* @param mapIds * @param mapIds

View File

@@ -125,5 +125,12 @@ public interface MapSheetMngRepositoryCustom {
*/ */
List<MngListDto> getMapSheetMngHst(Integer year, String mapSheetScope, List<String> mapSheetNum); List<MngListDto> getMapSheetMngHst(Integer year, String mapSheetScope, List<String> mapSheetNum);
/**
* 비교연도 사용 가능한 이전도엽을 조회한다.
*
* @param year 연도
* @param mapIds 도엽목록
* @return 사용 가능한 이전도엽목록
*/
List<MngListDto> findFallbackCompareYearByMapSheets(Integer year, List<String> mapIds); List<MngListDto> findFallbackCompareYearByMapSheets(Integer year, List<String> mapIds);
} }