# GLOBEAU 웹 알림 센터(Notification Center) 아키텍처 및 UI/UX 설계 명세서

본 문서는 GLOBEAU 플랫폼의 웹 알림 센터를 구축함에 있어, 알림의 비즈니스 목적, 마이페이지(MyPage) 및 헤더(GNB/서브헤더) 내 알림 접점의 UI/UX 설계 철학, 시각적 위계, Laravel 공식 데이터베이스 알림 시스템 기반의 데이터베이스 스키마, 그리고 프론트엔드/백엔드 기술 구현 전반을 체계적으로 정리한 전문 기술 명세서입니다.

---

## 1. 알림 시스템의 비즈니스 목적 및 마이페이지 통합 당위성

### 1.1 왜 웹 알림 시스템이 필요한가? (비즈니스적 배경)
1. 비동기 이벤트의 완벽한 피드백 루프 완성
    - 뷰티 플랫폼의 핵심 비즈니스인 '방문 예약 신청'과 '파트너 뷰티샵 입점 신청'은 실시간 체결이 아닌 관리자 및 매장 원장님의 확인이 수반되는 '비동기(Asynchronous) 이벤트'입니다.
    - 사용자가 화면을 떠나 있거나 추후 재방문했을 때, 본인의 예약이 승인되었는지, 입점 서류에 보완이 필요한지 즉각 인지할 수 있는 시스템 피드백 채널이 필수적입니다.
2. 외부 채널(SMS/알림톡) 의존도 완화 및 온사이트 리텐션 증대
    - 비용이 발생하는 카카오 알림톡/SMS의 보완재이자, 사이트 접속 중인 사용자가 외부 앱을 확인하지 않고도 서비스 내에서 즉시 다음 행동으로 이어지도록 유도하는 온사이트(On-site) 전환 허브 역할을 수행합니다.

### 1.2 왜 마이페이지(MyPage)에서 알림을 통합 관리하는가?
1. 개인화 관제탑(Control Tower)으로서의 마이페이지
    - 마이페이지는 회원의 티켓, 포인트, 예약, 입점 현황 등 모든 사적 자산과 상태가 집약된 화면입니다.
    - 알림 내역은 사용자의 개인 상태 변화와 직결되므로, 독립된 별도 게시판 형태가 아닌 마이페이지의 핵심 하위 모듈로 위치해야 사용자의 멘탈 모델(Mental Model)과 일치합니다.
2. 사용자 데이터 소유권 보장 (수동 삭제 정책 채택 사유)
    - 30일/90일 경과 후 시스템이 강제로 알림을 지우는 방식 대신, 사용자가 직접 '삭제'할 때까지 보관하는 방식을 채택했습니다.
    - 중요한 예약 코드, 입점 피드백 안내, 혜택 링크 등을 사용자가 본인의 판단에 따라 스크랩처럼 보관하고자 하는 니즈를 충족시키며, 사용자가 자신의 알림 이력을 직접 통제(Agency)할 수 있도록 합니다.

---

## 2. 글로벌 헤더(GNB/서브헤더) 알림 UI/UX 인터랙션 설계

### 2.1 엠비언트 인식(Ambient Awareness)을 위한 붉은색 'N' 배지 설계
1. 마이크로 뱃지(Micro-Badge)의 역할
    - 사용자가 사이트 내 어느 페이지를 탐색하든(메인, 샵 상세, 마이페이지 등), 헤더 우측의 사용자/로그인 아이콘 우측 상단에 붉은색 'N' 도트 배지를 노출합니다.
    - 팝업이나 모달처럼 사용자의 탐색 흐름을 강제로 가로막지 않으면서도, 시야 주변부(Peripheral Vision)에서 '새로운 상태 변화가 발생했음'을 자연스럽게 인지시키는 엠비언트 UI 방식을 채택했습니다.
2. 텍스트 'N'과 원형 도트 규격
    - 모바일과 데스크톱 화면 비율을 고려하여 아이콘 우측 상단에 위치(`position: absolute; top: 0; start: 100%; translate-middle`)하며, 흰색 1px 보더를 두어 아이콘 배경색과 겹쳐도 시인성이 떨어지지 않도록 설계했습니다.

### 2.2 드롭다운 메뉴 최상단 알림함 배치 및 수량 배지 표기
1. 메뉴 위계(Hierarchy)의 최우선 배치
    - 사용자 아이콘 클릭 시 열리는 드롭다운 메뉴에서 일반적인 '정보수정', '로그아웃'보다 더 높은 최상단 1순위 자리에 '알림함'을 배치했습니다.
    - 사이트에 재방문한 회원이 가장 먼저 확인해야 할 우선순위는 '내 상태의 변동'이기 때문입니다.
2. 정량적 미확인 수량 배지 표기
    - 메뉴 우측에 단순 도트가 아닌 실제 안 읽은 건수(`$unreadCount`)를 붉은 알약형 배지(`rounded-pill`)로 표기합니다.
    - 100건 이상 누적 시 UI가 깨지는 현상을 방지하기 위해 `99+` 포맷팅 규칙을 적용했습니다.

---

## 3. 마이페이지 대시보드(Index) 내 알림 접점 설계

### 3.1 이중 진입점(Dual Entry Point) 전략
1. 상단 프로필 카드 우측 퀵 액션 영역
    - 프로필 영역의 [정보수정], [로그아웃] 버튼과 나란히 **[알림함] 버튼**을 배치하고, 우측 상단에 붉은 뱃지를 연동했습니다.
    - 마이페이지 진입 즉시 시선이 가장 먼저 머무는 영역에서 1차적인 확인을 유도합니다.
2. 하단 상세 바로가기 그리드 (대형 카드)
    - 6대 핵심 메뉴 그리드 중 1번 카드에 **[나의 알림함] 카드**를 배치했습니다.
    - 아이콘 영역에 붉은 펄스 도트를 배치하고, 타이틀 우측에 `N건 안 읽음` 배지를 직관적으로 표시하여 메뉴 탐색 과정에서 누락 없이 접근할 수 있도록 2차 접점을 완성했습니다.

### 3.2 파트너 센터(비즈니스 위젯)와의 유기적 상태 동기화
- 알림함으로 수신된 "입점 신청 접수 완료", "입점 심사 반려", "입점 최종 승인" 알림은 마이페이지 중앙의 [파트너 센터 위젯]과 1:1로 상태가 완벽히 연동됩니다.
- 알림 상세에서 바로가기를 누르면 해당 파트너 상태 확인 페이지(`shops.status`)나 관리자 센터(`shop_admin.index`)로 즉시 이동하여 사용자 동선을 단일화합니다.

---

## 4. 알림함 목록(Index) vs 상세(Show) 화면 분리 및 인터랙션 철학

### 4.1 카드형 대신 게시판(1줄 리스트) 형태를 채택한 이유
1. 정보 밀도(Information Density)의 극대화
    - 기존의 카드형 배치는 1페이지에 3~4개의 알림만 노출되어 사용자가 스크롤을 과도하게 내려야 하는 피로감이 있었습니다.
    - 1줄 게시판 형태로 전환함으로써 PC 기준 한 화면에 10~15개 이상의 알림 제목과 발송 시각을 즉시 훑어볼 수(Skimming) 있는 환경을 제공합니다.
2. 명확한 시각적 대비 (읽음/안 읽음 인지성)
    - **안 읽은 알림:** 흰색 바탕 + 좌측 붉은색 3px 보더 + 굵은 검은색 폰트 + 붉은색 [N] 배지.
    - **읽은 알림:** 연한 그레이 배경(`bg-light bg-opacity-50`) + 일반 굵기 텍스트 + 차분한 메타데이터 색상.
    - 국내 사용자가 가장 익숙한 전통적인 게시판 읽음 상태 UX를 그대로 재현하여 인지 비용을 제로화했습니다.

### 4.2 목록과 상세 화면의 명확한 역할 분리
1. 목록 화면 (Index)
    - 제목, 수신 시각, 바로가기 유무 아이콘, 삭제 버튼만 1줄로 미니멀하게 제공합니다.
    - 목록에서 본문 전문을 노출하지 않으므로써 목록 스캔 속도를 유지하고, 알림 클릭 시 상세 화면(`show`)으로 이동해야 하는 당위성을 부여합니다.
2. 상세 화면 (Show)
    - 알림 제목을 클릭하여 진입 시 백엔드에서 자동으로 `read_at`을 업데이트하여 읽음 처리합니다.
    - 본문 텍스트는 편안하게 읽을 수 있도록 충분한 글자 크기(`1rem`)와 줄간격(`line-height: 1.8`)으로 전문을 노출합니다.

### 4.3 상세 화면 버튼 위계(Visual Hierarchy) 및 규격 일치화
1. 높이와 폰트 크기의 완전한 일치 (정렬 안정성)
    - 동일 라인에 위치하는 [삭제], [목록으로], [관련 페이지 바로가기] 3개 버튼의 높이를 `height: 40px`, 폰트 크기를 `font-size: 0.88rem`, 모서리 라운드값을 `var(--radius-fixed)`로 완벽히 동일화하여 버튼 간 어색한 단차를 제거했습니다.
2. 가로 폭과 색상을 통한 액션 위계 차등
    - **관련 페이지 바로가기 (핵심 액션):** 우측에 넉넉한 가로 패딩(`px-4`), 볼드 폰트, 프리미엄 브라운 테마(`btn-premium`)를 적용하여 사용자의 시선이 최우선으로 머물도록 설계했습니다.
    - **알림 삭제 (보조 액션):** 좌측에 아담하고 슬림한 가로 패딩(`12px`), 차분한 테두리(`btn-outline-secondary`)를 적용하여 불필요한 시선 분산을 막았습니다.

