관리자 알림 — 탐지해 놓고 알리지 않던 것을 메일로 통보 (제안서 slide15)

제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 "관리자 알림".
종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면
아무도 모르는 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.

· PrivacyAdminAlertService + Impl 신설
  - 무결성 위반 탐지 시(해시체인 검증 배치)
  - 이상행위 탐지 시(정기점검 배치, 0건인 달은 보내지 않음)
    매달 "0건" 메일이 오면 곧 아무도 열지 않는다.

· 채널은 메일 — 다만 문서 근거는 없다
  RFP·제안서 어디에도 알림 채널이 명시돼 있지 않다("관리자 알림" 네 글자뿐).
  ACCOUNT_MNG_POLICY.ADMIN_EMAIL 이 이미 있고 휴면 사전알림이 그 주소로
  메일을 보내고 있어 같은 경로를 재사용했다. 운영에서 메일 장애를 한 곳에서
  확인할 수 있어야 한다는 이유도 있다. SMS 는 문자발송모듈 연동규격이
  미확정이라(SFR-003 과 같은 블로커) 붙일 수 없다.
  → 채널 확정은 발주기관 협의 항목으로 남긴다.

· 메일에 개인정보 본문을 담지 않는다
  건수·기간·취급자 계정ID·요약만 넣고 상세는 화면에서 보게 한다.
  메일함이 개인정보 저장소가 되면 안 된다.

· 배치를 막지 않는다
  발송 실패·수신주소 미설정 모두 로그만 남기고 배치는 계속한다. 다만 미설정은
  WARN 으로 구분해 남긴다 — "알림이 안 왔다"와 "보낼 주소가 없다"는 다른 문제다.

검증: mvn compile EXIT=0, 로컬 반영 후 재기동 정상(빈 생성 오류 0건 —
privacyAdminAlertService/sndngMailRegistService/accountMngDAO 배선 확인),
frt/main.do 200. 실제 발송은 월 1회 배치라 이번에 실행하지 못했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-27 18:08:51 +09:00
parent 4c7e677dec
commit bb721fe4fa
3 changed files with 233 additions and 4 deletions

View File

@ -0,0 +1,57 @@
/**
* Class Name : PrivacyAdminAlertService.java
* Description : 개인정보보호 관리자 알림 — 개인정보보호 기능개선 사업(제안서 slide15 "관리자 알림")
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO;
/**
* 개인정보 접속기록 관련 이상 상황을 <b>관리자에게 통보</b>한다.
*
* <p>제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 <b>관리자 알림</b>이다.
* 종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면 아무도 모르는
* 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.</p>
*
* <p><b>수신자</b>는 {@code ACCOUNT_MNG_POLICY.ADMIN_EMAIL}(계정 관리 정책 화면에서 설정)이며,
* 발송은 휴면 사전알림({@code DormantAlertService})이 쓰는 표준 메일 모듈을 그대로 재사용한다.
* 알림 채널을 새로 만들지 않는 이유는, 운영에서 메일 발송 경로가 하나여야 장애를 한 곳에서
* 확인할 수 있기 때문이다.</p>
*
* <p><b>메일에 개인정보 본문을 담지 않는다.</b> 알림에는 건수·취급자 계정ID·기간처럼 조치에
* 필요한 최소 정보만 넣는다. 상세는 관리자가 접속기록 화면에서 확인한다 — 메일함이
* 개인정보 저장소가 되면 안 된다.</p>
*
* <p><b>배치를 막지 않는다.</b> 모든 메서드는 실패해도 예외를 올리지 않는다(발송 실패로
* 파기·검증 배치가 중단되면 본말이 전도된다). 발송 여부와 무관하게 로그는 항상 남긴다.</p>
*/
public interface PrivacyAdminAlertService {
/**
* 접속기록 해시체인 <b>무결성 위반</b> 통보.
*
* @param result 검증 결과(위반이 아니면 아무것도 보내지 않는다)
* @return 발송 건수(0 = 미발송)
*/
int alertIntegrityViolation(HashChainVerifyResultVO result);
/**
* <b>이상행위 탐지</b> 통보(정기점검 배치가 월 1회 호출).
*
* @param periodFrom 집계 시작일시
* @param periodTo 집계 종료일시
* @param summary 탐지 요약 문자열({@code AbnormalAccessDetectionService.summarize})
* @param detected 탐지 합계(0이면 보내지 않는다 — 조용한 달에는 메일도 조용해야 한다)
* @return 발송 건수(0 = 미발송)
*/
int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected);
}

View File

