# GLOBEAU 통합 알림 관리 시스템 (Notification Management System) 아키텍처 및 UI/UX 기술 명세서

본 문서는 GLOBEAU 플랫폼의 비동기 피드백 루프와 온사이트 리텐션을 완성하기 위한 통합 알림 관리 시스템(웹 알림, SMS/LMS, 카카오 알림톡 호환, 이메일)의 비즈니스 목적, 마이페이지 및 글로벌 헤더의 UI/UX 설계 철학, 3+1 데이터베이스 아키텍처, 백엔드 서비스 파이프라인, 그리고 프론트엔드/백엔드 기술 구현 전반을 집대성한 최종 기술 명세서입니다.

---

## 1. 시스템 개요 및 아키텍처 원칙

### 1.1 비즈니스 배경 및 필요성
1. **비동기 이벤트의 완벽한 피드백 루프 완성**
    - 뷰티 플랫폼의 핵심 비즈니스인 '방문 예약 신청'과 '파트너 뷰티샵 입점 신청'은 실시간 즉시 처리가 아닌 관리자 및 매장 원장님의 확인이 수반되는 '비동기(Asynchronous) 이벤트'입니다.
    - 사용자가 화면을 벗어나 있거나 추후 재방문했을 때, 본인의 입점 심사 승인/반려 여부나 예약 상태 변동을 즉각 인지할 수 있는 시스템 피드백 채널이 필수적입니다.
2. **외부 채널(SMS/알림톡) 의존도 완화 및 온사이트 리텐션 증대**
    - 건당 과금이 발생하는 외부 통신사 문자(SMS/LMS)의 비용을 방어하고, 접속 중인 회원이 외부 앱으로 이탈하지 않고 플랫폼 내에서 즉시 다음 행동(매장 정보 등록, 예약 확인 등)으로 이어지도록 온사이트(On-site) 전환 허브 역할을 수행합니다.
3. **개인화 관제탑(Control Tower)으로서의 마이페이지 통합**
    - 마이페이지는 회원의 티켓, 포인트, 예약 내역, 입점 현황 등 모든 사적 자산과 상태가 집약된 화면입니다.
    - 알림 내역은 사용자의 개인 상태 변화와 직결되므로, 독립된 별도 게시판 형태가 아닌 마이페이지의 핵심 하위 모듈로 위치해야 사용자의 멘탈 모델(Mental Model)과 일치합니다.
4. **사용자 데이터 소유권 보장 (수동 삭제 정책 채택)**
    - 30일/90일 경과 후 시스템이 강제로 알림을 지우는 방식 대신, 사용자가 직접 '삭제'할 때까지 영구 보관하는 방식을 채택합니다.
    - 중요한 예약 코드, 입점 피드백 안내, 혜택 링크 등을 사용자가 스크랩처럼 보관하고자 하는 니즈를 충족시키며, 사용자가 자신의 알림 이력을 직접 통제(Agency)할 수 있도록 지원합니다.

### 1.2 핵심 알림 정책 (방안 A: 채널별 분기 표준)
1. **템플릿 본문(`content`)**: 순수 안내 텍스트만 유지하며 본문 내 URL 치환 변수 삽입을 배제합니다.
2. **이동 경로(`action_url`)**: 템플릿의 전용 인풋 필드에 별도로 정의된 경로를 활용하며, 상대 경로 입력 시 시스템이 현재 도메인을 자동 결합하여 절대 URL로 완성합니다.
3. **웹 알림 (인앱)**: 본문에 조잡한 텍스트 링크를 노출하지 않고, 상세 화면 하단 [관련 페이지 바로가기] 버튼에 `action_url`을 바인딩하여 시각적 완성도를 높입니다.
4. **문자 메시지 (SMS/LMS)**: 통신사 문자는 링크 버튼 기능이 없으므로, 시스템이 본문 최하단에 `\n\n▶ 바로가기: {전체 도메인 URL}`을 자동 첨부하여 모바일 터치 편의성을 제공합니다.
5. **카카오 알림톡 호환성**: 본문과 웹링크 버튼(WL)을 엄격히 분리하는 카카오 비즈메시지 사전 검수 규격과 1:1로 일치하여, 향후 API 연동 시 템플릿 수정 없이 즉시 승인 및 발송이 가능합니다.

