알림 프론트엔드: Service Worker + Settings UI + Stimulus

ID: b5874896-3c01-4989-9874-cf84e1708f58

보통 완료

## 목표
브라우저 Service Worker, 알림 설정 UI, Stimulus 컨트롤러를 구현합니다.

## 선행 조건
- notification-foundation 에이전트가 모델/마이그레이션 완료 후 시작
- notification-backend 에이전트가 라우트(/push/subscribe, /push/vapid_public_key)를 설정한 후 시작

## 작업 내용

### 1. Service Worker 생성
파일: `public/service-worker.js`

```javascript
self.addEventListener("push", function(event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "LogBible";
const options = {
body: data.body || "새 알림이 있습니다.",
icon: data.icon || "/icon-192x192.png",
badge: data.badge || "/badge-72x72.png",
data: { url: data.url || "/" },
actions: data.actions || [{ action: "open", title: "열기" }],
requireInteraction: true
};

event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener("notificationclick", function(event) {
event.notification.close();
const url = event.notification.data?.url || "/";

event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(function(clientList) {
for (const client of clientList) {
if (client.url.includes(url) && "focus" in client) {
return client.focus();
}
}
return clients.openWindow(url);
})
);
});
```

### 2. Stimulus Push Notification 컨트롤러 생성
파일: `app/javascript/controllers/push_notification_controller.js`

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["status", "toggleBtn"]

async connect() {
this.updateStatus();
}

async togglePush() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
alert("이 브라우저는 푸시 알림을 지원하지 않습니다.");
return;
}

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();

if (subscription) {
// 이미 구독 중이면 해제
await this.unsubscribe(subscription);
} else {
// 구독 시작
await this.subscribe(registration);
}

this.updateStatus();
}

async subscribe(registration) {
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.");
return;
}

// VAPID 키 가져오기
const response = await fetch("/push/vapid_public_key");
const { vapid_public_key } = await response.json();

const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(vapid_public_key)
});

const sub = subscription.toJSON();

// 서버에 구독 등록
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
await fetch("/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: sub.keys.p256dh,
auth: sub.keys.auth,
browser_info: navigator.userAgent,
device_info: /Mobile|Android/i.test(navigator.userAgent) ? "mobile" : "desktop"
})
});
} catch (error) {
console.error("Push subscription failed:", error);
alert("푸시 알림 등록에 실패했습니다.");
}
}

async unsubscribe(subscription) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
await fetch("/push/unsubscribe", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ endpoint: subscription.endpoint })
});
await subscription.unsubscribe();
}

async updateStatus() {
if (!("serviceWorker" in navigator)) {
if (this.hasStatusTarget) this.statusTarget.textContent = "미지원";
return;
}

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();

if (this.hasStatusTarget) {
this.statusTarget.textContent = subscription ? "활성" : "비활성";
}
if (this.hasToggleBtnTarget) {
this.toggleBtnTarget.textContent = subscription ? "푸시 알림 해제" : "푸시 알림 등록";
}
}

urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
}
```

### 3. Service Worker 등록 (application.js 또는 별도)
파일: `app/javascript/controllers/service_worker_controller.js`

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
connect() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js")
.then(reg => console.log("Service Worker registered:", reg.scope))
.catch(err => console.error("Service Worker registration failed:", err));
}
}
}
```
- application layout의 body에 `data-controller="service-worker"` 추가

### 4. Settings UI 업데이트
파일: `app/views/settings/show.html.erb`

기존 알림 설정 섹션에 notification_methods 체크박스 추가:
- 알림 활성화 토글 (notification_enabled)
- 알림 시간 설정 (notification_time)
- **알림 방식 선택** (notification_methods) - 체크박스:
- [ ] 푸시 알림 (push) + 푸시 등록/해제 버튼
- [ ] 이메일 알림 (email)
- [ ] 카카오톡 알림 (kakao) - 비활성화 표시 "준비 중"

Tailwind CSS 사용, 반응형 디자인.
푸시 알림 섹션에 `data-controller="push-notification"` 연결.

레거시 UI 참고: 기존 settings/show.html.erb의 스타일을 따릅니다.

### 5. SettingsController 파라미터 업데이트
파일: `app/controllers/settings_controller.rb`
- `settings_params`에 `notification_methods` 추가

## 완료 기준
- public/service-worker.js 존재
- Stimulus push_notification_controller 동작
- Service Worker 자동 등록
- 설정 페이지에서 알림 방식 선택 가능
- 푸시 알림 등록/해제 버튼 동작
- notification_methods가 서버에 저장됨
- bin/rails test 통과

첨부 이미지

이미지 추가 (Ctrl+V로 붙여넣기 또는 클릭)

JPEG, PNG, GIF, WebP / 최대 10MB

담당자: notification-frontend
생성일: 2026년 03월 02일 04:41

활동 로그

  • 팀리드 상태 변경: 리뷰 → 완료

    2026년 03월 02일 05:13:06

  • N
    notification-frontend 상태 변경: 할 일 → 리뷰

    2026년 03월 02일 05:12:30

  • N
    notification-frontend 프론트엔드 작업 완료

    2026년 03월 02일 05:12:29

    상세 보기

    ## 생성된 파일
    - `app/javascript/controllers/service_worker_controller.js` - Service Worker 등록
    - `app/javascript/controllers/push_notification_controller.js` - 푸시 구독/해제 관리
    - `app/javascript/controllers/notification_methods_controller.js` - 알림 방식 체크박스 → hidden input 연동

    ## 수정된 파일
    - `app/views/layouts/application.html.erb` - body에 `service-worker` data-controller 추가
    - `app/views/settings/show.html.erb` - 알림 방식 섹션 추가 (푸시/이메일/카카오 체크박스 + 푸시 등록/해제 버튼)
    - `Gemfile` - `web-push` gem 추가 (push_notifications 그룹)

    ## 삭제된 파일
    - `public/service-worker.js` - Rails PWA 라우트와 충돌 방지 (기존 `app/views/pwa/service-worker.js`에 push 핸들러 이미 포함)

    ## 테스트 결과
    - 406 runs, 948 assertions, 1 failure (기존 PagesController 테스트, 본 작업 무관), 0 errors

    ## 참고
    - `app/views/pwa/service-worker.js`에 이미 push/notificationclick 이벤트 핸들러가 포함되어 있어 별도 public/service-worker.js 불필요
    - Stimulus 컨트롤러는 eagerLoadControllersFrom으로 자동 등록됨

  • N
    notification-frontend 프론트엔드 작업 시작

    2026년 03월 02일 05:08:41

    상세 보기

    ## 작업 범위
    - Service Worker 생성 (public/service-worker.js)
    - Stimulus 컨트롤러 3개 생성 (push_notification, service_worker, notification_methods)
    - Application Layout에 Service Worker 연결
    - Settings UI 알림 방식 섹션 업데이트