submission CacheModule 순환 의존성 핫픽스 + StudyMemberGuard Redis 통합

요약Sprint 194에서 추가된 submission CacheModule의 NestJS DI 순환 의존성(cache.module.ts ↔ stats-cache.service.ts 양방향 import)으로 신규 이미지가 부트스트랩 26ms 만에 결정론적으로 throw → CrashLoopBackOff → 롤아웃 차단. REDIS_CLIENT 토큰을 cache.constants.ts로 분리해 사이클을 구조적으로 차단하고, 실제 모듈 그래프를 컴파일하는 cache.module.spec.ts 회귀 테스트를 신설(단위 테스트가 못 잡던 갭 차단). 부수로 StudyMemberGuard의 독립 ioredis 인스턴스를 글로벌 REDIS_CLIENT DI 주입으로 통합(연결 풀 2→1). build 0·ESLint 0 errors·jest 379 pass·Critic(Codex gpt-5.5) 0건·CI #343 36 pass / 0 fail. 머지 후 클러스터 재배포는 서버 측.

날짜
영향도높음

목표

  • Sprint 194 신규 코드가 유발한 submission-service 부트스트랩 실패(NestJS DI 순환 의존성)를 해소해 롤아웃 블로커를 제거한다.
  • 동일 클래스 버그(모듈 그래프 컴파일 실패)가 CI에서 잡히도록 회귀 방어 테스트를 추가한다.
  • (부수) StudyMemberGuard의 독립 ioredis 인스턴스를 글로벌 REDIS_CLIENT로 통합해 연결 풀을 2→1로 줄인다.

배경

  • Sprint 194에서 Submission 서비스에 글로벌 CacheModule(REDIS_CLIENT) + StatsCacheService를 신설했다(대시보드 통계 Cache-Aside).
  • 머지 후 신규 이미지가 CrashLoopBackOff에 빠졌다. 서버 에이전트 진단 + 코드 교차검증으로 원인을 확정: 부트스트랩 26ms 만에 A circular dependency has been detected inside CacheModule throw → 프로세스 종료. 결정론적이라 재시도해도 100% 같은 지점에서 실패.
  • 사용자 영향 없음: 구버전 ReplicaSet(2 Pod)이 정상 서빙 중. 롤아웃만 막혀 신규 기능(통계 캐시)이 배포되지 않은 상태였다.
  • OOM/Redis 장애 아님: Last State Terminated Exit Code 1(137 OOMKilled 아님), memory limit 1Gi 여유, init db-migrate Exit 0, Redis Pod 73일 무중단. 기존 인계서의 "lazy connect 비차단" 분석은 맞으나, throw는 그 이전 DI 그래프 빌드 단계에서 발생.

결정

D1. 스코프 — 핫픽스 + 가드 Redis 통합 (사용자)

  • AskUserQuestion으로 확정. ① 순환 의존성 핫픽스 ② StudyMemberGuard의 독립 ioredis를 CacheModuleREDIS_CLIENT로 통합까지 한 PR에. 원래 Sprint 195 예정이던 problem.tags JSON 전환은 Sprint 196으로 이월.

D2. 롤아웃 처리 — 머지 후 서버 재배포 (사용자)

  • 구버전이 정상 서빙 중이라 긴급 롤백 불필요. 코드 핫픽스 + PR + CI green까지는 본 워크플로우에서, 머지 후 클러스터 재배포·CrashLoopBackOff 알림 해제는 서버 측(사용자).

D3. 순환 차단 방식 — 토큰 별도 파일 분리

  • 근본 원인: cache.module.tsREDIS_CLIENT 토큰을 정의(:13)하면서 동시에 StatsCacheServiceimport(:11)하고, stats-cache.service.ts는 그 토큰을 cache.module에서 역참조(:10) → 모듈 평가 시점에 StatsCacheServiceundefined로 들어가 Nest가 throw.
  • REDIS_CLIENTcache/cache.constants.ts로 분리하고 cache.module.ts·stats-cache.service.ts가 모두 거기서 import → 두 파일 간 양방향 참조 소멸. 결과 그래프: module→service, module→constants, service→constants (사이클 0).