### 4.4 삭제 컨펌 모달(SweetAlert2)의 심리적 피로도 완화 철학
- 알림 삭제는 금융 결제나 예약 취소처럼 시스템에 치명적인 영향을 미치는 행위가 아닙니다.
- 붉은색 경고 박스나 위협적인 문구를 배제하고, 차분한 질문형(`question`) 아이콘과 "[알림명] 알림을 삭제하시겠습니까?"라는 간결한 1문장 컨펌창을 노출하여 사용자의 시각적·심리적 피로도를 대폭 낮췄습니다.

---

## 5. Laravel 데이터베이스 알림 시스템 및 데이터베이스 스키마

### 5.1 Laravel 공식 알림(Notifications) 시스템 활용
본 알림 센터는 임의의 커스텀 테이블을 신설하지 않고, **Laravel 프레임워크에서 공식 제공하는 다형성 알림 시스템(`Illuminate\Notifications\Notifiable`)**을 기반으로 구축되었습니다.

1. 프레임워크 표준 기능 활용의 이점
    - `User` 모델에 `Notifiable` 트레이트를 추가하는 것만으로 `$user->notifications`, `$user->unreadNotifications` 등의 Eloquent 관계를 즉시 사용합니다.
    - `$notification->markAsRead()`를 통한 손쉬운 읽음 처리, 다형성(`notifiable_type`, `notifiable_id`)을 통한 확장성 보장, 그리고 향후 메일(Mail), SMS 채널과의 통합 확장이 유연합니다.
2. 마이그레이션 생성 방식
    - Laravel 공식 아티즌 명령어(`php artisan notifications:table`)를 통해 표준 테이블을 생성하여 유지보수성을 극대화했습니다.

### 5.2 알림 테이블 (notifications) 상세 정의서
- **파일 위치:** `database/migrations/2026_07_23_000000_create_notifications_table.php`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Char(36) / Uuid | N | - | PK | 알림 고유 식별자 (UUID v4) |
| **2** | 식별값 | `type` | Varchar(255) | N | - | - | 알림 클래스 네임스페이스<br>(예: `App\Notifications\SystemWebNotification`) |
| **3** | 다형성 | `notifiable_type` | Varchar(255) | N | - | Composite Index<br>(`notifiable_type`, `notifiable_id`) | 수신 대상 모델 클래스<br>(예: `App\Models\User`) |
| **4** | 다형성 | `notifiable_id` | Bigint (unsigned) | N | - | Composite Index<br>(`notifiable_type`, `notifiable_id`) | 수신 대상 모델의 기본키 PK (`user_id`) |
| **5** | 페이로드 | `data` | Text / LongText | N | - | - | 알림 본문 데이터 JSON 규격<br>- `title`: 알림 제목<br>- `message`: 알림 상세 본문 (줄바꿈 지원)<br>- `url`: 연동 목적지 링크 (null 가능) |
| **6** | 상태값 | `read_at` | Timestamp | Y | NULL | - | 알림 열람/확인 일시<br>- `NULL`: 미열람 (안 읽음, N 배지 활성화 대상)<br>- `Timestamp`: 열람 완료 (읽음) |
| **7** | 일시 | `created_at` | Timestamp | Y | NULL | - | 알림 발송/생성 일시 (목록 정렬 기준) |
| **8** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 레코드 최종 수정 일시 |

---

## 6. 프론트엔드 및 백엔드 기술 구현 명세

### 6.1 전역 뷰 컴포저 (AppServiceProvider)
모든 컨트롤러에서 `$unreadCount`를 중복 계산하여 뷰에 주입하지 않도록 서비스 프로바이더(`app/Providers/AppServiceProvider.php`) 레벨에서 전역 주입합니다.

#### 6.1.1 미확인 알림 카운트($unreadCount)의 화면별 역할과 전역 주입 목적
- 핵심 데이터: `$unreadCount`
    - 데이터 정의: 현재 로그인한 회원이 아직 열람하지 않은 알림의 실시간 총합(`Auth::user()->unreadNotifications()->count()`)입니다.
    - 화면 표시 기준:
        1. 글로벌 헤더 및 서브헤더: 0건보다 클 때 프로필 및 종 아이콘 우측 상단에 붉은색 'N' 뱃지를 활성화하여 새 소식을 암시합니다.
        2. 드롭다운 및 대시보드 위젯: 텍스트 옆에 실제 미확인 수량(`1`, `5`, `99+`)을 알약형 배지로 표기하여 직관적인 확인을 돕습니다.
