개인정보보호 기능개선 2차 구현 — 접속기록 안정화·솔트 인증·관리자 화면 4종

접속기록(SFR-004/#33):
- AccessLogQueue 포화 방어. 종전 offer(timeout) 무한 반복은 컨슈머 정지 시
  요청 스레드가 영구 대기해 WAS 스레드 풀 고갈 → 사이트 전면 정지로 이어졌다.
  대기를 3회로 제한하고 포화 차단기(saturated)를 도입해 지속 포화 시 즉시 판정한다.
- 유실은 조용히 넘기지 않는다. recordDrop 이 누적 집계 + 초기 20건/이후 100건 표본
  ERROR 로그로 남긴다(법정 보관 대상이므로 사후 추적 가능해야 함).
- 컨슈머 루프가 Exception 이 아닌 Throwable 까지 흡수한다. Error 하나로 컨슈머가
  죽으면 running=true 인 채 이후 전 요청이 포화 대기에 빠졌다.
- 자체 데몬 워치독(30초) 추가 — 컨슈머 사망 시 재기동, 적체 경고, 차단기 자동 해제.
  task:annotation-driven 활성화 여부와 무관해야 하므로 @Scheduled 를 쓰지 않는다.
- 해시체인 순번 보장을 위한 단일 컨슈머 전제는 유지(병렬화 금지).

인증정보 보호(SER-001/SER-003/#27):
- EgovFileScrty 사용자별 랜덤 솔트(S1: 형식) 도입, matches() 로 검증 일원화 —
  기존 무솔트 계정과 공존한다.
- 비밀번호 변경 시 현재 비밀번호 검증 복구. 종전에는 DB 값을 자기 자신과 비교해
  항상 true 였고, 입력한 oldPassword 가 어디에도 쓰이지 않았다.
- 평문 비밀번호 println 제거.

관리자 화면: 접속기록 조회, 업무구분 매핑 관리, 권한변경 이력, 미사용계정 현황.
계정 파기(AccountDelete) 서비스 및 DDL/DML 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-07 15:04:27 +09:00
parent 65bbe591d1
commit f2029a00a0
83 changed files with 5038 additions and 279 deletions

7
.gitignore vendored
View File

@ -9,3 +9,10 @@
.classpath .classpath
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# 배포 작업 산출물 — 저장소 이력 대상 아님 (2026-08-07)
_deploy_backup_*/
_workspace/
_workspace_privacy/
dump-csv-*.sql
.idea/

View File

@ -34,9 +34,16 @@
**트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함. **트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함.
## 하네스: 델타 배포 (공통 규칙)
**배포는 WAR 재전개가 아니라 델타(변경 파일) 반영이다.** 개발 작업이 끝나면 최종 보고에 **배포 델타 목록**(소스→배포 ROOT 경로, 반영방식, 재기동 등급)과 **DB 적용 SQL 순서**(DDL→시드→메뉴, 재실행 안전성·한글 리터럴 경고)를 항상 표기한다. 이 표기가 없으면 작업 미완료다.
**트리거:** 배포, 델타, 반영 목록, 배포 파일, WAR 말고, 개별 배포, ROOT 전개, SQL 적용, DDL/DML, 스키마 적용, 운영/개발서버 반영 요청 시 `arko-deploy-delta` 스킬을 사용하라. 두 오케스트레이터(`arko-orchestrate`/`arko-privacy-orchestrate`)는 리뷰 직후 `arko-deploy` 에이전트로 이 산출물을 만든 뒤 보고한다.
## 변경 이력 ## 변경 이력
| 날짜 | 변경 내용 | 대상 | 사유 | | 날짜 | 변경 내용 | 대상 | 사유 |
|------|----------|------|------| |------|----------|------|------|
| 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 | | 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 |
| 2026-08-05 | 델타 배포 하네스 추가 | `arko-deploy`(신규 에이전트), `arko-deploy-delta`(신규 스킬 + 매니페스트/SQL번들/반영 스크립트 3종), `arko-orchestrate`(Phase 6 배포 산출물 신설), `arko-privacy-orchestrate`(Phase 5 신설), `arko-backend`/`arko-frontend`/`arko-api-specialist`/`arko-privacy-authctrl`/`arko-privacy-auditlog`(배포 델타 기록 의무), `arko-reviewer`(9절 배포 가능성 점검) | WAR 전체가 아닌 델타로 배포하므로 반영 파일·DDL/DML을 명시적 산출물로 강제 |
| 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 | | 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 |

View File

@ -0,0 +1,50 @@
package egovframework.com.cmm.privacy.account;
import java.util.List;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 장기 미사용 계정 개인정보 파기 DAO (SFR-002 / 개발항목 #12).
*
* <p>statement 는 {@code PrivacyAccountDelete_SQL_Mysql.xml}(namespace {@code AccountDeleteDAO}),
* sql-map-config 등록. 보존기간은 SQL 내부에서 ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 를
* 직접 조회하므로 자바 측 하드코딩이 없다. 1회 처리건수 상한(200)도 SQL 측에 있다.</p>
*/
@Repository("accountDeleteDAO")
public class AccountDeleteDAO extends EgovComAbstractDAO {
/** 파기 대상(일반회원) — 휴면확정 후 보존기간 경과분, 최대 200건. */
@SuppressWarnings("unchecked")
public List<AccountDeleteTargetVO> selectExpiredGnrAccounts() {
return list("AccountDeleteDAO.selectExpiredGnrAccounts", null);
}
/** 파기 대상(개인정보취급자) — 휴면확정 후 보존기간 경과분, 최대 200건. */
@SuppressWarnings("unchecked")
public List<AccountDeleteTargetVO> selectExpiredEmpAccounts() {
return list("AccountDeleteDAO.selectExpiredEmpAccounts", null);
}
/** 일반회원 개인정보 파기(마스킹 + MBER_STTUS='D'). */
public int anonymizeGnrMember(String mberId) {
return update("AccountDeleteDAO.anonymizeGnrMember", mberId);
}
/** 개인정보취급자 개인정보 파기(마스킹 + EMPLYR_STTUS_CODE='D'). */
public int anonymizeEmplyr(String emplyrId) {
return update("AccountDeleteDAO.anonymizeEmplyr", emplyrId);
}
/** 파기 완료 계정을 휴면 추적 대상에서 제거(재처리 방지). */
public int deleteDiapauseRow(String mberId) {
return delete("AccountDeleteDAO.deleteDiapauseRow", mberId);
}
/** 파기 이력 적재(ACCOUNT_DELETE_HISTORY). */
public void insertDeleteHistory(AccountDeleteHistoryVO vo) {
insert("AccountDeleteDAO.insertDeleteHistory", vo);
}
}

View File

@ -0,0 +1,40 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 장기 미사용 계정 개인정보 파기 이력 VO — ACCOUNT_DELETE_HISTORY 매핑 (SFR-002 / #12).
*/
public class AccountDeleteHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
private long delId;
/** 파기 대상 계정ID */
private String mberId;
/** 회원유형 GNR:일반회원 EMP:개인정보취급자 */
private String mberTyCode;
/** 고유식별자(파기 시점 스냅샷) */
private String esntlId;
/** 휴면 확정 기준일시 */
private String diapauseDt;
/** 파기 시점 적용된 보존기간(정책값 스냅샷, 일) */
private int preserveDay;
/** 파기 처리 일시 */
private String delDt;
public long getDelId() { return delId; }
public void setDelId(long delId) { this.delId = delId; }
public String getMberId() { return mberId; }
public void setMberId(String mberId) { this.mberId = mberId; }
public String getMberTyCode() { return mberTyCode; }
public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; }
public String getEsntlId() { return esntlId; }
public void setEsntlId(String esntlId) { this.esntlId = esntlId; }
public String getDiapauseDt() { return diapauseDt; }
public void setDiapauseDt(String diapauseDt) { this.diapauseDt = diapauseDt; }
public int getPreserveDay() { return preserveDay; }
public void setPreserveDay(int preserveDay) { this.preserveDay = preserveDay; }
public String getDelDt() { return delDt; }
public void setDelDt(String delDt) { this.delDt = delDt; }
}

View File

@ -0,0 +1,15 @@
package egovframework.com.cmm.privacy.account;
/**
* 장기 미사용 계정 개인정보 파기 서비스 (SFR-002 / 개발항목 #12).
*/
public interface AccountDeleteService {
/**
* 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY) 경과 휴면계정의 개인정보를 파기한다.
*
* @return 파기 처리 건수
* @throws Exception 파기 오류
*/
int purgeExpiredAccounts() throws Exception;
}

View File

@ -0,0 +1,89 @@
package egovframework.com.cmm.privacy.account;
import java.util.List;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 장기 미사용 계정 개인정보 파기 서비스 구현 (SFR-002 / 개발항목 #12).
*
* <p><b>보존기간</b>은 코드에 상수로 두지 않고 {@code ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY}
* 를 SQL 실행 시점에 조회해 적용한다(정책 화면 #13에서 변경 시 즉시 반영).</p>
*
* <p><b>파기 방식</b>은 행 DELETE 가 아니라 PII 컬럼 마스킹 + 상태코드 'D' 전환이다.
* 게시글·활동확인·마일리지 등 다수 테이블이 MBER_ID/EMPLYR_ID 를 FK 없이 참조하고 있어
* 행을 지우면 해당 이력이 고아가 되기 때문이며, 기존 회원탈퇴 컨벤션과도 동일하다.</p>
*
* <p><b>처리량 상한</b>: 배치가 장기간 정지했다 재가동되는 날 백로그가 한꺼번에 처리되지
* 않도록 1회 200건으로 제한(SQL의 LIMIT). 남은 대상은 다음 배치에서 이어 처리한다.</p>
*
* <p>계정 1건 처리는 마스킹→휴면행 제거→이력적재 순이며, 개별 실패는 로깅 후 계속(fail-safe)
* 하여 한 건의 오류가 배치 전체를 막지 않는다.</p>
*/
@Service("accountDeleteService")
public class AccountDeleteServiceImpl implements AccountDeleteService {
private static final Logger LOG = LoggerFactory.getLogger(AccountDeleteServiceImpl.class);
private static final String TY_GNR = "GNR";
private static final String TY_EMP = "EMP";
@Resource(name = "accountDeleteDAO")
private AccountDeleteDAO accountDeleteDAO;
@Resource(name = "accountMngPolicyService")
private AccountMngPolicyService accountMngPolicyService;
@Override
public int purgeExpiredAccounts() throws Exception {
// 파기는 되돌릴 수 없으므로 정책 플래그(ACCOUNT_MNG_POLICY.PURGE_USE_YN)가 'Y'일 때만 수행.
// 기본값 'N' — 관리자가 계정 관리 정책 화면에서 명시적으로 켜야 한다.
AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy();
if (policy == null || !"Y".equals(policy.getPurgeUseYn())) {
LOG.info("[개인정보/#12] 파기 배치 미사용(PURGE_USE_YN != 'Y') — 처리 없이 종료");
return 0;
}
int purged = 0;
purged += purge(accountDeleteDAO.selectExpiredGnrAccounts(), TY_GNR);
purged += purge(accountDeleteDAO.selectExpiredEmpAccounts(), TY_EMP);
LOG.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — {}건", Integer.valueOf(purged));
return purged;
}
/** 유형별 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */
private int purge(List<AccountDeleteTargetVO> targets, String mberTyCode) {
if (targets == null || targets.isEmpty()) {
return 0;
}
int cnt = 0;
for (AccountDeleteTargetVO t : targets) {
try {
if (TY_EMP.equals(mberTyCode)) {
accountDeleteDAO.anonymizeEmplyr(t.getMberId());
} else {
accountDeleteDAO.anonymizeGnrMember(t.getMberId());
}
accountDeleteDAO.deleteDiapauseRow(t.getMberId());
AccountDeleteHistoryVO hist = new AccountDeleteHistoryVO();
hist.setMberId(t.getMberId());
hist.setMberTyCode(mberTyCode);
hist.setEsntlId(t.getEsntlId());
hist.setDiapauseDt(t.getDiapauseDt());
hist.setPreserveDay(t.getPreserveDay());
accountDeleteDAO.insertDeleteHistory(hist);
cnt++;
} catch (Exception e) {
LOG.error("[개인정보/#12] 계정 파기 실패 — 건너뜀 (id={}, ty={}): {}",
new Object[] { t.getMberId(), mberTyCode, e.getMessage() });
}
}
return cnt;
}
}

View File

@ -0,0 +1,32 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 장기 미사용 계정 파기 대상 VO (SFR-002 / 개발항목 #12).
*
* <p>휴면 확정('P') 후 정책상 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이
* 경과한 계정. preserveDay 는 조회 시점의 정책값으로, 파기 이력에 스냅샷으로 남긴다.</p>
*/
public class AccountDeleteTargetVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 파기 대상 계정ID(일반회원 MBER_ID / 취급자 EMPLYR_ID) */
private String mberId;
/** 고유식별자(ESNTL_ID) */
private String esntlId;
/** 휴면 확정 기준일시 */
private String diapauseDt;
/** 조회 시점 적용된 보존기간(일) */
private int preserveDay;
public String getMberId() { return mberId; }
public void setMberId(String mberId) { this.mberId = mberId; }
public String getEsntlId() { return esntlId; }
public void setEsntlId(String esntlId) { this.esntlId = esntlId; }
public String getDiapauseDt() { return diapauseDt; }
public void setDiapauseDt(String diapauseDt) { this.diapauseDt = diapauseDt; }
public int getPreserveDay() { return preserveDay; }
public void setPreserveDay(int preserveDay) { this.preserveDay = preserveDay; }
}

View File

@ -1,7 +1,9 @@
package egovframework.com.cmm.privacy.account; package egovframework.com.cmm.privacy.account;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import javax.annotation.Resource; import javax.annotation.Resource;
@ -13,12 +15,19 @@ import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
import egovframework.com.cop.ems.service.SndngMailVO; import egovframework.com.cop.ems.service.SndngMailVO;
/** /**
* 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day + 관리자 알림). * 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day 관리자 알림).
* *
* <p>정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해 * <p>정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해
* 본인과 관리자(ADMIN_EMAIL)에게 사전 알림 메일을 발송한다. 기존 휴면 배치의 * 관리자(ADMIN_EMAIL)에게 배치 1회 실행당 전체 대상을 모은 요약(digest) 메일
* 유예/확정 본인 메일(EgovLoginServiceImpl.processDiapauseList)과 독립적으로 동작하며, * 1통으로 발송한다. 대상자가 많을 때 관리자 메일함이 건별 메일로 도배되는 것을
* 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.</p> * 막기 위함(2026-07-28 개선). 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.</p>
*
* <p><b>2026-07-30 본인 발송 제거</b>: 발주 요구사항(Ⅲ-2.3, 요구사항 #4)은
* '비활성화 전 <b>관리자</b> 알림 D-30/D-7/D-Day'이며 대상자 본인 개별 통지는 요건이
* 아니다. 대상 5만건 규모에서 본인 건별 발송이 배치 처리량을 지배해 일일 상한을
* 200건으로 묶어야 했고(휴면 전환 소진에 수년 소요), 이를 해소하기 위해 본인 발송을
* 걷어냈다. 휴면 확정 체인의 본인 메일 2종도 함께 제거
* (EgovLoginServiceImpl.processDiapauseList 주석 참조).</p>
*/ */
@Service("dormantAlertService") @Service("dormantAlertService")
public class DormantAlertServiceImpl implements DormantAlertService { public class DormantAlertServiceImpl implements DormantAlertService {
@ -46,6 +55,11 @@ public class DormantAlertServiceImpl implements DormantAlertService {
String adminEmail = policy.getAdminEmail(); String adminEmail = policy.getAdminEmail();
int sent = 0; int sent = 0;
int targetCnt = 0;
// 단계별 대상자를 모아두었다가 관리자에게는 배치 종료 후 요약 1통만 발송한다.
Map<Integer, List<DormantAccountStatusVO>> stageTargets =
new LinkedHashMap<Integer, List<DormantAccountStatusVO>>();
for (Integer before : stages) { for (Integer before : stages) {
List<DormantAccountStatusVO> targets; List<DormantAccountStatusVO> targets;
@ -55,14 +69,19 @@ public class DormantAlertServiceImpl implements DormantAlertService {
LOG.error("[개인정보/#10] 사전알림 대상 조회 오류(D-{}) — 건너뜀: {}", before, e.getMessage()); LOG.error("[개인정보/#10] 사전알림 대상 조회 오류(D-{}) — 건너뜀: {}", before, e.getMessage());
continue; continue;
} }
if (targets == null) { if (targets == null || targets.isEmpty()) {
continue; continue;
} }
for (DormantAccountStatusVO t : targets) { stageTargets.put(before, targets);
sent += sendOne(t, before.intValue(), adminEmail); targetCnt += targets.size();
} }
if (adminEmail != null && adminEmail.trim().length() > 0 && !stageTargets.isEmpty()) {
sent += sendAdminDigest(stageTargets, adminEmail);
} }
LOG.info("[개인정보/#10] 휴면 사전알림 발송 완료 — {}건", Integer.valueOf(sent));
LOG.info("[개인정보/#10] 휴면 사전알림 완료 — 대상 {}건, 관리자 요약 {}통 발송",
Integer.valueOf(targetCnt), Integer.valueOf(sent));
return sent; return sent;
} }
@ -73,21 +92,30 @@ public class DormantAlertServiceImpl implements DormantAlertService {
} }
} }
/** 본인 + 관리자 발송(각각 fail-safe). 발송 성공 건수 반환. */ /** 관리자 요약 발송(fail-safe). 배치 1회 실행당 단계 무관 1통. 발송 성공 시 1, 실패 시 0. */
private int sendOne(DormantAccountStatusVO t, int before, String adminEmail) { private int sendAdminDigest(Map<Integer, List<DormantAccountStatusVO>> stageTargets, String adminEmail) {
int cnt = 0; int totalCount = 0;
String dLabel = before == 0 ? "D-Day" : ("D-" + before); for (List<DormantAccountStatusVO> targets : stageTargets.values()) {
String subject = "[문화품앗이] 장기 미접속 계정 휴면 전환 사전 안내 (" + dLabel + ")"; totalCount += targets.size();
String body = (t.getUserNm() == null ? "" : t.getUserNm()) + "(" + t.getAccountId()
+ ") 계정이 미접속 " + t.getNoConnectDays() + "일로 곧 휴면 전환됩니다. 로그인 시 유지됩니다.";
// 본인 발송(대상 이메일이 조회 VO에 없으면 관리자에게만 — 취급자 메일 컬럼 미보유 방어)
cnt += sendMail(t.getAccountId(), subject, body);
// 관리자 발송(#10 핵심 — 관리자 알림 추가)
if (adminEmail != null && adminEmail.trim().length() > 0) {
cnt += sendMail(adminEmail, subject + " - 관리자통지", body);
} }
return cnt;
StringBuilder body = new StringBuilder();
body.append("휴면 전환 사전 알림 대상 총 ").append(totalCount).append("건\n\n");
for (Map.Entry<Integer, List<DormantAccountStatusVO>> entry : stageTargets.entrySet()) {
int before = entry.getKey().intValue();
String dLabel = before == 0 ? "D-Day" : ("D-" + before);
List<DormantAccountStatusVO> targets = entry.getValue();
body.append("[").append(dLabel).append("] ").append(targets.size()).append("건\n");
for (DormantAccountStatusVO t : targets) {
body.append(" - ").append(t.getUserNm() == null ? "" : t.getUserNm())
.append("(").append(t.getAccountId()).append(") 미접속 ")
.append(t.getNoConnectDays()).append("일\n");
}
body.append("\n");
}
String subject = "[문화품앗이] 휴면 전환 사전 알림 요약 (총 " + totalCount + "건)";
return sendMail(adminEmail, subject, body.toString());
} }
private int sendMail(String recipient, String subject, String body) { private int sendMail(String recipient, String subject, String body) {

View File

@ -5,6 +5,7 @@ import java.util.List;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy; import javax.annotation.PreDestroy;
@ -23,11 +24,23 @@ import org.springframework.stereotype.Component;
* *
* <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내 * <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내
* {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상 * {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상
* 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).</p> * 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).
* 컨슈머 재기동·종료 flush 등 예외 경로에서도 {@code persistNow}가 {@code synchronized}라
* 두 스레드가 동시에 봉인·적재하는 일은 없다.</p>
* *
* <p><b>드롭 금지·백프레셔:</b> 큐가 가득 차면 레코드를 버리지 않고 {@code offer(timeout)}로 * <p><b>백프레셔 상한(2026-07-30):</b> 종전에는 큐가 가득 차면 {@code offer(timeout)}를
* 대기(백프레셔)한다. enqueue는 업무 트랜잭션 커밋 이후(Aspect의 finally) 호출되므로 짧은 * <u>무한 반복</u>해 대기했다. 컨슈머가 정지하면(예: 파티션 DROP·전체 무결성 검증 중 테이블
* 대기가 업무 데이터 정합성을 해치지 않는다.</p> * 잠금으로 {@code persistNow}가 블로킹, 또는 컨슈머 스레드 사망) 요청 스레드가 이 지점에서
* 영구 대기하고, 그 상태가 지속되면 WAS 스레드 풀이 고갈돼 <b>사이트 전체가 정지</b>한다.
* 복구 경로가 없는 구조라 대기를 {@link #MAX_OFFER_ATTEMPT}회로 제한한다. 상한을 넘기면
* 해당 1건을 포기하되 <b>유실 사실과 식별 정보를 ERROR 로그로 남기고 누적 건수를 집계</b>한다
* (접속기록은 법정 보관 대상이므로 조용한 유실은 금지 — 사후 추적 가능하게 남긴다).</p>
*
* <p><b>컨슈머 감시(2026-07-30):</b> 적재 루프가 {@code Throwable}까지 흡수해 스스로 죽지
* 않지만, 그것만으로는 외부 요인(스레드 강제 종료 등)에 대비할 수 없다. 별도 감시 스레드가
* 주기적으로 컨슈머 생존과 큐 적체를 점검하고, 죽어 있으면 재기동한다. 감시는 스케줄러
* 설정({@code task:annotation-driven})에 의존하지 않는 자체 데몬 스레드다 — 스케줄러 활성화
* 여부와 무관하게 동작해야 하기 때문이다.</p>
* *
* <p><b>종료 flush:</b> 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).</p> * <p><b>종료 flush:</b> 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).</p>
* *
@ -39,12 +52,24 @@ public class AccessLogQueue {
private static final Log LOG = LogFactory.getLog(AccessLogQueue.class); private static final Log LOG = LogFactory.getLog(AccessLogQueue.class);
/** 큐 용량(백프레셔 경계). 초과 시 드롭하지 않고 대기한다. */ /** 큐 용량(백프레셔 경계). */
private static final int CAPACITY = 10000; private static final int CAPACITY = 10000;
/** enqueue 대기 1회 최대 시간(초). 초과하면 경고 로깅 후 재대기(무한 유실 방지). */ /** enqueue 대기 1회 최대 시간(초). */
private static final long OFFER_WAIT_SEC = 5L; private static final long OFFER_WAIT_SEC = 5L;
/** enqueue 대기 최대 횟수 — 초과 시 해당 건을 포기한다(요청 스레드 영구 대기 방지). */
private static final int MAX_OFFER_ATTEMPT = 3;
/** 종료 시 컨슈머 스레드 join 최대 대기(ms). */ /** 종료 시 컨슈머 스레드 join 최대 대기(ms). */
private static final long SHUTDOWN_JOIN_MS = 10000L; private static final long SHUTDOWN_JOIN_MS = 10000L;
/** 컨슈머 생존·적체 감시 주기(ms). */
private static final long WATCHDOG_INTERVAL_MS = 30000L;
/** 적체 경고 임계치(용량 대비). 이 이상 쌓이면 감시 스레드가 경고한다. */
private static final int BACKLOG_WARN_SIZE = (int) (CAPACITY * 0.8);
/** 포화 해제(차단기 복구) 임계치 — 잔량이 이 수준까지 빠지면 정상 경로로 복귀한다. */
private static final int SATURATION_CLEAR_SIZE = CAPACITY / 2;
/** 유실 로그 상세를 남기는 초기 건수(이후는 표본 기록 — 로그 폭주 방지). */
private static final long DROP_LOG_HEAD = 20L;
/** 초기 건수 이후 유실 로그 표본 주기. */
private static final long DROP_LOG_SAMPLE = 100L;
private final BlockingQueue<PersonalDataAccessLogVO> queue = private final BlockingQueue<PersonalDataAccessLogVO> queue =
new LinkedBlockingQueue<PersonalDataAccessLogVO>(CAPACITY); new LinkedBlockingQueue<PersonalDataAccessLogVO>(CAPACITY);
@ -53,54 +78,127 @@ public class AccessLogQueue {
private PersonalDataLogService personalDataLogService; private PersonalDataLogService personalDataLogService;
private volatile boolean running = false; private volatile boolean running = false;
private Thread consumer; private volatile Thread consumer;
private volatile Thread watchdog;
/** 큐 포화로 포기한 접속기록 누적 건수(운영 점검용). */
private final AtomicLong droppedCount = new AtomicLong(0L);
/** 컨슈머 재기동 누적 횟수(운영 점검용). */
private final AtomicLong consumerRestartCount = new AtomicLong(0L);
/**
* 포화 차단기. 한 번 포기가 발생하면 켜지고, 잔량이 {@link #SATURATION_CLEAR_SIZE}까지
* 빠지면 감시 스레드가 끈다. 켜져 있는 동안 {@link #enqueue}는 대기 없이 즉시 판정한다 —
* 포화가 지속되는 상황에서 요청마다 최대 {@code OFFER_WAIT_SEC × MAX_OFFER_ATTEMPT}초를
* 물게 하면 사이트 전체가 느려지기 때문이다.
*/
private volatile boolean saturated = false;
@PostConstruct @PostConstruct
public void start() { public void start() {
running = true; running = true;
consumer = new Thread(new Runnable() { startConsumer();
startWatchdog();
}
/**
* 단일 컨슈머 스레드를 기동한다. 이미 살아 있으면 아무것도 하지 않는다
* (해시체인 순번 보장 전제상 컨슈머는 반드시 하나여야 하므로 동기화한다).
*/
private synchronized void startConsumer() {
if (!running) {
return;
}
Thread cur = consumer;
if (cur != null && cur.isAlive()) {
return;
}
Thread t = new Thread(new Runnable() {
public void run() { public void run() {
consumeLoop(); consumeLoop();
} }
}, "pdal-accesslog-consumer"); }, "pdal-accesslog-consumer");
consumer.setDaemon(true); t.setDaemon(true);
consumer.start(); consumer = t;
t.start();
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 기동 — 단일 스레드, capacity=" + CAPACITY); LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 기동 — 단일 스레드, capacity=" + CAPACITY);
} }
/** 컨슈머 생존·큐 적체 감시 스레드를 기동한다. */
private synchronized void startWatchdog() {
Thread cur = watchdog;
if (cur != null && cur.isAlive()) {
return;
}
Thread t = new Thread(new Runnable() {
public void run() {
watchdogLoop();
}
}, "pdal-accesslog-watchdog");
t.setDaemon(true);
watchdog = t;
t.start();
LOG.info("[개인정보/#33] 접속기록 컨슈머 감시 기동 — 주기 " + (WATCHDOG_INTERVAL_MS / 1000) + "초");
}
/** /**
* 접속기록 1건을 큐에 적재한다(비동기, 드롭 금지). * 접속기록 1건을 큐에 적재한다(비동기).
* 큐 포화 시 백프레셔로 대기한다. 종료 진행 중이면 인라인 적재로 유실을 방지한다. *
* <p>큐가 가득 차면 {@link #MAX_OFFER_ATTEMPT}회까지만 대기한다. 그 이상은 요청 스레드를
* 붙잡지 않고 포기하며, 유실 사실을 ERROR로 남긴다. 종료 진행 중이면 인라인 적재한다
* ({@code persistNow}가 {@code synchronized}라 컨슈머와 동시 실행돼도 순번은 안전).</p>
*/ */
public void enqueue(PersonalDataAccessLogVO vo) { public void enqueue(PersonalDataAccessLogVO vo) {
if (vo == null) { if (vo == null) {
return; return;
} }
try { if (!running) {
boolean queued = false; // 종료 중 — 큐에 넣어도 처리되지 않을 수 있으므로 인라인 적재로 유실을 막는다.
while (running && !queued) {
queued = queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS);
if (!queued) {
LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size()
+ ") — 컨슈머 지연. 드롭하지 않고 계속 대기.");
}
}
if (!queued) {
// 종료 중(running=false) — 컨슈머가 이미 멈췄으므로 단일 라이터 보장 하에 인라인 적재.
personalDataLogService.persistNow(vo); personalDataLogService.persistNow(vo);
return;
} }
// 차단기 ON — 대기하지 않고 즉시 판정(요청 지연 확산 차단).
if (saturated) {
if (!queue.offer(vo)) {
recordDrop(vo);
}
return;
}
try {
for (int attempt = 1; attempt <= MAX_OFFER_ATTEMPT; attempt++) {
if (!running) {
personalDataLogService.persistNow(vo);
return;
}
if (queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS)) {
return;
}
LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size() + ") — 컨슈머 지연, 대기 "
+ attempt + "/" + MAX_OFFER_ATTEMPT);
}
// 상한 초과 — 요청 스레드를 더 붙잡지 않는다. 무한 대기로 WAS 스레드를 고갈시키는 것보다
// 1건 유실이 낫다는 판단(단, recordDrop 로그로 사후 추적 가능하게 남긴다).
saturated = true;
LOG.error("[개인정보/#33] 접속기록 큐 포화 차단기 ON — 이후 대기 없이 즉시 판정한다. "
+ "잔량이 " + SATURATION_CLEAR_SIZE + " 이하로 빠지면 자동 복구.");
recordDrop(vo);
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도.", ie); LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도. " + describe(vo), ie);
try { try {
personalDataLogService.persistNow(vo); personalDataLogService.persistNow(vo);
} catch (Exception e) { } catch (Exception e) {
LOG.error("[개인정보/#33] 인라인 적재 실패", e); LOG.error("[개인정보/#33] 인라인 적재 실패 — " + describe(vo), e);
} }
} }
} }
/** 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장). */ /**
* 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장).
*
* <p>{@code Throwable}까지 흡수한다. {@code Exception}만 잡던 종전 구조에서는
* {@code Error}(OOM 등) 하나로 컨슈머가 죽고, {@code running}은 true로 남아 이후 모든
* {@link #enqueue}가 포화 대기에 빠졌다.</p>
*/
private void consumeLoop() { private void consumeLoop() {
while (running || !queue.isEmpty()) { while (running || !queue.isEmpty()) {
try { try {
@ -111,20 +209,99 @@ public class AccessLogQueue {
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
// 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다. // 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다.
} catch (Exception e) { } catch (Throwable t) {
LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", e); LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", t);
} }
} }
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료."); LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료.");
} }
/** 컨슈머 생존과 큐 적체를 주기 점검한다. 컨슈머가 죽어 있으면 재기동한다. */
private void watchdogLoop() {
while (running) {
try {
Thread.sleep(WATCHDOG_INTERVAL_MS);
if (!running) {
break;
}
Thread cur = consumer;
if (cur == null || !cur.isAlive()) {
long n = consumerRestartCount.incrementAndGet();
LOG.error("[개인정보/#33] 접속기록 컨슈머 사망 감지 — 재기동(누적 " + n + "회). 큐 잔량="
+ queue.size() + ", 누적 포기=" + droppedCount.get());
startConsumer();
} else if (queue.size() >= BACKLOG_WARN_SIZE) {
LOG.warn("[개인정보/#33] 접속기록 큐 적체 경고 — size=" + queue.size() + "/" + CAPACITY
+ ". 이 상태가 지속되면 포화로 유실이 시작된다(누적 포기=" + droppedCount.get() + "건).");
}
// 차단기 복구 — 잔량이 절반 이하로 빠졌으면 정상(대기 허용) 경로로 되돌린다.
if (saturated && queue.size() <= SATURATION_CLEAR_SIZE) {
saturated = false;
LOG.info("[개인정보/#33] 접속기록 큐 포화 차단기 OFF — 정상 경로 복귀(size=" + queue.size()
+ ", 누적 포기=" + droppedCount.get() + "건).");
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
} catch (Throwable t) {
LOG.error("[개인정보/#33] 접속기록 컨슈머 감시 중 예외(계속)", t);
}
}
LOG.info("[개인정보/#33] 접속기록 컨슈머 감시 종료.");
}
/**
* 유실 1건을 집계하고 기록한다.
*
* <p>접속기록은 법정 보관 대상이라 조용히 버리지 않는다. 다만 포화가 지속되면 유실 로그가
* 그대로 로그 폭주가 되므로, 초기 {@link #DROP_LOG_HEAD}건은 전부 남기고 이후에는
* {@link #DROP_LOG_SAMPLE}건마다 표본으로 남긴다. 누적 건수는 항상 정확히 집계된다.</p>
*/
private void recordDrop(PersonalDataAccessLogVO vo) {
long dropped = droppedCount.incrementAndGet();
if (dropped <= DROP_LOG_HEAD || dropped % DROP_LOG_SAMPLE == 0L) {
LOG.error("[개인정보/#33] 접속기록 1건 포기(누적 " + dropped + "건, size=" + queue.size()
+ ") — 컨슈머 상태 점검 필요. 유실건: " + describe(vo));
}
}
/** 유실·실패 로그용 식별 정보. 개인정보 본문은 담지 않는다(#31 본문 미적재 원칙). */
private String describe(PersonalDataAccessLogVO vo) {
if (vo == null) {
return "(null)";
}
return "conectId=" + vo.getConectId() + ", conectDt=" + vo.getConectDt()
+ ", jobSeCode=" + vo.getJobSeCode() + ", trgetId=" + vo.getTrgetId();
}
/** 큐 포화로 포기한 누적 건수(운영 점검·정기점검 리포트 참고용). */
public long getDroppedCount() {
return droppedCount.get();
}
/** 컨슈머 재기동 누적 횟수(운영 점검용). */
public long getConsumerRestartCount() {
return consumerRestartCount.get();
}
/** 현재 큐 잔량(운영 점검용). */
public int getQueueSize() {
return queue.size();
}
@PreDestroy @PreDestroy
public void stop() { public void stop() {
running = false; running = false;
if (consumer != null) { Thread w = watchdog;
consumer.interrupt(); if (w != null) {
w.interrupt();
}
Thread c = consumer;
if (c != null) {
c.interrupt();
try { try {
consumer.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기 c.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
@ -136,11 +313,15 @@ public class AccessLogQueue {
try { try {
personalDataLogService.persistNow(remaining.get(i)); personalDataLogService.persistNow(remaining.get(i));
} catch (Exception e) { } catch (Exception e) {
LOG.error("[개인정보/#33] 종료 flush 적재 실패", e); LOG.error("[개인정보/#33] 종료 flush 적재 실패 — " + describe(remaining.get(i)), e);
} }
} }
if (!remaining.isEmpty()) { if (!remaining.isEmpty()) {
LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료."); LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료.");
} }
if (droppedCount.get() > 0L) {
LOG.error("[개인정보/#33] 가동 중 큐 포화로 포기한 접속기록 누계 " + droppedCount.get()
+ "건 — 원인 점검 필요(컨슈머 처리량/DB 지연).");
}
} }
} }

View File

@ -0,0 +1,267 @@
package egovframework.com.cmm.privacy.auditlog;
import java.net.URLEncoder;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 업무유형 매핑 관리 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #32-1).
*
* <p>{@code PDAL_JOB_MAPPING}(#32) 을 화면에서 등록·수정·사용중지·삭제한다. 이 매핑은
* 접속기록 수집 Aspect(#31)가 (서비스 클래스, 메서드)를 업무코드·업무명으로 분류하는 기준이며,
* 접속기록 조회 화면(#47)의 '수행업무' 검색 드롭다운 소스이기도 하다.</p>
*
* <p><b>화면 경로</b> {@code /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp}, {@code regist.jsp}</p>
*
* <p><b>수집 범위와의 관계(오해 주의):</b> 매핑을 지우거나 사용중지해도 <u>수집이 멈추지는 않는다</u>.
* 수집 대상은 {@code context-syslogaop.xml} 의 pointcut 이 정하고, 이 테이블은 분류만 담당한다.
* 매핑이 없으면 메서드명으로 유도한 기본 분류로 적재된다.</p>
*
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
* /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
* 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 #32-1)
* </pre>
*/
@Controller
public class JobMappingController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
private static final String LIST_REDIRECT = "redirect:/mnt/sec/jobMapping/listView.do?page=9100045";
@Resource(name = "jobMappingManageService")
private JobMappingManageService jobMappingManageService;
/**
* 업무유형 매핑 목록 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp
*/
@RequestMapping(value = "/mnt/sec/jobMapping/listView.do")
public String listView(@ModelAttribute("jobMappingSearchVO") JobMappingSearchVO searchVO,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
// selectJobMappingManageList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
List<JobMappingVO> list = jobMappingManageService.selectJobMappingManageList(searchVO);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("resultMsg", resultMsg);
return "site/mnt/sec/jobMapping/list";
}
/**
* 등록·수정 폼 화면. mappingId 가 있으면 수정, 없으면 등록.
* 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp
*/
@RequestMapping(value = "/mnt/sec/jobMapping/registView.do")
public String registView(@RequestParam(value = "mappingId", required = false) Long mappingId,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
JobMappingVO vo = null;
if (mappingId != null && mappingId.longValue() > 0) {
vo = jobMappingManageService.selectJobMapping(mappingId.longValue());
if (vo == null) {
return listRedirect("대상 매핑이 존재하지 않습니다.");
}
}
if (vo == null) {
vo = new JobMappingVO();
vo.setMethodPttrn("*");
vo.setSortOrdr(100); // DDL DEFAULT 와 동일
vo.setUseAt("Y");
}
model.addAttribute("jobMappingVO", vo);
return "site/mnt/sec/jobMapping/regist";
}
/**
* 등록·수정 저장. mappingId 가 0 이면 등록, 그 외는 수정.
*
* <p>{@code BindingResult} 를 반드시 받는다 — 숫자 필드(우선순위)를 비우거나 문자로 채우면
* 바인딩이 실패하는데, 이 파라미터가 없으면 Spring 이 BindException 을 던져 500 오류 화면이 뜬다.
* 여기서 받아 사용자 메시지로 되돌린다.</p>
*/
@RequestMapping(value = "/mnt/sec/jobMapping/save.do")
public String save(@ModelAttribute("jobMappingVO") JobMappingVO vo,
BindingResult bindingResult,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!isPost(request) || !isSameOrigin(request)) {
return listRedirect("잘못된 요청 경로입니다.");
}
if (bindingResult.hasErrors()) {
return listRedirect("입력값 형식이 올바르지 않습니다. 우선순위는 숫자로 입력하세요.");
}
try {
if (vo.getMappingId() > 0) {
jobMappingManageService.updateJobMapping(vo, user.getId());
return listRedirect("수정되었습니다. 접속기록 분류에 즉시 반영됩니다.");
}
jobMappingManageService.insertJobMapping(vo, user.getId());
return listRedirect("등록되었습니다. 접속기록 분류에 즉시 반영됩니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/** 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로. */
@RequestMapping(value = "/mnt/sec/jobMapping/useAt.do")
public String updateUseAt(@RequestParam("mappingId") long mappingId,
@RequestParam("useAt") String useAt,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!isPost(request) || !isSameOrigin(request)) {
return listRedirect("잘못된 요청 경로입니다.");
}
try {
jobMappingManageService.updateJobMappingUseAt(mappingId, useAt, user.getId());
return listRedirect("Y".equals(useAt) ? "사용으로 전환했습니다." : "사용중지로 전환했습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/** 매핑 삭제(행 제거). */
@RequestMapping(value = "/mnt/sec/jobMapping/delete.do")
public String delete(@RequestParam("mappingId") long mappingId,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!isPost(request) || !isSameOrigin(request)) {
return listRedirect("잘못된 요청 경로입니다.");
}
try {
jobMappingManageService.deleteJobMapping(mappingId, user.getId());
return listRedirect("삭제되었습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/**
* Resolver 캐시 재적재. 화면을 거치지 않고 DB 를 직접 수정한 경우
* WAS 재기동 없이 반영하기 위한 수단이다.
*/
@RequestMapping(value = "/mnt/sec/jobMapping/refresh.do")
public String refresh(HttpServletRequest request) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (!isPost(request) || !isSameOrigin(request)) {
return listRedirect("잘못된 요청 경로입니다.");
}
jobMappingManageService.refreshCache();
return listRedirect("매핑 캐시를 재적재했습니다.");
}
/** 처리결과 메시지를 붙여 목록으로 리다이렉트. */
private String listRedirect(String message) throws Exception {
if (message == null) {
return LIST_REDIRECT;
}
return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
/** 상태를 바꾸는 요청은 POST 로 한정(링크 클릭·이미지 태그를 통한 변경 방지). */
private boolean isPost(HttpServletRequest request) {
return "POST".equalsIgnoreCase(request.getMethod());
}
/**
* 취급자 영역(/mnt) 인증·구분 판정.
*
* @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
/**
* CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로,
* Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다
* (헤더 미전송 환경은 과잉차단을 피해 통과 — AccountMngController 와 동일 규약).
*/
private boolean isSameOrigin(HttpServletRequest request) {
String source = request.getHeader("Origin");
if (source == null) {
source = request.getHeader("Referer");
}
if (source == null) {
return true;
}
String sourceHost = hostOf(source);
String requestHost = hostOf(request.getHeader("Host"));
return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost);
}
/** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */
private String hostOf(String value) {
if (value == null) {
return null;
}
String host = value;
int scheme = host.indexOf("://");
if (scheme >= 0) {
host = host.substring(scheme + 3);
}
int slash = host.indexOf('/');
if (slash >= 0) {
host = host.substring(0, slash);
}
int colon = host.indexOf(':');
if (colon >= 0) {
host = host.substring(0, colon);
}
return host.length() == 0 ? null : host;
}
}

View File

@ -25,4 +25,54 @@ public class JobMappingDAO extends EgovComAbstractDAO {
public List<JobMappingVO> selectJobMappingList() { public List<JobMappingVO> selectJobMappingList() {
return list("JobMappingDAO.selectJobMappingList", null); return list("JobMappingDAO.selectJobMappingList", null);
} }
// =================================================================
// 관리 화면(#32-1) CRUD — 위 selectJobMappingList 와 달리 USE_AT='N' 행도 대상
// =================================================================
/** 관리 목록 조회(검색 + 페이징). 사용중지(N) 행도 포함한다. */
@SuppressWarnings("unchecked")
public List<JobMappingVO> selectJobMappingManageList(JobMappingSearchVO searchVO) {
return list("JobMappingDAO.selectJobMappingManageList", searchVO);
}
/** 관리 목록 전체 건수 */
public int selectJobMappingManageListCount(JobMappingSearchVO searchVO) {
Object cnt = selectByPk("JobMappingDAO.selectJobMappingManageListCount", searchVO);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 매핑 단건 조회 */
public JobMappingVO selectJobMapping(long mappingId) {
return (JobMappingVO) selectByPk("JobMappingDAO.selectJobMapping", Long.valueOf(mappingId));
}
/**
* (TARGET_CLASS, METHOD_PTTRN) 중복 건수. 수정 시에는 자기 자신을 제외한다.
* 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 서비스 계층에서 막는다.
*/
public int selectJobMappingDuplicateCount(JobMappingVO vo) {
Object cnt = selectByPk("JobMappingDAO.selectJobMappingDuplicateCount", vo);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 매핑 등록 */
public void insertJobMapping(JobMappingVO vo) {
insert("JobMappingDAO.insertJobMapping", vo);
}
/** 매핑 수정 */
public void updateJobMapping(JobMappingVO vo) {
update("JobMappingDAO.updateJobMapping", vo);
}
/** 매핑 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로 */
public void updateJobMappingUseAt(JobMappingVO vo) {
update("JobMappingDAO.updateJobMappingUseAt", vo);
}
/** 매핑 삭제 */
public void deleteJobMapping(long mappingId) {
delete("JobMappingDAO.deleteJobMapping", Long.valueOf(mappingId));
}
} }

View File

@ -0,0 +1,53 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 — 개발항목 #32-1.
*
* <p>#32 는 테이블·로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다.
* 본 서비스는 취급자가 화면에서 매핑을 등록·수정·사용중지·삭제할 수 있게 한다.
* 매핑이 바뀌면 {@link JobMappingResolver} 캐시를 즉시 무효화하므로 WAS 재기동이 불요하다.</p>
*
* @author URP
* @since 2026-08-07
*/
public interface JobMappingManageService {
/** 관리 목록 조회(검색 + 페이징). searchVO 에 firstIndex/totalRecordCount 를 세팅한다. */
List<JobMappingVO> selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception;
/** 매핑 단건 조회(수정 폼). 없으면 null. */
JobMappingVO selectJobMapping(long mappingId) throws Exception;
/**
* 매핑 등록.
*
* @throws IllegalArgumentException 입력값 위반 또는 (대상클래스, 메서드패턴) 중복
*/
void insertJobMapping(JobMappingVO vo, String actorId) throws Exception;
/**
* 매핑 수정.
*
* @throws IllegalArgumentException 입력값 위반 / 중복 / 대상 매핑 없음
*/
void updateJobMapping(JobMappingVO vo, String actorId) throws Exception;
/**
* 사용여부 전환(Y↔N). 삭제 대신 권장하는 비활성화 경로.
*
* @throws IllegalArgumentException 대상 매핑 없음 또는 useAt 값 오류
*/
void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception;
/**
* 매핑 삭제(행 제거).
*
* @throws IllegalArgumentException 대상 매핑 없음
*/
void deleteJobMapping(long mappingId, String actorId) throws Exception;
/** Resolver 캐시 강제 재적재(DB 직접 수정분 반영용). */
void refreshCache() throws Exception;
}

View File

@ -0,0 +1,181 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import java.util.regex.Pattern;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 구현 — 개발항목 #32-1.
*
* <p>클래스명이 {@code *Impl} 이고 패키지가 {@code egovframework.com..} 이므로
* {@code context-transaction.xml} 의 requiredTx pointcut 에 걸려 메서드 단위 트랜잭션이 적용된다
* (DB 반영 + 캐시 무효화가 함께 처리된다).</p>
*
* <p><b>캐시 무효화 범위:</b> {@link JobMappingResolver#refresh()} 는 <u>해당 JVM 의 캐시만</u>
* 비운다. arko 는 단일 Tomcat ROOT 전개라 현재는 충분하나, 다중 인스턴스로 확장하면
* 인스턴스별 재적재 수단(관리 화면의 '캐시 재적재' 버튼을 각 노드에서 호출 등)이 필요하다.</p>
*
* <p><b>입력 검증:</b> TARGET_CLASS/METHOD_PTTRN 은 Aspect 가 (클래스 단순명, 메서드명)으로
* 매칭하는 값이라 자바 식별자 형식만 허용한다. 형식을 열어두면 어떤 값도 매칭되지 않는
* 죽은 매핑이 쌓여 접속기록 분류가 조용히 폴백 처리된다.</p>
*
* @author URP
* @since 2026-08-07
*/
@Service("jobMappingManageService")
public class JobMappingManageServiceImpl implements JobMappingManageService {
/** 자바 식별자(패키지 포함 FQN 허용) */
private static final Pattern CLASS_PATTERN =
Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$");
/** 메서드명 접두(부분 식별자) */
private static final Pattern METHOD_PATTERN =
Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$");
/** 업무코드 — 대문자/숫자/언더스코어 */
private static final Pattern CODE_PATTERN =
Pattern.compile("^[A-Z0-9_]+$");
@Resource(name = "jobMappingDAO")
private JobMappingDAO jobMappingDAO;
@Resource(name = "jobMappingResolver")
private JobMappingResolver jobMappingResolver;
public List<JobMappingVO> selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception {
if (searchVO.getPageIndex() < 1) {
searchVO.setPageIndex(1);
}
if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) {
searchVO.setRecordCountPerPage(20);
}
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
searchVO.setTotalRecordCount(jobMappingDAO.selectJobMappingManageListCount(searchVO));
return jobMappingDAO.selectJobMappingManageList(searchVO);
}
public JobMappingVO selectJobMapping(long mappingId) throws Exception {
return jobMappingDAO.selectJobMapping(mappingId);
}
public void insertJobMapping(JobMappingVO vo, String actorId) throws Exception {
normalize(vo);
validate(vo);
// 신규는 자기 자신 제외가 없으므로 mappingId=0 으로 전체 대조
vo.setMappingId(0L);
if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) {
throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)");
}
vo.setRegisterId(actorId);
vo.setUpdusrId(actorId);
jobMappingDAO.insertJobMapping(vo);
jobMappingResolver.refresh();
}
public void updateJobMapping(JobMappingVO vo, String actorId) throws Exception {
normalize(vo);
validate(vo);
if (jobMappingDAO.selectJobMapping(vo.getMappingId()) == null) {
throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
}
if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) {
throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)");
}
vo.setUpdusrId(actorId);
jobMappingDAO.updateJobMapping(vo);
jobMappingResolver.refresh();
}
public void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception {
if (!"Y".equals(useAt) && !"N".equals(useAt)) {
throw new IllegalArgumentException("사용여부 값이 올바르지 않습니다.");
}
JobMappingVO target = jobMappingDAO.selectJobMapping(mappingId);
if (target == null) {
throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
}
JobMappingVO vo = new JobMappingVO();
vo.setMappingId(mappingId);
vo.setUseAt(useAt);
vo.setUpdusrId(actorId);
jobMappingDAO.updateJobMappingUseAt(vo);
jobMappingResolver.refresh();
}
public void deleteJobMapping(long mappingId, String actorId) throws Exception {
if (jobMappingDAO.selectJobMapping(mappingId) == null) {
throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
}
jobMappingDAO.deleteJobMapping(mappingId);
jobMappingResolver.refresh();
}
public void refreshCache() throws Exception {
jobMappingResolver.refresh();
}
/** 공백 제거 + 미입력 항목 기본값 보정(체크박스/셀렉트 미전송 대비). */
private void normalize(JobMappingVO vo) {
vo.setTargetClass(trim(vo.getTargetClass()));
vo.setMethodPttrn(trim(vo.getMethodPttrn()));
vo.setJobSeCode(trim(vo.getJobSeCode()));
vo.setJobSeNm(trim(vo.getJobSeNm()));
vo.setDmlGubun(trim(vo.getDmlGubun()));
vo.setRm(trim(vo.getRm()));
if (vo.getMethodPttrn() == null) {
vo.setMethodPttrn("*"); // DDL DEFAULT 와 동일
}
// dmlGubun 은 미입력(null) 이 곧 '메서드명으로 유도' 규약이라 보정하지 않는다.
if (!"N".equals(vo.getUseAt())) {
vo.setUseAt("Y");
}
if (vo.getJobSeCode() != null) {
vo.setJobSeCode(vo.getJobSeCode().toUpperCase());
}
}
/** 화면 우회 입력(직접 POST)에 대비한 서버측 검증. 위반 시 사용자 메시지로 예외. */
private void validate(JobMappingVO vo) {
if (vo.getTargetClass() == null) {
throw new IllegalArgumentException("대상 클래스명은 필수입니다.");
}
if (vo.getTargetClass().length() > 200 || !CLASS_PATTERN.matcher(vo.getTargetClass()).matches()) {
throw new IllegalArgumentException("대상 클래스명은 자바 클래스명(또는 FQN) 형식으로 200자 이내 입력하세요.");
}
if (!"*".equals(vo.getMethodPttrn())
&& (vo.getMethodPttrn().length() > 100 || !METHOD_PATTERN.matcher(vo.getMethodPttrn()).matches())) {
throw new IllegalArgumentException("메서드 패턴은 '*' 또는 메서드명 접두(영문/숫자) 100자 이내로 입력하세요.");
}
if (vo.getJobSeCode() == null) {
throw new IllegalArgumentException("업무코드는 필수입니다.");
}
if (vo.getJobSeCode().length() > 30 || !CODE_PATTERN.matcher(vo.getJobSeCode()).matches()) {
throw new IllegalArgumentException("업무코드는 영문 대문자·숫자·밑줄 조합으로 30자 이내 입력하세요.");
}
if (vo.getJobSeNm() == null || vo.getJobSeNm().length() > 100) {
throw new IllegalArgumentException("업무명은 필수이며 100자 이내로 입력하세요.");
}
if (vo.getDmlGubun() != null && !"S".equals(vo.getDmlGubun()) && !"I".equals(vo.getDmlGubun())
&& !"U".equals(vo.getDmlGubun()) && !"D".equals(vo.getDmlGubun())) {
throw new IllegalArgumentException("처리구분 값이 올바르지 않습니다.");
}
if (vo.getSortOrdr() < 0 || vo.getSortOrdr() > 9999) {
throw new IllegalArgumentException("우선순위는 0~9999 범위로 입력하세요.");
}
if (vo.getRm() != null && vo.getRm().length() > 300) {
throw new IllegalArgumentException("비고는 300자 이내로 입력하세요.");
}
}
/** 앞뒤 공백 제거 후 빈 문자열은 null 로(iBatis isNotEmpty·DB NULL 규약 일치). */
private String trim(String v) {
if (v == null) {
return null;
}
String s = v.trim();
return s.length() == 0 ? null : s;
}
}

View File

@ -0,0 +1,61 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 화면 검색조건 VO — 개발항목 #32-1.
*
* <p>매핑 조회용 {@link JobMappingVO} 와 달리 <b>USE_AT='N' 행도 조회 대상</b>이다.
* 관리 화면에서는 사용중지된 매핑도 보여야 재사용·복구가 가능하기 때문이다
* ({@code JobMappingDAO.selectJobMappingList} 는 Resolver 캐시용이라 'Y'만 조회).</p>
*
* @author URP
* @since 2026-08-07
*/
public class JobMappingSearchVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 대상 클래스명(부분 일치) */
private String searchTargetClass;
/** 업무코드 또는 업무명(부분 일치) */
private String searchJobSe;
/** 사용여부 Y/N (빈 값이면 전체) */
private String searchUseAt;
/** 처리구분 S/I/U/D (빈 값이면 전체) */
private String searchDmlGubun;
// ---- 페이징 ----
/** 현재 페이지 번호(1-base) */
private int pageIndex = 1;
/** 페이지당 레코드 수 */
private int recordCountPerPage = 20;
/** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
private int firstIndex = 0;
/** 전체 레코드 수(조회 후 세팅) */
private int totalRecordCount = 0;
public String getSearchTargetClass() { return searchTargetClass; }
public void setSearchTargetClass(String searchTargetClass) { this.searchTargetClass = searchTargetClass; }
public String getSearchJobSe() { return searchJobSe; }
public void setSearchJobSe(String searchJobSe) { this.searchJobSe = searchJobSe; }
public String getSearchUseAt() { return searchUseAt; }
public void setSearchUseAt(String searchUseAt) { this.searchUseAt = searchUseAt; }
public String getSearchDmlGubun() { return searchDmlGubun; }
public void setSearchDmlGubun(String searchDmlGubun) { this.searchDmlGubun = searchDmlGubun; }
public int getPageIndex() { return pageIndex; }
public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
public int getRecordCountPerPage() { return recordCountPerPage; }
public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; }
public int getFirstIndex() { return firstIndex; }
public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; }
public int getTotalRecordCount() { return totalRecordCount; }
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
}

View File

@ -33,6 +33,16 @@ public class JobMappingVO implements Serializable {
private String useAt; private String useAt;
private String rm; private String rm;
// ---- 변경 추적(#32-1 관리 화면) : 시드 적재 행은 등록자 NULL ----
/** 등록자 계정ID */
private String registerId;
/** 등록 일시 */
private String regDt;
/** 최종 변경자 계정ID */
private String updusrId;
/** 최종 변경 일시 */
private String updtDt;
public long getMappingId() { return mappingId; } public long getMappingId() { return mappingId; }
public void setMappingId(long mappingId) { this.mappingId = mappingId; } public void setMappingId(long mappingId) { this.mappingId = mappingId; }
@ -59,4 +69,16 @@ public class JobMappingVO implements Serializable {
public String getRm() { return rm; } public String getRm() { return rm; }
public void setRm(String rm) { this.rm = rm; } public void setRm(String rm) { this.rm = rm; }
public String getRegisterId() { return registerId; }
public void setRegisterId(String registerId) { this.registerId = registerId; }
public String getRegDt() { return regDt; }
public void setRegDt(String regDt) { this.regDt = regDt; }
public String getUpdusrId() { return updusrId; }
public void setUpdusrId(String updusrId) { this.updusrId = updusrId; }
public String getUpdtDt() { return updtDt; }
public void setUpdtDt(String updtDt) { this.updtDt = updtDt; }
} }

View File

@ -0,0 +1,195 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 개인정보 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #47).
*
* <p>{@code PERSONAL_DATA_ACCESS_LOG}(#30) 적재분을 접속자ID·업무유형·처리대상·IP·처리구분·
* 처리결과·기간으로 복합 검색한다(#41 백엔드 연계). 화면:
* {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp}</p>
*
* <p><b>다운로드(#42):</b> 사유 입력이 필수이며 미입력 시 400으로 차단하고 파일을 열지 않는다.
* 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}에 적재된다
* ({@link AccessLogDownloadService}). 개인정보 본문은 이력에 남기지 않는다.</p>
*
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
* /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
* 단에서 직접 판정한다(LoginFailHistoryController 와 동일 방식).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.06 URP 최초 생성 (개인정보보호 SFR-004 #47)
* </pre>
*/
@Controller
public class PersonalDataAccessLogController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@Resource(name = "accessLogDownloadService")
private AccessLogDownloadService accessLogDownloadService;
@Resource(name = "personalDataAccessLogSearchDAO")
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
/**
* 개인정보 접속기록 조회 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
*/
@RequestMapping(value = "/mnt/sec/accessLog/listView.do")
public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
// selectAccessLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
List<PersonalDataAccessLogVO> list = personalDataAccessLogSearchService.selectAccessLogList(searchVO);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("jobSeList", selectJobSeCodeList());
return "site/mnt/sec/accessLog/list";
}
/**
* 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재(#42).
* 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다.
*/
@RequestMapping(value = "/mnt/sec/accessLog/download.do")
public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
@RequestParam(value = "dlReason", required = false) String dlReason,
HttpServletRequest request, HttpServletResponse response) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
return;
}
List<PersonalDataAccessLogVO> list;
try {
list = accessLogDownloadService.downloadAccessLog(user.getId(),
ClientIpResolver.getClientIp(request), dlReason, searchVO);
} catch (IllegalArgumentException e) {
// 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답
response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
return;
}
String fileName = URLEncoder.encode(
"개인정보접속기록_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".csv", "UTF-8");
response.setContentType("text/csv; charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
response.setHeader("Content-Transfer-Encoding", "binary");
OutputStream os = response.getOutputStream();
os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환)
PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8"));
pw.println("로그번호,접속일시,접속자ID,처리자구분,접속IP,수행업무코드,수행업무명,처리구분,처리대상ID,처리대상범위,처리결과,무결성해시");
if (list != null) {
for (PersonalDataAccessLogVO log : list) {
pw.println(log.getLogId() + "," + csv(log.getConectDt()) + "," + csv(log.getConectId())
+ "," + csv(log.getUserSe()) + "," + csv(log.getConectIp())
+ "," + csv(log.getJobSeCode()) + "," + csv(log.getJobSeNm())
+ "," + csv(dmlLabel(log.getDmlGubun())) + "," + csv(log.getTrgetId())
+ "," + csv(log.getTrgetCn()) + "," + csv(resultLabel(log.getProcessResult()))
+ "," + csv(log.getHashVal()));
}
}
pw.flush();
}
/**
* 검색조건 드롭다운용 업무유형 목록.
*
* <p>2026-08-07 소스 변경: 종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 읽었으나,
* 수집이 관리자 화면 메뉴 기반({@code MenuAccessLogInterceptor})으로 바뀌어 업무유형이
* 메뉴에서 나온다. 매핑 테이블을 보면 실제 적재값과 어긋난 선택지가 떠 검색 결과가
* 항상 0건이 되므로, 적재된 로그의 DISTINCT 업무유형을 소스로 쓴다.</p>
*
* <p>조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.</p>
*/
private List<JobMappingVO> selectJobSeCodeList() {
try {
List<JobMappingVO> list = personalDataAccessLogSearchDAO.selectJobSeCodeList();
return (list == null) ? new ArrayList<JobMappingVO>() : list;
} catch (Exception e) {
// 드롭다운 소스일 뿐이므로 조회 실패가 화면 전체를 막지 않도록 한다.
return new ArrayList<JobMappingVO>();
}
}
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
private String csv(String v) {
if (v == null) {
return "";
}
String s = v.replace("\"", "\"\"");
return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
}
private String dmlLabel(String code) {
if ("S".equals(code)) return "조회";
if ("I".equals(code)) return "등록";
if ("U".equals(code)) return "수정";
if ("D".equals(code)) return "삭제";
return code;
}
private String resultLabel(String code) {
if ("S".equals(code)) return "성공";
if ("F".equals(code)) return "실패/차단";
return code;
}
/**
* 취급자 영역(/mnt) 인증·구분 판정.
*
* @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
}

View File

@ -30,4 +30,16 @@ public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO {
Object cnt = selectByPk("PersonalDataAccessLogDAO.selectAccessLogListCount", searchVO); Object cnt = selectByPk("PersonalDataAccessLogDAO.selectAccessLogListCount", searchVO);
return cnt == null ? 0 : ((Integer) cnt).intValue(); return cnt == null ? 0 : ((Integer) cnt).intValue();
} }
/**
* 조회화면 '수행업무' 드롭다운 소스 — 실제 적재된 (업무코드, 업무명) 목록.
*
* <p>수집이 관리자 화면 메뉴 기반으로 바뀌면서 업무유형은 메뉴에서 나온다(2026-08-07).
* PDAL_JOB_MAPPING 을 보면 적재값과 어긋난 선택지가 뜨므로 적재분에서 직접 뽑는다.
* 반환 타입은 (jobSeCode, jobSeNm) 두 프로퍼티만 쓰는 용도로 {@link JobMappingVO}를 재사용한다.</p>
*/
@SuppressWarnings("unchecked")
public List<JobMappingVO> selectJobSeCodeList() {
return list("PersonalDataAccessLogDAO.selectJobSeCodeList", null);
}
} }

