웹훅 발송 내역

This commit is contained in:
eastargh
2026-07-03 17:15:57 +09:00
parent eca120812c
commit f23cb02b1c
9 changed files with 49 additions and 27 deletions
@@ -11,7 +11,7 @@
response.setHeader("Expires", "0"); response.setHeader("Expires", "0");
MonitoringContext monitoringContext = (MonitoringContext)CommonUtil.getBean(request, "monitoringContext"); MonitoringContext monitoringContext = (MonitoringContext)CommonUtil.getBean(request, "monitoringContext");
String reverseProxyUrl = monitoringContext.getStringProperty("djb.reverse_proxy.url", ""); String reverseProxyUrl = monitoringContext.getStringProperty(MonitoringContext.RMS_WEBHOOK_REVERSE_PROXY_URL, "");
%> %>
<%! <%!
public String getRequiredLabel(String label) { public String getRequiredLabel(String label) {
@@ -89,16 +89,17 @@
mtype: 'POST', mtype: 'POST',
url: url, url: url,
postData: getSearchForJqgrid("cmd", "LIST"), postData: getSearchForJqgrid("cmd", "LIST"),
colNames: ['id', 'No.', '기관명', '이벤트유형', 'Target URL', 'Status', '성공여부', '등록일시', '발송일시'], colNames: ['id', 'No.', '기관명', '이벤트유형', 'Target URL', 'Proxy URL', 'Status', '성공여부', '발송서버', '발송일시'],
colModel: [ colModel: [
{name: 'id', align: 'center', key: true, hidden: true}, {name: 'id', align: 'center', key: true, hidden: true},
{name: 'rowNum', align: 'center', width: 45, sortable: false}, {name: 'rowNum', align: 'center', width: 45, sortable: false},
{name: 'orgName', align: 'left', width: 140}, {name: 'orgName', align: 'left', width: 140},
{name: 'eventType', align: 'center', width: 120, formatter: formatEventType}, {name: 'eventType', align: 'center', width: 120, formatter: formatEventType},
{name: 'targetUrl', align: 'left', width: 300, formatter: formatTargetUrl}, {name: 'targetUrl', align: 'left', width: 300, formatter: formatTargetUrl},
{name: 'proxyUrl', align: 'left', width: 300, formatter: formatTargetUrl},
{name: 'statusCode', align: 'center', width: 60}, {name: 'statusCode', align: 'center', width: 60},
{name: 'success', align: 'center', width: 70, formatter: formatSuccess}, {name: 'success', align: 'center', width: 70, formatter: formatSuccess},
{name: 'createdAt', align: 'center', width: 140}, {name: 'sendBy', align: 'center', width: 100},
{name: 'sentAt', align: 'center', width: 140} {name: 'sentAt', align: 'center', width: 140}
], ],
jsonReader: {repeatitems: false}, jsonReader: {repeatitems: false},
@@ -118,6 +119,7 @@
url2 += '&returnUrl=' + getReturnUrl(); url2 += '&returnUrl=' + getReturnUrl();
url2 += '&menuId=' + '${param.menuId}'; url2 += '&menuId=' + '${param.menuId}';
url2 += '&id=' + id; url2 += '&id=' + id;
url2 += '&' + getSearchUrl();
goNav(url2); goNav(url2);
}, },
loadComplete: function () { loadComplete: function () {
@@ -218,7 +220,7 @@
<tr> <tr>
<th>Target URL</th> <th>Target URL</th>
<td><input type="text" name="searchTargetUrl" autocomplete="off" style="width:95%;"></td> <td><input type="text" name="searchTargetUrl" autocomplete="off" style="width:95%;"></td>
<th>등록기간</th> <th>발송기간</th>
<td> <td>
<input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:40%; border:1px solid #ebebec;"> <input type="text" name="searchStartYYYYMMDD" id="startDatepicker" readonly="readonly" value="" size="10" style="width:40%; border:1px solid #ebebec;">
~ ~
@@ -37,9 +37,10 @@
$('#eventType').text(data.eventTypeName || data.eventType || '') $('#eventType').text(data.eventTypeName || data.eventType || '')
.attr('title', data.eventType || ''); .attr('title', data.eventType || '');
$('#targetUrl').text(data.targetUrl || ''); $('#targetUrl').text(data.targetUrl || '');
$('#proxyUrl').text(data.proxyUrl || '');
$('#statusCode').text(data.statusCode != null ? data.statusCode : ''); $('#statusCode').text(data.statusCode != null ? data.statusCode : '');
$('#retryCount').text(data.retryCount != null ? data.retryCount : ''); $('#retryCount').text(data.retryCount != null ? data.retryCount : '');
$('#createdAt').text(data.createdAt || ''); $('#sendBy').text(data.sendBy || '');
$('#sentAt').text(data.sentAt || ''); $('#sentAt').text(data.sentAt || '');
$('#signature').text(data.signature || ''); $('#signature').text(data.signature || '');
$('#errorMessage').text(data.errorMessage || ''); $('#errorMessage').text(data.errorMessage || '');
@@ -97,7 +98,9 @@
</tr> </tr>
<tr> <tr>
<th>Target URL</th> <th>Target URL</th>
<td colspan="3"><span id="targetUrl"></span></td> <td><span id="targetUrl"></span></td>
<th>Proxy URL</th>
<td><span id="proxyUrl"></span></td>
</tr> </tr>
<tr> <tr>
<th>성공여부</th> <th>성공여부</th>
@@ -106,8 +109,8 @@
<td><span id="retryCount"></span></td> <td><span id="retryCount"></span></td>
</tr> </tr>
<tr> <tr>
<th>등록일시</th> <th>발송서버</th>
<td><span id="createdAt"></span></td> <td><span id="sendBy"></span></td>
<th>발송일시</th> <th>발송일시</th>
<td><span id="sentAt"></span></td> <td><span id="sentAt"></span></td>
</tr> </tr>
@@ -26,9 +26,12 @@ public class WebhookSendLog {
) )
private Long id; private Long id;
@Column(name = "TARGET_URL", nullable = false, length = 500) @Column(name = "TARGET_URL", length = 500)
private String targetUrl; private String targetUrl;
@Column(name = "PROXY_URL", length = 500)
private String proxyUrl;
@Column(name = "EVENT_TYPE", length = 100) @Column(name = "EVENT_TYPE", length = 100)
private String eventType; private String eventType;
@@ -66,6 +69,9 @@ public class WebhookSendLog {
@Column(name = "SENT_AT") @Column(name = "SENT_AT")
private LocalDateTime sentAt; private LocalDateTime sentAt;
@Column(name = "SEND_BY", length = 100)
private String sendBy;
/* Boolean 편의 메서드 */ /* Boolean 편의 메서드 */
public void setSuccess(Boolean success) { public void setSuccess(Boolean success) {
this.success = (success != null && success) ? "Y" : "N"; this.success = (success != null && success) ? "Y" : "N";
@@ -20,6 +20,7 @@ public class WebhookSendLogUI {
/** 모니터링 공통코드(EVENT_TYPE) 코드명. 상세 조회에서만 채워진다(목록은 화면에서 콤보로 변환). */ /** 모니터링 공통코드(EVENT_TYPE) 코드명. 상세 조회에서만 채워진다(목록은 화면에서 콤보로 변환). */
private String eventTypeName; private String eventTypeName;
private String targetUrl; private String targetUrl;
private String proxyUrl;
private String payload; private String payload;
private String signature; private String signature;
@@ -28,6 +29,7 @@ public class WebhookSendLogUI {
private String responseBody; private String responseBody;
private Boolean success; private Boolean success;
private String sendBy;
private String errorMessage; private String errorMessage;
private Integer retryCount; private Integer retryCount;
@@ -17,7 +17,7 @@ public class WebhookSendLogUISearch {
/* 성공여부 (Y/N, 공백=전체) */ /* 성공여부 (Y/N, 공백=전체) */
private String searchSuccess; private String searchSuccess;
/* 등록일시 기간 검색 (yyyyMMdd, 8자리 — userLoginHistoryMan.jsp 기간검색 스타일) */ /* 발송일시 기간 검색 (yyyyMMdd, 8자리 — userLoginHistoryMan.jsp 기간검색 스타일) */
private String searchStartDate; private String searchStartDate;
private String searchEndDate; private String searchEndDate;
} }
@@ -8,6 +8,7 @@ import lombok.Setter;
public class WebhookSendRequest { public class WebhookSendRequest {
private String orgId; private String orgId;
private String targetUrl; private String targetUrl;
private String proxyUrl;
private String eventType; private String eventType;
private String secret; private String secret;
private String reverseProxyPath; private String reverseProxyPath;
@@ -80,10 +80,10 @@ public class WebhookSendLogManService extends BaseService {
predicate.and(log.success.eq(search.getSearchSuccess())); predicate.and(log.success.eq(search.getSearchSuccess()));
} }
if (StringUtils.isNotBlank(search.getSearchStartDate())) { if (StringUtils.isNotBlank(search.getSearchStartDate())) {
predicate.and(log.createdAt.goe(parseYYYYMMDD(search.getSearchStartDate()).atStartOfDay())); predicate.and(log.sentAt.goe(parseYYYYMMDD(search.getSearchStartDate()).atStartOfDay()));
} }
if (StringUtils.isNotBlank(search.getSearchEndDate())) { if (StringUtils.isNotBlank(search.getSearchEndDate())) {
predicate.and(log.createdAt.loe(parseYYYYMMDD(search.getSearchEndDate()).atTime(23, 59, 59))); predicate.and(log.sentAt.loe(parseYYYYMMDD(search.getSearchEndDate()).atTime(23, 59, 59)));
} }
Page<WebhookSendLog> page = webhookSendLogRepository.findAll(predicate, pageable); Page<WebhookSendLog> page = webhookSendLogRepository.findAll(predicate, pageable);
@@ -154,11 +154,13 @@ public class WebhookSendLogManService extends BaseService {
ui.setEventType(e.getEventType()); ui.setEventType(e.getEventType());
ui.setEventTypeName(eventTypeName); ui.setEventTypeName(eventTypeName);
ui.setTargetUrl(e.getTargetUrl()); ui.setTargetUrl(e.getTargetUrl());
ui.setProxyUrl(e.getProxyUrl());
ui.setPayload(e.getPayload()); ui.setPayload(e.getPayload());
ui.setSignature(e.getSignature()); ui.setSignature(e.getSignature());
ui.setStatusCode(e.getStatusCode()); ui.setStatusCode(e.getStatusCode());
ui.setResponseBody(e.getResponseBody()); ui.setResponseBody(e.getResponseBody());
ui.setSuccess(e.getSuccess()); ui.setSuccess(e.getSuccess());
ui.setSendBy(e.getSendBy());
ui.setErrorMessage(e.getErrorMessage()); ui.setErrorMessage(e.getErrorMessage());
ui.setRetryCount(e.getRetryCount()); ui.setRetryCount(e.getRetryCount());
ui.setCreatedAt(fmt(e.getCreatedAt(), FMT_DATETIME)); ui.setCreatedAt(fmt(e.getCreatedAt(), FMT_DATETIME));
@@ -24,6 +24,7 @@ import org.springframework.web.client.RestTemplate;
import com.eactive.apim.portal.portalorg.entity.QPortalOrg; import com.eactive.apim.portal.portalorg.entity.QPortalOrg;
import com.eactive.eai.rms.common.context.MonitoringContext; import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.common.util.ContainerUtil;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReq; import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReq;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReqApi; import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReqApi;
import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReqEvent; import com.eactive.eai.rms.data.entity.onl.djb.webhook.QWebhookReqEvent;
@@ -82,7 +83,8 @@ public class WebhookService {
resultMap.computeIfAbsent(key, k -> { resultMap.computeIfAbsent(key, k -> {
WebhookSendRequest wr = new WebhookSendRequest(); WebhookSendRequest wr = new WebhookSendRequest();
wr.setOrgId(t.get(req.orgId)); wr.setOrgId(t.get(req.orgId));
wr.setTargetUrl(this.convertTargetUrl(reverseProxyUrl, t.get(org.reverseProxyPath), t.get(req.targetUrl))); wr.setTargetUrl(t.get(req.targetUrl));
wr.setProxyUrl(this.getProxyUrl(reverseProxyUrl, t.get(org.reverseProxyPath), t.get(req.targetUrl)));
wr.setSecret(t.get(req.secret)); wr.setSecret(t.get(req.secret));
wr.setEventType(event); wr.setEventType(event);
wr.setData(new ArrayList<String>()); wr.setData(new ArrayList<String>());
@@ -95,7 +97,7 @@ public class WebhookService {
} }
//reverse proxy 서버로 발송하기 위한 url 만든다 //reverse proxy 서버로 발송하기 위한 url 만든다
private String convertTargetUrl(String proxyUrl, String path, String targetUrl) { private String getProxyUrl(String proxyUrl, String path, String targetUrl) {
String base = stripTrailingSlash(proxyUrl); String base = stripTrailingSlash(proxyUrl);
String normalizedPath = stripSlashes(path); String normalizedPath = stripSlashes(path);
// scheme://host[:port] 부분만 제거하고 이후 path/query/fragment는 그대로 유지 // scheme://host[:port] 부분만 제거하고 이후 path/query/fragment는 그대로 유지
@@ -141,6 +143,7 @@ public class WebhookService {
public void send(WebhookSendRequest req) { public void send(WebhookSendRequest req) {
String targetUrl = req.getTargetUrl(); String targetUrl = req.getTargetUrl();
String proxyUrl = req.getProxyUrl();
String secret = req.getSecret(); String secret = req.getSecret();
String eventType = req.getEventType(); String eventType = req.getEventType();
Object data = req.getData(); Object data = req.getData();
@@ -148,7 +151,9 @@ public class WebhookService {
WebhookSendLog sendLog = new WebhookSendLog(); WebhookSendLog sendLog = new WebhookSendLog();
sendLog.setOrgId(req.getOrgId()); sendLog.setOrgId(req.getOrgId());
sendLog.setTargetUrl(targetUrl); sendLog.setTargetUrl(targetUrl);
sendLog.setProxyUrl(proxyUrl);
sendLog.setEventType(eventType); sendLog.setEventType(eventType);
sendLog.setSendBy(ContainerUtil.getInstanceName());
try { try {
// 1. Payload 생성 // 1. Payload 생성
@@ -172,7 +177,7 @@ public class WebhookService {
int retryTime = monitoringContext.getIntProperty(MonitoringContext.RMS_WEBHOOK_RETRY_TIME, DEFAULT_RETRY_TIME); int retryTime = monitoringContext.getIntProperty(MonitoringContext.RMS_WEBHOOK_RETRY_TIME, DEFAULT_RETRY_TIME);
sendLog.setSentAt(LocalDateTime.now()); sendLog.setSentAt(LocalDateTime.now());
HttpEntity<String> httpRequest = new HttpEntity<>(payloadJson, headers); HttpEntity<String> httpRequest = new HttpEntity<>(payloadJson, headers);
ResponseEntity<String> response = sendWithRetry(targetUrl, httpRequest, sendLog, retryCount, retryTime); ResponseEntity<String> response = sendWithRetry(proxyUrl, httpRequest, sendLog, retryCount, retryTime);
// 5. 성공 로그 // 5. 성공 로그
sendLog.setStatusCode(response.getStatusCode().value()); sendLog.setStatusCode(response.getStatusCode().value());
@@ -180,7 +185,7 @@ public class WebhookService {
sendLog.setSuccess(response.getStatusCode().is2xxSuccessful()); sendLog.setSuccess(response.getStatusCode().is2xxSuccessful());
log.info("[Webhook] 발송 성공 - eventType: {}, url: {}, status: {}, retryCount: {}", log.info("[Webhook] 발송 성공 - eventType: {}, url: {}, status: {}, retryCount: {}",
eventType, targetUrl, response.getStatusCode(), sendLog.getRetryCount()); eventType, proxyUrl, response.getStatusCode(), sendLog.getRetryCount());
} catch (HttpClientErrorException | HttpServerErrorException e) { } catch (HttpClientErrorException | HttpServerErrorException e) {
sendLog.setStatusCode(e.getStatusCode().value()); sendLog.setStatusCode(e.getStatusCode().value());
@@ -188,13 +193,13 @@ public class WebhookService {
sendLog.setSuccess(false); sendLog.setSuccess(false);
sendLog.setErrorMessage(e.getMessage()); sendLog.setErrorMessage(e.getMessage());
log.error("[Webhook] HTTP 오류 - eventType: {}, url: {}, status: {}", log.error("[Webhook] HTTP 오류 - eventType: {}, url: {}, status: {}",
eventType, targetUrl, e.getStatusCode()); eventType, proxyUrl, e.getStatusCode());
} catch (Exception e) { } catch (Exception e) {
sendLog.setSuccess(false); sendLog.setSuccess(false);
sendLog.setErrorMessage(e.getMessage()); sendLog.setErrorMessage(e.getMessage());
log.error("[Webhook] 발송 실패 - eventType: {}, url: {}, error: {}", log.error("[Webhook] 발송 실패 - eventType: {}, url: {}, error: {}",
eventType, targetUrl, e.getMessage()); eventType, proxyUrl, e.getMessage());
} }
sendLogRepository.save(sendLog); sendLogRepository.save(sendLog);
@@ -206,24 +211,25 @@ public class WebhookService {
/** /**
* 재시도 포함 HTTP 발송 * 재시도 포함 HTTP 발송
* - 4xx: 클라이언트 오류이므로 즉시 throw (재시도 불필요) * - 4xx: 클라이언트 오류이므로 즉시 throw (재시도 불필요)
* - 5xx / 네트워크 오류: 최대 RETRY_COUNT회까지 exponential backoff 재시도 (1s → 2s → 4s) * - 5xx / 네트워크 오류: 최대 RETRY_COUNT회까지 선형 증가 backoff 재시도 (1s → 2s → 3s → 4s → 5s)
*/ */
private ResponseEntity<String> sendWithRetry(String targetUrl, HttpEntity<String> request, WebhookSendLog sendLog, int retryCount, int retryTime) throws Exception { private ResponseEntity<String> sendWithRetry(String proxyUrl, HttpEntity<String> request, WebhookSendLog sendLog, int retryCount, int retryTime) throws Exception {
Exception lastException = null; Exception lastException = null;
for (int attempt = 0; attempt <= retryCount; attempt++) { for (int attempt = 0; attempt <= retryCount; attempt++) {
try { try {
if (attempt > 0) { if (attempt > 0) {
long delayMs = retryTime * (1L << (attempt - 1)); long delayMs = (long) retryTime * attempt;
log.info("[Webhook] 재시도 {}/{} - url: {}, delay: {}ms", attempt, retryCount, targetUrl, delayMs); log.info("[Webhook] 재시도 {}/{} - url: {}, delay: {}ms", attempt, retryCount, proxyUrl, delayMs);
Thread.sleep(delayMs);
sendLog.setRetryCount(attempt); sendLog.setRetryCount(attempt);
Thread.sleep(delayMs);
} }
return restTemplate.postForEntity(targetUrl, request, String.class); return restTemplate.postForEntity(proxyUrl, request, String.class);
} catch (HttpClientErrorException e) { } catch (HttpClientErrorException e) {
throw e; // 4xx는 재시도 불필요 //throw e; // 4xx는 재시도 불필요
lastException = e; //TODO: 재시도 테스트
} catch (Exception e) { } catch (Exception e) {
lastException = e; lastException = e;
log.warn("[Webhook] 발송 실패 (attempt {}/{}) - url: {}, error: {}", attempt, retryCount, targetUrl, e.getMessage()); log.warn("[Webhook] 발송 실패 (attempt {}/{}) - url: {}, error: {}", attempt, retryCount, proxyUrl, e.getMessage());
} }
} }
throw lastException; throw lastException;