### 1.3 메시지 분류 체계 (3가지 성격 분리)
- **A. 시스템 자동 알림 (System Trigger)**: 입점 승인/반려, 예약 상태 변경, 결제/취소 등 비즈니스 로직 이벤트 발생 시 즉시 발송 (발송 주체: 시스템 Bot)
- **B. 운영자 타겟 공지 (Admin Broadcast)**: 본사 또는 뷰티샵 관리자가 특정 그룹/전체 회원을 대상으로 수동 발송 (발송 주체: 본사/샵 관리자)
- **C. 상호 1:1 문의 (Direct Message)**: 고객-샵, 샵-본사 간의 상담 내역. 대화형 게시판으로 운영되되, 새 글/답변 등록 시 A(시스템 알림) 파이프라인을 호출하여 상대방에게 알림을 전송

---

## 2. 데이터베이스 아키텍처 (3+1 구조)

기존 3개 테이블의 역할을 온전히 보존하고, 신규 마스터 테이블 `notification_logs` 1개를 추가하여 모든 발송 이력을 통합 관리합니다.

### 2.1 테이블 역할 정의
- `notification_templates`: 자동 발송용 템플릿 문구, 타입, 기본 이동 경로 보관
- `notifications`: 라라벨 표준 웹 알림함 데이터 (회원 브라우저 화면용)
- `sms_logs`: 카페24 통신사 전송 결과 및 비용 과금 증빙 로그
- `notification_logs`: **(신규)** 모든 발송 행위(자동/수동, 단체/개별, 웹/문자)를 총괄하는 본사 운영자용 통합 로그 마스터

### 2.2 `batch_id` 기반 다중 채널 그룹화 원칙
입점 승인과 같이 1회의 사건으로 복수의 수신자/채널(신청자 LMS, 본사 LMS, 신청자 웹알림)이 발생할 경우:
- **DB 물리적 저장**: 각 채널별 성공/실패 여부와 개별 수신자를 정확히 증빙하기 위해 `notification_logs`에 3개의 행(Row)으로 분리 저장합니다.
- **논리적 묶음**: 3개의 행에 동일한 고유 식별값 `batch_id`를 부여합니다.
- **UI 렌더링**: 관리자 화면 목록에서는 `batch_id` 기준으로 1줄의 사건으로 요약 노출하며, 상세 모달에서 3건의 채널별 결과를 펼쳐서 확인합니다.

### 2.3 라라벨 표준 알림 테이블 스키마 (`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 | - | - | 알림 클래스 네임스페이스 (`App\Notifications\SystemWebNotification`) |
| **3** | 다형성 | `notifiable_type` | Varchar(255) | N | - | Composite Index | 수신 대상 모델 클래스 (`App\Models\User`) |
| **4** | 다형성 | `notifiable_id` | Bigint (unsigned) | N | - | Composite Index | 수신 대상 모델의 기본키 PK (`user_id`) |
| **5** | 페이로드 | `data` | Text / LongText | N | - | - | 알림 본문 데이터 JSON (`title`, `message`, `url`) |
| **6** | 상태값 | `read_at` | Timestamp | Y | NULL | - | 알림 열람 일시 (NULL: 안 읽음, Timestamp: 읽음) |
| **7** | 일시 | `created_at` | Timestamp | Y | NULL | - | 알림 생성 일시 |
| **8** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 레코드 최종 수정 일시 |

