# 부서관리 페이지 버그 정리 — 시나리오 중심 작성일: 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`