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
부서관리 페이지 프론트엔드 버그 헌팅 리포트
대상: 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 자체는 건드리지 않는다. 이 시나리오는 오탐.
재현:
- "+ 추가" 클릭 → 부서명 입력 시작
- 트리에서 다른 부서 클릭 OR 회사 셀렉트 변경
- 입력 내용 소실, 토스트/확인창 없음
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값이다. 사용자가:
- 회사 A 선택 → "+ 추가" 클릭 → 신규 입력 시작
- 회사 셀렉트를 회사 B로 변경 (draft는 그대로 우측에 남아있음 — 가설 9)
- "저장" 클릭
→ 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는 이전 회사 값 그대로 남는다.
재현:
- 회사 A에서
DEPT001선택 → 우측에 상세 표시 - 회사 셀렉트를 회사 B로 변경
- 우측 패널에 여전히 회사 A의
DEPT001정보 표시 - "저장" 클릭 시 회사 B context에서
DEPT001PUT 요청 발생
트리가 회사 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가 없다. 재현 시나리오:
- 부서 A 선택 → "부서원 정보" 탭 클릭 → API 호출 시작 (느린 네트워크)
- 트리에서 부서 B 클릭 → selectedCode 변경 → effect 재실행 → B 멤버 API 호출
- B 응답 먼저 도착 →
setMembers(B 멤버) - A 응답 나중 도착 →
setMembers(A 멤버)로 덮어씀 - 화면에는 부서 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)