Files
sam-docs/plans/mng-menu-system-plan.md
hskwon 54d2eb5835 docs: 마이그레이션 계획 및 메뉴 시스템 문서 업데이트
- 5130 마이그레이션 현황 업데이트
- mng 메뉴 시스템 Phase 1-3 완료 체크
2025-12-16 23:38:30 +09:00

27 KiB

MNG 메뉴 시스템: DB 기반 동적 메뉴 전환 계획

작성일: 2025-12-16 수정일: 2025-12-16 (Laravel 12 미들웨어, JSON options 컬럼 방식으로 변경) 목적: mng 사이드바를 DB 기반으로 전환하여 직원별 권한에 따라 메뉴 동적 표시 선택: Option A - DB 메뉴 기반


1. 현재 시스템 분석

1.1 현재 구조 (AS-IS)

┌─────────────────────────────────────────────────────────────────┐
│                    현재 mng 메뉴 시스템                          │
├─────────────────────────────────────────────────────────────────┤
│  sidebar.blade.php (하드코딩)                                   │
│    ├── 일반 메뉴 (7개 그룹)                                      │
│    ├── 개발도구 메뉴                                             │
│    └── R&D Labs 메뉴                                            │
├─────────────────────────────────────────────────────────────────┤
│  권한 체크: hq.member 미들웨어만 (HQ 소속 확인)                   │
│  메뉴별 권한 체크: 없음 (전체 접근)                               │
└─────────────────────────────────────────────────────────────────┘

1.2 목표 구조 (TO-BE)

┌─────────────────────────────────────────────────────────────────┐
│                    목표 mng 메뉴 시스템                          │
├─────────────────────────────────────────────────────────────────┤
│  DB (menus 테이블 + options JSON 컬럼, tenant_id=1)             │
│    ├── 일반 메뉴 (역할/부서/개인 권한으로 제어)                   │
│    ├── 개발도구 메뉴 (슈퍼관리자 전용)                           │
│    └── R&D Labs 메뉴 (슈퍼관리자 전용)                          │
├─────────────────────────────────────────────────────────────────┤
│  동적 사이드바 렌더링: SidebarMenuService → Blade Component      │
│  권한 체크: 메뉴별 permission (menu:{id}.view)                   │
└─────────────────────────────────────────────────────────────────┘

1.3 DB 설계 방식 비교

방식 장점 단점
별도 테이블 menus 완전 무수정 JOIN 필요, 테이블 관리
JSON 컬럼 JOIN 불필요, 유연한 확장, 범용 menus 수정 (안전)

선택: JSON 컬럼 방식

  • nullable JSON 컬럼 추가는 기존 코드에 영향 없음
  • Laravel의 JSON 캐스팅으로 편리한 사용
  • 나중에 API, React에서도 활용 가능

1.4 DB 테이블 구조

menus 테이블 (기존 + options 컬럼 추가)

id, tenant_id, parent_id, global_menu_id
name, url, icon, sort_order
is_active, hidden, is_customized, is_external, external_url
options (JSON, nullable) ← 신규 추가
created_by, updated_by, deleted_by, created_at, updated_at, deleted_at

options JSON 구조 (범용 설계)

{
  "route_name": "dashboard",
  "section": "main",
  "menu_type": "normal",
  "requires_role": null,
  "blade_component": null,
  "css_class": null,
  "meta": {}
}
필드 타입 설명 예시
route_name string Laravel 라우트 이름 "pm.projects.index"
section string 메뉴 섹션 위치 "main", "tools", "labs"
menu_type string 메뉴 유형 "normal", "tool", "lab"
requires_role string 필요 역할 "super_admin", null
blade_component string 커스텀 컴포넌트 "menus.custom-item"
css_class string 추가 CSS 클래스 "text-red-500"
meta object 앱별 추가 데이터 {"tab": "s"}

범용 설계 원칙: mng 고유 필드는 meta에 저장, 공통 필드만 최상위에 배치

permissions 테이블 (Spatie)

id, tenant_id, name, guard_name, created_at, updated_at

