---
title: 본사 운영자 통합발송안내 기술 매뉴얼 및 관제 명세서
---

# GLOBEAU 뷰티 플랫폼 - 본사 운영자 통합발송안내 기술 매뉴얼 및 관제 명세서
(Technical Specification & Operations Manual for Integrated Notification Monitoring)

---

## 문서 제어 및 개정 이력 (Document Control & Revision History)

### 기본 제어 정보
| 항목 | 내용 |
|---|---|
| **문서명** | GLOBEAU 플랫폼 본사 운영자 통합발송안내 기술 매뉴얼 및 관제 명세서 |
| **현재 버전** | v1.0.0 |
| **최종 개정일** | 2026-10-16 |
| **작성/관리자** | 본사 시스템개발팀 |
| **적용 시스템** | GLOBEAU 본사 관리자 (/admin) - 메시지/알림 관리 (통합 발송 내역) |
| **문서 목적** | 플랫폼 내 비즈니스 이벤트 발생 시 다채널로 전송되는 알림의 단일 사건(`batch_id`) 묶음 추적, `notification_logs` 테이블 스키마, 4단계 발송 파이프라인, 마스터-아코디언 UI/UX 및 모달 검증 표준 수립 |
| **문서 상태** | 개발 및 운영 공식 표준 (Production Standard) |

### 개정 및 변경 이력
| 버전 | 개정일자 | 구분 | 주요 변경 내역 | 작업자 |
|:---:|:---:|:---:|---|:---:|
| **v1.0.0** | 2026-10-16 | 신규제정 | • 단일 사건 식별키(`batch_id`: M 포함 12자리) 기반 다채널 발송 묶음 추적 아키텍처 수립<br>• `notification_logs` 21개 컬럼 스키마 및 복합 인덱스(3종) 성능 튜닝 명세화<br>• 마스터 집계 쿼리 및 2단계 지연 로딩을 통한 N+1 쿼리 해소 컨트롤러 구현<br>• 마스터 행-아코디언 서브리스트-단건 상세 모달 3단 UI/UX 렌더링 규격 정립 | 개발팀 |

---

## 1. 시스템 개요 및 비즈니스 목적 (System Overview & Business Objective)

### 1.1 기능 정의 및 핵심 가치
하나의 비즈니스 사건(예: 뷰티샵 입점 신청, 시술 예약 완료, 결제 승인)이 발생하면 플랫폼 시스템은 본사 관리자, 가맹점주, 고객 등 다수의 주체에게 알림톡, SMS/LMS, 이메일, 웹 푸시 등 서로 다른 채널로 알림을 동시 다발적으로 전송합니다.
본 통합발송안내 시스템은 개별 로그 단위의 파편화된 조회를 지양하고, **단일 비즈니스 사건(Event) 단위로 모든 발송 내역을 집약·관제할 수 있는 통합 추적 환경**을 제공합니다.

- **동일 사건 추적성 (Traceability)**: 단일 이벤트에서 파생된 모든 채널 알림을 `batch_id`(M으로 시작하는 12자리 묶음 식별키)로 바인딩하여 데이터베이스에 영구 보존합니다.
- **운영 가시성 제공 (Batch Grouping)**: 본사 운영자 화면에서 수십 건의 단편적 로그가 아닌 **사건(Batch) 단위의 1건으로 그룹화된 마스터 목록**을 제공합니다.
- **계층형 인터랙션 (Accordion & Modal)**: 아코디언 UI를 통해 해당 사건으로 발송된 개별 채널 목록을 펼쳐보고, 단건 클릭 시 세부 페이로드 및 수발신 메타데이터를 모달로 정밀 검증합니다.

### 1.2 통합 발송 및 관제 라이프사이클 흐름도 (Business Flow)

    [비즈니스 사건 발생: 신규 입점 신청서 접수 완료]
                            │
                            ▼
    [1단계: 고유 batch_id 발행]
    $batchId = NotificationLog::generateBatchId(); // M 포함 12자리 채번
                            │
                            ▼
    [2단계: 다중 주체/채널 알림 일괄 발송]
    NotificationService::send() 동시 호출
      ├── 알림 A (본사 운영팀) : ALIMTALK  | batch_id 바인딩
      ├── 알림 B (신청 대표자) : LMS       | batch_id 바인딩
      └── 알림 C (접수증 발송) : EMAIL     | batch_id 바인딩
                            │
                            ▼
    [3단계: notification_logs 단일 배치 적재]
    동일한 batch_id 아래 3개의 개별 로그 레코드 저장 (성공/실패 격리 기록)
                            │
                            ▼
    [4단계: 본사 운영자 통합발송안내 UI 표출]
    동일 batch_id 기준 1건의 마스터 행으로 그룹화 집계 렌더링

