feat: 알림 신뢰성·내용 보강 (SSE 재동기화·웹푸시·전직원 일정·파일첨부)

[신뢰성]
- SSE 재연결 시 fetchInitial 로 재동기화: 끊긴 사이 놓친 알림 복구 +
  access 토큰 자동 refresh 로 만료 토큰 재연결 무한루프 해소
- 서비스워커 pushsubscriptionchange 처리로 구독 회전 시 자동 재등록
- 읽은 알림 90일 retention 정리 배치(@Cron)
- 웹푸시 다기기 병렬 발송(allSettled) + TTL/urgency 지정
- 알림 문구 SSOT 상호참조 주석 + web-push 커버리지 테스트 추가

[내용]
- 전 직원(allHands) 일정도 전 사용자에게 알림(생성/수정/삭제)
- task.file_attached 신규 타입: 파일 첨부 시 지시자·담당자 알림
- task.started 재개(수정요청 후) 문구 '재개/시작' 구분
- task.due_changed 푸시에 날짜/해제 상세 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:31:25 +09:00
parent 2d4f2e2c19
commit 9a79e169c8
10 changed files with 285 additions and 46 deletions
@@ -21,6 +21,7 @@ export type NotificationType =
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
| 'task.file_attached' // 내 관련 업무에 파일이 첨부됨
| 'member.invited' // 프로젝트에 멤버로 초대됨
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
| 'member.removed' // 프로젝트에서 제외됨
@@ -5,7 +5,8 @@ import {
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LessThan, Repository } from 'typeorm';
import { Subject, merge, interval, type Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import Redis from 'ioredis';
@@ -65,6 +66,8 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
private subscriber: Redis | null = null;
// 실시간 알림 채널명
private static readonly CHANNEL = 'relay:notifications';
// 읽은 알림 보관 기간(일) — 이보다 오래된 '읽음' 알림은 주기적으로 정리해 무한 증가를 막는다.
private static readonly READ_RETENTION_DAYS = 90;
constructor(
@InjectRepository(Notification)
@@ -224,6 +227,29 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
return { unreadCount: 0 };
}
// 오래된 '읽음' 알림 정리 — 매일 새벽 4시. 무한 증가 방지(미읽음은 보존).
// best-effort: 실패해도 서비스에 영향 없음(다음 주기에 재시도).
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async cleanupOldRead(): Promise<void> {
try {
const cutoff = new Date(
Date.now() -
NotificationService.READ_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const result = await this.notificationRepo.delete({
read: true,
createdAt: LessThan(cutoff),
});
if (result.affected) {
this.logger.log(`오래된 읽음 알림 ${result.affected}건 정리`);
}
} catch (err) {
this.logger.warn(
`읽음 알림 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지)
streamFor(userId: string): Observable<MessageEvent> {
const events = this.stream$.asObservable().pipe(
@@ -0,0 +1,66 @@
import { WebPushService } from './web-push.service';
import type { NotificationType } from './entities/notification.entity';
// 모든 NotificationType 에 대해 푸시 문구가 조립되는지 검증한다.
// (NotificationType 을 추가하고 notificationContent 의 case 를 빠뜨리면 기본 문구가 남아 실패)
//
// SSOT 주의: 인앱 표시 문구는 프론트 notification.store.ts 의 toView() 가 별도로 담당한다.
// 새 타입을 추가하면 이 목록과 양쪽(백엔드 notificationContent, 프론트 toView)을 함께 갱신할 것.
const ALL_TYPES: NotificationType[] = [
'task.assigned',
'task.unassigned',
'task.approval_requested',
'task.approved',
'task.changes_requested',
'task.started',
'task.due_changed',
'task.checkpoint_reported',
'task.removed',
'task.commented',
'task.file_attached',
'member.invited',
'member.role_changed',
'member.removed',
'schedule.invited',
'schedule.updated',
'schedule.removed',
];
// 서버 기본 문구(케이스 누락 시 남는 값) — 이 문구가 남으면 매핑이 빠진 것.
const DEFAULT_BODY = '새 알림이 도착했습니다';
describe('WebPushService.notificationContent', () => {
// notificationContent 는 repo/config 를 쓰지 않으므로 의존성 없이 인스턴스화한다.
const service = new WebPushService(
null as unknown as never,
null as unknown as never,
);
// 표시 문구 조립에 쓰이는 대표 payload(모든 필드 채워 어떤 case 든 자연스럽게)
const payload = {
actorName: '홍길동',
projectName: '테스트 프로젝트',
projectId: 'p-1',
taskTitle: '샘플 업무',
taskSeq: 12,
eventTitle: '주간 회의',
roleType: 'admin',
isReply: true,
fileName: '보고서.pdf',
dueDate: '2026-07-10T00:00:00.000Z',
resumed: true,
};
it.each(ALL_TYPES)('%s 타입의 본문이 기본 문구가 아니어야 한다', (type) => {
const content = service.notificationContent(type, payload);
expect(content.title).toBeTruthy();
expect(content.body).not.toBe(DEFAULT_BODY);
expect(content.body.length).toBeGreaterThan(0);
});
it('타입 목록이 17종(현재 정의)과 일치해야 한다', () => {
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
expect(ALL_TYPES).toHaveLength(17);
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
});
});
@@ -22,6 +22,19 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
const v = p[k];
return typeof v === 'number' ? v : null;
}
function pbool(p: Record<string, unknown>, k: string): boolean {
return p[k] === true;
}
// 마감일(ISO) → 한국식 날짜 라벨(한국 시간대 고정). 파싱 실패 시 빈 문자열.
function fmtDueKo(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long',
timeZone: 'Asia/Seoul',
}).format(d);
}
// 웹 푸시 발송 + 구독 관리.
// VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade.
@@ -29,6 +42,8 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
export class WebPushService implements OnModuleInit {
private readonly logger = new Logger(WebPushService.name);
private enabled = false;
// 푸시 서비스가 오프라인 기기에 보관·재시도하는 최대 시간(초). 하루 지나면 폐기.
private static readonly PUSH_TTL_SECONDS = 60 * 60 * 24;
constructor(
@InjectRepository(PushSubscription)
@@ -82,6 +97,10 @@ export class WebPushService implements OnModuleInit {
// type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로).
// 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성).
//
// SSOT 주의: 인앱 표시 문구는 프론트 `notification.store.ts` 의 `toView()` 가 별도로 조립한다
// (인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **두 곳 모두** case 를 넣어야 하며,
// 여기 커버리지는 `web-push.service.spec.ts` 가 기본 문구 잔존 여부로 검증한다.
notificationContent(
type: NotificationType,
payload: Record<string, unknown>,
@@ -111,11 +130,16 @@ export class WebPushService implements OnModuleInit {
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
break;
case 'task.started':
body = `'${taskTitle}' 업무를 시작했습니다`;
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`;
break;
case 'task.due_changed':
body = `'${taskTitle}' 업무의 마감기한이 변경되었습니다`;
case 'task.due_changed': {
const label = fmtDueKo(pstr(payload, 'dueDate'));
body = label
? `'${taskTitle}' 업무의 마감기한을 ${label}(으)로 변경했습니다`
: `'${taskTitle}' 업무의 마감기한을 해제했습니다`;
break;
}
case 'task.checkpoint_reported':
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
break;
@@ -136,6 +160,13 @@ export class WebPushService implements OnModuleInit {
case 'task.commented':
body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`;
break;
case 'task.file_attached': {
const fileName = pstr(payload, 'fileName');
body = fileName
? `'${taskTitle}'에 파일 '${fileName}'을(를) 첨부했습니다`
: `'${taskTitle}'에 파일을 첨부했습니다`;
break;
}
case 'member.invited':
body = `'${projectName}' 프로젝트에 초대했습니다`;
url = projectUrl;
@@ -164,28 +195,34 @@ export class WebPushService implements OnModuleInit {
return { title, body, url };
}
// 한 사용자의 모든 구독에 발송 — 만료(404/410) 구독은 정리. best-effort.
// 한 사용자의 모든 구독(기기)에 발송 — 만료(404/410) 구독은 정리. best-effort.
// 다기기 병렬 발송(allSettled) — 하나가 실패/지연돼도 다른 기기 발송을 막지 않는다.
async sendToUser(userId: string, content: PushContent): Promise<void> {
if (!this.enabled) return;
const subs = await this.subRepo.find({ where: { user: { id: userId } } });
if (subs.length === 0) return;
const json = JSON.stringify(content);
for (const s of subs) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
json,
await Promise.allSettled(subs.map((s) => this.sendOne(s, json)));
}
// 단일 구독 발송 — 만료 구독 정리 포함. 예외는 흡수(호출부 흐름 보호).
private async sendOne(s: PushSubscription, json: string): Promise<void> {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
json,
// 오프라인 기기용 보관 시간 + 일반 우선순위(배터리 절약 정책 존중)
{ TTL: WebPushService.PUSH_TTL_SECONDS, urgency: 'normal' },
);
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 404 || statusCode === 410) {
// 만료/해지된 구독 — 정리
await this.subRepo.delete({ id: s.id });
} else {
this.logger.warn(
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
);
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 404 || statusCode === 410) {
// 만료/해지된 구독 — 정리
await this.subRepo.delete({ id: s.id });
} else {
this.logger.warn(
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
@@ -145,16 +145,14 @@ export class ScheduleService {
});
const saved = await this.eventRepo.save(event);
// 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략)
await this.notifyEvent(
'schedule.invited',
this.idsOf(dto.attendeeIds),
actor,
{
eventTitle: event.title,
start: event.startDate,
},
);
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
const recipients = event.allHands
? await this.userService.findAllIds()
: this.idsOf(dto.attendeeIds);
await this.notifyEvent('schedule.invited', recipients, actor, {
eventTitle: event.title,
start: event.startDate,
});
return this.findEventOrThrow(saved.id);
}
@@ -169,8 +167,9 @@ export class ScheduleService {
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
// 수정 전 참석자(알림 대상 비교용)
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
const wasAllHands = event.allHands;
if (dto.categoryId !== undefined) {
const category = await this.categoryRepo.findOne({
@@ -195,10 +194,16 @@ export class ScheduleService {
}
await this.eventRepo.save(event);
// 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨
const newIds = (event.attendees ?? []).map((a) => a.id);
const invited = newIds.filter((i) => !oldIds.has(i));
const stillThere = newIds.filter((i) => oldIds.has(i));
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
const allIds =
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
const newEffective = event.allHands
? allIds
: (event.attendees ?? []).map((a) => a.id);
const invited = newEffective.filter((i) => !oldEffective.has(i));
const stillThere = newEffective.filter((i) => oldEffective.has(i));
const payload = { eventTitle: event.title, start: event.startDate };
await this.notifyEvent('schedule.invited', invited, actor, payload);
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
@@ -213,7 +218,10 @@ export class ScheduleService {
relations: ['attendees'],
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
const recipients = (event.attendees ?? []).map((a) => a.id);
// 전 직원 일정이면 전 사용자에게 삭제 알림
const recipients = event.allHands
? await this.userService.findAllIds()
: (event.attendees ?? []).map((a) => a.id);
await this.eventRepo.delete({ id });
+20 -2
View File
@@ -851,6 +851,20 @@ export class TaskService {
taskSeq: task.seq,
});
// 알림 — 업무 관련자(지시자 + 담당자, 업로더 본인 제외)에게 '파일 첨부'
await this.notification.notify({
recipientIds: [
...(task.issuer ? [task.issuer.id] : []),
...task.assignees.map((a) => a.id),
],
actorId: actingUserId,
type: 'task.file_attached',
payload: {
...this.taskNotifyPayload(project, task, membership.user.name),
fileName: name,
},
});
return this.toAttachmentResponse(
{ ...saved, uploader: membership.user },
project,
@@ -1182,12 +1196,16 @@ export class TaskService {
payload: this.taskNotifyPayload(project, task, actorName),
});
} else if (next === 'prog') {
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게.
// resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분).
await this.notification.notify({
recipientIds: task.issuer ? [task.issuer.id] : [],
actorId: actingUserId,
type: 'task.started',
payload: this.taskNotifyPayload(project, task, actorName),
payload: {
...this.taskNotifyPayload(project, task, actorName),
resumed: from === 'changes',
},
});
}
}
+6
View File
@@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap {
});
}
// 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용
async findAllIds(): Promise<string[]> {
const rows = await this.userRepository.find({ select: { id: true } });
return rows.map((u) => u.id);
}
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
findByEmailWithPassword(email: string): Promise<User | null> {
return this.userRepository
+43
View File
@@ -41,6 +41,49 @@ self.addEventListener('push', (event) => {
)
})
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의
// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준.
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
try {
// 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독
let sub = event.newSubscription || null
if (!sub) {
const opts =
event.oldSubscription && event.oldSubscription.options
? event.oldSubscription.options
: null
const appServerKey = opts && opts.applicationServerKey
// 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료
if (!appServerKey) return
sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: appServerKey,
})
}
if (!sub) return
const json = sub.toJSON()
await fetch('/api/notifications/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: {
p256dh: (json.keys && json.keys.p256dh) || '',
auth: (json.keys && json.keys.auth) || '',
},
}),
})
} catch (e) {
/* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */
}
})(),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/'
+41 -8
View File
@@ -49,6 +49,10 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
}
// API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립
//
// SSOT 주의: 푸시(사이트 닫힘) 표시 문구는 백엔드 web-push.service.ts 의 notificationContent() 가
// 별도로 조립한다(인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **양쪽 모두** case 를 넣을 것.
// (백엔드 커버리지는 web-push.service.spec.ts 가 검증)
function toView(n: ApiNotification): NotificationView {
const p = n.payload
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
@@ -77,7 +81,8 @@ function toView(n: ApiNotification): NotificationView {
break
case 'task.started':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다`
break
case 'task.due_changed': {
const due = pstr(p, 'dueDate')
@@ -113,6 +118,14 @@ function toView(n: ApiNotification): NotificationView {
tone = ''
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
break
case 'task.file_attached': {
const fileName = escapeHtml(pstr(p, 'fileName'))
tone = ''
html = fileName
? `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일 <b>${fileName}</b>을(를) 첨부했습니다`
: `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일을 첨부했습니다`
break
}
case 'member.invited':
tone = 'accent'
to = projectId ? `/projects/${projectId}` : null
@@ -204,20 +217,40 @@ export const useNotificationStore = defineStore('notification', () => {
es.close()
es = null
}
if (wantConnected && !reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (wantConnected) openStream()
}, RECONNECT_DELAY)
}
scheduleReconnect()
}
}
// 재연결 예약(중복 방지) — 지연 후 '재동기화 → 스트림 오픈' 순으로 복구한다.
// 재동기화(fetchInitial)는 axios 인터셉터를 타므로,
// 1) 끊긴 사이 놓친 알림을 수신함에 복구하고(미읽음 수도 서버값으로 보정)
// 2) access 토큰이 만료됐다면 자동 refresh 되어 쿠키가 갱신된다
// → 만료된 토큰으로 SSE 를 여는 무한 실패 루프를 방지.
function scheduleReconnect(): void {
if (!wantConnected || reconnectTimer) return
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
if (!wantConnected) return
try {
await fetchInitial()
} catch {
// 재동기화 실패(세션 만료/네트워크 지속) — 스트림은 열지 않고 다음 주기 재시도
scheduleReconnect()
return
}
if (wantConnected) openStream()
}, RECONNECT_DELAY)
}
// 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전
async function connect(): Promise<void> {
if (wantConnected) return
wantConnected = true
await fetchInitial()
try {
await fetchInitial()
} catch {
// 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구
}
openStream()
}
+1
View File
@@ -14,6 +14,7 @@ export type NotificationType =
| 'task.checkpoint_reported'
| 'task.removed'
| 'task.commented'
| 'task.file_attached'
| 'member.invited'
| 'member.role_changed'
| 'member.removed'