# GLOBEAU 뷰티샵 플랫폼 알림 서비스 매뉴얼

---

## 문서 제어 정보 (Document Control)

| 항목 | 내용 |
|---|---|
| **문서명** | GLOBEAU 뷰티샵 플랫폼 알림 서비스 매뉴얼 (Notification Service Manual) |
| **문서 버전** | v1.1.0 |
| **작성일자** | 2026-10-13 (개정) |
| **적용 대상** | GLOBEAU 웹 플랫폼 (사용자 마이페이지, 뷰티샵 파트너 센터, 본사 통합 어드민) |
| **문서 상태** | 공식 승인 (Approved) |
| **주요 개정 내역** | 1. `notification_logs` 테이블 스키마 고도화 (`template_code` 명칭 표준화, 비즈니스 출처 추적용 `source_type`, `source_id` 다형성 컬럼 추가)<br>2. 사건 추적키(`batch_id`)의 본사 관제 선별 적재 스위치 정책 확립<br>3. 발신 주체(`sender`)와 발생 원인 도메인(`source`)의 역할 분리 체계화<br>4. 통신망 장애 및 발송 실패 시 감사 로그(`FAIL` + `error_message`) 무조건 보존 규칙 수립<br>5. `NotificationService` 및 `NotificationLog` 엘로퀀트 모델 최신 구현체 동기화 |

---

## 제1장. 시스템 개요 및 운영

### 1.1 서비스 목적 및 운영 안내
#### 1. 플랫폼 참여자 간의 원활하고 신속한 소통 지원
- 본 시스템의 가장 큰 목적은 GLOBEAU 플랫폼에서 일어나는 모든 소식을 **시스템, 일반 사용자, 뷰티샵 운영자, 본사 운영자가 서로 막힘없이 소통하도록 돕는 것**입니다.
- **사용자**: 사이트 접속 중에는 마이페이지 알림함(웹 알림)에서, 이동 중에는 스마트폰(문자, 알림톡, 이메일)으로 본인의 예약이나 신청 결과를 즉시 확인합니다.
- **본사 및 뷰티샵 운영자**: 관리자 전용 화면과 업무용 문자를 통해 신규 접수, 심사 요청, 예약 변동 사항을 실시간으로 확인하고 빠르게 대응합니다.

#### 2. 내 소식과 상태를 한곳에서 모아보는 마이페이지 연계
- 마이페이지는 로그인한 회원이 본인의 예약 내역, 신청 현황, 회원 정보를 확인하는 가장 익숙한 공간입니다.
- 알림함을 엉뚱한 곳에 두지 않고 마이페이지 안에 배치하여, 사용자가 "내 정보와 진행 상태를 보러 가는 곳"에서 나에게 온 알림까지 한눈에 편안하게 찾아볼 수 있도록 구성했습니다.

#### 3. 알림 시스템을 구성하는 주요 5대 발송 채널의 역할
GLOBEAU 알림 시스템은 아래 5개 핵심 채널이 유기적으로 결합되어 운영됩니다.
- **웹 알림 (Web Notification)**: 사이트 이용 중인 사용자가 다른 앱으로 이동할 필요 없이 화면 안에서 즉시 확인하고 다음 작업(매장 관리, 예약 확인 등)으로 연결되는 온사이트 편의성 제공. 마이페이지 내 개인화된 소식 집약.
- **단문 문자 (SMS)**: 90바이트 이내의 핵심 요약 정보를 데이터 네트워크 연결 상태와 무관하게 모든 모바일 기기에 즉각적이고 확실하게 도달시키는 채널.
- **장문 문자 (LMS)**: 상세한 안내 본문과 바로가기 URL을 포함하여 최대 2,000바이트까지 안정적으로 전달하는 대용량 모바일 채널.
- **카카오 알림톡 (Alimtalk)**: 국민 메신저 카카오톡을 통해 공식 인증된 브랜드 프로필과 편리한 웹링크 버튼을 함께 전달하여 높은 신뢰도와 조작 편의성을 제공하는 메신저 채널.
- **이메일 (Email)**: 장문의 상세 정책, 신청 명세서, 증빙 자료 등 데이터 보관성과 공식 문서 성격이 강한 소통을 담당하는 채널.

#### 4. 사용자 중심의 알림 보관 및 관리 정책 (수동 삭제 원칙)
- 사용자가 수신한 알림(웹 알림)은 시스템이 30일/90일 경과 후 임의로 파기하지 않습니다.
- 중요한 예약 번호, 입점 피드백 안내, 혜택 링크를 사용자가 스크랩처럼 언제든 다시 찾아볼 수 있도록 영구 보관하며, 사용자가 본인의 의사에 따라 직접 [삭제] 버튼을 눌러 관리하도록 지원합니다.

---

## 제2장. 알림 발송 정책 및 운영 체계

### 2.1 발송 방식 구분 (자동 발송 vs 수동 발송)
GLOBEAU 플랫폼의 알림 발송은 시스템 비즈니스 이벤트에 의해 실행되는 **'자동 발송'**과 관리자/점주가 필요에 의해 전송하는 **'수동 발송'**으로 명확히 구분됩니다.

| 구분 | 발송 주체 | 트리거 방식 | 대상 지정 | 주요 사용 목적 |
|---|---|---|---|---|
| **자동 발송 (AUTO)** | 시스템 Bot | 비즈니스 로직 이벤트 감지 | 단일 수신자 (신청자, 담당자) | 입점 승인/반려, 예약 상태 변경, 결제/취소 |
| **본사 수동 발송 (MANUAL)** | 본사 최고 관리자 | 어드민 [메시지 직접 발송 / 캠페인] | 전체 회원, 전체 뷰티샵, 특정인 | 서비스 점검 공지, 정책 변경, 마케팅 프로모션 |
| **뷰티샵 수동 발송 (MANUAL)** | 뷰티샵 원장/매니저 | 뷰티샵 [고객 관리 센터] 화면 | 해당 뷰티샵 이용/예약 고객 | 매장 임시 휴무 안내, 시술 전/후 주의사항 통보 |

---

### 2.2 자동 발송(System Trigger) 세부 정책 및 안전장치

#### 1. 템플릿 코드 식별 표준화
- 모든 자동 발송 템플릿은 `[도메인]_[행위]_[수신주체]` 구조의 고유 코드로 명명하여 코드만으로도 발송 목적과 대상을 즉시 식별할 수 있도록 합니다.
  - 예시: `SHOP_APPLY_ADMIN` (입점 신청 접수 시 본사 통보)
  - 예시: `SHOP_APPLY_USER` (입점 신청 접수 완료 시 신청자 통보)
  - 예시: `SHOP_APPLY_APPROVE_USER` (입점 신청 승인 완료 시 원장 통보)
  - 예시: `RESERVATION_CONFIRM_USER` (예약 확정 시 고객 통보)

#### 2. 사건 추적키(`batch_id`)와 본사 관제 선별 적재 정책 (핵심 아키텍처)
`batch_id`는 단순한 난수가 아니라 **"본사 운영자가 이 발송 건을 한 묶음의 사건(Incident/Ticket)으로 관리할 것인가?"**를 결정하는 선별 제어 앵커입니다.

- **사건 묶음 관제 대상 (`batch_id` 전달)**:
  - 입점 신청, 취소, 예약, 결제 등 1건의 사건으로 인해 여러 대상(사용자 + 관리자)과 여러 채널(웹 + 문자)로 파생 발송되는 경우 컨트롤러에서 동일한 `batch_id`(`EVT_YYYYMMDD_랜덤문자`)를 공유하여 넘깁니다.
  - 서비스는 해당 `batch_id`를 기반으로 `notification_logs` 테이블에 각각의 채널 발송 결과를 기록하여, 본사 관제 화면에서 1건의 사건 티켓으로 묶어서 모니터링할 수 있도록 합니다.
