# 부서관리 페이지 프론트엔드 버그 헌팅 리포트

**대상**: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (1504줄)  
**분석일**: 2026-05-08  
**분석자**: Debugger (oh-my-claudecode)

---

## 가설별 판정

---

### 1. State 동기화 / race condition — ⚠️ 잠재 위험

**판정**: ⚠️ 잠재 위험 (rapid 클릭 시 stale 덮어쓰기)

`loadDepartments` (line 199)는 `useCallback`으로 메모이즈되어 있지만, 연속 호출 시 race condition 방지 로직이 없다. 예를 들어 사용자가 회사 셀렉트를 빠르게 두 번 변경하면:

- 호출 A (회사1) → 호출 B (회사2) 순으로 시작
- B가 먼저 응답 → `setDepartments(회사2 데이터)`
- A가 나중에 응답 → `setDepartments(회사1 데이터)` 로 덮어씀

```
page.tsx:199-212 — loadDepartments: AbortController / 취소 플래그 없음
page.tsx:214-216 — useEffect([loadDepartments]) 가 즉시 재실행됨
```

현실적으로 API 응답 속도가 유사하면 발생 가능성은 낮지만, 느린 네트워크에서 재현 가능.

---

### 2. Dirty tracking — ⚠️ 잠재 위험

**판정**: ⚠️ 잠재 위험 (false dirty 발생 조건 존재)

`isDirty` (line 553-555)는 `JSON.stringify` 순서 의존성 문제보다, **타입 변환** 문제가 더 현실적이다.

```
page.tsx:553-555
const isDirty = originalDraft
  ? JSON.stringify(originalDraft) !== JSON.stringify(draft)
  : isNewMode && ...
```

**구체적 시나리오**: `sort_order` 는 `number` 타입인데, `BasicInfoForm`의 `<Input type="number">` 에서 `Number(e.target.value) || 0` 로 변환한다 (line 1398). 그런데 백엔드가 `sort_order: "10"` (string)으로 내려주면 `originalDraft.sort_order`가 string, `draft.sort_order`가 number가 되어 stringify 비교 시 `"10" !== 10` → **값이 동일해도 isDirty=true**.

또한 `JSON.stringify`는 객체 키 삽입 순서에 따라 직렬화하므로, 두 객체의 키 순서가 다르면 내용이 같아도 불일치가 발생할 수 있다. `emptyDraft` 스프레드 후 override하는 패턴(line 267-288)에서 키 순서는 보통 일정하지만, 런타임에서 `(dept as any)` 캐스팅을 통해 추가 키가 혼입되면 오탐 가능.

---

### 3. Draft 데이터 손실 — ✅ 진짜 버그

**판정**: ✅ 진짜 버그 (경고 없이 draft 폐기됨)

다음 세 가지 시나리오 모두에서 `isDirty` 체크 없이 즉시 draft를 덮어쓴다.

**시나리오 A — 트리 노드 클릭**  
`handleSelectDepartment` (line 263)는 `isDirty` 체크 없이 곧바로 `setDraft(loaded)` / `setOriginalDraft(loaded)` 실행. 사용자가 신규 부서명을 절반쯤 입력하다 트리의 다른 노드를 클릭하면 입력 내용이 경고 없이 사라진다.

**시나리오 B — 회사 셀렉트 변경**  
`selectedCompanyCode` 변경 시 `loadDepartments`만 재호출되고 (line 214-216), `setDraft`/`setOriginalDraft`/`setSelectedCode`/`setIsNewMode` 초기화 로직이 없다 (가설 9와 연동). 작성 중인 신규 draft가 다른 회사로 전환 후에도 우측 패널에 그대로 남아있다가 저장하면 **잘못된 회사에 저장** 되는 위험까지 존재.

**시나리오 C — 검색 입력**  
검색은 `filteredDepts` 필터링만 하므로 draft 자체는 건드리지 않는다. 이 시나리오는 오탐.

재현:
1. "+ 추가" 클릭 → 부서명 입력 시작
2. 트리에서 다른 부서 클릭 OR 회사 셀렉트 변경
3. 입력 내용 소실, 토스트/확인창 없음

---

### 4. emptyDraft start_date 기본값 — ✅ 진짜 버그

**판정**: ✅ 진짜 버그 (사용자 의도 없는 today 저장)

```
page.tsx:113
start_date: new Date().toISOString().slice(0, 10),
```

