# 부서관리 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 조상 스타일 누락 | ⚠️ | 낮음 |