권한 연결 테이블

  • role_has_permissions: 역할-권한 매핑
  • department_permissions: 부서-권한 매핑 (is_allowed)
  • user_permission_overrides: 개인-권한 오버라이드 (is_allowed)

2. 권한 체계 설계

2.1 권한 우선순위

개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할 권한 > 기본 거부

2.2 메뉴 권한 명명 규칙

menu:{menu_id}.view       # 메뉴 조회 (사이드바 표시)
menu:{menu_id}.create     # 생성 권한
menu:{menu_id}.update     # 수정 권한
menu:{menu_id}.delete     # 삭제 권한

2.3 특수 메뉴 처리

메뉴 유형 권한 처리
일반 메뉴 역할/부서/개인 권한으로 제어
개발도구 options.requires_role = "super_admin"
R&D Labs options.requires_role = "super_admin"

3. 개발 계획

Phase 1: DB 스키마 및 시딩 (1-2일)

3.1.1 options 컬럼 마이그레이션

// database/migrations/xxxx_add_options_to_menus_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('menus', function (Blueprint $table) {
            $table->json('options')->nullable()
                  ->after('external_url')
                  ->comment('확장 옵션 (JSON): route_name, section, menu_type, requires_role, meta 등');
        });
    }

    public function down(): void
    {
        Schema::table('menus', function (Blueprint $table) {
            $table->dropColumn('options');
        });
    }
};

3.1.2 Menu 모델 수정 (API)

// api/app/Models/Commons/Menu.php
<?php

namespace App\Models\Commons;

// ... 기존 코드

class Menu extends Model
{
    protected $fillable = [
        'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order',
        'hidden', 'is_customized', 'is_external', 'external_url', 'icon',
        'options',  // 추가
        'created_by', 'updated_by', 'deleted_by',
    ];

    protected $casts = [
        'is_active' => 'boolean',
        'hidden' => 'boolean',
        'is_customized' => 'boolean',
        'is_external' => 'boolean',
        'options' => 'array',  // 추가
    ];

    // 헬퍼 메서드 (선택적)
    public function getOption(string $key, mixed $default = null): mixed
    {
        return data_get($this->options, $key, $default);
    }

    public function getRouteName(): ?string
    {
        return $this->getOption('route_name');
    }

    public function getSection(): string
    {
        return $this->getOption('section', 'main');
    }

    public function getMenuType(): string
    {
        return $this->getOption('menu_type', 'normal');
    }

    public function requiresRole(): ?string
    {
        return $this->getOption('requires_role');
    }
}

3.1.3 mng 메뉴 시더 생성

// mng/database/seeders/MngMenuSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Menu;
use Illuminate\Database\Seeder;

class MngMenuSeeder extends Seeder
{
    public function run(): void
    {
        $tenantId = 1; // HQ 테넌트

        $menus = [
            // 일반 메뉴
            [
                'name' => '대시보드',
                'url' => '/dashboard',
                'icon' => 'home',
                'options' => [
                    'route_name' => 'dashboard',
                    'section' => 'main',
                    'menu_type' => 'normal',
                ],
            ],

            // 그룹: 프로젝트 관리
            [
                'name' => '프로젝트 관리',
                'url' => null,
                'icon' => 'folder',
                'options' => [
                    'section' => 'main',
                    'menu_type' => 'normal',
                ],
                'children' => [
                    [
                        'name' => '프로젝트 대시보드',
                        'url' => '/project-management',
                        'options' => ['route_name' => 'pm.index'],
                    ],
                    [
                        'name' => '프로젝트',
                        'url' => '/project-management/projects',
                        'options' => ['route_name' => 'pm.projects.index'],
                    ],
                    [
                        'name' => '일일 스크럼',
                        'url' => '/daily-logs',
                        'options' => ['route_name' => 'daily-logs.index'],
                    ],
                ],
            ],

            // ... 기타 메뉴 그룹

            // 개발도구 (슈퍼관리자 전용)
            [
                'name' => '개발 도구',
                'url' => null,
                'icon' => 'cog',
                'options' => [
                    'section' => 'tools',
                    'menu_type' => 'tool',
                    'requires_role' => 'super_admin',
                ],
                'children' => [
                    [
                        'name' => 'API 플로우 테스터',
                        'url' => '/dev-tools/flow-tester',
                        'options' => [
                            'route_name' => 'dev-tools.flow-tester.index',
                            'requires_role' => 'super_admin',
                        ],
                    ],
                    [
                        'name' => 'API 요청 로그',
                        'url' => '/dev-tools/api-logs',
                        'options' => [
                            'route_name' => 'dev-tools.api-logs.index',
                            'requires_role' => 'super_admin',
                        ],
                    ],
                ],
            ],

            // R&D Labs (슈퍼관리자 전용)
            [
                'name' => 'R&D Labs',
                'url' => null,
                'icon' => 'beaker',
                'options' => [
                    'section' => 'labs',
                    'menu_type' => 'lab',
                    'requires_role' => 'super_admin',
                ],
                'children' => [
                    // 하위 메뉴들 (meta에 앱별 데이터 저장 가능)
                    [
                        'name' => 'S Lab',
                        'url' => '/labs/s',
                        'options' => [
                            'route_name' => 'labs.s.index',
                            'requires_role' => 'super_admin',
                            'meta' => ['tab' => 's'],
                        ],
                    ],
                ],
            ],
        ];

        $this->seedMenus($tenantId, $menus);
    }

