개인정보보호 기능개선 9차 — 관리자 화면 배치를 제안서 Ⅲ-4 시안대로 재편

제안요약서 slide 14~15 의 화면 시안을 열어보니 8차 구현과 어긋나 있었다.
시안은 좌측메뉴를 "접속기록·감사" 한 묶음으로 그리고 그 아래 4개를 두며,
이상행위를 별도 화면이 아니라 접속기록 목록의 "감사" 칸에 인라인으로 표시한다.
8차는 이 넷을 권한관리 하위에 낱개로 붙여 놓았다 — 기능은 같은데 배치가 달랐다.

· 메뉴 재편 — "접속기록 · 감사"(9100052) 그룹 신설
  ① 접속기록 조회(9100044) ② 개인정보 처리기록(9100053, 신설)
  ③ 이상행위 감사(9100049)  ④ 정기 점검 리포트(9100048)
  파기이력(9100050)은 시안에 메뉴가 없어 내렸다. 화면은 그대로 두고
  정기 점검 리포트에서 버튼으로 연다(양방향 링크). 기능을 지운 게 아니라
  노출 위치만 시안에 맞춘 것이다.
  그룹 메뉴의 PROGRM_FILE_NM 은 NOT NULL 이고 기존 그룹이 모두 센티넬 'dir'
  을 쓴다 — NULL 로 넣었다가 ERROR 1048 로 실패한 뒤 형제 행을 실측해 정정.

· 접속기록 조회 화면에 "감사" 칸 신설
  이상(처리결과 F=차단·비인가)은 붉게, 점검(정상이나 대량조회·계정공유의심·
  인증실패반복 대상 또는 업무시간 밖)은 노랗게 표시하고 해당 행을 강조한다.
  요약 줄도 시안대로 "검색결과 N건 · 보관기간 730일 · 이상행위 N건 탐지".
  판정값(auditFlag)은 저장하지 않는다 — 탐지 임계치는 정책값이라 적재해 두면
  임계치를 조정한 뒤에도 과거 기준으로 굳은 값이 남는다.
  감사 표시는 집계 5회라 기간 미지정 시 최근 30일로 잘라 계산하고,
  실패해도 목록은 그대로 보여준다(PSR-001).

· ② 개인정보 처리기록 — 조회(S)를 뺀 등록·수정·삭제만
  화면·검색조건은 ①과 공유하고 excludeSelect 조건 하나만 다르다.
  같은 표를 두 벌 관리하지 않으려는 선택이다.

검증: mvn compile EXIT=0, JSP 4종 Jasper 오류 0건, 로컬 ROOT 반영 후 재기동
정상(25.4초), 신규 processLog 포함 5개 엔드포인트 302→로그인 / 대조군 404,
통합본 privacy.install_mysql.sql PART3 [8] 병합 후 전체 재실행 오류 0건(멱등).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-27 16:03:34 +09:00
parent 5c7d064fbd
commit 3d359a1a8f
11 changed files with 722 additions and 115 deletions

View File

@ -9,9 +9,12 @@ import java.net.URLEncoder;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@ -58,6 +61,19 @@ public class PersonalDataAccessLogController {
/** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */ /** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */
private static final int MY_JOB_MAX = 100; private static final int MY_JOB_MAX = 100;
/** 감사 표시 — 이상(처리결과 차단·비인가). */
private static final String AUDIT_ABNORMAL = "A";
/** 감사 표시 — 점검(정상 처리였으나 이상징후 대상). */
private static final String AUDIT_WATCH = "W";
/** 감사 표시 계산 시 기간 미지정이면 적용할 기본 조회기간(일). */
private static final int AUDIT_DEFAULT_DAYS = 30;
/** 업무시간 시작(시). AbnormalAccessPolicy 기본값과 동일. */
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") @Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService; private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@ -70,6 +86,10 @@ public class PersonalDataAccessLogController {
@Resource(name = "accessLogDownloadJobService") @Resource(name = "accessLogDownloadJobService")
private AccessLogDownloadJobService accessLogDownloadJobService; private AccessLogDownloadJobService accessLogDownloadJobService;
/** 감사 표시용 이상징후 탐지(#35). 목록 렌더 시 검색기간 기준으로 대조한다. */
@Resource(name = "abnormalAccessDetectionService")
private AbnormalAccessDetectionService abnormalAccessDetectionService;
/** /**
* 개인정보 접속기록 조회 화면(서버 렌더링). * 개인정보 접속기록 조회 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp * 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
@ -77,6 +97,27 @@ public class PersonalDataAccessLogController {
@RequestMapping(value = "/mnt/sec/accessLog/listView.do") @RequestMapping(value = "/mnt/sec/accessLog/listView.do")
public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
ModelMap model) throws Exception { ModelMap model) throws Exception {
return renderList(searchVO, model, false);
}
/**
* 개인정보 <b>처리기록</b> 조회 화면 — 제안서 시안 좌측 (2) 메뉴.
*
* <p>(1)(접속기록 조회)이 접속·조회를 포함한 전체 기록이라면, 이 화면은 조회(S)를 빼고
* <b>등록·수정·삭제만</b> 남긴다. 정보주체 소명이나 감사 대응에서 "누가 이 사람의 개인정보를
* 바꿨는가"를 먼저 보게 되는 쪽이라 별도 진입점을 둔다. 화면(JSP)과 검색조건은 (1)과 공유하고
* {@code excludeSelect} 조건 하나만 다르다 — 같은 표를 두 벌 관리하지 않기 위해서다.</p>
*/
@RequestMapping(value = "/mnt/sec/processLog/listView.do")
public String processListView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
ModelMap model) throws Exception {
searchVO.setExcludeSelect(true);
return renderList(searchVO, model, true);
}
/** (1)·(2) 공통 렌더링. {@code processOnly} 는 화면 제목·안내 문구 분기에만 쓴다. */
private String renderList(PersonalDataAccessLogSearchVO searchVO, ModelMap model, boolean processOnly)
throws Exception {
if (handlerUser() == null) { if (handlerUser() == null) {
return LOGIN_REDIRECT; return LOGIN_REDIRECT;
} }
@ -97,9 +138,102 @@ public class PersonalDataAccessLogController {
model.addAttribute("dmlSummary", selectDmlSummary(searchVO)); model.addAttribute("dmlSummary", selectDmlSummary(searchVO));
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기 // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
model.addAttribute("dlJobList", selectMyDownloadJobs()); model.addAttribute("dlJobList", selectMyDownloadJobs());
model.addAttribute("processOnly", Boolean.valueOf(processOnly));
model.addAttribute("retentionDays", Integer.valueOf(ACCESS_LOG_RETENTION_DAYS));
// 시안의 "감사" 칸 — 목록 각 행에 이상/점검 표시를 채우고 기간 전체 탐지 요약을 함께 싣는다
markAudit(list, searchVO, model);
return "site/mnt/sec/accessLog/list"; return "site/mnt/sec/accessLog/list";
} }
/**
* 목록 각 행에 <b>감사 표시</b>를 채우고, 검색기간 전체의 탐지 요약을 모델에 싣는다.
*
* <p>판정 규칙(제안서 시안의 표기와 동일):</p>
* <ul>
* <li><b>이상(A)</b> — 처리결과가 실패·차단(F). 비인가 접근 시도가 여기 걸린다.</li>
* <li><b>점검(W)</b> — 처리는 정상이었으나 이상징후 대상인 경우. 대량조회·계정공유의심·
* 인증실패반복 대상자이거나, 업무시간 밖에 처리된 기록이다.</li>
* </ul>
*
* <p><b>기간을 반드시 좁힌다.</b> 탐지는 5개의 집계 질의라 기간이 열려 있으면 730일치를 훑는다.
* 검색조건에 기간이 없으면 최근 {@link #AUDIT_DEFAULT_DAYS}일로 잘라 계산한다 — 목록 한 장을
* 여는 비용이 감사 표시 때문에 커지면 안 된다(PSR-001).</p>
*
* <p><b>실패해도 목록은 보여준다.</b> 감사 표시는 부가 정보이므로, 탐지가 실패하면 표시 없이
* 넘어간다(목록 자체가 막히면 본래 목적인 접속기록 조회가 불가능해진다).</p>
*/
private void markAudit(List<PersonalDataAccessLogVO> list, PersonalDataAccessLogSearchVO searchVO,
ModelMap model) {
if (list == null || list.isEmpty()) {
return;
}
try {
SimpleDateFormat dtFmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String from = searchVO.getFromDtParam();
String to = searchVO.getToDtParam();
if (from == null || to == null) {
Calendar cal = Calendar.getInstance();
String computedTo = dtFmt.format(cal.getTime());
cal.add(Calendar.DAY_OF_MONTH, -AUDIT_DEFAULT_DAYS);
String computedFrom = dtFmt.format(cal.getTime());
from = (from == null) ? computedFrom : from;
to = (to == null) ? computedTo : to;
}
List<AbnormalAccessResultVO> results = abnormalAccessDetectionService.detect(from, to);
// 대상자형 유형(대량조회·계정공유·인증실패반복)의 접속자ID 집합 = 점검 대상
Set<String> watchIds = new HashSet<String>();
int detected = 0;
for (int i = 0; i < results.size(); i++) {
AbnormalAccessResultVO r = results.get(i);
detected += r.getCount();
List<Map<String, Object>> rows = r.getRows();
if (rows == null) {
continue;
}
for (int j = 0; j < rows.size(); j++) {
Object cid = rows.get(j).get("conectId");
if (cid != null) {
watchIds.add(cid.toString());
}
}
}
for (int i = 0; i < list.size(); i++) {
PersonalDataAccessLogVO vo = list.get(i);
if ("F".equals(vo.getProcessResult())) {
vo.setAuditFlag(AUDIT_ABNORMAL);
} else if ((vo.getConectId() != null && watchIds.contains(vo.getConectId()))
|| isOffHours(vo.getConectDt())) {
vo.setAuditFlag(AUDIT_WATCH);
}
}
model.addAttribute("auditDetected", Integer.valueOf(detected));
model.addAttribute("auditSummary", abnormalAccessDetectionService.summarize(from, to));
} catch (Exception e) {
// 감사 표시는 부가 정보다 — 실패해도 목록 조회 자체는 살린다.
model.addAttribute("auditError", Boolean.TRUE);
}
}
/**
* 업무시간(기본 09~18시) 밖 처리인지. 형식이 다르거나 파싱 불가면 false(과대표시 방지).
* 기준시각은 {@code AbnormalAccessPolicy} 기본값과 같다.
*/
private boolean isOffHours(String conectDt) {
if (conectDt == null || conectDt.length() < 13) {
return false;
}
try {
int hour = Integer.parseInt(conectDt.substring(11, 13));
return hour < OFF_HOURS_START || hour >= OFF_HOURS_END;
} catch (Exception e) {
return false;
}
}
/** /**
* 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다. * 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
* *

View File

@ -58,6 +58,18 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
/** 전체 레코드 수(조회 후 세팅) */ /** 전체 레코드 수(조회 후 세팅) */
private int totalRecordCount = 0; private int totalRecordCount = 0;
/**
* 조회(S)를 제외하고 <b>등록·수정·삭제만</b> 보여줄지 여부.
*
* <p>제안서 시안의 좌측 ② "개인정보 처리기록" 메뉴가 이 조건을 켠 화면이다.
* ① "접속기록 조회"가 접속·조회를 포함한 전체 기록이라면, ②는 개인정보를 실제로
* <u>바꾼</u> 행위만 추린 화면이다 — 정보주체 소명·감사 대응에서 먼저 보게 되는 쪽이다.</p>
*
* <p>{@code dmlGubun}(단일 값 지정)과는 다른 조건이다. 둘 다 지정되면 SQL 에서 AND 로 걸려
* 교집합이 되므로, 화면에서는 한쪽만 쓰게 한다.</p>
*/
private boolean excludeSelect = false;
public String getConectId() { return conectId; } public String getConectId() { return conectId; }
public void setConectId(String conectId) { this.conectId = conectId; } public void setConectId(String conectId) { this.conectId = conectId; }
@ -94,6 +106,9 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
public int getTotalRecordCount() { return totalRecordCount; } public int getTotalRecordCount() { return totalRecordCount; }
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
public boolean isExcludeSelect() { return excludeSelect; }
public void setExcludeSelect(boolean excludeSelect) { this.excludeSelect = excludeSelect; }
// ===================================================================== // =====================================================================
// SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용). // SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용).
// 원본 필드는 화면 재표시용으로 건드리지 않는다. // 원본 필드는 화면 재표시용으로 건드리지 않는다.