`emptyDraft()`는 매 호출 시 그 시점의 날짜를 `start_date`에 박는다. UI에서 시작일 행은 `{false && ...}` 로 숨겨져 있어 (line 1372) 사용자가 이 값을 인지하거나 수정할 수 없다. 결과:

- 신규 부서 생성 시 → `start_date = 오늘` 로 항상 DB에 저장됨
- 일괄등록 시 → `emptyDraft(selectedCompanyCode)` 스프레드로 동일 문제 (line 968)
- 사용자가 명시적으로 시작일을 지정한 적이 없음에도 not-null 값이 저장됨

기대 동작: `start_date: ""` 또는 `null` 로 초기화, UI가 숨겨져 있는 동안에는 서버에 null 전송.

---

### 5. handleMove — ✅ 진짜 버그 (부분 업데이트)

**판정**: ✅ 진짜 버그

```
page.tsx:403-411
const results = await Promise.all(
  reordered.map((s, i) =>
    departmentAPI.updateDepartment(s.dept_code, toUpdatePayload(s, { sort_order: (i+1)*10 }))
  )
);
```

`Promise.all`로 형제 부서 전체를 동시 PUT 호출한다. 일부 요청 실패 시:

- `results.find((r) => !r.success)` 로 첫 번째 실패를 감지하고 toast를 띄우지만 (line 412-413)
- 이미 성공한 요청의 `sort_order` 변경은 DB에 반영됨
- `loadDepartments()` 재호출도 catch 블록에서는 실행되지 않음 (line 414)

결과: 형제 5개 중 3번째가 실패하면 1, 2번 sort_order는 변경, 3, 4, 5번은 미변경인 불일치 상태가 DB에 영구 잔존. 게다가 실패 후 화면은 재로드 없이 이전 상태를 유지하므로 **UI와 DB가 불일치**.

---

### 6. 검색 + 트리 broken tree — ✅ 진짜 버그

**판정**: ✅ 진짜 버그 (자식만 매칭 시 트리에서 사라짐)

```
page.tsx:231-239 — filteredDepts: 이름/코드 단순 includes 필터
page.tsx:241-246 — childrenOf: filteredDepts 기준으로 자식 조회
page.tsx:1064   — DeptTree 내부: sub = allDepts.filter(...)  ← allDepts는 filteredDepts
```

**시나리오**: 부서 구조가 `경영지원본부 > 인사팀 > 채용파트` 일 때 "채용"으로 검색하면:

- `filteredDepts` = [`채용파트`] (부모 2개는 미포함)
- `childrenOf(null)` = `filteredDepts`에서 `parent_dept_code === null` 인 것 → 없음
- 트리에 아무것도 표시 안 됨

반대 시나리오: "경영"으로 검색하면 `filteredDepts` = [`경영지원본부`], `childrenOf(null)` = [`경영지원본부`] → 렌더됨. `DeptTree` 내부의 `sub = allDepts.filter(d => d.parent_dept_code === dept.dept_code)` 에서 `allDepts`는 `filteredDepts`이므로 `인사팀`, `채용파트`는 없음. 펼쳐도 자식 없음으로 표시.

검색 키워드가 있을 때는 트리 구조가 무너져 매칭 결과를 찾을 수 없는 케이스가 다수 발생.

---

### 7. handleSave new mode — ⚠️ 잠재 위험

**판정**: ⚠️ 잠재 위험

```
page.tsx:477
const res = await departmentAPI.createDepartment(selectedCompanyCode, payload);
```

`selectedCompanyCode`는 클로저로 캡처된 현재 state값이다. 사용자가:

1. 회사 A 선택 → "+ 추가" 클릭 → 신규 입력 시작
2. 회사 셀렉트를 회사 B로 변경 (draft는 그대로 우측에 남아있음 — 가설 9)
3. "저장" 클릭

→ `draft.company_code`는 A이지만, `createDepartment(selectedCompanyCode=B, ...)` 로 **회사 B에 부서가 생성됨**. `payload`에 `company_code` 필드는 없으므로 서버가 URL path의 company_code를 사용하면 B 소속이 됨.

`draft.company_code`와 `selectedCompanyCode`가 분리된 것 자체가 근본 원인.

---

### 8. Bulk register — ✅ 진짜 버그 + ⚠️ 잠재 위험

**판정**: ✅ start_date 강제 삽입 (진짜 버그) + ⚠️ 직렬 성능 (잠재 위험)

