From 4c7e677dec408f882b368f9ef21eee2701f97f67 Mon Sep 17 00:00:00 2001 From: infraurp Date: Thu, 27 Aug 2026 17:57:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=91=EC=86=8D=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B4=80=EA=B8=B0=EA=B0=84=EC=9D=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20(SF?= =?UTF-8?q?R-004=20/=20=EC=A0=9C=EC=95=88=EC=84=9C=20slide15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제안요약서 slide15 현행진단이 "보관 기간 — 2년 이상, 기본값 730일, 관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이다. 파기 배치가 자바 상수 ACCESS_LOG_RETENTION_DAYS=730 을 써서 관리자가 바꿀 수 없었다. · ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY 신설(기본 730) 이 표에 이미 LOGIN_HIST_KEEP_DAY 가 있어 사실상 보관·파기 정책의 단일 창구다. 표를 새로 만들면 관리자가 보관기간을 두 화면에서 나눠 보게 된다. · 법정 하한 730일을 세 겹으로 강제 화면 JS / 서버 validatePolicy / 파기 배치. 화면만 막으면 요청을 직접 만드는 경로가, 저장만 막으면 정책 행을 직접 고치는 경로가 남는다. 배치는 정책값이 730 미만이어도 730일치를 남긴다 — 한 번 지운 접속기록은 되돌릴 수 없어 설정 실수로 법정 보관기간을 깨는 쪽보다 더 남기는 쪽으로 기운다. DB CHECK 를 쓰지 않은 이유는 MariaDB/MySQL 5.7 에서 동작이 갈려 조용히 통과할 수 있어서다. · 접속기록 조회 화면의 "보관기간 N일" 안내도 정책값을 읽는다 파기 배치가 실제로 쓰는 값과 달라지면 안내가 거짓말이 된다. 정책 조회 실패 시 양쪽 모두 기본값으로 물러난다(안내 때문에 목록이 막히면 안 된다). 검증: mvn compile EXIT=0, JSP Jasper 오류 0건, DDL 적용 후 ACCESS_LOG_KEEP_DAY=730 확인, 재기동 정상, 정책화면·접속기록 302→로그인. Co-Authored-By: Claude Opus 5 --- .../privacy/account/AccountMngController.java | 8 +++ .../privacy/account/AccountMngPolicyVO.java | 16 +++++ .../PersonalDataAccessLogController.java | 25 +++++++- .../batch/PrivacyBatchServiceImpl.java | 41 ++++++++++++- .../privacy/PrivacyAccountMng_SQL_Mysql.xml | 2 + .../privacy/PrivacyAccountMng_SQL_Oracle.xml | 2 + .../jsp/site/mnt/sec/accountPolicy/policy.jsp | 21 +++++++ .../jsp/site/mnt/sec/mberCrypto/list.jsp | 17 ++++-- ...acy.mber_nm_dead_views_backup_20260810.sql | 17 ++++++ .../privacy.access_log_keep_day_add_mysql.sql | 59 +++++++++++++++++++ ...rivacy.mber_nm_view_hash_cutover_mysql.sql | 15 +++++ src/script/mysql/privacy.install_mysql.sql | 55 ++++++++++++++++- 12 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 src/script/mysql/ddl/privacy.access_log_keep_day_add_mysql.sql diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java index 1284949..d7c59dc 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java @@ -107,6 +107,14 @@ public class AccountMngController { if (p.getLoginHistKeepDay() < 1 || p.getLoginHistKeepDay() > 3650) { return "인증 실패 이력 보관기간은 1~3650일 범위로 입력하세요."; } + // [개인정보/SFR-004] 접속기록 보관기간. 법정 하한(2년)은 서버에서도 거절한다 — + // 화면 검증만 두면 요청을 직접 만드는 경로로 우회된다. + if (p.getAccessLogKeepDay() < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY + || p.getAccessLogKeepDay() > 3650) { + return "접속기록 보관기간은 " + AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY + + "~3650일 범위로 입력하세요. 최소 2년은 개인정보의 안전성 확보조치 기준 제8조가" + + " 요구하는 법정 하한이라 더 짧게 설정할 수 없습니다."; + } // #12 파기 배치 사용여부 — 체크박스 미전송 시 null 이 오므로 'N' 으로 보정 if (p.getPurgeUseYn() == null || p.getPurgeUseYn().trim().length() == 0) { p.setPurgeUseYn("N"); diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java index e21faa9..a294131 100644 --- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngPolicyVO.java @@ -21,6 +21,9 @@ public class AccountMngPolicyVO implements Serializable { /** 휴면 미설정 시 기준일수 fallback(#9). */ public static final int DEFAULT_DORMANT_DAY = 365; + /** 접속기록 보관기간 법정 하한(일) — 2년. SFR-004 "최소 2년 이상 보관". */ + public static final int MIN_ACCESS_LOG_KEEP_DAY = 730; + private int policyId = 1; /** 휴면 전환 기준 미접속 일수(#9). */ private int dormantDay = DEFAULT_DORMANT_DAY; @@ -44,6 +47,16 @@ public class AccountMngPolicyVO implements Serializable { * 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기한다. */ private int loginHistKeepDay = 730; + + /** + * 개인정보 접속기록 보관기간(일). 제안서 slide15 "보관 기간 — 관리자 설정값 미존재 → 신규 구축". + * + *

법정 하한 {@link #MIN_ACCESS_LOG_KEEP_DAY}일. 제안요청서 SFR-004 는 + * "접속기록은 최소 2년 이상 보관 가능하여야 한다"고 못박고 있다. 관리자가 임의로 더 짧게 + * 줄이면 요건 위반이 되므로 화면과 파기 배치 양쪽에서 하한을 강제한다 — + * 화면만 막으면 정책 행을 직접 고치는 경로가 남는다.

+ */ + private int accessLogKeepDay = MIN_ACCESS_LOG_KEEP_DAY; /** 사전알림 관리자 수신 이메일. */ private String adminEmail; private String updId; @@ -65,6 +78,9 @@ public class AccountMngPolicyVO implements Serializable { public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; } public String getPurgeUseYn() { return purgeUseYn; } public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; } + public int getAccessLogKeepDay() { return accessLogKeepDay; } + public void setAccessLogKeepDay(int accessLogKeepDay) { this.accessLogKeepDay = accessLogKeepDay; } + public int getLoginHistKeepDay() { return loginHistKeepDay; } public void setLoginHistKeepDay(int loginHistKeepDay) { this.loginHistKeepDay = loginHistKeepDay; } public String getAdminEmail() { return adminEmail; } diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java index 3300b85..fc16433 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java @@ -71,7 +71,7 @@ public class PersonalDataAccessLogController { private static final int OFF_HOURS_START = 9; /** 업무시간 종료(시). */ private static final int OFF_HOURS_END = 18; - /** 화면 안내에 표기할 접속기록 보관기간(일). PrivacyBatchServiceImpl 의 파기 기준과 같은 값. */ + /** 정책 조회 실패 시 화면 안내에 쓸 접속기록 보관기간 기본값(일). */ private static final int ACCESS_LOG_RETENTION_DAYS = 730; @Resource(name = "personalDataAccessLogSearchService") @@ -86,6 +86,27 @@ public class PersonalDataAccessLogController { @Resource(name = "accessLogDownloadJobService") private AccessLogDownloadJobService accessLogDownloadJobService; + /** 보관기간 안내 문구용 정책 조회(SFR-004 관리자 설정). */ + @Resource(name = "accountMngDAO") + private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO; + + /** + * 화면 안내에 쓸 접속기록 보관기간. 관리자 설정값을 그대로 보여준다 + * (파기 배치가 실제로 쓰는 값과 같아야 안내가 거짓말이 되지 않는다). + * 조회 실패 시 기본값으로 물러난다 — 안내 문구 때문에 목록이 막히면 안 된다. + */ + private int resolveRetentionDays() { + try { + egovframework.com.cmm.privacy.account.AccountMngPolicyVO policy = accountMngDAO.selectPolicy(); + if (policy != null && policy.getAccessLogKeepDay() > 0) { + return policy.getAccessLogKeepDay(); + } + } catch (Exception e) { + // 무시 — 기본값 사용 + } + return ACCESS_LOG_RETENTION_DAYS; + } + /** 감사 표시용 이상징후 탐지(#35). 목록 렌더 시 검색기간 기준으로 대조한다. */ @Resource(name = "abnormalAccessDetectionService") private AbnormalAccessDetectionService abnormalAccessDetectionService; @@ -130,7 +151,7 @@ public class PersonalDataAccessLogController { model.addAttribute("dmlSummary", selectDmlSummary(searchVO)); // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기 model.addAttribute("dlJobList", selectMyDownloadJobs()); - model.addAttribute("retentionDays", Integer.valueOf(ACCESS_LOG_RETENTION_DAYS)); + model.addAttribute("retentionDays", Integer.valueOf(resolveRetentionDays())); // 시안의 "감사" 칸 — 목록 각 행에 이상/점검 표시를 채우고 기간 전체 탐지 요약을 함께 싣는다 markAudit(list, searchVO, model); return "site/mnt/sec/accessLog/list"; diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java index 3e70717..734b0db 100644 --- a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java @@ -16,6 +16,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.stereotype.Service; +import egovframework.com.cmm.privacy.account.AccountMngPolicyVO; import egovframework.com.cmm.privacy.auditlog.AbnormalAccessDetectionService; import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO; import egovframework.com.cmm.privacy.auditlog.HashChainService; @@ -46,6 +47,35 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { */ private static final int ACCESS_LOG_RETENTION_DAYS = 730; + /** + * 접속기록 보관기간을 정책({@code ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY})에서 읽는다. + * + *

제안서 slide15 가 "관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이라 상수 대신 + * 정책값을 쓴다. 다만 법정 하한(2년)은 정책값보다 우선한다 — 정책 행을 직접 고쳐 + * 730 미만으로 낮추더라도 파기 배치는 2년치를 남긴다. 한 번 지운 접속기록은 되돌릴 수 + * 없으므로, 설정 실수로 법정 보관기간을 깨는 쪽보다 더 오래 남기는 쪽으로 기운다.

+ * + *

정책 조회가 실패하면 상수 기본값을 쓴다(배치가 멈추면 파티션이 무한정 쌓인다).

+ */ + private int resolveAccessLogRetentionDays() { + int days = ACCESS_LOG_RETENTION_DAYS; + try { + AccountMngPolicyVO policy = accountMngDAO.selectPolicy(); + if (policy != null && policy.getAccessLogKeepDay() > 0) { + days = policy.getAccessLogKeepDay(); + } + } catch (Exception e) { + LOG.warn("[개인정보/SFR-004] 접속기록 보관기간 정책 조회 실패 — 기본값 " + + ACCESS_LOG_RETENTION_DAYS + "일 적용: " + e.toString()); + } + if (days < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY) { + LOG.warn("[개인정보/SFR-004] 설정된 보관기간(" + days + "일)이 법정 하한 미만 — " + + AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY + "일로 보정해 파기한다."); + days = AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY; + } + return days; + } + /** 미래 파티션 선행 확보 개월 수(당월 포함). 적재 시 파티션 누락 방지. */ private static final int PARTITION_MONTHS_AHEAD = 2; @@ -60,6 +90,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { @Resource(name = "accessLogPartitionDAO") private AccessLogPartitionDAO accessLogPartitionDAO; + /** 보관기간 정책 조회용(SFR-004 관리자 설정). */ + @Resource(name = "accountMngDAO") + private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO; + @Resource(name = "inspectionReportDAO") private InspectionReportDAO inspectionReportDAO; @@ -227,7 +261,8 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { ensureFuturePartitions(); // 2) 보관기간(730일) 경과 파티션 파기. 파티션 상한(경계) <= 파기기준일이면 파티션 전체가 만료. - Date cutoff = minusDays(new Date(), ACCESS_LOG_RETENTION_DAYS); + int retentionDays = resolveAccessLogRetentionDays(); + Date cutoff = minusDays(new Date(), retentionDays); List> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE); int dropped = 0; if (partitions != null) { @@ -242,14 +277,14 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { if (!boundary.after(cutoff)) { // boundary <= cutoff → 파티션 전체가 보관기간 경과 accessLogPartitionDAO.dropPartition(name); recordPurge(name, boundaryStr, "DROP", - "보관기간(" + ACCESS_LOG_RETENTION_DAYS + "일) 경과 파티션 파기"); + "보관기간(" + retentionDays + "일) 경과 파티션 파기"); dropped++; LOG.info("[개인정보/#37] 접속기록 파티션 파기 — " + name + " (경계 " + boundaryStr + ")"); } } } LOG.info("[개인정보/#37] 접속기록 파기 배치 완료 — 파티션 " + dropped - + "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + ACCESS_LOG_RETENTION_DAYS + "일)."); + + "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + retentionDays + "일)."); return dropped; } diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml index 25c51af..01e2f2c 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Mysql.xml @@ -39,6 +39,7 @@ BATCH_CYCLE AS batchCycle, PURGE_USE_YN AS purgeUseYn, LOGIN_HIST_KEEP_DAY AS loginHistKeepDay, + ACCESS_LOG_KEEP_DAY AS accessLogKeepDay, ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt @@ -59,6 +60,7 @@ BATCH_CYCLE = #batchCycle#, PURGE_USE_YN = #purgeUseYn#, LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#, + ACCESS_LOG_KEEP_DAY = #accessLogKeepDay#, ADMIN_EMAIL = #adminEmail#, UPD_ID = #updId#, UPD_DT = NOW() diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml index 7f5e187..b82546f 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountMng_SQL_Oracle.xml @@ -23,6 +23,7 @@ DELETE_PRESERVE_DAY AS deletePreserveDay, BATCH_CYCLE AS batchCycle, PURGE_USE_YN AS purgeUseYn, LOGIN_HIST_KEEP_DAY AS loginHistKeepDay, + ACCESS_LOG_KEEP_DAY AS accessLogKeepDay, ADMIN_EMAIL AS adminEmail, UPD_ID AS updId, UPD_DT AS updDt FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1 ]]> @@ -36,6 +37,7 @@ ALERT_D3 = #alertD3#, DELETE_PRESERVE_DAY = #deletePreserveDay#, BATCH_CYCLE = #batchCycle#, PURGE_USE_YN = #purgeUseYn#, LOGIN_HIST_KEEP_DAY = #loginHistKeepDay#, + ACCESS_LOG_KEEP_DAY = #accessLogKeepDay#, ADMIN_EMAIL = #adminEmail#, UPD_ID = #updId#, UPD_DT = SYSDATE WHERE POLICY_ID = 1 diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp index 525f971..ee0a343 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accountPolicy/policy.jsp @@ -66,6 +66,16 @@ function fn_save() { frm.loginHistKeepDay.focus(); return; } } + /* [개인정보/SFR-004] 접속기록 보관기간 — 법정 하한 730일(2년)은 확인 없이 거절한다. + 인증 실패 이력(위 항목)은 권고라 경고 후 진행할 수 있지만, 접속기록 2년은 + 제안요청서 SFR-004 가 "최소 2년 이상"으로 못박은 요건이라 타협 대상이 아니다. */ + var logKeep = fn_num(frm.accessLogKeepDay.value); + if (logKeep < 730 || logKeep > 3650) { + alert("접속기록 보관기간은 730~3650일 범위로 입력하세요.\n\n" + + "최소 2년(730일)은 개인정보의 안전성 확보조치 기준 제8조 및\n" + + "제안요청서 SFR-004 가 요구하는 법정 하한이라 더 짧게 설정할 수 없습니다."); + frm.accessLogKeepDay.focus(); return; + } var email = $.trim(frm.adminEmail.value); if (email.length > 0 && !/^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/.test(email)) { alert("관리자 수신 이메일 형식이 올바르지 않습니다."); @@ -172,6 +182,17 @@ function fn_reload() { 이력은 [시스템관리 > 권한관리 > 인증 실패 이력] 화면에서 조회합니다. + <%-- [개인정보/SFR-004 · 제안서 slide15] 접속기록 보관기간 관리자 설정 --%> + + * + + + 일 (최소 730 = 2년, 법정 하한). 매월 1일 02:00 배치가 이 기간을 넘긴 + 월 파티션 단위로 접속기록을 파기합니다(개별 레코드 삭제는 사용하지 않습니다).
+ 파기 내역은 [접속기록 조회 및 감사] 화면의 파기이력 버튼에서 확인합니다. + 730일 미만은 저장되지 않으며, 정책 값이 그보다 작아도 배치는 730일치를 남깁니다.
+ + * diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp index 15bf413..53f71b9 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp @@ -63,11 +63,15 @@ function fn_history() { tr.decfail td { background:#fdf3f3; } .reauth { float:left; font-size:12px; color:#556; padding-top:4px; } .reauth strong { color:#4a7ebb; } +/* 검색조건 5개 + 버튼 = 한 줄. 공용 .tbox 폭(비율값)으로는 컨테이너를 넘겨 줄이 접히므로 + 이 화면에서만 px 로 고정한다. 폭을 더 줄여야 하면 input.tbox 의 width 부터 조정할 것. */ #search_wrap .srow { white-space:nowrap; } -#search_wrap .srow + .srow { margin-top:4px; } -#search_wrap .srow input.tbox { width:120px; } -#search_wrap .srow span { padding-left:12px; } +#search_wrap .srow input.tbox { width:88px; } +#search_wrap .srow input.tbox.nm { width:78px; } +#search_wrap .srow select { max-width:80px; } +#search_wrap .srow span { padding-left:9px; } #search_wrap .srow > span:first-child { padding-left:0; } +#search_wrap .srow .btn_search { margin-left:10px; } @@ -88,19 +92,20 @@ tr.decfail td { background:#fdf3f3; }