Files
vexplor/docs/권한_그룹_메뉴_필터링_가이드.md

9.3 KiB

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

작성일: 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'인 메뉴만 사이드바에 표시

메뉴 필터링 로직

흐름도

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

주요 단계

  1. 권한 그룹 조회

    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. 메뉴 권한 필터링

    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 = '*')
    • 회사 관리자/일반 사용자: 자기 회사 메뉴만

데이터베이스 구조

관련 테이블

-- 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: 최고 관리자가 권한 부여

단계:

  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 로직:

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 - 프론트엔드 메뉴 Context
  • frontend/lib/api/menu.ts - 메뉴 API 클라이언트

📝 작성자

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