아래 화면과 같이 메뉴를 트리로 표시하고 원하는 메뉴만을 선택해 그룹으로 관리하는 방법을 알아봅니다.

먼저 메뉴 트리 구조와 메뉴 그룹을 표현할 모델부터 정의합니다.
/// <summary>
/// 메뉴 트리 구조를 표현하는 클래스
/// </summary>
public class Menu
{
/// <summary>
/// 메뉴의 고유 ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 부모 메뉴의 ID
/// 최상위 메뉴인 경우 null
/// </summary>
public int? ParentId { get; set; }
/// <summary>
/// 메뉴에 표시될 이름
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 메뉴가 펼쳐진 상태인지 여부
/// (MudBlazor TreeView, ExpansionPanel 등에서 사용)
/// </summary>
public bool IsExpanded { get; set; } = true;
/// <summary>
/// 하위 메뉴 목록
/// 재귀 구조로 메뉴 트리를 구성
/// </summary>
public List<Menu> Children { get; set; } = new();
}
/// <summary>
/// 여러 메뉴를 하나의 그룹으로 관리하기 위한 클래스
/// (권한, 역할, 카테고리 묶음 등에 활용 가능)
/// </summary>
public class MenuGroup
{
/// <summary>
/// 메뉴 그룹의 고유 ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 메뉴 그룹 이름
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 이 그룹에 포함된 메뉴 ID 집합
/// HashSet을 사용해 중복 방지
/// </summary>
public HashSet<int> MenuIds { get; set; } = new();
}
메인 화면에서는 좌측에 그룹 목록, 우측에 메뉴 트리를 배치합니다.
좌측 : 메뉴 그룹 목록 / 그룹 선택 시 우측 트리에 반영
우측 : 체크박스가 있는 메뉴 트리 / 다중 선택 가능
하단 : 그룹 추가 / 저장 버튼
@page "/menu-group-manager"
@using System.Linq
<!--
메뉴 그룹 관리 페이지
- 메뉴 그룹을 선택하고 해당 그룹에 메뉴를 할당
- 트리 구조로 메뉴를 표시하며 다중 선택 지원
- 그룹 추가 및 저장 기능 제공
-->
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<MudText Typo="Typo.h4" Class="mb-4">메뉴 그룹 관리</MudText>
<MudGrid>
<!-- 왼쪽: 메뉴 그룹 목록 -->
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Style="height: 600px; overflow-y: auto;">
<MudText Typo="Typo.h6" Class="mb-3">메뉴 그룹</MudText>
<MudList Clickable="true" @bind-SelectedValue="SelectedGroupId">
@foreach (var group in MenuGroups)
{
<MudListItem Value="@group.Id" OnClick="@(() => OnGroupSelected(group.Id))">
<MudText>@group.Name</MudText>
</MudListItem>
}
</MudList>
</MudPaper>
</MudItem>
<!-- 오른쪽: 메뉴 트리 선택 -->
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Style="height: 600px; overflow-y: auto;">
<MudText Typo="Typo.h6" Class="mb-3">메뉴 선택</MudText>
@if (SelectedGroup != null)
{
<!-- 다중 선택 가능한 트리 뷰 -->
<MudTreeView SelectedValues="SelectedMenuIds"
T="string"
SelectedValuesChanged="OnSelectedValuesChanged"
SelectionMode="SelectionMode.MultiSelection"
CheckBoxColor="Color.Primary">
@foreach (var menu in RootMenus)
{
<MudTreeViewItem @bind-Expanded="@menu.IsExpanded" Value="@menu.Id.ToString()"
Text="@menu.Name" CheckedChanged="@((bool? isChecked) => OnMenuCheckedChanged(menu, isChecked))">
@RenderMenuChildren(menu)
</MudTreeViewItem>
}
</MudTreeView>
}
else
{
<MudText Color="Color.Secondary">그룹을 선택하세요.</MudText>
}
</MudPaper>
</MudItem>
</MudGrid>
<!-- 하단: 액션 버튼 -->
<MudGrid Class="mt-4">
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OpenAddGroupDialog" Class="mr-2">
그룹 추가
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Success" OnClick="SaveGroup" Disabled="@(SelectedGroup == null)">
저장
</MudButton>
</MudItem>
</MudGrid>
</MudContainer>
@code {
#region Fields & Properties
/// <summary>
/// 전체 메뉴 목록 (계층 구조를 포함한 모든 메뉴)
/// </summary>
private List<Menu> AllMenus = new();
/// <summary>
/// 최상위 메뉴 목록 (ParentId가 null인 메뉴)
/// </summary>
private List<Menu> RootMenus = new();
/// <summary>
/// 메뉴 그룹 목록
/// </summary>
private List<MenuGroup> MenuGroups = new();
/// <summary>
/// 현재 선택된 메뉴 그룹
/// </summary>
private MenuGroup? SelectedGroup;
/// <summary>
/// 선택된 그룹의 ID (MudList 바인딩용)
/// </summary>
private object? SelectedGroupId;
/// <summary>
/// 선택된 메뉴 ID 컬렉션 (트리 뷰 체크박스 상태)
/// </summary>
private IReadOnlyCollection<string> SelectedMenuIds = new HashSet<string>();
/// <summary>
/// 다이얼로그 서비스 (그룹 추가 다이얼로그 표시용)
/// </summary>
[Inject] private IDialogService DialogService { get; set; } = null!;
/// <summary>
/// 스낵바 서비스 (알림 메시지 표시용)
/// </summary>
[Inject] private ISnackbar Snackbar { get; set; } = null!;
#endregion
#region Lifecycle Methods
/// <summary>
/// 컴포넌트 초기화 시 호출
/// 샘플 데이터를 로드하고 메뉴 트리를 구성
/// </summary>
protected override void OnInitialized()
{
InitializeSampleData();
BuildMenuTree();
}
#endregion
#region Data Initialization
/// <summary>
/// 샘플 메뉴 및 그룹 데이터 초기화
/// </summary>
private void InitializeSampleData()
{
// 샘플 메뉴 데이터
AllMenus = new List<Menu>
{
// 1단계 (최상위 메뉴)
new Menu { Id = 1, ParentId = null, Name = "시스템 관리" },
new Menu { Id = 2, ParentId = null, Name = "사용자 관리" },
new Menu { Id = 3, ParentId = null, Name = "게시판 관리" },
// 2단계 (시스템 관리 하위)
new Menu { Id = 11, ParentId = 1, Name = "코드 관리" },
new Menu { Id = 12, ParentId = 1, Name = "권한 관리" },
new Menu { Id = 13, ParentId = 1, Name = "메뉴 관리" },
// 2단계 (사용자 관리 하위)
new Menu { Id = 21, ParentId = 2, Name = "회원 목록" },
new Menu { Id = 22, ParentId = 2, Name = "권한 설정" },
// 2단계 (게시판 관리 하위)
new Menu { Id = 31, ParentId = 3, Name = "공지사항" },
new Menu { Id = 32, ParentId = 3, Name = "자료실" },
// 3단계 (권한 관리 하위)
new Menu { Id = 121, ParentId = 12, Name = "역할 관리" },
new Menu { Id = 122, ParentId = 12, Name = "권한 매핑" },
// 3단계 (공지사항 하위)
new Menu { Id = 311, ParentId = 31, Name = "일반 공지" },
new Menu { Id = 312, ParentId = 31, Name = "긴급 공지" }
};
// 샘플 그룹 데이터
MenuGroups = new List<MenuGroup>
{
new MenuGroup
{
Id = 1,
Name = "관리자 그룹",
MenuIds = new HashSet<int> { 1, 11, 12, 13, 121, 122, 2, 21, 22, 3, 31, 311, 312, 32 }
},
new MenuGroup
{
Id = 2,
Name = "사용자 그룹",
MenuIds = new HashSet<int> { 3, 31, 311, 32 }
}
};
}
/// <summary>
/// 메뉴 계층 구조 구성
/// ParentId를 기반으로 부모-자식 관계를 설정하고 최상위 메뉴를 추출
/// </summary>
private void BuildMenuTree()
{
// 각 메뉴를 순회하며 부모 메뉴에 자식으로 추가
foreach (var menu in AllMenus)
{
if (menu.ParentId.HasValue)
{
var parent = AllMenus.FirstOrDefault(m => m.Id == menu.ParentId.Value);
parent?.Children.Add(menu);
}
}
// 최상위 메뉴만 필터링 (ParentId가 null인 메뉴)
RootMenus = AllMenus.Where(m => !m.ParentId.HasValue).ToList();
}
#endregion
#region Event Handlers
/// <summary>
/// 메뉴 그룹 선택 시 호출
/// 선택된 그룹의 메뉴 ID를 트리 뷰에 반영
/// </summary>
/// <param name="groupId">선택된 그룹 ID</param>
private void OnGroupSelected(int groupId)
{
SelectedGroup = MenuGroups.FirstOrDefault(g => g.Id == groupId);
if (SelectedGroup != null)
{
// 그룹에 할당된 메뉴 ID를 문자열로 변환하여 선택 상태로 설정
SelectedMenuIds = new HashSet<string>(SelectedGroup.MenuIds.Select(id => id.ToString()));
}
}
/// <summary>
/// 트리 뷰의 선택 값이 변경될 때 호출
/// </summary>
/// <param name="values">선택된 메뉴 ID 컬렉션</param>
private void OnSelectedValuesChanged(IReadOnlyCollection<string> values)
{
SelectedMenuIds = values;
}
/// <summary>
/// 메뉴 체크박스 상태 변경 시 호출
/// 선택된 메뉴와 모든 하위 메뉴를 함께 체크/해제
/// </summary>
/// <param name="menu">체크 상태가 변경된 메뉴</param>
/// <param name="isChecked">체크 여부 (true: 체크, false: 해제, null: indeterminate)</param>
private void OnMenuCheckedChanged(Menu menu, bool? isChecked)
{
var menuSet = new HashSet<string>(SelectedMenuIds);
if (isChecked == true)
{
// 체크: 해당 메뉴와 모든 하위 메뉴 추가
CheckMenuAndChildren(menu, menuSet);
}
else if (isChecked == false)
{
// 해제: 해당 메뉴와 모든 하위 메뉴 제거
UncheckMenuAndChildren(menu, menuSet);
}
SelectedMenuIds = menuSet;
}
#endregion
#region Helper Methods
/// <summary>
/// 메뉴와 모든 하위 메뉴를 선택 상태로 설정 (재귀)
/// </summary>
/// <param name="menu">체크할 메뉴</param>
/// <param name="menuSet">선택된 메뉴 ID 집합</param>
private void CheckMenuAndChildren(Menu menu, HashSet<string> menuSet)
{
menuSet.Add(menu.Id.ToString());
foreach (var child in menu.Children)
{
CheckMenuAndChildren(child, menuSet);
}
}
/// <summary>
/// 메뉴와 모든 하위 메뉴를 선택 해제 상태로 설정 (재귀)
/// </summary>
/// <param name="menu">체크 해제할 메뉴</param>
/// <param name="menuSet">선택된 메뉴 ID 집합</param>
private void UncheckMenuAndChildren(Menu menu, HashSet<string> menuSet)
{
menuSet.Remove(menu.Id.ToString());
foreach (var child in menu.Children)
{
UncheckMenuAndChildren(child, menuSet);
}
}
/// <summary>
/// 메뉴의 하위 메뉴를 재귀적으로 렌더링
/// RenderFragment를 사용하여 동적으로 트리 구조 생성
/// </summary>
/// <param name="parentMenu">부모 메뉴</param>
/// <returns>하위 메뉴를 렌더링하는 RenderFragment</returns>
private RenderFragment RenderMenuChildren(Menu parentMenu) => builder =>
{
foreach (var child in parentMenu.Children)
{
// MudTreeViewItem 컴포넌트 생성
builder.OpenComponent<MudTreeViewItem<string>>(0);
builder.AddAttribute(1, "Value", child.Id.ToString());
builder.AddAttribute(2, "Text", child.Name);
builder.AddAttribute(3, "Expanded", child.IsExpanded);
builder.AddAttribute(4, "CheckedChanged", EventCallback.Factory.Create<bool?>(this, (isChecked) => OnMenuCheckedChanged(child, isChecked)));
// 하위 메뉴가 있는 경우 재귀적으로 렌더링
if (child.Children.Any())
{
builder.AddAttribute(5, "ChildContent", RenderMenuChildren(child));
}
builder.CloseComponent();
}
};
#endregion
#region Dialog & Save Actions
/// <summary>
/// 그룹 추가 다이얼로그 열기
/// 현재 선택된 그룹의 메뉴를 복사할 수 있는 옵션 제공
/// </summary>
private async Task OpenAddGroupDialog()
{
var parameters = new DialogParameters<AddGroupDialog>
{
{ "CopyFromGroup", SelectedGroup }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<AddGroupDialog>("그룹 추가", parameters, options);
var result = await dialog.Result;
// 다이얼로그가 취소되지 않고 새 그룹 데이터가 있는 경우
if (result != null && !result.Canceled && result.Data is MenuGroup newGroup)
{
// 새 그룹 ID 생성 (기존 최대값 + 1)
newGroup.Id = MenuGroups.Any() ? MenuGroups.Max(g => g.Id) + 1 : 1;
MenuGroups.Add(new MenuGroup
{
Id = newGroup.Id,
Name = newGroup.Name,
MenuIds = newGroup.MenuIds
});
Snackbar.Add($"'{newGroup.Name}' 그룹이 추가되었습니다.", Severity.Success);
}
}
/// <summary>
/// 현재 선택된 그룹의 메뉴 할당 정보 저장
/// </summary>
private void SaveGroup()
{
if (SelectedGroup != null)
{
// 선택된 메뉴 ID를 int로 변환하여 그룹에 저장
SelectedGroup.MenuIds = new HashSet<int>(
SelectedMenuIds.Select(id => int.Parse(id))
);
Snackbar.Add($"'{SelectedGroup.Name}' 그룹이 저장되었습니다.", Severity.Success);
}
}
#endregion
}
@using MudBlazor
<MudDialog>
<DialogContent>
<MudContainer Class="pa-0">
<!-- 다이얼로그 안내 문구 -->
<MudText Typo="Typo.body1" Class="mb-4">
새로운 메뉴 그룹을 추가합니다.
</MudText>
<!-- 그룹명 입력 필드 -->
<MudTextField @bind-Value="GroupName"
Label="그룹명"
Variant="Variant.Outlined"
Required="true"
Placeholder="그룹명을 입력하세요"
HelperText="예: 운영자 그룹, 일반 사용자 그룹"
Immediate="true"
Class="mb-4" />
@* 기존 그룹에서 메뉴를 복사하는 경우 *@
@if (CopyFromGroup != null)
{
<!-- 복사 대상 그룹이 있을 때 안내 메시지 -->
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
<MudText Typo="Typo.body2">
<strong>@CopyFromGroup.Name</strong> 그룹의 메뉴 설정이 복사됩니다.
(@CopyFromGroup.MenuIds.Count 개 메뉴)
</MudText>
</MudAlert>
}
else
{
<!-- 복사 대상 그룹이 없을 때 안내 메시지 -->
<MudAlert Severity="Severity.Normal" Dense="true" Class="mt-3">
<MudText Typo="Typo.body2">
선택된 메뉴 없이 빈 그룹이 생성됩니다.
</MudText>
</MudAlert>
}
</MudContainer>
</DialogContent>
<!-- 다이얼로그 하단 버튼 영역 -->
<DialogActions>
<!-- 취소 버튼 -->
<MudButton OnClick="Cancel" Variant="Variant.Text">
취소
</MudButton>
<!-- 그룹 추가 버튼 -->
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="AddGroup"
Disabled="@(string.IsNullOrWhiteSpace(GroupName))">
추가
</MudButton>
</DialogActions>
</MudDialog>
@code {
#region Fields & Properties
/// <summary>
/// MudDialog 인스턴스 (닫기, 취소 제어용)
/// </summary>
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// 메뉴 설정을 복사할 대상 그룹
/// null이면 빈 그룹 생성
/// </summary>
[Parameter]
public MenuGroup? CopyFromGroup { get; set; }
/// <summary>
/// 새로 생성할 그룹명
/// </summary>
private string GroupName { get; set; } = string.Empty;
#endregion
#region Cancel
/// <summary>
/// 다이얼로그 취소 처리
/// </summary>
private void Cancel()
{
MudDialog.Cancel();
}
#endregion
#region AddGroup
/// <summary>
/// 메뉴 그룹 추가 처리
/// </summary>
private void AddGroup()
{
// 그룹명이 비어 있으면 처리 중단
if (string.IsNullOrWhiteSpace(GroupName))
{
return;
}
// 새 메뉴 그룹 생성
var newGroup = new MenuGroup
{
Name = GroupName.Trim(),
// 기존 그룹이 있으면 메뉴 ID 복사
MenuIds = CopyFromGroup != null
? new HashSet<int>(CopyFromGroup.MenuIds)
: new HashSet<int>()
};
// 결과와 함께 다이얼로그 닫기
MudDialog.Close(DialogResult.Ok(newGroup));
}
#endregion
}
결과

| BlazorDatasheet 에서 대문자 / 소문자만 입력되도록 처리하기 (0) | 2026.02.02 |
|---|---|
| Blazor 인쇄 막는 방법 (0) | 2025.12.17 |
| MudSelectExtended 로 가상화 처리하기 (0) | 2025.12.09 |
| MudBlazor 의 Mask 속성 이용한 입력 문자 제한하기 (0) | 2025.12.09 |
| MudBlazor 의 MudTextField 소문자를 대문자로 변경하기 (0) | 2025.12.09 |