- **단순 1회성 트랜잭션 (`batch_id` 미전달 / `null`)**:
  - 휴대폰 본인인증 번호, 비밀번호 재설정 코드 등 관리자가 굳이 관제 화면에서 추적할 필요가 없는 일회성 알림입니다.
  - 이 경우 `batch_id`를 `null`로 호출하며, 서비스는 `notification_logs` 적재를 완전히 생략하여 관제 DB의 용량 낭비를 막습니다. (단, 과금 및 통신 증빙을 위해 원천 `sms_logs`에는 100% 기록됩니다.)
- **실패 시 관제 로그 보존 보장 (Fail-Safe Audit)**:
  - `batch_id`가 부여된 사건의 경우, 외부 통신사 API 오류나 타임아웃으로 발송이 실패하더라도 스크립트 예외를 중단시키지 않고 `notification_logs`에 `status = 'FAIL'`, `error_message = '원인'`으로 반드시 기록하여 관리자가 누락 사실을 인지하고 재발송할 수 있도록 합니다.

#### 3. 설정 파일 기반 치환 변수 중앙 레지스트리 (`config/notifications.php`)
하드코딩된 변수로 인한 오류를 방지하고, 템플릿 등록 시 허용된 변수를 한눈에 파악할 수 있도록 라라벨 설정 파일에서 치환 변수를 중앙 관리합니다.

    // config/notifications.php 설정 구조
    return [
        'common_variables' => [
            'user_name'    => '수신 회원명',
            'created_at'   => '신청/처리 일시',
            'service_name' => '플랫폼명 (기본: GLOBEAU)',
        ],
        'templates' => [
            'SHOP_APPLY_APPROVE_USER' => [
                'name' => '뷰티샵 입점 승인 안내 (신청자용)',
                'allowed_variables' => [
                    'shop_name' => '승인된 매장명',
                    'shop_code' => '매장 고유 코드',
                ],
                'default_action_url' => '/shops/#{shop_code}/status',
            ],
            'RESERVATION_CONFIRM_USER' => [
                'name' => '방문 예약 확정 안내 (고객용)',
                'allowed_variables' => [
                    'shop_name'        => '예약 뷰티샵명',
                    'reservation_date' => '예약 일시',
                    'menu_name'        => '시술 프로그램명',
                ],
                'default_action_url' => '/mypage/reservations',
            ],
        ],
    ];

- **UI 연동**: 본사 어드민 템플릿 수정 화면에서는 위 설정 파일의 정보를 읽어와 **"사용 가능한 치환 변수 뱃지"**를 화면에 자동으로 노출하며, 클릭 시 본문에 즉시 삽입되도록 지원합니다.

#### 4. 치환 검증 및 안전장치 (Fail-Safe)
- **변수 누락 방어**: 컨트롤러에서 `$variables` 배열에 일부 변수값을 누락하여 전달하더라도 시스템 Fatal Error가 발생하지 않도록, 미정의된 변수는 공백(`""`) 또는 기본 대시(`"-"`)로 안전하게 치환됩니다.
- **예상치 못한 미치환 태그 잔존 방지**: 발송 직전 정규식을 검사하여 미치환된 `#{...}` 패턴이 남아있을 경우 시스템 로그에 경고(`Log::warning`)를 기록하고 원문 형태를 정돈하여 발송합니다.

#### 5. 실시간 활성화 제어 및 채널별 토글
- 소스코드 수정이나 배포 없이 본사 관리자 화면에서 다음 항목을 실시간으로 켜고 끌 수 있습니다.
  - **전체 활성화 (`is_active`)**: 끄면 해당 템플릿의 모든 발송(웹, 문자)이 즉시 차단됩니다.
  - **웹 알림 여부 (`is_web_notify`)**: 회원 화면용 인앱 알림 적재 여부를 독립 제어합니다.

#### 6. 용량 기반 발송 타입 자동 승격 (SMS -> LMS)
- 본문 텍스트 치환 완료 후, EUC-KR 인코딩 기준 바이트 길이를 실시간 검사합니다.
- **90바이트 이하**: 일반 단문 `SMS`로 발송
- **90바이트 초과**: 시스템이 자동으로 장문 `LMS`로 승격 처리하여 텍스트 잘림을 방지하고, 통신사 요금 과금 구분을 정확히 기록합니다.

---

### 2.3 수동 발송(Manual Broadcast) 대량 발송 및 성능 방어 대책

수백~수천 명을 대상으로 하는 관리자 수동 공지 발송 시 발생할 수 있는 **웹 서버 타임아웃(PHP `max_execution_time` 30초 초과, Nginx 504 Gateway Timeout)**을 원천 차단하기 위한 아키텍처입니다.

#### 1. 웹 요청 즉시 분리 (비동기 처리 원칙)
- 관리자가 수동 발송 버튼을 누르면, 웹 컨트롤러는 동기 방식으로 외부 통신사 API를 호출하지 않습니다.
- 상위 마스터인 **`notification_campaigns`** 레코드를 생성한 후, 발송 기본 정보와 대상 목록을 담아 **`BulkNotificationDispatchJob`**을 라라벨 큐(Queue)로 디스패치하고 **0.1초 만에 화면에 "발송 작업이 등록되었습니다"를 응답**합니다.

#### 2. 청크(Chunking) 분할 및 API 부하 제어
- 큐 워커(Queue Worker)는 백그라운드에서 수신 대상을 100~200명 단위의 청크(`chunkById`)로 분할하여 처리합니다.
- 통신사(카페24) API의 초당 요청 한도(TPS)를 준수할 수 있도록 배치 간 미세 지연(Rate Limiting)을 두어 차단 및 실패를 방지합니다.

#### 3. 발송 진행률(Progress) 실시간 추적
- 개별 발송 로그는 `source_type = 'campaign'`, `source_id = $campaign->id`로 연결되어 실시간 진행률(예: 850/1,000건 완료)을 어드민 화면에 렌더링합니다.

---

### 2.4 뷰티샵 관리자(Shop Admin)의 알림 발송 정책 및 권한 통제

뷰티샵 원장 및 매니저가 매장 고객에게 안내 알림을 발송할 때 적용되는 엄격한 격리 정책입니다.

#### 1. 수신 대상 범위의 엄격한 격리 (Tenant Isolation)
- 뷰티샵 관리자는 **자신의 매장을 이용한 이력이 있거나 현재 예약이 체결된 회원**에게만 알림을 보낼 수 있습니다.
- 타 매장 고객에 대한 임의 조회 및 발송 권한은 백엔드 쿼리 레벨에서 차단됩니다 (`where('shop_id', $currentShopId)`).

#### 2. 허용 발송 목적 및 채널 범위
- 예약 변경, 시술 전 주의사항 안내, 노쇼 확인, 매장 임시 휴무 공지 등 **매장 운영에 직접 관련된 안내**로 용도를 제한합니다.
- 광고성 홍보 메시지는 정보통신망법 준수를 위해 본사의 사전 승인된 양식 또는 정해진 마케팅 수신 동의 회원에게만 발송 가능합니다.

#### 3. 과금 및 발송 비용 정책
- 뷰티샵이 발송하는 외부 유료 문자(SMS/LMS)는 뷰티샵별 충전 포인트에서 차감하거나, 입점 계약에 따른 월간 무료 지원 한도 내에서 차감합니다.

#### 4. 본사 통합 감사(Audit) 및 모니터링
- 뷰티샵 관리자가 발송한 모든 내역 역시 중앙 `notification_logs`에 `sender_type='SHOP'`, `sender_id={shop_id}`로 빠짐없이 기록됩니다.
- 본사 운영자는 불법 스팸, 비방, 부적절한 홍보 발송 여부를 상시 모니터링하고 해당 매장의 발송 권한을 즉시 정지할 수 있습니다.

---

