# 부서관리 페이지 버그 정리 — 시나리오 중심

작성일: 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`