### 1.3 배치 식별키(batch_id) 표준 명명 규격
- **형식**: 영문 대문자 `M`으로 시작하는 영문 대문자 및 숫자 조합 12자리 고정폭 문자열 (언더바 없음)
- **정규식**: `/^M[A-Z0-9]{11}$/`
- **예시**: `M7A9K2X4B1C8`, `M9F0E1W8Q2Z3`
- **채번 메서드**: `NotificationLog::generateBatchId()`
- **역할**: 대량 로그 속에서 동일 비즈니스 맥락의 발송 건들을 단일 그룹으로 역추적하는 논리적 묶음 외래키 수행

---

## 2. 데이터베이스 테이블 명세서 (notification_logs)

### 2.1 테이블 개요 및 연관 관계
- **물리 테이블명:** `notification_logs`
- **논리 테이블명:** 본사 운영자용 통합 알림/메시지 발송 관제 로그 테이블
- **주요 역할:** 단일 사건(`batch_id`) 기반의 다채널 발송 이력 묶음 추적, 도메인 감사(Audit), 전사 운영 관제 모니터링
- **연관 관계 (Entity Relationships):**
  - `notification_templates` (N : 1) - 발송에 사용된 마스터 템플릿 (`template_code`)
  - `users` (N : 1) - 수신 회원 계정 (`receiver_id`)
  - `users` (N : 1) - 발송 관리자 계정 (`sender_id`)
  - `shops` (N : 1) - 발송 원인 매장 (`source_type = 'shop'`, `source_id = shops.id`)

### 2.2 컬럼 세부 명세표
| No | 구분 | 컬럼명 (Physical) | 논리명 (Logical) | 데이터 타입 | Null | 기본값 | Key | 설명 및 제약조건 |
|:---:|:---:|---|---|---|:---:|:---:|:---:|---|
| 1 | 기본키 | `id` | 로그 식별자 | BIGINT UNSIGNED | N | Auto | PK | 통합 발송 관제 로그 고유 식별자 |
| 2 | 그룹화 | `batch_id` | 배치 식별키 | VARCHAR(20) | N | - | IDX | 동일 사건 발송 묶음키 (M 포함 12자리) |
| 3 | 업무구분 | `dispatch_type` | 발송 유형 | VARCHAR(20) | N | 'AUTO' | - | `AUTO`(시스템 자동), `MANUAL`(수동 발송) |
| 4 | 업무구분 | `template_code` | 템플릿 코드 | VARCHAR(50) | Y | NULL | IDX | 사용된 템플릿 식별 코드 (`SHOP_APPLY_USER` 등) |
| 5 | 원인추적 | `source_type` | 원본 도메인 | VARCHAR(50) | Y | NULL | IDX | 발생 원본 엔티티 (`shop`, `reservation`, `order` 등) |
| 6 | 원인추적 | `source_id` | 원본 레코드 PK | BIGINT UNSIGNED | Y | NULL | IDX | 발생 원본 테이블 PK (`shops.id`, `orders.id` 등) |
| 7 | 발송주체 | `sender_type` | 발송 주체 구분 | VARCHAR(20) | N | 'SYSTEM' | - | `SYSTEM`(시스템), `ADMIN`(본사관리자), `SHOP`(뷰티샵) |
| 8 | 발송주체 | `sender_id` | 발송자 고유 ID | BIGINT UNSIGNED | Y | NULL | IDX | 발송자 회원 ID (`users.id`, SYSTEM은 NULL) |
| 9 | 수신대상 | `receiver_type` | 수신 대상 구분 | VARCHAR(20) | N | 'USER' | - | `USER`(일반회원), `SHOP`(뷰티샵), `ADMIN`(본사), `GUEST`(비회원) |
| 10 | 수신대상 | `receiver_id` | 수신 회원 ID | BIGINT UNSIGNED | Y | NULL | IDX | 수신 회원 ID (`users.id`, 외부/비회원 발송 시 NULL) |
| 11 | 수신대상 | `receiver_contact` | 수신 연락처 | VARCHAR(100) | Y | NULL | IDX | 수신 휴대폰 번호 또는 이메일 주소 |
| 12 | 채널/본문 | `channel` | 전송 채널 | VARCHAR(20) | N | - | IDX | `WEB`, `SMS`, `LMS`, `ALIMTALK`, `EMAIL` |
| 13 | 채널/본문 | `title` | 메시지 제목 | VARCHAR(255) | Y | NULL | - | 치환 완료된 제목 (단문 SMS는 NULL) |
| 14 | 채널/본문 | `content` | 메시지 본문 | TEXT | N | - | - | 치환 완료된 실제 발송 본문 전문 |
| 15 | 채널/본문 | `action_url` | 이동 경로 URL | VARCHAR(255) | Y | NULL | - | 도메인이 결합된 전체 이동 URL (바로가기 링크) |
| 16 | 전송결과 | `status` | 전송 상태 | VARCHAR(20) | N | 'SUCCESS' | IDX | `SUCCESS`(성공), `FAIL`(실패), `PENDING`(대기) |
| 17 | 감사/오류 | `error_code` | 오류 코드 | VARCHAR(50) | Y | NULL | - | 통신사 반환 에러코드 또는 시스템 예외 코드 |
| 18 | 감사/오류 | `error_message` | 실패 사유 | TEXT | Y | NULL | - | 전송 실패 상세 사유 전문 |
| 19 | 감사/오류 | `ip_address` | 요청 IP 주소 | VARCHAR(45) | Y | NULL | - | 발송 요청 클라이언트 IP 주소 (IPv4/IPv6) |
| 20 | 이력관리 | `created_at` | 발송 일시 | TIMESTAMP | Y | NULL | IDX | 발송 로그 최초 생성 일시 |
| 21 | 이력관리 | `updated_at` | 수정 일시 | TIMESTAMP | Y | NULL | - | 로그 상태 최종 갱신 일시 |