### 2.5 메시지 분기 및 이동 경로 정책 (방안 A)
- **본문(`content`) 순수 텍스트화**: 템플릿 본문에는 군더더기 없는 순수 안내 문구만 작성하며, 본문 안에 URL 주소를 직접 적지 않습니다.
- **이동 경로(`action_url`) 분리 관리**: 템플릿 설정의 전용 URL 필드에 이동할 경로를 따로 관리하며, 상대 경로(`/shops/#{shop_code}/status`)를 입력해 두면 시스템이 발송할 때 현재 도메인 주소를 자동으로 결합하여 완성합니다.
- **웹 알림 (인앱)**: 본문 내에 복잡한 인터넷 주소를 노출하지 않고, 상세 화면 하단의 전용 버튼([관련 페이지 바로가기])을 누르면 해당 화면으로 바로 이동하도록 처리합니다.
- **문자 메시지 (SMS/LMS)**: 문자는 버튼 기능이 없으므로, 시스템이 문자를 보낼 때 자동으로 본문 맨 아래에 `\n\n▶ 바로가기: {전체 도메인 URL}`을 덧붙여 전송합니다.
- **카카오 알림톡 규격 100% 호환**: 본문과 버튼 링크를 엄격히 분리하여 심사하는 카카오 알림톡 기준에 맞춰져 있으므로, 향후 알림톡을 도입할 때 문구를 다시 고칠 필요 없이 그대로 활용할 수 있습니다.

---

## 제3장. 데이터베이스 아키텍처 및 테이블 설계

### 3.1 데이터베이스 구조 개요 및 역할 분류

GLOBEAU 알림 데이터베이스는 **알림 생성 규칙**, **수신자 확인 및 채널별 실행 로그**, 그리고 **본사 운영자의 총괄 관리**라는 3대 목적에 따라 역할을 명확히 분리하여 설계합니다.

    [1. 규칙 정의 레이어]
    notification_templates (자동 알림 생성 규칙 및 치환변수 마스터)

    [2. 수신자 확인 및 개별 채널 실행/증빙 레이어]
    ├── notifications   : 사용자/관리자가 직접 화면에서 열람하는 인앱 웹 알림함
    ├── sms_logs        : 통신사(카페24) 문자 발송 결과 및 유료 과금 증빙 로그
    ├── email_logs      : 이메일 전송 결과 및 수신 이력 로그
    └── alimtalk_logs   : [차후 확장] 카카오 알림톡 발송 상세 및 대체발송 증빙 로그

    [3. 본사 운영자 통합 관리 레이어]
    notification_logs (본사 운영자가 전체 발송 현황을 한곳에서 추적·모니터링하는 통합 관제 로그)

#### 1. 발송 규칙 및 메시지 생성 테이블 (발송 전 단계)
- **`notification_templates`**: 시스템 이벤트 발생 시 치환변수(`#{...}`)를 대입하여 본문과 `action_url`을 조합하고, 발송 채널을 통제하는 기준 데이터입니다.

#### 2. 수신자 확인 및 개별 채널 실행/증빙 테이블 (수신자 접점 및 통신망 로그)
- **`notifications`**: 사용자가 마이페이지나 상단 바에서 직접 조회하고 수동 삭제하는 인앱 전용 사물함입니다.
- **`sms_logs`**: 인증번호든 공지든 카페24를 거친 모든 문자의 원천 통신/과금 영수증입니다.
- **`email_logs`**: 시스템이 발송한 공식 이메일의 전송 결과 로그입니다.
- **`alimtalk_logs`**: 카카오 알림톡 도달 결과와 LMS 대체발송(Fallback) 이력을 증빙하는 전용 로그입니다.

#### 3. 본사 운영자 통합 관리 테이블 (운영자 관제)
- **`notification_logs`**:
  - 본사 관리자가 "어느 채널로 발송되었고 성공/실패했는가"를 단일 화면에서 총괄 확인하기 위한 **유일한 통합 관제 테이블**입니다.
  - 하나의 비즈니스 사건으로 인해 파생된 여러 채널의 발송 내역들을 단일 묶음키(**`batch_id`**)로 묶어 관리합니다.
  - **선별 적재 원칙**: 관리자가 추적할 가치가 있는 중요 사건(`batch_id` 존재 시)만 기록하며, 1회성 단순 인증번호 문자는 기록하지 않아 관제 데이터의 순도를 높입니다.

---

### 3.2 테이블별 상세 명세서 (Schema Definition)

#### 1. 알림 템플릿 테이블 (`notification_templates`)
- **목적**: 시스템 자동 발송 문구 조립 규칙, 치환변수, 2단 업무 분류, 이동 경로 및 채널 정의
- **물리 테이블명**: `notification_templates`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Bigint (unsigned) | N | Auto Increment | PK | 템플릿 고유 식별 일련번호 |
| **2** | 식별값 | `code` | Varchar(50) | N | - | Unique Index | 템플릿 고유 식별 코드 (예: `SHOP_APPLY_USER`) |
| **3** | 업무분류 | `category` | Varchar(30) | N | - | Composite Index<br>(`category`, `subcategory`) | 대분류: `auth`, `shop`, `reservation`, `billing`, `notice` |
| **4** | 업무분류 | `subcategory` | Varchar(30) | N | - | Composite Index<br>(`category`, `subcategory`) | 소분류: `verify`, `apply`, `confirm`, `cancel`, `remind`, `payment` |
| **5** | 수신대상 | `target` | Varchar(20) | N | 'user' | Index | 기본 수신 대상: `user`, `shop`, `admin` |
| **6** | 채널구분 | `type` | Varchar(20) | N | 'SMS' | - | 기본 전송 채널: `SMS`, `LMS`, `ALIMTALK`, `EMAIL` |
| **7** | 내용/제목 | `title` | Varchar(255) | Y | NULL | - | LMS 제목 / 알림톡 강조 타이틀 / 이메일 제목 |
| **8** | 내용/본문 | `content` | Text | N | - | - | 발송 본문 전문 (치환 변수 포함: `#{shop_name}` 등) |
| **9** | 외부연동 | `template_key` | Varchar(100) | Y | NULL | - | 카카오 비즈니스 사전 승인 템플릿 코드 |
| **10** | 설명/가이드 | `description` | Text | Y | NULL | - | 관리자용 템플릿 설명 및 치환 가능 변수 가이드 |
| **11** | 제어스위치 | `is_active` | Boolean | N | 1 (true) | Index | 발송 활성화 여부 (1: 발송ON, 0: 발송OFF) |
| **12** | 제어스위치 | `is_web_notify` | Boolean | N | 1 (true) | - | 웹 내부 알림함(`notifications`) 적재 여부 |
| **13** | 연결링크 | `action_url` | Varchar(255) | Y | NULL | - | 웹 알림 클릭 시 이동할 기본 상대 URL |
| **14** | 일시 | `created_at` | Timestamp | Y | NULL | - | 레코드 생성 일시 |
| **15** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 레코드 최종 수정 일시 |
| **16** | 일시 | `deleted_at` | Timestamp | Y | NULL | - | 삭제 일시 (소프트 딜리트용) |

#### 2. 웹 알림함 테이블 (`notifications`)
- **목적**: 일반 사용자 및 뷰티샵 관리자가 마이페이지/상단 바에서 조회하는 인앱 알림 데이터
- **물리 테이블명**: `notifications` (라라벨 공식 표준)

| 번호 | 구분 | 컬럼명 | 타입 | 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<br>(`notifiable_type`, `notifiable_id`) | 수신 대상 모델 클래스 (`App\Models\User`) |
| **4** | 다형성 | `notifiable_id` | Bigint (unsigned) | N | - | Composite Index<br>(`notifiable_type`, `notifiable_id`) | 수신 대상 회원의 PK (`users.id`) |
| **5** | 페이로드 | `data` | Text / LongText | N | - | - | 알림 본문 데이터 JSON 규격 (`title`, `message`, `url`, `source_type`, `source_id`) |
| **6** | 상태값 | `read_at` | Timestamp | Y | NULL | Index | 알림 열람 일시 (`NULL`: 안 읽음, `Timestamp`: 읽음) |
| **7** | 일시 | `created_at` | Timestamp | Y | NULL | - | 알림 발송 일시 |
| **8** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 최종 수정 일시 |

#### 3. 통신사 문자 전송 로그 테이블 (`sms_logs`)
- **목적**: 카페24 통신사를 통한 문자 발송 결과 증빙 및 유료 포인트 정산 내역 보관
- **물리 테이블명**: `sms_logs`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Bigint (unsigned) | N | Auto Increment | PK | SMS 발송 로그 고유 식별자 |
| **2** | 식별값 | `user_id` | Bigint (unsigned) | Y | NULL | - | 수신 회원 ID (비회원/게스트 발송 시 NULL) |
| **3** | 수신대상 | `phone` | Varchar(20) | N | - | Composite Index<br>(`phone`, `type`) | 실제 수신 휴대폰 번호 (숫자만 정제 저장) |
| **4** | 내용/제목 | `title` | Varchar(100) | Y | NULL | - | 문자 제목 (LMS 발송 시 표기) |
| **5** | 내용/본문 | `message` | Text | N | - | - | 실제 통신사로 전송된 문자 본문 전문 |
| **6** | 업무구분 | `type` | Varchar(30) | N | 'verify' | Composite Index<br>(`phone`, `type`) | 발송 업무 구분 (`phone_verification`, `SHOP_APPLY_USER` 등) |
| **7** | 상태값 | `status` | Varchar(20) | N | 'success' | - | 전송 상태 (`success`: 성공, `failed`: 실패) |
| **8** | 실패사유 | `error_message` | Text | Y | NULL | - | 실패 시 통신사 게이트웨이 응답 전문 |
| **9** | 클라이언트 | `ip_address` | Varchar(45) | Y | NULL | - | 발송 요청 IP 주소 |
| **10** | 일시 | `created_at` | Timestamp | Y | NULL | Index | 발송 일시 |
| **11** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 최종 수정 일시 |

#### 4. 이메일 발송 로그 테이블 (`email_logs`)
- **목적**: 플랫폼에서 발송된 공식 이메일의 전송 결과 및 도달 여부 기록
- **물리 테이블명**: `email_logs`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Bigint (unsigned) | N | Auto Increment | PK | 이메일 로그 고유 식별자 |
| **2** | 식별값 | `user_id` | Bigint (unsigned) | Y | NULL | Index | 수신 회원 ID (비회원 발송 시 NULL) |
| **3** | 수신대상 | `recipient_email` | Varchar(255) | N | - | Index | 수신자 이메일 주소 |
| **4** | 내용/제목 | `subject` | Varchar(255) | N | - | - | 발송 메일 제목 |
| **5** | 내용/본문 | `content` | LongText | N | - | - | 발송된 HTML 본문 전문 |
| **6** | 업무구분 | `type` | Varchar(50) | N | 'NOTICE' | - | 발송 업무 목적 |
| **7** | 상태값 | `status` | Varchar(20) | N | 'PENDING' | - | 전송 상태 (`PENDING`, `SUCCESS`, `FAIL`) |
| **8** | 실패사유 | `error_message` | Text | Y | NULL | - | 실패 시 SMTP 에러 메시지 |
| **9** | 일시 | `created_at` | Timestamp | Y | NULL | - | 로그 생성 일시 |
| **10** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 최종 수정 일시 |

#### 5. 카카오 알림톡 로그 테이블 (`alimtalk_logs` - 차후 확장)
- **목적**: 카카오 비즈메시지 API 발송 결과 및 LMS 대체발송(Fallback) 이력 관리
- **물리 테이블명**: `alimtalk_logs`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Bigint (unsigned) | N | Auto Increment | PK | 알림톡 로그 고유 식별자 |
| **2** | 식별값 | `user_id` | Bigint (unsigned) | Y | NULL | Index | 수신 회원 ID |
| **3** | 수신대상 | `phone` | Varchar(20) | N | - | Composite Index<br>(`phone`, `type`) | 수신 휴대폰 번호 |
| **4** | 외부식별 | `template_code` | Varchar(100) | N | - | Index | 카카오 비즈니스 템플릿 키 |
| **5** | 내용/제목 | `title` | Varchar(100) | Y | NULL | - | 알림톡 강조 타이틀 |
| **6** | 내용/본문 | `message` | Text | N | - | - | 알림톡 본문 전문 |
| **7** | 부가기능 | `buttons` | Json | Y | NULL | - | 알림톡 버튼 JSON |
| **8** | 업무구분 | `type` | Varchar(30) | N | 'NOTICE' | Composite Index<br>(`phone`, `type`) | 발송 업무 구분 |
| **9** | 상태값 | `status` | Varchar(20) | N | 'PENDING' | Composite Index<br>(`status`, `created_at`) | 전송 상태 (`SUCCESS`, `FAIL`, `PENDING`) |
| **10** | 결과코드 | `result_code` | Varchar(30) | Y | NULL | - | 카카오 응답 코드 |
| **11** | 실패사유 | `error_message` | Text | Y | NULL | - | 실패 상세 사유 |
| **12** | 대체발송 | `is_fallback_lms` | Boolean | N | 0 (false) | - | 문자 대체발송 여부 |
| **13** | 대체발송 | `fallback_sms_log_id` | Bigint (unsigned) | Y | NULL | Index | 대체발송 시 `sms_logs.id` 연동 |
| **14** | 클라이언트 | `ip_address` | Varchar(45) | Y | NULL | - | 발송 요청 IP 주소 |
| **15** | 일시 | `created_at` | Timestamp | Y | NULL | Composite Index<br>(`status`, `created_at`) | 생성 일시 |
| **16** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 최종 수정 일시 |

#### 6. 본사 운영자 통합 관리 로그 테이블 (`notification_logs` - 최종 표준)
- **목적**: 본사 운영자가 모든 채널의 발송 사건을 한곳에서 추적·검색·감사하기 위한 최상위 관제 테이블
- **물리 테이블명**: `notification_logs`

