9c658ffd36
4개 도메인 병렬 분석 결과 + 통합 요약 + 시나리오 중심 정리.
총 25개 버그 (CRITICAL 3 / HIGH 11 / MEDIUM 7 / LOW 4) 식별.
실제 수정은 별도 커밋 (commit 68c1cb5b).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
14 KiB
부서관리 UX Edge Case / 버그 헌팅 (2026-05-08)
대상 파일:
frontend/app/(main)/admin/userMng/deptMngList/page.tsxfrontend/components/departments/DepartmentPicker.tsxfrontend/lib/api/department.ts
1. 휴지통 모드 — 삭제된 부서 클릭 / 컨텍스트 메뉴 ✅
page.tsx:1081onClick 가드!isDeleted && handlers.onSelect(dept)— 클릭 차단 OK.page.tsx:1105–1121삭제됨 노드는 ⋮ 메뉴 대신 복구 버튼만 렌더. 조건 분기isDeleted ? <복구> : <DropdownMenu>명확.- isNewMode 중 showDeleted 토글해도 우측 폼은 그대로 유지 — 의도된 동작이므로 문제 없음.
추가 시나리오: 삭제된 부서를 복구 버튼 클릭 → 부모 부서도 deleted 상태면 백엔드가 400 반환 (restoreDepartment 주석 참조). page.tsx:538–549 toast 처리 있음. OK.
2. collectAllDescendants — deleted 자손 포함 여부 ⚠️
page.tsx:329–341collectAllDescendants는departments배열 전체를 순회.departments는showDeleted=true이면 deleted 자손 포함,false면 미포함.- 문제:
showDeleted=false(기본)일 때 deleted 자손은departments에 없으므로excludeCodes에 들어가지 않음. picker에서 이미 soft-delete된 자손 코드를 새 부모로 선택 가능 — 백엔드 cycle 가드가 없으면 실제로는 무해하지만, deleted 부서를 새 부모로 지정하는 비정합 데이터 생성 가능성. DepartmentPicker.tsx:63includeDeletedprop이 있으나page.tsx:924picker 호출 시includeDeleted미전달(기본 false) — picker 자체는 deleted 부서를 보여주지 않으므로 선택 불가. 결과적으로 deleted 자손 선택 자체는 차단됨.- 실질 위험:
showDeleted=true이면서 picker도includeDeleted를 받았을 경우에만 deleted 부서를 새 부모로 지정 가능. 현재 코드에선 picker에includeDeleted미전달이므로 실질 위험 없음. 단, 향후 includeDeleted 전달 시 cycle 검증 미비로 이어질 수 있음. - 백엔드 cycle 가드: 코드 내 확인 불가 (백엔드 영역).
3. picker "최상위로" 선택 — 빈 문자열 vs null ⚠️
page.tsx:920–922:onSelect={(code) => handleConfirmMoveTo(typeof code === "string" && code ? code : null) }DepartmentPicker.tsx:179–184single 모드에서onSelect(d.dept_code)호출 — 항상 실제 부서코드 문자열 전달. "최상위로" 선택 버튼이 picker에 없음.- 문제: picker에 "최상위로 이동 (부모 없음)" 선택지가 없어 root 레벨로 이동하는 UX가 불가능.
handleConfirmMoveTo(null)경로 자체는 구현되어 있으나 트리거할 방법이 없음. - 빈 문자열 처리 로직은 방어코드로만 존재 — 실제로는 도달 불가.
4. 부서원 탭 — 신규 부서 ✅
page.tsx:839–845disabled={isNewMode}— 탭 버튼 클릭 차단. OK.page.tsx:220–228useEffect에서isNewMode이면setMembers([])즉시 반환. OK.
5. selectedCompanyCode 변경 시 selectedCode/draft 잔존 ❌
page.tsx:658회사 SelectonValueChange={setSelectedCompanyCode}—selectedCode,draft,isNewMode초기화 없음.- 문제: 회사 A의 부서를 선택 → 회사 B로 전환 → 우측 상세에 회사 A 부서 정보가 그대로 표시됨. breadcrumb도 회사 A 부서명. 회사 B 트리를 클릭하기 전까지 stale 데이터 노출.
loadDepartments(line 199)는selectedCompanyCode의존성으로 재호출되어 트리는 갱신되지만 우측 패널은 그대로.file:line—page.tsx:658onValueChange={setSelectedCompanyCode}에서 추가로handleClearDetail()호출 필요.
6. 부서원 fetch race condition ❌
page.tsx:219–228useEffect: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에는cancelledflag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음.- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음.
7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌
page.tsx:291–297handleAddNew:setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });emptyDraft(selectedCompanyCode)시점에company_code가 고정됨.- 이후 회사 변경 →
selectedCompanyCode갱신되지만draft.company_code는 이전 회사 코드 유지. page.tsx:477createDepartment(selectedCompanyCode, payload)— URL 파라미터는 새selectedCompanyCode사용, payload 내draft의company_code는 구 회사 코드 → 불일치.- 백엔드가 URL 파라미터
companyCode를 우선하면 실질 저장은 새 회사로 되지만, payload에 잘못된company_code가 포함되어 백엔드 유효성 검증에서 실패할 수도 있음. - 5번 버그와 동일한 근본 원인: 회사 변경 시
handleClearDetail()미호출.
8. 동시 두 삭제 다이얼로그 ⚠️
page.tsx:881deleteConfirmOpen다이얼로그 (메인 패널 "삭제" 버튼).page.tsx:898contextDeleteDept다이얼로그 (트리 ⋮ 메뉴 "삭제").- 문제: 두 다이얼로그 열기 조건이 독립적. 사용자가 ⋮ → 삭제를 클릭해
contextDeleteDept다이얼로그가 열린 상태에서, 뒤에 있는 메인 패널의 "삭제" 버튼(page.tsx:800)도 클릭 가능 여부는 Dialog의 z-index/모달 동작에 따라 다름. - 실제로 shadcn Dialog는 포커스 트랩을 걸므로 동시 두 개 열리기는 어렵지만, 빠른 클릭 시퀀스로 두 state가 모두 true가 될 수는 있음. React 렌더링 사이클 타이밍에 따라 두 Dialog가 동시에
open={true}인 상태 가능. - 치명적 버그는 아니지만 UI 혼란 가능.
9. handleDelete race — selectedCode 변경 후 삭제 ❌
page.tsx:503–523handleDelete: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)는contextDeleteDeptstate를 사용해 이 문제가 없음. 메인 패널 삭제만 취약.
10. DepartmentPicker — 다른 회사 부서 선택 가능 여부 ✅
page.tsx:916picker 호출:companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}DepartmentPicker.tsx:94getDepartments(companyCode, ...)— 해당 회사 부서만 로드. 다른 회사 부서 노출 없음.moveTargetDept?.company_code를 사용하므로 이동 대상 부서의 원래 회사 기준으로 picker가 열림. 올바른 동작.
11. 변경이력 — API 호출 없음 (미구현) ❌
page.tsx:997–1027변경이력 Dialog: hardcoded "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 텍스트만 표시.page.tsx:995–1002historyOpenstate 변경 시 fetch 트리거 없음. 실제 API 호출 코드 없음.department.ts전체 검색 시 history/changelog 관련 API 함수 없음.- 기능 미구현 상태. 사용자는 변경이력 버튼을 눌러도 항상 "이력 없음" 문구만 봄.
12. expandAll — 검색 필터 상태에서의 부분 적용 ⚠️
page.tsx:249–251:const expandAll = () => { setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code))); };filteredDepts는 검색 키워드가 있으면 필터된 부서만 포함.- 문제: 검색어 "인사" 입력 후 expandAll 클릭 →
expandedSet에 "인사" 매칭 부서들만 저장 → 검색어 삭제 → 전체 트리 표시되지만 expandedSet은 이전 검색 결과에 해당하는 코드만 expanded — 트리가 부분 펼침 상태로 보임. - 이후 collapseAll/expandAll 재클릭으로 복구 가능. 치명적이지 않으나 UX 혼란.
13. siteOpen 회사 변경 시 유지 ✅
siteOpenstate는 회사 변경에 리셋 로직 없음 → 기존 상태 유지.- 기본값
true— 첫 로드, 회사 전환 모두 자동 펼침. 의도된 동작.
14. handleClearDetail — isDirty 경고 없음 ❌
page.tsx:299–304:const handleClearDetail = () => { setSelectedCode(null); setIsNewMode(false); setDraft(emptyDraft(selectedCompanyCode)); setOriginalDraft(null); };page.tsx:553–555isDirty계산은 존재하지만handleClearDetail진입 시 확인 없음.page.tsx:808–815X 버튼onClick={handleClearDetail}— 폼에 미저장 내용이 있어도 즉시 초기화.- 또한
handleConfirmDeleteContext(line 435) 내부에서도handleClearDetail()직접 호출 — 삭제 성공 후 폼 클리어는 정상이지만, 만약 다른 부서가 선택된 상태에서 컨텍스트 삭제가 실행되면 해당 부서 폼도 무조건 지워짐 (line 435:if (selectedCode === d.dept_code) handleClearDetail()— 조건 있어서 이 케이스는 OK).
15. bulk register — 실패 부서 상세 없음 ⚠️
page.tsx:957–985:toast({ title: `일괄등록 완료`, description: `성공 ${success}건 / 실패 ${failed}건`, });- 실패 건의
dept_code,dept_name, 실패 사유(에러 메시지)를 수집하지 않음. - 100개 중 5개 실패 시 어떤 코드가 왜 실패했는지 사용자가 알 수 없음. 재시도 불가.
createDepartment반환의error,isDuplicate필드를 버리고failed++만 카운트.
16. member_count — UI에서 멤버 추가/제거 불가 ⚠️
page.tsx:1469–1502MembersPanel— 멤버 목록 표시만 있고 추가/제거 버튼 없음.department.ts:148–176addDepartmentMember,removeDepartmentMemberAPI 함수 존재하나 page.tsx에서 호출되지 않음.department.ts:132–143searchUsersAPI도 미사용.page.tsx:1472멤버 수 표시만 있고 편집 액션 없음 → 기능 미구현.- 트리 노드의
member_count(line 1125–1128)는 부서 목록 재로드 시 갱신되지만 UI에서 변경할 방법 없음.
추가 발견 시나리오
A. 정렬 이동(handleMove) — deleted 부서 포함 siblings ⚠️
page.tsx:382: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–184handleNodeClick:if (isExcluded(d.dept_code)) return; if (mode === "single") { onSelect(d.dept_code); onClose(); return; }isDeleted체크가 없음 —includeDeleted=true로 picker를 열면 deleted 부서도 선택 가능.- 현재
page.tsx:924picker 호출 시includeDeleted미전달(false)이므로 실질 위험 없으나, 향후 includeDeleted 활성화 시 deleted 부서를 새 부모로 지정 가능.
C. handleSave — isNewMode에서 draft.dept_code 중복 ⚠️
page.tsx:1258–1262부서코드 Input:readOnly={!!draft.dept_code}— 신규 시 빈 문자열이면 편집 가능.- 사용자가 이미 존재하는
dept_code를 수동 입력 후 저장 →createDepartment409 응답 →page.tsx:484–486toast "생성 실패"만 표시.isDuplicate플래그 존재하지만 별도 메시지 없음.
D. ancestors breadcrumb — deleted 조상 표시 ⚠️
page.tsx:164–178ancestorsuseMemo: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 조상 스타일 누락 | ⚠️ | 낮음 |