View File

@ -23,6 +23,16 @@ import egovframework.com.cmm.LoginVO;
/** /**
* 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002). * 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002).
* *
* <p><b>[2026-08-07 사용 중단]</b> 이 Aspect 는 더 이상 적용되지 않는다.
* {@code context-syslogaop.xml}의 {@code <aop:config>}가 주석 처리됐고, 접속기록 수집은
* {@code egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor}가 담당한다.
* <b>중단 사유:</b> 서비스 impl 을 가로채는 방식은 관리자 화면과 프론트 화면이 같은 서비스
* 메서드를 공유하는 구조에서 호출 화면을 구분하지 못한다. 그 결과 무인증 프론트 방문이
* 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다. 요구사항(SFR-004)은
* "개인정보<u>취급자</u>가 무슨 업무를 처리했는지"이므로, 요청 URL 로 관리자 화면을 식별하고
* 메뉴명을 수행업무로 남기는 인터셉터 방식으로 전환했다.
* 클래스는 판단 근거·복원 경로 보존을 위해 남겨둔다(빈은 등록되나 어떤 pointcut 에도 걸리지 않음).</p>
*
* <p><b>한정 수집:</b> {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라, * <p><b>한정 수집:</b> {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만 * 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고, * 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
@ -32,6 +42,16 @@ import egovframework.com.cmm.LoginVO;
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만 * <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
* 베스트에포트로 취득한다(#36).</p> * 베스트에포트로 취득한다(#36).</p>
* *
* <p><b>로그인 세션 한정(2026-08-07):</b> 세션에 {@code loginVO}가 없는 요청은 <b>수집하지 않는다</b>.
* 종전에는 접속자ID를 {@code ANONYMOUS}로 적재했다. 근거는 두 가지다.
* ① 제안요청서(붙임2) SFR-004 정의가 "개인정보<u>취급자</u> 접속 및 업무처리 기록 관리"이고
* 세부내용 첫 항목도 "개인정보취급자의 접속기록을 저장·관리"로, 비로그인 상태(정보주체 본인의
* 가입·실명확인 등)는 대상이 아니다(SER-002·안전성 확보조치 기준 제8조도 동일 기준).
* ② 비정상접근 탐지(#35)가 {@code GROUP BY CONECT_ID} 기반이라, 모든 익명 요청이
* {@code ANONYMOUS} 한 계정으로 뭉쳐 계정공유의심(상이 IP 다수)·대량조회에 상시 오탐을 만든다.
* 제외되는 대표 경로: 회원가입({@code insert*}), 실명확인({@code MberSsnServiceImpl}),
* 휴면해제, 계정잠금 해제, 인증번호 발송. 이 경로들의 추적은 인증 실패 이력(#22)이 담당한다.</p>
*
* <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장. * <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지). * pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만 * 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
@ -56,7 +76,6 @@ public class PersonalDataLogAspect {
private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class); private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class);
private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss"; private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
private static final String ANONYMOUS = "ANONYMOUS";
@Resource(name = "jobMappingResolver") @Resource(name = "jobMappingResolver")
private JobMappingResolver jobMappingResolver; private JobMappingResolver jobMappingResolver;
@ -95,6 +114,13 @@ public class PersonalDataLogAspect {
return; return;
} }
// 로그인 세션이 없는 요청은 취급자 접속기록이 아니므로 수집하지 않는다(사유는 클래스 Javadoc).
// 매핑 조회보다 먼저 판정해 불필요한 작업을 하지 않는다.
LoginVO login = currentLogin(request);
if (login == null || !notBlank(login.getId())) {
return;
}
String targetSimpleName = pjp.getTarget().getClass().getSimpleName(); String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
String methodName = pjp.getSignature().getName(); String methodName = pjp.getSignature().getName();
@ -116,10 +142,8 @@ public class PersonalDataLogAspect {
dmlGubun = JobMappingResolver.deriveDmlGubun(methodName); dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
} }
LoginVO login = currentLogin(request);
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO(); PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
vo.setConectId(login != null && notBlank(login.getId()) ? login.getId() : ANONYMOUS); vo.setConectId(login.getId());
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date())); vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
vo.setConectIp(safeIp(request)); vo.setConectIp(safeIp(request));
vo.setJobSeCode(jobSeCode); vo.setJobSeCode(jobSeCode);

View File

@ -1,14 +1,20 @@
package egovframework.com.cmm.privacy.author; package egovframework.com.cmm.privacy.author;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URLEncoder;
import java.util.List; import java.util.List;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
@ -18,8 +24,8 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
* 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러 * 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러
* (개인정보보호 SFR-001 / #3·#6). * (개인정보보호 SFR-001 / #3·#6).
* *
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #46 후속). * <p>취급자 영역(/mnt) 백엔드 엔드포인트. 처리자/신청자 식별자와 IP는
* 처리자/신청자 식별자와 IP는 세션 loginVO·ClientIpResolver(#34)에서 취득한다.</p> * 세션 loginVO·ClientIpResolver(#34)에서 취득한다.</p>
*/ */
@Controller @Controller
public class AuthorWorkflowController { public class AuthorWorkflowController {
@ -142,7 +148,7 @@ public class AuthorWorkflowController {
return JSON; return JSON;
} }
/** 권한 변경 이력 조회(대상자·변경자 양방향 검색 + 페이징) */ /** 권한 변경 이력 조회(대상자·변경자 양방향 검색 + 페이징, AJAX/JSON) */
@RequestMapping(value = "/mnt/sec/authorHistory/list.do") @RequestMapping(value = "/mnt/sec/authorHistory/list.do")
public String historyList(AuthorChangeHistoryVO searchVO, ModelMap model) throws Exception { public String historyList(AuthorChangeHistoryVO searchVO, ModelMap model) throws Exception {
List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO); List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO);
@ -151,11 +157,32 @@ public class AuthorWorkflowController {
return JSON; return JSON;
} }
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 */ /**
* 권한 변경 이력 조회·검색 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp
*/
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
ModelMap model) throws Exception {
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
// selectHistoryList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
return "site/mnt/sec/authorHistory/list";
}
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍 */
@RequestMapping(value = "/mnt/sec/authorHistory/download.do") @RequestMapping(value = "/mnt/sec/authorHistory/download.do")
public String historyDownload(AuthorChangeHistoryVO searchVO, public void historyDownload(AuthorChangeHistoryVO searchVO,
@org.springframework.web.bind.annotation.RequestParam(value = "dwldResn", required = false) String dwldResn, @RequestParam(value = "dwldResn", required = false) String dwldResn,
HttpServletRequest request, ModelMap model) throws Exception { HttpServletRequest request, HttpServletResponse response) throws Exception {
LoginVO user = loginUser(request); LoginVO user = loginUser(request);
AuthorHistDownloadVO dl = new AuthorHistDownloadVO(); AuthorHistDownloadVO dl = new AuthorHistDownloadVO();
dl.setDwldResn(dwldResn); dl.setDwldResn(dwldResn);
@ -166,15 +193,46 @@ public class AuthorWorkflowController {
dl.setDownloaderIp(ClientIpResolver.getClientIp(request)); dl.setDownloaderIp(ClientIpResolver.getClientIp(request));
dl.setSearchCond("trget=" + searchVO.getSearchTrgetId() + ",changer=" + searchVO.getSearchChangerId() dl.setSearchCond("trget=" + searchVO.getSearchTrgetId() + ",changer=" + searchVO.getSearchChangerId()
+ ",se=" + searchVO.getSearchChgSeCode() + ",from=" + searchVO.getFromDt() + ",to=" + searchVO.getToDt()); + ",se=" + searchVO.getSearchChgSeCode() + ",from=" + searchVO.getFromDt() + ",to=" + searchVO.getToDt());
List<AuthorChangeHistoryVO> list;
try { try {
List<AuthorChangeHistoryVO> list = authorHistoryService.downloadHistory(searchVO, dl); list = authorHistoryService.downloadHistory(searchVO, dl);
model.addAttribute("resultList", list);
model.addAttribute("result", "success");
// TODO: 화면 #46 — 실제 파일(CSV/XLS) 스트리밍은 프론트/뷰 계층에서 처리
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
model.addAttribute("result", "fail"); // 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답
model.addAttribute("message", e.getMessage()); // 사유 미입력 차단 response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
return;
} }
return JSON;
String fileName = URLEncoder.encode("권한변경이력_" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new java.util.Date()) + ".csv", "UTF-8");
response.setContentType("text/csv; charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
response.setHeader("Content-Transfer-Encoding", "binary");
OutputStream os = response.getOutputStream();
os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환)
PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8"));
pw.println("변경일시,대상자ID,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP");
if (list != null) {
for (AuthorChangeHistoryVO h : list) {
pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(chgSeLabel(h.getChgSeCode()))
+ "," + csv(h.getBeforeAuthor()) + "," + csv(h.getAfterAuthor()) + "," + csv(h.getChgResn())
+ "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp()));
}
}
pw.flush();
}
private String chgSeLabel(String code) {
if ("G".equals(code)) return "부여";
if ("R".equals(code)) return "회수";
if ("M".equals(code)) return "변경";
return code;
}
/** CSV 필드 이스케이프(쉼표/따옴표/개행 포함 시 큰따옴표로 감싸고 내부 따옴표는 두 배로) */
private String csv(String value) {
if (value == null) return "";
String escaped = value.replace("\"", "\"\"");
return "\"" + escaped + "\"";
} }
} }