| 번호 | 구분 | 컬럼명 | 타입 | Null | 기본값 | 인덱스/제약조건 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- | :--- |
| **1** | 기본키 | `id` | Bigint (unsigned) | N | Auto Increment | PK | 통합 발송 관제 로그 고유 식별자 |
| **2** | 묶음키 | `batch_id` | Varchar(64) | N | - | Index | 동일 사건 다중 채널 발송 묶음키 (`EVT_YYYYMMDD_랜덤문자`) |
| **3** | 발송유형 | `dispatch_type` | Varchar(20) | N | 'AUTO' | - | 발송 유형: `AUTO`(시스템 자동), `MANUAL`(관리자/캠페인 수동) |
| **4** | 템플릿식별 | `template_code` | Varchar(50) | Y | NULL | Index | 알림 템플릿 코드 (`SHOP_APPLY_USER`, `SHOP_APPLY_ADMIN` 등) |
| **5** | 연관도메인 | `source_type` | Varchar(50) | Y | NULL | Composite Index<br>(`source_type`, `source_id`) | 발송 원본 도메인 (`shop`, `reservation`, `payment`, `campaign` 등) |
| **6** | 연관도메인PK | `source_id` | Bigint (unsigned) | Y | NULL | Composite Index<br>(`source_type`, `source_id`) | 발송 원본 테이블 PK (`shops.id`, `reservations.id` 등) |
| **7** | 발송주체 | `sender_type` | Varchar(20) | N | 'SYSTEM' | - | 발송 주체: `SYSTEM`(시스템), `ADMIN`(본사관리자), `SHOP`(뷰티샵) |
| **8** | 발송주체ID | `sender_id` | Bigint (unsigned) | Y | NULL | Index | 발송자 ID (관리자 `user_id` 또는 `shop_id`, SYSTEM일 경우 NULL) |
| **9** | 수신구분 | `receiver_type` | Varchar(20) | N | 'USER' | - | 수신 대상 구분: `USER`, `SHOP_OWNER`, `ADMIN`, `GUEST` |
| **10** | 수신회원ID | `receiver_id` | Bigint (unsigned) | Y | NULL | Index | 수신 회원 ID (`users.id`, 본사/비회원 발송 시 NULL) |
| **11** | 수신연락처 | `receiver_contact` | Varchar(100) | Y | NULL | Index | 수신 연락처 (휴대폰 번호 또는 이메일 주소) |
| **12** | 채널구분 | `channel` | Varchar(20) | N | - | Composite Index<br>(`created_at`, `channel`) | 전송 채널: `WEB`, `SMS`, `LMS`, `ALIMTALK`, `EMAIL` |
| **13** | 제목 | `title` | Varchar(255) | Y | NULL | - | 메시지 제목 |
| **14** | 본문 | `content` | Text | N | - | - | 치환 완료된 실제 발송 본문 전문 |
| **15** | 바로가기 | `action_url` | Varchar(255) | Y | NULL | - | 도메인이 결합된 연결 이동 전체 URL |
| **16** | 전송상태 | `status` | Varchar(20) | N | 'SUCCESS' | Composite Index<br>(`channel`, `status`) | 전송 상태: `SUCCESS`, `FAIL`, `PENDING` |
| **17** | 결과코드 | `error_code` | Varchar(50) | Y | NULL | - | 통신사 에러코드 또는 시스템 예외 코드 |
| **18** | 실패사유 | `error_message` | Text | Y | NULL | - | 실패 상세 사유 (통신망 장애, 결번, 잔여포인트 부족 등) |
| **19** | 요청IP | `ip_address` | Varchar(45) | Y | NULL | - | 발송 요청 IP 주소 (보안 감사용) |
| **20** | 일시 | `created_at` | Timestamp | Y | NULL | Composite Index<br>(`created_at`, `channel`) | 로그 생성 일시 |
| **21** | 일시 | `updated_at` | Timestamp | Y | NULL | - | 최종 수정 일시 |

---

### 3.3 테이블 간 상관관계 및 데이터 연계 구조

#### 1. 테이블 연계 개념도

    [발송 규칙 마스터]
    notification_templates (템플릿 코드: code)
          │
          │ 1. 템플릿 참조 및 변수 치환
          ▼
    [본사 운영자 통합 관리 테이블]
    notification_logs (운영자 통합 관제 로그) ───────────── batch_id (사건 묶음키)
          │                                      │ (source_type, source_id 로 원본 컨텐츠 매핑)
          ├───────────────────────┬──────────────┴─────────┬───────────────────────┐
          │ 채널: 'WEB'           │ 채널: 'SMS'/'LMS'      │ 채널: 'EMAIL'         │ [차후 확장]
          ▼                       ▼                        ▼                       ▼
    notifications           sms_logs                 email_logs              alimtalk_logs
    (사용자 웹 알림함)        (통신사 문자 로그)        (이메일 발송 로그)        (알림톡/대체발송 로그)
    - id (UUID)             - id                     - id                    - id
    - notifiable_id=회원ID  - phone (수신번호)       - recipient_email (수신) - phone (수신번호)
    - data[url]=action_url  - type (업무구분)        - type (업무구분)        - template_code (카카오키)
    - read_at (열람 확인)   - status (성공/실패)     - status (전송상태)      - status / result_code
                            - message (발송본문)     - content (메일본문)     - is_fallback_lms
                                                                             - buttons (링크버튼)

#### 2. 핵심 컬럼 연계 및 역할 분리 규정
- **`template_code` 매핑**: `notification_templates.code`가 `notification_logs.template_code`에 그대로 기록되어 어떤 규칙으로 생성된 알림인지 추적합니다.
- **발송 주체(`sender`) vs 발생 원인(`source`) 분리**:
  - `sender_type = 'SYSTEM'`, `sender_id = null`: 시스템 봇이 비즈니스 로직에 의해 자동 발송함.
  - `source_type = 'shop'`, `source_id = 15`: 15번 매장의 입점 신청서 건으로 인해 발생함.
  - 본사 관리자 화면에서는 `source_type`과 `source_id`를 참조하여 해당 뷰티샵 심사 페이지나 예약 상세 페이지로 원클릭 딥링크(Jump Link)를 제공합니다.
- **다중 채널 묶음 키 (`batch_id`)**:
  - 단일 사건(입점 신청 등)으로 인해 발생한 신청자 웹알림, 신청자 SMS, 본사 관리자 SMS는 모두 동일한 `batch_id`를 가집니다.
  - 관리자 관제 화면에서는 이 `batch_id`를 기준으로 그룹화하여 1건의 사건 카드로 조회할 수 있습니다.
- **수신 접점 데이터 연동**:
  - `notification_logs.receiver_id` ⟷ `notifications.notifiable_id` (회원 웹 알림함)
  - `notification_logs.receiver_contact` ⟷ `sms_logs.phone` (휴대폰 문자 내역)

---

### 3.4 데이터 적재 흐름 및 생명주기 (Data Flow & Sequence)

비즈니스 이벤트(예: 뷰티샵 입점 신청 완료) 발생 시 데이터 처리 순서는 다음과 같습니다.

    [단계 1: 컨트롤러에서 사건 발생 및 batch_id 발행]
    ShopController::store()에서 DB 트랜잭션이 성공적으로 commit 된 직후,
    해당 비즈니스 사건을 대표할 고유키($batchId = 'EVT_20261013_A8F9D2')를 발행합니다.
            │
            ▼
    [단계 2: 본사 운영자 및 신청자 대상 NotificationService::send() 호출]
    동일한 $batchId와 extraData(['source_type' => 'shop', 'source_id' => $shop->id])를 주입하여
    관리자 통보용(SHOP_APPLY_ADMIN)과 신청자용(SHOP_APPLY_USER) 알림을 순차 호출합니다.
            │
            ▼
    [단계 3: 템플릿 로드, 치환 및 웹 알림 적재 (채널: WEB)]
    NotificationService 내부에서 is_web_notify 활성화 시:
    1) notifications 테이블에 레코드 INSERT ($user->notify())
    2) batch_id가 존재하므로 notification_logs에 channel='WEB', status='SUCCESS' INSERT
            │
            ▼
    [단계 4: 외부 통신사 문자 발송 및 관제 로그 적재 (채널: LMS/SMS)]
    1) EUC-KR 90바이트 초과 여부에 따라 SMS/LMS 결정 및 하단 바로가기 링크 첨부
    2) Cafe24SmsService 호출 ──> 통신사 전송 실행 (실패하더라도 try-catch 격리)
    3) sms_logs 테이블에 통신사 결과 코드와 함께 INSERT (과금 영수증)
    4) batch_id가 존재하므로 notification_logs에 channel='SMS'/'LMS', status='SUCCESS'/'FAIL' INSERT
       (통신망 장애 발생 시 status='FAIL' 및 error_message 온전히 기록)
            │
            ▼
    [단계 5: 본사 운영자 관제 화면 확인]
    관리자는 notification_logs 화면에서 WHERE batch_id = 'EVT_20261013_A8F9D2' 로
    신청자 웹/문자, 관리자 문자의 성공/실패 여부를 단일 묶음 카드로 한눈에 확인합니다.

---

### 3.5 데이터 무결성 및 시스템 제약 규정 (Integrity & Constraints)

