diff --git a/WebContent/jsp/onl/apim/portalmain/portalMainMan.jsp b/WebContent/jsp/onl/apim/portalmain/portalMainMan.jsp index 1181f11..874d29e 100644 --- a/WebContent/jsp/onl/apim/portalmain/portalMainMan.jsp +++ b/WebContent/jsp/onl/apim/portalmain/portalMainMan.jsp @@ -189,7 +189,7 @@
- 예시: #인증, #대출, #예금 + 예시: 인증, 대출, 예금
diff --git a/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp b/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp index b1cc2cc..ce82601 100644 --- a/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp +++ b/WebContent/jsp/onl/apim/portalmenu/portalMenuMan.jsp @@ -327,14 +327,61 @@ }, 'json').fail(ajaxFail); } + // ── 캐시 Reload ── + var reloadModal; + var reloadBtnHtml; + function reloadPortalCache() { + var $btn = $('#btnReloadCache'); + if ($btn.prop('disabled')) return; + if (reloadBtnHtml === undefined) reloadBtnHtml = $btn.html(); + $btn.prop('disabled', true) + .html(' 리로드 중...'); + $.post(url, { cmd: 'TRANSACTION_RELOAD' }, function (result) { - if (result && result.success) { - alert('포탈 메뉴 캐시 리로드 완료 (항목 ' + result.itemCount + '건, ' + result.reloadedAt + ')'); - } else { - alert('캐시 리로드 실패: ' + (result ? result.message : '알 수 없는 오류')); - } - }, 'json').fail(ajaxFail); + showReloadResult(result); + }, 'json').fail(ajaxFail).always(function () { + $btn.prop('disabled', false).html(reloadBtnHtml); + }); + } + + function showReloadResult(result) { + if (!result) { + alert('캐시 리로드 실패: 알 수 없는 오류'); + return; + } + + var servers = result.servers || []; + var $summary = $('#reloadSummary'); + $summary.removeClass('alert-success alert-danger alert-warning') + .addClass(result.success ? 'alert-success' + : (result.successCount > 0 ? 'alert-warning' : 'alert-danger')) + .text(result.message || ''); + + var rows = ''; + servers.forEach(function (s) { + var badge = s.success + ? '성공' + : '실패'; + var detail = s.success + ? ('항목 ' + (s.itemCount == null ? '-' : s.itemCount) + '건' + + (s.reloadedAt ? ' / ' + escapeHtml(String(s.reloadedAt)) : '')) + : escapeHtml(String(s.message || '-')); + rows += '' + + '' + badge + '' + + '' + escapeHtml(String(s.url || '')) + '' + + '' + detail + '' + + '' + (s.elapsedMs == null ? '-' : s.elapsedMs + 'ms') + '' + + ''; + }); + if (rows === '') { + rows = '' + + '호출 대상 서버가 없습니다. 아래 설정 안내를 확인하세요.'; + } + $('#reloadResultBody').html(rows); + + if (!reloadModal) reloadModal = new bootstrap.Modal(document.getElementById('reloadResultModal')); + reloadModal.show(); } function ajaxFail(xhr) { @@ -453,7 +500,7 @@ - @@ -463,6 +510,12 @@ 드래그로 위치를 조절한 뒤 저장 버튼으로 일괄 반영합니다. 기본 항목([기본] 배지)은 [-] 시 미배치로만 이동하고, 커스텀 항목은 미배치 시 삭제됩니다. 저장 후 캐시 Reload 를 실행해야 포탈에 즉시 반영됩니다(미실행 시 TTL 경과 후 반영). +
+ 캐시 Reload 대상 서버는 포탈 프로퍼티 Portal / portal.internal.menu-reload-url 로 설정합니다. + 포탈이 다중 서버(이중화) 로 기동된 경우 모든 서버의 리로드 URL 을 + 콤마(,) 또는 줄바꿈 으로 나열해야 전체 서버에 반영됩니다 + (예: http://10.0.0.11:39130/internal/menu/reload, http://10.0.0.12:39130/internal/menu/reload). + 서버별 요청 타임아웃은 3초 이며, 실행 결과는 서버별로 표시됩니다. + + + diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java index e36132c..c0565d4 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalmenu/PortalMenuCacheClient.java @@ -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 내부 호출). * *

호출 URL 은 PTL_PROPERTY {@code Portal / portal.internal.menu-reload-url} 로 관리한다 * (기본 {@code http://127.0.0.1:39130/internal/menu/reload}). + * 포탈이 다중 인스턴스로 기동되는 환경을 위해 콤마/세미콜론/줄바꿈 으로 여러 URL 을 나열할 수 있으며, + * 나열된 모든 서버로 병렬 호출한다. 각 호출의 connect/read 타임아웃은 {@value #TIMEOUT_MS}ms 이다. * portal 측은 {@code menu.internal.allow-ips} 허용 IP 목록으로 요청을 검증한다. - * 오류는 결과 Map 으로 감싸 화면에 사유를 표시한다 — 예외를 전파하지 않는다.

+ * 오류는 결과 Map 으로 감싸 화면에 서버별 사유를 표시한다 — 예외를 전파하지 않는다.

*/ @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} 는 모든 서버가 성공한 경우에만 true */ public Map reload() { - Map result = new LinkedHashMap<>(); - String url = resolveUrl(); - try { - @SuppressWarnings("unchecked") - ResponseEntity response = restTemplate.postForEntity(url, null, Map.class); - Map 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 urls = resolveUrls(); + List> servers = callAll(urls); + + int successCount = 0; + for (Map server : servers) { + if (Boolean.TRUE.equals(server.get("success"))) { + successCount++; + } + } + int failCount = servers.size() - successCount; + + Map 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 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> callAll(List urls) { + if (urls.isEmpty()) { + return Collections.emptyList(); + } + if (urls.size() == 1) { + return Collections.singletonList(callOne(urls.get(0))); + } + + List>> tasks = new ArrayList<>(urls.size()); + for (final String url : urls) { + tasks.add(new Callable>() { + @Override + public Map 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>> futures = + executor.invokeAll(tasks, TIMEOUT_MS + OVERALL_SLACK_MS, TimeUnit.MILLISECONDS); + + List> results = new ArrayList<>(urls.size()); + for (int i = 0; i < futures.size(); i++) { + String url = urls.get(i); + Future> 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> results = new ArrayList<>(urls.size()); + for (String url : urls) { + results.add(failure(url, "호출이 중단되었습니다", 0L)); + } + return results; + } finally { + executor.shutdownNow(); } } + + /** 서버 1대 호출 — 예외를 전파하지 않고 실패 Map 으로 감싼다. */ + private Map callOne(String url) { + long startedAt = System.currentTimeMillis(); + try { + @SuppressWarnings("unchecked") + ResponseEntity response = restTemplate.postForEntity(url, null, Map.class); + long elapsed = System.currentTimeMillis() - startedAt; + + @SuppressWarnings("unchecked") + Map body = response.getBody(); + if (response.getStatusCode().is2xxSuccessful() && body != null && "OK".equals(body.get("result"))) { + Map 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 failure(String url, String message, long elapsedMs) { + Map 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 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 urls = parseUrls(raw); + return urls.isEmpty() ? parseUrls(DEFAULT_RELOAD_URL) : urls; + } + + /** + * 콤마/세미콜론/줄바꿈으로 구분된 URL 목록을 파싱한다. + * {@code #} 로 시작하는 줄은 주석으로 무시하고, 중복 URL 은 최초 순서만 남긴다. + */ + static List parseUrls(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return Collections.emptyList(); + } + Set 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); + } }