# FCM 사용자별 알림 발송 계획 > **작성일**: 2026-01-28 > **목적**: FCM 푸시 알림을 테넌트 전체 브로드캐스트에서 사용자별 타겟 발송으로 변경 > **상태**: ✅ 구현 완료 --- ## 📍 현재 진행 상태 | 항목 | 내용 | |------|------| | **마지막 완료 작업** | Phase 4 - FCM 발송 로직 수정 완료 | | **다음 작업** | 테스트 검증 | | **진행률** | 8/8 (100%) | | **마지막 업데이트** | 2026-01-28 | --- ## 1. 개요 ### 1.1 배경 현재 TodayIssue 생성 시 FCM 푸시 알림이 **테넌트 전체 사용자** 중 알림 설정이 켜진 모든 사용자에게 발송됨. **문제점**: - 결재요청 알림이 결재자가 아닌 사람에게도 발송됨 - 기안 승인/반려/완료 알림이 기안자가 아닌 사람에게도 발송됨 - 불필요한 알림으로 사용자 경험 저하 ### 1.2 목표 ``` ┌─────────────────────────────────────────────────────────────────┐ │ 🎯 핵심 목표 │ ├─────────────────────────────────────────────────────────────────┤ │ 1. 이슈 타입에 따라 특정 대상자에게만 FCM 발송 │ │ 2. 사용자별 알림 설정(ON/OFF)이 정상 동작하도록 보장 │ │ 3. 근태 알림은 제외 (정책 미확정) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 1.3 발송 대상 정책 | 이슈 타입 | 현재 | 변경 후 대상 | |-----------|------|-------------| | **결재요청** | 테넌트 전체 | **결재자(나)** - ApprovalStep.user_id | | **기안 승인** | 테넌트 전체 | **기안자** - Approval.drafter_id | | **기안 반려** | 테넌트 전체 | **기안자** - Approval.drafter_id | | **기안 완료** | 테넌트 전체 | **기안자** - Approval.drafter_id | | 수주등록 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 추심이슈 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 안전재고 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 지출승인 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 세금신고 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 신규업체 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 입금 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | 출금 | 테넌트 전체 | 테넌트 전체 (변경 없음) | | **근태 알림** | - | **제외** (정책 미확정) | ### 1.4 변경 승인 정책 | 분류 | 예시 | 승인 | |------|------|------| | ✅ 즉시 가능 | 필드 추가, 로직 수정, 문서 수정 | 불필요 | | ⚠️ 컨펌 필요 | 마이그레이션, 새 테이블/컬럼 | **필수** | | 🔴 금지 | 기존 테이블 구조 변경, 파괴적 변경 | 별도 협의 | --- ## 2. 대상 범위 ### 2.1 Phase 1: 데이터베이스 변경 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 1.1 | TodayIssue 테이블에 `target_user_id` 컬럼 추가 | ✅ | nullable, FK | | 1.2 | 마이그레이션 파일 생성 | ✅ | 2026_01_28_132426 | ### 2.2 Phase 2: 모델 수정 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 2.1 | TodayIssue 모델에 target_user_id 추가 | ✅ | fillable, relation, scopes | | 2.2 | TodayIssue::createIssue() 메서드에 targetUserId 파라미터 추가 | ✅ | | ### 2.3 Phase 3: Observer 수정 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 3.1 | handleApprovalStepChange() - 결재요청 시 결재자 지정 | ✅ | step->user_id 전달 | | 3.2 | 기안 승인/반려/완료 알림 추가 (기안자 지정) | ✅ | ApprovalIssueObserver 신규 | ### 2.4 Phase 4: FCM 발송 로직 수정 | # | 작업 항목 | 상태 | 비고 | |---|----------|:----:|------| | 4.1 | sendFcmNotification() - target_user_id 있으면 해당 사용자만 | ✅ | | | 4.2 | getEnabledUserTokens() - 특정 사용자 필터링 로직 추가 | ✅ | | --- ## 3. 작업 절차 ### 3.1 단계별 절차 ``` Step 1: 데이터베이스 변경 ├── today_issues 테이블에 target_user_id 컬럼 추가 ├── 마이그레이션 실행 └── 검증: 테이블 구조 확인 Step 2: TodayIssue 모델 수정 ├── target_user_id fillable 추가 ├── targetUser() relation 추가 └── createIssue() 파라미터 추가 Step 3: TodayIssueObserverService 수정 ├── createIssueWithFcm() 파라미터 추가 ├── handleApprovalStepChange() 수정 - 결재자 지정 ├── 기안 상태 변경 알림 추가 (신규) └── 근태 알림 비활성화 Step 4: FCM 발송 로직 수정 ├── sendFcmNotification() 수정 ├── getEnabledUserTokens() 수정 - targetUserId 파라미터 추가 └── 검증: 대상자만 수신 확인 ``` --- ## 4. 상세 작업 내용 ### 4.1 Phase 1: 데이터베이스 변경 **마이그레이션 파일**: ```php // database/migrations/xxxx_add_target_user_id_to_today_issues_table.php Schema::table('today_issues', function (Blueprint $table) { $table->unsignedBigInteger('target_user_id') ->nullable() ->after('source_id') ->comment('특정 대상 사용자 ID (null이면 테넌트 전체)'); $table->foreign('target_user_id') ->references('id') ->on('users') ->onDelete('cascade'); $table->index(['tenant_id', 'target_user_id']); }); ``` ### 4.2 Phase 2: TodayIssue 모델 수정 ```php // app/Models/Tenants/TodayIssue.php protected $fillable = [ // ... 기존 필드 'target_user_id', // 추가 ]; public function targetUser(): BelongsTo { return $this->belongsTo(User::class, 'target_user_id'); } public static function createIssue( int $tenantId, string $sourceType, ?int $sourceId, string $badge, string $content, ?string $path = null, bool $needsApproval = false, ?\DateTime $expiresAt = null, ?int $targetUserId = null // 추가 ): self { // ... 기존 로직 + target_user_id 저장 } ``` ### 4.3 Phase 3: Observer 수정 **결재요청 - 결재자에게만**: ```php // handleApprovalStepChange() 수정 $this->createIssueWithFcm( tenantId: $approval->tenant_id, sourceType: TodayIssue::SOURCE_APPROVAL, sourceId: $step->id, badge: TodayIssue::BADGE_APPROVAL_REQUEST, content: __('message.today_issue.approval_pending', [...]), path: '/approval/inbox', needsApproval: true, expiresAt: null, targetUserId: $step->user_id // 결재자 ); ``` **기안 승인/반려/완료 - 기안자에게만** (신규): ```php // handleApprovalStatusChange() 신규 메서드 public function handleApprovalStatusChange(Approval $approval): void { $badge = match($approval->status) { 'approved' => TodayIssue::BADGE_DRAFT_APPROVED, 'rejected' => TodayIssue::BADGE_DRAFT_REJECTED, 'completed' => TodayIssue::BADGE_DRAFT_COMPLETED, default => null, }; if (!$badge) return; $this->createIssueWithFcm( tenantId: $approval->tenant_id, sourceType: TodayIssue::SOURCE_APPROVAL, sourceId: $approval->id, badge: $badge, content: __('message.today_issue.'.$approval->status, [...]), path: '/approval/draft', needsApproval: false, expiresAt: Carbon::now()->addDays(7), targetUserId: $approval->drafter_id // 기안자 ); } ``` ### 4.4 Phase 4: FCM 발송 로직 수정 ```php // sendFcmNotification() 수정 public function sendFcmNotification(TodayIssue $issue): void { // target_user_id가 있으면 해당 사용자만, 없으면 테넌트 전체 $tokens = $this->getEnabledUserTokens( $issue->tenant_id, $issue->notification_type, $issue->target_user_id // 추가 ); // ... 기존 발송 로직 } // getEnabledUserTokens() 수정 private function getEnabledUserTokens( int $tenantId, string $notificationType, ?int $targetUserId = null // 추가 ): array { $query = PushDeviceToken::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('is_active', true) ->whereNull('deleted_at'); // 특정 대상자가 지정된 경우 if ($targetUserId !== null) { $query->where('user_id', $targetUserId); } $tokens = $query->get(); // 알림 설정 확인 후 필터링 $enabledTokens = []; foreach ($tokens as $token) { if ($this->isNotificationEnabledForUser($tenantId, $token->user_id, $notificationType)) { $enabledTokens[] = $token->token; } } return $enabledTokens; } ``` --- ## 5. 제외 항목 ### 5.1 근태 알림 (정책 미확정) 다음 알림 타입은 이번 작업에서 **제외**: - 연차 알림 - 출근 알림 - 지각 알림 - 결근 알림 **사유**: 정책이 모호하여 추후 별도 작업 ### 5.2 알림 소리 커스터마이징 현재는 **하드코딩된 채널별 알림음** 사용: - `push_urgent`: 긴급 (신규업체) - `push_payment`: 결재 - `push_sales_order`: 수주 - `push_default`: 기타 **추후 작업**: 사용자별 알림 설정의 `soundType` 값 기준으로 발송 --- ## 6. 영향받는 파일 ### API (api/) | 파일 | 변경 내용 | |------|----------| | `database/migrations/2026_01_28_132426_add_target_user_id_to_today_issues_table.php` | 신규 - 마이그레이션 | | `app/Models/Tenants/TodayIssue.php` | target_user_id 추가, 신규 뱃지 상수, targetUser 관계, forUser/targetedTo 스코프 | | `app/Services/TodayIssueObserverService.php` | createIssueWithFcm, sendFcmNotification, getEnabledUserTokens 수정, handleApprovalStatusChange 추가 | | `app/Observers/TodayIssue/ApprovalIssueObserver.php` | 신규 - 기안 상태 변경 Observer | | `app/Providers/AppServiceProvider.php` | ApprovalIssueObserver 등록 | | `lang/ko/message.php` | 신규 메시지 키 추가 (draft_approved/rejected/completed) | ### React (react/) - 변경 없음 프론트엔드 알림 설정 UI는 이미 사용자별로 구현되어 있음. --- ## 7. 검증 방법 ### 7.1 테스트 시나리오 | # | 시나리오 | 예상 결과 | |---|----------|----------| | 1 | A가 B에게 결재 요청 | B에게만 FCM 발송 | | 2 | B가 A의 기안 승인 | A에게만 FCM 발송 | | 3 | B가 A의 기안 반려 | A에게만 FCM 발송 | | 4 | 수주 등록 | 테넌트 전체 (알림 ON인 사용자만) | | 5 | A가 알림 OFF → 수주 등록 | A에게는 발송 안됨 | ### 7.2 성공 기준 - [ ] 결재요청 알림이 결재자에게만 발송됨 - [ ] 기안 상태 변경 알림이 기안자에게만 발송됨 - [ ] 사용자별 알림 설정(ON/OFF)이 정상 동작함 - [ ] 기존 브로드캐스트 이슈(수주, 입금 등)는 정상 동작함 --- ## 8. 참고 문서 - `api/app/Services/TodayIssueObserverService.php` - 현재 발송 로직 - `api/app/Models/NotificationSetting.php` - 알림 설정 모델 - `react/src/components/settings/NotificationSettings/types.ts` - 프론트엔드 알림 설정 타입 --- ## 9. 변경 이력 | 날짜 | 항목 | 변경 내용 | 파일 | 승인 | |------|------|----------|------|------| | 2026-01-28 | - | 계획 문서 초안 작성 | - | - | | 2026-01-28 | Phase 1 | target_user_id 컬럼 추가 마이그레이션 | migrations/2026_01_28_132426_* | ✅ | | 2026-01-28 | Phase 2 | TodayIssue 모델 수정 (fillable, relation, scopes) | TodayIssue.php | ✅ | | 2026-01-28 | Phase 3 | Observer 수정 (결재자/기안자 타겟팅) | TodayIssueObserverService.php, ApprovalIssueObserver.php | ✅ | | 2026-01-28 | Phase 4 | FCM 발송 로직 수정 | TodayIssueObserverService.php | ✅ | | 2026-01-28 | 신규 | ApprovalIssueObserver 생성 | ApprovalIssueObserver.php | ✅ | | 2026-01-28 | i18n | 기안 상태 알림 메시지 추가 | lang/ko/message.php | ✅ | --- *이 문서는 /sc:plan 스킬로 생성되었습니다.*