# 부서관리 백엔드 버그 헌팅 (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