View File

@ -45,6 +45,16 @@ public class PersonalDataAccessLogVO implements Serializable {
/** 직전 레코드 해시(해시체인) */ /** 직전 레코드 해시(해시체인) */
private String prevHashVal; private String prevHashVal;
/**
* 감사 표시 — 제안서 시안의 목록 맨 오른쪽 "감사" 칸.
* {@code A}:이상(차단·비인가) / {@code W}:점검(정상이나 이상징후 대상) / {@code null}:해당없음.
*
* <p>DB 컬럼이 아니라 <b>조회 시점에 계산해 채우는 값</b>이다(컨트롤러가 탐지 결과와 대조).
* 탐지 임계치는 정책값이라 언제든 바뀌므로, 판정 결과를 적재해 두면 임계치를 조정한 뒤에도
* 과거 기준으로 굳은 값이 남는다 — 그래서 저장하지 않는다.</p>
*/
private String auditFlag;
public long getLogId() { return logId; } public long getLogId() { return logId; }
public void setLogId(long logId) { this.logId = logId; } public void setLogId(long logId) { this.logId = logId; }
@ -81,6 +91,9 @@ public class PersonalDataAccessLogVO implements Serializable {
public String getHashVal() { return hashVal; } public String getHashVal() { return hashVal; }
public void setHashVal(String hashVal) { this.hashVal = hashVal; } public void setHashVal(String hashVal) { this.hashVal = hashVal; }
public String getAuditFlag() { return auditFlag; }
public void setAuditFlag(String auditFlag) { this.auditFlag = auditFlag; }
public String getPrevHashVal() { return prevHashVal; } public String getPrevHashVal() { return prevHashVal; }
public void setPrevHashVal(String prevHashVal) { this.prevHashVal = prevHashVal; } public void setPrevHashVal(String prevHashVal) { this.prevHashVal = prevHashVal; }
} }

View File

@ -113,6 +113,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>
@ -135,6 +138,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>
@ -163,6 +169,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>

View File

@ -96,6 +96,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>
@ -117,6 +120,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>
@ -137,6 +143,9 @@
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty> <isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty> <isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty> <isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
<!-- 시안 좌측 ② "개인정보 처리기록" — 조회(S)를 빼고 실제로 개인정보를 바꾼 행위만 본다.
dmlGubun(단일값)과 함께 지정하면 AND 로 걸려 교집합이 되므로 화면에서 한쪽만 쓴다. -->
<isEqual prepend="AND" property="excludeSelect" compareValue="true"> DML_GUBUN &lt;&gt; 'S' </isEqual>
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="fromDt"> CONECT_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty> <isNotEmpty prepend="AND" property="toDt"> CONECT_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic> </dynamic>

View File

