docs(notes): 부서관리 버그 헌팅 분석 리포트 (frontend/backend/sql/ux)

4개 도메인 병렬 분석 결과 + 통합 요약 + 시나리오 중심 정리.
총 25개 버그 (CRITICAL 3 / HIGH 11 / MEDIUM 7 / LOW 4) 식별.
실제 수정은 별도 커밋 (commit 68c1cb5b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 17:08:23 +09:00
parent 68c1cb5b14
commit 9c658ffd36
6 changed files with 1264 additions and 0 deletions
@@ -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`
@@ -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
@@ -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``<Input type="number">` 에서 `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
<Input
value={draft.dept_code}
onChange={(e) => 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 — <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
```
`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)
@@ -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 id="updateDepartment" parameterType="map">
UPDATE DEPT_INFO SET ... WHERE DEPT_CODE = #{dept_code}
</update>
```
소프트 삭제된 부서도 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 반환
@@ -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:11051121` 삭제됨 노드는 ⋮ 메뉴 대신 복구 버튼만 렌더. 조건 분기 `isDeleted ? <복구> : <DropdownMenu>` 명확.
- isNewMode 중 showDeleted 토글해도 우측 폼은 그대로 유지 — 의도된 동작이므로 문제 없음.
**추가 시나리오**: 삭제된 부서를 복구 버튼 클릭 → 부모 부서도 deleted 상태면 백엔드가 400 반환 (`restoreDepartment` 주석 참조). `page.tsx:538549` toast 처리 있음. OK.
---
## 2. collectAllDescendants — deleted 자손 포함 여부 ⚠️
- `page.tsx:329341` `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:920922`:
```ts
onSelect={(code) =>
handleConfirmMoveTo(typeof code === "string" && code ? code : null)
}
```
- `DepartmentPicker.tsx:179184` single 모드에서 `onSelect(d.dept_code)` 호출 — 항상 실제 부서코드 문자열 전달. "최상위로" 선택 버튼이 picker에 **없음**.
- **문제**: picker에 "최상위로 이동 (부모 없음)" 선택지가 없어 root 레벨로 이동하는 UX가 불가능. `handleConfirmMoveTo(null)` 경로 자체는 구현되어 있으나 트리거할 방법이 없음.
- 빈 문자열 처리 로직은 방어코드로만 존재 — 실제로는 도달 불가.
---
## 4. 부서원 탭 — 신규 부서 ✅
- `page.tsx:839845` `disabled={isNewMode}` — 탭 버튼 클릭 차단. OK.
- `page.tsx:220228` 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:219228` 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:92107` 에는 `cancelled` flag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음.
- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음.
---
## 7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌
- `page.tsx:291297` `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:503523` `handleDelete`:
```ts
const handleDelete = async () => {
if (!selectedCode) return;
const res = await departmentAPI.deleteDepartment(selectedCode); // (A) selectedCode 캡처
...
};
```
- `page.tsx:881895` 삭제 확인 다이얼로그 `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:9971027` 변경이력 Dialog: hardcoded "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 텍스트만 표시.
- `page.tsx:9951002` `historyOpen` state 변경 시 fetch 트리거 없음. 실제 API 호출 코드 없음.
- `department.ts` 전체 검색 시 history/changelog 관련 API 함수 없음.
- 기능 미구현 상태. 사용자는 변경이력 버튼을 눌러도 항상 "이력 없음" 문구만 봄.
---
## 12. expandAll — 검색 필터 상태에서의 부분 적용 ⚠️
- `page.tsx:249251`:
```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:299304`:
```ts
const handleClearDetail = () => {
setSelectedCode(null);
setIsNewMode(false);
setDraft(emptyDraft(selectedCompanyCode));
setOriginalDraft(null);
};
```
- `page.tsx:553555` `isDirty` 계산은 존재하지만 `handleClearDetail` 진입 시 확인 없음.
- `page.tsx:808815` X 버튼 `onClick={handleClearDetail}` — 폼에 미저장 내용이 있어도 즉시 초기화.
- 또한 `handleConfirmDeleteContext`(line 435) 내부에서도 `handleClearDetail()` 직접 호출 — 삭제 성공 후 폼 클리어는 정상이지만, 만약 다른 부서가 선택된 상태에서 컨텍스트 삭제가 실행되면 해당 부서 폼도 무조건 지워짐 (line 435: `if (selectedCode === d.dept_code) handleClearDetail()` — 조건 있어서 이 케이스는 OK).
---
## 15. bulk register — 실패 부서 상세 없음 ⚠️
- `page.tsx:957985`:
```ts
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failed}건`,
});
```
- 실패 건의 `dept_code`, `dept_name`, 실패 사유(에러 메시지)를 수집하지 않음.
- 100개 중 5개 실패 시 어떤 코드가 왜 실패했는지 사용자가 알 수 없음. 재시도 불가.
- `createDepartment` 반환의 `error`, `isDuplicate` 필드를 버리고 `failed++`만 카운트.
---
## 16. member_count — UI에서 멤버 추가/제거 불가 ⚠️
- `page.tsx:14691502` `MembersPanel` — 멤버 목록 표시만 있고 추가/제거 버튼 없음.
- `department.ts:148176` `addDepartmentMember`, `removeDepartmentMember` API 함수 존재하나 page.tsx에서 호출되지 않음.
- `department.ts:132143` `searchUsers` API도 미사용.
- `page.tsx:1472` 멤버 수 표시만 있고 편집 액션 없음 → 기능 미구현.
- 트리 노드의 `member_count`(line 11251128)는 부서 목록 재로드 시 갱신되지만 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:179184` `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:12581262` 부서코드 Input: `readOnly={!!draft.dept_code}` — 신규 시 빈 문자열이면 편집 가능.
- 사용자가 이미 존재하는 `dept_code`를 수동 입력 후 저장 → `createDepartment` 409 응답 → `page.tsx:484486` toast "생성 실패"만 표시. `isDuplicate` 플래그 존재하지만 별도 메시지 없음.
### D. ancestors breadcrumb — deleted 조상 표시 ⚠️
- `page.tsx:164178` `ancestors` useMemo: `departments` 배열에서 parent 체인 추적.
- `showDeleted=false`이면 deleted 조상은 `departments`에 없어 체인이 중간에 끊김 → breadcrumb 불완전.
- `showDeleted=true`이면 deleted 조상도 표시 — strikethrough 없이 일반 텍스트로 표시됨 (ancestors 렌더에 deleted 스타일링 없음, `page.tsx:772777`).
---
## 요약 테이블
| # | 시나리오 | 판정 | 심각도 |
|---|---|---|---|
| 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 조상 스타일 누락 | ⚠️ | 낮음 |
@@ -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