Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s

부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리
탭 라벨 fallback, Windows dev HMR 복원 흡수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:51:34 +09:00
31 changed files with 3942 additions and 718 deletions
@@ -19,35 +19,47 @@ public class DepartmentController {
private final DepartmentService departmentService;
/**
* 부서 목록 조회 (회사별)
* GET /api/departments/companies/{companyCode}/departments
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode) {
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
/**
* 부서 상세 조회
* GET /api/departments/{deptCode}
* 부서 상세 조회.
* - 기본: active 부서만 (DELETED_AT IS NULL)
* - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용)
* - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체
* GET /api/departments/{deptCode}[?include_deleted=true]
*/
@GetMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDepartment(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
Map<String, Object> department = departmentService.getDepartment(deptCode);
Map<String, Object> department = includeDeleted
? departmentService.getDepartmentIncludingDeleted(deptCode)
: departmentService.getDepartment(deptCode);
if (department == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(department, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공"));
}
@@ -87,57 +99,148 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
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);
if (updated == null) {
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()));
}
}
/**
* 부서 삭제
* 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
* - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가
* - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남
* DELETE /api/departments/{deptCode}
*/
@DeleteMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Void>> deleteDepartment(
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
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 memberCount = departmentService.deleteDepartment(deptCode);
if (memberCount == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없니다."));
int result = departmentService.deleteDepartment(deptCode);
if (result == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String message = memberCount > 0
? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)"
: "부서가 삭제되었습니다.";
return ResponseEntity.ok(ApiResponse.success(null, message));
Map<String, Object> data = new java.util.HashMap<>();
data.put("soft_deleted", true);
data.put("dept_code", deptCode);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)"));
} catch (IllegalStateException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 로 되돌림
* - 부모도 deleted 상태면 차단
* POST /api/departments/{deptCode}/restore
*/
@PostMapping("/{deptCode}/restore")
public ResponseEntity<ApiResponse<Map<String, Object>>> restoreDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
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;
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<>();
data.put("dept_code", deptCode);
data.put("restored", true);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다."));
case NOT_FOUND:
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
case NOT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다."));
case PARENT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요."));
default:
return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류"));
}
}
/**
* 부서원 목록 조회
* GET /api/departments/{deptCode}/members
*/
@GetMapping("/{deptCode}/members")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode) {
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
List<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
@@ -150,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("검색어를 입력해주세요."));
}
@@ -168,15 +280,27 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> addDeptMember(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
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를 입력해주세요."));
}
@@ -200,12 +324,25 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
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) {
return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다."));
@@ -221,14 +358,31 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
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 {
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
// ──────────────────────────────────────────────────
@@ -242,4 +396,16 @@ public class DepartmentController {
private boolean isSuperAdmin(String companyCodeOrRole) {
return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole);
}
/**
* 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능.
* 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만.
*/
private boolean canAccessDept(Map<String, Object> dept, String userCompanyCode) {
if (dept == null) return false;
if (isSuperAdmin(userCompanyCode)) return true;
String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null;
if (deptCompanyCode == null) return false;
return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode);
}
}
@@ -41,7 +41,55 @@ public class StartupSchemaMigrator {
private static final List<String> MIGRATIONS = List.of(
// RUN_082: 첫 로그인 비밀번호 강제 변경 플래그
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE"
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE",
// V017: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열
// 조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹)
// 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는
// 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화.
// SEQ 만 갱신 → 멱등.
// 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것
// (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생).
"""
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
AND COMPANY_CODE = '*'
AND PARENT_OBJ_ID IS NOT NULL
AND PARENT_OBJ_ID <> '0'
AND MENU_NAME_KOR IN (
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
)
""",
// V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼.
// DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨.
// 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL",
// V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
"CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL",
// V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리.
// 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
// DROP IF EXISTS 로 멱등성 보장.
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE"
);
@EventListener(ApplicationReadyEvent.class)
@@ -6,8 +6,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
@@ -18,8 +20,14 @@ public class DepartmentService extends BaseService {
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
// member_count를 int로 변환
@@ -34,12 +42,20 @@ public class DepartmentService extends BaseService {
return departments;
}
/** active 부서만 반환. deleted 면 null. 복구 흐름은 getDepartmentIncludingDeleted 사용 */
public Map<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
}
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
}
@Transactional
public Map<String, Object> createDepartment(String companyCode, Map<String, Object> body) {
// 프론트엔드는 snake_case로 전송 (Node.js 호환)
@@ -65,10 +81,30 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 생성
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
String deptCode = "DEPT_" + nextNumber;
// 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 && 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("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
}
// 부서 생성 (전체 필드)
Map<String, Object> insertParams = new HashMap<>();
@@ -76,29 +112,21 @@ 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")));
insertParams.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
insertParams.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
insertParams.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
insertParams.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
insertParams.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
insertParams.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
insertParams.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
insertParams.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
insertParams.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
insertParams.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드 (master_*, location_*, data_type, sales_yn)
insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
insertParams.put("data_type", bodyParam(body, "data_type", "data_type"));
insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
sqlSession.insert("department.insertDepartment", insertParams);
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
@@ -115,32 +143,53 @@ 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"));
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);
params.put("dept_name", deptName);
params.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
params.put("parent_dept_code", newParent);
params.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
params.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
params.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
params.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
params.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
params.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
params.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
params.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
params.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
params.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
params.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
params.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
params.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
params.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드
params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
params.put("location", nullIfBlank(bodyParam(body, "location", "location")));
params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
params.put("data_type", bodyParam(body, "data_type", "data_type"));
params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
int updated = sqlSession.update("department.updateDepartment", params);
if (updated == 0) {
@@ -153,32 +202,140 @@ public class DepartmentService extends BaseService {
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
}
/**
* 부서 soft-delete (V1 slim scope).
* - hard delete 가 아니라 DELETED_AT = NOW() 로 마킹
* - USER_DEPT 행은 보존 → 복구 시 멤버 그대로 살아남
* - 활성 자식 부서가 있으면 차단 (deleted 자식은 무시)
* - 반환: 0 = soft-delete 성공 (보존된 부서원 수는 복구 시점에 재조회)
* -1 = not found / already deleted
*/
@Transactional
public int deleteDepartment(String deptCode) {
// 하위 부서 확인
// 활성 하위 부서 확인 (deleted 자식은 자식 카운트에서 제외)
Map<String, Object> childParams = new HashMap<>();
childParams.put("dept_code", deptCode);
childParams.put("include_deleted", false);
Number childCountNum = sqlSession.selectOne("department.selectChildDeptCount", childParams);
int childCount = childCountNum != null ? childCountNum.intValue() : 0;
if (childCount > 0) {
throw new IllegalStateException("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.");
}
// 부서원 삭제
Map<String, Object> memberParams = new HashMap<>();
memberParams.put("dept_code", deptCode);
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
// 부서 삭제
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
Map<String, Object> deptParams = new HashMap<>();
deptParams.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDepartment", deptParams);
if (deleted == 0) {
return -1; // not found
int updated = sqlSession.update("department.deleteDepartment", deptParams);
if (updated == 0) {
return -1; // not found 또는 이미 deleted
}
log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount);
return memberCount;
log.info("부서 soft-delete 성공: deptCode={} (USER_DEPT 행 보존)", deptCode);
return 0;
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 로 되돌림
* - 부모가 있고 부모도 deleted 상태면 차단 (orphan 방지)
* - USER_DEPT 행은 soft-delete 시점부터 보존되어왔으므로 자동 복원됨
*/
@Transactional
public RestoreResult restoreDepartment(String deptCode) {
Map<String, Object> dept = getDepartmentIncludingDeleted(deptCode);
if (dept == null) {
return RestoreResult.NOT_FOUND;
}
if (dept.get("deleted_at") == null) {
return RestoreResult.NOT_DELETED;
}
// 부모 deleted 검증
Object parentObj = dept.get("parent_dept_code");
if (parentObj != null && !parentObj.toString().isBlank()) {
String parentCode = parentObj.toString();
Map<String, Object> parent = getDepartmentIncludingDeleted(parentCode);
if (parent != null && parent.get("deleted_at") != null) {
return RestoreResult.PARENT_DELETED;
}
}
// 동일 이름의 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);
if (restored == 0) {
return RestoreResult.NOT_DELETED; // race: 동시 복구
}
log.info("부서 복구 성공: deptCode={}", deptCode);
return RestoreResult.OK;
}
public enum RestoreResult {
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
* 체인에 deptCode 자체가 들어있다면 사이클이 생기므로 차단.
* (newParent == null 은 최상위로 만들기 — 항상 안전)
*/
private void verifyParentCycle(String deptCode, String newParent) {
if (newParent == null) return;
if (newParent.equals(deptCode)) {
throw new IllegalArgumentException("자기 자신을 상위 부서로 지정할 수 없습니다.");
}
Set<String> visited = new HashSet<>();
String cur = newParent;
while (cur != null && !visited.contains(cur)) {
if (deptCode.equals(cur)) {
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
}
visited.add(cur);
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", cur)
);
if (p == null) break;
Object parent = p.get("parent_dept_code");
cur = parent != null ? parent.toString() : null;
}
}
// ──────────────────────────────────────────────────
@@ -234,19 +391,47 @@ public class DepartmentService extends BaseService {
@Transactional
public boolean removeDeptMember(String deptCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDeptMember", params);
// 1. 제거 전 — 이 row 가 primary 였는지 확인
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);
boolean wasPrimary = existing != null && Boolean.TRUE.equals(existing.get("is_primary"));
// 2. 제거
int deleted = sqlSession.delete("department.deleteDeptMember", existParams);
if (deleted == 0) {
return false;
}
log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode);
// 3. primary 였으면 다른 USER_DEPT row 중 하나 promote
if (wasPrimary) {
Map<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
Map.of("user_id", userId));
if (remaining != null && remaining.get("dept_code") != null) {
Map<String, Object> promote = new HashMap<>();
promote.put("user_id", userId);
promote.put("dept_code", remaining.get("dept_code").toString());
sqlSession.update("department.setUserPrimaryDept", promote);
log.info("주 부서 자동 승격: userId={}, newPrimaryDept={}", userId, remaining.get("dept_code"));
}
}
log.info("부서원 제거 성공: userId={}, deptCode={}, wasPrimary={}", userId, deptCode, wasPrimary);
return true;
}
@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);
@@ -277,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;
}
@@ -0,0 +1,39 @@
-- V017: 회사 관리 그룹 하위 관리자 메뉴(MENU_TYPE='0', COMPANY_CODE='*') 순서 재배열
--
-- 변경 후 순서:
-- 1) 회사관리 (조직 계층: 회사)
-- 2) 부서관리 (조직 계층: 부서)
-- 3) 사용자관리 (조직 계층: 사용자)
-- 4) 메뉴관리 (권한 체계: 메뉴)
-- 5) 권한관리 (권한 체계: 권한)
-- 6) 권한 그룹관리 (권한 체계: 권한 그룹)
--
-- 사유: 기존 순서(회사 → 사용자 → 메뉴 → 권한 → 권한그룹 → 부서)는 부서가 맨 끝으로
-- 빠져 조직 계층이 끊기고, 권한 그룹이 권한관리 뒤에 오는 등 그룹 일관성이 없었다.
-- 조직(회사→부서→사용자) + 권한(메뉴→권한→권한그룹) 두 묶음으로 정렬한다.
--
-- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE
-- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음.
--
-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로
-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다.
-- → THEN 값은 반드시 문자열 리터럴로 줄 것.
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
AND COMPANY_CODE = '*'
AND PARENT_OBJ_ID IS NOT NULL
AND PARENT_OBJ_ID <> '0'
AND MENU_NAME_KOR IN (
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
);
@@ -0,0 +1,18 @@
-- V018: invyone 부서관리 V1 - soft-delete
-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경.
-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경.
-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전.
--
-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가
-- 동일 statement 를 부팅 시점에 적용.
--
-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체,
-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리.
-- (1) DEPT_INFO 소프트삭제 컬럼
ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL;
-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE
ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE)
WHERE DELETED_AT IS NULL;
@@ -0,0 +1,22 @@
-- V019: 부서관리 미사용/중복 컬럼 정리
-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복.
-- DROP IF EXISTS 로 멱등성 보장.
--
-- 대상 컬럼 (8개):
-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복)
-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출)
-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명)
-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지)
-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복)
-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직)
-- ERP_MANAGED - ERP 관리 (분기 로직 없음)
-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌)
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE;
@@ -728,14 +728,9 @@
DEPT_CODE
, PARENT_DEPT_CODE
, DEPT_NAME
, MASTER_SABUN
, MASTER_USER_ID
, LOCATION
, LOCATION_NAME
, CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE
, DATA_TYPE
, STATUS
, SALES_YN
, COMPANY_CODE
, COMPANY_NAME
FROM DEPT_INFO
@@ -746,8 +741,7 @@
</if>
<if test="search != null and search != ''">
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
OR DEPT_CODE ILIKE '%' || #{search} || '%'
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
OR DEPT_CODE ILIKE '%' || #{search} || '%')
</if>
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
</select>
@@ -3,7 +3,7 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="department">
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
<select id="selectDepartments" parameterType="map" resultType="map">
SELECT
D.DEPT_CODE,
@@ -15,29 +15,30 @@
D.ORG_SYSTEM,
D.APPROVAL_MANAGER,
D.DEPT_MANAGER,
D.ORG_HEAD,
D.ZIPCODE,
D.ADDRESS1,
D.ADDRESS2,
D.START_DATE,
D.END_DATE,
D.ERP_MANAGED,
D.SHOW_IN_CHART,
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
FROM DEPT_INFO D
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
<if test="include_deleted == null or include_deleted == false">
AND D.DELETED_AT IS NULL
</if>
GROUP BY
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER, D.ORG_HEAD,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
D.SORT_ORDER, D.STATUS, D.DELETED_AT
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
</select>
<!-- 부서 단건 조회 -->
<!-- 부서 단건 조회 (active 만) -->
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
@@ -49,33 +50,58 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</select>
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
DEPT_NAME,
COMPANY_CODE,
PARENT_DEPT_CODE,
SHORT_NAME,
DEPT_TYPE,
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
<!-- 중복 부서명 확인 -->
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
SELECT DEPT_CODE, DEPT_NAME
FROM DEPT_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND DEPT_NAME = #{dept_name}
WHERE COMPANY_CODE = #{company_code}
AND DELETED_AT IS NULL
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
</select>
<!-- 회사명 조회 -->
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
<select id="selectCompanyName" parameterType="map" resultType="map">
SELECT COMPANY_NAME
FROM COMPANY_MNG
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
WHERE COMPANY_CODE = #{company_code}
LIMIT 1
</select>
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
@@ -98,22 +124,14 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS,
MASTER_SABUN,
MASTER_USER_ID,
LOCATION,
LOCATION_NAME,
DATA_TYPE,
SALES_YN,
CREATED_DATE
) VALUES (
#{dept_code},
@@ -126,22 +144,14 @@
#{org_system},
#{approval_manager},
#{dept_manager},
#{org_head},
#{zipcode},
#{address1},
#{address2},
#{start_date}::date,
#{end_date}::date,
COALESCE(#{erp_managed}, 'Y'),
COALESCE(#{show_in_chart}, 'Y'),
COALESCE(#{sort_order}, 10),
COALESCE(#{status}, 'active'),
#{master_sabun},
#{master_user_id},
#{location},
#{location_name},
COALESCE(#{data_type}, 'real'),
COALESCE(#{sales_yn}, 'N'),
NOW()
)
</insert>
@@ -157,43 +167,43 @@
ORG_SYSTEM = #{org_system},
APPROVAL_MANAGER = #{approval_manager},
DEPT_MANAGER = #{dept_manager},
ORG_HEAD = #{org_head},
ZIPCODE = #{zipcode},
ADDRESS1 = #{address1},
ADDRESS2 = #{address2},
START_DATE = #{start_date}::date,
END_DATE = #{end_date}::date,
ERP_MANAGED = #{erp_managed},
SHOW_IN_CHART = #{show_in_chart},
SORT_ORDER = #{sort_order},
STATUS = #{status},
MASTER_SABUN = #{master_sabun},
MASTER_USER_ID = #{master_user_id},
LOCATION = #{location},
LOCATION_NAME = #{location_name},
DATA_TYPE = #{data_type},
SALES_YN = #{sales_yn}
LOCATION = #{location}
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</update>
<!-- 하위 부서 수 조회 -->
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
<select id="selectChildDeptCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM DEPT_INFO
WHERE PARENT_DEPT_CODE = #{dept_code}
<if test="include_deleted == null or include_deleted == false">
AND DELETED_AT IS NULL
</if>
</select>
<!-- 부서 삭제 전 user_dept 삭제 -->
<delete id="deleteUserDeptByDeptCode" parameterType="map">
DELETE FROM USER_DEPT
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NOW()
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NULL
</update>
<!-- 부서 삭제 -->
<delete id="deleteDepartment" parameterType="map">
DELETE FROM DEPT_INFO
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
<update id="restoreDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NULL
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NOT NULL
</update>
<!-- 부서원 목록 조회 -->
<select id="selectDeptMembers" parameterType="map" resultType="map">
@@ -208,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
@@ -225,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
@@ -239,14 +249,23 @@
WHERE USER_ID = #{user_id}
</select>
<!-- 기존 부서원 확인 -->
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
<select id="selectExistingMember" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
FROM USER_DEPT
WHERE USER_ID = #{user_id}
AND DEPT_CODE = #{dept_code}
</select>
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
<select id="selectFirstUserDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
FROM USER_DEPT
WHERE USER_ID = #{user_id}
ORDER BY CREATED_DATE ASC
LIMIT 1
</select>
<!-- 사용자의 주 부서 확인 -->
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
+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;
```
+16
View File
@@ -0,0 +1,16 @@
# Windows + Docker Desktop 전용 override.
#
# 배경: Docker Desktop (Windows + WSL2) 의 bind mount 가 host inotify 이벤트를
# 컨테이너로 전파하지 못해, Turbopack 의 file watcher 가 host 편집을 감지 못 함.
# webpack 은 WATCHPACK_POLLING=true 폴백을 지원하므로, Windows 에서만
# Turbopack 을 끄고 webpack 으로 폴백 → 자동 HMR 복원.
#
# 적용 범위: scripts/start/invyone-start-docker-all.bat 에서 명시적으로 -f 추가.
# Mac/Linux 사용자가 쓰는 다른 진입점에는 영향 없음.
#
# Trade-off: 첫 컴파일 약간 느려짐 (~10-30%). 그러나 수정 → 화면 반영이 1~3초로
# 단축되어 전체 개발 사이클은 압도적으로 빨라짐.
services:
frontend:
command: ["npm", "run", "dev:docker:nopack"]
@@ -1,115 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
/**
* 부서 관리 메인 페이지
* 좌측: 부서 구조, 우측: 부서 인원
*/
export default function DepartmentManagementPage() {
const params = useParams();
const router = useRouter();
const { companyCode } = params as { companyCode: string };
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/userMng/companyList");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<div className="lg:hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="structure"> </TabsTrigger>
<TabsTrigger value="members"> </TabsTrigger>
</TabsList>
<TabsContent value="structure" className="mt-4">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -17,9 +17,9 @@ interface CompanyTableProps {
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
const router = useRouter();
// 부서 관리 페이지로 이동
const handleManageDepartments = (company: Company) => {
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
// 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지)
const handleManageDepartments = (_company: Company) => {
router.push(`/admin/userMng/deptMngList`);
};
// 디스크 사용량 포맷팅 함수
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -31,6 +31,9 @@ export function DepartmentStructure({
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// V1: soft-delete 된 부서 표시 토글
const [showDeleted, setShowDeleted] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
@@ -42,15 +45,15 @@ export function DepartmentStructure({
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드
// 부서 목록 로드 — showDeleted 도 의존성에 포함
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger]);
}, [companyCode, refreshTrigger, showDeleted]);
const loadDepartments = async () => {
setIsLoading(true);
try {
const response = await departmentAPI.getDepartments(companyCode);
const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted });
if (response.success && (response as any).data) {
setDepartments((response as any).data);
} else {
@@ -65,6 +68,34 @@ export function DepartmentStructure({
}
};
// V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기)
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
try {
const response = await departmentAPI.restoreDepartment(deptCode);
if (response.success) {
loadDepartments();
toast({
title: "부서 복구 완료",
description: `"${deptName}" 부서가 복구되었습니다.`,
variant: "default",
});
} else {
toast({
title: "복구 불가",
description: (response as any).error || "부서 복구에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서 복구 실패:", error);
toast({
title: "부서 복구 실패",
description: "복구 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 트리 구조 생성
const buildTree = (parentCode: string | null): Department[] => {
return departments
@@ -145,10 +176,13 @@ export function DepartmentStructure({
setDeptToDelete(null);
loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
// V1 soft-delete: 복구 가능 안내 추가
const isSoft = (response as any)?.data?.soft_deleted === true;
toast({
title: "부서 삭제 완료",
description: (response as any).message || "부서가 삭제되었습니다.",
title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료",
description: isSoft
? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.`
: (response as any).message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
@@ -184,17 +218,18 @@ export function DepartmentStructure({
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
const isExpanded = expandedDepts.has(dept.dept_code);
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
const isDeleted = !!(dept as any).deleted_at;
return (
<div key={dept.dept_code}>
{/* 부서 항목 */}
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
}`}
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
@@ -218,32 +253,54 @@ export function DepartmentStructure({
<Users className="h-3 w-3" />
<span>{dept.member_count || 0}</span>
</div>
{/* deleted 배지 */}
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
{isDeleted ? (
<Button
variant="ghost"
size="icon"
className="text-primary h-6 w-6"
title="복구"
onClick={(e) => {
e.stopPropagation();
handleRestoreDepartment(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="하위 부서 추가"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
title="삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
@@ -259,10 +316,23 @@ export function DepartmentStructure({
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* V1: soft-delete 부서 표시 토글 */}
<Button
variant={showDeleted ? "secondary" : "ghost"}
size="sm"
className="h-9 gap-2 text-sm"
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
</Button>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 부서 트리 */}
@@ -338,7 +408,9 @@ export function DepartmentStructure({
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
<p className="text-muted-foreground mt-2 text-xs">
. ( '삭제 부서 보기' ) .
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
@@ -71,8 +71,19 @@ export default function Step1Basic({
const r: any = await checkAvailability(payload);
if (payload.subdomain === state.subdomain) {
const sub = r?.subdomain;
// 우선순위: reserved > valid_format > available
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
setSubStatus(
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
!sub
? "idle"
: sub.reserved
? "reserved"
: !sub.valid_format
? "invalid"
: sub.available
? "available"
: "taken",
);
}
if (payload.dbPrefix === state.db_prefix) {
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
*/
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
export function Field({
label,
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
<XCircle size={13} />
</span>
);
if (status === "reserved")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={13} /> ( )
</span>
);
return (
<span
style={{
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
/** TextInput 의 status prop 과 매핑 */
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
if (a === "available") return "ok";
if (a === "taken" || a === "invalid") return "err";
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
return undefined;
}
@@ -0,0 +1,352 @@
"use client";
/**
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
*
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
*
* 사용 예:
* <DepartmentPicker
* companyCode="INVYONE"
* mode="single"
* value={parentDeptCode}
* open={isOpen}
* onSelect={(code) => setParentDeptCode(code as string)}
* onClose={() => setIsOpen(false)}
* excludeCodes={[currentDeptCode]}
* />
*
* 동작:
* - shadcn Dialog 안에 검색박스 + 트리뷰
* - 부모 클릭 시 자식 cascade 펼침
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
* - single: 클릭 즉시 onSelect → close
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
* - excludeCodes 에 포함된 dept 는 disabled
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
*
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
*/
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Department } from "@/types/department";
import { getDepartments } from "@/lib/api/department";
export interface DepartmentPickerProps {
companyCode: string;
mode: "single" | "multi";
/** 현재 선택값. single 이면 string, multi 면 string[] */
value?: string | string[];
open: boolean;
onSelect: (code: string | string[]) => void;
onClose: () => void;
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
excludeCodes?: string[];
/** soft-delete 된 부서도 보여줄지 (default false) */
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
allowRoot?: boolean;
}
export function DepartmentPicker({
companyCode,
mode,
value,
open,
onSelect,
onClose,
excludeCodes,
includeDeleted = false,
title = "부서 선택",
allowRoot = false,
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchTerm, setSearchTerm] = useState(""); // debounced
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
// value -> selected 동기화
useEffect(() => {
if (!open) return;
if (mode === "single") {
setSelected(new Set(typeof value === "string" && value ? [value] : []));
} else {
setSelected(new Set(Array.isArray(value) ? value : []));
}
}, [value, mode, open]);
// 검색어 debounce 200ms
useEffect(() => {
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
return () => clearTimeout(t);
}, [searchInput]);
// 부서 목록 로드
useEffect(() => {
if (!open) return;
let cancelled = false;
setIsLoading(true);
getDepartments(companyCode, { includeDeleted })
.then((res: any) => {
if (cancelled) return;
if (res?.success && Array.isArray(res?.data)) {
setDepartments(res.data);
} else {
setDepartments([]);
}
})
.catch(() => !cancelled && setDepartments([]))
.finally(() => !cancelled && setIsLoading(false));
return () => {
cancelled = true;
};
}, [open, companyCode, includeDeleted]);
// 코드 -> 부서 맵 (검색·자식 조회용)
const byCode = useMemo(() => {
const m = new Map<string, Department>();
for (const d of departments) m.set(d.dept_code, d);
return m;
}, [departments]);
// 검색 매칭 (이름·코드 부분일치)
const isMatch = (d: Department): boolean => {
if (!searchTerm) return true;
return (
d.dept_name.toLowerCase().includes(searchTerm) ||
d.dept_code.toLowerCase().includes(searchTerm)
);
};
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
const visibleCodes = useMemo(() => {
if (!searchTerm) return null; // null = 전체 visible
const visible = new Set<string>();
for (const d of departments) {
if (isMatch(d)) {
visible.add(d.dept_code);
// 조상 visible (부모-부모-...) — 사이클 차단
const visited = new Set<string>([d.dept_code]);
let parentCode = d.parent_dept_code;
while (parentCode && !visited.has(parentCode)) {
visited.add(parentCode);
visible.add(parentCode);
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
}
}
}
return visible;
}, [searchTerm, departments, byCode]);
// 부모 코드 → 자식 정렬 리스트
const childrenOf = (parentCode: string | null): Department[] => {
return departments
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
};
// 부모 + 자식 (재귀) cascade 코드들
const collectDescendants = (rootCode: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
const dfs = (code: string) => {
if (visited.has(code)) return;
visited.add(code);
result.push(code);
for (const child of childrenOf(code)) {
dfs(child.dept_code);
}
};
dfs(rootCode);
return result;
};
const toggleExpand = (code: string) => {
const next = new Set(expanded);
if (next.has(code)) next.delete(code);
else next.add(code);
setExpanded(next);
};
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
const handleNodeClick = (d: Department) => {
if (isExcluded(d.dept_code)) return;
if (mode === "single") {
onSelect(d.dept_code);
onClose();
return;
}
// multi: 자기 + 자손 모두 토글
const next = new Set(selected);
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
const allSelected = codes.every((c) => next.has(c));
if (allSelected) {
for (const c of codes) next.delete(c);
} else {
for (const c of codes) next.add(c);
}
setSelected(next);
};
const handleConfirmMulti = () => {
onSelect(Array.from(selected));
onClose();
};
// 트리 렌더 (재귀, visited Set 사이클 차단)
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
const list = childrenOf(parentCode);
return list.map((d) => {
if (visited.has(d.dept_code)) return null; // 사이클 차단
const nextVisited = new Set(visited);
nextVisited.add(d.dept_code);
const hasChildren = childrenOf(d.dept_code).length > 0;
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
const isSel = selected.has(d.dept_code);
const excluded = isExcluded(d.dept_code);
const deleted = isDeleted(d);
// 검색 시: visible 아닌 노드는 숨김
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
return (
<div key={d.dept_code}>
<div
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
excluded
? "cursor-not-allowed opacity-40"
: "hover:bg-muted cursor-pointer"
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
style={{ paddingLeft: `${level * 16 + 6}px` }}
onClick={() => handleNodeClick(d)}
>
{/* expand/collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(d.dept_code);
}}
className="flex h-4 w-4 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* multi: 체크 표시 */}
{mode === "multi" && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
}`}
>
{isSel && <Check className="h-3 w-3" />}
</div>
)}
{/* 부서명 + 코드 */}
<div className="flex flex-1 flex-col leading-tight">
<span className="font-medium">{d.dept_name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
</div>
{deleted && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>
)}
</div>
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
</div>
);
});
};
// 루트 — parent_dept_code 가 null/빈문자열인 부서들
const rootList = useMemo(() => childrenOf(null), [departments]);
const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code);
const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="부서명 또는 코드로 검색"
className="pl-8 pr-8"
autoFocus
/>
{searchInput && (
<button
onClick={() => setSearchInput("")}
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
title="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 트리 */}
<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 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> .</div>
) : noResults ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
renderTree(null, 0, new Set())
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
{mode === "multi" && (
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
({selected.size})
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DepartmentPicker;
@@ -196,11 +196,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
+25 -1
View File
@@ -409,7 +409,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (pathname.startsWith("/admin") && pathname !== "/admin") {
store.setMode("admin");
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", admin_url: pathname });
// menu API 가 실패하는 환경 (SUPER_ADMIN cross-tenant 등) 에서도 한글 라벨 유지
const ADMIN_PATH_LABELS: Record<string, string> = {
"/admin/userMng/deptMngList": "부서관리",
};
const fallbackTitle = ADMIN_PATH_LABELS[pathname] || pathname.split("/").pop() || "관리자";
store.openTab({ type: "admin", title: fallbackTitle, admin_url: pathname });
}
}, []);
@@ -902,6 +907,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
}, [activeTab, uiMenus, isMenuActive]);
// URL 직접 진입 / sessionStorage 복원 시 admin 탭의 영어 path-segment title 을
// menu_name_kor (uiMenus 의 tabTitle/label/name) 로 갱신.
// menu API 가 실패한 환경 (SUPER_ADMIN cross-tenant) 에서도 동작하도록 hardcoded map 도 같이 검사.
useEffect(() => {
const ADMIN_PATH_LABELS: Record<string, string> = {
"/admin/userMng/deptMngList": "부서관리",
};
const store = useTabStore.getState();
for (const tab of store.admin.tabs) {
if (tab.type !== "admin" || !tab.admin_url) continue;
const matched = uiMenus.find((m: any) => m.url === tab.admin_url);
const koreanTitle: string | undefined =
matched?.tabTitle || matched?.label || matched?.name || ADMIN_PATH_LABELS[tab.admin_url];
if (koreanTitle && tab.title !== koreanTitle) {
store.updateTabTitle(tab.id, koreanTitle);
}
}
}, [uiMenus]);
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
+37 -5
View File
@@ -6,12 +6,18 @@ import { apiClient } from "./client";
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
/**
* 부서 목록 조회 (회사별)
* 부서 목록 조회 (회사별).
* options.includeDeleted=true 시 soft-delete 된 부서도 포함.
*/
export async function getDepartments(companyCode: string) {
export async function getDepartments(
companyCode: string,
options?: { includeDeleted?: boolean },
) {
try {
const url = `/departments/companies/${companyCode}/departments`;
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url);
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, {
params: options?.includeDeleted ? { include_deleted: true } : undefined,
});
return response.data;
} catch (error: any) {
console.error("부서 목록 조회 실패:", error);
@@ -67,11 +73,16 @@ export async function updateDepartment(deptCode: string, data: DepartmentFormDat
}
/**
* 부서 삭제
* 부서 삭제 (V1: soft-delete).
* 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가.
*/
export async function deleteDepartment(deptCode: string) {
try {
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`);
const response = await apiClient.delete<{
success: boolean;
message?: string;
data?: { soft_deleted?: boolean; dept_code?: string };
}>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 삭제 실패:", error);
@@ -79,6 +90,27 @@ export async function deleteDepartment(deptCode: string) {
}
}
/**
* 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기).
* 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지.
*/
export async function restoreDepartment(deptCode: string) {
try {
const response = await apiClient.post<{
success: boolean;
message?: string;
data?: { dept_code?: string; restored?: boolean };
}>(`/departments/${deptCode}/restore`);
return response.data;
} catch (error: any) {
console.error("부서 복구 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 부서원 목록 조회
*/
+1
View File
@@ -8,6 +8,7 @@
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771",
"dev:docker": "next dev --turbopack -p 3000",
"dev:docker:nopack": "next dev -p 3000",
"build": "next build",
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
"start": "next start",
+11
View File
@@ -41,6 +41,7 @@ interface TabState {
closeAllTabs: () => void;
updateTabOrder: (fromIndex: number, toIndex: number) => void;
updateTabTitle: (tabId: string, title: string) => void;
}
// --- 헬퍼 함수 ---
@@ -195,6 +196,16 @@ export const useTabStore = create<TabState>()(
newTabs.splice(toIndex, 0, moved);
set({ [mk]: { ...modeData, tabs: newTabs } });
},
updateTabTitle: (tabId, title) => {
const mk = modeKey(get());
const modeData = get()[mk];
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
if (idx === -1 || modeData.tabs[idx].title === title) return;
const newTabs = [...modeData.tabs];
newTabs[idx] = { ...newTabs[idx], title };
set({ [mk]: { ...modeData, tabs: newTabs } });
},
}),
{
name: "erp-tab-store",
+5 -20
View File
@@ -2,19 +2,14 @@
* 부서 관리 관련 타입 정의
*/
// 부서 정보 (dept_info 테이블 1:1 매핑)
// 부서 정보 (dept_info 테이블 1:1 매핑) — V019 정리 후
export interface Department {
dept_code: string; // 부서 코드 (PK)
parent_dept_code?: string | null; // 상위 부서 코드
dept_name: string; // 부서명
master_sabun?: string | null; // 부서장 사번
master_user_id?: string | null; // 부서장 사용자ID
location?: string | null; // 위치코드
location_name?: string | null; // 위치명
location?: string | null; // 위치코드 (UI hide, V2 매핑용 컬럼만 유지)
created_date?: string | null; // 생성일시
data_type?: string | null; // 데이터 구분 (real/temp)
status?: "active" | "inactive" | null; // 사용여부
sales_yn?: "Y" | "N" | null; // 영업조직 여부
company_name?: string | null; // 회사명
company_code: string; // 회사 코드
short_name?: string | null; // 부서약칭
@@ -22,17 +17,15 @@ export interface Department {
org_system?: string | null; // 조직체계
approval_manager?: string | null; // 결재관리자 user_id
dept_manager?: string | null; // 부서관리자 user_id
org_head?: string | null; // 조직장 user_id
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
created_at?: string;
updated_at?: string;
deleted_at?: string | null; // V1: soft-delete 시각. NULL=active, 값 있음=휴지통
// UI용 추가 필드
children?: Department[];
member_count?: number;
@@ -59,7 +52,7 @@ export interface UserDepartmentMapping {
created_at?: string;
}
// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1
// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 (V019 정리 후)
export interface DepartmentFormData {
dept_name: string; // 부서명 (필수)
parent_dept_code?: string | null;
@@ -68,23 +61,15 @@ export interface DepartmentFormData {
org_system?: string | null;
approval_manager?: string | null;
dept_manager?: string | null;
org_head?: string | null;
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null;
end_date?: string | null;
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
status?: "active" | "inactive" | null;
// dept_info 추가 필드
master_sabun?: string | null;
master_user_id?: string | null;
// dept_info 추가 필드 (location 코드만 유지)
location?: string | null;
location_name?: string | null;
data_type?: string | null;
sales_yn?: "Y" | "N" | null;
dept_code?: string | null; // 일괄등록용 (자동 부여 시 미전달)
}
@@ -0,0 +1,427 @@
# Invyone 부서관리 — SRM_SAMYANGFOOD 대비 부족한 점 분석
> **V1 적용 완료 (2026-05-04, slim scope)**
> Phase 1 (DELETED_AT schema) + Slice 2.1 (soft-delete + 복구) + Slice 2.2 (DepartmentPicker) 적용 완료. 메타 + 3 테넌트 (test01/test02/siflex) 자동 동기화. 변경이력·직책 모델은 V2 행 (사용자 결정).
>
> 적용 우선순위 표 매핑:
> - ✅ #7 Soft-delete (DELETE → DELETED_AT 마킹 + 복구 endpoint + 토글 UI)
> - ✅ #2 부서 선택 팝업 — 컴포넌트 자체 (V1.1 에서 다른 화면 마이그레이션)
> - ⏸️ #1 변경이력 — V2 (감사 요구사항 발생 시)
> - ⏸️ #3 직책 — V2 (결재 모듈 도입 시 함께)
> - 그 외 #4·5·6·8·9·10 모두 V2+
- 작성일: 2026-05-04
- 비교 대상
- **invyone** : `C:\Dev\projects\invyone` (Spring Boot + Next.js 멀티테넌트 로우코드)
- **SRM_SAMYANGFOOD** : `C:\Dev\projects\SRM_SAMYANGFOOD` (Spring + JSP 레거시 SAP 연동 SRM)
- 비교 범위 : 부서관리(부서/조직/Dept) 모듈 한정 — 백엔드/스키마/프론트/연동/감사
---
## 0. 한 줄 요약
Invyone 은 **단일 회사 단위의 부서 CRUD/계층/멤버 할당** 은 잘 작동하지만, 한국형 ERP/SAP 연동 환경에서 표준이 된 **8 가지 축** (결재 라인 통합, 회계/Cost Center 연계, 변경 이력, 외부 시스템 동기화, 직책 모델, 부서 선택 팝업, 법인 분리, 본격적 단계 표현) 이 모두 빠져있다. 현재 코드는 **MVP 수준**이고, ERP 시장 진입 전 보강이 필요.
---
## 1. 두 시스템 스냅샷
### 1.1 SRM_SAMYANGFOOD 부서관리 핵심
- **컨트롤러** : `com.st_ones.everadmin.system.dept.web.DEPT01_Controller` (212줄)
- **서비스** : `DEPT01_Service` (148줄)
- **메인 테이블** : `STOCOGDP` — 부서 마스터, `STOCOGDT` — 디테일
- **외부 동기화 테이블** : `M_DEPT` (그룹웨어/SAP-인접) ← Quartz 배치 `GwDept` 가 정기 풀링
- **UI 화면 4종**
- `DEPT01_010` — 본부/부/팀/파트 4-pane 드릴다운 그리드 (gridT/M/B/DP)
- `DEPT01_020` — 9-단계 TreeGrid + in-cell edit + 자식 카운트
- `DEPT01_020P01` — 부서 상세 팝업 (depth 선택 + insert/update merge)
- `DEPT01_030` — 다른 화면 재사용용 부서 선택 트리 (multi/single)
### 1.2 Invyone 부서관리 핵심
- **컨트롤러** : `com.erp.controller.DepartmentController``/api/departments`
- **서비스** : `com.erp.service.DepartmentService` (BaseService 상속, sqlSession 직접)
- **매퍼** : `mapper/department.xml` (namespace: `department`)
- **테이블** : `DEPT_INFO` (22 컬럼), `USER_DEPT` (사용자↔부서 m:n + IS_PRIMARY)
- **UI 화면 2종**
- `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (legacy 단독 페이지)
- `frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx` (메인) — `DepartmentStructure` + `DepartmentMembers` 컴포넌트
---
## 2. 갭 — Tier 별 정리
### 🔴 Tier 1 — 운영 표면화 (즉시 막힘)
#### 2.1 결재 라인 통합 부재
| | SRM | Invyone |
|---|---|---|
| 결재 참여 토글 | `STOCOGDP.APPROVE_USE_FLAG` | ❌ 없음 |
| 결재 라인 모델 | AUTH 시스템과 연결 | 없음 |
| 결재 매니저 | TEAM_LEADER_USER_ID + 별도 결재 체인 | `approval_manager` 한 명 필드만, 결재 흐름 미연결 |
**영향** : 결재 모듈을 붙이는 순간 부서 모델을 다시 손대야 한다.
#### 2.2 변경 이력 (Audit Trail) — 백엔드 0%
- 화면(`DepartmentStructure.tsx` 등) 에 `변경이력` 모달 스켈레톤만 있고 백엔드 테이블·쿼리 모두 부재.
- `DEPT_INFO` 컬럼 : `CREATED_DATE` 만, **UPDATED_DATE / UPDATED_BY 없음**.
- SRM 도 완벽하진 않지만 `REG_DATE / MOD_DATE` 는 있음.
**영향** : "누가 언제 부서명 바꿨냐" 추적 불가 → 감사·컴플라이언스 실패.
#### 2.3 부서 선택 재사용 컴포넌트 부재
- SRM `DEPT01_030` : 다른 화면에서 부서 골라야 할 때 띄우는 트리 팝업 (single/multi-select, 부모 클릭 시 자식 cascade).
- Invyone : `searchUsers` (사용자 검색) 만 있음. 부서 picker 가 없어서 다른 화면에서 부서 참조하려면 매번 자체 셀렉트 만들어야 함.
#### 2.4 회계 코드 / Cost Center 미연계
- SRM : `ACC_CODE`, `INDEPT`(내부부서), `DIVISION_YN`.
- Invyone : 없음. 회계/원가 모듈을 붙일 때 부서 schema 재설계 필요.
---
### 🟠 Tier 2 — 한국형 ERP 표준 누락
#### 2.5 직책 (Position) 모델 부재
| | SRM | Invyone |
|---|---|---|
| 부서장 | `TEAM_LEADER_USER_ID` | `dept_manager`, `org_head` (각 1명) |
| 사용자↔부서 관계 | DEPT_CD + 직급 별도 | `USER_DEPT.IS_PRIMARY` boolean 만 |
**영향** : "팀의 PL 3명, 팀장 1명, 부장 1명" 같은 한국 조직 모델 표현 불가.
#### 2.6 DEPT_TYPE 표현력 부족
| 시스템 | enum 값 |
|---|---|
| SRM | `100=Division / 200=Department / 300=Team / 400=SubTeam` + `LVL` 숫자 |
| Invyone | `dept / team / temp` 3종 (`temp` 의미도 모호) |
**영향** : 한국 조직의 "본부 → 실 → 부 → 팀 → 파트" 5단계 구분이 안 들어감.
#### 2.7 외부 시스템 동기화 인프라 부재
- SRM : `GwDept` Quartz 배치 → 그룹웨어 `M_DEPT` 풀링 → 재귀 CTE 트리 재구성 → 회사 코드별(BUYER_CD: 100/300/500/700/800) `STOCOGDP` 클리어 후 일괄 적재.
- Invyone : CSV 한 줄씩 import 외 정기 동기화 없음.
**영향** : ERP 마이그레이션 / SAP 연동 고객 받으면 즉각 장애.
**참고** : 로우코드 플랫폼 철학상 의도적 미구현일 수 있음 → 그러나 ERP 도메인 진입 시 필요.
#### 2.8 법인 / 사업자 정보 분산
- SRM : 부서 자체에 `DEPT_IRS_NUM`(사업자등록번호), `DEPT_CEO_NM`(대표자) — **부서 = 별도 법인 단위 운영 가능**.
- Invyone : 사업자번호·대표자는 `COMPANY_MNG` 에만. **한 회사 안에 복수 법인** (계열사·자회사) 표현 불가 → 회사를 나누는 수밖에 없음.
---
### 🟡 Tier 3 — UX / 조회 효율
| # | 항목 | SRM | Invyone |
|---|---|---|---|
| 2.9 | 자식 노드 카운트 표시 | 트리 노드별 자식 수 표시 | ❌ 펼쳐봐야 알 수 있음 |
| 2.10 | 4-단계 그리드 드릴다운 화면 | `DEPT01_010` 4-pane | ❌ 단일 트리만 |
| 2.11 | In-cell 편집 | TreeGrid 안에서 직접 | ❌ 모달/사이드폼만 |
| 2.12 | 드래그앤드롭 재배열 | (명시적은 없으나 SEQ + tree edit) | ❌ `sort_order` 필드 있음, UI 없음 |
| 2.13 | Org Chart 시각화 | (없음) | ❌ `show_in_chart` 필드 있음 → **죽은 필드** |
| 2.14 | 개인정보 마스킹 | `J*n D*e` 마스킹 | ❌ 없음 |
---
### 🟢 Tier 4 — 모델링 / 성능 / 안정성
#### 2.15 Denormalized 계층 path 부재
- SRM : `ITEM_CLS1` ~ `ITEM_CLS9` + `LVL` 9-단계 비정규화 → breadcrumb · 검색 · 정렬 한 방.
- Invyone : `parent_dept_code` 만 → 깊은 트리는 클라이언트 재귀 join.
- 트레이드오프 : SRM 갱신 비용↑ 조회 비용↓ / Invyone 그 반대.
- **5단계 이상 깊은 조직** 에서는 SRM 모델이 유리.
#### 2.16 Hard Delete (soft-delete 부재)
- SRM : `DEL_FLAG='1'` 논리 삭제 → 이력·복구 가능.
- Invyone : `DELETE` 가 진짜 row 제거 + `USER_DEPT` 강제 정리 → 잘못 지우면 복구 안 됨.
#### 2.17 부서 폐지 / 합병 절차 부재
- SRM : DEL_FLAG + 직원 재배치 + 결재 라인 재구성.
- Invyone : 멤버 자동 제거 후 hard delete — "이 부서 직원들 어디로?" 안내 없음.
---
## 3. 우선순위 권장 (보강 로드맵)
| 순위 | 작업 | 작업량 | 막힘 위험 | 기대 효과 |
|---|---|---|---|---|
| 1 | **변경 이력** : `UPDATED_DATE/BY` + `DEPT_HISTORY` 테이블 + 모달 백엔드 | S | 즉시 감사 실패 | stub → 동작 |
| 2 | **부서 선택 팝업** 컴포넌트 (DEPT01_030 류) | M | 다른 화면 만들 때마다 막힘 | 재사용성 폭발 |
| 3 | **직책 모델** : `USER_DEPT.ROLE_IN_DEPT` (팀장/팀원/매니저...) | S | 권한·결재 모듈에서 필수 | 진짜 조직 표현 |
| 4 | **DEPT_TYPE 확장** : 5-단계 + `LVL` 숫자 | S | 데이터 입력 시 표현 부족 | 단계별 정렬·필터 |
| 5 | **회계 코드** : `ACC_CODE` + 매핑 UI | M | 회계 모듈 붙이는 순간 막힘 | 비용 집계 가능 |
| 6 | **결재 워크플로우** 통합 (`APPROVE_USE_FLAG` + 결재 라인 모델) | L | 결재 모듈 별도 | 한국형 ERP 진입 |
| 7 | **Soft-delete** : `DELETED_AT` + 복구 흐름 | S | 잘못된 hard delete 위험 | 데이터 안정성 |
| 8 | **Org chart 화면** (`show_in_chart` 플래그 활용) | M | 죽은 필드 살리기 | 시각화 |
| 9 | **외부 동기화** : SAP/그룹웨어 API 풀러 | L | 마이그레이션 시 | 이관 가능 |
| 10 | **UX 개선** : 자식 카운트 / 드래그앤드롭 / 드릴다운 그리드 | M~L | UX 차원 | 사용성 |
---
## 4. 추가 관찰
### 4.1 Invyone 강점 (잊지 말 것)
- 멀티테넌시 (`COMPANY_CODE='*'` 글로벌 + 회사별 격리) — SRM 의 `GATE_CD + BUYER_CD` 보다 깔끔
- 사용자 다중 부서 + IS_PRIMARY — SRM 은 사용자 1↔1 부서 기본
- React 컴포넌트화 (`DepartmentStructure` + `DepartmentMembers`) — 재사용성·테스트 용이
- 트랜잭셔널 일관성 + 명확한 cascade (자식 부서 있으면 삭제 거부)
### 4.2 Invyone 의 "이상한" 부분
- `DEPT_TYPE='temp'` — 의미 미정의, 일회성 부서?
- `MASTER_SABUN` (legacy 사번) 컬럼 — 어디서 쓰는지 불분명
- `ERP_MANAGED='Y'` 기본값 — Y/N 인데 실제 분기 로직 안 보임
- `DATA_TYPE='real'|'temp'``DEPT_TYPE='temp'` 와 의미 충돌 가능성
### 4.3 모델링 결정 : 단일 회사 vs. 다법인
Invyone 이 향후 **한 회사 안에서 복수 법인** 운영 (그룹사·계열사 모델) 을 지원할지가 분기점.
- 지원 안 함 : 현재 모델 유지, 회사를 더 만들면 됨 (사업자번호는 COMPANY_MNG 에)
- 지원 : 부서에 `legal_entity_code` 추가 + 사업자 정보 분리 → 큰 마이그레이션
### 4.4 Tier 1~2 만 했을 때의 결과
대략 1~2 sprint 분량. 이걸로 일반 한국 중견기업 ERP 부서관리 표준은 **충분히 커버**.
---
## 5. 다음 액션
- [ ] 본 문서를 stake-holder (사용자/내부) 와 함께 검토 → 우선순위 합의
- [ ] Tier 1 (변경이력 + 부서 선택 팝업 + 직책 모델 + DEPT_TYPE 확장) 1차 설계 문서 작성
- [ ] 마이그레이션 (V018 ~) 초안
- [ ] Tier 1 구현 → Tier 2 → ...
---
## 부록 A. 참고 코드 위치
### Invyone
```
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 (namespace: department)
frontend/app/(main)/admin/userMng/deptMngList/page.tsx
frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
frontend/lib/api/department.ts
frontend/types/department.ts
frontend/components/.../DepartmentStructure.tsx
frontend/components/.../DepartmentMembers.tsx
```
### SRM_SAMYANGFOOD
```
src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java
src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java
src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java
src/main/java/com/st_ones/batch/gwDept/web/GwDept.java
src/main/java/com/st_ones/batch/gwDept/service/GwDept_Service.java
src/main/java/com/st_ones/batch/gwDept/DeptMapper.java
```
## 부록 B. 주요 컬럼 비교 매트릭스
| 의미 | SRM (`STOCOGDP`) | Invyone (`DEPT_INFO`) |
|---|---|---|
| PK | DEPT_CD | DEPT_CODE |
| 이름 | DEPT_NM | DEPT_NAME |
| 상위 | PARENT_DEPT_CD | PARENT_DEPT_CODE |
| 깊이 | LVL | ❌ 없음 |
| Path | ITEM_CLS1 ~ ITEM_CLS9 | ❌ 없음 |
| 단계타입 | DEPT_TYPE (4종) | DEPT_TYPE (3종) |
| 부서장 | TEAM_LEADER_USER_ID | DEPT_MANAGER, ORG_HEAD |
| 결재 토글 | APPROVE_USE_FLAG | ❌ 없음 |
| 회계코드 | ACC_CODE | ❌ 없음 |
| 사업자번호 | DEPT_IRS_NUM | ❌ 부서엔 없음 (회사에만) |
| 대표자 | DEPT_CEO_NM | ❌ 부서엔 없음 |
| 내부부서 | INDEPT | ❌ 없음 |
| 사업부 플래그 | DIVISION_YN | ❌ 없음 |
| 정렬 | SEQ | SORT_ORDER |
| 소프트삭제 | DEL_FLAG | ❌ hard delete |
| 회사범위 | GATE_CD + BUYER_CD | COMPANY_CODE (+ '*' 글로벌) |
| 등록일 | REG_DATE | CREATED_DATE |
| 수정일 | MOD_DATE | ❌ 없음 |
| 시작/종료 | ❌ | START_DATE / END_DATE |
| 위치 | ❌ | LOCATION, LOCATION_NAME, ZIPCODE, ADDRESS1/2 |
| 매출부서 | ❌ | SALES_YN |
| 차트표시 | ❌ | SHOW_IN_CHART |
(Invyone 에만 있는 항목도 일부 있음 — 위치/주소/SALES_YN/SHOW_IN_CHART. 현재 활용도는 낮음.)
---
# 부록 C. KDSCont ↔ SRM_SAMYANGFOOD 부서관리 공통 패턴
(2026-05-04 추가) — KDSCont 도 함께 비교해 본 결과, 두 시스템의 공통점이 단순 우연이 아니라 **동일 SI 벤더의 ERP 프레임워크 표준 청사진** 임이 확인됨. 그래서 이 공통 패턴이 사실상 **한국 ERP/SI 시장의 부서관리 표준** 으로 봐도 됨.
## C.1 결정적 발견 — 같은 SI 벤더 산출물
| 항목 | KDSCont | SRM_SAMYANGFOOD |
|---|---|---|
| 패키지 root | `com.st_ones.eversrm` | `com.st_ones.everadmin` |
| 부서 테이블 | **STOCOGDP** | **STOCOGDP** (동일) |
| 화면 코드 체계 | MOGA0030/0031/0032 | DEPT01_010/020/030 |
| 베이스 클래스 | BaseController / BaseService | BaseController / BaseService |
| 매퍼 방식 | MyBatis XML + Mapper interface | MyBatis XML + Mapper interface |
**stones (st_ones)** 라는 동일 SI 의 ERP 프레임워크 위 다른 프로젝트. 두 시스템의 공통점 = **stones 표준** = 한국 ERP/SI 의 사실상 표준.
---
## C.2 데이터 모델 — 95% 일치하는 부분
### C.2.1 테이블 / PK 구조
```
STOCOGDP (부서 마스터)
PK : (GATE_CD, BUYER_CD, DEPT_CD)
├ 멀티테넌시 : GATE_CD (시스템 단위) + BUYER_CD (회사 단위)
└ 부서 식별 : DEPT_CD
```
### C.2.2 핵심 컬럼 (둘 다 동일)
| 컬럼 | 의미 | 비고 |
|---|---|---|
| `DEPT_CD` | 부서 코드 (PK) | **사용자 직접 입력** + 중복 체크 (auto-gen 아님) |
| `DEPT_NM` | 한국어 부서명 | KDSCont 는 `DEPT_NM_ENG` 영어 칼럼도 있음 |
| `PARENT_DEPT_CD` | 상위 부서 코드 | self-FK; level 1 은 BUYER_CD 가리킴 |
| `LVL` | 깊이 (0=회사 root) | KDSCont 0~4 / SRM 0~9 |
| `DEPT_TYPE` | 단계 enum (100~400) | ★ 핵심 — 100단위 4단계 |
| `SEQ` | 같은 부모 안에서 정렬 | |
| `ACC_CODE` | 회계/Cost Center 코드 | |
| `TEAM_LEADER_USER_ID` | 부서장 user_id (1명) | m:n 아님, 단일 |
| `DIVISION_YN` | 사업부 여부 | |
| `DEL_FLAG` | 소프트 삭제 ('0'/'1') | |
| `REG_DATE`, `MOD_DATE` | 감사 타임스탬프 | |
| `REG_USER_ID`, `MOD_USER_ID` | 감사 사용자 | |
| `ITEM_CLS1` ~ `ITEM_CLS{depth}` | 비정규화 path 세그먼트 | breadcrumb 용 |
### C.2.3 DEPT_TYPE 100/200/300/400 컨벤션 (★ stones 표준)
| 코드 | KDSCont | SRM | 한국 조직 의미 |
|---|---|---|---|
| 100 | 부 (Division) | Division | 본부/사업부 |
| 200 | 팀 (Team) | Department | 부 |
| 300 | 조 (Section) | Team | 팀 |
| 400 | 계 (Sub-section) | SubTeam | 파트 |
핵심 : **100단위 부여, 4-단계 cap, enum 기반 타입 분리**. 한국 SI 시장 사실상 표준.
### C.2.4 계층 표현 — 비정규화 path 컬럼
```
ITEM_CLS1=회사명 ITEM_CLS2=부 ITEM_CLS3=팀 ITEM_CLS4=조 ITEM_CLS5=계
ITEM_CLS_PATH_NM = "회사명 > 부 > 팀 > 조" (concat)
```
양쪽 모두 `parent_dept_cd` 만으로는 안 풀고 path 세그먼트를 박아둠 → 트리·검색·breadcrumb 한 방.
### C.2.5 멀티테넌시 — `GATE_CD + BUYER_CD` 복합
- `GATE_CD` : 시스템/오퍼레이션 단위 (super-user 만 관리)
- `BUYER_CD` : 회사 단위
- 모든 부서 쿼리는 `WHERE GATE_CD=? AND BUYER_CD=?` 필수.
---
## C.3 UI 패턴 — 동일한 화면 구성
### C.3.1 화면 2종 세트 (★)
| 화면 | KDSCont | SRM |
|---|---|---|
| 4-단계 그리드 드릴다운 | MOGA0030 | DEPT01_010 |
| 트리뷰 + in-cell edit | MOGA0031/0032 | DEPT01_020 |
| 부서 상세 팝업 | (그리드 cell 클릭) | DEPT01_020P01 |
| 부서 선택 재사용 팝업 | SP0067/0066 | DEPT01_030 |
### C.3.2 그리드 드릴다운
- 4 패널 grid: LV1 / LV2 / LV3 / LV4
- 라디오로 활성 grid 전환
- LV1 cell 클릭 → LV2 cascade 로드 → ...
### C.3.3 트리뷰 + 인라인 편집
- 회사 root (LV0) 부터 풀 트리 펼침
- ACC_CODE / 부서장 등 selected row 만 편집 → hidden grid 모아서 일괄 저장
### C.3.4 개인정보 마스킹 (★ 양쪽 동일)
- `GETUSERNAME(gateCd, userId, langCd)` 함수가 마스킹 처리
- 첫글자 + `*` + 마지막 N자 (예: `홍*동`, `J*n D*e`)
### C.3.5 사용자 입력 + 중복 체크 (auto-gen 아님)
- DEPT_CD 사용자 직접 입력 → save 시 unique 체크
- SAP/외부 시스템 코드 매핑을 위한 의도적 사용자 제어
---
## C.4 컨벤션 공통점 — invyone 과 다른 부분
| | stones (KDSCont · SRM) | invyone |
|---|---|---|
| 매퍼 | XML + Mapper interface (`@Mapper`) | XML namespace 만, **Mapper interface 금지** |
| DTO | Map + 일부 VO | Map<String, Object> 만 (DTO 금지) |
| 화면 코드 | 4-digit (MOGA0030 / DEPT01_010) | URL 기반 |
| Save 패턴 | MyBatis MERGE / UPSERT | INSERT / UPDATE 분리 |
| 다국어 | DEPT_NM + DEPT_NM_ENG | 미지원 |
| 부서 코드 | 사용자 입력 + 중복체크 | 자동 생성 (`DEPT_<n>`) |
---
## C.5 Invyone 에 시사하는 것
### C.5.1 받아들여야 할 것 (★ 한국 SI 표준)
- **DEPT_TYPE = 100/200/300/400** 4단계 enum (현재 invyone 은 dept/team/temp 3종 → 표현력 부족)
- **`LVL` 숫자 컬럼**
- **`ITEM_CLS1~5` 비정규화 path** + `ITEM_CLS_PATH_NM` 자동 갱신
- **`ACC_CODE`** (Cost Center)
- **`DIVISION_YN`**
- **`REG_USER_ID / MOD_USER_ID / MOD_DATE`** 감사 컬럼
- **`DEL_FLAG`** 소프트삭제 (현재 hard delete)
### C.5.2 UI 에서 받아들여야 할 것
- **4-단계 그리드 드릴다운** 화면 (현재 invyone 은 단일 트리만)
- **부서 선택 팝업** 컴포넌트 (재사용)
- **부서장 user picker + 개인정보 마스킹**
### C.5.3 받아들이지 말 것 (invyone 이 더 나은 부분)
- **GATE_CD + BUYER_CD 2단계 멀티테넌시** → invyone 의 단일 `COMPANY_CODE + '*'` 가 더 깔끔.
- **`@Mapper` interface** → invyone 의 sqlSession 직접 사용이 더 가볍고 CLAUDE.md 원칙과 부합.
- **사용자 직접 입력 DEPT_CD** → 외부 시스템 매핑 필요할 때 옵션으로만, 기본 자동생성 유지.
- **다국어 컬럼** → 진짜 글로벌 진출 전엔 over-engineering, 나중에.
### C.5.4 stones 모델에 없는데 invyone 이 이미 더 나은 부분
- 사용자 ↔ 부서 m:n + IS_PRIMARY (stones 는 1:1)
- 시작/종료 일자 (START_DATE/END_DATE)
- 위치 정보 (LOCATION/ADDRESS)
- React 컴포넌트화
---
## C.6 결론 — 우선순위 보강
본문 §3 의 우선순위에서 **확신도가 올라간 항목** (KDSCont 에서도 동일 패턴 확인):
1.**DEPT_TYPE 4-단계 enum (100/200/300/400)** — 한국 SI 표준
2.**ACC_CODE 회계 코드** — 한국 SI 표준
3.**변경 이력 (MOD_DATE / MOD_USER_ID)** — 한국 SI 표준
4.**DIVISION_YN** — 한국 SI 표준
5.**소프트 삭제 (DEL_FLAG)** — 한국 SI 표준
6.**부서장 user picker + 마스킹** — 한국 SI 표준
7.**`ITEM_CLS1~N` 비정규화 path** — 한국 SI 표준 (성능·breadcrumb 동시 해결)
특히 **`ITEM_CLS1~N` 비정규화 path** 는 양쪽 모두 채택한 만큼, invyone 도 이걸 따르면 깊은 트리 성능 + breadcrumb 표시를 한 번에 잡을 수 있음.
---
## C.7 참고 코드 위치
### KDSCont
```
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0030Controller.java (4-단계 grid)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0031Controller.java (트리뷰)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0032Controller.java (트리뷰 변형)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0020Controller.java (회사관리)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0010Controller.java (게이트관리)
src/main/resources/mappers/com/st_ones/eversrm/manager/org/MOGA0030~32Mapper.xml
src/main/webapp/WEB-INF/views/eversrm/manager/org/MOGA0030~32.jsp
```
### SRM_SAMYANGFOOD
```
src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java
src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java
src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java
src/main/java/com/st_ones/batch/gwDept/web/GwDept.java (그룹웨어 sync 배치)
```
@@ -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
+11 -2
View File
@@ -5,6 +5,9 @@ chcp 65001 >nul
pushd "%~dp0..\.."
set COMPOSE_FILE=docker\dev\docker-compose.invyone.yml
REM Windows 전용 override — Turbopack 끄고 webpack 으로 폴백해서 host 편집 자동 HMR 복원.
REM (Docker Desktop on Windows 의 bind mount inotify 미전파 이슈 회피)
set COMPOSE_WIN=docker\dev\docker-compose.windows.yml
where docker >nul 2>&1
if errorlevel 1 (
@@ -28,9 +31,15 @@ if not exist "%COMPOSE_FILE%" (
pause
exit /b 1
)
if not exist "%COMPOSE_WIN%" (
echo [invyone] Windows override 파일을 찾을 수 없음: %COMPOSE_WIN%
popd
pause
exit /b 1
)
echo [invyone] 도커 컨테이너 기동 중...
docker compose -f %COMPOSE_FILE% up -d
docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% up -d
if errorlevel 1 (
echo [invyone] 기동 실패. 로그를 확인해주세요.
popd
@@ -40,7 +49,7 @@ if errorlevel 1 (
echo.
echo [invyone] 컨테이너 상태:
docker compose -f %COMPOSE_FILE% ps
docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% ps
echo.
echo [invyone] 접속 URL: