# 알림(Notification) 시스템 전체 검토

> 검토일: 2026-06-19 · 대상: 8-2단계 실시간 알림(인앱 + SSE) 구현 전체
> 관련 계약: [`api-contract.md`](./api-contract.md) §8 · 진행 기록: [`integration-progress.md`](./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. ✅ 정상 동작 확인 항목

1. **핵심 플로우 6종 적재·전송** — 위 표대로 트리거·수신자 매핑 정확. 런타임에서 실시간 종 수신 확인됨(사용자 검증).
2. **행위자 제외** — 자기 행위로 자기 알림이 오지 않음(`notify` 필터).
3. **중복 제거** — 지시자=담당자(자가지시·자가담당) 같은 중복 수신자는 1건으로 합쳐짐(`[...new Set]`).
4. **권한·격리** — `streamFor(userId)`/`listFor`/`markRead`/`markAllRead` 모두 인증 사용자 본인 알림으로 한정. 타인 알림 열람·읽음 처리 불가(`recipient` WHERE 조건).
5. **베스트 에포트** — `notify` 전체가 try/catch 로 감싸져 알림 실패가 본 행위(업무 생성 등)를 막지 않음.
6. **XSS 안전** — payload 의 사용자 입력(actorName·taskTitle·repoName)을 프론트에서 `escapeHtml` 후 신뢰 태그(`<b>`)만 v-html 조립.
7. **연결 수명** — EventSource 를 store 싱글톤이 보유 → 화면 전환에 안 끊김, 로그아웃 시 해제. 멱등 `connect()`.
8. **null 안전** — 지시자 없음(`issuer` SET NULL)·담당자 0명 시 빈 수신자로 처리.
9. **읽음 동기화** — 단건/전체 읽음 후 서버 `unreadCount` 로 보정.

---

## 5. ⚠️ 빠진 부분 (Gaps)

핵심 원인: **활동 피드는 11종 이벤트를 적재하지만 알림은 6종만** 발생시킨다. 활동에는 있으나 알림이 없는 이벤트가 아래다.

| # | 누락 알림 | 현재 상태 | 권고 수신자 | 우선순위 | 비고 |
|---|---|---|---|---|---|
| ~~G1~~ | ~~멤버 역할 변경~~(`member.role_changed`) | ✅ **보완 완료** — 역할 변경 시 당사자에게 알림 | 역할이 바뀐 당사자 | ~~P1~~ 완료 | 2026-06-19 |
| ~~G2~~ | ~~멤버 제거~~(`member.removed`) | ✅ **보완 완료** — 제거 시 당사자에게 알림 | 제거된 당사자 | ~~P1~~ 완료 | 2026-06-19 |
| ~~G3~~ | ~~답글 → 원댓글 작성자~~ | ✅ **보완 완료** — `addReply` 가 원댓글 작성자도 수신자에 포함 | 원댓글 작성자(비참여자여도) | ~~P1~~ 완료 | 2026-06-19 |
| ~~G4~~ | ~~담당자에서 제외~~ | ✅ **보완 완료** — `update` 제거분에 `task.unassigned` | 제외된 담당자 | ~~P2~~ 완료 | 2026-06-19 |
| ~~G5~~ | ~~마감 변경~~ | ✅ **보완 완료** — `update` 마감 변경 시 `task.due_changed`(payload `dueDate`) | 담당자 | ~~P2~~ 완료 | 2026-06-19 |
| ~~G6~~ | ~~업무 삭제~~ | ✅ **보완 완료** — `remove` 전 스냅샷 후 `task.removed`(이동=저장소) | 담당자·지시자 | ~~P3~~ 완료 | 2026-06-19 |
| ~~G7~~ | ~~업무 시작/재개~~ | ✅ **보완 완료** — `transition` →`prog` 에서 `task.started` | 지시자 | ~~P3~~ 완료 | 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` 가 지시자+담당자 전원에게 발송. 대규모 팀·활발한 스레드에서 노이즈. | 현재 규모 허용. 추후 사용자별 음소거/멘션 한정 검토 |
| ~~R5~~ | ~~답글이 일반 댓글과 동일 문구~~ | ✅ **해소** — `addReply` payload `isReply:true` → 프론트 "답글을 남겼습니다"로 구분 표시 | 2026-06-19 |
| ~~R6~~ | ~~단일 인스턴스 전용~~ | ✅ **해소(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 는 경미한 견고성 항목으로 동작 영향 없음.