    private function seedMenus(int $tenantId, array $menus, ?int $parentId = null): void
    {
        foreach ($menus as $index => $menuData) {
            $children = $menuData['children'] ?? [];
            unset($menuData['children']);

            // 메뉴 생성
            $menu = Menu::create([
                'tenant_id' => $tenantId,
                'parent_id' => $parentId,
                'name' => $menuData['name'],
                'url' => $menuData['url'],
                'icon' => $menuData['icon'] ?? null,
                'sort_order' => $index,
                'is_active' => true,
                'options' => $menuData['options'] ?? null,
            ]);

            // 자식 메뉴 재귀 처리
            if (!empty($children)) {
                $this->seedMenus($tenantId, $children, $menu->id);
            }
        }
    }
}

Phase 2: 사용자별 메뉴 조회 서비스 (2-3일)

3.2.1 SidebarMenuService 생성

// mng/app/Services/SidebarMenuService.php
<?php

namespace App\Services;

use App\Models\Menu;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class SidebarMenuService
{
    /**
     * 현재 사용자가 접근 가능한 메뉴 트리 조회
     */
    public function getUserMenuTree(?User $user = null): Collection
    {
        $user = $user ?? auth()->user();
        $tenantId = session('selected_tenant_id', 1);

        // 1. 테넌트의 모든 활성 메뉴 조회
        $allMenus = Menu::where('tenant_id', $tenantId)
            ->where('is_active', true)
            ->where('hidden', false)
            ->orderBy('sort_order')
            ->get();

        // 2. 슈퍼관리자는 모든 메뉴 표시
        if ($user->is_super_admin) {
            return $this->buildMenuTree($allMenus);
        }

        // 3. 일반 사용자: 권한 기반 필터링
        $permittedMenuIds = $this->getPermittedMenuIds($user, $tenantId);

        // 4. 역할 필요 메뉴 및 특수 메뉴 제외
        $filteredMenus = $allMenus->filter(function ($menu) use ($permittedMenuIds, $user) {
            // requires_role 체크
            $requiredRole = $menu->getOption('requires_role');
            if ($requiredRole && !$this->hasRole($user, $requiredRole)) {
                return false;
            }

            // 권한 체크
            return in_array($menu->id, $permittedMenuIds);
        });

        return $this->buildMenuTree($filteredMenus);
    }

    /**
     * 섹션별 메뉴 조회 (main, tools, labs)
     */
    public function getMenusBySection(?User $user = null): array
    {
        $menuTree = $this->getUserMenuTree($user);

        return [
            'main' => $menuTree->filter(fn($m) => $m->getSection() === 'main')->values(),
            'tools' => $menuTree->filter(fn($m) => $m->getSection() === 'tools')->values(),
            'labs' => $menuTree->filter(fn($m) => $m->getSection() === 'labs')->values(),
        ];
    }

    /**
     * 역할 확인
     */
    private function hasRole(User $user, string $role): bool
    {
        return match ($role) {
            'super_admin' => $user->is_super_admin,
            default => $user->hasRole($role),
        };
    }

    /**
     * 사용자가 접근 가능한 메뉴 ID 목록 조회
     * 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할
     */
    private function getPermittedMenuIds(User $user, int $tenantId): array
    {
        // 역할 기반 권한
        $rolePermissions = $this->getRoleMenuPermissions($user, $tenantId);

        // 부서 기반 권한 (ALLOW/DENY)
        $deptPermissions = $this->getDepartmentMenuPermissions($user, $tenantId);

        // 개인 오버라이드 (ALLOW/DENY)
        $userOverrides = $this->getUserMenuOverrides($user, $tenantId);

        // 권한 병합 (우선순위 적용)
        return $this->mergePermissions($rolePermissions, $deptPermissions, $userOverrides);
    }

    private function getRoleMenuPermissions(User $user, int $tenantId): array
    {
        // menu:*.view 형식의 권한에서 메뉴 ID 추출
        return $user->getPermissionsViaRoles()
            ->filter(fn($p) => str_starts_with($p->name, 'menu:') && str_ends_with($p->name, '.view'))
            ->pluck('name')
            ->map(fn($name) => (int) explode('.', explode(':', $name)[1])[0])
            ->toArray();
    }

    private function getDepartmentMenuPermissions(User $user, int $tenantId): array
    {
        if (!$user->department_id) {
            return [];
        }

        return DB::table('department_permissions')
            ->where('department_id', $user->department_id)
            ->where('permission_id', 'LIKE', 'menu:%')
            ->get()
            ->mapWithKeys(fn($row) => [
                (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed
            ])
            ->toArray();
    }

    private function getUserMenuOverrides(User $user, int $tenantId): array
    {
        return DB::table('user_permission_overrides')
            ->where('user_id', $user->id)
            ->where('permission_id', 'LIKE', 'menu:%')
            ->get()
            ->mapWithKeys(fn($row) => [
                (int) explode('.', explode(':', $row->permission_id)[1])[0] => $row->is_allowed
            ])
            ->toArray();
    }

    private function mergePermissions(array $role, array $dept, array $user): array
    {
        $allMenuIds = array_unique(array_merge(
            $role,
            array_keys($dept),
            array_keys($user)
        ));

        $permitted = [];

        foreach ($allMenuIds as $menuId) {
            // 우선순위: 개인 DENY > 개인 ALLOW > 부서 DENY > 부서 ALLOW > 역할
            if (isset($user[$menuId])) {
                if ($user[$menuId]) {
                    $permitted[] = $menuId;
                }
                continue;
            }

            if (isset($dept[$menuId])) {
                if ($dept[$menuId]) {
                    $permitted[] = $menuId;
                }
                continue;
            }

            if (in_array($menuId, $role)) {
                $permitted[] = $menuId;
            }
        }

        return $permitted;
    }

    private function buildMenuTree(Collection $menus, ?int $parentId = null): Collection
    {
        return $menus->where('parent_id', $parentId)
            ->map(function ($menu) use ($menus) {
                $menu->children = $this->buildMenuTree($menus, $menu->id);
                return $menu;
            });
    }
}

Phase 3: 동적 사이드바 컴포넌트 (2-3일)

3.3.1 Blade 컴포넌트 구조

resources/views/
├── components/
│   └── sidebar/
│       ├── menu-tree.blade.php      # 메뉴 트리 전체
│       ├── menu-group.blade.php     # 그룹 (접기/펼치기)
│       ├── menu-item.blade.php      # 개별 메뉴 아이템
│       └── menu-icon.blade.php      # 아이콘 렌더링
└── partials/
    └── sidebar-dynamic.blade.php    # 동적 사이드바 (기존 대체)

3.3.2 메뉴 트리 컴포넌트

{{-- components/sidebar/menu-tree.blade.php --}}
@props(['menus'])

<ul class="space-y-1">
    @foreach($menus as $menu)
        @if($menu->children->isNotEmpty())
            <x-sidebar.menu-group :menu="$menu" />
        @else
            <x-sidebar.menu-item :menu="$menu" />
        @endif
    @endforeach
</ul>

3.3.3 ViewServiceProvider에서 메뉴 공유

// mng/app/Providers/ViewServiceProvider.php
<?php

namespace App\Providers;

use App\Services\SidebarMenuService;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class ViewServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        View::composer('partials.sidebar-dynamic', function ($view) {
            $menuService = app(SidebarMenuService::class);
            $menusBySection = $menuService->getMenusBySection();

            $view->with([
                'mainMenus' => $menusBySection['main'],
                'toolsMenus' => $menusBySection['tools'],
                'labsMenus' => $menusBySection['labs'],
            ]);
        });
    }
}

