Files
invyone/notes/johngreen/2026-05-08-부서관리-버그-정리.md
T
johngreen 9c658ffd36 docs(notes): 부서관리 버그 헌팅 분석 리포트 (frontend/backend/sql/ux)
4개 도메인 병렬 분석 결과 + 통합 요약 + 시나리오 중심 정리.
총 25개 버그 (CRITICAL 3 / HIGH 11 / MEDIUM 7 / LOW 4) 식별.
실제 수정은 별도 커밋 (commit 68c1cb5b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:08:23 +09:00

15 KiB

부서관리 페이지 버그 정리 — 시나리오 중심

작성일: 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_codeDEPT_47 (사용자가 입력한 값과 다름)
  5. 사용자는 "SALES-A" 로 만들어졌다고 착각

고치는 법:

  • 정규식 불일치 시 400 + "부서 코드는 영문/숫자/언더스코어만 가능합니다" 메시지

11. 새 부서의 "시작일" 이 항상 오늘로 강제 저장됩니다

어디서: page.tsx:113 emptyDraft, page.tsx:957 일괄등록

시나리오:

  • UI 에 시작일/종료일 입력란은 V2 로 미뤄져 hidden 처리됨 ({false && ...})
  • 그런데 emptyDraftstart_date: new Date().toISOString().slice(0,10) 으로 항상 today 입력
  • 결과: 사용자는 시작일 입력란을 본 적도 없는데 모든 신규 부서에 today 가 강제 저장됨
  • 일괄등록도 동일 — 100개 import 하면 100개 모두 import 한 날짜가 시작일

고치는 법:

  • emptyDraftstart_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

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