접속기록 보관기간을 관리자 설정으로 (SFR-004 / 제안서 slide15)

제안요약서 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 <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-27 17:57:14 +09:00
parent 660d826a58
commit 4c7e677dec
12 changed files with 266 additions and 12 deletions

View File

@ -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");

View File

@ -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 "보관 기간 — 관리자 설정값 미존재 → 신규 구축".
*
* <p><b>법정 하한 {@link #MIN_ACCESS_LOG_KEEP_DAY}일.</b> 제안요청서 SFR-004 는
* "접속기록은 최소 2년 이상 보관 가능하여야 한다"고 못박고 있다. 관리자가 임의로 더 짧게
* 줄이면 요건 위반이 되므로 화면과 파기 배치 양쪽에서 하한을 강제한다 —
* 화면만 막으면 정책 행을 직접 고치는 경로가 남는다.</p>
*/
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; }

View File

@ -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";

View File

@ -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})에서 읽는다.
*
* <p>제안서 slide15 가 "관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이라 상수 대신
* 정책값을 쓴다. 다만 <b>법정 하한(2년)은 정책값보다 우선</b>한다 — 정책 행을 직접 고쳐
* 730 미만으로 낮추더라도 파기 배치는 2년치를 남긴다. 한 번 지운 접속기록은 되돌릴 수
* 없으므로, 설정 실수로 법정 보관기간을 깨는 쪽보다 더 오래 남기는 쪽으로 기운다.</p>
*
* <p>정책 조회가 실패하면 상수 기본값을 쓴다(배치가 멈추면 파티션이 무한정 쌓인다).</p>
*/
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<Map<String, Object>> 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;
}

View File

@ -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()

View File

@ -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

View File

