9.3 KiB
9.3 KiB
권한 그룹 기반 메뉴 필터링 가이드
작성일: 2025-01-27
파일 위치:backend-node/src/services/adminService.ts
📋 목차
개요
✅ 구현 완료 (2025-01-27)
사용자가 좌측 사이드바에서 볼 수 있는 메뉴는 권한 그룹 기반으로 필터링됩니다:
- 사용자가 속한 권한 그룹 조회 (
authority_sub_user) - 해당 권한 그룹의 메뉴 권한 확인 (
rel_menu_auth) read_yn = 'Y'인 메뉴만 사이드바에 표시
메뉴 필터링 로직
흐름도
graph TD
A[사용자 로그인] --> B{권한 그룹 조회}
B -->|권한 그룹 있음| C[rel_menu_auth 조회]
B -->|권한 그룹 없음| D[메뉴 없음]
C --> E{read_yn = 'Y'?}
E -->|Yes| F[메뉴 표시]
E -->|No| G[메뉴 숨김]
주요 단계
-
권한 그룹 조회
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' -
메뉴 권한 필터링
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' -- 읽기 권한이 있어야 함 ) -
회사별 필터링 (기존 로직 유지)
- 최고 관리자: 공통 메뉴 (
company_code = '*') - 회사 관리자/일반 사용자: 자기 회사 메뉴만
- 최고 관리자: 공통 메뉴 (
데이터베이스 구조
관련 테이블
-- 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
로직:
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: 최고 관리자가 권한 부여
단계:
- 최고 관리자가 "테스트회사 2번"의 권한 그룹에 접속
- "대시보드" 메뉴에 대해
read_yn = 'Y'설정 - 권한 저장
결과:
- ✅ 해당 권한 그룹에 속한 사용자들에게 "대시보드" 메뉴가 사이드바에 표시됨
- ✅
read_yn = 'N'인 다른 메뉴는 표시되지 않음
로그 확인:
✅ 사용자 user001가 속한 권한 그룹: 1개
- 권한 그룹: ["테스트회사2 관리자"]
✅ 권한 그룹 기반 메뉴 필터링 적용: 1개 그룹
✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 20 메뉴만 표시
사용자 메뉴 목록 조회 결과: 5개
시나리오 2: 권한 그룹이 없는 사용자
단계:
- 새로운 사용자 생성 (
user002) - 권한 그룹에 추가하지 않음
- 로그인
결과:
- ✅ 사이드바에 메뉴가 하나도 표시되지 않음
로그 확인:
✅ 사용자 user002가 속한 권한 그룹: 0개
⚠️ 사용자 user002는 권한 그룹이 없어 메뉴가 표시되지 않습니다.
사용자 메뉴 목록 조회 결과: 0개
시나리오 3: 여러 권한 그룹에 속한 사용자
단계:
- 사용자
user003을 두 개의 권한 그룹에 추가- 그룹 A: "대시보드" 메뉴 (
read_yn = 'Y') - 그룹 B: "사용자 관리" 메뉴 (
read_yn = 'Y')
- 그룹 A: "대시보드" 메뉴 (
- 로그인
결과:
- ✅ "대시보드"와 "사용자 관리" 메뉴가 모두 표시됨
- ✅ 두 그룹의 권한이 OR 조건으로 합쳐짐
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서브쿼리: 메뉴마다 권한 확인- 인덱스 권장:
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- 프론트엔드 메뉴 Contextfrontend/lib/api/menu.ts- 메뉴 API 클라이언트
📝 작성자
- 작성: AI Assistant (Claude Sonnet 4.5)
- 검토 필요: 백엔드 개발자, 시스템 아키텍트