Compare commits

...

25 Commits

Author SHA1 Message Date
infraurp
6b57520afd [보안/SER-004] SMS DB 자격증명 하드코딩 제거 + 보안 응답 헤더
Some checks failed
deploy-wise / deploy (push) Successful in 1m6s
deploy-dev / deploy (push) Has been cancelled
1) 계정정보 하드코딩 (CWE-798)
   - SmsBasicDBUtil 의 JDBC URL/USER/PASSWORD 소스 하드코딩 제거 →
     EgovProperties(Globals.sms.jdbc.url/username/password)로 외부화.
   ※ globals-*.properties 는 .gitignore 대상(자격증명 미추적)이므로, 배포 환경마다
     아래 키를 별도 설정해야 한다:
       Globals.sms.jdbc.url / .username / .password

2) 보안 응답 헤더 (CWE-693 등)
   - web.xml 에 Tomcat HttpHeaderSecurityFilter 추가.
     X-Frame-Options: SAMEORIGIN(관리자 iframe 사용 → DENY 아님),
     X-Content-Type-Options: nosniff. HSTS 는 HTTP 환경 오작동 방지로 off(운영 HTTPS 시 on).
   - 쿠키 HttpOnly 는 Tomcat 기본 적용 확인(추가 조치 불요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:41 +09:00
infraurp
e82e0611c0 [보안/SER-004] 임시비밀번호 생성 난수를 SecureRandom 으로 교체
CWE-330(취약한 난수). BizEntrprsFrtManageController.randomValue 가 임시비밀번호를
Math.random()/java.util.Random 으로 생성 → 시드 예측 가능. 암호학적 안전 난수
(SecureRandom)로 교체하고 문자 분포는 정수 범위(nextInt)로 균등 생성.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:41 +09:00
infraurp
d75c0da00e [보안/SER-004] CSRF 토큰 방어 신규 구현
Spring Security 2.0.4 에 CSRF 기능이 없어 세션 동기화 토큰(Synchronizer Token)을
자체 구현. 웹취약점 점검(ZAP)의 "Absence of Anti-CSRF Tokens" 및 정적 분석에서
관리자 상태변경 요청 다수가 무방비였던 것을 조치.

- CsrfTokenInterceptor(신규): /mnt/ 상태변경 요청(POST/PUT/DELETE)에서 토큰 검증.
  이중 방어 — 토큰 없거나 불일치해도 '같은 출처'면 통과(정상 화면 회귀 방지),
  '교차 출처'만 차단(CSRF 공격 차단). 토큰은 SecureRandom 48자 hex.
- egov-com-servlet.xml: 인터셉터 리스트 등록 + bean 정의(enforce=true, /mnt/ 한정).
- mnt/include/head.jsp: 토큰 meta + 전역 스크립트로 모든 폼(hidden)·AJAX(X-CSRF-TOKEN
  헤더)에 자동 주입. 167개 관리자 화면 공통 적용, 개별 화면 수정 없음.

검증: cross-origin+토큰없음 → 403 차단, same-origin(토큰유무 무관) → 통과(회귀 없음).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:22 +09:00
infraurp
a085956a8b [보안/SER-004] 인증 없는 휴면계정 해제 차단 + 메일토큰 난수 강화
웹취약점 점검(SER-004)에서 확인된 휴면계정 관련 취약점 2건 조치.

1) 인증 없는 휴면계정 해제 (CWE-306, 긴급)
   - actionDiapause.do 가 요청 파라미터 mber_id 만으로 즉시 deleteDiapause 실행 →
     아이디만 알면 제3자가 임의 계정의 휴면을 해제할 수 있었음(로컬 PoC 실증).
   - 파라미터를 신뢰하지 않고, ID/PW 인증 성공 시에만 세션에 적재되는 diapauseVO 의
     mber_id 로만 해제. 세션 부재 시 resCode=002 거부, 성공 시 1회성 소거.
   - 정상 경로 releaseDiapauseAjax(토큰 검증)와 동일 원칙. 정상 사용자는 원래도
     세션값(${sessionScope.diapauseVO.mber_id})을 보냈으므로 결과 동일(회귀 없음).
   - mbl/EgovDiapause.jsp: resCode 분기로 거부 시 재로그인 안내.

2) 예측 가능한 난수 (CWE-330, 중)
   - 휴면 활성화 메일 토큰(20자) 생성을 java.util.Random → SecureRandom 으로 교체.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbEjijyQdS54xso1d2ks3u
2026-08-31 10:31:08 +09:00
infraurp
722fd978ce 관리자 알림 테스트 발송 버튼 + 발주기관 확인 항목 정리
· 테스트 발송 버튼 — [계정 관리 정책] 화면의 관리자 수신 이메일 옆
  실제 알림은 월 1회 배치에서만 나가서, 수신 주소를 바꾼 관리자가 그것이
  맞는지 한 달을 기다려야 확인할 수 있었다. 같은 발송 경로(수신주소 조회 →
  메일 모듈 적재·발송)를 그대로 타는 테스트를 눌러 즉시 결과를 본다.
  경로가 다르면 테스트가 통과해도 실제 알림은 실패할 수 있으므로 반드시
  같은 경로여야 한다.

  - 성공·실패 모두 서버가 준 사유를 그대로 화면에 띄운다.
    "실패했습니다"만 뜨면 주소 미설정인지 메일서버 문제인지 구분할 수 없다.
  - 메일 모듈이 예외 없이 false 를 반환하는 경우도 실패로 잡는다
    (insertSndngMail 은 적재 후 곧바로 전송하고 실패 시 결과코드를 'F' 로 갱신한다.
     예외가 없다고 발송된 것이 아니다).
  - 상태를 바꾸지는 않지만 외부로 메일이 나가므로 POST·동일출처만 받는다.

· 발주기관 확인 항목.txt 신규
  제안요청서·제안서만으로 결정할 수 없어 발주기관 확인이 필요한 항목을
  근거 문구·현재 구현 상태·미확정 시 영향과 함께 정리했다. A(개발 완료 조건) /
  B(운영정보 필요) / C(문서에 명시 없음) / D(사업관리)로 나눴고, 확인 없이
  진행한 값은 근거를 명시해 [참고] 절에 따로 적었다.

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, 로컬 반영·재기동 정상
(빈 생성 오류 0건), 정책화면 302→로그인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:18:23 +09:00
infraurp
bb721fe4fa 관리자 알림 — 탐지해 놓고 알리지 않던 것을 메일로 통보 (제안서 slide15)
제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 "관리자 알림".
종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면
아무도 모르는 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.

· PrivacyAdminAlertService + Impl 신설
  - 무결성 위반 탐지 시(해시체인 검증 배치)
  - 이상행위 탐지 시(정기점검 배치, 0건인 달은 보내지 않음)
    매달 "0건" 메일이 오면 곧 아무도 열지 않는다.

· 채널은 메일 — 다만 문서 근거는 없다
  RFP·제안서 어디에도 알림 채널이 명시돼 있지 않다("관리자 알림" 네 글자뿐).
  ACCOUNT_MNG_POLICY.ADMIN_EMAIL 이 이미 있고 휴면 사전알림이 그 주소로
  메일을 보내고 있어 같은 경로를 재사용했다. 운영에서 메일 장애를 한 곳에서
  확인할 수 있어야 한다는 이유도 있다. SMS 는 문자발송모듈 연동규격이
  미확정이라(SFR-003 과 같은 블로커) 붙일 수 없다.
  → 채널 확정은 발주기관 협의 항목으로 남긴다.

· 메일에 개인정보 본문을 담지 않는다
  건수·기간·취급자 계정ID·요약만 넣고 상세는 화면에서 보게 한다.
  메일함이 개인정보 저장소가 되면 안 된다.

· 배치를 막지 않는다
  발송 실패·수신주소 미설정 모두 로그만 남기고 배치는 계속한다. 다만 미설정은
  WARN 으로 구분해 남긴다 — "알림이 안 왔다"와 "보낼 주소가 없다"는 다른 문제다.

검증: mvn compile EXIT=0, 로컬 반영 후 재기동 정상(빈 생성 오류 0건 —
privacyAdminAlertService/sndngMailRegistService/accountMngDAO 배선 확인),
frt/main.do 200. 실제 발송은 월 1회 배치라 이번에 실행하지 못했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:08:51 +09:00
infraurp
4c7e677dec 접속기록 보관기간을 관리자 설정으로 (SFR-004 / 제안서 slide15)
제안요약서 slide15 현행진단이 "보관 기간 — 2년 이상, 기본값 730일,
관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이다. 파기 배치가
자바 상수 ACCESS_LOG_RETENTION_DAYS=730 을 써서 관리자가 바꿀 수 없었다.

· ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY 신설(기본 730)
  이 표에 이미 LOGIN_HIST_KEEP_DAY 가 있어 사실상 보관·파기 정책의 단일
  창구다. 표를 새로 만들면 관리자가 보관기간을 두 화면에서 나눠 보게 된다.

· 법정 하한 730일을 세 겹으로 강제
  화면 JS / 서버 validatePolicy / 파기 배치. 화면만 막으면 요청을 직접
  만드는 경로가, 저장만 막으면 정책 행을 직접 고치는 경로가 남는다.
  배치는 정책값이 730 미만이어도 730일치를 남긴다 — 한 번 지운 접속기록은
  되돌릴 수 없어 설정 실수로 법정 보관기간을 깨는 쪽보다 더 남기는 쪽으로 기운다.
  DB CHECK 를 쓰지 않은 이유는 MariaDB/MySQL 5.7 에서 동작이 갈려 조용히
  통과할 수 있어서다.

· 접속기록 조회 화면의 "보관기간 N일" 안내도 정책값을 읽는다
  파기 배치가 실제로 쓰는 값과 달라지면 안내가 거짓말이 된다.
  정책 조회 실패 시 양쪽 모두 기본값으로 물러난다(안내 때문에 목록이 막히면 안 된다).

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, DDL 적용 후
ACCESS_LOG_KEEP_DAY=730 확인, 재기동 정상, 정책화면·접속기록 302→로그인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:57:14 +09:00
infraurp
660d826a58 회원정보 암호화 관리 목록이 한 행 때문에 통째로 죽지 않게
목록은 페이지의 모든 행을 복호화하는데, 공용 MemberNmDecryptSupport 는
실패를 숨기지 않도록 예외를 던진다. 그 계약을 목록에 그대로 쓰면 암호문
한 건만 어긋나도 화면 전체가 500 이 된다 — 관리자 화면이 안 열리면 정작
무엇이 잘못됐는지 확인할 방법도 사라진다.

· 목록(MberCryptoAdminDAO.selectMberList)만 행 단위로 감싼다.
  실패한 행은 이름 자리에 "(복호화 실패)" 표식과 decryptFailed=true 를 남기고,
  나머지 행은 정상 표시한다. 실패를 감추는 게 아니라 어느 행인지 드러낸다.
· 단건 조회(selectMber)는 종전대로 예외를 올린다 — 상세는 값이 정확해야 한다.
  다만 컨트롤러가 받아 스택트레이스 대신 원인을 문장으로 안내하고 목록으로 되돌린다.
· 실패 표식은 마스킹 대상에서 제외한다(마스킹하면 표식이 뭉개진다).
· 화면에 실패 행을 붉게 표시하고 안내 문구를 추가했다.

이 처리는 로컬 더미 DB(백필 키 분실)에서 화면을 열기 위한 임시방편이 아니라,
운영에서 키를 교체하거나 암호문 한 건이 손상돼도 관리자 화면이 살아 있어야
한다는 요구에서 나온 것이다.

검증: mvn compile EXIT=0, JSP Jasper 오류 0건, 로컬 반영·재기동 후
mberCrypto/listView.do 302→로그인, frt/main.do 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:39:27 +09:00
infraurp
33bd57cae2 개인정보보호 기능개선 10차 — 접속·처리기록을 RFP대로 화면 1개로 정리
제안요청서 과업내용 "다"가 요구하는 화면은 하나다 —
"ㅇ 로그 조회 및 관리 기능 구현 / - 관리자용 로그 조회 화면 개발".
제안요약서 시안도 "접속기록 조회 및 감사" 한 장만 그려져 있다.
8·9차에서 이 영역을 여러 화면으로 벌려 놓은 것을 되돌린다.

· 삭제 — 정기 점검 리포트 화면 / 이상행위 감사 화면 / 개인정보 처리기록 화면
  AbnormalAccessController, abnormal/list.jsp, inspection/list.jsp 제거.
  InspectionResultController 는 화면 엔드포인트(listView/saveResultView)만 걷어내고
  8차 이전의 JSON 계약으로 되돌렸다.
  PersonalDataAccessLogSearchVO.excludeSelect 와 그 sqlmap 조건(Mysql/Oracle 각 3곳)도
  함께 제거 — processLog 진입점이 사라져 죽은 조건이 된다.

· 기능은 남긴다 (RFP 가 '기능'으로 명시한 항목이라 지우면 요건 미충족)
  · "처리기록 검색·조회 기능 제공"       → 처리구분 필터(조회/등록/수정/삭제 토글)
  · "비정상 접근 탐지 및 기록 기능 구현"  → 목록 '감사' 칸(이상/점검) 상시 표시
  · SFR-004 "접속기록 정기점검을 지원"   → 월 1회 배치 + PDAL_INSPECTION_REPORT 적재
  · 파기이력(SER-002-2)                    → 접속기록 화면의 "파기이력" 버튼
  AbnormalAccessDetectionService 6클래스는 감사 칸이 쓰므로 그대로 둔다.

· 메뉴 — 권한관리 하위 "접속기록 조회 및 감사"(9100044, ORDR 10) 하나로 수렴
  9100048/49/50/52/53 제거. install_mysql.sql PART 3 은 [7]+[8] 을 최종형 [7] 하나로
  대체했다(이미 중간형을 적용한 DB 를 위한 정리구문 포함, 신규 설치는 무영향).

검증: mvn compile EXIT=0, JSP 3종 Jasper 오류 0건, 로컬 반영·재기동 정상.
살아있는 화면 accessLog/purgeHistory/mberCrypto 302→로그인,
삭제한 inspection/abnormal/processLog **404**(제거 증명), frt/main.do 200,
통합본 전체 재실행 오류 0건(멱등).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:23:21 +09:00
infraurp
3d359a1a8f 개인정보보호 기능개선 9차 — 관리자 화면 배치를 제안서 Ⅲ-4 시안대로 재편
제안요약서 slide 14~15 의 화면 시안을 열어보니 8차 구현과 어긋나 있었다.
시안은 좌측메뉴를 "접속기록·감사" 한 묶음으로 그리고 그 아래 4개를 두며,
이상행위를 별도 화면이 아니라 접속기록 목록의 "감사" 칸에 인라인으로 표시한다.
8차는 이 넷을 권한관리 하위에 낱개로 붙여 놓았다 — 기능은 같은데 배치가 달랐다.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:03:34 +09:00
infraurp
5c7d064fbd @
All checks were successful
deploy-wise / deploy (push) Successful in 1m6s
개인정보보호 기능개선 8차 — 관리자 화면 4종 신설

제안서·RFP 대비 화면이 없어 증빙을 낼 수 없던 항목을 채운다.

· 정기점검 리포트(SFR-004-7 #48)
  InspectionResultController 가 JSON 엔드포인트만 갖고 있어 점검결과를
  화면으로 입력·보관할 수 없었다. listView/saveResultView 를 추가하고
  기존 JSON 계약은 그대로 병존시킨다. 배치 집계값은 화면에서 수정하지
  않는다 — 감사 증빙이라 사후 변경되면 안 된다.

· 비정상접근 탐지(과업내용 다-① #49)
  탐지 5유형 로직은 있었으나 호출처가 배치뿐이었다. AbnormalAccessResultVO
  에 의심 대상 행(rows)을 실어 "누가 걸렸는지"를 요약 문자열 파싱 없이
  보여주고, 각 항목에서 접속기록 화면으로 조건을 넘겨 원본을 확인한다.
  탐지 결과를 적재하지 않는 이유는 컨트롤러 Javadoc 참조(임계치 변경 시
  과거 기준이 굳는 문제).

· 접속기록 파기이력(SER-002-2 / DBR-001-2)
  PDAL_PURGE_HISTORY 는 쌓이고 있었으나 볼 수 있는 곳이 없었다.
  "보관기간 경과분을 규정대로 파기했고 그 외 경로로는 손대지 않았다"를
  함께 입증하는 증빙이라 조회 전용으로만 연다.

· 회원정보 암호화 관리 CRUD(추가제안 Action4 / 제안서 슬라이드16)
  복호화 조회·수정·파기·신규등록 + 사유 입력 + 재인증(10분 TTL).
  목록은 항상 마스킹하고 평문은 사유를 남긴 상세 한 건에서만 보인다 —
  목록에서 평문이 뜨면 사유를 받는 의미가 없다.
  파기는 행을 지우지 않고 식별 항목만 비운다(신청·활동 기록이 참조 중).
  MBER_CRYPTO_ACCESS_HISTORY 에 처리자·일시·IP·사유·변경항목명을 남기되
  개인정보 본문은 담지 않는다.
  재인증에 로그인 2차인증을 재사용하지 않은 이유는 MberCryptoReauth
  Javadoc 참조(세션 내내 유지돼 재확인이 안 되고, OTP 미등록자가 잠긴다).

검증: JDK8 mvn compile EXIT=0, JSP 9종 Jasper 사전컴파일 오류 0건,
로컬 ROOT 반영 후 Tomcat 재기동 정상(신규 sqlMap 4건 기동시 파싱 통과),
신규 엔드포인트 6개 302→로그인 / 대조군 404 / frt/main.do 200.

DB: MBER_CRYPTO_ACCESS_HISTORY DDL + 메뉴 9100048~9100051
    (privacy.install_mysql.sql PART1·PART3 에도 병합, 재실행 안전)

후속 발견: 기존 탈퇴 구문 mberManageDAO.updateDeleteMber_S 는 개인정보를
비우면서도 이름(MBER_NM/ENC/HASH)은 지우지 않는다 — 파기 누락 소지.
신설 화면의 파기는 이름까지 파기한다. 기존 경로 보정은 별도 판단 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
2026-08-27 14:34:05 +09:00
infraurp
db1999175a DB 커넥션 풀에 유효성 검사 추가 (validationQuery)
All checks were successful
deploy-wise / deploy (push) Successful in 1m6s
DBCP 는 validationQuery 가 없으면 testWhileIdle 을 켜도 아무 검사도 하지 않는다.
종전에는 validationQuery 가 주석 처리돼 있고 검사 주기가 10000000ms(약 2.8시간)
이라 사실상 무검사였다. MySQL·MariaDB 의 wait_timeout 은 기본 8시간이므로 야간처럼
오래 논 뒤 첫 요청이 서버가 이미 끊은 커넥션을 받아 실패한다.

실제 발생: 2026-08-26 AccessLogDownloadJobWorker
  unexpected end of stream, read 0 bytes (socket was closed by server)

- validationQuery = SELECT 1 (종전 주석의 'select 1 from dual' 은 Oracle 표기)
- validationQueryTimeout 3초, testOnBorrow 켬
- 유휴 검사 주기 2.8시간 → 5분, 유휴 정리 기준 30분

commons-dbcp 1.3 의 BasicDataSource 에 네 setter 가 모두 존재함을 jar 로 확인했다
(없는 프로퍼티를 넣으면 컴파일은 통과하고 WAS 기동 때만 터진다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:29:53 +09:00
infraurp
7f3c86c588 파이프라인: find | head -1 의 SIGPIPE 로 배포가 멈추던 것 수정
All checks were successful
deploy-wise / deploy (push) Successful in 1m5s
act 는 run: 스텝을 bash -e -o pipefail 로 실행한다. `find ... | head -1` 은
head 가 첫 줄을 받고 파이프를 닫는 순간 find 가 SIGPIPE(141)로 죽어 파이프라인
전체가 실패로 판정된다. 빌드가 BUILD SUCCESS 로 끝나고 192MB WAR 까지 만든 뒤
"산출물 검증" 스텝이 출력 한 줄 없이 죽어 배포가 멈췄다.

실측: bash -e -o pipefail 에서 `find | head -1` → 141, `-print -quit` → 0.

- 산출물 검증: find 를 -print -quit 로 바꿔 파이프 자체를 없앤다
- 사전 점검: `free -h | head -2` 도 같은 경합을 안고 있어 파이프를 제거

리포 주석이 경고하던 바로 그 함정이다(deploy/dev 배포 스크립트의
unzip -l | grep -q 사고와 동일 원인).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:23:14 +09:00
infraurp
8257820b57 개인정보보호 기능개선 7차 — 업무유형 매핑·로그인 접속기록 화면 폐기, 이름복호화 백필 컨트롤러 추가
Some checks failed
deploy-wise / deploy (push) Failing after 21s
관리자 메뉴 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록) 두 화면을
폐기하고 관련 5계층·sqlMap·JSP를 제거했다. 접속기록은 PERSONAL_DATA_ACCESS_LOG
경로로 일원화한다.

- JobMapping 5계층(Controller/DAO/Service/Impl/Resolver/SearchVO)과
  ConectLog 5계층, PersonalDataLogAspect 제거
- PrivacyJobMapping_SQL_{Mysql,Oracle}.xml 제거 + sql-map-config 등록 라인 삭제
- left.jsp의 9100045/9100046 분기와 상위 하이라이트 조건 제거
- 메뉴 폐기 DML 신설(권한매핑→메뉴→프로그램 순, 멱등) — 종전 등록 DML 2건 대체.
  JSP와 반드시 동반 배포해야 URL 직접 접근 경로가 남지 않는다
- privacy.install_mysql.sql에 PART 4(뷰 GNR 컷오버) 반영, 메뉴 번호 갱신
- MemberNmCryptoController 신설 — 회원 이름 암호화 백필 트리거
- SessionPolicyInterceptor에 stampLogin/clearLoginStamp 추가
  (EgovLoginController가 참조 — 이게 없으면 컴파일되지 않는다)
- pom: mysql-connector를 5.1.17로 고정(8.0.20 비활성화). 배포 환경의
  WEB-INF/lib 실측값과 일치시켜 WAR 배포 시 드라이버가 바뀌는 지뢰를 제거

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:19:41 +09:00
infraurp
28a0329114 MariaDB sql_mode에서 ONLY_FULL_GROUP_BY 제거
원본 MySQL 8과 동작을 맞추려고 이 모드를 켰던 것이 역효과였다. MySQL 8은
GROUP BY 컬럼에 대한 함수 종속성을 판정해 집계에 없는 컬럼도 통과시키지만
MariaDB는 그 판정을 하지 않는다. 같은 sql_mode를 그대로 옮기면 로컬에서
잘 돌던 쿼리가 서버에서만 터진다.

증상: 관리자 2차인증 로그인 직후 /mnt/main.do 가 404. 실제로는 대시보드
쿼리가 'Z.STATUS1' isn't in GROUP BY 로 실패하고 예외 처리가 404로 이어진 것.

ONLY_FULL_GROUP_BY 제외는 MariaDB의 기본값이기도 하다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:11:19 +09:00
infraurp
6c9d88aa2e csv.wise.ai.kr vhost에 압축·정적 캐시 추가
메인 화면 하나가 woff 폰트만 3MB 넘게 끌어오는데 압축도 캐시 헤더도 없었다.
유선망에서는 티가 안 나지만(같은 폰트 0.15초) 테더링에서는 25초가 걸려
브라우저가 로딩을 끝내지 못하고 "접속 안 됨"처럼 보인다.

- mod_deflate: HTML 52,665B → 9,516B (82% 감소)
- 폰트 1년 immutable, 이미지 7일, css/js 1시간 캐시 — 재방문 시 3MB 재다운로드 제거

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:42:47 +09:00
infraurp
ddacd1b7f5 csv.wise.ai.kr 배포 파이프라인 신설 (PC → git → web → was)
Some checks failed
deploy-wise / deploy (push) Failing after 11s
웹서버(175.118.124.176)의 Gitea 러너가 JDK8 로 WAR 을 빌드해 WAS(27.255.76.2)의
Tomcat·MariaDB 컨테이너로 배포한다. WAS 는 웹서버를 통해서만 닿고, Tomcat 은
127.0.0.1 바인딩이라 웹서버가 SSH 터널로 잇는다.

- WAS 는 가용 메모리가 3G 안팎(llama-server 200G+ 점유)이라 빌드를 웹서버에서 한다
- claudeops 가 sudo 를 쓸 수 없어 Tomcat·MariaDB 를 컨테이너로 올렸다
- 배포 스크립트를 매 실행마다 저장소본으로 덮어써 서버 사본 드리프트를 막는다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:16:10 +09:00
infraurp
7a8ec86642 개인정보보호 기능개선 6차 — DtaUseStatsVO userNmSe 누락으로 인한 기동실패 수정
545b49d(4차 델타)에서 EgovDtaUseStats_SQL_Mysql.xml resultMap에
userNmSe 컬럼 매핑만 추가하고 대응 Java 필드/DAO 조건부복호화 배선을
누락했다. iBatis2는 이 프로퍼티 부재를 컴파일타임이 아닌 Spring
컨텍스트 기동시 sql-map-config 전체 파싱 단계에서 검증하므로,
컴파일은 성공했지만 실제 WAS 재기동 시 ProbeException으로 컨텍스트
전체가 기동 실패해 로컬 ROOT가 다운되는 사고로 이어졌다.

- DtaUseStats.java: userNmSe 필드 + getter/setter 추가
- DtaUseStatsDAO.selectDtaUseStats(): GNR일 때만 조건부 복호화 배선

재기동 후 컨텍스트 정상 기동 확인(오류 0건), /frt/main.do 200,
/mnt/sec/accountPolicy/policyView.do 302로 검증.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:52:44 +09:00
infraurp
1be7d84e88 개인정보보호 기능개선 5차 — comvnusermaster/comvnuserinfo 뷰 GNR 컷오버 DDL 실행분 커밋
회원정보 암호화(Action4)의 마지막 단계. 545b49d로 반영한 소비자 정비(66개 표시 +
전체 검색전환)를 전제로, 두 공유 뷰의 GNR 분기를 MBER_NM(평문)에서 MBER_NM_ENC(암호문)로
전환하고 검색용 USER_NM_HASH 컬럼을 추가하는 DDL을 127.0.0.1:3306 dev DB에 실제 실행했다.

- information_schema.views.VIEW_DEFINITION을 서버사이드 REPLACE()로 최소 치환(PREPARE/
  EXECUTE) — comvnuserinfo의 한글 리터럴(USER_GUBUN_NM)을 클라이언트에서 재입력하지
  않아 터미널 인코딩 문제를 원천 회피
- 실행 전 원본 정의를 privacy_view_def_backup 테이블에 백업, 치환 건수/컬럼 존재
  여부를 assertion으로 강제해 잘못된 정의 반영을 차단
- 행 수(USER_SE/USER_GUBUN별 COUNT) 실행 전후 동일 확인, GNR 표본 암호문+해시 노출
  확인, USR/ENT 표본 평문 그대로 유지 확인 — 상세 로그는 _workspace/deploy/20260811_0850/
  DB_APPLY_LOG.md(gitignored, 로컬 전용)

주의: 이번엔 코드(545b49d) 커밋 후 로컬 WAS 재기동 전에 DB DDL을 먼저 실행했다 —
다음 반영 시 코드부터 배포하고 재기동할 것(LAST_DEPLOYED.md 참조).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 09:14:38 +09:00
infraurp
545b49daf7 개인정보보호 기능개선 4차 — 회원정보 암호화 읽기전환/검색전환 완료(뷰 재정의 대비)
일반회원 이름(COMTNGNRLMBER.MBER_NM) 암호화 저장(추가제안 Action4)의 마지막 단계로,
comvnusermaster/comvnuserinfo 뷰를 거쳐 이름을 표시·검색하는 전체 소비자(66개 표시
구문 + 신규 발견된 검색(LIKE/exact-match) 구문)를 뷰 재정의 이전에 전수 정비했다.

- 표시: 판별 컬럼(USER_SE 등) 추가 후 GNR일 때만 조건부 복호화
- 검색: LIKE/exact-match를 (HASH = #hash# OR 원본 조건) 패턴으로 전환
- 과정에서 발견한 기존 버그(EgovBBSLoneMaster/EgovBoard의 columnIndex 위치
  불일치, BizPrvol/EgovLoginUsr/BizPrapp의 누락된 검색 전환 지점)를 최소 범위로
  수정하거나(내 필드에 한해) 영향받지 않도록 우회 처리

뷰 자체(ALTER VIEW)는 아직 미실행 — 모든 소비자 정비가 끝나야 실행 가능한
all-or-nothing 게이트가 있어 별도로 진행한다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 08:50:04 +09:00
infraurp
e35aa9db06 로그인 실패 계정잠금 예외(SER-001 #24) — 최고관리자 완전 잠김 방지
관리자 계정이 5회 실패로 잠기면 해제 화면도 로그인이 있어야 들어갈 수 있어
아무도 풀 수 없는 상태가 될 수 있었다. 지정 계정을 LOGIN_LOCK_EXEMPT
화이트리스트로 등록하면 실패 잠금 판정만 면제되고, LOGIN_HISTORY 감사이력과
2단계 인증(OTP)은 예외 없이 그대로 적용된다. 캐시는 fail-closed로 조회
실패 시 전 계정 정상 잠금 판정을 유지한다.

접근통제 정책 화면에 '잠금 예외 계정' 섹션을 추가해 등록/해제하며,
같은 화면의 '잠금 해제 방식'은 실사용 중인 단일 옵션만 남기도록 단순화했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 17:35:04 +09:00
infraurp
363138eef5 장기 미사용 계정 현황 화면명에 "관리자" 명시 + 행별 조치 버튼 축소
미사용계정 현황(#11, 메뉴 9100037)이 대상이 개인정보취급자(EMP) 계정임을
화면·메뉴명에서 알 수 없어 오해 소지가 있었다. "장기 미사용 계정" →
"장기 미사용 관리자 계정"으로 명시(요약표/목록 캡션, 메뉴명, 프로그램 설명).
이미 배포된 환경을 위해 재실행 안전한 UPDATE 문을 DML에 추가.

목록 행별 "말소"/"잠금" 버튼은 시각적으로 과도하게 커서 축소(.btn3.mini).
상단 "선택 말소" 일괄 버튼은 그대로 둠.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:59:37 +09:00
infraurp
4413c5aaea 장기 미사용 계정 파기 배치를 관리자(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>
2026-08-10 16:40:46 +09:00
infraurp
94da0fe9ae 개인정보보호 기능개선 3차 — 접근통제 정책 화면 외 미커밋 산출물 일괄 커밋
5개 세션에 걸쳐 쌓인 미커밋 변경분을 한 번에 정리한다. 주 산출물은 이번 세션의
접근통제 정책 적용(SER-001 요건 4·5)이고, 나머지는 앞선 세션들의 백로그다.

■ 접근통제 정책 적용 (SER-001 요건 4·5, 이번 세션)
「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.

- ACCESS_CONTROL_POLICY(단일행) + 관리자 화면 신규 (메뉴 9100047)
  · 계정 잠금: 인증 실패 임계값(10 -> 5), 잠금 해제 방식(관리자화면+이력 / +사유필수)
  · 자동 로그아웃: 미사용 시간(60 -> 30분), 만료 임박 안내 사용여부·시점
  · 세션 재발급: 2차 인증 성공 시 세션 고정(Session Fixation) 차단 여부
- SessionPolicyInterceptor 신규 — /mnt/ 요청마다 정책값으로 세션 타임아웃 적용.
  web.xml 재배포 없이 값이 바뀐다(2025.9 자료요청 3번 "시간 설정 기능 없음" 해소).
  web.xml <session-timeout>30 은 이제 폴백값이다.
- 잠금 임계값 저장 시 인증 경로가 읽는 LOGIN_LOCK_COUNT 를 함께 동기화.
  정책 테이블만 고치면 화면 숫자만 바뀌고 실제 인증은 옛 값을 계속 쓰기 때문이다.
- 관리자 상단(top.jsp): 로그인 후 경과시간 + 자동 로그아웃 잔여 카운트다운,
  만료 임박 안내와 연장(/mnt/session/extend.do).
  세션 상태를 주기 폴링하지 않는다 — 그 요청 자체가 세션을 갱신해
  '미사용 자동 로그아웃'이 영원히 오지 않기 때문이다.
- 자동 로그아웃 시 로그인 화면 이동: actionLogout.do?expired=Y 로 세션 invalidate 후
  로그인 화면 + 사유 안내. 수동 로그아웃(프론트 메인 이동)은 기존 동작 유지.
- 계정잠금관리: 정책이 '사유 필수'면 해제 사유 강제, ACCOUNT_UNLOCK_HISTORY 적재.

■ EgovLoginLogAspect fail-safe (회귀 수정)
접속기록 적재 예외가 @Before 어드바이스 밖으로 나가 로그인·로그아웃이 통째로 500 이
되고 있었다(로컬: MySQL 8 + mysql-connector 5.1.17 의 tx_isolation 조회 실패).
클래스 Javadoc 이 이미 fail-safe 를 명세하고 있었으나 구현이 따르지 않았다.
try/catch 로 인증 흐름을 살리되, 접속기록 유실은 SFR-004 직결이라 ERROR 로 남긴다.

■ 앞선 세션 백로그
- 접속기록(SFR-004): 조회·CSV 비동기 다운로드(PDAL_DOWNLOAD_JOB), 업무유형 매핑
- 로그인 접속기록(#62): COMTNLOGINLOG 접속IP·접속ID 유실 복구 + 접속위치 스냅샷
- 권한관리(SFR-001): 권한변경 이력 CSV 비동기 다운로드, 신청·승인 워크플로 화면 정리
- 미사용계정(SFR-002): 현황 화면 시안 반영(요약·역할·잠금/말소·조치 이력)
- 회원정보 암호화(추가제안 Action4): AES 암복호화·검색용 해시, 이관 배치와 사전 백업

■ 커밋 제외
font/(글꼴 설치본 22M), 제안서 문서(pptx/hwp), .claude/(하네스) 는 소스가 아니라 제외.
.claude/ 버전관리 여부는 별도 판단이 필요하다.

배포 산출물: _workspace/deploy/20260810_1300/ (DELTA.md, sql/, APPLY_LOG.md)
브라우저 검증(2단계 인증 필요)은 APPLY_LOG.md B1~B7 로 남겨둔다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 13:58:58 +09:00
infraurp
f2029a00a0 개인정보보호 기능개선 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>
2026-08-07 15:05:03 +09:00
332 changed files with 23068 additions and 1747 deletions

View File

@ -0,0 +1,137 @@
# arko_renual — csv.wise.ai.kr 자동 배포 파이프라인
#
# [개발 PC] git push → 이 워크플로가 자동 실행
# [웹서버] 빌드(JDK8/Maven) → WAR 을 WAS 로 전송
# [WAS] ROOT 재전개 + 환경설정 주입 + Tomcat 재기동 + 헬스체크(실패 시 자동 롤백)
# [웹서버] SSH 터널 경유로 공개 확인 (https://csv.wise.ai.kr)
#
# 그 세 걸음이 곧 지정된 경로다: PC → git → web → was(db)
#
# ┌ 왜 웹서버에서 빌드하나 ────────────────────────────────────────────────┐
# │ WAS(27.255.76.2)는 64코어지만 llama-server 가 200G+ 를 점유해 가용 │
# │ 메모리가 3G 안팎이다. 거기서 Maven(힙 1G)을 돌리면 LLM 서비스가 OOM 될 │
# │ 수 있다. 웹서버(4코어/여유 4G)는 빌드 외 부하가 없어 여기서 빌드한다. │
# └────────────────────────────────────────────────────────────────────────┘
#
# ※ Gitea 1.22.3 은 workflow_dispatch(수동 실행 버튼)를 지원하지 않는다 —
# 넣으면 워크플로가 Actions 목록에 아예 뜨지 않는다. 트리거는 push 하나만 둔다.
# 수동 재배포는 WAS 에서 직접:
# ssh -J <웹서버> claudeops@27.255.76.2 \
# 'bash /opt/arko-app/deploy/arko-deploy.sh /opt/arko-app/incoming/arko.war'
name: deploy-wise
on:
push:
branches:
- master
# 현재 배포 대상은 이 브랜치다. master 는 7커밋 뒤처져 있고 이관된 DB 스키마와
# 맞지 않는다. master 로 병합한 뒤에는 아래 줄을 지워도 된다.
- feature/privacy-access-control-policy
paths-ignore: # 문서만 바뀐 커밋으로 재배포하지 않는다
- '**.md'
- '**.docx'
- '**.pptx'
- '**.hwp'
- 'docs/**'
- '_workspace/**'
- '_workspace_privacy/**'
concurrency:
group: arko-wise-deploy
cancel-in-progress: false # 배포 도중 취소되면 ROOT 가 반쪽 상태로 남는다
env:
JAVA_HOME: /usr/lib/jvm/java-1.8.0-openjdk
MVN: /opt/maven/bin/mvn
WAS_HOST: 27.255.76.2
WAS_USER: claudeops
WAS_KEY: /root/.ssh/id_ed25519
APP_HOME: /opt/arko-app
SITE_URL: https://csv.wise.ai.kr/
jobs:
deploy:
runs-on: arko-web
timeout-minutes: 25
steps:
# ⚠ lfs: true 를 주지 않는다 — 러너에 git-lfs 가 없으면 checkout 자체가 죽는다.
- uses: actions/checkout@v4
- name: 사전 점검
run: |
set -e
df -h /opt | tail -1
# `free -h | head -2` 도 같은 SIGPIPE 함정을 안고 있다(작아서 대개 통과하지만 경합이다).
free -h
# 터널이 살아 있어야 배포 후 확인이 가능하다
systemctl is-active arko-was-tunnel || echo "::warning::터널이 죽어 있다 — 배포 후 확인이 실패할 수 있다"
- name: 빌드 (JDK 8)
run: |
set -e
export JAVA_HOME="$JAVA_HOME"
export MAVEN_OPTS="-Xmx1024m"
# Maven 3.8+ 는 HTTP 저장소를 차단한다. eGov 2.7 은 HTTP 저장소(terracotta 등)를
# 쓰므로 3.6.3 을 고정해서 쓴다. 3.8 로 올리면 의존성 해석부터 실패한다.
"$MVN" -B clean package -DskipTests
ls -lh target/arko.war
- name: 산출물 검증
run: |
set -e
# 바이트코드 major=51(Java 7). JDK 11/17 로 잘못 빌드되면 WAS 에서 못 뜬다.
# `find ... | head -1` 로 쓰면 안 된다. head 가 첫 줄을 받고 파이프를 닫는 순간
# find 가 SIGPIPE(141)로 죽고, act 가 스텝을 bash -e -o pipefail 로 돌리기 때문에
# 파이프라인 전체가 실패로 판정된다 — 출력 한 줄 없이 배포가 멈춘다(2026-08-26 실제 발생).
# -print -quit 는 첫 매치를 찍고 스스로 끝내므로 파이프가 필요 없다.
CLS=$(find target/classes -name '*.class' -print -quit)
VER=$(od -An -t d1 -j 7 -N 1 "$CLS" | tr -d ' ')
echo "바이트코드 major=$VER (51이어야 정상)"
[ "$VER" = "51" ] || { echo "::error::JDK 8 이 아닌 컴파일러로 빌드됐다"; exit 1; }
# 파이프 없이 한 번만 읽어 검사한다(파이프+pipefail 조합이 정상 WAR 을 오판한 전례가 있다)
WAR_LIST=$(unzip -l target/arko.war)
grep -q "WEB-INF/web.xml" <<< "$WAR_LIST" || { echo "::error::정상 WAR 이 아니다"; exit 1; }
- name: WAS 로 전송
run: |
set -e
SSH="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -i $WAS_KEY"
$SSH "$WAS_USER@$WAS_HOST" "mkdir -p $APP_HOME/incoming $APP_HOME/deploy"
scp -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -i "$WAS_KEY" \
target/arko.war "$WAS_USER@$WAS_HOST:$APP_HOME/incoming/arko.war"
# 배포 스크립트도 매번 저장소본으로 덮어쓴다.
# (이전 파이프라인에서 서버 사본이 저장소본과 갈라져 고친 내용이 반영되지 않는
# 사고가 있었다. 저장소를 유일한 원본으로 둔다.)
scp -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -i "$WAS_KEY" \
deploy/wise/arko-deploy.sh "$WAS_USER@$WAS_HOST:$APP_HOME/deploy/arko-deploy.sh"
# 잘린 전송을 잡는다 — 전례가 있어 크기를 반드시 대조한다
LOCAL_MD5=$(md5sum target/arko.war | cut -d' ' -f1)
REMOTE_MD5=$($SSH "$WAS_USER@$WAS_HOST" "md5sum $APP_HOME/incoming/arko.war | cut -d' ' -f1")
echo "local=$LOCAL_MD5 remote=$REMOTE_MD5"
[ "$LOCAL_MD5" = "$REMOTE_MD5" ] || { echo "::error::전송된 WAR 이 손상됐다"; exit 1; }
- name: WAS 배포
run: |
set -e
ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -i "$WAS_KEY" \
"$WAS_USER@$WAS_HOST" "chmod +x $APP_HOME/deploy/arko-deploy.sh && bash $APP_HOME/deploy/arko-deploy.sh $APP_HOME/incoming/arko.war"
- name: 공개 확인
run: |
set -e
# 터널 경유(웹서버 → WAS)
TUN=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 http://127.0.0.1:11060/ || echo 000)
echo "터널 127.0.0.1:11060 → HTTP $TUN"
# 공개 URL. 첫 요청은 JSP 컴파일로 15초 이상 걸릴 수 있다.
CODE=000
for _ in $(seq 1 6); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 60 "$SITE_URL" || echo 000)
case "$CODE" in 200|302) break ;; esac
sleep 5
done
echo "$SITE_URL → HTTP $CODE"
case "$CODE" in
200|302) echo "배포 정상" ;;
*) echo "::error::공개 URL 응답 없음 (HTTP $CODE) — 터널(arko-was-tunnel) 상태를 확인할 것"; exit 1 ;;
esac

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,65 @@
#!/usr/bin/env bash
# arko WAS(27.255.76.2) 최초 구축 — 컨테이너 2기(MariaDB, Tomcat) 생성
#
# 실행 위치: WAS 에서 claudeops 계정으로. bash 01-was-setup.sh
# 재실행 안전: 컨테이너를 지우고 다시 만든다. 단 db-data 는 건드리지 않는다.
#
# ┌ 이 서버의 제약 ────────────────────────────────────────────────────────┐
# │ ① claudeops 는 sudo 를 쓸 수 없다. docker 그룹 소속이라 컨테이너로만 │
# │ 설치할 수 있다. /opt 쓰기도 컨테이너를 거쳐야 한다. │
# │ ② llama-server 가 200G+ 를 점유해 가용 메모리가 3G 안팎이다. │
# │ 두 컨테이너 합쳐 1.75G 로 묶어 둔다. 올리기 전 반드시 free -g 확인. │
# │ ③ 11000~11050 은 다른 서비스가 쓰고 있다. arko 는 11060/11061 을 쓴다. │
# └────────────────────────────────────────────────────────────────────────┘
set -euo pipefail
APP_HOME=/opt/arko-app
MARIADB_ROOT_PW=${MARIADB_ROOT_PW:?"MARIADB_ROOT_PW 를 지정할 것"}
# ---------------------------------------------------------- 0. 디렉토리
# /opt 는 root 소유라 컨테이너를 거쳐 만든다(claudeops 는 sudo 불가).
docker run --rm -v /opt:/host-opt alpine:3 sh -c \
'for d in db-data incoming webapps logs deploy conf backup lib upload privacy-download; do mkdir -p /host-opt/arko-app/$d; done; chown -R 1005:1006 /host-opt/arko-app'
mkdir -p "$APP_HOME"/upload/{thumbNail,preView,Synch,bbsTemp} "$APP_HOME"/logs/{mysql,tomcat}
docker network inspect arko-net >/dev/null 2>&1 || docker network create arko-net
# ------------------------------------------------------ 1. JDBC 드라이버
# MariaDB 로 가므로 MariaDB 커넥터를 쓴다. WAR 안의 mysql-connector 는 쓰지 않는다
# (pom 은 5.1.17 고정인데 properties 는 8.x 전용 com.mysql.cj.jdbc.Driver 를 가리켜
# 그대로는 맞지 않는다). Tomcat 의 lib 에 두면 WAR 을 건드리지 않고 교체할 수 있다.
[ -f "$APP_HOME/lib/mariadb-java-client-2.7.12.jar" ] || \
curl -fsSL -o "$APP_HOME/lib/mariadb-java-client-2.7.12.jar" \
https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.7.12/mariadb-java-client-2.7.12.jar
# ---------------------------------------------------------- 2. MariaDB
# lower_case_table_names=1 은 initdb 시점에만 반영된다(mariadb.cnf 주석 참고).
docker rm -f arko-mariadb >/dev/null 2>&1 || true
docker run -d --name arko-mariadb --restart unless-stopped --network arko-net \
-p 127.0.0.1:11061:3306 \
-m 768m --memory-swap 768m \
-e MARIADB_ROOT_PASSWORD="$MARIADB_ROOT_PW" \
-e MARIADB_DATABASE=csv -e MARIADB_USER=csv -e MARIADB_PASSWORD=csv \
-e TZ=Asia/Seoul \
-v "$APP_HOME/db-data":/var/lib/mysql \
-v "$APP_HOME/conf/mariadb.cnf":/etc/mysql/conf.d/arko.cnf:ro \
-v "$APP_HOME/logs/mysql":/var/log/mysql \
mariadb:11.4
# ----------------------------------------------------------- 3. Tomcat
# arko 는 source/target 1.7 이라 JDK 8 로만 뜬다(11/17 불가).
# 컨테이너는 만들어만 두고 기동은 arko-deploy.sh 가 한다(ROOT 가 없으면 의미가 없다).
docker rm -f arko-tomcat >/dev/null 2>&1 || true
docker create --name arko-tomcat --restart unless-stopped --network arko-net \
-p 127.0.0.1:11060:8080 \
-m 1g --memory-swap 1g \
-e TZ=Asia/Seoul \
-e JAVA_OPTS="-Xms256m -Xmx768m -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul -Djava.awt.headless=true -Djava.security.egd=file:/dev/./urandom" \
-v "$APP_HOME/webapps":/usr/local/tomcat/webapps \
-v "$APP_HOME/lib/mariadb-java-client-2.7.12.jar":/usr/local/tomcat/lib/mariadb-java-client-2.7.12.jar:ro \
-v "$APP_HOME/logs/tomcat":/usr/local/tomcat/logs \
-v "$APP_HOME/upload":/app/upload \
-v "$APP_HOME/privacy-download":/app/privacy-download \
tomcat:9.0-jdk8-temurin
echo "완료. 다음: config/globals-{dev,live}.properties 를 두고 arko-deploy.sh 를 실행할 것."

123
deploy/wise/README.md Normal file
View File

@ -0,0 +1,123 @@
# csv.wise.ai.kr 배포 구성
## 전체 구조
```
[개발 PC]
│ git push
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 웹서버 175.118.124.176 (utimsurpai.cafe24.com, Rocky 8, SSH :9271) │
│ Gitea git.wise.ai.kr (127.0.0.1:9003) │
│ act_runner `arko-web:host` → JDK8+Maven 3.6.3 으로 WAR 빌드 │
│ Apache httpd → https://csv.wise.ai.kr │
└───────────┬──────────────────────────────────┬──────────────────────┘
│ ① scp WAR + 배포 스크립트 (SSH) │ ② SSH 터널 :11060
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ WAS 27.255.76.2 (Ubuntu 22.04, 64코어) │
│ arko-tomcat 컨테이너 127.0.0.1:11060 ← ROOT 전개 │
│ arko-mariadb 컨테이너 127.0.0.1:11061 DB `csv` │
│ /opt/arko-app (webapps · config · upload · backup · logs) │
└─────────────────────────────────────────────────────────────────────┘
```
WAS 는 27 → 인터넷 방향으로만 열려 있고, 웹서버를 거쳐야 닿는다.
Tomcat 은 127.0.0.1 에만 바인딩되며 **인터넷에 직접 노출되지 않는다** — 웹서버가
아웃바운드 SSH 터널로 자기 쪽 11060 을 WAS 의 Tomcat 에 잇는다.
## 배포 흐름
```bash
git push # 이하 전부 자동
```
```
push → 웹서버에서 빌드(JDK8) → 바이트코드 major=51 검증 → WAR md5 대조 전송
→ WAS 에서 ROOT 재전개 + 환경설정 주입 + Tomcat 재기동 + 헬스체크
→ 실패 시 직전 백업으로 자동 롤백
→ https://csv.wise.ai.kr 공개 확인
```
## 왜 이런 구조인가
**빌드를 WAS 에서 하지 않는 이유** — WAS 는 64코어지만 llama-server 가 200G+ 를
점유해 **가용 메모리가 3G 안팎**이다. 여기서 Maven(힙 1G)을 돌리면 LLM 서비스가
OOM 될 수 있다. 웹서버(4코어 / 여유 4G)는 빌드 외 부하가 없다.
**컨테이너로 설치한 이유** — WAS 의 `claudeops` 계정은 **sudo 를 쓸 수 없고**
docker 그룹에만 속한다. 패키지 설치가 불가능해 Tomcat·MariaDB 를 컨테이너로 올렸다.
같은 서버의 다른 서비스(urpai 등)와 같은 관행이다.
**배포 스크립트를 매번 덮어쓰는 이유** — 이전 파이프라인에서 서버 사본과 저장소본이
갈라져, 저장소에서 고친 내용이 배포에 반영되지 않는 사고가 있었다. 워크플로가 매 실행마다
`deploy/wise/arko-deploy.sh` 를 WAS 로 복사하므로 **저장소가 유일한 원본**이다.
## 포트
| 포트 | 위치 | 용도 |
|---|---|---|
| 9271 | 웹서버 | SSH |
| 9003 | 웹서버 127.0.0.1 | Gitea |
| 11060 | WAS 127.0.0.1 | Tomcat (웹서버는 터널로 접근) |
| 11061 | WAS 127.0.0.1 | MariaDB |
11000~11050 은 같은 서버의 다른 서비스가 쓰고 있다. 포트를 새로 잡을 때 겹치지 않게 할 것.
## 파일
| 파일 | 설치 위치 | 역할 |
|---|---|---|
| `01-was-setup.sh` | WAS | 컨테이너 2기 최초 생성 (재실행 안전) |
| `arko-deploy.sh` | WAS `/opt/arko-app/deploy/` | 재전개·설정주입·기동·헬스체크·롤백 |
| `mariadb.cnf` | WAS `/opt/arko-app/conf/` | MariaDB 설정 (`lower_case_table_names=1` 주의) |
| `csv.wise.ai.kr.conf` | 웹 `/etc/httpd/conf.d/` | Apache vhost (80→443, 터널로 프록시) |
| `arko-was-tunnel.service` | 웹 `/etc/systemd/system/` | SSH 터널 (자동 재연결) |
| `arko-runner.service` | 웹 `/etc/systemd/system/` | arko 전용 act_runner |
| `arko-runner-config.yaml` | 웹 `/opt/arko-runner/config.yaml` | 러너 설정 |
## 환경설정 (저장소에 없음)
`globals-dev.properties` / `globals-live.properties` 는 `.gitignore` 대상이라
저장소에도 WAR 에도 들어가지 않는다. **WAS 의 `/opt/arko-app/config/` 에만 두고**
배포 때 `WEB-INF/classes/egovframework/egovProps/` 로 주입한다.
`context-datasource.xml` 이 `globals-dev.properties` 를 읽으므로 이 주입이 없으면 기동 실패한다.
WAS 값에서 로컬과 다른 부분:
```properties
Globals.OsType =UNIX
Globals.DriverClassName =org.mariadb.jdbc.Driver
Globals.Url =jdbc:mariadb://arko-mariadb:3306/csv?useUnicode=true&characterEncoding=UTF-8&useSSL=false
Globals.fileStorePath =/app/upload/
Globals.home.url =https://csv.wise.ai.kr
Globals.home.base =/usr/local/tomcat/webapps/ROOT
Globals.privacyDownloadPath=/app/privacy-download/
```
## 운영 명령
```bash
# 웹서버 (ssh -p 9271 root@175.118.124.176)
systemctl status arko-runner arko-was-tunnel # 502 가 나면 터널부터 확인
tail -f /var/log/httpd/csv_error.log
# WAS (ssh 는 웹서버를 경유한다)
docker ps --filter name=arko-
docker logs -f --tail 100 arko-tomcat
tail -f /opt/arko-app/logs/deploy.log
docker exec -it arko-mariadb mariadb -ucsv -pcsv csv
# 수동 재배포 (Gitea 1.22 는 수동 실행 버튼이 없다)
bash /opt/arko-app/deploy/arko-deploy.sh /opt/arko-app/incoming/arko.war
```
## 알려진 제약
- **Gitea 1.22.3 은 `workflow_dispatch` 를 지원하지 않는다.** 넣으면 워크플로가 Actions
목록에 아예 뜨지 않는다. 재배포는 실제 파일을 바꿔 push 하거나 WAS 에서 직접 실행한다.
- **Maven 은 3.6.3 을 쓴다.** 3.8+ 는 HTTP 저장소를 차단하는데 eGov 2.7 은 HTTP 저장소
(terracotta 등)에 의존해 의존성 해석부터 실패한다.
- **메모리가 빠듯하다.** 두 컨테이너 합쳐 1.75G 상한이다. 올리기 전 `free -g` 로 확인할 것.
- **DB 는 MariaDB 다.** 원본(MySQL 8)의 `utf8mb4_0900_ai_ci` 는 MariaDB 에 없어
`utf8mb4_general_ci` 로 변환해 이관했다. 한글 정렬 결과는 같다(코드포인트 순).

115
deploy/wise/arko-deploy.sh Normal file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env bash
# arko(문화품앗이) WAS 배포 스크립트 — 27.255.76.2
#
# 설치 위치: /opt/arko-app/deploy/arko-deploy.sh
# 호출 주체: Gitea Actions(웹서버 러너)가 ssh 로, 또는 수동 실행
# 사용법 : bash arko-deploy.sh /opt/arko-app/incoming/arko.war
#
# ┌ 이 서버의 제약 ────────────────────────────────────────────────────────┐
# │ claudeops 는 sudo 를 쓸 수 없다. 대신 docker 그룹에 속한다. │
# │ 그래서 Tomcat·MariaDB 는 패키지 설치가 아니라 컨테이너로 돌린다. │
# │ 또한 llama-server 가 200G+ 를 점유해 가용 메모리가 3G 안팎이다. │
# │ 컨테이너에 메모리 상한을 걸어 두었으니 함부로 올리지 말 것. │
# └────────────────────────────────────────────────────────────────────────┘
#
# 동작: WAR 검증 → 현재 ROOT 백업(3개 유지) → 컨테이너 정지 → 재전개
# → 환경설정 주입 → 기동 → 헬스체크 (실패 시 직전 백업으로 자동 롤백)
set -euo pipefail
APP_HOME=/opt/arko-app
WEBAPPS=$APP_HOME/webapps
ROOT_DIR=$WEBAPPS/ROOT
CONFIG_DIR=$APP_HOME/config
BACKUP_DIR=$APP_HOME/backup
LOG_DIR=$APP_HOME/logs
CONTAINER=arko-tomcat
HEALTH_URL=http://127.0.0.1:11060/
HEALTH_TIMEOUT=240 # 초. arko 는 첫 기동에서 JSP 컴파일로 60~120초 걸린다
KEEP_BACKUPS=3
WAR_SRC=${1:-$APP_HOME/incoming/arko.war}
TS=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR" "$LOG_DIR" "$WEBAPPS"
log() { echo "[$(date +'%F %T')] $*" | tee -a "$LOG_DIR/deploy.log"; }
die() { log "ERROR: $*"; exit 1; }
# ------------------------------------------------------------- 1. WAR 검증
[ -f "$WAR_SRC" ] || die "WAR 파일 없음: $WAR_SRC"
# unzip -l 을 파이프로 grep 하면 set -o pipefail 과 맞물려 정상 WAR 을 오판한다
# (2026-07-30 개발 파이프라인에서 실제로 배포가 멈춘 적이 있다). 한 번만 읽어 변수에 담는다.
WAR_LIST=$(unzip -l "$WAR_SRC") || die "WAR 손상: $WAR_SRC"
grep -q "WEB-INF/web.xml" <<< "$WAR_LIST" || die "WEB-INF/web.xml 없음 — 정상 WAR 아님"
log "배포 시작: $WAR_SRC ($(du -h "$WAR_SRC" | cut -f1))"
# 전개본 + 백업까지 WAR 3배의 여유가 필요하다
NEED_KB=$(( $(du -k "$WAR_SRC" | cut -f1) * 3 ))
FREE_KB=$(df -k "$APP_HOME" | awk 'NR==2 {print $4}')
[ "$FREE_KB" -gt "$NEED_KB" ] || die "디스크 부족: 여유 $((FREE_KB/1024))MB, 필요 $((NEED_KB/1024))MB"
# --------------------------------------------------------- 2. 현재본 백업
BACKUP_FILE=""
if [ -d "$ROOT_DIR" ]; then
BACKUP_FILE="$BACKUP_DIR/ROOT_$TS.tar.gz"
log "현재 ROOT 백업 → $BACKUP_FILE"
tar -czf "$BACKUP_FILE" -C "$WEBAPPS" ROOT
ls -1t "$BACKUP_DIR"/ROOT_*.tar.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS+1)) | xargs -r rm -f
fi
# --------------------------------------------------------- 3. 컨테이너 정지
if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then
log "Tomcat 정지"
docker stop "$CONTAINER" >/dev/null 2>&1 || true
fi
# ------------------------------------------------------------- 4. 재전개
log "ROOT 재전개"
rm -rf "$ROOT_DIR" "$WEBAPPS/ROOT.tmp"
mkdir -p "$WEBAPPS/ROOT.tmp"
unzip -q "$WAR_SRC" -d "$WEBAPPS/ROOT.tmp"
mv "$WEBAPPS/ROOT.tmp" "$ROOT_DIR"
# ------------------------------------------------- 5. 환경설정 주입 (필수)
# globals-{dev,live}.properties 는 .gitignore 대상이라 저장소에도 WAR 에도 없다.
# context-datasource.xml 이 globals-dev.properties 를 읽으므로 이 주입이 없으면 기동 실패한다.
PROPS_DEST="$ROOT_DIR/WEB-INF/classes/egovframework/egovProps"
[ -d "$PROPS_DEST" ] || die "egovProps 경로 없음: $PROPS_DEST"
for f in globals-dev.properties globals-live.properties; do
[ -f "$CONFIG_DIR/$f" ] || die "서버 보관 환경설정 없음: $CONFIG_DIR/$f"
cp -f "$CONFIG_DIR/$f" "$PROPS_DEST/$f"
log "환경설정 주입: $f"
done
chmod 640 "$PROPS_DEST"/globals-*.properties
# --------------------------------------------------------- 6. 컨테이너 기동
log "Tomcat 기동"
docker start "$CONTAINER" >/dev/null || die "컨테이너 기동 실패 — 'docker logs $CONTAINER' 확인"
# ------------------------------------------------------------- 7. 헬스체크
log "헬스체크 (최대 ${HEALTH_TIMEOUT}초)"
CODE=000
for _ in $(seq 1 $((HEALTH_TIMEOUT/5))); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$HEALTH_URL" || echo 000)
case "$CODE" in 200|302) break ;; esac
sleep 5
done
case "$CODE" in
200|302)
log "배포 완료 — HTTP $CODE"
;;
*)
log "헬스체크 실패 (HTTP $CODE)"
if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
log "롤백: $BACKUP_FILE 복구"
docker stop "$CONTAINER" >/dev/null 2>&1 || true
rm -rf "$ROOT_DIR"
tar -xzf "$BACKUP_FILE" -C "$WEBAPPS"
docker start "$CONTAINER" >/dev/null 2>&1 || true
log "롤백 완료 — 기동 로그: docker logs --tail 100 $CONTAINER"
else
log "복구할 백업이 없다(최초 배포). 컨테이너 로그를 확인할 것."
fi
exit 1
;;
esac

View File

@ -0,0 +1,14 @@
# arko 전용 act_runner 설정 — /opt/arko-runner/config.yaml
# 이 서버에는 러너가 셋(wise/urpai/arko) 상주한다. arko 빌드는 1~3분이 걸리고
# WAR 이 192MB 라, 다른 서비스 배포를 막지 않도록 러너를 분리해 둔다.
log:
level: info
runner:
capacity: 1 # arko 배포는 ROOT 를 교체하므로 절대 병렬로 돌리지 않는다
timeout: 30m
labels:
- arko-web:host # host 모드 — 러너가 웹서버에서 직접 명령을 실행한다
cache:
enabled: true
container:
network: host

View File

@ -0,0 +1,20 @@
# arko 전용 Gitea Actions 러너 — /etc/systemd/system/arko-runner.service
# 라벨 arko-web:host 로 .gitea/workflows/deploy-wise.yml 을 받는다.
[Unit]
Description=Gitea Actions runner (arko / csv.wise.ai.kr)
After=network-online.target gitea.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/arko-runner
# nginx/Apache 를 거치지 않고 Gitea 에 직결한다(127.0.0.1:9003).
ExecStart=/opt/arko-runner/act_runner daemon --config /opt/arko-runner/config.yaml
Environment=HOME=/root
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,40 @@
# arko(문화품앗이) WAS 터널 — 운영 웹(175.118.124.176) → WAS(27.255.76.2)
# 설치 위치: /etc/systemd/system/arko-was-tunnel.service
#
# 왜 터널인가:
# WAS 의 Tomcat 은 컨테이너 포트를 127.0.0.1:11060 에만 바인딩한다.
# 같은 서버의 urpai 처럼 공인 고포트로 열 수도 있지만, arko 는 개인정보보호
# 과제 대상 시스템이라 WAS 를 인터넷에 직접 노출하지 않는다.
# 웹서버가 아웃바운드 SSH 로 자기 쪽 127.0.0.1:11060 을 WAS 의 Tomcat 에 잇는다.
#
# 키 제한: WAS 의 authorized_keys 에서
# restrict,port-forwarding,permitopen="127.0.0.1:11060"
# 으로 묶여 있어 셸 접근이 불가능하고 이 포트 외에는 열 수 없다.
#
# 502 가 나면 여기부터 확인한다: systemctl status arko-was-tunnel
[Unit]
Description=SSH tunnel to arko WAS (27.255.76.2:11060)
After=network-online.target
Wants=network-online.target
Before=httpd.service
[Service]
Type=simple
ExecStart=/usr/bin/ssh -N -T \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile=/root/.ssh/known_hosts \
-o IdentitiesOnly=yes \
-i /root/.ssh/id_arko_tunnel \
-L 127.0.0.1:11060:127.0.0.1:11060 \
claudeops@27.255.76.2
Restart=always
RestartSec=10
User=root
Group=root
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,82 @@
# csv.wise.ai.kr — arko(문화품앗이) 웹 티어
# 설치 위치: /etc/httpd/conf.d/csv.wise.ai.kr.conf (이 서버는 Apache httpd 를 쓴다)
#
# 경로: 브라우저 → httpd 443 → [SSH 터널] 127.0.0.1:11060 → WAS(27.255.76.2) Tomcat
# DB(MariaDB)는 WAS 안에서만 접근한다. 이 서버는 DB 에 붙지 않는다.
#
# 127.0.0.1:11060 은 arko-was-tunnel.service 가 만들어 준다.
# 502 가 나면 `systemctl status arko-was-tunnel` 부터 확인할 것.
<VirtualHost *:80>
ServerName csv.wise.ai.kr
# 인증서 갱신(webroot)은 리다이렉트보다 먼저 처리되어야 한다
Alias /.well-known/acme-challenge/ /var/www/html/.well-known/acme-challenge/
<Directory "/var/www/html/.well-known/acme-challenge">
Require all granted
Options None
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName csv.wise.ai.kr
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/csv.wise.ai.kr/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/csv.wise.ai.kr/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite HIGH:!aNULL:!MD5
# 자원봉사 활동사진·첨부파일 업로드가 있어 기본값으로는 부족하다
LimitRequestBody 104857600
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# 운영(csv.culture.go.kr)이 따로 있는 검증 환경이므로 색인을 막는다
Header always set X-Robots-Tag "noindex, nofollow"
# Host 를 그대로 넘겨야 WAS 가 csv.wise.ai.kr 기준으로 링크를 만든다
ProxyPreserveHost On
# arko 는 첫 요청에서 JSP 를 컴파일하고, 통계·엑셀 다운로드가 오래 걸린다
ProxyTimeout 300
# 접속기록(개인정보보호 SFR-004)이 실제 접속자 IP 를 남겨야 한다.
# 프록시를 한 단 거치므로 원본 IP 를 헤더로 전달한다.
RequestHeader set X-Forwarded-Proto "https"
# ── 느린 회선 대응 ───────────────────────────────────────────────────
# 이 사이트는 메인 화면 하나에 woff 폰트만 3MB 넘게 끌어온다. 사내망에서는
# 티가 안 나지만 테더링·모바일에서는 로딩이 끝나지 않아 "접속 안 됨"으로 보인다.
# (실측: 같은 폰트가 유선망 0.15초 / 테더링 25초)
# ① 텍스트 자원 압축. HTML 52KB → 약 12KB.
# woff/woff2·이미지는 이미 압축돼 있어 넣지 않는다(CPU만 쓰고 안 줄어든다).
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/x-javascript application/json application/xml
# 압축 여부가 캐시 키에 반영되도록
Header append Vary Accept-Encoding
# ② 정적 자원 캐시. 원래는 매 방문마다 3MB 를 다시 받았다.
# 폰트는 파일명이 바뀌지 않으므로 길게, css/js 는 배포마다 바뀌므로 짧게 잡는다.
<LocationMatch "\.(woff2?|ttf|eot|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
<LocationMatch "\.(png|jpe?g|gif|ico|svg|webp)$">
Header set Cache-Control "public, max-age=604800"
</LocationMatch>
<LocationMatch "\.(css|js)$">
Header set Cache-Control "public, max-age=3600"
</LocationMatch>
ProxyPass /.well-known/acme-challenge/ !
ProxyPass / http://127.0.0.1:11060/
ProxyPassReverse / http://127.0.0.1:11060/
ErrorLog /var/log/httpd/csv_error.log
CustomLog /var/log/httpd/csv_access.log combined
</VirtualHost>

37
deploy/wise/mariadb.cnf Normal file
View File

@ -0,0 +1,37 @@
# arko(문화품앗이) MariaDB — /opt/arko-app/conf/mariadb.cnf
#
# 이 서버(27.255.76.2)는 llama-server가 200G+를 쓰는 GPU 추론 서버다.
# 가용 메모리가 3G 안팎이라 버퍼풀을 384M로 묶고 컨테이너에도 768m 상한을 건다.
# 늘리기 전에 반드시 `free -g` 로 여유를 확인할 것.
[mysqld]
# 원본은 Windows MySQL 8 (lower_case_table_names=1). Linux 기본값 0으로 두면
# iBatis SQL의 테이블명 대소문자가 어긋나 "Table doesn't exist"가 난다.
# 이 값은 초기화(initdb) 시점에만 반영되므로 db-data를 비우고 다시 만들어야 바뀐다.
lower_case_table_names = 1
character-set-server = utf8mb4
collation-server = utf8mb4_general_ci
innodb_buffer_pool_size = 384M
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
max_connections = 150
max_allowed_packet = 64M
table_open_cache = 400
tmp_table_size = 32M
max_heap_table_size = 32M
# 원본 MySQL 8 은 ONLY_FULL_GROUP_BY 가 켜진 상태로 운영되지만, 여기서는 빼야 한다.
# MySQL 8 은 GROUP BY 컬럼에 대한 '함수 종속성'을 판정해 집계에 없는 컬럼도 통과시키지만
# MariaDB 는 그 판정을 하지 않는다. 같은 sql_mode 를 그대로 옮기면 로컬에서 잘 돌던
# 쿼리가 여기서만 터진다 (2026-08-26 관리자 메인 대시보드: 'Z.STATUS1' isn't in GROUP BY).
# ONLY_FULL_GROUP_BY 제외는 MariaDB 의 기본값이기도 하다.
sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
default_time_zone = '+09:00'
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 3
[client]
default-character-set = utf8mb4

27
pom.xml
View File

@ -551,19 +551,42 @@
</dependency> </dependency>
<!-- 2025.05.14 나혁제 드라이버 버전 수정 <!-- ============================================================
[2026.08.10] MySQL 드라이버를 5.1.17 로 되돌린다 (8.0.20 비활성화).
사유: 배포된 환경(운영·개발서버 ROOT)의 WEB-INF/lib 는 실측 결과 모두
mysql-connector-java-5.1.17 이다. 그런데 pom 만 8.0.20 이라 여기서 만든 WAR 에는
8.0.20 이 들어간다. WAR 재배포는 WEB-INF/lib 를 통째 교체하므로,
아무도 의도하지 않은 채 다음 배포에서 서버 드라이버가 갈아끼워진다.
그게 왜 위험한가: connector 8.x 는 서버 타임존 이름을 해석하지 못하면 연결 자체를
거부한다(The server time zone value '...' is unrecognized). 현재 접속 URL
(globals-live.properties)에는 serverTimezone 파라미터가 없고, 한글 로케일 서버는
@@system_time_zone 이 한글 문자열로 나온다. 즉 WAR 배포 직후 전면 DB 연결 실패가 된다.
=> 현행 서버 구성(5.1.17)과 pom 을 일치시켜 이 지뢰를 제거한다.
드라이버 업그레이드가 필요하면 URL 파라미터(serverTimezone 등) 정비와
개발서버 선검증을 동반한 '계획된 별건 작업'으로 진행할 것.
참고: 로컬 개발 PC 는 MySQL 8.0.46 이라 5.1.17 로는 tx_isolation 조회가 깨져
접속기록(COMTNLOGINLOG) 적재가 실패한다. 이는 로컬 환경 한정 문제이며
EgovLoginLogAspect 의 fail-safe 로 로그인/로그아웃이 막히지는 않는다.
로컬에서 접속기록까지 검증해야 하면 로컬 ROOT 의 jar 와 URL 만 따로 조정한다
(커밋 대상 아님).
============================================================ -->
<dependency> <dependency>
<groupId>mysql</groupId> <groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId> <artifactId>mysql-connector-java</artifactId>
<version>5.1.17</version> <version>5.1.17</version>
</dependency> </dependency>
-->
<!-- 2025.05.14 나혁제 드라이버 버전 수정 (8.0.20) - 위 사유로 2026.08.10 비활성화
<dependency> <dependency>
<groupId>mysql</groupId> <groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId> <artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version> <version>8.0.20</version>
</dependency> </dependency>
-->
<dependency> <dependency>

View File

@ -105,6 +105,21 @@ public class ComDefaultVO implements Serializable {
this.searchKeyword = searchKeyword; this.searchKeyword = searchKeyword;
} }
/**
* 이름 검색 완전일치 해시(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10).
* 회원 이름이 암호화되며 부분검색(LIKE)이 불가능해져, 이름 검색을 수행하는 DAO가
* {@code searchKeyword}로부터 채운다. 화면·서비스는 이 필드를 직접 설정하지 않는다.
*/
private String searchKeywordHash = "";
public String getSearchKeywordHash() {
return searchKeywordHash;
}
public void setSearchKeywordHash(String searchKeywordHash) {
this.searchKeywordHash = searchKeywordHash;
}
public String getSearchUseYn() { public String getSearchUseYn() {
return searchUseYn; return searchUseYn;
} }

View File

@ -161,6 +161,21 @@ public class LoginVO implements Serializable{
public void setName(String name) { public void setName(String name) {
this.name = name; this.name = name;
} }
/**
* 이름(아이디/비밀번호 찾기) 검색 완전일치 해시(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10).
* 일반회원 이름이 암호화되며 {@code searchId}/{@code searchPassword} 조회의 이름 조건이
* 완전일치 해시로 바뀌었다. {@code LoginDAO}가 {@code name}으로부터 채운다.
*/
private String nameHash;
public String getNameHash() {
return nameHash;
}
public void setNameHash(String nameHash) {
this.nameHash = nameHash;
}
/** /**
* ihidNum attribute 를 리턴한다. * ihidNum attribute 를 리턴한다.
* @return String * @return String

View File

@ -0,0 +1,62 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 장기 미사용 계정 조치 이력 VO — ACCOUNT_ACTION_HISTORY 매핑 (#11).
*
* <p>현황 화면에서 관리자가 수행한 <b>잠금(휴면 확정)</b>·<b>말소</b> 조치를
* 조치자·조치일시·대상·사유·IP 와 함께 남긴다. 잠금 <i>해제</i> 이력은 기존
* ACCOUNT_UNLOCK_HISTORY(SER-001 #24)가 담당하며 본 테이블과 역할이 다르다.</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (#11 시안 반영)
* </pre>
*/
public class AccountActionHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 조치 유형 — 잠금(휴면 확정). */
public static final String ACTION_LOCK = "L";
/** 조치 유형 — 말소. */
public static final String ACTION_EXPIRE = "D";
private long actionHistId;
/** 조치 대상 계정 ID. */
private String trgetId;
/** 대상 회원구분 — 취급자는 'EMP'. */
private String trgetSe = "EMP";
/** 조치 유형 L:잠금 D:말소. */
private String actionTy;
/** 조치 처리자(관리자) ID. */
private String actionUserId;
/** 조치 사유(필수). */
private String actionResn;
/** 조치자 접속 IP(프록시 보정값). */
private String actionIp;
/** 조치 시점의 미접속 일수(사후 검증용 스냅샷). */
private int noConectDay;
private String actionDt;
public long getActionHistId() { return actionHistId; }
public void setActionHistId(long actionHistId) { this.actionHistId = actionHistId; }
public String getTrgetId() { return trgetId; }
public void setTrgetId(String trgetId) { this.trgetId = trgetId; }
public String getTrgetSe() { return trgetSe; }
public void setTrgetSe(String trgetSe) { this.trgetSe = trgetSe; }
public String getActionTy() { return actionTy; }
public void setActionTy(String actionTy) { this.actionTy = actionTy; }
public String getActionUserId() { return actionUserId; }
public void setActionUserId(String actionUserId) { this.actionUserId = actionUserId; }
public String getActionResn() { return actionResn; }
public void setActionResn(String actionResn) { this.actionResn = actionResn; }
public String getActionIp() { return actionIp; }
public void setActionIp(String actionIp) { this.actionIp = actionIp; }
public int getNoConectDay() { return noConectDay; }
public void setNoConectDay(int noConectDay) { this.noConectDay = noConectDay; }
public String getActionDt() { return actionDt; }
public void setActionDt(String actionDt) { this.actionDt = actionDt; }
}

View File

@ -0,0 +1,42 @@
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>대상은 개인정보취급자(관리자, 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회 처리건수 상한(1,000)도 SQL 측에 있다.</p>
*/
@Repository("accountDeleteDAO")
public class AccountDeleteDAO extends EgovComAbstractDAO {
/** 파기 대상(개인정보취급자) — 휴면확정 후 보존기간 경과분, 최대 1,000건. */
@SuppressWarnings("unchecked")
public List<AccountDeleteTargetVO> selectExpiredEmpAccounts() {
return list("AccountDeleteDAO.selectExpiredEmpAccounts", null);
}
/** 개인정보취급자 개인정보 파기(마스킹 + 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,86 @@
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>대상은 개인정보취급자(관리자, 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' 전환이다.
* 게시글·활동확인·마일리지 등 다수 테이블이 EMPLYR_ID 를 FK 없이 참조하고 있어
* 행을 지우면 해당 이력이 고아가 되기 때문이다.</p>
*
* <p><b>처리량 상한</b>: 배치가 장기간 정지했다 재가동되는 날 백로그가 한꺼번에 처리되지
* 않도록 1회 1,000건으로 제한(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_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 = purge(accountDeleteDAO.selectExpiredEmpAccounts());
LOG.info("[개인정보/#12] 장기 미사용 관리자 계정 파기 배치 완료 — {}건", Integer.valueOf(purged));
return purged;
}
/** 개인정보취급자(EMP) 계정 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */
private int purge(List<AccountDeleteTargetVO> targets) {
if (targets == null || targets.isEmpty()) {
return 0;
}
int cnt = 0;
for (AccountDeleteTargetVO t : targets) {
try {
accountDeleteDAO.anonymizeEmplyr(t.getMberId());
accountDeleteDAO.deleteDiapauseRow(t.getMberId());
AccountDeleteHistoryVO hist = new AccountDeleteHistoryVO();
hist.setMberId(t.getMberId());
hist.setMberTyCode(TY_EMP);
hist.setEsntlId(t.getEsntlId());
hist.setDiapauseDt(t.getDiapauseDt());
hist.setPreserveDay(t.getPreserveDay());
accountDeleteDAO.insertDeleteHistory(hist);
cnt++;
} catch (Exception e) {
LOG.error("[개인정보/#12] 관리자 계정 파기 실패 — 건너뜀 (id={}): {}",
new Object[] { t.getMberId(), 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(개인정보취급자 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,5 +1,6 @@
package egovframework.com.cmm.privacy.account; package egovframework.com.cmm.privacy.account;
import java.net.URLEncoder;
import java.util.List; import java.util.List;
import javax.annotation.Resource; import javax.annotation.Resource;
@ -9,8 +10,10 @@ 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.util.EgovUserDetailsHelper; import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
@ -23,6 +26,12 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
public class AccountMngController { public class AccountMngController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
/** 현황 목록(#11). page 파라미터는 좌측메뉴 하이라이트(left.jsp 9100037 분기)용이라 유지한다. */
private static final String DORMANT_LIST_REDIRECT = "redirect:/mnt/sec/dormantStatus/listView.do?page=9100037";
/** 관리자 알림 테스트 발송(제안서 slide15 "관리자 알림"). */
@Resource(name = "privacyAdminAlertService")
private egovframework.com.cmm.privacy.batch.PrivacyAdminAlertService privacyAdminAlertService;
@Resource(name = "accountMngPolicyService") @Resource(name = "accountMngPolicyService")
private AccountMngPolicyService accountMngPolicyService; private AccountMngPolicyService accountMngPolicyService;
@ -40,6 +49,35 @@ public class AccountMngController {
return "jsonView"; return "jsonView";
} }
/**
* 관리자 알림 <b>테스트 발송</b>.
*
* <p>실제 알림(무결성 위반·이상행위 탐지)은 월 1회 배치에서만 나가서, 수신 주소를
* 바꾼 관리자가 그것이 맞는지 한 달을 기다려야 확인할 수 있었다. 같은 발송 경로를
* 그대로 타는 테스트를 눌러 즉시 결과를 본다.</p>
*
* <p>상태를 바꾸지는 않지만 외부로 메일이 나가는 동작이라 POST·동일출처만 받는다 —
* 링크 한 번으로 반복 발송되면 안 된다.</p>
*/
@RequestMapping(value = "/mnt/privacy/accountPolicyTestAlert.do",
method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String testAlert(HttpServletRequest request, ModelMap model) throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
model.addAttribute("result", "fail");
model.addAttribute("message", "잘못된 요청 경로입니다.");
return "jsonView";
}
String resultMsg = privacyAdminAlertService.sendTestMail(loginVO.getId());
// 성공·실패 모두 사유를 그대로 화면에 보여준다. "실패했습니다"만 뜨면 원인을 못 찾는다.
model.addAttribute("result", resultMsg != null && resultMsg.startsWith("테스트 메일을 발송") ? "success" : "fail");
model.addAttribute("message", resultMsg);
return "jsonView";
}
/** #13 정책 수정. */ /** #13 정책 수정. */
@RequestMapping(value = "/mnt/privacy/accountPolicyUpdate.do") @RequestMapping(value = "/mnt/privacy/accountPolicyUpdate.do")
public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy, public String updatePolicy(@ModelAttribute("policy") AccountMngPolicyVO policy,
@ -102,6 +140,14 @@ public class AccountMngController {
if (p.getLoginHistKeepDay() < 1 || p.getLoginHistKeepDay() > 3650) { if (p.getLoginHistKeepDay() < 1 || p.getLoginHistKeepDay() > 3650) {
return "인증 실패 이력 보관기간은 1~3650일 범위로 입력하세요."; return "인증 실패 이력 보관기간은 1~3650일 범위로 입력하세요.";
} }
// [개인정보/SFR-004] 접속기록 보관기간. 법정 하한(2년)은 서버에서도 거절한다 —
// 화면 검증만 두면 요청을 직접 만드는 경로로 우회된다.
if (p.getAccessLogKeepDay() < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY
|| p.getAccessLogKeepDay() > 3650) {
return "접속기록 보관기간은 " + AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY
+ "~3650일 범위로 입력하세요. 최소 2년은 개인정보의 안전성 확보조치 기준 제8조가"
+ " 요구하는 법정 하한이라 더 짧게 설정할 수 없습니다.";
}
// #12 파기 배치 사용여부 — 체크박스 미전송 시 null 이 오므로 'N' 으로 보정 // #12 파기 배치 사용여부 — 체크박스 미전송 시 null 이 오므로 'N' 으로 보정
if (p.getPurgeUseYn() == null || p.getPurgeUseYn().trim().length() == 0) { if (p.getPurgeUseYn() == null || p.getPurgeUseYn().trim().length() == 0) {
p.setPurgeUseYn("N"); p.setPurgeUseYn("N");
@ -200,10 +246,20 @@ public class AccountMngController {
*/ */
@RequestMapping(value = "/mnt/sec/dormantStatus/listView.do") @RequestMapping(value = "/mnt/sec/dormantStatus/listView.do")
public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO, public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
@RequestParam(value = "searched", required = false) String searched,
ModelMap model) throws Exception { ModelMap model) throws Exception {
if (handlerUser() == null) { if (handlerUser() == null) {
return LOGIN_REDIRECT; return LOGIN_REDIRECT;
} }
DormantAccountSummaryVO summary = dormantAccountStatusService.selectDormantSummary();
// 최초 진입(검색 미수행)은 관리 대상만 보이도록 '휴면예정 기준일수 이상'을 기본 조건으로 건다.
// 사용자가 한 번이라도 검색하면(searched=Y) 선택한 조건을 그대로 존중한다('전체' 포함).
if (!"Y".equals(searched) && searchVO.getMinNoConnectDays() <= 0) {
searchVO.setMinNoConnectDays(summary.getWarnDay());
}
PaginationInfo paginationInfo = new PaginationInfo(); PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
@ -215,6 +271,70 @@ public class AccountMngController {
model.addAttribute("resultList", list); model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo); model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("summary", summary);
model.addAttribute("resultMsg", resultMsg);
return "site/mnt/sec/dormantStatus/list"; return "site/mnt/sec/dormantStatus/list";
} }
/** #11 선택 계정 잠금(휴면 확정). 상태를 바꾸므로 POST 한정. */
@RequestMapping(value = "/mnt/sec/dormantStatus/lock.do")
public String dormantAccountLock(@RequestParam(value = "checkedIds", required = false) String[] checkedIds,
@RequestParam(value = "actionResn", required = false) String actionResn,
HttpServletRequest request) throws Exception {
return handleAction(checkedIds, actionResn, request, true);
}
/** #11 선택 계정 말소(잠금 상태 계정 대상). 상태를 바꾸므로 POST 한정. */
@RequestMapping(value = "/mnt/sec/dormantStatus/expire.do")
public String dormantAccountExpire(@RequestParam(value = "checkedIds", required = false) String[] checkedIds,
@RequestParam(value = "actionResn", required = false) String actionResn,
HttpServletRequest request) throws Exception {
return handleAction(checkedIds, actionResn, request, false);
}
/** 잠금·말소 공통 전처리(인증·요청경로·사유 검증) 후 서비스 호출. */
private String handleAction(String[] checkedIds, String actionResn, HttpServletRequest request, boolean lock)
throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return LOGIN_REDIRECT;
}
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return dormantListRedirect("잘못된 요청 방식입니다.");
}
if (!isSameOrigin(request)) {
return dormantListRedirect("잘못된 요청 경로입니다.");
}
if (checkedIds == null || checkedIds.length == 0) {
return dormantListRedirect("대상 계정을 선택하세요.");
}
String resn = actionResn == null ? "" : actionResn.trim();
if (resn.length() == 0) {
return dormantListRedirect("조치 사유는 필수입니다.");
}
String ip = ClientIpResolver.getClientIp(request);
int done = lock
? dormantAccountStatusService.lockAccounts(checkedIds, resn, loginVO.getId(), ip)
: dormantAccountStatusService.expireAccounts(checkedIds, resn, loginVO.getId(), ip);
int skipped = checkedIds.length - done;
String label = lock ? "잠금" : "말소";
StringBuilder msg = new StringBuilder();
msg.append(done).append("건 ").append(label).append(" 처리했습니다.");
if (skipped > 0) {
// 본인 계정·이미 처리된 계정·(말소의 경우) 잠기지 않은 계정은 서버가 걸러낸다.
msg.append(" ").append(skipped).append("건은 조치 대상이 아니어서 제외했습니다");
msg.append(lock ? "(본인 계정·이미 잠긴 계정)." : "(본인 계정·잠금되지 않은 계정·이미 말소된 계정).");
}
return dormantListRedirect(msg.toString());
}
/** 처리결과 메시지를 붙여 현황 목록으로 리다이렉트. */
private String dormantListRedirect(String message) throws Exception {
if (message == null) {
return DORMANT_LIST_REDIRECT;
}
return DORMANT_LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
} }

View File

@ -46,4 +46,45 @@ public class AccountMngDAO extends EgovComAbstractDAO {
param.put("alertBefore", Integer.valueOf(alertBefore)); param.put("alertBefore", Integer.valueOf(alertBefore));
return list("AccountMngDAO.selectDormantAlertTargets", param); return list("AccountMngDAO.selectDormantAlertTargets", param);
} }
/** #11 현황 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수). 검색조건 무관 전체 집계. */
public DormantAccountSummaryVO selectDormantSummary() {
return (DormantAccountSummaryVO) selectByPk("AccountMngDAO.selectDormantSummary", null);
}
/** 단건 파생 계정상태(N/W/L/D). 없는 계정이면 null. */
public String selectDormantAccountState(String accountId) {
return (String) selectByPk("AccountMngDAO.selectDormantAccountState", accountId);
}
/** 단건 미접속 일수. 없는 계정이면 0. */
public int selectDormantAccountDays(String accountId) {
Object days = selectByPk("AccountMngDAO.selectDormantAccountDays", accountId);
return days == null ? 0 : ((Integer) days).intValue();
}
/** 휴면행 상태(DIAPAUSE_STAT). 행이 없으면 null, 값이 비어있으면 빈 문자열. */
public String selectDiapauseStatEmp(String accountId) {
return (String) selectByPk("AccountMngDAO.selectDiapauseStatEmp", accountId);
}
/** #11-2 잠금(휴면 확정) — 기존 휴면행 승격. */
public int updateDiapauseConfirmEmp(String accountId) {
return update("AccountMngDAO.updateDiapauseConfirmEmp", accountId);
}
/** #11-2 잠금(휴면 확정) — 휴면행이 없는 계정의 신규 적재. */
public void insertDiapauseConfirmEmp(String accountId) {
insert("AccountMngDAO.insertDiapauseConfirmEmp", accountId);
}
/** #11-2 말소 — EMPLYR_STTUS_CODE='D'. 이미 말소면 0건. */
public int updateEmplyrExpire(String accountId) {
return update("AccountMngDAO.updateEmplyrExpire", accountId);
}
/** #11-2 조치 이력 적재. */
public void insertAccountActionHistory(AccountActionHistoryVO vo) {
insert("AccountMngDAO.insertAccountActionHistory", vo);
}
} }

View File

@ -21,6 +21,9 @@ public class AccountMngPolicyVO implements Serializable {
/** 휴면 미설정 시 기준일수 fallback(#9). */ /** 휴면 미설정 시 기준일수 fallback(#9). */
public static final int DEFAULT_DORMANT_DAY = 365; public static final int DEFAULT_DORMANT_DAY = 365;
/** 접속기록 보관기간 법정 하한(일) — 2년. SFR-004 "최소 2년 이상 보관". */
public static final int MIN_ACCESS_LOG_KEEP_DAY = 730;
private int policyId = 1; private int policyId = 1;
/** 휴면 전환 기준 미접속 일수(#9). */ /** 휴면 전환 기준 미접속 일수(#9). */
private int dormantDay = DEFAULT_DORMANT_DAY; private int dormantDay = DEFAULT_DORMANT_DAY;
@ -30,12 +33,12 @@ public class AccountMngPolicyVO implements Serializable {
private int alertD2 = 7; private int alertD2 = 7;
/** 사전알림 3차(D-Day, 기본 0). */ /** 사전알림 3차(D-Day, 기본 0). */
private int alertD3 = 0; private int alertD3 = 0;
/** 휴면/탈퇴 후 삭제 보존기간(일). */ /** 휴면 후 삭제 보존기간(일) — 개인정보취급자(관리자, EMP) 계정 한정. 일반회원은 탈퇴 즉시 삭제되어 대상이 아니다. */
private int deletePreserveDay = 365; private int deletePreserveDay = 365;
/** 배치 주기(DAILY/WEEKLY/MONTHLY). */ /** 배치 주기(DAILY/WEEKLY/MONTHLY). */
private String batchCycle = "DAILY"; private String batchCycle = "DAILY";
/** /**
* 장기 미사용 계정 파기 배치 사용여부(#12). Y:사용 N:미사용. * 장기 미사용 관리자(개인정보취급자) 계정 파기 배치 사용여부(#12). Y:사용 N:미사용.
* 기본 'N' — 관리자가 정책 화면에서 명시적으로 켜야 파기가 수행된다(되돌릴 수 없는 처리). * 기본 'N' — 관리자가 정책 화면에서 명시적으로 켜야 파기가 수행된다(되돌릴 수 없는 처리).
*/ */
private String purgeUseYn = "N"; private String purgeUseYn = "N";
@ -44,6 +47,16 @@ public class AccountMngPolicyVO implements Serializable {
* 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기한다. * 경과분은 BatchScheduler.purgeExpiredLoginFailHistoryScheduler 가 파기한다.
*/ */
private int loginHistKeepDay = 730; private int loginHistKeepDay = 730;
/**
* 개인정보 접속기록 보관기간(일). 제안서 slide15 "보관 기간 — 관리자 설정값 미존재 → 신규 구축".
*
* <p><b>법정 하한 {@link #MIN_ACCESS_LOG_KEEP_DAY}일.</b> 제안요청서 SFR-004 는
* "접속기록은 최소 2년 이상 보관 가능하여야 한다"고 못박고 있다. 관리자가 임의로 더 짧게
* 줄이면 요건 위반이 되므로 화면과 파기 배치 양쪽에서 하한을 강제한다 —
* 화면만 막으면 정책 행을 직접 고치는 경로가 남는다.</p>
*/
private int accessLogKeepDay = MIN_ACCESS_LOG_KEEP_DAY;
/** 사전알림 관리자 수신 이메일. */ /** 사전알림 관리자 수신 이메일. */
private String adminEmail; private String adminEmail;
private String updId; private String updId;
@ -65,6 +78,9 @@ public class AccountMngPolicyVO implements Serializable {
public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; } public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; }
public String getPurgeUseYn() { return purgeUseYn; } public String getPurgeUseYn() { return purgeUseYn; }
public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; } public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; }
public int getAccessLogKeepDay() { return accessLogKeepDay; }
public void setAccessLogKeepDay(int accessLogKeepDay) { this.accessLogKeepDay = accessLogKeepDay; }
public int getLoginHistKeepDay() { return loginHistKeepDay; } public int getLoginHistKeepDay() { return loginHistKeepDay; }
public void setLoginHistKeepDay(int loginHistKeepDay) { this.loginHistKeepDay = loginHistKeepDay; } public void setLoginHistKeepDay(int loginHistKeepDay) { this.loginHistKeepDay = loginHistKeepDay; }
public String getAdminEmail() { return adminEmail; } public String getAdminEmail() { return adminEmail; }

View File

@ -12,4 +12,26 @@ public interface DormantAccountStatusService {
/** 현황 전체 건수. */ /** 현황 전체 건수. */
int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception; int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception;
/** 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수 + 정책 기준일수). */
DormantAccountSummaryVO selectDormantSummary() throws Exception;
/**
* 선택 계정 잠금(휴면 확정). 자동 배치가 하는 확정을 관리자가 앞당겨 수행한다.
*
* @param accountIds 대상 계정ID 배열
* @param resn 조치 사유(필수, 이력 적재)
* @param actorId 조치자(관리자) ID — 본인 계정은 대상에서 제외한다
* @param actorIp 조치자 IP
* @return 실제 처리 건수
*/
int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception;
/**
* 선택 계정 말소. 잠금(휴면·잠금) 상태인 계정만 대상으로 한다
* ("잠금 후에도 미사용 시 관리자 승인으로 말소" 정책).
*
* @return 실제 처리 건수
*/
int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception;
} }

View File

@ -7,14 +7,31 @@ import javax.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
* 장기 미사용 계정 현황 조회 서비스 구현 (#11). * 장기 미사용 계정 현황 조회·조치 서비스 구현 (#11).
*
* <p>조회(목록·요약)와 현황 화면의 조치(잠금=휴면 확정 / 말소)를 담당한다.
* 메서드 전체가 context-transaction.xml 의 {@code egovframework.com..*Impl.*} 포인트컷에
* 걸려 트랜잭션 경계 안에서 실행되므로, 다건 조치 중 예외가 나면 그 요청 전체가 롤백된다.</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.07.21 URP 최초 생성 (#11 현황 조회)
* 2026.08.10 URP 시안 반영 — 요약 집계 + 잠금·말소 조치(이력 적재)
* </pre>
*/ */
@Service("dormantAccountStatusService") @Service("dormantAccountStatusService")
public class DormantAccountStatusServiceImpl implements DormantAccountStatusService { public class DormantAccountStatusServiceImpl implements DormantAccountStatusService {
/** 조치 사유 최대 길이 — ACCOUNT_ACTION_HISTORY.ACTION_RESN VARCHAR(500). */
private static final int MAX_RESN_LEN = 500;
@Resource(name = "accountMngDAO") @Resource(name = "accountMngDAO")
private AccountMngDAO accountMngDAO; private AccountMngDAO accountMngDAO;
@Resource(name = "accountMngPolicyService")
private AccountMngPolicyService accountMngPolicyService;
@Override @Override
public List<DormantAccountStatusVO> selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception { public List<DormantAccountStatusVO> selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception {
int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex(); int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex();
@ -30,4 +47,106 @@ public class DormantAccountStatusServiceImpl implements DormantAccountStatusServ
public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception { public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception {
return accountMngDAO.selectDormantStatusListCount(searchVO); return accountMngDAO.selectDormantStatusListCount(searchVO);
} }
@Override
public DormantAccountSummaryVO selectDormantSummary() throws Exception {
DormantAccountSummaryVO summary = accountMngDAO.selectDormantSummary();
if (summary == null) {
summary = new DormantAccountSummaryVO();
}
// 카드 라벨(60일↑/90일↑)에 쓰이는 기준일수는 정책값에서 가져온다(하드코딩 없음, #9/#13).
AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy();
int dormantDay = policy == null ? AccountMngPolicyVO.DEFAULT_DORMANT_DAY : policy.getDormantDay();
int alertD1 = policy == null ? 0 : policy.getAlertD1();
summary.setDormantDay(dormantDay);
// 사전알림 1차 시점부터 '휴면예정'으로 본다. 정책이 뒤집혀도 음수가 되지 않게 하한 0.
summary.setWarnDay(Math.max(dormantDay - alertD1, 0));
summary.setBatchCycle(policy == null ? null : policy.getBatchCycle());
summary.setPurgeUseYn(policy == null ? null : policy.getPurgeUseYn());
return summary;
}
@Override
public int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception {
return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_LOCK);
}
@Override
public int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception {
return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_EXPIRE);
}
/**
* 잠금·말소 공통 처리. 대상 계정별로 상태를 다시 읽어 조치 가능 여부를 서버에서 판정한다
* (화면 목록이 오래됐거나 요청이 위조된 경우를 걸러낸다).
*/
private int processAction(String[] accountIds, String resn, String actorId, String actorIp, String actionTy)
throws Exception {
if (accountIds == null || accountIds.length == 0) {
return 0;
}
String reason = resn == null ? "" : resn.trim();
if (reason.length() > MAX_RESN_LEN) {
reason = reason.substring(0, MAX_RESN_LEN);
}
int done = 0;
for (int i = 0; i < accountIds.length; i++) {
String accountId = accountIds[i] == null ? "" : accountIds[i].trim();
if (accountId.length() == 0) {
continue;
}
// 자기 계정은 조치할 수 없다(관리자 자신을 잠가 운영이 막히는 사고 방지).
if (accountId.equals(actorId)) {
continue;
}
String state = accountMngDAO.selectDormantAccountState(accountId);
if (state == null || "D".equals(state)) {
continue; // 없는 계정이거나 이미 말소됨
}
boolean changed;
if (AccountActionHistoryVO.ACTION_LOCK.equals(actionTy)) {
changed = lockOne(accountId);
} else {
// 말소는 '잠금 후에도 미사용'인 계정만 — 잠기지 않은 계정은 건너뛴다.
if (!"L".equals(state)) {
continue;
}
changed = accountMngDAO.updateEmplyrExpire(accountId) > 0;
}
if (!changed) {
continue;
}
AccountActionHistoryVO hist = new AccountActionHistoryVO();
hist.setTrgetId(accountId);
hist.setTrgetSe("EMP");
hist.setActionTy(actionTy);
hist.setActionUserId(actorId);
hist.setActionResn(reason);
hist.setActionIp(actorIp);
hist.setNoConectDay(accountMngDAO.selectDormantAccountDays(accountId));
accountMngDAO.insertAccountActionHistory(hist);
done++;
}
return done;
}
/**
* 휴면행이 있으면 'P'로 승격, 없으면 신규 적재.
* 이미 확정('P')이면 아무것도 하지 않고 false — 같은 계정을 다시 눌러도 이력이 중복되지 않는다.
*/
private boolean lockOne(String accountId) {
String stat = accountMngDAO.selectDiapauseStatEmp(accountId);
if (stat == null) {
accountMngDAO.insertDiapauseConfirmEmp(accountId);
return true;
}
if ("P".equals(stat.trim())) {
return false;
}
return accountMngDAO.updateDiapauseConfirmEmp(accountId) > 0;
}
} }

View File

@ -23,9 +23,18 @@ public class DormantAccountStatusVO implements Serializable {
private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE) private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE)
private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd) private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd)
private String mberTyCode; // 회원유형(EMP/GNR) private String mberTyCode; // 회원유형(EMP/GNR)
private String roleNm; // 역할명(COMTNAUTHORINFO.AUTHOR_NM, 복수는 쉼표 연결)
/**
* 파생 계정상태 — 정책값 기준으로 SQL이 계산한다.
* N:사용 W:휴면예정(기준일수-1차알림일 경과) L:휴면·잠금(기준일수 경과 또는 휴면확정) D:말소
* EMPLYR_STTUS_CODE(sttusCode) 원본과 달리 '미접속 일수'를 함께 반영한 화면 표시용 상태다.
*/
private String acntState;
// ---- 검색조건(#11) ---- // ---- 검색조건(#11) ----
private int minNoConnectDays = 0; // 미접속일수 하한 필터 private int minNoConnectDays = 0; // 미접속일수 하한 필터
private String searchKeyword; // 취급자명 또는 계정ID 통합검색
private String searchState; // 계정상태 필터(N/W/L/D) — 파생 acntState 대상
// ---- 페이징 ---- // ---- 페이징 ----
private int pageIndex = 1; private int pageIndex = 1;
@ -53,8 +62,16 @@ public class DormantAccountStatusVO implements Serializable {
public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; } public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; }
public String getMberTyCode() { return mberTyCode; } public String getMberTyCode() { return mberTyCode; }
public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; } public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; }
public String getRoleNm() { return roleNm; }
public void setRoleNm(String roleNm) { this.roleNm = roleNm; }
public String getAcntState() { return acntState; }
public void setAcntState(String acntState) { this.acntState = acntState; }
public int getMinNoConnectDays() { return minNoConnectDays; } public int getMinNoConnectDays() { return minNoConnectDays; }
public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = minNoConnectDays; } public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = minNoConnectDays; }
public String getSearchKeyword() { return searchKeyword; }
public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; }
public String getSearchState() { return searchState; }
public void setSearchState(String searchState) { this.searchState = searchState; }
public int getPageIndex() { return pageIndex; } public int getPageIndex() { return pageIndex; }
public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
public int getRecordCountPerPage() { return recordCountPerPage; } public int getRecordCountPerPage() { return recordCountPerPage; }

View File

@ -0,0 +1,56 @@
package egovframework.com.cmm.privacy.account;
import java.io.Serializable;
/**
* 장기 미사용 계정 현황 요약 VO (#11 화면 상단 요약 카드).
*
* <p>취급자 전체 계정수와 휴면예정·휴면/잠금·말소 건수를 한 번의 조회로 담는다.
* 카드 라벨에 쓰이는 기준일수(dormantDay/warnDay)와 정책 적용상태는
* ACCOUNT_MNG_POLICY 값을 서비스 계층에서 채운다(하드코딩 없음, #9/#13 연계).</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (#11 시안 반영)
* </pre>
*/
public class DormantAccountSummaryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 취급자 전체 계정수. */
private int totCnt;
/** 휴면예정 건수(미접속 warnDay 이상). */
private int warnCnt;
/** 휴면·잠금 건수(미접속 dormantDay 이상 또는 휴면확정). */
private int lockCnt;
/** 말소 건수(EMPLYR_STTUS_CODE='D'). */
private int expiredCnt;
/** 휴면 전환(자동 잠금) 기준 미접속 일수 — ACCOUNT_MNG_POLICY.DORMANT_DAY. */
private int dormantDay;
/** 휴면예정 진입 기준 미접속 일수 — DORMANT_DAY - ALERT_D1. */
private int warnDay;
/** 배치 주기(DAILY/WEEKLY/MONTHLY). */
private String batchCycle;
/** 파기 배치 사용여부(Y/N). */
private String purgeUseYn;
public int getTotCnt() { return totCnt; }
public void setTotCnt(int totCnt) { this.totCnt = totCnt; }
public int getWarnCnt() { return warnCnt; }
public void setWarnCnt(int warnCnt) { this.warnCnt = warnCnt; }
public int getLockCnt() { return lockCnt; }
public void setLockCnt(int lockCnt) { this.lockCnt = lockCnt; }
public int getExpiredCnt() { return expiredCnt; }
public void setExpiredCnt(int expiredCnt) { this.expiredCnt = expiredCnt; }
public int getDormantDay() { return dormantDay; }
public void setDormantDay(int dormantDay) { this.dormantDay = dormantDay; }
public int getWarnDay() { return warnDay; }
public void setWarnDay(int warnDay) { this.warnDay = warnDay; }
public String getBatchCycle() { return batchCycle; }
public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; }
public String getPurgeUseYn() { return purgeUseYn; }
public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; }
}

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

@ -51,8 +51,8 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
// ② 대량조회(의심 접속자 목록) // ② 대량조회(의심 접속자 목록)
List<Map<String, Object>> mass = abnormalAccessDetectionDAO.selectMassQuery(p); List<Map<String, Object>> mass = abnormalAccessDetectionDAO.selectMassQuery(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass), results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass),
offenders(mass, "cnt", "건"))); offenders(mass, "cnt", "건")), mass));
// ③ 비정상시간대접속 // ③ 비정상시간대접속
int off = abnormalAccessDetectionDAO.countOffHours(p); int off = abnormalAccessDetectionDAO.countOffHours(p);
@ -60,13 +60,13 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
// ④ 계정공유의심(의심 접속자 목록) // ④ 계정공유의심(의심 접속자 목록)
List<Map<String, Object>> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p); List<Map<String, Object>> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing), results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing),
offenders(sharing, "ipCnt", "IP"))); offenders(sharing, "ipCnt", "IP")), sharing));
// ⑤ 인증실패반복(의심 접속자 목록) // ⑤ 인증실패반복(의심 접속자 목록)
List<Map<String, Object>> authFail = abnormalAccessDetectionDAO.selectRepeatedAuthFailure(p); List<Map<String, Object>> authFail = abnormalAccessDetectionDAO.selectRepeatedAuthFailure(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail), results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail),
offenders(authFail, "cnt", "회"))); offenders(authFail, "cnt", "회")), authFail));
return results; return results;
} }
@ -91,6 +91,15 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
return sb.length() == 0 ? "(이상징후 없음)" : sb.toString(); return sb.length() == 0 ? "(이상징후 없음)" : sb.toString();
} }
/**
* 대상자형 유형에 의심 대상 상세 행을 실어 준다(알림함 화면 #49 용).
* 요약 문자열({@code detail})은 종전 그대로라 리포트(#43) 동작에는 영향이 없다.
*/
private static AbnormalAccessResultVO withRows(AbnormalAccessResultVO vo, List<Map<String, Object>> rows) {
vo.setRows(rows);
return vo;
}
private static int size(List<Map<String, Object>> list) { private static int size(List<Map<String, Object>> list) {
return list == null ? 0 : list.size(); return list == null ? 0 : list.size();
} }

View File

@ -1,6 +1,8 @@
package egovframework.com.cmm.privacy.auditlog; package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
import java.util.Map;
/** /**
* 비정상 접근 탐지 결과 1유형 (#35). * 비정상 접근 탐지 결과 1유형 (#35).
@ -38,4 +40,16 @@ public class AbnormalAccessResultVO implements Serializable {
public String getDetail() { return detail; } public String getDetail() { return detail; }
public void setDetail(String detail) { this.detail = detail; } public void setDetail(String detail) { this.detail = detail; }
/**
* 의심 대상 상세 행(접속자ID + 건수/IP수). 대상자형 유형(대량조회·계정공유·인증실패반복)에만 있고,
* 건수형(비인가접근·비정상시간대)은 비어 있다 — 이 둘은 특정 대상자가 아니라 기간 전체의 건수다.
*
* <p>알림함 화면(#49)이 "누가 걸렸는지"를 요약 문자열에서 파싱하지 않도록 원본 행을 그대로 싣는다.
* 개인정보 본문은 담지 않는다(접속자ID·집계값뿐).</p>
*/
private List<Map<String, Object>> rows;
public List<Map<String, Object>> getRows() { return rows; }
public void setRows(List<Map<String, Object>> rows) { this.rows = rows; }
} }

View File

@ -0,0 +1,84 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) DAO.
*
* <p>개인정보보호 기능개선 사업(SFR-004-6 / SER-002). statement 는
* {@code PrivacyDownloadJob_SQL_Mysql.xml}(namespace {@code AccessLogDownloadJobDAO}).
* 신규 sqlMap 이므로 {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.</p>
*
* @since 2026-08-07
*/
@Repository("accessLogDownloadJobDAO")
public class AccessLogDownloadJobDAO extends EgovComAbstractDAO {
/** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */
public long insertJob(AccessLogDownloadJobVO vo) {
insert("AccessLogDownloadJobDAO.insertJob", vo);
return vo.getJobId();
}
public AccessLogDownloadJobVO selectJob(long jobId) {
return (AccessLogDownloadJobVO) selectByPk("AccessLogDownloadJobDAO.selectJob", Long.valueOf(jobId));
}
/** 요청자 본인의 최근 작업 목록. */
@SuppressWarnings("unchecked")
public List<AccessLogDownloadJobVO> selectJobListByRequester(String requesterId, int limitCnt) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("requesterId", requesterId);
param.put("limitCnt", Integer.valueOf(limitCnt));
return list("AccessLogDownloadJobDAO.selectJobListByRequester", param);
}
/** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */
public Long selectNextWaitingJobId() {
return (Long) selectByPk("AccessLogDownloadJobDAO.selectNextWaitingJobId", null);
}
/**
* 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다.
* (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.)
*/
public boolean claimJob(long jobId) {
return update("AccessLogDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1;
}
public void updateProgress(AccessLogDownloadJobVO vo) {
update("AccessLogDownloadJobDAO.updateProgress", vo);
}
public void updateSuccess(AccessLogDownloadJobVO vo) {
update("AccessLogDownloadJobDAO.updateSuccess", vo);
}
public void updateFail(AccessLogDownloadJobVO vo) {
update("AccessLogDownloadJobDAO.updateFail", vo);
}
public void updateDownloaded(long jobId) {
update("AccessLogDownloadJobDAO.updateDownloaded", Long.valueOf(jobId));
}
@SuppressWarnings("unchecked")
public List<AccessLogDownloadJobVO> selectExpiredJobs() {
return list("AccessLogDownloadJobDAO.selectExpiredJobs", null);
}
public void updatePurged(long jobId) {
update("AccessLogDownloadJobDAO.updatePurged", Long.valueOf(jobId));
}
/** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */
public int resetRunningJobs() {
return update("AccessLogDownloadJobDAO.resetRunningJobs", null);
}
}

View File

@ -0,0 +1,40 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 접속기록 CSV 비동기 다운로드 작업 서비스 (SFR-004-6 / SER-002).
*
* <p>다운로드를 '요청 → 백그라운드 생성 → 완료 후 내려받기'로 나눈다. 대용량 조건에서
* 전체를 메모리에 올린 뒤 응답에 싣던 방식의 메모리 압박·요청 타임아웃을 없애기 위함이다.</p>
*
* @since 2026-08-07
*/
public interface AccessLogDownloadJobService {
/**
* 다운로드 작업을 등록한다(즉시 반환). 사유는 필수다.
*
* @return 등록된 작업 ID
* @throws IllegalArgumentException 사유 미입력
*/
long requestJob(String requesterId, String requesterIp, String reason,
PersonalDataAccessLogSearchVO searchVO) throws Exception;
/** 요청자 본인의 최근 작업 목록(화면 표시·폴링용). */
List<AccessLogDownloadJobVO> selectMyJobs(String requesterId, int limitCnt) throws Exception;
/**
* 내려받기용 작업 조회 — <b>요청자 본인인지 재검증한다</b>.
* 작업ID를 바꿔치기해 남의 다운로드 파일을 가져가는 것을 막는다.
*
* @return 본인의 내려받기 가능한 작업. 아니면 null
*/
AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception;
/** 내려받기 성공 시 횟수·일시를 기록한다(다운로드 이력은 별도 적재). */
void markDownloaded(long jobId) throws Exception;
/** 만료된 작업 파일을 삭제하고 상태를 파기(X)로 정리한다. 파기 건수 반환. */
int purgeExpiredJobs() throws Exception;
}

View File

@ -0,0 +1,170 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.File;
import java.util.List;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* 접속기록 CSV 비동기 다운로드 작업 서비스 구현 (SFR-004-6 / SER-002).
*
* <p><b>보관 최소화:</b> 생성된 파일은 대량 개인정보 파일이라 오래 두지 않는다.
* {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.</p>
*
* <p><b>권한 재검증:</b> 내려받기는 <u>요청자 본인만</u> 가능하다. 작업ID는 연속 정수라
* 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.</p>
*
* @since 2026-08-07
*/
@Service("accessLogDownloadJobService")
public class AccessLogDownloadJobServiceImpl implements AccessLogDownloadJobService {
private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobServiceImpl.class);
@Resource(name = "accessLogDownloadJobDAO")
private AccessLogDownloadJobDAO jobDAO;
/**
* 생성 파일 보관일수. 대량 개인정보 파일이므로 짧게 잡는다(기본 3일).
* 접속기록 본체의 보관기간(730일)과는 무관한, '내려받기용 사본'의 수명이다.
*/
@Value("${Globals.privacyDownloadExpireDays:3}")
private int expireDays = 3;
@Override
public long requestJob(String requesterId, String requesterIp, String reason,
PersonalDataAccessLogSearchVO searchVO) throws Exception {
// 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다.
if (reason == null || reason.trim().length() == 0) {
throw new IllegalArgumentException("다운로드 사유는 필수입니다.");
}
if (requesterId == null || requesterId.trim().length() == 0) {
throw new IllegalArgumentException("요청자를 확인할 수 없습니다.");
}
if (searchVO == null) {
searchVO = new PersonalDataAccessLogSearchVO();
}
AccessLogDownloadJobVO job = new AccessLogDownloadJobVO();
job.setRequesterId(requesterId);
job.setRequesterIp(requesterIp);
job.setDlReason(cut(reason.trim(), 500));
job.setSearchCond(cut(summarizeCond(searchVO), 1000));
job.setCondConectId(searchVO.getConectId());
job.setCondJobSeCode(searchVO.getJobSeCode());
job.setCondTrgetId(searchVO.getTrgetId());
job.setCondConectIp(searchVO.getConectIp());
job.setCondDmlGubun(searchVO.getDmlGubun());
job.setCondProcessResult(searchVO.getProcessResult());
job.setCondFromDt(searchVO.getFromDt());
job.setCondToDt(searchVO.getToDt());
job.setExpireDays(expireDays < 1 ? 3 : expireDays);
long jobId = jobDAO.insertJob(job);
LOG.info("[개인정보/SFR-004-6] 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId
+ ", 조건=" + job.getSearchCond());
return jobId;
}
@Override
public List<AccessLogDownloadJobVO> selectMyJobs(String requesterId, int limitCnt) throws Exception {
if (requesterId == null || requesterId.trim().length() == 0) {
return java.util.Collections.emptyList();
}
return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt);
}
@Override
public AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception {
AccessLogDownloadJobVO job = jobDAO.selectJob(jobId);
if (job == null) {
return null;
}
// 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다.
if (requesterId == null || !requesterId.equals(job.getRequesterId())) {
LOG.warn("[개인정보/SFR-004-6] 타인 다운로드 시도 차단 — jobId=" + jobId
+ ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId);
return null;
}
if (!job.isDownloadable()) {
return null;
}
File f = new File(job.getFilePath());
if (!f.exists() || !f.isFile()) {
LOG.warn("[개인정보/SFR-004-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId);
return null;
}
return job;
}
@Override
public void markDownloaded(long jobId) throws Exception {
jobDAO.updateDownloaded(jobId);
}
@Override
public int purgeExpiredJobs() throws Exception {
List<AccessLogDownloadJobVO> expired = jobDAO.selectExpiredJobs();
int purged = 0;
if (expired != null) {
for (int i = 0; i < expired.size(); i++) {
AccessLogDownloadJobVO job = expired.get(i);
String path = job.getFilePath();
if (path != null && path.length() > 0) {
try {
File f = new File(path);
if (f.exists() && !f.delete()) {
// 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다.
LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId()
+ ", path=" + path + " (수동 삭제 필요)");
continue;
}
} catch (Exception e) {
LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e);
continue;
}
}
jobDAO.updatePurged(job.getJobId());
purged++;
}
}
LOG.info("[개인정보/SFR-004-6] 만료 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)");
return purged;
}
/** 검색조건을 개인정보 본문 없이 요약. */
private String summarizeCond(PersonalDataAccessLogSearchVO vo) {
StringBuilder sb = new StringBuilder();
appendCond(sb, "conectId", vo.getConectId());
appendCond(sb, "jobSeCode", vo.getJobSeCode());
appendCond(sb, "trgetId", vo.getTrgetId());
appendCond(sb, "conectIp", vo.getConectIp());
appendCond(sb, "dmlGubun", vo.getDmlGubun());
appendCond(sb, "processResult", vo.getProcessResult());
appendCond(sb, "fromDt", vo.getFromDt());
appendCond(sb, "toDt", vo.getToDt());
return sb.length() == 0 ? "(전체)" : sb.toString();
}
private void appendCond(StringBuilder sb, String key, String val) {
if (val != null && val.trim().length() > 0) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(key).append("=").append(val.trim());
}
}
private static String cut(String s, int max) {
if (s == null) {
return null;
}
return (s.length() > max) ? s.substring(0, max) : s;
}
}

View File

@ -0,0 +1,161 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 개인정보 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) VO.
*
* <p>개인정보보호 기능개선 사업(SFR-004-6 / SER-002) — 대용량 다운로드를 요청 즉시 응답하고
* 백그라운드에서 파일을 만들기 위한 작업 한 건.</p>
*
* <p><b>검색조건을 컬럼으로 보존하는 이유:</b> 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
* 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이라 접속기록 본문 미적재 원칙에
* 어긋나지 않는다.</p>
*
* @since 2026-08-07
*/
public class AccessLogDownloadJobVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 상태: 대기 */
public static final String ST_WAIT = "W";
/** 상태: 생성중 */
public static final String ST_RUNNING = "R";
/** 상태: 완료(내려받기 가능) */
public static final String ST_SUCCESS = "S";
/** 상태: 실패 */
public static final String ST_FAIL = "F";
/** 상태: 파기(파일 삭제됨) */
public static final String ST_PURGED = "X";
private long jobId;
private String requesterId;
private String requesterIp;
private String dlReason;
private String searchCond;
// 조건 재현용 — 프로퍼티명을 cond* 로 둔다.
// getFConectId 처럼 '소문자1자+대문자' 형태면 JavaBeans 인트로스펙션이 프로퍼티를
// 'FConectId' 로 해석해 iBatis 바인딩(#fConectId#)이 조용히 실패한다.
private String condConectId;
private String condJobSeCode;
private String condTrgetId;
private String condConectIp;
private String condDmlGubun;
private String condProcessResult;
private String condFromDt;
private String condToDt;
private String status;
private int totalCnt;
private int doneCnt;
private String fileNm;
private String filePath;
private long fileSize;
private String errMsg;
private int downloadCnt;
private String lastDlDt;
private String regDt;
private String startDt;
private String endDt;
private String expireDt;
/** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 화면에 알리기 위한 비영속 필드. */
private int expireDays;
public long getJobId() { return jobId; }
public void setJobId(long jobId) { this.jobId = jobId; }
public String getRequesterId() { return requesterId; }
public void setRequesterId(String requesterId) { this.requesterId = requesterId; }
public String getRequesterIp() { return requesterIp; }
public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; }
public String getDlReason() { return dlReason; }
public void setDlReason(String dlReason) { this.dlReason = dlReason; }
public String getSearchCond() { return searchCond; }
public void setSearchCond(String searchCond) { this.searchCond = searchCond; }
public String getCondConectId() { return condConectId; }
public void setCondConectId(String condConectId) { this.condConectId = condConectId; }
public String getCondJobSeCode() { return condJobSeCode; }
public void setCondJobSeCode(String condJobSeCode) { this.condJobSeCode = condJobSeCode; }
public String getCondTrgetId() { return condTrgetId; }
public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; }
public String getCondConectIp() { return condConectIp; }
public void setCondConectIp(String condConectIp) { this.condConectIp = condConectIp; }
public String getCondDmlGubun() { return condDmlGubun; }
public void setCondDmlGubun(String condDmlGubun) { this.condDmlGubun = condDmlGubun; }
public String getCondProcessResult() { return condProcessResult; }
public void setCondProcessResult(String condProcessResult) { this.condProcessResult = condProcessResult; }
public String getCondFromDt() { return condFromDt; }
public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; }
public String getCondToDt() { return condToDt; }
public void setCondToDt(String condToDt) { this.condToDt = condToDt; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public int getTotalCnt() { return totalCnt; }
public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; }
public int getDoneCnt() { return doneCnt; }
public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; }
public String getFileNm() { return fileNm; }
public void setFileNm(String fileNm) { this.fileNm = fileNm; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public long getFileSize() { return fileSize; }
public void setFileSize(long fileSize) { this.fileSize = fileSize; }
public String getErrMsg() { return errMsg; }
public void setErrMsg(String errMsg) { this.errMsg = errMsg; }
public int getDownloadCnt() { return downloadCnt; }
public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; }
public String getLastDlDt() { return lastDlDt; }
public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; }
public String getRegDt() { return regDt; }
public void setRegDt(String regDt) { this.regDt = regDt; }
public String getStartDt() { return startDt; }
public void setStartDt(String startDt) { this.startDt = startDt; }
public String getEndDt() { return endDt; }
public void setEndDt(String endDt) { this.endDt = endDt; }
public String getExpireDt() { return expireDt; }
public void setExpireDt(String expireDt) { this.expireDt = expireDt; }
public int getExpireDays() { return expireDays; }
public void setExpireDays(int expireDays) { this.expireDays = expireDays; }
/** 진행률(%). 총건수 미확정이면 0. */
public int getProgressPct() {
if (totalCnt <= 0) {
return ST_SUCCESS.equals(status) ? 100 : 0;
}
long pct = (long) doneCnt * 100L / totalCnt;
return (pct > 100L) ? 100 : (int) pct;
}
/** 내려받기 가능 여부(완료 + 파일 보유). */
public boolean isDownloadable() {
return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0;
}
}

View File

@ -0,0 +1,357 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import egovframework.com.utl.sim.service.EgovFileScrty;
/**
* 접속기록 CSV 비동기 다운로드 워커 (SFR-004-6 / SER-002).
*
* <p>대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. <b>단일 워커 스레드</b>다 —
* 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.</p>
*
* <p><b>메모리를 쓰지 않는 방식:</b> 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
* 끊어 조회하며 곧바로 파일에 쓴다. 100만 건이어도 상주 메모리는 한 청크뿐이다.
* 이것이 이 작업의 핵심이며, 종전 동기 다운로드의 OOM 위험을 없앤다.</p>
*
* <p><b>파일은 암호화해 둔다:</b> 생성 결과는 '대량 개인정보 파일'이다. 평문 CSV 를 디스크에
* 남기지 않기 위해 {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지운다.
* 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 <b>웹 루트 밖</b>이어야 한다 —
* webapps 하위에 두면 URL 직접 접근으로 무인증 유출이 된다.</p>
*
* <p><b>기동 시 고아 복구:</b> 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
* R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).</p>
*
* @since 2026-08-07
*/
@Component("accessLogDownloadJobWorker")
public class AccessLogDownloadJobWorker {
private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobWorker.class);
/** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */
private static final int CHUNK_ROWS = 2000;
/** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */
private static final long IDLE_SLEEP_MS = 3000L;
/** 예외 발생 후 폭주 방지 대기(ms). */
private static final long ERROR_SLEEP_MS = 10000L;
private static final String CSV_HEADER =
"로그번호,접속일시,접속자ID,처리자구분,접속IP,수행업무코드,수행업무명,처리구분,처리대상ID,처리대상범위,처리결과,무결성해시";
@Resource(name = "accessLogDownloadJobDAO")
private AccessLogDownloadJobDAO jobDAO;
@Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService searchService;
/**
* 생성 파일 저장 디렉터리. <b>반드시 웹 루트(webapps) 밖</b>이어야 한다.
* 미설정 시 기동 로그에 경고를 남기고 워커를 띄우지 않는다 — 임의 경로에
* 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
*/
@Value("${Globals.privacyDownloadPath:}")
private String downloadPath;
private volatile boolean running = false;
private volatile Thread worker;
@PostConstruct
public void start() {
// 저장경로가 없거나 만들 수 없어도 워커는 띄운다.
// 종전에는 여기서 return 해 워커를 아예 기동하지 않았고, 그 결과 요청은 W 로 정상 등록되지만
// 집어갈 주체가 없어 화면이 '대기'에서 영원히 멈췄다(사용자에겐 아무 단서도 보이지 않았다).
// 게다가 화면 폴링이 그 상태를 5초마다 새로고침하며 접속기록을 자가증식시켰다.
// 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다 —
// 사유가 화면에 뜨고, 경로가 나중에 생기면 재기동 없이 다음 작업부터 정상 처리된다.
try {
ensureDir();
LOG.info("[개인정보/SFR-004-6] 저장경로 확인 완료 — " + downloadPath);
} catch (Exception e) {
LOG.error("[개인정보/SFR-004-6] 저장경로를 확보하지 못했다 — " + e.getMessage()
+ " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. "
+ "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/).");
}
try {
int reset = jobDAO.resetRunningJobs();
if (reset > 0) {
LOG.warn("[개인정보/SFR-004-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다.");
}
} catch (Exception e) {
LOG.error("[개인정보/SFR-004-6] 고아 작업 복구 실패(계속 진행)", e);
}
running = true;
Thread t = new Thread(new Runnable() {
public void run() {
loop();
}
}, "pdal-download-worker");
t.setDaemon(true);
worker = t;
t.start();
LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath
+ ", 청크=" + CHUNK_ROWS + "행");
}
@PreDestroy
public void stop() {
running = false;
Thread t = worker;
if (t != null) {
t.interrupt();
}
}
/** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */
private void loop() {
while (running) {
try {
Long jobId = jobDAO.selectNextWaitingJobId();
if (jobId == null) {
Thread.sleep(IDLE_SLEEP_MS);
continue;
}
if (!jobDAO.claimJob(jobId.longValue())) {
// 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다.
continue;
}
process(jobId.longValue());
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
} catch (Throwable t) {
LOG.error("[개인정보/SFR-004-6] 워커 루프 예외(계속)", t);
try {
Thread.sleep(ERROR_SLEEP_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 종료.");
}
/** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */
private void process(long jobId) {
AccessLogDownloadJobVO job = null;
File plain = null;
File enc = null;
try {
job = jobDAO.selectJob(jobId);
if (job == null) {
return;
}
PersonalDataAccessLogSearchVO searchVO = toSearchVO(job);
int total = searchService.selectAccessLogListTotCnt(searchVO);
job.setTotalCnt(total);
job.setDoneCnt(0);
jobDAO.updateProgress(job);
// 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다.
// 확보 불가면 예외가 나 아래 catch 에서 이 작업이 '실패'로 확정된다(무한 대기 방지).
File dir = ensureDir();
String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String baseNm = "개인정보접속기록_" + stamp + "_" + jobId;
plain = new File(dir, baseNm + ".csv.tmp");
enc = new File(dir, baseNm + ".csv.enc");
int written = writeCsv(plain, searchVO, total, job);
// 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제.
if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) {
throw new IllegalStateException("파일 암호화 실패");
}
deleteQuietly(plain);
plain = null;
job.setDoneCnt(written);
job.setTotalCnt(total);
job.setFileNm(baseNm + ".csv");
job.setFilePath(enc.getAbsolutePath());
job.setFileSize(enc.length());
jobDAO.updateSuccess(job);
LOG.info("[개인정보/SFR-004-6] 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, "
+ enc.length() + "byte");
} catch (Throwable t) {
LOG.error("[개인정보/SFR-004-6] 다운로드 파일 생성 실패 — jobId=" + jobId, t);
deleteQuietly(plain);
deleteQuietly(enc);
try {
AccessLogDownloadJobVO fail = (job != null) ? job : new AccessLogDownloadJobVO();
fail.setJobId(jobId);
fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500));
jobDAO.updateFail(fail);
} catch (Exception e) {
LOG.error("[개인정보/SFR-004-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e);
}
}
}
/**
* 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다).
*
* @return 기록한 행 수
*/
private int writeCsv(File target, PersonalDataAccessLogSearchVO searchVO, int total,
AccessLogDownloadJobVO job) throws Exception {
BufferedWriter w = null;
int written = 0;
try {
FileOutputStream fos = new FileOutputStream(target);
// UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일).
fos.write(0xEF); fos.write(0xBB); fos.write(0xBF);
w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8"));
w.write(CSV_HEADER);
w.newLine();
int page = 1;
while (written < total) {
searchVO.setPageIndex(page);
searchVO.setRecordCountPerPage(CHUNK_ROWS);
List<PersonalDataAccessLogVO> chunk = searchService.selectAccessLogList(searchVO);
if (chunk == null || chunk.isEmpty()) {
break;
}
for (int i = 0; i < chunk.size(); i++) {
w.write(toCsvLine(chunk.get(i)));
w.newLine();
written++;
}
w.flush();
// 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다.
job.setDoneCnt(written);
job.setTotalCnt(total);
try {
jobDAO.updateProgress(job);
} catch (Exception e) {
// 진행률은 부가정보다 — 실패해도 생성은 계속한다.
LOG.warn("[개인정보/SFR-004-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId());
}
page++;
}
w.flush();
} finally {
if (w != null) {
try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ }
}
}
return written;
}
private String toCsvLine(PersonalDataAccessLogVO v) {
StringBuilder sb = new StringBuilder(256);
sb.append(v.getLogId()).append(',')
.append(csv(v.getConectDt())).append(',')
.append(csv(v.getConectId())).append(',')
.append(csv(v.getUserSe())).append(',')
.append(csv(v.getConectIp())).append(',')
.append(csv(v.getJobSeCode())).append(',')
.append(csv(v.getJobSeNm())).append(',')
.append(csv(dmlLabel(v.getDmlGubun()))).append(',')
.append(csv(v.getTrgetId())).append(',')
.append(csv(v.getTrgetCn())).append(',')
.append(csv(resultLabel(v.getProcessResult()))).append(',')
.append(csv(v.getHashVal()));
return sb.toString();
}
private PersonalDataAccessLogSearchVO toSearchVO(AccessLogDownloadJobVO job) {
PersonalDataAccessLogSearchVO vo = new PersonalDataAccessLogSearchVO();
vo.setConectId(job.getCondConectId());
vo.setJobSeCode(job.getCondJobSeCode());
vo.setTrgetId(job.getCondTrgetId());
vo.setConectIp(job.getCondConectIp());
vo.setDmlGubun(job.getCondDmlGubun());
vo.setProcessResult(job.getCondProcessResult());
vo.setFromDt(job.getCondFromDt());
vo.setToDt(job.getCondToDt());
return vo;
}
/**
* 저장 디렉터리를 확보한다(없으면 생성).
*
* <p>실패는 <b>예외로 알린다</b>. 조용히 null 을 돌려주면 호출측이 그냥 넘어가고,
* 그 결과가 '영원한 대기'였다. 메시지는 그대로 작업의 실패사유(ERR_MSG)로 화면에 노출되므로
* 운영자가 원인을 바로 알 수 있게 경로를 포함해 적는다.</p>
*/
private File ensureDir() {
if (downloadPath == null || downloadPath.trim().length() == 0) {
throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)");
}
File dir = new File(downloadPath.trim());
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath());
}
if (!dir.isDirectory()) {
throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath());
}
if (!dir.canWrite()) {
throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath());
}
return dir;
}
private void deleteQuietly(File f) {
if (f != null && f.exists()) {
try {
if (!f.delete()) {
LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath());
}
} catch (Exception ignore) {
LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath());
}
}
}
/** 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;
}
private static String cut(String s, int max) {
if (s == null) {
return null;
}
return (s.length() > max) ? s.substring(0, max) : s;
}
}

View File

@ -25,4 +25,21 @@ public interface AccessLogDownloadService {
*/ */
List<PersonalDataAccessLogVO> downloadAccessLog(String downloaderId, String downloaderIp, List<PersonalDataAccessLogVO> downloadAccessLog(String downloaderId, String downloaderIp,
String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception; String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception;
/**
* 다운로드 이력만 적재한다(조회 없이).
*
* <p>비동기 다운로드(SFR-004-6)는 파일을 백그라운드에서 미리 만들어 두므로, 실제로
* <b>내려받은 시점</b>에 이력을 남겨야 증빙이 맞다. 조회는 이미 끝나 있어 건수·조건을
* 작업 정보에서 그대로 받는다.</p>
*
* @param downloaderId 다운로드자 계정ID
* @param downloaderIp 다운로드자 접속 IP
* @param reason 다운로드 사유(요청 시 입력한 값)
* @param searchCond 검색조건 요약(개인정보 본문 미포함)
* @param resultCnt 내려받은 건수
* @throws Exception 적재 오류
*/
void recordDownloadHistory(String downloaderId, String downloaderIp, String reason,
String searchCond, int resultCnt) throws Exception;
} }

View File

@ -58,6 +58,18 @@ public class AccessLogDownloadServiceImpl implements AccessLogDownloadService {
return result; return result;
} }
@Override
public void recordDownloadHistory(String downloaderId, String downloaderIp, String reason,
String searchCond, int resultCnt) throws Exception {
AccessLogDownloadHistoryVO hist = new AccessLogDownloadHistoryVO();
hist.setDownloaderId(downloaderId);
hist.setDownloaderIp(downloaderIp);
hist.setDlReason(reason == null ? "" : reason.trim());
hist.setSearchCond(searchCond);
hist.setResultCnt(resultCnt);
downloadHistoryDAO.insertDownloadHistory(hist);
}
/** 검색조건을 개인정보 본문 없이 요약 문자열로. */ /** 검색조건을 개인정보 본문 없이 요약 문자열로. */
private String summarizeCond(PersonalDataAccessLogSearchVO vo) { private String summarizeCond(PersonalDataAccessLogSearchVO vo) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();

View File

@ -0,0 +1,102 @@
/**
* Class Name : AccessLogPurgeHistoryController.java
* Description : 개인정보 접속기록 파기이력 조회 화면 — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 파기 배치(#37) 이력의 화면 출력
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import javax.annotation.Resource;
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 egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.batch.AccessLogPartitionDAO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 접속기록 파기·증설 이력 조회 컨트롤러 (SER-002-2 / DBR-001-2).
*
* <p>보관기간(730일) 경과 파티션을 파기한 내역과, 미래 월 파티션을 미리 확보한 내역을 보여준다.
* 화면: {@code /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp}</p>
*
* <p><b>조회 전용이다.</b> 파기 이력 자체가 "접속기록을 규정대로 지웠고 그 외 경로로는 손대지
* 않았다"는 증빙이므로, 화면에서 이력을 고치거나 지울 수 있으면 증빙 가치가 사라진다.
* 등록·수정·삭제 엔드포인트를 두지 않는다.</p>
*
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이라 /mnt 하위라도 무인증 진입이
* 가능하므로 {@link #handlerUser()} 로 화면 단에서 다시 판정한다
* ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping}
* 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.</p>
*/
@Controller
public class AccessLogPurgeHistoryController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "accessLogPartitionDAO")
private AccessLogPartitionDAO accessLogPartitionDAO;
/**
* 파기이력 목록 화면.
* 뷰: /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp
*/
@RequestMapping(value = "/mnt/sec/purgeHistory/listView.do")
public String listView(@ModelAttribute("accessLogPurgeHistorySearchVO") AccessLogPurgeHistorySearchVO searchVO,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (searchVO.getPageIndex() < 1) {
searchVO.setPageIndex(1);
}
if (searchVO.getRecordCountPerPage() < 1) {
searchVO.setRecordCountPerPage(10);
}
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
int totalCount = accessLogPartitionDAO.selectPurgeHistoryListCount(searchVO);
searchVO.setTotalRecordCount(totalCount);
List<AccessLogPurgeHistoryVO> list = accessLogPartitionDAO.selectPurgeHistoryList(searchVO);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(totalCount);
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
return "site/mnt/sec/purgeHistory/list";
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 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

@ -0,0 +1,77 @@
/**
* Class Name : AccessLogPurgeHistorySearchVO.java
* Description : 접속기록 파기이력 검색조건 VO — 개인정보보호 기능개선 사업(SFR-004 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 파기이력 조회 검색조건 + 페이징.
*
* <p><b>종료일 보정:</b> 사용자가 {@code 2026-08-27} 처럼 날짜만 넣으면 그날 00:00:00 이 되어
* 당일 파기분이 통째로 빠진다. {@link #getToDtParam()} 가 날짜만 들어온 경우
* {@code 23:59:59} 를 붙여 당일을 포함시킨다 — 접속기록 조회화면
* ({@link PersonalDataAccessLogSearchVO})과 같은 규칙이라 두 화면의 기간 검색이 어긋나지 않는다.</p>
*/
public class AccessLogPurgeHistorySearchVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 유형 — DROP / ADD / 전체(빈값). */
private String purgeType;
/** 수행일시 시작(yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss). */
private String fromDt;
/** 수행일시 종료. */
private String toDt;
private int pageIndex = 1;
private int recordCountPerPage = 10;
/** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
private int firstIndex = 0;
private int totalRecordCount = 0;
public String getPurgeType() { return purgeType; }
public void setPurgeType(String purgeType) { this.purgeType = purgeType; }
public String getFromDt() { return fromDt; }
public void setFromDt(String fromDt) { this.fromDt = fromDt; }
public String getToDt() { return toDt; }
public void setToDt(String toDt) { this.toDt = toDt; }
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; }
/** 시작일시 바인딩값 — 날짜만 들어오면 00:00:00 을 붙인다. */
public String getFromDtParam() {
return isDateOnly(fromDt) ? fromDt + " 00:00:00" : fromDt;
}
/** 종료일시 바인딩값 — 날짜만 들어오면 당일 23:59:59 까지 포함시킨다. */
public String getToDtParam() {
return isDateOnly(toDt) ? toDt + " 23:59:59" : toDt;
}
private boolean isDateOnly(String v) {
return v != null && v.trim().length() == 10;
}
}

View File

@ -0,0 +1,60 @@
/**
* Class Name : AccessLogPurgeHistoryVO.java
* Description : 개인정보 접속기록 파기·증설 이력 VO — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 파기이력 조회 화면(#37 후속)
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* {@code PDAL_PURGE_HISTORY} 한 행 — 파기 배치(#37)가 수행한 파티션 DROP(파기)·ADD(선행증설) 내역.
*
* <p><b>이 이력이 증빙인 이유:</b> 접속기록 본문 테이블은 UPDATE/DELETE statement 자체를 두지 않고,
* 삭제는 오직 파티션 DROP 으로만 일어난다. 그래서 "언제 무엇이 지워졌는가"를 답할 수 있는 유일한
* 근거가 이 표다 — 보관기간 경과분을 실제로 파기했다는 사실(SER-002-2)과, 그 외 경로로는 지운 적이
* 없다는 사실을 함께 보인다.</p>
*/
public class AccessLogPurgeHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 이력 일련번호. */
private long purgeId;
/** 대상 파티션명(예: p202607). */
private String partitionName;
/** 파티션 상한 경계값(LESS THAN). */
private String boundaryDt;
/** 유형 — DROP:파기 / ADD:선행증설. */
private String purgeType;
/** 수행 일시. */
private String purgeDt;
/** 비고(보관기간·사유). */
private String note;
public long getPurgeId() { return purgeId; }
public void setPurgeId(long purgeId) { this.purgeId = purgeId; }
public String getPartitionName() { return partitionName; }
public void setPartitionName(String partitionName) { this.partitionName = partitionName; }
public String getBoundaryDt() { return boundaryDt; }
public void setBoundaryDt(String boundaryDt) { this.boundaryDt = boundaryDt; }
public String getPurgeType() { return purgeType; }
public void setPurgeType(String purgeType) { this.purgeType = purgeType; }
public String getPurgeDt() { return purgeDt; }
public void setPurgeDt(String purgeDt) { this.purgeDt = purgeDt; }
public String getNote() { return note; }
public void setNote(String note) { this.note = note; }
}

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;
@ -18,16 +19,28 @@ import org.springframework.stereotype.Component;
* 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001). * 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001).
* *
* <p><b>목적:</b> 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해 * <p><b>목적:</b> 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해
* 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}는 * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다(수집기는
* {@link #enqueue}만 호출하고 즉시 반환).</p> * {@link #enqueue}만 호출하고 즉시 반환).</p>
* *
* <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

@ -1,28 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) 조회 DAO.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.</p>
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
*/
@Repository("jobMappingDAO")
public class JobMappingDAO extends EgovComAbstractDAO {
/**
* 사용중(USE_AT='Y')인 업무유형 매핑 전체를 우선순위(SORT_ORDR) 오름차순으로 조회한다.
* (Resolver가 기동 시 1회 로드해 메모리 캐시)
*/
@SuppressWarnings("unchecked")
public List<JobMappingVO> selectJobMappingList() {
return list("JobMappingDAO.selectJobMappingList", null);
}
}

View File

@ -1,112 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Component;
/**
* 업무유형 매핑 해석기 (개발항목 #32).
*
* <p>PDAL_JOB_MAPPING 테이블을 메모리에 캐시하고, 접속기록 Aspect(#31)가 가로챈
* (대상클래스, 메서드명) 조합을 업무코드/업무명으로 해석한다.</p>
*
* <p>매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가
* 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 첫 매핑을 반환.
* 구체 규칙(낮은 SORT_ORDR)이 폴백('*', 높은 SORT_ORDR)보다 우선한다.</p>
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
*/
@Component("jobMappingResolver")
public class JobMappingResolver {
@Resource(name = "jobMappingDAO")
private JobMappingDAO jobMappingDAO;
/** 캐시(불변 스냅샷 참조 교체 방식). */
private volatile List<JobMappingVO> cache;
/**
* (대상클래스 단순명, 메서드명)에 매칭되는 업무유형 매핑을 반환한다.
* 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백).
*/
public JobMappingVO resolve(String targetSimpleClassName, String methodName) {
if (targetSimpleClassName == null || methodName == null) {
return null;
}
List<JobMappingVO> list = getCache();
for (int i = 0; i < list.size(); i++) {
JobMappingVO m = list.get(i);
if (classMatches(m.getTargetClass(), targetSimpleClassName)
&& methodMatches(m.getMethodPttrn(), methodName)) {
return m;
}
}
return null;
}
/** 매핑이 변경되면(관리자 화면 등) 호출해 캐시를 무효화한다. */
public void refresh() {
this.cache = null;
}
private List<JobMappingVO> getCache() {
List<JobMappingVO> local = this.cache;
if (local == null) {
synchronized (this) {
if (this.cache == null) {
List<JobMappingVO> loaded = null;
try {
loaded = jobMappingDAO.selectJobMappingList();
} catch (Exception e) {
// 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작).
loaded = null;
}
this.cache = (loaded == null) ? new ArrayList<JobMappingVO>() : loaded;
}
local = this.cache;
}
}
return local;
}
private boolean classMatches(String pattern, String simpleName) {
if (pattern == null) {
return false;
}
// 단순명 정확 일치, 또는 FQN 등록 시 '.단순명'으로 끝나는지
return pattern.equals(simpleName) || pattern.endsWith("." + simpleName);
}
private boolean methodMatches(String pattern, String methodName) {
if (pattern == null || "*".equals(pattern)) {
return true;
}
return methodName.startsWith(pattern);
}
/**
* 메서드명 접두로 처리구분(DML_GUBUN)을 유도한다.
* insert/regist/creat/add/save→I, update/modify/chang→U, delete/remov→D, 그 외 S(조회).
*/
public static String deriveDmlGubun(String methodName) {
if (methodName == null) {
return "S";
}
String m = methodName.toLowerCase();
if (m.startsWith("insert") || m.startsWith("regist") || m.startsWith("creat")
|| m.startsWith("add") || m.startsWith("save")) {
return "I";
}
if (m.startsWith("update") || m.startsWith("modify") || m.startsWith("chang")) {
return "U";
}
if (m.startsWith("delete") || m.startsWith("remov")) {
return "D";
}
return "S";
}
}

View File

@ -3,11 +3,19 @@ package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable; import java.io.Serializable;
/** /**
* 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO. * 개인정보 처리 업무유형 매핑 VO.
* *
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002). * <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002).
* 접속기록 수집({@link PersonalDataLogAspect})이 가로챈 서비스 메서드를 * 본래 PDAL_JOB_MAPPING 한 행을 담아, 폐기된 서비스 AOP 수집이 가로챈 서비스 메서드를
* 업무코드/업무명으로 해석하는 매핑 한 행.</p> * 업무코드/업무명으로 해석하는 데 썼다.</p>
*
* <p><b>[2026-08-11] 업무유형 매핑 관리 화면(메뉴 9100045) 삭제 후에도 이 클래스는 존치한다</b> —
* 접속기록 조회화면(9100044)의 '수행업무' 드롭다운 statement
* {@code PersonalDataAccessLogDAO.selectJobSeCodeList}(PrivacyAccessLog_SQL_Mysql.xml /
* _Oracle.xml)가 {@code resultClass} 로 이 클래스를 재사용한다. 실제 데이터는
* PDAL_JOB_MAPPING 이 아니라 PERSONAL_DATA_ACCESS_LOG 에서 직접 뽑으며
* jobSeCode/jobSeNm 두 프로퍼티만 채워진다. 삭제하면 iBatis2 특성상 기동 시가 아니라
* 해당 드롭다운 최초 조회 시점에 resultClass 로드 실패로 터진다.</p>
* *
* @author arko-privacy-auditlog (Fork B) * @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21 * @since 2026-07-21
@ -33,6 +41,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 +77,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,568 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.File;
import java.io.FileInputStream;
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.Calendar;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.com.utl.sim.service.EgovFileScrty;
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";
/** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */
private static final int MY_JOB_MAX = 100;
/** 감사 표시 — 이상(처리결과 차단·비인가). */
private static final String AUDIT_ABNORMAL = "A";
/** 감사 표시 — 점검(정상 처리였으나 이상징후 대상). */
private static final String AUDIT_WATCH = "W";
/** 감사 표시 계산 시 기간 미지정이면 적용할 기본 조회기간(일). */
private static final int AUDIT_DEFAULT_DAYS = 30;
/** 업무시간 시작(시). AbnormalAccessPolicy 기본값과 동일. */
private static final int OFF_HOURS_START = 9;
/** 업무시간 종료(시). */
private static final int OFF_HOURS_END = 18;
/** 정책 조회 실패 시 화면 안내에 쓸 접속기록 보관기간 기본값(일). */
private static final int ACCESS_LOG_RETENTION_DAYS = 730;
@Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@Resource(name = "accessLogDownloadService")
private AccessLogDownloadService accessLogDownloadService;
@Resource(name = "personalDataAccessLogSearchDAO")
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
@Resource(name = "accessLogDownloadJobService")
private AccessLogDownloadJobService accessLogDownloadJobService;
/** 보관기간 안내 문구용 정책 조회(SFR-004 관리자 설정). */
@Resource(name = "accountMngDAO")
private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO;
/**
* 화면 안내에 쓸 접속기록 보관기간. 관리자 설정값을 그대로 보여준다
* (파기 배치가 실제로 쓰는 값과 같아야 안내가 거짓말이 되지 않는다).
* 조회 실패 시 기본값으로 물러난다 — 안내 문구 때문에 목록이 막히면 안 된다.
*/
private int resolveRetentionDays() {
try {
egovframework.com.cmm.privacy.account.AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAccessLogKeepDay() > 0) {
return policy.getAccessLogKeepDay();
}
} catch (Exception e) {
// 무시 — 기본값 사용
}
return ACCESS_LOG_RETENTION_DAYS;
}
/** 감사 표시용 이상징후 탐지(#35). 목록 렌더 시 검색기간 기준으로 대조한다. */
@Resource(name = "abnormalAccessDetectionService")
private AbnormalAccessDetectionService abnormalAccessDetectionService;
/**
* 개인정보 접속기록 조회 화면(서버 렌더링).
* 뷰: /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 {
return renderList(searchVO, model);
}
/**
* 목록 렌더링.
*
* <p><b>화면은 이 하나다.</b> 제안요청서 과업내용 "다"가 요구하는 화면은
* "관리자용 로그 조회 화면" 한 개이며, 제안요약서 시안도 이 한 장만 그려져 있다.
* 처리기록(등록·수정·삭제) 조회는 별도 화면이 아니라 <b>처리구분 필터</b>로,
* 비정상 접근 탐지는 목록의 <b>감사 칸</b>으로 이 화면 안에서 제공한다.</p>
*/
private String renderList(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());
// 현재 검색조건 기준 조회/등록/수정/삭제 건수 — 목록을 넘겨보지 않고 처리행위를 판별하기 위함
model.addAttribute("dmlSummary", selectDmlSummary(searchVO));
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
model.addAttribute("dlJobList", selectMyDownloadJobs());
model.addAttribute("retentionDays", Integer.valueOf(resolveRetentionDays()));
// 시안의 "감사" 칸 — 목록 각 행에 이상/점검 표시를 채우고 기간 전체 탐지 요약을 함께 싣는다
markAudit(list, searchVO, model);
return "site/mnt/sec/accessLog/list";
}
/**
* 목록 각 행에 <b>감사 표시</b>를 채우고, 검색기간 전체의 탐지 요약을 모델에 싣는다.
*
* <p>판정 규칙(제안서 시안의 표기와 동일):</p>
* <ul>
* <li><b>이상(A)</b> — 처리결과가 실패·차단(F). 비인가 접근 시도가 여기 걸린다.</li>
* <li><b>점검(W)</b> — 처리는 정상이었으나 이상징후 대상인 경우. 대량조회·계정공유의심·
* 인증실패반복 대상자이거나, 업무시간 밖에 처리된 기록이다.</li>
* </ul>
*
* <p><b>기간을 반드시 좁힌다.</b> 탐지는 5개의 집계 질의라 기간이 열려 있으면 730일치를 훑는다.
* 검색조건에 기간이 없으면 최근 {@link #AUDIT_DEFAULT_DAYS}일로 잘라 계산한다 — 목록 한 장을
* 여는 비용이 감사 표시 때문에 커지면 안 된다(PSR-001).</p>
*
* <p><b>실패해도 목록은 보여준다.</b> 감사 표시는 부가 정보이므로, 탐지가 실패하면 표시 없이
* 넘어간다(목록 자체가 막히면 본래 목적인 접속기록 조회가 불가능해진다).</p>
*/
private void markAudit(List<PersonalDataAccessLogVO> list, PersonalDataAccessLogSearchVO searchVO,
ModelMap model) {
if (list == null || list.isEmpty()) {
return;
}
try {
SimpleDateFormat dtFmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String from = searchVO.getFromDtParam();
String to = searchVO.getToDtParam();
if (from == null || to == null) {
Calendar cal = Calendar.getInstance();
String computedTo = dtFmt.format(cal.getTime());
cal.add(Calendar.DAY_OF_MONTH, -AUDIT_DEFAULT_DAYS);
String computedFrom = dtFmt.format(cal.getTime());
from = (from == null) ? computedFrom : from;
to = (to == null) ? computedTo : to;
}
List<AbnormalAccessResultVO> results = abnormalAccessDetectionService.detect(from, to);
// 대상자형 유형(대량조회·계정공유·인증실패반복)의 접속자ID 집합 = 점검 대상
Set<String> watchIds = new HashSet<String>();
int detected = 0;
for (int i = 0; i < results.size(); i++) {
AbnormalAccessResultVO r = results.get(i);
detected += r.getCount();
List<Map<String, Object>> rows = r.getRows();
if (rows == null) {
continue;
}
for (int j = 0; j < rows.size(); j++) {
Object cid = rows.get(j).get("conectId");
if (cid != null) {
watchIds.add(cid.toString());
}
}
}
for (int i = 0; i < list.size(); i++) {
PersonalDataAccessLogVO vo = list.get(i);
if ("F".equals(vo.getProcessResult())) {
vo.setAuditFlag(AUDIT_ABNORMAL);
} else if ((vo.getConectId() != null && watchIds.contains(vo.getConectId()))
|| isOffHours(vo.getConectDt())) {
vo.setAuditFlag(AUDIT_WATCH);
}
}
model.addAttribute("auditDetected", Integer.valueOf(detected));
model.addAttribute("auditSummary", abnormalAccessDetectionService.summarize(from, to));
} catch (Exception e) {
// 감사 표시는 부가 정보다 — 실패해도 목록 조회 자체는 살린다.
model.addAttribute("auditError", Boolean.TRUE);
}
}
/**
* 업무시간(기본 09~18시) 밖 처리인지. 형식이 다르거나 파싱 불가면 false(과대표시 방지).
* 기준시각은 {@code AbnormalAccessPolicy} 기본값과 같다.
*/
private boolean isOffHours(String conectDt) {
if (conectDt == null || conectDt.length() < 13) {
return false;
}
try {
int hour = Integer.parseInt(conectDt.substring(11, 13));
return hour < OFF_HOURS_START || hour >= OFF_HOURS_END;
} catch (Exception e) {
return false;
}
}
/**
* 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
*
* <p>대용량 조건에서 전체를 메모리에 올려 응답에 싣던 방식(아래 {@link #download})은
* OOM·요청 타임아웃 위험이 있어, 파일 생성은 백그라운드 워커가 맡는다.
* 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.</p>
*/
@RequestMapping(value = "/mnt/sec/accessLog/downloadRequest.do")
public String downloadRequest(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
@RequestParam(value = "dlReason", required = false) String dlReason,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
try {
accessLogDownloadJobService.requestJob(user.getId(),
ClientIpResolver.getClientIp(request), dlReason, searchVO);
} catch (IllegalArgumentException e) {
model.addAttribute("dlMessage", e.getMessage());
} catch (Exception e) {
model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다.");
}
// 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다.
return listView(searchVO, model);
}
/**
* 완료된 다운로드 파일 내려받기 — <b>요청자 본인만</b> 가능하다.
*
* <p>저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
* 실제 내려받은 시점의 사유·건수 이력은 기존 {@code PDAL_DOWNLOAD_HISTORY} 에 적재한다.</p>
*/
@RequestMapping(value = "/mnt/sec/accessLog/downloadFile.do")
public void downloadFile(@RequestParam("jobId") long jobId,
HttpServletRequest request, HttpServletResponse response) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
return;
}
AccessLogDownloadJobVO job = accessLogDownloadJobService.selectDownloadableJob(jobId, user.getId());
if (job == null) {
// 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지).
response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다.");
return;
}
File plain = File.createTempFile("pdal-dl-", ".csv");
try {
if (!EgovFileScrty.decryptFile(job.getFilePath(), plain.getAbsolutePath())) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다.");
return;
}
String fileName = URLEncoder.encode(job.getFileNm(), "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");
response.setHeader("Content-Length", String.valueOf(plain.length()));
FileInputStream in = null;
OutputStream os = response.getOutputStream();
try {
in = new FileInputStream(plain);
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) > 0) {
os.write(buf, 0, len);
}
os.flush();
} finally {
if (in != null) {
try { in.close(); } catch (Exception ignore) { /* 무시 */ }
}
}
accessLogDownloadJobService.markDownloaded(jobId);
// 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화).
accessLogDownloadService.recordDownloadHistory(user.getId(), ClientIpResolver.getClientIp(request),
job.getDlReason(), job.getSearchCond(), job.getDoneCnt());
} finally {
// 복호화 임시본을 남기지 않는다.
if (plain.exists() && !plain.delete()) {
plain.deleteOnExit();
}
}
}
/**
* 내 다운로드 작업의 <b>상태만</b> 돌려주는 폴링 전용 엔드포인트(#47 후속).
*
* <p><b>왜 따로 뒀나:</b> 종전 화면은 진행률을 보려고 목록 화면 전체를 5초마다 다시 열었다.
* 그런데 이 화면은 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 <u>새로고침 한 번이
* 접속기록 1행</u>이 된다. 즉 폴링이 도는 동안 감사로그가 분당 12행씩 자기 자신을 기록했고,
* 이는 정기점검·비정상접근 탐지(SER-002)의 신호를 그대로 오염시킨다. 상태 조회를 별도 경로로
* 빼고 그 경로를 {@code excludePatterns} 에 넣어 <b>수집 대상에서 제외</b>한다.</p>
*
* <p><b>본문 원칙:</b> 개인정보는커녕 검색조건도 싣지 않는다. 작업ID·상태·진행률·건수와
* 실패사유뿐이며, 모두 <u>본인 작업</u>에 한정된다({@code selectMyJobs(본인ID)}).</p>
*
* <p>형식은 한 줄 1작업의 {@code |} 구분 텍스트다 — 이 스택(Spring 3.0.5)에 JSON 메시지
* 컨버터를 새로 물리지 않고도 확실히 동작하며, 파싱이 단순해 화면 쪽 실패 여지가 적다.</p>
*/
@RequestMapping(value = "/mnt/sec/accessLog/jobStatus.do")
public void jobStatus(HttpServletResponse response) throws Exception {
response.setContentType("text/plain; charset=UTF-8");
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
LoginVO user = handlerUser();
if (user == null) {
// 화면이 재로그인으로 유도하도록 상태코드만 알린다(본문 없음).
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
StringBuilder sb = new StringBuilder(256);
// 목록과 같은 범위를 봐야 한다 — 다르면 화면에 떠 있는데 폴링이 갱신하지 않는 행이 생긴다.
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) {
for (AccessLogDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|')
.append(nvl(j.getStatus())).append('|')
.append(j.getProgressPct()).append('|')
.append(j.getDoneCnt()).append('|')
.append(j.getTotalCnt()).append('|')
.append(oneLine(j.getErrMsg())).append('\n');
}
}
response.getWriter().write(sb.toString());
response.getWriter().flush();
}
/** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */
private String oneLine(String v) {
if (v == null) {
return "";
}
return v.replace('|', '/').replace('\r', ' ').replace('\n', ' ');
}
private String nvl(String v) {
return (v == null) ? "" : v;
}
/**
* 내 다운로드 작업 목록 — <b>만료 전 이력만</b>(2026-08-10 정책).
*
* <p>종전에는 '최근 10건'이라 이미 파일이 파기된 행까지 남아, 화면상 '완료'인데 눌러도
* 내려받아지지 않는 상태가 생겼다. 만료 기준으로 거르면 목록에 보이는 건 전부 실제로
* 내려받을 수 있는 것들이다. <b>테이블 행은 그대로 둔다</b> — 누가 언제 왜 개인정보를
* 반출 요청했는지는 감사 증적이라 지우지 않고, 화면에서만 감춘다.</p>
*
* <p>{@link #MY_JOB_MAX} 는 보관 3일 기준으로 사실상 걸리지 않지만, 단기 대량 요청 시
* 화면이 통째로 길어지는 것을 막는 안전장치다.</p>
*
* <p>조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.</p>
*/
private List<AccessLogDownloadJobVO> selectMyDownloadJobs() {
try {
LoginVO user = handlerUser();
if (user == null) {
return new ArrayList<AccessLogDownloadJobVO>();
}
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
return (list == null) ? new ArrayList<AccessLogDownloadJobVO>() : list;
} catch (Exception e) {
return new ArrayList<AccessLogDownloadJobVO>();
}
}
/**
* 접속기록 다운로드(CSV) — 동기 방식(기존 #42). 사유 필수 + 다운로드 이력 적재.
*
* <p><b>주의:</b> 조건 전체를 메모리에 올린다. 대용량은 위 {@link #downloadRequest} 비동기 경로를
* 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.</p>
*/
@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>();
}
}
/**
* 현재 검색조건 기준 처리구분별 건수 요약.
*
* <p>"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
* 판별하기 위한 값이다. 화면이 {@code ${dmlSummary.S}} 처럼 바로 꺼내 쓰도록
* S/I/U/D 네 키를 항상 채워 반환한다(해당 기록이 없으면 0).</p>
*
* <p>집계 실패가 화면 전체를 막지 않도록 0으로 채운 맵을 돌려준다(fail-safe).</p>
*/
private Map<String, Long> selectDmlSummary(PersonalDataAccessLogSearchVO searchVO) {
Map<String, Long> summary = new LinkedHashMap<String, Long>();
summary.put("S", Long.valueOf(0L));
summary.put("I", Long.valueOf(0L));
summary.put("U", Long.valueOf(0L));
summary.put("D", Long.valueOf(0L));
try {
List<Map<String, Object>> rows = personalDataAccessLogSearchDAO.selectAccessLogDmlSummary(searchVO);
if (rows == null) {
return summary;
}
for (Map<String, Object> row : rows) {
Object code = row.get("dmlGubun");
Object cnt = row.get("cnt");
if (code == null || !summary.containsKey(String.valueOf(code))) {
// S/I/U/D 외 값(레거시 적재분 등)은 요약에 넣지 않는다 — 목록에서 확인한다.
continue;
}
long v = (cnt instanceof Number) ? ((Number) cnt).longValue() : 0L;
summary.put(String.valueOf(code), Long.valueOf(v));
}
} catch (Exception e) {
// 요약은 보조 정보다 — 실패해도 목록은 떠야 한다.
return summary;
}
return summary;
}
/** 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

@ -1,6 +1,7 @@
package egovframework.com.cmm.privacy.auditlog; package egovframework.com.cmm.privacy.auditlog;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -30,4 +31,29 @@ 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);
}
/**
* 현재 검색조건 기준 처리구분(S/I/U/D)별 건수.
*
* <p>"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
* 확인하기 위한 집계다. 검색조건은 목록 조회와 동일하다.</p>
*
* @return {dmlGubun, cnt} 맵 목록. 해당 구분의 기록이 없으면 그 행 자체가 없다.
*/
@SuppressWarnings("unchecked")
public List<Map<String, Object>> selectAccessLogDmlSummary(PersonalDataAccessLogSearchVO searchVO) {
return list("PersonalDataAccessLogDAO.selectAccessLogDmlSummary", searchVO);
}
} }

View File

@ -10,7 +10,19 @@ import java.io.Serializable;
* 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며, * 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며,
* {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.</p> * {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.</p>
* *
* <p>화면(JSP)은 본 작업 범위 밖이다 — 백엔드까지 구현. (TODO: 화면 #47 연계)</p> * <p>화면: {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp} (#47 연계 완료)</p>
*
* <p><b>검색조건 정규화(2026-08-07).</b> 원본 입력값은 화면 재표시를 위해 그대로 두고,
* SQL 바인딩에는 아래 파생 getter를 쓴다. 이렇게 하면 MySQL/Oracle 두 sqlMap에
* 방언 차이({@code CONCAT} vs {@code ||})를 새로 만들지 않아도 된다.</p>
* <ul>
* <li>{@link #getToDtParam()} — <b>종료일 경계 결함 수정.</b> 종전에는 {@code CONECT_DT &lt;= '2026-08-07'}이
* {@code 2026-08-07 00:00:00}으로 해석돼 <b>종료일 하루가 통째로 누락</b>됐다(실측: 8/6~8/7 검색이
* 39건 중 38건만 반환). 날짜만 입력하면 {@code 23:59:59}를 붙여 당일을 포함시킨다.</li>
* <li>{@link #getConectIdLike()} 등 — 전방일치 검색. 종전 완전일치는 ID 전체를 정확히 알아야 해서
* SFR-004-6(조회·검색)의 실효가 없었다. 전방일치는 {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)}
* 인덱스를 그대로 탄다(중간일치 {@code %x%}는 인덱스를 못 써서 채택하지 않았다).</li>
* </ul>
* *
* @author arko-privacy-auditlog (Fork B - 5차) * @author arko-privacy-auditlog (Fork B - 5차)
* @since 2026-07-21 * @since 2026-07-21
@ -46,6 +58,7 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
/** 전체 레코드 수(조회 후 세팅) */ /** 전체 레코드 수(조회 후 세팅) */
private int totalRecordCount = 0; private int totalRecordCount = 0;
public String getConectId() { return conectId; } public String getConectId() { return conectId; }
public void setConectId(String conectId) { this.conectId = conectId; } public void setConectId(String conectId) { this.conectId = conectId; }
@ -81,4 +94,75 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
public int getTotalRecordCount() { return totalRecordCount; } public int getTotalRecordCount() { return totalRecordCount; }
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
// =====================================================================
// SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용).
// 원본 필드는 화면 재표시용으로 건드리지 않는다.
// =====================================================================
/** 날짜만 입력된 값인지(yyyy-MM-dd). */
private boolean isDateOnly(String v) {
return v != null && v.trim().length() == 10;
}
/**
* 조회 시작일시 바인딩값. 날짜만 입력하면 그날 {@code 00:00:00}부터.
* (시작 경계는 문자열 비교로도 동일하지만, 종료와 대칭을 맞춰 의도를 드러낸다.)
*/
public String getFromDtParam() {
if (fromDt == null) {
return null;
}
String v = fromDt.trim();
return isDateOnly(v) ? v + " 00:00:00" : v;
}
/**
* 조회 종료일시 바인딩값. 날짜만 입력하면 그날 {@code 23:59:59}까지 포함한다.
*
* <p>이 보정이 없으면 종료일 당일 기록이 전부 빠진다 — 화면 안내가 {@code yyyy-MM-dd} 입력을
* 권장하므로 반드시 밟게 되는 함정이었다.</p>
*/
public String getToDtParam() {
if (toDt == null) {
return null;
}
String v = toDt.trim();
return isDateOnly(v) ? v + " 23:59:59" : v;
}
/** LIKE 이스케이프 문자. SQL 의 {@code ESCAPE '!'} 와 반드시 짝을 이룬다. */
private static final String LIKE_ESCAPE = "!";
/**
* LIKE 전방일치 패턴으로 변환한다. 사용자가 입력한 {@code %}/{@code _}는 와일드카드가 아니라
* 문자 그대로 찾도록 이스케이프한다 — 안 하면 {@code %} 한 글자로 전건이 조회된다.
*
* <p>이스케이프 문자로 백슬래시가 아닌 {@code !}를 쓴다. MySQL 문자열 리터럴에서 {@code '\'}는
* 뒤따르는 따옴표를 이스케이프해 SQL 자체가 깨지므로 {@code ESCAPE '\'}를 쓸 수 없고,
* MySQL만 {@code '\\'}로 적으면 Oracle 쌍과 표현이 갈린다. {@code !}는 두 방언 모두 동일하다.</p>
*/
private String toPrefixLike(String v) {
if (v == null) {
return null;
}
String s = v.trim();
if (s.length() == 0) {
return null;
}
s = s.replace(LIKE_ESCAPE, LIKE_ESCAPE + LIKE_ESCAPE)
.replace("%", LIKE_ESCAPE + "%")
.replace("_", LIKE_ESCAPE + "_");
return s + "%";
}
/** 접속자ID 전방일치 패턴. */
public String getConectIdLike() { return toPrefixLike(conectId); }
/** 처리대상 식별자 전방일치 패턴. */
public String getTrgetIdLike() { return toPrefixLike(trgetId); }
/** 접속IP 전방일치 패턴 — 대역 검색용(예: {@code 192.168.} 입력 시 해당 대역 전체). */
public String getConectIpLike() { return toPrefixLike(conectIp); }
} }

View File

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

View File

@ -1,256 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import egovframework.com.cmm.LoginVO;
/**
* 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002).
*
* <p><b>한정 수집:</b> {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
* 접속 IP({@link ClientIpResolver}, #34)와 해시체인({@link HashChainService}, #39)을 결합해
* {@code PERSONAL_DATA_ACCESS_LOG}에 적재한다.</p>
*
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
* 베스트에포트로 취득한다(#36).</p>
*
* <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
* 갱신하면 되고 컨트롤러 수동 호출은 불요하다.</p>
*
* <p><b>TRGET_ID 취득 규약(#36):</b> 가로챈 메서드 인자에서 <u>식별자만</u> 베스트에포트로 추출한다.
* ① 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlId→getUniqId→getMberId→getEmplyrId→
* getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 non-blank 값. ② 인자가 {@code Map}이면
* 동일 우선순위 키. ③ 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로
* <b>스킵</b>(검색어·개인정보 본문 오적재 방지). 어느 것도 못 얻으면 {@code null}(fail-safe).
* 처리대상 건수(TRGET_CN)는 반환값이 {@code Collection}이면 "N건"으로 기록한다.</p>
*
* <p>스키마 기반 AOP(@AspectJ 미사용)라 {@code aspectj-autoproxy}를 전역 활성화하지 않는다 —
* 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.</p>
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
*/
@Component("personalDataLogAspect")
public class PersonalDataLogAspect {
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 ANONYMOUS = "ANONYMOUS";
@Resource(name = "jobMappingResolver")
private JobMappingResolver jobMappingResolver;
/** #33 비동기 적재 큐 — Aspect는 enqueue만 하고 즉시 반환(업무 응답시간 무영향). */
@Resource(name = "accessLogQueue")
private AccessLogQueue accessLogQueue;
/**
* 스키마 기반 {@code <aop:around>} 대상 메서드.
* 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음).
*/
public Object around(ProceedingJoinPoint pjp) throws Throwable {
String processResult = "S";
Object result = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable t) {
processResult = "F";
throw t;
} finally {
try {
writeAccessLog(pjp, processResult, result);
} catch (Exception ignore) {
// 접속기록 수집은 업무 흐름을 절대 방해하지 않는다.
LOG.error("[개인정보/#31] 접속기록 수집 중 예외(무시)", ignore);
}
}
}
private void writeAccessLog(ProceedingJoinPoint pjp, String processResult, Object result) {
HttpServletRequest request = currentRequest();
// 웹 요청 컨텍스트가 없으면(배치/스케줄러 등) 접속기록 대상이 아니다.
if (request == null) {
return;
}
String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
String methodName = pjp.getSignature().getName();
JobMappingVO mapping = jobMappingResolver.resolve(targetSimpleName, methodName);
String jobSeCode;
String jobSeNm;
String dmlGubun;
if (mapping != null) {
jobSeCode = mapping.getJobSeCode();
jobSeNm = mapping.getJobSeNm();
dmlGubun = (mapping.getDmlGubun() != null && mapping.getDmlGubun().length() > 0)
? mapping.getDmlGubun()
: JobMappingResolver.deriveDmlGubun(methodName);
} else {
// pointcut 범위 안이지만 매핑 미등록 → 메서드명 기반 기본값(누락 없이 수집)
jobSeCode = targetSimpleName;
jobSeNm = targetSimpleName + "." + methodName;
dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
}
LoginVO login = currentLogin(request);
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
vo.setConectId(login != null && notBlank(login.getId()) ? login.getId() : ANONYMOUS);
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
vo.setConectIp(safeIp(request));
vo.setJobSeCode(jobSeCode);
vo.setJobSeNm(jobSeNm);
// #36 처리대상 식별자(본문 아님)·건수 베스트에포트 취득 — 규약은 클래스 Javadoc 참조.
vo.setTrgetId(extractTargetId(pjp.getArgs()));
vo.setTrgetCn(extractTargetCount(result));
vo.setDmlGubun(dmlGubun);
vo.setProcessResult(processResult);
vo.setUserSe(login != null ? login.getUserSe() : null);
// #33 비동기 큐에 적재만 하고 즉시 반환 — 봉인+INSERT는 큐 단일 컨슈머가 순차 수행(순번 보장).
accessLogQueue.enqueue(vo);
}
private HttpServletRequest currentRequest() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes) {
return ((ServletRequestAttributes) attrs).getRequest();
}
return null;
}
private LoginVO currentLogin(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Object o = session.getAttribute("loginVO");
return (o instanceof LoginVO) ? (LoginVO) o : null;
}
private String safeIp(HttpServletRequest request) {
try {
String ip = ClientIpResolver.getClientIp(request);
return notBlank(ip) ? ip : "-";
} catch (Exception e) {
return "-";
}
}
private static boolean notBlank(String s) {
return s != null && s.trim().length() > 0;
}
// ===================== #36 처리대상 식별자/건수 취득 =====================
/** 우선순위 식별자 getter(개인정보 본문이 아닌 식별자만). */
private static final String[] ID_GETTERS = {
"getEsntlId", "getUniqId", "getMberId", "getEmplyrId", "getEntrprsId",
"getGroupId", "getUserId", "getId", "getSn"
};
/** Map 파라미터용 우선순위 식별자 키(ID_GETTERS와 동일 순서). */
private static final String[] ID_KEYS = {
"esntlId", "uniqId", "mberId", "emplyrId", "entrprsId",
"groupId", "userId", "id", "sn"
};
/** TRGET_ID 컬럼 길이(VARCHAR(50)) 보호. */
private static final int TRGET_ID_MAX = 50;
/**
* 인자에서 처리대상 식별자를 베스트에포트로 추출한다(규약: 클래스 Javadoc).
* VO는 우선순위 getter, Map은 우선순위 키. 원시 String/Number 인자는 스킵(검색어 오적재 방지).
* 실패 시 null(fail-safe) — 접속기록 자체는 계속 적재된다.
*/
private String extractTargetId(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg == null) {
continue;
}
String id = null;
if (arg instanceof Map) {
id = fromMap((Map<?, ?>) arg);
} else if (isDomainBean(arg)) {
id = fromBean(arg);
}
// 원시 String/Number/Collection/배열 인자는 식별자 확정 불가 → 스킵(본문·검색어 오적재 방지)
if (notBlank(id)) {
return id.trim().length() > TRGET_ID_MAX ? id.trim().substring(0, TRGET_ID_MAX) : id.trim();
}
}
return null;
}
/** 반환값이 컬렉션이면 처리대상 건수(범위)를 "N건"으로 기록한다. */
private String extractTargetCount(Object result) {
if (result instanceof Collection) {
return ((Collection<?>) result).size() + "건";
}
return null;
}
/** VO/도메인 빈 판정 — String/래퍼/Collection/Map/배열은 제외. */
private boolean isDomainBean(Object arg) {
if (arg instanceof String || arg instanceof Number || arg instanceof Boolean
|| arg instanceof Character || arg instanceof Collection || arg instanceof Map
|| arg.getClass().isArray()) {
return false;
}
return true;
}
private String fromBean(Object bean) {
for (String getter : ID_GETTERS) {
try {
Method m = bean.getClass().getMethod(getter);
Object v = m.invoke(bean);
if (v != null && notBlank(String.valueOf(v))) {
return String.valueOf(v);
}
} catch (Exception ignore) {
// 해당 getter 없음/접근 불가 → 다음 후보
}
}
return null;
}
private String fromMap(Map<?, ?> map) {
for (String key : ID_KEYS) {
try {
Object v = map.get(key);
if (v != null && notBlank(String.valueOf(v))) {
return String.valueOf(v);
}
} catch (Exception ignore) {
// 일부 Map 구현의 키 타입 불일치 등 → 다음 후보
}
}
return null;
}
}

View File

@ -20,6 +20,8 @@ public class AuthorChangeHistoryVO implements Serializable {
private String trgetUniqId; private String trgetUniqId;
/** 변경 대상자 계정ID */ /** 변경 대상자 계정ID */
private String trgetId; private String trgetId;
/** 변경 대상자 성명 — COMTNEMPLYRINFO 조인 표기용(비영속) */
private String trgetNm;
/** 변경구분 G:부여 R:회수 M:변경 */ /** 변경구분 G:부여 R:회수 M:변경 */
private String chgSeCode; private String chgSeCode;
private String beforeAuthor; private String beforeAuthor;
@ -54,6 +56,8 @@ public class AuthorChangeHistoryVO implements Serializable {
public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; } public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; }
public String getTrgetId() { return trgetId; } public String getTrgetId() { return trgetId; }
public void setTrgetId(String trgetId) { this.trgetId = trgetId; } public void setTrgetId(String trgetId) { this.trgetId = trgetId; }
public String getTrgetNm() { return trgetNm; }
public void setTrgetNm(String trgetNm) { this.trgetNm = trgetNm; }
public String getChgSeCode() { return chgSeCode; } public String getChgSeCode() { return chgSeCode; }
public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; } public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; }
public String getBeforeAuthor() { return beforeAuthor; } public String getBeforeAuthor() { return beforeAuthor; }

View File

@ -0,0 +1,85 @@
package egovframework.com.cmm.privacy.author;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) DAO.
*
* <p>개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6). statement 는
* {@code PrivacyAuthorHistDownloadJob_SQL_Mysql.xml}
* (namespace {@code AuthorHistDownloadJobDAO}). 신규 sqlMap 이므로
* {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.</p>
*
* @since 2026-08-10
*/
@Repository("authorHistDownloadJobDAO")
public class AuthorHistDownloadJobDAO extends EgovComAbstractDAO {
/** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */
public long insertJob(AuthorHistDownloadJobVO vo) {
insert("AuthorHistDownloadJobDAO.insertJob", vo);
return vo.getJobId();
}
public AuthorHistDownloadJobVO selectJob(long jobId) {
return (AuthorHistDownloadJobVO) selectByPk("AuthorHistDownloadJobDAO.selectJob", Long.valueOf(jobId));
}
/** 요청자 본인의 최근 작업 목록. */
@SuppressWarnings("unchecked")
public List<AuthorHistDownloadJobVO> selectJobListByRequester(String requesterId, int limitCnt) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("requesterId", requesterId);
param.put("limitCnt", Integer.valueOf(limitCnt));
return list("AuthorHistDownloadJobDAO.selectJobListByRequester", param);
}
/** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */
public Long selectNextWaitingJobId() {
return (Long) selectByPk("AuthorHistDownloadJobDAO.selectNextWaitingJobId", null);
}
/**
* 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다.
* (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.)
*/
public boolean claimJob(long jobId) {
return update("AuthorHistDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1;
}
public void updateProgress(AuthorHistDownloadJobVO vo) {
update("AuthorHistDownloadJobDAO.updateProgress", vo);
}
public void updateSuccess(AuthorHistDownloadJobVO vo) {
update("AuthorHistDownloadJobDAO.updateSuccess", vo);
}
public void updateFail(AuthorHistDownloadJobVO vo) {
update("AuthorHistDownloadJobDAO.updateFail", vo);
}
public void updateDownloaded(long jobId) {
update("AuthorHistDownloadJobDAO.updateDownloaded", Long.valueOf(jobId));
}
@SuppressWarnings("unchecked")
public List<AuthorHistDownloadJobVO> selectExpiredJobs() {
return list("AuthorHistDownloadJobDAO.selectExpiredJobs", null);
}
public void updatePurged(long jobId) {
update("AuthorHistDownloadJobDAO.updatePurged", Long.valueOf(jobId));
}
/** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */
public int resetRunningJobs() {
return update("AuthorHistDownloadJobDAO.resetRunningJobs", null);
}
}

View File

@ -0,0 +1,38 @@
package egovframework.com.cmm.privacy.author;
import java.util.List;
/**
* 권한변경 이력 CSV 비동기 다운로드 작업 서비스 (SFR-001 / 개발항목 #6).
*
* <p>요청은 작업만 등록하고 즉시 반환한다. 파일 생성은
* {@link AuthorHistDownloadJobWorker} 가 백그라운드에서 맡는다.</p>
*
* @since 2026-08-10
*/
public interface AuthorHistDownloadJobService {
/**
* 다운로드 요청 등록.
*
* @param requesterId 요청자 계정ID
* @param requesterIp 요청 IP
* @param reason 다운로드 사유(필수 — 없으면 IllegalArgumentException)
* @param searchVO 다운로드 대상 검색조건
* @return 채번된 작업 ID
*/
long requestJob(String requesterId, String requesterIp, String reason,
AuthorChangeHistoryVO searchVO) throws Exception;
/** 요청자 본인의 최근 작업 목록. */
List<AuthorHistDownloadJobVO> selectMyJobs(String requesterId, int limitCnt) throws Exception;
/** 내려받기 가능한 작업 조회. 본인 것이 아니거나 미완료/파일 없음이면 null. */
AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception;
/** 내려받은 횟수·일시 기록. */
void markDownloaded(long jobId) throws Exception;
/** 만료 파일 파기(배치). 파일 삭제 + 상태 X. */
int purgeExpiredJobs() throws Exception;
}

View File

@ -0,0 +1,169 @@
package egovframework.com.cmm.privacy.author;
import java.io.File;
import java.util.List;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* 권한변경 이력 CSV 비동기 다운로드 작업 서비스 구현 (SFR-001 / 개발항목 #6).
*
* <p><b>보관 최소화:</b> 생성 파일은 취급자 권한 변동 내역이 담긴 개인정보 파일이라 오래 두지 않는다.
* {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.
* 권한이력 본체의 보관기간(기본 1,095일)과는 무관한 '내려받기용 사본'의 수명이다.</p>
*
* <p><b>권한 재검증:</b> 내려받기는 <u>요청자 본인만</u> 가능하다. 작업ID는 연속 정수라
* 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.</p>
*
* @since 2026-08-10
*/
@Service("authorHistDownloadJobService")
public class AuthorHistDownloadJobServiceImpl implements AuthorHistDownloadJobService {
private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobServiceImpl.class);
@Resource(name = "authorHistDownloadJobDAO")
private AuthorHistDownloadJobDAO jobDAO;
/** 생성 파일 보관일수. 접속기록 비동기 다운로드와 같은 설정값을 공유한다(기본 3일). */
@Value("${Globals.privacyDownloadExpireDays:3}")
private int expireDays = 3;
@Override
public long requestJob(String requesterId, String requesterIp, String reason,
AuthorChangeHistoryVO searchVO) throws Exception {
// 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다.
if (reason == null || reason.trim().length() == 0) {
throw new IllegalArgumentException("다운로드 사유는 필수입니다.");
}
if (requesterId == null || requesterId.trim().length() == 0) {
throw new IllegalArgumentException("요청자를 확인할 수 없습니다.");
}
if (searchVO == null) {
searchVO = new AuthorChangeHistoryVO();
}
AuthorHistDownloadJobVO job = new AuthorHistDownloadJobVO();
job.setRequesterId(requesterId);
job.setRequesterIp(requesterIp);
job.setDlReason(cut(reason.trim(), 500));
job.setSearchCond(cut(summarizeCond(searchVO), 1000));
job.setCondTrgetId(searchVO.getSearchTrgetId());
job.setCondChangerId(searchVO.getSearchChangerId());
job.setCondChgSeCode(searchVO.getSearchChgSeCode());
job.setCondFromDt(searchVO.getFromDt());
job.setCondToDt(searchVO.getToDt());
job.setExpireDays(expireDays < 1 ? 3 : expireDays);
long jobId = jobDAO.insertJob(job);
LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId
+ ", 조건=" + job.getSearchCond());
return jobId;
}
@Override
public List<AuthorHistDownloadJobVO> selectMyJobs(String requesterId, int limitCnt) throws Exception {
if (requesterId == null || requesterId.trim().length() == 0) {
return java.util.Collections.emptyList();
}
return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt);
}
@Override
public AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception {
AuthorHistDownloadJobVO job = jobDAO.selectJob(jobId);
if (job == null) {
return null;
}
// 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다.
if (requesterId == null || !requesterId.equals(job.getRequesterId())) {
LOG.warn("[개인정보/SFR-001-6] 타인 다운로드 시도 차단 — jobId=" + jobId
+ ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId);
return null;
}
if (!job.isDownloadable()) {
return null;
}
File f = new File(job.getFilePath());
if (!f.exists() || !f.isFile()) {
LOG.warn("[개인정보/SFR-001-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId);
return null;
}
return job;
}
@Override
public void markDownloaded(long jobId) throws Exception {
jobDAO.updateDownloaded(jobId);
}
@Override
public int purgeExpiredJobs() throws Exception {
List<AuthorHistDownloadJobVO> expired = jobDAO.selectExpiredJobs();
int purged = 0;
if (expired != null) {
for (int i = 0; i < expired.size(); i++) {
AuthorHistDownloadJobVO job = expired.get(i);
String path = job.getFilePath();
if (path != null && path.length() > 0) {
try {
File f = new File(path);
if (f.exists() && !f.delete()) {
// 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다.
LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId()
+ ", path=" + path + " (수동 삭제 필요)");
continue;
}
} catch (Exception e) {
LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e);
continue;
}
}
jobDAO.updatePurged(job.getJobId());
purged++;
}
}
LOG.info("[개인정보/SFR-001-6] 만료 권한이력 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)");
return purged;
}
/** 검색조건을 요약(표시용). */
private String summarizeCond(AuthorChangeHistoryVO vo) {
StringBuilder sb = new StringBuilder();
appendCond(sb, "대상자", vo.getSearchTrgetId());
appendCond(sb, "변경자", vo.getSearchChangerId());
appendCond(sb, "변경구분", chgSeLabel(vo.getSearchChgSeCode()));
appendCond(sb, "시작일", vo.getFromDt());
appendCond(sb, "종료일", vo.getToDt());
return sb.length() == 0 ? "(전체)" : sb.toString();
}
private void appendCond(StringBuilder sb, String key, String val) {
if (val != null && val.trim().length() > 0) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(key).append("=").append(val.trim());
}
}
private String chgSeLabel(String code) {
if ("G".equals(code)) return "부여";
if ("R".equals(code)) return "회수";
if ("M".equals(code)) return "변경";
return code;
}
private static String cut(String s, int max) {
if (s == null) {
return null;
}
return (s.length() > max) ? s.substring(0, max) : s;
}
}

View File

@ -0,0 +1,148 @@
package egovframework.com.cmm.privacy.author;
import java.io.Serializable;
/**
* 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) VO.
*
* <p>개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6) — 대용량 다운로드를 요청 즉시 응답하고
* 백그라운드에서 파일을 만들기 위한 작업 한 건. 접속기록 쪽
* {@code AccessLogDownloadJobVO} 와 같은 패턴이며 검색조건 필드만 다르다.</p>
*
* <p><b>검색조건을 컬럼으로 보존하는 이유:</b> 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
* 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이다.</p>
*
* @since 2026-08-10
*/
public class AuthorHistDownloadJobVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 상태: 대기 */
public static final String ST_WAIT = "W";
/** 상태: 생성중 */
public static final String ST_RUNNING = "R";
/** 상태: 완료(내려받기 가능) */
public static final String ST_SUCCESS = "S";
/** 상태: 실패 */
public static final String ST_FAIL = "F";
/** 상태: 파기(파일 삭제됨) */
public static final String ST_PURGED = "X";
private long jobId;
private String requesterId;
private String requesterIp;
private String dlReason;
private String searchCond;
// 조건 재현용. 프로퍼티명을 cond* 로 둔다 — getFTrgetId 처럼 '대문자1자+대문자' 형태면
// JavaBeans 인트로스펙션이 프로퍼티를 다르게 해석해 iBatis 바인딩이 조용히 실패한다.
private String condTrgetId;
private String condChangerId;
private String condChgSeCode;
private String condFromDt;
private String condToDt;
private String status;
private int totalCnt;
private int doneCnt;
private String fileNm;
private String filePath;
private long fileSize;
private String errMsg;
private int downloadCnt;
private String lastDlDt;
private String regDt;
private String startDt;
private String endDt;
private String expireDt;
/** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 전달하기 위한 비영속 필드. */
private int expireDays;
public long getJobId() { return jobId; }
public void setJobId(long jobId) { this.jobId = jobId; }
public String getRequesterId() { return requesterId; }
public void setRequesterId(String requesterId) { this.requesterId = requesterId; }
public String getRequesterIp() { return requesterIp; }
public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; }
public String getDlReason() { return dlReason; }
public void setDlReason(String dlReason) { this.dlReason = dlReason; }
public String getSearchCond() { return searchCond; }
public void setSearchCond(String searchCond) { this.searchCond = searchCond; }
public String getCondTrgetId() { return condTrgetId; }
public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; }
public String getCondChangerId() { return condChangerId; }
public void setCondChangerId(String condChangerId) { this.condChangerId = condChangerId; }
public String getCondChgSeCode() { return condChgSeCode; }
public void setCondChgSeCode(String condChgSeCode) { this.condChgSeCode = condChgSeCode; }
public String getCondFromDt() { return condFromDt; }
public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; }
public String getCondToDt() { return condToDt; }
public void setCondToDt(String condToDt) { this.condToDt = condToDt; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public int getTotalCnt() { return totalCnt; }
public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; }
public int getDoneCnt() { return doneCnt; }
public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; }
public String getFileNm() { return fileNm; }
public void setFileNm(String fileNm) { this.fileNm = fileNm; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public long getFileSize() { return fileSize; }
public void setFileSize(long fileSize) { this.fileSize = fileSize; }
public String getErrMsg() { return errMsg; }
public void setErrMsg(String errMsg) { this.errMsg = errMsg; }
public int getDownloadCnt() { return downloadCnt; }
public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; }
public String getLastDlDt() { return lastDlDt; }
public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; }
public String getRegDt() { return regDt; }
public void setRegDt(String regDt) { this.regDt = regDt; }
public String getStartDt() { return startDt; }
public void setStartDt(String startDt) { this.startDt = startDt; }
public String getEndDt() { return endDt; }
public void setEndDt(String endDt) { this.endDt = endDt; }
public String getExpireDt() { return expireDt; }
public void setExpireDt(String expireDt) { this.expireDt = expireDt; }
public int getExpireDays() { return expireDays; }
public void setExpireDays(int expireDays) { this.expireDays = expireDays; }
/** 진행률(%). 총건수 미확정이면 0. */
public int getProgressPct() {
if (totalCnt <= 0) {
return ST_SUCCESS.equals(status) ? 100 : 0;
}
long pct = (long) doneCnt * 100L / totalCnt;
return (pct > 100L) ? 100 : (int) pct;
}
/** 내려받기 가능 여부(완료 + 파일 보유). */
public boolean isDownloadable() {
return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0;
}
}

View File

@ -0,0 +1,338 @@
package egovframework.com.cmm.privacy.author;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import egovframework.com.utl.sim.service.EgovFileScrty;
/**
* 권한변경 이력 CSV 비동기 다운로드 워커 (SFR-001 / 개발항목 #6).
*
* <p>대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. <b>단일 워커 스레드</b>다 —
* 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.
* 접속기록 워커({@code AccessLogDownloadJobWorker})와 같은 구조이며 조회 대상만 다르다.</p>
*
* <p><b>메모리를 쓰지 않는 방식:</b> 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
* 끊어 조회하며 곧바로 파일에 쓴다. 종전 동기 다운로드(전량 추출)의 OOM 위험을 없앤다.</p>
*
* <p><b>파일은 암호화해 둔다:</b> 취급자 권한 변동 내역이 담긴 개인정보 파일이라 평문 CSV 를
* 디스크에 남기지 않는다. {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지우고,
* 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 <b>웹 루트 밖</b>이어야 한다.</p>
*
* <p><b>기동 시 고아 복구:</b> 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
* R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).</p>
*
* @since 2026-08-10
*/
@Component("authorHistDownloadJobWorker")
public class AuthorHistDownloadJobWorker {
private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobWorker.class);
/** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */
private static final int CHUNK_ROWS = 2000;
/** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */
private static final long IDLE_SLEEP_MS = 3000L;
/** 예외 발생 후 폭주 방지 대기(ms). */
private static final long ERROR_SLEEP_MS = 10000L;
/** 화면 열 구성과 맞춘다(대상자명·변경자IP 포함). */
private static final String CSV_HEADER =
"변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP";
@Resource(name = "authorHistDownloadJobDAO")
private AuthorHistDownloadJobDAO jobDAO;
@Resource(name = "authorHistoryService")
private AuthorHistoryService authorHistoryService;
/**
* 생성 파일 저장 디렉터리. <b>반드시 웹 루트(webapps) 밖</b>이어야 한다.
* 접속기록 비동기 다운로드와 같은 설정값을 공유한다. 미설정 시 기동 로그에 경고를 남기고
* 워커를 띄우지 않는다 — 임의 경로에 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
*/
@Value("${Globals.privacyDownloadPath:}")
private String downloadPath;
private volatile boolean running = false;
private volatile Thread worker;
@PostConstruct
public void start() {
// 저장경로가 없거나 만들 수 없어도 워커는 띄운다(접속기록 워커와 동일한 이유).
// 종전에는 여기서 return 해 워커가 아예 안 돌았고, 작업이 '대기'로 영원히 굳었다.
// 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다.
try {
ensureDir();
LOG.info("[개인정보/SFR-001-6] 저장경로 확인 완료 — " + downloadPath);
} catch (Exception e) {
LOG.error("[개인정보/SFR-001-6] 저장경로를 확보하지 못했다 — " + e.getMessage()
+ " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. "
+ "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/).");
}
try {
int reset = jobDAO.resetRunningJobs();
if (reset > 0) {
LOG.warn("[개인정보/SFR-001-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다.");
}
} catch (Exception e) {
LOG.error("[개인정보/SFR-001-6] 고아 작업 복구 실패(계속 진행)", e);
}
running = true;
Thread t = new Thread(new Runnable() {
public void run() {
loop();
}
}, "author-hist-download-worker");
t.setDaemon(true);
worker = t;
t.start();
LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath
+ ", 청크=" + CHUNK_ROWS + "행");
}
@PreDestroy
public void stop() {
running = false;
Thread t = worker;
if (t != null) {
t.interrupt();
}
}
/** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */
private void loop() {
while (running) {
try {
Long jobId = jobDAO.selectNextWaitingJobId();
if (jobId == null) {
Thread.sleep(IDLE_SLEEP_MS);
continue;
}
if (!jobDAO.claimJob(jobId.longValue())) {
// 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다.
continue;
}
process(jobId.longValue());
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
} catch (Throwable t) {
LOG.error("[개인정보/SFR-001-6] 워커 루프 예외(계속)", t);
try {
Thread.sleep(ERROR_SLEEP_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
}
LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 종료.");
}
/** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */
private void process(long jobId) {
AuthorHistDownloadJobVO job = null;
File plain = null;
File enc = null;
try {
job = jobDAO.selectJob(jobId);
if (job == null) {
return;
}
AuthorChangeHistoryVO searchVO = toSearchVO(job);
int total = authorHistoryService.selectHistoryListCount(searchVO);
job.setTotalCnt(total);
job.setDoneCnt(0);
jobDAO.updateProgress(job);
// 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다.
File dir = ensureDir();
String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String baseNm = "권한변경이력_" + stamp + "_" + jobId;
plain = new File(dir, baseNm + ".csv.tmp");
enc = new File(dir, baseNm + ".csv.enc");
int written = writeCsv(plain, searchVO, total, job);
// 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제.
if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) {
throw new IllegalStateException("파일 암호화 실패");
}
deleteQuietly(plain);
plain = null;
job.setDoneCnt(written);
job.setTotalCnt(total);
job.setFileNm(baseNm + ".csv");
job.setFilePath(enc.getAbsolutePath());
job.setFileSize(enc.length());
jobDAO.updateSuccess(job);
LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, "
+ enc.length() + "byte");
} catch (Throwable t) {
LOG.error("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 실패 — jobId=" + jobId, t);
deleteQuietly(plain);
deleteQuietly(enc);
try {
AuthorHistDownloadJobVO fail = (job != null) ? job : new AuthorHistDownloadJobVO();
fail.setJobId(jobId);
fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500));
jobDAO.updateFail(fail);
} catch (Exception e) {
LOG.error("[개인정보/SFR-001-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e);
}
}
}
/**
* 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다).
*
* @return 기록한 행 수
*/
private int writeCsv(File target, AuthorChangeHistoryVO searchVO, int total,
AuthorHistDownloadJobVO job) throws Exception {
BufferedWriter w = null;
int written = 0;
try {
FileOutputStream fos = new FileOutputStream(target);
// UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일).
fos.write(0xEF); fos.write(0xBB); fos.write(0xBF);
w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8"));
w.write(CSV_HEADER);
w.newLine();
int page = 1;
while (written < total) {
searchVO.setPageIndex(page);
searchVO.setRecordCountPerPage(CHUNK_ROWS);
List<AuthorChangeHistoryVO> chunk = authorHistoryService.selectHistoryList(searchVO);
if (chunk == null || chunk.isEmpty()) {
break;
}
for (int i = 0; i < chunk.size(); i++) {
w.write(toCsvLine(chunk.get(i)));
w.newLine();
written++;
}
w.flush();
// 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다.
job.setDoneCnt(written);
job.setTotalCnt(total);
try {
jobDAO.updateProgress(job);
} catch (Exception e) {
// 진행률은 부가정보다 — 실패해도 생성은 계속한다.
LOG.warn("[개인정보/SFR-001-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId());
}
page++;
}
w.flush();
} finally {
if (w != null) {
try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ }
}
}
return written;
}
private String toCsvLine(AuthorChangeHistoryVO v) {
StringBuilder sb = new StringBuilder(256);
sb.append(csv(v.getChgDt())).append(',')
.append(csv(v.getTrgetId())).append(',')
.append(csv(v.getTrgetNm())).append(',')
.append(csv(chgSeLabel(v.getChgSeCode()))).append(',')
.append(csv(v.getBeforeAuthor())).append(',')
.append(csv(v.getAfterAuthor())).append(',')
.append(csv(v.getChgResn())).append(',')
.append(csv(v.getAprovlId())).append(',')
.append(csv(v.getChangerId())).append(',')
.append(csv(v.getChangerIp()));
return sb.toString();
}
private AuthorChangeHistoryVO toSearchVO(AuthorHistDownloadJobVO job) {
AuthorChangeHistoryVO vo = new AuthorChangeHistoryVO();
vo.setSearchTrgetId(job.getCondTrgetId());
vo.setSearchChangerId(job.getCondChangerId());
vo.setSearchChgSeCode(job.getCondChgSeCode());
vo.setFromDt(job.getCondFromDt());
vo.setToDt(job.getCondToDt());
return vo;
}
/**
* 저장 디렉터리를 확보한다(없으면 생성). 실패는 예외로 알린다 —
* 메시지가 그대로 작업 실패사유(ERR_MSG)로 화면에 노출된다.
*/
private File ensureDir() {
if (downloadPath == null || downloadPath.trim().length() == 0) {
throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)");
}
File dir = new File(downloadPath.trim());
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath());
}
if (!dir.isDirectory()) {
throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath());
}
if (!dir.canWrite()) {
throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath());
}
return dir;
}
private void deleteQuietly(File f) {
if (f != null && f.exists()) {
try {
if (!f.delete()) {
LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath());
}
} catch (Exception ignore) {
LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath());
}
}
}
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
private String csv(String v) {
if (v == null) {
return "";
}
String s = v.replace("\"", "\"\"");
return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
}
private String chgSeLabel(String code) {
if ("G".equals(code)) return "부여";
if ("R".equals(code)) return "회수";
if ("M".equals(code)) return "변경";
return code;
}
private static String cut(String s, int max) {
if (s == null) {
return null;
}
return (s.length() > max) ? s.substring(0, max) : s;
}
}

View File

@ -21,4 +21,18 @@ public interface AuthorHistoryService {
* @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서) * @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서)
*/ */
List<AuthorChangeHistoryVO> downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception; List<AuthorChangeHistoryVO> downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception;
/**
* 다운로드 증빙 이력만 적재. 비동기 다운로드는 파일 생성과 실제 내려받기 시점이 달라
* '내려받은 시점'에 이 메서드로 증빙을 남긴다(사유·건수 증빙을 동기 경로와 일원화).
*
* @param downloaderId 다운로드자 계정ID
* @param downloaderUniqId 다운로드자 고유식별자
* @param downloaderIp 다운로드자 IP
* @param reason 다운로드 사유(요청 시 입력값)
* @param searchCond 검색조건 요약
* @param cnt 내려받은 건수
*/
void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp,
String reason, String searchCond, int cnt) throws Exception;
} }

View File

@ -48,4 +48,17 @@ public class AuthorHistoryServiceImpl implements AuthorHistoryService {
return list; return list;
} }
@Override
public void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp,
String reason, String searchCond, int cnt) throws Exception {
AuthorHistDownloadVO dl = new AuthorHistDownloadVO();
dl.setDownloaderId(downloaderId);
dl.setDownloaderUniqId(downloaderUniqId);
dl.setDownloaderIp(downloaderIp);
dl.setDwldResn(reason);
dl.setSearchCond(searchCond);
dl.setDwldCnt(cnt);
authorHistoryDAO.insertDownloadHistory(dl);
}
} }

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,52 +24,59 @@ 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 {
protected static final String JSON = "jsonView"; protected static final String JSON = "jsonView";
/**
* 화면에 띄울 '내 다운로드 요청' 최대 건수. 목록은 <b>만료 전 이력만</b> 보여주며
* (2026-08-10 정책, 접속기록 다운로드와 동일), 이 값은 단기 대량 요청 시 화면이
* 통째로 길어지는 것을 막는 상한이다. 테이블 행 자체는 감사 증적이라 그대로 둔다.
*/
private static final int MY_JOB_MAX = 100;
@Resource(name = "authorWorkflowService") @Resource(name = "authorWorkflowService")
private AuthorWorkflowService authorWorkflowService; private AuthorWorkflowService authorWorkflowService;
@Resource(name = "authorHistoryService") @Resource(name = "authorHistoryService")
private AuthorHistoryService authorHistoryService; private AuthorHistoryService authorHistoryService;
/** 권한이력 CSV 비동기 다운로드 작업(#6) */
@Resource(name = "authorHistDownloadJobService")
private AuthorHistDownloadJobService authorHistDownloadJobService;
private LoginVO loginUser(HttpServletRequest request) { private LoginVO loginUser(HttpServletRequest request) {
Object o = request.getSession().getAttribute("loginVO"); Object o = request.getSession().getAttribute("loginVO");
return (o instanceof LoginVO) ? (LoginVO) o : null; return (o instanceof LoginVO) ? (LoginVO) o : null;
} }
/** 권한 변경 신청 목록 (AJAX/JSON) */
@RequestMapping(value = "/mnt/sec/authorRequest/list.do")
public String requestList(AuthorChangeRequestVO searchVO, ModelMap model) throws Exception {
List<AuthorChangeRequestVO> list = authorWorkflowService.selectRequestList(searchVO);
model.addAttribute("resultList", list);
model.addAttribute("totalCount", searchVO.getTotalRecordCount());
return JSON;
}
/** /**
* 권한 변경 신청 목록·승인·반려 화면(서버 렌더링). * 개인정보취급자 권한 관리 목록 화면(서버 렌더링) — 메뉴 진입점.
* 취급자 1인 = 1행. 소속기관/변경 전 역할/변경 후 역할/접근범위/권한상태/최근변경일/관리 열을 제공하고,
* 승인대기 행에서는 이 화면에서 바로 승인·반려한다.
* 뷰: /WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp * 뷰: /WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp
*/ */
@RequestMapping(value = "/mnt/sec/authorRequest/listView.do") @RequestMapping(value = "/mnt/sec/authorRequest/listView.do")
public String requestListView(@ModelAttribute("authorChangeRequestVO") AuthorChangeRequestVO searchVO, public String userAuthorListView(@ModelAttribute("privacyUserAuthorVO") PrivacyUserAuthorVO searchVO,
ModelMap model) throws Exception { ModelMap model) throws Exception {
PaginationInfo paginationInfo = new PaginationInfo(); PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage()); paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10); paginationInfo.setPageSize(10);
// selectRequestList 내부에서 firstIndex/totalRecordCount 를 세팅한다. // selectUserAuthorList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
List<AuthorChangeRequestVO> list = authorWorkflowService.selectRequestList(searchVO); List<PrivacyUserAuthorVO> list = authorWorkflowService.selectUserAuthorList(searchVO);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list); model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo); model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("orgnztList", authorWorkflowService.selectOrgnztList());
model.addAttribute("authorityList", authorWorkflowService.selectAuthorityList());
model.addAttribute("pendingCount", Integer.valueOf(authorWorkflowService.selectPendingRequestCount()));
return "site/mnt/sec/authorRequest/list"; return "site/mnt/sec/authorRequest/list";
} }
@ -142,7 +155,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 +164,188 @@ public class AuthorWorkflowController {
return JSON; return JSON;
} }
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 */ /**
@RequestMapping(value = "/mnt/sec/authorHistory/download.do") * 권한 변경 이력 조회·검색 화면(서버 렌더링).
public String historyDownload(AuthorChangeHistoryVO searchVO, * 뷰: /WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp
@org.springframework.web.bind.annotation.RequestParam(value = "dwldResn", required = false) String dwldResn, */
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
HttpServletRequest request, ModelMap model) throws Exception { HttpServletRequest request, 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);
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
model.addAttribute("dlJobList", selectMyDownloadJobs(request));
return "site/mnt/sec/authorHistory/list";
}
/**
* 내 권한이력 다운로드 작업의 <b>상태만</b> 돌려주는 폴링 전용 엔드포인트.
*
* <p>접속기록 화면과 같은 이유다 — 진행률을 보려고 목록 화면을 5초마다 다시 열면
* 이 화면이 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 <u>새로고침마다 감사로그 1행</u>이
* 쌓인다. 상태 조회를 별도 경로로 빼고 {@code excludePatterns} 로 수집에서 제외한다.
* 본문에는 작업ID·상태·진행률·건수·실패사유만 담으며 본인 작업으로 한정한다.</p>
*/
@RequestMapping(value = "/mnt/sec/authorHistory/jobStatus.do")
public void historyJobStatus(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType("text/plain; charset=UTF-8");
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
LoginVO user = loginUser(request);
if (user == null) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
StringBuilder sb = new StringBuilder(256);
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) {
for (AuthorHistDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|')
.append(j.getStatus() == null ? "" : j.getStatus()).append('|')
.append(j.getProgressPct()).append('|')
.append(j.getDoneCnt()).append('|')
.append(j.getTotalCnt()).append('|')
.append(jobStatusOneLine(j.getErrMsg())).append('\n');
}
}
response.getWriter().write(sb.toString());
response.getWriter().flush();
}
/** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */
private String jobStatusOneLine(String v) {
if (v == null) {
return "";
}
return v.replace('|', '/').replace('\r', ' ').replace('\n', ' ');
}
/**
* 권한이력 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
*
* <p>종전 동기 방식({@link #historyDownload})은 조건 전체를 List 로 메모리에 올려
* OOM·요청 타임아웃 위험이 있었다. 파일 생성은 백그라운드 워커가 맡는다.
* 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.</p>
*/
@RequestMapping(value = "/mnt/sec/authorHistory/downloadRequest.do")
public String historyDownloadRequest(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
@RequestParam(value = "dwldResn", required = false) String dwldResn,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO user = loginUser(request);
try {
if (user == null) {
throw new IllegalArgumentException("로그인 정보를 확인할 수 없습니다.");
}
authorHistDownloadJobService.requestJob(user.getId(),
ClientIpResolver.getClientIp(request), dwldResn, searchVO);
} catch (IllegalArgumentException e) {
model.addAttribute("dlMessage", e.getMessage());
} catch (Exception e) {
model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다.");
}
// 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다.
return historyListView(searchVO, request, model);
}
/**
* 완료된 권한이력 다운로드 파일 내려받기 — <b>요청자 본인만</b> 가능하다.
*
* <p>저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
* 실제 내려받은 시점의 사유·건수 이력은 기존 {@code AUTHOR_HIST_DOWNLOAD_HISTORY} 에 적재한다.</p>
*/
@RequestMapping(value = "/mnt/sec/authorHistory/downloadFile.do")
public void historyDownloadFile(@RequestParam("jobId") long jobId,
HttpServletRequest request, HttpServletResponse response) throws Exception {
LoginVO user = loginUser(request);
if (user == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
return;
}
AuthorHistDownloadJobVO job = authorHistDownloadJobService.selectDownloadableJob(jobId, user.getId());
if (job == null) {
// 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지).
response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다.");
return;
}
java.io.File plain = java.io.File.createTempFile("author-hist-dl-", ".csv");
try {
if (!egovframework.com.utl.sim.service.EgovFileScrty.decryptFile(
job.getFilePath(), plain.getAbsolutePath())) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다.");
return;
}
String fileName = URLEncoder.encode(job.getFileNm(), "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");
response.setHeader("Content-Length", String.valueOf(plain.length()));
java.io.FileInputStream in = null;
OutputStream os = response.getOutputStream();
try {
in = new java.io.FileInputStream(plain);
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) > 0) {
os.write(buf, 0, len);
}
os.flush();
} finally {
if (in != null) {
try { in.close(); } catch (Exception ignore) { /* 무시 */ }
}
}
authorHistDownloadJobService.markDownloaded(jobId);
// 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화).
authorHistoryService.recordDownloadHistory(user.getId(), user.getUniqId(),
ClientIpResolver.getClientIp(request), job.getDlReason(), job.getSearchCond(), job.getDoneCnt());
} finally {
// 복호화 임시본을 남기지 않는다.
if (plain.exists() && !plain.delete()) {
plain.deleteOnExit();
}
}
}
/** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */
private List<AuthorHistDownloadJobVO> selectMyDownloadJobs(HttpServletRequest request) {
try {
LoginVO user = loginUser(request);
if (user == null) {
return new java.util.ArrayList<AuthorHistDownloadJobVO>();
}
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
return (list == null) ? new java.util.ArrayList<AuthorHistDownloadJobVO>() : list;
} catch (Exception e) {
return new java.util.ArrayList<AuthorHistDownloadJobVO>();
}
}
/**
* 권한 변경 이력 다운로드 — 동기 방식(기존). 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍.
*
* <p><b>주의:</b> 조건 전체를 메모리에 올린다. 화면은 위 {@link #historyDownloadRequest}
* 비동기 경로를 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.</p>
*/
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
public void historyDownload(AuthorChangeHistoryVO searchVO,
@RequestParam(value = "dwldResn", required = false) String dwldResn,
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 +356,48 @@ 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"));
// 열 구성은 비동기 워커(AuthorHistDownloadJobWorker.CSV_HEADER)와 동일하게 유지한다.
pw.println("변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP");
if (list != null) {
for (AuthorChangeHistoryVO h : list) {
pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(h.getTrgetNm())
+ "," + 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

@ -30,18 +30,6 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId)); return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId));
} }
/** 신청 목록 조회(상태/대상 검색 + 페이징) */
@SuppressWarnings("unchecked")
public List<AuthorChangeRequestVO> selectRequestList(AuthorChangeRequestVO vo) {
return list("AuthorWorkflowDAO.selectRequestList", vo);
}
/** 신청 목록 전체 건수 */
public int selectRequestListCount(AuthorChangeRequestVO vo) {
Object cnt = selectByPk("AuthorWorkflowDAO.selectRequestListCount", vo);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 신청 상태 갱신(승인 A / 반려 J) */ /** 신청 상태 갱신(승인 A / 반려 J) */
public void updateRequestStatus(AuthorChangeRequestVO vo) { public void updateRequestStatus(AuthorChangeRequestVO vo) {
update("AuthorWorkflowDAO.updateRequestStatus", vo); update("AuthorWorkflowDAO.updateRequestStatus", vo);
@ -77,4 +65,28 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) { public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) {
return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId); return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId);
} }
/** 개인정보취급자 권한 목록(소속기관·역할·접근범위·권한상태·최근변경일 + 페이징) */
@SuppressWarnings("unchecked")
public List<PrivacyUserAuthorVO> selectUserAuthorList(PrivacyUserAuthorVO vo) {
return list("AuthorWorkflowDAO.selectUserAuthorList", vo);
}
/** 개인정보취급자 권한 목록 전체 건수 */
public int selectUserAuthorListCount(PrivacyUserAuthorVO vo) {
Object cnt = selectByPk("AuthorWorkflowDAO.selectUserAuthorListCount", vo);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 목록 상단 요약용 전체 승인대기 건수(검색조건 무관) */
public int selectPendingRequestCount() {
Object cnt = selectByPk("AuthorWorkflowDAO.selectPendingRequestCount", null);
return cnt == null ? 0 : ((Integer) cnt).intValue();
}
/** 검색조건 드롭다운용 소속기관 목록 */
@SuppressWarnings("unchecked")
public List<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() {
return list("AuthorWorkflowDAO.selectOrgnztList", null);
}
} }

View File

@ -10,12 +10,6 @@ public interface AuthorWorkflowService {
/** 권한 변경 신청 등록(상태 R) */ /** 권한 변경 신청 등록(상태 R) */
void submitRequest(AuthorChangeRequestVO vo) throws Exception; void submitRequest(AuthorChangeRequestVO vo) throws Exception;
/** 신청 목록 조회 */
List<AuthorChangeRequestVO> selectRequestList(AuthorChangeRequestVO vo) throws Exception;
/** 신청 목록 건수 */
int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception;
/** /**
* 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를 * 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를
* 단일 트랜잭션으로 처리한다. * 단일 트랜잭션으로 처리한다.
@ -34,4 +28,13 @@ public interface AuthorWorkflowService {
/** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */ /** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */
egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception; egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception;
/** 개인정보취급자 권한 목록 조회 — firstIndex/totalRecordCount 를 vo 에 세팅한다 */
List<PrivacyUserAuthorVO> selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception;
/** 목록 상단 요약용 전체 승인대기 건수 */
int selectPendingRequestCount() throws Exception;
/** 검색조건 드롭다운용 소속기관 목록 */
List<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() throws Exception;
} }

View File

@ -31,21 +31,6 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
authorWorkflowDAO.insertRequest(vo); authorWorkflowDAO.insertRequest(vo);
} }
@Override
public List<AuthorChangeRequestVO> selectRequestList(AuthorChangeRequestVO vo) throws Exception {
int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex();
int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage();
vo.setFirstIndex((pageIndex - 1) * perPage);
vo.setRecordCountPerPage(perPage);
vo.setTotalRecordCount(authorWorkflowDAO.selectRequestListCount(vo));
return authorWorkflowDAO.selectRequestList(vo);
}
@Override
public int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception {
return authorWorkflowDAO.selectRequestListCount(vo);
}
@Override @Override
public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception { public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception {
AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId); AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId);
@ -119,6 +104,26 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim()); return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim());
} }
@Override
public List<PrivacyUserAuthorVO> selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception {
int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex();
int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage();
vo.setFirstIndex((pageIndex - 1) * perPage);
vo.setRecordCountPerPage(perPage);
vo.setTotalRecordCount(authorWorkflowDAO.selectUserAuthorListCount(vo));
return authorWorkflowDAO.selectUserAuthorList(vo);
}
@Override
public int selectPendingRequestCount() throws Exception {
return authorWorkflowDAO.selectPendingRequestCount();
}
@Override
public List<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() throws Exception {
return authorWorkflowDAO.selectOrgnztList();
}
/** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */ /** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */
private String mapChgSeCode(String requestSe) { private String mapChgSeCode(String requestSe) {
if ("R".equals(requestSe)) { if ("R".equals(requestSe)) {

View File

@ -0,0 +1,116 @@
package egovframework.com.cmm.privacy.author;
import java.io.Serializable;
/**
* 개인정보취급자 권한 관리 목록 VO (개인정보보호 SFR-001 / #3 화면 #46).
*
* <p>취급자(COMTNEMPLYRINFO) 1행 = 목록 1행. 소속기관(COMTNORGNZTINFO),
* 역할·접근범위(COMTNEMPLYRSCRTYESTBS + COMTNAUTHORINFO), 권한상태(대기신청/권한유무),
* 최근변경일(AUTHOR_CHANGE_HISTORY)을 조인해 한 화면에 제공한다.</p>
*
* <pre>
* 개정이력
* 2026.08.07 URP 최초 생성 — 신청목록 화면을 취급자 권한현황 화면으로 재구성
* </pre>
*/
public class PrivacyUserAuthorVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 취급자 계정ID (COMTNEMPLYRINFO.EMPLYR_ID) */
private String emplyrId;
/** 취급자 고유식별자 (ESNTL_ID) */
private String uniqId;
/** 취급자명 */
private String userNm;
/** 소속기관 ID */
private String orgnztId;
/** 소속기관명 */
private String orgnztNm;
/** 역할 권한코드 */
private String authorCode;
/** 역할명(Role) */
private String authorNm;
/** 변경 전 역할명 — 승인대기 신청이 있으면 그 신청의 변경전권한, 없으면 최근 이력의 변경전권한 */
private String beforeAuthorNm;
/** 변경 후 역할명 — 승인대기 신청이 있으면 신청 목표권한, 없으면 현재 역할(=최근 이력의 변경후권한) */
private String afterAuthorNm;
/** 처리대기 신청구분 N:신설 C:변경 R:회수 (대기 신청 없으면 null) */
private String pendingRequestSe;
/** 접근범위 — 권한설명 없으면 허용 메뉴 수로 대체 */
private String accessScope;
/** 권한상태 N:정상 W:승인대기 R:말소 */
private String authorSttus;
/** 최근변경일 (yyyy-MM-dd) */
private String lastChgDt;
/** 처리대기 신청 일련번호 — 승인/반려 버튼용(없으면 0) */
private long pendingRequestId;
/** 계정상태 코드 P:사용 A:승인대기 S:사용중지 D:말소 */
private String emplyrSttusCode;
// --- 검색조건 ---
/** 소속기관 */
private String searchOrgnztId;
/** 역할(권한코드) */
private String searchAuthorCode;
/** 권한상태 N/W/R */
private String searchAuthorSttus;
/** 취급자명 또는 계정ID */
private String searchKeyword;
// --- 페이징 ---
private int pageIndex = 1;
private int recordCountPerPage = 10;
private int firstIndex = 0;
private int totalRecordCount = 0;
public String getEmplyrId() { return emplyrId; }
public void setEmplyrId(String emplyrId) { this.emplyrId = emplyrId; }
public String getUniqId() { return uniqId; }
public void setUniqId(String uniqId) { this.uniqId = uniqId; }
public String getUserNm() { return userNm; }
public void setUserNm(String userNm) { this.userNm = userNm; }
public String getOrgnztId() { return orgnztId; }
public void setOrgnztId(String orgnztId) { this.orgnztId = orgnztId; }
public String getOrgnztNm() { return orgnztNm; }
public void setOrgnztNm(String orgnztNm) { this.orgnztNm = orgnztNm; }
public String getAuthorCode() { return authorCode; }
public void setAuthorCode(String authorCode) { this.authorCode = authorCode; }
public String getAuthorNm() { return authorNm; }
public void setAuthorNm(String authorNm) { this.authorNm = authorNm; }
public String getBeforeAuthorNm() { return beforeAuthorNm; }
public void setBeforeAuthorNm(String beforeAuthorNm) { this.beforeAuthorNm = beforeAuthorNm; }
public String getAfterAuthorNm() { return afterAuthorNm; }
public void setAfterAuthorNm(String afterAuthorNm) { this.afterAuthorNm = afterAuthorNm; }
public String getPendingRequestSe() { return pendingRequestSe; }
public void setPendingRequestSe(String pendingRequestSe) { this.pendingRequestSe = pendingRequestSe; }
public String getAccessScope() { return accessScope; }
public void setAccessScope(String accessScope) { this.accessScope = accessScope; }
public String getAuthorSttus() { return authorSttus; }
public void setAuthorSttus(String authorSttus) { this.authorSttus = authorSttus; }
public String getLastChgDt() { return lastChgDt; }
public void setLastChgDt(String lastChgDt) { this.lastChgDt = lastChgDt; }
public long getPendingRequestId() { return pendingRequestId; }
public void setPendingRequestId(long pendingRequestId) { this.pendingRequestId = pendingRequestId; }
public String getEmplyrSttusCode() { return emplyrSttusCode; }
public void setEmplyrSttusCode(String emplyrSttusCode) { this.emplyrSttusCode = emplyrSttusCode; }
public String getSearchOrgnztId() { return searchOrgnztId; }
public void setSearchOrgnztId(String searchOrgnztId) { this.searchOrgnztId = searchOrgnztId; }
public String getSearchAuthorCode() { return searchAuthorCode; }
public void setSearchAuthorCode(String searchAuthorCode) { this.searchAuthorCode = searchAuthorCode; }
public String getSearchAuthorSttus() { return searchAuthorSttus; }
public void setSearchAuthorSttus(String searchAuthorSttus) { this.searchAuthorSttus = searchAuthorSttus; }
public String getSearchKeyword() { return searchKeyword; }
public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; }
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

@ -5,6 +5,8 @@ import java.util.Map;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO;
import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistoryVO;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO; import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/** /**
@ -54,4 +56,24 @@ public class AccessLogPartitionDAO extends EgovComAbstractDAO {
public void insertPurgeHistory(Map<String, String> param) throws Exception { public void insertPurgeHistory(Map<String, String> param) throws Exception {
insert("PersonalDataAccessLogDAO.insertPurgeHistory", param); insert("PersonalDataAccessLogDAO.insertPurgeHistory", param);
} }
/**
* 파기·증설 이력 목록(화면 조회용, 페이징).
*
* <p>배치가 쓰는 DAO 에 조회를 함께 두는 이유는 {@code PDAL_PURGE_HISTORY} 의 소유자가
* 이 클래스이기 때문이다. 이력을 읽는 statement 를 다른 sqlmap 으로 흩어 놓으면
* 적재 컬럼이 바뀔 때 조회 쪽이 조용히 어긋난다.</p>
*
* @param searchVO 유형·기간·페이징
*/
@SuppressWarnings("unchecked")
public List<AccessLogPurgeHistoryVO> selectPurgeHistoryList(AccessLogPurgeHistorySearchVO searchVO) {
return list("PersonalDataAccessLogDAO.selectPurgeHistoryList", searchVO);
}
/** 파기·증설 이력 총건수(페이징용). 목록과 <b>같은 검색조건</b>을 유지할 것. */
public int selectPurgeHistoryListCount(AccessLogPurgeHistorySearchVO searchVO) {
Object cnt = selectByPk("PersonalDataAccessLogDAO.selectPurgeHistoryListCount", searchVO);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
} }

View File

@ -0,0 +1,70 @@
/**
* Class Name : PrivacyAdminAlertService.java
* Description : 개인정보보호 관리자 알림 — 개인정보보호 기능개선 사업(제안서 slide15 "관리자 알림")
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO;
/**
* 개인정보 접속기록 관련 이상 상황을 <b>관리자에게 통보</b>한다.
*
* <p>제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 <b>관리자 알림</b>이다.
* 종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면 아무도 모르는
* 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.</p>
*
* <p><b>수신자</b>는 {@code ACCOUNT_MNG_POLICY.ADMIN_EMAIL}(계정 관리 정책 화면에서 설정)이며,
* 발송은 휴면 사전알림({@code DormantAlertService})이 쓰는 표준 메일 모듈을 그대로 재사용한다.
* 알림 채널을 새로 만들지 않는 이유는, 운영에서 메일 발송 경로가 하나여야 장애를 한 곳에서
* 확인할 수 있기 때문이다.</p>
*
* <p><b>메일에 개인정보 본문을 담지 않는다.</b> 알림에는 건수·취급자 계정ID·기간처럼 조치에
* 필요한 최소 정보만 넣는다. 상세는 관리자가 접속기록 화면에서 확인한다 — 메일함이
* 개인정보 저장소가 되면 안 된다.</p>
*
* <p><b>배치를 막지 않는다.</b> 모든 메서드는 실패해도 예외를 올리지 않는다(발송 실패로
* 파기·검증 배치가 중단되면 본말이 전도된다). 발송 여부와 무관하게 로그는 항상 남긴다.</p>
*/
public interface PrivacyAdminAlertService {
/**
* 접속기록 해시체인 <b>무결성 위반</b> 통보.
*
* @param result 검증 결과(위반이 아니면 아무것도 보내지 않는다)
* @return 발송 건수(0 = 미발송)
*/
int alertIntegrityViolation(HashChainVerifyResultVO result);
/**
* <b>이상행위 탐지</b> 통보(정기점검 배치가 월 1회 호출).
*
* @param periodFrom 집계 시작일시
* @param periodTo 집계 종료일시
* @param summary 탐지 요약 문자열({@code AbnormalAccessDetectionService.summarize})
* @param detected 탐지 합계(0이면 보내지 않는다 — 조용한 달에는 메일도 조용해야 한다)
* @return 발송 건수(0 = 미발송)
*/
int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected);
/**
* <b>테스트 발송</b> — 알림 설정이 실제로 동작하는지 확인한다.
*
* <p>실제 알림은 월 1회 배치에서만 나가므로, 설정을 바꾼 관리자가 그것이 맞는지
* 한 달을 기다려 확인해야 하는 문제가 있었다. 이 메서드는 같은 경로(수신주소 조회 →
* 메일 모듈 적재·발송)를 그대로 타서 즉시 결과를 알려준다 — 경로가 다르면
* 테스트가 통과해도 실제 알림은 실패할 수 있으므로 반드시 같은 경로여야 한다.</p>
*
* @param requesterId 발송을 요청한 취급자 계정ID(본문에 기록해 누가 눌렀는지 남긴다)
* @return 사람이 읽을 결과 문자열. 성공하면 수신 주소를, 실패하면 사유를 담는다.
*/
String sendTestMail(String requesterId);
}

View File

@ -0,0 +1,176 @@
/**
* Class Name : PrivacyAdminAlertServiceImpl.java
* Description : 개인정보보호 관리자 알림 구현 — 개인정보보호 기능개선 사업(제안서 slide15)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.batch;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import egovframework.com.cmm.privacy.account.AccountMngDAO;
import egovframework.com.cmm.privacy.account.AccountMngPolicyVO;
import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
import egovframework.com.cop.ems.service.SndngMailVO;
/**
* {@link PrivacyAdminAlertService} 구현.
*
* <p>발송 경로는 휴면 사전알림({@code DormantAlertServiceImpl.sendMail})과 동일하다 —
* {@code SndngMailVO} 를 만들어 표준 메일 모듈에 적재하면 발송 배치가 실제 전송을 맡는다.</p>
*/
@Service("privacyAdminAlertService")
public class PrivacyAdminAlertServiceImpl implements PrivacyAdminAlertService {
private static final Logger LOG = LoggerFactory.getLogger(PrivacyAdminAlertServiceImpl.class);
/** 메일 제목 접두 — 관리자 메일함에서 걸러 보기 쉽게 고정한다. */
private static final String SUBJECT_PREFIX = "[문화품앗이/개인정보] ";
/** 발신자 계정 — 기존 알림과 동일. */
private static final String SENDER = "webmaster";
@Resource(name = "accountMngDAO")
private AccountMngDAO accountMngDAO;
@Resource(name = "sndngMailRegistService")
private EgovSndngMailRegistService sndngMailRegistService;
@Override
public int alertIntegrityViolation(HashChainVerifyResultVO result) {
if (result == null || result.isOk()) {
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록의 해시체인 무결성 검증에서 불일치가 발견되었습니다.\n\n");
body.append("- 검증 일시 : ").append(nvl(result.getVerifiedAt())).append('\n');
body.append("- 검증 대상 : ").append(result.getTotalCount()).append("건\n");
body.append("- 최초 불일치 : 기록번호 ").append(result.getMismatchLogId()).append('\n');
body.append('\n');
body.append("접속기록은 각 행이 이전 행의 해시를 물고 있어, 중간의 어떤 행이 바뀌면\n");
body.append("그 뒤가 전부 어긋납니다. 위 기록번호부터가 검증에 실패한 지점입니다.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append("※ 이 메일에는 개인정보 본문이 포함되어 있지 않습니다. 상세는 화면에서 확인하십시오.\n");
return send("접속기록 무결성 위반 탐지", body.toString(), "무결성 위반");
}
@Override
public int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected) {
if (detected <= 0) {
// 조용한 달에는 메일도 조용해야 한다. 매달 "0건" 메일이 오면 곧 아무도 열지 않는다.
return 0;
}
StringBuilder body = new StringBuilder();
body.append("개인정보 접속기록 정기점검에서 이상행위가 탐지되었습니다.\n\n");
body.append("- 집계 기간 : ").append(nvl(periodFrom)).append(" ~ ").append(nvl(periodTo)).append('\n');
body.append("- 탐지 합계 : ").append(detected).append("건\n");
body.append("- 탐지 요약 : ").append(nvl(summary)).append('\n');
body.append('\n');
body.append("탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회나 야간작업이\n");
body.append("있을 수 있으므로, 아래 화면에서 원본 기록을 확인한 뒤 판단하십시오.\n\n");
body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n");
body.append(" (목록 오른쪽 '감사' 칸의 이상/점검 표시로 해당 기록을 찾을 수 있습니다)\n");
body.append("※ 이 메일에는 정보주체의 개인정보가 포함되어 있지 않습니다.\n");
return send("접속기록 이상행위 탐지", body.toString(), "이상행위 탐지");
}
@Override
public String sendTestMail(String requesterId) {
String adminEmail = resolveAdminEmail();
if (adminEmail == null) {
return "관리자 수신 이메일이 설정되어 있지 않습니다."
+ " 이 화면의 [관리자 수신 이메일] 항목에 주소를 입력하고 저장한 뒤 다시 시도하세요.";
}
StringBuilder body = new StringBuilder();
body.append("개인정보보호 관리자 알림 테스트 메일입니다.\n\n");
body.append("이 메일이 도착했다면 아래 알림이 같은 경로로 정상 발송됩니다.\n");
body.append(" - 접속기록 무결성 위반 탐지 (매월 1일 03:00 검증 배치)\n");
body.append(" - 접속기록 이상행위 탐지 (매월 1일 05:00 정기점검 배치)\n");
body.append(" - 계정 휴면 사전알림 (매일 06:00, D-30 / D-7 / D-Day)\n\n");
body.append("- 요청자 : ").append(nvl(requesterId)).append('\n');
body.append("- 수신주소 : ").append(adminEmail).append('\n');
body.append("- 발송시각 : ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date())).append('\n');
String err = doSend(adminEmail, "관리자 알림 테스트", body.toString(), "테스트");
if (err == null) {
return "테스트 메일을 발송했습니다. 수신함(" + adminEmail + ")을 확인하세요."
+ " 도착하지 않으면 메일서버 설정과 발송 이력(COMTNSNDNGMAIL)을 확인해야 합니다.";
}
return "발송에 실패했습니다: " + err;
}
/** 정책에서 관리자 수신 주소를 읽는다. 미설정·조회실패면 null. */
private String resolveAdminEmail() {
try {
AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAdminEmail() != null
&& policy.getAdminEmail().trim().length() > 0) {
return policy.getAdminEmail().trim();
}
} catch (Exception e) {
LOG.warn("[개인정보/알림] 정책 조회 실패: {}", e.toString());
}
return null;
}
/**
* 관리자 메일 적재·발송. 수신자 미설정이면 보내지 않되 <b>로그는 남긴다</b> —
* "알림이 안 왔다"와 "보낼 주소가 없다"를 구분할 수 있어야 한다.
*
* <p>표준 메일 모듈은 적재와 발송을 한 번에 한다({@code insertSndngMail} 이
* COMTNSNDNGMAIL 적재 후 곧바로 전송하고, 실패하면 발송결과코드를 'F' 로 갱신한다).
* 그래서 반환값 false 도 실패로 취급한다 — 예외가 없다고 발송된 것이 아니다.</p>
*
* @return 성공이면 null, 실패면 사유 문자열
*/
private String doSend(String adminEmail, String subject, String body, String kind) {
try {
SndngMailVO vo = new SndngMailVO();
vo.setDsptchPerson(SENDER);
vo.setRecptnPerson(adminEmail);
vo.setSj(SUBJECT_PREFIX + subject);
vo.setEmailCn(body);
vo.setAtchFileId("");
boolean ok = sndngMailRegistService.insertSndngMail(vo);
if (!ok) {
LOG.error("[개인정보/알림] {} 알림 발송 실패(메일모듈이 false 반환) → {}", kind, adminEmail);
return "메일 모듈이 발송 실패를 반환했습니다. 메일서버 설정을 확인하세요.";
}
LOG.info("[개인정보/알림] {} 알림 발송 완료 → {}", kind, adminEmail);
return null;
} catch (Exception e) {
// 발송 실패로 파기·검증 배치가 멈추면 본말이 전도된다.
LOG.error("[개인정보/알림] {} 알림 발송 오류 — 배치는 계속합니다: {}", kind, e.toString());
return e.toString();
}
}
/** 배치용 발송 — 결과를 건수로만 돌려준다. */
private int send(String subject, String body, String kind) {
String adminEmail = resolveAdminEmail();
if (adminEmail == null) {
LOG.warn("[개인정보/알림] 관리자 수신 이메일 미설정(ACCOUNT_MNG_POLICY.ADMIN_EMAIL) —"
+ " {} 알림을 보내지 못했습니다. 계정 관리 정책 화면에서 주소를 등록하십시오.", kind);
return 0;
}
return doSend(adminEmail, subject, body, kind) == null ? 1 : 0;
}
private String nvl(Object v) {
return v == null ? "-" : String.valueOf(v);
}
}

View File

@ -16,6 +16,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import egovframework.com.cmm.privacy.account.AccountMngPolicyVO;
import egovframework.com.cmm.privacy.auditlog.AbnormalAccessDetectionService; import egovframework.com.cmm.privacy.auditlog.AbnormalAccessDetectionService;
import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO; import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO;
import egovframework.com.cmm.privacy.auditlog.HashChainService; import egovframework.com.cmm.privacy.auditlog.HashChainService;
@ -46,6 +47,35 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
*/ */
private static final int ACCESS_LOG_RETENTION_DAYS = 730; private static final int ACCESS_LOG_RETENTION_DAYS = 730;
/**
* 접속기록 보관기간을 정책({@code ACCOUNT_MNG_POLICY.ACCESS_LOG_KEEP_DAY})에서 읽는다.
*
* <p>제안서 slide15 가 "관리자 설정값 미존재 → 신규 구축"으로 지목한 항목이라 상수 대신
* 정책값을 쓴다. 다만 <b>법정 하한(2년)은 정책값보다 우선</b>한다 — 정책 행을 직접 고쳐
* 730 미만으로 낮추더라도 파기 배치는 2년치를 남긴다. 한 번 지운 접속기록은 되돌릴 수
* 없으므로, 설정 실수로 법정 보관기간을 깨는 쪽보다 더 오래 남기는 쪽으로 기운다.</p>
*
* <p>정책 조회가 실패하면 상수 기본값을 쓴다(배치가 멈추면 파티션이 무한정 쌓인다).</p>
*/
private int resolveAccessLogRetentionDays() {
int days = ACCESS_LOG_RETENTION_DAYS;
try {
AccountMngPolicyVO policy = accountMngDAO.selectPolicy();
if (policy != null && policy.getAccessLogKeepDay() > 0) {
days = policy.getAccessLogKeepDay();
}
} catch (Exception e) {
LOG.warn("[개인정보/SFR-004] 접속기록 보관기간 정책 조회 실패 — 기본값 "
+ ACCESS_LOG_RETENTION_DAYS + "일 적용: " + e.toString());
}
if (days < AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY) {
LOG.warn("[개인정보/SFR-004] 설정된 보관기간(" + days + "일)이 법정 하한 미만 — "
+ AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY + "일로 보정해 파기한다.");
days = AccountMngPolicyVO.MIN_ACCESS_LOG_KEEP_DAY;
}
return days;
}
/** 미래 파티션 선행 확보 개월 수(당월 포함). 적재 시 파티션 누락 방지. */ /** 미래 파티션 선행 확보 개월 수(당월 포함). 적재 시 파티션 누락 방지. */
private static final int PARTITION_MONTHS_AHEAD = 2; private static final int PARTITION_MONTHS_AHEAD = 2;
@ -60,6 +90,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
@Resource(name = "accessLogPartitionDAO") @Resource(name = "accessLogPartitionDAO")
private AccessLogPartitionDAO accessLogPartitionDAO; private AccessLogPartitionDAO accessLogPartitionDAO;
/** 보관기간 정책 조회용(SFR-004 관리자 설정). */
@Resource(name = "accountMngDAO")
private egovframework.com.cmm.privacy.account.AccountMngDAO accountMngDAO;
@Resource(name = "inspectionReportDAO") @Resource(name = "inspectionReportDAO")
private InspectionReportDAO inspectionReportDAO; private InspectionReportDAO inspectionReportDAO;
@ -71,6 +105,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
@Resource(name = "accessLogDownloadHistoryDAO") @Resource(name = "accessLogDownloadHistoryDAO")
private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO;
/** 제안서 slide15 "관리자 알림" — 무결성 위반·이상행위 탐지 통보. */
@Resource(name = "privacyAdminAlertService")
private PrivacyAdminAlertService privacyAdminAlertService;
/** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */ /** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */
private static final int HANDLER_SUMMARY_TOP = 20; private static final int HANDLER_SUMMARY_TOP = 20;
@ -111,12 +149,36 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
} }
/** /**
* 무결성 위반 즉시 관리자 통보. * 무결성 위반 즉시 관리자 통보(제안서 slide15 "관리자 알림").
* TODO: 실제 통보 채널(관리자 메일/SMS/알림함 #49) 연동. 현재는 에러 로그로 남긴다. *
* <p>에러 로그는 그대로 남긴다 — 메일이 막혀 있어도 흔적은 서버에 있어야 한다.
* 실제 통보는 {@link PrivacyAdminAlertService} 가 관리자 메일로 보낸다.</p>
*/ */
private void notifyAdmin(HashChainVerifyResultVO result) { private void notifyAdmin(HashChainVerifyResultVO result) {
LOG.error("[개인정보/#40] 관리자 통보 필요 — 무결성 위반 LOG_ID=" + result.getMismatchLogId() LOG.error("[개인정보/#40] 관리자 통보 — 무결성 위반 LOG_ID=" + result.getMismatchLogId()
+ " (검증일시 " + result.getVerifiedAt() + "). TODO: 메일/SMS/알림함 연동."); + " (검증일시 " + result.getVerifiedAt() + ").");
privacyAdminAlertService.alertIntegrityViolation(result);
}
/**
* 기간 내 이상행위 탐지 합계. 알림 발송 여부를 정하는 값이라 요약 문자열을 파싱하지 않고
* 탐지 결과의 건수를 그대로 더한다(문자열 파싱은 문구가 바뀌면 조용히 어긋난다).
* 집계에 실패하면 0 을 돌려 알림을 보내지 않는다 — 근거 없이 알리지 않는다.
*/
private int countDetected(Map<String, Object> param) {
try {
List<egovframework.com.cmm.privacy.auditlog.AbnormalAccessResultVO> results =
abnormalAccessDetectionService.detect(param.get("fromDt").toString(),
param.get("toDt").toString());
int sum = 0;
for (int i = 0; results != null && i < results.size(); i++) {
sum += results.get(i).getCount();
}
return sum;
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 건수 집계 실패 - 알림 생략: " + e.getMessage());
return 0;
}
} }
@Override @Override
@ -200,6 +262,18 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
report.setDownloadCnt(downloadCnt); report.setDownloadCnt(downloadCnt);
inspectionReportDAO.insertInspectionReport(report); inspectionReportDAO.insertInspectionReport(report);
// [제안서 slide15 "관리자 알림"] 이상행위가 탐지된 달에만 관리자에게 통보한다.
// 리포트를 적재한 뒤에 보내는 이유는, 메일을 받은 관리자가 화면에서 바로 근거를
// 확인할 수 있어야 하기 때문이다(먼저 보내면 리포트가 아직 없을 수 있다).
try {
int detected = countDetected(param);
privacyAdminAlertService.alertAbnormalAccess(
report.getPeriodFrom(), report.getPeriodTo(), abnormalSummary, detected);
} catch (Exception e) {
LOG.error("[개인정보/알림] 이상행위 알림 처리 오류 - 리포트는 정상 생성됨: " + e.getMessage());
}
LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt
+ "), 무결성=" + report.getIntegrityResult() + ", 취급자 " + "), 무결성=" + report.getIntegrityResult() + ", 취급자 "
+ (handlers == null ? 0 : handlers.size()) + "종."); + (handlers == null ? 0 : handlers.size()) + "종.");
@ -227,7 +301,8 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
ensureFuturePartitions(); ensureFuturePartitions();
// 2) 보관기간(730일) 경과 파티션 파기. 파티션 상한(경계) <= 파기기준일이면 파티션 전체가 만료. // 2) 보관기간(730일) 경과 파티션 파기. 파티션 상한(경계) <= 파기기준일이면 파티션 전체가 만료.
Date cutoff = minusDays(new Date(), ACCESS_LOG_RETENTION_DAYS); int retentionDays = resolveAccessLogRetentionDays();
Date cutoff = minusDays(new Date(), retentionDays);
List<Map<String, Object>> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE); List<Map<String, Object>> partitions = accessLogPartitionDAO.selectPartitions(PDAL_TABLE);
int dropped = 0; int dropped = 0;
if (partitions != null) { if (partitions != null) {
@ -242,14 +317,14 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService {
if (!boundary.after(cutoff)) { // boundary <= cutoff → 파티션 전체가 보관기간 경과 if (!boundary.after(cutoff)) { // boundary <= cutoff → 파티션 전체가 보관기간 경과
accessLogPartitionDAO.dropPartition(name); accessLogPartitionDAO.dropPartition(name);
recordPurge(name, boundaryStr, "DROP", recordPurge(name, boundaryStr, "DROP",
"보관기간(" + ACCESS_LOG_RETENTION_DAYS + "일) 경과 파티션 파기"); "보관기간(" + retentionDays + "일) 경과 파티션 파기");
dropped++; dropped++;
LOG.info("[개인정보/#37] 접속기록 파티션 파기 — " + name + " (경계 " + boundaryStr + ")"); LOG.info("[개인정보/#37] 접속기록 파티션 파기 — " + name + " (경계 " + boundaryStr + ")");
} }
} }
} }
LOG.info("[개인정보/#37] 접속기록 파기 배치 완료 — 파티션 " + dropped LOG.info("[개인정보/#37] 접속기록 파기 배치 완료 — 파티션 " + dropped
+ "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + ACCESS_LOG_RETENTION_DAYS + "일)."); + "개 DROP(기준일시 " + formatDate(cutoff) + ", 보관 " + retentionDays + "일).");
return dropped; return dropped;
} }

View File

@ -0,0 +1,29 @@
package egovframework.com.cmm.privacy.conect;
import java.util.List;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62).
*
* <p>statement 는 {@code PrivacyConectLog_SQL_Mysql.xml}
* (namespace {@code ConectLogDAO})에 정의되어 있다. 2026-08-11 로그인·로그아웃 접속기록
* 조회화면이 삭제되면서 그 파일에 남은 statement 는 이것 하나뿐이지만, statement id 를
* 바꾸면 iBatis2 는 기동 시가 아니라 최초 호출 시점에 실패하고 {@link IpLocationResolver}
* 가 그 예외를 삼켜 CONECT_LC 가 조용히 전부 NULL 이 된다. namespace 를 그대로 둔다.</p>
*
* @author URP
* @since 2026-08-07
*/
@Repository("ipLocationDAO")
public class IpLocationDAO extends EgovComAbstractDAO {
/** 사용중(USE_AT='Y')인 IP 대역 매핑 전체. */
@SuppressWarnings("unchecked")
public List<IpLocationVO> selectUsedLocationList() {
return list("ConectLogDAO.selectUsedLocationList", null);
}
}

View File

@ -0,0 +1,189 @@
package egovframework.com.cmm.privacy.conect;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Component;
/**
* IP → 접속위치명 판정기 — 개인정보보호 기능개선 사업(#62).
*
* <p>{@code CONECT_IP_LOCATION}(IP 대역 → 위치명)을 캐시해 두고, 접속 IP 가 속하는
* 대역 중 <b>가장 긴 프리픽스</b> 1건의 위치명을 돌려준다. 매칭이 없으면
* 사설/루프백 대역 판정에 따른 내장 폴백을 쓴다 — 테이블이 비어 있어도 동작한다.</p>
*
* <p><b>왜 스냅샷인가:</b> 판정 결과는 적재 시점에
* {@code COMTNLOGINLOG.CONECT_LC} 로 기록된다. 나중에 대역 매핑을 바꿔도 과거
* 기록의 접속위치는 변하지 않아야 감사증빙으로 쓸 수 있다.</p>
*
* <p><b>fail-safe:</b> 이 판정은 로그인/로그아웃 경로에서 호출된다. 어떤 예외도
* 밖으로 던지지 않고 폴백 값을 돌려준다 — 접속위치 때문에 로그인이 막히면 안 된다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
* </pre>
*/
@Component("ipLocationResolver")
public class IpLocationResolver {
/** 매칭 실패 시 기본 위치명. */
public static final String LOC_EXTERNAL = "외부(인터넷)";
/** 사설 대역 폴백. */
public static final String LOC_INTERNAL = "내부망";
/** 루프백 폴백. */
public static final String LOC_LOCAL = "서버 로컬";
/** IP 자체를 못 얻은 경우. */
public static final String LOC_UNKNOWN = "확인불가";
/** 캐시 재적재 주기(ms). 운영 중 대역 행을 추가해도 이 주기 안에 반영된다. */
private static final long RELOAD_INTERVAL_MS = 5L * 60L * 1000L;
@Resource(name = "ipLocationDAO")
private IpLocationDAO ipLocationDAO;
/** 프리픽스 길이 내림차순으로 정렬된 매핑 캐시(가장 긴 것이 먼저 매칭된다). */
private volatile List<IpLocationVO> cache = new ArrayList<IpLocationVO>();
/** 마지막 적재 시각(0 = 미적재). */
private volatile long loadedAt = 0L;
/**
* 접속 IP 의 위치명을 판정한다.
*
* @param ip 점 표기 IPv4(IPv6/불명이면 폴백)
* @return 위치명(항상 비어 있지 않은 문자열)
*/
public String resolve(String ip) {
if (ip == null || ip.trim().length() == 0 || "unknown".equalsIgnoreCase(ip.trim())) {
return LOC_UNKNOWN;
}
String target = ip.trim();
long addr = ipv4ToLong(target);
if (addr < 0L) {
// IPv6 등 — 루프백만 별도로 알아보고 나머지는 외부로 본다.
return ("::1".equals(target) || "0:0:0:0:0:0:0:1".equals(target)) ? LOC_LOCAL : LOC_EXTERNAL;
}
for (IpLocationVO loc : currentCache()) {
if (loc.getPrefixLen() < 0) {
continue; // 파싱 불가 행은 무시(운영자가 잘못 입력해도 판정은 계속된다)
}
long mask = (loc.getPrefixLen() == 0) ? 0L : (0xFFFFFFFFL << (32 - loc.getPrefixLen())) & 0xFFFFFFFFL;
if ((addr & mask) == (loc.getNetwork() & mask)) {
return (loc.getLocNm() == null || loc.getLocNm().length() == 0) ? LOC_EXTERNAL : loc.getLocNm();
}
}
return fallback(addr);
}
/** 매핑 캐시를 즉시 비운다(대역 행을 고친 직후 반영용). */
public void refresh() {
loadedAt = 0L;
}
/** 유효기간이 지났으면 다시 읽고, 실패하면 직전 캐시를 그대로 쓴다. */
private List<IpLocationVO> currentCache() {
long now = System.currentTimeMillis();
if (loadedAt != 0L && (now - loadedAt) < RELOAD_INTERVAL_MS) {
return cache;
}
try {
List<IpLocationVO> loaded = ipLocationDAO.selectUsedLocationList();
if (loaded == null) {
loaded = new ArrayList<IpLocationVO>();
}
for (IpLocationVO loc : loaded) {
parseCidr(loc);
}
Collections.sort(loaded, new Comparator<IpLocationVO>() {
public int compare(IpLocationVO a, IpLocationVO b) {
// 프리픽스가 긴(=더 구체적인) 대역이 먼저 매칭되어야 한다.
return b.getPrefixLen() - a.getPrefixLen();
}
});
cache = loaded;
loadedAt = now;
} catch (Exception e) {
// 테이블 미적용·DB 일시 오류 — 직전 캐시(초기엔 빈 목록)로 계속 판정한다.
loadedAt = now; // 매 요청마다 실패 조회를 반복하지 않도록 주기는 넘긴다
}
return cache;
}
/** "10.0.0.0/8" → network/prefixLen. 형식 오류면 prefixLen = -1(무시 대상). */
private void parseCidr(IpLocationVO loc) {
loc.setNetwork(-1L);
loc.setPrefixLen(-1);
String cidr = loc.getIpCidr();
if (cidr == null) {
return;
}
String trimmed = cidr.trim();
int slash = trimmed.indexOf('/');
String network = (slash < 0) ? trimmed : trimmed.substring(0, slash);
int prefix;
try {
prefix = (slash < 0) ? 32 : Integer.parseInt(trimmed.substring(slash + 1).trim());
} catch (NumberFormatException e) {
return;
}
if (prefix < 0 || prefix > 32) {
return;
}
long net = ipv4ToLong(network);
if (net < 0L) {
return;
}
loc.setNetwork(net);
loc.setPrefixLen(prefix);
}
/** 테이블 매칭이 없을 때의 내장 판정(루프백/사설/그 외). */
private String fallback(long addr) {
if (inCidr(addr, "127.0.0.0", 8)) {
return LOC_LOCAL;
}
if (inCidr(addr, "10.0.0.0", 8) || inCidr(addr, "172.16.0.0", 12) || inCidr(addr, "192.168.0.0", 16)) {
return LOC_INTERNAL;
}
return LOC_EXTERNAL;
}
private boolean inCidr(long addr, String network, int prefix) {
long net = ipv4ToLong(network);
long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL;
return (addr & mask) == (net & mask);
}
/** 점 표기 IPv4 → 부호없는 long. 형식 오류(IPv6 포함) 시 -1. */
private static long ipv4ToLong(String ip) {
if (ip == null) {
return -1L;
}
String[] octets = ip.trim().split("\\.");
if (octets.length != 4) {
return -1L;
}
long result = 0L;
for (String octet : octets) {
int v;
try {
v = Integer.parseInt(octet.trim());
} catch (NumberFormatException e) {
return -1L;
}
if (v < 0 || v > 255) {
return -1L;
}
result = (result << 8) | v;
}
return result & 0xFFFFFFFFL;
}
}

View File

@ -0,0 +1,64 @@
package egovframework.com.cmm.privacy.conect;
import java.io.Serializable;
/**
* 접속위치 매핑 VO — 개인정보보호 기능개선 사업(#62, 로그인·로그아웃 접속기록).
*
* <p>{@code CONECT_IP_LOCATION} 1행. IP 대역(CIDR)과 그 대역에 붙일 위치명이다.
* 접속기록 적재 시점에 {@link IpLocationResolver}가 가장 긴 프리픽스 1건을 골라
* {@code COMTNLOGINLOG.CONECT_LC}에 스냅샷으로 남긴다 — 나중에 매핑을 바꿔도
* 과거 기록의 위치는 변하지 않는다(감사증빙).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
* </pre>
*/
public class IpLocationVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 일련번호(PK). */
private long locId;
/** IP 대역(CIDR 표기. 예 "10.0.0.0/8", "203.0.113.7/32"). */
private String ipCidr;
/** 접속위치명. */
private String locNm;
/** 내외부 구분 I:내부망 E:외부. */
private String locSe;
/** 정렬순서. */
private int sortOrdr;
/** 사용여부 Y/N. */
private String useAt;
/** 비고. */
private String remark;
// ---- CIDR 파싱 결과(캐시 적재 시 1회 계산, DB 컬럼 아님) ----
/** 네트워크 주소(부호없는 IPv4). 파싱 실패 시 -1. */
private transient long network = -1L;
/** 프리픽스 길이(0~32). 파싱 실패 시 -1. */
private transient int prefixLen = -1;
public long getLocId() { return locId; }
public void setLocId(long locId) { this.locId = locId; }
public String getIpCidr() { return ipCidr; }
public void setIpCidr(String ipCidr) { this.ipCidr = ipCidr; }
public String getLocNm() { return locNm; }
public void setLocNm(String locNm) { this.locNm = locNm; }
public String getLocSe() { return locSe; }
public void setLocSe(String locSe) { this.locSe = locSe; }
public int getSortOrdr() { return sortOrdr; }
public void setSortOrdr(int sortOrdr) { this.sortOrdr = sortOrdr; }
public String getUseAt() { return useAt; }
public void setUseAt(String useAt) { this.useAt = useAt; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public long getNetwork() { return network; }
public void setNetwork(long network) { this.network = network; }
public int getPrefixLen() { return prefixLen; }
public void setPrefixLen(int prefixLen) { this.prefixLen = prefixLen; }
}

View File

@ -0,0 +1,90 @@
/**
* Class Name : MberCryptoAccessHistoryVO.java
* Description : 암호화 회원정보 관리자 접근 이력 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 사유·재인증 이력
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.io.Serializable;
/**
* {@code MBER_CRYPTO_ACCESS_HISTORY} 한 행 — 관리자가 평문 개인정보를 열람·변경한 행위의 소명 근거.
*
* <p><b>개인정보 본문은 담지 않는다.</b> 무엇을 바꿨는지는 {@link #changedItems}(항목명 목록)로만
* 남기고 값은 적재하지 않는다 — 이력이 또 하나의 평문 개인정보 저장소가 되면 안 된다.</p>
*/
public class MberCryptoAccessHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 행위 — 복호화 조회. */
public static final String ACTION_SELECT = "S";
/** 행위 — 신규 등록. */
public static final String ACTION_INSERT = "I";
/** 행위 — 수정. */
public static final String ACTION_UPDATE = "U";
/** 행위 — 개인정보 파기. */
public static final String ACTION_DELETE = "D";
private long histId;
/** 처리자(취급자) 계정ID. */
private String handlrId;
/** 행위 S/I/U/D. */
private String actionTy;
/** 대상 회원ID. */
private String trgetMberId;
/** 사유(필수). */
private String accessReason;
/** 변경 항목명 목록(값 미적재). */
private String changedItems;
/** 처리자 IP. */
private String handlrIp;
/** 재인증 통과 여부 Y/N. */
private String reauthYn;
/** 처리 일시. */
private String accessDt;
public long getHistId() { return histId; }
public void setHistId(long histId) { this.histId = histId; }
public String getHandlrId() { return handlrId; }
public void setHandlrId(String handlrId) { this.handlrId = handlrId; }
public String getActionTy() { return actionTy; }
public void setActionTy(String actionTy) { this.actionTy = actionTy; }
public String getTrgetMberId() { return trgetMberId; }
public void setTrgetMberId(String trgetMberId) { this.trgetMberId = trgetMberId; }
public String getAccessReason() { return accessReason; }
public void setAccessReason(String accessReason) { this.accessReason = accessReason; }
public String getChangedItems() { return changedItems; }
public void setChangedItems(String changedItems) { this.changedItems = changedItems; }
public String getHandlrIp() { return handlrIp; }
public void setHandlrIp(String handlrIp) { this.handlrIp = handlrIp; }
public String getReauthYn() { return reauthYn; }
public void setReauthYn(String reauthYn) { this.reauthYn = reauthYn; }
public String getAccessDt() { return accessDt; }
public void setAccessDt(String accessDt) { this.accessDt = accessDt; }
/** 행위코드의 한글 표기(화면·리포트용). */
public String getActionNm() {
if (ACTION_SELECT.equals(actionTy)) { return "복호화조회"; }
if (ACTION_INSERT.equals(actionTy)) { return "등록"; }
if (ACTION_UPDATE.equals(actionTy)) { return "수정"; }
if (ACTION_DELETE.equals(actionTy)) { return "개인정보파기"; }
return actionTy;
}
}

View File

@ -0,0 +1,438 @@
/**
* Class Name : MberCryptoAdminController.java
* Description : 암호화 회원정보 관리자 전용 CRUD 화면 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 복호화 조회·수정·삭제·등록 + 사유·재인증
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
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.privacy.auditlog.ClientIpResolver;
import egovframework.com.cmm.privacy.util.MaskingUtil;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 관리자 전용 회원정보(암호화 항목) 화면 — 제안서 추가제안 Action4(슬라이드16).
*
* <p><b>화면 구성</b>
* <ul>
* <li>{@code listView.do} 목록 — <b>항상 마스킹</b>해서 보여준다(이름 홍*동, 휴대폰 010-****-1234).</li>
* <li>{@code view.do} 상세 — <b>사유 입력 + 재인증</b>을 통과해야 평문이 보인다.</li>
* <li>{@code registView.do}/{@code regist.do} 신규 등록, {@code update.do} 수정,
* {@code deletePii.do} 개인정보 파기 — 모두 사유·재인증 필수.</li>
* <li>{@code historyView.do} 이 화면에서 일어난 접근 이력(사유 포함) 조회.</li>
* </ul>
*
* <p><b>목록을 마스킹으로 고정한 이유:</b> 관리자가 목록만 열어도 평문 이름·연락처가 화면 한 장에
* 수십 건씩 뜨면, 사유를 받는 의미가 사라진다(정작 대량 노출은 목록에서 일어난다).
* 평문은 대상을 특정해 사유를 남긴 <u>상세 한 건</u>에서만 보이게 한다.</p>
*
* <p><b>접근통제 3중:</b> ① {@code urlRoleMapping} 의 {@code /mnt/sec/*} = ROLE_ADMIN(전체관리자
* 전용) ② {@link #handlerUser()} 로 업무사용자(USR) 세션 재확인 ③ 평문에 닿는 모든 경로에서
* {@link MberCryptoReauth} 재인증(10분). 여기에 접속기록 인터셉터가 {@code /mnt/sec/**} 를
* 자동 수집하므로 '언제 누가 열었는가'는 별도로도 남는다.</p>
*
* <p><b>상태 변경은 POST 만 받는다.</b> 등록·수정·파기가 GET 으로도 열리면 링크 한 번, 이미지
* 태그 하나로 실행될 수 있다.</p>
*/
@Controller
public class MberCryptoAdminController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
private static final String LIST_REDIRECT = "redirect:/mnt/sec/mberCrypto/listView.do?page=9100051";
/** 재인증 화면으로 되돌아올 수 있는 경로의 접두사. 이 밖의 값은 받지 않는다(오픈 리다이렉트 차단). */
private static final String RETURN_URL_PREFIX = "/mnt/sec/mberCrypto/";
@Resource(name = "mberCryptoAdminService")
private MberCryptoAdminService mberCryptoAdminService;
/* ================================================================
* 목록 — 항상 마스킹
* ================================================================ */
/**
* 회원정보 목록(마스킹). 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp
*
* <p>이름 검색은 완전일치만 된다(암호문은 부분검색이 원리적으로 불가). 그 사실을 화면 안내에
* 명시하고, 검색 결과가 0건일 때 "부분일치로는 찾을 수 없다"는 점을 함께 보여준다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/listView.do")
public String listView(@ModelAttribute("mberCryptoSearchVO") MberCryptoSearchVO searchVO,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (searchVO.getPageIndex() < 1) {
searchVO.setPageIndex(1);
}
if (searchVO.getRecordCountPerPage() < 1) {
searchVO.setRecordCountPerPage(10);
}
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
List<MberCryptoVO> list = mberCryptoAdminService.selectMberList(searchVO);
maskAll(list);
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);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/list";
}
/* ================================================================
* 재인증
* ================================================================ */
/** 재인증 입력 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/reauthView.do")
public String reauthView(@RequestParam(value = "returnUrl", required = false) String returnUrl,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
model.addAttribute("returnUrl", safeReturnUrl(returnUrl));
model.addAttribute("resultMsg", resultMsg);
return "site/mnt/sec/mberCrypto/reauth";
}
/**
* 재인증 처리(본인 비밀번호 재확인). 성공하면 원래 가려던 화면으로 돌려보낸다.
*
* <p>실패 문구는 사유를 구분하지 않는다(계정 없음/비밀번호 불일치 동일) — 계정 열거를 막는
* 이 사업의 기존 처리(#23)와 같은 방침이다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/reauth.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String reauth(@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "returnUrl", required = false) String returnUrl,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
String target = safeReturnUrl(returnUrl);
if (mberCryptoAdminService.verifyHandlerPassword(user.getId(), password)) {
MberCryptoReauth.markVerified(request);
return "redirect:" + target;
}
MberCryptoReauth.clear(request);
return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl=" + URLEncoder.encode(target, "UTF-8")
+ "&resultMsg=" + URLEncoder.encode("본인 확인에 실패했습니다. 비밀번호를 다시 입력하세요.", "UTF-8");
}
/* ================================================================
* 상세(복호화) — 사유 + 재인증
* ================================================================ */
/**
* 회원 상세(평문). 사유 미입력이면 열지 않고, 재인증이 만료됐으면 재인증 화면으로 보낸다.
* 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/view.do")
public String view(@RequestParam(value = "mberId", required = false) String mberId,
@RequestParam(value = "reason", required = false) String reason,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (isBlank(mberId)) {
return listRedirect("대상 회원이 지정되지 않았습니다.");
}
if (isBlank(reason)) {
return listRedirect("개인정보 조회 사유는 필수입니다. 목록에서 사유를 입력한 뒤 조회하세요.");
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(mberId, reason));
}
MberCryptoVO vo;
try {
vo = mberCryptoAdminService.selectMber(mberId);
} catch (IllegalStateException e) {
// 복호화 실패 — 상세는 값이 정확해야 하므로 열지 않는다.
// 다만 스택트레이스를 그대로 띄우지 않고 원인을 문장으로 알린다.
return listRedirect("이 회원의 이름을 복호화할 수 없습니다."
+ " 저장된 암호문이 현재 암호화 키와 맞지 않습니다(키 교체 또는 암호문 손상)."
+ " 대상 회원ID: " + mberId);
}
if (vo == null) {
return listRedirect("대상 회원을 찾을 수 없습니다.");
}
// 평문을 실제로 화면에 띄우는 시점에 사유와 함께 이력을 남긴다.
mberCryptoAdminService.recordDecryptView(mberId, user.getId(),
ClientIpResolver.getClientIp(request), reason);
model.addAttribute("mber", vo);
model.addAttribute("reason", reason);
model.addAttribute("resultMsg", resultMsg);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/view";
}
/* ================================================================
* 등록 / 수정 / 파기 — 모두 POST + 사유 + 재인증
* ================================================================ */
/** 신규 등록 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/registView.do")
public String registView(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051");
}
model.addAttribute("resultMsg", resultMsg);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/regist";
}
/** 신규 등록 처리. */
@RequestMapping(value = "/mnt/sec/mberCrypto/regist.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String regist(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051");
}
try {
String mberId = mberCryptoAdminService.registMber(vo, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect("회원을 등록했습니다: " + mberId);
} catch (IllegalArgumentException e) {
return registRedirect(e.getMessage());
}
}
/** 수정 처리. */
@RequestMapping(value = "/mnt/sec/mberCrypto/update.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String update(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(vo.getMberId(), reason));
}
try {
int cnt = mberCryptoAdminService.updateMber(vo, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect(cnt > 0 ? "회원정보를 수정했습니다." : "수정 대상이 없습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/**
* 개인정보 파기 처리.
*
* <p>되돌릴 수 없는 작업이라 화면에서 회원ID를 직접 입력해 확인시키고
* ({@code confirmId}), 서버에서도 대상과 일치하는지 다시 본다 — 확인 절차가 화면에만 있으면
* 요청을 직접 만드는 경로에서는 없는 것과 같다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/deletePii.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String deletePii(@RequestParam(value = "mberId", required = false) String mberId,
@RequestParam(value = "confirmId", required = false) String confirmId,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(mberId, reason));
}
if (isBlank(mberId) || confirmId == null || !mberId.trim().equals(confirmId.trim())) {
return listRedirect("파기 확인을 위해 대상 회원ID를 정확히 입력해야 합니다.");
}
try {
int cnt = mberCryptoAdminService.deleteMberPii(mberId, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect(cnt > 0
? "개인정보를 파기하고 회원상태를 탈퇴로 전환했습니다: " + mberId.trim()
: "파기 대상이 없습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/* ================================================================
* 접근 이력
* ================================================================ */
/** 이 화면에서 일어난 접근 이력(사유 포함) 조회. 뷰: .../mberCrypto/history.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/historyView.do")
public String historyView(@RequestParam(value = "trgetMberId", required = false) String trgetMberId,
@RequestParam(value = "handlrId", required = false) String handlrId,
@RequestParam(value = "actionTy", required = false) String actionTy,
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") int pageIndex,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
int page = pageIndex < 1 ? 1 : pageIndex;
int perPage = 10;
Map<String, Object> param = new HashMap<String, Object>();
param.put("trgetMberId", trim(trgetMberId));
param.put("handlrId", trim(handlrId));
param.put("actionTy", trim(actionTy));
param.put("firstIndex", Integer.valueOf((page - 1) * perPage));
param.put("recordCountPerPage", Integer.valueOf(perPage));
int total = mberCryptoAdminService.selectAccessHistoryListCount(param);
List<MberCryptoAccessHistoryVO> list = mberCryptoAdminService.selectAccessHistoryList(param);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(page);
paginationInfo.setRecordCountPerPage(perPage);
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(total);
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("totalCount", Integer.valueOf(total));
model.addAttribute("trgetMberId", trgetMberId);
model.addAttribute("handlrId", handlrId);
model.addAttribute("actionTy", actionTy);
return "site/mnt/sec/mberCrypto/history";
}
/* ================================================================
* 내부 헬퍼
* ================================================================ */
/** 목록에 실릴 값을 전부 마스킹한다(평문은 상세에서만 보여준다). */
private void maskAll(List<MberCryptoVO> list) {
if (list == null) {
return;
}
for (int i = 0; i < list.size(); i++) {
MberCryptoVO vo = list.get(i);
// 복호화 실패 표식은 이름이 아니므로 마스킹하지 않는다(마스킹하면 표식이 뭉개진다).
if (!vo.isDecryptFailed()) {
vo.setMberNm(MaskingUtil.maskName(vo.getMberNm()));
}
vo.setMoblphonNo(MaskingUtil.maskPhone(vo.getMoblphonNo()));
vo.setMberEmailAdres(MaskingUtil.maskEmail(vo.getMberEmailAdres()));
// 암호문·해시는 화면으로 나갈 이유가 없다. 목록 모델에서 지운다.
vo.setMberNmEnc(null);
vo.setMberNmHash(null);
}
}
/**
* 돌아갈 URL 을 이 화면 대역으로 제한한다.
* 외부 주소나 다른 화면이 들어오면 목록으로 되돌린다(오픈 리다이렉트 차단).
*/
private String safeReturnUrl(String returnUrl) {
if (returnUrl == null) {
return RETURN_URL_PREFIX + "listView.do?page=9100051";
}
String u = returnUrl.trim();
// "//host" 형태는 프로토콜 상대 URL 이라 외부로 나간다 — 접두사 검사만으로는 막히지 않는다.
if (u.startsWith(RETURN_URL_PREFIX) && !u.startsWith("//")) {
return u;
}
return RETURN_URL_PREFIX + "listView.do?page=9100051";
}
private String viewUrl(String mberId, String reason) throws Exception {
StringBuilder sb = new StringBuilder(RETURN_URL_PREFIX).append("view.do?page=9100051");
if (!isBlank(mberId)) {
sb.append("&mberId=").append(URLEncoder.encode(mberId.trim(), "UTF-8"));
}
if (!isBlank(reason)) {
sb.append("&reason=").append(URLEncoder.encode(reason.trim(), "UTF-8"));
}
return sb.toString();
}
private String reauthRedirect(String returnUrl) throws Exception {
return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl="
+ URLEncoder.encode(returnUrl, "UTF-8");
}
private String listRedirect(String message) throws Exception {
if (message == null) {
return LIST_REDIRECT;
}
return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
private String registRedirect(String message) throws Exception {
String base = "redirect:/mnt/sec/mberCrypto/registView.do?page=9100051";
if (message == null) {
return base;
}
return base + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
private String trim(String v) {
return (v == null || v.trim().length() == 0) ? null : v.trim();
}
private boolean isBlank(String v) {
return v == null || v.trim().length() == 0;
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 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

@ -0,0 +1,129 @@
/**
* Class Name : MberCryptoAdminDAO.java
* Description : 암호화 회원정보 관리자 CRUD DAO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 관리자 전용 회원정보 화면의 데이터 접근.
*
* <p><b>복호화 지점을 여기 한 곳으로 모은다.</b> 조회 statement 는 {@code MBER_NM_ENC} 를
* {@code mberNm} 별칭으로 가져오고, 이 DAO 가 반환 직전
* {@link MemberNmDecryptSupport} 로 같은 자리에 평문을 되돌려 쓴다. 서비스·화면은 복호화를
* 의식하지 않는다 — 읽기 전환 시 정착된 이 코드베이스의 관용을 그대로 따른다.</p>
*
* <p><b>기존 회원관리(mberManageDAO)와 왜 분리했나:</b> 표준 회원관리 VO 는 활동지역·재능 등
* 수십 개 필드를 함께 다뤄 이 화면이 필요로 하지 않는 값까지 오간다. 개인정보 항목만 좁게
* 취급하는 별도 계층을 두는 편이 "무엇을 읽고 무엇을 바꿨는가"를 이력에 정확히 남기기 쉽다.</p>
*/
@Repository("mberCryptoAdminDAO")
public class MberCryptoAdminDAO extends EgovComAbstractDAO {
/** 목록에서 복호화가 실패한 행의 이름 자리에 대신 넣는 표식. */
public static final String DECRYPT_FAIL_MARK = "(복호화 실패)";
/**
* 회원 목록(검색·페이징). 반환 직전 이름을 복호화해 {@code mberNm} 에 되돌려 쓴다.
*
* <p><b>행 단위로 실패를 견딘다.</b> 공용 {@link MemberNmDecryptSupport} 는 실패를 숨기지
* 않도록 예외를 던지는데, 목록에서 그대로 쓰면 한 행이 깨진 순간 페이지 전체가 죽는다.
* 키를 교체했거나 암호문 한 건이 손상된 상황에서 관리자 화면마저 열리지 않으면 원인을
* 확인할 방법이 없어진다. 그래서 여기서만 행 단위로 감싸 실패한 행에 표식을 남기고
* 나머지는 정상 표시한다 — 실패를 감추는 게 아니라 어느 행인지 드러내는 처리다.
* 단건 조회({@link #selectMber})는 값이 정확해야 하므로 종전대로 예외를 그대로 올린다.</p>
*/
@SuppressWarnings("unchecked")
public List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) {
List<MberCryptoVO> rows = list("MberCryptoAdminDAO.selectMberList", searchVO);
if (rows != null) {
for (int i = 0; i < rows.size(); i++) {
MberCryptoVO row = rows.get(i);
try {
MemberNmDecryptSupport.decryptInPlace(row, "mberNm");
} catch (RuntimeException e) {
row.setMberNm(DECRYPT_FAIL_MARK);
row.setDecryptFailed(true);
}
}
}
return rows;
}
/** 회원 목록 총건수(페이징용). 목록과 <b>같은 검색조건</b>을 유지할 것. */
public int selectMberListCount(MberCryptoSearchVO searchVO) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectMberListCount", searchVO);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
/** 회원 단건. 없으면 null. 반환 직전 이름을 복호화한다. */
public MberCryptoVO selectMber(String mberId) {
MberCryptoVO row = (MberCryptoVO) selectByPk("MberCryptoAdminDAO.selectMber", mberId);
MemberNmDecryptSupport.decryptInPlace(row, "mberNm");
return row;
}
/** 회원ID 중복 여부(신규 등록 전 확인). */
public int selectMberIdCount(String mberId) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectMberIdCount", mberId);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
/**
* 신규 회원 등록. 이름은 암호문·해시로만 저장하며, 평문 컬럼에는 표식만 들어간다
* (컬럼이 NOT NULL 이라 비울 수 없다 — sqlmap 주석 참조).
*/
public void insertMber(MberCryptoVO vo) {
insert("MberCryptoAdminDAO.insertMber", vo);
}
/** 회원 개인정보 수정(이름은 암호문·해시 동시 갱신). */
public int updateMber(MberCryptoVO vo) {
return update("MberCryptoAdminDAO.updateMber", vo);
}
/**
* 개인정보 파기 — 행을 지우지 않고 개인정보 항목만 비우고 상태를 탈퇴('D')로 바꾼다.
* 물리 삭제를 하지 않는 이유는 서비스 Javadoc 참조(참조 무결성·통계 정합).
*/
public int deleteMberPii(MberCryptoVO vo) {
return update("MberCryptoAdminDAO.deleteMberPii", vo);
}
/** 취급자 비밀번호(재인증 대조용). 계정이 없으면 null. */
public String selectHandlerPassword(String emplyrId) {
return (String) selectByPk("MberCryptoAdminDAO.selectHandlerPassword", emplyrId);
}
/** 관리자 접근 이력 적재(사유·재인증 여부 포함). */
public void insertAccessHistory(MberCryptoAccessHistoryVO vo) {
insert("MberCryptoAdminDAO.insertAccessHistory", vo);
}
/** 관리자 접근 이력 목록. param: trgetMberId/handlrId/actionTy/fromDt/toDt/firstIndex/recordCountPerPage */
@SuppressWarnings("unchecked")
public List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) {
return list("MberCryptoAdminDAO.selectAccessHistoryList", param);
}
/** 관리자 접근 이력 총건수. */
public int selectAccessHistoryListCount(Map<String, Object> param) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectAccessHistoryListCount", param);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
}

View File

@ -0,0 +1,83 @@
/**
* Class Name : MberCryptoAdminService.java
* Description : 암호화 회원정보 관리자 CRUD 서비스 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
/**
* 관리자 전용 회원정보(암호화 항목) CRUD.
*
* <p>제안서 추가제안 Action4(슬라이드16)의 요건을 구현한다 —
* <b>관리자 전용 화면에서 복호화 조회·수정·삭제·신규 등록, 조회·수정·삭제 시 사유 입력 및 재인증 연계.</b></p>
*
* <p><b>사유는 서비스가 강제한다.</b> 화면에서만 막으면 URL 을 직접 부르는 경로가 뚫린다.
* 사유가 비어 있으면 {@link IllegalArgumentException} 으로 작업 자체를 거절한다
* (접속기록 다운로드 사유 필수 처리와 같은 방식).</p>
*
* <p><b>모든 변경은 이력과 한 트랜잭션이다.</b> {@code egovframework.com..*Impl.*} 이
* 트랜잭션 어드바이스 대상이라, 회원정보 변경만 커밋되고 이력이 빠지는 상태가 생기지 않는다.</p>
*/
public interface MberCryptoAdminService {
/** 회원 목록(검색·페이징). 이름 검색은 완전일치(해시)로 처리된다. */
List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) throws Exception;
/** 회원 단건 조회(복호화). 없으면 null. */
MberCryptoVO selectMber(String mberId) throws Exception;
/**
* 복호화 상세 조회 행위를 이력에 남긴다(SFR: 조회 시 사유 입력).
*
* @throws IllegalArgumentException 사유 미입력
*/
void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 신규 회원 등록. 이름은 암호문·해시로 저장한다.
*
* @return 등록된 회원ID
* @throws IllegalArgumentException 필수값 누락·사유 미입력·회원ID 중복
*/
String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 회원 개인정보 수정. 바뀐 항목명만 이력에 남긴다(값은 남기지 않는다).
*
* @return 갱신 건수(대상 없음 0)
* @throws IllegalArgumentException 사유 미입력·대상 회원 없음
*/
int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 개인정보 파기 — 행은 남기고 개인정보 항목만 비운 뒤 상태를 탈퇴('D')로 바꾼다.
*
* @return 처리 건수
* @throws IllegalArgumentException 사유 미입력·대상 회원 없음
*/
int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 취급자 비밀번호 재확인(재인증).
*
* @return 일치하면 true
*/
boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception;
/** 관리자 접근 이력 목록. */
List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) throws Exception;
/** 관리자 접근 이력 총건수. */
int selectAccessHistoryListCount(Map<String, Object> param) throws Exception;
}

View File

@ -0,0 +1,317 @@
/**
* Class Name : MberCryptoAdminServiceImpl.java
* Description : 암호화 회원정보 관리자 CRUD 서비스 구현 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import egovframework.com.utl.sim.service.EgovFileScrty;
import egovframework.rte.fdl.idgnr.EgovIdGnrService;
/**
* {@link MberCryptoAdminService} 구현.
*
* <p><b>암호화 헬퍼를 필드로 들고 있는 이유:</b> {@link MemberDataCipher}·{@link MemberDataHasher}
* 는 생성 시점에 키·pepper 를 읽고 없으면 예외를 던진다. 매 호출마다 새로 만들면 키 미설정을
* 요청 시점에야 알게 되므로, 최초 사용 시 한 번 만들어 재사용한다(둘 다 불변·스레드 안전).</p>
*
* <p><b>왜 지연 생성인가:</b> 필드 초기화로 생성하면 키가 없는 개발 환경에서 이 빈 하나 때문에
* 컨텍스트 기동이 통째로 실패한다. 회원정보 암호화가 적용되지 않은 환경에서도 나머지 관리자
* 기능은 떠야 하므로, 이 화면을 실제로 쓸 때 실패하게 미룬다.</p>
*/
@Service("mberCryptoAdminService")
public class MberCryptoAdminServiceImpl implements MberCryptoAdminService {
/** 파기 후 평문 이름 컬럼(NOT NULL)에 남기는 표식. */
private static final String PII_PURGED_MARK = "(파기)";
/** 신규 등록 시 평문 이름 컬럼(NOT NULL)에 남기는 표식 — 평문은 저장하지 않는다. */
private static final String ENCRYPTED_MARK = "(암호화)";
@Resource(name = "mberCryptoAdminDAO")
private MberCryptoAdminDAO mberCryptoAdminDAO;
/** ESNTL_ID 채번 — 표준 회원관리(EgovMberManageServiceImpl)와 같은 채번기를 쓴다. */
@Resource(name = "egovUsrCnfrmIdGnrService")
private EgovIdGnrService idgenService;
private volatile MemberDataCipher cipher;
private volatile MemberDataHasher hasher;
private MemberDataCipher cipher() {
MemberDataCipher c = cipher;
if (c == null) {
synchronized (this) {
c = cipher;
if (c == null) {
c = new MemberDataCipher();
cipher = c;
}
}
}
return c;
}
private MemberDataHasher hasher() {
MemberDataHasher h = hasher;
if (h == null) {
synchronized (this) {
h = hasher;
if (h == null) {
h = new MemberDataHasher();
hasher = h;
}
}
}
return h;
}
@Override
public List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) throws Exception {
// 이름은 암호문이라 LIKE 가 불가능하다. 입력 이름을 해시로 바꿔 완전일치로만 찾는다.
// 평문 이름은 SQL 에 실리지 않는다(바인딩되는 값은 해시뿐).
if (isNotBlank(searchVO.getMberNm())) {
searchVO.setMberNmHash(hasher().hashName(searchVO.getMberNm()));
} else {
searchVO.setMberNmHash(null);
}
int total = mberCryptoAdminDAO.selectMberListCount(searchVO);
searchVO.setTotalRecordCount(total);
if (total == 0) {
return new java.util.ArrayList<MberCryptoVO>();
}
return mberCryptoAdminDAO.selectMberList(searchVO);
}
@Override
public MberCryptoVO selectMber(String mberId) throws Exception {
if (!isNotBlank(mberId)) {
return null;
}
return mberCryptoAdminDAO.selectMber(mberId.trim());
}
@Override
public void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
writeHistory(MberCryptoAccessHistoryVO.ACTION_SELECT, handlerId, handlerIp, mberId, r, null);
}
@Override
public String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (vo == null || !isNotBlank(vo.getMberId())) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
if (!isNotBlank(vo.getMberNm())) {
throw new IllegalArgumentException("이름은 필수입니다.");
}
if (!isNotBlank(vo.getPassword())) {
throw new IllegalArgumentException("초기 비밀번호는 필수입니다.");
}
String mberId = vo.getMberId().trim();
if (mberCryptoAdminDAO.selectMberIdCount(mberId) > 0) {
throw new IllegalArgumentException("이미 사용 중인 회원ID입니다: " + mberId);
}
vo.setMberId(mberId);
vo.setUniqId(idgenService.getNextStringId());
// 이름은 암호문·해시로만 들어간다. 평문 컬럼(NOT NULL)에는 표식만 남긴다.
vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim()));
vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim()));
vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
vo.setMberNm(ENCRYPTED_MARK);
// 초기 비밀번호는 솔트 해시로 저장한다(평문·무솔트 저장 금지 — SER-003).
vo.setPassword(EgovFileScrty.encryptPasswordWithSalt(vo.getPassword()));
if (!isNotBlank(vo.getMberSttus())) {
vo.setMberSttus("P");
}
normalizeBlanks(vo);
mberCryptoAdminDAO.insertMber(vo);
writeHistory(MberCryptoAccessHistoryVO.ACTION_INSERT, handlerId, handlerIp, mberId, r,
"이름,이메일,휴대폰,주소");
return mberId;
}
@Override
public int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (vo == null || !isNotBlank(vo.getMberId())) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
if (!isNotBlank(vo.getMberNm())) {
throw new IllegalArgumentException("이름은 필수입니다.");
}
String mberId = vo.getMberId().trim();
MberCryptoVO before = mberCryptoAdminDAO.selectMber(mberId);
if (before == null) {
throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + mberId);
}
// 바뀐 항목명만 뽑는다 — 값은 이력에 남기지 않는다(이력이 평문 저장소가 되면 안 된다).
String changed = diffItems(before, vo);
vo.setMberId(mberId);
vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim()));
vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim()));
vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
if (!isNotBlank(vo.getMberSttus())) {
vo.setMberSttus(before.getMberSttus());
}
normalizeBlanks(vo);
int cnt = mberCryptoAdminDAO.updateMber(vo);
if (cnt > 0) {
writeHistory(MberCryptoAccessHistoryVO.ACTION_UPDATE, handlerId, handlerIp, mberId, r,
changed.length() == 0 ? "(변경 항목 없음)" : changed);
}
return cnt;
}
@Override
public int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (!isNotBlank(mberId)) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
String id = mberId.trim();
MberCryptoVO before = mberCryptoAdminDAO.selectMber(id);
if (before == null) {
throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + id);
}
MberCryptoVO vo = new MberCryptoVO();
vo.setMberId(id);
vo.setMberNm(PII_PURGED_MARK);
int cnt = mberCryptoAdminDAO.deleteMberPii(vo);
if (cnt > 0) {
writeHistory(MberCryptoAccessHistoryVO.ACTION_DELETE, handlerId, handlerIp, id, r,
"이름,이메일,휴대폰,주소,생년월일,성별");
}
return cnt;
}
@Override
public boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception {
if (!isNotBlank(handlerId) || !isNotBlank(rawPassword)) {
return false;
}
String stored = mberCryptoAdminDAO.selectHandlerPassword(handlerId.trim());
if (stored == null || stored.length() == 0) {
// 계정이 없거나 비밀번호가 비어 있으면 통과시키지 않는다.
// (존재 여부를 응답으로 구분해 주지 않기 위해 호출부는 실패 문구를 한 가지로 쓴다)
return false;
}
// 솔트 유무를 자동 판별해 대조한다(무솔트 레거시 해시도 그대로 검증된다).
return EgovFileScrty.matches(rawPassword, stored);
}
@Override
public List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) throws Exception {
return mberCryptoAdminDAO.selectAccessHistoryList(param);
}
@Override
public int selectAccessHistoryListCount(Map<String, Object> param) throws Exception {
return mberCryptoAdminDAO.selectAccessHistoryListCount(param);
}
/**
* 이력 1행 적재. 재인증은 컨트롤러가 이미 통과시킨 뒤에만 이 서비스에 닿으므로 'Y'로 기록한다.
* (재인증 없이 도달할 수 있는 경로가 생기면 이 값도 인자로 받도록 바꿀 것)
*/
private void writeHistory(String actionTy, String handlerId, String handlerIp,
String trgetMberId, String reason, String changedItems) {
MberCryptoAccessHistoryVO h = new MberCryptoAccessHistoryVO();
h.setActionTy(actionTy);
h.setHandlrId(handlerId);
h.setHandlrIp(handlerIp);
h.setTrgetMberId(trgetMberId);
h.setAccessReason(reason);
h.setChangedItems(changedItems);
h.setReauthYn("Y");
mberCryptoAdminDAO.insertAccessHistory(h);
}
/**
* NOT NULL 컬럼에 대응하는 필드의 null 을 빈 문자열로 바꾼다.
*
* <p>{@code COMTNGNRLMBER} 는 ZIP·ADRES·MBTLNUM 등이 NOT NULL 이라, 화면에서 비워 둔 값이
* 그대로 null 로 내려가면 INSERT/UPDATE 가 제약 위반으로 실패한다. SQL 쪽에서 {@code IFNULL}
* 로 감싸는 방법도 있으나, 그러면 "이 컬럼은 비어도 되는가"의 답이 SQL 에 흩어진다.
* 값의 형태를 맞추는 일은 서비스가 한 곳에서 한다.</p>
*/
private void normalizeBlanks(MberCryptoVO vo) {
vo.setZip(blankIfNull(vo.getZip()));
vo.setAdres(blankIfNull(vo.getAdres()));
vo.setDetailAdres(blankIfNull(vo.getDetailAdres()));
vo.setSexdstnCode(blankIfNull(vo.getSexdstnCode()));
vo.setMoblphonNo(blankIfNull(vo.getMoblphonNo()));
// MBER_EMAIL_ADRES 는 NULL 허용 컬럼이라 그대로 둔다(빈 문자열과 미입력을 구분).
}
private String blankIfNull(String v) {
return v == null ? "" : v.trim();
}
/** 사유 필수 검증. 통과하면 앞뒤 공백을 다듬은 값을 돌려준다. */
private String requireReason(String reason) {
if (!isNotBlank(reason)) {
throw new IllegalArgumentException("개인정보 처리 사유는 필수 입력입니다. (접근 이력에 기록됩니다)");
}
String r = reason.trim();
return r.length() > 500 ? r.substring(0, 500) : r;
}
/** 변경된 항목명만 콤마로 잇는다(값은 담지 않는다). */
private String diffItems(MberCryptoVO before, MberCryptoVO after) {
StringBuilder sb = new StringBuilder();
appendIfChanged(sb, "이름", before.getMberNm(), after.getMberNm());
appendIfChanged(sb, "이메일", before.getMberEmailAdres(), after.getMberEmailAdres());
appendIfChanged(sb, "휴대폰", before.getMoblphonNo(), after.getMoblphonNo());
appendIfChanged(sb, "우편번호", before.getZip(), after.getZip());
appendIfChanged(sb, "주소", before.getAdres(), after.getAdres());
appendIfChanged(sb, "상세주소", before.getDetailAdres(), after.getDetailAdres());
appendIfChanged(sb, "성별", before.getSexdstnCode(), after.getSexdstnCode());
appendIfChanged(sb, "회원상태", before.getMberSttus(), after.getMberSttus());
return sb.toString();
}
private void appendIfChanged(StringBuilder sb, String label, String before, String after) {
String b = before == null ? "" : before;
String a = after == null ? "" : after;
if (b.equals(a)) {
return;
}
if (sb.length() > 0) {
sb.append(',');
}
sb.append(label);
}
private boolean isNotBlank(String v) {
return v != null && v.trim().length() > 0;
}
}

View File

@ -0,0 +1,90 @@
/**
* Class Name : MberCryptoReauth.java
* Description : 암호화 회원정보 화면 재인증 세션 표식 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
* 관리자 회원정보 화면의 <b>재인증</b> 상태를 세션에 표시하고 유효시간을 판정한다.
*
* <p>제안서 슬라이드16의 "조회·수정·삭제 시 사유 입력 및 <b>재인증</b> 연계" 요건이다.
* 로그인 자체가 아니라 <u>이 화면에 들어올 때 한 번 더</u> 본인을 확인한다 — 자리를 비운 사이
* 열려 있던 세션으로 평문 개인정보를 훑는 경로를 끊는 것이 목적이다.</p>
*
* <p><b>왜 로그인 2차 인증(SECOND_FACTOR_DONE)을 재사용하지 않는가:</b> 2차 인증은 로그인 시점에
* 한 번 통과하면 세션 내내 유지된다(30분 자동 로그아웃까지). 그 표식을 그대로 쓰면 "다시 확인"이
* 되지 못한다. 또 OTP 를 등록하지 않은 취급자는 2차 인증을 통과한 적이 없어 이 화면에 영영
* 들어오지 못한다. 그래서 <b>본인 비밀번호 재확인</b>이라는 독립 표식을 둔다.</p>
*
* <p><b>유효시간을 짧게 두는 이유:</b> 한 번 통과하면 세션이 끝날 때까지 열어 두는 방식은
* 사실상 재인증이 없는 것과 같다. {@link #TTL_MILLIS}(10분)마다 다시 묻는다. 작업 중 만료되면
* 재인증 화면을 거쳐 원래 위치로 돌아온다.</p>
*
* <p><b>연장하지 않는다.</b> 통과 시각을 갱신하지 않고 최초 통과 시각을 그대로 두므로, 화면을
* 계속 쓰더라도 10분마다 반드시 다시 확인한다(슬라이딩 만료가 아니다).</p>
*/
public final class MberCryptoReauth {
/** 재인증 통과 시각(밀리초)을 담는 세션 속성명. */
public static final String SESSION_KEY = "MBER_CRYPTO_REAUTH_AT";
/** 재인증 유효시간 — 10분. */
public static final long TTL_MILLIS = 10L * 60L * 1000L;
private MberCryptoReauth() {
}
/** 재인증이 유효한 상태인지 판정한다. 세션이 없거나 만료면 false. */
public static boolean isVerified(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return false;
}
Object v = session.getAttribute(SESSION_KEY);
if (!(v instanceof Long)) {
return false;
}
long at = ((Long) v).longValue();
return (System.currentTimeMillis() - at) < TTL_MILLIS;
}
/** 재인증 통과를 표시한다(현재 시각 기록). */
public static void markVerified(HttpServletRequest request) {
request.getSession(true).setAttribute(SESSION_KEY, Long.valueOf(System.currentTimeMillis()));
}
/** 재인증 표식을 지운다(화면을 벗어나거나 실패했을 때). */
public static void clear(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(SESSION_KEY);
}
}
/** 남은 유효시간(초). 만료·미인증이면 0. 화면에 남은 시간을 알려 주기 위한 값이다. */
public static long remainSeconds(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return 0L;
}
Object v = session.getAttribute(SESSION_KEY);
if (!(v instanceof Long)) {
return 0L;
}
long elapsed = System.currentTimeMillis() - ((Long) v).longValue();
long remain = TTL_MILLIS - elapsed;
return remain <= 0 ? 0L : remain / 1000L;
}
}

View File

@ -0,0 +1,117 @@
/**
* Class Name : MberCryptoSearchVO.java
* Description : 암호화 회원정보 관리자 화면 검색조건 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.io.Serializable;
/**
* 관리자 회원정보 검색조건 + 페이징.
*
* <p><b>이름은 완전일치만 된다.</b> 이름은 랜덤 IV AES-GCM 으로 저장돼 같은 이름도 매번 다른
* 암호문이라 {@code LIKE '%홍%'} 이 원리적으로 불가능하다. 대신 검색 전용 HMAC 해시 컬럼
* ({@code MBER_NM_HASH})에 인덱스가 걸려 있어 <u>정확히 같은 이름</u>은 빠르게 찾는다.
* 서비스가 입력 이름을 {@link MemberDataHasher#hashName(String)} 로 바꿔 {@link #setMberNmHash}
* 에 실어 주면 SQL 은 해시만 비교한다 — 평문 이름은 SQL 에 등장하지 않는다.</p>
*
* <p>이메일·휴대폰·회원ID 는 아직 평문 컬럼이라 전방일치 검색이 가능하다
* (암호화 대상 확대는 후속 단계 — 확대되면 이 필드들도 해시 검색으로 옮겨야 한다).</p>
*/
public class MberCryptoSearchVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 회원ID(전방일치). */
private String mberId;
/** 이름(완전일치 — 해시 검색). 화면 입력값이며 SQL 에는 해시만 나간다. */
private String mberNm;
/** 이메일(전방일치). */
private String mberEmailAdres;
/** 휴대폰(전방일치). */
private String moblphonNo;
/** 회원상태 P/D. 빈값이면 전체. */
private String mberSttus;
/** 서비스가 채우는 이름 해시(파생값). 화면에서 직접 입력받지 않는다. */
private String mberNmHash;
private int pageIndex = 1;
private int recordCountPerPage = 10;
/** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
private int firstIndex = 0;
private int totalRecordCount = 0;
private static final String LIKE_ESCAPE = "!";
public String getMberId() { return mberId; }
public void setMberId(String mberId) { this.mberId = mberId; }
public String getMberNm() { return mberNm; }
public void setMberNm(String mberNm) { this.mberNm = mberNm; }
public String getMberEmailAdres() { return mberEmailAdres; }
public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; }
public String getMoblphonNo() { return moblphonNo; }
public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; }
public String getMberSttus() { return mberSttus; }
public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; }
public String getMberNmHash() { return mberNmHash; }
public void setMberNmHash(String mberNmHash) { this.mberNmHash = mberNmHash; }
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; }
/** 회원ID 전방일치 바인딩값. */
public String getMberIdLike() { return toPrefixLike(mberId); }
/** 이메일 전방일치 바인딩값. */
public String getMberEmailAdresLike() { return toPrefixLike(mberEmailAdres); }
/** 휴대폰 전방일치 바인딩값. */
public String getMoblphonNoLike() { return toPrefixLike(moblphonNo); }
/**
* 전방일치 LIKE 값으로 변환한다. 사용자가 넣은 {@code %} {@code _} 는 와일드카드가 아니라
* 글자 그대로 찾게 이스케이프한다(SQL 은 {@code ESCAPE '!'} 로 받는다).
*/
private String toPrefixLike(String v) {
if (v == null) {
return null;
}
String t = v.trim();
if (t.length() == 0) {
return null;
}
StringBuilder sb = new StringBuilder(t.length() + 8);
for (int i = 0; i < t.length(); i++) {
char c = t.charAt(i);
if (c == '%' || c == '_' || c == '!') {
sb.append(LIKE_ESCAPE);
}
sb.append(c);
}
return sb.append('%').toString();
}
}

View File

@ -0,0 +1,124 @@
/**
* Class Name : MberCryptoVO.java
* Description : 암호화 회원정보 관리자 화면 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 관리자 전용 복호화 CRUD 화면
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.io.Serializable;
/**
* 관리자 전용 회원정보 화면이 다루는 {@code COMTNGNRLMBER} 한 행.
*
* <p><b>{@code mberNm} 에 무엇이 들어있는가는 단계에 따라 다르다.</b> DAO 가 조회하는 컬럼은
* {@code MBER_NM_ENC}(암호문)이고, DAO 반환 직전 {@link MemberNmDecryptSupport} 가 같은 자리에
* 평문을 되돌려 쓴다 — 서비스·화면은 항상 평문으로 본다. 저장(등록·수정) 방향은 서비스가
* 평문을 받아 {@link MemberDataCipher#encrypt(String)}·{@link MemberDataHasher#hashName(String)}
* 로 {@code mberNmEnc}/{@code mberNmHash} 를 채운 뒤 DAO 에 넘긴다.</p>
*
* <p><b>평문 컬럼 {@code MBER_NM} 은 이 화면이 건드리지 않는다.</b> 암호화 이관 당시 롤백 창구로
* 남겨둔 컬럼이라, 관리자 화면이 여기에 새 평문을 쓰기 시작하면 "이관이 끝나면 평문 컬럼을 지운다"는
* 계획이 조용히 무너진다. 신규 등록 시에도 평문 자리에는 마스킹 표식만 넣는다
* ({@code MBER_NM} 이 NOT NULL 이라 빈 값을 넣을 수 없기 때문이다).</p>
*/
public class MberCryptoVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 회원ID(PK). */
private String mberId;
/** 내부 식별자 ESNTL_ID. */
private String uniqId;
/**
* 이름 — 조회 결과에서는 <b>복호화된 평문</b>, 등록·수정 입력에서도 평문.
* (DB 컬럼 MBER_NM_ENC 와 이 필드를 잇는 변환은 서비스·DAO 가 담당한다)
*/
private String mberNm;
/** 이름 암호문(내부 전달용). 화면에 노출하지 않는다. */
private String mberNmEnc;
/** 이름 검색용 HMAC 해시(내부 전달용). */
private String mberNmHash;
/** 암호화 키 버전. 미암호화 행은 null. */
private Integer cryptoKeyVer;
private String mberEmailAdres;
private String moblphonNo;
private String zip;
private String adres;
private String detailAdres;
private String sexdstnCode;
/** 회원상태 — P:정상 D:탈퇴(기존 코드계 그대로). */
private String mberSttus;
private String sbscrbDe;
/** 신규 등록 시 초기 비밀번호(평문 입력). 저장 시 솔트 해시로 변환되며 조회에는 실리지 않는다. */
private String password;
/**
* 이 행의 이름 복호화가 실패했는지. DB 컬럼이 아니라 조회 시점에 채우는 값이다.
*
* <p><b>왜 이 표식이 필요한가:</b> 목록은 페이지의 모든 행을 복호화하는데, 키를 교체했거나
* 암호문 한 건이 손상되면 그 한 행 때문에 화면 전체가 죽는다. 관리자 화면이 통째로 열리지
* 않으면 정작 문제를 확인할 방법도 사라진다. 그래서 목록에서는 실패한 행만 표시해 두고
* 나머지는 정상적으로 보여준다 — 실패를 <u>숨기는</u> 것이 아니라 <u>행 단위로 드러내는</u> 것이다.
* (상세 조회는 값이 정확해야 하므로 지금처럼 실패를 그대로 알린다)</p>
*/
private boolean decryptFailed;
public String getMberId() { return mberId; }
public void setMberId(String mberId) { this.mberId = mberId; }
public String getUniqId() { return uniqId; }
public void setUniqId(String uniqId) { this.uniqId = uniqId; }
public String getMberNm() { return mberNm; }
public void setMberNm(String mberNm) { this.mberNm = mberNm; }
public String getMberNmEnc() { return mberNmEnc; }
public void setMberNmEnc(String mberNmEnc) { this.mberNmEnc = mberNmEnc; }
public String getMberNmHash() { return mberNmHash; }
public void setMberNmHash(String mberNmHash) { this.mberNmHash = mberNmHash; }
public Integer getCryptoKeyVer() { return cryptoKeyVer; }
public void setCryptoKeyVer(Integer cryptoKeyVer) { this.cryptoKeyVer = cryptoKeyVer; }
public String getMberEmailAdres() { return mberEmailAdres; }
public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; }
public String getMoblphonNo() { return moblphonNo; }
public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; }
public String getZip() { return zip; }
public void setZip(String zip) { this.zip = zip; }
public String getAdres() { return adres; }
public void setAdres(String adres) { this.adres = adres; }
public String getDetailAdres() { return detailAdres; }
public void setDetailAdres(String detailAdres) { this.detailAdres = detailAdres; }
public String getSexdstnCode() { return sexdstnCode; }
public void setSexdstnCode(String sexdstnCode) { this.sexdstnCode = sexdstnCode; }
public String getMberSttus() { return mberSttus; }
public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; }
public String getSbscrbDe() { return sbscrbDe; }
public void setSbscrbDe(String sbscrbDe) { this.sbscrbDe = sbscrbDe; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public boolean isDecryptFailed() { return decryptFailed; }
public void setDecryptFailed(boolean decryptFailed) { this.decryptFailed = decryptFailed; }
}

View File

@ -0,0 +1,181 @@
/**
* Class Name : MemberDataCipher.java
* Description : 회원 개인정보 항목 대칭암호(AES-GCM) 헬퍼 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성 — 회원 이름(MBER_NM) 암호화 저장
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
/**
* 회원 개인정보 항목을 <b>가역 암호화(AES-128/GCM)</b>하여 저장하고, 표시 시점에만 복호화한다.
*
* <p><b>대상:</b> 1단계는 일반회원 이름({@code COMTNGNRLMBER.MBER_NM}, 실측 77,053건).
* 이메일·휴대폰·주소는 동일 클래스를 재사용해 후속 단계에서 확장한다.</p>
*
* <p><b>랜덤 IV를 쓰는 이유:</b> 같은 이름이라도 매번 다른 암호문이 나온다. 결정적 암호(고정 IV)를
* 쓰면 암호문만 보고 "이 두 회원은 동명이인"이라는 사실이 새어나가고, 빈도분석으로 흔한 이름부터
* 역추적된다. 대신 <u>검색이 불가능해지므로</u> 완전일치 검색은
* {@link MemberDataHasher}의 해시 컬럼이 전담한다.</p>
*
* <p><b>저장 형식:</b> {@code Base64( IV(12) || ciphertext || GCM tag(16) )}.
* GCM은 무결성 태그를 포함하므로 저장된 암호문이 변조되면 복호화 시 예외가 발생한다 —
* 위·변조 탐지가 덤으로 따라온다.</p>
*
* <p><b>키 관리 — 이 클래스는 개발용 폴백을 두지 않는다.</b>
* 기존 {@code OtpSecretCipher}는 키 미설정 시 소스에 박힌 개발용 키로 폴백하는데, 회원정보에
* 그 방식을 승계하면 "암호화했으나 키가 소스에 있는 상태"가 되어 관리실태 점검 지적이 재발한다.
* 따라서 키가 없으면 {@link IllegalStateException}으로 <b>기동을 실패시킨다</b>. 조용히 약한
* 암호로 돌아가는 것보다 뜨는 편이 안전하다.</p>
*
* <p><b>키 설정:</b> 시스템 프로퍼티 {@code arko.mber.master.key} 또는 환경변수
* {@code ARKO_MBER_MASTER_KEY}에 <b>Base64 인코딩된 16바이트</b> 키를 넣는다.
* WAR 안에 두지 않는다(서버 {@code setenv.sh} 또는 퍼미션 600 키파일).</p>
*
* <pre>
* # 키 생성 예시
* openssl rand -base64 16
* </pre>
*
* <p><b>키 길이가 AES-128인 이유:</b> AES-256은 구형 JDK에서 JCE 무제한 정책 파일을 요구한다.
* 운영 JDK가 8u161 이상이면 무제한이 기본이므로 상향 가능하나, 운영 JDK 확인 전까지는
* {@code OtpSecretCipher}와 동일하게 128로 맞춰 배포 리스크를 없앤다.</p>
*
* <p><b>스레드 안전:</b> {@link Cipher}는 매 호출마다 새로 얻는다(재사용 불가). 이 클래스 자체는
* 불변이므로 싱글턴 빈으로 공유해도 된다.</p>
*/
public class MemberDataCipher {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int GCM_IV_BYTES = 12;
private static final int KEY_BYTES = 16; // AES-128
/** 마스터 키 시스템 프로퍼티명. */
public static final String MASTER_KEY_PROP = "arko.mber.master.key";
/** 마스터 키 환경변수명. */
public static final String MASTER_KEY_ENV = "ARKO_MBER_MASTER_KEY";
/**
* 현재 키 버전. 암호화한 행의 {@code CRYPTO_KEY_VER}에 함께 적재한다.
* 키를 교체할 때 이 값을 올리고 복호화 측에서 버전별 키를 선택하게 확장한다.
*/
public static final int CURRENT_KEY_VER = 1;
private final byte[] masterKey;
private final SecureRandom random = new SecureRandom();
/** 설정된 마스터 키로 생성한다. 키가 없으면 즉시 실패한다. */
public MemberDataCipher() {
this.masterKey = loadMasterKey();
}
/** 명시적 키 주입(테스트/키 교체용). */
public MemberDataCipher(byte[] key16) {
if (key16 == null || key16.length != KEY_BYTES) {
throw new IllegalArgumentException("AES-128 master key must be " + KEY_BYTES + " bytes");
}
this.masterKey = key16.clone();
}
/**
* 마스터 키를 로드한다.
* <b>폴백 없음</b> — 미설정·형식오류는 예외다.
*/
private static byte[] loadMasterKey() {
String b64 = System.getProperty(MASTER_KEY_PROP);
if (isBlank(b64)) {
b64 = System.getenv(MASTER_KEY_ENV);
}
if (isBlank(b64)) {
throw new IllegalStateException(
"[개인정보/암호화] 회원정보 마스터 키가 설정되지 않았습니다. "
+ "시스템 프로퍼티 " + MASTER_KEY_PROP + " 또는 환경변수 " + MASTER_KEY_ENV
+ " 에 Base64 인코딩된 16바이트 키를 설정하십시오. (생성: openssl rand -base64 16)");
}
byte[] key = Base64.decodeBase64(b64.trim().getBytes());
if (key.length != KEY_BYTES) {
throw new IllegalStateException(
"[개인정보/암호화] 마스터 키 길이가 올바르지 않습니다. 기대 " + KEY_BYTES
+ "바이트, 실제 " + key.length + "바이트. Base64 인코딩된 16바이트 키여야 합니다.");
}
return key;
}
/**
* 평문을 AES-GCM 암호화하여 Base64 문자열로 반환한다.
*
* <p>{@code null}은 {@code null}로, 빈 문자열은 빈 문자열로 그대로 돌려준다.
* arko는 회원 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우는 로직이 있어
* ({@code EgovMberManage_SQL_Mysql.xml}) NULL과 ''를 구분해 보존해야 파기 상태가 뭉개지지 않는다.</p>
*
* @param plain 평문(null/빈문자 허용)
* @return 암호문 Base64. 입력이 null이면 null, 빈문자면 빈문자
*/
public String encrypt(String plain) throws Exception {
if (plain == null) {
return null;
}
if (plain.length() == 0) {
return "";
}
byte[] iv = new byte[GCM_IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(masterKey, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] ct = cipher.doFinal(plain.getBytes("UTF-8"));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return new String(Base64.encodeBase64(out), "US-ASCII");
}
/**
* {@link #encrypt(String)} 결과를 복호화한다.
*
* <p>암호문이 변조됐거나 다른 키로 만든 것이면 GCM 태그 검증에 실패해 예외가 난다.
* 호출측은 이를 "복호화 실패"로 처리하고 <b>평문을 화면에 대체 출력하지 않는다</b>.</p>
*
* @param encryptedBase64 암호문 Base64(null/빈문자 허용)
* @return 평문. 입력이 null이면 null, 빈문자면 빈문자
*/
public String decrypt(String encryptedBase64) throws Exception {
if (encryptedBase64 == null) {
return null;
}
if (encryptedBase64.length() == 0) {
return "";
}
byte[] all = Base64.decodeBase64(encryptedBase64.getBytes("US-ASCII"));
if (all.length <= GCM_IV_BYTES) {
throw new IllegalArgumentException("암호문 길이가 비정상입니다: " + all.length + "바이트");
}
byte[] iv = new byte[GCM_IV_BYTES];
System.arraycopy(all, 0, iv, 0, GCM_IV_BYTES);
byte[] ct = new byte[all.length - GCM_IV_BYTES];
System.arraycopy(all, GCM_IV_BYTES, ct, 0, ct.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(masterKey, "AES"),
new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(ct), "UTF-8");
}
private static boolean isBlank(String s) {
return s == null || s.trim().length() == 0;
}
}

View File

@ -0,0 +1,162 @@
/**
* Class Name : MemberDataHasher.java
* Description : 회원 개인정보 검색용 키드 해시(HMAC-SHA256) — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성 — 이름 완전일치 검색용 해시
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* 암호화된 개인정보 항목의 <b>완전일치 검색</b>을 위한 키드 해시를 만든다.
*
* <p><b>왜 필요한가:</b> {@link MemberDataCipher}는 랜덤 IV라 같은 이름도 매번 다른 암호문이 된다.
* 따라서 암호문으로는 검색할 수 없다. 검색 전용으로 <u>결정적인</u> 값을 별도 컬럼
* ({@code MBER_NM_HASH})에 두고 인덱스를 건다.</p>
*
* <p><b>왜 순수 SHA-256이 아니라 HMAC인가:</b> 제안서 문구는 "SHA-256"이지만, 이름·휴대폰번호는
* 엔트로피가 극히 낮다. 한국인 이름은 사실상 수십만 가지, 휴대폰번호는 1억 미만이라 순수 해시는
* 전수대입 한 번으로 원문이 전부 복원된다 — 해시를 걸어도 평문이나 마찬가지가 된다.
* 비밀 pepper를 섞는 HMAC이면 pepper를 모르는 공격자는 사전을 만들 수 없다.
* <b>제안 범위보다 강화되는 방향</b>이므로 사업상 문제가 없고 점검 대응력이 올라간다.</p>
*
* <p><b>정규화가 해시 일치의 전제다.</b> 같은 값이 다른 해시로 갈리면 검색이 조용히 실패한다.
* 항목별 정규화 규칙을 이 클래스 한 곳에만 두고, 저장 시점과 검색 시점이 <u>반드시 같은
* 메서드</u>를 쓰게 한다.</p>
*
* <p><b>pepper 설정:</b> 시스템 프로퍼티 {@code arko.mber.hash.pepper} 또는 환경변수
* {@code ARKO_MBER_HASH_PEPPER}. 마스터 키와 <b>다른 값</b>이어야 한다(하나가 유출돼도
* 다른 하나는 살아 있게). 미설정 시 폴백 없이 실패한다.</p>
*
* <p><b>pepper는 교체할 수 없다.</b> 바꾸면 저장된 해시 전체를 재계산해야 한다
* (원본 복호화 → 재해시 배치). 처음에 충분히 긴 값으로 정하고 고정한다.</p>
*/
public class MemberDataHasher {
private static final String ALGORITHM = "HmacSHA256";
/** pepper 시스템 프로퍼티명. */
public static final String PEPPER_PROP = "arko.mber.hash.pepper";
/** pepper 환경변수명. */
public static final String PEPPER_ENV = "ARKO_MBER_HASH_PEPPER";
private static final char[] HEX = "0123456789abcdef".toCharArray();
private final byte[] pepper;
public MemberDataHasher() {
this.pepper = loadPepper();
}
/** 명시적 pepper 주입(테스트용). */
public MemberDataHasher(byte[] pepper) {
if (pepper == null || pepper.length == 0) {
throw new IllegalArgumentException("pepper must not be empty");
}
this.pepper = pepper.clone();
}
private static byte[] loadPepper() {
String p = System.getProperty(PEPPER_PROP);
if (p == null || p.trim().length() == 0) {
p = System.getenv(PEPPER_ENV);
}
if (p == null || p.trim().length() == 0) {
throw new IllegalStateException(
"[개인정보/암호화] 검색용 해시 pepper가 설정되지 않았습니다. "
+ "시스템 프로퍼티 " + PEPPER_PROP + " 또는 환경변수 " + PEPPER_ENV + " 를 설정하십시오. "
+ "(마스터 키와 다른 값이어야 합니다. 생성: openssl rand -base64 32)");
}
try {
return p.trim().getBytes("UTF-8");
} catch (Exception e) {
throw new IllegalStateException("pepper 인코딩 실패", e);
}
}
/**
* <b>이름</b> 검색용 해시. 정규화는 앞뒤 공백 제거만 한다.
*
* <p>가운데 공백은 지우지 않는다 — "남궁 민수"와 "남궁민수"는 실제로 다르게 등록된
* 별개 표기이며, 임의로 합치면 저장 시점과 관리자 검색 시점의 정규화가 어긋날 여지가 커진다.
* 소문자화도 하지 않는다(한글에 무의미하고 영문 이름의 대소문자는 유의미할 수 있다).</p>
*
* @param name 평문 이름(null/빈문자 허용)
* @return 소문자 hex 64자. 입력이 null이면 null, 빈문자면 빈문자
*/
public String hashName(String name) {
if (name == null) {
return null;
}
String norm = name.trim();
if (norm.length() == 0) {
return "";
}
return hmacHex(norm);
}
/**
* <b>이메일</b> 검색용 해시. 앞뒤 공백 제거 + 소문자화.
* (후속 단계용 — 이름 단계에서는 사용하지 않는다.)
*/
public String hashEmail(String email) {
if (email == null) {
return null;
}
String norm = email.trim().toLowerCase();
if (norm.length() == 0) {
return "";
}
return hmacHex(norm);
}
/**
* <b>휴대폰번호</b> 검색용 해시. 숫자 이외 문자를 모두 제거한다.
* ({@code 010-1234-5678}, {@code 010 1234 5678} → {@code 01012345678})
* (후속 단계용 — 이름 단계에서는 사용하지 않는다.)
*/
public String hashMobile(String mobile) {
if (mobile == null) {
return null;
}
StringBuilder sb = new StringBuilder(mobile.length());
for (int i = 0; i < mobile.length(); i++) {
char c = mobile.charAt(i);
if (c >= '0' && c <= '9') {
sb.append(c);
}
}
if (sb.length() == 0) {
return "";
}
return hmacHex(sb.toString());
}
/** 정규화가 끝난 문자열의 HMAC-SHA256 소문자 hex(64자). */
private String hmacHex(String normalized) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(pepper, ALGORITHM));
byte[] out = mac.doFinal(normalized.getBytes("UTF-8"));
char[] hex = new char[out.length * 2];
for (int i = 0; i < out.length; i++) {
int v = out[i] & 0xff;
hex[i * 2] = HEX[v >>> 4];
hex[i * 2 + 1] = HEX[v & 0x0f];
}
return new String(hex);
} catch (Exception e) {
// 해시 실패는 검색 불가로 이어지므로 삼키지 않는다.
throw new IllegalStateException("[개인정보/암호화] 검색용 해시 생성 실패", e);
}
}
}

View File

@ -0,0 +1,181 @@
package egovframework.com.cmm.privacy.crypto;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 회원 이름 암호화 백필(Action4) 운영 반영 트리거 — 관리자 전용, 1회성 이관 도구.
*
* <p><b>왜 컨트롤러가 필요한가:</b> {@link MemberNmCryptoWorker}는 이미
* {@code @Component("memberNmCryptoWorker")}로 빈에 올라가 있지만 시작·조회·중지를
* 호출할 진입점이 없었다(로컬 백필은 과거 세션에서 임시 경로로 1회 실행된 것으로
* 추정 — 운영에는 재사용 불가). 상주 스케줄러에 넣지 않기로 한 설계(Worker Javadoc
* 참조)와 일관되게, 관리자가 명시적으로 시작·중지하는 이 화면 없는 API 엔드포인트로
* 대체한다.</p>
*
* <p><b>접근통제:</b> URL이 {@code /mnt/sec/} 대역이라 별도 설정 없이 기존 인터셉터
* 3종이 그대로 적용된다 — AuthorityInterceptor(ROLE_ADMIN, urlRoleMapping
* {@code \A/mnt/sec/.*\Z}) · SecondFactorInterceptor(2차 인증) ·
* MenuAccessLogInterceptor(includePatterns {@code \A/mnt/(sec|privacy)/.*\Z} — 실행
* 이력이 접속기록에 남는다). {@link #handlerUser()}는 그 위에 얹는 방어 계층이다
* (다른 /mnt/sec 개인정보 화면과 동일 규약).</p>
*
* <p><b>메뉴에 올리지 않는다.</b> 1회성 이관 도구를 상시 네비게이션에 노출하면
* 이관 종료 후에도 실수로 재클릭될 여지가 남는다. URL을 아는 관리자만
* 호출한다(운영 반영 절차 문서에 URL 기록).</p>
*
* <p><b>재실행 안전:</b> {@link MemberNmCryptoWorker#start}는 {@code MBER_NM_ENC IS NULL}
* 조건으로 대상을 고르므로, 이미 끝난 뒤 재시작해도 대상 0건으로 즉시 종료된다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.11 URP 최초 생성 — 운영 컷오버 델타 갭 해소(회원 이름 백필 트리거)
* </pre>
*/
@Controller
public class MemberNmCryptoController {
@Resource(name = "memberNmCryptoWorker")
private MemberNmCryptoWorker worker;
/** 진행상태 조회. GET 허용(부작용 없음) — 화면 폴링용으로 쓸 수 있게. */
@RequestMapping(value = "/mnt/sec/memberNmCrypto/status.do")
@ResponseBody
public Map<String, Object> status() {
if (handlerUser() == null) {
return null;
}
Map<String, Object> progress = worker.getProgress();
if (progress != null) {
progress.put("running", Boolean.valueOf(worker.isRunning()));
}
return progress;
}
/**
* 백필 시작. 실수 클릭 방지를 위해 {@code confirm=BACKFILL} 파라미터를 강제한다.
* 이미 실행 중이면 무시(중복 시작 방지는 Worker 내부에서도 한 번 더 걸린다).
*/
@RequestMapping(value = "/mnt/sec/memberNmCrypto/start.do")
@ResponseBody
public String start(@RequestParam(value = "confirm", required = false) String confirm,
@RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize,
@RequestParam(value = "sleepMs", required = false, defaultValue = "50") long sleepMs,
HttpServletRequest request) {
LoginVO user = handlerUser();
if (user == null) {
return "ERROR: 인증되지 않은 요청입니다.";
}
if (!isPost(request) || !isSameOrigin(request)) {
return "ERROR: 잘못된 요청 경로입니다.";
}
if (!"BACKFILL".equals(confirm)) {
return "ERROR: confirm=BACKFILL 파라미터 없이는 시작하지 않습니다(실수 방지).";
}
boolean started = worker.start(chunkSize, sleepMs);
return started
? "OK: 백필을 시작했습니다(requester=" + user.getId() + "). /status.do 로 진행률을 확인하세요."
: "SKIP: 이미 실행 중입니다.";
}
/** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서 보존, 재시작 시 이어감). */
@RequestMapping(value = "/mnt/sec/memberNmCrypto/stop.do")
@ResponseBody
public String stop(HttpServletRequest request) {
if (handlerUser() == null) {
return "ERROR: 인증되지 않은 요청입니다.";
}
if (!isPost(request) || !isSameOrigin(request)) {
return "ERROR: 잘못된 요청 경로입니다.";
}
worker.requestStop();
return "OK: 중지를 요청했습니다. 진행 중인 청크가 끝나면 멈춥니다.";
}
/**
* 이관 검증(전건 복호화 대사). 77,053건 기준 수십 초~수 분 소요 — 요청 스레드를
* 그만큼 점유한다. 1회성 도구라 비동기화하지 않았다(뷰 컷오버 직전 1회 호출 용도).
*/
@RequestMapping(value = "/mnt/sec/memberNmCrypto/verify.do")
@ResponseBody
public String verify(@RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize,
HttpServletRequest request) {
if (handlerUser() == null) {
return "ERROR: 인증되지 않은 요청입니다.";
}
if (!isPost(request) || !isSameOrigin(request)) {
return "ERROR: 잘못된 요청 경로입니다.";
}
if (worker.isRunning()) {
return "ERROR: 백필이 아직 실행 중입니다 — 완료 후 검증하세요.";
}
return worker.verify(chunkSize);
}
private boolean isPost(HttpServletRequest request) {
return "POST".equalsIgnoreCase(request.getMethod());
}
/**
* 취급자 영역(/mnt) 인증·구분 판정(다른 /mnt/sec 개인정보 화면과 동일 규약).
*
* @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;
}
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

@ -0,0 +1,139 @@
/**
* Class Name : MemberNmCryptoDAO.java
* Description : 회원 이름 암호화 이관 DAO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.orm.ibatis.SqlMapClientCallback;
import org.springframework.stereotype.Repository;
import com.ibatis.sqlmap.client.SqlMapExecutor;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 회원 이름 암호화 백필 배치의 데이터 접근. 청크 조회 → 배치 UPDATE → 진행상태 기록.
*
* <p><b>평문 컬럼은 절대 UPDATE하지 않는다.</b> 이 DAO의 update문은 {@code MBER_NM_ENC} /
* {@code MBER_NM_HASH} / {@code CRYPTO_KEY_VER}만 건드린다. 평문이 그대로 남아 있어야
* ① 재실행 시 미처리 판정이 가능하고 ② 읽기 전환 전까지 롤백 창구가 유지된다.</p>
*
* @author 개인정보보호
* @since 2026-08-10
*/
@Repository("memberNmCryptoDAO")
public class MemberNmCryptoDAO extends EgovComAbstractDAO {
/** 진행상태 테이블의 작업 식별자. */
public static final String JOB_ID = "COMTNGNRLMBER.MBER_NM";
/**
* 커서 이후의 미암호화 행을 청크 크기만큼 조회한다.
*
* @param lastKey 직전 청크의 마지막 MBER_ID. 최초 실행이면 null
* @param chunkSize 청크 크기
*/
@SuppressWarnings("unchecked")
public List<MemberNmCryptoVO> selectChunk(String lastKey, int chunkSize) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("lastKey", lastKey);
param.put("chunkSize", Integer.valueOf(chunkSize));
return list("MemberNmCryptoDAO.selectChunk", param);
}
/**
* 청크 한 덩어리를 <b>단일 배치</b>로 UPDATE한다.
*
* <p>건별 executeUpdate를 77,053번 왕복하면 네트워크 라운드트립이 지배적이 된다.
* iBatis2 배치로 묶으면 한 번의 왕복으로 처리되어 수 분이 수십 초로 줄어든다.</p>
*
* @return 실제 반영 건수
*/
public int updateEncryptedBatch(final List<MemberNmCryptoVO> rows) {
if (rows == null || rows.isEmpty()) {
return 0;
}
Object result = getSqlMapClientTemplate().execute(new SqlMapClientCallback() {
public Object doInSqlMapClient(SqlMapExecutor executor) throws SQLException {
executor.startBatch();
for (int i = 0; i < rows.size(); i++) {
executor.update("MemberNmCryptoDAO.updateEncrypted", rows.get(i));
}
return Integer.valueOf(executor.executeBatch());
}
});
return (result instanceof Integer) ? ((Integer) result).intValue() : 0;
}
/** 아직 암호화되지 않은 잔여 건수. */
public int selectRemainingCount() {
Object v = selectByPk("MemberNmCryptoDAO.selectRemainingCount", null);
return (v instanceof Integer) ? ((Integer) v).intValue() : 0;
}
/** 진행상태 행을 없으면 만든다(재실행 안전). */
public void insertProgressIfAbsent() {
Map<String, Object> param = new HashMap<String, Object>();
param.put("jobId", JOB_ID);
insert("MemberNmCryptoDAO.insertProgressIfAbsent", param);
}
/** 청크 완료 시 커서·누적건수를 갱신한다. */
public void updateProgress(String lastKey, int doneDelta, int errDelta) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("jobId", JOB_ID);
param.put("lastKey", lastKey);
param.put("doneDelta", Integer.valueOf(doneDelta));
param.put("errDelta", Integer.valueOf(errDelta));
update("MemberNmCryptoDAO.updateProgress", param);
}
/** 작업 상태(R/S/C/E)와 비고를 갱신한다. */
public void updateStatus(String status, String remark) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("jobId", JOB_ID);
param.put("jobSttus", status);
param.put("rm", remark);
update("MemberNmCryptoDAO.updateStatus", param);
}
/** 진행상태 1건 조회(화면 표시·중지요청 확인용). */
@SuppressWarnings("unchecked")
public Map<String, Object> selectProgress() {
Map<String, Object> param = new HashMap<String, Object>();
param.put("jobId", JOB_ID);
return (Map<String, Object>) selectByPk("MemberNmCryptoDAO.selectProgress", param);
}
/**
* 검증용 집계 — 평문 보유 건수와 암호문 보유 건수, 그리고 누락 건수.
* 누락(평문 있는데 암호문 없음)이 0이어야 이관 완료다.
*/
@SuppressWarnings("unchecked")
public Map<String, Object> selectVerifyCounts() {
return (Map<String, Object>) selectByPk("MemberNmCryptoDAO.selectVerifyCounts", null);
}
/** 검증용 — 암호화된 행을 커서 기준으로 청크 조회(복호화 대사에 사용). */
@SuppressWarnings("unchecked")
public List<MemberNmCryptoVO> selectVerifyChunk(String lastKey, int chunkSize) {
Map<String, Object> param = new HashMap<String, Object>();
param.put("lastKey", lastKey);
param.put("chunkSize", Integer.valueOf(chunkSize));
return list("MemberNmCryptoDAO.selectVerifyChunk", param);
}
}

View File

@ -0,0 +1,105 @@
/**
* Class Name : MemberNmCryptoVO.java
* Description : 회원 이름 암호화 이관 대상 행 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.io.Serializable;
/**
* 백필 배치가 읽고 쓰는 한 행. {@code COMTNGNRLMBER}의 PK와 이름 관련 컬럼만 담는다.
*
* <p>회원 전체 컬럼(40여 개)을 끌고 오지 않는 이유는 두 가지다. 이관 배치가 다루는 개인정보를
* 최소화해야 하고(필요 최소 수집), 77,053건을 청크로 훑을 때 불필요한 I/O를 줄여야 한다.</p>
*/
public class MemberNmCryptoVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 회원ID — COMTNGNRLMBER의 PK이자 청크 커서 키.
*
* <p><b>실측 주의:</b> 이 컬럼의 DB 타입은 {@code VARCHAR}가 아니라 <b>{@code VARBINARY(32)}</b>이며,
* 77,053건 중 39건(LVMS 연계 계정 {@code ...@lvms})에 비인쇄 바이트가 들어 있다.
* 다만 전건 감사 결과 <b>UTF-8 문자열 왕복이 무손실</b>임을 확인했으므로(왕복 실패 0건)
* String 매핑을 유지한다.</p>
*
* <p>혹시 변환이 어긋나더라도 실패 양상은 안전하다 — UPDATE가 0건이 되어 해당 행의
* {@code MBER_NM_ENC}가 NULL로 남고, 검증 단계의 누락 건수에 그대로 잡힌다.
* 데이터가 잘못 덮어써지는 경우는 발생하지 않는다.</p>
*/
private String mberId;
/** 평문 이름(읽기 전용. 배치는 이 컬럼을 갱신하지 않는다). */
private String mberNm;
/** 이름 암호문 Base64. */
private String mberNmEnc;
/** 이름 검색용 HMAC-SHA256 hex. */
private String mberNmHash;
/** 암호화 키 버전. */
private Integer cryptoKeyVer;
public String getMberId() {
return mberId;
}
public void setMberId(String mberId) {
this.mberId = mberId;
}
public String getMberNm() {
return mberNm;
}
public void setMberNm(String mberNm) {
this.mberNm = mberNm;
}
public String getMberNmEnc() {
return mberNmEnc;
}
public void setMberNmEnc(String mberNmEnc) {
this.mberNmEnc = mberNmEnc;
}
public String getMberNmHash() {
return mberNmHash;
}
public void setMberNmHash(String mberNmHash) {
this.mberNmHash = mberNmHash;
}
public Integer getCryptoKeyVer() {
return cryptoKeyVer;
}
public void setCryptoKeyVer(Integer cryptoKeyVer) {
this.cryptoKeyVer = cryptoKeyVer;
}
/**
* 로그·디버깅용. <b>이름 값은 절대 포함하지 않는다</b> —
* 이관 로그에 평문이 남으면 암호화의 의미가 사라진다.
*/
@Override
public String toString() {
return "MemberNmCryptoVO[mberId=" + mberId
+ ", encSet=" + (mberNmEnc != null)
+ ", hashSet=" + (mberNmHash != null)
+ ", keyVer=" + cryptoKeyVer + "]";
}
}

View File

@ -0,0 +1,309 @@
/**
* Class Name : MemberNmCryptoWorker.java
* Description : 회원 이름 암호화 백필/검증 배치 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* 이미 적재된 회원 이름을 일괄 암호화하는 백필 배치와, 그 결과를 대사하는 검증 배치.
*
* <p><b>대상 규모(2026-08-10 실측):</b> {@code COMTNGNRLMBER} 77,053건 / 39.6MB /
* PK {@code MBER_ID} 단일. 1,000건 청크 기준 78회 순회이며 실측 소요는 수십 초~수 분이다.
* 야간 점검창구가 필요한 규모가 아니다.</p>
*
* <p><b>중단·재개 설계:</b> 청크마다 {@code MIG_CRYPTO_PROGRESS}에 커서를 남긴다. 그러나
* 재개의 진짜 근거는 커서가 아니라 <b>{@code MBER_NM_ENC IS NULL} 조건</b>이다 —
* 진행상태 테이블이 통째로 날아가도 처음부터 다시 돌리면 이미 처리된 행은 건너뛴다.
* 커서는 "다시 훑는 비용"을 줄이는 최적화일 뿐 정합성의 근거가 아니다.</p>
*
* <p><b>왜 상주 스케줄러에 넣지 않는가:</b> 1회성 이관 작업이다. {@code BatchScheduler}에
* 등록하면 이관이 끝난 뒤에도 매 주기 깨어나 빈 조회를 반복하고, 실수로 재실행될 여지가 남는다.
* 관리자가 명시적으로 시작·중지하는 형태가 맞다.</p>
*
* <p><b>평문을 로그에 남기지 않는다.</b> 이 클래스의 모든 로그는 건수·회원ID까지만 남긴다.
* 이관 로그에 이름이 찍히면 암호화를 하고도 평문이 파일로 새어 나간다.</p>
*
* @author 개인정보보호
* @since 2026-08-10
*/
@Component("memberNmCryptoWorker")
public class MemberNmCryptoWorker {
private static final Logger LOG = LoggerFactory.getLogger(MemberNmCryptoWorker.class);
/** 기본 청크 크기. */
public static final int DEFAULT_CHUNK_SIZE = 1000;
/** 청크 간 기본 휴지(ms) — 온라인 트래픽·복제 지연 보호. */
public static final long DEFAULT_SLEEP_MS = 50L;
/** 진행상태 코드. */
private static final String STTUS_RUNNING = "R";
private static final String STTUS_STOP = "S";
private static final String STTUS_DONE = "C";
private static final String STTUS_ERROR = "E";
@Resource(name = "memberNmCryptoDAO")
private MemberNmCryptoDAO dao;
/** 암호·해시 헬퍼는 키를 들고 있으므로 생성 실패 시 기동을 막는다(폴백 없음). */
private MemberDataCipher cipher;
private MemberDataHasher hasher;
/** 실행 중 스레드. null 이면 미실행. */
private volatile Thread runner;
/** 중지 요청 플래그. */
private volatile boolean stopRequested = false;
/** 지연 초기화 — 키 미설정 서버에서 컨텍스트 전체가 죽지 않도록 실행 시점에 만든다. */
private synchronized void ensureCrypto() {
if (cipher == null) {
cipher = new MemberDataCipher();
}
if (hasher == null) {
hasher = new MemberDataHasher();
}
}
/** 현재 실행 중인가. */
public boolean isRunning() {
Thread t = runner;
return t != null && t.isAlive();
}
/** 진행상태 조회(관리자 화면용). */
public Map<String, Object> getProgress() {
return dao.selectProgress();
}
/** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서는 보존). */
public void requestStop() {
this.stopRequested = true;
LOG.info("[개인정보/암호화] 이름 백필 중지 요청 접수");
}
/**
* 백필을 백그라운드로 시작한다.
*
* @param chunkSize 청크 크기(1 미만이면 기본값)
* @param sleepMs 청크 간 휴지(음수면 기본값)
* @return 시작했으면 true, 이미 실행 중이면 false
*/
public synchronized boolean start(int chunkSize, long sleepMs) {
if (isRunning()) {
LOG.warn("[개인정보/암호화] 이름 백필이 이미 실행 중입니다 — 중복 시작 무시");
return false;
}
final int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize;
final long sleep = (sleepMs < 0) ? DEFAULT_SLEEP_MS : sleepMs;
this.stopRequested = false;
Thread t = new Thread(new Runnable() {
public void run() {
runBackfill(chunk, sleep);
}
}, "mber-nm-crypto-backfill");
t.setDaemon(true);
this.runner = t;
t.start();
return true;
}
/**
* 백필 본체. 커서 페이징으로 미암호화 행을 청크 단위로 암호화한다.
*
* <p>한 건이 실패해도 청크 전체를 버리지 않는다 — 실패 행만 건너뛰고 계속한다.
* 실패 건은 {@code MBER_NM_ENC}가 NULL로 남으므로 다음 실행에서 자연히 재시도된다.</p>
*/
void runBackfill(int chunkSize, long sleepMs) {
long started = System.currentTimeMillis();
int totalDone = 0;
int totalErr = 0;
try {
ensureCrypto();
dao.insertProgressIfAbsent();
dao.updateStatus(STTUS_RUNNING, "백필 시작 chunk=" + chunkSize);
int remaining = dao.selectRemainingCount();
LOG.info("[개인정보/암호화] 이름 백필 시작 — 대상 {}건, chunk={}, sleep={}ms",
new Object[] { Integer.valueOf(remaining), Integer.valueOf(chunkSize), Long.valueOf(sleepMs) });
String lastKey = null;
while (true) {
if (stopRequested) {
dao.updateStatus(STTUS_STOP, "관리자 중지 요청 — 처리 " + totalDone + "건");
LOG.info("[개인정보/암호화] 이름 백필 중지 — 누적 {}건", Integer.valueOf(totalDone));
return;
}
List<MemberNmCryptoVO> rows = dao.selectChunk(lastKey, chunkSize);
if (rows == null || rows.isEmpty()) {
break;
}
List<MemberNmCryptoVO> ready = new ArrayList<MemberNmCryptoVO>(rows.size());
for (int i = 0; i < rows.size(); i++) {
MemberNmCryptoVO row = rows.get(i);
lastKey = row.getMberId();
try {
// NULL/빈문자 구분 보존은 cipher/hasher가 담당한다.
// arko는 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우므로 ''를 NULL로 뭉개면
// 파기 상태가 사라진다.
row.setMberNmEnc(cipher.encrypt(row.getMberNm()));
row.setMberNmHash(hasher.hashName(row.getMberNm()));
row.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
ready.add(row);
} catch (Exception e) {
totalErr++;
// 회원ID만 남긴다 — 이름은 절대 로그에 쓰지 않는다.
LOG.error("[개인정보/암호화] 행 암호화 실패(건너뜀) mberId=" + row.getMberId(), e);
}
}
int updated = 0;
if (!ready.isEmpty()) {
updated = dao.updateEncryptedBatch(ready);
}
totalDone += updated;
dao.updateProgress(lastKey, updated, 0);
if (LOG.isInfoEnabled()) {
LOG.info("[개인정보/암호화] 청크 완료 — 누적 {}건, 실패 {}건, cursor={}",
new Object[] { Integer.valueOf(totalDone), Integer.valueOf(totalErr), lastKey });
}
if (sleepMs > 0) {
try {
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
dao.updateStatus(STTUS_STOP, "인터럽트 — 처리 " + totalDone + "건");
return;
}
}
}
long elapsed = System.currentTimeMillis() - started;
String msg = "백필 완료 — 처리 " + totalDone + "건, 실패 " + totalErr + "건, 소요 " + (elapsed / 1000) + "초";
dao.updateStatus(totalErr > 0 ? STTUS_ERROR : STTUS_DONE, msg);
LOG.info("[개인정보/암호화] {}", msg);
} catch (Throwable t) {
String msg = "백필 중단 — " + t.getClass().getSimpleName() + ": " + t.getMessage();
try {
dao.updateStatus(STTUS_ERROR, msg);
} catch (Exception ignore) {
LOG.error("[개인정보/암호화] 진행상태 기록 실패", ignore);
}
LOG.error("[개인정보/암호화] 이름 백필 예외 — 누적 " + totalDone + "건", t);
} finally {
this.runner = null;
}
}
/**
* 이관 검증 — <b>전건</b> 복호화 대사.
*
* <p>77,053건 규모에서 샘플링할 이유가 없다. 전건을 복호화해 평문과 비교하고, 해시를
* 재계산해 저장값과 대조한다. 하나라도 어긋나면 읽기 전환을 해서는 안 된다.</p>
*
* <p>제안서가 요구하는 "이관 전후 건수·값 검증"의 값 검증 부분이며,
* 반환 문자열을 그대로 조치결과보고서 첨부에 쓸 수 있게 구성했다.</p>
*
* @param chunkSize 청크 크기
* @return 검증 결과 요약
*/
public String verify(int chunkSize) {
int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize;
ensureCrypto();
Map<String, Object> counts = dao.selectVerifyCounts();
long checked = 0;
long decMismatch = 0;
long hashMismatch = 0;
long decError = 0;
String lastKey = null;
while (true) {
List<MemberNmCryptoVO> rows = dao.selectVerifyChunk(lastKey, chunk);
if (rows == null || rows.isEmpty()) {
break;
}
for (int i = 0; i < rows.size(); i++) {
MemberNmCryptoVO row = rows.get(i);
lastKey = row.getMberId();
checked++;
try {
String decrypted = cipher.decrypt(row.getMberNmEnc());
if (!equalsNullable(decrypted, row.getMberNm())) {
decMismatch++;
LOG.error("[개인정보/암호화] 복호화 불일치 mberId={}", row.getMberId());
}
String expected = hasher.hashName(row.getMberNm());
if (!equalsNullable(expected, row.getMberNmHash())) {
hashMismatch++;
LOG.error("[개인정보/암호화] 해시 불일치 mberId={}", row.getMberId());
}
} catch (Exception e) {
decError++;
LOG.error("[개인정보/암호화] 복호화 오류 mberId=" + row.getMberId(), e);
}
}
}
StringBuilder sb = new StringBuilder(400);
sb.append("[회원 이름 암호화 이관 검증]\n");
if (counts != null) {
sb.append(" 총 행수 : ").append(counts.get("TOTAL_CNT")).append('\n');
sb.append(" 평문 보유 : ").append(counts.get("PLAIN_CNT")).append('\n');
sb.append(" 암호문 보유 : ").append(counts.get("ENC_CNT")).append('\n');
sb.append(" 해시 보유 : ").append(counts.get("HASH_CNT")).append('\n');
sb.append(" 누락(미암호) : ").append(counts.get("MISSING_CNT")).append(" <- 0 이어야 함\n");
}
sb.append(" 대사 건수 : ").append(checked).append('\n');
sb.append(" 복호화 불일치: ").append(decMismatch).append(" <- 0 이어야 함\n");
sb.append(" 해시 불일치 : ").append(hashMismatch).append(" <- 0 이어야 함\n");
sb.append(" 복호화 오류 : ").append(decError).append(" <- 0 이어야 함\n");
boolean ok = decMismatch == 0 && hashMismatch == 0 && decError == 0
&& counts != null && isZero(counts.get("MISSING_CNT"));
sb.append(" 판정 : ").append(ok ? "합격 — 읽기 전환 가능" : "불합격 — 읽기 전환 금지");
String report = sb.toString();
LOG.info("[개인정보/암호화] 검증 결과\n{}", report);
return report;
}
private static boolean equalsNullable(String a, String b) {
return (a == null) ? (b == null) : a.equals(b);
}
private static boolean isZero(Object v) {
if (v == null) {
return true;
}
if (v instanceof Number) {
return ((Number) v).longValue() == 0L;
}
return "0".equals(String.valueOf(v));
}
}

View File

@ -0,0 +1,105 @@
/**
* Class Name : MemberNmDecryptSupport.java
* Description : 회원 이름 읽기 전환(복호화) 공용 헬퍼 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.10 개인정보보호 최초 생성 — SELECT MBER_NM 을 MBER_NM_ENC 로 전환하는 DAO들이 공유
*
* @since 2026. 08. 10
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
import org.apache.commons.beanutils.PropertyUtils;
/**
* 조회 결과에 담긴 이름 암호문을 평문으로 되돌려 <b>기존 코드가 쓰던 키·필드 이름을 그대로</b>
* 채운다. arko의 조회 SQL은 대부분 {@code resultClass="egovMap"}(Map)이고 일부는 VO다 —
* 이 클래스가 두 형태를 모두 처리해 DAO마다 다른 복호화 코드를 두지 않게 한다.
*
* <p><b>사용 패턴:</b> SQL의 {@code MBER_NM as "userNm"}을 {@code MBER_NM_ENC as "userNm"}으로
* 바꾼 뒤(별칭은 그대로 유지), DAO 메서드가 결과를 반환하기 직전에
* {@code MemberNmDecryptSupport.decryptListInPlace(rows, "userNm")}를 호출한다.
* 같은 키를 입력이자 출력으로 써서 호출부가 한 줄만 늘어나고, 화면·서비스 코드는 전혀
* 건드리지 않는다 — 그 키에 이제 평문이 들어있다는 사실만 달라진다.</p>
*
* <p><b>실패 처리:</b> 복호화에 실패하면 예외를 던진다(평문 대체나 빈 값 대체를 하지 않는다).
* 개인정보 조회 화면이 복호화 실패를 숨기고 빈 이름을 보여주면, "왜 이름이 안 보이지"라는
* 장애가 "키가 바뀌었다/암호문이 깨졌다"는 진짜 원인을 감춘다.</p>
*/
public final class MemberNmDecryptSupport {
private static volatile MemberDataCipher cipher;
private MemberNmDecryptSupport() {
}
private static MemberDataCipher cipher() {
MemberDataCipher c = cipher;
if (c == null) {
synchronized (MemberNmDecryptSupport.class) {
c = cipher;
if (c == null) {
c = new MemberDataCipher();
cipher = c;
}
}
}
return c;
}
/**
* 목록의 각 행에서 {@code key} 위치의 암호문을 복호화해 같은 키에 되돌려 쓴다.
* 행이 {@link Map}이면 {@code put}, 그 외(JavaBean VO)면 리플렉션 setter를 쓴다.
*
* @param rows 조회 결과(egovMap 목록 또는 VO 목록). null이면 아무 것도 하지 않는다.
* @param key 암호문이 들어있고 평문이 들어갈 속성/컬럼 키(예: "userNm", "mberNm")
*/
public static void decryptListInPlace(List<?> rows, String key) {
if (rows == null || rows.isEmpty()) {
return;
}
for (int i = 0; i < rows.size(); i++) {
decryptInPlace(rows.get(i), key);
}
}
/** 단건 조회({@code selectByPk} 반환값 등)용. row가 null이면 아무 것도 하지 않는다. */
public static void decryptInPlace(Object row, String key) {
if (row == null) {
return;
}
try {
String enc;
if (row instanceof Map) {
Object v = ((Map<?, ?>) row).get(key);
enc = (v instanceof String) ? (String) v : null;
} else {
Object v = PropertyUtils.getProperty(row, key);
enc = (v instanceof String) ? (String) v : null;
}
if (enc == null || enc.length() == 0) {
// 탈퇴 등으로 이름이 파기된 행은 암호문도 없다 — 정상, 복호화할 것이 없다.
return;
}
String plain = cipher().decrypt(enc);
if (row instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) row;
m.put(key, plain);
} else {
PropertyUtils.setProperty(row, key, plain);
}
} catch (Exception e) {
throw new IllegalStateException(
"[개인정보/암호화] 이름 복호화 실패(key=" + key + ", rowType="
+ row.getClass().getName() + ")", e);
}
}
}

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,236 @@
package egovframework.com.cmm.privacy.interceptor;
import java.io.PrintWriter;
import java.security.SecureRandom;
import java.util.List;
import java.util.regex.Pattern;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
/**
* CSRF(크로스사이트 요청 위조) 방지 인터셉터 — 개인정보보호 기능개선 사업(SER-004 / 웹취약점 조치).
*
* <p>Spring Security 2.0.4 에는 CSRF 토큰 기능이 없어, 세션 기반 동기화 토큰(Synchronizer Token)을
* 이 인터셉터로 자체 구현한다. 개인정보취급자 영역(기본 {@code /mnt/})의 상태변경 요청
* (POST/PUT/DELETE/PATCH)에 대해 요청 토큰과 세션 토큰의 일치를 검증한다.</p>
*
* <p><b>이중 방어(회귀 최소화):</b> 본 프로젝트는 관리자 화면이 폼 전송(다수)과 AJAX(일부)로 혼재한다.
* 공통 헤더({@code /mnt/include/head.jsp})가 모든 폼에 토큰 hidden 을, 모든 AJAX 에 {@code X-CSRF-TOKEN}
* 헤더를 자동 주입하지만, 공통 헤더를 쓰지 않는 화면·동적 생성 폼이 있을 수 있다. 따라서 토큰이
* 없거나 불일치해도 <b>같은 출처(Origin/Referer host == Host)</b> 요청이면 통과시킨다(정상 화면 회귀 방지).
* CSRF 공격은 본질적으로 다른 출처(cross-origin)에서 발생하므로, 토큰 불일치 + 교차 출처인 경우에만 차단한다.
* 토큰이 정상 첨부된 요청은 출처와 무관하게 통과하므로 방어의 상한은 토큰이 담당한다.</p>
*
* <p><b>적용 범위 판정:</b> 본 프로젝트는 전용 {@code <mvc:interceptors>} 경로매핑이 아니라
* {@code selectAnnotaionMapper}가 {@code /**}/*.do 전체에 인터셉터를 적용하므로, 적용 범위 판정을
* 이 클래스 내부에서 수행한다(그 외 경로는 통과). {@code enforce=false}로 두면 검증 실패를 로그로만
* 남기고 통과시켜(관찰 모드), 운영 반영 전 토큰 미첨부 화면을 식별할 수 있다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.30 URP 최초 생성 (개인정보보호 기능개선 사업 SER-004 / CSRF 조치)
* </pre>
*/
public class CsrfTokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(CsrfTokenInterceptor.class);
/** 세션에 저장되는 CSRF 토큰 키. head.jsp 가 ${sessionScope.CSRF_TOKEN} 으로 읽는다. */
public static final String ATTR_CSRF_TOKEN = "CSRF_TOKEN";
/** 요청이 토큰을 싣는 헤더명(AJAX) */
private static final String HEADER_NAME = "X-CSRF-TOKEN";
/** 요청이 토큰을 싣는 파라미터명(폼 전송) */
private static final String PARAM_NAME = "_csrf";
private static final SecureRandom RANDOM = new SecureRandom();
/** 강제 여부. false 면 검증 실패를 로그만 남기고 통과(관찰 모드). */
private boolean enforce = true;
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
private String applyPathPrefix = "/mnt/";
/** 적용 제외 정규식 패턴(contextPath 제외 URI 대상) */
private List<String> excludePatterns;
public void setEnforce(boolean enforce) { this.enforce = enforce; }
public void setApplyPathPrefix(String applyPathPrefix) { this.applyPathPrefix = applyPathPrefix; }
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ctx = request.getContextPath();
String uri = request.getRequestURI();
String path = (ctx != null && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
// 1) 취급자 영역(/mnt/) 외 요청은 통과 — 공개 프론트에 영향 없음
if (applyPathPrefix != null && !path.startsWith(applyPathPrefix)) {
return true;
}
// 2) 세션 토큰 보장 — GET 진입 시에도 발급되어 head.jsp 가 화면에 심을 수 있게 한다.
HttpSession session = request.getSession(true);
String sessionToken = (String) session.getAttribute(ATTR_CSRF_TOKEN);
if (sessionToken == null || sessionToken.length() == 0) {
sessionToken = generateToken();
session.setAttribute(ATTR_CSRF_TOKEN, sessionToken);
}
// 3) 제외 패턴(로그인/프레임/공통 include 등) 통과
if (isExcluded(path)) {
return true;
}
// 4) 안전 메서드(GET/HEAD/OPTIONS/TRACE)는 상태를 바꾸지 않으므로 검증 대상 아님
String method = request.getMethod();
if (method == null || isSafeMethod(method)) {
return true;
}
// 5) 상태변경 요청 — 토큰 검증
String requestToken = request.getHeader(HEADER_NAME);
if (requestToken == null || requestToken.length() == 0) {
requestToken = request.getParameter(PARAM_NAME);
}
if (requestToken != null && constantTimeEquals(sessionToken, requestToken)) {
return true; // 토큰 일치 — 정상
}
// 6) 토큰 불일치/부재 — 같은 출처면 통과(회귀 방지), 교차 출처면 CSRF 로 차단
if (isSameOrigin(request)) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 미첨부(같은 출처라 통과) — path={}, method={}", path, method);
return true;
}
if (!enforce) {
LOG.warn("[개인정보/SER-004] CSRF 토큰 불일치(관찰 모드 통과) — path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
return true;
}
LOG.error("[개인정보/SER-004] CSRF 차단 — 교차 출처 상태변경 요청. path={}, method={}, origin={}",
new Object[] { path, method, originOf(request) });
writeForbidden(request, response);
return false;
}
/** 차단 응답. AJAX(JSON) 요청과 일반 폼 요청을 구분해 응답한다. */
private void writeForbidden(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
String accept = request.getHeader("Accept");
String xrw = request.getHeader("X-Requested-With");
boolean ajax = (xrw != null) || (accept != null && accept.contains("application/json"));
if (ajax) {
response.setContentType("application/json; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("{\"resCode\":\"403\",\"message\":\"잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.\"}");
out.flush();
} else {
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
out.print("<html><head><meta charset='UTF-8'></head><body>"
+ "<script>alert('잘못된 요청입니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.');history.back();</script>"
+ "</body></html>");
out.flush();
}
}
private boolean isSafeMethod(String method) {
return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)
|| "OPTIONS".equalsIgnoreCase(method) || "TRACE".equalsIgnoreCase(method);
}
private boolean isExcluded(String path) {
if (excludePatterns == null) {
return false;
}
for (String p : excludePatterns) {
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
return true;
}
}
return false;
}
/**
* 같은 출처 여부. Origin(우선) 또는 Referer 의 host 가 요청 Host 와 같으면 true.
* 두 헤더가 모두 없으면(브라우저 정상 폼 전송은 통상 Referer 를 보냄) 관례상 통과(true)로 처리해
* 프록시·직접 호출 등에서의 과잉 차단을 피한다. CSRF 공격은 헤더가 실려 교차 출처로 판별된다.
*/
private boolean isSameOrigin(HttpServletRequest request) {
String source = originOf(request);
if (source == null) {
return true; // 출처 헤더 없음 — 관례상 통과(토큰이 주 방어)
}
String sourceHost = hostOf(source);
String requestHost = hostOf(request.getHeader("Host"));
return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost);
}
private String originOf(HttpServletRequest request) {
String source = request.getHeader("Origin");
if (source == null) {
source = request.getHeader("Referer");
}
return source;
}
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;
}
/** SecureRandom 24바이트를 48자 소문자 hex 로 인코딩(외부 의존 없음, URL·헤더 안전). */
private String generateToken() {
byte[] bytes = new byte[24];
RANDOM.nextBytes(bytes);
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
sb.append(Character.forDigit(v >> 4, 16));
sb.append(Character.forDigit(v & 0x0F, 16));
}
return sb.toString();
}
/** 길이·내용 비교(타이밍 부채널 완화). */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}

View File

@ -0,0 +1,763 @@
package egovframework.com.cmm.privacy.interceptor;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
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.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
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;
/**
* 처리대상 식별자 파라미터 우선순위 — <b>화이트리스트 대상 컨트롤러의 {@code @RequestParam}
* 실측치</b>다(추정 아님). 개인 식별자를 앞에, 단체·일련번호를 뒤에 둬서 한 요청에 여러 개가
* 실려도 "누구의 개인정보인가"가 먼저 잡히게 한다.
*
* <p><b>넣지 않는 것:</b> {@code tmpNm}(이름)·{@code searchKeyword}(검색어)처럼 값 자체가
* 개인정보 본문이거나 본문일 수 있는 파라미터. 접속기록에 본문을 담지 않는다는 원칙 때문이며,
* 식별자만으로 정보주체를 특정할 수 있으므로 추적에 부족하지 않다.</p>
*
* <p>{@code tmpId}(해당 회원ID)·{@code tmpGId}(그룹ID)·{@code tmpAppId}(신청ID)·
* {@code checkedIdForUp}(일괄 수정 대상)은 arko 관용 파라미터명이라 표준 이름만으로는 못 잡는다.
* 이들이 빠지면 신청·활동확인·일괄승인 화면의 처리대상이 통째로 비어버린다.</p>
*/
private static final String[] TRGET_ID_PARAMS = {
// 개인 식별자
"esntlId", "uniqId", "selectedId", "mberId", "tmpId", "user_id", "userId",
"emplyrId", "entrprsId", "toId",
// 다건 선택(체크박스)
"checkedIdForDel", "checkedIdForUp",
// 단체·신청·일련 식별자
"groupId", "tmpGId", "tmpAppId", "tmpSeqId", "sn"
};
/**
* 처리 건수 취득에 쓸 모델 속성 후보(arko 목록·엑셀 화면 관용명).
* 코드목록 같은 부수 리스트를 세지 않도록 <b>명시 후보만</b> 본다.
*/
private static final String[] RESULT_LIST_ATTRS = { "resultList", "excel_data", "mberList" };
/** postHandle → afterCompletion 사이에 처리 건수를 넘기는 request attribute 키. */
private static final String ATTR_TRGET_CN = MenuAccessLogInterceptor.class.getName() + ".TRGET_CN";
/** TRGET_CN 컬럼 길이(VARCHAR(200)) 보호. */
private static final int TRGET_CN_MAX = 200;
// ===================== 설정(주입) =====================
/**
* 기록 대상 화면 정규식 화이트리스트(개인정보 취급 관리자 화면). 여기에 걸리는 요청만 기록한다.
* 미설정이면 아무것도 기록하지 않는다(과수집 방지 기본값).
*/
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; }
/**
* 처리 건수를 확보한다 — "몇 건의 개인정보를 열람·반출했는가".
*
* <p>서비스 AOP 시절에는 반환값이 {@code Collection}이면 크기를 셌다. 요청 인터셉터는 서비스
* 반환값을 볼 수 없으므로, 컨트롤러가 모델에 담은 목록에서 대신 얻는다. 이 값이 없으면
* 명단 조회·<b>엑셀 다운로드</b>가 "처리대상 미상"으로 남아 무엇을 반출했는지 확인할 수 없다.</p>
*
* <p>{@code postHandle}은 앞선 인터셉터가 요청을 차단하면 호출되지 않는다. 그때는 건수가
* 없는 채로 {@code afterCompletion}이 실패(F) 기록을 남긴다 — 차단된 요청에 건수가 없는 것이 옳다.</p>
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) {
try {
String cn = resolveResultCount(modelAndView);
if (cn != null) {
request.setAttribute(ATTR_TRGET_CN, cn);
}
} catch (Throwable t) {
// 건수는 부가정보다 — 취득 실패가 접속기록 자체를 막지 않는다.
LOG.error("[개인정보/SFR-004] 처리 건수 취득 중 예외(무시)", t);
}
}
/**
* 요청 종료 시점에 접속기록 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;
}
/**
* 처리대상 범위(TRGET_CN)를 만든다 — "몇 건을 다뤘는가".
*
* <p>두 출처를 합친다.
* ① 다건 선택(체크박스 파라미터가 여러 값) → "선택 N건"
* ② 모델 목록 건수({@link #postHandle}이 담아둔 값) → "조회 N건" / "조회 N건(전체 M건)"
* 둘 다 있으면 함께 남긴다. 단건 상세는 TRGET_ID 로 특정되므로 비어도 무방하다.</p>
*/
private String extractTrgetCn(HttpServletRequest request) {
String selected = selectedCount(request);
Object fromModel = request.getAttribute(ATTR_TRGET_CN);
String listCn = (fromModel instanceof String) ? (String) fromModel : null;
if (selected != null && listCn != null) {
return cut(selected + ", " + listCn, TRGET_CN_MAX);
}
if (selected != null) {
return cut(selected, TRGET_CN_MAX);
}
return (listCn == null) ? null : cut(listCn, TRGET_CN_MAX);
}
/** 체크박스 등으로 2건 이상을 한 번에 지정했을 때의 건수. 단건이면 null(TRGET_ID 로 충분). */
private String selectedCount(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) {
// 단일 값이지만 콤마로 여러 ID를 이어 보내는 화면이 있다(checkedIdForDel 관용).
int commas = countChar(values[0], ',');
if (commas > 0) {
return "선택 " + (commas + 1) + "건";
}
return null;
}
}
return null;
}
private static int countChar(String s, char c) {
if (s == null) {
return 0;
}
int n = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c) {
n++;
}
}
return n;
}
/**
* 모델에서 처리 건수를 뽑는다.
* 목록 크기(실제 열람·반출 건수)와 전체 검색 건수가 다르면 둘 다 남긴다 —
* "이 관리자가 10건을 봤고 검색 범위는 3,412건이었다"가 감사에서 의미가 다르기 때문이다.
*/
private String resolveResultCount(ModelAndView modelAndView) {
if (modelAndView == null) {
return null;
}
Map<String, Object> model = modelAndView.getModel();
if (model == null || model.isEmpty()) {
return null;
}
Integer listSize = null;
for (int i = 0; i < RESULT_LIST_ATTRS.length; i++) {
Object v = model.get(RESULT_LIST_ATTRS[i]);
if (v instanceof Collection) {
listSize = Integer.valueOf(((Collection<?>) v).size());
break;
}
}
Integer total = null;
Object pi = model.get("paginationInfo");
if (pi instanceof PaginationInfo) {
int t = ((PaginationInfo) pi).getTotalRecordCount();
if (t > 0) {
total = Integer.valueOf(t);
}
}
if (listSize == null && total == null) {
return null;
}
if (listSize == null) {
return "전체 " + total + "건";
}
if (total == null || total.intValue() == listSize.intValue()) {
return "조회 " + listSize + "건";
}
return "조회 " + listSize + "건(전체 " + total + "건)";
}
// ===================== 유틸 =====================
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

@ -0,0 +1,225 @@
package egovframework.com.cmm.privacy.interceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import egovframework.com.cmm.privacy.policy.AccessControlPolicyService;
import egovframework.com.cmm.privacy.policy.AccessControlPolicyVO;
/**
* 접근통제 세션 정책 인터셉터 — 개인정보보호 기능개선 사업(SER-001 요건 5, 자동 접속차단).
*
* <p>「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의
* <b>'미사용 자동 로그아웃'을 web.xml 수정·재배포 없이 관리자 설정값으로 즉시 반영</b>하는 지점이다.
* 2025.9 자료요청 3번 "시간 설정 기능 없음" 지적에 대응한다.</p>
*
* <p><b>하는 일 두 가지</b>
* <ol>
* <li>관리자 화면({@code /mnt/}) 요청마다 로그인 세션의
* {@code maxInactiveInterval} 을 정책값(ACCESS_CONTROL_POLICY.AUTO_LOGOUT_MIN)으로 맞춘다.
* web.xml 의 {@code <session-timeout>} 은 세션 생성 시 기본값일 뿐이며, 여기서 덮어쓴 값이
* 해당 세션의 실제 타임아웃이 된다.</li>
* <li>로그인 시각({@value #ATTR_LOGIN_TIME})을 세션에 도장 찍는다. 관리자 상단(top.jsp)의
* <b>"로그인 후 경과시간"</b> 표시가 이 값을 쓴다.
* <p>로그인 경로가 여럿(actionLogin / EgovSpringSecurityLoginFilter / SSO 필터)이라
* 각 경로를 고쳐 도장을 찍는 대신, 세션에 loginVO 가 생긴 뒤 <b>첫 관리자 요청</b>에서
* 한 번만 찍는다. 로그인 직후 곧바로 관리자 화면으로 이동하므로 실제 오차는 1초 미만이고,
* 인증 경로를 건드리지 않아 회귀 위험이 없다.</p></li>
* </ol>
*
* <p><b>적용 범위</b> — {@code applyPathPrefix}(기본 {@code /mnt/}) 이면서 세션에 loginVO 가 있을 때만
* 동작한다. 무인증 프론트(frt) 세션은 건드리지 않아 일반회원 이용에 영향이 없다.</p>
*
* <p><b>fail-safe</b> — 정책 조회 실패는 화면 접근을 막지 않는다. 서비스가 기본값(30분)을 돌려주며,
* 서비스 미주입 시에는 아무 것도 하지 않고 통과한다(web.xml 기본값이 그대로 유효).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 — 자동 로그아웃 정책값화)
* </pre>
*/
public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = LoggerFactory.getLogger(SessionPolicyInterceptor.class);
/** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */
public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME";
/**
* 로그인 시각을 찍을 당시의 계정 ID 세션 키.
*
* <p>시각만 들고 있으면 <b>다른 계정으로 갈아탄 경우</b>를 구분하지 못한다.
* 수동 로그아웃({@code actionLogout})은 세션을 무효화하지 않고 {@code loginVO} 만 null 로
* 바꾸므로 이 세션 속성들이 그대로 살아남기 때문이다. 계정 ID 를 함께 들고 있다가
* 달라지면 새 로그인으로 보고 시각을 다시 찍는다.</p>
*/
public static final String ATTR_LOGIN_ID = "PRIVACY_LOGIN_ACNT_ID";
/** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */
public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC";
/** 만료 임박 안내 시점(초 전) 세션 키. 0이면 안내 미사용. */
public static final String ATTR_EXPIRE_NOTICE_SEC = "PRIVACY_EXPIRE_NOTICE_SEC";
/** 로그인 정보 세션 키. */
private static final String ATTR_LOGIN_VO = "loginVO";
/** 적용 경로 접두사. 기본 관리자 영역. */
private String applyPathPrefix = "/mnt/";
private AccessControlPolicyService accessControlPolicyService;
public void setApplyPathPrefix(String applyPathPrefix) {
this.applyPathPrefix = applyPathPrefix;
}
public void setAccessControlPolicyService(AccessControlPolicyService accessControlPolicyService) {
this.accessControlPolicyService = accessControlPolicyService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try {
String uri = request.getRequestURI();
String ctx = request.getContextPath();
if (ctx != null && ctx.length() > 0 && uri.startsWith(ctx)) {
uri = uri.substring(ctx.length());
}
if (applyPathPrefix != null && !uri.startsWith(applyPathPrefix)) {
return true;
}
// 세션을 새로 만들지 않는다 — 무인증 요청까지 세션을 생성하면 불필요한 부하가 된다.
HttpSession session = request.getSession(false);
if (session == null) {
return true;
}
Object loginVO = session.getAttribute(ATTR_LOGIN_VO);
if (loginVO == null) {
// 로그아웃 상태 — 남아 있는 로그인 시각 도장을 지운다. 안 지우면 같은 세션으로
// 다시 로그인했을 때 '직전 로그인 시각' 기준으로 경과시간이 계산돼
// 로그인 직후에 "1시간 N분 경과" 같은 값이 뜬다.
clearLoginStamp(session);
return true;
}
// 로그인 시각 도장 — 계정이 바뀌었거나 도장이 없으면 새로 찍는다.
String acntId = accountIdOf(loginVO);
Object stampedAt = session.getAttribute(ATTR_LOGIN_TIME);
Object stampedId = session.getAttribute(ATTR_LOGIN_ID);
if (stampedAt == null || stampedId == null || !stampedId.equals(acntId)) {
stampLogin(session, acntId);
}
// 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지.
if (accessControlPolicyService != null) {
AccessControlPolicyVO policy = accessControlPolicyService.getCachedPolicy();
int timeoutSec = policy.getAutoLogoutSec();
if (timeoutSec > 0 && session.getMaxInactiveInterval() != timeoutSec) {
session.setMaxInactiveInterval(timeoutSec);
}
// 화면(top.jsp)이 스크립틀릿·별도 조회 없이 JSTL 로 읽어 쓰도록 세션에 실어둔다.
session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval()));
session.setAttribute(ATTR_EXPIRE_NOTICE_SEC,
Integer.valueOf(policy.isExpireNoticeUse() ? policy.getExpireNoticeMin() * 60 : 0));
} else {
session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval()));
session.setAttribute(ATTR_EXPIRE_NOTICE_SEC, Integer.valueOf(0));
}
} catch (Exception e) {
// 세션 정책 적용 실패가 화면 진입을 막아서는 안 된다.
LOG.error("[개인정보/접근통제] 세션 정책 적용 오류 - 요청은 계속 진행: {}", e.toString());
}
return true;
}
/**
* 로그인 시각 도장을 찍는다(지금 시각 기준).
*
* <p>인증 성공 지점({@code EgovLoginController.actionLogin})에서 직접 호출하면
* 인터셉터를 기다리지 않고 정확한 로그인 시점이 기록된다.</p>
*
* @param session 로그인 세션
* @param acntId 로그인 계정 ID(널 허용)
*/
public static void stampLogin(HttpSession session, String acntId) {
if (session == null) {
return;
}
session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis()));
session.setAttribute(ATTR_LOGIN_ID, acntId == null ? "" : acntId);
}
/**
* 로그인 시각 도장을 지운다. 로그아웃 지점에서 호출한다.
*
* @param session 세션(널이면 무시)
*/
public static void clearLoginStamp(HttpSession session) {
if (session == null) {
return;
}
session.removeAttribute(ATTR_LOGIN_TIME);
session.removeAttribute(ATTR_LOGIN_ID);
}
/** 세션의 loginVO 에서 계정 ID 를 꺼낸다. 타입이 달라도 깨지지 않게 리플렉션 없이 방어적으로 처리. */
private static String accountIdOf(Object loginVO) {
if (loginVO instanceof egovframework.com.cmm.LoginVO) {
String id = ((egovframework.com.cmm.LoginVO) loginVO).getId();
return id == null ? "" : id;
}
return "";
}
/**
* 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다.
*
* @param session 현재 세션(널이면 0)
* @return 경과 초(음수가 나오지 않도록 0 하한)
*/
public static long elapsedLoginSec(HttpSession session) {
if (session == null) {
return 0L;
}
long base;
Object stamped = session.getAttribute(ATTR_LOGIN_TIME);
if (stamped instanceof Long) {
base = ((Long) stamped).longValue();
} else {
base = session.getCreationTime();
}
long elapsed = (System.currentTimeMillis() - base) / 1000L;
return elapsed < 0L ? 0L : elapsed;
}
/**
* 자동 로그아웃까지 남은 시간(초).
*
* <p>세션 타임아웃은 <b>마지막 접근시각</b>부터 흐른다. 이 메서드는 화면 렌더 시점 기준의
* 잔여시간을 돌려주며, 화면 타이머는 이 값에서 카운트다운한다.</p>
*
* @param session 현재 세션(널이거나 타임아웃 미설정이면 0)
* @return 잔여 초(0 하한)
*/
public static long remainSec(HttpSession session) {
if (session == null) {
return 0L;
}
int timeoutSec = session.getMaxInactiveInterval();
if (timeoutSec <= 0) {
return 0L;
}
long idle = (System.currentTimeMillis() - session.getLastAccessedTime()) / 1000L;
long remain = timeoutSec - idle;
return remain < 0L ? 0L : remain;
}
}

View File

@ -5,6 +5,7 @@ import java.util.List;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import egovframework.com.cmm.privacy.crypto.MemberNmDecryptSupport;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO; import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/** /**
@ -30,7 +31,17 @@ public class LoginFailHistoryDAO extends EgovComAbstractDAO {
/** 인증 실패 이력 목록(검색·페이징) */ /** 인증 실패 이력 목록(검색·페이징) */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public List<LoginFailHistoryVO> selectLoginFailHistoryList(LoginFailHistoryVO vo) { public List<LoginFailHistoryVO> selectLoginFailHistoryList(LoginFailHistoryVO vo) {
return list("LoginFailHistoryDAO.selectLoginFailHistoryList", vo); List<LoginFailHistoryVO> result = list("LoginFailHistoryDAO.selectLoginFailHistoryList", vo);
// GNR(일반회원)만 userNm이 암호문이다 — USR(취급자)는 평문이다.
if (result != null) {
for (int i = 0; i < result.size(); i++) {
LoginFailHistoryVO row = result.get(i);
if (row != null && "GNR".equals(row.getGubun())) {
MemberNmDecryptSupport.decryptInPlace(row, "userNm");
}
}
}
return result;
} }
/** 인증 실패 이력 전체 건수(페이징) */ /** 인증 실패 이력 전체 건수(페이징) */

View File

@ -0,0 +1,346 @@
package egovframework.com.cmm.privacy.policy;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
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.privacy.interceptor.SessionPolicyInterceptor;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 접근통제 정책 설정 컨트롤러 (SER-001 요건 4·5).
*
* <p>「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의
* <b>접근통제 정책 설정</b> 화면을 담당한다.</p>
*
* <ul>
* <li>{@code /mnt/sec/accessPolicy/policyView.do} — 정책 설정 화면(서버 렌더링)</li>
* <li>{@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)</li>
* <li>{@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)</li>
* <li>{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)</li>
* <li>{@code /mnt/privacy/lockExempt*.do} — 잠금 예외 계정 조회/등록/해제(#24)</li>
* </ul>
*
* <p><b>경로 선정 근거</b> — 세션 연장·상태 조회는 전체관리자 전용이 아니라
* <b>로그인한 모든 취급자</b>가 써야 한다. {@code /mnt/privacy/*}·{@code /mnt/sec/*} 는
* AuthorityInterceptor 에서 ROLE_ADMIN 전용으로 매핑돼 있어 시도·시군구 관리자가 403 을
* 맞으므로, 별도 대역 {@code /mnt/session/} 을 쓰고 두 판정 인터셉터의 예외에 등록한다.</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
* </pre>
*/
@Controller
public class AccessControlPolicyController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "accessControlPolicyService")
private AccessControlPolicyService accessControlPolicyService;
@Resource(name = "loginLockExemptService")
private LoginLockExemptService loginLockExemptService;
// ────────────────────────────────────────────────────────── 정책 화면
/**
* 접근통제 정책 설정 화면.
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp
*/
@RequestMapping(value = "/mnt/sec/accessPolicy/policyView.do")
public String accessPolicyView(ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
model.addAttribute("policy", accessControlPolicyService.selectPolicy());
return "site/mnt/sec/accessPolicy/policy";
}
/** 접근통제 정책 저장(AJAX). */
@RequestMapping(value = "/mnt/privacy/accessPolicyUpdate.do")
public String updateAccessPolicy(@ModelAttribute("policy") AccessControlPolicyVO policy,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
return failJson(model, "잘못된 요청 경로입니다.");
}
String invalid = validate(policy);
if (invalid != null) {
return failJson(model, invalid);
}
policy.setUpdId(loginVO.getId());
accessControlPolicyService.updatePolicy(policy);
// 저장한 관리자 본인의 현재 세션에도 즉시 반영(다음 요청을 기다리지 않는다).
HttpSession session = request.getSession(false);
if (session != null) {
session.setMaxInactiveInterval(policy.getAutoLogoutSec());
}
model.addAttribute("result", "success");
return "jsonView";
}
/**
* 화면 우회 입력(직접 POST)에 대비한 서버측 범위 검증. 위반 시 사용자 메시지 반환.
*
* <p>상한을 두는 이유: 자동 로그아웃을 사실상 무한대로 늘리거나 잠금 임계값을 크게 잡아
* 안전성 확보조치 기준 제6조를 우회하는 설정을 막는다.</p>
*/
private String validate(AccessControlPolicyVO p) {
if (p.getLockThreshold() < 1 || p.getLockThreshold() > 10) {
return "인증 실패 계정 잠금 횟수는 1~10회 범위로 입력하세요. (기준 권고 5회)";
}
if (!AccessControlPolicyVO.UNLOCK_MODE_HIST.equals(p.getUnlockMode())
&& !AccessControlPolicyVO.UNLOCK_MODE_HIST_RESN.equals(p.getUnlockMode())) {
return "잠금 해제 방식 값이 올바르지 않습니다.";
}
if (p.getAutoLogoutMin() < 5 || p.getAutoLogoutMin() > 120) {
return "미사용 자동 로그아웃 시간은 5~120분 범위로 입력하세요. (기준 권고 30분)";
}
// 체크박스 미전송 시 null → 'N' 보정
p.setExpireNoticeYn(normalizeYn(p.getExpireNoticeYn()));
p.setSessionRegenYn(normalizeYn(p.getSessionRegenYn()));
if (p.getExpireNoticeYn() == null) {
return "만료 임박 안내 사용여부 값이 올바르지 않습니다.";
}
if (p.getSessionRegenYn() == null) {
return "세션 재발급 사용여부 값이 올바르지 않습니다.";
}
// 안내가 꺼져 있으면 시점값을 검증하지 않는다(화면에서도 비활성 입력이라 과거값이 남아 있을 수 있다).
if ("Y".equals(p.getExpireNoticeYn())
&& (p.getExpireNoticeMin() < 1 || p.getExpireNoticeMin() >= p.getAutoLogoutMin())) {
return "만료 임박 안내 시점은 1분 이상이며 자동 로그아웃 시간보다 작아야 합니다.";
}
return null;
}
/** 'Y'/'N' 이외(널·공백 포함)는 'N'. 값이 있으나 Y/N 이 아니면 null(=오류). */
private String normalizeYn(String value) {
if (value == null || value.trim().length() == 0) {
return "N";
}
String v = value.trim();
if ("Y".equals(v) || "N".equals(v)) {
return v;
}
return null;
}
// ────────────────────────────────────────────────────── 잠금 예외 계정(#24)
/**
* 잠금 예외 계정 목록(JSON). 정책 화면의 '잠금 예외 계정' 섹션이 진입 시 호출한다.
*/
@RequestMapping(value = "/mnt/privacy/lockExemptList.do")
public String lockExemptList(ModelMap model) throws Exception {
if (handlerUser() == null) {
return denyJson(model);
}
model.addAttribute("result", "success");
model.addAttribute("list", loginLockExemptService.selectExemptList());
return "jsonView";
}
/**
* 잠금 예외 계정 등록(JSON).
*
* <p>대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 서버측에서 확인한다 — 오타로
* 존재하지 않는 ID 가 등록되면 "예외를 걸어 뒀다"고 믿는 계정이 실제로는 잠기는
* 상황이 되고, 그 사실은 잠긴 뒤에야 드러난다. 저장되는 ID 는 입력값이 아니라
* 조회된 표기 그대로의 EMPLYR_ID 다(대소문자 불일치로 캐시 비교가 어긋나지 않게).</p>
*/
@RequestMapping(value = "/mnt/privacy/lockExemptAdd.do")
public String lockExemptAdd(@RequestParam(value = "exemptId", required = false) String exemptId,
@RequestParam(value = "exemptResn", required = false) String exemptResn,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO loginVO = handlerUser();
if (loginVO == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
return failJson(model, "잘못된 요청 경로입니다.");
}
String id = exemptId == null ? "" : exemptId.trim();
String resn = exemptResn == null ? "" : exemptResn.trim();
if (id.length() == 0) {
return failJson(model, "예외로 등록할 계정 ID를 입력하세요.");
}
if (id.length() > 20) {
return failJson(model, "계정 ID는 20자 이내로 입력하세요.");
}
if (resn.length() == 0) {
return failJson(model, "예외 등록 사유를 입력하세요. 사유는 감사 대응상 필수입니다.");
}
if (resn.length() > 500) {
return failJson(model, "예외 등록 사유는 500자 이내로 입력하세요.");
}
LoginLockExemptVO found = loginLockExemptService.selectEmplyr(id);
if (found == null) {
return failJson(model, "존재하지 않는 취급자 계정입니다. 계정 ID를 확인하세요.");
}
if (loginLockExemptService.isExempt(found.getExemptId())) {
return failJson(model, "이미 잠금 예외로 등록된 계정입니다.");
}
LoginLockExemptVO vo = new LoginLockExemptVO();
vo.setExemptId(found.getExemptId());
vo.setExemptResn(resn);
vo.setRegId(loginVO.getId());
loginLockExemptService.insertExempt(vo);
model.addAttribute("result", "success");
return "jsonView";
}
/** 잠금 예외 계정 해제(JSON). 해제 즉시 해당 계정은 다시 잠금 판정 대상이 된다. */
@RequestMapping(value = "/mnt/privacy/lockExemptDelete.do")
public String lockExemptDelete(@RequestParam(value = "exemptId", required = false) String exemptId,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return denyJson(model);
}
if (!isSameOrigin(request)) {
return failJson(model, "잘못된 요청 경로입니다.");
}
String id = exemptId == null ? "" : exemptId.trim();
if (id.length() == 0) {
return failJson(model, "해제할 계정 ID가 없습니다.");
}
loginLockExemptService.deleteExempt(id);
model.addAttribute("result", "success");
return "jsonView";
}
// ────────────────────────────────────────────────────────── 세션 상태·연장
/**
* 세션 상태 조회(JSON). 관리자 상단의 <b>로그인 경과시간·자동 로그아웃 잔여시간</b> 표시가
* 페이지 이동 없이 갱신할 때 쓴다.
*
* <p><b>주의</b> — 이 요청 자체가 세션을 갱신(lastAccessedTime 초기화)하므로,
* 화면 타이머는 이 엔드포인트를 <b>주기 폴링하지 않는다</b>. 그렇게 하면 "미사용 자동
* 로그아웃"이 영원히 오지 않는다. 페이지 진입 시 1회, 그리고 사용자가 연장을 선택했을 때만
* 호출한다.</p>
*/
@RequestMapping(value = "/mnt/session/status.do")
public String sessionStatus(HttpServletRequest request, ModelMap model) throws Exception {
HttpSession session = request.getSession(false);
if (session == null || handlerUser() == null) {
model.addAttribute("result", "expired");
return "jsonView";
}
model.addAttribute("result", "success");
model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session)));
model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval()));
return "jsonView";
}
/**
* 세션 연장(JSON). 만료 임박 안내에서 사용자가 '연장'을 선택하면 호출된다.
*
* <p>연장이라 해도 별도 처리는 필요 없다 — 이 요청이 컨테이너에 도달한 순간
* lastAccessedTime 이 갱신되어 타임아웃이 처음부터 다시 흐른다. 응답으로 갱신된
* 잔여시간을 돌려주어 화면 타이머를 재동기화한다.</p>
*/
@RequestMapping(value = "/mnt/session/extend.do")
public String sessionExtend(HttpServletRequest request, ModelMap model) throws Exception {
HttpSession session = request.getSession(false);
if (session == null || handlerUser() == null) {
model.addAttribute("result", "expired");
return "jsonView";
}
// 정책값을 다시 적용해 두면 연장 직후부터 최신 설정이 반영된다.
session.setMaxInactiveInterval(accessControlPolicyService.getCachedPolicy().getAutoLogoutSec());
model.addAttribute("result", "success");
model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session)));
model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval()));
return "jsonView";
}
// ────────────────────────────────────────────────────────── 공통
/**
* 취급자 영역(/mnt) 인증·구분 판정. 인터셉터 3단만으로는 무인증 노출 가능성이 있어
* 컨트롤러 단에서 직접 판정한다(AccountMngController 와 동일 규약).
*
* @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 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다.
*/
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;
}
private String denyJson(ModelMap model) {
return failJson(model, "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.");
}
private String failJson(ModelMap model, String message) {
model.addAttribute("result", "fail");
model.addAttribute("message", message);
return "jsonView";
}
}

View File

@ -0,0 +1,45 @@
package egovframework.com.cmm.privacy.policy;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 접근통제 정책 DAO (SER-001 요건 4·5).
*
* <p>statement 는 {@code PrivacyAccessControlPolicy_SQL_Mysql.xml}
* (namespace {@code AccessControlPolicyDAO}), sql-map-config 등록.</p>
*
* <p>루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의
* 인터셉터에서도 참조할 수 있다.</p>
*/
@Repository("accessControlPolicyDAO")
public class AccessControlPolicyDAO extends EgovComAbstractDAO {
/** 정책 단일행 조회. 없으면 null(호출부 기본값 fallback). */
public AccessControlPolicyVO selectPolicy() {
return (AccessControlPolicyVO) selectByPk("AccessControlPolicyDAO.selectPolicy", null);
}
/** 정책 수정. */
public int updatePolicy(AccessControlPolicyVO vo) {
return update("AccessControlPolicyDAO.updatePolicy", vo);
}
/**
* 정책 잠금 임계값을 인증 경로가 읽는 LOGIN_LOCK_COUNT 에 동기화한다.
*
* <p>인증 실패 잠금 판정은 기존 자산({@code loginDAO.selectFailTotalCount} → LOGIN_LOCK_COUNT)을
* 그대로 쓰므로, 정책 저장 시 이 한 줄이 있어야 "설정 즉시 활성 인증 경로에 적용"이 성립한다.</p>
*
* @return 갱신 행 수(0이면 LOGIN_LOCK_COUNT 가 비어 있는 것 — 호출부에서 INSERT 보정)
*/
public int updateLoginLockCount(AccessControlPolicyVO vo) {
return update("AccessControlPolicyDAO.updateLoginLockCount", vo);
}
/** LOGIN_LOCK_COUNT 가 비어 있을 때 임계값 1행을 생성한다. */
public void insertLoginLockCount(AccessControlPolicyVO vo) {
insert("AccessControlPolicyDAO.insertLoginLockCount", vo);
}
}

View File

@ -0,0 +1,23 @@
package egovframework.com.cmm.privacy.policy;
/**
* 접근통제 정책 서비스 (SER-001 요건 4·5).
*
* <p>계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부의 조회/수정.
* 인터셉터·인증 컨트롤러가 요청마다 호출하므로 조회는 짧은 TTL 캐시를 둔다.</p>
*/
public interface AccessControlPolicyService {
/** 현재 정책 조회(미설정 시 기본값 VO 반환). DB 직접 조회 — 관리자 화면용. */
AccessControlPolicyVO selectPolicy() throws Exception;
/**
* 현재 정책 조회(TTL 캐시). 인터셉터·인증 경로 등 요청마다 호출되는 지점 전용.
*
* <p>조회 실패 시에도 예외를 던지지 않고 기본값 VO 를 반환한다(사이트 잠금 방지).</p>
*/
AccessControlPolicyVO getCachedPolicy();
/** 정책 수정 + LOGIN_LOCK_COUNT 동기화 + 캐시 무효화. */
void updatePolicy(AccessControlPolicyVO vo) throws Exception;
}

View File

@ -0,0 +1,86 @@
package egovframework.com.cmm.privacy.policy;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Service;
/**
* 접근통제 정책 서비스 구현 (SER-001 요건 4·5).
*
* <p><b>캐시</b> — {@code SessionPolicyInterceptor} 가 관리자 화면 요청마다 정책을 읽으므로
* 매 요청 DB 조회를 피하기 위해 짧은 TTL(기본 60초) 캐시를 둔다. 저장 시에는 즉시 무효화하여
* "설정 저장 → 즉시 적용"을 보장한다(시안의 '설정 저장·즉시 적용').</p>
*
* <p><b>fail-safe</b> — 정책 조회 실패(테이블 미생성·DB 오류)는 관리자 화면 접근 자체를
* 막으면 안 된다. {@link #getCachedPolicy()} 는 예외를 삼키고 기본값 VO(잠금 5회·30분)를
* 반환한다. 기본값은 강화된 정책값이라 fail-open 이 아니다.</p>
*
* <pre>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
* </pre>
*/
@Service("accessControlPolicyService")
public class AccessControlPolicyServiceImpl implements AccessControlPolicyService {
private static final Log LOG = LogFactory.getLog(AccessControlPolicyServiceImpl.class);
/** 캐시 수명(ms). 저장 시 즉시 무효화되므로 관리자 체감 지연은 없다. */
private static final long CACHE_TTL_MS = 60 * 1000L;
@Resource(name = "accessControlPolicyDAO")
private AccessControlPolicyDAO accessControlPolicyDAO;
/** 캐시된 정책. volatile 참조 교체만 하므로 락 없이 안전(읽기 다수·쓰기 소수). */
private volatile AccessControlPolicyVO cached;
private volatile long cachedAt;
@Override
public AccessControlPolicyVO selectPolicy() throws Exception {
AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy();
// 정책 미설정(시드 미적용) 시 기본값 — 화면이 빈 값으로 뜨지 않게 한다.
return vo != null ? vo : new AccessControlPolicyVO();
}
@Override
public AccessControlPolicyVO getCachedPolicy() {
AccessControlPolicyVO snapshot = cached;
if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) {
return snapshot;
}
try {
AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy();
if (vo == null) {
vo = new AccessControlPolicyVO();
}
cached = vo;
cachedAt = System.currentTimeMillis();
return vo;
} catch (Exception e) {
// 조회 실패 — 관리자 화면을 막지 않는다. 기본값(잠금 5회·30분)으로 계속 진행.
LOG.error("[개인정보/접근통제] 정책 조회 실패 — 기본값 적용: " + e.getMessage());
AccessControlPolicyVO fallback = snapshot != null ? snapshot : new AccessControlPolicyVO();
cached = fallback;
cachedAt = System.currentTimeMillis();
return fallback;
}
}
@Override
public void updatePolicy(AccessControlPolicyVO vo) throws Exception {
accessControlPolicyDAO.updatePolicy(vo);
// 잠금 임계값은 인증 경로가 LOGIN_LOCK_COUNT 를 읽는다 — 같은 트랜잭션에서 동기화해야
// '저장 즉시 활성 인증 경로에 적용'이 성립한다. 행이 없으면 생성한다.
if (accessControlPolicyDAO.updateLoginLockCount(vo) == 0) {
accessControlPolicyDAO.insertLoginLockCount(vo);
}
// 캐시 무효화 — 다음 요청부터 새 정책(자동 로그아웃 시간 등)이 적용된다.
cached = null;
cachedAt = 0L;
}
}

View File

@ -0,0 +1,92 @@
package egovframework.com.cmm.privacy.policy;
import java.io.Serializable;
/**
* 접근통제 정책 VO — ACCESS_CONTROL_POLICY(단일행) 매핑.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 — 접근통제 정책 적용(SER-001 요건 4·5).
* 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.</p>
*
* <p>계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 소스/web.xml 하드코딩이 아니라
* 관리자 설정값으로 운영하기 위한 정책 값 묶음이다. 필드 기본값은 정책 미설정(테이블 empty)
* 상황에서도 화면·인터셉터가 안전하게 동작하도록 시안 기준값과 동일하게 둔다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
* </pre>
*/
public class AccessControlPolicyVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 잠금 해제 방식 — 관리자 화면 해제 + 해제 이력 적재. */
public static final String UNLOCK_MODE_HIST = "ADMIN_HIST";
/** 잠금 해제 방식 — 위 + 해제 사유 필수 입력. */
public static final String UNLOCK_MODE_HIST_RESN = "ADMIN_HIST_RESN";
private int policyId = 1;
/** 인증 실패 계정 잠금 임계값(회). 현행 10 → 5. */
private int lockThreshold = 5;
/** 잠금 해제 방식(ADMIN_HIST / ADMIN_HIST_RESN). */
private String unlockMode = UNLOCK_MODE_HIST;
/** 미사용 자동 로그아웃(분). 현행 web.xml 60 → 30. */
private int autoLogoutMin = 30;
/** 세션 만료 임박 안내 사용여부(Y/N). */
private String expireNoticeYn = "Y";
/** 만료 임박 안내 시점(만료 N분 전). */
private int expireNoticeMin = 5;
/** 2차 인증 성공 시 세션 재발급(세션고정 차단) 사용여부(Y/N). */
private String sessionRegenYn = "Y";
private String updId;
private String updDt;
/** 자동 로그아웃 시간(초). 인터셉터가 setMaxInactiveInterval 에 그대로 사용한다. */
public int getAutoLogoutSec() {
return autoLogoutMin * 60;
}
/** 만료 임박 안내 사용 여부. */
public boolean isExpireNoticeUse() {
return "Y".equals(expireNoticeYn);
}
/** 세션 재발급 사용 여부. */
public boolean isSessionRegenUse() {
return "Y".equals(sessionRegenYn);
}
/** 잠금 해제 시 사유 필수 여부. */
public boolean isUnlockResnRequired() {
return UNLOCK_MODE_HIST_RESN.equals(unlockMode);
}
public int getPolicyId() { return policyId; }
public void setPolicyId(int policyId) { this.policyId = policyId; }
public int getLockThreshold() { return lockThreshold; }
public void setLockThreshold(int lockThreshold) { this.lockThreshold = lockThreshold; }
public String getUnlockMode() { return unlockMode; }
public void setUnlockMode(String unlockMode) { this.unlockMode = unlockMode; }
public int getAutoLogoutMin() { return autoLogoutMin; }
public void setAutoLogoutMin(int autoLogoutMin) { this.autoLogoutMin = autoLogoutMin; }
public String getExpireNoticeYn() { return expireNoticeYn; }
public void setExpireNoticeYn(String expireNoticeYn) { this.expireNoticeYn = expireNoticeYn; }
public int getExpireNoticeMin() { return expireNoticeMin; }
public void setExpireNoticeMin(int expireNoticeMin) { this.expireNoticeMin = expireNoticeMin; }
public String getSessionRegenYn() { return sessionRegenYn; }
public void setSessionRegenYn(String sessionRegenYn) { this.sessionRegenYn = sessionRegenYn; }
public String getUpdId() { return updId; }
public void setUpdId(String updId) { this.updId = updId; }
public String getUpdDt() { return updDt; }
public void setUpdDt(String updDt) { this.updDt = updDt; }
}

View File

@ -0,0 +1,53 @@
package egovframework.com.cmm.privacy.policy;
import java.util.List;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 로그인 실패 계정잠금 예외 DAO (SER-001 / 개발항목 24).
*
* <p>statement 는 {@code PrivacyLoginLockExempt_SQL_Mysql.xml}
* (namespace {@code LoginLockExemptDAO}), sql-map-config 등록.</p>
*
* <p>루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의
* 인증 컨트롤러에서도 참조할 수 있다.</p>
*/
@Repository("loginLockExemptDAO")
public class LoginLockExemptDAO extends EgovComAbstractDAO {
/** 예외 계정ID 전체 조회 — 캐시 적재용(행 수가 한 자릿수 규모라 전건 조회로 충분). */
@SuppressWarnings("unchecked")
public List<String> selectExemptIds() {
return list("LoginLockExemptDAO.selectExemptIds", null);
}
/** 관리자 화면 목록(사유·등록자·등록일시·대상자 성명 포함). */
@SuppressWarnings("unchecked")
public List<LoginLockExemptVO> selectExemptList() {
return list("LoginLockExemptDAO.selectExemptList", null);
}
/**
* 등록 대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 확인. 없으면 null.
*
* <p>저장된 표기 그대로의 EMPLYR_ID 와 성명을 돌려준다. 등록 시 이 값을 쓰면
* 관리자가 대소문자를 다르게 입력해도 캐시의 문자열 비교가 어긋나지 않는다
* (MySQL 기본 collation 은 대소문자 무시라 조회는 되지만 자바 비교는 아니다).</p>
*/
public LoginLockExemptVO selectEmplyr(String exemptId) {
return (LoginLockExemptVO) selectByPk("LoginLockExemptDAO.selectEmplyr", exemptId);
}
/** 예외 등록. PK 중복은 호출부에서 사전 확인 후 진입. */
public void insertExempt(LoginLockExemptVO vo) {
insert("LoginLockExemptDAO.insertExempt", vo);
}
/** 예외 해제(행 삭제). */
public int deleteExempt(String exemptId) {
return delete("LoginLockExemptDAO.deleteExempt", exemptId);
}
}

View File

@ -0,0 +1,36 @@
package egovframework.com.cmm.privacy.policy;
import java.util.List;
/**
* 로그인 실패 계정잠금 예외 서비스 (SER-001 / 개발항목 24).
*
* <p>지정 계정을 인증 실패 <b>잠금 판정</b>에서만 제외한다. 실패 이력 적재와
* 2차 인증은 예외 계정에도 그대로 적용된다 — 잠금 예외는 인증 예외가 아니다.</p>
*/
public interface LoginLockExemptService {
/**
* 잠금 예외 계정 여부(TTL 캐시). 로그인 시도마다 호출되는 지점 전용.
*
* <p>조회 실패 시 예외를 던지지 않고 {@code false}(예외 아님)를 돌려준다 —
* 판단 불가일 때는 잠금이 정상 동작하는 쪽이 안전하다.</p>
*
* <p>대소문자를 구분하지 않는다(잠금 SQL 의 MySQL collation 과 동일 기준).
* 목록은 취급자(COMTNEMPLYRINFO) ID 만 담으므로, 호출부는 {@code userSe='USR'} 인
* 경우에만 물어야 한다 — 다른 회원구분과 ID 문자열이 겹치면 엉뚱한 계정의 잠금이 풀린다.</p>
*/
boolean isExempt(String loginId);
/** 관리자 화면 목록(사유·등록자·등록일시 포함). DB 직접 조회. */
List<LoginLockExemptVO> selectExemptList() throws Exception;
/** 등록 대상 ID 가 취급자로 실존하는지 확인. 실존하면 저장 표기 그대로의 ID·성명, 없으면 null. */
LoginLockExemptVO selectEmplyr(String exemptId) throws Exception;
/** 예외 등록 + 캐시 무효화. */
void insertExempt(LoginLockExemptVO vo) throws Exception;
/** 예외 해제 + 캐시 무효화. */
void deleteExempt(String exemptId) throws Exception;
}

View File

@ -0,0 +1,125 @@
package egovframework.com.cmm.privacy.policy;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.annotation.Resource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Service;
/**
* 로그인 실패 계정잠금 예외 서비스 구현 (SER-001 / 개발항목 24).
*
* <p><b>캐시</b> — {@code EgovLoginController.actionLogin()} 이 로그인 시도마다 두 번
* (잠금 선확인·실패 처리) 조회하므로 {@link AccessControlPolicyServiceImpl} 과 동일한
* 짧은 TTL(60초) 캐시를 둔다. 등록·삭제 시 즉시 무효화한다.</p>
*
* <p><b>fail-closed — {@link AccessControlPolicyServiceImpl} 과 반대 방향이다.</b>
* 접근통제 정책 쪽은 조회에 실패해도 기본값(잠금 5회·30분)을 돌려준다. 그 기본값이
* 이미 강화된 값이라 실패가 곧 완화가 아니기 때문이다. 반면 이 화이트리스트는
* <b>있으면 잠금이 면제되는</b> 목록이라, 조회 실패를 관대하게 다루면 그 순간
* 무차별 대입에 대한 잠금이 통째로 풀린다. 따라서 조회 실패 시에는 빈 집합
* (= 예외 계정 없음 = 전 계정 정상 잠금 판정)으로 취급한다. 테이블 미생성 등으로
* 계속 실패하는 상황에서 매 로그인마다 DB를 때리지 않도록, 실패한 빈 집합도
* 동일 TTL 로 캐싱한다(최대 60초 뒤 자동 재시도).</p>
*
* <pre>
* &lt;&lt; 개정이력(Modification Information) &gt;&gt;
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
* </pre>
*/
@Service("loginLockExemptService")
public class LoginLockExemptServiceImpl implements LoginLockExemptService {
private static final Log LOG = LogFactory.getLog(LoginLockExemptServiceImpl.class);
/** 캐시 수명(ms). 등록·삭제 시 즉시 무효화되므로 관리자 체감 지연은 없다. */
private static final long CACHE_TTL_MS = 60 * 1000L;
@Resource(name = "loginLockExemptDAO")
private LoginLockExemptDAO loginLockExemptDAO;
/** 캐시된 예외 ID 집합. 불변 집합 참조만 교체하므로 락 없이 안전(읽기 다수·쓰기 소수). */
private volatile Set<String> cachedIds;
private volatile long cachedAt;
@Override
public boolean isExempt(String loginId) {
if (loginId == null || loginId.trim().length() == 0) {
return false;
}
return cachedExemptIds().contains(normalize(loginId));
}
/**
* 잠금 판정 SQL(selectlockCheck·selectFailCount·updateFailLogin)은 모두
* {@code WHERE EMPLYR_ID = #id#} 로 MySQL 기본 collation(대소문자 무시)에 따라 매칭된다.
* 여기서만 자바 문자열을 그대로 비교하면 대소문자를 바꿔 시도하는 것만으로
* 화이트리스트를 우회해 실제 계정에 LOCK_YN='Y' 를 찍을 수 있다 — 이 기능의 존재 이유가 무너진다.
* 잠금 SQL 과 같은 기준으로 맞춘다.
*/
private String normalize(String id) {
return id.trim().toLowerCase(Locale.ENGLISH);
}
private Set<String> cachedExemptIds() {
Set<String> snapshot = cachedIds;
if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) {
return snapshot;
}
Set<String> loaded;
try {
List<String> ids = loginLockExemptDAO.selectExemptIds();
loaded = new HashSet<String>();
if (ids != null) {
for (String id : ids) {
if (id != null && id.trim().length() > 0) {
loaded.add(normalize(id));
}
}
}
loaded = Collections.unmodifiableSet(loaded);
} catch (Exception e) {
// fail-closed — 예외 없음으로 취급해 잠금 로직이 정상 동작하게 둔다(위 클래스 주석 참고).
LOG.error("[개인정보/24] 잠금 예외 목록 조회 실패 — 예외 없음(전 계정 잠금 판정)으로 진행: " + e.getMessage());
loaded = Collections.emptySet();
}
cachedIds = loaded;
cachedAt = System.currentTimeMillis();
return loaded;
}
@Override
public List<LoginLockExemptVO> selectExemptList() throws Exception {
return loginLockExemptDAO.selectExemptList();
}
@Override
public LoginLockExemptVO selectEmplyr(String exemptId) throws Exception {
return loginLockExemptDAO.selectEmplyr(exemptId);
}
@Override
public void insertExempt(LoginLockExemptVO vo) throws Exception {
loginLockExemptDAO.insertExempt(vo);
invalidate();
}
@Override
public void deleteExempt(String exemptId) throws Exception {
loginLockExemptDAO.deleteExempt(exemptId);
invalidate();
}
private void invalidate() {
cachedIds = null;
cachedAt = 0L;
}
}

View File

@ -0,0 +1,47 @@
package egovframework.com.cmm.privacy.policy;
import java.io.Serializable;
/**
* 로그인 실패 계정잠금 예외 VO — LOGIN_LOCK_EXEMPT 매핑 (SER-001 / 개발항목 24).
*
* <p>잠금 <b>판정</b>만 면제하는 화이트리스트 한 행이다. 인증 실패 이력(LOGIN_HISTORY)
* 적재와 2차 인증(OTP)은 예외 계정에도 그대로 적용된다.</p>
*
* <pre>
* &lt;&lt; 개정이력(Modification Information) &gt;&gt;
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
* </pre>
*/
public class LoginLockExemptVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 잠금 예외 대상 계정ID(COMTNEMPLYRINFO.EMPLYR_ID). */
private String exemptId;
/** 예외 등록 사유(필수). */
private String exemptResn;
/** 등록한 관리자 계정ID. */
private String regId;
/** 등록일시(화면 표시용 문자열). */
private String regDt;
/** 대상자 성명 — 목록 조회 시 COMTNEMPLYRINFO 조인 결과. */
private String userNm;
public String getExemptId() { return exemptId; }
public void setExemptId(String exemptId) { this.exemptId = exemptId; }
public String getExemptResn() { return exemptResn; }
public void setExemptResn(String exemptResn) { this.exemptResn = exemptResn; }
public String getRegId() { return regId; }
public void setRegId(String regId) { this.regId = regId; }
public String getRegDt() { return regDt; }
public void setRegDt(String regDt) { this.regDt = regDt; }
public String getUserNm() { return userNm; }
public void setUserNm(String userNm) { this.userNm = userNm; }
}

Some files were not shown because too many files have changed in this diff Show More