View File

@ -29,8 +29,10 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
* *
* <p><b>preHandle 판정 순서:</b> * <p><b>preHandle 판정 순서:</b>
* <ol> * <ol>
* <li>{@code /mnt/} 외 · 제외패턴 · 미인증(loginVO==null) → 통과(기존 유지)</li> * <li>{@code /mnt/} 외 · 제외패턴 → 통과(기존 유지)</li>
* <li>명시 매핑({@code urlRoleMapping}) 우선 — 매칭 시 해당 롤 필요(신규 privacy 화면 보호 유지)</li> * <li>명시 매핑({@code urlRoleMapping}) 판정을 인증 판정보다 <b>먼저</b> 수행. 매칭 경로는
* 미인증이면 로그인 유도(화면)/401(AJAX)로 차단하고, 인증 상태면 해당 롤을 요구한다.
* 매칭되지 않으면 미인증은 기존과 동일하게 통과(일반 arko 관리화면 동작 불변)</li>
* <li>바이패스 롤({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 → 통과(관리자 잠금 방지)</li> * <li>바이패스 롤({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 → 통과(관리자 잠금 방지)</li>
* <li>메뉴권한 판정 — 요청 path(쿼리 제외): * <li>메뉴권한 판정 — 요청 path(쿼리 제외):
* <ul> * <ul>
@ -50,12 +52,20 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
* ---------- ------ --------------------------- * ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2) * 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
* 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open * 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open
* 2026.07.28 URP 명시 매핑 판정을 미인증 판정보다 선행 — privacy 화면 무인증 노출 차단
* </pre> * </pre>
*/ */
public class AuthorityInterceptor extends HandlerInterceptorAdapter { public class AuthorityInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class); private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class);
/** 미인증 화면 진입 시 유도할 로그인 URL(기존 arko 관례). */
private static final String LOGIN_URL = "/uat/uia/egovLoginUsr.do";
/** 미인증 AJAX/JSON 액션 거부 본문(계정 존재 여부 등 부가정보 미노출 — AccountMngController 와 동일 문구). */
private static final String DENY_JSON =
"{\"result\":\"fail\",\"message\":\"접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.\"}";
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */ /** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/"; private String applyPathPrefix = "/mnt/";
@ -109,17 +119,26 @@ public class AuthorityInterceptor extends HandlerInterceptorAdapter {
return true; return true;
} }
// 3) 미인증은 통과(기존 인증판정 체계가 처리) // 3) 명시 매핑 판정을 미인증 판정보다 먼저 수행한다.
// (authenticInterceptor 가 비활성이라 미인증을 걸러줄 다음 단계가 실제로는 없다.
// 매핑에 걸린 privacy 화면만 여기서 인증을 강제하고, 그 외 /mnt/ 는 기존 동작 유지.)
String requiredRole = resolveRequiredRole(path);
LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser(); LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
if (loginVO == null) { if (loginVO == null) {
if (requiredRole == null) {
// 매핑 밖 경로 — 기존과 동일하게 통과(일반 arko 관리화면 동작 변경 없음)
return true; return true;
} }
LOG.warn("[개인정보/#2] 미인증 접근 차단: path={}, requiredRole={}", path, requiredRole);
denyUnauthenticated(request, response);
return false;
}
// 사용자 롤 목록(#1 getAuthorities 활용) // 사용자 롤 목록(#1 getAuthorities 활용)
List<String> roles = EgovUserDetailsHelper.getAuthorities(); List<String> roles = EgovUserDetailsHelper.getAuthorities();
// 4) 명시 매핑 우선 — 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호 // 4) 명시 매핑 우선 — 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호
String requiredRole = resolveRequiredRole(path);
if (requiredRole != null) { if (requiredRole != null) {
if (roles != null && roles.contains(requiredRole)) { if (roles != null && roles.contains(requiredRole)) {
return true; return true;
@ -178,6 +197,29 @@ public class AuthorityInterceptor extends HandlerInterceptorAdapter {
return true; return true;
} }
/**
* 미인증 + 명시 매핑 경로에 대한 거부 응답.
* 화면 진입(브라우저 내비게이션)은 로그인 화면으로 유도, AJAX/JSON 액션은 401 + jsonView 동형 본문.
*/
private void denyUnauthenticated(HttpServletRequest request, HttpServletResponse response) throws Exception {
if (isBrowserNavigation(request)) {
response.sendRedirect(request.getContextPath() + LOGIN_URL);
return;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write(DENY_JSON);
}
/** 브라우저 화면 진입 여부. jQuery AJAX(X-Requested-With) 우선 판정 후 Accept 헤더로 보완. */
private boolean isBrowserNavigation(HttpServletRequest request) {
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
return false;
}
String accept = request.getHeader("Accept");
return accept == null || accept.indexOf("text/html") >= 0 || "*/*".equals(accept.trim());
}
/** 명시 매핑에서 필요 권한 조회. 매핑 없으면 null. */ /** 명시 매핑에서 필요 권한 조회. 매핑 없으면 null. */
private String resolveRequiredRole(String path) { private String resolveRequiredRole(String path) {
if (urlRoleMapping == null || urlRoleMapping.isEmpty()) { if (urlRoleMapping == null || urlRoleMapping.isEmpty()) {

View File

@ -0,0 +1,625 @@
package egovframework.com.cmm.privacy.interceptor;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.auditlog.AccessLogQueue;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
import egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 관리자 화면 메뉴 접속기록 인터셉터 — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002).
*
* <p><b>무엇을 남기는가:</b> "개인정보취급자가 관리자 화면에서 어떤 메뉴를 열어 무슨 처리를 했는지"
* 1요청 = 1행으로 {@code PERSONAL_DATA_ACCESS_LOG}에 남긴다. 수행업무(JOB_SE_NM)는
* <b>기존 arko 메뉴 테이블의 메뉴명</b>이다 — 별도 매핑 테이블을 관리하지 않아도 메뉴가 늘면
* 접속기록 업무명도 따라 늘어난다.</p>
*
* <p><b>왜 서비스 AOP에서 인터셉터로 바꿨나(2026-08-07):</b> 종전
* {@code PersonalDataLogAspect}는 서비스 impl 메서드를 가로챘다. 서비스는 관리자 화면과
* 프론트 화면이 공유하므로 <u>어느 화면에서 호출됐는지 구분할 수 없었고</u>, 그 결과
* 무인증 프론트 방문이 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다
* (예: 공개 재능나눔 상세 {@code /frt/biz/talent/bizTalentView.do} → {@code selectTalentMber}).
* 요청 단위 인터셉터는 URL을 보므로 관리자 화면 요청만 정확히 골라낼 수 있다.</p>
*
* <p><b>기록 대상 판정(3중 조건, 모두 만족해야 기록):</b>
* <ol>
* <li>요청 경로가 {@code includePatterns}(개인정보 취급 화면 <b>화이트리스트</b>)에 걸릴 것.
* 관리자 화면 전체가 아니라 개인정보를 취급하는 화면만 남긴다 — SFR-004의 대상은
* "개인정보 처리" 기록이고, 통계·게시판·코드관리까지 730일 보관 테이블에 쌓으면
* 정기점검·이상탐지 신호가 묻힌다.
* <b>화이트리스트 산출 근거:</b> 개인정보 서비스(구 AOP pointcut 16종)를 실제로 주입·호출하는
* <u>관리자 컨트롤러를 역산</u>해 그 URL 대역을 열거했다. 접두 하드코딩이 아니라 정규식이라
* {@code /uss/umt/} 안에서 부서관리만 빼는 식의 예외도 표현된다.
* 미설정이면 아무것도 기록하지 않는다(설정 누락 → 전 화면 과수집 방지).</li>
* <li>{@code excludePatterns}에 걸리지 않을 것 — 화이트리스트 안에서 빼야 하는 것들이다.
* 프레임(left/top/footer)·공통 include 는 화면 1회 진입마다 동반 호출되므로 제외하지 않으면
* 같은 진입이 4~5행으로 부풀어 통계를 망친다. 부서관리({@code /uss/umt/dpt/})처럼
* 대역 안의 비개인정보 화면도 여기서 뺀다.</li>
* <li>인증된 <b>개인정보취급자</b> 세션 — {@code LoginVO.userSe}가 {@code handlerUserSes}
* (기본 {@code USR})에 속할 것. 비로그인 요청은 기록하지 않는다(SFR-004는 취급자 기록이며,
* 모든 익명 요청이 한 계정으로 뭉치면 계정공유·대량조회 탐지에 상시 오탐이 된다).
* {@code userSe}가 비어 있으면 취급자 기록 누락을 막기 위해 기록한다(fail-safe).</li>
* </ol>
*
* <p><b>화면이 늘면:</b> 개인정보 취급 화면을 새로 만들면 {@code includePatterns}에 한 줄을 더해야
* 수집된다(설정 파일 변경 → 컨텍스트 리로드). 자동 확장이 아닌 것은 의도된 선택이다 —
* 화이트리스트 없이 자동으로 늘리면 과수집을 통제할 지점이 사라진다.</p>
*
* <p><b>메뉴명 해석 순서:</b>
* <ol>
* <li>요청 파라미터 {@code page} — arko 관리자 링크는 {@code ...listView.do?page=9100044}처럼
* 메뉴번호를 달고 다니며 좌측메뉴 활성화 판정에 쓴다. 가장 정확한 단서라 1순위.</li>
* <li>경로 정확 일치 — 메뉴 URL('?' 앞)과 요청 경로가 같을 때.</li>
* <li>같은 디렉터리 내 최장 공통접두 — 목록화면만 메뉴로 등록되고 상세·수정·삭제 액션은
* 메뉴에 없는 것이 arko의 일반형이다. 이들을 소관 메뉴로 귀속시킨다
* (예: {@code /mnt/biz/talent/bizTalentMberView.do} → "재능나눔 회원관리" 메뉴).</li>
* <li>폴백 — 메뉴로 환원되지 않는 경로는 업무코드 {@code MNT_ETC}, 업무명에 경로를 남긴다
* (누락 없이 기록. 어느 메뉴에 붙일지는 운영에서 메뉴 등록으로 해소).</li>
* </ol>
*
* <p><b>처리구분(DML_GUBUN):</b> URL 마지막 세그먼트(.do 파일명)로 유도한다. HTTP 메서드는
* 쓰지 않는다 — 이 레거시는 삭제·수정도 GET 링크로 부르는 곳이 있어 메서드가 실제 행위를
* 대변하지 못한다. {@code ~View}로 끝나면 등록/수정 <u>화면</u>이므로 조회(S)로 판정하는 것이
* 핵심이다({@code EgovMberInsertView} = 등록화면 진입이지 등록이 아니다).</p>
*
* <p><b>처리대상(TRGET_ID):</b> 요청 파라미터에서 <u>식별자만</u> 우선순위로 취득한다. 검색어·
* 개인정보 본문은 담지 않는다(본문 미적재 원칙). 다건 선택(체크박스)이면 첫 값 + 건수를
* TRGET_CN에 남긴다.</p>
*
* <p><b>업무 무영향 보장:</b> {@code afterHandle} 대신 {@code afterCompletion}에서 적재하며
* (처리결과 성공/실패를 알아야 한다), 큐 enqueue만 하고 즉시 반환한다(#33). 이 인터셉터에서
* 발생한 예외는 전부 삼키고 로그만 남긴다 — 접속기록 수집이 업무를 막아서는 안 된다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (SFR-004 — 서비스 AOP 수집 → 관리자 화면 메뉴 접속기록 전환)
* </pre>
*/
public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(MenuAccessLogInterceptor.class);
/** 접속일시 형식 — 해시체인 재검증 시 DB 적재 문자열과 일치해야 한다(#39). */
private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 메뉴 미해석 경로의 업무코드. */
private static final String ETC_JOB_CODE = "MNT_ETC";
/** 메뉴 기반 업무코드 접두 — JOB_SE_CODE 는 VARCHAR(30). */
private static final String MENU_JOB_CODE_PREFIX = "MENU_";
/** JOB_SE_CODE 컬럼 길이 보호. */
private static final int JOB_SE_CODE_MAX = 30;
/** JOB_SE_NM 컬럼 길이 보호. */
private static final int JOB_SE_NM_MAX = 100;
/** TRGET_ID 컬럼 길이 보호. */
private static final int TRGET_ID_MAX = 50;
/**
* 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다
* (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다).
*/
private static final String[] TRGET_ID_PARAMS = {
"esntlId", "uniqId", "selectedId", "mberId", "emplyrId", "entrprsId",
"groupId", "userId", "checkedIdForDel", "checkedIdForUpdt", "sn"
};
// ===================== 설정(주입) =====================
/**
* 기록 대상 화면 정규식 화이트리스트(개인정보 취급 관리자 화면). 여기에 걸리는 요청만 기록한다.
* 미설정이면 아무것도 기록하지 않는다(과수집 방지 기본값).
*/
private List<String> includePatterns;
/** 기록 제외 정규식 패턴(프레임/공통 include 등). contextPath 제외 경로 대상. */
private List<String> excludePatterns;
/** 개인정보취급자로 볼 사용자구분 코드. 기본 {USR}. */
private Set<String> handlerUserSes;
/** 메뉴 캐시 유효시간(초). 기본 600초. */
private int cacheTtlSeconds = 600;
/** 메뉴명 조회 DAO(루트 컨텍스트 @Repository, 자식→부모 가시성으로 주입). */
private PrivacyMenuAuthDAO menuAuthDAO;
/** 비동기 적재 큐(루트 컨텍스트 @Component, 자식→부모 가시성으로 주입). */
private AccessLogQueue accessLogQueue;
// ===================== 캐시 =====================
/** 메뉴번호 → 메뉴 */
private volatile Map<String, MenuNameVO> byMenuNo;
/** 경로('?' 앞) → 메뉴 */
private volatile Map<String, MenuNameVO> byPath;
/** 디렉터리('/' 로 끝남) → 그 디렉터리의 메뉴 목록 */
private volatile Map<String, List<MenuNameVO>> byDir;
/** 캐시 적재 시각(ms) */
private volatile long cacheLoadedAt = 0L;
public void setIncludePatterns(List<String> includePatterns) { this.includePatterns = includePatterns; }
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
public void setHandlerUserSes(Set<String> handlerUserSes) { this.handlerUserSes = handlerUserSes; }
public void setCacheTtlSeconds(int cacheTtlSeconds) { this.cacheTtlSeconds = cacheTtlSeconds; }
public void setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; }
public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; }
/**
* 요청 종료 시점에 접속기록 1건을 남긴다.
* 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다.
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
try {
writeAccessLog(request, response, ex);
} catch (Throwable t) {
// 접속기록 수집은 업무 흐름을 절대 방해하지 않는다.
LOG.error("[개인정보/SFR-004] 메뉴 접속기록 수집 중 예외(무시)", t);
}
}
private void writeAccessLog(HttpServletRequest request, HttpServletResponse response, Exception ex) {
String path = requestPath(request);
// 1) 개인정보 취급 관리자 화면인가(화이트리스트)
if (!isIncluded(path)) {
return;
}
// 2) 화이트리스트 안이지만 빼야 하는 것인가(프레임·부서관리 등)
if (isExcluded(path)) {
return;
}
// 3) 개인정보취급자 세션인가
LoginVO login = handlerLogin();
if (login == null) {
return;
}
MenuNameVO menu = resolveMenu(request, path);
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
vo.setConectId(login.getId());
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
vo.setConectIp(safeIp(request));
if (menu != null) {
vo.setJobSeCode(cut(MENU_JOB_CODE_PREFIX + nvl(menu.getMenuNo()), JOB_SE_CODE_MAX));
vo.setJobSeNm(cut(menu.getMenuNm(), JOB_SE_NM_MAX));
} else {
vo.setJobSeCode(ETC_JOB_CODE);
vo.setJobSeNm(cut(path, JOB_SE_NM_MAX));
}
vo.setDmlGubun(deriveDmlGubun(path));
vo.setTrgetId(extractTrgetId(request));
vo.setTrgetCn(extractTrgetCn(request));
vo.setProcessResult(isFailure(response, ex) ? "F" : "S");
vo.setUserSe(login.getUserSe());
if (accessLogQueue == null) {
LOG.error("[개인정보/SFR-004] accessLogQueue 미주입 — 접속기록 적재 불가: path={}", path);
return;
}
// 큐에 적재만 하고 즉시 반환 — 봉인+INSERT 는 단일 컨슈머가 순차 수행(해시체인 순번 보장, #33).
accessLogQueue.enqueue(vo);
}
// ===================== 대상 판정 =====================
/** contextPath 를 제외한 요청 경로(쿼리 없음). */
private String requestPath(HttpServletRequest request) {
String ctx = request.getContextPath();
String uri = request.getRequestURI();
if (uri == null) {
return "";
}
return (ctx != null && ctx.length() > 0 && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
}
/**
* 개인정보 취급 화면 화이트리스트 판정.
* 미설정이면 아무것도 기록하지 않는다 — 설정 누락이 전 화면 과수집으로 이어지지 않게 하는 기본값이다.
*/
private boolean isIncluded(String path) {
if (includePatterns == null || includePatterns.isEmpty()) {
return false;
}
for (int i = 0; i < includePatterns.size(); i++) {
String p = includePatterns.get(i);
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
return true;
}
}
return false;
}
private boolean isExcluded(String path) {
if (excludePatterns == null) {
return false;
}
for (int i = 0; i < excludePatterns.size(); i++) {
String p = excludePatterns.get(i);
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
return true;
}
}
return false;
}
/**
* 인증된 개인정보취급자 로그인 정보. 취급자가 아니거나 미인증이면 null.
* userSe 가 비어 있으면 취급자 기록 누락을 막기 위해 통과시킨다(fail-safe).
*/
private LoginVO handlerLogin() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO login = (LoginVO) o;
if (!notBlank(login.getId())) {
return null;
}
String userSe = login.getUserSe();
if (!notBlank(userSe)) {
return login;
}
if (handlerUserSes == null || handlerUserSes.isEmpty()) {
return login;
}
return handlerUserSes.contains(userSe) ? login : null;
}
/**
* 처리결과 실패 판정 — 예외 발생 또는 4xx/5xx 응답.
*
* <p>{@code HttpServletResponse.getStatus()}는 Servlet 3.0부터다. 이 프로젝트는
* Servlet 2.5 API 로 컴파일되므로 직접 호출하면 컴파일되지 않는다. 런타임 컨테이너는
* Tomcat 9(Servlet 4.0)이라 실제로는 존재하므로 리플렉션으로 부르고, 없으면 성공으로 둔다.</p>
*/
private boolean isFailure(HttpServletResponse response, Exception ex) {
if (ex != null) {
return true;
}
if (response == null) {
return false;
}
try {
Method m = response.getClass().getMethod("getStatus");
Object status = m.invoke(response);
return (status instanceof Integer) && ((Integer) status).intValue() >= 400;
} catch (Throwable t) {
// Servlet 2.5 컨테이너/래퍼 등 미지원 — 판정 불가 시 성공으로 둔다.
return false;
}
}
private String safeIp(HttpServletRequest request) {
try {
String ip = ClientIpResolver.getClientIp(request);
return notBlank(ip) ? ip : "-";
} catch (Exception e) {
return "-";
}
}
// ===================== 메뉴명 해석 =====================
/**
* 요청을 메뉴로 해석한다. 해석 순서는 클래스 Javadoc 참조.
*
* @return 매칭된 메뉴. 해석 불가면 null(호출측이 경로 폴백)
*/
private MenuNameVO resolveMenu(HttpServletRequest request, String path) {
Map<String, MenuNameVO> noMap;
Map<String, MenuNameVO> pathMap;
Map<String, List<MenuNameVO>> dirMap;
try {
ensureCache();
noMap = this.byMenuNo;
pathMap = this.byPath;
dirMap = this.byDir;
} catch (Throwable t) {
LOG.error("[개인정보/SFR-004] 메뉴 캐시 로드 실패 — 경로 폴백으로 기록: path=" + path, t);
return null;
}
if (noMap == null || pathMap == null || dirMap == null) {
return null;
}
// 1) page 파라미터(메뉴번호) — arko 관리자 링크의 표준 단서
String page = trim(request.getParameter("page"));
if (notBlank(page)) {
MenuNameVO byNo = noMap.get(page);
if (byNo != null) {
return byNo;
}
}
// 2) 경로 정확 일치
MenuNameVO exact = pathMap.get(path);
if (exact != null) {
return exact;
}
// 3) 같은 디렉터리 내 최장 공통접두 — 메뉴에 없는 하위 액션을 소관 메뉴로 귀속
return resolveByDirectory(dirMap, path);
}
/**
* 같은 디렉터리의 메뉴 중 요청 경로와 공통접두가 가장 긴 것을 고른다.
* 동률이면 메뉴번호가 작은 쪽(상위/기본 화면일 확률이 높다)을 택해 결과를 결정적으로 만든다.
*/
private MenuNameVO resolveByDirectory(Map<String, List<MenuNameVO>> dirMap, String path) {
int slash = path.lastIndexOf('/');
if (slash < 0) {
return null;
}
List<MenuNameVO> candidates = dirMap.get(path.substring(0, slash + 1));
if (candidates == null || candidates.isEmpty()) {
return null;
}
MenuNameVO best = null;
int bestLen = -1;
for (int i = 0; i < candidates.size(); i++) {
MenuNameVO cand = candidates.get(i);
int len = commonPrefixLength(path, stripQuery(cand.getUrl()));
if (len > bestLen || (len == bestLen && best != null && compareMenuNo(cand, best) < 0)) {
best = cand;
bestLen = len;
}
}
return best;
}
private int commonPrefixLength(String a, String b) {
if (a == null || b == null) {
return 0;
}
int max = Math.min(a.length(), b.length());
int i = 0;
while (i < max && a.charAt(i) == b.charAt(i)) {
i++;
}
return i;
}
/** 메뉴번호 비교(숫자 우선, 파싱 불가 시 문자열). */
private int compareMenuNo(MenuNameVO a, MenuNameVO b) {
String x = nvl(a.getMenuNo());
String y = nvl(b.getMenuNo());
try {
return Long.valueOf(Long.parseLong(x)).compareTo(Long.valueOf(Long.parseLong(y)));
} catch (NumberFormatException e) {
return x.compareTo(y);
}
}
/** 캐시가 없거나 TTL 경과 시 메뉴를 재로드한다(더블체크 락). */
private void ensureCache() {
long now = System.currentTimeMillis();
long ttlMs = (long) cacheTtlSeconds * 1000L;
if (byPath != null && (now - cacheLoadedAt) < ttlMs) {
return;
}
synchronized (this) {
now = System.currentTimeMillis();
if (byPath != null && (now - cacheLoadedAt) < ttlMs) {
return;
}
loadCache();
}
}
/** 메뉴 전량 로드 후 캐시 갱신. 실패 시 기존 캐시 보존(로그만). */
private void loadCache() {
if (menuAuthDAO == null) {
LOG.error("[개인정보/SFR-004] menuAuthDAO 미주입 — 메뉴명 캐시 로드 불가(경로 폴백으로 기록)");
return;
}
try {
List<MenuNameVO> rows = menuAuthDAO.selectMenuNames();
Map<String, MenuNameVO> noMap = new HashMap<String, MenuNameVO>();
Map<String, MenuNameVO> pathMap = new HashMap<String, MenuNameVO>();
Map<String, List<MenuNameVO>> dirMap = new HashMap<String, List<MenuNameVO>>();
if (rows != null) {
for (int i = 0; i < rows.size(); i++) {
MenuNameVO row = rows.get(i);
if (row == null || !notBlank(row.getMenuNm())) {
continue;
}
if (notBlank(row.getMenuNo())) {
noMap.put(trim(row.getMenuNo()), row);
}
String p = stripQuery(row.getUrl());
if (!notBlank(p)) {
continue;
}
// 같은 경로에 메뉴가 둘 이상이면 먼저 온 것을 유지(ORDER 없는 조회라 결정성은
// 메뉴번호로 보정하지 않는다 — 실제 중복은 운영에서 메뉴 정리 대상).
if (!pathMap.containsKey(p)) {
pathMap.put(p, row);
}
int slash = p.lastIndexOf('/');
if (slash >= 0) {
String dir = p.substring(0, slash + 1);
List<MenuNameVO> bucket = dirMap.get(dir);
if (bucket == null) {
bucket = new ArrayList<MenuNameVO>();
dirMap.put(dir, bucket);
}
bucket.add(row);
}
}
}
this.byMenuNo = noMap;
this.byPath = pathMap;
this.byDir = dirMap;
this.cacheLoadedAt = System.currentTimeMillis();
if (LOG.isInfoEnabled()) {
LOG.info("[개인정보/SFR-004] 메뉴명 캐시 로드 완료: menus={}, paths={}, dirs={}",
new Object[] { Integer.valueOf(noMap.size()), Integer.valueOf(pathMap.size()),
Integer.valueOf(dirMap.size()) });
}
} catch (Exception e) {
LOG.error("[개인정보/SFR-004] 메뉴명 캐시 로드 실패(기존 캐시 유지)", e);
}
}
// ===================== 처리구분·처리대상 =====================
/**
* URL 마지막 세그먼트(.do 파일명)로 처리구분을 유도한다.
*
* <p>판정 순서가 중요하다.
* <ol>
* <li>{@code ~View}·{@code ~Pop}·{@code ~Form}으로 끝나면 <b>화면 진입</b>이므로 조회(S).
* {@code EgovMberInsertView}(등록화면)를 등록(I)으로 세면 실제 등록 건수가 부풀려진다.</li>
* <li>{@code select~}로 시작하면 조회(S). 이 규칙이 아래 I/U/D 토큰보다 <b>먼저</b>여야 한다 —
* {@code selectProappActivityApprovalList}(승인대상 목록조회)가 'approv' 때문에
* 수정(U)으로 잡히는 것을 막는다.</li>
* <li>삭제 → 등록 → 수정 토큰 순.</li>
* </ol>
* 짧고 흔한 토큰({@code add}·{@code save}·{@code write}·{@code edit}·{@code chang})은
* 부분일치로 쓰면 오판정한다({@code EgovAddress~}의 'add', {@code exchange}의 'chang').
* 이들은 접두 일치로만 본다.</p>
*/
static String deriveDmlGubun(String path) {
String name = lastSegment(path);
if (name == null) {
return "S";
}
String n = name.toLowerCase();
if (n.endsWith(".do")) {
n = n.substring(0, n.length() - 3);
}
// 1) 등록/수정 '화면' 진입은 조회다.
if (n.endsWith("view") || n.endsWith("pop") || n.endsWith("popup") || n.endsWith("form")) {
return "S";
}
// 2) select~ 는 조회 확정 — 뒤의 행위 토큰보다 우선한다.
if (n.startsWith("select")) {
return "S";
}
// 3) 삭제
if (contains(n, "delete") || contains(n, "remov") || n.endsWith("del")) {
return "D";
}
// 4) 등록
if (contains(n, "insert") || contains(n, "regist") || contains(n, "creat")
|| n.startsWith("add") || n.startsWith("save") || n.startsWith("write")) {
return "I";
}
// 5) 수정
if (contains(n, "updt") || contains(n, "update") || contains(n, "modify")
|| contains(n, "approv") || n.startsWith("chang") || n.startsWith("edit")) {
return "U";
}
return "S";
}
/**
* 요청 파라미터에서 처리대상 식별자를 취득한다(우선순위: {@link #TRGET_ID_PARAMS}).
* 검색어·개인정보 본문은 담지 않는다. 다건이면 첫 값만 담고 건수는 TRGET_CN 이 맡는다.
*/
private String extractTrgetId(HttpServletRequest request) {
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
if (values == null) {
continue;
}
for (int j = 0; j < values.length; j++) {
String v = trim(values[j]);
if (notBlank(v)) {
return cut(v, TRGET_ID_MAX);
}
}
}
return null;
}
/** 다건 선택(체크박스 등)일 때만 "N건"을 남긴다. 단건은 TRGET_ID 로 충분하다. */
private String extractTrgetCn(HttpServletRequest request) {
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
if (values == null) {
continue;
}
int count = 0;
for (int j = 0; j < values.length; j++) {
if (notBlank(values[j])) {
count++;
}
}
if (count > 1) {
return count + "건";
}
if (count == 1) {
return null;
}
}
return null;
}
// ===================== 유틸 =====================
private static boolean contains(String s, String token) {
return s.indexOf(token) >= 0;
}
private static String lastSegment(String path) {
if (path == null) {
return null;
}
int slash = path.lastIndexOf('/');
return (slash >= 0) ? path.substring(slash + 1) : path;
}
private static String stripQuery(String url) {
if (url == null) {
return null;
}
int q = url.indexOf('?');
return (q >= 0) ? url.substring(0, q) : url;
}
private static String cut(String s, int max) {
if (s == null) {
return null;
}
String t = s.trim();
return (t.length() > max) ? t.substring(0, max) : t;
}
private static String trim(String s) {
return (s == null) ? null : s.trim();
}
private static String nvl(String s) {
return (s == null) ? "" : s;
}
private static boolean notBlank(String s) {
return s != null && s.trim().length() > 0;
}
}

View File

@ -0,0 +1,63 @@
package egovframework.com.cmm.privacy.interceptor;
import java.io.Serializable;
/**
* 메뉴번호 → 메뉴명/URL 매핑 행(row) VO.
*
* <p>개인정보보호 기능개선 사업(SFR-004 접속기록) — {@link MenuAccessLogInterceptor}가
* "관리자 화면에서 어떤 메뉴를 열었는지"를 접속기록의 수행업무로 남기기 위해,
* 기존 arko 메뉴 2테이블(COMTNMENUINFO + COMTNPROGRMLIST)을 조인해 캐싱하는 단일 행 표현.</p>
*
* <ul>
* <li>menuNo : 메뉴번호(COMTNMENUINFO.MENU_NO). arko 관리자 화면은 URL 에 {@code ?page=<메뉴번호>}
* 를 붙여 좌측메뉴 활성화를 판정하므로, 요청 파라미터 {@code page} 로도 직접 매칭된다.</li>
* <li>menuNm : 메뉴명(COMTNMENUINFO.MENU_NM). 접속기록의 수행업무명(JOB_SE_NM)이 된다.</li>
* <li>url : 프로그램 URL(COMTNPROGRMLIST.URL). 쿼리스트링 포함 가능 →
* 인터셉터가 '?' 앞 경로만 취해 캐시에 적재</li>
* </ul>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 — 관리자 메뉴 접속기록 전환)
* </pre>
*/
public class MenuNameVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 메뉴번호(MENU_NO) */
private String menuNo;
/** 메뉴명(MENU_NM) */
private String menuNm;
/** 프로그램 URL(쿼리스트링 포함 가능) */
private String url;
public String getMenuNo() {
return menuNo;
}
public void setMenuNo(String menuNo) {
this.menuNo = menuNo;
}
public String getMenuNm() {
return menuNm;
}
public void setMenuNm(String menuNm) {
this.menuNm = menuNm;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@ -21,6 +21,7 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
* 수정일 수정자 수정내용 * 수정일 수정자 수정내용
* ---------- ------ --------------------------- * ---------- ------ ---------------------------
* 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2) * 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
* 2026.08.07 URP selectMenuNames 추가 (SFR-004 — 관리자 메뉴 접속기록 전환)
* </pre> * </pre>
*/ */
@Repository("privacyMenuAuthDAO") @Repository("privacyMenuAuthDAO")
@ -37,4 +38,20 @@ public class PrivacyMenuAuthDAO extends EgovComAbstractDAO {
public List<MenuAuthVO> selectMenuUrlRoles() throws Exception { public List<MenuAuthVO> selectMenuUrlRoles() throws Exception {
return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null); return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null);
} }
/**
* (메뉴번호, 메뉴명, 프로그램 URL) 전체 목록을 조회한다(파라미터 없음).
*
* <p>{@link MenuAccessLogInterceptor}가 "요청 URL·page 파라미터 → 메뉴명"을 해석하기 위해
* 최초 요청 시 1회 전량 로드해 캐싱한다. 권한 배정(COMTNMENUCREATDTLS)과 무관하게
* <b>메뉴에 연결된 프로그램 전체</b>를 대상으로 한다 — 롤에 배정되지 않은 메뉴로 진입한
* 요청도 기록해야 하기 때문이다(기록 대상 판정은 인터셉터의 경로 접두·취급자 구분이 담당).</p>
*
* @return (menuNo, menuNm, url) 목록. 없으면 빈 목록.
* @throws Exception 조회 오류
*/
@SuppressWarnings("unchecked")
public List<MenuNameVO> selectMenuNames() throws Exception {
return list("PrivacyMenuAuthDAO.selectMenuNames", null);
}
} }