- 전역 뷰 컴포저를 통한 아키텍처 개선 효과:
    1. 변수 누락 에러 원천 차단: 마이페이지 전반과 상단 네비게이션 어디서든 참조되므로, 개별 컨트롤러마다 `compact('unreadCount')`를 일일이 넘겨주던 작업 누락과 뷰 렌더링 에러를 방지합니다.
    2. 비로그인 사용자 예외 방어: 로그아웃 상태나 게스트 접속 시에도 삼항 연산자를 통해 기본값 `0`을 안전하게 공급하여 null 참조 오류를 방어합니다.
    3. 비즈니스 로직과 화면의 명확한 분리: 알림 개수 조회 쿼리를 서비스 프로바이더 단 한 곳에 캡슐화하여 유지보수 효율을 극대화합니다.

#### 6.1.2 전역 주입 필수 구현 소스
다른 환경 설정과 부가 로직을 제외하고, `$unreadCount` 데이터를 레이아웃 전반에 공급하기 위해 필요한 최소한의 구현 코드입니다. (불필요한 빈 `register()` 메서드는 생략함)

    <?php

    namespace App\Providers;

    use Illuminate\Support\ServiceProvider;
    use Illuminate\Support\Facades\View;
    use Illuminate\Support\Facades\Auth;

    class AppServiceProvider extends ServiceProvider
    {
        /**
         * Bootstrap any application services.
         */
        public function boot(): void
        {
            // layouts 및 mypage 하위 모든 Blade 템플릿에 $unreadCount 자동 주입
            View::composer(['layouts.*', 'mypage.*'], function ($view) {$view->with(
                    'unreadCount',
                    Auth::check() ? Auth::user()->unreadNotifications()->count() : 0
                );
            });
        }
    }

---

### 6.2 컨트롤러 (NotificationController.php)
50건 단위 페이징과 무한 스크롤 AJAX 응답을 동시에 처리합니다.

파일: `app/Http/Controllers/Mypage/NotificationController.php`

    <?php

    namespace App\Http\Controllers\Mypage;

    use App\Http\Controllers\Controller;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;

    class NotificationController extends Controller
    {
        public function index(Request $request)
        {
            $user = Auth::user();
            $filter =$request->input('filter', 'all');

            $query =$user->notifications();

            if ($filter === 'unread') {$query->whereNull('read_at');
            }

            // 게시판 1줄 구조에 맞춘 50건 단위 페이징
            $notifications = $query->latest()->paginate(50)->withQueryString();
            $unreadCount =$user->unreadNotifications()->count();
            $totalCount =$user->notifications()->count();

            // 무한 스크롤 비동기 요청 분기
            if ($request->ajax()) {$html = view('mypage.notifications._items', compact('notifications'))->render();
                return response()->json([
                    'html' => $html,
                    'hasMore' => $notifications->hasMorePages(),
                    'nextPageUrl' => $notifications->nextPageUrl()
                ]);
            }

            return view('mypage.notifications.index', compact('notifications', 'filter', 'unreadCount', 'totalCount'));
        }

        public function show(string $id)
        {
            $user = Auth::user();
            $notification = $user->notifications()->findOrFail($id);

            // 진입 시 자동 읽음 처리
            if (is_null($notification->read_at)) {
                $notification->markAsRead();
            }

            return view('mypage.notifications.show', compact('notification'));
        }

        public function readAndGo(string $id)
        {
            $user = Auth::user();
            $notification = $user->notifications()->findOrFail($id);

            if (is_null($notification->read_at)) {
                $notification->markAsRead();
            }

            $targetUrl = $notification->data['url'] ?? null;

            return $targetUrl ? redirect($targetUrl) : redirect()->route('mypage.notifications.show', $notification->id);
        }

        public function markAllAsRead()
        {
            Auth::user()->unreadNotifications->markAsRead();

            return response()->json([
                'success' => true,
                'message' => '모든 알림을 읽음 처리했습니다.'
            ]);
        }

        public function destroy(string $id)
        {
            $notification = Auth::user()->notifications()->findOrFail($id);
            $notification->delete();

            return back()->with('success', '알림이 삭제되었습니다.');
        }
    }

---

### 6.3 서브 헤더 통합 컴포넌트 (header-sub.blade.php)
상단 유저 아이콘 N 배지와 드롭다운 최상단 알림함 링크를 통합 구현한 헤더 템플릿입니다.