@ -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() {
이력은 [시스템관리 &gt; 권한관리 &gt; <strong>인증 실패 이력</strong>] 화면에서 조회합니다.</span>
</td>
</tr>
<%-- [개인정보/SFR-004 · 제안서 slide15] 접속기록 보관기간 관리자 설정 --%>
<tr>
<th><label for="accessLogKeepDay">접속기록 보관기간</label> *</th>
<td>
<form:input path="accessLogKeepDay" title="접속기록 보관기간" class="tbox" maxlength="4" cssStyle="width:80px; text-align:right;" />
<span class="unit">일 (<strong>최소 730 = 2년, 법정 하한</strong>). 매월 1일 02:00 배치가 이 기간을 넘긴
<strong>월 파티션 단위로</strong> 접속기록을 파기합니다(개별 레코드 삭제는 사용하지 않습니다).<br />
파기 내역은 [<strong>접속기록 조회 및 감사</strong>] 화면의 <strong>파기이력</strong> 버튼에서 확인합니다.
730일 미만은 저장되지 않으며, 정책 값이 그보다 작아도 배치는 730일치를 남깁니다.</span>
</td>
</tr>
<tr>
<th><label for="batchCycle">배치 주기</label> *</th>
<td>

View File

@ -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; }
</style>
</head>
@ -88,19 +92,20 @@ tr.decfail td { background:#fdf3f3; }
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<%-- 검색조건 5개 + 버튼을 한 줄에 넣는다. 공용 .tbox 폭으로는 컨테이너를 넘겨
줄이 접히므로 이 화면에서만 px 로 좁히고 nowrap 으로 묶는다
(접속기록 조회 화면과 같은 방식 — 공용 CSS 는 건드리지 않는다). --%>
<div class="srow">
<span>회원ID :</span>
<form:input path="mberId" title="회원ID" cssClass="tbox" />
<span>이름(완전일치) :</span>
<form:input path="mberNm" title="이름 완전일치" cssClass="tbox" />
<form:input path="mberNm" title="이름 완전일치" cssClass="tbox nm" />
<span>상태 :</span>
<select name="mberSttus" id="mberSttus" title="회원상태">
<option value="" <c:if test="${empty mberCryptoSearchVO.mberSttus}">selected="selected"</c:if>>전체</option>
<option value="P" <c:if test="${mberCryptoSearchVO.mberSttus eq 'P'}">selected="selected"</c:if>>정상</option>
<option value="D" <c:if test="${mberCryptoSearchVO.mberSttus eq 'D'}">selected="selected"</c:if>>탈퇴</option>
</select>
</div>
<div class="srow">
<span>이메일 :</span>
<form:input path="mberEmailAdres" title="이메일" cssClass="tbox" />
<span>휴대폰 :</span>

View File

@ -3,6 +3,23 @@
-- 복원 방법: 이 파일을 그대로 실행하면 뷰가 원래 정의로 복원된다.
-- 주의: 이 정의들은 여전히 평문 COMTNGNRLMBER.MBER_NM 을 참조한다 — 복원 시점에
-- 평문 컬럼이 이미 삭제된 상태라면 복원도 실패한다.
--
-- ★★ [2026-08-27] 운영에 그대로 실행하면 안 된다 — 대소문자 ★★
-- 이 백업은 **로컬 DB(Windows, lower_case_table_names=1)** 에서 떠서
-- 뷰명·테이블명이 전부 소문자다(`comtnbbsuserinfo`, `comtngnrlmber` ...).
-- 운영은 @@lower_case_table_names = 0 이고 실제 이름은 대문자·혼합 케이스다
-- (`COMTNBBSUSERINFO`, `excelAppconfirmList`, `COMTNGNRLMBER` — 2026-08-27 운영 실측).
-- 그대로 돌리면 ① 참조 테이블을 못 찾아 1146 으로 실패하거나,
-- ② 원래 뷰를 복구하는 게 아니라 **소문자 뷰가 새로 하나 더 생긴다**.
-- → 운영 복원이 필요하면 이 파일을 그대로 쓰지 말고, 운영에서 DROP 직전에
-- 아래로 정의를 떠서 보관한 것을 사용할 것.
--
-- SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.views
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND LOWER(TABLE_NAME) IN ('comtnbbsuserinfo','comvnusermaster2','comvnusermaster_1',
-- 'excelappconfirmlist','excelappconfirmlist_auth',
-- 'selectaddedoptionsinf','selectboardarticlelist',
-- 'selectmiddleappconfirmlist');
-- =====================================================================
-- ===== comtnbbsuserinfo =====

View File

@ -0,0 +1,59 @@
-- =====================================================================
-- 접속기록 보관기간 관리자 설정 : ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / 제안서 slide15)
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [배경] 제안요약서 slide15 "로그 조회 및 감사 기능" 현행진단 표:
-- 구분 "보관 기간" / 소스분석 "2년 이상 — 기본값 730일, 관리자 설정값 미존재"
-- / 개선방안 "신규 구축"
-- 지금까지 파기 배치가 자바 상수(ACCESS_LOG_RETENTION_DAYS = 730)를 써서
-- 관리자가 바꿀 수 없었다. 정책 테이블로 빼 화면에서 설정하게 한다.
--
-- [왜 ACCOUNT_MNG_POLICY 인가] 이 표에는 이미 LOGIN_HIST_KEEP_DAY(인증 실패 이력 보관일)가
-- 들어 있어 사실상 '보관·파기 정책'의 단일 창구다. 표를 새로 만들면 관리자가
-- 보관기간을 두 화면에서 나눠 보게 된다.
--
-- ★ 법정 하한 730일 ★
-- 제안요청서 SFR-004 "접속기록은 최소 2년 이상 보관 가능하여야 한다".
-- 컬럼 기본값을 730 으로 두고, 화면·서비스 양쪽에서 730 미만 입력을 거절한다.
-- DB 제약(CHECK)으로 막지 않는 이유는 MariaDB/MySQL 5.7 에서 CHECK 동작이 갈려
-- "조용히 통과"할 수 있기 때문이다 — 검증은 애플리케이션에서 확실히 한다.
--
-- [재실행 안전] information_schema 확인 후 ALTER — 반복 실행 무해.
-- [무중단] ALGORITHM=INPLACE, LOCK=NONE (단일행 테이블이라 사실상 즉시)
-- =====================================================================
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY()
BEGIN
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
AND COLUMN_NAME = 'ACCESS_LOG_KEEP_DAY';
IF v_cnt = 0 THEN
ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN ACCESS_LOG_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '개인정보 접속기록 보관기간(일). 법정 하한 730(2년)',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
-- 기존 행이 있는데 값이 비정상(2년 미만)이면 법정 하한으로 올린다.
UPDATE ACCOUNT_MNG_POLICY SET ACCESS_LOG_KEEP_DAY = 730
WHERE POLICY_ID = 1 AND (ACCESS_LOG_KEEP_DAY IS NULL OR ACCESS_LOG_KEEP_DAY < 730);
-- ---------------------------------------------------------------------
-- [적용 확인]
-- ---------------------------------------------------------------------
-- SELECT POLICY_ID, ACCESS_LOG_KEEP_DAY, LOGIN_HIST_KEEP_DAY FROM ACCOUNT_MNG_POLICY;
-- ---------------------------------------------------------------------
-- [롤백] 배치가 다시 자바 상수 730 을 쓰게 되므로 동작은 같다.
-- ---------------------------------------------------------------------
-- ALTER TABLE ACCOUNT_MNG_POLICY DROP COLUMN ACCESS_LOG_KEEP_DAY;