View File

@ -244,7 +244,11 @@ private FilterConfig config;
if(loginVO != null && loginVO.getId() != null && !loginVO.getId().equals("")) if(loginVO != null && loginVO.getId() != null && !loginVO.getId().equals(""))
{ {
System.out.println("1필터액션::[" + loginVO.getId()+"]["+password+"]"); // 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거.
// 종전: println("1필터액션::["+id+"]["+password+"]") — 로그인 성공 전건의
// 평문 비밀번호가 catalina.out 에 누적되어 로그 파일 자체가 유출원이 되었다.
// 인증정보(비밀번호/해시/OTP비밀키)는 어떤 형태로도 로그에 남기지 않는다.
System.out.println("1필터액션::[" + loginVO.getId()+"]");
// ip filtering // ip filtering
String myIp = ((HttpServletRequest)request).getRemoteAddr(); String myIp = ((HttpServletRequest)request).getRemoteAddr();

View File

@ -7,7 +7,6 @@ import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import egovframework.com.cmm.EgovWebUtil;
import egovframework.com.cmm.LoginVO; import egovframework.com.cmm.LoginVO;
import egovframework.com.cop.ems.service.EgovSndngMailRegistService; import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
import egovframework.com.uat.uia.service.EgovLoginService; import egovframework.com.uat.uia.service.EgovLoginService;
@ -80,7 +79,12 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
{ {
System.out.println("로그인 아이디 체크입니다."); System.out.println("로그인 아이디 체크입니다.");
// 1. 입력한 비밀번호를 암호화한다. // 1. 입력한 비밀번호를 암호화한다.
String enpassword = EgovFileScrty.encryptPassword(vo.getPassword()); // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)은 저장값에 솔트가 들어 있으므로
// 먼저 저장값을 읽어 같은 솔트로 해시한다. 무솔트(기존) 계정·없는 아이디면
// stored 가 null 이 되어 encryptPasswordAs 가 종전과 동일한 무솔트 해시를 돌려준다.
// → 아래 loginDAO.actionLogin 의 'AND password = #password#' 조건은 그대로 둔다.
String stored = loginDAO.selectStoredPassword(vo);
String enpassword = EgovFileScrty.encryptPasswordAs(vo.getPassword(), stored);
vo.setPassword(enpassword); vo.setPassword(enpassword);
// 2. 아이디와 암호화된 비밀번호가 DB와 일치하는지 확인한다. // 2. 아이디와 암호화된 비밀번호가 DB와 일치하는지 확인한다.
@ -202,8 +206,9 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
} }
// 3. 임시 비밀번호를 암호화하여 DB에 저장한다. // 3. 임시 비밀번호를 암호화하여 DB에 저장한다.
// [개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 사용자별 랜덤 솔트를 적용한다.
LoginVO pwVO = new LoginVO(); LoginVO pwVO = new LoginVO();
String enpassword = EgovFileScrty.encryptPassword(newpassword); String enpassword = EgovFileScrty.encryptPasswordWithSalt(newpassword);
pwVO.setId(vo.getId()); pwVO.setId(vo.getId());
pwVO.setPassword(enpassword); pwVO.setPassword(enpassword);
pwVO.setUserSe(vo.getUserSe()); pwVO.setUserSe(vo.getUserSe());
@ -233,8 +238,9 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
*/ */
public void updatePassword(LoginVO vo) throws Exception { public void updatePassword(LoginVO vo) throws Exception {
// 임시 비밀번호를 암호화하여 DB에 저장한다. // 임시 비밀번호를 암호화하여 DB에 저장한다.
// [개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 사용자별 랜덤 솔트를 적용한다.
LoginVO pwVO = new LoginVO(); LoginVO pwVO = new LoginVO();
String enpassword = EgovFileScrty.encryptPassword(vo.getPassword()); String enpassword = EgovFileScrty.encryptPasswordWithSalt(vo.getPassword());
pwVO.setId(vo.getId()); pwVO.setId(vo.getId());
pwVO.setPassword(enpassword); pwVO.setPassword(enpassword);
pwVO.setUserSe(vo.getUserSe()); pwVO.setUserSe(vo.getUserSe());
@ -289,38 +295,54 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
// 휴먼고객 확인후 테이블 입력 // 휴먼고객 확인후 테이블 입력
@Override @Override
public void insertDiapause(LoginVO loginVO) throws Exception { public void insertDiapause(LoginVO loginVO) throws Exception {
EgovWebUtil egovUtil = new EgovWebUtil();
// 1) 일반회원(COMTNGNRLMBER) 휴면 대상 적재 + 유예/확정 처리 // 1) 일반회원(COMTNGNRLMBER) 휴면 대상 적재 + 유예/확정 처리
loginDAO.insertDiapause(loginVO); loginDAO.insertDiapause(loginVO);
processDiapauseList(loginDAO.selectDiapauseList(loginVO), egovUtil); processDiapauseList(loginDAO.selectDiapauseList(loginVO));
// 2) [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 확장 — 동일 유예/확정 체인 // 2) [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 확장 — 동일 유예/확정 체인
// COMTNDIAPAUSEMBER.MBER_TY_CODE='EMP' 로 분리되어 일반회원 흐름과 혼선 없음. // COMTNDIAPAUSEMBER.MBER_TY_CODE='EMP' 로 분리되어 일반회원 흐름과 혼선 없음.
loginDAO.insertDiapauseEmplyr(loginVO); loginDAO.insertDiapauseEmplyr(loginVO);
processDiapauseList(loginDAO.selectDiapauseListEmplyr(loginVO), egovUtil); processDiapauseList(loginDAO.selectDiapauseListEmplyr(loginVO));
} }
// 휴면 유예(→'S')/확정(→'P') 공통 처리 — 일반회원·개인정보취급자 공용(#7) // 휴면 유예(→'S')/확정(→'P') 공통 처리 — 일반회원·개인정보취급자 공용(#7)
private void processDiapauseList(List<LoginVO> list, EgovWebUtil egovUtil) throws Exception { //
// 2026-07-30 최초상태 판정 수정: insertDiapause/insertDiapauseEmplyr 는 DIAPAUSE_STAT 에
// 빈 문자열('')을 넣는다. Oracle 은 ''를 NULL 로 저장해 기존 `== null` 검사가 통했지만
// MySQL/MariaDB 는 ''를 그대로 보관하므로 두 분기 모두 빠져나가 map 이 빈 채로
// updateDiapauseMber 가 호출됐다(→ WHERE MBER_ID = NULL, 0건 갱신). 그 결과 신규 휴면 행이
// ''에 머물러 'S'·'P' 로 진행되지 않고, 보존기간 경과 파기(#12)까지 도달하지 못했다.
// 07-28 수정은 selectDiapauseList 의 조회조건만 ''를 인식하게 고쳤고 이 판정은 누락돼 있었다.
//
// 2026-07-30 본인 메일 발송 제거: 유예('S')·확정('P') 전이 시 보내던 본인 안내메일 2종
// (sendIdDiapauseMail/sendIdDiapauseStandbyMail)을 걷어냈다. 이용자 통지는 #10 사전알림
// 배치(D-30/D-7/D-Day)로 일원화하며, 발주 요구사항은 '비활성화 전 관리자 알림'이다.
// 발송이 빠지면서 이 루프가 순수 상태전이(PK 단건 UPDATE)만 남아 일일 처리 상한을
// 200 → 5,000 으로 올릴 수 있게 됐다(sqlmap 주석 참조).
private void processDiapauseList(List<LoginVO> list) throws Exception {
if (list == null) { if (list == null) {
return; return;
} }
for (int i = 0; i < list.size(); i++) { for (int i = 0; i < list.size(); i++) {
LoginVO vo = list.get(i); LoginVO vo = list.get(i);
HashMap<String, String> map = new HashMap<>(); HashMap<String, String> map = new HashMap<>();
String stat = vo.getDiapause_stat();
if (vo.getDiapause_stat() == null) { if (stat == null || stat.trim().length() == 0) {
egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
map.put("id", vo.getId()); map.put("id", vo.getId());
map.put("diapause_stat", "S"); map.put("diapause_stat", "S");
} else if (vo.getDiapause_stat().equals("S")) { } else if ("S".equals(stat.trim())) {
egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
map.put("id", vo.getId()); map.put("id", vo.getId());
map.put("diapause_stat", "P"); map.put("diapause_stat", "P");
} }
// 어느 분기에도 걸리지 않은 상태값은 갱신 대상이 아니다. 빈 map 으로 UPDATE 를 던지면
// WHERE MBER_ID = NULL 이 되어 무의미하므로 건너뛴다(위 주석의 사고 재발 방지).
if (map.isEmpty()) {
continue;
}
loginDAO.updateDiapauseMber(map); loginDAO.updateDiapauseMber(map);
} }
} }

View File

@ -54,6 +54,22 @@ public class LoginDAO extends EgovComAbstractDAO {
return (LoginVO)selectByPk("loginDAO.actionLogin", vo); return (LoginVO)selectByPk("loginDAO.actionLogin", vo);
} }
/**
* [개인정보보호 #27 후속] 저장된 비밀번호 값을 아이디만으로 조회한다.
*
* <p>솔트 적용 계정(S1: 형식)은 저장값에 솔트가 들어 있으므로, 로그인 시
* 평문을 해시하기 전에 이 값을 먼저 읽어야 한다. 무솔트(기존) 계정이거나
* 존재하지 않는 아이디면 null 이 되고, 그 경우 종전과 동일한 무솔트 해시로
* 계산되어 actionLogin 이 0건을 돌려준다(동작·계정열거 노출 모두 종전과 동일).</p>
*
* @param vo LoginVO (id, userSe 사용)
* @return 저장된 비밀번호 값(없으면 null)
* @exception Exception
*/
public String selectStoredPassword(LoginVO vo) throws Exception {
return (String)selectByPk("loginDAO.selectStoredPassword", vo);
}
/** /**
* 인증서 로그인을 처리한다 * 인증서 로그인을 처리한다
* @param vo LoginVO * @param vo LoginVO

View File

@ -75,7 +75,8 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements
String uniqId = idgenService.getNextStringId(); String uniqId = idgenService.getNextStringId();
entrprsManageVO.setUniqId(uniqId); entrprsManageVO.setUniqId(uniqId);
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
entrprsManageVO.setEntrprsMberPassword(pass); entrprsManageVO.setEntrprsMberPassword(pass);
String result = entrprsManageDAO.insertEntrprsmber(entrprsManageVO); String result = entrprsManageDAO.insertEntrprsmber(entrprsManageVO);
@ -101,7 +102,8 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements
*/ */
public void updateEntrprsmber(EntrprsManageVO entrprsManageVO) throws Exception { public void updateEntrprsmber(EntrprsManageVO entrprsManageVO) throws Exception {
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
entrprsManageVO.setEntrprsMberPassword(pass); entrprsManageVO.setEntrprsMberPassword(pass);
entrprsManageDAO.updateEntrprsmber(entrprsManageVO); entrprsManageDAO.updateEntrprsmber(entrprsManageVO);
} }

View File

@ -78,7 +78,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
String uniqId = idgenService.getNextStringId(); String uniqId = idgenService.getNextStringId();
mberManageVO.setUniqId(uniqId); mberManageVO.setUniqId(uniqId);
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
mberManageVO.setPassword(pass); mberManageVO.setPassword(pass);
String result = mberManageDAO.insertMber(mberManageVO); String result = mberManageDAO.insertMber(mberManageVO);
return result; return result;
@ -134,7 +135,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
*/ */
public void updateMber(MberManageVO mberManageVO) throws Exception { public void updateMber(MberManageVO mberManageVO) throws Exception {
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
mberManageVO.setPassword(pass); mberManageVO.setPassword(pass);
mberManageDAO.updateMber(mberManageVO); mberManageDAO.updateMber(mberManageVO);
} }
@ -237,7 +239,10 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
} }
public String searchMber(MberManageVO mberManageVO) throws Exception { public String searchMber(MberManageVO mberManageVO) throws Exception {
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); //[개인정보보호 #27 후속] 아이디+비밀번호를 WHERE 절에서 비교하는 경로.
//저장값을 먼저 읽어 같은 솔트로 해시한다(무솔트 계정이면 종전 동작 그대로).
String stored = mberManageDAO.selectStoredPasswordByMberId(mberManageVO);
String pass = EgovFileScrty.encryptPasswordAs(mberManageVO.getPassword(), stored);
mberManageVO.setPassword(pass); mberManageVO.setPassword(pass);
return mberManageDAO.searchMber(mberManageVO); return mberManageDAO.searchMber(mberManageVO);
} }

View File