### 2.3 복합 인덱스(Composite Index) 설계표
운영 관제 화면의 실시간 집계 성능 및 모니터링 부하 분산을 위해 아래 3종의 복합 인덱스를 적용합니다.

| 복합 인덱스 명칭 | 대상 컬럼 | 설계 목적 및 주요 검색 쿼리 패턴 |
|---|---|---|
| `idx_logs_source_lookup` | `source_type`, `source_id` | 특정 뷰티샵(`shop_id`) 또는 예약 건 관련 알림 이력 역추적 튜닝 |
| `idx_logs_period_channel` | `created_at`, `channel` | 기간별(`created_at`) 채널별(`channel`) 발송 통계 및 마스터 리스트 페이징 튜닝 |
| `idx_logs_channel_status` | `channel`, `status` | 채널별 발송 실패(`FAIL`) 건 집중 관제 및 재발송 타깃 필터링 튜닝 |

### 2.4 주요 상태값 및 비즈니스 코드 정의
- **발송 유형 (`dispatch_type`)**:
  * `AUTO`: 비즈니스 이벤트 발생 시 시스템 백그라운드 자동 발송
  * `MANUAL`: 본사 관리자 또는 뷰티샵 원장이 화면에서 수동 발송
- **전송 채널 (`channel`)**:
  * `WEB`: 마이페이지 인앱 웹 알림함(`notifications`) 적재
  * `SMS`: 90바이트 이하 단문 문자 메시지
  * `LMS`: 90바이트 초과 장문 문자 메시지
  * `ALIMTALK`: 카카오 비즈니스 알림톡
  * `EMAIL`: 이메일 알림
- **전송 상태 (`status`)**:
  * `SUCCESS`: 통신사 게이트웨이 접수 완료 또는 웹 알림 적재 성공
  * `FAIL`: 통신사 거부, 결번, 잔여 포인트 부족, 예외 발생 등으로 인한 전송 실패
  * `PENDING`: 발송 대기 중 (예약 발송 등)

### 2.5 DDL 마이그레이션 스키마 (Laravel Blueprint)

    Schema::create('notification_logs', function (Blueprint $table) {
        $table->id();
        $table->string('batch_id', 20)->index();
        $table->string('dispatch_type', 20)->default('AUTO');
        $table->string('template_code', 50)->nullable()->index();
        $table->string('source_type', 50)->nullable();
        $table->unsignedBigInteger('source_id')->nullable();
        $table->string('sender_type', 20)->default('SYSTEM');
        $table->unsignedBigInteger('sender_id')->nullable()->index();
        $table->string('receiver_type', 20)->default('USER');
        $table->unsignedBigInteger('receiver_id')->nullable()->index();
        $table->string('receiver_contact', 100)->nullable()->index();
        $table->string('channel', 20)->index();
        $table->string('title', 255)->nullable();
        $table->text('content');
        $table->string('action_url', 255)->nullable();
        $table->string('status', 20)->default('SUCCESS')->index();
        $table->string('error_code', 50)->nullable();
        $table->text('error_message')->nullable();
        $table->string('ip_address', 45)->nullable();
        $table->timestamps();

        // 복합 인덱스 3종 구성
        $table->index(['source_type', 'source_id']);
        $table->index(['created_at', 'channel']);
        $table->index(['channel', 'status']);
    });

---

## 3. 중앙 설정 및 코드 레지스트리 (Configuration & Registry Standards)