Phase 4: 라우트 권한 미들웨어 (1-2일)

3.4.1 메뉴 권한 체크 미들웨어

// mng/app/Http/Middleware/CheckMenuPermission.php
<?php

namespace App\Http\Middleware;

use App\Models\Menu;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckMenuPermission
{
    public function handle(Request $request, Closure $next, ?string $permission = null): Response
    {
        $user = $request->user();

        // 슈퍼관리자는 패스
        if ($user->is_super_admin) {
            return $next($request);
        }

        // 라우트 이름으로 메뉴 찾기 (options JSON에서)
        $routeName = $request->route()->getName();
        $menu = Menu::where('tenant_id', session('selected_tenant_id', 1))
            ->whereJsonContains('options->route_name', $routeName)
            ->first();

        if (!$menu) {
            return $next($request); // 메뉴 등록 안 된 라우트는 패스
        }

        // requires_role 체크
        $requiredRole = $menu->getOption('requires_role');
        if ($requiredRole) {
            if ($requiredRole === 'super_admin' && !$user->is_super_admin) {
                abort(403, '슈퍼관리자만 접근 가능합니다.');
            }
            if ($requiredRole !== 'super_admin' && !$user->hasRole($requiredRole)) {
                abort(403, '접근 권한이 없습니다.');
            }
        }

        $permissionName = $permission ?? "menu:{$menu->id}.view";

        if (!$user->can($permissionName)) {
            abort(403, '접근 권한이 없습니다.');
        }

        return $next($request);
    }
}