@ -90,7 +90,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg
String uniqId = idgenService.getNextStringId(); String uniqId = idgenService.getNextStringId();
userManageVO.setUniqId(uniqId); userManageVO.setUniqId(uniqId);
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword()); //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword());
userManageVO.setPassword(pass); userManageVO.setPassword(pass);
String result = userManageDAO.insertUser(userManageVO); String result = userManageDAO.insertUser(userManageVO);
return result; return result;
@ -135,7 +136,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg
*/ */
public void updateUser(UserManageVO userManageVO) throws Exception { public void updateUser(UserManageVO userManageVO) throws Exception {
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword()); //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword());
userManageVO.setPassword(pass); userManageVO.setPassword(pass);
userManageDAO.updateUser(userManageVO); userManageDAO.updateUser(userManageVO);

View File

@ -175,6 +175,17 @@ public class MberManageDAO extends EgovComAbstractDAO{
return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.searchMber_S", mberManageVO); return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.searchMber_S", mberManageVO);
} }
/**
* [개인정보보호 #27 후속] 회원아이디로 저장된 비밀번호 값을 조회한다.
*
* <p>searchMber_S 는 비밀번호를 WHERE 절에서 비교하므로, 솔트 적용 계정을 판별하려면
* 평문을 해시하기 전에 저장값(솔트 포함)을 먼저 읽어야 한다. selectPassword_S 는
* ESNTL_ID 기준이라 아이디만 아는 이 경로에서는 쓸 수 없어 별도 statement 를 둔다.</p>
*/
public String selectStoredPasswordByMberId(MberManageVO mberManageVO){
return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectStoredPasswordByMberId_S", mberManageVO);
}
public int selectMberTalentCnt(String mberId) { public int selectMberTalentCnt(String mberId) {
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberTalentCnt", mberId); return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberTalentCnt", mberId);

View File

@ -852,7 +852,8 @@ public class EgovEntrprsManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
entrprsManageService.updatePassword(entrprsManageVO); entrprsManageService.updatePassword(entrprsManageVO);
model.addAttribute("entrprsManageVO", entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -928,10 +928,11 @@ public class EgovMberManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = mberManageService.selectPassword(mberManageVO); resultVO = mberManageService.selectPassword(mberManageVO);
//패스워드 암호화 //패스워드 암호화
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword); // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
String encryptPass = resultVO.getPassword(); // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다.
if (encryptPass.equals(resultVO.getPassword())){ // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -944,7 +945,8 @@ public class EgovMberManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
mberManageService.updatePassword(mberManageVO); mberManageService.updatePassword(mberManageVO);
model.addAttribute("mberManageVO", mberManageVO); model.addAttribute("mberManageVO", mberManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -609,10 +609,12 @@ public class EgovUserManageController {
resultVO = userManageService.selectPassword(userManageVO); resultVO = userManageService.selectPassword(userManageVO);
//패스워드 암호화 //패스워드 암호화
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword); // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
String encryptPass = resultVO.getPassword(); // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다
if (encryptPass.equals(resultVO.getPassword())){ // (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음).
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -625,7 +627,8 @@ public class EgovUserManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
userManageService.updatePassword(userManageVO); userManageService.updatePassword(userManageVO);
model.addAttribute("userManageVO", userManageVO); model.addAttribute("userManageVO", userManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";
@ -693,10 +696,12 @@ public class EgovUserManageController {
resultVO = userManageService.selectPassword(userManageVO); resultVO = userManageService.selectPassword(userManageVO);
//패스워드 암호화 //패스워드 암호화
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword); // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
String encryptPass = resultVO.getPassword(); // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다
if (encryptPass.equals(resultVO.getPassword())){ // (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음).
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -709,7 +714,8 @@ public class EgovUserManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
userManageService.updatePassword(userManageVO); userManageService.updatePassword(userManageVO);
model.addAttribute("userManageVO", userManageVO); model.addAttribute("userManageVO", userManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -280,6 +280,135 @@ public class EgovFileScrty {
return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes())); return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes()));
} }
// =====================================================================
// [개인정보보호 #27 후속] 사용자별 랜덤 솔트 적용 (SER-003 인증정보 보호)
// ---------------------------------------------------------------------
// 점검노트(개인정보보호_비밀번호해시_점검노트.md) 권고 1의 이행분.
// 알고리즘은 종전과 동일한 SHA-256이며, 신규로 설정되는 비밀번호에만
// 사용자별 랜덤 솔트를 부여한다. 기존 비밀번호(무솔트)는 손대지 않는다.
//
// [저장형식] 솔트 저장용 컬럼을 새로 만들지 않고, 비밀번호 컬럼 안에
// 자기기술(self-describing) 형식으로 함께 담는다.
//
// S1:<Base64 솔트 24자>:<Base64 해시 44자> → 총 71자
// (기존 무솔트 값은 접두어 없는 Base64 해시 44자)
//
// · Base64 문자집합(A-Za-z0-9+/=)에 ':' 가 없어 구분자로 안전하다.
// · 대상 4개 컬럼(COMTNGNRLMBER.PASSWORD / COMTNEMPLYRINFO.PASSWORD /
// COMTNENTRPRSMBER.ENTRPRS_MBER_PASSWORD / BIZ_GROUP.GROUP_PASSWORD)은
// VARCHAR(200) 이므로 자릿수 여유가 충분하다(DDL 로 사전 확인).
// · 솔트 컬럼을 따로 두지 않으므로 회원 테이블 4종의 INSERT/UPDATE
// sqlmap(Mysql/Oracle 8본)과 VO 를 건드리지 않는다 → 변경 파급 최소화.
//
// [마이그레이션] 강제 일괄전환 없음. 아래 규칙으로 자연 전환된다.
// · 신규 가입 → 솔트 적용(S1 형식)
// · 기존 계정 로그인 → 저장값 그대로 무솔트 검증(변화 없음)
// · 기존 계정 비번변경 → 그 시점부터 솔트 적용
//
// [주의] 게시물·댓글·만족도 비밀번호는 계정 인증정보가 아니므로 이 경로를
// 적용하지 않는다(종전 encryptPassword(String) 유지).
// =====================================================================
/** 솔트 적용 비밀번호의 저장형식 접두어. 이 값으로 시작하면 솔트 방식이다. */
private static final String SALT_PREFIX = "S1:";
/** 솔트 길이(바이트). Base64 인코딩 시 24자가 된다. */
private static final int SALT_BYTE_LENGTH = 16;
/** 솔트 생성용 난수원. SecureRandom 은 thread-safe 하므로 공유한다. */
private static final java.security.SecureRandom SALT_RANDOM = new java.security.SecureRandom();
/**
* 사용자별 랜덤 솔트를 생성한다(Base64 24자).
*
* @return Base64 인코딩된 솔트
*/
public static String createSalt() {
byte[] salt = new byte[SALT_BYTE_LENGTH];
SALT_RANDOM.nextBytes(salt);
return new String(Base64.encodeBase64(salt));
}
/**
* 신규로 설정되는 비밀번호를 솔트 적용하여 암호화한다(가입·비밀번호 변경 경로 전용).
*
* @param data 평문 비밀번호
* @return "S1:솔트:해시" 형식의 저장값
* @exception Exception
*/
public static String encryptPasswordWithSalt(String data) throws Exception {
if (data == null) {
return "";
}
String salt = createSalt();
return SALT_PREFIX + salt + ":" + encryptPassword(data, salt.getBytes("UTF-8"));
}
/**
* 저장값과 같은 방식으로 평문을 암호화한다.
*
* <p>저장값이 솔트 형식(S1:)이면 그 솔트를 재사용해 같은 형식의 문자열을,
* 아니면(기존 무솔트 계정) 종전 방식의 해시를 돌려준다. 결과를 저장값과
* 문자열 비교하면 검증이 되므로, <b>비밀번호를 WHERE 절에서 비교하는
* 기존 로그인 SQL을 수정하지 않고</b> 솔트를 도입할 수 있다.</p>
*
* @param data 평문 비밀번호
* @param stored DB에 저장된 비밀번호 값(없으면 null 허용)
* @return stored 와 같은 형식으로 암호화된 문자열
* @exception Exception
*/
public static String encryptPasswordAs(String data, String stored) throws Exception {
String salt = extractSalt(stored);
if (salt == null) {
// 기존(무솔트) 계정 또는 존재하지 않는 계정 → 종전 동작 그대로
return encryptPassword(data);
}
return SALT_PREFIX + salt + ":" + encryptPassword(data, salt.getBytes("UTF-8"));
}
/**
* 평문 비밀번호가 저장값과 일치하는지 검증한다(솔트·무솔트 양쪽 지원).
*
* <p>이미 저장값을 손에 쥐고 있는 경로(마이페이지 비밀번호 확인, 비밀번호
* 변경 시 현재 비밀번호 확인 등)에서는 이 메서드를 쓴다. 종전의
* {@code encryptPassword(raw).equals(stored)} 를 그대로 대체한다.</p>
*
* @param data 평문 비밀번호
* @param stored DB에 저장된 비밀번호 값
* @return 일치 여부
* @exception Exception
*/
public static boolean matches(String data, String stored) throws Exception {
if (data == null || stored == null) {
return false;
}
String salt = extractSalt(stored);
if (salt == null) {
// 무솔트 저장값: 종전 방식으로 해시 후 상수시간 비교
return MessageDigest.isEqual(encryptPassword(data).getBytes("UTF-8"), stored.getBytes("UTF-8"));
}
String encoded = stored.substring(SALT_PREFIX.length() + salt.length() + 1);
return checkPassword(data, encoded, salt.getBytes("UTF-8"));
}
/**
* 저장값이 솔트 형식이면 솔트를 꺼내고, 아니면 null 을 돌려준다.
*
* @param stored DB에 저장된 비밀번호 값
* @return Base64 솔트 또는 null(무솔트·형식오류)
*/
private static String extractSalt(String stored) {
if (stored == null || !stored.startsWith(SALT_PREFIX)) {
return null;
}
int end = stored.indexOf(':', SALT_PREFIX.length());
if (end < 0 || end == SALT_PREFIX.length()) {
// 접두어만 있고 형식이 깨진 값은 무솔트로 취급(검증 실패로 귀결)
return null;
}
return stored.substring(SALT_PREFIX.length(), end);
}
/* /*
public static void main(String[] args) { public static void main(String[] args) {
try { try {

View File

@ -42,7 +42,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM
public void updateLibMber(MberManageVO mberManageVO) throws Exception { public void updateLibMber(MberManageVO mberManageVO) throws Exception {
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
mberManageVO.setPassword(pass); mberManageVO.setPassword(pass);
bizMberMergeDAO.updateLibMber(mberManageVO); bizMberMergeDAO.updateLibMber(mberManageVO);
} }
@ -53,7 +54,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM
public void updateLibActivity(EntrprsManageVO entrprsManageVO) throws Exception { public void updateLibActivity(EntrprsManageVO entrprsManageVO) throws Exception {
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword()); //[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
entrprsManageVO.setEntrprsMberPassword(pass); entrprsManageVO.setEntrprsMberPassword(pass);
bizMberMergeDAO.updateLibActivity(entrprsManageVO); bizMberMergeDAO.updateLibActivity(entrprsManageVO);
} }

View File

@ -647,10 +647,8 @@ public class BizEntrprsFrtManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = entrprsManageService.selectPassword(entrprsManageVO); resultVO = entrprsManageService.selectPassword(entrprsManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(entrprsManageVO.getOldPassword()); if (EgovFileScrty.matches(entrprsManageVO.getOldPassword(), resultVO.getEntrprsMberPassword())){
if (encryptPass.equals(resultVO.getEntrprsMberPassword())){
if (entrprsManageVO.getNewPassword().equals(entrprsManageVO.getNewPassword2())){ if (entrprsManageVO.getNewPassword().equals(entrprsManageVO.getNewPassword2())){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -663,7 +661,8 @@ public class BizEntrprsFrtManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(entrprsManageVO.getNewPassword())); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getNewPassword()));
entrprsManageService.updatePassword(entrprsManageVO); entrprsManageService.updatePassword(entrprsManageVO);
model.addAttribute("entrprsManageVO", entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -533,10 +533,8 @@ public class BizGroupFrtManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = groupManageService.selectPassword(groupManageVO); resultVO = groupManageService.selectPassword(groupManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword()); if (EgovFileScrty.matches(groupManageVO.getGroupPassword(), resultVO.getGroupPassword())){
if (encryptPass.equals(resultVO.getGroupPassword())){
groupManageService.deleteGroupMberApp(loginVO.getUniqId()); groupManageService.deleteGroupMberApp(loginVO.getUniqId());
model.addAttribute("resultMsg", "success.request.msg"); model.addAttribute("resultMsg", "success.request.msg");
}else{ }else{
@ -578,10 +576,8 @@ public class BizGroupFrtManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = groupManageService.selectPassword(groupManageVO); resultVO = groupManageService.selectPassword(groupManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(oldPassword); if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){
if (encryptPass.equals(resultVO.getGroupPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -594,7 +590,8 @@ public class BizGroupFrtManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
groupManageService.updatePassword(groupManageVO); groupManageService.updatePassword(groupManageVO);
model.addAttribute("groupManageVO", groupManageVO); model.addAttribute("groupManageVO", groupManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -513,14 +513,14 @@ public class BizMberFrtManageController {
String resultMsg = ""; String resultMsg = "";
String returnUrl = ""; String returnUrl = "";
System.out.println("타입체크::"+user.getUserSe()); System.out.println("타입체크::"+user.getUserSe());
System.out.println("1패스워드확인::"+mberManageVO.getPassword()); // 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출)
String encryptPass = ""; System.out.println("1패스워드확인::요청됨");
if(user.getUserSe().equals("ENT")) if(user.getUserSe().equals("ENT"))
{ {
entVO = entrManageService.selectPassword(entVO); entVO = entrManageService.selectPassword(entVO);
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
if (encryptPass.equals(entVO.getEntrprsMberPassword())){ if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do"; returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
}else{ }else{
resultMsg="fail.user.passwordUpdate1"; resultMsg="fail.user.passwordUpdate1";
@ -531,11 +531,9 @@ public class BizMberFrtManageController {
else else
{ {
resultVO = mberManageService.selectPassword(mberManageVO); resultVO = mberManageService.selectPassword(mberManageVO);
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
// 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 log.debug 출력 제거(인증정보 노출).
log.debug("encryptPass =>"+encryptPass); if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword()))
if (encryptPass.equals(resultVO.getPassword()))
{ {
returnUrl = "redirect:/myp/umt/bizMberSelectUpdtView.do"; returnUrl = "redirect:/myp/umt/bizMberSelectUpdtView.do";
} }
@ -548,10 +546,8 @@ public class BizMberFrtManageController {
} }
System.out.println("비밀번호 체크후이동"+returnUrl); System.out.println("비밀번호 체크후이동"+returnUrl);
//패스워드 암호화 // 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 표준출력 제거(인증정보 노출, 점검노트 권고 3).
// 종전: System.out.println("3패스워드확인::" + EgovFileScrty.encryptPassword(...));
System.out.println("3패스워드확인::"+EgovFileScrty.encryptPassword(mberManageVO.getPassword()));
return returnUrl; return returnUrl;
} }
@ -835,9 +831,8 @@ public class BizMberFrtManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = mberManageService.selectPassword(mberManageVO); resultVO = mberManageService.selectPassword(mberManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(oldPassword); if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
if (encryptPass.equals(resultVO.getPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -850,7 +845,8 @@ public class BizMberFrtManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
mberManageService.updatePassword(mberManageVO); mberManageService.updatePassword(mberManageVO);
model.addAttribute("userManageVO", mberManageVO); model.addAttribute("userManageVO", mberManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";
@ -956,9 +952,8 @@ public class BizMberFrtManageController {
String resultMsg = ""; String resultMsg = "";
String returnUrl = ""; String returnUrl = "";
resultVO = mberManageService.selectPassword(mberManageVO); resultVO = mberManageService.selectPassword(mberManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword())){
if (encryptPass.equals(resultVO.getPassword())){
String userId = "USR01:"+user.getUniqId(); String userId = "USR01:"+user.getUniqId();
// 멤버 정보 삭제 (마일리지, 활동정보, 재능정보, 권한정보) // 멤버 정보 삭제 (마일리지, 활동정보, 재능정보, 권한정보)
mberManageService.deleteMber(userId); mberManageService.deleteMber(userId);

View File

@ -136,11 +136,11 @@ public class MblMypageController {
String resultMsg = ""; String resultMsg = "";
String returnUrl = ""; String returnUrl = "";
System.out.println("타입체크::"+user.getUserSe()); System.out.println("타입체크::"+user.getUserSe());
System.out.println("1패스워드확인::"+mberManageVO.getPassword()); // 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출)
String encryptPass = ""; System.out.println("1패스워드확인::요청됨");
entVO = entrManageService.selectPassword(entVO); entVO = entrManageService.selectPassword(entVO);
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword()); // [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
if (encryptPass.equals(entVO.getEntrprsMberPassword())){ if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do"; returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
}else{ }else{
resultMsg="fail.user.passwordUpdate1"; resultMsg="fail.user.passwordUpdate1";

View File

@ -68,7 +68,8 @@ public class BizGroupManageServiceImpl extends AbstractServiceImpl implements Bi
String uniqId = idgenService.getNextStringId(); String uniqId = idgenService.getNextStringId();
groupManageVO.setUniqId(uniqId); groupManageVO.setUniqId(uniqId);
//패스워드 암호화 //패스워드 암호화
String pass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword()); //[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
String pass = EgovFileScrty.encryptPasswordWithSalt(groupManageVO.getGroupPassword());
groupManageVO.setGroupPassword(pass); groupManageVO.setGroupPassword(pass);
return groupManageDAO.insertGroupMber(groupManageVO); return groupManageDAO.insertGroupMber(groupManageVO);

View File

@ -701,10 +701,11 @@ public class BizGroupManageController {
String resultMsg = ""; String resultMsg = "";
resultVO = groupManageService.selectPassword(groupManageVO); resultVO = groupManageService.selectPassword(groupManageVO);
//패스워드 암호화 //패스워드 암호화
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword); // [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
String encryptPass = resultVO.getGroupPassword(); // 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다.
if (encryptPass.equals(resultVO.getGroupPassword())){ // [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -717,7 +718,8 @@ public class BizGroupManageController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
groupManageService.updatePassword(groupManageVO); groupManageService.updatePassword(groupManageVO);
model.addAttribute("groupManageVO", groupManageVO); model.addAttribute("groupManageVO", groupManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -267,10 +267,8 @@ public class BizEntrprsMntController {
String resultMsg = ""; String resultMsg = "";
resultVO = entrprsManageService.selectPassword(entrprsManageVO); resultVO = entrprsManageService.selectPassword(entrprsManageVO);
//패스워드 암호화 //패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
String encryptPass = EgovFileScrty.encryptPassword(oldPassword); if (EgovFileScrty.matches(oldPassword, resultVO.getEntrprsMberPassword())){
if (encryptPass.equals(resultVO.getEntrprsMberPassword())){
if (newPassword.equals(newPassword2)){ if (newPassword.equals(newPassword2)){
isCorrectPassword = true; isCorrectPassword = true;
}else{ }else{
@ -283,7 +281,8 @@ public class BizEntrprsMntController {
} }
if (isCorrectPassword){ if (isCorrectPassword){
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword)); //[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
entrprsManageService.updatePassword(entrprsManageVO); entrprsManageService.updatePassword(entrprsManageVO);
model.addAttribute("entrprsManageVO", entrprsManageVO); model.addAttribute("entrprsManageVO", entrprsManageVO);
resultMsg = "success.common.update"; resultMsg = "success.common.update";

View File

@ -18,8 +18,10 @@
<!-- 스케쥴러파일이 존재하는 패키지 설정 --> <!-- 스케쥴러파일이 존재하는 패키지 설정 -->
<context:component-scan base-package="kr.or.arko.cmm.batch" /> <context:component-scan base-package="kr.or.arko.cmm.batch" />
<!-- 해당 태그들은 크론탭을 설정에 필요한 태그들 --> <!-- 해당 태그들은 크론탭을 설정에 필요한 태그들 -->
<!-- <task:scheduler id="jobScheduler" pool-size="10" /> <!-- 2026-07-28 활성화: BatchScheduler의 @Scheduled 전체 기동. OS crontab에서 동일 배치를
<task:annotation-driven scheduler="jobScheduler" /> --> 병행 운영 중이면 이중 실행이 되므로 운영과 경합 여부 확인 필요. -->
<task:scheduler id="jobScheduler" pool-size="10" />
<task:annotation-driven scheduler="jobScheduler" />
<bean id="messageSource" <bean id="messageSource"

View File

@ -25,6 +25,9 @@
</aop:config> --> </aop:config> -->
<!-- ========================================================================= <!-- =========================================================================
[이력 / 2026-08-07 중단] 아래 설명과 <aop:config> 는 현재 비활성이다.
중단 사유와 대체 구현은 바로 다음 주석 블록 참조. 이 블록은 판단 근거 보존용으로 남긴다.
─────────────────────────────────────────────────────────────────────────
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31) 개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31)
· 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음). · 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음).
· 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다. · 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다.
@ -35,7 +38,31 @@
· 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외. · 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외.
· #36(2026-07-21): 회원·업체 2개 지점 → 전 개인정보 처리 서비스(16종)로 확장. TRGET_ID 취득 규약은 PersonalDataLogAspect Javadoc 참조. · #36(2026-07-21): 회원·업체 2개 지점 → 전 개인정보 처리 서비스(16종)로 확장. TRGET_ID 취득 규약은 PersonalDataLogAspect Javadoc 참조.
[제외] main/search/data/event/dept/orgnztchart/team/stat/provol/rspofc/prbro/prmvp/midact/srv/sample/test/excel 등 개인정보 미취급 서비스. [제외] main/search/data/event/dept/orgnztchart/team/stat/provol/rspofc/prbro/prmvp/midact/srv/sample/test/excel 등 개인정보 미취급 서비스.
· 2026-08-06 과수집 1건 제외: BizTalentMberManageServiceImpl.selectJoinTalentMberList*
이 메서드는 취급자 업무가 아니라 프론트 공개 목록(메인 LayoutFrtController.main, frt 재능나눔 목록)이
공개동의분(PUBLIC_YN='Y')만 노출하는 경로다. 무인증 방문 1회당 ANONYMOUS 로그 1건이
730일 보관 테이블에 쌓여(실측 41건) 정기점검·이상탐지 통계를 희석시키므로 대상에서 뺀다.
같은 클래스의 취급자 경로(selectTalentMber/selectTalentMberList/selectTalentMberSingle)는 계속 수집한다.
========================================================================= --> ========================================================================= -->
<!-- =========================================================================
[2026-08-07] 서비스 AOP 접속기록 수집 중단 — 관리자 화면 메뉴 접속기록으로 전환.
아래 <aop:config> 는 서비스 impl 메서드를 가로채 접속기록을 남겼다. 그러나 서비스는
관리자 화면과 프론트 화면이 함께 호출하므로 '어느 화면에서 불렀는지'를 구분할 수 없었고,
그 결과 무인증 프론트 방문이 접속자ID=ANONYMOUS, 수행업무="재능나눔 회원관리" 로 적재됐다
(공개 재능나눔 상세 /frt/biz/talent/bizTalentView.do → selectTalentMber).
selectJoinTalentMberList 를 pointcut 에서 빼는 식의 개별 예외로는 같은 문제가 계속 생긴다 —
프론트/관리자가 공유하는 서비스 메서드가 그것 하나가 아니기 때문이다.
대체 구현: egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor
(등록: WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml)
요청 URL 로 관리자 화면만 골라내고, 기존 메뉴 테이블(COMTNMENUINFO)의 메뉴명을
수행업무로 남긴다. 적재 경로(AccessLogQueue → 해시체인 → PERSONAL_DATA_ACCESS_LOG)는 동일하다.
되돌리려면 아래 주석을 풀고 egov-com-servlet.xml 의 menuAccessLogInterceptor 등록을 제거할 것
(둘을 동시에 켜면 한 요청이 두 경로로 중복 적재된다).
========================================================================= -->
<!--
<aop:config> <aop:config>
<aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect"> <aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect">
<aop:pointcut id="personalDataProcessPointcut" <aop:pointcut id="personalDataProcessPointcut"
@ -48,7 +75,8 @@
or execution(* egovframework.com.uss.umt.service.impl.EgovAccountUnlockManageServiceImpl.*(..)) or execution(* egovframework.com.uss.umt.service.impl.EgovAccountUnlockManageServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.note.service.impl.BizNoteServiceImpl.*(..)) or execution(* kr.or.arko.frt.biz.note.service.impl.BizNoteServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..)) or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.*(..)) or (execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.*(..))
and not execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.selectJoinTalentMberList*(..)))
or execution(* kr.or.arko.mnt.biz.proapp.service.impl.BizProappServiceImpl.*(..)) or execution(* kr.or.arko.mnt.biz.proapp.service.impl.BizProappServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.appconfirm.service.impl.BizAppconfirmServiceImpl.*(..)) or execution(* kr.or.arko.mnt.biz.appconfirm.service.impl.BizAppconfirmServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.proresult.service.impl.BizProresultServiceImpl.*(..)) or execution(* kr.or.arko.mnt.biz.proresult.service.impl.BizProresultServiceImpl.*(..))
@ -58,5 +86,6 @@
<aop:around pointcut-ref="personalDataProcessPointcut" method="around" /> <aop:around pointcut-ref="personalDataProcessPointcut" method="around" />
</aop:aspect> </aop:aspect>
</aop:config> </aop:config>
-->
</beans> </beans>

View File

@ -511,10 +511,17 @@
FROM COMTNGNRLMBER FROM COMTNGNRLMBER
]]> ]]>
WHERE mber_id = #id# WHERE mber_id = #id#
AND password = #password#
AND mber_sttus = 'P' AND mber_sttus = 'P'
</isEqual> </isEqual>
<!-- 2025.11.05 나혁제 확인결과 아래부분은 운영에는 적용되어 있음 확인후 처리해야됨 --> <!-- ★ 2026-08-05 [개인정보/SER-001] 일반회원(GNR) 비밀번호 검증 조건 복구.
<!-- AND password = #password# --> · 종전: 이 조건이 주석 처리 + <isEqual> 블록 '바깥'에 위치해 있어(2025.11.05 나혁제 메모)
GNR 분기는 mber_id/mber_sttus 만으로 인증이 성립했다 → 아이디만 알면 임의 비밀번호로
로그인되는 인증 우회. GRO/ENT/USR 3개 분기는 정상, GNR 만 누락된 상태였다.
· 주석만 해제하면 안 된다: 블록 바깥의 텍스트는 userSe 와 무관하게 항상 렌더링되어
GRO/ENT/USR 로그인 시 SELECT 앞에 'AND password = #password#' 가 붙어 SQL 이 깨진다.
반드시 위와 같이 GNR <isEqual> 블록 안으로 넣어야 한다.
· EgovLoginUsr_SQL_Oracle.xml 은 처음부터 조건이 살아 있어 이제 두 파일이 동일하다. -->
<!-- 그룹회원 --> <!-- 그룹회원 -->
<isEqual property="userSe" compareValue="GRO"> <isEqual property="userSe" compareValue="GRO">
<![CDATA[ <![CDATA[
@ -584,6 +591,49 @@
</isEqual> </isEqual>
</select> </select>
<!-- ★ 2026-08-06 [개인정보/SER-003] 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
· 솔트 적용 계정은 저장값이 'S1:솔트:해시' 형식이라, 평문을 해시하기 전에 그 솔트를 알아야 한다.
EgovLoginServiceImpl.actionLogin 이 이 값을 먼저 읽어 EgovFileScrty.encryptPasswordAs 로
같은 형식의 문자열을 만든 뒤, 아래 loginDAO.actionLogin 의 WHERE 비교에 그대로 넣는다.
· 덕분에 인증 조건(AND password = 파라미터)을 건드리지 않는다. 2026-08-05 GNR 인증우회
사고가 이 WHERE 조건 이동에서 비롯됐으므로, 해당 조건은 원형 그대로 보존한다.
· 계정 상태(mber_sttus 등)는 조건에 넣지 않는다. 없는 아이디/휴면 계정이면 null 이 되어
무솔트 해시로 계산되고 actionLogin 이 0건을 돌려주므로 결과는 종전과 동일하다. -->
<select id="loginDAO.selectStoredPassword" resultClass="java.lang.String">
<!-- 일반회원 -->
<isEqual property="userSe" compareValue="GNR">
<![CDATA[
SELECT password
FROM COMTNGNRLMBER
WHERE mber_id = #id#
]]>
</isEqual>
<!-- 그룹회원 -->
<isEqual property="userSe" compareValue="GRO">
<![CDATA[
SELECT group_password
FROM BIZ_GROUP
WHERE group_id = #id#
]]>
</isEqual>
<!-- 기업회원 -->
<isEqual property="userSe" compareValue="ENT">
<![CDATA[
SELECT entrprs_mber_password
FROM COMTNENTRPRSMBER
WHERE ENTRPRS_MBER_ID = #id#
]]>
</isEqual>
<!-- 업무사용자 -->
<isEqual property="userSe" compareValue="USR">
<![CDATA[
SELECT password
FROM COMTNEMPLYRINFO
WHERE emplyr_id = #id#
]]>
</isEqual>
</select>
<!-- 인증서 로그인 --> <!-- 인증서 로그인 -->
<select id="loginDAO.actionCrtfctLogin" resultMap="login"> <select id="loginDAO.actionCrtfctLogin" resultMap="login">
<![CDATA[ <![CDATA[

View File

@ -355,6 +355,43 @@
</isEqual> </isEqual>
</select> </select>
<!-- ★ 2026-08-06 [개인정보/SER-003] 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
Mysql 본과 동일 statement. 상세 설명은 EgovLoginUsr_SQL_Mysql.xml 주석 참조. -->
<select id="loginDAO.selectStoredPassword" resultClass="java.lang.String">
<!-- 일반회원 -->
<isEqual property="userSe" compareValue="GNR">
<![CDATA[
SELECT password
FROM COMTNGNRLMBER
WHERE mber_id = #id#
]]>
</isEqual>
<!-- 그룹회원 -->
<isEqual property="userSe" compareValue="GRO">
<![CDATA[
SELECT group_password
FROM BIZ_GROUP
WHERE group_id = #id#
]]>
</isEqual>
<!-- 기업회원 -->
<isEqual property="userSe" compareValue="ENT">
<![CDATA[
SELECT entrprs_mber_password
FROM COMTNENTRPRSMBER
WHERE ENTRPRS_MBER_ID = #id#
]]>
</isEqual>
<!-- 업무사용자 -->
<isEqual property="userSe" compareValue="USR">
<![CDATA[
SELECT password
FROM COMTNEMPLYRINFO
WHERE emplyr_id = #id#
]]>
</isEqual>
</select>
<!-- 인증서 로그인 --> <!-- 인증서 로그인 -->
<select id="loginDAO.actionCrtfctLogin" resultMap="login"> <select id="loginDAO.actionCrtfctLogin" resultMap="login">
<![CDATA[ <![CDATA[

View File

@ -807,6 +807,15 @@ FROM(
WHERE ESNTL_ID=#uniqId# WHERE ESNTL_ID=#uniqId#
]]> ]]>
</select> </select>
<!-- 2026-08-06 [개인정보/SER-003] 아이디로 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
selectPassword_S 는 ESNTL_ID 기준이라 아이디만 아는 searchMber 경로에서 쓸 수 없어 별도로 둔다. -->
<select id="mberManageDAO.selectStoredPasswordByMberId_S" parameterClass="mberVO" resultClass="java.lang.String">
<![CDATA[
SELECT PASSWORD
FROM COMTNGNRLMBER
WHERE MBER_ID = #mberId#
]]>
</select>
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String"> <select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
<![CDATA[ <![CDATA[
SELECT MBER_ID "mberId" SELECT MBER_ID "mberId"

View File

@ -535,6 +535,15 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
]]> ]]>
</select> </select>
<!-- 2026-08-06 [개인정보/SER-003] 아이디로 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
Mysql 본과 동일 statement. -->
<select id="mberManageDAO.selectStoredPasswordByMberId_S" parameterClass="mberVO" resultClass="java.lang.String">
<![CDATA[
SELECT PASSWORD
FROM COMTNGNRLMBER
WHERE MBER_ID = #mberId#
]]>
</select>
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String"> <select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
<![CDATA[ <![CDATA[
SELECT MBER_ID "mberId" SELECT MBER_ID "mberId"

View File

@ -134,6 +134,25 @@
</dynamic> </dynamic>
</select> </select>
<!--
[SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스.
종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 소스로 썼으나, 수집이
관리자 화면 메뉴 기반(MenuAccessLogInterceptor)으로 바뀌면서 업무유형이 메뉴에서 나온다.
매핑 테이블을 보면 실제 적재값과 어긋난 선택지가 뜨므로, 적재된 값에서 직접 뽑는다.
(드롭다운이라 건수가 많지 않고, JOB_SE_CODE 는 인덱스 대상이 아니어도 메뉴 수준의 카디널리티다)
-->
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT JOB_SE_CODE AS jobSeCode,
MAX(JOB_SE_NM) AS jobSeNm
FROM PERSONAL_DATA_ACCESS_LOG
WHERE JOB_SE_CODE IS NOT NULL
GROUP BY JOB_SE_CODE
ORDER BY MAX(JOB_SE_NM) ASC
]]>
</select>
<!-- ================================================================ <!-- ================================================================
#43 정기점검 리포트 집계·적재. 전월분 PERSONAL_DATA_ACCESS_LOG 집계(SELECT) + #43 정기점검 리포트 집계·적재. 전월분 PERSONAL_DATA_ACCESS_LOG 집계(SELECT) +
PDAL_INSPECTION_REPORT 적재(INSERT). 파라미터는 fromDt/toDt(Map) 또는 InspectionReportVO. PDAL_INSPECTION_REPORT 적재(INSERT). 파라미터는 fromDt/toDt(Map) 또는 InspectionReportVO.

View File

@ -120,6 +120,19 @@
</dynamic> </dynamic>
</select> </select>
<!-- [SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스 — MySQL판과 동일 의미. -->
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT JOB_SE_CODE AS jobSeCode,
MAX(JOB_SE_NM) AS jobSeNm
FROM PERSONAL_DATA_ACCESS_LOG
WHERE JOB_SE_CODE IS NOT NULL
GROUP BY JOB_SE_CODE
ORDER BY MAX(JOB_SE_NM) ASC
]]>
</select>
<!-- #43 정기점검 리포트 집계·적재 — Oracle 쌍(시퀀스 PDAL_INSPECTION_REPORT_SEQ 필요). --> <!-- #43 정기점검 리포트 집계·적재 — Oracle 쌍(시퀀스 PDAL_INSPECTION_REPORT_SEQ 필요). -->
<select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod" <select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod"
parameterClass="java.util.HashMap" resultClass="java.util.HashMap"> parameterClass="java.util.HashMap" resultClass="java.util.HashMap">

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
장기 미사용 계정 개인정보 파기(#12) — SFR-002 개발항목 #12
보존기간(DELETE_PRESERVE_DAY)은 ACCOUNT_MNG_POLICY 정책값을 그때그때 조회해 사용(하드코딩 없음).
1회 처리건수는 LIMIT 으로 상한(백로그 최초가동 폭주 방지, #7과 동일 정책, 2026-07-28).
2026-07-30 상한 재산정 200 → 1,000(유형별): 휴면 적재 상한을 2,000/일로 올린 데 맞춰 파기도
올리되, 파기는 마스킹으로 되돌릴 수 없으므로 의도적으로 유입(2,000/일)보다 낮게 둔다.
이상 징후를 발견하면 PURGE_USE_YN='N' 으로 즉시 정지할 수 있는 관측 여유를 확보하기 위함.
-->
<sqlMap namespace="AccountDeleteDAO">
<typeAlias alias="accountDeleteTargetVO" type="egovframework.com.cmm.privacy.account.AccountDeleteTargetVO"/>
<typeAlias alias="accountDeleteHistoryVO" type="egovframework.com.cmm.privacy.account.AccountDeleteHistoryVO"/>
<!-- 파기 대상(일반회원): 휴면 확정('P') + 보존기간 경과 + 상한 1,000 -->
<select id="AccountDeleteDAO.selectExpiredGnrAccounts" resultClass="accountDeleteTargetVO">
<![CDATA[
SELECT A.MBER_ID AS mberId
, A.ESNTL_ID AS esntlId
, A.DIAPAUSE_DT AS diapauseDt
, (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
FROM COMTNDIAPAUSEMBER A
WHERE A.MBER_TY_CODE = 'GNR'
AND A.DIAPAUSE_STAT = 'P'
AND DATEDIFF(NOW(), A.DIAPAUSE_DT) > (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
ORDER BY A.DIAPAUSE_DT
LIMIT 1000
]]>
</select>
<!-- 파기 대상(개인정보취급자): 휴면 확정('P') + 보존기간 경과 + 상한 1,000 -->
<select id="AccountDeleteDAO.selectExpiredEmpAccounts" resultClass="accountDeleteTargetVO">
<![CDATA[
SELECT A.MBER_ID AS mberId
, A.ESNTL_ID AS esntlId
, A.DIAPAUSE_DT AS diapauseDt
, (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
FROM COMTNDIAPAUSEMBER A
WHERE A.MBER_TY_CODE = 'EMP'
AND A.DIAPAUSE_STAT = 'P'
AND DATEDIFF(NOW(), A.DIAPAUSE_DT) > (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
ORDER BY A.DIAPAUSE_DT
LIMIT 1000
]]>
</select>
<!-- 일반회원 개인정보 파기(마스킹) — 기존 회원탈퇴(MBER_STTUS='D') 컨벤션과 동일 규칙 재사용 -->
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM = CONCAT(LEFT(MBER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(MBER_NM)-1,0)))
, MBER_EMAIL_ADRES = NULL
, MBTLNUM = NULL
, MBER_FXNUM = NULL
, IHIDNUM = NULL
, ZIP = NULL
, ADRES = NULL
, DETAIL_ADRES = NULL
, MBER_STTUS = 'D'
, LOCK_YN = 'Y'
WHERE MBER_ID = #value#
]]>
</update>
<!-- 개인정보취급자 개인정보 파기(마스킹) -->
<update id="AccountDeleteDAO.anonymizeEmplyr" parameterClass="java.lang.String">
<![CDATA[
UPDATE COMTNEMPLYRINFO
SET USER_NM = CONCAT(LEFT(USER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(USER_NM)-1,0)))
, EMAIL_ADRES = NULL
, MBTLNUM = NULL
, FXNUM = NULL
, OFFM_TELNO = NULL
, IHIDNUM = NULL
, ZIP = NULL
, HOUSE_ADRES = NULL
, DETAIL_ADRES = NULL
, EMPLYR_STTUS_CODE = 'D'
, LOCK_YN = 'Y'
WHERE EMPLYR_ID = #value#
]]>
</update>
<!-- 파기 완료 대상은 휴면 추적 대상에서 제거(재처리 방지) -->
<delete id="AccountDeleteDAO.deleteDiapauseRow" parameterClass="java.lang.String">
<![CDATA[
DELETE FROM COMTNDIAPAUSEMBER WHERE MBER_ID = #value#
]]>
</delete>
<!-- 파기 이력 적재(#12) -->
<insert id="AccountDeleteDAO.insertDeleteHistory" parameterClass="accountDeleteHistoryVO">
<![CDATA[
INSERT INTO ACCOUNT_DELETE_HISTORY (MBER_ID, MBER_TY_CODE, ESNTL_ID, DIAPAUSE_DT, PRESERVE_DAY)
VALUES (#mberId#, #mberTyCode#, #esntlId#, #diapauseDt#, #preserveDay#)
]]>
</insert>
</sqlMap>

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!-- 장기 미사용 계정 개인정보 파기(#12) — Mysql 쌍(PrivacyAccountDelete_SQL_Mysql.xml) 참조.
대상 운영 DB가 MySQL(csv)이라 sql-map-config 미등록(다른 Privacy* Oracle 파일과 동일 관례). -->
<sqlMap namespace="AccountDeleteDAO">
<typeAlias alias="accountDeleteTargetVO" type="egovframework.com.cmm.privacy.account.AccountDeleteTargetVO"/>
<typeAlias alias="accountDeleteHistoryVO" type="egovframework.com.cmm.privacy.account.AccountDeleteHistoryVO"/>
<select id="AccountDeleteDAO.selectExpiredGnrAccounts" resultClass="accountDeleteTargetVO">
<![CDATA[
SELECT A.MBER_ID AS mberId
, A.ESNTL_ID AS esntlId
, A.DIAPAUSE_DT AS diapauseDt
, (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
FROM COMTNDIAPAUSEMBER A
WHERE A.MBER_TY_CODE = 'GNR'
AND A.DIAPAUSE_STAT = 'P'
AND (SYSDATE - A.DIAPAUSE_DT) > (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
AND ROWNUM <= 1000 /* 2026-07-30 상한 200→1,000 재산정(Mysql 쌍 주석 참조) */
]]>
</select>
<select id="AccountDeleteDAO.selectExpiredEmpAccounts" resultClass="accountDeleteTargetVO">
<![CDATA[
SELECT A.MBER_ID AS mberId
, A.ESNTL_ID AS esntlId
, A.DIAPAUSE_DT AS diapauseDt
, (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
FROM COMTNDIAPAUSEMBER A
WHERE A.MBER_TY_CODE = 'EMP'
AND A.DIAPAUSE_STAT = 'P'
AND (SYSDATE - A.DIAPAUSE_DT) > (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
AND ROWNUM <= 1000 /* 2026-07-30 상한 200→1,000 재산정(Mysql 쌍 주석 참조) */
]]>
</select>
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM = SUBSTR(MBER_NM,1,1) || RPAD('*', GREATEST(LENGTH(MBER_NM)-1,0), '*')
, MBER_EMAIL_ADRES = NULL
, MBTLNUM = NULL
, MBER_FXNUM = NULL
, IHIDNUM = NULL
, ZIP = NULL
, ADRES = NULL
, DETAIL_ADRES = NULL
, MBER_STTUS = 'D'
, LOCK_YN = 'Y'
WHERE MBER_ID = #value#
]]>
</update>
<update id="AccountDeleteDAO.anonymizeEmplyr" parameterClass="java.lang.String">
<![CDATA[
UPDATE COMTNEMPLYRINFO
SET USER_NM = SUBSTR(USER_NM,1,1) || RPAD('*', GREATEST(LENGTH(USER_NM)-1,0), '*')
, EMAIL_ADRES = NULL
, MBTLNUM = NULL
, FXNUM = NULL
, OFFM_TELNO = NULL
, IHIDNUM = NULL
, ZIP = NULL
, HOUSE_ADRES = NULL
, DETAIL_ADRES = NULL
, EMPLYR_STTUS_CODE = 'D'
, LOCK_YN = 'Y'
WHERE EMPLYR_ID = #value#
]]>
</update>
<delete id="AccountDeleteDAO.deleteDiapauseRow" parameterClass="java.lang.String">
<![CDATA[
DELETE FROM COMTNDIAPAUSEMBER WHERE MBER_ID = #value#
]]>
</delete>
<insert id="AccountDeleteDAO.insertDeleteHistory" parameterClass="accountDeleteHistoryVO">
<![CDATA[
INSERT INTO ACCOUNT_DELETE_HISTORY (MBER_ID, MBER_TY_CODE, ESNTL_ID, DIAPAUSE_DT, PRESERVE_DAY)
VALUES (#mberId#, #mberTyCode#, #esntlId#, #diapauseDt#, #preserveDay#)
]]>
</insert>
</sqlMap>

View File

@ -2,9 +2,13 @@
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd"> <!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!-- <!--
개인정보보호 기능개선 사업 (SFR-004 / SER-002) — 업무유형 매핑 조회(#32). 개인정보보호 기능개선 사업 (SFR-004 / SER-002) — 업무유형 매핑(#32) + 관리 화면(#32-1).
PDAL_JOB_MAPPING 을 JobMappingResolver 가 기동 시 1회 로드해 메모리 캐시한다. · selectJobMappingList : JobMappingResolver 가 캐시에 적재(USE_AT='Y' 만)
· selectJobMappingManage* 이하 : 관리 화면 CRUD (사용중지 행도 조회)
컬럼→프로퍼티 매핑을 위해 AS 별칭을 camelCase 로 부여. 컬럼→프로퍼티 매핑을 위해 AS 별칭을 camelCase 로 부여.
※ REGISTER_ID/REG_DT/UPDUSR_ID/UPDT_DT 는 privacy.job_mapping_audit_cols_mysql.sql 로
추가되는 컬럼이다. 이 sqlMap 을 배포하기 전에 해당 DDL 을 반드시 먼저 적용할 것.
--> -->
<sqlMap namespace="JobMappingDAO"> <sqlMap namespace="JobMappingDAO">
@ -26,4 +30,136 @@
]]> ]]>
</select> </select>
<!-- ================================================================
#32-1 관리 화면 CRUD.
위 selectJobMappingList 는 Resolver 캐시용이라 USE_AT='Y' 만 보지만,
관리 목록은 사용중지(N) 행도 보여야 재사용·복구가 가능하므로 조건을 두지 않는다.
================================================================ -->
<select id="JobMappingDAO.selectJobMappingManageList"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE CONCAT('%', #searchTargetClass#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE CONCAT('%', #searchJobSe#, '%') OR JOB_SE_NM LIKE CONCAT('%', #searchJobSe#, '%')) </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="JobMappingDAO.selectJobMappingManageListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE CONCAT('%', #searchTargetClass#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE CONCAT('%', #searchJobSe#, '%') OR JOB_SE_NM LIKE CONCAT('%', #searchJobSe#, '%')) </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
</select>
<select id="JobMappingDAO.selectJobMapping"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</select>
<!-- (대상클래스, 메서드패턴) 중복 검사. 수정 시에는 자기 자신(MAPPING_ID)을 제외한다.
테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 서비스 계층에서 막는다. -->
<select id="JobMappingDAO.selectJobMappingDuplicateCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO"
resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
WHERE TARGET_CLASS = #targetClass#
AND METHOD_PTTRN = #methodPttrn#
AND MAPPING_ID <> #mappingId#
]]>
</select>
<insert id="JobMappingDAO.insertJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
INSERT INTO PDAL_JOB_MAPPING
(TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM,
DML_GUBUN, SORT_ORDR, USE_AT, RM,
REGISTER_ID, REG_DT, UPDUSR_ID, UPDT_DT)
VALUES (#targetClass#, #methodPttrn#, #jobSeCode#, #jobSeNm#,
#dmlGubun:VARCHAR#, #sortOrdr#, #useAt#, #rm:VARCHAR#,
#registerId:VARCHAR#, NOW(), #updusrId:VARCHAR#, NOW())
]]>
</insert>
<update id="JobMappingDAO.updateJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET TARGET_CLASS = #targetClass#,
METHOD_PTTRN = #methodPttrn#,
JOB_SE_CODE = #jobSeCode#,
JOB_SE_NM = #jobSeNm#,
DML_GUBUN = #dmlGubun:VARCHAR#,
SORT_ORDR = #sortOrdr#,
USE_AT = #useAt#,
RM = #rm:VARCHAR#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = NOW()
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<update id="JobMappingDAO.updateJobMappingUseAt"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET USE_AT = #useAt#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = NOW()
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<delete id="JobMappingDAO.deleteJobMapping"
parameterClass="java.lang.Long">
<![CDATA[
DELETE FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</delete>
</sqlMap> </sqlMap>

View File

@ -2,8 +2,12 @@
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd"> <!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!-- <!--
개인정보보호 기능개선 사업 — 업무유형 매핑 조회(#32) Oracle 쌍(이중관리 규약). 개인정보보호 기능개선 사업 — 업무유형 매핑(#32) + 관리 화면(#32-1) Oracle 쌍(이중관리 규약).
※ arko 런타임은 MySQL sqlmap 만 로드한다. ※ arko 런타임은 MySQL sqlmap 만 로드한다. Oracle 전환 시에는 아래 차이를 확인할 것.
· 페이징: LIMIT 미지원 → ROWNUM 인라인뷰
· 문자열 결합: CONCAT 2인자 제한 → '%' || v || '%'
· 현재시각: NOW() → SYSDATE
· MAPPING_ID: AUTO_INCREMENT 없음 → 시퀀스(PDAL_JOB_MAPPING_SEQ) 선행 생성 필요
--> -->
<sqlMap namespace="JobMappingDAO"> <sqlMap namespace="JobMappingDAO">
@ -25,4 +29,135 @@
]]> ]]>
</select> </select>
<!-- ================================================================
#32-1 관리 화면 CRUD (사용중지 행도 조회)
================================================================ -->
<select id="JobMappingDAO.selectJobMappingManageList"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
SELECT * FROM (
SELECT ROWNUM AS RNUM, A.* FROM (
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE '%' || #searchTargetClass# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE '%' || #searchJobSe# || '%' OR JOB_SE_NM LIKE '%' || #searchJobSe# || '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
) A
) WHERE RNUM <![CDATA[ > ]]> #firstIndex# AND RNUM <![CDATA[ <= ]]> #firstIndex# + #recordCountPerPage#
</select>
<select id="JobMappingDAO.selectJobMappingManageListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE '%' || #searchTargetClass# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE '%' || #searchJobSe# || '%' OR JOB_SE_NM LIKE '%' || #searchJobSe# || '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
</select>
<select id="JobMappingDAO.selectJobMapping"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</select>
<select id="JobMappingDAO.selectJobMappingDuplicateCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO"
resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
WHERE TARGET_CLASS = #targetClass#
AND METHOD_PTTRN = #methodPttrn#
AND MAPPING_ID <> #mappingId#
]]>
</select>
<insert id="JobMappingDAO.insertJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
INSERT INTO PDAL_JOB_MAPPING
(MAPPING_ID, TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM,
DML_GUBUN, SORT_ORDR, USE_AT, RM,
REGISTER_ID, REG_DT, UPDUSR_ID, UPDT_DT)
VALUES (PDAL_JOB_MAPPING_SEQ.NEXTVAL, #targetClass#, #methodPttrn#, #jobSeCode#, #jobSeNm#,
#dmlGubun:VARCHAR#, #sortOrdr#, #useAt#, #rm:VARCHAR#,
#registerId:VARCHAR#, SYSDATE, #updusrId:VARCHAR#, SYSDATE)
]]>
</insert>
<update id="JobMappingDAO.updateJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET TARGET_CLASS = #targetClass#,
METHOD_PTTRN = #methodPttrn#,
JOB_SE_CODE = #jobSeCode#,
JOB_SE_NM = #jobSeNm#,
DML_GUBUN = #dmlGubun:VARCHAR#,
SORT_ORDR = #sortOrdr#,
USE_AT = #useAt#,
RM = #rm:VARCHAR#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = SYSDATE
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<update id="JobMappingDAO.updateJobMappingUseAt"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET USE_AT = #useAt#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = SYSDATE
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<delete id="JobMappingDAO.deleteJobMapping"
parameterClass="java.lang.Long">
<![CDATA[
DELETE FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</delete>
</sqlMap> </sqlMap>

View File

@ -24,4 +24,26 @@
]]> ]]>
</select> </select>
<!--
[SFR-004 접속기록 / 2026.08.07] 관리자 화면 메뉴 접속기록 — 요청 URL·page 파라미터 → 메뉴명 해석용.
MenuAccessLogInterceptor 가 최초 요청 시 1회 전량 로드해 캐싱한다(요청마다 조인 금지).
- COMTNMENUINFO m : 메뉴정보 — MENU_NO, MENU_NM, PROGRM_FILE_NM
- COMTNPROGRMLIST p : 프로그램목록 — PROGRM_FILE_NM, URL
위 selectMenuUrlRoles 와 달리 COMTNMENUCREATDTLS(권한배정) 를 조인하지 않는다.
권한 배정 여부와 무관하게 메뉴 전체가 필요하고, 조인 시 롤 수만큼 행이 중복되기 때문이다.
URL 접두 제한도 두지 않는다 — 관리자 화면 판정은 인터셉터의 applyPathPrefixes 가 담당하며,
arko 관리자 화면 중 회원관리(/uss/umt/...)처럼 '/mnt/' 접두가 아닌 것이 있다.
MENU_NO 는 NUMERIC 이라 CHAR 로 캐스팅해 문자열 프로퍼티(menuNo)에 맞춘다.
-->
<select id="PrivacyMenuAuthDAO.selectMenuNames" resultClass="egovframework.com.cmm.privacy.interceptor.MenuNameVO">
<![CDATA[
SELECT CAST(m.MENU_NO AS CHAR) AS menuNo,
m.MENU_NM AS menuNm,
p.URL AS url
FROM COMTNMENUINFO m
JOIN COMTNPROGRMLIST p ON p.PROGRM_FILE_NM = m.PROGRM_FILE_NM
WHERE p.URL IS NOT NULL
]]>
</select>
</sqlMap> </sqlMap>

View File

@ -19,4 +19,19 @@
]]> ]]>
</select> </select>
<!--
[SFR-004 접속기록 / 2026.08.07] 관리자 화면 메뉴 접속기록 — MySQL판과 동일 의미.
MENU_NO 숫자→문자 변환만 Oracle 문법(TO_CHAR)으로 바꿨다.
-->
<select id="PrivacyMenuAuthDAO.selectMenuNames" resultClass="egovframework.com.cmm.privacy.interceptor.MenuNameVO">
<![CDATA[
SELECT TO_CHAR(m.MENU_NO) AS menuNo,
m.MENU_NM AS menuNm,
p.URL AS url
FROM COMTNMENUINFO m
JOIN COMTNPROGRMLIST p ON p.PROGRM_FILE_NM = m.PROGRM_FILE_NM
WHERE p.URL IS NOT NULL
]]>
</select>
</sqlMap> </sqlMap>

View File