@ -94,10 +94,10 @@
</li> </li>
</c:if> </c:if>
<c:if test="${menuUpperVo2.menuNo eq '9100030'}"> <c:if test="${menuUpperVo2.menuNo eq '9100030'}">
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a> <li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100047' || param.page eq '9100051'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<c:forEach items="${list_menulist}" var="menuUpperVo3"> <c:forEach items="${list_menulist}" var="menuUpperVo3">
<c:if test="${menuUpperVo3.menuNo eq '9100030'}"> <c:if test="${menuUpperVo3.menuNo eq '9100030'}">
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">style="display: block;"</c:if>> <ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100047' || param.page eq '9100051'}">style="display: block;"</c:if>>
<c:forEach items="${list_menulist}" var="menuUpperVo4"> <c:forEach items="${list_menulist}" var="menuUpperVo4">
<c:choose> <c:choose>
<c:when test="${menuUpperVo4.menuNo eq '9100031'}"> <c:when test="${menuUpperVo4.menuNo eq '9100031'}">
@ -122,27 +122,11 @@
<c:when test="${menuUpperVo4.menuNo eq '9100039'}"> <c:when test="${menuUpperVo4.menuNo eq '9100039'}">
<li <c:if test="${param.page eq '9100039'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li> <li <c:if test="${param.page eq '9100039'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when> </c:when>
<%-- [개인정보/#47] 개인정보 접속기록 조회·다운로드 (9100040~43은 게시판관리가 선점 → 9100044) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100044'}">
<li <c:if test="${param.page eq '9100044'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [2026-08-11] 9100045 업무유형 매핑 관리 / 9100046 로그인·로그아웃 접속기록 분기 삭제(화면·메뉴 폐기) --%> <%-- [2026-08-11] 9100045 업무유형 매핑 관리 / 9100046 로그인·로그아웃 접속기록 분기 삭제(화면·메뉴 폐기) --%>
<%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%> <%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100047'}"> <c:when test="${menuUpperVo4.menuNo eq '9100047'}">
<li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li> <li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when> </c:when>
<%-- [개인정보/SFR-004-7 #48] 접속기록 정기점검 리포트(점검결과 입력·보관) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100048'}">
<li <c:if test="${param.page eq '9100048'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/과업내용 다-① #49] 비정상접근 탐지 5유형 --%>
<c:when test="${menuUpperVo4.menuNo eq '9100049'}">
<li <c:if test="${param.page eq '9100049'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/SER-002-2] 접속기록 파기이력(파티션 DROP/ADD 증빙) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100050'}">
<li <c:if test="${param.page eq '9100050'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%> <%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100051'}"> <c:when test="${menuUpperVo4.menuNo eq '9100051'}">
<li <c:if test="${param.page eq '9100051'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li> <li <c:if test="${param.page eq '9100051'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
@ -155,6 +139,40 @@
</li> </li>
</c:if> </c:if>
<%-- ============================================================
[개인정보/제안서 Ⅲ-4 시안] 접속기록 · 감사 (9100052)
① 접속기록 조회(9100044) ② 개인정보 처리기록(9100053)
③ 이상행위 감사(9100049) ④ 정기 점검 리포트(9100048)
제안서 시안이 이 넷을 한 묶음으로 그리고 있어 권한관리 하위에서 이설했다.
접속기록 파기이력은 시안에 메뉴가 없어 정기 점검 리포트 화면 안에서 연다.
============================================================ --%>
<c:if test="${menuUpperVo2.menuNo eq '9100052'}">
<li <c:if test="${param.page eq '9100044' || param.page eq '9100053' || param.page eq '9100049' || param.page eq '9100048'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<c:forEach items="${list_menulist}" var="menuUpperVo3">
<c:if test="${menuUpperVo3.menuNo eq '9100052'}">
<ul <c:if test="${param.page eq '9100044' || param.page eq '9100053' || param.page eq '9100049' || param.page eq '9100048'}">style="display: block;"</c:if>>
<c:forEach items="${list_menulist}" var="menuUpperVo4">
<c:choose>
<c:when test="${menuUpperVo4.menuNo eq '9100044'}">
<li <c:if test="${param.page eq '9100044'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<c:when test="${menuUpperVo4.menuNo eq '9100053'}">
<li <c:if test="${param.page eq '9100053'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<c:when test="${menuUpperVo4.menuNo eq '9100049'}">
<li <c:if test="${param.page eq '9100049'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<c:when test="${menuUpperVo4.menuNo eq '9100048'}">
<li <c:if test="${param.page eq '9100048'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
</c:choose>
</c:forEach>
</ul>
</c:if>
</c:forEach>
</li>
</c:if>
<c:if test="${menuUpperVo2.menuNo eq '9100040'}"> <c:if test="${menuUpperVo2.menuNo eq '9100040'}">
<li <c:if test="${param.page eq '9100041' || param.page eq '9100042' || param.page eq '9100043'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a> <li <c:if test="${param.page eq '9100041' || param.page eq '9100042' || param.page eq '9100043'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<c:forEach items="${list_menulist}" var="menuUpperVo3"> <c:forEach items="${list_menulist}" var="menuUpperVo3">

View File

@ -14,13 +14,17 @@
· 이 테이블은 UPDATE/DELETE statement 미정의(위·변조 방지) — 화면에서도 조회/다운로드만 제공 · 이 테이블은 UPDATE/DELETE statement 미정의(위·변조 방지) — 화면에서도 조회/다운로드만 제공
--%> --%>
<c:set var="PATH" value="/mnt" /> <c:set var="PATH" value="/mnt" />
<%-- 제안서 시안 좌측 (1)접속기록 조회 / (2)개인정보 처리기록 — 같은 화면을 조건만 바꿔 쓴다.
목록 재조회·페이징이 자기 화면으로 돌아오도록 진입 URL 을 여기서 정한다. --%>
<c:set var="LIST_URL" value="/mnt/sec/accessLog/listView.do" />
<c:if test="${processOnly}"><c:set var="LIST_URL" value="/mnt/sec/processLog/listView.do" /></c:if>
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script> <script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript"> <script type="text/javascript">
<!-- <!--
function fn_list(pageIndex) { function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex; document.frm.pageIndex.value = pageIndex;
document.frm.method = "get"; document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />"; document.frm.action = "<c:url value='${LIST_URL}' />";
document.frm.submit(); document.frm.submit();
} }
@ -40,7 +44,7 @@ function fn_download() {
document.frm.submit(); document.frm.submit();
/* 다음 검색이 다운로드로 가지 않도록 원복 */ /* 다음 검색이 다운로드로 가지 않도록 원복 */
document.frm.dlReason.value = ""; document.frm.dlReason.value = "";
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />"; document.frm.action = "<c:url value='${LIST_URL}' />";
} }
/* 다운로드 '요청'(비동기) : 사유를 받아 작업만 등록하고 목록으로 돌아온다. /* 다운로드 '요청'(비동기) : 사유를 받아 작업만 등록하고 목록으로 돌아온다.
@ -177,6 +181,13 @@ function fn_dml(gubun) {
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; } .badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.s { background:#4a7ebb; } .badge.s { background:#4a7ebb; }
.badge.f { background:#c25b5b; } .badge.f { background:#c25b5b; }
/* 감사 표시 — 시안의 목록 맨 오른쪽 "감사" 칸. 이상(차단)은 붉게, 점검(이상징후 대상)은 노랗게. */
.badge.audit_a { background:#c25b5b; }
.badge.audit_w { background:#d6a13a; }
tr.audit_a td { background:#fdf3f3; }
tr.audit_w td { background:#fffaf0; }
.audit_line { margin:0 10px; font-size:12px; color:#445; }
.audit_line strong.hit { color:#c25b5b; }
.dml { display:inline-block; padding:1px 6px; border-radius:3px; font-size:12px; border:1px solid #cbd5e0; color:#445; } .dml { display:inline-block; padding:1px 6px; border-radius:3px; font-size:12px; border:1px solid #cbd5e0; color:#445; }
.dml.d { border-color:#c25b5b; color:#c25b5b; } .dml.d { border-color:#c25b5b; color:#c25b5b; }
.ip { font-family:Consolas,monospace; } .ip { font-family:Consolas,monospace; }
@ -262,6 +273,11 @@ function fn_dml(gubun) {
<!-- //검색창 --> <!-- //검색창 -->
<div class="guide"> <div class="guide">
<c:choose>
<c:when test="${processOnly}">· <strong>개인정보를 실제로 바꾼 기록</strong>만 추린 화면입니다(등록·수정·삭제). 조회(S)는 제외되며, 전체 기록은 <strong>접속기록 조회</strong> 메뉴에서 봅니다.<br /></c:when>
<c:otherwise>· 개인정보 <strong>접속·처리 전체 기록</strong>입니다. 바꾼 기록만 보려면 <strong>개인정보 처리기록</strong> 메뉴를 쓰세요.<br /></c:otherwise>
</c:choose>
· <strong>감사</strong> 칸의 <span class="badge audit_a">이상</span>은 처리가 차단·실패한 기록(비인가 접근 시도), <span class="badge audit_w">점검</span>은 처리는 정상이었으나 대량조회·계정공유의심·인증실패반복 대상이거나 업무시간 밖 처리임을 뜻합니다. <strong>탐지되었다는 것이 곧 위반은 아닙니다</strong> — 업무상 정당한 경우가 있으므로 확인 후 판단하세요.<br />
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요. · 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.
날짜만 입력하면 <strong>종료일 당일(23:59:59)까지 포함</strong>해 검색합니다.<br /> 날짜만 입력하면 <strong>종료일 당일(23:59:59)까지 포함</strong>해 검색합니다.<br />
· <strong>접속자ID·처리대상ID·접속IP는 앞부분만 입력해도 검색</strong>됩니다(예: IP에 <code>192.168.</code> 입력 시 해당 대역 전체).<br /> · <strong>접속자ID·처리대상ID·접속IP는 앞부분만 입력해도 검색</strong>됩니다(예: IP에 <code>192.168.</code> 입력 시 해당 대역 전체).<br />
@ -273,7 +289,14 @@ function fn_dml(gubun) {
</div> </div>
<div class="btn_area"> <div class="btn_area">
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건 검색결과 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
<span class="audit_line">
· 보관기간 <strong><c:out value="${retentionDays}" />일</strong>
<c:if test="${not empty auditDetected}">
· 이상행위 <strong <c:if test="${auditDetected > 0}">class="hit"</c:if>><c:out value="${auditDetected}" /></strong>건 탐지<c:if test="${not empty auditSummary}"> <span class="nodata" title="<c:out value='${auditSummary}'/>">(상세)</span></c:if>
</c:if>
<c:if test="${auditError}">· <span class="nodata">이상행위 집계 실패 — 목록만 표시합니다</span></c:if>
</span>
<%-- 처리행위 요약 — 현재 검색조건 기준. 목록을 넘겨보지 않고 <%-- 처리행위 요약 — 현재 검색조건 기준. 목록을 넘겨보지 않고
"조회만 했는지 / 수정·삭제까지 했는지"를 바로 판별하기 위한 값이다. "조회만 했는지 / 수정·삭제까지 했는지"를 바로 판별하기 위한 값이다.
각 항목을 누르면 해당 처리구분으로 다시 검색한다. --%> 각 항목을 누르면 해당 처리구분으로 다시 검색한다. --%>
@ -283,7 +306,7 @@ function fn_dml(gubun) {
· <a href="#none" onclick="fn_dml('U'); return false;" title="수정 기록만 보기">수정 <strong><c:out value="${dmlSummary.U}" /></strong></a> · <a href="#none" onclick="fn_dml('U'); return false;" title="수정 기록만 보기">수정 <strong><c:out value="${dmlSummary.U}" /></strong></a>
· <a href="#none" onclick="fn_dml('D'); return false;" title="삭제 기록만 보기">삭제 <strong class="d"><c:out value="${dmlSummary.D}" /></strong></a>] · <a href="#none" onclick="fn_dml('D'); return false;" title="삭제 기록만 보기">삭제 <strong class="d"><c:out value="${dmlSummary.D}" /></strong></a>]
</span> </span>
<input type="button" class="btn_dl" value="CSV 다운로드 요청(사유 필수)" onclick="fn_downloadRequest();" /> <input type="button" class="btn_dl" value="엑셀 다운로드 요청(사유 필수)" onclick="fn_downloadRequest();" />
</div> </div>
<%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다. <%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다.
@ -355,14 +378,15 @@ function fn_dml(gubun) {
<caption>개인정보 접속기록 목록</caption> <caption>개인정보 접속기록 목록</caption>
<colgroup> <colgroup>
<col width="5%" /> <col width="5%" />
<col width="13%" /> <col width="12%" />
<col width="9%" />
<col width="10%" /> <col width="10%" />
<col width="11%" />
<col width="*" /> <col width="*" />
<col width="7%" /> <col width="7%" />
<col width="12%" /> <col width="11%" />
<col width="7%" />
<col width="6%" />
<col width="8%" /> <col width="8%" />
<col width="9%" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
@ -374,12 +398,13 @@ function fn_dml(gubun) {
<th>처리구분</th> <th>처리구분</th>
<th>처리대상</th> <th>처리대상</th>
<th>처리결과</th> <th>처리결과</th>
<th>감사</th>
<th>무결성해시</th> <th>무결성해시</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<c:forEach items="${resultList}" var="result" varStatus="status"> <c:forEach items="${resultList}" var="result" varStatus="status">
<tr> <tr<c:if test="${result.auditFlag eq 'A'}"> class="audit_a"</c:if><c:if test="${result.auditFlag eq 'W'}"> class="audit_w"</c:if>>
<td><c:out value="${personalDataAccessLogSearchVO.totalRecordCount - (status.index + (personalDataAccessLogSearchVO.pageIndex-1)*personalDataAccessLogSearchVO.recordCountPerPage)}" /></td> <td><c:out value="${personalDataAccessLogSearchVO.totalRecordCount - (status.index + (personalDataAccessLogSearchVO.pageIndex-1)*personalDataAccessLogSearchVO.recordCountPerPage)}" /></td>
<td><c:out value="${result.conectDt}" /></td> <td><c:out value="${result.conectDt}" /></td>
<td> <td>
@ -415,6 +440,15 @@ function fn_dml(gubun) {
<c:otherwise><span class="badge s">성공</span></c:otherwise> <c:otherwise><span class="badge s">성공</span></c:otherwise>
</c:choose> </c:choose>
</td> </td>
<%-- 감사 : 이상(차단·비인가) / 점검(정상이나 이상징후 대상) / 해당없음
판정은 조회 시점에 계산한다(PersonalDataAccessLogController.markAudit) --%>
<td>
<c:choose>
<c:when test="${result.auditFlag eq 'A'}"><span class="badge audit_a" title="처리가 차단·실패한 기록입니다. 비인가 접근 시도일 수 있습니다.">이상</span></c:when>
<c:when test="${result.auditFlag eq 'W'}"><span class="badge audit_w" title="처리는 정상이었으나 대량조회·계정공유의심·인증실패반복 대상이거나 업무시간 밖 처리입니다.">점검</span></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td class="hash" title="<c:out value='${result.hashVal}'/>"> <td class="hash" title="<c:out value='${result.hashVal}'/>">
<c:out value="${fn:substring(result.hashVal, 0, 12)}" />… <c:out value="${fn:substring(result.hashVal, 0, 12)}" />…
</td> </td>
@ -422,7 +456,7 @@ function fn_dml(gubun) {
</c:forEach> </c:forEach>
<c:if test="${fn:length(resultList) == 0}"> <c:if test="${fn:length(resultList) == 0}">
<tr> <tr>
<td colspan="9"><spring:message code="common.nodata.msg" /></td> <td colspan="10"><spring:message code="common.nodata.msg" /></td>
</tr> </tr>
</c:if> </c:if>
</tbody> </tbody>

View File

@ -103,6 +103,12 @@ tr.on td { background:#f2f6fb; }
<div class="btn_area"> <div class="btn_area">
총 <strong><c:out value="${totalCount}" /></strong>건 총 <strong><c:out value="${totalCount}" /></strong>건
<%-- 접속기록 파기이력 진입점.
제안서 시안의 좌측메뉴에는 파기이력이 없고, 파기는 "보관·점검"의 일부로 그려져 있다.
그래서 별도 메뉴 대신 점검 화면에서 연다 — 점검할 때 "규정대로 파기됐는가"를
같은 자리에서 확인하는 편이 실제 감사 흐름과도 맞는다. --%>
<input type="button" class="btn_search" value="접속기록 파기이력"
onclick="location.href='<c:url value="${PATH}/sec/purgeHistory/listView.do"/>?page=9100048';" />
</div> </div>
<table summary="정기점검 리포트 목록" class="list1"> <table summary="정기점검 리포트 목록" class="list1">

View File

@ -78,6 +78,10 @@ function fn_list(pageIndex) {
<div class="btn_area"> <div class="btn_area">
총 <strong><c:out value="${accessLogPurgeHistorySearchVO.totalRecordCount}" /></strong>건 총 <strong><c:out value="${accessLogPurgeHistorySearchVO.totalRecordCount}" /></strong>건
<%-- 이 화면은 좌측메뉴에 없다(제안서 시안에 파기이력 메뉴가 없음).
정기 점검 리포트에서 들어오므로 돌아갈 길을 같이 둔다. --%>
<input type="button" class="btn_search" value="정기 점검 리포트로"
onclick="location.href='<c:url value="${PATH}/sec/inspection/listView.do"/>?page=9100048';" />
</div> </div>
<table summary="개인정보 접속기록 파기·증설 이력 목록" class="list1"> <table summary="개인정보 접속기록 파기·증설 이력 목록" class="list1">

View File

@ -0,0 +1,117 @@
-- =====================================================================
-- 관리자 메뉴 재편 : "접속기록 · 감사" 그룹 신설 (MENU_NO 9100052)
-- 문화품앗이 개인정보보호 기능개선 사업 — 제안서 Ⅲ-4 시안 정합
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [왜] 제안요약서(개인정보보호강화 파일럿 개발항목.pptx) slide 14~15 의 화면 시안이
-- 좌측메뉴를 "접속기록 · 감사" 한 묶음으로 그리고 그 아래 4개를 두고 있다.
-- 2026-08-27 1차 구현은 이 넷을 권한관리(9100030) 하위에 낱개로 붙여 시안과 어긋났다.
-- 이 스크립트가 그 배치를 시안대로 되돌린다.
--
-- [TO-BE 구조]
-- 시스템관리(9100000)
-- └ 접속기록 · 감사 (9100052, ORDR 5) ← 신설
-- ① 접속기록 조회 9100044 (ORDR 1) ← 권한관리에서 이설
-- ② 개인정보 처리기록 9100053 (ORDR 2) ← 신설(조회 S 제외 = 등록·수정·삭제만)
-- ③ 이상행위 감사 9100049 (ORDR 3) ← 이설 + 명칭변경(비정상접근 탐지 →)
-- ④ 정기 점검 리포트 9100048 (ORDR 4) ← 이설 + 명칭변경(정기점검 리포트 →)
--
-- [내려가는 것] 접속기록 파기이력(9100050) — 시안에 메뉴가 없다. 메뉴만 제거하고
-- 화면(/mnt/sec/purgeHistory/listView.do)과 컨트롤러는 그대로 둔다.
-- 진입은 정기 점검 리포트 화면의 "접속기록 파기이력" 버튼으로 한다.
-- (기능을 지우는 게 아니라 노출 위치만 시안에 맞추는 것)
--
-- [그대로 두는 것] 회원정보 암호화 관리(9100051)는 추가제안 Action4 항목이라
-- 이 시안(Ⅲ-4 접속기록)의 범위가 아니다. 권한관리 하위에 그대로 둔다.
--
-- [ORDR 5 를 쓰는 이유] 시스템관리 하위 상위메뉴는 1~4,7,9 가 사용 중이고 5·6·8 이 비어 있다.
-- 권한관리(3) 바로 뒤에 두고 싶지만 4(게시판관리)가 선점하고 있어 다음 빈 번호를 쓴다.
--
-- ★★ 이 DML만으로는 좌측메뉴가 바뀌지 않는다 — left.jsp 동반 배포 필수 ★★
-- ① 9100052 그룹 블록 신설(하위 9100044·9100053·9100049·9100048)
-- ② 권한관리(9100030) 블록에서 9100044·9100048·9100049·9100050 분기 제거
-- ③ 권한관리 상위 하이라이트 조건 2곳에서 위 4개 제거
-- (소스에는 이미 반영 완료)
--
-- [재실행 안전] INSERT 는 NOT EXISTS, UPDATE 는 결과가 같은 값으로 수렴, DELETE 는 대상 한정.
-- [한글 리터럴 주의] mysql.exe 에 stdin 파이프로 넣지 말 것 — SOURCE 로 실행하고
-- 적용 후 HEX(MENU_NM) 가 EA/EB/EC/ED 로 시작하는지 확인한다.
-- =====================================================================
-- ── [1] 상위 그룹 "접속기록 · 감사" (9100052) ──
-- [실측] 그룹(2depth) 메뉴는 PROGRM_FILE_NM 이 NOT NULL 이라 비울 수 없고,
-- 기존 그룹(시스템관리·사용자관리·권한관리·게시판관리·메뉴관리)이 모두
-- 센티넬 값 'dir'(COMTNPROGRMLIST 에 URL='dir' 로 1행 존재)을 쓴다.
-- RELATE_IMAGE_PATH/NM 도 기존 그룹과 같이 NULL 로 맞춘다.
-- (NULL 을 넣었다가 ERROR 1048 로 실패한 뒤 형제 행을 실측해 정정)
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 · 감사' AS MENU_NM, 'dir' AS PROGRM_FILE_NM, 9100052 AS MENU_NO,
9100000 AS UPPER_MENU_NO, 5 AS MENU_ORDR,
'개인정보 접속·처리기록 조회 및 감사(제안서 Ⅲ-4)' AS MENU_DC,
NULL AS RELATE_IMAGE_PATH, NULL AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO)
AND EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = 'dir'); -- FK 안전
-- ── [2] ② 개인정보 처리기록 (9100053) 프로그램 + 메뉴 신설 ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'ProcessLogList' AS PROGRM_FILE_NM, '/mnt/sec/processLog/' AS PROGRM_STRE_PATH,
'개인정보 처리기록' AS PROGRM_KOREAN_NM,
'개인정보를 실제로 변경한 기록(등록·수정·삭제)만 조회' AS PROGRM_DC,
'/mnt/sec/processLog/listView.do?page=9100053' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '개인정보 처리기록' AS MENU_NM, 'ProcessLogList' AS PROGRM_FILE_NM, 9100053 AS MENU_NO,
9100052 AS UPPER_MENU_NO, 2 AS MENU_ORDR, '등록·수정·삭제 기록만 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
-- ── [3] 기존 3개 이설 + 명칭을 시안 표기로 정정 ──
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 1,
MENU_NM = '접속기록 조회'
WHERE MENU_NO = 9100044;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 3,
MENU_NM = '이상행위 감사',
MENU_DC = '비정상 접근 5유형 탐지 결과 조회'
WHERE MENU_NO = 9100049;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 4,
MENU_NM = '정기 점검 리포트'
WHERE MENU_NO = 9100048;
-- ── [4] 신규 2개(9100052 그룹, 9100053)에 노출권한 부여 ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100052 AS mno UNION ALL SELECT 9100053) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
-- ── [5] 접속기록 파기이력(9100050) 메뉴 내리기 ──
-- 화면·컨트롤러·프로그램(URL) 등록은 남긴다. 좌측메뉴 노출만 제거한다.
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100050;
DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100050;
-- ── [검증] 시안과 같은 구조인지 + 한글 손상 여부 ──
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_ORDR, m.MENU_NM, HEX(m.MENU_NM), p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m LEFT JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100052 OR m.UPPER_MENU_NO = 9100052
-- ORDER BY m.UPPER_MENU_NO, m.MENU_ORDR;
-- ── [롤백] 1차 구현(권한관리 하위 낱개 배치)으로 되돌리기 ──
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=10, MENU_NM='개인정보 접속기록' WHERE MENU_NO=9100044;
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=15, MENU_NM='비정상접근 탐지' WHERE MENU_NO=9100049;
-- UPDATE COMTNMENUINFO SET UPPER_MENU_NO=9100030, MENU_ORDR=14, MENU_NM='정기점검 리포트' WHERE MENU_NO=9100048;
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100052, 9100053);
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100052, 9100053);
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'ProcessLogList';
-- (9100050 파기이력 메뉴 복원은 privacy.menu_privacy_screens4_insert_mysql.sql 재실행)

View File

@ -1,11 +1,16 @@
-- ##################################################################### -- #####################################################################
-- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분) -- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분)
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) -- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
-- 구성 : [PART 1] DDL(테이블 생성·변경, 24개 테이블/인덱스) -- 구성 : [PART 1] DDL(테이블 생성·변경, 25개 테이블/인덱스)
-- → [PART 2] DML(기준/시드 데이터) -- → [PART 2] DML(기준/시드 데이터)
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/ -- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100047) -- COMTNMENUINFO/COMTNMENUCREATDTLS,
-- 9100035~9100039 + 9100044 + 9100047 + 9100048~9100051
-- + 9100052(접속기록·감사 그룹) + 9100053(개인정보 처리기록))
-- ※ [8]에서 9100044/48/49 를 9100052 하위로 이설하고 9100050(파기이력)
-- 메뉴는 내린다 — 제안서 Ⅲ-4 화면 시안 정합
-- ※ 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록)은 2026-08-11 폐기 -- ※ 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록)은 2026-08-11 폐기
-- — 폐기된 번호는 재사용하지 않는다(과거 권한매핑 잔재와 섞인다)
-- → [PART 4] 뷰 GNR 컷오버(comvnusermaster/comvnuserinfo, 회원 이름 암호화 -- → [PART 4] 뷰 GNR 컷오버(comvnusermaster/comvnuserinfo, 회원 이름 암호화
-- 최종 단계) — **자체 게이트 내장**, 백필 미완료 시 자동 SKIP(PART 4 헤더 참조) -- 최종 단계) — **자체 게이트 내장**, 백필 미완료 시 자동 SKIP(PART 4 헤더 참조)
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 → PART 4 순서로 실행 -- 실행순서: 반드시 PART 1 → PART 2 → PART 3 → PART 4 순서로 실행
@ -30,6 +35,30 @@
-- `backup/privacy.mber_nm_dead_views_backup_20260810.sql`(죽은 뷰 정의 백업)은 -- `backup/privacy.mber_nm_dead_views_backup_20260810.sql`(죽은 뷰 정의 백업)은
-- 되돌리기용 스냅샷 성격이라 이 통합 파일 실행 **전**에 별도로 실행하는 것을 권장하며 -- 되돌리기용 스냅샷 성격이라 이 통합 파일 실행 **전**에 별도로 실행하는 것을 권장하며
-- 통합하지 않았다(설치 로직이 아니라 사고 대비 아카이브라 실행 시점이 다름). -- 통합하지 않았다(설치 로직이 아니라 사고 대비 아카이브라 실행 시점이 다름).
-- 2026-08-27 개정(운영 실측 대조 반영):
-- [운영 실측 2026-08-27 15:31] @@version=5.7.31-log (**MySQL 5.7, MariaDB 아님**),
-- @@lower_case_table_names=**0**(대소문자 구분),
-- @@sql_mode=ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,
-- ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
-- (로컬 8.0.46 은 lctn=1, sql_mode 는 사실상 동일 → sql_mode 는 위험요소 아님)
-- 운영 DDL 덤프(csv_structure_Dump20260520) ↔ 로컬 csv 전수 대조 결과를 반영했다.
-- ① [치명] 운영은 객체명이 **대문자**(`COMTNGNRLMBER`), 로컬은 소문자다. PART 4 의
-- REPLACE() 는 lower_case_table_names 와 무관하게 항상 대소문자를 구분하므로,
-- 종전의 소문자 하드코딩은 운영에서 5개 치환이 전부 불발되고도
-- CREATE OR REPLACE VIEW 가 **에러 없이 성공**해 USER_NM_HASH 없는 뷰를 남겼다.
-- → information_schema 에서 실제 저장된 케이스를 읽어 검색문자열을 조립하고,
-- 치환 결과가 기대(USER_NM_HASH 4회 + MBER_NM_ENC + 평문참조 0)와 다르면
-- SIGNAL SQLSTATE '45000' 으로 **중단**하는 하드 게이트를 추가했다.
-- ② PART 1 의 죽은 뷰 8개 DROP 도 같은 이유로 운영에서 조용히 실패했다(운영 실제명은
-- `excelAppconfirmList` 등 혼합 케이스). → information_schema 기반 동적 DROP 으로 교체.
-- ③ 로컬 DB 에만 있고 저장소 어느 SQL 에도 없던 인덱스 2건
-- (COMTNEMPLYRINFO.IX_EMPLYR_ESNTL, BIZ_GROUP.IX_BIZ_GROUP_ESNTL) 을 PART 1 에 추가.
-- ④ PART 4 끝단 '반영 후 검증' SELECT 들도 뷰명을 소문자로 직접 참조하고 있었다.
-- 운영에서는 뷰 교체가 **성공한 직후** 이 SELECT 가 1146 으로 터져, 성공한 컷오버가
-- 실패처럼 보인다. → @v_master/@v_info 를 쓰는 동적 SQL 로 교체.
-- ⑤ PART 1 죽은 뷰 8개는 DROP 전에 정의를 privacy_view_def_backup 에 백업하도록 했다.
-- (backup/privacy.mber_nm_dead_views_backup_20260810.sql 은 로컬 소문자 캡처라
-- 운영 롤백 자산으로 쓸 수 없다 — 해당 파일에도 경고를 달아 두었다)
-- ##################################################################### -- #####################################################################
@ -951,6 +980,46 @@ SET @ddl := IF(@idx > 0,
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ─────────────────────────────
-- [2026-08-27 드리프트 보완] 스크립트에 빠져 있던 ESNTL_ID 조회 인덱스 2건
-- ─────────────────────────────
-- =====================================================================
-- [발견 경위] 운영 스키마 덤프(csv_structure_Dump20260520) ↔ 로컬 csv 스키마
-- 전수 대조 결과, 로컬에만 존재하고 저장소 어느 SQL 파일에도 없는
-- 인덱스 2개를 발견했다(개발 중 DB에 직접 수실행하고 스크립트화를 누락).
-- 이 파일만 운영에 돌리면 둘 다 누락돼 로컬에서만 빠른 조회가 된다.
--
-- · COMTNEMPLYRINFO(ESNTL_ID) : comvnusermaster/comvnuserinfo 뉴의 USR 분기와
-- 권한·미사용계정 화면이 ESNTL_ID 로 조인한다.
-- · BIZ_GROUP(ESNTL_ID) : 동일 뉴의 GRO 분기.
--
-- [안전성] 추가(additive) 인덱스라 기존 동작에 영향 없음.
-- [재실행] information_schema 존재 확인 후 생성 — 반복 실행 무해.
-- [롤백] DROP INDEX IX_EMPLYR_ESNTL ON COMTNEMPLYRINFO;
-- DROP INDEX IX_BIZ_GROUP_ESNTL ON BIZ_GROUP;
-- =====================================================================
SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNEMPLYRINFO'
AND INDEX_NAME = 'IX_EMPLYR_ESNTL');
SET @ddl := IF(@idx > 0,
'SELECT ''SKIP: IX_EMPLYR_ESNTL already exists'' AS msg',
'CREATE INDEX IX_EMPLYR_ESNTL ON COMTNEMPLYRINFO (ESNTL_ID)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'BIZ_GROUP'
AND INDEX_NAME = 'IX_BIZ_GROUP_ESNTL');
SET @ddl := IF(@idx > 0,
'SELECT ''SKIP: IX_BIZ_GROUP_ESNTL already exists'' AS msg',
'CREATE INDEX IX_BIZ_GROUP_ESNTL ON BIZ_GROUP (ESNTL_ID)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [privacy.mber_nm_encrypt_create_mysql.sql] (2026-08-11 통합) -- [privacy.mber_nm_encrypt_create_mysql.sql] (2026-08-11 통합)
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
@ -1043,15 +1112,64 @@ CREATE TABLE IF NOT EXISTS MIG_CRYPTO_PROGRESS (
-- [백업] 운영 반영 전 `src/script/mysql/backup/privacy.mber_nm_dead_views_backup_20260810.sql` -- [백업] 운영 반영 전 `src/script/mysql/backup/privacy.mber_nm_dead_views_backup_20260810.sql`
-- 로 정의를 먼저 백업해 둘 것(복원 시 그대로 재실행). -- 로 정의를 먼저 백업해 둘 것(복원 시 그대로 재실행).
-- [재실행 안전] DROP VIEW IF EXISTS. -- [재실행 안전] DROP VIEW IF EXISTS.
-- ★ [2026-08-27 대소문자 보정] 운영 DB(csv_structure_Dump20260520 실측)는 객체명을
-- 대문자/혼합 케이스로 저장한다(COMTNBBSUSERINFO, excelAppconfirmList ...). 로컬
-- Windows MySQL 은 전부 소문자라 종전의 소문자 DROP 문이 로컬에서만 동작하고
-- 운영에서는 IF EXISTS 때문에 **오류 없이 조용히 아무것도 지우지 않는다**.
-- → lower_case_table_names 설정에 의존하지 않도록 information_schema 에서
-- 실제 저장된 이름을 읽어 동적으로 DROP 한다(양쪽 환경 모두 동작).
-- ===================================================================== -- =====================================================================
DROP VIEW IF EXISTS comtnbbsuserinfo; DROP PROCEDURE IF EXISTS PRC_PRIVACY_DROP_DEAD_VIEWS;
DROP VIEW IF EXISTS comvnusermaster2; DELIMITER $$
DROP VIEW IF EXISTS comvnusermaster_1; CREATE PROCEDURE PRC_PRIVACY_DROP_DEAD_VIEWS()
DROP VIEW IF EXISTS excelappconfirmlist; BEGIN
DROP VIEW IF EXISTS excelappconfirmlist_auth; DECLARE v_done INT DEFAULT 0;
DROP VIEW IF EXISTS selectaddedoptionsinf; DECLARE v_name VARCHAR(64);
DROP VIEW IF EXISTS selectboardarticlelist; DECLARE cur CURSOR FOR
DROP VIEW IF EXISTS selectmiddleappconfirmlist; SELECT TABLE_NAME FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE()
AND LOWER(TABLE_NAME) IN ('comtnbbsuserinfo','comvnusermaster2','comvnusermaster_1',
'excelappconfirmlist','excelappconfirmlist_auth',
'selectaddedoptionsinf','selectboardarticlelist',
'selectmiddleappconfirmlist');
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
-- [2026-08-27] DROP 전에 **운영본 정의를 그 DB 안에** 백업한다.
-- backup/privacy.mber_nm_dead_views_backup_20260810.sql 은 로컬(소문자) 캡처라
-- 운영 복원에 쓸 수 없다. 롤백 자산은 반드시 운영에서 떠 둬야 한다.
CREATE TABLE IF NOT EXISTS privacy_view_def_backup (
view_name VARCHAR(64) NOT NULL,
backup_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
view_definition MEDIUMTEXT NOT NULL,
PRIMARY KEY (view_name, backup_at)
);
INSERT INTO privacy_view_def_backup (view_name, view_definition)
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')
AND NOT EXISTS (SELECT 1 FROM privacy_view_def_backup b
WHERE b.view_name = information_schema.views.TABLE_NAME);
OPEN cur;
drop_loop: LOOP
FETCH cur INTO v_name;
IF v_done = 1 THEN LEAVE drop_loop; END IF;
SET @drop_sql = CONCAT('DROP VIEW IF EXISTS `', v_name, '`');
PREPARE stmt_dv FROM @drop_sql;
EXECUTE stmt_dv;
DEALLOCATE PREPARE stmt_dv;
SELECT CONCAT('DROPPED dead view (definition backed up): ', v_name) AS DEAD_VIEW_STATUS;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
CALL PRC_PRIVACY_DROP_DEAD_VIEWS();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_DROP_DEAD_VIEWS;
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@ -1130,23 +1248,33 @@ WHERE NOT EXISTS (SELECT 1 FROM CONECT_IP_LOCATION x WHERE x.IP_CIDR = seed.IP_C
-- ===================================================================== -- =====================================================================
-- PART 3. 관리자 메뉴 등록 DML (좌측/상단 메뉴 노출) -- PART 3. 관리자 메뉴 등록 DML (좌측/상단 메뉴 노출)
-- 구성: 권한관리(9100030) 하위에 순서대로 9100035~9100039 5건 등록 -- 구성: 권한관리(9100030) 하위에 MENU_ORDR 순으로 등록
-- 권한 신청·승인 / 권한 변경 이력 / 미사용계정 현황 / 계정 관리 정책 / 인증 실패 이력 -- [1]~[5] 9100035~9100039 권한 신청·승인 / 권한 변경 이력 / 미사용계정 현황 /
-- 계정 관리 정책 / 인증 실패 이력
-- [6] 9100047(ORDR 13) 접근통제 정책
-- [7] 9100048~9100051(ORDR 14~17) 2026-08-27 추가
-- 정기점검 리포트 / 비정상접근 탐지 / 접속기록 파기이력 / 회원정보 암호화 관리
-- ※ 접속기록 조회(9100044, ORDR 10)는 위 [1]~[5] 블록에 함께 들어 있다.
-- 원본: src/script/mysql/dml/privacy.menu_author_request_insert_mysql.sql -- 원본: src/script/mysql/dml/privacy.menu_author_request_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_author_history_insert_mysql.sql -- src/script/mysql/dml/privacy.menu_author_history_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_dormant_status_insert_mysql.sql -- src/script/mysql/dml/privacy.menu_dormant_status_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_account_policy_insert_mysql.sql -- src/script/mysql/dml/privacy.menu_account_policy_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_access_log_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_access_policy_insert_mysql.sql
-- src/script/mysql/dml/privacy.menu_privacy_screens4_insert_mysql.sql
-- (개별 파일에 화면별 배치 근거·롤백 SQL이 더 상세히 있음 — 여기서는 통합 실행용) -- (개별 파일에 화면별 배치 근거·롤백 SQL이 더 상세히 있음 — 여기서는 통합 실행용)
-- --
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★ -- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라 -- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- 권한관리(9100030) 블록에 9100035~9100038 각각의 <c:when> 분기 + -- 권한관리(9100030) 블록에 등록한 메뉴번호마다 <c:when> 분기 +
-- 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '910003x')이 -- 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '91000xx')이
-- 반드시 함께 배포돼야 렌더된다. (arko_renual 소스에는 4건 모두 이미 반영 완료 — -- 반드시 함께 배포돼야 렌더된다. (arko_renual 소스에는 9100035~9100039·9100044·
-- 운영 배포 시 WAR/배포본에 이 JSP가 포함됐는지만 확인하면 됨) -- 9100047·9100048~9100051 전부 이미 반영 완료 — 운영 배포 시 WAR/배포본에 이 JSP가
-- 포함됐는지만 확인하면 됨)
-- --
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해. -- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] 4건 모두 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 동일 -- [노출권한] 전 건 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 동일
-- (실제 접근 차단은 urlRoleMapping 의 /mnt/sec/* = ROLE_ADMIN 이 담당)
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성 -- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성
-- ===================================================================== -- =====================================================================
@ -1361,6 +1489,70 @@ SELECT n.mno, g.ac
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac); WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
-- ── [8] "접속기록 · 감사" 그룹 재편(9100052) — 2026-08-27, 제안서 Ⅲ-4 시안 정합 ──
-- [왜] 제안요약서 slide 14~15 의 화면 시안이 좌측메뉴를 "접속기록 · 감사" 한 묶음으로 그리고
-- 그 아래 4개(접속기록 조회 / 개인정보 처리기록 / 이상행위 감사 / 정기 점검 리포트)를 둔다.
-- 위 [7]은 이 넷을 권한관리(9100030) 하위에 낱개로 붙여 시안과 어긋났다 — 여기서 되돌린다.
-- [순서] 반드시 [7] 뒤에 온다([7]이 만든 9100048·9100049 를 이설하기 때문).
-- 신규 설치는 [7]→[8]을 이어 실행하면 최종 구조에 도달하고,
-- 이미 [7]만 적용된 DB는 [8]만 실행해도 같은 결과가 된다(전 구간 멱등).
-- [내려가는 것] 접속기록 파기이력(9100050)은 시안에 메뉴가 없다. 메뉴만 제거하고 화면·
-- 컨트롤러·프로그램(URL) 등록은 남긴다 — 정기 점검 리포트 화면의 버튼으로 진입한다.
-- [그룹 메뉴 규약] PROGRM_FILE_NM 은 NOT NULL 이고 기존 그룹이 모두 센티넬 'dir' 을 쓴다
-- (COMTNPROGRMLIST 에 URL='dir' 1행). NULL 을 넣으면 ERROR 1048 로 실패한다.
-- ★ left.jsp 동반 배포 필수 — 9100052 그룹 블록 신설 + 권한관리 블록에서 4개 분기 제거
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 · 감사' AS MENU_NM, 'dir' AS PROGRM_FILE_NM, 9100052 AS MENU_NO,
9100000 AS UPPER_MENU_NO, 5 AS MENU_ORDR,
'개인정보 접속·처리기록 조회 및 감사(제안서 Ⅲ-4)' AS MENU_DC,
NULL AS RELATE_IMAGE_PATH, NULL AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO)
AND EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = 'dir');
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'ProcessLogList' AS PROGRM_FILE_NM, '/mnt/sec/processLog/' AS PROGRM_STRE_PATH,
'개인정보 처리기록' AS PROGRM_KOREAN_NM,
'개인정보를 실제로 변경한 기록(등록·수정·삭제)만 조회' AS PROGRM_DC,
'/mnt/sec/processLog/listView.do?page=9100053' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '개인정보 처리기록' AS MENU_NM, 'ProcessLogList' AS PROGRM_FILE_NM, 9100053 AS MENU_NO,
9100052 AS UPPER_MENU_NO, 2 AS MENU_ORDR, '등록·수정·삭제 기록만 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 1, MENU_NM = '접속기록 조회'
WHERE MENU_NO = 9100044;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 3, MENU_NM = '이상행위 감사',
MENU_DC = '비정상 접근 5유형 탐지 결과 조회'
WHERE MENU_NO = 9100049;
UPDATE COMTNMENUINFO SET UPPER_MENU_NO = 9100052, MENU_ORDR = 4, MENU_NM = '정기 점검 리포트'
WHERE MENU_NO = 9100048;
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100052 AS mno UNION ALL SELECT 9100053) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100050;
DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100050;
-- [검증] SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_ORDR,m.MENU_NM,HEX(m.MENU_NM)
-- FROM COMTNMENUINFO m WHERE m.MENU_NO=9100052 OR m.UPPER_MENU_NO=9100052
-- ORDER BY m.UPPER_MENU_NO, m.MENU_ORDR;
-- ===================================================================== -- =====================================================================
-- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합) -- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합)
-- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql -- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql
@ -1421,109 +1613,153 @@ BEGIN
FROM information_schema.views FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo'; WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo';
-- **★★ [2026-08-27 대소문자 보정] ★★**
-- REPLACE() 는 lower_case_table_names 설정과 무관하게 **항상 대소문자를 구분**한다.
-- 운영 DB(csv_structure_Dump20260520 실측)의 VIEW_DEFINITION 은 테이블명이 대문자
-- (`COMTNGNRLMBER`)인데, 종전 코드는 소문자(`comtngnrlmber`)를 찾고 있었다.
-- -> 운영에서 5개 REPLACE 가 전부 불발되고, 바뀌지 않은 정의로
-- CREATE OR REPLACE VIEW 가 **에러 없이 성공**해 USER_NM_HASH 없는 뉴가 남는다.
-- 아래처럼 실제 저장된 객체명을 information_schema 에서 읽어 검색문자열을 조립한다.
SET @n_gnr = NULL; SET @n_emp = NULL; SET @n_ent = NULL; SET @n_grp = NULL;
SET @v_master = NULL; SET @v_info = NULL;
SELECT TABLE_NAME INTO @n_gnr FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtngnrlmber' LIMIT 1;
SELECT TABLE_NAME INTO @n_emp FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtnemplyrinfo' LIMIT 1;
SELECT TABLE_NAME INTO @n_ent FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comtnentrprsmber' LIMIT 1;
SELECT TABLE_NAME INTO @n_grp FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'biz_group' LIMIT 1;
SELECT TABLE_NAME INTO @v_master FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnusermaster' LIMIT 1;
SELECT TABLE_NAME INTO @v_info FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnuserinfo' LIMIT 1;
IF @n_gnr IS NULL OR @n_emp IS NULL OR @n_ent IS NULL OR @n_grp IS NULL
OR @v_master IS NULL OR @v_info IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: base table or view not found (COMTNGNRLMBER/COMTNEMPLYRINFO/COMTNENTRPRSMBER/BIZ_GROUP/COMVNUSERMASTER/COMVNUSERINFO)';
END IF;
-- 1. comvnusermaster 재정의 -- 1. comvnusermaster 재정의
-- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컬럼 추가(MBER_NM_HASH) -- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컴럼 추가(MBER_NM_HASH)
-- USR/ENT/GRO 분기: USER_NM_HASH 컬럼 추가(NULL — 암호화 대상 아님) -- USR/ENT/GRO 분기: USER_NM_HASH 컴럼 추가(NULL - 암호화 대상 아님)
SET @def_master = ( SET @def_master = (
SELECT VIEW_DEFINITION FROM information_schema.views SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster' WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnusermaster'
); );
SET @def_master = REPLACE( SET @def_master = REPLACE(@def_master,
@def_master, CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`', CONCAT('`', @n_gnr, '`.`MBER_NM_ENC` AS `USER_NM`'));
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`' SET @def_master = REPLACE(@def_master,
); CONCAT(' end) AS `ADULT` from `', @n_gnr, '` where'),
SET @def_master = REPLACE( CONCAT(' end) AS `ADULT`,`', @n_gnr, '`.`MBER_NM_HASH` AS `USER_NM_HASH` from `', @n_gnr, '` where'));
@def_master, SET @def_master = REPLACE(@def_master,
' end) AS `ADULT` from `comtngnrlmber` where', CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_emp, '` union all'),
' end) AS `ADULT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` where' CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_emp, '` union all'));
); SET @def_master = REPLACE(@def_master,
SET @def_master = REPLACE( CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_ent, '` where'),
@def_master, CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_ent, '` where'));
'\'N\' AS `ADULT` from `comtnemplyrinfo` union all', SET @def_master = REPLACE(@def_master,
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo` union all' CONCAT('\'', 'N', '\'', ' AS `ADULT` from `', @n_grp, '` where'),
); CONCAT('\'', 'N', '\'', ' AS `ADULT`,NULL AS `USER_NM_HASH` from `', @n_grp, '` where'));
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `comtnentrprsmber` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` where'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `biz_group` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `biz_group` where'
);
SELECT SELECT
(LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences, (LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_master LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column, (@def_master LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_master NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed; (@def_master NOT LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%')) AS old_plaintext_reference_removed;
SET @sql_master = CONCAT('CREATE OR REPLACE VIEW comvnusermaster AS ', @def_master); -- ★ 하드 게이트: 치환이 하나라도 불발되면 여기서 중단한다.
-- (종전에는 위 SELECT 가 '보고용'이라 불발돼도 그대로 진행 -> 깨진 뉴가 남았다)
IF (LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') <> 4
OR @def_master NOT LIKE '%MBER_NM_ENC%'
OR @def_master LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: comvnusermaster rewrite incomplete (expected 4 USER_NM_HASH + MBER_NM_ENC + no plaintext MBER_NM). View NOT replaced.';
END IF;
SET @sql_master = CONCAT('CREATE OR REPLACE VIEW `', @v_master, '` AS ', @def_master);
PREPARE stmt_master FROM @sql_master; PREPARE stmt_master FROM @sql_master;
EXECUTE stmt_master; EXECUTE stmt_master;
DEALLOCATE PREPARE stmt_master; DEALLOCATE PREPARE stmt_master;
-- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 — 그대로 보존) -- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 - 그대로 보존)
SET @def_info = ( SET @def_info = (
SELECT VIEW_DEFINITION FROM information_schema.views SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo' WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) = 'comvnuserinfo'
); );
SET @def_info = REPLACE( SET @def_info = REPLACE(@def_info,
@def_info, CONCAT('`', @n_gnr, '`.`MBER_NM` AS `USER_NM`'),
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`', CONCAT('`', @n_gnr, '`.`MBER_NM_ENC` AS `USER_NM`'));
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`' SET @def_info = REPLACE(@def_info,
); CONCAT('`', @n_gnr, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_gnr, '` union all'),
SET @def_info = REPLACE( CONCAT('`', @n_gnr, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`', @n_gnr, '`.`MBER_NM_HASH` AS `USER_NM_HASH` from `', @n_gnr, '` union all'));
@def_info, SET @def_info = REPLACE(@def_info,
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtngnrlmber` union all', CONCAT('`', @n_grp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_grp, '` union all'),
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` union all' CONCAT('`', @n_grp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_grp, '` union all'));
); SET @def_info = REPLACE(@def_info,
SET @def_info = REPLACE( CONCAT('`', @n_ent, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_ent, '` union all'),
@def_info, CONCAT('`', @n_ent, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_ent, '` union all'));
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `biz_group` union all', SET @def_info = REPLACE(@def_info,
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `biz_group` union all' CONCAT('`', @n_emp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `', @n_emp, '`'),
); CONCAT('`', @n_emp, '`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `', @n_emp, '`'));
SET @def_info = REPLACE(
@def_info,
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnentrprsmber` union all',
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` union all'
);
SET @def_info = REPLACE(
@def_info,
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnemplyrinfo`',
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo`'
);
SELECT SELECT
(LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences, (LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_info LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column, (@def_info LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_info NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed; (@def_info NOT LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%')) AS old_plaintext_reference_removed;
SET @sql_info = CONCAT('CREATE OR REPLACE VIEW comvnuserinfo AS ', @def_info); -- ★ 하드 게이트 (comvnuserinfo)
IF (LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') <> 4
OR @def_info NOT LIKE '%MBER_NM_ENC%'
OR @def_info LIKE CONCAT('%`', @n_gnr, '`.`MBER_NM` AS `USER_NM`%') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PART4 ABORT: comvnuserinfo rewrite incomplete (expected 4 USER_NM_HASH + MBER_NM_ENC + no plaintext MBER_NM). View NOT replaced.';
END IF;
SET @sql_info = CONCAT('CREATE OR REPLACE VIEW `', @v_info, '` AS ', @def_info);
PREPARE stmt_info FROM @sql_info; PREPARE stmt_info FROM @sql_info;
EXECUTE stmt_info; EXECUTE stmt_info;
DEALLOCATE PREPARE stmt_info; DEALLOCATE PREPARE stmt_info;
-- 3. 반영 후 검증 -- 3. 반영 후 검증
-- ★ [2026-08-27] 이 검증 SELECT 들도 뉴 이름을 직접 참조하므로 동적 SQL 로 바꿈.
-- 운영(lower_case_table_names=0, 2026-08-27 실측)에서는 소문자 comvnusermaster 가
-- 존재하지 않아 1146(Table doesn't exist) 로 프로시저가 중단된다.
-- — 뉴 교체는 이미 성공한 뒤라, 성공한 컷오버가 실패처럼 보이게 된다.
-- (information_schema 비교는 콜레이션상 대소문자 무관이라 LOWER() 로 충분하다)
SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION
FROM information_schema.columns FROM information_schema.columns
WHERE TABLE_SCHEMA = DATABASE() WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('comvnusermaster', 'comvnuserinfo') AND LOWER(TABLE_NAME) IN ('comvnusermaster', 'comvnuserinfo')
AND COLUMN_NAME IN ('USER_NM', 'USER_SE', 'USER_GUBUN', 'USER_NM_HASH') AND COLUMN_NAME IN ('USER_NM', 'USER_SE', 'USER_GUBUN', 'USER_NM_HASH')
ORDER BY TABLE_NAME, ORDINAL_POSITION; ORDER BY TABLE_NAME, ORDINAL_POSITION;
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample SET @v_chk = CONCAT(
FROM comvnusermaster WHERE USER_SE = 'GNR' LIMIT 1; 'SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample,',
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH ' LEFT(USER_NM_HASH, 20) AS hash_sample FROM `', @v_master, '`',
FROM comvnusermaster WHERE USER_SE = 'USR' LIMIT 1; ' WHERE USER_SE = ''GNR'' LIMIT 1');
SELECT USER_SE, COUNT(*) FROM comvnusermaster GROUP BY USER_SE; PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample SET @v_chk = CONCAT(
FROM comvnuserinfo WHERE USER_GUBUN = 'GNR' LIMIT 1; 'SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH',
SELECT USER_GUBUN, COUNT(*) FROM comvnuserinfo GROUP BY USER_GUBUN; ' FROM `', @v_master, '` WHERE USER_SE = ''USR'' LIMIT 1');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT('SELECT USER_SE, COUNT(*) AS cnt FROM `', @v_master, '` GROUP BY USER_SE');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT(
'SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample,',
' LEFT(USER_NM_HASH, 20) AS hash_sample FROM `', @v_info, '`',
' WHERE USER_GUBUN = ''GNR'' LIMIT 1');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
SET @v_chk = CONCAT('SELECT USER_GUBUN, COUNT(*) AS cnt FROM `', @v_info, '` GROUP BY USER_GUBUN');
PREPARE stmt_chk FROM @v_chk; EXECUTE stmt_chk; DEALLOCATE PREPARE stmt_chk;
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@ -1532,7 +1768,19 @@ CALL PRC_PRIVACY_MBER_NM_VIEW_CUTOVER();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER; DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER;
-- ── [롤백] ── -- ── [롤백] ──
-- SELECT view_definition FROM privacy_view_def_backup WHERE view_name='comvnusermaster' ORDER BY backup_at DESC LIMIT 1; -- ★ 운영은 뷰 이름이 대문자(COMVNUSERMASTER)다. 소문자로 CREATE OR REPLACE 하면
-- -- 위 값을 CREATE OR REPLACE VIEW comvnusermaster AS <value> 로 재실행(comvnuserinfo 동일) -- lower_case_table_names=0 인 환경에서 **원본을 되돌리는 게 아니라 소문자 뷰가 새로
-- 하나 더 생긴다**. 아래처럼 실제 저장된 이름을 조회해서 그 이름으로 재실행할 것.
--
-- -- ① 실제 저장된 뷰 이름 확인
-- SELECT TABLE_NAME FROM information_schema.views
-- WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) IN ('comvnusermaster','comvnuserinfo');
--
-- -- ② 백업된 원본 정의 조회
-- SELECT view_definition FROM privacy_view_def_backup
-- WHERE view_name = 'comvnusermaster' ORDER BY backup_at DESC LIMIT 1;
--
-- -- ③ ①의 실제 이름 + ②의 값으로 재실행 (comvnuserinfo 동일)
-- -- CREATE OR REPLACE VIEW `COMVNUSERMASTER` AS <②의 value>
-- ############################## END ############################## -- ############################## END ##############################