### 2.4 본사 통합 로그 테이블 스키마 (`notification_logs`)
- 파일 위치: `database/migrations/2026_10_11_000000_create_notification_logs_table.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::create('notification_logs', function (Blueprint $table) {
                $table->id();
                $table->string('batch_id', 64)->index()->comment('동일 발송 이벤트 묶음 고유키');
                $table->string('dispatch_type', 20)->default('AUTO')->comment('AUTO(자동), MANUAL(수동)');
                $table->string('trigger_code', 50)->nullable()->comment('템플릿 코드 또는 수동 업무 식별자');
                
                // 발송 주체
                $table->string('sender_type', 20)->default('SYSTEM')->comment('SYSTEM, ADMIN, SHOP');
                $table->unsignedBigInteger('sender_id')->nullable()->index()->comment('발송자 user_id');
                
                // 수신 대상
                $table->string('receiver_type', 20)->default('USER')->comment('USER, SHOP_OWNER, ADMIN, GUEST');
                $table->unsignedBigInteger('receiver_id')->nullable()->index()->comment('수신 회원 user_id');
                $table->string('receiver_contact', 100)->nullable()->comment('수신 전화번호 또는 이메일');
                
                // 채널 및 내용
                $table->string('channel', 20)->comment('WEB, SMS, LMS, ALIMTALK, EMAIL');
                $table->string('title')->nullable()->comment('알림/메시지 제목');
                $table->text('content')->comment('최종 치환 발송된 본문 전문');
                $table->string('action_url')->nullable()->comment('연결 이동 전체 URL');
                
                // 결과 및 상태
                $table->string('status', 20)->default('SUCCESS')->comment('SUCCESS, FAIL, PENDING');
                $table->string('error_code', 50)->nullable()->comment('발송 실패 코드');
                $table->text('error_message')->nullable()->comment('실패 상세 사유');
                
                $table->timestamps();
                
                $table->index(['created_at', 'channel']);
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('notification_logs');
        }
    };

---

## 3. 백엔드 발송 엔진 설계 (`NotificationService`)

`NotificationService`는 템플릿 로드, 변수 치환, URL 도메인 결합, 채널별 분기, 통합 로그 적재를 단일 창구로 총괄합니다.

### 3.1 `app/Services/NotificationService.php`

    namespace App\Services;

    use App\Models\NotificationTemplate;
    use App\Models\User;
    use App\Notifications\SystemWebNotification;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Log;
    use Illuminate\Support\Str;

    class NotificationService
    {
        public function __construct(
            protected Cafe24SmsService $smsService
        ) {}

        /**
         * [시스템 자동 알림 통합 발송]
         *
         * @param string $templateCode - 템플릿 코드 (예: SHOP_APPLY_APPROVE_USER)
         * @param string $receiverPhone - 수신자 전화번호
         * @param array $variables - 치환 변수 (예: ['shop_name' => '글로뷰', 'shop_code' => 'C3231772'])
         * @param int|null $userId - 수신 회원 ID (웹 알림 및 회원 식별용)
         * @param string|null $actionUrl - 직접 지정할 이동 URL (미입력 시 템플릿 action_url 활용)
         * @param array $extraData - 부가 데이터 (source_type, source_id 등)
         */
        public function send(
            string $templateCode,
            string $receiverPhone,
            array $variables = [],
            ?int $userId = null,
            ?string $actionUrl = null,
            array $extraData = []
        ): bool {
            $batchId = 'EVT_' . now()->format('YmdHis') . '_' . Str::upper(Str::random(6));

            try {
                // 1. 템플릿 조회 및 활성화 검증
                $template = NotificationTemplate::where('code', $templateCode)->first();
                if (!$template || !$template->is_active) {
                    Log::warning("[NotificationService] 비활성화 또는 누락된 템플릿: {$templateCode}");
                    return false;
                }

                // 2. 모델 내장 파서를 통한 본문 및 URL 치환
                $title   = $template->parseTitle($variables);
                $content = $template->parseContent($variables);
                $fullActionUrl = $this->resolveActionUrl($actionUrl ?: $template->action_url, $variables);

                // 3. 내부 웹 알림(notifications) 적재
                $isWebNotify = isset($template->is_web_notify) ? (bool)$template->is_web_notify : true;
                if ($userId && $isWebNotify) {
                    $this->dispatchWebNotification(
                        batchId: $batchId,
                        templateCode: $templateCode,
                        userId: $userId,
                        title: $title ?: '[GLOBEAU 알림]',
                        content: $content,
                        actionUrl: $fullActionUrl,
                        extraData: $extraData
                    );
                }

                // 4. 외부 문자(SMS/LMS) 발송
                $cleanPhone = preg_replace('/[^0-9]/', '', $receiverPhone);
                if (!empty($cleanPhone)) {
                    $this->dispatchSmsNotification(
                        batchId: $batchId,
                        templateCode: $templateCode,
                        receiverPhone: $cleanPhone,
                        title: $title ?: '[GLOBEAU]',
                        content: $content,
                        fullActionUrl: $fullActionUrl,
                        userId: $userId
                    );
                }

                return true;
            } catch (\Exception $e) {
                Log::error("[NotificationService Exception] Code: {$templateCode} | Msg: " . $e->getMessage());
                return false;
            }
        }

        /**
         * 웹 알림 적재 및 통합 로그 기록
         */
        protected function dispatchWebNotification(
            string $batchId,
            string $templateCode,
            int $userId,
            string $title,
            string $content,
            ?string $actionUrl,
            array $extraData
        ): void {
            $user = User::find($userId);
            if (!$user) return;

            $user->notify(new SystemWebNotification([
                'code'        => $templateCode,
                'title'       => $title,
                'message'     => $content,
                'url'         => $actionUrl,
                'source_type' => $extraData['source_type'] ?? 'system',
                'source_id'   => $extraData['source_id'] ?? null,
            ]));

            DB::table('notification_logs')->insert([
                'batch_id'         => $batchId,
                'dispatch_type'    => 'AUTO',
                'trigger_code'     => $templateCode,
                'sender_type'      => 'SYSTEM',
                'sender_id'        => null,
                'receiver_type'    => 'USER',
                'receiver_id'      => $userId,
                'receiver_contact' => $user->phone ?? $user->email,
                'channel'          => 'WEB',
                'title'            => $title,
                'content'          => $content,
                'action_url'       => $actionUrl,
                'status'           => 'SUCCESS',
                'created_at'       => now(),
                'updated_at'       => now(),
            ]);
        }

        /**
         * 문자 메시지 발송 및 통합 로그 기록
         */
        protected function dispatchSmsNotification(
            string $batchId,
            string $templateCode,
            string $receiverPhone,
            string $title,
            string $content,
            ?string $fullActionUrl,
            ?int $userId
        ): void {
            // 문자는 링크 버튼이 없으므로 하단 자동 첨부
            $smsBody = $content;
            if (!empty($fullActionUrl)) {
                $smsBody .= "\n\n▶ 바로가기: " . $fullActionUrl;
            }

            $smsResult = $this->smsService->send(
                receiverPhone: $receiverPhone,
                message: $smsBody,
                title: $title,
                type: $templateCode,
                userId: $userId
            );

            $channelType = (mb_strwidth($smsBody, 'euc-kr') > 90) ? 'LMS' : 'SMS';

            DB::table('notification_logs')->insert([
                'batch_id'         => $batchId,
                'dispatch_type'    => 'AUTO',
                'trigger_code'     => $templateCode,
                'sender_type'      => 'SYSTEM',
                'sender_id'        => null,
                'receiver_type'    => $userId ? 'USER' : 'GUEST',
                'receiver_id'      => $userId,
                'receiver_contact' => $receiverPhone,
                'channel'          => $channelType,
                'title'            => $title,
                'content'          => $smsBody,
                'action_url'       => $fullActionUrl,
                'status'           => $smsResult ? 'SUCCESS' : 'FAIL',
                'error_message'    => $smsResult ? null : 'SMS 전송 실패',
                'created_at'       => now(),
                'updated_at'       => now(),
            ]);
        }

        /**
         * 상대 경로 URL 치환 및 도메인 자동 결합
         */
        protected function resolveActionUrl(?string $actionUrl, array $variables = []): ?string
        {
            if (empty($actionUrl)) {
                return null;
            }

            foreach ($variables as $key => $val) {
                $actionUrl = str_replace("#{{$key}}", (string)$val, $actionUrl);
            }

            return str_starts_with($actionUrl, 'http://') || str_starts_with($actionUrl, 'https://')
                ? $actionUrl
                : url($actionUrl);
        }
    }

---

## 4. 글로벌 헤더 및 마이페이지 대시보드 UI/UX 설계

### 4.1 글로벌 헤더(GNB/서브헤더) 엠비언트 'N' 배지 정책
1. **마이크로 뱃지(Micro-Badge)의 역할**
    - 사이트 내 어느 화면에서도 헤더 우측 사용자 아이콘 우측 상단에 붉은색 'N' 배지를 노출하여 시야 주변부에서 상태 변화를 자연스럽게 인지시킵니다.
2. **독립성 원칙 (버그 원천 차단)**
    - 매장의 심사 승인(`approved`)이나 반려(`rejected`) 플래그로 인해 상단 아이콘에 무조건 N 뱃지가 상시 노출되던 종속성을 분리합니다.
    - 상단 프로필 아이콘의 [N] 뱃지는 **순수 안 읽은 알림(`$unreadCount > 0`)이 존재할 때만 활성화**하며, 샵 상태는 드롭다운 메뉴 내부에서만 배지로 표현합니다.

### 4.2 드롭다운 메뉴 최상단 알림함 배치 및 수량 표기
1. **메뉴 위계 최우선 배치**
    - 사용자 아이콘 클릭 시 열리는 드롭다운 최상단 1순위 자리에 `마이페이지 홈` 및 `알림함`을 배치합니다.
2. **정량적 미확인 수량 배지 표기**
    - 메뉴 우측에 실제 안 읽은 건수(`$unreadCount`)를 붉은 알약형 배지로 표기하며, UI 오버플로우를 방지하기 위해 `99+` 포맷팅 규칙을 적용합니다.

### 4.3 마이페이지 대시보드 이중 진입점(Dual Entry Point) 전략
1. **상단 프로필 카드 퀵 액션**: [정보수정], [로그아웃] 버튼과 나란히 [알림함] 버튼을 배치하고 붉은 배지를 연동하여 1차 접근을 유도합니다.
2. **하단 메뉴 그리드 대형 카드**: 핵심 메뉴 그리드 1번 카드에 [나의 알림함] 카드를 배치하고, 펄스 도트 및 `N건 안 읽음` 배지를 통해 2차 접점을 완성합니다.
3. **파트너 센터 위젯 연동**: 수신된 입점 승인/반려 알림의 바로가기 클릭 시 해당 상태 확인 페이지(`shops.status`)나 관리자 센터(`shop_admin.index`)로 즉시 이동합니다.

---

## 5. 웹 알림 센터 UI/UX 인터랙션 설계

### 5.1 카드형 대신 게시판(1줄 리스트) 형태 채택 사유
1. **정보 밀도(Information Density) 극대화**: 1페이지에 3~4개만 보이던 카드형 대비, 1줄 게시판 형태로 전환하여 PC 기준 10~15개 이상의 알림 제목과 시각을 즉시 스캔(Skimming)할 수 있습니다.
2. **명확한 읽음/안 읽음 시각 대비**:
    - **안 읽은 알림**: 흰색 바탕 + 굵은 검은색 폰트 + 붉은색 [N] 배지
    - **읽은 알림**: 연한 그레이 배경(`bg-light bg-opacity-50`) + 일반 굵기 텍스트 + 차분한 메타데이터 색상

### 5.2 목록(Index) vs 상세(Show) 화면 분리
1. **목록 화면**: 제목, 수신 시각, 링크 포함 아이콘, 슬림 삭제 버튼만 노출하여 스캔 속도를 최적화합니다.
2. **상세 화면**: 제목 클릭 시 백엔드에서 `read_at`을 자동으로 업데이트(읽음 처리)하고, 편안한 가독성을 위해 본문 전문(`line-height: 1.8`, `1rem`)을 출력합니다.

### 5.3 상세 화면 버튼 위계(Visual Hierarchy) 및 규격 일치화
1. **규격 일치**: 동일 라인에 위치하는 [삭제], [목록으로], [관련 페이지 바로가기] 3개 버튼의 높이를 `height: 40px`, 폰트 크기를 `font-size: 0.88rem`, 모서리 라운드값을 `var(--radius-fixed)`로 완벽히 일치시켜 단차를 제거합니다.
2. **위계 차등**:
    - **관련 페이지 바로가기 (핵심 액션)**: 넉넉한 가로 패딩(`px-4`), 볼드 폰트, 프리미엄 브라운 테마(`btn-premium`)를 적용하여 시선을 집중시킵니다.
    - **알림 삭제 (보조 액션)**: 좌측에 아담하고 슬림한 가로 패딩(`12px`), 차분한 테두리(`btn-outline-secondary`)를 적용하여 불필요한 시선 분산을 방지합니다.

### 5.4 SweetAlert2 삭제 컨펌 모달의 심리적 피로도 완화
- 알림 삭제는 치명적인 파괴 행위가 아니므로, 붉은색 경고 박스를 배제하고 차분한 질문형(`question`) 아이콘과 "[알림명] 알림을 삭제하시겠습니까?"라는 간결한 1문장 컨펌창을 노출합니다.

---

## 6. 프론트엔드 및 백엔드 기술 구현 명세 (풀 소스코드)

### 6.1 전역 뷰 컴포저 (`app/Providers/AppServiceProvider.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 마이페이지 알림 컨트롤러 (`app/Http/Controllers/Mypage/NotificationController.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 서브 헤더 통합 컴포넌트 (`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 (!empty($user->phone)) {
                                $query->where('ceo_phone', $user->phone);
                            }
                        })
                        ->latest('id')
                        ->first();
                    $mypageHomeUrl = Route::has('mypage.dashboard') ? route('mypage.dashboard') : (Route::has('mypage.index') ? route('mypage.index') : url('/mypage'));
                @endphp
                <div class="dropdown">
                    <!-- 순수 unreadCount 기반 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;">
                        <!-- 마이페이지 홈 -->
                        <li>
                            <a class="dropdown-item py-2 d-flex align-items-center gap-2 fw-semibold text-dark" href="{{ $mypageHomeUrl }}">
                                <i class="fas fa-th-large text-brand" style="width: 16px;"></i> 마이페이지 홈
                            </a>
                        </li>
                        <li><hr class="dropdown-divider my-1"></li>

                        <!-- 알림함 메뉴 및 수량 배지 -->
                        <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" style="width: 16px;"></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>
                            <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" style="width: 16px;"></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" style="width: 16px;"></i> 나의 예약 내역
                            </a>
                        </li>

                        <!-- 비즈니스 센터 섹션 -->
                        @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->code ?? $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->code ?? $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" style="width: 16px;"></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" style="width: 16px;"></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 알림 목록 단일 행 컴포넌트 (`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 알림 목록 및 무한 스크롤 메인 뷰 (`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 () {
        // 전체 읽음 처리 AJAX
        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 알림 상세 보기 뷰 (`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

---

## 7. 본사 관리자 메시지 센터 및 확장 로드맵

### 7.1 관리자 메뉴 구성도
    [알림 / 메시지 센터]
    ├── 1. 알림 템플릿 관리   (routes: admin.notifications.templates.*)
    │      └── 자동 발송 문구, #{변수}, action_url, SMS/LMS 타입 관리
    ├── 2. 메시지 직접 발송   (routes: admin.notifications.send.*)
    │      └── 대상 선택(전체, 뷰티샵, 특정인) + 채널 선택(WEB, SMS) 수동 발송
    └── 3. 통합 발송 내역     (routes: admin.notifications.logs.*)
           └── notification_logs 기반 단일 검색 및 batch_id 모달 상세 조회

### 7.2 법적 컴플라이언스 준수 (정보통신망법)
1. **광고성과 정보성 알림의 엄격한 분리**:
    - 정보성 알림(입점 승인, 예약 체결 등)은 수신 동의 무관 발송 가능 및 야간 발송 제한 없음
    - 마케팅/이벤트 알림은 회원 테이블의 `marketing_opt_in = true` 동의자에게만 발송
2. **필수 표기 규격 준수**:
    - 광고성 메시지 발송 시 문두에 `(광고)`, 문미에 `무료수신거부: 080-XXX-XXXX` 표기 의무화
3. **야간 발송 제한 자동 이월**:
    - 20:00 ~ 익일 08:00 사이에 생성된 마케팅 알림은 스케줄러를 통해 익일 오전 08:00 이후로 발송 예약 이월

### 7.3 비동기 큐(Queue Worker) 전환 파이프라인
1. **컨트롤러 지연 차단**: 대량 수동 발송 및 통신사 API 네트워크 지연 시 웹 서버의 타임아웃(504 Gateway Timeout)을 방지하기 위해 `SendNotificationJob` 큐 작업으로 즉시 분리
2. **트랜잭션 안전성**: DB에 `notification_logs` 레코드를 PENDING 상태로 우선 생성 후, 백그라운드 워커가 외부 API 발송 완료 시 SUCCESS/FAIL 상태를 비동기로 갱신

### 7.4 뷰티 O2O 스케줄러 자동화 (CRM)
1. **노쇼(No-Show) 방지 리마인더**:
    - 예약 D-1일 18:00: 예약 확인 리마인더 자동 발송
    - 예약 당일 1~2시간 전: 매장 위치 및 길 안내 문자 자동 발송
2. **후기 작성 유도**: 시술 완료 2시간 후 포인트 적립 안내와 함께 리뷰 작성 유도 알림 발송
3. **재방문 주기 알림**: 뷰티 시술(네일, 피부 등) 특성을 반영하여 마지막 시술 후 25~30일 경과 시 추천 예약 시간 안내 알림 연동