API 상태 변화 감시 job

This commit is contained in:
eastargh
2026-04-30 17:42:20 +09:00
parent 8bd8558038
commit 5db046eb10
6 changed files with 464 additions and 0 deletions
@@ -0,0 +1,42 @@
package com.eactive.eai.rms.data.ext.djb.apistatus;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import com.eactive.eai.data.entity.AbstractEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true)
@Entity
@Table(name = "API_STATUS")
public class ApiStatus extends AbstractEntity<String> {
@Id
@Column(name = "EAISVCNAME", length = 100)
private String eaisvcname;
@Column(name = "STATUS_CODE", length = 1)
private String statusCode;
@LastModifiedBy // ← EMSAuditorAware가 자동으로 채움
@Column(name = "MOD_USERID", length = 50)
private String modUserid = "SCHEDULER";
@LastModifiedDate // ← save() 시 자동으로 sysdate 채움
@Column(name = "MOD_DATE")
private LocalDateTime modDate;
@Override
public @NonNull String getId() { return eaisvcname; }
}
@@ -0,0 +1,6 @@
package com.eactive.eai.rms.data.ext.djb.apistatus;
public interface ApiStatusEvent {
String getEaisvcname();
String getEvent();
}
@@ -0,0 +1,70 @@
package com.eactive.eai.rms.data.ext.djb.apistatus;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.eactive.eai.data.jpa.BaseRepository;
public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
/**
* API 상태 판단. 정상(N), 점검(C), 지연(D), 장애(E)
*
* 1. 정상/지연/장애 -> 점검중 : CONTROL_START
* 2. 점검 -> 점검X : CONTROL_END
* 3. 정상/지연 -> 장애 : ERROR_START
* 4. 장애 -> 장애X : ERROR_END
* 5. 정상 -> 지연 : DELAY_START
* 6. 지연 -> 지연X : DELAY_END
* 7. 기타 : STAY
*/
@Query(nativeQuery = true, value =
" SELECT EAISVCNAME, EVENT"
+ " FROM ("
+ " SELECT EAISVCNAME"
+ " , CASE WHEN STATUS_CODE != 'C' AND CTRL_YN = 'Y' THEN 'CONTROL_START'"
+ " WHEN STATUS_CODE = 'C' AND CTRL_YN = 'N' THEN 'CONTROL_END'"
+ " WHEN STATUS_CODE IN ('N','D') AND ERR_YN = 'Y' THEN 'ERROR_START'"
+ " WHEN STATUS_CODE = 'E' AND ERR_YN = 'N' THEN 'ERROR_END'"
+ " WHEN STATUS_CODE = 'N' AND DELAY_YN = 'Y' THEN 'DELAY_START'"
+ " WHEN STATUS_CODE = 'D' AND DELAY_YN = 'N' THEN 'DELAY_END'"
+ " ELSE 'STAY' END AS EVENT"
+ " FROM ("
+ " SELECT A.EAISVCNAME"
+ " , NVL(B.STATUS_CODE, '1') AS STATUS_CODE"
+ " , NVL((SELECT 'Y' FROM TSEAITI01"
+ " WHERE (TO_CHAR(SYSDATE,'HH24MI') BETWEEN SUBSTR(EAICTRLDSTICCTNT,16,4) AND SUBSTR(EAICTRLDSTICCTNT,21,4)"
+ " OR SUBSTR(EAICTRLDSTICCTNT,16,9) = '0000|0000')"
+ " AND A.EAISVCNAME = EAICTRLNAME),'N') AS CTRL_YN"
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN (TOTAL - SUCCESS) * 100 / TOTAL > :errorRate THEN 'Y'"
+ " ELSE 'N' END"
+ " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL"
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
+ " FROM API_STATS_MINUTE"
+ " WHERE A.EAISVCNAME = API_NAME"
+ " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:errorRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS ERR_YN"
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"
+ " WHEN RESP_SUM / TOTAL > :delayAvgRespTime THEN 'Y'"
+ " ELSE 'N' END"
+ " FROM (SELECT SUM(SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) AS TOTAL"
+ " , SUM(SUCCESS_CNT) AS SUCCESS"
+ " , SUM((SUCCESS_CNT + TIMEOUT_CNT + SYSTEM_ERR_CNT + BIZ_ERR_CNT) * AVG_RESP_TIME) AS RESP_SUM"
+ " FROM API_STATS_MINUTE"
+ " WHERE A.EAISVCNAME = API_NAME"
+ " AND TO_CHAR(STAT_TIME,'YYYYMMDDHH24MI')"
+ " BETWEEN TO_CHAR(SYSDATE - NUMTODSINTERVAL(:delayRangeMinute,'MINUTE'),'YYYYMMDDHH24MI')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDDHH24MI'))) AS DELAY_YN"
+ " FROM TSEAIHE01 A LEFT OUTER JOIN API_STATUS B ON A.EAISVCNAME = B.EAISVCNAME"
+ " )"
+ " ) WHERE EVENT != 'STAY'")
List<ApiStatusEvent> findApiStatusEvents(
@Param("errorRate") int errorRate,
@Param("errorRangeMinute") int errorRangeMinute,
@Param("delayRangeMinute") int delayRangeMinute,
@Param("delayAvgRespTime") int delayAvgRespTime
);
}
@@ -0,0 +1,61 @@
package com.eactive.eai.rms.data.ext.djb.apistatus;
import java.util.HashMap;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class ApiStatusService {
private static final Logger log = LoggerFactory.getLogger(ApiStatusService.class);
@Autowired
private ApiStatusRepository apiStatusRepository;
public void updateApiStatus(HashMap<String, String> param) {
List<ApiStatusEvent> list = apiStatusRepository.findApiStatusEvents(
Integer.parseInt(param.get("errorRate")),
Integer.parseInt(param.get("errorRangeMinute")),
Integer.parseInt(param.get("delayRangeMinute")),
Integer.parseInt(param.get("delayAvgRespTime"))
);
for (ApiStatusEvent event : list) {
String newStatusCode = resolveStatusCode(event.getEvent());
if (newStatusCode == null) continue;
ApiStatus apiStatus = apiStatusRepository.findById(event.getEaisvcname())
.orElse(new ApiStatus());
apiStatus.setEaisvcname(event.getEaisvcname());
apiStatus.setStatusCode(newStatusCode);
apiStatusRepository.save(apiStatus); // PK 있으면 UPDATE, 없으면 INSERT
log.debug("API 상태 변경: {} {} → {}", event.getEaisvcname(), event.getEvent(), newStatusCode);
//TODO: 내부직원 알림발송 & 제휴사 webhook 발송
}
}
private String resolveStatusCode(String event) {
switch (event) {
case "CONTROL_START": return "C";
case "CONTROL_END": return "N";
case "ERROR_START": return "E";
case "ERROR_END": return "N";
case "DELAY_START": return "D";
case "DELAY_END": return "N";
default:
log.warn("알 수 없는 이벤트: {}", event);
return null;
}
}
}
@@ -0,0 +1,175 @@
package com.eactive.eai.rms.ext.djb.job;
import java.util.HashMap;
import org.apache.commons.lang3.StringUtils;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.SchedulerException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.rms.common.context.MonitoringContext;
import com.eactive.eai.rms.common.datasource.DataSourceContextHolder;
import com.eactive.eai.rms.common.datasource.DataSourceTypeManager;
import com.eactive.eai.rms.common.util.CommonUtil;
import com.eactive.eai.rms.data.ext.djb.apistatus.ApiStatusService;
import com.eactive.eai.rms.onl.common.util.DateUtil;
/**
* Job - Quartz Job
* API_STATS_MINUTE 데이터를 1분마다 조회하여 Api 상태를 판단하여 API_STATUS 테이블을 insert/update 한다.
* & api 상태가 변경된 경우, 알림 테이블에 저장한다
*
* <p>Cron Schedule 권장:</p>
* <ul>
* <li>기본: 0 * * * * ? (매분 1회 실행)</li>
* </ul>
*
* <p>Job 파라미터 (JobDataMap):</p>
* <ul>
* <li>
* <b>api.status.error.range_minute</b>: API 장애기준 판단 시간간격 (단위: 분, 기본값: 1)
* <b>api.status.error.rate</b>: API 장애기준 오류건수 비율(%) (단위: 시간, 기본값: 100)
* <b>api.status.delay.range_minute</b>: API 지연기준 판단 시간간격 (단위: 분, 기본값: 1)
* <b>api.status.delay.avg_resp_time</b>: API 지연기준 평균응답시간 (단위: 밀리세컨드, 기본값: 10000)
* </li>
* </ul>
*
* </pre>
*/
@DisallowConcurrentExecution
public class ApiStatusUpdateJob implements Job {
private static final Logger log = LoggerFactory.getLogger(ApiStatusUpdateJob.class);
/** Job 파라미터 키: API 장애기준 시간간격(분) */
public static final String KEY_API_STATUS_ERROR_RANGE_MINUTE = "api.status.error.range_minute";
/** Job 파라미터 키: API 장애기준 오류건수 비율(%) */
public static final String KEY_API_STATUS_ERROR_RATE = "api.status.error.rate";
/** Job 파라미터 키: API 지연기준 시간간격(분) */
public static final String KEY_API_STATUS_DELAY_RANGE_MINUTE = "api.status.delay.range_minute";
/** Job 파라미터 키: API 지연기준 평균응답시간(ms) */
public static final String KEY_API_STATUS_DELAY_AVG_RESP_TIME = "api.status.delay.avg_resp_time";
public static final String DEFAULT_ERROR_RANGE_MINUTE = "1";
public static final String DEFAULT_ERROR_RATE = "100";
public static final String DEFAULT_DELAY_RANGE_MINUTE = "1";
public static final String DEFAULT_DELAY_AVG_RESP_TIME = "10000";
private transient MonitoringContext monitoringContext;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
log.info("*** START ApiStatusUpdateJob run({})", CommonUtil.getToday("yyyy-MM-dd HH:mm"));
// Job 파라미터 로깅
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
logJobParameters(jobDataMap);
HashMap<String, String> param = this.checkParameters(jobDataMap);
ApplicationContext appContext;
try {
appContext = (ApplicationContext) context.getScheduler().getContext().get("applicationContext");
} catch (SchedulerException e) {
log.error("applicationContext get module error", e);
return;
}
monitoringContext = (MonitoringContext) appContext.getBean("monitoringContext");
ApiStatusService apiStatusUpdateService = appContext.getBean(ApiStatusService.class);
DataSourceContextHolder.setDataSourceType(
DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW));
try {
apiStatusUpdateService.updateApiStatus(param);
} catch (Exception e) {
log.error("ApiStatusUpdateJob execution failed", e);
throw new JobExecutionException(e);
} finally {
DataSourceContextHolder.clearDataSourceType();
}
log.info("*** END ApiStatusUpdateJob run({})", DateUtil.getDateTime("yyyy-MM-dd HH:mm"));
}
/**
* Job 파라미터 로깅
*/
private void logJobParameters(JobDataMap jobDataMap) {
if (jobDataMap == null || jobDataMap.isEmpty()) {
log.debug("Job 파라미터 없음 (기본값 사용)");
return;
}
StringBuilder sb = new StringBuilder("Job 파라미터: ");
for (String key : jobDataMap.getKeys()) {
sb.append(key).append("=").append(jobDataMap.getString(key)).append(", ");
}
log.info(sb.toString());
}
/**
* 집계 범위 시간 파라미터 파싱
*
* @param jobDataMap Job 파라미터 맵
* @return 집계 범위 시간 (파싱 실패 시 기본값 반환)
*/
private HashMap<String,String> checkParameters(JobDataMap jobDataMap) {
HashMap<String, String> param = new HashMap<String, String>();
if (jobDataMap == null) {
param.put("errorRangeMinute", DEFAULT_ERROR_RANGE_MINUTE); //1분동안
param.put("errorRate", DEFAULT_ERROR_RATE); //에러가 100% 발생시 '장애'로 판단
param.put("delayRangeMinute", DEFAULT_DELAY_RANGE_MINUTE); //1분동안
param.put("delayAvgRespTime", DEFAULT_DELAY_AVG_RESP_TIME); //평균 응답속도가 10초 이상이면 '지연'으로 판단
return param;
}
if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_ERROR_RANGE_MINUTE))) {
param.put("errorRangeMinute", DEFAULT_ERROR_RANGE_MINUTE);
} else {
param.put("errorRangeMinute", jobDataMap.getString(KEY_API_STATUS_ERROR_RANGE_MINUTE));
}
if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_ERROR_RATE))) {
param.put("errorRate", DEFAULT_ERROR_RATE);
} else {
param.put("errorRate", jobDataMap.getString(KEY_API_STATUS_ERROR_RATE));
}
if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_DELAY_RANGE_MINUTE))) {
param.put("delayRangeMinute", DEFAULT_DELAY_RANGE_MINUTE);
} else {
param.put("delayRangeMinute", jobDataMap.getString(KEY_API_STATUS_DELAY_RANGE_MINUTE));
}
if (StringUtils.isEmpty(jobDataMap.getString(KEY_API_STATUS_DELAY_AVG_RESP_TIME))) {
param.put("delayAvgRespTime", DEFAULT_DELAY_AVG_RESP_TIME);
} else {
param.put("delayAvgRespTime", jobDataMap.getString(KEY_API_STATUS_DELAY_AVG_RESP_TIME));
}
return param;
}
/**
* 개발 모드 여부 확인
* @return eai.systemmode=D 이면 true
*/
private boolean isDevMode() {
String systemMode = System.getProperty("eai.systemmode", "");
return "D".equalsIgnoreCase(systemMode);
}
}
@@ -0,0 +1,110 @@
package com.eactive.eai.rms.ext.djb.statistics;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService;
import com.eactive.ext.kjb.statistics.mapping.ApiStatsUIMapper;
import com.eactive.ext.kjb.statistics.service.ApiStatsExcelExportService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.eactive.ext.kjb.statistics.ui.ApiStatsUI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ApiUseStatsController {
private final ApiStatsDayService service;
private final ApiStatsUIMapper mapper;
private final ApiStatsExcelExportService excelExportService;
@GetMapping(value = "/onl/djb/statistics/apiUseStatsMan.view")
public String view() {
return "/onl/djb/statistics/apiUseStatsMan";
}
@PostMapping(value = "/onl/djb/statistics/apiUseStatsMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<ApiStatsUI>> selectList(ApiStatsSearch search, Pageable pageable) {
Pageable sortedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(Sort.Order.desc("statTime")));
Page<ApiStatsDay> page = service.selectList(search, sortedPageable);
Page<ApiStatsUI> uiPage = page.map(mapper::toVo);
return ResponseEntity.ok(new GridResponse<>(uiPage));
}
@PostMapping(value = "/onl/djb/statistics/apiStatsDayMan.json", params = "cmd=EXCEL_EXPORT")
public void exportToExcel(ApiStatsSearch search, HttpServletResponse response) throws IOException {
log.info("Excel export started - search: {}", search);
try {
List<ApiStatsDay> dataList = service.selectListForExcel(search);
log.info("Data retrieved - count: {}", dataList.size());
if (dataList.isEmpty()) {
log.warn("No data found for export");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"조회된 데이터가 없습니다.\"}");
response.getWriter().flush();
return;
}
List<ApiStatsUI> uiList = dataList.stream()
.map(mapper::toVo)
.collect(Collectors.toList());
log.info("Data converted to UI list - count: {}", uiList.size());
String fileName = generateFileName(search);
log.info("Generating Excel file: {}", fileName);
excelExportService.exportApiStats(uiList, fileName, response);
log.info("Excel export completed successfully");
} catch (Exception e) {
log.error("Excel export failed - error: {}", e.getMessage(), e);
if (!response.isCommitted()) {
response.reset();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"Excel 파일 생성 중 오류가 발생했습니다: " + e.getMessage() + "\"}");
response.getWriter().flush();
} else {
log.error("Cannot send error response - response already committed");
}
}
}
private String generateFileName(ApiStatsSearch search) {
String timeRange = StringUtils.isNotBlank(search.getSearchStartDateTime())
? search.getSearchStartDateTime()
: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
return "API사용현황_" + timeRange + ".xlsx";
}
}