하드코딩을 방지하고 화면 표시 뱃지 및 라벨을 단일 원천(SSOT)으로 관리하기 위해 `config/notification_categories.php` 설정을 참조합니다.

    return [
        // 발송 유형 정의
        'dispatch_types' => [
            'AUTO'   => '자동 (AUTO)',
            'MANUAL' => '수동 (MANUAL)',
        ],

        // 수신 대상 주체 정의
        'targets' => [
            'user'  => ['label' => '일반회원', 'badge' => 'badge bg-primary'],
            'shop'  => ['label' => '뷰티샵',   'badge' => 'badge bg-info text-dark'],
            'admin' => ['label' => '본사관리자', 'badge' => 'badge bg-dark'],
            'guest' => ['label' => '비회원',   'badge' => 'badge bg-secondary'],
        ],

        // 발송 채널 정의
        'channels' => [
            'WEB'      => ['label' => '웹 알림',   'badge' => 'badge bg-secondary'],
            'SMS'      => ['label' => 'SMS',       'badge' => 'badge bg-primary'],
            'LMS'      => ['label' => 'LMS',       'badge' => 'badge bg-primary-subtle text-primary border border-primary-subtle'],
            'ALIMTALK' => ['label' => '알림톡',    'badge' => 'badge bg-warning text-dark'],
            'EMAIL'    => ['label' => '이메일',    'badge' => 'badge bg-info'],
        ],
    ];

---

## 4. 관리자 UI/UX 화면 명세 (Interface Specifications)

본사 관리자 센터의 **통합 발송 내역** 화면은 상단 정밀 검색 필터, 배치 단위 마스터 리스트, 하위 알림 아코디언 리스트, 그리고 단건 알림 상세 모달 팝업의 3단계 계층 구조로 구성됩니다.

### 4.1 상단 검색 및 정밀 필터 바 (Search & Filter Bar)
- **셀렉트 드롭다운 필터**:
  * 발송 유형 (`dispatch_type`): 전체, 자동(`AUTO`), 수동(`MANUAL`)
  * 최종 발송 상태 (`status`): 전체, 전체 성공, 전체 실패, 일부 실패
  * 발송 채널 구분 (`channel`): 전체, 웹 알림(`WEB`), 알림톡(`ALIMTALK`), `SMS`, `LMS`, 이메일(`EMAIL`)
- **키워드 복합 검색**:
  * 검색 타겟: 배치아이디(`batch_id`), 템플릿코드(`template_code`), 수신처(`receiver_contact`), 메시지 제목(`title`), 메시지 내용(`content`)
  * 검색 기간 선택기: 시작일 및 종료일 지정 (복합 인덱스 `['created_at', 'channel']` 연동, 기본값 최근 7일)

### 4.2 통합 발송 내역 마스터 리스트 (배치아이디 그룹 목록)
동일한 `batch_id`를 가진 발송 건들을 하나의 행(Row)으로 집약하여 1건의 사건 단위로 렌더링합니다.

![통합 발송 내역 마스터 화면](images/notification_logs_manual/image.png "▲ [그림 4-1] 통합 발송 내역 마스터 리스트 및 검색 필터 화면")

#### 마스터 리스트 테이블 컬럼 명세표
| 번호 | 컬럼명 | 출력 형식 및 UI 구성 요소 | 설명 |
|:---:|---|---|---|
| 1 | **발생일시** | `YYYY-MM-DD HH:mm:ss` | 해당 배치의 최초 알림 발송 일시 (`MIN(created_at)`) |
| 2 | **배치아이디** | `<span class="badge bg-light text-dark font-monospace">M7A9K2X4B1C8</span>` | 사건 추적 고유 묶음 식별키 (클릭 시 키 복사 기능 제공) |
| 3 | **발송 구분 및 업무** | • 발송 구분 뱃지: `[자동]` (`bg-info`) / `[수동]` (`bg-secondary`)<br/>• 업무 타이틀: 템플릿 카테고리 기반 한글명 (`business_title`)<br/>• 발생 원본 내역: `source_type`, `source_id` 연동 상호명 (`뷰티샵 : 365비비클럽` 등) | 발송이 발생한 비즈니스 도메인 및 사건의 성격 식별 |
| 4 | **수신자 정보** | `<span class="badge bg-primary rounded-pill">총 3건</span>` | 해당 배치로 발송된 총 수신 대상 건수 (`COUNT(id)`) |
| 5 | **발송 채널 요약** | 채널별 뱃지 나열: `[알림톡]` `[LMS]` `[이메일]` | 배치 내부에서 사용된 중복 없는 발송 채널 목록 전체 |
| 6 | **최종 결과** | • 전체 성공: `<span class="badge bg-success">성공 3건</span>`<br/>• 일부 실패: `<span class="badge bg-warning text-dark">성공 2건</span> <span class="badge bg-danger">실패 1건</span>`<br/>• 전체 실패: `<span class="badge bg-danger">실패 3건</span>` | 하위 발송 건들의 전송 성공/실패 여부를 집계 뱃지로 직관적 표시 |
| 7 | **상세 내역** | `<button class="btn btn-sm btn-outline-primary accordion-toggle">내역확인 (3) <i class="fas fa-chevron-down"></i></button>` | 클릭 시 아래로 아코디언 상세 리스트가 펼쳐지는 액션 버튼 |

