368 lines
9.3 KiB
Markdown
368 lines
9.3 KiB
Markdown
|
|
# 권한 그룹 기반 메뉴 필터링 가이드
|
||
|
|
|
||
|
|
> 작성일: 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)
|
||
|
|
- 검토 필요: 백엔드 개발자, 시스템 아키텍트
|