D4. 회귀 방어 — 실제 모듈 그래프 컴파일 테스트

  • 기존 stats-cache.service.spec.ts는 실제 CacheModule을 import하지 않고 provider를 수동 조립 → 사이클을 일으키는 DI 그래프를 한 번도 컴파일하지 않아 throw를 못 잡았다.
  • cache.module.spec.ts 신설: Test.createTestingModule({ imports: [LoggerModule, CacheModule] }).overrideProvider(REDIS_CLIENT).useValue(mockRedis).compile()실제 모듈 그래프를 컴파일하고 StatsCacheService/REDIS_CLIENT resolve를 단언. 수정 전이면 throw(실패), 후엔 통과 → 동일 클래스 회귀를 CI가 차단. (LoggerModule@Global이라 StructuredLoggerService 제공, REDIS_CLIENT override로 실 Redis 연결 회피, afterEach close()로 open handle 방지.)

D5. 가드 Redis 통합 — @Inject(REDIS_CLIENT)

  • StudyMemberGuard 생성자의 new Redis(redisUrl) 직접 생성을 제거하고 @Inject(REDIS_CLIENT) redis: Redis로 주입. 중복 redis.on('error') 핸들러는 cache.module 팩토리가 중앙화하므로 제거. ConfigServiceGATEWAY_INTERNAL_URL/INTERNAL_KEY_GATEWAY 용도로 유지. 가드는 submission/review/study-note 3개 컨트롤러에서 @UseGuards로 사용 — CacheModule@Global이라 REDIS_CLIENT가 전역 resolve되어 provider 등록 변경 불필요.

구현

구현 커밋 (2커밋, PR #343 squash → 2e1502e)

  • d050b9a fix(submission) — cache module 순환 의존성 해소 (토큰 분리) + 부트스트랩 회귀 테스트
    • 신규: cache/cache.constants.ts(REDIS_CLIENT 토큰) · cache/cache.module.spec.ts(DI 그래프 컴파일 회귀 테스트 3건)
    • 수정: cache.module.ts(로컬 토큰 정의 제거 → constants import) · stats-cache.service.ts·stats-cache.service.spec.ts(import 출처 → constants)
  • 60f2e1f refactor(submission) — StudyMemberGuard를 CacheModule REDIS_CLIENT로 통합 (ioredis 인스턴스 2→1)
    • 수정: common/guards/study-member.guard.ts(new Redis() 제거 → @Inject(REDIS_CLIENT) 주입, 중복 핸들러 제거) · study-member.guard.spec.ts(jest.mock('ioredis') 제거 → DI mock 주입)

검증

  • 타입/빌드: tsc --noEmit 에러 0. npm run lint(eslint {src,test}/**/*.ts) error 0, warning 9건(전부 기존 no-unused-vars, 이번 변경 무관). mockRedis as any는 spec 파일 한정 — .eslintrc.js overrides가 *.spec.tsno-explicit-any: 'off'(Sprint 194의 소스 파일 as any와 다름).
  • 테스트: jest 379 passed / 0 failed(24 suites). 커버리지 threshold 전부 통과(statements 98%+·branches 94%+·functions 96%+). 신규 cache.module.spec.ts 3건이 실제 DI 그래프 컴파일·StatsCacheService/REDIS_CLIENT resolve를 검증.
  • Critic: codex review --base main(gpt-5.5) — Critical/High/Medium/Low 0건. "The StudyMemberGuard's new shared Redis injection is backed by the global CacheModule in the application module, and the added tests cover the intended DI regression."
  • CI #343: 36 pass / 14 skip / 0 fail — Quality·Coverage Gate·Build Submission·Test Submission·E2E·Trivy 전부 pass.
  • 운영: 구버전 서빙 중이라 다운타임 없음. 머지 후 재배포로 신규 ReplicaSet 정상 기동 + CrashLoopBackOff 해소(서버 측).

신규 패턴

  • DI 토큰은 의존성 없는 constants 파일로 분리 — 모듈(provider 정의)과 토큰 소비처(서비스)가 같은 토큰을 참조하되 서로 import하지 않도록. NestJS 순환 의존성 표준 예방책.
  • 신규 모듈은 "DI 그래프 컴파일" 회귀 테스트 필수Test.createTestingModule({ imports: [RealModule] }).compile()로 부트스트랩 동등 검증. provider 수동 조립 spec만으론 그래프 빌드 throw를 못 잡는다. 외부 연결(Redis 등)은 overrideProvider().useValue(mock)로 회피.