**start_date 강제 삽입** (진짜 버그):
```
page.tsx:968
...emptyDraft(selectedCompanyCode),  ← start_date=today 강제 포함
dept_code,
dept_name,
```
가설 4와 동일. 일괄등록된 모든 부서에 `start_date=오늘` 이 박힌다.

**직렬 호출 성능** (잠재 위험):
```
page.tsx:959-979
for (const line of lines) {
  await departmentAPI.createDepartment(...)  // 직렬
}
```
100건 입력 시 API 평균 300ms 가정 → 30초 소요. `setBulkUploading(true)` 후 대기하지만 타임아웃 처리가 없어 네트워크 불량 환경에서 UI가 장시간 블로킹됨. `Promise.allSettled` 병렬화로 해결 가능하나 현재는 누락.

---

### 9. 회사 변경 시 selected/draft 초기화 누락 — ✅ 진짜 버그

**판정**: ✅ 진짜 버그

```
page.tsx:214-216
useEffect(() => {
  loadDepartments();  // 트리는 재로드
}, [loadDepartments]);
// selectedCode, draft, isNewMode 초기화 없음
```

`selectedCompanyCode`가 바뀌면 `loadDepartments`가 새 회사 부서 목록을 로드하지만, 우측 패널의 `selectedCode`, `draft`, `isNewMode`, `originalDraft`는 이전 회사 값 그대로 남는다.

재현:
1. 회사 A에서 `DEPT001` 선택 → 우측에 상세 표시
2. 회사 셀렉트를 회사 B로 변경
3. 우측 패널에 여전히 회사 A의 `DEPT001` 정보 표시
4. "저장" 클릭 시 회사 B context에서 `DEPT001` PUT 요청 발생

트리가 회사 B 부서를 보여주는 동안 상세 패널은 회사 A 데이터를 편집하는 모순 상태.

---

### 10. dept_code 입력 검증 부재 — ⚠️ 잠재 위험

**판정**: ⚠️ 잠재 위험 (UX 혼란, 서버 silent override)

```
page.tsx:1257-1263
<Input
  value={draft.dept_code}
  onChange={(e) => update("dept_code", e.target.value)}
  placeholder="저장 시 자동 부여 (DEPT_n)"
  readOnly={!!draft.dept_code}   ← 신규 시 draft.dept_code=""이므로 편집 가능
/>
```

신규 입력 시 `draft.dept_code`가 빈 문자열이므로 `readOnly={false}` 상태로 사용자가 임의 입력 가능. 그러나 프론트에는 `^[A-Za-z0-9_]+$` 패턴 검증이 없다. 백엔드가 패턴 불일치 시 자동 코드로 폴백한다면 사용자가 입력한 코드와 실제 저장된 코드가 달라지는 silent override 발생.

부수 문제: 신규 모드에서 사용자가 `dept_code`에 값을 직접 입력하면 `readOnly={!!draft.dept_code}`에 의해 즉시 readonly가 되어 수정이 불가능해진다 (onBlur 없이 onChange로 바로 잠김).

---

### 11. members 탭 effect — ✅ 진짜 버그

**판정**: ✅ 진짜 버그 (stale data 가능)

```
page.tsx:219-228
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]);
```

AbortController가 없다. 재현 시나리오:

1. 부서 A 선택 → "부서원 정보" 탭 클릭 → API 호출 시작 (느린 네트워크)
2. 트리에서 부서 B 클릭 → selectedCode 변경 → effect 재실행 → B 멤버 API 호출
3. B 응답 먼저 도착 → `setMembers(B 멤버)`
4. A 응답 나중 도착 → `setMembers(A 멤버)` 로 덮어씀
5. 화면에는 부서 B가 선택된 상태이지만 A의 멤버 목록이 표시됨

cleanup 함수에서 `let cancelled = true` 플래그 또는 `AbortController` 로 이전 fetch를 무효화해야 한다.

---

### 12. originalDraft 동일 ref 공유 — ❌ 오탐

**판정**: ❌ 오탐 (현재 코드에서 실제 문제 없음)

```
page.tsx:287-288
setDraft(loaded);
setOriginalDraft(loaded);
```

