# 부서관리 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 반환