@ -77,6 +77,14 @@
<!-- <ref local="authenticInterceptor" /> 로그인 체크 --> <!-- <ref local="authenticInterceptor" /> 로그인 체크 -->
<ref local="IpObtainInterceptor" /> <ref local="IpObtainInterceptor" />
<ref local="siteURLInterceptor" /> <ref local="siteURLInterceptor" />
<!-- 관리자 화면 메뉴 접속기록(SFR-004) — 판정이 아니라 기록이며, preHandle 은
아무것도 하지 않고 afterCompletion 에서만 적재한다.
※ 위치가 중요하다: 반드시 ②③ 판정 인터셉터보다 '앞'이어야 한다.
Spring 은 preHandle 이 false 를 반환하면 그 '이전' 인터셉터들에 대해서만
afterCompletion 을 호출한다. 뒤에 두면 2단계인증 미완료·권한 미허용으로
차단된 요청이 접속기록에 아예 남지 않는다(차단 이력 유실).
앞에 두면 차단된 요청도 처리결과 'F'(응답 401/403)로 기록된다. -->
<ref local="menuAccessLogInterceptor" />
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) --> <!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
<ref local="secondFactorInterceptor" /> <ref local="secondFactorInterceptor" />
<ref local="authorityInterceptor" /> <ref local="authorityInterceptor" />
@ -206,6 +214,77 @@
</property> </property>
</bean> </bean>
<!-- ============================================================
[개인정보보호 SFR-004 / 2026.08.07] 관리자 화면 메뉴 접속기록.
종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다.
서비스는 프론트/관리자가 공유해 호출 화면을 구분할 수 없었고, 그 탓에 무인증 프론트
방문이 접속자ID=ANONYMOUS 로 적재됐다. 요청 URL 을 보는 인터셉터로 전환해
'취급자가 관리자 화면에서 연 메뉴'만 남긴다. 상세 근거는 MenuAccessLogInterceptor Javadoc.
· includePatterns : 관리자 화면 전체가 아니라 '개인정보 취급 화면'만 담은 화이트리스트다.
SFR-004 의 대상은 개인정보 처리 기록이고, 통계·게시판·코드관리까지 730일 보관 테이블에
쌓으면 정기점검·이상탐지 신호가 묻힌다.
[산출 근거] 개인정보 서비스(구 AOP pointcut 16종: EgovMber/EgovEntrprs/EgovUser/
EgovDiapause/EgovAccountUnlock/BizTalentMber/BizProapp/BizAppconfirm/BizProresult/
BizGroupManage/BizMileage/BizProfile/BizNote/BizSms ManageService)를 실제로 주입·호출하는
'관리자 컨트롤러'를 역산해 그 URL 대역만 열거했다. 추정이 아니라 소스 실측이다.
※ 구 AOP 는 excel/provol 을 '개인정보 미취급'으로 분류했으나, 그건 서비스 기준이라
excelDown.do → EgovMberManageServiceImpl 호출이 서비스 쪽에서 잡혔기 때문이다.
URL 기준으로 바뀐 지금은 화면을 직접 넣지 않으면 '회원 명단 엑셀 다운로드'가
통째로 누락된다 — 대량 개인정보 반출이라 반드시 포함한다.
· excludePatterns : 화이트리스트 '안에서' 빼는 것. 프레임(left/top/footer)·공통 include 는
화면 1회 진입마다 동반 호출돼, 안 빼면 같은 진입이 4~5행으로 부풀어 통계가 왜곡된다.
/uss/umt/dpt/(부서관리)는 대역 안이지만 개인정보 취급이 아니라 제외.
· handlerUserSes : 개인정보취급자 판정(USR=업무사용자). 일반회원(GNR)·기업(ENT) 세션이
/uss/umt/ 계열을 호출해도 취급자 기록으로 섞이지 않는다.
· menuAuthDAO/accessLogQueue 는 루트 컨텍스트 빈 — 자식→부모 가시성으로 참조.
· 개인정보 취급 화면을 새로 만들면 includePatterns 에 한 줄을 더해야 수집된다
(자동 확장 아님 — 과수집 통제 지점을 남기기 위한 의도된 선택).
============================================================ -->
<bean id="menuAccessLogInterceptor" class="egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor">
<property name="includePatterns">
<list>
<!-- 회원·업체·업무사용자·휴면계정·계정잠금해제 관리 (표준프레임워크 사용자관리 모듈)
EgovMberManage / EgovEntrprsManage / EgovUserManage /
EgovDiapauseManage / EgovAccountUnlockManage Controller -->
<value>\A/uss/umt/.*\Z</value>
<!-- arko 관리자 개인정보 취급 화면
talent 재능나눔 회원관리 (BizTalentMberManageController)
group 그룹 회원관리 (BizGroupManageController)
mypage 그룹·업체 회원관리 (BizGroupMntManage/BizEntrprsMnt/BizActivityMntController)
proapp 신청자 관리 (BizProappMnt/BizAppconfirmMntController)
proresult 활동결과 관리 (BizProresultMntController)
profile 프로필 관리 (BizProfileMntController)
provol 모집·참가자 명단 (BizProvolMntController — excelEnterList 등)
excel 명단 엑셀 다운로드 (ExcelController — 대량 개인정보 반출)
mileage 마일리지 (BizMileageService 계열) -->
<value>\A/mnt/biz/(talent|group|mypage|proapp|appconfirm|proresult|profile|provol|excel|mileage|applymileage)/.*\Z</value>
<!-- 개인정보보호 관리화면 자체 — 접속기록·권한이력 조회 행위도 남겨야 한다(SER-002-3) -->
<value>\A/mnt/(sec|privacy)/.*\Z</value>
</list>
</property>
<property name="excludePatterns">
<list>
<!-- 관리자 프레임/레이아웃 — 화면 1회 진입에 동반 호출되는 부속 요청 -->
<value>\A/mnt/(main|left|top|right|footer|bottom)\.do.*\Z</value>
<value>\A/mnt/(include)/.*\.do.*\Z</value>
<value>\A/Egov(Left|Top|Bottom)\.do.*\Z</value>
<!-- 부서관리 — /uss/umt/ 대역이지만 개인정보 취급이 아니다(EgovDeptManageController) -->
<value>\A/uss/umt/dpt/.*\Z</value>
<!-- 로그인/2차인증 화면 자신 — 인증 이력은 #22 인증 실패 이력이 담당 -->
<value>\A/uat/uia/.*\Z</value>
</list>
</property>
<property name="handlerUserSes">
<set>
<value>USR</value>
</set>
</property>
<property name="menuAuthDAO" ref="privacyMenuAuthDAO" />
<property name="accessLogQueue" ref="accessLogQueue" />
<property name="cacheTtlSeconds" value="600" />
</bean>
<!-- <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver"> <!-- <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
<property name="defaultErrorView" value="egovframework/com/cmm/error/egovError"/> <property name="defaultErrorView" value="egovframework/com/cmm/error/egovError"/>
<property name="exceptionMappings"> <property name="exceptionMappings">

View File

