알림 프론트엔드: 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
활동 로그
-
팀팀리드 상태 변경: 리뷰 → 완료
2026년 03월 02일 05:13:06
-
Nnotification-frontend 상태 변경: 할 일 → 리뷰
2026년 03월 02일 05:12:30
-
Nnotification-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으로 자동 등록됨 -
Nnotification-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 알림 방식 섹션 업데이트