diff --git a/notes/johngreen/2026-05-08-부서관리-버그-정리.md b/notes/johngreen/2026-05-08-부서관리-버그-정리.md new file mode 100644 index 00000000..1da98640 --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그-정리.md @@ -0,0 +1,365 @@ +# 부서관리 페이지 버그 정리 — 시나리오 중심 + +작성일: 2026-05-08 +목적: 사용자/개발자가 "어떤 일이 벌어지는지" 바로 이해할 수 있게 정리 + +--- + +## 🔴 진짜 위험한 것 — 즉시 수정 + +### 1. 다른 회사 사용자 정보가 그냥 보입니다 🚨 + +**어디서**: `DepartmentController.java:234` `searchUsers` + +**무슨 일이 벌어지나**: +- 부서원 추가 시 사용자 검색 API 가 있음 +- 그런데 이 API 는 "당신이 어느 회사 사람인지" 검사를 안 함 +- 즉, A 회사 직원이 URL 에 B 회사 코드만 박으면 → B 회사 직원 명단을 그대로 받아옴 + +**왜 심각한가**: +- 멀티테넌시 (회사간 격리) 의 핵심 원칙이 깨짐 +- 다른 모든 부서 API 는 회사 격리 검사를 하는데 이거 하나만 빠짐 +- 또한 일반 USER 도 admin 검사 없이 호출 가능 + +**고치는 법**: +- 컨트롤러에 `if (!isSuperAdmin(...) && !userCompanyCode.equals(companyCode)) return 403;` 추가 +- admin role 체크도 추가 + +--- + +### 2. 사용자의 "주 부서" 가 통째로 사라질 수 있습니다 🚨 + +**어디서**: `DepartmentService.java:356-370` `setPrimaryDept` + +**무슨 일이 벌어지나**: +1. 김철수 씨가 영업부 소속 (영업부 = 주 부서) +2. 누군가 "김철수를 마케팅부의 주 부서로 설정" API 를 호출 (그런데 김철수는 마케팅부 소속이 아님) +3. 코드는 "일단 김철수의 모든 주 부서 표시 해제" 부터 실행 → 영업부 주 부서 표시 사라짐 +4. 그 다음 "마케팅부를 주 부서로 설정" 시도 → 김철수가 마케팅부 소속이 아니므로 0 row 변경 (조용히 실패) +5. 결과: 김철수는 어디에도 주 부서가 없는 사용자가 됨 + +**왜 심각한가**: +- 데이터 무결성 (모든 사용자는 주 부서 1개) 이 깨진 채 commit +- 에러도 안 남 +- 결재 흐름 등에서 주 부서 기준 로직이 망가짐 + +**고치는 법**: +- API 진입 시 `selectExistingMember(userId, deptCode)` 로 멤버십 검증 +- 멤버 아니면 400 반환 +- `setUserPrimaryDept` 가 0 row affected 면 예외 던져서 트랜잭션 롤백 + +--- + +### 3. 다른 회사의 부서를 부모로 지정할 수 있습니다 🚨 + +**어디서**: `DepartmentService.java:107` (생성), `:251` `verifyParentCycle` (수정) + +**무슨 일이 벌어지나**: +- 부서 생성/수정 시 `parent_dept_code` 를 받음 +- 이 코드가 (a) 실제 존재하는지, (b) 같은 회사인지, (c) 삭제된 건 아닌지 **전혀 검사 안 함** +- 사이클 검사 (`verifyParentCycle`) 는 있지만 "부모가 같은 회사인지" 검증은 빠져있음 + +**왜 심각한가**: +- SUPER_ADMIN 또는 컨트롤러 우회 경로로 다른 회사 부서를 부모로 지정 가능 +- 회사간 데이터가 트리로 엮여 멀티테넌시 침해 + +**고치는 법**: +- create/update 양쪽에서 parent 부서 조회 → company_code 일치 검증 + deleted_at IS NULL 검증 + +--- + +## 🟠 사용자가 자주 마주칠 버그 + +### 4. 회사를 바꿔도 우측 상세 패널에 이전 회사 부서가 그대로 남습니다 + +**어디서**: `page.tsx:658` 회사 Select 의 `onValueChange` + +**시나리오**: +1. 회사 A 의 "영업부" 클릭 → 우측에 영업부 정보 +2. 좌측 상단에서 회사 B 로 변경 +3. 좌측 트리는 회사 B 부서로 갱신됨 +4. **그런데 우측 상세 패널은 여전히 영업부 (회사 A) 정보 표시** +5. 사용자가 모르고 저장 버튼 누르면 회사 코드 mismatch 로 이상한 일이 벌어짐 + +**고치는 법**: +- 회사 변경 시 `handleClearDetail()` 호출하여 상세 패널 초기화 + +--- + +### 5. 부서 정렬 변경 (한 칸 위/아래) 이 부분 실패할 수 있습니다 + +**어디서**: `page.tsx:403` `handleMove` + +**시나리오**: +1. 형제 부서 5개 정렬 변경 시 코드는 5번의 PUT 요청을 동시에 보냄 (`Promise.all`) +2. 그 중 1개가 실패하면 → 4개는 이미 DB 에 반영됨 +3. 코드는 toast 만 띄우고 화면 새로고침은 **안 함** (catch 블록에 `loadDepartments()` 없음) +4. 화면은 이전 정렬, DB 는 부분 업데이트된 상태로 영구 불일치 + +**고치는 법**: +- catch 블록에서도 `loadDepartments()` 호출하여 DB 상태로 동기화 +- 더 좋은 방법: 백엔드에 "정렬 일괄 업데이트" API 만들어 트랜잭션 보장 + +--- + +### 6. 검색하면 트리가 망가집니다 + +**어디서**: `page.tsx:231-246` `filteredDepts` + `childrenOf` + +**시나리오**: +- "인사" 검색 → "인사팀" 부서가 매칭됨 +- 그런데 인사팀의 부모인 "경영지원본부" 는 매칭 안 됨 +- `childrenOf(null)` 은 매칭된 루트만 보여주는데 인사팀의 부모가 없으니 트리에 안 나타남 +- **결과: 검색 결과가 있어도 트리에 아무것도 안 보일 수 있음** + +**고치는 법**: +- 매칭된 노드의 부모 체인을 자동으로 결과에 포함 +- 또는 검색 모드에서는 트리가 아닌 평면 리스트로 표시 + +--- + +### 7. `update` 가 삭제된 부서도 변경합니다 (silent corruption) + +**어디서**: `department.xml:160-179` `updateDepartment` + +**무슨 일이 벌어지나**: +1. 사용자가 휴지통의 "옛 부서" 를 수정하려 시도 +2. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증 후 service 호출 +3. SQL UPDATE 는 `WHERE DEPT_CODE = ?` 만 있어 삭제 여부 무관하게 update 실행 +4. service 가 다음 줄에서 `selectDepartmentByCode` (DELETED_AT IS NULL) 로 재조회 → null +5. controller 가 404 반환 + +**결과**: 사용자는 "404 부서를 찾을 수 없음" 메시지를 보지만 **DB 는 이미 update 된 상태**. 데이터 손상. + +**고치는 법**: +- SQL UPDATE WHERE 절에 `AND DELETED_AT IS NULL` 추가 +- 0 row 면 service 가 null 반환 → 404 일관됨 + +--- + +### 8. 부서명을 바꿀 때 중복 검사를 안 합니다 + +**어디서**: `DepartmentService.java:131-170` `updateDepartment` + +**시나리오**: +- 새 부서 생성 시에는 "이미 같은 이름 있어요" 체크함 +- 그런데 **부서명 수정 시에는 체크 없음** +- 결과: 기존 "영업1팀" 을 "영업팀" 으로 수정 → 이미 있는 "영업팀" 과 동일 이름 두 부서 공존 가능 + +**같은 문제 — 복구 시에도**: `restoreDepartment` 에서도 동일. 삭제했던 부서를 살릴 때 같은 이름의 active 부서가 있어도 충돌 검사 없음. + +**고치는 법**: +- update/restore 양쪽에 `selectDuplicateDeptName` 호출 추가 +- 더 좋은 방법: DB 에 partial UNIQUE 인덱스 (`(company_code, lower(trim(dept_name))) WHERE deleted_at IS NULL`) 추가 + +--- + +### 9. 일반 회사 관리자가 글로벌 부서를 삭제할 수 있습니다 + +**어디서**: `DepartmentController.java:135` `deleteDepartment` + +**시나리오**: +- 시스템 공통 부서 (company_code='*') 가 있음 (예: 시스템 관리자, 공통 부서 등) +- 어느 회사 admin 이든 이 글로벌 부서의 dept_code 를 알면 DELETE 호출 가능 +- 코드의 권한 검사: `isAdmin(role)` 통과 + `canAccessDept` (글로벌이라 통과) → 삭제 진행 + +**고치는 법**: +- 글로벌 부서 (`company_code = '*'`) 의 write 작업은 `isSuperAdmin` 만 허용 + +--- + +### 10. 사용자가 입력한 부서코드가 조용히 무시됩니다 -> 사용자가 부서코드를 입력하게 하지말고 시스템이 동적으로 생성할수있게변경 입력값은 받지않게 수정 + +**어디서**: `DepartmentService.java:85-99` `createDepartment` + +**시나리오**: +1. 사용자가 부서코드란에 "SALES-A" 입력 (대시 포함) +2. 백엔드 정규식은 `^[A-Za-z0-9_]+$` 만 허용 → 불일치 +3. 코드는 예외를 던지지 않고 자동으로 `DEPT_47` 같은 코드 생성 +4. 응답은 201 성공, 하지만 `dept_code` 가 `DEPT_47` (사용자가 입력한 값과 다름) +5. 사용자는 "SALES-A" 로 만들어졌다고 착각 + +**고치는 법**: +- 정규식 불일치 시 400 + "부서 코드는 영문/숫자/언더스코어만 가능합니다" 메시지 + +--- + +### 11. 새 부서의 "시작일" 이 항상 오늘로 강제 저장됩니다 + +**어디서**: `page.tsx:113` `emptyDraft`, `page.tsx:957` 일괄등록 + +**시나리오**: +- UI 에 시작일/종료일 입력란은 V2 로 미뤄져 hidden 처리됨 (`{false && ...}`) +- 그런데 `emptyDraft` 는 `start_date: new Date().toISOString().slice(0,10)` 으로 항상 today 입력 +- 결과: 사용자는 시작일 입력란을 본 적도 없는데 모든 신규 부서에 today 가 강제 저장됨 +- 일괄등록도 동일 — 100개 import 하면 100개 모두 import 한 날짜가 시작일 + +**고치는 법**: +- `emptyDraft` 의 `start_date` 기본값을 빈 문자열로 변경 +- 또는 컬럼이 hidden 인 상태에서는 payload 에 포함하지 않음 + +--- + +## 🟡 알아두면 좋은 문제 + +### 12. 부서원 목록이 다른 부서 것으로 잠깐 보일 수 있습니다 + +**어디서**: `page.tsx:219-228` 멤버 fetch useEffect + +**상황**: 네트워크 느린 환경에서 부서 A 클릭 → 멤버 로딩 중 → 부서 B 클릭 → A 의 응답이 늦게 도착하여 B 의 패널에 A 의 멤버 표시. fetch 취소 로직 (AbortController) 없음. + +**고치는 법**: useEffect 에 cancellation flag 추가 + +--- + +### 13. 삭제 다이얼로그를 열어둔 채 다른 부서를 선택하면 잘못된 부서가 삭제됩니다 + +**어디서**: `page.tsx:503` `handleDelete` + +**상황**: +1. 영업부 선택 → 메인 패널의 "삭제" 버튼 클릭 → 다이얼로그 열림 +2. 다이얼로그 뒤로 트리에서 다른 부서 (예: 인사부) 클릭 → `selectedCode` = 인사부 +3. 다이얼로그의 "삭제" 확정 → 코드는 최신 `selectedCode` 사용 → **인사부가 삭제됨** + +shadcn Dialog 는 보통 포커스 트랩이 있지만 dismiss 가능한 설정이면 위험. + +**고치는 법**: 다이얼로그 열 때 dept_code 를 캡처해서 클로저로 전달 + +--- + +### 14. X 버튼 누르면 미저장 변경이 경고 없이 사라집니다 + +**어디서**: `page.tsx:299` `handleClearDetail` + +**상황**: 부서 정보 수정 중 우측 상단 X 버튼 클릭 → 즉시 폼 초기화. `isDirty` 상태는 계산되지만 사용 안 함. + +**고치는 법**: `if (isDirty && !confirm("변경사항이 있습니다. 폐기하시겠습니까?")) return;` + +--- + +### 15. 변경이력 기능이 화면만 있고 실제로는 동작 안 합니다 + +**어디서**: `page.tsx:997-1027` + +**상황**: "변경이력" 버튼 클릭 → 다이얼로그 열림 → 항상 "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 표시. 실제 API 호출 코드 없음. + +**고치는 법**: 백엔드 audit log API 와 연동. 기능 미구현 명시. + +--- + +### 16. 부서원 추가/제거 UI 가 없습니다 + +**어디서**: `page.tsx:1469` `MembersPanel` + +**상황**: +- 부서원 정보 탭에서 멤버 목록만 표시 +- 추가/제거 버튼 없음 +- 백엔드 API (`addDepartmentMember`, `removeDepartmentMember`, `searchUsers`) 는 존재하지만 UI 미연결 + +**고치는 법**: 멤버 추가 모달 + 행별 제거 버튼 구현 + +--- + +### 17. 일괄등록 실패 사유가 안 보입니다 + +**어디서**: `page.tsx:957-985` + +**상황**: 100개 import → "성공 95건 / 실패 5건" 토스트만. 어떤 부서코드가 왜 실패했는지 알 수 없어 재시도 불가. + +**고치는 법**: 실패 항목을 모달이나 다운로드 가능한 결과 화면으로 표시 + +--- + +## 🟢 사소한 문제 (정리/보강) + +### 18. 동시에 부서 생성하면 실패할 수 있음 + +**어디서**: `DepartmentService.java:96` `selectNextDeptNumber` + +`MAX(번호) + 1` 이 비원자적이라 두 사람이 동시에 부서 생성 시 같은 번호 → 두 번째 INSERT 가 PK 위반으로 5xx 에러. catch 로 잡지도 않아 사용자에게 raw 500 노출. + +### 19. 부서 코드/이름의 앞뒤 공백이 그대로 저장됨 + +**어디서**: `DepartmentService.java:107`+ `nullIfBlank` + +`nullIfBlank` 는 "완전히 빈 문자열만 null 로" 처리. ` " DEPT_3 "` 같은 공백 포함 코드는 그대로 저장됨. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정. + +### 20. 검색 후 "전체 펼치기" 누르면 검색 해제 시 부분 펼침 상태로 남음 + +**어디서**: `page.tsx:249` `expandAll` + +`filteredDepts` 만 펼치므로 검색 결과만 expanded. 검색 해제 후 트리가 일부만 펼쳐진 상태로 보임. + +### 21. 컨트롤러에 dead code 있음 + +**어디서**: `DepartmentController.java:271-272` + +```java +Object userIdObj = body.get("user_id"); +if (userIdObj == null) userIdObj = body.get("user_id"); // 같은 키 두 번 +``` + +camelCase fallback 의도였는데 오타로 같은 snake_case. → `body.get("userId")` 로 수정. + +### 22. Picker 에 "최상위로 이동" 옵션이 UI 에 없음 + +**어디서**: `page.tsx:920` `DepartmentPicker.onSelect` + +`handleConfirmMoveTo(null)` 코드 경로는 있지만 picker UI 에서 "부모 없음 (최상위)" 선택지가 없어 트리거 불가. + +### 23. SQL 의 LIKE 와일드카드 미이스케이프 + +**어디서**: `DepartmentService.java:283` `searchUsers` + +`%` + 사용자입력 + `%`. 사용자가 `_` 만 입력하면 모든 사용자 매칭. 데이터 누출은 아니지만 enumeration 가능. + +### 24. dead SQL 쿼리 잔존 + +**어디서**: `department.xml:192-195` `deleteUserDeptByDeptCode` + +주석에 "Slice 2.1 이후 사용 안 함" 명시되어 있지만 쿼리 그대로 남아있음. + +### 25. 부서원 목록 INNER JOIN + +**어디서**: `department.xml:213` `selectDeptMembers` + +USER_INFO 에서 사용자가 삭제되면 USER_DEPT row 가 남아있어도 멤버에 안 나타남. `member_count` 와 실제 표시되는 멤버 수가 불일치 가능. + +--- + +## 📋 수정 권장 순서 + +### Phase 1 — 보안/데이터 손상 (최우선) +- #1 searchUsers 회사/role 가드 +- #2 setPrimaryDept 멤버십 검증 +- #3 parent_dept_code 회사 격리 검증 +- #9 글로벌 부서 write 권한 강화 + +### Phase 2 — 데이터 무결성 +- #7 update SQL 의 DELETED_AT 누락 수정 +- #8 update/restore 부서명 중복 검증 +- #4 회사 변경 시 상세 패널 초기화 +- #5 handleMove 실패 처리 + +### Phase 3 — 사용자 경험 +- #6 검색 트리 broken 수정 +- #10 dept_code silent fallback 제거 +- #11 start_date 강제 저장 제거 +- #12 ~ #14 race/실수 방지 + +### Phase 4 — 기능 완성 +- #15 변경이력 API 연동 +- #16 부서원 추가/제거 UI +- #17 일괄등록 실패 상세 + +### Phase 5 — 정리 +- #18 ~ #25 race/공백/dead code/검색 정밀화 + +--- + +## 도메인별 상세 리포트 + +- 프론트엔드 React: `2026-05-08-부서관리-버그헌팅-frontend.md` +- 백엔드 Java: `2026-05-08-부서관리-버그헌팅-backend.md` +- SQL/Mapper: `2026-05-08-부서관리-버그헌팅-sql.md` +- UX 시나리오: `2026-05-08-부서관리-버그헌팅-ux.md` diff --git a/notes/johngreen/2026-05-08-부서관리-버그헌팅-backend.md b/notes/johngreen/2026-05-08-부서관리-버그헌팅-backend.md new file mode 100644 index 00000000..3ca845c0 --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그헌팅-backend.md @@ -0,0 +1,134 @@ +# 부서관리 백엔드 버그 헌팅 (2026-05-08) + +대상: +- `backend-spring/src/main/java/com/erp/controller/DepartmentController.java` +- `backend-spring/src/main/java/com/erp/service/DepartmentService.java` +- `backend-spring/src/main/resources/mapper/department.xml` (참고) + +--- + +## 1. createDepartment dept_code silent fallback ❌ HIGH + +`DepartmentService.java:85-99` — 사용자가 명시한 `dept_code` 가 정규식 `^[A-Za-z0-9_]+$` 위반 시 예외 없이 자동 코드 생성으로 폴백. 사용자는 201 응답을 받지만 응답의 `dept_code` 가 요청과 다름 (silent override). `IllegalArgumentException("부서 코드 형식이 올바르지 않습니다")` 던지는 게 맞음. + +## 2. createDepartment parent_dept_code 검증 누락 ❌ CRITICAL — cross-tenant + +`DepartmentService.java:107`, `DepartmentController.java:80-82` — parent 가 (a) 존재, (b) 같은 회사, (c) deleted 아님 검증 전혀 없음. SUPER_ADMIN 또는 controller 우회 경로로 cross-tenant 부모 지정 가능. FK 가 같은 DB 내 다른 회사 부서를 참조 가능. + +## 3. updateDepartment 부서명 중복 검증 누락 ❌ HIGH + +`DepartmentService.java:131-170` — create 는 `selectDuplicateDeptName` 체크하지만 update 에는 없음. UNIQUE 제약도 mapper 에 없음. 회사 내 동일 이름의 active 부서 두 개 공존 가능. + +## 4. verifyParentCycle 회사 격리 미검증 ❌ HIGH + +`DepartmentService.java:251-271` — cycle 만 체크. newParent.company_code 와 deptCode.company_code 비교 없음. update 흐름에서 #2 와 동일 결함 재현. + +## 5. selectNextDeptNumber race condition ❌ MEDIUM + +`DepartmentService.java:96-99`, `department.xml:108-111` — `MAX(...)+1` 비원자적. 두 요청 동시 진입 시 같은 `next_number` 읽음 → 두 번째 INSERT PK 위반으로 500. 컨트롤러 catch 절은 `DuplicateDeptNameException`/`IllegalArgumentException` 만 잡으므로 raw `DataIntegrityViolationException` 누출. + +## 6. delete-restore trap (활성 자식만 체크) ⚠️ MEDIUM + +`DepartmentService.java:181-202` (`include_deleted=false` 로 자식 카운트) — 활성 자식 0이면 부모 soft-delete OK. 자식 단독 복구 시도 시 `restoreDepartment` 가 부모 deleted 검사로 차단(`PARENT_DELETED`) → 부모 먼저 복구 필요. 영구 trap 은 아니지만 UX 혼선. + +## 7. restoreDepartment 부서명 충돌 미검증 ❌ HIGH + +`DepartmentService.java:210-239` — 시나리오: A 부서 삭제 → 같은 이름 B 부서 생성 (create 는 active 만 검사하므로 통과) → A 부서 복구 → 동일 이름 active 두 부서 공존. 100% 재현 가능. + +## 8. restoreDepartment 부모 1단계 검증 ✅ + +`DepartmentService.java:220-228` — 시스템 invariant 상 부모 active 면 조부모도 active 보장됨 → 1단계로 충분. 외부 직접 SQL 조작 시나리오는 막지 못하지만 OK. + +## 9. addDeptMember dead code ❌ LOW + +`DepartmentController.java:271-272`: +```java +Object userIdObj = body.get("user_id"); +if (userIdObj == null) userIdObj = body.get("user_id"); // 동일 키 +``` +camelCase fallback 의도였던 것이 작성 실수로 같은 snake_case 가 됨. → `body.get("userId")` 로 수정. + +## 10. setPrimaryDept 멤버십 미검증 → DATA CORRUPTION ❌ CRITICAL + +`DepartmentService.java:356-370` — 시나리오: +1. 사용자가 deptA 소속 (IS_PRIMARY=TRUE) +2. 클라이언트가 사용자가 소속되지 않은 deptB 로 PUT 호출 +3. `clearUserPrimaryDept` → 사용자의 모든 USER_DEPT 행 IS_PRIMARY=FALSE (deptA primary 제거) +4. `setUserPrimaryDept` (WHERE USER_ID=? AND DEPT_CODE=deptB) → 0 rows affected +5. 결과: 어떤 부서도 primary 가 아닌 상태로 남음 (invariant 깨짐) + +Service 진입 시 `selectExistingMember` 선검증 + 0 row affected 시 예외/롤백 필수. + +## 11. canAccessDept — 글로벌 부서(`*`) read 허용 ⚠️ MEDIUM + +`DepartmentController.java:361-367` — `userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode)`. 일반 USER 도 글로벌 부서 GET 가능 (의도된 듯). 단 read/write 분리 필요. + +## 12. deleteDepartment — COMPANY_ADMIN 의 글로벌 부서 삭제 ❌ HIGH + +`DepartmentController.java:135-165` — 임의 테넌트 COMPANY_ADMIN 이 글로벌 부서 (`'*'`) 를 DELETE 호출 → `isAdmin(role)` 통과 + `canAccessDept` 통과 (글로벌 매칭) → 삭제 진행. 글로벌 자원 write 는 SUPER_ADMIN 전용 가드 필요. + +## 13. trimString 일관성 ❌ MEDIUM + +`DepartmentService.java:62, 85, 133` — `trimString` 은 dept_name, requestedCode 에만 적용. parent_dept_code, dept_type, short_name, address1/2, zipcode 등은 `nullIfBlank` 만 거치는데 이건 빈 문자열만 null 로 바꿈 — 선행/후행 공백 보존됨. `parent_dept_code=" DEPT_3 "` 입력 시 DB 에 공백 포함 코드 저장. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정. + +## 14. removeDeptMember 트랜잭션 race ⚠️ LOW + +`DepartmentService.java:324-354` — 메서드 전체 `@Transactional` 이라 데이터 일관성 OK. 단 `selectFirstUserDept` 후 다른 트랜잭션이 그 row 도 삭제하는 race 시 `setUserPrimaryDept` 0 row affected, 예외 없음 → primary 부재 commit. 권장: row count 0 이면 재조회 또는 단일 SQL `UPDATE ... WHERE USER_ID=? AND DEPT_CODE=(SELECT ...)` 합치기. + +## 15. SUPER_ADMIN 식별 — `company_code = '*'` 의미 충돌 ⚠️ MEDIUM + +`DepartmentController.java:353-355` — `isSuperAdmin(companyCodeOrRole)` 가 `*` 또는 `SUPER_ADMIN` 둘 다 true. 일반 user 의 `company_code` 가 우연히 `*` 로 저장되면 super 권한 부여. provisioning 레이어에서 `company_code='*'` 차단 필수. 권장: role 만으로 super 판별, company_code 와 분리. + +--- + +## 추가 발견 + +### A. createDepartment 중복명 race ❌ HIGH +`selectDuplicateDeptName` → INSERT 사이 동시 요청 race. DB UNIQUE 제약 없으면 중복 이름 공존 가능. (`department.xml` 에서 unique 제약 없음.) + +### B. searchUsers SQL LIKE wildcard ⚠️ MEDIUM +`DepartmentService.java:283-288` — `params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드. `_` 단독 검색 시 모든 사용자 매칭. 데이터 누출은 아니나 enumeration 가능. + +### C. searchUsers 컨트롤러 권한 검증 누락 ❌ CRITICAL — TENANT BREACH +`DepartmentController.java:234-245` — `userCompanyCode` 가드 **없음**. 임의 사용자가 다른 회사 코드를 path 에 넣으면 그 회사 사용자 목록 검색 가능. 다른 모든 메서드엔 가드 있는데 여기만 누락. + +### D. searchUsers role 검증 누락 ❌ MEDIUM +admin 검사 없음. 일반 USER 가 회사 내 모든 사용자 (이름/아이디) 자유 검색 → 사용자 enumeration. + +### E. getDeptMembers — soft-deleted 부서 멤버 노출 ⚠️ LOW/MEDIUM +`getDepartmentIncludingDeleted` 로 검증만 하고 deleted 부서 members 그대로 반환. 의도된 사양인지 확인 필요. + +### F. ClassCastException 위험 ⚠️ LOW +`DepartmentService.java:81` — `(String) company.get("company_name")`. 컬럼 타입 변경 시 CCE. `Objects.toString(...)` 권장. + +### G. setPrimaryDept 비원자성 ⚠️ LOW +`DepartmentService.java:356-370` — clear + set 두 SQL. 단일 UPDATE 합치기 가능: `UPDATE USER_DEPT SET IS_PRIMARY = (DEPT_CODE = #{dept_code}) WHERE USER_ID = #{user_id}`. + +### H. nextNumber unboxing NPE ⚠️ LOW +`DepartmentService.java:97` — `((Number) codeResult.get("next_number")).longValue()`. 항상 non-null 보장이지만 NPE 가드 권장. + +### I. 로그 PII 평문 ⚠️ LOW +`DepartmentService.java:124, 200, 237, 321, 348, 352, 369` — userId 평문 로깅. PIPA/GDPR 환경 고려. + +### J. selectNextDeptNumber 회사 격리 ✅ +DB-per-tenant 아키텍처상 OK. 글로벌 `*` 회사도 같은 시퀀스 공유. + +--- + +## 우선순위 정리 + +| 등급 | 항목 | +|---|---| +| **CRITICAL** | #2 parent cross-tenant 무검증 / #10 setPrimaryDept data corruption / **C** searchUsers tenant breach | +| **HIGH** | #1 dept_code silent fallback / #3 update 중복명 / #4 verifyParentCycle 회사 / #7 restore 중복명 / #12 글로벌 삭제 / **A** create race / **D** searchUsers role | +| **MEDIUM** | #5 nextNumber race / #6 delete-restore trap / #11 글로벌 write 정책 / #13 trim 일관성 / #15 `*` 의미 / **B** LIKE wildcard / **E** deleted member 노출 | +| **LOW** | #9 dead code / #14 removeMember race / **F-I** | + +## 즉시 수정 권장 + +1. `searchUsers` 컨트롤러 권한/회사 가드 추가 +2. `setPrimaryDept` 선검증 + 0 rows 시 예외 +3. `create/updateDepartment` parent 회사 격리·존재·미삭제 검증 +4. `update/restoreDepartment` 에 `selectDuplicateDeptName` 추가 +5. 글로벌 부서 (`*`) write 작업 SUPER_ADMIN 전용 가드 +6. `dept_code` 형식 위반 시 silent fallback 대신 400 diff --git a/notes/johngreen/2026-05-08-부서관리-버그헌팅-frontend.md b/notes/johngreen/2026-05-08-부서관리-버그헌팅-frontend.md new file mode 100644 index 00000000..de6ac22e --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그헌팅-frontend.md @@ -0,0 +1,328 @@ +# 부서관리 페이지 프론트엔드 버그 헌팅 리포트 + +**대상**: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (1504줄) +**분석일**: 2026-05-08 +**분석자**: Debugger (oh-my-claudecode) + +--- + +## 가설별 판정 + +--- + +### 1. State 동기화 / race condition — ⚠️ 잠재 위험 + +**판정**: ⚠️ 잠재 위험 (rapid 클릭 시 stale 덮어쓰기) + +`loadDepartments` (line 199)는 `useCallback`으로 메모이즈되어 있지만, 연속 호출 시 race condition 방지 로직이 없다. 예를 들어 사용자가 회사 셀렉트를 빠르게 두 번 변경하면: + +- 호출 A (회사1) → 호출 B (회사2) 순으로 시작 +- B가 먼저 응답 → `setDepartments(회사2 데이터)` +- A가 나중에 응답 → `setDepartments(회사1 데이터)` 로 덮어씀 + +``` +page.tsx:199-212 — loadDepartments: AbortController / 취소 플래그 없음 +page.tsx:214-216 — useEffect([loadDepartments]) 가 즉시 재실행됨 +``` + +현실적으로 API 응답 속도가 유사하면 발생 가능성은 낮지만, 느린 네트워크에서 재현 가능. + +--- + +### 2. Dirty tracking — ⚠️ 잠재 위험 + +**판정**: ⚠️ 잠재 위험 (false dirty 발생 조건 존재) + +`isDirty` (line 553-555)는 `JSON.stringify` 순서 의존성 문제보다, **타입 변환** 문제가 더 현실적이다. + +``` +page.tsx:553-555 +const isDirty = originalDraft + ? JSON.stringify(originalDraft) !== JSON.stringify(draft) + : isNewMode && ... +``` + +**구체적 시나리오**: `sort_order` 는 `number` 타입인데, `BasicInfoForm`의 `` 에서 `Number(e.target.value) || 0` 로 변환한다 (line 1398). 그런데 백엔드가 `sort_order: "10"` (string)으로 내려주면 `originalDraft.sort_order`가 string, `draft.sort_order`가 number가 되어 stringify 비교 시 `"10" !== 10` → **값이 동일해도 isDirty=true**. + +또한 `JSON.stringify`는 객체 키 삽입 순서에 따라 직렬화하므로, 두 객체의 키 순서가 다르면 내용이 같아도 불일치가 발생할 수 있다. `emptyDraft` 스프레드 후 override하는 패턴(line 267-288)에서 키 순서는 보통 일정하지만, 런타임에서 `(dept as any)` 캐스팅을 통해 추가 키가 혼입되면 오탐 가능. + +--- + +### 3. Draft 데이터 손실 — ✅ 진짜 버그 + +**판정**: ✅ 진짜 버그 (경고 없이 draft 폐기됨) + +다음 세 가지 시나리오 모두에서 `isDirty` 체크 없이 즉시 draft를 덮어쓴다. + +**시나리오 A — 트리 노드 클릭** +`handleSelectDepartment` (line 263)는 `isDirty` 체크 없이 곧바로 `setDraft(loaded)` / `setOriginalDraft(loaded)` 실행. 사용자가 신규 부서명을 절반쯤 입력하다 트리의 다른 노드를 클릭하면 입력 내용이 경고 없이 사라진다. + +**시나리오 B — 회사 셀렉트 변경** +`selectedCompanyCode` 변경 시 `loadDepartments`만 재호출되고 (line 214-216), `setDraft`/`setOriginalDraft`/`setSelectedCode`/`setIsNewMode` 초기화 로직이 없다 (가설 9와 연동). 작성 중인 신규 draft가 다른 회사로 전환 후에도 우측 패널에 그대로 남아있다가 저장하면 **잘못된 회사에 저장** 되는 위험까지 존재. + +**시나리오 C — 검색 입력** +검색은 `filteredDepts` 필터링만 하므로 draft 자체는 건드리지 않는다. 이 시나리오는 오탐. + +재현: +1. "+ 추가" 클릭 → 부서명 입력 시작 +2. 트리에서 다른 부서 클릭 OR 회사 셀렉트 변경 +3. 입력 내용 소실, 토스트/확인창 없음 + +--- + +### 4. emptyDraft start_date 기본값 — ✅ 진짜 버그 + +**판정**: ✅ 진짜 버그 (사용자 의도 없는 today 저장) + +``` +page.tsx:113 +start_date: new Date().toISOString().slice(0, 10), +``` + +`emptyDraft()`는 매 호출 시 그 시점의 날짜를 `start_date`에 박는다. UI에서 시작일 행은 `{false && ...}` 로 숨겨져 있어 (line 1372) 사용자가 이 값을 인지하거나 수정할 수 없다. 결과: + +- 신규 부서 생성 시 → `start_date = 오늘` 로 항상 DB에 저장됨 +- 일괄등록 시 → `emptyDraft(selectedCompanyCode)` 스프레드로 동일 문제 (line 968) +- 사용자가 명시적으로 시작일을 지정한 적이 없음에도 not-null 값이 저장됨 + +기대 동작: `start_date: ""` 또는 `null` 로 초기화, UI가 숨겨져 있는 동안에는 서버에 null 전송. + +--- + +### 5. handleMove — ✅ 진짜 버그 (부분 업데이트) + +**판정**: ✅ 진짜 버그 + +``` +page.tsx:403-411 +const results = await Promise.all( + reordered.map((s, i) => + departmentAPI.updateDepartment(s.dept_code, toUpdatePayload(s, { sort_order: (i+1)*10 })) + ) +); +``` + +`Promise.all`로 형제 부서 전체를 동시 PUT 호출한다. 일부 요청 실패 시: + +- `results.find((r) => !r.success)` 로 첫 번째 실패를 감지하고 toast를 띄우지만 (line 412-413) +- 이미 성공한 요청의 `sort_order` 변경은 DB에 반영됨 +- `loadDepartments()` 재호출도 catch 블록에서는 실행되지 않음 (line 414) + +결과: 형제 5개 중 3번째가 실패하면 1, 2번 sort_order는 변경, 3, 4, 5번은 미변경인 불일치 상태가 DB에 영구 잔존. 게다가 실패 후 화면은 재로드 없이 이전 상태를 유지하므로 **UI와 DB가 불일치**. + +--- + +### 6. 검색 + 트리 broken tree — ✅ 진짜 버그 + +**판정**: ✅ 진짜 버그 (자식만 매칭 시 트리에서 사라짐) + +``` +page.tsx:231-239 — filteredDepts: 이름/코드 단순 includes 필터 +page.tsx:241-246 — childrenOf: filteredDepts 기준으로 자식 조회 +page.tsx:1064 — DeptTree 내부: sub = allDepts.filter(...) ← allDepts는 filteredDepts +``` + +**시나리오**: 부서 구조가 `경영지원본부 > 인사팀 > 채용파트` 일 때 "채용"으로 검색하면: + +- `filteredDepts` = [`채용파트`] (부모 2개는 미포함) +- `childrenOf(null)` = `filteredDepts`에서 `parent_dept_code === null` 인 것 → 없음 +- 트리에 아무것도 표시 안 됨 + +반대 시나리오: "경영"으로 검색하면 `filteredDepts` = [`경영지원본부`], `childrenOf(null)` = [`경영지원본부`] → 렌더됨. `DeptTree` 내부의 `sub = allDepts.filter(d => d.parent_dept_code === dept.dept_code)` 에서 `allDepts`는 `filteredDepts`이므로 `인사팀`, `채용파트`는 없음. 펼쳐도 자식 없음으로 표시. + +검색 키워드가 있을 때는 트리 구조가 무너져 매칭 결과를 찾을 수 없는 케이스가 다수 발생. + +--- + +### 7. handleSave new mode — ⚠️ 잠재 위험 + +**판정**: ⚠️ 잠재 위험 + +``` +page.tsx:477 +const res = await departmentAPI.createDepartment(selectedCompanyCode, payload); +``` + +`selectedCompanyCode`는 클로저로 캡처된 현재 state값이다. 사용자가: + +1. 회사 A 선택 → "+ 추가" 클릭 → 신규 입력 시작 +2. 회사 셀렉트를 회사 B로 변경 (draft는 그대로 우측에 남아있음 — 가설 9) +3. "저장" 클릭 + +→ `draft.company_code`는 A이지만, `createDepartment(selectedCompanyCode=B, ...)` 로 **회사 B에 부서가 생성됨**. `payload`에 `company_code` 필드는 없으므로 서버가 URL path의 company_code를 사용하면 B 소속이 됨. + +`draft.company_code`와 `selectedCompanyCode`가 분리된 것 자체가 근본 원인. + +--- + +### 8. Bulk register — ✅ 진짜 버그 + ⚠️ 잠재 위험 + +**판정**: ✅ start_date 강제 삽입 (진짜 버그) + ⚠️ 직렬 성능 (잠재 위험) + +**start_date 강제 삽입** (진짜 버그): +``` +page.tsx:968 +...emptyDraft(selectedCompanyCode), ← start_date=today 강제 포함 +dept_code, +dept_name, +``` +가설 4와 동일. 일괄등록된 모든 부서에 `start_date=오늘` 이 박힌다. + +**직렬 호출 성능** (잠재 위험): +``` +page.tsx:959-979 +for (const line of lines) { + await departmentAPI.createDepartment(...) // 직렬 +} +``` +100건 입력 시 API 평균 300ms 가정 → 30초 소요. `setBulkUploading(true)` 후 대기하지만 타임아웃 처리가 없어 네트워크 불량 환경에서 UI가 장시간 블로킹됨. `Promise.allSettled` 병렬화로 해결 가능하나 현재는 누락. + +--- + +### 9. 회사 변경 시 selected/draft 초기화 누락 — ✅ 진짜 버그 + +**판정**: ✅ 진짜 버그 + +``` +page.tsx:214-216 +useEffect(() => { + loadDepartments(); // 트리는 재로드 +}, [loadDepartments]); +// selectedCode, draft, isNewMode 초기화 없음 +``` + +`selectedCompanyCode`가 바뀌면 `loadDepartments`가 새 회사 부서 목록을 로드하지만, 우측 패널의 `selectedCode`, `draft`, `isNewMode`, `originalDraft`는 이전 회사 값 그대로 남는다. + +재현: +1. 회사 A에서 `DEPT001` 선택 → 우측에 상세 표시 +2. 회사 셀렉트를 회사 B로 변경 +3. 우측 패널에 여전히 회사 A의 `DEPT001` 정보 표시 +4. "저장" 클릭 시 회사 B context에서 `DEPT001` PUT 요청 발생 + +트리가 회사 B 부서를 보여주는 동안 상세 패널은 회사 A 데이터를 편집하는 모순 상태. + +--- + +### 10. dept_code 입력 검증 부재 — ⚠️ 잠재 위험 + +**판정**: ⚠️ 잠재 위험 (UX 혼란, 서버 silent override) + +``` +page.tsx:1257-1263 + update("dept_code", e.target.value)} + placeholder="저장 시 자동 부여 (DEPT_n)" + readOnly={!!draft.dept_code} ← 신규 시 draft.dept_code=""이므로 편집 가능 +/> +``` + +신규 입력 시 `draft.dept_code`가 빈 문자열이므로 `readOnly={false}` 상태로 사용자가 임의 입력 가능. 그러나 프론트에는 `^[A-Za-z0-9_]+$` 패턴 검증이 없다. 백엔드가 패턴 불일치 시 자동 코드로 폴백한다면 사용자가 입력한 코드와 실제 저장된 코드가 달라지는 silent override 발생. + +부수 문제: 신규 모드에서 사용자가 `dept_code`에 값을 직접 입력하면 `readOnly={!!draft.dept_code}`에 의해 즉시 readonly가 되어 수정이 불가능해진다 (onBlur 없이 onChange로 바로 잠김). + +--- + +### 11. members 탭 effect — ✅ 진짜 버그 + +**판정**: ✅ 진짜 버그 (stale data 가능) + +``` +page.tsx:219-228 +useEffect(() => { + if (activeTab !== "members" || !selectedCode || isNewMode) { + setMembers([]); + return; + } + (async () => { + const res = await departmentAPI.getDepartmentMembers(selectedCode); + if (res.success && (res as any).data) setMembers((res as any).data); + })(); +}, [activeTab, selectedCode, isNewMode]); +``` + +AbortController가 없다. 재현 시나리오: + +1. 부서 A 선택 → "부서원 정보" 탭 클릭 → API 호출 시작 (느린 네트워크) +2. 트리에서 부서 B 클릭 → selectedCode 변경 → effect 재실행 → B 멤버 API 호출 +3. B 응답 먼저 도착 → `setMembers(B 멤버)` +4. A 응답 나중 도착 → `setMembers(A 멤버)` 로 덮어씀 +5. 화면에는 부서 B가 선택된 상태이지만 A의 멤버 목록이 표시됨 + +cleanup 함수에서 `let cancelled = true` 플래그 또는 `AbortController` 로 이전 fetch를 무효화해야 한다. + +--- + +### 12. originalDraft 동일 ref 공유 — ❌ 오탐 + +**판정**: ❌ 오탐 (현재 코드에서 실제 문제 없음) + +``` +page.tsx:287-288 +setDraft(loaded); +setOriginalDraft(loaded); +``` + +`loaded`는 `handleSelectDepartment` 내에서 `{...emptyDraft(...), ...}` 객체 리터럴로 생성된 새 객체이므로 `setDraft`와 `setOriginalDraft`가 같은 ref를 공유해도, React의 `setDraft((prev) => ({...prev, [key]: value}))` 패턴 (line 1244)이 spread로 새 객체를 만들기 때문에 `originalDraft`를 mutate하지 않는다. 현재 코드 기준으로 실제 문제 없음. + +--- + +## 추가 발견 버그 + +### B1. DeptTree — sub 필터가 sort 없음 ⚠️ + +``` +page.tsx:1064 +const sub = allDepts.filter((d) => d.parent_dept_code === dept.dept_code); +``` + +`DeptTree` 컴포넌트 내부의 `sub` 계산에는 `sort_order` 정렬이 없다. 루트 레벨은 `childrenOf(null)` (line 245)에서 sort가 적용되지만, 2단계 이하 자식들은 `allDepts.filter`로 순서 보장 없이 렌더된다. `handleMove`로 sort_order를 변경해도 2단계 이하는 화면에 반영되지 않음. + +### B2. handleMove — 삭제된 부서가 siblings에 포함될 수 있음 ⚠️ + +``` +page.tsx:381-382 +.filter((d) => ... && !(d as any).deleted_at) +``` + +`loadDepartments`에서 `showDeleted=true`일 때 삭제된 부서도 `departments`에 포함된다. `handleMove`는 `!(d as any).deleted_at`로 필터하므로 siblings에서 제외하는 의도는 맞다. 그러나 `sort_order` normalize 결과가 삭제된 부서의 기존 sort_order와 충돌할 수 있다. 낮은 심각도. + +### B3. bulkOpen 취소 시 진행 중 요청 취소 불가 ✅ + +``` +page.tsx:986 +setBulkOpen(false); +``` + +`bulkUploading` 중에 다이얼로그 외부 클릭(onOpenChange)으로 닫으면 `setBulkOpen(false)`로 UI는 닫히지만 `for...of` 루프는 계속 실행된다. 완료 후 `loadDepartments()`가 호출되는데 이 시점에 이미 사용자가 다른 작업을 하고 있으면 예상치 못한 트리 재로드가 발생. + +``` +page.tsx:929 — +``` +`setBulkUploading` 중에는 다이얼로그를 닫지 못하도록 `onOpenChange={(o) => !bulkUploading && setBulkOpen(o)}` 가 필요. + +--- + +## 요약 테이블 + +| # | 가설 | 판정 | 심각도 | 라인 | +|---|------|------|--------|------| +| 1 | Race condition (loadDepartments) | ⚠️ 잠재 위험 | 낮음 | 199-216 | +| 2 | isDirty false dirty (타입 불일치) | ⚠️ 잠재 위험 | 낮음 | 553-555 | +| 3 | Draft 손실 (트리 클릭 / 회사 변경) | ✅ 진짜 버그 | **높음** | 263-288 | +| 4 | start_date=today 강제 저장 | ✅ 진짜 버그 | 중간 | 113 | +| 5 | handleMove 부분 업데이트 | ✅ 진짜 버그 | **높음** | 403-417 | +| 6 | 검색 broken tree | ✅ 진짜 버그 | **높음** | 231-246 | +| 7 | handleSave new mode 회사 불일치 | ⚠️ 잠재 위험 | 중간 | 477 | +| 8 | Bulk start_date 강제 / 직렬 성능 | ✅+⚠️ | 중간/낮음 | 959-979 | +| 9 | 회사 변경 시 상태 초기화 누락 | ✅ 진짜 버그 | **높음** | 214-216 | +| 10 | dept_code 검증 부재 / 즉시 readonly | ⚠️ 잠재 위험 | 낮음 | 1257-1263 | +| 11 | members 탭 AbortController 누락 | ✅ 진짜 버그 | 중간 | 219-228 | +| 12 | originalDraft 동일 ref | ❌ 오탐 | 없음 | 287-288 | +| B1 | DeptTree sub 정렬 없음 | ⚠️ 잠재 위험 | 낮음 | 1064 | +| B2 | handleMove 삭제 부서 충돌 | ⚠️ 잠재 위험 | 낮음 | 381 | +| B3 | bulkUploading 중 다이얼로그 강제 닫힘 | ✅ 진짜 버그 | 낮음 | 929 | + +**진짜 버그**: 8건 (3, 4, 5, 6, 8-start_date, 9, 11, B3) +**잠재 위험**: 6건 (1, 2, 7, 8-성능, 10, B1, B2) +**오탐**: 1건 (12) diff --git a/notes/johngreen/2026-05-08-부서관리-버그헌팅-sql.md b/notes/johngreen/2026-05-08-부서관리-버그헌팅-sql.md new file mode 100644 index 00000000..d2fb7dec --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그헌팅-sql.md @@ -0,0 +1,95 @@ +# 부서관리 SQL/매퍼 버그 헌팅 (2026-05-08) + +대상: `backend-spring/src/main/resources/mapper/department.xml` + +--- + +## ❌ 1. updateDepartment WHERE 절에 `DELETED_AT IS NULL` 누락 — HIGH + +`department.xml:160-179` +```xml + + UPDATE DEPT_INFO SET ... WHERE DEPT_CODE = #{dept_code} + +``` + +소프트 삭제된 부서도 update 가 통과해버림. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증한 뒤 호출하므로 deleted 부서에 대해서도 흐름이 진행됨. 그 후 service 의 재조회 (`selectDepartmentByCode` — `DELETED_AT IS NULL`) 가 null 반환 → controller 가 404 응답. **DB 는 이미 update 되었는데 사용자는 404 받음 → silent corruption**. + +→ `WHERE DEPT_CODE = #{dept_code} AND DELETED_AT IS NULL` 추가 필수. update 0 row 면 service 가 null 반환하여 404 도 일관됨. + +## ⚠️ 2. selectDepartments — 글로벌 부서 모든 회사에 노출 — MEDIUM + +`department.xml:29` `(D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')` + +글로벌 (`*`) 부서가 모든 회사 트리에 자식으로 섞여 표시됨. PARENT_DEPT_CODE 일치까지 고려하면 어느 부모 아래로 들어갈지 비결정적. 의도된 사양인지 확인 필요. INVYONE 은 DB-per-tenant 인데 글로벌 부서가 동일 DB 에 들어가는 경우 — 멀티 테넌시 모델과 충돌. + +## ✅ 3. selectChildDeptCount — 회사 격리 — OK (DB-per-tenant) + +`department.xml:182-189` — `WHERE PARENT_DEPT_CODE = #{dept_code}` 만 있어 회사 필터 없음. 단 INVYONE 은 DB-per-tenant 라 같은 DB 안에 다른 회사 부서가 거의 없음 (글로벌 `*` 제외). 실제 위험은 낮음. + +## ✅ 4. selectNextDeptNumber 회사 필터 없음 — OK (DB-per-tenant) + +`department.xml:108-112` — 전역 시퀀스. DB-per-tenant 라 회사별 충돌은 발생 안 함. 단 글로벌 (`*`) 부서까지 같은 시퀀스 공유는 의도된 듯. + +## ⚠️ 5. `#{date}::date` cast — MEDIUM + +`department.xml:150-151, 173-174` — null 또는 valid date 형식이면 OK. 잘못된 형식 ("2026/05/08", "abc") 이면 SQLException → 500. service 가 `nullIfBlank` 만 처리하지 형식 검증 안 함. controller 도 안 함. 사용자가 비정상 날짜 입력 시 5xx 누출. + +## ✅ 6. selectDuplicateDeptName 정규화 — OK + +`department.xml:91-97` — `TRIM(LOWER(...))` 양쪽 적용. 케이스/공백 정규화 OK. 단 update 흐름에서 호출 안 됨 (backend 리포트 #3 참조). + +## ⚠️ 7. selectDeptMembers INNER JOIN — LOW/MEDIUM + +`department.xml:213-230` — `JOIN USER_INFO U ON UD.USER_ID = U.USER_ID` (INNER). 사용자가 USER_INFO 에서 삭제되었지만 USER_DEPT row 가 남으면 orphan 멤버 안 보임. member_count (selectDepartments) 는 USER_DEPT 만 카운트하므로 카운트와 실제 표시되는 멤버 수가 불일치 가능. + +## ❌ 8. searchUsers ILIKE 와일드카드 미이스케이프 — MEDIUM + +`department.xml:233-248`, service 의 `params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드로 처리됨. `_` 단독 검색 시 모든 사용자 매칭. SQL injection 은 parameterized 라 안전하지만 enumeration 가능. ESCAPE 절 또는 사전 escaping 권장. + +## ⚠️ 9. deleteUserDeptByDeptCode dead query — LOW + +`department.xml:192-195` — 주석에 "Slice 2.1 이후 사용 안 함" 명시. 안 쓰는 쿼리 잔존. 정리 권장. + +## ✅ 10. selectCompanyName LIMIT 1 — OK (PK) + +`department.xml:100-105` — `LIMIT 1` 만 있고 ORDER BY 없음. company_code 가 PK 면 단일 행만 매칭되므로 OK. PK 가 아니면 비결정적. + +## ⚠️ 11. insertDeptMember UNIQUE 제약 의존 — MEDIUM + +`department.xml:283-286` — INSERT 만. service 가 `selectExistingMember` 로 사전 체크하지만 race 시 두 트랜잭션이 동시 통과 → 두 INSERT. USER_DEPT 에 (USER_ID, DEPT_CODE) UNIQUE 제약이 있어야 race 방어. **mapper 외부 스키마 확인 필요** — 없으면 중복 row 가능. + +## ⚠️ 12. selectFirstUserDept 비결정적 — LOW + +`department.xml:266-272` — `ORDER BY CREATED_DATE ASC LIMIT 1`. 동시 INSERT 시 동일 NOW() 다중 row 가능 → 비결정적 첫 row. primary 자동 승격 시 어떤 부서가 선택될지 예측 불가. 보조 정렬 키 (예: DEPT_CODE) 추가 권장. + +## ⚠️ 13. selectDepartments member_count 와 includeDeleted — LOW + +`department.xml:7-39` — `LEFT JOIN USER_DEPT UD`. USER_DEPT 는 soft-delete 컬럼 없음. 부서가 deleted 여도 USER_DEPT row 는 보존되므로 (멤버 보존 정책) member_count 는 그대로. 휴지통 표시 시 의미 있는 카운트인지 UI 정책 확인. + +## ⚠️ 14. GROUP BY 에 PARENT_DEPT_CODE 포함되었으나 필요한가 — LOW + +`department.xml:33-37` — GROUP BY 에 모든 SELECT 컬럼 나열 (PostgreSQL 요구). `MEMBER_COUNT` 만 집계라 OK. 단 컬럼 추가 시 GROUP BY 도 같이 늘려야 하는 운영 부담. 향후 컬럼 추가 시 누락 위험. + +## ⚠️ 15. dept_name UNIQUE 제약 없음 — HIGH (스키마 종속) + +mapper 만으로는 확정 불가하지만, `selectDuplicateDeptName` 를 SQL 레벨로 보강하는 UNIQUE 제약 (예: `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL`) 이 없으면 race 시 중복 부서명 공존. backend 리포트 추가 발견 A 와 동일 결함 — DB 스키마 차원의 방어 필요. + +--- + +## 우선순위 + +| 등급 | 항목 | +|---|---| +| **HIGH** | #1 update 의 DELETED_AT 누락 (silent corruption) / #15 dept_name UNIQUE 부재 | +| **MEDIUM** | #2 글로벌 부서 회사 트리 혼입 / #5 date cast 5xx / #8 LIKE 와일드카드 / #11 USER_DEPT UNIQUE | +| **LOW** | #7 INNER JOIN orphan / #9 dead query / #12 first-dept 비결정성 / #13-14 | +| **OK** | #3 #4 #6 #10 | + +## 즉시 수정 권장 + +1. `updateDepartment` WHERE 절에 `AND DELETED_AT IS NULL` 추가 +2. USER_DEPT (USER_ID, DEPT_CODE) UNIQUE 제약 확인 / 추가 +3. DEPT_INFO (COMPANY_CODE, dept_name) partial UNIQUE 인덱스 (DELETED_AT IS NULL) 추가 +4. searchUsers 입력값에 LIKE escaping 적용 +5. service 또는 controller 에서 date 형식 검증 → 400 반환 diff --git a/notes/johngreen/2026-05-08-부서관리-버그헌팅-ux.md b/notes/johngreen/2026-05-08-부서관리-버그헌팅-ux.md new file mode 100644 index 00000000..ef7c2d0c --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그헌팅-ux.md @@ -0,0 +1,261 @@ +# 부서관리 UX Edge Case / 버그 헌팅 (2026-05-08) + +대상 파일: +- `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` +- `frontend/components/departments/DepartmentPicker.tsx` +- `frontend/lib/api/department.ts` + +--- + +## 1. 휴지통 모드 — 삭제된 부서 클릭 / 컨텍스트 메뉴 ✅ + +- `page.tsx:1081` onClick 가드 `!isDeleted && handlers.onSelect(dept)` — 클릭 차단 OK. +- `page.tsx:1105–1121` 삭제됨 노드는 ⋮ 메뉴 대신 복구 버튼만 렌더. 조건 분기 `isDeleted ? <복구> : ` 명확. +- isNewMode 중 showDeleted 토글해도 우측 폼은 그대로 유지 — 의도된 동작이므로 문제 없음. + +**추가 시나리오**: 삭제된 부서를 복구 버튼 클릭 → 부모 부서도 deleted 상태면 백엔드가 400 반환 (`restoreDepartment` 주석 참조). `page.tsx:538–549` toast 처리 있음. OK. + +--- + +## 2. collectAllDescendants — deleted 자손 포함 여부 ⚠️ + +- `page.tsx:329–341` `collectAllDescendants`는 `departments` 배열 전체를 순회. +- `departments`는 `showDeleted=true`이면 deleted 자손 포함, `false`면 미포함. +- **문제**: `showDeleted=false`(기본)일 때 deleted 자손은 `departments`에 없으므로 `excludeCodes`에 들어가지 않음. picker에서 이미 soft-delete된 자손 코드를 새 부모로 선택 가능 — 백엔드 cycle 가드가 없으면 실제로는 무해하지만, deleted 부서를 새 부모로 지정하는 비정합 데이터 생성 가능성. +- `DepartmentPicker.tsx:63` `includeDeleted` prop이 있으나 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(기본 false) — picker 자체는 deleted 부서를 보여주지 않으므로 선택 불가. 결과적으로 deleted 자손 선택 자체는 차단됨. +- **실질 위험**: `showDeleted=true`이면서 picker도 `includeDeleted`를 받았을 경우에만 deleted 부서를 새 부모로 지정 가능. 현재 코드에선 picker에 `includeDeleted` 미전달이므로 실질 위험 없음. 단, 향후 includeDeleted 전달 시 cycle 검증 미비로 이어질 수 있음. +- 백엔드 cycle 가드: 코드 내 확인 불가 (백엔드 영역). + +--- + +## 3. picker "최상위로" 선택 — 빈 문자열 vs null ⚠️ + +- `page.tsx:920–922`: + ```ts + onSelect={(code) => + handleConfirmMoveTo(typeof code === "string" && code ? code : null) + } + ``` +- `DepartmentPicker.tsx:179–184` single 모드에서 `onSelect(d.dept_code)` 호출 — 항상 실제 부서코드 문자열 전달. "최상위로" 선택 버튼이 picker에 **없음**. +- **문제**: picker에 "최상위로 이동 (부모 없음)" 선택지가 없어 root 레벨로 이동하는 UX가 불가능. `handleConfirmMoveTo(null)` 경로 자체는 구현되어 있으나 트리거할 방법이 없음. +- 빈 문자열 처리 로직은 방어코드로만 존재 — 실제로는 도달 불가. + +--- + +## 4. 부서원 탭 — 신규 부서 ✅ + +- `page.tsx:839–845` `disabled={isNewMode}` — 탭 버튼 클릭 차단. OK. +- `page.tsx:220–228` useEffect에서 `isNewMode`이면 `setMembers([])` 즉시 반환. OK. + +--- + +## 5. selectedCompanyCode 변경 시 selectedCode/draft 잔존 ❌ + +- `page.tsx:658` 회사 Select `onValueChange={setSelectedCompanyCode}` — `selectedCode`, `draft`, `isNewMode` 초기화 없음. +- **문제**: 회사 A의 부서를 선택 → 회사 B로 전환 → 우측 상세에 회사 A 부서 정보가 그대로 표시됨. breadcrumb도 회사 A 부서명. 회사 B 트리를 클릭하기 전까지 stale 데이터 노출. +- `loadDepartments`(line 199)는 `selectedCompanyCode` 의존성으로 재호출되어 트리는 갱신되지만 우측 패널은 그대로. +- `file:line` — `page.tsx:658` `onValueChange={setSelectedCompanyCode}` 에서 추가로 `handleClearDetail()` 호출 필요. + +--- + +## 6. 부서원 fetch race condition ❌ + +- `page.tsx:219–228` useEffect: + ```ts + useEffect(() => { + if (activeTab !== "members" || !selectedCode || isNewMode) { setMembers([]); return; } + (async () => { + const res = await departmentAPI.getDepartmentMembers(selectedCode); + if (res.success && (res as any).data) setMembers((res as any).data); + })(); + }, [activeTab, selectedCode, isNewMode]); + ``` +- **문제**: cleanup 함수(cancellation flag)가 없음. fetch 도중 다른 부서 클릭 → 이전 fetch가 완료되면 `setMembers`가 구형 데이터로 상태 덮어씀. stale closure 문제. +- `DepartmentPicker.tsx:92–107` 에는 `cancelled` flag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음. +- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음. + +--- + +## 7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌ + +- `page.tsx:291–297` `handleAddNew`: + ```ts + setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode }); + ``` + `emptyDraft(selectedCompanyCode)` 시점에 `company_code`가 고정됨. +- 이후 회사 변경 → `selectedCompanyCode` 갱신되지만 `draft.company_code`는 이전 회사 코드 유지. +- `page.tsx:477` `createDepartment(selectedCompanyCode, payload)` — URL 파라미터는 새 `selectedCompanyCode` 사용, payload 내 `draft`의 `company_code`는 구 회사 코드 → 불일치. +- 백엔드가 URL 파라미터 `companyCode`를 우선하면 실질 저장은 새 회사로 되지만, payload에 잘못된 `company_code`가 포함되어 백엔드 유효성 검증에서 실패할 수도 있음. +- **5번 버그와 동일한 근본 원인**: 회사 변경 시 `handleClearDetail()` 미호출. + +--- + +## 8. 동시 두 삭제 다이얼로그 ⚠️ + +- `page.tsx:881` `deleteConfirmOpen` 다이얼로그 (메인 패널 "삭제" 버튼). +- `page.tsx:898` `contextDeleteDept` 다이얼로그 (트리 ⋮ 메뉴 "삭제"). +- **문제**: 두 다이얼로그 열기 조건이 독립적. 사용자가 ⋮ → 삭제를 클릭해 `contextDeleteDept` 다이얼로그가 열린 상태에서, 뒤에 있는 메인 패널의 "삭제" 버튼(`page.tsx:800`)도 클릭 가능 여부는 Dialog의 z-index/모달 동작에 따라 다름. +- 실제로 shadcn Dialog는 포커스 트랩을 걸므로 동시 두 개 열리기는 어렵지만, 빠른 클릭 시퀀스로 두 state가 모두 true가 될 수는 있음. React 렌더링 사이클 타이밍에 따라 두 Dialog가 동시에 `open={true}`인 상태 가능. +- 치명적 버그는 아니지만 UI 혼란 가능. + +--- + +## 9. handleDelete race — selectedCode 변경 후 삭제 ❌ + +- `page.tsx:503–523` `handleDelete`: + ```ts + const handleDelete = async () => { + if (!selectedCode) return; + const res = await departmentAPI.deleteDepartment(selectedCode); // (A) selectedCode 캡처 + ... + }; + ``` +- `page.tsx:881–895` 삭제 확인 다이얼로그 `open={deleteConfirmOpen}`. +- **문제**: 삭제 다이얼로그가 열린 상태에서 사용자가 배경 트리(Dialog가 포커스 트랩 없는 경우)를 클릭해 `selectedCode`가 변경될 경우, "삭제" 버튼 클릭 시 `handleDelete`는 **최신 `selectedCode`** 를 사용. 즉, 다이얼로그를 열 때 의도한 부서가 아닌 다른 부서가 삭제될 수 있음. +- shadcn Dialog는 기본 포커스 트랩 제공으로 일반 사용 시 배경 클릭은 차단되지만, `onOpenChange`로 dismiss도 가능하고, 로직상 `selectedCode`가 mutable 상태인 것 자체가 위험. +- `handleDelete` 시 클로저로 코드를 고정하지 않는 구조 — 컨텍스트 삭제의 `handleConfirmDeleteContext`(line 421)는 `contextDeleteDept` state를 사용해 이 문제가 없음. 메인 패널 삭제만 취약. + +--- + +## 10. DepartmentPicker — 다른 회사 부서 선택 가능 여부 ✅ + +- `page.tsx:916` picker 호출: + ```ts + companyCode={moveTargetDept?.company_code ?? selectedCompanyCode} + ``` +- `DepartmentPicker.tsx:94` `getDepartments(companyCode, ...)` — 해당 회사 부서만 로드. 다른 회사 부서 노출 없음. +- `moveTargetDept?.company_code`를 사용하므로 이동 대상 부서의 원래 회사 기준으로 picker가 열림. 올바른 동작. + +--- + +## 11. 변경이력 — API 호출 없음 (미구현) ❌ + +- `page.tsx:997–1027` 변경이력 Dialog: hardcoded "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 텍스트만 표시. +- `page.tsx:995–1002` `historyOpen` state 변경 시 fetch 트리거 없음. 실제 API 호출 코드 없음. +- `department.ts` 전체 검색 시 history/changelog 관련 API 함수 없음. +- 기능 미구현 상태. 사용자는 변경이력 버튼을 눌러도 항상 "이력 없음" 문구만 봄. + +--- + +## 12. expandAll — 검색 필터 상태에서의 부분 적용 ⚠️ + +- `page.tsx:249–251`: + ```ts + const expandAll = () => { + setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code))); + }; + ``` +- `filteredDepts`는 검색 키워드가 있으면 필터된 부서만 포함. +- **문제**: 검색어 "인사" 입력 후 expandAll 클릭 → `expandedSet`에 "인사" 매칭 부서들만 저장 → 검색어 삭제 → 전체 트리 표시되지만 expandedSet은 이전 검색 결과에 해당하는 코드만 expanded — 트리가 부분 펼침 상태로 보임. +- 이후 collapseAll/expandAll 재클릭으로 복구 가능. 치명적이지 않으나 UX 혼란. + +--- + +## 13. siteOpen 회사 변경 시 유지 ✅ + +- `siteOpen` state는 회사 변경에 리셋 로직 없음 → 기존 상태 유지. +- 기본값 `true` — 첫 로드, 회사 전환 모두 자동 펼침. 의도된 동작. + +--- + +## 14. handleClearDetail — isDirty 경고 없음 ❌ + +- `page.tsx:299–304`: + ```ts + const handleClearDetail = () => { + setSelectedCode(null); + setIsNewMode(false); + setDraft(emptyDraft(selectedCompanyCode)); + setOriginalDraft(null); + }; + ``` +- `page.tsx:553–555` `isDirty` 계산은 존재하지만 `handleClearDetail` 진입 시 확인 없음. +- `page.tsx:808–815` X 버튼 `onClick={handleClearDetail}` — 폼에 미저장 내용이 있어도 즉시 초기화. +- 또한 `handleConfirmDeleteContext`(line 435) 내부에서도 `handleClearDetail()` 직접 호출 — 삭제 성공 후 폼 클리어는 정상이지만, 만약 다른 부서가 선택된 상태에서 컨텍스트 삭제가 실행되면 해당 부서 폼도 무조건 지워짐 (line 435: `if (selectedCode === d.dept_code) handleClearDetail()` — 조건 있어서 이 케이스는 OK). + +--- + +## 15. bulk register — 실패 부서 상세 없음 ⚠️ + +- `page.tsx:957–985`: + ```ts + toast({ + title: `일괄등록 완료`, + description: `성공 ${success}건 / 실패 ${failed}건`, + }); + ``` +- 실패 건의 `dept_code`, `dept_name`, 실패 사유(에러 메시지)를 수집하지 않음. +- 100개 중 5개 실패 시 어떤 코드가 왜 실패했는지 사용자가 알 수 없음. 재시도 불가. +- `createDepartment` 반환의 `error`, `isDuplicate` 필드를 버리고 `failed++`만 카운트. + +--- + +## 16. member_count — UI에서 멤버 추가/제거 불가 ⚠️ + +- `page.tsx:1469–1502` `MembersPanel` — 멤버 목록 표시만 있고 추가/제거 버튼 없음. +- `department.ts:148–176` `addDepartmentMember`, `removeDepartmentMember` API 함수 존재하나 page.tsx에서 호출되지 않음. +- `department.ts:132–143` `searchUsers` API도 미사용. +- `page.tsx:1472` 멤버 수 표시만 있고 편집 액션 없음 → 기능 미구현. +- 트리 노드의 `member_count`(line 1125–1128)는 부서 목록 재로드 시 갱신되지만 UI에서 변경할 방법 없음. + +--- + +## 추가 발견 시나리오 + +### A. 정렬 이동(handleMove) — deleted 부서 포함 siblings ⚠️ + +- `page.tsx:382`: + ```ts + const siblings = departments + .filter((d) => (d.parent_dept_code ?? null) === (dept.parent_dept_code ?? null) && !(d as any).deleted_at) + ``` +- `deleted_at` 체크로 deleted 제외. OK. +- 단, `showDeleted=true`일 때 deleted 부서도 `departments`에 포함되어 있음 — siblings 필터가 올바르게 제외하므로 문제 없음. + +### B. DepartmentPicker single 모드 — deleted 부서 클릭 가능 ⚠️ + +- `DepartmentPicker.tsx:179–184` `handleNodeClick`: + ```ts + if (isExcluded(d.dept_code)) return; + if (mode === "single") { onSelect(d.dept_code); onClose(); return; } + ``` +- `isDeleted` 체크가 없음 — `includeDeleted=true`로 picker를 열면 deleted 부서도 선택 가능. +- 현재 `page.tsx:924` picker 호출 시 `includeDeleted` 미전달(false)이므로 실질 위험 없으나, 향후 includeDeleted 활성화 시 deleted 부서를 새 부모로 지정 가능. + +### C. handleSave — isNewMode에서 draft.dept_code 중복 ⚠️ + +- `page.tsx:1258–1262` 부서코드 Input: `readOnly={!!draft.dept_code}` — 신규 시 빈 문자열이면 편집 가능. +- 사용자가 이미 존재하는 `dept_code`를 수동 입력 후 저장 → `createDepartment` 409 응답 → `page.tsx:484–486` toast "생성 실패"만 표시. `isDuplicate` 플래그 존재하지만 별도 메시지 없음. + +### D. ancestors breadcrumb — deleted 조상 표시 ⚠️ + +- `page.tsx:164–178` `ancestors` useMemo: `departments` 배열에서 parent 체인 추적. +- `showDeleted=false`이면 deleted 조상은 `departments`에 없어 체인이 중간에 끊김 → breadcrumb 불완전. +- `showDeleted=true`이면 deleted 조상도 표시 — strikethrough 없이 일반 텍스트로 표시됨 (ancestors 렌더에 deleted 스타일링 없음, `page.tsx:772–777`). + +--- + +## 요약 테이블 + +| # | 시나리오 | 판정 | 심각도 | +|---|---|---|---| +| 1 | 휴지통 모드 삭제 부서 클릭/메뉴 | ✅ | - | +| 2 | collectAllDescendants deleted 자손 | ⚠️ | 낮음 | +| 3 | picker "최상위로" 선택 UX 누락 | ⚠️ | 중간 | +| 4 | 부서원 탭 신규 부서 차단 | ✅ | - | +| 5 | 회사 변경 시 selectedCode/draft 잔존 | ❌ | 높음 | +| 6 | 부서원 fetch race condition | ❌ | 중간 | +| 7 | 신규 작성 중 회사 변경 company_code mismatch | ❌ | 높음 | +| 8 | 동시 두 삭제 다이얼로그 | ⚠️ | 낮음 | +| 9 | handleDelete selectedCode race | ❌ | 중간 | +| 10 | picker 다른 회사 부서 선택 차단 | ✅ | - | +| 11 | 변경이력 API 미구현 | ❌ | 중간 | +| 12 | expandAll 검색 후 부분 펼침 | ⚠️ | 낮음 | +| 13 | siteOpen 회사 변경 유지 | ✅ | - | +| 14 | handleClearDetail isDirty 경고 없음 | ❌ | 중간 | +| 15 | bulk register 실패 상세 없음 | ⚠️ | 중간 | +| 16 | 부서원 추가/제거 UI 미구현 | ❌ | 중간 | +| A | handleMove deleted siblings 제외 | ✅ | - | +| B | picker single deleted 부서 선택 가능성 | ⚠️ | 낮음 | +| C | handleSave dept_code 중복 안내 미흡 | ⚠️ | 낮음 | +| D | ancestors breadcrumb deleted 조상 스타일 누락 | ⚠️ | 낮음 | diff --git a/notes/johngreen/2026-05-08-부서관리-버그헌팅-요약.md b/notes/johngreen/2026-05-08-부서관리-버그헌팅-요약.md new file mode 100644 index 00000000..0d297c60 --- /dev/null +++ b/notes/johngreen/2026-05-08-부서관리-버그헌팅-요약.md @@ -0,0 +1,81 @@ +# 부서관리 버그 헌팅 통합 요약 (2026-05-08) + +4개 도메인 병렬 분석 결과. 상세 리포트는 별도 파일 참조. + +- [Frontend (page.tsx)](2026-05-08-부서관리-버그헌팅-frontend.md) +- [Backend (Controller/Service)](2026-05-08-부서관리-버그헌팅-backend.md) +- [SQL/Mapper](2026-05-08-부서관리-버그헌팅-sql.md) +- [UX Edge Cases](2026-05-08-부서관리-버그헌팅-ux.md) + +--- + +## 🔴 CRITICAL (즉시 수정) + +| # | 위치 | 한 줄 | +|---|---|---| +| C1 | `DepartmentService.java:356-370` | **setPrimaryDept 데이터 손상** — 사용자가 소속되지 않은 부서로 호출 시 다른 부서 primary 만 해제되고 새 primary 미설정 → 사용자가 어떤 부서도 primary 가 아닌 invariant 깨진 상태로 commit | +| C2 | `DepartmentController.java:234-245` | **searchUsers 회사 격리 누락** — `userCompanyCode` 가드 없음. 임의 사용자가 다른 회사 사용자 목록 검색 가능 → 멀티테넌시 침해 | +| C3 | `DepartmentService.java:107` + `:251` | **parent_dept_code cross-tenant** — 존재/회사/삭제 검증 전혀 없음. update 의 verifyParentCycle 도 회사 격리 검증 없음. 다른 회사 부서를 부모로 지정 가능 | + +## 🟠 HIGH + +| # | 위치 | 한 줄 | +|---|---|---| +| H1 | `page.tsx:658` (회사 Select) | 회사 변경 시 `selectedCode`/`draft`/`isNewMode` 초기화 안 됨 → 다른 회사 부서가 우측 패널에 stale 노출 + 잘못된 회사 코드로 저장 위험 | +| H2 | `page.tsx:403` `handleMove` | `Promise.all` 로 N개 PUT — 일부 실패 시 부분 업데이트 영구 잔존 (catch 에서 loadDepartments 미호출). 트랜잭션 없음 | +| H3 | `page.tsx:231-246` 검색 필터 | 자식 매칭/부모 미매칭 시 트리 구조가 깨져 부서 안 보임 (broken tree) | +| H4 | `department.xml:160-179` | **updateDepartment 의 WHERE 에 `DELETED_AT IS NULL` 누락** — 삭제된 부서도 update 통과 후 controller 가 404 반환 → DB 는 변경되었는데 사용자는 실패로 인식 (silent corruption) | +| H5 | `DepartmentService.java:131` | **updateDepartment 부서명 중복 검증 없음** — create 에는 있지만 update 에 없어 동일 이름 active 부서 두 개 공존 가능 | +| H6 | `DepartmentService.java:210` `restoreDepartment` | 복구 시 동일 이름 active 부서 충돌 검증 없음 → 100% 재현되는 중복 이름 공존 | +| H7 | `DepartmentController.java:135` `deleteDepartment` | COMPANY_ADMIN 이 글로벌 부서 (`*`) 삭제 가능 → SUPER_ADMIN 전용 가드 필요 | +| H8 | `DepartmentService.java:85-99` `createDepartment` | 사용자 명시 dept_code 가 정규식 위반 시 silent fallback → 자동 코드로 발행되지만 사용자는 알지 못함 | +| H9 | `DepartmentController.java:234` `searchUsers` | role 검사 없음 — 일반 USER 가 회사 내 사용자 enumeration 가능 | +| H10 | `page.tsx:113` `emptyDraft` | `start_date` 가 `new Date().toISOString().slice(0,10)` 으로 고정 — UI 에서 hidden 인데도 today 가 강제 저장됨. 일괄등록도 동일 | +| H11 | DEPT_INFO 스키마 (mapper 외 확인 필요) | `(COMPANY_CODE, dept_name)` partial UNIQUE 인덱스 부재 가능성 → race 시 중복 부서명 공존 (#H5/H6 의 근본 방어선) | + +## 🟡 MEDIUM + +| # | 위치 | 한 줄 | +|---|---|---| +| M1 | `page.tsx:219-228` 멤버 fetch | AbortController/cancellation flag 없음 → 빠른 부서 전환 시 stale 멤버 데이터 표시 | +| M2 | `page.tsx:503` `handleDelete` | `selectedCode` 를 클로저로 캡처 안 함 → 다이얼로그 열린 상태에서 다른 부서 선택되면 엉뚱한 부서 삭제 위험 | +| M3 | `page.tsx:299` `handleClearDetail` | `isDirty` 무시. X 버튼 클릭 시 미저장 변경 즉시 폐기 | +| M4 | `page.tsx:997` 변경이력 모달 | API 호출 없음 — 항상 "이력 없음" 표시. 기능 미구현 | +| M5 | `DepartmentService.java:96` `selectNextDeptNumber` | `MAX+1` 비원자적 → 동시 생성 시 PK 충돌 5xx (catch 누락) | +| M6 | `DepartmentService.java:181` `deleteDepartment` | 활성 자식만 카운트 → 자식 단독 복구 시 부모 deleted 차단되는 UX trap | +| M7 | `DepartmentService.java:283` `searchUsers` | LIKE 와일드카드 `%`/`_` 미이스케이프 → 동작 이상 + enumeration | +| M8 | `DepartmentService.java:107`+ | `nullIfBlank` 만 적용. 선행/후행 공백 보존 → DB 에 공백 포함 코드 저장 가능 | +| M9 | `department.xml:150-174` | `#{date}::date` cast 시 잘못된 형식 → SQLException 5xx | +| M10 | DEPT_INFO `*` 회사 정책 | 글로벌 부서 read 일반 user 허용 / write 권한 정책 모호 | +| M11 | `DepartmentController.java:353` super 식별 | `company_code='*'` 우연 충돌 시 super 권한 부여 — provisioning 가드 필수 | + +## 🟢 LOW + +| # | 위치 | 한 줄 | +|---|---|---| +| L1 | `DepartmentController.java:271-272` | dead code (동일 키 두 번 lookup) | +| L2 | `DepartmentService.java:324` `removeDeptMember` | primary 자동 승격 race 가능성 (낮음) | +| L3 | `page.tsx:920` picker "최상위로" 선택 | UX 미구현 — `handleConfirmMoveTo(null)` 트리거 경로 없음 | +| L4 | `page.tsx:957` bulk register | 실패 부서 코드/사유 표시 없음 (성공/실패 카운트만) | +| L5 | `page.tsx:1469` 멤버 패널 | 멤버 추가/제거 UI 미구현. API 만 존재 | +| L6 | `page.tsx:249` `expandAll` + 검색 | 검색 후 expandAll → 검색 해제 시 부분 펼침 상태 | +| L7 | `department.xml:213` selectDeptMembers INNER JOIN | USER_INFO 삭제 시 orphan 멤버 표시 안 됨 | +| L8 | `department.xml:192` deleteUserDeptByDeptCode | dead query | +| L9 | `department.xml:266` selectFirstUserDept | 동일 NOW() 다중 row 시 비결정적 | + +--- + +## 추천 수정 순서 + +**Phase 1 (긴급/보안)**: C1, C2, C3, H7 +**Phase 2 (데이터 무결성)**: H4, H5, H6, H11, H1 +**Phase 3 (UX/안정성)**: H2, H3, H8, H10, M1~M3 +**Phase 4 (기능 보강)**: M4, L4, L5, L3 +**Phase 5 (정리)**: L1, L7~L9, M5~M11 + +## 도메인별 통계 + +- Frontend: 진짜 8 / 잠재 6 / 오탐 1 +- Backend: CRITICAL 3 + HIGH 7 + MEDIUM 7 + LOW 4 +- SQL: HIGH 2 + MEDIUM 4 + LOW 4 + OK 4 +- UX: 진짜 6 + 잠재 10 + OK 4