View File

@ -1,3 +1,18 @@
-- #####################################################################
-- ★★ [2026-08-27] 이 파일을 운영에 직접 실행하지 말 것 — 정본은 ★★
-- src/script/mysql/privacy.install_mysql.sql 의 PART 4 이다.
--
-- 이 사본은 REPLACE() 검색문자열에 테이블명을 **소문자로 하드코딩**한
-- 2026-08-27 이전 버전이다. 운영 DB 는 객체명이 대문자로 저장되어 있고
-- (@@lower_case_table_names = 0, MySQL 5.7.31 — 2026-08-27 운영 실측),
-- REPLACE() 는 항상 대소문자를 구분하므로 치환이 전부 불발된다.
-- 그런데도 CREATE OR REPLACE VIEW 는 **오류 없이 성공**해서
-- USER_NM_HASH 없는 뉴가 남고, WAS 기동 시에야 터진다.
--
-- install_mysql.sql PART 4 는 information_schema 에서 실제 저장된 케이스를 읽어
-- 검색문자열을 조립하고, 치환이 불발되면 SIGNAL 로 중단한다.
-- #####################################################################
-- 개인정보보호 기능개선 사업 추가제안 Action4 (2026-08-11)
-- comvnusermaster / comvnuserinfo 뷰 재정의: GNR 분기를 암호문(MBER_NM_ENC)으로 전환하고
-- 검색용 해시 컬럼(USER_NM_HASH)을 추가한다.

View File

