diff --git a/docs/api-contract.md b/docs/api-contract.md index 5c877a6..7a0b94c 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -42,14 +42,16 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다. 예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`. -### 페이지네이션 — **8단계에서 통일(진행 중)** +### 페이지네이션 — **8단계에서 통일(완료)** 목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`(기본 size 20, 최대 100), 응답 `{ items: [...], total, page, size }`. 프론트는 **더보기 버튼**으로 점진 로드(시안에 페이지 UI 없음). - **백엔드 적용 완료**: `GET /repos`, `/repos/:id/members`, `/tasks/assigned`, `/tasks/issued`, `/repos/:id/tasks`(세그먼트/검색 + `counts` 동봉). 공통 유틸 `common/pagination`. -- **프론트 적용 완료**: 저장소 목록, 내 업무. **남음**: 멤버, 저장소 업무 목록(RepoDetailPage). -- 응답이 단순 배열 → `{ items, total, page, size }` 로 바뀌었으므로 프론트 composable/store 가 함께 갱신돼야 한다. +- **프론트 적용 완료(전 도메인)**: + - **더보기(오프셋)**: 저장소 목록, 내 업무, **저장소 업무 목록(RepoDetailPage)**. 저장소 업무는 세그먼트(`segment`)·검색(`q`)도 백엔드로 이전하고 세그먼트 배지 수는 응답 `counts` 사용(`TaskListQuery={segment,q,assignee}`, `ApiRepoTaskListResult=Paginated & {counts}`). + - **전체 로드**: **멤버(RepoMembersPage)** 는 `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회해 전체를 적재(더보기 대신). 역할/검색 카운트가 전수 기준이어야 하고, TaskCreatePage 담당자 후보 풀이 같은 호출을 쓰므로 전체 셋을 보장한다(멤버는 팀 규모로 한정적). +- (회귀 수정 `c128b19`: `useMember.list`·`useTask.list` 가 `Paginated` 를 반환하도록 정합 — 위 작업의 선행 단계로 흡수됨.) --- @@ -178,7 +180,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | 메서드 | 경로 | 요청 | 응답 | 권한 | |---|---|---|---|---| -| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | 인증 | +| GET | `.../tasks` | `?segment&q&assignee&page&size` | `{ items: RepoTask[], total, page, size, counts }` | 인증 | | POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | admin | | GET | `.../tasks/:taskId` | — | `TaskDetail` | 인증 | | PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | admin | @@ -186,7 +188,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | 멤버(전이별 역할 검증) | - `:taskId` 는 **저장소 내 순번(seq)** — 정수(`ParseIntPipe`). 전역 PK 는 별도 uuid. -- 목록 정렬: seq 내림차순(최신 우선). `?status`/`?assignee`(userId) 필터. +- 목록 정렬: seq 내림차순(최신 우선). `?segment`(all/prog/todo/done, prog=prog+review)·`?q`(제목 부분일치)·`?assignee`(userId) 필터 + `?page&size` 페이지네이션. 응답에 세그먼트 카운트(`counts{all,prog,todo,done}`, 검색어 반영) 동봉. 상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신): @@ -289,7 +291,32 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done ## 8. 마무리(선택) — **8단계** -소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일. +### 알림 (`/api/notifications`) — **✅ 구현 완료 (인앱 + 실시간 SSE)** + +활동 피드와 별개로 **사용자별 수신함**을 두고, 도메인 행위에서 대상자에게 알림을 적재한다(쓰기 API 없음 — 다른 서비스가 내부에서 `NotificationService.notify` 호출). 실시간은 **SSE**(서버→클라이언트 단방향, 쿠키 인증). + +| 메서드 | 경로 | 요청 | 응답 | 비고 | +|---|---|---|---|---| +| GET | `/notifications` | `?page&size` | `{ items: ApiNotification[], total, page, size, unreadCount }` | 본인 수신함(최신순) | +| POST | `/notifications/:id/read` | — | `{ unreadCount }` | 단건 읽음 | +| POST | `/notifications/read-all` | — | `{ unreadCount: 0 }` | 전체 읽음 | +| SSE | `/notifications/stream` | — (EventSource) | `ApiNotification` 스트림 | 실시간. 표준 래퍼 미적용(`@SkipTransform`), 25초 하트비트(`event: ping`) | + +`ApiNotification`(원시): `{ id, type, payload, read, createdAt }` +- `type`(12종): `task.assigned`(지시→담당자) / `task.unassigned`(담당 해제→제거된 담당자) / `task.approval_requested`(승인요청→지시자) / `task.approved`(승인→담당자) / `task.changes_requested`(수정요청→담당자) / `task.started`(시작·재개→지시자) / `task.due_changed`(마감 변경→담당자, payload `dueDate`) / `task.removed`(삭제→관련자, 이동=저장소) / `task.commented`(댓글·답글→관련자, **답글은 원댓글 작성자 포함** + payload `isReply`) / `member.invited`(초대→피초대자) / `member.role_changed`(역할 변경→당사자) / `member.removed`(제거→당사자). +- `payload`: `repoId`(slug)·`repoName`·`taskSeq`·`taskTitle`·`actorName`·`roleType` 등. 표시 문구/톤/이동경로(`/repos/:repoId/tasks/:taskSeq` 또는 `/repos/:repoId`)는 **프론트가 type+payload 로 조립**(사용자 입력은 escapeHtml). +- **행위자 본인은 수신 대상에서 자동 제외.** 적재는 베스트 에포트(실패해도 본 흐름 안 막음), 도입 이후 발생분만(소급 없음). +- 인증: 모든 엔드포인트 `JwtAuthGuard`(본인 알림 전용). SSE 도 `access_token` 쿠키(EventSource `withCredentials`)로 연결 시점 검증. +- 실시간 전송은 `REDIS_HOST` 설정 시 **Redis pub/sub**(ioredis, 채널 `relay:notifications`)로 fan-out → **다중 인스턴스 수평 확장에서도 실시간 도달**. 미설정 시 인메모리 폴백(단일 인스턴스). +> 매핑: AppShell 상단바 종(미읽음 뱃지 + 드롭다운). 연결은 store 싱글톤(EventSource)이 보유 — 화면 전환에 끊기지 않고 로그아웃 시 해제. + +### 캐싱 (Redis) — **✅ 완료** + +전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외. + +### 그 외(미착수) + +소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.) --- @@ -303,5 +330,6 @@ src/modules/ task/ (4) ✅ — Task / ChecklistItem (Comment / Reply / Attachment 는 5단계) activity/ (6) — Activity 이벤트 (다른 모듈에서 emit) my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능) + notification/ (8) ✅ — Notification 엔티티 + SSE 스트림 (task/repo 모듈에서 notify) ``` > 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음). diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 2c71d9b..ecebf32 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -2,8 +2,8 @@ > 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. > 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조. -> 최종 갱신: 2026-06-18 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 작업 중.** -> 8단계 진행: **페이지네이션** 백엔드 전체 완료 + 프론트 2/4 도메인(저장소 목록·내 업무) 완료, 멤버·저장소 업무 도메인 대기. 그 외 8단계 항목(소셜 로그인·이메일 인증·알림·AI 패널·Redis 캐싱)은 미착수. +> 최종 갱신: 2026-06-19 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 + 실시간 알림 + Redis 캐싱 완료.** +> 8단계 진행: **① 페이지네이션 전 도메인 완료**(더보기/전체 로드). **② 실시간 알림(인앱 + SSE) 완료**. **③ Redis 캐싱 완료** — CacheModule을 Redis(keyv)로 전환(미설정 시 인메모리 폴백), 저장소 목록(`GET /repos`)을 버전 네임스페이스 캐시. 그 외 8단계 항목(소셜 로그인·이메일 인증·AI 패널)은 미착수. --- @@ -201,7 +201,7 @@ ### 8단계 — 마무리(선택) · **진행 중** -#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 2/4 도메인 +#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 전 도메인 ✅ **결정(2026-06-18)**: 전체 목록 통일 + 프론트는 **더보기 버튼** 방식(시안에 페이지 UI 없음). @@ -218,13 +218,55 @@ - ✅ **내 업무**: `useMyTask.assigned/issued(page,size)`, `my-task.store`(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + `loadMoreAssigned`/`loadMoreIssued`), MyTasksPage 뷰별 더보기. - 검증: 프론트 type-check/lint/build 0 err. -**프론트 — 남은 2/4 도메인 ⏳** -- ⏳ **멤버**(RepoMembersPage): 더보기 페이지네이션. 단 **TaskCreatePage 가 담당자 후보로 전체 멤버를 사용**하므로, 거기엔 전체 로드(모든 페이지 순회 `fetchAll`)를 별도 보장해야 함. -- ⏳ **저장소 업무**(RepoDetailPage): 현재 세그먼트/검색이 **클라이언트 필터** → 백엔드(`segment`/`q`)로 이전 + 세그먼트 카운트(`counts`) 연동 + 더보기. `useTask.list`·`task.store.fetchList` 응답 형태 변경 반영. (가장 복잡) -- ⚠️ **전환기 주의**: 백엔드 응답이 이미 `{items,...}` 로 바뀌어, 프론트 연동 전까지 **멤버·저장소 상세 화면은 일시적으로 동작이 어긋날 수 있음**(다음 작업에서 해소). +**응답 계약 회귀 수정 ✅ (2026-06-19, `c128b19`)** +- 백엔드 페이지네이션 도입으로 목록 API 가 `{items,total,...}` 를 반환하는데 프론트 멤버·업무 소비처가 **배열로 기대**해 `list.map` 이 깨지고 멤버 탭·업무 목록이 **비어 보이던 회귀**를 수정. + - 프론트: `useMember.list`·`useTask.list` 가 `Paginated` 반환하도록 맞추고, `member.store`·`task.store.fetchList` 에서 `.items` 로 언래핑(저장소·내업무와 동일 계약). → **첫 페이지(기본 20건)는 정상 표시.** + - 백엔드(task): 업무 목록 쿼리가 to-many 조인 컬럼(`checklist.sort_order`)으로 정렬해 페이지네이션 시 TypeORM 이 크래시하던 문제 제거 → `task.seq` 만 정렬(목록은 체크리스트 [완료/전체] 수만 쓰므로 항목 순서 불필요). +- ⚠️ 이전의 '전환기 화면 깨짐' 위험은 **이 커밋으로 해소**. 단 더보기/세그먼트 이전은 아직이라 **첫 페이지 이후 항목은 보이지 않음**(아래 ⏳). -#### 8-2. 그 외(미착수) -- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널 실연동, Redis 캐싱. +**프론트 — 남은 2/4 도메인 ✅ (2026-06-19)** +- ✅ **멤버**(RepoMembersPage): **더보기 대신 전체 로드 채택**. `member.store.fetchList` 가 페이지네이션 API를 끝까지 순회(`while items`)해 전체 멤버를 적재한다. 이유: ① 역할/검색 세그먼트 카운트(전체/관리자/멤버)가 **전수 기준**이어야 하고(부분 로드 시 합계 불일치), ② **TaskCreatePage 가 담당자 후보 풀로 같은 `fetchList` 를 사용**하므로 전체가 보장돼야 하며, ③ 멤버는 팀 규모로 한정적이라 전체 로드가 안전. → 두 소비처(멤버 페이지·업무 생성 폼) 모두 페이지 수정 없이 전체 셋 확보. (더보기 UI 불필요) +- ✅ **저장소 업무**(RepoDetailPage): 세그먼트/검색을 **백엔드로 이전** — `task.store` 에 `total`/`page`/`counts`/`hasMore`/`loadingMore` + `loadMore` 추가, `useTask.list(repoId, {segment,q}, page, size)` 가 `{items,total,counts}` 반환. 페이지가 `segment`(즉시)·`keyword`(250ms 디바운스) 변경 시 1페이지 리셋 로드, 더보기로 다음 페이지 append. 세그먼트 배지 수는 응답 `counts`(검색어 반영) 사용. 타입 `TaskSegment`/`TaskSegmentCounts`/`ApiRepoTaskListResult` 신설, 기존 `TaskListQuery.status` → `{segment,q}` 로 교체. +- 검증: 프론트 type-check/lint/build 0 err. + +> **알려진 한계**: RepoDetailPage 진행률 스트립의 '지연 N건'은 **현재 로드된 목록 기준**(전역 정확 집계는 백엔드 미지원). 업무 삭제 시 세그먼트 카운트는 목록 재진입(재마운트) 시 재조회로 갱신(`remove` 는 총계만 보정). + +#### 8-2. 실시간 알림 (인앱 + SSE) ✅ (2026-06-19) + +**전송 방식 결정**: **SSE(Server-Sent Events)** 채택. 알림은 서버→클라이언트 단방향 푸시라 SSE 가 정확히 맞고, HttpOnly 쿠키 인증(EventSource `withCredentials`)과 호환되며 NestJS `@Sse()` 네이티브 지원이라 **새 의존성 없음**(WebSocket 은 socket.io 등 추가 필요). 단일 인스턴스 가정 → 인메모리 pub/sub(RxJS Subject). + +**백엔드** `backend/src/modules/notification/` +- `Notification` 엔티티: `recipient`(User, CASCADE)·`type`·`payload`(jsonb)·`read`·`createdAt`. 복합 인덱스 `[recipient, createdAt]`. (활동 피드와 별개 — 활동=저장소 전체 로그, 알림=개인 수신함) +- `NotificationService`: `notify({recipientIds, actorId, type, payload})`(행위자 자동 제외 + 베스트 에포트 적재 후 스트림 푸시), `listFor`(페이지네이션 + `unreadCount`), `markRead`/`markAllRead`, `streamFor(userId)`(RxJS Subject 를 userId 로 필터 + 25초 하트비트). 인메모리 `Subject<{userId,data}>` 하나를 구독 시 필터. +- `NotificationController`: `GET /notifications`(목록+미읽음), `POST /notifications/:id/read`, `POST /notifications/read-all`, **`@Sse('stream')`**(실시간). SSE 는 `@SkipTransform()` 로 표준 래퍼(`{success,data}`)를 건너뛴다(신규 `common/decorators/skip-transform.decorator.ts` + `TransformInterceptor` 가 Reflector 로 마커 확인). +- **적재 지점**(다른 서비스가 `notify` 호출, **8종**): TaskService — 업무 생성→담당자(`task.assigned`), 상태전이 `transition` 단일 지점에서 review→지시자(`task.approval_requested`)·done→담당자(`task.approved`)·changes→담당자(`task.changes_requested`), 댓글/답글→관련자(`task.commented`, **답글은 원댓글 작성자 포함**), 업무 수정 시 새 담당자(`task.assigned`). MemberService — 초대→피초대자(`member.invited`), **역할 변경→당사자(`member.role_changed`)**, **제거→당사자(`member.removed`)**. NotificationModule 을 TaskModule·RepoModule 이 import(순환 없음 — Notification 은 User 만 참조). + - *(2026-06-19 보완: P1[멤버 역할변경/제거, 답글→원댓글 작성자] + P2[담당 해제 `task.unassigned`·마감 변경 `task.due_changed`] + P3[삭제 `task.removed`·시작 `task.started`·답글 `isReply` 구분] → 알림 **12종**, gap 전건 해소. 전수 검토는 [`notification-review.md`](./notification-review.md))* + +**프론트** `frontend/src/` +- `types/notification.ts`, `composables/useNotification.ts`(list/markRead/markAllRead — SSE 는 axios 비대상이라 store 에서 EventSource 직접 처리), `stores/notification.store.ts`(items/unreadCount/total + `connect`(멱등, 최초 1회 목록 로드 + 스트림 오픈)/`disconnect`/`loadMore`/`markRead`(낙관적)/`markAllRead`, type+payload→톤/문구/이동경로 뷰 매핑, **사용자 입력 escapeHtml 후 신뢰 태그만 v-html**). +- **AppShell**: 장식이던 상단바 종 버튼 → 실연동. **미읽음 뱃지**(99+ 캡), **드롭다운**(목록·모두 읽음·더보기·빈 상태), 항목 클릭=읽음 처리 + 대상(`/repos/:id/tasks/:seq` 또는 `/repos/:id`)으로 이동. 스트림은 store 싱글톤이 보유 → 화면 전환에도 끊기지 않고, **연결은 AppShell 마운트 시(로그인 상태) 1회, 해제는 로그아웃 시**. +- 검증: 백엔드 build/lint 0 err·20 tests pass, 프론트 type-check/lint/build 0 err. + +> **알려진 한계**: ① ~~단일 인스턴스 전용~~ → **다중 인스턴스 지원 완료(2026-06-19, R6)**: `REDIS_HOST` 설정 시 Redis pub/sub(ioredis)로 fan-out — `notify` 가 채널 `relay:notifications` 에 발행, 모든 인스턴스가 구독해 실시간 전달. 미설정 시 인메모리 폴백. ② access 토큰(15분) 만료 후 SSE 가 끊겨 재연결되는 시점에 쿠키가 만료돼 있으면, 다음 axios 호출이 토큰을 갱신할 때까지 5초 간격 무알림 재시도(유휴 상태 한정, 사용 재개 시 자동 복구). ③ 알림은 **도입 이후 발생분만**(소급 없음), 이메일/푸시는 범위 밖. + +#### 8-3. Redis 캐싱 ✅ (2026-06-19) + +**스택**: `@nestjs/cache-manager@3` + `cache-manager@6`(Keyv 기반) → Redis 연결은 신규 의존성 **`@keyv/redis`**. docker-compose 가 이미 redis 컨테이너 + `REDIS_HOST/PORT/PASSWORD` 주입. + +**백엔드** +- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(단독 실행/테스트). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초. +- **저장소 목록 캐싱**(`GET /repos`): 신규 `RepoCacheService`(repo 모듈) — **버전 네임스페이스 무효화**. 키 `repos:list:v{ver}:p{page}:s{size}`, 변경 시 `repos:list:ver` 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백. + - **무효화 지점**: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + **MemberService 초대/제거**(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — *2026-06-19 보강*). `RepoCacheService` 공유. + - **보강(2026-06-19, 검토 [`redis-cache-review.md`](./redis-cache-review.md))**: ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ `main.ts` `enableShutdownHooks()` — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 `onModuleDestroy` 가 死코드였음). + - **업무 카운트 staleness**: 업무 상태 변경은 목록 버전을 올리지 않아 `doneCount/totalCount` 는 최대 60초(TTL) 지연 — 대시보드 특성상 허용(정확 무효화 대신 task↔repo 결합 회피). +- **캐싱 제외**: `GET /repos/meta` 는 순수 인메모리 getter(DB 미접근)라 Redis 왕복이 오히려 손해 → 캐싱 안 함. + +**검증**: 백엔드 build/lint 0 err·20 tests pass. (프론트 변경 없음 — API 계약 동일, 응답만 캐시.) + +> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.** + +#### 8-4. 그 외(미착수) +- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동. --- diff --git a/docs/notification-review.md b/docs/notification-review.md new file mode 100644 index 0000000..e4ea9f4 --- /dev/null +++ b/docs/notification-review.md @@ -0,0 +1,125 @@ +# 알림(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` 후 신뢰 태그(``)만 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 는 경미한 견고성 항목으로 동작 영향 없음. diff --git a/docs/redis-cache-review.md b/docs/redis-cache-review.md new file mode 100644 index 0000000..ad9662d --- /dev/null +++ b/docs/redis-cache-review.md @@ -0,0 +1,123 @@ +# Redis 캐싱 시스템 전체 검토 + +> 검토일: 2026-06-19 · 대상: 8-3단계 Redis 캐싱 구현 전체 +> 관련 계약: [`api-contract.md`](./api-contract.md) §0(캐싱) · 진행 기록: [`integration-progress.md`](./integration-progress.md) §8-3 +> 코드 기준: `backend/src/app.module.ts`(CacheModule), `modules/repo/repo-cache.service.ts`, `repo.service.ts`(findAll·무효화), `repo-sync.service.ts` + +알림 검토([`notification-review.md`](./notification-review.md))와 동일 형식으로 **① 무엇을 캐싱했는가**, **② 빠진 부분은 없는가**, **③ 잘못된 부분은 없는가**에 답한다. + +> **갱신(2026-06-19)**: 검토 후 보강 완료 — **C1**(멤버 초대/제거 시 목록 캐시 무효화) 추가, **D5**(캐시 모드 기동 로그로 가시화), **D4**(`enableShutdownHooks` 로 종료 시 Redis 연결 정상 정리 — 기존 死코드였던 `onModuleDestroy` 활성화). 나머지(C2·D1·D2·D3·D6)는 평가 후 현행 유지(과설계 회피) — §6·§7 참조. + +--- + +## 1. 아키텍처 요약 + +이 프로젝트에서 Redis 는 **두 용도**로 쓰인다(같은 인스턴스, 별개 연결·클라이언트). + +| 용도 | 클라이언트 | 무엇 | 키/채널 | +|---|---|---|---| +| **캐시**(8-3) | `@keyv/redis`(node-redis 계열) | 전역 CacheModule 스토어 + 저장소 목록 캐시 | 키 `repos:list:*` | +| 알림 실시간(8-2/R6) | `ioredis` | SSE fan-out pub/sub | 채널 `relay:notifications` | + +- **캐시 전환**: `app.module.ts` `CacheModule.registerAsync` — `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, 없으면 **인메모리 폴백**(LRU). keyv `error` 흡수(장애→DB 폴백). 기본 TTL 60초. +- **저장소 목록 캐싱**: `RepoCacheService` 가 **버전 네임스페이스** 방식으로 `GET /repos`(findAll)를 캐시. 변경 시 버전을 올려 일괄 무효화. + +--- + +## 2. 구현된 캐싱 + +### 캐시 대상 + +| 대상 | 캐시? | 근거 | +|---|---|---| +| `GET /repos`(저장소 목록, `findAll`) | ✅ 캐시(버전 네임스페이스, TTL 60s) | 랜딩·최다 트래픽 + DB 집계(멤버 조인 + 업무 카운트) 비용 | +| `GET /repos/meta`(생성 폼 메타) | ❌ 미캐싱(의도) | DB 미접근 순수 인메모리 getter — Redis 왕복이 오히려 손해 | +| 그 외 모든 읽기 | ❌ 미캐싱 | §5 C2 참조 | + +### 동작 방식 (`RepoCacheService`) + +- **키**: `repos:list:v{ver}:p{page}:s{size}` — 페이지 단위로 별도 캐시. +- **버전 카운터**: `repos:list:ver`(TTL 없음, 영속). `version()` 이 없으면 1로 초기화. +- **무효화**: `invalidateList()` 가 버전을 +1 → 이전 버전 키 전체가 더 이상 참조되지 않음(keyv 와일드카드 삭제 미지원 회피). 남은 구버전 키는 60s TTL 로 자연 소멸. +- **폴백**: 모든 캐시 접근(`get`/`set`)을 try/catch 로 감싸 실패 시 DB 조회로 폴백. + +### 무효화 호출 지점 (현재 4곳) + +| 위치 | 트리거 | +|---|---| +| `repo.service.ts` | 저장소 **생성 / 수정 / 삭제** | +| `repo-sync.service.ts` | Gitea **동기화**(import/누락표시/admin 변경분 발생 시) | +| `member.service.ts` | 멤버 **초대 / 제거**(목록 카드의 멤버 수·아바타 반영) — *2026-06-19 보강* | + +> 멤버 **역할 변경**은 목록 응답(멤버 수/아바타)을 바꾸지 않으므로 의도적으로 무효화하지 않는다. + +--- + +## 3. 검토 결과 한눈에 + +| 구분 | 건수 | 비고 | +|---|---|---| +| ✅ 정상 동작 확인 | 7 | 특히 **다중 인스턴스 무효화 안전**(공유 버전 키) | +| ⚠️ 빠진 부분(gap) | 2 → **1** | C1 **보강 완료** / C2(캐싱 커버리지)는 선택 보류 | +| 🐛 주의/개선 | 6 | 기능 버그 없음. D4·D5 보강, 나머지 현행 유지 | + +**종합 판정**: 저장소 목록 캐싱은 **정상 동작**하며, 버전 네임스페이스 + 공유 Redis 덕에 **무효화가 다중 인스턴스에서도 안전**(알림과 달리 별도 pub/sub 불필요). 치명적 버그 없음. 이번 검토의 핵심 빠짐이던 **C1(멤버 변경 시 목록 캐시 무효화 누락)은 보강 완료** — 멤버 초대/제거 시 무효화 추가. 남은 것은 선택적 커버리지 확대(C2)와 동작에 영향 없는 견고성 항목뿐. + +--- + +## 4. ✅ 정상 동작 확인 항목 + +1. **버전 네임스페이스 무효화** — 변경 시 버전 +1 로 모든 페이지 캐시를 O(1) 폐기. 키 와일드카드 삭제 없이 정확. +2. **다중 인스턴스 무효화 안전** — 버전 카운터가 **공유 Redis** 에 있어, 인스턴스 A 의 변경이 인스턴스 B 의 다음 읽기에서 즉시 캐시 미스를 유발. (알림 R6 와 달리 캐시는 추가 작업 없이 이미 수평 확장 정합) +3. **그레이스풀 폴백** — `REDIS_HOST` 미설정 시 인메모리, Redis 장애 시 try/catch 로 DB 조회. 캐시가 가용성을 떨어뜨리지 않음. +4. **keyv 오류 흡수** — `app.module` 의 `keyv.on('error')` 로 연결 오류가 프로세스를 죽이지 않음. +5. **meta 미캐싱 결정** — DB 미접근 getter 라 캐싱 제외. 올바른 판단. +6. **plain-DTO 캐싱** — `RepoResponse[]`(날짜는 이미 ISO 문자열)만 저장 → JSON 직렬화 무손실, 엔티티 hydration 함정 없음. +7. **TTL 백스톱** — 업무 카운트처럼 무효화하지 않는 파생값도 60초 내 자연 보정. + +--- + +## 5. ⚠️ 빠진 부분 (Gaps) + +| # | 빠짐 | 현재 상태 | 영향 | 우선순위 | +|---|---|---|---|---| +| ~~C1~~ | ~~멤버 변경 시 목록 캐시 무효화 누락~~ | ✅ **보강 완료(2026-06-19)** — `MemberService` 에 `RepoCacheService` 주입, **초대/제거** 시 `invalidateList()` 호출. (역할 변경은 목록 응답 불변이라 제외) | 멤버 초대/제거 후 목록 카드의 멤버 수·아바타 **즉시 반영** | ~~P2~~ 완료 | +| C2 | **캐싱 커버리지** | `findOne`(저장소 상세)·멤버 목록·업무 목록(`/repos/:id/tasks`)·내 업무·사용자 검색은 미캐싱 | 성능 최적화 여지(특히 업무 목록·사용자 검색은 빈도 높음). 단 무효화 복잡도(세그먼트/검색/담당자 조합)가 커 신중 필요 | P3(선택, 보류) | + +### C2 참고(보류) +업무 목록(`/repos/:id/tasks`)은 `segment`·`q`·`page` 조합 키가 많고 업무 상태 변경마다 무효화가 필요해, 캐싱하려면 저장소별 버전 네임스페이스 + 상태전이 시 무효화 설계가 추가로 든다. **현재 규모에선 과설계라 보류** — 트래픽/병목이 실제로 나타날 때 도입. + +### C2 참고 +업무 목록(`/repos/:id/tasks`)은 `segment`·`q`·`page` 조합 키가 많고 업무 상태 변경마다 무효화가 필요해, 캐싱하려면 저장소별 버전 네임스페이스 + 상태전이 시 무효화 설계가 추가로 든다. 현재 규모에선 보류가 합리적. + +--- + +## 6. 🐛 주의 / 개선 (견고성·일관성·구조) + +> 기능 오류(버그)는 없다. 아래는 견고성·일관성·구조 관점. + +| # | 항목 | 영향 | 권고 | +|---|---|---|---| +| D1 | **버전 카운터 비원자 증가**(`get`+`set`) | 동시 무효화 시 증가분이 "유실"될 수 있음(둘 다 같은 값으로 set). **정합성은 깨지지 않음**(버전은 어쨌든 전진 → 구버전 키 폐기, DB 커밋이 버전 set 보다 선행). 단 암묵적 순서 의존. | 엄밀히 하려면 Redis 원자 `INCR`(ioredis 이미 도입됨) 사용. 현재는 TTL 백스톱으로 충분 | +| D2 | **캐시 스탬피드(single-flight 없음)** | 무효화/TTL 만료 직후 동시 요청이 모두 DB 재계산(한 요청이 채우기 전). 목록 쿼리라 영향 경미 | 트래픽 급증 시 잠금/단일 비행 도입 검토 | +| D3 | **Redis 클라이언트 2종 공존** | 캐시=`@keyv/redis`(node-redis), 알림=`ioredis`. 같은 Redis 에 두 클라이언트 라이브러리 | 동작 무해. 정리하려면 한쪽으로 통일(번들/유지보수 단순화) | +| D4 | **종료 시 연결 graceful close** | ✅ **보강** — `main.ts` `enableShutdownHooks()` 추가로 종료 시 lifecycle 훅 실행. 이전엔 훅 미설정이라 알림의 `onModuleDestroy`(ioredis 정리)가 **호출조차 안 되던 死코드**였음 → 이제 정상 종료 시 Redis pub/sub 연결 graceful close. (캐시 keyv 연결은 핸들 노출이 없어 프로세스 종료에 위임 — 잔여, 영향 없음) | 알림 연결 정리 활성화 완료 | +| D5 | **인메모리 폴백 = 다중 인스턴스 시 캐시 불일치** | ✅ **가시화 보강** — 기동 시 캐시 모드 로그(`캐시: Redis 모드` / `캐시: 인메모리 모드(단일 인스턴스)`) 추가로 오설정 조기 발견 가능. **설계상 단일 인스턴스 모드**는 정상 | 로그로 가시화 | +| D6 | **쓰기-무효화 사이 sub-ms 스테일 윈도** | DB 커밋 후 `invalidateList` 까지의 찰나에 들어온 읽기가 캐시 히트로 직전 데이터 반환 가능 | 캐시-어사이드 표준 한계. 사실상 무시 가능(현행 유지) | + +--- + +## 7. 권고 요약 (우선순위) + +- ✅ **보강 완료(2026-06-19)** + - **C1** 멤버 초대/제거 시 목록 캐시 무효화(`MemberService` → `RepoCacheService`) + - **D4** `enableShutdownHooks` 로 종료 시 Redis 연결 graceful close(死코드였던 `onModuleDestroy` 활성화) + - **D5** 캐시 모드 기동 로그로 오설정 가시화 +- **보류(선택) — 트래픽/확장 시 재검토** + - C2 캐싱 커버리지 확대(업무 목록/사용자 검색 등) — 무효화 설계 비용이 커 현재는 과설계 + - D3 Redis 클라이언트 단일화(keyv+ioredis) — 통합이 캐시를 더 복잡하게 만들어 현행 유지 +- **현행 유지(영향 없음, 평가 후 변경 불필요로 판단)** + - D1 버전 비원자 증가 — **정합성 증명상 안전**(버전은 전진 + DB 커밋이 set 보다 선행). 원자 INCR 은 클라이언트 혼용을 유발해 오히려 손해 → 변경 안 함 + - D2 캐시 스탬피드 · D6 sub-ms 윈도 — 캐시-어사이드 표준 한계 + +> **요약**: 실질 빠짐(C1)과 잠재 위험(D4 死코드·D5 오설정 가시성)은 보강 완료. 남은 항목은 모두 **평가 후 의도적으로 현행 유지**(과설계 회피)이며 동작에 영향이 없다.