---

### 4.3 아코디언 상세 알림 리스트 (배치 하위 발송 목록)
마스터 행의 `내역확인` 버튼을 클릭하면 테이블 행 바로 아래에 아코디언 패널이 슬라이드 다운(Slide-down)되며, 해당 배치에 묶인 개별 발송 로그가 세부 테이블 형태로 펼쳐집니다.

![아코디언 상세 알림 리스트](images/notification_logs_manual/image-1.png "▲ [그림 4-2] 배치아이디 그룹 아코디언 상세 내역 펼침 화면")

#### 아코디언 테이블 컬럼 명세표
| 서브 컬럼명 | 출력 형식 및 디자인 | 상세 명세 |
|---|---|---|
| **No** | `#1021` | 개별 알림 로그 고유 식별자 (`notification_logs.id`) |
| **발송채널** | `<span class="badge bg-warning text-dark"><i class="fas fa-comment me-1"></i>알림톡</span>` | 전송 채널 (`WEB`, `SMS`, `LMS`, `ALIMTALK`, `EMAIL`) 아이콘 및 뱃지 |
| **수신처 / 회원정보** | **010-1234-5678**<br/><small class="text-muted"><span class="badge bg-dark">본사관리자</span> 김운영 (#1)</small> | • **수신처**: 연락처(휴대폰 번호 또는 이메일 주소)<br/>• **회원정보**: 대상 구분 뱃지 + 회원 실명 + 회원 고유번호(`users.id`) |
| **메시지 타이틀** | **[GLOBEAU] 뷰티샵 입점 신청서가 접수되었습니다.**<br/><small class="text-secondary text-truncate d-inline-block" style="max-width: 320px;">치환된 본문 내용 미리보기가 여기에 출력됩니다...</small> | 메시지 제목 (`title`) 및 본문 앞부분 한 줄 말줄임 미리보기 |
| **상태** | `<span class="badge bg-success">성공</span>` 또는 `<span class="badge bg-danger">실패</span>` | 개별 알림의 최종 전송 상태 (`status`) |
| **단건 상세** | `<button class="btn btn-xs btn-outline-secondary btn-detail"><i class="fas fa-search me-1"></i>상세보기</button>` | 클릭 시 개별 알림의 전체 발송 데이터를 확인할 수 있는 상세 팝업 모달 호출 |

---

### 4.4 단건 알림 상세 팝업 모달 명세 (Notification Detail Modal)
아코디언 리스트에서 `상세보기` 버튼을 클릭했을 때 호출되는 레이어 팝업으로, 발송 원본부터 치환 전문, 통신사 결과 코드까지 전송 전 과정을 역추적 검증합니다.

![단건 알림 상세 팝업 모달](images/notification_logs_manual/image-2.png "▲ [그림 4-3] 알림 단건 상세 모달 팝업 화면")

#### 모달 내부 상세 구성 명세
1. **모달 헤더**:
   * 모달 제목: `알림 발송 상세 내역 (로그 ID: #{id})`
   * 우측 상태 뱃지: 발송 채널 뱃지(`ALIMTALK`) + 최종 전송 상태 뱃지(`SUCCESS`)
   * 닫기 아이콘 (`X`) 및 ESC 키 닫기 지원
2. **발송 및 수신 메타데이터 (2단 정보 테이블)**:
   * **배치 식별키 (`batch_id`)**: `M7A9K2X4B1C8`
   * **발송 유형 / 템플릿**: `AUTO (시스템자동)` / `SHOP_APPLY_USER`
   * **발송 주체 (Sender)**: `SYSTEM (시스템)` 또는 `ADMIN (관리자명: admin_user)`
   * **수신 대상 (Receiver)**: `[뷰티샵관리자] 김원장 (회원ID: #45)`
   * **수신 연락처 (`receiver_contact`)**: `010-9876-5432`
   * **발생 원본 (Source)**: `뷰티샵 (shop) / 뷰티샵 : 365비비클럽 (PK: #12)`
   * **발송 요청 일시 (`created_at`)**: `2026-10-16 09:30:12`
   * **액션 URL (`action_url`)**: `https://globeau.kr/admin/shops/12` (클릭 시 새창 링크 연결)
3. **메시지 본문 뷰어 (Message Canvas)**:
   * **제목 (`title`)**: 메시지 제목 표시 (미입력 시 '제목 없음')
   * **치환 본문 전문 (`content`)**: 모바일 메시지 프리뷰 카드 형태로 렌더링. 치환 변수(`#{shop_name}`, `#{created_at}` 등)가 실제 값으로 바인딩된 최종 발송 텍스트를 줄바꿈 서식 그대로 출력.
4. **전송 감사 및 오류 추적 패널 (Audit & Error Panel)**:
   * 성공 건: 발송 요청 IP(`ip_address`) 및 정상 발송 확인 메시지 출력
   * 실패 건:
     * **오류 코드 (`error_code`)**: 통신사 에러코드 (예: `KKO_3008`, `SMS_TIMEOUT`)
     * **실패 사유 (`error_message`)**: 실패 상세 원인 전문 출력 (예: `수신자 번호 형식 오류 또는 카카오 알림톡 차단`)

---

## 5. 백엔드 아키텍처 및 서비스 파이프라인 (Backend Implementation)

### 5.1 범용 알림 발송 서비스 엔진 (`App\Services\NotificationService`)
단일 알림 발송, 템플릿 변수 치환, 인앱 웹 알림함 적재(`notifications`), 외부 통신망(Cafe24 SMS/LMS 등) 발송 및 **`$batchId` 파라미터 전달 시 관제 로그(`notification_logs`) 선별 적재**를 총괄합니다.

    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) 변수 치환 및 절대 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 {
                            $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);
                        }

                        // 외부 발송 실패(FAIL) 시에도 batch_id가 있으면 무조건 관제 로그 적재
                        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());
            }
        }

        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);
        }
    }

