app.module 라이프사이클(.init) 부트스트랩 스모크 확장 (4개 NestJS 서비스)
요약Sprint 197의 .compile() 부트스트랩 스모크(4개 NestJS 서비스)를 .init()/.close() 라이프사이클 검증으로 확장. moduleRef.init()으로 onModuleInit + onApplicationBootstrap까지 부트스트랩 동등 수준에서 실행해, .compile()이 못 잡는 라이프사이클 단계 throw(saga onModuleInit의 incompleteSubmissions.length, MqPublisher의 amqplib.connect, env 누락)를 조기 차단. 별도 spec 파일(app.module.init.spec.ts)로 분리 — compile spec 실패=DI 그래프 / init spec 실패=라이프사이클 진단. submission은 jest.mock('amqplib')로 MqPublisher.onModuleInit 연결 차단 + mockRepository.find→[](saga onModuleInit TypeError 회피). 실측 발견: close()는 단일 TypeORM 연결(submission/identity/gateway)에선 정상 동작하나, problem의 이중 TypeOrmCoreModule(default + new-problem-db)에선 onApplicationShutdown이 mock DataSource를 strict resolve하다 throw — ScheduleModule cron 타이머는 그 앞 onModuleDestroy에서 이미 정리되므로(detectOpenHandles 0) 해당 quirk만 좁게 catch하고 나머지 teardown 에러는 re-throw. forceExit는 submission만 보유 → 나머지 3개는 close() 필수. 음성 검증(saga onModuleInit throw → compile 통과/init 실패)으로 라이프사이클 실행 입증. Critic(Codex)이 problem quirk를 독립 재현해 대응 정확성 교차 검증 — Critical/High 0건. tsc 0·ESLint 0·커버리지 threshold 통과(submission 381/problem 185/gateway 785/identity 265)·CI #351 FAIL 0. PR #351 squash → 4853cf2.
목표
- Sprint 197의
.compile()부트스트랩 스모크 테스트(DI 그래프 빌드만 검증)를.init()/.close()까지 확장해, 라이프사이클 훅(onModuleInit+onApplicationBootstrap)을 부트스트랩 동등 수준에서 실행한다. .compile()이 못 잡는 라이프사이클 단계 throw(예: sagaonModuleInit의incompleteSubmissions.length, RabbitMQ 연결, 라이프사이클에서만 요구되는 env)를 조기 차단한다.- Sprint 197이 별도 범위로 미룬 "amqplib mock으로 RabbitMQ 연결 단계까지 검증"을 완료한다.
배경
- Sprint 197은
Test.createTestingModule({imports:[AppModule]}).compile()로 4개 서비스(gateway·submission·problem·identity)의 DI 그래프를 검증했다. 그러나.compile()은 graph build +useFactory만 실행하고onModuleInit/onApplicationBootstrap은 호출하지 않는다(그것은.init()전용). 따라서 MqPublisher의amqplib.connect, saga의 미완료 Saga 재개 로직 등 라이프사이클 단계 결함은 여전히 미방어였다. - 대상 라이프사이클 (
.init()이 추가로 실행):- submission: MqPublisherService.onModuleInit(
amqplib.connect), SagaOrchestratorService.onModuleInit(submissionRepo.find()후.length접근, CircuitBreaker 등록,setInterval타임아웃 타이머), ProblemServiceClient.onModuleInit(CB 등록), MetricsService.onModuleInit(collectDefaultMetrics) - problem: DualWriteService/ReconciliationService.onModuleInit(
getDualWriteMode()읽기), MetricsService, ScheduleModule onApplicationBootstrap(@Cron reconcile 타이머) - gateway: MetricsService, ScheduleModule(@Cron 3개: event-log·notification·deadline-reminder)
- identity: MetricsService, ScheduleModule(@Cron 1개: feedback)
- submission: MqPublisherService.onModuleInit(
- 기술 난관:
.init()은 실 인프라 연결을 시도하는 라이프사이클을 실행하므로 RabbitMQ(amqplib)까지 mock해야 하고, 라이프사이클이 등록한 타이머(@Cron, sagasetInterval)를.close()로 정리해야 한다 — 그런데forceExit는 submission만 보유, 나머지 3개는 타이머가 누수되면 테스트가 행(hang)한다.
결정
D1. 별도 spec 파일로 분리 (사용자, AskUserQuestion)
- 각 서비스에
src/app.module.init.spec.ts를 신규 생성. Sprint 197의app.module.spec.ts(.compile())는 그대로 유지. - 근거: ① 진단 분리 — compile spec 실패 = DI 그래프(순환 의존성/누락 provider), init spec 실패(compile 통과) = 라이프사이클(onModuleInit/bootstrap). ② jest는 테스트 파일별로 모듈 레지스트리를 격리하므로
prom-client전역 상태 교차오염을 구조적으로 차단(collectDefaultMetrics가 파일마다 새 모듈 상태에서 1회만 실행). ③ Sprint 197의 빠른.compile()회귀 신호를 독립 보존. - 대안(기존 it에 추가 / 기존 it를 .init()으로 교체)은 같은 파일 내 2회 인스턴스화 시 prom-client 충돌 위험 또는 compile-only 독립 신호 소멸이라 미채택.
D2. 스코프 — 4개 NestJS 서비스 전부
- gateway·submission·problem·identity 모두에 init spec 추가(Sprint 197 일관). gateway/identity의
onModuleInit은collectDefaultMetrics로 거의 trivial하나,.init()은onApplicationBootstrap(ScheduleModule cron 등록, 전체 모듈 런타임 와이어링)까지 실행하므로 부트스트랩 동등 검증 가치가 있다.
D3. amqplib mock — submission MqPublisher.onModuleInit 차단
jest.mock('amqplib')(호이스팅)로connect → mockConnection(createChannel → mockChannel: assertExchange/assertQueue/bindQueue/publish/close,on),connection.on제공. 기존mq-publisher.service.spec.tsmock 패턴 계승.- env
RABBITMQ_URL세팅(MqPublisher.onModuleInit의getOrThrow('RABBITMQ_URL')충족). 이로써 RabbitMQ 연결 단계("RabbitMQ 연결 및 Exchange/Queue 설정 완료" 로그)까지 부트스트랩 동등 실행.
D4. saga onModuleInit — mockRepository.find → []
SagaOrchestratorService.onModuleInit이submissionRepo.find(...)결과의.length를 읽으므로,find는 반드시 배열을 resolve해야 한다(barejest.fn()은undefined반환 →TypeError).find: jest.fn().mockResolvedValue([])로 "미완료 Saga 없음 -- 정상 시작" 경로를 통과시킨다.
D5. close() teardown 전략 (실측 기반)
- 실측 발견:
moduleRef.init()후moduleRef.close()는 단일 TypeORM 연결(submission/identity)·TypeORM 없음(gateway)에선 throw 없이 정상 동작(CircuitBreaker/saga/mq/ScheduleModule이 각자 onModuleDestroy로 정리). → Sprint 197이 우려한 "close() 시 override된 DataSource 재resolve 실패"는 단일 연결에선 미발생. - problem만 예외: 이중
TypeOrmCoreModule(default +new-problem-db) 환경에서onApplicationShutdown이moduleRef.get(getDataSourceToken(this.options))로 defaultDataSource를 strict resolve하다UnknownElementException을 throw(teardown 한정 Nest 내부 quirk, 실 인프라가 아닌 mock이라 무해). - 타이머 안전성: close() 시퀀스는
onModuleDestroy(ScheduleModule cron 타이머 정리) →onApplicationShutdown(여기서 throw) 순. 즉 타이머는 throw 전에 정리됨 →--detectOpenHandles로 잔여 핸들 0 확인. - 대응: problem init spec만
await moduleRef.close().catch(...)로 알려진 quirk('could not find DataSource')만 좁게 무시하고, 그 외 teardown 에러(미래onModuleDestroy회귀 등)는throw e로 그대로 노출. 전역forceExit추가는 다른 테스트에 영향을 주므로 미채택.
D6. forceExit 부재 서비스는 close() 필수
forceExit:true는 submission jest.config만 보유. problem/gateway/identity는.init()이 등록한 @Cron 타이머(CronJob)가 누수되면 테스트가 행하므로 반드시.close()로 정리한다.
구현
구현 커밋 (1커밋, PR #351 squash → 4853cf2)
fd63c09test — AppModule 라이프사이클(.init) 부트스트랩 스모크 4종 추가- 신규
services/{submission,problem,gateway,identity}/src/app.module.init.spec.ts(+389) - 공통: 단일
it,.compile() → moduleRef.init() → moduleRef.close().init()은 HTTP 어댑터 없이 onModuleInit + onApplicationBootstrap만 실행. - submission: 최상단
jest.mock('amqplib')(connect → mockConnection/mockChannel) +getDataSourceToken()·getEntityManagerToken()·REDIS_CLIENT(./cache/cache.constants) override +find→[]+ envRABBITMQ_URL - problem: 기본 +
NEW_DB_CONNECTIONnamed DataSource/EntityManager +REDIS_CLIENT(./cache/cache.module) override +DUAL_WRITE_MODE='off'+ close() 좁은 catch(D5) - gateway: 최상단
jest.mock('ioredis')(직접new Redis()차단) + JWT/INTERNAL_KEY env + TypeORM 없음 - identity: DataSource/EntityManager override +
NODE_ENV='test'(TokenEncryptionService 키 검증 회피)
- 신규
검증
- 타입/빌드:
tsc --noEmit0 (4개 서비스). ESLint 0 (4개 서비스, spec override로no-explicit-anyoff). - 테스트: 4종 단독 통과 + 전체 회귀 통과. open handle/hang 0(
--detectOpenHandles로 problem close() quirk 후에도 잔여 핸들 0 확인). 서비스별 커버리지 threshold 통과(jest --coverageexit 0): submission 381 / problem 185 / gateway 785 / identity 265 pass. - 음성 검증: submission
SagaOrchestratorService.onModuleInit최상단에throw를 일시 삽입 →app.module.spec.ts(.compile())는 통과(onModuleInit 미실행),app.module.init.spec.ts(.init())는 실패(SP199_NEGATIVE_CHECK) → init spec이 라이프사이클을 실제로 실행함을 입증(원복 완료). - Critic:
codex review --base main(Codex, 세션019e4e0d-06d9-7592-b433-677efcae4b06) — Critical/High 0건("lifecycle smoke tests with appropriate infrastructure mocks and teardown handling. I did not identify a discrete regression introduced by the patch"). 주목: Codex가 자체 node 스크립트로 problem의 close() quirk를 독립 재현(submission/identity는 clean close, problem만UnknownElementException)하여 좁은 catch 대응의 정확성을 교차 검증. ✅ 머지 가능. - CI #351: FAIL 0 (Quality/Test/Build/Audit/Coverage Gate/E2E Programmers/Trivy 전부 pass, skipping은 미변경 영역) → Squash merge.
신규 패턴
- AppModule 라이프사이클 스모크 테스트 —
.compile() → moduleRef.init() → moduleRef.close(). 인프라 provider(DataSource·REDIS_CLIENT)는 override, 직접new하는 클라이언트(gateway ioredis)는 모듈 mock, RabbitMQ는jest.mock('amqplib'), 라이프사이클이 repo를 읽으면find→[]. 단일 TypeORM은close()그대로, 이중 연결은 teardown quirk만 좁게 catch.forceExit부재 서비스는 close() 필수. Sprint 197.compile()스모크와 별도 파일로 병존해 진단 분리. - 라이프사이클 실행 음성 검증 —
onModuleInit에 일시throw를 심어.compile()spec은 통과 /.init()spec은 실패함을 확인하면, init spec이 라이프사이클을 실제로 실행함을 입증할 수 있다(mock이 load-bearing임을 보이는 Sprint 197 음성 검증의 라이프사이클판).