파일: `resources/views/layouts/header-sub.blade.php`

    <header class="app-sub-header d-flex align-items-center">
        <div class="container-fluid d-flex justify-content-between align-items-center" style="max-width: 900px;">

            <!-- 좌측: 뒤로가기 & 브랜드 로고 -->
            <div class="d-flex align-items-center gap-2">
                <a href="javascript:history.back();" class="text-dark fs-5 text-decoration-none p-1" title="뒤로가기">
                    <i class="fas fa-arrow-left"></i>
                </a>
                <a href="{{ route('main') }}" class="text-decoration-none d-flex align-items-center">
                    <span class="fw-bold text-brand brand-font" style="font-size: 1.1rem; line-height: 1;">GLOBEAU</span>
                </a>
            </div>

            <!-- 우측: 검색 및 유저 드롭다운 -->
            <div class="d-flex align-items-center gap-2">
                <button class="btn border-0 p-0 text-dark fs-5" type="button" data-bs-toggle="modal" data-bs-target="#searchModal" title="검색">
                    <i class="fas fa-search"></i>
                </button>

                <a href="{{ route('main') }}" class="text-dark fs-5 text-decoration-none" title="홈으로">
                    <i class="fas fa-home"></i>
                </a>

                @auth
                @php
                    $user = Auth::user();
                    $latestShop = \App\Models\Shop::where('user_id',$user->id)
                        ->orWhere(function($query) use ($user) {
                            if ($user->phone) {
                                $query->where('ceo_phone',$user->phone);
                            }
                        })
                        ->latest('id')
                        ->first();
                @endphp
                <div class="dropdown">
                    <!-- N 배지가 부착된 유저 트리거 버튼 -->
                    <button class="btn btn-sm border-0 p-0 text-dark fs-5 dropdown-toggle d-flex align-items-center shadow-none position-relative" type="button" id="subMyPageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
                        <i class="fas fa-user-circle text-brand"></i>
                        @if(($unreadCount ?? 0) > 0)
                            <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger border border-white" style="font-size: 0.55rem; padding: 0.25em 0.4em;">
                                N
                            </span>
                        @endif
                    </button>
                    <ul class="dropdown-menu dropdown-menu-end shadow-sm border-0 py-2" aria-labelledby="subMyPageDropdown" style="border-radius: var(--radius-fixed); font-size: 0.85rem; min-width: 210px;">
                        <!-- 1순위: 알림함 바로가기 및 수량 배지 -->
                        <li>
                            <a class="dropdown-item py-2 d-flex align-items-center justify-content-between" href="{{ route('mypage.notifications.index') }}">
                                <span class="d-flex align-items-center gap-2">
                                    <i class="fas fa-bell text-muted"></i> 알림함
                                </span>
                                @if(($unreadCount ?? 0) > 0)
                                    <span class="badge bg-danger rounded-pill" style="font-size: 0.65rem;">
                                        {{ $unreadCount > 99 ? '99+' :$unreadCount }}
                                    </span>
                                @endif
                            </a>
                        </li>
                        <li><hr class="dropdown-divider my-1"></li>

                        <!-- 2순위: 마이페이지 핵심 메뉴 -->
                        <li>
                            <a class="dropdown-item py-2 d-flex align-items-center gap-2" href="{{ route('mypage.profile.edit') }}">
                                <i class="fas fa-user-edit text-muted"></i> 정보수정
                            </a>
                        </li>
                        <li>
                            <a class="dropdown-item py-2 d-flex align-items-center gap-2" href="{{ route('mypage.reservations') ?? '#' }}">
                                <i class="fas fa-calendar-check text-muted"></i> 나의 예약 내역
                            </a>
                        </li>

                        <!-- 3순위: 비즈니스 센터 파트너 섹션 -->
                        @if($latestShop)
                            <li><hr class="dropdown-divider my-1"></li>
                            <li>
                                <h6 class="dropdown-header text-muted small py-1" style="font-size: 0.72rem;">비즈니스 센터</h6>
                            </li>

                            @if($latestShop->status === 'pending')
                                <li>
                                    <a class="dropdown-item py-2 d-flex align-items-center justify-content-between text-warning-emphasis" href="{{ route('shops.status', $latestShop->id) }}">
                                        <span><i class="fas fa-hourglass-half me-2"></i> 뷰티샵 입점신청중</span>
                                        <span class="badge bg-warning-subtle text-warning border border-warning" style="font-size: 0.65rem;">심사중</span>
                                    </a>
                                </li>
                            @elseif($latestShop->status === 'rejected')
                                <li>
                                    <a class="dropdown-item py-2 d-flex align-items-center justify-content-between text-danger" href="{{ route('shops.status', $latestShop->id) }}">
                                        <span><i class="fas fa-exclamation-circle me-2"></i> 뷰티샵 입점신청 반려</span>
                                        <span class="badge bg-danger text-white rounded-pill" style="font-size: 0.65rem;">사유확인</span>
                                    </a>
                                </li>
                            @elseif($latestShop->status === 'approved')
                                <li>
                                    <a class="dropdown-item py-2 d-flex align-items-center gap-2 text-brand fw-bold" href="{{ route('shop_admin.index') }}" target="_blank">
                                        <i class="fas fa-store-alt"></i> 뷰티샵 관리자
                                    </a>
                                </li>
                            @endif
                        @endif

                        <li><hr class="dropdown-divider my-1"></li>
                        <li>
                            <form action="{{ route('logout') }}" method="POST" class="d-inline">
                                @csrf
                                <button type="submit" class="dropdown-item py-2 d-flex align-items-center gap-2 text-danger">
                                    <i class="fas fa-sign-out-alt"></i> 로그아웃
                                </button>
                            </form>
                        </li>
                    </ul>
                </div>
                @else
                <a href="{{ route('login') }}" class="text-dark fs-5 text-decoration-none" title="로그인">
                    <i class="fas fa-user"></i>
                </a>
                @endauth
            </div>
        </div>
    </header>