3.4.2 미들웨어 등록 (Laravel 12 방식)

// mng/bootstrap/app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'hq.member' => \App\Http\Middleware\EnsureHQMember::class,
            'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
            'password.changed' => \App\Http\Middleware\EnsurePasswordChanged::class,
            // 신규 메뉴 권한 미들웨어 추가
            'menu.permission' => \App\Http\Middleware\CheckMenuPermission::class,
        ]);
    })
    ->withExceptions(function ($exceptions) {
        //
    })
    ->create();

3.4.3 라우트에 미들웨어 적용

// routes/web.php
Route::middleware(['auth', 'hq.member', 'menu.permission'])->group(function () {
    // 기존 라우트들...
});

4. 마이그레이션 전략

4.1 단계별 전환

Phase 1: 준비 (하드코딩 + DB 병행)
├── menus 테이블에 options 컬럼 추가
├── mng 메뉴 시딩
├── SidebarMenuService 개발
└── 기존 sidebar.blade.php 유지

Phase 2: 테스트 (환경변수로 전환)
├── .env에 MNG_DYNAMIC_SIDEBAR=false
├── 동적 사이드바 개발 완료
└── 슈퍼관리자만 동적 사이드바 테스트

Phase 3: 전환 (동적 사이드바 활성화)
├── MNG_DYNAMIC_SIDEBAR=true
├── 권한 시딩 및 역할 배정
└── 기존 sidebar.blade.php 백업

Phase 4: 안정화
├── 하드코딩 사이드바 제거
├── 권한 관리 UI 활성화
└── 문서화 완료

4.2 롤백 계획

// partials/sidebar.blade.php
@if(config('app.mng_dynamic_sidebar', false))
    @include('partials.sidebar-dynamic')
@else
    @include('partials.sidebar-static')  // 기존 하드코딩
