# 권한 그룹 기반 메뉴 필터링 가이드

> 작성일: 2025-01-27  
> 파일 위치: `backend-node/src/services/adminService.ts`

---

## 📋 목차

1. [개요](#개요)
2. [메뉴 필터링 로직](#메뉴-필터링-로직)
3. [데이터베이스 구조](#데이터베이스-구조)
4. [구현 상세](#구현-상세)
5. [테스트 시나리오](#테스트-시나리오)

---

## 개요

### ✅ 구현 완료 (2025-01-27)

사용자가 좌측 사이드바에서 볼 수 있는 메뉴는 **권한 그룹 기반**으로 필터링됩니다:

1. 사용자가 속한 권한 그룹 조회 (`authority_sub_user`)
2. 해당 권한 그룹의 메뉴 권한 확인 (`rel_menu_auth`)
3. **`read_yn = 'Y'`인 메뉴만 사이드바에 표시**

---

## 메뉴 필터링 로직

### 흐름도

```mermaid
graph TD
    A[사용자 로그인] --> B{권한 그룹 조회}
    B -->|권한 그룹 있음| C[rel_menu_auth 조회]
    B -->|권한 그룹 없음| D[메뉴 없음]
    C --> E{read_yn = 'Y'?}
    E -->|Yes| F[메뉴 표시]
    E -->|No| G[메뉴 숨김]
```

### 주요 단계

1. **권한 그룹 조회**

   ```sql
   SELECT DISTINCT am.objid AS role_objid, am.auth_name
   FROM authority_master am
   JOIN authority_sub_user asu ON am.objid = asu.master_objid
   WHERE asu.user_id = $1
     AND am.status = 'active'
   ```

2. **메뉴 권한 필터링**

   ```sql
   AND EXISTS (
     SELECT 1
     FROM rel_menu_auth rma
     WHERE rma.menu_objid = MENU.OBJID
       AND rma.auth_objid = ANY($2)  -- 사용자의 권한 그룹 배열
       AND rma.read_yn = 'Y'          -- 읽기 권한이 있어야 함
   )
   ```

3. **회사별 필터링** (기존 로직 유지)
   - 최고 관리자: 공통 메뉴 (`company_code = '*'`)
   - 회사 관리자/일반 사용자: 자기 회사 메뉴만

---

## 데이터베이스 구조

### 관련 테이블

```sql
-- 1. 권한 그룹 마스터
authority_master (
  objid SERIAL PRIMARY KEY,
  auth_name VARCHAR(200),
  auth_code VARCHAR(100),
  company_code VARCHAR(50),
  status VARCHAR(20)
)

-- 2. 권한 그룹 멤버
authority_sub_user (
  objid SERIAL PRIMARY KEY,
  master_objid INTEGER,      -- FK to authority_master
  user_id VARCHAR(50)        -- 사용자 ID
)

-- 3. 메뉴 권한
rel_menu_auth (
  objid SERIAL PRIMARY KEY,
  menu_objid INTEGER,        -- FK to menu_info
  auth_objid INTEGER,        -- FK to authority_master
  create_yn VARCHAR(1),      -- 생성 권한
  read_yn VARCHAR(1),        -- 조회 권한 ⭐ 사이드바 표시 기준
  update_yn VARCHAR(1),      -- 수정 권한
  delete_yn VARCHAR(1),      -- 삭제 권한
  execute_yn VARCHAR(1),     -- 실행 권한
  export_yn VARCHAR(1)       -- 내보내기 권한
)

-- 4. 메뉴 정보
menu_info (
  objid SERIAL PRIMARY KEY,
  menu_name_kor VARCHAR(200),
  menu_url VARCHAR(500),
  parent_obj_id INTEGER,
  company_code VARCHAR(50),
  menu_type INTEGER,         -- 0: 관리자, 1: 사용자
  status VARCHAR(20)
)
```

### 관계도

```
user_info
  └─ authority_sub_user (user_id)
       └─ authority_master (master_objid)
            └─ rel_menu_auth (auth_objid)
                 └─ menu_info (menu_objid)
```

---

## 구현 상세

### AdminService.getUserMenuList()

**파일**: `backend-node/src/services/adminService.ts`

**로직**:

```typescript
static async getUserMenuList(paramMap: any): Promise<any[]> {
  const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;

  // 1. 사용자가 속한 권한 그룹 조회
  const userRoleGroups = await query<any>(
    `
    SELECT DISTINCT am.objid AS role_objid, am.auth_name
    FROM authority_master am
    JOIN authority_sub_user asu ON am.objid = asu.master_objid
    WHERE asu.user_id = $1
      AND am.status = 'active'
    `,
    [userId]
  );

  logger.info(`✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`);

  // 2. 권한 그룹 기반 메뉴 필터 조건 생성
  let authFilter = "";
  let queryParams: any[] = [userLang];
  let paramIndex = 2;

  if (userRoleGroups.length > 0) {
    // 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링
    const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
    authFilter = `
      AND EXISTS (
        SELECT 1
        FROM rel_menu_auth rma
        WHERE rma.menu_objid = MENU.OBJID
          AND rma.auth_objid = ANY($${paramIndex})
          AND rma.read_yn = 'Y'
      )
    `;
    queryParams.push(roleObjids);
    paramIndex++;
  } else {
    // 권한 그룹이 없는 경우: 메뉴 없음
    logger.warn(`⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`);
    return [];
  }

  // 3. 회사별 필터링 조건
  let companyFilter = "";
  if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
    companyFilter = `AND MENU.COMPANY_CODE = '*'`;
  } else {
    companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
    queryParams.push(userCompanyCode);
    paramIndex++;
  }

  // 4. 메뉴 조회 쿼리 (WITH RECURSIVE)
  const menuList = await query<any>(
    `
    WITH RECURSIVE v_menu(...) AS (
      SELECT ...
      FROM MENU_INFO MENU
      WHERE PARENT_OBJ_ID = 0
        AND MENU_TYPE = 1
        AND STATUS = 'active'
        ${companyFilter}
        ${authFilter}  -- ⭐ 권한 그룹 필터 적용

      UNION ALL

      SELECT ...
      FROM MENU_INFO MENU_SUB
      JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
      WHERE MENU_SUB.STATUS = 'active'
        ${authFilter.replace(/MENU\\.OBJID/g, 'MENU_SUB.OBJID')}  -- ⭐ 자식 메뉴에도 적용
    )
    SELECT ...
    FROM v_menu A
    ...
    ORDER BY PATH, SEQ
    `,
    queryParams
  );

  return menuList;
}
```

---

## 테스트 시나리오

### 시나리오 1: 최고 관리자가 권한 부여

**단계**:

1. 최고 관리자가 "테스트회사 2번"의 권한 그룹에 접속
2. "대시보드" 메뉴에 대해 `read_yn = 'Y'` 설정
3. 권한 저장

**결과**:

- ✅ 해당 권한 그룹에 속한 사용자들에게 "대시보드" 메뉴가 사이드바에 표시됨
- ✅ `read_yn = 'N'`인 다른 메뉴는 표시되지 않음

**로그 확인**:

```
✅ 사용자 user001가 속한 권한 그룹: 1개
  - 권한 그룹: ["테스트회사2 관리자"]
✅ 권한 그룹 기반 메뉴 필터링 적용: 1개 그룹
✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 20 메뉴만 표시
사용자 메뉴 목록 조회 결과: 5개
```

### 시나리오 2: 권한 그룹이 없는 사용자

**단계**:

1. 새로운 사용자 생성 (`user002`)
2. 권한 그룹에 추가하지 않음
3. 로그인

**결과**:

- ✅ 사이드바에 메뉴가 하나도 표시되지 않음

**로그 확인**:

```
✅ 사용자 user002가 속한 권한 그룹: 0개
⚠️ 사용자 user002는 권한 그룹이 없어 메뉴가 표시되지 않습니다.
사용자 메뉴 목록 조회 결과: 0개
```

### 시나리오 3: 여러 권한 그룹에 속한 사용자

**단계**:

1. 사용자 `user003`을 두 개의 권한 그룹에 추가
   - 그룹 A: "대시보드" 메뉴 (`read_yn = 'Y'`)
   - 그룹 B: "사용자 관리" 메뉴 (`read_yn = 'Y'`)
2. 로그인

**결과**:

- ✅ "대시보드"와 "사용자 관리" 메뉴가 모두 표시됨
- ✅ 두 그룹의 권한이 **OR 조건**으로 합쳐짐

**SQL 로직**:

```sql
AND EXISTS (
  SELECT 1
  FROM rel_menu_auth rma
  WHERE rma.menu_objid = MENU.OBJID
    AND rma.auth_objid = ANY(ARRAY[그룹A_ID, 그룹B_ID])  -- OR 조건
    AND rma.read_yn = 'Y'
)
```

### 시나리오 4: 회사 관리자 vs 일반 사용자

**공통점**:

- 둘 다 자기 회사 메뉴만 조회
- 권한 그룹 기반 필터링 적용

**차이점**:

- **회사 관리자 (COMPANY_ADMIN)**: 권한 그룹 관리 가능
- **일반 사용자 (USER)**: 권한 그룹 관리 불가 (읽기 전용)

---

## 주의사항

### 1. 메뉴 계층 구조

- 부모 메뉴에 `read_yn = 'Y'`가 있어야 자식 메뉴도 표시됨
- 자식 메뉴만 권한이 있어도 부모가 없으면 접근 불가

**예시**:

```
📁 시스템 관리 (read_yn = 'N')  ← 권한 없음
  └─ 📄 사용자 관리 (read_yn = 'Y')  ← 권한 있지만 부모가 없어서 접근 불가
```

**해결**:

- 부모 메뉴에도 `read_yn = 'Y'` 설정 필요

### 2. 권한 그룹 상태

- `authority_master.status = 'active'`인 그룹만 적용
- 비활성화된 그룹은 멤버가 있어도 권한 없음

### 3. 최고 관리자 예외

- 최고 관리자는 **공통 메뉴만** 조회
- 다른 회사 메뉴는 보이지 않음
- 최고 관리자도 권한 그룹에 속해야 메뉴가 보임 (일관성 유지)

### 4. 성능 고려사항

- `ANY($1)`: PostgreSQL 배열 연산자 사용으로 성능 최적화
- `EXISTS` 서브쿼리: 메뉴마다 권한 확인
- 인덱스 권장:
  ```sql
  CREATE INDEX idx_rel_menu_auth_menu ON rel_menu_auth(menu_objid);
  CREATE INDEX idx_rel_menu_auth_auth ON rel_menu_auth(auth_objid);
  CREATE INDEX idx_authority_sub_user_user ON authority_sub_user(user_id);
  ```

---

## 관련 파일

- `backend-node/src/services/adminService.ts` - `getUserMenuList()` 메서드
- `backend-node/src/services/roleService.ts` - 권한 그룹 관리
- `backend-node/src/controllers/adminController.ts` - API 엔드포인트
- `frontend/contexts/MenuContext.tsx` - 프론트엔드 메뉴 Context
- `frontend/lib/api/menu.ts` - 메뉴 API 클라이언트

---

## 📝 작성자

- 작성: AI Assistant (Claude Sonnet 4.5)
- 검토 필요: 백엔드 개발자, 시스템 아키텍트