---

### 5.2 비즈니스 이벤트 발생 및 배치 발송 연동 (`ShopApplyController@store`)
비즈니스 사건이 발생했을 때 컨트롤러에서 `NotificationLog::generateBatchId()`를 1회 발행하고, `Setting` 모델의 동적 관리자 번호 목록과 신청자에게 동일한 `$batchId`를 주입하여 발송하는 실제 구현 패턴입니다.

    namespace App\Http\Controllers;

    use App\Models\NotificationLog;
    use App\Models\Setting;
    use App\Models\Shop;
    use App\Services\NotificationService;
    use Illuminate\Http\Request;

    class ShopApplyController extends Controller
    {
        public function store(Request $request, NotificationService $notificationService)
        {
            // 1. 도메인 데이터 생성
            $shop = Shop::create([
                'name'    => $request->input('name'),
                'user_id' => auth()->id(),
                'status'  => 'APPLIED',
            ]);
            $applicant = auth()->user();

            // 2. 단일 사건 추적용 M 포함 12자리 배치 식별키 선발행
            $batchId = NotificationLog::generateBatchId();

            // 3. 메시지 공통 치환 변수 정의
            $variables = [
                'shop_name'  => $shop->name,
                'user_name'  => $applicant->name,
                'apply_date' => now()->format('Y-m-d H:i'),
                'cs_number'  => Setting::get('cs_phone', '1588-0000'),
            ];

            // 4. 본사 관리자 통보 발송 (Setting 모델의 JSON 배열 자동 역직렬화 활용)
            $adminPhones = Setting::get('notification_admin_phones', []);
            if (is_string($adminPhones)) {
                $adminPhones = array_filter(array_map('trim', explode(',', $adminPhones)));
            }

            foreach ($adminPhones as $adminPhone) {
                $notificationService->send(
                    templateCode: 'SHOP_APPLY_ADMIN',
                    receiver:     $adminPhone,
                    variables:    $variables,
                    userId:       null, // 외부 운영자 번호로 직접 수신
                    batchId:      $batchId, // ★ 동일 배치키 바인딩
                    actionUrl:    route('admin.shops.show', ['id' => $shop->id]),
                    extraData:    [
                        'source_type'   => 'shop',
                        'source_id'     => $shop->id,
                        'receiver_type' => 'ADMIN',
                        'dispatch_type' => 'AUTO',
                    ]
                );
            }

            // 5. 신청한 뷰티샵 원장님 통보 발송 (동일 batch_id 공유)
            $notificationService->send(
                templateCode: 'SHOP_APPLY_USER',
                receiver:     $applicant->phone,
                variables:    $variables,
                userId:       $applicant->id, // 회원 ID 지정 -> 웹알림 및 문자 동시 처리
                batchId:      $batchId, // ★ 동일 배치키 바인딩
                actionUrl:    route('mypage.apply.status'),
                extraData:    [
                    'source_type'   => 'shop',
                    'source_id'     => $shop->id,
                    'receiver_type' => 'SHOP',
                    'dispatch_type' => 'AUTO',
                ]
            );

            return redirect()->route('shops.apply.complete')->with('success', '신청이 정상 접수되었습니다.');
        }
    }