@endif

5. 파일 변경 목록

신규 생성

파일 설명
api/database/migrations/xxxx_add_options_to_menus.php options 컬럼 추가
mng/database/seeders/MngMenuSeeder.php mng 메뉴 시더
mng/database/seeders/MngMenuPermissionSeeder.php mng 메뉴 권한 시더
mng/app/Services/SidebarMenuService.php 사용자별 메뉴 조회
mng/app/Http/Middleware/CheckMenuPermission.php 메뉴 권한 미들웨어
mng/app/Providers/ViewServiceProvider.php 뷰 컴포저
mng/resources/views/partials/sidebar-dynamic.blade.php 동적 사이드바
mng/resources/views/components/sidebar/*.blade.php 사이드바 컴포넌트들

수정

파일 변경 내용
api/app/Models/Commons/Menu.php options 캐스팅 + 헬퍼 메서드
mng/bootstrap/app.php CheckMenuPermission 미들웨어 등록
mng/config/app.php mng_dynamic_sidebar 설정 추가
mng/routes/web.php 미들웨어 적용
mng/resources/views/partials/sidebar.blade.php 조건부 렌더링

6. 예상 일정

Phase 작업 예상 기간
Phase 1 DB 스키마 및 시딩 1-2일
Phase 2 SidebarMenuService 2-3일
Phase 3 동적 사이드바 컴포넌트 2-3일
Phase 4 권한 미들웨어 1-2일
테스트 통합 테스트 및 버그 수정 2-3일
총합 8-13일

7. 체크리스트

개발 전

  • 현재 mng 메뉴 목록 완전히 정리 (그룹/항목/라우트)
  • 권한 명명 규칙 확정
  • 개발도구/Labs 접근 정책 확정

Phase 1 완료 조건

  • 마이그레이션 실행 성공 (options 컬럼 추가)
  • mng 메뉴 시더 실행 성공
  • DB에 모든 mng 메뉴 존재 확인
  • 기존 API/React 영향 없음 확인

Phase 2 완료 조건

  • SidebarMenuService 생성 완료
  • 슈퍼관리자 전체 메뉴 조회 확인
  • 일반 사용자 권한 기반 필터링 확인 (requires_role)

Phase 3 완료 조건

  • 동적 사이드바 렌더링 정상 (컴포넌트 생성 완료)
  • 메뉴 접기/펼치기 동작
  • 활성 메뉴 하이라이트 동작
  • 사이드바 collapse 상태 동작

Phase 4 완료 조건

  • 미들웨어 권한 체크 동작
  • 403 에러 페이지 표시
  • 권한 없는 메뉴 URL 직접 접근 차단

전환 완료 조건

  • 모든 테스트 통과
  • 기존 기능 동일 동작 확인
  • 성능 영향 최소화 확인 (캐싱)
  • 롤백 가능 확인

8. 추가 고려사항

8.1 캐싱 전략

// 사용자별 메뉴 캐싱 (권한 변경 시 무효화)
Cache::remember("user:{$userId}:menus", 3600, function () use ($userId) {
    return $this->getUserMenuTree(User::find($userId));
});

8.2 권한 변경 시 캐시 무효화

// 역할 권한 변경 시
Cache::tags(['menus'])->flush();

// 개인 권한 변경 시
Cache::forget("user:{$userId}:menus");

8.3 감사 로그

// 메뉴 접근 로그 (선택적)
AuditLog::create([
    'action' => 'menu_access',
    'target_type' => 'menu',
    'target_id' => $menu->id,
    'actor_id' => auth()->id(),
]);

9. 다음 단계

이 계획을 승인하시면 다음 순서로 진행합니다:

  1. 현재 mng 메뉴 전체 목록 정리 (그룹/항목/라우트/아이콘)
  2. 마이그레이션 작성 (menus.options 컬럼)
  3. Menu 모델 수정 (options 캐스팅)
  4. SidebarMenuService 개발
  5. 동적 사이드바 컴포넌트 개발
  6. 권한 미들웨어 적용
  7. 테스트 및 전환

진행하시겠습니까?