#### 1. 논리적 참조 원칙 (물리 외래키 제약 배제)
- `notification_logs`, `sms_logs`, `email_logs`, `alimtalk_logs`는 회원(`users`)이나 매장(`shops`) 테이블에 대해 물리적인 데이터베이스 외래키(`FOREIGN KEY ... ON DELETE CASCADE`)를 설정하지 않습니다.
- **사유**: 회원이 서비스를 탈퇴하거나 매장이 폐업 삭제되더라도, **과거 발송 내역에 대한 감사(Audit) 및 통신 과금 증빙 데이터는 법정 보관 기간 동안 영구 보존**되어야 하기 때문입니다.

#### 2. 복합 인덱스(Composite Index) 전략
- **`['source_type', 'source_id']`**: 특정 매장, 특정 예약 건과 연관된 알림 발송 이력 전체를 역추적하는 감사 쿼리 커버링
- **`['created_at', 'channel']`**: 기간별, 발송 채널별 통계 집계 성능 극대화
- **`['channel', 'status']`**: "SMS 발송 실패(`FAIL`) 건만 필터링하여 일괄 재발송" 등 장애 모니터링 쿼리 최적화

#### 3. 부분 실패(Partial Failure) 격리 규정
- 특정 수신자의 전화번호 결번이나 통신사 타임아웃으로 인해 문자가 실패하더라도, 이미 성공한 웹 알림함(`notifications`)을 롤백시키지 않습니다.
- 각 채널은 독립적인 에러 핸들링을 거치며, 실패한 건만 `status = 'FAIL'`로 기록되어 안전한 개별 재발송 기반을 제공합니다.

---

## 제4장. 백엔드 서비스 파이프라인

### 4.1 발송 처리 흐름도

    [컨트롤러 비즈니스 이벤트 커밋 완료]
                    │
                    ▼
       통합 사건 키 ($batchId) 생성
                    │
                    ▼
       NotificationService::send() 호출
    (templateCode, receiver, variables, userId, batchId, actionUrl, extraData)
                    │
       ┌────────────┴────────────┐
       ▼                         ▼
    1. 변수 검증 및 치환      2. action_url 완성
    (누락 시 안전 기본값)     (상대경로 -> 도메인 결합)
       │                         │
       └────────────┬────────────┘
                    │
                    ▼
        [EUC-KR 90바이트 자동 검사]
        ├── 90바이트 이하 -> SMS 채널
        └── 90바이트 초과 -> LMS 채널 자동 승격
                    │
       ┌────────────┴────────────┐
       ▼                         ▼
    [채널 1: 인앱 웹 알림]    [채널 2: 외부 통신망 문자]
    - notifications 저장      - Cafe24 통신사 전송 API 호출
    - batch_id 존재 시:       - sms_logs 영수증 무조건 저장
      notification_logs       - batch_id 존재 시:
      (channel='WEB') 저장      notification_logs
                                (channel='SMS/LMS') 저장
                                (실패 시 status='FAIL' 기록)
                    │
                    ▼
     단일 사건 묶음 관제 완료 (사용자 흐름 무중단 보장)

---

