# 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 오설정 가시성)은 보강 완료. 남은 항목은 모두 **평가 후 의도적으로 현행 유지**(과설계 회피)이며 동작에 영향이 없다.