자동 장애 탐지 및 공지 처리 개선

- 점검 판정 로직에 KIND='MAINTENANCE' 조건 추가
- 자동 등록모드 OFF/DRAFT/PUBLISH 지원 및 기본값 적용
- 장애/지연 병합 및 자동 공지 후 게시 상태 동기화
This commit is contained in:
Rinjae
2026-08-03 17:03:35 +09:00
parent cd20aeee2a
commit cd8f826ba5
2 changed files with 223 additions and 30 deletions
@@ -32,6 +32,7 @@ import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusInc
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
@@ -39,7 +40,13 @@ import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
* API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다.
*
* <p>탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 초안으로 기록한다.</p>
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 로 기록한다.</p>
*
* <p>자동 등록 여부와 공지 게시 시점은 PTL_PROPERTY
* ({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 로 정한다.
* {@code OFF}면 자동 등록을 하지 않고, {@code DRAFT}(기본)면 관리자가 공지사항 관리에서
* 검수·게시해야 개발자포탈에 노출되며, {@code PUBLISH}면 탐지 즉시 노출된다.
* {@code OFF} 여도 이미 열려있는 장애의 복구 처리(ERROR_END/DELAY_END)는 계속 동작한다.</p>
*
* <p>점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다
* ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정).
@@ -70,6 +77,24 @@ public class ApiStatusDetectionService {
private static final String NOTICE_TYPE_INCIDENT = "3";
private static final int NOTICE_SUBJECT_MAX_LENGTH = 255;
/** 자동 탐지 공지 처리 방식 프로퍼티 그룹 (개발자포탈과 동일 그룹을 쓴다) */
public static final String PROPERTY_GROUP = "Portal";
/** 자동 탐지 장애/지연의 공지 처리 방식 - OFF / DRAFT / PUBLISH */
public static final String KEY_AUTO_INCIDENT_MODE = "djb.apistatus.auto-incident-mode";
/** 자동 등록 안 함. 신규 등록·병합 모두 하지 않는다 (복구 처리는 계속 동작) */
public static final String MODE_OFF = "OFF";
/** 초안만 생성. 관리자가 공지사항 관리에서 검수·게시해야 개발자포탈에 노출된다 */
public static final String MODE_DRAFT = "DRAFT";
/** 초안 생성 후 곧바로 게시. 검수 없이 개발자포탈에 즉시 노출된다 */
public static final String MODE_PUBLISH = "PUBLISH";
/** 오탐이 그대로 고객에게 노출되지 않도록 기본은 검수(DRAFT) */
private static final String DEFAULT_AUTO_INCIDENT_MODE = MODE_DRAFT;
@Autowired
private DjbApistatusIncidentRepository incidentRepository;
@@ -85,6 +110,9 @@ public class ApiStatusDetectionService {
@Autowired
private ApiStatusRepository apiStatusRepository;
@Autowired
private PortalPropertyService portalPropertyService;
/**
* 이벤트 감지
* @param event "CONTROL_START" : 점검시작
@@ -113,10 +141,10 @@ public class ApiStatusDetectionService {
switch (event) {
case EVENT_ERROR_START:
openIncident(event, targets, "에러율 임계 초과", "장애");
openIncident(event, IncidentKind.INCIDENT, targets, "에러율 임계 초과", "장애");
break;
case EVENT_DELAY_START:
openIncident(event, targets, "응답시간 임계 초과", "응답지연");
openIncident(event, IncidentKind.DELAY, targets, "응답시간 임계 초과", "응답지연");
break;
case EVENT_ERROR_END:
case EVENT_DELAY_END:
@@ -133,12 +161,12 @@ public class ApiStatusDetectionService {
}
/**
* 미종결 장애로 남아있는 API 목록 (탐지 Job 폴링용)
* 미종결 장애·지연으로 남아있는 API 목록 (탐지 Job 폴링용)
*/
@Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
public List<String> remainDownApiIds() {
List<DjbApistatusIncident> openIncidents =
incidentRepository.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES);
List<DjbApistatusIncident> openIncidents = incidentRepository
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES);
if (openIncidents.isEmpty()) {
return Collections.emptyList();
}
@@ -157,7 +185,20 @@ public class ApiStatusDetectionService {
// ────────────── 장애 발생 ──────────────
private void openIncident(String event, List<String> apiIds, String summary, String titleKeyword) {
/**
* 신규 이슈 등록. 지연(DELAY)은 공지를 만들지 않고, 장애(INCIDENT)만 공지 초안/게시를 동반한다.
*/
private void openIncident(String event, IncidentKind kind, List<String> apiIds,
String summary, String titleKeyword) {
// 0) OFF 면 신규 등록·병합·타임라인 기록을 모두 건너뛴다.
// 이미 열려있는 이슈의 복구(markRecovered)는 계속 동작해야 하므로 여기서만 막는다.
String mode = resolveAutoIncidentMode();
if (MODE_OFF.equals(mode)) {
log.info("자동 이슈 등록 비활성(OFF) - 기록하지 않음: {} {}", event, apiIds);
return;
}
boolean autoPublish = MODE_PUBLISH.equals(mode);
LocalDateTime now = LocalDateTime.now();
String interfaceId = makeInterfaceId(event, apiIds, now);
@@ -169,31 +210,34 @@ public class ApiStatusDetectionService {
return;
}
// 2) 이미 열려있는 자동 등록 장애가 해당 API 를 포함하면 영향 API 만 병합
// 2) 이미 열려있는 자동 등록 이슈가 해당 API 를 포함하면 영향 API 만 병합
// (관리자가 직접 작성한 장애 공지는 건드리지 않는다)
// 지연으로 열린 건에 장애가 얹히는 경우가 있어 지연·장애를 함께 찾는다.
List<DjbApistatusIncident> openIncidents = incidentRepository
.findOpenByApiIds(IncidentKind.INCIDENT, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
.findOpenByApiIds(IncidentKind.DEGRADING, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
if (!openIncidents.isEmpty()) {
mergeIntoOpenIncident(openIncidents.get(0), event, apiIds, now);
mergeIntoOpenIncident(openIncidents.get(0), event, kind, apiIds, now, autoPublish);
return;
}
// 3) 신규 장애 + 공지 초안 생성
// 3) 신규 이슈 생성. 장애는 공지도 같이 만들고, PUBLISH 면 검수 없이 바로 게시한다.
Map<String, String> apiNames = resolveApiNames(apiIds);
String title = buildTitle(titleKeyword, apiIds, apiNames);
PortalNotice notice = createDraftNotice(title, summary, apiIds, apiNames, now);
PortalNotice notice = kind == IncidentKind.DELAY
? null : createNotice(title, summary, apiIds, apiNames, now, autoPublish);
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(IncidentKind.INCIDENT);
incident.setKind(kind);
incident.setState(IncidentState.INVESTIGATING);
incident.setTitle(title);
incident.setSummary(summary);
incident.setStartedAt(now);
incident.setDetectedBy(DETECTED_BY_AUTO);
incident.setInterfaceId(interfaceId);
incident.setNoticeId(notice.getId());
incident.setDraftYn("Y");
incident.setNoticeId(notice == null ? null : notice.getId());
// 개발자포탈 노출 조건: 공지가 있으면 DRAFT_YN='N' + USE_YN='Y', 없으면(지연) DRAFT_YN='N' 만
incident.setDraftYn(autoPublish ? "N" : "Y");
incident.setFixYn("N");
stampAudit(incident, now);
@@ -201,9 +245,11 @@ public class ApiStatusDetectionService {
try {
saved = incidentRepository.saveAndFlush(incident);
} catch (DataIntegrityViolationException e) {
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 공지 초안 정리 타임라인만 남긴다
log.info("장애 중복 감지 (interfaceId={}) - 기존 장애에 타임라인 추가", interfaceId, e);
portalNoticeService.deleteById(notice.getId());
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 만든 공지를 정리하고 타임라인만 남긴다
log.info("이슈 중복 감지 (interfaceId={}) - 기존 이슈에 타임라인 추가", interfaceId, e);
if (notice != null) {
portalNoticeService.deleteById(notice.getId());
}
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
appendTimeline(existing.getIncidentId(), null,
"재탐지 신호 수신 (" + event + ")", now));
@@ -214,14 +260,62 @@ public class ApiStatusDetectionService {
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now);
log.info("자동 장애 등록: incidentId={}, noticeId={}, apis={}",
saved.getIncidentId(), notice.getId(), apiIds);
log.info("자동 이슈 등록: incidentId={}, kind={}, noticeId={}, mode={}, apis={}",
saved.getIncidentId(), kind, notice == null ? "-" : notice.getId(),
autoPublish ? MODE_PUBLISH : MODE_DRAFT, apiIds);
}
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event,
List<String> apiIds, LocalDateTime now) {
/**
* 자동 탐지 장애/지연의 공지 처리 방식.
*
* <p>PTL_PROPERTY({@code Portal} / {@code djb.apistatus.auto-incident-mode}) 를 읽는다.
* 최초 접근 시 기본값으로 row 가 자동 생성된다.</p>
*
* <ul>
* <li>{@code OFF} - 자동 등록 안 함 (복구 처리는 계속)</li>
* <li>{@code DRAFT}(기본) - 초안만 생성, 관리자 검수 후 게시</li>
* <li>{@code PUBLISH} - 초안 생성 후 즉시 게시</li>
* </ul>
*
* <p>조회 실패나 알 수 없는 값은 검수 쪽(DRAFT)으로 떨어뜨린다 - 오탐이 고객 화면에
* 그대로 나가는 것도, 진짜 장애를 아예 안 남기는 것도 기본 동작으로는 위험하기 때문이다.</p>
*/
private String resolveAutoIncidentMode() {
try {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP, KEY_AUTO_INCIDENT_MODE, DEFAULT_AUTO_INCIDENT_MODE,
"API 상태 자동 탐지 공지 처리 방식 - OFF: 자동 등록 안 함, "
+ "DRAFT: 초안만 생성(관리자 검수 후 게시), PUBLISH: 초안 생성 후 즉시 게시");
String normalized = StringUtils.trimToEmpty(value);
if (MODE_OFF.equalsIgnoreCase(normalized)) {
return MODE_OFF;
}
if (MODE_PUBLISH.equalsIgnoreCase(normalized)) {
return MODE_PUBLISH;
}
if (!MODE_DRAFT.equalsIgnoreCase(normalized)) {
log.warn("알 수 없는 자동 탐지 공지 처리 방식 '{}' - 기본값 {} 적용", value, DEFAULT_AUTO_INCIDENT_MODE);
}
return MODE_DRAFT;
} catch (Exception e) {
log.warn("자동 탐지 공지 처리 방식 조회 실패 - 기본값 {} 적용", DEFAULT_AUTO_INCIDENT_MODE, e);
return DEFAULT_AUTO_INCIDENT_MODE;
}
}
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event, IncidentKind kind,
List<String> apiIds, LocalDateTime now, boolean autoPublish) {
Long incidentId = incident.getIncidentId();
// 지연으로 열린 건에 장애가 얹히면 종류를 올린다 (강등은 하지 않는다)
escalateIfNeeded(incident, event, kind, now, autoPublish);
// DRAFT 로 만들어진 뒤 PUBLISH 로 설정이 바뀐 경우, 이 이슈가 닫힐 때까지 모든 탐지가
// 여기로 병합돼 아무것도 게시되지 않는다. 병합 시점에 게시 상태를 맞춰준다.
if (autoPublish) {
publishIfPending(incident);
}
Set<String> mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream()
.map(DjbApistatusIncidentApi::getApiId)
.collect(Collectors.toCollection(HashSet::new));
@@ -232,14 +326,81 @@ public class ApiStatusDetectionService {
if (added.isEmpty()) {
appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now);
log.debug("기존 장애에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
log.debug("기존 이슈에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
return;
}
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
appendTimeline(incidentId, null,
"영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now);
log.info("기존 장애에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
log.info("기존 이슈에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
}
/**
* 지연(DELAY)으로 열린 이슈에 장애(ERROR_START)가 감지되면 종류를 장애로 올린다.
*
* <p>지연은 공지가 없으므로 격상 시점에 공지를 새로 만들어 붙인다. 반대 방향(장애→지연)은
* 하지 않는다 - 한 번 장애로 인지된 구간을 나중에 완화해서 표기하면 이력이 왜곡된다.</p>
*/
private void escalateIfNeeded(DjbApistatusIncident incident, String event, IncidentKind kind,
LocalDateTime now, boolean autoPublish) {
if (kind != IncidentKind.INCIDENT || incident.getKind() != IncidentKind.DELAY) {
return;
}
List<String> apiIds = incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(DjbApistatusIncidentApi::getApiId)
.collect(Collectors.toList());
Map<String, String> apiNames = resolveApiNames(apiIds);
String title = buildTitle("장애", apiIds, apiNames);
String summary = "에러율 임계 초과";
incident.setKind(IncidentKind.INCIDENT);
incident.setTitle(title);
incident.setSummary(summary);
if (incident.getNoticeId() == null) {
// 이미 노출 중이던(DRAFT_YN='N') 지연이 격상되는데 공지가 미게시로 붙으면
// 보이던 이슈가 사라진다. 그 경우엔 공지도 게시 상태로 만든다.
boolean publishNotice = autoPublish || "N".equals(incident.getDraftYn());
PortalNotice notice = createNotice(title, summary, apiIds, apiNames, now, publishNotice);
incident.setNoticeId(notice.getId());
}
incident.setLastModifiedBy(AUTHOR_SYSTEM);
incident.setLastModifiedDate(now);
incidentRepository.save(incident);
appendTimeline(incident.getIncidentId(), null,
"지연에서 장애로 격상 (" + event + ")", now);
log.info("지연 → 장애 격상: incidentId={}, noticeId={}",
incident.getIncidentId(), incident.getNoticeId());
}
/**
* 아직 초안으로 남아있는 자동 탐지 이슈를 게시 상태로 올린다 (PUBLISH 모드에서만 호출).
*
* <p>대상은 자동 등록(DETECTED_BY='AUTO') 건뿐이다. 관리자가 공지사항 관리에서 손댄 건은
* DETECTED_BY 가 'MANUAL' 로 바뀌므로 여기 오지 않는다.</p>
*
* <p>지연은 연결 공지가 없으므로 DRAFT_YN 만 바꾸면 노출된다.</p>
*/
private void publishIfPending(DjbApistatusIncident incident) {
if (!"Y".equals(incident.getDraftYn())) {
return;
}
incident.setDraftYn("N");
incidentRepository.save(incident);
if (StringUtils.isNotBlank(incident.getNoticeId())) {
portalNoticeService.findById(incident.getNoticeId())
.filter(notice -> !"Y".equals(notice.getUseYn()))
.ifPresent(notice -> {
notice.setUseYn("Y");
portalNoticeService.save(notice);
});
}
log.info("자동 탐지 이슈 뒤늦은 게시: incidentId={}, noticeId={}",
incident.getIncidentId(), incident.getNoticeId());
}
// ────────────── 복구 ──────────────
@@ -254,8 +415,10 @@ public class ApiStatusDetectionService {
return;
}
// 복구는 이벤트 종류를 구분하지 않는다. ERROR_END 하나로 그 API 의 지연 건까지 닫는다
// (상태머신상 E → N 으로 돌아가므로 DELAY_END 가 따로 오지 않는다).
Map<Long, DjbApistatusIncident> openIncidents = incidentRepository
.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES).stream()
.findByKindInAndStateNotInOrderByStartedAtDesc(IncidentKind.DEGRADING, CLOSED_STATES).stream()
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
Map<Long, List<String>> recoveredByIncident = new HashMap<>();
@@ -345,22 +508,49 @@ public class ApiStatusDetectionService {
return "[자동감지] " + first + " API " + titleKeyword;
}
private PortalNotice createDraftNotice(String title, String summary, List<String> apiIds,
Map<String, String> apiNames, LocalDateTime now) {
/** 관리자가 검수 시 채워 넣을 항목 - {제목, 작성 힌트} */
private static final String[][] NOTICE_FILL_IN_SECTIONS = {
{"발생 원인", "예) 백엔드 DB 커넥션 풀 고갈로 응답 지연 발생"},
{"영향 범위", "예) 조회 API 전 구간 응답 실패, 등록/수정은 정상"},
{"조치 내용", "예) 커넥션 풀 증설 및 장애 인스턴스 격리 완료"},
{"예상 복구 시간", "예) 2026-08-03 14:30 (복구 완료 시 실제 시각으로 갱신)"}
};
/**
* 자동 탐지 장애 공지 생성.
*
* <p>본문에는 관리자가 검수하며 채울 항목(발생 원인·영향 범위·조치 내용·예상 복구 시간)을
* 미리 넣어 둔다. 초안이면 작성 힌트를 함께 남기고, 이미 게시되는 경우엔 고객에게
* 그대로 노출되므로 "확인 중입니다" 로 채워 둔다.</p>
*
* @param publish true 면 게시(USE_YN='Y'), false 면 초안(USE_YN='N')
*/
private PortalNotice createNotice(String title, String summary, List<String> apiIds,
Map<String, String> apiNames, LocalDateTime now, boolean publish) {
StringBuilder detail = new StringBuilder();
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
detail.append("<p>영향 API</p><ul>");
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다.");
detail.append(publish ? " 상세 내용은 확인 후 갱신될 수 있습니다." : " 관리자 검수 후 정식 게시됩니다.");
detail.append("</p>");
detail.append("<p><b>영향 API</b></p><ul>");
for (String apiId : apiIds) {
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
}
detail.append("</ul>");
for (String[] section : NOTICE_FILL_IN_SECTIONS) {
detail.append("<p><b>").append(section[0]).append("</b></p>");
// 게시 상태로 나가는 본문에 "작성 필요" 가 그대로 보이면 안 되므로 문구를 나눈다
detail.append(publish
? "<p>확인 중입니다.</p>"
: "<p>[작성 필요] " + section[1] + "</p>");
}
PortalNotice notice = new PortalNotice();
notice.setId(null);
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
notice.setNoticeDetail(detail.toString());
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
notice.setUseYn("N");
notice.setUseYn(publish ? "Y" : "N");
notice.setFixYn("N");
notice.setReadCount(0L);
notice.setInquirerName(AUTHOR_SYSTEM);
@@ -42,8 +42,11 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
+ " FROM ("
+ " SELECT A.EAISVCNAME"
+ " , NVL(B.STATUS_CODE, 'N') AS STATUS_CODE"
// 점검(KIND='MAINTENANCE')만 CTRL_YN 대상이다. KIND 를 안 걸면 관리자가 시작~종료 시각을
// 넣어 등록한 장애 공지까지 '점검중'으로 오판해 CONTROL_START 가 발생한다.
+ " , NVL(( SELECT MAX('Y') FROM EMSAPP.DJB_APISTATUS_INCIDENT_API X"
+ " WHERE EXISTS (SELECT 1 FROM EMSAPP.DJB_APISTATUS_INCIDENT Y WHERE X.INCIDENT_ID = Y.INCIDENT_ID "
+ " AND Y.KIND = 'MAINTENANCE'"
+ " AND SYSTIMESTAMP BETWEEN STARTED_AT AND END_AT) "
+ " AND A.EAISVCNAME = X.API_ID),'N') AS CTRL_YN "
+ " , (SELECT CASE WHEN TOTAL = 0 THEN 'X'"