`loaded`는 `handleSelectDepartment` 내에서 `{...emptyDraft(...), ...}` 객체 리터럴로 생성된 새 객체이므로 `setDraft`와 `setOriginalDraft`가 같은 ref를 공유해도, React의 `setDraft((prev) => ({...prev, [key]: value}))` 패턴 (line 1244)이 spread로 새 객체를 만들기 때문에 `originalDraft`를 mutate하지 않는다. 현재 코드 기준으로 실제 문제 없음.

---

## 추가 발견 버그

### B1. DeptTree — sub 필터가 sort 없음 ⚠️

```
page.tsx:1064
const sub = allDepts.filter((d) => d.parent_dept_code === dept.dept_code);
```

`DeptTree` 컴포넌트 내부의 `sub` 계산에는 `sort_order` 정렬이 없다. 루트 레벨은 `childrenOf(null)` (line 245)에서 sort가 적용되지만, 2단계 이하 자식들은 `allDepts.filter`로 순서 보장 없이 렌더된다. `handleMove`로 sort_order를 변경해도 2단계 이하는 화면에 반영되지 않음.

### B2. handleMove — 삭제된 부서가 siblings에 포함될 수 있음 ⚠️

```
page.tsx:381-382
.filter((d) => ... && !(d as any).deleted_at)
```

`loadDepartments`에서 `showDeleted=true`일 때 삭제된 부서도 `departments`에 포함된다. `handleMove`는 `!(d as any).deleted_at`로 필터하므로 siblings에서 제외하는 의도는 맞다. 그러나 `sort_order` normalize 결과가 삭제된 부서의 기존 sort_order와 충돌할 수 있다. 낮은 심각도.

### B3. bulkOpen 취소 시 진행 중 요청 취소 불가 ✅

```
page.tsx:986
setBulkOpen(false);
```

`bulkUploading` 중에 다이얼로그 외부 클릭(onOpenChange)으로 닫으면 `setBulkOpen(false)`로 UI는 닫히지만 `for...of` 루프는 계속 실행된다. 완료 후 `loadDepartments()`가 호출되는데 이 시점에 이미 사용자가 다른 작업을 하고 있으면 예상치 못한 트리 재로드가 발생.

```
page.tsx:929 — <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
```
`setBulkUploading` 중에는 다이얼로그를 닫지 못하도록 `onOpenChange={(o) => !bulkUploading && setBulkOpen(o)}` 가 필요.

---

## 요약 테이블

| # | 가설 | 판정 | 심각도 | 라인 |
|---|------|------|--------|------|
| 1 | Race condition (loadDepartments) | ⚠️ 잠재 위험 | 낮음 | 199-216 |
| 2 | isDirty false dirty (타입 불일치) | ⚠️ 잠재 위험 | 낮음 | 553-555 |
| 3 | Draft 손실 (트리 클릭 / 회사 변경) | ✅ 진짜 버그 | **높음** | 263-288 |
| 4 | start_date=today 강제 저장 | ✅ 진짜 버그 | 중간 | 113 |
| 5 | handleMove 부분 업데이트 | ✅ 진짜 버그 | **높음** | 403-417 |
| 6 | 검색 broken tree | ✅ 진짜 버그 | **높음** | 231-246 |
| 7 | handleSave new mode 회사 불일치 | ⚠️ 잠재 위험 | 중간 | 477 |
| 8 | Bulk start_date 강제 / 직렬 성능 | ✅+⚠️ | 중간/낮음 | 959-979 |
| 9 | 회사 변경 시 상태 초기화 누락 | ✅ 진짜 버그 | **높음** | 214-216 |
| 10 | dept_code 검증 부재 / 즉시 readonly | ⚠️ 잠재 위험 | 낮음 | 1257-1263 |
| 11 | members 탭 AbortController 누락 | ✅ 진짜 버그 | 중간 | 219-228 |
| 12 | originalDraft 동일 ref | ❌ 오탐 | 없음 | 287-288 |
| B1 | DeptTree sub 정렬 없음 | ⚠️ 잠재 위험 | 낮음 | 1064 |
| B2 | handleMove 삭제 부서 충돌 | ⚠️ 잠재 위험 | 낮음 | 381 |
| B3 | bulkUploading 중 다이얼로그 강제 닫힘 | ✅ 진짜 버그 | 낮음 | 929 |

**진짜 버그**: 8건 (3, 4, 5, 6, 8-start_date, 9, 11, B3)  
**잠재 위험**: 6건 (1, 2, 7, 8-성능, 10, B1, B2)  
**오탐**: 1건 (12)
