IP 매칭 유틸리티 추가 - CIDR/와일드카드 포함(#194).
eapim-admin CI / build (push) Has been cancelled

포탈 캐시 리로드 다중 서버 지원 - UI 개선/동시 호출 추가.
내부 API 유틸/테스트 및 JSP UI 연계 최적화.
This commit is contained in:
Rinjae
2026-08-06 09:59:23 +09:00
parent db57e95233
commit dc9e737aa9
3 changed files with 319 additions and 35 deletions
@@ -1,22 +1,40 @@
package com.eactive.eai.rms.onl.apim.portalmenu;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/**
* eapim-portal 메뉴 캐시 리로드 클라이언트 (admin → portal 내부 호출).
*
* <p>호출 URL 은 PTL_PROPERTY {@code Portal / portal.internal.menu-reload-url} 로 관리한다
* (기본 {@code http://127.0.0.1:39130/internal/menu/reload}).
* 포탈이 다중 인스턴스로 기동되는 환경을 위해 <b>콤마/세미콜론/줄바꿈</b> 으로 여러 URL 을 나열할 수 있으며,
* 나열된 모든 서버로 병렬 호출한다. 각 호출의 connect/read 타임아웃은 {@value #TIMEOUT_MS}ms 이다.
* portal 측은 {@code menu.internal.allow-ips} 허용 IP 목록으로 요청을 검증한다.
* 오류는 결과 Map 으로 감싸 화면에 사유를 표시한다 — 예외를 전파하지 않는다.</p>
* 오류는 결과 Map 으로 감싸 화면에 서버별 사유를 표시한다 — 예외를 전파하지 않는다.</p>
*/
@Component
public class PortalMenuCacheClient {
@@ -26,50 +44,230 @@ public class PortalMenuCacheClient {
static final String PROP_GROUP = "Portal";
static final String PROP_RELOAD_URL = "portal.internal.menu-reload-url";
static final String DEFAULT_RELOAD_URL = "http://127.0.0.1:39130/internal/menu/reload";
static final String PROP_DESCRIPTION =
"eapim-portal 메뉴 캐시 리로드 내부 URL. 다중 서버는 콤마(,)/세미콜론(;)/줄바꿈으로 구분";
/** 서버 1대당 connect/read 타임아웃(ms) */
static final int TIMEOUT_MS = 3000;
/** 병렬 호출 전체 대기 여유분(ms) — 스레드 시작/응답 파싱 지연 흡수 */
private static final int OVERALL_SLACK_MS = 2000;
/** 동시 호출 스레드 상한 */
private static final int MAX_PARALLELISM = 10;
private final PortalPropertyService portalPropertyService;
private final RestTemplate restTemplate;
@Autowired
public PortalMenuCacheClient(PortalPropertyService portalPropertyService, RestTemplate restTemplate) {
public PortalMenuCacheClient(PortalPropertyService portalPropertyService) {
this(portalPropertyService, createRestTemplateWithTimeout(TIMEOUT_MS));
}
/** 테스트용 — 타임아웃이 설정된 RestTemplate 을 직접 주입한다. */
PortalMenuCacheClient(PortalPropertyService portalPropertyService, RestTemplate restTemplate) {
this.portalPropertyService = portalPropertyService;
this.restTemplate = restTemplate;
}
private static RestTemplate createRestTemplateWithTimeout(int timeoutMs) {
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(timeoutMs)
.setConnectTimeout(timeoutMs)
.setSocketTimeout(timeoutMs)
.build();
HttpClient httpClient = HttpClientBuilder.create()
.setMaxConnTotal(MAX_PARALLELISM * 2)
.setMaxConnPerRoute(MAX_PARALLELISM)
.setDefaultRequestConfig(requestConfig)
.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(httpClient);
RestTemplate restTemplate = new RestTemplate();
restTemplate.setRequestFactory(factory);
return restTemplate;
}
/**
* @return {@code {success, message, itemCount?, reloadedAt?}}
* 설정된 모든 포탈 서버의 메뉴 캐시를 리로드한다.
*
* @return {@code {success, message, total, successCount, failCount, itemCount?, reloadedAt?,
* servers:[{url, success, message, itemCount?, reloadedAt?, elapsedMs}]}}
* — {@code success} 는 <b>모든</b> 서버가 성공한 경우에만 true
*/
public Map<String, Object> reload() {
Map<String, Object> result = new LinkedHashMap<>();
String url = resolveUrl();
try {
@SuppressWarnings("unchecked")
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
Map<String, Object> body = response.getBody();
if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) {
result.put("success", true);
result.put("message", "포탈 메뉴 캐시 리로드 완료");
result.put("itemCount", body.get("itemCount"));
result.put("reloadedAt", body.get("reloadedAt"));
} else {
result.put("success", false);
result.put("message", "포탈 응답 오류: " + (body == null ? response.getStatusCode() : body));
List<String> urls = resolveUrls();
List<Map<String, Object>> servers = callAll(urls);
int successCount = 0;
for (Map<String, Object> server : servers) {
if (Boolean.TRUE.equals(server.get("success"))) {
successCount++;
}
}
int failCount = servers.size() - successCount;
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", failCount == 0);
result.put("message", summarize(servers.size(), successCount, failCount));
result.put("total", servers.size());
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("servers", servers);
// 단일 서버 시절 응답과의 호환 — 첫 성공 서버의 값을 최상위에도 노출
for (Map<String, Object> server : servers) {
if (Boolean.TRUE.equals(server.get("success"))) {
result.put("itemCount", server.get("itemCount"));
result.put("reloadedAt", server.get("reloadedAt"));
break;
}
} catch (Exception e) {
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e);
result.put("success", false);
result.put("message", "포탈 호출 실패(" + url + "): " + e.getMessage());
}
return result;
}
private String resolveUrl() {
private String summarize(int total, int successCount, int failCount) {
if (total == 0) {
return "리로드 대상 URL 이 설정되지 않았습니다 (" + PROP_GROUP + " / " + PROP_RELOAD_URL + ")";
}
if (failCount == 0) {
return "포탈 메뉴 캐시 리로드 완료 (" + successCount + "/" + total + " 서버)";
}
if (successCount == 0) {
return "포탈 메뉴 캐시 리로드 실패 (0/" + total + " 서버)";
}
return "포탈 메뉴 캐시 리로드 일부 실패 (" + successCount + "/" + total + " 서버 성공)";
}
/** 모든 URL 로 병렬 호출. 반환 순서는 {@code urls} 순서와 동일하다. */
private List<Map<String, Object>> callAll(List<String> urls) {
if (urls.isEmpty()) {
return Collections.emptyList();
}
if (urls.size() == 1) {
return Collections.singletonList(callOne(urls.get(0)));
}
List<Callable<Map<String, Object>>> tasks = new ArrayList<>(urls.size());
for (final String url : urls) {
tasks.add(new Callable<Map<String, Object>>() {
@Override
public Map<String, Object> call() {
return callOne(url);
}
});
}
ExecutorService executor = Executors.newFixedThreadPool(Math.min(urls.size(), MAX_PARALLELISM));
try {
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_RELOAD_URL,
DEFAULT_RELOAD_URL, "eapim-portal 메뉴 캐시 리로드 내부 URL");
} catch (Exception e) {
logger.warn("리로드 URL 조회 실패 - 기본값 사용: " + DEFAULT_RELOAD_URL, e);
return DEFAULT_RELOAD_URL;
List<Future<Map<String, Object>>> futures =
executor.invokeAll(tasks, TIMEOUT_MS + OVERALL_SLACK_MS, TimeUnit.MILLISECONDS);
List<Map<String, Object>> results = new ArrayList<>(urls.size());
for (int i = 0; i < futures.size(); i++) {
String url = urls.get(i);
Future<Map<String, Object>> future = futures.get(i);
try {
results.add(future.get());
} catch (CancellationException e) {
logger.warn("포탈 메뉴 캐시 리로드 타임아웃 - url: " + url);
results.add(failure(url, "응답 시간 초과(" + TIMEOUT_MS + "ms)", TIMEOUT_MS));
} catch (ExecutionException e) {
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e.getCause());
results.add(failure(url, message(e.getCause()), 0L));
}
}
return results;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("포탈 메뉴 캐시 리로드 중단됨", e);
List<Map<String, Object>> results = new ArrayList<>(urls.size());
for (String url : urls) {
results.add(failure(url, "호출이 중단되었습니다", 0L));
}
return results;
} finally {
executor.shutdownNow();
}
}
/** 서버 1대 호출 — 예외를 전파하지 않고 실패 Map 으로 감싼다. */
private Map<String, Object> callOne(String url) {
long startedAt = System.currentTimeMillis();
try {
@SuppressWarnings("unchecked")
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class);
long elapsed = System.currentTimeMillis() - startedAt;
@SuppressWarnings("unchecked")
Map<String, Object> body = response.getBody();
if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) {
Map<String, Object> server = new LinkedHashMap<>();
server.put("url", url);
server.put("success", true);
server.put("message", "리로드 완료");
server.put("itemCount", body.get("itemCount"));
server.put("reloadedAt", body.get("reloadedAt"));
server.put("elapsedMs", elapsed);
return server;
}
logger.warn("포탈 메뉴 캐시 리로드 응답 오류 - url: " + url + ", status: " + response.getStatusCode()
+ ", body: " + body);
return failure(url, "포탈 응답 오류: " + (body == null ? response.getStatusCode() : body), elapsed);
} catch (Exception e) {
long elapsed = System.currentTimeMillis() - startedAt;
logger.error("포탈 메뉴 캐시 리로드 실패 - url: " + url, e);
return failure(url, message(e), elapsed);
}
}
private Map<String, Object> failure(String url, String message, long elapsedMs) {
Map<String, Object> server = new LinkedHashMap<>();
server.put("url", url);
server.put("success", false);
server.put("message", message);
server.put("elapsedMs", elapsedMs);
return server;
}
private String message(Throwable t) {
if (t == null) {
return "알 수 없는 오류";
}
String msg = t.getMessage();
return (msg == null || msg.trim().isEmpty()) ? t.getClass().getSimpleName() : msg;
}
private List<String> resolveUrls() {
String raw;
try {
raw = portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_RELOAD_URL,
DEFAULT_RELOAD_URL, PROP_DESCRIPTION);
} catch (Exception e) {
logger.warn("리로드 URL 조회 실패 - 기본값 사용: " + DEFAULT_RELOAD_URL, e);
raw = DEFAULT_RELOAD_URL;
}
List<String> urls = parseUrls(raw);
return urls.isEmpty() ? parseUrls(DEFAULT_RELOAD_URL) : urls;
}
/**
* 콤마/세미콜론/줄바꿈으로 구분된 URL 목록을 파싱한다.
* {@code #} 로 시작하는 줄은 주석으로 무시하고, 중복 URL 은 최초 순서만 남긴다.
*/
static List<String> parseUrls(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return Collections.emptyList();
}
Set<String> urls = new LinkedHashSet<>();
for (String token : raw.split("[,;\\r\\n]")) {
String url = token.trim();
if (url.isEmpty() || url.startsWith("#")) {
continue;
}
urls.add(url);
}
return new ArrayList<>(urls);
}
}