---

### 6.4 알림 목록 단일 행 컴포넌트 (_items.blade.php)
무한 스크롤 비동기 렌더링과 최초 로딩에서 공통으로 사용되는 1줄 게시판 구조의 서브 뷰입니다.

파일: `resources/views/mypage/notifications/_items.blade.php`

    @foreach($notifications as $item)
        @php
            $isUnread = is_null($item->read_at);
            $data =$item->data;
            $itemTitle =$data['title'] ?? '알림 안내';
        @endphp

        <div class="list-group-item list-group-item-action p-3 d-flex align-items-center justify-content-between gap-3 {{ $isUnread ? 'bg-white' : 'bg-light bg-opacity-50' }}"
             id="noti-row-{{ $item->id }}"
             style="transition: background-color 0.15s ease;">
            
            <!-- 좌측: 제목 영역 (클릭 시 상세 이동) -->
            <a href="{{ route('mypage.notifications.show', $item->id) }}" class="text-decoration-none min-w-0 flex-grow-1 d-flex align-items-center gap-2">
                @if($isUnread)
                    <span class="badge bg-danger rounded-pill flex-shrink-0" style="font-size: 0.65rem; padding: 0.25em 0.5em;">N</span>
                @endif

                <span class="text-truncate {{ $isUnread ? 'fw-bold text-dark' : 'text-secondary' }}" style="font-size: 0.95rem;">
                    {{ $itemTitle }}
                </span>

                @if(!empty($data['url']))
                    <i class="fas fa-link text-muted fs-xs flex-shrink-0" title="바로가기 포함"></i>
                @endif
            </a>

            <!-- 우측: 수신 일시 및 삭제 버튼 -->
            <div class="d-flex align-items-center gap-3 flex-shrink-0">
                <span class="text-muted small font-monospace">
                    {{ $item->created_at->format('m.d H:i') }}
                </span>

                <form id="delete-form-{{ $item->id }}" action="{{ route('mypage.notifications.destroy', $item->id) }}" method="POST" class="d-inline">
                    @csrf
                    @method('DELETE')
                    <button type="button" 
                            class="btn btn-link text-muted p-0 text-decoration-none opacity-50 hover-opacity-100" 
                            title="알림 삭제"
                            onclick="confirmDeleteNotification('{{ $item->id }}', '{{ addslashes($itemTitle) }}')">
                        <i class="fas fa-times"></i>
                    </button>
                </form>
            </div>
        </div>
    @endforeach

---

### 6.5 알림 목록 및 무한 스크롤 메인 뷰 (index.blade.php)
50건 단위의 IntersectionObserver 무한 스크롤 및 전체 읽음 처리 AJAX를 포함합니다.