---

### 5.3 통합 관제 컨트롤러 (`NotificationLogController`)
마스터 화면의 다중 조건 검색, 배치 단위 `GROUP BY` 페이징, N+1 문제를 방지하는 하위 아코디언 Eager Loading 및 단건 모달용 비동기 API를 처리합니다.

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use App\Models\NotificationLog;
    use Illuminate\Http\JsonResponse;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\DB;
    use Illuminate\View\View;

    class NotificationLogController extends Controller
    {
        /**
         * 통합 발송 내역 마스터 & 아코디언 목록
         */
        public function index(Request $request): View
        {
            // 1. 마스터 배치 단위 집계 서브쿼리 구성
            $query = NotificationLog::select(
                'batch_id',
                'dispatch_type',
                'template_code',
                'source_type',
                'source_id',
                DB::raw('MIN(created_at) as created_at'),
                DB::raw('COUNT(id) as total_count'),
                DB::raw("COUNT(CASE WHEN status = 'SUCCESS' THEN 1 END) as success_count"),
                DB::raw("COUNT(CASE WHEN status = 'FAIL' THEN 1 END) as fail_count"),
                DB::raw('GROUP_CONCAT(DISTINCT channel ORDER BY channel ASC) as channel_list')
            );

            // 2. 발송 유형 필터 (AUTO / MANUAL)
            if ($request->filled('dispatch_type')) {
                $query->where('dispatch_type', $request->input('dispatch_type'));
            }

            // 3. 발송 채널 구분 필터 (ALIMTALK, SMS, LMS, EMAIL, WEB)
            if ($request->filled('channel')) {
                $query->where('channel', $request->input('channel'));
            }

            // 4. 기간 검색 (created_at 복합 인덱스 활용)
            if ($request->filled('start_date') && $request->filled('end_date')) {
                $query->whereBetween('created_at', [
                    $request->input('start_date') . ' 00:00:00',
                    $request->input('end_date') . ' 23:59:59',
                ]);
            }

            // 5. 키워드 선택 검색 (배치아이디, 템플릿코드, 수신처, 제목, 내용)
            if ($request->filled('keyword') && $request->filled('keyword_type')) {
                $keyword = trim($request->input('keyword'));
                $keywordType = $request->input('keyword_type');

                $allowedKeywordTypes = ['batch_id', 'template_code', 'receiver_contact', 'title', 'content'];
                if (in_array($keywordType, $allowedKeywordTypes, true)) {
                    $query->where($keywordType, 'LIKE', "%{$keyword}%");
                }
            }

            // 6. 그룹화 및 정렬 기준 적용
            $query->groupBy('batch_id', 'dispatch_type', 'template_code', 'source_type', 'source_id');

            // 7. 최종 발송 상태 필터 (Having 절 집계 조건)
            if ($request->filled('status')) {
                $statusFilter = $request->input('status');
                if ($statusFilter === 'ALL_SUCCESS') {
                    $query->havingRaw("fail_count = 0 AND success_count > 0");
                } elseif ($statusFilter === 'ALL_FAIL') {
                    $query->havingRaw("success_count = 0 AND fail_count > 0");
                } elseif ($statusFilter === 'PARTIAL_FAIL') {
                    $query->havingRaw("success_count > 0 AND fail_count > 0");
                }
            }

            // 최신 사건 순으로 15개 페이징
            $batches = $query->orderByDesc(DB::raw('MIN(created_at)'))->paginate(15)->withQueryString();

            // 8. 아코디언용 하위 알림 컬렉션 사전 매핑 (N+1 방지)
            $batchIds = $batches->pluck('batch_id')->filter()->values();

            $subLogsGrouped = collect();
            if ($batchIds->isNotEmpty()) {
                $subLogsGrouped = NotificationLog::whereIn('batch_id', $batchIds)
                    ->with(['receiver:id,name', 'sender:id,name', 'template', 'shop:id,name'])
                    ->orderBy('id', 'asc')
                    ->get()
                    ->groupBy('batch_id');
            }

            return view('admin.notifications.index', compact('batches', 'subLogsGrouped'));
        }

        /**
         * 알림 단건 상세 모달 데이터 조회 API (AJAX)
         */
        public function showAjax(int $id): JsonResponse
        {
            $log = NotificationLog::with([
                'receiver:id,name,email,phone',
                'sender:id,name',
                'template',
                'shop:id,name',
            ])->findOrFail($id);

            return response()->json([
                'success' => true,
                'data'    => [
                    'id'                   => $log->id,
                    'batch_id'             => $log->batch_id,
                    'dispatch_type'        => $log->dispatch_type,
                    'dispatch_type_label'  => config("notification_categories.dispatch_types.{$log->dispatch_type}", $log->dispatch_type),
                    'template_code'        => $log->template_code ?: '-',
                    'template_name'        => $log->template?->name ?: $log->business_title,
                    'source_type'          => $log->source_type,
                    'source_id'            => $log->source_id,
                    'source_display_text'  => $log->source_display_text,
                    'sender_type'          => $log->sender_type,
                    'sender_name'          => $log->sender?->name ?: '시스템 자동',
                    'receiver_type'        => $log->receiver_type,
                    'receiver_type_label'  => config("notification_categories.targets." . strtolower($log->receiver_type) . ".label", $log->receiver_type),
                    'receiver_id'          => $log->receiver_id,
                    'receiver_name'        => $log->receiver?->name ?: '비회원/외부',
                    'receiver_contact'     => $log->receiver_contact,
                    'channel'              => $log->channel,
                    'channel_label'        => config("notification_categories.channels.{$log->channel}.label", $log->channel),
                    'channel_badge'        => $log->channel_badge_class,
                    'title'                => $log->title ?: '제목 없음',
                    'content'              => $log->content,
                    'action_url'           => $log->action_url,
                    'status'               => $log->status,
                    'status_badge'         => $log->status_badge_class,
                    'error_code'           => $log->error_code,
                    'error_message'        => $log->error_message,
                    'ip_address'           => $log->ip_address ?: '-',
                    'created_at'           => $log->created_at?->format('Y-m-d H:i:s'),
                ],
            ]);
        }
    }

