장기 미사용 계정 파기 배치를 관리자(EMP) 계정 한정으로 변경

일반회원(GNR)은 탈퇴 즉시 하드 DELETE되어 휴면 전환 기반 지연 파기 대상이
아니었으므로 배치에서 제외. 관리자(개인정보취급자) 계정은 SFR-002 핵심요건
(개인정보보호위 점검 지적사항 — 휴면 대상이 일반회원에 한정되고 개인정보
취급자가 미포함)이라 로직은 그대로 유지하고 대상만 EMP로 좁혔다.
정책 화면(계정 관리 정책) 안내 문구도 관리자 계정 대상임을 명시.

스키마 변경 없음(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY/PURGE_USE_YN,
ACCOUNT_DELETE_HISTORY 재사용).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
infraurp 2026-08-10 16:40:46 +09:00
parent 94da0fe9ae
commit 4413c5aaea
8 changed files with 48 additions and 125 deletions

View File

@ -9,30 +9,22 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 장기 미사용 계정 개인정보 파기 DAO (SFR-002 / 개발항목 #12).
*
* <p>대상은 개인정보취급자(관리자, EMP) 계정 한정 — 일반회원(GNR)은 탈퇴 즉시 하드 DELETE
* 되어 이 배치의 대상이 아니다(2026-08 정리).</p>
*
* <p>statement 는 {@code PrivacyAccountDelete_SQL_Mysql.xml}(namespace {@code AccountDeleteDAO}),
* sql-map-config 등록. 보존기간은 SQL 내부에서 ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 를
* 직접 조회하므로 자바 측 하드코딩이 없다. 1회 처리건수 상한(200)도 SQL 측에 있다.</p>
* 직접 조회하므로 자바 측 하드코딩이 없다. 1회 처리건수 상한(1,000)도 SQL 측에 있다.</p>
*/
@Repository("accountDeleteDAO")
public class AccountDeleteDAO extends EgovComAbstractDAO {
/** 파기 대상(일반회원) — 휴면확정 후 보존기간 경과분, 최대 200건. */
@SuppressWarnings("unchecked")
public List<AccountDeleteTargetVO> selectExpiredGnrAccounts() {
return list("AccountDeleteDAO.selectExpiredGnrAccounts", null);
}
/** 파기 대상(개인정보취급자) — 휴면확정 후 보존기간 경과분, 최대 200건. */
/** 파기 대상(개인정보취급자) — 휴면확정 후 보존기간 경과분, 최대 1,000건. */
@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);

View File

@ -11,15 +11,19 @@ import org.springframework.stereotype.Service;
/**
* 장기 미사용 계정 개인정보 파기 서비스 구현 (SFR-002 / 개발항목 #12).
*
* <p><b>대상은 개인정보취급자(관리자, EMP) 계정 한정이다.</b> 일반회원(GNR)은 탈퇴 신청 즉시
* {@code EgovMberManageServiceImpl.deleteMber()} 경로에서 하드 DELETE 되어 보존기간을 두고
* 지연 파기할 대상 자체가 없으므로(2026-08 정리), 이 배치의 GNR 처리 경로를 제거했다.</p>
*
* <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>
* 게시글·활동확인·마일리지 등 다수 테이블이 EMPLYR_ID 를 FK 없이 참조하고 있어
* 행을 지우면 해당 이력이 고아가 되기 때문이다.</p>
*
* <p><b>처리량 상한</b>: 배치가 장기간 정지했다 재가동되는 날 백로그가 한꺼번에 처리되지
* 않도록 1회 200건으로 제한(SQL의 LIMIT). 남은 대상은 다음 배치에서 이어 처리한다.</p>
* 않도록 1회 1,000건으로 제한(SQL의 LIMIT). 남은 대상은 다음 배치에서 이어 처리한다.</p>
*
* <p>계정 1건 처리는 마스킹→휴면행 제거→이력적재 순이며, 개별 실패는 로깅 후 계속(fail-safe)
* 하여 한 건의 오류가 배치 전체를 막지 않는다.</p>
@ -29,7 +33,6 @@ 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")
@ -48,31 +51,25 @@ public class AccountDeleteServiceImpl implements AccountDeleteService {
return 0;
}
int purged = 0;
purged += purge(accountDeleteDAO.selectExpiredGnrAccounts(), TY_GNR);
purged += purge(accountDeleteDAO.selectExpiredEmpAccounts(), TY_EMP);
LOG.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — {}건", Integer.valueOf(purged));
int purged = purge(accountDeleteDAO.selectExpiredEmpAccounts());
LOG.info("[개인정보/#12] 장기 미사용 관리자 계정 파기 배치 완료 — {}건", Integer.valueOf(purged));
return purged;
}
/** 유형별 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */
private int purge(List<AccountDeleteTargetVO> targets, String mberTyCode) {
/** 개인정보취급자(EMP) 계정 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */
private int purge(List<AccountDeleteTargetVO> targets) {
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.setMberTyCode(TY_EMP);
hist.setEsntlId(t.getEsntlId());
hist.setDiapauseDt(t.getDiapauseDt());
hist.setPreserveDay(t.getPreserveDay());
@ -80,8 +77,8 @@ public class AccountDeleteServiceImpl implements AccountDeleteService {
cnt++;
} catch (Exception e) {
LOG.error("[개인정보/#12] 계정 파기 실패 — 건너뜀 (id={}, ty={}): {}",
new Object[] { t.getMberId(), mberTyCode, e.getMessage() });
LOG.error("[개인정보/#12] 관리자 계정 파기 실패 — 건너뜀 (id={}): {}",
new Object[] { t.getMberId(), e.getMessage() });
}
}
return cnt;

View File

@ -12,7 +12,7 @@ public class AccountDeleteTargetVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 파기 대상 계정ID(일반회원 MBER_ID / 취급자 EMPLYR_ID) */
/** 파기 대상 계정ID(개인정보취급자 EMPLYR_ID) */
private String mberId;
/** 고유식별자(ESNTL_ID) */
private String esntlId;

View File

@ -30,12 +30,12 @@ public class AccountMngPolicyVO implements Serializable {
private int alertD2 = 7;
/** 사전알림 3차(D-Day, 기본 0). */
private int alertD3 = 0;
/** 휴면/탈퇴 후 삭제 보존기간(일). */
/** 휴면 후 삭제 보존기간(일) — 개인정보취급자(관리자, EMP) 계정 한정. 일반회원은 탈퇴 즉시 삭제되어 대상이 아니다. */
private int deletePreserveDay = 365;
/** 배치 주기(DAILY/WEEKLY/MONTHLY). */
private String batchCycle = "DAILY";
/**
* 장기 미사용 계정 파기 배치 사용여부(#12). Y:사용 N:미사용.
* 장기 미사용 관리자(개인정보취급자) 계정 파기 배치 사용여부(#12). Y:사용 N:미사용.
* 기본 'N' — 관리자가 정책 화면에서 명시적으로 켜야 파기가 수행된다(되돌릴 수 없는 처리).
*/
private String purgeUseYn = "N";

View File

@ -301,22 +301,28 @@ public class BatchScheduler {
}
/**
* [개인정보보호 #12] 장기 미사용 계정 개인정보 파기 배치.
* [개인정보보호 #12] 장기 미사용 관리자(개인정보취급자) 계정 개인정보 파기 배치.
* 매일 02:30, 휴면 확정('P') 후 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 정책값)이
* 경과한 계정의 개인정보를 파기(PII 마스킹 + 상태 'D')하고 ACCOUNT_DELETE_HISTORY 에 이력을 남긴다.
* 휴면 등록 배치(01:00)·사전알림(06:00)과 시각을 분리했다.
* 경과한 관리자 계정의 개인정보를 파기(PII 마스킹 + 상태 'D')하고 ACCOUNT_DELETE_HISTORY 에
* 이력을 남긴다. 휴면 등록 배치(01:00)·사전알림(06:00)과 시각을 분리했다.
*
* <p><b>일반회원(GNR) 계정은 이 배치의 대상이 아니다(2026-08 정리).</b> 탈퇴 신청 즉시
* {@code EgovMberManageServiceImpl.deleteMber()} 경로에서 하드 DELETE 되므로 휴면 전환을
* 거쳐 지연 파기할 대상 자체가 없다. 대상은 관리자(EMP) 계정 한정이며, 이는 SFR-002가
* 지적한 "휴면 대상이 일반회원에 한정되고 개인정보취급자가 미포함"인 취약점을 보완하는
* 핵심 구현 대상이라 유지한다.</p>
*
* <p><b>실제 파기 수행 여부는 정책 플래그로 제어한다.</b> 배치는 매일 등록되어 돌지만
* {@code ACCOUNT_MNG_POLICY.PURGE_USE_YN}(기본 'N')이 'Y'가 아니면 서비스가 즉시 0건으로
* 종료한다. 켜고 끄는 것은 계정 관리 정책 화면(#13)에서 하며 재배포가 필요 없다.
* 1회 처리건수는 SQL에서 200건으로 상한(백로그 일시 폭주 방지).</p>
* 1회 처리건수는 SQL에서 1,000건으로 상한(백로그 일시 폭주 방지).</p>
*
* @throws Exception 파기 오류
*/
@Scheduled(cron = "0 30 2 * * ?")
public void purgeExpiredAccountScheduler() throws Exception {
int purged = accountDeleteService.purgeExpiredAccounts();
log.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — " + purged + "건");
log.info("[개인정보/#12] 장기 미사용 관리자 계정 파기 배치 완료 — " + purged + "건");
}
/**

View File

@ -3,9 +3,12 @@
<!--
장기 미사용 계정 개인정보 파기(#12) — SFR-002 개발항목 #12
대상은 개인정보취급자(관리자, EMP) 계정 한정이다. 일반회원(GNR)은 탈퇴 신청 즉시
EgovMberManage_SQL_Mysql.xml(mberManageDAO.deleteMber_S 등)에서 하드 DELETE 되어
보존기간을 두고 지연 파기할 대상 자체가 없으므로, GNR 대상 statement 를 제거했다(2026-08).
보존기간(DELETE_PRESERVE_DAY)은 ACCOUNT_MNG_POLICY 정책값을 그때그때 조회해 사용(하드코딩 없음).
1회 처리건수는 LIMIT 으로 상한(백로그 최초가동 폭주 방지, #7과 동일 정책, 2026-07-28).
2026-07-30 상한 재산정 200 → 1,000(유형별): 휴면 적재 상한을 2,000/일로 올린 데 맞춰 파기도
2026-07-30 상한 재산정 200 → 1,000: 휴면 적재 상한을 2,000/일로 올린 데 맞춰 파기도
올리되, 파기는 마스킹으로 되돌릴 수 없으므로 의도적으로 유입(2,000/일)보다 낮게 둔다.
이상 징후를 발견하면 PURGE_USE_YN='N' 으로 즉시 정지할 수 있는 관측 여유를 확보하기 위함.
-->
@ -14,22 +17,6 @@
<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[
@ -46,32 +33,6 @@
]]>
</select>
<!--
일반회원 개인정보 파기(마스킹) — 기존 회원탈퇴(MBER_STTUS='D') 컨벤션과 동일 규칙 재사용.
MBER_NM_ENC/MBER_NM_HASH 를 함께 NULL 처리한다(추가제안 Action4, 2026-08-10).
평문 MBER_NM 은 첫 글자+마스킹으로 바뀌는데 암호문 컬럼을 그대로 두면, 파기된 계정도
키를 가진 사람이 복호화하면 마스킹 이전 실명이 그대로 나온다 — 마스킹이 무효화된다.
-->
<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_NM_ENC = NULL
, MBER_NM_HASH = NULL
, 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[

View File

@ -2,26 +2,14 @@
<!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 파일과 동일 관례). -->
대상 운영 DB가 MySQL(csv)이라 sql-map-config 미등록(다른 Privacy* Oracle 파일과 동일 관례).
대상은 개인정보취급자(관리자, EMP) 계정 한정 — 일반회원(GNR)은 탈퇴 즉시 하드 DELETE 되어
이 배치의 대상이 아니므로 GNR statement 를 제거했다(2026-08). -->
<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
@ -36,29 +24,6 @@
]]>
</select>
<!--
MBER_NM_ENC/MBER_NM_HASH 를 함께 NULL 처리한다(추가제안 Action4, 2026-08-10).
암호문을 남겨두면 파기 후에도 키를 가진 사람이 복호화해 마스킹 이전 실명을 복원할 수 있다.
-->
<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_NM_ENC = NULL
, MBER_NM_HASH = NULL
, 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

View File

@ -46,9 +46,10 @@ function fn_save() {
}
// 파기는 복구 불가 — '사용'으로 켤 때만 별도 확인
if (frm.purgeUseYn.value === "Y") {
if (!confirm("계정 파기를 '사용'으로 설정합니다.\n\n"
+ "휴면 전환 후 " + preserve + "일이 지난 계정의 개인정보(성명·연락처·주소 등)가\n"
+ "매일 02:30 배치로 파기되며, 파기된 정보는 복구할 수 없습니다.\n\n계속하시겠습니까?")) {
if (!confirm("관리자 계정 파기를 '사용'으로 설정합니다.\n\n"
+ "휴면 전환 후 " + preserve + "일이 지난 개인정보취급자(관리자) 계정의 개인정보(성명·연락처·주소 등)가\n"
+ "매일 02:30 배치로 파기되며, 파기된 정보는 복구할 수 없습니다.\n"
+ "(일반회원 계정은 탈퇴 시 즉시 삭제되므로 이 설정과 무관합니다.)\n\n계속하시겠습니까?")) {
return;
}
}
@ -147,7 +148,8 @@ function fn_reload() {
<th><label for="deletePreserveDay">파기 기준일(삭제 보존기간)</label> *</th>
<td>
<form:input path="deletePreserveDay" title="파기 기준일(삭제 보존기간)" class="tbox" maxlength="4" cssStyle="width:80px; text-align:right;" />
<span class="unit">일 (휴면 전환 후 이 기간이 지나면 개인정보 파기 대상)</span>
<span class="unit">일 — <strong>개인정보취급자(관리자) 계정</strong> 대상. 휴면 전환 후 이 기간이 지나면 개인정보 파기 대상
(일반회원은 탈퇴 시 즉시 삭제되므로 이 설정과 무관)</span>
</td>
</tr>
<tr>
@ -157,7 +159,7 @@ function fn_reload() {
<option value="N" <c:if test="${policy.purgeUseYn ne 'Y'}">selected="selected"</c:if>>미사용(파기 안 함)</option>
<option value="Y" <c:if test="${policy.purgeUseYn eq 'Y'}">selected="selected"</c:if>>사용(파기 수행)</option>
</select>
<span class="unit">'사용' 선택 시 매일 02:30 배치가 위 기준일 경과 휴면계정의 개인정보를 파기합니다(1회 최대 200건).
<span class="unit"><strong>개인정보취급자(관리자) 계정</strong> 대상. '사용' 선택 시 매일 02:30 배치가 위 기준일 경과 휴면 관리자계정의 개인정보를 파기합니다(1회 최대 1,000건).
<strong>파기된 개인정보는 복구할 수 없습니다.</strong></span>
</td>
</tr>