파일: `resources/views/mypage/notifications/index.blade.php`

    @extends('layouts.app-sub')

    @section('title', '알림함 - 마이페이지')
    @section('header-title', '알림함')

    @section('content')
    <div class="container py-3 py-lg-4" style="max-width: 780px;">

        <!-- 탭 필터 및 우측 모두 읽음 버튼 -->
        <div class="d-flex justify-content-between align-items-center mb-3 border-bottom pb-2">
            <div class="d-flex gap-2">
                <a href="{{ route('mypage.notifications.index', ['filter' => 'all']) }}" 
                   class="btn btn-sm px-3 fw-semibold {{ ($filter ?? 'all') === 'all' ? 'btn-dark' : 'btn-light text-muted' }}" 
                   style="border-radius: 20px; font-size: 0.85rem;">
                    전체 ({{ $totalCount ?? 0 }})
                </a>
                <a href="{{ route('mypage.notifications.index', ['filter' => 'unread']) }}" 
                   class="btn btn-sm px-3 fw-semibold {{ ($filter ?? 'all') === 'unread' ? 'btn-dark' : 'btn-light text-muted' }}" 
                   style="border-radius: 20px; font-size: 0.85rem;">
                    안 읽음 ({{ $unreadCount ?? 0 }})
                </a>
            </div>

            @if(($unreadCount ?? 0) > 0)
                <button type="button" class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1 shadow-sm py-1 px-3" id="btnMarkAllRead" style="border-radius: var(--radius-fixed); font-size: 0.8rem;">
                    <i class="fas fa-check-double text-muted"></i>
                    <span>모두 읽음</span>
                </button>
            @endif
        </div>

        <!-- 1줄 게시판 목록 카드 -->
        <div class="card border-0 shadow-sm overflow-hidden mb-3" style="border-radius: var(--radius-fixed);">
            <div class="list-group list-group-flush" id="notificationListContainer">
                @if($notifications->count() > 0)
                    @include('mypage.notifications._items')
                @else
                    <div class="text-center py-5 bg-white">
                        <div class="mb-3 text-muted opacity-50">
                            <i class="far fa-bell-slash fa-3x"></i>
                        </div>
                        <h6 class="fw-bold text-dark mb-1">
                            {{ ($filter ?? 'all') === 'unread' ? '새로운 안 읽은 알림이 없습니다.' : '받은 알림이 없습니다.' }}
                        </h6>
                        <p class="text-muted small mb-0">새로운 알림이 도착하면 이곳에 목록으로 표시됩니다.</p>
                    </div>
                @endif
            </div>
        </div>

        <!-- 무한 스크롤 트리거 & 로딩 인디케이터 -->
        <div id="infiniteScrollSentinel" class="text-center py-2 {{ $notifications->hasMorePages() ? '' : 'd-none' }}">
            <div class="spinner-border spinner-border-sm text-secondary opacity-75" role="status">
                <span class="visually-hidden">로딩 중...</span>
            </div>
        </div>

    </div>
    @endsection

    @push('styles')
    <style>
        .list-group-item:hover {
            background-color: #fcfbf9 !important;
        }
        .hover-opacity-100:hover {
            opacity: 1 !important;
            color: #dc3545 !important;
        }
        .fs-xs {
            font-size: 0.75rem;
        }
    </style>
    @endpush

    @push('scripts')
    <script>
    // 알림 삭제 컨펌 (심리적 피로감 완화된 질문형 모달)
    async function confirmDeleteNotification(notiId, notiTitle) {
        const form = document.getElementById('delete-form-' + notiId);
        if (!form) return;

        const message = `
            <div class="text-secondary small text-start px-1 py-1">
                <p class="mb-0" style="font-size: 0.95rem; line-height: 1.5;">
                    <strong class="text-dark">[${notiTitle}]</strong> 알림을 삭제하시겠습니까?
                </p>
            </div>
        `;

        const confirmed = await showSwalConfirm(
            message, 
            '알림 삭제', 
            '삭제', 
            '취소', 
            'question'
        );

        if (confirmed) {
            form.submit();
        }
    }

    document.addEventListener('DOMContentLoaded', function () {
        // 전체 읽음 처리
        const btnMarkAll = document.getElementById('btnMarkAllRead');
        if (btnMarkAll) {
            btnMarkAll.addEventListener('click', async function () {
                const message = `
                    <div class="text-secondary small text-start px-1 py-1">
                        <p class="mb-0" style="font-size: 0.95rem; line-height: 1.5;">
                            확인하지 않은 <strong class="text-dark">{{ $unreadCount ?? 0 }}건</strong>의 알림을 모두 읽음 처리하시겠습니까?
                        </p>
                    </div>
                `;

                const confirmed = await showSwalConfirm(
                    message,
                    '전체 읽음 처리',
                    '모두 읽음',
                    '취소',
                    'question'
                );

                if (confirmed) {
                    fetch('{{ route("mypage.notifications.read-all") }}', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-CSRF-TOKEN': '{{ csrf_token() }}',
                            'Accept': 'application/json'
                        }
                    })
                    .then(res => res.json())
                    .then(data => {
                        if (data.success) {
                            location.reload();
                        }
                    })
                    .catch(err => {
                        console.error(err);
                        alert('처리 중 오류가 발생했습니다.');
                    });
                }
            });
        }

        // IntersectionObserver 무한 스크롤 (50개 단위)
        let nextPageUrl = @json($notifications->nextPageUrl());
        let isLoading = false;
        const sentinel = document.getElementById('infiniteScrollSentinel');
        const container = document.getElementById('notificationListContainer');

        if (sentinel && nextPageUrl) {
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting && nextPageUrl && !isLoading) {
                        isLoading = true;
                        sentinel.classList.remove('d-none');

                        fetch(nextPageUrl, {
                            headers: {
                                'X-Requested-With': 'XMLHttpRequest',
                                'Accept': 'application/json'
                            }
                        })
                        .then(res => res.json())
                        .then(data => {
                            container.insertAdjacentHTML('beforeend', data.html);
                            nextPageUrl = data.nextPageUrl;

                            if (!data.hasMore) {
                                observer.disconnect();
                                sentinel.classList.add('d-none');
                            }
                            isLoading = false;
                        })
                        .catch(err => {
                            console.error('알림 로딩 오류:', err);
                            isLoading = false;
                            sentinel.classList.add('d-none');
                        });
                    }
                });
            }, { rootMargin: '100px' });

            observer.observe(sentinel);
        }
    });
    </script>
    @endpush

---