@ -1,7 +1,7 @@
-- #####################################################################
-- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분)
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
-- 구성 : [PART 1] DDL(테이블 생성·변경, 25개 테이블/인덱스)
-- 구성 : [PART 1] DDL(테이블 생성·변경, 25개 테이블/인덱스 + 정책 컬럼)
-- → [PART 2] DML(기준/시드 데이터)
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
-- COMTNMENUINFO/COMTNMENUCREATDTLS,
@ -59,6 +59,16 @@
-- ⑤ PART 1 죽은 뷰 8개는 DROP 전에 정의를 privacy_view_def_backup 에 백업하도록 했다.
-- (backup/privacy.mber_nm_dead_views_backup_20260810.sql 은 로컬 소문자 캡처라
-- 운영 롤백 자산으로 쓸 수 없다 — 해당 파일에도 경고를 달아 두었다)
-- ⑥ **[치명] PART 4 가 스키마 한정자를 고려하지 않고 있었다.**
-- information_schema.views.VIEW_DEFINITION 은 테이블을 항상 `csv`.`TBL` 로 저장하는데
-- (mysqldump 출력에는 없다), 2026-08-11 통합 시 원본 ddl 파일에 있던 `csv`. 가
-- 누락돼 5개 치환 중 4개가 **어느 환경에서도** 불발되는 상태였다.
-- dev 가 멀쩡한 것은 단독 파일(ddl/privacy.mber_nm_view_hash_cutover_mysql.sql,
-- `csv`. 포함)로 컷오버했기 때문이고, 이 통합본으로는 한 번도 성공한 적이 없다.
-- → 치환 전에 `REPLACE(@def, CONCAT('`', DATABASE(), '`.'), '')` 로 한정자를 제거한다.
-- (CREATE OR REPLACE VIEW 시 MySQL 이 다시 붙이므로 결과물은 동일)
-- 검증: 운영 런타임 형식(대문자+한정자) 재현 후 MySQL 엔진에서 실행 →
-- hash_cnt=4 / uses_enc=1 / plaintext_gone=1.
-- #####################################################################
@ -1264,6 +1274,36 @@ CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY
COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)';
-- ---------------------------------------------------------------------
-- [privacy.access_log_keep_day_add_mysql.sql] 2026-08-27 추가
-- 접속기록 보관기간 관리자 설정 (SFR-004 / 제안서 slide15 "관리자 설정값 미존재 → 신규 구축")
-- · 파기 배치가 쓰던 자바 상수 730 을 정책값으로 뺀다.
-- · 법정 하한 730일(2년) — 화면·서버 검증과 파기 배치 양쪽에서 강제한다.
-- DB CHECK 를 쓰지 않는 이유는 MariaDB/MySQL 5.7 에서 동작이 갈려 조용히 통과할 수 있어서다.
-- ---------------------------------------------------------------------
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY()
BEGIN
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
AND COLUMN_NAME = 'ACCESS_LOG_KEEP_DAY';
IF v_cnt = 0 THEN
ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN ACCESS_LOG_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '개인정보 접속기록 보관기간(일). 법정 하한 730(2년)',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_ACCESS_LOG_KEEP_DAY;
UPDATE ACCOUNT_MNG_POLICY SET ACCESS_LOG_KEEP_DAY = 730
WHERE POLICY_ID = 1 AND (ACCESS_LOG_KEEP_DAY IS NULL OR ACCESS_LOG_KEEP_DAY < 730);
-- =====================================================================
-- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지
-- 수록: ACCOUNT_MNG_POLICY(기본정책 1행)
@ -1629,10 +1669,22 @@ BEGIN
-- 1. comvnusermaster 재정의
-- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컴럼 추가(MBER_NM_HASH)
-- USR/ENT/GRO 분기: USER_NM_HASH 컴럼 추가(NULL - 암호화 대상 아님)
-- **★★ [2026-08-27] 스키마 한정자 제거 ★★**
-- information_schema.views.VIEW_DEFINITION 은 테이블을 항상
-- `csv`.`COMTNGNRLMBER` 처럼 **스키마 한정자를 붙여** 저장한다
-- (mysqldump 출력에는 없어서 덤프로 검증하면 몰라보인다).
-- 그래서 'from `COMTNGNRLMBER` where' 같은 검색문자열은
-- 실제 텍스트 'from `csv`.`COMTNGNRLMBER` where' 와 맞지 않는다.
-- → 치환 전에 한정자를 제거한다. CREATE OR REPLACE VIEW 시
-- MySQL 이 다시 붙이므로 결과물은 동일하다.
SET @qual = CONCAT('`', DATABASE(), '`.');
SET @def_master = (
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnusermaster'
);
SET @def_master = REPLACE(@def_master, @qual, '');
SET @def_master = REPLACE(@def_master,
CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),
@ -1674,6 +1726,7 @@ BEGIN
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnuserinfo'
);
SET @def_info = REPLACE(@def_info, @qual, '');
SET @def_info = REPLACE(@def_info,
CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),