@ -48,13 +48,13 @@
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041' || param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041'
|| param.page eq '9100042'|| param.page eq '9100043' || param.page eq '9100042'|| param.page eq '9100043'
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080' || param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq ''}">class="on"</c:if>><a href="#none">시스템관리</a> || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || 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 '9100045' || param.page eq ''}">class="on"</c:if>><a href="#none">시스템관리</a>
<!-- 2 Depth --> <!-- 2 Depth -->
<ul <c:if test="${param.page eq '9100012'|| param.page eq '9100012' || param.page eq '9100020' || param.page eq '9100021' <ul <c:if test="${param.page eq '9100012'|| param.page eq '9100012' || param.page eq '9100020' || param.page eq '9100021'
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034'|| param.page eq '9100041' || param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034'|| param.page eq '9100041'
|| param.page eq '9100042'|| param.page eq '9100043' || param.page eq '9100042'|| param.page eq '9100043'
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080' || param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq ''}">style="display: block;"</c:if>> || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || 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 '9100045' || param.page eq ''}">style="display: block;"</c:if>>
<c:forEach items="${list_menulist}" var="menuUpperVo2"> <c:forEach items="${list_menulist}" var="menuUpperVo2">
<c:if test="${menuUpperVo2.menuNo eq '9100010'}"> <c:if test="${menuUpperVo2.menuNo eq '9100010'}">
<li <c:if test="${param.page eq '9100012'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a> <li <c:if test="${param.page eq '9100012'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
@ -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'}">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 '9100044' || param.page eq '9100045'}">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'}">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 '9100044' || param.page eq '9100045'}">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,6 +122,14 @@
<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>
<%-- [개인정보/#32-1] 업무유형 매핑 관리 (접속기록 수행업무 분류 기준) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100045'}">
<li <c:if test="${param.page eq '9100045'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
</c:choose> </c:choose>
</c:forEach> </c:forEach>
</ul> </ul>

View File

@ -0,0 +1,206 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
개인정보 접속기록 조회·다운로드 (개인정보보호 SFR-004 / SER-002 / #47)
· PERSONAL_DATA_ACCESS_LOG(#30) 적재분을 접속자ID·업무유형·대상·IP·처리구분·결과·기간으로 복합검색
· 다운로드는 사유 필수(#42) — 미입력 시 서버가 400으로 차단하고 파일을 열지 않음
· 이 테이블은 UPDATE/DELETE statement 미정의(위·변조 방지) — 화면에서도 조회/다운로드만 제공
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />";
document.frm.submit();
}
/* 다운로드 : 사유 필수(#42). 사유·검색조건을 그대로 넘기고 이력을 남긴다. */
function fn_download() {
var reason = prompt("다운로드 사유를 입력하세요. (필수 — 다운로드 이력에 기록됩니다)", "");
if (reason == null) {
return; /* 취소 */
}
if (reason.replace(/^\s+|\s+$/g, "") == "") {
alert("다운로드 사유는 필수입니다.");
return;
}
document.frm.dlReason.value = reason;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/accessLog/download.do' />";
document.frm.submit();
/* 다음 검색이 다운로드로 가지 않도록 원복 */
document.frm.dlReason.value = "";
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />";
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.s { background:#4a7ebb; }
.badge.f { background:#c25b5b; }
.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; }
.ip { font-family:Consolas,monospace; }
.hash { font-family:Consolas,monospace; font-size:11px; color:#888; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.btn_area { margin:8px 0; text-align:right; }
.btn_dl { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<form:form commandName="personalDataAccessLogSearchVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<input type="hidden" name="dlReason" id="dlReason" value="" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>접속자ID :</span>
<form:input path="conectId" title="접속자ID" cssClass="tbox w4" />
<span>수행업무 :</span>
<select name="jobSeCode" id="jobSeCode" title="수행업무 유형">
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.jobSeCode}">selected="selected"</c:if>>전체</option>
<c:forEach items="${jobSeList}" var="job">
<option value="<c:out value='${job.jobSeCode}'/>" <c:if test="${personalDataAccessLogSearchVO.jobSeCode == job.jobSeCode}">selected="selected"</c:if>><c:out value="${job.jobSeNm}" /></option>
</c:forEach>
</select>
<span>처리대상ID :</span>
<form:input path="trgetId" title="처리대상 식별자" cssClass="tbox w4" />
<span>접속IP :</span>
<form:input path="conectIp" title="접속IP" cssClass="tbox w4" />
<br />
<span>처리구분 :</span>
<select name="dmlGubun" id="dmlGubun" title="처리구분">
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.dmlGubun}">selected="selected"</c:if>>전체</option>
<option value="S" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'S'}">selected="selected"</c:if>>조회</option>
<option value="I" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'I'}">selected="selected"</c:if>>등록</option>
<option value="U" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'U'}">selected="selected"</c:if>>수정</option>
<option value="D" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'D'}">selected="selected"</c:if>>삭제</option>
</select>
<span>처리결과 :</span>
<select name="processResult" id="processResult" title="처리결과">
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.processResult}">selected="selected"</c:if>>전체</option>
<option value="S" <c:if test="${personalDataAccessLogSearchVO.processResult == 'S'}">selected="selected"</c:if>>성공</option>
<option value="F" <c:if test="${personalDataAccessLogSearchVO.processResult == 'F'}">selected="selected"</c:if>>실패/차단</option>
</select>
<span>기간 :</span>
<form:input path="fromDt" title="조회 시작일시" cssClass="tbox w4" maxlength="19" />
~
<form:input path="toDt" title="조회 종료일시" cssClass="tbox w4" maxlength="19" />
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
<a href="<c:url value='${PATH}/sec/accessLog/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
</fieldset>
</div>
<!-- //검색창 -->
<div class="guide">
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.<br />
· 보관기간은 <strong>730일</strong>이며, 파기는 개별 삭제가 아니라 <strong>월 단위 파티션 삭제</strong>로만 수행됩니다(파기 내역은 별도 이력으로 보존).<br />
· 각 기록은 <strong>SHA-256 해시체인</strong>으로 연결되어 있어 중간 변조 시 무결성 검증 배치가 탐지합니다. 이 화면에서는 수정·삭제를 제공하지 않습니다.<br />
· <strong>다운로드는 사유 입력이 필수</strong>이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다.
</div>
<div class="btn_area">
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
<input type="button" class="btn_dl" value="CSV 다운로드(사유 필수)" onclick="fn_download();" />
</div>
<table summary="개인정보 접속기록 목록" class="list1">
<caption>개인정보 접속기록 목록</caption>
<colgroup>
<col width="5%" />
<col width="13%" />
<col width="10%" />
<col width="11%" />
<col width="*" />
<col width="7%" />
<col width="12%" />
<col width="8%" />
<col width="9%" />
</colgroup>
<thead>
<tr>
<th>NO.</th>
<th>접속일시</th>
<th>접속자ID</th>
<th>접속IP</th>
<th>수행업무</th>
<th>처리구분</th>
<th>처리대상</th>
<th>처리결과</th>
<th>무결성해시</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<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.conectId}" />
<c:if test="${not empty result.userSe}">(<c:out value="${result.userSe}" />)</c:if>
</td>
<td class="ip"><c:out value="${result.conectIp}" /></td>
<td><c:out value="${result.jobSeNm}" /></td>
<td>
<c:choose>
<c:when test="${result.dmlGubun == 'S'}"><span class="dml">조회</span></c:when>
<c:when test="${result.dmlGubun == 'I'}"><span class="dml">등록</span></c:when>
<c:when test="${result.dmlGubun == 'U'}"><span class="dml">수정</span></c:when>
<c:when test="${result.dmlGubun == 'D'}"><span class="dml d">삭제</span></c:when>
<c:otherwise><c:out value="${result.dmlGubun}" /></c:otherwise>
</c:choose>
</td>
<td>
<c:out value="${result.trgetId}" />
<c:if test="${not empty result.trgetCn}"><br /><span class="hash"><c:out value="${result.trgetCn}" /></span></c:if>
</td>
<td>
<c:choose>
<c:when test="${result.processResult == 'F'}"><span class="badge f">실패/차단</span></c:when>
<c:otherwise><span class="badge s">성공</span></c:otherwise>
</c:choose>
</td>
<td class="hash" title="<c:out value='${result.hashVal}'/>">
<c:out value="${fn:substring(result.hashVal, 0, 12)}" />…
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,157 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/authorHistory/listView.do' />";
document.frm.submit();
}
function fn_download() {
var resn = prompt("다운로드 사유를 입력하세요.(필수)", "");
if (resn === null) {
return;
}
resn = $.trim(resn);
if (resn.length === 0) {
alert("다운로드 사유는 필수입니다.");
return;
}
var params = {
searchTrgetId: $("#searchTrgetId").val(),
searchChangerId: $("#searchChangerId").val(),
searchChgSeCode: $("#searchChgSeCode").val(),
fromDt: $("#fromDt").val(),
toDt: $("#toDt").val(),
dwldResn: resn
};
var qs = $.param(params);
location.href = "<c:url value='${PATH}/sec/authorHistory/download.do' />?" + qs;
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.g { background:#3a9d5d; }
.badge.m { background:#5b7ec2; }
.badge.r { background:#c25b5b; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<form:form commandName="authorChangeHistoryVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>대상자ID :</span>
<form:input path="searchTrgetId" title="대상자ID" cssClass="tbox w4" />
<span>변경자ID :</span>
<form:input path="searchChangerId" title="변경자ID" cssClass="tbox w4" />
<span>변경구분 :</span>
<select name="searchChgSeCode" id="searchChgSeCode" title="변경구분">
<option value="" <c:if test="${empty authorChangeHistoryVO.searchChgSeCode}">selected="selected"</c:if>>전체</option>
<option value="G" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'G'}">selected="selected"</c:if>>부여</option>
<option value="M" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'M'}">selected="selected"</c:if>>변경</option>
<option value="R" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'R'}">selected="selected"</c:if>>회수</option>
</select>
<span>기간 :</span>
<form:input path="fromDt" title="검색시작일" cssClass="tbox w4" />
~
<form:input path="toDt" title="검색종료일" cssClass="tbox w4" />
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
<a href="<c:url value='${PATH}/sec/authorHistory/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
</fieldset>
</div>
<!-- //검색창 -->
<!-- 버튼 -->
<div class="btn_zone">
<a href="javascript:fn_download();" class="btn2">다운로드(CSV)</a>
</div>
<table summary="권한변경 이력 목록" class="list1">
<caption>권한변경 이력 목록</caption>
<colgroup>
<col width="5%" />
<col width="13%" />
<col width="10%" />
<col width="8%" />
<col width="14%" />
<col width="14%" />
<col width="*" />
<col width="9%" />
<col width="9%" />
</colgroup>
<thead>
<tr>
<th>NO.</th>
<th>변경일시</th>
<th>대상자ID</th>
<th>변경구분</th>
<th>변경전 권한</th>
<th>변경후 권한</th>
<th>변경사유</th>
<th>승인자</th>
<th>변경자ID</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<td><c:out value="${authorChangeHistoryVO.totalRecordCount - (status.index + (authorChangeHistoryVO.pageIndex-1)*authorChangeHistoryVO.recordCountPerPage)}" /></td>
<td><c:out value="${result.chgDt}" /></td>
<td><c:out value="${result.trgetId}" /></td>
<td>
<c:choose>
<c:when test="${result.chgSeCode == 'G'}"><span class="badge g">부여</span></c:when>
<c:when test="${result.chgSeCode == 'M'}"><span class="badge m">변경</span></c:when>
<c:when test="${result.chgSeCode == 'R'}"><span class="badge r">회수</span></c:when>
<c:otherwise><c:out value="${result.chgSeCode}" /></c:otherwise>
</c:choose>
</td>
<td><c:out value="${result.beforeAuthor}" /></td>
<td><c:out value="${result.afterAuthor}" /></td>
<td class="sbj"><c:out value="${result.chgResn}" /></td>
<td><c:out value="${result.aprovlId}" /></td>
<td><c:out value="${result.changerId}" /></td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,132 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/dormantStatus/listView.do' />";
document.frm.submit();
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.p { background:#3a9d5d; }
.badge.s { background:#c25b5b; }
.badge.a { background:#5b7ec2; }
.badge.d { background:#888; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<form:form commandName="dormantAccountStatusVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>계정ID :</span>
<form:input path="accountId" title="계정ID" cssClass="tbox w4" />
<span>성명 :</span>
<form:input path="userNm" title="성명" cssClass="tbox w4" />
<span>상태 :</span>
<select name="sttusCode" id="sttusCode" title="상태">
<option value="" <c:if test="${empty dormantAccountStatusVO.sttusCode}">selected="selected"</c:if>>전체</option>
<option value="P" <c:if test="${dormantAccountStatusVO.sttusCode == 'P'}">selected="selected"</c:if>>사용</option>
<option value="A" <c:if test="${dormantAccountStatusVO.sttusCode == 'A'}">selected="selected"</c:if>>승인대기</option>
<option value="S" <c:if test="${dormantAccountStatusVO.sttusCode == 'S'}">selected="selected"</c:if>>사용중지</option>
<option value="D" <c:if test="${dormantAccountStatusVO.sttusCode == 'D'}">selected="selected"</c:if>>말소</option>
</select>
<span>미접속일수(이상) :</span>
<form:input path="minNoConnectDays" title="미접속일수 하한" cssClass="tbox w4" />
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
<a href="<c:url value='${PATH}/sec/dormantStatus/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
</fieldset>
</div>
<!-- //검색창 -->
<table summary="장기 미사용 계정 현황 목록" class="list1">
<caption>장기 미사용 계정 현황 목록</caption>
<colgroup>
<col width="5%" />
<col width="12%" />
<col width="10%" />
<col width="12%" />
<col width="10%" />
<col width="12%" />
<col width="9%" />
<col width="9%" />
<col width="*" />
</colgroup>
<thead>
<tr>
<th>NO.</th>
<th>계정ID</th>
<th>성명</th>
<th>소속</th>
<th>직위</th>
<th>최종로그인일</th>
<th>미접속일수</th>
<th>상태</th>
<th>휴면예정일</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<td><c:out value="${dormantAccountStatusVO.totalRecordCount - (status.index + (dormantAccountStatusVO.pageIndex-1)*dormantAccountStatusVO.recordCountPerPage)}" /></td>
<td><c:out value="${result.accountId}" /></td>
<td><c:out value="${result.userNm}" /></td>
<td><c:out value="${result.orgnztId}" /></td>
<td><c:out value="${result.ofcpsNm}" /></td>
<td><c:out value="${result.lastLoginDt}" /></td>
<td><c:out value="${result.noConnectDays}" /></td>
<td>
<c:choose>
<c:when test="${result.sttusCode == 'P'}"><span class="badge p">사용</span></c:when>
<c:when test="${result.sttusCode == 'A'}"><span class="badge a">승인대기</span></c:when>
<c:when test="${result.sttusCode == 'S'}"><span class="badge s">사용중지</span></c:when>
<c:when test="${result.sttusCode == 'D'}"><span class="badge d">말소</span></c:when>
<c:otherwise><c:out value="${result.sttusCode}" /></c:otherwise>
</c:choose>
</td>
<td><c:out value="${result.dormantExpectDt}" /></td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,253 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
업무유형 매핑 관리 (개인정보보호 SFR-004 / SER-002 / #32-1)
· PDAL_JOB_MAPPING(#32) 등록·수정·사용중지·삭제. 접속기록 수집 Aspect(#31)의 분류 기준.
· 저장 즉시 JobMappingResolver 캐시가 무효화되어 WAS 재기동 없이 반영된다.
· 상태변경(save/useAt/delete/refresh)은 모두 POST — 링크 클릭으로는 변경되지 않는다.
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/jobMapping/listView.do' />";
document.frm.submit();
}
function fn_regist(mappingId) {
var url = "<c:url value='${PATH}/sec/jobMapping/registView.do' />?page=9100045";
if (mappingId) {
url = url + "&mappingId=" + mappingId;
}
location.href = url;
}
/* 상태변경은 POST 전용 폼으로 전송한다. */
function fn_post(action, mappingId, useAt) {
document.actFrm.action = action;
document.actFrm.mappingId.value = (mappingId == null) ? "" : mappingId;
document.actFrm.useAt.value = (useAt == null) ? "" : useAt;
document.actFrm.submit();
}
/* 업무명은 사용자 입력값이라 JS 문자열로 인라인 보간하지 않는다(따옴표 포함 시 스크립트 파손·주입).
data-* 속성에 c:out 으로 escape 해 담고, 버튼 클릭 시 DOM 에서 읽는다. */
$(document).ready(function() {
$(".js-useAt").click(function() {
var id = $(this).attr("data-id");
var useAt = $(this).attr("data-useat");
var nm = $(this).attr("data-nm");
var msg = (useAt == "N")
? "[" + nm + "] 매핑을 사용중지합니다.\n\n사용중지해도 접속기록 수집은 계속되며, 이 매핑으로 분류되던 처리는\n메서드명 기반 기본 분류로 적재됩니다. 진행할까요?"
: "[" + nm + "] 매핑을 다시 사용합니다. 진행할까요?";
if (confirm(msg)) {
fn_post("<c:url value='${PATH}/sec/jobMapping/useAt.do' />", id, useAt);
}
});
$(".js-delete").click(function() {
var id = $(this).attr("data-id");
var nm = $(this).attr("data-nm");
if (confirm("[" + nm + "] 매핑을 삭제합니다.\n\n삭제하면 어떤 분류 기준이 쓰였는지 확인할 수 없습니다.\n일시적으로 끄는 것이라면 '사용중지'를 쓰세요. 그래도 삭제할까요?")) {
fn_post("<c:url value='${PATH}/sec/jobMapping/delete.do' />", id, null);
}
});
$(".js-modify").click(function() {
fn_regist($(this).attr("data-id"));
});
});
function fn_refresh() {
if (!confirm("DB를 직접 수정한 내용을 반영하기 위해 매핑 캐시를 다시 읽습니다. 진행할까요?")) {
return;
}
fn_post("<c:url value='${PATH}/sec/jobMapping/refresh.do' />", null, null);
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.y { background:#4a7ebb; }
.badge.n { background:#999; }
.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.auto { border-style:dashed; color:#888; }
.mono { font-family:Consolas,monospace; }
.sub { font-size:11px; color:#888; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#eef5ff; border:1px solid #b8d0ef; font-size:12px; color:#24507f; }
.btn_area { margin:8px 0; text-align:right; }
.btn1 { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
.btn2 { padding:5px 14px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:12px; }
.btn_s { padding:2px 8px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:11px; }
.btn_s.del { border-color:#c25b5b; color:#c25b5b; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<%-- 상태변경 전용 POST 폼(목록 검색폼과 분리) --%>
<form name="actFrm" id="actFrm" method="post" action="#">
<input type="hidden" name="mappingId" value="" />
<input type="hidden" name="useAt" value="" />
</form>
<form:form commandName="jobMappingSearchVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<%-- 검색·페이징 후에도 좌측메뉴 하이라이트가 유지되도록 메뉴번호를 유지한다(left.jsp 는 param.page 로 판정). --%>
<input type="hidden" name="page" value="9100045" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>대상 클래스 :</span>
<form:input path="searchTargetClass" title="대상 클래스명" cssClass="tbox w4" />
<span>업무코드/업무명 :</span>
<form:input path="searchJobSe" title="업무코드 또는 업무명" cssClass="tbox w4" />
<span>처리구분 :</span>
<select name="searchDmlGubun" id="searchDmlGubun" title="처리구분">
<option value="" <c:if test="${empty jobMappingSearchVO.searchDmlGubun}">selected="selected"</c:if>>전체</option>
<option value="S" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'S'}">selected="selected"</c:if>>조회</option>
<option value="I" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'I'}">selected="selected"</c:if>>등록</option>
<option value="U" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'U'}">selected="selected"</c:if>>수정</option>
<option value="D" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'D'}">selected="selected"</c:if>>삭제</option>
</select>
<span>사용여부 :</span>
<select name="searchUseAt" id="searchUseAt" title="사용여부">
<option value="" <c:if test="${empty jobMappingSearchVO.searchUseAt}">selected="selected"</c:if>>전체</option>
<option value="Y" <c:if test="${jobMappingSearchVO.searchUseAt == 'Y'}">selected="selected"</c:if>>사용</option>
<option value="N" <c:if test="${jobMappingSearchVO.searchUseAt == 'N'}">selected="selected"</c:if>>사용중지</option>
</select>
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
<a href="<c:url value='${PATH}/sec/jobMapping/listView.do?page=9100045' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
</fieldset>
</div>
<!-- //검색창 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<div class="guide">
· 접속기록(개인정보 처리기록)의 <strong>수행업무 분류 기준</strong>입니다. 수집 Aspect가 가로챈 <strong>(대상 클래스, 메서드명)</strong>을 업무코드·업무명으로 해석합니다.<br />
· 개인정보 화면이 새로 생기면 <strong>이 표에 행만 추가</strong>하면 분류됩니다(소스 수정 불요). 저장 즉시 반영되며 WAS 재기동이 필요 없습니다.<br />
· <strong>매핑을 지워도 수집이 멈추지는 않습니다.</strong> 수집 대상은 <span class="mono">context-syslogaop.xml</span> 의 pointcut이 정하며, 매핑이 없으면 메서드명 기반 기본 분류(insert→등록, update→수정, delete→삭제, 그 외 조회)로 적재됩니다.<br />
· 한 메서드에 여러 매핑이 걸리면 <strong>우선순위(작은 값)</strong>가 이깁니다. 구체적인 규칙은 낮게, <span class="mono">*</span> 폴백은 높게 두세요.
</div>
<div class="btn_area">
총 <strong><c:out value="${jobMappingSearchVO.totalRecordCount}" /></strong>건
<input type="button" class="btn2" value="캐시 재적재" onclick="fn_refresh();" title="DB를 직접 수정한 내용을 반영합니다" />
<input type="button" class="btn1" value="매핑 등록" onclick="fn_regist();" />
</div>
<table summary="업무유형 매핑 목록" class="list1">
<caption>업무유형 매핑 목록</caption>
<colgroup>
<col width="5%" />
<col width="*" />
<col width="10%" />
<col width="20%" />
<col width="7%" />
<col width="7%" />
<col width="7%" />
<col width="16%" />
</colgroup>
<thead>
<tr>
<th>순위</th>
<th>대상 클래스</th>
<th>메서드 패턴</th>
<th>업무코드 / 업무명</th>
<th>처리구분</th>
<th>사용</th>
<th>최종변경</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result">
<tr>
<td><c:out value="${result.sortOrdr}" /></td>
<td class="mono">
<c:out value="${result.targetClass}" />
<c:if test="${not empty result.rm}"><br /><span class="sub"><c:out value="${result.rm}" /></span></c:if>
</td>
<td class="mono"><c:out value="${result.methodPttrn}" /></td>
<td>
<span class="mono"><c:out value="${result.jobSeCode}" /></span><br />
<c:out value="${result.jobSeNm}" />
</td>
<td>
<c:choose>
<c:when test="${result.dmlGubun == 'S'}"><span class="dml">조회</span></c:when>
<c:when test="${result.dmlGubun == 'I'}"><span class="dml">등록</span></c:when>
<c:when test="${result.dmlGubun == 'U'}"><span class="dml">수정</span></c:when>
<c:when test="${result.dmlGubun == 'D'}"><span class="dml d">삭제</span></c:when>
<c:otherwise><span class="dml auto" title="메서드명으로 유도">자동</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${result.useAt == 'N'}"><span class="badge n">중지</span></c:when>
<c:otherwise><span class="badge y">사용</span></c:otherwise>
</c:choose>
</td>
<td class="sub">
<c:choose>
<c:when test="${empty result.updusrId}">시드</c:when>
<c:otherwise>
<c:out value="${result.updusrId}" /><br /><c:out value="${fn:substring(result.updtDt, 0, 16)}" />
</c:otherwise>
</c:choose>
</td>
<td>
<input type="button" class="btn_s js-modify" value="수정" data-id="<c:out value='${result.mappingId}'/>" />
<c:choose>
<c:when test="${result.useAt == 'N'}">
<input type="button" class="btn_s js-useAt" value="사용" data-id="<c:out value='${result.mappingId}'/>" data-useat="Y" data-nm="<c:out value='${result.jobSeNm}'/>" />
</c:when>
<c:otherwise>
<input type="button" class="btn_s js-useAt" value="중지" data-id="<c:out value='${result.mappingId}'/>" data-useat="N" data-nm="<c:out value='${result.jobSeNm}'/>" />
</c:otherwise>
</c:choose>
<input type="button" class="btn_s del js-delete" value="삭제" data-id="<c:out value='${result.mappingId}'/>" data-nm="<c:out value='${result.jobSeNm}'/>" />
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,188 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%--
업무유형 매핑 등록·수정 (개인정보보호 SFR-004 / SER-002 / #32-1)
· mappingId 가 0 이면 등록, 그 외는 수정(컨트롤러 save.do 가 분기).
· 저장은 POST 전용. 서버측에서 형식·중복을 다시 검증하므로 여기 검사는 1차 안내용이다.
--%>
<c:set var="PATH" value="/mnt" />
<c:set var="isModify" value="${jobMappingVO.mappingId > 0}" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_save() {
var f = document.frm;
if (f.targetClass.value.replace(/^\s+|\s+$/g, "") == "") {
alert("대상 클래스명을 입력하세요.");
f.targetClass.focus();
return;
}
if (f.methodPttrn.value.replace(/^\s+|\s+$/g, "") == "") {
alert("메서드 패턴을 입력하세요. 전체를 대상으로 하려면 * 를 입력합니다.");
f.methodPttrn.focus();
return;
}
if (f.jobSeCode.value.replace(/^\s+|\s+$/g, "") == "") {
alert("업무코드를 입력하세요.");
f.jobSeCode.focus();
return;
}
if (!/^[A-Za-z0-9_]+$/.test(f.jobSeCode.value)) {
alert("업무코드는 영문·숫자·밑줄만 사용할 수 있습니다.");
f.jobSeCode.focus();
return;
}
if (f.jobSeNm.value.replace(/^\s+|\s+$/g, "") == "") {
alert("업무명을 입력하세요.");
f.jobSeNm.focus();
return;
}
/* 숫자 아닌 값이 가면 서버 바인딩이 실패한다(컨트롤러가 BindingResult 로 되돌리지만 입력은 유실됨). */
if (!/^[0-9]{1,4}$/.test(f.sortOrdr.value.replace(/^\s+|\s+$/g, ""))) {
alert("우선순위는 0~9999 사이의 숫자로 입력하세요.");
f.sortOrdr.focus();
return;
}
f.method = "post";
f.action = "<c:url value='${PATH}/sec/jobMapping/save.do' />";
f.submit();
}
function fn_cancel() {
location.href = "<c:url value='${PATH}/sec/jobMapping/listView.do?page=9100045' />";
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.mono { font-family:Consolas,monospace; }
.hint { display:block; margin-top:3px; font-size:11px; color:#888; }
.req { color:#c25b5b; }
.btn_area { margin:12px 0; text-align:center; }
.btn1 { padding:6px 20px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
.btn2 { padding:6px 20px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:12px; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<div class="guide">
· 접속기록 수집 Aspect가 가로챈 <strong>(대상 클래스, 메서드명)</strong>을 업무코드·업무명으로 분류하는 규칙 한 건입니다.<br />
· 대상 클래스는 <strong>수집 대상(pointcut)에 이미 포함된 서비스</strong>여야 의미가 있습니다. 여기 등록한다고 수집 범위가 넓어지지는 않습니다
(수집 범위 변경은 <span class="mono">context-syslogaop.xml</span> 수정·배포가 필요합니다).<br />
· 저장하면 매핑 캐시가 즉시 갱신되어 WAS 재기동 없이 반영됩니다.
</div>
<form:form commandName="jobMappingVO" id="frm" name="frm" method="post" onsubmit="return false;">
<form:hidden path="mappingId" />
<table summary="업무유형 매핑 입력" class="write1">
<caption>업무유형 매핑 <c:choose><c:when test="${isModify}">수정</c:when><c:otherwise>등록</c:otherwise></c:choose></caption>
<colgroup>
<col width="20%" />
<col width="*" />
</colgroup>
<tbody>
<tr>
<th scope="row">대상 클래스 <span class="req">*</span></th>
<td>
<form:input path="targetClass" cssClass="tbox w8 mono" maxlength="200" title="대상 서비스 클래스명" />
<span class="hint">서비스 impl 단순 클래스명(예: <span class="mono">EgovMberManageServiceImpl</span>) 또는 패키지 포함 FQN. 200자 이내.</span>
</td>
</tr>
<tr>
<th scope="row">메서드 패턴 <span class="req">*</span></th>
<td>
<form:input path="methodPttrn" cssClass="tbox w4 mono" maxlength="100" title="메서드 패턴" />
<span class="hint">메서드명 <strong>접두</strong> 매칭(예: <span class="mono">select</span>, <span class="mono">insertMber</span>) 또는 <span class="mono">*</span>(해당 클래스 전체).</span>
</td>
</tr>
<tr>
<th scope="row">업무코드 <span class="req">*</span></th>
<td>
<form:input path="jobSeCode" cssClass="tbox w4 mono" maxlength="30" title="수행업무 유형 코드" />
<span class="hint">영문 대문자·숫자·밑줄 30자 이내(예: <span class="mono">MBER_VIEW</span>). 소문자로 입력해도 대문자로 저장됩니다. 접속기록 검색 드롭다운의 값이 됩니다.</span>
</td>
</tr>
<tr>
<th scope="row">업무명 <span class="req">*</span></th>
<td>
<form:input path="jobSeNm" cssClass="tbox w8" maxlength="100" title="수행업무 유형명" />
<span class="hint">화면·기능명(예: 회원관리-조회). 접속기록 목록의 '수행업무' 열에 그대로 표시됩니다. 100자 이내.</span>
</td>
</tr>
<tr>
<th scope="row">처리구분</th>
<td>
<form:select path="dmlGubun" title="처리구분">
<form:option value="" label="자동(메서드명으로 유도)" />
<form:option value="S" label="조회" />
<form:option value="I" label="등록" />
<form:option value="U" label="수정" />
<form:option value="D" label="삭제" />
</form:select>
<span class="hint">'자동' 선택 시 메서드명 접두로 유도합니다(insert·regist·creat·add·save→등록 / update·modify·chang→수정 / delete·remov→삭제 / 그 외→조회).</span>
</td>
</tr>
<tr>
<th scope="row">우선순위</th>
<td>
<form:input path="sortOrdr" cssClass="tbox w2" maxlength="4" title="매칭 우선순위" />
<span class="hint">여러 매핑이 걸리면 <strong>작은 값이 우선</strong>합니다. 구체 규칙은 10~30, <span class="mono">*</span> 폴백은 90 이상을 권장합니다(0~9999).</span>
</td>
</tr>
<tr>
<th scope="row">사용여부</th>
<td>
<form:select path="useAt" title="사용여부">
<form:option value="Y" label="사용" />
<form:option value="N" label="사용중지" />
</form:select>
<span class="hint">사용중지해도 수집은 계속되며, 이 규칙만 매칭 대상에서 빠집니다.</span>
</td>
</tr>
<tr>
<th scope="row">비고</th>
<td>
<form:input path="rm" cssClass="tbox w8" maxlength="300" title="비고" />
<span class="hint">등록 근거·주의사항 등. 300자 이내.</span>
</td>
</tr>
<c:if test="${isModify}">
<tr>
<th scope="row">변경 이력</th>
<td>
등록 <c:out value="${jobMappingVO.regDt}" />
<c:if test="${not empty jobMappingVO.registerId}">(<c:out value="${jobMappingVO.registerId}" />)</c:if>
<c:if test="${not empty jobMappingVO.updtDt}">
/ 최종변경 <c:out value="${jobMappingVO.updtDt}" />
<c:if test="${not empty jobMappingVO.updusrId}">(<c:out value="${jobMappingVO.updusrId}" />)</c:if>
</c:if>
</td>
</tr>
</c:if>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn1" value="저장" onclick="fn_save();" />
<input type="button" class="btn2" value="취소" onclick="fn_cancel();" />
</div>
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,34 @@
-- =====================================================================
-- 장기 미사용 계정 개인정보 파기 이력 : ACCOUNT_DELETE_HISTORY
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12 계정 삭제 기능)
-- 작성일: 2026-07-28 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [목적] 휴면 확정(COMTNDIAPAUSEMBER.DIAPAUSE_STAT='P') 후 정책상 보존기간
-- (ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이 경과한 계정의 개인정보를
-- 파기(마스킹)한 이력을 남긴다. 실제 파기 방식은 행 DELETE가 아니라
-- PII 컬럼 마스킹(기존 회원탈퇴 MBER_STTUS='D' 컨벤션과 동일) — 다른
-- 테이블(게시글·마일리지·활동확인 등)이 MBER_ID/EMPLYR_ID를 참조하는
-- 소프트 조인이 많아 행 자체를 지우면 그쪽이 깨지기 때문.
--
-- [보존기간] ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 값을 실행 시점에 그대로
-- 조회해 사용(하드코딩 없음). 이 테이블의 PRESERVE_DAY 컬럼은
-- 그 시점에 적용된 값의 스냅샷(정책이 나중에 바뀌어도 이력은 불변).
--
-- [처리량 제한] 배치가 오래 정지돼 있다가 재가동되는 날 백로그가 한꺼번에
-- 몰리는 것을 막기 위해 1회 처리건수를 상한(LIMIT 200, DAO 참조)
-- 으로 제한한다(#7/#10과 동일 정책, 2026-07-28).
-- =====================================================================
CREATE TABLE IF NOT EXISTS ACCOUNT_DELETE_HISTORY (
DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)',
MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)',
MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자',
ESNTL_ID VARCHAR(20) NULL COMMENT '고유식별자(파기 시점 스냅샷, 참고용)',
DIAPAUSE_DT DATETIME NULL COMMENT '휴면 확정 기준일시(파기 판단 기준)',
PRESERVE_DAY INT NOT NULL COMMENT '파기 시점 적용된 보존기간(정책값 스냅샷, 일)',
DEL_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '파기 처리 일시',
PRIMARY KEY (DEL_ID),
KEY IX_ADH_MBER (MBER_ID),
KEY IX_ADH_DELDT (DEL_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='장기 미사용 계정 개인정보 파기 이력(개인정보보호 SFR-002 #12)';

View File

@ -0,0 +1,37 @@
-- =====================================================================
-- 계정 관리 정책 확장 : ACCOUNT_MNG_POLICY 에 파기 배치 사용여부 컬럼 추가
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12 계정 삭제 기능)
-- 작성일: 2026-07-28 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [배경] #12 파기 배치(BatchScheduler.purgeExpiredAccountScheduler)는 되돌릴 수 없는
-- 처리라 그동안 @Scheduled 를 소스에서 주석 처리해 막아두었다. 그 방식은
-- 켜고 끌 때마다 재배포가 필요해 운영이 불편하다.
-- → 배치는 항상 등록해 두고, 실제 파기 수행 여부를 이 정책 플래그로 제어한다.
-- (플래그 'N'이면 서비스가 즉시 0건 반환하고 아무것도 파기하지 않음)
--
-- [안전] DEFAULT 'N' — 신규/기존 환경 모두 기본은 '파기 안 함'. 관리자가
-- 계정 관리 정책 화면(#13, /mnt/sec/accountPolicy/policyView.do)에서
-- 명시적으로 'Y'로 바꿔야 비로소 파기가 시작된다.
--
-- [관련] 파기 기준일은 별도 컬럼이 아니라 기존 DELETE_PRESERVE_DAY 를 그대로 사용한다
-- (같은 의미의 값을 두 곳에 두지 않기 위함).
--
-- [멱등] MySQL 8 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않으므로
-- information_schema 로 컬럼 존재를 확인해 없을 때만 실행한다(재실행 안전).
-- MySQL 8 / MariaDB 10.x 공통 동작.
-- =====================================================================
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
AND COLUMN_NAME = 'PURGE_USE_YN') > 0,
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.PURGE_USE_YN already exists'' AS msg',
'ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT ''N''
COMMENT ''장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- [검증] SELECT PURGE_USE_YN FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1;
-- [롤백] ALTER TABLE ACCOUNT_MNG_POLICY DROP COLUMN PURGE_USE_YN;

View File

@ -9,7 +9,7 @@
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정). -- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
-- ===================================================================== -- =====================================================================
CREATE TABLE ACCOUNT_UNLOCK_HISTORY ( CREATE TABLE IF NOT EXISTS ACCOUNT_UNLOCK_HISTORY (
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)', UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)', TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR', TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',

View File

@ -16,7 +16,7 @@
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정). -- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
-- ===================================================================== -- =====================================================================
CREATE TABLE AUTHOR_CHANGE_HISTORY ( CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_HISTORY (
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)', HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)', TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)', TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',

View File

@ -10,7 +10,7 @@
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수). -- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
-- ===================================================================== -- =====================================================================
CREATE TABLE AUTHOR_CHANGE_REQUEST ( CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_REQUEST (
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)', REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)', TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)', TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
@ -32,7 +32,7 @@ CREATE TABLE AUTHOR_CHANGE_REQUEST (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)'; COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY ( CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)', DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자', DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',

View File

@ -13,13 +13,34 @@
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향. -- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다. -- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
-- --
-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행). -- [멱등] MySQL 8 은 ADD COLUMN/INDEX IF NOT EXISTS 를 지원하지 않으므로
-- information_schema 로 존재를 확인해 없을 때만 실행한다(재실행 안전).
-- MySQL 8 / MariaDB 10.x 공통 동작.
-- ===================================================================== -- =====================================================================
ALTER TABLE COMTNDIAPAUSEMBER SET @ddl := IF(
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR' (SELECT COUNT(*) FROM information_schema.COLUMNS
COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자'; WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNDIAPAUSEMBER'
AND COLUMN_NAME = 'MBER_TY_CODE') > 0,
'SELECT ''SKIP: COMTNDIAPAUSEMBER.MBER_TY_CODE already exists'' AS msg',
'ALTER TABLE COMTNDIAPAUSEMBER
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT ''GNR''
COMMENT ''회원유형 GNR:일반회원 EMP:개인정보취급자'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스 -- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
ALTER TABLE COMTNDIAPAUSEMBER SET @ddl := IF(
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT); (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNDIAPAUSEMBER'
AND INDEX_NAME = 'IX_DIAPAUSE_MBERTY') > 0,
'SELECT ''SKIP: IX_DIAPAUSE_MBERTY already exists'' AS msg',
'ALTER TABLE COMTNDIAPAUSEMBER
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- [검증] SHOW CREATE TABLE COMTNDIAPAUSEMBER;
-- [롤백] ALTER TABLE COMTNDIAPAUSEMBER DROP INDEX IX_DIAPAUSE_MBERTY, DROP COLUMN MBER_TY_CODE;

View File

@ -11,7 +11,7 @@
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의). -- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_DOWNLOAD_HISTORY ( CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_HISTORY (
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)', DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',

View File

@ -12,7 +12,7 @@
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력. -- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_INSPECTION_REPORT ( CREATE TABLE IF NOT EXISTS PDAL_INSPECTION_REPORT (
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)', RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)', RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시', PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',

View File

@ -0,0 +1,72 @@
-- =====================================================================
-- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002)
-- 개발항목 #32-1 (관리 화면) - 작성일: 2026-08-07 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [사유] #32 는 테이블+로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다.
-- 관리 화면(#32-1)에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면,
-- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다.
-- 특히 USE_AT='N' 전환은 해당 업무의 접속기록 분류를 무력화하므로
-- (수집 자체는 계속되나 업무코드가 폴백 처리됨) 변경 주체 기록이 필요하다.
--
-- [멱등] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 → information_schema 로
-- 존재를 확인해 없을 때만 실행한다(재실행 안전, MySQL 8 / MariaDB 공통).
--
-- [선행] privacy.job_mapping_create_mysql.sql (PDAL_JOB_MAPPING 생성)
-- =====================================================================
-- ── 등록자 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 등록일시 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 최종 변경자 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 최종 변경일시 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- [검증]
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'PDAL_JOB_MAPPING'
-- ORDER BY ORDINAL_POSITION;
-- -- 기대: 기존 9컬럼 + REGISTER_ID / REG_DT / UPDUSR_ID / UPDT_DT = 13컬럼
-- [롤백]
-- ALTER TABLE PDAL_JOB_MAPPING
-- DROP COLUMN REGISTER_ID, DROP COLUMN REG_DT,
-- DROP COLUMN UPDUSR_ID, DROP COLUMN UPDT_DT;

View File

@ -18,7 +18,7 @@
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. -- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_JOB_MAPPING ( CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
@ -35,19 +35,34 @@ CREATE TABLE PDAL_JOB_MAPPING (
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) -- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
-- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다. -- 신규 개인정보 화면 추가 시 아래 UNION ALL 목록에 행만 추가하면 수집된다.
--
-- [멱등] (TARGET_CLASS, METHOD_PTTRN) 조합이 이미 있으면 넣지 않는다.
-- 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 재실행 시
-- 시드가 통째로 중복 적재되는 것을 이 NOT EXISTS 가드로 막는다.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM) VALUES INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM)
('EgovMberManageServiceImpl', 'select', 'MBER_VIEW', '회원관리-조회', 'S', 10, '회원 개인정보 조회'), SELECT s.TARGET_CLASS, s.METHOD_PTTRN, s.JOB_SE_CODE, s.JOB_SE_NM, s.DML_GUBUN, s.SORT_ORDR, s.RM
('EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL), FROM (
('EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL), SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM
('EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL), UNION ALL SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL
('EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'), UNION ALL SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL
('EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'), UNION ALL SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL
('EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL), UNION ALL SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'
('EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL), UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'
('EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL), UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL
('EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'), UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL
('MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보'), UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL
('BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL), UNION ALL SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'
('BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL); UNION ALL SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보'
UNION ALL SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL
UNION ALL SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL
) s
WHERE NOT EXISTS (
SELECT 1 FROM PDAL_JOB_MAPPING t
WHERE t.TARGET_CLASS = s.TARGET_CLASS
AND t.METHOD_PTTRN = s.METHOD_PTTRN
);
-- [검증] SELECT COUNT(*) FROM PDAL_JOB_MAPPING; -- 시드 13건(운영 추가분 별도)
-- [롤백] DROP TABLE PDAL_JOB_MAPPING;

View File

@ -26,23 +26,62 @@
-- 컬럼목록이 없어, 이 ALTER 후에는 컬럼 수 불일치(ERROR 1136)로 실패한다. -- 컬럼목록이 없어, 이 ALTER 후에는 컬럼 수 불일치(ERROR 1136)로 실패한다.
-- 반드시 컬럼 명시 INSERT 로 교체된 sqlmap 과 함께 배포할 것. -- 반드시 컬럼 명시 INSERT 로 교체된 sqlmap 과 함께 배포할 것.
-- --
-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 기존 환경 재실행 시 -- [재실행] MySQL 8 은 ADD COLUMN/KEY IF NOT EXISTS 미지원이라, 아래는 각 항목마다
-- 중복 오류(1060/1061)는 무시. -- information_schema 로 존재를 확인해 없을 때만 실행한다(재실행 안전).
-- 컬럼/인덱스를 문장별로 분리했으므로 일부만 적용된 환경에서도 나머지가 채워진다.
-- ===================================================================== -- =====================================================================
ALTER TABLE LOGIN_HISTORY SET @ddl := IF(
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)', (SELECT COUNT(*) FROM information_schema.COLUMNS
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'; WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'CONECT_IP') > 0,
'SELECT ''SKIP: LOGIN_HISTORY.CONECT_IP already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT ''접속 IP(#22-1, ClientIpResolver 취득)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'RESET_YN') > 0,
'SELECT ''SKIP: LOGIN_HISTORY.RESET_YN already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT ''N''
COMMENT ''잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 조회화면 검색(계정·일자)·파기배치(일자) 대응 인덱스 -- 조회화면 검색(계정·일자)·파기배치(일자) 대응 인덱스
ALTER TABLE LOGIN_HISTORY SET @ddl := IF(
ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE), (SELECT COUNT(*) FROM information_schema.STATISTICS
ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE); WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_01') > 0,
'SELECT ''SKIP: IX_LOGIN_HISTORY_01 already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_02') > 0,
'SELECT ''SKIP: IX_LOGIN_HISTORY_02 already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 보관기간 정책값(단일행 정책 테이블에 편입 — 하드코딩 금지 원칙) -- 보관기간 정책값(단일행 정책 테이블에 편입 — 하드코딩 금지 원칙)
ALTER TABLE ACCOUNT_MNG_POLICY SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'LOGIN_HIST_KEEP_DAY') > 0,
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY already exists'' AS msg',
'ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730 ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'; COMMENT ''인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- [검증] -- [검증]
-- SHOW CREATE TABLE LOGIN_HISTORY; -- SHOW CREATE TABLE LOGIN_HISTORY;

View File

@ -27,7 +27,7 @@
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다. -- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
-- ===================================================================== -- =====================================================================
CREATE TABLE PERSONAL_DATA_ACCESS_LOG CREATE TABLE IF NOT EXISTS PERSONAL_DATA_ACCESS_LOG
( (
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호', LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)', CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',

View File

@ -12,7 +12,7 @@
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37). -- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_PURGE_HISTORY CREATE TABLE IF NOT EXISTS PDAL_PURGE_HISTORY
( (
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)', PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',

View File

@ -0,0 +1,64 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 개인정보 접속기록 조회·다운로드 (MENU_NO 9100044)
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002, 개발항목 #47)
-- 작성일: 2026-08-06 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위 10번째(MENU_ORDR 10).
-- PERSONAL_DATA_ACCESS_LOG(#30) 조회 화면이라 인증 실패 이력(9100039) 다음에 둔다.
--
-- [메뉴번호 9100044를 쓰는 이유] 권한관리(9100030) 하위의 다음 순번은 9100040 이지만
-- 9100040 은 이미 '게시판관리' 2depth 그룹(하위 9100041~9100043)이 선점하고 있다.
-- 9100044 는 미사용 번호임을 확인했다(9100044~9100070 구간에 9100070 만 존재).
-- 메뉴 정렬은 MENU_NO 가 아니라 MENU_ORDR(=10) 을 따르므로 노출 순서에 문제 없다.
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 동반 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- ① 권한관리(9100030) 블록에 9100044 <c:when> 분기
-- ② 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '9100044') 4곳
-- 이 함께 배포돼야 렌더된다. (소스에는 이미 반영 완료)
--
-- [재실행 안전] 전 구간 NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] 기존 권한관리 메뉴와 동일하게 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 2종.
-- [한글 리터럴 주의] git-bash + mysql.exe 로 이 파일을 stdin 파이프 실행하면 한글이
-- 이중 인코딩으로 손상된다(기존 실측). 적용 후 반드시 HEX(MENU_NM) 로 검증하고,
-- 손상 시 CONVERT(UNHEX('<UTF-8 hex>') USING utf8mb4) UPDATE 로 교정할 것.
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
-- =====================================================================
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST FK, 메뉴보다 먼저) ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AccessLogList' AS PROGRM_FILE_NM, '/mnt/sec/accessLog/' AS PROGRM_STRE_PATH,
'개인정보 접속기록' AS PROGRM_KOREAN_NM,
'개인정보 접속기록 조회·다운로드(SFR-004 #47)' AS PROGRM_DC,
'/mnt/sec/accessLog/listView.do?page=9100044' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 10 ──
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, 'AccessLogList' AS PROGRM_FILE_NM, 9100044 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 10 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] 권한↔메뉴 노출 매핑 (2권한, FK 안전) ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100044, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100044 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] 등록결과 + 한글 손상 여부(HEX 가 EA/EB/EC 대로 시작해야 정상) ──
-- 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 JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100044;
-- ── [롤백] (역순) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100044;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100044;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccessLogList';

View File

@ -0,0 +1,81 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 계정 관리 정책 설정 화면(#13)
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002 / 개발항목 #13)
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
-- ---------------------------------------------------------------------
-- [목적] /mnt/sec/accountPolicy/policyView.do (계정 관리 정책 설정)를
-- 관리자 좌측/상단 메뉴에 노출.
--
-- [배치] privacy.menu_dormant_status_insert_mysql.sql(#11, 9100037)의
-- "[향후 확장]" 주석에서 예고한 대로 9100038 부여:
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
-- - 권한생성 (9100031, EgovAuthorList)
-- - 롤관리 (9100034, EgovRoleList)
-- - 권한 신청·승인 (9100035, AuthorRequestList)
-- - 권한 변경 이력 (9100036, AuthorHistoryList)
-- - 미사용계정 현황 (9100037, DormantStatusList)
-- - 계정 관리 정책 (9100038, AccountPolicySet) <-- 신규
--
-- [메뉴번호 근거] 9100038 은 레포 전체 미사용(주석 예고만 존재) 확인 완료.
-- SFR-002 형제 화면(#11, 9100037)이 이미 권한관리(9100030) 하위에
-- 배치되어 있어, 신규 그룹 분리 대신 동일 그룹 유지를 택했다.
-- (신규 그룹 9100050 안은 SFR-002 화면 2개가 서로 다른 그룹으로
-- 쪼개지고 상위 메뉴 레코드까지 추가로 필요해 반려)
--
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
--
-- [메뉴 렌더 경로] LayoutMntController.left() -> MainMenu.selectMainMenuLeft
-- : COMTNMENUCREATDTLS(권한/메뉴) 없으면 화면에 안 뜸.
--
-- ** 이 DML만으로는 좌측메뉴에 안 뜬다 - left.jsp 수정 필수(반영 완료) **
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- 권한관리(9100030) 블록에 9100038 <c:when> 분기를 추가해야 렌더된다.
-- (2026-07-28 반영 완료: 9100038 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
--
-- [선행] privacy.account_mng_policy_create_mysql.sql (ACCOUNT_MNG_POLICY + 시드 1행)
-- 이 먼저 적용되어 있어야 화면 진입 시 기본값이 표시된다.
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 -> 반복 실행 무해.
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
-- [롤백] 맨 아래 주석 참조.
-- =====================================================================
-- -------------------------------------------------------------
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM -> COMTNPROGRMLIST)
-- -------------------------------------------------------------
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT 'AccountPolicySet', '/mnt/sec/accountPolicy/', '계정 관리 정책 설정',
'장기 미사용 계정 관리 정책 설정(SFR-002 개발항목 13)', '/mnt/sec/accountPolicy/policyView.do?page=9100038'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccountPolicySet');
-- -------------------------------------------------------------
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 미사용계정 현황(ord 7) 다음(ord 8)
-- -------------------------------------------------------------
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT '계정 관리 정책', 'AccountPolicySet', 9100038, 9100030, 8, '장기 미사용 계정 관리 정책 설정', '/', '/'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100038);
-- -------------------------------------------------------------
-- [3] 권한/메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
-- -------------------------------------------------------------
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100038, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100038 AND x.AUTHOR_CODE = g.ac);
-- -------------------------------------------------------------
-- [검증]
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM
-- WHERE m.MENU_NO=9100038;
--
-- [롤백]
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100038;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100038;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='AccountPolicySet';
-- =====================================================================

View File

@ -0,0 +1,70 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 권한 이력 조회·검색·다운로드 화면(#6 화면 구현)
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001 / 개발항목 #6)
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
-- ---------------------------------------------------------------------
-- [목적] /mnt/sec/authorHistory/listView.do (권한 변경 이력 조회·검색·다운로드)를
-- 관리자 좌측/상단 메뉴에 노출.
--
-- [배치] privacy.menu_author_request_insert_mysql.sql(#3, 9100035)의
-- "[향후 확장]" 주석에서 예고한 대로 9100036 부여:
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
-- ├ 권한생성 (9100031, EgovAuthorList)
-- ├ 롤관리 (9100034, EgovRoleList)
-- ├ 권한 신청·승인 (9100035, AuthorRequestList)
-- └ 권한 변경 이력 (9100036, AuthorHistoryList) ← 신규
--
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
--
-- [메뉴 렌더 경로] LayoutMntController.left() → MainMenu.selectMainMenuLeft
-- : COMTNMENUCREATDTLS(권한↔메뉴) 없으면 화면에 안 뜸.
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 필수(반영 완료) ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- 권한관리(9100030) 블록에 9100036 <c:when> 분기를 추가해야 렌더된다.
-- (2026-07-28 반영 완료: 9100036 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
-- [롤백] 맨 아래 주석 참조.
-- =====================================================================
-- ─────────────────────────────────────────────────────────────
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT 'AuthorHistoryList', '/mnt/sec/authorHistory/', '권한 변경 이력',
'권한 변경 이력 조회·검색·다운로드(SFR-001 #6)', '/mnt/sec/authorHistory/listView.do?page=9100036'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AuthorHistoryList');
-- ─────────────────────────────────────────────────────────────
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 권한 신청·승인(ord 5) 다음(ord 6)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT '권한 변경 이력', 'AuthorHistoryList', 9100036, 9100030, 6, '권한 변경 이력 조회·검색·다운로드', '/', '/'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100036);
-- ─────────────────────────────────────────────────────────────
-- [3] 권한↔메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100036, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100036 AND x.AUTHOR_CODE = g.ac);
-- ─────────────────────────────────────────────────────────────
-- [검증]
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM
-- WHERE m.MENU_NO=9100036;
--
-- [롤백]
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100036;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100036;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='AuthorHistoryList';
-- =====================================================================

View File

@ -0,0 +1,74 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 장기 미사용 계정 현황 조회 화면(#11)
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002 / 개발항목 #11)
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
-- ---------------------------------------------------------------------
-- [목적] /mnt/sec/dormantStatus/listView.do (장기 미사용 계정 현황 조회)를
-- 관리자 좌측/상단 메뉴에 노출.
--
-- [배치] privacy.menu_author_history_insert_mysql.sql(#6, 9100036)에 이어
-- 9100037 부여:
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
-- ├ 권한생성 (9100031, EgovAuthorList)
-- ├ 롤관리 (9100034, EgovRoleList)
-- ├ 권한 신청·승인 (9100035, AuthorRequestList)
-- ├ 권한 변경 이력 (9100036, AuthorHistoryList)
-- └ 미사용계정 현황 (9100037, DormantStatusList) ← 신규
--
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
--
-- [메뉴 렌더 경로] LayoutMntController.left() → MainMenu.selectMainMenuLeft
-- : COMTNMENUCREATDTLS(권한↔메뉴) 없으면 화면에 안 뜸.
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 필수(반영 완료) ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- 권한관리(9100030) 블록에 9100037 <c:when> 분기를 추가해야 렌더된다.
-- (2026-07-28 반영 완료: 9100037 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
-- [롤백] 맨 아래 주석 참조.
-- =====================================================================
-- ─────────────────────────────────────────────────────────────
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT 'DormantStatusList', '/mnt/sec/dormantStatus/', '장기 미사용계정 현황',
'장기 미사용 계정 현황 조회(SFR-002 #11)', '/mnt/sec/dormantStatus/listView.do?page=9100037'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'DormantStatusList');
-- ─────────────────────────────────────────────────────────────
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 권한 변경 이력(ord 6) 다음(ord 7)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT '미사용계정 현황', 'DormantStatusList', 9100037, 9100030, 7, '장기 미사용 계정 현황 조회', '/', '/'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100037);
-- ─────────────────────────────────────────────────────────────
-- [3] 권한↔메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
-- ─────────────────────────────────────────────────────────────
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100037, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100037 AND x.AUTHOR_CODE = g.ac);
-- ─────────────────────────────────────────────────────────────
-- [검증]
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM
-- WHERE m.MENU_NO=9100037;
--
-- [롤백]
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100037;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100037;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='DormantStatusList';
--
-- [향후 확장] 계정 관리 정책(#13) 화면도 동일 패턴으로 9100038 부여하여
-- 같은 권한관리/시스템관리 그룹에 추가.
-- =====================================================================

View File

@ -0,0 +1,60 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 업무유형 매핑 관리 (개인정보보호 SFR-004 / #32-1)
-- 대상 화면 : /mnt/sec/jobMapping/listView.do
-- 작성일 : 2026-08-07 DB: MySQL 8 (운영 DB가 MySQL 이라 Oracle 쌍 미작성)
-- ---------------------------------------------------------------------
-- [배치] 권한관리(9100030) 하위, 개인정보 접속기록(9100044) 바로 다음.
-- 접속기록 화면의 '수행업무' 분류 기준을 편집하는 화면이라 인접 배치한다.
-- ※ 9100040~9100043 은 '게시판관리' 그룹이 선점 → 9100045 사용(MENU_ORDR 11)
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- ① 9100045 <c:when> 분기 1곳 + ② 상위 하이라이트 조건 4곳
-- (시스템관리 class="on" / 시스템관리 display:block / 권한관리 class="on" /
-- 권한관리 ul display:block)에 param.page eq '9100045' 가 함께 배포돼야 렌더된다.
-- (arko_renual 소스에는 반영 완료 — 배포본에 이 JSP가 포함됐는지만 확인)
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (기존 개인정보 메뉴와 동일)
-- [적용순서] COMTNPROGRMLIST → COMTNMENUINFO → COMTNMENUCREATDTLS (FK 순서)
-- =====================================================================
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → FK, 메뉴보다 먼저) ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH,
'업무유형 매핑 관리' AS PROGRM_KOREAN_NM,
'개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC,
'/mnt/sec/jobMapping/listView.do?page=9100045' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 11 ──
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, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 11 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] 권한↔메뉴 노출 매핑 (FK 안전) ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100045, g.ac
FROM (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 = 9100045 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] ──
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_NM, m.MENU_ORDR, p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100045;
-- -- 한글 깨짐 확인: SELECT HEX(MENU_NM) FROM COMTNMENUINFO WHERE MENU_NO = 9100045;
-- -- '업' = EC9785 로 시작하면 정상(UTF-8)
-- ── [롤백] (역순) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100045;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100045;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'JobMappingList';

View File

@ -3,12 +3,14 @@
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) -- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
-- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터) -- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터)
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/ -- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039) -- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100045)
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행 -- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행
-- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구) -- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구)
-- 주의 : 신규 설치용. 기존 테이블이 있는 환경 재실행 시 CREATE 중복 오류 가능 -- 재실행 : **전 구간 재실행 안전(멱등)** — PART 1 은 CREATE TABLE IF NOT EXISTS 와
-- (IF NOT EXISTS 미지정 테이블). 그런 환경은 PART 2/PART 3(모두 NOT EXISTS -- information_schema 존재 확인 후 실행되는 ALTER(컬럼/인덱스), PART 2·3 은
-- 가드로 재실행 안전)만 실행하세요. -- NOT EXISTS 가드를 쓴다. 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고
-- 넘어가므로, 기존 환경에도 이 파일 하나만 그대로 실행하면 된다.
-- (2026-08-05 이전 버전은 재실행 시 1050/1060/1061 오류가 났다)
-- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가 -- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가
-- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★ -- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★
-- ##################################################################### -- #####################################################################
@ -66,7 +68,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_MNG_POLICY (
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정). -- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
-- ===================================================================== -- =====================================================================
CREATE TABLE ACCOUNT_UNLOCK_HISTORY ( CREATE TABLE IF NOT EXISTS ACCOUNT_UNLOCK_HISTORY (
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)', UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)', TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR', TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',
@ -115,7 +117,7 @@ UPDATE LOGIN_LOCK_COUNT SET LOCK_COUNT = 5;
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정). -- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
-- ===================================================================== -- =====================================================================
CREATE TABLE AUTHOR_CHANGE_HISTORY ( CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_HISTORY (
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)', HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)', TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)', TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',
@ -150,7 +152,7 @@ CREATE TABLE AUTHOR_CHANGE_HISTORY (
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수). -- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
-- ===================================================================== -- =====================================================================
CREATE TABLE AUTHOR_CHANGE_REQUEST ( CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_REQUEST (
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)', REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)', TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)', TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
@ -172,7 +174,7 @@ CREATE TABLE AUTHOR_CHANGE_REQUEST (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)'; COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY ( CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)', DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자', DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',
@ -209,7 +211,7 @@ CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY (
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. -- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_JOB_MAPPING ( CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
@ -227,8 +229,64 @@ CREATE TABLE PDAL_JOB_MAPPING (
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) -- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
-- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다. -- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다.
-- ※ 시드 INSERT 본문은 PART 2 로 분리되어 있다.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- ─────────────────────────────────────────────────────────────
-- [privacy.job_mapping_audit_cols_mysql.sql]
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가
-- 개발항목 #32-1 (관리 화면) - 2026-08-07
-- ---------------------------------------------------------------------
-- [사유] 관리 화면에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면
-- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다.
-- [멱등] information_schema 존재 확인 후 ALTER (재실행 안전).
-- ★ 관리 화면 sqlMap(PrivacyJobMapping_SQL_Mysql.xml)이 이 4개 컬럼을 SELECT/INSERT 하므로
-- 소스 배포보다 이 DDL 적용이 반드시 선행돼야 한다(미적용 시 1054 Unknown column).
-- =====================================================================
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [privacy.download_history_create_mysql.sql] -- [privacy.download_history_create_mysql.sql]
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
@ -245,7 +303,7 @@ CREATE TABLE PDAL_JOB_MAPPING (
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의). -- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_DOWNLOAD_HISTORY ( CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_HISTORY (
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)', DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID', DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시', DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
@ -280,7 +338,7 @@ CREATE TABLE PDAL_DOWNLOAD_HISTORY (
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력. -- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_INSPECTION_REPORT ( CREATE TABLE IF NOT EXISTS PDAL_INSPECTION_REPORT (
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)', RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)', RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시', PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',
@ -336,7 +394,7 @@ CREATE TABLE PDAL_INSPECTION_REPORT (
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다. -- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
-- ===================================================================== -- =====================================================================
CREATE TABLE PERSONAL_DATA_ACCESS_LOG CREATE TABLE IF NOT EXISTS PERSONAL_DATA_ACCESS_LOG
( (
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호', LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)', CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',
@ -404,7 +462,7 @@ PARTITION BY RANGE COLUMNS(CONECT_DT)
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37). -- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
-- ===================================================================== -- =====================================================================
CREATE TABLE PDAL_PURGE_HISTORY CREATE TABLE IF NOT EXISTS PDAL_PURGE_HISTORY
( (
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호', PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)', PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',
@ -465,16 +523,31 @@ CREATE TABLE IF NOT EXISTS OTP_DEVICE (
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향. -- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다. -- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
-- --
-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행). -- [멱등] MySQL 8 은 ADD COLUMN/INDEX IF NOT EXISTS 미지원 → information_schema 로
-- 존재를 확인해 없을 때만 실행한다(재실행 안전, MySQL 8 / MariaDB 공통).
-- ===================================================================== -- =====================================================================
ALTER TABLE COMTNDIAPAUSEMBER SET @ddl := IF(
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR' (SELECT COUNT(*) FROM information_schema.COLUMNS
COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자'; WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNDIAPAUSEMBER' AND COLUMN_NAME = 'MBER_TY_CODE') > 0,
'SELECT ''SKIP: COMTNDIAPAUSEMBER.MBER_TY_CODE already exists'' AS msg',
'ALTER TABLE COMTNDIAPAUSEMBER
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT ''GNR''
COMMENT ''회원유형 GNR:일반회원 EMP:개인정보취급자'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스 -- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
ALTER TABLE COMTNDIAPAUSEMBER SET @ddl := IF(
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT); (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNDIAPAUSEMBER' AND INDEX_NAME = 'IX_DIAPAUSE_MBERTY') > 0,
'SELECT ''SKIP: IX_DIAPAUSE_MBERTY already exists'' AS msg',
'ALTER TABLE COMTNDIAPAUSEMBER
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
@ -490,13 +563,20 @@ ALTER TABLE COMTNDIAPAUSEMBER
-- 직후에는 아무것도 파기되지 않으며, 관리자가 계정 관리 정책 화면(#13)에서 -- 직후에는 아무것도 파기되지 않으며, 관리자가 계정 관리 정책 화면(#13)에서
-- '사용'으로 바꿔야 시작된다. 파기 기준일은 기존 DELETE_PRESERVE_DAY 를 사용. -- '사용'으로 바꿔야 시작된다. 파기 기준일은 기존 DELETE_PRESERVE_DAY 를 사용.
-- --
-- ※ 신규 설치(위 PART 1 에서 ACCOUNT_MNG_POLICY 를 막 생성한 경우)에는 이미 -- ※ 신규 설치(위 PART 1 에서 ACCOUNT_MNG_POLICY 를 막 생성한 경우)에는 컬럼이
-- 컬럼이 없으므로 이 ALTER 가 필요하다. 기존 환경 재실행 시 중복 오류는 무시. -- 없으므로 추가되고, 기존 환경 재실행 시에는 존재 확인 후 건너뛴다(재실행 안전).
-- ===================================================================== -- =====================================================================
ALTER TABLE ACCOUNT_MNG_POLICY SET @ddl := IF(
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT 'N' (SELECT COUNT(*) FROM information_schema.COLUMNS
COMMENT '장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'; WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'PURGE_USE_YN') > 0,
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.PURGE_USE_YN already exists'' AS msg',
'ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT ''N''
COMMENT ''장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
@ -525,7 +605,7 @@ ALTER TABLE ACCOUNT_MNG_POLICY
-- 처리라 기본 비활성(@Scheduled 주석) 상태다. 보존기간 확정·운영협의 후 활성화. -- 처리라 기본 비활성(@Scheduled 주석) 상태다. 보존기간 확정·운영협의 후 활성화.
-- ===================================================================== -- =====================================================================
CREATE TABLE ACCOUNT_DELETE_HISTORY ( CREATE TABLE IF NOT EXISTS ACCOUNT_DELETE_HISTORY (
DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)', DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)',
MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)', MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)',
MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자', MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자',
@ -557,20 +637,59 @@ CREATE TABLE ACCOUNT_DELETE_HISTORY (
-- 컬럼 추가 후 ERROR 1136(Column count doesn't match)로 실패한다. -- 컬럼 추가 후 ERROR 1136(Column count doesn't match)로 실패한다.
-- 컬럼 명시 INSERT 로 교체된 EgovLoginUsr_SQL_Mysql.xml 과 함께 배포할 것. -- 컬럼 명시 INSERT 로 교체된 EgovLoginUsr_SQL_Mysql.xml 과 함께 배포할 것.
-- --
-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 중복 오류(1060/1061) 무시. -- [재실행] MySQL 8 은 ADD COLUMN/KEY IF NOT EXISTS 미지원 → 항목마다 information_schema
-- 로 존재를 확인해 없을 때만 실행한다(일부만 적용된 환경도 나머지가 채워짐).
-- ===================================================================== -- =====================================================================
ALTER TABLE LOGIN_HISTORY SET @ddl := IF(
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)', (SELECT COUNT(*) FROM information_schema.COLUMNS
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'; WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'CONECT_IP') > 0,
'SELECT ''SKIP: LOGIN_HISTORY.CONECT_IP already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT ''접속 IP(#22-1, ClientIpResolver 취득)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE LOGIN_HISTORY SET @ddl := IF(
ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE), (SELECT COUNT(*) FROM information_schema.COLUMNS
ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE); WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'RESET_YN') > 0,
'SELECT ''SKIP: LOGIN_HISTORY.RESET_YN already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT ''N''
COMMENT ''잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ACCOUNT_MNG_POLICY SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_01') > 0,
'SELECT ''SKIP: IX_LOGIN_HISTORY_01 already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_02') > 0,
'SELECT ''SKIP: IX_LOGIN_HISTORY_02 already exists'' AS msg',
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'LOGIN_HIST_KEEP_DAY') > 0,
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY already exists'' AS msg',
'ALTER TABLE ACCOUNT_MNG_POLICY
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730 ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'; COMMENT ''인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ===================================================================== -- =====================================================================
@ -685,11 +804,65 @@ SELECT 9100039, g.ac
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac); WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac);
-- ── [5] 개인정보 접속기록 조회·다운로드 메뉴(9100044) — SFR-004 #47, 2026-08-06 추가 ──
-- 원본: src/script/mysql/dml/privacy.menu_access_log_insert_mysql.sql
-- ★ left.jsp 9100044 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
-- ※ 9100040~9100043 은 '게시판관리' 그룹이 선점 → 미사용 번호 9100044 사용(MENU_ORDR 10)
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AccessLogList' AS PROGRM_FILE_NM, '/mnt/sec/accessLog/' AS PROGRM_STRE_PATH,
'개인정보 접속기록' AS PROGRM_KOREAN_NM, '개인정보 접속기록 조회·다운로드(SFR-004 #47)' AS PROGRM_DC,
'/mnt/sec/accessLog/listView.do?page=9100044' 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, 'AccessLogList' AS PROGRM_FILE_NM, 9100044 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 10 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);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100044, g.ac
FROM (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 = 9100044 AND x.AUTHOR_CODE = g.ac);
-- ── [6] 업무유형 매핑 관리 메뉴(9100045) — SFR-004 #32-1, 2026-08-07 추가 ──
-- 원본: src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql
-- ★ left.jsp 9100045 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
-- ※ 접속기록(9100044)의 '수행업무' 분류 기준을 편집하는 화면이라 바로 다음(MENU_ORDR 11)에 배치
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH,
'업무유형 매핑 관리' AS PROGRM_KOREAN_NM, '개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC,
'/mnt/sec/jobMapping/listView.do?page=9100045' 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, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 11 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);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100045, g.ac
FROM (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 = 9100045 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] ── -- ── [검증] ──
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL, -- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants -- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM -- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM
-- WHERE m.MENU_NO BETWEEN 9100035 AND 9100039 ORDER BY m.MENU_NO; -- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100045) ORDER BY m.MENU_ORDR;
-- ── [롤백] (역순 권장, 4건 일괄) ── -- ── [롤백] (역순 권장, 4건 일괄) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038; -- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038;

View File

@ -102,7 +102,7 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| **A** | 휴면계정(umt·휴면 sqlmap 단독) | #8 MariaDB 휴면배치 statement 정비 | Oracle에만 있던 배치 statement MariaDB 추가 | ✅ 완료 | | **A** | 휴면계정(umt·휴면 sqlmap 단독) | #8 MariaDB 휴면배치 statement 정비 | Oracle에만 있던 배치 statement MariaDB 추가 | ✅ 완료 |
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #41 접속기록 조회·검색 개선 | 접속자ID 등 신규 검색조건(현행 LIKE+날짜뿐) | ✅ 완료 | | **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #41 접속기록 조회·검색 개선 | 접속자ID 등 신규 검색조건(현행 LIKE+날짜뿐) | ✅ 완료 |
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #43 정기점검 리포트 생성 배치 | 월1회 이상징후·무결성·처리현황·다운로드 집계 | ✅ 완료 | | **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #43 정기점검 리포트 생성 배치 | 월1회 이상징후·무결성·처리현황·다운로드 집계 | ✅ 완료 |
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #23 계정 열거 방지 | 실패 메시지 존재여부 무관 일관화(#22 핸드오프 해소) | ✅ 완료 | | **C** | 접근통제마무리(로그인·servlet.xml 단독) | #23 계정 열거 방지 | 실패 메시지 존재여부 무관 일관화(#22 핸드오프 해소) | ✅ 진행중 |
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #26 인터셉터 3단 순서 확정 | 인증→SecondFactor→Authority 순서 확정·검증 | ✅ 완료 | | **C** | 접근통제마무리(로그인·servlet.xml 단독) | #26 인터셉터 3단 순서 확정 | 인증→SecondFactor→Authority 순서 확정·검증 | ✅ 완료 |
- 2026-07-21 5차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. - 2026-07-21 5차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
@ -135,7 +135,10 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 | | **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 |
| **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 | | **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 |
- 2026-08-05 **#22-1 완료** (인증 실패 이력 IP 기록·보관기간·조회화면): #22 이설로 적재는 살아났으나 IP 미기록 + 성공 시 당일 이력 DELETE 로 흔적이 소멸하던 것을 보완. 신규 6파일(`privacy/lock/LoginFailHistory{VO,DAO,Service,ServiceImpl,Controller}`, `PrivacyLoginFailHistory_SQL_Mysql/Oracle`) + JSP 1 + DDL/메뉴DML 2 + 기존 8파일 수정. **핵심 함정: `insertFailHistory`가 컬럼목록 없는 `VALUES(3값)` 이라 DDL 단독 배포 시 ERROR 1136 으로 로그인 실패 적재가 깨진다 — sqlmap 동반 배포 필수.** 잠금 동작은 `selectFailCount`가 `RESET_YN='N'`만 세도록 해 종전과 동일. 후속: javac 미검증(사용자 요청으로 컴파일 생략), 배포 DB에 ALTER 적용 필요. - 2026-08-05 **#22-1 완료** (인증 실패 이력 IP 기록·보관기간·조회화면): #22 이설로 적재는 살아났으나 IP 미기록 + 성공 시 당일 이력 DELETE 로 흔적이 소멸하던 것을 보완. 신규 6파일(`privacy/lock/LoginFailHistory{VO,DAO,Service,ServiceImpl,Controller}`, `PrivacyLoginFailHistory_SQL_Mysql/Oracle`) + JSP 1 + DDL/메뉴DML 2 + 기존 8파일 수정. **핵심 함정: `insertFailHistory`가 컬럼목록 없는 `VALUES(3값)` 이라 DDL 단독 배포 시 ERROR 1136 으로 로그인 실패 적재가 깨진다 — sqlmap 동반 배포 필수.** 잠금 동작은 `selectFailCount`가 `RESET_YN='N'`만 세도록 해 종전과 동일.
- 2026-08-05 **#22-1 로컬 활성화 완료**: JDK8 `mvn compile` **BUILD SUCCESS**(종전 "javac 미검증" 해소). 로컬 DB에 `privacy.install_mysql.sql` 전체 적용(privacy 7테이블 + `LOGIN_HISTORY.CONECT_IP`/`RESET_YN`+인덱스 2종 + `LOGIN_HIST_KEEP_DAY=730` + 메뉴 9100035~9100039, 한글 `HEX()` 무결성 확인). 파일 델타 19건 반영 후 Tomcat 재기동 → 컨텍스트 정상 기동(= iBatis sqlMap 등록·파싱 성공), `listView.do` 302→로그인(대조군 404로 매핑 실재 확인). 산출물: `_workspace/deploy/20260805_1800/{DELTA.md,APPLY_LOG.md,sql/}`.
- **함정 2건 실측:** ① 로컬 3307 도커(`urpai-mysql`)가 2026-07-30 재생성되며 `csv` DB 소실 → 실제 csv 는 MySQL80 **3306**, 배포본 `globals-live.properties` 포트 변경으로 복구. ② 배포본 `sql-map-config` 의 `BizApplyMileage`(소문자) ↔ 소스 `BizApplyMIleage`(대문자) drift 실재 — 통째 교체 시 전체 404, 신규 1줄만 병합해야 한다.
- **미완:** 개발서버 미반영(SSH 권한 차단). 관리자 계정이 없어 좌측메뉴 노출·목록 렌더·IP 적재·`RESET_YN` 소프트 리셋 실동작 검증은 미수행.
- 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환. - 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
- 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경). - 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경).
- 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프). - 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프).
@ -228,20 +231,20 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| # | 개발항목 | 상태 | 소스 실측 / 비고 | | # | 개발항목 | 상태 | 소스 실측 / 비고 |
|:--:|----------|:----:|------------------| |:--:|----------|:----:|------------------|
| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 진행중 | [ Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 | | 22 | 인증 실패 이력 활성 경로 이설 | ✅ 진짜 완료 | [ Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 |
| 22-1 | 인증 실패 이력 IP 기록·보관기간·조회화면 | ✅ 완료 | AS-IS: `LOGIN_HISTORY`가 ID/CREATE_DATE/GUBUN 3컬럼뿐 → IP 미기록, 성공 시 당일 이력 DELETE 로 흔적 소멸. **DDL** `CONECT_IP`(VARCHAR45)+`RESET_YN`+인덱스 2종, `ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY`(기본 730). **적재** `insertFailHistory` 컬럼 명시 전환(★DDL 필수 동반 — 종전 무컬럼 VALUES 는 ERROR 1136)+`ClientIpResolver` IP 세팅, 성공 시 DELETE→`RESET_YN='Y'` 소프트 리셋(`selectFailCount` 가 'N'만 카운트 → 잠금 동작 불변). **화면** `/mnt/sec/loginFailHistory/listView.do`(계정·구분·IP·기간·리셋여부 검색+페이징), 메뉴 9100039. **배치** `purgeExpiredLoginFailHistoryScheduler` 매일 03:30, 1회 5,000건 상한 | | 22-1 | 인증 실패 이력 IP 기록·보관기간·조회화면 | ✅ 진짜 완료 | AS-IS: `LOGIN_HISTORY`가 ID/CREATE_DATE/GUBUN 3컬럼뿐 → IP 미기록, 성공 시 당일 이력 DELETE 로 흔적 소멸. **DDL** `CONECT_IP`(VARCHAR45)+`RESET_YN`+인덱스 2종, `ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY`(기본 730). **적재** `insertFailHistory` 컬럼 명시 전환(★DDL 필수 동반 — 종전 무컬럼 VALUES 는 ERROR 1136)+`ClientIpResolver` IP 세팅, 성공 시 DELETE→`RESET_YN='Y'` 소프트 리셋(`selectFailCount` 가 'N'만 카운트 → 잠금 동작 불변). **화면** `/mnt/sec/loginFailHistory/listView.do`(계정·구분·IP·기간·리셋여부 검색+페이징), 메뉴 9100039. **배치** `purgeExpiredLoginFailHistoryScheduler` 매일 03:30, 1회 5,000건 상한 |
| 23 | 계정 열거 방지 처리 | ✅ 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 | | 23 | 계정 열거 방지 처리 | ✅ 진짜 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 |
| 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 | | 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 진짜 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 |
| 25 | 세션 타임아웃 조정(60→30분) | ✅ 완료 | [Fork C] `web.xml:218` `<session-timeout>30</session-timeout>`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 | | 25 | 세션 타임아웃 조정(60→30분) | ✅ 진짜 완료 | [Fork C] `web.xml:218` `<session-timeout>30</session-timeout>`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 |
| 26 | 인터셉터 3단 판정 순서 확정 | ✅ 완료 | [Fork C] `egov-com-servlet.xml:66` 리스트 순서 확정 ①authenticInterceptor(비활성)→②secondFactor→③authority + 근거주석. ①재활성 시 위치 유지 핸드오프. xml well-formed | | 26 | 인터셉터 3단 판정 순서 확정 | ✅ 진짜 완료 | [Fork C] `egov-com-servlet.xml:66` 리스트 순서 확정 ①authenticInterceptor(비활성)→②secondFactor→③authority + 근거주석. ①재활성 시 위치 유지 핸드오프. xml well-formed |
#### 6) 인증정보 보호 (SER-003) #### 6) 인증정보 보호 (SER-003)
| # | 개발항목 | 상태 | 소스 실측 / 비고 | | # | 개발항목 | 상태 | 소스 실측 / 비고 |
|:--:|----------|:----:|------------------| |:--:|----------|:----:|------------------|
| 27 | 비밀번호 해시 알고리즘 점검 | ✅ 완료 | [Fork C] 점검노트 `개인정보보호_비밀번호해시_점검노트.md`. 알고리즘 **SHA-256(기준 충족)**, 단 운영경로 **무솔트·무반복** + `BizMberFrtManageController.java:553` 해시값 로그출력 3건 보완 권고 | | 27 | 비밀번호 해시 알고리즘 점검 | ✅ 진짜 완료 | [Fork C] 점검노트 `개인정보보호_비밀번호해시_점검노트.md`. 알고리즘 **SHA-256(기준 충족)**, 단 운영경로 **무솔트·무반복** + `BizMberFrtManageController.java:553` 해시값 로그출력 3건 보완 권고 |
| 28 | 신규 인증정보 저장정책 적용 | ✅ 완료 | [Fork C→오케스트레이터] 점검노트 `개인정보보호_인증정보저장정책_점검노트.md`. OTP AES암호화저장(로그노출0)·SMS 미저장설계·CERT DN만 → **3종 준수, 위반 0건**. OTP 프로비저닝 URI 화면(#53) 취급 주의 1건 | | 28 | 신규 인증정보 저장정책 적용 | ✅ 서버에서 적용해야함 | [Fork C→오케스트레이터] 점검노트 `개인정보보호_인증정보저장정책_점검노트.md`. OTP AES암호화저장(로그노출0)·SMS 미저장설계·CERT DN만 → **3종 준수, 위반 0건**. OTP 프로비저닝 URI 화면(#53) 취급 주의 1건, 지금 열쇠가 소스코드 안에 글자 그대로 적혀 있습니다 arko-otp-dev-2026 이렇게요 열쇠를 소스에서 빼서, 서버에만 두는 것 |
| 29 | 2차 인증 화면 개인정보 마스킹 | ✅ 완료 | [Fork C] `cmm/privacy/util/MaskingUtil.java`. maskPhone(010-****-1234)/maskEmail(ho**@)/maskName(홍*동)/범용 mask. null·형식 방어, javac+self-check 통과. #52 화면에서 사용 | | 29 | 2차 인증 화면 개인정보 마스킹 | ✅ SMS인증 | [Fork C] `cmm/privacy/util/MaskingUtil.java`. maskPhone(010-****-1234)/maskEmail(ho**@)/maskName(홍*동)/범용 mask. null·형식 방어, javac+self-check 통과. #52 화면에서 사용 |
### Ⅲ. 접속 및 처리기록 관리 (Ⅲ-4, SFR-004·DBR-001·SER-002) — arko-privacy-auditlog ### Ⅲ. 접속 및 처리기록 관리 (Ⅲ-4, SFR-004·DBR-001·SER-002) — arko-privacy-auditlog
@ -249,9 +252,10 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| # | 개발항목 | 상태 | 소스 실측 / 비고 | | # | 개발항목 | 상태 | 소스 실측 / 비고 |
|:--:|----------|:----:|------------------| |:--:|----------|:----:|------------------|
| 30 | PERSONAL_DATA_ACCESS_LOG 신규 테이블 | ✅ 완료 | [Fork B] `src/script/mysql/ddl/privacy.pdal_create_mysql.sql`. 기존 PERSONAL_DATA_DML_HISTORY 실측 승격 + HASH_VAL/PREV_HASH_VAL, PK(LOG_ID,CONECT_DT), 월 RANGE COLUMNS 파티셔닝(접속자ID 검색 인덱스 포함). 파티션 자동증설/파기는 #37 연계 | | 30 | PERSONAL_DATA_ACCESS_LOG 신규 테이블 | ✅ 진행중 | [Fork B] `src/script/mysql/ddl/privacy.pdal_create_mysql.sql`. 기존 PERSONAL_DATA_DML_HISTORY 실측 승격 + HASH_VAL/PREV_HASH_VAL, PK(LOG_ID,CONECT_DT), 월 RANGE COLUMNS 파티셔닝(접속자ID 검색 인덱스 포함). 파티션 자동증설/파기는 #37 연계 |
| 31 | AOP pointcut 한정 수집 설계 | ✅ 완료 | [Fork B] `PersonalDataLogAspect`+Service/Impl+DAO(10파일)+`context-syslogaop.xml`. pointcut **개인정보 서비스 impl 5개 한정**(과수집 방지), `ClientIpResolver`(#34)+`HashChainService.linkAndSeal`(#39) 적재, synchronized 순번직렬화. 본문 미적재. 비동기(#33)·TRGET_ID(#36) 후속 | | 31 | ~~AOP pointcut 한정 수집 설계~~ → **관리자 화면 메뉴 접속기록** | ✅ 완료(2026-08-07 방식 전환) | **[전환]** 서비스 impl AOP 수집은 **중단**하고(`context-syslogaop.xml`의 `<aop:config>` 주석 처리), 요청 인터셉터 `egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor`(신규, `egov-com-servlet.xml` 등록)가 수집한다. **전환 사유:** 서비스는 관리자·프론트 화면이 공유해 호출 화면을 구분할 수 없어, 무인증 프론트 방문이 접속자ID `ANONYMOUS`·수행업무 "재능나눔 회원관리"로 적재됐다(공개 재능나눔 상세 `/frt/biz/talent/bizTalentView.do` → `selectTalentMber`). `selectJoinTalentMberList` 개별 제외로는 같은 문제가 반복된다 — 공유 서비스 메서드가 그것 하나가 아니다. **새 수집 규칙:** ①요청경로가 관리자 화면 접두(`/mnt/`·`/uss/umt/`·`/sec/`·`/sym/`) ②프레임·include 제외 ③취급자 세션(`userSe=USR`) 3중 조건. 수행업무=기존 메뉴명(`COMTNMENUINFO.MENU_NM`), 해석순서 `page`파라미터(메뉴번호)→경로정확→동일디렉터리 최장공통접두→경로폴백. 처리구분은 `.do` 파일명 유도(`~View`=조회 우선, `select~`=조회 우선 — 21케이스 검증 0불일치). 적재경로(AccessLogQueue→해시체인→`PERSONAL_DATA_ACCESS_LOG`)·스키마는 불변, **DDL 없음**. 델타: `_workspace/deploy/20260807_1440/DELTA.md`<br>─ 이하 전환 前 이력 ─<br>[Fork B] | [Fork B] `PersonalDataLogAspect`+Service/Impl+DAO(10파일)+`context-syslogaop.xml`. pointcut **개인정보 서비스 impl 5개 한정**(과수집 방지), `ClientIpResolver`(#34)+`HashChainService.linkAndSeal`(#39) 적재, synchronized 순번직렬화. 본문 미적재. 비동기(#33)·TRGET_ID(#36) 후속<br>**2026-08-07 과수집 1건 제외:** `BizTalentMberManageServiceImpl.selectJoinTalentMberList*` — 취급자 업무가 아니라 **무인증 공개 목록**(메인 `LayoutFrtController.main`, frt 재능나눔 목록) 경로라 방문 1회당 `ANONYMOUS` 로그 1건이 730일 보관 테이블에 적재됨(실측 41건). pointcut 에 `and not execution(...)` 추가. **검증:** 메인 3회+공개목록 1회 → 증가 0건 / 개인상세(`selectTalentMber`) → 증가 1건(취급자 경로 수집 유지), 해시체인 정상(누락 0) |
| 32 | 업무유형 매핑 테이블 구현 | ✅ 완료 | [Fork B] `privacy.job_mapping_create_mysql.sql`(`PDAL_JOB_MAPPING`+시드 13행) + `JobMappingResolver`(@Component 캐시)+DAO+sqlmap. (클래스,메서드)→업무코드/명, 신규화면은 행 등록만으로 수집(소스 무수정) | | 32 | 업무유형 매핑 테이블 구현 | ⚠️ 수집경로에서 미사용(2026-08-07) | **[전환 영향]** #31이 메뉴 기반으로 바뀌어 수행업무가 `COMTNMENUINFO.MENU_NM`에서 나온다. `PDAL_JOB_MAPPING`/`JobMappingResolver`는 **수집에 더 이상 관여하지 않는다**(테이블·관리화면 #32-1은 기존 적재분 해석·복원 경로 보존용으로 존치). 접속기록 조회화면의 '수행업무' 드롭다운 소스도 이 테이블 → 적재분 DISTINCT(`PersonalDataAccessLogDAO.selectJobSeCodeList`)로 교체(매핑을 보면 실제 적재값과 어긋나 검색결과가 항상 0건이 된다).<br>─ 이하 전환 前 이력 ─<br>[Fork B] | [Fork B] `privacy.job_mapping_create_mysql.sql`(`PDAL_JOB_MAPPING`+시드 13행) + `JobMappingResolver`(@Component 캐시)+DAO+sqlmap. (클래스,메서드)→업무코드/명, 신규화면은 행 등록만으로 수집(소스 무수정). 관리 화면은 #32-1 로 분리 |
| 32-1 | 업무유형 매핑 **관리 화면** | ✅ 완료 | 2026-08-07 신설. #32 는 테이블+로더까지라 매핑 행을 **DB 직접 INSERT 로만** 운영했음 → 화면에서 등록·수정·사용중지·삭제. `JobMappingController`+`JobMappingManageService`/`Impl`+`JobMappingSearchVO`+DAO CRUD 7개+sqlmap(Mysql/Oracle 쌍, **config 등록 불요 — 기존 파일 확장**)+`site/mnt/sec/jobMapping/{list,regist}.jsp`+메뉴 9100045(권한관리 하위 `MENU_ORDR` 11)+`left.jsp` 5곳.<br>저장 즉시 `JobMappingResolver.refresh()` → **WAS 재기동 불요**. DB 직접수정 반영용 '캐시 재적재' 버튼 별도.<br>**감사 컬럼 신설**(`privacy.job_mapping_audit_cols_mysql.sql`: REGISTER_ID/REG_DT/UPDUSR_ID/UPDT_DT) — 분류 기준 변경 주체를 남긴다. 기존 13행은 등록자 NULL → 화면에 '시드' 표기.<br>보안: 상태변경 전 엔드포인트 **POST 한정** + Origin/Referer 동일출처 + `handlerUser()` USR 판정, 서버측 형식·중복(TARGET_CLASS+METHOD_PTTRN) 재검증, 업무명은 JS 인라인 보간 대신 `data-*`+`c:out`.<br>**검증:** JDK8 `mvn -o compile` 성공 / DDL·메뉴DML 로컬 3306 적용 + 멱등 재실행 확인 + `HEX()` 한글 정상 / CRUD SQL 실DB 실행 왕복 확인(13행 원복). **실화면 렌더는 Tomcat 미기동으로 미검증** |
| 33 | 비동기 적재 큐 구현 | ✅ 완료 | [Fork B] `AccessLogQueue`(LinkedBlockingQueue cap1만+단일 데몬컨슈머). Aspect가 enqueue만(업무 응답 무영향), 컨슈머가 persistNow 순차→**단일 라이터로 해시체인 순번 보장**. 백프레셔(offer 5s), @PreDestroy flush 유실방지 | | 33 | 비동기 적재 큐 구현 | ✅ 완료 | [Fork B] `AccessLogQueue`(LinkedBlockingQueue cap1만+단일 데몬컨슈머). Aspect가 enqueue만(업무 응답 무영향), 컨슈머가 persistNow 순차→**단일 라이터로 해시체인 순번 보장**. 백프레셔(offer 5s), @PreDestroy flush 유실방지 |
| 34 | IP 취득 로직 보정 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/ClientIpResolver.java`. XFF→Proxy/WL-Proxy→HTTP_CLIENT_IP→getRemoteAddr 순 + **신뢰프록시 CIDR 검증(헤더위조 방지)** + IPv6 루프백 정규화. javac+단위검증 통과. AS-IS `IpObtainInterceptor`(getRemoteAddr+인증시만) 격차 확인. 신뢰대역 운영값=#57 후 확정 | | 34 | IP 취득 로직 보정 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/ClientIpResolver.java`. XFF→Proxy/WL-Proxy→HTTP_CLIENT_IP→getRemoteAddr 순 + **신뢰프록시 CIDR 검증(헤더위조 방지)** + IPv6 루프백 정규화. javac+단위검증 통과. AS-IS `IpObtainInterceptor`(getRemoteAddr+인증시만) 격차 확인. 신뢰대역 운영값=#57 후 확정 |
@ -284,9 +288,9 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| # | 개발항목 | 구분 | 상태 | | # | 개발항목 | 구분 | 상태 |
|:--:|----------|:----:|:----:| |:--:|----------|:----:|:----:|
| 45 | 권한부여 신청/승인 관리 화면 | mnt | ⬜ 미착수 | | 45 | 권한부여 신청/승인 관리 화면 | mnt | ✅ 완료 |
| 46 | 권한변경 이력 조회 화면 | mnt | ⬜ 미착수 | | 46 | 권한변경 이력 조회 화면 | mnt | ✅ 완료 |
| 47 | 개인정보 접속기록 조회/다운로드 화면 | mnt | ⬜ 미착수 | | 47 | 개인정보 접속기록 조회/다운로드 화면 | mnt | ✅ 완료 |
| 48 | 접속기록 정기점검 리포트 대시보드 | mnt | ⬜ 미착수 | | 48 | 접속기록 정기점검 리포트 대시보드 | mnt | ⬜ 미착수 |
| 49 | 비정상접근 탐지 알림함 화면 | mnt | ⬜ 미착수 | | 49 | 비정상접근 탐지 알림함 화면 | mnt | ⬜ 미착수 |
| 50 | 인증수단 관리 설정 화면 | mnt | ⬜ 미착수 | | 50 | 인증수단 관리 설정 화면 | mnt | ⬜ 미착수 |
@ -295,6 +299,20 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
| 53 | 마이페이지 인증수단 등록/관리 화면 | frt | ⬜ 미착수 | | 53 | 마이페이지 인증수단 등록/관리 화면 | frt | ⬜ 미착수 |
| 54 | 계정 잠금 안내 화면 | frt | ⬜ 미착수 | | 54 | 계정 잠금 안내 화면 | frt | ⬜ 미착수 |
> **표 갱신 이력**
> - 2026-08-06 정정: #45·#46 은 이미 구현·메뉴등록(9100035/9100036)까지 완료된 상태였는데 이 표만 ⬜ 로 뒤처져 있었다. 실측 반영.
> - 2026-08-06 #47 완료: `PersonalDataAccessLogController`(`/mnt/sec/accessLog/listView.do`·`download.do`) + `site/mnt/sec/accessLog/list.jsp` + 메뉴 9100044(권한관리 하위, `MENU_ORDR` 10) + `left.jsp` 분기 4곳.
> 검색 7조건(접속자ID·업무유형·처리대상·IP·처리구분·처리결과·기간)·페이징·CSV 다운로드(사유 필수 #42 연계, `PDAL_DOWNLOAD_HISTORY` 적재).
> 9100040~9100043 은 게시판관리 그룹이 선점하여 미사용 번호 9100044 사용.
> 로컬 3306 `csv` 메뉴 등록 + `HEX()` 한글 검증 완료 / 배포 ROOT 델타 반영 완료. **실화면 렌더는 Tomcat 미기동으로 미검증.**
> - 2026-08-07 #32-1 신설·완료: 업무유형 매핑 관리 화면(`/mnt/sec/jobMapping/`) + 메뉴 9100045(권한관리 하위, `MENU_ORDR` 11) + `left.jsp` 5곳.
> `PDAL_JOB_MAPPING` 감사 컬럼 4개 추가(`privacy.job_mapping_audit_cols_mysql.sql`). 저장 시 Resolver 캐시 즉시 무효화(재기동 불요).
> 신규 sqlMap 파일 없이 기존 `PrivacyJobMapping_SQL_Mysql.xml` 을 확장했으므로 **sql-map-config 등록 변경 불요**.
> 로컬 3306 DDL·메뉴 DML 적용 + 멱등 재실행 + `HEX()` 한글 검증 완료 / **배포 ROOT 델타는 미반영**(매니페스트 `_workspace/deploy/20260807_1500/DELTA.md`).
> **실화면 렌더는 Tomcat 미기동으로 미검증.**
> - 2026-08-07 대조 확인: `privacy.job_mapping_create_mysql.sql` 미커밋 변경분은 **멱등화 전용**(CREATE TABLE IF NOT EXISTS + 시드 NOT EXISTS 가드)이라
> 스키마·시드 내용이 커밋본과 동일하다 → 로컬 3306 적용본과 불일치 없음. 개발/운영 DB 는 privacy DDL 자체가 미적용 상태.
> UI 목업 브리프(스티치 AI 프롬프트 포함)는 `디자인.md`에 문서화 완료 — **설계 산출물은 준비됨**, 코드 구현은 미착수. > UI 목업 브리프(스티치 AI 프롬프트 포함)는 `디자인.md`에 문서화 완료 — **설계 산출물은 준비됨**, 코드 구현은 미착수.
### Ⅴ. 착수 즉시 확인 필요 사항 — arko-privacy-analyst ### Ⅴ. 착수 즉시 확인 필요 사항 — arko-privacy-analyst