Merge branch 'main' into hjjeong

This commit is contained in:
hjjeong
2026-05-09 16:53:14 +09:00
12 changed files with 1833 additions and 97 deletions
@@ -113,6 +113,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
@@ -120,6 +124,8 @@ public class DepartmentController {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
@@ -149,6 +155,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
int result = departmentService.deleteDepartment(deptCode);
@@ -187,8 +197,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode);
DepartmentService.RestoreResult result;
try {
result = departmentService.restoreDepartment(deptCode);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
switch (result) {
case OK:
Map<String, Object> data = new java.util.HashMap<>();
@@ -234,8 +253,17 @@ public class DepartmentController {
@GetMapping("/companies/{companyCode}/users/search")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestParam(required = false) String search) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
}
if (search == null || search.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
}
@@ -266,10 +294,13 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id");
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
@@ -307,6 +338,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
boolean removed = departmentService.removeDeptMember(deptCode, userId);
if (!removed) {
@@ -337,9 +372,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
try {
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
// ──────────────────────────────────────────────────
@@ -81,21 +81,29 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성
String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code"));
String deptCode;
if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) {
// 중복 체크
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", requestedCode));
if (existing != null) {
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
}
deptCode = requestedCode;
} else {
// parent_dept_code cross-tenant / 존재 / 삭제 검증
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
String parentCode = parentObj != null ? parentObj.toString() : null;
validateParent(parentCode, companyCode);
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
// 재시도 로직 (race condition 대비, 최대 3회)
String deptCode = null;
for (int attempt = 0; attempt < 3; attempt++) {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
deptCode = "DEPT_" + nextNumber;
long nextNumber = codeResult != null && codeResult.get("next_number") != null
? ((Number) codeResult.get("next_number")).longValue()
: 1L;
String candidate = "DEPT_" + nextNumber;
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", candidate));
if (existing == null) {
deptCode = candidate;
break;
}
}
if (deptCode == null) {
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
}
// 부서 생성 (전체 필드)
@@ -104,7 +112,7 @@ public class DepartmentService extends BaseService {
insertParams.put("dept_name", deptName);
insertParams.put("company_code", companyCode);
insertParams.put("company_name", companyName);
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
insertParams.put("parent_dept_code", parentCode);
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
@@ -135,9 +143,34 @@ public class DepartmentService extends BaseService {
throw new IllegalArgumentException("부서명을 입력해주세요.");
}
// 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용)
Map<String, Object> existingDept = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", deptCode)
);
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
? existingDept.get("company_code").toString()
: null;
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null);
String newParentCode = newParent != null ? newParent.toString() : null;
// parent_dept_code cross-tenant / 존재 / 삭제 검증
if (deptCompanyCode != null) {
validateParent(newParentCode, deptCompanyCode);
}
verifyParentCycle(deptCode, newParentCode);
// 부서명 중복 검증 — 본인 dept_code 는 제외
if (deptCompanyCode != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", deptCompanyCode);
dupParams.put("dept_name", deptName);
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
@@ -227,6 +260,19 @@ public class DepartmentService extends BaseService {
}
}
// 동일 이름의 active 부서 중복 검증 (복구 시점)
Object companyCodeObj = dept.get("company_code");
Object deptNameObj = dept.get("dept_name");
if (companyCodeObj != null && deptNameObj != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCodeObj.toString());
dupParams.put("dept_name", deptNameObj.toString());
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
int restored = sqlSession.update("department.restoreDepartment", params);
@@ -242,6 +288,28 @@ public class DepartmentService extends BaseService {
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
}
/**
* parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증.
* null/blank 면 검증 스킵 (최상위 부서).
*/
private void validateParent(String parentCode, String companyCode) {
if (parentCode == null || parentCode.isBlank()) return;
Map<String, Object> parent = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", parentCode)
);
if (parent == null) {
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
}
if (parent.get("deleted_at") != null) {
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
}
Object parentCompany = parent.get("company_code");
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
}
}
/**
* parent_dept_code 변경 시 사이클 검증.
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
@@ -355,6 +423,15 @@ public class DepartmentService extends BaseService {
@Transactional
public void setPrimaryDept(String deptCode, String userId) {
// 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
if (existing == null) {
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
}
// 다른 부서의 주 부서 해제
Map<String, Object> clearParams = new HashMap<>();
clearParams.put("user_id", userId);
@@ -385,10 +462,13 @@ public class DepartmentService extends BaseService {
return val != null ? val : body.get(camelCase);
}
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
private Object nullIfBlank(Object value) {
if (value == null) return null;
if (value instanceof String s && s.trim().isEmpty()) return null;
if (value instanceof String s) {
String trimmed = s.trim();
return trimmed.isEmpty() ? null : trimmed;
}
return value;
}
@@ -176,6 +176,7 @@
STATUS = #{status},
LOCATION = #{location}
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</update>
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
@@ -188,12 +189,6 @@
</if>
</select>
<!-- 부서 삭제 전 user_dept 삭제 (※ Slice 2.1 이후 deleteDepartment 에서는 사용 안 함 — 멤버 보존) -->
<delete id="deleteUserDeptByDeptCode" parameterType="map">
DELETE FROM USER_DEPT
WHERE DEPT_CODE = #{dept_code}
</delete>
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
@@ -223,7 +218,7 @@
D.DEPT_NAME,
UD.IS_PRIMARY
FROM USER_DEPT UD
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
WHERE UD.DEPT_CODE = #{dept_code}
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
@@ -240,8 +235,8 @@
FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND (
USER_ID ILIKE #{search}
OR USER_NAME ILIKE #{search}
USER_ID ILIKE #{search} ESCAPE '\'
OR USER_NAME ILIKE #{search} ESCAPE '\'
)
ORDER BY USER_NAME
LIMIT 20
+72
View File
@@ -0,0 +1,72 @@
# 085 마이그레이션 — 부서관리 데이터 무결성 제약 추가
작성일: 2026-05-08
작성자: johngreen
관련: 부서관리 페이지 버그 수정 (notes/johngreen/2026-05-08-부서관리-버그-정리.md)
## 목적
부서명/부서원 중복을 DB 레벨에서 방어. race condition 시에도 데이터 무결성 보장.
## 추가 제약
| 제약 | 대상 | 용도 |
|---|---|---|
| `idx_dept_info_company_name_unique` | DEPT_INFO `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL` | 회사 내 동일 이름 active 부서 중복 방지 |
| `idx_user_dept_user_dept_unique` | USER_DEPT `(USER_ID, DEPT_CODE)` | 동일 사용자가 같은 부서 중복 등록 방지 |
(USER_DEPT 에 이미 PK 또는 UNIQUE 가 있으면 IF NOT EXISTS 로 안전 적용)
## SQL
```sql
-- 085: 부서관리 무결성 제약
CREATE UNIQUE INDEX IF NOT EXISTS idx_dept_info_company_name_unique
ON DEPT_INFO (COMPANY_CODE, LOWER(TRIM(DEPT_NAME)))
WHERE DELETED_AT IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_dept_user_dept_unique
ON USER_DEPT (USER_ID, DEPT_CODE);
```
## 실행
각 테넌트 DB 에 적용 필요. 운영 DB 와 모든 회사 DB 에 한 번씩.
```bash
# 메타 DB
psql -h <host> -U postgres -d invyone -f RUN_085.sql
# 각 테넌트 DB (예시)
for db in $(psql -tA -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
psql -h <host> -U postgres -d "$db" -f RUN_085.sql
done
```
## 사전 점검
기존 데이터에 중복이 있으면 인덱스 생성이 실패합니다. 사전 확인:
```sql
-- 부서명 중복 확인
SELECT COMPANY_CODE, LOWER(TRIM(DEPT_NAME)), COUNT(*)
FROM DEPT_INFO
WHERE DELETED_AT IS NULL
GROUP BY COMPANY_CODE, LOWER(TRIM(DEPT_NAME))
HAVING COUNT(*) > 1;
-- USER_DEPT 중복 확인
SELECT USER_ID, DEPT_CODE, COUNT(*)
FROM USER_DEPT
GROUP BY USER_ID, DEPT_CODE
HAVING COUNT(*) > 1;
```
중복이 있다면 수동 정리 후 인덱스 생성.
## 롤백
```sql
DROP INDEX IF EXISTS idx_dept_info_company_name_unique;
DROP INDEX IF EXISTS idx_user_dept_user_dept_unique;
```
@@ -110,7 +110,7 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
zipcode: "",
address1: "",
address2: "",
start_date: new Date().toISOString().slice(0, 10),
start_date: "",
end_date: "",
sort_order: 10,
});
@@ -143,12 +143,13 @@ export default function DeptMngListPage() {
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkUploading, setBulkUploading] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
@@ -221,21 +222,40 @@ export default function DeptMngListPage() {
setMembers([]);
return;
}
let cancelled = false;
(async () => {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (cancelled) return;
if (res.success && (res as any).data) setMembers((res as any).data);
})();
return () => {
cancelled = true;
};
}, [activeTab, selectedCode, isNewMode]);
// ── 트리 구성 ────────────────────────────────────────
const filteredDepts = useMemo(() => {
if (!searchKeyword.trim()) return departments;
const kw = searchKeyword.toLowerCase();
return departments.filter(
(d) =>
d.dept_name?.toLowerCase().includes(kw) ||
d.dept_code?.toLowerCase().includes(kw),
// 1차: 직접 매칭된 부서들
const directMatches = new Set(
departments
.filter((d) => d.dept_name?.toLowerCase().includes(kw) || d.dept_code?.toLowerCase().includes(kw))
.map((d) => d.dept_code),
);
// 2차: 매칭된 노드의 모든 조상도 포함 (트리 구조 유지)
const visible = new Set<string>(directMatches);
const byCode = new Map(departments.map((d) => [d.dept_code, d]));
for (const code of directMatches) {
let cur: string | null | undefined = byCode.get(code)?.parent_dept_code ?? null;
const visited = new Set<string>([code]);
while (cur && !visited.has(cur)) {
visited.add(cur);
visible.add(cur);
cur = byCode.get(cur)?.parent_dept_code ?? null;
}
}
return departments.filter((d) => visible.has(d.dept_code));
}, [departments, searchKeyword]);
const childrenOf = useCallback(
@@ -247,7 +267,7 @@ export default function DeptMngListPage() {
);
const expandAll = () => {
setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
setExpandedSet(new Set(departments.map((d) => d.dept_code)));
};
const collapseAll = () => setExpandedSet(new Set());
@@ -414,6 +434,7 @@ export default function DeptMngListPage() {
await loadDepartments();
} catch (err: any) {
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화
}
};
@@ -501,15 +522,16 @@ export default function DeptMngListPage() {
// ── 삭제 ─────────────────────────────────────────────
const handleDelete = async () => {
if (!selectedCode) return;
const target = pendingDeleteDept;
if (!target) return;
try {
const res = await departmentAPI.deleteDepartment(selectedCode);
const res = await departmentAPI.deleteDepartment(target.code);
if (res.success) {
const softDeleted = (res as any).data?.soft_deleted;
toast({
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
description: softDeleted
? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
: undefined,
});
await loadDepartments();
@@ -519,6 +541,7 @@ export default function DeptMngListPage() {
}
} finally {
setDeleteConfirmOpen(false);
setPendingDeleteDept(null);
}
};
@@ -594,14 +617,9 @@ export default function DeptMngListPage() {
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCode) {
toast({ title: "부서를 먼저 선택하세요", variant: "destructive" });
return;
}
setHistoryOpen(true);
}}
className="h-8 gap-1.5 text-xs opacity-60"
disabled
title="변경이력 기능은 준비 중입니다"
>
<History className="h-3.5 w-3.5" />
@@ -655,7 +673,16 @@ export default function DeptMngListPage() {
</div>
)}
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode}>
<Select
value={selectedCompanyCode}
onValueChange={(v) => {
setSelectedCompanyCode(v);
setSelectedCode(null);
setIsNewMode(false);
setDraft(emptyDraft(v));
setOriginalDraft(null);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
@@ -799,7 +826,12 @@ export default function DeptMngListPage() {
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setDeleteConfirmOpen(true)}
onClick={() => {
if (selectedCode) {
setPendingDeleteDept({ code: selectedCode, name: draft.dept_name });
setDeleteConfirmOpen(true);
}
}}
disabled={isNewMode}
>
@@ -808,7 +840,10 @@ export default function DeptMngListPage() {
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={handleClearDetail}
onClick={() => {
if (isDirty && !window.confirm("저장되지 않은 변경사항이 있습니다. 폐기하시겠습니까?")) return;
handleClearDetail();
}}
title="상세 닫기"
>
<X className="h-4 w-4" />
@@ -858,7 +893,18 @@ export default function DeptMngListPage() {
}
/>
) : (
<MembersPanel members={members} />
<MembersPanel
members={members}
deptCode={selectedCode!}
companyCode={selectedCompany?.company_code ?? ""}
onChanged={async () => {
if (selectedCode) {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (res.success && (res as any).data) setMembers((res as any).data);
}
await loadDepartments();
}}
/>
)}
</div>
</>
@@ -878,17 +924,23 @@ export default function DeptMngListPage() {
</div>
{/* 삭제 확인 */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<Dialog
open={deleteConfirmOpen}
onOpenChange={(o) => {
setDeleteConfirmOpen(o);
if (!o) setPendingDeleteDept(null);
}}
>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<p className="text-sm">
<span className="font-semibold">{draft.dept_name}</span> ?
<span className="font-semibold">{pendingDeleteDept?.name ?? draft.dept_name}</span> ?
</p>
<p className="text-xs text-muted-foreground"> . ( '삭제 보기' ) .</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}></Button>
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setPendingDeleteDept(null); }}></Button>
<Button variant="destructive" onClick={handleDelete}></Button>
</DialogFooter>
</DialogContent>
@@ -915,6 +967,7 @@ export default function DeptMngListPage() {
<DepartmentPicker
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
mode="single"
allowRoot
value={moveTargetDept?.parent_dept_code || ""}
open={!!moveTargetDept}
onSelect={(code) =>
@@ -935,14 +988,15 @@ export default function DeptMngListPage() {
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1.5 font-semibold">CSV </p>
<p className="text-muted-foreground">
: <code className="rounded bg-background px-1 py-0.5 font-mono">,,,(dept|team|temp)</code>
: <code className="rounded bg-background px-1 py-0.5 font-mono">,,(dept|team|temp)</code>
</p>
<p className="mt-1 text-muted-foreground">: <code className="rounded bg-background px-1 py-0.5 font-mono">D001,,,dept</code></p>
<p className="mt-1 text-muted-foreground"> (DEPT_n).</p>
<p className="mt-1 text-muted-foreground">: <code className="rounded bg-background px-1 py-0.5 font-mono">,,dept</code></p>
</div>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder={"D001,경영지원본부,,dept\nD002,인사팀,D001,team"}
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
@@ -954,36 +1008,39 @@ export default function DeptMngListPage() {
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
setBulkUploading(true);
const failures: { line: number; deptName: string; reason: string }[] = [];
let success = 0;
let failed = 0;
for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cols = line.split(",").map((c) => c.trim());
const [dept_code, dept_name, parent, dept_type] = cols;
if (!dept_code || !dept_name) {
failed++;
const [dept_name, parent, dept_type] = cols;
if (!dept_name) {
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
continue;
}
try {
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
...emptyDraft(selectedCompanyCode),
dept_code,
dept_name,
parent_dept_code: parent || null,
dept_type: (dept_type || "dept") as any,
} as any);
if (res.success) success++;
else failed++;
} catch {
failed++;
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
} catch (e: any) {
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
}
}
setBulkUploading(false);
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failed}`,
variant: failed > 0 ? "destructive" : "default",
description: `성공 ${success}건 / 실패 ${failures.length}`,
variant: failures.length > 0 ? "destructive" : "default",
});
setBulkOpen(false);
if (failures.length > 0) {
setBulkFailures(failures);
} else {
setBulkOpen(false);
}
await loadDepartments();
}}
>
@@ -993,38 +1050,38 @@ export default function DeptMngListPage() {
</DialogContent>
</Dialog>
{/* 변경이력 */}
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
<DialogContent className="max-w-[720px]">
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle>
{selectedCode && <span className="text-muted-foreground text-sm font-normal">- {draft.dept_name} ({selectedCode})</span>}
</DialogTitle>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"> </th>
<th className="px-3 py-2 text-left font-semibold"> </th>
<th className="px-3 py-2 text-left font-semibold w-16"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td colSpan={4} className="px-3 py-12 text-center text-muted-foreground">
, .
</td>
</tr>
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setHistoryOpen(false)}></Button>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1254,13 +1311,11 @@ function BasicInfoForm({
</Row>
<Row label="부서코드">
<Input
value={draft.dept_code}
onChange={(e) => update("dept_code", e.target.value)}
placeholder="저장 시 자동 부여 (DEPT_n)"
className="h-8 text-sm"
readOnly={!!draft.dept_code}
/>
{draft.dept_code ? (
<span className="font-mono text-sm">{draft.dept_code}</span>
) : (
<span className="text-muted-foreground text-sm"> (DEPT_n)</span>
)}
</Row>
<Row label="부서유형">
@@ -1463,14 +1518,195 @@ function PickerField({
);
}
// ───────────────────────────────────────────────────────
// 사용자 검색 모달
// ───────────────────────────────────────────────────────
function UserSearchModal({
open,
companyCode,
existingMemberIds,
onAdd,
onClose,
}: {
open: boolean;
companyCode: string;
existingMemberIds: Set<string>;
onAdd: (userId: string) => Promise<void>;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Record<string, any>[]>([]);
const [searching, setSearching] = useState(false);
const [addingId, setAddingId] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
}
}, [open]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setSearching(true);
try {
const res = await departmentAPI.searchUsers(companyCode, query.trim());
if (res.success && (res as any).data) setResults((res as any).data);
else setResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, companyCode]);
const handleAdd = async (userId: string) => {
setAddingId(userId);
try {
await onAdd(userId);
onClose();
} finally {
setAddingId(null);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="이름 또는 아이디로 검색..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
<div className="max-h-72 overflow-y-auto rounded-md border bg-card">
{searching ? (
<div className="py-8 text-center text-xs text-muted-foreground"> ...</div>
) : results.length === 0 && query.trim() ? (
<div className="py-8 text-center text-xs text-muted-foreground"> </div>
) : results.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground"> </div>
) : (
<div className="divide-y">
{results.map((u) => {
const alreadyMember = existingMemberIds.has(u.user_id);
return (
<div
key={u.user_id}
className={cn(
"flex items-center justify-between px-4 py-2.5",
alreadyMember ? "opacity-50" : "cursor-pointer hover:bg-muted/50",
)}
onClick={() => !alreadyMember && !addingId && handleAdd(u.user_id)}
>
<div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{u.user_name}</span>
<span className="text-xs text-muted-foreground">({u.user_id})</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
{u.position_name && <span>{u.position_name}</span>}
{u.email && <span>{u.email}</span>}
</div>
</div>
{alreadyMember ? (
<span className="text-[11px] text-muted-foreground"> </span>
) : (
<Button
size="sm"
className="h-6 px-2 text-[11px]"
disabled={addingId === u.user_id}
onClick={(e) => {
e.stopPropagation();
handleAdd(u.user_id);
}}
>
</Button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ───────────────────────────────────────────────────────
// 부서원 패널
// ───────────────────────────────────────────────────────
function MembersPanel({ members }: { members: DepartmentMember[] }) {
function MembersPanel({
members,
deptCode,
companyCode,
onChanged,
}: {
members: DepartmentMember[];
deptCode: string;
companyCode: string;
onChanged: () => Promise<void>;
}) {
const { toast } = useToast();
const [addModalOpen, setAddModalOpen] = useState(false);
const existingMemberIds = useMemo(() => new Set(members.map((m) => m.user_id)), [members]);
const handleAdd = async (userId: string) => {
const res = await departmentAPI.addDepartmentMember(deptCode, userId);
if (res.success) {
toast({ title: "부서원이 추가되었습니다" });
await onChanged();
} else {
toast({ title: "추가 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
const handleRemove = async (userId: string, userName: string) => {
if (!window.confirm(`${userName}을(를) 부서에서 제거하시겠습니까?`)) return;
const res = await departmentAPI.removeDepartmentMember(deptCode, userId);
if (res.success) {
toast({ title: "부서원이 제거되었습니다" });
await onChanged();
} else {
toast({ title: "제거 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
const handleSetPrimary = async (userId: string) => {
const res = await departmentAPI.setPrimaryDepartment(deptCode, userId);
if (res.success) {
toast({ title: "주부서로 설정되었습니다" });
await onChanged();
} else {
toast({ title: "주부서 설정 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {members.length}</div>
<Button size="sm" className="h-7 gap-1.5 text-xs" onClick={() => setAddModalOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="divide-y rounded-md border bg-card">
{members.length === 0 ? (
@@ -1494,10 +1730,41 @@ function MembersPanel({ members }: { members: DepartmentMember[] }) {
{m.phone && <span>{m.phone}</span>}
</div>
</div>
<div className="flex items-center gap-1">
{!m.is_primary && (
<Button
size="sm"
variant="outline"
className="h-6 gap-1 px-2 text-[11px]"
onClick={() => handleSetPrimary(m.user_id)}
title="주부서로 설정"
>
<Star className="h-3 w-3" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(m.user_id, m.user_name)}
title="부서원 제거"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
<UserSearchModal
open={addModalOpen}
companyCode={companyCode}
existingMemberIds={existingMemberIds}
onAdd={handleAdd}
onClose={() => setAddModalOpen(false)}
/>
</div>
);
}
@@ -50,6 +50,8 @@ export interface DepartmentPickerProps {
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
allowRoot?: boolean;
}
export function DepartmentPicker({
@@ -62,6 +64,7 @@ export function DepartmentPicker({
excludeCodes,
includeDeleted = false,
title = "부서 선택",
allowRoot = false,
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -307,6 +310,18 @@ export function DepartmentPicker({
{/* 트리 */}
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
{mode === "single" && allowRoot && !isLoading && (
<button
type="button"
onClick={() => {
onSelect("");
onClose();
}}
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
>
📂 ( )
</button>
)}
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (
@@ -0,0 +1,365 @@
# 부서관리 페이지 버그 정리 — 시나리오 중심
작성일: 2026-05-08
목적: 사용자/개발자가 "어떤 일이 벌어지는지" 바로 이해할 수 있게 정리
---
## 🔴 진짜 위험한 것 — 즉시 수정
### 1. 다른 회사 사용자 정보가 그냥 보입니다 🚨
**어디서**: `DepartmentController.java:234` `searchUsers`
**무슨 일이 벌어지나**:
- 부서원 추가 시 사용자 검색 API 가 있음
- 그런데 이 API 는 "당신이 어느 회사 사람인지" 검사를 안 함
- 즉, A 회사 직원이 URL 에 B 회사 코드만 박으면 → B 회사 직원 명단을 그대로 받아옴
**왜 심각한가**:
- 멀티테넌시 (회사간 격리) 의 핵심 원칙이 깨짐
- 다른 모든 부서 API 는 회사 격리 검사를 하는데 이거 하나만 빠짐
- 또한 일반 USER 도 admin 검사 없이 호출 가능
**고치는 법**:
- 컨트롤러에 `if (!isSuperAdmin(...) && !userCompanyCode.equals(companyCode)) return 403;` 추가
- admin role 체크도 추가
---
### 2. 사용자의 "주 부서" 가 통째로 사라질 수 있습니다 🚨
**어디서**: `DepartmentService.java:356-370` `setPrimaryDept`
**무슨 일이 벌어지나**:
1. 김철수 씨가 영업부 소속 (영업부 = 주 부서)
2. 누군가 "김철수를 마케팅부의 주 부서로 설정" API 를 호출 (그런데 김철수는 마케팅부 소속이 아님)
3. 코드는 "일단 김철수의 모든 주 부서 표시 해제" 부터 실행 → 영업부 주 부서 표시 사라짐
4. 그 다음 "마케팅부를 주 부서로 설정" 시도 → 김철수가 마케팅부 소속이 아니므로 0 row 변경 (조용히 실패)
5. 결과: 김철수는 어디에도 주 부서가 없는 사용자가 됨
**왜 심각한가**:
- 데이터 무결성 (모든 사용자는 주 부서 1개) 이 깨진 채 commit
- 에러도 안 남
- 결재 흐름 등에서 주 부서 기준 로직이 망가짐
**고치는 법**:
- API 진입 시 `selectExistingMember(userId, deptCode)` 로 멤버십 검증
- 멤버 아니면 400 반환
- `setUserPrimaryDept` 가 0 row affected 면 예외 던져서 트랜잭션 롤백
---
### 3. 다른 회사의 부서를 부모로 지정할 수 있습니다 🚨
**어디서**: `DepartmentService.java:107` (생성), `:251` `verifyParentCycle` (수정)
**무슨 일이 벌어지나**:
- 부서 생성/수정 시 `parent_dept_code` 를 받음
- 이 코드가 (a) 실제 존재하는지, (b) 같은 회사인지, (c) 삭제된 건 아닌지 **전혀 검사 안 함**
- 사이클 검사 (`verifyParentCycle`) 는 있지만 "부모가 같은 회사인지" 검증은 빠져있음
**왜 심각한가**:
- SUPER_ADMIN 또는 컨트롤러 우회 경로로 다른 회사 부서를 부모로 지정 가능
- 회사간 데이터가 트리로 엮여 멀티테넌시 침해
**고치는 법**:
- create/update 양쪽에서 parent 부서 조회 → company_code 일치 검증 + deleted_at IS NULL 검증
---
## 🟠 사용자가 자주 마주칠 버그
### 4. 회사를 바꿔도 우측 상세 패널에 이전 회사 부서가 그대로 남습니다
**어디서**: `page.tsx:658` 회사 Select 의 `onValueChange`
**시나리오**:
1. 회사 A 의 "영업부" 클릭 → 우측에 영업부 정보
2. 좌측 상단에서 회사 B 로 변경
3. 좌측 트리는 회사 B 부서로 갱신됨
4. **그런데 우측 상세 패널은 여전히 영업부 (회사 A) 정보 표시**
5. 사용자가 모르고 저장 버튼 누르면 회사 코드 mismatch 로 이상한 일이 벌어짐
**고치는 법**:
- 회사 변경 시 `handleClearDetail()` 호출하여 상세 패널 초기화
---
### 5. 부서 정렬 변경 (한 칸 위/아래) 이 부분 실패할 수 있습니다
**어디서**: `page.tsx:403` `handleMove`
**시나리오**:
1. 형제 부서 5개 정렬 변경 시 코드는 5번의 PUT 요청을 동시에 보냄 (`Promise.all`)
2. 그 중 1개가 실패하면 → 4개는 이미 DB 에 반영됨
3. 코드는 toast 만 띄우고 화면 새로고침은 **안 함** (catch 블록에 `loadDepartments()` 없음)
4. 화면은 이전 정렬, DB 는 부분 업데이트된 상태로 영구 불일치
**고치는 법**:
- catch 블록에서도 `loadDepartments()` 호출하여 DB 상태로 동기화
- 더 좋은 방법: 백엔드에 "정렬 일괄 업데이트" API 만들어 트랜잭션 보장
---
### 6. 검색하면 트리가 망가집니다
**어디서**: `page.tsx:231-246` `filteredDepts` + `childrenOf`
**시나리오**:
- "인사" 검색 → "인사팀" 부서가 매칭됨
- 그런데 인사팀의 부모인 "경영지원본부" 는 매칭 안 됨
- `childrenOf(null)` 은 매칭된 루트만 보여주는데 인사팀의 부모가 없으니 트리에 안 나타남
- **결과: 검색 결과가 있어도 트리에 아무것도 안 보일 수 있음**
**고치는 법**:
- 매칭된 노드의 부모 체인을 자동으로 결과에 포함
- 또는 검색 모드에서는 트리가 아닌 평면 리스트로 표시
---
### 7. `update` 가 삭제된 부서도 변경합니다 (silent corruption)
**어디서**: `department.xml:160-179` `updateDepartment`
**무슨 일이 벌어지나**:
1. 사용자가 휴지통의 "옛 부서" 를 수정하려 시도
2. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증 후 service 호출
3. SQL UPDATE 는 `WHERE DEPT_CODE = ?` 만 있어 삭제 여부 무관하게 update 실행
4. service 가 다음 줄에서 `selectDepartmentByCode` (DELETED_AT IS NULL) 로 재조회 → null
5. controller 가 404 반환
**결과**: 사용자는 "404 부서를 찾을 수 없음" 메시지를 보지만 **DB 는 이미 update 된 상태**. 데이터 손상.
**고치는 법**:
- SQL UPDATE WHERE 절에 `AND DELETED_AT IS NULL` 추가
- 0 row 면 service 가 null 반환 → 404 일관됨
---
### 8. 부서명을 바꿀 때 중복 검사를 안 합니다
**어디서**: `DepartmentService.java:131-170` `updateDepartment`
**시나리오**:
- 새 부서 생성 시에는 "이미 같은 이름 있어요" 체크함
- 그런데 **부서명 수정 시에는 체크 없음**
- 결과: 기존 "영업1팀" 을 "영업팀" 으로 수정 → 이미 있는 "영업팀" 과 동일 이름 두 부서 공존 가능
**같은 문제 — 복구 시에도**: `restoreDepartment` 에서도 동일. 삭제했던 부서를 살릴 때 같은 이름의 active 부서가 있어도 충돌 검사 없음.
**고치는 법**:
- update/restore 양쪽에 `selectDuplicateDeptName` 호출 추가
- 더 좋은 방법: DB 에 partial UNIQUE 인덱스 (`(company_code, lower(trim(dept_name))) WHERE deleted_at IS NULL`) 추가
---
### 9. 일반 회사 관리자가 글로벌 부서를 삭제할 수 있습니다
**어디서**: `DepartmentController.java:135` `deleteDepartment`
**시나리오**:
- 시스템 공통 부서 (company_code='*') 가 있음 (예: 시스템 관리자, 공통 부서 등)
- 어느 회사 admin 이든 이 글로벌 부서의 dept_code 를 알면 DELETE 호출 가능
- 코드의 권한 검사: `isAdmin(role)` 통과 + `canAccessDept` (글로벌이라 통과) → 삭제 진행
**고치는 법**:
- 글로벌 부서 (`company_code = '*'`) 의 write 작업은 `isSuperAdmin` 만 허용
---
### 10. 사용자가 입력한 부서코드가 조용히 무시됩니다 -> 사용자가 부서코드를 입력하게 하지말고 시스템이 동적으로 생성할수있게변경 입력값은 받지않게 수정
**어디서**: `DepartmentService.java:85-99` `createDepartment`
**시나리오**:
1. 사용자가 부서코드란에 "SALES-A" 입력 (대시 포함)
2. 백엔드 정규식은 `^[A-Za-z0-9_]+$` 만 허용 → 불일치
3. 코드는 예외를 던지지 않고 자동으로 `DEPT_47` 같은 코드 생성
4. 응답은 201 성공, 하지만 `dept_code``DEPT_47` (사용자가 입력한 값과 다름)
5. 사용자는 "SALES-A" 로 만들어졌다고 착각
**고치는 법**:
- 정규식 불일치 시 400 + "부서 코드는 영문/숫자/언더스코어만 가능합니다" 메시지
---
### 11. 새 부서의 "시작일" 이 항상 오늘로 강제 저장됩니다
**어디서**: `page.tsx:113` `emptyDraft`, `page.tsx:957` 일괄등록
**시나리오**:
- UI 에 시작일/종료일 입력란은 V2 로 미뤄져 hidden 처리됨 (`{false && ...}`)
- 그런데 `emptyDraft``start_date: new Date().toISOString().slice(0,10)` 으로 항상 today 입력
- 결과: 사용자는 시작일 입력란을 본 적도 없는데 모든 신규 부서에 today 가 강제 저장됨
- 일괄등록도 동일 — 100개 import 하면 100개 모두 import 한 날짜가 시작일
**고치는 법**:
- `emptyDraft``start_date` 기본값을 빈 문자열로 변경
- 또는 컬럼이 hidden 인 상태에서는 payload 에 포함하지 않음
---
## 🟡 알아두면 좋은 문제
### 12. 부서원 목록이 다른 부서 것으로 잠깐 보일 수 있습니다
**어디서**: `page.tsx:219-228` 멤버 fetch useEffect
**상황**: 네트워크 느린 환경에서 부서 A 클릭 → 멤버 로딩 중 → 부서 B 클릭 → A 의 응답이 늦게 도착하여 B 의 패널에 A 의 멤버 표시. fetch 취소 로직 (AbortController) 없음.
**고치는 법**: useEffect 에 cancellation flag 추가
---
### 13. 삭제 다이얼로그를 열어둔 채 다른 부서를 선택하면 잘못된 부서가 삭제됩니다
**어디서**: `page.tsx:503` `handleDelete`
**상황**:
1. 영업부 선택 → 메인 패널의 "삭제" 버튼 클릭 → 다이얼로그 열림
2. 다이얼로그 뒤로 트리에서 다른 부서 (예: 인사부) 클릭 → `selectedCode` = 인사부
3. 다이얼로그의 "삭제" 확정 → 코드는 최신 `selectedCode` 사용 → **인사부가 삭제됨**
shadcn Dialog 는 보통 포커스 트랩이 있지만 dismiss 가능한 설정이면 위험.
**고치는 법**: 다이얼로그 열 때 dept_code 를 캡처해서 클로저로 전달
---
### 14. X 버튼 누르면 미저장 변경이 경고 없이 사라집니다
**어디서**: `page.tsx:299` `handleClearDetail`
**상황**: 부서 정보 수정 중 우측 상단 X 버튼 클릭 → 즉시 폼 초기화. `isDirty` 상태는 계산되지만 사용 안 함.
**고치는 법**: `if (isDirty && !confirm("변경사항이 있습니다. 폐기하시겠습니까?")) return;`
---
### 15. 변경이력 기능이 화면만 있고 실제로는 동작 안 합니다
**어디서**: `page.tsx:997-1027`
**상황**: "변경이력" 버튼 클릭 → 다이얼로그 열림 → 항상 "데이터를 불러오는 중이거나, 등록된 이력이 없습니다." 표시. 실제 API 호출 코드 없음.
**고치는 법**: 백엔드 audit log API 와 연동. 기능 미구현 명시.
---
### 16. 부서원 추가/제거 UI 가 없습니다
**어디서**: `page.tsx:1469` `MembersPanel`
**상황**:
- 부서원 정보 탭에서 멤버 목록만 표시
- 추가/제거 버튼 없음
- 백엔드 API (`addDepartmentMember`, `removeDepartmentMember`, `searchUsers`) 는 존재하지만 UI 미연결
**고치는 법**: 멤버 추가 모달 + 행별 제거 버튼 구현
---
### 17. 일괄등록 실패 사유가 안 보입니다
**어디서**: `page.tsx:957-985`
**상황**: 100개 import → "성공 95건 / 실패 5건" 토스트만. 어떤 부서코드가 왜 실패했는지 알 수 없어 재시도 불가.
**고치는 법**: 실패 항목을 모달이나 다운로드 가능한 결과 화면으로 표시
---
## 🟢 사소한 문제 (정리/보강)
### 18. 동시에 부서 생성하면 실패할 수 있음
**어디서**: `DepartmentService.java:96` `selectNextDeptNumber`
`MAX(번호) + 1` 이 비원자적이라 두 사람이 동시에 부서 생성 시 같은 번호 → 두 번째 INSERT 가 PK 위반으로 5xx 에러. catch 로 잡지도 않아 사용자에게 raw 500 노출.
### 19. 부서 코드/이름의 앞뒤 공백이 그대로 저장됨
**어디서**: `DepartmentService.java:107`+ `nullIfBlank`
`nullIfBlank` 는 "완전히 빈 문자열만 null 로" 처리. ` " DEPT_3 "` 같은 공백 포함 코드는 그대로 저장됨. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정.
### 20. 검색 후 "전체 펼치기" 누르면 검색 해제 시 부분 펼침 상태로 남음
**어디서**: `page.tsx:249` `expandAll`
`filteredDepts` 만 펼치므로 검색 결과만 expanded. 검색 해제 후 트리가 일부만 펼쳐진 상태로 보임.
### 21. 컨트롤러에 dead code 있음
**어디서**: `DepartmentController.java:271-272`
```java
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id"); // 같은 키 두 번
```
camelCase fallback 의도였는데 오타로 같은 snake_case. → `body.get("userId")` 로 수정.
### 22. Picker 에 "최상위로 이동" 옵션이 UI 에 없음
**어디서**: `page.tsx:920` `DepartmentPicker.onSelect`
`handleConfirmMoveTo(null)` 코드 경로는 있지만 picker UI 에서 "부모 없음 (최상위)" 선택지가 없어 트리거 불가.
### 23. SQL 의 LIKE 와일드카드 미이스케이프
**어디서**: `DepartmentService.java:283` `searchUsers`
`%` + 사용자입력 + `%`. 사용자가 `_` 만 입력하면 모든 사용자 매칭. 데이터 누출은 아니지만 enumeration 가능.
### 24. dead SQL 쿼리 잔존
**어디서**: `department.xml:192-195` `deleteUserDeptByDeptCode`
주석에 "Slice 2.1 이후 사용 안 함" 명시되어 있지만 쿼리 그대로 남아있음.
### 25. 부서원 목록 INNER JOIN
**어디서**: `department.xml:213` `selectDeptMembers`
USER_INFO 에서 사용자가 삭제되면 USER_DEPT row 가 남아있어도 멤버에 안 나타남. `member_count` 와 실제 표시되는 멤버 수가 불일치 가능.
---
## 📋 수정 권장 순서
### Phase 1 — 보안/데이터 손상 (최우선)
- #1 searchUsers 회사/role 가드
- #2 setPrimaryDept 멤버십 검증
- #3 parent_dept_code 회사 격리 검증
- #9 글로벌 부서 write 권한 강화
### Phase 2 — 데이터 무결성
- #7 update SQL 의 DELETED_AT 누락 수정
- #8 update/restore 부서명 중복 검증
- #4 회사 변경 시 상세 패널 초기화
- #5 handleMove 실패 처리
### Phase 3 — 사용자 경험
- #6 검색 트리 broken 수정
- #10 dept_code silent fallback 제거
- #11 start_date 강제 저장 제거
- #12 ~ #14 race/실수 방지
### Phase 4 — 기능 완성
- #15 변경이력 API 연동
- #16 부서원 추가/제거 UI
- #17 일괄등록 실패 상세
### Phase 5 — 정리
- #18 ~ #25 race/공백/dead code/검색 정밀화
---
## 도메인별 상세 리포트
- 프론트엔드 React: `2026-05-08-부서관리-버그헌팅-frontend.md`
- 백엔드 Java: `2026-05-08-부서관리-버그헌팅-backend.md`
- SQL/Mapper: `2026-05-08-부서관리-버그헌팅-sql.md`
- UX 시나리오: `2026-05-08-부서관리-버그헌팅-ux.md`
@@ -0,0 +1,134 @@
# 부서관리 백엔드 버그 헌팅 (2026-05-08)
대상:
- `backend-spring/src/main/java/com/erp/controller/DepartmentController.java`
- `backend-spring/src/main/java/com/erp/service/DepartmentService.java`
- `backend-spring/src/main/resources/mapper/department.xml` (참고)
---
## 1. createDepartment dept_code silent fallback ❌ HIGH
`DepartmentService.java:85-99` — 사용자가 명시한 `dept_code` 가 정규식 `^[A-Za-z0-9_]+$` 위반 시 예외 없이 자동 코드 생성으로 폴백. 사용자는 201 응답을 받지만 응답의 `dept_code` 가 요청과 다름 (silent override). `IllegalArgumentException("부서 코드 형식이 올바르지 않습니다")` 던지는 게 맞음.
## 2. createDepartment parent_dept_code 검증 누락 ❌ CRITICAL — cross-tenant
`DepartmentService.java:107`, `DepartmentController.java:80-82` — parent 가 (a) 존재, (b) 같은 회사, (c) deleted 아님 검증 전혀 없음. SUPER_ADMIN 또는 controller 우회 경로로 cross-tenant 부모 지정 가능. FK 가 같은 DB 내 다른 회사 부서를 참조 가능.
## 3. updateDepartment 부서명 중복 검증 누락 ❌ HIGH
`DepartmentService.java:131-170` — create 는 `selectDuplicateDeptName` 체크하지만 update 에는 없음. UNIQUE 제약도 mapper 에 없음. 회사 내 동일 이름의 active 부서 두 개 공존 가능.
## 4. verifyParentCycle 회사 격리 미검증 ❌ HIGH
`DepartmentService.java:251-271` — cycle 만 체크. newParent.company_code 와 deptCode.company_code 비교 없음. update 흐름에서 #2 와 동일 결함 재현.
## 5. selectNextDeptNumber race condition ❌ MEDIUM
`DepartmentService.java:96-99`, `department.xml:108-111``MAX(...)+1` 비원자적. 두 요청 동시 진입 시 같은 `next_number` 읽음 → 두 번째 INSERT PK 위반으로 500. 컨트롤러 catch 절은 `DuplicateDeptNameException`/`IllegalArgumentException` 만 잡으므로 raw `DataIntegrityViolationException` 누출.
## 6. delete-restore trap (활성 자식만 체크) ⚠️ MEDIUM
`DepartmentService.java:181-202` (`include_deleted=false` 로 자식 카운트) — 활성 자식 0이면 부모 soft-delete OK. 자식 단독 복구 시도 시 `restoreDepartment` 가 부모 deleted 검사로 차단(`PARENT_DELETED`) → 부모 먼저 복구 필요. 영구 trap 은 아니지만 UX 혼선.
## 7. restoreDepartment 부서명 충돌 미검증 ❌ HIGH
`DepartmentService.java:210-239` — 시나리오: A 부서 삭제 → 같은 이름 B 부서 생성 (create 는 active 만 검사하므로 통과) → A 부서 복구 → 동일 이름 active 두 부서 공존. 100% 재현 가능.
## 8. restoreDepartment 부모 1단계 검증 ✅
`DepartmentService.java:220-228` — 시스템 invariant 상 부모 active 면 조부모도 active 보장됨 → 1단계로 충분. 외부 직접 SQL 조작 시나리오는 막지 못하지만 OK.
## 9. addDeptMember dead code ❌ LOW
`DepartmentController.java:271-272`:
```java
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id"); // 동일 키
```
camelCase fallback 의도였던 것이 작성 실수로 같은 snake_case 가 됨. → `body.get("userId")` 로 수정.
## 10. setPrimaryDept 멤버십 미검증 → DATA CORRUPTION ❌ CRITICAL
`DepartmentService.java:356-370` — 시나리오:
1. 사용자가 deptA 소속 (IS_PRIMARY=TRUE)
2. 클라이언트가 사용자가 소속되지 않은 deptB 로 PUT 호출
3. `clearUserPrimaryDept` → 사용자의 모든 USER_DEPT 행 IS_PRIMARY=FALSE (deptA primary 제거)
4. `setUserPrimaryDept` (WHERE USER_ID=? AND DEPT_CODE=deptB) → 0 rows affected
5. 결과: 어떤 부서도 primary 가 아닌 상태로 남음 (invariant 깨짐)
Service 진입 시 `selectExistingMember` 선검증 + 0 row affected 시 예외/롤백 필수.
## 11. canAccessDept — 글로벌 부서(`*`) read 허용 ⚠️ MEDIUM
`DepartmentController.java:361-367``userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode)`. 일반 USER 도 글로벌 부서 GET 가능 (의도된 듯). 단 read/write 분리 필요.
## 12. deleteDepartment — COMPANY_ADMIN 의 글로벌 부서 삭제 ❌ HIGH
`DepartmentController.java:135-165` — 임의 테넌트 COMPANY_ADMIN 이 글로벌 부서 (`'*'`) 를 DELETE 호출 → `isAdmin(role)` 통과 + `canAccessDept` 통과 (글로벌 매칭) → 삭제 진행. 글로벌 자원 write 는 SUPER_ADMIN 전용 가드 필요.
## 13. trimString 일관성 ❌ MEDIUM
`DepartmentService.java:62, 85, 133``trimString` 은 dept_name, requestedCode 에만 적용. parent_dept_code, dept_type, short_name, address1/2, zipcode 등은 `nullIfBlank` 만 거치는데 이건 빈 문자열만 null 로 바꿈 — 선행/후행 공백 보존됨. `parent_dept_code=" DEPT_3 "` 입력 시 DB 에 공백 포함 코드 저장. → `nullIfBlank` 자체를 trim+blank→null 한 번에 처리하도록 수정.
## 14. removeDeptMember 트랜잭션 race ⚠️ LOW
`DepartmentService.java:324-354` — 메서드 전체 `@Transactional` 이라 데이터 일관성 OK. 단 `selectFirstUserDept` 후 다른 트랜잭션이 그 row 도 삭제하는 race 시 `setUserPrimaryDept` 0 row affected, 예외 없음 → primary 부재 commit. 권장: row count 0 이면 재조회 또는 단일 SQL `UPDATE ... WHERE USER_ID=? AND DEPT_CODE=(SELECT ...)` 합치기.
## 15. SUPER_ADMIN 식별 — `company_code = '*'` 의미 충돌 ⚠️ MEDIUM
`DepartmentController.java:353-355``isSuperAdmin(companyCodeOrRole)``*` 또는 `SUPER_ADMIN` 둘 다 true. 일반 user 의 `company_code` 가 우연히 `*` 로 저장되면 super 권한 부여. provisioning 레이어에서 `company_code='*'` 차단 필수. 권장: role 만으로 super 판별, company_code 와 분리.
---
## 추가 발견
### A. createDepartment 중복명 race ❌ HIGH
`selectDuplicateDeptName` → INSERT 사이 동시 요청 race. DB UNIQUE 제약 없으면 중복 이름 공존 가능. (`department.xml` 에서 unique 제약 없음.)
### B. searchUsers SQL LIKE wildcard ⚠️ MEDIUM
`DepartmentService.java:283-288``params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드. `_` 단독 검색 시 모든 사용자 매칭. 데이터 누출은 아니나 enumeration 가능.
### C. searchUsers 컨트롤러 권한 검증 누락 ❌ CRITICAL — TENANT BREACH
`DepartmentController.java:234-245``userCompanyCode` 가드 **없음**. 임의 사용자가 다른 회사 코드를 path 에 넣으면 그 회사 사용자 목록 검색 가능. 다른 모든 메서드엔 가드 있는데 여기만 누락.
### D. searchUsers role 검증 누락 ❌ MEDIUM
admin 검사 없음. 일반 USER 가 회사 내 모든 사용자 (이름/아이디) 자유 검색 → 사용자 enumeration.
### E. getDeptMembers — soft-deleted 부서 멤버 노출 ⚠️ LOW/MEDIUM
`getDepartmentIncludingDeleted` 로 검증만 하고 deleted 부서 members 그대로 반환. 의도된 사양인지 확인 필요.
### F. ClassCastException 위험 ⚠️ LOW
`DepartmentService.java:81``(String) company.get("company_name")`. 컬럼 타입 변경 시 CCE. `Objects.toString(...)` 권장.
### G. setPrimaryDept 비원자성 ⚠️ LOW
`DepartmentService.java:356-370` — clear + set 두 SQL. 단일 UPDATE 합치기 가능: `UPDATE USER_DEPT SET IS_PRIMARY = (DEPT_CODE = #{dept_code}) WHERE USER_ID = #{user_id}`.
### H. nextNumber unboxing NPE ⚠️ LOW
`DepartmentService.java:97``((Number) codeResult.get("next_number")).longValue()`. 항상 non-null 보장이지만 NPE 가드 권장.
### I. 로그 PII 평문 ⚠️ LOW
`DepartmentService.java:124, 200, 237, 321, 348, 352, 369` — userId 평문 로깅. PIPA/GDPR 환경 고려.
### J. selectNextDeptNumber 회사 격리 ✅
DB-per-tenant 아키텍처상 OK. 글로벌 `*` 회사도 같은 시퀀스 공유.
---
## 우선순위 정리
| 등급 | 항목 |
|---|---|
| **CRITICAL** | #2 parent cross-tenant 무검증 / #10 setPrimaryDept data corruption / **C** searchUsers tenant breach |
| **HIGH** | #1 dept_code silent fallback / #3 update 중복명 / #4 verifyParentCycle 회사 / #7 restore 중복명 / #12 글로벌 삭제 / **A** create race / **D** searchUsers role |
| **MEDIUM** | #5 nextNumber race / #6 delete-restore trap / #11 글로벌 write 정책 / #13 trim 일관성 / #15 `*` 의미 / **B** LIKE wildcard / **E** deleted member 노출 |
| **LOW** | #9 dead code / #14 removeMember race / **F-I** |
## 즉시 수정 권장
1. `searchUsers` 컨트롤러 권한/회사 가드 추가
2. `setPrimaryDept` 선검증 + 0 rows 시 예외
3. `create/updateDepartment` parent 회사 격리·존재·미삭제 검증
4. `update/restoreDepartment``selectDuplicateDeptName` 추가
5. 글로벌 부서 (`*`) write 작업 SUPER_ADMIN 전용 가드
6. `dept_code` 형식 위반 시 silent fallback 대신 400
@@ -0,0 +1,328 @@
# 부서관리 페이지 프론트엔드 버그 헌팅 리포트
**대상**: `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)
@@ -0,0 +1,95 @@
# 부서관리 SQL/매퍼 버그 헌팅 (2026-05-08)
대상: `backend-spring/src/main/resources/mapper/department.xml`
---
## ❌ 1. updateDepartment WHERE 절에 `DELETED_AT IS NULL` 누락 — HIGH
`department.xml:160-179`
```xml
<update id="updateDepartment" parameterType="map">
UPDATE DEPT_INFO SET ... WHERE DEPT_CODE = #{dept_code}
</update>
```
소프트 삭제된 부서도 update 가 통과해버림. 컨트롤러는 `getDepartmentIncludingDeleted` 로 검증한 뒤 호출하므로 deleted 부서에 대해서도 흐름이 진행됨. 그 후 service 의 재조회 (`selectDepartmentByCode``DELETED_AT IS NULL`) 가 null 반환 → controller 가 404 응답. **DB 는 이미 update 되었는데 사용자는 404 받음 → silent corruption**.
`WHERE DEPT_CODE = #{dept_code} AND DELETED_AT IS NULL` 추가 필수. update 0 row 면 service 가 null 반환하여 404 도 일관됨.
## ⚠️ 2. selectDepartments — 글로벌 부서 모든 회사에 노출 — MEDIUM
`department.xml:29` `(D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')`
글로벌 (`*`) 부서가 모든 회사 트리에 자식으로 섞여 표시됨. PARENT_DEPT_CODE 일치까지 고려하면 어느 부모 아래로 들어갈지 비결정적. 의도된 사양인지 확인 필요. INVYONE 은 DB-per-tenant 인데 글로벌 부서가 동일 DB 에 들어가는 경우 — 멀티 테넌시 모델과 충돌.
## ✅ 3. selectChildDeptCount — 회사 격리 — OK (DB-per-tenant)
`department.xml:182-189``WHERE PARENT_DEPT_CODE = #{dept_code}` 만 있어 회사 필터 없음. 단 INVYONE 은 DB-per-tenant 라 같은 DB 안에 다른 회사 부서가 거의 없음 (글로벌 `*` 제외). 실제 위험은 낮음.
## ✅ 4. selectNextDeptNumber 회사 필터 없음 — OK (DB-per-tenant)
`department.xml:108-112` — 전역 시퀀스. DB-per-tenant 라 회사별 충돌은 발생 안 함. 단 글로벌 (`*`) 부서까지 같은 시퀀스 공유는 의도된 듯.
## ⚠️ 5. `#{date}::date` cast — MEDIUM
`department.xml:150-151, 173-174` — null 또는 valid date 형식이면 OK. 잘못된 형식 ("2026/05/08", "abc") 이면 SQLException → 500. service 가 `nullIfBlank` 만 처리하지 형식 검증 안 함. controller 도 안 함. 사용자가 비정상 날짜 입력 시 5xx 누출.
## ✅ 6. selectDuplicateDeptName 정규화 — OK
`department.xml:91-97``TRIM(LOWER(...))` 양쪽 적용. 케이스/공백 정규화 OK. 단 update 흐름에서 호출 안 됨 (backend 리포트 #3 참조).
## ⚠️ 7. selectDeptMembers INNER JOIN — LOW/MEDIUM
`department.xml:213-230``JOIN USER_INFO U ON UD.USER_ID = U.USER_ID` (INNER). 사용자가 USER_INFO 에서 삭제되었지만 USER_DEPT row 가 남으면 orphan 멤버 안 보임. member_count (selectDepartments) 는 USER_DEPT 만 카운트하므로 카운트와 실제 표시되는 멤버 수가 불일치 가능.
## ❌ 8. searchUsers ILIKE 와일드카드 미이스케이프 — MEDIUM
`department.xml:233-248`, service 의 `params.put("search", "%" + search + "%")`. 사용자 입력의 `%`/`_` 가 와일드카드로 처리됨. `_` 단독 검색 시 모든 사용자 매칭. SQL injection 은 parameterized 라 안전하지만 enumeration 가능. ESCAPE 절 또는 사전 escaping 권장.
## ⚠️ 9. deleteUserDeptByDeptCode dead query — LOW
`department.xml:192-195` — 주석에 "Slice 2.1 이후 사용 안 함" 명시. 안 쓰는 쿼리 잔존. 정리 권장.
## ✅ 10. selectCompanyName LIMIT 1 — OK (PK)
`department.xml:100-105``LIMIT 1` 만 있고 ORDER BY 없음. company_code 가 PK 면 단일 행만 매칭되므로 OK. PK 가 아니면 비결정적.
## ⚠️ 11. insertDeptMember UNIQUE 제약 의존 — MEDIUM
`department.xml:283-286` — INSERT 만. service 가 `selectExistingMember` 로 사전 체크하지만 race 시 두 트랜잭션이 동시 통과 → 두 INSERT. USER_DEPT 에 (USER_ID, DEPT_CODE) UNIQUE 제약이 있어야 race 방어. **mapper 외부 스키마 확인 필요** — 없으면 중복 row 가능.
## ⚠️ 12. selectFirstUserDept 비결정적 — LOW
`department.xml:266-272``ORDER BY CREATED_DATE ASC LIMIT 1`. 동시 INSERT 시 동일 NOW() 다중 row 가능 → 비결정적 첫 row. primary 자동 승격 시 어떤 부서가 선택될지 예측 불가. 보조 정렬 키 (예: DEPT_CODE) 추가 권장.
## ⚠️ 13. selectDepartments member_count 와 includeDeleted — LOW
`department.xml:7-39``LEFT JOIN USER_DEPT UD`. USER_DEPT 는 soft-delete 컬럼 없음. 부서가 deleted 여도 USER_DEPT row 는 보존되므로 (멤버 보존 정책) member_count 는 그대로. 휴지통 표시 시 의미 있는 카운트인지 UI 정책 확인.
## ⚠️ 14. GROUP BY 에 PARENT_DEPT_CODE 포함되었으나 필요한가 — LOW
`department.xml:33-37` — GROUP BY 에 모든 SELECT 컬럼 나열 (PostgreSQL 요구). `MEMBER_COUNT` 만 집계라 OK. 단 컬럼 추가 시 GROUP BY 도 같이 늘려야 하는 운영 부담. 향후 컬럼 추가 시 누락 위험.
## ⚠️ 15. dept_name UNIQUE 제약 없음 — HIGH (스키마 종속)
mapper 만으로는 확정 불가하지만, `selectDuplicateDeptName` 를 SQL 레벨로 보강하는 UNIQUE 제약 (예: `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL`) 이 없으면 race 시 중복 부서명 공존. backend 리포트 추가 발견 A 와 동일 결함 — DB 스키마 차원의 방어 필요.
---
## 우선순위
| 등급 | 항목 |
|---|---|
| **HIGH** | #1 update 의 DELETED_AT 누락 (silent corruption) / #15 dept_name UNIQUE 부재 |
| **MEDIUM** | #2 글로벌 부서 회사 트리 혼입 / #5 date cast 5xx / #8 LIKE 와일드카드 / #11 USER_DEPT UNIQUE |
| **LOW** | #7 INNER JOIN orphan / #9 dead query / #12 first-dept 비결정성 / #13-14 |
| **OK** | #3 #4 #6 #10 |
## 즉시 수정 권장
1. `updateDepartment` WHERE 절에 `AND DELETED_AT IS NULL` 추가
2. USER_DEPT (USER_ID, DEPT_CODE) UNIQUE 제약 확인 / 추가
3. DEPT_INFO (COMPANY_CODE, dept_name) partial UNIQUE 인덱스 (DELETED_AT IS NULL) 추가
4. searchUsers 입력값에 LIKE escaping 적용
5. service 또는 controller 에서 date 형식 검증 → 400 반환
@@ -0,0 +1,261 @@
# 부서관리 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` `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:920922`:
```ts
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:line` — `page.tsx:658` `onValueChange={setSelectedCompanyCode}` 에서 추가로 `handleClearDetail()` 호출 필요.
---
## 6. 부서원 fetch race condition ❌
- `page.tsx:219228` 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:92107` 에는 `cancelled` flag 패턴이 올바르게 적용되어 있음 — 동일 패턴이 members fetch에 없음.
- 재현: 네트워크 느린 환경에서 빠르게 부서 A → B 클릭 시 B의 멤버 대신 A의 멤버가 표시될 수 있음.
---
## 7. 신규 부서 작성 중 회사 변경 — company_code mismatch ❌
- `page.tsx:291297` `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:503523` `handleDelete`:
```ts
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 호출:
```ts
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`:
```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:299304`:
```ts
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`:
```ts
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`:
```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:179184` `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: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 조상 스타일 누락 | ⚠️ | 낮음 |
@@ -0,0 +1,81 @@
# 부서관리 버그 헌팅 통합 요약 (2026-05-08)
4개 도메인 병렬 분석 결과. 상세 리포트는 별도 파일 참조.
- [Frontend (page.tsx)](2026-05-08-부서관리-버그헌팅-frontend.md)
- [Backend (Controller/Service)](2026-05-08-부서관리-버그헌팅-backend.md)
- [SQL/Mapper](2026-05-08-부서관리-버그헌팅-sql.md)
- [UX Edge Cases](2026-05-08-부서관리-버그헌팅-ux.md)
---
## 🔴 CRITICAL (즉시 수정)
| # | 위치 | 한 줄 |
|---|---|---|
| C1 | `DepartmentService.java:356-370` | **setPrimaryDept 데이터 손상** — 사용자가 소속되지 않은 부서로 호출 시 다른 부서 primary 만 해제되고 새 primary 미설정 → 사용자가 어떤 부서도 primary 가 아닌 invariant 깨진 상태로 commit |
| C2 | `DepartmentController.java:234-245` | **searchUsers 회사 격리 누락**`userCompanyCode` 가드 없음. 임의 사용자가 다른 회사 사용자 목록 검색 가능 → 멀티테넌시 침해 |
| C3 | `DepartmentService.java:107` + `:251` | **parent_dept_code cross-tenant** — 존재/회사/삭제 검증 전혀 없음. update 의 verifyParentCycle 도 회사 격리 검증 없음. 다른 회사 부서를 부모로 지정 가능 |
## 🟠 HIGH
| # | 위치 | 한 줄 |
|---|---|---|
| H1 | `page.tsx:658` (회사 Select) | 회사 변경 시 `selectedCode`/`draft`/`isNewMode` 초기화 안 됨 → 다른 회사 부서가 우측 패널에 stale 노출 + 잘못된 회사 코드로 저장 위험 |
| H2 | `page.tsx:403` `handleMove` | `Promise.all` 로 N개 PUT — 일부 실패 시 부분 업데이트 영구 잔존 (catch 에서 loadDepartments 미호출). 트랜잭션 없음 |
| H3 | `page.tsx:231-246` 검색 필터 | 자식 매칭/부모 미매칭 시 트리 구조가 깨져 부서 안 보임 (broken tree) |
| H4 | `department.xml:160-179` | **updateDepartment 의 WHERE 에 `DELETED_AT IS NULL` 누락** — 삭제된 부서도 update 통과 후 controller 가 404 반환 → DB 는 변경되었는데 사용자는 실패로 인식 (silent corruption) |
| H5 | `DepartmentService.java:131` | **updateDepartment 부서명 중복 검증 없음** — create 에는 있지만 update 에 없어 동일 이름 active 부서 두 개 공존 가능 |
| H6 | `DepartmentService.java:210` `restoreDepartment` | 복구 시 동일 이름 active 부서 충돌 검증 없음 → 100% 재현되는 중복 이름 공존 |
| H7 | `DepartmentController.java:135` `deleteDepartment` | COMPANY_ADMIN 이 글로벌 부서 (`*`) 삭제 가능 → SUPER_ADMIN 전용 가드 필요 |
| H8 | `DepartmentService.java:85-99` `createDepartment` | 사용자 명시 dept_code 가 정규식 위반 시 silent fallback → 자동 코드로 발행되지만 사용자는 알지 못함 |
| H9 | `DepartmentController.java:234` `searchUsers` | role 검사 없음 — 일반 USER 가 회사 내 사용자 enumeration 가능 |
| H10 | `page.tsx:113` `emptyDraft` | `start_date``new Date().toISOString().slice(0,10)` 으로 고정 — UI 에서 hidden 인데도 today 가 강제 저장됨. 일괄등록도 동일 |
| H11 | DEPT_INFO 스키마 (mapper 외 확인 필요) | `(COMPANY_CODE, dept_name)` partial UNIQUE 인덱스 부재 가능성 → race 시 중복 부서명 공존 (#H5/H6 의 근본 방어선) |
## 🟡 MEDIUM
| # | 위치 | 한 줄 |
|---|---|---|
| M1 | `page.tsx:219-228` 멤버 fetch | AbortController/cancellation flag 없음 → 빠른 부서 전환 시 stale 멤버 데이터 표시 |
| M2 | `page.tsx:503` `handleDelete` | `selectedCode` 를 클로저로 캡처 안 함 → 다이얼로그 열린 상태에서 다른 부서 선택되면 엉뚱한 부서 삭제 위험 |
| M3 | `page.tsx:299` `handleClearDetail` | `isDirty` 무시. X 버튼 클릭 시 미저장 변경 즉시 폐기 |
| M4 | `page.tsx:997` 변경이력 모달 | API 호출 없음 — 항상 "이력 없음" 표시. 기능 미구현 |
| M5 | `DepartmentService.java:96` `selectNextDeptNumber` | `MAX+1` 비원자적 → 동시 생성 시 PK 충돌 5xx (catch 누락) |
| M6 | `DepartmentService.java:181` `deleteDepartment` | 활성 자식만 카운트 → 자식 단독 복구 시 부모 deleted 차단되는 UX trap |
| M7 | `DepartmentService.java:283` `searchUsers` | LIKE 와일드카드 `%`/`_` 미이스케이프 → 동작 이상 + enumeration |
| M8 | `DepartmentService.java:107`+ | `nullIfBlank` 만 적용. 선행/후행 공백 보존 → DB 에 공백 포함 코드 저장 가능 |
| M9 | `department.xml:150-174` | `#{date}::date` cast 시 잘못된 형식 → SQLException 5xx |
| M10 | DEPT_INFO `*` 회사 정책 | 글로벌 부서 read 일반 user 허용 / write 권한 정책 모호 |
| M11 | `DepartmentController.java:353` super 식별 | `company_code='*'` 우연 충돌 시 super 권한 부여 — provisioning 가드 필수 |
## 🟢 LOW
| # | 위치 | 한 줄 |
|---|---|---|
| L1 | `DepartmentController.java:271-272` | dead code (동일 키 두 번 lookup) |
| L2 | `DepartmentService.java:324` `removeDeptMember` | primary 자동 승격 race 가능성 (낮음) |
| L3 | `page.tsx:920` picker "최상위로" 선택 | UX 미구현 — `handleConfirmMoveTo(null)` 트리거 경로 없음 |
| L4 | `page.tsx:957` bulk register | 실패 부서 코드/사유 표시 없음 (성공/실패 카운트만) |
| L5 | `page.tsx:1469` 멤버 패널 | 멤버 추가/제거 UI 미구현. API 만 존재 |
| L6 | `page.tsx:249` `expandAll` + 검색 | 검색 후 expandAll → 검색 해제 시 부분 펼침 상태 |
| L7 | `department.xml:213` selectDeptMembers INNER JOIN | USER_INFO 삭제 시 orphan 멤버 표시 안 됨 |
| L8 | `department.xml:192` deleteUserDeptByDeptCode | dead query |
| L9 | `department.xml:266` selectFirstUserDept | 동일 NOW() 다중 row 시 비결정적 |
---
## 추천 수정 순서
**Phase 1 (긴급/보안)**: C1, C2, C3, H7
**Phase 2 (데이터 무결성)**: H4, H5, H6, H11, H1
**Phase 3 (UX/안정성)**: H2, H3, H8, H10, M1~M3
**Phase 4 (기능 보강)**: M4, L4, L5, L3
**Phase 5 (정리)**: L1, L7~L9, M5~M11
## 도메인별 통계
- Frontend: 진짜 8 / 잠재 6 / 오탐 1
- Backend: CRITICAL 3 + HIGH 7 + MEDIUM 7 + LOW 4
- SQL: HIGH 2 + MEDIUM 4 + LOW 4 + OK 4
- UX: 진짜 6 + 잠재 10 + OK 4