### 6.6 알림 상세 보기 (show.blade.php)
버튼 규격 통일(`40px`, `0.88rem`)과 시각적 위계(바로가기 강조, 삭제 슬림화)를 구현한 뷰입니다.

파일: `resources/views/mypage/notifications/show.blade.php`

    @extends('layouts.app-sub')

    @section('title', '알림 상세 - 마이페이지')
    @section('header-title', '알림함')

    @section('content')
    <div class="container py-3 py-lg-4" style="max-width: 780px;">

        @php
            $data = $notification->data;
            $actionUrl =$data['url'] ?? null;
            $itemTitle =$data['title'] ?? '알림 안내';
        @endphp

        <div class="card border-0 shadow-sm" style="border-radius: var(--radius-fixed);">
            <div class="card-body p-4 p-md-5">

                <!-- 헤더 메타데이터 -->
                <div class="border-bottom pb-3 mb-4">
                    <div class="d-flex align-items-center justify-content-between mb-2">
                        <span class="badge bg-light text-brand border px-2 py-1" style="font-size: 0.78rem;">
                            <i class="fas fa-bell me-1"></i>시스템 알림
                        </span>
                        <span class="text-muted small font-monospace">
                            {{ $notification->created_at->format('Y-m-d H:i') }}
                        </span>
                    </div>
                    <h4 class="fw-bold text-dark mb-0" style="font-size: 1.25rem;">
                        {{ $itemTitle }}
                    </h4>
                </div>

                <!-- 본문 텍스트 전문 -->
                <div class="notification-body text-secondary mb-5" style="white-space: pre-line; line-height: 1.8; font-size: 1rem;">
                    {{ $data['message'] ?? '내용이 없습니다.' }}
                </div>

                <!-- 하단 액션 버튼 그룹 (높이 40px 통일, 위계 분리) -->
                <div class="d-flex align-items-center justify-content-between pt-3 border-top gap-2 flex-wrap">
                    <!-- 좌측: 슬림한 너비의 삭제 버튼 -->
                    <div>
                        <form id="delete-form-{{ $notification->id }}" action="{{ route('mypage.notifications.destroy', $notification->id) }}" method="POST" class="d-inline">
                            @csrf
                            @method('DELETE')
                            <button type="button" 
                                    class="btn btn-outline-secondary d-inline-flex align-items-center justify-content-center text-muted noti-action-btn noti-btn-slim" 
                                    onclick="confirmDeleteNotification('{{ $notification->id }}', '{{ addslashes($itemTitle) }}')">
                                <i class="far fa-trash-alt me-1"></i> 삭제
                            </button>
                        </form>
                    </div>

                    <!-- 우측: 목록으로 & 넓고 시원한 바로가기 버튼 -->
                    <div class="d-flex align-items-center gap-2">
                        <a href="{{ route('mypage.notifications.index') }}" 
                           class="btn btn-outline-secondary d-inline-flex align-items-center justify-content-center fw-medium noti-action-btn px-3">
                            <i class="fas fa-list me-1"></i> 목록으로
                        </a>

                        @if(!empty($actionUrl))
                            <a href="{{ $actionUrl }}" 
                               class="btn btn-premium d-inline-flex align-items-center justify-content-center gap-1.5 fw-bold shadow-sm noti-action-btn noti-btn-wide">
                                <span>관련 페이지 바로가기</span>
                                <i class="fas fa-chevron-right" style="font-size: 0.7rem;"></i>
                            </a>
                        @endif
                    </div>
                </div>

            </div>
        </div>

    </div>
    @endsection

    @push('styles')
    <style>
        .noti-action-btn {
            height: 40px;
            font-size: 0.88rem !important;
            border-radius: var(--radius-fixed) !important;
            line-height: 1;
            white-space: nowrap;
        }
        
        .noti-btn-slim {
            padding-left: 12px;
            padding-right: 12px;
            border-color: #e5e7eb;
        }
        .noti-btn-slim:hover {
            background-color: #fee2e2 !important;
            border-color: #fca5a5 !important;
            color: #dc2626 !important;
        }

        .noti-btn-wide {
            padding-left: 24px;
            padding-right: 24px;
        }
    </style>
    @endpush

    @push('scripts')
    <script>
    async function confirmDeleteNotification(notiId, notiTitle) {
        const form = document.getElementById('delete-form-' + notiId);
        if (!form) return;

        const message = `
            <div class="text-secondary small text-start px-1 py-1">
                <p class="mb-0" style="font-size: 0.95rem; line-height: 1.5;">
                    <strong class="text-dark">[${notiTitle}]</strong> 알림을 삭제하시겠습니까?
                </p>
            </div>
        `;

        const confirmed = await showSwalConfirm(
            message, 
            '알림 삭제', 
            '삭제', 
            '취소', 
            'question'
        );

        if (confirmed) {
            form.submit();
        }
    }
    </script>
    @endpush