- integration-progress/api-contract: 8-1 페이지네이션·8-2 알림·8-3 Redis 캐싱 반영. - notification-review.md: 알림 시스템 전수 검토(유형/빠짐/주의) + 보완 이력. - redis-cache-review.md: Redis 캐시 전수 검토 + 보완 이력. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
알림(Notification) 시스템 전체 검토
검토일: 2026-06-19 · 대상: 8-2단계 실시간 알림(인앱 + SSE) 구현 전체 관련 계약:
api-contract.md§8 · 진행 기록:integration-progress.md§8-2 코드 기준:backend/src/modules/notification/,task.service.ts·member.service.ts의notify호출부,frontend/src/stores/notification.store.ts·layouts/AppShell.vue
이 문서는 세 가지 질문에 답한다 — ① 어떤 알림을 구현했는가, ② 빠진 부분은 없는가, ③ 잘못된 부분은 없는가.
갱신(2026-06-19): 본 검토의 빠짐(gap) 전건 + 확장(R6) 보완 완료 — P1(G1·G2·G3) + P2(G4·G5) + P3(G6·G7) + R5(답글 구분) + R6(Redis pub/sub 다중 인스턴스 실시간). 알림 유형 6→12종, 미보완 gap 0건, 남은 건 경미한 견고성 R1~R4 뿐. (아래 §2·§5·§6·§7 에 반영)
1. 아키텍처 요약
- 수신함 모델: 활동 피드(저장소 전체 로그)와 별개로, 사용자별
Notification(recipient·type·payload·read) 수신함을 둔다. - 적재 경로: 도메인 서비스(Task/Member)가 행위 시점에
NotificationService.notify({recipientIds, actorId, type, payload})호출 → 행위자 제외 + DB 적재(베스트 에포트) → 실시간 전달(REDIS_HOST설정 시 Redis pub/sub fan-out, 미설정 시 인메모리Subject). - 실시간 전송: SSE(
@Sse('stream')), 쿠키 인증, 25초 하트비트. 표준 응답 래퍼는@SkipTransform()으로 우회. - 프론트: store 싱글톤이
EventSource보유(화면 전환에 안 끊김), 상단바 종에 미읽음 뱃지 + 드롭다운. type+payload→문구/이동경로 파생(escapeHtml).
2. 구현된 알림 유형 (12종)
업무 8종 + 멤버 3종 + 댓글/답글 1종. (P1·P2·P3 보완 반영)
| # | type | 트리거(코드) | 수신자 | payload 핵심 | 프론트 문구 / 이동 | 톤 |
|---|---|---|---|---|---|---|
| 1 | task.assigned |
업무 생성(create) · 수정 시 추가된 담당자(update) |
담당자(추가분) | repoId·taskSeq·taskTitle·actorName | "{actor}님이 {업무}를 지시했습니다" → 업무 상세 | blue |
| 2 | task.unassigned |
수정 시 제외된 담당자(update) |
담당자(제거분) | 〃 | "{actor}님이 {업무} 담당에서 회원님을 제외했습니다" → 업무 상세 | red |
| 3 | task.approval_requested |
상태 전이 →review(transition) |
지시자(issuer) | 〃 | "{actor}님이 {업무} 승인을 요청했습니다" → 업무 상세 | amber |
| 4 | task.approved |
상태 전이 →done |
담당자 전원 | 〃 | "{업무} 업무가 승인되었습니다" → 업무 상세 | green |
| 5 | task.changes_requested |
상태 전이 →changes |
담당자 전원 | 〃 | "{actor}님이 {업무} 수정을 요청했습니다" → 업무 상세 | red |
| 6 | task.started |
상태 전이 →prog(시작/재개) |
지시자(issuer) | 〃 | "{actor}님이 {업무}를 시작했습니다" → 업무 상세 | blue |
| 7 | task.due_changed |
수정 시 마감 변경(update) |
담당자 전원 | 〃 + dueDate |
"{actor}님이 {업무}의 마감기한을 {날짜}로 변경/해제했습니다" → 업무 상세 | blue |
| 8 | task.removed |
업무 삭제(remove) |
지시자 + 담당자 | 〃(삭제 전 스냅샷) | "{actor}님이 {업무} 업무를 삭제했습니다" → 저장소(업무 소멸) | red |
| 9 | task.commented |
댓글(addComment) · 답글(addReply) |
지시자 + 담당자 + (답글 시) 원댓글 작성자 | 〃 + (답글 시) isReply |
"{actor}님이 {업무}에 댓글/답글을 남겼습니다" → 업무 상세 | (기본) |
| 10 | member.invited |
멤버 초대(invite) |
피초대자 | repoId·repoName·roleType·actorName | "{actor}님이 {저장소}에 초대했습니다" → 저장소 | accent |
| 11 | member.role_changed |
멤버 역할 변경(update) |
역할이 바뀐 당사자 | repoId·repoName·roleType·actorName | "{actor}님이 …역할을 {관리자/멤버}로 변경했습니다" → 저장소 | accent |
| 12 | member.removed |
멤버 제거(remove) |
제거된 당사자 | repoId·repoName·actorName | "{actor}님이 {저장소}에서 회원님을 제외했습니다" → 저장소 | red |
모든 경우 행위자 본인은 수신 대상에서 자동 제외(
notify내부Set중복 제거 + actorId 필터). 빈 수신자면 적재/푸시 생략.
3. 검토 결과 한눈에
| 구분 | 건수 | 비고 |
|---|---|---|
| ✅ 정상 동작 확인 | 9 | 런타임 실시간 수신 포함 확인됨 |
| ⚠️ 빠진 부분(gap) | 7 → 0 (전건 보완 완료) | 활동 대비 알림 격차 해소 |
| 🐛 주의/개선 | 6 → 4 (R5·R6 해소) | 기능 버그는 없음. 남은 항목은 경미한 견고성(R1~R4) |
종합 판정: 핵심 업무 협업 플로우(지시→진행→승인/수정요청→댓글, 초대)의 알림은 정상 구현·동작한다. 치명적 버그 없음. 빠짐(gap) 7건은 P1/P2/P3 로 전건 보완 완료(2026-06-19) — 의도적 제외 항목(저장소 설정 변경·파일 첨부 등)을 빼면 알림화할 도메인 이벤트는 모두 커버됨. 남은 것은 견고성·확장 항목(§6, 특히 R6 다중 인스턴스).
4. ✅ 정상 동작 확인 항목
- 핵심 플로우 6종 적재·전송 — 위 표대로 트리거·수신자 매핑 정확. 런타임에서 실시간 종 수신 확인됨(사용자 검증).
- 행위자 제외 — 자기 행위로 자기 알림이 오지 않음(
notify필터). - 중복 제거 — 지시자=담당자(자가지시·자가담당) 같은 중복 수신자는 1건으로 합쳐짐(
[...new Set]). - 권한·격리 —
streamFor(userId)/listFor/markRead/markAllRead모두 인증 사용자 본인 알림으로 한정. 타인 알림 열람·읽음 처리 불가(recipientWHERE 조건). - 베스트 에포트 —
notify전체가 try/catch 로 감싸져 알림 실패가 본 행위(업무 생성 등)를 막지 않음. - XSS 안전 — payload 의 사용자 입력(actorName·taskTitle·repoName)을 프론트에서
escapeHtml후 신뢰 태그(<b>)만 v-html 조립. - 연결 수명 — EventSource 를 store 싱글톤이 보유 → 화면 전환에 안 끊김, 로그아웃 시 해제. 멱등
connect(). - null 안전 — 지시자 없음(
issuerSET NULL)·담당자 0명 시 빈 수신자로 처리. - 읽음 동기화 — 단건/전체 읽음 후 서버
unreadCount로 보정.
5. ⚠️ 빠진 부분 (Gaps)
핵심 원인: 활동 피드는 11종 이벤트를 적재하지만 알림은 6종만 발생시킨다. 활동에는 있으나 알림이 없는 이벤트가 아래다.
| # | 누락 알림 | 현재 상태 | 권고 수신자 | 우선순위 | 비고 |
|---|---|---|---|---|---|
member.role_changed) |
✅ 보완 완료 — 역할 변경 시 당사자에게 알림 | 역할이 바뀐 당사자 | 2026-06-19 | ||
member.removed) |
✅ 보완 완료 — 제거 시 당사자에게 알림 | 제거된 당사자 | 2026-06-19 | ||
✅ 보완 완료 — addReply 가 원댓글 작성자도 수신자에 포함 |
원댓글 작성자(비참여자여도) | 2026-06-19 | |||
✅ 보완 완료 — update 제거분에 task.unassigned |
제외된 담당자 | 2026-06-19 | |||
✅ 보완 완료 — update 마감 변경 시 task.due_changed(payload dueDate) |
담당자 | 2026-06-19 | |||
✅ 보완 완료 — remove 전 스냅샷 후 task.removed(이동=저장소) |
담당자·지시자 | 2026-06-19 | |||
✅ 보완 완료 — transition →prog 에서 task.started |
지시자 | 2026-06-19 |
의도적으로 제외(정상): 저장소 생성/이름변경/공개범위 변경(
repo.*)·파일 첨부/삭제(task.file_*)·업무 일반 수정(task.edited)·체크리스트 개별 완료. 노이즈 대비 가치가 낮아 활동 피드로만 표현. (필요 시 추후 추가 가능)
보완 시 주의
- G1/G2(멤버):
member.role_changed/removed는 활동 적재 직후notify({recipientIds:[targetUserId], actorId:actingUserId, type, payload:{repoId,repoName,roleType?,actorName}})한 줄 추가로 해결. 단 프론트notification.store의toView에 두 type 분기 + 타입 정의(NotificationType) 추가 필요. - G3(답글):
addReply에서 수신자에parent.author?.id를 추가하면 됨(이미parent로딩됨). 중복은notify가 dedup.
6. 🐛 주의 / 개선 필요 (견고성·UX·확장)
기능 오류(버그)는 발견되지 않았다. 아래는 견고성·정합·확장 관점의 개선점이다.
| # | 항목 | 영향 | 권고 |
|---|---|---|---|
| R1 | 초기 fetch ↔ SSE 구독이 비원자적 | connect() 가 await fetchInitial() 후 openStream() — 그 사이 ms 윈도에 생성된 알림은 실시간 푸시 누락(DB엔 남아 새로고침 시 노출). |
표준 SSE 한계. 필요 시 구독 먼저→직후 목록 fetch 순서로 바꿔 누락→중복(이미 dedup) 쪽으로 전환 |
| R2 | notify 가 요청 경로에서 await |
업무 생성/전이/댓글마다 알림 INSERT + 푸시를 동기 대기 → 약간의 지연(실패는 흡수). | 영향 미미. 트래픽 증가 시 void(fire-and-forget) 또는 큐로 분리 검토 |
| R3 | markAllRead 클라 가드 |
unreadCount===0 이면 호출 생략 — 클라 카운트가 드리프트로 0이면 서버 미읽음을 못 비움. |
드물고 다음 fetch 로 자가 보정. 가드 제거 또는 주기적 재동기 고려 |
| R4 | 댓글 알림 광범위 + 음소거 없음 | task.commented 가 지시자+담당자 전원에게 발송. 대규모 팀·활발한 스레드에서 노이즈. |
현재 규모 허용. 추후 사용자별 음소거/멘션 한정 검토 |
✅ 해소 — addReply payload isReply:true → 프론트 "답글을 남겼습니다"로 구분 표시 |
2026-06-19 | ||
✅ 해소(2026-06-19) — NotificationService 가 REDIS_HOST 설정 시 Redis pub/sub(ioredis)로 fan-out: notify 가 채널 relay:notifications 에 발행, 모든 인스턴스가 구독해 각자 로컬 stream$ 로 전달 → 다중 인스턴스에서도 실시간 도달. 미설정 시 인메모리 폴백. |
2026-06-19 | ||
| R7 | recipients[i]↔saved[i] 순서 의존(참고) |
save(entities) 반환 순서가 입력 순서와 같다는 전제로 userId 라우팅. TypeORM 보장 범위 내 정상이나 암묵적. |
견고화하려면 recipient.id 를 응답에 포함해 직접 매핑(선택) |
보안·정합 재확인(이상 없음)
- 타 사용자 알림 접근 불가(§4-4).
- 토큰 만료 후 유휴 재연결은 5초 간격 무알림 재시도 → 다음 axios 호출의 refresh 로 자가 복구(기존 문서화된 한계).
- 알림은 도입 이후 발생분만(소급 없음) — 의도된 동작.
7. 권고 요약 (우선순위)
P1 (사용자 체감 빠짐 — 우선 보완 권장)→ ✅ 완료(2026-06-19)- ✅ G1 멤버 역할 변경 알림
- ✅ G2 멤버 제거 알림
- ✅ G3 답글 → 원댓글 작성자 알림
- ✅ P2 — 완료(2026-06-19): G4 담당자 제외 · G5 마감 변경
- ✅ P3 — 완료(2026-06-19): G6 업무 삭제 · G7 업무 시작 · R5 답글 구분
- ✅ 확장(인프라) — 완료(2026-06-19): R6 Redis pub/sub fan-out 전환(다중 인스턴스 실시간 지원)
- 남은 경미 항목(선택): R1(초기 fetch↔구독 비원자적)·R2(notify await)·R3(markAllRead 가드)·R4(댓글 광범위) — 모두 동작에 영향 없는 견고성/UX 미세조정
알림화할 도메인 이벤트의 빠짐(gap) 전건 + 확장(R6)까지 보완 완료. 남은 R1~R4 는 경미한 견고성 항목으로 동작 영향 없음.