feat: 실시간 알림(인앱+SSE, Redis pub/sub) + Redis 캐싱 (8단계)

알림(8-2):
- Notification 모듈 + SSE 스트림(@Sse, @SkipTransform 으로 표준 래퍼 우회).
- 12종 알림 적재(업무 지시/해제/승인요청/승인/수정요청/시작/마감변경/삭제/댓글·답글,
  멤버 초대/역할변경/제거). 행위자 제외 + 중복 dedup. AppShell 종(미읽음 뱃지·드롭다운).
- 실시간 fan-out: REDIS_HOST 시 ioredis pub/sub(다중 인스턴스), 미설정 시 인메모리 폴백.

Redis 캐싱(8-3):
- CacheModule → @keyv/redis(미설정 시 인메모리 폴백). 저장소 목록을 버전 네임스페이스로
  캐시, 생성/수정/삭제·동기화·멤버 초대/제거 시 무효화(공유 버전 키라 다중 인스턴스 정합).
- enableShutdownHooks 로 종료 시 Redis 연결 graceful close.

검증: 백엔드 build/lint 0·20 tests, 프론트 build/lint/type-check 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:12:47 +09:00
parent ef858f6e34
commit d87726194b
23 changed files with 1539 additions and 42 deletions
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
// 응답 표준 래핑({ success, data }) 제외 마커.
// SSE(text/event-stream)처럼 스트림을 그대로 흘려보내야 하는 핸들러에 사용한다.
export const SKIP_TRANSFORM = 'skipTransform';
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM, true);
@@ -4,8 +4,10 @@ import {
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_TRANSFORM } from '../decorators/skip-transform.decorator';
export interface StandardResponse<T> {
success: boolean;
@@ -17,10 +19,23 @@ export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T>
> {
// main.ts 에서 new 로 생성하므로 DI 대신 자체 Reflector 인스턴스 사용
private readonly reflector = new Reflector();
intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler<T>,
): Observable<StandardResponse<T>> {
// @SkipTransform() 핸들러(SSE 스트림 등)는 래핑하지 않고 그대로 흘려보낸다
const skip = this.reflector.get<boolean>(
SKIP_TRANSFORM,
context.getHandler(),
);
if (skip) {
// SSE MessageEvent 스트림 등은 래핑 대상이 아니므로 그대로 통과(타입만 인터페이스에 맞춤)
return next.handle() as unknown as Observable<StandardResponse<T>>;
}
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
return next.handle().pipe(
map((data: T) => ({