@ -0,0 +1,132 @@
/**
* Class Name : PrivacyAdminAlertServiceImpl.java
* Description : 개인정보보호 관리자 알림 구현 — 개인정보보호 기능개선 사업(제안서 slide15)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import egovframework.com.cmm.privacy.account.AccountMngDAO;
import egovframework.com.cmm.privacy.account.AccountMngPolicyVO;
import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
import egovframework.com.cop.ems.service.SndngMailVO;
/**
* {@link PrivacyAdminAlertService} 구현.
*
* <p>발송 경로는 휴면 사전알림({@code DormantAlertServiceImpl.sendMail})과 동일하다 —
* {@code SndngMailVO} 를 만들어 표준 메일 모듈에 적재하면 발송 배치가 실제 전송을 맡는다.</p>
*/
@Service("privacyAdminAlertService")
public class PrivacyAdminAlertServiceImpl implements PrivacyAdminAlertService {
private static final Logger LOG = LoggerFactory.getLogger(PrivacyAdminAlertServiceImpl.class);
/** 메일 제목 접두 — 관리자 메일함에서 걸러 보기 쉽게 고정한다. */
private static final String SUBJECT_PREFIX = "[문화품앗이/개인정보] ";
/** 발신자 계정 — 기존 알림과 동일. */
private static final String SENDER = "webmaster";
@Resource(name = "accountMngDAO")
private AccountMngDAO accountMngDAO;
@Resource(name = "sndngMailRegistService")
private EgovSndngMailRegistService sndngMailRegistService;
@Override
public int alertIntegrityViolation(HashChainVerifyResultVO result) {
if (result == null || result.isOk()) {
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록의 해시체인 무결성 검증에서 불일치가 발견되었습니다.\n\n");
body.append("- 검증 일시 : ").append(nvl(result.getVerifiedAt())).append('\n');
body.append("- 검증 대상 : ").append(result.getTotalCount()).append("건\n");
body.append("- 최초 불일치 : 기록번호 ").append(result.getMismatchLogId()).append('\n');
body.append('\n');
body.append("접속기록은 각 행이 이전 행의 해시를 물고 있어, 중간의 어떤 행이 바뀌면\n");
body.append("그 뒤가 전부 어긋납니다. 위 기록번호부터가 검증에 실패한 지점입니다.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append("※ 이 메일에는 개인정보 본문이 포함되어 있지 않습니다. 상세는 화면에서 확인하십시오.\n");
return send("접속기록 무결성 위반 탐지", body.toString(), "무결성 위반");
}
@Override
public int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected) {
if (detected <= 0) {
// 조용한 달에는 메일도 조용해야 한다. 매달 "0건" 메일이 오면 곧 아무도 열지 않는다.
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록 정기점검에서 이상행위가 탐지되었습니다.\n\n");
body.append("- 집계 기간 : ").append(nvl(periodFrom)).append(" ~ ").append(nvl(periodTo)).append('\n');
body.append("- 탐지 합계 : ").append(detected).append("건\n");
body.append("- 탐지 요약 : ").append(nvl(summary)).append('\n');
body.append('\n');
body.append("탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회나 야간작업이\n");
body.append("있을 수 있으므로, 아래 화면에서 원본 기록을 확인한 뒤 판단하십시오.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append(" (목록 오른쪽 '감사' 칸의 이상/점검 표시로 해당 기록을 찾을 수 있습니다)\n");
body.append("※ 이 메일에는 정보주체의 개인정보가 포함되어 있지 않습니다.\n");
return send("접속기록 이상행위 탐지", body.toString(), "이상행위 탐지");
}
/**
* 관리자 메일 적재. 수신자 미설정이면 보내지 않되 <b>로그는 남긴다</b> —
* "알림이 안 왔다"와 "보낼 주소가 없다"를 구분할 수 있어야 한다.
*/
private int send(String subject, String body, String kind) {
String adminEmail = null;
try {
AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null) {
adminEmail = policy.getAdminEmail();
}
} catch (Exception e) {
LOG.warn("[개인정보/알림] 정책 조회 실패 — {} 알림을 보내지 못했습니다: {}", kind, e.toString());
return 0;
}
if (adminEmail == null || adminEmail.trim().length() == 0) {
LOG.warn("[개인정보/알림] 관리자 수신 이메일 미설정(ACCOUNT_MNG_POLICY.ADMIN_EMAIL) —"
+ " {} 알림을 보내지 못했습니다. 계정 관리 정책 화면에서 주소를 등록하십시오.", kind);
return 0;
}
try {
SndngMailVO vo = new SndngMailVO();
vo.setDsptchPerson(SENDER);
vo.setRecptnPerson(adminEmail.trim());
vo.setSj(SUBJECT_PREFIX + subject);
vo.setEmailCn(body);
vo.setAtchFileId("");
sndngMailRegistService.insertSndngMail(vo);
LOG.info("[개인정보/알림] {} 알림 발송 적재 완료 → {}", kind, adminEmail.trim());
return 1;
} catch (Exception e) {
// 발송 실패로 파기·검증 배치가 멈추면 본말이 전도된다.
LOG.error("[개인정보/알림] {} 알림 발송 실패 — 배치는 계속합니다: {}", kind, e.toString());
return 0;
}
}
private String nvl(Object v) {
return v == null ? "-" : String.valueOf(v);
}
}

View File

@ -105,6 +105,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
@Resource(name = "accessLogDownloadHistoryDAO") @Resource(name = "accessLogDownloadHistoryDAO")
private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO;
/** 제안서 slide15 "관리자 알림" — 무결성 위반·이상행위 탐지 통보. */
@Resource(name = "privacyAdminAlertService")
private PrivacyAdminAlertService privacyAdminAlertService;
/** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */ /** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */
private static final int HANDLER_SUMMARY_TOP = 20; private static final int HANDLER_SUMMARY_TOP = 20;
@ -145,12 +149,36 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
} }
/** /**
* 무결성 위반 즉시 관리자 통보. * 무결성 위반 즉시 관리자 통보(제안서 slide15 "관리자 알림").
* TODO: 실제 통보 채널(관리자 메일/SMS/알림함 #49) 연동. 현재는 에러 로그로 남긴다. *
* <p>에러 로그는 그대로 남긴다 — 메일이 막혀 있어도 흔적은 서버에 있어야 한다.
* 실제 통보는 {@link PrivacyAdminAlertService} 가 관리자 메일로 보낸다.</p>
*/ */
private void notifyAdmin(HashChainVerifyResultVO result) { private void notifyAdmin(HashChainVerifyResultVO result) {
LOG.error("[개인정보/#40] 관리자 통보 필요 — 무결성 위반 LOG_ID=" + result.getMismatchLogId() LOG.error("[개인정보/#40] 관리자 통보 — 무결성 위반 LOG_ID=" + result.getMismatchLogId()
+ " (검증일시 " + result.getVerifiedAt() + "). TODO: 메일/SMS/알림함 연동."); + " (검증일시 " + result.getVerifiedAt() + ").");
privacyAdminAlertService.alertIntegrityViolation(result);
}
/**
* 기간 내 이상행위 탐지 합계. 알림 발송 여부를 정하는 값이라 요약 문자열을 파싱하지 않고
* 탐지 결과의 건수를 그대로 더한다(문자열 파싱은 문구가 바뀌면 조용히 어긋난다).
* 집계에 실패하면 0 을 돌려 알림을 보내지 않는다 — 근거 없이 알리지 않는다.
*/
private int countDetected(Map<String, Object> param) {
try {
List<egovframework.com.cmm.privacy.auditlog.AbnormalAccessResultVO> results =
abnormalAccessDetectionService.detect(param.get("fromDt").toString(),
param.get("toDt").toString());
int sum = 0;
for (int i = 0; results != null && i < results.size(); i++) {
sum += results.get(i).getCount();
}
return sum;
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 건수 집계 실패 - 알림 생략: " + e.getMessage());
return 0;
}
} }
@Override @Override
@ -234,6 +262,18 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
report.setDownloadCnt(downloadCnt); report.setDownloadCnt(downloadCnt);
inspectionReportDAO.insertInspectionReport(report); inspectionReportDAO.insertInspectionReport(report);
// [제안서 slide15 "관리자 알림"] 이상행위가 탐지된 달에만 관리자에게 통보한다.
// 리포트를 적재한 뒤에 보내는 이유는, 메일을 받은 관리자가 화면에서 바로 근거를
// 확인할 수 있어야 하기 때문이다(먼저 보내면 리포트가 아직 없을 수 있다).
try {
int detected = countDetected(param);
privacyAdminAlertService.alertAbnormalAccess(
report.getPeriodFrom(), report.getPeriodTo(), abnormalSummary, detected);
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 알림 처리 오류 - 리포트는 정상 생성됨: " + e.getMessage());
}
LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt
+ "), 무결성=" + report.getIntegrityResult() + ", 취급자 " + "), 무결성=" + report.getIntegrityResult() + ", 취급자 "
+ (handlers == null ? 0 : handlers.size()) + "종."); + (handlers == null ? 0 : handlers.size()) + "종.");