---

## 6. 데이터 무결성, 보안 및 장애 방어 대책 (Security & Fail-Safe)

### 6.1 통신망 장애 격리 및 실패 감사 로그 보존 (Fail-Safe Strategy)
- 외부 통신사(카페24) 게이트웨이 타임아웃, 결번, 잔여 포인트 부족 등의 사유로 전송이 실패하더라도 스크립트 실행이 중단(Fatal Exception)되지 않습니다.
- 발송 실패 시 원천 통신 영수증인 `sms_logs`는 물론, 통합 관제 테이블 `notification_logs`에도 `status = 'FAIL'`, `error_message = '원인'`이 온전히 기록되어 본사 운영자가 누락 건을 즉시 인지하고 수동 재발송할 수 있습니다.

### 6.2 대량 로그 페이징 및 복합 인덱스 매칭 성능 최적화
- `notification_logs` 테이블의 데이터가 수십만 건 이상 누적되더라도, 기간 검색(`created_at`)과 채널 필터(`channel`)에 매핑된 복합 인덱스 `['created_at', 'channel']`를 통해 인덱스 풀스캔을 차단합니다.
- `GROUP BY batch_id` 집계 시 메모리 정렬 부하를 최소화하기 위해 1차로 15건의 배치키만 페이징 조회하고, 2차로 `whereIn('batch_id', $batchIds)`를 통해 하위 로그를 1회 쿼리로 Eager Loading하여 N+1 문제를 원천 해소했습니다.

### 6.3 개인정보 보호 및 감사 추적 (Audit Trail)
- 모든 발송 요청 시점의 클라이언트 IP(`ip_address`)를 기록하여 비인가 API 호출이나 어뷰징 대량 발송을 역추적합니다.
- 수신처(`receiver_contact`)는 암호화 전송을 준수하며, 운영자 화면에서 마스킹 처리가 가능하도록 관리 정책을 수립합니다.

---

## 7. 운영 배포 및 기능 검증 체크리스트 (Deployment & Verification Checklist)

- [ ] **DB 마이그레이션**: `notification_logs` 테이블에 21개 컬럼 및 3종 복합 인덱스가 정상 생성되었는가?
- [ ] **배치 아이디 규격**: `NotificationLog::generateBatchId()` 호출 시 `M`으로 시작하는 12자리 난수가 정확히 채번되는가?
- [ ] **마스터 행-아코디언 연동**: 마스터 행 클릭 시 하위 발송 로그가 N+1 쿼리 없이 매끄럽게 펼쳐지는가?
- [ ] **단건 모달 비동기 조회**: 상세보기 클릭 시 `/admin/notifications/logs/{id}/ajax` 통신을 통해 치환 전문과 에러 메시지가 정확히 바인딩되는가?
- [ ] **발송 실패 격리 적재**: 통신사 잔여 포인트 부족 시에도 `notification_logs`에 `status = 'FAIL'`과 사유가 정상 기록되는가?
- [ ] **동적 관리자 번호 발송**: `Setting::get('notification_admin_phones')`의 JSON 배열 번호 전체로 알림이 누락 없이 동시 발송되는가?