Files
invyone/notes/johngreen/2026-05-08-부서관리-버그헌팅-ux.md
T
johngreen 9c658ffd36 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>
2026-05-08 17:08:23 +09:00

14 KiB
Raw Blame History

부서관리 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 collectAllDescendantsdepartments 배열 전체를 순회.
  • departmentsshowDeleted=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:
    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:linepage.tsx:658 onValueChange={setSelectedCompanyCode} 에서 추가로 handleClearDetail() 호출 필요.

6. 부서원 fetch race condition

  • page.tsx:219228 useEffect:
    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:
    setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });
    
    emptyDraft(selectedCompanyCode) 시점에 company_code가 고정됨.
  • 이후 회사 변경 → selectedCompanyCode 갱신되지만 draft.company_code는 이전 회사 코드 유지.
  • page.tsx:477 createDepartment(selectedCompanyCode, payload) — URL 파라미터는 새 selectedCompanyCode 사용, payload 내 draftcompany_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:
    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 호출:
    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:
    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:
    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:
    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:
    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:
    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 조상 스타일 누락 ⚠️ 낮음