### 4.2 통합 알림 서비스 구현 (`app/Services/NotificationService.php`)

    namespace App\Services;

    use App\Models\NotificationTemplate;
    use App\Models\NotificationLog;
    use App\Models\User;
    use App\Notifications\SystemWebNotification;
    use Illuminate\Support\Facades\Log;

    class NotificationService
    {
        public function __construct(
            protected Cafe24SmsService $smsService
        ) {}

        /**
         * [범용 알림 발송 및 관제 로그 선별 적재 메서드]
         */
        public function send(
            string $templateCode,
            string $receiver,
            array $variables = [],
            ?int $userId = null,
            ?string $batchId = null,
            ?string $actionUrl = null,
            array $extraData = []
        ): bool {
            try {
                // 1. 템플릿 조회 및 활성화 여부 확인
                $template = NotificationTemplate::where('code', $templateCode)->first();

                if (!$template || !$template->is_active) {
                    Log::warning("[NotificationService] 비활성화되었거나 존재하지 않는 템플릿: {$templateCode}");
                    return false;
                }

                // 2. 모델 파서를 통한 제목 및 본문 변수 치환
                $title   = $template->parseTitle($variables);
                $content = $template->parseContent($variables);

                // 3. 이동 경로(action_url) 변수 치환 및 절대 도메인 결합
                $resolvedActionUrl = $this->resolveActionUrl($actionUrl ?: $template->action_url, $variables);

                // 4. 관제 공통 메타데이터 정제
                $sourceType   = $extraData['source_type'] ?? $template->category;
                $sourceId     = $extraData['source_id'] ?? null;
                $senderType   = $extraData['sender_type'] ?? 'SYSTEM';
                $senderId     = $extraData['sender_id'] ?? null;
                $dispatchType = $extraData['dispatch_type'] ?? 'AUTO';
                
                $receiverType = $extraData['receiver_type'] ?? (
                    $userId ? 'USER' : (str_ends_with($templateCode, '_ADMIN') ? 'ADMIN' : 'GUEST')
                );

                // =========================================================================
                // 5. 채널 1: 인앱 웹 알림함(notifications) 적재
                // =========================================================================
                $isWebNotifyEnabled = isset($template->is_web_notify) ? (bool)$template->is_web_notify : true;

                if ($userId && $isWebNotifyEnabled) {
                    $user = User::find($userId);
                    if ($user) {
                        $webSuccess = false;
                        $webErrorMsg = null;

                        try {
                            $user->notify(new SystemWebNotification([
                                'code'        => $templateCode,
                                'title'       => $title ?: '[GLOBEAU 알림]',
                                'message'     => $content,
                                'url'         => $resolvedActionUrl,
                                'source_type' => $sourceType,
                                'source_id'   => $sourceId,
                            ]));
                            $webSuccess = true;
                        } catch (\Throwable $webError) {
                            $webSuccess  = false;
                            $webErrorMsg = $webError->getMessage();
                            Log::error("[NotificationService] 웹 알림 발송 예외: " . $webErrorMsg);
                        }

                        // batch_id가 전달된 관제 대상일 때만 notification_logs 적재 (실패 시에도 기록)
                        if (!empty($batchId)) {
                            $this->createNotificationLog([
                                'batch_id'         => $batchId,
                                'dispatch_type'    => $dispatchType,
                                'template_code'    => $templateCode,
                                'source_type'      => $sourceType,
                                'source_id'        => $sourceId,
                                'sender_type'      => $senderType,
                                'sender_id'        => $senderId,
                                'receiver_type'    => $receiverType,
                                'receiver_id'      => $userId,
                                'receiver_contact' => $receiver,
                                'channel'          => 'WEB',
                                'title'            => $title,
                                'content'          => $content,
                                'action_url'       => $resolvedActionUrl,
                                'status'           => $webSuccess ? 'SUCCESS' : 'FAIL',
                                'error_message'    => $webErrorMsg,
                                'ip_address'       => request()?->ip(),
                            ]);
                        }
                    }
                }

                // =========================================================================
                // 6. 채널 2: 외부 통신망(문자/알림톡/이메일) 발송
                // =========================================================================
                switch (strtoupper($template->type)) {
                    case 'EMAIL':
                        Log::info("[NotificationService] EMAIL 발송 요청 (수신자: {$receiver})");
                        return true;

                    case 'SMS':
                    case 'LMS':
                    case 'ALIMTALK':
                    default:
                        $smsBody = $content;
                        if (!empty($resolvedActionUrl)) {
                            $smsBody .= "\n\n▶ 바로가기: " . $resolvedActionUrl;
                        }

                        $actualChannel = (mb_strwidth($smsBody, 'euc-kr') > 90) ? 'LMS' : 'SMS';
                        $isSent = false;
                        $errorMessage = null;

                        try {
                            // Cafe24 문자 발송 (내부에서 sms_logs 과금 로그는 무조건 적재)
                            $isSent = $this->smsService->send(
                                receiverPhone: $receiver,
                                message:       $smsBody,
                                title:         $title ?: '[GLOBEAU]',
                                type:          $templateCode,
                                userId:        $userId
                            );

                            if (!$isSent) {
                                $errorMessage = '통신사 게이트웨이 전송 거부 또는 잔여 포인트 부족';
                            }
                        } catch (\Throwable $sendError) {
                            $isSent       = false;
                            $errorMessage = $sendError->getMessage();
                            Log::error("[NotificationService] 문자 발송 통신망 예외: " . $errorMessage);
                        }

                        // batch_id가 전달된 관제 대상일 때만 notification_logs 최종 결과 기록 (실패 시에도 기록)
                        if (!empty($batchId)) {
                            $this->createNotificationLog([
                                'batch_id'         => $batchId,
                                'dispatch_type'    => $dispatchType,
                                'template_code'    => $templateCode,
                                'source_type'      => $sourceType,
                                'source_id'        => $sourceId,
                                'sender_type'      => $senderType,
                                'sender_id'        => $senderId,
                                'receiver_type'    => $receiverType,
                                'receiver_id'      => $userId,
                                'receiver_contact' => $receiver,
                                'channel'          => $actualChannel,
                                'title'            => $title,
                                'content'          => $smsBody,
                                'action_url'       => $resolvedActionUrl,
                                'status'           => $isSent ? 'SUCCESS' : 'FAIL',
                                'error_message'    => $errorMessage,
                                'ip_address'       => request()?->ip(),
                            ]);
                        }

                        return $isSent;
                }
            } catch (\Throwable $e) {
                Log::error("[NotificationService Top Exception] Code: {$templateCode} | Error: " . $e->getMessage());
                return false;
            }
        }

        /**
         * 통합 관제 로그(notification_logs) 안전 생성 헬퍼
         */
        protected function createNotificationLog(array $data): void
        {
            try {
                NotificationLog::create([
                    'batch_id'         => $data['batch_id'],
                    'dispatch_type'    => $data['dispatch_type'] ?? 'AUTO',
                    'template_code'    => $data['template_code'] ?? null,
                    'source_type'      => $data['source_type'] ?? null,
                    'source_id'        => $data['source_id'] ?? null,
                    'sender_type'      => $data['sender_type'] ?? 'SYSTEM',
                    'sender_id'        => $data['sender_id'] ?? null,
                    'receiver_type'    => $data['receiver_type'] ?? 'USER',
                    'receiver_id'      => $data['receiver_id'] ?? null,
                    'receiver_contact' => $data['receiver_contact'] ?? null,
                    'channel'          => $data['channel'],
                    'title'            => $data['title'] ?? null,
                    'content'          => $data['content'],
                    'action_url'       => $data['action_url'] ?? null,
                    'status'           => $data['status'] ?? 'SUCCESS',
                    'error_code'       => $data['error_code'] ?? null,
                    'error_message'    => $data['error_message'] ?? null,
                    'ip_address'       => $data['ip_address'] ?? null,
                ]);
            } catch (\Throwable $e) {
                Log::error("[NotificationService] notification_logs 적재 실패: " . $e->getMessage(), [
                    'exception' => $e
                ]);
            }
        }

        /**
         * 이동 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.3 통합 관제 엘로퀀트 모델 (`app/Models/NotificationLog.php`)

    namespace App\Models;

    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\BelongsTo;
    use Illuminate\Database\Eloquent\Builder;

    class NotificationLog extends Model
    {
        use HasFactory;

        protected $table = 'notification_logs';

        protected $fillable = [
            'batch_id',         // 사건 묶음 식별키
            'dispatch_type',    // 발송 유형 (AUTO, MANUAL)
            'template_code',    // 템플릿 고유 식별 코드
            'source_type',      // 발생 도메인 (shop, reservation, payment, campaign 등)
            'source_id',        // 발생 도메인 레코드 PK
            'sender_type',      // 발송 주체 (SYSTEM, ADMIN, SHOP)
            'sender_id',        // 발송자 ID
            'receiver_type',    // 수신 대상 구분 (USER, SHOP_OWNER, ADMIN, GUEST)
            'receiver_id',      // 수신 회원 ID (users.id)
            'receiver_contact', // 수신 연락처 (전화번호 또는 이메일)
            'channel',          // WEB, SMS, LMS, ALIMTALK, EMAIL
            'title',            // 메시지 제목
            'content',          // 실제 발송 본문 전문
            'action_url',       // 이동 전체 URL
            'status',           // SUCCESS, FAIL, PENDING
            'error_code',       // 에러 코드
            'error_message',    // 실패 상세 사유
            'ip_address',       // 요청 IP
        ];

        protected $casts = [
            'sender_id'   => 'integer',
            'receiver_id' => 'integer',
            'source_id'   => 'integer',
            'created_at'  => 'datetime',
            'updated_at'  => 'datetime',
        ];

        // =========================================================================
        // 관계 정의 (Relationships)
        // =========================================================================

        public function receiver(): BelongsTo
        {
            return $this->belongsTo(User::class, 'receiver_id');
        }

        public function user(): BelongsTo
        {
            return $this->receiver();
        }

        public function sender(): BelongsTo
        {
            return $this->belongsTo(User::class, 'sender_id');
        }

        public function template(): BelongsTo
        {
            return $this->belongsTo(NotificationTemplate::class, 'template_code', 'code');
        }

        // =========================================================================
        // 쿼리 스코프 (Scopes)
        // =========================================================================

        public function scopeBatch(Builder $query, string $batchId): Builder
        {
            return $query->where('batch_id', $batchId);
        }

        public function scopeChannel(Builder $query, string $channel): Builder
        {
            return $query->where('channel', strtoupper($channel));
        }

        public function scopeStatus(Builder $query, string $status): Builder
        {
            return $query->where('status', strtoupper($status));
        }

        public function scopeSource(Builder $query, string $type, ?int $id = null): Builder
        {
            $q = $query->where('source_type', $type);
            return $id ? $q->where('source_id', $id) : $q;
        }

        // =========================================================================
        // 뱃지 접근자 (Accessors)
        // =========================================================================

        public function getStatusBadgeClassAttribute(): string
        {
            return match ($this->status) {
                'SUCCESS' => 'badge bg-success',
                'FAIL'    => 'badge bg-danger',
                'PENDING' => 'badge bg-warning text-dark',
                default   => 'badge bg-secondary',
            };
        }

        public function getChannelBadgeClassAttribute(): string
        {
            return match ($this->channel) {
                'WEB'      => 'badge bg-info text-dark',
                'SMS'      => 'badge bg-primary',
                'LMS'      => 'badge bg-indigo text-white',
                'ALIMTALK' => 'badge bg-warning text-dark',
                default    => 'badge bg-dark',
            };
        }
    }

---

### 4.4 전역 뷰 컴포저 설정 (`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
    {
        public function boot(): void
        {
            View::composer(['layouts.*', 'mypage.*'], function ($view) {
                $view->with(
                    'unreadCount',
                    Auth::check() ? Auth::user()->unreadNotifications()->count() : 0
                );
            });
        }
    }

---

## 제5장. 프론트엔드 UI·UX 가이드라인

### 5.1 글로벌 헤더(GNB/서브헤더) [N] 배지 동작 기준
- **오직 안 읽은 알림 기준**: 매장 승인 여부나 심사 상태와 무관하게, **확인하지 않은 알림(`$unreadCount > 0`)이 있을 때만 프로필/종 아이콘 우측 상단에 붉은색 [N] 배지**가 뜹니다.
- **수량 표기**: 드롭다운 메뉴 안에는 실제 안 읽은 개수를 숫자로 표시하며, 100건 이상일 때는 `99+`로 단축 표기합니다.

    <!-- 상단 헤더 프로필 버튼 뱃지 표준 -->
    <button class="btn btn-sm border-0 p-0 text-dark fs-5 dropdown-toggle position-relative" ...>
        <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>

### 5.2 마이페이지 대시보드 진입 동선
- **프로필 카드**: 상단 내 정보 영역 우측에 [알림함] 바로가기 버튼을 두고 붉은 배지를 연결합니다.
- **메뉴 그리드**: 6대 핵심 메뉴 중 1번 카드에 [나의 알림함] 큰 카드를 배치하여 `N건 안 읽음`을 한눈에 보여줍니다.
- **헤더와 연결**: 헤더 드롭다운 첫 번째 메뉴를 `마이페이지 홈`으로 두어 언제든 대시보드로 쉽게 돌아올 수 있게 합니다.

### 5.3 알림함 목록(Index) - 1줄 게시판 구조
- **한눈에 보기**: 카드 형태 대신 1줄 게시판 형태를 사용하여, 화면 스크롤을 많이 내리지 않고도 10~15개 이상의 알림 제목과 시간을 편안하게 훑어볼 수 있습니다.
- **읽음/안 읽음 구분**: 안 읽은 알림은 새하얀 배경에 굵은 글씨와 붉은색 N 배지를 붙이고, 이미 읽은 알림은 연한 회색 배경(`bg-light bg-opacity-50`)과 보통 글씨로 차분하게 보여줍니다.
- **무한 스크롤**: 50개 단위로 끊어서 보여주며, 화면 맨 아래로 내리면 다음 알림이 자동으로 로딩됩니다.

### 5.4 알림 상세(Show) - 버튼 크기 및 역할 정리
- **버튼 높이 통일**: 아래쪽에 나란히 놓이는 [삭제], [목록으로], [관련 페이지 바로가기] 3개 버튼의 높이를 모두 `40px`로 맞추어 줄이 삐뚤빼뚤하지 않고 단정합니다.
- **버튼 역할 강조**:
  - **관련 페이지 바로가기**: 사용자가 눌러야 할 가장 중요한 버튼이므로, 우측에 넓은 패딩(`px-4`)과 브랜드 색상(`btn-premium`)으로 돋보이게 만듭니다.
  - **알림 삭제**: 보조 기능이므로 좌측에 아담한 크기(`12px` 패딩)와 부드러운 테두리(`btn-outline-secondary`)로 배치하여 실수로 누르지 않게 합니다.

### 5.5 알림 삭제 확인 창(SweetAlert2)
- 알림 삭제는 결제 취소처럼 위험한 작업이 아니므로 빨간색 경고창을 띄우지 않고, 친절한 물음표(`question`) 아이콘과 함께 "[알림 제목] 알림을 삭제하시겠습니까?"라는 1문장 확인 창을 띄워 사용자의 불편함을 덜어줍니다.

---

## 제6장. 본사 및 샵 관리자 운영 매뉴얼

### 6.1 관리자 통합 메시지 센터 메뉴 구조

    [본사 관리자 (Super Admin)]
    └── 메시지 / 알림 센터
        ├── 1. 알림 템플릿 관리   (routes: admin.notifications.templates.*)
        │     └── 시스템 자동 발송 문구, #{변수}, 이동 경로, 채널별 On/Off 관리
        ├── 2. 메시지 직접 발송   (routes: admin.notifications.send.*)
        │     └── 대상 선택(전체 회원, 뷰티샵 전체, 특정 회원) + 채널 선택 후 수동 발송
        └── 3. 통합 발송 내역     (routes: admin.notifications.logs.*)
               └── notification_logs 기반 단일 검색 및 batch_id 모달 상세 조회

### 6.2 알림 템플릿 문구 및 변수 작성 방법
- **변수 작성**: 문구나 이동 주소 안에 `#{변수명}` 형태로 적습니다. (예: `#{name}`, `#{shop_name}`, `#{shop_code}`)
- **주소 입력**: 이동 주소 입력칸에는 도메인 주소(`https://...`)를 뺀 뒷부분(`/shops/#{shop_code}/status`)만 적습니다. 시스템이 알아서 완전한 도메인 주소로 결합합니다.
- **자동 승격 확인**: 입력 중인 본문이 한글 45자(90바이트)를 넘으면 화면에 `[LMS 승격]` 배지가 실시간으로 표기됩니다.

### 6.3 CS 응대 및 발송 실패 모니터링 절차
1. **민원 인입 접수**: 고객이나 매장 원장님이 "알림 문자를 받지 못했다"고 문의할 때
2. **원클릭 관제 검색**: 본사 어드민 [통합 발송 내역] 메뉴에서 해당 수신 번호(`receiver_contact`) 또는 회원명으로 검색
3. **사건 묶음 카드 확인**: 해당 행의 `batch_id`를 클릭하여 동일 사건으로 나간 웹 알림, 고객 LMS, 관리자 LMS의 전송 상태를 일괄 대조
4. **실패 원인 확인**: 상태가 `FAIL`인 경우 `error_message`(결번, 수신 거부, 통신사 일시 오류 등)를 즉시 파악
5. **원천 도메인 이동**: `source_type`('shop')과 `source_id`(15) 링크를 클릭하여 매장 심사 페이지로 즉시 이동한 뒤 정보 보완 및 [수동 재발송] 수행

---

## 제7장. 컴플라이언스 및 기술 확장 로드맵

### 7.1 정보통신망법 준수 (광고성 메시지 분리)
- **정보성 안내**: 입점 심사, 예약 확정, 결제 내역 등 서비스 이용에 꼭 필요한 알림은 수신 동의 여부와 상관없이 즉시 발송할 수 있으며 밤 시간 발송 제한이 없습니다.
- **광고/홍보성 안내**: 할인 쿠폰, 기획전, 이벤트 안내는 반드시 사전에 마케팅 수신에 동의한 회원(`marketing_opt_in = true`)에게만 발송합니다.
- **표기 의무**: 광고 메시지에는 문장 맨 앞에 `(광고)`, 맨 뒤에 `무료수신거부: 080-XXX-XXXX`를 시스템에서 자동으로 붙여줍니다.
- **야간 발송 제한**: 밤 8시(20:00)부터 다음 날 아침 8시(08:00)까지는 광고 메시지를 보낼 수 없으므로, 이 시간에 등록된 광고는 스케줄러가 다음 날 오전 8시 이후로 발송을 자동 이월합니다.

### 7.2 비동기 대기열(Queue Worker) 전환 계획
수백 명 이상의 회원에게 단체 공지를 보낼 때 화면이 하얗게 멈추거나 서버 시간 초과(504 에러)가 나지 않도록, 백그라운드 작업(Queue Job)으로 분리 발송합니다.

    // 관리자 화면은 즉시 완료 응답 처리
    SendNotificationJob::dispatch($templateCode, $receiverPhone, $variables, $userId);

- DB에 먼저 발송 요청을 대기(`PENDING`) 상태로 적재하고, 서버 백그라운드 프로세스가 차례대로 문자를 발송한 뒤 성공(`SUCCESS`)으로 상태를 갱신합니다.

### 7.3 뷰티 O2O 예약 스케줄러(CRM / 노쇼 방지) 연동
라라벨 스케줄러(`Cron`)를 통해 정해진 시간에 예약 관련 알림을 자동 전송합니다.
1. **노쇼 방지 전날 알림**: 매일 오후 6시(18:00) 실행 → 다음 날 방문 예약이 있는 고객에게 리마인더 발송.
2. **당일 매장 길 안내**: 매 시간 실행 → 시술 2시간 전 매장 위치와 주차 안내 발송.
3. **시술 후기 작성 안내**: 매 시간 실행 → 시술 완료 2시간 후 포인트 적립 안내와 리뷰 작성 링크 발송.
4. **정기 관리 주기 안내**: 네일이나 피부 관리 주기(약 3~4주)가 지나면 단골 케어 추천 알림 발송.